mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 15:45:01 -05:00
- #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 in7673c77c5so 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:
1 parent
42b4e51f85
commit
7c0ae509ac
25 files changed
+2122
-39
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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.`);
|
||||
@@ -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`);
|
||||
@@ -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`);
|
||||
@@ -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`);
|
||||
@@ -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`);
|
||||
@@ -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`);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user