Skip to content
Merged
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/assets/images/lookbook/sortable_lists_a.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/assets/images/lookbook/sortable_lists_b.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/assets/images/lookbook/sortable_lists_c.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/assets/images/lookbook/sortable_lists_d.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@

import GenericDragAndDropController from '../../generic-drag-and-drop.controller';

/**
* @deprecated Inherits the deprecated {@link GenericDragAndDropController};
* migrate this surface to the `sortable-lists` controller suite.
*/
export default class extends GenericDragAndDropController {
protected buildData(el:Element, target:Element):FormData {
const data = super.buildData(el, target);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ interface TargetConfig {
targetId:string|null;
}

/**
* @deprecated Closed to new consumers. Use the `sortable-lists` controller
* suite instead — or, once it lands, the declarative `BorderBoxListComponent`
* options that emit its contract. This controller is Dragula-based, sends
* index-based positions, and offers no keyboard path; its remaining call sites
* are being migrated. See the drag and drop pattern page in Lookbook.
*/
export default class GenericDragAndDropController extends Controller {
static targets = ['container', 'scrollContainer'];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ import GenericDragAndDropController from '../generic-drag-and-drop.controller';
import { appendCollapsedState } from '../../../helpers/meetings-helpers';
import { hasUnsavedChanges } from '../../../helpers/meetings-helpers';

/**
* @deprecated Inherits the deprecated {@link GenericDragAndDropController};
* migrate this surface to the `sortable-lists` controller suite.
*/
export default class extends GenericDragAndDropController {
protected override buildData(el:Element, target:Element):FormData {
const data = super.buildData(el, target);
Expand Down
8 changes: 6 additions & 2 deletions lookbook/docs/01-how-to-use.md.erb
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@ If you are looking for a specific object you know by name, you can also use the

### Patterns

In the lower left, there is a section called "Patterns". In here, we will document specific decisions and patterns concerning how we approach the use of certain components, or how certain things (like dialogs or flash banners) work in OpenProject.
In the lower left, there is a section called "Patterns". In here, we will document specific decisions and patterns concerning how we approach the use of certain components, or how certain things (like dialogs or flash banners) work in OpenProject.

This includes behaviour and patters that explicitly do not follow the Primer approach, as well as specific UX patterns that are unique to OpenProject.
This includes behaviour and patterns that explicitly do not follow the Primer approach, as well as specific UX patterns that are unique to OpenProject.

Some patterns also include implementation guidance and live previews. The
[Drag and drop](./patterns/drag_and_drop) page has tabs for design guidance,
implementation scenarios and an API reference.

### Icons

Expand Down
9 changes: 9 additions & 0 deletions lookbook/docs/20-patterns/40-drag-and-drop.md.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Drag and drop lets users reorder rows and move them between lists. This
page brings together the design guidance and the implementation of the
`sortable-lists` controller suite.

Start with **Design guidance** for interaction decisions. To implement drag
and drop, choose **Implementation overview** in the [tabs below](#page-tabbed-sections),
then **One list**, **Sibling lists** or **Nested lists** for your scenario.
**API reference** covers the attributes, request and response contract, and
migration from `generic-drag-and-drop`.
74 changes: 74 additions & 0 deletions lookbook/docs/20-patterns/41-drag-and-drop[design-guidance].md.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
label: Design guidance
---

Drag and drop lets a user change the order of rows, or move a row into a
neighboring list, by picking it up and releasing it where it belongs. In
OpenProject it is always paired with a move menu, so the same change is
reachable from the keyboard and with a screen reader. For implementation
details, choose **Implementation overview** in the [tabs above](#page-tabbed-sections),
then the scenario that matches your list.

## Anatomy

<img src="<%= image_path("lookbook/drag_and_drop_anatomy.png") %>" alt="A list of four rows with drag handles; the second row is dimmed while its preview floats beside the list and a line between the third and fourth rows marks where it will land" width="636" style="max-width: 100%">

1. **Drag handle**: the grip at the start of a list row. A drag starts here,
nowhere else on the row. A card has no grip: the whole card is its handle.
2. **Drag source**: the row being moved. It stays in place, dimmed, until the
drop.
3. **Drag preview**: the copy of the row that follows the pointer.
4. **Drop indicator**: the line marking where the row will land. It marks a
spot without reserving space.
5. **Drop container**: the outline on the list that will receive the row.

## When to use it

Use drag and drop when the order of a list is itself the setting: the
position of an enumeration value, the sequence of sections on a form, the
position of work packages in a sprint. Do not add it to lists whose order
is derived (sorted by name or date) or where a single row moves rarely; a
move menu alone serves those.

## Where it is used today

* Admin lists with a persisted order: enumerations, text transform actions,
project phase definitions, export templates, work package types.
* Project Attributes settings: sections reorder, and custom fields reorder
within a section or move to another.
* Backlogs: work packages reorder within the inbox and sprints and move
between them.

## What users can expect

* **A visible drag handle** at the start of a list row. The row is picked up
from the handle, not from anywhere on it, so links and buttons inside the
row keep working. A card (Backlogs) shows no grip and is picked up from
anywhere on it except its links and buttons.
* **A drop indicator** that marks where the row will land, and a highlight
on the list that will receive it.
* **A move menu** in the row's action menu ("Move to top", "Move up", "Move
down", "Move to bottom") offering only the directions that apply.
* **An announcement** for screen-reader users when a row lands in another
list, naming that list.
* **Rows that cannot move** still show a position and can be dropped next
to; they simply refuse to be picked up, or refuse to leave their list.
* **Selecting several rows and moving them together** is being added for
Backlogs ([AGILE-278](https://community.openproject.org/wp/AGILE-278)).

## Vocabulary

Design and review discussions use one set of terms: *drag source* (the
original row), *drag preview* (what follows the pointer), *drop indicator*
(the line marking the landing spot), *drop container* (the highlighted
receiving list), *reorder* (within a list) and *move* (between lists).
Avoid "mirror", "placeholder" and "container" for these; they meant other
things in the previous implementation.

## Further reading

The implementation is built on Atlassian's Pragmatic drag and drop, whose
design guidance applies here too:

* [Web platform design constraints](https://atlassian.design/components/pragmatic-drag-and-drop/web-platform-design-constraints)
* [Accessibility guidelines](https://atlassian.design/components/pragmatic-drag-and-drop/accessibility-guidelines)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not a big fan of the naming, both "behaviours" and "sortable-lists" as the name of the page... Why is that separated from the drag-and-drop page you added? Nobody will understand that they have to look below behaviors/sortable-lists if they are looking for how drag&drop can be implemented..

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@HDinger I've tried consolidating everything on the Patterns → Drag and drop page. Let me know what you think?

Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
---
label: Implementation overview
---

Server-rendered lists become sortable through the `sortable-lists` Stimulus
controller suite. A drag or a move menu action sends one request describing
where the row landed; the server persists the order and streams the list
back. The same suite drives a single admin list, the Backlogs board and the
nested Project Attributes settings.

This page documents the markup and HTTP contract for wiring the controllers
by hand. The **Design guidance** tab covers when drag and drop is appropriate
and what users expect from it.

## Before you wire anything

**Prefer the component API where it exists.** `BorderBoxListComponent` is
gaining `sortable_list:` / `sortable_item:` options that emit this contract
for you ([DREAM-805](https://community.openproject.org/wp/DREAM-805)). Once
those land they cover a single list and sibling lists of rows the server can
reorder. Wire by hand only for what they do not reach: several item types
under one root, per-type move endpoints, non-`BorderBoxList` markup such as
table rows or cards, or optimistic placement.

**Do not use `generic-drag-and-drop`.** The Dragula-based controller is
deprecated and closed to new consumers. The API reference tab has a migration
table for its remaining call sites.

## Anatomy

Four controllers divide the work and find each other through Stimulus
outlets, never by walking the DOM:

| Controller | Placed on | Owns |
| --- | --- | --- |
| `sortable-lists` | one wrapper per page region | the move URL, the request, rollback on failure, the outlet selectors for the three children |
| `sortable-lists--list` | each list | the list's type, its id, which item type it accepts, where a drop on empty space lands |
| `sortable-lists--item` | each row that can move | the row's id, type and accessible label; the drag handle and preview; the move menu actions |
| `sortable-lists--scrollable` | whatever element scrolls | auto-scrolling while a drag hovers its edge |

### A single list

<img src="<%= image_path("lookbook/sortable_lists_a.png") %>" alt="One root, one list, three rows; the root reaches the list and each row through outlets" width="460" style="max-width: 100%">
Comment thread
myabc marked this conversation as resolved.

The minimum topology. Three controllers; the root reaches the other two
through outlets, never by traversing the DOM.

### Sibling lists in scrolling columns

<img src="<%= image_path("lookbook/sortable_lists_b.png") %>" alt="Two scroll columns under one root, each holding lists of the same type; the scrollable sits between root and lists" width="636" style="max-width: 100%">

Two scrolling columns, each holding several lists of one type. The
scrollables sit inside the root and are reached by outlet like every other
child; the root still addresses every item directly, however deep it sits.

### How the root reaches its children

<img src="<%= image_path("lookbook/sortable_lists_c.png") %>" alt="The root's three outlet selectors resolving to the list, item and scrollable controllers" width="760" style="max-width: 100%">

Nesting is DOM structure; this is how the controllers address each other.
The root declares one outlet per child type, so it holds a live collection
of each with no `closest()` and no `querySelector` walking. The selectors
are ordinary descendant selectors scoped to the root's own wrapper id, so
two roots on one page cannot capture each other's children:

```html
<div id="tasks"
data-controller="sortable-lists"
data-sortable-lists-move-url-template-value="<%%= move_url_template %>"
data-sortable-lists-sortable-lists--list-outlet="#tasks [data-controller~='sortable-lists--list']"
data-sortable-lists-sortable-lists--item-outlet="#tasks [data-controller~='sortable-lists--item']">
```

Because the selectors match at any depth, one root holds every list and
every item beneath it in one flat collection. A row's owning list is the
innermost list element that contains it. That single rule is what lets one
root serve nested lists as well as flat ones.

### Vocabulary

* **Sortable list, sortable item, sortable item type**: a list whose order the
server persists, a row that takes part in that order, and the category a
list accepts. A row is an item once it carries
`data-sortable-lists--item-id-value`; that is what counts it and anchors
its neighbors' drops. The `sortable-lists--item` controller on it is what
makes it draggable and gives it a move menu, and `confined` keeps a
draggable item in its own list.
* **Drag source**: the original row while it is being dragged. **Drag
preview**: the copy that follows the pointer.
* **Drop indicator**: the line marking where the row will land, drawn without
reserving space. **Drop container**: the list-level outline shown while a
compatible row hovers a list (`data-drop-container="active"`).
* **Reorder** happens within one list; a **move** changes list. In terms of
the backend request they are identical.
Comment on lines +81 to +94

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want a diagram here too? - or does Behaviours already cover it?


## Choosing a scenario

* **One list**: rows reorder within a single list, such as admin enumerations
or text transform actions.
* **Sibling lists**: several lists of the same kind, with rows moving between
them, as in Backlogs.
* **Nested lists**: rows that are themselves lists, such as Project Attributes
sections and their custom fields.
* **API reference**: every attribute, the backend request, the server half, the
Angular counterpart, migration.
150 changes: 150 additions & 0 deletions lookbook/docs/20-patterns/43-drag-and-drop[one-list].md.erb

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL that something like this is possible. Nice 👍

Image

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the first time we're using this, but I think it helps make the page more manageable where we have a bunch of different options. Here's the Lookbook docs - just a matter of naming the .md.erb files accordingly.

Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
---
label: One list
---

One list whose rows the server can reorder. `Admin::TextTransformActions` is
the smallest complete implementation in the codebase; the samples below
follow it.

<%= embed Patterns::SortableListsPreview, :single_list, panels: %i[source] %>

The preview's move URL points at a path that does not exist, so a drop moves
the row, the request fails, and the row rolls back with an error toast. That
is the whole failure path; persistence needs the endpoint described at the
end of this tab.

## The root

One wrapper per page region. It carries the move URL template and the outlet
selectors, scoped to its own id:

```html
<div id="text_transform_actions_list"
data-controller="sortable-lists"
data-sortable-lists-move-url-template-value="<%%= move_url_template %>"
data-sortable-lists-sortable-lists--list-outlet="#text_transform_actions_list [data-controller~='sortable-lists--list']"
data-sortable-lists-sortable-lists--item-outlet="#text_transform_actions_list [data-controller~='sortable-lists--item']">
```

Build the template from the route helper through a sentinel, so an
installation under a relative URL root keeps working; `{id}` is expanded on
the client with the dragged row's id:

```ruby
def move_url_template
id_placeholder = "__id__"
drop_admin_text_transform_action_path(id_placeholder).sub(id_placeholder, "{id}")
end
```

The expanded URL must stay same-origin and relative; only path, query and
fragment survive.

## The list

The list element declares the type it persists and the type it accepts.
`type` doubles as the `list_type` the server receives, so a list without one
cannot accept drops and warns in the console. `name` is what screen readers
hear when a row arrives from another list; give it even for one list, it
costs nothing and the list may gain siblings.

```html
<div data-controller="sortable-lists--list"
data-sortable-lists--list-type-value="text_transform_action"
data-sortable-lists--list-accepted-type-value="text_transform_action"
data-sortable-lists--list-name-value="<%%= t(".title") %>">
<ul>
…rows…
</ul>
</div>
```

Rows may sit in a child `<ul>`, as `BorderBoxList` renders them, or directly
under the list element. Both resolve without configuration, so table rows
and cards need no special markup.

## The row

Each row declares its identity and hosts the drag handle:

```html
<li data-controller="sortable-lists--item"
data-sortable-lists--item-id-value="<%%= action.id %>"
data-sortable-lists--item-type-value="text_transform_action"
data-sortable-lists--item-label-value="<%%= action.label %>">
<button type="button" class="handle" data-sortable-lists--item-target="handle" aria-hidden="true" tabindex="-1">
<%%= primer_octicon(:grabber) %>
</button>
…
</li>
```

`Primer::OpenProject::DragHandle` renders that button for you; give it the
`handle` target.

Without a `handle` target the whole row is the handle, which fights with
links and buttons inside it. Add a `preview` target on an inner element when
the row's own box is not what should follow the pointer (a card inside a
padded `<li>`, for instance); the default preview is the row itself.

## The move menu

A drag is a pointer gesture, so every sortable row also needs a keyboard-
and screen-reader-reachable path. Put move actions in the row's Primer
`ActionMenu`; the controller depends on that component. Each action is a
`moveItem` target with a `direction` param (`top`, `up`, `down`, `bottom`)
and the `move` action, passed to `with_item` so the attributes land on the
menu's `<li>`, not on the button inside it:

```erb
<%%= render(Primer::Alpha::ActionMenu.new) do |menu| %>
<%% menu.with_show_button(icon: "kebab-horizontal", scheme: :invisible, "aria-label": action.label) %>
<%% menu.with_divider(data: { sortable_lists__item_target: "moveDivider" }) %>
<%% menu.with_item(
label: "Move up",
tag: :button,
data: {
sortable_lists__item_target: "moveItem",
sortable_lists__item_direction_param: "up",
action: "click->sortable-lists--item#move"
}
) %>
<%% end %>
```

`move` looks up the row's `<action-menu>` element and asks it whether that
`<li>` is disabled or hidden before moving anything; the click reaches the
`<li>` by bubbling up from the button. A row without an `ActionMenu` still
drags, its `move` action just does nothing. The `moveDivider` target on the
divider above the group lets the controller hide it together with the
actions when none applies.

Availability is recomputed every time the menu opens: the first row has no
"up", the last no "down". Unavailable actions are hidden; set
`data-sortable-lists--item-hide-unavailable-value="false"` on the row to
disable them instead.

## The backend request

A drop, or a move action, sends `PUT` to the expanded move URL with
`responseKind: 'turbo-stream'` and three form fields:

| Field | Value for one list |
| --- | --- |
| `list_type` | the list's `type` |
| `list_id` | blank; the list declares no `id` |
| `prev_id` | id of the row the moved row now follows, or blank when it landed first |

`prev_id` is an anchor, not an index: a stale ordinal cannot silently move
the wrong row. A drop that leaves the row where it was sends nothing.

The row moves in the DOM before the request resolves. The endpoint answers
by streaming the re-rendered list, and the morph replaces the optimistic
placement with the server's order. A failure rolls the row back; a `422`
stays silent because the server has already streamed its own message, any
other status raises a generic toast.

For `acts_as_list` models the endpoint is a few lines with the
`Lists::MoveAfterAnchor` concern; the API reference tab describes the server
half, including the anchor rules and what to do when the model orders by
something other than a position column.
Loading
Loading