mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 21:44:59 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1207b573b5 | ||
|
|
a26295a2fb | ||
|
|
b2da9ac8d4 | ||
|
|
53dcf7379d | ||
|
|
dfa6c78d4f | ||
|
|
795850ed05 | ||
|
|
9ef7f4a07a | ||
|
|
cb2394a95d | ||
|
|
ac368de06a | ||
|
|
b26494766f | ||
|
|
68ab63c7fe | ||
|
|
e3e71b254a | ||
|
|
fc4eadedc9 | ||
|
|
9dca403782 | ||
|
|
dd90995db7 | ||
|
|
f8e31745d8 | ||
|
|
736d308860 | ||
|
|
9eca7a3941 | ||
|
|
af436e16c4 | ||
|
|
b1b414e850 | ||
|
|
57da3885a2 | ||
|
|
cb9c897309 | ||
|
|
81f07baeb8 | ||
|
|
001c258f96 | ||
|
|
9d77b9d7b7 | ||
|
|
2242da4176 | ||
|
|
0a3a28ef29 | ||
|
|
dcf5aa65b0 | ||
|
|
dd3be179ca | ||
|
|
bc1f149ea3 | ||
|
|
0dd11ea573 | ||
|
|
5feb83d919 | ||
|
|
8980668686 | ||
|
|
0d4e48ddd6 | ||
|
|
a01b175895 | ||
|
|
83f63c9efa | ||
|
|
3b031bd901 |
No files matched your search
@@ -800,7 +800,20 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install snapcraft
|
||||
run: sudo snap install snapcraft --classic
|
||||
# The Snap Store intermittently rejects the install with
|
||||
# 'error: cannot install "snapcraft": too many requests' (a 429-style
|
||||
# rate limit) that instantly fails the whole snap job before any build.
|
||||
# Retry a few times so one transient store throttle does not fail the
|
||||
# release (same spirit as the remote-build retry below).
|
||||
run: |
|
||||
set -uo pipefail
|
||||
for i in 1 2 3 4 5; do
|
||||
sudo snap install snapcraft --classic && exit 0
|
||||
echo "snapcraft install attempt $i/5 failed (Snap Store throttle?); retrying in 30s..."
|
||||
sleep 30
|
||||
done
|
||||
echo "::error::snapcraft install failed after 5 attempts (Snap Store rate limit)."
|
||||
exit 1
|
||||
|
||||
- name: Provide Launchpad credentials for remote-build
|
||||
env:
|
||||
|
||||
+228
-2
@@ -30,8 +30,6 @@ Versions:
|
||||
Carried to a future release — investigated this session but not finished, with findings recorded for whoever picks
|
||||
them up next.
|
||||
|
||||
- **Need a runtime reproduction before a safe fix:**
|
||||
[#5730](https://github.com/wekan/wekan/issues/5730) (board vanished after adding a user — no logs/repro).
|
||||
- **Need specific infrastructure we cannot reproduce/verify here (left for environment owners):**
|
||||
[#4560](https://github.com/wekan/wekan/issues/4560) (LDAP→OIDC), [#3707](https://github.com/wekan/wekan/issues/3707)
|
||||
& [#3700](https://github.com/wekan/wekan/issues/3700) (LDAP), [#3575](https://github.com/wekan/wekan/issues/3575)
|
||||
@@ -41,6 +39,234 @@ 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:
|
||||
|
||||
- **[Fix "Internal Server Error" when signing up despite the account being created](https://github.com/wekan/wekan/commit/b1b414e850c5494dd3849f8dab6db44fecef0196)**:
|
||||
Registering a new account showed a red "Internal server error" on the sign-up form even
|
||||
though the account was created and could sign in — which typically happens when SMTP is not
|
||||
configured. Root cause: useraccounts' `ATCreateUserServer` creates the account and then
|
||||
calls `Accounts.sendVerificationEmail()` (because `sendVerificationEmail: true`); when SMTP
|
||||
is missing/misconfigured that send throws **after** the user row is inserted, and with no
|
||||
try/catch the exception leaves the createUser method as an opaque HTTP 500. The verification
|
||||
email is best-effort at sign-up, so `Accounts.sendVerificationEmail` is now wrapped to log
|
||||
and swallow a transport failure — registration completes and redirects to sign-in — while an
|
||||
"already verified" error is re-thrown so the resend-verification flow still reports it. This
|
||||
mirrors the [#5706](https://github.com/wekan/wekan/issues/5706) reset-password hardening.
|
||||
Covered by `tests/verificationEmail.test.cjs`.
|
||||
Thanks to Firas-Git and xet7.
|
||||
|
||||
- **[Fix can not add members to a Linked Card](https://github.com/wekan/wekan/commit/cb9c8973092df5aa3112d3d59e3da4d8793c628b)**:
|
||||
A **linked card** (created by "Link card to this card") is only a placeholder on the board
|
||||
that links it — its members are stored on the **real card** it points at (`linkedId`), which
|
||||
lives on another board, and `Card.getMembers()` / `assignMember()` / `unassignMember()`
|
||||
already read and write that real card. But the member **picker** listed the members of the
|
||||
board you were *viewing* the linked card on, not the board the real card lives on. So on a
|
||||
board that links a card from another board, the picker offered the wrong set of members and
|
||||
toggling them did not behave as a consistent add/remove — "can not add members to the linked
|
||||
card". The picker now resolves a linked card to its real card's board and offers *that*
|
||||
board's active members (matching where the membership is actually stored), falling back to
|
||||
the current board for normal cards or when the real card isn't loaded yet. Extracted the
|
||||
target-card/target-board resolution into `models/lib/linkedCardMembers.js`, covered by
|
||||
`tests/linkedCardMembers.test.cjs`.
|
||||
Thanks to ITT5 and xet7.
|
||||
|
||||
- **[Fix can't search numbers in custom fields](https://github.com/wekan/wekan/commit/001c258f967caa502bf1d1ebb147b8506596f4bf)**:
|
||||
Searching a board for the value of a **number** or **currency** custom field (e.g. a
|
||||
transaction number `2025001`, or a currency amount `123`) found nothing. Those field types
|
||||
store their value as a JS **Number** (the inputs save `parseInt(...)` / `Number(...)`), but
|
||||
`Board.searchCards()` only matched custom fields with `{ value: <regex> }` — and a MongoDB /
|
||||
Minimongo regex only matches **string** values, so it silently skipped every numeric custom
|
||||
field. The search now also adds an exact numeric-equality clause when the term is a plain
|
||||
number (a comma is accepted as a decimal separator, matching the currency input), so numeric
|
||||
custom fields match too; text/title/description matching is unchanged. Card search runs
|
||||
against Minimongo on the client, which — like MongoDB — cannot regex a numeric field, so
|
||||
equality is the correct cross-environment match. Covered by `tests/cardSearch.test.cjs`.
|
||||
Thanks to MarcusDger and xet7.
|
||||
|
||||
- **[Fix "Removed nonexistent document" crash during notification_cleanup](https://github.com/wekan/wekan/commit/2242da4176edcd7ed6bbdd779fe2bafab8457b7c)**:
|
||||
The scheduled notification-tray cleanup logged `Exception in removed observeChanges
|
||||
callback: Error: Removed nonexistent document …`. The crash itself came from the old
|
||||
`cottz:publish-relations` package (since replaced by `reywood:publish-composite`), but the
|
||||
cleanup that provoked it still fired one **un-awaited** `removeNotification()` per expired
|
||||
notification — a separate `Users.update` `$pull` each time — so a user with K stale
|
||||
notifications produced K writes to the Users collection, and every publication that
|
||||
republishes user documents re-ran its observers K times in quick bursts (the churn that
|
||||
surfaced the removed-document error), while any rejected write went unhandled. The cleanup
|
||||
now scans only users that have notifications and prunes each user's stale notifications in a
|
||||
single awaited `$pull … $in`. It also removes an activity's notifications only when *every*
|
||||
entry for that activity is read and past its removal age (so a freshly re-created unread
|
||||
notification sharing an activity id is not dropped), and guards missing/invalid `read`
|
||||
timestamps. Covered by `tests/notificationCleanup.test.cjs`.
|
||||
Thanks to xet7.
|
||||
|
||||
- **[Fix impossible to select another board in rules](https://github.com/wekan/wekan/issues/5698)**:
|
||||
In the IFTTT-Rules "Move card to the board" and "Link card to the board" actions, the
|
||||
board dropdown was empty for some users — the current board included — while colleagues
|
||||
in the same company could pick boards normally. Root cause: the dropdown filtered the
|
||||
(already access-scoped) client cache with `'members.userId': me`, i.e. it only kept boards
|
||||
where the user has a *direct* member entry. A user who reaches a board through an
|
||||
Organization, Team or email-domain share — but is not listed individually in
|
||||
`board.members` — matched nothing, so the whole selector came up empty. The dropdown now
|
||||
filters by the same visibility rule as `Boards.userBoards()` (public OR active member OR
|
||||
active org OR active team OR active domain), so org/team/domain-shared boards appear too,
|
||||
while archived boards, template containers, the user's templates board and internal helper
|
||||
boards stay excluded.
|
||||
Thanks to Augustin356 and xet7.
|
||||
|
||||
- **[Fix board disappeared after adding another user](https://github.com/wekan/wekan/commit/933aad87fca6fc8b17efce577127e0b1fddf17f2)**:
|
||||
A board admin adding another user could make the whole board silently vanish from a
|
||||
user's board list, with no archive and "nothing out of order" in the logs. Root cause:
|
||||
the `setBoardTeams` server method (used by the board Teams/Members management popups)
|
||||
blindly overwrote the board's entire `members` array with a snapshot sent by the client.
|
||||
When that client's board document was stale — e.g. a member had just been added via
|
||||
`inviteUserToBoard` on the server and the change had not yet propagated to the client —
|
||||
the overwrite dropped members, and in the worst case the board's own admin, so the board
|
||||
no longer matched the board-list publication (which requires an active membership) and
|
||||
disappeared. Because a wholesale `$set: { members }` never passes through
|
||||
`foreachRemovedMember()`, no `removeBoardMember` activity was logged and no
|
||||
card/watcher/star cleanup ran, which is why the logs looked normal. `setBoardTeams` now
|
||||
reconciles against the authoritative server-side members instead of trusting the client
|
||||
snapshot: it never drops an active admin, keeps every existing member the client still
|
||||
lists, adds the members the client introduces, and only removes non-admin members the
|
||||
client explicitly omitted (an intentional team-leave) — logging and cleaning up each such
|
||||
removal so it is auditable rather than silent.
|
||||
Thanks to DVNBLMHC and xet7.
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v9.77 2026-07-06 WeKan ® release
|
||||
|
||||
This release adds the following updates:
|
||||
|
||||
- Updated Code of Conduct.
|
||||
[Part 1](https://github.com/wekan/wekan/commit/209cb8b43d84f7382d181a9f5d1e96ff32f2634d),
|
||||
[Part 2](https://github.com/wekan/wekan/commit/574dd5bedd4f7c584b0fbbfaf57df86f286607fa).
|
||||
Thanks to xet7.
|
||||
|
||||
- **[Retry snapcraft install in the release-all.yml snap job](https://github.com/wekan/wekan/commit/0d4e48ddd6b9ee086f0aa10d9f7c9893fe1637e4)**:
|
||||
The v9.76 release's `snap` job failed at `sudo snap install snapcraft --classic` with
|
||||
`error: cannot install "snapcraft": too many requests` — a transient Snap Store rate limit
|
||||
(429-style throttle) that instantly failed the whole job before any build ran. The install now
|
||||
retries up to 5 times with 30s backoff, so one store throttle no longer fails the release
|
||||
(matching the `remote-build` retry already in the same job).
|
||||
Thanks to xet7.
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v9.76 2026-07-06 WeKan ® release
|
||||
|
||||
This release adds the following fixes:
|
||||
|
||||
+1
-1
@@ -2,10 +2,10 @@
|
||||
|
||||
For all code at WeKan GitHub Organization https://github.com/wekan
|
||||
|
||||
- For pull requests, mention only those participants that are **human**.
|
||||
- All code in pull requests need to have permission already to add it to WeKan with MIT license, and will become MIT license.
|
||||
- For any dependencies, permissive licenses like https://copyfree.org are preferred
|
||||
- For anything currently that is non-permissive (like GPL, AGPL, SSPL), those will be replaced with permissive-licensed alternatives
|
||||
- Any intelligence allowed: Human, AI or any other. No discrimination, please have world peace. Mentioning participants optional.
|
||||
- All code is checked and validated by xet7 and many different security AI. If code is real working fix or feature, it usually will be merged.
|
||||
- All known vulnerabilities have been fixed at newest WeKan, please upgrade https://wekan.fi/upgrade/
|
||||
- There is no known backdoors. If WeKan is installed by someone else, first registered user is Admin and has Admin Panel access.
|
||||
|
||||
+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.76
|
||||
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.76.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'))) {
|
||||
|
||||
@@ -51,6 +51,7 @@ import Lists from '/models/lists';
|
||||
import CardComments from '/models/cardComments';
|
||||
import { ALLOWED_COLORS } from '/config/const';
|
||||
import { uniqBy } from '/imports/lib/collectionHelpers';
|
||||
import { memberTargetBoardId } from '/models/lib/linkedCardMembers';
|
||||
import { UserAvatar } from '../users/userAvatar';
|
||||
import { Filter } from '/client/lib/filter';
|
||||
import { BoardSwimlaneListCardDialog } from '/client/lib/dialogWithBoardSwimlaneListCard';
|
||||
@@ -1657,7 +1658,25 @@ Template.cardLocationsPopup.events({
|
||||
});
|
||||
|
||||
const filterMembers = (filterTerm) => {
|
||||
let currBoard = Utils.getCurrentBoard();
|
||||
// #5676: for a linked card the members live on the REAL card it points at,
|
||||
// which may be on another board (that is what getMembers()/toggleMember() act
|
||||
// on). Offer that board's members instead of the viewing board's, so the
|
||||
// picker is consistent with where membership is actually stored. Falls back to
|
||||
// the current board for normal cards or when the real card isn't loaded yet.
|
||||
const card = getCurrentCardFromContext();
|
||||
let currBoard = null;
|
||||
if (card) {
|
||||
const boardId = memberTargetBoardId(card, id => ReactiveCache.getCard(id));
|
||||
if (boardId) {
|
||||
currBoard = ReactiveCache.getBoard(boardId);
|
||||
}
|
||||
}
|
||||
if (!currBoard) {
|
||||
currBoard = Utils.getCurrentBoard();
|
||||
}
|
||||
if (!currBoard) {
|
||||
return [];
|
||||
}
|
||||
let members = currBoard.activeMembers();
|
||||
|
||||
if (filterTerm) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,6 +4,7 @@ import Actions from '/models/actions';
|
||||
import Rules from '/models/rules';
|
||||
import Triggers from '/models/triggers';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { canSelectBoardInRules } from '/models/lib/rulesBoardSelector';
|
||||
|
||||
Template.boardActions.onCreated(function () {
|
||||
this.subscribe('boards');
|
||||
@@ -11,19 +12,30 @@ Template.boardActions.onCreated(function () {
|
||||
|
||||
Template.boardActions.helpers({
|
||||
boards() {
|
||||
const ret = ReactiveCache.getBoards(
|
||||
{
|
||||
archived: false,
|
||||
'members.userId': Meteor.userId(),
|
||||
_id: {
|
||||
$ne: ReactiveCache.getCurrentUser().getTemplatesBoardId(),
|
||||
},
|
||||
},
|
||||
// #5698: don't restrict to boards where the user is a DIRECT member — that
|
||||
// hid every board reached through an Organization / Team / email-domain
|
||||
// share (the reporter's whole board dropdown was empty, current board
|
||||
// included). Filter the already access-scoped client cache with the same
|
||||
// visibility rule as Boards.userBoards() so org/team/domain boards show too.
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (!user) return [];
|
||||
const ctx = {
|
||||
userId: user._id,
|
||||
orgIds: typeof user.orgIds === 'function' ? user.orgIds() : [],
|
||||
teamIds: typeof user.teamIds === 'function' ? user.teamIds() : [],
|
||||
emailDomains:
|
||||
typeof user.emailDomains === 'function' ? user.emailDomains() : [],
|
||||
};
|
||||
const templatesBoardId = user.getTemplatesBoardId();
|
||||
const boards = ReactiveCache.getBoards(
|
||||
{ archived: false },
|
||||
{
|
||||
sort: { sort: 1 /* boards default sorting */ },
|
||||
},
|
||||
);
|
||||
return ret;
|
||||
return boards.filter(board =>
|
||||
canSelectBoardInRules(board, ctx, templatesBoardId),
|
||||
);
|
||||
},
|
||||
|
||||
loadingBoardsLabel() {
|
||||
|
||||
@@ -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 };
|
||||
@@ -300,4 +300,19 @@ if (Meteor.isServer) {
|
||||
Accounts.sendResetPasswordEmail,
|
||||
(code, message) => new Meteor.Error(code, message),
|
||||
);
|
||||
|
||||
// #5672: "Internal Server Error When Signing Up Despite Successful Account
|
||||
// Creation". useraccounts' ATCreateUserServer creates the account and then
|
||||
// calls Accounts.sendVerificationEmail(); when SMTP is not configured that
|
||||
// send throws AFTER the user is inserted, so the createUser method returns a
|
||||
// raw HTTP 500 even though the account exists and can sign in. The
|
||||
// verification email is best-effort at sign-up, so swallow + log a transport
|
||||
// failure (registration then completes and redirects to sign-in). An
|
||||
// "already verified" error is re-thrown so ATResendVerificationEmail still
|
||||
// reports it. Unit-tested in tests/verificationEmail.test.cjs.
|
||||
const { wrapSendVerificationEmail } = require('/server/lib/verificationEmail');
|
||||
Accounts.sendVerificationEmail = wrapSendVerificationEmail(
|
||||
Accounts.sendVerificationEmail,
|
||||
message => console.warn(message),
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
|
||||
|
||||
Right click and download files 1-4:
|
||||
|
||||
1. [wekan-9.76-win64.zip](https://github.com/wekan/wekan/releases/download/v9.76/wekan-9.76-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.76-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');
|
||||
});
|
||||
|
||||
+4
-7
@@ -23,6 +23,7 @@ import { pullMemberById } from '/server/lib/removeMember';
|
||||
import getSlug from 'limax';
|
||||
import { findWhere, where, groupBy } from '/imports/lib/collectionHelpers';
|
||||
import { generateUniversalAttachmentUrl } from '/models/lib/universalUrlGenerator';
|
||||
import { buildCardSearchOr } from '/models/lib/cardSearch';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
const getTAPi18n = () => require('/imports/i18n').TAPi18n;
|
||||
|
||||
@@ -1618,13 +1619,9 @@ Boards.helpers({
|
||||
}
|
||||
const projection = { sort: { createdAt: -1 } };
|
||||
|
||||
const regex = new RegExp(term, 'i');
|
||||
|
||||
query.$or = [
|
||||
{ title: regex },
|
||||
{ description: regex },
|
||||
{ customFields: { $elemMatch: { value: regex } } },
|
||||
];
|
||||
// #5680: build the $or so numeric custom fields (number / currency, stored
|
||||
// as JS Numbers) match by value too — a regex alone only matches strings.
|
||||
query.$or = buildCardSearchOr(term);
|
||||
ret = ReactiveCache.getCards(query, projection);
|
||||
}
|
||||
return ret;
|
||||
|
||||
+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,51 @@
|
||||
'use strict';
|
||||
|
||||
// Pure helpers for board card search. Extracted from models/boards.js
|
||||
// (Board.searchCards) so the query it builds is unit-testable without Meteor.
|
||||
//
|
||||
// #5680 (can't search numbers in custom fields): "number" and "currency" custom
|
||||
// fields store their value as a JS Number (the inputs save `parseInt(...)` /
|
||||
// `Number(...)`), but the search only matched custom fields with
|
||||
// `{ value: <regex> }`. A MongoDB / Minimongo regex only matches STRING values,
|
||||
// so it silently skipped every numeric custom field — searching a card's
|
||||
// transaction number (e.g. 2025001) or currency amount (e.g. 123) never found
|
||||
// it. We add an exact numeric-equality clause when the term parses to a plain
|
||||
// finite number, so numeric custom fields match too. (Card search runs against
|
||||
// Minimongo on the client, which — like MongoDB — cannot regex a numeric field,
|
||||
// so equality is the correct cross-environment match rather than `$toString`.)
|
||||
|
||||
// Parse a search term into a Number the way the number/currency custom-field
|
||||
// inputs store their value (a comma is accepted as a decimal separator, matching
|
||||
// the currency input's `value.replace(',', '.')`). Returns null when the term is
|
||||
// not a plain number, so we never widen a text search into an accidental match.
|
||||
function parseNumericSearchTerm(term) {
|
||||
if (typeof term !== 'string') return null;
|
||||
const trimmed = term.trim();
|
||||
if (trimmed === '') return null;
|
||||
const normalized = trimmed.replace(',', '.');
|
||||
// Require a plain decimal number. This rejects things Number() would otherwise
|
||||
// coerce (Number('') === 0, Number('0x1f') === 31, Number('1e3') === 1000) so
|
||||
// only genuine numeric terms add the numeric clause.
|
||||
if (!/^[+-]?(\d+(\.\d+)?|\.\d+)$/.test(normalized)) return null;
|
||||
const n = Number(normalized);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
// Build the `$or` clause array for a board card search on `term`. `term` is
|
||||
// expected to be a trimmed, non-empty string (Board.searchCards guards that).
|
||||
function buildCardSearchOr(term) {
|
||||
const regex = new RegExp(term, 'i');
|
||||
const or = [
|
||||
{ title: regex },
|
||||
{ description: regex },
|
||||
{ customFields: { $elemMatch: { value: regex } } },
|
||||
];
|
||||
const numeric = parseNumericSearchTerm(term);
|
||||
if (numeric !== null) {
|
||||
// #5680: also match numeric custom fields (number / currency) by value.
|
||||
or.push({ customFields: { $elemMatch: { value: numeric } } });
|
||||
}
|
||||
return or;
|
||||
}
|
||||
|
||||
module.exports = { parseNumericSearchTerm, buildCardSearchOr };
|
||||
@@ -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,48 @@
|
||||
'use strict';
|
||||
|
||||
// Pure helpers for resolving which card / board a (possibly linked) card's
|
||||
// member changes act on. Extracted from the card member popup so the choice is
|
||||
// unit-testable without Meteor.
|
||||
//
|
||||
// #5676 (can not add members to a Linked Card): a linked card
|
||||
// (type 'cardType-linkedCard') is only a placeholder on the board that links it —
|
||||
// its members are stored on the REAL card it points at (`linkedId`), which lives
|
||||
// on another board. Card.getMembers() / assignMember() / unassignMember() already
|
||||
// read and write that real card, but the member PICKER listed the *viewing*
|
||||
// board's members (Utils.getCurrentBoard()). So on a board that links a card from
|
||||
// another board, the picker offered members that don't belong where the
|
||||
// membership is actually stored, and toggling them did not behave as a consistent
|
||||
// add/remove. The picker must instead offer the members of the board the real
|
||||
// card lives on — the same board whose membership getMembers() reflects.
|
||||
|
||||
const TYPE_LINKED_CARD = 'cardType-linkedCard';
|
||||
const TYPE_LINKED_BOARD = 'cardType-linkedBoard';
|
||||
|
||||
// The card id that a member add/remove must target: the real card behind a link,
|
||||
// otherwise the card itself. Returns null when there is no usable id.
|
||||
function memberTargetCardId(card) {
|
||||
if (!card) return null;
|
||||
if (card.type === TYPE_LINKED_CARD && card.linkedId) return card.linkedId;
|
||||
return card._id || null;
|
||||
}
|
||||
|
||||
// The board id whose active members are valid for this card's membership.
|
||||
// `resolveCard(id)` returns the card doc for a given id and is used to hop a
|
||||
// linked card to its real card's board. Returns null when it can't be resolved
|
||||
// (e.g. the real card isn't loaded yet), so the caller can fall back.
|
||||
function memberTargetBoardId(card, resolveCard) {
|
||||
if (!card) return null;
|
||||
if (card.type === TYPE_LINKED_CARD && card.linkedId) {
|
||||
const realCard =
|
||||
typeof resolveCard === 'function' ? resolveCard(card.linkedId) : null;
|
||||
return realCard ? realCard.boardId || null : null;
|
||||
}
|
||||
if (card.type === TYPE_LINKED_BOARD && card.linkedId) {
|
||||
// A linked-board card's members are the linked board's own members, so the
|
||||
// linkedId *is* the board id whose members are valid.
|
||||
return card.linkedId;
|
||||
}
|
||||
return card.boardId || null;
|
||||
}
|
||||
|
||||
module.exports = { memberTargetCardId, memberTargetBoardId };
|
||||
@@ -0,0 +1,61 @@
|
||||
'use strict';
|
||||
|
||||
// Pure helper for the scheduled notification-tray cleanup. Extracted from
|
||||
// server/models/users.js so the "which notifications are stale" decision is
|
||||
// unit-testable without Meteor.
|
||||
//
|
||||
// #5685 ("Exception in removed observeChanges callback: Error: Removed
|
||||
// nonexistent document" during notification_cleanup): the cleanup used to fire
|
||||
// one un-awaited `removeNotification()` per expired notification. Each was a
|
||||
// separate `Users.update` `$pull`, so a user with K stale notifications produced
|
||||
// K writes to the Users collection — and every publication that republishes user
|
||||
// documents (board members, notification relations, …) re-ran its observers K
|
||||
// times in quick succession. Under the old cottz:publish-relations that churn is
|
||||
// what surfaced the "Removed nonexistent document" crash. Collapsing a user's
|
||||
// stale notifications into a SINGLE awaited `$pull … $in` removes that churn (and
|
||||
// fixes the un-awaited async / swallowed-rejection defect).
|
||||
//
|
||||
// It also removes an activity's notifications only when EVERY entry for that
|
||||
// activity is read AND past its removal age, so a freshly re-created unread
|
||||
// notification that happens to share an activity id is not pulled by mistake.
|
||||
// Entries with a missing/invalid `read` timestamp are treated as not-yet-removable.
|
||||
//
|
||||
// Returns a de-duplicated array of activity ids safe to `$pull` for this user.
|
||||
function expiredNotificationActivityIds(notifications, removeAgeDays, now) {
|
||||
if (!Array.isArray(notifications)) return [];
|
||||
|
||||
const nowMs = (now instanceof Date ? now : new Date(now)).getTime();
|
||||
if (Number.isNaN(nowMs)) return [];
|
||||
|
||||
// activity id -> { total, removable }
|
||||
const perActivity = new Map();
|
||||
|
||||
for (const notification of notifications) {
|
||||
if (!notification || !notification.activity) continue;
|
||||
const activity = notification.activity;
|
||||
const rec = perActivity.get(activity) || { total: 0, removable: 0 };
|
||||
rec.total += 1;
|
||||
|
||||
if (notification.read) {
|
||||
const removeDate = new Date(notification.read);
|
||||
if (!Number.isNaN(removeDate.getTime())) {
|
||||
removeDate.setDate(removeDate.getDate() + removeAgeDays);
|
||||
if (removeDate.getTime() <= nowMs) {
|
||||
rec.removable += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
perActivity.set(activity, rec);
|
||||
}
|
||||
|
||||
const ids = [];
|
||||
for (const [activity, rec] of perActivity) {
|
||||
if (rec.total > 0 && rec.removable === rec.total) {
|
||||
ids.push(activity);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
module.exports = { expiredNotificationActivityIds };
|
||||
@@ -0,0 +1,72 @@
|
||||
'use strict';
|
||||
|
||||
// Pure helper that reconciles a board's `members` array for the setBoardTeams
|
||||
// method. Extracted from server/models/boards.js so it is unit-testable without
|
||||
// Meteor.
|
||||
//
|
||||
// #5730 (board disappeared after adding another user): setBoardTeams used to
|
||||
// blindly overwrite board.members with the array sent by the client. When that
|
||||
// client's board document was stale — e.g. a member had just been added via
|
||||
// inviteUserToBoard on the server and the change had not yet propagated — the
|
||||
// overwrite silently dropped members, and in the worst case the board's own
|
||||
// admin, which made the whole board vanish from that user's board list (the
|
||||
// board-list publication requires an ACTIVE membership). And because a wholesale
|
||||
// `$set: { members }` never passes through foreachRemovedMember(), no
|
||||
// removeBoardMember activity was logged, so "the logs look normal".
|
||||
//
|
||||
// This reconciles against the AUTHORITATIVE server-side members instead of
|
||||
// trusting the client snapshot:
|
||||
// - never drops an active admin (this is what made the board disappear),
|
||||
// - keeps every existing member the client still lists (using the existing
|
||||
// server entry, so a stale client cannot silently change permissions),
|
||||
// - adds the members the client introduces (team-derived users),
|
||||
// - reports the non-admin members the client explicitly omitted as removed, so
|
||||
// the caller can log + clean up each intentional team-leave removal instead
|
||||
// of dropping them silently.
|
||||
//
|
||||
// Returns { members, removedMemberIds }.
|
||||
function reconcileBoardTeamMembers(authoritativeMembers = [], clientMembers = []) {
|
||||
const authoritative = Array.isArray(authoritativeMembers)
|
||||
? authoritativeMembers
|
||||
: [];
|
||||
const client = Array.isArray(clientMembers) ? clientMembers : [];
|
||||
|
||||
const clientById = new Map(client.map(m => [m.userId, m]));
|
||||
const authoritativeById = new Map(authoritative.map(m => [m.userId, m]));
|
||||
|
||||
const members = [];
|
||||
const removedMemberIds = [];
|
||||
|
||||
for (const existing of authoritative) {
|
||||
const isActiveAdmin = !!existing.isActive && !!existing.isAdmin;
|
||||
if (clientById.has(existing.userId) || isActiveAdmin) {
|
||||
// Keep the authoritative entry. Even if the client still lists this member
|
||||
// we deliberately reuse the server entry so a stale client cannot silently
|
||||
// change a member's permissions through this team-management path.
|
||||
members.push(existing);
|
||||
} else {
|
||||
removedMemberIds.push(existing.userId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const member of client) {
|
||||
if (!authoritativeById.has(member.userId)) {
|
||||
members.push({
|
||||
userId: member.userId,
|
||||
isAdmin: !!member.isAdmin,
|
||||
isActive: member.isActive !== false,
|
||||
isNoComments: !!member.isNoComments,
|
||||
isCommentOnly: !!member.isCommentOnly,
|
||||
isWorker: !!member.isWorker,
|
||||
isNormalAssignedOnly: !!member.isNormalAssignedOnly,
|
||||
isCommentAssignedOnly: !!member.isCommentAssignedOnly,
|
||||
isReadOnly: !!member.isReadOnly,
|
||||
isReadAssignedOnly: !!member.isReadAssignedOnly,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { members, removedMemberIds };
|
||||
}
|
||||
|
||||
module.exports = { reconcileBoardTeamMembers };
|
||||
@@ -0,0 +1,53 @@
|
||||
'use strict';
|
||||
|
||||
// Pure helpers deciding which boards may be picked as a destination in the
|
||||
// IFTTT-Rules "move card to the board" / "link card to the board" selectors.
|
||||
// Extracted from client/components/rules/actions/boardActions.js so the choice
|
||||
// is unit-testable without Meteor.
|
||||
//
|
||||
// #5698 (Impossible to select other board in rules): the board dropdown used to
|
||||
// filter the (already access-scoped) client cache with `'members.userId': me`,
|
||||
// i.e. it only kept boards where the user has a DIRECT member entry. A user who
|
||||
// reaches a board through an Organization, Team or email-domain share — but is
|
||||
// not listed individually in board.members — got an EMPTY dropdown (not even the
|
||||
// current board showed up), while a colleague who was a direct member saw the
|
||||
// boards. This mirrors the board-list publication / Boards.userBoards() rule:
|
||||
// public OR active member OR active org OR active team OR active domain.
|
||||
|
||||
function hasActive(list, predicate) {
|
||||
return Array.isArray(list) && list.some(entry => entry && predicate(entry));
|
||||
}
|
||||
|
||||
// Does `board` match `ctx`'s visibility? ctx = { userId, orgIds, teamIds,
|
||||
// emailDomains } — the last three are plain id arrays (empty when absent).
|
||||
function boardVisibleToUserContext(board, ctx) {
|
||||
if (!board || !ctx) return false;
|
||||
if (board.permission === 'public') return true;
|
||||
if (hasActive(board.members, m => m.userId === ctx.userId && !!m.isActive)) {
|
||||
return true;
|
||||
}
|
||||
const inSet = (list, key, ids) =>
|
||||
Array.isArray(ids) &&
|
||||
hasActive(list, entry => !!entry.isActive && ids.includes(entry[key]));
|
||||
if (inSet(board.orgs, 'orgId', ctx.orgIds)) return true;
|
||||
if (inSet(board.teams, 'teamId', ctx.teamIds)) return true;
|
||||
if (inSet(board.domains, 'domain', ctx.emailDomains)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// May this board appear in the rules board selector for `ctx`?
|
||||
// Excludes archived boards, non-'board' types (e.g. template containers), the
|
||||
// user's own templates board, and internal caret-wrapped helper boards
|
||||
// (`^Subtasks^` etc.), then requires the board to be visible to the user.
|
||||
function canSelectBoardInRules(board, ctx, templatesBoardId) {
|
||||
if (!board || !ctx) return false;
|
||||
if (board.archived) return false;
|
||||
if (board.type && board.type !== 'board') return false;
|
||||
if (typeof board.title === 'string' && /^\^.*\^$/.test(board.title)) {
|
||||
return false;
|
||||
}
|
||||
if (templatesBoardId && board._id === templatesBoardId) return false;
|
||||
return boardVisibleToUserContext(board, ctx);
|
||||
}
|
||||
|
||||
module.exports = { canSelectBoardInRules, boardVisibleToUserContext };
|
||||
@@ -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.76.0",
|
||||
"version": "v9.80.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wekan",
|
||||
"version": "v9.76.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.76.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/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.76</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.76<!-- -->)</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.76","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.76
|
||||
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 = 976,
|
||||
appVersion = 980,
|
||||
# Increment this for every release.
|
||||
|
||||
appMarketingVersion = (defaultText = "9.76~2026-07-05"),
|
||||
appMarketingVersion = (defaultText = "9.80~2026-07-06"),
|
||||
# Human-readable presentation of the app version.
|
||||
|
||||
minUpgradableAppVersion = 0,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// verificationEmail.js
|
||||
//
|
||||
// Pure, Meteor-free helper hardening the sign-up verification-email send.
|
||||
// Extracted so it can be unit-tested in plain Node (see
|
||||
// tests/verificationEmail.test.cjs).
|
||||
//
|
||||
// #5672: "Internal Server Error When Signing Up Despite Successful Account
|
||||
// Creation". useraccounts' ATCreateUserServer method creates the account and
|
||||
// THEN calls `Accounts.sendVerificationEmail(userId, email)`. When SMTP is not
|
||||
// configured (no/invalid MAIL_URL / MAIL_FROM, or the mail server is
|
||||
// unreachable), that send throws AFTER the user row is already inserted, and the
|
||||
// exception propagates out of the createUser method as an opaque HTTP 500 — so
|
||||
// the user sees "Internal server error" even though the account was created and
|
||||
// can sign in.
|
||||
//
|
||||
// The verification email is best-effort at sign-up: the account already exists,
|
||||
// so a mail failure must not fail the registration. Wrap the sender so a send
|
||||
// failure is logged and swallowed (registration completes and redirects to
|
||||
// sign-in). An "already verified" error is re-thrown unchanged so the separate
|
||||
// "resend verification email" flow (ATResendVerificationEmail) can still report
|
||||
// it.
|
||||
|
||||
// True when the error is accounts-password signalling that the address is
|
||||
// already verified (there is nothing to send), as opposed to a mail-transport
|
||||
// failure. Kept broad but conservative: only the word "verified" qualifies.
|
||||
function isAlreadyVerifiedError(error) {
|
||||
const message = ((error && (error.reason || error.message)) || '')
|
||||
.toString()
|
||||
.toLowerCase();
|
||||
return message.includes('verified');
|
||||
}
|
||||
|
||||
// Wrap an Accounts.sendVerificationEmail-style sender so a transport failure at
|
||||
// sign-up is logged via `logWarning` and swallowed (resolves to undefined)
|
||||
// instead of bubbling up as an HTTP 500. Already-verified errors are re-thrown.
|
||||
// A non-function `originalSend` is returned unchanged.
|
||||
function wrapSendVerificationEmail(originalSend, logWarning) {
|
||||
if (typeof originalSend !== 'function') {
|
||||
return originalSend;
|
||||
}
|
||||
const warn = typeof logWarning === 'function' ? logWarning : () => {};
|
||||
return async function wrappedSendVerificationEmail(...args) {
|
||||
try {
|
||||
return await originalSend.apply(this, args);
|
||||
} catch (error) {
|
||||
// Preserve the "resend to an already-verified address" signal.
|
||||
if (isAlreadyVerifiedError(error)) {
|
||||
throw error;
|
||||
}
|
||||
warn(
|
||||
'[sendVerificationEmail] Could not send the verification email — is ' +
|
||||
'SMTP configured (MAIL_URL / MAIL_FROM)? The account was still ' +
|
||||
'created and can sign in. ' +
|
||||
((error && (error.message || error.reason)) || error),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { isAlreadyVerifiedError, wrapSendVerificationEmail };
|
||||
+50
-1
@@ -7,6 +7,7 @@ import { WekanCreator } from '/models/wekanCreator';
|
||||
import { Authentication } from '/server/authentication';
|
||||
import { sendJsonResult } from '/server/apiMiddleware';
|
||||
import { allowIsBoardAdmin, boardMemberRoleToFlags } from '/server/lib/utils';
|
||||
import { reconcileBoardTeamMembers } from '/models/lib/reconcileBoardTeamMembers';
|
||||
import { filterUserBoards } from '/server/lib/boardListFilter';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import Actions from '/models/actions';
|
||||
@@ -367,12 +368,60 @@ Meteor.methods({
|
||||
if (member.isReadAssignedOnly !== undefined) check(member.isReadAssignedOnly, Boolean);
|
||||
}
|
||||
|
||||
// #5730: setBoardTeams used to blindly overwrite board.members with the
|
||||
// client-supplied `membersArray`. When the caller's board document was stale
|
||||
// (e.g. a user had just been added via inviteUserToBoard on the server and
|
||||
// that change had not yet propagated to the caller's client), the overwrite
|
||||
// silently dropped members — and in the worst case the board's own admin —
|
||||
// which made the whole board vanish from that user's board list. Because a
|
||||
// wholesale `$set: { members }` never goes through foreachRemovedMember(),
|
||||
// no removeBoardMember activity was logged and no card/watcher/star cleanup
|
||||
// ran, so "the logs look normal and nothing seems to be out of order".
|
||||
//
|
||||
// Reconcile against the AUTHORITATIVE server-side members instead of trusting
|
||||
// the client snapshot (see reconcileBoardTeamMembers for the full rationale):
|
||||
// - never drop an active admin (this is what made the board disappear),
|
||||
// - keep every existing member the client still lists,
|
||||
// - add the members the client introduces (team-derived users),
|
||||
// - only remove non-admin members the client explicitly omitted (an
|
||||
// intentional team-leave), logging + cleaning up each such removal below
|
||||
// so it is auditable rather than silent.
|
||||
const { members: resultMembers, removedMemberIds } =
|
||||
reconcileBoardTeamMembers(board.members, membersArray);
|
||||
|
||||
await Boards.updateAsync(currBoardId, {
|
||||
$set: {
|
||||
members: membersArray,
|
||||
members: resultMembers,
|
||||
teams: boardTeamsArray,
|
||||
},
|
||||
});
|
||||
|
||||
// Run the same cleanup + audit trail that a normal member removal performs,
|
||||
// so team-leave removals are not silent (see comment above).
|
||||
for (const memberId of removedMemberIds) {
|
||||
await Cards.updateAsync(
|
||||
{ boardId: currBoardId },
|
||||
{ $pull: { members: memberId, watchers: memberId } },
|
||||
{ multi: true },
|
||||
);
|
||||
await Lists.updateAsync(
|
||||
{ boardId: currBoardId },
|
||||
{ $pull: { watchers: memberId } },
|
||||
{ multi: true },
|
||||
);
|
||||
if (!board.isPublic()) {
|
||||
await Users.updateAsync(memberId, {
|
||||
$pull: { 'profile.starredBoards': currBoardId },
|
||||
});
|
||||
}
|
||||
await Activities.insertAsync({
|
||||
userId,
|
||||
memberId,
|
||||
type: 'member',
|
||||
activityType: 'removeBoardMember',
|
||||
boardId: currBoardId,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async setBoardDomains(boardDomainsArray, currBoardId) {
|
||||
|
||||
+90
-11
@@ -21,6 +21,9 @@ import InviteToBoardRolesSettings from '/models/inviteToBoardRolesSettings';
|
||||
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 =
|
||||
@@ -831,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 } },
|
||||
@@ -861,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
|
||||
@@ -960,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({
|
||||
@@ -1214,17 +1226,35 @@ const runNotificationCleanup = async function runNotificationCleanup() {
|
||||
process.env.NOTIFICATION_TRAY_AFTER_READ_DAYS_BEFORE_REMOVE;
|
||||
const defaultRemoveAge = 2;
|
||||
const removeAge = parseInt(envRemoveAge, 10) || defaultRemoveAge;
|
||||
const now = new Date();
|
||||
|
||||
for (const user of await ReactiveCache.getUsers()) {
|
||||
if (!user.profile || !user.profile.notifications) continue;
|
||||
for (const notification of user.profile.notifications) {
|
||||
if (notification && notification.read) {
|
||||
const removeDate = new Date(notification.read);
|
||||
removeDate.setDate(removeDate.getDate() + removeAge);
|
||||
if (removeDate <= new Date()) {
|
||||
user.removeNotification(notification.activity);
|
||||
}
|
||||
}
|
||||
// #5685: only scan users that actually have notifications, and prune each
|
||||
// user's stale notifications in a SINGLE awaited `$pull … $in` instead of one
|
||||
// un-awaited `removeNotification()` per entry. That collapses K Users-collection
|
||||
// writes (and the K publication observer re-runs they triggered — the churn
|
||||
// behind the "Removed nonexistent document" crash) into one.
|
||||
const users = await ReactiveCache.getUsers({
|
||||
'profile.notifications': { $exists: true, $ne: [] },
|
||||
});
|
||||
for (const user of users) {
|
||||
const activityIds = expiredNotificationActivityIds(
|
||||
user.profile && user.profile.notifications,
|
||||
removeAge,
|
||||
now,
|
||||
);
|
||||
if (activityIds.length === 0) continue;
|
||||
try {
|
||||
await Users.updateAsync(user._id, {
|
||||
$pull: {
|
||||
'profile.notifications': { activity: { $in: activityIds } },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Notification cleanup: failed to prune notifications for user',
|
||||
user._id,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1828,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.76'
|
||||
version: '9.80'
|
||||
base: core26
|
||||
build-base: devel
|
||||
confinement: strict
|
||||
@@ -194,14 +194,14 @@ parts:
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
WEKAN_ZIP="wekan-9.76-${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.76/wekan-9.76-amd64.zip
|
||||
wget "https://github.com/wekan/wekan/releases/download/v9.76/${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,135 @@
|
||||
'use strict';
|
||||
|
||||
// Plain-Node unit test (no Meteor) for the board card-search query builder.
|
||||
// Run: node tests/cardSearch.test.cjs
|
||||
//
|
||||
// Regression guard for #5680 ("How can I search for numbers in user-defined
|
||||
// fields?"): number and currency custom fields store their value as a JS Number,
|
||||
// but the search only matched custom fields with a regex, and a Mongo/Minimongo
|
||||
// regex never matches numeric values — so searching a custom-field number found
|
||||
// nothing. buildCardSearchOr() must add an exact numeric-equality clause for
|
||||
// plain-number terms while leaving the string regex clauses intact, and must NOT
|
||||
// add one for non-numeric terms.
|
||||
|
||||
const assert = require('assert');
|
||||
const {
|
||||
parseNumericSearchTerm,
|
||||
buildCardSearchOr,
|
||||
} = require('../models/lib/cardSearch');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) {
|
||||
fn();
|
||||
passed += 1;
|
||||
console.log(' ok -', name);
|
||||
}
|
||||
|
||||
// A tiny Minimongo-like matcher so we can assert the built $or actually selects
|
||||
// (or rejects) representative card documents — closer to real behavior than just
|
||||
// eyeballing the query shape.
|
||||
function clauseMatches(clause, card) {
|
||||
if (clause.title) return clause.title.test(card.title || '');
|
||||
if (clause.description) return clause.description.test(card.description || '');
|
||||
if (clause.customFields) {
|
||||
const cond = clause.customFields.$elemMatch.value;
|
||||
const fields = card.customFields || [];
|
||||
return fields.some(f => {
|
||||
if (cond instanceof RegExp) {
|
||||
return typeof f.value === 'string' && cond.test(f.value);
|
||||
}
|
||||
return f.value === cond; // exact (numeric) equality
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const orMatches = (or, card) => or.some(c => clauseMatches(c, card));
|
||||
|
||||
// --- parseNumericSearchTerm --------------------------------------------------
|
||||
test('parseNumericSearchTerm parses integers and decimals', () => {
|
||||
assert.strictEqual(parseNumericSearchTerm('2025001'), 2025001);
|
||||
assert.strictEqual(parseNumericSearchTerm('123'), 123);
|
||||
assert.strictEqual(parseNumericSearchTerm('123.45'), 123.45);
|
||||
});
|
||||
|
||||
test('parseNumericSearchTerm accepts a comma decimal separator', () => {
|
||||
assert.strictEqual(parseNumericSearchTerm('123,00'), 123);
|
||||
assert.strictEqual(parseNumericSearchTerm('123,45'), 123.45);
|
||||
});
|
||||
|
||||
test('parseNumericSearchTerm rejects non-plain-numbers', () => {
|
||||
// things Number() would otherwise coerce, plus mixed text
|
||||
['', ' ', 'abc', '123abc', '1 2', '0x1f', '1e3', 'NaN', '12,00 €'].forEach(t => {
|
||||
assert.strictEqual(parseNumericSearchTerm(t), null, `should reject ${JSON.stringify(t)}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('parseNumericSearchTerm tolerates non-string input', () => {
|
||||
assert.strictEqual(parseNumericSearchTerm(123), null);
|
||||
assert.strictEqual(parseNumericSearchTerm(null), null);
|
||||
assert.strictEqual(parseNumericSearchTerm(undefined), null);
|
||||
});
|
||||
|
||||
// --- buildCardSearchOr: POSITIVE --------------------------------------------
|
||||
test('#5680: a numeric custom field is matched by a number term', () => {
|
||||
const or = buildCardSearchOr('2025001');
|
||||
const card = { title: 'Invoice', customFields: [{ value: 2025001 }] };
|
||||
assert.ok(orMatches(or, card), 'card with numeric custom field should match');
|
||||
});
|
||||
|
||||
test('#5680: a currency custom field (123) matches search "123"', () => {
|
||||
const or = buildCardSearchOr('123');
|
||||
const card = { title: 'X', customFields: [{ value: 123 }] };
|
||||
assert.ok(orMatches(or, card));
|
||||
});
|
||||
|
||||
test('#5680: comma-typed term "123,00" matches numeric value 123', () => {
|
||||
const or = buildCardSearchOr('123,00');
|
||||
const card = { title: 'X', customFields: [{ value: 123 }] };
|
||||
assert.ok(orMatches(or, card));
|
||||
});
|
||||
|
||||
test('string custom fields still match via regex (unchanged behavior)', () => {
|
||||
const or = buildCardSearchOr('123');
|
||||
const card = { title: 'X', customFields: [{ value: '123,00 €' }] };
|
||||
assert.ok(orMatches(or, card));
|
||||
});
|
||||
|
||||
test('title and description still match via regex', () => {
|
||||
const or = buildCardSearchOr('hello');
|
||||
assert.ok(orMatches(or, { title: 'Say HELLO there' }));
|
||||
assert.ok(orMatches(or, { description: 'a hello world card' }));
|
||||
});
|
||||
|
||||
test('the numeric clause is only appended for numeric terms', () => {
|
||||
assert.strictEqual(buildCardSearchOr('2025001').length, 4); // + numeric clause
|
||||
assert.strictEqual(buildCardSearchOr('hello').length, 3); // no numeric clause
|
||||
});
|
||||
|
||||
// --- buildCardSearchOr: NEGATIVE --------------------------------------------
|
||||
test('a different number does NOT match (exact equality, not substring)', () => {
|
||||
const or = buildCardSearchOr('202');
|
||||
const card = { title: 'X', customFields: [{ value: 2025001 }] };
|
||||
assert.ok(!orMatches(or, card), '202 must not match 2025001');
|
||||
});
|
||||
|
||||
test('numeric term does not match a numeric field of a different value', () => {
|
||||
const or = buildCardSearchOr('124');
|
||||
const card = { title: 'X', customFields: [{ value: 123 }] };
|
||||
assert.ok(!orMatches(or, card));
|
||||
});
|
||||
|
||||
test('regex clause never matches a numeric field value', () => {
|
||||
// Only the string-regex clauses (no numeric term) — a numeric field must not
|
||||
// be matched by a regex, which is the exact #5680 bug.
|
||||
const or = buildCardSearchOr('hello');
|
||||
const card = { customFields: [{ value: 123 }] };
|
||||
assert.ok(!orMatches(or, card));
|
||||
});
|
||||
|
||||
test('non-numeric term against a numeric-only card finds nothing', () => {
|
||||
const or = buildCardSearchOr('abc');
|
||||
const card = { title: 'Numbers', customFields: [{ value: 2025001 }] };
|
||||
assert.ok(!orMatches(or, card));
|
||||
});
|
||||
|
||||
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,96 @@
|
||||
'use strict';
|
||||
|
||||
// Plain-Node unit test (no Meteor) for the linked-card member target helpers.
|
||||
// Run: node tests/linkedCardMembers.test.cjs
|
||||
//
|
||||
// Regression guard for #5676 ("Bug: Can not add members to Linked Card"): a
|
||||
// linked card's members are stored on the REAL card it points at (on another
|
||||
// board), which is what getMembers()/assignMember() act on — but the member
|
||||
// picker used to list the *viewing* board's members. memberTargetBoardId() must
|
||||
// resolve a linked card to its real card's board (so the picker matches where
|
||||
// membership is stored), and memberTargetCardId() must resolve to the real card
|
||||
// id that member changes are written to. Normal cards and linked-board cards must
|
||||
// keep their own board.
|
||||
|
||||
const assert = require('assert');
|
||||
const {
|
||||
memberTargetCardId,
|
||||
memberTargetBoardId,
|
||||
} = require('../models/lib/linkedCardMembers');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) {
|
||||
fn();
|
||||
passed += 1;
|
||||
console.log(' ok -', name);
|
||||
}
|
||||
|
||||
// Real card on board A; a linked card on board B points at it.
|
||||
const realCard = { _id: 'realA', boardId: 'boardA', type: 'card' };
|
||||
const linkedCard = {
|
||||
_id: 'linkB',
|
||||
boardId: 'boardB',
|
||||
type: 'cardType-linkedCard',
|
||||
linkedId: 'realA',
|
||||
};
|
||||
const resolve = id => (id === 'realA' ? realCard : undefined);
|
||||
|
||||
// --- memberTargetCardId ------------------------------------------------------
|
||||
test('linked card resolves to the real card id for member writes', () => {
|
||||
assert.strictEqual(memberTargetCardId(linkedCard), 'realA');
|
||||
});
|
||||
|
||||
test('normal card targets its own id', () => {
|
||||
assert.strictEqual(memberTargetCardId(realCard), 'realA');
|
||||
});
|
||||
|
||||
test('memberTargetCardId is null-safe', () => {
|
||||
assert.strictEqual(memberTargetCardId(null), null);
|
||||
assert.strictEqual(memberTargetCardId({ type: 'card' }), null);
|
||||
});
|
||||
|
||||
// --- memberTargetBoardId: POSITIVE ------------------------------------------
|
||||
test('#5676: linked card resolves to the REAL card\'s board (not the viewing board)', () => {
|
||||
const boardId = memberTargetBoardId(linkedCard, resolve);
|
||||
assert.strictEqual(boardId, 'boardA');
|
||||
// and crucially NOT the board the linked card is viewed on
|
||||
assert.notStrictEqual(boardId, 'boardB');
|
||||
});
|
||||
|
||||
test('normal card uses its own board', () => {
|
||||
assert.strictEqual(memberTargetBoardId(realCard, resolve), 'boardA');
|
||||
});
|
||||
|
||||
test('linked-board card uses the linked board id itself', () => {
|
||||
const linkedBoardCard = {
|
||||
_id: 'lbc',
|
||||
boardId: 'boardB',
|
||||
type: 'cardType-linkedBoard',
|
||||
linkedId: 'boardX',
|
||||
};
|
||||
assert.strictEqual(memberTargetBoardId(linkedBoardCard, resolve), 'boardX');
|
||||
});
|
||||
|
||||
// --- memberTargetBoardId: NEGATIVE ------------------------------------------
|
||||
test('linked card whose real card is not loaded yet resolves to null (caller falls back)', () => {
|
||||
const orphan = { type: 'cardType-linkedCard', linkedId: 'missing' };
|
||||
assert.strictEqual(memberTargetBoardId(orphan, resolve), null);
|
||||
});
|
||||
|
||||
test('linked card with no resolver does not throw and returns null', () => {
|
||||
assert.strictEqual(memberTargetBoardId(linkedCard, undefined), null);
|
||||
});
|
||||
|
||||
test('a linked card must NOT fall back to the viewing board when unresolved', () => {
|
||||
// Guards the bug: returning boardB here would re-introduce the wrong-board
|
||||
// picker. Unresolved must be null so the caller decides the fallback.
|
||||
const orphan = { boardId: 'boardB', type: 'cardType-linkedCard', linkedId: 'missing' };
|
||||
assert.strictEqual(memberTargetBoardId(orphan, resolve), null);
|
||||
});
|
||||
|
||||
test('memberTargetBoardId is null-safe', () => {
|
||||
assert.strictEqual(memberTargetBoardId(null, resolve), null);
|
||||
assert.strictEqual(memberTargetBoardId({ type: 'card' }, resolve), null);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} tests passed`);
|
||||
@@ -0,0 +1,130 @@
|
||||
'use strict';
|
||||
|
||||
// Plain-Node unit test (no Meteor) for expiredNotificationActivityIds().
|
||||
// Run: node tests/notificationCleanup.test.cjs
|
||||
//
|
||||
// Regression guard for #5685 ("Exception in removed observeChanges callback:
|
||||
// Error: Removed nonexistent document" during notification_cleanup). The cleanup
|
||||
// now computes, per user, the set of activity ids whose notifications are all
|
||||
// read and past the removal age, then prunes them in a single `$pull … $in`.
|
||||
// This helper is that decision: it must select fully-expired activities, skip
|
||||
// unread / not-yet-aged / invalid-date / re-created ones, and de-duplicate.
|
||||
|
||||
const assert = require('assert');
|
||||
const {
|
||||
expiredNotificationActivityIds,
|
||||
} = require('../models/lib/notificationCleanup');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) {
|
||||
fn();
|
||||
passed += 1;
|
||||
console.log(' ok -', name);
|
||||
}
|
||||
|
||||
const NOW = new Date('2025-02-12T12:00:00Z');
|
||||
const daysAgo = n => new Date(NOW.getTime() - n * 24 * 60 * 60 * 1000);
|
||||
const REMOVE_AGE = 2; // days after "read" before a notification is removable
|
||||
|
||||
// --- POSITIVE: read + aged past the window is removable ---------------------
|
||||
test('a read notification older than the removal age is removed', () => {
|
||||
const ids = expiredNotificationActivityIds(
|
||||
[{ activity: 'a1', read: daysAgo(3) }],
|
||||
REMOVE_AGE,
|
||||
NOW,
|
||||
);
|
||||
assert.deepStrictEqual(ids, ['a1']);
|
||||
});
|
||||
|
||||
test('multiple expired activities are all returned, de-duplicated', () => {
|
||||
const ids = expiredNotificationActivityIds(
|
||||
[
|
||||
{ activity: 'a1', read: daysAgo(5) },
|
||||
{ activity: 'a2', read: daysAgo(10) },
|
||||
{ activity: 'a1', read: daysAgo(4) }, // same activity, also expired
|
||||
],
|
||||
REMOVE_AGE,
|
||||
NOW,
|
||||
);
|
||||
assert.deepStrictEqual(ids.sort(), ['a1', 'a2']);
|
||||
// de-duplicated: 'a1' appears once
|
||||
assert.strictEqual(ids.filter(x => x === 'a1').length, 1);
|
||||
});
|
||||
|
||||
test('exactly at the removal boundary is removable (<=)', () => {
|
||||
// read 2 days ago, removeAge 2 -> removeDate == now -> removable
|
||||
const ids = expiredNotificationActivityIds(
|
||||
[{ activity: 'edge', read: daysAgo(2) }],
|
||||
REMOVE_AGE,
|
||||
NOW,
|
||||
);
|
||||
assert.deepStrictEqual(ids, ['edge']);
|
||||
});
|
||||
|
||||
// --- NEGATIVE: things that must NOT be removed ------------------------------
|
||||
test('an unread notification (read=null) is never removed', () => {
|
||||
const ids = expiredNotificationActivityIds(
|
||||
[{ activity: 'a1', read: null }],
|
||||
REMOVE_AGE,
|
||||
NOW,
|
||||
);
|
||||
assert.deepStrictEqual(ids, []);
|
||||
});
|
||||
|
||||
test('a read notification still within the age window is kept', () => {
|
||||
const ids = expiredNotificationActivityIds(
|
||||
[{ activity: 'a1', read: daysAgo(1) }], // 1 day < 2 day window
|
||||
REMOVE_AGE,
|
||||
NOW,
|
||||
);
|
||||
assert.deepStrictEqual(ids, []);
|
||||
});
|
||||
|
||||
test('#5685: an activity with a re-created unread entry is NOT removed', () => {
|
||||
// Same activity: one read+expired entry, one fresh unread entry. Pulling by
|
||||
// activity id would wrongly drop the unread one, so the activity is kept.
|
||||
const ids = expiredNotificationActivityIds(
|
||||
[
|
||||
{ activity: 'a1', read: daysAgo(9) }, // old, expired
|
||||
{ activity: 'a1', read: null }, // re-notified, unread
|
||||
],
|
||||
REMOVE_AGE,
|
||||
NOW,
|
||||
);
|
||||
assert.deepStrictEqual(ids, []);
|
||||
});
|
||||
|
||||
test('a notification with an invalid read date is not removed', () => {
|
||||
const ids = expiredNotificationActivityIds(
|
||||
[{ activity: 'a1', read: 'not-a-date' }],
|
||||
REMOVE_AGE,
|
||||
NOW,
|
||||
);
|
||||
assert.deepStrictEqual(ids, []);
|
||||
});
|
||||
|
||||
test('entries without an activity id are ignored', () => {
|
||||
const ids = expiredNotificationActivityIds(
|
||||
[{ read: daysAgo(9) }, null, { activity: '', read: daysAgo(9) }],
|
||||
REMOVE_AGE,
|
||||
NOW,
|
||||
);
|
||||
assert.deepStrictEqual(ids, []);
|
||||
});
|
||||
|
||||
test('non-array / empty input yields an empty list without throwing', () => {
|
||||
assert.deepStrictEqual(expiredNotificationActivityIds(undefined, REMOVE_AGE, NOW), []);
|
||||
assert.deepStrictEqual(expiredNotificationActivityIds(null, REMOVE_AGE, NOW), []);
|
||||
assert.deepStrictEqual(expiredNotificationActivityIds([], REMOVE_AGE, NOW), []);
|
||||
});
|
||||
|
||||
test('an invalid `now` is handled defensively (no removals)', () => {
|
||||
const ids = expiredNotificationActivityIds(
|
||||
[{ activity: 'a1', read: daysAgo(9) }],
|
||||
REMOVE_AGE,
|
||||
new Date('nonsense'),
|
||||
);
|
||||
assert.deepStrictEqual(ids, []);
|
||||
});
|
||||
|
||||
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,139 @@
|
||||
'use strict';
|
||||
|
||||
// Plain-Node unit test (no Meteor) for reconcileBoardTeamMembers().
|
||||
// Run: node tests/reconcileBoardTeamMembers.test.cjs
|
||||
//
|
||||
// Regression guard for #5730 ("Board disappeared after adding another user"):
|
||||
// setBoardTeams used to blindly overwrite board.members with the array sent by
|
||||
// the client. When that client's board document was stale (e.g. a member had
|
||||
// just been added via inviteUserToBoard on the server), the overwrite silently
|
||||
// dropped members — and in the worst case the board's own admin — which made the
|
||||
// whole board vanish from that user's board list, with no removeBoardMember
|
||||
// activity logged. The reconciler must NEVER let a stale/partial client array
|
||||
// drop an active admin, and must report intentional non-admin removals so they
|
||||
// can be logged/cleaned up instead of vanishing silently.
|
||||
|
||||
const assert = require('assert');
|
||||
const {
|
||||
reconcileBoardTeamMembers,
|
||||
} = require('../models/lib/reconcileBoardTeamMembers');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) {
|
||||
fn();
|
||||
passed += 1;
|
||||
console.log(' ok -', name);
|
||||
}
|
||||
|
||||
const admin = {
|
||||
userId: 'admin',
|
||||
isAdmin: true,
|
||||
isActive: true,
|
||||
isNoComments: false,
|
||||
isCommentOnly: false,
|
||||
isWorker: false,
|
||||
};
|
||||
const bob = {
|
||||
userId: 'bob',
|
||||
isAdmin: false,
|
||||
isActive: true,
|
||||
isNoComments: false,
|
||||
isCommentOnly: false,
|
||||
isWorker: false,
|
||||
};
|
||||
|
||||
const ids = list => list.map(m => m.userId).sort();
|
||||
|
||||
// --- POSITIVE: adding a team's users keeps existing + appends new -----------
|
||||
test('adding a team user keeps the admin and appends the new user', () => {
|
||||
const { members, removedMemberIds } = reconcileBoardTeamMembers(
|
||||
[admin],
|
||||
[admin, { userId: 'carol', isAdmin: false, isActive: true }],
|
||||
);
|
||||
assert.deepStrictEqual(ids(members), ['admin', 'carol']);
|
||||
assert.deepStrictEqual(removedMemberIds, []);
|
||||
// The admin entry is preserved verbatim from the authoritative array.
|
||||
assert.strictEqual(members.find(m => m.userId === 'admin'), admin);
|
||||
// The new member gets fully-defaulted flags.
|
||||
const carol = members.find(m => m.userId === 'carol');
|
||||
assert.deepStrictEqual(carol, {
|
||||
userId: 'carol',
|
||||
isAdmin: false,
|
||||
isActive: true,
|
||||
isNoComments: false,
|
||||
isCommentOnly: false,
|
||||
isWorker: false,
|
||||
isNormalAssignedOnly: false,
|
||||
isCommentAssignedOnly: false,
|
||||
isReadOnly: false,
|
||||
isReadAssignedOnly: false,
|
||||
});
|
||||
});
|
||||
|
||||
// --- POSITIVE: leaving a team removes the intended non-admin member ---------
|
||||
test('leaving a team removes the omitted non-admin and reports it', () => {
|
||||
const { members, removedMemberIds } = reconcileBoardTeamMembers(
|
||||
[admin, bob],
|
||||
[admin],
|
||||
);
|
||||
assert.deepStrictEqual(ids(members), ['admin']);
|
||||
assert.deepStrictEqual(removedMemberIds, ['bob']);
|
||||
});
|
||||
|
||||
// --- POSITIVE: a stale client cannot change an existing member's permissions -
|
||||
test('existing member keeps its authoritative entry, not the client copy', () => {
|
||||
const clientBob = { ...bob, isAdmin: true }; // client tries to promote bob
|
||||
const { members } = reconcileBoardTeamMembers([admin, bob], [admin, clientBob]);
|
||||
const resultBob = members.find(m => m.userId === 'bob');
|
||||
assert.strictEqual(resultBob, bob); // server entry, still non-admin
|
||||
assert.strictEqual(resultBob.isAdmin, false);
|
||||
});
|
||||
|
||||
// --- NEGATIVE (regression #5730): stale client omits the admin --------------
|
||||
test('stale client omitting the admin does NOT drop the admin', () => {
|
||||
// Server truth is [admin, bob]; the client only knows about bob (its board
|
||||
// doc was stale). Blindly trusting it would drop the admin and the board would
|
||||
// vanish for them.
|
||||
const { members, removedMemberIds } = reconcileBoardTeamMembers(
|
||||
[admin, bob],
|
||||
[bob],
|
||||
);
|
||||
assert.ok(
|
||||
members.some(m => m.userId === 'admin' && m.isActive && m.isAdmin),
|
||||
'active admin must survive a stale client array',
|
||||
);
|
||||
assert.ok(
|
||||
!removedMemberIds.includes('admin'),
|
||||
'admin must never be reported as removed',
|
||||
);
|
||||
});
|
||||
|
||||
// --- NEGATIVE (regression #5730): empty/undefined client array (worst case) --
|
||||
test('empty client array still preserves the admin', () => {
|
||||
const { members, removedMemberIds } = reconcileBoardTeamMembers([admin, bob], []);
|
||||
assert.ok(members.some(m => m.userId === 'admin' && m.isActive && m.isAdmin));
|
||||
assert.ok(!removedMemberIds.includes('admin'));
|
||||
assert.deepStrictEqual(removedMemberIds, ['bob']);
|
||||
});
|
||||
|
||||
test('undefined args do not throw and yield empty result', () => {
|
||||
const { members, removedMemberIds } = reconcileBoardTeamMembers(undefined, undefined);
|
||||
assert.deepStrictEqual(members, []);
|
||||
assert.deepStrictEqual(removedMemberIds, []);
|
||||
});
|
||||
|
||||
// --- NEGATIVE (regression #5730): stale client tries to deactivate the admin -
|
||||
test('client cannot deactivate or demote the admin through this path', () => {
|
||||
const downgradedAdmin = { userId: 'admin', isAdmin: false, isActive: false };
|
||||
const { members, removedMemberIds } = reconcileBoardTeamMembers(
|
||||
[admin],
|
||||
[downgradedAdmin],
|
||||
);
|
||||
const resultAdmin = members.find(m => m.userId === 'admin');
|
||||
assert.strictEqual(resultAdmin, admin); // authoritative entry wins
|
||||
assert.strictEqual(resultAdmin.isActive, true);
|
||||
assert.strictEqual(resultAdmin.isAdmin, true);
|
||||
assert.deepStrictEqual(removedMemberIds, []);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} tests passed`);
|
||||
@@ -0,0 +1,134 @@
|
||||
'use strict';
|
||||
|
||||
// Plain-Node unit test (no Meteor) for the IFTTT-Rules board selector logic.
|
||||
// Run: node tests/rulesBoardSelector.test.cjs
|
||||
//
|
||||
// Regression guard for #5698 ("Impossible to select other board in rules"): the
|
||||
// rules "move card to the board" / "link card to the board" dropdown filtered
|
||||
// the client cache with `'members.userId': me`, so boards reached through an
|
||||
// Organization / Team / email-domain share (where the user is not an individual
|
||||
// member) never appeared — the reporter's dropdown was empty, current board
|
||||
// included, while direct-member colleagues saw boards. canSelectBoardInRules()
|
||||
// must include org/team/domain/public boards, while still excluding archived,
|
||||
// helper, template-container and templates boards, and boards with no access.
|
||||
|
||||
const assert = require('assert');
|
||||
const {
|
||||
canSelectBoardInRules,
|
||||
boardVisibleToUserContext,
|
||||
} = require('../models/lib/rulesBoardSelector');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) {
|
||||
fn();
|
||||
passed += 1;
|
||||
console.log(' ok -', name);
|
||||
}
|
||||
|
||||
// The user: member of org O1 and team T1, email domain example.com.
|
||||
const CTX = {
|
||||
userId: 'u1',
|
||||
orgIds: ['O1'],
|
||||
teamIds: ['T1'],
|
||||
emailDomains: ['example.com'],
|
||||
};
|
||||
|
||||
const board = props => ({ _id: 'b', type: 'board', title: 'Board', ...props });
|
||||
|
||||
// --- POSITIVE: every access path lets the board be selected -----------------
|
||||
test('direct active member board is selectable', () => {
|
||||
const b = board({ members: [{ userId: 'u1', isActive: true }] });
|
||||
assert.strictEqual(canSelectBoardInRules(b, CTX), true);
|
||||
});
|
||||
|
||||
test('public board is selectable even without membership', () => {
|
||||
const b = board({ permission: 'public', members: [] });
|
||||
assert.strictEqual(canSelectBoardInRules(b, CTX), true);
|
||||
});
|
||||
|
||||
test('#5698: org-shared board (not a direct member) is selectable', () => {
|
||||
const b = board({
|
||||
members: [{ userId: 'someone-else', isActive: true }],
|
||||
orgs: [{ orgId: 'O1', isActive: true }],
|
||||
});
|
||||
assert.strictEqual(canSelectBoardInRules(b, CTX), true);
|
||||
});
|
||||
|
||||
test('#5698: team-shared board (not a direct member) is selectable', () => {
|
||||
const b = board({ members: [], teams: [{ teamId: 'T1', isActive: true }] });
|
||||
assert.strictEqual(canSelectBoardInRules(b, CTX), true);
|
||||
});
|
||||
|
||||
test('#5698: domain-shared board (not a direct member) is selectable', () => {
|
||||
const b = board({
|
||||
members: [],
|
||||
domains: [{ domain: 'example.com', isActive: true }],
|
||||
});
|
||||
assert.strictEqual(canSelectBoardInRules(b, CTX), true);
|
||||
});
|
||||
|
||||
// --- NEGATIVE: no access / excluded categories are NOT selectable -----------
|
||||
test('board the user cannot access at all is NOT selectable', () => {
|
||||
const b = board({
|
||||
permission: 'private',
|
||||
members: [{ userId: 'other', isActive: true }],
|
||||
orgs: [{ orgId: 'O2', isActive: true }],
|
||||
teams: [{ teamId: 'T2', isActive: true }],
|
||||
domains: [{ domain: 'other.com', isActive: true }],
|
||||
});
|
||||
assert.strictEqual(canSelectBoardInRules(b, CTX), false);
|
||||
});
|
||||
|
||||
test('inactive member entry does NOT grant selection', () => {
|
||||
const b = board({ members: [{ userId: 'u1', isActive: false }] });
|
||||
assert.strictEqual(canSelectBoardInRules(b, CTX), false);
|
||||
});
|
||||
|
||||
test('inactive org / team / domain shares do NOT grant selection', () => {
|
||||
const b = board({
|
||||
members: [],
|
||||
orgs: [{ orgId: 'O1', isActive: false }],
|
||||
teams: [{ teamId: 'T1', isActive: false }],
|
||||
domains: [{ domain: 'example.com', isActive: false }],
|
||||
});
|
||||
assert.strictEqual(canSelectBoardInRules(b, CTX), false);
|
||||
});
|
||||
|
||||
test('archived board is excluded even if visible', () => {
|
||||
const b = board({ archived: true, members: [{ userId: 'u1', isActive: true }] });
|
||||
assert.strictEqual(canSelectBoardInRules(b, CTX), false);
|
||||
});
|
||||
|
||||
test('the user templates board is excluded by id', () => {
|
||||
const b = board({ _id: 'tpl', members: [{ userId: 'u1', isActive: true }] });
|
||||
assert.strictEqual(canSelectBoardInRules(b, CTX, 'tpl'), false);
|
||||
});
|
||||
|
||||
test('template-container type is excluded', () => {
|
||||
const b = board({
|
||||
type: 'template-container',
|
||||
members: [{ userId: 'u1', isActive: true }],
|
||||
});
|
||||
assert.strictEqual(canSelectBoardInRules(b, CTX), false);
|
||||
});
|
||||
|
||||
test('internal caret-wrapped helper board is excluded', () => {
|
||||
const b = board({
|
||||
title: '^Subtasks^',
|
||||
members: [{ userId: 'u1', isActive: true }],
|
||||
});
|
||||
assert.strictEqual(canSelectBoardInRules(b, CTX), false);
|
||||
});
|
||||
|
||||
test('null board / null ctx are handled without throwing', () => {
|
||||
assert.strictEqual(canSelectBoardInRules(null, CTX), false);
|
||||
assert.strictEqual(canSelectBoardInRules(board({}), null), false);
|
||||
assert.strictEqual(boardVisibleToUserContext(null, CTX), false);
|
||||
});
|
||||
|
||||
test('missing org/team/domain arrays on ctx do not throw', () => {
|
||||
const b = board({ members: [], orgs: [{ orgId: 'O1', isActive: true }] });
|
||||
assert.strictEqual(boardVisibleToUserContext(b, { userId: 'u1' }), false);
|
||||
});
|
||||
|
||||
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`);
|
||||
@@ -0,0 +1,149 @@
|
||||
'use strict';
|
||||
|
||||
// Plain-Node unit test (no Meteor) for the sign-up verification-email wrapper.
|
||||
// Run: node tests/verificationEmail.test.cjs
|
||||
//
|
||||
// Regression guard for #5672 ("Internal Server Error When Signing Up Despite
|
||||
// Successful Account Creation"). At sign-up, useraccounts creates the account
|
||||
// and then calls Accounts.sendVerificationEmail(); when SMTP is not configured
|
||||
// that throws AFTER the user exists, surfacing as an HTTP 500. wrapSend-
|
||||
// VerificationEmail() must swallow + log a transport failure so registration
|
||||
// still succeeds, while re-throwing an "already verified" error so the resend
|
||||
// flow keeps working.
|
||||
|
||||
const assert = require('assert');
|
||||
const {
|
||||
isAlreadyVerifiedError,
|
||||
wrapSendVerificationEmail,
|
||||
} = require('../server/lib/verificationEmail');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) {
|
||||
// Support async test bodies.
|
||||
const r = fn();
|
||||
if (r && typeof r.then === 'function') {
|
||||
return r.then(() => {
|
||||
passed += 1;
|
||||
console.log(' ok -', name);
|
||||
});
|
||||
}
|
||||
passed += 1;
|
||||
console.log(' ok -', name);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
// --- isAlreadyVerifiedError ------------------------------------------------
|
||||
await test('isAlreadyVerifiedError detects verified messages', () => {
|
||||
assert.strictEqual(
|
||||
isAlreadyVerifiedError(new Error('Email already verified')),
|
||||
true,
|
||||
);
|
||||
assert.strictEqual(
|
||||
isAlreadyVerifiedError({ reason: 'Already verified' }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
await test('isAlreadyVerifiedError is false for transport errors / junk', () => {
|
||||
assert.strictEqual(
|
||||
isAlreadyVerifiedError(new Error('connect ECONNREFUSED 127.0.0.1:25')),
|
||||
false,
|
||||
);
|
||||
assert.strictEqual(isAlreadyVerifiedError(null), false);
|
||||
assert.strictEqual(isAlreadyVerifiedError(undefined), false);
|
||||
assert.strictEqual(isAlreadyVerifiedError({}), false);
|
||||
});
|
||||
|
||||
// --- POSITIVE: a working sender is passed through --------------------------
|
||||
await test('a successful send returns its value and logs nothing', async () => {
|
||||
const logs = [];
|
||||
const wrapped = wrapSendVerificationEmail(
|
||||
async (userId, email) => `sent:${userId}:${email}`,
|
||||
m => logs.push(m),
|
||||
);
|
||||
const ret = await wrapped('u1', 'a@b.com');
|
||||
assert.strictEqual(ret, 'sent:u1:a@b.com');
|
||||
assert.strictEqual(logs.length, 0);
|
||||
});
|
||||
|
||||
await test('the wrapper preserves `this` binding', async () => {
|
||||
const obj = {
|
||||
token: 'T',
|
||||
async send() {
|
||||
return this.token;
|
||||
},
|
||||
};
|
||||
obj.send = wrapSendVerificationEmail(obj.send, () => {});
|
||||
assert.strictEqual(await obj.send(), 'T');
|
||||
});
|
||||
|
||||
await test('a non-function sender is returned unchanged', () => {
|
||||
assert.strictEqual(wrapSendVerificationEmail(undefined, () => {}), undefined);
|
||||
assert.strictEqual(wrapSendVerificationEmail(null, () => {}), null);
|
||||
});
|
||||
|
||||
// --- NEGATIVE / the actual bug: transport failure must NOT throw -----------
|
||||
await test('#5672: an SMTP send failure is swallowed (no throw) and logged', async () => {
|
||||
const logs = [];
|
||||
const wrapped = wrapSendVerificationEmail(async () => {
|
||||
throw new Error('connect ECONNREFUSED 127.0.0.1:25');
|
||||
}, m => logs.push(m));
|
||||
|
||||
let threw = false;
|
||||
let ret = 'unset';
|
||||
try {
|
||||
ret = await wrapped('u1', 'a@b.com');
|
||||
} catch (e) {
|
||||
threw = true;
|
||||
}
|
||||
assert.strictEqual(threw, false, 'must not throw on SMTP failure');
|
||||
assert.strictEqual(ret, undefined);
|
||||
assert.strictEqual(logs.length, 1);
|
||||
assert.ok(/verification email/i.test(logs[0]));
|
||||
assert.ok(/ECONNREFUSED/.test(logs[0]), 'logs the underlying cause');
|
||||
});
|
||||
|
||||
await test('a synchronous throw is also swallowed', async () => {
|
||||
const logs = [];
|
||||
const wrapped = wrapSendVerificationEmail(() => {
|
||||
throw new Error('MAIL_URL not set');
|
||||
}, m => logs.push(m));
|
||||
const ret = await wrapped('u1');
|
||||
assert.strictEqual(ret, undefined);
|
||||
assert.strictEqual(logs.length, 1);
|
||||
});
|
||||
|
||||
await test('swallowing still works when no logger is supplied', async () => {
|
||||
const wrapped = wrapSendVerificationEmail(async () => {
|
||||
throw new Error('smtp down');
|
||||
});
|
||||
// Must neither throw or need a logger.
|
||||
assert.strictEqual(await wrapped('u1'), undefined);
|
||||
});
|
||||
|
||||
// --- NEGATIVE guard: an "already verified" error is re-thrown --------------
|
||||
await test('an "already verified" error is re-thrown (resend flow preserved)', async () => {
|
||||
const logs = [];
|
||||
const original = new Error('Verify email link is for an already verified user');
|
||||
const wrapped = wrapSendVerificationEmail(async () => {
|
||||
throw original;
|
||||
}, m => logs.push(m));
|
||||
|
||||
let caught = null;
|
||||
try {
|
||||
await wrapped('u1');
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
assert.strictEqual(caught, original, 'already-verified error must propagate');
|
||||
assert.strictEqual(logs.length, 0, 'and must not be logged as a transport failure');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} tests passed`);
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in new issue
Block a user