Implementing react-bootstrap-typeahead with react-bootstrap-table

Published on 13 December 2017

Well, this was entertaining struggle of many hours.

Long story short, I needed to implement a controlled `AsyncTypeahead` field from `react-bootstrap-typeahead` as an editable and insertable field in `react-bootstrap-table` in a redux app. Oh, and the typeahead needed to allow new values to be entered.

You Just… or Do You?

According to the instructions provided by `react-bootstrap-table` “you just”:

  1. Use the `customEditor` prop to pass in your custom field component which implements `focus()` and `updateData()` (see: react-bootstrap-table: custom cell edit example).
  2. Use the `customInsertEditor` prop to pass in another custom field component which implements `getFieldValue()`. (see: React Bootstrap Table - Customization)
  3. Implement a wrapped component of `AsyncTypeahead` that implements the required interfaces above, so that you can also pass in the necessary params that `customEditor` and `customInsertEditor` needs.

Sadly, “you don’t just”.

Side Effects

There were a few side-effects which made this not work quite as expected.

  • Typeahead suggestions vanish inside the table cell. Resolved with some CSS.
  • Using `AsyncTypeahead` as a controlled field removes focus on change. Could be the result of `blurToSave` handling by the table, as typeahead selection would fire the `onBlur` event because you’re clicking out of the field, or something hinky from using the typeahead as a controlled field. Or both.
  • Once an option is selected, when you refocus on the field and hit backspace, instead of removing one letter, it clears the selected value. Makes sense, but means you can’t use it like a regular controlled field. Which you probably shouldn’t in the table anyway seeing as it handles updates separately. Note: The demo doesn’t show this side effect because it’s not a controlled field.
  • Once the typeahead is no longer in focus, the table doesn’t reset the editable mode so the field still looks in focus. When using `blurToSave` and custom fields, you have to fire the `onBlur` handler yourself in the custom component. Logically it would make sense to do this in the typeahead `onChange` but that handler doesn’t pass the event at all, and the table’s `onBlur` requires the event target. Woe.
  • When inserting a new row, `Uncaught TypeError: dom.getFieldValue is not a function` is thrown and the row cannot be saved. Even if you have implemented the function in your component, the way the insert modal builds the fields for value retrieval doesn’t retrieve the correct component reference. When I put debugging in the lib, the ref attribute that was being passed from the table was `undefined`. Could this be the problem? Either way, the `dom` object was broken so of course `getFieldValue` was not a function.

Solution

Here’s a gist of the applied solution. Commented where relevant changes were made.

