react-conventions
Version:
An open source set of React components that implement Ambassador's Design and UX patterns.
404 lines (359 loc) • 12 kB
JavaScript
import React from 'react'
import Clipboard from 'clipboard'
import style from './style.scss'
import classNames from 'classnames/bind'
import Icon from '../Icon'
import Spinner from '../Spinner'
import Tooltip from '../Tooltip'
import SelectField from '../SelectField'
class InlineEdit extends React.Component {
constructor(props) {
super(props)
}
static defaultProps = {
isEditing: false,
placeholder: 'Click to edit',
loading: false,
readonly: false,
error: '',
value: '',
tooltipPlacement: 'right',
type: 'text'
}
static propTypes = {
/**
* Name of the input.
*/
name: React.PropTypes.string,
/**
* A callback function to be called when save is clicked.
*/
changeCallback: React.PropTypes.func,
/**
* Value of the input.
*/
value: React.PropTypes.oneOfType([
React.PropTypes.number,
React.PropTypes.string,
React.PropTypes.array
]),
/**
* Boolean used to show/hide the input vs formatted display.
*/
isEditing: React.PropTypes.bool,
/**
* Optional styles to add to the inline-edit.
*/
optClass: React.PropTypes.string,
/**
* Optional placeholder string for empty submission.
*/
placeholder: React.PropTypes.string,
/**
* Whether the inline-edit is readonly.
*/
readonly: React.PropTypes.bool,
/**
* Boolean used to show/hide the loader.
*/
loading: React.PropTypes.bool,
/**
* Error to display under the field.
*/
error: React.PropTypes.string,
/**
* Boolean used to display the copy to clipboard icon.
*/
copyToClipboard: React.PropTypes.bool,
/**
* A label to display next to the component.
*/
label: React.PropTypes.string,
/**
* An icon to display next to the component.
*/
icon: React.PropTypes.string,
/**
* Text to display inside the tooltip.
*/
tooltipText: React.PropTypes.string,
/**
* The placement of the tooltip.
*/
tooltipPlacement: React.PropTypes.oneOf(['left', 'right', 'top', 'bottom']),
/**
* An optional class to add to the tooltip.
*/
tooltipClass: React.PropTypes.string,
/**
* Type of the field.
*/
type: React.PropTypes.oneOf(['text', 'select']),
/**
* Options for the dropdown menu (required if type is 'select').
*/
selectOptions: React.PropTypes.array
}
state = {
isEditing: this.props.isEditing,
value: this.props.value || '',
loading: this.props.loading,
error: this.props.error,
copied: false
}
componentWillReceiveProps = (nextProps) => {
if (nextProps.isEditing) {
this.showButtons()
}
let newState = {}
if (nextProps.loading !== this.state.loading) {
newState.loading = nextProps.loading
}
if (nextProps.error !== this.state.error) {
newState.error = nextProps.error
}
if (nextProps.error !== '' && this.props.type === 'text') {
this.showButtons()
}
if (Object.keys(newState).length > 0) {
this.setState(newState)
}
}
componentDidMount = () => {
if (this.props.type === 'text') {
this.attachKeyListeners()
this.activateCopyToClipboard()
}
this.getStyles()
}
shouldComponentUpdate = (nextProps, nextState) => {
return this.state.isEditing !== nextState.isEditing
|| this.state.value !== nextState.value
|| this.state.loading !== nextState.loading
|| this.state.error !== nextState.error
|| this.state.copied !== nextState.copied
|| this.state.inlineEditMaxWidth !== nextState.inlineEditMaxWidth
|| this.props.tooltipText !== nextProps.tooltipText
|| this.props.tooltipPlacement !== nextProps.tooltipPlacement
|| this.props.readonly !== nextProps.readonly
}
handleSave = (event) => {
if (this.props.type === 'text') {
const inputText = this._textValue.textContent
const shouldTriggerCallback = inputText !== this.state.value
const previousValue = this.state.value
const isEditing = this.state.error !== '' ? true : false
this.setState({ isEditing: isEditing, value: inputText }, () => {
if (!isEditing) {
this.activateCopyToClipboard()
this._textValue.blur()
this._textValue.scrollLeft = 0
}
if (typeof this.props.changeCallback === 'function' && shouldTriggerCallback) {
const event = {
target: {
name: this.props.name,
value: this.state.value
}
}
this.props.changeCallback(event)
}
})
}
else {
const shouldTriggerCallback = event.target.value !== this.state.value
this.setState({ value: event.target.value }, () => {
if (typeof this.props.changeCallback === 'function' && shouldTriggerCallback) {
const event = {
target: {
name: this.props.name,
value: this.state.value
}
}
this.props.changeCallback(event)
}
})
}
}
handleCancel = () => {
let newState = { isEditing: false }
let shouldTriggerCallback = false
if (this.state.error !== '' && this.props.value !== this.state.value) {
newState.error = ''
newState.value = this.props.value
shouldTriggerCallback = true
}
this.setState(newState, () => {
this.activateCopyToClipboard()
this._textValue.blur()
this._textValue.scrollLeft = 0
if (typeof this.props.changeCallback === 'function' && shouldTriggerCallback) {
const event = {
target: {
name: this.props.name,
value: this.state.value,
canceled: true
}
}
this.props.changeCallback(event)
}
})
}
showButtons = () => {
if (!this.props.readonly) {
this.setState({ isEditing: true }, () => {
this.selectElementContents(this._textValue)
})
}
}
getField = () => {
if (this.props.type === 'select') {
return this.getSelect()
}
else {
return this.getSpan()
}
}
getSelect = () => {
const selectClass = classNames.bind(style)(style['inline-edit-select'], this.state.loading ? 'loading' : '')
return (
<SelectField
options={this.props.options}
valueProp='value'
displayProp='label'
changeCallback={this.handleSave}
value={this.state.value}
optClass={selectClass}
disabled={this.props.readonly}>
</SelectField>
)
}
getSpan = () => {
if (this.state.isEditing) {
return <span id='span_id' contentEditable className={style['inline-text-wrapper']} dangerouslySetInnerHTML={{__html: this.state.value}} ref={(c) => this._textValue = c} />
}
return (
<span id='span_id' onClick={this.showButtons} className={style['inline-text-wrapper-hover']} ref={(c) => this._textValue = c}>
{this.props.tooltipText
? <Tooltip content={this.props.tooltipText} tooltipPlacement={this.props.tooltipPlacement} appendToBody={true} className={style['value-tooltip']} optClass={this.props.tooltipClass || ''}>{this.state.value || this.props.placeholder }</Tooltip>
: <span>{this.state.value || this.props.placeholder }</span>
}
</span>
)
}
getCopyIcon = () => {
if (this.state.copied) {
return 'copied!'
}
const copyIconFill = this.state.value === '' ? '#9198A0' : '#3C97D3'
return <Icon name='icon-clipboard-1' height='14' width='14' fill={copyIconFill} />
}
getIcon = () => {
if (this.props.icon) {
return <span className={style['inline-icon']} ref={(c) => this._inlineIcon = c}><Icon name={this.props.icon} height='14' width='14' fill='#9198A0' /></span>
}
}
getLabel = () => {
if (this.props.label) {
return <label className={style['inline-label']} ref={(c) => this._inlineLabel = c}>{this.props.label}</label>
}
}
selectElementContents = (element) => {
const range = document.createRange()
range.selectNodeContents(element)
const selection = window.getSelection()
selection.removeAllRanges()
selection.addRange(range)
element.focus()
}
attachKeyListeners = () => {
this._textValue.addEventListener('keypress', this.handleKeyPress)
this._textValue.addEventListener('keyup', this.handleKeyUp)
}
handleKeyPress = (event) => {
// Grabs the character code, even in FireFox
const charCode = event.keyCode ? event.keyCode : event.which
if (charCode === 13) {
event.preventDefault()
this.handleSave()
}
}
handleKeyUp = (event) => {
// Grabs the character code, even in FireFox
const charCode = event.keyCode ? event.keyCode : event.which
if (charCode === 27) {
event.preventDefault()
this.handleCancel()
}
}
activateCopyToClipboard = () => {
if (!this.props.copyToClipboard) {
return
}
const clipboard = new Clipboard(this._copyTrigger)
clipboard.on('success', () => {
this.handleCopy()
})
}
handleCopy = () => {
this.setState({ copied: true }, () => {
setTimeout(() => {
this.setState({ copied: false })
}, 1800)
})
}
getStyles = () => {
let offset = 0
if (this._inlineIcon) {
// Add width and margin to the offset
offset += this._inlineIcon.getBoundingClientRect().width + 5
}
if (this._inlineLabel) {
// Add width and margin to the offset
offset += this._inlineLabel.getBoundingClientRect().width + 10
}
this.setState({ inlineEditMaxWidth: `calc(100% - ${offset}px)` })
}
render = () => {
const cx = classNames.bind(style)
const readonlyClass = this.props.readonly ? 'readonly' : ''
const errorClass = this.state.error !== '' ? 'error' : ''
const placeholderClass = this.state.value === '' ? 'placeholder' : ''
const copyDisabledClass = this.state.value === '' ? 'disabled' : ''
const copyIconClass = cx(style['copy-icon'], copyDisabledClass, this.state.copied ? 'copied' : '')
const inlineEditClass = cx(style['inline-edit-wrapper'], this.props.optClass, readonlyClass, errorClass, placeholderClass)
const overflowWrapperClass = cx(style['inline-text-overflow-wrapper'], this.props.type === 'select' ? style['visible'] : '')
return (
<div className={inlineEditClass}>
<div className={style['inline-edit-wrapper-inner']}>
{this.getIcon()}
{this.getLabel()}
<div className={overflowWrapperClass} style={{ maxWidth: this.state.inlineEditMaxWidth }}>
{this.getField()}
{this.state.isEditing && !this.state.loading
? <div className={style['inline-button-wrapper']}>
<Icon name='icon-check-2-1' onClick={this.handleSave} height='20' width='20' className={style['save-button']}>Save</Icon>
<Icon name='icon-delete-1-1' onClick={this.handleCancel} height='20' width='20' className={style['cancel-button']}>Cancel</Icon>
</div>
: null
}
{this.props.copyToClipboard && !this.state.isEditing && !this.state.loading
? <span ref={(c) => this._copyTrigger = c} data-clipboard-text={this.state.value}>
<span className={copyIconClass}>{this.getCopyIcon()}</span>
</span>
: null
}
<div className={style['loader-wrapper']}>
<Spinner loading={this.state.loading} optClass={style['spinner']} type='spinner-bounce' color='#9198A0' />
</div>
</div>
</div>
{this.state.error && this.state.error !== ''
? <div className={style['error-text']}>{this.state.error}</div>
: null
}
</div>
)
}
}
export default InlineEdit