Skip to content

Commit

Permalink
Merge pull request #76 from iCHEF/release/1.1.0
Browse files Browse the repository at this point in the history
Release 1.1.0
  • Loading branch information
zhusee2 authored Aug 15, 2017
2 parents 1c58109 + b801897 commit 7c6bb04
Show file tree
Hide file tree
Showing 5 changed files with 188 additions and 44 deletions.
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]
### Added
N/A

## [1.1.0]
### Changed
- API changes to `<EditableTextLabel>`:
* `inEdit` prop now defaults to `undefined`, which means the component is **uncontrolled**.
* When `inEdit` is set either `true` or `false`, the component is **controlled**
* ~`onEditRequest`~ prop is removed in favor of new `onDblClick` callback. Users can decide when to update the edit state.
- Behavior changes to `<EditableTextLabel>`:
* Custom element passed via `icon` now renders correctly under edit mode
* Double touch on mobile devices also triggers `onDblClick` callback.
* If component is **uncontrolled**, it auto enters edit mode on double clicks/touches and leaves on edit ends.

## [1.0.0]
### Added
Expand Down
15 changes: 8 additions & 7 deletions examples/TextLabel/Editable.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,16 @@ const cleanAction = decorateAction([
}
]);

class EditableExample extends PureComponent {
class ControlledExample extends PureComponent {
state = {
isEditing: false,
status: null,
currentBasic: 'Kitchen Printer',
};

handleEditRequest = (event) => {
handleDblClick = (event) => {
this.setState({ isEditing: true });
action('editRequest')(event);
action('dblClick')(event);
}

handleEditEnd = (payload) => {
Expand Down Expand Up @@ -57,7 +57,7 @@ class EditableExample extends PureComponent {
basic={this.state.currentBasic}
aside="00:11:22:33"
tag="Online"
onEditRequest={this.handleEditRequest}
onDblClick={this.handleDblClick}
onEditEnd={this.handleEditEnd}
status={this.state.status} />
</DebugBox>
Expand All @@ -68,16 +68,17 @@ class EditableExample extends PureComponent {
function Editable() {
return (
<div>
<p>Uncontrolled (self-controlled) editable label:</p>
<EditableTextLabel
icon="printer"
basic="Kitchen Printer"
aside="00:11:22:33"
tag="Online"
onEditRequest={action('editRequest')}
onDblClick={action('dblClick')}
onEditEnd={cleanAction('editEnd')} />

<p>Interactive example:</p>
<EditableExample />
<p>Controlled editable label:</p>
<ControlledExample />
</div>
);
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ichef/gypcrete",
"version": "1.0.0",
"version": "1.1.0",
"description": "iCHEF web components library, built with React.",
"main": "lib/index.js",
"repository": {
Expand Down
109 changes: 87 additions & 22 deletions src/EditableTextLabel.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,20 @@ import keycode from 'keycode';
import type { ReactChildren } from 'react-flow-types';

import { getTextLayoutProps } from './mixins/rowComp';
import wrapIfNotElement from './utils/wrapIfNotElement';

import EditableText from './EditableText';
import Icon from './Icon';
import TextLabel from './TextLabel';

import { STATUS_CODE as STATUS } from './StatusIcon';

const TOUCH_TIMEOUT_MS = 250;

export type Props = {
inEdit: boolean,
onEditRequest: () => void,
inEdit?: boolean,
onEditEnd: (payload?: { value: string | null, event: Event }) => void,
onDblClick: (event?: Event) => void,
// #FIXME: use exported Flow types
icon?: string,
basic?: ReactChildren,
Expand All @@ -27,8 +30,8 @@ export type Props = {
class EditableTextLabel extends PureComponent<Props, Props, any> {
static propTypes = {
inEdit: PropTypes.bool,
onEditRequest: PropTypes.func,
onEditEnd: PropTypes.func,
onDblClick: PropTypes.func,
// <TextLabel> props
icon: TextLabel.propTypes.icon,
basic: TextLabel.propTypes.basic,
Expand All @@ -37,33 +40,91 @@ class EditableTextLabel extends PureComponent<Props, Props, any> {
};

static defaultProps = {
inEdit: false,
onEditRequest: () => {},
inEdit: undefined,
onEditEnd: () => {},
onDblClick: () => {},
// <TextLabel> props
icon: TextLabel.defaultProps.icon,
basic: TextLabel.defaultProps.basic,
align: TextLabel.defaultProps.align,
status: TextLabel.defaultProps.status,
};

handleDoubleClick = () => {
state = {
inEdit: this.props.inEdit || false,
// For simulating double-touch
touchCount: 0,
dblTouchTimeout: null,
};

componentWillReceiveProps(nextProps: Props) {
/**
* Request edit via double-click is not favored,
* because users can hardly find out this interaction.
*
* This is kept for compatibility reasons.
*
* Currently I have no plan for supporting the simulated double-click detection
* on mobile devices. It's even harder for users to figure out,
* and it's not a common UI pattern.
*
* We should rely on visible buttons or menus to trigger edit.
* If the edit-state of <EditableTextLabel> is *controlled* by `inEdit` prop.
* If the prop is `undefined`, this component became *uncontrolled*
* and should run itself.
*/
this.props.onEditRequest();
if (this.getEditabilityControlled(nextProps)) {
this.setState({ inEdit: nextProps.inEdit });
}
}

getEditabilityControlled(fromProps: Props = this.props) {
return fromProps.inEdit !== undefined;
}

leaveEditModeIfNotControlled() {
if (!this.getEditabilityControlled(this.props)) {
this.setState({ inEdit: false });
}
}

resetDblTouchSimulation = () => {
this.setState({
touchCount: 0,
dblTouchTimeout: null,
});
}

handleDoubleClick = (event: Event) => {
/**
* If `inEdit` isn't controlled, this component by default
* goes into edit mode on double click/touch.
*/
if (!this.getEditabilityControlled()) {
this.setState({ inEdit: true });
}

this.props.onDblClick(event);
}

handleTouchStart = (event: Event) => {
const currentCount = this.state.touchCount + 1;

if (currentCount === 2) {
// Simulates “double touch”
this.handleDoubleClick(event);
this.resetDblTouchSimulation();
return;
}

/**
* Clears prev timeout to keep touch counts, and then
* create new timeout to reset touch counts.
*/
global.clearTimeout(this.state.dblTouchTimeout);
const resetTimeout = global.setTimeout(
this.resetDblTouchSimulation,
TOUCH_TIMEOUT_MS
);

this.setState({
touchCount: currentCount,
dblTouchTimeout: resetTimeout,
});
}

handleInputBlur = (event: Event & { currentTarget: HTMLInputElement }) => {
this.leaveEditModeIfNotControlled();
this.props.onEditEnd({
value: event.currentTarget.value,
event,
Expand All @@ -77,6 +138,7 @@ class EditableTextLabel extends PureComponent<Props, Props, any> {
event.currentTarget.blur();
break;
case keycode('Escape'):
this.leaveEditModeIfNotControlled();
this.props.onEditEnd({
value: null,
event,
Expand All @@ -89,31 +151,34 @@ class EditableTextLabel extends PureComponent<Props, Props, any> {

render() {
const {
inEdit,
onEditRequest,
inEdit, // not used here
onDblClick, // also not used here
onEditEnd,
...labelProps,
} = this.props;
const { icon, basic, align, status } = labelProps;

if (!inEdit && status !== STATUS.LOADING) {
if (!this.state.inEdit && status !== STATUS.LOADING) {
return (
<TextLabel
onDoubleClick={this.handleDoubleClick}
onTouchStart={this.handleTouchStart}
{...labelProps} />
);
}

const layoutProps = getTextLayoutProps(align, !!icon);
const labelIcon = icon && wrapIfNotElement(icon, { with: Icon, via: 'type' });

return (
<TextLabel {...labelProps}>
{icon && <Icon type={icon} />}
{labelIcon}

<EditableText
defaultValue={basic}
onBlur={this.handleInputBlur}
input={{
autoFocus: inEdit,
autoFocus: this.state.inEdit,
onKeyDown: this.handleInputKeyDown,
}}
{...layoutProps} />
Expand Down
94 changes: 80 additions & 14 deletions src/__tests__/EditableTextLabel.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,20 +65,6 @@ it('renders <EditableText> with layout props the same as rowComp() in edit mode'
});
});

it('requests to go edit mode when double-clicked in normal mode', () => {
const handleEditRequest = jest.fn();
const wrapper = shallow(
<EditableTextLabel basic="Foo" onEditRequest={handleEditRequest} />
);

wrapper.simulate('dblclick');
expect(handleEditRequest).toHaveBeenCalled();

// Should not break event when callback not set.
wrapper.setProps({ onEditRequest: undefined });
expect(() => wrapper.simulate('dblclick')).not.toThrowError();
});

it('fires onEditEnd with input value on input blurs', () => {
const handleEditEnd = jest.fn(() => EditableTextLabel.defaultProps.onEditEnd());
const wrapper = mount(<EditableTextLabel basic="foo" onEditEnd={handleEditEnd} inEdit />);
Expand Down Expand Up @@ -155,3 +141,83 @@ it('does not fire onEditEnd on other keys', () => {
wrapper.find('input').simulate('keydown', { keyCode: keycode('Delete') });
expect(handleEditEnd).not.toHaveBeenCalled();
});

// Self-controlled edit mode
it("goes edit mode on double click if 'inEdit' is uncontrolled", () => {
const wrapper = shallow(<EditableTextLabel basic="Foo" />);
expect(wrapper.state('inEdit')).toBeFalsy();

wrapper.simulate('dblclick');
expect(wrapper.state('inEdit')).toBeTruthy();
});

it("stays in edit mode as long as 'inEdit' is uncontrolled", () => {
const wrapper = shallow(<EditableTextLabel basic="Foo" />);
wrapper.setState({ inEdit: true });
wrapper.setProps({ icon: 'printer' });

expect(wrapper.state('inEdit')).toBeTruthy();
});

it("leaves edit mode on blur if 'inEdit' is uncontrolled", () => {
const wrapper = mount(<EditableTextLabel basic="foo" />);

wrapper.setState({ inEdit: true });
wrapper.find('input').simulate('blur');

expect(wrapper.state('inEdit')).toBeFalsy();
});

it("leaves edit mode on Esc if 'inEdit' is uncontrolled", () => {
const wrapper = mount(<EditableTextLabel basic="foo" />);

wrapper.setState({ inEdit: true });
wrapper.find('input').simulate('keydown', { keyCode: keycode('Escape') });

expect(wrapper.state('inEdit')).toBeFalsy();
});

it("does not go edit mode on double click if 'inEdit' is controlled", () => {
const wrapper = shallow(<EditableTextLabel basic="Foo" inEdit={false} />);
expect(wrapper.state('inEdit')).toBeFalsy();

wrapper.simulate('dblclick');
expect(wrapper.state('inEdit')).toBeFalsy();
});

// Double-touch simulation
it('triggers dblClick callback when touch twice with in 250ms', () => {
const handleDblClick = jest.fn();
const wrapper = shallow(<EditableTextLabel basic="foo" onDblClick={handleDblClick} />);

expect(handleDblClick).not.toHaveBeenCalled();

return new Promise((resolve) => {
wrapper.simulate('touchstart');
expect(wrapper.state('touchCount')).toBe(1);

setTimeout(() => {
wrapper.simulate('touchstart');
resolve();
}, 200);
}).then(() => {
expect(handleDblClick).toHaveBeenCalledTimes(1);
});
});

it('does not trigger dblClick callback when touch twice in over 250ms', () => {
const handleDblClick = jest.fn();
const wrapper = shallow(<EditableTextLabel basic="foo" onDblClick={handleDblClick} />);

expect(handleDblClick).not.toHaveBeenCalled();

return new Promise((resolve) => {
wrapper.simulate('touchstart');
setTimeout(() => {
wrapper.simulate('touchstart');
resolve();
}, 500);
}).then(() => {
expect(handleDblClick).not.toHaveBeenCalled();
});
});

0 comments on commit 7c6bb04

Please sign in to comment.