import React from 'react';
import editor from 'react-bootstrap-table/lib/Editor';
// Copied wholesale from react-bootstrap-table, modified where noted
class CustomModalBody extends React.Component {
getFieldValue() {
const newRow = {};
this.props.columns.forEach((column, i) => {
let inputVal;
if (column.autoValue) {
// when you want same auto generate value and not allow edit, example ID field
const time = new Date().getTime();
inputVal = typeof column.autoValue === 'function' ?
column.autoValue() :
(`autovalue-${time}`);
} else if (column.hiddenOnInsert || !column.field) {
inputVal = '';
} else {
// CHANGE: Not using `refs` syntax, omitted the counter.
// Add the counter back if there is risk of duplicate fields.
const dom = this[column.field];
inputVal = dom.value;
if (column.editable && column.editable.type === 'checkbox') {
const values = inputVal.split(':');
inputVal = dom.checked ? values[0] : values[1];
} else if (column.customInsertEditor) {
inputVal = inputVal || dom.getFieldValue();
}
}
newRow[column.field] = inputVal;
}, this);
return newRow;
}
render() {
const { columns, validateState, ignoreEditable } = this.props;
return (
<div className='modal-body'>
{
columns.map((column, i) => {
const {
editable,
format,
field,
name,
autoValue,
hiddenOnInsert,
customInsertEditor
} = column;
const attr = {
// CHANGE: Use function ref instead.
// If using field index above, apply here as well
ref: (ref) => {
this[field] = field === 'name' && ref && ref.instanceRef ? ref.instanceRef : ref;
},
placeholder: editable.placeholder ? editable.placeholder : name
};
let fieldElement;
const defaultValue = editable.defaultValue || undefined;
if (customInsertEditor) {
const { getElement } = customInsertEditor;
fieldElement = getElement(column, attr, 'form-control', ignoreEditable, defaultValue);
}
// fieldElement = false, means to use default editor when enable custom editor
// Becasuse some users want to have default editor based on some condition.
if (!customInsertEditor || fieldElement === false) {
fieldElement = editor(editable, attr, format, '', defaultValue, ignoreEditable);
}
if (autoValue || hiddenOnInsert || !column.field) {
// when you want same auto generate value
// and not allow edit, for example ID field
return null;
}
const error = validateState[field] ?
(<span className='help-block bg-danger'>{ validateState[field] }</span>) :
null;
return (
<div className='form-group' key={ field }>
<label>{ name }</label>
{ fieldElement }
{ error }
</div>
);
})
}
</div>
);
}
}
export default CustomModalBody;
import React from 'react';
import { Typeahead, asyncContainer } from 'react-bootstrap-typeahead';
// Install this so that you can remove editable mode from the custom field
import onClickOutside from 'react-onclickoutside';
const AsyncTypeahead = asyncContainer(Typeahead);
class CustomTypeaheadField extends React.Component {
constructor(props) {
super(props);
this.updateState = this.updateState.bind(this);
this.updateData = this.updateData.bind(this);
this.state = {
name: props.defaultValue,
isLoading: false,
options: []
}
}
// From react-onclickoutside, this is your manual 'saveOnBlur' handling
// that would otherwise be done by the table that also deselects the field
handleClickOutside(e) {
try {
if (this.props.tableRef) {
this.updateData();
this.props.tableRef.cleanSelected();
}
} catch (e) {}
}
focus() {
this._typeahead.getInstance().focus();
}
getFieldValue() {
return this.state.name;
}
updateData() {
this.props.onUpdate(this.state.name);
}
// Update internal state instead of whatever handler/redux action you would have used.
// Note: typeahead onChange returns an array, or empty array on clear, handle as needed
updateState(values) {
let value = '';
if (values.length > 0) {
value = values[0];
}
this.setState({name: value});
}
render() {
return <div>
<AsyncTypeahead
innerRef={ref => this._typeahead = ref}
allowNew={true}
clearButton={true}
isLoading={this.state.isLoading}
labelKey={'name'}
onSearch={query => {
this.setState({isLoading: true});
let URL = 'REMOTE';
fetch(URL)
.then(res => res.json())
.then(json => {
this.setState({isLoading: false, options: json});
});
}}
options={this.state.options}
// defaultSelected instead of selected, can't use it controlled in a table
defaultSelected={[this.props.defaultValue]}
onChange={this.updateState}
/>
</div>;
}
}
export default onClickOutside(CentreAgentNameField);
import React from 'react';
import styled from 'styled-components';
import { BootstrapTable as UnstyledBootstrapTable, TableHeaderColumn } from 'react-bootstrap-table';
import CustomTypeaheadField from './CustomTypeaheadField';
import CustomModalBody from './CustomModalBody';
// Resolves typeahead suggestions disappearing inside field borders
const BootstrapTable = styled(UnstyledBootstrapTable)`
& .react-bs-container-body {
overflow: inherit !important;
}
& td.typeahead-cell {
overflow: inherit !important;
}
`;
class ViewComponent extends React.Component {
constructor(props) {
this.createCustomEditField = this.createCustomEditField.bind(this);
this.createCustomInsertField = this.createCustomInsertField.bind(this);
}
createCustomModalBody(columns, validateState, ignoreEditable) {
return (
<CustomModalBody
columns={ columns }
validateState={ validateState }
ignoreEditable={ ignoreEditable }
/>
);
}
createCentreAgentNameEditField(onUpdate, props) {
return <CustomTypeaheadField onUpdate={onUpdate} {...props} tableRef={this._bootstraptable} />;
}
createCentreAgentNameInsertField(column, attr, editorClass, ignoreEditable) {
return <CustomTypeaheadField {...attr} />;
}
render() {
return <BootstrapTable
// Need this for the onBlur handling for the custom field
ref={ref => this._bootstraptable = ref}
data={ data }
remote={ true }
insertRow={ true }
deleteRow={ true }
selectRow={ { mode: 'radio' } }
cellEdit={{
mode: 'click',
blurToSave: true,
afterSaveCell: updateHandler
}}
options={{
// Override insert modal body
insertModalBody: this.createCustomModalBody,
onAddRow: addHandler,
onDeleteRow: removeHandler
}}
>
<TableHeaderColumn dataField='id' isKey={ true } autoValue={ true }>ID</TableHeaderColumn>
<TableHeaderColumn
dataField='name'
// Typeahead suggestions disappearing behind cell borders
editColumnClassName='typeahead-cell'
// Custom typeahead fields
customEditor={ { getElement: this.createCustomEditField } }
customInsertEditor={ { getElement: this.createCustomInsertField } }
editable={ { readOnly: false } }
>
Name
</TableHeaderColumn>
...
</BootstrapTable>
}
}