Git Product home page Git Product logo

form-state's Introduction

npm CircleCI

form-state

form-state is a headless form state management library, built on top of mobx.

It acts as a buffer between the canonical data (i.e. the server-side data, or your app's GraphQL/Redux/etc. global store) and the user's WIP data that is being actively mutated in form fields (which is "too chatty"/WIP to push back into global stores).

It also keeps track of low-level form UX details like:

  • Which form fields are dirty
  • Which form fields are valid/invalid
  • Which form fields are touched (i.e. don't show validation errors for untouched fields)
  • Enabling/disabling buttons/form UX based on the overall form-wide state
  • Submitting the form should touch (validate) all fields
  • Auto-saving the form when appropriate (i.e. not on keystroke, but after blur/leaving the field)
  • Queueing auto-saves if one is already in-flight
    • Auto-saves in a table with per-row forms will serialize to avoid cross-child write conflicts on the backend
  • Not over-writing the user's WIP/actively-focused field when auto-saved data refreshes
  • Building a wire payload that has only changed fields
    • form.changedValue will return the entity id + only changed fields to faciliate doing partial update APIs
    • Supports collections of children, i.e. a author: { books: [...} } will include only changed books if necessary
    • Child collections can be either exhaustive (if any child changes, submit them all) or incremental (only include changed children), to match the backend endpoint's semantics

Main Features

There are two main reasons why form-state exists:

  1. Provide a FieldState interface for component libraries
  2. Match the "three shapes" mental model of our forms

FieldState Interface & "One-Line" Forms

The core abstraction that form-state provides is a FieldState interface that looks like:

// Very simplified example
interface FieldState {
  // Can be used to read & write the value bound into the form field
  value: V;
  errors: string[];
  valid: boolean;
  touched: boolean;
}

Which combines all the logical aspects of "a single form field" into a single object/prop.

This facilitates the "gold standard" of form DX, which is "one line per form field", i.e:

function AuthorEditorComponent() {
  const author = useFormState(() => /* ... */ );
  return (
    <FormLines>
      <BoundTextField field={author.firstName} />
      <BoundTextField field={author.lastName} />
      <BoundSelectField field={author.city} options={...cities...} />
    </FormLines>
  )
}

Besides great developer ergonomics (low boilerplate, very DRY code), this approach also provides a very consistent UI/UX for users, because all forms get the highly-polish behavior of BoundTextField for free.

(See the BoundTextField in Beam for an actual implementation of this approach.)

The Three Shapes Mental Model

In general when working with any forms (i.e. not just form-state), there are three types/shapes of data involved:

  1. The input data/shape from the server (i.e. a GraphQL/REST query)
  2. The form data/shape that is being reactively bound to form fields (i.e. used as <TextField value={form.firstName} onChange={(v) => form.firstName = v} />)
  3. The mutation data/shape that will submit the change to the server (i.e. the GraphQL mutation/REST POST)

form-state generally refers to each of these shapes as:

  • The input type
    • (Hrm, in retrospect "input" is an unfortunate term b/c that is what GraphQL uses for its mutation types, i.e. input SaveAuthorInput...we should consider changing this).
  • The form type
  • The ...third type...

And then provides an API/DSL for managing the mapping between each of these in a standard/conventional manner.

Admittedly (and hopefully, b/c it makes the code simpler), the differences between each of these types can often be small, i.e.:

  • The input type might have { author: { book: { id: "b:1" } } but the mutation wants { author: { bookId: "b:1" } }
  • ...have other examples...

These are usually simple/mechanistic changes, but nonetheless just some boilerplate that form-state provides conventions for.

Basic Usage

See the sample.

Todo

  • Add conditional readonly logic, like { type: "field", readOnlyIf: i => i.isInternal.value }

  • Add omitFromValue so we can have two fields, book.author.id / book.author.name, where book.author.name is used for showing the author name, but book.author.id is the only field that is submitted to the server on mutation (maybe pair this with Ref based mutations)

  • Undo/redo would in theory be neat and easy to do on top of the existing infra

Features

Fragments

Normally, form-state expects all fields in the form to be inputs to the GraphQL mutation/wire call. For example, the author.firstName field will always be submitted to the saveAuthor mutation (albeit with author.changedValue you can have firstName conditionally included).

However, sometimes there is "other data" that your UX needs to render the form, which is not strictly a form field, but would be handy for the data to "just be on the form" anyway, as you're passing it in around code.

A stereotypical example of this is GraphQL fragments, where an AuthorFragment might have a lot of misc read-only info that you want to display next to/within your form, but is not technically editable.

In form-state, you can model with as a Fragment, which is set up as:

// Your input type, likely generated from GraphQL mutation
type AuthorInput = { firstName?: string };

// Your wire data likely from your page's GraphQL query to get
// the author to edit + also "misc other data"
type AuthorFragment = { firstName: string; miscOtherData: {} };

// For your page's form state, add-in the "extra data"
type AuthorForm = AuthorInput & {
  // The `Fragment` type tells form-state this is not a regular form field
  data: Fragment<AuthorFragment>;
};

// Tell the form config the "fragment" is not a real field
const config: ObjectConfig<AuthorForm> = {
  firstName: { type: "value", rules: [require] },
  data: { type: "fragment" },
};

// Now in the component...
const data = useGraphQLQuery();
const form = useFormState({
  config,
  init: {
    input: data,
    map: (d) => ({
      firstName: data.author.firstName,
      data: fragment(data),
    }),
  },
});

Internal Implementation Notes

form-state keeps the "actual data" (basically a POJO of your form data) separate from the "mobx proxies that track reactivity" (the ObjectState interface with .get / .set / .errors other methods).

This works well b/c the "actual data" returned from ObjectState.value or FieldState.value is always a non-proxy POJO that can be dropped on the wire without causing serialization issues.

However, it does mean that form-state internally uses a few "that looks odd" tricks like _tick.value++ to ensure code like formState.value.firstName will be reactive, even though the .firstName is not actually a proxy access (but doing formState.firstName.value would be).

(To be clear, both formState.firstName.value and formState.value.firstName return the same value, and also have the same reactivity semantics, this is just noting that form-state's internals need to do a few extra tricks to get the latter to be reactive.)

form-state's People

Contributors

bdow avatar blambillotte avatar blimmer avatar jonncharpentier avatar koltong avatar semantic-release-bot avatar stephenh avatar tylerr909 avatar

Watchers

 avatar  avatar

form-state's Issues

Implement undo/redo

Should be pretty easy to do form-level undo/redo on top of the form state.

There are a few mobx undo/redo libraries out there, not sure if the best approach is using an existing library, or copy/pasting the basic approach and adopting it for form-state's specific needs.

Support FE/BE type alignment

Given a GraphQL query like:

query {
  projectItem(id: 1) {
    bidItem { id name }
  }
}

And an input query like:

mutation {
  saveProjectItem(input: SaveProjectItemInput)
}

input SaveProjectItemInput {
  bidItem: EntityId
}

input EntityId { id: String! }

We'd like to bind that to a form like:

const formConfig: ObjectConfig<SaveProjectItem> = {
  bidItem: { type: "value", localKeys: "name" }
}

const jsx = <LazyBoundBidItemSelect field={form.bidItem}

And be able to pass the SelectField.options: initial + lambda

Questions:

  • Is formConfig.bidItem a type: "value" or type: "object" with nested keys?
  • Is the type of LazyBoundBidItemSelect a FieldState<{ id: string; name: string}> or an ObjectState<{ ... }>? Does it matter?

Add Zod/Yup style API

 const SignupSchema = Yup.object().shape({
   firstName: Yup.string()
     .min(2, 'Too Short!')
     .max(50, 'Too Long!')
     .required('Required'),
   lastName: Yup.string()
     .min(2, 'Too Short!')
     .max(50, 'Too Long!')
     .required('Required'),
   email: Yup.string().email('Invalid email').required('Required'),
 });

Current theory is that we probably can't use exactly the Zod/Yup API / libraries one-for-one, b/c we'll have misc form-state-isms/settings like "this is the delete key", etc. that aren't included in Zod/Yup.

Support rules against options

If we have a form config like:

const config = {
  authorId: { required: true, rules: (...) => }
};

The Rule interface today only accepts the serialized value, i.e. "a:1", but when authorId is bound to, say, a drop down / select field, it'd be great to have the Rule accept the AuthorFragment directly.

Currently the rule has to accept the value string and re-find the selected AuthorFragment from a shared list of authors: AuthorFragment[].

Elegant approach for "Save as Active"

Copy/pasted from https://github.com/homebound-team/internal-frontend/pull/3424#discussion_r1107253955

I couldn't think of a great way to handle this with like "first-class" support in Beam...

I believe the concept's we'd want would be:

  1. Validation rules that are conditional on rpc.status (which form-state does support today, and where I'd nudged Dean and Craig at first)
  2. "Dual valid-ness", to drive "only enable Save As Active if the conditional-on-rpc.status rules all pass", essentially separate form.validIfDraft / form.validIfActive flags
  3. The "conditional on rpc.status=active" error messages should only ever show up if the user comes in to an already-active RPC and starts deleting fields.

The 2nd one, which is basically the useComputed they're writing by hand, is the tough one--b/c we don't want to actually set rpc.status=active, b/c that would show a bunch of errors in the UI like (First Name is required).

But we want to ask if we happened to set rpc.status=active, then would all of the rules be true?

...I dunno, like maybe something like:

const canBeActive = useComputed(() => {
  const originalStatus = rpc.status
  // quickly switch to active
  rpc.status.value = active
  // run all the form rules
  const canBeActive = rpc.valid
  // okay, put it back
  rpc.status.value = originalStatus
  // and return our "probed" validness
  return canBeActive
});

That actually doesn't seem that bad, and maybe we could write a hook like:

const canBeActive = useMaybeValid(formState, formState.status, active);

That would internalize the "try flipping to active, record valid, put it back" dance.

But I'm not actually sure if the "flip to active, flip it back" would work in mobx...like, I'm 80% sure it "would technically work", but would be nuances around making sure the changes happened synchronously and ideally were not observed by anyone else.

...actually, I saw the phrase "transaction" in the mobx docs last night, so maybe that would be an approach...start a transaction, change the status, record the canBeActive = rpc.valid, and then rollback the transaction. Never used the mobx transactions before so not sure/don't know the API.

Move AutoSaveStatusProvider to beam

Originally we thought that form-state's autoSave methods would need to trigger auto-save statuses.

However, we've since added the apolloHooks.ts instrumentation in internal-frontend that hooks all mutations up to the auto-save status. Which actually works better b/c some of the schedule page's mutations weren't going through form-state.

Given this new slice point, we should be able to move AutoSaveStatusProvider to beam and not have form-state itself need to know/trigger it anymore.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.