- #2769: refresh drag geometry when the DOM changes mid-drag - cards no
  longer drop into the wrong swimlane while someone adds a card.
- #1992: board import restores all five card date fields (and creator)
  with fallbacks; receivedAt/endAt were never imported at all.
- #2292: archiving a swimlane archives its cards (mirroring lists);
  restore brings back exactly the cards archived with it.
- #2494: client noCache card lookup returned a Promise since the
  Meteor 3 port - multi-select board moves wrote sort: NaN everywhere.
- #1853: subtask "View it" falls back to the subtask's boardId when the
  deposit board is not loaded, instead of silently doing nothing.
- #1554: restore the reactive dependency lost in 7673c77c5 so cards
  added after board render accept sidebar label/member drops.
- #2306: "filter to selection" and every bulk action are board-scoped;
  foreign-board cards can no longer be silently archived.
- #1437: new POST /users/logout API endpoint revokes the presented
  resume token, or all tokens with {"all": true}.

All with positive+negative regression tests (see tests/*.test.cjs).

Closes #1389: already fixed - the standalone pencil edit button
(0cc63b810) plus the wrapped viewer (bf2b3b40c) guarantee an edit target
even when a full-width link fills the description.

Thanks to hever, Cactusbone, Vermeille, Vanclief, Miffe, IcedQuinn,
ppouliot, GhassenRjab and xet7.
This commit is contained in:
Lauri Ojansivu committed 2026-07-17 03:34:23 +03:00
1 parent 42b4e51f85
commit 7c0ae509ac
25 files changed
+2122 -39

No files matched your search

+58
View File
@@ -195,6 +195,64 @@ and fixes the following bugs:
`tests/oauth2LoginStyle.test.cjs` (12 tests, positive + negative). Thanks to ArturRuta and
xet7.
- **Dragging a card while someone added a card to the target list dropped it into the WRONG
swimlane** ([#2769](https://github.com/wekan/wekan/issues/2769), new
`client/lib/cardDragGeometry.js`, `client/components/lists/listBody.js`): jQuery UI sortable
snapshots container geometry at drag start; a mid-drag DOM insertion (another user's new
card, or the drag's own composer auto-close) shifted every swimlane below while the cached
rectangles stayed put — the drop landed in the neighbouring swimlane with no visible
placeholder. A MutationObserver now refreshes the active drag's geometry on real mid-drag
layout changes (sortable's own churn filtered out). Tests:
`tests/cardDragGeometry.test.cjs` (13). Thanks to hever and xet7.
- **Board import lost card dates** ([#1992](https://github.com/wekan/wekan/issues/1992),
`models/wekanCreator.js`, new `models/lib/importedCardDates.js`): the importer derived
`createdAt` only from a `createCard` activity (absent in Sandstorm/pruned exports — dates
silently reset to import time) and never imported `receivedAt`/`endAt` at all. All five date
fields now restore with sane fallbacks (activity → the card's own exported date → import
time), and the card creator falls back to the exported userId. The missing-cards half of the
report was already fixed by 68e0032c6. Tests: `tests/importedCardDates.test.cjs` (13).
Thanks to xet7.
- **Archiving a swimlane made its cards disappear — and restore brought back an empty
swimlane** ([#2292](https://github.com/wekan/wekan/issues/2292), `models/swimlanes.js`, new
`models/lib/swimlaneArchive.js`): only the swimlane document was flagged; its unarchived
cards became invisible everywhere (board views render unarchived swimlanes, Archive lists
archived docs). Archiving a swimlane now archives its cards (mirroring lists), and restore
brings back exactly the cards archived WITH it — individually archived cards stay archived.
Tests: `tests/archiveSwimlaneCards.test.cjs` (11). Thanks to Cactusbone and xet7.
- **"Move/Copy selection to board" wrote `sort: NaN` to every card**
([#2494](https://github.com/wekan/wekan/issues/2494), `imports/reactiveCache.js`): the
client-side `noCache` card lookup returned a PROMISE since the Meteor 3 port, so the max-sort
read was `undefined` and every moved card got NaN — cards appeared and disappeared and could
not be reordered. The uncached client path is synchronous minimongo again. Tests:
`tests/reactiveCacheNoCacheCard.test.cjs` (8, incl. a proof the pre-fix routing yields NaN).
Thanks to Vermeille and xet7.
- **Subtask "View it" did nothing when the subtask lives on another board**
([#1853](https://github.com/wekan/wekan/issues/1853),
`client/components/cards/subtaskViewHelpers.js`, `subtasks.js`): the original crash
(`board._id` of undefined) had become a silent no-op guard — when the deposit board is not
in minimongo the button just did nothing. Navigation now falls back to the subtask's own
boardId (the route loads the board), and truly broken subtasks warn instead of dying.
Tests: `tests/subtaskViewNavigation.test.cjs` (11). Thanks to Vanclief and xet7.
- **Labels/members could not be dragged onto cards added after the board rendered**
([#1554](https://github.com/wekan/wekan/issues/1554), `client/components/lists/list.js`):
the droppable-initializing autorun lost its reactive dependency in 7673c77c5 (2023), so it
ran once per list render and later-added minicards silently rejected sidebar drags until the
board was re-entered ("works after search-and-back"). Dependency restored via the
ReactiveCache. Tests: `tests/labelDragDroppable.test.cjs` (3). Thanks to Miffe and xet7.
- **"Add filtered cards to selection" swept cards from OTHER boards into bulk actions**
([#2306](https://github.com/wekan/wekan/issues/2306), `client/lib/filter.js`,
`client/lib/multiSelection.js`, new `models/lib/boardScopedSelection.js`): the filter
selector carried no boardId, and minimongo legitimately holds foreign-board cards (linked
boards, dialogs, notifications) — a bulk archive could silently mutate other boards. The
selection and its bulk-action selector are now board-scoped, and foreign ids are rejected at
insertion. Tests: `tests/boardScopedSelection.test.cjs` (17, incl. the exact reported
repro). Thanks to IcedQuinn and xet7.
- **REST API: login tokens could never be revoked** ([#1437](https://github.com/wekan/wekan/issues/1437),
`server/apiAuthRoutes.js`, new `models/lib/apiLogout.js`): every `POST /users/login` minted
another ~90-day resume token with no way to invalidate any of them. New `POST /users/logout`
revokes the presented token (or all of the user's tokens with `{"all": true}`), always scoped
to the authenticated user. Tests: `tests/apiLogout.test.cjs` (12). Thanks to
ppouliot and xet7.
- **Editing one checklist item and clicking another left BOTH edit forms open — and
submitting overwrote the new item's title with the previous item's text**
([#2418](https://github.com/wekan/wekan/issues/2418), `client/lib/inlinedform.js`, new
+58 -13
View File
@@ -17,24 +17,69 @@ export function canNavigateToSubtaskBoard(board) {
// the parent card again, and so the behaviour is unit-testable.
//
// Returns the FlowRouter 'card' route params taken from the subtask, or
// undefined when the subtask's board is not loaded yet (see #4762 guard).
// undefined for a malformed subtask (no usable card id / board id).
//
// #1853: "Subtask board id missing: cannot read property _id of undefined".
// The subtask often lives on ANOTHER board (the deposit/target board) whose
// document is not in minimongo while the parent card's board is open. In that
// case subtask.board() yields nothing; instead of crashing (the original
// TypeError) or silently doing nothing (the post-#4762 behaviour), fall back
// to the subtask's own boardId and the default 'board' slug — the 'card'
// route action only uses :boardId/:cardId (config/router.js) and its
// subscriptions load the board, so navigation succeeds without a reload.
// 'board' is the model's own default slug (models/boards.js).
export function subtaskNavTarget(subtask) {
if (!subtask) {
return undefined;
}
const board = typeof subtask.board === 'function' ? subtask.board() : undefined;
if (!canNavigateToSubtaskBoard(board)) {
return undefined;
}
// Prefer the real card id (resolves linked subtask cards to their source);
// fall back to the plain _id when getRealId is unavailable (e.g. in tests).
const cardId =
typeof subtask.getRealId === 'function' ? subtask.getRealId() : subtask._id;
return {
boardId: board._id,
slug: board.slug,
cardId,
swimlaneId: subtask.swimlaneId,
listId: subtask.listId,
};
(typeof subtask.getRealId === 'function' ? subtask.getRealId() : undefined) ||
subtask._id;
if (!cardId) {
// Malformed subtask document: nothing sane to navigate to.
return undefined;
}
const board = typeof subtask.board === 'function' ? subtask.board() : undefined;
if (canNavigateToSubtaskBoard(board)) {
return {
boardId: board._id,
slug: board.slug,
cardId,
swimlaneId: subtask.swimlaneId,
listId: subtask.listId,
};
}
if (subtask.boardId) {
// Board doc missing locally (#1853 / #4762): navigate by id and let the
// route load the board. The slug segment is cosmetic for the card route.
return {
boardId: subtask.boardId,
slug: 'board',
cardId,
swimlaneId: subtask.swimlaneId,
listId: subtask.listId,
};
}
// No board reference at all: broken subtask, don't navigate (and don't
// throw — the caller shows a warning instead).
return undefined;
}
// Same idea for the "Go to board" popup entry (js-go-to-subtask-board):
// prefer the loaded board's id/slug, fall back to the subtask's boardId so a
// not-yet-subscribed deposit board is still reachable (#1853).
export function subtaskBoardNavTarget(subtask) {
if (!subtask) {
return undefined;
}
const board = typeof subtask.board === 'function' ? subtask.board() : undefined;
if (canNavigateToSubtaskBoard(board)) {
return { id: board._id, slug: board.slug };
}
if (subtask.boardId) {
return { id: subtask.boardId, slug: 'board' };
}
return undefined;
}
+23 -6
View File
@@ -3,7 +3,10 @@ import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import Cards from '/models/cards';
import { Filter } from '/client/lib/filter';
import { subtaskStatusLabel } from './subtaskStatusHelpers';
import { subtaskNavTarget } from './subtaskViewHelpers';
import {
subtaskNavTarget,
subtaskBoardNavTarget,
} from './subtaskViewHelpers';
import { Utils } from '/client/lib/utils';
Template.subtasks.events({
@@ -125,20 +128,34 @@ Template.subtaskActionsPopup.events({
const subtask = Template.currentData().subtask;
// #3743: open the SUBTASK card itself, not the parent/current card.
// subtaskNavTarget derives boardId/slug/cardId from the subtask only.
// #4762: it also returns undefined while the subtask's board is not
// loaded yet (ReactiveCache miss), mirroring js-go-to-subtask-board.
// #1853 / #4762: when the subtask's board (often a DIFFERENT deposit
// board) is not in minimongo yet, it falls back to the subtask's own
// boardId so the route loads the board — no TypeError, no dead button.
// It only returns undefined for a truly broken subtask document.
const target = subtaskNavTarget(subtask);
if (target) {
FlowRouter.go('card', target);
} else {
// Broken subtask (no card id / board id): warn instead of throwing.
console.warn(
'Cannot view subtask: missing board/card id on subtask',
subtask && subtask._id,
);
}
}
},
'click .js-go-to-subtask-board'() {
const subtask = Template.currentData().subtask;
const board = subtask.board();
if (board) {
// #1853: fall back to subtask.boardId when the board doc is not loaded.
const target = subtaskBoardNavTarget(subtask);
if (target) {
Popup.close();
FlowRouter.go('board', { id: board._id, slug: board.slug });
FlowRouter.go('board', target);
} else {
console.warn(
'Cannot go to subtask board: missing board id on subtask',
subtask && subtask._id,
);
}
},
'click .js-delete-subtask' : Popup.afterConfirm('subtaskDelete', async function () {
+7
View File
@@ -447,6 +447,13 @@ Template.list.onRendered(function () {
const currentBoardId = Tracker.nonreactive(() => {
return Session.get('currentBoard');
});
// #1554: reactive dependency so newly added / re-rendered minicards are
// (re)initialized as droppable for label/member drags. The dependency
// (Cards.find(...).fetch()) was dropped in 7673c77c5, so the autorun ran
// ONCE per list render and cards added afterwards silently rejected
// sidebar label/member drops until the board was re-entered. Re-running
// .droppable() on initialized elements is an idempotent option refresh.
ReactiveCache.getCards({ boardId: currentBoardId });
Tracker.afterFlush(() => {
$cards.find(itemsSelector).droppable({
hoverClass: 'draggable-hover-card',
+78
View File
@@ -13,6 +13,10 @@ import { isLinkableCardTarget } from '/models/lib/linkedCardTarget';
import { listCardsSelector } from '/models/lib/swimlaneFilter';
import { sortCardsByTitle } from '/models/lib/sortCardsByTitle';
import { isLazyCards, BoardListCardCounts, windowCountId } from '/client/lib/lazyCards';
import {
mutationsChangeDragGeometry,
findActiveCardDrag,
} from '/client/lib/cardDragGeometry';
import autosize from 'autosize';
// SubsManager removed for Meteor 3 migration
@@ -453,6 +457,80 @@ Template.listBody.events({
},
});
// #2769 Card lands in the WRONG swimlane when it is dropped while a connected
// list's layout changed mid-drag. jQuery UI sortable snapshots the geometry
// of every connected `.js-minicards` container once at drag start
// (_mouseStart runs refreshPositions() BEFORE the `start` callback) and only
// re-snapshots after its own placeholder moves — never when Blaze mutates the
// DOM. Two such mutations routinely happen during a drag: another user's
// newly added card is reactively inserted into the target list (the issue's
// "target column where a new card is being added"), and the dragging user's
// own open add-card composer is closed by the `start` callback's
// EscapeActions call, AFTER the snapshot. Every list/swimlane below the
// change then shifts vertically while the cached rectangles stay put, so the
// drop resolves against a stale map: the placeholder is parked in the
// neighbouring swimlane's copy of the list (hence the reporter's "no drop
// shadow in the target column") and the stop handler's
// ui.item.parents('.swimlane') (client/components/lists/list.js) faithfully
// persists that wrong swimlane.
// Fix: watch each list's minicards for REAL DOM changes (Blaze insertions and
// removals — not jQuery UI's own placeholder/helper churn, which the library
// already follows with its own refresh) and re-cache the active drag's
// geometry with sortable('refresh'). The decision helpers are pure and unit
// tested in tests/cardDragGeometry.test.cjs.
Template.listBody.onRendered(function () {
const minicardsEl = this.find('.js-minicards');
if (!minicardsEl || typeof MutationObserver === 'undefined') {
return;
}
this.dragGeometryRefreshQueued = false;
this.dragGeometryObserver = new MutationObserver(mutations => {
// Cheapest gate first: no drag helper on the page means no drag at all
// (this observer also fires on every ordinary reactive card update).
if (!document.getElementsByClassName('ui-sortable-helper').length) {
return;
}
if (!mutationsChangeDragGeometry(mutations)) {
return;
}
if (this.dragGeometryRefreshQueued) {
return;
}
this.dragGeometryRefreshQueued = true;
// Coalesce bursts (Blaze can insert several nodes per flush); by the time
// the timeout runs, this tick's layout is final and measurable.
setTimeout(() => {
this.dragGeometryRefreshQueued = false;
const active = findActiveCardDrag(
$('.js-minicards')
.toArray()
.map(el => $.data(el, 'ui-sortable')),
);
if (active) {
try {
// Re-collects the items of every connected container and re-measures
// all container rectangles, so the pointer is matched against the
// CURRENT layout for the rest of the drag.
active.refresh();
} catch (e) {
// A failed re-measure must never break the drag itself.
}
}
}, 0);
});
this.dragGeometryObserver.observe(minicardsEl, {
childList: true,
subtree: true,
});
});
Template.listBody.onDestroyed(function () {
if (this.dragGeometryObserver) {
this.dragGeometryObserver.disconnect();
this.dragGeometryObserver = null;
}
});
function toggleValueInReactiveArray(reactiveValue, value) {
const array = reactiveValue.get();
const valueIndex = array.indexOf(value);
+103
View File
@@ -0,0 +1,103 @@
'use strict';
// Pure helpers for keeping jQuery UI sortable's cached drag geometry in sync
// with the real card layout. Extracted so the decision logic is unit-testable
// in plain Node without Meteor (mirrors models/lib/cardSortRepair.js).
//
// #2769 "Card moves to wrong swimlane when moving to target column where a
// new card is being added": jQuery UI sortable snapshots the geometry of
// every connected `.js-minicards` container ONCE at drag start
// (sortable._mouseStart calls refreshPositions() BEFORE it fires the `start`
// callback) and only re-snapshots after its OWN placeholder rearranges. It
// never notices DOM changes made by anyone else. Two such changes routinely
// happen DURING a card drag:
//
// * another user submits the open add-card composer, so Blaze reactively
// inserts the new minicard into a connected list (the issue's "target
// column where a new card is being added"), and
// * the dragging user's own open composer is closed by the `start`
// callback's EscapeActions.executeUpTo('popup-close')
// (client/components/lists/list.js) — i.e. AFTER the snapshot was taken.
//
// Either way, every list and swimlane below the change shifts vertically
// while the cached rectangles stay where they were, so the pointer is matched
// against a stale map: the placeholder is parked in the NEIGHBOURING
// swimlane's copy of the list (which is why the reporter saw no drop shadow
// in the column they were aiming at), and the `stop` handler's
// ui.item.parents('.swimlane') then faithfully reports that wrong swimlane —
// the card "disappears and reappears in the swimlane below".
//
// The wiring lives in client/components/lists/listBody.js (a MutationObserver
// per list body that calls sortable('refresh') on the active drag). The
// decisions worth testing in isolation are below:
// 1. whether a mutation batch represents a REAL layout change (a Blaze
// insertion/removal) as opposed to jQuery UI's own placeholder/helper
// churn, which happens on every mousemove and must not trigger anything,
// and
// 2. which sortable instance (if any) is the active card drag whose
// geometry needs re-caching.
// jQuery UI marks the elements it moves around itself during a drag: the drop
// shadow ("ui-sortable-placeholder") and the dragged clone
// ("ui-sortable-helper"). Their insertions/removals are part of the drag and
// already followed by the library's own refreshPositions; reacting to them
// would just do the same work twice on every mousemove.
function nodeChangesDragGeometry(node) {
if (!node || node.nodeType !== 1) {
// Text/comment nodes: no box of their own worth re-snapshotting for.
return false;
}
const classList = node.classList;
if (!classList || typeof classList.contains !== 'function') {
// An element we cannot classify still occupies layout space.
return true;
}
return (
!classList.contains('ui-sortable-placeholder') &&
!classList.contains('ui-sortable-helper')
);
}
// Does this MutationObserver batch contain at least one real DOM change
// (an element other than jQuery UI's own drag artefacts was added or
// removed)? Only then is the drag geometry snapshot stale.
function mutationsChangeDragGeometry(mutations) {
if (!mutations) return false;
for (let i = 0; i < mutations.length; i += 1) {
const mutation = mutations[i];
if (!mutation || mutation.type !== 'childList') continue;
const added = mutation.addedNodes || [];
const removed = mutation.removedNodes || [];
for (let j = 0; j < added.length; j += 1) {
if (nodeChangesDragGeometry(added[j])) return true;
}
for (let j = 0; j < removed.length; j += 1) {
if (nodeChangesDragGeometry(removed[j])) return true;
}
}
return false;
}
// Given the sortable instances of every card container on the board, return
// the one an active card drag started from — that instance owns the cached
// geometry of ALL connected containers (its refresh() re-collects items and
// re-measures every container). Returns null when no card drag is running,
// e.g. when the observed change was an ordinary reactive update, or the
// leftover DOM churn of sortable('cancel') after the drag already ended
// (sortable sets `dragging` back to false before its stop event fires).
function findActiveCardDrag(instances) {
if (!instances) return null;
for (let i = 0; i < instances.length; i += 1) {
const instance = instances[i];
if (instance && instance.dragging === true && instance.currentItem) {
return instance;
}
}
return null;
}
module.exports = {
nodeChangesDragGeometry,
mutationsChangeDragGeometry,
findActiveCardDrag,
};
+19 -5
View File
@@ -27,6 +27,8 @@ import {
buildDateValueSelector,
} from '/imports/lib/advancedFilter';
import { weekRange } from '/models/lib/weekStart';
import { Session } from 'meteor/session';
import { boardScopedFilterSelector } from '/models/lib/boardScopedSelection';
// Sidebar is imported late to avoid circular dependency (sidebar.js needs its
// jade template loaded first, but router.js → filter.js would load it too early)
let _Sidebar;
@@ -785,11 +787,23 @@ export const Filter = {
mongoSelector(additionalSelector) {
const filterSelector = this._getMongoSelector();
if (additionalSelector === undefined) return filterSelector;
else
return {
$and: [filterSelector, additionalSelector],
};
if (additionalSelector === undefined) {
// #2306: a call without an additional selector means "all cards
// matching the filter" (e.g. the filter sidebar's "To selection"
// button). The client cache may also hold cards from OTHER boards
// (a board being navigated away from, linked-board subscriptions,
// notifications, popup card data, ...), so scope the query to the
// board currently being viewed. Callers that pass an additional
// selector are already scoped to a list/swimlane/card of the current
// board.
return boardScopedFilterSelector(
filterSelector,
Session.get('currentBoard'),
);
}
return {
$and: [filterSelector, additionalSelector],
};
},
reset() {
+20 -3
View File
@@ -1,8 +1,13 @@
import { ReactiveVar } from 'meteor/reactive-var';
import { Session } from 'meteor/session';
import { ReactiveCache } from '/imports/reactiveCache';
import { Filter } from '/client/lib/filter';
import { EscapeActions } from '/client/lib/escapeActions';
import { Utils } from '/client/lib/utils';
import {
boardScopedSelectionSelector,
cardIdsOnBoard,
} from '/models/lib/boardScopedSelection';
// Late-bind Sidebar to avoid circular dependency (sidebar.js needs its template first)
let _Sidebar;
@@ -78,9 +83,15 @@ export const MultiSelection = {
},
getMongoSelector() {
return Filter.mongoSelector({
_id: { $in: this._selectedCards.get() },
});
// #2306: scope the selection to the board currently being viewed so bulk
// actions (archive, move, label, ...) never touch cards that were
// selected on a previously visited board.
return Filter.mongoSelector(
boardScopedSelectionSelector(
this._selectedCards.get(),
Session.get('currentBoard'),
),
);
},
isActive() {
@@ -147,6 +158,12 @@ export const MultiSelection = {
toggle(cardIds, options = {}) {
cardIds = typeof cardIds === 'string' ? [cardIds] : cardIds;
// #2306: never let cards that verifiably live on ANOTHER board enter the
// selection (e.g. handed over by a caller that queried the client card
// cache without a board scope).
cardIds = cardIdsOnBoard(cardIds, Session.get('currentBoard'), id =>
ReactiveCache.getCard(id),
);
options = {
add: true,
remove: true,
+15 -2
View File
@@ -67,14 +67,27 @@ describe('subtaskNavTarget (#3743)', function() {
expect(target.cardId).to.not.equal(parentCard._id);
});
it('returns undefined when the subtask board is not loaded yet (#4762)', function() {
it('falls back to the subtask boardId when its board is not loaded (#1853/#4762)', function() {
// The deposit/target board is often ANOTHER board that is not in
// minimongo while the parent board is open; navigation must still work
// (the card route loads the board), it must not throw or no-op.
const subtask = makeSubtask();
subtask.board = () => undefined;
expect(subtaskNavTarget(subtask)).to.equal(undefined);
const target = subtaskNavTarget(subtask);
expect(target.boardId).to.equal('subtaskBoardId');
expect(target.slug).to.equal('board');
expect(target.cardId).to.equal('subtaskRealId');
});
it('returns undefined for a missing subtask', function() {
expect(subtaskNavTarget(undefined)).to.equal(undefined);
expect(subtaskNavTarget(null)).to.equal(undefined);
});
it('returns undefined for a broken subtask with no board reference at all', function() {
const subtask = makeSubtask();
subtask.board = () => undefined;
subtask.boardId = undefined;
expect(subtaskNavTarget(subtask)).to.equal(undefined);
});
});
+25 -3
View File
@@ -1180,11 +1180,33 @@ const ReactiveCache = {
}
},
getCard(idOrFirstObjectSelector = null, options = {}, noCache = false) {
if (Meteor.isServer || noCache === true) {
if (Meteor.isServer) {
return ReactiveCacheServer.getCard(idOrFirstObjectSelector, options);
} else {
return ReactiveCacheClient.getCard(idOrFirstObjectSelector, options);
}
if (noCache === true) {
// #2494 (cards sent to another board appear/disappear when reordering):
// on the CLIENT, `noCache` must bypass the DataCache but stay
// SYNCHRONOUS like every other client-side ReactiveCache getter.
// Routing it to the async ReactiveCacheServer.getCard returned a
// Promise; synchronous callers such as the multi-select "Move/Copy
// selection" max-sort lookup (client/components/sidebar/
// sidebarFilters.js getMaxSortForList) read `.sort` off that Promise,
// got `undefined`, and wrote `sort: NaN` to every card moved to the
// target list — the cards then landed at unpredictable positions,
// hid behind each other and could not be reordered. Minimongo's
// findOne is synchronous on the client, and callers that `await`
// this call (models/cards.js getSort, models/boards.js
// getNextCardNumber) work unchanged when given a plain document.
if (
idOrFirstObjectSelector === null ||
idOrFirstObjectSelector === undefined ||
idOrFirstObjectSelector === ''
) {
return null;
}
return Cards.findOne(idOrFirstObjectSelector, options);
}
return ReactiveCacheClient.getCard(idOrFirstObjectSelector, options);
},
getCards(selector = {}, options = {}, getQuery = false) {
if (Meteor.isServer) {
+84
View File
@@ -0,0 +1,84 @@
'use strict';
// Pure helper for the POST /users/logout REST endpoint (issue #1437: "Old
// tokens are not replaced or set invalid"). Extracted so the request
// validation / Mongo-update planning is unit-testable in plain Node without
// Meteor (mirrors models/lib/restLabel.js and models/lib/boardSortReorder.js).
//
// #1437: the REST API has always had POST /users/login (which mints a new
// resume login token on every call) but never a logout, so tokens acquired
// via the API could not be invalidated — they simply piled up in
// services.resume.loginTokens until their ~90 day expiry ("I have probably
// created 50+ valid tokens recently"). A leaked token therefore stayed valid
// with no server-side way to revoke it short of editing the database.
//
// buildLogoutPlan validates the (already-authenticated) request and yields a
// definite outcome for the route handler:
// - { ok: true, all, selector, modifier } -> caller runs
// Meteor.users.updateAsync(selector, modifier) and reports success.
// - { ok: false, status, error, reason } -> caller sends that JSON error.
//
// Two modes:
// - default: revoke ONLY the token presented in the Authorization header
// ($pull of its hashed form) — other sessions (e.g. the browser GUI)
// stay logged in;
// - { "all": true } in the request body: revoke EVERY resume token of the
// user ("logout everywhere"), cleaning up any accumulated/leaked tokens.
// Truthy spellings accepted for the `all` flag. Body parsing may deliver a
// boolean (JSON body) or a string (urlencoded body), so accept both.
function wantsAllSessions(rawAll) {
if (rawAll === true) return true;
if (typeof rawAll === 'string') {
const s = rawAll.trim().toLowerCase();
return s === 'true' || s === '1' || s === 'yes';
}
if (rawAll === 1) return true;
return false;
}
function buildLogoutPlan(request) {
const { userId, hashedToken, all } =
request && typeof request === 'object' ? request : {};
if (typeof userId !== 'string' || userId.length === 0) {
return {
ok: false,
status: 401,
error: 'unauthorized',
reason:
'You must be logged in to log out. Send your token as an Authorization: Bearer header.',
};
}
const everywhere = wantsAllSessions(all);
if (everywhere) {
return {
ok: true,
all: true,
selector: { _id: userId },
modifier: { $set: { 'services.resume.loginTokens': [] } },
};
}
if (typeof hashedToken !== 'string' || hashedToken.length === 0) {
return {
ok: false,
status: 401,
error: 'unauthorized',
reason: 'No login token found on the request.',
};
}
return {
ok: true,
all: false,
selector: { _id: userId },
modifier: { $pull: { 'services.resume.loginTokens': { hashedToken } } },
};
}
module.exports = {
buildLogoutPlan,
wantsAllSessions,
};
+60
View File
@@ -0,0 +1,60 @@
'use strict';
// Pure, Meteor-free helpers that scope the Filter / MultiSelection card
// selectors to a single board. Extracted so they are unit-testable in plain
// Node (mirrors models/lib/calendarFilter.js and models/lib/swimlaneFilter.js).
//
// #2306 ("filter to selection"): the filter sidebar's "To selection" button
// queried the client card cache with ONLY the Filter's selector — no boardId —
// so it swept up matching cards from every board present in Minimongo (cards
// from a board being navigated away from, linked-board subscriptions, the
// notifications drawer, popup card data, ...). A later bulk action (archive,
// move, label, ...) on the selection then silently mutated cards on other
// boards. These helpers AND a `boardId` clause into the relevant selectors so
// both the selection query and every bulk action stay contained to the board
// the user is looking at.
// Scope a Filter selector (the object produced by Filter._getMongoSelector())
// to one board. Without a boardId (e.g. on a non-board page) the selector is
// returned unchanged. The board clause is combined via `$and` so it can never
// collide with the Filter's own top-level `$or`.
function boardScopedFilterSelector(filterSelector, boardId) {
const base =
filterSelector && typeof filterSelector === 'object' ? filterSelector : {};
if (!boardId) return base;
return { $and: [base, { boardId }] };
}
// Selector for the cards of a multi-selection, scoped to one board. Bulk
// actions iterate the cards this selector matches, so even if a stale
// selection still holds ids from a previously visited board (the selection is
// not always reset on every navigation path), those foreign cards are never
// touched.
function boardScopedSelectionSelector(selectedCardIds, boardId) {
const selector = {
_id: { $in: Array.isArray(selectedCardIds) ? selectedCardIds : [] },
};
if (boardId) selector.boardId = boardId;
return selector;
}
// Keep only the card ids that belong to `boardId`. `getCard` is a lookup
// (id -> card doc or undefined/null). Ids whose card cannot be found or has no
// boardId are kept (they cannot be positively identified as foreign, and a
// card missing from the cache cannot be acted on anyway); ids whose card
// verifiably lives on ANOTHER board are dropped. Without a boardId or lookup
// function the input is returned unchanged.
function cardIdsOnBoard(cardIds, boardId, getCard) {
if (!Array.isArray(cardIds)) return cardIds;
if (!boardId || typeof getCard !== 'function') return cardIds;
return cardIds.filter(cardId => {
const card = getCard(cardId);
return !card || !card.boardId || card.boardId === boardId;
});
}
module.exports = {
boardScopedFilterSelector,
boardScopedSelectionSelector,
cardIdsOnBoard,
};
+51
View File
@@ -0,0 +1,51 @@
// #1992: restore ALL card date fields when importing a WeKan board export.
//
// The exporter (models/exporter.js) dumps full card documents, so an export
// carries createdAt, receivedAt, startAt, dueAt and endAt. The importer
// (models/wekanCreator.js) previously restored only startAt and dueAt, and
// derived createdAt exclusively from a matching `createCard` activity — but
// Sandstorm exports and old/pruned boards often have no such activity, so the
// card's creation date silently reset to the import time ("dates missing" in
// the issue). receivedAt and endAt were dropped for every import.
//
// This module is pure (no Meteor) so it can be unit-tested with plain Node.
/**
* Parse a date-ish value (ISO string or Date) into a valid Date, or null.
* Invalid values return null instead of an Invalid Date so a corrupt field in
* a foreign/hand-edited export can never fail collection2 validation and
* abort the whole card insert (which would make cards go missing).
*/
function toValidDateOrNull(value) {
if (!value) return null;
const d = new Date(value);
return isNaN(d.getTime()) ? null : d;
}
/**
* Compute the date fields for a card being imported.
*
* @param {Object} card the card object from the export
* @param {String|Date|null} activityCreatedAt creation date recovered from the
* export's `createCard` activity, when one exists (takes precedence,
* matching the importer's historical behaviour)
* @param {Date} now fallback timestamp (the import operation's shared date)
* @returns {{createdAt: Date, receivedAt: ?Date, startAt: ?Date, dueAt: ?Date, endAt: ?Date}}
*/
function importedCardDates(card, activityCreatedAt, now) {
const fallbackNow =
now instanceof Date && !isNaN(now.getTime()) ? now : new Date();
const createdAt =
toValidDateOrNull(activityCreatedAt) ||
toValidDateOrNull(card && card.createdAt) ||
fallbackNow;
return {
createdAt,
receivedAt: toValidDateOrNull(card && card.receivedAt),
startAt: toValidDateOrNull(card && card.startAt),
dueAt: toValidDateOrNull(card && card.dueAt),
endAt: toValidDateOrNull(card && card.endAt),
};
}
module.exports = { importedCardDates, toValidDateOrNull };
+52
View File
@@ -0,0 +1,52 @@
'use strict';
// Pure, Meteor-free helpers for the swimlane archive/restore card cascade.
// Extracted so the cascade decisions are unit-testable in plain Node
// (mirrors models/lib/defaultSwimlane.js / models/lib/swimlaneFilter.js).
//
// #2292 ("Archive a swimlane drives to deletion of all cards included"):
// archiving a swimlane used to flag ONLY the swimlane document. Its cards
// stayed `archived: false` but became invisible everywhere — the board views
// only render unarchived swimlanes, and the Archive sidebar only lists
// documents with `archived: true`, so the cards showed up in neither. Worse,
// the startup schema upgrade (server/lib/schemaUpgradeSteps.js,
// `swimlane-structure`, #1959/#1971) treats "unarchived card under an
// ARCHIVED swimlane" as broken state and reassigns such cards to a visible
// swimlane — so after a server restart the archived swimlane was emptied for
// good and restoring it produced an empty swimlane, exactly as reported.
//
// The fix mirrors the existing list cascade (models/lists.js archive()/
// restore(), which archive/restore the list's cards with card.archive() /
// card.restore()): archiving a swimlane archives its still-unarchived cards
// so they are visible in Archive and restorable, and restoring the swimlane
// restores the cards that were archived WITH it — not cards the user had
// individually archived beforehand.
// Cards that must be archived together with the swimlane: every card of the
// swimlane that is not archived yet. Already-archived cards are left alone so
// their own archivedAt (and their place in the user's Archive history) is not
// overwritten by the swimlane cascade.
function cardsToArchiveWithSwimlane(cards) {
return (cards || []).filter(card => card && card.archived !== true);
}
// Cards that must be restored together with the swimlane: archived cards of
// the swimlane whose archivedAt is at/after the swimlane's own archivedAt —
// i.e. the ones the archive cascade above archived (the swimlane's archivedAt
// is stamped before the per-card archivedAt). Cards the user archived
// individually BEFORE archiving the swimlane keep their archived state.
// A swimlane archived before this fix has cascade-untouched cards (and may
// even lack archivedAt), so nothing is restored for it — its unarchived cards
// were never hidden by an `archived` flag in the first place.
function cardsToRestoreWithSwimlane(cards, swimlaneArchivedAt) {
if (!swimlaneArchivedAt) return [];
const archivedAtMs = new Date(swimlaneArchivedAt).getTime();
if (Number.isNaN(archivedAtMs)) return [];
return (cards || []).filter(card => {
if (!card || card.archived !== true || !card.archivedAt) return false;
const cardArchivedAtMs = new Date(card.archivedAt).getTime();
return !Number.isNaN(cardArchivedAtMs) && cardArchivedAtMs >= archivedAtMs;
});
}
module.exports = { cardsToArchiveWithSwimlane, cardsToRestoreWithSwimlane };
+25 -1
View File
@@ -524,7 +524,22 @@ Swimlanes.helpers({
await list.archive();
}
}
return await Swimlanes.updateAsync(this._id, { $set: { archived: true, archivedAt: new Date() } });
// #2292: archive the swimlane's cards WITH it, mirroring the card cascade
// of Lists.archive() (models/lists.js). Without this the cards stayed
// `archived: false` under an archived swimlane — invisible in every board
// view AND in the Archive sidebar, and reassigned to another swimlane by
// the startup rescue of #1959/#1971 (server/lib/schemaUpgradeSteps.js),
// so restoring the swimlane later found it empty. The swimlane's
// archivedAt is captured BEFORE the per-card archive() calls stamp each
// card's archivedAt, so restore() below can recognise the cards that were
// archived with the swimlane. Cards already archived by the user keep
// their own archivedAt (see models/lib/swimlaneArchive.js).
const { cardsToArchiveWithSwimlane } = require('./lib/swimlaneArchive');
const archivedAt = new Date();
for (const card of cardsToArchiveWithSwimlane(await this.allCards())) {
await card.archive();
}
return await Swimlanes.updateAsync(this._id, { $set: { archived: true, archivedAt } });
},
async restore() {
@@ -533,6 +548,15 @@ Swimlanes.helpers({
await list.restore();
}
}
// #2292: restore the cards that were archived WITH this swimlane by
// archive() above (their archivedAt is at/after the swimlane's),
// mirroring the card cascade of Lists.restore() (models/lists.js). Cards
// the user archived individually BEFORE archiving the swimlane stay
// archived (see models/lib/swimlaneArchive.js).
const { cardsToRestoreWithSwimlane } = require('./lib/swimlaneArchive');
for (const card of cardsToRestoreWithSwimlane(await this.allCards(), this.archivedAt)) {
await card.restore();
}
return await Swimlanes.updateAsync(this._id, { $set: { archived: false } });
},
+20 -6
View File
@@ -8,6 +8,7 @@ import { BOARD_COLORS, CARD_COLORS, SWIMLANE_COLORS } from '/models/metadata/col
import Users from '/models/users';
import { generateUniversalAttachmentUrl } from '/models/lib/universalUrlGenerator';
import { planImportedBoardMember } from '/models/lib/importedBoardMemberPlan';
import { importedCardDates } from '/models/lib/importedCardDates';
import {
getImportExportSecuritySettings,
anonymizedUserWord,
@@ -435,23 +436,36 @@ export class WekanCreator {
if (!listId) {
listId = await this._createDefaultList(boardId);
}
// #1992: restore every card date carried in the export. createdAt
// prefers the export's createCard activity but falls back to the
// card's own createdAt (Sandstorm and old/pruned exports have no
// createCard activity, which used to reset the creation date to the
// import time); receivedAt and endAt were previously dropped entirely.
const cardDates = importedCardDates(
card,
this.createdAt.cards[card._id],
this._now(),
);
const cardToCreate = {
archived: card.archived,
boardId,
cardNumber: card.cardNumber || (await boardObj.getNextCardNumber()),
// very old boards won't have a creation activity so no creation date
createdAt: this._now(this.createdAt.cards[card._id]),
createdAt: cardDates.createdAt,
dateLastActivity: this._now(),
description: card.description,
listId,
swimlaneId: this.swimlanes[card.swimlaneId] || this._defaultSwimlaneId,
sort: card.sort,
title: card.title,
// we attribute the card to its creator if available
userId: this._user(this.createdBy.cards[card._id]),
// we attribute the card to its creator if available: the createCard
// activity's author when present, else the userId stored on the
// exported card itself (again, Sandstorm/old exports lack activities).
userId: this._user(this.createdBy.cards[card._id] || card.userId),
isOvertime: card.isOvertime || false,
startAt: card.startAt ? this._now(card.startAt) : null,
dueAt: card.dueAt ? this._now(card.dueAt) : null,
receivedAt: cardDates.receivedAt,
startAt: cardDates.startAt,
dueAt: cardDates.dueAt,
endAt: cardDates.endAt,
spentTime: card.spentTime || null,
};
// add labels
+55
View File
@@ -7,6 +7,7 @@ const { Accounts } = require('meteor/accounts-base');
const { WebApp } = require('meteor/webapp');
const { check, Match } = require('meteor/check');
const { sendJsonResult } = require('/server/apiMiddleware');
const { buildLogoutPlan } = require('/models/lib/apiLogout');
const NonEmptyString = Match.Where(function (x) {
check(x, String);
@@ -100,6 +101,60 @@ WebApp.handlers.post('/users/login', async function (req, res) {
}
});
// ---------------------------------------------------------------------------
// POST /users/logout
// Fixes #1437 ("Old tokens are not replaced or set invalid"): the API could
// mint tokens via /users/login but never revoke them, so old/leaked tokens
// stayed valid until their expiry. Authenticate with the token to revoke
// (Authorization: Bearer <token>); by default only that token is removed.
// Send { "all": true } in the body to revoke every login token of the user.
// ---------------------------------------------------------------------------
WebApp.handlers.options('/users/logout', function (req, res) {
sendJsonResult(res);
});
WebApp.handlers.post('/users/logout', async function (req, res) {
try {
// req.userId / req.authToken are set by the apiMiddleware bearer-token
// and authenticateByToken middleware (loaded above via require).
const hashedToken = req.authToken
? Accounts._hashLoginToken(req.authToken)
: null;
const plan = buildLogoutPlan({
userId: req.userId,
hashedToken,
all: req.body && req.body.all,
});
if (!plan.ok) {
sendJsonResult(res, {
code: plan.status,
data: { error: plan.error, reason: plan.reason },
});
return;
}
await Meteor.users.updateAsync(plan.selector, plan.modifier);
sendJsonResult(res, {
data: {
message: plan.all
? 'All login tokens have been invalidated.'
: "You've been logged out!",
},
});
} catch (error) {
res.statusCode = error.statusCode || 500;
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({
error: error.error || error.message || 'Logout failed',
reason: error.reason || error.message,
}),
);
}
});
// ---------------------------------------------------------------------------
// POST /users/register
// ---------------------------------------------------------------------------
+143
View File
@@ -0,0 +1,143 @@
'use strict';
// Plain-Node unit test (no Meteor) for the POST /users/logout planning helper.
// Run: ELECTRON_RUN_AS_NODE=1 <node> tests/apiLogout.test.cjs
//
// Regression guard for #1437 ("Old tokens are not replaced or set invalid"):
// the REST API had POST /users/login but no logout, so tokens acquired via
// the API could never be invalidated server-side. buildLogoutPlan must
// produce the exact Mongo update that revokes the presented token (default)
// or every token ({ all: true }), and must cleanly reject unauthenticated
// requests — never throwing, never yielding an update without a user filter.
const assert = require('assert');
const {
buildLogoutPlan,
wantsAllSessions,
} = require('../models/lib/apiLogout');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
const USER = 'user123';
const HASH = 'sha256-hashed-token-value';
// --- positive: default single-token logout ---------------------------------
test('authenticated request revokes exactly the presented token via $pull', () => {
const r = buildLogoutPlan({ userId: USER, hashedToken: HASH });
assert.strictEqual(r.ok, true);
assert.strictEqual(r.all, false);
assert.deepStrictEqual(r.selector, { _id: USER });
assert.deepStrictEqual(r.modifier, {
$pull: { 'services.resume.loginTokens': { hashedToken: HASH } },
});
});
test('single-token logout matches by hashedToken (never the raw token)', () => {
const r = buildLogoutPlan({ userId: USER, hashedToken: HASH });
const pull = r.modifier.$pull['services.resume.loginTokens'];
assert.deepStrictEqual(Object.keys(pull), ['hashedToken']);
assert.strictEqual(pull.hashedToken, HASH);
});
// --- positive: logout everywhere --------------------------------------------
test('{ all: true } clears every resume login token of the user', () => {
const r = buildLogoutPlan({ userId: USER, hashedToken: HASH, all: true });
assert.strictEqual(r.ok, true);
assert.strictEqual(r.all, true);
assert.deepStrictEqual(r.selector, { _id: USER });
assert.deepStrictEqual(r.modifier, {
$set: { 'services.resume.loginTokens': [] },
});
});
test('urlencoded bodies work: all="true" / "1" / "yes" mean everywhere', () => {
['true', '1', 'yes', ' TRUE '].forEach(v => {
const r = buildLogoutPlan({ userId: USER, hashedToken: HASH, all: v });
assert.strictEqual(r.all, true, `all=${JSON.stringify(v)}`);
});
});
test('{ all: true } does not require a hashed token (revokes leaked ones too)', () => {
const r = buildLogoutPlan({ userId: USER, all: true });
assert.strictEqual(r.ok, true);
assert.strictEqual(r.all, true);
});
// --- negative: falsy / unknown `all` values stay in single-token mode --------
test('all=false / "false" / "0" / undefined stay in single-token mode', () => {
[false, 'false', '0', 'no', undefined, null, 0, ''].forEach(v => {
const r = buildLogoutPlan({ userId: USER, hashedToken: HASH, all: v });
assert.strictEqual(r.ok, true, `all=${JSON.stringify(v)}`);
assert.strictEqual(r.all, false, `all=${JSON.stringify(v)}`);
assert.ok(r.modifier.$pull, 'must be a $pull of the single token');
});
});
test('wantsAllSessions rejects arbitrary truthy junk (objects, arrays)', () => {
[{}, [], 'maybe', 2, -1].forEach(v => {
assert.strictEqual(wantsAllSessions(v), false, `all=${JSON.stringify(v)}`);
});
});
// --- negative: unauthenticated requests are rejected with 401 ----------------
test('missing userId is rejected with 401 (no update plan produced)', () => {
const r = buildLogoutPlan({ hashedToken: HASH });
assert.strictEqual(r.ok, false);
assert.strictEqual(r.status, 401);
assert.strictEqual(r.error, 'unauthorized');
assert.strictEqual(r.selector, undefined);
assert.strictEqual(r.modifier, undefined);
});
test('empty / non-string userId is rejected with 401', () => {
['', null, undefined, 42, {}].forEach(uid => {
const r = buildLogoutPlan({ userId: uid, hashedToken: HASH });
assert.strictEqual(r.ok, false, `userId=${JSON.stringify(uid)}`);
assert.strictEqual(r.status, 401, `userId=${JSON.stringify(uid)}`);
});
});
test('single-token mode without a token is rejected with 401', () => {
['', null, undefined, 7].forEach(tok => {
const r = buildLogoutPlan({ userId: USER, hashedToken: tok });
assert.strictEqual(r.ok, false, `hashedToken=${JSON.stringify(tok)}`);
assert.strictEqual(r.status, 401, `hashedToken=${JSON.stringify(tok)}`);
});
});
// --- safety: selector always scopes to the authenticated user ----------------
test('every successful plan filters by the authenticated user id only', () => {
[
buildLogoutPlan({ userId: USER, hashedToken: HASH }),
buildLogoutPlan({ userId: USER, hashedToken: HASH, all: true }),
].forEach(r => {
assert.strictEqual(r.ok, true);
assert.deepStrictEqual(Object.keys(r.selector), ['_id']);
assert.strictEqual(r.selector._id, USER);
});
});
// --- nothing ever throws ------------------------------------------------------
test('never throws for a range of odd inputs', () => {
const inputs = [
undefined,
null,
{},
{ userId: USER },
{ hashedToken: HASH },
{ userId: 42, hashedToken: 42, all: 'garbage' },
{ userId: USER, hashedToken: HASH, all: {} },
];
inputs.forEach(v => {
assert.doesNotThrow(() => buildLogoutPlan(v));
const r = buildLogoutPlan(v);
assert.ok(r && typeof r.ok === 'boolean', 'result must always have a boolean ok');
});
});
console.log(`\n${passed} tests passed.`);
+188
View File
@@ -0,0 +1,188 @@
'use strict';
// Plain-Node unit test (no Meteor) for the swimlane archive/restore card
// cascade. Run: node tests/archiveSwimlaneCards.test.cjs
//
// Regression guard for #2292 ("Archive a swimlane drives to deletion of all
// cards included"): archiving a swimlane used to flag only the swimlane
// document, leaving its cards `archived: false` under an archived swimlane —
// invisible in every board view AND in the Archive sidebar (which lists only
// `archived: true` documents), and reassigned to another swimlane by the
// startup rescue of #1959/#1971 (server/lib/schemaUpgradeSteps.js), so a
// later restore found the swimlane empty. The fix mirrors the card cascade
// of Lists.archive()/restore() (models/lists.js): Swimlanes.archive()
// archives the swimlane's still-unarchived cards, and Swimlanes.restore()
// restores exactly the cards that were archived WITH the swimlane.
const assert = require('assert');
const {
cardsToArchiveWithSwimlane,
cardsToRestoreWithSwimlane,
} = require('../models/lib/swimlaneArchive');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// --- tiny in-memory board: two swimlanes, cards in both ---------------------
// Simulates exactly what models/swimlanes.js archive()/restore() do with the
// helpers: archive() captures the swimlane's archivedAt FIRST, then archives
// each selected card with its own (later) archivedAt; restore() restores the
// cards selected by cardsToRestoreWithSwimlane(swimlane.archivedAt).
function makeCard(id, swimlaneId, extra = {}) {
return { _id: id, swimlaneId, archived: false, archivedAt: null, ...extra };
}
function archiveSwimlane(swimlane, cards, now) {
// model: const archivedAt = new Date(); cascade; then flag the swimlane
const archivedAt = new Date(now);
const swimlaneCards = cards.filter(c => c.swimlaneId === swimlane._id);
for (const card of cardsToArchiveWithSwimlane(swimlaneCards)) {
card.archived = true;
card.archivedAt = new Date(now + 1); // card.archive() stamps AFTER capture
}
swimlane.archived = true;
swimlane.archivedAt = archivedAt;
}
function restoreSwimlane(swimlane, cards) {
const swimlaneCards = cards.filter(c => c.swimlaneId === swimlane._id);
for (const card of cardsToRestoreWithSwimlane(swimlaneCards, swimlane.archivedAt)) {
card.archived = false; // card.restore() clears the flag, keeps archivedAt
}
swimlane.archived = false;
}
const T0 = Date.parse('2026-07-17T10:00:00Z');
function makeFixture() {
const laneA = { _id: 'laneA', archived: false, archivedAt: null };
const laneB = { _id: 'laneB', archived: false, archivedAt: null };
const cards = [
makeCard('a1', 'laneA'),
makeCard('a2', 'laneA'),
// archived by the USER an hour before the swimlane is archived:
makeCard('aPre', 'laneA', { archived: true, archivedAt: new Date(T0 - 3600000) }),
makeCard('b1', 'laneB'),
];
return { laneA, laneB, cards };
}
// --- cardsToArchiveWithSwimlane ---------------------------------------------
test('selects only unarchived cards for the archive cascade', () => {
const { cards } = makeFixture();
const laneACards = cards.filter(c => c.swimlaneId === 'laneA');
const ids = cardsToArchiveWithSwimlane(laneACards).map(c => c._id);
assert.deepStrictEqual(ids.sort(), ['a1', 'a2']);
});
test('negative: already-archived card is NOT selected (not double-touched)', () => {
const { cards } = makeFixture();
const laneACards = cards.filter(c => c.swimlaneId === 'laneA');
assert.ok(!cardsToArchiveWithSwimlane(laneACards).some(c => c._id === 'aPre'));
});
test('tolerates empty/null input', () => {
assert.deepStrictEqual(cardsToArchiveWithSwimlane([]), []);
assert.deepStrictEqual(cardsToArchiveWithSwimlane(null), []);
assert.deepStrictEqual(cardsToArchiveWithSwimlane([null]), []);
});
// --- cardsToRestoreWithSwimlane ---------------------------------------------
test('selects cards archived at/after the swimlane archivedAt', () => {
const swimlaneArchivedAt = new Date(T0);
const cards = [
{ _id: 'with', archived: true, archivedAt: new Date(T0 + 1) },
{ _id: 'same', archived: true, archivedAt: new Date(T0) },
{ _id: 'before', archived: true, archivedAt: new Date(T0 - 1) },
{ _id: 'unarchived', archived: false, archivedAt: null },
{ _id: 'noStamp', archived: true, archivedAt: null },
];
const ids = cardsToRestoreWithSwimlane(cards, swimlaneArchivedAt).map(c => c._id);
assert.deepStrictEqual(ids.sort(), ['same', 'with']);
});
test('negative: legacy swimlane without archivedAt restores no cards', () => {
const cards = [{ _id: 'x', archived: true, archivedAt: new Date(T0) }];
assert.deepStrictEqual(cardsToRestoreWithSwimlane(cards, null), []);
assert.deepStrictEqual(cardsToRestoreWithSwimlane(cards, undefined), []);
});
test('negative: invalid swimlane archivedAt restores no cards', () => {
const cards = [{ _id: 'x', archived: true, archivedAt: new Date(T0) }];
assert.deepStrictEqual(cardsToRestoreWithSwimlane(cards, 'not a date'), []);
});
// --- end-to-end simulation of the model flow --------------------------------
test('archiving a swimlane archives its unarchived cards (the #2292 fix)', () => {
const { laneA, cards } = makeFixture();
archiveSwimlane(laneA, cards, T0);
assert.strictEqual(laneA.archived, true);
const a1 = cards.find(c => c._id === 'a1');
const a2 = cards.find(c => c._id === 'a2');
assert.strictEqual(a1.archived, true, 'card a1 must be archived with the lane');
assert.strictEqual(a2.archived, true, 'card a2 must be archived with the lane');
// no unarchived card may remain under the archived swimlane — the broken
// state #2292 reported and the startup rescue has to repair
assert.ok(
!cards.some(c => c.swimlaneId === 'laneA' && c.archived === false),
'no unarchived card may remain under an archived swimlane',
);
});
test('restoring the swimlane brings back the cards archived with it', () => {
const { laneA, cards } = makeFixture();
archiveSwimlane(laneA, cards, T0);
restoreSwimlane(laneA, cards);
assert.strictEqual(laneA.archived, false);
assert.strictEqual(cards.find(c => c._id === 'a1').archived, false);
assert.strictEqual(cards.find(c => c._id === 'a2').archived, false);
});
test('negative: individually pre-archived card stays archived through the round-trip', () => {
const { laneA, cards } = makeFixture();
const before = cards.find(c => c._id === 'aPre').archivedAt;
archiveSwimlane(laneA, cards, T0);
const aPre = cards.find(c => c._id === 'aPre');
assert.strictEqual(aPre.archived, true, 'stays archived while lane is archived');
assert.strictEqual(aPre.archivedAt, before, 'archivedAt not overwritten by the cascade');
restoreSwimlane(laneA, cards);
assert.strictEqual(aPre.archived, true, 'restore must not resurrect it');
});
test("negative: other swimlanes' cards are untouched by archive AND restore", () => {
const { laneA, laneB, cards } = makeFixture();
archiveSwimlane(laneA, cards, T0);
const b1 = cards.find(c => c._id === 'b1');
assert.strictEqual(b1.archived, false, 'laneB card not archived');
assert.strictEqual(b1.archivedAt, null, 'laneB card not stamped');
assert.strictEqual(laneB.archived, false, 'laneB itself untouched');
restoreSwimlane(laneA, cards);
assert.strictEqual(b1.archived, false, 'laneB card still untouched after restore');
});
test('re-archiving after restore archives the restored cards again', () => {
const { laneA, cards } = makeFixture();
archiveSwimlane(laneA, cards, T0);
restoreSwimlane(laneA, cards);
archiveSwimlane(laneA, cards, T0 + 60000);
restoreSwimlane(laneA, cards);
assert.strictEqual(cards.find(c => c._id === 'a1').archived, false);
assert.strictEqual(cards.find(c => c._id === 'aPre').archived, true);
});
console.log(`\n${passed} tests passed`);
+212
View File
@@ -0,0 +1,212 @@
'use strict';
// Plain-Node unit test (no Meteor) for models/lib/boardScopedSelection.js.
// Run: ELECTRON_RUN_AS_NODE=1 <node> tests/boardScopedSelection.test.cjs
//
// Regression guard for #2306 ("filter to selection"): the filter sidebar's
// "To selection" button queried the client card cache with only the Filter's
// selector — no boardId — so it selected matching cards from EVERY board
// present in Minimongo (e.g. a previously visited board), and a later bulk
// action (archive, move, ...) then mutated cards on other boards. The helpers
// under test scope the filter query, the multi-selection selector, and the
// ids entering the selection to the board currently being viewed.
const assert = require('assert');
const {
boardScopedFilterSelector,
boardScopedSelectionSelector,
cardIdsOnBoard,
} = require('../models/lib/boardScopedSelection');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// Minimal Mongo/Minimongo-like matcher for the operators these selectors emit
// ($and, $or, $in, scalar equality, array containment) so we can assert the
// built selector actually selects/rejects representative card documents.
function fieldMatches(cond, value) {
if (cond && typeof cond === 'object' && !Array.isArray(cond)) {
if ('$in' in cond) {
const list = cond.$in;
if (Array.isArray(value)) return value.some(v => list.includes(v));
return list.includes(value);
}
}
if (Array.isArray(value) && !Array.isArray(cond)) return value.includes(cond);
return value === cond;
}
function docMatches(selector, doc) {
return Object.entries(selector).every(([key, cond]) => {
if (key === '$and') return cond.every(sub => docMatches(sub, doc));
if (key === '$or') return cond.some(sub => docMatches(sub, doc));
return fieldMatches(cond, doc[key]);
});
}
// The shape Filter._getMongoSelector() produces for an active members filter:
// an (empty) exceptions clause OR the members criterion — exactly the filter
// used in the #2306 reproduction ("Filter cards by user").
function membersFilterSelector(userId) {
return {
$or: [{ _id: { $in: [] } }, { members: { $in: [userId] } }],
};
}
// The #2306 reproduction data set: the same user is on one card of board1 and
// one card of board2, and both cards sit in the client cache.
const cardBoard1 = { _id: 'card1', boardId: 'board1', members: ['userA'] };
const cardBoard2 = { _id: 'card2', boardId: 'board2', members: ['userA'] };
const cardBoard1NoMatch = { _id: 'card3', boardId: 'board1', members: [] };
console.log('boardScopedFilterSelector');
test('positive: keeps matching cards of the current board', () => {
const selector = boardScopedFilterSelector(
membersFilterSelector('userA'),
'board1',
);
assert.strictEqual(docMatches(selector, cardBoard1), true);
});
test('negative: rejects matching cards of OTHER boards (#2306 repro)', () => {
const selector = boardScopedFilterSelector(
membersFilterSelector('userA'),
'board1',
);
assert.strictEqual(docMatches(selector, cardBoard2), false);
});
test('negative: still rejects same-board cards not matching the filter', () => {
const selector = boardScopedFilterSelector(
membersFilterSelector('userA'),
'board1',
);
assert.strictEqual(docMatches(selector, cardBoard1NoMatch), false);
});
test('without a boardId the filter selector is returned unchanged', () => {
const filter = membersFilterSelector('userA');
assert.strictEqual(boardScopedFilterSelector(filter, null), filter);
assert.strictEqual(boardScopedFilterSelector(filter, undefined), filter);
});
test('board clause is ANDed, never merged into the filter $or', () => {
const selector = boardScopedFilterSelector(
membersFilterSelector('userA'),
'board1',
);
assert.ok(Array.isArray(selector.$and));
assert.deepStrictEqual(selector.$and[1], { boardId: 'board1' });
// The original $or must survive intact inside the $and.
assert.ok(Array.isArray(selector.$and[0].$or));
});
test('falsy/non-object filter selector falls back to {} plus board scope', () => {
const selector = boardScopedFilterSelector(null, 'board1');
assert.deepStrictEqual(selector, { $and: [{}, { boardId: 'board1' }] });
assert.strictEqual(docMatches(selector, cardBoard1), true);
assert.strictEqual(docMatches(selector, cardBoard2), false);
});
console.log('boardScopedSelectionSelector');
test('positive: matches a selected card on the current board', () => {
const selector = boardScopedSelectionSelector(['card1', 'card2'], 'board1');
assert.strictEqual(docMatches(selector, cardBoard1), true);
});
test('negative: rejects a selected card living on another board', () => {
// Stale selection scenario: card2 (board2) is still in the selected ids
// after navigating to board1 — bulk actions must not touch it.
const selector = boardScopedSelectionSelector(['card1', 'card2'], 'board1');
assert.strictEqual(docMatches(selector, cardBoard2), false);
});
test('negative: rejects an unselected card even on the current board', () => {
const selector = boardScopedSelectionSelector(['card1'], 'board1');
assert.strictEqual(docMatches(selector, cardBoard1NoMatch), false);
});
test('without a boardId only the id list constrains the match', () => {
const selector = boardScopedSelectionSelector(['card2'], null);
assert.strictEqual(docMatches(selector, cardBoard2), true);
assert.strictEqual('boardId' in selector, false);
});
test('non-array ids produce an empty (match-nothing) $in', () => {
const selector = boardScopedSelectionSelector(undefined, 'board1');
assert.deepStrictEqual(selector._id, { $in: [] });
assert.strictEqual(docMatches(selector, cardBoard1), false);
});
console.log('cardIdsOnBoard');
const cache = {
card1: cardBoard1,
card2: cardBoard2,
card3: cardBoard1NoMatch,
card4: { _id: 'card4' }, // no boardId on the doc
};
const getCard = id => cache[id];
test('positive: keeps ids of cards on the current board', () => {
assert.deepStrictEqual(
cardIdsOnBoard(['card1', 'card3'], 'board1', getCard),
['card1', 'card3'],
);
});
test('negative: drops ids of cards verifiably on another board', () => {
assert.deepStrictEqual(
cardIdsOnBoard(['card1', 'card2'], 'board1', getCard),
['card1'],
);
});
test('keeps ids whose card is unknown or has no boardId', () => {
assert.deepStrictEqual(
cardIdsOnBoard(['missing', 'card4'], 'board1', getCard),
['missing', 'card4'],
);
});
test('without a boardId or lookup the ids pass through unchanged', () => {
const ids = ['card1', 'card2'];
assert.strictEqual(cardIdsOnBoard(ids, null, getCard), ids);
assert.strictEqual(cardIdsOnBoard(ids, 'board1', undefined), ids);
});
test('non-array input passes through unchanged', () => {
assert.strictEqual(cardIdsOnBoard(undefined, 'board1', getCard), undefined);
});
console.log('end-to-end: filter-to-selection then bulk action stays on board');
test('#2306: selection built from the scoped filter query holds only current-board cards', () => {
const allCachedCards = [cardBoard1, cardBoard2, cardBoard1NoMatch];
// Step 1: the "To selection" query (Filter.mongoSelector() with no
// additional selector) — now board-scoped.
const query = boardScopedFilterSelector(
membersFilterSelector('userA'),
'board1',
);
const selectedIds = allCachedCards
.filter(c => docMatches(query, c))
.map(c => c._id);
assert.deepStrictEqual(selectedIds, ['card1']);
// Step 2: the bulk-action selector (MultiSelection.getMongoSelector()) —
// even if a foreign id had crept in, it would not match.
const actionSelector = boardScopedSelectionSelector(
selectedIds.concat('card2'),
'board1',
);
const mutated = allCachedCards.filter(c => docMatches(actionSelector, c));
assert.deepStrictEqual(mutated.map(c => c._id), ['card1']);
});
console.log(`\n${passed} tests passed`);
+173
View File
@@ -0,0 +1,173 @@
'use strict';
// Plain-Node unit test (no Meteor) for the #2769 drag-geometry helpers.
// Run: ELECTRON_RUN_AS_NODE=1 <node> tests/cardDragGeometry.test.cjs
//
// #2769 "Card moves to wrong swimlane when moving to target column where a
// new card is being added": jQuery UI sortable snapshots the geometry of all
// connected .js-minicards containers once at drag start and never notices
// Blaze DOM mutations, so a card reactively inserted mid-drag (another user
// submitting the add-card composer) — or the dragging user's own composer
// being closed by the drag's start callback — shifts every list/swimlane
// below while the cached rectangles stay put. The drop then resolves against
// the stale map: the placeholder is parked in the NEIGHBOURING swimlane's
// list (no drop shadow where the user aims) and the stop handler persists
// that wrong swimlane.
//
// The fix (client/components/lists/listBody.js) observes each list's
// minicards and, when a REAL layout change happens during an active drag,
// calls sortable('refresh') on the drag's source instance. The pure decision
// helpers live in client/lib/cardDragGeometry.js and are tested here.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const {
nodeChangesDragGeometry,
mutationsChangeDragGeometry,
findActiveCardDrag,
} = require('../client/lib/cardDragGeometry');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// Minimal DOM-node stand-ins (MutationRecord nodes duck-typed).
function el(...classes) {
return {
nodeType: 1,
classList: { contains: c => classes.includes(c) },
};
}
const textNode = { nodeType: 3 };
function childList(added, removed) {
return { type: 'childList', addedNodes: added || [], removedNodes: removed || [] };
}
// --- POSITIVE: real layout changes during a drag must trigger a re-measure --
test('#2769: a minicard inserted by another user mid-drag is a geometry change', () => {
// Blaze inserts <a class="minicard-wrapper js-minicard"> when the other
// user's Cards.insert arrives over DDP — the exact trigger of the issue.
const mutations = [childList([el('minicard-wrapper', 'js-minicard')], [])];
assert.strictEqual(mutationsChangeDragGeometry(mutations), true);
});
test('the add-card composer opening mid-drag is a geometry change', () => {
const mutations = [childList([el('inlined-form', 'js-inlined-form')], [])];
assert.strictEqual(mutationsChangeDragGeometry(mutations), true);
});
test('the add-card composer CLOSING mid-drag is a geometry change (removal counts)', () => {
// The drag start callback closes the open composer via EscapeActions —
// after sortable already snapshotted the layout that still contained it.
const mutations = [childList([], [el('inlined-form', 'js-inlined-form')])];
assert.strictEqual(mutationsChangeDragGeometry(mutations), true);
});
test('an element without a usable classList is conservatively a geometry change', () => {
assert.strictEqual(nodeChangesDragGeometry({ nodeType: 1 }), true);
});
test('a mixed batch (placeholder churn plus a real insertion) is a geometry change', () => {
const mutations = [
childList([el('ui-sortable-placeholder', 'minicard-wrapper', 'placeholder')], []),
childList([el('minicard-wrapper', 'js-minicard')], []),
];
assert.strictEqual(mutationsChangeDragGeometry(mutations), true);
});
// --- NEGATIVE: jQuery UI's own drag churn must NOT trigger a re-measure -----
// (the placeholder moves on every mousemove; reacting to it would re-measure
// the whole board constantly and duplicate the library's own refresh)
test("NEGATIVE: the sortable's own placeholder moving is not a geometry change", () => {
const placeholder = el('ui-sortable-placeholder', 'minicard-wrapper', 'placeholder');
const mutations = [
childList([placeholder], []),
childList([], [placeholder]),
];
assert.strictEqual(mutationsChangeDragGeometry(mutations), false);
});
test('NEGATIVE: the drag helper clone is not a geometry change', () => {
const helper = el('ui-sortable-helper', 'minicard-wrapper', 'js-minicard');
assert.strictEqual(mutationsChangeDragGeometry([childList([helper], [])]), false);
});
test('NEGATIVE: text/comment nodes are not geometry changes', () => {
assert.strictEqual(mutationsChangeDragGeometry([childList([textNode], [])]), false);
});
test('NEGATIVE: non-childList batches and empty/missing input are ignored', () => {
assert.strictEqual(mutationsChangeDragGeometry([{ type: 'attributes' }]), false);
assert.strictEqual(mutationsChangeDragGeometry([]), false);
assert.strictEqual(mutationsChangeDragGeometry(null), false);
assert.strictEqual(mutationsChangeDragGeometry([null]), false);
});
// --- Active drag detection ---------------------------------------------------
test('the dragging sortable instance is found even when the mutation is in ANOTHER list', () => {
// The drag starts in list A; the new card arrives in list B. The refresh
// must go to A's instance — it owns the cached geometry of all connected
// containers.
const idle = { dragging: false, currentItem: null };
const active = { dragging: true, currentItem: {} };
assert.strictEqual(findActiveCardDrag([idle, active, idle]), active);
});
test('NEGATIVE: no refresh target when no card drag is running', () => {
// Ordinary reactive updates (and non-sortable .js-minicards, e.g. the
// search popup, whose $.data lookup yields undefined) must be no-ops.
const idle = { dragging: false, currentItem: null };
assert.strictEqual(findActiveCardDrag([idle, undefined, null]), null);
assert.strictEqual(findActiveCardDrag([]), null);
assert.strictEqual(findActiveCardDrag(null), null);
});
test("NEGATIVE: sortable('cancel') aftermath is not an active drag", () => {
// At drop time list.js calls sortable('cancel'), whose DOM restore fires
// the observer once more — but sortable has already set dragging=false, so
// no refresh must run after the drag ended.
const ended = { dragging: false, currentItem: {} };
assert.strictEqual(findActiveCardDrag([ended]), null);
});
// --- Wiring: listBody must actually observe and refresh ----------------------
test('listBody.js wires the observer to the helpers and refreshes the active drag', () => {
const src = fs.readFileSync(
path.join(__dirname, '..', 'client', 'components', 'lists', 'listBody.js'),
'utf8',
);
assert.ok(
src.includes("from '/client/lib/cardDragGeometry'"),
'listBody.js must import the geometry helpers',
);
assert.ok(
src.includes('new MutationObserver'),
'listBody.js must observe minicard DOM changes',
);
assert.ok(
src.includes('mutationsChangeDragGeometry(mutations)'),
'the observer must filter out placeholder/helper churn',
);
assert.ok(
src.includes('findActiveCardDrag('),
'the observer must locate the active drag instance',
);
assert.ok(
src.includes('active.refresh()'),
"the active drag's cached geometry must be re-measured",
);
assert.ok(
src.includes('dragGeometryObserver.disconnect()'),
'the observer must be disconnected when the list body is destroyed',
);
});
console.log(`\n${passed} passing`);
+118
View File
@@ -0,0 +1,118 @@
'use strict';
// Plain-Node unit test (no Meteor) for the board-import card date helper.
// Run: node tests/importedCardDates.test.cjs
//
// Covers #1992 "Some cards and dates missing when exporting from Sandstorm
// Wekan and importing to Standalone Wekan": the importer must restore ALL
// card dates carried in the export (createdAt, receivedAt, startAt, dueAt,
// endAt), falling back to the card's own createdAt when the export has no
// createCard activity (typical of Sandstorm and old/pruned exports), and a
// corrupt date value must degrade to null instead of an Invalid Date that
// would abort the card insert (a missing card).
const assert = require('assert');
const {
importedCardDates,
toValidDateOrNull,
} = require('../models/lib/importedCardDates');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
const NOW = new Date('2026-07-17T10:00:00.000Z');
// --- positive: all dates present in the export are restored ------------------
test('restores receivedAt, startAt, dueAt and endAt from the exported card', () => {
const card = {
receivedAt: '2018-11-01T08:00:00.000Z',
startAt: '2018-11-02T09:00:00.000Z',
dueAt: '2018-11-05T17:00:00.000Z',
endAt: '2018-11-04T16:30:00.000Z',
};
const dates = importedCardDates(card, null, NOW);
assert.strictEqual(dates.receivedAt.toISOString(), '2018-11-01T08:00:00.000Z');
assert.strictEqual(dates.startAt.toISOString(), '2018-11-02T09:00:00.000Z');
assert.strictEqual(dates.dueAt.toISOString(), '2018-11-05T17:00:00.000Z');
assert.strictEqual(dates.endAt.toISOString(), '2018-11-04T16:30:00.000Z');
});
test('createCard activity date wins for createdAt when present (historical behaviour)', () => {
const card = { createdAt: '2018-10-01T00:00:00.000Z' };
const dates = importedCardDates(card, '2018-09-15T12:00:00.000Z', NOW);
assert.strictEqual(dates.createdAt.toISOString(), '2018-09-15T12:00:00.000Z');
});
test('falls back to the card\'s own createdAt when there is no createCard activity (Sandstorm export)', () => {
const card = { createdAt: '2018-10-01T00:00:00.000Z' };
const dates = importedCardDates(card, null, NOW);
assert.strictEqual(dates.createdAt.toISOString(), '2018-10-01T00:00:00.000Z');
});
test('accepts Date objects as well as ISO strings (board clone path)', () => {
const card = { startAt: new Date('2020-01-01T00:00:00.000Z') };
const dates = importedCardDates(card, null, NOW);
assert.strictEqual(dates.startAt.toISOString(), '2020-01-01T00:00:00.000Z');
});
// --- negative: missing/corrupt data degrades safely --------------------------
test('createdAt falls back to the import time when neither activity nor card has it', () => {
const dates = importedCardDates({}, null, NOW);
assert.strictEqual(dates.createdAt.getTime(), NOW.getTime());
});
test('absent optional dates come back as null (not undefined, not Invalid Date)', () => {
const dates = importedCardDates({}, null, NOW);
assert.strictEqual(dates.receivedAt, null);
assert.strictEqual(dates.startAt, null);
assert.strictEqual(dates.dueAt, null);
assert.strictEqual(dates.endAt, null);
});
test('a corrupt date string becomes null instead of an Invalid Date', () => {
const card = { dueAt: 'not-a-date', endAt: '2018-13-99Tgarbage' };
const dates = importedCardDates(card, null, NOW);
assert.strictEqual(dates.dueAt, null);
assert.strictEqual(dates.endAt, null);
});
test('a corrupt createdAt on the card falls through to the import time', () => {
const dates = importedCardDates({ createdAt: 'garbage' }, null, NOW);
assert.strictEqual(dates.createdAt.getTime(), NOW.getTime());
});
test('a corrupt activity date falls through to the card createdAt', () => {
const card = { createdAt: '2019-05-05T05:05:05.000Z' };
const dates = importedCardDates(card, 'garbage', NOW);
assert.strictEqual(dates.createdAt.toISOString(), '2019-05-05T05:05:05.000Z');
});
test('handles a null/undefined card without throwing', () => {
const dates = importedCardDates(null, null, NOW);
assert.strictEqual(dates.createdAt.getTime(), NOW.getTime());
assert.strictEqual(dates.dueAt, null);
});
test('an invalid `now` fallback is replaced by a real current date', () => {
const dates = importedCardDates({}, null, new Date('garbage'));
assert.ok(dates.createdAt instanceof Date && !isNaN(dates.createdAt.getTime()));
});
// --- toValidDateOrNull unit checks -------------------------------------------
test('toValidDateOrNull: falsy values give null', () => {
assert.strictEqual(toValidDateOrNull(null), null);
assert.strictEqual(toValidDateOrNull(undefined), null);
assert.strictEqual(toValidDateOrNull(''), null);
assert.strictEqual(toValidDateOrNull(0), null);
});
test('toValidDateOrNull: valid ISO string gives the matching Date', () => {
const d = toValidDateOrNull('2021-06-01T12:00:00.000Z');
assert.strictEqual(d.toISOString(), '2021-06-01T12:00:00.000Z');
});
console.log(`\n${passed} tests passed`);
+47
View File
@@ -0,0 +1,47 @@
'use strict';
// Plain-Node regression guard (no Meteor) for issue #1554: dragging labels or
// members from the sidebar onto cards stopped working for any minicard
// rendered AFTER the list's initial render (new cards, cards dragged in,
// Blaze re-renders) — it only worked again after leaving and re-entering the
// board. Root cause: commit 7673c77c5 (2023) deleted the reactive dependency
// (`Cards.find({boardId}).fetch()`) from the droppable-initializing autorun in
// client/components/lists/list.js instead of converting it to the
// ReactiveCache, so the autorun ran exactly once per list render.
// Run: node tests/labelDragDroppable.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const src = fs.readFileSync(path.join(repoRoot, 'client/components/lists/list.js'), 'utf8');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
test('the droppable autorun depends reactively on the board\'s cards (#1554)', () => {
const autorun = src.match(/\/\/ We want to re-run this function any time a card is added\.[\s\S]*?Tracker\.afterFlush/);
assert.ok(autorun, 'droppable autorun found');
assert.ok(autorun[0].includes('ReactiveCache.getCards({ boardId: currentBoardId })'),
'without this dependency the autorun runs once and later-added cards never become droppable');
});
test('negative: the dependency is a reactive read, not wrapped in Tracker.nonreactive', () => {
const autorun = src.match(/\/\/ We want to re-run this function any time a card is added\.[\s\S]*?Tracker\.afterFlush/)[0];
const depIndex = autorun.indexOf('ReactiveCache.getCards');
const nonreactiveBlock = autorun.match(/Tracker\.nonreactive\(\(\) => \{[\s\S]*?\}\);/);
assert.ok(depIndex > autorun.indexOf(nonreactiveBlock[0]) + nonreactiveBlock[0].length,
'the getCards read must be OUTSIDE the nonreactive block or it registers no dependency');
});
test('the droppable still accepts sidebar members and labels', () => {
assert.ok(src.includes("accept: '.js-member,.js-label'"),
'sidebar member/label drags must remain accepted drop payloads');
});
console.log(`labelDragDroppable.test.cjs: ${passed} tests passed`);
+243
View File
@@ -0,0 +1,243 @@
'use strict';
// Plain-Node unit test (no Meteor) for ReactiveCache.getCard's client-side
// `noCache` path — the #2494 fix ("Send to another board makes cards appear
// and disappear when reordering").
// Run: node tests/reactiveCacheNoCacheCard.test.cjs
//
// Root cause: on the client, `ReactiveCache.getCard(selector, options, true)`
// (noCache) was routed to the ASYNC ReactiveCacheServer.getCard, so it
// returned a Promise. The multi-select "Move/Copy selection" dialogs' max-sort
// lookup (client/components/sidebar/sidebarFilters.js getMaxSortForList) is
// synchronous: it read `.sort` off that Promise, got `undefined`, and
// buildInsertionSortIndexes computed `undefined + 1 = NaN` — every card moved
// to the target list was written with `sort: NaN`, landing at unpredictable
// positions, hiding behind other cards and refusing to be reordered (exactly
// the #2494 symptom). The fix makes the client noCache path a SYNCHRONOUS
// uncached minimongo findOne, while callers that `await` it
// (models/cards.js getSort, models/boards.js getNextCardNumber) keep working
// because awaiting a plain document is a no-op.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const source = fs.readFileSync(
path.join(repoRoot, 'imports', 'reactiveCache.js'),
'utf8',
);
// --- load imports/reactiveCache.js in plain Node with stubbed Meteor deps ---
function loadReactiveCache({ isServer, cardsStub, dataCacheLog }) {
let src = source
// strip the 4 meteor/app import lines; their bindings are injected below
.replace(/^import .*$/gm, '')
.replace(
'export { ReactiveCache, ReactiveMiniMongoIndex };',
'module.exports = { ReactiveCache, ReactiveMiniMongoIndex };',
);
assert.ok(src.includes('module.exports'), 'export line was rewritten');
const Meteor = { isServer, isClient: !isServer };
const EJSON = { stringify: v => JSON.stringify(v), parse: v => JSON.parse(v) };
// Minimal DataCache: remembers the compute fn, records cache hits.
class DataCache {
constructor(fn) {
this.fn = fn;
}
get(key) {
if (dataCacheLog) dataCacheLog.push(key);
return this.fn(key);
}
clear() {}
}
const groupBy = () => ({});
const indexBy = () => ({});
// lazyCollectionProxy(() => require('/models/cards').default) — only the
// collections a test actually touches need to resolve.
const requireStub = id => {
if (id === '/models/cards') return { default: cardsStub };
return { default: {} };
};
const module_ = { exports: {} };
const fn = new Function(
'module',
'exports',
'require',
'Meteor',
'EJSON',
'DataCache',
'groupBy',
'indexBy',
src,
);
fn(module_, module_.exports, requireStub, Meteor, EJSON, DataCache, groupBy, indexBy);
return module_.exports;
}
// A stub Cards collection over an in-memory doc set, honouring the
// { sort: { sort: 1|-1 } } option the max/min-sort lookups use.
function makeCardsStub(docs) {
const calls = { findOne: 0, findOneAsync: 0 };
const pick = (selector, options) => {
// minimongo accepts an _id string or a selector object
const match =
typeof selector === 'string'
? d => d._id === selector
: d => Object.keys(selector).every(k => d[k] === selector[k]);
let list = docs.filter(match);
if (options && options.sort && options.sort.sort) {
const dir = options.sort.sort;
list = list.slice().sort((a, b) => (a.sort - b.sort) * dir);
}
return list[0];
};
return {
calls,
findOne(selector, options) {
calls.findOne += 1;
return pick(selector, options);
},
async findOneAsync(selector, options) {
calls.findOneAsync += 1;
return pick(selector, options);
},
find() {
return { fetch: () => docs, fetchAsync: async () => docs };
},
};
}
const TARGET_LIST_CARDS = [
{ _id: 'a', listId: 'L3', swimlaneId: 'S1', archived: false, sort: 1 },
{ _id: 'b', listId: 'L3', swimlaneId: 'S1', archived: false, sort: 7 },
{ _id: 'c', listId: 'L3', swimlaneId: 'S1', archived: false, sort: 4 },
];
// The exact caller pattern of client/components/sidebar/sidebarFilters.js
// getMaxSortForList + buildInsertionSortIndexes (bottom-of-list branch).
function maxSortThenIndexes(ReactiveCache, cardsCount) {
const card = ReactiveCache.getCard(
{ listId: 'L3', swimlaneId: 'S1', archived: false },
{ sort: { sort: -1 } },
true,
);
const maxSort = card ? card.sort : null;
const start = maxSort === null ? 0 : maxSort + 1;
const indexes = [];
for (let i = 0; i < cardsCount; i += 1) indexes.push(start + i);
return indexes;
}
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
(async () => {
// --- POSITIVE: client noCache read is synchronous and correct -------------
test('POSITIVE: client noCache getCard returns the document synchronously (not a Promise)', () => {
const cards = makeCardsStub(TARGET_LIST_CARDS);
const { ReactiveCache } = loadReactiveCache({ isServer: false, cardsStub: cards });
const card = ReactiveCache.getCard(
{ listId: 'L3', swimlaneId: 'S1', archived: false },
{ sort: { sort: -1 } },
true,
);
assert.ok(!(card instanceof Promise), 'must not be a Promise on the client');
assert.strictEqual(card._id, 'b', 'sort:-1 returns the max-sort card');
assert.strictEqual(card.sort, 7);
assert.strictEqual(cards.calls.findOne, 1, 'sync minimongo findOne used');
assert.strictEqual(cards.calls.findOneAsync, 0);
});
test('POSITIVE: #2494 move-selection insertion indexes are finite and strictly increasing', () => {
const cards = makeCardsStub(TARGET_LIST_CARDS);
const { ReactiveCache } = loadReactiveCache({ isServer: false, cardsStub: cards });
const indexes = maxSortThenIndexes(ReactiveCache, 3);
assert.deepStrictEqual(indexes, [8, 9, 10], 'cards go after the current max sort');
indexes.forEach(i => assert.ok(Number.isFinite(i), `sort index ${i} must be finite`));
// Distinct sorts — no two moved cards may occupy the same position.
assert.strictEqual(new Set(indexes).size, indexes.length);
});
test('POSITIVE: awaited callers (models getSort/getNextCardNumber pattern) still work', async () => {
const cards = makeCardsStub(TARGET_LIST_CARDS);
const { ReactiveCache } = loadReactiveCache({ isServer: false, cardsStub: cards });
// models/cards.js getSort awaits the noCache call; awaiting a plain doc is fine.
const card = await ReactiveCache.getCard(
{ listId: 'L3', swimlaneId: 'S1', archived: false },
{ sort: { sort: 1 } },
true,
);
assert.strictEqual(card._id, 'a', 'sort:1 returns the min-sort card');
assert.strictEqual(card.sort, 1);
});
test('POSITIVE: empty target list yields sort 0,1,2,... (not NaN)', () => {
const cards = makeCardsStub([]);
const { ReactiveCache } = loadReactiveCache({ isServer: false, cardsStub: cards });
const indexes = maxSortThenIndexes(ReactiveCache, 2);
assert.deepStrictEqual(indexes, [0, 1]);
});
// --- NEGATIVE: guards and unchanged paths ----------------------------------
test('NEGATIVE: null / undefined / empty selector returns null (no findOne call)', () => {
const cards = makeCardsStub(TARGET_LIST_CARDS);
const { ReactiveCache } = loadReactiveCache({ isServer: false, cardsStub: cards });
assert.strictEqual(ReactiveCache.getCard(null, {}, true), null);
assert.strictEqual(ReactiveCache.getCard(undefined, {}, true), null);
assert.strictEqual(ReactiveCache.getCard('', {}, true), null);
assert.strictEqual(cards.calls.findOne, 0);
});
test('NEGATIVE: the old routing is the bug — an async read makes the caller compute NaN', async () => {
// Demonstrates why the client noCache path must be synchronous: feed the
// caller pattern a Promise (what the pre-fix routing returned) and the
// insertion indexes degrade to NaN — the #2494 data corruption.
const promiseCard = Promise.resolve(TARGET_LIST_CARDS[1]);
const maxSort = promiseCard ? promiseCard.sort : null; // undefined, not 7
const start = maxSort === null ? 0 : maxSort + 1;
assert.ok(Number.isNaN(start), 'Promise-based read yields NaN sort indexes');
await promiseCard;
});
test('NEGATIVE: default (cached) client path is untouched and goes through DataCache', () => {
const cards = makeCardsStub(TARGET_LIST_CARDS);
const dataCacheLog = [];
const { ReactiveCache } = loadReactiveCache({
isServer: false,
cardsStub: cards,
dataCacheLog,
});
const card = ReactiveCache.getCard('a');
assert.strictEqual(card._id, 'a');
assert.strictEqual(dataCacheLog.length, 1, 'cached path consulted the DataCache');
});
test('NEGATIVE: server path is unchanged (async, findOneAsync)', async () => {
const cards = makeCardsStub(TARGET_LIST_CARDS);
const { ReactiveCache } = loadReactiveCache({ isServer: true, cardsStub: cards });
const ret = ReactiveCache.getCard(
{ listId: 'L3', swimlaneId: 'S1', archived: false },
{ sort: { sort: -1 } },
true,
);
assert.ok(ret instanceof Promise, 'server keeps the async contract');
const card = await ret;
assert.strictEqual(card._id, 'b');
assert.strictEqual(cards.calls.findOneAsync, 1);
});
console.log(`\n${passed} passing`);
})().catch(err => {
console.error(err);
process.exit(1);
});
+245
View File
@@ -0,0 +1,245 @@
'use strict';
// Plain-Node unit test (no Meteor) for issue #1853:
// "Bug: Subtask board id missing: cannot read property _id of undefined".
// Run: ELECTRON_RUN_AS_NODE=1 <node> tests/subtaskViewNavigation.test.cjs
//
// Clicking "View it" on a subtask in card details ran
// const board = subtask.board();
// FlowRouter.go('card', { boardId: board._id, ... })
// and threw `Cannot read property '_id' of undefined` whenever the subtask's
// board — often a DIFFERENT deposit/target board — was not in minimongo.
// The #4762-era guard removed the TypeError but turned the click into a
// silent no-op: the user still could not view the subtask.
//
// The fix (client/components/cards/subtaskViewHelpers.js): subtaskNavTarget()
// falls back to the subtask's own boardId (with the model's default 'board'
// slug — the card route action in config/router.js only consumes
// :boardId/:cardId) so navigation proceeds and the route's subscriptions load
// the board. Only a truly broken subtask (no card id AND/OR no board
// reference) yields undefined, and the click handler warns instead of
// throwing.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const repoRoot = path.resolve(__dirname, '..');
const helpersPath = path.join(
repoRoot,
'client',
'components',
'cards',
'subtaskViewHelpers.js',
);
const handlersPath = path.join(
repoRoot,
'client',
'components',
'cards',
'subtasks.js',
);
const helpersSrc = fs.readFileSync(helpersPath, 'utf8');
const handlersSrc = fs.readFileSync(handlersPath, 'utf8');
// The helper module is dependency-free ES module code; strip the `export`
// keywords and evaluate it in a bare context.
const context = { console };
vm.createContext(context);
vm.runInContext(helpersSrc.replace(/^export /gm, ''), context);
const { canNavigateToSubtaskBoard, subtaskNavTarget, subtaskBoardNavTarget } =
context;
assert.strictEqual(typeof canNavigateToSubtaskBoard, 'function');
assert.strictEqual(typeof subtaskNavTarget, 'function');
assert.strictEqual(typeof subtaskBoardNavTarget, 'function');
let passed = 0;
const tests = [];
function test(name, fn) {
tests.push([name, fn]);
}
// A subtask stub mimicking the Card model surface the helpers use. `board()`
// returns the board doc when it is "in minimongo", undefined otherwise.
function makeSubtask({ boardLoaded = true, withRealId = true } = {}) {
const boardDoc = { _id: 'depositBoardId', slug: 'deposit-board' };
return {
_id: 'subtaskCardId',
boardId: 'depositBoardId',
swimlaneId: 'subSwimlaneId',
listId: 'subListId',
board() {
return boardLoaded ? boardDoc : undefined;
},
getRealId() {
return withRealId ? 'subtaskRealId' : undefined;
},
};
}
// Mimics the js-view-subtask click handler contract: navigate when a target
// exists, warn (never throw) otherwise. Kept in lockstep with the handler by
// the source-level pins further below.
function simulateViewClick(subtask) {
const gone = [];
const warned = [];
const target = subtaskNavTarget(subtask);
if (target) {
gone.push(['card', target]);
} else {
warned.push(subtask && subtask._id);
}
return { gone, warned };
}
// --- POSITIVE: board present in minimongo -----------------------------------
test('board loaded: navigates to the subtask card on its own board', () => {
const { gone, warned } = simulateViewClick(makeSubtask());
assert.strictEqual(warned.length, 0);
assert.strictEqual(gone.length, 1);
const [route, params] = gone[0];
assert.strictEqual(route, 'card');
assert.strictEqual(params.boardId, 'depositBoardId');
assert.strictEqual(params.slug, 'deposit-board');
assert.strictEqual(params.cardId, 'subtaskRealId');
assert.strictEqual(params.swimlaneId, 'subSwimlaneId');
assert.strictEqual(params.listId, 'subListId');
});
test('board loaded, no getRealId result: falls back to the plain _id', () => {
const target = subtaskNavTarget(makeSubtask({ withRealId: false }));
assert.strictEqual(target.cardId, 'subtaskCardId');
});
test('canNavigateToSubtaskBoard accepts only a board doc with an _id', () => {
assert.strictEqual(canNavigateToSubtaskBoard({ _id: 'b1', slug: 's' }), true);
assert.strictEqual(canNavigateToSubtaskBoard(undefined), false);
assert.strictEqual(canNavigateToSubtaskBoard(null), false);
assert.strictEqual(canNavigateToSubtaskBoard({ slug: 'only' }), false);
});
// --- NEGATIVE→FALLBACK: board doc missing from minimongo (#1853) -------------
test('#1853: board NOT loaded does not throw and still navigates via boardId', () => {
let result;
assert.doesNotThrow(() => {
result = simulateViewClick(makeSubtask({ boardLoaded: false }));
});
assert.strictEqual(result.warned.length, 0, 'must not degrade to a warning');
assert.strictEqual(result.gone.length, 1, 'must navigate, not no-op');
const [route, params] = result.gone[0];
assert.strictEqual(route, 'card');
assert.strictEqual(
params.boardId,
'depositBoardId',
'falls back to the subtask own boardId',
);
assert.strictEqual(
params.slug,
'board',
"uses the model's default slug; the card route only consumes ids",
);
assert.strictEqual(params.cardId, 'subtaskRealId');
});
test('#1853: subtask.board absent entirely (plain doc) still navigates', () => {
const bare = { _id: 'c1', boardId: 'b1' };
const target = subtaskNavTarget(bare);
assert.ok(target);
assert.strictEqual(target.boardId, 'b1');
assert.strictEqual(target.cardId, 'c1');
});
test('#1853: js-go-to-subtask-board fallback mirrors the view fallback', () => {
// Loaded board: real id + slug. (Field-level asserts: the helper runs in a
// vm realm, so deepStrictEqual would trip on cross-realm prototypes.)
const loaded = subtaskBoardNavTarget(makeSubtask());
assert.strictEqual(loaded.id, 'depositBoardId');
assert.strictEqual(loaded.slug, 'deposit-board');
// Missing board doc: boardId + default slug, matching the 'board' route
// params ({ id, slug }).
const fallback = subtaskBoardNavTarget(makeSubtask({ boardLoaded: false }));
assert.strictEqual(fallback.id, 'depositBoardId');
assert.strictEqual(fallback.slug, 'board');
});
// --- NEGATIVE: malformed subtask must warn, never crash ----------------------
test('malformed subtask without any board reference warns instead of navigating', () => {
const broken = makeSubtask({ boardLoaded: false });
broken.boardId = undefined;
let result;
assert.doesNotThrow(() => {
result = simulateViewClick(broken);
});
assert.strictEqual(result.gone.length, 0);
assert.deepStrictEqual(result.warned, ['subtaskCardId']);
});
test('subtask without a card id yields no target (never routes to /undefined)', () => {
assert.strictEqual(subtaskNavTarget({ boardId: 'b1' }), undefined);
});
test('missing/null subtask yields no target and no throw', () => {
assert.strictEqual(subtaskNavTarget(undefined), undefined);
assert.strictEqual(subtaskNavTarget(null), undefined);
assert.strictEqual(subtaskBoardNavTarget(undefined), undefined);
const { gone, warned } = simulateViewClick(undefined);
assert.strictEqual(gone.length, 0);
assert.strictEqual(warned.length, 1);
});
// --- Source-level pins: the handler must keep using the guarded helper -------
test('js-view-subtask handler routes through subtaskNavTarget and guards it', () => {
const m = handlersSrc.match(
/'click \.js-view-subtask'\(event\) \{([\s\S]*?)\n \},/,
);
assert.ok(m, 'js-view-subtask handler found in subtasks.js');
const body = m[1];
assert.ok(
/subtaskNavTarget\(/.test(body),
'handler derives the target via subtaskNavTarget',
);
assert.ok(/if \(target\)/.test(body), 'handler guards a missing target');
assert.ok(
/console\.warn/.test(body),
'handler warns (not throws) on a broken subtask',
);
// The original #1853 crash pattern must not come back.
assert.ok(
!/board\(\)\s*[\s\S]{0,80}board\._id/.test(body),
'handler must not dereference board._id from subtask.board() directly',
);
});
test('js-go-to-subtask-board handler routes through subtaskBoardNavTarget', () => {
const m = handlersSrc.match(
/'click \.js-go-to-subtask-board'\(\) \{([\s\S]*?)\n \},/,
);
assert.ok(m, 'js-go-to-subtask-board handler found in subtasks.js');
const body = m[1];
assert.ok(/subtaskBoardNavTarget\(/.test(body));
assert.ok(/if \(target\)/.test(body));
assert.ok(
!/board\._id/.test(body),
'handler must not dereference board._id directly',
);
});
// --- Runner ------------------------------------------------------------------
(async () => {
for (const [name, fn] of tests) {
await fn();
passed += 1;
console.log(' ok -', name);
}
console.log(`\n${passed}/${tests.length} subtaskViewNavigation tests passed`);
})().catch(err => {
console.error(err);
process.exit(1);
});