Compare commits

...
7 Commits
Author SHA1 Message Date
github-actions[bot] b26494766f Bump versions for v9.79 2026-07-06 11:46:51 +00:00
Lauri Ojansivu 68ab63c7fe Updated ChangeLog. 2026-07-06 14:43:27 +03:00
Lauri Ojansivu e3e71b254a Updated ChangeLog.
Link #6439/#6440/#6441 and the i18n test fix to their commits; wire the new unit tests into test:unit:node.

Thanks to jullbo and xet7.
2026-07-06 14:35:35 +03:00
Lauri Ojansivu fc4eadedc9 Fix flaky server-side Mocha i18n test (zh-CN "is not a spy")
The .loadLanguage suite intermittently reported "1 failing" (the failure bouncing between the zh-CN and fr cases). Two causes: stubbing the shared TAPi18n.i18n let state leak between tests, and asserting via sinon-chai routed the matcher through chai-as-promised so it could evaluate after afterEach's sinon.restore(). Give each test a fresh i18next instance via TAPi18n.init() (mirrors the reliable .setLanguage suite) and assert with synchronous sinon.assert.* instead of sinon-chai.

Thanks to xet7.
2026-07-06 14:35:34 +03:00
Lauri Ojansivu 9dca403782 Fix #6441: label filter now applies board-wide across all swimlanes
Thanks to jullbo and xet7.
2026-07-06 13:49:44 +03:00
Lauri Ojansivu dd90995db7 Fix #6440: '+' add-item button on minicard checklist now adds the item
Thanks to jullbo and xet7.
2026-07-06 13:49:44 +03:00
Lauri Ojansivu f8e31745d8 Fix #6439: Custom (drag order) sort on All Boards page now reorders via drag-and-drop
Thanks to jullbo and xet7.
2026-07-06 13:49:44 +03:00
23 changed files with 814 additions and 83 deletions

No files matched your search

