mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 01:20:07 -05:00
Fix multi-list swimlane drops and show named drag previews
Recognize empty swimlane bodies as mixed-selection drop destinations while keeping swimlane drag starts on headers. Replace the count-only indicator with a tilted stack of selected object names and icons rendered as text. Verify two-list moves, card parents, ordering, collapsed containers, both handle modes and persistence after reload. Thanks to xet7 !
This commit is contained in:
1 parent
fc434e47d3
commit
f772663e5b
4 files changed
+136
-4
No files matched your search
@@ -20,7 +20,17 @@ button.js-structural-selection:focus-visible { outline: 2px solid currentColor;
|
||||
color: #333;
|
||||
border: 2px solid #216694;
|
||||
pointer-events: none;
|
||||
min-width: 160px;
|
||||
max-width: 280px;
|
||||
border-radius: 5px;
|
||||
transform: rotate(-3deg);
|
||||
transform-origin: top left;
|
||||
box-shadow: 3px 4px 0 #ddd, 6px 8px 0 #bbb, 0 8px 18px #0004;
|
||||
}
|
||||
.structural-drag-count { font-weight: bold; margin-bottom: 4px; }
|
||||
.structural-drag-name { display: flex; gap: 8px; padding: 4px; }
|
||||
.structural-drag-name span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.structural-drag-name .fa { flex: 0 0 16px; }
|
||||
.structural-drop-target { outline: 2px dashed #216694; }
|
||||
/* Header checkboxes occupy the left margin, without adding a header row. */
|
||||
.js-list-header > button.js-structural-selection {
|
||||
|
||||
@@ -68,7 +68,7 @@ const entitySelectors = [
|
||||
['item', '.js-checklist-item'], ['checklist', '.minicard-checklist, .js-checklist'],
|
||||
['card', '.js-minicard'], ['list', '.js-list'], ['swimlane', '.js-swimlane-header'],
|
||||
];
|
||||
function entityAt(element) {
|
||||
function entityAt(element, dropTarget = false) {
|
||||
for (const [kind, selector] of entitySelectors) {
|
||||
const node = element?.closest(selector);
|
||||
if (!node) continue;
|
||||
@@ -76,6 +76,13 @@ function entityAt(element) {
|
||||
const doc = kind === 'item' ? data?.item : kind === 'checklist' ? data?.checklist : data;
|
||||
if (doc?._id) return { kind, doc, node };
|
||||
}
|
||||
// Empty lane space has no list/card ancestor. Accept it on drop, while
|
||||
// retaining the header-only start target for dragging a selected swimlane.
|
||||
if (dropTarget) {
|
||||
const node = element?.closest('.js-swimlane');
|
||||
const doc = node && Blaze.getData(node);
|
||||
if (doc?._id) return { kind: 'swimlane', doc, node };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function targetFor(entity) {
|
||||
@@ -88,6 +95,40 @@ function targetFor(entity) {
|
||||
if (kind === 'swimlane') target.swimlaneId = doc._id;
|
||||
return target;
|
||||
}
|
||||
// Render names as text, never clone interactive controls or insert title HTML.
|
||||
function createDragPreview(selection) {
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 'structural-drag-preview';
|
||||
const count = document.createElement('div');
|
||||
count.className = 'structural-drag-count';
|
||||
count.textContent = String(selection.length);
|
||||
preview.append(count);
|
||||
const types = {
|
||||
swimlane: ['getSwimlane', 'fa-bars'], list: ['getList', 'fa-list'],
|
||||
card: ['getCard', 'fa-sticky-note-o'], checklist: ['getChecklist', 'fa-check-square-o'],
|
||||
item: ['getChecklistItem', 'fa-check'],
|
||||
};
|
||||
for (const entry of selection.slice(0, 8)) {
|
||||
const type = types[entry.kind];
|
||||
if (!type) continue;
|
||||
const doc = ReactiveCache[type[0]](entry.id);
|
||||
const row = document.createElement('div');
|
||||
row.className = 'structural-drag-name';
|
||||
const icon = document.createElement('i');
|
||||
icon.className = `fa ${type[1]}`;
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
const name = document.createElement('span');
|
||||
name.textContent = doc?.title || entry.id;
|
||||
row.append(icon, name);
|
||||
preview.append(row);
|
||||
}
|
||||
if (selection.length > 8) {
|
||||
const more = document.createElement('div');
|
||||
more.textContent = `+${selection.length - 8}`;
|
||||
preview.append(more);
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
Meteor.startup(() => {
|
||||
let drag;
|
||||
document.addEventListener('mousedown', event => {
|
||||
@@ -112,13 +153,13 @@ Meteor.startup(() => {
|
||||
if (!drag) return;
|
||||
if (!drag.preview && Math.hypot(event.clientX - drag.x, event.clientY - drag.y) < 7) return;
|
||||
if (!drag.preview) {
|
||||
drag.preview = document.createElement('div'); drag.preview.className = 'structural-drag-preview';
|
||||
drag.preview.textContent = `${drag.selection.length}`; document.body.append(drag.preview);
|
||||
drag.preview = createDragPreview(drag.selection);
|
||||
document.body.append(drag.preview);
|
||||
}
|
||||
event.preventDefault();
|
||||
drag.preview.style.left = `${event.clientX + 12}px`; drag.preview.style.top = `${event.clientY + 12}px`;
|
||||
drag.target?.node.classList.remove('structural-drop-target');
|
||||
drag.target = entityAt(document.elementFromPoint(event.clientX, event.clientY));
|
||||
drag.target = entityAt(document.elementFromPoint(event.clientX, event.clientY), true);
|
||||
drag.target?.node.classList.add('structural-drop-target');
|
||||
}, true);
|
||||
document.addEventListener('mouseup', async event => {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
'use strict';
|
||||
const { test, expect } = require('../fixtures');
|
||||
const db = require('../helpers/db');
|
||||
for (const handles of [false,true]) for (const target of ['body','header','collapsed-header']) {
|
||||
test(`two selected lists move to another swimlane ${target} (handles ${handles})`, async ({boardPage:page,board,user}) => {
|
||||
await page.setViewportSize({width:1500,height:1500});
|
||||
db.updateOne('users',{_id:user.id},{$set:{'profile.showDesktopDragHandles':handles}});
|
||||
for (const id of board.listIds) db.updateOne('lists',{_id:id},{$set:{swimlaneId:board.swimlaneId}});
|
||||
const laneId=db.uid('lane');
|
||||
db.insertOne('swimlanes',{...db.findOne('swimlanes',{_id:board.swimlaneId}),_id:laneId,title:'Destination lane',sort:1,height:300});
|
||||
const cards=db.find('cards',{boardId:board.boardId,listId:{$in:board.listIds.slice(0,2)}});
|
||||
await page.reload();
|
||||
if (target === 'collapsed-header') {
|
||||
await page.locator('.js-swimlane-header').filter({hasText:'Destination lane'}).locator('.js-collapse-swimlane').click();
|
||||
for (const id of board.listIds.slice(0,2)) await page.locator(`#js-list-${id} .js-collapse`).click();
|
||||
}
|
||||
await page.locator('.js-multiselection-activate').click();
|
||||
for(const id of board.listIds.slice(0,2)) await page.locator(`.js-structural-selection[data-kind="list"][data-id="${id}"]`).click();
|
||||
const fromNode=page.locator(`#js-list-${board.listIds[0]}`).locator(handles?'.js-list-handle:visible':'.list-header-name').first();
|
||||
const toNode=target==='body'?page.locator(`#swimlane-${laneId}`):page.locator('.js-swimlane-header').filter({hasText:'Destination lane'});
|
||||
const from=await fromNode.boundingBox(),to=await toNode.boundingBox();
|
||||
await page.mouse.move(from.x+from.width/2,from.y+from.height/2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(to.x+to.width/2,to.y+to.height/2,{steps:30});
|
||||
const preview = page.locator('.structural-drag-preview');
|
||||
await expect(preview).toBeVisible();
|
||||
await expect(preview.locator('.structural-drag-name span')).toHaveText(['List A','List B']);
|
||||
expect(await preview.evaluate(node=>getComputedStyle(node).transform)).not.toBe('none');
|
||||
if (!handles && target === 'body') await page.screenshot({path:test.info().outputPath('named-preview.png')});
|
||||
await page.mouse.up();
|
||||
await expect.poll(()=>db.find('lists',{_id:{$in:board.listIds.slice(0,2)},swimlaneId:laneId}).length).toBe(2);
|
||||
expect(db.findOne('lists',{_id:board.listIds[2]}).swimlaneId).toBe(board.swimlaneId);
|
||||
for(const card of cards) {
|
||||
const moved=db.findOne('cards',{_id:card._id});
|
||||
expect(moved.swimlaneId).toBe(laneId);
|
||||
expect(moved.listId).toBe(card.listId);
|
||||
}
|
||||
const movedLists = db.find('lists',{_id:{$in:board.listIds.slice(0,2)}}).sort((a,b)=>a.sort-b.sort);
|
||||
expect(movedLists.map(list=>list._id)).toEqual(board.listIds.slice(0,2));
|
||||
if (target === 'collapsed-header') await page.locator('.js-swimlane-header').filter({hasText:'Destination lane'}).locator('.js-collapse-swimlane').click();
|
||||
for(const id of board.listIds.slice(0,2)) await expect(page.locator(`#swimlane-${laneId} #js-list-${id}`)).toBeVisible();
|
||||
await page.reload();
|
||||
for(const id of board.listIds.slice(0,2)) await expect(page.locator(`#swimlane-${laneId} #js-list-${id}`)).toBeVisible();
|
||||
});
|
||||
}
|
||||
@@ -46,3 +46,39 @@ assert.equal(state.hasObjects(), false);
|
||||
state.reset();
|
||||
assert.equal(state.isSelected('card'), false);
|
||||
console.log('structuralSelection: board scoping, mixed Shift selection, toggle and reset passed');
|
||||
|
||||
// A lane's empty body is a drop target, but not a new swimlane drag handle.
|
||||
const dragSource = fs.readFileSync('client/components/main/structuralSelection.js', 'utf8');
|
||||
const entityCode = dragSource.slice(dragSource.indexOf('const entitySelectors'), dragSource.indexOf('Meteor.startup'));
|
||||
const laneNode = { doc: { _id: 'destination-lane' } };
|
||||
const listNode = { doc: { _id: 'target-list', swimlaneId: 'destination-lane' } };
|
||||
const bodyElement = { closest: selector => selector === '.js-swimlane' ? laneNode : null };
|
||||
const listElement = { closest: selector => selector === '.js-list' ? listNode : selector === '.js-swimlane' ? laneNode : null };
|
||||
const resolver = vm.runInNewContext(`${entityCode}\n({entityAt,targetFor})`, {
|
||||
Blaze: { getData: node => node.doc }, Session: { get: () => 'board' },
|
||||
});
|
||||
assert.equal(resolver.entityAt(bodyElement), null);
|
||||
assert.equal(resolver.entityAt(bodyElement, true).doc._id, 'destination-lane');
|
||||
assert.equal(resolver.targetFor(resolver.entityAt(bodyElement,true)).swimlaneId, 'destination-lane');
|
||||
assert.equal(resolver.entityAt(listElement,true).kind, 'list');
|
||||
assert.equal(resolver.entityAt({closest:()=>null},true), null);
|
||||
console.log('structuralSelection: empty lane drop, header-only drag start and nested-target precedence passed');
|
||||
|
||||
const previewDocument = { createElement(tag) {
|
||||
return { tag, children: [], append(...nodes) { this.children.push(...nodes); }, setAttribute() {} };
|
||||
} };
|
||||
const buildPreview = vm.runInNewContext(`${entityCode}\ncreateDragPreview;`, {
|
||||
document: previewDocument,
|
||||
ReactiveCache: {
|
||||
getList: id => ({title:id === 'a' ? '<img src=x onerror=alert(1)>' : 'Second list'}),
|
||||
getCard: () => ({title:'A selected card'}),
|
||||
},
|
||||
});
|
||||
const preview = buildPreview([{kind:'list',id:'a'},{kind:'list',id:'b'},{kind:'card',id:'c'}]);
|
||||
assert.equal(preview.children[0].textContent, '3');
|
||||
assert.equal(preview.children[1].children[1].textContent, '<img src=x onerror=alert(1)>');
|
||||
assert.equal(preview.children[1].children[1].tag, 'span');
|
||||
assert.equal(preview.children[2].children[1].textContent, 'Second list');
|
||||
assert.equal(preview.children[3].children[1].textContent, 'A selected card');
|
||||
assert.equal(buildPreview(Array.from({length:10},()=>({kind:'list',id:'b'}))).children.at(-1).textContent, '+2');
|
||||
console.log('structuralSelection: named mixed preview, literal title text and bounded large selections passed');
|
||||
Reference in new issue
Block a user