Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[V4] EditableCell improvements #278

Merged
merged 3 commits into from
Aug 14, 2018
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 150 additions & 34 deletions src/table/src/EditableCell.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react'
import PropTypes from 'prop-types'
import { withTheme } from '../../theme'
import { Portal } from '../../portal'
import { Stack } from '../../stack'
import TextTableCell from './TextTableCell'
import TableCell from './TableCell'
import EditableCellField from './EditableCellField'
Expand All @@ -13,6 +14,22 @@ class EditableCell extends React.PureComponent {
*/
...TableCell.propTypes,

/*
* Makes the TableCell focusable.
* Will add tabIndex={-1 || this.props.tabIndex}.
*/
isSelectable: PropTypes.bool,

/**
* When true, the cell can't be edited.
*/
disabled: PropTypes.bool,

/**
* Optional placeholder when children is falsy.
*/
placeholder: PropTypes.node,

/**
* The size used for the TextTableCell and Textarea.
*/
Expand All @@ -21,23 +38,62 @@ class EditableCell extends React.PureComponent {
/**
* This is the value of the cell.
*/
children: PropTypes.string,
children: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),

/**
* Function called when value changes. (value: string) => void.
*/
onChange: PropTypes.func
onChange: PropTypes.func,

/**
* When a value is passed, this component will be controlled.
*/
isEditing: PropTypes.bool,

/**
* Only called when component is controlled and isSelectable={true}.
*/
onEditStart: PropTypes.func,

/**
* Only called when component is controlled.
*/
onEditComplete: PropTypes.func
}

static defaultProps = {
size: 300
size: 300,
isSelectable: true
}

static getDerivedStateFromProps(props, state) {
if (props.children !== state.value) {
return {
value: props.children
}
}
return null
}

state = {
isEditing: false,
value: this.props.children
}

componentWillUnmount() {
if (this.isControlled() && this.props.isEditing) {
this.props.onEditComplete()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should check that this has been passed in and is a function first, since it's not marked as required nor does it have a default prop value:

if (typeof this.props.onEditComplete === 'function') {
  this.props.onEditComplete()
}

}
}

/**
* Return true when component is controlled.
*/
isControlled = () => {
const { isEditing } = this.props
return isEditing !== null && isEditing !== undefined
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could probably do return typeof this.props.isEditing === 'boolean' if that's the expected type, and even doing 'isEditing' in this.props might be clearer semantics for what you are looking for here.

}

onMainRef = ref => {
this.mainRef = ref
}
Expand All @@ -47,40 +103,76 @@ class EditableCell extends React.PureComponent {
}

handleDoubleClick = () => {
this.setState({
isEditing: true
})
if (this.props.disabled || !this.props.isSelectable) return
if (this.isControlled()) {
this.props.onEditStart()
} else {
this.setState({
isEditing: true
})
}
}

handleKeyDown = e => {
if (this.props.disabled) return
const { key } = e

/**
* When the user presses a character on the keyboard, use that character
* as the value in the text field.
*/
if (key.match(/^[a-z]{0,10}$/) && !e.metaKey && !e.ctrlKey && !e.altKey) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems excessive to me – why not use an actual input element or a contenteditable div? This would all be built into the component and you'd get normal onChange handlers.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see you are you a textarea, but binding to onKeyDown. Can you explain your decision there a bit more?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be a bit confusing! EditableCell is actually just a selectable Table.TextCell. As soon as you hit enter, space or a character — a component called EditableCellField which contains a Textarea is overlayed exactly on top of the table cell. The EditableCellField position will be kept in sync. The reason for matching the key and passing the key as the value is then passed to the Textarea. This is inline with how spreadsheets work.

this.setState({
isEditing: true,
value: key
})
if (this.isControlled()) {
this.props.onEditStart()
this.setState({
value: key
})
} else {
this.setState({
isEditing: true,
value: key
})
}
} else if (key === 'Enter') {
this.setState({
isEditing: true
})
if (this.isControlled()) {
this.props.onEditStart()
} else {
this.setState({
isEditing: true
})
}
}
}

handleFieldBlur = value => {
const { onChange } = this.props
const { onChange, onEditComplete, isSelectable } = this.props
const currentValue = this.state.value

this.setState({
isEditing: false,
// Make edit instantious. Deal with errors up the tree.
value
})
if (this.isControlled() && typeof onEditComplete === 'function') {
this.props.onEditComplete(value)

if (this.mainRef) this.mainRef.focus()
this.setState({
value
})
} else {
this.setState({
isEditing: false,
value
})
}

if (currentValue !== value && typeof onChange === 'function') {
onChange(value)
}

if (this.mainRef) {
if (isSelectable) {
this.mainRef.focus()
} else {
const element = this.mainRef.querySelector('button')
if (element) element.focus()
}
}
}