+53
View File
@@ -39,6 +39,59 @@ them up next.
[#3823](https://github.com/wekan/wekan/issues/3823), [#3138](https://github.com/wekan/wekan/issues/3138),
[#2204](https://github.com/wekan/wekan/issues/2204) (restrict permanent delete to the Admin role).
# v9.79 2026-07-06 WeKan ® release
This release fixes the following bugs:
- **[Fix #6439: Custom (drag order) sort on All Boards page now reorders via drag-and-drop](https://github.com/wekan/wekan/commit/f8e31745d8104c58dc7bbcb4c1e4c615141a8e82)**:
On the All Boards page the jQuery-ui sortable that reordered boards in the "Custom (drag order)"
mode was removed when the page switched to HTML5 drag-and-drop for workspaces, and nothing
replaced it, so dragging a board showed a not-allowed cursor and never updated
`profile.boardSortIndex`. Added HTML5 `dragover`/`drop` reorder handlers on the board tiles in
`client/components/boards/boardsList.js` (active only in the custom sort mode), a
`setBoardSortIndexes` helper in `models/users.js` to persist the new order in one write, a
drop-hint style in `client/components/boards/boardsList.css`, and extracted the reorder
decision/index math into the pure, unit-tested `models/lib/boardSortReorder.js`. Covered by
`tests/boardSortReorder.test.cjs`.
Thanks to jullbo and xet7.
- **[Fix #6440: '+' add-item button on minicard checklist does nothing](https://github.com/wekan/wekan/commit/dd90995db74593228d40bc504405620ec09c175d)**:
On the minicard the checklist add-item `<form>` is rendered inside the `a.minicard-wrapper`
anchor, so the native form `submit` event never reached the Blaze event map — the #5565
minicard-checklist work wired only a `submit .js-add-checklist-item` handler (which never fires
there) and gave the Save button no click handler, so clicking "+" Save did nothing. Added an
explicit `click .js-submit-add-checklist-item-form` handler in
`client/components/cards/minicard.js` (mirroring the working edit-item button) that inserts the
item, with the blank-input guard and title parsing extracted into the pure, Meteor-free
`models/lib/checklistItemTitles.js`. Card-detail checklists are unaffected. Covered by
`tests/checklistItemTitles.test.cjs`.
Thanks to jullbo and xet7.
- **[Fix #6441: label filter now applies board-wide across all swimlanes](https://github.com/wekan/wekan/commit/9dca403782d476eb3c593fe614848ef02bb7f5a3)**:
A label filter hid non-matching cards in one swimlane (e.g. "Focus") but left another swimlane
(e.g. "Background") unfiltered. Each list scopes its cards to the current swimlane while also
showing shared/orphaned cards that have no swimlane; that fallback was written as a bare
top-level `$or`, which competes with the board Filter's own top-level `$or` (label/member
criteria) when the two selectors are combined — dropping the label criterion in every swimlane
except the default one. The swimlane-membership fallback is now a single
`swimlaneId: { $in: [id, null, ''] }` clause (the same form already used in `sidebarFilters.js`,
`cardDetails.js` and `dialogWithBoardSwimlaneList.js`), extracted to the pure, unit-tested
`models/lib/swimlaneFilter.js` and used by `client/components/lists/listBody.js` and
`models/lists.js`, so no second `$or` exists and the filter is always `$and`-combined and applied
board-wide. Covered by `tests/swimlaneFilter.test.cjs`.
Thanks to jullbo and xet7.
- **[Fix flaky server-side Mocha test (i18n zh-CN "is not a spy")](https://github.com/wekan/wekan/commit/fc4eadedc9109602ee029b60c44eeda869aa98c3)**:
The server-side Mocha suite intermittently reported "1 failing" on the #5756
`imports/i18n/i18n.test.js` region-tag test with `TypeError: [Function] is not a spy`. The
assertion re-read `TAPi18n.i18n.addResourceBundle`, and the sinon-chai matcher (routed through
chai-as-promised) could evaluate after this suite's `afterEach` had already run
`sinon.restore()` — at which point the property is the original function, not the stub. The
`.loadLanguage` tests now assert on the captured stub reference (restore unwraps the property but
leaves the spy intact), which is deterministic. Reproduced and verified with a standalone
sinon/chai script.
Thanks to xet7.
# v9.78 2026-07-06 WeKan ® release
This release fixes the following bugs:
+1 -1
View File
@@ -7,7 +7,7 @@ LABEL org.opencontainers.image.source="https://github.com/wekan/wekan"
# TARGETARCH and TARGETVARIANT are automatically provided by Docker Buildx
ARG TARGETARCH
ARG TARGETVARIANT
ARG VERSION=9.78
ARG VERSION=9.79
ARG DEBIAN_FRONTEND=noninteractive
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v9.78.0"
appVersion: "v9.79.0"
files:
userUploads:
- README.md
+7
View File
@@ -1078,6 +1078,13 @@ body.grey-icons-enabled .checkmark-no-grey {
margin: 8px; /* Space between board items */
}
/* #6439: highlight the board a dragged board will be dropped before, when
reordering in the Custom (drag order) sort mode. */
.js-board.board-reorder-over {
outline: 2px dashed #3498db;
outline-offset: 2px;
}
/* Reset background for add-board button */
.js-add-board {
background-color: transparent !important;
+57
View File
@@ -6,6 +6,10 @@ import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
import { BoardMultiSelection } from '/client/lib/boardMultiSelection';
import { EscapeActions } from '/client/lib/escapeActions';
import { Utils } from '/client/lib/utils';
import {
isDragReorderEnabled,
computeReorderedSortIndex,
} from '/models/lib/boardSortReorder';
// SubsManager removed for Meteor 3 migration
@@ -59,6 +63,15 @@ const DEFAULT_WORKSPACE_ICON = '📁';
// Matches the Admin Panel > People page size.
const BOARDS_PER_PAGE = 25;
// #6439: the effective All Boards sort mode for the current user, defaulting to
// 'custom' (manual drag order) when unknown. Used to gate board drag-reordering.
function currentAllBoardsSortBy() {
const cu = ReactiveCache.getCurrentUser();
return cu && typeof cu.getAllBoardsSortBy === 'function'
? cu.getAllBoardsSortBy()
: 'custom';
}
function getCurrentWorkspacesTree() {
const currentUser = ReactiveCache.getCurrentUser();
const tree =
@@ -973,6 +986,50 @@ Template.boardList.events({
document.querySelectorAll('.workspace-node.board-drag-hint, .js-select-menu.board-drag-hint').forEach((el) => {
el.classList.remove('board-drag-hint');
});
document.querySelectorAll('.js-board.board-reorder-over').forEach((el) => {
el.classList.remove('board-reorder-over');
});
},
// #6439: reorder boards on the All Boards page by dropping one board onto
// another. Only active in the "custom" (manual drag order) sort mode — in the
// sorted modes the order comes from the title, so a board is not a valid drop
// target (which is why, before this, the browser showed the not-allowed cursor
// and nothing persisted). The reorder decision/index math lives in the pure,
// unit-tested models/lib/boardSortReorder.js.
'dragover .js-board'(evt, tpl) {
if (!isDragReorderEnabled(currentAllBoardsSortBy())) return;
// A multi-board drag is a share/assign gesture, not a reorder.
const dt = evt.originalEvent.dataTransfer;
if (dt && dt.getData('application/x-board-multi') === 'true') return;
evt.preventDefault();
evt.stopPropagation();
if (dt) dt.dropEffect = 'move';
evt.currentTarget.classList.add('board-reorder-over');
},
'dragleave .js-board'(evt) {
evt.currentTarget.classList.remove('board-reorder-over');
},
'drop .js-board'(evt, tpl) {
if (!isDragReorderEnabled(currentAllBoardsSortBy())) return;
const dt = evt.originalEvent.dataTransfer;
if (dt && dt.getData('application/x-board-multi') === 'true') return;
const draggedId = dt ? dt.getData('text/plain') : '';
if (!draggedId) return;
evt.preventDefault();
evt.stopPropagation();
evt.currentTarget.classList.remove('board-reorder-over');
const targetId = this._id;
// Current display order of the board grid: the li.js-board carries the board
// id as its first class (see the .js-board template and #filterBtn handler).
const orderedIds = Array.from(tpl.findAll('.js-board'))
.map((el) => el.classList[0])
.filter(Boolean);
const mapping = computeReorderedSortIndex(orderedIds, draggedId, targetId);
if (!mapping) return;
const currentUser = ReactiveCache.getCurrentUser();
if (currentUser && typeof currentUser.setBoardSortIndexes === 'function') {
currentUser.setBoardSortIndexes(mapping);
}
},
'click .js-clone-board'(evt) {
if (confirm(TAPi18n.__('duplicate-board-confirm'))) {
+42 -15
View File
@@ -6,6 +6,10 @@ import uploadProgressManager from '../../lib/uploadProgressManager';
import { Utils } from '/client/lib/utils';
import ChecklistItems from '/models/checklistItems';
import Cards from '/models/cards';
import {
parseChecklistItemTitles,
buildChecklistItemPayload,
} from '/models/lib/checklistItemTitles';
import { normalizeDependencies } from '/models/metadata/dependencies';
function getMinicardFlag(board, onMinicardField, legacyField, defaultValue) {
@@ -376,6 +380,28 @@ Template.minicardChecklist.helpers({
},
});
// #6440: create the checklist item(s) for the minicard add-item form. Shared by
// the Save-button click handler and the (rarely reached) form-submit handler.
// The Save button needs an explicit click handler because the minicard — and so
// the add-item <form> — is rendered inside the `a.minicard-wrapper` anchor, where
// the native form `submit` event never reaches the Blaze event map; without this
// the "+" add-item on the minicard did nothing. The blank-input guard and the
// title parsing live in the pure, unit-tested models/lib/checklistItemTitles.js.
function addMinicardChecklistItems(textarea, checklist) {
if (!textarea || !checklist) return;
const titles = parseChecklistItemTitles(textarea.value);
if (titles.length === 0) return;
const items = checklist.items();
const lastItem = items[items.length - 1] || null;
let sortIndex = Utils.calculateIndexData(lastItem, null).base;
titles.forEach(title => {
ChecklistItems.insert(buildChecklistItemPayload(title, checklist, sortIndex));
sortIndex += 1;
});
textarea.value = '';
textarea.focus();
}
Template.minicardChecklist.events({
'click .js-convert-checklist-item-to-card'(event) {
event.stopPropagation();
@@ -417,25 +443,26 @@ Template.minicardChecklist.events({
}
$form.find('.js-close-inlined-form').trigger('click');
},
// #6440: explicit click handler for the minicard "+" add-item Save button.
// The native form `submit` event does not fire on the minicard (the form is
// inside the `a.minicard-wrapper` anchor), so — unlike card-detail — we cannot
// rely on `submit .js-add-checklist-item`. This mirrors the already-working
// `click .js-submit-edit-checklist-item-form` above.
'click .js-submit-add-checklist-item-form'(event) {
event.preventDefault();
event.stopPropagation();
const $form = $(event.currentTarget).closest('form');
const textarea = $form.find('textarea.js-add-checklist-item')[0];
const formData = Blaze.getData($form[0]) || Template.currentData() || {};
addMinicardChecklistItems(textarea, formData.checklist);
},
// Kept for completeness (e.g. card-detail-style contexts); on the minicard this
// rarely fires, so the click handler above is the real path. See #6440.
'submit .js-add-checklist-item'(event, tpl) {
event.preventDefault();
const textarea = tpl.find('textarea.js-add-checklist-item');
if (!textarea) return;
const title = textarea.value.trim();
const data = Template.currentData() || {};
const checklist = data.checklist;
if (!checklist || !title) return;
const items = checklist.items();
const lastItem = items[items.length - 1] || null;
const sortIndex = Utils.calculateIndexData(lastItem, null).base;
ChecklistItems.insert({
title,
checklistId: checklist._id,
cardId: checklist.cardId,
sort: sortIndex,
});
textarea.value = '';
textarea.focus();
addMinicardChecklistItems(textarea, data.checklist);
},
'click .js-delete-checklist-item': Popup.afterConfirm('checklistItemDelete', function () {
Popup.back();
+7 -13
View File
@@ -10,6 +10,7 @@ import { Filter } from '/client/lib/filter';
import { MultiSelection } from '/client/lib/multiSelection';
import { Utils } from '/client/lib/utils';
import { isLinkableCardTarget } from '/models/lib/linkedCardTarget';
import { listCardsSelector } from '/models/lib/swimlaneFilter';
import autosize from 'autosize';
// SubsManager removed for Meteor 3 migration
@@ -339,19 +340,12 @@ Template.listBody.helpers({
if (!sortBy) {
sortBy = defaultSort;
}
const selector = {
listId: Template.currentData()._id,
archived: false,
};
if (swimlaneId) {
// Fallback: also show cards with no swimlaneId (null/empty) so that
// pre-migration / orphaned cards are always visible without a DB migration.
selector.$or = [
{ swimlaneId },
{ swimlaneId: null }, // null covers null AND missing field
{ swimlaneId: '' }, // empty string from shared-lists era
];
}
// #6441: build the swimlane-membership fallback as a single `swimlaneId:
// { $in: [...] }` clause (via the shared, unit-tested helper) instead of a
// bare top-level `$or`, so it never competes with the board Filter's own
// top-level `$or` (labels/members/exceptions) and the label filter applies
// board-wide across every swimlane.
const selector = listCardsSelector(Template.currentData()._id, swimlaneId);
const ret = ReactiveCache.getCards(Filter.mongoSelector(selector), {
// sort: ['sort'],
sort: sortBy,
+2 -2
View File
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
Right click and download files 1-4:
1. [wekan-9.78-win64.zip](https://github.com/wekan/wekan/releases/download/v9.78/wekan-9.78-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
1. [wekan-9.79-win64.zip](https://github.com/wekan/wekan/releases/download/v9.79/wekan-9.79-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
2. [node.exe](https://nodejs.org/dist/latest-v24.x/win-x64/node.exe)
@@ -22,7 +22,7 @@ Right click and download files 1-4:
6. Double click `mongodb-windows-x86_64-7.0.34-signed.msi` . In installer, uncheck downloading MongoDB compass.
7. Unzip `wekan-9.78-win64.zip` , inside it is directory `bundle`, to it copy other files:
7. Unzip `wekan-9.79-win64.zip` , inside it is directory `bundle`, to it copy other files:
```
bundle (directory)
+25 -11
View File
@@ -73,19 +73,33 @@ describe('TAPi18n', () => {
describe('.loadLanguage', () => {
beforeEach(() => {
sinon.stub(TAPi18n.i18n, 'addResourceBundle');
// Start each test from a FRESH i18next instance, then stub addResourceBundle
// on it. Two things made this suite flakily report "1 failing":
// - stubbing the shared TAPi18n.i18n left state to leak between tests, so
// the stub could end up on a different instance than loadLanguage() used
// (its captured call count then read 0 -> "called exactly once" failure);
// - asserting via sinon-chai (`expect(spy).to.be.calledOnceWith(...)`) runs
// through chai-as-promised, which could evaluate the matcher AFTER the
// outer afterEach ran `sinon.restore()` (-> "is not a spy").
// init() gives a clean instance (mirrors the reliable .setLanguage suite
// below), and sinon.assert.* is synchronous and never routed through
// chai-as-promised, so the checks are deterministic. init() calls
// addResourceBundle once for the default language, so we stub AFTER it.
let addResourceBundle;
beforeEach(async () => {
await TAPi18n.init();
addResourceBundle = sinon.stub(TAPi18n.i18n, 'addResourceBundle');
});
it('actually loads the language data', async () => {
await TAPi18n.loadLanguage('fr');
expect(TAPi18n.i18n.addResourceBundle).to.be.calledOnceWith('fr');
expect(TAPi18n.i18n.addResourceBundle.firstCall.args[2]).to.have.property('accept');
sinon.assert.calledOnceWithMatch(addResourceBundle, 'fr');
expect(addResourceBundle.firstCall.args[2]).to.have.property('accept');
});
it('throws error if language is missing', async () => {
await expect(TAPi18n.loadLanguage('miss')).to.be.rejectedWith('not supported');
expect(TAPi18n.i18n.addResourceBundle).to.not.be.called;
sinon.assert.notCalled(addResourceBundle);
});
// #5756: region/script-tagged and legacy underscore languages must register
@@ -94,29 +108,29 @@ describe('TAPi18n', () => {
it('registers region-tagged languages (zh-CN) under the i18next lookup code', async () => {
await TAPi18n.loadLanguage('zh-CN');
const code = TAPi18n.toI18nCode('zh-CN');
expect(TAPi18n.i18n.addResourceBundle).to.be.calledOnceWith(code);
sinon.assert.calledOnceWithMatch(addResourceBundle, code);
// The actual translation map (not an ES-module namespace) is registered.
expect(TAPi18n.i18n.addResourceBundle.firstCall.args[2]).to.have.property('accept');
expect(addResourceBundle.firstCall.args[2]).to.have.property('accept');
});
it('registers script-tagged languages (zh-Hans) under the i18next lookup code', async () => {
await TAPi18n.loadLanguage('zh-Hans');
expect(TAPi18n.i18n.addResourceBundle).to.be.calledOnceWith(TAPi18n.toI18nCode('zh-Hans'));
expect(TAPi18n.i18n.addResourceBundle.firstCall.args[2]).to.have.property('accept');
sinon.assert.calledOnceWithMatch(addResourceBundle, TAPi18n.toI18nCode('zh-Hans'));
expect(addResourceBundle.firstCall.args[2]).to.have.property('accept');
});
it('converts legacy underscore tags (af_ZA) to a hyphenated i18next code', async () => {
await TAPi18n.loadLanguage('af_ZA');
const code = TAPi18n.toI18nCode('af_ZA');
expect(code).to.not.contain('_');
expect(TAPi18n.i18n.addResourceBundle).to.be.calledOnceWith(code);
sinon.assert.calledOnceWithMatch(addResourceBundle, code);
});
it('does not unwrap the "default" translation key as an ES-module export', async () => {
// The data files contain a key literally named "default" ("Default"),
// which must survive loading and not be mistaken for an ESM namespace.
await TAPi18n.loadLanguage('de');
const data = TAPi18n.i18n.addResourceBundle.firstCall.args[2];
const data = addResourceBundle.firstCall.args[2];
expect(data).to.have.property('accept');
expect(data.default).to.be.a('string');
});
+68
View File
@@ -0,0 +1,68 @@
'use strict';
// Pure helpers for reordering boards on the All Boards page by drag-and-drop.
// Extracted so the decision logic is unit-testable in plain Node without Meteor
// (mirrors models/lib/cardSearch.js).
//
// #6439 (Custom (drag order) sort — drag shows 'not-allowed' cursor, drop does
// not reorder): the old jQuery-ui sortable that reordered boards in the "custom"
// mode was removed when the All Boards page moved to HTML5 drag-and-drop for
// workspace/space management, and no HTML5 reorder handler replaced it. Boards
// carry draggable="true", but with no drop target that accepts them the browser
// shows the forbidden cursor and nothing persists to profile.boardSortIndex.
//
// The wiring lives in client/components/boards/boardsList.js; the pieces below
// are the two decisions worth testing in isolation:
// 1. whether drag-reordering should be active for the current sort mode, and
// 2. the new per-user boardSortIndex mapping produced by a drop.
// Drag-reordering only makes sense in the manual "custom" order. In the sorted
// modes ('title-asc' / 'title-desc') the order is derived from the title, so a
// manual drop must be ignored (and the board grid must not offer a drop target).
function isDragReorderEnabled(sortBy) {
return sortBy === 'custom';
}
// Given the currently displayed board ids (in display order), move `draggedId`
// so it sits immediately before `targetId` (the board it was dropped on). Returns
// the new ordered array, or null when the drop is a no-op or the ids are unknown.
function computeReorderedIds(orderedIds, draggedId, targetId) {
if (!Array.isArray(orderedIds)) return null;
if (!draggedId || !targetId || draggedId === targetId) return null;
const from = orderedIds.indexOf(draggedId);
const to = orderedIds.indexOf(targetId);
if (from === -1 || to === -1) return null;
const result = orderedIds.slice();
result.splice(from, 1);
// Insert before the target's current position (after the dragged id has been
// removed), so the dragged board takes the target's slot and pushes it down.
const insertAt = result.indexOf(targetId);
result.splice(insertAt, 0, draggedId);
return result;
}
// Turn an ordered list of board ids into a { boardId: index } mapping suitable
// for profile.boardSortIndex. Sequential integers keep the order stable and
// avoid fractional drift on repeated reorders.
function computeSortIndexMapping(orderedIds) {
const mapping = {};
(orderedIds || []).forEach((id, i) => {
mapping[id] = i;
});
return mapping;
}
// Convenience: compute the persisted boardSortIndex mapping for a drop, or null
// when the drop changes nothing (so callers can skip the write).
function computeReorderedSortIndex(orderedIds, draggedId, targetId) {
const reordered = computeReorderedIds(orderedIds, draggedId, targetId);
if (!reordered) return null;
return computeSortIndexMapping(reordered);
}
module.exports = {
isDragReorderEnabled,
computeReorderedIds,
computeSortIndexMapping,
computeReorderedSortIndex,
};
+47
View File
@@ -0,0 +1,47 @@
'use strict';
// Pure helpers for adding checklist item(s) from the text a user typed into the
// "add checklist item" form. Extracted from the client event handlers so the
// parsing and the blank-input guard are unit-testable without Meteor / the DOM.
//
// #6440 ("'+' add-item button on minicard checklist does nothing"): on the
// minicard the add-item <form> is rendered inside the `a.minicard-wrapper`
// anchor, so the native form `submit` event never reaches the Blaze event map —
// clicking Save did nothing. The fix wires an explicit click handler on the Save
// button (mirroring the already-working edit button); this module holds the pure
// decision of WHICH item title(s) that click should create, so the same logic is
// shared and can be regression-tested in plain Node.
// Split the typed text into the list of checklist-item titles to create.
// - Always trims each candidate and drops blank ones, so whitespace-only input
// creates nothing (the negative case that guards the bug).
// - When `newlineBecomesNewChecklistItem` is true, each non-blank line becomes a
// separate item; otherwise the whole trimmed text is a single item.
// - When `reverse` is true (top-insert without originOrder), the resulting order
// is reversed so the visible order matches the typed order.
function parseChecklistItemTitles(text, options = {}) {
const { newlineBecomesNewChecklistItem = false, reverse = false } = options;
if (typeof text !== 'string') return [];
if (!newlineBecomesNewChecklistItem) {
const title = text.trim();
return title ? [title] : [];
}
const titles = text
.split('\n')
.map(line => line.trim())
.filter(line => line !== '');
return reverse ? titles.reverse() : titles;
}
// Build the ChecklistItems.insert payload for one title. Kept pure so tests can
// assert it targets the correct checklist/card and carries the sort index.
function buildChecklistItemPayload(title, checklist, sort) {
return {
title,
checklistId: checklist._id,
cardId: checklist.cardId,
sort,
};
}
module.exports = { parseChecklistItemTitles, buildChecklistItemPayload };
+75
View File
@@ -0,0 +1,75 @@
'use strict';
// Pure, Meteor-free helpers for building the per-list / per-swimlane card
// selector that the board renders. Extracted so the selector it produces is
// unit-testable in plain Node (mirrors models/lib/cardSearch.js).
//
// #6441 ("Label filter works in one swimlane but not another on the same
// board"): each list shows its cards scoped to the current swimlane, but also
// surfaces "shared" / pre-migration / orphaned cards that have no swimlane
// (swimlaneId null / '' / missing) so they stay visible in every swimlane
// without a database migration. That membership fallback used to be written as
// a bare top-level `$or` on the SAME selector object that is then combined with
// the board Filter — and the board Filter ALSO owns a top-level `$or` (the
// label / member criteria plus the "exceptions" clause). A MongoDB / Minimongo
// document selector can only carry ONE `$or` key per object, so whenever the
// two selectors are folded into one object the swimlane `$or` and the filter
// `$or` compete and one is dropped. Because only NON-default swimlanes carry
// the fallback `$or` (the default/first swimlane's cards match via the
// `swimlaneId: null / ''` branch), the label criterion survived in one swimlane
// but was silently lost in the others — exactly the reported "filters in Focus
// but not in Background" behaviour.
//
// The fix expresses the swimlane-membership fallback as a SINGLE field clause
// using `$in` (`{ swimlaneId: { $in: [swimlaneId, null, ''] } }`), which is
// semantically identical (in Mongo/Minimongo an `$in` list containing `null`
// matches null AND a missing field) but introduces no second `$or`, and always
// combines the board Filter with `$and`, so the label criterion is preserved
// and the filter applies board-wide across every swimlane.
// The swimlane-membership clause for one swimlane. Returns an empty object when
// no swimlane context is given (whole-list scope, unchanged behaviour).
function swimlaneMembershipSelector(swimlaneId) {
if (!swimlaneId) return {};
// `$in` with null matches swimlaneId === null AND a missing swimlaneId field;
// '' covers the empty-string value from the shared-lists era.
return { swimlaneId: { $in: [swimlaneId, null, ''] } };
}
// Base (unfiltered) selector for the active cards of one list, optionally
// scoped to one swimlane (plus orphaned/shared cards).
function listCardsSelector(listId, swimlaneId) {
return Object.assign(
{ listId, archived: false },
swimlaneMembershipSelector(swimlaneId),
);
}
// Combine a base list/swimlane selector with the board Filter's selector so the
// filter criteria are ALWAYS ANDed in and can never be overwritten by the
// swimlane clause. Returns the base selector unchanged when there is no active
// filter (empty/absent filterSelector).
function combineWithFilter(baseSelector, filterSelector) {
if (!filterSelector || Object.keys(filterSelector).length === 0) {
return baseSelector;
}
return { $and: [filterSelector, baseSelector] };
}
// Full selector for the cards of one list, scoped to one swimlane, with the
// board Filter applied. `filterSelector` is the object produced by
// Filter._getMongoSelector() (or Filter.mongoSelector() with no argument); pass
// a falsy/empty value when no filter is active.
function filteredListCardsSelector(listId, swimlaneId, filterSelector) {
return combineWithFilter(
listCardsSelector(listId, swimlaneId),
filterSelector,
);
}
module.exports = {
swimlaneMembershipSelector,
listCardsSelector,
combineWithFilter,
filteredListCardsSelector,
};
+8 -26
View File
@@ -5,6 +5,7 @@ import { LIST_COLORS } from '/models/metadata/colors';
import PositionHistory from './positionHistory';
import Boards from '/models/boards';
import Cards from '/models/cards';
import { listCardsSelector } from '/models/lib/swimlaneFilter';
const { SimpleSchema } = require('/imports/simpleSchema');
const Lists = new Mongo.Collection('lists');
@@ -298,20 +299,11 @@ Lists.helpers({
},
cards(swimlaneId) {
const selector = {
listId: this._id,
archived: false,
};
if (swimlaneId) {
// Fallback: also surface cards with no swimlaneId (null/empty) so that
// pre-migration / orphaned cards are always visible in every swimlane
// without requiring a database migration.
selector.$or = [
{ swimlaneId },
{ swimlaneId: null }, // null covers null AND missing field
{ swimlaneId: '' }, // empty string from shared-lists era
];
}
// #6441: express the swimlane-membership fallback as a single `swimlaneId:
// { $in: [...] }` clause (via the shared, unit-tested helper) instead of a
// bare top-level `$or`, so it never competes with the board Filter's own
// top-level `$or` when the two selectors are combined.
const selector = listCardsSelector(this._id, swimlaneId);
const filterSelector =
typeof Filter !== 'undefined' && typeof Filter.mongoSelector === 'function'
? Filter.mongoSelector(selector)
@@ -321,18 +313,8 @@ Lists.helpers({
},
cardsUnfiltered(swimlaneId) {
const selector = {
listId: this._id,
archived: false,
};
if (swimlaneId) {
// Same fallback as cards(): include orphaned cards with no swimlaneId.
selector.$or = [
{ swimlaneId },
{ swimlaneId: null },
{ swimlaneId: '' },
];
}
// Same swimlane-membership fallback as cards() (#6441), without the Filter.
const selector = listCardsSelector(this._id, swimlaneId);
const ret = ReactiveCache.getCards(selector, { sort: ['sort'] });
return ret;
},
+7
View File
@@ -1744,6 +1744,13 @@ Users.helpers({
return await Users.updateAsync(this._id, { $set: { 'profile.boardSortIndex': mapping } });
},
// #6439: persist a whole boardSortIndex mapping in one write. Used by the
// All Boards page after a drag-reorder recomputes the manual order.
async setBoardSortIndexes(mapping) {
const merged = Object.assign({}, (this.profile && this.profile.boardSortIndex) || {}, mapping || {});
return await Users.updateAsync(this._id, { $set: { 'profile.boardSortIndex': merged } });
},
async toggleAutoWidth(boardId) {
const { autoWidthBoards = {} } = this.profile || {};
autoWidthBoards[boardId] = !autoWidthBoards[boardId];
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wekan",
"version": "v9.78.0",
"version": "v9.79.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wekan",
"version": "v9.78.0",
"version": "v9.79.0",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.1071.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "wekan",
"version": "v9.78.0",
"version": "v9.79.0",
"description": "Open-Source kanban",
"private": true,
"repository": {
@@ -25,7 +25,7 @@
},
"scripts": {
"test": "meteor test --once --driver-package meteortesting:mocha",
"test:unit:node": "node tests/filenameSanitizer.test.cjs && node tests/cardBoardConsistency.test.cjs && node tests/cardUrl.test.cjs && node tests/defaultSwimlane.test.cjs && node tests/cardMoveModifier.test.cjs && node tests/authenticationMethod.test.cjs && node tests/linkedCardTarget.test.cjs && node tests/staleWhileRevalidate.test.cjs && node tests/restArrayParam.test.cjs && node tests/minicardLabelHeight.test.cjs && node tests/dateUtils.normalizeDigits.test.cjs && node tests/reconcileBoardTeamMembers.test.cjs && node tests/rulesBoardSelector.test.cjs && node tests/notificationCleanup.test.cjs && node tests/cardSearch.test.cjs && node tests/linkedCardMembers.test.cjs && node tests/verificationEmail.test.cjs && node tests/unit/resetPasswordEmail.test.js",
"test:unit:node": "node tests/filenameSanitizer.test.cjs && node tests/cardBoardConsistency.test.cjs && node tests/cardUrl.test.cjs && node tests/defaultSwimlane.test.cjs && node tests/cardMoveModifier.test.cjs && node tests/authenticationMethod.test.cjs && node tests/linkedCardTarget.test.cjs && node tests/staleWhileRevalidate.test.cjs && node tests/restArrayParam.test.cjs && node tests/minicardLabelHeight.test.cjs && node tests/dateUtils.normalizeDigits.test.cjs && node tests/reconcileBoardTeamMembers.test.cjs && node tests/rulesBoardSelector.test.cjs && node tests/notificationCleanup.test.cjs && node tests/cardSearch.test.cjs && node tests/linkedCardMembers.test.cjs && node tests/verificationEmail.test.cjs && node tests/boardSortReorder.test.cjs && node tests/checklistItemTitles.test.cjs && node tests/swimlaneFilter.test.cjs && node tests/unit/resetPasswordEmail.test.js",
"test:unit:all": "npm run test:unit:node && node tests/cards.stickers.test.js && node tests/stickers.test.js && node tests/stickers.packs.test.js && node tests/linkCardPopup.test.js && node tests/trelloCreator.import.test.js && node tests/trelloImport.maplink.test.js && node tests/trelloImport.zip.test.js && node tests/wekanCreator.import.test.js && node tests/wekanCreator.inconsistent.test.js && node tests/unit/oauth2-admin-groups.test.js",
"test:watch": "meteor test --driver-package meteortesting:mocha",
"test:e2e": "node tests/e2e/list-regressions.js",
+3 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf8" />
<title>WeKan REST API v9.78</title>
<title>WeKan REST API v9.79</title>
<!-- needed for adaptive design -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
@@ -423,7 +423,7 @@ data-styled.g139[id="sc-iIvZUO"]{content:"kAmnlO,"}/*!sc*/
55.627 l 55.6165,55.627 -231.245496,231.24803 c -127.185,127.1864
-231.5279,231.248 -231.873,231.248 -0.3451,0 -104.688,
-104.0616 -231.873,-231.248 z
" fill="currentColor"></path></g></svg></div></div><div class="sc-jtyQFi ldXCvc api-content"><div class="sc-eCQgVK jleRXN"><div class="sc-iCECmn lhRBUS"><div class="sc-hKVpXn kktglN api-info"><h1 class="sc-fuztkK sc-ctiVdb eRMVDe cBgvIi">Wekan REST API<!-- --> <span>(<!-- -->v9.78<!-- -->)</span></h1><p>Download OpenAPI specification<!-- -->:</p><div class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-summary" html="" class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-description" html="&lt;p&gt;The REST API allows you to control and extend Wekan with ease.&lt;/p&gt;
" fill="currentColor"></path></g></svg></div></div><div class="sc-jtyQFi ldXCvc api-content"><div class="sc-eCQgVK jleRXN"><div class="sc-iCECmn lhRBUS"><div class="sc-hKVpXn kktglN api-info"><h1 class="sc-fuztkK sc-ctiVdb eRMVDe cBgvIi">Wekan REST API<!-- --> <span>(<!-- -->v9.79<!-- -->)</span></h1><p>Download OpenAPI specification<!-- -->:</p><div class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-summary" html="" class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-description" html="&lt;p&gt;The REST API allows you to control and extend Wekan with ease.&lt;/p&gt;
&lt;p&gt;If you are an end-user and not a dev or a tester, &lt;a href=&quot;https://github.com/wekan/wekan/issues/new&quot;&gt;create an issue&lt;/a&gt; to request new APIs.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;All API calls in the documentation are made using &lt;code&gt;curl&lt;/code&gt;. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.&lt;/p&gt;
@@ -1983,7 +1983,7 @@ comment-only / worker members are rejected).</p>
" class="sc-iJSMbW sc-cBEgGa sc-ciCrSJ fiNpIH dNfUH dDDioG"><p>Attachment not found</p>
</div></button></div></div></div><div class="sc-jSppWd sc-gKkgUA fpMlmc bdQQyo"><div class="sc-fXwuWv fYxpnv"><button class="sc-jWMFtl jzaJhV"><span type="delete" class="sc-eEFuoE iINwyG http-verb delete">delete</span><span class="sc-FpjRO dsIRaZ">/api/attachment/delete/{attachmentId}</span><svg class="sc-dYjPD bQKUih" style="margin-right:-25px" version="1.1" viewBox="0 0 24 24" x="0" xmlns="http://www.w3.org/2000/svg" y="0" aria-hidden="true"><polygon points="17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 "></polygon></svg></button><div aria-hidden="true" class="sc-fmtEmb tUxhh"><div class="sc-ljIcGq bAnPPh"><div html="" class="sc-iJSMbW sc-cBEgGa fiNpIH bAoMjv"></div><div tabindex="0" role="button"><div class="sc-jlJOIR PEJyb"><span></span>/api/attachment/delete/{attachmentId}</div></div></div></div></div></div></div></div></div><div class="sc-ekVkVN bjzTxL"></div></div></div>
<script>
const __redoc_state = {"menu":{"activeItemIdx":-1},"spec":{"data":{"openapi":"3.0.0","info":{"title":"Wekan REST API","version":"v9.78","description":"The REST API allows you to control and extend Wekan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `<field>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the gLine truncated
const __redoc_state = {"menu":{"activeItemIdx":-1},"spec":{"data":{"openapi":"3.0.0","info":{"title":"Wekan REST API","version":"v9.79","description":"The REST API allows you to control and extend Wekan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `<field>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the gLine truncated
var container = document.getElementById('redoc');
Redoc.hydrate(__redoc_state, container);
+1 -1
View File
@@ -1,7 +1,7 @@
swagger: '2.0'
info:
title: Wekan REST API
version: v9.78
version: v9.79
description: |
The REST API allows you to control and extend Wekan with ease.
+2 -2
View File
@@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
appTitle = (defaultText = "Wekan"),
# The name of the app as it is displayed to the user.
appVersion = 978,
appVersion = 979,
# Increment this for every release.
appMarketingVersion = (defaultText = "9.78~2026-07-06"),
appMarketingVersion = (defaultText = "9.79~2026-07-06"),
# Human-readable presentation of the app version.
minUpgradableAppVersion = 0,
+4 -4
View File
@@ -1,5 +1,5 @@
name: wekan
version: '9.78'
version: '9.79'
base: core26
build-base: devel
confinement: strict
@@ -194,14 +194,14 @@ parts:
exit 1
;;
esac
WEKAN_ZIP="wekan-9.78-${WEKAN_ARCH}.zip"
WEKAN_ZIP="wekan-9.79-${WEKAN_ARCH}.zip"
NODE_TAR="node-v24.17.0-linux-${NODE_ARCH}.tar.xz"
echo "Cleaning environment first"
rm -rf .build
mkdir .build
cd .build
#wget https://github.com/wekan/wekan/releases/download/v9.78/wekan-9.78-amd64.zip
wget "https://github.com/wekan/wekan/releases/download/v9.78/${WEKAN_ZIP}"
#wget https://github.com/wekan/wekan/releases/download/v9.79/wekan-9.79-amd64.zip
wget "https://github.com/wekan/wekan/releases/download/v9.79/${WEKAN_ZIP}"
unzip "${WEKAN_ZIP}"
rm "${WEKAN_ZIP}"
# Fix: bcrypt 5.0.1 (Meteor accounts-password) ships node-addon-api@3
+112
View File
@@ -0,0 +1,112 @@
'use strict';
// Plain-Node unit test (no Meteor) for the All Boards drag-reorder helpers.
// Run: node tests/boardSortReorder.test.cjs
//
// Regression guard for #6439 ("Custom (drag order) sort — drag shows
// 'not-allowed' cursor, drop doesn't reorder"): the jQuery-ui sortable that
// reordered boards in the manual "custom" mode was removed when the All Boards
// page switched to HTML5 drag-and-drop, and nothing replaced it — so dropping a
// board changed nothing and never wrote profile.boardSortIndex. The pure helpers
// must (a) report that reordering is ENABLED only in 'custom' mode, and (b) turn
// a drop into a new, actually-changed boardSortIndex mapping.
const assert = require('assert');
const {
isDragReorderEnabled,
computeReorderedIds,
computeSortIndexMapping,
computeReorderedSortIndex,
} = require('../models/lib/boardSortReorder');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// --- isDragReorderEnabled ----------------------------------------------------
test('reordering is enabled in the custom (manual drag order) mode', () => {
assert.strictEqual(isDragReorderEnabled('custom'), true);
});
test('reordering is disabled in the sorted (title) modes', () => {
assert.strictEqual(isDragReorderEnabled('title-asc'), false);
assert.strictEqual(isDragReorderEnabled('title-desc'), false);
});
test('reordering is disabled for unknown / missing sort modes', () => {
assert.strictEqual(isDragReorderEnabled(undefined), false);
assert.strictEqual(isDragReorderEnabled(null), false);
assert.strictEqual(isDragReorderEnabled('whatever'), false);
});
// --- computeReorderedIds: POSITIVE ------------------------------------------
test('#6439: dropping a board onto another moves it before that board', () => {
// Drag D onto B -> D takes B's slot, B (and the rest) shift down.
const order = ['A', 'B', 'C', 'D'];
assert.deepStrictEqual(
computeReorderedIds(order, 'D', 'B'),
['A', 'D', 'B', 'C'],
);
});
test('dragging a board upward onto the first board moves it to the front', () => {
assert.deepStrictEqual(
computeReorderedIds(['A', 'B', 'C'], 'C', 'A'),
['C', 'A', 'B'],
);
});
test('the reordered list keeps exactly the same ids (nothing lost/duplicated)', () => {
const order = ['A', 'B', 'C', 'D'];
const out = computeReorderedIds(order, 'A', 'D');
assert.deepStrictEqual(out.slice().sort(), order.slice().sort());
});
// --- computeSortIndexMapping / computeReorderedSortIndex ---------------------
test('computeSortIndexMapping numbers ids sequentially from 0', () => {
assert.deepStrictEqual(computeSortIndexMapping(['X', 'Y', 'Z']), {
X: 0,
Y: 1,
Z: 2,
});
});
test('#6439: a drop produces a persistable boardSortIndex mapping', () => {
const mapping = computeReorderedSortIndex(['A', 'B', 'C', 'D'], 'D', 'B');
assert.deepStrictEqual(mapping, { A: 0, D: 1, B: 2, C: 3 });
});
// --- REGRESSION GUARD (fails against the old, no-reorder behavior) ----------
test('#6439 regression: a custom-mode drop actually CHANGES the order', () => {
// The bug was that dragging a board did nothing: order stayed the same and no
// boardSortIndex was written. The mapping produced by a real drop must differ
// from the identity mapping of the original order.
const order = ['A', 'B', 'C', 'D'];
const before = computeSortIndexMapping(order); // { A:0, B:1, C:2, D:3 }
const after = computeReorderedSortIndex(order, 'D', 'B');
assert.ok(after, 'a genuine reorder must yield a mapping, not null');
assert.notDeepStrictEqual(after, before, 'the order must change on drop');
assert.strictEqual(after.D, 1, 'dragged board D must move to B\'s old slot');
});
// --- NEGATIVE: no-ops and bad input -----------------------------------------
test('dropping a board onto itself is a no-op (null)', () => {
assert.strictEqual(computeReorderedIds(['A', 'B'], 'A', 'A'), null);
assert.strictEqual(computeReorderedSortIndex(['A', 'B'], 'A', 'A'), null);
});
test('unknown dragged/target ids yield no reorder', () => {
assert.strictEqual(computeReorderedIds(['A', 'B'], 'Z', 'A'), null);
assert.strictEqual(computeReorderedIds(['A', 'B'], 'A', 'Z'), null);
});
test('missing ids or non-array input yield no reorder', () => {
assert.strictEqual(computeReorderedIds(['A', 'B'], '', 'A'), null);
assert.strictEqual(computeReorderedIds(['A', 'B'], 'A', ''), null);
assert.strictEqual(computeReorderedIds(null, 'A', 'B'), null);
});
console.log(`\n${passed} tests passed`);
+106
View File
@@ -0,0 +1,106 @@
'use strict';
// Plain-Node unit test (no Meteor) for the minicard/card-detail "add checklist
// item" text parsing and payload builder.
// Run: node tests/checklistItemTitles.test.cjs
//
// Regression guard for #6440 ("'+' add-item button on minicard checklist does
// nothing"): the Save button now has an explicit click handler (the native form
// `submit` never fires inside the `a.minicard-wrapper` anchor). This module holds
// the pure decision of WHICH item title(s) that click creates — it must trim and
// drop blank input (so whitespace-only input creates NOTHING) and target the
// correct checklist/card in the insert payload.
const assert = require('assert');
const {
parseChecklistItemTitles,
buildChecklistItemPayload,
} = require('../models/lib/checklistItemTitles');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// --- parseChecklistItemTitles: POSITIVE -------------------------------------
test('#6440: a typed word becomes a single item', () => {
assert.deepStrictEqual(parseChecklistItemTitles('test'), ['test']);
});
test('#6440: surrounding whitespace is trimmed', () => {
assert.deepStrictEqual(parseChecklistItemTitles(' test '), ['test']);
});
test('by default newlines do NOT split (single item, minicard behavior)', () => {
assert.deepStrictEqual(parseChecklistItemTitles('a\nb'), ['a\nb']);
});
test('newlineBecomesNewChecklistItem splits into one item per non-blank line', () => {
assert.deepStrictEqual(
parseChecklistItemTitles('a\nb\nc', { newlineBecomesNewChecklistItem: true }),
['a', 'b', 'c'],
);
});
test('newline split trims each line and drops blank lines', () => {
assert.deepStrictEqual(
parseChecklistItemTitles(' a \n\n \n b ', { newlineBecomesNewChecklistItem: true }),
['a', 'b'],
);
});
test('reverse flips the resulting order (top-insert without originOrder)', () => {
assert.deepStrictEqual(
parseChecklistItemTitles('a\nb\nc', {
newlineBecomesNewChecklistItem: true,
reverse: true,
}),
['c', 'b', 'a'],
);
});
// --- parseChecklistItemTitles: NEGATIVE (guards the bug) ---------------------
test('#6440: blank input creates NO item', () => {
assert.deepStrictEqual(parseChecklistItemTitles(''), []);
});
test('#6440: whitespace-only input creates NO item', () => {
assert.deepStrictEqual(parseChecklistItemTitles(' \n\t '), []);
});
test('whitespace-only input with newline-split still creates NO item', () => {
assert.deepStrictEqual(
parseChecklistItemTitles(' \n \n', { newlineBecomesNewChecklistItem: true }),
[],
);
});
test('non-string input is tolerated and creates NO item', () => {
assert.deepStrictEqual(parseChecklistItemTitles(null), []);
assert.deepStrictEqual(parseChecklistItemTitles(undefined), []);
assert.deepStrictEqual(parseChecklistItemTitles(42), []);
});
// --- buildChecklistItemPayload: POSITIVE ------------------------------------
test('#6440: payload targets the correct checklist and card', () => {
const checklist = { _id: 'chk1', cardId: 'card9' };
const payload = buildChecklistItemPayload('test', checklist, 3);
assert.deepStrictEqual(payload, {
title: 'test',
checklistId: 'chk1',
cardId: 'card9',
sort: 3,
});
});
// --- buildChecklistItemPayload: NEGATIVE ------------------------------------
test('payload does NOT target a different checklist/card', () => {
const checklist = { _id: 'chk1', cardId: 'card9' };
const payload = buildChecklistItemPayload('test', checklist, 0);
assert.notStrictEqual(payload.checklistId, 'chk2');
assert.notStrictEqual(payload.cardId, 'card8');
});
console.log(`\n${passed} tests passed`);
+182
View File
@@ -0,0 +1,182 @@
'use strict';
// Plain-Node unit test (no Meteor) for the per-list / per-swimlane card
// selector builder. Run: node tests/swimlaneFilter.test.cjs
//
// Regression guard for #6441 ("Label filter works in one swimlane but not
// another on the same board"): the board scopes each list's cards to the
// current swimlane while still surfacing shared/orphaned cards (no swimlane).
// That swimlane-membership fallback used to be a bare top-level `$or`, which
// competes with the board Filter's own top-level `$or` (label/member criteria)
// when the two selectors are combined into one document — dropping the label
// criterion in every swimlane except the default one. The builder must express
// the fallback as a single `swimlaneId: { $in: [...] }` clause and combine the
// Filter with `$and`, so a labelled card matches — and an unlabelled card does
// NOT match — the SAME way regardless of which swimlane it is rendered in.
const assert = require('assert');
const {
swimlaneMembershipSelector,
listCardsSelector,
combineWithFilter,
filteredListCardsSelector,
} = require('../models/lib/swimlaneFilter');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// A tiny Mongo/Minimongo-like matcher supporting exactly the operators the
// builder emits ($and, $or, $in with null/missing, array containment,
// scalar equality), so we can assert the built selector actually selects (or
// rejects) representative card documents rather than eyeballing query shape.
function fieldMatches(cond, value) {
if (cond && typeof cond === 'object' && !Array.isArray(cond)) {
if ('$in' in cond) {
const list = cond.$in;
if (Array.isArray(value)) {
// array field (e.g. labelIds): matches when any element is in the list
return value.some(v => list.includes(v));
}
// scalar field: null in the list matches null AND a missing field
if (value === undefined || value === null) {
return list.includes(null) || list.includes(undefined);
}
return list.includes(value);
}
}
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 label filter:
// an exceptions clause (empty here) OR the label criterion.
function labelFilterSelector(labelId) {
return {
$or: [
{ _id: { $in: [] } }, // exceptions (none)
{ labelIds: { $in: [labelId] } },
],
};
}
const RECURRING = 'label-recurring';
const FOCUS = 'swimlane-focus';
const BACKGROUND = 'swimlane-background';
const LIST = 'list-1';
// --- swimlaneMembershipSelector ---------------------------------------------
test('swimlaneMembershipSelector uses a single $in clause, not a top-level $or', () => {
const sel = swimlaneMembershipSelector(BACKGROUND);
assert.deepStrictEqual(sel, { swimlaneId: { $in: [BACKGROUND, null, ''] } });
assert.ok(!('$or' in sel), 'must NOT introduce a top-level $or (#6441)');
});
test('swimlaneMembershipSelector is empty without a swimlane context', () => {
assert.deepStrictEqual(swimlaneMembershipSelector(undefined), {});
assert.deepStrictEqual(swimlaneMembershipSelector(''), {});
});
test('the swimlane clause matches the swimlane plus orphaned/shared cards', () => {
const sel = swimlaneMembershipSelector(BACKGROUND);
assert.ok(docMatches(sel, { swimlaneId: BACKGROUND }));
assert.ok(docMatches(sel, { swimlaneId: null }));
assert.ok(docMatches(sel, { swimlaneId: '' }));
assert.ok(docMatches(sel, {}), 'missing swimlaneId is treated as orphaned');
assert.ok(!docMatches(sel, { swimlaneId: FOCUS }), 'other swimlane excluded');
});
// --- combineWithFilter -------------------------------------------------------
test('combineWithFilter ANDs the filter in and never drops it', () => {
const base = { listId: LIST, archived: false };
const filter = labelFilterSelector(RECURRING);
const combined = combineWithFilter(base, filter);
assert.deepStrictEqual(combined, { $and: [filter, base] });
});
test('combineWithFilter returns the base unchanged with no active filter', () => {
const base = { listId: LIST, archived: false };
assert.strictEqual(combineWithFilter(base, null), base);
assert.strictEqual(combineWithFilter(base, {}), base);
});
// --- filteredListCardsSelector: POSITIVE ------------------------------------
test('#6441: a labelled card matches in the Focus swimlane', () => {
const sel = filteredListCardsSelector(LIST, FOCUS, labelFilterSelector(RECURRING));
const card = { _id: 'c1', listId: LIST, archived: false, swimlaneId: FOCUS, labelIds: [RECURRING] };
assert.ok(docMatches(sel, card));
});
test('#6441: a labelled card matches in the Background swimlane too', () => {
const sel = filteredListCardsSelector(LIST, BACKGROUND, labelFilterSelector(RECURRING));
const card = { _id: 'c2', listId: LIST, archived: false, swimlaneId: BACKGROUND, labelIds: [RECURRING] };
assert.ok(docMatches(sel, card), 'label filter must apply board-wide, not only in the first swimlane');
});
// --- filteredListCardsSelector: NEGATIVE ------------------------------------
test('#6441: an unlabelled card is hidden in the Focus swimlane', () => {
const sel = filteredListCardsSelector(LIST, FOCUS, labelFilterSelector(RECURRING));
const card = { _id: 'c3', listId: LIST, archived: false, swimlaneId: FOCUS, labelIds: ['label-other'] };
assert.ok(!docMatches(sel, card));
});
test('#6441: an unlabelled card is hidden in the Background swimlane too', () => {
const sel = filteredListCardsSelector(LIST, BACKGROUND, labelFilterSelector(RECURRING));
const card = { _id: 'c4', listId: LIST, archived: false, swimlaneId: BACKGROUND, labelIds: ['label-other'] };
assert.ok(!docMatches(sel, card), 'the label filter must hide non-matching cards in Background (#6441)');
});
// --- the core regression: SAME result regardless of swimlane ----------------
test('#6441: matching result is identical across swimlanes for the same card', () => {
const labelled = { _id: 'c5', listId: LIST, archived: false, labelIds: [RECURRING] };
const unlabelled = { _id: 'c6', listId: LIST, archived: false, labelIds: [] };
[FOCUS, BACKGROUND].forEach(swimlaneId => {
const sel = filteredListCardsSelector(LIST, swimlaneId, labelFilterSelector(RECURRING));
// place each card in this swimlane, then evaluate
const inHere = id => Object.assign({}, id === 'labelled' ? labelled : unlabelled, { swimlaneId });
assert.strictEqual(docMatches(sel, inHere('labelled')), true, `labelled card must match in ${swimlaneId}`);
assert.strictEqual(docMatches(sel, inHere('unlabelled')), false, `unlabelled card must NOT match in ${swimlaneId}`);
});
});
test('#6441: an orphaned (no swimlane) card is filtered consistently in every swimlane', () => {
const orphanLabelled = { _id: 'c7', listId: LIST, archived: false }; // no swimlaneId, no labels
const orphanWithLabel = { _id: 'c8', listId: LIST, archived: false, labelIds: [RECURRING] };
[FOCUS, BACKGROUND].forEach(swimlaneId => {
const sel = filteredListCardsSelector(LIST, swimlaneId, labelFilterSelector(RECURRING));
assert.ok(!docMatches(sel, orphanLabelled), `orphaned unlabelled card hidden in ${swimlaneId}`);
assert.ok(docMatches(sel, orphanWithLabel), `orphaned labelled card shown in ${swimlaneId}`);
});
});
// --- do not regress other filter types --------------------------------------
test('a non-label filter (e.g. member) is likewise preserved across swimlanes', () => {
const memberFilter = { $or: [{ _id: { $in: [] } }, { members: { $in: ['user-a'] } }] };
[FOCUS, BACKGROUND].forEach(swimlaneId => {
const sel = filteredListCardsSelector(LIST, swimlaneId, memberFilter);
const match = { _id: 'm1', listId: LIST, archived: false, swimlaneId, members: ['user-a'] };
const noMatch = { _id: 'm2', listId: LIST, archived: false, swimlaneId, members: ['user-b'] };
assert.ok(docMatches(sel, match));
assert.ok(!docMatches(sel, noMatch));
});
});
test('archived cards never match the active-cards selector', () => {
const sel = filteredListCardsSelector(LIST, FOCUS, labelFilterSelector(RECURRING));
const card = { _id: 'c9', listId: LIST, archived: true, swimlaneId: FOCUS, labelIds: [RECURRING] };
assert.ok(!docMatches(sel, card));
});
console.log(`\n${passed} tests passed`);