733 lines
23 KiB
JavaScript
733 lines
23 KiB
JavaScript
// ==UserScript==
|
|
// @name OSRT Staging Move Drag-n-Drop
|
|
// @namespace openSUSE/openSUSE-release-tools
|
|
// @version 0.2.0
|
|
// @description Provide staging request moving interface on staging dashboard.
|
|
// @author Jimmy Berry
|
|
// @match */project/staging_projects/*
|
|
// @require https://code.jquery.com/jquery-3.3.1.min.js
|
|
// @require https://raw.githubusercontent.com/p34eu/selectables/master/selectables.js
|
|
// @grant none
|
|
// ==/UserScript==
|
|
|
|
// Uses a combination of two sources:
|
|
// - https://www.sitepoint.com/accessible-drag-drop/ (modified slightly)
|
|
// - https://github.com/p34eu/selectables (used directly with abuse of modifier key option)
|
|
|
|
(function()
|
|
{
|
|
// Exclude not usable browsers.
|
|
if (!document.querySelectorAll || !('draggable' in document.createElement('span'))) {
|
|
return;
|
|
}
|
|
|
|
// Ensure user is logged in.
|
|
if (!document.querySelector('#link-to-user-home')) {
|
|
return;
|
|
}
|
|
|
|
// Add explanation of trigger shortcut to legend box.
|
|
var explanation = document.createElement('div');
|
|
explanation.id = 'osrt-explanation';
|
|
explanation.innerText = 'enter move mode';
|
|
explanation.setAttribute('title', 'ctrl + m');
|
|
explanation.onclick = function() {
|
|
initMoveInterface();
|
|
this.onclick = null;
|
|
};
|
|
document.querySelector('#legends').appendChild(explanation);
|
|
|
|
window.onkeyup = function(e) {
|
|
if (e.keyCode == 77 && e.ctrlKey) {
|
|
initMoveInterface();
|
|
}
|
|
}
|
|
|
|
// Include CSS immediately for explanation.
|
|
$('head').append(`<style>
|
|
#osrt-explanation
|
|
{
|
|
padding: 10px;
|
|
background-color: #d9b200;
|
|
color: white;
|
|
cursor: pointer;
|
|
}
|
|
|
|
#osrt-explanation.osrt-active
|
|
{
|
|
cursor: default;
|
|
}
|
|
|
|
#osrt-summary
|
|
{
|
|
position: sticky;
|
|
z-index: 10000;
|
|
bottom: 0;
|
|
left: 0;
|
|
width: 100%%;
|
|
height: 2em;
|
|
padding-top: 0.7em;
|
|
text-align: center;
|
|
background-color: white;
|
|
}
|
|
|
|
#osrt-summary button
|
|
{
|
|
margin-left: 10px;
|
|
}
|
|
|
|
#osrt-summary progress
|
|
{
|
|
display: none;
|
|
}
|
|
|
|
#osrt-summary.osrt-progress progress
|
|
{
|
|
display: inline;
|
|
}
|
|
|
|
#osrt-summary.osrt-progress span,
|
|
#osrt-summary.osrt-progress button,
|
|
#osrt-summary.osrt-success button
|
|
{
|
|
display: none;
|
|
}
|
|
|
|
#osrt-summary.osrt-failed
|
|
{
|
|
background-color: red;
|
|
color: white;
|
|
}
|
|
|
|
#osrt-summary.osrt-failed span
|
|
{
|
|
display: inline;
|
|
}
|
|
|
|
#osrt-summary.osrt-failed progress
|
|
{
|
|
display: none;
|
|
}
|
|
|
|
#osrt-summary.osrt-success
|
|
{
|
|
background-color: green;
|
|
color: white;
|
|
}
|
|
|
|
/* drop target state */
|
|
[data-draggable="target"][aria-dropeffect="move"]
|
|
{
|
|
border-color: #68b;
|
|
}
|
|
|
|
[data-draggable="target"][aria-dropeffect="move"]:focus,
|
|
[data-draggable="target"][aria-dropeffect="move"].dragover
|
|
{
|
|
box-shadow:0 0 0 1px #fff, 0 0 0 3px #68b;
|
|
}
|
|
|
|
[data-draggable="item"]:focus
|
|
{
|
|
box-shadow: 0 0 0 2px #68b, inset 0 0 0 1px #ddd;
|
|
}
|
|
|
|
[data-draggable="item"][aria-grabbed="true"],
|
|
.color-legend .selected
|
|
{
|
|
background: #8ad;
|
|
color: #fff;
|
|
}
|
|
|
|
.color-legend .moved,
|
|
.osrt-moved
|
|
{
|
|
background: repeating-linear-gradient(
|
|
45deg,
|
|
#606dbc,
|
|
#606dbc 10px,
|
|
#465298 10px,
|
|
#465298 20px
|
|
);
|
|
}
|
|
</style>`);
|
|
|
|
})();
|
|
|
|
var initMoveInterface = function() {
|
|
// Update explanation text and add new legend entries.
|
|
function addLegend(type)
|
|
{
|
|
var listItem = document.createElement('li');
|
|
var span = document.createElement('span');
|
|
span.classList.add(type.toLowerCase());
|
|
listItem.appendChild(span);
|
|
listItem.appendChild(document.createTextNode(type));
|
|
document.querySelector('ul.color-legend').appendChild(listItem);
|
|
}
|
|
|
|
addLegend('Moved');
|
|
addLegend('Selected');
|
|
|
|
var explanation = document.querySelector('#osrt-explanation');
|
|
explanation.innerText = 'drag box around requests or ctrl/shift + click requests to select and drag a request to another staging.';
|
|
explanation.setAttribute('title', 'move mode activated');
|
|
explanation.classList.add('osrt-active');
|
|
|
|
// @resource will not work since served without proper MIME type.
|
|
$.get('https://raw.githubusercontent.com/p34eu/selectables/master/selectables.css', function(data, status) {
|
|
$('head').append('<style>' + data + '</style>');
|
|
});
|
|
|
|
// Mark the drag targets and draggable items.
|
|
// Preferable to use the tr element as target, but mouse events not handled properly.
|
|
// Avoid making expand/collapse links selectable and avoid forcing them to be expanded.
|
|
// The pointer-events changes only seem to work properly from script.
|
|
$('table.staging-dashboard td').attr('data-draggable', 'target');
|
|
$('table.staging-dashboard ul.packages-list li.request:not(:has(a.staging_expand, a.staging_collapse))').attr('data-draggable', 'item').css('pointer-events', 'all');
|
|
|
|
// Disable mouse events on the request links as that makes it nearly impossible to drag them.
|
|
$('table.staging-dashboard ul.packages-list li.request:not(:has(a.staging_expand, a.staging_collapse)) a').css('pointer-events', 'none');
|
|
|
|
// Configure selectables to play nice with drag-n-drop code.
|
|
new Selectables({
|
|
elements: 'ul.packages-list li[data-draggable="item"]',
|
|
zone: 'body',
|
|
start: function (e) {
|
|
e.osrtContinue = (e.target.getAttribute('data-draggable') != 'item' &&
|
|
e.target.tagName != 'A' &&
|
|
e.target.tagName != 'LABEL' &&
|
|
!e.target.id.startsWith('osrt-'));
|
|
},
|
|
// Abuse key option by setting the value in start callback whic is run
|
|
// first and the value determines if drag selection is started.
|
|
key: 'osrtContinue',
|
|
onSelect: function (e) {
|
|
addSelection(e);
|
|
},
|
|
onDeselect: function (e) {
|
|
removeSelection(e);
|
|
}
|
|
});
|
|
|
|
function getStaging(item)
|
|
{
|
|
var parent;
|
|
if (item.tagName == 'TD') {
|
|
parent = item.parentElement;
|
|
} else {
|
|
parent = item.parentElement.parentElement.parentElement;
|
|
}
|
|
if (item.parentElement.classList.contains('staging_collapsible')) {
|
|
// An additional layer since in hidden container.
|
|
parent = parent.parentElement;
|
|
}
|
|
return parent.querySelector('div.letter a').innerText;
|
|
}
|
|
|
|
var summary = {};
|
|
function updateSummary()
|
|
{
|
|
var summaryElement = document.querySelector('div#osrt-summary');
|
|
if (!summaryElement) {
|
|
summaryElement = document.createElement('div');
|
|
summaryElement.id = 'osrt-summary';
|
|
summaryElement.appendChild(document.createElement('span'))
|
|
|
|
var button = document.createElement('button');
|
|
button.innerText = 'Apply';
|
|
button.onclick = applyChanges;
|
|
summaryElement.appendChild(button);
|
|
|
|
summaryElement.appendChild(document.createElement('progress'))
|
|
document.body.appendChild(summaryElement);
|
|
}
|
|
|
|
var elements = document.querySelectorAll('.osrt-moved');
|
|
summary = {};
|
|
var staging;
|
|
for (var i = 0; i < elements.length; i++) {
|
|
staging = getStaging(elements[i]);
|
|
if (!isNaN(staging)) {
|
|
staging = 'adi:' + staging;
|
|
}
|
|
if (!(staging in summary)) {
|
|
summary[staging] = [];
|
|
}
|
|
summary[staging].push(elements[i].children[0].innerText.trim());
|
|
}
|
|
|
|
summaryElement.children[0].innerText = elements.length + ' request(s) to move affecting ' + Object.keys(summary).length + ' stagings(s)';
|
|
summaryElement.children[2].setAttribute('max', elements.length);
|
|
}
|
|
|
|
function applyChanges()
|
|
{
|
|
var summaryElement = document.querySelector('div#osrt-summary');
|
|
summaryElement.classList.add('osrt-progress');
|
|
|
|
var user = document.querySelector('#link-to-user-home').innerText.trim();
|
|
var pathParts = window.location.pathname.split('/');
|
|
var project = pathParts[pathParts.length - 1];
|
|
|
|
var data = JSON.stringify({'user': user, 'project': project, 'move': true, 'selection': summary});
|
|
var domain_parent = window.location.hostname.split('.').splice(1).join('.');
|
|
var subdomain = domain_parent.endsWith('suse.de') ? 'tortuga' : 'operator';
|
|
var url = 'https://' + subdomain + '.' + domain_parent + '/staging/select';
|
|
$.post({url: url, data: data, crossDomain: true, xhrFields: {withCredentials: true},
|
|
success: applyChangesSuccess}).fail(applyChangesFailed);
|
|
}
|
|
|
|
function applyChangesSuccess(data)
|
|
{
|
|
// Could provide link to this in UI.
|
|
console.log(data);
|
|
|
|
var summaryElement = document.querySelector('div#osrt-summary');
|
|
summaryElement.classList.add('osrt-complete');
|
|
if (data.trim().endsWith('failed')) {
|
|
applyChangesFailed();
|
|
return;
|
|
}
|
|
|
|
var expected = summaryElement.children[2].getAttribute('max');
|
|
if ((data.match(/\(\d+\/\d+\)/g) || []).length == expected) {
|
|
summaryElement.children[0].innerText = 'Moved ' + expected + ' request(s).';
|
|
summaryElement.children[2].setAttribute('value', expected);
|
|
summaryElement.classList.add('osrt-success');
|
|
summaryElement.classList.remove('osrt-progress');
|
|
|
|
// Could reset UI in a more elegant way.
|
|
reloadShortly();
|
|
return;
|
|
}
|
|
|
|
applyChangesFailed();
|
|
}
|
|
|
|
function applyChangesFailed()
|
|
{
|
|
var summaryElement = document.querySelector('div#osrt-summary');
|
|
summaryElement.children[0].innerText = 'Failed to move requests.';
|
|
summaryElement.classList.add('osrt-failed');
|
|
}
|
|
|
|
function reloadShortly()
|
|
{
|
|
setTimeout(function() { window.location.reload(); }, 3000);
|
|
}
|
|
|
|
//get the collection of draggable targets and add their draggable attribute
|
|
for(var
|
|
targets = document.querySelectorAll('[data-draggable="target"]'),
|
|
len = targets.length,
|
|
i = 0; i < len; i ++)
|
|
{
|
|
targets[i].setAttribute('aria-dropeffect', 'none');
|
|
}
|
|
|
|
//get the collection of draggable items and add their draggable attributes
|
|
for(var
|
|
items = document.querySelectorAll('[data-draggable="item"]'),
|
|
len = items.length,
|
|
i = 0; i < len; i ++)
|
|
{
|
|
items[i].setAttribute('draggable', 'true');
|
|
items[i].setAttribute('aria-grabbed', 'false');
|
|
items[i].setAttribute('tabindex', '0');
|
|
|
|
// OSRT modification: keep track of original staging.
|
|
items[i].setAttribute('data-staging-origin', getStaging(items[i]));
|
|
}
|
|
|
|
//dictionary for storing the selections data
|
|
//comprising an array of the currently selected items
|
|
//a reference to the selected items' owning container
|
|
//and a refernce to the current drop target container
|
|
var selections =
|
|
{
|
|
items : [],
|
|
owner : null,
|
|
droptarget : null
|
|
};
|
|
|
|
//function for selecting an item
|
|
function addSelection(item)
|
|
{
|
|
//if the owner reference is still null, set it to this item's parent
|
|
//so that further selection is only allowed within the same container
|
|
if(!selections.owner)
|
|
{
|
|
selections.owner = item.parentNode;
|
|
}
|
|
|
|
//or if that's already happened then compare it with this item's parent
|
|
//and if they're not the same container, return to prevent selection
|
|
else if(selections.owner != item.parentNode)
|
|
{
|
|
return;
|
|
}
|
|
|
|
//set this item's grabbed state
|
|
item.setAttribute('aria-grabbed', 'true');
|
|
|
|
//add it to the items array
|
|
selections.items.push(item);
|
|
}
|
|
|
|
//function for unselecting an item
|
|
function removeSelection(item)
|
|
{
|
|
//reset this item's grabbed state
|
|
item.setAttribute('aria-grabbed', 'false');
|
|
|
|
//then find and remove this item from the existing items array
|
|
for(var len = selections.items.length, i = 0; i < len; i ++)
|
|
{
|
|
if(selections.items[i] == item)
|
|
{
|
|
selections.items.splice(i, 1);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
//function for resetting all selections
|
|
function clearSelections()
|
|
{
|
|
//if we have any selected items
|
|
if(selections.items.length)
|
|
{
|
|
//reset the owner reference
|
|
selections.owner = null;
|
|
|
|
//reset the grabbed state on every selected item
|
|
for(var len = selections.items.length, i = 0; i < len; i ++)
|
|
{
|
|
selections.items[i].setAttribute('aria-grabbed', 'false');
|
|
}
|
|
|
|
//then reset the items array
|
|
selections.items = [];
|
|
}
|
|
}
|
|
|
|
//shorctut function for testing whether a selection modifier is pressed
|
|
function hasModifier(e)
|
|
{
|
|
return (e.ctrlKey || e.metaKey || e.shiftKey);
|
|
}
|
|
|
|
//function for applying dropeffect to the target containers
|
|
function addDropeffects()
|
|
{
|
|
//apply aria-dropeffect and tabindex to all targets apart from the owner
|
|
for(var len = targets.length, i = 0; i < len; i ++)
|
|
{
|
|
if
|
|
(
|
|
targets[i] != selections.owner
|
|
&&
|
|
targets[i].getAttribute('aria-dropeffect') == 'none'
|
|
)
|
|
{
|
|
targets[i].setAttribute('aria-dropeffect', 'move');
|
|
targets[i].setAttribute('tabindex', '0');
|
|
}
|
|
}
|
|
|
|
//remove aria-grabbed and tabindex from all items inside those containers
|
|
for(var len = items.length, i = 0; i < len; i ++)
|
|
{
|
|
if
|
|
(
|
|
items[i].parentNode != selections.owner
|
|
&&
|
|
items[i].getAttribute('aria-grabbed')
|
|
)
|
|
{
|
|
items[i].removeAttribute('aria-grabbed');
|
|
items[i].removeAttribute('tabindex');
|
|
}
|
|
}
|
|
}
|
|
|
|
//function for removing dropeffect from the target containers
|
|
function clearDropeffects()
|
|
{
|
|
//if we have any selected items
|
|
if(selections.items.length)
|
|
{
|
|
//reset aria-dropeffect and remove tabindex from all targets
|
|
for(var len = targets.length, i = 0; i < len; i ++)
|
|
{
|
|
if(targets[i].getAttribute('aria-dropeffect') != 'none')
|
|
{
|
|
targets[i].setAttribute('aria-dropeffect', 'none');
|
|
targets[i].removeAttribute('tabindex');
|
|
}
|
|
}
|
|
|
|
//restore aria-grabbed and tabindex to all selectable items
|
|
//without changing the grabbed value of any existing selected items
|
|
for(var len = items.length, i = 0; i < len; i ++)
|
|
{
|
|
if(!items[i].getAttribute('aria-grabbed'))
|
|
{
|
|
items[i].setAttribute('aria-grabbed', 'false');
|
|
items[i].setAttribute('tabindex', '0');
|
|
}
|
|
else if(items[i].getAttribute('aria-grabbed') == 'true')
|
|
{
|
|
items[i].setAttribute('tabindex', '0');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
//shortcut function for identifying an event element's target container
|
|
function getContainer(element)
|
|
{
|
|
do
|
|
{
|
|
if(element.nodeType == 1 && element.getAttribute('aria-dropeffect'))
|
|
{
|
|
return element;
|
|
}
|
|
}
|
|
while(element = element.parentNode);
|
|
|
|
return null;
|
|
}
|
|
|
|
//mousedown event to implement single selection
|
|
document.addEventListener('mousedown', function(e)
|
|
{
|
|
//if the element is a draggable item
|
|
if(e.target.getAttribute('draggable'))
|
|
{
|
|
//clear dropeffect from the target containers
|
|
clearDropeffects();
|
|
|
|
//if the multiple selection modifier is not pressed
|
|
//and the item's grabbed state is currently false
|
|
if
|
|
(
|
|
!hasModifier(e)
|
|
&&
|
|
e.target.getAttribute('aria-grabbed') == 'false'
|
|
)
|
|
{
|
|
//clear all existing selections
|
|
clearSelections();
|
|
|
|
//then add this new selection
|
|
addSelection(e.target);
|
|
}
|
|
}
|
|
|
|
//else [if the element is anything else]
|
|
//and the selection modifier is not pressed
|
|
else if(!hasModifier(e))
|
|
{
|
|
//clear dropeffect from the target containers
|
|
clearDropeffects();
|
|
|
|
//clear all existing selections
|
|
clearSelections();
|
|
}
|
|
|
|
//else [if the element is anything else and the modifier is pressed]
|
|
else
|
|
{
|
|
//clear dropeffect from the target containers
|
|
clearDropeffects();
|
|
}
|
|
|
|
}, false);
|
|
|
|
//mouseup event to implement multiple selection
|
|
document.addEventListener('mouseup', function(e)
|
|
{
|
|
//if the element is a draggable item
|
|
//and the multipler selection modifier is pressed
|
|
if(e.target.getAttribute('draggable') && hasModifier(e))
|
|
{
|
|
//if the item's grabbed state is currently true
|
|
if(e.target.getAttribute('aria-grabbed') == 'true')
|
|
{
|
|
//unselect this item
|
|
removeSelection(e.target);
|
|
|
|
//if that was the only selected item
|
|
//then reset the owner container reference
|
|
if(!selections.items.length)
|
|
{
|
|
selections.owner = null;
|
|
}
|
|
}
|
|
|
|
//else [if the item's grabbed state is false]
|
|
else
|
|
{
|
|
//add this additional selection
|
|
addSelection(e.target);
|
|
}
|
|
}
|
|
|
|
}, false);
|
|
|
|
//dragstart event to initiate mouse dragging
|
|
document.addEventListener('dragstart', function(e)
|
|
{
|
|
//if the element's parent is not the owner, then block this event
|
|
if(selections.owner != e.target.parentNode)
|
|
{
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
|
|
//[else] if the multiple selection modifier is pressed
|
|
//and the item's grabbed state is currently false
|
|
if
|
|
(
|
|
hasModifier(e)
|
|
&&
|
|
e.target.getAttribute('aria-grabbed') == 'false'
|
|
)
|
|
{
|
|
//add this additional selection
|
|
addSelection(e.target);
|
|
}
|
|
|
|
//we don't need the transfer data, but we have to define something
|
|
//otherwise the drop action won't work at all in firefox
|
|
//most browsers support the proper mime-type syntax, eg. "text/plain"
|
|
//but we have to use this incorrect syntax for the benefit of IE10+
|
|
e.dataTransfer.setData('text', '');
|
|
|
|
//apply dropeffect to the target containers
|
|
addDropeffects();
|
|
|
|
}, false);
|
|
|
|
//related variable is needed to maintain a reference to the
|
|
//dragleave's relatedTarget, since it doesn't have e.relatedTarget
|
|
var related = null;
|
|
|
|
//dragenter event to set that variable
|
|
document.addEventListener('dragenter', function(e)
|
|
{
|
|
related = e.target;
|
|
|
|
}, false);
|
|
|
|
//dragleave event to maintain target highlighting using that variable
|
|
document.addEventListener('dragleave', function(e)
|
|
{
|
|
//get a drop target reference from the relatedTarget
|
|
var droptarget = getContainer(related);
|
|
|
|
//if the target is the owner then it's not a valid drop target
|
|
if(droptarget == selections.owner)
|
|
{
|
|
droptarget = null;
|
|
}
|
|
|
|
//if the drop target is different from the last stored reference
|
|
//(or we have one of those references but not the other one)
|
|
if(droptarget != selections.droptarget)
|
|
{
|
|
//if we have a saved reference, clear its existing dragover class
|
|
if(selections.droptarget)
|
|
{
|
|
selections.droptarget.className =
|
|
selections.droptarget.className.replace(/ dragover/g, '');
|
|
}
|
|
|
|
//apply the dragover class to the new drop target reference
|
|
if(droptarget)
|
|
{
|
|
droptarget.className += ' dragover';
|
|
}
|
|
|
|
//then save that reference for next time
|
|
selections.droptarget = droptarget;
|
|
}
|
|
|
|
}, false);
|
|
|
|
//dragover event to allow the drag by preventing its default
|
|
document.addEventListener('dragover', function(e)
|
|
{
|
|
//if we have any selected items, allow them to be dragged
|
|
if(selections.items.length)
|
|
{
|
|
e.preventDefault();
|
|
}
|
|
|
|
}, false);
|
|
|
|
//dragend event to implement items being validly dropped into targets,
|
|
//or invalidly dropped elsewhere, and to clean-up the interface either way
|
|
document.addEventListener('dragend', function(e)
|
|
{
|
|
//if we have a valid drop target reference
|
|
//(which implies that we have some selected items)
|
|
if(selections.droptarget)
|
|
{
|
|
// OSRT modification: only move if location is changing.
|
|
if (getStaging(selections.droptarget) == getStaging(selections.items[0])) {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
|
|
// OSRT modification: place requests back in package list.
|
|
var target = selections.droptarget.parentElement.querySelector('ul.packages-list');
|
|
|
|
//append the selected items to the end of the target container
|
|
for(var len = selections.items.length, i = 0; i < len; i ++)
|
|
{
|
|
// OSRT modification: place in package list and determine if moved from origin.
|
|
// selections.droptarget.appendChild(selections.items[i]);
|
|
target.appendChild(selections.items[i]);
|
|
if (getStaging(selections.items[i]) != selections.items[i].getAttribute('data-staging-origin'))
|
|
{
|
|
selections.items[i].classList.add('osrt-moved');
|
|
}
|
|
else {
|
|
selections.items[i].classList.remove('osrt-moved');
|
|
}
|
|
}
|
|
|
|
// OSRT modification: after drag update overall summary of moves.
|
|
updateSummary();
|
|
|
|
//prevent default to allow the action
|
|
e.preventDefault();
|
|
}
|
|
|
|
//if we have any selected items
|
|
if(selections.items.length)
|
|
{
|
|
//clear dropeffect from the target containers
|
|
clearDropeffects();
|
|
|
|
//if we have a valid drop target reference
|
|
if(selections.droptarget)
|
|
{
|
|
//reset the selections array
|
|
clearSelections();
|
|
|
|
//reset the target's dragover class
|
|
selections.droptarget.className =
|
|
selections.droptarget.className.replace(/ dragover/g, '');
|
|
|
|
//reset the target reference
|
|
selections.droptarget = null;
|
|
}
|
|
}
|
|
|
|
}, false);
|
|
};
|