gcanti / tcomb-form

Forms library for react
https://gcanti.github.io/tcomb-form
MIT License
1.16k stars 136 forks source link

List with Dynamic Items (Different structs based on selected value) #297

Closed sockhead closed 8 years ago

sockhead commented 8 years ago

Is it possible to create a list of dynamic items? I'm not sure how to pass value to Account in order for it appropriately return the correct structure. Essentially I have a predefined group of account types but I allow the user to select Other if the account type they want is not available. If they select other they should then be given a textbox where they can input the name of the account type, but this should only happen if they select Other.

For example a list of accounts where the account has a structure of:

getType(value) {
  let props = {
    accounts: t.list(Account)
  }
  return t.struct(props);
}
const Account = t.struct({
  type: t.enums.of(accountTypes),
  Fields: t.maybe(t.list(Field))
});

However I want to be able to make the Account have a dynamic structure depending on the value of type. I want a new field to appear if and only if the item's type == 'Other' like below.

const Account = t.struct({
  type: t.enums.of(accountTypes),
  label: t.Str,
  Fields: t.maybe(t.list(Field))
});

I also tried creating a list where Account was a function, but I have no clue if it's possible to do what I'm hoping for and have no clue how to return the struct associated with each item.

How would I go about passing the correct item's value to each item in the list in order to dynamically generate the appropriate type for the required structure? Is it possible to do some sort of foreach on the list values and then generate the appropriate structures and concatenate them together in a list? Is this even a possibility with how List works?

gcanti commented 8 years ago

Hi @sockhead,

Lists of different types are not supported at the moment and technically they won't be supported, ever. This is because a tcomb's list, by definition, contains only values of the same type. What we can do though is adding support for unions, this way the list would correctly contain only one type but that type would be a union:

const AccountType = t.enums.of([
  'type 1',
  'type 2',
  'other'
], 'AccountType')

const KnownAccount = t.struct({
  type: AccountType
}, 'KnownAccount')

// UnknownAccount extends KnownAccount so it owns also the type field
const UnknownAccount = KnownAccount.extend({
  label: t.String,
}, 'UnknownAccount')

// the union
const Account = t.union([KnownAccount, UnknownAccount], 'Account')

// the final form type
const Type = t.list(Account)

Generally tcomb's unions require a dispatch implementation in order to select the suitable type constructor for a given value and this would be the key in your use case:

// if account type is 'other' return the UnknownAccount type
Account.dispatch = value => value && value.type === 'other' ? UnknownAccount : KnownAccount

A complete example:

import React from 'react'
import t from 'tcomb-form'

const AccountType = t.enums.of([
  'type 1',
  'type 2',
  'other'
], 'AccountType')

const KnownAccount = t.struct({
  type: AccountType
}, 'KnownAccount')

const UnknownAccount = KnownAccount.extend({
  label: t.String,
}, 'UnknownAccount')

const Account = t.union([KnownAccount, UnknownAccount], 'Account')

Account.dispatch = value => value && value.type === 'other' ? UnknownAccount : KnownAccount

const Type = t.list(Account)

const App = React.createClass({

  onSubmit(evt) {
    evt.preventDefault()
    const v = this.refs.form.getValue()
    if (v) {
      console.log(v)
    }
  },

  render() {
    return (
      <form onSubmit={this.onSubmit}>
        <t.form.Form
          ref="form"
          type={Type}
        />
        <div className="form-group">
          <button type="submit" className="btn btn-primary">Save</button>
        </div>
      </form>
    )
  }

})

There's a draft implementation on this branch https://github.com/gcanti/tcomb-form/tree/297 Seems to work very well but I must do a few additional tests

sockhead commented 8 years ago

Thanks for the quick response. I'm currently using an older version of tcomb-form (0.5) but plan on updating to the latest version in the near future when I have time to go through and update everywhere that I use the forms.

Since I'm using an older version, the changes that you made to enable union support is quite substantial between 0.5 and 0.8.

I tried a quick crack at updating components.js but it broke my struct templates so I just reverted back to 0.5. I look forward to trying this out when I update to 0.8.

Thanks again!

gcanti commented 8 years ago

v0.5 is quite old, you are still using tcomb and tcomb-validation v1 I guess.

However, after scanning the changelog, upgrading to 0.8 shouldn't be too painful (tcomb-form API is fairly stable):

Breaking changes per version:

If you decide to upgrade let me know how it goes and if you need some help.

Cheers, Giulio

amrut-bawane commented 8 years ago

I needed exactly same functionality. Wanted to know if unions are supported in v0.8.1. Great Work!

gcanti commented 8 years ago

@amrut-bawane Currently there's a candidate implementation in the https://github.com/gcanti/tcomb-form/tree/297 branch if you want to give it a whirl. If everything's ok (needs tests) I'll write some documentation and then release v0.8.2

amrut-bawane commented 8 years ago

Yeah i tried that version. Lib folder is missing hence module is not being imported. Not perfectly sure about this issue, maybe you can look into it. Thanks

gcanti commented 8 years ago

@amrut-bawane after cloning the repo

npm install
npm run build

should build the lib folder

gcanti commented 8 years ago

@amrut-bawane Nevermind, just released version https://github.com/gcanti/tcomb-form/releases/tag/v0.8.2

amrut-bawane commented 8 years ago

