shakacode / re-formality

Form validation tool for reason-react
https://re-formality.now.sh
MIT License
244 stars 35 forks source link

[Experimental] PPX + dynamic forms support #60

Closed alex35mil closed 4 years ago

alex35mil commented 4 years ago

This PR implements PPX that introduces input/output concept. It handles the most basic use-case (see example), for now. But might be extended to support all features of current non-ppx implementation + dynamic forms.

Config shape:

module Form = [%form
  type input;
  type output;
];

input holds data that are shown in UI. Usually, these are strings or bools. output is what you get in submit handler if form is fully valid.

Both input and output types must be records with the same set of fields (definitions must be inlined). In the simplest possible case, when input is the same as output, the latter can be alias of the former:

module EmailForm = [%form
  type input = {email: string};
  type output = input;
];

Validators are defined as an argument of useForm hook which gets generated by ppx. A type signature of validators is changed: instead of list of validators it's a record, with same fields as input/output types. Validator function must return value of field type in output record (instead of Valid | NoValue variant) wrapped in result. Basically, it's guaranteed on type level that you'd be able to submit a form only when all validators returned Ok(outputValue).

Use cases:

module EmailForm = [%form
  type input = {email: string};
  type output = input;
];

let validators: EmailForm.validators = {
  email:
    Some({
      strategy: OnFirstSuccessOrFirstBlur,
      validate: ({email}) => {
        switch (email) {
        | "" => Error("Email is required")
        | _ as value when !Email.regex->Js.Re.test_(value) =>
          Error("Email is invalid")
        | _ => Ok(email)
        };
      },
    }),
};

[@react.component]
let make = () => {
  let form =
    LoginForm.useForm(
      ~validators,
      ~initialInput={email: ""},
      ~onSubmit=(output, form) => { ... },
    );

  <Form className="form" onSubmit={form.submit}>
    <label htmlFor="email-field">
      "Email"->React.string
    </label>
    <input
      id="email-field"
      type_="text"
      value={form.input.email}
      disabled={form.submitting}
      onBlur={_ => form.blurEmail()}
      onChange={event =>
        form.updateEmail({
          ...form.input,
          email: event->ReactEvent.Form.target##value,
        })
      }
    />
    {switch (form.emailResult()) {
     | Some(Error(message)) =>
       <div className="failure">
         message->React.string
       </div>
     | Some(Ok(_)) =>
       <div className="success">
         {j|βœ“|j}->React.string
       </div>
     | None => React.null
     }}
    <button disabled={form.submitting}>
      (form.submitting ? "Submitting..." : "Submit")->React.string
    </button>
  </Form>;
};
module EmailForm = [%form
  type input = {email: string};
  type output = {email: Email.t};
];

let validators: EmailForm.validators = {
  // Since `input.email` & `output.email` of different types
  // there must be a validator that would produce value of `output` type
  // so validator is required, hence it's not of `option` type
  email: {
    strategy: OnFirstSuccessOrFirstBlur,
    validate: ({email}) => {
      // Email.parse: string => result(Email.t, Email.error)
      switch (email->Email.parse) {
      | Error(NoValue) => Error("Email is required")
      | Error(InvalidValue) => Error("Email is invalid")
      | Ok(email) => Ok(email)
      };
    },
  },
};

// Component code is the same as in previous example
// Defaults:
// type message = string;
// type submissionError = unit;
module EmailForm = [%form
  type input = {email: string};
  type output = {email: Email.t};
  type message = I18n.t;
  type submissionError = BadEmail | UserExists;
];

Collections, dependent fields & async forms are not implemented yet so examples below are just drafts that might be changed.

module BlogPostForm = [%form
  type input = {
    title: string,
    authors: [@field.collection] array(author)
  }
  and author = {name: string};
  type output = input;
];

