diff --git public/javascripts/context_menu.js public/javascripts/context_menu.js index 2c5886364..95ee4d365 100644 --- public/javascripts/context_menu.js +++ public/javascripts/context_menu.js @@ -1,14 +1,16 @@ /* Redmine - project management software Copyright (C) 2006-2022 Jean-Philippe Lang */ -var observing; +let observing; function rightClick(event) { - var target = $(event.target); + const target = $(event.target); if (target.is('a:not(.js-contextmenu)')) {return;} - var tr = target.closest('.hascontextmenu').first(); + + const tr = target.closest('.hascontextmenu').first(); if (tr.length < 1) {return;} event.preventDefault(); + if (!isSelected(tr)) { unselectAll(); addSelection(tr); @@ -18,8 +20,7 @@ function rightClick(event) { } function click(event) { - var target = $(event.target); - var lastSelected; + const target = $(event.target); if (target.is('a') && target.hasClass('submenu')) { event.preventDefault(); @@ -27,48 +28,12 @@ function click(event) { } hide(); if (target.is('a') || target.is('img')) { return; } + if (event.which == 1 || (navigator.appVersion.match(/\bMSIE\b/))) { - var tr = target.closest('.hascontextmenu').first(); + const tr = target.closest('.hascontextmenu').first(); if (tr.length > 0) { // a row was clicked - if (target.is('td.checkbox')) { - // the td containing the checkbox was clicked, toggle the checkbox - target = target.find('input').first(); - target.prop("checked", !target.prop("checked")); - } - if (target.is('input')) { - // a checkbox may be clicked - if (target.prop('checked')) { - tr.addClass('context-menu-selection'); - } else { - tr.removeClass('context-menu-selection'); - } - } else { - if (event.ctrlKey || event.metaKey) { - toggleSelection(tr); - clearDocumentSelection(); - } else if (event.shiftKey) { - lastSelected = getLastSelected(); - if (lastSelected.length) { - var toggling = false; - $('.hascontextmenu').each(function(){ - if (toggling || $(this).is(tr)) { - addSelection($(this)); - clearDocumentSelection(); - } - if ($(this).is(tr) || $(this).is(lastSelected)) { - toggling = !toggling; - } - }); - } else { - addSelection(tr); - } - } else { - unselectAll(); - addSelection(tr); - } - setLastSelected(tr); - } + selectRows(target, tr, event); } else { // click is outside the rows if (target.is('a') && (target.hasClass('disabled') || target.hasClass('submenu'))) { @@ -82,85 +47,141 @@ function click(event) { } } +export function selectRows($element, row, { ctrlKey, metaKey, shiftKey } ) { + let $target = $element; + if ($target.is('td.checkbox')) { + // the td containing the checkbox was clicked, toggle the checkbox + $target = $target.find('input').first(); + $target.prop("checked", !$target.prop("checked")); + } + if ($target.is('input')) { + // a checkbox may be clicked + row.toggleClass('context-menu-selection', $target.prop('checked')) + } else { + if (ctrlKey || metaKey) { + toggleSelection(row); + clearDocumentSelection(); + } else if (shiftKey) { + const lastSelected = getLastSelected(); + if (lastSelected.length) { + const selected = addMultipleSelection($('.hascontextmenu'), lastSelected, row); + selected.forEach($e => addSelection($e)); + } else { + addSelection(row); + } + } else { + unselectAll(); + addSelection(row); + } + setLastSelected(row); + } +} + +export function addMultipleSelection($rows, lastSelected, clicked) { + let toggling = false; + const selected = []; + $rows.each((i, elm) => { + const $elm = $(elm); + if (!$elm.is(lastSelected) && (toggling || $elm.is(clicked))) { + selected.push($elm); + clearDocumentSelection(); + } + if ($elm.is(lastSelected) !== $elm.is(clicked)) { + toggling = !toggling; + } + }); + return selected; +} + function create() { if ($('#context-menu').length < 1) { - var menu = document.createElement("div"); + const menu = document.createElement("div"); menu.setAttribute("id", "context-menu"); menu.setAttribute("style", "display:none;"); document.getElementById("content").appendChild(menu); } } -function show(event) { - var mouse_x = event.pageX; - var mouse_y = event.pageY; - var mouse_y_c = event.clientY; - var render_x = mouse_x; - var render_y = mouse_y; - var dims; - var menu_width; - var menu_height; - var window_width; - var window_height; - var max_width; - var max_height; - var url; - - $('#context-menu').css('left', (render_x + 'px')); - $('#context-menu').css('top', (render_y + 'px')); - $('#context-menu').html(''); - - url = $(event.target).parents('form').first().data('cm-url'); +function show({ pageX: mouse_x, pageY: mouse_y, clientY: mouse_y_c, target: target }) { + const $element = $('#context-menu'); + $element.css({ left: `${mouse_x}px`, top: `${mouse_y}px` }); + $element.html(''); + + const form = $(target).parents('form').first(); + const url = form.data('cm-url'); if (url == null) {alert('no url'); return;} $.ajax({ url: url, - data: $(event.target).parents('form').first().serialize(), - success: function(data, textStatus, jqXHR) { - $('#context-menu').html(data); - menu_width = $('#context-menu').width(); - menu_height = $('#context-menu').height(); - max_width = mouse_x + 2*menu_width; - max_height = mouse_y_c + menu_height; - - var ws = window_size(); - window_width = ws.width; - window_height = ws.height; - - /* display the menu above and/or to the left of the click if needed */ - if (max_width > window_width) { - render_x -= menu_width; - $('#context-menu').addClass('reverse-x'); - } else { - $('#context-menu').removeClass('reverse-x'); - } + data: form.serialize(), + success: (data, textStatus, jqXHR) => { + $element.html(data); - if (max_height > window_height) { - render_y -= menu_height; - $('#context-menu').addClass('reverse-y'); - // adding class for submenu - if (mouse_y_c < 325) { - $('#context-menu .folder').addClass('down'); - } - } else { - // adding class for submenu - if (window_height - mouse_y_c < 345) { - $('#context-menu .folder').addClass('up'); - } - $('#context-menu').removeClass('reverse-y'); - } + const menu_width = $element.width(); + const menu_height = $element.height(); + const max_width = mouse_x + 2 * menu_width; + const max_height = mouse_y_c + menu_height; + const ws = window_size(); + + const is_reverse_x = max_width > ws.width; + const arg_x = { render_pos: mouse_x, menu_size: menu_width, position: 'left', class_name: 'reverse_x' } + const action_x = is_reverse_x ? reverseRenderAction(arg_x) + : normalRenderAction(arg_x); - if (render_x <= 0) render_x = 1; - if (render_y <= 0) render_y = 1; - $('#context-menu').css('left', (render_x + 'px')); - $('#context-menu').css('top', (render_y + 'px')); - $('#context-menu').show(); + const is_reverse_y = max_height > ws.height; + const arg_y = { render_pos: mouse_y, menu_size: menu_height, position: 'top', class_name: 'reverse_y' } + const action_y = is_reverse_y ? reverseRenderAction(arg_y) + : normalRenderAction(arg_y); - //if (window.parseStylesheets) { window.parseStylesheets(); } // IE + const arg_submenu = { window_height: ws.height, mouse_y_c } + const action_submenu = is_reverse_y ? reverseFolderAction(arg_submenu) + : normalFolderAction(arg_submenu); + action_x($element); + action_y($element); + // adding class for submenu + action_submenu($element); + + $element.show(); } }); } +export function reverseRenderAction({ render_pos, menu_size, position, class_name }) { + return element => { + element.addClass(class_name) + const n = adjustLessThanZero(render_pos - menu_size); + element.css(position, `${n}px`); + } +} + +export function normalRenderAction({ render_pos, menu_size, position, class_name }) { + return element => { + element.removeClass(class_name); + const n = adjustLessThanZero(render_pos); + element.css(position, `${n}px`); + } +} + +export function reverseFolderAction({ window_height, mouse_y_c }) { + return element => { + if (mouse_y_c < 325) { + element.find('.folder').addClass('down'); + } + } +} + +export function normalFolderAction({ window_height, mouse_y_c }) { + return element => { + if (window_height - mouse_y_c < 345) { + element.find('.folder').addClass('up'); + } + } +} + +function adjustLessThanZero(n) { + return n <= 0 ? 1 : n; +} + function setLastSelected(tr) { $('.cm-last').removeClass('cm-last'); tr.addClass('cm-last'); @@ -172,8 +193,8 @@ function getLastSelected() { function unselectAll() { $('input[type=checkbox].toggle-selection').prop('checked', false); - $('.hascontextmenu').each(function(){ - removeSelection($(this)); + $('.hascontextmenu').each((i, el) => { + removeSelection($(el)); }); $('.cm-last').removeClass('cm-last'); } @@ -220,24 +241,26 @@ function clearDocumentSelection() { function init() { create(); unselectAll(); - + if (!observing) { $(document).click(click); $(document).contextmenu(rightClick); $(document).on('click', '.js-contextmenu', rightClick); observing = true; } + $('input[type=checkbox].toggle-selection').on('change', toggleIssuesSelection); } -function toggleIssuesSelection(el) { - var checked = $(this).prop('checked'); - var boxes = $(this).parents('table').find('input[name=ids\\[\\]]'); +function toggleIssuesSelection(event) { + const $target = $(event.target) + const checked = $target.prop('checked'); + const boxes = $target.parents('table').find('input[name=ids\\[\\]]'); boxes.prop('checked', checked).parents('.hascontextmenu').toggleClass('context-menu-selection', checked); } function window_size() { - var w; - var h; + let w; + let h; if (window.innerWidth) { w = window.innerWidth; h = window.innerHeight; @@ -251,7 +274,4 @@ function window_size() { return {width: w, height: h}; } -$(document).ready(function(){ - init(); - $('input[type=checkbox].toggle-selection').on('change', toggleIssuesSelection); -}); +init(); diff --git test/javascripts/context_menu.html test/javascripts/context_menu.html new file mode 100644 index 000000000..d9ba3e619 --- /dev/null +++ test/javascripts/context_menu.html @@ -0,0 +1,23 @@ + + + + + +
+ + + + + + + + +
+
+ + + + diff --git test/javascripts/context_menu.test.js test/javascripts/context_menu.test.js new file mode 100644 index 000000000..ccbc91ac4 --- /dev/null +++ test/javascripts/context_menu.test.js @@ -0,0 +1,171 @@ +import { setup, suite, test } from 'mocha'; +import { assert } from 'chai'; +import { prepare } from './helper.js'; + +suite('context menu', () => { + + setup((done) => { + prepare(import.meta.url, './context_menu.html').then(dom => { + dom.window.addEventListener('load', (e) => { + global.window = dom.window; + global.document = dom.window.document; + global.$ = dom.window.$; + done(); + }); + }); + }); + + test('create contextmenu element', async () => { + await import('../../public/javascripts/context_menu.js'); + const menu = document.getElementById('context-menu'); + assert.isNotNull(menu); + }); + + suite('when a row is clicked', async () => { + let $rows; + let selectRows; + + setup(async () => { + $rows = $('.hascontextmenu'); + ({ selectRows } = await import('../../public/javascripts/context_menu.js')); + }); + + test('When the checkbox is clicked directly, select the row', () => { + const target = $($rows[0]).find('input'); + target.prop('checked', true); + const tr = target.closest('.hascontextmenu').first(); + selectRows(target, tr, {} ) + assert.isTrue(tr.hasClass('context-menu-selection')); + }); + + test('When the td containing the checkbox is clicked, toggle the checkbox and select the row', () => { + const target = $($rows[0]).find('td.checkbox'); + const tr = target.closest('.hascontextmenu').first(); + selectRows(target, tr, {} ) + assert.isTrue(target.find('input').prop('checked')); + assert.isTrue(tr.hasClass('context-menu-selection')); + }); + + test('When the td not containing the checkbox is clicked with no modifier, toggle the checkbox and select the row', () => { + const target0 = $($rows[0]).find('td.noinput'); + const tr0 = target0.closest('.hascontextmenu').first(); + selectRows(target0, tr0, {} ) + assert.isTrue(tr0.find('input').prop('checked')); + assert.isTrue(tr0.hasClass('context-menu-selection')); + + const target1 = $($rows[1]).find('td.noinput'); + const tr1 = target1.closest('.hascontextmenu').first(); + selectRows(target1, tr1, {} ) + + assert.isFalse(tr0.find('input').prop('checked')); + assert.isFalse(tr0.hasClass('context-menu-selection')); + + assert.isTrue(tr1.find('input').prop('checked')); + assert.isTrue(tr1.hasClass('context-menu-selection')); + }); + + test('When the td not containing the checkbox is clicked with ctrl key, toggle the checkbox and select the row', () => { + const target0 = $($rows[0]).find('td.noinput'); + const tr0 = target0.closest('.hascontextmenu').first(); + selectRows(target0, tr0, {} ) + assert.isTrue(tr0.find('input').prop('checked')); + assert.isTrue(tr0.hasClass('context-menu-selection')); + + const target3 = $($rows[3]).find('td.noinput'); + const tr3 = target3.closest('.hascontextmenu').first(); + selectRows(target3, tr3, {ctrlKey:true} ) + + assert.isTrue(tr0.find('input').prop('checked')); + assert.isTrue(tr0.hasClass('context-menu-selection')); + + assert.isTrue(tr3.find('input').prop('checked')); + assert.isTrue(tr3.hasClass('context-menu-selection')); + }); + }); + + suite('With shift key + left click, multiple rows can be selected ', async () => { + + let $rows; + let addMultipleSelection; + + setup(async () => { + $rows = $('.hascontextmenu'); + ({ addMultipleSelection } = await import('../../public/javascripts/context_menu.js')); + }); + + test('The last selected row is lower than the clicked', async () => { + const selected = await addMultipleSelection($rows, $rows[5], $rows[0]); + assert.equal(selected.length, 5); + }); + + test('The last selected row is above the clicked', async () => { + const selected = await addMultipleSelection($rows, $rows[0], $rows[5]); + assert.equal(selected.length, 5); + }); + + test('The last selected row is same as the clicked', async () => { + const selected = await addMultipleSelection($rows, $rows[0], $rows[0]); + assert.equal(selected.length, 0); + }); + }); + + suite('get context menu position', () => { + + let $element; + let reverseRenderAction; + let normalRenderAction; + let reverseFolderAction; + let normalFolderAction; + + setup(async () => { + const div = await document.getElementById('menu'); + $element = $(div); + ({ reverseRenderAction, normalRenderAction, reverseFolderAction, normalFolderAction } = await import('../../public/javascripts/context_menu.js')); + }); + + test('reverseRenderAction returns function to change DOM Element', () => { + const obj = { render_pos: 100, menu_size: 50, position: 'left', class_name: 'reverse-x' } + const action = reverseRenderAction(obj); + action($element); + assert.isTrue($element.hasClass('reverse-x')); + assert.equal('50px', $element.css('left')); + }); + + test('normalRenderAction returns function to change DOM Element', () => { + const obj = { render_pos: 100, menu_size: 50, position: 'left', class_name: 'reverse-x' } + const action = normalRenderAction(obj); + action($element); + assert.isFalse($element.hasClass('reverse-x')); + assert.equal('100px', $element.css('left')); + }); + + test('reverseFolderAction returns funtion to change submenu element', () => { + const obj = { window_height: 100, mouse_y_c: 100 } + const action = reverseFolderAction(obj) + action($element); + assert.isTrue($element.find('.folder').toArray().every(e => e.classList.contains('down'))); + }); + + test('reverseFolderAction returns funtion that dont change submenu element', () => { + const obj = { window_height: 100, mouse_y_c: 325 } + const action = reverseFolderAction(obj) + action($element); + assert.isTrue($element.find('.folder').toArray().every(e => !e.classList.contains('down'))); + }); + + test('normalFolderAction returns funtion to change submenu element', () => { + const obj = { window_height: 100, mouse_y_c: 100 } + const action = normalFolderAction(obj) + action($element); + assert.isTrue($element.find('.folder').toArray().every(e => e.classList.contains('up'))); + }); + + test('normalFolderAction returns funtion that dont change submenu element', () => { + const obj = { window_height: 445, mouse_y_c: 100 } + const action = normalFolderAction(obj) + action($element); + assert.isTrue($element.find('.folder').toArray().every(e => !e.classList.contains('up'))); + }); + }); +}); + diff --git test/javascripts/helper.js test/javascripts/helper.js new file mode 100644 index 000000000..754154286 --- /dev/null +++ test/javascripts/helper.js @@ -0,0 +1,12 @@ +import path from 'path'; +import { JSDOM } from 'jsdom'; + +export const prepare = (url, filename) => { + const __dirname = path.dirname(new URL(url).pathname) + const html = path.resolve(__dirname, filename) + const options = { + runScripts: "dangerously", + resources: "usable" + } + return JSDOM.fromFile(html, options); +}