gcanti / tcomb-form

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

Question: what is the best way to implement an inline field #211

Closed pbreah closed 8 years ago

pbreah commented 8 years ago

I have an UI task I'm looking to implement that requires inline form fields that when you click a value label it switches from a value label into a form field with its value on click.

I'm looking to implement something like this: https://vitalets.github.io/x-editable/demo-bs3.html?c=inline

What would be the cleanest approach you would suggest? I have a few ideas, but wanted to see if there is a best way to do using your form API.

Thanks for your help.

benmonro commented 8 years ago

you can set a template in the options of a field and then return any jsx you want for when that field is rendered.

https://github.com/gcanti/tcomb-form/blob/master/GUIDE.md#templates

In addition you can also override the default template used by all fields of a certain type. For example, to override all textboxes just add something like this code:


t.form.Form.templates.textbox = (locals) => {

    if (locals.type === 'textarea') {
        return (locals, <TextArea fieldClassName="text-box multi-line" {...locals.attrs} />);
    } else if (locals.type === 'hidden') {
        return (<Input {...locals.attrs} initialValue={locals.value}
                                         type="hidden"/>);
    } else {

        return (locals,
            <Input
                errorClassName="field-validation-error"
                fieldErrorClassName="input-validation-error"
                initialValue={locals.value}
                onChange={onChange.bind(locals)}
                ref={locals.attrs.name}
                type={locals.type}
                {...locals.attrs}
                />
        );
    }
}
pbreah commented 8 years ago

@benmonro thanks for sharing.

I tried your solution, which is fine I believe. What I was looking was a way to "extend" all form components, in a way that I would keep their existing logic, then add the functionality to switch between a label that displays the value of the control (any control) to the actual field rendered by the model.

I was leaning more into creating a factory that could extend the existing field, adding an onClick that would switch from a label with a value into the actual form control (as on the example link I provided)

This would be also an excellent example for the docs on how to use factories, which is a bit brief.

After looking at the tcomb-form source, I was looking to implement a "smart factory" that would know the type of control I'm using from the model, and switch from a label (onClick), into the control type I'm using on my model. So in a way it would be a factory that would turn any control into an inline control. This means I could add this factory to any control, and it would just work.

I believe it would be a render() method similar to the way control definitions are rendered on components.js (lines 238-244):

render() {
    const locals = this.getLocals();
    // getTemplate is the only required implementation when extending Component
    assert(t.Func.is(this.getTemplate), `[${SOURCE}] missing getTemplate method of component ${this.constructor.name}`);
    const template = this.getTemplate();
    return compile(template(locals));
  }

I would love if someone really familiar with the source would create a simple example factory that would do this. I'm currently looking at the source on how to approach this, but any help is appreciated.

Thanks again,

pbreah commented 8 years ago

I managed to create a version that works for text inputs but for it doesn't work for the other components. I wanted to reuse as much as possible without rewriting the same code again.

It would be easier if the control-level template callback would provide me with the actual renderable element, rather than the locals, I would be able to "decorate" the element with an inline show/hide buttons.

This is the factory I have so far that works for text input controls only:

import { compile } from 'uvdom/react';
import { getComponent, Component as MyComponent } from 'tcomb-form/lib/components';

// this is added to the factory option like so - factory: InlineElement
class InlineElement extends MyComponent {

    constructor(props) {
        super(props);
        this.state = {
            showValue: false
        };
        this.onEdit = this.onEdit.bind(this);
    }

    shouldComponentUpdate(nextProps, nextState) {
        const should = (
            nextState.value !== this.state.value ||
            nextState.hasError !== this.state.hasError ||
            nextProps.options !== this.props.options ||
            nextProps.type !== this.props.type ||
            nextState.showValue !== this.state.showValue
        );
        return should;
    }

    onEdit() {
        this.setState({ showValue: !this.state.showValue, value: this.state.value });
    }

    getTemplate() {
        var template_name = '';

        switch (this.props.type.meta.kind) {
            case 'irreducible' :
                template_name =
                    this.props.type === tv.Bool ? 'checkbox' :
                        this.props.type === tv.Dat ?  'datetime' :
                            'textbox';
                break;
            case 'struct' :
            case 'list' :
            case 'enums' :
                template_name = 'select';
                break;
            case 'maybe' :
                template_name = this.props.type.meta.kind;
                break;
            case 'subtype' :
                return getTempate();
            default :
                t.fail(`[${SOURCE}] unsupported type ${name}`);
        }
        return t.form.Form.templates[template_name];
    }

