Technology
Building Boardly: The Drag 'n' Drop experience and some variants
Continuing our series on how we built Boardly, in this article we will talk about how we integrated Dragula, made it work with turbo streams, and set on a path to making Boardly mobile friendly.
Our app is starting to get noticed. We've only launched it about a week ago and already we've had a few thousand visits, 200+ boards created and 400+ tasks added. It is quite an experience, seeing something you've built gets so much attention and positive feedback.
We've also added a new feature to the Boards. Boards can now be made public. You can share them with your friends, co-workers, and the general public. It's a view-only feature and it can be used for things like sharing your progress with a client, displaying a roadmap for your project, and keeping your boss informed of the status of your work.
There are a few more features that we think could be useful and that we want to build. But we will make sure that the whole experience isn't made too complicated. Simplicity will always be the rule with Boardly.
For now, let's dive into how we built in the drag 'n' drop experience into Boardly.
Read the first article in the series: How we decided to try out Hotwire.
We've also added a new feature to the Boards. Boards can now be made public. You can share them with your friends, co-workers, and the general public. It's a view-only feature and it can be used for things like sharing your progress with a client, displaying a roadmap for your project, and keeping your boss informed of the status of your work.
There are a few more features that we think could be useful and that we want to build. But we will make sure that the whole experience isn't made too complicated. Simplicity will always be the rule with Boardly.
For now, let's dive into how we built in the drag 'n' drop experience into Boardly.
Read the first article in the series: How we decided to try out Hotwire.
Here be Dragons
Drag 'n' drop is easy to do now. Just pick a library, download it, install it and connect to your app. Right? Yes, and no. The first question you must answer is: which library should you use?
Here's the list of the drag and drop libraries that we've tried while building TeamHQ:
- jQuery's Drag and Drop module
- Draggable.js
- Sortable
- Html5sortable
- Dragula.js
We started with jQuery and quickly realized how slow and outdated it was. It was basically unusable. Next was draggable.js. And it was not much better. The promise of Shopify's quality code and quite frankly an amazing demo page didn't compare well with the documentation and lack of easy examples. So instead of spending time trying to dig through the extensive docs, we decided to skip to Sortable.
Sorting out Sortable
Sortable is by far the king of drag 'n' drop libraries. It is used by many projects and has a lively community of developers. Our experience with it started great. It was easy to understand, plenty of examples and it worked out the box on our test pages. We implemented it for drag and drop and life was great.
Then our test users started to notice that TeamHQ was slowing down. Adding tasks took forever and dragging and dropping things was getting harder and harder. It was as if our draggable elements gained weight over time.
When tried using the app, we didn't see the same problems. Everything was snappy, sortable worked, adding tasks was a breeze. We just couldn't figure out what the problem was.
It happened by luck. We had some time off for a long weekend and when we came back our test script added 100 tasks to one of the boards. We tried using it and it was so slow. It took forever. Refreshing of the page worked, but then after dragging and dropping items around, sorting started to slow down again.
It turned out that every time we added a new task to our draggable board or every time we updated the task (new HTML element), we reinitialized the sortable library.
var el = document.getElementById('importat_tasks_board'); var sortable = Sortable.create(el);
We had to do that because new or replaced tasks couldn't be sorted. The solution to re-initialize the sortable worked but it created a new problem.
Every time we reinitialized the sortable, to kept attaching new javascript events to the same elements. Events for dragging and dropping, events for updating sort rank, etc. Tasks ended up having 10-20 events attached that did the same thing, and the longer you stayed on the page, the more events were added. We called it anonymous events hell. Sortable uses anonymous functions to handle events and they weren't being cleared out properly after sortable was reinitialized.
Sorting tasks would crash the server because the main sortable function would be called too many times. If we added 15 tasks, for example, our sort REST call would be called 15 times every time we tried moving a task.
In hindsight, there might have been a better way to implement this, but we needed a better solution quickly.
Next on the docket was Html5sortable. A great little library solved our problems with too many events being added. And we were going to stick with it.
Subduing The Dragon
But then we found Dragula.
It was super easy to setup. It worked. It gave us no issues with events. We could create and recreate Dragula elements at will without creating unnecessary events. And Dragula was fast. Very fast.
This is the library that we used to build drag and drop boards in TeamHQ and this is what we decided to use for Boardly.
In Boardly, each board has two sections that implement our drag and drop library. The Doing Section and the Todo Section.
We make Dragula work, we set up a StimulusJS Controller, and added the controller to our todo_bucket and doing_bucket elements.
<div id="doing_bucket" data-controller="boards--tasks-sortable"> ... </div> <div id="todo_bucket" data-controller="boards--tasks-sortable"> ... </div>
Then, we initialized Dragula in the connect method of the controller.
connect() { if (this.element.id == "todo_bucket") { var drake = dragula({ moves: function (el, source, handle, sibling) { return !el.classList.contains("blank-slate") } }) drake.containers.push(document.getElementById("doing_bucket")); drake.containers.push(document.getElementById("todo_bucket")); drake.on("drop", this.sortAndUpdateStatusView) } }
The code is simple, if todo_bucket is present, run the code to setup Dragula. Dragula works by pushing container elements onto its container array and making child elements draggable and sortable.
drake.containers.push(document.getElementById("doing_bucket")); drake.containers.push(document.getElementById("todo_bucket"));
It quite easily handles drag ' drop actions within containers and between the lists. And to make changes permanent, you just have to provide a function for the 'drop' event.
drake.on("drop", this.sortAndUpdateStatusView)
This worked well for boards with existing tasks. You could drag and drop, and sort without issues. However, adding tasks didn't work because Dragula wasn't aware of the new task that was added. Updating a task by replacing its HTML didn't work either.
One solution was to recreate a Dragula element after a task was added, removed, or updated. The same thing that we did with Sortable. This worked at first, but after updating the whole container bucket via TurboStream it stopped working. This was because our Dragula instance was created inside the StimulusJS controller instance.
Because we had two containers (todo_bucket and doing_bucket) that needed to work with each other, replacing one via TurboStream meant that we lost the reference to the other and we couldn't drag and drop between the buckets.
The solution, a bit of hack, was to refer to the Dragula instance globally. This way we could tear down and rebuild draggable buckets every time we updated HTML and not worry about losing interconnectivity.
This was our new controller setup:
var $drake = dragula({ moves: function (el, source, handle, sibling) { return !el.classList.contains("blank-slate") && !el.classList.contains("no-drag") } }) $drake.on("drop", TasksManager.sortAndUpdateStatusView) import { Controller } from "stimulus" export default class extends Controller { connect() { $drake.containers.splice(0, 2) $drake.containers.push(document.getElementById("doing_bucket")); $drake.containers.push(document.getElementById("todo_bucket")); } }
Our Dragula instance was created outside of the controller. $drake variable could then be used by the controller without re-instantiating the instance. (We prefixed with $ to make it easy to understand that it was a global variable)
Now we could rebuild containers every time we replaced HTML and reconnected the controller. When a new connect() was invoked, we cleared out the existing containers attached to Dragula.
$drake.containers.splice(0, 2)
Then we added those containers again, in effect, re-initializing the Dragula instance.
$drake.containers.push(document.getElementById("doing_bucket")); $drake.containers.push(document.getElementById("todo_bucket"));
We built a separate javascript class to handle the actual sorting and updating process:
$drake.on("drop", TasksManager.sortAndUpdateStatusView)
While this may be a hacky solution, but it has worked out well. There probably is a better method and we'll spend some time in the future improving it.
Streaming In Turbo
Having the drag and drop situation worked out, the next step was to ensure that drag 'n' drop events and task-related actions were streamed to other connected browsers.
Adding a task and making it stream properly was easy. New tasks were appended to the todo bucket. This was done in the Task model using the broadcasts_to helper:
broadcasts_to ->(task) { task.board }, inserts_by: :append, target: "todo_bucket"
Updating a task happened via the same route, though we started with update.turbo_stream.erb file first, later removing it in favour of the general broadcast_to method.
<%= turbo_stream.replace dom_id(@task) do %> <%= render "tasks/task", task: @task %> <% end %>
For boards, it was a bit of a different issue. Because our board has three separate sections, updating it via the simple broadcasts_to method wouldn't work. We struggled, trying to figure out how best to do this and in the end, the solution to this was simple:
after_update_commit do broadcast_replace_later_to self, target: "todo_bucket_component", partial: "boards/shared/todo_bucket", locals: { board: self } broadcast_replace_later_to self, target: "doing_bucket_component", partial: "boards/shared/doing_bucket", locals: { board: self } broadcast_replace_later_to self, target: "done_bucket_component", partial: "boards/shared/done_bucket", locals: { board: self } end
After the update, we'd issue a call to replace all three separate sections by specifying different partials for each.
To make sure that boards are updated when tasks are drag and dropped between different sections, we had to touch the board in the Task model.
belongs_to :board, touch: true
This triggered the update_commit callback on the board and board turbo_stream update.
Here we ran into the danger of touching the board on every task update. When sorting tasks, the standard method to persist the new order is to send all of the IDs within the container in the proper order and then update the rank column.
With touch: true this means that if there are 15 tasks in a container then there will be 15 touches to the board and 15 turbo_stream updates. That's not a good thing. Every time we moved a task, there were 15 board updates sent back to the browser. It crashed our dev servers.
The solution was to use update_column instead of update and touch the board on the last task in a list. For example, you could do something like this:
def update last = nil rankable_ids.split(",").each_with_index do |id, index| last = find_and_update_rank(id, index + 1) end if last.present? && last.board.present? last.board.touch end end def find_and_update_rank(id, rank) task = Task.find(id) task&.update_column(:rank, rank) return task end
Notice how we use update_column to update the order for each task, but on the last task, we issue a simple board.touch call. This works whether you have 1 task or 100. The last one will always issue the call to the board and trigger a turbo stream update.
We're still working out the kinks and one of the issues with turbo_stream is that it can be updating the same page multiple times after one update. It is a matter of architectural design more than the downside of the framework.
Small trouble with variants
Variants are amazing, not the virus ones, but the ones built into Rails. For example, with a variant, you can send back a mobile version of your template to a client using your app from a phone.
To detect if the user is using a mobile browser you can create a simple concern to detect what browser is connected (using browser gem).
module MobileDetector extend ActiveSupport::Concern included do before_action :detect_device_variant end private def detect_device_variant request.variant = :phone if browser.device.mobile? end end
Then you can create mobile-only views that will be rendered automatically using a variant template.
index.html+phone.erb
<div class="col-12"> <%= @board.name %> </div>
Versus:
index.html.erb
<div class="col-8"> <%= @board.name %> </div> <div class="col-4"> Total Tasks: 12 ... </div>
This method helps you create views for web and mobile and not worry about hiding and showing information using CSS and other tricks.
The trouble with this approach comes when you want to use turbo_stream updates from the model. Consider the code for our Board model.
after_update_commit do broadcast_replace_later_to self, target: "todo_bucket_component", partial: "boards/shared/todo_bucket", locals: { board: self } broadcast_replace_later_to self, target: "doing_bucket_component", partial: "boards/shared/doing_bucket", locals: { board: self } broadcast_replace_later_to self, target: "done_bucket_component", partial: "boards/shared/done_bucket", locals: { board: self } end
This code broadcasts HTML partial back to connected clients after a board is updated. In normal circumstances, this works out great. Turbo Stream just processes boards/shared/_todo_bucket.html.erb and sends the HTML back.
If there is a _todo_bucket.html+phone.erb, currently turbo_stream outside of the controllers ignores it. This is because turbo_stream in the model has no idea if the connected client is on the normal web browser or mobile.
This also has the same effect if the update is coming from a background job and is then pushed to connected clients. The background job invoking this has no idea which clients are mobile and which are desktop.
The most workable solution is to not use variants for now and make sure that your partials are mobile-friendly. While this works, we think that variants are great and hope that Hotwire will have something that will make it work with variants.
We ended up making our partials as mobile-friendly as possible, using Bootstrap's awesome helpers.
Perhaps the mysterious Strada will have the solution to this issue soon.
Conclusion
Overall, Hotwire has worked out decently well, there are bugs and kinks to work out but we are sure soon it will become a solid tool to build reactive web applications.
Onwards and upwards from here.
If you like to try out Boardly, go to https://boardlyapp.com. It's easy to signup and gives it a go. If you like it and have a product hunt account, we would be grateful if you could upvote it.