Skip to content

Commit eeeb1bd

Browse files
committed
Port backlogs drag and drop to Stimulus
Replace the previous Dragula-oriented card/list wiring with Pragmatic Drag and Drop Stimulus controllers for backlog cards and list targets. Keep draggable card state client-side, submit moves through the existing backlogs move endpoints, and tolerate empty drop-target reports by resolving the element under the pointer. Render the draggable attribute and Stimulus item id from the server so Turbo morphs preserve the DnD contract.
1 parent c1fac3c commit eeeb1bd

12 files changed

Lines changed: 662 additions & 84 deletions

File tree

app/components/open_project/common/work_package_card_box_component.rb

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,8 @@ def initialize(work_packages:, project:, container:, current_user: User.current,
110110
@system_arguments[:data] = merge_data(
111111
{
112112
data: {
113-
# Sprint historically used "container" alone. The shared box keeps the
114-
# first mirror container on the page for now until parent-specific DnD
115-
# handling is extracted in follow-up work.
116-
generic_drag_and_drop_target: "container mirrorContainer",
117-
target_container_accessor: ":scope > ul",
118-
target_id: drop_target_id,
119-
target_allowed_drag_type: "story"
113+
backlogs_target: "list",
114+
backlogs_target_id: drop_target_id
120115
}
121116
},
122117
@system_arguments

app/components/open_project/common/work_package_card_component.html.erb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ See COPYRIGHT and LICENSE files for more details.
2727
2828
++# %>
2929

30-
<%= grid_layout("op-work-package-card", tag: :article) do |grid| %>
30+
<%= grid_layout(
31+
"op-work-package-card",
32+
tag: :article,
33+
**card_arguments
34+
) do |grid| %>
3135
<% grid.with_area(:info_line) do %>
3236
<%= render(WorkPackages::InfoLineComponent.new(work_package:)) %>
3337
<% end %>

app/components/open_project/common/work_package_card_component.rb

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,30 @@ def row_args
6666
}
6767
end
6868

69+
def card_data
70+
base = {
71+
controller: "backlogs--story",
72+
backlogs__story_id_value: work_package.id,
73+
backlogs__story_split_url_value: split_url,
74+
backlogs__story_full_url_value: full_url,
75+
backlogs__story_selected_class: "Box-row--blue"
76+
}
77+
78+
return base unless draggable?
79+
80+
base.merge(
81+
controller: "#{base[:controller]} backlogs--item",
82+
backlogs__item_item_id_value: work_package.id,
83+
drop_url:
84+
)
85+
end
86+
87+
def card_arguments
88+
{ data: card_data }.tap do |args|
89+
args[:draggable] = true if draggable?
90+
end
91+
end
92+
6993
private
7094

7195
def story_points
@@ -114,26 +138,8 @@ def row_classes
114138
)
115139
end
116140

117-
# `story` data attrs match the live Stimulus controller and Dragula drag-type;
118-
# renaming requires coordinated JS changes (separate PR).
119141
def row_data
120-
base = {
121-
story: true,
122-
controller: "backlogs--story",
123-
backlogs__story_id_value: work_package.id,
124-
backlogs__story_split_url_value: split_url,
125-
backlogs__story_full_url_value: full_url,
126-
backlogs__story_selected_class: "Box-row--blue",
127-
test_selector: "work-package-#{work_package.id}"
128-
}
129-
130-
return base unless draggable?
131-
132-
base.merge(
133-
draggable_id: work_package.id,
134-
draggable_type: "story",
135-
drop_url:
136-
)
142+
{ test_selector: "work-package-#{work_package.id}" }
137143
end
138144
end
139145
end

frontend/src/stimulus/controllers/dynamic/backlogs.controller.ts

Lines changed: 103 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -27,35 +27,118 @@
2727
//++
2828