Ohh that's cool! A small issue that I am facing is, while trying to add item to a list field. How to get handler to the addItem, removeItem functions associated with the list from, let's say clicking a button outside of the form? Appreciate your quick fixes

gcanti commented 8 years ago

@amrut-bawane

How to get handler to the addItem, removeItem functions associated with the list from, let's say clicking a button outside of the form?

tcomb-form's forms are controlled components. You already have complete control on the form by tweaking its value:

const Type = t.list(Account)

const App = React.createClass({

  getInitialState() {
    return {
      value: []
    }
  },

  onChange(value) {
    this.setState({value})
  },

  addItem() {
    // adds a new item
    this.setState({ value: this.state.value.concat(undefined) })
  },

  render() {
    return (
      <div>
        <t.form.Form
          ref="form"
          type={Type}
          value={this.state.value}
          onChange={this.onChange}
        />
        <div className="form-group">
          <button type="button" className="btn btn-primary" onClick={this.addItem}>Add item</button>
        </div>
      </div>
    )
  }

})
amrut-bawane commented 8 years ago

I wish to create a list that can accept fields of two types - FilterField and MetaField.

generateSchema() {
    var FilterField = t.enums.of([
        'Pattern',
        'Color',
        'Brand'
    ], 'FilterField');

    var MetaField = t.struct({
        title: t.String,
        identifier: t.String,
        canCreateVariant: t.Boolean,
        required: t.Boolean,
        isSearchFilter: t.Boolean,
        type: t.String,
        unit: t.String,
        minVal: t.String,
        maxVal: t.String,
        toolTip: t.String,
        placeHolder: t.String
      }, 'MetaField');

    var Field = t.union([FilterField, MetaField], 'Field');
    const that = this;
    Field.dispatch = value => {
      if(that.state.filterField) return FilterField;
      else return MetaField;
    }

    var Fields = t.list(Field);

    var Schema = t.struct({
      title: t.String,
      description: t.String,
      longName: t.String,
      canAddProducts: t.Boolean,
      formfields: Fields
    });
     return Schema;
  }

Upon receiving a click event, I am calling the addItem method of the list -

addFilter(e) {
    this.setState({filterField:true}, () => {
      this.refs.form.getComponent('formfields').addItem(e);
    });
  }
addMeta(e) {
    this.setState({filterField:false}, () => {
      this.refs.form.getComponent('formfields').addItem(e);  
    });
  }

The issue is each time the list gets updated, only the latest entry i.e. either FilterField or MetaField gets added to the list.

gcanti commented 8 years ago

@amrut-bawane relying on getComponent('xxx').addItem is not safe: it's an internal API and is not documented. The idiomatic way is to leverage the tcomb-form's controlled component behaviour:

const FilterField = t.enums.of([
  'Pattern',
  'Color',
  'Brand'
], 'FilterField')

const MetaField = t.struct({
  title: t.String,
  identifier: t.String,
  canCreateconstiant: t.Boolean,
  required: t.Boolean,
  isSearchFilter: t.Boolean,
  type: t.String,
  unit: t.String,
  minVal: t.String,
  maxVal: t.String,
  toolTip: t.String,
  placeHolder: t.String
}, 'MetaField')

const Field = t.union([FilterField, MetaField], 'Field')

Field.dispatch = value => {
  if (t.Object.is(value)) {
    return MetaField
  }
  return FilterField
}

const Fields = t.list(Field)

const Schema = t.struct({
  title: t.String,
  description: t.String,
  longName: t.String,
  canAddProducts: t.Boolean,
  formfields: Fields
})

const App = React.createClass({

  getInitialState() {
    return {
      value: {
        formfields: []
      }
    }
  },

  onChange(value) {
    this.setState({value})
  },

  addFilter() {
    this.setState({
      // here I'm using the tcomb immutability helpers, use what you want but be sure to change the `value` reference
      // otherwise tcomb-form won't detect any change
      value: t.update(this.state.value, {
        formfields: {
          $push: [undefined]
        }
      })
    })
  },

  addMeta() {
    this.setState({
      value: t.update(this.state.value, {
        formfields: {
          $push: [{}]
        }
      })
    })
  },

  onSubmit(evt) {
    evt.preventDefault()
    const v = this.refs.form.getValue()
    if (v) {
      console.log(v) // eslint-disable-line
    }
  },

  render() {
    return (
      <form onSubmit={this.onSubmit}>
        <t.form.Form
          ref="form"
          type={Schema}
          value={this.state.value}
          onChange={this.onChange}
        />
        <div className="form-group">
          <button type="button" className="btn btn-primary" onClick={this.addFilter}>Add filter</button>
          <button type="button" className="btn btn-primary" onClick={this.addMeta}>Add meta</button>
          <button type="submit" className="btn btn-primary">Save</button>
        </div>
      </form>
    )
  }

})
amrut-bawane commented 8 years ago

Cool, that lets me add fields of both types. But as the dispatch method of the union field is called, all the list items update to the same type- all turn to FilterFields or MetaFields. I guess that's how a union works, but any workaround to get me the required functionality?

 Field.dispatch = value => {
      if(that.state.filterField) return FilterField;
      else return MetaField;
    }
gcanti commented 8 years ago

But as the dispatch method of the union field is called, all the list items update to the same type- all turn to FilterFields or MetaFields

Weird, that is not the result I see when I run the example above. Please open a new issue with the complete code you are running in order to reproduce the problem.