WIP
Carlos Martinez
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.
In this Spec I'm going to define a solution that will address this problem using the following approach:
Users will be able to edit multiple timers per 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
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.
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.
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) }
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 |
+-------------------------------------+
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 los metodos son
toggleSingleEntry
ytoggleAllEntries
para togglear solo una o togglear todos los entries