Compare commits

...
304 Commits
Author SHA1 Message Date
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
github-actions[bot] 0411766509 Bump versions for v9.44 2026-06-14 06:05:07 +00:00
Lauri Ojansivu 258f712672 Updated ChangeLog. 2026-06-14 08:59:58 +03:00
Lauri Ojansivu 2536861b79 - Full right-to-left (RTL) UI for every page when an RTL language is selected
(Arabic and its variants, Persian/Farsi, Hebrew, Uyghur, Uzbek-Arabic and
  Yiddish — the languages flagged `rtl: true` in `imports/i18n/languages.js`).
  Previously only some pages flipped. The root `<html>` element now gets a
  reactive `dir="rtl"`/`dir="ltr"` attribute that follows the chosen language, so
  the whole UI mirrors at once. To make this work on every page rather than a
  handful, all directional component CSS was converted to CSS logical properties
  (`margin-left`→`margin-inline-start`, `left:`→`inset-inline-start:`,
  `text-align: left`→`text-align: start`, `float: left`→`float: inline-start`,
  and the mirror-image right/end variants) across 48 stylesheets, which flip
  automatically with `dir`. Horizontal-centering rules using
  `left: 50%` + `transform: translate(-50%, …)` were intentionally kept physical,
  since those already center correctly in both directions. The calendar view also
  renders RTL. Thanks to xet7 and Claude.
- Tests for the RTL UI. A fast, server-less `tests/rtl.test.js` (run with
  `node tests/rtl.test.js`) checks that exactly the expected languages are
  flagged `rtl: true`, that the direction helper maps each language to the right
  `dir` value, that the root `<html>` and the client keep `dir` in sync, and —
  as a regression guard — that component CSS keeps using logical properties
  (no physical `margin-left`/`float: left`/bare `left:` offsets sneak back in).
  A Playwright browser spec `tests/playwright/specs/18-rtl-layout.e2e.js` drives
  the app in both English (LTR) and Arabic (RTL) and asserts, on the boards list,
  board view, card details, my-cards / due-cards / global-search, the admin
  settings page and the login page, that the direction is correct, the
  translated text is visible, and leading-edge content (the boards menu, the
  first board list) sits on the correct side. Thanks to xet7 and Claude.
- Fixed the "Meteor unit tests" CI job hanging until its 45-minute timeout. The
  workflow set `TEST_WATCH: '0'`, but meteortesting:mocha computes
  `testWatch: TEST_WATCH || …` and the string `'0'` is truthy in JavaScript, so
  it turned on watch mode and the process never exited. Removing the env var lets
  `meteor test --once` finish and exit with the correct status.

Thanks to xet7 and Claude !

Fixes #6136,
fixes #3376,
fixes #884
2026-06-14 08:59:58 +03:00
Lauri Ojansivu 682376656f Updated translations. 2026-06-14 08:59:58 +03:00
github-actions[bot] 0d6c8d0b57 Bump versions for v9.43 2026-06-13 17:24:59 +00:00
Lauri Ojansivu 371302a160 Updated ChangeLog. 2026-06-13 20:18:53 +03:00
Lauri Ojansivu 6b2eb03a25 Updated ChangeLog. 2026-06-13 20:15:04 +03:00
Lauri Ojansivu e63c5b112d Add/Edit location popup can also detect a location from a map link from many
providers, grouped by region — USA: Google Maps, Bing Maps, Apple Maps, Waze;
  Europe: OpenStreetMap, HERE WeGo, Yandex Maps, Mapy.cz, 2GIS; Asia: Baidu Maps,
  Amap (Gaode) — plus generic `?q=`/`?ll=` links: paste the link, press "Detect",
  and the latitude, longitude and (when present) the place name/address are
  filled in automatically. Detection handles each provider's real-world URL
  shapes, including percent-encoded commas (e.g. Waze/Yandex `ll=...%2C...`) and
  alternate forms (HERE `share.here.com/l/`, Yandex `pt=`, 2GIS `m=`), and the
  coordinate order (several non-US providers use lon,lat). For the Chinese
  providers a datum conversion is applied — Baidu uses BD-09 and Amap uses
  GCJ-02, both offset from WGS-84 — so pins land in the right place on the way in
  and out. The popup also has an "Open map links at" setting (OpenStreetMap by
  default) saved to the user profile, with the same region-grouped provider list,
  controlling which map service the location "Open in map" links use.
  Thanks to xet7 and Claude.

and fix:

- Fix incomplete URL substring sanitization.

Thanks to xet7 and Claude.

