Skip to content

Instantly share code, notes, and snippets.

@carlows
Last active August 12, 2016 00:14
Show Gist options
  • Save carlows/cbe070b3d2d90d5a73247f92ed862f86 to your computer and use it in GitHub Desktop.
Save carlows/cbe070b3d2d90d5a73247f92ed862f86 to your computer and use it in GitHub Desktop.

Inline editing time entries

Disclaimer

WIP

Author

Carlos Martinez.

Problem Overview

Users in Denbora are able to edit entries created in the timer through a Modal, it is required to modify this behaviour and allow users to edit them inline, when they click any of the fields, and input should appear for them to edit the data in that entry.

The fields that are going to be editable are:

  • Description: through a text input field.
  • Project: through a select input field.
  • Time and duration: through a dropdown with fields for duration, start time, end time and a datepicker.

We will also need to implement the following functionality:

  • When a field in a time entry is clicked, we enable the edit mode for that field
  • If the user presses tab, we can move between all of the fields we can edit (only on desktop).
  • If the user presses scape, changes are not saved.
  • If the user presses Enter inside or outside an entry, changes are persisted.
  • If the user makes some changes and then clicks outside of the field, changes are persisted.

Constraints

  • We can edit only one entry at a time.

Technical Specification

Editable Fields

This component will be in charge of selecting/deselecting fields, it will attach an event to the document that will set the active state to false whenever the user clicks outside of the component.

class EditableField extends React.Component {
  constructor(props) {
    super(props);

    this.onClickOnDocument = this.onClickOnDocument.bind(this);
    this.onEscapeKeyPressed = this.onEscapeKeyPressed.bind(this);
    this.onEnterKeyPressed = this.onEnterKeyPressed.bind(this);
  }

  onClick() {
    this.props.onActive();
  }

  onEnterKeyPressed(msg, data) {
    if(this.props.active) {
      this.props.onUnactive(true);
    }
  }

  onEscapeKeyPressed(msg, data) {
    if(this.props.active) {
      this.props.onUnactive(false);
    }
  }

  onClickOnDocument(msg, event) {
    const area = this.refs.field;

    if (!area.contains(event.target) && this.props.active) {
      this.props.onUnactive(true);
    }
  }

  componentDidMount() {
    this.clickDocumentEventToken = PubSub.subscribe('click.document', this.onClickOnDocument);
    this.enterKeyEventToken = PubSub.subscribe('keypressed.enter', this.onEnterKeyPressed);
    this.escapeKeyEventToken = PubSub.subscribe('keypressed.escape', this.onEscapeKeyPressed);
  }

  componentWillUnmount() {
    PubSub.unsubscribe(this.clickDocumentEventToken);
    PubSub.unsubscribe(this.enterKeyEventToken);
    PubSub.unsubscribe(this.escapeKeyEventToken);
  }

  render() {
    return (
      <div onClick={this.onClick.bind(this)} className="editable-input-group" ref="field">
        {this.props.children}
      </div>
    );
  }
}

We are returning a boolean to the onUnactive callback that will communicate to the parent component if we should save or not the entry depending on the event that was triggered by the user.

We're handling the ESCAPE key event and the ENTER key event, on ESCAPE we set the component to unactive and return false as a flag to not save changes. Same with ENTER, but we return true to save the changes.

For events, I'm adding an EventHandler component responsible of binding the events to the document and pushing the events to the suscribers:

class EventHandler extends React.Component {
  constructor(props) {
    super(props);

    this.handleDocumentClick = this.handleDocumentClick.bind(this);
    this.handleDocumentKeyPressed = this.handleDocumentKeyPressed.bind(this);
  }

  handleDocumentClick(event) {
    PubSub.publish('click.document', event);
  }

  handleDocumentKeyPressed(event) {
    const ESCAPE_KEYCODE = 27;
    const ENTER_KEYCODE = 13;

    if(event.keyCode === ESCAPE_KEYCODE) {
      PubSub.publish('keypressed.escape', true);
    } else if (event.keyCode === ENTER_KEYCODE) {
      PubSub.publish('keypressed.enter', true);
    }
  }

  componentDidMount() {
    document.addEventListener('click', this.handleDocumentClick, false);
    document.addEventListener('touchend', this.handleDocumentClick, false);
    document.addEventListener('keyup', this.handleDocumentKeyPressed, false);
  }

  componentWillUnmount() {
    document.removeEventListener('click', this.handleDocumentClick, false);
    document.removeEventListener('touchend', this.handleDocumentClick, false);
    document.addEventListener('keyup', this.handleDocumentKeyPressed, false);
  }

  render() {
    return false;
  }
}

This component is attached to the root Timer component.

Time Entry component

