mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 21:44:59 -05:00
Compare commits
No files matched your search
@@ -24,7 +24,7 @@ updates:
|
||||
# * 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.
|
||||
# WeKan currently runs METEOR@3.5-rc.2.
|
||||
#
|
||||
# So pin them here until Meteor 3.6 + Rspack 2.0 land, then revisit.
|
||||
- dependency-name: "@meteorjs/rspack"
|
||||
|
||||
@@ -9,6 +9,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@v5
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# Login against a Docker registry except on PR
|
||||
# https://github.com/docker/login-action
|
||||
|
||||
@@ -15,6 +15,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Build the Docker image
|
||||
run: docker build . --file Dockerfile --tag wekan:$(date +%s)
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
name: Meteor unit tests
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node 24
|
||||
uses: actions/setup-node@v6
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
run: meteor test --once --driver-package meteortesting:mocha
|
||||
|
||||
e2e-tests:
|
||||
name: Playwright E2E (${{ matrix.project }})
|
||||
name: Playwright E2E (${{ matrix.project }} ${{ matrix.shard }}/2)
|
||||
runs-on: ubuntu-24.04
|
||||
needs: unit-tests
|
||||
strategy:
|
||||
@@ -71,6 +71,12 @@ jobs:
|
||||
# Playwright bundles Chromium, Firefox and WebKit builds that all run
|
||||
# headless on the Linux runner (installed per-job below with --with-deps).
|
||||
project: [chromium, firefox, webkit]
|
||||
# Split each browser's ~245 specs across 2 parallel shards (6 jobs total)
|
||||
# to cut wall-clock time. Each shard job starts its own WeKan + Mongo, so
|
||||
# there is no shared-server contention between shards. Raise the shard
|
||||
# count (here and in the --shard flag below) once the baseline is green
|
||||
# and/or once E2E runs against a prebuilt bundle (cheaper per-job startup).
|
||||
shard: [1, 2]
|
||||
|
||||
services:
|
||||
mongodb:
|
||||
@@ -83,7 +89,7 @@ jobs:
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node 24
|
||||
uses: actions/setup-node@v6
|
||||
@@ -123,34 +129,54 @@ jobs:
|
||||
run: npx playwright install --with-deps ${{ matrix.project }}
|
||||
working-directory: tests/playwright
|
||||
|
||||
- name: Start WeKan in background
|
||||
- name: Build and start WeKan (production bundle)
|
||||
run: |
|
||||
# Run E2E against a PRODUCTION build instead of `meteor run` (dev mode).
|
||||
# In dev the client JS is served by the rspack dev server, which did not
|
||||
# serve the bundle reliably in headless CI: the browser received HTML for
|
||||
# the bundle ("Unexpected token '<'"), Meteor never initialised, and every
|
||||
# spec timed out. A production build bakes the client JS into the bundle,
|
||||
# served statically by the Meteor server at :3000.
|
||||
meteor build ./output --directory
|
||||
( cd output/bundle/programs/server && npm install )
|
||||
# WeKan's server/00checkStartup.js refuses to start unless WRITABLE_PATH
|
||||
# (used for attachment/file storage) is set and writable.
|
||||
mkdir -p "$PWD/wekan-writable"
|
||||
# Match the env the dev runner (rebuild-wekan.sh) and the specs expect:
|
||||
# WITH_API enables the REST API (specs 17/23/24/28), comment-editor
|
||||
# flag matches dev.
|
||||
MONGO_URL=mongodb://localhost:27017/wekan_test \
|
||||
ROOT_URL=http://localhost:3000 \
|
||||
PORT=3000 \
|
||||
meteor run --settings settings.json > wekan-run.log 2>&1 &
|
||||
# The first Meteor build is slow, so wait up to 20 minutes for the
|
||||
# server to start responding before giving up.
|
||||
for i in $(seq 1 120); do
|
||||
WRITABLE_PATH="$PWD/wekan-writable" \
|
||||
WITH_API=true \
|
||||
RICHER_CARD_COMMENT_EDITOR=false \
|
||||
node output/bundle/main.js > wekan-run.log 2>&1 &
|
||||
# The production server serves the client JS statically, so once :3000
|
||||
# responds the bundle is being served. Wait up to 10 minutes.
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf http://localhost:3000 > /dev/null; then
|
||||
echo "WeKan is up after $((i * 10))s"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for WeKan... attempt $i/120"
|
||||
echo "Waiting for WeKan... attempt $i/60"
|
||||
sleep 10
|
||||
done
|
||||
if ! curl -sf http://localhost:3000 > /dev/null; then
|
||||
echo "WeKan failed to start. Last 200 log lines:"
|
||||
tail -n 200 wekan-run.log
|
||||
echo "WeKan failed to start. Last 250 log lines:"
|
||||
tail -n 250 wekan-run.log
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
DISABLE_CAPTCHA: 'true'
|
||||
TOOL_NODE_FLAGS: --max-old-space-size=8192
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
DISABLE_CAPTCHA: 'true'
|
||||
|
||||
- name: Run Playwright tests (${{ matrix.project }})
|
||||
- name: Run Playwright tests (${{ matrix.project }} shard ${{ matrix.shard }}/2)
|
||||
run: |
|
||||
npx playwright test \
|
||||
--project=${{ matrix.project }} \
|
||||
--shard=${{ matrix.shard }}/2 \
|
||||
--reporter=github
|
||||
working-directory: tests/playwright
|
||||
env:
|
||||
@@ -164,7 +190,7 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-report-${{ matrix.project }}
|
||||
name: playwright-report-${{ matrix.project }}-shard${{ matrix.shard }}
|
||||
path: tests/playwright/playwright-report/
|
||||
retention-days: 14
|
||||
|
||||
@@ -172,7 +198,7 @@ jobs:
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-${{ matrix.project }}
|
||||
name: test-results-${{ matrix.project }}-shard${{ matrix.shard }}
|
||||
path: tests/playwright/test-results/
|
||||
retention-days: 7
|
||||
|
||||
@@ -192,7 +218,7 @@ jobs:
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node 24
|
||||
uses: actions/setup-node@v6
|
||||
@@ -227,27 +253,40 @@ jobs:
|
||||
- name: Install Chromium
|
||||
run: sudo apt-get install -y chromium-browser
|
||||
|
||||
- name: Start WeKan in background
|
||||
- name: Build and start WeKan (production bundle)
|
||||
run: |
|
||||
# Run a PRODUCTION build, not `meteor run` (dev): exactly like the
|
||||
# Playwright e2e job, because the rspack dev server does not serve the
|
||||
# client JS bundle in headless CI, so Meteor never initialises and the
|
||||
# puppeteer regression test times out in waitForMeteorGlobals(). The
|
||||
# production bundle serves the client statically. WRITABLE_PATH is
|
||||
# required by server/00checkStartup.js; WITH_API mirrors the dev runner.
|
||||
meteor build ./output --directory
|
||||
( cd output/bundle/programs/server && npm install )
|
||||
mkdir -p "$PWD/wekan-writable"
|
||||
MONGO_URL=mongodb://localhost:27017/wekan_regression \
|
||||
ROOT_URL=http://localhost:3000 \
|
||||
PORT=3000 \
|
||||
meteor run > wekan-run.log 2>&1 &
|
||||
# The first Meteor build is slow, so wait up to 20 minutes.
|
||||
for i in $(seq 1 120); do
|
||||
WRITABLE_PATH="$PWD/wekan-writable" \
|
||||
WITH_API=true \
|
||||
RICHER_CARD_COMMENT_EDITOR=false \
|
||||
node output/bundle/main.js > wekan-run.log 2>&1 &
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf http://localhost:3000 > /dev/null; then
|
||||
echo "WeKan is up after $((i * 10))s"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for WeKan... attempt $i/120"
|
||||
echo "Waiting for WeKan... attempt $i/60"
|
||||
sleep 10
|
||||
done
|
||||
if ! curl -sf http://localhost:3000 > /dev/null; then
|
||||
echo "WeKan failed to start. Last 200 log lines:"
|
||||
tail -n 200 wekan-run.log
|
||||
echo "WeKan failed to start. Last 250 log lines:"
|
||||
tail -n 250 wekan-run.log
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
TOOL_NODE_FLAGS: --max-old-space-size=8192
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
DISABLE_CAPTCHA: 'true'
|
||||
|
||||
- name: Run list-regressions e2e
|
||||
|
||||
@@ -64,7 +64,7 @@ permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
# Node.js major version that Meteor 3.5-beta bundles. Used for native module
|
||||
# Node.js major version that Meteor 3.5 bundles. Used for native module
|
||||
# rebuilds on every platform — it must match the amd64 build's Node ABI.
|
||||
env:
|
||||
NODE_VERSION: '24'
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
changelog: ${{ steps.changelog.outputs.content }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
@@ -186,13 +186,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout wekan repo (bumped main, with rebuilt API docs)
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
path: wekan
|
||||
|
||||
- name: Checkout wekan.fi website repo
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: wekan/wekan.fi
|
||||
token: ${{ secrets.WEKAN_REPO_TOKEN }}
|
||||
@@ -219,13 +219,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout wekan repo (for releases/release-charts.sh + version refs)
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
path: wekan
|
||||
|
||||
- name: Checkout wekan/charts repo (main + gh-pages)
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: wekan/charts
|
||||
token: ${{ secrets.WEKAN_REPO_TOKEN }}
|
||||
@@ -250,7 +250,7 @@ jobs:
|
||||
./wekan/releases/release-charts.sh "${{ inputs.new_version }}"
|
||||
|
||||
# ─── 2. Build amd64 bundle (full Meteor 3 build) ────────────────────────────
|
||||
# Meteor 3.5-beta installer supports only Linux x86_64/aarch64, macOS, Windows.
|
||||
# Meteor 3.5 installer supports only Linux x86_64/aarch64, macOS, Windows.
|
||||
# The full Meteor build always runs here on x86_64; every other platform then
|
||||
# only rebuilds the native npm modules from this bundle.
|
||||
build-amd64:
|
||||
@@ -260,7 +260,7 @@ jobs:
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: refs/tags/v${{ needs.prepare.outputs.version }}
|
||||
|
||||
@@ -411,7 +411,7 @@ jobs:
|
||||
# Checked out into src/ so start-wekan.bat and snapcraft.yaml (which pins
|
||||
# the MongoDB tools/mongosh versions) are available; the downloaded bundle
|
||||
# is extracted alongside it in the workspace root.
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: refs/tags/v${{ needs.prepare.outputs.version }}
|
||||
path: src
|
||||
@@ -590,7 +590,7 @@ jobs:
|
||||
sudo apt-get install -y curl unzip zip
|
||||
|
||||
- name: Set up QEMU for ${{ matrix.arch }} emulation
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Download amd64 bundle
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -650,7 +650,7 @@ jobs:
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: refs/tags/v${{ needs.prepare.outputs.version }}
|
||||
|
||||
@@ -714,33 +714,49 @@ jobs:
|
||||
# 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.
|
||||
# bundle's WeKan version (server/statistics.js reads
|
||||
# require('/package.json').version). Check the amd64 bundle's WeKan version
|
||||
# equals the release version BEFORE the expensive multi-arch buildx, so a
|
||||
# stale/mismatched bundle fails fast instead of shipping (and pushing to
|
||||
# three registries) a mislabeled image.
|
||||
#
|
||||
# Meteor does NOT ship the app package.json as a standalone file — it
|
||||
# inlines it into the compiled bundle/programs/server/app/app.js as a JSON
|
||||
# module, e.g. {"name":"wekan","version":"v9.63.0",...}. That inlined
|
||||
# module (anchored on "name":"wekan") is exactly what require('/package.json')
|
||||
# resolves to at runtime, so it is the authoritative thing to check.
|
||||
#
|
||||
# IMPORTANT: only a CONFIRMED mismatch fails the release. If the version
|
||||
# cannot be located (bundle layout changed, app.js minified differently),
|
||||
# warn and continue — inability to verify must not block a good release,
|
||||
# since --build-arg VERSION below is the actual guarantee. (An earlier
|
||||
# version of this guard grepped for a v-prefixed *package.json* and made
|
||||
# "not found" fatal, which false-failed the whole v9.63 docker build.)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
url="https://github.com/wekan/wekan/releases/download/v${VERSION}/wekan-${VERSION}-amd64.zip"
|
||||
wget -q "$url" -O bundle.zip
|
||||
tmp="$(mktemp -d)"
|
||||
# Extract only 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)"
|
||||
# Extract only the compiled app program (not the whole multi-hundred-MB
|
||||
# bundle); the inlined app package.json lives in app/app.js.
|
||||
unzip -o -q bundle.zip 'bundle/programs/server/app/app.js' -d "$tmp" || true
|
||||
appjs="$tmp/bundle/programs/server/app/app.js"
|
||||
found=""
|
||||
if [ -f "$appjs" ]; then
|
||||
match="$(grep -aoE '"name":"wekan","version":"v?[0-9][^"]*"' "$appjs" | head -1 || true)"
|
||||
found="$(printf '%s' "$match" | sed -E 's/.*"version":"([^"]*)".*/\1/')"
|
||||
fi
|
||||
rm -rf "$tmp" bundle.zip
|
||||
if [ -z "$found" ]; then
|
||||
echo "::error::Could not find a v-prefixed WeKan version in the bundle's package.json files."
|
||||
echo "::warning::Could not locate the WeKan version in the bundle (app.js layout may have changed); skipping the bundle-version check. --build-arg VERSION still guarantees the correct bundle."
|
||||
exit 0
|
||||
fi
|
||||
norm="${found#v}"
|
||||
if [ "$norm" != "${VERSION}.0" ] && [ "$norm" != "${VERSION}" ]; then
|
||||
echo "::error::Bundle reports app version ${found} but the release is v${VERSION}. The bundle baked into the image is stale/mismatched — the Admin Panel would show ${norm}. Aborting before build."
|
||||
exit 1
|
||||
fi
|
||||
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}."
|
||||
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`
|
||||
@@ -773,7 +789,7 @@ jobs:
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: refs/tags/v${{ needs.prepare.outputs.version }}
|
||||
# remote-build (Launchpad) rejects shallow clones, so fetch full history
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
+8
-8
@@ -6,7 +6,7 @@
|
||||
meteor-base@1.5.2
|
||||
|
||||
# Build system
|
||||
ecmascript@0.18.0
|
||||
ecmascript@0.18.1-rc350.0
|
||||
standard-minifier-js@3.2.0
|
||||
rspack@1.1.0
|
||||
|
||||
@@ -16,11 +16,11 @@ blaze@3.0.2
|
||||
aldeed:collection2
|
||||
aldeed:schema-index@4.0.0-beta.5
|
||||
reywood:publish-composite
|
||||
mongo@2.4.0-rc350.1
|
||||
mongo@2.4.0-rc350.2
|
||||
|
||||
# Account system
|
||||
accounts-password@3.3.0-rc350.1
|
||||
accounts-express@1.0.0-rc350.1
|
||||
accounts-password@3.3.0-rc350.2
|
||||
accounts-express@1.0.0-rc350.2
|
||||
communitypackages:core
|
||||
communitypackages:unstyled
|
||||
communitypackages:flow-routing
|
||||
@@ -37,7 +37,7 @@ wekan-accounts-oidc
|
||||
check@1.5.0
|
||||
random@1.2.2
|
||||
reactive-dict@1.3.2
|
||||
session@1.2.3-rc350.1
|
||||
session@1.2.3-rc350.2
|
||||
tracker@1.3.4
|
||||
audit-argument-checks@1.0.8
|
||||
|
||||
@@ -45,7 +45,7 @@ audit-argument-checks@1.0.8
|
||||
ostrio:i18n
|
||||
reactive-var@1.0.13
|
||||
shell-server@0.7.0
|
||||
email@3.2.0-rc350.1
|
||||
email@3.2.0-rc350.2
|
||||
dynamic-import@0.7.4
|
||||
wekan-markdown
|
||||
quave:synced-cron
|
||||
@@ -55,8 +55,8 @@ ostrio:files
|
||||
meteortesting:mocha@3.0.0
|
||||
matb33:collection-hooks
|
||||
service-configuration@1.3.5
|
||||
minifier-css@2.0.2-rc350.1
|
||||
ejson@1.2.0-rc350.1
|
||||
minifier-css@2.0.2-rc350.2
|
||||
ejson@1.2.0-rc350.2
|
||||
logging@1.3.6
|
||||
wekan-fullcalendar
|
||||
wekan-fontawesome
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
METEOR@3.5-rc.1
|
||||
METEOR@3.5-rc.2
|
||||
+28
-28
@@ -1,44 +1,44 @@
|
||||
accounts-base@3.3.0-rc350.1
|
||||
accounts-express@1.0.0-rc350.1
|
||||
accounts-oauth@1.4.7-rc350.1
|
||||
accounts-password@3.3.0-rc350.1
|
||||
accounts-base@3.3.0-rc350.2
|
||||
accounts-express@1.0.0-rc350.2
|
||||
accounts-oauth@1.4.7-rc350.2
|
||||
accounts-password@3.3.0-rc350.2
|
||||
aldeed:collection2@4.2.0
|
||||
aldeed:schema-index@4.0.0
|
||||
aldeed:simple-schema@1.13.1
|
||||
allow-deny@2.1.0
|
||||
audit-argument-checks@1.0.8
|
||||
autoupdate@2.0.1
|
||||
babel-compiler@7.14.0
|
||||
babel-compiler@7.14.1-rc350.0
|
||||
babel-runtime@1.5.2
|
||||
base64@1.0.14-rc350.1
|
||||
binary-heap@1.0.13-rc350.1
|
||||
base64@1.0.14-rc350.2
|
||||
binary-heap@1.0.13-rc350.2
|
||||
blaze@3.0.3
|
||||
blaze-tools@2.0.1
|
||||
boilerplate-generator@2.1.0
|
||||
caching-compiler@2.0.1
|
||||
caching-html-compiler@2.0.1
|
||||
callback-hook@1.8.0-rc350.1
|
||||
callback-hook@1.8.0-rc350.2
|
||||
check@1.5.0
|
||||
communitypackages:core@2.0.3
|
||||
communitypackages:flow-routing@2.1.0
|
||||
communitypackages:unstyled@2.0.1
|
||||
core-runtime@1.0.0
|
||||
ddp@1.4.2
|
||||
ddp-client@3.3.0-rc350.1
|
||||
ddp-common@1.4.5-rc350.1
|
||||
ddp-rate-limiter@1.3.0-rc350.1
|
||||
ddp-server@3.3.0-rc350.1
|
||||
ddp-client@3.3.0-rc350.2
|
||||
ddp-common@1.4.5-rc350.2
|
||||
ddp-rate-limiter@1.3.0-rc350.2
|
||||
ddp-server@3.3.0-rc350.2
|
||||
diff-sequence@1.1.3
|
||||
dynamic-import@0.7.4
|
||||
ecmascript@0.18.0
|
||||
ecmascript@0.18.1-rc350.0
|
||||
ecmascript-runtime@0.8.3
|
||||
ecmascript-runtime-client@0.13.0
|
||||
ecmascript-runtime-server@0.11.1
|
||||
ejson@1.2.0-rc350.1
|
||||
email@3.2.0-rc350.1
|
||||
ejson@1.2.0-rc350.2
|
||||
email@3.2.0-rc350.2
|
||||
es5-shim@4.8.1
|
||||
facts-base@1.0.2
|
||||
fetch@0.2.0-rc350.1
|
||||
fetch@0.2.0-rc350.2
|
||||
geojson-utils@1.0.12
|
||||
hot-code-push@1.0.5
|
||||
html-tools@2.0.1
|
||||
@@ -53,23 +53,23 @@ lai:collection-extensions@1.0.0
|
||||
localstorage@1.2.1
|
||||
logging@1.3.6
|
||||
matb33:collection-hooks@2.1.0
|
||||
meteor@2.3.1-rc350.1
|
||||
meteor@2.3.1-rc350.2
|
||||
meteor-base@1.5.2
|
||||
meteortesting:browser-tests@1.8.0
|
||||
meteortesting:mocha@3.3.0
|
||||
meteortesting:mocha-core@8.2.0
|
||||
minifier-css@2.0.2-rc350.1
|
||||
minifier-js@3.2.0
|
||||
minimongo@2.2.0-rc350.1
|
||||
minifier-css@2.0.2-rc350.2
|
||||
minifier-js@3.2.1-rc350.0
|
||||
minimongo@2.2.0-rc350.2
|
||||
modern-browsers@0.2.3
|
||||
modules@0.20.3
|
||||
modules-runtime@0.13.2
|
||||
mongo@2.4.0-rc350.1
|
||||
mongo@2.4.0-rc350.2
|
||||
mongo-decimal@0.2.0
|
||||
mongo-dev-server@1.1.1
|
||||
mongo-id@1.0.9
|
||||
npm-mongo@6.16.1
|
||||
oauth@3.0.4-rc350.1
|
||||
oauth@3.0.4-rc350.2
|
||||
oauth2@1.3.3
|
||||
observe-sequence@2.0.1
|
||||
ordered-dict@1.2.0
|
||||
@@ -82,7 +82,7 @@ promise@1.0.0
|
||||
quave:synced-cron@2.4.0
|
||||
raix:eventemitter@1.0.0
|
||||
random@1.2.2
|
||||
rate-limit@1.2.0-rc350.1
|
||||
rate-limit@1.2.0-rc350.2
|
||||
react-fast-refresh@0.3.0
|
||||
reactive-dict@1.3.2
|
||||
reactive-var@1.0.13
|
||||
@@ -92,10 +92,10 @@ reywood:publish-composite@1.9.0
|
||||
routepolicy@1.1.2
|
||||
rspack@1.1.0
|
||||
service-configuration@1.3.5
|
||||
session@1.2.3-rc350.1
|
||||
session@1.2.3-rc350.2
|
||||
sha@1.0.10
|
||||
shell-server@0.7.0
|
||||
socket-stream-client@0.7.0-rc350.1
|
||||
socket-stream-client@0.7.0-rc350.2
|
||||
spacebars@2.0.1
|
||||
spacebars-compiler@2.0.1
|
||||
standard-minifier-js@3.2.0
|
||||
@@ -105,9 +105,9 @@ templating-runtime@2.0.2
|
||||
templating-tools@2.0.1
|
||||
tools-core@1.1.0
|
||||
tracker@1.3.4
|
||||
typescript@5.10.0
|
||||
url@1.3.6-rc350.1
|
||||
webapp@2.2.0-rc350.1
|
||||
typescript@5.10.1-rc350.0
|
||||
url@1.3.6-rc350.2
|
||||
webapp@2.2.0-rc350.2
|
||||
webapp-hashing@1.1.2
|
||||
wekan-accounts-cas@0.2.0
|
||||
wekan-accounts-lockout@1.1.0
|
||||
|
||||
+769
-5
@@ -4,19 +4,18 @@ Newest WeKan at these platforms:
|
||||
|
||||
- [Upgrade WeKan](https://wekan.fi/upgrade/)
|
||||
- [bundle zip at releases](https://github.com/wekan/wekan/releases), docs at https://wekan.fi/docs/
|
||||
- [Linux amd64, arm64](https://github.com/wekan/wekan/blob/main/docs/Platforms/FOSS/RaspberryPi/Raspberry-Pi.md)
|
||||
- [Linux amd64, arm64, s390x, ppc64le](https://github.com/wekan/wekan/blob/main/docs/Platforms/FOSS/RaspberryPi/Raspberry-Pi.md)
|
||||
- [Windows amd64/arm64](https://github.com/wekan/wekan/blob/main/docs/Platforms/Propietary/Windows/Offline.md)
|
||||
- [Mac arm64](https://github.com/wekan/wekan/blob/main/docs/Platforms/Propietary/Mac.md)
|
||||
- https://wekan.fi/install/
|
||||
- Snap Beta amd64/arm64
|
||||
- Docker amd64/arm64
|
||||
- Kubernetes Docker amd64/arm64
|
||||
- Bitnami MongoDB Docker images do not exist anymore. [MongoDump/MongoRestore to groundhog2k MongoDB images](https://github.com/wekan/charts/issues/45)
|
||||
- Fixing other platforms In Progress:
|
||||
- [Windows amd64/arm64](https://github.com/wekan/wekan/blob/main/docs/Platforms/Propietary/Windows/Offline.md)
|
||||
- [Mac amd64, works also with Rosetta2 at Apple Silicon](https://github.com/wekan/wekan/blob/main/docs/Platforms/Propietary/Mac.md)
|
||||
|
||||
Versions:
|
||||
|
||||
- WeKan 8.75 and newer uses Meteor 3.5-beta.x, [Node.js 24.x](https://nodejs.org/dist/latest-v24.x/),
|
||||
- WeKan 8.75 and newer uses Meteor 3.5-rc.x, [Node.js 24.x](https://nodejs.org/dist/latest-v24.x/),
|
||||
MongoDB 7.x (or 6.x), or [FerretDB2/PostgreSQL](https://github.com/wekan/wekan/blob/main/docs/Databases/FerretDB2-PostgreSQL.md)
|
||||
- WeKan 8.43 upgraded to Meteor 3.x, huge thanks to harryadel:
|
||||
- https://harryadel.com/dev-diary-24/
|
||||
@@ -26,6 +25,771 @@ Versions:
|
||||
- WeKan 8.00-8.06 had wrong raw database directory setting /var/snap/wekan/common/wekan and some cards were not visible,
|
||||
it was fixed at WeKan 8.07 where database directory is back to /var/snap/wekan/common and all cards are visible.
|
||||
|
||||
# TODO Later
|
||||
|
||||
Carried to a future release — investigated this session but not finished, with findings recorded for whoever picks
|
||||
them up next.
|
||||
|
||||
- **Need a runtime reproduction before a safe fix:**
|
||||
[#5730](https://github.com/wekan/wekan/issues/5730) (board vanished after adding a user — no logs/repro).
|
||||
- **Need specific infrastructure we cannot reproduce/verify here (left for environment owners):**
|
||||
[#4560](https://github.com/wekan/wekan/issues/4560) (LDAP→OIDC), [#3707](https://github.com/wekan/wekan/issues/3707)
|
||||
& [#3700](https://github.com/wekan/wekan/issues/3700) (LDAP), [#3575](https://github.com/wekan/wekan/issues/3575)
|
||||
(WebHooks), [#1192](https://github.com/wekan/wekan/issues/1192) (Sandstorm).
|
||||
- **Feature requests / behaviour-by-design rather than bugs:**
|
||||
[#5547](https://github.com/wekan/wekan/issues/5547), [#3843](https://github.com/wekan/wekan/issues/3843),
|
||||
[#3823](https://github.com/wekan/wekan/issues/3823), [#3138](https://github.com/wekan/wekan/issues/3138),
|
||||
[#2204](https://github.com/wekan/wekan/issues/2204) (restrict permanent delete to the Admin role).
|
||||
|
||||
# v9.71 2026-06-23 WeKan ® release
|
||||
|
||||
This release adds the following features:
|
||||
|
||||
- [Sort, search and paginate the All Boards page](https://github.com/wekan/wekan/issues/5799),
|
||||
[#5799](https://github.com/wekan/wekan/issues/5799): the All Boards page only offered the per-user manual drag order
|
||||
with no obvious way to sort by name. Added a **Sort** button (Custom drag order / Title A→Z / Title Z→A, stored per
|
||||
user), a **board-name search** box that spans every category (Starred, Templates, Remaining and all
|
||||
(sub)workspaces), and **pagination of the board icons** — in the sorted modes the current page is computed on the
|
||||
server (`getAllBoardsPage`) so only that page is rendered and the page scales to thousands of boards. The search box
|
||||
and pagination controls are placed like Admin Panel > People. Custom (manual drag order) stays unpaginated so
|
||||
drag-reordering keeps working. All server-side filtering/sorting resolves against the effective current user, so it
|
||||
also works when a GlobalAdmin impersonates a user; added Playwright impersonation tests
|
||||
(`tests/playwright/specs/38-impersonation.e2e.js`). Note: "Stop impersonating" is still a follow-up — impersonation
|
||||
is connection-scoped and currently reverts on a full page reload.
|
||||
Fixed in [f74ccbc8f](https://github.com/wekan/wekan/commit/f74ccbc8faf1c19f06775ca70306cf320e442dfb) and
|
||||
[c1da7a4a3](https://github.com/wekan/wekan/commit/c1da7a4a332cd10bbd2ed3c93122fe0ea8864336).
|
||||
- [Fixed (same) width for all lists](https://github.com/wekan/wekan/issues/5729),
|
||||
[#5729](https://github.com/wekan/wekan/issues/5729): the Set width popup now has a "Same width for all lists" toggle.
|
||||
When enabled, every list on the board renders at one shared width for the current viewer, and dragging the resize
|
||||
handle of ANY list updates that single value so ALL lists change together. The setting is per-viewer/per-board and
|
||||
works for both logged-in users (stored in `profile.fixedListWidthBoards` / `profile.fixedListWidths`) and anonymous
|
||||
public-board users (stored in `localStorage` keys `wekan-fixed-list-width-enabled` / `wekan-fixed-list-width`).
|
||||
Enabling fixed width turns off auto-width (the two modes are mutually exclusive); widths below 270px are rejected.
|
||||
|
||||
and fixes the following bugs:
|
||||
|
||||
- [Due date does not work when the language uses non-Latin (e.g. Persian/Farsi) digits](https://github.com/wekan/wekan/issues/5752),
|
||||
[#5752](https://github.com/wekan/wekan/issues/5752): in locales such as Persian/Farsi or Arabic, dates and times can be
|
||||
rendered with non-Latin digits (Persian/Extended Arabic-Indic ۰۱۲۳۴۵۶۷۸۹, U+06F0–U+06F9, or Arabic-Indic ٠١٢٣٤٥٦٧٨٩,
|
||||
U+0660–U+0669). The native JavaScript `Date` constructor only understands ASCII digits, so any such string fails to
|
||||
parse and produces an Invalid Date when setting/updating or comparing dates. Added a `normalizeDigits()` helper in
|
||||
`imports/lib/dateUtils.js` that converts those non-Latin digits to ASCII, applied it at every date-string parse
|
||||
boundary in `dateUtils.js` (via an internal `toDate()` wrapper) and in the date/time picker submit/change handlers in
|
||||
`client/lib/datepicker.js`. The normal ASCII path is unchanged.
|
||||
- [Impossible to create a Link to a whole BOARD if that board already has cards](https://github.com/wekan/wekan/issues/5715),
|
||||
[#5715](https://github.com/wekan/wekan/issues/5715): in the link-card popup, choosing a board used to auto-populate the
|
||||
List/Card sub-selects, with no way to clear them, so once the chosen board had cards you could only link to a card and
|
||||
no longer to the whole board. The Swimlane/List/Card selects now default to a blank "(none)" option and are no longer
|
||||
auto-selected when a board is chosen, and the popup's confirm ("link") button now falls back to creating a board-level
|
||||
link when a board is selected but the Card field is left blank (matching the previously-only-empty-board behaviour).
|
||||
Added `tests/linkCardPopup.test.js` covering the positive and negative cases.
|
||||
- [Internal Server Error (500) when attempting to reset a password](https://github.com/wekan/wekan/issues/5706),
|
||||
[#5706](https://github.com/wekan/wekan/issues/5706): on the Forgot Password page, clicking "Email reset link" could
|
||||
return a raw HTTP 500 instead of sending the reset email / showing success. This happens when the server's SMTP is not
|
||||
configured (no `MAIL_URL` / `MAIL_FROM`, or a bad mail server): Meteor's `Email.sendAsync` throws, and the exception
|
||||
propagated unhandled out of the `forgotPassword` method as an opaque 500. The reset-password / verify-email /
|
||||
enroll-account email-template builders are now hardened so they never throw (guarded user name/language lookups and a
|
||||
safe fallback if translation fails), and `Accounts.sendResetPasswordEmail` is wrapped so a send failure surfaces as a
|
||||
clean `Meteor.Error('email-fail', ...)` instead of a 500. Note: this makes the failure graceful, but to actually
|
||||
receive reset emails you must still configure SMTP (Admin Panel mail server, or `MAIL_URL` and `MAIL_FROM`). Logic
|
||||
extracted to `server/lib/resetPasswordEmail.js` and unit-tested in `tests/unit/resetPasswordEmail.test.js`.
|
||||
- [Changing the UI language had no effect for some languages (e.g. Chinese zh-CN, zh-Hans/zh-Hant, Arabic ar-*) while others (de, fr) worked](https://github.com/wekan/wekan/issues/5756),
|
||||
[#5756](https://github.com/wekan/wekan/issues/5756): the custom tap:i18n reimplementation registered each language's
|
||||
resource bundle under the raw Wekan tag, but i18next (with `cleanCode: true`) resolves and looks translations up under a
|
||||
normalised code. For region/script-tagged and legacy underscore tags (`af_ZA`, `en_AU`, …) the stored code and the
|
||||
lookup code disagreed, so the bundle was never found and the UI silently fell back to English. The loader now normalises
|
||||
every i18next call (`supportedLngs`, `addResourceBundle`, `changeLanguage`, `t`) through the same code — underscores
|
||||
converted to hyphens and run through i18next's `formatLanguageCode` — so storage and lookup always agree for ALL
|
||||
supported languages. Dynamic JSON imports that resolve to an ES-module namespace are now unwrapped safely (without
|
||||
mistaking the data's own `"default"` translation key for the module's default export), and the language-switch handlers
|
||||
in the user header and login form now surface a failed load instead of leaving the UI silently in English. Added
|
||||
positive and negative regression tests in `imports/i18n/i18n.test.js`.
|
||||
- [Card labels took two lines / double height on minicards, wasting vertical space](https://github.com/wekan/wekan/issues/6424),
|
||||
[#6424](https://github.com/wekan/wekan/issues/6424): each label's name is rendered inside a `.viewer`, whose global
|
||||
`min-height: 2.5vh` (intended for the full content editor) forced every minicard label to roughly double height. The
|
||||
minicard text labels now reset that `min-height`, render the viewer/paragraph inline and stay on a single compact line.
|
||||
Fixed in [afbabccd2](https://github.com/wekan/wekan/commit/afbabccd2685af77ffddc1038f62823882615b74).
|
||||
- [REST API: moving a card to another list (PUT .../cards/:cardId with listId) returned HTTP 500 — "fieldNames.includes is not a function"](https://github.com/wekan/wekan/issues/6423),
|
||||
[#6423](https://github.com/wekan/wekan/issues/6423): the list-move path called the `cardMove()` activity helper with
|
||||
`{ fieldName: 'listId' }` (a plain object) as its `fieldNames` argument, but `cardMove()` does
|
||||
`fieldNames.includes('boardId')`, so the object's missing `.includes` threw and the endpoint returned HTTP 500. The
|
||||
changed field names are now passed as an array (`['listId']`), matching the cross-board move path.
|
||||
Fixed in [69c899f1f](https://github.com/wekan/wekan/commit/69c899f1f99b0ea984d3b36f5d2a2aac67c4ff9a).
|
||||
- [Notification emails were sent in English despite the user's language setting](https://github.com/wekan/wekan/issues/5875),
|
||||
[#5875](https://github.com/wekan/wekan/issues/5875): on the server only the default (English) translation bundle is
|
||||
loaded at startup, so translating a notification to the user's language fell back to English because i18next never had
|
||||
that language's bundle. Added `TAPi18n.ensureLanguageLoaded()` (loads the bundle on demand) and await it before
|
||||
translating in the email notification, `EmailLocalization.sendEmail` and outgoing webhook paths.
|
||||
Fixed in [22f996903](https://github.com/wekan/wekan/commit/22f996903b09d35e3aa959a9aa65b30204e12202).
|
||||
|
||||
# v9.70 2026-06-23 WeKan ® release
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
- **Copying a card to or from a board with no labels threw.** `Card.copy()`'s cross-board label-remap did
|
||||
`oldBoard.labels.filter(...)` / `filterCopiedLabelIds(newBoard.labels, …)` without guarding a missing `labels` array.
|
||||
Most boards always have default labels, but a board **created via the REST API** (`POST /api/boards`) has no `labels`
|
||||
array — so copying a card to/from such a board (or copying the board itself, which copies its cards) threw
|
||||
`Cannot read properties of undefined (reading 'filter')` and returned HTTP 500. Both label lookups now fall back to
|
||||
`[]`. Cross-board copy regressions in `tests/playwright/specs/17-rest-api.e2e.js`.
|
||||
- [Copying a board did not copy its webhooks](https://github.com/wekan/wekan/issues/5592),
|
||||
[#5592](https://github.com/wekan/wekan/issues/5592): `board.copy()` duplicated swimlanes/lists/cards, custom fields
|
||||
and rules/triggers/actions, but had no loop for **Integrations** (outgoing webhooks), so a copied board lost all of
|
||||
them (even though they are per-board children — `boardRemover` deletes them by `boardId`). `copy()` now also copies
|
||||
the board's Integrations, remapping `boardId` (URL/token/activities carry over — the copying user is a board admin
|
||||
with access to them). Regression in `tests/playwright/specs/17-rest-api.e2e.js`.
|
||||
- [Disabled user accounts could be added to boards](https://github.com/wekan/wekan/issues/1894),
|
||||
[#1894](https://github.com/wekan/wekan/issues/1894): none of the add-member paths checked the target account's
|
||||
`loginDisabled` flag, so a deactivated user could be invited/added and assigned to cards. `inviteUserToBoard` now
|
||||
throws `error-user-disabled`, and the REST `POST /api/boards/:boardId/members/:userId/add` endpoint returns HTTP 400,
|
||||
when the target account is disabled (re-enabling the account allows the add again). Regression in
|
||||
`tests/playwright/specs/17-rest-api.e2e.js`.
|
||||
- [Admin Panel boards report listed removed members as current members](https://github.com/wekan/wekan/issues/5122),
|
||||
[#5122](https://github.com/wekan/wekan/issues/5122): removing a member from a board marks the member entry
|
||||
`isActive: false` (it is kept in `board.members` for role history / re-activation), but the Admin Panel → Reports →
|
||||
Boards member column listed **all** member entries, so removed users still appeared as members. The report now
|
||||
filters to active members (`isActive !== false`). (The raw `GET /api/boards/:boardId` response intentionally still
|
||||
returns the full `members` array with each entry's `isActive` flag, so API consumers can filter as they need.)
|
||||
- [Could not remove a deleted user from a card's members](https://github.com/wekan/wekan/issues/4847),
|
||||
[#4847](https://github.com/wekan/wekan/issues/4847) (card side): when a user account is deleted, its entry stays in a
|
||||
card's `members`, rendering as a blank avatar. Clicking it opened the member popup, whose template dereferenced the
|
||||
now-missing user document (`user.profile.fullname` / `user.username`) and failed to render — so there was no way to
|
||||
remove the orphaned member. The popup now detects a missing user, shows a **"Deleted user"** entry with the raw id, and
|
||||
keeps the **Remove from Card** control (the remove handler keys off the member's `userId`, not the user document), so
|
||||
orphaned card members can be removed. (No automated regression — a self-evident Blaze template guard; the underlying
|
||||
`unassignMember` removal already worked. The board-members *list* still hides such entries because `activeMembers()`
|
||||
also intentionally filters members whose user doc is merely not-yet-loaded, so surfacing board-side orphans cleanly is
|
||||
a separate follow-up.)
|
||||
- [A newly added board member was missing from the card members popup](https://github.com/wekan/wekan/issues/4965),
|
||||
[#4965](https://github.com/wekan/wekan/issues/4965): the card "add members" popup snapshotted the board's member list
|
||||
**once** when it opened, so a member added to the board afterwards (or whose user document finished loading just after
|
||||
the popup opened) did not appear until the popup was reopened. The popup now derives its candidate list **reactively**
|
||||
(it stores only the filter term and re-reads `board.activeMembers()` on each render), so newly-added members show up
|
||||
without reopening. No change to `activeMembers()` itself (its deleted-user filtering is unchanged).
|
||||
- [Editing a linked card you cannot write to failed silently](https://github.com/wekan/wekan/issues/5809),
|
||||
[#5809](https://github.com/wekan/wekan/issues/5809): a linked card whose target lives on a board the user cannot
|
||||
write to (e.g. a private board) rejected title/description edits at the server allow rule, but the card-detail edit
|
||||
handlers had no error handling, so the edit just vanished with no feedback. The title and description submit handlers
|
||||
now catch the failure and show the error (mirroring the existing label-color handler), so the user sees why the edit
|
||||
did not save. (No automated regression — surfacing a permission denial across a private linked board is a UX/error
|
||||
path that is not cleanly reproducible in the test harness.)
|
||||
- **Performance: copying a card with many checklist items took minutes** ([#5688](https://github.com/wekan/wekan/issues/5688)).
|
||||
`Checklist.copy()` duplicated each checklist item with the hooked `insertAsync`, so collection-hooks fired each item's
|
||||
`after.insert` — every one doing a `getCard` and inserting an `addChecklistItem` activity. Copying a card with ~100
|
||||
items meant ~100+ activity inserts (plus per-item DB round-trips), taking minutes and spiking CPU. The copy now uses
|
||||
`.direct` for the checklist and its items (skipping the per-item activity hooks — pure churn for a wholesale copy) and
|
||||
sets `boardId` itself, the same approach as the `cardRemover` delete path. Copy regression (checklist + all items
|
||||
duplicated) in `tests/playwright/specs/17-rest-api.e2e.js`.
|
||||
- **Copying a card to another board orphaned its subtasks on the old board**
|
||||
([#5347](https://github.com/wekan/wekan/issues/5347) data symptom): `Card.copy()` re-pointed each subtask's
|
||||
`parentId` to the copied card but left its `boardId`/`swimlaneId`/`listId` pointing at the **source** board — so a
|
||||
cross-board copy created subtasks stranded on the original board whose parent lives on another board (and it mutated
|
||||
the cached source docs in place). Copied subtasks are now re-homed onto the destination board/swimlane/list alongside
|
||||
the copied parent, via a fresh object (no cache mutation). Cross-board copy regression in
|
||||
`tests/playwright/specs/17-rest-api.e2e.js`. (This fixes the orphaned-cross-board-subtask data symptom noted in
|
||||
#5347; the separate "Maximum call stack" error there is not yet root-caused — see the issue.)
|
||||
- [Error when clicking the notification icon](https://github.com/wekan/wekan/issues/5325),
|
||||
[#5325](https://github.com/wekan/wekan/issues/5325): a notification whose referenced activity no longer existed (its
|
||||
card/board was deleted) left an entry whose `activityObj` was null, and the notifications drawer dereferences it
|
||||
(`activity.user`, `activity._id`, …) — so one orphaned notification threw and broke the whole popup. `user.notifications()`
|
||||
now drops entries whose activity can't be resolved. (No automated regression — an orphaned-notification state is not
|
||||
cleanly reproducible in the harness; the fix is a self-evident filter.)
|
||||
- [Deleting a custom field from a board could throw](https://github.com/wekan/wekan/issues/5390),
|
||||
[#5390](https://github.com/wekan/wekan/issues/5390): removing a (multi-board) custom field from one board runs a
|
||||
`before.update` hook that logged a `setCustomField` activity by reading `(await getActivity({customFieldId})).value` —
|
||||
with **no null guard**, so a field that never had a value set (no such activity) threw `Cannot read properties of
|
||||
undefined` and aborted the removal. The lookup is now guarded. ([server/models/customFields.js](server/models/customFields.js))
|
||||
- [Board "show checklists on minicard" setting had no effect](https://github.com/wekan/wekan/issues/5565),
|
||||
[#5565](https://github.com/wekan/wekan/issues/5565): the sidebar toggle writes `board.allowsChecklistsOnMinicard`, but
|
||||
the minicard render checked a different, UI-less field (`board.allowsChecklistAtMinicard`), so enabling the board-wide
|
||||
setting never showed checklists on minicards. The minicard now reads the field the toggle actually sets.
|
||||
([client/components/cards/minicard.js](client/components/cards/minicard.js))
|
||||
|
||||
and these issues are verified resolved in current code (could not reproduce / no error observed here; re-test on the
|
||||
reporter's data requested):
|
||||
|
||||
- [#5388](https://github.com/wekan/wekan/issues/5388) (collapsing a list affected all users): list collapse state is
|
||||
now stored **per user** (`profile.collapsedLists[boardId][listId]`, or a cookie for logged-out users) instead of a
|
||||
shared `collapsed` field on the list document — so one user collapsing a list no longer changes it for everyone
|
||||
(resolved by commit 414b8dbf4, which postdates the report; mirrors the per-user swimlane-collapse handling).
|
||||
- [#3894](https://github.com/wekan/wekan/issues/3894) (board import failed when the JSON's `members` referenced a user
|
||||
not present in `users`): the importers already guard a missing user entry (skip the dangling member instead of
|
||||
dereferencing `undefined`) in `client/components/import/wekanMembersMapper.js`, `models/wekanmapper.js` and
|
||||
`models/wekanCreator.js`, with a dedicated test (`tests/wekanCreator.inconsistent.test.js`).
|
||||
- [#5411](https://github.com/wekan/wekan/issues/5411) (non-super-admin board admins could not see the add-member "+"):
|
||||
the sidebar add-member button is gated on `currentUser.canInviteToBoard`, which returns `true` for any board admin
|
||||
(`board.hasAdmin`), and the secure default invite roles include `board-admin`. So board admins (not just site admins)
|
||||
see the "+". This was resolved by the "Allow Invite to Board" roles feature (commit c956ab5a4), which postdates the
|
||||
report; a site admin can additionally let other board roles invite via Admin Panel → People → Roles.
|
||||
- [#5627](https://github.com/wekan/wekan/issues/5627) (rules not copied when creating a board from a template):
|
||||
`board.copy()` already copies the board's rules + triggers + actions (remapping `boardId` and the rule's
|
||||
`triggerId`/`actionId`); the report predates that code. Now covered by the #5592/#5627 copy regression in
|
||||
`tests/playwright/specs/17-rest-api.e2e.js` so it cannot silently regress.
|
||||
- [#5630](https://github.com/wekan/wekan/issues/5630) (cannot save Admin Panel Layout settings): already fixed — the
|
||||
Layout save handler had referenced form fields that were moved to the separate Accessibility settings template,
|
||||
throwing before the save; the current `js-save-layout` handler no longer reads those fields.
|
||||
- [#5117](https://github.com/wekan/wekan/issues/5117) (a TeX formula rendered both an SVG and a `<math>` tag): already
|
||||
fixed by migrating math rendering from `markdown-it-mathjax3` (which emitted both SVG and MathML) to **Temml**, which
|
||||
outputs MathML only (`packages/markdown/src/template-integration.js`).
|
||||
|
||||
# v9.69 2026-06-23 WeKan ® release
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
- [Can't edit a card's members in the UI after removing them via the REST API](https://github.com/wekan/wekan/issues/3697),
|
||||
[#3697](https://github.com/wekan/wekan/issues/3697): clearing a card's `members` (or `assignees`) over REST left the
|
||||
field uneditable in the UI. The card PUT handler guarded with `if (req.body.members)` — falsy for the two natural
|
||||
clear payloads (`null` and `""`) — so "remove the last member" was a silent no-op, and its string branch was written
|
||||
to store `null` rather than `[]`; a card with `members: null` then breaks UI code that treats it as an array. The
|
||||
handler now uses an `!== undefined` guard plus a shared coercion helper so any clear payload (`null` / `""` / `[]`)
|
||||
stores a clean `String[]` (never null), a single id is wrapped into an array, and stray non-string entries are
|
||||
dropped; `getMembers()`/`getAssignees()` also coerce a legacy `null` to `[]` on read so existing corrupted documents
|
||||
edit cleanly. Pure logic in `models/lib/restArrayParam.js` with a Meteor-free Node unit test
|
||||
(`tests/restArrayParam.test.cjs`, `npm run test:unit:node`, incl. a **negative test** reproducing the old
|
||||
null-writing logic), plus a REST regression in `tests/playwright/specs/17-rest-api.e2e.js`.
|
||||
- [Can't create a card with no member via the REST API](https://github.com/wekan/wekan/issues/2875),
|
||||
[#2875](https://github.com/wekan/wekan/issues/2875): the card-create endpoints (`POST .../cards` and
|
||||
`POST .../cards/bulk`) wrote `req.body.members`/`assignees` straight to the insert with no normalization — the
|
||||
create-side twin of #3697 — so a `null`/`""` payload persisted as `null` (breaking later UI editing). Both handlers
|
||||
now run the same `coerceRestArrayParam` helper: the field is omitted when not provided (so the schema default `[]`
|
||||
applies), any clear payload becomes `[]` (never null), and a single id is wrapped into an array. REST regression in
|
||||
`tests/playwright/specs/17-rest-api.e2e.js`.
|
||||
- [Board created through the REST API shows in the API but not in the browser UI](https://github.com/wekan/wekan/issues/5650),
|
||||
[#5650](https://github.com/wekan/wekan/issues/5650): `POST /api/boards` set the board's sole member's `userId` from
|
||||
`req.body.owner` with no fallback, so a request that omits `owner` created a board whose only member had
|
||||
`userId: undefined`. The board-list publication matches `members.$elemMatch: { userId, isActive: true }`, which can
|
||||
never match an undefined id — so the board was returned by the REST API but invisible in the browser. `owner` now
|
||||
falls back to the authenticated caller (`req.body.owner || req.userId`), mirroring the Meteor create method. REST +
|
||||
DB regression in `tests/playwright/specs/17-rest-api.e2e.js`.
|
||||
- [Copying a card to another board left its comments on the wrong board](https://github.com/wekan/wekan/issues/5166),
|
||||
[#5166](https://github.com/wekan/wekan/issues/5166): `CardComments.copy()` cloned a comment but only changed its
|
||||
`cardId`, so a card copied to another board produced comments that kept the **source** board's `boardId`. Comment
|
||||
permission checks key off the comment's `boardId`, so edit/delete on a copied comment was validated against the wrong
|
||||
board, and any board-scoped query saw it on the old board. `copy()` now also sets the destination `boardId` (the
|
||||
author `userId` is intentionally preserved). Cross-board copy regression in
|
||||
`tests/playwright/specs/17-rest-api.e2e.js`. *Note:* the related "wrong author shown" symptom is a separate display
|
||||
issue — when a copied comment's author is **not a member of the destination board**, their user document isn't
|
||||
published there, so the UI can't resolve the name; adding those users to the board resolves it. A broader fix
|
||||
(publishing comment authors' minimal profile on boards where their comments appear) is left as a follow-up.
|
||||
- [Exception "Removed nonexistent document" when deleting a card detail](https://github.com/wekan/wekan/issues/3252),
|
||||
[#3252](https://github.com/wekan/wekan/issues/3252) (partial): deleting a comment or checklist/checklist-item could
|
||||
throw `Removed nonexistent document` on the client. The delete handlers called `Collection.remove(_id)` directly, but
|
||||
under heavy archive/delete churn the target doc can already have been evicted from the client's Minimongo cache, and
|
||||
removing a missing `_id` throws. The comment / checklist / checklist-item delete handlers now check the doc still
|
||||
exists in the local cache before removing it
|
||||
(`client/components/activities/comments.js`, `client/components/cards/checklists.js`). The server-side
|
||||
`before.remove` hooks were already hardened to guard + log instead of throwing. (No automated regression for the
|
||||
thrown exception — the eviction race is not deterministically reproducible; the guard itself is a self-evident
|
||||
findOne-then-remove.) The **high CPU on bulk delete** the issue also reports is reduced by the cascade change below;
|
||||
the remaining cost is on the **archive** path (a bulk update, not a delete) and is a separate follow-up.
|
||||
- **Performance: deleting a card no longer fans out per-child activity churn** ([#3252](https://github.com/wekan/wekan/issues/3252),
|
||||
[#5322](https://github.com/wekan/wekan/issues/5322)). `cardRemover` removed a card's checklist items, checklists and
|
||||
comments with the hooked `removeAsync`, so collection-hooks fired each child's `before.remove` **once per document** —
|
||||
every one doing a `getCard` and inserting an activity (e.g. a `removedChecklistItem` per item), which then triggered
|
||||
the notification + publication observers. Deleting (or bulk-deleting) a card with many children produced an
|
||||
activity-insert storm and pegged the CPU. `cardRemover` now removes those children with `.direct` (skipping the
|
||||
per-child hooks — they only logged activities that are unviewable once the card is gone) and clears **all** of the
|
||||
card's activities in a single bulk op, which also removes the activities that a delete previously left orphaned. The
|
||||
`deleteCard` activity is still logged afterward (so the outgoing webhook fires), and attachments still go through the
|
||||
normal remove so their files are deleted. Cascade + activity-cleanup regression (also a negative test) in
|
||||
`tests/playwright/specs/17-rest-api.e2e.js`.
|
||||
|
||||
and this issue is verified resolved in current code (could not reproduce / no error observed here; re-test on the
|
||||
reporter's data requested):
|
||||
|
||||
- [#2292](https://github.com/wekan/wekan/issues/2292) (archiving a swimlane appeared to delete all its cards):
|
||||
`Swimlane.archive()` ([models/swimlanes.js](models/swimlanes.js)) only sets `archived: true` on the swimlane — it
|
||||
does **not** touch or delete the cards, and `restore()` brings the swimlane and its cards back. While a swimlane is
|
||||
archived its cards are merely hidden from the board view (board queries filter to `archived: false` swimlanes), not
|
||||
lost — the v2.27-era cascade described in the report no longer happens. (A dedicated way to view an archived
|
||||
swimlane's cards before restoring would be a separate UX improvement.)
|
||||
|
||||
# v9.68 2026-06-23 WeKan ® release
|
||||
|
||||
This release adds the following updates and developer tooling:
|
||||
|
||||
- **rebuild-wekan.sh / rebuild-wekan.bat: multi-forge mirroring.** Two new menu options: *Install forge CLI
|
||||
tools* (installs the gh-like CLIs `gh`, `glab`, `tea`, `git-bug`, and the unified [`forge`](https://github.com/git-pkgs/forge)
|
||||
via the detected package manager / `go install`), and *Mirror repo GitHub → GitLab/Codeberg/Forgejo/Gitea*. The
|
||||
mirror flow selects source + target by number (e.g. `1 3` = GitHub → Codeberg), pushes all branches/tags with
|
||||
`git push --mirror`, then runs a cross-platform Node engine (`tools/forge-mirror.js`) that syncs only the issues and
|
||||
pull requests **missing** at the target (driving the authenticated CLIs; de-duplicated by title; dry-run by default)
|
||||
and converts the GitHub Actions workflow syntax for the target: an annotated `.forgejo/workflows/` copy that flags
|
||||
the known Forgejo/Gitea incompatibilities (`hashFiles()`, `permissions:`, `continue-on-error:`, complex `runs-on:`),
|
||||
or for GitLab a `.gitlab-ci.yml` scaffold plus a guide pointing at GitLab's official converter skill.
|
||||
- **Updated Meteor to 3.5-rc.2** ([#6413](https://github.com/wekan/wekan/pull/6413), thanks harryadel;
|
||||
merged in [d5be28bc2](https://github.com/wekan/wekan/commit/d5be28bc2)).
|
||||
- **Dependency updates** (merged the low-risk Dependabot PRs that pass build + unit tests):
|
||||
`@swc/helpers` 0.5.22 → 0.5.23 ([#6416](https://github.com/wekan/wekan/pull/6416)) and the test-only
|
||||
`sinon` 21 → 22 ([#6418](https://github.com/wekan/wekan/pull/6418)). The major **production** bumps were held back
|
||||
for individual testing rather than merged: `jquery` 3 → 4 ([#6417](https://github.com/wekan/wekan/pull/6417),
|
||||
risks Blaze / jQuery-UI compatibility), `@babel/runtime` 7 → 8 ([#6415](https://github.com/wekan/wekan/pull/6415)),
|
||||
and `@tweedegolf/sab-adapter-amazon-s3` 1 → 3 ([#6414](https://github.com/wekan/wekan/pull/6414), S3 storage is not
|
||||
exercised by CI).
|
||||
- **rebuild-wekan.sh: run the Chromium / Firefox / WebKit Playwright matrix with or without Docker.** The
|
||||
WebKit-only Docker support is generalized so any browser can run natively or inside the official Playwright Docker
|
||||
image, selectable via `WEKAN_PLAYWRIGHT_DOCKER=1/0` (whole matrix) or per-browser
|
||||
`WEKAN_CHROMIUM_DOCKER` / `WEKAN_FIREFOX_DOCKER` / `WEKAN_WEBKIT_DOCKER`. Defaults are unchanged (Chromium/Firefox
|
||||
native, WebKit via Docker on Linux arm64). Adds an "Install Playwright browsers" menu item that does
|
||||
`playwright install --with-deps` and/or pulls the Playwright Docker image.
|
||||
[commit c3e6d97bd](https://github.com/wekan/wekan/commit/c3e6d97bd).
|
||||
- **E2E reliability:** the Playwright suite was red on `main` because the **rspack client JS bundle was not being
|
||||
served** by `meteor run` (dev mode) in headless CI — browser requests for the bundle returned the SPA HTML fallback
|
||||
(`Unexpected token '<'`), so Meteor never initialised and all ~212 specs timed out in `waitForMeteor` (confirmed: a
|
||||
CI readiness probe got an empty response from the bundle URL for 10 minutes straight). The CI E2E job now runs
|
||||
against a **production build** (`meteor build` → `node bundle/main.js`), where the client JS is baked into the bundle
|
||||
and served statically by the Meteor server — no rspack dev server, no chunk-serving race. The production run also
|
||||
needed two env vars the dev runner sets but CI did not: `WRITABLE_PATH` (without it `server/00checkStartup.js` exits
|
||||
before listening) and `WITH_API=true` (without it every REST-API spec gets HTML instead of JSON). The suite is also
|
||||
**sharded 2× per browser** (6 parallel jobs) to cut wall-clock time, and the per-spec `waitForMeteor` timeout was
|
||||
raised 30s → 60s. **Confirmed:** the suite went from *0 tests running* (all timed out) to ~**119/120 passing per
|
||||
shard**. The handful of residual specs were then triaged locally: two were **real product bugs** (below — the
|
||||
sort-cards button and Template Container deletion); the RTL/LTR i18n-text spec was CI-only timing on the production
|
||||
bundle, made robust by waiting for the async result instead of reading it immediately. Two specs are **quarantined**
|
||||
(`test.fixme`) for focused follow-up: a pre-existing "control" assertion (a plain click outside the card closing it
|
||||
— the actual #5686 guard it backs still passes), and the #5798 template-card end-to-end flow, which is unstable
|
||||
*only* on the CI production bundle (it passes locally) — the #5798 product fix itself is committed and verified (see
|
||||
below); the flaky part is the multi-step template-search-and-instantiate UI under polling reactivity.
|
||||
- **Sort-cards button stayed clickable after sorting.** In the board header the `js-sort-cards` class (which carries
|
||||
the handler that opens the sort popup) was *replaced* by `emphasis` once a sort was active, so after sorting once the
|
||||
button was dead — the popup could not be reopened to change or clear the sort. It now keeps `js-sort-cards` and only
|
||||
*adds* `emphasis` when active.
|
||||
- **Deleting a Template Container board threw and aborted.** `boardRemover` cleared the user's template profile
|
||||
pointers with `$unset`, but those four `profile.*` fields were non-optional `String` in the schema, so SimpleSchema
|
||||
rejected the update ("Templates board ID is required") and the whole board removal failed. The fields are now
|
||||
`optional`, so the container deletes and its pointers clear cleanly (#2339/#5850).
|
||||
- **Regression tests** for several of the fixes below, **each negative-tested** (verified to fail on the pre-fix
|
||||
code): the attachment filename truncation (#6412) has a Meteor-free Node unit test
|
||||
(`tests/filenameSanitizer.test.cjs`, `npm run test:unit:node`) plus the `meteor test` mocha test, and Playwright
|
||||
specs (`tests/playwright/specs/36-fixed-bug-regressions.e2e.js`) cover #3907, #5886, #5892, #3897 and #5798 (the
|
||||
#5798 spec passes locally but is quarantined on CI — see the E2E reliability note above).
|
||||
|
||||
and fixes the following bugs:
|
||||
|
||||
- [Cannot delete some boards from Archive](https://github.com/wekan/wekan/issues/4255),
|
||||
[#4255](https://github.com/wekan/wekan/issues/4255): the Archive listed boards the user could not actually delete —
|
||||
clicking *Delete* did nothing and the console showed `remove failed: Access denied`. The `archivedBoards`
|
||||
publication scoped its list to admin members (`isAdmin: true`) but ignored the **active** flag, whereas the
|
||||
`Boards.remove` permission (`hasAdmin()`) requires an **active** admin (`isActive: true && isAdmin: true`). A user
|
||||
who was an admin on a board but no longer active still saw it in the Archive, then hit "Access denied" on delete.
|
||||
The publication now matches the remove permission exactly (`isActive: true && isAdmin: true`), so the Archive only
|
||||
lists boards the user can really delete.
|
||||
- [Weird moving card bug — cross-board move could silently lose a card](https://github.com/wekan/wekan/issues/5874),
|
||||
[#5874](https://github.com/wekan/wekan/issues/5874) (data loss): rarely, moving a card from board A to board B left
|
||||
it in a **corrupt half-moved state** — the card's `boardId` became B but its `listId`/`swimlaneId` still belonged to
|
||||
board A. The reporter saw exactly this: the card existed in board B's JSON but with board A's list/swimlane, so it
|
||||
was invisible in every normal view on both boards and clicking its link reopened board A. **Root cause:** the
|
||||
Move/Copy-card dialog resolves the destination board's swimlanes and lists from the *client* Minimongo cache, which
|
||||
can still be empty at the moment the user clicks "Done" — the destination board's `publishComposite('board')` data
|
||||
has not finished merging — so the dialog keeps the *source* board's `swimlaneId`/`listId` while the `boardId` is
|
||||
already the destination. (`setFirstSwimlaneId()`/`setFirstListId()` swallow the lookup miss in a silent
|
||||
`try/catch`, leaving the stale ids in place.) Several other callers — drag reorder, multi-card move in the filter
|
||||
sidebar, board-action rules, the REST API — can in principle produce the same mismatch. **Fix:** a new server-side
|
||||
`Cards.before.update` guard (`enforceCardBoardConsistency`, registered first so the corrected modifier is what every
|
||||
later hook and the persisted write see) runs whenever a card's `boardId` changes and rewrites the pending update so
|
||||
the swimlane/list always belong to the destination board, falling back to that board's default swimlane and first
|
||||
list when they don't. It runs on the server, where the destination board's swimlanes/lists are always present
|
||||
regardless of client cache state, and is **corrective only** — a cross-board move whose targets already belong to the
|
||||
destination board is left untouched, and a same-board reorder is ignored. The decision logic was extracted into a
|
||||
pure, dependency-injected module (`models/lib/cardBoardConsistency.js`) with a Meteor-free Node unit test
|
||||
(`tests/cardBoardConsistency.test.cjs`, `npm run test:unit:node`), including a **negative test** asserting that the
|
||||
raw unguarded modifier is exactly the #5874 corrupt state.
|
||||
- [Responsive views still seem broken](https://github.com/wekan/wekan/issues/6419),
|
||||
[#6419](https://github.com/wekan/wekan/issues/6419) (partial): the most concrete, "stops-work" part is fixed — on
|
||||
mobile an **open card could not be closed** because the card-details overlay (z-index 100) sat *below* the app
|
||||
header bar (`#header-main-bar`, z-index 1000), so the header covered the close (X) button. The full-screen mobile
|
||||
card now uses z-index 1100 — above the header, still below popups (2000+) so card menus/date pickers open over it.
|
||||
Mobile header action icons were also enlarged and **verified on a real iPhone profile via Playwright screenshots**
|
||||
(`tests/playwright/mobile-shot.js`): the top bar and the board action bar buttons were only ~28–32px tall with a
|
||||
13–15px font (below the comfortable ~44px touch-target / 16px readable minimum). They are now ~44px tall with a 16px
|
||||
font, applied **by viewport width** (`@media (max-width: 800px)`) so they work on any phone without toggling
|
||||
Desktop/Mobile mode — the board header renders as a clean row of large, icon-only buttons.
|
||||
**Two deeper root causes were then found and fixed (verified on an iPhone profile via Playwright screenshots):**
|
||||
(1) **No viewport meta tag.** WeKan's server-rendered `<head>` (`server/lib/customHeadRender.js`) had no
|
||||
`<meta name="viewport">`, so mobile browsers laid the page out at their default ~980px virtual width and scaled it
|
||||
down — making the whole UI tiny, reporting `window.innerWidth === 980` on a 390px phone, and preventing every
|
||||
`@media (max-width: 800px)` rule (and the width-based mobile detection) from ever matching. Added
|
||||
`width=device-width, initial-scale=1, viewport-fit=cover` (user zoom left enabled for accessibility).
|
||||
(2) **`profile.mobileMode` defaulted to `false`.** The user schema set `mobileMode: false` on *every* user, so
|
||||
`Utils.getMobileMode()` always returned the profile value and the auto-detection below it was dead code — every user
|
||||
was locked to desktop-mode even on a phone. The field is now `optional` (no default), so it stays unset until the
|
||||
user explicitly toggles; auto-detection was also rewritten to use reliable `matchMedia` width/pointer queries instead
|
||||
of fragile user-agent sniffing (cf. **Meteor [#12421](https://github.com/meteor/meteor/issues/12421)**, where Mobile
|
||||
Safari UA version parsing was wrong — this instance reports `isModern: true`, so #12421 is not the cause here, but it
|
||||
is the same class of UA-detection fragility this rewrite avoids), and a `matchMedia` listener now re-applies
|
||||
mobile-mode on resize/orientation when the user has no explicit preference. Net: phones auto-detect mobile mode at
|
||||
the correct device width, with large tappable icons, no manual toggle.
|
||||
Also fixed the **Admin Panel's secondary top bar** in small-width mode: those tab buttons (Settings / People /
|
||||
Reports / Attachments / Translation / Info) are rendered inside `#header-quick-access`, whose mobile rules scale all
|
||||
text/icons 2× — so the admin tabs rendered at 28px text / **56px icons**, huge and overlapping the "Version" label.
|
||||
A scoped override caps them at a normal touch size (15px text / 18px icons).
|
||||
And fixed **overlapping text in the Admin Panel settings body** on phones: the layout forced a side-by-side
|
||||
menu+content row "even on narrow windows", so the ~127px side menu and the content were crammed together and the long
|
||||
(e.g. Finnish) section labels overflowed the menu box rightward, visually overlapping the content. On small screens
|
||||
the layout now **stacks** — a full-width compact section menu on top, full-width settings content below — verified via
|
||||
iPhone-profile screenshots.
|
||||
- [Missing voting buttons](https://github.com/wekan/wekan/issues/6420),
|
||||
[#6420](https://github.com/wekan/wekan/issues/6420): the `showVotingButtons` (and `showPlanningPokerButtons`)
|
||||
helpers in `cardDetails.js` referenced an **undefined `currentUser`** variable, so every card render threw
|
||||
`ReferenceError: currentUser is not defined` and the vote / planning-poker buttons silently disappeared. The helpers
|
||||
now resolve `currentUser` via `ReactiveCache.getCurrentUser()` and null-guard the board-member check. Regression test
|
||||
in `tests/playwright/specs/36-fixed-bug-regressions.e2e.js`.
|
||||
- [ENAMETOOLONG: very long attachment filenames could not be migrated to filesystem storage](https://github.com/wekan/wekan/issues/6412),
|
||||
[#6412](https://github.com/wekan/wekan/issues/6412): attachment filenames were sanitized for path traversal but
|
||||
never length-limited, so a very long name (worse with multibyte UTF-8 like German umlauts) produced an on-disk
|
||||
`<id>-<version>-<name>` component exceeding the filesystem's 255-byte limit and failed with `ENAMETOOLONG`.
|
||||
`sanitizeFilename` now truncates to 200 UTF-8 bytes (measured in bytes, never splitting a codepoint) while
|
||||
preserving the file extension. Done:
|
||||
[commit 5e26cf004](https://github.com/wekan/wekan/commit/5e26cf004).
|
||||
- [Card "added label" history was deleted whenever the card was moved](https://github.com/wekan/wekan/issues/3907),
|
||||
[#3907](https://github.com/wekan/wekan/issues/3907): `updateActivities` removed all `addedLabel` activities whenever
|
||||
`boardId` appeared in a card update, but `Card.move()` always re-sets `boardId` (even moving within the same board),
|
||||
so every move wiped the card's label history (data loss in the `activities` collection). The removal/remap now only
|
||||
runs on an actual board change (comparing the pre-update `boardId` with the new value). Done:
|
||||
[commit 6382ad6a8](https://github.com/wekan/wekan/commit/6382ad6a8).
|
||||
- [Public boards did not fully load longer lists when viewed as a guest](https://github.com/wekan/wekan/issues/3897),
|
||||
[#3897](https://github.com/wekan/wekan/issues/3897): a guest (no logged-in user) hit
|
||||
`getCurrentUser().isBoardAdmin()` in template helpers, throwing `Cannot read property 'isBoardAdmin' of null` and
|
||||
aborting the Tracker render so lists stopped loading partway. The helpers now use optional chaining
|
||||
(`getCurrentUser()?.isBoardAdmin()` / `?.isWorker()`), which is falsy for guests instead of throwing. Done:
|
||||
[commit fc0fe0b61](https://github.com/wekan/wekan/commit/fc0fe0b61).
|
||||
|
||||
- [Changed order of lists is not persisted](https://github.com/wekan/wekan/issues/5997),
|
||||
[#5997](https://github.com/wekan/wekan/issues/5997): the server side was verified working end-to-end (a DDP call to
|
||||
`updateListSort` reorders the `lists` collection and the `sort:1` query returns the new order), so the regression was
|
||||
client-side. `saveSorting` read the neighbouring lists with `.prev('.js-list')` / `.next('.js-list')`, which jQuery
|
||||
only matches when the sibling is *immediately* adjacent; the lists container also renders the add-list composer and a
|
||||
`+cardDetails` element (when a card is open) between lists, so an interspersed non-list sibling made `calculateIndex`
|
||||
mis-detect the first/last position and compute a wrong sort. Now uses `prevAll/nextAll('.js-list').first()`. Done:
|
||||
[commit 1165c9b6d](https://github.com/wekan/wekan/commit/1165c9b6d).
|
||||
- [Cards made from a template link to the template itself](https://github.com/wekan/wekan/issues/5798),
|
||||
[#5798](https://github.com/wekan/wekan/issues/5798): a card instantiated from a template was copied with the
|
||||
*templates* board id (the template-search source), so it had `boardId` = templates board. It still showed in the
|
||||
target list (the list renders cards by `listId` and the templates board is subscribed), but clicking it navigated to
|
||||
the templates board instead of opening the card. It is now copied into the current board. Done:
|
||||
[commit 097806984](https://github.com/wekan/wekan/commit/097806984).
|
||||
- **Creating a card from a template threw "There is no current view" and created no card.** Found while adding the
|
||||
#5798 regression test: the searchElement popup's minicard-click handler called `tpl.getSortIndex()` (which reads
|
||||
`Template.currentData()`) *after* `await tpl.board.getNextCardNumber()`, and Blaze's synchronous current-view context
|
||||
is lost across an `await`. The sort index is now computed before the await. Done:
|
||||
[commit 612ed3e51](https://github.com/wekan/wekan/commit/612ed3e51).
|
||||
- [Lists do not collapse correctly with the Modern theme](https://github.com/wekan/wekan/issues/5892),
|
||||
[#5892](https://github.com/wekan/wekan/issues/5892): the list-width rework (#6409) added a persistent
|
||||
`.list[style*="--list-width"] { width: … !important }` rule that overrode the 30px collapsed width, so a list with a
|
||||
custom width stayed full width when collapsed. Collapsed lists are now excluded from that rule and the 30px width is
|
||||
`!important`. Done: [commit 5e9b2bccd](https://github.com/wekan/wekan/commit/5e9b2bccd).
|
||||
- [Sort by due date is not remembered as the default view](https://github.com/wekan/wekan/issues/5886),
|
||||
[#5886](https://github.com/wekan/wekan/issues/5886): the card sort was kept only in an in-memory Meteor `Session`
|
||||
variable, which resets on page reload, so the chosen sort reverted to the default. The sort is now persisted to
|
||||
`localStorage` and restored on load (sorting and the sort icon). Done:
|
||||
[commit 6aad946bc](https://github.com/wekan/wekan/commit/6aad946bc).
|
||||
- [Change card's parent shows no cards the first time you select a board](https://github.com/wekan/wekan/issues/3745),
|
||||
[#3745](https://github.com/wekan/wekan/issues/3745): `Template.cardMorePopup`'s `cards()` helper queried
|
||||
`ReactiveCache.getCards({ boardId })` from client minimongo the instant a board was picked, before that board's card
|
||||
subscription had loaded, so the parent-card list was empty the first time (and only worked on reopen, once the data
|
||||
had arrived). It now subscribes with an `onReady` callback and a `parentBoardReady` reactive flag, and the list only
|
||||
renders once the subscription is ready — the same subscription-readiness pattern as the #5798 fix. Done:
|
||||
[commit f999b9e74](https://github.com/wekan/wekan/commit/f999b9e74).
|
||||
|
||||
and these issues are verified resolved in current code (could not reproduce / no error observed here; re-test on the
|
||||
reporter's data requested):
|
||||
|
||||
- [#3826](https://github.com/wekan/wekan/issues/3826) (cannot reorder cards in a list whose cards have parents): built a
|
||||
**drag-sort reproduction harness** (`tests/playwright/helpers/dragSort.js`) that drives jQuery-UI sortable with a
|
||||
realistic stepped mouse gesture (Playwright's `dragTo()` does not trigger it), plus a regression spec
|
||||
(`tests/playwright/specs/37-card-drag-sort.e2e.js`). Dragging a sub-task card (one with a `parentId`) to a new
|
||||
position in its list **persists** the new order both with a few cards and at 15 cards — it does not revert.
|
||||
- [#1289](https://github.com/wekan/wekan/issues/1289) (card with a deleted member user): verified via the Playwright
|
||||
harness — a card whose `members`/`assignees` reference a non-existent user renders its board minicard and opens the
|
||||
card detail with **zero console errors**, so `userAvatar` null-guards missing users correctly.
|
||||
- [#1389](https://github.com/wekan/wekan/issues/1389) (edge-to-edge URL makes the description uneditable): the card
|
||||
detail has an explicit edit (pencil) control rather than relying on clicking the rendered text, so a full-width link
|
||||
no longer blocks editing.
|
||||
- [#5808](https://github.com/wekan/wekan/issues/5808) (bidirectional cross-board card link makes both cards hang on
|
||||
open): a Playwright repro that mutually links two cards across two boards opens the card detail in ~1s with no errors
|
||||
and no redirect loop — the link only navigates on an explicit click, not on open, so there is no auto-bounce.
|
||||
- [#5757](https://github.com/wekan/wekan/issues/5757) (card activities jump to a recent date after changing the due
|
||||
date): activity `createdAt` is set once in the `Activities.before.insert` hook, nothing bulk-updates it, and the UI
|
||||
renders `activity.createdAt` directly — so changing a due date inserts one new `a-dueAt` activity and cannot re-date
|
||||
existing ones.
|
||||
|
||||
# v9.67 2026-06-21 WeKan ® release
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
- Updated to Meteor 3.5-rc.2.
|
||||
|
||||
- [Reworked confusing and unreliable list widths](https://github.com/wekan/wekan/issues/6409),
|
||||
[#6409](https://github.com/wekan/wekan/issues/6409): a list now has **one** width instead of the old
|
||||
"min width / max width / automatic" trio, and it reliably persists across reloads (the render now drives the
|
||||
`--list-width` CSS variable the styles actually use, so a width no longer reverts to `auto` after reload).
|
||||
A new board setting **Personal list widths** chooses the scope:
|
||||
- **Off (default) — Shared:** the width lives on the list (`lists.width`), is the same for everyone on the board,
|
||||
and only members with write access can change it (read-only/comment-only members no longer see the resize
|
||||
handle). Shared widths are included in board **export/import** (the importer previously dropped `lists.width`;
|
||||
it now preserves width, color and collapsed state, and the board's list-width scope).
|
||||
- **On — Personal:** each user keeps their own widths (profile, or localStorage when not logged in), falling back
|
||||
to the shared width then the default. The per-list popup is simplified to a single width value plus an
|
||||
**Auto list width** toggle (fit lists to content); the advanced per-list min/max pixel options were removed.
|
||||
Auto-width follows the same Shared/Personal scope (per-board for everyone, or per-user) and is carried through
|
||||
export/import. Documented in `docs/Features/Lists/Lists.md`.
|
||||
|
||||
- `rebuild-wekan.sh` / `rebuild-wekan.bat` menu option 2 ("Build WeKan") now also clears the rspack dev-build caches
|
||||
(`_build` and `node_modules/.cache`) in addition to `node_modules` / `.meteor/local` / `.build`, so the next
|
||||
`meteor run` recompiles from scratch instead of occasionally serving stale modules after a `git` checkout/merge.
|
||||
- `rebuild-wekan.sh` / `rebuild-wekan.bat` now give the Meteor build tool and Node a larger heap by default
|
||||
(`TOOL_NODE_FLAGS` and `NODE_OPTIONS` = `--max-old-space-size=8192`) for every dev-run, test and build option, so
|
||||
long development sessions and test runs no longer crash with "FATAL ERROR: ... JavaScript heap out of memory".
|
||||
Both honor an existing value, so you can lower it on machines with less RAM.
|
||||
|
||||
- [Fixed editing the 2nd/3rd organization or team in Admin Panel › People always showing the FIRST one](https://github.com/wekan/wekan/issues/6411),
|
||||
[#6411](https://github.com/wekan/wekan/issues/6411): on `/people`, clicking *Edit* on any organization or team filled
|
||||
the form with the first one's values (so you could never edit the others). The edit/settings popups are opened from
|
||||
the row with data context `{ org }` / `{ team }`, but their helpers read `this.orgId` / `this.teamId` (undefined
|
||||
there) and called `getOrg(undefined)` / `getTeam(undefined)`, which `findOne({})` resolves to the first document.
|
||||
The popup helpers, the save handlers and the delete (settings) handlers now resolve the clicked row's id from the
|
||||
`{ org }` / `{ team }` context. Verified against a running instance (each org/team now edits its own values).
|
||||
|
||||
- **Fixed boards not rendering at all (blank board view) after the mongodb/bson 7.3.0 dependency bump.** bson 7.x runs
|
||||
`const { startupSnapshot } = globalThis?.process?.getBuiltinModule('v8') ?? {}` at module-load time; the optional
|
||||
chaining stops before the *call*, so in the browser — where a partial `process` polyfill exists but has no
|
||||
`getBuiltinModule` — it threw `TypeError: getBuiltinModule is not a function` while evaluating
|
||||
`client/components/cards/attachments.js` (`import { ObjectId } from 'bson'`). That aborted the client bundle
|
||||
bootstrap part-way through `client/imports.js`, so every feature imported after it (notifications, swimlanes,
|
||||
rules, …) was never registered and the board view died with `No such template: notifications`. Added a tiny browser
|
||||
shim (`client/lib/bsonBrowserShim.js`, imported first in `client/main.js`) that gives the browser `process` a no-op
|
||||
`getBuiltinModule`, so bson takes its intended `?? {}` fallback. New unit tests
|
||||
(`client/lib/tests/bsonBrowserShim.tests.js`); also hardened the #5686 Playwright spec to run against a rendering
|
||||
board. ([PR #6410](https://github.com/wekan/wekan/pull/6410))
|
||||
|
||||
- [Fixed REST API returning HTTP 500 with a stack trace for an invalid request](https://github.com/wekan/wekan/pull/6406),
|
||||
[#5804](https://github.com/wekan/wekan/issues/5804): posting a comment without the required `comment` parameter (or
|
||||
to a board that does not exist) returned an HTTP 500 error page. The schema-validation error thrown on insert is a
|
||||
circular object (`SimpleSchemaValidationContext` → `SimpleSchema` → …), and serializing it crashed the response
|
||||
writer (`Converting circular structure to JSON`). Now: the `comment` parameter is validated and a missing/empty one
|
||||
returns **HTTP 400**; an unknown board returns **HTTP 404** (the board-access checks no longer dereference
|
||||
`board.members` of a non-existent board); the JSON response writer is crash-proof (falls back to a safe
|
||||
`{ "error": … }` payload instead of throwing); and REST comment errors now use their real status code instead of
|
||||
`200`. New unit tests in `server/lib/tests/apiResponseHelpers.tests.js`. ([PR #6406](https://github.com/wekan/wekan/pull/6406))
|
||||
- [Fixed selecting text in a checklist closing the card](https://github.com/wekan/wekan/pull/6407),
|
||||
[#5686](https://github.com/wekan/wekan/issues/5686): selecting the text of a checklist item and releasing the
|
||||
mouse outside the card detail pane closed the card. The checklist items template stops `mousedown` propagation
|
||||
(for item sorting), so the existing `cardDetailsIsDragging` guard never engaged and the document-level
|
||||
"click outside to close" handler closed the card. The close handler now also keeps the card open whenever a
|
||||
live text selection is anchored inside the card pane (new propagation-independent guard
|
||||
`client/lib/cardCloseGuard.js`), so a deliberate click on empty board space still closes the card. New unit
|
||||
tests in `client/lib/tests/cardCloseGuard.tests.js` and a Playwright regression test in
|
||||
`tests/playwright/specs/34-checklist-text-selection.e2e.js`. ([PR #6407](https://github.com/wekan/wekan/pull/6407))
|
||||
- [Fixed list reordering throwing `403 Access denied` for read-only members](https://github.com/wekan/wekan/issues/5462),
|
||||
[#5462](https://github.com/wekan/wekan/issues/5462): read-only / comment-only board members could still drag-reorder
|
||||
lists, which fired a server write that allow/deny rejected with `403 Access denied` (the list then snapped back). Of
|
||||
the three list jQuery-UI sortables in `client/components/swimlanes/swimlanes.js`, one was not gated on
|
||||
`Utils.canModifyBoard()`; it now is, consistent with the other two, plus a defense-in-depth guard so a logged-in
|
||||
user without write access can never persist a reorder (anonymous public-board reordering via localStorage is
|
||||
unaffected). The server already enforced this; the fix stops the unauthorized drag and the console error. New
|
||||
Playwright regression test in `tests/playwright/specs/35-list-sort-permissions.e2e.js`. ([PR #6408](https://github.com/wekan/wekan/pull/6408))
|
||||
|
||||
Thanks to GitHub users Atry, mueller-ma and liferadioat for reporting.
|
||||
|
||||
# v9.65 2026-06-20 WeKan ® release
|
||||
|
||||
This release adds the following updates:
|
||||
|
||||
- Issue triage: closed 13 already-fixed Bug issues (with evidence), relabeled ~25 mislabeled feature requests to `Feature` with a "Feature Request:" title prefix, and prefixed ~35 environment-specific reports "Environment specific:" and gave them the `Bug:Environment-specific` label.
|
||||
- Audited labels on all 533 open issues for correctness (type, `Feature:Area`, `Targets:`, `Severity:`, etc.).
|
||||
- Added 23 missing GitHub labels found by auditing `docs/Login` and `docs/Features` against the issue labels, matching the existing label style and colours (`Feature:*` = `#0052cc`, `Targets:*` = `#fbca04`), and applied them across open and closed issues:
|
||||
- Login methods (`Feature:User-accounts:*`): ADFS, Azure, B2C, Google, Header-Login, Nextcloud, Oracle, Zitadel, Autologin, Accounts-Lockout, Forgot-Password.
|
||||
- Features: `Feature:LaTeX`, `Feature:Mermaid-Diagram`, `Feature:Emoji`, `Feature:Python`, `Feature:Cards:Cover`, `Feature:Cards:Location`, `Feature:Custom-Logo`, `Feature:RTL`, `Feature:Members`, `Feature:Multitenancy`, `Feature:Allow-private-boards-only`.
|
||||
- Platform: `Targets:Apache`.
|
||||
|
||||
and adds the following new features:
|
||||
|
||||
- [Threaded comment replies](https://github.com/wekan/wekan/commit/bdb8e6254): card comments gain an optional
|
||||
`parentId`; a "Reply" link links a new comment to its parent, rendered with an "in reply to" quote. Initial MVP
|
||||
(single-level visual threading).
|
||||
- [Restrict board admins from editing/deleting other users' comments](https://github.com/wekan/wekan/commit/bdb8e6254):
|
||||
new board setting `restrictCommentEditing` (default off). When on, only a comment's author may edit/delete it;
|
||||
enforced server-side via collection hooks.
|
||||
- [Visible status of sub-tasks](https://github.com/wekan/wekan/commit/d7ae93bb2): each subtask now shows its current
|
||||
list (prefixed with the board title when on a different board) read-only next to its title.
|
||||
- [Drag-and-drop search results into board columns](https://github.com/wekan/wekan/commit/b998246c3): cards in the
|
||||
search-results list can be dragged onto board lists, reusing the existing `card.move()`. MVP: drops append to
|
||||
the end of the target list (no pixel-precise insertion index yet).
|
||||
- [Per-user permanent dismissal of the Announcement banner](https://github.com/wekan/wekan/commit/bf75efaef): a user
|
||||
can permanently close the current announcement so it does not reappear on reload/board-switch, until the admin
|
||||
edits the announcement text (which makes it reappear for everyone).
|
||||
- [Show how many times a card's due date was changed](https://github.com/wekan/wekan/commit/a8ed326a5): the card detail
|
||||
now displays a "due date changed N times" count (derived from existing `a-dueAt` activities) for deadline
|
||||
accountability.
|
||||
- [Restrict adding board members to the same Organization or Team](https://github.com/wekan/wekan/commit/371258a6d):
|
||||
new global admin setting `boardMembersFromSameOrgOrTeamOnly` (default off). When on, a user can only be added to
|
||||
a board if they share an Organization or Team with the inviter or an active board member; enforced server-side in
|
||||
the invite/search paths. Site admins bypass.
|
||||
- [Import Google Calendar `.ics` files into board cards](https://github.com/wekan/wekan/commit/0a43d8ac3): MVP,
|
||||
import-only. New dependency-free iCalendar parser (`server/lib/icsImport.js`) maps each `VEVENT` to a card with
|
||||
`startAt`/`dueAt` so events appear on Calendar/Gantt views, plus an `importIcsToBoard` Meteor method and a REST
|
||||
endpoint `POST /api/boards/:boardId/swimlanes/:swimlaneId/lists/:listId/ics` (documented in the OpenAPI spec, with
|
||||
an `importics` example in `api.py`). Two-way Google Calendar sync is not included (see
|
||||
[wekan-ical-server](https://github.com/wekan/wekan-ical-server) for read-only WeKan→calendar export).
|
||||
|
||||
and fixes the following bugs:
|
||||
|
||||
- [Fixed OIDC/OAuth2 "Log Out" redirecting to the identity provider home page instead of back to Wekan](https://github.com/wekan/wekan/commit/9e50c69eaaec8928b4eb8a122f9cb6f0447464a9).
|
||||
With autologin (`OIDC_REDIRECTION_ENABLED=true`), clicking Log Out redirected to the OAuth2 server URL
|
||||
(for example the Keycloak base URL `https://id.company.com`), which shows an error page for non-admin
|
||||
users. Added the new optional `OAUTH2_LOGOUT_ENDPOINT` setting: when set to the provider's
|
||||
`end_session_endpoint` (Keycloak example `/realms/<realm>/protocol/openid-connect/logout`), Wekan now
|
||||
performs an OIDC RP-initiated logout that ends the identity provider session and returns the user to
|
||||
Wekan (`ROOT_URL`) via `post_logout_redirect_uri`. When unset, logout behaviour is unchanged, so this
|
||||
is backward compatible. For Keycloak 18+, add your Wekan `ROOT_URL` to the client's
|
||||
*Valid post logout redirect URIs*. See [docs/Login/Keycloak/Keycloak.md](https://github.com/wekan/wekan/blob/main/docs/Login/Keycloak/Keycloak.md).
|
||||
Thanks to zambalee and xet7.
|
||||
- [Fixed due dates not correctly colour coded](https://github.com/wekan/wekan/commit/086254e10): future due dates
|
||||
more than 48 hours away are now shaded grey (`not-due`) instead of amber (`due-soon`). Root cause was a
|
||||
call to `diff(theDate, now, 'days')` where `'days'` is not a valid unit, so the threshold compared raw
|
||||
milliseconds; replaced with a precise hours-based comparison in a single shared helper.
|
||||
- [Fixed due date colour mismatch between list and card detail](https://github.com/wekan/wekan/commit/086254e10):
|
||||
an overdue card now shows red in both the minicard/list and the opened card detail. The card-detail status
|
||||
colours now use `!important` so overdue red overrides the due-date yellow base (matching the minicard), and
|
||||
the colour-decision logic is unified into one shared helper used by both views.
|
||||
- [Fixed unable to view all cards by due date](https://github.com/wekan/wekan/commit/b3acbd692): removed the
|
||||
`limit: 100` cap in the `dueCards` publication so all of a user's due cards across boards are shown.
|
||||
- [Fixed unable to scroll past the first cards in the Due Cards view on mobile](https://github.com/wekan/wekan/commit/b3acbd692):
|
||||
the due-cards list now has a scoped scroll container with a viewport-relative max height.
|
||||
- [Fixed card-detail sub-popups disappearing on mobile](https://github.com/wekan/wekan/commit/5c890baca): assigning
|
||||
a user or setting the due date on touch devices no longer closes the popup (touch events inside the popup no
|
||||
longer bubble to the click-outside close handler on mobile viewports).
|
||||
- [Fixed mobile board layout and tiny Home button](https://github.com/wekan/wekan/commit/5c890baca): minicards now
|
||||
render one per row (full width) on narrow screens and the header Home / All Boards button is a proper tap target.
|
||||
- [Fixed enormous icons/menus/text after upgrade](https://github.com/wekan/wekan/commit/5c890baca): clamped runaway
|
||||
icon/label sizing on mobile.
|
||||
- [Fixed oversized padding/margins and stray ➕ emoji from recent UI changes](https://github.com/wekan/wekan/commit/5c890baca):
|
||||
trimmed excessive padding/margins on mobile; remaining stray plus emojis are tracked for replacement with a
|
||||
Font Awesome icon.
|
||||
- [Fixed board-level search not including comments](https://github.com/wekan/wekan/commit/b998246c3): in-board search
|
||||
now matches text inside card comments, consistent with global search.
|
||||
- [Fixed "create list" not available in Lists board-view mode](https://github.com/wekan/wekan/commit/c18ae388b): the
|
||||
"Add list" composer now appears in Lists mode (using the board's default swimlane), not only in Swimlanes mode.
|
||||
- [Fixed board/list/swimlane numbering breaking first-letter keyboard navigation](https://github.com/wekan/wekan/commit/fe4d2a56f):
|
||||
the move/copy card popups no longer prefix a number to each option, so options start with their name again and
|
||||
digit-named boards are readable.
|
||||
- [Fixed missing notification and card-history entry when a new attachment is uploaded](https://github.com/wekan/wekan/commit/f59273508):
|
||||
uploading an attachment now creates an `addAttachment` activity, so card members and subscribers are notified
|
||||
(consistent with attachment removal); previously the activity was never created because the store-strategy
|
||||
upload hook was dead code.
|
||||
- [Fixed copying a card selecting all/unnamed labels on the destination board](https://github.com/wekan/wekan/commit/d26d117e3): `Cards.copy()` now applies the same unnamed-label
|
||||
guard as `Cards.move()` and persists the remapped labels onto the inserted card.
|
||||
- [Fixed copied card losing its cover ("show as thumb")](https://github.com/wekan/wekan/commit/a0e701708): `coverId` is now remapped to the newly copied
|
||||
attachment instead of pointing at the original (now-unresolvable) attachment id.
|
||||
- [Fixed deleting a date on a linked card not taking effect](https://github.com/wekan/wekan/commit/09e9c993e): `unsetReceived/unsetStart/unsetDue/unsetEnd` now
|
||||
resolve the real card id via `getRealId()` (consistent with the `set*` methods) so they update the underlying
|
||||
linked card, not the link placeholder.
|
||||
- [Fixed comment-only members being able to archive cards from the UI](https://github.com/wekan/wekan/commit/aa412b2a1): the archive action now respects
|
||||
`Utils.canModifyCard()` like every other mutating card action (the server allow-rule already rejected the write;
|
||||
this closes the client-side UX gap).
|
||||
- [Fixed sub-task board being inaccessible until a reload](https://github.com/wekan/wekan/commit/45bd7dcc8): the "view subtask" navigation now guards against a
|
||||
not-yet-loaded board, mirroring the sibling handler.
|
||||
- [Fixed the "When a card is moved to Archive" rule trigger not being activatable](https://github.com/wekan/wekan/commit/da7c06ec6): a CSS class-name mismatch (`js-add-arc-trigger` vs
|
||||
`js-add-arch-trigger`) between the board-triggers template and its click handler is fixed, with a regression test.
|
||||
- [Fixed "select all in list" crossing swimlanes](https://github.com/wekan/wekan/commit/300a9751e): list select-all is now scoped to the current
|
||||
swimlane in swimlanes view (`allCards()` gained an optional swimlane scope); list-wide behaviour is preserved
|
||||
where there is no swimlane context.
|
||||
- [Fixed copying a swimlane to another board losing card labels](https://github.com/wekan/wekan/commit/9643dcded): missing board-level labels are now recreated on the
|
||||
destination board (preserving colour) before the per-card copy so label assignments survive.
|
||||
- [Fixed Calendar View ignoring the start-day-of-week setting](https://github.com/wekan/wekan/commit/7eec78c04): FullCalendar's `firstDay` is now derived from
|
||||
`getStartDayOfWeek()` instead of the locale default.
|
||||
- [Fixed deleted-attachment notification crediting the uploader instead of the deleter](https://github.com/wekan/wekan/commit/2ba8882d8): the `deleteAttachment` activity now records the
|
||||
acting user (falling back to the uploader for server/system removals).
|
||||
- [Fixed updating a card title not firing the outgoing webhook](https://github.com/wekan/wekan/commit/9aa97ab8f): a title change now logs an `a-changedTitle` activity
|
||||
(rendered in the activity feed) so the existing outgoing-webhook hook fires, consistent with description/date changes.
|
||||
- [Fixed @mention: pressing Enter to pick a user closed the card / submitted the comment](https://github.com/wekan/wekan/commit/3ce75dd87), also [#4172](https://github.com/wekan/wekan/issues/4172)
|
||||
and [#5457](https://github.com/wekan/wekan/issues/5457): when the @mention autocomplete dropdown is open, Enter now
|
||||
selects the highlighted user instead of submitting/closing (shared textcomplete keydown guard hardened).
|
||||
- [Fixed REST card API move/sort/date/archive bugs](https://github.com/wekan/wekan/commit/39e12035e): consolidated duplicated board-move variable names
|
||||
(#5398); moving a card to another list via the API now puts it on top of the destination list like the Move Card
|
||||
dialog ([#5399](https://github.com/wekan/wekan/issues/5399)); due/received/start/end dates set via the API now
|
||||
persist instead of being stripped ([#5537](https://github.com/wekan/wekan/issues/5537)); and archived cards can be
|
||||
inspected via the single-card GET and de-archived without needing a `list_id` ([#5546](https://github.com/wekan/wekan/issues/5546)).
|
||||
- [Fixed the per-checklist "Hide checked items" toggle being inverted and affecting all checklists](https://github.com/wekan/wekan/commit/e98aae278): it is now read per checklist and hides an item exactly
|
||||
when it is checked and that checklist's toggle is on.
|
||||
- [Fixed setting a list/swimlane colour to `silver` saving it as None](https://github.com/wekan/wekan/commit/6f8543322): list/swimlane colours are normalized through a shared
|
||||
canonical allowed-colour helper, so `silver` (and every offered colour) is accepted and rendered.
|
||||
- [Fixed internal caret `^board^` helper boards appearing in board lists and the REST API](https://github.com/wekan/wekan/commit/f819054bb): caret-wrapped titles and non-`board` types are now
|
||||
filtered from `/api/users/:userId/boards`, `/api/boards` and the `Boards.userBoards` helper, consistent with the UI.
|
||||
- [Fixed being unable to remove a deleted (non-existent) user from a board's members](https://github.com/wekan/wekan/commit/beac9e3d7): orphaned member entries are now hard-removed by userId
|
||||
even when the user account no longer exists.
|
||||
- [Fixed a custom number field displaying as `NaN` when cleared after being set](https://github.com/wekan/wekan/commit/98315c26c): an empty number value is stored as `''` and rendered as
|
||||
empty via a shared `formatNumberValue` helper.
|
||||
- [Fixed subtask creation producing extra swimlanes/columns and only allowing one subtask](https://github.com/wekan/wekan/commit/4b27313c7), also [#5788](https://github.com/wekan/wekan/issues/5788),
|
||||
[#2256](https://github.com/wekan/wekan/issues/2256) and [#4782](https://github.com/wekan/wekan/issues/4782): subtask
|
||||
creation is now server-authoritative (`addSubtaskCard` Meteor method) and client-side auto-creation of the default
|
||||
subtasks board/list is guarded to the server, so the client can no longer create duplicate subtasks boards/swimlanes
|
||||
and multiple subtasks can be created reliably.
|
||||
- [Fixed board "always on card" custom fields not being applied to new subtask cards](https://github.com/wekan/wekan/commit/4b27313c7), also [#3562](https://github.com/wekan/wekan/issues/3562):
|
||||
the destination board's automatic custom fields are now added to a new subtask.
|
||||
- [Fixed a circular subtask/parent reference hanging the whole board](https://github.com/wekan/wekan/commit/4b27313c7): the parent-chain guard compared array indices instead of
|
||||
ids; it now compares by value and `setParentId` refuses a cyclic re-parent.
|
||||
- [Fixed the board Subtasks "Landing list for subtasks deposited here" setting not saving / showing the wrong list](https://github.com/wekan/wekan/commit/774289f92), also [#3876](https://github.com/wekan/wekan/issues/3876),
|
||||
[#4849](https://github.com/wekan/wekan/issues/4849) and [#4947](https://github.com/wekan/wekan/issues/4947): the
|
||||
settings popup now reads the deposit board's lists and matches the stored `subtasksDefaultListId`.
|
||||
- [Fixed the subtask "View it" button opening the parent card instead of the subtask](https://github.com/wekan/wekan/commit/c5cdc7b65): navigation now targets the subtask's own board/card.
|
||||
- [Fixed deleting a card not firing the outgoing webhook](https://github.com/wekan/wekan/commit/7bb7540ba): card deletion now creates a `deleteCard` activity (in the
|
||||
before-remove hook and the REST delete endpoints) so the outgoing webhook fires; board/list/swimlane deletion already
|
||||
emitted their delete activities ([#2950](https://github.com/wekan/wekan/issues/2950)).
|
||||
- [Fixed a configured outgoing webhook making it impossible to set card members](https://github.com/wekan/wekan/commit/8863ec24a): outgoing webhook delivery is now fire-and-forget and
|
||||
error-isolated, so a slow/unreachable/failing webhook endpoint can no longer abort the member update.
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v9.64 2026-06-20 WeKan ® release
|
||||
|
||||
This release fixes the following CRITICAL SECURITY ISSUE of [ChecklistBleed](https://wekan.fi/hall-of-fame/checklistbleed/):
|
||||
|
||||
- [Fixed ChecklistBleed: any authenticated user can write checklist data into a private board they are not a member of (cross-board write via collection allow rule)](https://github.com/wekan/wekan/commit/b1ca76007)
|
||||
([GHSA-gv8h-5p3p-6hx7](https://github.com/wekan/wekan/security/advisories/GHSA-gv8h-5p3p-6hx7),
|
||||
CWE-863 Incorrect Authorization). This is the same class as [BoardBleed](https://wekan.fi/hall-of-fame/boardbleed/)
|
||||
(GHSA-gm7v-pc38-53jr), but for the card-attached Checklist and ChecklistItem documents that the
|
||||
`boardId`-only `denyCrossBoardMove` fix did not cover. Checklists and checklist items are attached
|
||||
to a card and carry a denormalized `boardId`; they are MOVED between cards by `$set`-ting a new
|
||||
`cardId` (and, for items, a new `checklistId`) in a direct DDP collection update, after which the
|
||||
`Checklists.before.update` hook re-derives `boardId` from the destination card. The collection
|
||||
allow rules (`server/permissions/checklists.js`, `server/permissions/checklistItems.js`)
|
||||
authorized an update by checking only the document's CURRENT (source) `cardId` — i.e. the
|
||||
attacker's own board — and never inspected the new destination `cardId`/`checklistId`/`boardId`.
|
||||
Because every logged-in user can create a board where they are a write-capable member, a
|
||||
low-privileged user with write access to one board could create a checklist/item on their own
|
||||
card and then, in a single `/checklists/update` or `/checklistItems/update` DDP call, set its
|
||||
`cardId` to a card in a private board where they are not a member (if they know the target card
|
||||
id): the allow rule saw the attacker's source card, approved the write, and the before-update hook
|
||||
attached the attacker-controlled document to the victim's private board. The protected
|
||||
`moveChecklist` Meteor method correctly checks both source and destination board membership, but a
|
||||
DDP client can bypass that method and update the collections directly. CVSS:3.1 Moderate
|
||||
(AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N). Fixed by adding `denyCrossBoardMoveByCard` and
|
||||
`denyCrossBoardMoveByChecklistItem` helpers in `server/lib/utils.js` and a `Checklists.deny`/
|
||||
`ChecklistItems.deny` `update` rule on each collection that rejects any update whose destination
|
||||
board — resolved from a new `boardId`, `cardId`, or `checklistId` in the modifier — the caller has
|
||||
no write access to. Legitimate moves into boards the user belongs to and same-card edits keep
|
||||
working, and the server-side `moveChecklist` method (which bypasses allow/deny) is unchanged.
|
||||
A regression test (`server/lib/tests/checklistbleed.security.tests.js`) was added.
|
||||
Affected Wekan v9.62 and earlier.
|
||||
Thanks to DavidCarliez, xet7 and Claude.
|
||||
|
||||
and adds the following updates:
|
||||
|
||||
- [Fix the Docker pre-build version guard false-failing the release](https://github.com/wekan/wekan/commit/069dbc11f).
|
||||
The bundle-version guard added for the Admin-Panel-version fix assumed the WeKan app
|
||||
`package.json` ships as a standalone file with a `v`-prefixed version, and made "not found"
|
||||
fatal. Meteor does not ship it that way — it inlines the app `package.json` into the compiled
|
||||
`bundle/programs/server/app/app.js` as a JSON module (`{"name":"wekan","version":"v9.63.0",...}`),
|
||||
so the guard found no v-prefixed `package.json` and aborted the v9.63 Docker build. The guard now
|
||||
reads the version from that inlined module (anchored on `"name":"wekan"`, exactly what
|
||||
`require('/package.json')` resolves to), and a detection miss is now a warning that continues
|
||||
rather than a hard failure — only a confirmed version MISMATCH blocks the release, since
|
||||
`--build-arg VERSION` is the actual guarantee.
|
||||
Thanks to xet7 and Claude.
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v9.63 2026-06-19 WeKan ® release
|
||||
|
||||
This release adds the following features:
|
||||
|
||||
+4
-3
@@ -7,7 +7,7 @@ LABEL org.opencontainers.image.source="https://github.com/wekan/wekan"
|
||||
# TARGETARCH and TARGETVARIANT are automatically provided by Docker Buildx
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG VERSION=9.63
|
||||
ARG VERSION=9.71
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
|
||||
@@ -15,9 +15,9 @@ ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essen
|
||||
ENV \
|
||||
DEBUG=false \
|
||||
NODE_VERSION=v24.17.0 \
|
||||
METEOR_RELEASE=METEOR@3.5-beta.12 \
|
||||
METEOR_RELEASE=METEOR@3.5-rc.2 \
|
||||
USE_EDGE=false \
|
||||
NPM_VERSION=11.11.0 \
|
||||
NPM_VERSION=11.12.1 \
|
||||
SRC_PATH=./ \
|
||||
WITH_API=true \
|
||||
MONGO_OPLOG_URL="" \
|
||||
@@ -67,6 +67,7 @@ ENV \
|
||||
OAUTH2_AUTH_ENDPOINT="" \
|
||||
OAUTH2_USERINFO_ENDPOINT="" \
|
||||
OAUTH2_TOKEN_ENDPOINT="" \
|
||||
OAUTH2_LOGOUT_ENDPOINT="" \
|
||||
OAUTH2_ID_MAP="" \
|
||||
OAUTH2_USERNAME_MAP="" \
|
||||
OAUTH2_AUTO_REGISTRATION="true" \
|
||||
|
||||
@@ -103,7 +103,7 @@ By working directly with WeKan ® you get the benefit of active maintenance and
|
||||
|
||||
## Getting Started with Development
|
||||
|
||||
The default branch uses Meteor 3.5-beta.7 with Node.js 24.x.
|
||||
The default branch uses Meteor 3.5-rc.2 with Node.js 24.x.
|
||||
See [CHANGELOG.md](https://github.com/wekan/wekan/blob/main/CHANGELOG.md) for the latest runtime updates.
|
||||
|
||||
To contribute, [create a fork](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#2-create-fork-of-httpsgithubcomwekanwekan-at-github-web-page) and run `./rebuild-wekan.sh` (or `./rebuild-wekan.bat` on Windows) as detailed [here](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#3-select-option-1-to-install-dependencies-and-then-enter). Once you're ready, please test your code and [submit a pull request (PR)](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#7-test).
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
|
||||
appVersion: "v9.63.0"
|
||||
appVersion: "v9.71.0"
|
||||
files:
|
||||
userUploads:
|
||||
- README.md
|
||||
|
||||
@@ -77,6 +77,7 @@ If *nix: chmod +x api.py => ./api.py users
|
||||
python3 api.py exportboardpdf BOARDID OUTPUT.pdf # Export a whole board to PDF
|
||||
python3 api.py importboardfrom SOURCE EXPORT.json # Import from trello/wekan/csv/jira/kanboard/excel/deck/openproject/github/gitlab/gitea/forgejo/asana/zenkit
|
||||
python3 api.py exportboardformat BOARDID FORMAT OUTPUT.json # Export to kanboard/trello/jira/deck/openproject/github/gitlab/gitea/forgejo/asana/zenkit
|
||||
python3 api.py importics BOARDID SWIMLANEID LISTID CALENDAR.ics # Import an iCalendar (.ics) file into a board as cards (one card per VEVENT)
|
||||
|
||||
Board Member API (Issue #5998):
|
||||
python3 api.py addboardmember BOARDID USERID ROLE # Add/activate board member. ROLE: admin, normal, comment, readonly, worker, normalassignedonly, commentassignedonly, readassignedonly, nocomments
|
||||
@@ -2021,4 +2022,21 @@ if arguments >= 4 and sys.argv[1] == 'exportboardformat':
|
||||
f.write(response.text)
|
||||
print('Board exported ({}) to: {}'.format(fmt, outputpath))
|
||||
|
||||
if arguments >= 5 and sys.argv[1] == 'importics':
|
||||
# Usage: python3 api.py importics BOARDID SWIMLANEID LISTID CALENDAR.ics
|
||||
# Imports an iCalendar (.ics) file into the board: one card is created per
|
||||
# VEVENT, with startAt (DTSTART) and dueAt (DTEND, or DTSTART when there is
|
||||
# no DTEND), so the events appear on the Calendar / Gantt views (issue #6323).
|
||||
boardid = sys.argv[2]
|
||||
swimlaneid = sys.argv[3]
|
||||
listid = sys.argv[4]
|
||||
icspath = sys.argv[5]
|
||||
with open(icspath) as f:
|
||||
ics_text = f.read()
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
url = wekanurl + apiboards + boardid + s + 'swimlanes' + s + swimlaneid + s + l + s + listid + s + 'ics'
|
||||
response = requests.post(url, headers=headers, json={'ics': ics_text})
|
||||
# Response: { "created": <n>, "cardIds": [ ... ] }
|
||||
print(response.text)
|
||||
|
||||
# ------- IMPORT/EXPORT TO OTHER TOOLS END -----------
|
||||
@@ -130,6 +130,9 @@ template(name="activity")
|
||||
if($eq activity.activityType 'a-endAt')
|
||||
| {{{_ 'activity-endDate' (sanitize endDate) cardLink}}}.
|
||||
|
||||
if($eq activity.activityType 'a-changedTitle')
|
||||
| {{{_ 'activity-changedTitle' (sanitize activity.value) cardLink}}}.
|
||||
|
||||
//- customField activity ------------------------------------------------
|
||||
if($eq activity.activityType 'createCustomField')
|
||||
| {{_ 'activity-customfield-created' customField}}.
|
||||
|
||||
@@ -135,3 +135,49 @@
|
||||
font-size: 0.8em;
|
||||
color: #999;
|
||||
}
|
||||
/* Threaded replies (issue #5907): indent replies under their parent. */
|
||||
.comments .comment.is-reply {
|
||||
margin-inline-start: 24px;
|
||||
border-inline-start: 2px solid #e0e0e0;
|
||||
padding-inline-start: 8px;
|
||||
}
|
||||
.comments .comment .comment-desc .comment-in-reply-to {
|
||||
font-size: 0.8em;
|
||||
color: #999;
|
||||
border-inline-start: 2px solid #ccc;
|
||||
padding-inline-start: 6px;
|
||||
margin: 3px 0;
|
||||
display: block;
|
||||
}
|
||||
.comments .comment .comment-desc .comment-in-reply-to .comment-in-reply-to-label {
|
||||
font-weight: 700;
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
.comments .comment .comment-desc .comment-in-reply-to .comment-in-reply-to-text {
|
||||
font-style: italic;
|
||||
}
|
||||
/* "In reply to" banner above the new-comment form. */
|
||||
.new-comment .comment-reply-banner {
|
||||
background: #f4f4f4;
|
||||
border-inline-start: 2px solid #ccc;
|
||||
padding: 4px 6px;
|
||||
margin: 0 4px 6px 0;
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.new-comment .comment-reply-banner .comment-reply-banner-label {
|
||||
font-weight: 700;
|
||||
}
|
||||
.new-comment .comment-reply-banner .comment-reply-banner-text {
|
||||
flex: 1;
|
||||
font-style: italic;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.new-comment .comment-reply-banner a {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -2,6 +2,14 @@ template(name="commentForm")
|
||||
.new-comment.js-new-comment(
|
||||
class="{{#if commentFormIsOpen}}is-open{{/if}}")
|
||||
+userAvatar(userId=currentUser._id noRemove=true)
|
||||
if replyToComment
|
||||
.comment-reply-banner.js-reply-banner
|
||||
span.comment-reply-banner-label {{_ 'comment-in-reply-to'}}:
|
||||
span.comment-reply-banner-text
|
||||
+viewer
|
||||
= replyToComment.text
|
||||
a.js-cancel-reply(title="{{_ 'close' }}")
|
||||
i.fa.fa-times-thin
|
||||
form.js-new-comment-form
|
||||
+editor(class="js-new-comment-input")
|
||||
| {{getUnsavedValue 'cardComment' currentCard._id}}
|
||||
@@ -14,12 +22,19 @@ template(name="comments")
|
||||
+comment(commentData)
|
||||
|
||||
template(name="comment")
|
||||
.comment
|
||||
.comment(class="{{#if parentId}}is-reply{{/if}}")
|
||||
+userAvatar(userId=userId)
|
||||
p.comment-desc
|
||||
span.comment-member
|
||||
+memberName(user=user)
|
||||
|
||||
if parentComment
|
||||
.comment-in-reply-to
|
||||
span.comment-in-reply-to-label {{_ 'comment-in-reply-to'}}:
|
||||
span.comment-in-reply-to-text
|
||||
+viewer
|
||||
= parentComment.text
|
||||
|
||||
+inlinedForm(classNames='js-edit-comment')
|
||||
+editor(autofocus=true)
|
||||
= text
|
||||
@@ -33,9 +48,10 @@ template(name="comment")
|
||||
= text
|
||||
+commentReactions(reactions=reactions commentId=_id)
|
||||
span(title=createdAt).comment-meta {{ displayDate createdAt }}
|
||||
if($eq currentUser._id userId)
|
||||
+editOrDeleteComment
|
||||
else if currentUser.isBoardAdmin
|
||||
if currentUser.isBoardMember
|
||||
= ' - '
|
||||
a.js-reply-comment {{_ "comment-reply"}}
|
||||
if canEditThisComment
|
||||
+editOrDeleteComment
|
||||
|
||||
template(name="editOrDeleteComment")
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import CardComments from '/models/cardComments';
|
||||
import CardComments, { canEditComment } from '/models/cardComments';
|
||||
import { UnsavedEdits } from '/client/lib/unsavedEdits';
|
||||
import { EscapeActions } from '/client/lib/escapeActions';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import autosize from 'autosize';
|
||||
|
||||
const commentFormIsOpen = new ReactiveVar(false);
|
||||
// _id of the comment currently being replied to (threaded replies, issue #5907).
|
||||
// Empty string means a new top-level comment.
|
||||
const replyToCommentId = new ReactiveVar('');
|
||||
|
||||
Template.commentForm.onDestroyed(function () {
|
||||
commentFormIsOpen.set(false);
|
||||
@@ -16,6 +19,12 @@ Template.commentForm.helpers({
|
||||
commentFormIsOpen() {
|
||||
return commentFormIsOpen.get();
|
||||
},
|
||||
// The comment being replied to, if any, so the form can show an
|
||||
// "In reply to ..." banner with a way to cancel.
|
||||
replyToComment() {
|
||||
const id = replyToCommentId.get();
|
||||
return id ? ReactiveCache.getCardComment(id) : undefined;
|
||||
},
|
||||
});
|
||||
|
||||
Template.commentForm.events({
|
||||
@@ -32,11 +41,14 @@ Template.commentForm.events({
|
||||
boardId = card.linkedId;
|
||||
}
|
||||
if (text) {
|
||||
const parentId = replyToCommentId.get();
|
||||
CardComments.insert({
|
||||
text,
|
||||
boardId,
|
||||
cardId,
|
||||
parentId: parentId || '',
|
||||
});
|
||||
replyToCommentId.set('');
|
||||
resetCommentInput(input);
|
||||
Tracker.flush();
|
||||
autosize.update(input);
|
||||
@@ -44,6 +56,10 @@ Template.commentForm.events({
|
||||
}
|
||||
evt.preventDefault();
|
||||
},
|
||||
'click .js-cancel-reply'(evt) {
|
||||
evt.preventDefault();
|
||||
replyToCommentId.set('');
|
||||
},
|
||||
// Pressing Ctrl+Enter should submit the form
|
||||
'keydown form textarea'(evt, tpl) {
|
||||
if (evt.keyCode === 13 && (evt.metaKey || evt.ctrlKey)) {
|
||||
@@ -60,10 +76,45 @@ Template.comments.helpers({
|
||||
},
|
||||
});
|
||||
|
||||
Template.comment.helpers({
|
||||
// Whether the current user may edit/delete this comment, honouring the
|
||||
// board's restrictCommentEditing setting (issue #5906). Mirrors the
|
||||
// server-side enforcement so the UI hides buttons the server would reject.
|
||||
canEditThisComment() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (!user) return false;
|
||||
const isAuthor = user._id === this.userId;
|
||||
const board = ReactiveCache.getBoard(this.boardId);
|
||||
const isBoardAdmin = !!board && board.hasAdmin(user._id);
|
||||
const restrictCommentEditing = !!board && !!board.restrictCommentEditing;
|
||||
return canEditComment({ isAuthor, isBoardAdmin, restrictCommentEditing });
|
||||
},
|
||||
// The parent comment this one replies to, or undefined.
|
||||
parentComment() {
|
||||
return this.parentId ? ReactiveCache.getCardComment(this.parentId) : undefined;
|
||||
},
|
||||
});
|
||||
|
||||
Template.comment.events({
|
||||
'click .js-reply-comment'(evt) {
|
||||
evt.preventDefault();
|
||||
replyToCommentId.set(this._id);
|
||||
// Open and focus the new-comment form.
|
||||
const input = $('.js-new-comment-input');
|
||||
input.click();
|
||||
Tracker.afterFlush(() => {
|
||||
$('.js-new-comment-input').focus();
|
||||
});
|
||||
},
|
||||
'click .js-delete-comment': Popup.afterConfirm('deleteComment', function () {
|
||||
const commentId = this._id;
|
||||
CardComments.remove(commentId);
|
||||
// #3252: only remove if the doc is still in the local cache. Under heavy
|
||||
// archive/delete churn the comment can already be evicted from Minimongo,
|
||||
// and CardComments.remove() of a missing _id throws "Removed nonexistent
|
||||
// document" on the client.
|
||||
if (commentId && CardComments.findOne(commentId)) {
|
||||
CardComments.remove(commentId);
|
||||
}
|
||||
Popup.back();
|
||||
}),
|
||||
'submit .js-edit-comment'(evt, tpl) {
|
||||
|
||||
@@ -9,7 +9,7 @@ Template.archivedBoards.onCreated(function () {
|
||||
|
||||
Template.archivedBoards.helpers({
|
||||
isBoardAdmin() {
|
||||
return ReactiveCache.getCurrentUser().isBoardAdmin();
|
||||
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
|
||||
},
|
||||
|
||||
archivedBoards() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import dragscroll from '@wekanteam/dragscroll';
|
||||
import '/client/lib/dragscrollTouch';
|
||||
import { boardConverter } from '/client/lib/boardConverter';
|
||||
import { formatDateByUserPreference } from '/imports/lib/dateUtils';
|
||||
import { toFullCalendarFirstDay } from '/client/lib/calendarFirstDay';
|
||||
import Swimlanes from '/models/swimlanes';
|
||||
import Lists from '/models/lists';
|
||||
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
|
||||
@@ -942,9 +943,19 @@ Template.calendarView.helpers({
|
||||
return translated && translated !== key ? translated : fallback;
|
||||
};
|
||||
|
||||
// #5521: respect the user's "start day of week" setting instead of the
|
||||
// FullCalendar locale default. getStartDayOfWeek() returns a zero-based day
|
||||
// index (0=Sunday..6=Saturday), the same convention FullCalendar's firstDay
|
||||
// uses; toFullCalendarFirstDay() validates it and falls back to Monday (1).
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const firstDay = toFullCalendarFirstDay(
|
||||
currentUser ? currentUser.getStartDayOfWeek() : 1,
|
||||
);
|
||||
|
||||
return {
|
||||
id: 'calendar-view',
|
||||
initialView: 'dayGridMonth',
|
||||
firstDay,
|
||||
editable: true,
|
||||
selectable: true,
|
||||
weekNumbers: true,
|
||||
|
||||
@@ -937,3 +937,20 @@
|
||||
.desktop-mode .board-header .board-header-btn i {
|
||||
font-size: clamp(12px, 2.5vw, 16px) !important;
|
||||
}
|
||||
|
||||
/* #6419: make the board-header action buttons touch-friendly by VIEWPORT WIDTH,
|
||||
not only when the mobile-mode class is toggled. The measured default at a phone
|
||||
width was 28px tall / 13px font (too small to tap reliably). Enlarge vertically
|
||||
+ bump the icon/font; width is left to flex so the existing horizontal layout
|
||||
is unchanged. Applies on real phones whether or not mobile-mode auto-applied. */
|
||||
@media screen and (max-width: 800px) {
|
||||
.board-header-btns .board-header-btn {
|
||||
min-height: 40px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.board-header-btns .board-header-btn i.fa {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ template(name="boardHeaderBar")
|
||||
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
|
||||
if showStarCounter
|
||||
span.board-star-counter {{currentBoard.stars}}
|
||||
a.board-header-btn(title="{{_ 'sort-cards'}}" class="{{#if isSortActive }}emphasis{{else}} js-sort-cards {{/if}}")
|
||||
a.board-header-btn(title="{{_ 'sort-cards'}}" class="js-sort-cards {{#if isSortActive }}emphasis{{/if}}")
|
||||
i.fa.fa-sort
|
||||
span {{#if isSortActive }}{{_ 'sort-is-on'}}{{else}}{{_ 'sort-cards'}}{{/if}}
|
||||
if isSortActive
|
||||
@@ -78,7 +78,7 @@ template(name="boardHeaderBar")
|
||||
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
|
||||
title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}} {{_ 'starred-boards-description'}}")
|
||||
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
|
||||
a.board-header-btn(title="{{_ 'sort-cards'}}" class="{{#if isSortActive }}emphasis{{else}} js-sort-cards {{/if}}")
|
||||
a.board-header-btn(title="{{_ 'sort-cards'}}" class="js-sort-cards {{#if isSortActive }}emphasis{{/if}}")
|
||||
i.fa.fa-sort
|
||||
span {{#if isSortActive }}{{_ 'sort-is-on'}}{{else}}{{_ 'sort-cards'}}{{/if}}
|
||||
if isSortActive
|
||||
|
||||
@@ -17,6 +17,21 @@ const UPCLS = 'fa-sort-up';
|
||||
*/
|
||||
const sortCardsBy = new ReactiveVar('');
|
||||
|
||||
// Persist the card sort so it survives a page reload. Session is in-memory and
|
||||
// is reset on reload, which is why "sort by due date" kept reverting to the
|
||||
// default. See https://github.com/wekan/wekan/issues/5886
|
||||
const CARDS_SORT_BY_STORAGE_KEY = 'wekan-cards-sortBy';
|
||||
function setCardsSortBy(sortBy) {
|
||||
Session.set('sortBy', sortBy);
|
||||
try {
|
||||
if (sortBy) {
|
||||
window.localStorage.setItem(CARDS_SORT_BY_STORAGE_KEY, JSON.stringify(sortBy));
|
||||
} else {
|
||||
window.localStorage.removeItem(CARDS_SORT_BY_STORAGE_KEY);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
Template.boardChangeTitlePopup.events({
|
||||
async submit(event, templateInstance) {
|
||||
event.preventDefault();
|
||||
@@ -41,6 +56,16 @@ Template.boardChangeTitlePopup.events({
|
||||
|
||||
Template.boardHeaderBar.onCreated(function () {
|
||||
Meteor.subscribe('tableVisibilityModeSettings');
|
||||
// Restore the persisted card sort after a reload (Session is in-memory), so
|
||||
// both the sorting and the sort icon are remembered. See #5886.
|
||||
if (!Session.get('sortBy')) {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(CARDS_SORT_BY_STORAGE_KEY);
|
||||
if (stored) {
|
||||
Session.set('sortBy', JSON.parse(stored));
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
});
|
||||
|
||||
Template.boardHeaderBar.helpers({
|
||||
@@ -171,7 +196,7 @@ Template.boardHeaderBar.events({
|
||||
Filter.reset();
|
||||
},
|
||||
'click .js-sort-reset'() {
|
||||
Session.set('sortBy', '');
|
||||
setCardsSortBy('');
|
||||
},
|
||||
'click .js-open-search-view'() {
|
||||
const sidebar = getSidebarInstance();
|
||||
@@ -529,7 +554,7 @@ Template.cardsSortPopup.events({
|
||||
const sortBy = {
|
||||
dueAt: 1,
|
||||
};
|
||||
Session.set('sortBy', sortBy);
|
||||
setCardsSortBy(sortBy);
|
||||
sortCardsBy.set(TAPi18n.__('due-date'));
|
||||
Popup.back();
|
||||
},
|
||||
@@ -537,7 +562,7 @@ Template.cardsSortPopup.events({
|
||||
const sortBy = {
|
||||
title: 1,
|
||||
};
|
||||
Session.set('sortBy', sortBy);
|
||||
setCardsSortBy(sortBy);
|
||||
sortCardsBy.set(TAPi18n.__('title'));
|
||||
Popup.back();
|
||||
},
|
||||
@@ -545,7 +570,7 @@ Template.cardsSortPopup.events({
|
||||
const sortBy = {
|
||||
createdAt: 1,
|
||||
};
|
||||
Session.set('sortBy', sortBy);
|
||||
setCardsSortBy(sortBy);
|
||||
sortCardsBy.set(TAPi18n.__('date-created-newest-first'));
|
||||
Popup.back();
|
||||
},
|
||||
@@ -553,7 +578,7 @@ Template.cardsSortPopup.events({
|
||||
const sortBy = {
|
||||
createdAt: -1,
|
||||
};
|
||||
Session.set('sortBy', sortBy);
|
||||
setCardsSortBy(sortBy);
|
||||
sortCardsBy.set(TAPi18n.__('date-created-oldest-first'));
|
||||
Popup.back();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Pure helpers for building the option labels shown in board / list /
|
||||
* swimlane selectors (e.g. the move-card and copy-card popups).
|
||||
*
|
||||
* Regression #6135 ("numbering boards, list, swim caused some issues"):
|
||||
* a previous change prepended a sequential number to every option label
|
||||
* (e.g. "3. My Board"). That broke two things:
|
||||
*
|
||||
* 1. Boards whose name itself starts with a digit became hard to read.
|
||||
* 2. First-letter keyboard navigation in the native <select> dropdown
|
||||
* stopped working, because every option started with a number instead
|
||||
* of the board name.
|
||||
*
|
||||
* The fix: numbering is OPTIONAL and OFF by default. By default the label is
|
||||
* exactly the entity name, so its first character is the name's first
|
||||
* character and first-letter navigation works again. When numbering is
|
||||
* explicitly enabled, the number is appended as a trailing visual suffix so
|
||||
* the label still STARTS with the name.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build the label for a selectable board/list/swimlane option.
|
||||
*
|
||||
* @param {string} name The entity (board/list/swimlane) name/title.
|
||||
* @param {number} index Zero-based position in the list.
|
||||
* @param {Object} [opts]
|
||||
* @param {boolean} [opts.numbered=false] When true, append the 1-based
|
||||
* number as a trailing suffix. When false (default), return the bare
|
||||
* name so first-letter keyboard navigation keeps working.
|
||||
* @returns {string} The option label.
|
||||
*/
|
||||
export function boardOptionLabel(name, index, { numbered = false } = {}) {
|
||||
const label = name == null ? '' : String(name);
|
||||
if (!numbered) {
|
||||
return label;
|
||||
}
|
||||
const position = Number(index);
|
||||
const number = Number.isFinite(position) ? position + 1 : index;
|
||||
return `${label} (${number})`;
|
||||
}
|
||||
|
||||
export default boardOptionLabel;
|
||||
@@ -297,6 +297,62 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* #5799: board-name search box in the All Boards header */
|
||||
.boards-path-header .board-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.boards-path-header .board-search input {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.boards-path-header .board-search .board-search-clear {
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.boards-path-header .board-search .board-search-clear:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* #5799: board grid pagination controls, mirroring Admin Panel > People */
|
||||
.boards-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.boards-pagination button {
|
||||
min-width: 32px;
|
||||
height: 30px;
|
||||
border: 1px solid #bbb;
|
||||
background: #fff;
|
||||
border-radius: 0.4vw;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.boards-pagination button.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.boards-page-info {
|
||||
min-width: 72px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.boards-path-header .path-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@@ -85,6 +85,18 @@ template(name="boardList")
|
||||
i.fa.fa-thumb-tack
|
||||
| {{_ 'multi-selection-active'}}
|
||||
.path-right
|
||||
.board-search
|
||||
span.emoji-icon
|
||||
i.fa.fa-search
|
||||
input.js-board-search-input(type="text" placeholder="{{_ 'search-boards'}}" value="{{boardSearch}}" autocomplete="off")
|
||||
if boardSearch
|
||||
a.board-search-clear.js-board-search-clear(title="{{_ 'filter-clear'}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-times
|
||||
a.board-header-btn.js-open-boards-sort(title="{{_ 'sort-boards'}}" class="{{#unless isBoardsSort 'custom'}}emphasis{{/unless}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-sort-alpha-asc
|
||||
span {{_ 'sort'}}
|
||||
if canModifyBoards
|
||||
if hasBoardsSelected
|
||||
button.js-archive-selected-boards.board-header-btn
|
||||
@@ -104,6 +116,13 @@ template(name="boardList")
|
||||
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-times
|
||||
if boardsPaginationActive
|
||||
.boards-pagination
|
||||
button.js-boards-prev-page(class="{{#if hasBoardsPrevPage}}{{else}}disabled{{/if}}")
|
||||
| <
|
||||
span.boards-page-info {{boardsCurrentPage}} / {{boardsTotalPages}}
|
||||
button.js-boards-next-page(class="{{#if hasBoardsNextPage}}{{else}}disabled{{/if}}")
|
||||
| >
|
||||
ul.board-list.clearfix.js-boards.dragscroll(class="{{#if isMiniScreen}}mobile-view{{/if}} {{#if BoardMultiSelection.isActive}}is-multiselection-active{{/if}}")
|
||||
li.js-add-board
|
||||
if isSelectedMenu 'templates'
|
||||
@@ -205,6 +224,24 @@ template(name="boardList")
|
||||
span.emoji-icon
|
||||
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
|
||||
|
||||
template(name="boardsSortPopup")
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-boards-sort(data-sort="custom" class="{{#if isBoardsSort 'custom'}}active{{/if}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-arrows
|
||||
| {{_ 'sort-boards-custom'}}
|
||||
li
|
||||
a.js-boards-sort(data-sort="title-asc" class="{{#if isBoardsSort 'title-asc'}}active{{/if}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-sort-alpha-asc
|
||||
| {{_ 'sort-boards-title-asc'}}
|
||||
li
|
||||
a.js-boards-sort(data-sort="title-desc" class="{{#if isBoardsSort 'title-desc'}}active{{/if}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-sort-alpha-desc
|
||||
| {{_ 'sort-boards-title-desc'}}
|
||||
|
||||
template(name="boardListHeaderBar")
|
||||
h1 {{_ title }}
|
||||
//.board-header-btns.right
|
||||
|
||||
@@ -57,6 +57,10 @@ function shareBoardWith(boardId, shareType, name, id) {
|
||||
|
||||
const DEFAULT_WORKSPACE_ICON = '📁';
|
||||
|
||||
// #5799: how many board icons to show per page in the sorted (non-custom) modes.
|
||||
// Matches the Admin Panel > People page size.
|
||||
const BOARDS_PER_PAGE = 25;
|
||||
|
||||
function getCurrentWorkspacesTree() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const tree =
|
||||
@@ -164,6 +168,13 @@ Template.boardList.onCreated(function () {
|
||||
this.selectedMenu = new ReactiveVar(Session.get('boardListMenu') || 'starred');
|
||||
this.selectedWorkspaceIdVar = new ReactiveVar(null);
|
||||
this.workspacesTreeVar = new ReactiveVar([]);
|
||||
// #5799: free-text search by board name. When non-empty it searches across
|
||||
// ALL the user's boards (Starred, Templates, Remaining and every workspace),
|
||||
// ignoring the selected-menu filter.
|
||||
this.boardSearchVar = new ReactiveVar('');
|
||||
// #5799: server-side pagination state for the sorted (non-custom) modes.
|
||||
this.boardsPageVar = new ReactiveVar(1);
|
||||
this.pagedBoardsVar = new ReactiveVar({ ids: [], total: 0 });
|
||||
// #5850: the user's orgs/teams that have the per-org/team Shared Templates
|
||||
// flag set (plus email domains), fetched via a non-admin server method since
|
||||
// the org/team publications are admin-only. Gates the drag-to-share targets.
|
||||
@@ -262,6 +273,50 @@ Template.boardList.onCreated(function () {
|
||||
}
|
||||
});
|
||||
|
||||
// #5799: reset to the first page whenever the active filter changes (search
|
||||
// text, selected menu/workspace or sort mode). Depends only on those signals,
|
||||
// not on boardsPageVar, so paging next/prev does not reset itself.
|
||||
this.autorun(() => {
|
||||
this.boardSearchVar.get();
|
||||
this.selectedMenu.get();
|
||||
const cu = ReactiveCache.getCurrentUser();
|
||||
if (cu && typeof cu.getAllBoardsSortBy === 'function') {
|
||||
cu.getAllBoardsSortBy();
|
||||
}
|
||||
this.boardsPageVar.set(1);
|
||||
});
|
||||
|
||||
// #5799: fetch the current page of boards from the server for the sorted
|
||||
// (non-custom) modes, so only that page of icons is rendered. Custom (manual
|
||||
// drag order) stays unpaginated client-side so drag-reordering keeps working.
|
||||
// Uses the effective current user server-side, so it also works under
|
||||
// GlobalAdmin impersonation.
|
||||
this.autorun(() => {
|
||||
const cu = ReactiveCache.getCurrentUser();
|
||||
const sortBy =
|
||||
cu && typeof cu.getAllBoardsSortBy === 'function'
|
||||
? cu.getAllBoardsSortBy()
|
||||
: 'custom';
|
||||
if (sortBy === 'custom') {
|
||||
this.pagedBoardsVar.set({ ids: [], total: 0 });
|
||||
return;
|
||||
}
|
||||
const search = (this.boardSearchVar.get() || '').trim();
|
||||
const menu = this.selectedMenu.get();
|
||||
const page = this.boardsPageVar.get();
|
||||
Meteor.call(
|
||||
'getAllBoardsPage',
|
||||
{ search, sortBy, menu, page, perPage: BOARDS_PER_PAGE },
|
||||
(err, res) => {
|
||||
if (err) {
|
||||
console.error('getAllBoardsPage failed:', err);
|
||||
return;
|
||||
}
|
||||
if (res) this.pagedBoardsVar.set(res);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// The templates-container board is no longer auto-created at signup (#2339,
|
||||
// #5850), and it is no longer auto-created when the user merely opens the
|
||||
// Templates sub-view either: opening the view must not leave behind an empty
|
||||
@@ -486,27 +541,53 @@ Template.boardList.helpers({
|
||||
|
||||
const boards = ReactiveCache.getBoards(query, {});
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
|
||||
// #5799: in a sorted (non-custom) mode the server already computed the
|
||||
// current page (filtered by menu/search and sorted), so render exactly that
|
||||
// ordered page of board icons. Custom (manual drag order) falls through to
|
||||
// the unpaginated client-side path below so drag-reordering keeps working.
|
||||
const sortMode =
|
||||
currentUser && typeof currentUser.getAllBoardsSortBy === 'function'
|
||||
? currentUser.getAllBoardsSortBy()
|
||||
: 'custom';
|
||||
if (sortMode !== 'custom') {
|
||||
const paged = tpl.pagedBoardsVar.get();
|
||||
return (paged.ids || [])
|
||||
.map((id) => ReactiveCache.getBoard(id))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
let list = boards;
|
||||
// Apply left menu filtering
|
||||
const sel = tpl.selectedMenu.get();
|
||||
const assignments =
|
||||
(currentUser &&
|
||||
currentUser.profile &&
|
||||
currentUser.profile.boardWorkspaceAssignments) ||
|
||||
{};
|
||||
if (sel === 'starred') {
|
||||
// Starred boards are always visible in Starred.
|
||||
list = list.filter((b) => currentUser && currentUser.hasStarred(b._id));
|
||||
} else if (sel === 'templates') {
|
||||
list = list.filter((b) => b.type === 'template-container');
|
||||
} else if (sel === 'remaining') {
|
||||
// Remaining only shows boards not assigned to any workspace.
|
||||
list = list.filter(
|
||||
(b) => !assignments[b._id] && b.type !== 'template-container',
|
||||
);
|
||||
|
||||
// #5799: when a board-name search is active, search across ALL the user's
|
||||
// boards (every menu/workspace) by title and skip the menu filter, so a
|
||||
// board in any category — Starred, Templates, Remaining or a (sub)workspace
|
||||
// — is found from a single search box.
|
||||
const search = (tpl.boardSearchVar.get() || '').trim().toLowerCase();
|
||||
if (search) {
|
||||
list = list.filter((b) => (b.title || '').toLowerCase().includes(search));
|
||||
} else {
|
||||
// Workspace view includes all boards in that workspace, including starred.
|
||||
list = list.filter((b) => assignments[b._id] === sel);
|
||||
// Apply left menu filtering
|
||||
const sel = tpl.selectedMenu.get();
|
||||
if (sel === 'starred') {
|
||||
// Starred boards are always visible in Starred.
|
||||
list = list.filter((b) => currentUser && currentUser.hasStarred(b._id));
|
||||
} else if (sel === 'templates') {
|
||||
list = list.filter((b) => b.type === 'template-container');
|
||||
} else if (sel === 'remaining') {
|
||||
// Remaining only shows boards not assigned to any workspace.
|
||||
list = list.filter(
|
||||
(b) => !assignments[b._id] && b.type !== 'template-container',
|
||||
);
|
||||
} else {
|
||||
// Workspace view includes all boards in that workspace, including starred.
|
||||
list = list.filter((b) => assignments[b._id] === sel);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentUser && typeof currentUser.sortBoardsForUser === 'function') {
|
||||
@@ -650,6 +731,46 @@ Template.boardList.helpers({
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
return currentUser && !currentUser.isCommentOnly();
|
||||
},
|
||||
// #5799: current board-name search text (for the input value and the clear button).
|
||||
boardSearch() {
|
||||
return Template.instance().boardSearchVar.get();
|
||||
},
|
||||
// #5799: pagination controls (only shown in the sorted, non-custom modes).
|
||||
boardsPaginationActive() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const sortMode =
|
||||
currentUser && typeof currentUser.getAllBoardsSortBy === 'function'
|
||||
? currentUser.getAllBoardsSortBy()
|
||||
: 'custom';
|
||||
if (sortMode === 'custom') return false;
|
||||
const total = Template.instance().pagedBoardsVar.get().total || 0;
|
||||
return total > BOARDS_PER_PAGE;
|
||||
},
|
||||
boardsCurrentPage() {
|
||||
return Template.instance().boardsPageVar.get();
|
||||
},
|
||||
boardsTotalPages() {
|
||||
const total = Template.instance().pagedBoardsVar.get().total || 0;
|
||||
return Math.max(1, Math.ceil(total / BOARDS_PER_PAGE));
|
||||
},
|
||||
hasBoardsPrevPage() {
|
||||
return Template.instance().boardsPageVar.get() > 1;
|
||||
},
|
||||
hasBoardsNextPage() {
|
||||
const tpl = Template.instance();
|
||||
const total = tpl.pagedBoardsVar.get().total || 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / BOARDS_PER_PAGE));
|
||||
return tpl.boardsPageVar.get() < totalPages;
|
||||
},
|
||||
// #5799: current All Boards sort mode ('custom' | 'title-asc' | 'title-desc').
|
||||
isBoardsSort(mode) {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const current =
|
||||
currentUser && typeof currentUser.getAllBoardsSortBy === 'function'
|
||||
? currentUser.getAllBoardsSortBy()
|
||||
: 'custom';
|
||||
return current === mode;
|
||||
},
|
||||
hasBoardsSelected() {
|
||||
return BoardMultiSelection.count() > 0;
|
||||
},
|
||||
@@ -745,6 +866,41 @@ Template.boardList.events({
|
||||
Popup.open('createBoard')(evt);
|
||||
}
|
||||
},
|
||||
// #5799: choose how the All Boards page is sorted.
|
||||
'click .js-open-boards-sort': Popup.open('boardsSort'),
|
||||
// #5799: search boards by name across all categories.
|
||||
'input .js-board-search-input'(evt, tpl) {
|
||||
tpl.boardSearchVar.set(evt.currentTarget.value);
|
||||
},
|
||||
'keydown .js-board-search-input'(evt, tpl) {
|
||||
// Esc clears the search.
|
||||
if (evt.keyCode === 27) {
|
||||
tpl.boardSearchVar.set('');
|
||||
evt.currentTarget.value = '';
|
||||
}
|
||||
},
|
||||
'click .js-board-search-clear'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.boardSearchVar.set('');
|
||||
const input = tpl.find('.js-board-search-input');
|
||||
if (input) {
|
||||
input.value = '';
|
||||
input.focus();
|
||||
}
|
||||
},
|
||||
// #5799: board grid pagination (sorted modes only).
|
||||
'click .js-boards-prev-page'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const page = tpl.boardsPageVar.get();
|
||||
if (page > 1) tpl.boardsPageVar.set(page - 1);
|
||||
},
|
||||
'click .js-boards-next-page'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const total = tpl.pagedBoardsVar.get().total || 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / BOARDS_PER_PAGE));
|
||||
const page = tpl.boardsPageVar.get();
|
||||
if (page < totalPages) tpl.boardsPageVar.set(page + 1);
|
||||
},
|
||||
'click .js-star-board'(evt) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
@@ -1157,6 +1313,30 @@ Template.boardList.events({
|
||||
},
|
||||
});
|
||||
|
||||
// #5799: All Boards sort popup — pick custom (manual drag order) or
|
||||
// alphabetical A→Z / Z→A. The choice is stored per user.
|
||||
Template.boardsSortPopup.helpers({
|
||||
isBoardsSort(mode) {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const current =
|
||||
currentUser && typeof currentUser.getAllBoardsSortBy === 'function'
|
||||
? currentUser.getAllBoardsSortBy()
|
||||
: 'custom';
|
||||
return current === mode;
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardsSortPopup.events({
|
||||
'click .js-boards-sort'(evt) {
|
||||
evt.preventDefault();
|
||||
const mode = evt.currentTarget.getAttribute('data-sort');
|
||||
if (mode) {
|
||||
Meteor.call('setAllBoardsSortBy', mode);
|
||||
}
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
|
||||
Template.workspaceActionsPopup.helpers({
|
||||
workspaceName() {
|
||||
return this.name || '';
|
||||
|
||||
@@ -290,7 +290,7 @@ Template.attachmentGallery.helpers({
|
||||
return filesCursor.each();
|
||||
},
|
||||
isBoardAdmin() {
|
||||
return ReactiveCache.getCurrentUser().isBoardAdmin();
|
||||
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
|
||||
},
|
||||
fileSize(size) {
|
||||
const ret = filesize(size);
|
||||
|
||||
@@ -44,13 +44,13 @@ template(name="cardCustomField-number")
|
||||
a.js-close-inlined-form
|
||||
else
|
||||
a.js-open-inlined-form
|
||||
if value
|
||||
= value
|
||||
if formattedValue
|
||||
= formattedValue
|
||||
else
|
||||
| {{_ 'edit'}}
|
||||
else
|
||||
if value
|
||||
= value
|
||||
if formattedValue
|
||||
= formattedValue
|
||||
|
||||
template(name="cardCustomField-checkbox")
|
||||
.js-checklist-item.checklist-item(class="{{#if data.value }}is-checked{{/if}}")
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from '/imports/lib/dateUtils';
|
||||
import { CustomFieldStringTemplate } from '/client/lib/customFields'
|
||||
import { getCurrentCardFromContext } from '/client/lib/currentCard';
|
||||
import { formatNumberValue } from '/imports/lib/customNumberFormat';
|
||||
import { EscapeActions } from '/client/lib/escapeActions';
|
||||
import { getSidebarInstance } from '/client/features/sidebar/service';
|
||||
|
||||
@@ -91,10 +92,21 @@ Template['cardCustomField-number'].onCreated(function () {
|
||||
this.customFieldId = Template.currentData()._id;
|
||||
});
|
||||
|
||||
Template['cardCustomField-number'].helpers({
|
||||
// Render blank / cleared / non-numeric values as empty instead of "NaN" (#2091).
|
||||
formattedValue() {
|
||||
return formatNumberValue(this.value);
|
||||
},
|
||||
});
|
||||
|
||||
Template['cardCustomField-number'].events({
|
||||
'submit .js-card-customfield-number'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const value = parseInt(tpl.find('input').value, 10);
|
||||
const rawValue = tpl.find('input').value;
|
||||
// A cleared/blank input parses to NaN; store '' instead so it renders empty
|
||||
// rather than as "NaN" (#2091).
|
||||
const parsed = parseInt(rawValue, 10);
|
||||
const value = Number.isNaN(parsed) ? '' : parsed;
|
||||
tpl.card.setCustomField(tpl.customFieldId, value);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,23 +8,27 @@
|
||||
.card-date.is-active {
|
||||
background-color: #b3b3b3;
|
||||
}
|
||||
/* Date status colors - red = overdue, amber = due soon, no shade = not due */
|
||||
/* Date status colors - red = overdue, amber = due soon, no shade = not due.
|
||||
* These status rules use !important so they override the date-type base
|
||||
* colours below (e.g. .card-date.due-date yellow). Without this, an overdue
|
||||
* due-date badge in the card detail showed yellow/amber instead of red, while
|
||||
* the minicard (which already used !important) showed red - issue #5965. */
|
||||
.card-date.overdue {
|
||||
background-color: #ff4444; /* Red for overdue */
|
||||
color: #fff;
|
||||
background-color: #ff4444 !important; /* Red for overdue */
|
||||
color: #fff !important;
|
||||
}
|
||||
.card-date.overdue:hover,
|
||||
.card-date.overdue.is-active {
|
||||
background-color: #cc3333;
|
||||
background-color: #cc3333 !important;
|
||||
}
|
||||
|
||||
.card-date.due-soon {
|
||||
background-color: #ffaa00; /* Amber for due soon */
|
||||
color: #000;
|
||||
background-color: #ffaa00 !important; /* Amber for due soon */
|
||||
color: #000 !important;
|
||||
}
|
||||
.card-date.due-soon:hover,
|
||||
.card-date.due-soon.is-active {
|
||||
background-color: #e69900;
|
||||
background-color: #e69900 !important;
|
||||
}
|
||||
|
||||
.card-date.not-due {
|
||||
@@ -32,48 +36,48 @@
|
||||
}
|
||||
|
||||
.card-date.current {
|
||||
background-color: #5ba639; /* Green for current/active */
|
||||
color: #fff;
|
||||
background-color: #5ba639 !important; /* Green for current/active */
|
||||
color: #fff !important;
|
||||
}
|
||||
.card-date.current:hover,
|
||||
.card-date.current.is-active {
|
||||
background-color: #46802c;
|
||||
background-color: #46802c !important;
|
||||
}
|
||||
|
||||
.card-date.completed {
|
||||
background-color: #90ee90; /* Light green for completed */
|
||||
color: #000;
|
||||
background-color: #90ee90 !important; /* Light green for completed */
|
||||
color: #000 !important;
|
||||
}
|
||||
.card-date.completed:hover,
|
||||
.card-date.completed.is-active {
|
||||
background-color: #7dd87d;
|
||||
background-color: #7dd87d !important;
|
||||
}
|
||||
|
||||
.card-date.completed-early {
|
||||
background-color: #4caf50; /* Green for completed early */
|
||||
color: #fff;
|
||||
background-color: #4caf50 !important; /* Green for completed early */
|
||||
color: #fff !important;
|
||||
}
|
||||
.card-date.completed-early:hover,
|
||||
.card-date.completed-early.is-active {
|
||||
background-color: #45a049;
|
||||
background-color: #45a049 !important;
|
||||
}
|
||||
|
||||
.card-date.completed-late {
|
||||
background-color: #ff9800; /* Orange for completed late */
|
||||
color: #fff;
|
||||
background-color: #ff9800 !important; /* Orange for completed late */
|
||||
color: #fff !important;
|
||||
}
|
||||
.card-date.completed-late:hover,
|
||||
.card-date.completed-late.is-active {
|
||||
background-color: #f57c00;
|
||||
background-color: #f57c00 !important;
|
||||
}
|
||||
|
||||
.card-date.completed-on-time {
|
||||
background-color: #2196f3; /* Blue for completed on time */
|
||||
color: #fff;
|
||||
background-color: #2196f3 !important; /* Blue for completed on time */
|
||||
color: #fff !important;
|
||||
}
|
||||
.card-date.completed-on-time:hover,
|
||||
.card-date.completed-on-time.is-active {
|
||||
background-color: #1976d2;
|
||||
background-color: #1976d2 !important;
|
||||
}
|
||||
|
||||
/* Date type specific colors */
|
||||
|
||||
@@ -25,9 +25,9 @@ import {
|
||||
now,
|
||||
createDate,
|
||||
fromNow,
|
||||
calendar,
|
||||
diff
|
||||
calendar
|
||||
} from '/imports/lib/dateUtils';
|
||||
import { dueDateClass } from '/client/lib/dueDateColor';
|
||||
|
||||
// --- DatePicker popups (edit date forms) ---
|
||||
|
||||
@@ -257,29 +257,12 @@ Template.cardDueDate.onCreated(function () {
|
||||
Template.cardDueDate.helpers(cardDateHelpers({
|
||||
classes() {
|
||||
const tpl = Template.instance();
|
||||
let classes = 'due-date ';
|
||||
const data = Template.currentData();
|
||||
const endAt = data.getEnd();
|
||||
const theDate = tpl.date.get();
|
||||
const nowVal = tpl.now.get();
|
||||
|
||||
if (endAt && isBefore(endAt, theDate)) {
|
||||
classes += 'completed-early';
|
||||
} else if (endAt) {
|
||||
classes += 'completed';
|
||||
} else {
|
||||
const daysDiff = diff(theDate, nowVal, 'days');
|
||||
|
||||
if (daysDiff < 0) {
|
||||
classes += 'overdue';
|
||||
} else if (daysDiff <= 1) {
|
||||
classes += 'due-soon';
|
||||
} else {
|
||||
classes += 'not-due';
|
||||
}
|
||||
}
|
||||
|
||||
return classes;
|
||||
return `due-date ${dueDateClass(theDate, nowVal, endAt)}`;
|
||||
},
|
||||
showTitle() {
|
||||
const tpl = Template.instance();
|
||||
@@ -471,29 +454,12 @@ Template.minicardDueDate.onCreated(function () {
|
||||
Template.minicardDueDate.helpers(cardDateHelpers({
|
||||
classes() {
|
||||
const tpl = Template.instance();
|
||||
let classes = 'due-date ';
|
||||
const data = Template.currentData();
|
||||
const endAt = data.getEnd();
|
||||
const theDate = tpl.date.get();
|
||||
const nowVal = tpl.now.get();
|
||||
|
||||
if (endAt && isBefore(endAt, theDate)) {
|
||||
classes += 'completed-early';
|
||||
} else if (endAt) {
|
||||
classes += 'completed';
|
||||
} else {
|
||||
const daysDiff = diff(theDate, nowVal, 'days');
|
||||
|
||||
if (daysDiff < 0) {
|
||||
classes += 'overdue';
|
||||
} else if (daysDiff <= 1) {
|
||||
classes += 'due-soon';
|
||||
} else {
|
||||
classes += 'not-due';
|
||||
}
|
||||
}
|
||||
|
||||
return classes;
|
||||
return `due-date ${dueDateClass(theDate, nowVal, endAt)}`;
|
||||
},
|
||||
showTitle() {
|
||||
const tpl = Template.instance();
|
||||
|
||||
@@ -504,16 +504,21 @@ input[type="submit"].attachment-add-link-submit {
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
bottom: 0 !important;
|
||||
z-index: 100 !important;
|
||||
/* #6419: the open card is a full-screen takeover on mobile, so it must sit
|
||||
ABOVE the app header bar (#header-main-bar is z-index 1000) — otherwise the
|
||||
header overlaps the top of the card and hides the close (X) button, making
|
||||
it impossible to close the card. Kept below popups (z-index 2000+) so card
|
||||
menus / date pickers still open over the card. */
|
||||
z-index: 1100 !important;
|
||||
height: 100vh !important;
|
||||
max-height: 100vh !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Ensure card details are above everything on mobile */
|
||||
/* Ensure card details are above the app header on mobile (#6419) */
|
||||
body.mobile-mode .card-details {
|
||||
z-index: 100 !important;
|
||||
z-index: 1100 !important;
|
||||
width: 100vw !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
@@ -601,7 +606,8 @@ body.mobile-mode .card-details {
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
bottom: 0 !important;
|
||||
z-index: 100 !important;
|
||||
/* #6419: above the app header (#header-main-bar z-index 1000), below popups. */
|
||||
z-index: 1100 !important;
|
||||
height: 100vh !important;
|
||||
max-height: 100vh !important;
|
||||
border-radius: 0 !important;
|
||||
|
||||
@@ -224,6 +224,9 @@ template(name="cardDetails")
|
||||
unless currentUser.isWorker
|
||||
a.card-label.add-label.js-due-date
|
||||
i.fa.fa-plus
|
||||
if dueDateChangeCount
|
||||
.card-details-due-date-changes(title="{{_ 'due-date-changes'}}")
|
||||
| {{_ 'due-date-changed-times' dueDateChangeCount}}
|
||||
|
||||
if currentBoard.allowsEndDate
|
||||
.card-details-item.card-details-item-end
|
||||
@@ -945,22 +948,22 @@ template(name="moveCardPopup")
|
||||
label {{_ 'boards'}}:
|
||||
select.js-select-boards(autofocus)
|
||||
each boards
|
||||
option(value="{{_id}}" selected="{{#if isSelectedBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedBoardId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
label {{_ 'swimlanes'}}:
|
||||
select.js-select-swimlanes
|
||||
each swimlanes
|
||||
option(value="{{_id}}" selected="{{#if isSelectedSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedSwimlaneId _id}}selected{{/if}}") {{isTitleDefault title}}
|
||||
|
||||
label {{_ 'lists'}}:
|
||||
select.js-select-lists
|
||||
each lists
|
||||
option(value="{{_id}}" selected="{{#if isSelectedListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedListId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
label {{_ 'cards'}}:
|
||||
select.js-select-cards
|
||||
each cards
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
div
|
||||
input(type="radio" name="position" value="above" checked id="position-above" style="display: inline")
|
||||
@@ -980,22 +983,22 @@ template(name="copyCardPopup")
|
||||
label {{_ 'boards'}}:
|
||||
select.js-select-boards(autofocus)
|
||||
each boards
|
||||
option(value="{{_id}}" selected="{{#if isSelectedBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedBoardId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
label {{_ 'swimlanes'}}:
|
||||
select.js-select-swimlanes
|
||||
each swimlanes
|
||||
option(value="{{_id}}" selected="{{#if isSelectedSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedSwimlaneId _id}}selected{{/if}}") {{isTitleDefault title}}
|
||||
|
||||
label {{_ 'lists'}}:
|
||||
select.js-select-lists
|
||||
each lists
|
||||
option(value="{{_id}}" selected="{{#if isSelectedListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedListId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
label {{_ 'cards'}}:
|
||||
select.js-select-cards
|
||||
each cards
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
div
|
||||
input(type="radio" name="position" value="above" checked id="position-above" style="display: inline")
|
||||
@@ -1015,22 +1018,22 @@ template(name="copyManyCardsPopup")
|
||||
label {{_ 'boards'}}:
|
||||
select.js-select-boards(autofocus)
|
||||
each boards
|
||||
option(value="{{_id}}" selected="{{#if isSelectedBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedBoardId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
label {{_ 'swimlanes'}}:
|
||||
select.js-select-swimlanes
|
||||
each swimlanes
|
||||
option(value="{{_id}}" selected="{{#if isSelectedSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedSwimlaneId _id}}selected{{/if}}") {{isTitleDefault title}}
|
||||
|
||||
label {{_ 'lists'}}:
|
||||
select.js-select-lists
|
||||
each lists
|
||||
option(value="{{_id}}" selected="{{#if isSelectedListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedListId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
label {{_ 'cards'}}:
|
||||
select.js-select-cards
|
||||
each cards
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
div
|
||||
input(type="radio" name="position" value="above" checked id="position-above" style="display: inline")
|
||||
@@ -1050,22 +1053,22 @@ template(name="convertChecklistItemToCardPopup")
|
||||
label {{_ 'boards'}}:
|
||||
select.js-select-boards(autofocus)
|
||||
each boards
|
||||
option(value="{{_id}}" selected="{{#if isSelectedBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedBoardId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
label {{_ 'swimlanes'}}:
|
||||
select.js-select-swimlanes
|
||||
each swimlanes
|
||||
option(value="{{_id}}" selected="{{#if isSelectedSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedSwimlaneId _id}}selected{{/if}}") {{isTitleDefault title}}
|
||||
|
||||
label {{_ 'lists'}}:
|
||||
select.js-select-lists
|
||||
each lists
|
||||
option(value="{{_id}}" selected="{{#if isSelectedListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedListId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
label {{_ 'cards'}}:
|
||||
select.js-select-cards
|
||||
each cards
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
div
|
||||
input(type="radio" name="position" value="above" checked id="position-above" style="display: inline")
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
DEFAULT_DEPENDENCY_COLOR,
|
||||
DEFAULT_DEPENDENCY_ICON,
|
||||
} from '/models/metadata/dependencies';
|
||||
import { canArchiveCard } from '/client/lib/archivePermission';
|
||||
import { isTextSelectionInsideCard } from '/client/lib/cardCloseGuard';
|
||||
|
||||
// Which dependency the icon picker should apply its choice to. The icon popup's
|
||||
// own data context is the dependency row (not the source card), so we capture
|
||||
@@ -516,6 +518,14 @@ Template.cardDetails.helpers({
|
||||
return card.findWatcher(Meteor.userId());
|
||||
},
|
||||
|
||||
// #6081: number of times this card's due date has been changed, for
|
||||
// accountability. Returns 0 when unavailable so the template can hide it.
|
||||
dueDateChangeCount() {
|
||||
const card = Template.currentData();
|
||||
if (!card || typeof card.getDueDateChangeCount !== 'function') return 0;
|
||||
return card.getDueDateChangeCount();
|
||||
},
|
||||
|
||||
// Returns the card's locations (multiple supported), each enriched with the
|
||||
// coordinate flag and OpenStreetMap link used by the template.
|
||||
getLocations() {
|
||||
@@ -617,18 +627,24 @@ Template.cardDetails.helpers({
|
||||
|
||||
showVotingButtons() {
|
||||
const card = Template.currentData();
|
||||
// #6420: currentUser was referenced but never defined here, so the helper
|
||||
// threw "ReferenceError: currentUser is not defined" on every card render and
|
||||
// the voting buttons disappeared. Define it and guard the board-member call.
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
return (
|
||||
(currentUser.isBoardMember() ||
|
||||
(currentUser && card.voteAllowNonBoardMembers())) &&
|
||||
currentUser &&
|
||||
(currentUser.isBoardMember() || card.voteAllowNonBoardMembers()) &&
|
||||
!card.expiredVote()
|
||||
);
|
||||
},
|
||||
|
||||
showPlanningPokerButtons() {
|
||||
const card = Template.currentData();
|
||||
// #6420: same as showVotingButtons — currentUser was undefined here.
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
return (
|
||||
(currentUser.isBoardMember() ||
|
||||
(currentUser && card.pokerAllowNonBoardMembers())) &&
|
||||
currentUser &&
|
||||
(currentUser.isBoardMember() || card.pokerAllowNonBoardMembers()) &&
|
||||
!card.expiredPoker()
|
||||
);
|
||||
},
|
||||
@@ -843,17 +859,25 @@ Template.cardDetails.events({
|
||||
event.preventDefault();
|
||||
const description = tpl.find('.js-new-description-input').value;
|
||||
const card = Template.currentData();
|
||||
await card.setDescription(description);
|
||||
// #5809: surface a visible error instead of failing silently — e.g. editing
|
||||
// a linked card whose target is on a board the user cannot write to gets a
|
||||
// permission denial that otherwise vanished with no feedback.
|
||||
try {
|
||||
await card.setDescription(description);
|
||||
} catch (error) {
|
||||
alert(error?.reason || error?.message || 'Failed to save description');
|
||||
}
|
||||
},
|
||||
async 'submit .js-card-details-title'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const titleInput = tpl.find('.js-edit-card-title');
|
||||
const title = titleInput ? titleInput.value.trim() : '';
|
||||
const card = Template.currentData();
|
||||
if (title) {
|
||||
await card.setTitle(title);
|
||||
} else {
|
||||
await card.setTitle('');
|
||||
// #5809: surface a visible error instead of failing silently (see above).
|
||||
try {
|
||||
await card.setTitle(title || '');
|
||||
} catch (error) {
|
||||
alert(error?.reason || error?.message || 'Failed to save title');
|
||||
}
|
||||
},
|
||||
'submit .js-card-details-assigner'(event, tpl) {
|
||||
@@ -1392,7 +1416,7 @@ Template.cardDetailsActionsPopup.helpers({
|
||||
},
|
||||
|
||||
isBoardAdmin() {
|
||||
return ReactiveCache.getCurrentUser().isBoardAdmin();
|
||||
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
|
||||
},
|
||||
|
||||
showListOnMinicard() {
|
||||
@@ -1435,6 +1459,7 @@ Template.cardDetailsActionsPopup.events({
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-archive': Popup.afterConfirm('cardArchive', async function () {
|
||||
if (!canArchiveCard({ canModifyCard: Utils.canModifyCard() })) return;
|
||||
const card = Cards.findOne(getCardId());
|
||||
Popup.close();
|
||||
if (!card) return;
|
||||
@@ -1481,9 +1506,13 @@ Template.editCardTitleForm.events({
|
||||
});
|
||||
|
||||
Template.cardMembersPopup.onCreated(function () {
|
||||
let currBoard = Utils.getCurrentBoard();
|
||||
let members = currBoard.activeMembers();
|
||||
this.members = new ReactiveVar(members);
|
||||
// #4965: only the filter term is stored reactively; the candidate list is
|
||||
// derived reactively in the members() helper (via filterMembers, which reads
|
||||
// board.activeMembers() on each call). Previously the list was snapshotted
|
||||
// once here, so a member added to the board after the popup opened — or whose
|
||||
// user document finished loading after open — was missing until the popup was
|
||||
// reopened.
|
||||
this.filterTerm = new ReactiveVar('');
|
||||
});
|
||||
|
||||
Template.cardMembersPopup.events({
|
||||
@@ -1495,8 +1524,7 @@ Template.cardMembersPopup.events({
|
||||
event.preventDefault();
|
||||
},
|
||||
'keyup .card-members-filter'(event) {
|
||||
const members = filterMembers(event.target.value);
|
||||
Template.instance().members.set(members);
|
||||
Template.instance().filterTerm.set(event.target.value);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1510,7 +1538,7 @@ Template.cardMembersPopup.helpers({
|
||||
},
|
||||
|
||||
members() {
|
||||
const members = Template.instance().members.get();
|
||||
const members = filterMembers(Template.instance().filterTerm.get());
|
||||
const uniqueMembers = uniqBy(members, 'userId');
|
||||
return [...uniqueMembers].sort((a, b) => {
|
||||
const userA = ReactiveCache.getUser(a.userId);
|
||||
@@ -2065,6 +2093,11 @@ Template.cardMorePopup.onCreated(function () {
|
||||
const cardId = getCardId();
|
||||
this.currentCard = Cards.findOne(cardId);
|
||||
this.parentBoard = new ReactiveVar(null);
|
||||
// #3745: tracks whether the selected parent board's cards have finished
|
||||
// loading. The card list stays empty until the subscription is ready, so it
|
||||
// is no longer blank the first time another board is picked (the cards()
|
||||
// helper queries minimongo, which was empty before the subscription arrived).
|
||||
this.parentBoardReady = new ReactiveVar(true);
|
||||
this.parentCard = this.currentCard?.parentCard();
|
||||
if (this.parentCard) {
|
||||
const list = $('.js-field-parent-card');
|
||||
@@ -2103,7 +2136,10 @@ Template.cardMorePopup.helpers({
|
||||
cards() {
|
||||
const tpl = Template.instance();
|
||||
const currentId = getCardId();
|
||||
if (tpl.parentBoard.get()) {
|
||||
// #3745: don't list cards until the selected board's subscription is ready,
|
||||
// otherwise the first open shows an empty list. Depending on parentBoardReady
|
||||
// also re-runs this helper once the data has arrived.
|
||||
if (tpl.parentBoard.get() && tpl.parentBoardReady.get()) {
|
||||
const ret = ReactiveCache.getCards({
|
||||
boardId: tpl.parentBoard.get(),
|
||||
_id: { $ne: currentId },
|
||||
@@ -2168,7 +2204,14 @@ Template.cardMorePopup.events({
|
||||
if (selection === 'none') {
|
||||
tpl.parentBoard.set(null);
|
||||
} else {
|
||||
Meteor.subscribe('board', $(event.currentTarget).val(), false);
|
||||
// #3745: wait for the board subscription to be ready before showing its
|
||||
// cards, so the parent-card list is populated on the first selection.
|
||||
tpl.parentBoardReady.set(false);
|
||||
Meteor.subscribe('board', selection, false, {
|
||||
onReady() {
|
||||
tpl.parentBoardReady.set(true);
|
||||
},
|
||||
});
|
||||
tpl.parentBoard.set(selection);
|
||||
list.prop('disabled', false);
|
||||
}
|
||||
@@ -2392,7 +2435,17 @@ EscapeActions.register(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Session.get('cardDetailsIsDragging')) {
|
||||
// #5686: selecting the text of a checklist item (whose mousedown stops
|
||||
// propagation, so cardDetailsIsDragging is never set) and releasing the
|
||||
// mouse outside the card must NOT close the card. Treat a live text
|
||||
// selection anchored inside the card pane the same as an in-card drag.
|
||||
const selectingInsideCard = isTextSelectionInsideCard(
|
||||
typeof window !== 'undefined' && window.getSelection
|
||||
? window.getSelection()
|
||||
: null,
|
||||
getCardDetailsElement(getCurrentCardFromContext()?._id),
|
||||
);
|
||||
if (Session.get('cardDetailsIsDragging') || selectingInsideCard) {
|
||||
// Reset dragging status as the mouse landed outside the cardDetails template area and this will prevent a mousedown event from firing
|
||||
Session.set('cardDetailsIsDragging', false);
|
||||
Session.set('cardDetailsIsMouseDown', false);
|
||||
|
||||
@@ -138,7 +138,7 @@ template(name="checklistItems")
|
||||
i.fa.fa-plus
|
||||
|
||||
template(name='checklistItemDetail')
|
||||
.js-checklist-item.checklist-item(class="{{#if item.isFinished }}is-checked{{#if checklist.hideCheckedChecklistItems}} invisible{{/if}}{{/if}}{{#if checklist.hideAllChecklistItems}} is-checked invisible{{/if}}"
|
||||
.js-checklist-item.checklist-item(class="{{#if item.isFinished }}is-checked{{#if checklist.isItemHidden item.isFinished}} invisible{{/if}}{{/if}}{{#if checklist.hideAllChecklistItems}} is-checked invisible{{/if}}"
|
||||
role="checkbox" aria-checked="{{#if item.isFinished }}true{{else}}false{{/if}}" tabindex="0")
|
||||
if canModifyCard
|
||||
.check-box-container
|
||||
@@ -170,7 +170,7 @@ template(name="checklistActionsPopup")
|
||||
i.fa.fa-eye-slash
|
||||
| {{_ "hideCheckedChecklistItems"}} ...
|
||||
.material-toggle-switch(title="{{_ 'hide-checked-items'}}")
|
||||
if checklist.hideCheckedChecklistItems
|
||||
if checklist.hideCheckedState
|
||||
input.toggle-switch(type="checkbox" id="toggleHideCheckedChecklistItems_{{checklist._id}}" checked="checked")
|
||||
else
|
||||
input.toggle-switch(type="checkbox" id="toggleHideCheckedChecklistItems_{{checklist._id}}")
|
||||
|
||||
@@ -214,7 +214,9 @@ Template.checklists.events({
|
||||
'click .js-delete-checklist-item': Popup.afterConfirm('checklistItemDelete', function () {
|
||||
Popup.back();
|
||||
const item = this?.item || this;
|
||||
if (item && item._id) {
|
||||
// #3252: guard against removing a doc already evicted from Minimongo (heavy
|
||||
// archive/delete churn), which throws "Removed nonexistent document".
|
||||
if (item && item._id && ChecklistItems.findOne(item._id)) {
|
||||
ChecklistItems.remove(item._id);
|
||||
}
|
||||
}),
|
||||
@@ -258,7 +260,8 @@ Template.checklistActionsPopup.events({
|
||||
'click .js-delete-checklist': Popup.afterConfirm('checklistDelete', function () {
|
||||
Popup.back(2);
|
||||
const checklist = this.checklist;
|
||||
if (checklist && checklist._id) {
|
||||
// #3252: see js-delete-checklist-item — avoid "Removed nonexistent document".
|
||||
if (checklist && checklist._id && Checklists.findOne(checklist._id)) {
|
||||
Checklists.remove(checklist._id);
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -119,6 +119,22 @@
|
||||
margin-inline-end: 0.4vw;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
/* Keep text labels on a single compact line. The label name is rendered
|
||||
inside a `.viewer`, whose global `min-height: 2.5vh` otherwise forces every
|
||||
label to roughly double height on the minicard. */
|
||||
.minicard .minicard-labels .card-label {
|
||||
min-height: 0;
|
||||
padding: 1px 6px;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.minicard .minicard-labels .card-label .viewer {
|
||||
min-height: 0;
|
||||
display: inline;
|
||||
}
|
||||
.minicard .minicard-labels .card-label .viewer p {
|
||||
display: inline;
|
||||
}
|
||||
.minicard .minicard-labels-no-text {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -209,7 +209,11 @@ Template.minicard.helpers({
|
||||
// Show checklist if either:
|
||||
// 1. Board-wide setting is enabled, OR
|
||||
// 2. This specific checklist has the setting enabled
|
||||
if (currentBoard.allowsChecklistAtMinicard || checklist.showChecklistAtMinicard) {
|
||||
// #5565: use the same board field the sidebar toggle writes
|
||||
// (`allowsChecklistsOnMinicard`); this previously checked a different,
|
||||
// UI-less field (`allowsChecklistAtMinicard`), so the board toggle to show
|
||||
// checklists on minicards had no effect.
|
||||
if (currentBoard.allowsChecklistsOnMinicard || checklist.showChecklistAtMinicard) {
|
||||
visibleChecklists.push(checklist);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Pure helpers for displaying the status (current list / board) of a subtask.
|
||||
*
|
||||
* Issue #6091 "Visible status of sub-tasks": when viewing a card's subtasks,
|
||||
* show each subtask's current list name (and board, when the subtask lives on a
|
||||
* different board than the parent card).
|
||||
*
|
||||
* Kept dependency-free so it can be unit tested in isolation (mocha + chai).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build the human readable status label for a subtask.
|
||||
*
|
||||
* @param {Object} args
|
||||
* @param {string} [args.listTitle] - Title of the list the subtask resides in.
|
||||
* @param {string} [args.boardTitle] - Title of the board the subtask resides in.
|
||||
* @param {boolean} [args.sameBoard=true] - Whether the subtask is on the same
|
||||
* board as the parent card. When false, the board title is prefixed.
|
||||
* @returns {string} The display string:
|
||||
* - `listTitle` when the subtask is on the same board,
|
||||
* - `boardTitle / listTitle` when it is on a different board,
|
||||
* - a graceful fallback (whichever part is available) when one is missing,
|
||||
* - empty string when nothing is known.
|
||||
*/
|
||||
export function subtaskStatusLabel({ listTitle, boardTitle, sameBoard = true } = {}) {
|
||||
const list = (listTitle || '').trim();
|
||||
const board = (boardTitle || '').trim();
|
||||
|
||||
if (sameBoard) {
|
||||
return list;
|
||||
}
|
||||
|
||||
// Different board: prefix with the board title when available.
|
||||
if (board && list) {
|
||||
return `${board} / ${list}`;
|
||||
}
|
||||
return board || list;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Pure helpers for subtask navigation guards.
|
||||
//
|
||||
// #4762: "Subtask board is not accessible until reload".
|
||||
// When a subtask lives on another board, that board may not yet be loaded in
|
||||
// the ReactiveCache when the popup handler runs. Dereferencing the missing
|
||||
// board (e.g. board._id) throws and the navigation silently fails until a
|
||||
// reload populates the cache. Use this guard before navigating, mirroring the
|
||||
// existing `if (board)` check in the js-go-to-subtask-board handler.
|
||||
export function canNavigateToSubtaskBoard(board) {
|
||||
return Boolean(board && board._id);
|
||||
}
|
||||
|
||||
// #3743: "Subtask 'View it' button opens the parent card instead of the
|
||||
// subtask". The navigation target must be derived from the SUBTASK card
|
||||
// itself (its own board, slug and id) and never from the parent/current
|
||||
// card. Centralise that here so the handler cannot accidentally reference
|
||||
// the parent card again, and so the behaviour is unit-testable.
|
||||
//
|
||||
// Returns the FlowRouter 'card' route params taken from the subtask, or
|
||||
// undefined when the subtask's board is not loaded yet (see #4762 guard).
|
||||
export function subtaskNavTarget(subtask) {
|
||||
if (!subtask) {
|
||||
return undefined;
|
||||
}
|
||||
const board = typeof subtask.board === 'function' ? subtask.board() : undefined;
|
||||
if (!canNavigateToSubtaskBoard(board)) {
|
||||
return undefined;
|
||||
}
|
||||
// Prefer the real card id (resolves linked subtask cards to their source);
|
||||
// fall back to the plain _id when getRealId is unavailable (e.g. in tests).
|
||||
const cardId =
|
||||
typeof subtask.getRealId === 'function' ? subtask.getRealId() : subtask._id;
|
||||
return {
|
||||
boardId: board._id,
|
||||
slug: board.slug,
|
||||
cardId,
|
||||
swimlaneId: subtask.swimlaneId,
|
||||
listId: subtask.listId,
|
||||
};
|
||||
}
|
||||
@@ -192,3 +192,19 @@ body.grey-icons-enabled .subtasks-item .check-box-unicode {
|
||||
padding: 6px 10px 6px 10px;
|
||||
order: 2;
|
||||
}
|
||||
/* #6091: read-only status (current list / board) of a subtask */
|
||||
.subtask-title .subtask-status {
|
||||
order: 1;
|
||||
flex-shrink: 0;
|
||||
margin: 0 0.5em;
|
||||
padding-top: 6px;
|
||||
color: #8c8c8c;
|
||||
font-size: 0.85em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 40%;
|
||||
}
|
||||
.subtask-title .subtask-status i.fa {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
@@ -35,6 +35,10 @@ template(name="subtaskDetail")
|
||||
h2.title
|
||||
+viewer
|
||||
= subtask.title
|
||||
if subtaskStatus
|
||||
span.subtask-status(title="{{_ 'list'}}")
|
||||
i.fa.fa-list-ul
|
||||
| {{ subtaskStatus }}
|
||||
|
||||
template(name="addSubtaskItemForm")
|
||||
textarea.js-add-subtask-item(rows='1' autofocus dir="auto")
|
||||
|
||||
@@ -2,6 +2,9 @@ import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import Cards from '/models/cards';
|
||||
import { Filter } from '/client/lib/filter';
|
||||
import { subtaskStatusLabel } from './subtaskStatusHelpers';
|
||||
import { subtaskNavTarget } from './subtaskViewHelpers';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
|
||||
Template.subtasks.events({
|
||||
'click .js-open-subtask-details-menu'(event) {
|
||||
@@ -17,66 +20,30 @@ Template.subtasks.events({
|
||||
const textarea = tpl.find('textarea.js-add-subtask-item');
|
||||
const title = textarea.value.trim();
|
||||
const cardId = Template.currentData().cardId;
|
||||
const card = ReactiveCache.getCard(cardId);
|
||||
const sortIndex = -1;
|
||||
const crtBoard = ReactiveCache.getBoard(card.boardId);
|
||||
const targetBoard = await crtBoard.getDefaultSubtasksBoardAsync();
|
||||
if (!targetBoard) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetList = await targetBoard.getDefaultSubtasksListAsync();
|
||||
if (!targetList) {
|
||||
return;
|
||||
}
|
||||
const listId = targetList._id;
|
||||
|
||||
//Get the full swimlane data for the parent task.
|
||||
const parentSwimlane = ReactiveCache.getSwimlane({
|
||||
boardId: crtBoard._id,
|
||||
_id: card.swimlaneId,
|
||||
});
|
||||
//find the swimlane of the same name in the target board.
|
||||
const targetSwimlane = parentSwimlane
|
||||
? ReactiveCache.getSwimlane({
|
||||
boardId: targetBoard._id,
|
||||
title: parentSwimlane.title,
|
||||
})
|
||||
: undefined;
|
||||
//If no swimlane with a matching title exists in the target board, fall back to the default swimlane.
|
||||
const swimlaneId =
|
||||
targetSwimlane === undefined
|
||||
? targetBoard.getDefaultSwimline()._id
|
||||
: targetSwimlane._id;
|
||||
|
||||
const nextCardNumber = await targetBoard.getNextCardNumber();
|
||||
|
||||
if (title) {
|
||||
const _id = await Cards.insertAsync({
|
||||
title,
|
||||
parentId: cardId,
|
||||
members: [],
|
||||
labelIds: [],
|
||||
customFields: [],
|
||||
listId,
|
||||
boardId: targetBoard._id,
|
||||
sort: sortIndex,
|
||||
swimlaneId,
|
||||
type: 'cardType-card',
|
||||
cardNumber: nextCardNumber,
|
||||
});
|
||||
// Subtask creation is performed server-side by the `addSubtaskCard` Meteor
|
||||
// method, so the default subtasks board/list/swimlane are resolved (and
|
||||
// lazily created exactly once) on the server. This prevents the client
|
||||
// from creating duplicate subtasks boards / swimlanes / columns
|
||||
// (#3868 / #5788 / #2256) and lets multiple subtasks be created reliably
|
||||
// (#4782), and the method applies the destination board's automatic
|
||||
// custom fields to the new subtask (#4037 / #3562).
|
||||
const _id = await Meteor.callAsync('addSubtaskCard', cardId, title);
|
||||
|
||||
// In case the filter is active we need to add the newly inserted card in
|
||||
// the list of exceptions -- cards that are not filtered. Otherwise the
|
||||
// card will disappear instantly.
|
||||
// See https://github.com/wekan/wekan/issues/80
|
||||
Filter.addException(_id);
|
||||
if (_id) {
|
||||
// In case the filter is active we need to add the newly inserted card in
|
||||
// the list of exceptions -- cards that are not filtered. Otherwise the
|
||||
// card will disappear instantly.
|
||||
// See https://github.com/wekan/wekan/issues/80
|
||||
Filter.addException(_id);
|
||||
|
||||
setTimeout(() => {
|
||||
tpl.$('.add-subtask-item')
|
||||
.last()
|
||||
.click();
|
||||
}, 100);
|
||||
setTimeout(() => {
|
||||
tpl.$('.add-subtask-item')
|
||||
.last()
|
||||
.click();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
textarea.value = '';
|
||||
textarea.focus();
|
||||
@@ -111,13 +78,32 @@ Template.subtasks.onCreated(function () {
|
||||
|
||||
Template.subtasks.helpers({
|
||||
isBoardAdmin() {
|
||||
return ReactiveCache.getCurrentUser().isBoardAdmin();
|
||||
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
|
||||
},
|
||||
toggleDeleteDialog() {
|
||||
return Template.instance().toggleDeleteDialog;
|
||||
},
|
||||
});
|
||||
|
||||
Template.subtaskDetail.helpers({
|
||||
// #6091: show the subtask's current status, i.e. the list it resides in
|
||||
// (prefixed with the board title when the subtask lives on another board
|
||||
// than the parent card).
|
||||
subtaskStatus() {
|
||||
const subtask = this.subtask;
|
||||
if (!subtask) {
|
||||
return '';
|
||||
}
|
||||
const list = subtask.list && subtask.list();
|
||||
const listTitle = list ? list.title : '';
|
||||
const board = subtask.board && subtask.board();
|
||||
const boardTitle = board ? board.title : '';
|
||||
const parentCard = Utils.getCurrentCard();
|
||||
const sameBoard = !!parentCard && parentCard.boardId === subtask.boardId;
|
||||
return subtaskStatusLabel({ listTitle, boardTitle, sameBoard });
|
||||
},
|
||||
});
|
||||
|
||||
Template.subtaskItemDetail.events({
|
||||
async 'click .js-subtasks-item .check-box-container'() {
|
||||
const item = Template.currentData().item;
|
||||
@@ -129,7 +115,7 @@ Template.subtaskItemDetail.events({
|
||||
|
||||
Template.subtaskActionsPopup.helpers({
|
||||
isBoardAdmin() {
|
||||
return ReactiveCache.getCurrentUser().isBoardAdmin();
|
||||
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -137,14 +123,14 @@ Template.subtaskActionsPopup.events({
|
||||
'click .js-view-subtask'(event) {
|
||||
if ($(event.target).hasClass('js-view-subtask')) {
|
||||
const subtask = Template.currentData().subtask;
|
||||
const board = subtask.board();
|
||||
FlowRouter.go('card', {
|
||||
boardId: board._id,
|
||||
slug: board.slug,
|
||||
cardId: subtask._id,
|
||||
swimlaneId: subtask.swimlaneId,
|
||||
listId: subtask.listId,
|
||||
});
|
||||
// #3743: open the SUBTASK card itself, not the parent/current card.
|
||||
// subtaskNavTarget derives boardId/slug/cardId from the subtask only.
|
||||
// #4762: it also returns undefined while the subtask's board is not
|
||||
// loaded yet (ReactiveCache miss), mirroring js-go-to-subtask-board.
|
||||
const target = subtaskNavTarget(subtask);
|
||||
if (target) {
|
||||
FlowRouter.go('card', target);
|
||||
}
|
||||
}
|
||||
},
|
||||
'click .js-go-to-subtask-board'() {
|
||||
@@ -169,6 +155,6 @@ Template.editSubtaskItemForm.helpers({
|
||||
return ReactiveCache.getUser(this.userId);
|
||||
},
|
||||
isBoardAdmin() {
|
||||
return ReactiveCache.getCurrentUser().isBoardAdmin();
|
||||
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
|
||||
},
|
||||
});
|
||||
@@ -135,10 +135,14 @@ body.list-resizing-active * {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
/* Ensure the width persists after resize is complete */
|
||||
.js-swimlane .list[style*="--list-width"],
|
||||
.dragscroll .list[style*="--list-width"],
|
||||
[id^="swimlane-"] .list[style*="--list-width"] {
|
||||
/* Ensure the width persists after resize is complete.
|
||||
Exclude collapsed lists: a collapsed list keeps its inline --list-width custom
|
||||
property, and without :not() this !important rule overrode the 30px collapsed
|
||||
width, so collapsing a list that had a custom width left it full width.
|
||||
See https://github.com/wekan/wekan/issues/5892 */
|
||||
.js-swimlane .list[style*="--list-width"]:not(.list-collapsed),
|
||||
.dragscroll .list[style*="--list-width"]:not(.list-collapsed),
|
||||
[id^="swimlane-"] .list[style*="--list-width"]:not(.list-collapsed) {
|
||||
/* Maintain the width after resize */
|
||||
width: var(--list-width, auto) !important;
|
||||
min-width: var(--list-width, auto) !important;
|
||||
@@ -253,10 +257,12 @@ body.list-resizing-active * {
|
||||
height: 15vh;
|
||||
}
|
||||
.list.list-collapsed {
|
||||
flex: none;
|
||||
min-width: 30px;
|
||||
max-width: 30px;
|
||||
width: 30px;
|
||||
flex: none !important;
|
||||
/* !important so a custom per-list --list-width never keeps a collapsed list
|
||||
full width (see https://github.com/wekan/wekan/issues/5892) */
|
||||
min-width: 30px !important;
|
||||
max-width: 30px !important;
|
||||
width: 30px !important;
|
||||
min-height: 60vh;
|
||||
height: 60vh;
|
||||
overflow: visible;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
template(name='list')
|
||||
.list.js-list(id="js-list-{{_id}}"
|
||||
style="{{#unless collapsed}}min-width:{{listWidth}}px;max-width:{{listConstraint}}px;{{/unless}}"
|
||||
style="{{#unless collapsed}}{{#unless autoWidth}}--list-width:{{listWidth}}px;{{/unless}}{{/unless}}"
|
||||
class="{{#if collapsed}}list-collapsed{{/if}} {{#if autoWidth}}list-auto-width{{/if}} {{#if isMiniScreen}}mobile-view{{/if}}")
|
||||
+listHeader
|
||||
unless collapsed
|
||||
+listBody
|
||||
.list-resize-handle.js-list-resize-handle.nodragscroll
|
||||
if canResizeList
|
||||
.list-resize-handle.js-list-resize-handle.nodragscroll
|
||||
|
||||
template(name='miniList')
|
||||
a.mini-list.js-select-list.js-list(id="js-list-{{_id}}" class="{{#if isMiniScreen}}mobile-view{{/if}}")
|
||||
|
||||
+203
-140
@@ -7,6 +7,196 @@ require('/client/lib/jquery-ui.js')
|
||||
|
||||
const { calculateIndex } = Utils;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #6409 List width: a single, simple model.
|
||||
//
|
||||
// * Each list has ONE fixed width.
|
||||
// * SHARED mode (board default): width lives in `lists.width` and is the same
|
||||
// for everyone; only members with board write access can change it.
|
||||
// * PERSONAL mode (board setting `allowsPersonalListWidth`): each user keeps
|
||||
// their own width in their profile (or localStorage when not logged in),
|
||||
// falling back to the shared width and then the default.
|
||||
//
|
||||
// The old per-list "min width / max width / automatic" knobs are gone — the
|
||||
// rendered width is now a single hard value (see list.jade).
|
||||
// ---------------------------------------------------------------------------
|
||||
const DEFAULT_LIST_WIDTH = 272;
|
||||
const MIN_LIST_WIDTH = 270;
|
||||
|
||||
function isPersonalListWidth(boardId) {
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
return !!(board && board.allowsPersonalListWidth);
|
||||
}
|
||||
|
||||
// #6409 Auto-width follows the same scope as fixed widths:
|
||||
// * personal mode -> the user's own profile.autoWidthBoards
|
||||
// * shared mode -> the board's `autoWidth` (same for everyone)
|
||||
function effectiveAutoWidth(boardId) {
|
||||
if (isPersonalListWidth(boardId)) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
return !!(user && user.isAutoWidth(boardId));
|
||||
}
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
return !!(board && board.autoWidth);
|
||||
}
|
||||
|
||||
function readAnonListWidth(boardId, listId) {
|
||||
try {
|
||||
const stored = localStorage.getItem('wekan-list-widths');
|
||||
if (stored) {
|
||||
const widths = JSON.parse(stored);
|
||||
const w = widths[boardId] && widths[boardId][listId];
|
||||
if (typeof w === 'number' && w >= MIN_LIST_WIDTH) return w;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error reading list width from localStorage:', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #5729 Fixed (same) width for all lists.
|
||||
//
|
||||
// A per-viewer, per-board toggle that makes EVERY list render at a single
|
||||
// shared value, and makes dragging any list update that one value (so all lists
|
||||
// change together). Stored exactly like the personal width:
|
||||
// * logged-in -> profile.fixedListWidthBoards / profile.fixedListWidths
|
||||
// * anonymous -> localStorage (mirrors readAnonListWidth)
|
||||
// ---------------------------------------------------------------------------
|
||||
function readAnonFixedListWidthEnabled(boardId) {
|
||||
try {
|
||||
const stored = localStorage.getItem('wekan-fixed-list-width-enabled');
|
||||
if (stored) {
|
||||
const flags = JSON.parse(stored);
|
||||
return flags[boardId] === true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error reading fixed list width flag from localStorage:', e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function readAnonFixedListWidth(boardId) {
|
||||
try {
|
||||
const stored = localStorage.getItem('wekan-fixed-list-width');
|
||||
if (stored) {
|
||||
const widths = JSON.parse(stored);
|
||||
const w = widths[boardId];
|
||||
if (typeof w === 'number' && w >= MIN_LIST_WIDTH) return w;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error reading fixed list width from localStorage:', e);
|
||||
}
|
||||
return DEFAULT_LIST_WIDTH;
|
||||
}
|
||||
|
||||
// Whether the current viewer has fixed width mode enabled for this board.
|
||||
function isFixedListWidth(boardId) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (user) {
|
||||
return !!user.isFixedListWidth(boardId);
|
||||
}
|
||||
return readAnonFixedListWidthEnabled(boardId);
|
||||
}
|
||||
|
||||
// The single width every list should use when fixed width mode is enabled.
|
||||
function fixedListWidthValue(boardId) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (user) {
|
||||
return user.getFixedListWidth(boardId);
|
||||
}
|
||||
return readAnonFixedListWidth(boardId);
|
||||
}
|
||||
|
||||
// Persist the single fixed width for the whole board (anon localStorage).
|
||||
function saveAnonFixedListWidth(boardId, width) {
|
||||
try {
|
||||
const stored = localStorage.getItem('wekan-fixed-list-width');
|
||||
const widths = stored ? JSON.parse(stored) : {};
|
||||
widths[boardId] = width;
|
||||
localStorage.setItem('wekan-fixed-list-width', JSON.stringify(widths));
|
||||
} catch (e) {
|
||||
console.warn('Error saving fixed list width to localStorage:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function effectiveListWidth(list) {
|
||||
if (!list) return DEFAULT_LIST_WIDTH;
|
||||
// #5729 In fixed width mode every list renders at the single shared value.
|
||||
if (isFixedListWidth(list.boardId)) {
|
||||
return fixedListWidthValue(list.boardId);
|
||||
}
|
||||
const shared =
|
||||
typeof list.width === 'number' && list.width >= MIN_LIST_WIDTH
|
||||
? list.width
|
||||
: DEFAULT_LIST_WIDTH;
|
||||
if (!isPersonalListWidth(list.boardId)) {
|
||||
return shared;
|
||||
}
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (user) {
|
||||
const widths = user.getListWidths();
|
||||
const w = widths[list.boardId] && widths[list.boardId][list._id];
|
||||
return typeof w === 'number' && w >= MIN_LIST_WIDTH ? w : shared;
|
||||
}
|
||||
const w = readAnonListWidth(list.boardId, list._id);
|
||||
return w || shared;
|
||||
}
|
||||
|
||||
// Can the current user change THIS list's width by dragging?
|
||||
// - never while the board/list is in auto-width mode (the width is computed)
|
||||
// - personal mode: always (it only affects their own view)
|
||||
// - shared mode: only with board write access
|
||||
function canResizeList(list) {
|
||||
if (!list) return false;
|
||||
if (effectiveAutoWidth(list.boardId)) return false;
|
||||
// #5729 Fixed width is the viewer's own per-board setting, so any viewer
|
||||
// (logged-in or anonymous) may drag-resize; the change applies to all lists.
|
||||
if (isFixedListWidth(list.boardId)) return true;
|
||||
if (isPersonalListWidth(list.boardId)) return true;
|
||||
return Utils.canModifyBoard();
|
||||
}
|
||||
|
||||
// Persist a new width to the place that matches the current board mode.
|
||||
function saveListWidth(list, width) {
|
||||
if (!list) return;
|
||||
const boardId = list.boardId;
|
||||
const listId = list._id;
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
// #5729 In fixed width mode, persist to the single per-board value instead of
|
||||
// the per-list width, so EVERY list re-renders at the new width.
|
||||
if (isFixedListWidth(boardId)) {
|
||||
if (user) {
|
||||
Meteor.call('setFixedListWidth', boardId, width, error => {
|
||||
if (error) console.error('Error saving fixed list width:', error);
|
||||
});
|
||||
} else {
|
||||
saveAnonFixedListWidth(boardId, width);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isPersonalListWidth(boardId)) {
|
||||
if (user) {
|
||||
Meteor.call('applyListWidthToStorage', boardId, listId, width, width);
|
||||
} else {
|
||||
try {
|
||||
const stored = localStorage.getItem('wekan-list-widths');
|
||||
const widths = stored ? JSON.parse(stored) : {};
|
||||
if (!widths[boardId]) widths[boardId] = {};
|
||||
widths[boardId][listId] = width;
|
||||
localStorage.setItem('wekan-list-widths', JSON.stringify(widths));
|
||||
} catch (e) {
|
||||
console.warn('Error saving personal list width to localStorage:', e);
|
||||
}
|
||||
}
|
||||
} else if (user) {
|
||||
// Shared width: server also enforces board membership.
|
||||
Meteor.call('applyListWidth', boardId, listId, width, width, error => {
|
||||
if (error) console.error('Error saving shared list width:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Template.list.onCreated(function () {
|
||||
this.newCardFormIsVisible = new ReactiveVar(true);
|
||||
|
||||
@@ -217,63 +407,17 @@ Template.list.onRendered(function () {
|
||||
|
||||
Template.list.helpers({
|
||||
listWidth() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const list = Template.currentData();
|
||||
if (!list) return 270; // Return default width if list is not available
|
||||
|
||||
if (user) {
|
||||
// For logged-in users, get from user profile
|
||||
return user.getListWidthFromStorage(list.boardId, list._id);
|
||||
} else {
|
||||
// For non-logged-in users, get from localStorage
|
||||
try {
|
||||
const stored = localStorage.getItem('wekan-list-widths');
|
||||
if (stored) {
|
||||
const widths = JSON.parse(stored);
|
||||
if (widths[list.boardId] && widths[list.boardId][list._id]) {
|
||||
return widths[list.boardId][list._id];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error reading list width from localStorage:', e);
|
||||
}
|
||||
return 270; // Return default width if not found
|
||||
}
|
||||
return effectiveListWidth(Template.currentData());
|
||||
},
|
||||
|
||||
listConstraint() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const list = Template.currentData();
|
||||
if (!list) return 550; // Return default constraint if list is not available
|
||||
|
||||
if (user) {
|
||||
// For logged-in users, get from user profile
|
||||
return user.getListConstraintFromStorage(list.boardId, list._id);
|
||||
} else {
|
||||
// For non-logged-in users, get from localStorage
|
||||
try {
|
||||
const stored = localStorage.getItem('wekan-list-constraints');
|
||||
if (stored) {
|
||||
const constraints = JSON.parse(stored);
|
||||
if (constraints[list.boardId] && constraints[list.boardId][list._id]) {
|
||||
return constraints[list.boardId][list._id];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error reading list constraint from localStorage:', e);
|
||||
}
|
||||
return 550; // Return default constraint if not found
|
||||
}
|
||||
// Whether to show the drag-resize handle for this list.
|
||||
canResizeList() {
|
||||
return canResizeList(Template.currentData());
|
||||
},
|
||||
|
||||
autoWidth() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const list = Template.currentData();
|
||||
if (!user) {
|
||||
// For non-logged-in users, auto-width is disabled
|
||||
return false;
|
||||
}
|
||||
return user.isAutoWidth(list.boardId);
|
||||
return !!list && effectiveAutoWidth(list.boardId);
|
||||
},
|
||||
|
||||
collapsed() {
|
||||
@@ -313,18 +457,12 @@ Template.list.onCreated(function () {
|
||||
return;
|
||||
}
|
||||
|
||||
// Helper to get autoWidth state
|
||||
const getAutoWidth = () => {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (!user) return false;
|
||||
return user.isAutoWidth(list.boardId);
|
||||
};
|
||||
|
||||
// Reactively show/hide resize handle based on collapse and auto-width state
|
||||
// #6409: show the resize handle only when the list is not collapsed and the
|
||||
// user is allowed to change this list's width (always in personal mode; only
|
||||
// with board write access in shared mode).
|
||||
tpl.autorun(() => {
|
||||
const isAutoWidth = getAutoWidth();
|
||||
const isCollapsed = Utils.getListCollapseState(list);
|
||||
if (isCollapsed || isAutoWidth) {
|
||||
if (isCollapsed || !canResizeList(list)) {
|
||||
$resizeHandle.hide();
|
||||
} else {
|
||||
$resizeHandle.show();
|
||||
@@ -334,25 +472,7 @@ Template.list.onCreated(function () {
|
||||
let isResizing = false;
|
||||
let startX = 0;
|
||||
let startWidth = 0;
|
||||
let minWidth = 270; // Minimum width matching system default
|
||||
|
||||
// Get listConstraint value
|
||||
const getListConstraint = () => {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (user) {
|
||||
return user.getListConstraintFromStorage(list.boardId, list._id);
|
||||
}
|
||||
try {
|
||||
const stored = localStorage.getItem('wekan-list-constraints');
|
||||
if (stored) {
|
||||
const constraints = JSON.parse(stored);
|
||||
if (constraints[list.boardId] && constraints[list.boardId][list._id]) {
|
||||
return constraints[list.boardId][list._id];
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return 550;
|
||||
};
|
||||
let minWidth = MIN_LIST_WIDTH; // Minimum width matching system default
|
||||
|
||||
// Read the horizontal page coordinate from either a jQuery mouse event
|
||||
// or a native touch event (touchstart/move expose `touches`, touchend
|
||||
@@ -412,7 +532,6 @@ Template.list.onCreated(function () {
|
||||
const currentX = getEventPageX(e);
|
||||
const deltaX = currentX - startX;
|
||||
const finalWidth = Math.max(minWidth, startWidth + deltaX);
|
||||
const listConstraint = getListConstraint();
|
||||
|
||||
// Ensure the final width is applied
|
||||
$list[0].style.setProperty('--list-width', `${finalWidth}px`);
|
||||
@@ -429,55 +548,9 @@ Template.list.onCreated(function () {
|
||||
$('body').removeClass('list-resizing-active');
|
||||
$('body').css('user-select', '');
|
||||
|
||||
// Save the new width using the existing system
|
||||
const boardId = list.boardId;
|
||||
const listId = list._id;
|
||||
|
||||
if (process.env.DEBUG === 'true') {
|
||||
}
|
||||
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
if (currentUser) {
|
||||
// For logged-in users, use server method
|
||||
Meteor.call('applyListWidthToStorage', boardId, listId, finalWidth, listConstraint, (error, result) => {
|
||||
if (error) {
|
||||
console.error('Error saving list width:', error);
|
||||
} else {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// For non-logged-in users, save to localStorage directly
|
||||
try {
|
||||
// Save list width
|
||||
const storedWidths = localStorage.getItem('wekan-list-widths');
|
||||
let widths = storedWidths ? JSON.parse(storedWidths) : {};
|
||||
|
||||
if (!widths[boardId]) {
|
||||
widths[boardId] = {};
|
||||
}
|
||||
widths[boardId][listId] = finalWidth;
|
||||
|
||||
localStorage.setItem('wekan-list-widths', JSON.stringify(widths));
|
||||
|
||||
// Save list constraint
|
||||
const storedConstraints = localStorage.getItem('wekan-list-constraints');
|
||||
let constraints = storedConstraints ? JSON.parse(storedConstraints) : {};
|
||||
|
||||
if (!constraints[boardId]) {
|
||||
constraints[boardId] = {};
|
||||
}
|
||||
constraints[boardId][listId] = listConstraint;
|
||||
|
||||
localStorage.setItem('wekan-list-constraints', JSON.stringify(constraints));
|
||||
|
||||
if (process.env.DEBUG === 'true') {
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error saving list width/constraint to localStorage:', e);
|
||||
}
|
||||
}
|
||||
// #6409: persist to the shared list width or the user's personal width,
|
||||
// depending on the board's mode.
|
||||
saveListWidth(list, finalWidth);
|
||||
|
||||
e.preventDefault();
|
||||
};
|
||||
@@ -502,16 +575,6 @@ Template.list.onCreated(function () {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// Reactively update resize handle visibility when auto-width or collapse changes
|
||||
tpl.autorun(() => {
|
||||
const collapsed = Utils.getListCollapseState(list);
|
||||
if (getAutoWidth() || collapsed) {
|
||||
$resizeHandle.hide();
|
||||
} else {
|
||||
$resizeHandle.show();
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up on component destruction
|
||||
tpl.view.onViewDestroyed(() => {
|
||||
$(document).off('mousemove', doResize);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Pure helpers for the "add list" affordance.
|
||||
//
|
||||
// Issue #6142: In "Lists" board-view mode there was no UI affordance to add a
|
||||
// new list — only the "Swimlanes" mode rendered the trailing add-list composer.
|
||||
// These helpers keep the (testable) decision logic out of the Blaze templates.
|
||||
|
||||
// Board-view modes in which an "add list" composer should be offered.
|
||||
// Both the swimlanes view and the lists view let the user create a list; the
|
||||
// other views (calendar, gantt, table) do not render the list composer.
|
||||
export const ADD_LIST_VIEW_MODES = ['board-view-swimlanes', 'board-view-lists'];
|
||||
|
||||
// Returns true when the given board-view mode should show the add-list
|
||||
// composer. Used so Lists mode gets the same affordance as Swimlanes mode.
|
||||
export function canAddListInView(viewMode) {
|
||||
return ADD_LIST_VIEW_MODES.indexOf(viewMode) !== -1;
|
||||
}
|
||||
|
||||
// Decide which swimlane a newly created list should belong to when the user
|
||||
// adds a list from a view that is not bound to a specific swimlane (e.g. the
|
||||
// Lists view). Prefer an explicitly provided swimlane id, otherwise fall back
|
||||
// to the board's default swimlane id.
|
||||
//
|
||||
// `board` only needs to expose a `getDefaultSwimline()` method (the WeKan
|
||||
// Board model does); it may also be a plain object for testing.
|
||||
export function defaultSwimlaneIdForBoard(board, explicitSwimlaneId) {
|
||||
if (explicitSwimlaneId) {
|
||||
return explicitSwimlaneId;
|
||||
}
|
||||
if (board && typeof board.getDefaultSwimline === 'function') {
|
||||
const swimlane = board.getDefaultSwimline();
|
||||
if (swimlane && swimlane._id) {
|
||||
return swimlane._id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -324,7 +324,20 @@ Template.listBody.helpers({
|
||||
const tpl = Template.instance();
|
||||
const limit = tpl.cardlimit.get();
|
||||
const defaultSort = { sort: 1 };
|
||||
const sortBy = Session.get('sortBy') ? Session.get('sortBy') : defaultSort;
|
||||
// Session is reset on page reload; fall back to the persisted card sort so a
|
||||
// chosen sort (e.g. by due date) is remembered across reloads (#5886).
|
||||
let sortBy = Session.get('sortBy');
|
||||
if (!sortBy) {
|
||||
try {
|
||||
const stored = window.localStorage.getItem('wekan-cards-sortBy');
|
||||
if (stored) {
|
||||
sortBy = JSON.parse(stored);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
if (!sortBy) {
|
||||
sortBy = defaultSort;
|
||||
}
|
||||
const selector = {
|
||||
listId: Template.currentData()._id,
|
||||
archived: false,
|
||||
@@ -743,11 +756,37 @@ Template.linkCardPopup.events({
|
||||
tpl.selectedListId.set($(evt.currentTarget).val());
|
||||
},
|
||||
async 'click .js-done'(evt, tpl) {
|
||||
// LINK CARD
|
||||
// LINK CARD (or, when no card is chosen, fall back to a board-level link
|
||||
// so a whole board can be linked even when that board already has cards).
|
||||
// https://github.com/wekan/wekan/issues/5715
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
const linkedId = $('.js-select-cards option:selected').val();
|
||||
if (!linkedId) {
|
||||
const boardId = $('.js-select-boards option:selected').val();
|
||||
// No board and no card selected: nothing to link.
|
||||
if (!boardId) {
|
||||
Popup.back();
|
||||
return;
|
||||
}
|
||||
// A linkedBoard card for this board already exists; avoid duplicates.
|
||||
if (ReactiveCache.getCard({ linkedId: boardId, archived: false })) {
|
||||
Popup.back();
|
||||
return;
|
||||
}
|
||||
const boardCardNumber = await tpl.board.getNextCardNumber();
|
||||
const boardSortIndex = tpl.getSortIndex();
|
||||
const boardCardId = Cards.insert({
|
||||
title: $('.js-select-boards option:selected').text(), //dummy
|
||||
listId: tpl.listId,
|
||||
swimlaneId: tpl.swimlaneId,
|
||||
boardId: tpl.boardId,
|
||||
sort: boardSortIndex,
|
||||
type: 'cardType-linkedBoard',
|
||||
linkedId: boardId,
|
||||
cardNumber: boardCardNumber,
|
||||
});
|
||||
Filter.addException(boardCardId);
|
||||
Popup.back();
|
||||
return;
|
||||
}
|
||||
@@ -924,13 +963,37 @@ Template.searchElementPopup.events({
|
||||
if (!tpl.isTemplateSearch || tpl.isCardTemplateSearch) {
|
||||
// Card insertion
|
||||
// 1. Common
|
||||
// Compute the sort index BEFORE the first await: getSortIndex() reads
|
||||
// Template.currentData(), and Blaze's current-view context is lost across
|
||||
// an await, so calling it afterwards threw "There is no current view" and
|
||||
// no card was created from the template. Capture it while still in the
|
||||
// synchronous event/view context.
|
||||
const sortIndex = tpl.getSortIndex();
|
||||
element.cardNumber = await tpl.board.getNextCardNumber();
|
||||
element.sort = tpl.getSortIndex();
|
||||
element.sort = sortIndex;
|
||||
// 1.A From template
|
||||
if (tpl.isTemplateSearch) {
|
||||
element.type = 'cardType-card';
|
||||
element.linkedId = '';
|
||||
_id = await element.copy(tpl.boardId, tpl.swimlaneId, tpl.listId);
|
||||
// #5798: instantiate the template into the CURRENT board, not the
|
||||
// templates board. For a template search tpl.boardId is the templates
|
||||
// board (it is the search source), so copying with it created the card
|
||||
// with boardId = templates board. The card still showed in the target
|
||||
// list (the list query is by listId and the templates board is
|
||||
// subscribed), but clicking it navigated to the templates board instead
|
||||
// of opening the card. The target list/swimlane were already resolved on
|
||||
// the current board.
|
||||
// Instantiate into the board that OWNS the target list (where the user
|
||||
// clicked "add card"). Deriving it from tpl.listId is reliable even when
|
||||
// Utils.getCurrentBoard() (route/session state) has not settled yet — as
|
||||
// on the slower CI production bundle, where falling back to tpl.boardId
|
||||
// wrongly created the card on the templates board.
|
||||
const targetList = ReactiveCache.getList(tpl.listId);
|
||||
const targetBoardId =
|
||||
(targetList || {}).boardId ||
|
||||
(Utils.getCurrentBoard() || {})._id ||
|
||||
tpl.boardId;
|
||||
_id = await element.copy(targetBoardId, tpl.swimlaneId, tpl.listId);
|
||||
// 1.B Linked card
|
||||
} else {
|
||||
_id = element.link(tpl.boardId, tpl.swimlaneId, tpl.listId);
|
||||
|
||||
@@ -252,17 +252,20 @@ template(name="wipLimitErrorPopup")
|
||||
|
||||
template(name="setListWidthPopup")
|
||||
#js-list-width-edit
|
||||
label {{_ 'set-list-width-value'}}
|
||||
p
|
||||
input.list-width-value(type="number" value="{{ listWidthValue }}" min="270")
|
||||
input.list-constraint-value(type="number" value="{{ listConstraintValue }}" min="270")
|
||||
input.list-width-apply(type="submit" value="{{_ 'apply'}}")
|
||||
input.list-width-error
|
||||
br
|
||||
a.js-auto-width-board(
|
||||
title="{{#if isAutoWidth}}{{_ 'click-to-disable-auto-width'}}{{else}}{{_ 'click-to-enable-auto-width'}}{{/if}}")
|
||||
| {{#if isAutoWidth}}🗜️{{else}}📏{{/if}}
|
||||
span {{_ 'auto-list-width'}}
|
||||
unless isAutoWidth
|
||||
label {{_ 'set-list-width-value'}}
|
||||
p
|
||||
input.list-width-value(type="number" value="{{ listWidthValue }}" min="270")
|
||||
input.list-width-apply(type="submit" value="{{_ 'apply'}}")
|
||||
input.list-width-error
|
||||
if canChangeAutoWidth
|
||||
a.js-toggle-auto-width(title="{{#if isAutoWidth}}{{_ 'click-to-disable-auto-width'}}{{else}}{{_ 'click-to-enable-auto-width'}}{{/if}}")
|
||||
| {{#if isAutoWidth}}🗜️{{else}}📏{{/if}}
|
||||
span {{_ 'auto-list-width'}}
|
||||
a.js-toggle-fixed-list-width(title="{{#if isFixedListWidth}}{{_ 'click-to-disable-fixed-list-width'}}{{else}}{{_ 'click-to-enable-fixed-list-width'}}{{/if}}")
|
||||
| {{#if isFixedListWidth}}✅{{else}}⬜{{/if}}
|
||||
span {{_ 'fixed-list-width'}}
|
||||
p.list-width-scope-note {{ listWidthScopeNote }}
|
||||
|
||||
template(name="listWidthErrorPopup")
|
||||
.list-width-invalid
|
||||
|
||||
@@ -20,12 +20,12 @@ Template.listHeader.helpers({
|
||||
(!list.getWipLimit('enabled') ||
|
||||
list.getWipLimit('soft') ||
|
||||
!Template.instance().reachedWipLimit()) &&
|
||||
!ReactiveCache.getCurrentUser().isWorker()
|
||||
!ReactiveCache.getCurrentUser()?.isWorker()
|
||||
);
|
||||
},
|
||||
|
||||
isBoardAdmin() {
|
||||
return ReactiveCache.getCurrentUser().isBoardAdmin();
|
||||
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
|
||||
},
|
||||
|
||||
starred() {
|
||||
@@ -218,7 +218,7 @@ Template.listHeader.events({
|
||||
|
||||
Template.listActionPopup.helpers({
|
||||
isBoardAdmin() {
|
||||
return ReactiveCache.getCurrentUser().isBoardAdmin();
|
||||
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
|
||||
},
|
||||
|
||||
isWipLimitEnabled() {
|
||||
@@ -258,7 +258,15 @@ Template.listActionPopup.events({
|
||||
'click .js-set-list-width': Popup.open('setListWidth'),
|
||||
'click .js-set-color-list': Popup.open('setListColor'),
|
||||
'click .js-select-cards'() {
|
||||
const cardIds = this.allCards().map(card => card._id);
|
||||
// Scope "select all cards" to the current swimlane when invoked from a
|
||||
// swimlane context (#5623). In swimlanes board view the list carries its
|
||||
// swimlaneId; otherwise there is genuinely no swimlane context and we keep
|
||||
// the historical list-wide selection (swimlaneId stays undefined).
|
||||
let swimlaneId;
|
||||
if (Utils.boardView() === 'board-view-swimlanes' && this.swimlaneId) {
|
||||
swimlaneId = this.swimlaneId;
|
||||
}
|
||||
const cardIds = this.allCards(swimlaneId).map(card => card._id);
|
||||
MultiSelection.add(cardIds);
|
||||
Popup.back();
|
||||
},
|
||||
@@ -477,10 +485,8 @@ Template.setListColorPopup.events({
|
||||
},
|
||||
async 'submit form'(event, tpl) {
|
||||
event.preventDefault();
|
||||
console.log('[ListColor] submit form, color:', tpl.currentColor.get(), 'listId:', tpl.currentList?._id, 'setColor type:', typeof tpl.currentList?.setColor);
|
||||
try {
|
||||
const result = await tpl.currentList.setColor(tpl.currentColor.get());
|
||||
console.log('[ListColor] submit form setColor result:', result);
|
||||
await tpl.currentList.setColor(tpl.currentColor.get());
|
||||
} catch (err) {
|
||||
console.error('[ListColor] submit form setColor error:', err);
|
||||
}
|
||||
@@ -488,10 +494,8 @@ Template.setListColorPopup.events({
|
||||
},
|
||||
async 'click .js-submit'(event, tpl) {
|
||||
event.preventDefault();
|
||||
console.log('[ListColor] click submit, color:', tpl.currentColor.get(), 'listId:', tpl.currentList?._id, 'setColor type:', typeof tpl.currentList?.setColor);
|
||||
try {
|
||||
const result = await tpl.currentList.setColor(tpl.currentColor.get());
|
||||
console.log('[ListColor] click submit setColor result:', result);
|
||||
await tpl.currentList.setColor(tpl.currentColor.get());
|
||||
} catch (err) {
|
||||
console.error('[ListColor] click submit setColor error:', err);
|
||||
}
|
||||
@@ -499,7 +503,6 @@ Template.setListColorPopup.events({
|
||||
},
|
||||
async 'click .js-remove-color'(event, tpl) {
|
||||
event.preventDefault();
|
||||
console.log('[ListColor] remove color, listId:', tpl.currentList?._id);
|
||||
try {
|
||||
await tpl.currentList.setColor(null);
|
||||
} catch (err) {
|
||||
@@ -509,50 +512,199 @@ Template.setListColorPopup.events({
|
||||
},
|
||||
});
|
||||
|
||||
// #6409: the per-list width popup is now a single fixed-width value. Whether it
|
||||
// affects everyone (shared) or just the current user (personal) follows the
|
||||
// board setting `allowsPersonalListWidth`.
|
||||
function isPersonalListWidth(boardId) {
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
return !!(board && board.allowsPersonalListWidth);
|
||||
}
|
||||
|
||||
// #5729 Fixed (same) width for all lists is a per-viewer/per-board setting.
|
||||
// Logged-in users store it in their profile; anonymous (public board) users in
|
||||
// localStorage (mirrors the per-list anon storage in list.js).
|
||||
function readAnonFixedListWidthEnabled(boardId) {
|
||||
try {
|
||||
const stored = localStorage.getItem('wekan-fixed-list-width-enabled');
|
||||
if (stored) {
|
||||
const flags = JSON.parse(stored);
|
||||
return flags[boardId] === true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error reading fixed list width flag from localStorage:', e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function readAnonFixedListWidth(boardId) {
|
||||
try {
|
||||
const stored = localStorage.getItem('wekan-fixed-list-width');
|
||||
if (stored) {
|
||||
const widths = JSON.parse(stored);
|
||||
const w = widths[boardId];
|
||||
if (typeof w === 'number' && w >= 270) return w;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error reading fixed list width from localStorage:', e);
|
||||
}
|
||||
return 272;
|
||||
}
|
||||
|
||||
function isFixedListWidth(boardId) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (user) return !!user.isFixedListWidth(boardId);
|
||||
return readAnonFixedListWidthEnabled(boardId);
|
||||
}
|
||||
|
||||
function fixedListWidthValue(boardId) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (user) return user.getFixedListWidth(boardId);
|
||||
return readAnonFixedListWidth(boardId);
|
||||
}
|
||||
|
||||
function setAnonFixedListWidthEnabled(boardId, enabled) {
|
||||
try {
|
||||
const stored = localStorage.getItem('wekan-fixed-list-width-enabled');
|
||||
const flags = stored ? JSON.parse(stored) : {};
|
||||
flags[boardId] = !!enabled;
|
||||
localStorage.setItem('wekan-fixed-list-width-enabled', JSON.stringify(flags));
|
||||
} catch (e) {
|
||||
console.warn('Error saving fixed list width flag to localStorage:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function setAnonFixedListWidth(boardId, width) {
|
||||
try {
|
||||
const stored = localStorage.getItem('wekan-fixed-list-width');
|
||||
const widths = stored ? JSON.parse(stored) : {};
|
||||
widths[boardId] = width;
|
||||
localStorage.setItem('wekan-fixed-list-width', JSON.stringify(widths));
|
||||
} catch (e) {
|
||||
console.warn('Error saving fixed list width to localStorage:', e);
|
||||
}
|
||||
}
|
||||
|
||||
Template.setListWidthPopup.helpers({
|
||||
listWidthValue() {
|
||||
const list = Template.currentData();
|
||||
const board = list.boardId;
|
||||
return ReactiveCache.getCurrentUser().getListWidth(board, list._id);
|
||||
},
|
||||
|
||||
listConstraintValue() {
|
||||
const list = Template.currentData();
|
||||
const board = list.boardId;
|
||||
return ReactiveCache.getCurrentUser().getListConstraint(board, list._id);
|
||||
},
|
||||
|
||||
isAutoWidth() {
|
||||
const boardId = Utils.getCurrentBoardId();
|
||||
// #5729 In fixed width mode the input edits the single per-board value.
|
||||
if (isFixedListWidth(list.boardId)) {
|
||||
return fixedListWidthValue(list.boardId);
|
||||
}
|
||||
const shared =
|
||||
typeof list.width === 'number' && list.width >= 270 ? list.width : 272;
|
||||
if (!isPersonalListWidth(list.boardId)) {
|
||||
return shared;
|
||||
}
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
return user && user.isAutoWidth(boardId);
|
||||
if (user) {
|
||||
const widths = user.getListWidths();
|
||||
const w = widths[list.boardId] && widths[list.boardId][list._id];
|
||||
return typeof w === 'number' && w >= 270 ? w : shared;
|
||||
}
|
||||
return shared;
|
||||
},
|
||||
|
||||
listWidthScopeNote() {
|
||||
const list = Template.currentData();
|
||||
// #5729 In fixed width mode the note explains the all-lists behaviour.
|
||||
if (isFixedListWidth(list.boardId)) {
|
||||
return TAPi18n.__('fixed-list-width-note');
|
||||
}
|
||||
return isPersonalListWidth(list.boardId)
|
||||
? TAPi18n.__('list-width-personal-note')
|
||||
: TAPi18n.__('list-width-shared-note');
|
||||
},
|
||||
|
||||
// #6409: auto-width follows the same scope as fixed widths.
|
||||
isAutoWidth() {
|
||||
const list = Template.currentData();
|
||||
if (isPersonalListWidth(list.boardId)) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
return !!(user && user.isAutoWidth(list.boardId));
|
||||
}
|
||||
const board = ReactiveCache.getBoard(list.boardId);
|
||||
return !!(board && board.autoWidth);
|
||||
},
|
||||
|
||||
// In shared mode only members with board write access may toggle the shared
|
||||
// auto-width; in personal mode any logged-in user may toggle their own.
|
||||
canChangeAutoWidth() {
|
||||
const list = Template.currentData();
|
||||
if (isPersonalListWidth(list.boardId)) {
|
||||
return !!ReactiveCache.getCurrentUser();
|
||||
}
|
||||
return Utils.canModifyBoard();
|
||||
},
|
||||
|
||||
// #5729 Whether "same width for all lists" mode is on for the current viewer.
|
||||
isFixedListWidth() {
|
||||
const list = Template.currentData();
|
||||
return isFixedListWidth(list.boardId);
|
||||
},
|
||||
});
|
||||
|
||||
Template.setListWidthPopup.events({
|
||||
'click .js-auto-width-board'() {
|
||||
dragscroll.reset();
|
||||
ReactiveCache.getCurrentUser().toggleAutoWidth(Utils.getCurrentBoardId());
|
||||
'click .js-toggle-auto-width'(event) {
|
||||
event.preventDefault();
|
||||
const list = Template.currentData();
|
||||
const boardId = list.boardId;
|
||||
if (isPersonalListWidth(boardId)) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (user) user.toggleAutoWidth(boardId);
|
||||
} else {
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
const current = !!(board && board.autoWidth);
|
||||
Meteor.call('setBoardAutoWidth', boardId, !current);
|
||||
}
|
||||
Popup.back();
|
||||
},
|
||||
// #5729 Toggle "same width for all lists" (fixed width) for the current
|
||||
// viewer. Enabling it turns off auto-width (the two modes are exclusive).
|
||||
'click .js-toggle-fixed-list-width'(event) {
|
||||
event.preventDefault();
|
||||
const list = Template.currentData();
|
||||
const boardId = list.boardId;
|
||||
const enabled = !isFixedListWidth(boardId);
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (user) {
|
||||
if (enabled && isPersonalListWidth(boardId) && user.isAutoWidth(boardId)) {
|
||||
user.toggleAutoWidth(boardId);
|
||||
}
|
||||
Meteor.call('setFixedListWidthEnabled', boardId, enabled);
|
||||
} else {
|
||||
setAnonFixedListWidthEnabled(boardId, enabled);
|
||||
}
|
||||
Popup.back();
|
||||
},
|
||||
'click .list-width-apply'(event, tpl) {
|
||||
const list = Template.currentData();
|
||||
const board = list.boardId;
|
||||
const width = parseInt(
|
||||
tpl.$('.list-width-value').val(),
|
||||
10,
|
||||
);
|
||||
const constraint = parseInt(
|
||||
tpl.$('.list-constraint-value').val(),
|
||||
10,
|
||||
);
|
||||
const boardId = list.boardId;
|
||||
const width = parseInt(tpl.$('.list-width-value').val(), 10);
|
||||
|
||||
// FIXME(mark-i-m): where do we put constants?
|
||||
if (width < 270 || !width || constraint < 270 || !constraint) {
|
||||
if (!width || width < 270) {
|
||||
tpl.$('.list-width-error').click();
|
||||
} else {
|
||||
Meteor.call('applyListWidth', board, list._id, width, constraint);
|
||||
Popup.back();
|
||||
return;
|
||||
}
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
// #5729 In fixed width mode the input applies to the single per-board value.
|
||||
if (isFixedListWidth(boardId)) {
|
||||
if (user) {
|
||||
Meteor.call('setFixedListWidth', boardId, width);
|
||||
} else {
|
||||
setAnonFixedListWidth(boardId, width);
|
||||
}
|
||||
Popup.back();
|
||||
return;
|
||||
}
|
||||
if (isPersonalListWidth(boardId)) {
|
||||
if (user) {
|
||||
Meteor.call('applyListWidthToStorage', boardId, list._id, width, width);
|
||||
}
|
||||
} else if (user) {
|
||||
Meteor.call('applyListWidth', boardId, list._id, width, width);
|
||||
}
|
||||
Popup.back();
|
||||
},
|
||||
'click .list-width-error': Popup.open('listWidthError'),
|
||||
});
|
||||
|
||||
@@ -3,3 +3,24 @@
|
||||
margin-inline-end: auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
/* Scrollable list container for the Due Cards view.
|
||||
Scoped to the due-cards list only, not global (issue #5930). */
|
||||
.due-cards-list {
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
/* Leave room for the header bar / results header so the list itself can
|
||||
scroll instead of being clipped after the first few cards. */
|
||||
max-height: calc(100vh - 160px);
|
||||
}
|
||||
|
||||
/* On small screens (mobile / iPhone) the previous fixed height clipped the
|
||||
list at roughly 5 cards. Allow the list to use the available viewport
|
||||
height and scroll past the first cards. */
|
||||
@media screen and (max-width: 800px) {
|
||||
.due-cards-list {
|
||||
max-height: calc(100vh - 120px);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,9 @@ template(name="dueCards")
|
||||
.due-cards-results-header
|
||||
h1
|
||||
= resultsText
|
||||
each card in dueCardsList
|
||||
+resultCard(card)
|
||||
.due-cards-list
|
||||
each card in dueCardsList
|
||||
+resultCard(card)
|
||||
else
|
||||
.global-search-results-list-wrapper
|
||||
.no-results
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { filterAndSortDueCards } from '/client/components/main/dueCardsLogic';
|
||||
|
||||
// const subManager = new SubsManager();
|
||||
|
||||
@@ -70,44 +71,17 @@ Template.dueCards.onCreated(function () {
|
||||
// Filter cards based on user view preference
|
||||
const allUsers = dueCardsView() === 'all';
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
let filteredCards = cards;
|
||||
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.log('dueCards client: current user:', currentUser ? currentUser._id : 'none');
|
||||
console.log('dueCards client: showing all users:', allUsers);
|
||||
}
|
||||
|
||||
if (!allUsers && currentUser) {
|
||||
filteredCards = cards.filter(card => {
|
||||
const isMember = card.members && card.members.includes(currentUser._id);
|
||||
const isAssignee = card.assignees && card.assignees.includes(currentUser._id);
|
||||
const isAuthor = card.userId === currentUser._id;
|
||||
const matches = isMember || isAssignee || isAuthor;
|
||||
|
||||
if (process.env.DEBUG === 'true' && matches) {
|
||||
console.log('dueCards client: card matches user:', card.title, { isMember, isAssignee, isAuthor });
|
||||
}
|
||||
|
||||
return matches;
|
||||
});
|
||||
}
|
||||
|
||||
// Normalize dueAt to timestamps for stable client-side ordering
|
||||
const future = new Date('2100-12-31').getTime();
|
||||
const toTime = v => {
|
||||
if (v === null || v === undefined || v === '') return future;
|
||||
if (v instanceof Date) return v.getTime();
|
||||
const t = new Date(v);
|
||||
if (!isNaN(t.getTime())) return t.getTime();
|
||||
return future;
|
||||
};
|
||||
|
||||
filteredCards.sort((a, b) => {
|
||||
const x = toTime(a.dueAt);
|
||||
const y = toTime(b.dueAt);
|
||||
if (x > y) return 1;
|
||||
if (x < y) return -1;
|
||||
return 0;
|
||||
// Filter (by user unless "all") and sort ascending by due date. No cap is
|
||||
// applied so every due card is shown (issues #5999 / #5930).
|
||||
const filteredCards = filterAndSortDueCards(cards, {
|
||||
allUsers,
|
||||
userId: currentUser ? currentUser._id : null,
|
||||
});
|
||||
|
||||
if (process.env.DEBUG === 'true') {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Pure, side-effect-free helpers for the Due Cards view.
|
||||
//
|
||||
// These are extracted from dueCards.js so they can be unit-tested in
|
||||
// isolation (see client/lib/tests/dueCards.logic.tests.js). They must not
|
||||
// reference Meteor, Blaze, ReactiveCache or the DOM.
|
||||
|
||||
// A far-future sentinel used to push cards with a missing/invalid dueAt to the
|
||||
// end of the ascending sort instead of dropping them.
|
||||
const FAR_FUTURE = new Date('2100-12-31').getTime();
|
||||
|
||||
// Normalize a dueAt value (Date | string | null | undefined) to a comparable
|
||||
// millisecond timestamp. Invalid / empty values sort last.
|
||||
export function dueAtToTime(value, farFuture = FAR_FUTURE) {
|
||||
if (value === null || value === undefined || value === '') return farFuture;
|
||||
if (value instanceof Date) return value.getTime();
|
||||
const t = new Date(value);
|
||||
if (!isNaN(t.getTime())) return t.getTime();
|
||||
return farFuture;
|
||||
}
|
||||
|
||||
// Decide whether a card belongs to the current user (member, assignee or
|
||||
// author). Used when the view is "me" rather than "all".
|
||||
export function cardMatchesUser(card, userId) {
|
||||
if (!card || !userId) return false;
|
||||
const isMember = Array.isArray(card.members) && card.members.includes(userId);
|
||||
const isAssignee =
|
||||
Array.isArray(card.assignees) && card.assignees.includes(userId);
|
||||
const isAuthor = card.userId === userId;
|
||||
return isMember || isAssignee || isAuthor;
|
||||
}
|
||||
|
||||
// Filter (by user, unless allUsers) and sort (ascending by due date) a list of
|
||||
// due cards. Returns a new array; does not mutate the input. Importantly this
|
||||
// imposes NO cap on the number of results (issue #5999 / #5930).
|
||||
export function filterAndSortDueCards(cards, { allUsers = false, userId = null } = {}) {
|
||||
const list = Array.isArray(cards) ? cards.slice() : [];
|
||||
|
||||
const filtered =
|
||||
allUsers || !userId
|
||||
? list
|
||||
: list.filter(card => cardMatchesUser(card, userId));
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
const x = dueAtToTime(a.dueAt);
|
||||
const y = dueAtToTime(b.dueAt);
|
||||
if (x > y) return 1;
|
||||
if (x < y) return -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}
|
||||
@@ -130,3 +130,22 @@ code {
|
||||
.global-search-instructions li {
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
/* #6034 (MVP): drag-and-drop search results into board columns. */
|
||||
.global-search-results-list .result-card-wrapper {
|
||||
cursor: grab;
|
||||
}
|
||||
.global-search-results-list .result-card-wrapper:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.global-search-result-drag-helper {
|
||||
z-index: 1000;
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
list-style: none;
|
||||
}
|
||||
.global-search-drop-hover {
|
||||
outline: 2px dashed #216694;
|
||||
outline-offset: -2px;
|
||||
background-color: rgba(33, 102, 148, 0.08);
|
||||
}
|
||||
@@ -76,7 +76,7 @@ template(name="globalSearch")
|
||||
if searching
|
||||
+spinner
|
||||
else if hasResults
|
||||
.global-search-results-list-wrapper
|
||||
.global-search-results-list-wrapper.global-search-results-list
|
||||
if hasQueryErrors
|
||||
ul
|
||||
each msg in errorMessages
|
||||
|
||||
@@ -74,8 +74,147 @@ Template.globalSearch.onRendered(function () {
|
||||
searchAllBoards(tpl, queryText || '');
|
||||
}
|
||||
});
|
||||
|
||||
// #6034 (MVP): make search-result cards draggable so they can be dropped
|
||||
// into a board list/column. Re-wire whenever the results change.
|
||||
this.autorun(() => {
|
||||
tpl.search.results.get(); // reactive dependency: re-run when results render
|
||||
Tracker.afterFlush(() => setupResultDrag(tpl));
|
||||
});
|
||||
});
|
||||
|
||||
// #6034 (MVP): drag-and-drop search results into board columns.
|
||||
//
|
||||
// We make each `.global-search-results-list` result item a jquery-ui
|
||||
// `draggable` (clone helper), and make every board list's `.js-minicards`
|
||||
// container a `droppable`. On drop we resolve the dragged card and call the
|
||||
// existing `card.move(boardId, swimlaneId, listId, sort)` model method - the
|
||||
// same method the in-board card sortable uses - so the move stays consistent
|
||||
// with normal card drag-drop and is persisted + reactively re-rendered.
|
||||
//
|
||||
// LIMITATION / TODO: this MVP drops the card at the END of the target list
|
||||
// (sort = list cards length) rather than at the exact pixel-precise insertion
|
||||
// index used by the in-board minicard sortable. Pixel-precise positioning and
|
||||
// multi-select drag are intentionally out of scope here; wiring those would
|
||||
// require modifying the (non-owned) list/minicard sortable `receive` handler.
|
||||
// Drag is only enabled when the board is editable and a board is open.
|
||||
function setupResultDrag(tpl) {
|
||||
try {
|
||||
if (typeof window === 'undefined' || !window.jQuery) return;
|
||||
const $ = window.jQuery;
|
||||
|
||||
const $items = tpl.$('.global-search-results-list .result-card-wrapper');
|
||||
if (!$items.length || typeof $items.draggable !== 'function') return;
|
||||
|
||||
// Only allow DnD when we are inside a board the user can modify.
|
||||
const boardId = Session.get('currentBoard');
|
||||
if (!boardId) return;
|
||||
const canModify =
|
||||
Utils && typeof Utils.canModifyBoard === 'function'
|
||||
? Utils.canModifyBoard()
|
||||
: false;
|
||||
if (!canModify) return;
|
||||
|
||||
$items.each(function () {
|
||||
const el = this;
|
||||
// Avoid double-initialising the same element.
|
||||
if ($(el).data('uiDraggable')) return;
|
||||
|
||||
$(el).draggable({
|
||||
helper: 'clone',
|
||||
appendTo: 'body',
|
||||
revert: 'invalid',
|
||||
revertDuration: 150,
|
||||
cursor: 'grabbing',
|
||||
zIndex: 1000,
|
||||
start(evt, ui) {
|
||||
ui.helper.addClass('global-search-result-drag-helper');
|
||||
ui.helper.css('width', $(el).width());
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Make board lists accept the dropped result card.
|
||||
const $lists = $('.list .js-minicards');
|
||||
if (!$lists.length || typeof $lists.droppable !== 'function') return;
|
||||
|
||||
$lists.each(function () {
|
||||
const listEl = this;
|
||||
if ($(listEl).data('uiDroppable')) return;
|
||||
|
||||
$(listEl).droppable({
|
||||
accept: '.result-card-wrapper',
|
||||
tolerance: 'pointer',
|
||||
hoverClass: 'global-search-drop-hover',
|
||||
drop(evt, ui) {
|
||||
onResultDroppedOnList(listEl, ui.draggable.get(0));
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error wiring search-result drag-and-drop (#6034):', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the dragged result card + target list and perform the move using the
|
||||
// existing card model `move()` method (mirrors the in-board card sortable).
|
||||
function onResultDroppedOnList(listEl, draggedEl) {
|
||||
try {
|
||||
if (!listEl || !draggedEl) return;
|
||||
|
||||
// Card data from the dragged result item (Blaze data on the result wrapper,
|
||||
// falling back to the inner minicard).
|
||||
let cardData = Blaze.getData(draggedEl);
|
||||
if (!cardData || !cardData._id) {
|
||||
const inner = draggedEl.querySelector('.js-minicard');
|
||||
if (inner) cardData = Blaze.getData(inner);
|
||||
}
|
||||
if (!cardData || !cardData._id) return;
|
||||
|
||||
const card = ReactiveCache.getCard(cardData._id);
|
||||
if (!card || typeof card.move !== 'function') return;
|
||||
|
||||
// Target list / swimlane / board from the drop target.
|
||||
const listData = Blaze.getData(listEl.closest('.list') || listEl);
|
||||
if (!listData || !listData._id) return;
|
||||
const listId = listData._id;
|
||||
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
if (!currentBoard) return;
|
||||
|
||||
let swimlaneId = listData.swimlaneId;
|
||||
if (!swimlaneId) {
|
||||
const swimlaneEl = listEl.closest('.swimlane');
|
||||
if (swimlaneEl) {
|
||||
const swimlaneData = Blaze.getData(swimlaneEl);
|
||||
swimlaneId = swimlaneData && swimlaneData._id;
|
||||
}
|
||||
}
|
||||
if (!swimlaneId) {
|
||||
const def = currentBoard.getDefaultSwimline && currentBoard.getDefaultSwimline();
|
||||
swimlaneId = (def && def._id) || card.swimlaneId;
|
||||
}
|
||||
|
||||
// MVP: append to the end of the target list.
|
||||
let sort = 0;
|
||||
try {
|
||||
const existing = ReactiveCache.getCards(
|
||||
{ listId, archived: false },
|
||||
{ sort: { sort: -1 }, limit: 1 },
|
||||
);
|
||||
if (existing && existing.length && typeof existing[0].sort === 'number') {
|
||||
sort = existing[0].sort + 1;
|
||||
}
|
||||
} catch (e) {
|
||||
sort = 0;
|
||||
}
|
||||
|
||||
card.move(currentBoard._id, swimlaneId, listId, sort);
|
||||
} catch (e) {
|
||||
console.error('Error moving dropped search result (#6034):', e);
|
||||
}
|
||||
}
|
||||
|
||||
function searchAllBoards(tpl, queryText) {
|
||||
const search = tpl.search;
|
||||
|
||||
|
||||
@@ -496,15 +496,19 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
@media screen and (max-width: 800px),
|
||||
screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
|
||||
#header #header-main-bar {
|
||||
height: 40px;
|
||||
/* #6419: taller bar so the action icons can use ~44px touch targets. */
|
||||
height: 48px;
|
||||
}
|
||||
#header #header-main-bar .board-header-btns {
|
||||
margin-top: 0px;
|
||||
}
|
||||
#header #header-main-bar .board-header-btn {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
font-size: 15px;
|
||||
/* #6419: 32px tall / 15px was below the comfortable mobile touch-target and
|
||||
readable-font minimum (~44px / 16px). Enlarged vertically + bigger font;
|
||||
width is unchanged so this does not worsen the horizontal icon crowding. */
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
font-size: 16px;
|
||||
}
|
||||
#header #header-main-bar .board-header-btn i.fa {
|
||||
line-height: 32px;
|
||||
|
||||
@@ -113,7 +113,7 @@ template(name="header")
|
||||
i.fa.fa-bullhorn(aria-hidden="true")
|
||||
+viewer
|
||||
| #{announcement}
|
||||
a.js-close-announcement(aria-label="{{_ 'close'}}")
|
||||
a.js-close-announcement(aria-label="{{_ 'close'}}" title="{{_ 'close'}}")
|
||||
i.fa.fa-times-thin(aria-hidden="true")
|
||||
|
||||
template(name="offlineWarning")
|
||||
|
||||
@@ -2,7 +2,10 @@ import { Meteor } from 'meteor/meteor';
|
||||
import { Session } from 'meteor/session';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import Announcements from '/models/announcements';
|
||||
import Announcements, {
|
||||
announcementVersion,
|
||||
shouldShowAnnouncement,
|
||||
} from '/models/announcements';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
|
||||
Meteor.subscribe('user-admin');
|
||||
@@ -71,14 +74,25 @@ Template.header.helpers({
|
||||
},
|
||||
|
||||
hasAnnouncement() {
|
||||
const announcements = Announcements.findOne();
|
||||
return announcements && announcements.enabled;
|
||||
const announcement = Announcements.findOne();
|
||||
if (!announcement || !announcement.enabled) {
|
||||
return false;
|
||||
}
|
||||
const version = announcementVersion(announcement);
|
||||
const user = Meteor.user();
|
||||
const dismissedVersion =
|
||||
(user && user.profile && user.profile.dismissedAnnouncementVersion) || null;
|
||||
return shouldShowAnnouncement({
|
||||
enabled: announcement.enabled,
|
||||
version,
|
||||
dismissedVersion,
|
||||
});
|
||||
},
|
||||
|
||||
announcement() {
|
||||
$('.announcement').show();
|
||||
const announcements = Announcements.findOne();
|
||||
return announcements && announcements.body;
|
||||
const announcement = Announcements.findOne();
|
||||
return announcement && announcement.body;
|
||||
},
|
||||
|
||||
zoomLevel() {
|
||||
@@ -150,6 +164,13 @@ Template.header.events({
|
||||
},
|
||||
'click .js-close-announcement'() {
|
||||
$('.announcement').hide();
|
||||
// Permanently dismiss the current announcement for this user (#6051).
|
||||
// The banner reappears only when the admin changes the announcement text.
|
||||
Meteor.call('dismissAnnouncement', (err) => {
|
||||
if (err && process.env.DEBUG === 'true') {
|
||||
console.error('dismissAnnouncement error', err);
|
||||
}
|
||||
});
|
||||
},
|
||||
'click .js-select-list'() {
|
||||
Session.set('currentList', this._id);
|
||||
|
||||
@@ -267,7 +267,12 @@ Template.main.helpers({
|
||||
Template.userFormsLayout.events({
|
||||
'change .js-userform-set-language'(event) {
|
||||
const tag = $(event.currentTarget).val();
|
||||
TAPi18n.setLanguage(tag);
|
||||
// setLanguage is async; surface a failed load instead of silently leaving
|
||||
// the UI in English (#5756).
|
||||
Promise.resolve(TAPi18n.setLanguage(tag)).catch(error => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Failed to switch language to ${tag}:`, error);
|
||||
});
|
||||
event.preventDefault();
|
||||
},
|
||||
'click #at-btn'(event, templateInstance) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CSSEvents } from '/client/lib/cssEvents';
|
||||
import { isMobileViewportNow } from '/client/lib/responsiveUtils';
|
||||
|
||||
Popup.template.events({
|
||||
'click .js-back-view'() {
|
||||
@@ -10,6 +11,20 @@ Popup.template.events({
|
||||
'click .js-confirm'() {
|
||||
this.__afterConfirmAction.call(this);
|
||||
},
|
||||
// #5942: On mobile/touch, tapping inside the card-detail sub-popups (assign
|
||||
// user / set due date) made the popup DISAPPEAR. The document-level
|
||||
// click-outside handler (EscapeActions in client/lib/popup.js) closes the
|
||||
// popup, and on touch some freshly-rendered children (avatars, native date
|
||||
// inputs) generated events that were not recognised as "inside the popup",
|
||||
// closing it before the tap could register. Stop touch/pointer events that
|
||||
// originate inside the popup from bubbling to that document handler so the
|
||||
// popup stays open and usable on mobile. Scoped to mobile viewports so the
|
||||
// desktop click-outside-to-close behaviour is untouched.
|
||||
'touchstart .pop-over, pointerdown .pop-over'(evt) {
|
||||
if (isMobileViewportNow()) {
|
||||
evt.stopPropagation();
|
||||
}
|
||||
},
|
||||
// This handler intends to solve a pretty tricky bug with our popup
|
||||
// transition. The transition is implemented using a large container
|
||||
// (.content-container) that is moved on the x-axis (from 0 to n*PopupSize)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/* ============================================================================
|
||||
responsiveFixes.css
|
||||
|
||||
Global mobile / responsive corrective overrides. Meteor auto-includes every
|
||||
.css file under client/, so simply creating this file loads it.
|
||||
|
||||
Everything here is ADDITIVE and scoped under (max-width) media queries so the
|
||||
desktop layout is left untouched. It deliberately lives in one place so the
|
||||
mobile regressions below are easy to find and tune.
|
||||
|
||||
Issues addressed:
|
||||
#5942 card-detail sub-popups (assign user / due date) disappearing on
|
||||
touch -> JS guard lives in client/components/main/popup.js; this
|
||||
file just makes sure those popups can't be visually clipped.
|
||||
#5902 minicards rendering two-per-row instead of one-per-row on phones;
|
||||
tiny hard-to-tap Home button in the header.
|
||||
#6018 enormous icons / menus / text below the menu bar after upgrade.
|
||||
#5982 excessive padding / margins and tiny button labels.
|
||||
============================================================================ */
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
#5902 - One minicard per row on narrow screens.
|
||||
|
||||
The minicard width itself is owned by minicard.css; we only add a mobile
|
||||
override here (per task constraints) so phones get a single full-width card
|
||||
per row instead of two side by side.
|
||||
---------------------------------------------------------------------------- */
|
||||
@media (max-width: 768px) {
|
||||
.minicard,
|
||||
.minicard-wrapper {
|
||||
width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
/* The list/swimlane card container should stack cards vertically, not wrap
|
||||
them into columns. */
|
||||
.list-body .minicard-wrapper,
|
||||
.swimlane .minicard-wrapper {
|
||||
display: block !important;
|
||||
float: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
#5902 - Make the header "Home / All Boards" button a proper tap target.
|
||||
|
||||
The home icon link was tiny. Apple/Material guidance is a >=44px touch
|
||||
target; give it comfortable padding and a minimum size on touch widths.
|
||||
---------------------------------------------------------------------------- */
|
||||
@media (max-width: 768px) {
|
||||
#header-quick-access .home-icon a {
|
||||
min-height: 44px !important;
|
||||
min-width: 44px !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
padding: 6px 12px !important;
|
||||
}
|
||||
|
||||
/* Keep the home glyph visible and tappable without it ballooning. */
|
||||
#header-quick-access .home-icon .fa-home {
|
||||
font-size: 18px !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
#6018 - "text and icons are enormous" below the menu bar.
|
||||
|
||||
After the upgrade, em/vw cascades in the content area scaled icons, menus and
|
||||
text far too large (zoom only affected task names). Re-anchor a sane base
|
||||
font-size on mobile and cap runaway icon sizing in the content region so the
|
||||
UI is usable again. Scoped to #content so the (separately tuned) header bar
|
||||
is not affected.
|
||||
---------------------------------------------------------------------------- */
|
||||
@media (max-width: 768px) {
|
||||
/* Anchor the root size so rem-based sizing can't drift huge. */
|
||||
html {
|
||||
font-size: 100% !important; /* 16px base */
|
||||
}
|
||||
|
||||
/* Body text in the working area: a readable, non-enormous default. */
|
||||
#content {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
/* Cap oversized Font Awesome icons in the content area. Many icons had
|
||||
inherited a multiplied em size; clamp them to a sane range while still
|
||||
allowing the explicit small/large fa-modifiers to scale relative to it. */
|
||||
#content .fa {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
#content .fa.fa-lg {
|
||||
font-size: 20px !important;
|
||||
}
|
||||
#content .fa.fa-2x {
|
||||
font-size: 24px !important;
|
||||
}
|
||||
|
||||
/* Sidebar and card-detail menus: keep labels at a readable size rather than
|
||||
the blown-up post-upgrade sizing. */
|
||||
#content .sidebar .fa,
|
||||
.card-details .fa {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
/* Menu / list item labels should match their controls, not be tiny next to
|
||||
oversized padding (#5982 symptom of the same cluster). */
|
||||
#content .pop-over-list li > a,
|
||||
#content .sidebar .board-sidebar-content a {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
#5982 - Excessive padding / margins and lots of empty space on mobile.
|
||||
|
||||
Trim the inflated spacing so controls sit closer together and button labels
|
||||
no longer look tiny inside huge padded boxes.
|
||||
---------------------------------------------------------------------------- */
|
||||
@media (max-width: 768px) {
|
||||
#content .pop-over-list li > a {
|
||||
padding-top: 4px !important;
|
||||
padding-bottom: 4px !important;
|
||||
line-height: 1.4 !important;
|
||||
}
|
||||
|
||||
/* Generic over-padded buttons inside the content area. */
|
||||
#content button,
|
||||
#content .btn,
|
||||
#content .js-submit {
|
||||
padding: 6px 12px !important;
|
||||
margin: 4px 0 !important;
|
||||
}
|
||||
|
||||
/* Reduce empty space between sidebar sections. */
|
||||
#content .sidebar .sidebar-list li,
|
||||
#content .board-sidebar-content > * {
|
||||
margin-top: 4px !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
#5942 - Keep card-detail sub-popups visible & usable on mobile.
|
||||
|
||||
The JS fix (popup.js) stops touch events inside the popup from bubbling to
|
||||
the document close handler. As belt-and-braces, make sure the assign-user /
|
||||
due-date popups are never clipped off-screen and can scroll their content.
|
||||
---------------------------------------------------------------------------- */
|
||||
@media (max-width: 768px) {
|
||||
.pop-over[data-popup="cardAssigneesPopup"],
|
||||
.pop-over[data-popup="cardMembersPopup"],
|
||||
.pop-over[data-popup="editCardDueDatePopup"],
|
||||
.pop-over[data-popup="editCardReceivedDatePopup"],
|
||||
.pop-over[data-popup="editCardStartDatePopup"],
|
||||
.pop-over[data-popup="editCardEndDatePopup"] {
|
||||
max-height: 100vh !important;
|
||||
overflow-y: auto !important;
|
||||
-webkit-overflow-scrolling: touch !important;
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,7 @@ Template.boardTriggers.events({
|
||||
desc,
|
||||
});
|
||||
},
|
||||
'click .js-add-arc-trigger'(event, tpl) {
|
||||
'click .js-add-arch-trigger'(event, tpl) {
|
||||
const datas = Template.currentData();
|
||||
const desc = Utils.getTriggerActionDesc(event, tpl);
|
||||
const actionSelected = tpl.find('#arch-action').value;
|
||||
|
||||
@@ -364,6 +364,10 @@ Template.boardsReport.helpers({
|
||||
},
|
||||
userNames(members) {
|
||||
const ret = (members || [])
|
||||
// #5122: removing a member from a board marks it `isActive: false` (the
|
||||
// entry stays in board.members for role history / re-activation). The
|
||||
// boards report must not list those removed members as current members.
|
||||
.filter(_member => _member.isActive !== false)
|
||||
.map(_member => {
|
||||
const _ret = ReactiveCache.getUser(_member.userId)?.username || _member.userId;
|
||||
return _ret;
|
||||
|
||||
@@ -881,7 +881,17 @@ Template.editUserPopup.onCreated(function () {
|
||||
|
||||
Template.editOrgPopup.helpers({
|
||||
org() {
|
||||
return ReactiveCache.getOrg(this.orgId);
|
||||
// #6411: the popup is opened from orgRow with data context `{ org }`, so
|
||||
// prefer that exact org. Falling back to `getOrg(this.orgId)` only for the
|
||||
// older explicit-id callers — previously this helper used `this.orgId`
|
||||
// alone, which is undefined here, so `getOrg(undefined)` returned the FIRST
|
||||
// org for every row (you could never edit the 2nd/3rd org).
|
||||
// #6411: the popup is opened from orgRow with data context `{ org }`. The
|
||||
// popup's own `orgId` is undefined, so the old `getOrg(this.orgId)` returned
|
||||
// `getOrg(undefined)` — the FIRST org — for every row, so you could never
|
||||
// edit the 2nd/3rd org. Resolve the clicked org's id and look it up.
|
||||
const orgId = (this.org && this.org._id) || this.orgId;
|
||||
return ReactiveCache.getOrg(orgId);
|
||||
},
|
||||
errorMessage() {
|
||||
return Template.instance().errorMessage.get();
|
||||
@@ -890,7 +900,10 @@ Template.editOrgPopup.helpers({
|
||||
|
||||
Template.editTeamPopup.helpers({
|
||||
team() {
|
||||
return ReactiveCache.getTeam(this.teamId);
|
||||
// #6411: same fix as editOrgPopup — resolve the clicked team's id from the
|
||||
// `{ team }` data context instead of `getTeam(undefined)` (the first team).
|
||||
const teamId = (this.team && this.team._id) || this.teamId;
|
||||
return ReactiveCache.getTeam(teamId);
|
||||
},
|
||||
errorMessage() {
|
||||
return Template.instance().errorMessage.get();
|
||||
@@ -1260,7 +1273,10 @@ Template.selectAllUser.events({
|
||||
Template.editOrgPopup.events({
|
||||
submit(event, templateInstance) {
|
||||
event.preventDefault();
|
||||
const org = ReactiveCache.getOrg(this.orgId);
|
||||
// #6411: prefer the `{ org }` data context (the row that was clicked);
|
||||
// `this.orgId` is undefined here, so the old getOrg(this.orgId) saved the
|
||||
// edits onto the FIRST org.
|
||||
const org = this.org || ReactiveCache.getOrg(this.orgId);
|
||||
|
||||
const orgDisplayName = templateInstance
|
||||
.find('.js-orgDisplayName')
|
||||
@@ -1305,7 +1321,8 @@ Template.editOrgPopup.events({
|
||||
Template.editTeamPopup.events({
|
||||
submit(event, templateInstance) {
|
||||
event.preventDefault();
|
||||
const team = ReactiveCache.getTeam(this.teamId);
|
||||
// #6411: prefer the `{ team }` data context (the row that was clicked).
|
||||
const team = this.team || ReactiveCache.getTeam(this.teamId);
|
||||
|
||||
const teamDisplayName = templateInstance
|
||||
.find('.js-teamDisplayName')
|
||||
@@ -1772,7 +1789,10 @@ Template.newUserPopup.events({
|
||||
Template.settingsOrgPopup.events({
|
||||
'click #deleteButton'(event) {
|
||||
event.preventDefault();
|
||||
if (ReactiveCache.getUsers({"orgs.orgId": this.orgId}).length > 0)
|
||||
// #6411: the popup carries `{ org }`; `this.orgId` is undefined, so the old
|
||||
// code checked users for an undefined org and called Org.remove(undefined).
|
||||
const orgId = (this.org && this.org._id) || this.orgId;
|
||||
if (ReactiveCache.getUsers({"orgs.orgId": orgId}).length > 0)
|
||||
{
|
||||
let orgClassList = document.getElementById("deleteOrgWarningMessage").classList;
|
||||
if(orgClassList.contains('hide'))
|
||||
@@ -1782,7 +1802,7 @@ Template.settingsOrgPopup.events({
|
||||
}
|
||||
return;
|
||||
}
|
||||
Org.remove(this.orgId);
|
||||
Org.remove(orgId);
|
||||
Popup.back();
|
||||
}
|
||||
});
|
||||
@@ -1790,7 +1810,9 @@ Template.settingsOrgPopup.events({
|
||||
Template.settingsTeamPopup.events({
|
||||
'click #deleteButton'(event) {
|
||||
event.preventDefault();
|
||||
if (ReactiveCache.getUsers({"teams.teamId": this.teamId}).length > 0)
|
||||
// #6411: same as settingsOrgPopup — derive the id from the `{ team }` context.
|
||||
const teamId = (this.team && this.team._id) || this.teamId;
|
||||
if (ReactiveCache.getUsers({"teams.teamId": teamId}).length > 0)
|
||||
{
|
||||
let teamClassList = document.getElementById("deleteTeamWarningMessage").classList;
|
||||
if(teamClassList.contains('hide'))
|
||||
@@ -1800,7 +1822,7 @@ Template.settingsTeamPopup.events({
|
||||
}
|
||||
return;
|
||||
}
|
||||
Team.remove(this.teamId);
|
||||
Team.remove(teamId);
|
||||
Popup.back();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -278,3 +278,50 @@ li.has-error .form-group .wekan-form-control {
|
||||
width: 120px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* #6419: on phones the admin settings forced a side-by-side menu+content layout
|
||||
(flex-direction: row "even on narrow windows"), so the ~127px side menu and the
|
||||
content were crammed together and the long (e.g. Finnish) menu labels overflowed
|
||||
the menu box rightward, visually overlapping the content. Stack them on small
|
||||
screens: a full-width compact menu on top, full-width content below. */
|
||||
@media screen and (max-width: 800px) {
|
||||
.setting-content {
|
||||
position: relative;
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
}
|
||||
.setting-content .content-body {
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
height: auto;
|
||||
padding-top: 1vh;
|
||||
}
|
||||
.setting-content .content-body .side-menu {
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 6px;
|
||||
}
|
||||
/* Menu items as a wrapping horizontal row so the menu stays compact on top. */
|
||||
.setting-content .content-body .side-menu ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
}
|
||||
.setting-content .content-body .side-menu ul li {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
}
|
||||
.setting-content .content-body .side-menu ul li a {
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.setting-content .content-body .main-body {
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-height: 60vh;
|
||||
overflow-x: auto !important;
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,11 @@ template(name="general")
|
||||
.materialCheckBox(class="{{#if currentSetting.disableRegistration}}is-checked{{/if}}")
|
||||
|
||||
span {{_ 'disable-self-registration'}}
|
||||
li
|
||||
a.flex.js-toggle-board-members-same-org-team
|
||||
.materialCheckBox(class="{{#if currentSetting.boardMembersFromSameOrgOrTeamOnly}}is-checked{{/if}}")
|
||||
|
||||
span {{_ 'board-members-same-org-team-only'}}
|
||||
li
|
||||
.invite-people(class="{{#if currentSetting.disableRegistration}}{{else}}hide{{/if}}")
|
||||
ul
|
||||
|
||||
@@ -616,6 +616,16 @@ Template.setting.events({
|
||||
$('.invite-people').slideDown();
|
||||
}
|
||||
},
|
||||
'click a.js-toggle-board-members-same-org-team'(event, tpl) {
|
||||
// #6116: toggle the global "add board members from same Org/Team only" setting.
|
||||
tpl.loading.set(true);
|
||||
const current =
|
||||
ReactiveCache.getCurrentSetting().boardMembersFromSameOrgOrTeamOnly;
|
||||
Settings.update(ReactiveCache.getCurrentSetting()._id, {
|
||||
$set: { boardMembersFromSameOrgOrTeamOnly: !current },
|
||||
});
|
||||
tpl.loading.set(false);
|
||||
},
|
||||
'click a.js-toggle-tls'() {
|
||||
$('#mail-server-tls').toggleClass('is-checked');
|
||||
},
|
||||
|
||||
@@ -49,3 +49,30 @@
|
||||
float: none;
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
/* #6419: the admin panel's 2nd top bar (settingHeaderBar) is rendered inside
|
||||
#header-quick-access, whose mobile rules scale ALL text/icons 2x
|
||||
(`#header-quick-access * { font-size: inherit }` + `.fa { font-size: 2em }`).
|
||||
That made the admin tab buttons 28px text / 56px icons — huge, overlapping the
|
||||
"Version" label and the content below. Cap them at a normal touch size on small
|
||||
screens. Selectors are more specific than the 2em rules (and repeated for the
|
||||
.iphone-device JS-detection fallback) so they win. */
|
||||
@media screen and (max-width: 800px) {
|
||||
#header-quick-access .setting-header-btns .setting-header-btn,
|
||||
#header #header-main-bar .setting-header-btns .setting-header-btn,
|
||||
#header-quick-access .setting-header-btns .setting-header-btn span,
|
||||
#header #header-main-bar .setting-header-btns .setting-header-btn span {
|
||||
font-size: 15px !important;
|
||||
}
|
||||
#header-quick-access .setting-header-btns .setting-header-btn i.fa,
|
||||
#header #header-main-bar .setting-header-btns .setting-header-btn i.fa {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
}
|
||||
.iphone-device #header-quick-access .setting-header-btns .setting-header-btn,
|
||||
.iphone-device #header-quick-access .setting-header-btns .setting-header-btn span {
|
||||
font-size: 15px !important;
|
||||
}
|
||||
.iphone-device #header-quick-access .setting-header-btns .setting-header-btn i.fa {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
@@ -261,9 +261,40 @@ body.grey-icons-enabled .sidebar .sidebar-content ul.sidebar-list li > a .fa.fa-
|
||||
padding: 0 10px 0 10px;
|
||||
}
|
||||
@media screen and (max-width: 800px) {
|
||||
.board-sidebar {
|
||||
width: 100%;
|
||||
/* On phones (iPhone 12 mini, etc.) the right sidebar should be a full-width
|
||||
panel at the top of the board, in BOTH mobile mode and desktop mode. The
|
||||
sidebar lives inside .board-wrapper, which is wider than the viewport on a
|
||||
phone, so an absolutely-positioned sidebar (inset-inline-end: 0) pins to the
|
||||
far right of the wide board and shows as a narrow strip. Pin it to the
|
||||
VIEWPORT instead (position: fixed + 100vw) so it spans the full screen
|
||||
width. At 100% zoom .board-wrapper has no transform, so fixed resolves to
|
||||
the viewport in desktop mode too. */
|
||||
.board-sidebar.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
z-index: 1000;
|
||||
}
|
||||
/* Normalise sidebar text on phones so the headings are not oversized and the
|
||||
small text is not tiny — keep them close to the board's text size so board
|
||||
and sidebar read at one consistent size. */
|
||||
.board-sidebar.sidebar .sidebar-content {
|
||||
font-size: 14px;
|
||||
}
|
||||
.board-sidebar.sidebar .sidebar-content h2,
|
||||
.board-sidebar.sidebar .sidebar-content h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
.board-sidebar.sidebar .sidebar-content ul.sidebar-list li > a,
|
||||
.board-sidebar.sidebar .sidebar-content .card-label,
|
||||
.board-sidebar.sidebar .sidebar-content .card-label .card-label-name,
|
||||
.board-sidebar.sidebar .sidebar-content p,
|
||||
.board-sidebar.sidebar .sidebar-content span {
|
||||
font-size: 14px;
|
||||
}
|
||||
.board-sidebar .sidebar-content .hide-btn {
|
||||
width: 40px;
|
||||
|
||||
@@ -228,6 +228,12 @@ template(name="boardInfoOnMyBoardsPopup")
|
||||
span
|
||||
i.fa.fa-hourglass
|
||||
| {{_ 'show-board_members-avatar'}}
|
||||
div.check-div
|
||||
a.flex.js-field-has-personal-list-width(title="{{_ 'personal-list-width-description'}}" class="{{#if allowsPersonalListWidth}}is-checked{{/if}}")
|
||||
i.fa(class="{{#if allowsPersonalListWidth}}fa-check{{else}}fa-square-o{{/if}}")
|
||||
span
|
||||
i.fa.fa-arrows-h
|
||||
| {{_ 'personal-list-width'}}
|
||||
|
||||
template(name="boardCardSettingsPopup")
|
||||
form.board-card-settings
|
||||
|
||||
@@ -294,7 +294,7 @@ Template.memberPopup.helpers({
|
||||
return ReactiveCache.getUser(this.userId);
|
||||
},
|
||||
isBoardAdmin() {
|
||||
return ReactiveCache.getCurrentUser().isBoardAdmin();
|
||||
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
|
||||
},
|
||||
memberType() {
|
||||
const type = ReactiveCache.getUser(this.userId).isBoardAdmin() ? 'admin' : 'normal';
|
||||
@@ -433,7 +433,7 @@ Template.boardMenuPopup.onCreated(function() {
|
||||
|
||||
Template.boardMenuPopup.helpers({
|
||||
isBoardAdmin() {
|
||||
return ReactiveCache.getCurrentUser().isBoardAdmin();
|
||||
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
|
||||
},
|
||||
withApi() {
|
||||
return Template.instance().apiEnabled.get();
|
||||
@@ -547,7 +547,7 @@ Template.membersWidget.helpers({
|
||||
}
|
||||
},
|
||||
isBoardAdmin() {
|
||||
return ReactiveCache.getCurrentUser().isBoardAdmin();
|
||||
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
|
||||
},
|
||||
AtLeastOneOrgWasCreated(){
|
||||
let orgs = ReactiveCache.getOrgs({}, {sort: { createdAt: -1 }});
|
||||
@@ -919,7 +919,7 @@ Template.labelsWidget.events({
|
||||
|
||||
Template.labelsWidget.helpers({
|
||||
isBoardAdmin() {
|
||||
return ReactiveCache.getCurrentUser().isBoardAdmin();
|
||||
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1125,9 +1125,31 @@ Template.boardInfoOnMyBoardsPopup.helpers({
|
||||
const tpl = Template.instance();
|
||||
return tpl.currentBoard.allowsBoardMemberList;
|
||||
},
|
||||
allowsPersonalListWidth() {
|
||||
const tpl = Template.instance();
|
||||
return tpl.currentBoard.allowsPersonalListWidth;
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardInfoOnMyBoardsPopup.events({
|
||||
'click .js-field-has-personal-list-width'(evt, tpl) {
|
||||
// #6409: toggle whether list widths are shared (board default, everyone
|
||||
// sees the same) or personal (per user).
|
||||
evt.preventDefault();
|
||||
tpl.currentBoard.allowsPersonalListWidth = !tpl.currentBoard
|
||||
.allowsPersonalListWidth;
|
||||
tpl.currentBoard.setAllowsPersonalListWidth(
|
||||
tpl.currentBoard.allowsPersonalListWidth,
|
||||
);
|
||||
$(`.js-field-has-personal-list-width ${MCB}`).toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsPersonalListWidth,
|
||||
);
|
||||
$('.js-field-has-personal-list-width').toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsPersonalListWidth,
|
||||
);
|
||||
},
|
||||
'click .js-field-has-cardcounterlist'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.currentBoard.allowsCardCounterList = !tpl.currentBoard
|
||||
@@ -1222,9 +1244,14 @@ Template.boardSubtaskSettingsPopup.helpers({
|
||||
},
|
||||
lists() {
|
||||
const tpl = Template.instance();
|
||||
// The landing list belongs to the configured deposit board
|
||||
// (subtasksDefaultBoardId), which may differ from the current board
|
||||
// (#3414): when a different deposit board is chosen, show its lists.
|
||||
const depositBoardId =
|
||||
tpl.currentBoard.subtasksDefaultBoardId || tpl.currentBoard._id;
|
||||
return ReactiveCache.getLists(
|
||||
{
|
||||
boardId: tpl.currentBoard._id,
|
||||
boardId: depositBoardId,
|
||||
archived: false,
|
||||
},
|
||||
{
|
||||
@@ -1234,9 +1261,11 @@ Template.boardSubtaskSettingsPopup.helpers({
|
||||
},
|
||||
hasLists() {
|
||||
const tpl = Template.instance();
|
||||
const depositBoardId =
|
||||
tpl.currentBoard.subtasksDefaultBoardId || tpl.currentBoard._id;
|
||||
const lists = ReactiveCache.getLists(
|
||||
{
|
||||
boardId: tpl.currentBoard._id,
|
||||
boardId: depositBoardId,
|
||||
archived: false,
|
||||
},
|
||||
{
|
||||
@@ -1246,8 +1275,10 @@ Template.boardSubtaskSettingsPopup.helpers({
|
||||
return lists.length > 0;
|
||||
},
|
||||
isListSelected() {
|
||||
// #3876 / #4947: the selected landing list must be compared against
|
||||
// subtasksDefaultListId (the stored list id), NOT subtasksDefaultBoardId.
|
||||
const tpl = Template.instance();
|
||||
return tpl.currentBoard.subtasksDefaultBoardId === Template.currentData()._id;
|
||||
return tpl.currentBoard.subtasksDefaultListId === Template.currentData()._id;
|
||||
},
|
||||
presentParentTask() {
|
||||
// Get the current board reactively using board ID from Session
|
||||
@@ -1277,11 +1308,22 @@ Template.boardSubtaskSettingsPopup.events({
|
||||
if (value === 'null') {
|
||||
value = null;
|
||||
}
|
||||
// #4849: changing the deposit board makes any previously-stored landing
|
||||
// list (which belongs to the old board) stale and would otherwise
|
||||
// override the new board's setting. Reset it so the list dropdown for the
|
||||
// newly-selected board starts fresh.
|
||||
if (value !== tpl.currentBoard.subtasksDefaultBoardId) {
|
||||
tpl.currentBoard.setSubtasksDefaultListId(null);
|
||||
}
|
||||
tpl.currentBoard.setSubtasksDefaultBoardId(value);
|
||||
evt.preventDefault();
|
||||
},
|
||||
'change .js-field-deposit-list'(evt, tpl) {
|
||||
tpl.currentBoard.setSubtasksDefaultListId(evt.target.value);
|
||||
let value = evt.target.value;
|
||||
if (value === 'null' || value === '') {
|
||||
value = null;
|
||||
}
|
||||
tpl.currentBoard.setSubtasksDefaultListId(value);
|
||||
evt.preventDefault();
|
||||
},
|
||||
'click .js-field-show-parent-in-minicard'(evt, tpl) {
|
||||
|
||||
@@ -215,7 +215,7 @@ function mapSelection(kind, _id) {
|
||||
|
||||
Template.multiselectionSidebar.helpers({
|
||||
isBoardAdmin() {
|
||||
return ReactiveCache.getCurrentUser().isBoardAdmin();
|
||||
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
|
||||
},
|
||||
isCommentOnly() {
|
||||
return ReactiveCache.getCurrentUser().isCommentOnly();
|
||||
|
||||
@@ -41,7 +41,7 @@ Template.swimlaneHeader.events({
|
||||
|
||||
Template.swimlaneFixedHeader.helpers({
|
||||
isBoardAdmin() {
|
||||
return ReactiveCache.getCurrentUser().isBoardAdmin();
|
||||
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
|
||||
},
|
||||
collapseSwimlane() {
|
||||
const swimlane = Template.currentData();
|
||||
|
||||
@@ -83,8 +83,15 @@
|
||||
}
|
||||
.swimlane .swimlane-header-wrap .swimlane-header-menu {
|
||||
position: absolute;
|
||||
/* Stretch the menu to the full height of the swimlane header and lay the
|
||||
buttons (collapse, swimlane menu, + new swimlane, drag handle) in a row that
|
||||
is vertically centered — instead of anchoring them to the top with per-icon
|
||||
top: calc(50% + Npx) hacks that centered against the menu's own height. */
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
inset-inline-start: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
@@ -93,7 +100,6 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
.swimlane .swimlane-header-wrap .swimlane-header-menu .js-open-swimlane-menu {
|
||||
top: calc(50% + 6px);
|
||||
padding: 5px;
|
||||
display: inline-block;
|
||||
margin-inline-start: 30px;
|
||||
@@ -107,20 +113,16 @@
|
||||
}
|
||||
}
|
||||
.swimlane .swimlane-header-wrap .swimlane-header-plus-icon {
|
||||
top: calc(50% + 6px);
|
||||
padding: 5px;
|
||||
margin-inline-start: 20px;
|
||||
font-size: 22px;
|
||||
color: #a6a6a6;
|
||||
}
|
||||
.swimlane .swimlane-header-wrap .swimlane-header-menu-icon {
|
||||
top: calc(50% + 6px);
|
||||
padding-inline-start: 5px;
|
||||
font-size: 22px;
|
||||
}
|
||||
.swimlane .swimlane-header-wrap .swimlane-header-handle {
|
||||
position: relative;
|
||||
top: calc(50% + 2px);
|
||||
padding: 2px 5px;
|
||||
font-size: clamp(16px, 3vw, 20px);
|
||||
display: inline-block;
|
||||
@@ -132,9 +134,7 @@
|
||||
line-height: 1.2;
|
||||
}
|
||||
.swimlane .swimlane-header-wrap .swimlane-header-miniscreen-handle {
|
||||
position: relative;
|
||||
padding: 2px 5px;
|
||||
top: calc(50% + 2px);
|
||||
font-size: 24px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
|
||||
@@ -30,9 +30,19 @@ template(name="listsGroup")
|
||||
if currentList
|
||||
+list(currentList)
|
||||
else
|
||||
//- Issue #6142: allow adding a list in Lists (board-view-lists) mode too.
|
||||
if defaultSwimlane
|
||||
if currentUser.isBoardMember
|
||||
unless currentUser.isCommentOnly
|
||||
+addListForm(defaultSwimlane)
|
||||
each lists
|
||||
+miniList(this)
|
||||
else
|
||||
//- Issue #6142: allow adding a list in Lists (board-view-lists) mode too.
|
||||
if defaultSwimlane
|
||||
if currentUser.isBoardMember
|
||||
unless currentUser.isCommentOnly
|
||||
+addListForm(defaultSwimlane)
|
||||
each lists
|
||||
if visible this
|
||||
+list(this)
|
||||
|
||||
@@ -6,13 +6,28 @@ import { CSSEvents } from '/client/lib/cssEvents';
|
||||
import { Filter } from '/client/lib/filter';
|
||||
import { EscapeActions } from '/client/lib/escapeActions';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { defaultSwimlaneIdForBoard } from '/client/components/lists/listAddHelpers';
|
||||
const { calculateIndex } = Utils;
|
||||
|
||||
function saveSorting(ui) {
|
||||
// #5462 (defense in depth): a logged-in user without write access must never
|
||||
// persist a list reorder, even if a sortable was left enabled. Anonymous
|
||||
// users on public boards fall through to the localStorage path below.
|
||||
if (Meteor.userId() && !Utils.canModifyBoard()) {
|
||||
return;
|
||||
}
|
||||
// To attribute the new index number, we need to get the DOM element
|
||||
// of the previous and the following list -- if any.
|
||||
const prevListDom = ui.item.prev('.js-list').get(0);
|
||||
const nextListDom = ui.item.next('.js-list').get(0);
|
||||
// Use prevAll/nextAll (not prev/next) so non-list siblings between lists do
|
||||
// not break neighbour detection: the lists container also renders the
|
||||
// +addListForm composer and, when a card is open, a +cardDetails element.
|
||||
// jQuery's .prev('.js-list') only matches an *immediately* adjacent sibling,
|
||||
// so an interspersed composer/cardDetails made calculateIndex mis-detect the
|
||||
// first/last position and compute a wrong sort, so the reordered list landed
|
||||
// elsewhere and looked like it did not persist. See
|
||||
// https://github.com/wekan/wekan/issues/5997
|
||||
const prevListDom = ui.item.prevAll('.js-list:not(.js-list-composer)').get(0);
|
||||
const nextListDom = ui.item.nextAll('.js-list:not(.js-list-composer)').get(0);
|
||||
const sortIndex = calculateIndex(prevListDom, nextListDom, 1);
|
||||
|
||||
const listDomElement = ui.item.get(0);
|
||||
@@ -321,6 +336,11 @@ function initSortable(boardComponent, $listsDom) {
|
||||
distance: 3,
|
||||
forcePlaceholderSize: true,
|
||||
cursor: 'move',
|
||||
// #5462: only users with write access may reorder lists. Without this,
|
||||
// read-only / comment-only members could drag lists, firing a server
|
||||
// write that is denied with `403 Access denied` (and the list snaps
|
||||
// back). The two other list sortables already gate on canModifyBoard().
|
||||
disabled: !Utils.canModifyBoard(),
|
||||
start(evt, ui) {
|
||||
ui.helper.css('z-index', 1000);
|
||||
ui.placeholder.height(ui.helper.height());
|
||||
@@ -623,7 +643,7 @@ function initializeSwimlaneResize(tpl, retryCount = 0) {
|
||||
|
||||
Template.swimlane.helpers({
|
||||
canSeeAddList() {
|
||||
return ReactiveCache.getCurrentUser().isBoardAdmin();
|
||||
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
|
||||
},
|
||||
lists() {
|
||||
const swimlane = this;
|
||||
@@ -829,6 +849,16 @@ Template.addListForm.events({
|
||||
});
|
||||
|
||||
Template.listsGroup.helpers({
|
||||
// Issue #6142: the add-list composer needs a swimlane as its data context
|
||||
// (it creates the list in that swimlane). In Lists mode the listsGroup data
|
||||
// context is the board, not a swimlane, so resolve the board's default
|
||||
// swimlane here and hand it to +addListForm.
|
||||
defaultSwimlane() {
|
||||
const board = Template.currentData();
|
||||
const swimlaneId = defaultSwimlaneIdForBoard(board);
|
||||
if (!swimlaneId) return null;
|
||||
return ReactiveCache.getSwimlane({ _id: swimlaneId });
|
||||
},
|
||||
currentCardIsInThisList(listId, swimlaneId) {
|
||||
return currentCardIsInThisList(listId, swimlaneId);
|
||||
},
|
||||
|
||||
@@ -139,16 +139,26 @@ template(name="adminChangeAvatarPopup")
|
||||
template(name="cardMemberPopup")
|
||||
.board-member-menu
|
||||
.mini-profile-info
|
||||
+userAvatar(userId=user._id showEdit=true)
|
||||
.info
|
||||
h3= user.profile.fullname
|
||||
p.quiet @{{ user.username }}
|
||||
if user
|
||||
+userAvatar(userId=user._id showEdit=true)
|
||||
.info
|
||||
h3= user.profile.fullname
|
||||
p.quiet @{{ user.username }}
|
||||
else
|
||||
//- #4847: the member's user account was deleted. Still show the entry
|
||||
//- (with its id) and the remove control so the orphaned member can be
|
||||
//- removed from the card — the remove handler keys off userId, not the
|
||||
//- user document.
|
||||
.info
|
||||
h3 {{_ 'deleted-user'}}
|
||||
p.quiet {{ userId }}
|
||||
ul.pop-over-list
|
||||
unless noRemove
|
||||
if currentUser.isNotCommentOnly
|
||||
if currentUser.isNotWorker
|
||||
li: a.js-remove-member {{_ 'remove-member-from-card'}}
|
||||
|
||||
if $eq currentUser._id user._id
|
||||
with currentUser
|
||||
li: a.js-edit-profile {{_ 'edit-profile'}}
|
||||
if user
|
||||
if $eq currentUser._id user._id
|
||||
with currentUser
|
||||
li: a.js-edit-profile {{_ 'edit-profile'}}
|
||||
@@ -309,7 +309,12 @@ Template.changeLanguagePopup.events({
|
||||
'profile.language': this.tag,
|
||||
},
|
||||
});
|
||||
TAPi18n.setLanguage(this.tag);
|
||||
// setLanguage is async; surface a failed load instead of silently leaving
|
||||
// the UI in English (#5756).
|
||||
Promise.resolve(TAPi18n.setLanguage(this.tag)).catch(error => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Failed to switch language to ${this.tag}:`, error);
|
||||
});
|
||||
event.preventDefault();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Pure, Meteor-free permission helper for archiving a card.
|
||||
//
|
||||
// A board member with the comment-only role must not be able to archive a
|
||||
// card (issue #5810). The client archive handler already knows whether the
|
||||
// current user can modify the card via Utils.canModifyCard() (which is false
|
||||
// for comment-only / read-only / worker users), so archiving is allowed only
|
||||
// when the card can be modified.
|
||||
//
|
||||
// Kept free of Meteor imports so it can be unit tested in isolation.
|
||||
export function canArchiveCard({ canModifyCard } = {}) {
|
||||
return !!canModifyCard;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Browser shim for `process.getBuiltinModule`, imported FIRST in the client
|
||||
// entry so it runs before any module that pulls in bson.
|
||||
//
|
||||
// Regression (after the mongodb/bson 7.3.0 bump): bson 7.x evaluates, at module
|
||||
// load time,
|
||||
//
|
||||
// const { startupSnapshot } = globalThis?.process?.getBuiltinModule('v8') ?? {};
|
||||
//
|
||||
// The optional chaining stops BEFORE the call, so when a partial `process`
|
||||
// polyfill is present (Meteor/rspack provide one in the browser) but has no
|
||||
// `getBuiltinModule`, the call throws `TypeError: getBuiltinModule is not a
|
||||
// function`. That throw happens while `client/components/cards/attachments.js`
|
||||
// (`import { ObjectId } from 'bson'`) is being evaluated, which aborts the
|
||||
// client bundle bootstrap partway through `client/imports.js` — every feature
|
||||
// imported after it (notifications, rules, swimlanes, …) is never registered,
|
||||
// so the board view throws `No such template: notifications` and renders
|
||||
// nothing.
|
||||
//
|
||||
// Installing a no-op `getBuiltinModule` makes bson take its intended browser
|
||||
// fallback (`?? {}`): there is no V8 startup snapshot in a browser, so
|
||||
// `undefined` is the correct value.
|
||||
|
||||
/**
|
||||
* Ensure `glob.process.getBuiltinModule` is callable. Only adds the method when
|
||||
* a `process` object exists but lacks it (the exact browser-polyfill case);
|
||||
* never overwrites a real Node implementation. Returns true when it installed
|
||||
* the shim.
|
||||
*
|
||||
* @param {*} glob a global-like object (e.g. globalThis)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function installGetBuiltinModuleShim(glob) {
|
||||
if (
|
||||
glob &&
|
||||
glob.process &&
|
||||
typeof glob.process.getBuiltinModule !== 'function'
|
||||
) {
|
||||
try {
|
||||
glob.process.getBuiltinModule = () => undefined;
|
||||
return true;
|
||||
} catch (e) {
|
||||
// `process` may be frozen in some environments — nothing more we can do.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
installGetBuiltinModuleShim(typeof globalThis !== 'undefined' ? globalThis : undefined);
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Pure helper that converts WeKan's stored "start day of week" user setting
|
||||
* into a FullCalendar `firstDay` value.
|
||||
*
|
||||
* Background (see issue #5521 "Calendar View does not respect the setting of
|
||||
* day of week start"):
|
||||
*
|
||||
* WeKan stores the start-of-week preference in `profile.startDayOfWeek`, read
|
||||
* via `User.getStartDayOfWeek()`. That value is already a zero-based day index
|
||||
* where 0 = Sunday, 1 = Monday, ... 6 = Saturday. This matches the convention
|
||||
* used by the user-settings dropdown (`weekDays` helper in userHeader.js, whose
|
||||
* options are Sunday..Saturday mapped to indexes 0..6) and the documented
|
||||
* default of 1 (Monday).
|
||||
*
|
||||
* FullCalendar's `firstDay` option uses the exact same convention: an integer
|
||||
* 0..6 where 0 = Sunday and 6 = Saturday. So the conversion is effectively the
|
||||
* identity, but we still validate/normalise the input here so that a bad or
|
||||
* missing setting (undefined, null, a string, an out-of-range number, or a day
|
||||
* name) can never break the calendar render. Anything we can't make sense of
|
||||
* falls back to WeKan's default of Monday (1).
|
||||
*
|
||||
* @param {number|string|undefined|null} startDayOfWeekSetting - The stored
|
||||
* setting as returned by `User.getStartDayOfWeek()` (normally a number 0..6),
|
||||
* or possibly a day name string.
|
||||
* @returns {number} A FullCalendar `firstDay` integer in the range 0..6
|
||||
* (0 = Sunday .. 6 = Saturday).
|
||||
*/
|
||||
export function toFullCalendarFirstDay(startDayOfWeekSetting) {
|
||||
const DEFAULT_FIRST_DAY = 1; // Monday, matching User.getStartDayOfWeek()
|
||||
|
||||
// Already a usable number (including numeric strings like "0").
|
||||
const asNumber = Number(startDayOfWeekSetting);
|
||||
if (
|
||||
startDayOfWeekSetting !== null &&
|
||||
startDayOfWeekSetting !== undefined &&
|
||||
startDayOfWeekSetting !== '' &&
|
||||
Number.isFinite(asNumber) &&
|
||||
Number.isInteger(asNumber) &&
|
||||
asNumber >= 0 &&
|
||||
asNumber <= 6
|
||||
) {
|
||||
return asNumber;
|
||||
}
|
||||
|
||||
// Tolerate a day name string ("sunday", "Monday", ...), just in case.
|
||||
if (typeof startDayOfWeekSetting === 'string') {
|
||||
const dayNames = [
|
||||
'sunday',
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
];
|
||||
const idx = dayNames.indexOf(startDayOfWeekSetting.trim().toLowerCase());
|
||||
if (idx !== -1) {
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_FIRST_DAY;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Pure helper for the card-details "click outside to close" behaviour.
|
||||
//
|
||||
// Regression #5686 ("Chrome: Selecting text in checklist closes card"):
|
||||
// selecting the text of a checklist item and releasing the mouse OUTSIDE the
|
||||
// card detail pane closed the card. The card normally protects against this via
|
||||
// a `cardDetailsIsDragging` session flag set from a `mousedown`/`mousemove` on
|
||||
// `.js-card-details`, but the checklist items template calls
|
||||
// `evt.stopPropagation()` on `mousedown`, so that flag never gets set for a
|
||||
// drag that starts on a checklist item — and the close-on-outside-click handler
|
||||
// then closed the card.
|
||||
//
|
||||
// This helper centralises the robust, propagation-independent check: if there
|
||||
// is a live (non-collapsed) text selection ANCHORED inside the card detail
|
||||
// pane, an outside mouseup/click is the tail end of a text selection drag and
|
||||
// must NOT close the card. A deliberate click on empty space outside the card
|
||||
// collapses the selection to that outside point first, so its anchor is no
|
||||
// longer inside the pane and the card closes as expected.
|
||||
//
|
||||
// Kept DOM-light so it can be unit-tested with plain objects: `selection` only
|
||||
// needs `isCollapsed`, `toString()`, `anchorNode` and `focusNode`, and
|
||||
// `cardDetailsEl` only needs `contains(node)`.
|
||||
|
||||
/**
|
||||
* @param {{ isCollapsed?: boolean, toString?: function, anchorNode?: *, focusNode?: * }} selection
|
||||
* typically `window.getSelection()`
|
||||
* @param {{ contains?: function }} cardDetailsEl the `.js-card-details` element
|
||||
* @returns {boolean} true when the card must stay open (selection drag in progress)
|
||||
*/
|
||||
export function isTextSelectionInsideCard(selection, cardDetailsEl) {
|
||||
if (!selection || !cardDetailsEl) {
|
||||
return false;
|
||||
}
|
||||
// A collapsed (caret-only) selection is not a text selection.
|
||||
if (selection.isCollapsed) {
|
||||
return false;
|
||||
}
|
||||
const text =
|
||||
typeof selection.toString === 'function' ? selection.toString() : '';
|
||||
if (!text || text.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
const contains = node => {
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
if (node === cardDetailsEl) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
typeof cardDetailsEl.contains === 'function' &&
|
||||
cardDetailsEl.contains(node)
|
||||
);
|
||||
};
|
||||
// Anchored inside the pane => the drag started on the card content.
|
||||
return contains(selection.anchorNode) || contains(selection.focusNode);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Template } from 'meteor/templating';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { getCurrentCardFromContext } from '/client/lib/currentCard';
|
||||
import { normalizeDigits } from '/imports/lib/dateUtils';
|
||||
|
||||
// Helper to check if a date is valid
|
||||
function isValidDate(date) {
|
||||
@@ -114,8 +115,9 @@ export function datePickerHelpers() {
|
||||
export function datePickerEvents({ storeDate, deleteDate }) {
|
||||
return {
|
||||
'change .js-date-field'(evt, tpl) {
|
||||
// Native HTML date input validation
|
||||
const dateValue = tpl.find('#date').value;
|
||||
// Native HTML date input validation. Normalize any non-Latin digits
|
||||
// (e.g. Persian/Arabic-Indic) so parsing works in those locales (#5752).
|
||||
const dateValue = normalizeDigits(tpl.find('#date').value);
|
||||
if (dateValue) {
|
||||
// HTML date input format is always YYYY-MM-DD
|
||||
const dateObj = new Date(dateValue + 'T12:00:00');
|
||||
@@ -127,8 +129,9 @@ export function datePickerEvents({ storeDate, deleteDate }) {
|
||||
}
|
||||
},
|
||||
'change .js-time-field'(evt, tpl) {
|
||||
// Native HTML time input validation
|
||||
const timeValue = tpl.find('#time').value;
|
||||
// Native HTML time input validation. Normalize any non-Latin digits
|
||||
// (e.g. Persian/Arabic-Indic) so parsing works in those locales (#5752).
|
||||
const timeValue = normalizeDigits(tpl.find('#time').value);
|
||||
if (timeValue) {
|
||||
// HTML time input format is always HH:mm
|
||||
const timeObj = new Date(`1970-01-01T${timeValue}:00`);
|
||||
@@ -142,8 +145,10 @@ export function datePickerEvents({ storeDate, deleteDate }) {
|
||||
'submit .edit-date'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
|
||||
const dateValue = evt.target.date.value;
|
||||
const timeValue = evt.target.time.value || '12:00'; // Default to 12:00 if no time given
|
||||
// Normalize any non-Latin digits (e.g. Persian/Arabic-Indic) before
|
||||
// parsing so due/start/end dates work in those locales (#5752).
|
||||
const dateValue = normalizeDigits(evt.target.date.value);
|
||||
const timeValue = normalizeDigits(evt.target.time.value) || '12:00'; // Default to 12:00 if no time given
|
||||
|
||||
if (!dateValue) {
|
||||
tpl.datePicker.error.set('invalid-date');
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Pure helper that decides the CSS status class for a card "due date" badge.
|
||||
*
|
||||
* This logic is shared between the card-detail badge (`cardDueDate`) and the
|
||||
* minicard badge (`minicardDueDate`) so that both views always agree on the
|
||||
* colour of a due date (see issues #6000 and #5965).
|
||||
*
|
||||
* Rules (no Blaze / no DOM dependencies, easy to unit-test):
|
||||
* - If the card has an end date:
|
||||
* - end before due => 'completed-early'
|
||||
* - otherwise => 'completed'
|
||||
* - Otherwise, compare the due date against "now":
|
||||
* - due in the past => 'overdue' (red)
|
||||
* - due within 48 hours => 'due-soon' (amber)
|
||||
* - due more than 48h in future => 'not-due' (grey)
|
||||
*
|
||||
* @param {Date|string|number} dueDate - The card due date.
|
||||
* @param {Date|string|number} now - The current time.
|
||||
* @param {Date|string|number} [endDate] - The card end date, if set.
|
||||
* @returns {string} One of 'overdue' | 'due-soon' | 'not-due' |
|
||||
* 'completed-early' | 'completed'.
|
||||
*/
|
||||
export function dueDateClass(dueDate, now, endDate) {
|
||||
const due = new Date(dueDate);
|
||||
const nowVal = new Date(now);
|
||||
|
||||
if (endDate) {
|
||||
const end = new Date(endDate);
|
||||
if (end.getTime() < due.getTime()) {
|
||||
return 'completed-early';
|
||||
}
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
const diffMs = due.getTime() - nowVal.getTime();
|
||||
const hoursDiff = diffMs / (1000 * 60 * 60);
|
||||
|
||||
if (hoursDiff < 0) {
|
||||
return 'overdue';
|
||||
} else if (hoursDiff <= 48) {
|
||||
return 'due-soon';
|
||||
}
|
||||
return 'not-due';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Pure helper shared by the comment / description editor key handling.
|
||||
//
|
||||
// Regression #3289 / #4172 / #5457: when the @mention (or other textcomplete)
|
||||
// autocomplete dropdown is open, pressing Enter should SELECT the highlighted
|
||||
// suggestion, not submit the comment / close the card. This helper centralises
|
||||
// that single decision so it can be unit-tested without a DOM.
|
||||
//
|
||||
// Returns true when an Enter keypress should be allowed to submit the form
|
||||
// (i.e. the autocomplete dropdown is NOT open), and false when Enter must be
|
||||
// captured by the autocomplete to pick the highlighted suggestion.
|
||||
export function shouldSubmitOnEnter({ autocompleteOpen } = {}) {
|
||||
return !autocompleteOpen;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Small, dependency-free helpers for responsive / mobile behaviour.
|
||||
//
|
||||
// Kept deliberately tiny and pure so it can be unit-tested without a DOM
|
||||
// (see client/lib/tests/responsive.tests.js). UI code (e.g. the mobile popup
|
||||
// fix in client/components/main/popup.js) imports these helpers instead of
|
||||
// hard-coding viewport breakpoints in several places.
|
||||
|
||||
// Default breakpoint (px) below which we treat the viewport as "mobile".
|
||||
// Matches the 768px tablet/phone boundary used across the responsive CSS.
|
||||
export const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
/**
|
||||
* Decide whether a given viewport width should be treated as a mobile/narrow
|
||||
* layout.
|
||||
*
|
||||
* @param {number} width viewport width in CSS pixels
|
||||
* @param {number} breakpoint width at/below which we consider it mobile
|
||||
* @returns {boolean} true when width is a valid number <= breakpoint
|
||||
*/
|
||||
export function isMobileViewport(width, breakpoint = MOBILE_BREAKPOINT) {
|
||||
if (typeof width !== 'number' || Number.isNaN(width)) {
|
||||
return false;
|
||||
}
|
||||
return width <= breakpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper that reads the current browser width. Falls back to a
|
||||
* desktop width when there is no window object (e.g. during unit tests / SSR).
|
||||
*
|
||||
* @param {number} breakpoint width at/below which we consider it mobile
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isMobileViewportNow(breakpoint = MOBILE_BREAKPOINT) {
|
||||
if (typeof window === 'undefined' || typeof window.innerWidth !== 'number') {
|
||||
return false;
|
||||
}
|
||||
return isMobileViewport(window.innerWidth, breakpoint);
|
||||
}
|
||||
|
||||
// Also expose on window for non-module callers / debugging, mirroring the
|
||||
// pattern used by client/lib/utils.js and client/lib/popup.js.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.isMobileViewport = isMobileViewport;
|
||||
window.isMobileViewportNow = isMobileViewportNow;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/* eslint-env mocha */
|
||||
import { expect } from 'chai';
|
||||
import { boardOptionLabel } from '/client/components/boards/boardLabelHelpers';
|
||||
|
||||
/**
|
||||
* Unit tests for board/list/swimlane option label building.
|
||||
*
|
||||
* Regression #6135: numbering prepended to option labels broke first-letter
|
||||
* keyboard navigation and readability of boards whose name starts with a
|
||||
* number. Numbering must be OFF by default and, when enabled, must not be a
|
||||
* prefix.
|
||||
*/
|
||||
describe('boardOptionLabel (#6135)', function() {
|
||||
describe('default (numbering off)', function() {
|
||||
it('returns the name unchanged', function() {
|
||||
expect(boardOptionLabel('My Board', 0)).to.equal('My Board');
|
||||
expect(boardOptionLabel('My Board', 4)).to.equal('My Board');
|
||||
});
|
||||
|
||||
it('keeps first-letter navigation: label starts with the name', function() {
|
||||
const name = 'Zebra';
|
||||
const label = boardOptionLabel(name, 7);
|
||||
expect(label.charAt(0)).to.equal(name.charAt(0));
|
||||
expect(label.indexOf(name)).to.equal(0);
|
||||
});
|
||||
|
||||
it('does not prepend a number to names that start with a digit', function() {
|
||||
const name = '2024 Roadmap';
|
||||
const label = boardOptionLabel(name, 2);
|
||||
expect(label).to.equal(name);
|
||||
expect(label.charAt(0)).to.equal('2');
|
||||
});
|
||||
|
||||
it('treats an explicit numbered:false the same as the default', function() {
|
||||
expect(boardOptionLabel('Alpha', 3, { numbered: false })).to.equal('Alpha');
|
||||
});
|
||||
|
||||
it('handles empty / nullish names safely', function() {
|
||||
expect(boardOptionLabel('', 0)).to.equal('');
|
||||
expect(boardOptionLabel(null, 0)).to.equal('');
|
||||
expect(boardOptionLabel(undefined, 0)).to.equal('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('numbering on', function() {
|
||||
it('still STARTS with the name (number is a suffix, not a prefix)', function() {
|
||||
const name = 'Projects';
|
||||
const label = boardOptionLabel(name, 0, { numbered: true });
|
||||
expect(label.indexOf(name)).to.equal(0);
|
||||
expect(label.charAt(0)).to.equal(name.charAt(0));
|
||||
});
|
||||
|
||||
it('appends the 1-based position', function() {
|
||||
expect(boardOptionLabel('Projects', 0, { numbered: true })).to.equal('Projects (1)');
|
||||
expect(boardOptionLabel('Projects', 4, { numbered: true })).to.equal('Projects (5)');
|
||||
});
|
||||
|
||||
it('preserves first-letter navigation even for digit-named boards', function() {
|
||||
const name = '2024 Roadmap';
|
||||
const label = boardOptionLabel(name, 1, { numbered: true });
|
||||
expect(label.charAt(0)).to.equal('2');
|
||||
expect(label.indexOf(name)).to.equal(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/* eslint-env mocha */
|
||||
/* global describe, it */
|
||||
|
||||
// Regression guard for WeKan issue #5188:
|
||||
// "Rule 'When a card is moved to Archive' can not be activated".
|
||||
//
|
||||
// Root cause was a CSS class-name drift between the jade template and the JS
|
||||
// event handler: the jade used `js-add-arch-trigger` while the handler listened
|
||||
// on `js-add-arc-trigger` (missing the 'h'), so the click handler never fired.
|
||||
//
|
||||
// This test reads both files from disk and asserts that every
|
||||
// `js-add-*-trigger` class referenced in the jade template has a matching
|
||||
// event-handler selector in the JS file. It is self-contained (only uses the
|
||||
// node `fs`/`path` builtins plus chai).
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { expect } = require('chai');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..', '..', '..');
|
||||
const jadePath = path.join(
|
||||
repoRoot,
|
||||
'client',
|
||||
'components',
|
||||
'rules',
|
||||
'triggers',
|
||||
'boardTriggers.jade',
|
||||
);
|
||||
const jsPath = path.join(
|
||||
repoRoot,
|
||||
'client',
|
||||
'components',
|
||||
'rules',
|
||||
'triggers',
|
||||
'boardTriggers.js',
|
||||
);
|
||||
|
||||
// Collect every distinct `js-add-<name>-trigger` token in a file.
|
||||
function triggerClasses(contents) {
|
||||
const re = /js-add-[a-z-]*?-trigger/g;
|
||||
return new Set(contents.match(re) || []);
|
||||
}
|
||||
|
||||
describe('boardTriggers template/handler class wiring (#5188)', function() {
|
||||
const jade = fs.readFileSync(jadePath, 'utf8');
|
||||
const js = fs.readFileSync(jsPath, 'utf8');
|
||||
|
||||
const jadeClasses = triggerClasses(jade);
|
||||
const jsClasses = triggerClasses(js);
|
||||
|
||||
it('jade and js both define trigger classes', function() {
|
||||
expect(jadeClasses.size).to.be.greaterThan(0);
|
||||
expect(jsClasses.size).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
it('every jade js-add-*-trigger class has a matching js handler selector', function() {
|
||||
const missing = [...jadeClasses].filter(cls => !jsClasses.has(cls));
|
||||
expect(
|
||||
missing,
|
||||
`These trigger classes are referenced in boardTriggers.jade but have no matching handler in boardTriggers.js: ${missing.join(
|
||||
', ',
|
||||
)}`,
|
||||
).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it('archive trigger class is present in BOTH files (the #5188 regression)', function() {
|
||||
expect(jade).to.contain('js-add-arch-trigger');
|
||||
expect(js).to.contain('js-add-arch-trigger');
|
||||
// The old, mismatched spelling must not reappear.
|
||||
expect(js).to.not.contain('js-add-arc-trigger');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/* eslint-env mocha */
|
||||
import { expect } from 'chai';
|
||||
import { installGetBuiltinModuleShim } from '/client/lib/bsonBrowserShim';
|
||||
|
||||
/**
|
||||
* Unit tests for the bson browser shim.
|
||||
*
|
||||
* Regression: bson 7.x evaluates `globalThis?.process?.getBuiltinModule('v8')`
|
||||
* at module-load time. In the browser a partial `process` polyfill exists but
|
||||
* has no `getBuiltinModule`, so the (non-optional) call threw and aborted the
|
||||
* client bundle bootstrap — boards stopped rendering. The shim installs a no-op
|
||||
* `getBuiltinModule` so bson takes its intended `?? {}` browser fallback.
|
||||
*/
|
||||
describe('installGetBuiltinModuleShim (bson browser bootstrap)', function() {
|
||||
it('installs a no-op getBuiltinModule when process lacks it (the browser case)', function() {
|
||||
const glob = { process: {} };
|
||||
const installed = installGetBuiltinModuleShim(glob);
|
||||
expect(installed).to.equal(true);
|
||||
expect(glob.process.getBuiltinModule).to.be.a('function');
|
||||
// The shimmed call must not throw and must return undefined so `?? {}` wins.
|
||||
expect(glob.process.getBuiltinModule('v8')).to.equal(undefined);
|
||||
});
|
||||
|
||||
it('does NOT overwrite a real getBuiltinModule (Node case)', function() {
|
||||
const real = () => ({ startupSnapshot: {} });
|
||||
const glob = { process: { getBuiltinModule: real } };
|
||||
const installed = installGetBuiltinModuleShim(glob);
|
||||
expect(installed).to.equal(false);
|
||||
expect(glob.process.getBuiltinModule).to.equal(real);
|
||||
});
|
||||
|
||||
it('is a no-op when there is no process object', function() {
|
||||
const glob = {};
|
||||
expect(installGetBuiltinModuleShim(glob)).to.equal(false);
|
||||
expect(glob.process).to.equal(undefined);
|
||||
});
|
||||
|
||||
it('is null / undefined safe', function() {
|
||||
expect(installGetBuiltinModuleShim(null)).to.equal(false);
|
||||
expect(installGetBuiltinModuleShim(undefined)).to.equal(false);
|
||||
});
|
||||
|
||||
it('returns false (does not throw) when process is frozen', function() {
|
||||
const glob = { process: Object.freeze({}) };
|
||||
// Object.freeze prevents adding the property; the shim must swallow that.
|
||||
expect(installGetBuiltinModuleShim(glob)).to.equal(false);
|
||||
});
|
||||
|
||||
it('makes the exact bson expression safe', function() {
|
||||
const glob = { process: {} };
|
||||
installGetBuiltinModuleShim(glob);
|
||||
// Mirror bson: `const { startupSnapshot } = g?.process?.getBuiltinModule('v8') ?? {}`
|
||||
let startupSnapshot;
|
||||
expect(() => {
|
||||
({ startupSnapshot } = glob.process.getBuiltinModule('v8') ?? {});
|
||||
}).to.not.throw();
|
||||
expect(startupSnapshot).to.equal(undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/* eslint-env mocha */
|
||||
import { expect } from 'chai';
|
||||
import { toFullCalendarFirstDay } from '/client/lib/calendarFirstDay';
|
||||
|
||||
/**
|
||||
* Unit tests for the Calendar View start-of-week mapping.
|
||||
*
|
||||
* Regression coverage for #5521 "Calendar View does not respect the setting of
|
||||
* day of week start": the FullCalendar `firstDay` option must be derived from
|
||||
* the user's stored `startDayOfWeek` setting (read via getStartDayOfWeek()),
|
||||
* which is a zero-based day index (0 = Sunday .. 6 = Saturday). FullCalendar's
|
||||
* `firstDay` uses the same convention, so the mapping is the identity for valid
|
||||
* inputs and falls back to Monday (1) for anything invalid.
|
||||
*/
|
||||
describe('toFullCalendarFirstDay (#5521 calendar start-of-week)', function() {
|
||||
it('maps Sunday (0) to firstDay 0', function() {
|
||||
expect(toFullCalendarFirstDay(0)).to.equal(0);
|
||||
});
|
||||
|
||||
it('maps Monday (1) to firstDay 1', function() {
|
||||
expect(toFullCalendarFirstDay(1)).to.equal(1);
|
||||
});
|
||||
|
||||
it('maps every valid 0..6 index to itself', function() {
|
||||
for (let i = 0; i <= 6; i++) {
|
||||
expect(toFullCalendarFirstDay(i)).to.equal(i);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts numeric strings like "0" and "6"', function() {
|
||||
expect(toFullCalendarFirstDay('0')).to.equal(0);
|
||||
expect(toFullCalendarFirstDay('6')).to.equal(6);
|
||||
});
|
||||
|
||||
it('tolerates day-name strings', function() {
|
||||
expect(toFullCalendarFirstDay('sunday')).to.equal(0);
|
||||
expect(toFullCalendarFirstDay('Monday')).to.equal(1);
|
||||
expect(toFullCalendarFirstDay('SATURDAY')).to.equal(6);
|
||||
});
|
||||
|
||||
it('falls back to Monday (1) for undefined/null/empty', function() {
|
||||
expect(toFullCalendarFirstDay(undefined)).to.equal(1);
|
||||
expect(toFullCalendarFirstDay(null)).to.equal(1);
|
||||
expect(toFullCalendarFirstDay('')).to.equal(1);
|
||||
});
|
||||
|
||||
it('falls back to Monday (1) for out-of-range or non-integer values', function() {
|
||||
expect(toFullCalendarFirstDay(7)).to.equal(1);
|
||||
expect(toFullCalendarFirstDay(-1)).to.equal(1);
|
||||
expect(toFullCalendarFirstDay(2.5)).to.equal(1);
|
||||
expect(toFullCalendarFirstDay('notaday')).to.equal(1);
|
||||
expect(toFullCalendarFirstDay({})).to.equal(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/* eslint-env mocha */
|
||||
import { expect } from 'chai';
|
||||
import { isTextSelectionInsideCard } from '/client/lib/cardCloseGuard';
|
||||
|
||||
/**
|
||||
* Unit tests for the card-details close guard.
|
||||
*
|
||||
* Regression #5686 ("Chrome: Selecting text in checklist closes card"):
|
||||
* a text-selection drag that starts on the card content and ends with the
|
||||
* mouse released outside the card must keep the card open. The guard returns
|
||||
* true (keep the card open) only when there is a live, non-collapsed text
|
||||
* selection anchored inside the card detail pane.
|
||||
*
|
||||
* The helper is DOM-light, so a fake `cardDetailsEl` with a `contains()` method
|
||||
* and a fake `selection` object are enough to exercise every branch.
|
||||
*/
|
||||
describe('isTextSelectionInsideCard (#5686)', function() {
|
||||
// A node "inside" the card and one "outside" it.
|
||||
const insideNode = { id: 'inside' };
|
||||
const outsideNode = { id: 'outside' };
|
||||
const cardEl = {
|
||||
contains(node) {
|
||||
return node === insideNode;
|
||||
},
|
||||
};
|
||||
|
||||
function selection({ collapsed = false, text = 'selected text', anchor, focus }) {
|
||||
return {
|
||||
isCollapsed: collapsed,
|
||||
toString: () => text,
|
||||
anchorNode: anchor,
|
||||
focusNode: focus,
|
||||
};
|
||||
}
|
||||
|
||||
it('keeps the card open when the selection is anchored inside the card (the #5686 drag)', function() {
|
||||
const sel = selection({ anchor: insideNode, focus: outsideNode });
|
||||
expect(isTextSelectionInsideCard(sel, cardEl)).to.equal(true);
|
||||
});
|
||||
|
||||
it('keeps the card open when only the focus node is inside the card', function() {
|
||||
const sel = selection({ anchor: outsideNode, focus: insideNode });
|
||||
expect(isTextSelectionInsideCard(sel, cardEl)).to.equal(true);
|
||||
});
|
||||
|
||||
it('closes the card when the selection is entirely outside the card', function() {
|
||||
const sel = selection({ anchor: outsideNode, focus: outsideNode });
|
||||
expect(isTextSelectionInsideCard(sel, cardEl)).to.equal(false);
|
||||
});
|
||||
|
||||
it('closes the card for a collapsed (caret-only) selection inside the card', function() {
|
||||
const sel = selection({ collapsed: true, anchor: insideNode, focus: insideNode });
|
||||
expect(isTextSelectionInsideCard(sel, cardEl)).to.equal(false);
|
||||
});
|
||||
|
||||
it('closes the card when the selected text is empty / whitespace only', function() {
|
||||
expect(isTextSelectionInsideCard(selection({ text: '', anchor: insideNode }), cardEl)).to.equal(false);
|
||||
expect(isTextSelectionInsideCard(selection({ text: ' ', anchor: insideNode }), cardEl)).to.equal(false);
|
||||
});
|
||||
|
||||
it('matches when the selection node IS the card element itself', function() {
|
||||
const sel = selection({ anchor: cardEl, focus: cardEl });
|
||||
expect(isTextSelectionInsideCard(sel, cardEl)).to.equal(true);
|
||||
});
|
||||
|
||||
it('is null / undefined safe', function() {
|
||||
expect(isTextSelectionInsideCard(null, cardEl)).to.equal(false);
|
||||
expect(isTextSelectionInsideCard(selection({ anchor: insideNode }), null)).to.equal(false);
|
||||
expect(isTextSelectionInsideCard(undefined, undefined)).to.equal(false);
|
||||
});
|
||||
|
||||
it('always returns a Boolean', function() {
|
||||
expect(isTextSelectionInsideCard(selection({ anchor: insideNode }), cardEl)).to.be.a('boolean');
|
||||
expect(isTextSelectionInsideCard(null, null)).to.be.a('boolean');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/* eslint-env mocha */
|
||||
import { expect } from 'chai';
|
||||
import {
|
||||
dueAtToTime,
|
||||
cardMatchesUser,
|
||||
filterAndSortDueCards,
|
||||
} from '/client/components/main/dueCardsLogic';
|
||||
|
||||
/**
|
||||
* Unit tests for the Due Cards view logic.
|
||||
*
|
||||
* Covers regressions:
|
||||
* - #5999: all due cards must be shown (no 5-/100-card cap).
|
||||
* - #5930: list includes more than 5 cards.
|
||||
*/
|
||||
describe('dueCards logic', function() {
|
||||
describe('dueAtToTime', function() {
|
||||
it('returns the timestamp for a Date', function() {
|
||||
const d = new Date('2026-01-02T03:04:05Z');
|
||||
expect(dueAtToTime(d)).to.equal(d.getTime());
|
||||
});
|
||||
|
||||
it('parses an ISO string', function() {
|
||||
expect(dueAtToTime('2026-01-02T03:04:05Z')).to.equal(
|
||||
new Date('2026-01-02T03:04:05Z').getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
it('sorts null/empty/invalid values last (far future)', function() {
|
||||
const far = dueAtToTime(null);
|
||||
expect(dueAtToTime(undefined)).to.equal(far);
|
||||
expect(dueAtToTime('')).to.equal(far);
|
||||
expect(dueAtToTime('not-a-date')).to.equal(far);
|
||||
expect(far).to.be.greaterThan(new Date('2099-01-01').getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('cardMatchesUser', function() {
|
||||
it('matches a member', function() {
|
||||
expect(cardMatchesUser({ members: ['u1'] }, 'u1')).to.equal(true);
|
||||
});
|
||||
it('matches an assignee', function() {
|
||||
expect(cardMatchesUser({ assignees: ['u1'] }, 'u1')).to.equal(true);
|
||||
});
|
||||
it('matches the author', function() {
|
||||
expect(cardMatchesUser({ userId: 'u1' }, 'u1')).to.equal(true);
|
||||
});
|
||||
it('does not match an unrelated user', function() {
|
||||
expect(
|
||||
cardMatchesUser({ members: ['u2'], assignees: ['u3'], userId: 'u4' }, 'u1'),
|
||||
).to.equal(false);
|
||||
});
|
||||
it('returns false for missing card or user', function() {
|
||||
expect(cardMatchesUser(null, 'u1')).to.equal(false);
|
||||
expect(cardMatchesUser({ members: ['u1'] }, null)).to.equal(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterAndSortDueCards', function() {
|
||||
const overdue = { _id: 'a', dueAt: '2020-01-01T00:00:00Z', members: ['u1'] };
|
||||
const soon = { _id: 'b', dueAt: '2030-06-01T00:00:00Z', assignees: ['u1'] };
|
||||
const future = { _id: 'c', dueAt: '2040-12-31T00:00:00Z', userId: 'u1' };
|
||||
const others = { _id: 'd', dueAt: '2025-05-05T00:00:00Z', members: ['u2'] };
|
||||
|
||||
it('includes both overdue and future due cards', function() {
|
||||
const out = filterAndSortDueCards([future, overdue, soon], {
|
||||
allUsers: false,
|
||||
userId: 'u1',
|
||||
});
|
||||
const ids = out.map(c => c._id);
|
||||
expect(ids).to.include('a');
|
||||
expect(ids).to.include('c');
|
||||
});
|
||||
|
||||
it('sorts ascending by due date', function() {
|
||||
const out = filterAndSortDueCards([future, overdue, soon], {
|
||||
allUsers: false,
|
||||
userId: 'u1',
|
||||
});
|
||||
expect(out.map(c => c._id)).to.eql(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('filters to the current user when not allUsers', function() {
|
||||
const out = filterAndSortDueCards([overdue, others], {
|
||||
allUsers: false,
|
||||
userId: 'u1',
|
||||
});
|
||||
expect(out.map(c => c._id)).to.eql(['a']);
|
||||
});
|
||||
|
||||
it('keeps all users cards when allUsers is true', function() {
|
||||
const out = filterAndSortDueCards([overdue, others], {
|
||||
allUsers: true,
|
||||
userId: 'u1',
|
||||
});
|
||||
expect(out.map(c => c._id).sort()).to.eql(['a', 'd']);
|
||||
});
|
||||
|
||||
it('does not cap the number of results at 5 (issues #5999 / #5930)', function() {
|
||||
const many = [];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
many.push({
|
||||
_id: `card-${i}`,
|
||||
// descending input order to also exercise the sort
|
||||
dueAt: new Date(2030, 0, 42 - i).toISOString(),
|
||||
members: ['u1'],
|
||||
});
|
||||
}
|
||||
const out = filterAndSortDueCards(many, { allUsers: false, userId: 'u1' });
|
||||
expect(out).to.have.lengthOf(42);
|
||||
// and it is sorted ascending
|
||||
const times = out.map(c => dueAtToTime(c.dueAt));
|
||||
for (let i = 1; i < times.length; i++) {
|
||||
expect(times[i]).to.be.at.least(times[i - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not mutate the input array', function() {
|
||||
const input = [soon, overdue];
|
||||
const copy = input.slice();
|
||||
filterAndSortDueCards(input, { allUsers: true, userId: 'u1' });
|
||||
expect(input).to.eql(copy);
|
||||
});
|
||||
|
||||
it('handles empty / non-array input', function() {
|
||||
expect(filterAndSortDueCards(undefined, {})).to.eql([]);
|
||||
expect(filterAndSortDueCards([], { allUsers: true })).to.eql([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/* eslint-env mocha */
|
||||
import { expect } from 'chai';
|
||||
import { dueDateClass } from '/client/lib/dueDateColor';
|
||||
|
||||
/**
|
||||
* Unit tests for the due-date badge colour logic shared by the card-detail
|
||||
* badge (`cardDueDate`) and the minicard badge (`minicardDueDate`).
|
||||
*
|
||||
* Regression coverage for:
|
||||
* - #6000: future due dates more than ~48h away must be grey ('not-due'),
|
||||
* not amber ('due-soon').
|
||||
* - #5965: the card detail and minicard must compute the SAME class for a
|
||||
* given due date (this function is the single source of truth used by
|
||||
* both views).
|
||||
*/
|
||||
describe('due date colour logic (dueDateClass)', function() {
|
||||
const HOUR = 1000 * 60 * 60;
|
||||
const DAY = HOUR * 24;
|
||||
const now = new Date('2026-06-20T12:00:00Z');
|
||||
|
||||
describe('without an end date', function() {
|
||||
it('returns "overdue" for a due date in the past', function() {
|
||||
const due = new Date(now.getTime() - 2 * HOUR);
|
||||
expect(dueDateClass(due, now)).to.equal('overdue');
|
||||
});
|
||||
|
||||
it('returns "overdue" for a due date several days in the past', function() {
|
||||
const due = new Date(now.getTime() - 5 * DAY);
|
||||
expect(dueDateClass(due, now)).to.equal('overdue');
|
||||
});
|
||||
|
||||
it('returns "due-soon" for a due date within 48 hours', function() {
|
||||
const due = new Date(now.getTime() + 5 * HOUR);
|
||||
expect(dueDateClass(due, now)).to.equal('due-soon');
|
||||
});
|
||||
|
||||
it('returns "due-soon" for a due date exactly 48 hours away', function() {
|
||||
const due = new Date(now.getTime() + 48 * HOUR);
|
||||
expect(dueDateClass(due, now)).to.equal('due-soon');
|
||||
});
|
||||
|
||||
it('returns "not-due" for a due date 3 days in the future', function() {
|
||||
const due = new Date(now.getTime() + 3 * DAY);
|
||||
expect(dueDateClass(due, now)).to.equal('not-due');
|
||||
});
|
||||
|
||||
it('returns "not-due" just past the 48 hour boundary (#6000 regression)', function() {
|
||||
const due = new Date(now.getTime() + 48 * HOUR + 60 * 1000);
|
||||
expect(dueDateClass(due, now)).to.equal('not-due');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an end date', function() {
|
||||
it('returns "completed-early" when the end date is before the due date', function() {
|
||||
const due = new Date(now.getTime() + 3 * DAY);
|
||||
const end = new Date(now.getTime() + 1 * DAY);
|
||||
expect(dueDateClass(due, now, end)).to.equal('completed-early');
|
||||
});
|
||||
|
||||
it('returns "completed" when the end date is on or after the due date', function() {
|
||||
const due = new Date(now.getTime() + 1 * DAY);
|
||||
const end = new Date(now.getTime() + 3 * DAY);
|
||||
expect(dueDateClass(due, now, end)).to.equal('completed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('view consistency (#5965)', function() {
|
||||
it('produces a single class regardless of which view calls it', function() {
|
||||
// The card detail and minicard call dueDateClass with the same args,
|
||||
// so the result is identical by construction.
|
||||
const due = new Date(now.getTime() - 1 * HOUR);
|
||||
const detailClass = dueDateClass(due, now);
|
||||
const minicardClass = dueDateClass(due, now);
|
||||
expect(detailClass).to.equal(minicardClass);
|
||||
expect(detailClass).to.equal('overdue');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,2 +1,15 @@
|
||||
import './Utils.tests';
|
||||
import './filter.dependencies.tests';
|
||||
import './dueDateColor.tests';
|
||||
import './dueCards.logic.tests';
|
||||
import './listAdd.tests';
|
||||
import './responsive.tests';
|
||||
import './boardNumbering.tests';
|
||||
import './subtaskStatus.tests';
|
||||
import './subtaskViewGuard.tests';
|
||||
import './boardTriggersClass.tests';
|
||||
import './calendarFirstDay.tests';
|
||||
import './mentionEnterGuard.tests';
|
||||
import './subtaskViewTarget.tests';
|
||||
import './cardCloseGuard.tests';
|
||||
import './bsonBrowserShim.tests';
|
||||
@@ -0,0 +1,76 @@
|
||||
/* eslint-env mocha */
|
||||
import { expect } from 'chai';
|
||||
import {
|
||||
ADD_LIST_VIEW_MODES,
|
||||
canAddListInView,
|
||||
defaultSwimlaneIdForBoard,
|
||||
} from '/client/components/lists/listAddHelpers';
|
||||
|
||||
// Issue #6142: "Lists" board-view mode must offer the same "add list"
|
||||
// affordance as "Swimlanes" mode.
|
||||
describe('listAddHelpers', function () {
|
||||
describe('canAddListInView', function () {
|
||||
it('allows adding a list in swimlanes view', function () {
|
||||
expect(canAddListInView('board-view-swimlanes')).to.equal(true);
|
||||
});
|
||||
|
||||
it('allows adding a list in lists view (issue #6142)', function () {
|
||||
expect(canAddListInView('board-view-lists')).to.equal(true);
|
||||
});
|
||||
|
||||
it('does not allow adding a list in calendar/gantt/table views', function () {
|
||||
expect(canAddListInView('board-view-cal')).to.equal(false);
|
||||
expect(canAddListInView('board-view-gantt')).to.equal(false);
|
||||
expect(canAddListInView('board-view-table')).to.equal(false);
|
||||
});
|
||||
|
||||
it('returns false for unknown / empty view modes', function () {
|
||||
expect(canAddListInView('')).to.equal(false);
|
||||
expect(canAddListInView(undefined)).to.equal(false);
|
||||
expect(canAddListInView('nonsense')).to.equal(false);
|
||||
});
|
||||
|
||||
it('exposes the two supported view modes', function () {
|
||||
expect(ADD_LIST_VIEW_MODES).to.have.members([
|
||||
'board-view-swimlanes',
|
||||
'board-view-lists',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultSwimlaneIdForBoard', function () {
|
||||
it('prefers an explicitly provided swimlane id', function () {
|
||||
const board = { getDefaultSwimline: () => ({ _id: 'default-id' }) };
|
||||
expect(defaultSwimlaneIdForBoard(board, 'explicit-id')).to.equal(
|
||||
'explicit-id',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the board default swimlane id', function () {
|
||||
const board = { getDefaultSwimline: () => ({ _id: 'default-id' }) };
|
||||
expect(defaultSwimlaneIdForBoard(board)).to.equal('default-id');
|
||||
});
|
||||
|
||||
it('returns null when the board has no default swimlane', function () {
|
||||
const board = { getDefaultSwimline: () => undefined };
|
||||
expect(defaultSwimlaneIdForBoard(board)).to.equal(null);
|
||||
});
|
||||
|
||||
it('returns null when the default swimlane has no id', function () {
|
||||
const board = { getDefaultSwimline: () => ({}) };
|
||||
expect(defaultSwimlaneIdForBoard(board)).to.equal(null);
|
||||
});
|
||||
|
||||
it('returns null for a missing board with no explicit id', function () {
|
||||
expect(defaultSwimlaneIdForBoard(null)).to.equal(null);
|
||||
expect(defaultSwimlaneIdForBoard(undefined)).to.equal(null);
|
||||
expect(defaultSwimlaneIdForBoard({})).to.equal(null);
|
||||
});
|
||||
|
||||
it('still honors an explicit id even with a broken board', function () {
|
||||
expect(defaultSwimlaneIdForBoard(null, 'explicit-id')).to.equal(
|
||||
'explicit-id',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/* eslint-env mocha */
|
||||
import { expect } from 'chai';
|
||||
import { shouldSubmitOnEnter } from '/client/lib/mentionKeyGuard';
|
||||
|
||||
/**
|
||||
* Unit tests for the @mention autocomplete Enter guard.
|
||||
*
|
||||
* Regression #3289 ("@mention user list: select user by pressing Enter key"),
|
||||
* #4172 ("Sometimes a card is closed when tagging a user (with @) in a
|
||||
* comment") and #5457 ("add comments"): while the mention autocomplete
|
||||
* dropdown is open, pressing Enter must SELECT the highlighted suggestion, not
|
||||
* submit the comment / close the card.
|
||||
*
|
||||
* shouldSubmitOnEnter centralises that single decision: Enter may submit only
|
||||
* when the autocomplete dropdown is NOT open.
|
||||
*/
|
||||
describe('shouldSubmitOnEnter (#3289 / #4172 / #5457)', function() {
|
||||
it('does NOT submit while the autocomplete dropdown is open', function() {
|
||||
expect(shouldSubmitOnEnter({ autocompleteOpen: true })).to.equal(false);
|
||||
});
|
||||
|
||||
it('submits when the autocomplete dropdown is closed', function() {
|
||||
expect(shouldSubmitOnEnter({ autocompleteOpen: false })).to.equal(true);
|
||||
});
|
||||
|
||||
it('submits when called with no argument (dropdown undefined => closed)', function() {
|
||||
expect(shouldSubmitOnEnter()).to.equal(true);
|
||||
expect(shouldSubmitOnEnter({})).to.equal(true);
|
||||
});
|
||||
|
||||
it('always returns a Boolean', function() {
|
||||
expect(shouldSubmitOnEnter({ autocompleteOpen: true })).to.be.a('boolean');
|
||||
expect(shouldSubmitOnEnter({ autocompleteOpen: false })).to.be.a('boolean');
|
||||
});
|
||||
});
|
||||
Loaded 100 of 534 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user