gcanti / tcomb-form

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

Tags input #252

Closed Xananax closed 8 years ago

Xananax commented 8 years ago

Since I had a bit of a hard time wrapping my head around custom factories, here's my tags input component, in the hopes it would be useful to someone else:

//TagsComponent.js
import React,{Component,PropTypes} from 'react';
import t from 'tcomb-form';
import cx from 'classnames';

const onTagClick = (id,onClick) => (evt) => {
    evt && evt.preventDefault()
    onClick(id);
}

function Tag({tag,id,onClick}){
    return (<div>{tag} <span onClick={onTagClick(id,onClick)}>x</span></div>)
}

function onChange(sep,evt){
    const currentValue = evt.target.value
    if(currentValue && sep.test(currentValue)){
        const addedValue = currentValue.trim();
        if(this.state.value && this.state.value.indexOf(addedValue)>=0){
            return this.setState({currentValue:''});
        }
        const value = (this.state.value ? [...this.state.value,addedValue] : [addedValue]);
        return this.setState(
            {value,currentValue:''}
        ,   () => this.props.onChange(value, this.props.ctx.path)
        );
    }
    this.setState({currentValue});
}

function onRemoveTag(id){
    const {value} = this.state;
    this.setState({value:value.filter((tag,i)=>i!==id)});
}

class TagsComponent extends Component{
    static propTypes = {
        separator:PropTypes.instanceOf(RegExp)
    ,   value:PropTypes.array
    ,   currentValue:PropTypes.string
    ,   component:PropTypes.element
    }
    static defaultProps = {
        separator:/\s$/
    ,   value:[]
    ,   currentValue:''
    }
    constructor(props,context) {
        super(props,context)
        this.state = {
            hasError: false
        ,   value:props.value
        ,   currentValue:props.currentValue
        };
        this.onChange = onChange.bind(this,(props.separator));
        this.onRemoveTag = onRemoveTag.bind(this);
    }
    shouldComponentUpdate(nextProps, nextState) {
        return (
            nextState.value !== this.state.value ||
            nextState.hasError !== this.state.hasError ||
            nextProps.value !== this.props.value ||
            nextProps.options !== this.props.options ||
            nextProps.onChange !== this.props.onChange ||
            nextProps.type !== this.props.type ||
            nextState.currentValue !== this.state.currentValue
        );
    }
    getValidationOptions(){
        return {
            path: this.props.ctx.path,
            context: t.mixin(t.mixin({}, this.props.context || this.props.ctx.context), { options: this.props.options })
        };
    }
    validate() {
        const {value} = this.state;
        const result = t.validate(value, this.props.type, this.getValidationOptions());
        this.setState({hasError: !result.isValid()});
        return result;
    }
    renderTags(value){
        return (value && value.map((tag,id)=>
            <Tag tag={tag} id={id} key={id} onClick={this.onRemoveTag}/>
        ));
    }
    render(){
        var options = this.props.options || {};
        const {value,currentValue,hasError} = this.state;
        const {ctx} = this.props;
        const CustomComponent = this.props.component;
        const label = options.label || (ctx.auto === 'labels' && ctx.label);
        const placeholder = ((!label && ctx.auto !== 'none')?
            !t.Nil.is(options.placeholder) ? options.placeholder : ctx.label :
            null
        );
        const name = options.name || ctx.name;
        const error = t.Func.is(options.error) ? options.error(this.state.value) : options.error;

        var formGroupClasses = cx({
            'form-group': true
        ,   'has-feedback': true
        ,   'has-error': hasError
        });

        const inputProps = {
            disabled:options.disabled
        ,   className:'form-control'
        ,   name
        ,   placeholder
        ,   onChange:this.onChange
        ,   type:'text'
        ,   value:currentValue
        }

        const input = CustomComponent ? (<CustomComponent {...inputProps}/>) : (<input {...inputProps}/>);

        return (<div className={formGroupClasses}>
            {label ? (<label className="control-label">{label}</label>) : null}
            {this.renderTags(value)}
            {input}
            <span className="glyphicon glyphicon-search form-control-feedback"></span>
            {(error ? (<span className="help-block error-block">{error}</span>): null)}
            {(options.help ?(<span className="help-block">{options.help}</span>) : null)}
        </div>);
    }
};

export default TagsComponent;

Use it like this:

//otherFile.js
import TagsComponent from './TagsComponent';

var Type = t.struct({
  tags: t.list(t.String)
});

const options = {
      fields:{
        tags:{
            factory:TagsComponent
        }
    }
}

<Form type={Type} options={options} />

Hope it helps anyone, comments would be welcome.

volkanunsal commented 8 years ago

Epic. Thanks for sharing!

