Skip to content

Commit e3e57a6

Browse files
Merge pull request #1927 from basecamp/speed-up-sorting
Faster D&D by optimistically inserting dropped cards
2 parents fee376b + 9639e07 commit e3e57a6

File tree

19 files changed

+148
-52
lines changed

19 files changed

+148
-52
lines changed

app/assets/stylesheets/blank-slates.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,8 @@
3939
display: none;
4040
}
4141
}
42+
43+
.cards__list:has(> :not(.blank-slate)) .card--hide-unless-empty {
44+
display: none;
45+
}
4246
}

app/controllers/boards/columns/not_nows_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ class Boards::Columns::NotNowsController < ApplicationController
22
include BoardScoped
33

44
def show
5-
set_page_and_extract_portion_from @board.cards.postponed.reverse_chronologically.with_golden_first.preloaded
5+
set_page_and_extract_portion_from @board.cards.postponed.latest.preloaded
66
fresh_when etag: @page.records
77
end
88
end
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
class Public::Boards::Columns::NotNowsController < Public::BaseController
22
def show
3-
set_page_and_extract_portion_from @board.cards.postponed.reverse_chronologically.with_golden_first
3+
set_page_and_extract_portion_from @board.cards.postponed.latest
44
end
55
end

app/helpers/cards_helper.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
module CardsHelper
2-
def card_article_tag(card, id: dom_id(card, :article), **options, &block)
2+
def card_article_tag(card, id: dom_id(card, :article), data: {}, **options, &block)
33
classes = [
44
options.delete(:class),
55
("golden-effect" if card.golden?),
66
("card--postponed" if card.postponed?),
77
("card--active" if card.active?)
88
].compact.join(" ")
99

10+
data[:drag_and_drop_top] = true if card.golden? && !card.closed? && !card.postponed?
11+
1012
tag.article \
1113
id: id,
1214
style: "--card-color: #{card.color}; view-transition-name: #{id}",
1315
class: classes,
16+
data: data,
1417
**options,
1518
&block
1619
end

app/helpers/columns_helper.rb

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,16 @@ def button_to_set_column(card, column)
1010
data: { turbo_frame: "_top" }
1111
end
1212

13-
def column_tag(id:, name:, drop_url:, collapsed: true, selected: nil, data: {}, **properties, &block)
13+
def column_tag(id:, name:, drop_url:, collapsed: true, selected: nil, card_color: "var(--color-card-default)", data: {}, **properties, &block)
1414
classes = token_list("cards", properties.delete(:class), "is-collapsed": collapsed)
1515

1616
data = {
1717
drag_and_drop_target: "container",
1818
navigable_list_target: "item",
1919
column_name: name,
20-
drag_and_drop_url: drop_url
20+
drag_and_drop_url: drop_url,
21+
drag_and_drop_css_variable_name: "--card-color",
22+
drag_and_drop_css_variable_value: card_color
2123
}.merge(data)
2224

