Skip to content

Commit 90820f6

Browse files
authored
[OGUI-893] Reorder tabs in QCG layout (#3243)
* feat: add dropzones and event listeners for the tabs in edit * feat: add ability to reorder the tabs using drag and drop * feat: add class styling for drop target * feat: add the primary color for the drop borders * test: add test for drag and drop reordering of tabs in edit mode * feat: add title to incite the user to re-arrange the tabs
1 parent aef4fd7 commit 90820f6

5 files changed

Lines changed: 239 additions & 10 deletions

File tree

QualityControl/public/app.css

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,9 @@
152152
}
153153
}
154154

155-
.cursor-pointer {
156-
cursor: pointer;
157-
}
155+
.cursor-pointer { cursor: pointer; }
156+
.cursor-grab { cursor: grab; }
157+
.cursor-inherit { cursor: inherit; }
158158

159159
.b1 { border-style: solid; border-width: 1px; }
160160

@@ -193,6 +193,36 @@
193193
white-space: nowrap;
194194
}
195195

196+
.drop-zone {
197+
position: absolute;
198+
height: 100%;
199+
width: 50%;
200+
pointer-events: none;
201+
202+
&.before {
203+
left: 0;
204+
205+
&.active {
206+
border-left: 2px solid var(--color-primary);
207+
}
208+
}
209+
210+
&.after {
211+
right: 0;
212+
213+
&.active {
214+
border-right: 2px solid var(--color-primary);
215+
}
216+
}
217+
}
218+
219+
.pointer-events-auto {
220+
pointer-events: auto;
221+
}
222+
.pointer-events-none {
223+
pointer-events: none;
224+
}
225+
196226
/* This hacky workaround is required due to `justify-content: center;` being unusable thanks to a horizontal scrolling bug */
197227
#header-detector-qualities {
198228
&::before, &::after {

QualityControl/public/layout/Layout.js

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ export default class Layout extends BaseViewModel {
6161
});
6262
this.cellHeight = 100 / this.gridListSize * 0.95; // %, put some margin at bottom to see below
6363
this.cellWidth = 100 / this.gridListSize; // %
64+
65+
this.isDragging = false;
66+
this.dropTargetId = undefined;
67+
this.position = undefined;
6468
}
6569

6670
/**
@@ -777,4 +781,82 @@ export default class Layout extends BaseViewModel {
777781
ownsLayout(layoutOwnerId) {
778782
return this.model.session.personid == layoutOwnerId;
779783
}
784+
785+
/**
786+
* Sets the current drop target for a drag-and-drop operation.
787+
* This is typically used to render a visual indicator (like a blue line)
788+
* in the UI showing where the dragged tab will be placed.
789+
* @param {string|number} tabId - The ID of the tab currently being hovered over.
790+
* @param {'before'|'after'} position - The side of the target tab where the drop indicator should appear.
791+
*/
792+
setDropTarget(tabId, position) {
793+
this.dropTargetId = tabId;
794+
this.position = position;
795+
796+
this.notify();
797+
}
798+
799+
/**
800+
* Clears the current drop target state, usually when the drag operation
801+
* is finished or the dragged item is no longer over a valid drop zone.
802+
* This action typically causes the visual drop indicator to be hidden.
803+
*/
804+
clearDropTarget() {
805+
this.dropTargetId = undefined;
806+
this.position = undefined;
807+
808+
this.notify();
809+
}
810+
811+
/**
812+
* Reorders the tabs in the internal array based on the drag source and drop target.
813+
* This function calculates the correct index for insertion, accounting for the
814+
* tab being removed from its original position.
815+
* @param {string|number} sourceId - The ID of the tab that was dragged.
816+
* @param {string|number} targetId - The ID of the tab that the source was dropped onto.
817+
* @param {'before'|'after'} position - The placement relative to the target tab.
818+
*/
819+
reorderTabs(sourceId, targetId, position) {
820+
const sourceIndex = this.item.tabs.findIndex((t) => t.id === sourceId);
821+
let targetIndex = this.item.tabs.findIndex((t) => t.id === targetId);
822+
823+
if (sourceIndex === -1 || targetIndex === -1) {
824+
return;
825+
}
826+
827+
if (position === 'after') {
828+
targetIndex += 1;
829+
}
830+
831+
const [movedTab] = this.item.tabs.splice(sourceIndex, 1);
832+
833+
if (sourceIndex < targetIndex) {
834+
targetIndex--;
835+
}
836+
837+
this.item.tabs.splice(targetIndex, 0, movedTab);
838+
839+
this.notify();
840+
}
841+
842+
/**
843+
* Sets the layout state to indicate that a tab drag-and-drop operation has begun.
844+
* It typically triggers a redraw and enables pointer events on all drop zones via CSS.
845+
*/
846+
startDragging() {
847+
this.isDragging = true;
848+
849+
this.notify();
850+
}
851+
852+
/**
853+
* Resets the layout state to indicate that a tab drag-and-drop operation has ended,
854+
* regardless of whether the drop was successful or cancelled.
855+
* It typically triggers a redraw and disables pointer events on the drop zones via CSS.
856+
*/
857+
stopDragging() {
858+
this.isDragging = false;
859+
860+
this.notify();
861+
}
780862
}