2929
import { Controller } from '@hotwired/stimulus';
30-
import { FrameElement } from '@hotwired/turbo';
31-
import { HalEventsService } from 'core-app/features/hal/services/hal-events.service';
32-
import { filter, Subscription } from 'rxjs';
30+
import { FetchRequest } from '@rails/request.js';
31+
import { CleanupFn } from '@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types';
32+
import { dropTargetForElements, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
33+
import { extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
34+
import { debugLog } from 'core-app/shared/helpers/debug_output';
35+
36+
import {
37+
buildMoveFormData,
38+
isItemData,
39+
resolveFallbackDropTarget,
40+
resolveListPreviousItemId,
41+
resolveListTargetId,
42+
resolvePreviousItemId,
43+
} from './backlogs/drag-and-drop';
3344

3445
export default class BacklogsController extends Controller<HTMLElement> {
35-
private service:HalEventsService|null = null;
36-
private subscription:Subscription|null = null;
46+
static targets = ['list'];
47+
48+
declare readonly listTargets:HTMLElement[];
3749

38-
// eslint-disable-next-line @typescript-eslint/no-misused-promises
39-
async connect() {
40-
const { services: { halEvents } } = await window.OpenProject.getPluginContext();
50+
private cleanupFn?:CleanupFn;
51+
private listCleanupFns = new Map<HTMLElement, CleanupFn>();
4152

42-
this.service = halEvents;
43-
this.subscription = this.service.aggregated$('WorkPackage')
44-
.pipe(filter((events) => events.some((event) => event.eventType === 'updated')))
45-
.subscribe(() => { this.refreshList(); });
53+
connect():void {
54+
this.cleanupFn = monitorForElements({
55+
canMonitor: ({ source }) => isItemData(source.data),
56+
onDrop: (args) => {
57+
void this.handleDrop(args);
58+
},
59+
});
4660
}
4761

48-
disconnect() {
49-
this.subscription?.unsubscribe();
50-
this.subscription = null;
51-
this.service = null;
62+
disconnect():void {
63+
this.cleanupFn?.();
64+
this.cleanupFn = undefined;
65+
this.listCleanupFns.forEach((cleanup) => cleanup());
66+
this.listCleanupFns.clear();
5267
}
5368

54-
private refreshList() {
55-
void this.listElement.reload();
69+
listTargetConnected(element:HTMLElement):void {
70+
const cleanup = dropTargetForElements({
71+
element,
72+
canDrop: ({ source }) => isItemData(source.data),
73+
getData: () => ({ type: 'list', targetId: resolveListTargetId(element) }),
74+
getIsSticky: () => true,
75+
});
76+
77+
this.listCleanupFns.set(element, cleanup);
5678
}
5779

58-
private get listElement() {
59-
return this.element.querySelector<FrameElement>('#backlogs_container')!;
80+
listTargetDisconnected(element:HTMLElement):void {
81+
this.listCleanupFns.get(element)?.();
82+
this.listCleanupFns.delete(element);
83+
}
84+
85+
private async handleDrop({ location, source }:Parameters<NonNullable<Parameters<typeof monitorForElements>[0]['onDrop']>>[0]) {
86+
if (!isItemData(source.data) || !(source.element instanceof HTMLElement)) {
87+
return;
88+
}
89+
90+
const dropUrl = source.element.getAttribute('data-drop-url');
91+
if (!dropUrl) {
92+
return;
93+
}
94+
95+
const targetItem = location.current.dropTargets.find(({ data, element }) => (
96+
isItemData(data) && element instanceof HTMLElement
97+
));
98+
const fallbackTarget = location.current.dropTargets.length === 0
99+
? resolveFallbackDropTarget({ input: location.current.input, root: this.element })
100+
: null;
101+
const fallbackItem = fallbackTarget?.isItem ? fallbackTarget : null;
102+
const resolvedTargetItem = targetItem ?? fallbackItem;
103+
const targetElement = resolvedTargetItem?.element ?? location.current.dropTargets[0]?.element ?? fallbackTarget?.element;
104+
105+
if (!(targetElement instanceof HTMLElement)) {
106+
return;
107+
}
108+
109+
const targetId = resolveListTargetId(targetElement);
110+
if (!targetId) {
111+
return;
112+
}
113+
114+
const previousItemId = resolvedTargetItem?.element instanceof HTMLElement
115+
? resolvePreviousItemId({
116+
sourceItemId: source.data.itemId,
117+
targetItem: resolvedTargetItem.element,
118+
closestEdge: extractClosestEdge(resolvedTargetItem.data),
119+
})
120+
: resolveListPreviousItemId({
121+
sourceItemId: source.data.itemId,
122+
list: targetElement,
123+
});
124+
125+
const request = new FetchRequest(
126+
'put',
127+
dropUrl,
128+
{
129+
body: buildMoveFormData({ targetId, previousItemId }),
130+
responseKind: 'turbo-stream',
131+
},
132+
);
133+
134+
try {
135+
const response = await request.perform();
136+
137+
if (!response.ok) {
138+
debugLog(`Failed to move backlogs item: ${response.statusCode}`);
139+
}
140+
} catch (error) {
141+
debugLog('Failed to move backlogs item due to request error', error);
142+
}
60143
}
61144
}

0 commit comments

Comments
 (0)