2325
data[:action] = token_list(
@@ -28,7 +30,8 @@ def column_tag(id:, name:, drop_url:, collapsed: true, selected: nil, data: {},
2830

2931
tag.section(id: id, class: classes, tabindex: "0", "aria-selected": selected, data: data, **properties) do
3032
tag.div(class: "cards__transition-container", data: {
31-
controller: "navigable-list",
33+
controller: "navigable-list css-variable-counter",
34+
css_variable_counter_property_name_value: "--card-count",
3235
navigable_list_supports_horizontal_navigation_value: "false",
3336
navigable_list_prevent_handled_keys_value: "true",
3437
navigable_list_auto_select_value: "false",

app/javascript/controllers/bubble_controller.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,18 @@ export default class extends Controller {
2828
}
2929
}
3030

31+
morphed({target}) {
32+
if (this.element === target) {
33+
this.update()
34+
}
35+
}
36+
3137
get #hasEntropy() {
3238
return this.#entropyCleanupInDays < this.entropyValue.daysBeforeReminder
3339
}
3440

3541
get #entropyCleanupInDays() {
36-
this.entropyCleanupInDays ??= signedDifferenceInDays(new Date(), new Date(this.entropyValue.closesAt))
37-
return this.entropyCleanupInDays
42+
return signedDifferenceInDays(new Date(), new Date(this.entropyValue.closesAt))
3843
}
3944

4045
#showEntropy() {
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
import { debounce } from "helpers/timing_helpers"
3+
4+
export default class extends Controller {
5+
static targets = [ "item", "counter" ]
6+
static values = {
7+
propertyName: String,
8+
maxValue: { type: Number, default: 20 }
9+
}
10+
11+
initialize() {
12+
this.#updateCounter = debounce(this.#updateCounter.bind(this), 50)
13+
}
14+
15+
connect() {
16+
this.#updateCounter()
17+
}
18+
19+
itemTargetConnected() {
20+
this.#updateCounter()
21+
}
22+
23+
itemTargetDisconnected() {
24+
this.#updateCounter()
25+
}
26+
27+
#updateCounter = () => {
28+
if (!this.hasCounterTarget) return
29+
30+
const count = Math.min(this.itemTargets.length, this.maxValueValue)
31+
this.counterTarget.style.setProperty(this.propertyNameValue, count)
32+
}
33+
}

app/javascript/controllers/drag_and_drop_controller.js

Lines changed: 73 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,44 +16,54 @@ export default class extends Controller {
1616
await nextFrame()
1717
this.dragItem = this.#itemContaining(event.target)
1818
this.sourceContainer = this.#containerContaining(this.dragItem)
19+
this.originalDraggedItemCssVariable = this.#containerCssVariableFor(this.sourceContainer)
1920
this.dragItem.classList.add(this.draggedItemClass)
2021
}
2122

2223
dragOver(event) {
2324
event.preventDefault()
25+
if (!this.dragItem) { return }
26+
2427
const container = this.#containerContaining(event.target)
2528
this.#clearContainerHoverClasses()
2629

2730
if (!container) { return }
2831

2932
if (container !== this.sourceContainer) {
3033
container.classList.add(this.hoverContainerClass)
34+
this.#applyContainerCssVariable(container)
35+
} else {
36+
this.#restoreOriginalDraggedItemCssVariable()
3137
}
3238
}
3339

3440
async drop(event) {
35-
const container = this.#containerContaining(event.target)
41+
const targetContainer = this.#containerContaining(event.target)
3642

37-
if (!container || container === this.sourceContainer) { return }
43+
if (!targetContainer || targetContainer === this.sourceContainer) { return }
3844

3945
this.wasDropped = true
46+
this.#increaseCounter(targetContainer)
4047
this.#decreaseCounter(this.sourceContainer)
48+
4149
const sourceContainer = this.sourceContainer
42-
await this.#submitDropRequest(this.dragItem, container)
43-
this.#reloadSourceFrame(sourceContainer);
50+
this.#insertDraggedItem(targetContainer, this.dragItem)
51+
await this.#submitDropRequest(this.dragItem, targetContainer)
52+
this.#reloadSourceFrame(sourceContainer)
4453
}
4554

4655
dragEnd() {
4756
this.dragItem.classList.remove(this.draggedItemClass)
4857
this.#clearContainerHoverClasses()
4958

50-
if (this.wasDropped) {
51-
this.dragItem.remove()
59+
if (!this.wasDropped) {
60+
this.#restoreOriginalDraggedItemCssVariable()
5261
}
5362

5463
this.sourceContainer = null
5564
this.dragItem = null
5665
this.wasDropped = false
66+
this.originalDraggedItemCssVariable = null
5767
}
5868

5969
#itemContaining(element) {
@@ -68,30 +78,73 @@ export default class extends Controller {
6878
this.containerTargets.forEach(container => container.classList.remove(this.hoverContainerClass))
6979
}
7080

71-
async #submitDropRequest(item, container) {
72-
const body = new FormData()
73-
const id = item.dataset.id
74-
const url = container.dataset.dragAndDropUrl.replaceAll("__id__", id)
81+
#applyContainerCssVariable(container) {
82+
const cssVariable = this.#containerCssVariableFor(container)
83+
if (cssVariable) {
84+
this.dragItem.style.setProperty(cssVariable.name, cssVariable.value)
85+
}
86+
}
7587

