Skip to content
Open
80 changes: 80 additions & 0 deletions app/components/sortable_lists/move_menu_items.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# frozen_string_literal: true

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

module SortableLists
# Shared vocabulary and rendering for the four-direction move items every
# sortable-lists consumer offers (#DREAM-775).
#
# `move_to:` carries the acts_as_list value surfaces that still persist
# server-side build their hrefs from; each drops it on moving to `prev_id`.
class MoveMenuItems
include ActionView::RecordIdentifier

DIRECTIONS = [
{ direction: :top, label: :label_sort_highest, icon: :"move-to-top", move_to: :highest }.freeze,
{ direction: :up, label: :label_sort_higher, icon: :"chevron-up", move_to: :higher }.freeze,
{ direction: :down, label: :label_sort_lower, icon: :"chevron-down", move_to: :lower }.freeze,
{ direction: :bottom, label: :label_sort_lowest, icon: :"move-to-bottom", move_to: :lowest }.freeze
].freeze

def initialize(dom_key:)
@dom_key = dom_key
end

def render_into(menu)
DIRECTIONS.each { |entry| add_item(menu, entry) }
end

private

attr_reader :dom_key

def add_item(menu, entry)
direction = entry[:direction]

# The `data:` hash must live at item level so Primer renders it on the
# ActionList `<li>`: the controller's targets and the action-menu API's
# `disableItem`/`enableItem` both address that element, and the click
# action rides it via the bubbled button click.
menu.with_item(
id: dom_target(dom_key, :menu, direction),
label: I18n.t(entry[:label]),
tag: :button,
data: {
sortable_lists__item_target: "moveItem",
sortable_lists__item_direction_param: direction,
action: "click->sortable-lists--item#move"
}
) do |item|
item.with_leading_visual_icon(icon: entry[:icon])
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,63 @@ describe('Contextual action menu controller', () => {
expect(origins).toEqual(['pointer', 'keyboard']);
});

it('dispatches a cancelable pointer beforeOpen before the presenter opens', async () => {
const { card } = renderCard();
await nextFrame();
const sequence:string[] = [];
const events:CustomEvent<{ origin:string }>[] = [];
card.addEventListener('contextual-action-menu:beforeOpen', (event) => {
sequence.push('beforeOpen');
events.push(event as CustomEvent<{ origin:string }>);
});
vi.spyOn(ContextualActionMenu.prototype, 'openAtPoint').mockImplementation(() => {
sequence.push('present');
});

contextMenu(card);

expect(sequence).toEqual(['beforeOpen', 'present']);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ cancelable: true, detail: { origin: 'pointer' } });
});

it.each([
['Context Menu key', 'ContextMenu', {}],
['Shift+F10', 'F10', { shiftKey: true }],
])('dispatches a cancelable keyboard beforeOpen before the presenter opens for %s', async (_label, key, init) => {
const { card } = renderCard();
await nextFrame();
const sequence:string[] = [];
const events:CustomEvent<{ origin:string }>[] = [];
card.addEventListener('contextual-action-menu:beforeOpen', (event) => {
sequence.push('beforeOpen');
events.push(event as CustomEvent<{ origin:string }>);
});
vi.spyOn(ContextualActionMenu.prototype, 'openAtInvoker').mockImplementation(() => {
sequence.push('present');
});

keydown(card, key, init);

expect(sequence).toEqual(['beforeOpen', 'present']);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ cancelable: true, detail: { origin: 'keyboard' } });
});

it('cancellation prevents both pointer and keyboard presentation', async () => {
const { card } = renderCard();
await nextFrame();
const openAtPoint = vi.spyOn(ContextualActionMenu.prototype, 'openAtPoint');
const openAtInvoker = vi.spyOn(ContextualActionMenu.prototype, 'openAtInvoker');
card.addEventListener('contextual-action-menu:beforeOpen', (event) => event.preventDefault());

contextMenu(card);
keydown(card, 'ContextMenu');

expect(openAtPoint).not.toHaveBeenCalled();
expect(openAtInvoker).not.toHaveBeenCalled();
});

