mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 09:40:20 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1207b573b5 | ||
|
|
a26295a2fb | ||
|
|
b2da9ac8d4 | ||
|
|
53dcf7379d | ||
|
|
dfa6c78d4f | ||
|
|
795850ed05 | ||
|
|
9ef7f4a07a | ||
|
|
cb2394a95d | ||
|
|
ac368de06a | ||
|
|
b26494766f | ||
|
|
68ab63c7fe | ||
|
|
e3e71b254a | ||
|
|
fc4eadedc9 | ||
|
|
9dca403782 | ||
|
|
dd90995db7 | ||
|
|
f8e31745d8 |
No files matched your search
+112
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'))) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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];
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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,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="<p>The REST API allows you to control and extend Wekan with ease.</p>
|
||||
" 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="<p>The REST API allows you to control and extend Wekan with ease.</p>
|
||||
<p>If you are an end-user and not a dev or a tester, <a href="https://github.com/wekan/wekan/issues/new">create an issue</a> to request new APIs.</p>
|
||||
<blockquote>
|
||||
<p>All API calls in the documentation are made using <code>curl</code>. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.</p>
|
||||
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
@@ -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`);
|
||||
@@ -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`);
|
||||
@@ -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`);
|
||||
@@ -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`);
|
||||
@@ -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`);
|
||||
@@ -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`);
|
||||
Reference in new issue
Block a user