QualityControl/public/layout/view/header.js

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -143,15 +143,70 @@ const toolbarEditModeTab = (layout, tab, i) => {
143143
*/
144144
const selectTab = () => layout.selectTab(i);
145145

146+
const dragActiveClass = layout.isDragging ? 'pointer-events-auto' : '';
147+
const disableButtonsOnDragClass = layout.isDragging ? 'pointer-events-none' : '';
148+
const dropZoneClass = (position) => layout.dropTargetId === tab.id && layout.position === position ? 'active' : '';
149+
146150
return [
147-
h('.btn-group.flex-fixed', [
148-
h('button.br-pill.ph2.btn.btn-tab.whitespace-nowrap', { class: linkClass, onclick: selectTab }, tab.name),
149-
selected && [
150-
editTabButton(layout, linkClass, tab, i),
151-
resizeGridTabDropDown(layout, tab),
152-
deleteTabButton(layout, linkClass, i),
151+
h(
152+
'.btn-group.flex-fixed.relative.cursor-grab',
153+
{
154+
title: 'Drag the tab to re-arrange them',
155+
draggable: true,
156+
ondragstart: (e) => {
157+
e.dataTransfer.setData('text/plain', tab.id);
158+
layout.startDragging();
159+
},
160+
ondrop: (e) => {
161+
layout.reorderTabs(e.dataTransfer.getData('text/plain'), layout.dropTargetId, layout.position);
162+
layout.clearDropTarget();
163+
layout.stopDragging();
164+
},
165+
ondragend: () => layout.stopDragging(),
166+
},
167+
[
168+
h(
169+
'button.br-pill.ph2.btn.btn-tab.whitespace-nowrap',
170+
{ id: 'btn-tab', class: `${linkClass} cursor-inherit`, onclick: selectTab },
171+
tab.name,
172+
),
173+
[
174+
h(
175+
'.drop-zone.before',
176+
{
177+
class: `${dragActiveClass} ${dropZoneClass('before')}`,
178+
ondragenter: () => layout.setDropTarget(tab.id, 'before'),
179+
ondragover: (e) => e.preventDefault(), // prevent default to allow drop
180+
ondragleave: () => {
181+
if (layout.dropTargetId === tab.id && layout.position === 'before') {
182+
layout.clearDropTarget();
183+
}
184+
},
185+
},
186+
'',
187+
),
188+
h(
189+
'.drop-zone.after',
190+
{
191+
class: `${dragActiveClass} ${dropZoneClass('after')}`,
192+
ondragenter: () => layout.setDropTarget(tab.id, 'after'),
193+
ondragover: (e) => e.preventDefault(), // prevent default to allow drop
194+
ondragleave: () => {
195+
if (layout.dropTargetId === tab.id && layout.position === 'after') {
196+
layout.clearDropTarget();
197+
}
198+
},
199+
},
200+
'',
201+
),
202+
selected && [
203+
editTabButton(layout, `${disableButtonsOnDragClass} ${linkClass}`, tab, i),
204+
resizeGridTabDropDown(layout, tab),
205+
deleteTabButton(layout, `${disableButtonsOnDragClass} ${linkClass}`, i),
206+
],
207+
].flat().filter(Boolean),
153208
],
154-
]),
209+
),
155210
' ',
156211
];
157212
};

QualityControl/test/public/pages/layout-show.test.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import { strictEqual, ok, deepStrictEqual } from 'node:assert';
1515
import { delay } from '../../testUtils/delay.js';
1616
import { editedMockedLayout } from '../../setup/seeders/layout-show/json-file-mock.js';
17+
import { getElementCenter } from '../../testUtils/dragAndDrop.js';
1718

1819
/**
1920
* Performs a series of automated tests on the layoutShow page using Puppeteer.
@@ -308,6 +309,39 @@ export const layoutShowTests = async (url, page, timeout = 5000, testParent) =>
308309
},
309310
);
310311

312+
await testParent.test(
313+
'should reorder tabs via drag and drop in edit mode',
314+
{ timeout },
315+
async () => {
316+
const originalTabNames = await page.$$eval('#btn-tab', (elements) =>
317+
elements.map((element) => element.textContent.trim()));
318+
319+
const sourceTabSelector = '.btn-group.flex-fixed.relative:nth-child(1)';
320+
const targetZoneSelector = '.btn-group.flex-fixed.relative:nth-child(2) .drop-zone.after';
321+
322+
const sourceCenter = await getElementCenter(page, sourceTabSelector);
323+
const targetCenter = await getElementCenter(page, targetZoneSelector);
324+
325+
await page.mouse.move(sourceCenter.x, sourceCenter.y);
326+
await page.mouse.down();
327+
328+
// We add 'steps' to make the move smoother, which helps trigger event
329+
await page.mouse.move(targetCenter.x, targetCenter.y, { steps: 10 });
330+
331+
await delay(1000);
332+
333+
// Wait a moment for the 'active' class to appear in the UI
334+
await page.waitForSelector('.drop-zone.after.active');
335+
336+
await page.mouse.up();
337+
338+
const tabNames = await page.$$eval('#btn-tab', (elements) =>
339+
elements.map((element) => element.textContent.trim()));
340+
341+
strictEqual(tabNames[1], originalTabNames[0]);
342+
}
343+
);
344+
311345
await testParent.test(
312346
'should show normal sidebar after Cancel click',
313347
{ timeout },
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* @license
3+
* Copyright 2019-2020 CERN and copyright holders of ALICE O2.
4+
* See http://alice-o2.web.cern.ch/copyright for details of the copyright holders.
5+
* All rights not expressly granted are reserved.
6+
*
7+
* This software is distributed under the terms of the GNU General Public
8+
* License v3 (GPL Version 3), copied verbatim in the file "COPYING".
9+
*
10+
* In applying this license CERN does not waive the privileges and immunities
11+
* granted to it by virtue of its status as an Intergovernmental Organization
12+
* or submit itself to any jurisdiction.
13+
*/
14+
15+
/**
16+
* Helper to get the center of an element.
17+
* @param {object} page - Puppeteer page object.
18+
* @param {string} selector - Element selector to look for.
19+
* @returns {Promise<{x: number, y: number}>} A promise that resolves to the center x & y coordinates.
20+
*/
21+
export const getElementCenter = async (page, selector) => {
22+
const element = await page.waitForSelector(selector);
23+
const box = await element.boundingBox();
24+
return {
25+
x: box.x + box.width / 2,
26+
y: box.y + box.height / 2
27+
};
28+
};

0 commit comments

Comments
 (0)