class TimeEntry extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      description: this.props.entryData.description,
      projectId: this.props.entryData.project_id,
      projectName: this.props.entryData.project_name,
      active: false
    };

    this.resetState = this.resetState.bind(this);
  }

  onActive() {
    this.setState({ active: true });
  }

  onUnactive(shouldSave) {
    if(shouldSave) {
      this.saveEntry();
    } else {
      this.resetState();
    }

    this.setState({ active: false });
  }

  saveEntry() {
    const { description, projectId } = this.state;
    const descriptionChanged = this.props.entryData.description !== description;
    const projectChanged = this.props.entryData.project_id !== projectId;
    const dataChanged = descriptionChanged || projectChanged;

    if (dataChanged) {
      this.props.editEntry(Object.assign({}, {
        description: description,
        projectId: projectId,
        id: this.props.entryData.id
      }));
    }
  }

  resetState() {
    this.setState({
      description: this.props.entryData.description,
      projectId: this.props.entryData.project_id,
      projectName: this.props.entryData.project_name
    });
  }

  render() {
    const entry = this.props.entryData;

    return (
      <div>
        <div>
          <EditableField active={this.state.active} onActive={this.onActive.bind(this)} onUnactive={this.onUnactive.bind(this)}>
            <div>
              <div>
                <EditableTextInput entryActive={this.state.active} onChange={this.onChangeDescription.bind(this)} text={ this.state.description } />
              </div>

              <div>
                <EditableSelectInput entryActive={this.state.active} onChange={this.onChangeProject.bind(this)} projectId={this.state.projectId} text={this.state.projectName} projects={this.props.projects} />
              </div>

              <div>
                <EditableTimerProperties onCancel={() => {this.onUnactive(false)}} onSave={() => { this.onUnactive(true)} } entryActive={this.state.active}  entryDuration={entry.duration} startTime={entry.start_time} endTime={entry.end_time} timezone={entry.timezone} />
              </div>
            </div>
          </EditableField>
        </div>
      </div>
    );
  }
}
class EditableTextInput extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      active: true
    };
  }

  inputChange(event) {
    const text = this.refs.text.value;
    this.props.onChange(text);
  }

  render() {
    const { text } = this.props;

    const inputClasses = classNames(
      'form-control',
      'denbora-form-input',
      'denbora-form-input-editable',
      { 'hidden-item': !this.props.entryActive }
    );

    return (
      <div>
        <input type="text" value={text} className={inputClasses} ref="text" onChange={this.inputChange.bind(this)} />
        <div>{text}</div>
      </div>
    );
  }
}

The hidden class will hide/show the field, and the input-editable class will set the position to absolute to display the input inline.

For the select input we do the same:

class EditableSelectInput extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      active: true
    };
  }

  inputChange(event) {
    const selectedProjectId = event.nativeEvent.target.value;
    this.props.onChange(selectedProjectId);
  }

  render() {
    const { text } = this.props;
    const { entryActive } = this.props;

    return (
      <div>
        <SelectProject ref="selectInput" projects={this.props.projects} inputError={false} selectedProjectId={this.props.projectId} hidden={!entryActive} editableInput={true} onSelectChange={this.inputChange.bind(this)} />
        <div>{text}</div>
      </div>
    );
  }
}

Durations dropdown

To edit the entry time properties (start time, end time, duration and day) we need to setup a dropdown that will include all these fields, first, we will setup the component the same way as the others:

class EditableTimerProperties extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      active: true
    };
  }

  render() {
    const { entryActive } = this.props;
    const { entryDuration, startTime, endTime, timezone } = this.props;
    const formattedEntryDuration = TimeFormatHelper.getFormattedEntryDuration(entryDuration);
    const formattedStartTime = TimeFormatHelper.formatTime(startTime, timezone);
    const formattedEndTime = TimeFormatHelper.formatTime(endTime, timezone);

    const btnClasses = classNames(
      { "hidden-item": !this.props.entryActive }
    );

    return (
      <div>
        <div>
          <DropdownDuration ref="dropdown" active={entryActive} startTime={startTime} endTime={endTime} duration={entryDuration} />
          { formattedEntryDuration }
        </div>

        <div>
          { formattedStartTime } - { formattedEndTime }
          <div>
            <button onClick={this.props.onCancel}>Cancel</button>
            <button onClick={this.props.onSave}>Save</button>
          </div>
        </div>
      </div>
    );
  }
}

And the dropdown will display all the inputs we need:

class DropdownDuration extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      startTime: this.props.startTime,
      endTime: this.props.endTime,
      duration: this.props.duration,
      active: false
    };

    this.onClickOnDocument = this.onClickOnDocument.bind(this);
  }

  onFocus() {
    this.setState({ active: true });
  }

  onClickOnDocument(msg, event) {
    const area = this.refs.field;

    if (!area.contains(event.target) && this.props.active) {
      this.setState({ active: false });
    }
  }

  componentDidMount() {
    this.clickDocumentEventToken = PubSub.subscribe('click.document', this.onClickOnDocument);
  }

  componentWillUnmount() {
    PubSub.unsubscribe(this.clickDocumentEventToken);
  }

  render() {
    const { startTime, endTime, duration } = this.state;

    const dropdownClasses = classNames(
      'denbora-durations',
      { 'hidden-item': !this.props.active }
    );

    const dropdownItemsClasses = classNames(
      'denbora-durations-dropdown',
      { 'hidden-dropdown': !this.state.active }
    );

    return (
      <div ref="field" className={dropdownClasses}>
        <input ref="duration" className="form-control denbora-form-input" type="text" onFocus={this.onFocus.bind(this)} value={duration} />

        <div className={dropdownItemsClasses}>
          <input type="text" className="form-control denbora-form-input" value={startTime} />
          <input type="text" className="form-control denbora-form-input" value={endTime} />
          <Datepicker />
        </div>
      </div>
    );
  }
}
@orlando
Copy link

orlando commented Aug 10, 2016

mmm en EditableField estamos usando uno por field right?, no creo que ese sea un buen approach (tipo estamos triggerando el mismo evento varias veces, en fields que no estan activos)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment