diff --git a/QualityControl/public/app.css b/QualityControl/public/app.css index 956128933..36bc92181 100644 --- a/QualityControl/public/app.css +++ b/QualityControl/public/app.css @@ -152,9 +152,9 @@ } } -.cursor-pointer { - cursor: pointer; -} +.cursor-pointer { cursor: pointer; } +.cursor-grab { cursor: grab; } +.cursor-inherit { cursor: inherit; } .header-layout { &.edit { @@ -187,3 +187,33 @@ .whitespace-nowrap { white-space: nowrap; } + +.drop-zone { + position: absolute; + height: 100%; + width: 50%; + pointer-events: none; + + &.before { + left: 0; + + &.active { + border-left: 2px solid var(--color-primary); + } + } + + &.after { + right: 0; + + &.active { + border-right: 2px solid var(--color-primary); + } + } +} + +.pointer-events-auto { + pointer-events: auto; +} +.pointer-events-none { + pointer-events: none; +} diff --git a/QualityControl/public/layout/Layout.js b/QualityControl/public/layout/Layout.js index 58740064f..81c805581 100644 --- a/QualityControl/public/layout/Layout.js +++ b/QualityControl/public/layout/Layout.js @@ -61,6 +61,10 @@ export default class Layout extends BaseViewModel { }); this.cellHeight = 100 / this.gridListSize * 0.95; // %, put some margin at bottom to see below this.cellWidth = 100 / this.gridListSize; // % + + this.isDragging = false; + this.dropTargetId = undefined; + this.position = undefined; } /** @@ -777,4 +781,82 @@ export default class Layout extends BaseViewModel { ownsLayout(layoutOwnerId) { return this.model.session.personid == layoutOwnerId; } + + /** + * Sets the current drop target for a drag-and-drop operation. + * This is typically used to render a visual indicator (like a blue line) + * in the UI showing where the dragged tab will be placed. + * @param {string|number} tabId - The ID of the tab currently being hovered over. + * @param {'before'|'after'} position - The side of the target tab where the drop indicator should appear. + */ + setDropTarget(tabId, position) { + this.dropTargetId = tabId; + this.position = position; + + this.notify(); + } + + /** + * Clears the current drop target state, usually when the drag operation + * is finished or the dragged item is no longer over a valid drop zone. + * This action typically causes the visual drop indicator to be hidden. + */ + clearDropTarget() { + this.dropTargetId = undefined; + this.position = undefined; + + this.notify(); + } + + /** + * Reorders the tabs in the internal array based on the drag source and drop target. + * This function calculates the correct index for insertion, accounting for the + * tab being removed from its original position. + * @param {string|number} sourceId - The ID of the tab that was dragged. + * @param {string|number} targetId - The ID of the tab that the source was dropped onto. + * @param {'before'|'after'} position - The placement relative to the target tab. + */ + reorderTabs(sourceId, targetId, position) { + const sourceIndex = this.item.tabs.findIndex((t) => t.id === sourceId); + let targetIndex = this.item.tabs.findIndex((t) => t.id === targetId); + + if (sourceIndex === -1 || targetIndex === -1) { + return; + } + + if (position === 'after') { + targetIndex += 1; + } + + const [movedTab] = this.item.tabs.splice(sourceIndex, 1); + + if (sourceIndex < targetIndex) { + targetIndex--; + } + + this.item.tabs.splice(targetIndex, 0, movedTab); + + this.notify(); + } + + /** + * Sets the layout state to indicate that a tab drag-and-drop operation has begun. + * It typically triggers a redraw and enables pointer events on all drop zones via CSS. + */ + startDragging() { + this.isDragging = true; + + this.notify(); + } + + /** + * Resets the layout state to indicate that a tab drag-and-drop operation has ended, + * regardless of whether the drop was successful or cancelled. + * It typically triggers a redraw and disables pointer events on the drop zones via CSS. + */ + stopDragging() { + this.isDragging = false; + + this.notify(); + } } diff --git a/QualityControl/public/layout/view/header.js b/QualityControl/public/layout/view/header.js index b254de5b0..25a69d272 100644 --- a/QualityControl/public/layout/view/header.js +++ b/QualityControl/public/layout/view/header.js @@ -143,15 +143,70 @@ const toolbarEditModeTab = (layout, tab, i) => { */ const selectTab = () => layout.selectTab(i); + const dragActiveClass = layout.isDragging ? 'pointer-events-auto' : ''; + const disableButtonsOnDragClass = layout.isDragging ? 'pointer-events-none' : ''; + const dropZoneClass = (position) => layout.dropTargetId === tab.id && layout.position === position ? 'active' : ''; + return [ - h('.btn-group.flex-fixed', [ - h('button.br-pill.ph2.btn.btn-tab.whitespace-nowrap', { class: linkClass, onclick: selectTab }, tab.name), - selected && [ - editTabButton(layout, linkClass, tab, i), - resizeGridTabDropDown(layout, tab), - deleteTabButton(layout, linkClass, i), + h( + '.btn-group.flex-fixed.relative.cursor-grab', + { + title: 'Drag the tab to re-arrange them', + draggable: true, + ondragstart: (e) => { + e.dataTransfer.setData('text/plain', tab.id); + layout.startDragging(); + }, + ondrop: (e) => { + layout.reorderTabs(e.dataTransfer.getData('text/plain'), layout.dropTargetId, layout.position); + layout.clearDropTarget(); + layout.stopDragging(); + }, + ondragend: () => layout.stopDragging(), + }, + [ + h( + 'button.br-pill.ph2.btn.btn-tab.whitespace-nowrap', + { id: 'btn-tab', class: `${linkClass} cursor-inherit`, onclick: selectTab }, + tab.name, + ), + [ + h( + '.drop-zone.before', + { + class: `${dragActiveClass} ${dropZoneClass('before')}`, + ondragenter: () => layout.setDropTarget(tab.id, 'before'), + ondragover: (e) => e.preventDefault(), // prevent default to allow drop + ondragleave: () => { + if (layout.dropTargetId === tab.id && layout.position === 'before') { + layout.clearDropTarget(); + } + }, + }, + '', + ), + h( + '.drop-zone.after', + { + class: `${dragActiveClass} ${dropZoneClass('after')}`, + ondragenter: () => layout.setDropTarget(tab.id, 'after'), + ondragover: (e) => e.preventDefault(), // prevent default to allow drop + ondragleave: () => { + if (layout.dropTargetId === tab.id && layout.position === 'after') { + layout.clearDropTarget(); + } + }, + }, + '', + ), + selected && [ + editTabButton(layout, `${disableButtonsOnDragClass} ${linkClass}`, tab, i), + resizeGridTabDropDown(layout, tab), + deleteTabButton(layout, `${disableButtonsOnDragClass} ${linkClass}`, i), + ], + ].flat().filter(Boolean), ], - ]), + ), ' ', ]; }; diff --git a/QualityControl/test/public/pages/layout-show.test.js b/QualityControl/test/public/pages/layout-show.test.js index 1d96724a3..2a044a2b4 100644 --- a/QualityControl/test/public/pages/layout-show.test.js +++ b/QualityControl/test/public/pages/layout-show.test.js @@ -14,6 +14,7 @@ import { strictEqual, ok, deepStrictEqual } from 'node:assert'; import { delay } from '../../testUtils/delay.js'; import { editedMockedLayout } from '../../setup/seeders/layout-show/json-file-mock.js'; +import { getElementCenter } from '../../testUtils/dragAndDrop.js'; /** * Performs a series of automated tests on the layoutShow page using Puppeteer. @@ -297,6 +298,39 @@ export const layoutShowTests = async (url, page, timeout = 5000, testParent) => }, ); + await testParent.test( + 'should reorder tabs via drag and drop in edit mode', + { timeout }, + async () => { + const originalTabNames = await page.$$eval('#btn-tab', (elements) => + elements.map((element) => element.textContent.trim())); + + const sourceTabSelector = '.btn-group.flex-fixed.relative:nth-child(1)'; + const targetZoneSelector = '.btn-group.flex-fixed.relative:nth-child(2) .drop-zone.after'; + + const sourceCenter = await getElementCenter(page, sourceTabSelector); + const targetCenter = await getElementCenter(page, targetZoneSelector); + + await page.mouse.move(sourceCenter.x, sourceCenter.y); + await page.mouse.down(); + + // We add 'steps' to make the move smoother, which helps trigger event + await page.mouse.move(targetCenter.x, targetCenter.y, { steps: 10 }); + + await delay(1000); + + // Wait a moment for the 'active' class to appear in the UI + await page.waitForSelector('.drop-zone.after.active'); + + await page.mouse.up(); + + const tabNames = await page.$$eval('#btn-tab', (elements) => + elements.map((element) => element.textContent.trim())); + + strictEqual(tabNames[1], originalTabNames[0]); + } + ); + await testParent.test( 'should show normal sidebar after Cancel click', { timeout }, diff --git a/QualityControl/test/testUtils/dragAndDrop.js b/QualityControl/test/testUtils/dragAndDrop.js new file mode 100644 index 000000000..786c35c7d --- /dev/null +++ b/QualityControl/test/testUtils/dragAndDrop.js @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +/** + * Helper to get the center of an element. + * @param {object} page - Puppeteer page object. + * @param {string} selector - Element selector to look for. + * @returns {Promise<{x: number, y: number}>} A promise that resolves to the center x & y coordinates. + */ +export const getElementCenter = async (page, selector) => { + const element = await page.waitForSelector(selector); + const box = await element.boundingBox(); + return { + x: box.x + box.width / 2, + y: box.y + box.height / 2 + }; +};