Skip to content

Instantly share code, notes, and snippets.

@carlows
Last active August 1, 2016 13:13
Show Gist options
  • Save carlows/3a1e7c90d9d1c49e3bfd6ffe49e50661 to your computer and use it in GitHub Desktop.
Save carlows/3a1e7c90d9d1c49e3bfd6ffe49e50661 to your computer and use it in GitHub Desktop.
Bulk Actions Denbora Spec

Bulk Actions

Disclaimer

WIP

Author

Carlos Martinez

Problem Overview

In order to enhance the user experience when using Denbora, we need a way to allow users to edit multiple timers at once, or even delete them as needed.

Approach and considerations

In this Spec I'm going to define a solution that will address this problem using the following approach:

Isolated edition

Users will be able to edit multiple timers per day

day

This means, users can select one or more TimeEntries for an specific day, and they will be able to edit entries only for the action attached to the day in those entries

dropdown

To explain this further, if users select entries for multiple days, they will only update or delete entries within the day for the action dropdown they selected.

Fields to edit in bulk mode

When editing multiple time entries, users will only be able to edit the following fields:

  • Description for the entries (this will update the description of all the entries selected).
  • The day they were created.
  • The project they are assigned to.

Technical Specification

Server side bulk actions

To be able to edit multiple items o add the bulk actions, I need to define two new routes:

resources :time_entries do
  collection do
    put :bulk, to: 'time_entries#bulk_update'
    delete :bulk, to: 'time_entries#bulk_delete'
  end
end

And two new actions in the time_entries_controller, that will allow us to edit/delete multiple items at once:

  before_action :parse_entry_date, only: [:update, :bulk_update]

  def bulk_update
    entry_ids = params[:ids]

    updated = TimeEntry.bulk_update(entry_ids, time_entry_params, current_user.id)

    respond_to do |format|
      if updated
        format.json { render json: { items_updated: updated, entries: get_latest_entries }, status: :ok }
      else
        format.json { render json: { error: "Couldn't update the entries" }, status: :unprocessable_entity }
      end
    end
  end

  def bulk_delete
    entry_ids = params[:ids]

    deleted = TimeEntry.bulk_delete(entry_ids, current_user.id)

    respond_to do |format|
      if deleted
        format.json { render json: { items_deleted: deleted, entries: get_latest_entries }, status: :ok }
      else
        format.json { render json: { error: "Couldn't delete the entries" }, status: :unprocessable_entity }
      end
    end
  end

  private

  def parse_entry_date
    date_param = params[:time_entry][:date]
    date_param = Time.strptime(date_param, "%Y-%m-%d").in_time_zone if date_param
  end

I extracted the code that updates/deletes the entries to the time_entries model, this will allow us to test it much easier:

  def self.bulk_update(entry_ids, data, user_id)
    begin
      TimeEntry.transaction do
        entries_to_update = TimeEntry.where("user_id = ? AND id IN (?)", user_id, entry_ids)

        entries_to_update.each do |entry|
          entry.update!(data)
        end
      end

      return true
    rescue Exception => error
      return false
    end
  end

  def self.bulk_delete(entry_ids, user_id)
    begin
      TimeEntry.transaction do
        entries_to_delete = TimeEntry.where("user_id = ? AND id IN (?)", user_id, entry_ids)

        entries_to_delete.each do |entry|
          entry.destroy!
        end
      end

      return true
    rescue Exception => error
      return false
    end
  end

To avoid repeating the where clause, I'm setting the following scope in the TimeEntry model:

scope :find_within, -> (user_id, entry_ids) { where("user_id = ? AND id IN (?)", user_id, entry_ids) }

Client side integration

To integrate this functionality we will need to modify a couple of existing components.

The flow we'll follow for this goes like this:

  • Each TimeEntry component has a checkbox
  • When the user clicks the checkbox for a TimeEntry, the entry id gets pushed to the parent component
  • TimeEntryGroup (which represents a day and holds multiple entries) gets the id and it gets stored in an array of selected_ids
  • The TimeEntryGroup component will include another checkbox in its title together with a dropdown to allow users to edit and delete multiple entries.
  • When the user clicks an action in the dropdown, we will execute a callback in the Timer component that will either open the edition modal or send a request to delete the entries in the server.
         +-------------+
         |             |                       +--------------------+
  +------+    Timer    |                       |   TimeEntriesList  |
  |      |             |                       +---------+----------+
  |      +-------------+                                 |
  |                                                      |
+-v---------------------------------+          +---------v---------+
| Delete Entries in Server Function +--------->+ Passes down props |
+-+---------------------------------+          +^-------+----------+
  |                                             |       |
+-v---------------------------------+           |       |
|  Open modal for edition Function  +----+------+       |
+-+---------------------------------+    ^              |
  |                                      |              |
+-v---------------------------------+    |              |
|  Edit Entries in Server Function  +----+              |
+-----------------------------------+          +--------v-------+
                                               | TimeEntryGroup |
                                               +--------+-------+
                                                        |
                                                        |
                                        +---------------v--------------------------+
                                        |  Holds an array of selected Entries (IDS)|
                                        +---------------+--------------------------+
                                                        |
                                                        |
                                                        |
                                                 +------v------+
                                                 |  TimeEntry  |
                                                 +------+------+
                                                        |
                                                        |
                                        +---------------v---------------------+
                                        |  Checkbox holds the selected state  |
                                        +-------------------------------------+

Implementation

Starting from the simplest point, the TimeEntry component needs to store its selected state with a controlled checkbox:

class TimeEntry {
  onCheckboxChange() {
    props.toggleSingleEntry(props.entry.id);
    setState({ selected: !selected });
  }

  render() {
   return (<input type="checkbox" checked={state.selected} onChange={onCheckboxChange} />);
  }
}

We also need to update the parent TimeEntryGroup component array of selected ids.

The TimeEntryGroup component needs to hold the ids of the selected entries, it also needs its own checkbox with a dropdown that will display the actions to the user.

We will include a way to toggle a single entry, adding it to the array of ids, and toggling all the day entries, adding all of them to the list.

class TimeEntryGroup {
  constructor() {
    state = {
      selectedEntries: []
    };
  }

  toggleSingleEntry(id) {
    let currentIds = state.selectedEntries;
    let item = _.find(currentIds, (item) => { return item === id; });

    if (item) {
      let index = _.indexOf(state.selectedEntries, item);
      currentIds.splice(index, 1);
    } else {
      currentIds.push(id);
    }

    setState({
      selectedEntries: currentIds
    });
  }

  toggleAllEntries() {
    let entries =
      selectedEntries.length !== props.entries.length
        ? _.map(props.entriesData, (entry) => { return entry.id; })) : [];

    setState({
      selectedEntries: entryids
    });
  }

  render() {
    return (<DropdownCheckbox toggleSelectAll={toggleAllEntries} editEntries={props.editEntries} deleteEntries={props.deleteEntries} /> <TimeEntry toggle={toggleSingle} />);
  }
}

The DropdownCheckbox component will include a checkbox and a dropdown with actions, they will trigger callbacks sent from the TimeEntryGroup components:

class DropdownCheckbox {
  toggleSelect() {
    props.toggleAllEntries();
    setState({ selected: !selected });
  }

  render() {
    return (
      <div>
        <input type="checkbox" checked={state.selected} onChange={toggleSelect} />
        <ul>
          <li onClick={props.editEntries}>Edit</li>
          <li onClick={props.deleteEntries}>Delete</li>
        </ul>
      </div>
    );
  }
}

With these components setup we now need to communicate with the backend, as our Timer component is holding the global state, we will setup two methods to communicate with the API through ajax.

Also, we need to create a new modal for timer edition.

class Timer {
  constructor() {
    state: { showBulkModal: false, selectedDay: "", entryIdstoModify: [] };
  }

  handleDeleteEntries(ids) {
    deleteEntriesInServer(ids);
  }

  handleEditEntries(data) {
    editEntriesInServer(state.entryIdstoModify, data);
    setState
  }

  openEditEntryModal(ids, day) {
    setState({ showBulkModal: true, entryIdstoModify: ids, selectedDay: day });
  }

  clearSelectedEntries() {
    setState({ entryIdstoModify: [], day: "", showBulkModal: false });
  }

  render() {
    return (
      <TimeEntryBulkModal editEntries={handleEditEntries} clearSelection={clearSelectedEntries} />
      <TimeEntryList deleteEntries={handleDeleteEntries} editEntries={openEditEntryModal} />
    );
  }
}

When we use the dropdown menu and use the edit action, we will populate entryIdstoModfy with the ids selected for that day. We need to clear this array when the action is completed (items deleted or the modal is closed).

When users open the modal, it will have an empty state, we will only display the selected day. This is because descriptions are not supposed to be equal between entries and if the user wants to set a new description, we will set the new description to all the entries.

class TimeEntryBulkModal {
  constructor() {
    state = {
      day: props.day,
      selectedProjectId: ''
    };
  }

  getData() {
    let description = refs.description.value;
    let projectId = state.selectedProjectId;
    let day = state.day;

    let data = {};
    if (!!description) {
      data.description = description;
    }

    if (!!projectId) {
      data.projectId = projectId;
    }

    if (day != props.day) {
      data.day = day;
    }

    return data;
  }

  handleEditEntries() {
    let data = this.getData();
    props.clearSelection();
    props.handleEditEntries(data);
  }

  render() {
    return (
      <ReactModal>
        <input name="description" />
        <SelectProject />
        <DatePicker />

        <button onClick={handleEditEntries}>Edit</button>
      </ReactModal>
    );
  }
}

I separated the getData function as it will do a simple validation and will return an object only with the keys that were modified by the user. This way it will be easier to test.

@orlando
Copy link

orlando commented Jul 31, 2016

@carlows no vi referencias a cuando le das al checkbox en el TimeEntryGroup, pero queremos seleccionar todos los TimeEntries de ese dia http://recordit.co/o68xsg18AN

@orlando
Copy link

orlando commented Jul 31, 2016

Tambien un metodo que se llama toggleSingleEntry y otro toggleSelectedEntry, me imagino que son el mismo y algun tiene un typo

@orlando
Copy link

orlando commented Jul 31, 2016

En general looks good, vamos a go over esto mañana. Thanks! @carlows

@carlows
Copy link
Author

carlows commented Aug 1, 2016

@orlando los metodos son toggleSingleEntry y toggleAllEntries para togglear solo una o togglear todos los entries

@carlows
Copy link
Author

carlows commented Aug 1, 2016

Oh, ya vi, si era un typo

@orlando
Copy link

orlando commented Aug 1, 2016

@carlows todo se ve bien, 👌 vamos a darle al implementation.

Recuerda poner esto dentro del folder docs/specs

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