Thanks to above GitHub users for their contributions and translators for their translations.
2026-06-13 20:15:04 +03:00
Lauri Ojansivu 2301dacfa7 Updated translations. 2026-06-13 20:15:04 +03:00
github-actions[bot] 967b8482d3 Bump versions for v9.42 2026-06-13 14:38:34 +00:00
Lauri Ojansivu cb43837c32 Updated ChangeLog. 2026-06-13 17:31:44 +03:00
Lauri Ojansivu 899617bd4d Greatly improved import from Trello to WeKan.
- Attachments now import. Trello attachment URLs require OAuth to download, so
  WeKan could not fetch them. The Trello import page now has two separate file
    fields: a **.json** field for a single Trello board, and a
    **.zip** field for a package produced by the
    [Trello Card Attachments Downloader](https://github.com/wekan/trello-attachments-downloader).
    The .zip may contain one *or more* board .json files together with each
    board's attachments in a board-name subfolder. The .zip is uploaded over HTTP
    and processed entirely on the server (the attachment bytes never travel over
    the realtime WebSocket, which previously overflowed the connection and made
    the page flicker): WeKan detects every board .json, matches each board to its
    attachment subfolder by name, streams each file straight into the Default
    file storage, imports all boards, then opens All Boards. The upload is guarded
    against abuse — it rejects oversized uploads, zip bombs (too many files or too
    much uncompressed data, per-file size caps) and unsafe entry paths (absolute
    paths or `..` traversal), and never overwrites existing files (stored under
    generated ids). The importer also reads attachments from `card.attachments`
    (newer Trello exports), not only from the action log, and uses the correct
    Meteor-Files insert API (the previous calls were no-ops on Meteor 3). Single
    board imports (the .json file or the pasted JSON, after the map-members step)
    now run through the same server-side HTTP path rather than the realtime
    `importBoard` method, so a heavy import can no longer drop the WebSocket and
    get retried in an endless loop (the page flickering with "Invalid frame
    header" / repeated "logged out" errors). When importing only a single board
    .json and you have saved Trello API key and token, WeKan uses them on the
    server to download that board's attachments, background image, member
    avatars and card stickers from Trello (a .json export often omits stickers;
    avatars only fill in mapped users who have none — an existing avatar is never
    overwritten); each download is best-effort and a failure never aborts the
    import. After a single-board import the new board's data is subscribed and
    loaded before opening it, so it shows its swimlanes, lists and cards
    immediately instead of needing a browser page reload (the HTTP import, unlike
    the old realtime method, does not push the written documents to the client by
    itself).
  - Import Trello-only card features: stickers (shown as Font Awesome icons on
    minicards and the card detail), card cover (color or attachment), card
    location (name, address, coordinates with an OpenStreetMap link), and the
    due-date "complete" checkbox shown on the minicard. Trello's two named
    sticker packs are renamed and highlighted (the icon keeps its normal colour)
    so they stay distinct: the "taco" pack becomes "mascot" with an underlined
    icon, and the "pete" pack becomes "computer" with a ring around the icon,
    e.g. "pete-ghost" imports as "computer ghost" with a Snapchat-ghost icon.
    Several named stickers that used to share an icon now get their own Font
    Awesome 4.7 icon (mascot active => heartbeat, pixel => qrcode, proto =>
    flask, embarrassed => meh-o, clean => bath, computer shipped => truck).
  - Live Trello API import: paste your Trello API key and token on the Trello
    import page to list all your Trello workspaces and boards, then import all
    or selected boards together with their attachments (downloaded
    server-side). Imported boards are placed under a personal workspace named
    after their Trello workspace, created by name if it does not exist, under an
    optional parent workspace you choose. The API client respects Trello's rate
    limits: it spaces out requests, honours `Retry-After` on HTTP 429, retries
    transient 5xx and network errors with capped exponential backoff, and
    reports a clear error on an invalid key/token (401). After a successful API
    import you are taken to the All Boards page where the new boards appear.
  - The live API import runs as a background job on the server: you can navigate
    away and come back to the Trello import page to watch progress, see
    per-board results, and read the full error log. Errors are shown in a
    selectable text box with a one-click "Copy to clipboard" button (and you can
    still select and copy just part of the text) so they are easy to share when
    fixing issues. If the import stops on a fatal API error (invalid token, or
    rate limit still failing after retries) you can fix the cause and Resume
    from where it left off, and a server restart leaves the job paused for
    resuming rather than lost. You can also Cancel the import, or Cancel and
    delete the boards already imported, to start the whole process over cleanly.
  - You can optionally save your Trello API key and token with the Save and
    Delete buttons on the import page. When saved, they are stored only on the
    server, never sent back to the browser, and are reused automatically when
    you list workspaces or import (so you do not have to paste them again); a
    "saved" indicator is shown, Save can overwrite them with new values, and
    Delete clears them from the database. If you do not save them, they are kept
    only in server memory for the running import and re-supplied when you resume.
  - Larger Trello JSON exports can be selected as a file instead of pasted.
  - Keep imported usernames for later mapping. Imported members that match an
    existing WeKan user (by username, or by a previously recorded imported
    username) are mapped automatically, and the imported username is remembered
    on that user so future imports map too. Members with no matching WeKan user
    are no longer lost: their usernames are kept on the board, so an admin can
    map them to real users later in Admin Panel / People (Imported Usernames),
    without having to map everyone up front at import time.
  Thanks to xet7 and Claude.
- Board background images can now be stored in WeKan. A new `backgrounds` file
  storage directory is created alongside `attachments` and `avatars` (using the
  current default storage backend), and Board menu → Board backgrounds lets a
  board admin upload background images, set one as the active board background,
  download, and delete them. Board export now includes the board's background
  images and re-imports them, and a Trello board's background image is
  downloaded and stored on import (so it keeps working even if the original
  Trello URL later changes). Thanks to xet7 and Claude.
- Stickers can be added to and removed from cards directly in WeKan, chosen from
  a set of Font Awesome icons similar to Trello's stickers (previously stickers
  only arrived via Trello import). The picker also includes every "mascot"
  (underlined) and "computer" (ringed) highlighted sticker that a Trello import
  can produce — generated from the same icon mapping the importer uses — so any
  imported sticker can also be added by hand. Stickers show on the minicard and
  in the card detail. Thanks to xet7 and Claude.
- The Trello-style "complete" checkbox (mark a card complete/incomplete,
  independent of the due date) is shown as an animated green checkbox to the
  left of the card title, both on the minicard and in the opened card, with
  "Mark as complete" / "Mark as incomplete" tooltips. It uses the same animated
  checkbox style as Admin Panel / Settings / Announcements and is vertically
  centered with the title text, and the two stay in sync. The minicard and the
  opened-card checkboxes use the same checked style (the board theme's colour, or
  green when no theme is set), so they always look identical. Subtask checkboxes
  and the card-detail custom-field checkbox now use the same animated checkbox as
  checklist items (they previously used static square icons), so every checkbox
  on a card animates consistently. Thanks to xet7 and Claude.
- Cards can now have multiple locations, similar to multiple members. The card
  detail shows a Location section (after Labels and Stickers) listing each
  location with its name, address and an OpenStreetMap link, with an "Add
  location" button to add more and a button to edit or remove each one. A single
  location imported from Trello keeps working and is shown in the same list. The
  Add/Edit location popup can also detect a location from any map link (Google
  Maps, OpenStreetMap, Bing Maps, Apple Maps, or generic `?q=`/`?ll=` links):
  paste the link, press "Detect", and the latitude, longitude and (when present)
  the place name/address are filled in automatically. The popup also has an
  "Open map links at" setting (OpenStreetMap by default, or Google/Bing/Apple
  Maps) saved to the user profile, controlling which map service the location
  "Open in map" links use. Thanks to xet7 and Claude.
- The opened card now docks to the top of the window, overlaying the global and
  board header bars, instead of opening downward from the clicked minicard, and
  it can be dragged all the way to the top without its top hiding behind those
  bars. (At 100% zoom the board wrapper no longer sets a `transform: scale(1)`,
  which had made it the containing block for the fixed card and trapped it below
  the headers.) Thanks to xet7 and Claude.

Fixes #5727,
fixes #2457,
fixes #5818,
fixes #755
2026-06-13 17:19:51 +03:00
Lauri Ojansivu 2757bfb365 Updated translations. 2026-06-13 17:18:06 +03:00
Lauri Ojansivu 1ed942bb8a Updated translations. 2026-06-13 09:09:57 +03:00
Lauri Ojansivu 179157ccd2 Updated translations. 2026-06-13 07:45:56 +03:00
Lauri Ojansivu 3e36b931a6 Updated translations. 2026-06-13 06:57:06 +03:00
Lauri Ojansivu c9e60847d0 Updated translations. 2026-06-13 06:35:09 +03:00
Lauri Ojansivu 4d83dc0c05 Updated translations. 2026-06-13 05:45:53 +03:00
Lauri Ojansivu 995965d157 Updated translations. 2026-06-13 03:01:17 +03:00
Lauri Ojansivu 15fbb314b0 Updated ChangeLog. 2026-06-12 20:21:35 +03:00
Lauri Ojansivu 68e0032c6d Fix Board Export/Import error.
Fix import/clone of inconsistent board JSON so the newest WeKan can import
board exports from any newer or older WeKan version. A syntactically valid
export can still be internally inconsistent (a board member whose user
account was deleted is no longer in the export's `users`, cards pointing at a
missing `listId`, orphaned checklists, or a missing `lists` array). Such
exports previously failed import as "error-json-malformed" or silently
dropped data (cards with an undefined list never rendered). The importer now
skips dangling user references in the member mapper (client
`wekanMembersMapper`, server `wekanmapper` used by board cloning, and
`WekanCreator`), falls back to the first imported list — creating one default
list when the export has none — for cards with a dangling `listId`, and skips
orphaned checklists whose card is missing. The importer remains
version-agnostic: it never reads `_format`, normalizes old `id` vs `_id`
fields, and ignores the source `boardId`.
Added `tests/wekanCreator.inconsistent.test.js` covering these cases.

Thanks to titver968, xet7 and Claude !

Fixes #6371
2026-06-12 20:15:56 +03:00
Lauri Ojansivu cffca535b5 Updated ChangeLog. 2026-06-12 19:49:13 +03:00
Lauri Ojansivu b2d2632e3b Fix Wrong card number after Import.
Thanks to titver968, xet7 and Claude !

Fixes #6372
2026-06-12 19:49:13 +03:00
Lauri Ojansivu 119379d4b4 Updated translations. 2026-06-12 19:49:13 +03:00
481 changed files with 63460 additions and 13680 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.1.
#
# 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"
+12 -4
View File
@@ -53,9 +53,13 @@ jobs:
- name: Run Meteor unit tests
# First Meteor build is slow; allow time before the test driver starts.
timeout-minutes: 45
# NOTE: do not set TEST_WATCH here. meteortesting:mocha computes
# `testWatch: TEST_WATCH || …`, and the string '0' is truthy in JS, so
# TEST_WATCH=0 turns ON watch mode and the process never exits — that is
# what made this job hang until the 45-minute timeout. With TEST_WATCH
# unset, `meteor test --once` runs the server tests and exits with the
# correct status (matching the local rebuild-wekan.sh run).
run: meteor test --once --driver-package meteortesting:mocha
env:
TEST_WATCH: '0'
e2e-tests:
name: Playwright E2E (${{ matrix.project }})
@@ -64,8 +68,9 @@ jobs:
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]
services:
mongodb:
@@ -151,6 +156,9 @@ jobs:
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()
+280 -37
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:
@@ -20,9 +24,14 @@ name: Release All Platforms
# - website: updates the wekan/wekan.fi repo (install page + API docs) and pushes
# - charts: updates and publishes the wekan/charts Helm chart and pushes
# - 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
@@ -224,7 +233,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: |
@@ -357,9 +366,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:
@@ -455,9 +498,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 +556,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@v3
- 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).
@@ -552,7 +658,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 +678,81 @@ 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 app package.json version (server/statistics.js reads
# require('/package.json').version). Assert the amd64 bundle's WeKan
# version equals the release version BEFORE the expensive multi-arch
# buildx, so a stale/mismatched bundle fails fast with a clear message
# instead of shipping (and pushing to three registries) a mislabeled image.
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 package.json files (not the whole multi-hundred-MB bundle).
unzip -o -q bundle.zip '*package.json' -d "$tmp"
# The WeKan app package.json is the only one whose "version" carries a
# leading 'v' (e.g. "v9.61.0"); every dependency uses plain semver. So a
# v-prefixed "version" uniquely identifies the app's own package.json.
found="$(grep -rhoE '"version": "v[0-9][^"]*"' "$tmp" \
| grep -oE 'v[0-9][^"]*' | sort -u | head -1 || true)"
rm -rf "$tmp" bundle.zip
if [ -z "$found" ]; then
echo "::error::Could not find a v-prefixed WeKan version in the bundle's package.json files."
exit 1
fi
if [ "$found" != "v${VERSION}.0" ] && [ "$found" != "v${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 $found. 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} \
@@ -613,15 +790,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 +866,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
+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
+1596
View File
File diff suppressed because it is too large. Load diff
+20 -3
View File
@@ -7,14 +7,14 @@ 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.41
ARG VERSION=9.63
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 \
NODE_VERSION=v24.17.0 \
METEOR_RELEASE=METEOR@3.5-beta.12 \
USE_EDGE=false \
NPM_VERSION=11.11.0 \
@@ -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 \
@@ -68,6 +69,8 @@ ENV \
OAUTH2_TOKEN_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 +96,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 +126,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 +215,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
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v9.41.0"
appVersion: "v9.63.0"
files:
userUploads:
- README.md
+536 -2
View File
@@ -70,12 +70,24 @@ 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
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 +126,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 +243,7 @@ ats = 'attachments'
users = wekanurl + apiusers
user = wekanurl + apiuser
allusers = wekanurl + apiallusers
settings = wekanurl + 'api/settings'
# ------- API URL GENERATION 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,320 @@ 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))
# ------- 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().
+1 -1
View File
@@ -36,7 +36,7 @@
flex: 1;
align-self: center;
margin: 0;
margin-left: 0.4vw;
margin-inline-start: 0.4vw;
overflow: hidden;
word-break: break-word;
}
+3 -3
View File
@@ -6,7 +6,7 @@
opacity: 0.7;
position: absolute;
top: 1px;
left: -38px;
inset-inline-start: -38px;
}
.new-comment.is-open .member {
opacity: 1;
@@ -84,7 +84,7 @@
flex: 1;
align-self: center;
margin: 0;
margin-left: 3px;
margin-inline-start: 3px;
overflow: hidden;
word-break: break-word;
}
@@ -113,7 +113,7 @@
font-size: clamp(14px, 2vw, 18px);
font-weight: 500;
line-height: 1;
margin-left: 4px;
margin-inline-start: 4px;
}
.comments .comment .comment-desc .reactions .reaction {
cursor: pointer;
@@ -2,7 +2,7 @@
.board-conversion-overlay {
position: fixed;
top: 0;
left: 0;
inset-inline-start: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
@@ -52,7 +52,7 @@
}
.board-conversion-header h3 i {
margin-right: 8px;
margin-inline-end: 8px;
color: #2196F3;
}
@@ -91,8 +91,8 @@
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
inset-inline-start: 0;
inset-inline-end: 0;
bottom: 0;
background: linear-gradient(
90deg,
@@ -127,7 +127,7 @@
}
.conversion-status i {
margin-right: 8px;
margin-inline-end: 8px;
color: #2196F3;
}
@@ -142,7 +142,7 @@
}
.conversion-time i {
margin-right: 6px;
margin-inline-end: 6px;
color: #FF9800;
}
@@ -160,7 +160,7 @@
}
.conversion-info i {
margin-right: 6px;
margin-inline-end: 6px;
color: #2196F3;
}
@@ -174,8 +174,8 @@
.board-conversion-header,
.board-conversion-content,
.board-conversion-footer {
padding-left: 16px;
padding-right: 16px;
padding-inline-start: 16px;
padding-inline-end: 16px;
}
.board-conversion-header h3 {
+2 -2
View File
@@ -8,10 +8,10 @@ template(name="archivedBoards")
each archivedBoards
li.archived-lists-item
div.board-header-btns
button.board-header-btn.js-delete-board
button.primary.board-header-btn.js-delete-board
i.fa.fa-trash
| {{_ 'delete-board'}}
button.board-header-btn.js-restore-board
button.primary.board-header-btn.js-restore-board
i.fa.fa-undo
| {{_ 'restore-board'}}
= title
+16 -16
View File
@@ -1,7 +1,7 @@
.board-wrapper {
position: absolute;
left: 0;
right: 0;
inset-inline-start: 0;
inset-inline-end: 0;
top: 0;
bottom: 0;
overflow-x: hidden;
@@ -30,8 +30,8 @@
}
.board-wrapper .board-canvas {
position: absolute;
left: 0;
right: 0;
inset-inline-start: 0;
inset-inline-end: 0;
top: 0;
bottom: 0;
transition: margin 0.1s;
@@ -276,12 +276,12 @@ body.mobile-mode.iphone-device .card-details .card-details-item-title {
}
.board-wrapper .board-canvas .board-overlay {
position: fixed;
left: 0;
right: 0;
inset-inline-start: 0;
inset-inline-end: 0;
top: 0;
bottom: 0;
top: -100px;
right: -400px;
inset-inline-end: -400px;
background: #000;
opacity: 0.33;
animation: fadeIn 0.2s;
@@ -325,8 +325,8 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
width: 100vw !important;
max-width: 100vw !important;
min-width: 100vw !important;
left: 0 !important;
right: 0 !important;
inset-inline-start: 0 !important;
inset-inline-end: 0 !important;
overflow-x: hidden !important;
overflow-y: auto !important;
}
@@ -335,8 +335,8 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
width: 100vw !important;
max-width: 100vw !important;
min-width: 100vw !important;
left: 0 !important;
right: 0 !important;
inset-inline-start: 0 !important;
inset-inline-end: 0 !important;
overflow-x: hidden !important;
overflow-y: auto !important;
}
@@ -360,8 +360,8 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
width: 100vw !important;
max-width: 100vw !important;
min-width: 100vw !important;
left: 0 !important;
right: 0 !important;
inset-inline-start: 0 !important;
inset-inline-end: 0 !important;
overflow-x: hidden !important;
overflow-y: auto !important;
}
@@ -370,8 +370,8 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
width: 100vw !important;
max-width: 100vw !important;
min-width: 100vw !important;
left: 0 !important;
right: 0 !important;
inset-inline-start: 0 !important;
inset-inline-end: 0 !important;
overflow-x: hidden !important;
overflow-y: auto !important;
}
@@ -560,7 +560,7 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
align-items: center;
position: absolute;
top: 5px;
right: 5px;
inset-inline-end: 5px;
font-size: 25px;
cursor: pointer;
}
+6 -3
View File
@@ -21,16 +21,17 @@ template(name="boardBody")
else
// Debug information (remove in production)
if debugBoardState
.debug-info(style="position: fixed; top: 0; left: 0; background: rgba(0,0,0,0.8); color: white; padding: 10px; z-index: 9999; font-size: 12px;")
.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
+20 -2
View File
@@ -353,8 +353,10 @@ Template.boardBody.onRendered(function () {
el.setAttribute('tabindex', '0');
el.setAttribute('role', 'button');
});
// Set the language attribute on the <html> element for accessibility
// Set the language and direction attributes on the <html> element for
// accessibility and RTL layout.
document.documentElement.lang = TAPi18n.getLanguage();
document.documentElement.dir = TAPi18n.getLanguageDirection();
// Ensure the accessible name for the board view switcher matches the visible label "Swimlanes"
// This fixes WCAG 2.5.3: Label in Name
@@ -672,6 +674,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) {
@@ -907,8 +922,10 @@ document.addEventListener('keydown', function (e) {
});
Template.calendarView.onRendered(function () {
// Set the language attribute on the <html> element for accessibility
// Set the language and direction attributes on the <html> element for
// accessibility and RTL layout.
document.documentElement.lang = TAPi18n.getLanguage();
document.documentElement.dir = TAPi18n.getLanguageDirection();
this.autorun(function () {
const calendarEl = document.getElementById('calendar-view');
@@ -971,6 +988,7 @@ Template.calendarView.helpers({
end: '18:00',
},
locale: TAPi18n.getLanguage(),
isRTL: TAPi18n.isRTL(),
events(fetchInfo, callback) {
const currentBoard = Utils.getCurrentBoard();
const events = [];
+103 -103
View File
@@ -9,7 +9,7 @@ THEME - NEPHRITIS
background-color: #27ae60;
}
.board-color-nephritis .is-selected .minicard {
border-left: 3px solid #27ae60;
border-inline-start: 3px solid #27ae60;
}
.board-color-nephritis .minicard {
border-radius: 7px;
@@ -47,7 +47,7 @@ THEME - NEPHRITIS
}
.board-color-nephritis .materialCheckBox.is-checked {
border-bottom: 2px solid #27ae60;
border-right: 2px solid #27ae60;
border-inline-end: 2px solid #27ae60;
}
.board-color-nephritis .checklist-progress-bar {
background-color: #d4f1dd !important;
@@ -95,7 +95,7 @@ THEME - NEPHRITIS
/* Remove margins in between columns/fix spacing */
.board-color-nephritis .list {
border-left: none;
border-inline-start: none;
padding-bottom: 8px;
}
@@ -116,7 +116,7 @@ THEME - Pomegranate
background-color: #c0392b;
}
.board-color-pomegranate .is-selected .minicard {
border-left: 3px solid #c0392b;
border-inline-start: 3px solid #c0392b;
}
.board-color-pomegranate .minicard {
border-radius: 7px;
@@ -154,7 +154,7 @@ THEME - Pomegranate
}
.board-color-pomegranate .materialCheckBox.is-checked {
border-bottom: 2px solid #c0392b;
border-right: 2px solid #c0392b;
border-inline-end: 2px solid #c0392b;
}
.board-color-pomegranate .checklist-progress-bar {
background-color: #f5d5d2 !important;
@@ -202,7 +202,7 @@ THEME - Pomegranate
/* Remove margins in between columns/fix spacing */
.board-color-pomegranate .list {
border-left: none;
border-inline-start: none;
padding-bottom: 8px;
}
@@ -223,7 +223,7 @@ THEME - Belize
background-color: #2980b9;
}
.board-color-belize .is-selected .minicard {
border-left: 3px solid #2980b9;
border-inline-start: 3px solid #2980b9;
}
.board-color-belize .minicard {
border-radius: 7px;
@@ -261,7 +261,7 @@ THEME - Belize
}
.board-color-belize .materialCheckBox.is-checked {
border-bottom: 2px solid #2980b9;
border-right: 2px solid #2980b9;
border-inline-end: 2px solid #2980b9;
}
.board-color-belize .checklist-progress-bar {
background-color: #d1e7f5 !important;
@@ -309,7 +309,7 @@ THEME - Belize
/* Remove margins in between columns/fix spacing */
.board-color-belize .list {
border-left: none;
border-inline-start: none;
padding-bottom: 8px;
}
@@ -330,7 +330,7 @@ THEME - Wisteria
background-color: #8e44ad;
}
.board-color-wisteria .is-selected .minicard {
border-left: 3px solid #8e44ad;
border-inline-start: 3px solid #8e44ad;
}
.board-color-wisteria .minicard {
border-radius: 7px;
@@ -368,7 +368,7 @@ THEME - Wisteria
}
.board-color-wisteria .materialCheckBox.is-checked {
border-bottom: 2px solid #8e44ad;
border-right: 2px solid #8e44ad;
border-inline-end: 2px solid #8e44ad;
}
.board-color-wisteria .checklist-progress-bar {
background-color: #e8d9f0 !important;
@@ -416,7 +416,7 @@ THEME - Wisteria
/* Remove margins in between columns/fix spacing */
.board-color-wisteria .list {
border-left: none;
border-inline-start: none;
padding-bottom: 8px;
}
@@ -437,7 +437,7 @@ THEME - Midnight
background-color: #2c3e50;
}
.board-color-midnight .is-selected .minicard {
border-left: 3px solid #2c3e50;
border-inline-start: 3px solid #2c3e50;
}
.board-color-midnight .minicard {
border-radius: 7px;
@@ -475,7 +475,7 @@ THEME - Midnight
}
.board-color-midnight .materialCheckBox.is-checked {
border-bottom: 2px solid #2c3e50;
border-right: 2px solid #2c3e50;
border-inline-end: 2px solid #2c3e50;
}
.board-color-midnight .checklist-progress-bar {
background-color: #d2dae2 !important;
@@ -523,7 +523,7 @@ THEME - Midnight
/* Remove margins in between columns/fix spacing */
.board-color-midnight .list {
border-left: none;
border-inline-start: none;
padding-bottom: 8px;
}
@@ -544,7 +544,7 @@ THEME - Pumpkin
background-color: #e67e22;
}
.board-color-pumpkin .is-selected .minicard {
border-left: 3px solid #e67e22;
border-inline-start: 3px solid #e67e22;
}
.board-color-pumpkin .minicard {
border-radius: 7px;
@@ -582,7 +582,7 @@ THEME - Pumpkin
}
.board-color-pumpkin .materialCheckBox.is-checked {
border-bottom: 2px solid #e67e22;
border-right: 2px solid #e67e22;
border-inline-end: 2px solid #e67e22;
}
.board-color-pumpkin .checklist-progress-bar {
background-color: #f9e5d1 !important;
@@ -630,7 +630,7 @@ THEME - Pumpkin
/* Remove margins in between columns/fix spacing */
.board-color-pumpkin .list {
border-left: none;
border-inline-start: none;
padding-bottom: 8px;
}
@@ -651,7 +651,7 @@ THEME - Moderate Pink
background-color: #cd5a91;
}
.board-color-moderatepink .is-selected .minicard {
border-left: 3px solid #cd5a91;
border-inline-start: 3px solid #cd5a91;
}
.board-color-moderatepink .minicard {
border-radius: 7px;
@@ -689,7 +689,7 @@ THEME - Moderate Pink
}
.board-color-moderatepink .materialCheckBox.is-checked {
border-bottom: 2px solid #cd5a91;
border-right: 2px solid #cd5a91;
border-inline-end: 2px solid #cd5a91;
}
.board-color-moderatepink .checklist-progress-bar {
background-color: #f4dde8 !important;
@@ -737,7 +737,7 @@ THEME - Moderate Pink
/* Remove margins in between columns/fix spacing */
.board-color-moderatepink .list {
border-left: none;
border-inline-start: none;
padding-bottom: 8px;
}
@@ -758,7 +758,7 @@ THEME - Strong Cyan
background-color: #00aecc;
}
.board-color-strongcyan .is-selected .minicard {
border-left: 3px solid #00aecc;
border-inline-start: 3px solid #00aecc;
}
.board-color-strongcyan .minicard {
border-radius: 7px;
@@ -796,7 +796,7 @@ THEME - Strong Cyan
}
.board-color-strongcyan .materialCheckBox.is-checked {
border-bottom: 2px solid #00aecc;
border-right: 2px solid #00aecc;
border-inline-end: 2px solid #00aecc;
}
.board-color-strongcyan .checklist-progress-bar {
background-color: #ccf2f9 !important;
@@ -844,7 +844,7 @@ THEME - Strong Cyan
/* Remove margins in between columns/fix spacing */
.board-color-strongcyan .list {
border-left: none;
border-inline-start: none;
padding-bottom: 8px;
}
@@ -865,7 +865,7 @@ THEME - Lime Green
background-color: #4bbf6b;
}
.board-color-limegreen .is-selected .minicard {
border-left: 3px solid #4bbf6b;
border-inline-start: 3px solid #4bbf6b;
}
.board-color-limegreen .minicard {
border-radius: 7px;
@@ -903,7 +903,7 @@ THEME - Lime Green
}
.board-color-limegreen .materialCheckBox.is-checked {
border-bottom: 2px solid #4bbf6b;
border-right: 2px solid #4bbf6b;
border-inline-end: 2px solid #4bbf6b;
}
.board-color-limegreen .checklist-progress-bar {
background-color: #daf4de !important;
@@ -951,7 +951,7 @@ THEME - Lime Green
/* Remove margins in between columns/fix spacing */
.board-color-limegreen .list {
border-left: none;
border-inline-start: none;
padding-bottom: 8px;
}
@@ -972,7 +972,7 @@ THEME - Dark
background-color: #2c3e51;
}
.board-color-dark .is-selected .minicard {
border-left: 3px solid #2c3e51;
border-inline-start: 3px solid #2c3e51;
}
.board-color-dark .minicard {
border-radius: 7px;
@@ -1011,7 +1011,7 @@ THEME - Dark
}
.board-color-dark .materialCheckBox.is-checked {
border-bottom: 2px solid #2c3e51;
border-right: 2px solid #2c3e51;
border-inline-end: 2px solid #2c3e51;
}
.board-color-dark .checklist-progress-bar {
background-color: #d2dae2 !important;
@@ -1161,7 +1161,7 @@ THEME - Dark
/* Remove margins in between columns/fix spacing */
.board-color-dark .list {
border-left: none; /* Remove this property to bring back lines in-between columns if needed*/
border-inline-start: none; /* Remove this property to bring back lines in-between columns if needed*/
padding: 0px 1px 8px 1px; /* Improves spacing between columns due to no borders, 8px padding at bottom to separate horizontal scrollbar/lists */
}
@@ -1182,7 +1182,7 @@ THEME - Relax
background-color: #27ae61;
}
.board-color-relax .is-selected .minicard {
border-left: 3px solid #27ae61;
border-inline-start: 3px solid #27ae61;
}
.board-color-relax .minicard {
border-radius: 7px;
@@ -1220,7 +1220,7 @@ THEME - Relax
}
.board-color-relax .materialCheckBox.is-checked {
border-bottom: 2px solid #27ae61;
border-right: 2px solid #27ae61;
border-inline-end: 2px solid #27ae61;
}
.board-color-relax .checklist-progress-bar {
background-color: #d4f1dd !important;
@@ -1263,7 +1263,7 @@ THEME - Relax
background-color: #a7e366;
}
.board-color-relax .list {
border-left: 1px dotted #000;
border-inline-start: 1px dotted #000;
}
.board-color-relax .card-details .card-details-items ~ .js-open-inlined-form .viewer {
background-color: #fff !important;
@@ -1297,7 +1297,7 @@ THEME - Relax
/* Remove margins in between columns/fix spacing */
.board-color-relax .list {
border-left: none;
border-inline-start: none;
/* padding-bottom: 8px; - Removed to get rid of grey bars for relax theme */
}
@@ -1318,7 +1318,7 @@ THEME - Corteza
background-color: #568ba2;
}
.board-color-corteza .is-selected .minicard {
border-left: 3px solid #568ba2;
border-inline-start: 3px solid #568ba2;
}
.board-color-corteza .minicard {
border-radius: 7px;
@@ -1356,7 +1356,7 @@ THEME - Corteza
}
.board-color-corteza .materialCheckBox.is-checked {
border-bottom: 2px solid #568ba2;
border-right: 2px solid #568ba2;
border-inline-end: 2px solid #568ba2;
}
.board-color-corteza .checklist-progress-bar {
background-color: #dce6ec !important;
@@ -1404,7 +1404,7 @@ THEME - Corteza
/* Remove margins in between columns/fix spacing */
.board-color-corteza .list {
border-left: none;
border-inline-start: none;
padding-bottom: 8px;
}
@@ -1424,7 +1424,7 @@ THEME - Clear Blue
background: linear-gradient(180deg, #499bea 0%, #00aecc 100%);
}
.board-color-clearblue .is-selected .minicard {
border-left: 3px solid #499bea;
border-inline-start: 3px solid #499bea;
}
.board-color-clearblue.pop-over .pop-over-list li a:not(.disabled):hover,
.board-color-clearblue .sidebar .sidebar-content .sidebar-btn:hover,
@@ -1467,7 +1467,7 @@ THEME - Clear Blue
}
.board-color-clearblue .materialCheckBox.is-checked {
border-bottom: 2px solid #499bea;
border-right: 2px solid #499bea;
border-inline-end: 2px solid #499bea;
}
.board-color-clearblue .checklist-progress-bar {
background-color: #daeefb !important;
@@ -1495,8 +1495,8 @@ THEME - Clear Blue
}
.board-color-clearblue .swimlane .list:first-child {
min-width: 20px;
margin-left: 10px; /* Added 10px margin left to stop lists being butted up against the edge of the screen */
border-left: none;
margin-inline-start: 10px; /* Added 10px margin left to stop lists being butted up against the edge of the screen */
border-inline-start: none;
}
.board-color-clearblue .swimlane .list:nth-child {
flex: 0 0 265px;
@@ -1513,8 +1513,8 @@ THEME - Clear Blue
flex: unset;
padding-bottom: 16px;
min-width: 20px;
margin-left: 0px;
border-left: none;
margin-inline-start: 0px;
border-inline-start: none;
}
.board-color-clearblue .list.list-composer .open-list-composer {
border-radius: 7px;
@@ -1550,7 +1550,7 @@ THEME - Clear Blue
.board-color-clearblue .card-details {
border-radius: 0 0 14px 14px;
box-shadow: 0 0 7px 0 rgba(0,0,0,0.5);
margin-left: -10px;
margin-inline-start: -10px;
}
.board-color-clearblue .list-body .open-minicard-composer {
border-radius: 7px;
@@ -1584,7 +1584,7 @@ THEME - Clear Blue
color: rgba(0,0,0,0.5);
position: absolute;
margin-top: -34px;
margin-left: -11px;
margin-inline-start: -11px;
}
.board-color-clearblue a.fa,
.board-color-clearblue a i.fa {
@@ -1605,7 +1605,7 @@ THEME - Clear Blue
}
.board-color-clearblue .sidebar-shadow {
box-shadow: none;
border-left: 9px solid #00aecc;
border-inline-start: 9px solid #00aecc;
}
.board-color-clearblue .is-open .sidebar-shadow {
box-shadow: -10px 0 8px rgba(0,0,0,0.3);
@@ -1646,7 +1646,7 @@ THEME - Clear Blue
vertical-align: middle;
}
.board-color-clearblue .swimlane-header-wrap .primary.confirm {
margin-right: 0;
margin-inline-end: 0;
}
.board-color-clearblue .swimlane-header-wrap .fa.fa-times-thin {
margin-top: 2px;
@@ -1677,7 +1677,7 @@ THEME - Clear Blue
/* Remove margins in between columns/fix spacing */
.board-color-clearblue .list {
border-left: none;
border-inline-start: none;
padding-bottom: 8px;
}
@@ -1698,7 +1698,7 @@ THEME - Natural
background-color: #596557;
}
.board-color-natural .is-selected .minicard {
border-left: 3px solid #596557;
border-inline-start: 3px solid #596557;
}
.board-color-natural .minicard {
border-radius: 7px;
@@ -1736,7 +1736,7 @@ THEME - Natural
}
.board-color-natural .materialCheckBox.is-checked {
border-bottom: 2px solid #596557;
border-right: 2px solid #596557;
border-inline-end: 2px solid #596557;
}
.board-color-natural .checklist-progress-bar {
background-color: #dee0dd !important;
@@ -1793,7 +1793,7 @@ THEME - Natural
/* Remove margins in between columns/fix spacing */
.board-color-natural .list {
border-left: none;
border-inline-start: none;
padding-bottom: 8px;
}
@@ -1814,7 +1814,7 @@ THEME - Modern
background-color: #2a80b8;
}
.board-color-modern .is-selected .minicard {
border-left: 3px solid #2a80b8;
border-inline-start: 3px solid #2a80b8;
}
.board-color-modern .minicard {
border-radius: 7px;
@@ -1852,7 +1852,7 @@ THEME - Modern
}
.board-color-modern .materialCheckBox.is-checked {
border-bottom: 2px solid #2a80b8;
border-right: 2px solid #2a80b8;
border-inline-end: 2px solid #2a80b8;
}
.board-color-modern .checklist-progress-bar {
background-color: #d1e7f5 !important;
@@ -1904,7 +1904,7 @@ THEME - Modern
display: none;
}
.board-color-modern#header-quick-access ul li:nth-child(3) {
margin-right: 10px;
margin-inline-end: 10px;
}
.board-color-modern#header-quick-access ul li a {
padding: 5px 10px;
@@ -1952,7 +1952,7 @@ THEME - Modern
}
.board-color-modern .list {
background: transparent;
border-left: 0;
border-inline-start: 0;
margin: 10px 0;
padding: 0px;
border-radius: 5px;
@@ -1964,8 +1964,8 @@ THEME - Modern
}
.board-color-modern .swimlane .list:first-child {
min-width: 20px;
margin-left: 0px;
border-left: none;
margin-inline-start: 0px;
border-inline-start: none;
}
.board-color-modern .swimlane .list:nth-child {
flex: 0 0 265px;
@@ -2033,7 +2033,7 @@ THEME - Modern
.board-color-modern .pop-over .header .close-btn {
font-size: 20px;
top: 6px;
right: 8px;
inset-inline-end: 8px;
}
.board-color-modern .pop-over .content-container .content {
padding: 5px 20px 20px;
@@ -2043,7 +2043,7 @@ THEME - Modern
border-radius: 5px;
}
.board-color-modern .pop-over-list li > a > i {
margin-right: 5px;
margin-inline-end: 5px;
}
.board-color-modern .pop-over-list li>a .sub-name {
margin-bottom: 8px;
@@ -2086,7 +2086,7 @@ THEME - Modern
/* Remove margins in between columns/fix spacing */
.board-color-modern .list {
border-left: none;
border-inline-start: none;
padding-bottom: 8px;
}
@@ -2107,7 +2107,7 @@ THEME - Modern Dark
background-color: #2a2a2a;
}
.board-color-moderndark .is-selected .minicard {
border-left: 3px solid #2a2a2a;
border-inline-start: 3px solid #2a2a2a;
}
.board-color-moderndark .minicard {
border-radius: 7px;
@@ -2150,7 +2150,7 @@ THEME - Modern Dark
}
.board-color-moderndark .materialCheckBox.is-checked {
border-bottom: 2px solid #2a2a2a;
border-right: 2px solid #2a2a2a;
border-inline-end: 2px solid #2a2a2a;
}
.board-color-moderndark .checklist-progress-bar {
background-color: #d1d1d1 !important;
@@ -2250,7 +2250,7 @@ THEME - Modern Dark
display: none;
}
.board-color-moderndark#header-quick-access ul li:nth-child(3) {
margin-right: 10px;
margin-inline-end: 10px;
}
.board-color-moderndark#header-quick-access ul li a {
padding: 5px 10px;
@@ -2293,8 +2293,8 @@ THEME - Modern Dark
.board-color-moderndark .swimlane .list:first-child {
color: #eee;
min-width: 20px;
margin-left: 0px;
border-left: none;
margin-inline-start: 0px;
border-inline-start: none;
}
.board-color-moderndark .swimlane .list-composer .list-header-add .inlined-form .edit-controls .quiet,
.board-color-moderndark .swimlane .list-composer .list-header-add .inlined-form .edit-controls .quiet a.js-list-template {
@@ -2315,7 +2315,7 @@ THEME - Modern Dark
background: #6a6a6a;
}
.board-color-moderndark .list-header .viewer {
padding-left: 10px;
padding-inline-start: 10px;
}
.board-color-moderndark .list-header .list-header-name,
.board-color-moderndark .minicard {
@@ -2333,7 +2333,7 @@ THEME - Modern Dark
}
@media screen and (min-width: 801px) {
.board-color-moderndark .list-header .list-header-name {
float: left;
float: inline-start;
}
.board-color-moderndark .list-header .list-header-menu {
padding: 0 10px 10px;
@@ -2445,7 +2445,7 @@ THEME - Modern Dark
padding: 0px 20px;
}
.board-color-moderndark .card-details .card-details-header .card-details-menu-mobile-web {
margin-right: 0;
margin-inline-end: 0;
}
.board-color-moderndark .pop-over > .content-wrapper > .popup-container-depth-0 > .content {
width: calc(100% - 20px);
@@ -2455,7 +2455,7 @@ THEME - Modern Dark
.board-color-moderndark .card-details {
position: fixed;
top: 82px;
left: calc(50% - 384px);
inset-inline-start: calc(50% - 384px);
width: 768px;
max-height: calc(100% - 60px);
}
@@ -2513,9 +2513,9 @@ THEME - Modern Dark
}
.board-color-moderndark .card-details .check-box.materialCheckBox.is-checked {
border-bottom: 2px solid #fff;
border-right: 2px solid #fff;
border-inline-end: 2px solid #fff;
border-top: 0;
border-left: 0;
border-inline-start: 0;
}
.board-color-moderndark .card-details .js-add-checklist-item {
margin-top: 4px;
@@ -2530,7 +2530,7 @@ THEME - Modern Dark
.board-color-moderndark .sidebar {
background-color: #222;
box-shadow: -10px 0 5px -10px #444;
border-left: 1px solid #333;
border-inline-start: 1px solid #333;
color: #ccc;
}
.board-color-moderndark .activities .activity .activity-desc .activity-comment {
@@ -2581,7 +2581,7 @@ THEME - Modern Dark
/* Remove margins in between columns/fix spacing */
.board-color-moderndark .list {
border-left: none;
border-inline-start: none;
padding-bottom: 8px;
}
@@ -2603,7 +2603,7 @@ THEME - Exodark
background-color: #222;
}
.board-color-exodark .is-selected .minicard {
border-left: 3px solid #222;
border-inline-start: 3px solid #222;
}
.board-color-exodark .minicard {
border-radius: 7px;
@@ -2641,7 +2641,7 @@ THEME - Exodark
}
.board-color-exodark .materialCheckBox.is-checked {
border-bottom: 2px solid #dbdbdb!important;/*Fix contrast of checkbox*/
border-right: 2px solid #dbdbdb!important;
border-inline-end: 2px solid #dbdbdb!important;
}
.board-color-exodark .checklist-progress-bar {
background-color: #cccccc !important;
@@ -2698,8 +2698,8 @@ THEME - Exodark
}
.board-color-exodark .swimlane .list:first-child {
min-width: 20px;
margin-left: 10px; /*Added 10px margin to prevent butting up against edge of screen */
border-left: none;
margin-inline-start: 10px; /*Added 10px margin to prevent butting up against edge of screen */
border-inline-start: none;
}
.board-color-exodark .swimlane .list:nth-child {
flex: 0 0 265px;
@@ -2960,10 +2960,10 @@ THEME - Clean Dark
background: #009B64;
width: min-content;
color: #FFFFFF;
padding-left: 8px;
padding-right: 8px;
padding-inline-start: 8px;
padding-inline-end: 8px;
border-radius: 8px;
margin-left: 4px;
margin-inline-start: 4px;
}
.board-color-cleandark .card-details hr {
@@ -3203,7 +3203,7 @@ THEME - Clean Dark
.board-color-cleandark .card-details .check-box.materialCheckBox.is-checked {
border-bottom: 2px solid #FFFFFF;
border-right: 2px solid #FFFFFF;
border-inline-end: 2px solid #FFFFFF;
width: 11px;
height: 19px;
border-radius: 0;
@@ -3234,12 +3234,12 @@ THEME - Clean Dark
.board-color-cleandark #cards .materialCheckBox.is-checked {
border-bottom: 2px solid #FFFFFF;
border-right: 2px solid #FFFFFF;
border-inline-end: 2px solid #FFFFFF;
width: 5px;
height: 13px;
border-radius: 0;
background: none;
margin-left: 3px;
margin-inline-start: 3px;
margin-top: 3px;
}
.board-color-cleandark .checklist-progress-bar {
@@ -3258,7 +3258,7 @@ THEME - Clean Dark
align-items: center;
padding-bottom: 4px;
padding-top: 4px;
margin-right: 10px;
margin-inline-end: 10px;
}
.board-color-cleandark#header-quick-access ul.header-quick-access-list {
@@ -3280,7 +3280,7 @@ THEME - Clean Dark
/* Remove margins in between columns/fix spacing */
.board-color-cleandark .list {
border-left: none;
border-inline-start: none;
padding-bottom: 8px;
}
@@ -3371,7 +3371,7 @@ THEME - Clean Light
.board-color-cleanlight .list,
.board-color-cleandark .list {
background: none !important;
border-left: none !important;
border-inline-start: none !important;
}
.board-color-cleanlight .list .list-header,
@@ -3445,11 +3445,11 @@ THEME - Clean Light
border-radius: 12px;
margin-top: 18px;
padding: 8px;
margin-right: 8px;
margin-inline-end: 8px;
display: flex;
align-items: center;
justify-content: center;
margin-left: 10px;
margin-inline-start: 10px;
}
.board-color-cleanlight .list-header-add:hover {
@@ -3479,7 +3479,7 @@ THEME - Clean Light
.board-color-cleandark .card-label {
border-radius: 18px;
margin-top: 6px;
margin-right: 8px;
margin-inline-end: 8px;
border: none;
padding: 4px 12px;
}
@@ -3529,14 +3529,14 @@ THEME - Clean Light
.board-color-cleanlight .swimlane .swimlane-header-wrap .swimlane-header-plus-icon,
.board-color-cleandark .swimlane .swimlane-header-wrap .swimlane-header-plus-icon {
margin-left: 14px;
margin-inline-start: 14px;
}
.board-color-cleanlight .swimlane .swimlane-header-wrap .list-composer,
.board-color-cleandark .swimlane .swimlane-header-wrap .list-composer {
display: flex;
gap: 12px;
margin-left: 20px;
margin-inline-start: 20px;
}
.board-color-cleanlight .swimlane .swimlane-header-wrap .swimlane-header .viewer p,
@@ -3670,10 +3670,10 @@ THEME - Clean Light
background: #009B64;
width: min-content;
color: #FFFFFF;
padding-left: 8px;
padding-right: 8px;
padding-inline-start: 8px;
padding-inline-end: 8px;
border-radius: 8px;
margin-left: 4px;
margin-inline-start: 4px;
}
.board-color-cleanlight .card-details hr {
@@ -3962,7 +3962,7 @@ THEME - Clean Light
.board-color-cleanlight .card-details .check-box.materialCheckBox.is-checked {
border-bottom: 2px solid #000000;
border-right: 2px solid #000000;
border-inline-end: 2px solid #000000;
width: 11px;
height: 19px;
border-radius: 0;
@@ -3993,12 +3993,12 @@ THEME - Clean Light
.board-color-cleanlight #cards .materialCheckBox.is-checked {
border-bottom: 2px solid #000000;
border-right: 2px solid #000000;
border-inline-end: 2px solid #000000;
width: 5px;
height: 13px;
border-radius: 0;
background: none;
margin-left: 3px;
margin-inline-start: 3px;
margin-top: 3px;
}
.board-color-cleanlight .checklist-progress-bar {
@@ -4050,7 +4050,7 @@ THEME - Clean Light
align-items: center;
padding-bottom: 4px;
padding-top: 4px;
margin-right: 10px;
margin-inline-end: 10px;
}
.board-color-cleanlight#header-quick-access ul.header-quick-access-list {
@@ -4238,12 +4238,12 @@ THEME - Clean Light
.board-color-cleanlight .card-details-item-customfield:has(.checklist-item) div,
.board-color-cleandark .card-details-item-customfield:has(.checklist-item) div {
padding-right: 0 !important;
padding-inline-end: 0 !important;
}
.board-color-cleanlight .card-details .card-details-items .card-details-item.custom-fields,
.board-color-cleanlight .card-details .card-details-items .card-details-item.custom-fields {
margin-left: auto;
margin-inline-start: auto;
flex-grow: 0;
border-radius: 12px;
}
@@ -4290,11 +4290,11 @@ THEME - Clean Light
.board-color-cleanlight .pop-over-list li > a i,
.board-color-cleandark .pop-over-list li > a i {
margin-right: 6px !important;
margin-inline-end: 6px !important;
}
.board-color-cleanlight .pop-over .quiet {
margin-left: 100px !important;
margin-inline-start: 100px !important;
}
.board-color-cleandark .minicard:hover:not(.minicard-composer),
@@ -4316,7 +4316,7 @@ THEME - Clean Light
/* Remove margins in between columns/fix spacing */
.board-color-cleanlight .list {
border-left: none;
border-inline-start: none;
padding-bottom: 8px;
}
+26 -26
View File
@@ -208,7 +208,7 @@
display: block !important;
float: none !important;
margin-bottom: 2rem !important;
border-left: none !important;
border-inline-start: none !important;
border-bottom: 2px solid #ccc !important;
clear: both !important;
}
@@ -249,7 +249,7 @@
.mobile-mode .list-header .list-header-menu-icon {
position: static !important;
right: auto !important;
inset-inline-end: auto !important;
top: auto !important;
transform: none !important;
grid-row: 1/3 !important;
@@ -264,7 +264,7 @@
.mobile-mode .list-header .list-header-handle {
position: static !important;
right: auto !important;
inset-inline-end: auto !important;
top: auto !important;
transform: none !important;
grid-row: 1/3 !important;
@@ -325,9 +325,9 @@
width: auto !important;
min-width: auto !important;
display: flex !important;
float: left !important;
float: inline-start !important;
margin-bottom: 0 !important;
border-left: 1px solid #ccc !important;
border-inline-start: 1px solid #ccc !important;
border-bottom: none !important;
}
@@ -356,7 +356,7 @@
.desktop-mode .list-header .list-header-menu-icon {
position: absolute !important;
right: 60px !important;
inset-inline-end: 60px !important;
top: 50% !important;
transform: translateY(-50%) !important;
grid-row: auto !important;
@@ -367,7 +367,7 @@
.desktop-mode .list-header .list-header-handle {
position: absolute !important;
right: 10px !important;
inset-inline-end: 10px !important;
top: 50% !important;
transform: translateY(-50%) !important;
grid-row: auto !important;
@@ -463,7 +463,7 @@
visibility: visible !important;
opacity: 1 !important;
position: static !important;
right: auto !important;
inset-inline-end: auto !important;
top: auto !important;
transform: none !important;
}
@@ -557,8 +557,8 @@
padding: 0 !important;
float: none !important;
clear: both !important;
border-left: none !important;
border-right: none !important;
border-inline-start: none !important;
border-inline-end: none !important;
border-top: none !important;
border-bottom: 2px solid #ccc !important;
flex: none !important;
@@ -566,20 +566,20 @@
flex-grow: 0 !important;
flex-shrink: 0 !important;
position: static !important;
left: auto !important;
right: auto !important;
inset-inline-start: auto !important;
inset-inline-end: auto !important;
top: auto !important;
bottom: auto !important;
transform: none !important;
}
.mobile-mode .list:first-child {
margin-left: 0 !important;
margin-inline-start: 0 !important;
margin-top: 0 !important;
}
.mobile-mode .list:last-child {
margin-right: 0 !important;
margin-inline-end: 0 !important;
margin-bottom: 0 !important;
}
@@ -594,8 +594,8 @@
padding: 0 !important;
float: none !important;
clear: both !important;
border-left: none !important;
border-right: none !important;
border-inline-start: none !important;
border-inline-end: none !important;
border-top: none !important;
border-bottom: 2px solid #ccc !important;
flex: none !important;
@@ -612,8 +612,8 @@
padding: 0 !important;
float: none !important;
clear: both !important;
border-left: none !important;
border-right: none !important;
border-inline-start: none !important;
border-inline-end: none !important;
border-top: none !important;
border-bottom: 2px solid #ccc !important;
flex: none !important;
@@ -643,8 +643,8 @@
padding: 0 !important;
float: none !important;
clear: both !important;
border-left: none !important;
border-right: none !important;
border-inline-start: none !important;
border-inline-end: none !important;
border-top: none !important;
border-bottom: 2px solid #ccc !important;
flex: none !important;
@@ -652,8 +652,8 @@
flex-grow: 0 !important;
flex-shrink: 0 !important;
position: static !important;
left: auto !important;
right: auto !important;
inset-inline-start: auto !important;
inset-inline-end: auto !important;
top: auto !important;
bottom: auto !important;
transform: none !important;
@@ -707,8 +707,8 @@
padding: 0 !important;
float: none !important;
clear: both !important;
border-left: none !important;
border-right: none !important;
border-inline-start: none !important;
border-inline-end: none !important;
border-top: none !important;
border-bottom: 2px solid #ccc !important;
flex: none !important;
@@ -716,8 +716,8 @@
flex-grow: 0 !important;
flex-shrink: 0 !important;
position: static !important;
left: auto !important;
right: auto !important;
inset-inline-start: auto !important;
inset-inline-end: auto !important;
top: auto !important;
bottom: auto !important;
transform: none !important;
+19 -12
View File
@@ -51,6 +51,8 @@ template(name="boardHeaderBar")
span {{_ 'log-in'}}
.board-header-btns.center
if currentUser
+boardButtons
.board-header-btns.right
if currentBoard
@@ -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'}}
+23 -14
View File
@@ -181,6 +181,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 +220,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 +231,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 +259,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 +273,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 +326,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 +358,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 +381,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 +388,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({
+58 -35
View File
@@ -16,8 +16,8 @@
}
.boards-left-menu {
border-right: 1px solid #e0e0e0;
padding-right: 12px;
border-inline-end: 1px solid #e0e0e0;
padding-inline-end: 12px;
}
.boards-left-menu ul.menu {
@@ -46,7 +46,7 @@
border-radius: 12px;
font-size: 12px;
font-weight: bold;
margin-left: 8px;
margin-inline-start: 8px;
}
.boards-left-menu .menu-item.active a,
.boards-left-menu .menu-item a:hover {
@@ -87,12 +87,12 @@
border: none;
padding: 2px 8px;
border-radius: 4px;
margin-right: 10px;
margin-inline-end: 10px;
}
.workspace-tree {
list-style: none;
padding-left: 10px;
padding-inline-start: 10px;
}
.workspace-node {
@@ -345,7 +345,7 @@
border-radius: 4px;
cursor: pointer;
font-size: 16px;
margin-left: 10px; /* Extra space between MultiSelection toggle and Remove Filter */
margin-inline-start: 10px; /* Extra space between MultiSelection toggle and Remove Filter */
}
.boards-path-header .board-header-btn-close:hover {
@@ -429,7 +429,7 @@
margin: 0 8px;
}
.board-list li {
float: left;
float: inline-start;
width: 20%;
box-sizing: border-box;
position: relative;
@@ -533,7 +533,7 @@
opacity: 0;
padding: 9px 9px;
position: absolute;
right: 0;
inset-inline-end: 0;
transition-duration: 0.15s;
transition-property: color, font-size, background;
}
@@ -544,7 +544,7 @@
line-height: 10px;
padding: 9px 9px;
position: absolute;
right: 0;
inset-inline-end: 0;
transition-duration: 0.15s;
transition-property: color, font-size, background;
}
@@ -564,7 +564,7 @@
height: 18px;
line-height: 18px;
opacity: 0;
right: 0;
inset-inline-end: 0;
padding: 9px 9px;
transition-duration: 0.15s;
transition-property: color, font-size, background;
@@ -576,7 +576,7 @@
height: 18px;
line-height: 18px;
opacity: 0;
left: 0;
inset-inline-start: 0;
padding: 9px 9px;
transition-duration: 0.15s;
transition-property: color, font-size, background;
@@ -642,7 +642,7 @@
.board-list .board-list-item .multi-selection-checkbox {
position: absolute !important;
bottom: 4px !important;
left: 4px !important;
inset-inline-start: 4px !important;
top: auto !important;
width: 24px;
height: 24px;
@@ -673,7 +673,7 @@
width: 24px !important;
height: 24px !important;
top: auto !important;
left: 4px !important;
inset-inline-start: 4px !important;
transform: none !important;
border-radius: 4px !important;
}
@@ -711,7 +711,7 @@ body.grey-icons-enabled .board-list.is-multiselection-active .js-board.is-checke
.board-backgrounds-list .board-background-select {
box-sizing: border-box;
display: block;
float: left;
float: inline-start;
width: 50%;
padding-top: 12px;
position: relative;
@@ -721,10 +721,10 @@ body.grey-icons-enabled .board-list.is-multiselection-active .js-board.is-checke
padding-top: 0;
}
.board-backgrounds-list .board-background-select:nth-child(2n) {
padding-left: 6px;
padding-inline-start: 6px;
}
.board-backgrounds-list .board-background-select:nth-child(2n+1) {
padding-right: 6px;
padding-inline-end: 6px;
}
.board-backgrounds-list .board-background-select .background-box {
color: #fff;
@@ -769,32 +769,32 @@ body.grey-icons-enabled .checkmark-no-grey {
float: none;
display: block;
margin-bottom: 1rem;
padding-right: 50px; /* Space for drag handle */
padding-inline-end: 50px; /* Space for drag handle */
}
.board-list.mobile-view .board-list-item {
overflow: visible;
height: 8rem;
width: 100%;
margin: 0;
padding-right: 50px; /* Ensure content doesn't overlap with drag handle */
padding-inline-end: 50px; /* Ensure content doesn't overlap with drag handle */
}
.board-list.mobile-view .board-list-item .details {
padding-right: 50px; /* Extra space for drag handle */
padding-inline-end: 50px; /* Extra space for drag handle */
width: 100%;
box-sizing: border-box;
}
.board-list.mobile-view .board-list-item-sub-name {
position: relative;
top: -100px;
left: -100px;
inset-inline-start: -100px;
}
.board-list.mobile-view .board-handle {
position: absolute;
padding: 7px;
top: 50%;
transform: translateY(-50%);
right: 10px;
inset-inline-end: 10px;
font-size: 24px;
color: #fff;
background: rgba(0,0,0,0.3);
@@ -878,32 +878,32 @@ body.grey-icons-enabled .checkmark-no-grey {
float: none;
display: block;
margin-bottom: 1rem;
padding-right: 50px; /* Space for drag handle */
padding-inline-end: 50px; /* Space for drag handle */
}
.board-list .board-list-item {
overflow: visible;
height: 8rem;
width: 100%;
margin: 0;
padding-right: 50px; /* Ensure content doesn't overlap with drag handle */
padding-inline-end: 50px; /* Ensure content doesn't overlap with drag handle */
}
.board-list .board-list-item .details {
padding-right: 50px; /* Extra space for drag handle */
padding-inline-end: 50px; /* Extra space for drag handle */
width: 100%;
box-sizing: border-box;
}
.board-list .board-list-item-sub-name {
position: relative;
top: -100px;
left: -100px;
inset-inline-start: -100px;
}
.board-list .board-handle {
position: absolute;
padding: 7px;
top: 50%;
transform: translateY(-50%);
right: 10px;
inset-inline-end: 10px;
font-size: 24px;
color: #fff;
background: rgba(0,0,0,0.3);
@@ -933,7 +933,7 @@ body.grey-icons-enabled .checkmark-no-grey {
padding: 7px;
top: 50%;
transform: translateY(-50%);
right: 10px;
inset-inline-end: 10px;
font-size: 24px;
color: #fff;
background: rgba(0,0,0,0.3);
@@ -971,9 +971,9 @@ body.grey-icons-enabled .checkmark-no-grey {
}
.boards-left-menu {
border-right: none !important;
border-inline-end: none !important;
border-bottom: 1px solid #e0e0e0;
padding-right: 0 !important;
padding-inline-end: 0 !important;
padding-bottom: 12px;
}
@@ -1157,7 +1157,7 @@ body.grey-icons-enabled .checkmark-no-grey {
padding: 10px !important;
top: 50%;
transform: translateY(-50%);
right: 15px !important;
inset-inline-end: 15px !important;
font-size: 28px !important; /* Much larger drag handle */
color: #fff;
background: rgba(0,0,0,0.4) !important;
@@ -1221,13 +1221,13 @@ body.grey-icons-enabled .checkmark-no-grey {
.AllBoardTeams,
.AllBoardOrgs,
.AllBoardBtns {
float: left;
float: inline-start;
}
.js-AllBoardOrgs {
margin-left: 16px;
margin-inline-start: 16px;
}
.AllBoardTeams {
margin-left: 16px;
margin-inline-start: 16px;
}
.AllBoardButtonsContainer {
margin: 16px;
@@ -1296,7 +1296,7 @@ body.grey-icons-enabled .checkmark-no-grey {
padding: 6px 0 6px 8px;
width: 100%;
margin-bottom: 2px;
margin-left: -4px;
margin-inline-start: -4px;
display: inline-block;
}
.minicard-lists {
@@ -1312,7 +1312,7 @@ body.grey-icons-enabled .checkmark-no-grey {
}
.flex-wrap .item {
margin: 2px;
padding-right: 6px;
padding-inline-end: 6px;
text-align: center;
}
@@ -1403,3 +1403,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;
}
+12 -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
@@ -154,7 +163,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}}")
+157 -9
View File
@@ -2,8 +2,8 @@ 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 { enablePageDragscroll, disablePageDragscroll } from '/client/lib/pageDragscroll';
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
import { BoardMultiSelection } from '/client/lib/boardMultiSelection';
import { EscapeActions } from '/client/lib/escapeActions';
@@ -11,6 +11,50 @@ 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 = '📁';
function getCurrentWorkspacesTree() {
@@ -115,9 +159,18 @@ 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([]);
// #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 +249,37 @@ 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);
}
});
// 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();
// Enable drag-to-scroll on the All Boards page the same way the board
// canvas/swimlanes do. On desktop the page scroll container is <body>, so we
// tag it via enablePageDragscroll(); the .board-list.dragscroll element only
// becomes a scroll container in mobile view, where its own class handles it.
// Finger drag-scrolling is handled by dragscrollTouch.js.
enablePageDragscroll();
// jQuery sortable is disabled in favor of HTML5 drag-and-drop for space management
// The old sortable code has been removed to prevent conflicts
@@ -250,6 +325,13 @@ Template.boardList.onRendered(function () {
*/
});
Template.boardList.onDestroyed(function () {
// Stop tagging <body> as drag-scrollable when leaving the All Boards page so
// other layouts (e.g. a board view, whose canvas does its own dragscroll) are
// not affected.
disablePageDragscroll();
});
Template.boardList.helpers({
userHasTeams() {
if (ReactiveCache.getCurrentUser()?.teams?.length > 0) return true;
@@ -360,7 +442,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 +463,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 });
}
@@ -476,6 +569,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;
},
@@ -643,6 +754,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;
@@ -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();
},
});
@@ -35,7 +35,7 @@
}
.original-positions-loading i {
margin-right: 8px;
margin-inline-end: 8px;
}
.original-positions-filters {
@@ -117,7 +117,7 @@
}
.original-position-item-details {
margin-left: 24px;
margin-inline-start: 24px;
}
.original-position-description {
@@ -191,7 +191,7 @@
}
.original-position-item-details {
margin-left: 0;
margin-inline-start: 0;
margin-top: 8px;
}
}
+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'),
});
+16 -16
View File
@@ -23,7 +23,7 @@
width: 150px;
min-width: 150px;
max-height: 150px;
padding-right: 16px;
padding-inline-end: 16px;
}
.attachment-thumbnail {
max-width: 150px;
@@ -48,7 +48,7 @@
.attachment-details {
display: flex;
justify-content: space-between;
margin-right: 25px; /* Make sure the icons are not to far to the right */
margin-inline-end: 25px; /* Make sure the icons are not to far to the right */
}
.attachment-actions {
display: flex;
@@ -56,10 +56,10 @@
align-items: center;
}
.attachment-actions a {
margin-left: 16px;
margin-inline-start: 16px;
}
.attachment-actions a:first-child {
margin-left: 0;
margin-inline-start: 0;
}
.add-attachment {
display: flex;
@@ -74,7 +74,7 @@
.icon {
font-size: 1.5em;
cursor: pointer;
margin-left: 10px;
margin-inline-start: 10px;
}
.icon:hover {
color: #666;
@@ -84,7 +84,7 @@
height: 100vh;
position: fixed;
top: 0;
left: 0;
inset-inline-start: 0;
z-index: 9999 !important;
background: rgba(13, 13, 13, 0.95);
}
@@ -113,7 +113,7 @@
cursor: pointer;
font-size: 4em;
position: absolute;
right: 50px;
inset-inline-end: 50px;
top: 16px;
}
@@ -137,7 +137,7 @@
}
.upload-progress-header i {
margin-right: 8px;
margin-inline-end: 8px;
color: #007bff;
}
@@ -201,7 +201,7 @@
.upload-progress-error i,
.upload-progress-success i {
margin-right: 4px;
margin-inline-end: 4px;
}
/* Minicard specific styles */
@@ -263,14 +263,14 @@
color: white;
cursor: pointer;
align-self: center;
margin-left: 70px;
margin-inline-start: 70px;
}
#next-attachment {
font-size: 4em;
color: white;
cursor: pointer;
align-self: center;
margin-right: 70px;
margin-inline-end: 70px;
}
#viewer-content {
display: flex;
@@ -325,20 +325,20 @@
padding: 16px;
}
#prev-attachment {
left: 0;
inset-inline-start: 0;
position: absolute;
bottom: 2.2em;
font-size: 1.6em;
padding: 16px;
margin-left: 0;
margin-inline-start: 0;
}
#next-attachment {
right: 0;
inset-inline-end: 0;
position: absolute;
bottom: 2.2em;
font-size: 1.6em;
padding: 16px;
margin-right: 0;
margin-inline-end: 0;
}
#pdf-viewer {
width: 100%;
@@ -365,7 +365,7 @@
}
.attachment-details {
flex-direction: column;
margin-right: 0px;
margin-inline-end: 0px;
}
.attachment-actions {
flex-direction: row;
@@ -55,11 +55,10 @@ template(name="cardCustomField-number")
template(name="cardCustomField-checkbox")
.js-checklist-item.checklist-item(class="{{#if data.value }}is-checked{{/if}}")
if canModifyCard
span.check-box-unicode
i.fa(class="{{#if data.value}}fa-check-square{{else}}fa-square-o{{/if}}")
.check-box-container
.check-box.materialCheckBox(class="{{#if data.value}}is-checked{{/if}}")
else
span.check-box-unicode
i.fa(class="{{#if data.value}}fa-check-square{{else}}fa-square-o{{/if}}")
.materialCheckBox(class="{{#if data.value}}is-checked{{/if}}")
template(name="cardCustomField-currency")
if canModifyCard
@@ -106,9 +106,6 @@ Template['cardCustomField-checkbox'].onCreated(function () {
});
Template['cardCustomField-checkbox'].events({
'click .js-checklist-item .check-box-unicode'(event, tpl) {
tpl.card.setCustomField(tpl.customFieldId, !Template.currentData().value);
},
'click .js-checklist-item .check-box-container'(event, tpl) {
tpl.card.setCustomField(tpl.customFieldId, !Template.currentData().value);
},
+1 -1
View File
@@ -134,7 +134,7 @@
}
.card-date time::before {
font-size: inherit;
margin-right: 0.3em;
margin-inline-end: 0.3em;
display: inline-block;
}
.customfield-date {
+261 -70
View File
@@ -31,7 +31,7 @@
.assignee {
display: block;
position: relative;
float: left;
float: inline-start;
height: clamp(24px, 3.5vw, 36px);
width: clamp(24px, 3.5vw, 36px);
margin: 0.3vh;
@@ -73,7 +73,7 @@
height: 7px;
width: 7px;
position: absolute;
right: -1px;
inset-inline-end: -1px;
bottom: -1px;
z-index: 15;
}
@@ -142,36 +142,36 @@ body.desktop-mode .card-details:not(.card-details-popup) {
/* Default position for first card or when dragged */
body.desktop-mode .card-details:not(.card-details-popup):not([style*="left"]):not([style*="top"]) {
top: 8px;
left: 20px;
right: 20px;
inset-inline-start: 20px;
inset-inline-end: 20px;
bottom: 20px;
}
/* Stagger positions for multiple cards using nth-of-type */
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(1) {
top: 8px;
left: 20px;
inset-inline-start: 20px;
}
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(2) {
top: 38px;
left: 50px;
inset-inline-start: 50px;
}
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(3) {
top: 68px;
left: 80px;
inset-inline-start: 80px;
}
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(4) {
top: 98px;
left: 110px;
inset-inline-start: 110px;
}
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(5) {
top: 128px;
left: 140px;
inset-inline-start: 140px;
}
/* For expanded cards, set dimensions */
body.desktop-mode .card-details:not(.card-details-popup):not(.card-details-collapsed) {
right: 20px;
inset-inline-end: 20px;
bottom: 20px;
}
@@ -188,7 +188,7 @@ body.desktop-mode .card-details.card-details-collapsed {
bottom: auto !important;
}
.card-details .mCustomScrollBox {
padding-left: 0;
padding-inline-start: 0;
}
.card-details .card-details-canvas {
width: auto;
@@ -208,16 +208,16 @@ body.desktop-mode .card-details.card-details-collapsed {
.card-details .card-details-header .card-number {
color: #b3b3b3;
display: inline-block;
margin-right: 6px;
margin-inline-end: 6px;
}
/* Collapse toggle triangle */
.card-details .card-details-header .card-collapse-toggle {
float: left;
float: inline-start;
font-size: 20px;
padding: 7px 10px;
margin-left: -10px;
margin-right: 5px;
margin-inline-start: -10px;
margin-inline-end: 5px;
cursor: pointer;
user-select: none;
color: #000;
@@ -228,11 +228,11 @@ body.desktop-mode .card-details.card-details-collapsed {
.card-details .card-details-header .card-drag-handle {
font-size: 20px;
padding: 8px 10px;
margin-right: 10px;
margin-inline-end: 10px;
cursor: move;
user-select: none;
display: inline-block;
float: right;
float: inline-end;
vertical-align: middle;
line-height: 1.2;
}
@@ -246,14 +246,14 @@ body.desktop-mode .card-details.card-details-collapsed {
.card-details .card-details-header .close-card-details-mobile-web,
.card-details .card-details-header .card-details-menu-mobile-web,
.card-details .card-details-header .copied-tooltip {
float: right;
float: inline-end;
}
.card-details .card-details-header .close-card-details,
.card-details .card-details-header .maximize-card-details,
.card-details .card-details-header .minimize-card-details {
font-size: 24px;
padding: 5px 10px 5px 10px;
margin-right: -8px;
margin-inline-end: -8px;
cursor: pointer;
user-select: none;
vertical-align: middle;
@@ -264,19 +264,19 @@ body.desktop-mode .card-details.card-details-collapsed {
.card-details .card-details-header .card-mobile-desktop-toggle {
font-size: 24px;
padding: 5px;
margin-right: 5px;
margin-inline-end: 5px;
cursor: pointer;
user-select: none;
}
.card-details .card-details-header .card-copy-button {
font-size: 17px;
padding: 10px;
margin-right: 10px;
margin-inline-end: 10px;
}
.card-details .card-details-header .card-copy-mobile-button {
font-size: 17px;
padding: 10px;
margin-right: 10px;
margin-inline-end: 10px;
}
.card-details .card-details-header .card-details-menu {
font-size: 17px;
@@ -287,17 +287,17 @@ body.desktop-mode .card-details.card-details-collapsed {
.card-details .card-details-header .card-details-menu-mobile-web {
font-size: 17px;
padding: 10px;
margin-right: 30px;
margin-inline-end: 30px;
}
.card-details .card-details-header .card-mobile-desktop-toggle,
.card-details .card-details-header .card-zoom-in,
.card-details .card-details-header .card-zoom-out {
font-size: 24px;
padding: 5px 10px 5px 10px;
margin-right: 5px;
margin-inline-end: 5px;
cursor: pointer;
user-select: none;
float: right;
float: inline-end;
}
/* Unify all card text to match title size */
@@ -321,7 +321,7 @@ body.desktop-mode .card-details.card-details-collapsed {
}
.card-details .card-details-header .card-details-watch {
font-size: 17px;
padding-left: 7px;
padding-inline-start: 7px;
color: #a6a6a6;
}
.card-details .card-details-header .card-details-title {
@@ -363,11 +363,11 @@ body.desktop-mode .card-details.card-details-collapsed {
padding: 0 5px;
}
.card-details .card-details-header .copied-tooltip {
margin-right: 10px;
margin-inline-end: 10px;
padding: 10px;
}
.card-details .card-description i.fa.fa-pencil-square-o {
float: right;
float: inline-end;
}
.card-details .card-description textarea {
min-height: 100px;
@@ -380,11 +380,11 @@ body.desktop-mode .card-details.card-details-collapsed {
gap: 0.5em;
}
.card-details .card-details-items .card-details-item {
margin-right: 0.5em;
margin-inline-end: 0.5em;
flex-grow: 1;
}
.card-details .card-details-items .card-details-item:last-child {
margin-right: 0;
margin-inline-end: 0;
}
.card-details .card-details-items .card-details-item.card-details-item-labels {
display: block;
@@ -409,7 +409,7 @@ body.desktop-mode .card-details.card-details-collapsed {
max-width: 28%;
}
.card-details .card-details-items .card-details-item.custom-fields {
padding-left: 10px;
padding-inline-start: 10px;
}
.card-details .card-details-item-title {
font-size: 16px;
@@ -422,7 +422,7 @@ body.desktop-mode .card-details.card-details-collapsed {
@media screen and (min-width: 801px) {
.card-details {
top: 97px;
left: calc(50% - (600px / 2));
inset-inline-start: calc(50% - (600px / 2));
width: 600px;
bottom: 0;
position: fixed;
@@ -433,8 +433,8 @@ body.desktop-mode .card-details.card-details-collapsed {
body.mobile-mode .card-details {
width: 100vw !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
inset-inline-start: 0 !important;
inset-inline-end: 0 !important;
bottom: 0 !important;
height: 100vh !important;
max-height: 100vh !important;
@@ -456,23 +456,23 @@ body.desktop-mode .card-details.card-details-collapsed {
transition: flex-basis 0.1s;
box-sizing: border-box;
top: 97px;
left: 0px;
inset-inline-start: 0px;
height: calc(100% - 100px);
width: calc(100% - 20px);
float: left;
float: inline-start;
}
.card-details-maximized .card-details-left {
float: left;
float: inline-start;
top: 60px;
left: 20px;
inset-inline-start: 20px;
width: 47%;
border-right: solid 2px #dbdbdb;
padding-right: 10px;
border-inline-end: solid 2px #dbdbdb;
padding-inline-end: 10px;
}
.card-details-maximized .card-details-right {
position: absolute;
float: right;
left: 50%;
float: inline-end;
inset-inline-start: 50%;
margin: 15px 0;
}
.card-details-maximized .card-details-header {
@@ -480,12 +480,12 @@ body.desktop-mode .card-details.card-details-collapsed {
}
}
input[type="text"].attachment-add-link-input {
float: left;
float: inline-start;
margin: 0 0 8px;
width: 80%;
}
input[type="submit"].attachment-add-link-submit {
float: left;
float: inline-start;
margin: 0 0 8px 4px;
padding: 6px 12px;
width: 18%;
@@ -501,8 +501,8 @@ input[type="submit"].attachment-add-link-submit {
-webkit-overflow-scrolling: touch;
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
inset-inline-start: 0 !important;
inset-inline-end: 0 !important;
bottom: 0 !important;
z-index: 100 !important;
height: 100vh !important;
@@ -515,38 +515,38 @@ input[type="submit"].attachment-add-link-submit {
body.mobile-mode .card-details {
z-index: 100 !important;
width: 100vw !important;
left: 0 !important;
right: 0 !important;
inset-inline-start: 0 !important;
inset-inline-end: 0 !important;
}
.card-details .card-details-canvas {
width: 100%;
padding-left: 0px;
padding-inline-start: 0px;
padding: 0 15px;
}
.card-details .card-details-header .close-card-details {
margin-right: 0px;
margin-inline-end: 0px;
display: block !important;
}
.card-details .card-details-header .close-card-details-mobile-web {
display: block !important;
margin-right: 5px !important;
margin-inline-end: 5px !important;
}
.card-details .card-details-header .card-mobile-desktop-toggle {
display: block !important;
margin-right: 5px !important;
margin-inline-end: 5px !important;
}
.card-details .card-details-header .card-mobile-desktop-toggle {
display: block !important;
margin-right: 5px !important;
margin-inline-end: 5px !important;
}
.card-details .card-details-header .card-details-menu {
margin-right: 40px;
margin-inline-end: 40px;
}
.card-details .card-details-header .maximize-card-details {
margin-right: 40px;
margin-inline-end: 40px;
}
.card-details .card-details-header .minimize-card-details {
margin-right: 40px;
margin-inline-end: 40px;
}
.card-details-popup {
padding: 0px 10px;
@@ -565,7 +565,7 @@ input[type="submit"].attachment-add-link-submit {
}
/* iPhone mobile: enlarge header buttons and increase spacing */
body.mobile-mode.iphone-device .card-details .card-details-header {
padding-right: 16px;
padding-inline-end: 16px;
}
body.mobile-mode.iphone-device .card-details .card-details-header .close-card-details,
body.mobile-mode.iphone-device .card-details .card-details-header .maximize-card-details,
@@ -577,12 +577,12 @@ input[type="submit"].attachment-add-link-submit {
body.mobile-mode.iphone-device .card-details .card-details-header .card-zoom-out {
font-size: 2em !important; /* 2x bigger */
padding: 0.3em !important;
margin-right: 0.75em !important; /* 2x space compared to default */
margin-left: 0 !important;
margin-inline-end: 0.75em !important; /* 2x space compared to default */
margin-inline-start: 0 !important;
}
/* Avoid clipping of the close button on the right edge */
body.mobile-mode.iphone-device .card-details .card-details-header .close-card-details {
margin-right: 0.75em !important;
margin-inline-end: 0.75em !important;
}
/* Enlarge the header title too */
body.mobile-mode.iphone-device .card-details .card-details-header .card-details-title {
@@ -598,8 +598,8 @@ body.mobile-mode .card-details {
margin: 0px !important;
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
inset-inline-start: 0 !important;
inset-inline-end: 0 !important;
bottom: 0 !important;
z-index: 100 !important;
height: 100vh !important;
@@ -731,7 +731,7 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
}
.vote-title .js-edit-date {
align-self: flex-start;
margin-left: 6px;
margin-inline-start: 6px;
}
.vote-result {
display: flex;
@@ -750,7 +750,7 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
}
.poker-title .js-edit-date {
align-self: flex-start;
margin-left: 6px;
margin-inline-start: 6px;
}
.poker-result {
display: flex;
@@ -827,11 +827,11 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
display: inline-block;
}
.poker-table-side-right {
padding-left: 10px;
padding-inline-start: 10px;
}
@media (max-width: 400px) {
.poker-table-side-right {
padding-left: 0px;
padding-inline-start: 0px;
}
}
.estimation-add {
@@ -842,16 +842,16 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
}
.estimation-add input {
display: inline-block;
float: right;
float: inline-end;
margin: auto;
margin-right: 10px;
margin-inline-end: 10px;
width: 100px;
border-radius: 2px;
padding: 3px 6px;
}
.estimation-add button {
display: inline-block;
float: right;
float: inline-end;
margin: auto;
border-radius: 2px;
padding: 3px 10px;
@@ -859,7 +859,7 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
.poker-card {
width: 48px;
height: 72px;
float: left;
float: inline-start;
background: #fff;
border-radius: 5px;
display: table;
@@ -890,3 +890,194 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
transform: scale(1.01);
transition: all 0.2s ease;
}
/* Sticker picker + card-detail stickers + due-complete toggle */
.card-stickers-picker {
max-width: 320px;
}
.card-sticker-select {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
margin: 2px;
border-radius: 3px;
font-size: 18px;
color: #4d4d4d;
cursor: pointer;
position: relative;
}
.card-sticker-select:hover {
background: #e0e0e0;
}
.card-sticker-select.active {
background: #d6ecd2;
color: #3cb500;
}
.card-sticker-select .card-sticker-check {
position: absolute;
bottom: 1px;
inset-inline-end: 2px;
font-size: 10px;
color: #3cb500;
}
.card-stickers .card-sticker {
display: inline-flex;
align-items: center;
margin: 0 8px 4px 0;
font-size: 18px;
}
.card-sticker .card-sticker-remove {
margin-inline-start: 3px;
font-size: 11px;
color: #999;
cursor: pointer;
}
.card-sticker .card-sticker-remove:hover {
color: #eb4646;
}
.card-due-complete-toggle {
display: inline-flex;
align-items: center;
margin-inline-start: 8px;
vertical-align: middle;
}
/* Trello-style "mark complete" toggle, left of the card title */
.card-details-complete-toggle {
float: inline-start;
display: flex;
align-items: center;
/* Title is 1.35em with 1.3 line-height (~28px box) and margin-top 7px; push
the 13px checkbox down so it lands at the vertical middle of the title. */
margin: 14px 10px 0 0;
cursor: pointer;
}
/* Card locations: multiple locations, each editable with a remove button */
.card-details-item-location .card-locations {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 6px;
}
.card-details-item-location .card-location {
display: flex;
align-items: flex-start;
justify-content: space-between;
background: #fff;
border: 1px solid #e3e3e3;
border-radius: 4px;
padding: 6px 8px;
}
.card-details-item-location .card-location-info {
flex: 1;
}
.card-details-item-location .js-edit-location {
cursor: pointer;
}
.card-details-item-location .card-location-name {
font-weight: bold;
}
.card-details-item-location .card-location-address {
color: #4d4d4d;
}
.card-details-item-location .card-location-remove {
margin-inline-start: 8px;
color: #8c8c8c;
cursor: pointer;
}
.card-details-item-location .card-location-remove:hover {
color: #eb4646;
}
/* Sticker icon highlight styles (used instead of colour): taco pack => underline,
pete pack => round (ring around the icon). Icon keeps its normal colour. */
.card-sticker i.sticker-underline {
border-bottom: 2px solid currentColor;
padding-bottom: 1px;
line-height: 1;
}
.card-sticker i.sticker-round {
border: 1px solid currentColor;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.6em;
height: 1.6em;
line-height: 1;
box-sizing: border-box;
}
/* "Open map links at" provider selector in the location popup */
.js-card-location-form .map-provider-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.js-card-location-form .map-provider-row .js-map-provider {
flex: 0 0 auto;
width: auto;
}
/* Opened card (card details popup) docks to the very top of the window and
overlays the global + board header bars instead of opening below them. The
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;
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 {
border-bottom: 2px solid currentColor;
padding-bottom: 1px;
line-height: 1;
}
.card-sticker-select i.sticker-round {
border: 1px solid currentColor;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.6em;
height: 1.6em;
line-height: 1;
box-sizing: border-box;
}
+164 -12
View File
@@ -61,6 +61,10 @@ template(name="cardDetails")
span.emoji-icon
i.fa.fa-link
span.copied-tooltip {{_ 'copied'}}
if canModifyCard
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
@@ -126,6 +130,49 @@ template(name="cardDetails")
a.card-label.add-label.js-add-labels(title="{{_ 'card-labels-title'}}")
i.fa.fa-plus
.card-details-item.card-details-item-stickers
h3.card-details-item-title
i.fa.fa-sticky-note-o
| {{_ 'stickers'}}
.card-details-item-content.card-stickers
each stickers
span.card-sticker(title="{{#if name}}{{name}}{{else}}{{icon}}{{/if}}")
i.fa(class="fa-{{icon}} {{#if highlight}}sticker-{{highlight}}{{/if}}")
if canModifyCard
a.card-sticker-remove.js-remove-sticker(data-index="{{@index}}" title="{{_ 'remove-sticker'}}")
i.fa.fa-times
if canModifyCard
unless currentUser.isWorker
a.card-label.add-label.js-add-stickers(title="{{_ 'add-sticker'}}")
i.fa.fa-plus
.card-details-item.card-details-item-location
h3.card-details-item-title
i.fa.fa-map-marker
| {{_ 'location'}}
.card-details-item-content.card-locations
each getLocations
.card-location
.card-location-info(class="{{#if canModifyCard}}js-edit-location{{/if}}" data-location-id="{{_id}}")
if name
.card-location-name= name
if address
.card-location-address= address
if hasCoordinates
a.card-location-map(href=mapUrl target="_blank" rel="noopener noreferrer")
i.fa.fa-map-o
= ' '
| {{_ 'location-open-map'}}
if canModifyCard
a.card-location-remove.js-remove-location(data-location-id="{{_id}}" title="{{_ 'delete'}}")
i.fa.fa-times
if canModifyCard
unless currentUser.isWorker
a.card-label.add-label.js-add-location(title="{{_ 'add-location'}}")
i.fa.fa-plus
+cardButtons(_id=_id boardId=boardId)
if currentBoard.hasAnyAllowsDate
hr
@@ -275,6 +322,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
@@ -868,17 +945,17 @@ 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}}") {{add @index 1}}. {{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}}") {{add @index 1}}. {{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}}") {{add @index 1}}. {{title}}
label {{_ 'cards'}}:
select.js-select-cards
@@ -903,17 +980,17 @@ 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}}") {{add @index 1}}. {{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}}") {{add @index 1}}. {{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}}") {{add @index 1}}. {{title}}
label {{_ 'cards'}}:
select.js-select-cards
@@ -938,17 +1015,17 @@ 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}}") {{add @index 1}}. {{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}}") {{add @index 1}}. {{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}}") {{add @index 1}}. {{title}}
label {{_ 'cards'}}:
select.js-select-cards
@@ -973,17 +1050,17 @@ 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}}") {{add @index 1}}. {{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}}") {{add @index 1}}. {{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}}") {{add @index 1}}. {{title}}
label {{_ 'cards'}}:
select.js-select-cards
@@ -1196,3 +1273,78 @@ template(name="cardStartPlanningPokerPopup")
if getPokerQuestion
if currentUser.isBoardAdmin
button.js-remove-poker.negate.wide.right {{_ 'delete'}}
template(name="cardLocationsPopup")
form.js-card-location-form
label
| {{_ 'location-detect-from-map'}}
input.js-location-map-link(type="text" placeholder="https://..." autofocus)
button.primary.js-detect-location(type="button") {{_ 'location-detect'}}
if detectMessage
p.quiet.js-detect-message {{detectMessage}}
hr
label
| {{_ 'location-name'}}
input.js-location-name(type="text" value="{{location.name}}")
label
| {{_ 'location-address'}}
input.js-location-address(type="text" value="{{location.address}}")
label
| {{_ 'location-latitude'}}
input.js-location-latitude(type="number" step="any" value="{{location.latitude}}")
label
| {{_ 'location-longitude'}}
input.js-location-longitude(type="number" step="any" value="{{location.longitude}}")
button.primary.confirm.js-submit-location(type="submit") {{_ 'save'}}
if location._id
button.negate.wide.right.js-delete-location(type="button") {{_ 'delete'}}
hr
.map-provider-row
span.map-provider-label {{_ 'location-open-map-at'}}
select.js-map-provider
optgroup(label="{{_ 'map-region-usa'}}")
option(value="google" selected="{{#if isMapProvider 'google'}}selected{{/if}}") Google Maps
option(value="bing" selected="{{#if isMapProvider 'bing'}}selected{{/if}}") Bing Maps
option(value="apple" selected="{{#if isMapProvider 'apple'}}selected{{/if}}") Apple Maps
option(value="waze" selected="{{#if isMapProvider 'waze'}}selected{{/if}}") Waze
optgroup(label="{{_ 'map-region-europe'}}")
option(value="openstreetmap" selected="{{#if isMapProvider 'openstreetmap'}}selected{{/if}}") OpenStreetMap
option(value="here" selected="{{#if isMapProvider 'here'}}selected{{/if}}") HERE WeGo
option(value="yandex" selected="{{#if isMapProvider 'yandex'}}selected{{/if}}") Yandex Maps
option(value="mapy" selected="{{#if isMapProvider 'mapy'}}selected{{/if}}") Mapy.cz
option(value="2gis" selected="{{#if isMapProvider '2gis'}}selected{{/if}}") 2GIS
optgroup(label="{{_ 'map-region-asia'}}")
option(value="baidu" selected="{{#if isMapProvider 'baidu'}}selected{{/if}}") Baidu Maps
option(value="amap" selected="{{#if isMapProvider 'amap'}}selected{{/if}}") Amap (Gaode)
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}}")
+596 -9
View File
@@ -1,6 +1,19 @@
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';
// 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,
@@ -53,6 +66,231 @@ import { MultiSelection } from '/client/lib/multiSelection';
import { Utils } from '/client/lib/utils';
import autosize from 'autosize';
// Id of the location currently being edited in the cardLocationsPopup; null
// when adding a new location.
const editingLocationId = new ReactiveVar(null);
// Chinese datum conversions ("eviltransform" algorithm). Baidu Maps uses BD-09
// and Amap (Gaode) uses GCJ-02; both are offset from the WGS-84 coordinates
// Wekan stores, so a raw lat/lon would land a few hundred metres off. Convert on
// the way out (mapLinkFor) and back (parseMapLink). Coordinates outside mainland
// China use no offset, so every non-Chinese provider is unaffected.
const GCJ = (() => {
const PI = Math.PI;
const A = 6378245.0; // Krasovsky 1940 semi-major axis
const EE = 0.00669342162296594323; // eccentricity squared
const XPI = (PI * 3000.0) / 180.0;
const outOfChina = (lat, lon) =>
lon < 72.004 || lon > 137.8347 || lat < 0.8293 || lat > 55.8271;
const tLat = (x, y) => {
let r = -100 + 2 * x + 3 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
r += ((20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2) / 3;
r += ((20 * Math.sin(y * PI) + 40 * Math.sin((y / 3) * PI)) * 2) / 3;
r += ((160 * Math.sin((y / 12) * PI) + 320 * Math.sin((y * PI) / 30)) * 2) / 3;
return r;
};
const tLon = (x, y) => {
let r = 300 + x + 2 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
r += ((20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2) / 3;
r += ((20 * Math.sin(x * PI) + 40 * Math.sin((x / 3) * PI)) * 2) / 3;
r += ((150 * Math.sin((x / 12) * PI) + 300 * Math.sin((x / 30) * PI)) * 2) / 3;
return r;
};
const wgs2gcj = (lat, lon) => {
if (outOfChina(lat, lon)) return [lat, lon];
let dLat = tLat(lon - 105, lat - 35);
let dLon = tLon(lon - 105, lat - 35);
const rad = (lat / 180) * PI;
let magic = Math.sin(rad);
magic = 1 - EE * magic * magic;
const sq = Math.sqrt(magic);
dLat = (dLat * 180) / (((A * (1 - EE)) / (magic * sq)) * PI);
dLon = (dLon * 180) / ((A / sq) * Math.cos(rad) * PI);
return [lat + dLat, lon + dLon];
};
// Approximate inverse by fixed-point iteration (converges to ~1e-9 deg).
const gcj2wgs = (lat, lon) => {
if (outOfChina(lat, lon)) return [lat, lon];
let wLat = lat;
let wLon = lon;
for (let i = 0; i < 10; i++) {
const [gLat, gLon] = wgs2gcj(wLat, wLon);
const dLat = gLat - lat;
const dLon = gLon - lon;
if (Math.abs(dLat) < 1e-9 && Math.abs(dLon) < 1e-9) break;
wLat -= dLat;
wLon -= dLon;
}
return [wLat, wLon];
};
const gcj2bd = (lat, lon) => {
const z = Math.sqrt(lon * lon + lat * lat) + 0.00002 * Math.sin(lat * XPI);
const theta = Math.atan2(lat, lon) + 0.000003 * Math.cos(lon * XPI);
return [z * Math.sin(theta) + 0.006, z * Math.cos(theta) + 0.0065];
};
const bd2gcj = (lat, lon) => {
const x = lon - 0.0065;
const y = lat - 0.006;
const z = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * XPI);
const theta = Math.atan2(y, x) - 0.000003 * Math.cos(x * XPI);
return [z * Math.sin(theta), z * Math.cos(theta)];
};
return {
wgs2gcj,
gcj2wgs,
wgs2bd: (lat, lon) => {
if (outOfChina(lat, lon)) return [lat, lon];
const [gLat, gLon] = wgs2gcj(lat, lon);
return gcj2bd(gLat, gLon);
},
bd2wgs: (lat, lon) => {
if (outOfChina(lat, lon)) return [lat, lon];
const [gLat, gLon] = bd2gcj(lat, lon);
return gcj2wgs(gLat, gLon);
},
};
})();
// Parse coordinates (and, when present, a place name / address) out of a map
// link from common providers, grouped by region:
// USA: Google Maps, Bing Maps, Apple Maps, Waze
// Europe: OpenStreetMap, HERE WeGo, Yandex Maps, Mapy.cz, 2GIS
// Asia: Baidu Maps, Amap (Gaode)
// plus generic `?q=lat,lon` / `?ll=lat,lon` links. Returns whatever it can find
// as { latitude, longitude, name, address }.
function parseMapLink(url) {
const result = {};
if (!url) return result;
const s = String(url).trim();
let lat;
let lon;
const setCoords = (la, lo) => {
if (lat !== undefined) return;
const a = parseFloat(la);
const o = parseFloat(lo);
if (!isNaN(a) && !isNaN(o) && Math.abs(a) <= 90 && Math.abs(o) <= 180) {
lat = a;
lon = o;
}
};
const N = '(-?\\d+(?:\\.\\d+)?)';
// Coordinates are matched against a normalized copy where percent-encoded
// commas/slashes are decoded, since copied URLs often arrive as `%2C`/`%2F`
// (e.g. Waze `ll=45.69%2C-120.81`, Yandex `ll=37.6%2C55.7`).
const sd = s.replace(/%2c/gi, ',').replace(/%2f/gi, '/');
let m;
// Provider-specific forms that use lon,lat order must be handled before the
// generic lat,lon patterns below, since setCoords keeps only the first match.
// Yandex Maps: ?ll=<lon>,<lat> or ?pt=<lon>,<lat>[,style]
if (/yandex\./i.test(sd) && (m = sd.match(new RegExp(`[?&](?:ll|pt)=${N},${N}`)))) setCoords(m[2], m[1]);
// Mapy.cz: ?x=<lon>&y=<lat>
if (/mapy\./i.test(sd) && (m = sd.match(new RegExp(`[?&]y=${N}`)))) {
const mx = sd.match(new RegExp(`[?&]x=${N}`));
if (mx) setCoords(m[1], mx[1]);
}
// 2GIS: /geo/<lon>,<lat> or ?m=<lon>,<lat>[/zoom]
if (/2gis\./i.test(sd) && (m = sd.match(new RegExp(`(?:/geo/|[?&]m=)${N},${N}`)))) setCoords(m[2], m[1]);
// Amap (Gaode): ?position=<lon>,<lat> in GCJ-02 datum -> WGS-84.
if (/amap\./i.test(sd) && (m = sd.match(new RegExp(`[?&]position=${N},${N}`)))) {
const [wLat, wLon] = GCJ.gcj2wgs(parseFloat(m[2]), parseFloat(m[1]));
setCoords(wLat, wLon);
}
// Baidu Maps: ?location=<lat>,<lon> in BD-09 datum -> WGS-84.
if (/baidu\./i.test(sd) && (m = sd.match(new RegExp(`[?&]location=${N},${N}`)))) {
const [wLat, wLon] = GCJ.bd2wgs(parseFloat(m[1]), parseFloat(m[2]));
setCoords(wLat, wLon);
}
// HERE WeGo: ?map=<lat>,<lon>,<zoom> or share path /l/<lat>,<lon>
if (/here\./i.test(sd) && (m = sd.match(new RegExp(`(?:[?&]map=|/l/)${N},${N}`)))) setCoords(m[1], m[2]);
// Google Maps place marker in the data part: !3d<lat>!4d<lon> (most precise).
if ((m = sd.match(new RegExp(`!3d${N}!4d${N}`)))) setCoords(m[1], m[2]);
// Google Maps view/place: @<lat>,<lon>
if ((m = sd.match(new RegExp(`@${N},${N}`)))) setCoords(m[1], m[2]);
// OpenStreetMap marker: ?mlat=..&mlon=..
if ((m = sd.match(new RegExp(`[?&#]mlat=${N}`)))) {
const mlon = sd.match(new RegExp(`[?&#]mlon=${N}`));
if (mlon) setCoords(m[1], mlon[1]);
}
// OpenStreetMap map hash: #map=z/lat/lon
if ((m = sd.match(new RegExp(`[#&]map=\\d+(?:\\.\\d+)?/${N}/${N}`)))) setCoords(m[1], m[2]);
// Bing Maps: cp=lat~lon
if ((m = sd.match(new RegExp(`[?&]cp=${N}~${N}`)))) setCoords(m[1], m[2]);
// Apple Maps / Waze and generic ll=lat,lon
if ((m = sd.match(new RegExp(`[?&]ll=${N},${N}`)))) setCoords(m[1], m[2]);
// Generic q=lat,lon (Google/Apple query form)
if ((m = sd.match(new RegExp(`[?&]q=${N},${N}`)))) setCoords(m[1], m[2]);
// Generic center=lat,lon
if ((m = sd.match(new RegExp(`[?&]center=${N},${N}`)))) setCoords(m[1], m[2]);
if (lat !== undefined) {
result.latitude = lat;
result.longitude = lon;
}
const decode = (raw) => {
try {
return decodeURIComponent(raw.replace(/\+/g, ' ')).trim();
} catch (e) {
return raw.replace(/\+/g, ' ').trim();
}
};
const isCoordText = (t) => /^-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?$/.test(t);
// Place name from Google Maps /place/<Name>/
if ((m = s.match(/\/place\/([^/@?]+)/))) {
const name = decode(m[1]);
if (name && !isCoordText(name)) result.name = name;
}
// Address from a textual q=/query=/destination= parameter.
if ((m = s.match(/[?&](?:q|query|destination)=([^&]+)/))) {
const q = decode(m[1]);
if (q && !isCoordText(q)) result.address = q;
}
return result;
}
// Build an "open in map" link for the given provider and coordinates. Mirrors
// the providers offered in the location popup's "Open map links at" selector,
// grouped by region (USA, Europe, Asia). Note that several non-US providers
// expect the coordinates in lon,lat order rather than lat,lon.
function mapLinkFor(provider, lat, lon) {
switch (provider) {
// --- USA ---
case 'google':
return `https://www.google.com/maps/search/?api=1&query=${lat},${lon}`;
case 'bing':
return `https://www.bing.com/maps?cp=${lat}~${lon}&lvl=16`;
case 'apple':
return `https://maps.apple.com/?ll=${lat},${lon}`;
case 'waze':
return `https://waze.com/ul?ll=${lat},${lon}`;
// --- Europe ---
case 'here':
return `https://wego.here.com/?map=${lat},${lon},16`;
case 'yandex':
return `https://yandex.com/maps/?ll=${lon},${lat}&z=16`;
case 'mapy':
return `https://mapy.cz/zakladni?x=${lon}&y=${lat}&z=16`;
case '2gis':
return `https://2gis.ru/geo/${lon},${lat}`;
// --- Asia (coordinates converted from WGS-84 into the local datum) ---
case 'baidu': {
const [bLat, bLon] = GCJ.wgs2bd(parseFloat(lat), parseFloat(lon));
return `https://api.map.baidu.com/marker?location=${bLat},${bLon}&output=html`;
}
case 'amap': {
const [gLat, gLon] = GCJ.wgs2gcj(parseFloat(lat), parseFloat(lon));
return `https://uri.amap.com/marker?position=${gLon},${gLat}`;
}
case 'openstreetmap':
default:
return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=16/${lat}/${lon}`;
}
}
// SubsManager removed for Meteor 3 migration
const { calculateIndexData } = Utils;
@@ -134,6 +372,11 @@ Template.cardDetails.onCreated(function () {
});
Template.cardDetails.onRendered(function () {
// A reactive re-render (e.g. a card moving between the inline swimlane render
// and the draggable openCards popup) can create and then remove this instance
// within the same flush. If our DOM range is already gone, calling this.$()
// below throws "Can't select in removed DomRange", so bail out early.
if (this.view && this.view.isDestroyed) return;
this.calculateNextPeak();
if (Meteor.settings.public.CARD_OPENED_WEBHOOK_ENABLED) {
// Send Webhook but not create Activities records ---
@@ -273,6 +516,51 @@ Template.cardDetails.helpers({
return card.findWatcher(Meteor.userId());
},
// Returns the card's locations (multiple supported), each enriched with the
// coordinate flag and OpenStreetMap link used by the template.
getLocations() {
const card = Template.currentData();
if (!card || !card.getLocations) return [];
const user = ReactiveCache.getCurrentUser();
const provider = user ? user.getMapProvider() : 'openstreetmap';
return card.getLocations().map(loc => {
const hasCoordinates =
typeof loc.latitude === 'number' && typeof loc.longitude === 'number';
const mapUrl = hasCoordinates
? mapLinkFor(provider, loc.latitude, loc.longitude)
: '';
return { ...loc, hasCoordinates, mapUrl };
});
},
// #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();
},
@@ -463,6 +751,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();
@@ -605,14 +904,96 @@ 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'),
'click .js-add-assignees': Popup.open('cardAssignees'),
'click .js-add-labels': Popup.open('cardLabels'),
'click .js-add-stickers': Popup.open('cardStickers'),
'click .js-remove-sticker'(event) {
event.preventDefault();
event.stopPropagation();
if (!Utils.canModifyCard()) return;
const index = parseInt(event.currentTarget.dataset.index, 10);
const card = Template.currentData();
if (card && !Number.isNaN(index)) {
card.removeStickerAt(index);
}
},
'click .js-add-location'(event) {
event.preventDefault();
if (!Utils.canModifyCard()) return;
const card = Template.currentData();
editingLocationId.set(null);
Popup.open('cardLocations')(event, {
dataContextIfCurrentDataIsUndefined: card,
});
},
'click .js-edit-location'(event) {
// Let the "Open in map" link work without also opening the edit popup.
if ($(event.target).closest('a.card-location-map').length) return;
event.preventDefault();
if (!Utils.canModifyCard()) return;
const card = Template.currentData();
editingLocationId.set(event.currentTarget.dataset.locationId || null);
Popup.open('cardLocations')(event, {
dataContextIfCurrentDataIsUndefined: card,
});
},
'click .js-remove-location'(event) {
event.preventDefault();
event.stopPropagation();
if (!Utils.canModifyCard()) return;
const locationId = event.currentTarget.dataset.locationId;
const card = Template.currentData();
if (card && locationId) {
card.removeLocation(locationId);
}
},
'click .js-received-date': Popup.open('editCardReceivedDate'),
'click .js-start-date': Popup.open('editCardStartDate'),
'click .js-due-date': Popup.open('editCardDueDate'),
'click .js-toggle-due-complete'(event) {
event.preventDefault();
event.stopPropagation();
if (!Utils.canModifyCard()) return;
const card = Template.currentData();
card.setDueComplete(!card.getDueComplete());
},
'click .js-end-date': Popup.open('editCardEndDate'),
'click .js-show-positive-votes': Popup.open('positiveVoteMembers'),
'click .js-show-negative-votes': Popup.open('negativeVoteMembers'),
@@ -856,7 +1237,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 () {
@@ -1144,6 +1525,109 @@ Template.cardMembersPopup.helpers({
},
});
// Popup that adds or edits a single card location (name, address, latitude,
// longitude). Cards can hold multiple locations, like members.
Template.cardLocationsPopup.onCreated(function () {
const data = Template.currentData();
this.cardId = data && data._id;
this.detectMsg = new ReactiveVar('');
this.mapSavedMsg = new ReactiveVar('');
});
Template.cardLocationsPopup.helpers({
location() {
const tpl = Template.instance();
const card = ReactiveCache.getCard(tpl.cardId);
const id = editingLocationId.get();
if (card && id) {
const found = card.getLocations().find(loc => loc._id === id);
if (found) return found;
}
return {};
},
detectMessage() {
return Template.instance().detectMsg.get();
},
isMapProvider(provider) {
const user = ReactiveCache.getCurrentUser();
const current = user ? user.getMapProvider() : 'openstreetmap';
return current === provider;
},
mapSavedMessage() {
return Template.instance().mapSavedMsg.get();
},
});
Template.cardLocationsPopup.events({
'click .js-detect-location'(event) {
event.preventDefault();
const tpl = Template.instance();
const linkInput = tpl.find('.js-location-map-link');
const parsed = parseMapLink(linkInput ? linkInput.value : '');
let filled = false;
if (typeof parsed.latitude === 'number') {
tpl.find('.js-location-latitude').value = parsed.latitude;
tpl.find('.js-location-longitude').value = parsed.longitude;
filled = true;
}
if (parsed.name) {
tpl.find('.js-location-name').value = parsed.name;
filled = true;
}
if (parsed.address) {
tpl.find('.js-location-address').value = parsed.address;
filled = true;
}
tpl.detectMsg.set(
TAPi18n.__(filled ? 'location-detect-done' : 'location-detect-none'),
);
},
'submit .js-card-location-form'(event) {
event.preventDefault();
const tpl = Template.instance();
const card = ReactiveCache.getCard(tpl.cardId);
if (!card) {
Popup.back();
return;
}
const name = tpl.find('.js-location-name').value.trim();
const address = tpl.find('.js-location-address').value.trim();
const latRaw = tpl.find('.js-location-latitude').value.trim();
const lonRaw = tpl.find('.js-location-longitude').value.trim();
const latitude = latRaw === '' ? undefined : parseFloat(latRaw);
const longitude = lonRaw === '' ? undefined : parseFloat(lonRaw);
const data = { name, address, latitude, longitude };
const id = editingLocationId.get();
if (id) {
card.updateLocation(id, data);
} else {
card.addLocation(data);
}
Popup.back();
},
'click .js-delete-location'(event) {
event.preventDefault();
const tpl = Template.instance();
const card = ReactiveCache.getCard(tpl.cardId);
const id = editingLocationId.get();
if (card && id) {
card.removeLocation(id);
}
Popup.back();
},
'click .js-save-map-provider'(event) {
event.preventDefault();
const tpl = Template.instance();
const select = tpl.find('.js-map-provider');
const provider = select ? select.value : 'openstreetmap';
Meteor.call('setMapProvider', provider, err => {
tpl.mapSavedMsg.set(
TAPi18n.__(err ? 'server-error' : 'map-provider-saved'),
);
});
},
});
const filterMembers = (filterTerm) => {
let currBoard = Utils.getCurrentBoard();
let members = currBoard.activeMembers();
@@ -1222,6 +1706,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);
},
@@ -1233,14 +1726,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
@@ -1996,3 +2491,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();
},
});
@@ -0,0 +1,7 @@
template(name="cardStickersPopup")
.card-stickers-picker.clearfix
each stickerIcons
a.card-sticker-select.js-select-sticker(class="{{#if isSelected}}active{{/if}}" title="{{stickerTitle}}")
i.fa(class="fa-{{icon}} {{#if highlight}}sticker-{{highlight}}{{/if}}")
if isSelected
i.fa.fa-check.card-sticker-check
+39
View File
@@ -0,0 +1,39 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { STICKER_PICKER } from '/models/metadata/stickers';
// Picker popup that lets a user add/remove stickers on a card. Each entry is
// { icon, highlight?, name? }: the plain Font Awesome icons plus the mascot
// (underlined) and computer (ringed) highlighted stickers. Opened from the card
// details stickers section with the card as the data context.
Template.cardStickersPopup.onCreated(function () {
const data = Template.currentData();
this.cardId = data && data._id;
});
function pickerCard(tpl) {
return ReactiveCache.getCard(tpl.cardId);
}
Template.cardStickersPopup.helpers({
stickerIcons() {
return STICKER_PICKER;
},
isSelected() {
const card = pickerCard(Template.instance());
return !!(card && card.hasSticker(this.icon, this.highlight));
},
stickerTitle() {
return this.name || this.icon;
},
});
Template.cardStickersPopup.events({
'click .js-select-sticker'(event) {
event.preventDefault();
const card = pickerCard(Template.instance());
if (card) {
card.toggleSticker(this.icon, this.highlight, this.name);
}
},
});
+1 -1
View File
@@ -14,5 +14,5 @@
font-size: inherit;
-webkit-font-smoothing: antialiased;
content: "\f017";
margin-right: 0.3em;
margin-inline-end: 0.3em;
}
+14 -14
View File
@@ -14,7 +14,7 @@ textarea.js-edit-checklist-item {
color: #8c8c8c;
text-decoration: underline;
word-wrap: break-word;
float: right;
float: inline-end;
padding-top: 6px;
}
.delete-text:hover,
@@ -32,7 +32,7 @@ textarea.js-edit-checklist-item {
align-items: center;
}
.checklist-progress-bar-container .checklist-progress-text {
margin-right: 10px;
margin-inline-end: 10px;
}
.checklist-progress-bar-container .checklist-progress-bar {
width: 80%;
@@ -51,7 +51,7 @@ textarea.js-edit-checklist-item {
padding: 10px;
}
.checklist-title .checkbox {
float: left;
float: inline-start;
width: 30px;
height: 30px;
font-size: 18px;
@@ -63,21 +63,21 @@ textarea.js-edit-checklist-item {
}
.checklist-title .checklist-stat {
margin: 0 0.5em;
float: right;
float: inline-end;
padding-top: 6px;
}
.checklist-title .checklist-stat.is-finished {
color: #3cb500;
}
.checklist-title span.fa.checklist-handle {
padding-right: 20px;
padding-inline-end: 20px;
padding-top: 3px;
float: left;
float: inline-start;
}
#card-details-overlay {
top: 0;
bottom: -600px;
right: 0;
inset-inline-end: 0;
}
.checklist {
background: #f7f7f7;
@@ -123,14 +123,14 @@ textarea.js-edit-checklist-item {
background-color: #ebebeb;
}
.checklist-item .check-box-container {
padding-right: 10px;
padding-inline-end: 10px;
}
.checklist-item .check-box {
margin: 0.1em 0 0 0;
}
.checklist-item .check-box.is-checked {
border-bottom: 2px solid #3cb500;
border-right: 2px solid #3cb500;
border-inline-end: 2px solid #3cb500;
}
.checklist-item .item-title {
flex: 1;
@@ -148,7 +148,7 @@ textarea.js-edit-checklist-item {
}
.checklist-item span.fa.checklistitem-handle {
padding-top: 2px;
padding-right: 10px;
padding-inline-end: 10px;
}
.js-delete-checklist-item,
.js-convert-checklist-item-to-card {
@@ -171,9 +171,9 @@ textarea.js-edit-checklist-item {
}
.add-checklist-top {
/* more space to checklists title */
padding-left: 20px;
padding-inline-start: 20px;
/* + is easier clickable */
padding-right: 20px;
padding-inline-end: 20px;
}
.add-checklist-top.js-open-inlined-form:hover {
background: #dbdbdb;
@@ -185,9 +185,9 @@ textarea.js-edit-checklist-item {
width: 100%;
}
.checklist-details-menu {
float: right;
float: inline-end;
padding: 6px 10px 6px 10px;
}
.edit-controls label.toggle-label {
margin-left: 2px;
margin-inline-start: 2px;
}
+12 -12
View File
@@ -5,7 +5,7 @@
display: inline-block;
font-weight: 700;
font-size: 13px;
margin-right: 4px;
margin-inline-end: 4px;
margin-bottom: 5px;
padding: 3px 8px;
max-width: 210px;
@@ -140,7 +140,7 @@
}
.edit-label .card-label,
.create-label .card-label {
float: left;
float: inline-start;
height: 25px;
margin: 0px 3% 7px 0px;
width: 10.5%;
@@ -153,7 +153,7 @@
}
.edit-labels .card-label {
height: 30px;
left: 0;
inset-inline-start: 0;
padding: 1px 5px;
position: absolute;
top: 0;
@@ -164,7 +164,7 @@
margin-bottom: 4px;
position: relative;
top: auto;
left: 0;
inset-inline-start: 0;
width: 260px;
}
.edit-labels-pop-over {
@@ -185,24 +185,24 @@
min-height: 18px;
padding: 8px;
position: relative;
transition: margin-right 0.1s;
transition: margin-inline-end 0.1s;
}
.card-label-selectable .card-label-selectable-icon {
position: absolute;
top: 8px;
right: -20px;
inset-inline-end: -20px;
}
.card-label-selectable.active:hover,
.card-label-selectable.active,
.card-label-selectable.active.selected:hover,
.card-label-selectable.active.selected {
padding-right: 32px;
padding-inline-end: 32px;
}
.card-label-selectable.active:hover .card-label-selectable-icon,
.card-label-selectable.active .card-label-selectable-icon,
.card-label-selectable.active.selected:hover .card-label-selectable-icon,
.card-label-selectable.active.selected .card-label-selectable-icon {
right: 6px;
inset-inline-end: 6px;
}
.card-label-selectable.selected,
.card-label-selectable:hover {
@@ -210,21 +210,21 @@
}
.active .card-label-selectable,
.active .card-label-selectable:hover {
margin-right: 0;
margin-inline-end: 0;
}
.active .card-label-selectable .card-label-selectable-icon {
right: 8px;
inset-inline-end: 8px;
}
.card-label-edit-button {
border-radius: 3px;
float: right;
float: inline-end;
padding: 8px;
}
.card-label-edit-button:hover {
background: #dbdbdb;
}
ul.edit-labels-pop-over span.label-handle {
padding-right: 10px;
padding-inline-end: 10px;
display: inline-block;
width: 1.2em;
text-align: center;
+79 -30
View File
@@ -26,7 +26,7 @@
display: none;
}
.minicard-wrapper .multi-selection-checkbox + .minicard {
margin-left: 1vw;
margin-inline-start: 1vw;
}
/* Enlarge the clickable area of the small selection checkbox so it can be
reliably clicked at the left side of the minicard. The visible 13px box (and
@@ -36,8 +36,8 @@
content: "";
position: absolute;
top: -8px;
left: -8px;
right: -8px;
inset-inline-start: -8px;
inset-inline-end: -8px;
bottom: -8px;
}
@media only screen {
@@ -56,16 +56,16 @@
}
}
.minicard-details-menu-with-handle {
float: right;
padding-left: 0.7vw;
float: inline-end;
padding-inline-start: 0.7vw;
font-size: clamp(14px, 3vw, 18px);
padding: 0;
z-index: 1;
}
.minicard-details-menu {
float: right;
float: inline-end;
font-size: clamp(14px, 3vw, 18px);
padding-left: 0.7vw;
padding-inline-start: 0.7vw;
}
@media print {
.minicard-details-menu,
@@ -76,7 +76,7 @@
.minicard.linked-board .linked-icon,
.minicard.linked-card .linked-icon {
display: inline-block;
margin-right: 1.5vw;
margin-inline-end: 1.5vw;
vertical-align: baseline;
font-size: 0.9em;
}
@@ -110,13 +110,13 @@
}
.minicard .minicard-labels {
float: none;
margin-right: 6vw;
margin-inline-end: 6vw;
}
.minicard .minicard-labels .minicard-label {
width: clamp(12px, 1.5vw, 16px);
height: clamp(12px, 1.5vw, 16px);
border-radius: 0.3vw;
margin-right: 0.4vw;
margin-inline-end: 0.4vw;
margin-bottom: 4px;
}
.minicard .minicard-labels-no-text {
@@ -125,7 +125,7 @@
}
.minicard .minicard-custom-fields {
display: block;
margin-right: 6vw;
margin-inline-end: 6vw;
}
.minicard .minicard-custom-field {
display: flex;
@@ -135,20 +135,20 @@
display: block;
word-wrap: break-word;
max-width: 13vw;
margin-right: 0.5vw;
margin-inline-end: 0.5vw;
}
.minicard .minicard-custom-field-item-fullwidth {
flex-grow: 1;
display: block;
word-wrap: break-word;
max-width: 100%;
margin-right: 0.5vw;
margin-inline-end: 0.5vw;
}
.minicard .handle {
width: clamp(20px, 2.5vw, 28px);
height: clamp(20px, 2.5vw, 28px);
position: absolute;
right: 0vw;
inset-inline-end: 0vw;
top: 28px;
display: none;
z-index: 1;
@@ -166,12 +166,12 @@
text-align: center;
}
.minicard .minicard-title {
margin-right: 1.5vw;
margin-inline-end: 1.5vw;
}
.minicard .minicard-title .card-number {
color: #b3b3b3;
display: inline-block;
margin-right: 0.7vw;
margin-inline-end: 0.7vw;
}
@media only screen {
.minicard .minicard-title p:last-child {
@@ -188,11 +188,11 @@
flex-wrap: wrap;
position: relative;
z-index: 5;
margin-right: 6vw;
margin-inline-end: 6vw;
clear: both;
}
.minicard .date {
margin-right: 0.4vw;
margin-inline-end: 0.4vw;
}
/* Unicode icons for minicard dates - matching cardDate.css */
@@ -211,7 +211,7 @@
.minicard .card-date time::before {
font-size: inherit;
margin-right: 0.3em;
margin-inline-end: 0.3em;
display: inline-block;
}
@@ -324,7 +324,7 @@
}
.minicard .badges {
float: left;
float: inline-start;
margin-top: 8px;
color: #808080;
}
@@ -332,8 +332,8 @@
display: none;
}
.minicard .badges .badge {
float: left;
margin-right: 1.5vw;
float: inline-start;
margin-inline-end: 1.5vw;
margin-bottom: 4px;
font-size: 0.9em;
}
@@ -344,7 +344,7 @@
color: #fff;
}
.minicard .badges .badge:last-of-type {
margin-right: 0;
margin-inline-end: 0;
}
.minicard .badges .badge .badge-icon,
.minicard .badges .badge .badge-text {
@@ -356,24 +356,24 @@
}
.minicard .badges .badge .badge-text {
font-size: 0.9em;
padding-left: 0.3vw;
padding-inline-start: 0.3vw;
line-height: 1.2;
}
.minicard .badges .badge .check-list-text {
padding-left: 0px;
padding-inline-start: 0px;
line-height: 1.1;
}
.minicard .minicard-members,
.minicard .minicard-assignees,
.minicard .minicard-creator {
float: right;
margin-left: 0.7vw;
float: inline-end;
margin-inline-start: 0.7vw;
margin-bottom: 4px;
}
.minicard .minicard-members .member,
.minicard .minicard-assignees .member,
.minicard .minicard-creator .member {
float: right;
float: inline-end;
border-radius: 50%;
height: clamp(24px, 3.5vw, 32px);
width: clamp(24px, 3.5vw, 32px);
@@ -382,7 +382,7 @@
.minicard .minicard-members .assignee,
.minicard .minicard-assignees .assignee,
.minicard .minicard-creator .assignee {
float: right;
float: inline-end;
border-radius: 50%;
height: clamp(24px, 3.5vw, 32px);
width: clamp(24px, 3.5vw, 32px);
@@ -408,7 +408,7 @@
background-color: #eee;
width: 100%;
margin-bottom: 3px;
margin-left: -0.5vw;
margin-inline-start: -0.5vw;
border-radius: 0.4vw;
overflow: hidden;
text-overflow: ellipsis;
@@ -834,3 +834,52 @@
word-wrap: break-word;
overflow-wrap: break-word;
}
/* Trello-style "mark complete" toggle, left of the minicard title */
.minicard-title .minicard-complete-toggle {
display: inline-flex;
align-items: center;
vertical-align: middle;
/* Nudge down so the checkmark sits at the vertical middle of the title text
(the checked materialCheckBox is shifted up 4px by its own positioning). */
position: relative;
top: 4px;
margin-inline-end: 6px;
cursor: pointer;
}
/* Sticker icon highlight styles (used instead of colour): taco pack => underline,
pete pack => round (ring around the icon). Icon keeps its normal colour. */
.minicard-sticker i.sticker-underline {
border-bottom: 2px solid currentColor;
padding-bottom: 1px;
line-height: 1;
}
.minicard-sticker i.sticker-round {
border: 1px solid currentColor;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.6em;
height: 1.6em;
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;
}
+23 -1
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
@@ -21,7 +29,7 @@ template(name="minicard")
+minicardStartDate
if showDue
if getDue
.date
.date(class="{{#if getDueComplete}}due-complete{{/if}}")
+minicardDueDate
if showEnd
if getEnd
@@ -54,6 +62,10 @@ template(name="minicard")
span {{_ 'upload-completed'}}
.minicard-title
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 ' > ' }}
@@ -152,6 +164,16 @@ 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
span.badge-icon.minicard-sticker(title="{{#if name}}{{name}}{{else}}{{icon}}{{/if}}")
i.fa(class="fa-{{icon}} {{#if highlight}}sticker-{{highlight}}{{/if}}")
if comments.length
.badge(title="{{_ 'card-comments-title' comments.length }}")
span.badge-icon.badge-comment.badge-text
+90
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,6 +133,13 @@ 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);
@@ -172,12 +218,56 @@ 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.
event.preventDefault();
event.stopPropagation();
if (!Utils.canModifyCard()) {
return;
}
this.setDueComplete(!this.getDueComplete());
},
'click .js-toggle-minicard-label-text'() {
if (window.localStorage.getItem('hiddenMinicardLabelText')) {
window.localStorage.removeItem('hiddenMinicardLabelText'); //true
+16 -13
View File
@@ -59,13 +59,13 @@ textarea.js-edit-subtask-item {
.js-confirm-subtask-delete {
background-color: #f7f7f7;
position: absolute;
float: left;
float: inline-start;
width: 60%;
margin-top: 0;
margin-left: 13%;
margin-inline-start: 13%;
padding-bottom: 2%;
padding-left: 3%;
padding-right: 3%;
padding-inline-start: 3%;
padding-inline-end: 3%;
z-index: 17;
border-radius: 3px;
}
@@ -86,17 +86,17 @@ textarea.js-edit-subtask-item {
padding: left 2% right 2%;
}
.js-confirm-subtask-delete .js-subtask-delete-buttons .confirm-subtask-delete {
margin-left: 12%;
float: left;
margin-inline-start: 12%;
float: inline-start;
}
.js-confirm-subtask-delete .js-subtask-delete-buttons .toggle-delete-subtask-dialog {
margin-right: 12%;
float: right;
margin-inline-end: 12%;
float: inline-end;
}
#card-details-overlay {
top: 0;
bottom: -600px;
right: 0;
inset-inline-end: 0;
z-index: 15;
}
@@ -139,17 +139,20 @@ textarea.js-edit-subtask-item {
.subtasks-item:hover {
background-color: #ebebeb;
}
.subtasks-item .check-box-container {
padding-inline-end: 10px;
}
.subtasks-item .check-box {
margin: 0.1em 0 0 0;
}
.subtasks-item .check-box.is-checked {
border-bottom: 2px solid #3cb500;
border-right: 2px solid #3cb500;
border-inline-end: 2px solid #3cb500;
}
/* Unicode checkbox icons styling */
.subtasks-item .check-box-unicode {
font-size: 1.3em;
margin-right: 8px;
margin-inline-end: 8px;
cursor: pointer;
display: inline-block;
vertical-align: middle;
@@ -158,7 +161,7 @@ textarea.js-edit-subtask-item {
/* Grey checkmarks when grey icons setting is enabled */
body.grey-icons-enabled .subtasks-item .check-box.is-checked {
border-bottom: 2px solid #7a7a7a;
border-right: 2px solid #7a7a7a;
border-inline-end: 2px solid #7a7a7a;
}
body.grey-icons-enabled .subtasks-item .check-box-unicode {
filter: grayscale(100%);
@@ -167,7 +170,7 @@ body.grey-icons-enabled .subtasks-item .check-box-unicode {
}
.subtasks-item .item-title {
flex: 1;
padding-left: 10px;
padding-inline-start: 10px;
}
.subtasks-item .item-title.is-checked {
color: #8c8c8c;
+3 -4
View File
@@ -74,14 +74,13 @@ template(name="subtasksItems")
template(name='subtaskItemDetail')
.js-subtasks-item.subtasks-item
if canModifyCard
span.check-box-unicode
i.fa(class="{{#if item.isFinished}}fa-check-square{{else}}fa-square-o{{/if}}")
.check-box-container
.check-box.materialCheckBox(class="{{#if item.isFinished}}is-checked{{/if}}")
.item-title.js-open-inlined-form.is-editable(class="{{#if item.isFinished }}is-checked{{/if}}")
+viewer
= item.title
else
span.check-box-unicode
i.fa(class="{{#if item.isFinished}}fa-check-square{{else}}fa-square-o{{/if}}")
.materialCheckBox(class="{{#if item.isFinished}}is-checked{{/if}}")
.item-title(class="{{#if item.isFinished }}is-checked{{/if}}")
+viewer
= item.title
+1 -1
View File
@@ -119,7 +119,7 @@ Template.subtasks.helpers({
});
Template.subtaskItemDetail.events({
async 'click .js-subtasks-item .check-box-unicode'() {
async 'click .js-subtasks-item .check-box-container'() {
const item = Template.currentData().item;
if (item && item._id) {
await item.toggleItem();
@@ -13,7 +13,7 @@
}
.original-position-loading i {
margin-right: 5px;
margin-inline-end: 5px;
}
.original-position-details {
@@ -34,7 +34,7 @@
.original-position-moved i {
color: #f39c12;
margin-right: 5px;
margin-inline-end: 5px;
}
.original-position-unchanged {
@@ -48,7 +48,7 @@
.original-position-unchanged i {
color: #28a745;
margin-right: 5px;
margin-inline-end: 5px;
}
.original-position-text {
+26 -26
View File
@@ -143,8 +143,8 @@ button {
}
input[type="submit"] .wide,
button .wide {
padding-left: 4vw;
padding-right: 4vw;
padding-inline-start: 4vw;
padding-inline-end: 4vw;
}
input[type="submit"]:hover,
button:hover,
@@ -209,7 +209,7 @@ button.negate:active {
}
input[type="submit"] i.fa,
button i.fa {
margin-right: 10px;
margin-inline-end: 10px;
}
input[type="submit"].disabled,
input[type="submit"]:disabled,
@@ -248,7 +248,7 @@ input[type="hidden"] {
}
.radio-div input,
.check-div input {
left: -2.5vw;
inset-inline-start: -2.5vw;
min-height: 0;
margin: 0;
padding: 0;
@@ -289,7 +289,7 @@ textarea::-moz-placeholder {
.add-controls button[type=submit],
.edit-controls input[type=button],
.add-controls input[type=button] {
float: left;
float: inline-start;
height: 4.5vh;
margin-bottom: 0px;
}
@@ -301,7 +301,7 @@ textarea::-moz-placeholder {
[type="checkbox"]:not(:checked),
[type="checkbox"]:checked {
position: absolute;
left: -9999px;
inset-inline-start: -9999px;
visibility: hidden;
display: none;
}
@@ -318,14 +318,14 @@ textarea::-moz-placeholder {
}
.materialCheckBox.is-checked {
top: -4px;
left: -3px;
inset-inline-start: -3px;
width: 7px;
height: 15px;
margin-right: 6px;
margin-inline-end: 6px;
border-top: 2px solid transparent;
border-left: 2px solid transparent;
border-inline-start: 2px solid transparent;
border-bottom: 2px solid #3cb500;
border-right: 2px solid #3cb500;
border-inline-end: 2px solid #3cb500;
transform: rotate(40deg);
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
@@ -334,7 +334,7 @@ textarea::-moz-placeholder {
/* Grey checkmarks when grey icons setting is enabled */
body.grey-icons-enabled .materialCheckBox.is-checked {
border-bottom: 2px solid #7a7a7a;
border-right: 2px solid #7a7a7a;
border-inline-end: 2px solid #7a7a7a;
}
.button-link {
background: #fff;
@@ -369,12 +369,12 @@ body.grey-icons-enabled .materialCheckBox.is-checked {
margin: 0;
padding: 2px 4px;
position: absolute;
right: 5px;
inset-inline-end: 5px;
top: 5px;
text-align: center;
}
.button-link.is-on {
padding-right: 30px;
padding-inline-end: 30px;
max-width: 196px;
}
.button-link.is-on .on {
@@ -383,11 +383,11 @@ body.grey-icons-enabled .materialCheckBox.is-checked {
.button-link.inline {
color: #666;
padding: 2px 14px;
margin-left: 4px;
margin-inline-start: 4px;
}
.button-link.setting {
height: 52px;
float: left;
float: inline-start;
position: relative;
margin-top: 0;
}
@@ -440,7 +440,7 @@ body.grey-icons-enabled .materialCheckBox.is-checked {
border: none;
cursor: pointer;
height: 50px;
left: 0;
inset-inline-start: 0;
margin: 0;
opacity: 0;
position: absolute;
@@ -501,7 +501,7 @@ body.grey-icons-enabled .materialCheckBox.is-checked {
button.quiet-button,
button.loud-text-button {
background: none;
text-align: left;
text-align: start;
line-height: normal;
border: none;
box-shadow: none;
@@ -535,7 +535,7 @@ button.loud-text-button:hover {
}
.emphasis-button.w-img,
.quiet-button.w-img {
padding-left: 28px;
padding-inline-start: 28px;
}
.emphasis-button:hover,
.quiet-button:hover {
@@ -582,13 +582,13 @@ button.loud-text-button:hover {
background: #dcdcdc;
}
.big-link.options {
padding-right: 41px;
padding-inline-end: 41px;
}
.big-link .option {
height: 30px;
width: 30px;
position: absolute;
right: 6px;
inset-inline-end: 6px;
top: 6px;
}
.big-link.none {
@@ -599,7 +599,7 @@ button.loud-text-button:hover {
background: transparent;
}
.big-link.avatar-changer {
padding-right: 51px;
padding-inline-end: 51px;
}
.big-link.avatar-changer .member {
border: 1px solid #ccc;
@@ -607,7 +607,7 @@ button.loud-text-button:hover {
height: 40px;
width: 40px;
position: absolute;
right: 0;
inset-inline-end: 0;
top: 0;
}
.big-link.avatar-changer .member .member-avatar {
@@ -652,7 +652,7 @@ button.loud-text-button:hover {
line-height: 34px;
position: absolute;
top: 0;
left: 0;
inset-inline-start: 0;
width: 100%;
z-index: 2;
font-size: 23px;
@@ -687,7 +687,7 @@ button.loud-text-button:hover {
.dropdown-menu li a img {
width: 18px;
height: 18px;
margin-right: 5px;
margin-inline-end: 5px;
vertical-align: middle;
}
.dropdown-menu li a .minicard-label {
@@ -719,7 +719,7 @@ button.loud-text-button:hover {
}
.toggle-label:after {
position: absolute;
left: -2px;
inset-inline-start: -2px;
top: -3px;
display: block;
width: 26px;
@@ -737,7 +737,7 @@ button.loud-text-button:hover {
background-color: #6fbeb5;
}
.toggle-switch:checked ~ .toggle-label:after {
left: 20px;
inset-inline-start: 20px;
background-color: #179588;
}
.toggle-switch:checked:disabled ~ .toggle-label {
+7 -7
View File
@@ -67,17 +67,17 @@
.gantt-container thead th,
.gantt-container thead tr > td:first-child {
border-right: 2px solid #666;
border-inline-end: 2px solid #666;
padding: 4px; /* half of 8px */
width: 100px; /* half of 200px */
text-align: left;
text-align: start;
font-weight: bold;
background-color: #e8e8e8;
min-width: 100px; /* half of 200px */
}
.gantt-container thead td {
border-right: 1px solid #999;
border-inline-end: 1px solid #999;
padding: 2px 1px; /* half */
text-align: center;
background-color: #f5f5f5;
@@ -104,7 +104,7 @@
}
.gantt-container tbody td {
border-right: 1px solid #ccc;
border-inline-end: 1px solid #ccc;
padding: 1px; /* half */
text-align: center;
min-width: 15px; /* half of 30px */
@@ -121,12 +121,12 @@
}
.gantt-container tbody td:first-child {
border-right: 2px solid #666;
border-inline-end: 2px solid #666;
padding: 4px; /* half of 8px */
font-weight: 500;
cursor: pointer;
background-color: #fafafa !important;
text-align: left;
text-align: start;
width: 100px; /* half of 200px */
min-width: 100px; /* half of 200px */
white-space: nowrap;
@@ -155,7 +155,7 @@
.gantt-container .ganttview-today {
background-color: #fcf8e3;
border-right: 2px solid #ffb347;
border-inline-end: 2px solid #ffb347;
}
/* Task bar styling - VERY VISIBLE */
+116
View File
@@ -49,3 +49,119 @@ a.show-mapping {
font-size: 90%;
font-weight: bold;
}
/* Trello API import: file rows, workspace tree, job progress and errors */
/* The shared page spinners are centered with a large top margin (e.g. bounce
uses margin:100px auto), which leaves a big empty gap above the spinner in
this compact panel. Make spinners here small and inline instead. */
.trello-api-import .sk-spinner {
margin: 0 8px 0 0;
display: inline-block;
vertical-align: middle;
width: auto;
}
.trello-api-import .import-file-row label {
display: block;
font-weight: bold;
}
.trello-api-import .import-file-row input,
.trello-api-import .import-file-row select {
width: 100%;
}
.trello-workspaces {
max-height: 40vh;
overflow-y: auto;
border: 1px solid #ddd;
padding: 8px;
margin: 8px 0;
}
.trello-workspace {
margin-bottom: 8px;
}
.trello-workspace .trello-boards {
margin-inline-start: 22px;
}
.trello-workspace-title,
.trello-board {
align-items: center;
padding: 3px 0;
cursor: pointer;
text-decoration: none;
color: inherit;
}
.trello-workspace-title .materialCheckBox,
.trello-board .materialCheckBox {
flex: 0 0 auto;
margin-inline-end: 12px;
}
.trello-select-controls button {
margin-inline-end: 6px;
}
.trello-job-controls button {
margin-inline-end: 6px;
}
/* Import progress meter, styled like Admin Panel / Attachments / Move Attachments */
.trello-progress-label {
margin: 8px 0 6px;
}
.trello-progress-bar {
background: #e9ecef;
border-radius: 8px;
height: 12px;
overflow: hidden;
margin-bottom: 4px;
}
.trello-progress-fill {
background: linear-gradient(90deg, #28a745, #20c997);
height: 100%;
border-radius: 8px;
transition: width 0.3s ease;
position: relative;
}
.trello-progress-fill.is-running::after {
content: '';
position: absolute;
top: 0;
inset-inline-start: 0;
inset-inline-end: 0;
bottom: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
animation: trelloProgressShimmer 2s infinite;
}
@keyframes trelloProgressShimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.trello-progress-percent {
text-align: end;
font-size: 12px;
color: #666;
font-weight: 600;
margin-bottom: 8px;
}
.trello-import-result.is-ok {
color: #3cb500;
}
.trello-import-result.is-error {
color: #eb4646;
}
.js-trello-errors-text {
width: 100%;
font-family: monospace;
white-space: pre;
}
/* Trello API credential Save/Delete buttons */
.trello-api-creds-buttons button {
margin-inline-end: 6px;
}
.trello-creds-saved {
color: #3cb500;
}
/* Simplified per-field hint text under the Trello file upload labels */
.import-file-row .import-file-hint {
display: block;
font-size: 0.85em;
margin: 2px 0 4px;
}
+121 -4
View File
@@ -2,20 +2,136 @@ template(name="importHeaderBar")
h1
a.back-btn(href="{{pathFor 'home'}}")
i.fa-arrow-left
| {{_ title}}
| {{title}}
template(name="import")
.wrapper
if error.get
.warning {{_ error.get}}
if zipImporting
.warning {{_ 'import-trello-zip-progress'}}
+Template.dynamic(template=currentTemplate)
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'}}
span.quiet.import-file-hint {{_ 'import-trello-json-file-hint'}}
input.js-import-json-file(id='import-json-file' type="file" accept=".json,application/json")
p.import-file-row
label(for='import-zip-file') {{_ 'import-trello-zip-file'}}
span.quiet.import-file-hint {{_ 'import-trello-zip-file-hint'}}
input.js-import-zip-file(id='import-zip-file' type="file" accept=".zip,application/zip")
p.import-file-row
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
template(name="importTrelloApi")
.trello-api-import
h2 {{_ 'trello-api-import'}}
p.quiet {{_ 'trello-api-import-desc'}}
p.import-file-row
label(for='trello-api-key') {{_ 'trello-api-key'}}
input.js-trello-key(id='trello-api-key' type="text" autocomplete="off")
p.import-file-row
label(for='trello-api-token') {{_ 'trello-api-token'}}
input.js-trello-token(id='trello-api-token' type="password" autocomplete="off")
p.trello-api-creds-buttons
button.primary.js-trello-save-creds(type="button") {{_ 'save'}}
button.negate.js-trello-delete-creds(type="button") {{_ 'delete'}}
if credsSaved
p.quiet.trello-creds-saved
i.fa.fa-check
span {{_ 'trello-api-credentials-saved'}}
p
button.primary.js-trello-list-workspaces(type="button") {{_ 'trello-list-workspaces'}}
if error.get
.warning {{_ error.get}}
if loading.get
+spinner
if hasWorkspaces
p.import-file-row
label(for='trello-parent-workspace') {{_ 'import-trello-parent-workspace'}}
select.js-trello-parent-workspace(id='trello-parent-workspace')
option(value="") {{_ 'trello-parent-workspace-top'}}
each flatWorkspaceNodes
option(value=id)= label
p.trello-select-controls
button.primary.js-trello-select-all(type="button") {{_ 'select-all'}}
button.primary.js-trello-unselect-all(type="button") {{_ 'unselect-all'}}
.trello-workspaces
each workspaceList
.trello-workspace
a.flex.trello-workspace-title.js-toggle-workspace
.materialCheckBox(class="{{#if workspaceSelected}}is-checked{{/if}}")
b {{name}}
.trello-boards
each boards
a.flex.trello-board.js-toggle-board(data-board-id="{{id}}")
.materialCheckBox(class="{{#if boardSelected}}is-checked{{/if}}")
span {{name}}
if closed
span.quiet ({{_ 'archived'}})
if canStartImport
p
button.primary.wide.js-trello-import-selected(type="button") {{_ 'trello-import-selected'}}
if currentJob
hr
.trello-import-job
h3 {{_ 'trello-import-progress'}}
with currentJob
p.trello-progress-label
b {{_ 'status'}}:
span {{_ status}} ({{jobProgressText}})
.trello-progress-bar
.trello-progress-fill(class="{{#if $eq status 'running'}}is-running{{/if}}" style="width: {{jobProgressPercent}}%")
.trello-progress-percent {{jobProgressPercent}}%
if lastError
.warning {{lastError}}
p.trello-job-controls
if jobIsRunning
button.primary.js-trello-cancel(type="button") {{_ 'trello-cancel'}}
button.negate.js-trello-cancel-delete(type="button") {{_ 'trello-cancel-delete'}}
if jobCanResume
button.primary.js-trello-resume(type="button") {{_ 'trello-resume'}}
button.negate.js-trello-cancel-delete(type="button") {{_ 'trello-cancel-delete'}}
if jobIsFinished
button.negate.js-trello-cancel-delete(type="button") {{_ 'trello-delete-imported'}}
button.primary.js-trello-clear(type="button") {{_ 'trello-clear-job'}}
if jobResults.length
.trello-import-results
each jobResults
p.trello-import-result(class="{{#if success}}is-ok{{else}}is-error{{/if}}")
if success
i.fa.fa-check
span {{title}} — {{attachmentsImported}} {{_ 'attachments'}}
else
i.fa.fa-warning
span {{trelloBoardId}}: {{this.error}}
if jobHasErrors
.trello-import-errors
p
b {{_ 'trello-import-errors'}}
button.primary.js-trello-copy-errors(type="button") {{_ 'copy-to-clipboard'}}
if copied.get
span.quiet {{_ 'copied'}}
textarea.js-trello-errors-text(readonly rows="8" wrap="off")= jobErrorText
template(name="importMapMembers")
h2 {{_ 'import-map-members'}}
@@ -48,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
+640 -30
View File
@@ -3,15 +3,36 @@ 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';
import { TAPi18n } from '/imports/i18n';
import TrelloImportJobs from '/models/trelloImportJobs';
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}`;
},
});
@@ -40,10 +61,116 @@ 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;
}
// All Trello file/text imports are run on the server over HTTP instead of the
// DDP `importBoard` method. This matters for correctness, not just size: Meteor
// automatically re-sends an unacknowledged method call on every reconnect, so if
// an import is heavy (or the connection hiccups) the WebSocket can enter an
// endless drop/retry loop (the "Invalid frame header" flicker). An HTTP request
// is never auto-retried, and a .zip's attachment bytes never touch the realtime
// connection at all. The body is either a .zip File (application/zip) or a JSON
// string { board, membersMapping } (application/json).
async function postTrelloImport(body, contentType) {
const token =
(window.localStorage && window.localStorage.getItem('Meteor.loginToken')) || '';
const resp = await fetch('/import-trello', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': contentType,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body,
});
let result = {};
try {
result = await resp.json();
} catch (e) {
result = {};
}
if (!resp.ok || result.error) {
throw new Error(result.error || 'import-trello-failed');
}
return result;
}
// A safe URL slug for an imported board. Trello exports name the board `name`,
// WeKan exports use `title`; getSlug (limax) throws on undefined, so guard it.
function boardSlug(data) {
const raw = (data && (data.title || data.name)) || '';
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
// into Minimongo — otherwise the board opens with an empty Swimlanes view until
// the page is reloaded. The subscription is left running so the data stays
// available when the board is reopened from All Boards in the same session. A
// timeout is the safety net in case the subscription never signals ready.
function goToImportedBoard(boardId, slug) {
let navigated = false;
const go = () => {
if (navigated) return;
navigated = true;
FlowRouter.go('board', { id: boardId, slug });
};
Meteor.subscribe('board', boardId, false, { onReady: go });
Meteor.setTimeout(go, 5000);
}
// Find a workspace node by name anywhere in the user's personal workspace tree.
function findWorkspaceByName(nodes, name) {
for (const node of nodes || []) {
if (node.name === name) return node;
if (node.children) {
const found = findWorkspaceByName(node.children, name);
if (found) return found;
}
}
return null;
}
// Assign an imported board to a personal workspace named `wsName`, creating the
// workspace (under an optional parent) only if one with that name doesn't exist.
function assignBoardToNamedWorkspace(boardId, wsName, parentId = null) {
const user = ReactiveCache.getCurrentUser();
const tree = (user && user.profile && user.profile.boardWorkspacesTree) || [];
const existing = findWorkspaceByName(tree, wsName);
if (existing) {
Meteor.call('assignBoardToWorkspace', boardId, existing.id);
return;
}
Meteor.call('createWorkspace', { parentId, name: wsName }, (err, node) => {
if (!err && node) {
Meteor.call('assignBoardToWorkspace', boardId, node.id);
}
});
}
Template.import.onCreated(function () {
this.error = new ReactiveVar('');
this.steps = ['importTextarea', 'importMapMembers'];
@@ -51,6 +178,8 @@ Template.import.onCreated(function () {
this.importedData = new ReactiveVar();
this.membersToMap = new ReactiveVar([]);
this.importSource = Session.get('importSource');
// True while a Trello .zip package is being uploaded/imported server-side.
this.zipImporting = new ReactiveVar(false);
this.nextStep = () => {
const nextStepIndex = this._currentStepIndex.get() + 1;
@@ -65,63 +194,179 @@ Template.import.onCreated(function () {
this.error.set(error);
};
this.importData = (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 input = this.find('.js-import-json').value;
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;
const ret = Papa.parse(csv);
if (ret && ret.data && ret.data.length) this.importedData.set(ret.data);
else throw new Meteor.Error('error-csv-schema');
const membersToMap = _prepareAdditionalData(ret.data);
this.membersToMap.set(membersToMap);
this.nextStep();
} else {
try {
const dataObject = JSON.parse(input);
this.setError('');
this.importedData.set(dataObject);
const membersToMap = _prepareAdditionalData(dataObject);
// 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();
} catch (e) {
this.setError('error-json-malformed');
await advance();
return;
}
// Trello: a .zip package (one or more board .json files plus per-board
// attachment subdirectories from the Trello Attachments Downloader) imports
// all of its boards at once, through a separate field from the single .json.
if (dataSource === 'trello') {
const zipEl = this.find('.js-import-zip-file');
if (zipEl && zipEl.files && zipEl.files[0]) {
await this.importTrelloZip(zipEl.files[0]);
return;
}
}
try {
// A single board: JSON may come from an uploaded .json file (large Trello
// exports are awkward to paste) or from the textarea.
let input = this.find('.js-import-json').value;
const jsonFileEl = this.find('.js-import-json-file');
if (jsonFileEl && jsonFileEl.files && jsonFileEl.files[0]) {
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.
this.workspaceName = '';
if (dataSource === 'trello') {
const wsEl = this.find('.js-import-workspace-name');
this.workspaceName = wsEl && wsEl.value ? wsEl.value.trim() : '';
}
this.importedData.set(dataObject);
const membersToMap = _prepareAdditionalData(dataObject);
// 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);
await advance();
} catch (e) {
this.setError('error-json-malformed');
}
};
this.finishImport = () => {
const additionalData = {};
// Upload a Trello .zip package to the server, which extracts it (with
// zip-bomb / path-traversal guards), imports every board and streams each
// attachment to the Default storage, then go to All Boards.
this.importTrelloZip = async (zipFile) => {
this.setError('');
const wsEl = this.find('.js-import-workspace-name');
const workspaceName = wsEl && wsEl.value ? wsEl.value.trim() : '';
this.zipImporting.set(true);
let result;
try {
result = await postTrelloImport(zipFile, 'application/zip');
} catch (e) {
this.zipImporting.set(false);
this.setError((e && e.message) || 'import-trello-failed');
return;
}
this.zipImporting.set(false);
(result.boardIds || []).forEach(boardId => {
if (workspaceName) {
assignBoardToNamedWorkspace(boardId, workspaceName);
}
});
Session.set('fromBoard', null);
// Go to All Boards, where the newly imported boards appear.
FlowRouter.go('home');
};
this.finishImport = async () => {
const membersMapping = this.membersToMap.get();
const mappingById = {};
if (membersMapping) {
const mappingById = {};
membersMapping.forEach(member => {
if (member.wekanId) {
mappingById[member.id] = member.wekanId;
}
});
additionalData.membersMapping = mappingById;
}
const importedData = this.importedData.get();
// Trello: import over HTTP (see postTrelloImport) so the realtime DDP
// connection is never used for the board payload and can't enter the
// drop/retry "Invalid frame header" flicker loop. Do NOT mutate the still-
// mounted map-members template (e.g. clearing membersToMap) before
// navigating away — that forces an empty re-render mid-teardown and can
// throw "Can't select in removed DomRange". We navigate away, which
// destroys the import templates.
if (this.importSource === 'trello') {
let result;
try {
result = await postTrelloImport(
JSON.stringify({ board: importedData, membersMapping: mappingById }),
'application/json',
);
} catch (e) {
this.setError((e && e.message) || 'import-trello-failed');
return;
}
const boardId = (result.boardIds || [])[0];
if (!boardId) {
this.setError('import-trello-failed');
return;
}
Session.set('fromBoard', null);
if (this.workspaceName) {
assignBoardToNamedWorkspace(boardId, this.workspaceName);
}
goToImportedBoard(boardId, boardSlug(importedData));
return;
}
// wekan / csv: unchanged DDP import.
this.membersToMap.set([]);
Meteor.call(
'importBoard',
this.importedData.get(),
additionalData,
importedData,
{ membersMapping: mappingById },
this.importSource,
Session.get('fromBoard'),
(err, res) => {
if (err) {
this.setError(err.error);
} else {
let title = getSlug(this.importedData.get().title) || 'imported-board';
Session.set('fromBoard', null);
FlowRouter.go('board', {
id: res,
slug: title,
});
//Utils.goBoardId(res);
goToImportedBoard(res, boardSlug(importedData));
}
},
);
@@ -135,11 +380,42 @@ Template.import.helpers({
currentTemplate() {
return Template.instance().steps[Template.instance()._currentStepIndex.get()];
},
zipImporting() {
return Template.instance().zipImporting.get();
},
});
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');
@@ -149,6 +425,12 @@ Template.importTextarea.helpers({
return 'import-json-placeholder';
}
},
isTrelloImport() {
return Session.get('importSource') === 'trello';
},
isExcelImport() {
return Session.get('importSource') === 'excel';
},
});
Template.importTextarea.events({
@@ -158,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
@@ -288,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) {
@@ -320,7 +619,11 @@ Template.importMapMembersAddPopup.onCreated(function () {
});
Template.importMapMembersAddPopup.onRendered(function () {
this.find('.js-search-member-input').focus();
// Guard against the DOM range being gone (e.g. the popup was closed during a
// re-render) — calling find/$ then throws "Can't select in removed DomRange".
if (this.view && this.view.isDestroyed) return;
const input = this.find('.js-search-member-input');
if (input) input.focus();
});
Template.importMapMembersAddPopup.onDestroyed(function () {
@@ -380,3 +683,310 @@ Template.importMapMembersAddPopup.helpers({
return importMemberPopupState.noResults;
},
});
// ---------------------------------------------------------------------------
// Live Trello API import: key/token -> list workspaces & boards -> import
// selected boards (with attachments) server-side, placing each under a
// personal workspace named after its Trello workspace.
// ---------------------------------------------------------------------------
function flattenWorkspaceTree(nodes, depth = 0, acc = []) {
(nodes || []).forEach(node => {
acc.push({ id: node.id, label: `${'— '.repeat(depth)}${node.name}` });
if (node.children && node.children.length) {
flattenWorkspaceTree(node.children, depth + 1, acc);
}
});
return acc;
}
// Build the copy-paste-friendly error text for a job: the error log plus a
// summary line per failed board.
function jobErrorText(job) {
if (!job) return '';
const lines = [];
(job.errorLog || []).forEach(line => lines.push(line));
return lines.join('\n');
}
Template.importTrelloApi.onCreated(function () {
this.error = new ReactiveVar('');
this.loading = new ReactiveVar(false);
this.workspaces = new ReactiveVar([]);
this.copied = new ReactiveVar(false);
// Board selection state: map of trelloBoardId -> true. Tracked here (rather
// than via DOM checkboxes) because the UI uses animated .materialCheckBox
// elements, not native inputs.
this.selectedBoards = new ReactiveVar({});
// The import itself runs server-side as a persisted job; watch it reactively
// so progress survives navigating away and back.
this.subscribe('trelloImportJobs');
});
// Collect every board id across all listed workspaces.
function allBoardIds(workspaces) {
const ids = {};
(workspaces || []).forEach(ws => {
(ws.boards || []).forEach(b => {
ids[b.id] = true;
});
});
return ids;
}
Template.importTrelloApi.helpers({
error() {
return Template.instance().error;
},
loading() {
return Template.instance().loading;
},
copied() {
return Template.instance().copied;
},
hasWorkspaces() {
return Template.instance().workspaces.get().length > 0;
},
workspaceList() {
return Template.instance().workspaces.get();
},
boardSelected() {
return !!Template.instance().selectedBoards.get()[this.id];
},
workspaceSelected() {
const sel = Template.instance().selectedBoards.get();
const boards = this.boards || [];
return boards.length > 0 && boards.every(b => sel[b.id]);
},
flatWorkspaceNodes() {
const user = ReactiveCache.getCurrentUser();
const tree = (user && user.profile && user.profile.boardWorkspacesTree) || [];
return flattenWorkspaceTree(tree);
},
credsSaved() {
const user = ReactiveCache.getCurrentUser();
return !!(user && user.profile && user.profile.trelloApiSaved);
},
// --- current background job ---
currentJob() {
return TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
},
jobIsRunning() {
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
return job && job.status === 'running';
},
jobCanResume() {
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
return job && (job.status === 'paused' || job.status === 'error');
},
jobIsFinished() {
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
return job && (job.status === 'done' || job.status === 'cancelled');
},
canStartImport() {
// Don't start a second import while one is active (running/paused/error),
// which would create a hidden concurrent job.
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
return !job || job.status === 'done' || job.status === 'cancelled';
},
jobProgressText() {
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
if (!job) return '';
return `${job.currentIndex} / ${job.total}`;
},
jobProgressPercent() {
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
if (!job || !job.total) return 0;
return Math.round((job.currentIndex / job.total) * 100);
},
jobResults() {
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
return (job && job.results) || [];
},
jobHasErrors() {
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
return !!(job && job.errorLog && job.errorLog.length);
},
jobErrorText() {
return jobErrorText(TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } }));
},
});
Template.importTrelloApi.events({
'click .js-trello-save-creds'(evt, tpl) {
evt.preventDefault();
const key = tpl.find('.js-trello-key').value.trim();
const token = tpl.find('.js-trello-token').value.trim();
if (!key || !token) {
tpl.error.set('trello-api-credentials-required');
return;
}
tpl.error.set('');
Meteor.call('saveTrelloCredentials', key, token, err => {
if (err) {
tpl.error.set(err.reason || err.error || 'trello-api-error');
return;
}
// Don't keep the token sitting in the browser; it now lives server-side.
tpl.find('.js-trello-key').value = '';
tpl.find('.js-trello-token').value = '';
});
},
'click .js-trello-delete-creds'(evt, tpl) {
evt.preventDefault();
tpl.error.set('');
Meteor.call('deleteTrelloCredentials', err => {
if (err) tpl.error.set(err.reason || err.error || 'trello-api-error');
});
tpl.find('.js-trello-key').value = '';
tpl.find('.js-trello-token').value = '';
},
'click .js-trello-list-workspaces'(evt, tpl) {
evt.preventDefault();
// key/token may be empty when saved credentials exist; the server falls
// back to the saved ones and returns an error if neither is available.
const key = tpl.find('.js-trello-key').value.trim();
const token = tpl.find('.js-trello-token').value.trim();
tpl.error.set('');
tpl.loading.set(true);
Meteor.call('trelloListWorkspaces', key, token, (err, res) => {
tpl.loading.set(false);
if (err) {
tpl.error.set(err.reason || err.error || 'trello-api-error');
tpl.workspaces.set([]);
tpl.selectedBoards.set({});
} else {
const workspaces = res || [];
tpl.workspaces.set(workspaces);
// Preselect all boards by default.
tpl.selectedBoards.set(allBoardIds(workspaces));
}
});
},
// Toggle a single board's animated checkbox.
'click .js-toggle-board'(evt, tpl) {
evt.preventDefault();
const id = this.id;
const sel = { ...tpl.selectedBoards.get() };
if (sel[id]) {
delete sel[id];
} else {
sel[id] = true;
}
tpl.selectedBoards.set(sel);
},
// Toggle all boards in a workspace: if all are selected, clear them; else
// select them all.
'click .js-toggle-workspace'(evt, tpl) {
evt.preventDefault();
const boards = this.boards || [];
const sel = { ...tpl.selectedBoards.get() };
const allSelected = boards.length > 0 && boards.every(b => sel[b.id]);
boards.forEach(b => {
if (allSelected) {
delete sel[b.id];
} else {
sel[b.id] = true;
}
});
tpl.selectedBoards.set(sel);
},
'click .js-trello-select-all'(evt, tpl) {
evt.preventDefault();
tpl.selectedBoards.set(allBoardIds(tpl.workspaces.get()));
},
'click .js-trello-unselect-all'(evt, tpl) {
evt.preventDefault();
tpl.selectedBoards.set({});
},
'click .js-trello-import-selected'(evt, tpl) {
evt.preventDefault();
const key = tpl.find('.js-trello-key').value.trim();
const token = tpl.find('.js-trello-token').value.trim();
const sel = tpl.selectedBoards.get();
const boardIds = Object.keys(sel).filter(id => sel[id]);
if (!boardIds.length) {
tpl.error.set('trello-select-boards');
return;
}
const parentEl = tpl.find('.js-trello-parent-workspace');
const parentId = parentEl && parentEl.value ? parentEl.value : null;
tpl.error.set('');
// Start the server-side job; progress shows up via the subscription. The
// user is free to navigate away and come back.
Meteor.call('trelloStartImport', key, token, boardIds, parentId, err => {
if (err) {
tpl.error.set(err.reason || err.error || 'trello-api-error');
}
});
},
'click .js-trello-resume'(evt, tpl) {
evt.preventDefault();
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
if (!job) return;
const key = tpl.find('.js-trello-key').value.trim();
const token = tpl.find('.js-trello-token').value.trim();
if (!key || !token) {
tpl.error.set('trello-api-credentials-required');
return;
}
tpl.error.set('');
Meteor.call('trelloResumeImport', job._id, key, token, err => {
if (err) tpl.error.set(err.reason || err.error || 'trello-api-error');
});
},
'click .js-trello-cancel'(evt, tpl) {
evt.preventDefault();
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
if (!job) return;
Meteor.call('trelloCancelImport', job._id, false);
},
'click .js-trello-cancel-delete'(evt, tpl) {
evt.preventDefault();
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
if (!job) return;
// eslint-disable-next-line no-alert
if (!window.confirm(TAPi18n.__('trello-cancel-delete-confirm'))) return;
Meteor.call('trelloCancelImport', job._id, true);
},
'click .js-trello-clear'(evt, tpl) {
evt.preventDefault();
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
if (!job) return;
Meteor.call('trelloClearImportJob', job._id, false);
},
'click .js-trello-copy-errors'(evt, tpl) {
evt.preventDefault();
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
const text = jobErrorText(job);
if (!text) return;
const done = () => {
tpl.copied.set(true);
setTimeout(() => tpl.copied.set(false), 2000);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done, () => {
// Fall back to selecting the textarea so the user can copy manually.
const ta = tpl.find('.js-trello-errors-text');
if (ta) {
ta.focus();
ta.select();
}
});
} else {
const ta = tpl.find('.js-trello-errors-text');
if (ta) {
ta.focus();
ta.select();
try {
document.execCommand('copy');
done();
} catch (e) {
// user can copy manually from the selected textarea
}
}
}
},
});
@@ -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);
}
+13 -3
View File
@@ -4,15 +4,24 @@ export function wekanGetMembersToMap(data) {
// we will work on the list itself (an ordered array of objects) when a
// mapping is done, we add a 'wekan' field to the object representing the
// imported member
const membersToMap = data.members;
const users = data.users;
const membersToMap = data.members || [];
const users = data.users || [];
// auto-map based on username
const mappable = [];
membersToMap.forEach(importedMember => {
importedMember.id = importedMember.userId;
delete importedMember.userId;
const user = users.filter(user => {
return user._id === importedMember.id;
})[0];
// The exported `users` array only contains users that still exist in the
// source instance, so a member whose account was deleted (or any other
// dangling user reference, e.g. from an older export) has no matching
// user. Skip it instead of dereferencing `undefined`, which would throw
// and make the whole import fail as "error-json-malformed".
if (!user) {
return;
}
if (user.profile && user.profile.fullname) {
importedMember.fullName = user.profile.fullname;
}
@@ -21,6 +30,7 @@ export function wekanGetMembersToMap(data) {
if (wekanUser) {
importedMember.wekanId = wekanUser._id;
}
mappable.push(importedMember);
});
return membersToMap;
return mappable;
}
+86 -83
View File
@@ -4,16 +4,16 @@
flex-direction: column;
position: relative;
background: #dedede;
border-left: 1px solid #ccc;
border-inline-start: 1px solid #ccc;
padding: 0;
float: left;
float: inline-start;
}
/* List resize handle */
.list-resize-handle {
position: absolute;
top: 0;
right: -3px;
inset-inline-end: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
@@ -84,16 +84,17 @@
flex-basis: auto !important;
flex-grow: 0 !important;
flex-shrink: 0 !important;
/* Override any conflicting layout properties */
float: left !important;
display: block !important;
/* 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: 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 {
@@ -129,8 +130,9 @@ body.list-resizing-active * {
flex-basis: auto !important;
flex-grow: 0 !important;
flex-shrink: 0 !important;
float: left !important;
display: block !important;
float: inline-start !important;
display: flex !important;
flex-direction: column !important;
}
/* Ensure the width persists after resize is complete */
@@ -145,8 +147,9 @@ body.list-resizing-active * {
flex-basis: auto !important;
flex-grow: 0 !important;
flex-shrink: 0 !important;
float: left !important;
display: block !important;
float: inline-start !important;
display: flex !important;
flex-direction: column !important;
}
/* Ensure consistent header height for all lists */
@@ -189,7 +192,7 @@ body.list-resizing-active * {
.list-header .js-open-list-menu {
position: absolute !important;
top: 5px !important;
right: 10px !important;
inset-inline-end: 10px !important;
z-index: 15 !important;
display: inline-block !important;
padding: 4px !important;
@@ -198,7 +201,7 @@ body.list-resizing-active * {
.list-header .list-header-plus-top {
position: absolute !important;
top: 5px !important;
right: 30px !important;
inset-inline-end: 30px !important;
z-index: 15 !important;
display: inline-block !important;
padding: 4px !important;
@@ -207,7 +210,7 @@ body.list-resizing-active * {
.list-header .list-header-handle-desktop {
position: absolute !important;
top: 5px !important;
right: 80px !important;
inset-inline-end: 80px !important;
z-index: 15 !important;
display: inline-block !important;
cursor: move !important;
@@ -229,11 +232,11 @@ body.list-resizing-active * {
flex: 1;
}
.list:first-child {
border-left: none;
border-inline-start: none;
flex: none;
}
.card-details + .list {
border-left: none;
border-inline-start: none;
}
.list.ui-sortable-helper {
box-shadow: -2px 2px 8px rgba(0,0,0,0.3), 0 0 1px rgba(0,0,0,0.5);
@@ -311,7 +314,7 @@ body.list-resizing-active * {
white-space: nowrap;
display: block;
width: auto;
left: auto !important;
inset-inline-start: auto !important;
top: auto !important;
}
.list.list-collapsed .list-header .list-header-handle {
@@ -322,7 +325,7 @@ body.list-resizing-active * {
display: block;
width: auto;
top: auto !important;
right: auto !important;
inset-inline-end: auto !important;
visibility: visible !important;
opacity: 1 !important;
pointer-events: auto !important;
@@ -336,8 +339,8 @@ body.list-resizing-active * {
display: block;
width: auto;
top: auto !important;
right: auto !important;
left: auto !important;
inset-inline-end: auto !important;
inset-inline-start: auto !important;
transform: none !important;
visibility: visible !important;
opacity: 1 !important;
@@ -373,7 +376,7 @@ body.list-resizing-active * {
max-height: 100%;
height: 100%;
position: static;
left: auto;
inset-inline-start: auto;
top: auto;
writing-mode: vertical-rl;
text-orientation: mixed;
@@ -443,7 +446,7 @@ form.inlined-form .list-composer {
/* Sum badge shown before list title */
.list-header .list-sum-badge {
display: inline-block;
margin-right: 8px;
margin-inline-end: 8px;
padding: 0;
border-radius: 0;
background: transparent;
@@ -456,19 +459,19 @@ form.inlined-form .list-composer {
width: 1.3vw;
height: 35vh;
margin-top: -12vh;
margin-left: -14vw;
margin-right: 0;
margin-inline-start: -14vw;
margin-inline-end: 0;
transform: rotate(90deg);
position: relative;
text-overflow: ellipsis;
white-space: nowrap;
}
.list-header .list-header-watch-icon {
padding-left: 10px;
padding-inline-start: 10px;
color: #a6a6a6;
}
.list-header .list-header-menu {
float: right;
float: inline-end;
}
@media print {
.list-header .list-header-menu,
@@ -478,7 +481,7 @@ form.inlined-form .list-composer {
}
.list-header .list-header-plus-top {
color: #a6a6a6;
margin-right: 15px;
margin-inline-end: 15px;
vertical-align: middle;
line-height: 1.2;
}
@@ -487,13 +490,13 @@ form.inlined-form .list-composer {
}
.list-header .list-header-collapse-left {
color: #a6a6a6;
margin-right: 15px;
margin-inline-end: 15px;
}
/* List header collapse button styling - positioned at top left */
.list-header .js-collapse {
position: absolute !important;
top: 5px !important;
left: 10px !important;
inset-inline-start: 10px !important;
color: #a6a6a6;
display: inline-block;
vertical-align: top;
@@ -527,8 +530,8 @@ form.inlined-form .list-composer {
.list:not(.list-collapsed):not(.mobile-view) .list-header .js-collapse {
position: static !important;
top: auto !important;
left: auto !important;
right: auto !important;
inset-inline-start: auto !important;
inset-inline-end: auto !important;
transform: none !important;
margin: 0 !important;
min-width: 24px;
@@ -580,7 +583,7 @@ form.inlined-form .list-composer {
margin: 0;
overflow: hidden;
position: static;
left: auto;
inset-inline-start: auto;
top: auto;
writing-mode: vertical-rl;
text-orientation: mixed;
@@ -631,7 +634,7 @@ form.inlined-form .list-composer {
margin: 0;
overflow: hidden;
position: static;
left: auto;
inset-inline-start: auto;
top: auto;
writing-mode: vertical-rl;
text-orientation: mixed;
@@ -682,7 +685,7 @@ form.inlined-form .list-composer {
margin: 0;
overflow: hidden;
position: static;
left: auto;
inset-inline-start: auto;
top: auto;
writing-mode: vertical-rl;
text-orientation: mixed;
@@ -702,7 +705,7 @@ form.inlined-form .list-composer {
}
.list-header .list-header-collapse {
color: #a6a6a6;
margin-right: 15px;
margin-inline-end: 15px;
}
.list-header .highlight {
color: #ce1414;
@@ -716,7 +719,7 @@ form.inlined-form .list-composer {
.js-open-list-menu,
.list-header-menu a {
color: #4d4d4d;
padding-left: 4px;
padding-inline-start: 4px;
}
.js-open-list-menu {
font-size: 18px;
@@ -757,7 +760,7 @@ form.inlined-form .list-composer {
}
}
.list-body .open-minicard-composer i.fa {
margin-right: 7px;
margin-inline-end: 7px;
}
.list-body .open-minicard-composer:hover {
background: #fafafa;
@@ -775,16 +778,16 @@ form.inlined-form .list-composer {
}
#js-wip-limit-edit .wip-limit-value {
width: 20%;
margin-right: 5%;
margin-inline-end: 5%;
}
#js-wip-limit-edit .wip-limit-error {
display: none;
}
#js-wip-limit-edit .soft-wip-limit {
margin-right: 8px;
margin-inline-end: 8px;
}
#js-wip-limit-edit div {
float: left;
float: inline-start;
}
#js-list-width-edit .list-width-error {
display: none;
@@ -796,7 +799,7 @@ form.inlined-form .list-composer {
width: 100vw;
max-width: 100vw;
min-width: 100vw;
border-left: 0px !important;
border-inline-start: 0px !important;
border-bottom: 1px solid #ccc;
display: block !important;
}
@@ -806,19 +809,19 @@ form.inlined-form .list-composer {
width: 100vw;
max-width: 100vw;
min-width: 100vw;
border-left: 0px !important;
border-inline-start: 0px !important;
margin: 0 !important;
padding: 0 !important;
}
.list.mobile-view:first-child {
margin-left: 0px;
margin-inline-start: 0px;
}
.list.mobile-view.ui-sortable-helper {
flex: 0 0 60px;
height: 60px;
width: 100vw;
max-width: 100vw;
border-left: 0px !important;
border-inline-start: 0px !important;
border-bottom: 1px solid #ccc;
display: block !important;
}
@@ -830,7 +833,7 @@ form.inlined-form .list-composer {
height: 60px;
width: 100vw;
max-width: 100vw;
border-left: 0px !important;
border-inline-start: 0px !important;
border-bottom: 1px solid #ccc;
display: block !important;
}
@@ -857,10 +860,10 @@ form.inlined-form .list-composer {
}
.list.mobile-view .list-header .list-header-left-icon {
padding: 7px;
padding-right: 27px;
padding-inline-end: 27px;
margin-top: 1px;
top: -7px;
left: -7px;
inset-inline-start: -7px;
}
.list.mobile-view .list-header .list-header-menu-icon {
padding: 14px;
@@ -868,7 +871,7 @@ form.inlined-form .list-composer {
text-align: center;
/* Force positioning for iPhone */
position: absolute !important;
right: 60px !important;
inset-inline-end: 60px !important;
top: 50% !important;
transform: translateY(-50%) !important;
z-index: 10;
@@ -879,7 +882,7 @@ form.inlined-form .list-composer {
text-align: center;
/* Force positioning for iPhone */
position: absolute !important;
right: 10px !important;
inset-inline-end: 10px !important;
top: 50% !important;
transform: translateY(-50%) !important;
z-index: 10;
@@ -902,9 +905,9 @@ form.inlined-form .list-composer {
grid-row: 2;
grid-column: 2;
align-self: start;
text-align: left;
padding-left: 0;
margin-left: 0;
text-align: start;
padding-inline-start: 0;
margin-inline-start: 0;
font-size: 16px !important;
line-height: 1.2;
}
@@ -936,7 +939,7 @@ form.inlined-form .list-composer {
width: 100vw;
max-width: 100vw;
min-width: 100vw;
border-left: 0px !important;
border-inline-start: 0px !important;
border-bottom: 1px solid #ccc;
display: block !important;
}
@@ -946,19 +949,19 @@ form.inlined-form .list-composer {
width: 100vw;
max-width: 100vw;
min-width: 100vw;
border-left: 0px !important;
border-inline-start: 0px !important;
margin: 0 !important;
padding: 0 !important;
}
.list:first-child {
margin-left: 0px;
margin-inline-start: 0px;
}
.list.ui-sortable-helper {
flex: 0 0 60px;
height: 60px;
width: 100vw;
max-width: 100vw;
border-left: 0px !important;
border-inline-start: 0px !important;
border-bottom: 1px solid #ccc;
display: block !important;
}
@@ -970,7 +973,7 @@ form.inlined-form .list-composer {
height: 60px;
width: 100vw;
max-width: 100vw;
border-left: 0px !important;
border-inline-start: 0px !important;
border-bottom: 1px solid #ccc;
display: block !important;
}
@@ -993,10 +996,10 @@ form.inlined-form .list-composer {
}
.list-header .list-header-left-icon {
padding: 7px;
padding-right: 27px;
padding-inline-end: 27px;
margin-top: 1px;
top: -7px;
left: -7px;
inset-inline-start: -7px;
}
.list-header .list-header-menu-icon {
padding: 14px;
@@ -1004,7 +1007,7 @@ form.inlined-form .list-composer {
text-align: center;
/* iOS Safari fallback positioning */
position: absolute;
right: 60px;
inset-inline-end: 60px;
top: 50%;
transform: translateY(-50%);
}
@@ -1014,7 +1017,7 @@ form.inlined-form .list-composer {
text-align: center;
/* iOS Safari fallback positioning */
position: absolute;
right: 10px;
inset-inline-end: 10px;
top: 50%;
transform: translateY(-50%);
}
@@ -1080,7 +1083,7 @@ form.inlined-form .list-composer {
.list.mobile-view .list-header .list-header-menu-icon {
/* Remove absolute positioning for iPhone 12 Mini */
position: static !important;
right: auto !important;
inset-inline-end: auto !important;
top: auto !important;
transform: none !important;
/* Use grid positioning */
@@ -1094,7 +1097,7 @@ form.inlined-form .list-composer {
.list.mobile-view .list-header .list-header-handle {
/* Remove absolute positioning for iPhone 12 Mini */
position: static !important;
right: auto !important;
inset-inline-end: auto !important;
top: auto !important;
transform: none !important;
/* Use grid positioning */
@@ -1119,9 +1122,9 @@ form.inlined-form .list-composer {
grid-row: 2 !important;
grid-column: 2 !important;
align-self: start !important;
text-align: left !important;
padding-left: 0 !important;
margin-left: 0 !important;
text-align: start !important;
padding-inline-start: 0 !important;
margin-inline-start: 0 !important;
font-size: 16px !important;
line-height: 1.2 !important;
}
@@ -1145,7 +1148,7 @@ form.inlined-form .list-composer {
.iphone-device .list.mobile-view .list-header .list-header-menu-icon {
/* Remove absolute positioning for iPhone devices */
position: static !important;
right: auto !important;
inset-inline-end: auto !important;
top: auto !important;
transform: none !important;
/* Use grid positioning */
@@ -1159,7 +1162,7 @@ form.inlined-form .list-composer {
.iphone-device .list.mobile-view .list-header .list-header-handle {
/* Remove absolute positioning for iPhone devices */
position: static !important;
right: auto !important;
inset-inline-end: auto !important;
top: auto !important;
transform: none !important;
/* Use grid positioning */
@@ -1216,7 +1219,7 @@ form.inlined-form .list-composer {
align-items: baseline;
}
.link-board-wrapper .js-link-board {
margin-left: 15px;
margin-inline-start: 15px;
}
.search-card-results {
max-height: 250px;
@@ -1321,8 +1324,8 @@ form.inlined-form .list-composer {
.list.list-collapsed:not(.mobile-view) .list-header .list-header-handle {
position: relative !important;
top: auto !important;
right: auto !important;
left: auto !important;
inset-inline-end: auto !important;
inset-inline-start: auto !important;
transform: none !important;
display: block !important;
margin: 4px auto 2px auto !important;
@@ -1337,8 +1340,8 @@ form.inlined-form .list-composer {
.list.list-collapsed:not(.mobile-view) .list-header .js-collapse {
position: relative !important;
top: auto !important;
left: auto !important;
right: auto !important;
inset-inline-start: auto !important;
inset-inline-end: auto !important;
display: block !important;
margin: 2px auto !important;
z-index: 400 !important;
@@ -1374,7 +1377,7 @@ form.inlined-form .list-composer {
transform: none !important;
writing-mode: vertical-lr !important;
text-orientation: mixed !important;
text-align: left !important;
text-align: start !important;
visibility: visible !important;
opacity: 1 !important;
}
@@ -1444,8 +1447,8 @@ form.inlined-form .list-composer {
flex: 0 0 auto;
position: static !important;
top: auto !important;
left: auto !important;
right: auto !important;
inset-inline-start: auto !important;
inset-inline-end: auto !important;
transform: none !important;
margin: 2px 2px 0 0 !important;
padding: 2px 4px !important;
@@ -1463,7 +1466,7 @@ form.inlined-form .list-composer {
flex: 1 0 100%;
min-width: 0;
padding-top: 4px !important;
padding-right: 0 !important;
padding-inline-end: 0 !important;
}
/* Hide standalone duplicate drag handle outside the menu group */
@@ -1475,7 +1478,7 @@ form.inlined-form .list-composer {
.list:not(.list-collapsed):not(.mobile-view) .list-header .list-header-watch-icon {
order: 2;
flex: 0 0 auto;
padding-left: 0;
padding-inline-start: 0;
align-self: center;
}
@@ -1486,7 +1489,7 @@ form.inlined-form .list-composer {
display: inline-flex !important;
align-items: center;
gap: 4px;
margin-left: auto;
margin-inline-start: auto;
margin-top: 2px;
flex: 0 0 auto;
}
@@ -1497,8 +1500,8 @@ form.inlined-form .list-composer {
.list:not(.list-collapsed):not(.mobile-view) .list-header .list-header-menu .js-open-list-menu {
position: static !important;
top: auto !important;
right: auto !important;
left: auto !important;
inset-inline-end: auto !important;
inset-inline-start: auto !important;
transform: none !important;
display: inline-flex !important;
align-items: center;
@@ -1533,7 +1536,7 @@ form.inlined-form .list-composer {
.list.list-collapsed .list-header .js-collapse {
position: relative !important;
left: -10px !important;
inset-inline-start: -10px !important;
color: #333;
background: transparent;
border: none;
+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
+4 -2
View File
@@ -355,9 +355,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 +370,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
);
},
+4
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
+29
View File
@@ -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();
+7 -7
View File
@@ -2,8 +2,8 @@
border-radius: 0 0 0.5vw 0.5vw;
min-width: min(400px, 52vw);
margin-bottom: 2.5vh;
margin-right: auto;
margin-left: auto;
margin-inline-end: auto;
margin-inline-start: auto;
border-width: 0.3vw;
border-style: solid;
border-color: #a2a2a2;
@@ -46,8 +46,8 @@
}
.my-cards-dueat-list-wrapper {
max-width: min(500px, 65vw);
margin-right: auto;
margin-left: auto;
margin-inline-end: auto;
margin-inline-start: auto;
}
.my-cards-board-table thead {
border-bottom: 3px solid #4d4d4d;
@@ -62,15 +62,15 @@
}
.my-cards-card-title-table {
font-weight: bold;
padding-left: 2px;
padding-inline-start: 2px;
max-width: 243px;
}
.my-cards-board-badge {
width: 36px;
height: 24px;
float: left;
float: inline-start;
border-radius: 5px;
margin-right: 5px;
margin-inline-end: 5px;
}
/* Accessibility page styles */
+2 -2
View File
@@ -10,8 +10,8 @@
}
.broken-cards-wrapper {
max-width: min(500px, 65vw);
margin-right: auto;
margin-left: auto;
margin-inline-end: auto;
margin-inline-start: auto;
}
.broken-cards-card-title {
font-weight: bold;
+2 -2
View File
@@ -1,5 +1,5 @@
.due-cards-dueat-list-wrapper {
max-width: min(500px, 65vw);
margin-right: auto;
margin-left: auto;
margin-inline-end: auto;
margin-inline-start: auto;
}
+5 -5
View File
@@ -1,19 +1,19 @@
.new-comment a.fa.fa-brands.fa-markdown,
.inlined-form a.fa.fa-brands.fa-markdown {
float: right;
float: inline-end;
position: absolute;
top: -10px;
right: 60px;
inset-inline-end: 60px;
}
.new-comment a.fa.fa-copy,
.inlined-form a.fa.fa-copy {
float: right;
float: inline-end;
position: relative;
top: -10px;
right: 5px;
inset-inline-end: 5px;
}
.js-inlined-form.viewer.btn-sm {
position: absolute;
top: 20px;
right: 6px;
inset-inline-end: 6px;
}
+10 -10
View File
@@ -5,8 +5,8 @@
border-color: #808080;
border-style: solid;
margin-bottom: 2rem;
margin-right: auto;
margin-left: auto;
margin-inline-end: auto;
margin-inline-start: auto;
}
.global-search-board-title {
font-size: 1.4rem;
@@ -48,8 +48,8 @@
}
.global-search-results-list-wrapper {
max-width: 500px;
margin-right: auto;
margin-left: auto;
margin-inline-end: auto;
margin-inline-start: auto;
}
.global-search-field-name {
font-weight: bold;
@@ -69,8 +69,8 @@
.global-search-page {
width: 40%;
min-width: 400px;
margin-right: auto;
margin-left: auto;
margin-inline-end: auto;
margin-inline-start: auto;
line-height: 150%;
}
.global-search-page h1 {
@@ -81,8 +81,8 @@
}
.global-search-query-input {
width: 90% !important;
margin-right: auto;
margin-left: auto;
margin-inline-end: auto;
margin-inline-start: auto;
}
.js-new-search {
cursor: pointer;
@@ -121,11 +121,11 @@ code {
}
.global-search-next-page {
border: none;
text-align: right;
text-align: end;
}
.global-search-previous-page {
border: none;
text-align: left;
text-align: start;
}
.global-search-instructions li {
margin-bottom: 0.3rem;
+35 -3
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,10 +68,11 @@ 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
+49 -15
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,32 @@ Template.globalSearch.onCreated(function () {
Template.globalSearch.onRendered(function () {
Meteor.subscribe('setting');
// eslint-disable-next-line no-console
//console.log('lang:', TAPi18n.getLanguage());
if (Session.get('globalQuery')) {
searchAllBoards(this, Session.get('globalQuery'));
}
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 || '');
}
});
});
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 +98,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 +118,7 @@ function searchAllBoards(tpl, queryText) {
return;
}
search.runGlobalSearch(query.getQueryParams());
search.runGlobalSearch(queryParams);
}
function errorMessages(tpl) {
+36 -36
View File
@@ -13,12 +13,12 @@
line-height: 1.7em;
padding: 0 10px;
margin: 0;
margin-right: 10px;
float: left;
margin-inline-end: 10px;
float: inline-start;
border-radius: 3px;
}
#header #header-main-bar h1 .board-header-watch-icon {
padding-left: 7px;
padding-inline-start: 7px;
}
#header #header-main-bar h1 a.fa,
#header #header-main-bar h1 a i.fa {
@@ -26,14 +26,14 @@
}
#header #header-main-bar h1 .back-btn {
font-size: 0.9em;
margin-right: 10px;
margin-inline-end: 10px;
}
#header #header-main-bar .wekan-logo {
margin: 3px auto auto;
width: 97px;
opacity: 0.6;
transition: opacity 0.15s;
float: right;
float: inline-end;
}
#header #header-main-bar .wekan-logo:hover {
opacity: 0.9;
@@ -44,10 +44,10 @@
width: auto;
}
#header #header-main-bar .board-header-btns.left {
float: left;
float: inline-start;
}
#header #header-main-bar .board-header-btns.right {
float: right;
float: inline-end;
}
#header #header-main-bar .board-header-btn {
border-radius: 3px;
@@ -55,13 +55,13 @@
padding: 0;
height: 28px;
font-size: 13px;
float: left;
float: inline-start;
overflow: hidden;
line-height: 28px;
margin: 0 12px;
}
#header #header-main-bar .board-header-btn i.fa {
float: left;
float: inline-start;
display: block;
line-height: 28px;
color: #f2f2f2;
@@ -70,16 +70,16 @@
#header #header-main-bar .board-header-btn i.fa + span {
display: inline-block;
margin-top: 1px;
margin-right: 10px;
margin-inline-end: 10px;
}
#header #header-main-bar .board-header-btn .board-header-btn-close {
float: right;
float: inline-end;
}
#header #header-main-bar .board-header-btn .board-header-btn-close i.fa {
margin: 0 6px;
}
#header #header-main-bar .board-header-btn .board-header-btn-icon {
float: left;
float: inline-start;
display: block;
line-height: 28px;
color: #27ae60;
@@ -100,9 +100,9 @@
}
#header #header-main-bar .separator {
margin: 2px 4px;
border-left: 1px solid rgba(255,255,255,0.3);
border-inline-start: 1px solid rgba(255,255,255,0.3);
height: 24px;
float: left;
float: inline-start;
}
/* On non-board pages (All Boards / home, public, my cards, …) the second bar
@@ -141,7 +141,7 @@ body:not(.board-view) #header #header-main-bar h1 {
#header-quick-access .home-icon {
display: flex;
align-items: center;
margin-right: 1rem;
margin-inline-end: 1rem;
flex-shrink: 0;
}
@@ -161,7 +161,7 @@ body:not(.board-view) #header #header-main-bar h1 {
#header-quick-access .home-icon .fa-home {
font-size: 16px;
margin-right: 4px;
margin-inline-end: 4px;
}
#header-quick-access .allBoards {
@@ -257,10 +257,10 @@ body:not(.board-view) #header #header-main-bar h1 {
}
#header-quick-access ul.header-quick-access-list li:first-child .fa-home,
#header-quick-access ul.header-quick-access-list li:nth-child(3) .fa-globe {
margin-right: 5px;
margin-inline-end: 5px;
}
#header-quick-access ul.header-quick-access-list li a.js-create-board {
margin-left: 5px;
margin-inline-start: 5px;
}
#header-quick-access #header-user-bar,
#header-quick-access #header-new-board-icon {
@@ -270,10 +270,10 @@ body:not(.board-view) #header #header-main-bar h1 {
margin: 2px 0;
}
#header-quick-access #header-user-bar .header-user-bar-avatar {
float: left;
float: inline-start;
position: relative;
top: -5px;
margin-right: 5px;
margin-inline-end: 5px;
}
#header-quick-access #header-user-bar .header-user-bar-avatar .member,
#header-quick-access #header-help {
@@ -286,7 +286,7 @@ body:not(.board-view) #header #header-main-bar h1 {
#header-quick-access #header-user-bar .header-user-bar-name,
#header-quick-access #header-help {
margin: 4px 8px 0 0;
float: left;
float: inline-start;
}
/* Zoom Controls in Header */
@@ -299,7 +299,7 @@ body:not(.board-view) #header #header-main-bar h1 {
border-radius: 0.5vw;
box-shadow: 0 0.2vh 0.5vh rgba(0,0,0,0.1);
margin: 0 1vw;
float: left;
float: inline-start;
}
#header-quick-access .zoom-controls .board-header-btn {
@@ -401,7 +401,7 @@ body:not(.board-view) #header #header-main-bar h1 {
display: flex;
align-items: center;
margin: 0 1vw;
float: left;
float: inline-start;
}
#header-quick-access .mobile-mode-toggle .board-header-btn {
@@ -486,7 +486,7 @@ body:not(.board-view) #header #header-main-bar h1 {
transform: scale(1.1) !important;
}
#header-quick-access #header-user-bar .header-user-bar-name i.fa-chevron-down {
margin-right: 4px;
margin-inline-end: 4px;
}
#header-quick-access #header-new-board-icon {
flex-grow: 1;
@@ -522,12 +522,12 @@ body:not(.board-view) #header #header-main-bar h1 {
/* Mobile home icon styling */
#header-quick-access .home-icon {
margin-right: 0.5rem;
margin-inline-end: 0.5rem;
}
#header-quick-access .home-icon .fa-home {
font-size: 16px;
margin-right: 4px;
margin-inline-end: 4px;
}
#header-quick-access .home-icon a {
@@ -550,7 +550,7 @@ body:not(.board-view) #header #header-main-bar h1 {
#header-quick-access .home-icon .fa-home {
font-size: 14px;
margin-right: 3px;
margin-inline-end: 3px;
}
}
@@ -612,7 +612,7 @@ body:not(.board-view) #header #header-main-bar h1 {
/* Mobile header wrapping and spacing */
#header-quick-access .home-icon {
flex-shrink: 0 !important;
margin-right: 0.5rem !important;
margin-inline-end: 0.5rem !important;
margin-bottom: 4px !important;
}
@@ -808,7 +808,7 @@ body:not(.board-view) #header #header-main-bar h1 {
/* iPhone 12 Mini header wrapping and spacing */
#header-quick-access .home-icon {
flex-shrink: 0 !important;
margin-right: 0.5rem !important;
margin-inline-end: 0.5rem !important;
margin-bottom: 6px !important;
}
@@ -868,11 +868,11 @@ body:not(.board-view) #header #header-main-bar h1 {
#header-quick-access .home-icon .fa-home {
font-size: 20px !important; /* Much larger icon */
margin-right: 6px !important;
margin-inline-end: 6px !important;
}
#header-quick-access .home-icon {
margin-right: 1rem !important;
margin-inline-end: 1rem !important;
}
/* Make zoom controls larger */
@@ -956,7 +956,7 @@ body:not(.board-view) #header #header-main-bar h1 {
/* iPhone device header wrapping and spacing */
.iphone-device #header-quick-access .home-icon {
flex-shrink: 0 !important;
margin-right: 0.5rem !important;
margin-inline-end: 0.5rem !important;
margin-bottom: 4px !important;
}
@@ -1057,7 +1057,7 @@ body:not(.board-view) #header #header-main-bar h1 {
/* iPhone 12 Mini header wrapping and spacing - JavaScript fallback */
.iphone-device #header-quick-access .home-icon {
flex-shrink: 0 !important;
margin-right: 0.5rem !important;
margin-inline-end: 0.5rem !important;
margin-bottom: 6px !important;
}
@@ -1167,7 +1167,7 @@ body:not(.board-view) #header #header-main-bar h1 {
#header-quick-access ul {
width: calc(100% - 60px);
margin-right: 10px;
margin-inline-end: 10px;
}
#header-quick-access ul li {
height: 100%;
@@ -1179,7 +1179,7 @@ body:not(.board-view) #header #header-main-bar h1 {
display: none;
}
#header-quick-access #header-user-bar {
right: auto;
inset-inline-end: auto;
padding: 10px;
margin: 0 0.5rem;
}
@@ -1261,7 +1261,7 @@ body:not(.board-view) #header #header-main-bar h1 {
display: none;
}
#header #header-user-bar {
float: right;
float: inline-end;
}
}
.announcement .viewer {
+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'}}")
i.fa.fa-times-thin(aria-hidden="true")
template(name="offlineWarning")
.offline-warning
+1 -1
View File
@@ -172,7 +172,7 @@ Template.header.events({
if (Utils.isMiniScreen()) {
FlowRouter.go('bookmarks');
} else {
Popup.open('bookmarksPopup')(evt);
Popup.open('bookmarks')(evt);
}
},
});
+1 -1
View File
@@ -7,7 +7,7 @@
}
.shortcuts-list .shortcuts-list-item .shortcuts-list-item-keys {
margin-top: 5px;
float: right;
float: inline-end;
}
.shortcuts-list .shortcuts-list-item .shortcuts-list-item-keys kbd {
padding: 5px 8px;
+134 -29
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;
@@ -119,8 +224,8 @@ body.mobile-mode #content {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
inset-inline-start: 0;
inset-inline-end: 0;
background: rgba(0,0,0,0.6);
z-index: 100;
overflow-y: auto;
@@ -139,7 +244,7 @@ body.mobile-mode #content {
}
#modal .modal-content .modal-close-btn {
display: block;
float: right;
float: inline-end;
font-size: clamp(18px, 4vw, 24px);
}
#modal .modal-content-wide {
@@ -156,7 +261,7 @@ body.mobile-mode #content {
}
#modal .modal-content-wide .modal-close-btn {
display: block;
float: right;
float: inline-end;
font-size: clamp(18px, 4vw, 24px);
}
h1 {
@@ -245,7 +350,7 @@ tt {
}
blockquote {
margin: 1vh 0 1vh 1vw;
border-left: 1px solid #ccc;
border-inline-start: 1px solid #ccc;
color: #666;
padding: 0 0 0 1vw;
}
@@ -264,12 +369,12 @@ td,
th {
vertical-align: top;
border-top: 1px solid #ccc;
border-left: 1px solid #ccc;
border-inline-start: 1px solid #ccc;
}
td,
th {
padding: 0.8vh 0.8vw;
border-right: 1px solid #ccc;
border-inline-end: 1px solid #ccc;
border-bottom: 1px solid #ccc;
}
th {
@@ -315,18 +420,18 @@ kbd {
text-align: center;
}
.left {
float: left;
float: inline-start;
}
.right {
float: right;
float: inline-end;
}
.first {
margin-left: 0;
padding-left: 0;
margin-inline-start: 0;
padding-inline-start: 0;
}
.last {
margin-right: 0;
padding-right: 0;
margin-inline-end: 0;
padding-inline-end: 0;
}
.top {
margin-top: 0;
@@ -441,7 +546,7 @@ a:not(.disabled).is-active i.fa {
line-height: 1.3;
}
.gutter {
margin-left: 5vw;
margin-inline-start: 5vw;
}
.viewer {
min-height: 2.5vh;
@@ -454,12 +559,12 @@ a:not(.disabled).is-active i.fa {
}
.viewer ol {
list-style-type: decimal;
padding-left: 3vw;
padding-inline-start: 3vw;
padding-bottom: 1.5vh;
}
.viewer ul {
list-style-type: initial;
padding-left: 3vw;
padding-inline-start: 3vw;
padding-bottom: 1.5vh;
}
.viewer em {
@@ -478,7 +583,7 @@ a:not(.disabled).is-active i.fa {
color: #333;
}
.basicTabs-container .tabs-list {
text-align: left;
text-align: start;
width: 100%;
}
.basicTabs-container .tabs-list .tab-item {
@@ -629,16 +734,16 @@ a:not(.disabled).is-active i.fa {
/* Ensure proper spacing for mobile header elements */
#header-quick-access .zoom-controls {
margin-left: auto;
margin-right: 8px;
margin-inline-start: auto;
margin-inline-end: 8px;
}
.mobile-mode-toggle {
margin-right: 8px;
margin-inline-end: 8px;
}
#header-user-bar {
margin-left: auto;
margin-inline-start: auto;
}
/* Ensure header elements don't wrap on very small screens */
@@ -858,56 +963,56 @@ a:not(.disabled).is-active i.fa {
}
.lds-roller div:nth-child(1):after {
top: 50px;
left: 50px;
inset-inline-start: 50px;
}
.lds-roller div:nth-child(2) {
animation-delay: -0.072s;
}
.lds-roller div:nth-child(2):after {
top: 54px;
left: 45px;
inset-inline-start: 45px;
}
.lds-roller div:nth-child(3) {
animation-delay: -0.108s;
}
.lds-roller div:nth-child(3):after {
top: 57px;
left: 39px;
inset-inline-start: 39px;
}
.lds-roller div:nth-child(4) {
animation-delay: -0.144s;
}
.lds-roller div:nth-child(4):after {
top: 58px;
left: 32px;
inset-inline-start: 32px;
}
.lds-roller div:nth-child(5) {
animation-delay: -0.18s;
}
.lds-roller div:nth-child(5):after {
top: 57px;
left: 25px;
inset-inline-start: 25px;
}
.lds-roller div:nth-child(6) {
animation-delay: -0.216s;
}
.lds-roller div:nth-child(6):after {
top: 54px;
left: 19px;
inset-inline-start: 19px;
}
.lds-roller div:nth-child(7) {
animation-delay: -0.252s;
}
.lds-roller div:nth-child(7):after {
top: 50px;
left: 14px;
inset-inline-start: 14px;
}
.lds-roller div:nth-child(8) {
animation-delay: -0.288s;
}
.lds-roller div:nth-child(8):after {
top: 45px;
left: 10px;
inset-inline-start: 10px;
}
.at-link {
color: #17683a;
+12 -7
View File
@@ -1,5 +1,5 @@
template(name="main")
html(lang="{{TAPi18n.getLanguage}}")
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)
+56 -2
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,10 @@ 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();
Meteor.call('getAuthenticationsEnabled', (_, result) => {
let enabledAuthenticationMethods = ['password']; // we show/hide this based on isPasswordLoginEnabled
@@ -57,9 +62,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 +196,12 @@ 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();
});
Template.userFormsLayout.helpers({
isLegalNoticeLinkExist() {
const currSet = Template.instance().currentSetting.get();
@@ -234,6 +254,16 @@ 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();
@@ -257,6 +287,30 @@ 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;
}
});
});
});
async function authentication(event, templateInstance) {
const match = $('#at-field-username_and_email').val();
const password = $('#at-field-password').val();
+7 -7
View File
@@ -2,8 +2,8 @@
border-radius: 0 0 0.5vw 0.5vw;
min-width: min(400px, 52vw);
margin-bottom: 2.5vh;
margin-right: auto;
margin-left: auto;
margin-inline-end: auto;
margin-inline-start: auto;
border-width: 0.3vw;
border-style: solid;
border-color: #a2a2a2;
@@ -46,8 +46,8 @@
}
.my-cards-dueat-list-wrapper {
max-width: min(500px, 65vw);
margin-right: auto;
margin-left: auto;
margin-inline-end: auto;
margin-inline-start: auto;
}
.my-cards-board-table thead {
border-bottom: 3px solid #4d4d4d;
@@ -62,13 +62,13 @@
}
.my-cards-card-title-table {
font-weight: bold;
padding-left: 2px;
padding-inline-start: 2px;
max-width: 243px;
}
.my-cards-board-badge {
width: 36px;
height: 24px;
float: left;
float: inline-start;
border-radius: 5px;
margin-right: 5px;
margin-inline-end: 5px;
}
+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)
+11
View File
@@ -1,5 +1,6 @@
import { CardSearchPaged } from '../../lib/cardSearch';
import { Utils } from '/client/lib/utils';
import { enablePageDragscroll, disablePageDragscroll } from '/client/lib/pageDragscroll';
Template.myCardsHeaderBar.helpers({
myCardsSort() {
@@ -40,6 +41,16 @@ Template.myCards.onCreated(function () {
Meteor.subscribe('setting');
});
Template.myCards.onRendered(function () {
// My Cards renders into <body>'s page scroll area (.wrapper inside #content),
// so enable drag-to-scroll on <body> like the All Boards page does.
enablePageDragscroll();
});
Template.myCards.onDestroyed(function () {
disablePageDragscroll();
});
Template.myCards.helpers({
userId() {
return Meteor.userId();
+26 -26
View File
@@ -50,7 +50,7 @@
white-space: nowrap;
}
.pop-over .header .back-btn {
float: left;
float: inline-start;
overflow: hidden;
width: 4vw;
transition: width 0.2s;
@@ -66,7 +66,7 @@
padding: 1.3vh 1.3vw 1.3vh 0.5vw;
position: absolute;
top: 0;
right: 0;
inset-inline-end: 0;
}
.pop-over.no-title .header {
background: none;
@@ -126,8 +126,8 @@
.pop-over[data-popup='memberMenuPopup'] > .header {
position: absolute;
top: 0;
left: 0;
right: 0;
inset-inline-start: 0;
inset-inline-end: 0;
margin-bottom: 0;
z-index: 1;
}
@@ -195,8 +195,8 @@
position: fixed !important;
bottom: 0 !important;
top: auto !important;
left: auto !important;
right: 20px !important;
inset-inline-start: auto !important;
inset-inline-end: 20px !important;
width: auto !important;
max-width: 450px !important;
height: 100vh !important;
@@ -415,21 +415,21 @@
padding: 1.3vh 1.3vw 1.3vh;
box-sizing: border-box;
/* Ensure content is not shifted left */
margin-left: 0 !important;
margin-inline-start: 0 !important;
}
/* Utility: remove left gutter inside specific popups */
.pop-over .content .flush-left {
margin-left: 0;
padding-left: 0;
margin-inline-start: 0;
padding-inline-start: 0;
width: 100%;
}
/* Swimlane popups: remove left gutter, align content fully left */
.pop-over .content form.swimlane-color-popup,
.pop-over .content .swimlane-height-popup {
margin-left: 0;
padding-left: 0;
margin-inline-start: 0;
padding-inline-start: 0;
width: 100%;
}
@@ -437,15 +437,15 @@
.pop-over .content form.swimlane-color-popup .palette-colors,
.pop-over .content form.edit-label .palette-colors,
.pop-over .content form.create-label .palette-colors {
margin-left: 0;
padding-left: 0;
margin-inline-start: 0;
padding-inline-start: 0;
width: 100%;
}
/* Color palette items: ensure proper positioning */
.pop-over .content .palette-colors .palette-color {
margin-left: 0;
margin-right: 2px;
margin-inline-start: 0;
margin-inline-end: 2px;
margin-bottom: 2px;
}
@@ -456,8 +456,8 @@
.pop-over .popup-container-depth-4,
.pop-over .popup-container-depth-5,
.pop-over .popup-container-depth-6 {
margin-left: 0 !important;
padding-left: 0 !important;
margin-inline-start: 0 !important;
padding-inline-start: 0 !important;
}
/* Ensure buttons don’t reserve left space; align to flow */
@@ -466,7 +466,7 @@
.pop-over .content .swimlane-height-popup .primary.confirm,
.pop-over .content .swimlane-height-popup .negate.wide.right {
float: none;
margin-left: 0;
margin-inline-start: 0;
}
.pop-over .content-container .content.no-height {
height: 0;
@@ -484,7 +484,7 @@
}
.pop-over.search-over .content {
padding: 8px 4px 8px 10px;
margin-right: 8px;
margin-inline-end: 8px;
}
.pop-over .at-form .at-error,
.pop-over .at-form .at-result {
@@ -550,7 +550,7 @@
.pop-over-list li > a .item-name {
display: block;
width: auto;
padding-right: 22px;
padding-inline-end: 22px;
}
.pop-over-list li > a:not(.disabled):hover {
background-color: #005377;
@@ -601,11 +601,11 @@
display: none;
position: absolute;
top: 6px;
right: 12px;
inset-inline-end: 12px;
color: #3cb500;
}
.pop-over-list .pop-over-list.checkable li.active a {
padding-right: 28px;
padding-inline-end: 28px;
}
.pop-over-list .pop-over-list.checkable li.active a .fa-check {
display: block;
@@ -618,7 +618,7 @@ body.grey-icons-enabled .pop-over-list .pop-over-list.checkable .fa-check {
border-bottom-color: transparent;
height: 30px;
position: absolute;
right: 0;
inset-inline-end: 0;
top: 0;
width: 60px;
z-index: 1;
@@ -638,7 +638,7 @@ body.grey-icons-enabled .pop-over-list .pop-over-list.checkable .fa-check {
.pop-over.miniprofile .miniprofile-header .avatar {
position: absolute;
top: 2px;
left: 2px;
inset-inline-start: 2px;
height: 50px;
width: 50px;
}
@@ -752,8 +752,8 @@ body.grey-icons-enabled .pop-over-list .pop-over-list.checkable .fa-check {
body.mobile-mode .pop-over {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
inset-inline-start: 0 !important;
inset-inline-end: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
+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
+1 -1
View File
@@ -11,7 +11,7 @@
height: 15px;
position: absolute;
top: 0;
left: 0;
inset-inline-start: 0;
-webkit-animation: sk-cubemove 1.8s infinite ease-in-out;
animation: sk-cubemove 1.8s infinite ease-in-out;
}
+1 -1
View File
@@ -7,7 +7,7 @@
width: 33%;
height: 33%;
background-color: #333;
float: left;
float: inline-start;
-webkit-animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out;
animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out;
}
@@ -13,7 +13,7 @@
opacity: 0.6;
position: absolute;
top: 0;
left: 0;
inset-inline-start: 0;
-webkit-animation: sk-bounce 2s infinite ease-in-out;
animation: sk-bounce 2s infinite ease-in-out;
}
@@ -1,7 +1,7 @@
section#notifications-drawer {
position: fixed;
top: 48px;
right: 0;
inset-inline-end: 0;
width: 400px;
background-color: #fafafa;
box-shadow: 0 1px 2px rgba(0,0,0,0.15);
@@ -16,7 +16,7 @@ section#notifications-drawer a:hover {
section#notifications-drawer .header {
position: fixed;
top: 48px;
right: 0;
inset-inline-end: 0;
width: calc(400px - 32px);
padding: 8px 16px;
background: #ededed;
@@ -25,7 +25,7 @@ section#notifications-drawer .header {
}
section#notifications-drawer .header .notification-menu-toggle {
position: absolute;
left: 16px;
inset-inline-start: 16px;
top: calc(50% - 12px);
font-size: 20px;
cursor: pointer;
@@ -37,7 +37,7 @@ section#notifications-drawer .header .notification-menu-toggle:hover {
}
section#notifications-drawer .header .notification-menu {
position: absolute;
left: 16px;
inset-inline-start: 16px;
top: 44px;
background: white;
border: 1px solid #dbdbdb;
@@ -74,13 +74,13 @@ section#notifications-drawer .header .notification-menu .menu-item.selected {
section#notifications-drawer .header .notification-menu .menu-item .check-icon {
width: 20px;
min-width: 20px;
margin-right: 8px;
margin-inline-end: 8px;
text-align: center;
color: #2980b9;
font-weight: bold;
}
section#notifications-drawer .header .notification-menu .menu-item .menu-icon {
margin-right: 8px;
margin-inline-end: 8px;
font-size: 16px;
}
section#notifications-drawer .header h5 {
@@ -90,7 +90,7 @@ section#notifications-drawer .header h5 {
section#notifications-drawer .header .close {
position: absolute;
top: calc(50% - 12px);
right: 12px;
inset-inline-end: 12px;
font-size: 24px;
height: 24px;
line-height: 24px;
@@ -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);
},
});
+210 -19
View File
@@ -1,8 +1,170 @@
/* 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;
padding-right: 0.7vw;
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;
@@ -12,7 +174,7 @@
}
.rules-lists-item p {
display: inline-block;
float: left;
float: inline-start;
margin: revert;
}
.hide-element {
@@ -23,7 +185,7 @@
}
.rules-btns-group {
position: absolute;
right: 0;
inset-inline-end: 0;
top: 50%;
transform: translateY(-50%);
}
@@ -38,13 +200,13 @@
}
.rules-add input {
display: inline-block;
float: right;
float: inline-end;
margin: auto;
margin-right: 1.3vw;
margin-inline-end: 1.3vw;
}
.rules-add button {
display: inline-block;
float: right;
float: inline-end;
margin: auto;
}
.rules-back {
@@ -55,9 +217,9 @@
}
.rules-back button {
display: inline-block;
float: right;
float: inline-end;
margin: auto;
margin-right: 14px;
margin-inline-end: 14px;
}
.rules-back .js-goback .fa-arrow-left {
color: #fff !important;
@@ -134,7 +296,7 @@
position: absolute;
top: 50%;
transform: translateY(-50%);
left: 10px;
inset-inline-start: 10px;
}
.triggers-content .triggers-body .triggers-main-body .trigger-item .trigger-content .trigger-text {
font-size: 16px;
@@ -153,7 +315,7 @@
box-shadow: 0 0 0 2px #666 inset;
}
.triggers-content .triggers-body .triggers-main-body .trigger-item .trigger-content .trigger-text.trigger-text-email {
margin-left: 5px;
margin-inline-start: 5px;
margin-top: 10px;
margin-bottom: 10px;
}
@@ -164,7 +326,7 @@
width: auto;
height: 30px;
margin: 0px;
margin-left: 5px;
margin-inline-start: 5px;
}
.triggers-content .triggers-body .triggers-main-body .trigger-item .trigger-content .trigger-dropdown input {
display: inline-block;
@@ -172,8 +334,8 @@
margin: 0;
}
.triggers-content .triggers-body .triggers-main-body .trigger-item .trigger-content-mail {
left: 20px;
right: 100px;
inset-inline-start: 20px;
inset-inline-end: 100px;
}
.triggers-content .triggers-body .triggers-main-body .trigger-item .trigger-button {
position: absolute;
@@ -186,7 +348,7 @@
box-shadow: inset -1px -1px 3px rgba(0,0,0,0.05);
text-align: center;
font-size: 20px;
right: 10px;
inset-inline-end: 10px;
}
.triggers-content .triggers-body .triggers-main-body .trigger-item .trigger-button i {
position: absolute;
@@ -203,7 +365,7 @@
top: 30px;
}
.triggers-content .triggers-body .triggers-main-body .trigger-item .trigger-button.trigger-button-person {
right: -40px;
inset-inline-end: -40px;
}
.triggers-content .triggers-body .triggers-main-body .trigger-item .trigger-button.trigger-button-color {
top: unset;
@@ -211,10 +373,39 @@
transform: unset;
font-size: 16px;
width: auto;
padding-left: 10px;
padding-right: 10px;
padding-inline-start: 10px;
padding-inline-end: 10px;
height: 40px;
}
.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
+81 -5
View File
@@ -1,15 +1,91 @@
import { ReactiveCache } from '/imports/reactiveCache';
import Actions from '/models/actions';
import Rules from '/models/rules';
import Triggers from '/models/triggers';
function boardRuleIds() {
const boardId = Session.get('currentBoard');
return ReactiveCache.getRules({ boardId }).map(r => r._id);
}
function getSelected() {
return Session.get('selectedRuleIds') || [];
}
function setSelected(ids) {
Session.set('selectedRuleIds', ids);
}
Template.rulesList.onCreated(function () {
this.subscribe('allRules');
this.autorun(() => {
const boardId = Session.get('currentBoard');
if (boardId) this.subscribe('boardRules', boardId);
});
this.editingRuleId = new ReactiveVar(null);
});
Template.rulesList.helpers({
rules() {
const boardId = Session.get('currentBoard');
const ret = ReactiveCache.getRules({
boardId,
});
return ret;
return ReactiveCache.getRules({ boardId });
},
canAdmin() {
const user = ReactiveCache.getCurrentUser();
return user && (user.isAdmin || user.isBoardAdmin);
},
isSelected() {
return getSelected().includes(this._id);
},
isEditing() {
return Template.instance().editingRuleId.get() === this._id;
},
});
Template.rulesList.events({
'change .js-rule-select'(event) {
const ruleId = event.currentTarget.getAttribute('data-rule-id');
const selected = new Set(getSelected());
if (event.currentTarget.checked) {
selected.add(ruleId);
} else {
selected.delete(ruleId);
}
setSelected([...selected]);
},
'click .js-rules-select-all'() {
setSelected(boardRuleIds());
},
'click .js-rules-select-none'() {
setSelected([]);
},
'click .js-rules-delete-selected'() {
getSelected().forEach(ruleId => {
const rule = ReactiveCache.getRule(ruleId);
if (rule) {
Rules.remove(rule._id);
Actions.remove(rule.actionId);
Triggers.remove(rule.triggerId);
}
});
setSelected([]);
},
'click .js-rules-export-selected': Popup.open('rulesImportExport'),
// Inline rename of a rule.
'click .js-edit-rule'(event, tpl) {
tpl.editingRuleId.set(this._id);
},
'keydown .js-edit-rule-input'(event, tpl) {
if (event.key === 'Enter') {
const title = event.currentTarget.value.trim();
if (title) Rules.update(this._id, { $set: { title } });
tpl.editingRuleId.set(null);
} else if (event.key === 'Escape') {
tpl.editingRuleId.set(null);
}
},
'blur .js-edit-rule-input'(event, tpl) {
const title = event.currentTarget.value.trim();
if (title) Rules.update(this._id, { $set: { title } });
tpl.editingRuleId.set(null);
},
});
+31 -8
View File
@@ -1,9 +1,32 @@
template(name="rulesHeaderBar")
h1
i.fa.fa-magic
| {{_ 'r-board-rules'}}
.board-header-btns.left
a.board-header-btn.js-rules-back-to-board(title="{{_ 'back'}}")
i.fa.fa-arrow-left
| {{_ 'board'}}
a.board-header-btn.js-rules-toggle-view(title="{{_ 'r-workflow-view'}}")
i.fa.fa-sitemap
if isWorkflowView
| {{_ 'r-list-view'}}
else
| {{_ 'r-workflow-view'}}
a.board-header-btn.js-rules-import-export(title="{{_ 'r-import-export'}}")
i.fa.fa-exchange
| {{_ 'r-import-export'}}
template(name="rulesMain")
if($eq rulesCurrentTab.get 'rulesList')
+rulesList
if($eq rulesCurrentTab.get 'trigger')
+rulesTriggers(ruleName=ruleName triggerVar=triggerVar)
if($eq rulesCurrentTab.get 'action')
+rulesActions(ruleName=ruleName triggerVar=triggerVar)
if($eq rulesCurrentTab.get 'ruleDetails')
+ruleDetails(ruleId=ruleId)
.wrapper
.rules-page(class=currentBoard.colorClass)
if($eq rulesCurrentTab.get 'rulesList')
if isWorkflowView
+rulesWorkflow
else
+rulesList
if($eq rulesCurrentTab.get 'trigger')
+rulesTriggers(ruleName=ruleName triggerVar=triggerVar)
if($eq rulesCurrentTab.get 'action')
+rulesActions(ruleName=ruleName triggerVar=triggerVar)
if($eq rulesCurrentTab.get 'ruleDetails')
+ruleDetails(ruleId=ruleId)
Loaded 100 of 481 files, more files were not shown because too many files have changed in this diff. Show more