Compare commits

...
16 Commits
Author SHA1 Message Date
github-actions[bot] 1207b573b5 Bump versions for v9.80 2026-07-06 15:40:14 +00:00
Lauri Ojansivu a26295a2fb Updated ChangeLog. 2026-07-06 18:35:50 +03:00
Lauri Ojansivu b2da9ac8d4 Admin Panel Domains table: server-side pagination, column sort and search (like Board Table view). Thanks to xet7. 2026-07-06 18:27:38 +03:00
Lauri Ojansivu 53dcf7379d Updated ChangeLog. 2026-07-06 18:13:31 +03:00
Lauri Ojansivu dfa6c78d4f Add regression test for board-invite email language. Refs #5664. 2026-07-06 16:02:56 +03:00
Lauri Ojansivu 795850ed05 Updated ChangeLog. 2026-07-06 15:25:54 +03:00
Lauri Ojansivu 9ef7f4a07a Thanks to 32Dexter and xet7 . Fixes #5666 2026-07-06 15:22:22 +03:00
Lauri Ojansivu cb2394a95d Updated ChangeLog. 2026-07-06 15:12:21 +03:00
Lauri Ojansivu ac368de06a Thanks to MarcusDger and xet7 . Fixes #5667 2026-07-06 15:12:21 +03:00
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
37 changed files with 1817 additions and 203 deletions

No files matched your search

