Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Experimental] PPX + dynamic forms support #60

Merged
merged 46 commits into from
Mar 21, 2020
Merged

[Experimental] PPX + dynamic forms support #60

merged 46 commits into from
Mar 21, 2020

Conversation

alex35mil
Copy link
Member

@alex35mil alex35mil commented Jan 30, 2020

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:

  • input/output are the same
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>;
};
  • input/output are different
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
  • custom message & submissionError types
// 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.

  • collections
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);
  • dependent fields
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;
];
  • async form
// 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
Copy link
Contributor

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
Copy link

baransu commented Feb 2, 2020

  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: Dynamic forms #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.

@alex35mil
Copy link
Member Author

@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
Copy link
Member Author

[@field.deps] is in.

@alex35mil
Copy link
Member Author

alex35mil commented Feb 16, 2020

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

@johnhaley81
Copy link
Contributor

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

image

@alex35mil
Copy link
Member Author

@johnhaley81 Will take a look soon.

@alex35mil
Copy link
Member Author

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

@johnhaley81
Copy link
Contributor

@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
Copy link
Member Author

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
Copy link
Member Author

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

@alex35mil
Copy link
Member Author

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

@alex35mil alex35mil merged commit eabee3a into master Mar 21, 2020
@alex35mil alex35mil deleted the ppx branch March 21, 2020 14:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants