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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ import {
isOrderableItem,
itemAcceptsDestination,
reorderRows,
resolveBlockMove,
resolveBlockMoveAvailability,
resolveDirectionalPreviousItemId,
resolveItemId,
resolveItemLabel,
Expand Down Expand Up @@ -492,20 +494,29 @@ export default class SortableListsController extends Controller<HTMLElement> imp
return this.element.hasAttribute(sortableListsBusyAttribute);
}

// A direction is offered exactly when the move resolver can produce a
// target for it, and never for an item moveInDirection would refuse below.
// Null means the item is not in an owned list yet. A snapshot for menu
// gating; the click path re-resolves the live DOM.
// A direction is offered exactly when the move resolver can produce a target
// for it, which keeps the menu honest about truncated lists. Null means the
// item is not in an owned list yet, or takes no part in ordering. A snapshot
// for menu gating; the click path re-resolves the live DOM.
moveAvailability(itemElement:HTMLElement):MoveAvailability|null {
if (!isOrderableItem(itemElement)) {
return {
top: false, up: false, down: false, bottom: false,
};
const list = this.ownerListOf(itemElement);
if (!list || !isOrderableItem(itemElement)) {
return null;
}

const list = this.ownerListOf(itemElement);
const scope = this.actionScopeFor(itemElement);
if (scope.kind === 'refused') {
return resolveMoveAvailability({
itemElement,
rowsContainer: list.rowsContainer,
});
}
Comment thread
myabc marked this conversation as resolved.

return list ? resolveMoveAvailability({ itemElement, rowsContainer: list.rowsContainer }) : null;
if (!this.resolveCollectionMoveUrl()) {
return { top: false, up: false, down: false, bottom: false };
}

return resolveBlockMoveAvailability({ itemElements: scope.items, rowsContainer: list.rowsContainer });
}

moveToDestination(itemElement:HTMLElement, target:DestinationIdentity):void {
Expand Down Expand Up @@ -539,6 +550,62 @@ export default class SortableListsController extends Controller<HTMLElement> imp
return;
}

if (this.selection) {
const moveUrl = this.resolveCollectionMoveUrl();
if (!moveUrl) {
return;
}

// Resolved without mutating: every check below can still refuse the
// move, and a stale menu must not replace the user's batch with the
// invoker for a move that then never runs.
const scope = this.actionScopeFor(itemElement);
if (scope.kind === 'refused') {
return;
}

const list = this.ownerListOf(itemElement);
if (!list) {
return;
}

const resolution = resolveBlockMove({
itemElements: scope.items,
direction,
rowsContainer: list.rowsContainer,
});
if (!resolution.available) {
return;
}

// Scope members are resolved candidates, so both identity attributes
// exist; refusing on a mismatch keeps the moved rows and the submitted
// ids from ever diverging.
const items = scope.items.flatMap((element):SelectionItem[] => {
const type = resolveItemType(element);
const id = resolveItemId(element);
return type && id ? [{ type, id }] : [];
});
if (items.length !== scope.items.length) {
return;
}

// Committed only now the move is known executable: invoking a position
// action on an unselected card selects it, and a failed request keeps
// that selection for the retry.
this.selectForAction(itemElement);

void this.performMove({
rows: resolution.rows,
items,
rowsContainer: list.rowsContainer,
listData: list.listData,
previousItemId: resolution.previousItemId,
moveUrl,
});
return;
}

const list = this.ownerListOf(itemElement);
if (!list) {
return;
Expand All @@ -560,8 +627,6 @@ export default class SortableListsController extends Controller<HTMLElement> imp
return;
}

this.selection?.collapseForAction(itemElement);

void this.performMove({
rows: [sourceRow],
items: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ export interface SortableListsRoot {
availableDestinations(scope:ActionScope, candidates:DestinationIdentity[]):DestinationIdentity[];
moveToDestination(itemElement:HTMLElement, target:DestinationIdentity):void;
moveInDirection(itemElement:HTMLElement, direction:MoveDirection):void;
// A snapshot for menu gating; the click path re-resolves against the live DOM.
// A snapshot for menu gating over the invoker's prospective action scope;
// the click path re-resolves against the live DOM.
moveAvailability(itemElement:HTMLElement):MoveAvailability|null;
// The element of the list an item currently belongs to; null outside any
// registered list. Items carry no list reference, so the root resolves it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1612,7 +1612,7 @@ describe('Sortable lists item controller', () => {
expect(menu.showItem).toHaveBeenCalledWith(moveToInbox);
});

it('hides destination actions and the position submenu for a true multi-card scope', async () => {
it('shows available batch position directions when every destination action is hidden', async () => {
const { el, menu } = renderItemWithMenu(1, true);
document.body.appendChild(el);
const controller = await mountItemController(el);
Expand All @@ -1622,6 +1622,7 @@ describe('Sortable lists item controller', () => {
const { root, actionScopeFor, availableDestinations } = stubMenuRoot(el, { isFirst: false, isLast: false });
actionScopeFor.mockReturnValue(scope);
availableDestinations.mockReturnValue([]);
root.moveAvailability = () => ({ top: false, up: true, down: true, bottom: false });
controller.connectRoot(root);

const moveToSprint = destinationFor(el, [{ type: 'sprint', id: '1' }]);
Expand All @@ -1632,11 +1633,39 @@ describe('Sortable lists item controller', () => {
const divider = el.querySelector<HTMLElement>('li[data-sortable-lists--item-target="moveDivider"]')!;
expect(menu.hideItem).toHaveBeenCalledWith(moveToSprint);
expect(menu.hideItem).toHaveBeenCalledWith(moveToInbox);
expect(menu.hideItem).toHaveBeenCalledWith(moveMenu);
expect(menu.showItem).toHaveBeenCalledWith(moveMenu);
expect(menu.hideItem).toHaveBeenCalledWith(liFor(el, 'top'));
expect(menu.showItem).toHaveBeenCalledWith(liFor(el, 'up'));
expect(menu.showItem).toHaveBeenCalledWith(liFor(el, 'down'));
expect(menu.hideItem).toHaveBeenCalledWith(liFor(el, 'bottom'));
expect(divider.hasAttribute('hidden')).toBe(false);
});

it('hides an all-unavailable batch position submenu and its directions', async () => {
const { el, menu } = renderItemWithMenu(1, true);
document.body.appendChild(el);
const controller = await mountItemController(el);
const scope:ActionScope = { kind: 'batch', items: [el] };
const { root, actionScopeFor, availableDestinations } = stubMenuRoot(el, { isFirst: false, isLast: false });
actionScopeFor.mockReturnValue(scope);
availableDestinations.mockReturnValue([]);
root.moveAvailability = () => ({ top: false, up: false, down: false, bottom: false });
controller.connectRoot(root);

const moveToSprint = destinationFor(el, [{ type: 'sprint', id: '1' }]);
await menuCtx!.nextFrame();

const moveMenu = el.querySelector<HTMLElement>('li[data-sortable-lists--item-target="moveMenu"]')!;
const divider = el.querySelector<HTMLElement>('li[data-sortable-lists--item-target="moveDivider"]')!;
expect(menu.hideItem).toHaveBeenCalledWith(moveToSprint);
expect(menu.hideItem).toHaveBeenCalledWith(moveMenu);
for (const direction of ['top', 'up', 'down', 'bottom']) {
expect(menu.hideItem).toHaveBeenCalledWith(liFor(el, direction));
}
expect(divider.hasAttribute('hidden')).toBe(true);

actionScopeFor.mockReturnValue({ kind: 'batch', items: [el] });
root.moveAvailability = () => ({ top: true, up: true, down: false, bottom: false });
el.querySelector('action-menu > anchored-position')!.dispatchEvent(new ToggleEvent('toggle', { newState: 'open' }));
expect(moveMenu.hasAttribute('hidden')).toBe(false);
expect(liFor(el, 'top').hasAttribute('hidden')).toBe(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import {
itemMobility,
permittedDestinations,
reorderRows,
resolveBlockMove,
resolveBlockMoveAvailability,
sortableItemMobilityAttribute,
resolveDirectionalPreviousItemId,
resolveMoveAvailability,
Expand Down Expand Up @@ -664,6 +666,175 @@ describe('directional move helpers', () => {
});
});

describe('block move helpers', () => {
function fixture():{
rowsContainer:HTMLUListElement;
items:HTMLLIElement[];
marker:HTMLLIElement;
divider:HTMLLIElement;
} {
const rowsContainer = document.createElement('ul');
const items = ['1', '2', '3', '4'].map((id) => {
const row = document.createElement('li');
row.setAttribute('data-sortable-lists--item-id-value', id);
return row;
});
const marker = document.createElement('li');
marker.setAttribute('data-sortable-lists-prev-item-id', 'last-hidden');
marker.setAttribute('data-sortable-lists-omitted-count', '3');
const divider = document.createElement('li');
divider.classList.add('divider');

rowsContainer.append(...items, marker, divider);

return { rowsContainer, items, marker, divider };
}

it('resolves one card in all four directions', () => {
const { rowsContainer, items: [, second] } = fixture();

expect(resolveBlockMove({ itemElements: [second], direction: 'top', rowsContainer }))
.toEqual({ available: true, rows: [second], previousItemId: null });
expect(resolveBlockMove({ itemElements: [second], direction: 'up', rowsContainer }))
.toEqual({ available: true, rows: [second], previousItemId: null });
expect(resolveBlockMove({ itemElements: [second], direction: 'down', rowsContainer }))
.toEqual({ available: true, rows: [second], previousItemId: '3' });
expect(resolveBlockMove({ itemElements: [second], direction: 'bottom', rowsContainer }))
.toEqual({ available: true, rows: [second], previousItemId: '4' });
});

it('resolves an adjacent block in all four directions and excludes selected predecessors', () => {
const { rowsContainer, items: [, second, third] } = fixture();

expect(resolveBlockMove({ itemElements: [second, third], direction: 'top', rowsContainer }))
.toEqual({ available: true, rows: [second, third], previousItemId: null });
expect(resolveBlockMove({ itemElements: [second, third], direction: 'up', rowsContainer }))
.toEqual({ available: true, rows: [second, third], previousItemId: null });
expect(resolveBlockMove({ itemElements: [second, third], direction: 'down', rowsContainer }))
.toEqual({ available: true, rows: [second, third], previousItemId: '4' });
expect(resolveBlockMove({ itemElements: [second, third], direction: 'bottom', rowsContainer }))
.toEqual({ available: true, rows: [second, third], previousItemId: '4' });
});

it('rejects an empty or fixed selection as not orderable', () => {
const { rowsContainer, items: [first, second] } = fixture();
second.setAttribute(sortableItemMobilityAttribute, 'fixed');

expect(resolveBlockMove({ itemElements: [], direction: 'top', rowsContainer }))
.toEqual({ available: false, reason: 'not-orderable' });
expect(resolveBlockMove({ itemElements: [first, second], direction: 'top', rowsContainer }))
.toEqual({ available: false, reason: 'not-orderable' });
});

it('rejects selected elements from different containers', () => {
const { rowsContainer, items: [first] } = fixture();
const { items: [foreign] } = fixture();

expect(resolveBlockMove({ itemElements: [first, foreign], direction: 'top', rowsContainer }))
.toEqual({ available: false, reason: 'cross-list' });
});

it('rejects an inner-list item that resolves to its outer host row', () => {
const rowsContainer = document.createElement('ul');
const outerHost = document.createElement('li');
outerHost.setAttribute('data-sortable-lists--item-id-value', 'outer-1');
const nestedRows = document.createElement('ul');
const inner = document.createElement('li');
inner.setAttribute('data-sortable-lists--item-id-value', 'inner-1');
nestedRows.append(inner);
outerHost.append(nestedRows);

const outerSecond = document.createElement('li');
outerSecond.setAttribute('data-sortable-lists--item-id-value', 'outer-2');
const outerThird = document.createElement('li');
outerThird.setAttribute('data-sortable-lists--item-id-value', 'outer-3');
rowsContainer.append(outerHost, outerSecond, outerThird);

expect(resolveBlockMove({ itemElements: [inner, outerSecond], direction: 'bottom', rowsContainer }))
.toEqual({ available: false, reason: 'cross-list' });
});

it('rejects sparse, reverse-order, and duplicate input', () => {
const { rowsContainer, items: [first, second, third] } = fixture();

expect(resolveBlockMove({ itemElements: [first, third], direction: 'top', rowsContainer }))
.toEqual({ available: false, reason: 'non-contiguous' });
expect(resolveBlockMove({ itemElements: [third, second], direction: 'top', rowsContainer }))
.toEqual({ available: false, reason: 'non-contiguous' });
expect(resolveBlockMove({ itemElements: [second, second], direction: 'top', rowsContainer }))
.toEqual({ available: false, reason: 'non-contiguous' });
});

it('rejects up and down across an adjacent truncation marker', () => {
const { rowsContainer, items: [, second, third], marker } = fixture();

second.after(marker);
expect(resolveBlockMove({ itemElements: [second], direction: 'down', rowsContainer }))
.toEqual({ available: false, reason: 'truncation-boundary' });
expect(resolveBlockMove({ itemElements: [third], direction: 'up', rowsContainer }))
.toEqual({ available: false, reason: 'truncation-boundary' });
});

it('rejects up and down across an unaddressable gap', () => {
const { rowsContainer, items: [, second, third], divider } = fixture();

second.after(divider);
expect(resolveBlockMove({ itemElements: [second], direction: 'down', rowsContainer }))
.toEqual({ available: false, reason: 'unaddressable-gap' });
expect(resolveBlockMove({ itemElements: [third], direction: 'up', rowsContainer }))
.toEqual({ available: false, reason: 'unaddressable-gap' });
});

it('keeps fixed rows addressable as up and down neighbours', () => {
const { rowsContainer, items: [first, second, third] } = fixture();
first.setAttribute(sortableItemMobilityAttribute, 'fixed');
third.setAttribute(sortableItemMobilityAttribute, 'fixed');

expect(resolveBlockMove({ itemElements: [second], direction: 'up', rowsContainer }))
.toEqual({ available: true, rows: [second], previousItemId: null });
expect(resolveBlockMove({ itemElements: [second], direction: 'down', rowsContainer }))
.toEqual({ available: true, rows: [second], previousItemId: '3' });
});

it('keeps top and bottom available across loaded sparse-list boundaries', () => {
const { rowsContainer, items: [, second, third, fourth], marker } = fixture();

second.after(marker);
expect(resolveBlockMove({ itemElements: [third], direction: 'top', rowsContainer }))
.toEqual({ available: true, rows: [third], previousItemId: null });
expect(resolveBlockMove({ itemElements: [second], direction: 'bottom', rowsContainer }))
.toEqual({ available: true, rows: [second], previousItemId: '4' });
expect(resolveBlockMove({ itemElements: [third], direction: 'bottom', rowsContainer }))
.toEqual({ available: true, rows: [third], previousItemId: '4' });
expect(resolveBlockMove({ itemElements: [fourth], direction: 'top', rowsContainer }))
.toEqual({ available: true, rows: [fourth], previousItemId: null });
});

it('rejects every move that returns the block to its effective placement', () => {
const { rowsContainer, items: [first, second, third, fourth] } = fixture();

expect(resolveBlockMove({ itemElements: [first], direction: 'top', rowsContainer }))
.toEqual({ available: false, reason: 'no-op' });
expect(resolveBlockMove({ itemElements: [first, second], direction: 'up', rowsContainer }))
.toEqual({ available: false, reason: 'no-op' });
expect(resolveBlockMove({ itemElements: [fourth], direction: 'down', rowsContainer }))
.toEqual({ available: false, reason: 'no-op' });
expect(resolveBlockMove({ itemElements: [third, fourth], direction: 'bottom', rowsContainer }))
.toEqual({ available: false, reason: 'no-op' });
});

it('derives availability by resolving all four directions', () => {
const { rowsContainer, items: [first, second, third, fourth] } = fixture();

expect(resolveBlockMoveAvailability({ itemElements: [first, second], rowsContainer }))
.toEqual({ top: false, up: false, down: true, bottom: true });
expect(resolveBlockMoveAvailability({ itemElements: [second, third], rowsContainer }))
.toEqual({ top: true, up: true, down: true, bottom: true });
expect(resolveBlockMoveAvailability({ itemElements: [third, fourth], rowsContainer }))
.toEqual({ top: true, up: true, down: false, bottom: false });
});
});

describe('resolveItemPosition', () => {
function item(id:string, label?:string):HTMLLIElement {
const row = document.createElement('li');
Expand Down
Loading
Loading