+112
View File
@@ -39,6 +39,118 @@ 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.80 2026-07-06 WeKan ® release
This release adds the following new features:
- **[Admin Panel Domains table: pagination, column sort and search (like the Board Table view)](https://github.com/wekan/wekan/issues/5850)**:
The Admin Panel > People > Domains table loaded every domain (aggregated from all users) into the
browser at once, with a fixed order and no search. It now behaves like the Board Table view: the
server aggregates the domains and returns only one small page, so the whole list is never sent to
the browser. You can order by the Domain or Users column (click the header to toggle ascending /
descending, with a ▲/▼ indicator) and filter with a search box; prev/next controls page through the
results. The search + sort + slice runs in the new pure, unit-tested `models/lib/domainTablePage.js`
behind a new `getDomainsWithUserCountsPage` admin method (`server/models/users.js`), and the
`domainGeneral` template (`client/components/settings/peopleBody.{jade,js,css}`) is now
self-contained and fetches only the current page. Covered by `tests/domainTablePage.test.cjs`.
Thanks to xet7.
and adds the following tests:
- **[Verified and added a regression test for the board-invitation email language](https://github.com/wekan/wekan/commit/dfa6c78d4f032a698f6feaad189c86da903d27fb)**:
Confirmed that a board-invitation email is localised in the existing recipient's own profile
language, or — when the invitee is a new account created by the invite — in the inviter's profile
language, defaulting to `en` (`en.i18n.json`) when none is set. The behaviour was already correct;
the language choice is now extracted into the pure, unit-tested `models/lib/inviteEmailLanguage.js`
used by `inviteUserToBoard`, and locked in by `tests/inviteEmailLanguage.test.cjs`.
Thanks to xet7.
and fixes the following bugs:
- **[Linked-card minicard now shows the cover image of the real card](https://github.com/wekan/wekan/commit/9ef7f4a07a6b40a2582af24b27fe133119adcd18)**:
A linked card (created by "Link card to this card") on one board did not show the cover image of
the real card it points at on another board, even though the card's other fields did. A linked
card is only a placeholder — its real content lives on the card at `linkedId` — and every other
minicard getter resolves through the real card (`getTitle`/`getReceived`/`getDue`/…), but the
cover helpers read `this.coverId` directly, and a linked card has no `coverId` of its own. The
real card's cover attachment is already published to the linking board (see the "linked cards" /
"attachments for linked cards" children of the `board` publication), so this was purely a
client-side resolution gap. `Card.cover()` and the minicard `cover()` helper now resolve the
cover id through the real card via the pure, unit-tested `models/lib/linkedCardCover.js`; normal
cards are unaffected. Covered by `tests/linkedCardCover.test.cjs`.
Thanks to 32Dexter and xet7.
- **[Fix date-picker calendar stays fully visible when opened low on a scrolled page](https://github.com/wekan/wekan/commit/7a0eb61c0a4d3ace60f95cfbda7c1d70dff665ef)**:
Opening a date field (due/start/end date, or a date custom field) low on the screen showed the
calendar popup extending past the visible area, and — because the pop-over is `position: absolute`
(document coordinates) — scrolling to reach it moved the calendar along with the page, so the full
calendar could never be seen (the workaround was to close it, drag the field to the center and
reopen). `Popup._getOffset` computed the space above/below the opener and the clamped `top` from
the opener's DOCUMENT offset mixed with the VIEWPORT height, ignoring the page scroll, so on a
scrolled page the anchored popup landed outside the visible viewport. The geometry now runs in
viewport coordinates (subtracting the page scroll) and clamps the popup fully within the visible
viewport, then converts back to document coordinates for the absolute style; when the page is not
scrolled the output is unchanged. Extracted the math into the pure, unit-tested
`client/lib/popupOffset.js`, used by `client/lib/popup.js`. Covered by `tests/popupOffset.test.cjs`.
Thanks to MarcusDger and xet7.
Thanks to above GitHub users for their contributions and translators for their translations.
# 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.
Thanks to above GitHub users for their contributions and translators for their translations.
# 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.80
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.80.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'))) {
+49 -17
View File
@@ -6,6 +6,11 @@ import uploadProgressManager from '../../lib/uploadProgressManager';
import { Utils } from '/client/lib/utils';
import ChecklistItems from '/models/checklistItems';
import Cards from '/models/cards';
import { resolveCoverId } from '/models/lib/linkedCardCover';
import {
parseChecklistItemTitles,
buildChecklistItemPayload,
} from '/models/lib/checklistItemTitles';
import { normalizeDependencies } from '/models/metadata/dependencies';
function getMinicardFlag(board, onMinicardField, legacyField, defaultValue) {
@@ -164,8 +169,12 @@ Template.minicard.helpers({
}
},
cover() {
if (!this.coverId) return null;
const attachment = ReactiveCache.getAttachment(this.coverId);
// #5666: for a linked card the cover lives on the real card it points at, so
// resolve the cover id through it (mirroring getTitle/getDue/...); a plain
// card resolves to its own coverId.
const coverId = resolveCoverId(this, id => ReactiveCache.getCard(id));
if (!coverId) return null;
const attachment = ReactiveCache.getAttachment(coverId);
if (!attachment) return null;
const coverLink = typeof attachment.link === 'function' ? attachment.link() : '';
if (!coverLink) return null;
@@ -376,6 +385,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 +448,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,
+59
View File
@@ -378,3 +378,62 @@ table td:first-child {
.roles-settings .roles-description {
margin-bottom: 1rem;
}
/* Admin Panel > People > Domains table: search + column sort + pagination,
matching the Board Table view / other admin tables. */
.domain-controls {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.domain-controls input.js-domain-search {
flex: 1 1 auto;
height: 30px;
padding: 0 8px;
border: 1px solid #bbb;
border-radius: 0.4vw;
}
.domain-controls button.js-domain-search-button {
height: 30px;
padding: 0 12px;
border: 1px solid #bbb;
background: #fff;
border-radius: 0.4vw;
cursor: pointer;
}
.domain-pagination {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
}
.domain-pagination button {
min-width: 32px;
height: 30px;
padding: 0 10px;
border: 1px solid #bbb;
background: #fff;
border-radius: 0.4vw;
cursor: pointer;
}
.domain-pagination button.disabled {
opacity: 0.5;
pointer-events: none;
}
.domain-page-info {
min-width: 72px;
text-align: center;
color: #333;
}
th.js-domain-sort {
cursor: pointer;
user-select: none;
}
+10 -3
View File
@@ -122,7 +122,7 @@ template(name="people")
else if templatesSetting.get
+templatesGeneral
else if domainSetting.get
+domainGeneral(domainList=domainList)
+domainGeneral
template(name="orgGeneral")
@@ -239,11 +239,18 @@ template(name="selectAllUser")
input.allUserChkBox(type="checkbox", id="chkSelectAll")
template(name="domainGeneral")
.domain-controls
input.js-domain-search(type="text" placeholder="{{_ 'search'}}" aria-label="{{_ 'search'}}")
button.js-domain-search-button(type="button") {{_ 'search'}}
.domain-pagination
button.js-domain-prev-page(type="button" class="{{#unless hasPrevPage}}disabled{{/unless}}" aria-label="{{_ 'previous'}}") {{_ 'previous'}}
span.domain-page-info {{currentPage}} / {{totalPages}}
button.js-domain-next-page(type="button" class="{{#unless hasNextPage}}disabled{{/unless}}" aria-label="{{_ 'next'}}") {{_ 'next'}}
table
thead
tr
th {{_ 'domain'}}
th {{_ 'domain-user-count'}}
th.js-domain-sort(scope="col" data-sort="domain") {{_ 'domain'}} {{domainSortIndicator 'domain'}}
th.js-domain-sort(scope="col" data-sort="count") {{_ 'domain-user-count'}} {{domainSortIndicator 'count'}}
tbody
if domainList.length
each domainList
+99 -11
View File
@@ -14,6 +14,7 @@ import InviteToBoardRolesSettings, {
const orgsPerPage = 25;
const teamsPerPage = 25;
const usersPerPage = 25;
const domainsPerPage = 25;
let userOrgsTeamsAction = ""; //poosible actions 'addOrg', 'addTeam', 'removeOrg' or 'removeTeam' when adding or modifying a user
let selectedUserChkBoxUserIds = [];
@@ -28,9 +29,9 @@ Template.people.onCreated(function () {
this.lockedUsersSetting = new ReactiveVar(false);
this.rolesSetting = new ReactiveVar(false);
this.templatesSetting = new ReactiveVar(false);
// #5850: Admin Panel > People > Domains tab.
// #5850: Admin Panel > People > Domains tab. The domains table itself
// (domainGeneral) now owns its data via getDomainsWithUserCountsPage.
this.domainSetting = new ReactiveVar(false);
this.domains = new ReactiveVar([]);
this.subscribe('inviteToBoardRolesSettings');
this.findOrgsOptions = new ReactiveVar({});
this.findTeamsOptions = new ReactiveVar({});
@@ -205,12 +206,9 @@ Template.people.onCreated(function () {
this.templatesSetting.set('templates-setting' === targetID);
this.domainSetting.set('domains-setting' === targetID);
// #5850: load the domains + per-domain user counts when the tab opens.
if ('domains-setting' === targetID) {
Meteor.call('getDomainsWithUserCounts', (err, res) => {
if (!err) this.domains.set(res || []);
});
}
// The Domains table (domainGeneral) now fetches its own single page from
// getDomainsWithUserCountsPage (server-side search / sort / pagination), so
// the parent no longer eagerly loads every domain into the browser.
// When switching to locked users tab, refresh the locked users list
if ('locked-users-setting' === targetID) {
@@ -292,9 +290,6 @@ Template.people.helpers({
domainSetting() {
return Template.instance().domainSetting;
},
domainList() {
return Template.instance().domains.get();
},
orgList() {
const tpl = Template.instance();
// The 'org' publication already returns only the current page (server-side
@@ -1897,3 +1892,96 @@ Template.settingsUserPopup.helpers({
return Template.instance().errorMessage.get();
},
});
// Admin Panel > People > Domains table. Self-contained (like the Board Table
// view): it keeps its own search / sort / page state and fetches only ONE page
// from the server method getDomainsWithUserCountsPage, so the whole domain list
// is never loaded into the browser.
Template.domainGeneral.onCreated(function () {
this.searchQuery = new ReactiveVar('');
this.sortField = new ReactiveVar('domain'); // domain | count
this.sortDirection = new ReactiveVar(1); // 1 ascending, -1 descending
this.page = new ReactiveVar(1);
this.pageData = new ReactiveVar({ rows: [], total: 0, totalPages: 1 });
this.autorun(() => {
const params = {
search: this.searchQuery.get(),
sortField: this.sortField.get(),
sortDirection: this.sortDirection.get(),
page: this.page.get(),
perPage: domainsPerPage,
};
Meteor.call('getDomainsWithUserCountsPage', params, (err, res) => {
if (!err && res) {
this.pageData.set(res);
// Server clamps the page into range; mirror that so the controls agree.
if (typeof res.page === 'number' && res.page !== this.page.get()) {
this.page.set(res.page);
}
}
});
});
});
Template.domainGeneral.helpers({
domainList() {
return Template.instance().pageData.get().rows;
},
currentPage() {
return Template.instance().page.get();
},
totalPages() {
return Template.instance().pageData.get().totalPages || 1;
},
hasPrevPage() {
return Template.instance().page.get() > 1;
},
hasNextPage() {
const tpl = Template.instance();
return tpl.page.get() < (tpl.pageData.get().totalPages || 1);
},
// Excel-like sort arrow on the active column, matching the Board Table view.
domainSortIndicator(field) {
const tpl = Template.instance();
if (tpl.sortField.get() !== field) return '';
return tpl.sortDirection.get() === 1 ? '▲' : '▼';
},
});
Template.domainGeneral.events({
'click .js-domain-search-button'(event, tpl) {
event.preventDefault();
tpl.searchQuery.set(tpl.$('.js-domain-search').val() || '');
tpl.page.set(1);
},
'keydown .js-domain-search'(event, tpl) {
if (event.keyCode === 13) {
event.preventDefault();
tpl.searchQuery.set(tpl.$('.js-domain-search').val() || '');
tpl.page.set(1);
}
},
'click .js-domain-prev-page'(event, tpl) {
event.preventDefault();
const current = tpl.page.get();
if (current > 1) tpl.page.set(current - 1);
},
'click .js-domain-next-page'(event, tpl) {
event.preventDefault();
const current = tpl.page.get();
if (current < (tpl.pageData.get().totalPages || 1)) tpl.page.set(current + 1);
},
'click .js-domain-sort'(event, tpl) {
event.preventDefault();
const field = event.currentTarget.dataset.sort;
if (!field) return;
if (tpl.sortField.get() === field) {
tpl.sortDirection.set(tpl.sortDirection.get() * -1);
} else {
tpl.sortField.set(field);
tpl.sortDirection.set(1);
}
tpl.page.set(1);
},
});
+27 -101
View File
@@ -4,6 +4,7 @@ import { Tracker } from 'meteor/tracker';
import { TAPi18n } from '/imports/i18n';
import { EscapeActions } from '/client/lib/escapeActions';
import { Utils } from '/client/lib/utils';
import { computePopupOffset } from '/client/lib/popupOffset';
window.Popup = new (class {
constructor() {
@@ -222,108 +223,33 @@ window.Popup = new (class {
return () => {
Utils.windowResizeDep.depend();
if (Utils.isMiniScreen()) return { left: 0, top: 0 };
// #5667: read the current page scroll and the opener's document offset,
// then delegate the geometry to the pure, unit-tested computePopupOffset.
// It lays the popup out in VIEWPORT coordinates — so a date-picker opened
// low on a scrolled page stays fully visible instead of extending past the
// visible area — and returns document coordinates for the absolute style.
const hasOpener = !!($element && $element.length);
const offset = hasOpener ? $element.offset() : null;
const opener = hasOpener
? { top: offset.top, left: offset.left, height: $element.outerHeight() }
: null;
const viewportWidth = $(window).width();
const viewportHeight = $(window).height();
const viewportPadding = 10;
// Calculate actual popup width based on CSS: min(380px, 55vw)
const popupWidth = Math.min(380, viewportWidth * 0.55);
// Card details popup: dock it to the top of the viewport so it overlays
// the global and board header bars (its z-index already sits above them)
// instead of opening downward from the clicked minicard.
if (popupName === 'cardDetailsPopup') {
const cardWidth = Math.min(600, viewportWidth * 0.8);
const centeredLeft = (viewportWidth - cardWidth) / 2;
// top is forced to 0 in CSS (.pop-over[data-popup="cardDetailsPopup"])
// so the card overlays the header bars; use the full viewport height.
return {
left: Math.max(viewportPadding, centeredLeft),
top: 0,
maxHeight: viewportHeight,
};
}
// If the opener element is missing (e.g., programmatic open), fallback to viewport origin
if (!$element || $element.length === 0) {
return {
left: viewportPadding,
top: viewportPadding,
maxHeight: viewportHeight - viewportPadding * 2,
};
}
const offset = $element.offset();
const openerTop = offset.top;
const openerBottom = offset.top + $element.outerHeight();
const clampedLeft = Math.max(
viewportPadding,
Math.min(offset.left, viewportWidth - popupWidth - viewportPadding),
);
// Check if this is an admin panel edit popup
const isAdminEditPopup = $element.hasClass('edit-user') ||
$element.hasClass('edit-org') ||
$element.hasClass('edit-team');
if (isAdminEditPopup) {
// Center the popup horizontally and use full height
const centeredLeft = (viewportWidth - popupWidth) / 2;
return {
left: Math.max(viewportPadding, centeredLeft), // Ensure popup doesn't go off screen
top: viewportPadding, // Start from top with small margin
maxHeight: viewportHeight - viewportPadding * 2, // Use full height minus small margins
};
}
// Calculate available heights around the opener
const spaceBelow = viewportHeight - openerBottom - viewportPadding;
const spaceAbove = openerTop - viewportPadding;
const preferBelow = spaceBelow >= spaceAbove;
// For language popup, don't use dynamic height to avoid overlapping board
const isLanguagePopup = $element.hasClass('js-change-language');
let availableHeight;
if (isLanguagePopup) {
// For language popup, position content area below right vertical scrollbar
const languageTop = openerBottom;
const languageAvailable = Math.max(
0,
viewportHeight - languageTop - viewportPadding,
);
const calculatedHeight = Math.min(
languageAvailable,
viewportHeight * 0.5,
); // Max 50% of viewport
return {
left: clampedLeft,
top: languageTop,
maxHeight: Math.max(calculatedHeight, 0),
};
} else {
// For other popups, choose the side with more space and keep popup fully in viewport.
if (preferBelow) {
availableHeight = Math.max(0, Math.min(spaceBelow, viewportHeight * 0.8));
return {
left: clampedLeft,
top: openerBottom,
maxHeight: availableHeight,
};
}
availableHeight = Math.max(0, Math.min(spaceAbove, viewportHeight * 0.8));
const top = Math.max(viewportPadding, openerTop - availableHeight);
return {
left: clampedLeft,
top,
maxHeight: availableHeight,
};
}
return computePopupOffset({
viewportWidth: $(window).width(),
viewportHeight: $(window).height(),
scrollTop: $(window).scrollTop() || 0,
scrollLeft: $(window).scrollLeft() || 0,
opener,
popupName,
isMiniScreen: Utils.isMiniScreen(),
isAdminEditPopup:
hasOpener &&
($element.hasClass('edit-user') ||
$element.hasClass('edit-org') ||
$element.hasClass('edit-team')),
isLanguagePopup: hasOpener && $element.hasClass('js-change-language'),
viewportPadding: 10,
});
};
}
+118
View File
@@ -0,0 +1,118 @@
'use strict';
// Pure geometry for Popup._getOffset — extracted so the positioning math is
// unit-testable without a DOM / Meteor (see tests/popupOffset.test.cjs).
//
// #5667 ("Problem when scrolling in a map with date fields"): a date-picker
// calendar (due/start/end date, or a date custom field) opened low on the screen
// appeared partly outside the visible area, and — because the pop-over is
// `position: absolute` (document coordinates) — trying to scroll it into view
// moved it along with the page, so the calendar could never be seen fully.
//
// Root cause: _getOffset computed the space above / below the opener (and the
// clamped `top`) from the opener's DOCUMENT offset (`$el.offset()`) mixed with
// the VIEWPORT height, ignoring the page scroll. On a scrolled page that placed
// the anchored popup outside the visible viewport.
//
// Fix: do the space / clamp math in VIEWPORT coordinates (subtract
// scrollTop/scrollLeft), then convert the chosen top/left back to DOCUMENT
// coordinates (add the scroll back) for the absolute style. The popup box is then
// always laid out fully within the visible viewport. When the page is not
// scrolled (scrollTop === scrollLeft === 0) the output is identical to the
// previous behaviour, so nothing regresses in the common case.
function computePopupOffset(params) {
const {
viewportWidth,
viewportHeight,
scrollTop = 0,
scrollLeft = 0,
// Opener document offset { top, left, height }, or null for a programmatic
// open with no reference element.
opener = null,
popupName,
isMiniScreen = false,
isAdminEditPopup = false,
isLanguagePopup = false,
viewportPadding = 10,
} = params;
if (isMiniScreen) return { left: 0, top: 0 };
// Actual popup width from CSS: min(380px, 55vw).
const popupWidth = Math.min(380, viewportWidth * 0.55);
// Card details popup: docked to the top of the viewport (CSS also forces
// top:0) so it overlays the header bars instead of opening from the minicard.
if (popupName === 'cardDetailsPopup') {
const cardWidth = Math.min(600, viewportWidth * 0.8);
const centeredLeft = (viewportWidth - cardWidth) / 2;
return {
left: Math.max(viewportPadding, centeredLeft),
top: scrollTop, // viewport top in document coords
maxHeight: viewportHeight,
};
}
// No opener element: fall back to the top-left of the VISIBLE viewport.
if (!opener) {
return {
left: viewportPadding + scrollLeft,
top: viewportPadding + scrollTop,
maxHeight: viewportHeight - viewportPadding * 2,
};
}
// Opener position in viewport coordinates (this is the #5667 fix: the previous
// code used the raw document offset here).
const openerTopVp = opener.top - scrollTop;
const openerBottomVp = openerTopVp + opener.height;
const openerLeftVp = opener.left - scrollLeft;
const clampedLeftVp = Math.max(
viewportPadding,
Math.min(openerLeftVp, viewportWidth - popupWidth - viewportPadding),
);
const clampedLeft = clampedLeftVp + scrollLeft;
// Admin panel edit popups: horizontally centered, anchored to the viewport top.
if (isAdminEditPopup) {
const centeredLeft = (viewportWidth - popupWidth) / 2;
return {
left: Math.max(viewportPadding, centeredLeft),
top: viewportPadding + scrollTop,
maxHeight: viewportHeight - viewportPadding * 2,
};
}
const spaceBelow = viewportHeight - openerBottomVp - viewportPadding;
const spaceAbove = openerTopVp - viewportPadding;
const preferBelow = spaceBelow >= spaceAbove;
// Language popup: fixed-ish height below the opener, capped at 50% viewport.
if (isLanguagePopup) {
const languageTopVp = openerBottomVp;
const languageAvailable = Math.max(
0,
viewportHeight - languageTopVp - viewportPadding,
);
const calculatedHeight = Math.min(languageAvailable, viewportHeight * 0.5);
return {
left: clampedLeft,
top: languageTopVp + scrollTop,
maxHeight: Math.max(calculatedHeight, 0),
};
}
// Other popups: open on the side with more room and stay fully in the viewport.
if (preferBelow) {
const maxHeight = Math.max(0, Math.min(spaceBelow, viewportHeight * 0.8));
return { left: clampedLeft, top: openerBottomVp + scrollTop, maxHeight };
}
const maxHeight = Math.max(0, Math.min(spaceAbove, viewportHeight * 0.8));
const topVp = Math.max(viewportPadding, openerTopVp - maxHeight);
return { left: clampedLeft, top: topVp + scrollTop, maxHeight };
}
module.exports = { computePopupOffset };
+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.80-win64.zip](https://github.com/wekan/wekan/releases/download/v9.80/wekan-9.80-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.80-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');
});
+7 -2
View File
@@ -28,6 +28,7 @@ import {
TYPE_LINKED_CARD,
} from '../config/const';
import { CARD_COLORS } from '/models/metadata/colors';
import { resolveCoverId } from '/models/lib/linkedCardCover';
import {
DEFAULT_DEPENDENCY_COLOR,
DEFAULT_DEPENDENCY_ICON,
@@ -1171,8 +1172,12 @@ Cards.helpers({
},
cover() {
if (!this.coverId) return false;
const cover = ReactiveCache.getAttachment(this.coverId);
// #5666: resolve the cover id through the real card for linked cards (a
// linked card has no coverId of its own), matching the other linked-card
// getters; a plain card resolves to its own coverId.
const coverId = resolveCoverId(this, id => ReactiveCache.getCard(id));
if (!coverId) return false;
const cover = ReactiveCache.getAttachment(coverId);
// if we return a cover before it is fully stored, we will get errors when we try to display it
// todo XXX we could return a default "upload pending" image in the meantime?
return cover && cover.link() && cover;
+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 };
+71
View File
@@ -0,0 +1,71 @@
'use strict';
// Pure filter + sort + paginate for the Admin Panel > People > Domains table.
// Extracted so the page logic is unit-testable without Meteor and shared by the
// server method that feeds the table.
//
// The Domains table used to load EVERY domain (aggregated from all users) into
// the browser at once, with a fixed order and no search. To behave like the
// Board Table view — send only one small page to the browser, let the user
// order by a column and search — the server aggregates all users into
// { domain, count } rows and then hands them to this helper, which applies the
// search filter, the requested column sort and the requested page, returning
// only that page plus the total so the client can render pagination controls.
const PER_PAGE_DEFAULT = 25;
const PER_PAGE_MAX = 200;
function paginateDomains(rows, opts = {}) {
const all = Array.isArray(rows) ? rows : [];
const search =
typeof opts.search === 'string' ? opts.search.trim().toLowerCase() : '';
// Only two real columns; anything else falls back to the domain name.
const sortField = opts.sortField === 'count' ? 'count' : 'domain';
const sortDirection = opts.sortDirection === -1 ? -1 : 1;
const rawPerPage = Number(opts.perPage);
const perPage = Math.min(
PER_PAGE_MAX,
Number.isFinite(rawPerPage) && rawPerPage >= 1 ? rawPerPage : PER_PAGE_DEFAULT,
);
let filtered = all;
if (search) {
filtered = all.filter(
row => (row.domain || '').toLowerCase().indexOf(search) !== -1,
);
}
const byDomain = (a, b) =>
(a.domain || '').localeCompare(b.domain || '', undefined, {
sensitivity: 'base',
numeric: true,
});
filtered = filtered.slice().sort((a, b) => {
if (sortField === 'count') {
const cmp = ((a.count || 0) - (b.count || 0)) * sortDirection;
if (cmp !== 0) return cmp;
// Ties always break alphabetically (ascending), independent of direction.
return byDomain(a, b);
}
return byDomain(a, b) * sortDirection;
});
const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / perPage));
// Clamp the requested page into range so a stale/too-large page never yields
// an empty table.
const page = Math.min(Math.max(1, Number(opts.page) || 1), totalPages);
const start = (page - 1) * perPage;
return {
rows: filtered.slice(start, start + perPage),
total,
page,
totalPages,
perPage,
};
}
module.exports = { paginateDomains, PER_PAGE_DEFAULT, PER_PAGE_MAX };
+27
View File
@@ -0,0 +1,27 @@
'use strict';
// Pure helper choosing the language of the board-invitation email. Extracted
// from server/models/users.js (inviteUserToBoard) so the rule is explicit and
// unit-testable without Meteor.
//
// #5664 ("Change the email board invitation language"): the invite email must be
// localised as follows —
// - when the invitee is an ALREADY-EXISTING user, use THAT user's (the
// recipient's) profile language, so the person receiving the invite reads it
// in their own language;
// - when the invitee is a NEW user (created by the invite), use the INVITER's
// profile language, since the new account has no language preference of its
// own yet.
// A missing/empty language falls back to 'en' (i.e. en.i18n.json), matching
// User.getLanguage()'s default.
function chooseInviteEmailLanguage({
isNewUser,
inviterLanguage,
recipientLanguage,
} = {}) {
const lang = isNewUser ? inviterLanguage : recipientLanguage;
return lang || 'en';
}
module.exports = { chooseInviteEmailLanguage };
+37
View File
@@ -0,0 +1,37 @@
'use strict';
// Pure helper for resolving a (possibly linked) card's cover attachment id.
// Extracted so the resolution is unit-testable without Meteor.
//
// #5666 ("Minicard connection without images"): a linked card
// (type 'cardType-linkedCard') is a placeholder on the linking board — its real
// content, including the cover image, lives on the card it points at (`linkedId`)
// on the other board. Every other minicard field resolves through the real card
// (getTitle/getReceived/getDue/... all do `getCard(this.linkedId)`), but the
// cover helpers read `this.coverId` directly. A linked card has no coverId of its
// own, so the cover never showed on the linking board's minicard even though the
// real card's attachment is published there. Resolve the cover id through the
// real card, mirroring the other linked-card getters.
const TYPE_LINKED_CARD = 'cardType-linkedCard';
// The card whose cover applies to `card`: the real card behind a link, otherwise
// the card itself. `getCard(id)` returns the card doc for an id (used to hop a
// linked card to its real card). Returns null when it can't be resolved.
function coverSourceCard(card, getCard) {
if (!card) return null;
if (card.type === TYPE_LINKED_CARD && card.linkedId) {
return typeof getCard === 'function' ? getCard(card.linkedId) || null : null;
}
return card;
}
// The cover attachment id to display for `card`, resolved through the real card
// for linked cards. Returns null when there is none (or the real card isn't
// loaded yet), so the caller renders no cover instead of throwing.
function resolveCoverId(card, getCard) {
const source = coverSourceCard(card, getCard);
return source && source.coverId ? source.coverId : null;
}
module.exports = { coverSourceCard, resolveCoverId };
+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.80.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wekan",
"version": "v9.78.0",
"version": "v9.80.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.80.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/popupOffset.test.cjs && node tests/linkedCardCover.test.cjs && node tests/inviteEmailLanguage.test.cjs && node tests/domainTablePage.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.80</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.80<!-- -->)</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.80","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.80
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 = 980,
# Increment this for every release.
appMarketingVersion = (defaultText = "9.78~2026-07-06"),
appMarketingVersion = (defaultText = "9.80~2026-07-06"),
# Human-readable presentation of the app version.
minUpgradableAppVersion = 0,
+61 -1
View File
@@ -22,6 +22,8 @@ import Lists from '/models/lists';
import Swimlanes from '/models/swimlanes';
import Users, { allowedSortValues, allowedAllBoardsSortValues } from '/models/users';
import { expiredNotificationActivityIds } from '/models/lib/notificationCleanup';
import { chooseInviteEmailLanguage } from '/models/lib/inviteEmailLanguage';
import { paginateDomains } from '/models/lib/domainTablePage';
const getTAPi18n = () => require('/imports/i18n').TAPi18n;
const isSandstorm =
@@ -832,6 +834,8 @@ Meteor.methods({
const posAt = username.indexOf('@');
let user = null;
// #5664: existing invitee => their own language; new invitee => inviter's.
let isNewUser = false;
if (posAt >= 0) {
user = await ReactiveCache.getUser({
emails: { $elemMatch: { address: username } },
@@ -862,6 +866,7 @@ Meteor.methods({
}
Accounts.sendEnrollmentEmail(newUserId);
user = await ReactiveCache.getUser(newUserId);
isNewUser = true;
}
// #6116: when the global admin setting is enabled, only allow adding a user
@@ -961,7 +966,13 @@ Meteor.methods({
board: board.title,
url: board.absoluteUrl(),
};
const lang = user.getLanguage();
// #5664: pick the invite email's language — the existing recipient's own
// profile language, or (for a newly created invitee) the inviter's.
const lang = chooseInviteEmailLanguage({
isNewUser,
inviterLanguage: inviter.getLanguage(),
recipientLanguage: user.getLanguage(),
});
if (typeof EmailLocalization !== 'undefined') {
await EmailLocalization.sendEmail({
@@ -1847,6 +1858,55 @@ Meteor.methods({
.sort((a, b) => b.count - a.count || a.domain.localeCompare(b.domain));
},
// Paginated / searchable / column-sortable variant of getDomainsWithUserCounts
// for the Admin Panel Domains table. The aggregation still scans all users on
// the SERVER, but only one small page of rows is returned to the browser
// (matching the Board Table view). `paginateDomains` (pure, unit-tested) does
// the search + sort + slice; this method only does the auth check and the
// domain/count aggregation.
async getDomainsWithUserCountsPage(params = {}) {
check(params, Match.ObjectIncluding({
search: Match.Optional(String),
sortField: Match.Optional(String),
sortDirection: Match.Optional(Number),
page: Match.Optional(Number),
perPage: Match.Optional(Number),
}));
if (!this.userId) {
throw new Meteor.Error('not-logged-in', 'User must be logged in');
}
const currentUser = await ReactiveCache.getUser(
{ _id: this.userId },
{ fields: { isAdmin: 1 } },
);
if (!currentUser || !currentUser.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
const users = await Users.find({}, { fields: { emails: 1 } }).fetchAsync();
const counts = {};
for (const u of users) {
const addr = (u.emails && u.emails[0] && u.emails[0].address) || '';
const at = addr.lastIndexOf('@');
if (at === -1) continue;
const domain = addr.slice(at + 1).toLowerCase().trim();
if (!domain) continue;
counts[domain] = (counts[domain] || 0) + 1;
}
const rows = Object.keys(counts).map(domain => ({
domain,
count: counts[domain],
}));
return paginateDomains(rows, {
search: params.search,
sortField: params.sortField,
sortDirection: params.sortDirection,
page: params.page,
perPage: params.perPage,
});
},
// Feature #3313 "Shared templates": admin-only.
// For every user whose Templates board is NON-EMPTY (contains at least one
// shared template board), return the data the Admin Panel needs to group the
+4 -4
View File
@@ -1,5 +1,5 @@
name: wekan
version: '9.78'
version: '9.80'
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.80-${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.80/wekan-9.80-amd64.zip
wget "https://github.com/wekan/wekan/releases/download/v9.80/${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`);
+99
View File
@@ -0,0 +1,99 @@
'use strict';
// Plain-Node unit test (no Meteor) for the Admin Panel Domains table paging.
// Run: node tests/domainTablePage.test.cjs
//
// The Domains table now behaves like the Board Table view: only one small page
// is sent to the browser, ordered by a column and filtered by a search term.
// paginateDomains() is the server-side search + sort + slice that backs it.
const assert = require('assert');
const { paginateDomains } = require('../models/lib/domainTablePage');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
const rows = [
{ domain: 'gmail.com', count: 5 },
{ domain: 'example.com', count: 12 },
{ domain: 'apple.com', count: 3 },
{ domain: 'zeta.org', count: 12 },
{ domain: 'beta.io', count: 1 },
];
const names = r => r.rows.map(x => x.domain);
// --- Pagination (only a page is returned) -----------------------------------
test('returns only one page and the correct total', () => {
const r = paginateDomains(rows, { perPage: 2, page: 1, sortField: 'domain', sortDirection: 1 });
assert.strictEqual(r.total, 5);
assert.strictEqual(r.totalPages, 3);
assert.strictEqual(r.rows.length, 2);
});
test('page 2 returns the next slice; page beyond range clamps to the last page', () => {
// sorted asc: apple.com, beta.io, example.com, gmail.com, zeta.org
const p2 = paginateDomains(rows, { perPage: 2, page: 2, sortField: 'domain', sortDirection: 1 });
assert.deepStrictEqual(names(p2), ['example.com', 'gmail.com']);
const clamped = paginateDomains(rows, { perPage: 2, page: 99, sortField: 'domain', sortDirection: 1 });
assert.strictEqual(clamped.page, 3);
assert.strictEqual(clamped.rows.length, 1);
});
// --- Column sorting ----------------------------------------------------------
test('sort by domain ascending / descending', () => {
const asc = paginateDomains(rows, { perPage: 25, sortField: 'domain', sortDirection: 1 });
assert.deepStrictEqual(names(asc), ['apple.com', 'beta.io', 'example.com', 'gmail.com', 'zeta.org']);
const desc = paginateDomains(rows, { perPage: 25, sortField: 'domain', sortDirection: -1 });
assert.deepStrictEqual(names(desc), ['zeta.org', 'gmail.com', 'example.com', 'beta.io', 'apple.com']);
});
test('sort by count descending, tie-broken by domain name', () => {
const r = paginateDomains(rows, { perPage: 25, sortField: 'count', sortDirection: -1 });
// 12/12 tie -> example.com before zeta.org, then 5, 3, 1
assert.deepStrictEqual(names(r), ['example.com', 'zeta.org', 'gmail.com', 'apple.com', 'beta.io']);
});
test('an unknown sortField falls back to sorting by domain', () => {
const r = paginateDomains(rows, { perPage: 25, sortField: 'bogus', sortDirection: 1 });
assert.deepStrictEqual(names(r), ['apple.com', 'beta.io', 'example.com', 'gmail.com', 'zeta.org']);
});
// --- Search ------------------------------------------------------------------
test('search filters by domain substring (case-insensitive) and adjusts total', () => {
const r = paginateDomains(rows, { perPage: 25, search: 'COM', sortField: 'domain', sortDirection: 1 });
assert.deepStrictEqual(names(r), ['apple.com', 'example.com', 'gmail.com']);
assert.strictEqual(r.total, 3);
});
// --- NEGATIVE / edges --------------------------------------------------------
test('a search with no matches returns an empty page and total 0', () => {
const r = paginateDomains(rows, { perPage: 25, search: 'no-such-domain' });
assert.deepStrictEqual(r.rows, []);
assert.strictEqual(r.total, 0);
assert.strictEqual(r.totalPages, 1);
});
test('non-array / empty input is handled without throwing', () => {
assert.deepStrictEqual(paginateDomains(undefined).rows, []);
assert.deepStrictEqual(paginateDomains(null).rows, []);
const empty = paginateDomains([]);
assert.strictEqual(empty.total, 0);
assert.strictEqual(empty.page, 1);
});
test('perPage is clamped to a sane range (>=1, <=200)', () => {
assert.strictEqual(paginateDomains(rows, { perPage: 0 }).perPage, 25); // 0 -> default
assert.strictEqual(paginateDomains(rows, { perPage: 9999 }).perPage, 200);
assert.strictEqual(paginateDomains(rows, { perPage: -5 }).perPage, 25);
});
test('page is never below 1', () => {
const r = paginateDomains(rows, { perPage: 2, page: -3 });
assert.strictEqual(r.page, 1);
});
console.log(`\n${passed} tests passed`);
+86
View File
@@ -0,0 +1,86 @@
'use strict';
// Plain-Node unit test (no Meteor) for the board-invitation email language rule.
// Run: node tests/inviteEmailLanguage.test.cjs
//
// Locks in #5664: a board-invitation email is localised in the EXISTING
// recipient's own profile language, or — when the invitee is a NEW account
// created by the invite — in the INVITER's profile language, defaulting to 'en'
// (en.i18n.json) when no language is set.
const assert = require('assert');
const {
chooseInviteEmailLanguage,
} = require('../models/lib/inviteEmailLanguage');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// --- POSITIVE ---------------------------------------------------------------
test('#5664: existing invitee uses their own (recipient) language', () => {
assert.strictEqual(
chooseInviteEmailLanguage({
isNewUser: false,
inviterLanguage: 'de',
recipientLanguage: 'fr',
}),
'fr',
);
});
test('#5664: new invitee uses the inviter language', () => {
assert.strictEqual(
chooseInviteEmailLanguage({
isNewUser: true,
inviterLanguage: 'de',
recipientLanguage: 'fr', // must be ignored for a new user
}),
'de',
);
});
// --- NEGATIVE / defaults ----------------------------------------------------
test('existing invitee with no language defaults to en (en.i18n.json)', () => {
assert.strictEqual(
chooseInviteEmailLanguage({ isNewUser: false, inviterLanguage: 'de', recipientLanguage: undefined }),
'en',
);
assert.strictEqual(
chooseInviteEmailLanguage({ isNewUser: false, inviterLanguage: 'de', recipientLanguage: '' }),
'en',
);
});
test('new invitee with an inviter that has no language defaults to en', () => {
assert.strictEqual(
chooseInviteEmailLanguage({ isNewUser: true, inviterLanguage: undefined, recipientLanguage: 'fr' }),
'en',
);
});
test('direction guard: an existing invitee never uses the inviter language', () => {
// Recipient has no language, inviter is German — must still be en, NOT de.
assert.strictEqual(
chooseInviteEmailLanguage({ isNewUser: false, inviterLanguage: 'de', recipientLanguage: null }),
'en',
);
});
test('direction guard: a new invitee never uses the (blank) recipient language', () => {
// New account has no real preference; the inviter language wins over recipient.
assert.strictEqual(
chooseInviteEmailLanguage({ isNewUser: true, inviterLanguage: 'es', recipientLanguage: 'zz' }),
'es',
);
});
test('missing / empty arguments fall back to en without throwing', () => {
assert.strictEqual(chooseInviteEmailLanguage(), 'en');
assert.strictEqual(chooseInviteEmailLanguage({}), 'en');
});
console.log(`\n${passed} tests passed`);
+74
View File
@@ -0,0 +1,74 @@
'use strict';
// Plain-Node unit test (no Meteor) for the linked-card cover resolver.
// Run: node tests/linkedCardCover.test.cjs
//
// Regression guard for #5666 ("Minicard connection without images"): a linked
// card's cover image lives on the real card it points at, but the cover helpers
// read `this.coverId` directly — and a linked card has no coverId of its own, so
// the cover never showed on the linking board. resolveCoverId() must hop a linked
// card to its real card's coverId, while leaving normal cards unchanged.
const assert = require('assert');
const {
coverSourceCard,
resolveCoverId,
} = require('../models/lib/linkedCardCover');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// Real card on board A with a cover; a linked card on board B points at it.
const realCard = { _id: 'realA', type: 'card', coverId: 'att-A' };
const linkedCard = { _id: 'linkB', type: 'cardType-linkedCard', linkedId: 'realA' };
const getCard = id => (id === 'realA' ? realCard : undefined);
// --- POSITIVE ---------------------------------------------------------------
test('#5666: a linked card resolves to the REAL card\'s cover id', () => {
assert.strictEqual(resolveCoverId(linkedCard, getCard), 'att-A');
assert.strictEqual(coverSourceCard(linkedCard, getCard), realCard);
});
test('a plain card resolves to its own cover id', () => {
assert.strictEqual(resolveCoverId(realCard, getCard), 'att-A');
});
test('a linked card ignores any stray coverId on the placeholder, using the real card', () => {
// Even if the linked-card placeholder somehow carried a coverId, the real
// card's cover is authoritative (guards the #5666 root cause directly).
const stray = { _id: 'linkB', type: 'cardType-linkedCard', linkedId: 'realA', coverId: 'stray' };
assert.strictEqual(resolveCoverId(stray, getCard), 'att-A');
});
// --- NEGATIVE ---------------------------------------------------------------
test('a plain card with no cover returns null', () => {
assert.strictEqual(resolveCoverId({ _id: 'x', type: 'card' }, getCard), null);
});
test('a linked card whose real card has no cover returns null', () => {
const realNoCover = { _id: 'realA', type: 'card' };
const linked = { _id: 'l', type: 'cardType-linkedCard', linkedId: 'realA' };
assert.strictEqual(resolveCoverId(linked, () => realNoCover), null);
});
test('a linked card whose real card is not loaded yet returns null (no throw)', () => {
const orphan = { _id: 'l', type: 'cardType-linkedCard', linkedId: 'missing' };
assert.strictEqual(resolveCoverId(orphan, getCard), null);
});
test('a linked card with no resolver returns null (no throw)', () => {
assert.strictEqual(resolveCoverId(linkedCard, undefined), null);
assert.strictEqual(coverSourceCard(linkedCard, undefined), null);
});
test('null / typeless cards are handled without throwing', () => {
assert.strictEqual(resolveCoverId(null, getCard), null);
assert.strictEqual(resolveCoverId(undefined, getCard), null);
assert.strictEqual(coverSourceCard(null, getCard), null);
});
console.log(`\n${passed} tests passed`);
+162
View File
@@ -0,0 +1,162 @@
'use strict';
// Plain-Node unit test (no DOM/Meteor) for the popup positioning geometry.
// Run: node tests/popupOffset.test.cjs
//
// Regression guard for #5667 ("Problem when scrolling in a map with date
// fields"): a date-picker opened low on a SCROLLED page used to be positioned
// from the opener's DOCUMENT offset mixed with the VIEWPORT height, so it landed
// partly outside the visible viewport (and, being position:absolute, scrolled
// away when the user tried to reach it). computePopupOffset must lay the popup
// out so it is fully within the visible viewport for any scroll position, while
// staying byte-identical to the old behaviour when the page is not scrolled.
const assert = require('assert');
const { computePopupOffset } = require('../client/lib/popupOffset');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
const VW = 1200;
const VH = 800;
const PAD = 10;
// The popup box in VIEWPORT coordinates (result.top is a DOCUMENT coordinate).
function viewportBox(result, scrollTop) {
const topVp = result.top - scrollTop;
return { topVp, bottomVp: topVp + (result.maxHeight || 0) };
}
function assertFullyVisible(result, scrollTop, label) {
const { topVp, bottomVp } = viewportBox(result, scrollTop);
assert.ok(topVp >= 0, `${label}: top ${topVp} must be >= 0 (in viewport)`);
assert.ok(
bottomVp <= VH + 0.001,
`${label}: bottom ${bottomVp} must be <= viewportHeight ${VH}`,
);
}
// --- POSITIVE: fully visible regardless of scroll / opener position ---------
test('#5667: low opener on a scrolled page stays fully within the viewport', () => {
const scrollTop = 1000;
// Opener is near the BOTTOM of the viewport (viewport y ~= 720), so its
// document top is 1000 + 720.
const result = computePopupOffset({
viewportWidth: VW,
viewportHeight: VH,
scrollTop,
opener: { top: scrollTop + 720, left: 300, height: 24 },
popupName: 'editCardDueDatePopup',
});
assertFullyVisible(result, scrollTop, 'low+scrolled');
// A low opener has more room above, so it opens upward (bottom is at/above the opener).
assert.ok(result.top - scrollTop < 720, 'should open above the low opener');
});
test('#5667: opener near the top of a scrolled page does not overflow the top', () => {
const scrollTop = 1000;
const result = computePopupOffset({
viewportWidth: VW,
viewportHeight: VH,
scrollTop,
opener: { top: scrollTop + 40, left: 300, height: 24 },
popupName: 'editCardDueDatePopup',
});
assertFullyVisible(result, scrollTop, 'top+scrolled');
});
test('opener in the upper half opens below and stays visible', () => {
const result = computePopupOffset({
viewportWidth: VW,
viewportHeight: VH,
scrollTop: 0,
opener: { top: 100, left: 300, height: 24 },
popupName: 'editCardDueDatePopup',
});
assert.strictEqual(result.top, 124, 'opens just below the opener (100+24)');
assertFullyVisible(result, 0, 'upper-half');
});
test('scrollLeft keeps the popup within the viewport horizontally', () => {
const result = computePopupOffset({
viewportWidth: VW,
viewportHeight: VH,
scrollTop: 0,
scrollLeft: 500,
opener: { top: 100, left: 500 + 1195, height: 24 }, // far right in viewport
popupName: 'editCardDueDatePopup',
});
const leftVp = result.left - 500;
const popupWidth = Math.min(380, VW * 0.55);
assert.ok(leftVp >= PAD, 'left within viewport');
assert.ok(leftVp + popupWidth <= VW - PAD + 0.001, 'right edge within viewport');
});
// --- Behaviour parity when NOT scrolled (no regression) ---------------------
test('unscrolled output equals the pre-fix behaviour (below case)', () => {
const r = computePopupOffset({
viewportWidth: VW, viewportHeight: VH, scrollTop: 0,
opener: { top: 200, left: 300, height: 24 }, popupName: 'x',
});
// Old code: top = openerBottom = 224; maxHeight = min(spaceBelow, 0.8*VH).
const spaceBelow = VH - 224 - PAD;
assert.strictEqual(r.top, 224);
assert.strictEqual(r.maxHeight, Math.min(spaceBelow, VH * 0.8));
});
test('special popups: cardDetails / no-opener / admin edit', () => {
const cd = computePopupOffset({ viewportWidth: VW, viewportHeight: VH, scrollTop: 0, popupName: 'cardDetailsPopup' });
assert.strictEqual(cd.top, 0);
assert.strictEqual(cd.maxHeight, VH);
const none = computePopupOffset({ viewportWidth: VW, viewportHeight: VH, scrollTop: 0, opener: null, popupName: 'x' });
assert.deepStrictEqual(none, { left: PAD, top: PAD, maxHeight: VH - PAD * 2 });
const mini = computePopupOffset({ viewportWidth: VW, viewportHeight: VH, isMiniScreen: true, popupName: 'x' });
assert.deepStrictEqual(mini, { left: 0, top: 0 });
});
// --- NEGATIVE / the actual bug: the OLD formula overflowed when scrolled -----
test('#5667 regression: the OLD document-coord formula placed it OFF-SCREEN', () => {
const scrollTop = 1000;
const opener = { top: scrollTop + 40, left: 300, height: 24 }; // near viewport top
// Reproduce the pre-fix math (document offset mixed with viewport height).
const openerTop = opener.top;
const openerBottom = opener.top + opener.height;
const spaceBelow = VH - openerBottom - PAD;
const spaceAbove = openerTop - PAD;
const preferBelow = spaceBelow >= spaceAbove;
let oldTop, oldMax;
if (preferBelow) {
oldMax = Math.max(0, Math.min(spaceBelow, VH * 0.8));
oldTop = openerBottom;
} else {
oldMax = Math.max(0, Math.min(spaceAbove, VH * 0.8));
oldTop = Math.max(PAD, openerTop - oldMax);
}
const oldTopVp = oldTop - scrollTop;
// The old popup's TOP edge is far above the visible viewport (negative) — the bug.
assert.ok(oldTopVp < 0, `old top ${oldTopVp} should be off-screen (the bug)`);
// The fixed function keeps it on-screen for the exact same inputs.
const fixed = computePopupOffset({
viewportWidth: VW, viewportHeight: VH, scrollTop, opener, popupName: 'editCardDueDatePopup',
});
assertFullyVisible(fixed, scrollTop, 'fixed');
});
test('maxHeight is never negative for an opener above the viewport', () => {
const scrollTop = 1000;
const result = computePopupOffset({
viewportWidth: VW, viewportHeight: VH, scrollTop,
opener: { top: scrollTop - 500, left: 300, height: 24 }, // scrolled past, above viewport
popupName: 'editCardDueDatePopup',
});
assert.ok(result.maxHeight >= 0, 'maxHeight must never be negative');
});
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`);