let validators: BlogPostForm.validators = {
  title: Some({ ... }),
  authors: {
    collection: 
      Some(({authors}) => {
        switch (authors->Array.length) {
        | 0 => Error("At least one author is required")
        | _ => Ok()
        };
      }),
    fields: {
      name:
        Some({
          strategy: OnFirstSuccessOrFirstBlur,
          validator: ({authors}, ~at as index) => {
            switch (authors->Array.getUnsafe(index)) {
            | {name: ""} => Error("Author name is required")
            | {name} => Ok(name)
            };
          },
        }),
    }
  },
};

let form = BlogPostForm.useForm(...);

form.addAuthor({name: ""});
form.removeAuthor(~at=index);
form.updateAuthorName(~at=index, {name: ""});
form.authorNameResult(~at=index);
module MyForm = [%form
  type input = {
    password: [@field.deps passwordConfirmation] string,
    passwordConfirmation: string,
    title: string,
    fieldWithMultipleDeps: [@field.deps (field, author.name)] string,
    authors: [@field.collection] array(author),
  }
  and author = {name: string};
  type output = input;
];
// Either using separate extension point
module MyAsyncForm = [%form.async
  type input = {email: string};
  type output = input;
];

// Or field level attribute
module MyAsyncForm = [%form
  type input = {email: [@field.async] string};
  type output = input;
];

The main downside of this approach I see is that more code gets generated. It can be optimized in some ways but still. Also, source code became more complex.

Why field type is gone? Initially, I prototyped ppx output using field type + GADTs but in the end I bumped into type issue when I can't preserve type information while passing action(i, o) type to React.useReducer. I could manage this part to compile only by hiding GADT types with existential wrapper but it wouldn't work since this information is required b/c validators return result('output, 'message) type which is field specific (output bit).

The code below results in the following error:

This definition has type
  state * ('a action -> unit)
which is less general than
  'a0. state * ('a0 action -> unit)
type field(_) =
  | Title: field(string)
  | Published: field(bool);

type state = {
  title: string,
  published: bool,
};

