Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions app/components/open_project/common/border_box_list_component.sass
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,16 @@
// A transparent (not absent) border keeps the first row's height stable
// when a state recolors it. These recolors repeat the state rules from
// further down because this selector outweighs them.
//
// The two states below live on different elements: `data-batch-selected`
// is written on the row itself (the sortable item id it keys off is a row
// attribute), while `aria-current` is maintained by the backlogs work
// package controller on the card nested inside it. Match each accordingly
// rather than assuming both sit in the same place.
> ul > .Box-row:first-of-type
border-top-color: transparent

&:has(> .Box-card[data-selected])
&[data-batch-selected]
border-top-color: var(--box-list-item-selected-border-color)

&:has(> .Box-card[aria-current="true"])
Expand Down Expand Up @@ -196,10 +202,19 @@
&:has(> .Box-card:focus-visible)
background-color: var(--bgColor-muted)

// Selected state: applied synchronously on activation by the
// backlogs--work-package controller (single selection), so the list reacts
// before the split screen has loaded.
.Box-row:has(> .Box-card[data-selected])
// Batch selection: the set of work packages chosen together for a batch
// action, maintained by the sortable-lists root. Distinct from the current
// work package below: batch selection fills the row's background in
// addition to its border, while the current work package's stronger
// border wins on its own, with no fill.
//
// `data-batch-selected` is written directly on the `.Box-row` (the sortable
// item element the selection controller walks), not on a child, so it is
// matched as an attribute of the row itself. The current-work-package rule
// just below is different: `aria-current` is set on the `.Box-card` nested
// inside the row, hence its `:has(> .Box-card[...])` form. Keep the two
// asymmetric rather than "fixing" one to match the other.
.Box-row[data-batch-selected]
background-color: var(--box-list-item-selected-bg-color)
@include op-box-list-item-edge-borders(var(--box-list-item-selected-border-color))

Expand All @@ -209,7 +224,7 @@

// Current state: the card is open in the split screen. aria-current is
// maintained by the backlogs--work-package controller once the URL reflects
// the details pane; the strong border wins over a simultaneous selected state.
// the details pane; the strong border wins over a simultaneous batch selection.
.Box-row:has(> .Box-card[aria-current="true"])
@include op-box-list-item-edge-borders(var(--box-list-item-pressed-border-color))

Expand Down
11 changes: 11 additions & 0 deletions config/locales/js-en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,17 @@ en:
move_failed_rolled_back: "Move failed. %{label} returned to its previous position."
moved: "%{label} moved to position %{position} of %{total}"
moved_to_list: "%{label} moved to %{list}, position %{position} of %{total}"
selection:
cleared: "Selection cleared."
not_selectable: "Selection unchanged. This item takes no part in this list's ordering."
range_blocked: "Selection unchanged. That range contains an item that takes no part in this list's ordering."
range_restarted:
one: "Could not extend the range. 1 item selected."
other: "Could not extend the range. %{count} items selected."
range_unavailable: "Selection unchanged. Expand this list to select that range."
selected:
one: "1 item selected."
other: "%{count} items selected."

spot:
drop_modal:
Expand Down
2 changes: 2 additions & 0 deletions frontend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
- `./src/common/` - Framework-agnostic modules (the `core-common` alias), importable from both Angular and Stimulus. Code belongs here when it depends on neither framework and both sides need it; a helper only Stimulus controllers use belongs in `./src/stimulus/helpers/` instead.
- `./src/stimulus/` - Stimulus controllers
- `./src/turbo/` - Turbo integration
- `sortable-lists` batch selection is opt-in: a root enables it with a `selectionEnabled` value, and no other consumer's behavior changes. A root also sets `announcementScope`, so the shared controller's announcements speak the consumer's vocabulary instead of "item", and `selectionDescriptionId`, pointing at one shared element every selected card references via `aria-describedby`. Items declare `mobility` — `fixed`, `confined` or `free` — which gates dragging, selection eligibility, and positional moves alike. A missing value means `free`, so a consumer that renders none keeps working; an unrecognised one falls closed to `fixed` rather than handing the user controls the server will refuse. The pure selection model lives in `./src/common/batch-selection.ts` (framework-agnostic, so Angular consumers can adopt it); the DOM-facing adapter is `sortable-lists/selection.ts`, and gesture interpretation sits behind `sortable-lists/selection-orchestrator.ts`, which takes a narrow host port and imports no Stimulus. Selection identity is `(type, id)`, never the id alone: ids are unique per source table, so a nested list of another type can hold a colliding one. A root must render exactly one instance of each `(type, id)`, and an item declaring no type is refused as a candidate. A batch holds one item type — that cohort rule is orchestrator policy, not a constraint of the model, since identity namespacing and batch compatibility are different concerns. Ranges and select-all (Ctrl/Cmd+A) are both confined to the focused card's list; selecting across lists is a deliberate gap, reserved for a separate mechanism. An item belongs to its nearest ancestor root, so an independently nested root is an ownership boundary. Batch movement is not implemented: a drag still moves one card and collapses any wider selection onto it — that's a later work package.
- `data-batch-selected` is written on the sortable item element — the row, in Backlogs — while `aria-current` is written on the card inside it. A stylesheet assuming both live on the same element will silently paint nothing while attribute assertions stay green.

## Configuration Files

Expand Down
171 changes: 171 additions & 0 deletions frontend/src/common/batch-selection.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
//-- 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.
//++

import { BatchSelection, selectionKey } from './batch-selection';

describe('BatchSelection', () => {
let selection:BatchSelection;

beforeEach(() => {
selection = new BatchSelection();
});

it('starts empty with no anchor', () => {
expect(selection.size).toBe(0);
expect(selection.anchor).toBeNull();
});

it('replaces the batch and establishes the anchor', () => {
selection.replace({ type: 'work_package', id: '1' }, 'sprint:7');
selection.replace({ type: 'work_package', id: '2' }, 'sprint:7');

expect(selection.items().map((entry) => entry.id)).toEqual(['2']);
expect(selection.anchor).toEqual({ type: 'work_package', id: '2', listKey: 'sprint:7' });
});

it('re-bases the anchor when toggling on', () => {
selection.replace({ type: 'work_package', id: '1' }, 'sprint:7');
selection.toggle({ type: 'work_package', id: '2' }, 'sprint:7');

expect(selection.items().map((entry) => entry.id)).toEqual(['1', '2']);
expect(selection.anchor).toEqual({ type: 'work_package', id: '2', listKey: 'sprint:7' });
});

// The anchor deliberately survives its own deselection: the next Shift
// gesture still measures its range from the card the user last touched.
it('re-bases the anchor when toggling off', () => {
selection.replace({ type: 'work_package', id: '1' }, 'sprint:7');
selection.toggle({ type: 'work_package', id: '1' }, 'sprint:7');

expect(selection.size).toBe(0);
expect(selection.anchor).toEqual({ type: 'work_package', id: '1', listKey: 'sprint:7' });
});

it('replaces the batch with a range and preserves the anchor', () => {
selection.replace({ type: 'work_package', id: '1' }, 'sprint:7');
selection.range([{ type: 'work_package', id: '1' }, { type: 'work_package', id: '2' }, { type: 'work_package', id: '3' }]);
selection.range([{ type: 'work_package', id: '1' }, { type: 'work_package', id: '2' }]);

expect(selection.items().map((entry) => entry.id)).toEqual(['1', '2']);
expect(selection.anchor).toEqual({ type: 'work_package', id: '1', listKey: 'sprint:7' });
});

it('selects all with an explicit anchor', () => {
selection.selectAll([{ type: 'work_package', id: '3' }, { type: 'work_package', id: '1' }, { type: 'work_package', id: '2' }], { type: 'work_package', id: '2', listKey: 'sprint:7' });

expect(selection.items().map((entry) => entry.id)).toEqual(['3', '1', '2']);
expect(selection.anchor).toEqual({ type: 'work_package', id: '2', listKey: 'sprint:7' });
});

it('clears the batch and the anchor', () => {
selection.replace({ type: 'work_package', id: '1' }, 'sprint:7');
selection.clear();

expect(selection.size).toBe(0);
expect(selection.anchor).toBeNull();
});

it('prunes ids that are no longer live and reports the change', () => {
selection.replace({ type: 'work_package', id: '1' }, 'sprint:7');
selection.toggle({ type: 'work_package', id: '2' }, 'sprint:7');

expect(selection.prune(new Set([selectionKey({ type: 'work_package', id: '1' })]))).toBe(true);
expect(selection.items().map((entry) => entry.id)).toEqual(['1']);
expect(selection.prune(new Set([selectionKey({ type: 'work_package', id: '1' })]))).toBe(false);
});

it('drops an anchor whose card is gone', () => {
selection.replace({ type: 'work_package', id: '1' }, 'sprint:7');
selection.prune(new Set<string>());

expect(selection.anchor).toBeNull();
});

it('reports membership without exposing mutable state', () => {
selection.replace({ type: 'work_package', id: '1' }, 'sprint:7');

expect(selection.has({ type: 'work_package', id: '1' })).toBe(true);
expect(selection.has({ type: 'work_package', id: '2' })).toBe(false);
});

describe('composite identity', () => {
// Ids are unique per table, not per root: a section and a custom field
// can both be id 5, and a nested topology puts them under one root.
it('keeps items of different types that share an id apart', () => {
selection.toggle({ type: 'section', id: '5' }, 'sections');
selection.toggle({ type: 'custom_field', id: '5' }, 'custom_field:5');

expect(selection.size).toBe(2);
expect(selection.has({ type: 'section', id: '5' })).toBe(true);
expect(selection.has({ type: 'custom_field', id: '5' })).toBe(true);
expect(selection.has({ type: 'work_package', id: '5' })).toBe(false);
});

it('prunes by composite key, not by bare id', () => {
selection.toggle({ type: 'custom_field', id: '5' }, 'custom_field:5');

// The id survives — under a different type, which is a different item.
selection.prune(new Set([selectionKey({ type: 'section', id: '5' })]));

expect(selection.size).toBe(0);
});

it('drops an anchor whose type no longer exists even when the id survives', () => {
selection.replace({ type: 'custom_field', id: '5' }, 'custom_field:5');

selection.prune(new Set([selectionKey({ type: 'section', id: '5' })]));

expect(selection.anchor).toBeNull();
});

it('builds a key that no type or id can forge a collision in', () => {
expect(selectionKey({ type: 'a', id: 'b' }))
.not.toEqual(selectionKey({ type: 'a\u001Fb', id: '' }));
});
});

describe('#rebindAnchor', () => {
// The anchor records where a range may span, and a card can be moved to
// another list while staying the anchor. Its holder re-derives the key
// and hands it back; the model stays opaque about what a list is.
it('points the anchor at a different list without disturbing membership', () => {
selection.replace({ type: 'work_package', id: '7' }, 'sprint:1');

selection.rebindAnchor('sprint:2');

expect(selection.anchor).toEqual({ type: 'work_package', id: '7', listKey: 'sprint:2' });
expect(selection.items().map((entry) => entry.id)).toEqual(['7']);
});

it('does nothing when there is no anchor', () => {
selection.rebindAnchor('sprint:2');

expect(selection.anchor).toBeNull();
});
});
});
Loading
Loading