it('leaves the native context menu alone when the card has no action menu', async () => {
const { card } = renderCardWithoutMenu();
await nextFrame();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1575,6 +1575,85 @@ describe('Sortable lists controller', () => {
])).toEqual([{ type: 'sprint', id: '1' }]);
});

describe('action menu invocation scope', () => {
const selectItem = (item:HTMLElement, init:MouseEventInit = {}):void => {
item.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, ...init }));
};

it('preserves a selected eligible invoker and returns the live ordered batch', async () => {
const { root, items } = renderSelectableRoot();
await ctx.nextFrame();
const controller = ctx.application.getControllerForElementAndIdentifier(root, 'sortable-lists') as SortableListsControllerType;
selectItem(items[1]);
selectItem(items[0], { ctrlKey: true });

expect(controller.selectForAction(items[1])).toMatchObject({
kind: 'batch'
});
expect(items.filter((item) => item.hasAttribute('data-batch-selected'))).toEqual([items[0], items[1]]);
});

it('replaces an unrelated selection when an unselected eligible invoker opens its menu', async () => {
const { root, items } = renderSelectableRoot();
await ctx.nextFrame();
const controller = ctx.application.getControllerForElementAndIdentifier(root, 'sortable-lists') as SortableListsControllerType;
selectItem(items[0]);
selectItem(items[1], { ctrlKey: true });

expect(controller.selectForAction(items[2])).toMatchObject({
kind: 'batch'
});
expect(items.filter((item) => item.hasAttribute('data-batch-selected'))).toEqual([items[2]]);
});

it('preserves the current selection when a fixed invoker opens its singular menu', async () => {
const { root, items } = renderSelectableRoot();
items[2].setAttribute('data-sortable-lists--item-mobility-value', 'fixed');
await ctx.nextFrame();
const controller = ctx.application.getControllerForElementAndIdentifier(root, 'sortable-lists') as SortableListsControllerType;
selectItem(items[0]);
selectItem(items[1], { ctrlKey: true });
announceSpy.mockClear();

expect(controller.selectForAction(items[2])).toEqual({ kind: 'refused', items: [] });
expect(items.filter((item) => item.hasAttribute('data-batch-selected'))).toEqual([items[0], items[1]]);
expect(announcedMessages()).toEqual([]);
});

it('returns a prospective one-card scope without changing visible selection while busy', async () => {
const { root, items } = renderSelectableRoot();
await ctx.nextFrame();
const controller = ctx.application.getControllerForElementAndIdentifier(root, 'sortable-lists') as SortableListsControllerType;
selectItem(items[0]);
selectItem(items[1], { ctrlKey: true });
root.setAttribute('data-sortable-lists-busy', 'true');

expect(controller.selectForAction(items[2])).toMatchObject({
kind: 'batch'
});
expect(items.filter((item) => item.hasAttribute('data-batch-selected'))).toEqual([items[0], items[1]]);
});

it('settles duplicate pre-open delivery once without repeating its selection announcement', async () => {
const { root, items } = renderSelectableRoot();
await ctx.nextFrame();
const controller = ctx.application.getControllerForElementAndIdentifier(root, 'sortable-lists') as SortableListsControllerType;
selectItem(items[0]);
selectItem(items[1], { ctrlKey: true });
announceSpy.mockClear();

const contextualScope = controller.selectForAction(items[2]);
const popoverScope = controller.selectForAction(items[2]);

expect(contextualScope).toMatchObject({ kind: 'batch' });
expect(popoverScope).toMatchObject({ kind: 'batch' });
expect(items.filter((item) => item.hasAttribute('data-batch-selected'))).toEqual([items[2]]);
expect(announcedMessages()).toEqual([
['[selected:1]', { politeness: 'polite' }],
]);
});
});