handleFieldCancel = () => {
Expand All @@ -92,36 +184,60 @@ class EditableCell extends React.PureComponent {
}

render() {
const { children, theme, size, ...props } = this.props
const { isEditing } = this.state
const {
children,
theme,
size,
disabled,
placeholder,
isSelectable,
isEditing: propsIsEditing,
onEditComplete,
...props
} = this.props
const { isEditing: stateIsEditing, value } = this.state

let isEditing
if (this.isControlled()) {
// Controlled usage.
isEditing = propsIsEditing
} else {
isEditing = stateIsEditing
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anytime you allow a component to be fully controlled OR fully uncontrolled you need to make some careful decisions around what happens when consumers switch a mounted component from one to the other. This could be as simple as a consumer unintentionally passing an undefined value to one of the controlled props, so the component would start out uncontrolled, and as soon their actual value comes in the component would be switched to controlled.

How will this component's value be affected when you switch from controlled to uncontrolled or vice versa?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beware of some of these pitfalls (which are commonly used when you have controlled and uncontrolled support side-by-side): https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html#anti-pattern-unconditionally-copying-props-to-state

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One implementation I've seen that helps tease apart concerns is to actually make a *Stateless component that is fully controlled, and a separate component for holding any internal state (uncontrolled, in this case).


return (
<React.Fragment>
<TextTableCell
isSelectable
isSelectable={isSelectable && !disabled}
innerRef={this.onMainRef}
onClick={this.handleClick}
onDoubleClick={this.handleDoubleClick}
onKeyDown={this.handleKeyDown}
size={size}
cursor="default"
cursor={disabled ? 'not-allowed' : 'default'}
textProps={{
size
size,
opacity: disabled || (!children && placeholder) ? 0.5 : 1
}}
{...props}
>
{children}
{children ? children : placeholder}
</TextTableCell>
{isEditing && (
<Portal>
<EditableCellField
getTargetRef={() => this.mainRef}
value={children}
onEscape={this.handleFieldEscape}
onBlur={this.handleFieldBlur}
onCancel={this.handleFieldCancel}
size={size}
/>
<Stack>
{zIndex => (
<EditableCellField
zIndex={zIndex}
getTargetRef={() => this.mainRef}
value={value}
onEscape={this.handleFieldEscape}
onBlur={this.handleFieldBlur}
onCancel={this.handleFieldCancel}
size={size}
/>
)}
</Stack>
</Portal>
)}
</React.Fragment>
Expand Down
36 changes: 29 additions & 7 deletions src/table/src/EditableCellField.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ export default class EditableCellField extends React.PureComponent {
*/
value: PropTypes.string.isRequired,

/**
* The z-index placed on the element.
*/
zIndex: PropTypes.number.isRequired,

/**
* Function to get the target ref of the parent.
* Used to mirror the position.
Expand Down Expand Up @@ -71,7 +76,11 @@ export default class EditableCellField extends React.PureComponent {
if (isTableBody) {
return ref
}
ref = ref.parentElement
if (ref.parentElement) {
ref = ref.parentElement
} else {
return null
}
}

this.tableBodyRef = ref
Expand All @@ -84,15 +93,26 @@ export default class EditableCellField extends React.PureComponent {
if (!targetRef) return
const tableBodyRef = this.getTableBodyRef(targetRef)

const bounds = tableBodyRef.getBoundingClientRect()

const { left, top, height, width } = targetRef.getBoundingClientRect()
const {
left,
top: targetTop,
height,
width
} = targetRef.getBoundingClientRect()

let top
if (tableBodyRef) {
const bounds = tableBodyRef.getBoundingClientRect()
top = Math.min(Math.max(targetTop, bounds.top), bounds.bottom - height)
} else {
top = targetTop
}

this.setState(
() => {
return {
left,
top: Math.min(Math.max(top, bounds.top), bounds.bottom - height),
top,
height,
width
}
Expand All @@ -117,11 +137,12 @@ export default class EditableCellField extends React.PureComponent {
const { key } = e
if (key === 'Escape' || key === 'Enter') {
this.textareaRef.blur()
e.preventDefault()
}
}

render() {
const { size, value, minWidth, minHeight } = this.props
const { size, value, minWidth, minHeight, zIndex } = this.props
const { left, top, height, width } = this.state

return (
Expand All @@ -137,7 +158,8 @@ export default class EditableCellField extends React.PureComponent {
height,
minHeight: Math.max(height, minHeight),
width,
minWidth: Math.max(width, minWidth)
minWidth: Math.max(width, minWidth),
zIndex
}}
height={null}
width={null}
Expand Down
2 changes: 2 additions & 0 deletions src/table/src/SelectMenuCell.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'
import debounce from 'lodash.debounce'
import { withTheme } from '../../theme'
import { SelectMenu } from '../../select-menu'
import { Icon } from '../../icon'
import TextTableCell from './TextTableCell'
import TableCell from './TableCell'

Expand Down Expand Up @@ -94,6 +95,7 @@ class SelectMenuCell extends React.PureComponent {
<TextTableCell
innerRef={this.onMainRef.bind(null, getRef)}
isSelectable
rightView={<Icon icon="caret-down" color="muted" />}
aria-haspopup
aria-expanded={isShown}
size={size}
Expand Down
9 changes: 9 additions & 0 deletions src/table/src/TableCell.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ class TableCell extends PureComponent {
*/
appearance: PropTypes.string.isRequired,

/**
* Optional node to be placed on the right side of the table cell.
* Useful for icons and icon buttons.
*/
rightView: PropTypes.node,

/**
* Theme provided by ThemeProvider.
*/
Expand All @@ -51,6 +57,7 @@ class TableCell extends PureComponent {
flex: 1,
display: 'flex',
alignItems: 'center',
flexShrink: 0,
overflow: 'hidden'
}

Expand Down Expand Up @@ -101,6 +108,7 @@ class TableCell extends PureComponent {
isSelectable,
tabIndex = -1,
className,
rightView,
...props
} = this.props

Expand All @@ -122,6 +130,7 @@ class TableCell extends PureComponent {
{...props}
>
{children}
{rightView ? rightView : null}
</Pane>
)
}}
Expand Down
Loading