type action('a) =
  | Change(field('a));

type interface = {
  updateTitle: unit => unit,
  updatePublished: unit => unit,
};

let initialState = {title: "", published: false};

let useForm = () => {
  let reducer: type a. (state, action(a)) => state =
    (state, action) =>
      switch (action) {
      | Change(field) => initialState
      };
  let x: type a. (state, action(a) => unit) =
    reducer->React.useReducer(initialState);
  let (state, dispatch) = x;

  {
    updateTitle: () => dispatch(Change(Title->Field)),
    updatePublic: () => dispatch(Change(Public->Field)),
  };
};

If it's possible to solve this case, it would probably help to reduce amount of generated code. On the other hand, I'm not sure that code size difference would make a huge difference and dumb set of functions is easier to manage (e.g. debugging/error messages).


Anyway, appreciate feedback on this experiment.

/cc @baransu @mlms13

johnhaley81 commented 4 years ago

This looks great IMO! We're big on domain driven design and reformality is one of the things we use to be a gatekeeper for our data.

The way we use our forms is the same as the way you have written it out above. At the input level everything is a string, bool and we try to parse the data onto the types that we're expecting.

One of the things that might be improved is the update<fieldName> function. If that didn't need to take in the previous input state then you would reduce the complexity of the update since you could clobber over a previously updated value if you are sending in a stale input state.

I love what you're doing and if there's anything you can section out and need/want some help on LMK.

baransu commented 4 years ago
  1. I really like new validations syntax, one record instead of a list is in most cases better.
  2. As @johnhaley81 pointed - it would be great to treat update<fieldName> function as setState where it receives state => state function instead of state.
  3. What collections does @form.collection is ppx intended to support? Is it only an array or other types like list or Belt.Map.String could be possible?
  4. Types have to be inlined in [%form]. This is harder if you're type is more complex and you're wrapping it into a module to hide implementation details.
  5. I'm using the forked version on reformality (mostly described here: https://github.com/MinimaHQ/re-formality/issues/43#issuecomment-482797082). My use-case is hardcore - it's used to handle complex graph with few levels of nesting. For most of the cases, proposed ppx is a great solution but it would be great to opt-out of it and create more complex cases by hand.
alex35mil commented 4 years ago

@johnhaley81 @baransu Thanks for feedback!

One of the things that might be improved is the update function. If that didn't need to take in the previous input state then you would reduce the complexity of the update since you could clobber over a previously updated value if you are sending in a stale input state. As @johnhaley81 pointed - it would be great to treat update function as setState where it receives state => state function instead of state.

It makes perfect sense! Will change this πŸ‘

What collections does @form.collection is ppx intended to support? Is it only an array or other types like list or Belt.Map.String could be possible?

The rules here are the same as for non-collection fields except it must be array of records. I need type information for each field in collection the same way as for the root fields to keep track of the state of each field in collection. I use type name inside an array for looking up the record type definition inside a list of items in the config module.

Types have to be inlined in [%form]. This is harder if you're type is more complex and you're wrapping it into a module to hide implementation details.

Yeah, this is unfortunate requirement that I can't overcome yet since I need names of the fields and type information. With ppx, I can get this only when these things are inlined.

I'm using the forked version on reformality (mostly described here: #43 (comment)). My use-case is hardcore - it's used to handle complex graph with few levels of nesting. For most of the cases, proposed ppx is a great solution but it would be great to opt-out of it and create more complex cases by hand.

It's totally possible since ppx generates c/p'able code. In this scenario, it acts more like scaffolder of the base implementation that can be updated by hand.

if there's anything you can section out and need/want some help on LMK

That's a bit hard in the current state since I'm doing changes all over the place while reimplementing existing functionality. I'll definitely ping you once there will be pieces that can be implemented in parallel!

alex35mil commented 4 years ago

[@field.deps] is in.

alex35mil commented 4 years ago

AFAICT all features of non-ppx version are implemented.

Changes since the first post:

  1. validators definition is moved to config. It allowed a number of optimizations + it's possible to unwrap validators, e.g. if validator exists just define it. If it doesn't, set it to None.
let validators = {
  email: {
    strategy: OnSubmit,
    validate: ...,
  },
  remember: None,
};
  1. Async mode can be turned on on per field basis (instead of affecting the whole form).
[%form
  type input = {
    email: [@field.async] string,
    password: string,
    asyncFieldTriggeredOnBlur: [@field.async {mode: OnBlur}] string,
  };
];
  1. Signature of async validator gets changed: it accepts output type of the value that is returned from sync validator, this way it's guaranteed on a type level that async validator wouldn't be called unless sync validator returned Ok(x). I want to get feedback on this, e.g. would anyone need input record or single value should be enough (I assume it should but).
validateAsync: 'outputValue => Js.Promise.t(result('outputValue, 'message))
  1. Result of async field is:
type asyncResult('outputValue, 'message) =
  | Validating('outputValue)
  | Result(result('outputValue, 'message));

switch (form.emailResult()) {
| Some(Validating(_)) => <Spinner />
| Some(Result(Ok(_))) => ...
| Some(Result(Error(error))) => error->React.string
| None => React.null
}

See SignupForm example

alex35mil commented 4 years ago

With [@field.collection] implemented, I think things are pretty much ready for the first beta.

I also added skeletons of 2 types of test suits:

  1. ppx tests: for now these tests check error messages produced by ppx
  2. integration tests

Not much of tests are actually written though.

I imagine the plan would be:

  1. Clean up few warnings in generated code.
  2. Update examples.
  3. Update docs.
  4. Configure CI.
  5. Release first beta.
  6. Add critical tests.
  7. If all ok, stable release.

-- @johnhaley81 @baransu I updated signature of updateField() functions so those take input => input argument but spotted very unfortunate issue. This code would produce runtime error:

<input
  value={form.input.name}
  onBlur={_ => form.blurName()}
  onChange={event => {
    form.updateName(_input => {name: event->ReactEvent.Form.target##value});
  }}
/>

It happens due to synthetic events pooling. Since callback gets triggered asynchronously, by the time it gets called, event is already null'ed. To fix this, it should be written this way:

<input
  value={form.input.name}
  onBlur={_ => form.blurName()}
  onChange={event => {
    let value = event->ReactEvent.Form.target##value;
    form.updateName(_input => {name: value});
  }}
/>

Which seems very subtile. What are your thoughts on this?

alex35mil commented 4 years ago

@johnhaley81 Pinging you on RC.

I'm almost finished with the docs (rest few will be finished tomorrow): https://github.com/MinimaHQ/re-formality/tree/ppx/docs And published RC: re-formality@next

johnhaley81 commented 4 years ago

Thanks @alexfedoseev I'm going to pull it down now and test it out!

johnhaley81 commented 4 years ago

@alexfedoseev would it be possible at all to have different fields in input and output?

For example say we want to make a date/time picker with fields:

type input = {
  minutes: int,
  hours: int,
  day: int,
  month: int,
  year: int
};

We'd really want an output of a Js.Date.t and not all of these fields.

type output = Js.Date.t;

Would that be a possible change?

EDIT:

I see now that the validators are mapping straight across from the fields in input to the fields in output so it doesn't look like the above is possible without a change in how the ppx works.

alex35mil commented 4 years ago

@johnhaley81 Yeah, for now it's not possible to combine multiple fields into single type. But I thought about [@field.entity] (or something like this) which suppose to handle this kind of thing. Though I didn't look into details of it yet.

johnhaley81 commented 4 years ago

I keep thinking of things as I'm going through it to only get to the solution later in the docs πŸ˜‚πŸ˜‚

I'll save my comments then till the end and then I'll do a write up.

alex35mil commented 4 years ago

Really quick prototype of [@field.entity]:

type input = {date: [@field.entity] date'}
and date' = {
  minutes: int,
  hours: int,
  day: int,
  month: int,
  year: int,
};

type output = {date: Js.Date.t};

type validators = {
  date: {
    entity: date' => result(Js.Date.t, message),
    fields: {
      minutes: {
        strategy: Strategy.t,
        validate: input => result(int, message),
      },
      ...
    }
  }
};

// Interface
form.updateDateMinutes(1)
form.dateMinutesResult
form.dateResult
johnhaley81 commented 4 years ago

@alexfedoseev I'm getting a type error while trying to define submissionError.

image

alex35mil commented 4 years ago

@johnhaley81 Will take a look soon.

alex35mil commented 4 years ago

@johnhaley81 Published 4.0.0-beta.2 with the fix.

johnhaley81 commented 4 years ago

@alexfedoseev the submissionError fix works great!

A lot of the forms are cleaner now and much easier to understand what's going on. This is a great change!

One of the things that I was thinking about was with with the setters (i.e. update<fieldName>)

With the runtime exception issue, maybe it's better to have the function that passes in the state to be a different one? So the default setter could be update<fieldName> which just takes in the value and blindly plops it into the state and a more advanced one update<fieldName>WithState would give you copy of the latest state if you needed it.

I also liked that entity change. Would it need that [@field.entity] tag or would the ppx be able to derive it since the field is a record?

alex35mil commented 4 years ago

I also liked that entity change. Would it need that [@field.entity] tag or would the ppx be able to derive it since the field is a record?

It would require attribute b/c in AST it would be just Ptyp_constr and it would require parsing of the whole structure for each field to find out if it's record or not. So it's easier and faster to be explicit here.

I will create separate issue to discuss details since I have one more use-case for record field in mind and I'm not sure yet if it would be useful and if it would how to split 2 these attributes.

With the runtime exception issue, maybe it's better to have the function that passes in the state to be a different one? So the default setter could be update which just takes in the value and blindly plops it into the state and a more advanced one updateWithState would give you copy of the latest state if you needed it.

Also tend to move it to separate issue. In general, the less api the better and both of ways have own caveats, so instead of one issue it would probably give us two and more things to keep in mind for users.

alex35mil commented 4 years ago

I extracted all leftovers to own issues. Looks like ready to merge!

alex35mil commented 4 years ago

Merging. I think it makes sense to keep it on @next tag for sometime and add some integration tests. Then publish as latest.