describe('direct destination moves', () => {
const destination = { type: 'inbox', id: null };

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,61 +26,58 @@
// See COPYRIGHT and LICENSE files for more details.
//++

import { refreshMenuAvailability } from './menu-availability';
import type { ActionMenuElement } from '@openproject/primer-view-components/app/components/primer/alpha/action_menu/action_menu_element';
import { SortableActionMenu } from './action-menu';

function fixture(hideUnavailable = true) {
const list = document.createElement('ul');
list.innerHTML = '<li role="separator"></li><li data-sortable-lists-destinations=\'[ { "type": "inbox", "id": null } ]\'></li><li></li><anchored-position popover="auto"></anchored-position>';
const [divider, destination, moveMenu] = Array.from(list.querySelectorAll('li'));
list.innerHTML = '<li role="separator"></li><li></li><li></li><anchored-position popover="auto"></anchored-position>';
const [groupDivider, destination, moveSubmenu] = Array.from(list.querySelectorAll('li'));
const moveItem = document.createElement('li');
moveItem.setAttribute('data-sortable-lists-item-direction-param', 'top');
moveMenu.append(moveItem);
moveSubmenu.append(moveItem);
const menu = {
showItem: (item:HTMLElement) => item.removeAttribute('hidden'),
hideItem: (item:HTMLElement) => item.setAttribute('hidden', ''),
enableItem: (item:HTMLElement) => item.removeAttribute('aria-disabled'),
disableItem: (item:HTMLElement) => item.setAttribute('aria-disabled', 'true'),
};
const input:Parameters<typeof refreshMenuAvailability>[0] = {
menu,
scope: { kind: 'batch', items: [document.createElement('div'), document.createElement('div')] },
} as ActionMenuElement;
const projection = new SortableActionMenu(menu, hideUnavailable, null);
const elements = {
destinationItems: [destination],
moveItems: [moveItem],
moveMenu,
divider,
hideUnavailable,
identifier: 'sortable-lists-item',
availableDestinations: vi.fn(() => []),
moveAvailability: vi.fn(() => ({ top: false, up: false, down: false, bottom: false })),
moveSubmenu,
groupDivider,
invokerGroup: null,
batchGroup: null,
};
return { input, divider, destination, moveMenu, moveItem };
const scope = { batch: false, count: 1 };
const availability = { destinationItem: () => false, moveItem: () => false };
return { projection, elements, scope, availability, destination, moveSubmenu, moveItem, groupDivider };
}

describe('menu availability projection', () => {
it('ignores the trailing overlay when every real action is hidden', () => {
const { input, divider, destination, moveMenu, moveItem } = fixture();
refreshMenuAvailability(input);
for (const element of [divider, destination, moveMenu, moveItem]) {
const { projection, elements, scope, availability, groupDivider, destination, moveSubmenu, moveItem } = fixture();
projection.project(elements, scope, availability);
for (const element of [groupDivider, destination, moveSubmenu, moveItem]) {
expect(element.hasAttribute('hidden')).toBe(true);
}
expect(input.moveAvailability).toHaveBeenCalled();
});

it('restores the position group when the batch becomes positionable', () => {
const { input, divider, moveMenu, moveItem } = fixture();
refreshMenuAvailability(input);
input.moveAvailability = vi.fn(() => ({ top: true, up: true, down: false, bottom: false }));
refreshMenuAvailability(input);
for (const element of [divider, moveMenu, moveItem]) {
const { projection, elements, scope, availability, groupDivider, moveSubmenu, moveItem } = fixture();
projection.project(elements, scope, availability);
projection.project(elements, scope, { ...availability, moveItem: () => true });
for (const element of [groupDivider, moveSubmenu, moveItem]) {
expect(element.hasAttribute('hidden')).toBe(false);
}
});

it('disables unavailable actions without hiding the group separator', () => {
const { input, divider, destination, moveMenu, moveItem } = fixture(false);
refreshMenuAvailability(input);
expect(divider.hasAttribute('hidden')).toBe(false);
for (const element of [destination, moveMenu, moveItem]) {
const { projection, elements, scope, availability, groupDivider, destination, moveSubmenu, moveItem } = fixture(false);
projection.project(elements, scope, availability);
expect(groupDivider.hasAttribute('hidden')).toBe(false);
for (const element of [destination, moveSubmenu, moveItem]) {
expect(element.getAttribute('aria-disabled')).toBe('true');
expect(element.hasAttribute('hidden')).toBe(false);
}
Expand Down
Loading
Loading