gcanti commented 8 years ago

Hi @Xananax, thanks for sharing.

Another option would be using a third party tagsinput (or build your own) and then a custom factory:

import React from 'react';
import t from 'tcomb-form';
import TagsInput from 'react-tagsinput'; // I'm using this but you can build your own (and reusable!) tagsinput

// import react-tagsinput.css...

class TagsComponent extends t.form.Component { // extend the base class

  getTemplate() {
    return (locals) => {
      return (
        <TagsInput value={locals.value} onChange={locals.onChange} />
      );
    };
  }

}

export default TagsComponent;
Xananax commented 8 years ago

@gcanti I realize this, but the problem with the React ecosystem currently is that you have a million components, and no idea what is maintained and what is not, what is compatible with 0.14 (react-dom split) and what is not...A million ways to handle styling (inline styles, bem-style classes, webpack-controller classnames, ...) or state, and so on...Which is why I'm more comfortable keeping control, specially for something as simple as this. I'd rather choose a minimal set of libraries that seems trustworthy and stick with them.

I also wanted to understand how factories work and I thought this would be a good exercise.

gcanti commented 8 years ago

but the problem with the React ecosystem currently is that you have a million components... A million ways to handle styling ...

I feel your pain... :(

gcanti commented 8 years ago

I also wanted to understand how factories work and I thought this would be a good exercise.

Just a note... yet another option is to extend t.form.Component, it would:

(or use the t.form.Textbox.prototype as with getPlaceholder)

Something like... (sorry for the different style / spaces but I have a draconian es-lint on)

//TagsComponent.js
import React, {Component, PropTypes} from 'react';
import t from 'tcomb-form';
import cx from 'classnames';

function Tag({tag, onClick}){
  return <div>{tag} <span onClick={onClick}>x</span></div>;
}

class TagsComponent extends t.form.Component {

    static propTypes = {
        separator: PropTypes.instanceOf(RegExp)
    ,   value: PropTypes.array
    ,   currentValue: PropTypes.string
    ,   component: PropTypes.element
    }

    static defaultProps = {
        separator: /\s$/
    ,   value: []
    ,   currentValue: ''
    }

    constructor(props, context) {
        super(props, context);
        this.state = {
            hasError: false
        ,   value: props.value
        ,   currentValue: props.currentValue
        };
    }

    onChange(evt){
        const currentValue = evt.target.value;
        if(currentValue && this.props.separator.test(currentValue)){
            const addedValue = currentValue.trim();
            if(this.state.value && this.state.value.indexOf(addedValue) >= 0){
                return this.setState({currentValue: ''});
            }
            const value = (this.state.value ? [...this.state.value, addedValue] : [addedValue]);
            return this.setState(
                {value, currentValue: ''}
            ,   () => this.props.onChange(value, this.props.ctx.path)
            );
        }
        this.setState({currentValue});
    }

    shouldComponentUpdate(nextProps, nextState) {
        return super.shouldComponentUpdate(nextProps, nextState) || nextState.currentValue !== this.state.currentValue;
    }

    onRemoveTag(id, evt) {
      if (evt) {
        evt.preventDefault();
      }
      this.setState({value: this.state.value.filter((tag, i) => i !== id)});
    }

    renderTags(value){
        return value.map((tag, id) =>
            <Tag tag={tag} key={id} onClick={this.onRemoveTag.bind(this, id)}/>
        );
    }

    render(){
        var options = this.props.options || {};
        const {value, currentValue, hasError} = this.state;
        const {ctx} = this.props;
        const CustomComponent = this.props.component;
        const label = this.getLabel();
        const placeholder = this.getPlaceholder();
        const name = this.getName();
        const error = this.getError();

        var formGroupClasses = cx({
            'form-group': true
        ,   'has-feedback': true
        ,   'has-error': hasError
        });

        const inputProps = {
            disabled: options.disabled
        ,   className: 'form-control'
        ,   name
        ,   placeholder
        ,   onChange: this.onChange.bind(this)
        ,   type: 'text'
        ,   value: currentValue
        };

        const input = CustomComponent ? (<CustomComponent {...inputProps}/>) : (<input {...inputProps}/>);

        return (<div className={formGroupClasses}>
            {label ? (<label className="control-label">{label}</label>) : null}
            {this.renderTags(value)}
            {input}
            <span className="glyphicon glyphicon-search form-control-feedback"></span>
            {(error ? (<span className="help-block error-block">{error}</span>) : null)}
            {(options.help ? (<span className="help-block">{options.help}</span>) : null)}
        </div>);
    }
}

TagsComponent.prototype.getPlaceholder = t.form.Textbox.prototype.getPlaceholder;

export default TagsComponent;