76-
return post(url, { body, headers: { Accept: "text/vnd.turbo-stream.html" } })
88+
#restoreOriginalDraggedItemCssVariable() {
89+
if (this.originalDraggedItemCssVariable) {
90+
const { name, value } = this.originalDraggedItemCssVariable
91+
this.dragItem.style.setProperty(name, value)
92+
}
7793
}
7894

79-
#reloadSourceFrame(sourceContainer) {
80-
const frame = sourceContainer.querySelector("[data-drag-and-drop-refresh]")
81-
if (frame) frame.reload()
95+
#containerCssVariableFor(container) {
96+
const { dragAndDropCssVariableName, dragAndDropCssVariableValue } = container.dataset
97+
if (dragAndDropCssVariableName && dragAndDropCssVariableValue) {
98+
return { name: dragAndDropCssVariableName, value: dragAndDropCssVariableValue }
99+
}
100+
return null
101+
}
102+
103+
#increaseCounter(container) {
104+
this.#modifyCounter(container, count => count + 1)
105+
}
106+
107+
#decreaseCounter(container) {
108+
this.#modifyCounter(container, count => Math.max(0, count - 1))
82109
}
83110

84-
#decreaseCounter(sourceContainer) {
85-
const counterElement = sourceContainer.querySelector("[data-drag-and-drop-counter]")
111+
#modifyCounter(container, fn) {
112+
const counterElement = container.querySelector("[data-drag-and-drop-counter]")
86113
if (counterElement) {
87114
const currentValue = counterElement.textContent.trim()
88115

89116
if (!/^\d+$/.test(currentValue)) return
90117

91-
const count = parseInt(currentValue)
92-
if (count > 0) {
93-
counterElement.textContent = count - 1
94-
}
118+
counterElement.textContent = fn(parseInt(currentValue))
95119
}
96120
}
121+
122+
#insertDraggedItem(container, item) {
123+
const itemContainer = container.querySelector("[data-drag-drop-item-container]")
124+
const topItems = itemContainer.querySelectorAll("[data-drag-and-drop-top]")
125+
const firstTopItem = topItems[0]
126+
const lastTopItem = topItems[topItems.length - 1]
127+
128+
const isTopItem = item.hasAttribute("data-drag-and-drop-top")
129+
const referenceItem = isTopItem ? firstTopItem : lastTopItem
130+
131+
if (referenceItem) {
132+
referenceItem[isTopItem ? "before" : "after"](item)
133+
} else {
134+
itemContainer.prepend(item)
135+
}
136+
}
137+
138+
async #submitDropRequest(item, container) {
139+
const body = new FormData()
140+
const id = item.dataset.id
141+
const url = container.dataset.dragAndDropUrl.replaceAll("__id__", id)
142+
143+
return post(url, { body, headers: { Accept: "text/vnd.turbo-stream.html" } })
144+
}
145+
146+
#reloadSourceFrame(sourceContainer) {
147+
const frame = sourceContainer.querySelector("[data-drag-and-drop-refresh]")
148+
if (frame) frame.reload()
149+
}
97150
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<div class="cards__list" data-drag-drop-item-container>
2+
<div class="blank-slate blank-slate--drag card card--hide-unless-empty">
3+
<p>Drag cards here</p>
4+
</div>
5+
<div class="blank-slate blank-slate--empty card--hide-unless-empty">No cards here</div>
6+
</div>
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
<div class="cards__list">
1+
<div class="cards__list" data-drag-drop-item-container >
22
<%= render "cards/display/previews", cards: cards, draggable: draggable %>
33
</div>

0 commit comments

Comments
 (0)