    render() {
        const locals = this.getLocals();
        //locals.options = this.getOptions();
        var template = null;

        if(!this.state.showValue) {
            template = (locals) => {
                return <div>{locals.value}<button onClick={this.onEdit}>edit</button></div>;
            }
        }
        else {
            template = (locals) => {
                return <div>{compile(this.getTemplate()(locals))}<button onClick={this.onEdit}>OK</button></div>
            }
        }
        return compile(template(locals));
    }
}

Any ideas @gcanti ? ...I believe the simplest way would be to extend the API so I can decorate the a "renderable" element at the control-level (the form level will keep the same logic) - on line 243 of components.js

This is a great enhancement as well.

Thanks,

gcanti commented 8 years ago

Hi @pbreah, Something like this?

class MyTextbox extends t.form.Textbox {

  constructor(...args) {
    super(...args);
    this.state.showValue = true;
  }

  toggle(evt) {
    evt.preventDefault();
    this.state.showValue = !this.state.showValue;
    this.forceUpdate(); // overrides the default shouldComponentUpdate
  }

  getTemplate() {
    if (!this.state.showValue) {
      const template = super.getTemplate();
      return (locals) => {
        let uvdom = template(locals);
        // modify the uvdom on the fly
        uvdom.children.push(<a href="#" onClick={this.toggle.bind(this)}>Toggle</a>);
        return uvdom;
      };
    }
    return (locals) => {
      return (
        <div>
          <a href="#" style={{ color: locals.hasError ? '#a94442' : 'normal' }} onClick={this.toggle.bind(this)}>{locals.value || 'Empty'}</a>
        </div>
      );
    };
  }

}

var Type = t.struct({
  name: t.String,
  surname: t.String
});

var options = {
  fields: {
    name: {
      factory: MyTextbox
    }
  }
};
pbreah commented 8 years ago

thanks @gcanti it works great. Very clean code.

I wanted to create a "generic" factory for all components, so that in a single factory class would render any type of form control with this toggle function. This factory would take each control, determine its type, pull the existing template and decorate it with the same toggle link & function you added.

It is really close...

If the control would store the existing template, or would have a getter method I could call to get the "built-in" template then we could decorate it, the same way, but without hard-coding the "extends"

Another approach would be to add an option to allow this:

var options = {
  fields: {
    name: {
      inline: true,
      inline_template: MyInlineToggle
    }
  }
};

I know many users would appreciate this feature. :+1:

gcanti commented 8 years ago

I wanted to create a "generic" factory for all components

It's tricky but seems to work:

class GenericFactory {

  constructor(...args) {

    const props = args[0];
    const options = {
      ...props.options,
      factory: null // avoid circular reference
    };

    class Inliner extends t.form.getComponent(props.type, options) {

      constructor(...args) {
        super(...args);
        this.state.showValue = true;
      }

      toggle(evt) {
        evt.preventDefault();
        this.state.showValue = !this.state.showValue;
        this.forceUpdate(); // overrides the default shouldComponentUpdate
      }

      getTemplate() {
        if (!this.state.showValue) {
          const template = super.getTemplate();
          return (locals) => {
            let uvdom = template(locals);
            // modify the uvdom on the fly
            uvdom.children.push(<a href="#" onClick={this.toggle.bind(this)}>Toggle</a>);
            return uvdom;
          };
        }
        return (locals) => {
          return (
            <div>
              <a href="#" style={{ color: locals.hasError ? '#a94442' : 'normal' }} onClick={this.toggle.bind(this)}>{locals.value || 'Empty'}</a>
            </div>
          );
        };
      }

    }

    return new Inliner(...args);
  }

}

const Country = t.enums.of(['IT', 'US']);

var Type = t.struct({
  name: t.String,
  country: Country
});

var options = {
  fields: {
    name: {
      factory: GenericFactory
    },
    country: {
      factory: GenericFactory
    }
  }
};

var App = React.createClass({

  onSubmit(evt) {
    evt.preventDefault();
    var value = this.refs.form.getValue();
    if (value) {
      console.log(value);
    }
  },

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

});
pbreah commented 8 years ago

@gcanti thanks! It works!

It is tricky indeed. Even my editor lint gets confused reporting errors.

With this x-editable has real competition!

Put up a donation link, your project deserves to be supported. :+1:

johnraz commented 8 years ago

I couldn't agree more with the donation link ! :+1:

gcanti commented 8 years ago

Hi guys, thanks for your support.

I didn't put up a donation link because I use these libraries in my daily work. All your contributions make tcomb-form and the other libraries better, day after day.

So I'm here to thank you.

Actually there's a thing which would help me a lot: if you have success stories or good use cases for tcomb-form or the other libraries, please spread the word, maybe write some blog post... I'll be really glad to know that my open source projects have been helpful.

Giulio