prometheusresearch-archive / react-forms

Forms library for React.
1.16k stars 112 forks source link

Add concept of a Layout node (or similar), like a Mapping but adding no semantics #66

Closed AndrewIngram closed 8 years ago

AndrewIngram commented 9 years ago

I have some forms where it doesn't make sense for me to create deep nestings of Mappings, when the actual data for the form is relatively flat. At the moment I'm using Mappings primarily to group fields together for presentational, rather than functional reasons, which means I'm doing extra work to transform data from the mostly flat format my application uses, to the format that react-forms uses.

It would be useful to be able to have field groupings that don't actual alter the data schema. I don't know what the API could be, since the new key-value approach to schema definition means that it's not really possible to have a node that doesn't have a name (at the moment I'm giving them dumb names like 'group1', 'group2' etc).

Alternatively, it might be worth investigating some patterns for defining the layout of a form independently of the data definition.

I've attached a screenshot of the form I'm trying to define.

screen shot 2014-12-02 at 15 58 59

andreypopp commented 9 years ago

The UI should be dictated by form components. So if you have flat data structure you just define flat schema structure but use a custom form component which will group related fields into fieldsets. Something like:

var CustomFieldset = React.createClass({

  render() {
    var {value, ...props} = this.props
    return (
      <div {...props}>
        <div>
          <h1>How can we help?</h1>
          <ReactForms.Element value={value.get('reason')} />
        </div>
        <div>
          <ReactForms.Element value={value.get('arrival')} />
          <ReactForms.Element value={value.get('departure')} />
          <ReactForms.Element value={value.get('isFlexible')} />
        </div>
        ...
      </div>
    )
  }
})

But I see how that hurts reusability of form components. Probably the solution would be to define a method .project(fieldNames) for Value class which would return a projection which have the similar interface to Value but only for projected field names.

The custom form component would like then:

var CustomFieldset = React.createClass({

  render() {
    var {value, ...props} = this.props
    return (
      <div {...props}>
        <ReasonFieldset value={value.project(['reason'])} />
        <DatesFieldset value={value.project(['arrival', 'departure', 'isFlexible'])} />
        ...
      </div>
    )
  }
})
AndrewIngram commented 9 years ago

Another option might be to have a layout property for the Form component

<Form
  ref="form"
  method="post"
  action="."
  layout={layout}
  schema={schema} />

Where layout is some data structure in a similar fashion to those in django-crispy-forms: http://django-crispy-forms.readthedocs.org/en/d-0/layouts.html

andreypopp commented 9 years ago

Yes, that way form components can decide what to render based on layout prop of schema node.

AndrewIngram commented 9 years ago

This may also get around the issue I have where my schema is full of component overrides where I want different rendering to the library's default, which is pretty much every node.

andreypopp commented 9 years ago

@AndrewIngram I think this can be solved just by defining wrappers around schema constructors and using them instead of ones which come with React Forms.

AndrewIngram commented 9 years ago

That's true to an extent, but I feel it makes more sense for wrappers for be used for setting the input component, rather than the wrapping field/fieldset component. If I reuse the same mapping in multiple places it seems to me that it's reasonable I'll want to reuse the same input, but not necessarily true that I'll want to use the same wrapping markup. Even something as simple as where to put the help text for a field (in the label, or below the field?) is something that feels strictly like a render-time presentational concern, rather than part of the data definition.

andreypopp commented 9 years ago

Yes, I thought about injecting default components through <FormConfiguration /> and <Form /> component:

function componentFor(node) {
  if (node instanceof ScalarNode) {
    return <CustomField />
  }
  ...
}

With <Form /> component:

<Form  componentFor={componentFor} ... />

or in arbitrary place inside form components:

<FormConfiguration componentFor={componentFor}>
   ...
</FormConfiguration>
AndrewIngram commented 9 years ago

that seems like a workable approach

andreypopp commented 9 years ago

Yes, but I'd rather wait the next release of React (which will have context propagated through the component tree and not the owner tree).

natew commented 9 years ago

Damn, didn't know that was the case but I'm incredibly happy with that context change. Has been giving me headaches in my first foray into using it.

AndrewIngram commented 9 years ago

So for my example above, i'd envisage something like this for a layout object:

FormLayout(
    Group(
        Row(
            Column('topic', {size: 'full'})
        )   
    ),
    Group(
        Row(
            Column('arrival_date', {size: 'half'}),
            Column('departure_date', {size: 'half'})
        ),      
        Row(
            Column('flexible_dates', {size: 'full'})
        ),          
    ),
    Group(
        Row(
            Column('location', {size: 'half'}),
            Column('budget', {size: 'half'})
        ),
    ),
    Group(
        Row(
            Column('adults', {size: 'quarter'}),
            Column('children', {size: 'quarter'}),
            Column('toddlers', {size: 'quarter'}),
            Column('babies', {size: 'quarter'})
        )       
    ),
    Group(
        Row(
            Column('message', {size: 'full'}),
        )       
    ),
    Group(
        Row(
            Column('contact_preference', {size: 'full', input: <TabbedSelected />})
        ),
        Row(
            Column('phone_number', {size: 'full', input: <PhoneNumberInput />})
        ),
        Row(
            Column('timezone', {size: 'full'})
        ),      
        Row(
            Column('has_preferred_call_time', {size: 'full'}
        ),
        Row(
            Column('preferred_call_time', {size: 'full'}
        )
    )
)

There could be a number of shortcuts, for example if a row only has one field with no options provided, the syntax could simply be Row('field_name'), with the column being implicitly created.

andreypopp commented 9 years ago

How does it differ from a regular React component? You can just define a component which will render form value:

var MyFieldset = React.createClass({

  render() {
    return (
      <FormLayout>
          <Group>
              <Row>
                  <ReactForms.Element value={value.get('topic')} size="full" />
              </Row>
              ...
           </Group>
           ...
      <FormLayout>
    )
  }
})
AndrewIngram commented 9 years ago

It could very well take that structure, I'm still trying to grok the architecture of react-form's components, and their relationships to nodes, so I didn't want to get too specific with whether the layout is just components, or something that describes how components would get created.

AndrewIngram commented 9 years ago

I'll try this out today, and let you know where I get to.

andreypopp commented 9 years ago

The overall React Forms architecture looks like:

So schema isn't purely about data, it also can be used to alter appearance. It is mainly to reduce the boilerplate of form components so they can be written generically, parametrised by schema props.

I think, in your case the overhead of creating a generic form component which can render form layout into DOM is the same as creating a new form component which will render exactly same layout. So you can implement just a form component.

natew commented 9 years ago

@andreypopp Is there a way to wrap or replace the form components but still use the schema stuff? Basically, if I have a UI library that gives me a whole set of components for fieldsets, etc, and the components need to pass each other properties (IE the fieldset component uses cloneWithProps to manage it's own inputs, etc), would I still be able to use react-form's validation/schema syntax with those components?

andreypopp commented 9 years ago

@natew yes, though now you have to pass component prop to schema nodes everywhere or create your own wrappers for creating schema nodes:

function Scalar(props) {
  return ReactForms.schema.Scalar({...props, component: MyField});
}
...

When React 0.13 is release I plan to introduce <FormConfiguration /> component which will be able to inject form components via context.

AndrewIngram commented 9 years ago

So I've had pretty good success with using the pattern you described, here's my full example

'use strict';

var React = require('react');
var PureRenderMixin = require('react').addons.PureRenderMixin;

var ReactForms = require('react-forms');
var Mapping = ReactForms.schema.Mapping;
var List  = ReactForms.schema.List;
var Scalar = ReactForms.schema.Scalar;
var Form = ReactForms.Form;

var Checkbox = require('react-forms/lib/Checkbox');

var SelectMultiple = require('../components/select-multiple');
var SelectOne = require('../components/select-one');

var Element = ReactForms.Element;
var Column = require('../layout/column');
var Group = require('../layout/group');
var Layout = require('../layout/layout');
var Row = require('../layout/row');

var EventFormLayout = React.createClass({
  mixins: [PureRenderMixin],

  render: function() {
    var value = this.props.value;

    return (
      <Layout>
        <Row>
          <Column><Element value={value.get('title')} /></Column>
        </Row>
        <Row>
          <Column><Element value={value.get('venue')} /></Column>
        </Row>
        <Row>
          <Column size="half"><Element value={value.get('start_datetime')} /></Column>
          <Column size="half"><Element value={value.get('end_datetime')} /></Column>
        </Row>
        <Row>
          <Column><Element value={value.get('styles')} /></Column>
        </Row>
        <Row>
          <Column><Element value={value.get('description')} /></Column>
        </Row>
        <Row>
          <Column><h2>Which of the following will your event feature?</h2></Column>
        </Row>
        <Row>
          <Column size="quarter"><Element value={value.get('has_classes')} /></Column>
          <Column size="quarter"><Element value={value.get('has_social')} /></Column>
          <Column size="quarter"><Element value={value.get('has_performances')} /></Column>
          <Column size="quarter"><Element value={value.get('has_live_music')} /></Column>
        </Row>
      </Layout>
    );
  }
})

var EventSchema = function(state) {
  var venueOptions = state.venues.map(function(obj) {
    return {
      name: obj.get('name'),
      value: obj.get('id')
    };
  });
  var styleOptions = state.styles.map(function(obj) {
    return {
      name: obj.get('name'),
      value: obj.get('id')
    };
  });

  return Mapping({component: EventFormLayout},{
    title: Scalar({label: 'Event Title'}),
    venue: Scalar({
      label: 'Venue',
      type: 'number',
      input: <SelectOne options={venueOptions} />
    }),
    styles: Scalar({
      label: 'Styles',
      type: 'array',
      defaultValue: [],
      input: <SelectMultiple options={styleOptions} />
    }),
    start_datetime: Scalar({
        label: 'Start',
        type: 'date'
    }),
    end_datetime: Scalar({
        label: 'End',
        type: 'date'
    }),
    description: Scalar({
        label: 'Description',
        input: <textarea />,
        type: 'string'
    }),
    has_classes: Scalar({
        label: 'Classes',
        defaultValue: true,
        type: 'bool',
        input: <Checkbox />
    }),
    has_social: Scalar({
        label: 'Social dancing',
        defaultValue: true,
        type: 'bool',
        input: <Checkbox />
    }),
    has_performances: Scalar({
        label: 'Performances',
        type: 'bool',
        input: <Checkbox />
    }),
    has_live_music: Scalar({
        label: 'Live Music',
        type: 'bool',
        input: <Checkbox />
    })
  });
};

module.exports = EventSchema;

A couple of things still feel clunky, the lack of per data-type default inputs is one. I can get around this by wrapping Scalar, but it feels dirty. And first class support for options/choices would be good, and even typed arrays. Eg, like array[number] would automatically sanitise values to numbers (or error). These can go in a separate issue though

tomhelmer commented 9 years ago

@AndrewIngram I'm trying to do something similar with http://kumailht.com/gridforms/ How did it work out and can you provide an full example ?