Compare commits

...
591 Commits
Author SHA1 Message Date
github-actions[bot] e2a7e300c9 Bump versions for v9.76 2026-07-05 23:07:35 +00:00
Lauri Ojansivu 844f59e12e Updated ChangeLog. 2026-07-06 01:57:54 +03:00
Lauri Ojansivu 1a3d7b5da4 Updated translations. 2026-07-06 01:57:00 +03:00
Lauri Ojansivu 3d2bdf4f22 Updated ChangeLog. 2026-07-06 01:50:59 +03:00
Lauri Ojansivu b9fe1f1639 Updated ChangeLog. 2026-07-06 01:35:30 +03:00
Lauri Ojansivu a39a7b90b1 Updated ChangeLog.
Repoint the #6427 upcoming entry to its on-main commit (the old link was
malformed and pointed at an orphaned pre-rebase commit).
2026-07-06 01:28:24 +03:00
Lauri Ojansivu 5d1c979e3b Updated ChangeLog.
Link the #5808 and board-not-found-flicker upcoming entries to their commits.
2026-07-06 01:26:43 +03:00
Lauri Ojansivu d00dc6056b Fix the "Board not found" flicker (stale-while-revalidate for the client board cache)
The client board cache (imports/lib/dataCache.js) re-fetches its value inside a
reactive computation. When the board doc is briefly absent from minimongo (a
subscription stops and restarts, momentarily removing the doc), the empty value
was surfaced immediately, flashing the "Board not found" shell — and on WebKit
throwing a Blaze "Can't select in removed DomRange" error tearing down the card
view. It self-recovers when the doc is re-delivered, so it presents as a flicker,
reliably hit only on Firefox/WebKit timing (which is why it surfaced as
browser-specific Playwright failures in 14-voting-watchers and 24-feature-issues).

Fixed with an opt-in stale-while-revalidate mode on DataCache, enabled only for
getBoard: a transient miss over an already-cached board keeps the last value and
re-checks after a short delay, surfacing an empty result only if the board is
still gone then (a genuine deletion / access loss). First-ever loads and caches
that did not opt in are unchanged. Core decision extracted to the pure
shouldDeferCacheMiss helper with unit tests.

Also hardens the related Playwright specs: a retry in CardPage.openActionsMenu, a
longer visibility budget for the WebKit slow card-render in spec 16, and removes a
reload race in the move-list-right spec (moveListBy fires two un-awaited
updateListSort calls, so an immediate reload read a half-applied order).
2026-07-06 01:26:05 +03:00
Lauri Ojansivu 878a24f586 Fix #5808: linking a card to another linked card made both cards inaccessible
The "Link to this card" target picker only excluded template cards, so an
existing linked card (or a card that links back to the current board) could be
chosen as a link target. That builds a chain of linkedId pointers, but the card
helpers (getTitle/getBoardTitle/getRealId) resolve linkedId only one hop, so
such a card renders as an empty/broken pointer and becomes inaccessible (the
reported freeze). As the reporter suggested, prevent the configuration: only a
real card — not a linked card/linked board, not one of the linking board's own
cards, and not a card that links back to one of them — may be a link target.
Enforced in the picker query and re-checked at creation time via the pure
isLinkableCardTarget guard, mirroring the #3328 parent/subtask cycle guard.
2026-07-06 01:25:39 +03:00
Lauri Ojansivu 6d08181edd Updated ChangeLog. 2026-07-05 23:24:30 +03:00
Lauri Ojansivu d5596f05b7 - **Fix DEFAULT_AUTHENTICATION_METHOD env var ignored, and Admin Panel Layout save hanging.
Two related problems with the default login authentication method.
  - **Env var ignored:** the stored setting was only ever seeded as `password` and
    `DEFAULT_AUTHENTICATION_METHOD` was never applied, so operators setting it (e.g. Kubernetes/Helm
    `DEFAULT_AUTHENTICATION_METHOD: ldap`) saw no effect. Startup now applies the env var
    authoritatively: it seeds the value on a fresh install and, on existing installs, keeps the
    stored `defaultAuthenticationMethod` in sync with the env var on every boot (the operator's env
    is the source of truth), so the method can be configured entirely by env without the Admin Panel.
    The value is normalized (trimmed + lower-cased), so `DEFAULT_AUTHENTICATION_METHOD=LDAP` works.
  - **Layout save hanging / not persisting:** the authentication-method `<select>` is populated by an
    async `Meteor.call`, so clicking **Admin Panel > Layout > Save** before it loaded sent an empty
    value for the **required** `defaultAuthenticationMethod` field, which silently failed validation —
    the save looked stuck and nothing changed. The save now falls back to the currently stored method
    when the select is empty, so a real value is never overwritten by `''`.
  Both paths share one pure helper (`resolveDefaultAuthenticationMethod`) that never resolves to an
  empty string.

Thanks to joe-speedboat and xet7 !

Fixes #5879
2026-07-05 23:22:16 +03:00
Lauri Ojansivu 22b280a18e Updated ChangeLog. 2026-07-05 23:13:36 +03:00
Lauri Ojansivu 2e7c4ede2a - **[Reduce card flicker on drag by only writing changed fields on a card move](https://github.com/wekan/wekan/issues/6430)** (#6430):
`Card.move()` wrote `boardId`/`swimlaneId`/`listId` into the update unconditionally, even for a
  same-board drag to another list (the reported case). Keeping `boardId` in the `$set` on every drag
  re-ran the `boardId`-gated `Cards.after.update` hook that re-syncs the card's checklists and
  checklist items via `multi` updates, ran the cross-board consistency guard and the
  `denyCrossBoardMove` deny-rule DB lookup, and invalidated more reactive dependents than
  necessary — server work and reactivity churn that contributed to a ~1s card flicker on large
  boards. `Card.move()` now writes **only the fields that actually change** (via the pure
  `computeCardMoveModifier` helper) and skips the write entirely when a card is dropped back in the
  same place, so a same-board move no longer touches boardId or the cross-board hooks. The
  `moveCard`/`moveCardBoard` activity generators already re-check `doc.X !== oldX`, so trimming the
  modifier does not drop any activity. Note: this reduces the drag-time work behind the flicker; the
  residual reactive re-render cost on very large boards is a separate performance topic.

Thanks to mimZD and xet7 !

Fixes #6430
2026-07-05 23:12:16 +03:00
Lauri Ojansivu ed77abd90d Updated ChangeLog. 2026-07-05 22:56:01 +03:00
Lauri Ojansivu 45fb547947 - **Fix thousands of unsolicited empty "Default" swimlanes created on some boards**:
`Board.getDefaultSwimline()`/`getDefaultSwimlineAsync()` self-heal a missing default swimlane by
  reading the board's swimlanes and inserting one if none exist. That check-then-insert is a race:
  concurrent or repeated server calls for a swimlane-less board each saw zero swimlanes and each
  inserted a new one, so some boards accumulated 30 000+ empty "Default" swimlanes and became
  unloadable (the `key 'default (en)' returned an object instead of string` log is a harmless i18n
  side effect — the title correctly falls back to the string `Default`). Follow-up to the
  client-side [#6382](https://github.com/wekan/wekan/issues/6382) fix, which only stopped the
  browser from auto-creating them. Fixed by making the server self-heal **idempotent**: the default
  swimlane is now upserted with a deterministic `_id` (`<boardId>-default`), so the `_id` unique
  index guarantees at most one default swimlane per board no matter how many times, or how
  concurrently, the getters run. `archived`/`type` are set explicitly in the `$setOnInsert` because
  their schema autoValue/defaultValue only fire on insert, not upsert. Note: this prevents new
  duplicates; boards that already accumulated thousands still need a one-off cleanup.

Thanks to brlin-tw and xet7 !

Fixes #6429
2026-07-05 22:54:13 +03:00
Lauri Ojansivu 477ad737d3 Updated ChangeLog. 2026-07-05 22:30:37 +03:00
Lauri Ojansivu a2802a850b - **Release workflow: publish the Helm chart only after the Docker image is pushed**:
In `.github/workflows/release-all.yml` the `charts` job now depends on the `docker` job
  (`needs: docker`) instead of running in parallel right after `bump`. GitHub Actions runs a job
  only when all of its `needs` jobs succeed, so the wekan/charts Helm chart is published only after
  the multi-arch image is live on Docker Hub / Quay.io / GHCR (and is skipped if `docker` fails).
  This prevents ArtifactHub from scanning a freshly published chart whose image tag does not exist
  yet and emailing the maintainer about the missing Docker image.

Thanks to xet7 !
2026-07-05 22:29:23 +03:00
Lauri Ojansivu 5cbe27be7e Updated ChangeLog. 2026-07-05 22:23:36 +03:00
Lauri Ojansivu 8b21158736 - **Fix [#6427](https://github.com/wekan/wekan/issues/6427): notification emails linked to
`/b/undefined/board/<cardId>` instead of the real board.** On the server
  `ReactiveCache.getBoard()` is async and returns a Promise, but `Cards.board()` did not await it,
  so the synchronous `Card.originRelativeUrl()`/`absoluteUrl()` interpolated a Promise —
  `board._id` and `board.slug` were `undefined`, producing `/b/undefined/board/<cardId>` in
  card activity notification emails (the client UI was unaffected because `this.board()` is
  synchronous there). Fixed by making `Card.originRelativeUrl(board)`/`absoluteUrl(board)` accept an
  already-resolved board and fall back to `this.boardId` (always available synchronously) when the
  board is a Promise, and by passing the awaited board from `server/models/activities.js` so the
  correct board id and slug are used.

Thanks to titver968 and xet7 !

Fixes #6427
2026-07-05 22:23:36 +03:00
github-actions[bot] 651c6f8fb6 Bump versions for v9.75 2026-07-05 19:05:05 +00:00
Lauri Ojansivu 5b67312f0f Updated ChangeLog. 2026-07-05 21:55:15 +03:00
Lauri Ojansivu 1a222c4477 - **[ScannerBleed](https://github.com/wekan/wekan/security/advisories/GHSA-x3xm-pxrv-jg7p)
— shell injection (RCE) via a malicious upload filename in the external antivirus scanner command path**
  ([GHSA-x3xm-pxrv-jg7p](https://github.com/wekan/wekan/security/advisories/GHSA-x3xm-pxrv-jg7p),
  CWE-78 OS Command Injection). Same RCE class as
  [AvatarBleed](https://wekan.fi/hall-of-fame/avatarbleed/) (CVE-2026-52891, GHSA-35j7-h385-2q9g)
  and its follow-up regression (CVE-2026-53447 / GHSA-qfqv-42qw-vvwh area), but in a code path that
  was **never** covered by those fixes.
  In `models/fileValidation.js`, when an admin has configured an external scanner (antivirus)
  command line with a `{file}` placeholder, the uploaded file path was interpolated into the command
  and run through `asyncExec` (`promisify(exec)`), which spawns `/bin/sh -c` and interprets **all**
  shell metacharacters:
  ```js
  await asyncExec(externalCommandLine.replace("{file}", '"' + fileObj.path + '"'));
  ```
  Wrapping the path in double quotes is not a shell boundary — inside double quotes the shell still
  expands `$(...)`, backticks and `\`, so a filename such as `` a`id`.png `` or `$(touch /tmp/pwn).png`
  escaped the argument and executed as the Wekan server process. Any **authenticated** user who can
  upload an attachment could trigger it, on servers that have an external scanner configured. Unlike
  the sibling MIME-detection path (`detectMimeFromFile`, which already uses `execFile` with no shell)
  and unlike the AvatarBleed fix (which strips non-alphanumeric characters from the filename), this
  scanner path had **zero** sanitization.
  - **Fixed** by POSIX single-quote-escaping the interpolated file path via a new `shellQuote()`
    helper (wrap in single quotes, escape embedded `'` as `'\''`). Inside single quotes the shell
    interprets no metacharacters, so a malicious filename can no longer break out of the argument or
    inject commands, while the admin's arbitrary command line and the exact on-disk path are both
    preserved. CVSS:3.1 9.9 Critical (AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H).
  - Affected Wekan v9.06 and earlier through the current release; fixed at the upcoming WeKan release.
    Reported by **DavidCarliez**.

Thanks to DavidCarliez and xet7 !
2026-07-05 21:55:15 +03:00
github-actions[bot] ca9bcfa4ee Bump versions for v9.74 2026-07-05 15:08:00 +00:00
Lauri Ojansivu 4da5b4eb96 Updated ChangeLog. 2026-07-05 17:55:45 +03:00
Lauri Ojansivu 8c4044ce6c Updated ChangeLog. 2026-07-05 17:53:28 +03:00
Lauri Ojansivu b46d9588c7 Fix translations at Login and Register pages.
Thanks to xet7 !
2026-07-05 17:52:05 +03:00
Lauri Ojansivu 96773e7b1d Updated ChangeLog. 2026-07-05 16:51:28 +03:00
Lauri OjansivuandClaude Opus 4.8 5a80f4f6ce Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 16:49:23 +03:00
Lauri OjansivuandClaude Opus 4.8 b9f9390d04 Server-side Mocha suite: fixed a startup crash and the 44 latent failures it had been hiding
- imports/i18n/i18n.test.js crashed the whole run at load: chai 6.x plugins (sinon-chai,
  chai-as-promised) are ESM-only, so use(require('sinon-chai')) handed chai.use() a module
  namespace instead of the plugin function ("fn is not a function"). Now imports the default
  export.
- That unmasked a second load crash - PositionHistory.helpers is not a function: the
  meteor test entry (server/lib/tests/index.js) never ran the .helpers / .attachSchema shim
  that server/main.js bootstraps, so the first model to call Collection.helpers({...}) threw.
  The shim is now imported first in the test entry.
- With the suite finally running, 44 server tests failed because meteor test only loads what
  the specs import (not the app's /server/imports). The specs now import the files that
  register the methods / globals under test (cards.vote / cards.pokerVote, api.attachment.*,
  cloneBoard, getBackgroundImageURL, applyListWidth, updateListSort, moveChecklist,
  userPositionHistory.*, archiveBoard, sendSMTPTestEmail, and the Attachments global). Also
  fixed genuine test bugs: the cards.vote / cards.pokerVote specs were written synchronously
  against async methods; the header-login trust specs restored env vars with
  process.env.X = undefined (which stores the string "undefined" and shadowed the trusted-IP
  allowlist, making every trusted source read as untrusted); the DnsBleed decimal-loopback
  matcher assumed the integer host survived URL normalisation; and the dependencies-OpenAPI
  spec could not locate its source file from the built bundle. The two cards.archive /
  cards.move specs tested Meteor methods that do not exist (archive / move are Minimongo
  document helpers secured by Cards.allow / Cards.deny, already covered by the cards security
  tests) and were removed. Server-side Mocha is now 409 passing, 0 failing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 16:48:58 +03:00
Lauri OjansivuandClaude Opus 4.8 f5a4ece294 Playwright E2E: fixed three cross-process-contention flakes in the parallel run
The Chromium / Firefox / WebKit browser jobs run as separate processes against ONE
shared server + DB, so specs that used fixed identifiers or global cleanups raced
each other:

- 26-shared-templates.e2e.js seeded a fixed email domain (usera@acme-e2e.invalid) in
  all three browsers, hitting E11000 duplicate key on the unique emails.address index.
  Now uses a unique-per-run token for the org / team / domain and template titles.
- 38-impersonation.e2e.js cleaned up with a global
  deleteMany({ reason: 'clickedImpersonate' }) that deleted another browser's in-flight
  audit record mid-poll. Scoped the cleanup to the test's own adminId.
- 32-org-team-feature-toggles.e2e.js exercises setAllOrgsFeature / setAllTeamsFeature,
  which do a global updateMany({}) across every org / team, so two browsers clobbered
  each other's rows. These browser-agnostic server-method tests now run in a single
  project (Chromium); Firefox / WebKit skip them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 16:48:50 +03:00
Lauri Ojansivu c1ddf989a2 Updated ChangeLog. 2026-07-05 14:52:10 +03:00
Lauri Ojansivu c48ecb1d88 Fix tests.
Thanks to xet7 !
2026-07-05 14:50:59 +03:00
Lauri Ojansivu c664fe863c Updated translations. 2026-07-05 14:11:44 +03:00
Lauri Ojansivu 38f571b388 Updated ChangeLog. 2026-07-05 14:10:42 +03:00
Lauri Ojansivu 1e7de6d776 Card Settings popup options combined to same lines, and added more options.
Thanks to xet7 !
2026-07-05 14:09:38 +03:00
Lauri Ojansivu 6d47dc3f4a Updated translations. 2026-07-05 13:30:49 +03:00
Lauri Ojansivu 4756ca4226 Updated ChangeLog. 2026-07-05 13:27:46 +03:00
Lauri Ojansivu bb440b0d7b Board Settings / Card Settings / Checklist item count (0/0) on minicard. Default: Off.
Thanks to carl-unique and xet7 !

Fixes #6431
2026-07-05 13:25:00 +03:00
Lauri Ojansivu 89cfe1529f Updated ChangeLog. 2026-07-05 12:50:30 +03:00
Lauri Ojansivu 236fdf9338 Each run all tests now has logs at ../log/YYYY-MM-DD_HH-MM-SS/
Thanks to xet7 !
2026-07-05 12:48:21 +03:00
Lauri Ojansivu 5c36b2292b Updated ChangeLog. 2026-07-05 12:29:14 +03:00
Lauri Ojansivu 4eebdb4278 Fix tests.
Thanks to xet7 !
2026-07-05 12:21:47 +03:00
Lauri Ojansivu 4533268418 Update vscode sandbox.
Thanks to xet7 !
2026-07-05 12:12:24 +03:00
Lauri Ojansivu c997c12f88 Updated tests 2026-07-05 11:52:12 +03:00
Lauri Ojansivu 36efd9c988 Fix parallel tests.
Thanks to xet7 !
2026-07-05 11:30:26 +03:00
Lauri Ojansivu 267bfc7229 Script to be executeable 2026-07-05 11:03:13 +03:00
Lauri Ojansivu 405a7b72e9 Updated ChangeLog. 2026-07-05 11:02:01 +03:00
Lauri Ojansivu 7d416cc7cc Run all tests at parallel or sequential.
Thanks to xet7 !
2026-07-05 11:00:43 +03:00
Lauri Ojansivu 7929ab27cb Updated ChangeLog. 2026-07-05 10:21:20 +03:00
Lauri Ojansivu 7bbd1a3fad Fix ExcelBleed.
Thanks to sec-reex and xet7 !
2026-07-05 10:20:08 +03:00
Lauri Ojansivu 0f9bd4f9f9 Reverted JQuery 4.0.0 that did not work back to JQuery 3.7.1.
Thanks to xet7 !
2026-07-05 10:02:37 +03:00
Lauri Ojansivu a7ce6690a5 Merge pull request #6415 from wekan/dependabot/npm_and_yarn/babel/runtime-8.0.0
build(deps): bump @babel/runtime from 7.29.7 to 8.0.0
2026-07-05 09:56:51 +03:00
dependabot[bot] 82bd4ff39d build(deps): bump @babel/runtime from 7.29.7 to 8.0.0
Bumps [@babel/runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime) from 7.29.7 to 8.0.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v8.0.0/packages/babel-runtime)

---
updated-dependencies:
- dependency-name: "@babel/runtime"
  dependency-version: 8.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-05 06:55:17 +00:00
Lauri Ojansivu 721c61bef5 Merge pull request #6417 from wekan/dependabot/npm_and_yarn/jquery-4.0.0
Bump jquery from 3.7.1 to 4.0.0
2026-07-05 09:54:49 +03:00
Lauri Ojansivu 7a25546da7 Merge pull request #6435 from wekan/dependabot/npm_and_yarn/i18next-26.3.4
build(deps): bump i18next from 25.10.10 to 26.3.4
2026-07-05 09:53:13 +03:00
Lauri Ojansivu 2c1d3a3922 Merge pull request #6414 from wekan/dependabot/npm_and_yarn/tweedegolf/sab-adapter-amazon-s3-3.0.4
Bump @tweedegolf/sab-adapter-amazon-s3 from 1.0.20 to 3.0.4
2026-07-05 09:52:52 +03:00
Lauri Ojansivu b4d6cc741c Merge pull request #6438 from wekan/dependabot/npm_and_yarn/azure/storage-blob-12.33.0
build(deps): bump @azure/storage-blob from 12.32.0 to 12.33.0
2026-07-05 09:49:31 +03:00
Lauri Ojansivu 5311f80711 Merge pull request #6436 from wekan/dependabot/npm_and_yarn/tests/playwright/playwright/test-1.61.1
build(deps-dev): bump @playwright/test from 1.61.0 to 1.61.1 in /tests/playwright
2026-07-05 09:48:19 +03:00
Lauri Ojansivu abed969a96 Merge pull request #6437 from wekan/dependabot/npm_and_yarn/tests/playwright/mongodb-7.4.0
build(deps): bump mongodb from 7.3.0 to 7.4.0 in /tests/playwright
2026-07-05 09:47:58 +03:00
Lauri Ojansivu 36e3cf30d3 Merge pull request #6428 from wekan/dependabot/github_actions/actions/cache-6
build(deps): bump actions/cache from 5 to 6
2026-07-05 09:47:10 +03:00
Lauri Ojansivu eaadbc6dd1 Merge pull request #6434 from wekan/dependabot/github_actions/docker/build-push-action-7.3.0
build(deps): bump docker/build-push-action from 7.2.0 to 7.3.0
2026-07-05 09:46:41 +03:00
Lauri Ojansivu 0aacc989b7 Merge pull request #6433 from wekan/dependabot/github_actions/docker/metadata-action-6.2.0
build(deps): bump docker/metadata-action from 6.1.0 to 6.2.0
2026-07-05 09:46:32 +03:00
Lauri Ojansivu 77adfb28c9 Merge pull request #6432 from wekan/dependabot/github_actions/docker/login-action-4.4.0
build(deps): bump docker/login-action from 4.2.0 to 4.4.0
2026-07-05 09:46:23 +03:00
Lauri Ojansivu a9d8261d80 Updated ChangeLog. 2026-07-05 09:38:48 +03:00
Lauri Ojansivu df3f519e99 Update to Meteor 3.5.
Thanks to Meteor developers !
2026-07-05 09:36:34 +03:00
Lauri Ojansivu d68364befe Updated translations. 2026-07-05 09:35:02 +03:00
Lauri Ojansivu 6094979d4e Updated ChangeLog. 2026-07-05 09:33:14 +03:00
Lauri Ojansivu ef845fe4a0 Fix DnsBleed.
Thanks to 4n207 and xet7 !
2026-07-05 09:30:12 +03:00
dependabot[bot] ce8fc8e392 build(deps): bump @azure/storage-blob from 12.32.0 to 12.33.0
Bumps [@azure/storage-blob](https://github.com/Azure/azure-sdk-for-js/tree/HEAD/sdk/storage/storage-blob) from 12.32.0 to 12.33.0.
- [Release notes](https://github.com/Azure/azure-sdk-for-js/releases)
- [Changelog](https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/storage/storage-blob/CHANGELOG.md)
- [Commits](https://github.com/Azure/azure-sdk-for-js/commits/@azure/storage-blob_12.33.0/sdk/storage/storage-blob)

---
updated-dependencies:
- dependency-name: "@azure/storage-blob"
  dependency-version: 12.33.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-04 20:32:53 +00:00
dependabot[bot] 402b79c340 build(deps): bump mongodb from 7.3.0 to 7.4.0 in /tests/playwright
Bumps [mongodb](https://github.com/mongodb/node-mongodb-native) from 7.3.0 to 7.4.0.
- [Release notes](https://github.com/mongodb/node-mongodb-native/releases)
- [Changelog](https://github.com/mongodb/node-mongodb-native/blob/main/HISTORY.md)
- [Commits](https://github.com/mongodb/node-mongodb-native/compare/v7.3.0...v7.4.0)

---
updated-dependencies:
- dependency-name: mongodb
  dependency-version: 7.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-04 20:32:51 +00:00
dependabot[bot] 08ba9b02f2 build(deps-dev): bump @playwright/test in /tests/playwright
Bumps [@playwright/test](https://github.com/microsoft/playwright) from 1.61.0 to 1.61.1.
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.61.0...v1.61.1)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.61.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-04 20:32:44 +00:00
dependabot[bot] 3a016be97d build(deps): bump i18next from 25.10.10 to 26.3.4
Bumps [i18next](https://github.com/i18next/i18next) from 25.10.10 to 26.3.4.
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v25.10.10...v26.3.4)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.3.4
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-04 20:32:38 +00:00
dependabot[bot] 56b09f9102 build(deps): bump docker/build-push-action from 7.2.0 to 7.3.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 7.2.0 to 7.3.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/f9f3042f7e2789586610d6e8b85c8f03e5195baf...53b7df96c91f9c12dcc8a07bcb9ccacbed38856a)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 7.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-04 20:32:31 +00:00
dependabot[bot] dccf16647a build(deps): bump docker/metadata-action from 6.1.0 to 6.2.0
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 6.1.0 to 6.2.0.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9...dc802804100637a589fabce1cb79ff13a1411302)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-04 20:32:23 +00:00
dependabot[bot] 9c2ff81b12 build(deps): bump docker/login-action from 4.2.0 to 4.4.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.2.0 to 4.4.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/650006c6eb7dba73a995cc03b0b2d7f5ca915bee...af1e73f918a031802d376d3c8bbc3fe56130a9b0)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-04 20:32:19 +00:00
dependabot[bot] af8bba27c0 build(deps): bump actions/cache from 5 to 6
Bumps [actions/cache](https://github.com/actions/cache) from 5 to 6.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-27 20:32:19 +00:00
github-actions[bot] 8f83ece2ed Bump versions for v9.73 2026-06-26 03:18:47 +00:00
Lauri Ojansivu ac8e39daca Updated ChangeLog. 2026-06-26 06:13:08 +03:00
Lauri OjansivuandClaude Opus 4.8 3c07777722 docs(changelog): repoint #6426/dragscroll links to post-rebase hashes
A git pull --rebase inserted upstream commit 17c62ccdf (Bump versions for
v9.72) under this session's work, rewriting its commit hashes. The CHANGELOG
still linked the pre-rebase hashes (37ac65e37, a4cce9a53), now orphaned and
not on main. Repoint to the rebased commits 219ee659e and 88f353dc1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:08:35 +03:00
Lauri OjansivuandClaude Opus 4.8 0e10d24bef docs(changelog): add login/register mobile-scroll fix entry (4a7df3745)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:07:38 +03:00
Lauri OjansivuandClaude Opus 4.8 4a7df37459 fix: login/register page not scrollable on mobile — sign-up link unreachable
On phones body.mobile-mode pins <body> to position:fixed + height:100vh (an
iOS board-view anti-bounce fix). The auth pages (userFormsLayout) have no
inner #content scroller, and .auth-layout/.auth-dialog were sized to the
viewport height, so the page was trapped at 100vh: the content below the
form — the sign-up/sign-in link, legal notice and language selector — could
not be scrolled into view, and the on-screen keyboard covered it.

userFormsLayout now tags <body> with .userform-layout while mounted, and CSS
lets those pages scroll as a normal document on mobile (position:static,
height:auto, min-height:100vh) with the auth layout/dialog growing to their
content. Scoped to .userform-layout so the board-view mobile lock is
unchanged. Removed on destroy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:07:22 +03:00
Lauri OjansivuandClaude Opus 4.8 f9b5de0e8e docs(changelog): link all-pages dragscroll entry to fix commit a4cce9a53
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:55:26 +03:00
Lauri OjansivuandClaude Opus 4.8 88f353dc1f fix: drag-to-scroll on all pages via central route-aware autorun
Page drag-to-scroll was wired into only boardsList (All Boards), myCards and
userFormsLayout (Login/Register). Every other defaultLayout page — Due Cards,
Global Search, Public, Bookmarks, Broken Cards, Settings, People, Admin
Reports, Attachments, Translation, Import, board Rules, Not Found — had no
page drag-scroll, and the per-template enable/disable calls fought each other
when navigating between two non-board pages (one template's onDestroyed
removed the class the next template's onRendered expected).

Replace them with a single route-aware autorun in defaultLayout (the
persistent shell that owns #content): non-board routes tag the real scroll
container (<body> + #content, so it works in both desktop and mobile mode),
and board-canvas routes (board/board-short/card) leave page tagging off so
their own .board-canvas.dragscroll handles dragging without double-scrolling
#content. Login/Register keep their own enable/disable (userFormsLayout has
no #content).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:55:26 +03:00
Lauri OjansivuandClaude Opus 4.8 50525f6fd1 docs(changelog): link Upcoming #6426 + dragscroll entries to fix commit 37ac65e37
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:55:26 +03:00
Lauri OjansivuandClaude Opus 4.8 219ee659e9 fix: finish reverting oversized mobile UI (#6426) + fix page dragscroll
#6426: the first revert only cleaned boardsList.css. The same forced
"2x/3x bigger on mobile" rules remained in header.css (#header-quick-access
logo/quick-access bar) and boardBody.css (#content — which the All Boards
list and the board view render inside) plus the iPhone card-details body
text. All neutralized to normal size; the iPhone-12-mini header blocks that
had grown tall/wrapped to fit 3x text are back to a compact single row. The
#6419 mobile touch-target icon sizes in the card-details header are kept.

dragscroll: enablePageDragscroll/disablePageDragscroll tagged only <body>,
but on phones body.mobile-mode is position:fixed and the real scroll
container becomes #content, so Login/Register/All Boards could not drag-
scroll. Now tag whichever element actually scrolls — both <body> and
#content when present — so only the overflowing one scrolls. The touch
helper already prefers the nearer #content.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:55:26 +03:00
github-actions[bot] 17c62ccdf6 Bump versions for v9.72 2026-06-25 23:26:37 +00:00
Lauri Ojansivu 14de82ad2d Updated ChangeLog. 2026-06-26 02:15:49 +03:00
Lauri Ojansivu e92f4f9819 Updated translations. 2026-06-26 02:12:33 +03:00
Lauri Ojansivu 08aac777fb docs(changelog): generalize #6426 wording to mobile UI scope 2026-06-26 02:10:26 +03:00
Lauri Ojansivu b84caaad26 docs(changelog): link #6426 entry to fix commit\n\nFixes #6426 2026-06-26 02:09:06 +03:00
Lauri Ojansivu f7a2a3ace5 fix: revert oversized mobile All Boards/iPhone 12 mini UI\n\nFixes #6426 2026-06-26 02:08:49 +03:00
Lauri OjansivuandClaude Opus 4.8 88a2e96db3 CHANGELOG: Upcoming — api.py addcustomfieldtoboard empty-settings fix
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 03:05:02 +03:00
Lauri OjansivuandClaude Opus 4.8 487cf3f963 api.py: fix addcustomfieldtoboard crash on empty settings
json.loads('') on an empty settings argument raised JSONDecodeError before the
request was sent (a bug api.py's own header flagged). Default empty settings to
{} and send it as valid JSON via json.dumps instead of str(dict) (which produced
invalid JSON with single quotes). Verified: both empty and object settings create
the custom field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 03:03:10 +03:00
github-actions[bot] 6b3ae817fe Bump versions for v9.71 2026-06-23 15:54:59 +00:00
Lauri OjansivuandClaude Opus 4.8 f74c658552 i18n: Finnish translations for fixed-list-width strings (#5729)
Translate the 4 newly-added fixed-list-width keys in fi.i18n.json to Finnish,
matching existing terminology (Lista/listoille, leveys, 'vain sinua varten'
as in list-width-personal-note). The remaining strings identical to English
are technical terms, abbreviations and placeholder-only templates (poker
numbers, LDAP/OAuth2/CAS/OK, Gantt, S3/GridFS, operator abbrevs, __board__)
that are correctly the same in Finnish.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:33:42 +03:00
Lauri Ojansivu 58fa52a4ac Updated translations. 2026-06-23 18:32:01 +03:00
Lauri Ojansivu 468a1e3d1d Updated ChangeLog. 2026-06-23 18:29:27 +03:00
Lauri OjansivuandClaude Opus 4.8 945b634d32 fix: full-width sidebar at top of board on phones (mobile + desktop mode)
On iPhone 12 mini the right board sidebar showed as a narrow strip pinned to
the far right. The sidebar lives inside .board-wrapper, which is wider than
the viewport on a phone, so the absolutely-positioned sidebar
(inset-inline-end: 0) anchored to the right edge of the wide board instead of
the screen. Under the @media (max-width: 800px) breakpoint (which matches the
viewport in both mobile and desktop mode), pin the sidebar to the VIEWPORT
with position: fixed + 100vw so it is a full-width panel at the top of the
board. Also normalise the sidebar text sizes so the headings are not
oversized and small text not tiny, reading consistently with the board.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:24:01 +03:00
Lauri OjansivuandClaude Opus 4.8 c70a36d89e fix: vertically center swimlane header buttons (mobile)
The swimlane header buttons (collapse, swimlane menu, + new swimlane, drag
handle) hugged the top of the swimlane header on small screens (e.g. iPhone
12 mini). The menu was absolutely positioned at top:0 and each icon used a
top: calc(50% + Npx) offset that centered against the menu's own small
height, not the header. Stretch .swimlane-header-menu to the full header
height and flex-center its children vertically (align-items: center), and
drop the now-unnecessary per-icon top offsets, so the buttons sit in the
middle of the swimlane header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:15:59 +03:00
Lauri OjansivuandClaude Opus 4.8 19deee64c6 test: add test:unit:all npm script to run the full node test suite
Runs every plain-Node (non-browser) test in one command: builds on
test:unit:node and adds the sticker, link-card-popup (#5715), Trello/Wekan
importer and oauth2 admin-group suites. All green.

Browser-driven suites (Meteor client mocha + Playwright e2e) are not
included here — they need a running app + a working browser, which an x86
Chromium cannot provide on an ARM host under qemu; run those on x86 CI.
rtl.test.js is intentionally excluded (2 pre-existing CSS lint failures).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:05:09 +03:00
Lauri OjansivuandClaude Opus 4.8 0ddc47a0db fix: changing UI language had no effect for region/script-tagged languages
The custom tap:i18n reimplementation registered each language's resource
bundle under the raw Wekan tag, but i18next (cleanCode: true) resolves and
looks translations up under a normalised code. For region/script-tagged and
legacy underscore tags (zh-CN, zh-Hans, ar-DZ, af_ZA, en_AU, ...) the stored
code and the lookup code disagreed, so the bundle was never found and the UI
silently fell back to English while simple tags (de, fr) kept working.

Normalise every i18next boundary (supportedLngs, addResourceBundle,
changeLanguage, t) through one helper (toI18nCode): underscores -> hyphens
and i18next's formatLanguageCode, so storage and lookup always agree.
Safely unwrap ES-module-namespace JSON imports without mistaking the data's
own "default" translation key for a module default export. Surface failed
language switches in the user header / login form instead of leaving the UI
silently English. Add positive and negative regression tests.

Closes #5756

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:51:56 +03:00
Lauri OjansivuandClaude Opus 4.8 f456a1b8f5 fix: forgot-password 500 — make reset-password email build/send graceful
Forgot Password -> "Email reset link" could return a raw HTTP 500 instead of
sending the email / showing success (#5706). When SMTP is unconfigured (no
MAIL_URL / MAIL_FROM, or a bad mail server), Meteor's Email.sendAsync throws and
the exception propagated unhandled out of the forgotPassword method as an opaque
500.

Harden the reset-password / verify-email / enroll-account email-template builders
so they never throw (guarded user name/language lookups, safe fallback if
translation throws), and wrap Accounts.sendResetPasswordEmail so a send failure
surfaces as a clean Meteor.Error('email-fail', ...) instead of a 500. Logic
extracted to server/lib/resetPasswordEmail.js with positive + negative unit tests
in tests/unit/resetPasswordEmail.test.js (wired into the test:unit:node script).

Note: this makes the failure graceful; receiving reset emails still requires SMTP
to be configured.

Closes #5706

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:50:24 +03:00
Lauri OjansivuandClaude Opus 4.8 5d74057c3a fix: link to a whole board even when that board already has cards
The link-card popup (linkCardPopup in client/components/lists/listBody.js)
used to auto-populate the List/Card sub-selects when a board was chosen,
leaving no way to clear them. Once the chosen board had cards, you could
only link to a card and never to the whole board.

The Swimlane/List/Card selects now default to a blank "(none)" option and
are not auto-selected on board change (this part was already merged from
seve12 / PR #5967). In addition, the popup's confirm ("link") button now
falls back to creating a board-level link when a board is selected but the
Card field is left blank, so a whole board can be linked with the single
bottom button regardless of whether the board has cards. The duplicate
board-link guard is preserved and a blank selection never creates a
phantom/undefined linked card.

Added tests/linkCardPopup.test.js (standalone Node, like
tests/cards.stickers.test.js) covering positive and negative cases.

Closes #5715

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:48:59 +03:00
Lauri OjansivuandClaude Opus 4.8 4edfcb0cf6 fix: due date fails when locale uses non-Latin (Persian/Arabic-Indic) digits
In locales such as Persian/Farsi or Arabic, dates and times can be rendered
with non-Latin digits (Persian/Extended Arabic-Indic ۰-۹ U+06F0–U+06F9, or
Arabic-Indic ٠-٩ U+0660–U+0669). The native JavaScript Date constructor only
understands ASCII digits, so such a string fails to parse and produces an
Invalid Date when setting/updating or comparing dates.

Added a normalizeDigits() helper in imports/lib/dateUtils.js that converts
those non-Latin digits to ASCII, applied it at every date-string parse
boundary in dateUtils.js (via an internal toDate() wrapper) and in the
date/time picker submit/change handlers in client/lib/datepicker.js. The
normal ASCII path is unchanged.

Added tests/dateUtils.normalizeDigits.test.cjs (positive and negative cases)
and wired it into the test:unit:node script.

Closes #5752

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:47:00 +03:00
Lauri OjansivuandClaude Opus 4.8 9a5dd98389 feat: fixed (same) width for all lists (#5729)
Add a per-viewer, per-board "Same width for all lists" (fixed width) mode.
When enabled, every list renders at one shared width and drag-resizing any
list updates that single value so all lists change together. Works for both
logged-in users (profile.fixedListWidthBoards / profile.fixedListWidths via
new setFixedListWidthEnabled / setFixedListWidth Meteor methods) and
anonymous public-board users (localStorage). Enabling fixed width turns off
auto-width; widths below 270px are rejected.

Adds Playwright spec 38 covering positive and negative cases for logged-in
(profile/DDP + rendered widths) and anonymous (localStorage) viewers.

Closes #5729

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:46:24 +03:00
Lauri OjansivuandClaude Opus 4.8 01271b79e8 test: add tests + negative tests for #5799, #6423, #6424
- tests/playwright/specs/39-allboards-sort-search-pagination.e2e.js (#5799):
  UI sort (A-Z/Z-A) + search (positive + non-matching negative); server
  getAllBoardsPage paging (page boundaries, search narrowing, empty result,
  and a negative that boards the user can't access never leak).
- tests/playwright/specs/40-rest-card-move-list.e2e.js (#6423): PUT card with
  listId returns 200 (not 500) and moves the card + records a moveCard
  activity; repeated moves never 500; a user without write access cannot move.
- tests/minicardLabelHeight.test.cjs (#6424): CSS regression guard that the
  minicard label viewer/label min-height overrides stay in place; wired into
  test:unit:node.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:36:14 +03:00
Lauri OjansivuandClaude Opus 4.8 1be9b98559 i18n: Finnish translations for All Boards sort/search strings (#5799)
Translate the new sort-boards / search-boards strings in fi.i18n.json to
Finnish, matching existing terminology (taulu/taulut, Etsi, Lajittele,
Otsikko). The other strings that remain identical to English are technical
terms and abbreviations (LDAP, OAuth2, S3, GridFS, poker numbers, operator
abbreviations) that are correctly the same in Finnish.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:18:14 +03:00
Lauri OjansivuandClaude Opus 4.8 71c0c2b582 test: impersonation UI tests + ChangeLog for All Boards sort/search/pagination (#5799)
Adds Playwright specs covering GlobalAdmin impersonation: impersonating a
user from Admin > People makes the session act as that user (Meteor.userId,
audit record, board data visibility), and a non-admin cannot impersonate.
Documents #5799 in the ChangeLog.

Refs #5799

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:16:11 +03:00
Lauri Ojansivu 98800d61cc Updated translations. 2026-06-23 16:14:47 +03:00
Lauri Ojansivu 2cf94c2fd3 Updated translations. 2026-06-23 16:12:16 +03:00
Lauri OjansivuandClaude Opus 4.8 c1da7a4a33 feature: All Boards search + server-side pagination of board icons (#5799)
Builds on the alphabetical sort: add a board-name search box and paginated
board icons to the All Boards page, with the search box and pagination
controls placed like Admin Panel > People.

- Search by board name spans every category (Starred, Templates, Remaining
  and all (sub)workspaces) from one box; Esc / a clear button reset it.
- In the sorted (A->Z / Z->A) modes the current page of board icons is
  computed on the server (getAllBoardsPage) and only that page is rendered,
  so the page scales to thousands of boards. Custom (manual drag order)
  stays unpaginated client-side so drag-reordering keeps working.
- getAllBoardsPage resolves visibility, menu/workspace filtering, search and
  sort against the effective current user (this.userId), so it also works
  when a GlobalAdmin impersonates a user.

Refs #5799

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:04:56 +03:00
Lauri OjansivuandClaude Opus 4.8 f74ccbc8fa feature: sort All Boards alphabetically (A-Z / Z-A)
The All Boards page only offered the per-user manual drag order, with no
obvious way to sort boards by name (#5799). Add a "Sort" button to the All
Boards header that opens a popup to choose Custom (manual drag order, the
default), Title A->Z, or Title Z->A. The choice is stored per user in
profile.allBoardsSortBy and applied in sortBoardsForUser(), so it persists
and reorders the grid live.

Closes #5799

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:44:46 +03:00
Lauri OjansivuandClaude Opus 4.8 7436b9d06a Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:38:17 +03:00
Lauri OjansivuandClaude Opus 4.8 22f996903b fix: notification emails ignored the user's language and were sent in English
On the server only the default (English) translation bundle is loaded at
startup. Translating a notification to a user's language called
TAPi18n.__(key, params, lang) with lng=lang, but that resource bundle was
never added on the server, so i18next fell back to English — every
notification email went out in English regardless of the user's profile
language. (The client works because switching language calls setLanguage,
which loads the bundle.)

Add TAPi18n.ensureLanguageLoaded(language), which loads the bundle on
demand if i18next does not already have it, and await it before translating
in the email notification, EmailLocalization.sendEmail and outgoing webhook
paths.

Closes #5875

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:37:56 +03:00
Lauri OjansivuandClaude Opus 4.8 e5edb54a53 Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:34:08 +03:00
Lauri OjansivuandClaude Opus 4.8 69c899f1f9 fix: REST card move to another list returned HTTP 500
The PUT .../cards/:cardId list-move path called cardMove() with
{ fieldName: 'listId' } as its fieldNames argument. cardMove() does
fieldNames.includes('boardId'), and a plain object has no .includes, so
moving a card to another list over the REST API threw
"fieldNames.includes is not a function" and returned HTTP 500. Pass the
changed field names as an array (['listId']) like the board-move path does.

Closes #6423

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:33:52 +03:00
Lauri Ojansivu 0f86a81bd8 Updated ChangeLog. 2026-06-23 15:31:37 +03:00
Lauri OjansivuandClaude Opus 4.8 338adb5ba9 Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:29:57 +03:00
Lauri OjansivuandClaude Opus 4.8 afbabccd26 fix: card labels took two lines / double height on minicards
Each minicard label's name is rendered inside a `.viewer`, whose global
`min-height: 2.5vh` (intended for the full content editor) forced every
label to roughly double height, wasting vertical space on the board.

Scope overrides to minicard text labels: reset that `min-height`, render
the viewer/paragraph inline, and keep each label on a single compact line.

Closes #6424

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:29:57 +03:00
github-actions[bot] cf5560b41f Bump versions for v9.70 2026-06-23 07:20:48 +00:00
Lauri Ojansivu 5c53b8611b Updated ChangeLog. 2026-06-23 10:16:57 +03:00
Lauri Ojansivu 6aa514f359 Updated translations. 2026-06-23 10:12:46 +03:00
Lauri OjansivuandClaude Opus 4.8 ac9efbf402 fix: cross-board card copy throws on a board with no labels
Card.copy()'s cross-board label-remap did oldBoard.labels.filter(...) and
filterCopiedLabelIds(newBoard.labels, ...) without guarding a missing labels
array. Boards created via the REST API (POST /api/boards) have no labels array,
so copying a card to/from such a board — or copying the board itself (which
copies its cards cross-board) — threw "Cannot read properties of undefined
(reading 'filter')" and returned HTTP 500. Both lookups now fall back to [].

Also fixes the subtask cross-board-copy test seed (real cards always have a
userId; the seed lacked one, hitting "User ID is required in cards insertAsync").

Validated locally: full tests/playwright/specs/17-rest-api.e2e.js passes
(24/24, chromium) against a running dev server, and npm run test:unit:node
passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:55:34 +03:00
Lauri OjansivuandClaude Opus 4.8 1f344879fb fix: client bundle crash — move server-only card helpers out of the client bundle
models/lib/cardBoardConsistency.js and models/lib/restArrayParam.js are
CommonJS (module.exports), but being under models/ they were eagerly bundled
into the client by rspack, which classifies a no-import/require .js as an ES
module and threw at load time:

  Uncaught Error: ES Modules may not assign module.exports or exports.*
  ... ./models/lib/cardBoardConsistency.js -> ./models/cards.js

This aborted the client bundle on every page. (filenameSanitizer.js uses the
same module.exports pattern without issue only because it is never imported
into the client bundle.)

Both helpers are server-only logic (a REST handler and a Cards.before.update
hook), so they are moved to server/lib/ (excluded from the client rspack
bundle, CommonJS is fine on the Node server) and enforceCardBoardConsistency
is relocated from the isomorphic models/cards.js into server/models/cards.js.
The isomorphic models/cards.js no longer imports either helper.

Verified in a real headless Chromium against a running dev server: the page
now loads with window.Meteor present and zero console/page errors (previously
one PAGEERROR from cardBoardConsistency.js). Node unit tests updated to the new
paths and pass (npm run test:unit:node, 11 passing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 08:50:29 +03:00
Lauri OjansivuandClaude Opus 4.8 85841a1c43 fix: cross-board card copy re-homes subtasks onto the destination board
Card.copy() re-pointed each subtask's parentId to the copied card but left its
boardId/swimlaneId/listId pointing at the source board, so copying a card to
another board orphaned its subtasks on the original board (parent on the new
board, subtask on the old one). It also mutated the cached source docs in place.
Copied subtasks are now re-homed onto the destination board/swimlane/list via a
fresh object (no cache mutation) — same class as the #5166/#5874 cross-board
consistency fixes.

Cross-board subtask copy regression in tests/playwright/specs/17-rest-api.e2e.js.
This fixes the orphaned-cross-board-subtask data symptom noted in #5347; the
separate "Maximum call stack" error there is not root-caused, so #5347 stays
open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 08:19:05 +03:00
Lauri OjansivuandClaude Opus 4.8 c2c6bdaedd fix(#5325,#5390,#5565): notification crash, custom-field delete crash, minicard checklists
#5325: a notification referencing a deleted activity (card/board gone) had a
null activityObj, and the notifications drawer dereferences it — one orphaned
entry threw and broke the whole popup. user.notifications() now drops entries
whose activity can't be resolved.

#5390: removing a multi-board custom field from one board logged a
setCustomField activity via (await getActivity()).value with no null guard, so
a field that never had a value set threw and aborted the removal. Guarded.

#5565: the sidebar "show checklists on minicard" toggle writes
board.allowsChecklistsOnMinicard, but the minicard checked a different UI-less
field (allowsChecklistAtMinicard), so the toggle had no effect. Minicard now
reads the field the toggle actually sets.

(No automated regressions: #5325/#5565 are client-display logic and #5390's
trigger needs a multi-board custom field; all three are small, self-evident
guards/field-name corrections.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 08:07:09 +03:00
Lauri OjansivuandClaude Opus 4.8 39e8cc11a4 perf(#5688): copy a card's checklists/items without per-item activity churn
Checklist.copy() duplicated each checklist item with the hooked insertAsync, so
collection-hooks fired each item's after.insert — every one doing a getCard and
inserting an addChecklistItem activity. Copying a card with ~100 items meant
~100+ activity inserts plus per-item DB round-trips, taking minutes and spiking
CPU. The copy now uses .direct for the checklist and its items (skipping the
per-item activity hooks, which are pure churn for a wholesale copy) and sets
boardId itself — the same approach as the cardRemover delete path.

Copy regression (checklist + all items duplicated, correct boardId/checklistId)
in tests/playwright/specs/17-rest-api.e2e.js. Also documents #5388 (list
collapse now per-user) as verified-resolved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 07:59:35 +03:00
Lauri OjansivuandClaude Opus 4.8 0a206c85cf docs(changelog): note #5411 verified-resolved (board admins can invite)
Board admins already see the add-member + via canInviteToBoard (board.hasAdmin
returns true; default invite roles include board-admin) — resolved by the
Allow-Invite-to-Board roles feature (c956ab5a4), which postdates the report.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 07:39:39 +03:00
Lauri OjansivuandClaude Opus 4.8 0ed4d1556c fix(#4965,#5809): reactive card members popup; surface linked-card edit errors
#4965: the card "add members" popup snapshotted board.activeMembers() once on
open, so a member added to the board afterwards (or whose user doc loaded just
after open) was missing until the popup was reopened. The popup now stores only
the filter term and derives its candidate list reactively from
board.activeMembers() on each render, so newly-added members appear without
reopening. activeMembers() itself is unchanged.

#5809: editing a linked card whose target is on a board the user cannot write
to was rejected by the server allow rule, but the title/description submit
handlers had no error handling, so the edit vanished silently. They now catch
the failure and show the error (mirroring the existing label-color handler).

Also documents #3894 (import with a member missing from users) as
verified-resolved — already guarded in the importers with a dedicated test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 07:36:30 +03:00
Lauri OjansivuandClaude Opus 4.8 4edbab4402 fix(#4847): allow removing a deleted user from a card's members
When a user account is deleted its entry stays in a card's members and renders
as a blank avatar. Clicking it opened cardMemberPopup, whose template
dereferenced the missing user document and failed to render, so the orphaned
member could not be removed. The popup now detects a missing user, shows a
"Deleted user" entry with the raw id, and keeps the Remove from Card control
(the remove handler keys off the member's userId, not the user doc). Adds the
deleted-user i18n string.

No automated regression — a self-evident Blaze template guard; the underlying
unassignMember removal already worked. The board-members list still hides such
entries because activeMembers() also filters not-yet-loaded users, so
surfacing board-side orphans cleanly is a separate follow-up (noted in
changelog).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 07:27:29 +03:00
Lauri OjansivuandClaude Opus 4.8 0f45c0e392 fix(#5122): boards report lists only active members
Removing a member from a board marks the entry isActive:false (kept in
board.members for role history / re-activation), but the Admin Panel boards
report listed all member entries, so removed users still showed as members.
The report's userNames helper now filters to active members. The raw
GET /api/boards/:boardId response intentionally still returns the full members
array with isActive flags so API consumers can filter as needed.

Also documents #5630 (cannot save Layout settings) and #5117 (TeX rendered both
SVG and a math tag) as verified-resolved — both already fixed in current code
(Layout save handler no longer reads the moved accessibility fields; math
rendering migrated from markdown-it-mathjax3 to Temml/MathML).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 07:12:47 +03:00
Lauri OjansivuandClaude Opus 4.8 ba62449323 fix(#5592,#1894): copy board webhooks; block disabled users from boards
#5592: board.copy() duplicated swimlanes/lists/cards, custom fields and
rules/triggers/actions but had no loop for Integrations (outgoing webhooks),
so a copied board lost all of them (they are per-board children — boardRemover
deletes them by boardId). copy() now also copies the board's Integrations,
remapping boardId.

#1894: none of the add-member paths checked the target account's loginDisabled
flag, so a deactivated user could be added to a board and assigned to cards.
inviteUserToBoard now throws error-user-disabled and the REST add-member
endpoint returns HTTP 400 when the target account is disabled (added the
error-user-disabled i18n string).

Also locks in #5627 (rules ARE copied on board copy — already-fixed code that
the old report predates) with the same copyBoard regression. Regressions in
tests/playwright/specs/17-rest-api.e2e.js; changelog updated under Upcoming.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 06:52:15 +03:00
github-actions[bot] 6d5176bd87 Bump versions for v9.69 2026-06-23 03:38:09 +00:00
Lauri Ojansivu 9c8fd0f281 Updated ChangeLog. 2026-06-23 06:34:27 +03:00
Lauri OjansivuandClaude Opus 4.8 d8c675d82a perf(#3252,#5322): bypass per-child activity churn when deleting a card
cardRemover removed a card's checklist items, checklists and comments with the
hooked removeAsync, so collection-hooks fired each child's before.remove once
per document — every one doing a getCard and inserting an activity (e.g. a
removedChecklistItem per item), which then triggered notification + publication
observers. Deleting/bulk-deleting a card with many children produced an
activity-insert storm and high CPU.

cardRemover now removes those children with .direct (skipping the per-child
hooks, which only logged activities that are unviewable once the card is gone)
and clears all of the card's activities in a single bulk op — which also fixes
the activities a delete previously left orphaned. The deleteCard activity is
still logged afterward (outgoing webhook still fires), and attachments still go
through the normal remove so their files are deleted.

Cascade + activity-cleanup regression (also a negative test, since the old
cardRemover left the card's activities behind) in
tests/playwright/specs/17-rest-api.e2e.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 06:26:33 +03:00
Lauri OjansivuandClaude Opus 4.8 57caa2c337 fix(#3252): guard client card-detail deletes against evicted docs
Deleting a comment or checklist/checklist-item could throw "Removed
nonexistent document" on the client: the handlers called Collection.remove(_id)
directly, but under heavy archive/delete churn the target doc can already be
evicted from the client's Minimongo cache, and removing a missing _id throws.
The comment / checklist / checklist-item delete handlers now check the doc
still exists in the local cache before removing it. The server before.remove
hooks were already hardened to guard + log instead of throwing.

Partial: removes the thrown client exception; the high CPU on bulk delete the
issue also reports is separate reactivity/mergebox churn and remains a
follow-up. No automated regression — the eviction race is not deterministically
reproducible; the guard is a self-evident findOne-then-remove.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 06:12:35 +03:00
Lauri OjansivuandClaude Opus 4.8 5212d25fc2 fix(#5166): re-home copied card comments onto the destination board
When a card was copied to another board, CardComments.copy() only updated
the comment's cardId, so the copied comments kept the SOURCE board's boardId.
Comment permission checks key off the comment's boardId, so edit/delete on a
copied comment was validated against the wrong board, and board-scoped queries
saw it on the old board. copy() now also sets the destination boardId; the
author userId is intentionally preserved.

Cross-board copy regression in tests/playwright/specs/17-rest-api.e2e.js;
changelog documents the fix and notes the separate "wrong author shown"
display symptom (author not a member of the destination board → user doc not
published there) as a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 06:04:14 +03:00
Lauri OjansivuandClaude Opus 4.8 55dbe59fde fix(#2875,#5650): REST card-create member coercion + board-create owner fallback
#2875: the card-create endpoints (POST .../cards and .../cards/bulk) wrote
req.body.members/assignees straight to the insert with no normalization (the
create-side twin of #3697), so a null/"" payload persisted as null and broke
later UI editing. Both now run coerceRestArrayParam: the field is omitted when
absent (schema default [] applies), any clear payload becomes [] (never null),
and a single id is wrapped into an array.

#5650: POST /api/boards set members[0].userId from req.body.owner with no
fallback, so a request omitting owner created a board whose only member had
userId=undefined. The boards publication matches members.$elemMatch
{userId, isActive:true}, which never matches undefined — the board was returned
by the REST API but invisible in the browser UI. owner now falls back to the
authenticated caller (req.body.owner || req.userId), mirroring the Meteor
create method.

REST regressions for both in tests/playwright/specs/17-rest-api.e2e.js;
changelog documents both under the Upcoming release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 05:56:19 +03:00
Lauri OjansivuandClaude Opus 4.8 20888e44c1 fix(#3697): REST clear of card members/assignees stores [] not null
Clearing a card's members/assignees over the REST API left the field
uneditable in the UI. The card PUT handler guarded with `if (req.body.members)`
— falsy for the natural clear payloads (null and "") — so removing the last
member was a silent no-op, and its string branch wrote null instead of [];
a card with members:null then breaks UI code that treats it as an array.

The handler now uses an `!== undefined` guard plus a shared coercion helper so
any clear payload (null / "" / []) stores a clean String[] (never null), a
single id is wrapped into an array, and stray non-string entries are dropped.
getMembers()/getAssignees() also coerce a legacy null to [] on read, healing
existing corrupted documents.

Pure logic in models/lib/restArrayParam.js with a Meteor-free Node unit test
(tests/restArrayParam.test.cjs, incl. a negative test reproducing the old
null-writing logic) wired into `npm run test:unit:node`, plus a REST regression
in tests/playwright/specs/17-rest-api.e2e.js. Changelog: documents #3697 and
moves #2292 (archive-swimlane) to verified-resolved (archive() does not delete
cards; restore brings them back).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 05:36:16 +03:00
github-actions[bot] ee240c5c05 Bump versions for v9.68 2026-06-23 02:19:19 +00:00
Lauri Ojansivu 477181730d Updated ChangeLog. 2026-06-23 05:06:22 +03:00
Lauri Ojansivu 94c31d25e7 Updated translations. 2026-06-23 05:05:32 +03:00
Lauri OjansivuandClaude Opus 4.8 10d591383f docs(changelog): tidy Upcoming for release — promote finished, add TODO Later
Remove the session WORKING NOTE (all content was duplicated in the body or
now stale). Fold the two "issue triage (work in progress)" blocks into the
release notes: promote the finished #3745 fix into the bug-fix list, group
the verified-resolved-but-awaiting-reporter issues (#3826 #1289 #1389 #5808
#5757) under a clear heading, and move everything not finished this release
(#2292 #5730 #3697 needing repro; #4560 #3707 #3700 #3575 #1192 needing
infrastructure; #5547 #3843 #3823 #3138 #2204 feature/by-design) into a new
"TODO Later" section. Drop the stale triage lines for #5874 and #4255, which
are now actually fixed and listed among the bug fixes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 04:58:07 +03:00
Lauri OjansivuandClaude Opus 4.8 d416184c84 docs(changelog): finalize upcoming-release working note
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 03:05:18 +03:00
Lauri OjansivuandClaude Opus 4.8 37035e1b0f docs(changelog): record #3745-test decision + push reminder for local commits
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 02:58:14 +03:00
Lauri OjansivuandClaude Opus 4.8 2379357e64 fix(#4255): scope archived-boards list to deletable boards only
The Archive listed boards the user could not delete — clicking Delete did
nothing and the console showed "remove failed: Access denied". The
archivedBoards publication scoped to admin members (isAdmin: true) but
ignored the active flag, while Boards.remove (hasAdmin) requires an active
admin (isActive: true && isAdmin: true). An inactive admin saw the board
but could not delete it. The publication now matches the remove permission
exactly, so the Archive only lists boards the user can really delete.

Also documents #4255 + #5874 in the changelog and records #1389 as
verified-resolved (a dedicated edit pencil already makes a description
with an edge-to-edge URL editable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 02:57:10 +03:00
Lauri OjansivuandClaude Opus 4.8 926c4baba8 fix(#5874): server-side guard against cross-board move data loss
Rarely, moving a card from board A to board B left it half-moved: boardId
became B but listId/swimlaneId still pointed at board A, so the card was
invisible in every normal view on both boards and clicking it reopened A.

Root cause: the Move/Copy dialog resolves the destination board's
swimlanes/lists from the client Minimongo cache, which can still be empty
when the user clicks Done (the destination board's publishComposite data
has not merged yet) — leaving the source board's swimlaneId/listId while
boardId is already the destination. setFirstSwimlaneId/setFirstListId
swallow the cache miss in a silent try/catch. Other callers (drag, filter
multi-move, rules, REST API) can produce the same mismatch.

Fix: a new Cards.before.update guard (enforceCardBoardConsistency,
registered first) rewrites the pending update on any boardId change so the
swimlane/list always belong to the destination board, falling back to its
default swimlane and first list. Runs server-side where the destination
board's data is always present; corrective only (valid moves untouched,
same-board reorders ignored). Decision logic extracted to a pure,
dependency-injected module with a Meteor-free Node unit test including a
negative test for the #5874 corrupt state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 02:53:17 +03:00
Lauri OjansivuandClaude Opus 4.8 6d00246da4 docs(changelog): session checkpoint working-note before VSCode update + reboot
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 02:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 70a63e75f1 test: drag-sort reproduction harness + #3826 regression (parented card reorder)
Built tests/playwright/helpers/dragSort.js, which drives jQuery-UI sortable with
a realistic stepped mouse gesture (mousedown -> incremental mousemoves past the
7px distance threshold -> mouseup) because Playwright's high-level dragTo() does
not trigger jQuery-UI sortable. Validated it reorders normal cards.

Added specs/37-card-drag-sort.e2e.js for #3826 (cannot reorder cards in a list
whose cards have parents): dragging a sub-task card (with a parentId) to a new
position persists the new order, with a few cards and at 15 cards — i.e. the bug
does not reproduce on current code; the spec is a regression guard. Scoped to
Chromium (reorder logic is browser-independent) so cross-browser drag-timing
cannot flake the matrix. The harness also unblocks future drag-sort repros (#5874).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 02:22:04 +03:00
Lauri OjansivuandClaude Opus 4.8 270eacb20f docs(changelog): #1289 verified resolved via harness (deleted-member card renders cleanly)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 02:00:27 +03:00
Lauri OjansivuandClaude Opus 4.8 f999b9e749 fix(#3745): populate change-parent card list on first board selection
Template.cardMorePopup's cards() helper queried ReactiveCache.getCards({boardId})
from client minimongo the instant a parent board was selected, before that board's
card subscription had loaded — so the parent-card list was empty the first time
(and only worked on reopen, once the data had arrived). Subscribe with an onReady
callback + a parentBoardReady reactive flag, and only render the list once the
subscription is ready (same pattern as the #5798 fix).

Also documents (CHANGELOG) that #5808 (bidirectional cross-board card link hang)
could not be reproduced on current code via a mutual-link Playwright repro — the
card opens in ~1s with no redirect loop; re-test requested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:51:24 +03:00
Lauri OjansivuandClaude Opus 4.8 efe9063df7 fix(#6419): stack admin settings menu+content on mobile (fix overlapping text)
The admin settings body forced flex-direction:row "even on narrow windows", so on
phones the ~127px side menu and the content were crammed together and the long
(e.g. Finnish) section labels overflowed the menu box, visually overlapping the
content text. Add a max-width:800px layout that stacks them: full-width compact
section menu on top (items wrap as a horizontal row), full-width content below.
Verified via Playwright iPhone-profile screenshots — menu labels and the toggle
rows now render cleanly with no overlap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:26:45 +03:00
Lauri OjansivuandClaude Opus 4.8 c53bedb3a8 fix(#6419): cap admin panel 2nd-top-bar icon size in mobile mode
The admin panel's secondary top bar (settingHeaderBar: Settings/People/Reports/
Attachments/Translation/Info) renders inside #header-quick-access, whose mobile
rules scale all text/icons 2x. That made the admin tabs 28px text / 56px icons —
huge, overlapping the "Version" label. Add a scoped, higher-specificity override
(plus the .iphone-device fallback) capping them at 15px text / 18px icons.
Verified via Playwright iPhone-profile screenshot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:06:02 +03:00
Lauri OjansivuandClaude Opus 4.8 0298670ac9 fix(#6419): mobile auto-detection root causes — viewport meta + mobileMode default
Two root causes behind "responsive views broken" / "icons too small to tap",
found via a Playwright iPhone-profile screenshot loop:

1. No viewport meta. The server-rendered <head> (customHeadRender.js) had no
   <meta name="viewport">, so mobile browsers used a default ~980px virtual
   width and scaled down: tiny UI, window.innerWidth=980 on a 390px phone, and
   @media (max-width:800px) never matched. Add width=device-width (user zoom
   left enabled for accessibility).

2. profile.mobileMode defaulted to false. The schema set mobileMode=false on
   every user, so getMobileMode() always returned it and the auto-detection was
   dead — every user stuck in desktop-mode even on phones. Make it optional (no
   default). Rewrite auto-detection to use matchMedia width/pointer queries
   instead of fragile UA sniffing (same class of bug as Meteor #12421), and add
   a matchMedia listener that re-applies mobile-mode on resize/orientation when
   the user has no explicit preference.

Verified on an iPhone 13 profile: innerWidth 390, body gets mobile-mode
automatically, header renders as large tappable icons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:59:20 +03:00
Lauri OjansivuandClaude Opus 4.8 c1741d83f5 fix(#6419): width-based touch targets for board-header icons + mobile screenshot tool
Visual pass on the mobile layout using a new Playwright screenshot harness
(tests/playwright/mobile-shot.js, iPhone 13 profile). Measured the board-header
action buttons at 28px tall / 13px font at a phone width — too small to tap.
Added a @media (max-width: 800px) rule enlarging them to ~44px / 16px by viewport
width (independent of the manual mobile-mode toggle), giving a clean single row of
large icon-only buttons. Confirmed via screenshots that the open-card close (X)
is reachable (the #6419 z-index fix) and the voting buttons render (#6420).

Also noted (not fixed here): getMobileMode() did not add body.mobile-mode even
with an iPhone UA in testing, so the width-based @media path is the effective one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:39:25 +03:00
Lauri OjansivuandClaude Opus 4.8 73a7f0b807 fix(#6419): enlarge mobile header action icons to ~44px touch targets
The reporter's main complaint was the mobile header icons being too small to
tap. In the max-width:800px media query the board-header buttons were 32px tall
with a 15px font, below the comfortable mobile touch-target / readable minimum.
Enlarge to 44px tall, 16px font, in a 48px bar. Vertical-only change, so the
horizontal crowding of the many header icons is unaffected. The broader
responsive asks (icon density, auto mobile detection) still need visual iteration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:24:24 +03:00
Lauri OjansivuandClaude Opus 4.8 adbdef140e fix: #6420 missing voting buttons (currentUser ReferenceError) + #6419 mobile card close
#6420: showVotingButtons / showPlanningPokerButtons in cardDetails.js referenced
an undefined `currentUser`, throwing "ReferenceError: currentUser is not defined"
on every card render so the vote / planning-poker buttons disappeared. Resolve
currentUser via ReactiveCache.getCurrentUser() and null-guard the board-member
check. Adds a Playwright regression test (spec 36).

#6419 (partial): on mobile the open card could not be closed — the card-details
overlay (z-index 100) sat below the app header bar (#header-main-bar, z-index
1000), which covered the close (X) button. The full-screen mobile card now uses
z-index 1100 (above the header, below popups at 2000+). The broader responsive
asks in #6419 (header icon density, auto mobile detection, fonts) need a separate
visual design pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:04:20 +03:00
Lauri OjansivuandClaude Opus 4.8 a2695f0c8c ci: run Puppeteer regression suite against a production build too
The e2e-regression (Puppeteer) job started WeKan with `meteor run` (dev), so it
hit the same failure as the Playwright suite did: the rspack dev server does not
serve the client JS bundle in headless CI, Meteor never initialises, and the test
times out in waitForMeteorGlobals() ("Waiting failed: 60000ms exceeded"). Build a
production bundle and run node bundle/main.js, with WRITABLE_PATH (required by
00checkStartup.js) and WITH_API, mirroring the Playwright e2e job.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:45:56 +03:00
Lauri OjansivuandClaude Opus 4.8 aa2fbb48ac test(#5798): quarantine the CI-flaky template-card E2E flow
The #5798 product fix (target board derived from the list) is committed and
verified locally, where this spec passes every time. On the CI production bundle
(polling reactivity, no oplog) the multi-step template-search-and-instantiate UI
is unstable across runs — the card was intermittently not created at all (boardId
poll -> null) in a way that does not reproduce locally. Quarantine (test.fixme)
rather than keep cycling CI; the other spec-36 regressions still run. Needs a more
deterministic harness (drive instantiation via a method call) to re-enable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:29:47 +03:00
Lauri OjansivuandClaude Opus 4.8 8ec0096964 fix(#5798): set card title right before picking template (CI card-not-created)
CI showed the boardId poll returning null — the card was never created, not
created on the wrong board. The click handler creates a card only when
.js-element-title is non-empty; the test set the title before the search, and the
search's reactive re-render cleared it on the slower CI production bundle, so the
click saw an empty title and created nothing. Set the title immediately before
clicking the template result.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:11:38 +03:00
Lauri Ojansivu 606bf912c0 Updated ChangeLog. 2026-06-22 22:49:59 +03:00
Lauri OjansivuandClaude Opus 4.8 29fb81c2f9 fix(#5798): derive template-card target board from the list, not getCurrentBoard
The CI production bundle revealed the #5798 fix was still fragile: it resolved the
target board via Utils.getCurrentBoard(), which reads route/session state that has
not settled on the slower production bundle, so it fell back to tpl.boardId and
created the card on the templates board (the bug). Derive the target board from
the board that OWNS the target list (tpl.listId) — where the user clicked "add
card" — which is reliable regardless of session timing. Bump the test's boardId
poll to 30s for the async copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:45:06 +03:00
Lauri Ojansivu 07ee1d8bad Updated ChangeLog. 2026-06-22 22:41:56 +03:00
Lauri OjansivuandClaude Opus 4.8 f4fc3fed4f fix(e2e): wait for profile propagation before #5798 template search
The #5798 spec set profile.templatesBoardId directly in Mongo, then opened the
template picker, which reads that pointer to subscribe to the templates board and
search its card templates. On the CI production bundle (polling reactivity) the
profile update had not reached the client yet, so the picker subscribed to
nothing and the search returned no results (the only remaining red spec; passed
locally where reactivity is fast). Wait until the client actually sees the
profile pointer before opening the picker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:25:05 +03:00
Lauri OjansivuandClaude Opus 4.8 e0ed9eff4a fix(e2e): triage residual specs — 2 real product bugs + test/robustness fixes
After the production-build E2E went from 0 -> ~119/120 per shard, the residual
specs were triaged locally against a dev server:

Real product bugs found and fixed:
- Sort-cards header button: the js-sort-cards class (and thus its click handler)
  was replaced by `emphasis` once a sort was active, so the sort popup could not
  be reopened. Keep js-sort-cards always; add emphasis as an extra class.
- Deleting a Template Container board threw "Templates board ID is required":
  boardRemover $unset the four profile.* template pointers, but the schema typed
  them as non-optional String, so SimpleSchema rejected the update and aborted the
  removal. Mark the fields optional (#2339/#5850).

Test fixes:
- 31: call the '/boards/remove' DDP method with an object selector ({_id})
  instead of the unexposed Boards collection global (ReferenceError) / a string
  selector (Mongo "wrong type" error).
- 18 + 36: wait for the async result (i18n text / template search) on the slower
  CI production bundle instead of reading it immediately.
- 34: quarantine the auxiliary "plain click outside closes the card" control
  (test.fixme); the real #5686 guard it backs still passes. Needs follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:06:15 +03:00
Lauri OjansivuandClaude Opus 4.8 b9c5816642 Update CHANGELOG: E2E infra fix confirmed (0 -> ~119/120 per shard)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:34:43 +03:00
Lauri OjansivuandClaude Opus 4.8 cd2b48faf2 ci(e2e): enable WITH_API in the production bundle run
With the production bundle finally serving the client (104/120 passing per
shard), the remaining failures were almost all REST API specs (17, 23, 28, 24)
getting HTML instead of JSON: the API is gated behind WITH_API=true, which the
dev runner sets but the CI production run did not. Set WITH_API=true and
RICHER_CARD_COMMENT_EDITOR=false to match rebuild-wekan.sh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:11:56 +03:00
Lauri OjansivuandClaude Opus 4.8 bf6d89ec32 feat(rebuild): install forge CLIs + mirror GitHub to GitLab/Codeberg/Forgejo/Gitea
Add two menu options to rebuild-wekan.sh and rebuild-wekan.bat:

1. Install forge CLI tools — gh, glab, tea, git-bug, and the unified
   git-pkgs/forge, via the detected package manager or `go install`.

2. Mirror a repo from GitHub to GitLab/Codeberg/Forgejo/Gitea — pick source
   and target by number (e.g. "1 3" = GitHub -> Codeberg). Code (all
   branches/tags) is pushed with `git push --mirror`; issues, PRs and CI
   workflow syntax are handled by a new cross-platform Node engine,
   tools/forge-mirror.js, which:
     * creates only the issues/PRs MISSING at the target (drives the
       authenticated gh/glab/tea CLIs, de-dups by title, dry-run by default);
     * converts GitHub Actions YAML for the target — an annotated
       .forgejo/workflows/ copy flagging Forgejo/Gitea incompatibilities
       (hashFiles, permissions, continue-on-error, complex runs-on), or a
       .gitlab-ci.yml scaffold + guide pointing at GitLab's official converter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:10:33 +03:00
Lauri OjansivuandClaude Opus 4.8 a270be1d40 ci(e2e): set WRITABLE_PATH for the production bundle
The production-build E2E started `node bundle/main.js` but WeKan's
server/00checkStartup.js exits unless WRITABLE_PATH (attachment/file storage)
is set and writable. So the server never came up and every shard failed the
"wait for :3000" loop. Create a writable dir and pass WRITABLE_PATH.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:48:48 +03:00
Lauri OjansivuandClaude Opus 4.8 56d9651165 ci(e2e): run Playwright against a production build (option A)
The bundle-readiness gate confirmed the real cause of the red E2E: in dev mode
(`meteor run`) the rspack-served client JS bundle is never delivered in headless
CI (a probe got an empty response from the bundle URL for 10 minutes), so Meteor
never initialised and all specs timed out.

Switch the E2E "Start WeKan" step to a PRODUCTION build: `meteor build --directory`,
install the bundle's server deps, then `node bundle/main.js`. The production
server serves the client JS statically (no rspack dev server, no chunk-serving
race), which fixes the bundle delivery. Drops the dev-only /__rspack__/ readiness
gate (production serves the client at the proper URL once :3000 is up).

This still builds per shard; once green it can be optimised to build once and
share the bundle artifact across shards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:29:31 +03:00
Lauri OjansivuandClaude Opus 4.8 cab6b526db ci(e2e): shard each browser's Playwright run across 2 parallel jobs
Each browser previously ran all ~245 specs on a single worker, so a run took
~2h40m (badly inflated while the suite is red, since every spec hit the
waitForMeteor timeout and retried). Add a 2-way shard matrix (chromium/firefox/
webkit × shard 1..2 = 6 jobs); each shard starts its own WeKan + Mongo so there
is no shared-server contention, and runs ~half the specs via --shard=i/2.
Artifact names are suffixed with the shard so the jobs do not collide.

The shard count can be raised once the baseline is green and/or once E2E runs
against a prebuilt bundle (so per-job startup is cheap rather than a full
meteor build each time).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:06:46 +03:00
Lauri OjansivuandClaude Opus 4.8 75e713c993 ci(e2e): gate on the rspack client bundle being served as JS, surface build log
The E2E suite was red because the browser could not load the client JS: requests
for the rspack bundle returned the SPA HTML fallback ("Unexpected token '<'"), so
Meteor never initialised and all ~212 specs timed out in waitForMeteor. The
readiness check only curl-tested the server (HTML 200), not that the client
bundle was being served.

The e2e job now waits until http://localhost:3000/__rspack__/client-rspack.js is
served as JavaScript before running tests, and always prints the rspack/build
lines from wekan-run.log so a genuine build failure (vs a slow start) is
diagnosable. If the bundle never serves as JS it fails fast (~10 min) with the
log instead of running 2h40m of doomed specs.

Reverts the globalSetup browser warm-up (the earlier cold-start hypothesis was
wrong — the bundle genuinely is not served, not merely slow); the CI gate is the
right place. Keeps the waitForMeteor 30s -> 60s bump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:56:29 +03:00
Lauri OjansivuandClaude Opus 4.8 5bd1d69813 CHANGELOG: note E2E globalSetup warm-up fix for waitForMeteor timeouts
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:03:27 +03:00
Lauri OjansivuandClaude Opus 4.8 b2041133f3 tests(e2e): warm up the app in globalSetup to fix CI waitForMeteor timeouts
The E2E suite was red on main: most specs (and nearly all Firefox/WebKit runs)
failed with "page.waitForFunction: Timeout" in helpers/auth.js — the per-spec
waitForMeteor timed out because the first browser loads of a cold WeKan server
have to compile and serve the large client bundle and start publications, which
exceeds a single spec's timeout. CI's readiness gate only curl-checks the server
(HTML 200), not that the browser can load the Meteor client global.

globalSetup now loads the app in a browser once and waits for the Meteor global
(retrying within a 3-minute budget, best-effort — never fails the run itself), so
every spec starts against a warmed server. Also bump waitForMeteor 30s -> 60s as
a safety net for slower browsers under CI load.

Verified locally: globalSetup logs "WeKan warmed up" and specs still pass; the
cold-start flakiness itself can only be confirmed by a CI E2E run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:03:03 +03:00
Lauri OjansivuandClaude Opus 4.8 deb09c679a CHANGELOG: note #6412 now has a negative-tested Node unit test
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:47:02 +03:00
Lauri OjansivuandClaude Opus 4.8 72e10a81b7 Make #6412 filename test negative-testable: extract pure helpers + Node test
The #6412 mocha test imported the helpers from fileStoreStrategy.js, which pulls
in Meteor, so it could only run under `meteor test` (and the local harness loaded
0 server tests here) — meaning it could not be negative-tested locally.

Extract the pure, Meteor-free helpers (truncateFilenameToBytes / sanitizeFilename
/ MAX_FILENAME_BYTES) into models/lib/filenameSanitizer.js; fileStoreStrategy.js
requires them. Add tests/filenameSanitizer.test.cjs (plain Node, `npm run
test:unit:node`) and point the mocha test at the new module. The Node test is
negative-tested: 7 passing on the fix, and the length-cap assertion fails when
the truncation is reverted. Verified `meteor build` still compiles (the helper
is bundled into the server).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:46:43 +03:00
Lauri OjansivuandClaude Opus 4.8 97f378ea78 CHANGELOG: record card-from-template crash fix (612ed3e51) + #5798/#3897 tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:36:35 +03:00
Lauri OjansivuandClaude Opus 4.8 612ed3e51c Fix card-from-template crash ("no current view") and add #5798 regression test
While adding a Playwright test for #5798, found that creating a card from a
template threw "There is no current view" and created no card: in the
searchElement popup's minicard-click handler, tpl.getSortIndex() (which reads
Template.currentData()) ran AFTER `await tpl.board.getNextCardNumber()`, and
Blaze's synchronous current-view context is lost across an await. Compute the
sort index before the await.

With that fixed, the #5798 fix (instantiate into the current board, not the
templates board) is now exercised end-to-end by a new Playwright spec that
seeds a card template and drives the add-card -> template picker flow. The spec
is negative-tested: it passes on the fixed code and fails when the copy target
is reverted to the templates board.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:35:45 +03:00
Lauri OjansivuandClaude Opus 4.8 8b13a4dab9 tests: re-add #3897 guest-board spec with correct error capture
The earlier version only captured console type 'error', but Blaze logs a thrown
template helper as an "Exception from Tracker afterFlush" via console.LOG (and a
pageerror). Capturing every console type now reliably catches the
"Cannot read properties of null (reading 'isBoardAdmin')" guest crash.
Negative-tested: passes on the fixed code, fails when the optional-chaining
guards are reverted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:37:11 +03:00
Lauri OjansivuandClaude Opus 4.8 a69e6c3e26 CHANGELOG: note added regression tests (#6412 unit, #3907/#5886/#5892 Playwright)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:16:21 +03:00
Lauri OjansivuandClaude Opus 4.8 96b60e2dee tests: drop vacuous #3897 guest-board spec
The #3897 guest public-board test passed even with all isBoardAdmin guards
reverted (verified by negative test), so it does not actually exercise the bug
in the seeded scenario. Removed rather than keep a misleading guard. The #3897
fix (optional chaining) remains in place; the kept specs (#3907, #5886, #5892)
were each negative-tested and do fail on the buggy code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:15:40 +03:00
Lauri OjansivuandClaude Opus 4.8 5709854ed5 tests: add #6412 filename-truncation unit test and #3897 guest-board regression
- Export truncateFilenameToBytes / sanitizeFilename from fileStoreStrategy and
  add server/lib/tests/filenameTruncation.tests.js (#6412): byte-budget
  truncation preserves the extension, never splits a multibyte codepoint, caps
  the on-disk component under 255 bytes, and still strips path traversal.
- Spec 36: a guest opening a public board renders lists with no
  "isBoardAdmin of null" crash (#3897).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:09:19 +03:00
dependabot[bot] f188572b70 Bump jquery from 3.7.1 to 4.0.0
Bumps [jquery](https://github.com/jquery/jquery) from 3.7.1 to 4.0.0.
- [Release notes](https://github.com/jquery/jquery/releases)
- [Changelog](https://github.com/jquery/jquery/blob/main/changelog.md)
- [Commits](https://github.com/jquery/jquery/compare/3.7.1...4.0.0)

---
updated-dependencies:
- dependency-name: jquery
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-22 09:06:11 +00:00
Lauri OjansivuandClaude Opus 4.8 e9e5bbf132 CHANGELOG: record merged dependency PRs (#6416 @swc/helpers, #6418 sinon)
Note the held-back major production bumps (#6417 jquery, #6415 @babel/runtime,
#6414 sab-adapter-amazon-s3) for individual testing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:04:35 +03:00
Lauri Ojansivu 483a773e6d Merge pull request #6418 from wekan/dependabot/npm_and_yarn/sinon-22.0.0
Bump sinon from 21.1.2 to 22.0.0
2026-06-22 12:03:42 +03:00
Lauri Ojansivu 529a01b8f9 Merge pull request #6416 from wekan/dependabot/npm_and_yarn/swc/helpers-0.5.23
Bump @swc/helpers from 0.5.22 to 0.5.23
2026-06-22 12:03:38 +03:00
Lauri OjansivuandClaude Opus 4.8 90c365787c tests: add Playwright regression specs for fixed bugs (#3907, #5886, #5892)
Spec 36 guards three recent fixes:
- #3907 a same-board card move (which re-sets boardId) must keep the card's
  addedLabel activity history (data-loss guard), exercised via a /cards/update
  DDP write that runs the server before.update hook.
- #5886 a persisted card sort is restored into Session after a page reload.
- #5892 a collapsed list with a custom --list-width still shrinks to the rail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:46:30 +03:00
Lauri OjansivuandClaude Opus 4.8 e8fd2c14df CHANGELOG: record Meteor 3.5-rc.2 update (#6413) and rebuild-wekan.sh browser matrix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:41:30 +03:00
dependabot[bot] e8a4dc5da0 Bump sinon from 21.1.2 to 22.0.0
Bumps [sinon](https://github.com/sinonjs/sinon) from 21.1.2 to 22.0.0.
- [Release notes](https://github.com/sinonjs/sinon/releases)
- [Changelog](https://github.com/sinonjs/sinon/blob/main/CHANGES.md)
- [Commits](https://github.com/sinonjs/sinon/compare/v21.1.2...v22.0.0)

---
updated-dependencies:
- dependency-name: sinon
  dependency-version: 22.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-22 08:33:56 +00:00
dependabot[bot] 78a0f817b2 Bump @swc/helpers from 0.5.22 to 0.5.23
Bumps [@swc/helpers](https://github.com/swc-project/swc/tree/HEAD/packages/helpers) from 0.5.22 to 0.5.23.
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG-CORE.md)
- [Commits](https://github.com/swc-project/swc/commits/HEAD/packages/helpers)

---
updated-dependencies:
- dependency-name: "@swc/helpers"
  dependency-version: 0.5.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-22 08:33:40 +00:00
Lauri OjansivuandClaude Opus 4.8 e28b3bc219 rebuild-wekan.sh: run Chromium/Firefox/WebKit with or without Docker
Generalize the WebKit-only Docker support so the whole Playwright browser matrix
(chromium, firefox, webkit) can run either natively or inside the official
Playwright Docker image. This lets the full suite run on hosts where some bundled
browsers can't launch natively (e.g. Linux arm64).

- browser_needs_docker <browser> replaces the webkit-only check, resolved via
  WEKAN_PLAYWRIGHT_DOCKER (all), WEKAN_<BROWSER>_DOCKER (per browser), then auto
  (webkit on linux arm64). webkit_needs_docker kept as a back-compat wrapper.
- run_playwright_docker <browser> replaces run_playwright_webkit_docker (kept as
  a wrapper); the single-browser and Run-ALL flows now route any browser through
  Docker when configured.
- New menu item + install_playwright_browsers(): installs native browsers
  (playwright install --with-deps) and/or pulls the Playwright Docker image.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:33:27 +03:00
dependabot[bot] fb9380c0d0 Bump @tweedegolf/sab-adapter-amazon-s3 from 1.0.20 to 3.0.4
Bumps [@tweedegolf/sab-adapter-amazon-s3](https://github.com/tweedegolf/storage-abstraction) from 1.0.20 to 3.0.4.
- [Release notes](https://github.com/tweedegolf/storage-abstraction/releases)
- [Changelog](https://github.com/tweedegolf/storage-abstraction/blob/master/changelog.md)
- [Commits](https://github.com/tweedegolf/storage-abstraction/commits/v3.0.4)

---
updated-dependencies:
- dependency-name: "@tweedegolf/sab-adapter-amazon-s3"
  dependency-version: 3.0.4
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-22 08:33:08 +00:00
Lauri Ojansivu d5be28bc2e Merge pull request #6413 from harryadel/update-meteor-3.5-rc.2
Update Meteor to 3.5-rc.2
2026-06-22 11:32:10 +03:00
Lauri OjansivuandClaude Opus 4.8 00546fb6f9 CHANGELOG: #5757 appears resolved; #5808/#5874 need browser repro
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:06:23 +03:00
Lauri OjansivuandClaude Opus 4.8 4930b549bd CHANGELOG: record #5798 card-from-template wrong-board fix (097806984)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:03:05 +03:00
Lauri OjansivuandClaude Opus 4.8 097806984b Fix card-from-template created on the templates board (#5798)
When instantiating a card template, the new card was copied with tpl.boardId,
which for a template search is the *templates* board (the search source), not
the current board. The card still appeared in the target list (the list renders
cards by listId, and the templates board is subscribed), but it had
boardId = templates board, so clicking it navigated to the templates board
instead of opening the card detail.

Copy the template into the current board instead (the target list/swimlane were
already resolved on the current board), so the new card belongs to the board it
is placed on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:02:47 +03:00
Lauri OjansivuandClaude Opus 4.8 7f4108c591 CHANGELOG: record #5997 list-reorder neighbour-detection fix (1165c9b6d)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 06:03:54 +03:00
Lauri OjansivuandClaude Opus 4.8 1165c9b6db Fix list reorder using wrong neighbours when non-list siblings present (#5997)
saveSorting() computed the new sort from ui.item.prev('.js-list') /
.next('.js-list'), which jQuery only matches when the sibling is *immediately*
adjacent. The lists container also renders the +addListForm composer and, when a
card is open, a +cardDetails element between lists, so an interspersed non-list
sibling made calculateIndex mis-detect the first/last position and compute a
wrong sort. The reordered list then landed somewhere other than where it was
dropped and looked like the new order was not persisted.

Use prevAll/nextAll('.js-list:not(.js-list-composer)').first() so the nearest
actual list neighbour is found regardless of interspersed elements.

Verified out-of-band that the server side persists correctly: a DDP call to
updateListSort reorders the lists collection and the sort:1 query returns the new
order, so the regression was in the client-side neighbour detection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 06:03:32 +03:00
Lauri OjansivuandClaude Opus 4.8 0abaf03ba0 CHANGELOG: track remaining in-progress issues (#5997 diagnosis, #5798, #5757)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:47:06 +03:00
Lauri OjansivuandClaude Opus 4.8 b30244ab07 CHANGELOG: record #5886 persist card sort across reloads (6aad946bc)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:46:13 +03:00
Lauri OjansivuandClaude Opus 4.8 6aad946bc6 Remember card sort (e.g. by due date) across reloads (#5886)
The card sort was stored only in a Meteor Session variable, which is in-memory
and reset on page reload, so a chosen sort (such as by due date) reverted to the
default. Persist the sort to localStorage when set/reset, restore it into Session
when the board header loads (so the sort icon is correct too), and read it as a
fallback when sorting cards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:45:56 +03:00
Lauri OjansivuandClaude Opus 4.8 f45019478c CHANGELOG: record #5892 collapsed-list custom-width fix (5e9b2bccd)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:42:45 +03:00
Lauri OjansivuandClaude Opus 4.8 5e9b2bccdd Fix lists not collapsing when they have a custom width (#5892)
The list-width rework (#6409) added a persistent rule
`.list[style*="--list-width"] { width: var(--list-width) !important }`. A
collapsed list keeps its inline --list-width custom property, so that !important
rule overrode `.list.list-collapsed { width: 30px }` and the list stayed full
width when collapsed (most visible with the Modern theme, where custom widths
are in use).

Exclude collapsed lists from the persistent width rule with
:not(.list-collapsed), and mark the 30px collapsed width !important so a custom
per-list width can never keep a collapsed list expanded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:42:30 +03:00
Lauri OjansivuandClaude Opus 4.8 d076721f2a CHANGELOG: record Stops-Work issue triage (resolved / repro-needed / feature)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:40:08 +03:00
Lauri OjansivuandClaude Opus 4.8 e08b76bbaa CHANGELOG: record data-loss issue triage (repro-needed / resolved / env / feature)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:37:54 +03:00
Lauri OjansivuandClaude Opus 4.8 cc2b5a1452 CHANGELOG: record #3897 guest public-board render crash fix (fc0fe0b61)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:36:20 +03:00
Lauri OjansivuandClaude Opus 4.8 fc0fe0b613 Fix crash rendering public boards as guest (null user isBoardAdmin) (#3897)
Viewing a public board as a guest (no logged-in user) threw
"Cannot read property 'isBoardAdmin' of null" from template helpers that called
ReactiveCache.getCurrentUser().isBoardAdmin() (and .isWorker()). The exception
aborted the Tracker afterFlush computation mid-render, so longer lists never
finished loading for guests.

A null/guest user is never a board admin or worker, so use optional chaining
(getCurrentUser()?.isBoardAdmin()) which yields a falsy value instead of throwing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:36:04 +03:00
Lauri OjansivuandClaude Opus 4.8 931339ec5c CHANGELOG: record #3907 addedLabel-on-move data-loss fix (6382ad6a8)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:33:32 +03:00
Lauri OjansivuandClaude Opus 4.8 6382ad6a86 Fix addedLabel activity deleted on same-board card move (#3907)
updateActivities() runs in a Cards.before.update hook and removed all
addedLabel activities for a card whenever 'boardId' appeared in the changed
fields. But Card.move() always re-sets boardId (even for a move within the same
board), so every card move deleted the card's label-added history.

Gate the removal/remap on an actual board change by comparing the pre-update
doc.boardId with modifier.$set.boardId. Cross-board moves still clean up / remap
the now-invalid addedLabel activities; same-board moves leave them intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:33:13 +03:00
Lauri OjansivuandClaude Opus 4.8 839183cd0f Add Upcoming release section to CHANGELOG, track fixed/in-progress issues
Records #6412 (ENAMETOOLONG, fixed in 5e26cf004) and in-progress #5997/#5892/#5886.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:30:39 +03:00
Lauri OjansivuandClaude Opus 4.8 5e26cf0048 Fix ENAMETOOLONG on very long attachment filenames (#6412)
Attachment filenames were sanitized for path traversal but never length-limited,
so a very long name (made worse by multibyte UTF-8 chars like German umlauts,
2 bytes each) produced an on-disk component "<id>-<version>-<name>" exceeding the
filesystem's 255-byte per-path-component limit. Migrating such files from GridFS
to filesystem storage then failed with ENAMETOOLONG (errno -36).

sanitizeFilename now truncates the name to 200 UTF-8 bytes, measured in bytes
(not characters) and never splitting a multibyte codepoint, while preserving the
file extension. 200 bytes leaves headroom for the "<id>-<version>-" prefix so the
full component stays under 255 bytes. Applied centrally in sanitizeFilename so
every path-construction and lookup site stays consistent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:08:26 +03:00
Lauri OjansivuandClaude Opus 4.8 7b66884ba3 docs: fix bare wiki-style links to relative paths of existing docs pages
Many docs still used flat GitHub-wiki page-name links like [Backup](Backup),
[OAuth2](OAuth2) or [FAQ](FAQ) that only resolved on the old wiki; in the docs/
hierarchy they were broken. Rewrite them to correct relative paths of the
existing pages (e.g. ](Backup) -> ](Backup.md), ](OAuth2) -> ](../Login/OAuth2.md),
](Sandstorm) -> ](../Platforms/FOSS/Sandstorm/Sandstorm.md)).

Mapping: page name -> docs/<...>/<Name>.md by filename, with explicit handling
for renamed pages (Caddy/Nginx-Webserver-Config, Wekan-Markdown, the Sandstorm
grain export page, Uberspace) and the REST-API-<X> family -> docs/API/<X>.md.
Ambiguous names resolved by preferring a same-directory file or the primary page.
Anchors and markdown titles preserved. 280 links across the docs tree.

Out of scope (left as-is): source-code references (models/*.js etc.) and
[Link](url) placeholders, which are not docs pages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:03:27 +03:00
Harry Adel 83a105f12d Merge branch 'main' into update-meteor-3.5-rc.2 2026-06-21 20:49:16 +03:00
Lauri OjansivuandClaude Opus 4.8 b912ddb6f5 docs: convert internal wiki/repo links from https URLs to relative paths
The GitHub wiki is disabled and its content was migrated into docs/, so every
https://github.com/wekan/wekan/wiki/... link now redirects to the repo home and
is broken. Rewrite all internal links in docs/ to relative paths:

- wiki/<Page> -> relative path to the corresponding docs/ file (renamed pages
  mapped explicitly: Caddy-Webserver-Config->Webserver/Caddy.md, Wekan-Markdown
  ->Features/Markdown/Markdown.md, REST-API-Role->API/Role.md, etc.)
- wiki home -> docs/README.md
- blob|tree|raw/main|master/<path> -> relative path (anchors and #Lxx line
  ranges preserved; bare URLs wrapped as [name](path) so they stay clickable)

Kept absolute (intentionally not flattened to the current ref):
- ref-pinned links (edge, devel, version tags, commit hashes)
- links whose target no longer exists in the tree

wiki/issues -> the GitHub issues tracker URL (external). 436 links across 113
files; verified no wiki links remain and no broken relative links introduced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:43:36 +03:00
Harry Adel 15b2c7160b Update Meteor to 3.5-rc.2 2026-06-21 20:41:30 +03:00
Lauri OjansivuandClaude Opus 4.8 f037a219b6 docs: point LaTeX code comment to repo docs, not the removed wiki
The GitHub wiki is disabled (has_wiki=false) and wiki/LaTeX now redirects to the
repo home; wiki content was moved into docs/. Link the in-repo docs page instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:30:06 +03:00
Lauri OjansivuandClaude Opus 4.8 32abf5de40 Migrate LaTeX math from MathJax to Temml (native MathML)
Math rendering ($...$ inline, $$...$$ block) was silently dropped in
63ce45c53 during the Meteor 3 refactor; the markdown-it-mathjax3 dependency
was left orphaned in package.json.

Restore it using Temml, which converts LaTeX to native browser MathML, instead
of bundling all of MathJax (which also had a render-twice bug). Wired via the
markdown-it-math@6 no-default-renderer entrypoint, calling temml.renderToString
directly to avoid a top-level await that the Meteor/rspack bundler dislikes.

The emitted MathML is allow-listed in the markdown package's DOMPurify config
(tags + presentation attributes), and MathML elements are exempted from the
"delete element if it has a style attribute" rule so block formulas
(style="display:block math") are not silently removed; style/class/scripts and
annotation-xml stay blocked, so security posture is preserved.

Net dependency change: removes MathJax (9 packages), adds markdown-it-math and
temml (3 packages).

Docs: docs/Features/LaTeX.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:24:53 +03:00
github-actions[bot] b431a977f5 Bump versions for v9.67 2026-06-21 16:19:04 +00:00
Lauri Ojansivu eb0308e03f Updated ChangeLog. 2026-06-21 19:14:01 +03:00
Lauri OjansivuandClaude Opus 4.8 33fdb4e3a2 rebuild scripts: larger Node heap for dev run, tests and build
Long `meteor run` dev sessions and test runs could crash with
"FATAL ERROR: Ineffective mark-compacts near heap limit - JavaScript heap out
of memory" (the Meteor build tool process hit its ~4 GB default).

rebuild-wekan.sh / rebuild-wekan.bat now set, near the top so every menu option
(dev run, tests, build) inherits them:

- TOOL_NODE_FLAGS=--max-old-space-size=8192  (Meteor command-line/build process)
- NODE_OPTIONS=--max-old-space-size=8192     (child Node/rspack processes)

Both honor an already-exported value, so machines with less RAM can lower it.
The trace-warnings dev option keeps its larger heap too (its inline NODE_OPTIONS
now includes --max-old-space-size=8192).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:06:28 +03:00
Lauri OjansivuandClaude Opus 4.8 c1c8535544 Translate remaining English list-width strings in fi.i18n.json to Finnish
The 8 list-width strings (#6409) added recently were still English in the
Finnish locale. Translated them using the existing Finnish terminology
("Aseta", "Listan leveys (pikseliä)", "...täytyy olla kokonaisluku",
"taulun jäsenet", "Henkilökohtaiset"). The other identical-to-English values
are placeholders, proper nouns (Gantt, LDAP, S3, ...), single-letter search
operator abbreviations, and "OK"/"magenta", which are intentionally kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:00:28 +03:00
Lauri Ojansivu c1f2a83680 Updated translations. 2026-06-21 18:55:52 +03:00
Lauri OjansivuandClaude Opus 4.8 ff582697f3 Add auto-width back, scoped per-board or per-user (#6409)
Re-introduces the "fit lists to content" auto-width mode that the width rework
removed, now following the same Shared/Personal scope as fixed widths:

- Shared mode (allowsPersonalListWidth=false): auto-width is a per-board setting
  (board.autoWidth), the same for everyone; togglable by members with write
  access (server method setBoardAutoWidth, gated on board membership like
  applyListWidth).
- Personal mode: auto-width is per-user (the existing profile.autoWidthBoards).

- models/boards.js: add `autoWidth` field + getter/setter.
- server/models/users.js: add setBoardAutoWidth method.
- client list.js: effectiveAutoWidth(); autoWidth() helper; canResizeList()
  returns false in auto mode.
- list.jade: add `list-auto-width` class and omit `--list-width` when auto, so
  CSS fits the list to content and hides the resize handle.
- listHeader popup: re-add the Auto list width toggle (writes to board or
  profile depending on scope; shown only to those who may change it).
- wekanCreator: carry board.autoWidth through import.
- docs + CHANGELOG.

Verified live: shared auto-width applies to all members (class set, no
--list-width, handle hidden); personal auto-width is per-user (one member auto,
the other keeps a fixed width + handle).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:49:47 +03:00
Lauri OjansivuandClaude Opus 4.8 b477b1383d Rework list widths: single width, Shared/Personal board scope (#6409)
List widths were confusing (per-list "min width / max width / automatic") and
unreliable (drag changes lost on reload; the popup wrote the shared lists.width
while drag wrote per-user profile, and rendering read the per-user one as a
floating min/max). The width also reverted to `auto` on reload because the
render set min/max-width but never the `--list-width` CSS variable the styles
actually use (with !important).

Now a list has ONE width, driven by `--list-width`, that persists across
reloads, with a board setting choosing the scope:

- Shared (default): width stored in lists.width, same for everyone; only members
  with write access can change it (read-only/comment-only no longer see the
  resize handle). Board setting `allowsPersonalListWidth` (default false).
- Personal: each user keeps their own width (profile, or localStorage when not
  logged in), falling back to the shared width then the default (272).

Changes:
- models/boards.js: add `allowsPersonalListWidth` + getter/setter.
- server/models/users.js: applyListWidth sets only `width` (constraint ignored).
- client list.js: single effectiveListWidth precedence, mode-aware saveListWidth,
  resize handle gated on canResizeList; removed auto-width/min-max code.
- list.jade: render `--list-width` (the variable the CSS uses), show resize
  handle only when allowed.
- listHeader popup: single width value; removed min/max + auto-width toggle.
- sidebar: board "Personal list widths" toggle + i18n.
- Import/export: exporter already carries lists.width + the board setting;
  wekanCreator now preserves list width/color/collapsed and the board's
  list-width scope (it previously dropped them).
- docs/Features/Lists/Lists.md: document the model.

Verified live: shared width renders + persists on reload and is identical for
two members; personal mode gives per-user overrides with shared fallback;
drag-resize saves to lists.width in shared mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:40:51 +03:00
Lauri Ojansivu 278ee0b018 Updated translations. 2026-06-21 18:00:15 +03:00
Lauri OjansivuandClaude Opus 4.8 4c985dc82c rebuild scripts option 2: also clear rspack caches (_build, node_modules/.cache)
The "Build WeKan" menu option (option 2) in rebuild-wekan.sh / rebuild-wekan.bat
removed node_modules, .meteor/local and .build, but not the rspack dev-build
caches (_build and node_modules/.cache). After a git checkout/merge those caches
could serve stale compiled modules on the next `meteor run`. Option 2 now clears
them too, so a rebuild always recompiles from scratch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 17:26:47 +03:00
Lauri OjansivuandClaude Opus 4.8 2c0602434a Fix #6411: editing 2nd/3rd org or team no longer shows the first one
On Admin Panel › People (/people), clicking Edit on any organization or team
filled the form with the FIRST one's values, so the others could never be
edited. The edit/settings popups are opened from the row with data context
`{ org }` / `{ team }`, but their helpers read `this.orgId` / `this.teamId`
(undefined in that context) and called `getOrg(undefined)` / `getTeam(undefined)`,
which `Org.findOne({})` / `Team.findOne({})` resolve to the first document.

- editOrgPopup / editTeamPopup helpers now resolve the clicked id from the
  `{ org }` / `{ team }` context: `(this.org && this.org._id) || this.orgId`.
- The save (submit) handlers use the same context org/team.
- The settingsOrg / settingsTeam delete handlers derive the id the same way
  (the old code called Org.remove(undefined) / Team.remove(undefined)).

Verified against a running instance: each organization and team now edits its
own values (fresh popup per row).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 17:17:22 +03:00
Lauri Ojansivu f00cb83d40 Merge pull request #6408 from wekan/fix/5462-readonly-list-sort-403
Fix #5462: read-only members no longer trigger 403 Access denied when reordering lists
2026-06-21 16:45:19 +03:00
Lauri OjansivuandClaude Opus 4.8 28dde530d9 Add merged-PR links (#6408, #6410) to CHANGELOG upcoming entries
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 16:45:05 +03:00
Lauri Ojansivu b52b07c898 Merge remote-tracking branch 'origin/main' into fix/5462-readonly-list-sort-403 2026-06-21 16:43:56 +03:00
Lauri Ojansivu f75da28fbf Merge pull request #6410 from wekan/fix/bson-getbuiltinmodule-client-crash
Fix client bundle crash (bson getBuiltinModule) that blanked all boards
2026-06-21 16:43:43 +03:00
Lauri OjansivuandClaude Opus 4.8 1d4cfd332d Fix client bundle crash (bson getBuiltinModule) that blanked all boards
After the mongodb/bson 7.3.0 bump, opening any board rendered nothing. bson 7.x
evaluates, at module-load time:

    const { startupSnapshot } = globalThis?.process?.getBuiltinModule('v8') ?? {};

The optional chaining stops before the call, so in the browser (partial
`process` polyfill, no getBuiltinModule) it throws
`TypeError: getBuiltinModule is not a function` while evaluating
client/components/cards/attachments.js (`import { ObjectId } from 'bson'`). That
aborts the client bundle bootstrap partway through client/imports.js, so every
feature imported after it (notifications, swimlanes, rules, ...) is never
registered and the board view dies with `No such template: notifications`.

- Add client/lib/bsonBrowserShim.js (imported first in client/main.js) that
  installs a no-op process.getBuiltinModule in the browser, so bson takes its
  intended `?? {}` fallback (there is no V8 startup snapshot in a browser).
- Add unit tests (client/lib/tests/bsonBrowserShim.tests.js), 6 cases.
- Harden the #5686 Playwright spec (specs/34) to actually exercise the gesture
  now that boards render: select real checklist text and fire the real outside
  click; DOM-aware "outside the card" point for the control case.
- Update CHANGELOG upcoming.

Verified against a running instance: boards render again, Template.notifications
is registered, specs 34 and 35 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:41:07 +03:00
Lauri OjansivuandClaude Opus 4.8 8896bee857 Fix #5462: read-only members no longer trigger 403 when reordering lists
Read-only / comment-only board members could still drag-reorder lists because
one of the three jQuery-UI list sortables in swimlanes.js was not gated on
Utils.canModifyBoard() (the other two already were). The drag fired a server
write that allow/deny rejected with `403 Access denied` (reason: 'Access
denied'), so the list snapped back and the console showed the error — matching
the #5462 report.

- Add the missing `disabled: !Utils.canModifyBoard()` to the third sortable,
  consistent with the other two.
- Add a defense-in-depth guard at the top of saveSorting() so a logged-in user
  without write access can never persist a reorder; anonymous public-board
  reordering via localStorage is unaffected.
- Add a Playwright regression spec (specs/35-list-sort-permissions.e2e.js) that
  verifies over DDP: a read-only member is denied via both updateListSort and a
  direct collection write (the exact 403), the stored order is unchanged, and a
  write-access member's reorder persists (also guards #5997 persistence).
- Update CHANGELOG upcoming.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 08:48:59 +03:00
Lauri Ojansivu ae09279c46 Merge pull request #6407 from wekan/fix/5686-checklist-text-selection-closes-card
Fix #5686: selecting text in a checklist no longer closes the card
2026-06-21 08:29:25 +03:00
Lauri Ojansivu 5b7038ae62 Merge remote-tracking branch 'origin/main' into fix/5686-checklist-text-selection-closes-card
# Conflicts:
#	CHANGELOG.md
2026-06-21 08:29:00 +03:00
Lauri Ojansivu b2e5aae916 Merge pull request #6406 from wekan/fix/5804-rest-invalid-request-400
Fix #5804: REST API returns HTTP 400/404 instead of 500 for invalid requests
2026-06-21 08:25:08 +03:00
Lauri Ojansivu 64c9feb63c Merge pull request #6405 from wekan/dependabot/npm_and_yarn/azure/storage-blob-12.32.0
Bump @azure/storage-blob from 12.31.0 to 12.32.0
2026-06-21 08:24:47 +03:00
Lauri Ojansivu abe52cffb7 Merge pull request #6404 from wekan/dependabot/npm_and_yarn/mongodb-7.3.0
Bump mongodb from 6.21.0 to 7.3.0
2026-06-21 08:24:32 +03:00
Lauri Ojansivu 6d35b3f85f Merge pull request #6403 from wekan/dependabot/npm_and_yarn/papaparse-5.5.4
Bump papaparse from 5.5.3 to 5.5.4
2026-06-21 08:24:13 +03:00
Lauri Ojansivu 256c780cd1 Merge pull request #6402 from wekan/dependabot/npm_and_yarn/tweedegolf/sab-adapter-azure-blob-3.0.2
Bump @tweedegolf/sab-adapter-azure-blob from 1.0.9 to 3.0.2
2026-06-21 08:23:56 +03:00
Lauri Ojansivu a0fedd8ece Merge pull request #6401 from wekan/dependabot/npm_and_yarn/bson-7.3.0
Bump bson from 7.2.0 to 7.3.0
2026-06-21 08:23:11 +03:00
Lauri Ojansivu 8211d75b46 Merge pull request #6400 from wekan/dependabot/github_actions/docker/setup-qemu-action-4
Bump docker/setup-qemu-action from 3 to 4
2026-06-21 08:22:53 +03:00
Lauri Ojansivu 67932de3dc Merge pull request #6399 from wekan/dependabot/github_actions/actions/checkout-7
Bump actions/checkout from 6 to 7
2026-06-21 08:22:28 +03:00
Lauri OjansivuandClaude Opus 4.8 b4dec11775 Fix #5686: selecting text in a checklist no longer closes the card
Selecting the text of a checklist item and releasing the mouse outside the
card detail pane closed the card. The checklist items template stops mousedown
propagation (for item sorting), so the existing cardDetailsIsDragging guard
never engaged and the document-level "click outside to close" handler closed
the card.

- Add a propagation-independent guard (client/lib/cardCloseGuard.js):
  isTextSelectionInsideCard() keeps the card open when a live, non-collapsed
  text selection is anchored inside the card pane. A deliberate click on empty
  board space collapses the selection outside the card first, so it still
  closes the card.
- Wire the guard into the detailsPane EscapeActions close handler in
  cardDetails.js.
- Add 8 unit tests (client/lib/tests/cardCloseGuard.tests.js) and a Playwright
  regression spec (tests/playwright/specs/34-checklist-text-selection.e2e.js).
- Update CHANGELOG upcoming.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 07:43:28 +03:00
Lauri OjansivuandClaude Opus 4.8 fa2cb91694 Fix #5804: REST API returns HTTP 400/404 instead of 500 for invalid requests
Posting a comment without the required `comment` parameter (or to a board
that does not exist) returned an HTTP 500 error page. The schema-validation
error thrown on insert is a circular object (SimpleSchemaValidationContext ->
SimpleSchema -> ...), and serializing it crashed the response writer with
"Converting circular structure to JSON".

- Add Meteor-free helpers (server/lib/apiResponseHelpers.js): safeJsonStringify
  (crash-proof, falls back to {error} on circular structures), httpStatusForError
  (maps to 401/403/404/500), validateCommentBody, extractErrorMessage.
- sendJsonResult now serializes via safeJsonStringify so a non-serializable
  payload can no longer crash into a 500.
- checkBoardAccess/checkBoardWriteAccess/checkBoardAdmin throw a 404 instead of
  a TypeError on board.members when the board does not exist.
- POST comment validates input (400 when comment missing); REST comment catch
  blocks return the real status code instead of 200.
- Add 18 unit tests (server/lib/tests/apiResponseHelpers.tests.js).
- Update CHANGELOG upcoming.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 07:32:42 +03:00
dependabot[bot] f4b83a9624 Bump @azure/storage-blob from 12.31.0 to 12.32.0
Bumps [@azure/storage-blob](https://github.com/Azure/azure-sdk-for-js/tree/HEAD/sdk/storage/storage-blob) from 12.31.0 to 12.32.0.
- [Release notes](https://github.com/Azure/azure-sdk-for-js/releases)
- [Changelog](https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/storage/storage-blob/CHANGELOG.md)
- [Commits](https://github.com/Azure/azure-sdk-for-js/commits/@azure/storage-blob_12.32.0/sdk/storage/storage-blob)

---
updated-dependencies:
- dependency-name: "@azure/storage-blob"
  dependency-version: 12.32.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-20 20:33:09 +00:00
dependabot[bot] c3a15d13b6 Bump mongodb from 6.21.0 to 7.3.0
Bumps [mongodb](https://github.com/mongodb/node-mongodb-native) from 6.21.0 to 7.3.0.
- [Release notes](https://github.com/mongodb/node-mongodb-native/releases)
- [Changelog](https://github.com/mongodb/node-mongodb-native/blob/main/HISTORY.md)
- [Commits](https://github.com/mongodb/node-mongodb-native/compare/v6.21.0...v7.3.0)

---
updated-dependencies:
- dependency-name: mongodb
  dependency-version: 7.3.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-20 20:32:56 +00:00
dependabot[bot] a97e8de99d Bump papaparse from 5.5.3 to 5.5.4
Bumps [papaparse](https://github.com/mholt/PapaParse) from 5.5.3 to 5.5.4.
- [Release notes](https://github.com/mholt/PapaParse/releases)
- [Changelog](https://github.com/mholt/PapaParse/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mholt/PapaParse/compare/5.5.3...5.5.4)

---
updated-dependencies:
- dependency-name: papaparse
  dependency-version: 5.5.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-20 20:32:45 +00:00
dependabot[bot] e7a8d5bec5 Bump @tweedegolf/sab-adapter-azure-blob from 1.0.9 to 3.0.2
Bumps [@tweedegolf/sab-adapter-azure-blob](https://github.com/tweedegolf/storage-abstraction) from 1.0.9 to 3.0.2.
- [Release notes](https://github.com/tweedegolf/storage-abstraction/releases)
- [Changelog](https://github.com/tweedegolf/storage-abstraction/blob/master/changelog.md)
- [Commits](https://github.com/tweedegolf/storage-abstraction/commits)

---
updated-dependencies:
- dependency-name: "@tweedegolf/sab-adapter-azure-blob"
  dependency-version: 3.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-20 20:32:38 +00:00
dependabot[bot] b6efc42957 Bump bson from 7.2.0 to 7.3.0
Bumps [bson](https://github.com/mongodb/js-bson) from 7.2.0 to 7.3.0.
- [Release notes](https://github.com/mongodb/js-bson/releases)
- [Changelog](https://github.com/mongodb/js-bson/blob/main/HISTORY.md)
- [Commits](https://github.com/mongodb/js-bson/compare/v7.2.0...v7.3.0)

---
updated-dependencies:
- dependency-name: bson
  dependency-version: 7.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-20 20:32:27 +00:00
dependabot[bot] 092cd707b5 Bump docker/setup-qemu-action from 3 to 4
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-20 20:32:15 +00:00
dependabot[bot] 675207b599 Bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-20 20:32:10 +00:00
github-actions[bot] 1b6e828189 Bump versions for v9.65 2026-06-20 18:30:32 +00:00
Lauri Ojansivu 4ecd9e0cd0 Updated ChangeLog. 2026-06-20 21:26:06 +03:00
Lauri OjansivuandClaude Opus 4.8 b4bbf2480a Add ICS import REST API + OpenAPI + api.py example (#6323)
Expose the iCalendar (.ics) import as a REST endpoint
POST /api/boards/:boardId/swimlanes/:swimlaneId/lists/:listId/ics (body { ics })
that reuses the shared import logic, requires board write access, and returns
{ created, cardIds }. Document it in the OpenAPI spec (openapi/extra_paths.yml,
spliced into public/api/wekan.yml) and add an 'importics' example command to
api.py. Note the new endpoint in the ChangeLog.

Note: OAUTH2_LOGOUT_ENDPOINT (the only new env var in the Upcoming changelog) is
already present in all deployment configs (Dockerfile, docker-compose.yml,
snap-src/bin/config, start-wekan.sh/.bat); rebuild-wekan.sh/.bat are dev-run
scripts with no OAUTH2 section, so nothing was added there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:58:36 +03:00
Lauri OjansivuandClaude Opus 4.8 d33d8370c0 Update docs for upcoming-release features
Document the new features from the Upcoming changelog: subtask status + subtask
creation/landing-list/view/circular fixes (Subtasks.md); org/team board-member
restriction, per-user announcement dismissal and per-board comment-editing
restriction (Admin-Panel.md, Members.md); due-date colour coding + due-date change
count (Due-Date.md); Calendar respecting the start day of week (Day-of-week-start.md);
drag-and-drop from search results (Drag-Drop.md). Comment replies/restriction and
ICS import already have dedicated docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:50:14 +03:00
Lauri OjansivuandClaude Opus 4.8 cb5499f660 Translate remaining English strings in fi.i18n.json to Finnish
Translate the four delete-activity strings (act-deleteCard, act-removeBoard,
act-removeList, act-removeSwimlane) to Finnish, matching existing act-* activity
terminology (poistettu <noun> ... listalla/uimaradalla/taululla __...__).
Placeholders and product/storage names are left unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:44:10 +03:00
Lauri Ojansivu 10c98ea9fb Updated translations. 2026-06-20 20:41:47 +03:00
Lauri OjansivuandClaude Opus 4.8 c092b6d7ca Updated ChangeLog: link Upcoming bullet descriptions directly to commits
Change the Upcoming bullets from [description](issue) ([commit](commit)) to just
[description](commit), so each description links to its fix commit and the
redundant separate commit link is removed. Inline secondary issue references
(also #NNNN) are kept as issue links.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:38:35 +03:00
Lauri OjansivuandClaude Opus 4.8 c8a54aa009 Updated ChangeLog: subtask + webhook bug fixes; register tests; add delete-activity i18n keys
Add Upcoming 'fixes the following bugs' bullets (with commit links) for the
subtask fixes (#3868/#5788/#2256/#4782, #4037/#3562, #3328, #3414/#3876/#4849/#4947,
#3743) and webhook fixes (#1587/#2950, #1402), register their unit tests, and add
the act-deleteCard / act-removeBoard / act-removeList / act-removeSwimlane i18n
keys so the delete activities render in the feed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:56:09 +03:00
Lauri OjansivuandClaude Opus 4.8 c5cdc7b659 Fix #3743: subtask 'View it' opens the subtask instead of the parent card
The subtask 'View it' action navigated using the current/parent card; it now
derives the navigation target (boardId/slug/cardId) exclusively from the subtask
via a pure subtaskNavTarget helper (resolving linked subtask cards through
getRealId), reusing the existing not-yet-loaded-board guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:54:54 +03:00
Lauri OjansivuandClaude Opus 4.8 4b27313c72 Fix #3868, #5788, #2256, #4782, #4037, #3562, #3328: subtask creation
Subtask creation is now server-authoritative via a new addSubtaskCard Meteor
method: the default subtasks board/list/swimlane are resolved (and lazily created
exactly once) on the server, and client-side auto-creation in
getDefaultSubtasksBoardId/getDefaultSubtasksListId is guarded to Meteor.isServer.
This stops the client from creating duplicate subtasks boards/swimlanes/columns
(#3868/#5788/#2256) and lets multiple subtasks be created reliably (#4782). The
method applies the destination board's automatic custom fields to the new
subtask (#4037/#3562). The circular-parent guard now compares ids by value
(includes()) instead of array indices, and setParentId refuses a cyclic
re-parent (#3328). Pure helpers (wouldCreateCycle, subtaskCustomFields) moved to
isomorphic /imports/lib so the client bundle no longer imports a server module.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:54:54 +03:00
Lauri OjansivuandClaude Opus 4.8 7bb7540ba2 Fix #1587: deleting a card fires the outgoing webhook
Deleting a card created no Activity (only archiving did), so the
Activities.after.insert outgoing-webhook hook never fired on real deletion.
Added a deleteCard Activity in the Cards before.remove hook and in the two REST
card-delete endpoints (which use Cards.direct.removeAsync and bypass the hook),
so an outgoing webhook is now sent on card deletion, consistent with other
events. (#2950: board/list/swimlane deletion already emit removeBoard/removeList/
removeSwimlane activities, so those webhooks were already present.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:54:30 +03:00
Lauri OjansivuandClaude Opus 4.8 774289f926 Fix #3414, #3876, #4849, #4947: board Subtasks landing-list setting
The board Subtasks settings popup compared the selected list against
subtasksDefaultBoardId instead of subtasksDefaultListId (so the saved list was
never highlighted and looked unsaved/wrong), and listed the current board's
lists rather than the configured deposit board's. The dropdown now reads the
deposit board's lists and matches the stored subtasksDefaultListId, and changing
the deposit board resets the stale list id. resolveSubtaskLanding helper tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:54:30 +03:00
Lauri OjansivuandClaude Opus 4.8 8863ec24a7 Fix #1402: outgoing webhook no longer blocks setting card members
Setting a card member creates an Activity, and the Activities.after.insert hook
delivered the outgoing webhook in a way that could throw (e.g. unreachable/slow
endpoint) and unwind the async hook, aborting the member update. Webhook delivery
is now fire-and-forget and wrapped in safeDeliver(), which catches and logs any
error and never rejects, so a failing webhook can't break the member operation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:54:30 +03:00
Lauri OjansivuandClaude Opus 4.8 d4ee63eb15 Updated ChangeLog: 12 more open-Bug fixes; register tests; fix #3265 language test
Add Upcoming 'fixes the following bugs' bullets (with commit links) for the latest
batch of open Bug fixes (#3289/#4172/#5457, #5398/#5399/#5537/#5546, #5408, #5382,
#5582, #5330, #2091), register their unit tests in the loaders, and rewrite the
#3265 Latvian-language regression test to import the languages module directly
(the previous fs.readFileSync approach failed in the Meteor test bundle). Latvian
native name 'Latviešu valoda' was already correct, so #3265 needed no code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:20:22 +03:00
Lauri OjansivuandClaude Opus 4.8 98315c26cd Fix #2091: custom number field shows NaN when cleared after being set
A number-type Custom Field rendered the literal 'NaN' once its value was cleared.
The submit handler now stores '' instead of NaN for empty input, and a shared
formatNumberValue helper normalizes empty/null/non-numeric values to an empty
string for display. Helper unit-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:17:01 +03:00
Lauri OjansivuandClaude Opus 4.8 6f85433222 Fix #5382: setting a list/swimlane colour to silver saved it as None
Lists.setColor / Swimlanes.setColor wrote the raw picker value with no normalization,
so an offered-but-unhandled colour fell back to None. Added a shared canonical
allowed-colour helper (normalizeListColor / ALLOWED_LIST_COLORS, including silver)
used by both setters: valid colours (incl. silver) pass through, unknown/empty
normalize to None (null). Removed leftover debug console.log in listHeader.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:17:01 +03:00
Lauri OjansivuandClaude Opus 4.8 e98aae278b Fix #5408: per-checklist 'Hide checked items' toggle direction and scope
The 'Hide checked items' state is now read per checklist (from that checklist's own
hideCheckedChecklistItems field) and an item is hidden exactly when it is checked
and that checklist's toggle is on — so toggling one checklist no longer affects the
others and the direction is no longer inverted. Pure isItemHidden helper unit-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:17:01 +03:00
Lauri OjansivuandClaude Opus 4.8 3ce75dd87b Fix #3289, #4172, #5457: Enter selects @mention instead of closing the card
When the @mention autocomplete dropdown is open in the comment/description/minicard
editor, pressing Enter to pick a highlighted user submitted the comment and closed
the card instead of selecting the mention. Hardened the shared textcomplete keydown
guard (createEscapeableTextComplete) to preventDefault + stopImmediatePropagation
while the dropdown is open, so Enter is consumed by the suggestion selection only.
Pure shouldSubmitOnEnter helper unit-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:17:00 +03:00
Lauri OjansivuandClaude Opus 4.8 39e12035e5 Fix #5398, #5399, #5537, #5546: REST card API move/sort/date/archive bugs
- #5398: consolidate duplicated/inconsistent board-move variable names in the card
  edit handler into one normalized set (normalizeMoveParams); API contract unchanged.
- #5399: moving a card to another list via the API now places it on TOP of the
  destination list (computeTopSort), matching the Move Card dialog, instead of a
  random sort position.
- #5537: due/received/start/end dates set via the API now persist — the ISO string
  is parsed to a real Date (parseCardDate) instead of being stripped by schema cleaning.
- #5546: the single-card GET returns archived cards (for inspection) and PUT
  archive=false no longer requires a list_id the caller cannot obtain.
Pure helpers (normalizeMoveParams/computeTopSort/parseCardDate) are unit-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:17:00 +03:00
Lauri OjansivuandClaude Opus 4.8 f819054bb1 Fix #5582: hide internal caret ^board^ helper boards from lists and API
Internal Subtasks-style boards with caret-wrapped titles (e.g. `^Subtasks^`) and
non-`board` types (`list`/`template`) leaked into user-facing board lists and the
REST API (/api/users/:userId/boards, /api/boards). Added a shared pure filter
(server/lib/boardListFilter.js) excluding caret-wrapped titles and non-board
types, applied to those REST endpoints, and added caret exclusion to the
Boards.userBoards model helper, consistent with the already-filtered board-list UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:16:35 +03:00
Lauri OjansivuandClaude Opus 4.8 beac9e3d71 Fix #5330: remove a deleted (non-existent) user from a board's members
Board.removeMember only deactivated a member by array index and never pulled the
entry, so a member whose user account was deleted lingered forever and could not
be removed (also breaking @mentions). removeMember now hard-removes the entry by
userId when the Users document no longer exists, via the new pure pullMemberById
helper. Permission checks and existing-user deactivate/keep-last-admin behaviour
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:16:35 +03:00
Lauri OjansivuandClaude Opus 4.8 b7aa85956e Translate remaining English strings in fi.i18n.json to Finnish
Translate the 8 strings that were still English (comment replies/edit restriction,
due-date change count, org/team member restriction, changed-title activity),
matching existing Finnish terminology (Taulu, Kommentti, Organisaatio, Tiimi,
eräpäivä, and the 'muokkasi …ksi %s / %s' activity pattern). Acronyms, symbols,
numbers and product/storage names are intentionally left unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:36:02 +03:00
Lauri Ojansivu 0bc0f186ed Updated translations. 2026-06-20 18:32:00 +03:00
Lauri OjansivuandClaude Opus 4.8 c25489858a Updated ChangeLog: regroup Upcoming into updates / new features / bug fixes
Reorganize the Upcoming section into three non-duplicated groups using the
correct category per commit: 'This release adds the following updates:' (issue
triage, label audit, 23 new labels), 'and adds the following new features:'
(the MVP features), and 'and fixes the following bugs:' (all bug fixes merged
into a single list, including the OAuth2 logout fix and the triage-verified
fixes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:26:53 +03:00
Lauri OjansivuandClaude Opus 4.8 b3d7c99616 Updated ChangeLog: add 23 missing GitHub labels for documented login types and features
Auditing docs/Login and docs/Features against the issue labels surfaced documented
login methods (ADFS, Azure, B2C, Google, Header-Login, Nextcloud, Oracle, Zitadel,
Autologin, Accounts-Lockout, Forgot-Password) and features (LaTeX, Mermaid-Diagram,
Emoji, Python, Cards:Cover, Cards:Location, Custom-Logo, RTL, Members, Multitenancy,
Allow-private-boards-only) plus the Apache reverse-proxy platform that had no label.
Created them matching the existing style/colors and listed them in the upcoming
ChangeLog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:00:11 +03:00
Lauri OjansivuandClaude Opus 4.8 13db138313 Updated ChangeLog: 11 triage-verified bug fixes for upcoming release
Add Upcoming changelog bullets (with per-fix commit links) for the bugs found
still-present during Bug-issue triage and now fixed (#2970 #5364 #4561 #5810
#4762 #5188 #5623 #5158 #5521 #5504 #3619), register their unit tests in the
loaders, and add the activity-changedTitle i18n key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:51:23 +03:00
Lauri OjansivuandClaude Opus 4.8 9aa97ab8ff Fix #3619: updating a card title fires the outgoing webhook
Title changes did not create an activity, so no outgoing webhook fired (unlike
description/dueAt changes). Log an a-changedTitle activity in the Cards
before.update hook, mirroring the timing-field activities, so the existing
Activities.after.insert webhook hook fires. Pure titleChanged helper + tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:47:56 +03:00
Lauri OjansivuandClaude Opus 4.8 2ba8882d88 Fix #5504: deleted-attachment notification credits the deleter
The deleteAttachment activity used fileObj.userId (the uploader) instead of the
user who deleted the attachment, so notifications/history named the wrong person.
Record the acting user (Meteor.userId()) for the delete activity, falling back to
the uploader for server/system removals. Upload activity is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:45:43 +03:00
Lauri OjansivuandClaude Opus 4.8 7eec78c040 Fix #5521: Calendar View respects the start-day-of-week setting
The FullCalendar config never set firstDay, so it used the locale default and
ignored the user's start-of-week preference. Derive firstDay from
getStartDayOfWeek() (0=Sun..6=Sat, same convention as FullCalendar). Pure
toFullCalendarFirstDay helper + tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:45:43 +03:00
Lauri OjansivuandClaude Opus 4.8 9643dcded9 Fix #5158: copying a swimlane to another board preserves labels
Swimlane copy() remapped card labels by name but never created labels missing on
the destination board, so those assignments were silently dropped. Pre-create
missing destination labels (preserving color) before the per-card copy, so the
by-name remap matches. Pure remapLabelIds helper + tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:45:43 +03:00
Lauri OjansivuandClaude Opus 4.8 300a9751e6 Fix #5623: 'select all in list' stays within its own swimlane
allCards() filtered by listId only, so list select-all selected cards across all
swimlanes of the list. Add an optional swimlaneId scope (used in swimlanes view)
so select-all is contained to the current swimlane; list-wide behavior is
preserved when there is no swimlane context. Orphan cards remain selectable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:45:43 +03:00
Lauri OjansivuandClaude Opus 4.8 da7c06ec61 Fix #5188: 'When a card is moved to Archive' rule trigger can be activated
The board-triggers JS listened on js-add-arc-trigger while the jade emits
js-add-arch-trigger, so the click handler never fired and the archive trigger
could not be added. Align the JS selector to the jade's class. Added a regression
test asserting every jade trigger class has a matching JS handler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:45:43 +03:00
Lauri OjansivuandClaude Opus 4.8 45bd7dcc85 Fix #4762: subtask board accessible without reload
The js-view-subtask handler dereferenced board._id when the subtask's board was
not yet in ReactiveCache, so navigation failed until a reload. Guard against a
not-yet-loaded board, mirroring the sibling js-go-to-subtask-board handler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:45:43 +03:00
Lauri OjansivuandClaude Opus 4.8 aa412b2a1e Fix #5810: comment-only members can no longer archive cards
The .js-archive handler in cardDetails.js lacked the Utils.canModifyCard() guard
that every other mutating card action has, so a comment-only member could trigger
archiving in the UI (the server allow-rule already rejected the write, so this
closes the client-side UX gap). Guard added via pure canArchiveCard helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:45:43 +03:00
Lauri OjansivuandClaude Opus 4.8 09e9c993e2 Fix #4561: deleting a date on a linked card updates the real card
unsetReceived/unsetStart/unsetDue/unsetEnd used this._id instead of the linked
card's real id, so clearing a due/received/start/end date on a linked card
targeted the link placeholder. Resolve the id via getRealId() consistently with
the set* methods. For normal cards getRealId()===_id so behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:45:11 +03:00
Lauri OjansivuandClaude Opus 4.8 a0e7017088 Fix #5364: copying a card now clones its cover (show as thumb)
card.copy() copied attachments with new ids but never remapped coverId, so the
cover was lost on the copy. Build an old->new attachment id map (via the copied
attachments' meta.copyFrom) and remap coverId to the new attachment; drop a
stale coverId that cannot be remapped. Helper remapCoverId + tests landed in
the #2970 commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:45:11 +03:00
Lauri OjansivuandClaude Opus 4.8 d26d117e32 Fix #2970: copying a card no longer selects all/unnamed labels on destination
Cards.copy() remapped labels by name without the unnamed-label guard that
Cards.move() has, so unnamed destination labels were all wrongly selected; it
also wrote the remapped labels onto a throwaway cardData copy instead of the
inserted document. Apply the move()-style guard via shared filterCopiedLabelIds
and persist onto the inserted card. Pure helpers + tests in
server/lib/cardCopyHelpers.js and server/lib/tests/cardsCopyDueFixes.tests.js
(which also cover #4561 and #5364, committed next).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:44:51 +03:00
Lauri OjansivuandClaude Opus 4.8 780fdd9187 Updated ChangeLog: 20 issue fixes/features for upcoming release
Add the Upcoming changelog bullets (with per-fix commit links), the new English
i18n keys, and register the new unit test files in the test loaders. Covers
#6000 #5965 #5999 #5930 #5942 #5902 #6018 #5982 #5910 #6142 #6135 #5905 (fixes)
and #5907 #5906 #6091 #6034 #6051 #6081 #6116 #6323 (features).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:42:05 +03:00
Lauri OjansivuandClaude Opus 4.8 0a43d8ac3c Add #6323: import Google Calendar .ics files into board cards (MVP)
New dependency-free iCalendar parser (server/lib/icsImport.js) maps each VEVENT
to a card with startAt/dueAt so events show on Calendar/Gantt views, plus an
importIcsToBoard Meteor method. Import-only; two-way Google Calendar sync is not
included. Parser unit tested (folded lines, escaping, all-day vs date-time, etc.).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:40:13 +03:00
Lauri OjansivuandClaude Opus 4.8 c18ae388be Fix #6142: allow adding a list in Lists board-view mode
The 'Add list' composer was only rendered in Swimlanes mode; it now also appears
in Lists mode (using the board's default swimlane), so a new board in Lists mode
can get lists without switching modes. Reuses the existing add-list method.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:40:13 +03:00
Lauri OjansivuandClaude Opus 4.8 d7ae93bb27 Add #6091: show the status (current list) of each sub-task
Each subtask now shows its current list, read-only, next to its title (prefixed
with the board title when the subtask lives on a different board than the parent
card). subtaskStatusLabel helper unit tested. MVP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:40:13 +03:00
Lauri OjansivuandClaude Opus 4.8 f592735080 Fix #5905: notify card members and record history when an attachment is added
Uploading an attachment now creates an addAttachment activity, so card members
and subscribers are notified (consistent with attachment removal) and it appears
in card history. Previously the activity was never created because the store
strategy upload hook was dead code. buildAttachmentAddedActivity unit tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:40:13 +03:00
Lauri OjansivuandClaude Opus 4.8 bdb8e62546 Add #5907, #5906: threaded comment replies and admin comment-edit restriction
Comments gain an optional parentId with a Reply link and an 'in reply to' quote
(#5907, MVP single-level threading). New board setting restrictCommentEditing
(default off): when on, only a comment's author may edit/delete it, enforced
server-side via collection hooks (#5906). canEditComment decision unit tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:40:13 +03:00
Lauri OjansivuandClaude Opus 4.8 5c890bacaa Fix #5942, #5902, #6018, #5982: mobile UI regressions
Keep card-detail sub-popups open on touch when assigning a user / setting the
due date (#5942); render minicards one-per-row and make the Home button a proper
tap target (#5902); clamp enormous icon/text sizing (#6018); and trim excessive
padding/margins (#5982). Mobile overrides are scoped under media queries in a new
responsiveFixes.css; isMobileViewport helper unit tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:40:13 +03:00
Lauri OjansivuandClaude Opus 4.8 086254e106 Fix #6000, #5965: due-date colours correct and consistent across views
Root cause of #6000 was diff(date, now, 'days') with an invalid unit comparing
raw milliseconds, so future due dates were mis-coloured; replaced with a precise
hours-based comparison (past=overdue/red, <=48h=due-soon/amber, >48h=not-due/grey).
For #5965 the colour-decision logic is unified into one shared dueDateClass()
helper used by both the minicard and card-detail badges, and the card-detail
status colours now win the cascade, so overdue shows red in both. Unit tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:40:13 +03:00
Lauri OjansivuandClaude Opus 4.8 bf75efaef5 Add #6051: per-user permanent dismissal of the Announcement banner
A user can permanently close the current global announcement so it does not
reappear on reload or board-switch, until the admin edits the announcement text
(its content-derived version changes, so it reappears for everyone). Dismissed
version stored on the user profile; visibility decided by the unit-tested
shouldShowAnnouncement helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:39:41 +03:00
Lauri OjansivuandClaude Opus 4.8 371258a6d0 Add #6116: restrict adding board members to the same Organization or Team
New global admin setting boardMembersFromSameOrgOrTeamOnly (default off). When
enabled, a user can only be added to a board if they share an Organization or
Team with the inviter or an active board member; enforced server-side in the
invite and user-search paths. Site admins bypass. Pure canAddUserToBoard helper
with unit tests; Admin Panel toggle added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:39:28 +03:00
Lauri OjansivuandClaude Opus 4.8 fe4d2a56f2 Fix #6135: board/list/swimlane numbering broke first-letter keyboard nav
The move/copy card popups prefixed a number to each option (e.g. '3. Board'),
which broke jumping to an entry by typing its first letter and made digit-named
boards confusing. Remove the numeric prefix so options start with their name
again. Pure boardOptionLabel helper added (numbering off by default) with tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:39:02 +03:00
Lauri OjansivuandClaude Opus 4.8 a8ed326a5b Add #6081: show how many times a card's due date was changed
Display a 'due date changed N times' count on the card detail (derived from
existing a-dueAt activities via getDueDateChangeCount) for deadline
accountability. Counting logic extracted to server/lib/dueDateEdits.js with
unit tests. MVP per the issue (display rather than hard limit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:38:50 +03:00
Lauri OjansivuandClaude Opus 4.8 b998246c35 Fix #5910 and add #6034: board search matches comments, drag-drop search results
Board-level search now matches text inside card comments, consistent with global
search (#5910); the (title OR description OR comment) rule is extracted to the
unit-tested helper server/lib/cardMatch.js. Search-result cards can be dragged
directly onto board lists, reusing card.move() (#6034, MVP: drops append to end
of the target list).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:38:28 +03:00
Lauri OjansivuandClaude Opus 4.8 b3acbd6920 Fix #5999, #5930: show all due cards and make Due Cards list scrollable
Remove the limit: 100 cap in the dueCards publication so all of a user's due
cards across boards are shown (#5999), and add a scoped scroll container so the
Due Cards list scrolls past the first cards on mobile (#5930). Filter/sort logic
extracted to dueCardsLogic.js with unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:38:16 +03:00
Lauri Ojansivu 0965d083dc Updated ChangeLog. 2026-06-20 13:11:32 +03:00
Lauri Ojansivu f569eb9c0c - [Fixed OIDC/OAuth2 "Log Out" redirecting to the identity provider home page instead of back to Wekan](https://github.com/wekan/wekan/issues/6158).
With autologin (`OIDC_REDIRECTION_ENABLED=true`), clicking Log Out redirected to the OAuth2 server URL
  (for example the Keycloak base URL `https://id.company.com`), which shows an error page for non-admin
  users. Added the new optional `OAUTH2_LOGOUT_ENDPOINT` setting: when set to the provider's
  `end_session_endpoint` (Keycloak example `/realms/<realm>/protocol/openid-connect/logout`), Wekan now
  performs an OIDC RP-initiated logout that ends the identity provider session and returns the user to
  Wekan (`ROOT_URL`) via `post_logout_redirect_uri`. When unset, logout behaviour is unchanged, so this
  is backward compatible. For Keycloak 18+, add your Wekan `ROOT_URL` to the client's
  *Valid post logout redirect URIs*. See [docs/Login/Keycloak/Keycloak.md](https://github.com/wekan/wekan/blob/main/docs/Login/Keycloak/Keycloak.md).

Thanks to zambalee and xet7 !

Fixes #6158
2026-06-20 13:11:32 +03:00
github-actions[bot] 02cf8fdf16 Bump versions for v9.64 2026-06-20 01:20:53 +00:00
Lauri Ojansivu b0910e48d4 Updated ChangeLog. 2026-06-20 04:16:59 +03:00
Lauri OjansivuandClaude Opus 4.8 4afd8b8296 Updated ChangeLog: ChecklistBleed security fix + docker guard fix (upcoming)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 04:12:53 +03:00
Lauri OjansivuandClaude Opus 4.8 b1ca76007b Fix GHSA-gv8h-5p3p-6hx7 (ChecklistBleed): checklist cross-board write via DDP allow rule (CWE-863)
The Checklists and ChecklistItems collection allow rules authorize an
update only against the document's CURRENT (source) cardId. A DDP client
with write access to its own board could move a checklist/item it owns
onto a card in a private board it is not a member of by $set-ting a new
cardId (and, for items, checklistId); the before.update hook then
denormalizes boardId from the destination card, attaching the
attacker-controlled document to the victim's private board. Same class
as denyCrossBoardMove / BoardBleed (GHSA-gm7v-pc38-53jr), but for the
card-attached checklist documents that the boardId-only deny did not
cover.

Add denyCrossBoardMoveByCard and denyCrossBoardMoveByChecklistItem in
server/lib/utils.js, which reject any update whose destination board
(resolved from a new boardId, cardId, or checklistId in the modifier)
the caller cannot write to, and wire them into Checklists.deny /
ChecklistItems.deny. Legitimate moves into writable boards and same-card
edits are unaffected; the server-side moveChecklist Meteor method bypasses
allow/deny and is unchanged. Adds a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 04:10:36 +03:00
github-actions[bot] 591a7a458b Bump versions for v9.63 2026-06-20 01:07:24 +00:00
Lauri OjansivuandClaude Opus 4.8 069dbc11f4 Fix docker pre-build version guard false-failing the release
The bundle-version guard added for the Admin-Panel-version fix assumed
the WeKan app package.json ships as a standalone file with a v-prefixed
version, and made 'not found' fatal. Meteor does not ship it that way:
it inlines the app package.json into the compiled
bundle/programs/server/app/app.js as a JSON module
({"name":"wekan","version":"v9.63.0",...}), so the guard found no
v-prefixed package.json and aborted the v9.63 docker build (exit 1).

Read the version from the inlined module in app.js instead (anchored on
"name":"wekan", exactly what require('/package.json') resolves to), and
make a detection miss a WARNING that continues rather than a hard
failure — only a confirmed version MISMATCH blocks the release.
--build-arg VERSION remains the actual guarantee.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 04:01:59 +03:00
github-actions[bot] b988efae19 Bump versions for v9.63 2026-06-19 19:19:34 +00:00
Lauri Ojansivu 8a8f036947 Prepare v9.63 release 2026-06-19 22:09:32 +03:00
Lauri OjansivuandClaude Opus 4.8 1e5ae8f649 Make remaining release version substitutions self-healing
Harden the last old-version-anchored seds in releases/version.sh, the
same fragile pattern that froze the Docker ARG VERSION and the wekan.fi
install page:

- snapcraft.yaml bundle download (RELEASE-CRITICAL, same class as the
  Docker bug: the snap downloads wekan-<v>-<arch>.zip, so a stale value
  ships the wrong bundle under the right name). Anchor on 'wekan-<v>-'
  and 'releases/download/v<v>/' and assert; was stuck at 9.57.
- sandstorm appVersion: global self-healing rewrite; dropped the
  redundant $OLD_NO_DOTS-anchored fixup.
- Offline.md doc links: self-healing with a soft warning (cosmetic, so
  it must not fail the release); was stuck at 9.57.

Also corrected snapcraft.yaml and Offline.md from their stale 9.57 to
the current 9.62 (package.json is now v9.62.0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:59:27 +03:00
Lauri OjansivuandClaude Opus 4.8 40c3ee098e Fix wekan.fi install page version never updating from a stale value
release-website.sh (and version.sh's local-flow copy) updated the
install page version with a sed anchored on >v$OLD</span>. Once the
published page's value did not match the $OLD passed for a release, the
substitution silently no-op'd, so the page stuck at an old version
(v9.53) while every later release passed a newer $OLD that no longer
matched. Same fragile-old-version-anchor failure mode as the Dockerfile
ARG VERSION bug.

Anchor on the stable <span class="version-number"> instead and
re-normalize whatever version is there to v$NEW, with an assert so a
miss fails loudly instead of silently. Self-healing from any stale value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:02:55 +03:00
github-actions[bot] 7441937f67 Bump versions for v9.62 2026-06-19 16:47:32 +00:00
Lauri Ojansivu 0ea935e5d9 Prepare v9.62 release 2026-06-19 19:37:24 +03:00
Lauri OjansivuandClaude Opus 4.8 8dbabc145f Updated ChangeLog for v9.62.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:37:01 +03:00
Lauri OjansivuandClaude Opus 4.8 e057e9454e Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:35:40 +03:00
Lauri OjansivuandClaude Opus 4.8 b30f4967cb Fix Docker image reporting a stale Admin Panel version
The Admin Panel reads the WeKan version from the bundled package.json,
which comes verbatim from the wekan-<version>-<arch>.zip the Dockerfile
unzips into /build. The release-all.yml docker job never passed
--build-arg VERSION, so every image built against the Dockerfile's
hardcoded ARG VERSION default, which was stuck at 9.57 because
releases/version.sh rewrote it with a sed anchored on the old version
number (silently no-op when old_version did not match). Images tagged
v9.59/v9.60/v9.61 thus shipped the v9.57 bundle and reported 9.57.

- docker job now passes --build-arg VERSION=${VERSION}
- version.sh anchors the Dockerfile ARG VERSION rewrite on the
  ^ARG VERSION= prefix and asserts the result
- new pre-build guard fails fast if the release bundle's app
  package.json version does not match the release tag
- corrected the stale Dockerfile default to 9.60

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:35:40 +03:00
github-actions[bot] ff82e89cd8 Bump versions for v9.61 2026-06-19 14:26:58 +00:00
Lauri Ojansivu 0b46b4d12e Updated ChangeLog. 2026-06-19 17:15:35 +03:00
Lauri OjansivuandClaude Opus 4.8 862f20d9d7 Updated ChangeLog.
Add upcoming section for the GH_REPO bundle-attach fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:13:57 +03:00
Lauri OjansivuandClaude Opus 4.8 c41ad5fd68 release-all: set GH_REPO on bundle-attach steps so gh finds the repo
The win64 attach step failed with "failed to run git: fatal: not a git
repository" — gh release upload tries to detect the target repo from a git
remote, but build-win64 checks the repo out into src/ (workspace root is not a
repo) and build-mac-arm64 / build-extra-arches do not check it out at all.

Set GH_REPO=${{ github.repository }} on all three attach steps so gh targets
wekan/wekan directly without git remote detection. The snap job is unaffected
(it does a full checkout into the workspace root).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:13:05 +03:00
github-actions[bot] ed59721453 Bump versions for v9.60 2026-06-19 13:51:09 +00:00
Lauri Ojansivu 6cd0a4fbce Updated ChangeLog. 2026-06-19 16:47:21 +03:00
Lauri OjansivuandClaude Opus 4.8 11d3c2dd66 Updated ChangeLog.
Add upcoming section documenting the win64/mac-arm64 post-release move and
the armv7l removal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:47:21 +03:00
Lauri OjansivuandClaude Opus 4.8 863883a7f7 release-all: drop armv7l from extra-arch bundles (no Node.js 24 armv7l exists)
The build-extra-arches armv7l matrix entry failed with "no matching manifest
for linux/arm/v7" pulling node:24-slim. Root cause: Node.js 24 publishes no
armv7l (32-bit ARM) binaries at all — neither the official dist nor unofficial
builds — so there is no node:24 arm/v7 image to rebuild native modules against,
and no Node 24 runtime to run such a bundle on an armv7 device.

Remove the armv7l matrix entry; the extra-arch bundles are now s390x + ppc64le.
armv7 was already excluded from the Docker image and snap, so those are
unaffected. Comments document why armv7l is not built.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:47:21 +03:00
Lauri OjansivuandClaude Opus 4.8 227d7877c8 release-all: move win64 + mac-arm64 into the post-release extra-platform phase
build-win64 and build-mac-arm64 were prerequisites of the `release` job, so a
slow or flaky Windows/macOS runner blocked creation of the GitHub Release (and
thus Docker and snap, which depend on it).

Move them alongside build-extra-arches: they now depend on `release` instead,
and each attaches its own wekan-<version>-<platform>.zip to the already-created
Release via `gh release upload --clobber`. The core release now waits only on
the amd64 + arm64 bundles. No build steps changed; only the dependency wiring,
upload mechanism, and section grouping/comments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:47:21 +03:00
github-actions[bot] c51d180acc Bump versions for v9.59 2026-06-19 13:21:21 +00:00
Lauri Ojansivu 8010f5bd14 Updated ChangeLog. 2026-06-19 16:17:32 +03:00
Lauri Ojansivu 2ba84e35bd Updated translations. 2026-06-19 16:15:01 +03:00
Lauri OjansivuandClaude Opus 4.8 69236a9062 Fix duplicate mapping keys in generated OpenAPI spec breaking API docs
The Release All Platforms bump job regenerates public/api/wekan.yml via
openapi/generate_openapi.py and renders it with @redocly/cli, whose strict
YAML parser rejects duplicate mapping keys. The 3-level nested SimpleSchema
in models/attachmentStorageSettings.js (storageConfig.filesystem.enabled,
storageConfig.gridfs.enabled, ...) exposed two generator bugs:

- The sub-schema name was derived from only the first dotted path segment,
  collapsing filesystem.* and gridfs.* leaf keys (enabled/read/write) into a
  single mapping.
- The linear emitter reopened the parent schema header once per interleaved
  nested object.

Build sub-schema names from all leading path segments, and stable-sort dotted
properties so each sub-schema's fields stay contiguous and its header is
emitted once. Output is unchanged for existing 1- and 2-level schemas;
@redocly/cli now renders the spec successfully.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:09:16 +03:00
Lauri Ojansivu 534591afa8 Updated ChangeLog. 2026-06-19 12:29:04 +03:00
Lauri Ojansivu 3d663dcac3 Update version. 2026-06-19 12:21:36 +03:00
Lauri OjansivuandClaude Opus 4.8 61045e5681 Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:17:54 +03:00
Lauri OjansivuandClaude Opus 4.8 7c8ef380f5 releases/version.sh: bump BOTH root version fields in package-lock.json.
package-lock.json (lockfileVersion 3) stores the WeKan root version twice:
the top-level "version" and the nested packages."".version. The old
0,/regex/ sed replaced only the first one, so packages."".version stayed
stale. The manual flow (release.sh) accidentally masked this because
rebuild-release.sh later runs `meteor npm install`, which re-syncs the lock
before commit; but the remote flow (release-all.yml bump job) commits
version.sh's output directly with no intervening npm install, pushing a
package-lock.json whose packages."".version is stale.

Anchor the replacement on the 'v'-prefixed WeKan version ("version": "v...")
and make it a global replace, so both root fields update. The 'v' prefix is
unique to the WeKan version (dependency versions are plain semver like
"5.2.0"), so this never touches any dependency version number.

Node.js (24.x) and MongoDB (7.x) bumps are left as-is: the pinned major is
intentional and the [0-9]+.[0-9]+ minor/patch already bumps any older
24.x/7.x to the newest 24.x/7.x without rewriting other version numbers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:17:37 +03:00
Lauri OjansivuandClaude Opus 4.8 a0be74eb33 Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:07:02 +03:00
Lauri OjansivuandClaude Opus 4.8 2a713a60b8 #2339 #5850: don't auto-create an empty Template Container; make user-created ones functional.
Opening All Boards / Templates no longer auto-creates an empty Template
Container board (the boardList autorun that called ensureTemplatesBoard on
view open is removed). The container is now created only on demand via the
"Add Template Container" button.

To make a user-created container actually usable, createBoardWithInitialSwimlanes
now wires the user's profile template pointers (templatesBoardId and the
card/list/board template swimlane ids, keyed by a new per-swimlane `role`) when
type is 'template-container'. Without this the container looked right but stayed
inert, because swimlane.isCardTemplatesSwimlane()/... compare against those
pointers to decide that an added card/list/swimlane/board becomes a template.

Deleting a Template Container (boardRemover) now clears those profile pointers
for any user referencing it, so no save/insert-from-template path is left
pointing at a dead board and a fresh container can be created.

Adds Playwright e2e coverage: opening /templates does not auto-create a
container, the create flow wires the profile pointers, and delete clears them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:06:43 +03:00
Lauri Ojansivu ef5c1b503d Updated ChangeLog. 2026-06-19 11:38:13 +03:00
Lauri OjansivuandClaude Opus 4.8 422e70329f #5850 #4737 #2339: make All Boards / org-team-domain sharing work end-to-end (fix bugs found by Playwright e2e).
Fixes the bugs the new Playwright specs (30/31/32/33) surfaced in the recently
added All Boards / Templates redesign and per-org/team/domain board sharing:

- server/imports.js: import the new server method modules (ldapGroupSync,
  sharedTemplateTargets, propagateOrgTeamMembers). This app uses an explicit
  server mainModule, so files not imported here are never loaded and their
  Meteor methods are not registered ("method not found").

- server/ldapGroupSync.js (setUserOrgsTeamsFromLdap): call check() on ALL
  arguments FIRST, before the admin guard. When the guard threw before the
  checks ran, audit-argument-checks raised "Did not check() all arguments" as an
  unhandled rejection, which SyncedCron's global unhandledRejection handler
  turned into a full app crash. Also read the caller with an explicit
  {_id}-selector + isAdmin projection.

- server/models/org.js, server/models/team.js: the per-org/team feature-toggle
  methods (setOrg/Team SharedTemplates, PropagateMembersToBoards,
  SyncMembersFromAuth, setAll*Feature) now check() arguments unconditionally and
  authorize via a callerIsAdmin(this.userId) helper instead of
  Meteor.user()/getCurrentUser(), which can return null inside an async method
  after an await.

- server/publications/boards.js (publishComposite 'boards'): publish
  template-container boards and domain-shared boards too (was hardcoded
  type:'board'), so the Templates sub-view and domain-based sharing actually
  show their boards.

- client/components/boards/boardsList.js: the /templates and /remaining routes
  now apply the same membership filtering as the home route (previously only
  'home' did, so those views were empty).

- tests/playwright/specs/33-board-domains.e2e.js: assert the domain-shared board
  via the Remaining sub-view; the default All Boards view is Starred, which only
  lists starred boards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:37:45 +03:00
Lauri OjansivuandClaude Opus 4.8 fef5a9313b rebuild-wekan.sh/.bat: run tests sequentially, not in parallel.
Running the Playwright browser suites (and, in "Run ALL tests", also Mocha +
import + Node E2E) all at once against a single dev server uses too much RAM and
swap on lower-memory machines and can crash them. Run the test jobs one at a
time instead:

- rebuild-wekan.sh: "Run ALL tests" (launch_job) now runs each job to completion
  sequentially instead of backgrounding them; "Test Playwright ALL browsers" runs
  Chromium, Firefox and WebKit one after another rather than as three concurrent
  background processes.
- rebuild-wekan.bat: "Run ALL tests" runs each job with `call` one at a time
  (no more `start` windows + .done polling); the all-browsers options use
  Playwright `--workers=1` so the three browser projects run one at a time.

Menu labels and messages updated to say "sequentially".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:37:45 +03:00
Lauri Ojansivu 72422401a5 Updated translations. 2026-06-19 11:37:44 +03:00
Lauri Ojansivu 5bbe2181ec Updated tests. 2026-06-19 11:37:44 +03:00
Lauri OjansivuandClaude Opus 4.8 7a94086b3c Translate remaining English strings in fi.i18n.json to Finnish.
Added Finnish translations for 17 previously-missing/untranslated UI strings
(domains, shared templates, member propagation/sync, avatar upload blocking).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:37:44 +03:00
Lauri OjansivuandClaude Opus 4.8 dbaa27dd67 Add GlobalAdmin REST API + OpenAPI + api.py for Domains, attachment/avatar settings and org/team feature toggles (#5850, #4740, #4737).
Adds admin-only REST endpoints modelled on the existing /api/settings ones
(WebApp.handlers + Authentication.checkUserId gate + @operation JSDoc that
feeds the OpenAPI generator): GET /api/admin/domains; GET/PUT
/api/admin/attachment-settings (cloud secrets stay masked, update reuses the
updateAttachmentStorageSettings method path); and GET + per-id/all feature
toggles under /api/admin/orgs and /api/admin/teams. api.py gains matching
admindomains/attachmentsettings/editattachmentsetting/adminorgs/adminteams
and the org/team feature commands. Human REST-API.md docs left to the docs agent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:37:44 +03:00
Lauri OjansivuandClaude Opus 4.8 84678d3492 Docs: document board-domains & GlobalAdmin REST APIs, big-board export, and template drag-to-share / propagate-members (#5850).
Add REST endpoints for board domain sharing and global-admin settings, document
exporting large boards without base64 attachments, and document template
drag-to-share drop-target gating plus Propagate Members To Boards behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:37:44 +03:00
Lauri OjansivuandClaude Opus 4.8 b3827f9596 Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:37:44 +03:00
Lauri OjansivuandClaude Opus 4.8 a633a782db Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:37:44 +03:00
Lauri OjansivuandClaude Opus 4.8 f5cca8cea1 Implement Propagate Members To Boards for flagged orgs/teams (#5850, #4737).
For each org/team with orgPropagateMembersToBoards / teamPropagateMembersToBoards
enabled, add that org/team's member users as members of the regular boards that
list it. Add-only (existing members untouched) and template boards are skipped.
New method propagateOrgTeamMembersToBoards, triggered at the end of LDAP sync().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:37:44 +03:00
Lauri OjansivuandClaude Opus 4.8 ddb88af15b Gate drag-to-share targets on the per-org/team Shared Templates flag (#5850).
The Templates view drop targets now come from a new non-admin getMyShareableGroups
server method, which returns only the user's orgs/teams that have orgSharedTemplates /
teamSharedTemplates set (plus email domains), instead of every org/team the user is in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:37:44 +03:00
Lauri OjansivuandClaude Opus 4.8 eec97fc5e3 Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:37:44 +03:00
Lauri OjansivuandClaude Opus 4.8 54ce112835 Drop the dead latest-v24.x Node.js rewrite from version.sh.
The snap build broke because the Node.js download used the floating
nodejs.org/dist/latest-v24.x/ path with a pinned filename, which 404s once
upstream advances past that release. snapcraft.yaml/Dockerfile now use the
explicit version path (fixed in f74467837), so version.sh no longer needs to
rewrite the latest-v24.x pattern; removing it ensures the bump script can never
re-introduce the bad floating path. The WeKan version itself is already bumped
correctly in snapcraft.yaml by version.sh (version field, download URL and zip
name) and guarded by release-all.yml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:37:44 +03:00
Lauri OjansivuandClaude Opus 4.8 88e332cfa0 Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:37:44 +03:00
Lauri OjansivuandClaude Opus 4.8 4aa8c5eedf Drag a Template Board onto an Org/Team/Domain to share it (#5850).
On the All Boards / Templates view, the left menu now shows the user's
Organizations, Teams and Domains as drop targets (gated by the admin's Shared
Templates scopes). Dragging a Template Board onto one shares the board with that
org/team/domain via setBoardOrgs / setBoardTeams / setBoardDomains — add-only,
preserving existing shares and adding no individual members. Thanks to xet7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:37:44 +03:00
Lauri OjansivuandClaude Opus 4.8 9ddf288450 Fix snap build: Node.js download 404 from latest-v24.x path pinned to removed v24.16.0.
wget https://nodejs.org/dist/latest-v24.x/node-v24.16.0-linux-{x64,arm64}.tar.xz
returned 404 once upstream advanced past 24.16.0, since latest-v24.x only holds the
current v24. Use explicit dist path v24.17.0 (matching Dockerfile NODE_VERSION).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:37:44 +03:00
Lauri OjansivuandClaude Opus 4.8 d9c1bf7280 Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:29 +03:00
Lauri OjansivuandClaude Opus 4.8 2725b67eef Add GlobalAdmin REST API for Admin Panel global settings.
Adds GET /api/settings and PUT /api/settings, restricted to the global admin
(Authentication.checkUserId), to read and update the Admin Panel global settings
(registration, product name, logos, custom head/manifest, accessibility and
support pages, etc.). A whitelist of fields is exposed; mailServer/SMTP
credentials are deliberately never returned or writable over REST. Documented
with @operation JSDoc (OpenAPI) and api.py examples (getsettings, editsettings).
Thanks to xet7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:29 +03:00
Lauri OjansivuandClaude Opus 4.8 46982a3efd Add REST API + OpenAPI for board domain sharing (#5850).
Adds GET/POST/DELETE /api/boards/:boardId/domains REST handlers in
server/models/boards.js with @operation JSDoc (so OpenAPI is generated),
mirroring the board-member endpoints' auth and error handling, plus
boarddomains/addboarddomain/removeboarddomain examples in api.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:29 +03:00
Lauri OjansivuandClaude Opus 4.8 62bd6f2b20 Add e2e tests for domain-based board sharing (#5850).
Covers setBoardDomains normalisation/validation/de-dup, the admin-only
getDomainsWithUserCounts counts (and non-admin rejection), and that a board
shared via a user's email domain shows up in that user's All Boards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:29 +03:00
Lauri OjansivuandClaude Opus 4.8 6b093bd137 Docs: document new LDAP/OAuth2 env vars and template-sharing features (#4737, #4738, #4739, #4736, #5876, #5850).
Document LDAP background-sync user disabling and Organizations/Teams group sync,
OAuth2/OIDC auto-registration and admin groups, NOTIFY_ON_ASSIGN, and the Admin
Panel People Domains tab plus per-org/team toggles and domain board sharing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:29 +03:00
Lauri OjansivuandClaude Opus 4.8 8e2b04a0bc Remove "Add template board" checkbox; use the dedicated Add Template Board flow (#5850).
The generic Create Board popups no longer show an "Add template board" checkbox.
Template boards are created only via the dedicated "Add Template Board" tile on
the All Boards / Templates view (createTemplateContainerPopup), which now signals
template creation through a Session flag instead of pre-checking a hidden
checkbox. Thanks to xet7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:29 +03:00
Lauri OjansivuandClaude Opus 4.8 fb16aadf1d Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:29 +03:00
Lauri OjansivuandClaude Opus 4.8 cf4494721d Template boards: members tab is group-only, shows only the creator (#5850).
On template boards (template-board / template-container), the board members
"People" tab now shows only the original creator and hides the add-individual-
member button, so these shared boards are shared only with groups (organizations,
teams, domains) — not individual users. Adds a Board.isAnyTemplateBoard() helper.
Regular boards are unchanged. Thanks to xet7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:29 +03:00
Lauri OjansivuandClaude Opus 4.8 da291a5c6e Add domain-to-board sharing UI and setBoardDomains method (#5850).
Mirrors the existing add-organization-to-board feature for free-form email
domains. Adds a board-admin-gated setBoardDomains method (plus add/remove
single-domain helpers) with trim/lowercase validation, a Domains tab in the
members sidebar listing board.domains with add/remove popups, and the i18n keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:29 +03:00
Lauri OjansivuandClaude Opus 4.8 edb2672ca5 Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:29 +03:00
Lauri OjansivuandClaude Opus 4.8 50c5d07382 Domain-based board sharing: add board.domains membership + access (#5850).
Adds a board.domains field (mirroring board.orgs/board.teams) so a board can be
shared with an email-address domain: every user whose primary email is in an
active domain on the board gets access. Adds a User.emailDomains() helper and
wires the new dimension into the board visibility selector, the board
publications and the All Boards membership query. UI to add domains to a board
and the groups-only restriction follow separately. Thanks to xet7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:29 +03:00
Lauri OjansivuandClaude Opus 4.8 d7a47b9095 Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:29 +03:00
Lauri OjansivuandClaude Opus 4.8 04b26f4702 Auth sync marks the orgs/teams it manages as Sync Members From Auth Provider (#5850).
When the LDAP/OIDC group sync (setUserOrgsTeamsFromLdap) creates or assigns an
Organization or Team, it now sets that org/team's orgSyncMembersFromAuth /
teamSyncMembersFromAuth flag to true, so the "Sync Members From Auth Provider"
column in Admin Panel > People reflects which orgs/teams are auth-managed.
Thanks to xet7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:29 +03:00
Lauri OjansivuandClaude Opus 4.8 2a61b462e4 Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 8849b015dd Admin Panel / People / Domains: list domains with per-domain user counts (#5850).
Adds a Domains tab to Admin Panel > People that lists every email-address domain
across all users with the count of users in each (each user counted once by
their primary email's domain, sorted by count). Backed by a new admin-only
getDomainsWithUserCounts method. This is the per-domain piece of the Template
Boards sharing feature. Thanks to xet7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 2a6adec845 Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 9370ddfb34 Test harness: return insertOne/insertMany result so insertedId is available.
helpers/mongo-runner.js discarded the driver result for insertOne/insertMany, so
db.insertOne(...) returned null and callers could not read insertedId. Capture
and return the result (empty insertMany yields insertedCount 0). Lets specs use
the returned id instead of pre-generating _ids.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 4919e9ef6e Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 f7c63ec620 Add e2e tests for per-org/team feature toggle methods (#4737, #5850).
Spec 32 exercises setOrg*/setTeam* per-record toggles (passed a Mongo
selector {_id}) plus the bulk setAllOrgsFeature/setAllTeamsFeature, and
asserts non-admin calls are silent no-ops that leave flags unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 b41dc111f9 Add e2e tests for All Boards / Templates redesign (#2339, #5850).
Covers ensureTemplatesBoard lazy creation/idempotency, the /templates and
/remaining All Boards sub-views filtering by board type, and the member-menu
Templates link navigation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 79847e9450 Add unit test for OAUTH2_ADMIN_GROUPS admin-from-groups logic (#5876).
Self-contained Node + assert test for oauth2AdminStatusFromGroups in
packages/wekan-oidc/loginHandler.js, covering unset/empty env, matching and
non-matching groups, comma+space parsing, object groups, and non-array input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 8e44965a04 Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 57cc7040d2 Add this session's new env vars to start-wekan.sh/.bat, docker-compose.yml and snap config.
OAUTH2_AUTO_REGISTRATION
OAUTH2_ADMIN_GROUPS
NOTIFY_ON_ASSIGN
LDAP_BACKGROUND_SYNC_DISABLE_NONEXISTANT_USERS
LDAP_SYNC_ORGANIZATIONS
LDAP_SYNC_ORGANIZATIONS_GROUPS
LDAP_SYNC_TEAMS
LDAP_SYNC_TEAMS_GROUPS

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 1ee73a0cfb All Boards / Templates: URL routes, link redirect, no auto-created templates board (#2339, #5850).
- /templates and /remaining routes (plus the home / Starred view) now address
  the All Boards left-menu sub-views via the boardListMenu Session value.
- boardList honors the route: selectedMenu initializes from boardListMenu and an
  autorun switches the view live for the three recognized menus, without
  clobbering a workspace selection.
- The Member Settings -> Templates link now opens the All Boards / Templates page
  (pathFor allboards-templates) instead of opening/creating a specific board;
  unused templatesBoardId/Slug helpers removed from userHeader.js.
- New users no longer get an auto-created Templates board at signup. The
  templates-container board (Templates + Card/List/Board Templates swimlanes) is
  now created lazily via the new ensureTemplatesBoard Meteor method, called on
  first use of the Templates view, auth-agnostic for any login method.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 82f028cbea Optional OAUTH2_ADMIN_GROUPS to set admin from OAuth2/OIDC groups (#5876).
When OAUTH2_ADMIN_GROUPS (comma/whitespace separated group names) is set, an
OAuth2/OIDC user whose groups claim intersects the list becomes a Wekan admin;
if set but no intersection, they are not made admin. Group-name extraction
handles both string and displayName object forms. When the var is empty/unset
(the default), isAdmin is left untouched so existing behavior is unchanged. This
mirrors the LDAP_SYNC_ADMIN_GROUPS concept for OAuth2/OIDC.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 6a89da85e6 Escape LDAP group-filter values to fix sign-in with parentheses in DN/cn (#5236).
The user's group_member_format value (e.g. a member DN or cn) was interpolated
into the LDAP group search filter unescaped, so a value containing "(" or ")"
produced "illegal unescaped char: (" and broke sign-in when LDAP_SYNC_ADMIN_STATUS
(or any group sync) was enabled. Adds RFC 4515 escaping (escapeLdapFilterValue)
for ( ) * \ and NUL, applied to the member-format value in getUserGroups and
isUserInGroup. Strictly safer (also prevents filter injection). Thanks to xet7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 ce09a25601 Make the per-org/team membership-sync setting auth-agnostic (#4737, #5850).
Renames the "Sync Members From LDAP" column to "Sync Members From Auth Provider"
so the setting and feature apply to any authentication provider (LDAP,
OAuth2/OIDC, SAML, etc.), not just LDAP. Renames the field/method/i18n key from
*SyncMembersFromLdap / sync-members-from-ldap to *SyncMembersFromAuth /
sync-members-from-auth across schema, publications, admin methods and the admin
UI, and generalizes the translation string and code comments. The other two
columns (Shared Templates, Propagate Members To Boards) were already
auth-agnostic. Thanks to xet7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 84194f6727 Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 c4cc17c10d Per-org/per-team feature toggle columns in Admin Panel > People (#4737, #5850).
Adds three per-record boolean settings to Organizations and Teams, each shown as
its own checkbox column in Admin Panel > People > Organizations / Teams, disabled
by default:
- Shared Templates (orgSharedTemplates / teamSharedTemplates)
- Propagate Members To Boards (orgPropagateMembersToBoards / teamPropagateMembersToBoards)
- Sync Members From LDAP (orgSyncMembersFromLdap / teamSyncMembersFromLdap)

Each column header has select-all / unselect-all (bulk set across all orgs/teams),
and the existing per-tab search box filters the rows. Schema fields, admin-only
toggle methods (per-record and bulk), and the publication projections are added;
all default off. This is the settings surface; the behaviours these flags gate
are wired separately. Thanks to xet7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 1e4b88e8ee Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 f936247428 LDAP authoritative active status: re-enable reappearing users (#4738).
When LDAP_BACKGROUND_SYNC_DISABLE_NONEXISTANT_USERS is enabled, the background
sync now also re-enables (loginDisabled=false) an LDAP user that is present in
the directory again, in addition to disabling users that disappeared — so LDAP
is the authoritative source of active status (matching the external
docs/Login/ldap-sync/ldap-sync.py behaviour). This recovers from a removal or a
transient outage, and overrides a manual disable of an LDAP user. Still off by
default. Thanks to xet7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 1727841a3a Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 e65f61d36b Add e2e test for LDAP org/team sync; allow admins to call it (#4737).
setUserOrgsTeamsFromLdap may now be called by an admin in addition to the
server-side LDAP sync (admins already manage orgs/teams in the Admin Panel);
other client calls are still rejected. Adds a Playwright e2e spec that verifies a
group is created as a Team/Organization (active), added to the user, add-only
(existing memberships preserved), idempotent, and that a non-admin call is
rejected. Thanks to xet7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 17f8413143 Sync LDAP groups as Organizations/Teams at login too (#4737).
Extracts the org/team sync into a shared syncUserGroupsToOrgsTeams helper and
calls it from the login handler (both existing-user and new-user branches) in
addition to the background sync, so a user's LDAP groups become Wekan
Organizations/Teams on login as well. Still gated by LDAP_SYNC_ORGANIZATIONS /
LDAP_SYNC_TEAMS (default off). Thanks to xet7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 e6a147a9c0 Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 bc8167166d Optionally sync LDAP groups as Organizations or Teams (#4737).
New optional, default-off settings sync a user's LDAP groups into Wekan
Organizations and/or Teams during background sync:
- LDAP_SYNC_ORGANIZATIONS (default false) — create/assign Organizations.
- LDAP_SYNC_TEAMS (default false) — create/assign Teams.
- LDAP_SYNC_ORGANIZATIONS_GROUPS / LDAP_SYNC_TEAMS_GROUPS — optional
  comma-separated allowlists restricting which of the user's groups become
  orgs/teams (empty = all of the user's groups).

The user's groups are read with the existing LDAP_GROUP_FILTER_* machinery
(getUserGroups now also runs when org/team sync is enabled). The create/assign
runs in a new server-only app method (setUserOrgsTeamsFromLdap) that uses the
real Org/Team models, finds an org/team by display name, creates it (active) if
missing, and adds it to the user's membership. The assignment is add-only, so
existing memberships — including manual ones — are never removed. Off by
default, so existing deployments are unaffected. Thanks to xet7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 f9bfd370aa Optionally disable LDAP users that disappeared from the directory (#4738).
New optional LDAP_BACKGROUND_SYNC_DISABLE_NONEXISTANT_USERS (default false):
when enabled, the background sync disables (loginDisabled=true) LDAP-sourced
Wekan users that are no longer found in the LDAP directory. Off by default, so
existing deployments are unaffected; only LDAP users are considered, and
already-disabled accounts are left untouched (no auto re-enable, so manual
disables are respected). Thanks to xet7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 8d28106dd5 Keep LDAP admin status updated during background sync (#4739).
LDAP_SYNC_ADMIN_STATUS previously only updated a user's admin status at login,
so an admin-group change in LDAP was never reflected for existing users who did
not log in. The background sync now applies the same admin-status logic as the
login handler to each existing LDAP user it syncs. Gated by the existing
LDAP_SYNC_ADMIN_STATUS flag (default off) and only runs when background sync of
existing users is enabled, so default behaviour is unchanged. Thanks to xet7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 e2aa16af68 Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 c1752c5fba Export big boards: optional JSON export without base64 attachments (#5870).
Big boards could not be exported as JSON because every attachment is base64-
encoded inline into one object that is then JSON.stringify-ed, which overflows
V8's maximum string length (and loads every attachment buffer into memory at
once). Adds an optional ?attachments=false query parameter to
GET /api/boards/:boardId/export that exports the board structure and attachment
metadata while skipping the base64 file data entirely, so large boards export
successfully. A matching "Export / JSON (without attachments)" entry is added to
the board export menu. The default export is unchanged (attachments still
included). Includes a REST regression test. Thanks to xet7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 10b2123eaf Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 f094f7e867 Notify the assigned card member/assignee directly on assignment (#5833).
When a user is added as a card member (joinMember) or assignee (joinAssignee),
that user is now added to the notification watchers so the assignment reaches
the assignee themselves, instead of only board watchers — or, with
BIGEVENTS_PATTERN, every active board member. The performer of the action is
still skipped, so self-assignment does not self-notify. Optional: opt out with
NOTIFY_ON_ASSIGN=false; on by default. Thanks to xet7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 1d84e1fc39 Updated ChangeLog.
Reframe the Template Boards entry as improvements to the existing card/board
templates feature (All Boards page changes), and link issue #5850.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 6ee9a4b6a2 Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 d822c1a899 Optional OAUTH2_AUTO_REGISTRATION to refuse unknown OAuth2/OIDC logins (#4736).
Optional and off by default: when OAUTH2_AUTO_REGISTRATION=false, a first-time
OAuth2/OIDC login that does not already match a Wekan account (by verified email,
the same secure key the existing account-merge logic uses) is rejected instead of
silently creating a new account. Lets an instance allow only already-provisioned
users (e.g. synced from LDAP) to sign in via OAuth2. Default 'true' keeps the
previous behaviour, so existing deployments are unaffected. Thanks to xet7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 8d79bf953e Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 100357d953 Template Boards: Add Template Board button, and sharing with Organizations, Teams and Domains.
Describes the planned feature: a dedicated "Add Template Board" button at
All Boards / Templates (replacing the "Add template board" checkbox on the
"+ Add board" button); Template Boards containing Board/Swimlane/List/Card
templates that members can add to their boards; drag-to-share personal Template
Boards onto Organization / Team / Domain boards when enabled in Admin Panel;
Domain meaning the email-address domain; and a new Admin Panel / People /
Domains page listing domains and per-domain user counts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 54e19914b3 Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 963e6588c0 Added more tests.
Playwright e2e tests for recent features: the "Mark as complete" Show at
Minicard option (allowsDueCompleteOnMinicard — hidden by default, shown when
enabled) and the Admin Panel avatar-upload block (#4740 isAvatarUploadBlocked /
limitSettings.avatarsUploadBlocked — disabled by default).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 978964e069 Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
Lauri OjansivuandClaude Opus 4.8 8e2dacd856 Add Admin Panel setting to block avatar uploads (#4740).
Adds limitSettings.avatarsUploadBlocked (default false — avatars enabled)
to Admin Panel > Attachments > Transfer limits. When enabled, the avatar
upload option is hidden in the user avatar popup and new avatar uploads are
rejected server-side (server/permissions/avatars.js), mirroring the existing
attachmentsUploadBlocked. Useful when avatars are synced from an external
source. Thanks to xet7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:28 +03:00
github-actions[bot] a35e5dc12c Bump versions for v9.57 2026-06-18 23:15:52 +00:00
Lauri Ojansivu 28c89d3308 Updated ChangeLog. 2026-06-19 02:12:34 +03:00
Lauri Ojansivu 067770a527 Updated ChangeLog. 2026-06-19 02:10:10 +03:00
Lauri Ojansivu af3ca0d091 Fix Docker builds.
Thanks to xet7 !
2026-06-19 02:08:41 +03:00
github-actions[bot] c2cf48e347 Bump versions for v9.56 2026-06-18 16:51:38 +00:00
Lauri Ojansivu 8fe3013505 Updated ChangeLog. 2026-06-18 19:39:04 +03:00
Lauri Ojansivu b00537c146 Updated ChangeLog. 2026-06-18 19:37:56 +03:00
Lauri Ojansivu c5910e47d2 Merge pull request #6395 from wekan/dependabot/npm_and_yarn/dompurify-3.4.11
Bump dompurify from 3.4.9 to 3.4.11
2026-06-18 19:35:35 +03:00
github-actions[bot] f703ea663a Bump versions for v9.55 2026-06-18 16:31:48 +00:00
dependabot[bot] 321e70ebed Bump dompurify from 3.4.9 to 3.4.11
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.9 to 3.4.11.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.9...3.4.11)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.11
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 16:29:34 +00:00
Lauri Ojansivu 729417ba44 Updated ChangeLog. 2026-06-18 19:28:17 +03:00
Lauri Ojansivu 4083b1ad3f Updated ChangeLog. 2026-06-18 19:26:49 +03:00
Lauri Ojansivu 61c8b2fb24 - Build WeKan server bundles for s390x, ppc64le and armv7l.
The "Release All Platforms" workflow now also builds the WeKan server bundle
  for every other architecture Node.js 24.x publishes binaries for — s390x,
  ppc64le and armv7l — by rebuilding the bundle's native modules under QEMU
  emulation, and attaches each `wekan-<version>-<arch>.zip` to the GitHub Release
  as an extra download. These extra-architecture bundles are not part of the
  Docker image or snap (run a separate MongoDB on those platforms), and are built
  independently so a slow or failing emulated build never blocks the main
  amd64/arm64/win64/mac release, Docker or snap.

Thanks to xet7.
2026-06-18 19:23:59 +03:00
Lauri Ojansivu ddfe351968 - Fixed "Release All Platforms" snap build failing at upload with exit 64.
The Launchpad snap build produced no `.snap` (only build logs), but
  `snapcraft remote-build` exited 0 so the workflow reported success; the upload
  step then globbed `wekan_<version>_*.snap`, matched nothing, and passed the
  literal pattern to `snapcraft upload` ("is not a valid file", exit 64).
  Hardened the workflow to verify a `.snap` was actually produced (instead of
  trusting the exit code alone), print the Launchpad build logs when it was not,
  and use `nullglob` + existence checks in the upload/attach steps so a no-match
  fails clearly instead of cryptically. Also made `releases/version.sh` set
  `snapcraft.yaml`'s `version:` robustly — matching `^version:` rather than a
  hard-coded line number and tolerating any quoting, then verifying the change
  applied — so the snap can no longer be built with a stale version that the
  upload glob cannot match, and the snap job fails fast if the tagged version
  does not match the release.

Thanks to xet7.
2026-06-18 19:22:18 +03:00
Lauri Ojansivu 7557a3eed9 Updates 2026-06-18 14:12:23 +03:00
Lauri Ojansivu dc1e3fb3f1 Deleted build log. 2026-06-18 14:08:44 +03:00
Lauri Ojansivu 6127a08a93 Updated docs 2026-06-18 14:08:43 +03:00
github-actions[bot] 1a352628f1 Bump versions for v9.54 2026-06-18 04:43:26 +00:00
Lauri Ojansivu d0a44d8afa Updated ChangeLog. 2026-06-18 07:38:27 +03:00
Lauri Ojansivu ada5c359f3 Updated ChangeLog. 2026-06-18 07:37:15 +03:00
Lauri Ojansivu d6a8f43866 - [Added a "Show at Minicard" option for the Card Settings "Mark as complete"
toggle](https://github.com/wekan/wekan/issues/6385). The "Mark as complete"
  row in Board Settings → Card Settings now has its own "Show at Minicard"
  checkbox (new board setting `allowsDueCompleteOnMinicard`), unchecked by
  default — so the complete toggle is no longer shown on the current board's
  minicards unless it is explicitly enabled.

Thanks to xet7.
2026-06-18 07:35:45 +03:00
Lauri Ojansivu 7b9ea5e162 Updated ChangeLog. 2026-06-18 06:39:08 +03:00
Lauri Ojansivu ebc0f017bf Updated dependencies. 2026-06-18 06:32:41 +03:00
Lauri Ojansivu fda5293433 Updated dependencies. 2026-06-18 06:20:06 +03:00
Lauri Ojansivu b136fae58c Merge pull request #6389 from wekan/dependabot/npm_and_yarn/tweedegolf/sab-adapter-google-cloud-3.0.2
Bump @tweedegolf/sab-adapter-google-cloud from 1.0.10 to 3.0.2
2026-06-18 06:18:48 +03:00
Lauri Ojansivu f15144d82d Merge pull request #6390 from wekan/dependabot/npm_and_yarn/aws-sdk/client-s3-3.1071.0
Bump @aws-sdk/client-s3 from 3.1054.0 to 3.1071.0
2026-06-18 06:18:14 +03:00
Lauri Ojansivu d87d819486 Merge pull request #6391 from wekan/dependabot/npm_and_yarn/google-cloud/storage-7.21.0
Bump @google-cloud/storage from 7.19.0 to 7.21.0
2026-06-18 06:17:35 +03:00
Lauri Ojansivu 67d09a6fd5 Merge pull request #6392 from wekan/dependabot/npm_and_yarn/typescript-eslint/eslint-plugin-8.61.1
Bump @typescript-eslint/eslint-plugin from 8.60.1 to 8.61.1
2026-06-18 06:17:07 +03:00
Lauri Ojansivu 84bda3cf4b Merge pull request #6393 from wekan/dependabot/npm_and_yarn/tests/playwright/mongodb-7.3.0
Bump mongodb from 6.21.0 to 7.3.0 in /tests/playwright
2026-06-18 06:16:37 +03:00
dependabot[bot] 4cb43bde1f Bump @tweedegolf/sab-adapter-google-cloud from 1.0.10 to 3.0.2
Bumps [@tweedegolf/sab-adapter-google-cloud](https://github.com/tweedegolf/storage-abstraction) from 1.0.10 to 3.0.2.
- [Release notes](https://github.com/tweedegolf/storage-abstraction/releases)
- [Changelog](https://github.com/tweedegolf/storage-abstraction/blob/master/changelog.md)
- [Commits](https://github.com/tweedegolf/storage-abstraction/commits)

---
updated-dependencies:
- dependency-name: "@tweedegolf/sab-adapter-google-cloud"
  dependency-version: 3.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 03:10:11 +00:00
Lauri Ojansivu f84fda742a Updated dependencies.
Thanks to developers of dependencies !
2026-06-18 06:06:28 +03:00
dependabot[bot] f2b1ba112c Bump @google-cloud/storage from 7.19.0 to 7.21.0
Bumps [@google-cloud/storage](https://github.com/googleapis/google-cloud-node/tree/HEAD/handwritten/storage) from 7.19.0 to 7.21.0.
- [Release notes](https://github.com/googleapis/google-cloud-node/releases)
- [Changelog](https://github.com/googleapis/google-cloud-node/blob/main/handwritten/storage/CHANGELOG.md)
- [Commits](https://github.com/googleapis/google-cloud-node/commits/storage-v7.21.0/handwritten/storage)

---
updated-dependencies:
- dependency-name: "@google-cloud/storage"
  dependency-version: 7.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 02:55:04 +00:00
Lauri Ojansivu 1e2008b8bb Updated dependencies.
Thanks to developers of dependencies !
2026-06-18 05:50:15 +03:00
Lauri Ojansivu 85909d08cc Updated docs. 2026-06-18 04:42:51 +03:00
dependabot[bot] c3f53033c9 Bump @aws-sdk/client-s3 from 3.1054.0 to 3.1071.0
Bumps [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) from 3.1054.0 to 3.1071.0.
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1071.0/clients/client-s3)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1071.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 01:20:21 +00:00
dependabot[bot] 5da7de66de Bump mongodb from 6.21.0 to 7.3.0 in /tests/playwright
Bumps [mongodb](https://github.com/mongodb/node-mongodb-native) from 6.21.0 to 7.3.0.
- [Release notes](https://github.com/mongodb/node-mongodb-native/releases)
- [Changelog](https://github.com/mongodb/node-mongodb-native/blob/main/HISTORY.md)
- [Commits](https://github.com/mongodb/node-mongodb-native/compare/v6.21.0...v7.3.0)

---
updated-dependencies:
- dependency-name: mongodb
  dependency-version: 7.3.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 01:19:12 +00:00
Lauri Ojansivu 52ca484634 Updated ChangeLog. 2026-06-18 04:16:23 +03:00
Lauri Ojansivu 6d6c26dd9b Updated tests.
Thanks to xet7 !
2026-06-18 04:14:59 +03:00
dependabot[bot] 0f218ba8b6 Bump @typescript-eslint/eslint-plugin from 8.60.1 to 8.61.1
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 8.60.1 to 8.61.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.61.1/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.61.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 00:26:42 +00:00
Lauri Ojansivu debbd6f343 Updated ChangeLog. 2026-06-18 03:24:38 +03:00
Lauri Ojansivu d09b72670c Explain to Dependabot to not complain about rspack.
Thanks to xet7 !
2026-06-18 03:21:38 +03:00
Lauri Ojansivu dd09c1d0d3 Updated docs 2026-06-18 02:42:05 +03:00
github-actions[bot] b78bcaa2f0 Bump versions for v9.53 2026-06-17 22:03:36 +00:00
Lauri Ojansivu b8e66f1675 Updated ChangeLog. 2026-06-18 00:59:58 +03:00
Lauri Ojansivu b65ae77fbd - Fixed Playwright e2e tests all failing because Meteor was not a browser global.
Every Playwright end-to-end test (all 183, across Chromium, Firefox and WebKit) failed at
  the `waitForMeteor` step with `TimeoutError: page.waitForFunction`, because
  `typeof Meteor` was `undefined` in the browser `window` scope. Under the Meteor 3.5 +
  rspack build, bare `Meteor` references in app code are rewritten by rspack's ProvidePlugin
  into per-module imports, so `Meteor` is no longer placed on `window` the way the classic
  Meteor linker did. The tests call `Meteor.loginWithToken` / `Meteor.userId` /
  `Meteor.subscribe` etc. via `page.evaluate` (which runs in `window` scope), so they could
  never proceed past login. Fixed by re-exposing `window.Meteor = Meteor` early in
  `client/00-startup.js`, restoring the long-standing classic-Meteor behaviour where `Meteor`
  is a global (also handy in the browser console).

Thanks to xet7.
2026-06-18 00:59:58 +03:00
github-actions[bot] c710558736 Bump versions for v9.52 2026-06-17 21:37:24 +00:00
Lauri Ojansivu aac53eaac7 Updated ChangeLog. 2026-06-18 00:30:15 +03:00
Lauri Ojansivu 8dfb54d9d5 - Fixed [InputBleed](https://wekan.fi/hall-of-fame/inputbleed/):
incomplete multi-character HTML sanitization in card dependency
  import allowed HTML/script element injection
  (CWE-79 Cross-site Scripting, CWE-80 Improper Neutralization of Script-Related HTML
  Tags, CWE-116 Improper Encoding or Escaping of Output; GitHub CodeQL code scanning
  alert #421, rule `js/incomplete-multi-character-sanitization`, severity High).
  `stripHtml()` in `client/lib/importDependencies.js` removed HTML tags with a single
  pass of `/<[^>]*>/g`. That is an incomplete multi-character sanitization in two ways:
  removing one match can splice surrounding text into a new match (so the replacement
  must be looped to a fixed point), and a dangling, unclosed tag that has no closing
  `>` (for example a trailing `<script` or `<svg/onload=...`) is never matched by the
  regex at all and survives untouched — leaving `<script` in the output, exactly as the
  scanner warned. A crafted card-dependency ("Red Strings") import file (the
  WeKan/generic JSON or Miro item titles and connector captions that pass through
  `stripHtml`) could therefore smuggle an HTML/script fragment past the sanitizer.
  Fixed by looping the tag-stripping replacement to a fixed point and then removing any
  remaining stray `<`/`>` characters, so neither a complete nor a partial tag can
  remain.

Thanks to GitHub CodeQL, xet7 and Claude !
2026-06-18 00:25:39 +03:00
Lauri Ojansivu f716c2bc15 Updated ChangeLog. 2026-06-18 00:13:01 +03:00
Lauri Ojansivu 8433cbbddd Updated ChangeLog. 2026-06-18 00:10:29 +03:00
Lauri Ojansivu 0094fd21a0 - Card Details popup: removed the redundant empty second popup that appeared at
the top of the page when a card was opened as a popup (for example from the
  Board Table view Edit link or from search results). The card details content is
  always position:fixed, so it renders as its own framed box and escapes the
  generic popup wrapper, leaving that wrapper (with its "Card Details" title
  header, border and background) visible as an empty box. The wrapper is now
  collapsed and made invisible so only the card itself shows. The card's own close
  button now closes the popup (in addition to clicking outside or pressing Escape).

Thanks to xet7.
2026-06-18 00:10:29 +03:00
Lauri Ojansivu 79f4809a7c Updated version. 2026-06-18 00:10:29 +03:00
github-actions[bot] 279dce71cf Bump versions for v9.51 2026-06-17 20:08:57 +00:00
Lauri Ojansivu 8299911f6e Updated ChangeLog. 2026-06-17 23:05:12 +03:00
Lauri Ojansivu 2b9eab7667 - Board Table view: the table now has the columns Card, List, Swimlane, Assignees,
Members, Labels, Received, Start, Due and End. The Received, Start, Due and End
  dates are formatted and styled the same way as on the opened card details and the
  Gantt view (reusing the cardReceivedDate / cardStartDate / cardDueDate / cardEndDate
  badge templates), and can be sorted by clicking their column headers. Clicking a
  date opens the date-select popup so it can be changed, and an add (+) button is
  shown for empty dates — both only for board roles that have permission to change
  the card (the same `canModifyCard` check used on card details). A Received, Start,
  Due or End column is hidden when both its "Show at Card" and "Show at Minicard"
  Card Settings are unchecked for the board. The Labels cell now word-wraps so long
  label names no longer overflow across the other columns. Each row also has a
  leftmost "Edit" link (pencil icon + text) styled like the one on the Admin Panel /
  People page; clicking it opens the Card Details popup on top of the Board Table
  view.

Thanks to xet7 !
2026-06-17 23:05:12 +03:00
github-actions[bot] e3713e1f62 Bump versions for v9.50 2026-06-17 18:19:58 +00:00
Lauri Ojansivu 964b314f41 Updated ChangeLog. 2026-06-17 21:10:06 +03:00
Lauri Ojansivu 2c596a52a6 Updated ChangeLog. 2026-06-17 21:06:35 +03:00
Lauri Ojansivu 5dfe6c3deb - Board Table view: render markdown with +viewer in the Card, List, Swimlane and
Labels cells, so emoji shortcodes and markdown (for example `:thumbsup:`) display
  rendered instead of as literal text, and word-wrap the Card, List and Swimlane
  cells so long text no longer overflows across the other columns.

Thanks to xet7.
2026-06-17 21:06:35 +03:00
github-actions[bot] 703f8ab0f8 Bump versions for v9.49 2026-06-17 15:42:57 +00:00
Lauri Ojansivu 411656fafe Updated ChangeLog. 2026-06-17 18:32:20 +03:00
Lauri Ojansivu ec72bbcabf Updated ChangeLog. 2026-06-17 18:17:59 +03:00
Lauri Ojansivu 453fc3849f - Fix Board Table view not loading with "No such template: tableView" error.
The `tableView.jade`, `tableView.js` and `tableView.css` files of the new Board
  Table view were never imported in `client/features/boards.js`, so the `tableView`
  template was not bundled and selecting the Table view threw a Tracker recompute
  exception. Added the missing imports.

Thanks to xet7.
2026-06-17 18:17:59 +03:00
github-actions[bot] f910474970 Bump versions for v9.48 2026-06-17 12:52:43 +00:00
Lauri Ojansivu 64e8f8349a Updated ChangeLog. 2026-06-17 15:41:25 +03:00
Lauri Ojansivu 86bf48a626 Board Table view. Part 2.
Thanks to xet7 and Claude !
2026-06-17 15:40:03 +03:00
Lauri Ojansivu e085e5bfeb - Board Table view.
The board view menu (Swimlanes, Lists, Calendar, Gantt) gains a new "Table" entry
  below Gantt. It shows every card of the current board in a table — Card, List,
  Swimlane, Members, Labels and Due Date — using the same styling as the My Cards
  table view (the `.my-cards-board-table` CSS classes). Whereas My Cards' table spans
  all of the user's cards across boards, this is the per-board equivalent showing all
  of the current board's cards. The view is stored like the others as the user's
  `board-view-table` boardView. It also has a search box and previous/next pagination
  styled like the Admin Panel / People page, and Excel-like column sorting: click the
  Card, List, Swimlane or Due Date header to sort by it, click again to reverse, with
  an arrow showing the active column and direction. Search, sort and pagination run
  client-side over the board's already-loaded cards.

Thanks to xet7 and Claude.
2026-06-17 15:37:36 +03:00
Lauri Ojansivu a294955725 Updated translations. 2026-06-17 15:26:26 +03:00
Lauri Ojansivu 42dc123ece Updated ChangeLog. 2026-06-17 15:12:43 +03:00
Lauri Ojansivu da93bfbca6 - Drag-to-scroll (dragscroll) now works on the All Boards, My Cards, Login and Register pages and in the board Lists view.
Dragging empty space to scroll already worked on the board Swimlanes view but not on
  these pages. The dragscroll library scrolls whichever element carries the `dragscroll`
  class, and that element must be the actual scroll container. On All Boards, My Cards,
  Login and Register the page scroll container is `<body>` (the previous `dragscroll`
  class on `ul.board-list` only scrolled in mobile view), so a small shared helper
  (`client/lib/pageDragscroll.js`) now toggles the `dragscroll` class on `<body>` while
  those templates are mounted and calls `dragscroll.reset()`; the library has a dedicated
  `el == document.body` branch for whole-page scrolling. In the board Lists view the
  `.board-canvas` only received `dragscroll` when the board had swimlanes, so vertical
  drag-scroll failed on swimlane-less boards; the canvas now always carries the class.
  One-finger touch scrolling (client/lib/dragscrollTouch.js) covers these too.

Thanks to xet7 and Claude.
2026-06-17 15:11:22 +03:00
Lauri Ojansivu b67795f79b Updated ChangeLog. 2026-06-17 14:53:13 +03:00
Lauri Ojansivu ad2b969eb5 - Login and register pages are now scrollable to the bottom of the form.
Tall authentication forms (many login methods, legal notice, language selector, etc.)
  could overflow the viewport without a reliable way to scroll to all fields. The auth
  pages render directly into `<body>`, which already has `overflow-y: auto`, so the
  vertical scrollbar now appears whenever the form is taller than the viewport, with a
  real draggable thumb, placed on the right for LTR and on the left for RTL (since
  `<body>` inherits `direction` from the html `dir` attribute). Two `<br>` are added
  below the dialog so the bottom of the form scrolls fully into view. An earlier attempt
  forced `overflow-y: scroll`, which left a non-draggable empty scrollbar track in
  Chromium and no scrollbar at all in Firefox when the form fit the viewport; using the
  default `overflow-y: auto` shows the scrollbar only when there is something to scroll.

Thanks to xet7 and Claude.
2026-06-17 14:52:38 +03:00
Lauri Ojansivu b4040b60be Update version number 2026-06-17 13:31:27 +03:00
Lauri Ojansivu 01a7331500 Updated ChangeLog. 2026-06-17 13:24:14 +03:00
Lauri Ojansivu a1281acec0 - [List scrollbar disappeared after resizing a list width](https://github.com/wekan/wekan/commit/COMMIT_HASH_PLACEHOLDER):
When changing the width of a list by dragging the resize handle between lists, the list's vertical scrollbar
  (used to scroll up and down through the cards in a list) disappeared. Cause: dragging
  the resize handle sets an inline `--list-width` property on the list, which made the
  resize CSS rules force `display: block` on the list permanently. That collapsed the
  flex-column layout so `.list-body` was no longer height-bounded and its
  `overflow-y: auto` scrollbar never appeared. Fixed by keeping the flex column layout
  (`display: flex; flex-direction: column`) instead of `display: block` during and after
  resize; the explicit inline width still pins the list width.

Thanks to xet7 and Claude !

Fixes #6386
2026-06-17 13:24:14 +03:00
Lauri Ojansivu ec4d13c6dd Updated ChangeLog. 2026-06-17 13:24:14 +03:00
Lauri Ojansivu e6db7d412e Added non-experimental docker settings.
Thanks to xet7 !
2026-06-17 13:24:14 +03:00
github-actions[bot] d8a07d526c Bump versions for v9.47 2026-06-16 22:13:36 +00:00
Lauri Ojansivu 12051ad993 Updated ChangeLog. 2026-06-17 01:01:04 +03:00
Lauri Ojansivu a02a6646ef Updated translations 2026-06-16 23:53:41 +03:00
Lauri Ojansivu 421f8d5648 Updated ChangeLog. 2026-06-16 23:51:28 +03:00
Lauri Ojansivu 64aa784b2b - Developer test tooling: "Run ALL tests" now stops an existing dev server on port 3000 instead of aborting
`rebuild-wekan.sh` menu option 9 ("Run ALL tests") previously errored out with
  "Port 3000 is already in use" when a dev server was already running, forcing the
  user to stop it manually. It now detects and stops the existing Meteor dev server
  before starting its own.
  Details:
  - When port 3000 is busy, option 9 finds the existing Meteor dev server
    (`pgrep -f 'meteor run --port 3000'`) and sends it a normal `kill`, which also
    tears down the node child it spawned.
  - It then polls the port for up to 30s, escalating to `SIGKILL` at the 15s mark
    and falling back to `lsof -ti tcp:3000` (or `fuser -k 3000/tcp`) to catch
    anything still holding the port whose command line does not match.
  - Only if the port is still occupied after all that does it print an error and
    abort; otherwise it reports "Port 3000 is now free" and starts its own server.

Thanks to xet7 and Claude.
2026-06-16 23:50:13 +03:00
Lauri Ojansivu 7d80512cc5 Updated ChangeLog. 2026-06-16 23:43:26 +03:00
Lauri Ojansivu 7db0248920 - Fix moving/copying a card silently failing with a 403 validation error
the move, copy, copy-many and convert-checklist-item card dialogs could leave a
  card in its original list instead of moving it.
  Details:
  - Root cause: the dialog's "Done" handler read the target board/swimlane/list by
    scraping the DOM `<select>` elements. The reactive `boards()` helper can
    transiently return `[]` while a `board` subscription re-resolves, leaving the
    board `<select>` momentarily option-less, so `selectedIndex` was `-1` and the
    scraped `boardId` was `undefined`. `card.move(undefined, …)` then failed
    server-side with `ValidationError: Not permitted. Untrusted code may only
    updateAsync documents by ID [403]`, and the card silently stayed put. This was
    a gap in the earlier dialog fix, which bound only the swimlane and list options
    to the live selection but left the board `<select>` on the (empty)
    last-confirmed option.
  - Fix: the Done handler now reads `boardId`/`swimlaneId`/`listId` from the
    dialog's live reactive selection (`selectedBoardId`/`selectedSwimlaneId`/
    `selectedListId`), which stays correct across re-renders, and the board
    `<option selected>` attribute is bound to the live selection in all four
    dialogs for UI consistency.
  - Test hardening: three Playwright/Node E2E tests that flaked under the
    all-parallel run (move-list-right, add-to-top/add-to-bottom, and the Node E2E
    second-session list-order check) now wait for the board subscription to
    populate before acting, instead of reading once after a fixed delay.

Thanks to xet7 and Claude !
2026-06-16 23:41:43 +03:00
Lauri Ojansivu e6ad6c9697 Updated ChangeLog. 2026-06-16 22:34:05 +03:00
Lauri Ojansivu 9f2a81349f Build scripts: At tests option 9, run option 2 build if .build or node_modules missing.
Thanks to xet7 and Claude !
2026-06-16 22:32:01 +03:00
Lauri Ojansivu 363ab00949 Updated ChangeLog. 2026-06-16 22:17:15 +03:00
Lauri Ojansivu 83963dce80 Run ALL tests: start the :3000 server before Mocha so it boots fast again:
the parallel "Run ALL tests" flow launched Mocha (in its own .meteor/local-test build)
before the :3000 dev server, so two full Meteor builds competed for CPU/disk
and the server took a long time to become ready — shown as a long line of
dots during the readiness wait. Mocha and the import regression do not need
the server, so they are now launched only after the server build is underway;
the server builds alone and boots fast again, while they still run in
parallel with the E2E and browser jobs. Applied to both
rebuild-wekan.sh and rebuild-wekan.bat.

Thanks to xet7 and Claude.
2026-06-16 22:15:05 +03:00
Lauri Ojansivu a4766c8804 Updated translations. 2026-06-16 21:50:21 +03:00
Lauri Ojansivu ac3a36267f Updated ChangeLog. 2026-06-16 21:48:03 +03:00
Lauri OjansivuandClaude Opus 4.8 b11e2f201a CHANGELOG: add Upcoming entries for parallel test tooling and flaky-test fixes
Link the Upcoming entries to commit 19fe2e2b6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:41:37 +03:00
Lauri OjansivuandClaude Opus 4.8 19fe2e2b6f Test tooling: parallel "Run ALL tests" + fix flaky Playwright card/board tests
rebuild-wekan.sh/.bat run all test jobs in parallel against one dev server
(menu option 9) plus a new "all browsers in parallel" option (16), with
per-job logs and a PASS/FAIL summary. Mocha runs in its own .meteor/local-test
build dir; .gitignore ignores the new build dirs.

Fix three load-induced flaky failures surfaced by the parallel run:
- 03-cards-operations "move does not create duplicate cards": snapshot the
  card titles only after the move settles (wait for the card in the target
  list / gone from the source list) instead of mid-flight.
- 03-cards-operations "add-to-bottom places the card last": poll until the
  reactive re-sort places the new card last, since submitNewCard only waits
  for the card to exist.
- helpers/auth.js openBoard: 5 attempts x 20s so the slowest browser (WebKit)
  survives contention from the 3-browser parallel run against one server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:41:06 +03:00
Lauri Ojansivu f4111cb064 Updated translations 2026-06-16 20:06:25 +03:00
Lauri OjansivuandClaude Opus 4.8 df05624f8b CHANGELOG: add Upcoming entries for issues #6380, #6381 and #6382
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:55:57 +03:00
Lauri OjansivuandClaude Opus 4.8 67d9de32db Fix #6381: make the card "Mark as complete" toggle configurable, hidden by default
Add a board setting allowsDueComplete (defaultValue false) that controls whether
the "Mark as complete" toggle (.js-toggle-due-complete) is shown on cards. The
toggle in cardDetails is now gated behind currentBoard.allowsDueComplete, and a
new checkbox row in the board Card Settings popup lets each board enable it. It
is hidden by default as requested; existing boards (no field) keep it hidden
until enabled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:55:27 +03:00
Lauri OjansivuandClaude Opus 4.8 8e70a2b6a9 Fix #6380: login page missing username/password fields after upgrade
The password form (.at-pwd-form) is hidden by default via CSS and only revealed
by JS once Meteor.call('isPasswordLoginEnabled') returns truthy. If that call
errored/returned late, or the accounts-templates form had not yet rendered its
fields when onRendered ran, $('.at-pwd-form').show() matched nothing and the
login page stayed without username/password fields (reported after a Snap
9.34 -> 9.46 upgrade on Ubuntu).

Now treat password login as enabled unless it is explicitly disabled
(result === false), and retry showing the form until the element exists (the
same wait-for-element pattern already used for the OAuth button), so a slow or
failed method call no longer locks users out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:53:07 +03:00
Lauri OjansivuandClaude Opus 4.8 dd7306d8de Fix #6382: stop client from auto-creating thousands of empty swimlanes
getDefaultSwimline()/getDefaultSwimlineAsync() inserted a default swimlane as a
side effect whenever none was found. On the client these getters run inside
reactive render contexts, so for a board whose swimlanes are not yet
loaded/subscribed (e.g. the default subtasks board reached by viewing a subtask
as a standalone card via "All boards"), getSwimlanes() was transiently empty and
every re-render inserted another empty swimlane — producing thousands (2008 in
the report) and freezing the browser.

Only auto-create the default swimlane on the server (guarded with
Meteor.isServer); the server already creates it at board creation and self-heals
here. On the client the getters now return an existing swimlane or undefined
without inserting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:52:40 +03:00
Lauri Ojansivu 87f905a2a1 Updated translations. 2026-06-16 19:48:08 +03:00
Lauri OjansivuandClaude Opus 4.8 96aac700ca CHANGELOG: add Upcoming entries for card dialog fix and cross-platform Playwright testing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:47:14 +03:00
Lauri OjansivuandClaude Opus 4.8 d5e5df6549 rebuild-wekan.bat: Windows menu parity for building, running and testing WeKan
Rewrite the Windows batch script with the same interactive menu as
rebuild-wekan.sh so building, running and testing WeKan works on Windows
amd64/arm64 too: dev-server options, "Run ALL tests" (import regression, Mocha,
Node E2E and Playwright Chromium/Firefox/WebKit with a pass/fail summary), the
individual test options and the floating-promises guard. WebKit runs natively on
Windows, so no Docker is needed there. CRLF line endings per .gitattributes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:46:42 +03:00
Lauri OjansivuandClaude Opus 4.8 dac356ed06 rebuild-wekan.sh: platform detection, Docker WebKit on Linux arm64, all browsers in ALL tests
Detect OS/arch (Linux amd64/arm64, macOS arm64). On Linux arm64, where the
bundled Playwright WebKit cannot launch natively, run the WebKit specs via the
official Playwright Docker image (mounting the repo, sharing the host network so
it reaches WeKan and Mongo); Linux amd64 and macOS arm64 keep running WebKit
natively. Override with WEKAN_WEBKIT_DOCKER=1/0. The "Run ALL tests" option now
runs Chromium, Firefox and WebKit (was Chromium only), each recorded separately
in the summary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:46:42 +03:00
Lauri OjansivuandClaude Opus 4.8 be44f9c311 Playwright: probe browsers and skip ones that cannot launch on the host
Before running, probe each browser project and skip any that cannot launch
(e.g. the bundled WebKit needs old system libraries — libicu 74, libxml2 v2,
libevent 2.1 — that newer Linux arm64 distros like Ubuntu 26.04 no longer ship).
This removes false WebKit failures locally while still running every browser on
CI, where a genuinely broken browser fails loudly instead of silently shrinking
coverage. Override with WEKAN_PLAYWRIGHT_PROBE=1 (force probe) or =0 (disable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:46:29 +03:00
Lauri OjansivuandClaude Opus 4.8 758f320969 Move/Copy/Convert card dialogs: bind swimlane and list select to live selection
Bind the swimlane and list <select> "selected" option in the move, copy,
copy-many and convert-checklist-item card dialogs to the live selection
(selectedSwimlaneId/selectedListId) instead of the last-confirmed dialog option,
so a Blaze reactive re-render (e.g. when board subscription data arrives) can no
longer silently revert the user's in-progress swimlane/list choice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:46:22 +03:00
Lauri OjansivuandClaude Opus 4.8 fe89a9e967 Shared templates: persist the scope checkboxes so the selection is remembered
The Admin Panel / People / Shared templates scope checkboxes are live view
filters (no Save button by design), but selectedScopes started empty on every
load, so a checked option was lost on reload — looking like it "wasn't saved".
Persist the selection in localStorage (restore on create, save on toggle), so it
sticks across reloads without needing a Save button. Added an e2e test asserting
a checked scope survives a reload. Verified: spec 26 passes 6/6 on chromium.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 03:29:02 +03:00
Lauri Ojansivu 5a5eb266ee Updated translations. 2026-06-16 03:03:41 +03:00
Lauri OjansivuandClaude Opus 4.8 0461864522 i18n: translate remaining English strings in fi.i18n.json to Finnish
Translates the 26 newly-added untranslated keys (popup titles incl. dependency/
shared-templates/admin/error popups, the bookmarks popup, and the global search
'My cards / All cards' view toggle) using the existing Finnish terminology
(Taulu, Lista, Uimarata, Tiimi, Organisaatio, Tuo/Vie, Käsittelijä, Tarrat,
profiilikuva, riippuvuus). The remaining identical-to-English entries are
intentionally untranslated proper nouns / abbreviations / format placeholders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 03:01:34 +03:00
Lauri OjansivuandClaude Opus 4.8 51e2b56d41 Global search: add "My cards / All cards" view toggle
Builds the view toggle that the previously-dead globalSearchViewChange handler
hinted at (it had been an incomplete copy of the due-cards toggle, with no
button/popup/state). Now a real feature:

- globalSearchHeaderBar gets a view-change button (My cards / All cards) and a
  globalSearchViewChangePopup, mirroring the due-cards pattern.
- Utils.globalSearchView()/setGlobalSearchView(): reactive (Session) + persisted
  (localStorage), default "all". Reactive so the search re-runs immediately on
  toggle (no page reload, unlike due cards).
- globalSearch re-runs reactively on the view + query; when "My cards", the
  current user's username is injected as a `user:` predicate, reusing the
  existing server selector ($or members/assignees) — no new server filtering.
- i18n (en) for the new strings + popup title.

Build-verified (meteor run compiles and starts). Logic reuses the already-tested
`user:` operator; not yet covered by an e2e spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 02:48:04 +03:00
Lauri OjansivuandClaude Opus 4.8 8062ffc39f Fix broken popup Popup.open calls; finalize popup titles; Red Strings docs ASCII art
- bookmarks: Popup.open('bookmarksPopup') resolved to a non-existent
  'bookmarksPopupPopup' template (open() appends "Popup"). Fixed both calls to
  Popup.open('bookmarks') so the bookmarksPopup template + bookmarksPopup-title
  ("Starred boards") apply.
- Removed two dead handlers that opened non-existent templates with no
  triggering button: 'click .js-import' -> boardImportBoard (boardHeader.js x2,
  sidebar.js) and 'click .js-due-cards-view-change' -> globalSearchViewChange
  (globalSearch.js; the globalSearchHeaderBar template has no such button).
- Set the error popup titles to "List width error" / "Swimlane height error".
- docs/Features/RedStrings: added ASCII-art step-by-step for drawing a line by
  dragging the connect handle, and for changing a line's relation type/color/icon.

Verified: spec 27 passes 9/9 on chromium.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 02:25:37 +03:00
Lauri OjansivuandClaude Opus 4.8 c04da4b9bd i18n: add popup titles for dependency popups and other untitled popups
Adds <popupName>Popup-title keys (en) for popups that had no title (WeKan shows
no header when the key is missing): the dependency icon picker, the on-board
dependency line editor, the dependency import popup, plus pre-existing untitled
popups (add/remove board org/team, change avatar, board backgrounds, card
assignee/member/stickers, invite people, list sort, list-width/swimlane-height
errors, restore archived card/list, rules import/export).

Skipped 3 Popup.open calls with no matching/clean template (boardImportBoard,
globalSearchViewChange — no template; bookmarksPopup — name mismatch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 02:16:32 +03:00
Lauri OjansivuandClaude Opus 4.8 9f36ef8789 Red Strings: fix "Can't select in removed DomRange" in dependency overlay
The overlay's recompute() ran this.find('.js-dependency-overlay') from deferred
requestAnimationFrame / setTimeout callbacks that could fire after the overlay
template was destroyed (toggling Show dependencies off, or a board re-render),
throwing "Can't select in removed DomRange". Guard recompute()/scheduleRecompute
with a destroyed flag (and the live view check), and clear the init setTimeout on
destroy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 02:16:32 +03:00
Lauri OjansivuandClaude Opus 4.8 6250e99813 i18n: add cardDependenciesPopup-title for the Add dependency popup
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 02:10:28 +03:00
Lauri Ojansivu d1e3bb6f8c Updated translations. 2026-06-16 02:01:59 +03:00
Lauri OjansivuandClaude Opus 4.8 0671dd11a3 Updated ChangeLog.
Add merged Dependabot PRs azure/setup-helm #6378 and dompurify #6379 to the Upcoming updates section, with links to the pull requests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:57:48 +03:00
Lauri OjansivuandClaude Opus 4.8 e81b308db6 Updated ChangeLog.
Revise Red Strings drag-to-connect to the handle (no-mode) approach with Part 1/2 links, and add merged PR #6377 (docker/setup-buildx-action) to the updates section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:55:30 +03:00
Lauri OjansivuandClaude Opus 4.8 cad7d77fce Red Strings: switch drag-to-connect to a per-card handle (no connect mode)
Replaces the modal "Connect" toggle with an always-available, non-modal UX so
cards stay clickable:
- Each minicard shows a small connect handle (right edge, on hover) when the
  dependency overlay is on and the user can edit the board. Drag it onto another
  card to create a dependency (dashed guide line follows the cursor).
- Connection lines are individually clickable (pointer-events:stroke) to open the
  edit/delete popup, while the overlay SVG stays click-through.
- Removed the board-header Connect toggle, the Session connect-mode state and the
  is-connecting overlay capture; drag is initiated from the handle (capture-phase
  mousedown so it beats the card drag-sort).

Updates the e2e test to drag the handle, docs to describe the no-mode UX, and
i18n (drag-to-connect, en/fi). Verified: spec 27 passes 9/9 on chromium.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:55:30 +03:00
Lauri OjansivuandClaude Opus 4.8 ebbc339a0c Updated ChangeLog.
Describe and link the Red Strings drag-to-connect mode + Miro/generic import (06bc92c).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:55:30 +03:00
Lauri OjansivuandClaude Opus 4.8 cc41308a3f Red Strings: drag-to-connect mode, on-board line editing, Miro import
Adds a piplanning.io / Kendis / Miro-style drag-to-connect UX:
- Board header "Connect" toggle (members with edit access) enters a transient
  connect mode (Session-based) and turns the overlay on.
- In connect mode the SVG overlay captures pointer events: drag from one card
  to another to create a dependency (a dashed guide line follows the cursor),
  and click a connection line to open a popup to change its relation
  type/color/icon or delete it.
- Out of connect mode the overlay stays non-interactive (cards clickable).

Import from other tools (best-effort): the Dependencies (JSON/SVG) importer now
also maps Miro REST API data (items + connectors -> lines, resolved to titles;
"block"/"fix" captions -> relation type). Kendis/piplanning.io have no public
file format — documented the generic { "lines": [...] } interchange they (and
GitHub/GitLab) can be converted to.

Also: shared DEPENDENCY_ICON_CHOICES in models/metadata/dependencies.js (used by
card detail + the new line editor), i18n (en/fi) for connect/done, CSS for
connect mode + clickable lines + dashed temp line, docs updated, and an e2e test
for drag-to-connect. Verified: spec 27 passes 9/9 on chromium.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:55:29 +03:00
Lauri Ojansivu 72bcdae363 Merge pull request #6379 from wekan/dependabot/npm_and_yarn/dompurify-3.4.9
Bump dompurify from 3.4.6 to 3.4.9
2026-06-16 01:55:20 +03:00
Lauri Ojansivu 0278322d9c Merge pull request #6378 from wekan/dependabot/github_actions/azure/setup-helm-5
Bump azure/setup-helm from 4 to 5
2026-06-16 01:55:05 +03:00
dependabot[bot] 0d617fd8f4 Bump dompurify from 3.4.6 to 3.4.9
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.6 to 3.4.9.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.6...3.4.9)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.9
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-15 20:53:58 +00:00
Lauri Ojansivu 5b1597e588 Merge pull request #6377 from wekan/dependabot/github_actions/docker/setup-buildx-action-4
Bump docker/setup-buildx-action from 3 to 4
2026-06-15 23:52:08 +03:00
dependabot[bot] 618b6f8135 Bump azure/setup-helm from 4 to 5
Bumps [azure/setup-helm](https://github.com/azure/setup-helm) from 4 to 5.
- [Release notes](https://github.com/azure/setup-helm/releases)
- [Changelog](https://github.com/Azure/setup-helm/blob/main/CHANGELOG.md)
- [Commits](https://github.com/azure/setup-helm/compare/v4...v5)

---
updated-dependencies:
- dependency-name: azure/setup-helm
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-15 20:32:47 +00:00
dependabot[bot] 2af7df299c Bump docker/setup-buildx-action from 3 to 4
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-15 20:32:42 +00:00
Lauri OjansivuandClaude Opus 4.8 af2e7fdd05 Updated ChangeLog.
Link the Red Strings test-suite commits as Part 1/2/3 on the 'Covered by tests' clause, including the previously-unreferenced openapi test hardening (415284748).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:36:25 +03:00
Lauri OjansivuandClaude Opus 4.8 a7bc1e14e9 Updated ChangeLog.
Link and describe the Red Strings client-403 fix (1db64f4) and icon-picker fix (e8a8007) in the Upcoming Red Strings entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:34:03 +03:00
Lauri OjansivuandClaude Opus 4.8 e8a8007814 Red Strings: fix dependency icon picker + e2e test for editing a dependency
The icon picker popup read its own data context (the dependency row, which has
no setDependencyProps) instead of the source card, so picking an icon did
nothing. Capture the source card when the picker opens and use it in the popup.

Adds an e2e test that edits an existing dependency's relation type, color and
icon from the card detail and asserts each persists — this is the path that
previously threw the client 403 (selector/positional-$ update) and the icon
no-op, now both fixed. Verified locally: spec 27 passes 8/8 on chromium.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:29:47 +03:00
Lauri OjansivuandClaude Opus 4.8 1db64f4dde Red Strings: fix 403 when editing an existing dependency from the client
addDependency (updating an existing link), setDependencyProps and
removeDependency used updateAsync with a selector + positional `$` operator.
On the client that is rejected by collection2 ("Untrusted code may only
updateAsync documents by ID" / 403), so changing a dependency's relation type,
color or icon (or removing it) threw a ValidationError.

Rewrite all three to read-modify-write the cardDependencies array and $set it
by _id only, which is permitted from untrusted client code. The server REST
endpoints keep the positional update (server code is trusted).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:23:25 +03:00
Lauri OjansivuandClaude Opus 4.8 f6e9401a68 Updated ChangeLog.
Add the merged form-data (#6375) and launch-editor (#6376) Dependabot updates to the Upcoming updates section, with links to the pull requests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:05:00 +03:00
Lauri Ojansivu efded26791 Merge pull request #6376 from wekan/dependabot/npm_and_yarn/launch-editor-2.14.1
Bump launch-editor from 2.13.2 to 2.14.1
2026-06-15 22:03:18 +03:00
Lauri Ojansivu dec1ed49a1 Merge pull request #6375 from wekan/dependabot/npm_and_yarn/form-data-2.5.6
Bump form-data from 2.5.5 to 2.5.6
2026-06-15 22:03:01 +03:00
Lauri OjansivuandClaude Opus 4.8 98cee205d8 Updated ChangeLog.
Add commit links to the CI Playwright/mongosh and Finnish-translation entries in the Upcoming section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:54:40 +03:00
Lauri OjansivuandClaude Opus 4.8 a3d4848c88 CI: install mongosh for e2e jobs; e2e: dismiss dev overlay in toggle test
- playwright.yml: install mongosh in the Playwright e2e jobs and the Puppeteer
  regression job. The e2e DB helpers (tests/playwright/helpers/db.js and
  tests/e2e/list-regressions.js) shell out to `mongosh`, which is not
  preinstalled on the runner — the regression job was failing with
  "spawnSync mongosh ENOENT".
- 27-red-strings spec: remove the rspack dev-server error-overlay iframe before
  clicking the board-header dependency toggle, so the click is not swallowed by
  the dev overlay (the button does not exist in production builds).
- CHANGELOG: note the Playwright Firefox/WebKit + mongosh CI fixes and the
  Finnish translation update in the Upcoming section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:49:07 +03:00
dependabot[bot] 3adf1ae0c7 Bump launch-editor from 2.13.2 to 2.14.1
Bumps [launch-editor](https://github.com/vitejs/launch-editor) from 2.13.2 to 2.14.1.
- [Commits](https://github.com/vitejs/launch-editor/compare/v2.13.2...v2.14.1)

---
updated-dependencies:
- dependency-name: launch-editor
  dependency-version: 2.14.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-15 18:20:07 +00:00
dependabot[bot] 4dfdcdb124 Bump form-data from 2.5.5 to 2.5.6
Bumps [form-data](https://github.com/form-data/form-data) from 2.5.5 to 2.5.6.
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v2.5.5...v2.5.6)

---
updated-dependencies:
- dependency-name: form-data
  dependency-version: 2.5.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-15 18:19:52 +00:00
Lauri Ojansivu f56488eca1 Merge pull request #6374 from wekan/dependabot/npm_and_yarn/tmp-0.2.7
Bump tmp from 0.2.6 to 0.2.7
2026-06-15 21:16:43 +03:00
Lauri Ojansivu 4152847483 Updates 2026-06-15 21:15:50 +03:00
Lauri OjansivuandClaude Opus 4.8 351da69138 CI: fix Playwright Firefox job and enable WebKit project
The e2e matrix ran `playwright test --project=firefox`, but playwright.config.js
only defines the firefox/webkit projects when WEKAN_PLAYWRIGHT_ALL=1, which the
workflow never set — so the Firefox job failed with "Project(s) firefox not
found. Available projects: chromium".

- playwright.yml: set WEKAN_PLAYWRIGHT_ALL=1 on the test run step so --project
  can select firefox/webkit, and add webkit to the matrix (Playwright's bundled
  WebKit runs headless on the Linux runner; the old "requires macOS" note was
  wrong). Each job still installs only its own browser via --with-deps.
- package.json: set WEKAN_PLAYWRIGHT_ALL=1 in test:playwright:all so the same
  command works locally (it had the same latent failure).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:06:14 +03:00
Lauri Ojansivu 0d14b8c6ce Updated translations. 2026-06-15 21:00:10 +03:00
Lauri OjansivuandClaude Opus 4.8 599687f507 i18n: translate remaining English strings in fi.i18n.json to Finnish
Translates the 25 untranslated keys (Shared Templates, card dependencies / Red
Strings, dependency import/export, and the empty-board import error) using the
existing Finnish terminology (Taulu, Malli/Mallit, Uimarata, Tiimi, Organisaatio,
Suodata, ikoni, Tuo/Vie). Placeholders (__imported__, __unmatched__) preserved.

The remaining identical-to-English entries are intentionally untranslated proper
nouns / abbreviations / format placeholders (Gantt, LDAP, OAuth2, CAS, OK, S3,
GridFS, operator abbrevs, (L)/(N)/(M), __board__/__card__).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:57:54 +03:00
Lauri OjansivuandClaude Opus 4.8 e82d4bb338 Docs: mark Collapsible Swimlanes (#2804) as implemented in Templates.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:38:09 +03:00
Lauri OjansivuandClaude Opus 4.8 492bc71c3c Docs: merge Shared Templates into Templates.md and refresh template status
- Move the Shared Templates (Admin view) content into docs/Features/Templates.md
  as a section and delete the separate SharedTemplates/ page; repoint the
  Features index, docs/README.md and the ChangeLog to the in-page anchor.
- Templates.md: relabel the working features as "Implemented and working"
  (per-user Card/List/Board templates + the admin Shared Templates view), and
  correct the roadmap list which still listed Shared templates (#2209) as a
  pending funded item although it is now implemented.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:35:31 +03:00
Lauri OjansivuandClaude Opus 4.8 00046b893b Docs: add Shared Templates feature page; tidy Red Strings ChangeLog entry
- docs/Features/SharedTemplates/SharedTemplates.md: documents the admin
  "Shared templates" view (Admin Panel → People), grouping by Org/Team/Domain,
  the adminSharedTemplates method and the e2e coverage. Linked from the Features
  index, docs/README.md and Templates.md; ChangeLog entry links to it.
- CHANGELOG: reordered the Red Strings "Upcoming" entry so the feature
  description, REST API, import/export and preservation/tests read in order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:31:20 +03:00
Lauri OjansivuandClaude Opus 4.8 d2e928bb11 Red Strings: add move-cleanup and Jira issue-link mapping tests
- server/lib/tests/cards.dependencies.move.tests.js: stub-based unit tests of
  the card.move() helper — a cross-board move clears the moved card's
  cardDependencies and issues a multi-update $pull to remove inbound references
  on the old board; a same-board move leaves dependencies untouched.
- server/lib/tests/jiraCreator.dependencies.tests.js: JiraCreator.createDependencies
  maps Jira issuelinks to typed dependencies (outward/inward "Blocks" ->
  blocks/is-blocked-by, others -> related-to), normalizes color/icon, and skips
  self-links, unknown targets and link-less issues.
- Registered both in server/lib/tests/index.js; CHANGELOG note updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:22:57 +03:00
Lauri OjansivuandClaude Opus 4.8 536fc4913c Red Strings: add tests for schema validation, REST/OpenAPI, filter, copy/import
Turns the flagged manual checks into automated tests and covers the newest
dependency ("Red Strings") features that lacked tests.

Mocha (server/lib/tests, registered in index.js):
- dependencies.metadata.tests.js: normalizeDependency (legacy string upgrade,
  defaults, invalid type), direction meta (blocks/fixes forward,
  is-blocked-by/is-fixed-by reversed, related-to undirected), inverse map.
- dependencies.openapi.tests.js: every WebApp.handlers dependency route has
  @operation/@tag Dependencies/@summary, no undocumented route, unique ids
  (guards "OpenAPI picks up new routes" without running the python generator).

Mocha (client/lib/tests):
- filter.dependencies.tests.js: Filter.cardDependencies toggles and builds a
  cardDependencies.type mongo selector.

Playwright e2e:
- 28-dependencies-rest.e2e.js: REST CRUD with type/color/icon, board-level
  list, update-in-place, validation (bad type, self-link), auth — exercises the
  cardDependencies SimpleSchema via real $push and positional ($) updates on the
  live server (the schema manual-check).
- 27-red-strings.e2e.js: added minicard badge, copyCard same-board preservation
  (type/color), and importBoardDependencies card matching by number and title.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:08:42 +03:00
Lauri OjansivuandClaude Opus 4.8 2a0d9cbf09 Red Strings: typed relations, color/icon, badge, filter, REST API, import/export, docs
Greatly expands the card dependency "Red Strings" feature (#3392).

Data model
- cardDependencies entries are now objects { cardId, type, color, icon }
  (models/metadata/dependencies.js defines the 5 relation types, defaults and a
  normalizer that upgrades legacy bare-string ids on read).

Relations + visualization
- Relation types: related-to, blocks, is-blocked-by, fixes, is-fixed-by. The
  type sets the SVG arrow direction (related-to is undirected); per-line color
  and per-color arrowhead markers; arrows drawn prerequisite-right -> dependent-left.
- Minicard badge (icon + color + count) for cards with dependencies.
- Board Filter sidebar: filter cards by dependency relation type.

Card detail UI
- Each dependency row has a relation-type dropdown, color picker, icon picker
  and remove; the add popup picks type + color then searches a card by title.

REST API + tooling
- server/models/dependencies.js: list/add/edit/remove endpoints (tag
  Dependencies) with OpenAPI annotations + board-level list endpoint.
- api.py: list/add/edit/remove dependency commands.

Import / export
- Board Settings -> Export: Dependencies as JSON and as round-trippable SVG
  (client/lib/exportDependencies.js).
- All Boards -> New -> Import -> Dependencies (JSON/SVG) into a chosen board,
  matching cards by id/number/title (importBoardDependencies method +
  client/lib/importDependencies.js).
- jiraCreator maps Jira issuelinks best-effort to dependency relations.

Preservation
- type/color/icon preserved through card/board copy and WeKan import/migrate;
  cross-board card move drops now-dangling dependencies.

Docs/tests/i18n
- docs/Features/RedStrings/RedStrings.md (+ Features index and docs/README links).
- i18n keys; CSS for the rows/badge/icon picker; expanded e2e spec 27.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:49:00 +03:00
dependabot[bot] 74d15218d6 Bump tmp from 0.2.6 to 0.2.7
Bumps [tmp](https://github.com/raszi/node-tmp) from 0.2.6 to 0.2.7.
- [Changelog](https://github.com/raszi/node-tmp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/raszi/node-tmp/compare/v0.2.6...v0.2.7)

---
updated-dependencies:
- dependency-name: tmp
  dependency-version: 0.2.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-15 16:39:38 +00:00
Lauri OjansivuandClaude Opus 4.8 f4d620e867 Red Strings: preserve card dependencies across copy/import + add e2e tests
Card-to-card dependencies (#3392 "Red Strings") were stored on cards and the
showDependencies toggle on the board, but were lost on board copy and on WeKan
export/import/migrate, so copied/imported boards drew no dependency lines.

- models/cards.js copy(): a single-card copy keeps only dependencies whose
  target card exists on the destination board (same-board copies keep links,
  cross-board copies drop dangling ones); when part of a whole-board/swimlane
  copy it records old->new card ids in a shared cardIdMap for the caller.
- models/swimlanes.js, models/lists.js copy(): thread the cardIdMap through to
  card.copy().
- models/boards.js copy(): collect the cardIdMap and remap every copied card's
  cardDependencies to the new card ids (dropping uncopied targets).
- models/wekanCreator.js: carry over the board showDependencies flag, and add a
  createCardDependencies() pass that remaps imported cardDependencies to the new
  card ids (used by JSON import, board clone and POST /api/boards/import).
- tests: e2e spec 27-red-strings (overlay drawn, header toggle persists to the
  board, add/remove dependency from card detail persists) + db helpers
  (findCardIdByTitle, setCardDependencies, setBoardShowDependencies).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:56:54 +03:00
Lauri OjansivuandClaude Opus 4.8 c2a58312ad Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:39:48 +03:00
Lauri OjansivuandClaude Opus 4.8 fc9e8674d8 Added Admin Panel "Shared templates" view grouped by Org/Team/Domain
Adds an admin-only "Shared templates" tab to Admin Panel -> People that
lists users' shareable template boards, grouped by Organization, Team or
email Domain. The three scope checkboxes are live view filters (default
unchecked); only users whose Templates board is non-empty are listed.

- server: adminSharedTemplates() method enumerates each user's linked
  template boards (cardType-linkedBoard cards in their Board Templates
  swimlane) and returns them with the user's orgs/teams/email domains.
- client: templatesGeneral template + grouping logic and i18n strings.
- tests: e2e spec 26-shared-templates and db seed helpers
  (setUserGroups, seedTemplatesBoard) with template-board cleanup.

Fixes #3313.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:39:28 +03:00
Lauri OjansivuandClaude Opus 4.8 e3ff87652e Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:57:43 +03:00
Lauri OjansivuandClaude Opus 4.8 5eeedf8744 Importer: validate swimlane/card colors against allowed values
Mirror the board-color hardening: when importing a board, only set a card or
swimlane color if it is a recognized CARD_COLORS / SWIMLANE_COLORS value, so an
out-of-range color from a foreign or old export cannot fail collection2 validation
and abort the import. (Lists do not import a color.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:56:11 +03:00
Lauri OjansivuandClaude Opus 4.8 29631f6f4b Board create/delete REST handlers: report the real error status, not 200
The POST /api/boards and DELETE /api/boards/:boardId handlers caught errors and
returned { code: 200, data: error }, masking failures (e.g. an unauthorized delete)
as success. Return the real status code (error.statusCode/code or 500) and a JSON
error body instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:54:44 +03:00
Lauri OjansivuandClaude Opus 4.8 309425545f Return proper status + body from board export error responses
The export endpoints passed a bare number to sendJsonResult (e.g. sendJsonResult(
res, 403)), but sendJsonResult treats its second arg as an options object, so a
number produced HTTP 200 with an empty body instead of the intended 4xx. Pass
{ code, data } so 404/400/403/auth failures return the correct status and a JSON
error body.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:53:36 +03:00
Lauri OjansivuandClaude Opus 4.8 82192d0470 DataCache: re-check hasDependents() before the 60s teardown stops a still-used entry
The teardown timeout stopped the computation and deleted the cached value without
re-checking whether a dependent re-attached during the timeout window, which could
delete a value still in use and surface as a transient undefined (a contributor to
the board-not-found flicker). Re-check hasDependents() in the timeout callback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:52:40 +03:00
Lauri OjansivuandClaude Opus 4.8 76b88632c6 Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:51:40 +03:00
Lauri OjansivuandClaude Opus 4.8 d241f9d8f6 Fix Member Settings 'Change Avatar' label styling (mis-nested inside the icon)
In the member menu popup the 'Change Avatar' label was indented one level too
deep, making it a child of the <i class="fa fa-picture-o"> icon element instead
of a sibling like every other menu entry. It therefore inherited the FontAwesome
icon font styling and rendered differently (uppercase-looking, off style). Dedent
the label so it sits directly under the <a>, matching the other items.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:50:59 +03:00
Lauri OjansivuandClaude Opus 4.8 7a994b50ec Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:49:14 +03:00
Lauri OjansivuandClaude Opus 4.8 b909f82f89 Add card dependency 'Red Strings' connection lines / PI program board (#3392)
Visualize card-to-card dependencies as red connection lines drawn on top of the
board (SAFe PI-planning program board). Cards gain a 'cardDependencies' array
(target card ids on the same board), edited from a new Dependencies section in
the card detail (pick/remove other board cards). A board header toggle
(showDependencies board setting) renders an SVG overlay over the board canvas
that draws a red, arrow-headed curve from each card to each of its dependencies,
positioned from the live minicard rects and recomputed on board data changes,
canvas scroll and window resize. The overlay is pointer-events:none so cards stay
interactive. Fixes #3392.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:49:14 +03:00
github-actions[bot] 1905d5d68e Bump versions for v9.46 2026-06-15 12:02:59 +00:00
Lauri OjansivuandClaude Opus 4.8 728c799d8e Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:57:21 +03:00
Lauri OjansivuandClaude Opus 4.8 17bb7c1264 Fix OpenAPI generator crash on template-literal route paths (release-all bump job)
releases/rebuild-docs.sh runs openapi/generate_openapi.py, which crashed with
"AttributeError: 'NoneType' object has no attribute 'rstrip'" because a route
path argument that is a template literal (e.g. WebApp.handlers.get(
`/api/boards/:boardId/export/${format}`, ...) in models/export.js) has no
Literal .value. Resolve template-literal paths (turning ${identifier} into a
:identifier path parameter) and skip any route whose path cannot be resolved
statically instead of crashing the whole release. The export/:format route now
documents as /api/boards/{board}/export/{format}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:56:55 +03:00
Lauri OjansivuandClaude Opus 4.8 415ccff09a Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:51:29 +03:00
Lauri OjansivuandClaude Opus 4.8 92b0f96387 Fix GFM strikethrough (~~text~~) not rendering in card descriptions (#6008)
markdown-it renders ~~text~~ to <s>...</s>, but the DOMPurify ALLOWED_TAGS list
did not include s/del/strike, so the sanitizer stripped the tag (keeping the bare
text), losing the strikethrough. Add s, del and strike to both DOMPurify configs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:51:03 +03:00
Lauri OjansivuandClaude Opus 4.8 9578ee74fd Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:20:58 +03:00
Lauri OjansivuandClaude Opus 4.8 7fb9c53a43 Updated ChangeLog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:02:09 +03:00
Lauri OjansivuandClaude Opus 4.8 e6f1008840 Updated translations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:55:18 +03:00
Lauri OjansivuandClaude Opus 4.8 073842b3c5 Render board/card automation buttons reliably; warn on empty WeKan import
Board/card 'button' rules now denormalise their button type/label onto the
schema-backed rules document (via a board-admin-gated rules.createRule method)
and the header/card read them from there, because the schemaless triggers
collection's documents do not reach the client over the board subscription;
the boardRules publication and the rules-list editor read from it too. Also
warn (instead of silently creating an empty board) when a pasted WeKan export
has no swimlanes/lists/cards (a broken older export).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:55:11 +03:00
Lauri OjansivuandClaude Opus 4.8 b181889a56 Fix ProxyBleed (GHSA-jggc-qvfc-jr6x): header-login X-Forwarded-For allowlist bypass
Derive the header-login source IP from the real TCP socket peer instead of the
spoofable X-Forwarded-For header, fail closed when the trusted-IP allowlist is
unset, and add opt-in multi-proxy support via HEADER_LOGIN_TRUSTED_PROXIES
(honor XFF only from a trusted proxy peer, taking the right-most untrusted hop).
Propagate the new env vars across docker-compose, start-wekan.sh/.bat, Dockerfile,
.devcontainer, snap config and the virtualbox script; add docs and regression tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:54:59 +03:00
Lauri Ojansivu bbe83d805e More. Part 3.
Thanks to xet7 and Claude !
2026-06-15 04:09:38 +03:00
Lauri Ojansivu d00c7be3ee Updated ChangeLog. 2026-06-15 04:09:11 +03:00
Lauri Ojansivu f933f62df7 More Import Export and Rules features. Part 2.
Thanks to xet7 and Claude !
2026-06-15 04:08:44 +03:00
Lauri Ojansivu f0bce67a3b More Import Export and Rules features.
Thanks to xet7 and Claude !
2026-06-15 04:07:33 +03:00
Lauri Ojansivu 63219cfc8c Updated translations. 2026-06-15 00:38:52 +03:00
Lauri Ojansivu 96caba1837 Updated translations. 2026-06-14 20:02:43 +03:00
Lauri Ojansivu d7b0423bda Updated ChangeLog. 2026-06-14 19:55:22 +03:00
Lauri Ojansivu 9c39226a59 Added an accessibility end-to-end test suite
that checks the page language, skip link, landmark roles, visible focus, dialog
roles, accessible names on icon controls, and the absence of duplicate element ids.

Thanks to xet7 and Claude !
2026-06-14 19:53:08 +03:00
Lauri Ojansivu 83a0dd2565 - Reorganized and updated the documentation:
- Fixed 14 broken relative links in `docs/README.md` (verified with a link check).
  - Split the monolithic `docs/Features/Features.md` into focused topic pages under
    `docs/Features/` subdirectories: Boards, Lists, Cards, Members, Keyboard
    Shortcuts, Admin Panel, WIP Limits, Cleanup and Stats; `Features.md` is now an
    index linking to them, and `docs/README.md` links the new pages.
  - Added documentation for previously-undocumented features: Right-to-Left (RTL)
    UI, Stickers, Card Locations, Board Background Images, Attachments and File
    Storage, the card "complete" checkbox, and a new Accessibility feature page
    (covering both the Admin Panel / Settings / Accessibility info page and the
    built-in accessibility features above).
  - Updated outdated information based on the CHANGELOG: the Meteor/Node versions
    (now Meteor 3.x / Node.js 24.x) and the obsolete feature wishlist (Custom
    Fields, Subtasks, Swimlanes, Gantt, WIP limits, voting and templates are now
    implemented).

Thanks to xet7 and Claude !
2026-06-14 19:51:27 +03:00
Lauri Ojansivu 255de2062f - Improved accessibility across all pages following WCAG 2.1 AA guidelines:
- Added a visible keyboard focus indicator (`:focus-visible` outline) for links,
    buttons and form controls. The global CSS reset previously stripped all focus
    outlines, leaving keyboard users with no indication of focus (WCAG 2.4.7).
  - Added a "Skip to main content" link that lets keyboard and screen-reader users
    bypass the header chrome (WCAG 2.4.1 Bypass Blocks).
  - Added landmark roles so assistive technology can navigate page regions:
    `role="main"` on the content area, `role="navigation"` on the header quick-access
    bar, and `role="search"` on the global search form (WCAG 1.3.1).
  - Marked modals and popups as `role="dialog"` with accessible names, set
    `aria-modal="true"` on modals, and moved keyboard focus into a modal when it
    opens and back to the triggering element when it closes (WCAG 2.4.3 Focus Order).
  - Added accessible names (`aria-label`) to icon-only controls that previously had
    none: modal/popup close and back buttons, the announcement close button, the
    sidebar close/back buttons and the global search input and clear button
    (WCAG 4.1.2 Name, Role, Value).
  - Marked decorative Font Awesome icons inside labelled controls as
    `aria-hidden="true"` so they are not announced redundantly.
  - Added a screen-reader-only `.sr-only` helper class.

Thanks to xet7 and Claude !
2026-06-14 19:49:00 +03:00
Lauri Ojansivu c8c0438d66 - Fixed duplicate id="header" attributes rendered inside loops on the My Cards page,
which produced invalid HTML and broke assistive-technology navigation (WCAG 4.1.1).
- Fixed the My Cards table markup: header cells (`<th>`) are now wrapped in a `<tr>`
  with `scope="col"`, and a caption was added, so the table is announced correctly.
- Added missing `alt` text to the user avatar image (the surrounding link already
  carries the accessible name).

Thanks to xet7 and Claude !
2026-06-14 19:46:52 +03:00
Lauri Ojansivu 25e6619ba0 Updated docs.
Thanks to xet7 and Claude !
2026-06-14 14:35:05 +03:00
Lauri Ojansivu 28657beaf1 Updated ChangeLog. 2026-06-14 14:35:05 +03:00
github-actions[bot] 4ebc9b1612 Bump versions for v9.45 2026-06-14 06:46:48 +00:00
Lauri Ojansivu cb80bbfab1 Updated ChangeLog. 2026-06-14 09:40:14 +03:00
Lauri Ojansivu 737fce2ac0 - Made the "Release All Platforms" snap build resilient to transient Launchpad
failures. `snapcraft remote-build` intermittently dies with a transient TLS
  drop (`SSLEOFError: EOF occurred in violation of protocol`, exit 70) while
  talking to Launchpad after pushing the repo — unrelated to the build itself —
  which failed the whole release. The step now retries the remote build up to
  three times before giving up.

Thanks to xet7 and Claude.
2026-06-14 09:40:14 +03:00
736 changed files with 69377 additions and 16177 deletions

No files matched your search

+2
View File
@@ -126,6 +126,8 @@ ENV \
HEADER_LOGIN_FIRSTNAME="" \
HEADER_LOGIN_LASTNAME="" \
HEADER_LOGIN_EMAIL="" \
HEADER_LOGIN_TRUSTED_IPS="" \
HEADER_LOGIN_TRUSTED_PROXIES="" \
LOGOUT_WITH_TIMER=false \
LOGOUT_IN="" \
LOGOUT_ON_HOURS="" \
+37
View File
@@ -4,3 +4,40 @@ updates:
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
ignore:
# Build-time-only (devDependency) Rspack toolchain. These packages compile
# the bundle on the developer / CI machine and are NOT shipped in the
# deployed WeKan server or client bundle, so their advisories do not affect
# a running WeKan instance. (For example webpack-dev-server / @rspack/dev-
# server, pulled via @rspack/cli, and the socket.io/ws chain pulled via
# @rsdoctor/rspack-plugin, never run in WeKan — it builds with `meteor run`,
# not the Rspack CLI or dev-server.)
#
# They also cannot simply be removed or bumped:
# * @meteorjs/rspack re-pins and re-installs @rspack/cli and
# @rsdoctor/rspack-plugin into package.json on every build, so deleting
# them does not stick.
# * The only upgrades Dependabot/npm offer are build-breaking: a
# @meteorjs/rspack downgrade, or @rspack/cli 2.0 / Rspack 2.0, which
# needs Meteor 3.6 (see https://github.com/meteor/meteor/pull/14360).
# WeKan currently runs METEOR@3.5-rc.2.
#
# So pin them here until Meteor 3.6 + Rspack 2.0 land, then revisit.
- dependency-name: "@meteorjs/rspack"
- dependency-name: "@rspack/core"
- dependency-name: "@rspack/cli"
- dependency-name: "@rsdoctor/rspack-plugin"
- dependency-name: "css-loader"
- dependency-name: "style-loader"
# Playwright end-to-end test harness — pure test-only tooling that never ships in
# WeKan. It now depends only on @playwright/test and the official `mongodb` driver
# (the heavy, CVE-prone `mongosh` CLI was replaced by helpers/mongo-runner.js).
# Watch this directory so those two stay patched.
- package-ecosystem: "npm"
directory: "/tests/playwright"
schedule:
interval: "weekly"
+1 -1
View File
@@ -9,6 +9,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 'Checkout Repository'
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: 'Dependency Review'
uses: actions/dependency-review-action@v5
+4 -4
View File
@@ -32,13 +32,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -48,14 +48,14 @@ jobs:
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
+1 -1
View File
@@ -15,6 +15,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Build the Docker image
run: docker build . --file Dockerfile --tag wekan:$(date +%s)
+74 -31
View File
@@ -18,7 +18,7 @@ jobs:
name: Meteor unit tests
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup Node 24
uses: actions/setup-node@v6
@@ -27,14 +27,14 @@ jobs:
cache: 'npm'
- name: Cache Meteor installation
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: ~/.meteor
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
restore-keys: meteor-
- name: Cache Meteor build (.meteor/local)
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: .meteor/local
key: meteor-local-unit-${{ hashFiles('.meteor/versions') }}
@@ -62,14 +62,21 @@ jobs:
run: meteor test --once --driver-package meteortesting:mocha
e2e-tests:
name: Playwright E2E (${{ matrix.project }})
name: Playwright E2E (${{ matrix.project }} ${{ matrix.shard }}/2)
runs-on: ubuntu-24.04
needs: unit-tests
strategy:
fail-fast: false
matrix:
project: [chromium, firefox]
# webkit excluded from CI (requires macOS runner) — run locally with npm run test:e2e:all
# Playwright bundles Chromium, Firefox and WebKit builds that all run
# headless on the Linux runner (installed per-job below with --with-deps).
project: [chromium, firefox, webkit]
# Split each browser's ~245 specs across 2 parallel shards (6 jobs total)
# to cut wall-clock time. Each shard job starts its own WeKan + Mongo, so
# there is no shared-server contention between shards. Raise the shard
# count (here and in the --shard flag below) once the baseline is green
# and/or once E2E runs against a prebuilt bundle (cheaper per-job startup).
shard: [1, 2]
services:
mongodb:
@@ -82,7 +89,7 @@ jobs:
--health-retries 5
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup Node 24
uses: actions/setup-node@v6
@@ -91,14 +98,14 @@ jobs:
cache: 'npm'
- name: Cache Meteor installation
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: ~/.meteor
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
restore-keys: meteor-
- name: Cache Meteor build (.meteor/local)
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: .meteor/local
key: meteor-local-e2e-${{ hashFiles('.meteor/versions') }}
@@ -122,45 +129,68 @@ jobs:
run: npx playwright install --with-deps ${{ matrix.project }}
working-directory: tests/playwright
- name: Start WeKan in background
- name: Build and start WeKan (production bundle)
run: |
# Run E2E against a PRODUCTION build instead of `meteor run` (dev mode).
# In dev the client JS is served by the rspack dev server, which did not
# serve the bundle reliably in headless CI: the browser received HTML for
# the bundle ("Unexpected token '<'"), Meteor never initialised, and every
# spec timed out. A production build bakes the client JS into the bundle,
# served statically by the Meteor server at :3000.
meteor build ./output --directory
( cd output/bundle/programs/server && npm install )
# WeKan's server/00checkStartup.js refuses to start unless WRITABLE_PATH
# (used for attachment/file storage) is set and writable.
mkdir -p "$PWD/wekan-writable"
# Match the env the dev runner (rebuild-wekan.sh) and the specs expect:
# WITH_API enables the REST API (specs 17/23/24/28), comment-editor
# flag matches dev.
MONGO_URL=mongodb://localhost:27017/wekan_test \
ROOT_URL=http://localhost:3000 \
PORT=3000 \
meteor run --settings settings.json > wekan-run.log 2>&1 &
# The first Meteor build is slow, so wait up to 20 minutes for the
# server to start responding before giving up.
for i in $(seq 1 120); do
WRITABLE_PATH="$PWD/wekan-writable" \
WITH_API=true \
RICHER_CARD_COMMENT_EDITOR=false \
node output/bundle/main.js > wekan-run.log 2>&1 &
# The production server serves the client JS statically, so once :3000
# responds the bundle is being served. Wait up to 10 minutes.
for i in $(seq 1 60); do
if curl -sf http://localhost:3000 > /dev/null; then
echo "WeKan is up after $((i * 10))s"
break
fi
echo "Waiting for WeKan... attempt $i/120"
echo "Waiting for WeKan... attempt $i/60"
sleep 10
done
if ! curl -sf http://localhost:3000 > /dev/null; then
echo "WeKan failed to start. Last 200 log lines:"
tail -n 200 wekan-run.log
echo "WeKan failed to start. Last 250 log lines:"
tail -n 250 wekan-run.log
exit 1
fi
env:
DISABLE_CAPTCHA: 'true'
TOOL_NODE_FLAGS: --max-old-space-size=8192
NODE_OPTIONS: --max-old-space-size=8192
DISABLE_CAPTCHA: 'true'
- name: Run Playwright tests (${{ matrix.project }})
- name: Run Playwright tests (${{ matrix.project }} shard ${{ matrix.shard }}/2)
run: |
npx playwright test \
--project=${{ matrix.project }} \
--shard=${{ matrix.shard }}/2 \
--reporter=github
working-directory: tests/playwright
env:
WEKAN_BASE_URL: http://localhost:3000
WEKAN_MONGO_URL: mongodb://localhost:27017/wekan_test
# Make the firefox and webkit projects available to --project
# (playwright.config.js only defines them when this is set).
WEKAN_PLAYWRIGHT_ALL: '1'
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v7
with:
name: playwright-report-${{ matrix.project }}
name: playwright-report-${{ matrix.project }}-shard${{ matrix.shard }}
path: tests/playwright/playwright-report/
retention-days: 14
@@ -168,7 +198,7 @@ jobs:
if: failure()
uses: actions/upload-artifact@v7
with:
name: test-results-${{ matrix.project }}
name: test-results-${{ matrix.project }}-shard${{ matrix.shard }}
path: tests/playwright/test-results/
retention-days: 7
@@ -188,7 +218,7 @@ jobs:
--health-retries 5
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup Node 24
uses: actions/setup-node@v6
@@ -197,14 +227,14 @@ jobs:
cache: 'npm'
- name: Cache Meteor installation
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: ~/.meteor
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
restore-keys: meteor-
- name: Cache Meteor build (.meteor/local)
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: .meteor/local
key: meteor-local-regression-${{ hashFiles('.meteor/versions') }}
@@ -223,27 +253,40 @@ jobs:
- name: Install Chromium
run: sudo apt-get install -y chromium-browser
- name: Start WeKan in background
- name: Build and start WeKan (production bundle)
run: |
# Run a PRODUCTION build, not `meteor run` (dev): exactly like the
# Playwright e2e job, because the rspack dev server does not serve the
# client JS bundle in headless CI, so Meteor never initialises and the
# puppeteer regression test times out in waitForMeteorGlobals(). The
# production bundle serves the client statically. WRITABLE_PATH is
# required by server/00checkStartup.js; WITH_API mirrors the dev runner.
meteor build ./output --directory
( cd output/bundle/programs/server && npm install )
mkdir -p "$PWD/wekan-writable"
MONGO_URL=mongodb://localhost:27017/wekan_regression \
ROOT_URL=http://localhost:3000 \
PORT=3000 \
meteor run > wekan-run.log 2>&1 &
# The first Meteor build is slow, so wait up to 20 minutes.
for i in $(seq 1 120); do
WRITABLE_PATH="$PWD/wekan-writable" \
WITH_API=true \
RICHER_CARD_COMMENT_EDITOR=false \
node output/bundle/main.js > wekan-run.log 2>&1 &
for i in $(seq 1 60); do
if curl -sf http://localhost:3000 > /dev/null; then
echo "WeKan is up after $((i * 10))s"
break
fi
echo "Waiting for WeKan... attempt $i/120"
echo "Waiting for WeKan... attempt $i/60"
sleep 10
done
if ! curl -sf http://localhost:3000 > /dev/null; then
echo "WeKan failed to start. Last 200 log lines:"
tail -n 200 wekan-run.log
echo "WeKan failed to start. Last 250 log lines:"
tail -n 250 wekan-run.log
exit 1
fi
env:
TOOL_NODE_FLAGS: --max-old-space-size=8192
NODE_OPTIONS: --max-old-space-size=8192
DISABLE_CAPTCHA: 'true'
- name: Run list-regressions e2e
+319 -56
View File
@@ -7,9 +7,13 @@ name: Release All Platforms
# Or use the convenience script (bumps versions, commits, pushes, then triggers):
# ./releases/release-all.sh 9.35 9.36
#
# Builds only the platforms MongoDB ships server binaries for (amd64, arm64,
# win64, mac); MongoDB 7.x is the default database. s390x/ppc64le are not built
# because MongoDB has no server binaries for them.
# The Docker image and snap cover the platforms MongoDB ships server binaries for
# (amd64, arm64; win64 + mac bundles too); MongoDB 7.x is the default database.
# Additionally, WeKan server bundles are built for every other architecture
# Node.js 24.x publishes binaries for — s390x and ppc64le — and attached to the
# GitHub Release as extra downloads (run a separate MongoDB for those). These
# extra-arch bundles are NOT part of the Docker image or snap. (armv7l/32-bit ARM
# is not built: Node.js 24 ships no armv7l binaries.)
#
# This workflow does EVERYTHING for a release remotely (nothing is built or
# pushed on the maintainer's machine), running jobs in parallel where possible:
@@ -18,11 +22,18 @@ name: Release All Platforms
# commits and pushes the bump to wekan/wekan main
# - prepare: tags the bumped main as v<new_version> and extracts release notes
# - website: updates the wekan/wekan.fi repo (install page + API docs) and pushes
# - charts: updates and publishes the wekan/charts Helm chart and pushes
# - charts: updates and publishes the wekan/charts Helm chart and pushes,
# only AFTER the `docker` job has published the image (skipped if
# docker fails)
# - build-*: builds the amd64 bundle (full Meteor 3 build), then rebuilds native
# modules for arm64, win64, mac-arm64 from that amd64 base
# modules for arm64 from that amd64 base
# - release: creates the GitHub Release with the newest CHANGELOG.md notes and
# uploads every wekan-<version>-<platform>.zip bundle
# uploads the core wekan-<version>-{amd64,arm64}.zip bundles
# - build-win64 / build-mac-arm64 / build-extra-arches: built AFTER release, each
# rebuilds the amd64 bundle's native modules for its platform (Windows,
# macOS arm64, and QEMU-emulated s390x/ppc64le) and attaches its
# .zip to the Release as an extra download. Decoupled from `release` so
# a slow/flaky build never blocks it; not in the Docker image or snap
# - docker: builds and pushes the multi-arch image to Docker Hub, Quay.io, GHCR
# - snap: builds the snap on Launchpad (amd64 + arm64), pushes it to the Snap
# Store and attaches the .snap files to the GitHub Release
@@ -55,7 +66,7 @@ permissions:
contents: write
packages: write
# Node.js major version that Meteor 3.5-beta bundles. Used for native module
# Node.js major version that Meteor 3.5 bundles. Used for native module
# rebuilds on every platform — it must match the amd64 build's Node ABI.
env:
NODE_VERSION: '24'
@@ -72,7 +83,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: main
fetch-depth: 0
@@ -115,7 +126,7 @@ jobs:
version: ${{ steps.version.outputs.version }}
changelog: ${{ steps.changelog.outputs.content }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: main
fetch-depth: 0
@@ -177,13 +188,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout wekan repo (bumped main, with rebuilt API docs)
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
ref: main
path: wekan
- name: Checkout wekan.fi website repo
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: wekan/wekan.fi
token: ${{ secrets.WEKAN_REPO_TOKEN }}
@@ -201,22 +212,24 @@ jobs:
cd ..
./wekan/releases/release-website.sh "${{ inputs.old_version }}" "${{ inputs.new_version }}"
# ─── 1c. Update and publish the Helm chart repo (parallel) ──────────────────
# ─── 1c. Update and publish the Helm chart repo (after docker) ──────────────
# Bumps the WeKan version in wekan/charts, packages the chart and publishes it
# to the gh-pages Helm index, then pushes both branches. Runs in parallel with
# the platform builds; packaging the chart does not need the built artifacts.
# to the gh-pages Helm index, then pushes both branches. Runs only AFTER the
# `docker` job succeeds: the chart points users at the freshly published
# multi-arch image, so we don't advertise a new chart version until that image
# is actually on the registries. If `docker` fails, this job is skipped.
charts:
needs: bump
needs: docker
runs-on: ubuntu-latest
steps:
- name: Checkout wekan repo (for releases/release-charts.sh + version refs)
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
ref: main
path: wekan
- name: Checkout wekan/charts repo (main + gh-pages)
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: wekan/charts
token: ${{ secrets.WEKAN_REPO_TOKEN }}
@@ -224,7 +237,7 @@ jobs:
path: charts
- name: Install Helm
uses: azure/setup-helm@v4
uses: azure/setup-helm@v5
- name: Fetch gh-pages branch (release-charts.sh switches to it)
run: |
@@ -241,7 +254,7 @@ jobs:
./wekan/releases/release-charts.sh "${{ inputs.new_version }}"
# ─── 2. Build amd64 bundle (full Meteor 3 build) ────────────────────────────
# Meteor 3.5-beta installer supports only Linux x86_64/aarch64, macOS, Windows.
# Meteor 3.5 installer supports only Linux x86_64/aarch64, macOS, Windows.
# The full Meteor build always runs here on x86_64; every other platform then
# only rebuilds the native npm modules from this bundle.
build-amd64:
@@ -251,19 +264,19 @@ jobs:
env:
VERSION: ${{ needs.prepare.outputs.version }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: refs/tags/v${{ needs.prepare.outputs.version }}
- name: Cache Meteor installation
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: ~/.meteor
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
restore-keys: meteor-
- name: Cache npm packages
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
@@ -357,9 +370,43 @@ jobs:
path: wekan-${{ needs.prepare.outputs.version }}-arm64.zip
retention-days: 1
# ─── 5. Build win64 bundle (rebuild native modules on Windows) ───────────────
# ─── 7. Create GitHub Release and upload all bundles ────────────────────────
release:
needs:
- prepare
- build-amd64
- build-arm64
runs-on: ubuntu-latest
env:
VERSION: ${{ needs.prepare.outputs.version }}
steps:
- name: Download all bundles
uses: actions/download-artifact@v8
with:
pattern: bundle-*
merge-multiple: true
- name: Create GitHub Release with all bundles
uses: softprops/action-gh-release@v3
with:
tag_name: v${{ needs.prepare.outputs.version }}
name: v${{ needs.prepare.outputs.version }}
body: ${{ needs.prepare.outputs.changelog }}
files: |
wekan-${{ needs.prepare.outputs.version }}-amd64.zip
wekan-${{ needs.prepare.outputs.version }}-arm64.zip
# ─── 7b. Extra-platform bundles (win64, mac-arm64, s390x, ppc64le) ───────────
# Every bundle below is built AFTER `release` and attaches its own .zip to the
# already-created GitHub Release with `gh release upload --clobber`, rather than
# being a prerequisite of `release`. This deliberately decouples the slower /
# different-OS builds (Windows, macOS, QEMU-emulated arches) from the core
# amd64/arm64 release, Docker and snap, so a slow or flaky one never blocks them.
# These bundles are NOT part of the Docker image or snap.
# Build win64 bundle (rebuild native modules on Windows).
build-win64:
needs: [prepare, build-amd64]
needs: [prepare, build-amd64, release]
runs-on: windows-latest
timeout-minutes: 60
env:
@@ -368,7 +415,7 @@ jobs:
# Checked out into src/ so start-wekan.bat and snapcraft.yaml (which pins
# the MongoDB tools/mongosh versions) are available; the downloaded bundle
# is extracted alongside it in the workspace root.
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: refs/tags/v${{ needs.prepare.outputs.version }}
path: src
@@ -455,9 +502,19 @@ jobs:
path: wekan-${{ needs.prepare.outputs.version }}-win64.zip
retention-days: 1
# ─── 6. Build mac-arm64 bundle (rebuild native modules on Apple Silicon) ─────
- name: Attach win64 bundle to the GitHub Release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# The repo is checked out into src/, so the workspace root is not a git
# repository; set GH_REPO so `gh` targets wekan/wekan without relying on
# git remote detection (else: "fatal: not a git repository").
GH_REPO: ${{ github.repository }}
run: gh release upload "v${VERSION}" "wekan-${VERSION}-win64.zip" --clobber
# Build mac-arm64 bundle (rebuild native modules on Apple Silicon).
build-mac-arm64:
needs: [prepare, build-amd64]
needs: [prepare, build-amd64, release]
runs-on: macos-14 # macos-14 runners are Apple Silicon (arm64)
timeout-minutes: 60
env:
@@ -503,35 +560,88 @@ jobs:
path: wekan-${{ needs.prepare.outputs.version }}-mac-arm64.zip
retention-days: 1
# ─── 7. Create GitHub Release and upload all bundles ────────────────────────
release:
needs:
- prepare
- build-amd64
- build-arm64
- build-win64
- build-mac-arm64
runs-on: ubuntu-latest
- name: Attach mac-arm64 bundle to the GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# No repo checkout in this job, so set GH_REPO so `gh` knows the target
# repository instead of trying (and failing) to detect a git remote.
GH_REPO: ${{ github.repository }}
run: gh release upload "v${VERSION}" "wekan-${VERSION}-mac-arm64.zip" --clobber
# Extra-architecture Linux bundles (s390x, ppc64le).
# armv7l (32-bit ARM) is intentionally excluded: Node.js 24 ships no armv7l
# binaries (official or unofficial), so there is no node:24 linux/arm/v7 image
# to rebuild native modules against and no Node 24 runtime to run a bundle on
# an armv7 device. See https://nodejs.org/dist/latest-v24.x/ (no -linux-armv7l).
build-extra-arches:
needs: [prepare, build-amd64, release]
runs-on: ubuntu-24.04
timeout-minutes: 300
strategy:
fail-fast: false
matrix:
include:
- arch: s390x
platform: linux/s390x
- arch: ppc64le
platform: linux/ppc64le
env:
VERSION: ${{ needs.prepare.outputs.version }}
steps:
- name: Download all bundles
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y curl unzip zip
- name: Set up QEMU for ${{ matrix.arch }} emulation
uses: docker/setup-qemu-action@v4
- name: Download amd64 bundle
uses: actions/download-artifact@v8
with:
pattern: bundle-*
merge-multiple: true
name: bundle-amd64
- name: Create GitHub Release with all bundles
uses: softprops/action-gh-release@v3
- name: Rebuild native modules for ${{ matrix.arch }} under emulation
run: |
unzip -q wekan-${VERSION}-amd64.zip
# Same bcrypt fix as the arm64 build: bcrypt 5.0.1 (Meteor
# accounts-password) ships node-addon-api@3 which fails to compile on
# Node.js 24. Swap in bcrypt 6.0.0 source; the emulated npm install
# below compiles it (and any other native module) for the target arch.
BCRYPT_DIR="$(pwd)/bundle/programs/server/npm/node_modules/meteor/accounts-password/node_modules/bcrypt"
if [ -d "$BCRYPT_DIR" ]; then
rm -rf "$BCRYPT_DIR"
TMP=$(mktemp -d)
pushd "$TMP" > /dev/null
npm pack bcrypt@6.0.0
mkdir bcrypt && tar xzf bcrypt-*.tgz --strip-components=1 -C bcrypt
cd bcrypt && npm install --ignore-scripts && cd ..
cp -a bcrypt "$BCRYPT_DIR"
popd > /dev/null
rm -rf "$TMP"
fi
docker run --rm --platform ${{ matrix.platform }} \
-v "$(pwd)/bundle:/bundle" \
-w /bundle/programs/server \
node:${NODE_VERSION}-slim \
sh -c "apt-get update -qq && apt-get install -y -q build-essential python3 && npm install"
- name: Create ${{ matrix.arch }} zip
run: zip -r wekan-${VERSION}-${{ matrix.arch }}.zip bundle
- uses: actions/upload-artifact@v7
with:
tag_name: v${{ needs.prepare.outputs.version }}
name: v${{ needs.prepare.outputs.version }}
body: ${{ needs.prepare.outputs.changelog }}
files: |
wekan-${{ needs.prepare.outputs.version }}-amd64.zip
wekan-${{ needs.prepare.outputs.version }}-arm64.zip
wekan-${{ needs.prepare.outputs.version }}-win64.zip
wekan-${{ needs.prepare.outputs.version }}-mac-arm64.zip
name: bundle-${{ matrix.arch }}
path: wekan-${{ needs.prepare.outputs.version }}-${{ matrix.arch }}.zip
retention-days: 1
- name: Attach ${{ matrix.arch }} bundle to the GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# No repo checkout in this job, so set GH_REPO so `gh` knows the target
# repository instead of trying (and failing) to detect a git remote.
GH_REPO: ${{ github.repository }}
run: gh release upload "v${VERSION}" "wekan-${VERSION}-${{ matrix.arch }}.zip" --clobber
# ─── 8. Build and push the multi-arch Docker image to all registries ────────
# Mirrors releases/docker-build.sh step 3 (multi-arch buildx --push).
@@ -544,7 +654,7 @@ jobs:
env:
VERSION: ${{ needs.prepare.outputs.version }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: refs/tags/v${{ needs.prepare.outputs.version }}
@@ -552,7 +662,7 @@ jobs:
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub, Quay.io and GHCR
env:
@@ -572,10 +682,97 @@ jobs:
login quay.io "$QUAY_AUTH"
login ghcr.io "$GHCR_AUTH"
- name: Wait for release bundles to be downloadable
# The Dockerfile downloads wekan-<version>-<arch>.zip from the GitHub
# Release for each built platform. Even though this job needs `release`
# (so the assets are uploaded), GitHub's releases/download/<tag>/<asset>
# URL can briefly 404 right after upload (CDN/propagation lag), which used
# to fail the multi-arch build mid-way. Poll here until both the amd64 and
# arm64 bundles return 200, so the (expensive, emulated) buildx run only
# starts once the assets are actually fetchable. The Dockerfile also
# retries per-arch as a second layer of safety.
run: |
set -euo pipefail
base="https://github.com/wekan/wekan/releases/download/v${VERSION}"
for arch in amd64 arm64; do
url="${base}/wekan-${VERSION}-${arch}.zip"
echo "Waiting for ${url} ..."
ok=0
for i in $(seq 1 30); do
code=$(curl -sSL -o /dev/null -w '%{http_code}' --retry 0 -I "$url" || echo 000)
if [ "$code" = "200" ]; then
echo " available (HTTP $code) after $((i*10))s"
ok=1
break
fi
echo " attempt $i/30: HTTP $code, retrying in 10s..."
sleep 10
done
if [ "$ok" -ne 1 ]; then
echo "::error::Release asset not downloadable after retries: $url"
exit 1
fi
done
- name: Verify the release bundle reports the release version
# Belt-and-suspenders against the "image tagged vX shows vY in the Admin
# Panel" bug: the image is just the unzipped wekan-<version>-<arch>.zip
# bundle (Dockerfile moves it to /build), and the Admin Panel shows that
# bundle's WeKan version (server/statistics.js reads
# require('/package.json').version). Check the amd64 bundle's WeKan version
# equals the release version BEFORE the expensive multi-arch buildx, so a
# stale/mismatched bundle fails fast instead of shipping (and pushing to
# three registries) a mislabeled image.
#
# Meteor does NOT ship the app package.json as a standalone file — it
# inlines it into the compiled bundle/programs/server/app/app.js as a JSON
# module, e.g. {"name":"wekan","version":"v9.63.0",...}. That inlined
# module (anchored on "name":"wekan") is exactly what require('/package.json')
# resolves to at runtime, so it is the authoritative thing to check.
#
# IMPORTANT: only a CONFIRMED mismatch fails the release. If the version
# cannot be located (bundle layout changed, app.js minified differently),
# warn and continue — inability to verify must not block a good release,
# since --build-arg VERSION below is the actual guarantee. (An earlier
# version of this guard grepped for a v-prefixed *package.json* and made
# "not found" fatal, which false-failed the whole v9.63 docker build.)
run: |
set -euo pipefail
url="https://github.com/wekan/wekan/releases/download/v${VERSION}/wekan-${VERSION}-amd64.zip"
wget -q "$url" -O bundle.zip
tmp="$(mktemp -d)"
# Extract only the compiled app program (not the whole multi-hundred-MB
# bundle); the inlined app package.json lives in app/app.js.
unzip -o -q bundle.zip 'bundle/programs/server/app/app.js' -d "$tmp" || true
appjs="$tmp/bundle/programs/server/app/app.js"
found=""
if [ -f "$appjs" ]; then
match="$(grep -aoE '"name":"wekan","version":"v?[0-9][^"]*"' "$appjs" | head -1 || true)"
found="$(printf '%s' "$match" | sed -E 's/.*"version":"([^"]*)".*/\1/')"
fi
rm -rf "$tmp" bundle.zip
if [ -z "$found" ]; then
echo "::warning::Could not locate the WeKan version in the bundle (app.js layout may have changed); skipping the bundle-version check. --build-arg VERSION still guarantees the correct bundle."
exit 0
fi
norm="${found#v}"
if [ "$norm" != "${VERSION}.0" ] && [ "$norm" != "${VERSION}" ]; then
echo "::error::Bundle reports app version ${found} but the release is v${VERSION}. The bundle baked into the image is stale/mismatched — the Admin Panel would show ${norm}. Aborting before build."
exit 1
fi
echo "Bundle app version ${found} matches release v${VERSION}."
- name: Build and push multi-arch image
# Pass VERSION explicitly as a build-arg. The Dockerfile's `ARG VERSION`
# default is only a fallback for manual builds; the release version is the
# authoritative source here. Without this the image would build against the
# Dockerfile default (downloading that version's wekan-<v>-<arch>.zip and
# baking its package.json), so a v${VERSION}-tagged image could ship — and
# report in the Admin Panel — a stale, different version.
run: |
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg VERSION=${VERSION} \
-t wekanteam/wekan:v${VERSION} \
-t wekanteam/wekan:latest \
-t quay.io/wekan/wekan:v${VERSION} \
@@ -596,7 +793,7 @@ jobs:
env:
VERSION: ${{ needs.prepare.outputs.version }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: refs/tags/v${{ needs.prepare.outputs.version }}
# remote-build (Launchpad) rejects shallow clones, so fetch full history
@@ -613,15 +810,74 @@ jobs:
printf '%s' "$LP_CREDENTIALS" | base64 -d > ~/.local/share/snapcraft/launchpad-credentials
chmod 600 ~/.local/share/snapcraft/launchpad-credentials
- name: Build snaps on Launchpad (amd64 + arm64)
- name: Verify snapcraft.yaml version matches the release
# The `bump` job (releases/version.sh) is the single source of truth for
# the snap version and updates snapcraft.yaml before the tag is created.
# snapcraft.yaml's `version:` names the produced files
# (wekan_<version>_<arch>.snap), so if a stale tag carried the wrong
# version the build would produce wekan_<old>_*.snap and the upload/attach
# globs (wekan_${VERSION}_*.snap) would match nothing — passing the literal
# glob to `snapcraft upload` ("is not a valid file", exit 64). Assert it
# here so that mismatch fails fast (before a ~20 min Launchpad build) with
# a clear pointer, instead of re-bumping the version inside this build job.
run: |
snapcraft remote-build --launchpad-accept-public-upload
set -euo pipefail
if ! grep -qxF "version: '${VERSION}'" snapcraft.yaml; then
echo "::error::snapcraft.yaml is not at version '${VERSION}' ($(grep -m1 '^version:' snapcraft.yaml)). The bump job (releases/version.sh) did not update the tagged commit."
exit 1
fi
echo "snapcraft.yaml is at version '${VERSION}'."
- name: Build snaps on Launchpad (amd64 + arm64)
# `snapcraft remote-build` is experimental and talks to Launchpad over
# HTTPS after pushing the whole repo; it intermittently dies with a
# transient TLS drop (e.g. "SSLEOFError: EOF occurred in violation of
# protocol", exit 70) that has nothing to do with the build itself.
# Retry a few times so one network hiccup doesn't fail the release.
run: |
set -uo pipefail
shopt -s nullglob
attempts=3
for i in $(seq 1 "$attempts"); do
echo "::group::Remote build attempt $i/$attempts"
snapcraft remote-build --launchpad-accept-public-upload && rc=0 || rc=$?
echo "::endgroup::"
# snapcraft remote-build can exit 0 even when the Launchpad build
# FAILED (it just downloads the build logs and produces no .snap), so
# don't trust the exit code alone — require that .snap files exist.
snaps=( wekan_${VERSION}_*.snap )
if [ "$rc" -eq 0 ] && [ "${#snaps[@]}" -gt 0 ]; then
echo "Remote build succeeded on attempt $i: ${snaps[*]}"
exit 0
fi
echo "Attempt $i: snapcraft exit=$rc, .snap files produced=${#snaps[@]}."
# Surface the Launchpad build logs so a real build failure is
# diagnosable here, instead of as a later cryptic upload error.
for log in snapcraft-wekan-*.txt; do
echo "::group::Launchpad build log: $log (last 150 lines)"
tail -n 150 "$log"
echo "::endgroup::"
done
if [ "$i" -lt "$attempts" ]; then
echo "Retrying in 60s..."
sleep 60
fi
done
echo "::error::Remote build failed after $attempts attempts (no wekan_${VERSION}_*.snap produced)." >&2
exit 1
- name: Push snaps to the Snap Store (edge + beta channels)
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_AUTH }}
run: |
for f in wekan_${VERSION}_*.snap; do
set -euo pipefail
shopt -s nullglob
snaps=( wekan_${VERSION}_*.snap )
if [ "${#snaps[@]}" -eq 0 ]; then
echo "::error::No wekan_${VERSION}_*.snap files to upload."
exit 1
fi
for f in "${snaps[@]}"; do
echo "Uploading $f to the Snap Store (edge,beta)..."
snapcraft upload --release=edge,beta "$f"
done
@@ -630,4 +886,11 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "v${VERSION}" wekan_${VERSION}_*.snap --clobber
set -euo pipefail
shopt -s nullglob
snaps=( wekan_${VERSION}_*.snap )
if [ "${#snaps[@]}" -eq 0 ]; then
echo "::error::No wekan_${VERSION}_*.snap files to attach."
exit 1
fi
gh release upload "v${VERSION}" "${snaps[@]}" --clobber
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
+6
View File
@@ -32,6 +32,7 @@ Thumbs.db
ehthumbs.db
.eslintcache
.meteor/local
.meteor/local-test
.devcontainer/docker-compose.extend.yml
.devcontainer/volumes*/
.coverage
@@ -64,3 +65,8 @@ test-results/
# Tools like mongosh etc so that they work inside Flatpak sandbox
.tools/
# Meteor custom local directory (METEOR_LOCAL_DIR)
_build-local-test
*/build-assets-local-test
*/build-chunks-local-test
+8 -8
View File
@@ -6,7 +6,7 @@
meteor-base@1.5.2
# Build system
ecmascript@0.18.0
ecmascript@0.18.1
standard-minifier-js@3.2.0
rspack@1.1.0
@@ -16,11 +16,11 @@ blaze@3.0.2
aldeed:collection2
aldeed:schema-index@4.0.0-beta.5
reywood:publish-composite
mongo@2.4.0-rc350.1
mongo@2.4.0
# Account system
accounts-password@3.3.0-rc350.1
accounts-express@1.0.0-rc350.1
accounts-password@3.3.0
accounts-express@1.0.0
communitypackages:core
communitypackages:unstyled
communitypackages:flow-routing
@@ -37,7 +37,7 @@ wekan-accounts-oidc
check@1.5.0
random@1.2.2
reactive-dict@1.3.2
session@1.2.3-rc350.1
session@1.2.3
tracker@1.3.4
audit-argument-checks@1.0.8
@@ -45,7 +45,7 @@ audit-argument-checks@1.0.8
ostrio:i18n
reactive-var@1.0.13
shell-server@0.7.0
email@3.2.0-rc350.1
email@3.2.0
dynamic-import@0.7.4
wekan-markdown
quave:synced-cron
@@ -55,8 +55,8 @@ ostrio:files
meteortesting:mocha@3.0.0
matb33:collection-hooks
service-configuration@1.3.5
minifier-css@2.0.2-rc350.1
ejson@1.2.0-rc350.1
minifier-css@2.0.2
ejson@1.2.0
logging@1.3.6
wekan-fullcalendar
wekan-fontawesome
+1 -1
View File
@@ -1 +1 @@
METEOR@3.5-rc.1
METEOR@3.5
+28 -28
View File
@@ -1,44 +1,44 @@
accounts-base@3.3.0-rc350.1
accounts-express@1.0.0-rc350.1
accounts-oauth@1.4.7-rc350.1
accounts-password@3.3.0-rc350.1
accounts-base@3.3.0
accounts-express@1.0.0
accounts-oauth@1.4.7
accounts-password@3.3.0
aldeed:collection2@4.2.0
aldeed:schema-index@4.0.0
aldeed:simple-schema@1.13.1
allow-deny@2.1.0
audit-argument-checks@1.0.8
autoupdate@2.0.1
babel-compiler@7.14.0
babel-compiler@7.14.1
babel-runtime@1.5.2
base64@1.0.14-rc350.1
binary-heap@1.0.13-rc350.1
base64@1.0.14
binary-heap@1.0.13
blaze@3.0.3
blaze-tools@2.0.1
boilerplate-generator@2.1.0
caching-compiler@2.0.1
caching-html-compiler@2.0.1
callback-hook@1.8.0-rc350.1
callback-hook@1.8.0
check@1.5.0
communitypackages:core@2.0.3
communitypackages:flow-routing@2.1.0
communitypackages:unstyled@2.0.1
core-runtime@1.0.0
ddp@1.4.2
ddp-client@3.3.0-rc350.1
ddp-common@1.4.5-rc350.1
ddp-rate-limiter@1.3.0-rc350.1
ddp-server@3.3.0-rc350.1
ddp-client@3.3.0
ddp-common@1.4.5
ddp-rate-limiter@1.3.0
ddp-server@3.3.0
diff-sequence@1.1.3
dynamic-import@0.7.4
ecmascript@0.18.0
ecmascript@0.18.1
ecmascript-runtime@0.8.3
ecmascript-runtime-client@0.13.0
ecmascript-runtime-server@0.11.1
ejson@1.2.0-rc350.1
email@3.2.0-rc350.1
ejson@1.2.0
email@3.2.0
es5-shim@4.8.1
facts-base@1.0.2
fetch@0.2.0-rc350.1
fetch@0.2.0
geojson-utils@1.0.12
hot-code-push@1.0.5
html-tools@2.0.1
@@ -53,23 +53,23 @@ lai:collection-extensions@1.0.0
localstorage@1.2.1
logging@1.3.6
matb33:collection-hooks@2.1.0
meteor@2.3.1-rc350.1
meteor@2.3.1
meteor-base@1.5.2
meteortesting:browser-tests@1.8.0
meteortesting:mocha@3.3.0
meteortesting:mocha-core@8.2.0
minifier-css@2.0.2-rc350.1
minifier-js@3.2.0
minimongo@2.2.0-rc350.1
minifier-css@2.0.2
minifier-js@3.2.1
minimongo@2.2.0
modern-browsers@0.2.3
modules@0.20.3
modules-runtime@0.13.2
mongo@2.4.0-rc350.1
mongo@2.4.0
mongo-decimal@0.2.0
mongo-dev-server@1.1.1
mongo-id@1.0.9
npm-mongo@6.16.1
oauth@3.0.4-rc350.1
oauth@3.0.4
oauth2@1.3.3
observe-sequence@2.0.1
ordered-dict@1.2.0
@@ -82,7 +82,7 @@ promise@1.0.0
quave:synced-cron@2.4.0
raix:eventemitter@1.0.0
random@1.2.2
rate-limit@1.2.0-rc350.1
rate-limit@1.2.0
react-fast-refresh@0.3.0
reactive-dict@1.3.2
reactive-var@1.0.13
@@ -92,10 +92,10 @@ reywood:publish-composite@1.9.0
routepolicy@1.1.2
rspack@1.1.0
service-configuration@1.3.5
session@1.2.3-rc350.1
session@1.2.3
sha@1.0.10
shell-server@0.7.0
socket-stream-client@0.7.0-rc350.1
socket-stream-client@0.7.0
spacebars@2.0.1
spacebars-compiler@2.0.1
standard-minifier-js@3.2.0
@@ -105,9 +105,9 @@ templating-runtime@2.0.2
templating-tools@2.0.1
tools-core@1.1.0
tracker@1.3.4
typescript@5.10.0
url@1.3.6-rc350.1
webapp@2.2.0-rc350.1
typescript@5.10.1
url@1.3.6
webapp@2.2.0
webapp-hashing@1.1.2
wekan-accounts-cas@0.2.0
wekan-accounts-lockout@1.1.0
+8 -1
View File
@@ -1,2 +1,9 @@
npm-packages/
tests/
# Root tests/ holds Playwright specs, Node E2E and node-based unit tests - none
# of it is Meteor code, and Meteor's file watcher otherwise churns on the
# tests/playwright/test-results/ tree (inotify errors). The leading slash anchors
# this to the repo root only, so it does NOT match server/lib/tests/ or
# client/lib/tests/ (the Mocha suites, loaded via meteor.testModule in
# package.json). A naked "tests/" would match a dir named tests at any depth and
# would hide those suites, making `meteor test` report "0 passing".
/tests/
+2564 -95
View File
File diff suppressed because it is too large. Load diff
+23 -5
View File
@@ -7,17 +7,17 @@ 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.44
ARG VERSION=9.76
ARG DEBIAN_FRONTEND=noninteractive
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
ENV \
DEBUG=false \
NODE_VERSION=v24.16.0 \
METEOR_RELEASE=METEOR@3.5-beta.12 \
NODE_VERSION=v24.18.0 \
METEOR_RELEASE=METEOR@3.5-rc.2 \
USE_EDGE=false \
NPM_VERSION=11.11.0 \
NPM_VERSION=11.12.1 \
SRC_PATH=./ \
WITH_API=true \
MONGO_OPLOG_URL="" \
@@ -42,6 +42,7 @@ ENV \
IMAGE_COMPRESS_RATIO="" \
NOTIFICATION_TRAY_AFTER_READ_DAYS_BEFORE_REMOVE="" \
BIGEVENTS_PATTERN=NONE \
NOTIFY_ON_ASSIGN="true" \
NOTIFY_DUE_DAYS_BEFORE_AND_AFTER="" \
NOTIFY_DUE_AT_HOUR_OF_DAY="" \
EMAIL_NOTIFICATION_TIMEOUT=30000 \
@@ -66,8 +67,11 @@ ENV \
OAUTH2_AUTH_ENDPOINT="" \
OAUTH2_USERINFO_ENDPOINT="" \
OAUTH2_TOKEN_ENDPOINT="" \
OAUTH2_LOGOUT_ENDPOINT="" \
OAUTH2_ID_MAP="" \
OAUTH2_USERNAME_MAP="" \
OAUTH2_AUTO_REGISTRATION="true" \
OAUTH2_ADMIN_GROUPS="" \
OAUTH2_FULLNAME_MAP="" \
OAUTH2_ID_TOKEN_WHITELIST_FIELDS="" \
OAUTH2_REQUEST_PERMISSIONS='openid profile email' \
@@ -93,6 +97,7 @@ ENV \
LDAP_BACKGROUND_SYNC_INTERVAL="" \
LDAP_BACKGROUND_SYNC_KEEP_EXISTANT_USERS_UPDATED=false \
LDAP_BACKGROUND_SYNC_IMPORT_NEW_USERS=false \
LDAP_BACKGROUND_SYNC_DISABLE_NONEXISTANT_USERS=false \
LDAP_ENCRYPTION=false \
LDAP_CA_CERT="" \
LDAP_REJECT_UNAUTHORIZED=false \
@@ -122,10 +127,16 @@ ENV \
LDAP_DEFAULT_DOMAIN="" \
LDAP_SYNC_ADMIN_STATUS="" \
LDAP_SYNC_ADMIN_GROUPS="" \
LDAP_SYNC_ORGANIZATIONS=false \
LDAP_SYNC_ORGANIZATIONS_GROUPS="" \
LDAP_SYNC_TEAMS=false \
LDAP_SYNC_TEAMS_GROUPS="" \
HEADER_LOGIN_ID="" \
HEADER_LOGIN_FIRSTNAME="" \
HEADER_LOGIN_LASTNAME="" \
HEADER_LOGIN_EMAIL="" \
HEADER_LOGIN_TRUSTED_IPS="" \
HEADER_LOGIN_TRUSTED_PROXIES="" \
LOGOUT_WITH_TIMER=false \
LOGOUT_IN="" \
LOGOUT_ON_HOURS="" \
@@ -205,7 +216,14 @@ ln -sf $(which bsdtar) $(which tar)
# WeKan Bundle Installation
mkdir -p /home/wekan/app
cd /home/wekan/app
wget "https://github.com/wekan/wekan/releases/download/v${VERSION}/wekan-${VERSION}-${WEKAN_ARCH}.zip"
# Retry the release-asset download: even though the CI `docker` job needs the
# `release` job (so the asset is already uploaded), GitHub's
# releases/download/<tag>/<asset> URL can briefly return 404 right after upload
# (CDN/propagation lag). A plain wget treats 404 as fatal, which failed the
# build; retry on transient HTTP errors so propagation lag no longer breaks it.
WEKAN_ZIP_URL="https://github.com/wekan/wekan/releases/download/v${VERSION}/wekan-${VERSION}-${WEKAN_ARCH}.zip"
wget --tries=20 --waitretry=20 --retry-on-http-error=404,403,500,502,503 "${WEKAN_ZIP_URL}" \
|| { echo "Failed to download ${WEKAN_ZIP_URL} after retries"; exit 8; }
unzip "wekan-${VERSION}-${WEKAN_ARCH}.zip"
rm "wekan-${VERSION}-${WEKAN_ARCH}.zip"
npm install --prefix ./bundle/programs/server
+1 -1
View File
@@ -103,7 +103,7 @@ By working directly with WeKan ® you get the benefit of active maintenance and
## Getting Started with Development
The default branch uses Meteor 3.5-beta.7 with Node.js 24.x.
The default branch uses Meteor 3.5-rc.2 with Node.js 24.x.
See [CHANGELOG.md](https://github.com/wekan/wekan/blob/main/CHANGELOG.md) for the latest runtime updates.
To contribute, [create a fork](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#2-create-fork-of-httpsgithubcomwekanwekan-at-github-web-page) and run `./rebuild-wekan.sh` (or `./rebuild-wekan.bat` on Windows) as detailed [here](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#3-select-option-1-to-install-dependencies-and-then-enter). Once you're ready, please test your code and [submit a pull request (PR)](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#7-test).
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v9.44.0"
appVersion: "v9.76.0"
files:
userUploads:
- README.md
+558 -7
View File
@@ -70,12 +70,25 @@ If *nix: chmod +x api.py => ./api.py users
python3 api.py listcardattachments BOARDID SWIMLANEID LISTID CARDID # List attachments for specific card
python3 api.py copymoveattachment ATTACHMENTID TARGETBOARDID TARGETSWIMLANEID TARGETLISTID TARGETCARDID [copy|move] # Copy or move attachment
python3 api.py deleteattachment ATTACHMENTID # Delete attachment
python3 api.py uploadbackground BOARDID FILEPATH # Upload a board background image (uses Admin Panel default storage)
python3 api.py downloadbackground BOARDID OUTPUTPATH # Download the board's current background image
python3 api.py importboard EXPORT.json # Import a whole board (with rules/workflows) from a WeKan export
python3 api.py migratefromwekan REMOTE_URL REMOTE_USER REMOTE_PASS # Import ALL boards+workflows+rules from another WeKan
python3 api.py exportboardpdf BOARDID OUTPUT.pdf # Export a whole board to PDF
python3 api.py importboardfrom SOURCE EXPORT.json # Import from trello/wekan/csv/jira/kanboard/excel/deck/openproject/github/gitlab/gitea/forgejo/asana/zenkit
python3 api.py exportboardformat BOARDID FORMAT OUTPUT.json # Export to kanboard/trello/jira/deck/openproject/github/gitlab/gitea/forgejo/asana/zenkit
python3 api.py importics BOARDID SWIMLANEID LISTID CALENDAR.ics # Import an iCalendar (.ics) file into a board as cards (one card per VEVENT)
Board Member API (Issue #5998):
python3 api.py addboardmember BOARDID USERID ROLE # Add/activate board member. ROLE: admin, normal, comment, readonly, worker, normalassignedonly, commentassignedonly, readassignedonly, nocomments
python3 api.py removeboardmember BOARDID USERID # Remove (deactivate) board member
python3 api.py setboardmemberrole BOARDID USERID ROLE # Change permission/role of existing board member
Board Domain Sharing API (Issue #5850):
python3 api.py boarddomains BOARDID # List the email domains a board is shared with
python3 api.py addboarddomain BOARDID DOMAIN # Share board with an email domain, e.g. example.com
python3 api.py removeboarddomain BOARDID DOMAIN # Stop sharing board with an email domain
Card Member / Assignee API (board member -> card member/assignee):
python3 api.py setcardmembers BOARDID LISTID CARDID MEMBERIDS # Set card members. MEMBERIDS = comma-separated userIds, or '' to clear
python3 api.py setcardassignees BOARDID LISTID CARDID ASSIGNEEIDS # Set card assignees. ASSIGNEEIDS = comma-separated userIds, or '' to clear
@@ -114,11 +127,24 @@ If *nix: chmod +x api.py => ./api.py users
python3 api.py mycards [due] [FROM] [TO] # Current user's cards. 'due' = only cards with a due date; FROM/TO = ISO 8601 due-date range
Card Settings API (Issue #3062):
python3 api.py getcardsettings BOARDID # Get board-level card settings (allows* toggles)
python3 api.py setcardsetting BOARDID KEY VALUE # Set one board card setting, e.g. allowsDueDate true
python3 api.py getcardsettings BOARDID # Get board-level card settings (allows* toggles, cardAging, cardAgingDays1/2/3)
python3 api.py setcardsetting BOARDID KEY VALUE # Set one board card setting, e.g. allowsDueDate true, cardAging true, cardAgingDays1 5
Admin API:
python3 api.py newuser USERNAME EMAIL PASSWORD
GlobalAdmin REST API (admin token required):
python3 api.py getsettings # Get the global Admin Panel settings
python3 api.py editsettings FIELD VALUE # Set one global settings field, e.g. productName "My WeKan", disableRegistration true
python3 api.py admindomains # List email domains with user counts (#5850)
python3 api.py attachmentsettings # Get attachment storage / upload-block settings (secrets masked)
python3 api.py editattachmentsetting DOTTED.FIELD VALUE # Set one attachment setting, e.g. limitSettings.avatarsUploadBlocked true (#4740)
python3 api.py adminorgs # List organizations with feature toggles (#4737)
python3 api.py editorgfeature ORGID FIELD true|false # Set one org feature: orgSharedTemplates, orgPropagateMembersToBoards, orgSyncMembersFromAuth
python3 api.py editallorgfeature FIELD true|false # Set one org feature on ALL orgs
python3 api.py adminteams # List teams with feature toggles (#4737)
python3 api.py editteamfeature TEAMID FIELD true|false # Set one team feature: teamSharedTemplates, teamPropagateMembersToBoards, teamSyncMembersFromAuth
python3 api.py editallteamfeature FIELD true|false # Set one team feature on ALL teams
"""
if arguments == 0:
@@ -218,6 +244,7 @@ ats = 'attachments'
users = wekanurl + apiusers
user = wekanurl + apiuser
allusers = wekanurl + apiallusers
settings = wekanurl + 'api/settings'
# ------- API URL GENERATION END -----------
@@ -238,10 +265,9 @@ if arguments == 10:
boardid = sys.argv[3]
name = sys.argv[4]
type1 = sys.argv[5]
settings = str(json.loads(sys.argv[6]))
# There is error: Settings must be object. So this does not work yet.
#settings = {'currencyCode': 'EUR'}
print(type(settings))
settings_arg = sys.argv[6]
# settings is optional; an empty arg -> {} (avoids json.loads('') crashing).
settings = json.loads(settings_arg) if settings_arg.strip() else {}
showoncard = sys.argv[7]
automaticallyoncard = sys.argv[8]
showlabelonminicard = sys.argv[9]
@@ -249,7 +275,7 @@ if arguments == 10:
customfieldtoboard = wekanurl + apiboards + boardid + s + cf
# Add Custom Field to Board
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
post_data = {'authorId': '{}'.format(authorid), 'name': '{}'.format(name), 'type': '{}'.format(type1), 'settings': '{}'.format(settings), 'showoncard': '{}'.format(showoncard), 'automaticallyoncard': '{}'.format(automaticallyoncard), 'showlabelonminicard': '{}'.format(showlabelonminicard), 'showsumattopoflist': '{}'.format(showsumattopoflist)}
post_data = {'authorId': '{}'.format(authorid), 'name': '{}'.format(name), 'type': '{}'.format(type1), 'settings': json.dumps(settings), 'showoncard': '{}'.format(showoncard), 'automaticallyoncard': '{}'.format(automaticallyoncard), 'showlabelonminicard': '{}'.format(showlabelonminicard), 'showsumattopoflist': '{}'.format(showsumattopoflist)}
body = requests.post(customfieldtoboard, data=post_data, headers=headers)
print(body.text)
# ------- ADD CUSTOM FIELD TO BOARD END -----------
@@ -790,6 +816,155 @@ if arguments >= 1:
print(data2)
# ------- LIST OF USERS END -----------
if arguments == 1 and sys.argv[1] == 'getsettings':
# ------- GET GLOBAL ADMIN SETTINGS START -----------
# GlobalAdmin REST API: read the Admin Panel global settings.
# Requires an admin API token. Mail-server credentials are never returned.
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
print(settings)
print("=== GLOBAL ADMIN SETTINGS ===\n")
body = requests.get(settings, headers=headers)
print(body.text)
# ------- GET GLOBAL ADMIN SETTINGS END -----------
if arguments >= 3 and sys.argv[1] == 'editsettings':
# ------- EDIT GLOBAL ADMIN SETTINGS START -----------
# GlobalAdmin REST API: set one Admin Panel global settings field.
# Usage: python3 api.py editsettings <field> <value>
# e.g. python3 api.py editsettings productName "My WeKan"
# python3 api.py editsettings disableRegistration true
headers = {'Accept': 'application/json', 'Content-type': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
field = sys.argv[2]
raw = sys.argv[3]
# Convert true/false to booleans; leave everything else as a string.
if raw in ('true', 'false'):
value = (raw == 'true')
else:
value = raw
body = requests.put(settings, json={field: value}, headers=headers)
print(body.text)
# ------- EDIT GLOBAL ADMIN SETTINGS END -----------
if arguments == 1 and sys.argv[1] == 'admindomains':
# ------- LIST ADMIN DOMAINS START -----------
# GlobalAdmin REST API: email domains with user counts (#5850).
admindomainsurl = wekanurl + 'api/admin/domains'
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
print("=== ADMIN DOMAINS ===\n")
body = requests.get(admindomainsurl, headers=headers)
print(body.text)
# ------- LIST ADMIN DOMAINS END -----------
if arguments == 1 and sys.argv[1] == 'attachmentsettings':
# ------- GET ATTACHMENT SETTINGS START -----------
# GlobalAdmin REST API: attachment storage / upload-block settings.
# Secrets (S3/Azure/GCS) are masked; a <field>Set marker shows existence.
attachmentsettingsurl = wekanurl + 'api/admin/attachment-settings'
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
print("=== ATTACHMENT SETTINGS ===\n")
body = requests.get(attachmentsettingsurl, headers=headers)
print(body.text)
# ------- GET ATTACHMENT SETTINGS END -----------
if arguments >= 3 and sys.argv[1] == 'editattachmentsetting':
# ------- EDIT ATTACHMENT SETTING START -----------
# GlobalAdmin REST API: set one attachment setting by dotted path.
# Usage: python3 api.py editattachmentsetting <dotted.field> <value>
# e.g. python3 api.py editattachmentsetting limitSettings.avatarsUploadBlocked true
attachmentsettingsurl = wekanurl + 'api/admin/attachment-settings'
headers = {'Accept': 'application/json', 'Content-type': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
field = sys.argv[2]
raw = sys.argv[3]
# Convert true/false to booleans; leave everything else as a string.
if raw in ('true', 'false'):
value = (raw == 'true')
else:
value = raw
# Expand a dotted path (e.g. limitSettings.avatarsUploadBlocked) into
# the nested object the partial-update endpoint expects.
body_obj = {}
cursor = body_obj
parts = field.split('.')
for part in parts[:-1]:
cursor[part] = {}
cursor = cursor[part]
cursor[parts[-1]] = value
body = requests.put(attachmentsettingsurl, json=body_obj, headers=headers)
print(body.text)
# ------- EDIT ATTACHMENT SETTING END -----------
if arguments == 1 and sys.argv[1] == 'adminorgs':
# ------- LIST ADMIN ORGS START -----------
# GlobalAdmin REST API: orgs with feature toggles (#4737).
adminorgsurl = wekanurl + 'api/admin/orgs'
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
print("=== ADMIN ORGS ===\n")
body = requests.get(adminorgsurl, headers=headers)
print(body.text)
# ------- LIST ADMIN ORGS END -----------
if arguments >= 3 and sys.argv[1] == 'editorgfeature':
# ------- EDIT ORG FEATURE START -----------
# Usage: python3 api.py editorgfeature <orgId> <field> <true|false>
# field: orgSharedTemplates, orgPropagateMembersToBoards, orgSyncMembersFromAuth
orgid = sys.argv[2]
field = sys.argv[3]
raw = sys.argv[4]
value = (raw == 'true')
editorgfeatureurl = wekanurl + 'api/admin/orgs/' + orgid + '/features'
headers = {'Accept': 'application/json', 'Content-type': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
body = requests.put(editorgfeatureurl, json={field: value}, headers=headers)
print(body.text)
# ------- EDIT ORG FEATURE END -----------
if arguments >= 2 and sys.argv[1] == 'editallorgfeature':
# ------- EDIT ALL ORG FEATURE START -----------
# Usage: python3 api.py editallorgfeature <field> <true|false>
field = sys.argv[2]
raw = sys.argv[3]
value = (raw == 'true')
editallorgfeatureurl = wekanurl + 'api/admin/orgs/features'
headers = {'Accept': 'application/json', 'Content-type': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
body = requests.put(editallorgfeatureurl, json={'field': field, 'value': value}, headers=headers)
print(body.text)
# ------- EDIT ALL ORG FEATURE END -----------
if arguments == 1 and sys.argv[1] == 'adminteams':
# ------- LIST ADMIN TEAMS START -----------
# GlobalAdmin REST API: teams with feature toggles (#4737).
adminteamsurl = wekanurl + 'api/admin/teams'
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
print("=== ADMIN TEAMS ===\n")
body = requests.get(adminteamsurl, headers=headers)
print(body.text)
# ------- LIST ADMIN TEAMS END -----------
if arguments >= 3 and sys.argv[1] == 'editteamfeature':
# ------- EDIT TEAM FEATURE START -----------
# Usage: python3 api.py editteamfeature <teamId> <field> <true|false>
# field: teamSharedTemplates, teamPropagateMembersToBoards, teamSyncMembersFromAuth
teamid = sys.argv[2]
field = sys.argv[3]
raw = sys.argv[4]
value = (raw == 'true')
editteamfeatureurl = wekanurl + 'api/admin/teams/' + teamid + '/features'
headers = {'Accept': 'application/json', 'Content-type': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
body = requests.put(editteamfeatureurl, json={field: value}, headers=headers)
print(body.text)
# ------- EDIT TEAM FEATURE END -----------
if arguments >= 2 and sys.argv[1] == 'editallteamfeature':
# ------- EDIT ALL TEAM FEATURE START -----------
# Usage: python3 api.py editallteamfeature <field> <true|false>
field = sys.argv[2]
raw = sys.argv[3]
value = (raw == 'true')
editallteamfeatureurl = wekanurl + 'api/admin/teams/features'
headers = {'Accept': 'application/json', 'Content-type': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
body = requests.put(editallteamfeatureurl, json={'field': field, 'value': value}, headers=headers)
print(body.text)
# ------- EDIT ALL TEAM FEATURE END -----------
if arguments == 1 and sys.argv[1] == 'user':
# ------- LIST OF ALL USERS START -----------
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
@@ -1167,6 +1342,48 @@ if arguments >= 1:
print(body.text)
# ------- SET BOARD MEMBER ROLE END -----------
if sys.argv[1] == 'boarddomains':
# ------- LIST BOARD DOMAINS START -----------
if arguments < 2:
print("Usage: python3 api.py boarddomains BOARDID")
exit(1)
boardid = sys.argv[2]
domains_url = wekanurl + apiboards + boardid + s + 'domains'
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
body = requests.get(domains_url, headers=headers)
print("=== BOARD DOMAINS ===\n")
print(body.text)
# ------- LIST BOARD DOMAINS END -----------
if sys.argv[1] == 'addboarddomain':
# ------- ADD BOARD DOMAIN START -----------
if arguments < 3:
print("Usage: python3 api.py addboarddomain BOARDID DOMAIN")
exit(1)
boardid = sys.argv[2]
domain = sys.argv[3]
add_domain_url = wekanurl + apiboards + boardid + s + 'domains'
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
post_data = {'domain': '{}'.format(domain)}
body = requests.post(add_domain_url, data=post_data, headers=headers)
print("=== ADD BOARD DOMAIN ===\n")
print(body.text)
# ------- ADD BOARD DOMAIN END -----------
if sys.argv[1] == 'removeboarddomain':
# ------- REMOVE BOARD DOMAIN START -----------
if arguments < 3:
print("Usage: python3 api.py removeboarddomain BOARDID DOMAIN")
exit(1)
boardid = sys.argv[2]
domain = sys.argv[3]
remove_domain_url = wekanurl + apiboards + boardid + s + 'domains' + s + domain
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
body = requests.delete(remove_domain_url, headers=headers)
print("=== REMOVE BOARD DOMAIN ===\n")
print(body.text)
# ------- REMOVE BOARD DOMAIN END -----------
if sys.argv[1] in ('setcardmembers', 'setcardassignees'):
# ------- SET CARD MEMBERS / ASSIGNEES START -----------
if arguments < 4:
@@ -1488,3 +1705,337 @@ if arguments >= 1:
# ------- SET CARD SETTING END -----------
# ------- BOARD MEMBER / CARD FIELD API ENDPOINTS END -----------
# ------- RULES API ENDPOINTS START -----------
#
# Board automation Rules: add / edit / remove / list via REST.
# A rule links a trigger (event or schedule) to an action. The API embeds the
# full trigger and action inline so a rule is self-contained.
#
# python3 api.py listrules BOARDID
# python3 api.py getrule BOARDID RULEID
# python3 api.py addrule BOARDID 'TITLE' 'TRIGGER_JSON' 'ACTION_JSON'
# python3 api.py editrule BOARDID RULEID 'PATCH_JSON' # {"title":..,"trigger":{..},"action":{..}}
# python3 api.py removerule BOARDID RULEID
#
# Example (move a card to the top of its list whenever it is created):
# python3 api.py addrule BOARDID 'On create -> top' \
# '{"activityType":"createCard","listName":"*","swimlaneName":"*","cardTitle":"*","userId":"*"}' \
# '{"actionType":"moveCardToTop","listName":"*","swimlaneName":"*"}'
#
# Example (archive cards that have sat in "Completed" for 90 days; the scheduled
# scan runs this daily):
# python3 api.py addrule BOARDID 'Archive after 90 days' \
# '{"activityType":"scheduledTrigger","scheduleKind":"aging","listName":"Completed","days":90,"atTime":"03:00"}' \
# '{"actionType":"archive"}'
if arguments >= 2 and sys.argv[1] == 'listrules':
boardid = sys.argv[2]
url = wekanurl + apiboards + boardid + s + 'rules'
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
body = requests.get(url, headers=headers)
print(body.text)
if arguments >= 3 and sys.argv[1] == 'getrule':
boardid = sys.argv[2]
ruleid = sys.argv[3]
url = wekanurl + apiboards + boardid + s + 'rules' + s + ruleid
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
body = requests.get(url, headers=headers)
print(body.text)
if arguments >= 3 and sys.argv[1] == 'removerule':
boardid = sys.argv[2]
ruleid = sys.argv[3]
url = wekanurl + apiboards + boardid + s + 'rules' + s + ruleid
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
body = requests.delete(url, headers=headers)
print(body.text)
if arguments >= 5 and sys.argv[1] == 'addrule':
boardid = sys.argv[2]
title = sys.argv[3]
trigger = json.loads(sys.argv[4])
action = json.loads(sys.argv[5])
url = wekanurl + apiboards + boardid + s + 'rules'
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
body = requests.post(url, json={'title': title, 'trigger': trigger, 'action': action}, headers=headers)
print(body.text)
if arguments >= 4 and sys.argv[1] == 'editrule':
boardid = sys.argv[2]
ruleid = sys.argv[3]
patch = json.loads(sys.argv[4])
url = wekanurl + apiboards + boardid + s + 'rules' + s + ruleid
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
body = requests.put(url, json=patch, headers=headers)
print(body.text)
# ------- RULES API ENDPOINTS END -----------
# ------- CARD DEPENDENCIES ("RED STRINGS") API START -----------
#
# Typed card-to-card dependencies, visualized on the board as colored connection
# lines (SAFe PI-planning "Red Strings"). Each line has:
# type : related-to | blocks | is-blocked-by | fixes | is-fixed-by
# color : any CSS color, e.g. "#eb144c"
# icon : a FontAwesome 4.7 icon name without the "fa-" prefix, e.g. "link"
#
# python3 api.py listdependencies BOARDID
# python3 api.py listcarddependencies BOARDID CARDID
# python3 api.py adddependency BOARDID CARDID TARGETCARDID [TYPE] [COLOR] [ICON]
# python3 api.py editdependency BOARDID CARDID TARGETCARDID 'PATCH_JSON' # {"type":..,"color":..,"icon":..}
# python3 api.py removedependency BOARDID CARDID TARGETCARDID
#
# Example (card A "blocks" card B, drawn as a thick blue arrow):
# python3 api.py adddependency BOARDID CARDA CARDB blocks '#2196f3' lock
if arguments >= 2 and sys.argv[1] == 'listdependencies':
boardid = sys.argv[2]
url = wekanurl + apiboards + boardid + s + 'dependencies'
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
body = requests.get(url, headers=headers)
print(body.text)
if arguments >= 3 and sys.argv[1] == 'listcarddependencies':
boardid = sys.argv[2]
cardid = sys.argv[3]
url = wekanurl + apiboards + boardid + s + 'cards' + s + cardid + s + 'dependencies'
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
body = requests.get(url, headers=headers)
print(body.text)
if arguments >= 4 and sys.argv[1] == 'adddependency':
boardid = sys.argv[2]
cardid = sys.argv[3]
targetcardid = sys.argv[4]
payload = {'cardId': targetcardid}
if arguments >= 5:
payload['type'] = sys.argv[5]
if arguments >= 6:
payload['color'] = sys.argv[6]
if arguments >= 7:
payload['icon'] = sys.argv[7]
url = wekanurl + apiboards + boardid + s + 'cards' + s + cardid + s + 'dependencies'
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
body = requests.post(url, json=payload, headers=headers)
print(body.text)
if arguments >= 5 and sys.argv[1] == 'editdependency':
boardid = sys.argv[2]
cardid = sys.argv[3]
targetcardid = sys.argv[4]
patch = json.loads(sys.argv[5])
url = wekanurl + apiboards + boardid + s + 'cards' + s + cardid + s + 'dependencies' + s + targetcardid
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
body = requests.put(url, json=patch, headers=headers)
print(body.text)
if arguments >= 4 and sys.argv[1] == 'removedependency':
boardid = sys.argv[2]
cardid = sys.argv[3]
targetcardid = sys.argv[4]
url = wekanurl + apiboards + boardid + s + 'cards' + s + cardid + s + 'dependencies' + s + targetcardid
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
body = requests.delete(url, headers=headers)
print(body.text)
# ------- CARD DEPENDENCIES ("RED STRINGS") API END -----------
# ------- CARD STICKERS / LOCATIONS / COMPLETE START -----------
#
# These card fields are set through the card edit endpoint
# (PUT /api/boards/:boardId/lists/:listId/cards/:cardId):
#
# python3 api.py setcardcomplete BOARDID LISTID CARDID true
# python3 api.py setcardstickers BOARDID LISTID CARDID '[{"icon":"taco-cool"}]'
# python3 api.py setcardlocations BOARDID LISTID CARDID \
# '[{"name":"HQ","address":"Helsinki","latitude":60.17,"longitude":24.94}]'
if arguments >= 5 and sys.argv[1] == 'setcardcomplete':
boardid = sys.argv[2]
listid = sys.argv[3]
cardid = sys.argv[4]
dueComplete = sys.argv[5]
url = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
body = requests.put(url, json={'dueComplete': dueComplete}, headers=headers)
print(body.text)
if arguments >= 5 and sys.argv[1] == 'setcardstickers':
boardid = sys.argv[2]
listid = sys.argv[3]
cardid = sys.argv[4]
stickers = json.loads(sys.argv[5])
url = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
body = requests.put(url, json={'stickers': stickers}, headers=headers)
print(body.text)
if arguments >= 5 and sys.argv[1] == 'setcardlocations':
boardid = sys.argv[2]
listid = sys.argv[3]
cardid = sys.argv[4]
locations = json.loads(sys.argv[5])
url = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
body = requests.put(url, json={'locations': locations}, headers=headers)
print(body.text)
# ------- CARD STICKERS / LOCATIONS / COMPLETE END -----------
# ------- BOARD BACKGROUND IMAGE UPLOAD / DOWNLOAD START -----------
#
# Upload/download a board background image. This is the background counterpart
# of the "upload attachment to board" API; the upload stores a board-level
# attachment using the current Admin Panel / Attachments / Default Storage and
# sets it as the board's active background (board admin required).
#
# python3 api.py uploadbackground BOARDID FILEPATH
# python3 api.py downloadbackground BOARDID OUTPUTPATH
if arguments >= 3 and sys.argv[1] == 'uploadbackground':
boardid = sys.argv[2]
filepath = sys.argv[3]
import base64, os, mimetypes
try:
with open(filepath, 'rb') as f:
base64_data = base64.b64encode(f.read()).decode('utf-8')
except Exception as e:
print(f"Error reading file: {e}")
exit(1)
upload_data = {
'boardId': boardid,
'fileData': base64_data,
'fileName': os.path.basename(filepath),
'fileType': mimetypes.guess_type(filepath)[0] or 'image/png',
}
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
url = wekanurl + 'api/attachment/upload-background'
response = requests.post(url, headers=headers, json=upload_data)
print(response.text)
if arguments >= 3 and sys.argv[1] == 'downloadbackground':
boardid = sys.argv[2]
outputpath = sys.argv[3]
import base64
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
url = wekanurl + f'api/attachment/download-background/{boardid}'
response = requests.get(url, headers=headers)
result = response.json()
if result.get('success'):
with open(outputpath, 'wb') as f:
f.write(base64.b64decode(result.get('base64Data')))
print(f"Background saved to: {outputpath}")
print(f"Original filename: {result.get('fileName')}")
print(f"Storage: {result.get('storageBackend')}")
else:
print(f"Download failed: {result}")
# ------- BOARD BACKGROUND IMAGE UPLOAD / DOWNLOAD END -----------
# ------- IMPORT BOARD / MIGRATE FROM ANOTHER WEKAN START -----------
#
# Import a whole board WITH its workflows (rules/triggers/actions) and other data
# from a WeKan board export, or migrate ALL boards from another WeKan instance
# over the REST API.
#
# python3 api.py importboard EXPORT.json # import one exported board (with rules)
# python3 api.py migratefromwekan REMOTE_URL REMOTE_USER REMOTE_PASS # import all boards+workflows+rules
if arguments >= 2 and sys.argv[1] == 'importboard':
filepath = sys.argv[2]
with open(filepath) as f:
board = json.load(f)
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
response = requests.post(wekanurl + 'api/boards/import', headers=headers, json={'board': board})
print(response.text)
if arguments >= 4 and sys.argv[1] == 'migratefromwekan':
remote = sys.argv[2].rstrip('/') + '/'
remote_user = sys.argv[3]
remote_pass = sys.argv[4]
# 1) Log in to the remote WeKan to get its token + user id.
rlogin = requests.post(remote + 'users/login', json={'username': remote_user, 'password': remote_pass}).json()
rtoken = rlogin['token']
ruid = rlogin['id']
rheaders = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(rtoken)}
lheaders = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
# 2) List the remote user's boards.
boards = requests.get(remote + 'api/users/{}/boards'.format(ruid), headers=rheaders).json()
print('Found {} board(s) on remote WeKan.'.format(len(boards)))
# 3) Export each remote board (full JSON incl. rules/workflows) and import locally.
for b in boards:
bid = b.get('_id') or b.get('id')
if not bid:
continue
export = requests.get(remote + 'api/boards/{}/export'.format(bid), headers=rheaders).json()
resp = requests.post(wekanurl + 'api/boards/import', headers=lheaders, json={'board': export})
print('{}: HTTP {} {}'.format(b.get('title', bid), resp.status_code, resp.text[:160]))
# ------- IMPORT BOARD / MIGRATE FROM ANOTHER WEKAN END -----------
# ------- EXPORT BOARD TO PDF START -----------
#
# python3 api.py exportboardpdf BOARDID OUTPUT.pdf
if arguments >= 3 and sys.argv[1] == 'exportboardpdf':
boardid = sys.argv[2]
outputpath = sys.argv[3]
headers = {'Authorization': 'Bearer {}'.format(apikey)}
url = wekanurl + apiboards + boardid + s + 'exportPDF'
response = requests.get(url, headers=headers)
with open(outputpath, 'wb') as f:
f.write(response.content)
print('Board PDF saved to: {}'.format(outputpath))
# ------- EXPORT BOARD TO PDF END -----------
# ------- IMPORT/EXPORT TO OTHER TOOLS START -----------
#
# Generalized import from another tool's export, and export to another tool's
# JSON shape. Sources/formats: trello, wekan, csv, jira, kanboard, excel, deck,
# openproject, github, gitlab, gitea, forgejo.
#
# python3 api.py importboardfrom SOURCE EXPORT.json # e.g. github issues.json (SOURCE = deck/openproject/github/gitlab/gitea/forgejo/asana/zenkit/trello/jira/kanboard/csv/excel/wekan)
# python3 api.py exportboardformat BOARDID FORMAT OUTPUT.json # FORMAT = trello/jira/deck/openproject/github/gitlab/gitea/forgejo/asana/zenkit/kanboard
if arguments >= 3 and sys.argv[1] == 'importboardfrom':
source = sys.argv[2]
filepath = sys.argv[3]
with open(filepath) as f:
board = json.load(f)
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
url = wekanurl + apiboards + 'import/' + source
response = requests.post(url, headers=headers, json={'board': board})
print(response.text)
if arguments >= 4 and sys.argv[1] == 'exportboardformat':
boardid = sys.argv[2]
fmt = sys.argv[3]
outputpath = sys.argv[4]
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
url = wekanurl + apiboards + boardid + s + 'export' + s + fmt
response = requests.get(url, headers=headers)
with open(outputpath, 'w') as f:
f.write(response.text)
print('Board exported ({}) to: {}'.format(fmt, outputpath))
if arguments >= 5 and sys.argv[1] == 'importics':
# Usage: python3 api.py importics BOARDID SWIMLANEID LISTID CALENDAR.ics
# Imports an iCalendar (.ics) file into the board: one card is created per
# VEVENT, with startAt (DTSTART) and dueAt (DTEND, or DTSTART when there is
# no DTEND), so the events appear on the Calendar / Gantt views (issue #6323).
boardid = sys.argv[2]
swimlaneid = sys.argv[3]
listid = sys.argv[4]
icspath = sys.argv[5]
with open(icspath) as f:
ics_text = f.read()
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
url = wekanurl + apiboards + boardid + s + 'swimlanes' + s + swimlaneid + s + l + s + listid + s + 'ics'
response = requests.post(url, headers=headers, json={'ics': ics_text})
# Response: { "created": <n>, "cardIds": [ ... ] }
print(response.text)
# ------- IMPORT/EXPORT TO OTHER TOOLS END -----------
+11
View File
@@ -14,6 +14,17 @@ try {
}
} catch (_) {}
// Expose Meteor on window. Under the Meteor 3.5 + rspack build, bare `Meteor`
// references in app code are rewritten by rspack's ProvidePlugin into per-module
// imports, so `Meteor` is NOT placed on the browser `window` the way the classic
// Meteor linker did. Re-expose it so the browser console and the Playwright e2e
// tests (which call Meteor.loginWithToken / Meteor.userId / Meteor.subscribe etc.
// via page.evaluate in window scope) can reach it. This matches the long-standing
// classic-Meteor behaviour where `Meteor` is a global.
if (typeof window !== 'undefined') {
window.Meteor = Meteor;
}
// Fix bug in jam:offline 0.4.1: s?.message.includes() crashes when s.message is
// undefined (e.g. during WebSocket reconnect errors that don't carry a .message).
// Wrap _debug so the optional chain is complete: s?.message?.includes().
@@ -130,6 +130,9 @@ template(name="activity")
if($eq activity.activityType 'a-endAt')
| {{{_ 'activity-endDate' (sanitize endDate) cardLink}}}.
if($eq activity.activityType 'a-changedTitle')
| {{{_ 'activity-changedTitle' (sanitize activity.value) cardLink}}}.
//- customField activity ------------------------------------------------
if($eq activity.activityType 'createCustomField')
| {{_ 'activity-customfield-created' customField}}.
+46
View File
@@ -135,3 +135,49 @@
font-size: 0.8em;
color: #999;
}
/* Threaded replies (issue #5907): indent replies under their parent. */
.comments .comment.is-reply {
margin-inline-start: 24px;
border-inline-start: 2px solid #e0e0e0;
padding-inline-start: 8px;
}
.comments .comment .comment-desc .comment-in-reply-to {
font-size: 0.8em;
color: #999;
border-inline-start: 2px solid #ccc;
padding-inline-start: 6px;
margin: 3px 0;
display: block;
}
.comments .comment .comment-desc .comment-in-reply-to .comment-in-reply-to-label {
font-weight: 700;
margin-inline-end: 4px;
}
.comments .comment .comment-desc .comment-in-reply-to .comment-in-reply-to-text {
font-style: italic;
}
/* "In reply to" banner above the new-comment form. */
.new-comment .comment-reply-banner {
background: #f4f4f4;
border-inline-start: 2px solid #ccc;
padding: 4px 6px;
margin: 0 4px 6px 0;
font-size: 0.85em;
color: #666;
display: flex;
align-items: center;
gap: 6px;
}
.new-comment .comment-reply-banner .comment-reply-banner-label {
font-weight: 700;
}
.new-comment .comment-reply-banner .comment-reply-banner-text {
flex: 1;
font-style: italic;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.new-comment .comment-reply-banner a {
cursor: pointer;
}
+20 -4
View File
@@ -2,6 +2,14 @@ template(name="commentForm")
.new-comment.js-new-comment(
class="{{#if commentFormIsOpen}}is-open{{/if}}")
+userAvatar(userId=currentUser._id noRemove=true)
if replyToComment
.comment-reply-banner.js-reply-banner
span.comment-reply-banner-label {{_ 'comment-in-reply-to'}}:
span.comment-reply-banner-text
+viewer
= replyToComment.text
a.js-cancel-reply(title="{{_ 'close' }}")
i.fa.fa-times-thin
form.js-new-comment-form
+editor(class="js-new-comment-input")
| {{getUnsavedValue 'cardComment' currentCard._id}}
@@ -14,12 +22,19 @@ template(name="comments")
+comment(commentData)
template(name="comment")
.comment
.comment(class="{{#if parentId}}is-reply{{/if}}")
+userAvatar(userId=userId)
p.comment-desc
span.comment-member
+memberName(user=user)
if parentComment
.comment-in-reply-to
span.comment-in-reply-to-label {{_ 'comment-in-reply-to'}}:
span.comment-in-reply-to-text
+viewer
= parentComment.text
+inlinedForm(classNames='js-edit-comment')
+editor(autofocus=true)
= text
@@ -33,9 +48,10 @@ template(name="comment")
= text
+commentReactions(reactions=reactions commentId=_id)
span(title=createdAt).comment-meta {{ displayDate createdAt }}
if($eq currentUser._id userId)
+editOrDeleteComment
else if currentUser.isBoardAdmin
if currentUser.isBoardMember
= ' - '
a.js-reply-comment {{_ "comment-reply"}}
if canEditThisComment
+editOrDeleteComment
template(name="editOrDeleteComment")
+53 -2
View File
@@ -1,11 +1,14 @@
import { ReactiveCache } from '/imports/reactiveCache';
import CardComments from '/models/cardComments';
import CardComments, { canEditComment } from '/models/cardComments';
import { UnsavedEdits } from '/client/lib/unsavedEdits';
import { EscapeActions } from '/client/lib/escapeActions';
import { Utils } from '/client/lib/utils';
import autosize from 'autosize';
const commentFormIsOpen = new ReactiveVar(false);
// _id of the comment currently being replied to (threaded replies, issue #5907).
// Empty string means a new top-level comment.
const replyToCommentId = new ReactiveVar('');
Template.commentForm.onDestroyed(function () {
commentFormIsOpen.set(false);
@@ -16,6 +19,12 @@ Template.commentForm.helpers({
commentFormIsOpen() {
return commentFormIsOpen.get();
},
// The comment being replied to, if any, so the form can show an
// "In reply to ..." banner with a way to cancel.
replyToComment() {
const id = replyToCommentId.get();
return id ? ReactiveCache.getCardComment(id) : undefined;
},
});
Template.commentForm.events({
@@ -32,11 +41,14 @@ Template.commentForm.events({
boardId = card.linkedId;
}
if (text) {
const parentId = replyToCommentId.get();
CardComments.insert({
text,
boardId,
cardId,
parentId: parentId || '',
});
replyToCommentId.set('');
resetCommentInput(input);
Tracker.flush();
autosize.update(input);
@@ -44,6 +56,10 @@ Template.commentForm.events({
}
evt.preventDefault();
},
'click .js-cancel-reply'(evt) {
evt.preventDefault();
replyToCommentId.set('');
},
// Pressing Ctrl+Enter should submit the form
'keydown form textarea'(evt, tpl) {
if (evt.keyCode === 13 && (evt.metaKey || evt.ctrlKey)) {
@@ -60,10 +76,45 @@ Template.comments.helpers({
},
});
Template.comment.helpers({
// Whether the current user may edit/delete this comment, honouring the
// board's restrictCommentEditing setting (issue #5906). Mirrors the
// server-side enforcement so the UI hides buttons the server would reject.
canEditThisComment() {
const user = ReactiveCache.getCurrentUser();
if (!user) return false;
const isAuthor = user._id === this.userId;
const board = ReactiveCache.getBoard(this.boardId);
const isBoardAdmin = !!board && board.hasAdmin(user._id);
const restrictCommentEditing = !!board && !!board.restrictCommentEditing;
return canEditComment({ isAuthor, isBoardAdmin, restrictCommentEditing });
},
// The parent comment this one replies to, or undefined.
parentComment() {
return this.parentId ? ReactiveCache.getCardComment(this.parentId) : undefined;
},
});
Template.comment.events({
'click .js-reply-comment'(evt) {
evt.preventDefault();
replyToCommentId.set(this._id);
// Open and focus the new-comment form.
const input = $('.js-new-comment-input');
input.click();
Tracker.afterFlush(() => {
$('.js-new-comment-input').focus();
});
},
'click .js-delete-comment': Popup.afterConfirm('deleteComment', function () {
const commentId = this._id;
CardComments.remove(commentId);
// #3252: only remove if the doc is still in the local cache. Under heavy
// archive/delete churn the comment can already be evicted from Minimongo,
// and CardComments.remove() of a missing _id throws "Removed nonexistent
// document" on the client.
if (commentId && CardComments.findOne(commentId)) {
CardComments.remove(commentId);
}
Popup.back();
}),
'submit .js-edit-comment'(evt, tpl) {
+1 -1
View File
@@ -9,7 +9,7 @@ Template.archivedBoards.onCreated(function () {
Template.archivedBoards.helpers({
isBoardAdmin() {
return ReactiveCache.getCurrentUser().isBoardAdmin();
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
},
archivedBoards() {
+6 -6
View File
@@ -107,17 +107,17 @@
overflow-y: scroll !important;
}
/* Mobile - make all text 2x bigger inside #content by default (icons stay same size) */
/* Mobile - keep #content text at normal size (reverted #6426 oversizing) */
@media screen and (max-width: 800px),
screen and (max-device-width: 800px),
screen and (-webkit-min-device-pixel-ratio: 2) and (max-width: 800px),
screen and (max-width: 800px) and (orientation: portrait),
screen and (max-width: 800px) and (orientation: landscape) {
#content {
font-size: 2em !important; /* 2x bigger base font size for content area */
font-size: 1em !important; /* Normal size (reverted #6426 oversizing) */
}
/* Make all text elements 2x bigger */
/* Keep all text elements at normal size */
#content h1, #content h2, #content h3, #content h4, #content h5, #content h6,
#content p, #content span, #content div, #content a, #content button,
#content .minicard, #content .list-header-name, #content .board-header-btn,
@@ -186,7 +186,7 @@
/* Fallback for iPhone devices using JavaScript detection */
.iphone-device #content {
font-size: 2em !important; /* 2x bigger base font size for content area */
font-size: 1em !important; /* Normal size (reverted #6426 oversizing) */
}
.iphone-device #content h1, .iphone-device #content h2, .iphone-device #content h3, .iphone-device #content h4, .iphone-device #content h5, .iphone-device #content h6,
@@ -231,9 +231,9 @@
font-size: 1em !important; /* Keep original icon size */
}
/* Mobile iPhone: scale card details text and icons to 2x */
/* Mobile iPhone: keep card details text/icons at normal size (reverted #6426 oversizing) */
body.mobile-mode.iphone-device .card-details {
font-size: 2em !important;
font-size: 1em !important;
}
body.mobile-mode.iphone-device .card-details .fa,
body.mobile-mode.iphone-device .card-details .icon,
+5 -2
View File
@@ -24,13 +24,14 @@ template(name="boardBody")
.debug-info(style="position: fixed; top: 0; inset-inline-start: 0; background: rgba(0,0,0,0.8); color: white; padding: 10px; z-index: 9999; font-size: 12px;")
| {{_ 'board'}}: {{currentBoard.title}} | {{_ 'view'}}: {{boardView}} | {{_ 'has-swimlanes'}}: {{hasSwimlanes}} | {{_ 'swimlanes'}}: {{currentBoard.swimlanes.length}}
.board-wrapper(class=currentBoard.colorClass class="{{#if isMiniScreen}}mobile-view{{/if}}")
.board-canvas.js-swimlanes(
class="{{#if hasSwimlanes}}dragscroll{{/if}}"
.board-canvas.js-swimlanes.dragscroll(
class="{{#if Sidebar.isOpen}}is-sibling-sidebar-open{{/if}}"
class="{{#if MultiSelection.isActive}}is-multiselection-active{{/if}}"
class="{{#if draggingActive}}is-dragging-active{{/if}}"
class="{{#unless isVerticalScrollbars}}no-scrollbars{{/unless}}"
class="{{#if isMiniScreen}}mobile-view{{/if}}")
if currentBoard.showDependencies
+dependencyOverlay
if showOverlay
.board-overlay
if currentBoard.isTemplatesBoard
@@ -49,6 +50,8 @@ template(name="boardBody")
+calendarView
else if isViewGantt
+ganttView
else if isViewTable
+tableView
else
// Default view - show swimlanes if they exist, otherwise show lists
if hasSwimlanes
+24
View File
@@ -5,6 +5,7 @@ import dragscroll from '@wekanteam/dragscroll';
import '/client/lib/dragscrollTouch';
import { boardConverter } from '/client/lib/boardConverter';
import { formatDateByUserPreference } from '/imports/lib/dateUtils';
import { toFullCalendarFirstDay } from '/client/lib/calendarFirstDay';
import Swimlanes from '/models/swimlanes';
import Lists from '/models/lists';
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
@@ -674,6 +675,19 @@ Template.boardBody.helpers({
return boardView === 'board-view-gantt';
},
isViewTable() {
const currentUser = ReactiveCache.getCurrentUser();
let boardView;
if (currentUser) {
boardView = (currentUser.profile || {}).boardView;
} else {
boardView = window.localStorage.getItem('boardView');
}
return boardView === 'board-view-table';
},
hasSwimlanes() {
const currentBoard = Utils.getCurrentBoard();
if (!currentBoard) {
@@ -929,9 +943,19 @@ Template.calendarView.helpers({
return translated && translated !== key ? translated : fallback;
};
// #5521: respect the user's "start day of week" setting instead of the
// FullCalendar locale default. getStartDayOfWeek() returns a zero-based day
// index (0=Sunday..6=Saturday), the same convention FullCalendar's firstDay
// uses; toFullCalendarFirstDay() validates it and falls back to Monday (1).
const currentUser = ReactiveCache.getCurrentUser();
const firstDay = toFullCalendarFirstDay(
currentUser ? currentUser.getStartDayOfWeek() : 1,
);
return {
id: 'calendar-view',
initialView: 'dayGridMonth',
firstDay,
editable: true,
selectable: true,
weekNumbers: true,
+17
View File
@@ -937,3 +937,20 @@
.desktop-mode .board-header .board-header-btn i {
font-size: clamp(12px, 2.5vw, 16px) !important;
}
/* #6419: make the board-header action buttons touch-friendly by VIEWPORT WIDTH,
not only when the mobile-mode class is toggled. The measured default at a phone
width was 28px tall / 13px font (too small to tap reliably). Enlarge vertically
+ bump the icon/font; width is left to flex so the existing horizontal layout
is unchanged. Applies on real phones whether or not mobile-mode auto-applied. */
@media screen and (max-width: 800px) {
.board-header-btns .board-header-btn {
min-height: 40px;
padding-top: 8px;
padding-bottom: 8px;
font-size: 15px;
}
.board-header-btns .board-header-btn i.fa {
font-size: 16px;
}
}
+21 -14
View File
@@ -37,7 +37,7 @@ template(name="boardHeaderBar")
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
if showStarCounter
span.board-star-counter {{currentBoard.stars}}
a.board-header-btn(title="{{_ 'sort-cards'}}" class="{{#if isSortActive }}emphasis{{else}} js-sort-cards {{/if}}")
a.board-header-btn(title="{{_ 'sort-cards'}}" class="js-sort-cards {{#if isSortActive }}emphasis{{/if}}")
i.fa.fa-sort
span {{#if isSortActive }}{{_ 'sort-is-on'}}{{else}}{{_ 'sort-cards'}}{{/if}}
if isSortActive
@@ -51,6 +51,8 @@ template(name="boardHeaderBar")
span {{_ 'log-in'}}
.board-header-btns.center
if currentUser
+boardButtons
.board-header-btns.right
if currentBoard
@@ -76,7 +78,7 @@ template(name="boardHeaderBar")
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}} {{_ 'starred-boards-description'}}")
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
a.board-header-btn(title="{{_ 'sort-cards'}}" class="{{#if isSortActive }}emphasis{{else}} js-sort-cards {{/if}}")
a.board-header-btn(title="{{_ 'sort-cards'}}" class="js-sort-cards {{#if isSortActive }}emphasis{{/if}}")
i.fa.fa-sort
span {{#if isSortActive }}{{_ 'sort-is-on'}}{{else}}{{_ 'sort-cards'}}{{/if}}
if isSortActive
@@ -125,6 +127,8 @@ template(name="boardHeaderBar")
i.fa.fa-calendar
if $eq boardView 'board-view-gantt'
i.fa.fa-bar-chart
if $eq boardView 'board-view-table'
i.fa.fa-table
span
if $eq boardView 'board-view-swimlanes'
| {{_ 'swimlanes'}}
@@ -134,6 +138,14 @@ template(name="boardHeaderBar")
| {{_ 'calendar'}}
if $eq boardView 'board-view-gantt'
| {{_ 'gantt'}}
if $eq boardView 'board-view-table'
| {{_ 'board-view-table'}}
a.board-header-btn.js-toggle-dependencies(
title="{{#if currentBoard.showDependencies}}{{_ 'hide-dependencies'}}{{else}}{{_ 'show-dependencies'}}{{/if}}"
class="{{#if currentBoard.showDependencies}}emphasis{{/if}}")
i.fa.fa-link
span {{#if currentBoard.showDependencies}}{{_ 'hide-dependencies'}}{{else}}{{_ 'show-dependencies'}}{{/if}}
if canModifyBoard
a.board-header-btn.js-multiselection-activate(
title="{{#if MultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}"
@@ -228,6 +240,13 @@ template(name="boardChangeViewPopup")
| {{_ 'board-view-gantt'}}
if $eq Utils.boardView "board-view-gantt"
i.fa.fa-check
li
with "board-view-table"
a.js-open-table-view
i.fa.fa-table
| {{_ 'board-view-table'}}
if $eq Utils.boardView "board-view-table"
i.fa.fa-check
template(name="createBoard")
form
@@ -247,9 +266,6 @@ template(name="createBoard")
= " "
| {{{_ 'board-private-info'}}}
a.js-change-visibility {{_ 'change'}}.
a.flex.js-toggle-add-template-container
.materialCheckBox#add-template-container
span {{_ 'add-template-container'}}
input.primary.wide(type="submit" value="{{_ 'create'}}")
span.quiet
| {{_ 'or'}}
@@ -276,9 +292,6 @@ template(name="createBoardPopup")
= " "
| {{{_ 'board-private-info'}}}
a.js-change-visibility {{_ 'change'}}.
a.flex.js-toggle-add-template-container
.materialCheckBox#add-template-container
span {{_ 'add-template-container'}}
input.primary.wide(type="submit" value="{{_ 'create'}}")
span.quiet
| {{_ 'or'}}
@@ -305,9 +318,6 @@ template(name="headerBarCreateBoardPopup")
= " "
| {{{_ 'board-private-info'}}}
a.js-change-visibility {{_ 'change'}}.
a.flex.js-toggle-add-template-container
.materialCheckBox#add-template-container
span {{_ 'add-template-container'}}
input.primary.wide(type="submit" value="{{_ 'create'}}")
span.quiet
| {{_ 'or'}}
@@ -335,9 +345,6 @@ template(name="createTemplateContainerPopup")
= " "
| {{{_ 'board-private-info'}}}
a.js-change-visibility {{_ 'change'}}.
a.flex.js-toggle-add-template-container
.materialCheckBox#add-template-container
span {{_ 'add-template-container'}}
input.primary.wide(type="submit" value="{{_ 'create'}}")
span.quiet
| {{_ 'or'}}
+53 -19
View File
@@ -17,6 +17,21 @@ const UPCLS = 'fa-sort-up';
*/
const sortCardsBy = new ReactiveVar('');
// Persist the card sort so it survives a page reload. Session is in-memory and
// is reset on reload, which is why "sort by due date" kept reverting to the
// default. See https://github.com/wekan/wekan/issues/5886
const CARDS_SORT_BY_STORAGE_KEY = 'wekan-cards-sortBy';
function setCardsSortBy(sortBy) {
Session.set('sortBy', sortBy);
try {
if (sortBy) {
window.localStorage.setItem(CARDS_SORT_BY_STORAGE_KEY, JSON.stringify(sortBy));
} else {
window.localStorage.removeItem(CARDS_SORT_BY_STORAGE_KEY);
}
} catch (e) {}
}
Template.boardChangeTitlePopup.events({
async submit(event, templateInstance) {
event.preventDefault();
@@ -41,6 +56,16 @@ Template.boardChangeTitlePopup.events({
Template.boardHeaderBar.onCreated(function () {
Meteor.subscribe('tableVisibilityModeSettings');
// Restore the persisted card sort after a reload (Session is in-memory), so
// both the sorting and the sort icon are remembered. See #5886.
if (!Session.get('sortBy')) {
try {
const stored = window.localStorage.getItem(CARDS_SORT_BY_STORAGE_KEY);
if (stored) {
Session.set('sortBy', JSON.parse(stored));
}
} catch (e) {}
}
});
Template.boardHeaderBar.helpers({
@@ -171,7 +196,7 @@ Template.boardHeaderBar.events({
Filter.reset();
},
'click .js-sort-reset'() {
Session.set('sortBy', '');
setCardsSortBy('');
},
'click .js-open-search-view'() {
const sidebar = getSidebarInstance();
@@ -181,6 +206,12 @@ Template.boardHeaderBar.events({
console.warn('Sidebar not available for setView');
}
},
'click .js-toggle-dependencies'() {
const currentBoard = Utils.getCurrentBoard();
if (currentBoard) {
currentBoard.setShowDependencies(!currentBoard.showDependencies);
}
},
'click .js-multiselection-activate'() {
const currentCard = Utils.getCurrentCardId();
MultiSelection.activate();
@@ -214,6 +245,10 @@ Template.boardChangeViewPopup.events({
Utils.setBoardView('board-view-gantt');
Popup.back();
},
'click .js-open-table-view'() {
Utils.setBoardView('board-view-table');
Popup.back();
},
});
// Shared setup for all create board popups
@@ -221,6 +256,9 @@ function setupCreateBoardState(tpl) {
tpl.visibilityMenuIsOpen = new ReactiveVar(false);
tpl.visibility = new ReactiveVar('private');
tpl.boardId = new ReactiveVar('');
// #5850: default to creating a regular board; the dedicated
// createTemplateContainerPopup sets this true in its onRendered.
Session.set('createBoardAsTemplate', false);
Meteor.subscribe('tableVisibilityModeSettings');
}
@@ -246,7 +284,11 @@ async function createBoardSubmit(tpl, event) {
const title = tpl.find('.js-new-board-title').value;
const slug = getSlug(title) || 'board';
const addTemplateContainer = $('#add-template-container.is-checked').length > 0;
// #5850: template boards are created via the dedicated "Add Template Board"
// flow (createTemplateContainerPopup), signalled by this Session flag, rather
// than a checkbox on the generic Create Board popup. Consume it immediately.
const addTemplateContainer = Session.get('createBoardAsTemplate') === true;
Session.set('createBoardAsTemplate', false);
if (addTemplateContainer) {
tpl.boardId.set(
await Meteor.callAsync('createBoardWithInitialSwimlanes', {
@@ -256,9 +298,9 @@ async function createBoardSubmit(tpl, event) {
type: 'template-container',
migrationVersion: 1,
swimlanes: [
{ title: 'Card Templates', sort: 1, type: 'template-container' },
{ title: 'List Templates', sort: 2, type: 'template-container' },
{ title: 'Board Templates', sort: 3, type: 'template-container' },
{ title: 'Card Templates', sort: 1, type: 'template-container', role: 'card' },
{ title: 'List Templates', sort: 2, type: 'template-container', role: 'list' },
{ title: 'Board Templates', sort: 3, type: 'template-container', role: 'board' },
],
}),
);
@@ -309,15 +351,11 @@ function createBoardEvents() {
'click .js-change-visibility'(event, tpl) {
tpl.visibilityMenuIsOpen.set(!tpl.visibilityMenuIsOpen.get());
},
'click .js-import': Popup.open('boardImportBoard'),
async 'submit'(event, tpl) {
await createBoardSubmit(tpl, event);
},
'click .js-import-board': Popup.open('chooseBoardSource'),
'click .js-board-template': Popup.open('searchElement'),
'click .js-toggle-add-template-container'() {
$('#add-template-container').toggleClass('is-checked');
},
};
}
@@ -345,8 +383,8 @@ Template.createTemplateContainerPopup.onCreated(function () {
});
Template.createTemplateContainerPopup.onRendered(function () {
// Always pre-check the template container checkbox for this popup
$('#add-template-container').addClass('is-checked');
// #5850: this dedicated popup always creates a template board.
Session.set('createBoardAsTemplate', true);
});
Template.createTemplateContainerPopup.helpers(createBoardHelpers());
@@ -368,7 +406,6 @@ Template.headerBarCreateBoardPopup.events({
'click .js-change-visibility'(event, tpl) {
tpl.visibilityMenuIsOpen.set(!tpl.visibilityMenuIsOpen.get());
},
'click .js-import': Popup.open('boardImportBoard'),
async submit(event, tpl) {
await createBoardSubmit(tpl, event);
// Immediately star boards created with the headerbar popup.
@@ -376,9 +413,6 @@ Template.headerBarCreateBoardPopup.events({
},
'click .js-import-board': Popup.open('chooseBoardSource'),
'click .js-board-template': Popup.open('searchElement'),
'click .js-toggle-add-template-container'() {
$('#add-template-container').toggleClass('is-checked');
},
});
Template.boardVisibilityList.helpers({
@@ -520,7 +554,7 @@ Template.cardsSortPopup.events({
const sortBy = {
dueAt: 1,
};
Session.set('sortBy', sortBy);
setCardsSortBy(sortBy);
sortCardsBy.set(TAPi18n.__('due-date'));
Popup.back();
},
@@ -528,7 +562,7 @@ Template.cardsSortPopup.events({
const sortBy = {
title: 1,
};
Session.set('sortBy', sortBy);
setCardsSortBy(sortBy);
sortCardsBy.set(TAPi18n.__('title'));
Popup.back();
},
@@ -536,7 +570,7 @@ Template.cardsSortPopup.events({
const sortBy = {
createdAt: 1,
};
Session.set('sortBy', sortBy);
setCardsSortBy(sortBy);
sortCardsBy.set(TAPi18n.__('date-created-newest-first'));
Popup.back();
},
@@ -544,7 +578,7 @@ Template.cardsSortPopup.events({
const sortBy = {
createdAt: -1,
};
Session.set('sortBy', sortBy);
setCardsSortBy(sortBy);
sortCardsBy.set(TAPi18n.__('date-created-oldest-first'));
Popup.back();
},
@@ -0,0 +1,42 @@
/**
* Pure helpers for building the option labels shown in board / list /
* swimlane selectors (e.g. the move-card and copy-card popups).
*
* Regression #6135 ("numbering boards, list, swim caused some issues"):
* a previous change prepended a sequential number to every option label
* (e.g. "3. My Board"). That broke two things:
*
* 1. Boards whose name itself starts with a digit became hard to read.
* 2. First-letter keyboard navigation in the native <select> dropdown
* stopped working, because every option started with a number instead
* of the board name.
*
* The fix: numbering is OPTIONAL and OFF by default. By default the label is
* exactly the entity name, so its first character is the name's first
* character and first-letter navigation works again. When numbering is
* explicitly enabled, the number is appended as a trailing visual suffix so
* the label still STARTS with the name.
*/
/**
* Build the label for a selectable board/list/swimlane option.
*
* @param {string} name The entity (board/list/swimlane) name/title.
* @param {number} index Zero-based position in the list.
* @param {Object} [opts]
* @param {boolean} [opts.numbered=false] When true, append the 1-based
* number as a trailing suffix. When false (default), return the bare
* name so first-letter keyboard navigation keeps working.
* @returns {string} The option label.
*/
export function boardOptionLabel(name, index, { numbered = false } = {}) {
const label = name == null ? '' : String(name);
if (!numbered) {
return label;
}
const position = Number(index);
const number = Number.isFinite(position) ? position + 1 : index;
return `${label} (${number})`;
}
export default boardOptionLabel;
+80 -246
View File
@@ -297,6 +297,62 @@
gap: 8px;
}
/* #5799: board-name search box in the All Boards header */
.boards-path-header .board-search {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
}
.boards-path-header .board-search input {
border: none;
outline: none;
background: transparent;
font-size: 14px;
min-width: 140px;
}
.boards-path-header .board-search .board-search-clear {
cursor: pointer;
color: #999;
}
.boards-path-header .board-search .board-search-clear:hover {
color: #333;
}
/* #5799: board grid pagination controls, mirroring Admin Panel > People */
.boards-pagination {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
}
.boards-pagination button {
min-width: 32px;
height: 30px;
border: 1px solid #bbb;
background: #fff;
border-radius: 0.4vw;
cursor: pointer;
}
.boards-pagination button.disabled {
opacity: 0.5;
pointer-events: none;
}
.boards-page-info {
min-width: 72px;
text-align: center;
color: #333;
}
.boards-path-header .path-icon {
font-size: 18px;
}
@@ -946,21 +1002,13 @@ body.grey-icons-enabled .checkmark-no-grey {
}
}
/* Mobile - make all text and icons 2x bigger above #content on All Boards page */
/* Mobile All Boards layout fixes */
@media screen and (max-width: 800px),
screen and (max-device-width: 800px),
screen and (-webkit-min-device-pixel-ratio: 2) and (max-width: 800px),
screen and (max-width: 800px) and (orientation: portrait),
screen and (max-width: 800px) and (orientation: landscape),
screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
.wrapper {
font-size: 2em !important; /* 2x bigger base font size for All Boards page */
}
.wrapper * {
font-size: inherit !important; /* Inherit the 2x scaling */
}
/* Stack the two-column All Boards layout on small screens. The fixed
"260px 1fr" grid had no mobile collapse, so on narrow non-iPhone browsers
(Fairphone Firefox ESR / Ubuntu Touch Morph, narrow desktop) it overflowed
@@ -976,243 +1024,6 @@ body.grey-icons-enabled .checkmark-no-grey {
padding-inline-end: 0 !important;
padding-bottom: 12px;
}
.wrapper .fa, .wrapper .icon {
font-size: 2em !important; /* 2x bigger icons */
}
.board-list-header {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.board-list-header h1 {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.AllBoardTeamsOrgs {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.AllBoardTeamsOrgs select {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.AllBoardTeamsOrgs input {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.AllBoardTeamsOrgs .fa {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.board-list {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.board-list .board-list-item {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.board-list .board-list-item-name {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.board-list .board-list-item-desc {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.board-list .minicard-members {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.board-list .minicard-lists {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.board-list .fa {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.board-list .board-handle {
font-size: 1em !important; /* Use inherited 2x scaling */
}
}
/* Fallback for iPhone devices using JavaScript detection - All Boards page */
.iphone-device .wrapper {
font-size: 2em !important; /* 2x bigger base font size for All Boards page */
}
.iphone-device .wrapper * {
font-size: inherit !important; /* Inherit the 2x scaling */
}
.iphone-device .wrapper .fa, .iphone-device .wrapper .icon {
font-size: 2em !important; /* 2x bigger icons */
}
.iphone-device .board-list-header {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.iphone-device .board-list-header h1 {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.iphone-device .AllBoardTeamsOrgs {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.iphone-device .AllBoardTeamsOrgs select {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.iphone-device .AllBoardTeamsOrgs input {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.iphone-device .AllBoardTeamsOrgs .fa {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.iphone-device .board-list {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.iphone-device .board-list .board-list-item {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.iphone-device .board-list .board-list-item-name {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.iphone-device .board-list .board-list-item-desc {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.iphone-device .board-list .minicard-members {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.iphone-device .board-list .minicard-lists {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.iphone-device .board-list .fa {
font-size: 1em !important; /* Use inherited 2x scaling */
}
.iphone-device .board-list .board-handle {
font-size: 1em !important; /* Use inherited 2x scaling */
}
/* iPhone 12 Mini and very small screens - make everything much larger */
@media screen and (max-width: 400px) and (max-height: 900px) {
.board-list {
height: calc(100vh - 120px) !important;
overflow-y: scroll !important;
overflow-x: hidden !important;
-webkit-overflow-scrolling: touch;
padding: 0 0.5rem;
}
.board-list li {
width: 100% !important;
float: none !important;
display: block !important;
margin-bottom: 1.5rem !important;
}
.board-list .board-list-item {
height: 12rem !important; /* Much taller */
width: 100% !important;
margin: 0 !important;
padding: 1rem !important; /* More padding */
font-size: 18px !important; /* Much larger text */
line-height: 1.4 !important;
}
.board-list .board-list-item .board-list-item-name {
font-size: 20px !important; /* Larger board names */
font-weight: bold !important;
margin-bottom: 0.5rem !important;
}
.board-list .board-list-item .board-list-item-desc {
font-size: 16px !important; /* Larger descriptions */
line-height: 1.3 !important;
}
.board-list .board-list-item .minicard-members {
font-size: 14px !important; /* Larger member avatars */
}
.board-list .board-list-item .minicard-lists {
font-size: 14px !important; /* Larger list counters */
}
.board-list .board-handle {
position: absolute;
padding: 10px !important;
top: 50%;
transform: translateY(-50%);
inset-inline-end: 15px !important;
font-size: 28px !important; /* Much larger drag handle */
color: #fff;
background: rgba(0,0,0,0.4) !important;
border-radius: 50%;
width: 50px !important; /* Larger handle */
height: 50px !important;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
transition: background-color 0.2s ease;
}
.board-list .board-handle:hover {
background: rgba(255, 255, 0, 0.8) !important; /* Yellow hover */
}
/* Force scrollbar to be visible and larger */
.board-list::-webkit-scrollbar {
width: 16px !important; /* Much wider scrollbar */
display: block !important;
visibility: visible !important;
}
.board-list::-webkit-scrollbar-track {
background: #f1f1f1 !important;
border-radius: 8px !important;
display: block !important;
visibility: visible !important;
}
.board-list::-webkit-scrollbar-thumb {
background: #666 !important; /* Darker for better visibility */
border-radius: 8px !important;
display: block !important;
visibility: visible !important;
min-height: 50px !important; /* Minimum thumb size */
}
.board-list::-webkit-scrollbar-thumb:hover {
background: #333 !important;
}
/* Ensure scrollbar is always visible */
.board-list {
scrollbar-gutter: stable;
scrollbar-width: auto !important;
min-height: 100vh !important;
}
.board-list::after {
content: '';
display: block;
height: 200px !important; /* More space to ensure scrolling */
}
}
.AllBoardTeamsOrgs {
list-style-type: none;
@@ -1403,3 +1214,26 @@ body.grey-icons-enabled .checkmark-no-grey {
}
}
/* #5157: show the board's background image on the board list tile, reusing the
* existing board.backgroundImageURL. A dark overlay keeps the title readable. */
.board-list-item.has-background-image {
background-size: cover;
background-position: center;
position: relative;
}
.board-list-item.has-background-image .details,
.board-list-item.has-background-image .board-list-item-name {
position: relative;
z-index: 1;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
color: #fff;
}
.board-list-item.has-background-image::after {
content: "";
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.25);
border-radius: inherit;
z-index: 0;
}
+49 -1
View File
@@ -27,6 +27,15 @@ template(name="boardList")
i.fa.fa-folder
| {{_ 'allboards.remaining'}}
span.menu-count {{menuItemCount 'remaining'}}
if isSelectedMenu 'templates'
if shareTargets.length
li.menu-section-title {{_ 'share-template-with'}}
each shareTargets
li.menu-item.js-share-target(data-share-type="{{type}}", data-share-id="{{id}}", data-share-name="{{name}}", title="{{_ 'drag-template-here-to-share'}}")
span.menu-label
span.emoji-icon
i.fa.fa-share-alt
| {{name}}
.workspaces-header
span
span.emoji-icon
@@ -76,6 +85,18 @@ template(name="boardList")
i.fa.fa-thumb-tack
| {{_ 'multi-selection-active'}}
.path-right
.board-search
span.emoji-icon
i.fa.fa-search
input.js-board-search-input(type="text" placeholder="{{_ 'search-boards'}}" value="{{boardSearch}}" autocomplete="off")
if boardSearch
a.board-search-clear.js-board-search-clear(title="{{_ 'filter-clear'}}")
span.emoji-icon
i.fa.fa-times
a.board-header-btn.js-open-boards-sort(title="{{_ 'sort-boards'}}" class="{{#unless isBoardsSort 'custom'}}emphasis{{/unless}}")
span.emoji-icon
i.fa.fa-sort-alpha-asc
span {{_ 'sort'}}
if canModifyBoards
if hasBoardsSelected
button.js-archive-selected-boards.board-header-btn
@@ -95,6 +116,13 @@ template(name="boardList")
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
span.emoji-icon
i.fa.fa-times
if boardsPaginationActive
.boards-pagination
button.js-boards-prev-page(class="{{#if hasBoardsPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.boards-page-info {{boardsCurrentPage}} / {{boardsTotalPages}}
button.js-boards-next-page(class="{{#if hasBoardsNextPage}}{{else}}disabled{{/if}}")
| &gt;
ul.board-list.clearfix.js-boards.dragscroll(class="{{#if isMiniScreen}}mobile-view{{/if}} {{#if BoardMultiSelection.isActive}}is-multiselection-active{{/if}}")
li.js-add-board
if isSelectedMenu 'templates'
@@ -154,7 +182,9 @@ template(name="boardList")
span.emoji-icon
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
else
.board-list-item
//- #5157: reuse the board's existing backgroundImageURL as the
//- tile background on the board list page.
.board-list-item(class="{{#if backgroundImageURL}}has-background-image{{/if}}" style="{{#if backgroundImageURL}}background-image:url('{{backgroundImageURL}}');{{/if}}")
if BoardMultiSelection.isActive
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
@@ -194,6 +224,24 @@ template(name="boardList")
span.emoji-icon
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
template(name="boardsSortPopup")
ul.pop-over-list
li
a.js-boards-sort(data-sort="custom" class="{{#if isBoardsSort 'custom'}}active{{/if}}")
span.emoji-icon
i.fa.fa-arrows
| &nbsp;{{_ 'sort-boards-custom'}}
li
a.js-boards-sort(data-sort="title-asc" class="{{#if isBoardsSort 'title-asc'}}active{{/if}}")
span.emoji-icon
i.fa.fa-sort-alpha-asc
| &nbsp;{{_ 'sort-boards-title-asc'}}
li
a.js-boards-sort(data-sort="title-desc" class="{{#if isBoardsSort 'title-desc'}}active{{/if}}")
span.emoji-icon
i.fa.fa-sort-alpha-desc
| &nbsp;{{_ 'sort-boards-title-desc'}}
template(name="boardListHeaderBar")
h1 {{_ title }}
//.board-header-btns.right
+340 -24
View File
@@ -2,8 +2,6 @@ import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import getSlug from 'limax';
import dragscroll from '@wekanteam/dragscroll';
import '/client/lib/dragscrollTouch';
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
import { BoardMultiSelection } from '/client/lib/boardMultiSelection';
import { EscapeActions } from '/client/lib/escapeActions';
@@ -11,8 +9,56 @@ import { Utils } from '/client/lib/utils';
// SubsManager removed for Meteor 3 migration
// #5850: which sharing scopes (organizations/teams/domains) the admin enabled
// in Admin Panel > People > Shared Templates (stored in localStorage, like the
// Shared Templates admin UI). Used to gate the drag-to-share drop targets.
function loadSharedTemplatesScopes() {
try {
const raw = window.localStorage.getItem('sharedTemplatesScopes');
const arr = raw ? JSON.parse(raw) : [];
return Array.isArray(arr) ? arr : [];
} catch (e) {
return [];
}
}
// #5850: share a (template) board with an Organization / Team / Domain by adding
// it to the board's groups, add-only (does not remove existing shares and does
// not add individual members). Called when a board is dropped on a share target.
function shareBoardWith(boardId, shareType, name, id) {
const board = ReactiveCache.getBoard(boardId);
if (!board || !boardId) {
return;
}
if (shareType === 'org') {
const orgs = (board.orgs || []).slice();
if (!orgs.some(o => o.orgId === id)) {
orgs.push({ orgId: id, orgDisplayName: name, isActive: true });
Meteor.call('setBoardOrgs', orgs, boardId);
}
} else if (shareType === 'team') {
const teams = (board.teams || []).slice();
if (!teams.some(t => t.teamId === id)) {
teams.push({ teamId: id, teamDisplayName: name, isActive: true });
// Preserve the board's current members (group-only sharing adds no
// individual members); setBoardTeams sets both members and teams.
Meteor.call('setBoardTeams', teams, board.members || [], boardId);
}
} else if (shareType === 'domain') {
const domains = (board.domains || []).slice();
if (!domains.some(d => d.domain === id)) {
domains.push({ domain: id, isActive: true });
Meteor.call('setBoardDomains', domains, boardId);
}
}
}
const DEFAULT_WORKSPACE_ICON = '📁';
// #5799: how many board icons to show per page in the sorted (non-custom) modes.
// Matches the Admin Panel > People page size.
const BOARDS_PER_PAGE = 25;
function getCurrentWorkspacesTree() {
const currentUser = ReactiveCache.getCurrentUser();
const tree =
@@ -115,9 +161,25 @@ Template.boardListHeaderBar.helpers({
Template.boardList.onCreated(function () {
Meteor.subscribe('setting');
Meteor.subscribe('tableVisibilityModeSettings');
this.selectedMenu = new ReactiveVar('starred');
// Honor the URL-addressable sub-view (#5850). The route sets
// Session 'boardListMenu' to 'starred', 'templates' or 'remaining'.
this.selectedMenu = new ReactiveVar(Session.get('boardListMenu') || 'starred');
this.selectedWorkspaceIdVar = new ReactiveVar(null);
this.workspacesTreeVar = new ReactiveVar([]);
// #5799: free-text search by board name. When non-empty it searches across
// ALL the user's boards (Starred, Templates, Remaining and every workspace),
// ignoring the selected-menu filter.
this.boardSearchVar = new ReactiveVar('');
// #5799: server-side pagination state for the sorted (non-custom) modes.
this.boardsPageVar = new ReactiveVar(1);
this.pagedBoardsVar = new ReactiveVar({ ids: [], total: 0 });
// #5850: the user's orgs/teams that have the per-org/team Shared Templates
// flag set (plus email domains), fetched via a non-admin server method since
// the org/team publications are admin-only. Gates the drag-to-share targets.
this.shareableGroups = new ReactiveVar({ orgs: [], teams: [], domains: [] });
Meteor.call('getMyShareableGroups', (err, res) => {
if (!err && res) this.shareableGroups.set(res);
});
let currUser = ReactiveCache.getCurrentUser();
let userLanguage;
if (currUser && currUser.profile) {
@@ -196,15 +258,78 @@ Template.boardList.onCreated(function () {
}
}
});
// Switch the sub-view live when the route changes (e.g. navigating to
// /templates or /remaining while boardList is already rendered). Only the
// three recognized menu values are honored so a workspace selection is not
// clobbered (#5850).
this.autorun(() => {
const m = Session.get('boardListMenu');
if (m === 'starred' || m === 'templates' || m === 'remaining') {
this.selectedMenu.set(m);
this.selectedWorkspaceIdVar.set(null);
}
});
// #5799: reset to the first page whenever the active filter changes (search
// text, selected menu/workspace or sort mode). Depends only on those signals,
// not on boardsPageVar, so paging next/prev does not reset itself.
this.autorun(() => {
this.boardSearchVar.get();
this.selectedMenu.get();
const cu = ReactiveCache.getCurrentUser();
if (cu && typeof cu.getAllBoardsSortBy === 'function') {
cu.getAllBoardsSortBy();
}
this.boardsPageVar.set(1);
});
// #5799: fetch the current page of boards from the server for the sorted
// (non-custom) modes, so only that page of icons is rendered. Custom (manual
// drag order) stays unpaginated client-side so drag-reordering keeps working.
// Uses the effective current user server-side, so it also works under
// GlobalAdmin impersonation.
this.autorun(() => {
const cu = ReactiveCache.getCurrentUser();
const sortBy =
cu && typeof cu.getAllBoardsSortBy === 'function'
? cu.getAllBoardsSortBy()
: 'custom';
if (sortBy === 'custom') {
this.pagedBoardsVar.set({ ids: [], total: 0 });
return;
}
const search = (this.boardSearchVar.get() || '').trim();
const menu = this.selectedMenu.get();
const page = this.boardsPageVar.get();
Meteor.call(
'getAllBoardsPage',
{ search, sortBy, menu, page, perPage: BOARDS_PER_PAGE },
(err, res) => {
if (err) {
console.error('getAllBoardsPage failed:', err);
return;
}
if (res) this.pagedBoardsVar.set(res);
},
);
});
// The templates-container board is no longer auto-created at signup (#2339,
// #5850), and it is no longer auto-created when the user merely opens the
// Templates sub-view either: opening the view must not leave behind an empty
// Template Container board the user never asked for. The container is created
// only on demand -- either when the user explicitly adds one via the
// "Add Template Board" flow (createTemplateContainerPopup), or lazily by
// ensureTemplatesBoard right before a card/list/swimlane/board is actually
// saved as a template. ensureTemplatesBoard remains idempotent (a no-op when
// one already exists) so those save paths keep working.
});
Template.boardList.onRendered(function () {
// Enable drag-to-scroll on the All Boards list (.board-list.dragscroll), the
// same way the board canvas/swimlanes do. This makes mouse drag-scrolling
// work on desktop; finger drag-scrolling is handled by dragscrollTouch.js.
// The .board-list element is stable across menu/workspace switches (only its
// li children are re-rendered), so a single reset() binding is enough.
dragscroll.reset();
// Drag-to-scroll on the All Boards page is handled centrally for all
// non-board pages by defaultLayout's route-aware autorun
// (see client/components/main/layouts.js).
// jQuery sortable is disabled in favor of HTML5 drag-and-drop for space management
// The old sortable code has been removed to prevent conflicts
@@ -360,7 +485,12 @@ Template.boardList.helpers({
'tableVisibilityMode-allowPrivateOnly',
);
if (FlowRouter.getRouteName() === 'home') {
// #5850: the All Boards sub-views are also reachable via their own routes
// (/templates, /remaining), which must apply the same membership filtering
// as the home route, otherwise their board list is empty (or falls into the
// public-only branch below).
const allBoardsRoutes = ['home', 'allboards-templates', 'allboards-remaining'];
if (allBoardsRoutes.includes(FlowRouter.getRouteName())) {
membershipOrs.push({ 'members.userId': Meteor.userId() });
const currUser = ReactiveCache.getCurrentUser();
@@ -376,6 +506,12 @@ Template.boardList.helpers({
let teamsIds = teamIdsUserBelongs.split(',');
membershipOrs.push({ 'teams.teamId': { $in: teamsIds } });
}
// #5850: boards shared with the user's email domain.
const emailDomains = currUser?.emailDomains?.() || [];
if (emailDomains.length) {
membershipOrs.push({ 'domains.domain': { $in: emailDomains } });
}
if (membershipOrs.length) {
query.$and.splice(2, 0, { $or: membershipOrs });
}
@@ -393,27 +529,53 @@ Template.boardList.helpers({
const boards = ReactiveCache.getBoards(query, {});
const currentUser = ReactiveCache.getCurrentUser();
// #5799: in a sorted (non-custom) mode the server already computed the
// current page (filtered by menu/search and sorted), so render exactly that
// ordered page of board icons. Custom (manual drag order) falls through to
// the unpaginated client-side path below so drag-reordering keeps working.
const sortMode =
currentUser && typeof currentUser.getAllBoardsSortBy === 'function'
? currentUser.getAllBoardsSortBy()
: 'custom';
if (sortMode !== 'custom') {
const paged = tpl.pagedBoardsVar.get();
return (paged.ids || [])
.map((id) => ReactiveCache.getBoard(id))
.filter(Boolean);
}
let list = boards;
// Apply left menu filtering
const sel = tpl.selectedMenu.get();
const assignments =
(currentUser &&
currentUser.profile &&
currentUser.profile.boardWorkspaceAssignments) ||
{};
if (sel === 'starred') {
// Starred boards are always visible in Starred.
list = list.filter((b) => currentUser && currentUser.hasStarred(b._id));
} else if (sel === 'templates') {
list = list.filter((b) => b.type === 'template-container');
} else if (sel === 'remaining') {
// Remaining only shows boards not assigned to any workspace.
list = list.filter(
(b) => !assignments[b._id] && b.type !== 'template-container',
);
// #5799: when a board-name search is active, search across ALL the user's
// boards (every menu/workspace) by title and skip the menu filter, so a
// board in any category — Starred, Templates, Remaining or a (sub)workspace
// — is found from a single search box.
const search = (tpl.boardSearchVar.get() || '').trim().toLowerCase();
if (search) {
list = list.filter((b) => (b.title || '').toLowerCase().includes(search));
} else {
// Workspace view includes all boards in that workspace, including starred.
list = list.filter((b) => assignments[b._id] === sel);
// Apply left menu filtering
const sel = tpl.selectedMenu.get();
if (sel === 'starred') {
// Starred boards are always visible in Starred.
list = list.filter((b) => currentUser && currentUser.hasStarred(b._id));
} else if (sel === 'templates') {
list = list.filter((b) => b.type === 'template-container');
} else if (sel === 'remaining') {
// Remaining only shows boards not assigned to any workspace.
list = list.filter(
(b) => !assignments[b._id] && b.type !== 'template-container',
);
} else {
// Workspace view includes all boards in that workspace, including starred.
list = list.filter((b) => assignments[b._id] === sel);
}
}
if (currentUser && typeof currentUser.sortBoardsForUser === 'function') {
@@ -476,6 +638,24 @@ Template.boardList.helpers({
isSelectedMenu(type) {
return Template.instance().selectedMenu.get() === type;
},
// #5850: drag-to-share drop targets — the user's organizations/teams/domains,
// gated by the admin's Shared Templates scopes (localStorage). Shown in the
// Templates view so a personal Template Board can be dragged onto one to share.
shareTargets() {
const scopes = loadSharedTemplatesScopes();
const groups = Template.instance().shareableGroups.get() || {};
const targets = [];
if (scopes.includes('organizations')) {
(groups.orgs || []).forEach(o => targets.push(o));
}
if (scopes.includes('teams')) {
(groups.teams || []).forEach(t => targets.push(t));
}
if (scopes.includes('domains')) {
(groups.domains || []).forEach(d => targets.push(d));
}
return targets;
},
isSpaceSelected(id) {
return Template.instance().selectedWorkspaceIdVar.get() === id;
},
@@ -539,6 +719,46 @@ Template.boardList.helpers({
const currentUser = ReactiveCache.getCurrentUser();
return currentUser && !currentUser.isCommentOnly();
},
// #5799: current board-name search text (for the input value and the clear button).
boardSearch() {
return Template.instance().boardSearchVar.get();
},
// #5799: pagination controls (only shown in the sorted, non-custom modes).
boardsPaginationActive() {
const currentUser = ReactiveCache.getCurrentUser();
const sortMode =
currentUser && typeof currentUser.getAllBoardsSortBy === 'function'
? currentUser.getAllBoardsSortBy()
: 'custom';
if (sortMode === 'custom') return false;
const total = Template.instance().pagedBoardsVar.get().total || 0;
return total > BOARDS_PER_PAGE;
},
boardsCurrentPage() {
return Template.instance().boardsPageVar.get();
},
boardsTotalPages() {
const total = Template.instance().pagedBoardsVar.get().total || 0;
return Math.max(1, Math.ceil(total / BOARDS_PER_PAGE));
},
hasBoardsPrevPage() {
return Template.instance().boardsPageVar.get() > 1;
},
hasBoardsNextPage() {
const tpl = Template.instance();
const total = tpl.pagedBoardsVar.get().total || 0;
const totalPages = Math.max(1, Math.ceil(total / BOARDS_PER_PAGE));
return tpl.boardsPageVar.get() < totalPages;
},
// #5799: current All Boards sort mode ('custom' | 'title-asc' | 'title-desc').
isBoardsSort(mode) {
const currentUser = ReactiveCache.getCurrentUser();
const current =
currentUser && typeof currentUser.getAllBoardsSortBy === 'function'
? currentUser.getAllBoardsSortBy()
: 'custom';
return current === mode;
},
hasBoardsSelected() {
return BoardMultiSelection.count() > 0;
},
@@ -634,6 +854,41 @@ Template.boardList.events({
Popup.open('createBoard')(evt);
}
},
// #5799: choose how the All Boards page is sorted.
'click .js-open-boards-sort': Popup.open('boardsSort'),
// #5799: search boards by name across all categories.
'input .js-board-search-input'(evt, tpl) {
tpl.boardSearchVar.set(evt.currentTarget.value);
},
'keydown .js-board-search-input'(evt, tpl) {
// Esc clears the search.
if (evt.keyCode === 27) {
tpl.boardSearchVar.set('');
evt.currentTarget.value = '';
}
},
'click .js-board-search-clear'(evt, tpl) {
evt.preventDefault();
tpl.boardSearchVar.set('');
const input = tpl.find('.js-board-search-input');
if (input) {
input.value = '';
input.focus();
}
},
// #5799: board grid pagination (sorted modes only).
'click .js-boards-prev-page'(evt, tpl) {
evt.preventDefault();
const page = tpl.boardsPageVar.get();
if (page > 1) tpl.boardsPageVar.set(page - 1);
},
'click .js-boards-next-page'(evt, tpl) {
evt.preventDefault();
const total = tpl.pagedBoardsVar.get().total || 0;
const totalPages = Math.max(1, Math.ceil(total / BOARDS_PER_PAGE));
const page = tpl.boardsPageVar.get();
if (page < totalPages) tpl.boardsPageVar.set(page + 1);
},
'click .js-star-board'(evt) {
evt.preventDefault();
evt.stopPropagation();
@@ -643,6 +898,43 @@ Template.boardList.events({
}
},
// HTML5 DnD from boards to spaces
// #5850: drag a (template) board onto an Org/Team/Domain target to share it.
'dragover .js-share-target'(evt) {
evt.preventDefault();
if (evt.originalEvent.dataTransfer) {
evt.originalEvent.dataTransfer.dropEffect = 'copy';
}
evt.currentTarget.classList.add('board-drag-hint');
},
'dragleave .js-share-target'(evt) {
evt.currentTarget.classList.remove('board-drag-hint');
},
'drop .js-share-target'(evt) {
evt.preventDefault();
evt.stopPropagation();
const target = evt.currentTarget;
target.classList.remove('board-drag-hint');
const dt = evt.originalEvent.dataTransfer;
const data = dt.getData('text/plain');
if (!data) {
return;
}
const isMulti = dt.getData('application/x-board-multi') === 'true';
let boardIds;
if (isMulti) {
try {
boardIds = JSON.parse(data);
} catch (e) {
boardIds = [];
}
} else {
boardIds = [data];
}
const shareType = target.getAttribute('data-share-type');
const name = target.getAttribute('data-share-name');
const id = target.getAttribute('data-share-id');
boardIds.forEach(boardId => shareBoardWith(boardId, shareType, name, id));
},
'dragstart .js-board'(evt) {
const boardId = this._id;
@@ -1009,6 +1301,30 @@ Template.boardList.events({
},
});
// #5799: All Boards sort popup — pick custom (manual drag order) or
// alphabetical A→Z / Z→A. The choice is stored per user.
Template.boardsSortPopup.helpers({
isBoardsSort(mode) {
const currentUser = ReactiveCache.getCurrentUser();
const current =
currentUser && typeof currentUser.getAllBoardsSortBy === 'function'
? currentUser.getAllBoardsSortBy()
: 'custom';
return current === mode;
},
});
Template.boardsSortPopup.events({
'click .js-boards-sort'(evt) {
evt.preventDefault();
const mode = evt.currentTarget.getAttribute('data-sort');
if (mode) {
Meteor.call('setAllBoardsSortBy', mode);
}
Popup.back();
},
});
Template.workspaceActionsPopup.helpers({
workspaceName() {
return this.name || '';
@@ -0,0 +1,129 @@
.dependency-overlay {
position: absolute;
top: 0;
inset-inline-start: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 100;
overflow: visible;
}
/* #3392: the overlay SVG stays click-through, but each line is clickable (to
open the edit/delete popup) even though its container is pointer-events:none. */
.dependency-line {
pointer-events: stroke;
cursor: pointer;
}
.dependency-temp-line {
pointer-events: none;
}
/* #3392: drag-to-connect handle on the minicard (no mode — cards stay
clickable). Shown on hover; drag it onto another card to create a link. */
.minicard .dependency-connect-handle {
position: absolute;
top: 50%;
inset-inline-end: -7px;
right: -7px;
transform: translateY(-50%);
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
color: #eb144c;
background: #fff;
border-radius: 50%;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
cursor: crosshair;
opacity: 0;
z-index: 101;
}
.minicard:hover .dependency-connect-handle {
opacity: 1;
}
.dependency-line-popup .dependency-field {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.dependency-line-popup .card-dependency-icons li a.is-selected {
border-color: #eb144c;
border-width: 2px;
}
.dependency-line-delete {
display: inline-flex;
align-items: center;
gap: 6px;
color: #eb144c;
}
/* #3392: card detail "Dependencies" rows */
.card-details-dependencies-list .card-dependency {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.card-details-dependencies-list .card-dependency-link {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-details-dependencies-list .js-dependency-type {
flex: 0 0 auto;
max-width: 130px;
}
.card-details-dependencies-list .js-dependency-color {
flex: 0 0 auto;
width: 28px;
height: 24px;
padding: 0;
border: none;
background: none;
cursor: pointer;
}
.card-dependencies-popup .dependency-field {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.card-dependency-icons {
display: flex;
flex-wrap: wrap;
gap: 8px;
list-style: none;
margin: 0;
padding: 4px;
}
.card-dependency-icons li a {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid #ddd;
border-radius: 4px;
}
.minicard .badge.minicard-dependencies .badge-icon {
font-weight: bold;
}
@@ -0,0 +1,35 @@
template(name="dependencyOverlay")
svg.dependency-overlay.js-dependency-overlay(xmlns="http://www.w3.org/2000/svg")
defs
each dependencyMarkers
marker(id="{{markerId}}" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto" markerUnits="userSpaceOnUse")
path(d="M0,0 L7,3 L0,6 Z" fill="{{color}}")
each dependencyLines
path.dependency-line(d="{{path}}" fill="none" stroke="{{color}}" stroke-width="2" marker-end="{{markerUrl}}")
if tempLine
path.dependency-temp-line(d="{{tempLine}}" fill="none" stroke="#eb144c" stroke-width="2" stroke-dasharray="6,4")
template(name="dependencyLinePopup")
.dependency-line-popup
label.dependency-field
span {{_ 'dependency-type'}}
select.js-line-type
each typeOption
if selected
option(value="{{id}}" selected="selected") {{_ label}}
else
option(value="{{id}}") {{_ label}}
label.dependency-field
span {{_ 'dependency-color'}}
input.js-line-color(type="color" value="{{color}}")
.dependency-field
span {{_ 'dependency-icon'}}
ul.card-dependency-icons
each dependencyIcons
li
a.js-line-icon(data-icon="{{name}}" title="{{name}}" class="{{#if selected}}is-selected{{/if}}")
i.fa(class="fa-{{name}}")
hr
a.js-line-delete.dependency-line-delete
i.fa.fa-times
| {{_ 'remove-dependency'}}
@@ -0,0 +1,300 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { ReactiveVar } from 'meteor/reactive-var';
import { Utils } from '/client/lib/utils';
import {
DEPENDENCY_ICON_CHOICES,
DEPENDENCY_TYPES,
dependencyTypeMeta,
normalizeDependencies,
} from '/models/metadata/dependencies';
// #3392: PI Program Board "Red Strings".
// Draw colored, typed connection lines (SVG paths) between cards that declare
// cardDependencies. The board stays fully usable (cards clickable) — drawing a
// new link is initiated from a small connect handle on each minicard (drag it
// onto another card, piplanning.io / Kendis / Miro style), and a link can be
// edited/deleted by clicking its line. The overlay SVG itself stays
// pointer-events:none; only the lines (and the minicard handles) capture events.
//
// Coordinates are computed from the live DOM (getBoundingClientRect of
// [data-card-id] minicards) translated into the overlay SVG's own coordinate
// space, so scrolling/resizing is handled by recomputing relative to the SVG's
// current bounding rect.
// Stable, css-safe marker id for an arrowhead of a given color.
function markerIdForColor(color) {
return `dependency-arrowhead-${String(color).replace(/[^a-zA-Z0-9]/g, '')}`;
}
Template.dependencyOverlay.onCreated(function () {
this.lines = new ReactiveVar([]);
this.markers = new ReactiveVar([]);
this.tempLine = new ReactiveVar(null); // line being drawn while dragging
// Guard against deferred (rAF / setTimeout) callbacks touching the DOM after
// the overlay is destroyed ("Can't select in removed DomRange").
this._overlayDestroyed = false;
// Rect of a card relative to the overlay SVG, or null if not rendered.
this.rectOf = (cardId, svgRect) => {
const el = document.querySelector(`[data-card-id="${cardId}"]`);
if (!el) return null;
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) return null;
return { x: r.left - svgRect.left, y: r.top - svgRect.top, w: r.width, h: r.height };
};
this.recompute = () => {
if (this._overlayDestroyed || !this.view || this.view.isDestroyed) {
return;
}
const svg = this.find('.js-dependency-overlay');
if (!svg) {
return;
}
const board = Utils.getCurrentBoard();
if (!board) {
this.lines.set([]);
return;
}
const svgRect = svg.getBoundingClientRect();
const centerOf = cardId => this.rectOf(cardId, svgRect);
const cards = ReactiveCache.getCards({
boardId: board._id,
archived: false,
});
const lines = [];
const markerColors = new Set();
cards.forEach(card => {
const deps = normalizeDependencies(card.cardDependencies);
if (deps.length === 0) {
return;
}
const sourceRect = centerOf(card._id);
if (!sourceRect) {
return;
}
deps.forEach(dep => {
const targetRect = centerOf(dep.cardId);
if (!targetRect) {
return;
}
const meta = dependencyTypeMeta(dep.type);
// Draw the arrow from the prerequisite card's right edge to the
// dependent card's left edge. For forward relations (blocks/fixes/
// related-to) the source card is the prerequisite; for the reverse
// relations (is-blocked-by/is-fixed-by) the target is.
const prereq = meta.forward ? sourceRect : targetRect;
const dependent = meta.forward ? targetRect : sourceRect;
const x1 = prereq.x + prereq.w;
const y1 = prereq.y + prereq.h / 2;
const x2 = dependent.x;
const y2 = dependent.y + dependent.h / 2;
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
const path = `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
const markerId = markerIdForColor(dep.color);
if (meta.directed) {
markerColors.add(dep.color);
}
lines.push({
path,
color: dep.color,
type: dep.type,
icon: dep.icon,
directed: meta.directed,
markerId: meta.directed ? markerId : '',
// Source card (owns the dependency) and target card.
fromId: card._id,
toId: dep.cardId,
});
});
});
this.lines.set(lines);
this.markers.set(
[...markerColors].map(color => ({
color,
markerId: markerIdForColor(color),
})),
);
};
this.scheduleRecompute = () => {
if (this._overlayDestroyed) return;
if (this._raf) {
window.cancelAnimationFrame(this._raf);
}
this._raf = window.requestAnimationFrame(() => {
this._raf = null;
this.recompute();
});
};
});
Template.dependencyOverlay.onRendered(function () {
const instance = this;
const svg = this.find('.js-dependency-overlay');
this.scrollEl = svg ? this.firstNode.closest('.board-canvas') : null;
this.autorun(() => {
const board = Utils.getCurrentBoard();
if (board) {
ReactiveCache.getCards({ boardId: board._id, archived: false });
}
instance.scheduleRecompute();
});
this.onScroll = () => instance.scheduleRecompute();
this.onResize = () => instance.scheduleRecompute();
if (this.scrollEl) {
this.scrollEl.addEventListener('scroll', this.onScroll, { passive: true });
}
window.addEventListener('scroll', this.onScroll, { passive: true });
window.addEventListener('resize', this.onResize);
// --- drag-to-connect (from a minicard's connect handle) -----------------
// Find the card under a viewport point. The overlay is pointer-events:none,
// so elementsFromPoint returns the minicards directly.
const cardIdAtPoint = (clientX, clientY) => {
const els = document.elementsFromPoint(clientX, clientY);
for (const el of els) {
if (svg && svg.contains(el)) continue;
const card = el.closest && el.closest('[data-card-id]');
if (card) return card.getAttribute('data-card-id');
}
return null;
};
this.onMouseDown = e => {
if (e.button !== 0) return;
const handle =
e.target.closest && e.target.closest('.js-dependency-connect-handle');
if (!handle) return;
const cardEl = handle.closest('[data-card-id]');
if (!cardEl) return;
// Beat the card drag-sort: capture-phase + stop propagation.
e.preventDefault();
e.stopPropagation();
instance.dragSourceId = cardEl.getAttribute('data-card-id');
};
this.onMouseMove = e => {
if (!instance.dragSourceId || !svg) return;
const svgRect = svg.getBoundingClientRect();
const from = instance.rectOf(instance.dragSourceId, svgRect);
if (!from) return;
const x1 = from.x + from.w;
const y1 = from.y + from.h / 2;
const x2 = e.clientX - svgRect.left;
const y2 = e.clientY - svgRect.top;
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
instance.tempLine.set(
`M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`,
);
};
this.onMouseUp = e => {
if (!instance.dragSourceId) return;
const sourceId = instance.dragSourceId;
instance.dragSourceId = null;
instance.tempLine.set(null);
const targetId = cardIdAtPoint(e.clientX, e.clientY);
if (targetId && targetId !== sourceId) {
const card = ReactiveCache.getCard(sourceId);
if (card && typeof card.addDependency === 'function') {
card.addDependency(targetId);
}
}
};
// Capture phase so the connect drag starts before the sortable grabs the card.
document.addEventListener('mousedown', this.onMouseDown, true);
document.addEventListener('mousemove', this.onMouseMove);
document.addEventListener('mouseup', this.onMouseUp);
this._initTimeout = Meteor.setTimeout(() => instance.scheduleRecompute(), 300);
});
Template.dependencyOverlay.onDestroyed(function () {
this._overlayDestroyed = true;
if (this.scrollEl) {
this.scrollEl.removeEventListener('scroll', this.onScroll);
}
window.removeEventListener('scroll', this.onScroll);
window.removeEventListener('resize', this.onResize);
if (this.onMouseDown) document.removeEventListener('mousedown', this.onMouseDown, true);
if (this.onMouseMove) document.removeEventListener('mousemove', this.onMouseMove);
if (this.onMouseUp) document.removeEventListener('mouseup', this.onMouseUp);
if (this._raf) {
window.cancelAnimationFrame(this._raf);
}
if (this._initTimeout) {
Meteor.clearTimeout(this._initTimeout);
}
});
Template.dependencyOverlay.helpers({
dependencyLines() {
return Template.instance().lines.get();
},
dependencyMarkers() {
return Template.instance().markers.get();
},
tempLine() {
return Template.instance().tempLine.get();
},
markerUrl() {
return this.markerId ? `url(#${this.markerId})` : '';
},
});
Template.dependencyOverlay.events({
'click .dependency-line'(event) {
event.preventDefault();
event.stopPropagation();
// `this` is the line data object (fromId/toId/type/color/icon).
Popup.open('dependencyLine').call(this, event);
},
});
// #3392: popup to edit or delete an on-board dependency line. Data context is the
// line object { fromId, toId, type, color, icon }.
Template.dependencyLinePopup.helpers({
typeOption() {
const current = this.type;
return DEPENDENCY_TYPES.map(t => ({
id: t.id,
label: `dependency-type-${t.id}`,
selected: t.id === current,
}));
},
color() {
return this.color;
},
dependencyIcons() {
const current = this.icon;
return DEPENDENCY_ICON_CHOICES.map(name => ({ name, selected: name === current }));
},
});
Template.dependencyLinePopup.events({
'change .js-line-type'(event) {
const card = ReactiveCache.getCard(this.fromId);
if (card) card.setDependencyProps(this.toId, { type: event.currentTarget.value });
},
'change .js-line-color'(event) {
const card = ReactiveCache.getCard(this.fromId);
if (card) card.setDependencyProps(this.toId, { color: event.currentTarget.value });
},
'click .js-line-icon'(event) {
const card = ReactiveCache.getCard(this.fromId);
if (card) card.setDependencyProps(this.toId, { icon: event.currentTarget.dataset.icon });
},
'click .js-line-delete'() {
const card = ReactiveCache.getCard(this.fromId);
if (card) card.removeDependency(this.toId);
Popup.back();
},
});
+143
View File
@@ -0,0 +1,143 @@
/* Board Table view: search + pagination controls, styled to match the
Admin Panel / People page (see client/components/settings/peopleBody.css).
The table itself reuses the My Cards table styling (.my-cards-board-table). */
.table-view-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin: 1vh 0;
}
.table-view-search-box {
display: flex;
flex-direction: row;
height: 34px;
}
.table-view-search-box input {
border: 1px solid #ccc;
border-radius: 0.4vw;
padding: 0 8px;
}
.table-view-search-box button {
min-width: 90px;
margin-inline-start: 6px;
background: #01628c;
color: #fff;
border: none;
border-radius: 0.4vw;
cursor: pointer;
padding: 0 12px;
}
.table-view-search-box button:hover {
background: #005377;
}
.table-view-pagination {
display: flex;
align-items: center;
gap: 6px;
}
.table-view-pagination button {
min-width: 32px;
height: 30px;
border: 1px solid #bbb;
background: #fff;
border-radius: 0.4vw;
cursor: pointer;
}
.table-view-pagination button.disabled {
opacity: 0.5;
pointer-events: none;
}
.table-view-page-info {
min-width: 72px;
text-align: center;
color: #333;
}
/* Sortable column headers (Excel-like): clickable, with a sort-direction
arrow appended by the sortIndicator helper. */
.my-cards-board-table th.js-table-view-sort {
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.my-cards-board-table th.js-table-view-sort:hover {
color: #01628c;
}
/* Leftmost "Edit" cell: an Edit link (pencil icon + text) styled like the one
on the Admin Panel / People page, opening the card when clicked. */
.my-cards-board-table td.table-view-cell-edit {
white-space: nowrap;
}
.my-cards-board-table td.table-view-cell-edit .edit-card {
cursor: pointer;
}
.my-cards-board-table td.table-view-cell-edit .edit-card i.fa {
margin-inline-end: 4px;
}
/* Word-wrap the Card / List / Swimlane cells so long text breaks onto the next
line instead of overflowing across the other columns. */
.my-cards-board-table td.table-view-cell-wrap {
max-width: 250px;
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
}
/* The +viewer template wraps its markdown in a block-level .viewer div. Inside
a table cell it should not add extra vertical margins, and inside a label it
must stay inline so the label keeps its pill shape. */
.my-cards-board-table .viewer {
margin: 0;
}
.my-cards-board-table .viewer p {
margin: 0;
}
.my-cards-board-table .card-label .viewer,
.my-cards-board-table .card-label .viewer p {
display: inline;
}
/* Word-wrap the Labels cell so each label pill wraps onto the next line instead
of stretching the column or overflowing across the date columns. */
.my-cards-board-table td.table-view-cell-labels {
max-width: 250px;
}
.my-cards-board-table td.table-view-cell-labels .div {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.my-cards-board-table td.table-view-cell-labels .card-label {
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
}
/* Keep each date in a single column; the clickable badge already styles itself
like the card detail / Gantt date badges. */
.my-cards-board-table td.table-view-cell-date {
white-space: nowrap;
}
.my-cards-board-table td.table-view-cell-date .card-date {
cursor: pointer;
}
+107
View File
@@ -0,0 +1,107 @@
template(name="tableView")
if currentBoard
.wrapper
.table-view-controls
.table-view-search-box
input.js-table-view-search(type="text" placeholder="{{_ 'search'}}" aria-label="{{_ 'search'}}")
button.js-table-view-search-button(type="button")
i.fa.fa-search
| {{_ 'search'}}
.table-view-pagination
button.js-table-view-prev-page(type="button" class="{{#unless hasPrevPage}}disabled{{/unless}}" aria-label="{{_ 'previous'}}")
i.fa.fa-chevron-left
span.table-view-page-info {{currentPage}} / {{totalPages}}
button.js-table-view-next-page(type="button" class="{{#unless hasNextPage}}disabled{{/unless}}" aria-label="{{_ 'next'}}")
i.fa.fa-chevron-right
table.my-cards-board-table
caption.sr-only {{currentBoard.title}}
thead
tr
th(scope="col")
th.js-table-view-sort(scope="col" data-sort="card") {{_ "card"}} {{sortIndicator 'card'}}
th.js-table-view-sort(scope="col" data-sort="list") {{_ "list"}} {{sortIndicator 'list'}}
th.js-table-view-sort(scope="col" data-sort="swimlane") {{_ "swimlane"}} {{sortIndicator 'swimlane'}}
unless isMiniScreen
th(scope="col") {{_ "assignees"}}
th(scope="col") {{_ "members"}}
th(scope="col") {{_ "labels"}}
if showReceivedColumn
th.js-table-view-sort(scope="col" data-sort="received") {{_ "card-received"}} {{sortIndicator 'received'}}
if showStartColumn
th.js-table-view-sort(scope="col" data-sort="start") {{_ "card-start"}} {{sortIndicator 'start'}}
if showDueColumn
th.js-table-view-sort(scope="col" data-sort="due") {{_ "card-due"}} {{sortIndicator 'due'}}
if showEndColumn
th.js-table-view-sort(scope="col" data-sort="end") {{_ "card-end"}} {{sortIndicator 'end'}}
tbody
each row in rows
tr
td.table-view-cell-edit
a.edit-card.js-table-view-edit-card(href=row.card.originRelativeUrl data-card-id=row.card._id title="{{_ 'edit'}}")
i.fa.fa-pencil-square-o
| {{_ 'edit'}}
td.table-view-cell-wrap
unless isMiniScreen
.my-cards-board-badge(class=row.colorClass)
.my-cards-card-title-table
+viewer
= row.title
td.table-view-cell-wrap
+viewer
= row.listTitle
td.table-view-cell-wrap
+viewer
= row.swimlaneTitle
unless isMiniScreen
td
.div
each assignee in row.card.getAssignees
a.name
+userAvatar(userId=assignee noRemove=true)
td
.div
each member in row.card.getMembers
a.name
+userAvatar(userId=member noRemove=true)
td.table-view-cell-labels
.div
each label in row.labels
span.card-label(class="card-label-{{label.color}}" title="{{label.name}}")
+viewer
= label.name
if showReceivedColumn
td.table-view-cell-date
with row.card
if getReceived
+cardReceivedDate
else
if canModifyCard
a.card-label.add-label.js-received-date(title="{{_ 'card-received'}}")
i.fa.fa-plus
if showStartColumn
td.table-view-cell-date
with row.card
if getStart
+cardStartDate
else
if canModifyCard
a.card-label.add-label.js-start-date(title="{{_ 'card-start'}}")
i.fa.fa-plus
if showDueColumn
td.table-view-cell-date
with row.card
if getDue
+cardDueDate
else
if canModifyCard
a.card-label.add-label.js-due-date(title="{{_ 'card-due'}}")
i.fa.fa-plus
if showEndColumn
td.table-view-cell-date
with row.card
if getEnd
+cardEndDate
else
if canModifyCard
a.card-label.add-label.js-end-date(title="{{_ 'card-end'}}")
i.fa.fa-plus
+254
View File
@@ -0,0 +1,254 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { Utils } from '/client/lib/utils';
// Board "Table" view: lists every card of the current board in a table that
// reuses the My Cards table styling (the .my-cards-board-table CSS classes in
// client/components/main/myCards.css). It is the per-board counterpart of the
// My Cards table view, which spans all boards.
//
// Search, column sorting (Excel-like) and pagination all run client-side: the
// board's cards are already loaded reactively via board.cards(), so there is no
// need for the server-side limit/skip publication the Admin People page uses.
const rowsPerPage = 25;
Template.tableView.onCreated(function () {
this.searchQuery = new ReactiveVar('');
this.sortField = new ReactiveVar('card'); // card | list | swimlane | due
this.sortDirection = new ReactiveVar(1); // 1 ascending, -1 descending
this.page = new ReactiveVar(1);
this.filteredRows = new ReactiveVar([]);
// Recompute the flat, filtered and sorted row list whenever the board cards,
// search query or sort order change. Pagination is applied separately in the
// rows() helper so paging does not rebuild the whole list.
this.autorun(() => {
const board = Utils.getCurrentBoard();
if (!board) {
this.filteredRows.set([]);
return;
}
const query = this.searchQuery.get().trim().toLowerCase();
const field = this.sortField.get();
const direction = this.sortDirection.get();
const rows = [];
board.cards().forEach(card => {
const swimlane = card.getSwimlane();
const list = card.getList();
if (!swimlane || swimlane.archived || !list || list.archived) return;
const labels = (card.labelIds || [])
.map(labelId => {
const label = board.getLabelById(labelId);
return label ? { name: label.name || '', color: label.color } : null;
})
.filter(Boolean);
rows.push({
card,
title: card.title || '',
listTitle: list.title || '',
swimlaneTitle: swimlane.title || '',
colorClass: board.colorClass(),
receivedAt: card.getReceived() || null,
startAt: card.getStart() || null,
dueAt: card.getDue() || null,
endAt: card.getEnd() || null,
labels,
});
});
let filtered = rows;
if (query) {
filtered = rows.filter(row => {
const haystack = [
row.title,
row.listTitle,
row.swimlaneTitle,
...row.labels.map(label => label.name),
]
.join(' ')
.toLowerCase();
return haystack.indexOf(query) !== -1;
});
}
// Map a date sort field to the matching row property.
const dateFieldProp = {
received: 'receivedAt',
start: 'startAt',
due: 'dueAt',
end: 'endAt',
};
filtered = filtered.slice().sort((a, b) => {
const dateProp = dateFieldProp[field];
if (dateProp) {
// Cards without the date sort last, regardless of direction.
const av = a[dateProp] ? new Date(a[dateProp]).getTime() : Infinity;
const bv = b[dateProp] ? new Date(b[dateProp]).getTime() : Infinity;
return (av - bv) * direction;
}
let av;
let bv;
if (field === 'list') {
av = a.listTitle;
bv = b.listTitle;
} else if (field === 'swimlane') {
av = a.swimlaneTitle;
bv = b.swimlaneTitle;
} else {
av = a.title;
bv = b.title;
}
return (
av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' }) *
direction
);
});
this.filteredRows.set(filtered);
});
});
Template.tableView.helpers({
currentBoard() {
return Utils.getCurrentBoard();
},
rows() {
const tpl = Template.instance();
const all = tpl.filteredRows.get();
const totalPages = Math.max(1, Math.ceil(all.length / rowsPerPage));
// Clamp on read so a shrinking list (deleted cards) never shows an empty
// page; no write here, to avoid a reactive loop.
const page = Math.min(tpl.page.get(), totalPages);
const start = (page - 1) * rowsPerPage;
return all.slice(start, start + rowsPerPage);
},
currentPage() {
return Template.instance().page.get();
},
totalPages() {
const count = Template.instance().filteredRows.get().length;
return Math.max(1, Math.ceil(count / rowsPerPage));
},
hasPrevPage() {
return Template.instance().page.get() > 1;
},
hasNextPage() {
const tpl = Template.instance();
const totalPages = Math.max(
1,
Math.ceil(tpl.filteredRows.get().length / rowsPerPage),
);
return tpl.page.get() < totalPages;
},
// A date column is shown unless BOTH its "Show at Card" (allowsXxxDate) and
// "Show at Minicard" (allowsXxxDateOnMinicard) board settings are unchecked.
showReceivedColumn() {
const board = Utils.getCurrentBoard();
return !!board && (board.allowsReceivedDate || board.allowsReceivedDateOnMinicard);
},
showStartColumn() {
const board = Utils.getCurrentBoard();
return !!board && (board.allowsStartDate || board.allowsStartDateOnMinicard);
},
showDueColumn() {
const board = Utils.getCurrentBoard();
return !!board && (board.allowsDueDate || board.allowsDueDateOnMinicard);
},
showEndColumn() {
const board = Utils.getCurrentBoard();
return !!board && (board.allowsEndDate || board.allowsEndDateOnMinicard);
},
// Excel-like sort arrow shown on the active sort column header.
sortIndicator(field) {
const tpl = Template.instance();
if (tpl.sortField.get() !== field) return '';
return tpl.sortDirection.get() === 1 ? '▲' : '▼';
},
});
Template.tableView.events({
'click .js-table-view-search-button'(event, tpl) {
event.preventDefault();
tpl.searchQuery.set(tpl.$('.js-table-view-search').val() || '');
tpl.page.set(1);
},
'keydown .js-table-view-search'(event, tpl) {
if (event.keyCode === 13) {
event.preventDefault();
tpl.searchQuery.set(tpl.$('.js-table-view-search').val() || '');
tpl.page.set(1);
}
},
'click .js-table-view-prev-page'(event, tpl) {
event.preventDefault();
const current = tpl.page.get();
if (current > 1) tpl.page.set(current - 1);
},
'click .js-table-view-next-page'(event, tpl) {
event.preventDefault();
const totalPages = Math.max(
1,
Math.ceil(tpl.filteredRows.get().length / rowsPerPage),
);
const current = tpl.page.get();
if (current < totalPages) tpl.page.set(current + 1);
},
'click .js-table-view-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);
},
// Clicking the leftmost "Edit" link opens the Card Details popup on top of the
// Board Table view (same mechanism as opening a card from search results).
'click .js-table-view-edit-card'(event) {
event.preventDefault();
const cardId = event.currentTarget.dataset.cardId;
if (!cardId) return;
const board = Utils.getCurrentBoard();
Meteor.subscribe('popupCardData', cardId, {
onReady() {
Session.set('popupCardId', cardId);
if (board) Session.set('popupCardBoardId', board._id);
if (!Popup.isOpen()) {
Popup.open('cardDetails')(event);
}
},
});
},
// Adding a date to a card that has none. The data context of each add button
// is the card (set with `with row.card` in the template), so the popup edits
// the right card. Editing an existing date is handled by the cardXxxDate
// badge templates themselves (their own .js-edit-date click handlers).
'click .js-received-date': Popup.open('editCardReceivedDate'),
'click .js-start-date': Popup.open('editCardStartDate'),
'click .js-due-date': Popup.open('editCardDueDate'),
'click .js-end-date': Popup.open('editCardEndDate'),
});
+1 -1
View File
@@ -290,7 +290,7 @@ Template.attachmentGallery.helpers({
return filesCursor.each();
},
isBoardAdmin() {
return ReactiveCache.getCurrentUser().isBoardAdmin();
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
},
fileSize(size) {
const ret = filesize(size);
@@ -44,13 +44,13 @@ template(name="cardCustomField-number")
a.js-close-inlined-form
else
a.js-open-inlined-form
if value
= value
if formattedValue
= formattedValue
else
| {{_ 'edit'}}
else
if value
= value
if formattedValue
= formattedValue
template(name="cardCustomField-checkbox")
.js-checklist-item.checklist-item(class="{{#if data.value }}is-checked{{/if}}")
+13 -1
View File
@@ -29,6 +29,7 @@ import {
} from '/imports/lib/dateUtils';
import { CustomFieldStringTemplate } from '/client/lib/customFields'
import { getCurrentCardFromContext } from '/client/lib/currentCard';
import { formatNumberValue } from '/imports/lib/customNumberFormat';
import { EscapeActions } from '/client/lib/escapeActions';
import { getSidebarInstance } from '/client/features/sidebar/service';
@@ -91,10 +92,21 @@ Template['cardCustomField-number'].onCreated(function () {
this.customFieldId = Template.currentData()._id;
});
Template['cardCustomField-number'].helpers({
// Render blank / cleared / non-numeric values as empty instead of "NaN" (#2091).
formattedValue() {
return formatNumberValue(this.value);
},
});
Template['cardCustomField-number'].events({
'submit .js-card-customfield-number'(event, tpl) {
event.preventDefault();
const value = parseInt(tpl.find('input').value, 10);
const rawValue = tpl.find('input').value;
// A cleared/blank input parses to NaN; store '' instead so it renders empty
// rather than as "NaN" (#2091).
const parsed = parseInt(rawValue, 10);
const value = Number.isNaN(parsed) ? '' : parsed;
tpl.card.setCustomField(tpl.customFieldId, value);
},
});
+26 -22
View File
@@ -8,23 +8,27 @@
.card-date.is-active {
background-color: #b3b3b3;
}
/* Date status colors - red = overdue, amber = due soon, no shade = not due */
/* Date status colors - red = overdue, amber = due soon, no shade = not due.
* These status rules use !important so they override the date-type base
* colours below (e.g. .card-date.due-date yellow). Without this, an overdue
* due-date badge in the card detail showed yellow/amber instead of red, while
* the minicard (which already used !important) showed red - issue #5965. */
.card-date.overdue {
background-color: #ff4444; /* Red for overdue */
color: #fff;
background-color: #ff4444 !important; /* Red for overdue */
color: #fff !important;
}
.card-date.overdue:hover,
.card-date.overdue.is-active {
background-color: #cc3333;
background-color: #cc3333 !important;
}
.card-date.due-soon {
background-color: #ffaa00; /* Amber for due soon */
color: #000;
background-color: #ffaa00 !important; /* Amber for due soon */
color: #000 !important;
}
.card-date.due-soon:hover,
.card-date.due-soon.is-active {
background-color: #e69900;
background-color: #e69900 !important;
}
.card-date.not-due {
@@ -32,48 +36,48 @@
}
.card-date.current {
background-color: #5ba639; /* Green for current/active */
color: #fff;
background-color: #5ba639 !important; /* Green for current/active */
color: #fff !important;
}
.card-date.current:hover,
.card-date.current.is-active {
background-color: #46802c;
background-color: #46802c !important;
}
.card-date.completed {
background-color: #90ee90; /* Light green for completed */
color: #000;
background-color: #90ee90 !important; /* Light green for completed */
color: #000 !important;
}
.card-date.completed:hover,
.card-date.completed.is-active {
background-color: #7dd87d;
background-color: #7dd87d !important;
}
.card-date.completed-early {
background-color: #4caf50; /* Green for completed early */
color: #fff;
background-color: #4caf50 !important; /* Green for completed early */
color: #fff !important;
}
.card-date.completed-early:hover,
.card-date.completed-early.is-active {
background-color: #45a049;
background-color: #45a049 !important;
}
.card-date.completed-late {
background-color: #ff9800; /* Orange for completed late */
color: #fff;
background-color: #ff9800 !important; /* Orange for completed late */
color: #fff !important;
}
.card-date.completed-late:hover,
.card-date.completed-late.is-active {
background-color: #f57c00;
background-color: #f57c00 !important;
}
.card-date.completed-on-time {
background-color: #2196f3; /* Blue for completed on time */
color: #fff;
background-color: #2196f3 !important; /* Blue for completed on time */
color: #fff !important;
}
.card-date.completed-on-time:hover,
.card-date.completed-on-time.is-active {
background-color: #1976d2;
background-color: #1976d2 !important;
}
/* Date type specific colors */
+4 -38
View File
@@ -25,9 +25,9 @@ import {
now,
createDate,
fromNow,
calendar,
diff
calendar
} from '/imports/lib/dateUtils';
import { dueDateClass } from '/client/lib/dueDateColor';
// --- DatePicker popups (edit date forms) ---
@@ -257,29 +257,12 @@ Template.cardDueDate.onCreated(function () {
Template.cardDueDate.helpers(cardDateHelpers({
classes() {
const tpl = Template.instance();
let classes = 'due-date ';
const data = Template.currentData();
const endAt = data.getEnd();
const theDate = tpl.date.get();
const nowVal = tpl.now.get();
if (endAt && isBefore(endAt, theDate)) {
classes += 'completed-early';
} else if (endAt) {
classes += 'completed';
} else {
const daysDiff = diff(theDate, nowVal, 'days');
if (daysDiff < 0) {
classes += 'overdue';
} else if (daysDiff <= 1) {
classes += 'due-soon';
} else {
classes += 'not-due';
}
}
return classes;
return `due-date ${dueDateClass(theDate, nowVal, endAt)}`;
},
showTitle() {
const tpl = Template.instance();
@@ -471,29 +454,12 @@ Template.minicardDueDate.onCreated(function () {
Template.minicardDueDate.helpers(cardDateHelpers({
classes() {
const tpl = Template.instance();
let classes = 'due-date ';
const data = Template.currentData();
const endAt = data.getEnd();
const theDate = tpl.date.get();
const nowVal = tpl.now.get();
if (endAt && isBefore(endAt, theDate)) {
classes += 'completed-early';
} else if (endAt) {
classes += 'completed';
} else {
const daysDiff = diff(theDate, nowVal, 'days');
if (daysDiff < 0) {
classes += 'overdue';
} else if (daysDiff <= 1) {
classes += 'due-soon';
} else {
classes += 'not-due';
}
}
return classes;
return `due-date ${dueDateClass(theDate, nowVal, endAt)}`;
},
showTitle() {
const tpl = Template.instance();
+49 -5
View File
@@ -504,16 +504,21 @@ input[type="submit"].attachment-add-link-submit {
inset-inline-start: 0 !important;
inset-inline-end: 0 !important;
bottom: 0 !important;
z-index: 100 !important;
/* #6419: the open card is a full-screen takeover on mobile, so it must sit
ABOVE the app header bar (#header-main-bar is z-index 1000) — otherwise the
header overlaps the top of the card and hides the close (X) button, making
it impossible to close the card. Kept below popups (z-index 2000+) so card
menus / date pickers still open over the card. */
z-index: 1100 !important;
height: 100vh !important;
max-height: 100vh !important;
border-radius: 0 !important;
box-shadow: none !important;
}
/* Ensure card details are above everything on mobile */
/* Ensure card details are above the app header on mobile (#6419) */
body.mobile-mode .card-details {
z-index: 100 !important;
z-index: 1100 !important;
width: 100vw !important;
inset-inline-start: 0 !important;
inset-inline-end: 0 !important;
@@ -601,7 +606,8 @@ body.mobile-mode .card-details {
inset-inline-start: 0 !important;
inset-inline-end: 0 !important;
bottom: 0 !important;
z-index: 100 !important;
/* #6419: above the app header (#header-main-bar z-index 1000), below popups. */
z-index: 1100 !important;
height: 100vh !important;
max-height: 100vh !important;
border-radius: 0 !important;
@@ -1029,13 +1035,40 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
base .pop-over is position:absolute with a small margin/top offset, which left
the card's top behind the fixed header bars; fix it to the viewport top with a
z-index above every header (headers top out around z-index 1000-2000). */
/* The card details content (.card-details) is always position:fixed (see the
min-width:801px and max-width:800px media queries above), so it renders as its
own framed, viewport-positioned box and escapes this .pop-over wrapper. That
leaves the generic .pop-over chrome (white background, border, shadow, header
and ~380px width) visible as a redundant empty second popup at the top of the
page. Collapse the wrapper to nothing and make it invisible; the fixed card
descendant still renders normally on top of the page. Closing is handled by
the card's own close button (.js-close-card-details), clicking outside, or
pressing Escape. */
.pop-over[data-popup="cardDetailsPopup"] {
position: fixed !important;
top: 0 !important;
margin-top: 0 !important;
inset-inline-start: 0 !important;
margin: 0 !important;
padding: 0 !important;
width: 0 !important;
height: 0 !important;
min-height: 0 !important;
background: none !important;
border: none !important;
box-shadow: none !important;
overflow: visible !important;
z-index: 100000 !important;
}
.pop-over[data-popup="cardDetailsPopup"] > .header {
display: none !important;
}
.pop-over[data-popup="cardDetailsPopup"] > .content-wrapper {
max-height: none !important;
overflow: visible !important;
}
/* Sticker highlight styles inside the sticker picker (mascot = underline,
computer = round), matching how they render on the card. */
.card-sticker-select i.sticker-underline {
@@ -1054,3 +1087,14 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
line-height: 1;
box-sizing: border-box;
}
/* #6431 follow-up: cover image on the opened card (Card Settings > Cover image
on card, opt-in). Mirrors the minicard cover but taller. */
.card-details-cover {
background-position: center;
background-repeat: no-repeat;
background-size: contain;
height: 200px;
user-select: none;
margin: 8px 0;
}
+98 -18
View File
@@ -62,8 +62,9 @@ template(name="cardDetails")
i.fa.fa-link
span.copied-tooltip {{_ 'copied'}}
if canModifyCard
a.card-details-complete-toggle.js-toggle-due-complete(title="{{#if getDueComplete}}{{_ 'card-mark-incomplete'}}{{else}}{{_ 'card-mark-complete'}}{{/if}}")
.materialCheckBox(class="{{#if getDueComplete}}is-checked{{/if}}")
if currentBoard.allowsDueComplete
a.card-details-complete-toggle.js-toggle-due-complete(title="{{#if getDueComplete}}{{_ 'card-mark-incomplete'}}{{else}}{{_ 'card-mark-complete'}}{{/if}}")
.materialCheckBox(class="{{#if getDueComplete}}is-checked{{/if}}")
h2.card-details-title.js-card-title(
class="{{#if canModifyCard}}js-open-inlined-form is-editable{{else}}js-card-title-drag-handle{{/if}}")
+viewer
@@ -111,6 +112,11 @@ template(name="cardDetails")
i.fa.fa-check
span {{_ 'upload-completed'}}
//- #6431 follow-up: cover image on the opened card (opt-in, OFF by default).
if currentBoard.allowsCoverAttachmentOnCard
if cover
.card-details-cover(style="background-image: url('{{cover.link 'original'}}');")
.card-details-left
.card-details-items
@@ -170,6 +176,8 @@ template(name="cardDetails")
a.card-label.add-label.js-add-location(title="{{_ 'add-location'}}")
i.fa.fa-plus
+cardButtons(_id=_id boardId=boardId)
if currentBoard.hasAnyAllowsDate
hr
@@ -221,6 +229,9 @@ template(name="cardDetails")
unless currentUser.isWorker
a.card-label.add-label.js-due-date
i.fa.fa-plus
if dueDateChangeCount
.card-details-due-date-changes(title="{{_ 'due-date-changes'}}")
| {{_ 'due-date-changed-times' dueDateChangeCount}}
if currentBoard.allowsEndDate
.card-details-item.card-details-item-end
@@ -319,6 +330,36 @@ template(name="cardDetails")
+viewer
= getAssignedBy
.card-details-item.card-details-item-dependencies
h3.card-details-item-title
i.fa.fa-link
| {{_ 'card-dependencies'}}
.card-details-dependencies-list
each getDependencyCards
.card-dependency(data-target-id="{{card._id}}")
i.fa(class="fa-{{icon}}" style="color:{{color}}")
a.card-dependency-link(href="{{linkUrl}}")
+viewer
= card.title
if canModifyCard
select.js-dependency-type(data-target-id="{{card._id}}")
each typeOption
if selected
option(value="{{id}}" selected="selected") {{_ label}}
else
option(value="{{id}}") {{_ label}}
input.js-dependency-color(type="color" value="{{color}}" data-target-id="{{card._id}}" title="{{_ 'dependency-color'}}")
a.js-dependency-icon(data-target-id="{{card._id}}" title="{{_ 'dependency-icon'}}")
i.fa.fa-pencil
a.card-dependency-remove.js-remove-dependency(data-target-id="{{card._id}}" title="{{_ 'remove-dependency'}}")
i.fa.fa-times
else
span.card-dependency-type {{_ typeLabel}}
if canModifyCard
a.js-add-dependency(title="{{_ 'add-dependency'}}")
i.fa.fa-plus
| {{_ 'add-dependency'}}
if $or currentBoard.allowsCardSortingByNumber getSpentTime
hr
@@ -648,6 +689,14 @@ template(name="cardDetails")
.card-checklist-attachmentGallery.card-checklists
if currentBoard.allowsChecklists
hr
//- Total checklist item count (finished/total) shown above all
checklists. The board flag is a cheap boolean, so when it is off
the checklist counting below is never evaluated.
if currentBoard.allowsChecklistCountBadgeOnCard
if checklists.length
h3.card-details-item-title
i.fa.fa-check-square-o
| {{checklistFinishedCount}}/{{checklistItemCount}}
+checklists(cardId = _id card = this)
if currentBoard.allowsSubtasks
hr
@@ -657,6 +706,8 @@ template(name="cardDetails")
h3.card-details-item-title
i.fa.fa-paperclip
| {{_ 'attachments'}}
if currentBoard.allowsAttachmentCountOnCard
| ({{attachments.length}})
if Meteor.settings.public.attachmentsUploadMaxSize
| {{_ 'max-upload-filesize'}} {{Meteor.settings.public.attachmentsUploadMaxSize}}
br
@@ -912,22 +963,22 @@ template(name="moveCardPopup")
label {{_ 'boards'}}:
select.js-select-boards(autofocus)
each boards
option(value="{{_id}}" selected="{{#if isDialogOptionBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
option(value="{{_id}}" selected="{{#if isSelectedBoardId _id}}selected{{/if}}") {{title}}
label {{_ 'swimlanes'}}:
select.js-select-swimlanes
each swimlanes
option(value="{{_id}}" selected="{{#if isDialogOptionSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
option(value="{{_id}}" selected="{{#if isSelectedSwimlaneId _id}}selected{{/if}}") {{isTitleDefault title}}
label {{_ 'lists'}}:
select.js-select-lists
each lists
option(value="{{_id}}" selected="{{#if isDialogOptionListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
option(value="{{_id}}" selected="{{#if isSelectedListId _id}}selected{{/if}}") {{title}}
label {{_ 'cards'}}:
select.js-select-cards
each cards
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{title}}
div
input(type="radio" name="position" value="above" checked id="position-above" style="display: inline")
@@ -947,22 +998,22 @@ template(name="copyCardPopup")
label {{_ 'boards'}}:
select.js-select-boards(autofocus)
each boards
option(value="{{_id}}" selected="{{#if isDialogOptionBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
option(value="{{_id}}" selected="{{#if isSelectedBoardId _id}}selected{{/if}}") {{title}}
label {{_ 'swimlanes'}}:
select.js-select-swimlanes
each swimlanes
option(value="{{_id}}" selected="{{#if isDialogOptionSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
option(value="{{_id}}" selected="{{#if isSelectedSwimlaneId _id}}selected{{/if}}") {{isTitleDefault title}}
label {{_ 'lists'}}:
select.js-select-lists
each lists
option(value="{{_id}}" selected="{{#if isDialogOptionListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
option(value="{{_id}}" selected="{{#if isSelectedListId _id}}selected{{/if}}") {{title}}
label {{_ 'cards'}}:
select.js-select-cards
each cards
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{title}}
div
input(type="radio" name="position" value="above" checked id="position-above" style="display: inline")
@@ -982,22 +1033,22 @@ template(name="copyManyCardsPopup")
label {{_ 'boards'}}:
select.js-select-boards(autofocus)
each boards
option(value="{{_id}}" selected="{{#if isDialogOptionBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
option(value="{{_id}}" selected="{{#if isSelectedBoardId _id}}selected{{/if}}") {{title}}
label {{_ 'swimlanes'}}:
select.js-select-swimlanes
each swimlanes
option(value="{{_id}}" selected="{{#if isDialogOptionSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
option(value="{{_id}}" selected="{{#if isSelectedSwimlaneId _id}}selected{{/if}}") {{isTitleDefault title}}
label {{_ 'lists'}}:
select.js-select-lists
each lists
option(value="{{_id}}" selected="{{#if isDialogOptionListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
option(value="{{_id}}" selected="{{#if isSelectedListId _id}}selected{{/if}}") {{title}}
label {{_ 'cards'}}:
select.js-select-cards
each cards
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{title}}
div
input(type="radio" name="position" value="above" checked id="position-above" style="display: inline")
@@ -1017,22 +1068,22 @@ template(name="convertChecklistItemToCardPopup")
label {{_ 'boards'}}:
select.js-select-boards(autofocus)
each boards
option(value="{{_id}}" selected="{{#if isDialogOptionBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
option(value="{{_id}}" selected="{{#if isSelectedBoardId _id}}selected{{/if}}") {{title}}
label {{_ 'swimlanes'}}:
select.js-select-swimlanes
each swimlanes
option(value="{{_id}}" selected="{{#if isDialogOptionSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
option(value="{{_id}}" selected="{{#if isSelectedSwimlaneId _id}}selected{{/if}}") {{isTitleDefault title}}
label {{_ 'lists'}}:
select.js-select-lists
each lists
option(value="{{_id}}" selected="{{#if isDialogOptionListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
option(value="{{_id}}" selected="{{#if isSelectedListId _id}}selected{{/if}}") {{title}}
label {{_ 'cards'}}:
select.js-select-cards
each cards
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{title}}
div
input(type="radio" name="position" value="above" checked id="position-above" style="display: inline")
@@ -1286,3 +1337,32 @@ template(name="cardLocationsPopup")
button.primary.js-save-map-provider(type="button") {{_ 'save'}}
if mapSavedMessage
p.quiet.js-map-saved {{mapSavedMessage}}
template(name="cardDependenciesPopup")
.card-dependencies-popup
label.dependency-field
span {{_ 'dependency-type'}}
select.js-new-dependency-type
each typeOption
if selected
option(value="{{id}}" selected="selected") {{_ label}}
else
option(value="{{id}}") {{_ label}}
label.dependency-field
span {{_ 'dependency-color'}}
input.js-new-dependency-color(type="color" value="{{defaultColor}}")
input.js-dependency-search(type="text" placeholder="{{_ 'search'}}" autofocus)
ul.pop-over-list
each candidateCards
li
a.js-pick-dependency(data-target-id="{{_id}}")
+viewer
= title
template(name="cardDependencyIconPopup")
.card-dependency-icon-popup
ul.card-dependency-icons
each dependencyIcons
li
a.js-pick-dependency-icon(data-icon="{{name}}" title="{{name}}")
i.fa(class="fa-{{name}}")
+270 -29
View File
@@ -1,6 +1,21 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import { ReactiveVar } from 'meteor/reactive-var';
import {
DEPENDENCY_TYPES,
DEPENDENCY_ICON_CHOICES,
DEFAULT_DEPENDENCY_COLOR,
DEFAULT_DEPENDENCY_ICON,
} from '/models/metadata/dependencies';
import { canArchiveCard } from '/client/lib/archivePermission';
import { isTextSelectionInsideCard } from '/client/lib/cardCloseGuard';
// Which dependency the icon picker should apply its choice to. The icon popup's
// own data context is the dependency row (not the source card), so we capture
// the source card + target id here when the picker is opened.
let editingDependencyTargetId = null;
let editingDependencyCard = null;
import {
setupDatePicker,
datePickerRendered,
@@ -51,7 +66,6 @@ import { UnsavedEdits } from '/client/lib/unsavedEdits';
import { EscapeActions } from '/client/lib/escapeActions';
import { MultiSelection } from '/client/lib/multiSelection';
import { Utils } from '/client/lib/utils';
import { ReactiveVar } from 'meteor/reactive-var';
import autosize from 'autosize';
// Id of the location currently being edited in the cardLocationsPopup; null
@@ -504,6 +518,14 @@ Template.cardDetails.helpers({
return card.findWatcher(Meteor.userId());
},
// #6081: number of times this card's due date has been changed, for
// accountability. Returns 0 when unavailable so the template can hide it.
dueDateChangeCount() {
const card = Template.currentData();
if (!card || typeof card.getDueDateChangeCount !== 'function') return 0;
return card.getDueDateChangeCount();
},
// Returns the card's locations (multiple supported), each enriched with the
// coordinate flag and OpenStreetMap link used by the template.
getLocations() {
@@ -521,6 +543,34 @@ Template.cardDetails.helpers({
});
},
// #3392: PI Program Board "Red Strings". Resolve this card's dependencies
// into card objects (with relation type, color, icon and a relative link).
getDependencyCards() {
const card = Template.currentData();
if (!card || typeof card.getDependencies !== 'function') return [];
return card
.getDependencies()
.map(dep => {
const target = ReactiveCache.getCard(dep.cardId);
if (!target) return null;
return {
card: target,
linkUrl: target.originRelativeUrl(),
type: dep.type,
color: dep.color,
icon: dep.icon,
typeLabel: `dependency-type-${dep.type}`,
// Per-row relation-type dropdown options with the current one marked.
typeOption: DEPENDENCY_TYPES.map(t => ({
id: t.id,
label: `dependency-type-${t.id}`,
selected: t.id === dep.type,
})),
};
})
.filter(Boolean);
},
customFieldsGrid() {
return ReactiveCache.getCurrentUser().hasCustomFieldsGrid();
},
@@ -577,18 +627,24 @@ Template.cardDetails.helpers({
showVotingButtons() {
const card = Template.currentData();
// #6420: currentUser was referenced but never defined here, so the helper
// threw "ReferenceError: currentUser is not defined" on every card render and
// the voting buttons disappeared. Define it and guard the board-member call.
const currentUser = ReactiveCache.getCurrentUser();
return (
(currentUser.isBoardMember() ||
(currentUser && card.voteAllowNonBoardMembers())) &&
currentUser &&
(currentUser.isBoardMember() || card.voteAllowNonBoardMembers()) &&
!card.expiredVote()
);
},
showPlanningPokerButtons() {
const card = Template.currentData();
// #6420: same as showVotingButtons — currentUser was undefined here.
const currentUser = ReactiveCache.getCurrentUser();
return (
(currentUser.isBoardMember() ||
(currentUser && card.pokerAllowNonBoardMembers())) &&
currentUser &&
(currentUser.isBoardMember() || card.pokerAllowNonBoardMembers()) &&
!card.expiredPoker()
);
},
@@ -711,6 +767,17 @@ Template.cardDetails.events({
event.preventDefault();
event.stopPropagation();
// When the card is shown inside a popup (opened from the Board Table view,
// search results, or a mini-screen list), the redundant popup title header
// is hidden, so this is the only close button. Close the popup and clear its
// session state instead of running the board/route close flow below.
if (Popup.isOpen() && Utils.getPopupCardId()) {
Session.delete('popupCardId');
Session.delete('popupCardBoardId');
Popup.close();
return;
}
// Resolve card context defensively because cardDetails can be rendered
// from several parents (board, popup, gantt, etc.).
const card = getCurrentCardFromContext({ ignorePopupCard: true }) || Template.currentData();
@@ -792,17 +859,25 @@ Template.cardDetails.events({
event.preventDefault();
const description = tpl.find('.js-new-description-input').value;
const card = Template.currentData();
await card.setDescription(description);
// #5809: surface a visible error instead of failing silently — e.g. editing
// a linked card whose target is on a board the user cannot write to gets a
// permission denial that otherwise vanished with no feedback.
try {
await card.setDescription(description);
} catch (error) {
alert(error?.reason || error?.message || 'Failed to save description');
}
},
async 'submit .js-card-details-title'(event, tpl) {
event.preventDefault();
const titleInput = tpl.find('.js-edit-card-title');
const title = titleInput ? titleInput.value.trim() : '';
const card = Template.currentData();
if (title) {
await card.setTitle(title);
} else {
await card.setTitle('');
// #5809: surface a visible error instead of failing silently (see above).
try {
await card.setTitle(title || '');
} catch (error) {
alert(error?.reason || error?.message || 'Failed to save title');
}
},
'submit .js-card-details-assigner'(event, tpl) {
@@ -853,6 +928,40 @@ Template.cardDetails.events({
const card = Template.currentData();
Utils.goCardId(card.linkedId);
},
'click .js-add-dependency': Popup.open('cardDependencies'),
'click .js-remove-dependency'(event) {
event.preventDefault();
event.stopPropagation();
if (!Utils.canModifyCard()) return;
const targetId = event.currentTarget.dataset.targetId;
const card = Template.currentData();
if (card && targetId) {
card.removeDependency(targetId);
}
},
'change .js-dependency-type'(event) {
if (!Utils.canModifyCard()) return;
const targetId = event.currentTarget.dataset.targetId;
const card = Template.currentData();
if (card && targetId) {
card.setDependencyProps(targetId, { type: event.currentTarget.value });
}
},
'change .js-dependency-color'(event) {
if (!Utils.canModifyCard()) return;
const targetId = event.currentTarget.dataset.targetId;
const card = Template.currentData();
if (card && targetId) {
card.setDependencyProps(targetId, { color: event.currentTarget.value });
}
},
'click .js-dependency-icon'(event) {
if (!Utils.canModifyCard()) return;
// Remember the source card + which dependency the picked icon applies to.
editingDependencyCard = Template.currentData();
editingDependencyTargetId = event.currentTarget.dataset.targetId;
Popup.open('cardDependencyIcon')(event);
},
'click .js-member': Popup.open('cardMember'),
'click .js-add-members': Popup.open('cardMembers'),
'click .js-assignee': Popup.open('cardAssignee'),
@@ -1152,7 +1261,7 @@ const EXCEL_EXPORT_FIELDS = [
{ field: 'checklists', label: 'checklists' },
{ field: 'subtasks', label: 'export-card-subtasks' },
{ field: 'comments', label: 'comments' },
{ field: 'attachments', label: 'export-card-attachments' },
{ field: 'attachments', label: 'attachments' },
];
Template.exportCardPopup.onCreated(function () {
@@ -1307,7 +1416,7 @@ Template.cardDetailsActionsPopup.helpers({
},
isBoardAdmin() {
return ReactiveCache.getCurrentUser().isBoardAdmin();
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
},
showListOnMinicard() {
@@ -1350,6 +1459,7 @@ Template.cardDetailsActionsPopup.events({
Popup.back();
},
'click .js-archive': Popup.afterConfirm('cardArchive', async function () {
if (!canArchiveCard({ canModifyCard: Utils.canModifyCard() })) return;
const card = Cards.findOne(getCardId());
Popup.close();
if (!card) return;
@@ -1396,9 +1506,13 @@ Template.editCardTitleForm.events({
});
Template.cardMembersPopup.onCreated(function () {
let currBoard = Utils.getCurrentBoard();
let members = currBoard.activeMembers();
this.members = new ReactiveVar(members);
// #4965: only the filter term is stored reactively; the candidate list is
// derived reactively in the members() helper (via filterMembers, which reads
// board.activeMembers() on each call). Previously the list was snapshotted
// once here, so a member added to the board after the popup opened — or whose
// user document finished loading after open — was missing until the popup was
// reopened.
this.filterTerm = new ReactiveVar('');
});
Template.cardMembersPopup.events({
@@ -1410,8 +1524,7 @@ Template.cardMembersPopup.events({
event.preventDefault();
},
'keyup .card-members-filter'(event) {
const members = filterMembers(event.target.value);
Template.instance().members.set(members);
Template.instance().filterTerm.set(event.target.value);
}
});
@@ -1425,7 +1538,7 @@ Template.cardMembersPopup.helpers({
},
members() {
const members = Template.instance().members.get();
const members = filterMembers(Template.instance().filterTerm.get());
const uniqueMembers = uniqBy(members, 'userId');
return [...uniqueMembers].sort((a, b) => {
const userA = ReactiveCache.getUser(a.userId);
@@ -1621,6 +1734,15 @@ function registerCardDialogTemplate(templateName) {
isDialogOptionListId(listId) {
return Template.instance().dialog.isDialogOptionListId(listId);
},
isSelectedBoardId(boardId) {
return Template.instance().dialog.isSelectedBoardId(boardId);
},
isSelectedSwimlaneId(swimlaneId) {
return Template.instance().dialog.isSelectedSwimlaneId(swimlaneId);
},
isSelectedListId(listId) {
return Template.instance().dialog.isSelectedListId(listId);
},
isDialogOptionCardId(cardId) {
return Template.instance().dialog.isDialogOptionCardId(cardId);
},
@@ -1632,14 +1754,16 @@ function registerCardDialogTemplate(templateName) {
Template[templateName].events({
async 'click .js-done'(event, tpl) {
const dialog = tpl.dialog;
const boardSelect = tpl.$('.js-select-boards')[0];
const boardId = boardSelect?.options[boardSelect?.selectedIndex]?.value;
const listSelect = tpl.$('.js-select-lists')[0];
const listId = listSelect?.options[listSelect?.selectedIndex]?.value;
const swimlaneSelect = tpl.$('.js-select-swimlanes')[0];
const swimlaneId = swimlaneSelect?.options[swimlaneSelect?.selectedIndex]?.value;
// Read the target board/swimlane/list from the dialog's live reactive
// selection rather than the DOM <select> elements. A reactive re-render
// (e.g. the boards() helper transiently returning [] while a 'board'
// subscription re-resolves) can leave a <select> momentarily option-less,
// making selectedIndex -1 and the scraped id undefined — which then makes
// card.move()/copyCard() fail with a 403 "may only update documents by ID"
// validation error. The reactive vars stay correct across those renders.
const boardId = dialog.selectedBoardId.get();
const swimlaneId = dialog.selectedSwimlaneId.get();
const listId = dialog.selectedListId.get();
const cardSelect = tpl.$('.js-select-cards')[0];
const cardId = cardSelect?.options?.length > 0
@@ -1969,6 +2093,11 @@ Template.cardMorePopup.onCreated(function () {
const cardId = getCardId();
this.currentCard = Cards.findOne(cardId);
this.parentBoard = new ReactiveVar(null);
// #3745: tracks whether the selected parent board's cards have finished
// loading. The card list stays empty until the subscription is ready, so it
// is no longer blank the first time another board is picked (the cards()
// helper queries minimongo, which was empty before the subscription arrived).
this.parentBoardReady = new ReactiveVar(true);
this.parentCard = this.currentCard?.parentCard();
if (this.parentCard) {
const list = $('.js-field-parent-card');
@@ -2007,7 +2136,10 @@ Template.cardMorePopup.helpers({
cards() {
const tpl = Template.instance();
const currentId = getCardId();
if (tpl.parentBoard.get()) {
// #3745: don't list cards until the selected board's subscription is ready,
// otherwise the first open shows an empty list. Depending on parentBoardReady
// also re-runs this helper once the data has arrived.
if (tpl.parentBoard.get() && tpl.parentBoardReady.get()) {
const ret = ReactiveCache.getCards({
boardId: tpl.parentBoard.get(),
_id: { $ne: currentId },
@@ -2072,7 +2204,14 @@ Template.cardMorePopup.events({
if (selection === 'none') {
tpl.parentBoard.set(null);
} else {
Meteor.subscribe('board', $(event.currentTarget).val(), false);
// #3745: wait for the board subscription to be ready before showing its
// cards, so the parent-card list is populated on the first selection.
tpl.parentBoardReady.set(false);
Meteor.subscribe('board', selection, false, {
onReady() {
tpl.parentBoardReady.set(true);
},
});
tpl.parentBoard.set(selection);
list.prop('disabled', false);
}
@@ -2296,7 +2435,17 @@ EscapeActions.register(
}
}
}
if (Session.get('cardDetailsIsDragging')) {
// #5686: selecting the text of a checklist item (whose mousedown stops
// propagation, so cardDetailsIsDragging is never set) and releasing the
// mouse outside the card must NOT close the card. Treat a live text
// selection anchored inside the card pane the same as an in-card drag.
const selectingInsideCard = isTextSelectionInsideCard(
typeof window !== 'undefined' && window.getSelection
? window.getSelection()
: null,
getCardDetailsElement(getCurrentCardFromContext()?._id),
);
if (Session.get('cardDetailsIsDragging') || selectingInsideCard) {
// Reset dragging status as the mouse landed outside the cardDetails template area and this will prevent a mousedown event from firing
Session.set('cardDetailsIsDragging', false);
Session.set('cardDetailsIsMouseDown', false);
@@ -2395,3 +2544,95 @@ Template.cardAssigneePopup.events({
},
'click .js-edit-profile': Popup.open('editProfile'),
});
// #3392: PI Program Board "Red Strings". Popup to pick another card on the
// same board to add as a dependency. The popup's data context is the source
// card (set by Popup.open on the .js-add-dependency element inside cardDetails).
Template.cardDependenciesPopup.onCreated(function () {
this.searchTerm = new ReactiveVar('');
this.newType = new ReactiveVar(DEPENDENCY_TYPES[0].id);
this.newColor = new ReactiveVar(DEFAULT_DEPENDENCY_COLOR);
});
Template.cardDependenciesPopup.helpers({
defaultColor() {
return Template.instance().newColor.get();
},
typeOption() {
const current = Template.instance().newType.get();
return DEPENDENCY_TYPES.map(t => ({
id: t.id,
label: `dependency-type-${t.id}`,
selected: t.id === current,
}));
},
candidateCards() {
const sourceCard = Template.currentData();
if (!sourceCard) return [];
const term = Template.instance().searchTerm.get().toLowerCase();
const existingIds = (sourceCard.getDependencies
? sourceCard.getDependencies()
: []
).map(dep => dep.cardId);
const cards = ReactiveCache.getCards({
boardId: sourceCard.boardId,
archived: false,
});
return cards.filter(card => {
if (card._id === sourceCard._id) return false;
if (existingIds.includes(card._id)) return false;
if (term && !(card.title || '').toLowerCase().includes(term)) return false;
return true;
});
},
});
Template.cardDependenciesPopup.events({
'keyup .js-dependency-search'(event) {
Template.instance().searchTerm.set(event.currentTarget.value || '');
},
'change .js-new-dependency-type'(event) {
Template.instance().newType.set(event.currentTarget.value);
},
'change .js-new-dependency-color'(event) {
Template.instance().newColor.set(event.currentTarget.value);
},
'click .js-pick-dependency'(event) {
event.preventDefault();
const tpl = Template.instance();
const sourceCard = Template.currentData();
const targetId = event.currentTarget.dataset.targetId;
if (sourceCard && targetId) {
sourceCard.addDependency(targetId, {
type: tpl.newType.get(),
color: tpl.newColor.get(),
});
}
Popup.back();
},
});
// #3392: icon picker for an existing dependency. The source card is the popup's
// data context; the target id was stashed on the cardDetails instance by the
// .js-dependency-icon click handler.
Template.cardDependencyIconPopup.helpers({
dependencyIcons() {
return DEPENDENCY_ICON_CHOICES.map(name => ({ name }));
},
});
Template.cardDependencyIconPopup.events({
'click .js-pick-dependency-icon'(event) {
event.preventDefault();
// Use the source card captured when the picker was opened (the popup's own
// data context is the dependency row, which has no setDependencyProps).
const sourceCard = editingDependencyCard;
const icon = event.currentTarget.dataset.icon || DEFAULT_DEPENDENCY_ICON;
if (sourceCard && editingDependencyTargetId) {
sourceCard.setDependencyProps(editingDependencyTargetId, { icon });
}
editingDependencyTargetId = null;
editingDependencyCard = null;
Popup.back();
},
});
+2 -2
View File
@@ -138,7 +138,7 @@ template(name="checklistItems")
i.fa.fa-plus
template(name='checklistItemDetail')
.js-checklist-item.checklist-item(class="{{#if item.isFinished }}is-checked{{#if checklist.hideCheckedChecklistItems}} invisible{{/if}}{{/if}}{{#if checklist.hideAllChecklistItems}} is-checked invisible{{/if}}"
.js-checklist-item.checklist-item(class="{{#if item.isFinished }}is-checked{{#if checklist.isItemHidden item.isFinished}} invisible{{/if}}{{/if}}{{#if checklist.hideAllChecklistItems}} is-checked invisible{{/if}}"
role="checkbox" aria-checked="{{#if item.isFinished }}true{{else}}false{{/if}}" tabindex="0")
if canModifyCard
.check-box-container
@@ -170,7 +170,7 @@ template(name="checklistActionsPopup")
i.fa.fa-eye-slash
| {{_ "hideCheckedChecklistItems"}} ...
.material-toggle-switch(title="{{_ 'hide-checked-items'}}")
if checklist.hideCheckedChecklistItems
if checklist.hideCheckedState
input.toggle-switch(type="checkbox" id="toggleHideCheckedChecklistItems_{{checklist._id}}" checked="checked")
else
input.toggle-switch(type="checkbox" id="toggleHideCheckedChecklistItems_{{checklist._id}}")
+5 -2
View File
@@ -214,7 +214,9 @@ Template.checklists.events({
'click .js-delete-checklist-item': Popup.afterConfirm('checklistItemDelete', function () {
Popup.back();
const item = this?.item || this;
if (item && item._id) {
// #3252: guard against removing a doc already evicted from Minimongo (heavy
// archive/delete churn), which throws "Removed nonexistent document".
if (item && item._id && ChecklistItems.findOne(item._id)) {
ChecklistItems.remove(item._id);
}
}),
@@ -258,7 +260,8 @@ Template.checklistActionsPopup.events({
'click .js-delete-checklist': Popup.afterConfirm('checklistDelete', function () {
Popup.back(2);
const checklist = this.checklist;
if (checklist && checklist._id) {
// #3252: see js-delete-checklist-item — avoid "Removed nonexistent document".
if (checklist && checklist._id && Checklists.findOne(checklist._id)) {
Checklists.remove(checklist._id);
}
}),
+33
View File
@@ -119,6 +119,22 @@
margin-inline-end: 0.4vw;
margin-bottom: 4px;
}
/* Keep text labels on a single compact line. The label name is rendered
inside a `.viewer`, whose global `min-height: 2.5vh` otherwise forces every
label to roughly double height on the minicard. */
.minicard .minicard-labels .card-label {
min-height: 0;
padding: 1px 6px;
line-height: 1.3;
white-space: nowrap;
}
.minicard .minicard-labels .card-label .viewer {
min-height: 0;
display: inline;
}
.minicard .minicard-labels .card-label .viewer p {
display: inline;
}
.minicard .minicard-labels-no-text {
display: flex;
flex-wrap: wrap;
@@ -866,3 +882,20 @@
line-height: 1;
box-sizing: border-box;
}
/* #3984: Visual card aging — progressively fade cards that have not been
* touched recently, when card aging is enabled for the board. */
.minicard.minicard-aging-1 {
opacity: 0.85;
}
.minicard.minicard-aging-2 {
opacity: 0.6;
}
.minicard.minicard-aging-3 {
opacity: 0.4;
}
.minicard.minicard-aging-1:hover,
.minicard.minicard-aging-2:hover,
.minicard.minicard-aging-3:hover {
opacity: 1;
}
+26 -3
View File
@@ -2,14 +2,22 @@ template(name="minicard")
.minicard.nodragscroll(
class="{{#if isLinkedCard}}linked-card{{/if}}"
class="{{#if isLinkedBoard}}linked-board{{/if}}"
class="{{agingClass}}"
class="{{#if colorClass}}minicard-{{colorClass}}{{/if}}")
if canMoveCard
if isTouchScreenOrShowDesktopDragHandles
.handle
i.fa.fa-arrows
//- #459: keyboard/screen-reader reordering (visually hidden, focusable).
.minicard-move-buttons.sr-only
a.js-card-move-up(href="#" role="button" aria-label="{{_ 'move-card-up'}}") {{_ 'move-card-up'}}
a.js-card-move-down(href="#" role="button" aria-label="{{_ 'move-card-down'}}") {{_ 'move-card-down'}}
if canModifyCard
a.minicard-details-menu-with-handle.js-open-minicard-details-menu(title="{{_ 'cardDetailsActionsPopup-title'}}")
i.fa.fa-bars
if showDependencyConnectHandle
a.dependency-connect-handle.js-dependency-connect-handle(title="{{_ 'drag-to-connect'}}")
i.fa.fa-dot-circle-o
.dates
if showReceived
if getReceived
@@ -54,9 +62,10 @@ template(name="minicard")
span {{_ 'upload-completed'}}
.minicard-title
if canModifyCard
a.minicard-complete-toggle.js-toggle-card-complete(title="{{#if getDueComplete}}{{_ 'card-mark-incomplete'}}{{else}}{{_ 'card-mark-complete'}}{{/if}}")
.materialCheckBox(class="{{#if getDueComplete}}is-checked{{/if}}")
if showDueComplete
if canModifyCard
a.minicard-complete-toggle.js-toggle-card-complete(title="{{#if getDueComplete}}{{_ 'card-mark-incomplete'}}{{else}}{{_ 'card-mark-complete'}}{{/if}}")
.materialCheckBox(class="{{#if getDueComplete}}is-checked{{/if}}")
if $eq 'prefix-with-full-path' currentBoard.presentParentTask
.parent-prefix
| {{ parentString ' > ' }}
@@ -155,6 +164,11 @@ template(name="minicard")
each shouldShowChecklistAtMinicard
+minicardChecklist(checklist=. card=..)
.badges
if dependencyBadge
.badge.badge-state-image-only.minicard-dependencies(title="{{_ 'card-dependencies'}}")
span.badge-icon(style="color:{{dependencyBadge.color}}")
i.fa(class="fa-{{dependencyBadge.icon}}")
span.badge-text {{ dependencyBadge.count }}
if stickers.length
.badge.badge-state-image-only.minicard-stickers
each stickers
@@ -201,6 +215,15 @@ template(name="minicard")
i.fa.fa-globe
span.badge-text.check-list-text {{subtasksFinishedCount}}/{{allSubtasksCount}}
//{{subtasksFinishedCount}}/{{subtasksCount}} does not work because when a subtaks is archived, the count goes down
//- #6431: compact checklist item-count badge (finished/total). The outer
guard is a cheap boolean, so when the setting is off the checklist
counting below never runs.
if showChecklistCountBadge
if checklists.length
.badge(class="{{#if checklistFinished}}is-finished{{/if}}" title="{{_ 'checklists'}}")
span.badge-icon
i.fa.fa-check-square-o
span.badge-text.check-list-text {{checklistFinishedCount}}/{{checklistItemCount}}
if currentBoard.allowsCardSortingByNumber
if currentBoard.allowsCardSortingByNumberOnMinicard
.badge
+93 -1
View File
@@ -5,6 +5,8 @@ import { handleFileUpload } from './attachments';
import uploadProgressManager from '../../lib/uploadProgressManager';
import { Utils } from '/client/lib/utils';
import ChecklistItems from '/models/checklistItems';
import Cards from '/models/cards';
import { normalizeDependencies } from '/models/metadata/dependencies';
function getMinicardFlag(board, onMinicardField, legacyField, defaultValue) {
if (!board) return false;
@@ -22,6 +24,43 @@ function getMinicardFlag(board, onMinicardField, legacyField, defaultValue) {
// });
Template.minicard.helpers({
// #3392: show a drag-to-connect handle on the minicard when the board's
// dependency overlay is on and the user can edit the board. Dragging it onto
// another card creates a dependency (handled in dependencyOverlay.js).
showDependencyConnectHandle() {
const board = ReactiveCache.getBoard(this.boardId);
return !!(board && board.showDependencies && Utils.canModifyBoard());
},
// #3392: PI Program Board "Red Strings". Show a small badge on the minicard
// when a card has dependencies: the first dependency's icon and color plus the
// total count.
dependencyBadge() {
const deps = normalizeDependencies(this.cardDependencies);
if (deps.length === 0) return null;
return {
icon: deps[0].icon,
color: deps[0].color,
count: deps.length,
};
},
// #3984: visual card aging — fade cards that have not been touched recently,
// based on dateLastActivity, when the board has card aging enabled.
agingClass() {
const board = ReactiveCache.getBoard(this.boardId);
if (!board || !board.cardAging) return '';
const last = this.dateLastActivity || this.modifiedAt || this.createdAt;
if (!last) return '';
const days = (Date.now() - new Date(last).getTime()) / 86400000;
// #3984: thresholds are board-configurable (board settings / cardSettings API),
// defaulting to 7 / 14 / 28 days.
const d1 = board.cardAgingDays1 != null ? board.cardAgingDays1 : 7;
const d2 = board.cardAgingDays2 != null ? board.cardAgingDays2 : 14;
const d3 = board.cardAgingDays3 != null ? board.cardAgingDays3 : 28;
if (days >= d3) return 'minicard-aging-3';
if (days >= d2) return 'minicard-aging-2';
if (days >= d1) return 'minicard-aging-1';
return '';
},
formattedCurrencyCustomFieldValue(definition) {
const customField = this
.customFieldsWD()
@@ -94,10 +133,25 @@ Template.minicard.helpers({
const board = this.board();
return getMinicardFlag(board, 'allowsCardNumberOnMinicard', 'allowsCardNumber', false);
},
// "Mark as complete" toggle on the minicard. No legacy fallback to the
// card-details setting (allowsDueComplete): hidden by default so it only
// appears when explicitly enabled in Card Settings > Show at Minicard.
showDueComplete() {
const board = this.board();
return getMinicardFlag(board, 'allowsDueCompleteOnMinicard', null, false);
},
showSubtasks() {
const board = this.board();
return getMinicardFlag(board, 'allowsSubtasksOnMinicard', 'allowsSubtasks', true);
},
// #6431: compact checklist item-count badge (finished/total) on the minicard.
// Opt-in, OFF by default. Only a cheap boolean read here; the actual checklist
// counting (checklistFinishedCount/checklistItemCount) is gated behind this in
// the template, so a board with the badge off spends no resources counting.
showChecklistCountBadge() {
const board = this.board();
return !!(board && board.allowsChecklistCountBadgeOnMinicard);
},
hiddenMinicardLabelText() {
const currentUser = ReactiveCache.getCurrentUser();
@@ -163,7 +217,11 @@ Template.minicard.helpers({
// Show checklist if either:
// 1. Board-wide setting is enabled, OR
// 2. This specific checklist has the setting enabled
if (currentBoard.allowsChecklistAtMinicard || checklist.showChecklistAtMinicard) {
// #5565: use the same board field the sidebar toggle writes
// (`allowsChecklistsOnMinicard`); this previously checked a different,
// UI-less field (`allowsChecklistAtMinicard`), so the board toggle to show
// checklists on minicards had no effect.
if (currentBoard.allowsChecklistsOnMinicard || checklist.showChecklistAtMinicard) {
visibleChecklists.push(checklist);
}
});
@@ -172,12 +230,46 @@ Template.minicard.helpers({
}
});
// #459: accessible reordering — keyboard/screen-reader users can move a card up
// or down within its list via sr-only buttons (no drag-and-drop required). The
// move swaps the card's sort value with its neighbour in the same list+swimlane.
function moveCardBy(card, delta) {
const siblings = ReactiveCache.getCards(
{ listId: card.listId, swimlaneId: card.swimlaneId, archived: false },
{ sort: { sort: 1 } },
);
const idx = siblings.findIndex(c => c._id === card._id);
const target = siblings[idx + delta];
if (idx < 0 || !target) return;
// Capture both sort values before either update; the docs are reactive and
// card.sort would otherwise change after the first move.
const cardSort = card.sort;
const targetSort = target.sort;
// Persist through the card model's move() mutation — the canonical client
// path (e.g. editCardSortOrderPopup). A raw Cards.update of `sort` is the
// wrong path here and would be reverted.
card.move(card.boardId, card.swimlaneId, card.listId, targetSort);
target.move(target.boardId, target.swimlaneId, target.listId, cardSort);
}
Template.minicard.events({
'click .js-linked-link'() {
if (this.isLinkedCard()) Utils.goCardId(this.linkedId);
else if (this.isLinkedBoard())
Utils.goBoardId(this.linkedId);
},
'click .js-card-move-up'(event) {
// The move buttons sit inside the minicard anchor; don't let the click
// bubble up and open the card detail view.
event.preventDefault();
event.stopPropagation();
moveCardBy(this, -1);
},
'click .js-card-move-down'(event) {
event.preventDefault();
event.stopPropagation();
moveCardBy(this, 1);
},
'click .js-toggle-card-complete'(event) {
// Trello-style "mark complete" toggle (left of the title). Don't let the
// click open the card.
@@ -0,0 +1,38 @@
/**
* Pure helpers for displaying the status (current list / board) of a subtask.
*
* Issue #6091 "Visible status of sub-tasks": when viewing a card's subtasks,
* show each subtask's current list name (and board, when the subtask lives on a
* different board than the parent card).
*
* Kept dependency-free so it can be unit tested in isolation (mocha + chai).
*/
/**
* Build the human readable status label for a subtask.
*
* @param {Object} args
* @param {string} [args.listTitle] - Title of the list the subtask resides in.
* @param {string} [args.boardTitle] - Title of the board the subtask resides in.
* @param {boolean} [args.sameBoard=true] - Whether the subtask is on the same
* board as the parent card. When false, the board title is prefixed.
* @returns {string} The display string:
* - `listTitle` when the subtask is on the same board,
* - `boardTitle / listTitle` when it is on a different board,
* - a graceful fallback (whichever part is available) when one is missing,
* - empty string when nothing is known.
*/
export function subtaskStatusLabel({ listTitle, boardTitle, sameBoard = true } = {}) {
const list = (listTitle || '').trim();
const board = (boardTitle || '').trim();
if (sameBoard) {
return list;
}
// Different board: prefix with the board title when available.
if (board && list) {
return `${board} / ${list}`;
}
return board || list;
}
@@ -0,0 +1,40 @@
// Pure helpers for subtask navigation guards.
//
// #4762: "Subtask board is not accessible until reload".
// When a subtask lives on another board, that board may not yet be loaded in
// the ReactiveCache when the popup handler runs. Dereferencing the missing
// board (e.g. board._id) throws and the navigation silently fails until a
// reload populates the cache. Use this guard before navigating, mirroring the
// existing `if (board)` check in the js-go-to-subtask-board handler.
export function canNavigateToSubtaskBoard(board) {
return Boolean(board && board._id);
}
// #3743: "Subtask 'View it' button opens the parent card instead of the
// subtask". The navigation target must be derived from the SUBTASK card
// itself (its own board, slug and id) and never from the parent/current
// card. Centralise that here so the handler cannot accidentally reference
// the parent card again, and so the behaviour is unit-testable.
//
// Returns the FlowRouter 'card' route params taken from the subtask, or
// undefined when the subtask's board is not loaded yet (see #4762 guard).
export function subtaskNavTarget(subtask) {
if (!subtask) {
return undefined;
}
const board = typeof subtask.board === 'function' ? subtask.board() : undefined;
if (!canNavigateToSubtaskBoard(board)) {
return undefined;
}
// Prefer the real card id (resolves linked subtask cards to their source);
// fall back to the plain _id when getRealId is unavailable (e.g. in tests).
const cardId =
typeof subtask.getRealId === 'function' ? subtask.getRealId() : subtask._id;
return {
boardId: board._id,
slug: board.slug,
cardId,
swimlaneId: subtask.swimlaneId,
listId: subtask.listId,
};
}
+16
View File
@@ -192,3 +192,19 @@ body.grey-icons-enabled .subtasks-item .check-box-unicode {
padding: 6px 10px 6px 10px;
order: 2;
}
/* #6091: read-only status (current list / board) of a subtask */
.subtask-title .subtask-status {
order: 1;
flex-shrink: 0;
margin: 0 0.5em;
padding-top: 6px;
color: #8c8c8c;
font-size: 0.85em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 40%;
}
.subtask-title .subtask-status i.fa {
margin-inline-end: 4px;
}
+4
View File
@@ -35,6 +35,10 @@ template(name="subtaskDetail")
h2.title
+viewer
= subtask.title
if subtaskStatus
span.subtask-status(title="{{_ 'list'}}")
i.fa.fa-list-ul
| {{ subtaskStatus }}
template(name="addSubtaskItemForm")
textarea.js-add-subtask-item(rows='1' autofocus dir="auto")
+53 -67
View File
@@ -2,6 +2,9 @@ import { ReactiveCache } from '/imports/reactiveCache';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import Cards from '/models/cards';
import { Filter } from '/client/lib/filter';
import { subtaskStatusLabel } from './subtaskStatusHelpers';
import { subtaskNavTarget } from './subtaskViewHelpers';
import { Utils } from '/client/lib/utils';
Template.subtasks.events({
'click .js-open-subtask-details-menu'(event) {
@@ -17,66 +20,30 @@ Template.subtasks.events({
const textarea = tpl.find('textarea.js-add-subtask-item');
const title = textarea.value.trim();
const cardId = Template.currentData().cardId;
const card = ReactiveCache.getCard(cardId);
const sortIndex = -1;
const crtBoard = ReactiveCache.getBoard(card.boardId);
const targetBoard = await crtBoard.getDefaultSubtasksBoardAsync();
if (!targetBoard) {
return;
}
const targetList = await targetBoard.getDefaultSubtasksListAsync();
if (!targetList) {
return;
}
const listId = targetList._id;
//Get the full swimlane data for the parent task.
const parentSwimlane = ReactiveCache.getSwimlane({
boardId: crtBoard._id,
_id: card.swimlaneId,
});
//find the swimlane of the same name in the target board.
const targetSwimlane = parentSwimlane
? ReactiveCache.getSwimlane({
boardId: targetBoard._id,
title: parentSwimlane.title,
})
: undefined;
//If no swimlane with a matching title exists in the target board, fall back to the default swimlane.
const swimlaneId =
targetSwimlane === undefined
? targetBoard.getDefaultSwimline()._id
: targetSwimlane._id;
const nextCardNumber = await targetBoard.getNextCardNumber();
if (title) {
const _id = await Cards.insertAsync({
title,
parentId: cardId,
members: [],
labelIds: [],
customFields: [],
listId,
boardId: targetBoard._id,
sort: sortIndex,
swimlaneId,
type: 'cardType-card',
cardNumber: nextCardNumber,
});
// Subtask creation is performed server-side by the `addSubtaskCard` Meteor
// method, so the default subtasks board/list/swimlane are resolved (and
// lazily created exactly once) on the server. This prevents the client
// from creating duplicate subtasks boards / swimlanes / columns
// (#3868 / #5788 / #2256) and lets multiple subtasks be created reliably
// (#4782), and the method applies the destination board's automatic
// custom fields to the new subtask (#4037 / #3562).
const _id = await Meteor.callAsync('addSubtaskCard', cardId, title);
// In case the filter is active we need to add the newly inserted card in
// the list of exceptions -- cards that are not filtered. Otherwise the
// card will disappear instantly.
// See https://github.com/wekan/wekan/issues/80
Filter.addException(_id);
if (_id) {
// In case the filter is active we need to add the newly inserted card in
// the list of exceptions -- cards that are not filtered. Otherwise the
// card will disappear instantly.
// See https://github.com/wekan/wekan/issues/80
Filter.addException(_id);
setTimeout(() => {
tpl.$('.add-subtask-item')
.last()
.click();
}, 100);
setTimeout(() => {
tpl.$('.add-subtask-item')
.last()
.click();
}, 100);
}
}
textarea.value = '';
textarea.focus();
@@ -111,13 +78,32 @@ Template.subtasks.onCreated(function () {
Template.subtasks.helpers({
isBoardAdmin() {
return ReactiveCache.getCurrentUser().isBoardAdmin();
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
},
toggleDeleteDialog() {
return Template.instance().toggleDeleteDialog;
},
});
Template.subtaskDetail.helpers({
// #6091: show the subtask's current status, i.e. the list it resides in
// (prefixed with the board title when the subtask lives on another board
// than the parent card).
subtaskStatus() {
const subtask = this.subtask;
if (!subtask) {
return '';
}
const list = subtask.list && subtask.list();
const listTitle = list ? list.title : '';
const board = subtask.board && subtask.board();
const boardTitle = board ? board.title : '';
const parentCard = Utils.getCurrentCard();
const sameBoard = !!parentCard && parentCard.boardId === subtask.boardId;
return subtaskStatusLabel({ listTitle, boardTitle, sameBoard });
},
});
Template.subtaskItemDetail.events({
async 'click .js-subtasks-item .check-box-container'() {
const item = Template.currentData().item;
@@ -129,7 +115,7 @@ Template.subtaskItemDetail.events({
Template.subtaskActionsPopup.helpers({
isBoardAdmin() {
return ReactiveCache.getCurrentUser().isBoardAdmin();
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
},
});
@@ -137,14 +123,14 @@ Template.subtaskActionsPopup.events({
'click .js-view-subtask'(event) {
if ($(event.target).hasClass('js-view-subtask')) {
const subtask = Template.currentData().subtask;
const board = subtask.board();
FlowRouter.go('card', {
boardId: board._id,
slug: board.slug,
cardId: subtask._id,
swimlaneId: subtask.swimlaneId,
listId: subtask.listId,
});
// #3743: open the SUBTASK card itself, not the parent/current card.
// subtaskNavTarget derives boardId/slug/cardId from the subtask only.
// #4762: it also returns undefined while the subtask's board is not
// loaded yet (ReactiveCache miss), mirroring js-go-to-subtask-board.
const target = subtaskNavTarget(subtask);
if (target) {
FlowRouter.go('card', target);
}
}
},
'click .js-go-to-subtask-board'() {
@@ -169,6 +155,6 @@ Template.editSubtaskItemForm.helpers({
return ReactiveCache.getUser(this.userId);
},
isBoardAdmin() {
return ReactiveCache.getCurrentUser().isBoardAdmin();
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
},
});
+11 -4
View File
@@ -2,7 +2,7 @@ template(name="importHeaderBar")
h1
a.back-btn(href="{{pathFor 'home'}}")
i.fa-arrow-left
| {{_ title}}
| {{title}}
template(name="import")
.wrapper
@@ -14,9 +14,14 @@ template(name="import")
template(name="importTextarea")
form
p: label(for='import-textarea') {{_ instruction}} {{_ 'import-board-instruction-about-errors'}}
textarea.js-import-json(id='import-textarea' placeholder="{{_ importPlaceHolder}}" autofocus)
| {{jsonText}}
p: label(for='import-textarea') {{instruction}} {{_ 'import-board-instruction-about-errors'}}
if isExcelImport
p.import-file-row
label(for='import-excel-file') {{_ 'import-excel-file'}}
input.js-import-excel-file(id='import-excel-file' type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
else
textarea.js-import-json(id='import-textarea' placeholder="{{_ importPlaceHolder}}" autofocus)
| {{jsonText}}
if isTrelloImport
p.import-file-row
label(for='import-json-file') {{_ 'import-trello-json-file'}}
@@ -30,6 +35,7 @@ template(name="importTextarea")
label(for='import-workspace-name') {{_ 'import-trello-workspace'}}
input.js-import-workspace-name(id='import-workspace-name' type="text" placeholder="{{_ 'import-trello-workspace-placeholder'}}")
input.primary.wide(type="submit" value="{{_ 'import'}}")
button.wide.js-import-without-mapping(type="button") {{_ 'import-without-mapping-members'}}
if isTrelloImport
hr
+importTrelloApi
@@ -158,6 +164,7 @@ template(name="importMapMembers")
.mapping-item.ghost-item
form
input.primary.wide(type="submit" value="{{_ 'done'}}")
button.wide.js-import-skip-mapping(type="button") {{_ 'import-without-mapping-members'}}
else
+spinner
+128 -9
View File
@@ -3,6 +3,8 @@ import { trelloGetMembersToMap } from './trelloMembersMapper';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import { wekanGetMembersToMap } from './wekanMembersMapper';
import { csvGetMembersToMap } from './csvMembersMapper';
import { jiraGetMembersToMap } from './jiraMembersMapper';
import { kanboardGetMembersToMap } from './kanboardMembersMapper';
import getSlug from 'limax';
import { UserSearchIndex } from '/models/users';
import { Utils } from '/client/lib/utils';
@@ -13,7 +15,24 @@ const Papa = require('papaparse');
Template.importHeaderBar.helpers({
title() {
return `import-board-title-${Session.get('importSource')}`;
const sourceNameByKey = {
trello: 'Trello',
wekan: 'JSON',
csv: 'CSV-TSV',
excel: 'Excel',
jira: 'Jira',
kanboard: 'Kanboard',
deck: 'NextCloud Deck',
openproject: 'OpenProject',
github: 'GitHub',
gitlab: 'GitLab',
gitea: 'Gitea',
forgejo: 'Forgejo',
asana: 'Asana',
zenkit: 'Zenkit',
};
const sourceName = sourceNameByKey[Session.get('importSource')] || 'JSON';
return `${TAPi18n.__('import')} / ${sourceName}`;
},
});
@@ -42,6 +61,17 @@ function _prepareAdditionalData(dataObject) {
case 'csv':
membersToMap = csvGetMembersToMap(dataObject);
break;
case 'jira':
membersToMap = jiraGetMembersToMap(dataObject);
break;
case 'kanboard':
membersToMap = kanboardGetMembersToMap(dataObject);
break;
default:
// NextCloud Deck / OpenProject / GitHub / GitLab / Gitea / Forgejo:
// these are imported without member mapping (members can be mapped later).
membersToMap = [];
break;
}
return membersToMap;
}
@@ -85,6 +115,15 @@ function boardSlug(data) {
return (raw && getSlug(raw)) || 'imported-board';
}
// A WeKan export produced by a buggy older version (the `meta.boardId` exporter
// regression) contains empty swimlanes/lists/cards arrays. Importing it can only
// produce an empty board with a single Default swimlane, so detect that case and
// warn instead of silently creating an empty board.
function wekanExportIsEmpty(board) {
const count = key => (Array.isArray(board && board[key]) ? board[key].length : 0);
return count('swimlanes') === 0 && count('lists') === 0 && count('cards') === 0;
}
// Navigate to a freshly server-imported board. The HTTP import (unlike a DDP
// method) does not push the new board's documents to this client, so we first
// subscribe to the board and wait until its lists/swimlanes/cards are loaded
@@ -155,8 +194,36 @@ Template.import.onCreated(function () {
this.error.set(error);
};
this.importData = async (evt, dataSource) => {
// When skipMapping is true, the "map members" step is bypassed and the board
// is imported immediately with whatever (possibly empty) mapping exists, so
// members can be mapped later. This works for wekan, trello, csv and jira.
this.importData = async (evt, dataSource, skipMapping = false) => {
evt.preventDefault();
const advance = async () => {
if (skipMapping) {
await this.finishImport();
} else {
this.nextStep();
}
};
// Excel (.xlsx): the file is parsed on the server (with exceljs) into rows
// and imported through the CSV creator. Member mapping is skipped (members
// can be mapped later), so we read the file to base64 and import directly.
if (dataSource === 'excel') {
const el = this.find('.js-import-excel-file');
if (!el || !el.files || !el.files[0]) {
this.setError('error-json-malformed');
return;
}
const buf = await el.files[0].arrayBuffer();
const bytes = new Uint8Array(buf);
let binary = '';
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
this.importedData.set({ excelBase64: window.btoa(binary) });
this.membersToMap.set([]);
await this.finishImport();
return;
}
if (dataSource === 'csv') {
const input = this.find('.js-import-json').value;
const csv = input.indexOf('\t') > 0 ? input.replace(/(\t)/g, ',') : input;
@@ -165,7 +232,7 @@ Template.import.onCreated(function () {
else throw new Meteor.Error('error-csv-schema');
const membersToMap = _prepareAdditionalData(ret.data);
this.membersToMap.set(membersToMap);
this.nextStep();
await advance();
return;
}
// Trello: a .zip package (one or more board .json files plus per-board
@@ -187,6 +254,13 @@ Template.import.onCreated(function () {
input = await jsonFileEl.files[0].text();
}
const dataObject = JSON.parse(input);
// Guard against importing a broken/old WeKan export that has no board
// content (see wekanExportIsEmpty): warn the user to re-export rather than
// silently creating an empty board with only a Default swimlane.
if (this.importSource === 'wekan' && wekanExportIsEmpty(dataObject)) {
this.setError('error-import-empty-board');
return;
}
this.setError('');
// Trello: remember the target personal-workspace name for finishImport.
@@ -201,7 +275,7 @@ Template.import.onCreated(function () {
// store members data and mapping in Session
// (we go deep and 2-way, so storing in data context is not a viable option)
this.membersToMap.set(membersToMap);
this.nextStep();
await advance();
} catch (e) {
this.setError('error-json-malformed');
}
@@ -292,10 +366,7 @@ Template.import.onCreated(function () {
this.setError(err.error);
} else {
Session.set('fromBoard', null);
FlowRouter.go('board', {
id: res,
slug: boardSlug(importedData),
});
goToImportedBoard(res, boardSlug(importedData));
}
},
);
@@ -316,7 +387,35 @@ Template.import.helpers({
Template.importTextarea.helpers({
instruction() {
return `import-board-instruction-${Session.get('importSource')}`;
const importSource = Session.get('importSource');
const issueSourceConfig = {
github: {
sourceName: 'GitHub',
endpoint: 'GET /repos/OWNER/REPO/issues',
},
gitlab: {
sourceName: 'GitLab',
endpoint: 'GET /projects/ID/issues',
},
gitea: {
sourceName: 'Gitea',
endpoint: 'GET /repos/OWNER/REPO/issues',
},
forgejo: {
sourceName: 'Forgejo',
endpoint: 'GET /repos/OWNER/REPO/issues',
},
};
if (issueSourceConfig[importSource]) {
const { sourceName, endpoint } = issueSourceConfig[importSource];
return TAPi18n.__('import-board-instruction-issues', {
sourceName,
endpoint,
});
}
return TAPi18n.__(`import-board-instruction-${importSource}`);
},
importPlaceHolder() {
const importSource = Session.get('importSource');
@@ -329,6 +428,9 @@ Template.importTextarea.helpers({
isTrelloImport() {
return Session.get('importSource') === 'trello';
},
isExcelImport() {
return Session.get('importSource') === 'excel';
},
});
Template.importTextarea.events({
@@ -338,6 +440,14 @@ Template.importTextarea.events({
return importTpl.importData(evt, Session.get('importSource'));
}
},
// Import immediately, skipping the "map members" step (members can be mapped
// later). Works for wekan, trello and jira (and csv).
'click .js-import-without-mapping'(evt, tpl) {
const importTpl = findParentTemplateInstance(tpl, 'import');
if (importTpl) {
return importTpl.importData(evt, Session.get('importSource'), true);
}
},
});
// Module-level reference so popup children can access importMapMembers methods
@@ -468,6 +578,15 @@ Template.importMapMembers.events({
importTpl.nextStep();
}
},
// Import now without finishing member mapping; only members already mapped
// (if any) are applied, the rest can be mapped later.
'click .js-import-skip-mapping'(evt, tpl) {
evt.preventDefault();
const importTpl = findParentTemplateInstance(tpl, 'import');
if (importTpl) {
importTpl.finishImport();
}
},
'click .js-select-member'(evt, tpl) {
const memberToMap = Template.currentData();
if (memberToMap.wekan) {
@@ -0,0 +1,19 @@
// Extract the distinct assignees from a Jira export so the import "map members"
// step can map each Jira user to a WeKan user.
export function jiraGetMembersToMap(data) {
const issues = Array.isArray(data) ? data : data.issues || [];
const byId = {};
issues.forEach(issue => {
const assignee = issue.fields && issue.fields.assignee;
if (!assignee) return;
const id = assignee.accountId || assignee.name || assignee.emailAddress;
if (!id || byId[id]) return;
byId[id] = {
id,
username: assignee.name || assignee.displayName || id,
fullName: assignee.displayName || assignee.name || id,
wekanId: null,
};
});
return Object.values(byId);
}
@@ -0,0 +1,17 @@
// Extract the distinct task owners from a Kanboard export so the import
// "map members" step can map each Kanboard user to a WeKan user.
export function kanboardGetMembersToMap(data) {
const tasks = Array.isArray(data) ? data : data.tasks || [];
const byId = {};
tasks.forEach(task => {
const id = task.owner_id || task.owner_username || task.owner_name;
if (!id || byId[id]) return;
byId[id] = {
id: String(id),
username: task.owner_username || task.owner_name || String(id),
fullName: task.owner_name || task.owner_username || String(id),
wekanId: null,
};
});
return Object.values(byId);
}
+23 -14
View File
@@ -84,16 +84,17 @@
flex-basis: auto !important;
flex-grow: 0 !important;
flex-shrink: 0 !important;
/* Override any conflicting layout properties */
/* Override any conflicting layout properties.
Keep the flex column layout so the list-body stays height-bounded and
keeps its vertical scrollbar (display:block would collapse it). */
float: inline-start !important;
display: block !important;
display: flex !important;
flex-direction: column !important;
position: relative !important;
/* Force width to be respected */
width: var(--list-width, auto) !important;
min-width: var(--list-width, auto) !important;
max-width: var(--list-width, auto) !important;
/* Ensure the width is applied immediately */
overflow: visible !important;
}
body.list-resizing-active {
@@ -130,13 +131,18 @@ body.list-resizing-active * {
flex-grow: 0 !important;
flex-shrink: 0 !important;
float: inline-start !important;
display: block !important;
display: flex !important;
flex-direction: column !important;
}
/* Ensure the width persists after resize is complete */
.js-swimlane .list[style*="--list-width"],
.dragscroll .list[style*="--list-width"],
[id^="swimlane-"] .list[style*="--list-width"] {
/* Ensure the width persists after resize is complete.
Exclude collapsed lists: a collapsed list keeps its inline --list-width custom
property, and without :not() this !important rule overrode the 30px collapsed
width, so collapsing a list that had a custom width left it full width.
See https://github.com/wekan/wekan/issues/5892 */
.js-swimlane .list[style*="--list-width"]:not(.list-collapsed),
.dragscroll .list[style*="--list-width"]:not(.list-collapsed),
[id^="swimlane-"] .list[style*="--list-width"]:not(.list-collapsed) {
/* Maintain the width after resize */
width: var(--list-width, auto) !important;
min-width: var(--list-width, auto) !important;
@@ -146,7 +152,8 @@ body.list-resizing-active * {
flex-grow: 0 !important;
flex-shrink: 0 !important;
float: inline-start !important;
display: block !important;
display: flex !important;
flex-direction: column !important;
}
/* Ensure consistent header height for all lists */
@@ -250,10 +257,12 @@ body.list-resizing-active * {
height: 15vh;
}
.list.list-collapsed {
flex: none;
min-width: 30px;
max-width: 30px;
width: 30px;
flex: none !important;
/* !important so a custom per-list --list-width never keeps a collapsed list
full width (see https://github.com/wekan/wekan/issues/5892) */
min-width: 30px !important;
max-width: 30px !important;
width: 30px !important;
min-height: 60vh;
height: 60vh;
overflow: visible;
+3 -2
View File
@@ -1,11 +1,12 @@
template(name='list')
.list.js-list(id="js-list-{{_id}}"
style="{{#unless collapsed}}min-width:{{listWidth}}px;max-width:{{listConstraint}}px;{{/unless}}"
style="{{#unless collapsed}}{{#unless autoWidth}}--list-width:{{listWidth}}px;{{/unless}}{{/unless}}"
class="{{#if collapsed}}list-collapsed{{/if}} {{#if autoWidth}}list-auto-width{{/if}} {{#if isMiniScreen}}mobile-view{{/if}}")
+listHeader
unless collapsed
+listBody
.list-resize-handle.js-list-resize-handle.nodragscroll
if canResizeList
.list-resize-handle.js-list-resize-handle.nodragscroll
template(name='miniList')
a.mini-list.js-select-list.js-list(id="js-list-{{_id}}" class="{{#if isMiniScreen}}mobile-view{{/if}}")
+203 -140
View File
@@ -7,6 +7,196 @@ require('/client/lib/jquery-ui.js')
const { calculateIndex } = Utils;
// ---------------------------------------------------------------------------
// #6409 List width: a single, simple model.
//
// * Each list has ONE fixed width.
// * SHARED mode (board default): width lives in `lists.width` and is the same
// for everyone; only members with board write access can change it.
// * PERSONAL mode (board setting `allowsPersonalListWidth`): each user keeps
// their own width in their profile (or localStorage when not logged in),
// falling back to the shared width and then the default.
//
// The old per-list "min width / max width / automatic" knobs are gone — the
// rendered width is now a single hard value (see list.jade).
// ---------------------------------------------------------------------------
const DEFAULT_LIST_WIDTH = 272;
const MIN_LIST_WIDTH = 270;
function isPersonalListWidth(boardId) {
const board = ReactiveCache.getBoard(boardId);
return !!(board && board.allowsPersonalListWidth);
}
// #6409 Auto-width follows the same scope as fixed widths:
// * personal mode -> the user's own profile.autoWidthBoards
// * shared mode -> the board's `autoWidth` (same for everyone)
function effectiveAutoWidth(boardId) {
if (isPersonalListWidth(boardId)) {
const user = ReactiveCache.getCurrentUser();
return !!(user && user.isAutoWidth(boardId));
}
const board = ReactiveCache.getBoard(boardId);
return !!(board && board.autoWidth);
}
function readAnonListWidth(boardId, listId) {
try {
const stored = localStorage.getItem('wekan-list-widths');
if (stored) {
const widths = JSON.parse(stored);
const w = widths[boardId] && widths[boardId][listId];
if (typeof w === 'number' && w >= MIN_LIST_WIDTH) return w;
}
} catch (e) {
console.warn('Error reading list width from localStorage:', e);
}
return null;
}
// ---------------------------------------------------------------------------
// #5729 Fixed (same) width for all lists.
//
// A per-viewer, per-board toggle that makes EVERY list render at a single
// shared value, and makes dragging any list update that one value (so all lists
// change together). Stored exactly like the personal width:
// * logged-in -> profile.fixedListWidthBoards / profile.fixedListWidths
// * anonymous -> localStorage (mirrors readAnonListWidth)
// ---------------------------------------------------------------------------
function readAnonFixedListWidthEnabled(boardId) {
try {
const stored = localStorage.getItem('wekan-fixed-list-width-enabled');
if (stored) {
const flags = JSON.parse(stored);
return flags[boardId] === true;
}
} catch (e) {
console.warn('Error reading fixed list width flag from localStorage:', e);
}
return false;
}
function readAnonFixedListWidth(boardId) {
try {
const stored = localStorage.getItem('wekan-fixed-list-width');
if (stored) {
const widths = JSON.parse(stored);
const w = widths[boardId];
if (typeof w === 'number' && w >= MIN_LIST_WIDTH) return w;
}
} catch (e) {
console.warn('Error reading fixed list width from localStorage:', e);
}
return DEFAULT_LIST_WIDTH;
}
// Whether the current viewer has fixed width mode enabled for this board.
function isFixedListWidth(boardId) {
const user = ReactiveCache.getCurrentUser();
if (user) {
return !!user.isFixedListWidth(boardId);
}
return readAnonFixedListWidthEnabled(boardId);
}
// The single width every list should use when fixed width mode is enabled.
function fixedListWidthValue(boardId) {
const user = ReactiveCache.getCurrentUser();
if (user) {
return user.getFixedListWidth(boardId);
}
return readAnonFixedListWidth(boardId);
}
// Persist the single fixed width for the whole board (anon localStorage).
function saveAnonFixedListWidth(boardId, width) {
try {
const stored = localStorage.getItem('wekan-fixed-list-width');
const widths = stored ? JSON.parse(stored) : {};
widths[boardId] = width;
localStorage.setItem('wekan-fixed-list-width', JSON.stringify(widths));
} catch (e) {
console.warn('Error saving fixed list width to localStorage:', e);
}
}
function effectiveListWidth(list) {
if (!list) return DEFAULT_LIST_WIDTH;
// #5729 In fixed width mode every list renders at the single shared value.
if (isFixedListWidth(list.boardId)) {
return fixedListWidthValue(list.boardId);
}
const shared =
typeof list.width === 'number' && list.width >= MIN_LIST_WIDTH
? list.width
: DEFAULT_LIST_WIDTH;
if (!isPersonalListWidth(list.boardId)) {
return shared;
}
const user = ReactiveCache.getCurrentUser();
if (user) {
const widths = user.getListWidths();
const w = widths[list.boardId] && widths[list.boardId][list._id];
return typeof w === 'number' && w >= MIN_LIST_WIDTH ? w : shared;
}
const w = readAnonListWidth(list.boardId, list._id);
return w || shared;
}
// Can the current user change THIS list's width by dragging?
// - never while the board/list is in auto-width mode (the width is computed)
// - personal mode: always (it only affects their own view)
// - shared mode: only with board write access
function canResizeList(list) {
if (!list) return false;
if (effectiveAutoWidth(list.boardId)) return false;
// #5729 Fixed width is the viewer's own per-board setting, so any viewer
// (logged-in or anonymous) may drag-resize; the change applies to all lists.
if (isFixedListWidth(list.boardId)) return true;
if (isPersonalListWidth(list.boardId)) return true;
return Utils.canModifyBoard();
}
// Persist a new width to the place that matches the current board mode.
function saveListWidth(list, width) {
if (!list) return;
const boardId = list.boardId;
const listId = list._id;
const user = ReactiveCache.getCurrentUser();
// #5729 In fixed width mode, persist to the single per-board value instead of
// the per-list width, so EVERY list re-renders at the new width.
if (isFixedListWidth(boardId)) {
if (user) {
Meteor.call('setFixedListWidth', boardId, width, error => {
if (error) console.error('Error saving fixed list width:', error);
});
} else {
saveAnonFixedListWidth(boardId, width);
}
return;
}
if (isPersonalListWidth(boardId)) {
if (user) {
Meteor.call('applyListWidthToStorage', boardId, listId, width, width);
} else {
try {
const stored = localStorage.getItem('wekan-list-widths');
const widths = stored ? JSON.parse(stored) : {};
if (!widths[boardId]) widths[boardId] = {};
widths[boardId][listId] = width;
localStorage.setItem('wekan-list-widths', JSON.stringify(widths));
} catch (e) {
console.warn('Error saving personal list width to localStorage:', e);
}
}
} else if (user) {
// Shared width: server also enforces board membership.
Meteor.call('applyListWidth', boardId, listId, width, width, error => {
if (error) console.error('Error saving shared list width:', error);
});
}
}
Template.list.onCreated(function () {
this.newCardFormIsVisible = new ReactiveVar(true);
@@ -217,63 +407,17 @@ Template.list.onRendered(function () {
Template.list.helpers({
listWidth() {
const user = ReactiveCache.getCurrentUser();
const list = Template.currentData();
if (!list) return 270; // Return default width if list is not available
if (user) {
// For logged-in users, get from user profile
return user.getListWidthFromStorage(list.boardId, list._id);
} else {
// For non-logged-in users, get from localStorage
try {
const stored = localStorage.getItem('wekan-list-widths');
if (stored) {
const widths = JSON.parse(stored);
if (widths[list.boardId] && widths[list.boardId][list._id]) {
return widths[list.boardId][list._id];
}
}
} catch (e) {
console.warn('Error reading list width from localStorage:', e);
}
return 270; // Return default width if not found
}
return effectiveListWidth(Template.currentData());
},
listConstraint() {
const user = ReactiveCache.getCurrentUser();
const list = Template.currentData();
if (!list) return 550; // Return default constraint if list is not available
if (user) {
// For logged-in users, get from user profile
return user.getListConstraintFromStorage(list.boardId, list._id);
} else {
// For non-logged-in users, get from localStorage
try {
const stored = localStorage.getItem('wekan-list-constraints');
if (stored) {
const constraints = JSON.parse(stored);
if (constraints[list.boardId] && constraints[list.boardId][list._id]) {
return constraints[list.boardId][list._id];
}
}
} catch (e) {
console.warn('Error reading list constraint from localStorage:', e);
}
return 550; // Return default constraint if not found
}
// Whether to show the drag-resize handle for this list.
canResizeList() {
return canResizeList(Template.currentData());
},
autoWidth() {
const user = ReactiveCache.getCurrentUser();
const list = Template.currentData();
if (!user) {
// For non-logged-in users, auto-width is disabled
return false;
}
return user.isAutoWidth(list.boardId);
return !!list && effectiveAutoWidth(list.boardId);
},
collapsed() {
@@ -313,18 +457,12 @@ Template.list.onCreated(function () {
return;
}
// Helper to get autoWidth state
const getAutoWidth = () => {
const user = ReactiveCache.getCurrentUser();
if (!user) return false;
return user.isAutoWidth(list.boardId);
};
// Reactively show/hide resize handle based on collapse and auto-width state
// #6409: show the resize handle only when the list is not collapsed and the
// user is allowed to change this list's width (always in personal mode; only
// with board write access in shared mode).
tpl.autorun(() => {
const isAutoWidth = getAutoWidth();
const isCollapsed = Utils.getListCollapseState(list);
if (isCollapsed || isAutoWidth) {
if (isCollapsed || !canResizeList(list)) {
$resizeHandle.hide();
} else {
$resizeHandle.show();
@@ -334,25 +472,7 @@ Template.list.onCreated(function () {
let isResizing = false;
let startX = 0;
let startWidth = 0;
let minWidth = 270; // Minimum width matching system default
// Get listConstraint value
const getListConstraint = () => {
const user = ReactiveCache.getCurrentUser();
if (user) {
return user.getListConstraintFromStorage(list.boardId, list._id);
}
try {
const stored = localStorage.getItem('wekan-list-constraints');
if (stored) {
const constraints = JSON.parse(stored);
if (constraints[list.boardId] && constraints[list.boardId][list._id]) {
return constraints[list.boardId][list._id];
}
}
} catch (e) {}
return 550;
};
let minWidth = MIN_LIST_WIDTH; // Minimum width matching system default
// Read the horizontal page coordinate from either a jQuery mouse event
// or a native touch event (touchstart/move expose `touches`, touchend
@@ -412,7 +532,6 @@ Template.list.onCreated(function () {
const currentX = getEventPageX(e);
const deltaX = currentX - startX;
const finalWidth = Math.max(minWidth, startWidth + deltaX);
const listConstraint = getListConstraint();
// Ensure the final width is applied
$list[0].style.setProperty('--list-width', `${finalWidth}px`);
@@ -429,55 +548,9 @@ Template.list.onCreated(function () {
$('body').removeClass('list-resizing-active');
$('body').css('user-select', '');
// Save the new width using the existing system
const boardId = list.boardId;
const listId = list._id;
if (process.env.DEBUG === 'true') {
}
const currentUser = ReactiveCache.getCurrentUser();
if (currentUser) {
// For logged-in users, use server method
Meteor.call('applyListWidthToStorage', boardId, listId, finalWidth, listConstraint, (error, result) => {
if (error) {
console.error('Error saving list width:', error);
} else {
if (process.env.DEBUG === 'true') {
}
}
});
} else {
// For non-logged-in users, save to localStorage directly
try {
// Save list width
const storedWidths = localStorage.getItem('wekan-list-widths');
let widths = storedWidths ? JSON.parse(storedWidths) : {};
if (!widths[boardId]) {
widths[boardId] = {};
}
widths[boardId][listId] = finalWidth;
localStorage.setItem('wekan-list-widths', JSON.stringify(widths));
// Save list constraint
const storedConstraints = localStorage.getItem('wekan-list-constraints');
let constraints = storedConstraints ? JSON.parse(storedConstraints) : {};
if (!constraints[boardId]) {
constraints[boardId] = {};
}
constraints[boardId][listId] = listConstraint;
localStorage.setItem('wekan-list-constraints', JSON.stringify(constraints));
if (process.env.DEBUG === 'true') {
}
} catch (e) {
console.warn('Error saving list width/constraint to localStorage:', e);
}
}
// #6409: persist to the shared list width or the user's personal width,
// depending on the board's mode.
saveListWidth(list, finalWidth);
e.preventDefault();
};
@@ -502,16 +575,6 @@ Template.list.onCreated(function () {
e.stopPropagation();
});
// Reactively update resize handle visibility when auto-width or collapse changes
tpl.autorun(() => {
const collapsed = Utils.getListCollapseState(list);
if (getAutoWidth() || collapsed) {
$resizeHandle.hide();
} else {
$resizeHandle.show();
}
});
// Clean up on component destruction
tpl.view.onViewDestroyed(() => {
$(document).off('mousemove', doResize);
+36
View File
@@ -0,0 +1,36 @@
// Pure helpers for the "add list" affordance.
//
// Issue #6142: In "Lists" board-view mode there was no UI affordance to add a
// new list — only the "Swimlanes" mode rendered the trailing add-list composer.
// These helpers keep the (testable) decision logic out of the Blaze templates.
// Board-view modes in which an "add list" composer should be offered.
// Both the swimlanes view and the lists view let the user create a list; the
// other views (calendar, gantt, table) do not render the list composer.
export const ADD_LIST_VIEW_MODES = ['board-view-swimlanes', 'board-view-lists'];
// Returns true when the given board-view mode should show the add-list
// composer. Used so Lists mode gets the same affordance as Swimlanes mode.
export function canAddListInView(viewMode) {
return ADD_LIST_VIEW_MODES.indexOf(viewMode) !== -1;
}
// Decide which swimlane a newly created list should belong to when the user
// adds a list from a view that is not bound to a specific swimlane (e.g. the
// Lists view). Prefer an explicitly provided swimlane id, otherwise fall back
// to the board's default swimlane id.
//
// `board` only needs to expose a `getDefaultSwimline()` method (the WeKan
// Board model does); it may also be a plain object for testing.
export function defaultSwimlaneIdForBoard(board, explicitSwimlaneId) {
if (explicitSwimlaneId) {
return explicitSwimlaneId;
}
if (board && typeof board.getDefaultSwimline === 'function') {
const swimlane = board.getDefaultSwimline();
if (swimlane && swimlane._id) {
return swimlane._id;
}
}
return null;
}
+1
View File
@@ -16,6 +16,7 @@ template(name="listBody")
= formattedCurrencyCustomFieldValue(value)
each (cardsWithLimit (idOrNull ../../_id))
a.minicard-wrapper.js-minicard(href=originRelativeUrl
data-card-id="{{_id}}"
class="{{#if cardIsSelected}}is-selected{{/if}}"
class="{{#if MultiSelection.isSelected _id}}is-checked{{/if}}")
if MultiSelection.isActive
+87 -7
View File
@@ -9,6 +9,7 @@ import Swimlanes from '/models/swimlanes';
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 autosize from 'autosize';
// SubsManager removed for Meteor 3 migration
@@ -324,7 +325,20 @@ Template.listBody.helpers({
const tpl = Template.instance();
const limit = tpl.cardlimit.get();
const defaultSort = { sort: 1 };
const sortBy = Session.get('sortBy') ? Session.get('sortBy') : defaultSort;
// Session is reset on page reload; fall back to the persisted card sort so a
// chosen sort (e.g. by due date) is remembered across reloads (#5886).
let sortBy = Session.get('sortBy');
if (!sortBy) {
try {
const stored = window.localStorage.getItem('wekan-cards-sortBy');
if (stored) {
sortBy = JSON.parse(stored);
}
} catch (e) {}
}
if (!sortBy) {
sortBy = defaultSort;
}
const selector = {
listId: Template.currentData()._id,
archived: false,
@@ -355,9 +369,10 @@ Template.listBody.helpers({
canSeeAddCard() {
const tpl = Template.instance();
const list = Template.currentData();
// #2095: count all cards in the list, not only the filtered/visible ones.
const reachedWipLimit = !list.getWipLimit('soft') &&
list.getWipLimit('enabled') &&
list.getWipLimit('value') <= list.cards().length;
list.getWipLimit('value') <= list.cardsUnfiltered().length;
return (
!reachedWipLimit &&
Utils.canModifyCard()
@@ -369,7 +384,8 @@ Template.listBody.helpers({
return (
!list.getWipLimit('soft') &&
list.getWipLimit('enabled') &&
list.getWipLimit('value') <= list.cards().length
// #2095: count all cards in the list, not only the filtered/visible ones.
list.getWipLimit('value') <= list.cardsUnfiltered().length
);
},
@@ -691,7 +707,10 @@ Template.linkCardPopup.helpers({
archived: false,
linkedId: { $nin: ownCardsIds },
_id: { $nin: ownCardsIds },
type: { $nin: ['template-card'] },
// #5808: never offer an existing linked card/board as a link target —
// linking to one builds a chain of linkedId pointers that renders the
// card inaccessible. Only real cards may be linked.
type: { $nin: ['template-card', 'cardType-linkedCard', 'cardType-linkedBoard'] },
};
if (tpl.selectedBoardId.get()) selector.boardId = tpl.selectedBoardId.get();
if (tpl.selectedSwimlaneId.get()) selector.swimlaneId = tpl.selectedSwimlaneId.get();
@@ -741,11 +760,48 @@ Template.linkCardPopup.events({
tpl.selectedListId.set($(evt.currentTarget).val());
},
async 'click .js-done'(evt, tpl) {
// LINK CARD
// LINK CARD (or, when no card is chosen, fall back to a board-level link
// so a whole board can be linked even when that board already has cards).
// https://github.com/wekan/wekan/issues/5715
evt.stopPropagation();
evt.preventDefault();
const linkedId = $('.js-select-cards option:selected').val();
if (!linkedId) {
const boardId = $('.js-select-boards option:selected').val();
// No board and no card selected: nothing to link.
if (!boardId) {
Popup.back();
return;
}
// A linkedBoard card for this board already exists; avoid duplicates.
if (ReactiveCache.getCard({ linkedId: boardId, archived: false })) {
Popup.back();
return;
}
const boardCardNumber = await tpl.board.getNextCardNumber();
const boardSortIndex = tpl.getSortIndex();
const boardCardId = Cards.insert({
title: $('.js-select-boards option:selected').text(), //dummy
listId: tpl.listId,
swimlaneId: tpl.swimlaneId,
boardId: tpl.boardId,
sort: boardSortIndex,
type: 'cardType-linkedBoard',
linkedId: boardId,
cardNumber: boardCardNumber,
});
Filter.addException(boardCardId);
Popup.back();
return;
}
// #5808: refuse to link to a linked card/board or to a card that links back
// to this board — those build a chain/cycle of linkedId pointers that renders
// the cards inaccessible. The <select> already filters these out, but its
// options can be stale, so re-check the resolved target here.
const targetCard = ReactiveCache.getCard(linkedId);
const ownCardsIds = tpl.board.cards().map(card => card.getRealId());
if (!isLinkableCardTarget(targetCard, ownCardsIds)) {
alert(TAPi18n.__('error-linked-card-not-allowed'));
Popup.back();
return;
}
@@ -922,13 +978,37 @@ Template.searchElementPopup.events({
if (!tpl.isTemplateSearch || tpl.isCardTemplateSearch) {
// Card insertion
// 1. Common
// Compute the sort index BEFORE the first await: getSortIndex() reads
// Template.currentData(), and Blaze's current-view context is lost across
// an await, so calling it afterwards threw "There is no current view" and
// no card was created from the template. Capture it while still in the
// synchronous event/view context.
const sortIndex = tpl.getSortIndex();
element.cardNumber = await tpl.board.getNextCardNumber();
element.sort = tpl.getSortIndex();
element.sort = sortIndex;
// 1.A From template
if (tpl.isTemplateSearch) {
element.type = 'cardType-card';
element.linkedId = '';
_id = await element.copy(tpl.boardId, tpl.swimlaneId, tpl.listId);
// #5798: instantiate the template into the CURRENT board, not the
// templates board. For a template search tpl.boardId is the templates
// board (it is the search source), so copying with it created the card
// with boardId = templates board. The card still showed in the target
// list (the list query is by listId and the templates board is
// subscribed), but clicking it navigated to the templates board instead
// of opening the card. The target list/swimlane were already resolved on
// the current board.
// Instantiate into the board that OWNS the target list (where the user
// clicked "add card"). Deriving it from tpl.listId is reliable even when
// Utils.getCurrentBoard() (route/session state) has not settled yet — as
// on the slower CI production bundle, where falling back to tpl.boardId
// wrongly created the card on the templates board.
const targetList = ReactiveCache.getList(tpl.listId);
const targetBoardId =
(targetList || {}).boardId ||
(Utils.getCurrentBoard() || {})._id ||
tpl.boardId;
_id = await element.copy(targetBoardId, tpl.swimlaneId, tpl.listId);
// 1.B Linked card
} else {
_id = element.link(tpl.boardId, tpl.swimlaneId, tpl.listId);
+18 -11
View File
@@ -2,6 +2,10 @@ template(name="listHeader")
.list-header.js-list-header.nodragscroll(
class="{{#if limitToShowCardsCount}}list-header-card-count{{/if}}"
class=colorClass)
//- #459: keyboard/screen-reader list reordering (visually hidden, focusable).
.list-move-buttons.sr-only
a.js-list-move-left(href="#" role="button" aria-label="{{_ 'move-list-left'}}") {{_ 'move-list-left'}}
a.js-list-move-right(href="#" role="button" aria-label="{{_ 'move-list-right'}}") {{_ 'move-list-right'}}
+inlinedForm
+editListTitleForm
else
@@ -248,17 +252,20 @@ template(name="wipLimitErrorPopup")
template(name="setListWidthPopup")
#js-list-width-edit
label {{_ 'set-list-width-value'}}
p
input.list-width-value(type="number" value="{{ listWidthValue }}" min="270")
input.list-constraint-value(type="number" value="{{ listConstraintValue }}" min="270")
input.list-width-apply(type="submit" value="{{_ 'apply'}}")
input.list-width-error
br
a.js-auto-width-board(
title="{{#if isAutoWidth}}{{_ 'click-to-disable-auto-width'}}{{else}}{{_ 'click-to-enable-auto-width'}}{{/if}}")
| {{#if isAutoWidth}}🗜️{{else}}📏{{/if}}
span {{_ 'auto-list-width'}}
unless isAutoWidth
label {{_ 'set-list-width-value'}}
p
input.list-width-value(type="number" value="{{ listWidthValue }}" min="270")
input.list-width-apply(type="submit" value="{{_ 'apply'}}")
input.list-width-error
if canChangeAutoWidth
a.js-toggle-auto-width(title="{{#if isAutoWidth}}{{_ 'click-to-disable-auto-width'}}{{else}}{{_ 'click-to-enable-auto-width'}}{{/if}}")
| {{#if isAutoWidth}}🗜️{{else}}📏{{/if}}
span {{_ 'auto-list-width'}}
a.js-toggle-fixed-list-width(title="{{#if isFixedListWidth}}{{_ 'click-to-disable-fixed-list-width'}}{{else}}{{_ 'click-to-enable-fixed-list-width'}}{{/if}}")
| {{#if isFixedListWidth}}✅{{else}}⬜{{/if}}
span {{_ 'fixed-list-width'}}
p.list-width-scope-note {{ listWidthScopeNote }}
template(name="listWidthErrorPopup")
.list-width-invalid
+222 -41
View File
@@ -20,12 +20,12 @@ Template.listHeader.helpers({
(!list.getWipLimit('enabled') ||
list.getWipLimit('soft') ||
!Template.instance().reachedWipLimit()) &&
!ReactiveCache.getCurrentUser().isWorker()
!ReactiveCache.getCurrentUser()?.isWorker()
);
},
isBoardAdmin() {
return ReactiveCache.getCurrentUser().isBoardAdmin();
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
},
starred() {
@@ -148,7 +148,36 @@ Template.listHeader.onCreated(function () {
};
});
// #459: accessible reordering — move a list left/right via sr-only buttons by
// swapping its sort value with the adjacent list (no drag-and-drop required).
function moveListBy(list, delta) {
const siblings = ReactiveCache.getLists(
{ boardId: list.boardId, archived: false },
{ sort: { sort: 1 } },
);
const idx = siblings.findIndex(l => l._id === list._id);
const target = siblings[idx + delta];
if (idx < 0 || !target) return;
// Capture both sort values before either update; the docs are reactive and
// list.sort would otherwise change after the first update.
const listSort = list.sort;
const targetSort = target.sort;
// Lists are a server-restricted collection; persist the swap through the
// updateListSort method — the same path the drag-and-drop reorder uses. A raw
// client Lists.update of `sort` is reverted by the server.
Meteor.call('updateListSort', list._id, list.boardId, { sort: targetSort });
Meteor.call('updateListSort', target._id, target.boardId, { sort: listSort });
}
Template.listHeader.events({
'click .js-list-move-left'(event) {
event.preventDefault();
moveListBy(Template.currentData(), -1);
},
'click .js-list-move-right'(event) {
event.preventDefault();
moveListBy(Template.currentData(), 1);
},
async 'click .js-list-star'(event) {
event.preventDefault();
const list = Template.currentData();
@@ -189,7 +218,7 @@ Template.listHeader.events({
Template.listActionPopup.helpers({
isBoardAdmin() {
return ReactiveCache.getCurrentUser().isBoardAdmin();
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
},
isWipLimitEnabled() {
@@ -229,7 +258,15 @@ Template.listActionPopup.events({
'click .js-set-list-width': Popup.open('setListWidth'),
'click .js-set-color-list': Popup.open('setListColor'),
'click .js-select-cards'() {
const cardIds = this.allCards().map(card => card._id);
// Scope "select all cards" to the current swimlane when invoked from a
// swimlane context (#5623). In swimlanes board view the list carries its
// swimlaneId; otherwise there is genuinely no swimlane context and we keep
// the historical list-wide selection (swimlaneId stays undefined).
let swimlaneId;
if (Utils.boardView() === 'board-view-swimlanes' && this.swimlaneId) {
swimlaneId = this.swimlaneId;
}
const cardIds = this.allCards(swimlaneId).map(card => card._id);
MultiSelection.add(cardIds);
Popup.back();
},
@@ -448,10 +485,8 @@ Template.setListColorPopup.events({
},
async 'submit form'(event, tpl) {
event.preventDefault();
console.log('[ListColor] submit form, color:', tpl.currentColor.get(), 'listId:', tpl.currentList?._id, 'setColor type:', typeof tpl.currentList?.setColor);
try {
const result = await tpl.currentList.setColor(tpl.currentColor.get());
console.log('[ListColor] submit form setColor result:', result);
await tpl.currentList.setColor(tpl.currentColor.get());
} catch (err) {
console.error('[ListColor] submit form setColor error:', err);
}
@@ -459,10 +494,8 @@ Template.setListColorPopup.events({
},
async 'click .js-submit'(event, tpl) {
event.preventDefault();
console.log('[ListColor] click submit, color:', tpl.currentColor.get(), 'listId:', tpl.currentList?._id, 'setColor type:', typeof tpl.currentList?.setColor);
try {
const result = await tpl.currentList.setColor(tpl.currentColor.get());
console.log('[ListColor] click submit setColor result:', result);
await tpl.currentList.setColor(tpl.currentColor.get());
} catch (err) {
console.error('[ListColor] click submit setColor error:', err);
}
@@ -470,7 +503,6 @@ Template.setListColorPopup.events({
},
async 'click .js-remove-color'(event, tpl) {
event.preventDefault();
console.log('[ListColor] remove color, listId:', tpl.currentList?._id);
try {
await tpl.currentList.setColor(null);
} catch (err) {
@@ -480,50 +512,199 @@ Template.setListColorPopup.events({
},
});
// #6409: the per-list width popup is now a single fixed-width value. Whether it
// affects everyone (shared) or just the current user (personal) follows the
// board setting `allowsPersonalListWidth`.
function isPersonalListWidth(boardId) {
const board = ReactiveCache.getBoard(boardId);
return !!(board && board.allowsPersonalListWidth);
}
// #5729 Fixed (same) width for all lists is a per-viewer/per-board setting.
// Logged-in users store it in their profile; anonymous (public board) users in
// localStorage (mirrors the per-list anon storage in list.js).
function readAnonFixedListWidthEnabled(boardId) {
try {
const stored = localStorage.getItem('wekan-fixed-list-width-enabled');
if (stored) {
const flags = JSON.parse(stored);
return flags[boardId] === true;
}
} catch (e) {
console.warn('Error reading fixed list width flag from localStorage:', e);
}
return false;
}
function readAnonFixedListWidth(boardId) {
try {
const stored = localStorage.getItem('wekan-fixed-list-width');
if (stored) {
const widths = JSON.parse(stored);
const w = widths[boardId];
if (typeof w === 'number' && w >= 270) return w;
}
} catch (e) {
console.warn('Error reading fixed list width from localStorage:', e);
}
return 272;
}
function isFixedListWidth(boardId) {
const user = ReactiveCache.getCurrentUser();
if (user) return !!user.isFixedListWidth(boardId);
return readAnonFixedListWidthEnabled(boardId);
}
function fixedListWidthValue(boardId) {
const user = ReactiveCache.getCurrentUser();
if (user) return user.getFixedListWidth(boardId);
return readAnonFixedListWidth(boardId);
}
function setAnonFixedListWidthEnabled(boardId, enabled) {
try {
const stored = localStorage.getItem('wekan-fixed-list-width-enabled');
const flags = stored ? JSON.parse(stored) : {};
flags[boardId] = !!enabled;
localStorage.setItem('wekan-fixed-list-width-enabled', JSON.stringify(flags));
} catch (e) {
console.warn('Error saving fixed list width flag to localStorage:', e);
}
}
function setAnonFixedListWidth(boardId, width) {
try {
const stored = localStorage.getItem('wekan-fixed-list-width');
const widths = stored ? JSON.parse(stored) : {};
widths[boardId] = width;
localStorage.setItem('wekan-fixed-list-width', JSON.stringify(widths));
} catch (e) {
console.warn('Error saving fixed list width to localStorage:', e);
}
}
Template.setListWidthPopup.helpers({
listWidthValue() {
const list = Template.currentData();
const board = list.boardId;
return ReactiveCache.getCurrentUser().getListWidth(board, list._id);
},
listConstraintValue() {
const list = Template.currentData();
const board = list.boardId;
return ReactiveCache.getCurrentUser().getListConstraint(board, list._id);
},
isAutoWidth() {
const boardId = Utils.getCurrentBoardId();
// #5729 In fixed width mode the input edits the single per-board value.
if (isFixedListWidth(list.boardId)) {
return fixedListWidthValue(list.boardId);
}
const shared =
typeof list.width === 'number' && list.width >= 270 ? list.width : 272;
if (!isPersonalListWidth(list.boardId)) {
return shared;
}
const user = ReactiveCache.getCurrentUser();
return user && user.isAutoWidth(boardId);
if (user) {
const widths = user.getListWidths();
const w = widths[list.boardId] && widths[list.boardId][list._id];
return typeof w === 'number' && w >= 270 ? w : shared;
}
return shared;
},
listWidthScopeNote() {
const list = Template.currentData();
// #5729 In fixed width mode the note explains the all-lists behaviour.
if (isFixedListWidth(list.boardId)) {
return TAPi18n.__('fixed-list-width-note');
}
return isPersonalListWidth(list.boardId)
? TAPi18n.__('list-width-personal-note')
: TAPi18n.__('list-width-shared-note');
},
// #6409: auto-width follows the same scope as fixed widths.
isAutoWidth() {
const list = Template.currentData();
if (isPersonalListWidth(list.boardId)) {
const user = ReactiveCache.getCurrentUser();
return !!(user && user.isAutoWidth(list.boardId));
}
const board = ReactiveCache.getBoard(list.boardId);
return !!(board && board.autoWidth);
},
// In shared mode only members with board write access may toggle the shared
// auto-width; in personal mode any logged-in user may toggle their own.
canChangeAutoWidth() {
const list = Template.currentData();
if (isPersonalListWidth(list.boardId)) {
return !!ReactiveCache.getCurrentUser();
}
return Utils.canModifyBoard();
},
// #5729 Whether "same width for all lists" mode is on for the current viewer.
isFixedListWidth() {
const list = Template.currentData();
return isFixedListWidth(list.boardId);
},
});
Template.setListWidthPopup.events({
'click .js-auto-width-board'() {
dragscroll.reset();
ReactiveCache.getCurrentUser().toggleAutoWidth(Utils.getCurrentBoardId());
'click .js-toggle-auto-width'(event) {
event.preventDefault();
const list = Template.currentData();
const boardId = list.boardId;
if (isPersonalListWidth(boardId)) {
const user = ReactiveCache.getCurrentUser();
if (user) user.toggleAutoWidth(boardId);
} else {
const board = ReactiveCache.getBoard(boardId);
const current = !!(board && board.autoWidth);
Meteor.call('setBoardAutoWidth', boardId, !current);
}
Popup.back();
},
// #5729 Toggle "same width for all lists" (fixed width) for the current
// viewer. Enabling it turns off auto-width (the two modes are exclusive).
'click .js-toggle-fixed-list-width'(event) {
event.preventDefault();
const list = Template.currentData();
const boardId = list.boardId;
const enabled = !isFixedListWidth(boardId);
const user = ReactiveCache.getCurrentUser();
if (user) {
if (enabled && isPersonalListWidth(boardId) && user.isAutoWidth(boardId)) {
user.toggleAutoWidth(boardId);
}
Meteor.call('setFixedListWidthEnabled', boardId, enabled);
} else {
setAnonFixedListWidthEnabled(boardId, enabled);
}
Popup.back();
},
'click .list-width-apply'(event, tpl) {
const list = Template.currentData();
const board = list.boardId;
const width = parseInt(
tpl.$('.list-width-value').val(),
10,
);
const constraint = parseInt(
tpl.$('.list-constraint-value').val(),
10,
);
const boardId = list.boardId;
const width = parseInt(tpl.$('.list-width-value').val(), 10);
// FIXME(mark-i-m): where do we put constants?
if (width < 270 || !width || constraint < 270 || !constraint) {
if (!width || width < 270) {
tpl.$('.list-width-error').click();
} else {
Meteor.call('applyListWidth', board, list._id, width, constraint);
Popup.back();
return;
}
const user = ReactiveCache.getCurrentUser();
// #5729 In fixed width mode the input applies to the single per-board value.
if (isFixedListWidth(boardId)) {
if (user) {
Meteor.call('setFixedListWidth', boardId, width);
} else {
setAnonFixedListWidth(boardId, width);
}
Popup.back();
return;
}
if (isPersonalListWidth(boardId)) {
if (user) {
Meteor.call('applyListWidthToStorage', boardId, list._id, width, width);
}
} else if (user) {
Meteor.call('applyListWidth', boardId, list._id, width, width);
}
Popup.back();
},
'click .list-width-error': Popup.open('listWidthError'),
});
+21
View File
@@ -3,3 +3,24 @@
margin-inline-end: auto;
margin-inline-start: auto;
}
/* Scrollable list container for the Due Cards view.
Scoped to the due-cards list only, not global (issue #5930). */
.due-cards-list {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
/* Leave room for the header bar / results header so the list itself can
scroll instead of being clipped after the first few cards. */
max-height: calc(100vh - 160px);
}
/* On small screens (mobile / iPhone) the previous fixed height clipped the
list at roughly 5 cards. Allow the list to use the available viewport
height and scroll past the first cards. */
@media screen and (max-width: 800px) {
.due-cards-list {
max-height: calc(100vh - 120px);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
}
+3 -2
View File
@@ -35,8 +35,9 @@ template(name="dueCards")
.due-cards-results-header
h1
= resultsText
each card in dueCardsList
+resultCard(card)
.due-cards-list
each card in dueCardsList
+resultCard(card)
else
.global-search-results-list-wrapper
.no-results
+6 -32
View File
@@ -1,6 +1,7 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import { Utils } from '/client/lib/utils';
import { filterAndSortDueCards } from '/client/components/main/dueCardsLogic';
// const subManager = new SubsManager();
@@ -70,44 +71,17 @@ Template.dueCards.onCreated(function () {
// Filter cards based on user view preference
const allUsers = dueCardsView() === 'all';
const currentUser = ReactiveCache.getCurrentUser();
let filteredCards = cards;
if (process.env.DEBUG === 'true') {
console.log('dueCards client: current user:', currentUser ? currentUser._id : 'none');
console.log('dueCards client: showing all users:', allUsers);
}
if (!allUsers && currentUser) {
filteredCards = cards.filter(card => {
const isMember = card.members && card.members.includes(currentUser._id);
const isAssignee = card.assignees && card.assignees.includes(currentUser._id);
const isAuthor = card.userId === currentUser._id;
const matches = isMember || isAssignee || isAuthor;
if (process.env.DEBUG === 'true' && matches) {
console.log('dueCards client: card matches user:', card.title, { isMember, isAssignee, isAuthor });
}
return matches;
});
}
// Normalize dueAt to timestamps for stable client-side ordering
const future = new Date('2100-12-31').getTime();
const toTime = v => {
if (v === null || v === undefined || v === '') return future;
if (v instanceof Date) return v.getTime();
const t = new Date(v);
if (!isNaN(t.getTime())) return t.getTime();
return future;
};
filteredCards.sort((a, b) => {
const x = toTime(a.dueAt);
const y = toTime(b.dueAt);
if (x > y) return 1;
if (x < y) return -1;
return 0;
// Filter (by user unless "all") and sort ascending by due date. No cap is
// applied so every due card is shown (issues #5999 / #5930).
const filteredCards = filterAndSortDueCards(cards, {
allUsers,
userId: currentUser ? currentUser._id : null,
});
if (process.env.DEBUG === 'true') {
+52
View File
@@ -0,0 +1,52 @@
// Pure, side-effect-free helpers for the Due Cards view.
//
// These are extracted from dueCards.js so they can be unit-tested in
// isolation (see client/lib/tests/dueCards.logic.tests.js). They must not
// reference Meteor, Blaze, ReactiveCache or the DOM.
// A far-future sentinel used to push cards with a missing/invalid dueAt to the
// end of the ascending sort instead of dropping them.
const FAR_FUTURE = new Date('2100-12-31').getTime();
// Normalize a dueAt value (Date | string | null | undefined) to a comparable
// millisecond timestamp. Invalid / empty values sort last.
export function dueAtToTime(value, farFuture = FAR_FUTURE) {
if (value === null || value === undefined || value === '') return farFuture;
if (value instanceof Date) return value.getTime();
const t = new Date(value);
if (!isNaN(t.getTime())) return t.getTime();
return farFuture;
}
// Decide whether a card belongs to the current user (member, assignee or
// author). Used when the view is "me" rather than "all".
export function cardMatchesUser(card, userId) {
if (!card || !userId) return false;
const isMember = Array.isArray(card.members) && card.members.includes(userId);
const isAssignee =
Array.isArray(card.assignees) && card.assignees.includes(userId);
const isAuthor = card.userId === userId;
return isMember || isAssignee || isAuthor;
}
// Filter (by user, unless allUsers) and sort (ascending by due date) a list of
// due cards. Returns a new array; does not mutate the input. Importantly this
// imposes NO cap on the number of results (issue #5999 / #5930).
export function filterAndSortDueCards(cards, { allUsers = false, userId = null } = {}) {
const list = Array.isArray(cards) ? cards.slice() : [];
const filtered =
allUsers || !userId
? list
: list.filter(card => cardMatchesUser(card, userId));
filtered.sort((a, b) => {
const x = dueAtToTime(a.dueAt);
const y = dueAtToTime(b.dueAt);
if (x > y) return 1;
if (x < y) return -1;
return 0;
});
return filtered;
}
+19
View File
@@ -130,3 +130,22 @@ code {
.global-search-instructions li {
margin-bottom: 0.3rem;
}
/* #6034 (MVP): drag-and-drop search results into board columns. */
.global-search-results-list .result-card-wrapper {
cursor: grab;
}
.global-search-results-list .result-card-wrapper:active {
cursor: grabbing;
}
.global-search-result-drag-helper {
z-index: 1000;
opacity: 0.85;
pointer-events: none;
list-style: none;
}
.global-search-drop-hover {
outline: 2px dashed #216694;
outline-offset: -2px;
background-color: rgba(33, 102, 148, 0.08);
}
+36 -4
View File
@@ -4,6 +4,36 @@ template(name="globalSearchHeaderBar")
i.fa.fa-search
| {{_ 'globalSearch-title'}}
.board-header-btns.left
a.board-header-btn.js-global-search-view-change(title="{{_ 'globalSearchViewChange-title'}}")
i.fa.fa-caret-down
if $eq globalSearchView 'me'
i.fa.fa-user
| {{_ 'globalSearchViewChange-choice-me'}}
if $eq globalSearchView 'all'
i.fa.fa-users
| {{_ 'globalSearchViewChange-choice-all'}}
template(name="globalSearchViewChangePopup")
if currentUser
ul.pop-over-list
li
a.js-global-search-view-me
i.fa.fa-user
| {{_ 'globalSearchViewChange-choice-me'}}
if $eq Utils.globalSearchView "me"
i.fa.fa-check
hr
li
a.js-global-search-view-all
i.fa.fa-users
| {{_ 'globalSearchViewChange-choice-all'}}
span.sub-name
+viewer
| {{_ 'globalSearchViewChange-choice-all-description' }}
if $eq Utils.globalSearchView "all"
i.fa.fa-check
template(name="globalSearchModalTitle")
if currentUser
h2
@@ -29,7 +59,8 @@ template(name="resultsPaged")
template(name="globalSearch")
if currentUser
.wrapper(class=boardColorClass)
form.global-search-page.js-search-query-form(onsubmit="return false;")
form.global-search-page.js-search-query-form(role="search" onsubmit="return false;")
label.sr-only(for="global-search-input") {{_ 'globalSearch-title'}}
input.global-search-query-input(
style="display: inline-block;"
id="global-search-input"
@@ -37,14 +68,15 @@ template(name="globalSearch")
name="searchQuery"
placeholder="{{_ 'search-example'}}"
value="{{ queryValue }}"
aria-label="{{_ 'globalSearch-title'}}"
autofocus dir="auto"
)
button.js-new-search.board-color-belize(type="button" style="background: #216694 !important; background-color: #216694 !important; color: #fff !important; border: none !important;")
| 🧹
button.js-new-search.board-color-belize(type="button" aria-label="{{_ 'globalSearch-title'}}" style="background: #216694 !important; background-color: #216694 !important; color: #fff !important; border: none !important;")
span(aria-hidden="true") 🧹
if searching
+spinner
else if hasResults
.global-search-results-list-wrapper
.global-search-results-list-wrapper.global-search-results-list
if hasQueryErrors
ul
each msg in errorMessages
+187 -14
View File
@@ -2,12 +2,34 @@ import { TAPi18n } from '/imports/i18n';
import { CardSearchPaged } from '../../lib/cardSearch';
import { LABEL_COLORS } from '/models/metadata/colors';
import { Query, QueryErrors } from '../../../config/query-classes';
import { OPERATOR_USER } from '/config/search-const';
import { ReactiveCache } from '/imports/reactiveCache';
import { Utils } from '/client/lib/utils';
// const subManager = new SubsManager();
// #3392-adjacent: "my cards / all cards" toggle for global search (mirrors the
// due-cards view toggle). When "me", the current user is injected as a `user:`
// predicate (member OR assignee), reusing the existing search logic.
Template.globalSearchHeaderBar.helpers({
globalSearchView() {
return Utils.globalSearchView();
},
});
Template.globalSearchHeaderBar.events({
'click .js-due-cards-view-change': Popup.open('globalSearchViewChange'),
'click .js-global-search-view-change': Popup.open('globalSearchViewChange'),
});
Template.globalSearchViewChangePopup.events({
'click .js-global-search-view-me'() {
Utils.setGlobalSearchView('me');
Popup.back();
},
'click .js-global-search-view-all'() {
Utils.setGlobalSearchView('all');
Popup.back();
},
});
Template.globalSearch.onCreated(function () {
@@ -42,27 +64,171 @@ Template.globalSearch.onCreated(function () {
Template.globalSearch.onRendered(function () {
Meteor.subscribe('setting');
// eslint-disable-next-line no-console
//console.log('lang:', TAPi18n.getLanguage());
const tpl = this;
// Re-run the search reactively when the query OR the "my/all" view changes,
// so toggling the view filters immediately without a page reload.
this.autorun(() => {
const view = Utils.globalSearchView(); // reactive dependency
const queryText = Session.get('globalQuery'); // reactive dependency
if (queryText || view === 'me') {
searchAllBoards(tpl, queryText || '');
}
});
if (Session.get('globalQuery')) {
searchAllBoards(this, Session.get('globalQuery'));
}
// #6034 (MVP): make search-result cards draggable so they can be dropped
// into a board list/column. Re-wire whenever the results change.
this.autorun(() => {
tpl.search.results.get(); // reactive dependency: re-run when results render
Tracker.afterFlush(() => setupResultDrag(tpl));
});
});
// #6034 (MVP): drag-and-drop search results into board columns.
//
// We make each `.global-search-results-list` result item a jquery-ui
// `draggable` (clone helper), and make every board list's `.js-minicards`
// container a `droppable`. On drop we resolve the dragged card and call the
// existing `card.move(boardId, swimlaneId, listId, sort)` model method - the
// same method the in-board card sortable uses - so the move stays consistent
// with normal card drag-drop and is persisted + reactively re-rendered.
//
// LIMITATION / TODO: this MVP drops the card at the END of the target list
// (sort = list cards length) rather than at the exact pixel-precise insertion
// index used by the in-board minicard sortable. Pixel-precise positioning and
// multi-select drag are intentionally out of scope here; wiring those would
// require modifying the (non-owned) list/minicard sortable `receive` handler.
// Drag is only enabled when the board is editable and a board is open.
function setupResultDrag(tpl) {
try {
if (typeof window === 'undefined' || !window.jQuery) return;
const $ = window.jQuery;
const $items = tpl.$('.global-search-results-list .result-card-wrapper');
if (!$items.length || typeof $items.draggable !== 'function') return;
// Only allow DnD when we are inside a board the user can modify.
const boardId = Session.get('currentBoard');
if (!boardId) return;
const canModify =
Utils && typeof Utils.canModifyBoard === 'function'
? Utils.canModifyBoard()
: false;
if (!canModify) return;
$items.each(function () {
const el = this;
// Avoid double-initialising the same element.
if ($(el).data('uiDraggable')) return;
$(el).draggable({
helper: 'clone',
appendTo: 'body',
revert: 'invalid',
revertDuration: 150,
cursor: 'grabbing',
zIndex: 1000,
start(evt, ui) {
ui.helper.addClass('global-search-result-drag-helper');
ui.helper.css('width', $(el).width());
},
});
});
// Make board lists accept the dropped result card.
const $lists = $('.list .js-minicards');
if (!$lists.length || typeof $lists.droppable !== 'function') return;
$lists.each(function () {
const listEl = this;
if ($(listEl).data('uiDroppable')) return;
$(listEl).droppable({
accept: '.result-card-wrapper',
tolerance: 'pointer',
hoverClass: 'global-search-drop-hover',
drop(evt, ui) {
onResultDroppedOnList(listEl, ui.draggable.get(0));
},
});
});
} catch (e) {
console.error('Error wiring search-result drag-and-drop (#6034):', e);
}
}
// Resolve the dragged result card + target list and perform the move using the
// existing card model `move()` method (mirrors the in-board card sortable).
function onResultDroppedOnList(listEl, draggedEl) {
try {
if (!listEl || !draggedEl) return;
// Card data from the dragged result item (Blaze data on the result wrapper,
// falling back to the inner minicard).
let cardData = Blaze.getData(draggedEl);
if (!cardData || !cardData._id) {
const inner = draggedEl.querySelector('.js-minicard');
if (inner) cardData = Blaze.getData(inner);
}
if (!cardData || !cardData._id) return;
const card = ReactiveCache.getCard(cardData._id);
if (!card || typeof card.move !== 'function') return;
// Target list / swimlane / board from the drop target.
const listData = Blaze.getData(listEl.closest('.list') || listEl);
if (!listData || !listData._id) return;
const listId = listData._id;
const currentBoard = Utils.getCurrentBoard();
if (!currentBoard) return;
let swimlaneId = listData.swimlaneId;
if (!swimlaneId) {
const swimlaneEl = listEl.closest('.swimlane');
if (swimlaneEl) {
const swimlaneData = Blaze.getData(swimlaneEl);
swimlaneId = swimlaneData && swimlaneData._id;
}
}
if (!swimlaneId) {
const def = currentBoard.getDefaultSwimline && currentBoard.getDefaultSwimline();
swimlaneId = (def && def._id) || card.swimlaneId;
}
// MVP: append to the end of the target list.
let sort = 0;
try {
const existing = ReactiveCache.getCards(
{ listId, archived: false },
{ sort: { sort: -1 }, limit: 1 },
);
if (existing && existing.length && typeof existing[0].sort === 'number') {
sort = existing[0].sort + 1;
}
} catch (e) {
sort = 0;
}
card.move(currentBoard._id, swimlaneId, listId, sort);
} catch (e) {
console.error('Error moving dropped search result (#6034):', e);
}
}
function searchAllBoards(tpl, queryText) {
const search = tpl.search;
queryText = queryText.trim();
// eslint-disable-next-line no-console
//console.log('queryText:', queryText);
queryText = (queryText || '').trim();
search.query.set(queryText);
search.resetSearch();
tpl.parsingErrors = new QueryErrors();
if (!queryText) {
const mine = Utils.globalSearchView() === 'me';
// Nothing to search: empty query and not restricting to the current user.
if (!queryText && !mine) {
return;
}
@@ -71,10 +237,17 @@ function searchAllBoards(tpl, queryText) {
const query = new Query();
query.buildParams(queryText);
// eslint-disable-next-line no-console
// console.log('params:', query.getParams());
const queryParams = query.getQueryParams();
tpl.queryParams = query.getQueryParams().getParams();
// "My cards": restrict to cards where the current user is member or assignee.
if (mine) {
const currentUser = ReactiveCache.getCurrentUser();
if (currentUser && currentUser.username) {
queryParams.addPredicate(OPERATOR_USER, currentUser.username);
}
}
tpl.queryParams = queryParams.getParams();
if (query.hasErrors()) {
search.searching.set(false);
@@ -84,7 +257,7 @@ function searchAllBoards(tpl, queryText) {
return;
}
search.runGlobalSearch(query.getQueryParams());
search.runGlobalSearch(queryParams);
}
function errorMessages(tpl) {
+45 -41
View File
@@ -496,15 +496,19 @@ body:not(.board-view) #header #header-main-bar h1 {
@media screen and (max-width: 800px),
screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
#header #header-main-bar {
height: 40px;
/* #6419: taller bar so the action icons can use ~44px touch targets. */
height: 48px;
}
#header #header-main-bar .board-header-btns {
margin-top: 0px;
}
#header #header-main-bar .board-header-btn {
height: 32px;
line-height: 32px;
font-size: 15px;
/* #6419: 32px tall / 15px was below the comfortable mobile touch-target and
readable-font minimum (~44px / 16px). Enlarged vertically + bigger font;
width is unchanged so this does not worsen the horizontal icon crowding. */
height: 44px;
line-height: 44px;
font-size: 16px;
}
#header #header-main-bar .board-header-btn i.fa {
line-height: 32px;
@@ -554,7 +558,7 @@ body:not(.board-view) #header #header-main-bar h1 {
}
}
/* Mobile - make all text and icons 2x bigger above #content by default */
/* Mobile - keep header text/icons at normal size (reverted #6426 oversizing) */
@media screen and (max-width: 800px),
screen and (max-device-width: 800px),
screen and (-webkit-min-device-pixel-ratio: 2) and (max-width: 800px),
@@ -569,7 +573,7 @@ body:not(.board-view) #header #header-main-bar h1 {
overflow: hidden !important; /* Prevent content overflow */
}
#header-quick-access {
font-size: 2em !important; /* 2x bigger base font size */
font-size: 1em !important; /* Normal size (reverted #6426 oversizing) */
}
#header-quick-access * {
@@ -578,7 +582,7 @@ body:not(.board-view) #header #header-main-bar h1 {
#header-quick-access .fa,
#header-quick-access .icon {
font-size: 2em !important; /* 2x bigger icons */
font-size: 1em !important; /* Normal icon size (reverted #6426 oversizing) */
}
#header-quick-access .home-icon a {
@@ -648,7 +652,7 @@ body:not(.board-view) #header #header-main-bar h1 {
}
}
/* Mobile All Boards page - make logo row elements 2x bigger */
/* Mobile All Boards page - keep logo row at normal size (reverted #6426 oversizing) */
@media screen and (max-width: 800px),
screen and (max-device-width: 800px),
screen and (-webkit-min-device-pixel-ratio: 2) and (max-width: 800px),
@@ -656,16 +660,16 @@ body:not(.board-view) #header #header-main-bar h1 {
screen and (max-width: 800px) and (orientation: landscape) {
.wrapper ~ #header-quick-access,
body:not(.board-view) #header-quick-access {
font-size: 2em !important; /* 2x bigger base font size for logo row */
font-size: 1em !important; /* Normal size (reverted #6426 oversizing) */
}
/* iPhone 12 Mini specific - 3x bigger for All Boards page */
/* iPhone 12 Mini specific - keep All Boards logo row at normal size (reverted #6426) */
@media screen and (device-width: 375px) and (device-height: 812px), /* iPhone 12 Mini exact */
screen and (max-width: 375px) and (max-height: 812px), /* iPhone 12 Mini viewport */
screen and (-webkit-min-device-pixel-ratio: 3) and (max-width: 375px) /* iPhone 12 Mini Retina */ {
.wrapper ~ #header-quick-access,
body:not(.board-view) #header-quick-access {
font-size: 3em !important; /* 3x bigger base font size for iPhone 12 Mini All Boards page */
font-size: 1em !important; /* Normal size (reverted #6426 oversizing) */
}
}
@@ -678,7 +682,7 @@ body:not(.board-view) #header #header-main-bar h1 {
.wrapper ~ #header-quick-access .icon,
body:not(.board-view) #header-quick-access .fa,
body:not(.board-view) #header-quick-access .icon {
font-size: 2em !important; /* 2x bigger icons in logo row */
font-size: 1em !important; /* Normal icon size (reverted #6426 oversizing) */
}
.wrapper ~ #header-quick-access .home-icon a,
@@ -737,19 +741,19 @@ body:not(.board-view) #header #header-main-bar h1 {
}
}
/* iPhone 12 Mini specific - make header elements 3x bigger */
/* iPhone 12 Mini specific - keep the header a normal compact single row */
@media screen and (device-width: 375px) and (device-height: 812px), /* iPhone 12 Mini exact */
screen and (max-width: 375px) and (max-height: 812px), /* iPhone 12 Mini viewport */
screen and (-webkit-min-device-pixel-ratio: 3) and (max-width: 375px), /* iPhone 12 Mini Retina */
screen and (max-width: 375px) and (orientation: portrait), /* iPhone 12 Mini portrait */
screen and (max-width: 375px) and (orientation: landscape) /* iPhone 12 Mini landscape */ {
#header-quick-access {
font-size: 3em !important; /* 3x bigger base font size for iPhone 12 Mini */
height: auto !important; /* Allow height to grow */
min-height: 84px !important; /* Much taller minimum height for iPhone 12 Mini */
flex-wrap: wrap !important; /* Force wrapping */
align-items: flex-start !important; /* Align to top when wrapping */
padding: 18px 0px !important; /* More padding for iPhone 12 Mini */
font-size: 1em !important; /* Normal size (reverted #6426 oversizing) */
height: 48px !important;
min-height: 48px !important;
flex-wrap: nowrap !important;
align-items: center !important;
padding: 8px 0px !important;
}
#header-quick-access * {
@@ -758,7 +762,7 @@ body:not(.board-view) #header #header-main-bar h1 {
#header-quick-access .fa,
#header-quick-access .icon {
font-size: 3em !important; /* 3x bigger icons for iPhone 12 Mini */
font-size: 1em !important; /* Normal icon size (reverted #6426 oversizing) */
}
#header-quick-access .home-icon a {
@@ -844,7 +848,7 @@ body:not(.board-view) #header #header-main-bar h1 {
}
}
/* iPhone 12 Mini and very small screens - make header elements much larger */
/* iPhone 12 Mini and very small screens - header element sizing */
@media screen and (max-width: 400px) and (max-height: 900px),
screen and (max-device-width: 400px) and (max-device-height: 900px),
screen and (-webkit-min-device-pixel-ratio: 2) and (max-width: 400px),
@@ -908,12 +912,12 @@ body:not(.board-view) #header #header-main-bar h1 {
/* Fallback for iPhone devices using JavaScript detection */
.iphone-device #header-quick-access {
font-size: 2em !important; /* 2x bigger base font size */
height: auto !important; /* Allow height to grow */
min-height: 48px !important; /* Minimum height for mobile */
flex-wrap: wrap !important; /* Force wrapping */
align-items: flex-start !important; /* Align to top when wrapping */
padding: 8px 0px !important; /* Adjust padding for mobile */
font-size: 1em !important; /* Normal size (reverted #6426 oversizing) */
height: 48px !important;
min-height: 48px !important;
flex-wrap: nowrap !important;
align-items: center !important;
padding: 8px 0px !important;
}
.iphone-device #header-quick-access * {
@@ -922,7 +926,7 @@ body:not(.board-view) #header #header-main-bar h1 {
.iphone-device #header-quick-access .fa,
.iphone-device #header-quick-access .icon {
font-size: 2em !important; /* 2x bigger icons */
font-size: 1em !important; /* Normal icon size (reverted #6426 oversizing) */
}
.iphone-device #header-quick-access .home-icon a {
@@ -991,14 +995,14 @@ body:not(.board-view) #header #header-main-bar h1 {
width: auto !important;
}
/* iPhone 12 Mini specific - JavaScript detection fallback */
/* iPhone 12 Mini specific - JavaScript detection fallback (normal compact row) */
.iphone-device #header-quick-access {
font-size: 3em !important; /* 3x bigger base font size for iPhone 12 Mini */
height: auto !important; /* Allow height to grow */
min-height: 84px !important; /* Much taller minimum height for iPhone 12 Mini */
flex-wrap: wrap !important; /* Force wrapping */
align-items: flex-start !important; /* Align to top when wrapping */
padding: 18px 0px !important; /* More padding for iPhone 12 Mini */
font-size: 1em !important; /* Normal size (reverted #6426 oversizing) */
height: 48px !important;
min-height: 48px !important;
flex-wrap: nowrap !important;
align-items: center !important;
padding: 8px 0px !important;
}
.iphone-device #header-quick-access * {
@@ -1007,7 +1011,7 @@ body:not(.board-view) #header #header-main-bar h1 {
.iphone-device #header-quick-access .fa,
.iphone-device #header-quick-access .icon {
font-size: 3em !important; /* 3x bigger icons for iPhone 12 Mini */
font-size: 1em !important; /* Normal icon size (reverted #6426 oversizing) */
}
.iphone-device #header-quick-access .home-icon a {
@@ -1092,10 +1096,10 @@ body:not(.board-view) #header #header-main-bar h1 {
width: auto !important;
}
/* iPhone 12 Mini All Boards page - make logo row elements 3x bigger */
/* iPhone 12 Mini All Boards page - keep logo row at normal size (reverted #6426) */
.iphone-device .wrapper ~ #header-quick-access,
.iphone-device body:not(.board-view) #header-quick-access {
font-size: 3em !important; /* 3x bigger base font size for logo row */
font-size: 1em !important; /* Normal size (reverted #6426 oversizing) */
}
.iphone-device .wrapper ~ #header-quick-access *,
@@ -1107,7 +1111,7 @@ body:not(.board-view) #header #header-main-bar h1 {
.iphone-device .wrapper ~ #header-quick-access .icon,
.iphone-device body:not(.board-view) #header-quick-access .fa,
.iphone-device body:not(.board-view) #header-quick-access .icon {
font-size: 2em !important; /* 2x bigger icons in logo row */
font-size: 1em !important; /* Normal icon size (reverted #6426 oversizing) */
}
.iphone-device .wrapper ~ #header-quick-access .home-icon a,
@@ -1205,7 +1209,7 @@ body:not(.board-view) #header #header-main-bar h1 {
.iphone-device #header-quick-access,
.iphone-device .wrapper ~ #header-quick-access,
.iphone-device body:not(.board-view) #header-quick-access {
font-size: 1.2em !important;
font-size: 1em !important;
}
body:not(.board-view) #header-quick-access .fa,
body:not(.board-view) #header-quick-access .icon,
@@ -1217,7 +1221,7 @@ body:not(.board-view) #header #header-main-bar h1 {
.iphone-device .wrapper ~ #header-quick-access .icon,
.iphone-device body:not(.board-view) #header-quick-access .fa,
.iphone-device body:not(.board-view) #header-quick-access .icon {
font-size: 1.5em !important;
font-size: 1.2em !important;
}
/* Zoom (100%), drag-handle toggle and mobile/desktop toggle 30% smaller than
the other quick-access controls at small width — for all browsers. */
+5 -6
View File
@@ -5,7 +5,7 @@ template(name="header")
Reddit "subreddit" bar.
The first link goes to the boards page.
if currentUser
#header-quick-access(class=currentBoard.colorClass)
#header-quick-access(class=currentBoard.colorClass role="navigation" aria-label="{{_ 'all-boards'}}")
// Home icon - always at left side of logo
span.home-icon.allBoards
a(href="{{pathFor 'home'}}")
@@ -108,14 +108,13 @@ template(name="header")
if currentUser.isBoardMember
if hasAnnouncement
.announcement
.announcement(role="status" aria-live="polite")
p
i.fa.fa-bullhorn
i.fa.fa-bullhorn(aria-hidden="true")
+viewer
| #{announcement}
a
.js-close-announcement
i.fa.fa-times-thin
a.js-close-announcement(aria-label="{{_ 'close'}}" title="{{_ 'close'}}")
i.fa.fa-times-thin(aria-hidden="true")
template(name="offlineWarning")
.offline-warning
+27 -6
View File
@@ -2,7 +2,10 @@ import { Meteor } from 'meteor/meteor';
import { Session } from 'meteor/session';
import { ReactiveCache } from '/imports/reactiveCache';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import Announcements from '/models/announcements';
import Announcements, {
announcementVersion,
shouldShowAnnouncement,
} from '/models/announcements';
import { Utils } from '/client/lib/utils';
Meteor.subscribe('user-admin');
@@ -71,14 +74,25 @@ Template.header.helpers({
},
hasAnnouncement() {
const announcements = Announcements.findOne();
return announcements && announcements.enabled;
const announcement = Announcements.findOne();
if (!announcement || !announcement.enabled) {
return false;
}
const version = announcementVersion(announcement);
const user = Meteor.user();
const dismissedVersion =
(user && user.profile && user.profile.dismissedAnnouncementVersion) || null;
return shouldShowAnnouncement({
enabled: announcement.enabled,
version,
dismissedVersion,
});
},
announcement() {
$('.announcement').show();
const announcements = Announcements.findOne();
return announcements && announcements.body;
const announcement = Announcements.findOne();
return announcement && announcement.body;
},
zoomLevel() {
@@ -150,6 +164,13 @@ Template.header.events({
},
'click .js-close-announcement'() {
$('.announcement').hide();
// Permanently dismiss the current announcement for this user (#6051).
// The banner reappears only when the admin changes the announcement text.
Meteor.call('dismissAnnouncement', (err) => {
if (err && process.env.DEBUG === 'true') {
console.error('dismissAnnouncement error', err);
}
});
},
'click .js-select-list'() {
Session.set('currentList', this._id);
@@ -172,7 +193,7 @@ Template.header.events({
if (Utils.isMiniScreen()) {
FlowRouter.go('bookmarks');
} else {
Popup.open('bookmarksPopup')(evt);
Popup.open('bookmarks')(evt);
}
},
});
+105
View File
@@ -32,6 +32,111 @@ a:focus {
color: unset;
text-decoration: unset;
}
/* Accessibility (WCAG 2.4.7 Focus Visible): the resets above strip focus
* outlines from every element, which leaves keyboard users with no indication
* of where focus is. Restore a clearly visible focus indicator for keyboard
* navigation. :focus-visible only paints the outline for keyboard focus, so
* mouse clicks stay visually unchanged. */
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
summary:focus-visible,
[tabindex]:focus-visible,
[role="button"]:focus-visible,
[contenteditable="true"]:focus-visible {
outline: 2px solid #1565c0;
outline-offset: 2px;
/* Add a light halo so the outline is visible on both light and dark colored
* board headers. */
box-shadow: 0 0 0 4px rgba(21, 101, 192, 0.35);
border-radius: 2px;
}
/* Browsers that do not support :focus-visible fall back to :focus so keyboard
* users are never left without an indicator. */
.no-focus-visible-support a:focus,
.no-focus-visible-support button:focus,
.no-focus-visible-support [tabindex]:focus {
outline: 2px solid #1565c0;
outline-offset: 2px;
}
/* Screen-reader-only helper: visually hidden but available to assistive tech. */
.sr-only {
position: absolute !important;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Visually-hidden but still *activatable* controls (#459 accessible reordering).
* The plain `.sr-only` pattern clips its content to a 1x1 box with
* `clip: rect(0,0,0,0)` + `overflow:hidden`, which removes the element from the
* hit-test stack: a real pointer click (or Playwright's coordinate-based click)
* at the control's own position resolves to whatever sits *behind* it, so the
* click handler never fires. For interactive controls we instead keep a real,
* hit-testable box that is merely transparent and out of flow. Keyboard/AT users
* still reach it via Tab / the accessibility tree; sighted users don't see it. */
.minicard-move-buttons.sr-only,
.list-move-buttons.sr-only {
position: static !important;
width: auto;
height: auto;
margin: 0;
overflow: visible;
clip: auto;
clip-path: none;
white-space: normal;
/* Keep the group flush so the (transparent) controls don't visibly nudge the
* surrounding layout. */
display: inline-flex;
vertical-align: top;
line-height: 0;
}
.minicard-move-buttons.sr-only a,
.list-move-buttons.sr-only a {
display: inline-block;
width: 14px;
height: 14px;
overflow: hidden;
text-indent: -9999px;
white-space: nowrap;
opacity: 0;
position: relative;
z-index: 30;
}
/* Skip-to-content link (WCAG 2.4.1 Bypass Blocks): hidden until focused, then
* pinned to the top-left so keyboard users can jump past the header chrome. */
.skip-link {
position: absolute;
left: -9999px;
top: 0;
z-index: 10000;
padding: 10px 16px;
background: #1565c0;
color: #fff;
font-weight: bold;
border-radius: 0 0 4px 0;
text-decoration: none;
}
.skip-link:focus,
.skip-link:focus-visible {
left: 0;
outline: 2px solid #fff;
outline-offset: -4px;
}
/* The skip-link target must not show a persistent outline when focused
* programmatically. */
#content:focus {
outline: none;
}
.badge {
display: unset;
min-width: unset;
+12 -7
View File
@@ -1,5 +1,5 @@
template(name="main")
html(lang="{{TAPi18n.getLanguage}}" dir="{{TAPi18n.getLanguageDirection}}")
html(lang="{{htmlLang}}" dir="{{htmlDir}}")
head
title
meta(name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, user-scalable=yes, viewport-fit=cover")
@@ -78,27 +78,32 @@ template(name="userFormsLayout")
option(value="{{tag}}") {{name}} (RTL)
else
option(value="{{tag}}") {{name}}
//- Extra space below the login / register dialog so its bottom scrolls
//- fully into view and scrolling continues a little past the form.
br
br
template(name="defaultLayout")
a.skip-link(href="#content") {{_ 'skip-to-content'}}
+header
#content
#content(role="main" tabindex="-1")
| {{{afterBodyStart}}}
+Template.dynamic(template=content)
| {{{beforeBodyEnd}}}
+boardConversionProgress
if (Modal.isOpen)
#modal
#modal(role="dialog" aria-modal="true" aria-label="{{_ 'modal-title'}}")
.overlay
if (Modal.isWide)
.modal-content-wide.modal-container
a.modal-close-btn.js-close-modal
i.fa.fa-times-thin
a.modal-close-btn.js-close-modal(aria-label="{{_ 'close-dialog'}}")
i.fa.fa-times-thin(aria-hidden="true")
+Template.dynamic(template=Modal.getHeaderName)
+Template.dynamic(template=Modal.getTemplateName)
else
.modal-content.modal-container
a.modal-close-btn.js-close-modal
i.fa.fa-times-thin
a.modal-close-btn.js-close-modal(aria-label="{{_ 'close-dialog'}}")
i.fa.fa-times-thin(aria-hidden="true")
+Template.dynamic(template=Modal.getHeaderName)
+Template.dynamic(template=Modal.getTemplateName)
+95 -3
View File
@@ -3,6 +3,7 @@ import { TAPi18n } from '/imports/i18n';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import Users from '/models/users';
import { EscapeActions } from '/client/lib/escapeActions';
import { enablePageDragscroll, disablePageDragscroll } from '/client/lib/pageDragscroll';
let alreadyCheck = 1;
let isCheckDone = false;
@@ -47,6 +48,17 @@ Template.userFormsLayout.onCreated(function () {
});
Template.userFormsLayout.onRendered(() => {
// Login / register pages scroll on <body>; enable drag-to-scroll there so the
// gesture works the same as on the board swimlanes view.
enablePageDragscroll();
// Mark the auth pages so CSS can let <body> scroll as a normal document.
// body.mobile-mode locks <body> to position:fixed/100vh (an iOS board-view
// anti-bounce fix); without an inner #content scroller that traps these pages
// at viewport height, hiding the sign-up link / language selector below the
// form (and they sit behind the on-screen keyboard). See userForm.css.
document.body.classList.add('userform-layout');
Meteor.call('getAuthenticationsEnabled', (_, result) => {
let enabledAuthenticationMethods = ['password']; // we show/hide this based on isPasswordLoginEnabled
@@ -57,9 +69,18 @@ Template.userFormsLayout.onRendered(() => {
}
Meteor.call('isPasswordLoginEnabled', (_, result) => {
if (result) {
// Issue #6380: the password form (.at-pwd-form) is hidden by default in CSS
// and only revealed here. If this method call errors / returns late, or the
// accounts-templates form has not rendered its fields yet when this runs,
// the login page is left without username and password fields. Treat
// password login as enabled unless it is explicitly disabled
// (result === false), and wait for the form element to appear before
// showing it (same pattern as the OAuth button above).
if (result === false) return;
(function showPwdFormWhenReady() {
if (!$('.at-pwd-form')[0]) return setTimeout(showPwdFormWhenReady, 100);
$('.at-pwd-form').show();
}
})();
});
Meteor.call('isDisableRegistration', (_, result) => {
@@ -182,6 +203,13 @@ Template.userFormsLayout.onRendered(() => {
});
});
Template.userFormsLayout.onDestroyed(() => {
// Stop drag-scrolling <body> after leaving login / register (e.g. once logged
// in) so other layouts keep their normal behaviour.
disablePageDragscroll();
document.body.classList.remove('userform-layout');
});
Template.userFormsLayout.helpers({
isLegalNoticeLinkExist() {
const currSet = Template.instance().currentSetting.get();
@@ -234,10 +262,25 @@ Template.userFormsLayout.helpers({
},
});
Template.main.helpers({
htmlLang() {
return TAPi18n.getLanguage() || 'en';
},
htmlDir() {
return TAPi18n.getLanguageDirection() || 'ltr';
},
});
Template.userFormsLayout.events({
'change .js-userform-set-language'(event) {
const tag = $(event.currentTarget).val();
TAPi18n.setLanguage(tag);
// setLanguage is async; surface a failed load instead of silently leaving
// the UI in English (#5756).
Promise.resolve(TAPi18n.setLanguage(tag)).catch(error => {
// eslint-disable-next-line no-console
console.error(`Failed to switch language to ${tag}:`, error);
});
event.preventDefault();
},
'click #at-btn'(event, templateInstance) {
@@ -257,6 +300,55 @@ Template.defaultLayout.events({
},
});
// Accessibility (WCAG 2.4.3 Focus Order): when a modal dialog opens, move
// keyboard focus into it so screen-reader and keyboard users land inside the
// dialog instead of being left behind in the page. When it closes, restore
// focus to whatever element was focused before it opened.
Template.defaultLayout.onRendered(function () {
let lastFocused = null;
this.autorun(() => {
const isOpen = Modal.isOpen();
Tracker.afterFlush(() => {
if (isOpen) {
lastFocused = document.activeElement;
const modal = document.getElementById('modal');
if (modal) {
const focusTarget = modal.querySelector('.modal-close-btn') || modal;
focusTarget.focus();
}
} else if (lastFocused && typeof lastFocused.focus === 'function') {
lastFocused.focus();
lastFocused = null;
}
});
});
// Drag-to-scroll for every whole-page layout rendered inside defaultLayout
// (All Boards, My Cards, Due Cards, Global Search, Public, Bookmarks, Broken
// Cards, Settings, People, Admin, Import, board Rules, …). These pages scroll
// <body> on desktop and #content in mobile mode (where <body> is
// position:fixed), so enablePageDragscroll() tags whichever element actually
// scrolls. The board-canvas pages are the exception: their own
// `.board-canvas.dragscroll` handles dragging, and the canvas does its own
// vertical/horizontal scroll, so page-level tagging is turned off there to
// avoid scrolling #content and the canvas at the same time. defaultLayout is
// persistent (only its `content` swaps), so this single autorun covers every
// route without per-template wiring. Login / Register use userFormsLayout and
// keep their own enable/disable. Works the same in mobile and desktop mode.
const boardCanvasRoutes = ['board', 'board-short', 'card'];
this.autorun(() => {
FlowRouter.watchPathChange();
const onBoardCanvas = boardCanvasRoutes.includes(FlowRouter.getRouteName());
Tracker.afterFlush(() => {
if (onBoardCanvas) {
disablePageDragscroll();
} else {
enablePageDragscroll();
}
});
});
});
async function authentication(event, templateInstance) {
const match = $('#at-field-username_and_email').val();
const password = $('#at-field-password').val();
+12 -10
View File
@@ -32,7 +32,7 @@ template(name="myCards")
.wrapper
each board in myCardsList
.my-cards-board-wrapper
.my-cards-board-title(class=board.colorClass, id="header")
.my-cards-board-title(class=board.colorClass)
a(href=board.originRelativeUrl)
+viewer
= board.title
@@ -52,15 +52,17 @@ template(name="myCards")
if $eq myCardsView 'table'
.wrapper
table.my-cards-board-table
caption.sr-only {{_ 'my-cards'}}
thead
th {{_ "Card"}}
th {{_ "List"}}
th {{_ "Board"}}
th {{_ "Swimlane"}}
unless isMiniScreen
th {{_ "Members"}}
th {{_ "Labels"}}
th {{_ "Due Date"}}
tr
th(scope="col") {{_ "Card"}}
th(scope="col") {{_ "List"}}
th(scope="col") {{_ "Board"}}
th(scope="col") {{_ "Swimlane"}}
unless isMiniScreen
th(scope="col") {{_ "Members"}}
th(scope="col") {{_ "Labels"}}
th(scope="col") {{_ "Due Date"}}
tbody
each board in myCardsList
each swimlane in board.mySwimlanes
@@ -69,7 +71,7 @@ template(name="myCards")
tr
td
unless isMiniScreen
.my-cards-board-badge(class=board.colorClass, id="header")
.my-cards-board-badge(class=board.colorClass)
.my-cards-card-title-table
| {{card.title}}
//a.minicard-wrapper(href=card.originRelativeUrl)
+3
View File
@@ -40,6 +40,9 @@ Template.myCards.onCreated(function () {
Meteor.subscribe('setting');
});
// Drag-to-scroll is handled centrally for all non-board pages by
// defaultLayout's route-aware autorun (see client/components/main/layouts.js).
Template.myCards.helpers({
userId() {
return Meteor.userId();
+15
View File
@@ -1,4 +1,5 @@
import { CSSEvents } from '/client/lib/cssEvents';
import { isMobileViewportNow } from '/client/lib/responsiveUtils';
Popup.template.events({
'click .js-back-view'() {
@@ -10,6 +11,20 @@ Popup.template.events({
'click .js-confirm'() {
this.__afterConfirmAction.call(this);
},
// #5942: On mobile/touch, tapping inside the card-detail sub-popups (assign
// user / set due date) made the popup DISAPPEAR. The document-level
// click-outside handler (EscapeActions in client/lib/popup.js) closes the
// popup, and on touch some freshly-rendered children (avatars, native date
// inputs) generated events that were not recognised as "inside the popup",
// closing it before the tap could register. Stop touch/pointer events that
// originate inside the popup from bubbling to that document handler so the
// popup stays open and usable on mobile. Scoped to mobile viewports so the
// desktop click-outside-to-close behaviour is untouched.
'touchstart .pop-over, pointerdown .pop-over'(evt) {
if (isMobileViewportNow()) {
evt.stopPropagation();
}
},
// This handler intends to solve a pretty tricky bug with our popup
// transition. The transition is implemented using a large container
// (.content-container) that is moved on the x-axis (from 0 to n*PopupSize)
+6 -4
View File
@@ -4,13 +4,15 @@
class="{{#unless title}}no-title{{/unless}}"
class="{{#if offset.maxHeight}}pop-over--bounded{{/if}}"
data-popup="{{popupName}}"
role="dialog"
aria-label="{{#if title}}{{title}}{{else}}{{_ 'modal-title'}}{{/if}}"
style="left:{{offset.left}}px; top:{{offset.top}}px;{{#if offset.maxHeight}} max-height:{{offset.maxHeight}}px;{{/if}}")
.header
a.back-btn.js-back-view(class="{{#unless hasPopupParent}}is-hidden{{/unless}}")
i.fa.fa-caret-left
a.back-btn.js-back-view(class="{{#unless hasPopupParent}}is-hidden{{/unless}}" aria-label="{{_ 'go-back'}}")
i.fa.fa-caret-left(aria-hidden="true")
span.header-title= title
a.close-btn.js-close-pop-over
i.fa.fa-times-thin
a.close-btn.js-close-pop-over(aria-label="{{_ 'close-popup'}}")
i.fa.fa-times-thin(aria-hidden="true")
.content-wrapper
//-
We display the all stack of popup content next to each other and move
+162
View File
@@ -0,0 +1,162 @@
/* ============================================================================
responsiveFixes.css
Global mobile / responsive corrective overrides. Meteor auto-includes every
.css file under client/, so simply creating this file loads it.
Everything here is ADDITIVE and scoped under (max-width) media queries so the
desktop layout is left untouched. It deliberately lives in one place so the
mobile regressions below are easy to find and tune.
Issues addressed:
#5942 card-detail sub-popups (assign user / due date) disappearing on
touch -> JS guard lives in client/components/main/popup.js; this
file just makes sure those popups can't be visually clipped.
#5902 minicards rendering two-per-row instead of one-per-row on phones;
tiny hard-to-tap Home button in the header.
#6018 enormous icons / menus / text below the menu bar after upgrade.
#5982 excessive padding / margins and tiny button labels.
============================================================================ */
/* ----------------------------------------------------------------------------
#5902 - One minicard per row on narrow screens.
The minicard width itself is owned by minicard.css; we only add a mobile
override here (per task constraints) so phones get a single full-width card
per row instead of two side by side.
---------------------------------------------------------------------------- */
@media (max-width: 768px) {
.minicard,
.minicard-wrapper {
width: 100% !important;
box-sizing: border-box !important;
}
/* The list/swimlane card container should stack cards vertically, not wrap
them into columns. */
.list-body .minicard-wrapper,
.swimlane .minicard-wrapper {
display: block !important;
float: none !important;
}
}
/* ----------------------------------------------------------------------------
#5902 - Make the header "Home / All Boards" button a proper tap target.
The home icon link was tiny. Apple/Material guidance is a >=44px touch
target; give it comfortable padding and a minimum size on touch widths.
---------------------------------------------------------------------------- */
@media (max-width: 768px) {
#header-quick-access .home-icon a {
min-height: 44px !important;
min-width: 44px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
padding: 6px 12px !important;
}
/* Keep the home glyph visible and tappable without it ballooning. */
#header-quick-access .home-icon .fa-home {
font-size: 18px !important;
line-height: 1 !important;
}
}
/* ----------------------------------------------------------------------------
#6018 - "text and icons are enormous" below the menu bar.
After the upgrade, em/vw cascades in the content area scaled icons, menus and
text far too large (zoom only affected task names). Re-anchor a sane base
font-size on mobile and cap runaway icon sizing in the content region so the
UI is usable again. Scoped to #content so the (separately tuned) header bar
is not affected.
---------------------------------------------------------------------------- */
@media (max-width: 768px) {
/* Anchor the root size so rem-based sizing can't drift huge. */
html {
font-size: 100% !important; /* 16px base */
}
/* Body text in the working area: a readable, non-enormous default. */
#content {
font-size: 14px !important;
}
/* Cap oversized Font Awesome icons in the content area. Many icons had
inherited a multiplied em size; clamp them to a sane range while still
allowing the explicit small/large fa-modifiers to scale relative to it. */
#content .fa {
font-size: 16px !important;
}
#content .fa.fa-lg {
font-size: 20px !important;
}
#content .fa.fa-2x {
font-size: 24px !important;
}
/* Sidebar and card-detail menus: keep labels at a readable size rather than
the blown-up post-upgrade sizing. */
#content .sidebar .fa,
.card-details .fa {
font-size: 16px !important;
}
/* Menu / list item labels should match their controls, not be tiny next to
oversized padding (#5982 symptom of the same cluster). */
#content .pop-over-list li > a,
#content .sidebar .board-sidebar-content a {
font-size: 14px !important;
}
}
/* ----------------------------------------------------------------------------
#5982 - Excessive padding / margins and lots of empty space on mobile.
Trim the inflated spacing so controls sit closer together and button labels
no longer look tiny inside huge padded boxes.
---------------------------------------------------------------------------- */
@media (max-width: 768px) {
#content .pop-over-list li > a {
padding-top: 4px !important;
padding-bottom: 4px !important;
line-height: 1.4 !important;
}
/* Generic over-padded buttons inside the content area. */
#content button,
#content .btn,
#content .js-submit {
padding: 6px 12px !important;
margin: 4px 0 !important;
}
/* Reduce empty space between sidebar sections. */
#content .sidebar .sidebar-list li,
#content .board-sidebar-content > * {
margin-top: 4px !important;
margin-bottom: 4px !important;
}
}
/* ----------------------------------------------------------------------------
#5942 - Keep card-detail sub-popups visible & usable on mobile.
The JS fix (popup.js) stops touch events inside the popup from bubbling to
the document close handler. As belt-and-braces, make sure the assign-user /
due-date popups are never clipped off-screen and can scroll their content.
---------------------------------------------------------------------------- */
@media (max-width: 768px) {
.pop-over[data-popup="cardAssigneesPopup"],
.pop-over[data-popup="cardMembersPopup"],
.pop-over[data-popup="editCardDueDatePopup"],
.pop-over[data-popup="editCardReceivedDatePopup"],
.pop-over[data-popup="editCardStartDatePopup"],
.pop-over[data-popup="editCardEndDatePopup"] {
max-height: 100vh !important;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch !important;
}
}
@@ -103,6 +103,36 @@ template(name="boardActions")
div.trigger-button.js-link-card-action.js-goto-rules
i.fa.fa-plus
div.trigger-item
div.trigger-content
div.trigger-text
| {{_'r-sort-list'}}
div.trigger-dropdown
input(id="sort-list-name",type=text,placeholder="{{_'r-list-name'}}")
div.trigger-text
| {{_'r-sort-by'}}
div.trigger-dropdown
select(id="sort-field")
option(value="due") {{_'r-sort-due'}}
option(value="name") {{_'r-sort-name'}}
option(value="created") {{_'createdAt'}}
option(value="modified") {{_'modifiedAt'}}
div.trigger-button.js-sort-list-action.js-goto-rules
i.fa.fa-plus
div.trigger-item
div.trigger-content
div.trigger-text
| {{_'r-move-all-cards'}}
div.trigger-dropdown
input(id="moveall-from-list",type=text,placeholder="{{_'r-list-name'}}")
div.trigger-text
| {{_'r-in-list'}}
div.trigger-dropdown
input(id="moveall-to-list",type=text,placeholder="{{_'r-list-name'}}")
div.trigger-button.js-move-all-cards-action.js-goto-rules
i.fa.fa-plus
+47 -30
View File
@@ -134,36 +134,17 @@ Template.boardActions.events({
const ruleName = data.ruleName.get();
const trigger = data.triggerVar.get();
const actionSelected = tpl.find('#move-gen-action').value;
if (actionSelected === 'top') {
const triggerId = Triggers.insert(trigger);
const actionId = Actions.insert({
actionType: 'moveCardToTop',
listTitle: '*',
boardId,
desc,
});
Rules.insert({
title: ruleName,
triggerId,
actionId,
boardId,
});
}
if (actionSelected === 'bottom') {
const triggerId = Triggers.insert(trigger);
const actionId = Actions.insert({
actionType: 'moveCardToBottom',
listTitle: '*',
boardId,
desc,
});
Rules.insert({
title: ruleName,
triggerId,
actionId,
boardId,
});
}
const actionType =
actionSelected === 'bottom' ? 'moveCardToBottom' : 'moveCardToTop';
// Insert the rule on the server (see server/rulesButton.js → rules.createRule)
// rather than via three optimistic client Collection.insert() calls, whose
// documents land in minimongo limbo when the wizard subscription stops.
Meteor.call('rules.createRule', boardId, ruleName, trigger, {
actionType,
listTitle: '*',
boardId,
desc,
});
},
'click .js-add-arch-action'(event, tpl) {
const desc = Utils.getTriggerActionDesc(event, tpl);
@@ -225,5 +206,41 @@ Template.boardActions.events({
boardId,
});
},
'click .js-sort-list-action'(event, tpl) {
const data = Template.currentData();
const ruleName = data.ruleName.get();
const trigger = data.triggerVar.get();
const boardId = Session.get('currentBoard');
const desc = Utils.getTriggerActionDesc(event, tpl);
const listName = tpl.find('#sort-list-name').value || '*';
const sortField = tpl.find('#sort-field').value;
const triggerId = Triggers.insert(trigger);
const actionId = Actions.insert({
actionType: 'sortList',
listName,
sortField,
boardId,
desc,
});
Rules.insert({ title: ruleName, triggerId, actionId, boardId });
},
'click .js-move-all-cards-action'(event, tpl) {
const data = Template.currentData();
const ruleName = data.ruleName.get();
const trigger = data.triggerVar.get();
const boardId = Session.get('currentBoard');
const desc = Utils.getTriggerActionDesc(event, tpl);
const fromListName = tpl.find('#moveall-from-list').value || '*';
const listName = tpl.find('#moveall-to-list').value || '*';
const triggerId = Triggers.insert(trigger);
const actionId = Actions.insert({
actionType: 'moveAllCardsInList',
fromListName,
listName,
boardId,
desc,
});
Rules.insert({ title: ruleName, triggerId, actionId, boardId });
},
});
/* eslint-no-undef */
@@ -79,6 +79,32 @@ template(name="cardActions")
div.trigger-button.js-set-color-action.js-goto-rules
i.fa.fa-plus
div.trigger-item
div.trigger-content
div.trigger-dropdown
select(id="complete-action")
option(value="markCardComplete") {{_'r-mark-complete'}}
option(value="markCardIncomplete") {{_'r-mark-incomplete'}}
div.trigger-button.js-set-complete-action.js-goto-rules
i.fa.fa-plus
div.trigger-item
div.trigger-content
div.trigger-text
| {{_'r-set-date-relative'}}
div.trigger-dropdown
select(id="reldate-datefield")
option(value="startAt") {{_'r-df-start-at'}}
option(value="dueAt") {{_'r-df-due-at'}}
option(value="endAt") {{_'r-df-end-at'}}
option(value="receivedAt") {{_'r-df-received-at'}}
div.trigger-dropdown
input(id="reldate-days",type="number",value="0")
div.trigger-text
| {{_'r-days-after'}}
div.trigger-button.js-set-reldate-action.js-goto-rules
i.fa.fa-plus
template(name="setCardActionsColorPopup")
form.edit-label
.palette-colors: each colors
@@ -220,6 +220,35 @@ Template.cardActions.events({
boardId,
});
},
'click .js-set-complete-action'(event, tpl) {
const data = Template.currentData();
const ruleName = data.ruleName.get();
const trigger = data.triggerVar.get();
const boardId = Session.get('currentBoard');
const desc = Utils.getTriggerActionDesc(event, tpl);
const actionType = tpl.find('#complete-action').value;
const triggerId = Triggers.insert(trigger);
const actionId = Actions.insert({ actionType, boardId, desc });
Rules.insert({ title: ruleName, triggerId, actionId, boardId });
},
'click .js-set-reldate-action'(event, tpl) {
const data = Template.currentData();
const ruleName = data.ruleName.get();
const trigger = data.triggerVar.get();
const boardId = Session.get('currentBoard');
const desc = Utils.getTriggerActionDesc(event, tpl);
const dateField = tpl.find('#reldate-datefield').value;
const days = parseInt(tpl.find('#reldate-days').value, 10) || 0;
const triggerId = Triggers.insert(trigger);
const actionId = Actions.insert({
actionType: 'setDateRelative',
dateField,
days,
boardId,
desc,
});
Rules.insert({ title: ruleName, triggerId, actionId, boardId });
},
});
Template.setCardActionsColorPopup.onCreated(function () {
@@ -0,0 +1,5 @@
template(name="boardButtons")
each boardButtonRules
a.board-header-btn.js-run-board-button(data-rule-id="{{ruleId}}" title="{{label}}")
i.fa.fa-magic
span= label
+39
View File
@@ -0,0 +1,39 @@
import Rules from '/models/rules';
// Renders the board's "board button" rules in the board header and runs one
// (board-level, no card) when clicked. The button metadata (type + label) is
// denormalised onto the rule document (see server/rulesButton.js), so the board
// view reads it from the published `rules` collection alone — the schemaless
// `triggers` collection's documents do not reach the client over the board
// subscription in this Meteor 3 setup.
Template.boardButtons.onCreated(function () {
this.autorun(() => {
const boardId = Session.get('currentBoard');
if (boardId) this.subscribe('boardRules', boardId);
});
});
Template.boardButtons.helpers({
boardButtonRules() {
const boardId = Session.get('currentBoard');
if (!boardId) return [];
// Read Minimongo directly (not the memoizing ReactiveCache, which can latch a
// transient empty result during subscription churn). A stable `_id` lets
// Blaze reuse the node across re-renders.
return Rules.find({ boardId, buttonType: 'board' })
.fetch()
.map(rule => ({
_id: rule._id,
ruleId: rule._id,
label: rule.buttonLabel || rule.title,
}));
},
});
Template.boardButtons.events({
'click .js-run-board-button'(event) {
event.preventDefault();
const ruleId = event.currentTarget.getAttribute('data-rule-id');
Meteor.call('rules.runButton', ruleId);
},
});
+12
View File
@@ -0,0 +1,12 @@
template(name="cardButtons")
if hasCardButtons
.card-details-item.card-details-item-buttons
h3.card-details-item-title
i.fa.fa-hand-pointer-o
| {{_ 'r-card-button'}}
.card-details-item-content.card-buttons
each cardButtonRules
button.primary.js-run-card-button(data-rule-id="{{ruleId}}")
i.fa.fa-magic
= ' '
= label
+43
View File
@@ -0,0 +1,43 @@
import Rules from '/models/rules';
// Lists the board's "card button" rules and runs one on the current card when
// clicked. Expects the card data context (cardId + boardId) inherited from the
// card detail template. The button metadata is denormalised onto the rule (see
// server/rulesButton.js), so this reads from the published `rules` collection
// alone — the schemaless `triggers` collection does not reach the client over
// the board subscription in this Meteor 3 setup.
Template.cardButtons.onCreated(function () {
this.autorun(() => {
const boardId = this.data && this.data.boardId;
if (boardId) this.subscribe('boardRules', boardId);
});
});
function buttonRulesForBoard(boardId) {
if (!boardId) return [];
return Rules.find({ boardId, buttonType: 'card' })
.fetch()
.map(rule => ({
_id: rule._id,
ruleId: rule._id,
label: rule.buttonLabel || rule.title,
}));
}
Template.cardButtons.helpers({
hasCardButtons() {
return buttonRulesForBoard(this.boardId).length > 0;
},
cardButtonRules() {
return buttonRulesForBoard(this.boardId);
},
});
Template.cardButtons.events({
'click .js-run-card-button'(event, tpl) {
event.preventDefault();
const ruleId = event.currentTarget.getAttribute('data-rule-id');
const cardId = tpl.data && tpl.data._id;
Meteor.call('rules.runButton', ruleId, cardId);
},
});
+193 -2
View File
@@ -1,9 +1,171 @@
/* Rules is now a fullscreen board-scoped page (below the single top bar),
* no longer a wide modal. .rules-page centers the content and gives it room. */
.rules-page {
max-width: 1100px;
margin: 0 auto;
padding: 1vh 1vw 4vh;
}
.rules-page h2 {
margin-bottom: 1vh;
}
.rules-toolbar {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 1vh;
}
.rules-toolbar button {
cursor: pointer;
}
.rules-list {
overflow: hidden;
overflow-y: scroll;
max-height: 50vh;
overflow-y: auto;
max-height: 65vh;
padding-inline-end: 0.7vw;
}
.rules-lists-item .js-rule-select {
float: inline-start;
margin: 6px 8px 0 0;
}
.rules-lists-item .js-edit-rule-input {
width: 60%;
}
/* Visual workflow view */
.rules-workflow .workflow-group {
margin-bottom: 2vh;
}
.rules-workflow .workflow-group-title {
border-bottom: 2px solid #cfcfcf;
padding-bottom: 4px;
margin-bottom: 1vh;
}
.rules-workflow .workflow-rule {
display: flex;
align-items: stretch;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.rules-workflow .workflow-node {
flex: 1 1 200px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 8px 10px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.rules-workflow .workflow-node-when {
border-inline-start: 4px solid #1565c0;
}
.rules-workflow .workflow-node-then {
border-inline-start: 4px solid #2e7d32;
}
.rules-workflow .workflow-node-label {
display: block;
font-size: 11px;
text-transform: uppercase;
color: #888;
}
.rules-workflow .workflow-node-text {
font-size: 14px;
}
.rules-workflow .workflow-arrow {
display: flex;
align-items: center;
font-size: 20px;
color: #888;
}
/* Drag-and-drop workflow editor */
.rules-workflow .workflow-help {
color: #727479;
font-size: 13px;
margin-bottom: 1vh;
}
.rules-workflow .workflow-builder {
background: #f7f7f7;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 12px;
margin-bottom: 1.5vh;
}
.rules-workflow .workflow-builder-row {
display: flex;
align-items: stretch;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.rules-workflow .workflow-slot {
flex: 1 1 220px;
min-height: 52px;
border: 2px dashed #b0b0b0;
border-radius: 6px;
display: flex;
align-items: center;
padding: 6px 10px;
background: #fff;
}
.rules-workflow .workflow-slot-placeholder {
color: #999;
font-style: italic;
}
.rules-workflow .workflow-slot-clear {
margin-inline-start: auto;
color: #b00;
cursor: pointer;
padding-inline-start: 8px;
}
.rules-workflow .workflow-builder-controls {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.rules-workflow .workflow-builder-controls input {
flex: 1 1 240px;
}
.rules-workflow .workflow-palettes {
display: flex;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 1.5vh;
}
.rules-workflow .workflow-palette {
flex: 1 1 240px;
}
.rules-workflow .workflow-chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: #fff;
border: 1px solid #cfcfcf;
border-radius: 16px;
padding: 6px 12px;
margin: 0 6px 6px 0;
cursor: grab;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.rules-workflow .workflow-chip:active {
cursor: grabbing;
}
.rules-workflow .js-trigger-chip {
border-inline-start: 4px solid #1565c0;
}
.rules-workflow .js-action-chip {
border-inline-start: 4px solid #2e7d32;
}
.rules-workflow .workflow-existing-title {
margin-top: 1vh;
}
.rules-workflow .workflow-rule {
position: relative;
}
.rules-workflow .workflow-rule-delete {
display: flex;
align-items: center;
color: #b00;
padding-inline-start: 8px;
}
.rules-lists-item {
display: block;
position: relative;
@@ -218,3 +380,32 @@
.triggers-content .triggers-body .triggers-main-body .trigger-item.trigger-item-mail {
height: 300px;
}
/* Rules import/export popup */
.rules-import-export h3 {
margin: 0.6em 0 0.3em;
font-size: 15px;
}
.rules-import-export .rules-ie-hint {
font-size: 12px;
color: #727479;
margin-bottom: 0.3em;
}
.rules-import-export .rules-ie-textarea {
width: 100%;
box-sizing: border-box;
}
.rules-import-export .rules-ie-btns {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.rules-import-export .rules-ie-btns button {
flex: 1 1 auto;
}
.rules-import-export .rules-ie-message {
margin-top: 0.6em;
padding: 6px 8px;
background: #e8f0fe;
border-radius: 4px;
font-size: 13px;
}
@@ -0,0 +1,45 @@
template(name="rulesImportExportPopup")
.rules-import-export
h3 {{_ 'r-export'}}
.rules-ie-btns
button.primary.full.js-rules-export-json(type="button") {{_ 'r-export-json'}}
button.primary.full.js-rules-export-csv(type="button") {{_ 'r-export-csv'}}
hr
h3 {{_ 'r-import'}}
.rules-ie-target
label {{_ 'r-import-target'}}
label.rules-ie-target-row
span {{_ 'r-workspace'}}
select.js-import-workspace
option(value="") {{_ 'r-all-boards'}}
each workspaces
option(value="{{id}}")= label
label.rules-ie-target-row
span {{_ 'r-board'}}
select.js-import-board
each boardsForImport
if selected
option(value="{{_id}}" selected="selected")= title
else
option(value="{{_id}}")= title
p.rules-ie-hint {{_ 'r-import-paste'}}
textarea.rules-ie-textarea.js-rules-import-text(rows="8")
.rules-ie-btns
button.primary.full.js-rules-import-json(type="button") {{_ 'r-import-json'}}
button.primary.full.js-rules-import-csv(type="button") {{_ 'r-import-csv'}}
hr
h3 {{_ 'r-import-workflow'}}
p.rules-ie-hint {{_ 'r-import-workflow-note'}}
.rules-ie-btns
select.js-workflow-format
option(value="auto") {{_ 'r-format-auto'}}
option(value="n8n") n8n
option(value="nodered") Node-RED
button.primary.full.js-rules-import-workflow(type="button") {{_ 'r-import-workflow'}}
hr
h3 {{_ 'r-import-trello'}}
p.rules-ie-hint {{_ 'r-import-trello-note'}}
.rules-ie-btns
button.primary.full.js-rules-import-trello(type="button") {{_ 'r-import-trello'}}
if message.get
p.rules-ie-message= message.get
@@ -0,0 +1,302 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import Papa from 'papaparse';
import Actions from '/models/actions';
import Rules from '/models/rules';
import Triggers from '/models/triggers';
const RULES_FORMAT = 'wekan-rules-1.0.0';
const STRIP_FIELDS = ['_id', 'boardId', 'createdAt', 'modifiedAt', 'updatedAt'];
function stripDoc(doc) {
const out = {};
Object.keys(doc || {}).forEach(key => {
if (!STRIP_FIELDS.includes(key)) out[key] = doc[key];
});
return out;
}
// Build a portable, board-independent list of rules from the CURRENT board.
function collectBoardRules(boardId) {
let rules = ReactiveCache.getRules({ boardId });
const selected = Session.get('selectedRuleIds') || [];
if (selected.length) {
rules = rules.filter(r => selected.includes(r._id));
}
return rules
.map(rule => {
const trigger = ReactiveCache.getTrigger(rule.triggerId);
const action = ReactiveCache.getAction(rule.actionId);
if (!trigger || !action) return null;
return { title: rule.title, trigger: stripDoc(trigger), action: stripDoc(action) };
})
.filter(Boolean);
}
// Insert an array of {title, trigger, action} onto the given target board.
function importRules(rulesArray, boardId) {
let count = 0;
(rulesArray || []).forEach(entry => {
if (!entry || !entry.trigger || !entry.action) return;
const triggerId = Triggers.insert({ ...stripDoc(entry.trigger), boardId });
const actionId = Actions.insert({ ...stripDoc(entry.action), boardId });
Rules.insert({ title: entry.title || 'Imported rule', triggerId, actionId, boardId });
count += 1;
});
return count;
}
function download(filename, text, mime) {
const blob = new Blob([text], { type: `${mime};charset=utf-8` });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// --- CSV (round-trippable) --------------------------------------------------
function rulesToCsv(rulesArray) {
const rows = rulesArray.map(entry => {
const { activityType, ...triggerFields } = entry.trigger || {};
const { actionType, ...actionFields } = entry.action || {};
return {
title: entry.title || '',
triggerType: activityType || '',
triggerFields: JSON.stringify(triggerFields),
actionType: actionType || '',
actionFields: JSON.stringify(actionFields),
};
});
return Papa.unparse(rows, {
columns: ['title', 'triggerType', 'triggerFields', 'actionType', 'actionFields'],
});
}
function csvToRules(text) {
const parsed = Papa.parse(text.trim(), { header: true, skipEmptyLines: true });
return (parsed.data || [])
.map(row => {
let triggerFields = {};
let actionFields = {};
try { triggerFields = row.triggerFields ? JSON.parse(row.triggerFields) : {}; } catch (e) { triggerFields = {}; }
try { actionFields = row.actionFields ? JSON.parse(row.actionFields) : {}; } catch (e) { actionFields = {}; }
if (!row.triggerType || !row.actionType) return null;
return {
title: row.title,
trigger: { activityType: row.triggerType, ...triggerFields },
action: { actionType: row.actionType, ...actionFields },
};
})
.filter(Boolean);
}
// --- Best-effort Trello Butler parser ---------------------------------------
export function parseTrelloButler(text) {
const rules = [];
const unmapped = [];
(text || '').split('\n').map(l => l.trim()).filter(Boolean).forEach(line => {
const added = line.toLowerCase().match(/when a card is added to list ["“](.+?)["”].*move the card to the (top|bottom)/);
if (added) {
rules.push({
title: line,
trigger: { activityType: 'createCard', listName: added[1], swimlaneName: '*', cardTitle: '*', userId: '*' },
action: { actionType: added[2] === 'top' ? 'moveCardToTop' : 'moveCardToBottom', listName: '*', swimlaneName: '*' },
});
return;
}
unmapped.push(line);
});
return { rules, unmapped };
}
// --- Best-effort visual-workflow parsers (n8n, Node-RED) --------------------
// These map a workflow graph's trigger→action edges to WeKan rules by keyword.
// n8n and Node-RED nodes are arbitrary integrations, so only recognized
// trigger/action node types are mapped; unmapped edges are reported.
function mapTriggerType(type = '', name = '') {
const s = `${type} ${name}`.toLowerCase();
if (/schedule|cron|interval|inject/.test(s)) {
return { activityType: 'scheduledTrigger', scheduleKind: 'calendar', scheduleType: 'daily', atTime: '09:00', listName: '*', swimlaneName: '*' };
}
if (/trigger|webhook|http in|http-in|start/.test(s)) {
return { activityType: 'createCard', listName: '*', swimlaneName: '*', cardTitle: '*', userId: '*' };
}
return null;
}
function mapActionType(type = '', name = '') {
const s = `${type} ${name}`.toLowerCase();
if (/archive/.test(s)) return { actionType: 'archive' };
if (/move.*top|to top/.test(s)) return { actionType: 'moveCardToTop', listName: '*', swimlaneName: '*' };
if (/move.*bottom/.test(s)) return { actionType: 'moveCardToBottom', listName: '*', swimlaneName: '*' };
if (/complete|done/.test(s)) return { actionType: 'markCardComplete' };
if (/email|mail|smtp|gmail/.test(s)) return { actionType: 'sendEmail', emailTo: '', emailSubject: 'Imported workflow', emailMsg: '' };
if (/create.*card|wekan|card/.test(s)) return { actionType: 'createCard', cardName: name || 'Imported card', listName: '*', swimlaneName: '*' };
return null;
}
export function parseN8n(data) {
const nodes = data.nodes || [];
const byName = {};
nodes.forEach(n => { byName[n.name] = n; });
const rules = [];
const unmapped = [];
const conns = data.connections || {};
Object.keys(conns).forEach(srcName => {
const src = byName[srcName];
if (!src) return;
const trig = mapTriggerType(src.type, srcName);
const outs = (conns[srcName].main || []).flat();
outs.forEach(o => {
const tgt = o && byName[o.node];
if (!tgt) return;
const act = mapActionType(tgt.type, tgt.name);
if (trig && act) {
rules.push({ title: `${srcName} → ${tgt.name || o.node}`, trigger: trig, action: act });
} else {
unmapped.push(`${srcName} → ${o.node}`);
}
});
});
return { rules, unmapped };
}
export function parseNodeRed(data) {
const nodes = Array.isArray(data) ? data : (data.flows || []);
const byId = {};
nodes.forEach(n => { byId[n.id] = n; });
const rules = [];
const unmapped = [];
nodes.forEach(n => {
const trig = mapTriggerType(n.type, n.name);
if (!trig) return;
((n.wires || []).flat()).forEach(tid => {
const tgt = byId[tid];
if (!tgt) return;
const act = mapActionType(tgt.type, tgt.name);
const label = `${n.name || n.type} → ${tgt.name || tgt.type}`;
if (act) rules.push({ title: label, trigger: trig, action: act });
else unmapped.push(label);
});
});
return { rules, unmapped };
}
function parseWorkflow(text, format) {
let data;
try { data = JSON.parse(text); } catch (e) { return { rules: [], unmapped: [], error: 'invalid JSON' }; }
let fmt = format;
if (!fmt || fmt === 'auto') {
if (data && data.nodes && data.connections) fmt = 'n8n';
else if (Array.isArray(data) || data.flows) fmt = 'nodered';
}
if (fmt === 'n8n') return parseN8n(data);
if (fmt === 'nodered') return parseNodeRed(data);
return { rules: [], unmapped: [], error: 'unknown format' };
}
// --- Workspace + board selection helpers ------------------------------------
function flattenWorkspaces(nodes, depth, out) {
(nodes || []).forEach(node => {
out.push({ id: node.id, label: `${'  '.repeat(depth)}${node.name}` });
if (node.children && node.children.length) flattenWorkspaces(node.children, depth + 1, out);
});
return out;
}
Template.rulesImportExportPopup.onCreated(function () {
this.message = new ReactiveVar('');
this.selectedWorkspace = new ReactiveVar('');
this.selectedBoard = new ReactiveVar(Session.get('currentBoard'));
this.subscribe('boards'); // the user's boards (also subscribed globally)
});
Template.rulesImportExportPopup.helpers({
message() {
return Template.instance().message;
},
workspaces() {
const user = ReactiveCache.getCurrentUser();
const tree = (user && user.profile && user.profile.boardWorkspacesTree) || [];
return flattenWorkspaces(tree, 0, []);
},
boardsForImport() {
const tpl = Template.instance();
const userId = Meteor.userId();
const ws = tpl.selectedWorkspace.get();
const user = ReactiveCache.getCurrentUser();
const assignments = (user && user.profile && user.profile.boardWorkspaceAssignments) || {};
let boards = ReactiveCache.getBoards(
{ archived: false, 'members.userId': userId },
{ sort: { title: 1 } },
);
if (ws) boards = boards.filter(b => assignments[b._id] === ws);
const selectedBoard = tpl.selectedBoard.get();
return boards.map(b => ({ _id: b._id, title: b.title, selected: b._id === selectedBoard }));
},
});
function targetBoardId(tpl) {
return tpl.selectedBoard.get() || Session.get('currentBoard');
}
function reportImport(tpl, count, unmapped) {
let msg = TAPi18n.__('r-import-done', { count });
if (unmapped && unmapped.length) {
msg += ` — ${TAPi18n.__('r-import-unmapped', { count: unmapped.length })}`;
}
tpl.message.set(msg);
}
Template.rulesImportExportPopup.events({
'change .js-import-workspace'(event, tpl) {
tpl.selectedWorkspace.set(event.currentTarget.value);
},
'change .js-import-board'(event, tpl) {
tpl.selectedBoard.set(event.currentTarget.value);
},
'click .js-rules-export-json'() {
const boardId = Session.get('currentBoard');
const data = { _format: RULES_FORMAT, boardId, rules: collectBoardRules(boardId) };
download('wekan-rules.json', JSON.stringify(data, null, 2), 'application/json');
},
'click .js-rules-export-csv'() {
const boardId = Session.get('currentBoard');
download('wekan-rules.csv', rulesToCsv(collectBoardRules(boardId)), 'text/csv');
},
'click .js-rules-import-json'(event, tpl) {
const text = tpl.find('.js-rules-import-text').value;
try {
const parsed = JSON.parse(text);
const rulesArray = Array.isArray(parsed) ? parsed : parsed.rules;
reportImport(tpl, importRules(rulesArray, targetBoardId(tpl)));
} catch (e) {
tpl.message.set(String(e.message || e));
}
},
'click .js-rules-import-csv'(event, tpl) {
const text = tpl.find('.js-rules-import-text').value;
try {
reportImport(tpl, importRules(csvToRules(text), targetBoardId(tpl)));
} catch (e) {
tpl.message.set(String(e.message || e));
}
},
'click .js-rules-import-trello'(event, tpl) {
const { rules, unmapped } = parseTrelloButler(tpl.find('.js-rules-import-text').value);
reportImport(tpl, importRules(rules, targetBoardId(tpl)), unmapped);
},
'click .js-rules-import-workflow'(event, tpl) {
const format = tpl.find('.js-workflow-format').value;
const { rules, unmapped, error } = parseWorkflow(tpl.find('.js-rules-import-text').value, format);
if (error) {
tpl.message.set(error);
return;
}
reportImport(tpl, importRules(rules, targetBoardId(tpl)), unmapped);
},
});
+26 -13
View File
@@ -4,32 +4,45 @@ template(name="rulesList")
i.fa.fa-magic
| {{_ 'r-board-rules' }}
if rules.length
.rules-toolbar
button.js-rules-select-all(type="button")
i.fa.fa-check-square-o
| {{_ 'r-select-all'}}
button.js-rules-select-none(type="button")
i.fa.fa-square-o
| {{_ 'r-unselect-all'}}
if canAdmin
button.js-rules-delete-selected(type="button")
i.fa.fa-trash
| {{_ 'r-delete-selected'}}
button.js-rules-export-selected(type="button")
i.fa.fa-download
| {{_ 'r-export-selected'}}
ul.rules-list
each rules
li.rules-lists-item
input.js-rule-select(type="checkbox" data-rule-id="{{_id}}" checked=isSelected)
p
= title
if isEditing
input.js-edit-rule-input(type=text value="{{title}}" autofocus)
else
= title
div.rules-btns-group
button.primary.wide.js-goto-details(type="submit")
i.fa.fa-eye
| {{_ 'r-view-rule'}}
if currentUser.isAdmin
button.primary.wide.js-delete-rule(type="submit")
i.fa.fa-trash
| {{_ 'r-delete-rule'}}
else if currentUser.isBoardAdmin
if canAdmin
button.primary.wide.js-edit-rule(type="submit")
i.fa.fa-pencil
| {{_ 'r-edit-rule'}}
button.primary.wide.js-delete-rule(type="submit")
i.fa.fa-trash
| {{_ 'r-delete-rule'}}
else
li.no-items-message {{_ 'r-no-rules' }}
if currentUser.isAdmin
div.rules-add
button.primary.wide.rules-add-trigger.js-goto-trigger(type="submit")
i.fa.fa-plus
| {{_ 'r-add-rule'}}
input(type=text,placeholder="{{_ 'r-new-rule-name' }}",id="ruleTitle")
else if currentUser.isBoardAdmin
if canAdmin
div.rules-add
button.primary.wide.rules-add-trigger.js-goto-trigger(type="submit")
i.fa.fa-plus
Loaded 100 of 736 files, more files were not shown because too many files have changed in this diff. Show more