mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 01:20:07 -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,13 +32,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# Login against a Docker registry except on PR
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -48,14 +48,14 @@ jobs:
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -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
|
||||
@@ -27,14 +27,14 @@ jobs:
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache Meteor installation
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.meteor
|
||||
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
|
||||
restore-keys: meteor-
|
||||
|
||||
- name: Cache Meteor build (.meteor/local)
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: .meteor/local
|
||||
key: meteor-local-unit-${{ hashFiles('.meteor/versions') }}
|
||||
@@ -62,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
|
||||
@@ -92,14 +98,14 @@ jobs:
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache Meteor installation
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.meteor
|
||||
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
|
||||
restore-keys: meteor-
|
||||
|
||||
- name: Cache Meteor build (.meteor/local)
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: .meteor/local
|
||||
key: meteor-local-e2e-${{ hashFiles('.meteor/versions') }}
|
||||
@@ -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
|
||||
@@ -201,14 +227,14 @@ jobs:
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache Meteor installation
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.meteor
|
||||
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
|
||||
restore-keys: meteor-
|
||||
|
||||
- name: Cache Meteor build (.meteor/local)
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: .meteor/local
|
||||
key: meteor-local-regression-${{ hashFiles('.meteor/versions') }}
|
||||
@@ -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
|
||||
|
||||
@@ -22,7 +22,9 @@ name: Release All Platforms
|
||||
# commits and pushes the bump to wekan/wekan main
|
||||
# - prepare: tags the bumped main as v<new_version> and extracts release notes
|
||||
# - website: updates the wekan/wekan.fi repo (install page + API docs) and pushes
|
||||
# - charts: updates and publishes the wekan/charts Helm chart and pushes
|
||||
# - charts: updates and publishes the wekan/charts Helm chart and pushes,
|
||||
# only AFTER the `docker` job has published the image (skipped if
|
||||
# docker fails)
|
||||
# - build-*: builds the amd64 bundle (full Meteor 3 build), then rebuilds native
|
||||
# modules for arm64 from that amd64 base
|
||||
# - release: creates the GitHub Release with the newest CHANGELOG.md notes and
|
||||
@@ -64,7 +66,7 @@ permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
# Node.js major version that Meteor 3.5-beta bundles. Used for native module
|
||||
# Node.js major version that Meteor 3.5 bundles. Used for native module
|
||||
# rebuilds on every platform — it must match the amd64 build's Node ABI.
|
||||
env:
|
||||
NODE_VERSION: '24'
|
||||
@@ -81,7 +83,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
@@ -124,7 +126,7 @@ jobs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
changelog: ${{ steps.changelog.outputs.content }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
@@ -186,13 +188,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout wekan repo (bumped main, with rebuilt API docs)
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
path: wekan
|
||||
|
||||
- name: Checkout wekan.fi website repo
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: wekan/wekan.fi
|
||||
token: ${{ secrets.WEKAN_REPO_TOKEN }}
|
||||
@@ -210,22 +212,24 @@ jobs:
|
||||
cd ..
|
||||
./wekan/releases/release-website.sh "${{ inputs.old_version }}" "${{ inputs.new_version }}"
|
||||
|
||||
# ─── 1c. Update and publish the Helm chart repo (parallel) ──────────────────
|
||||
# ─── 1c. Update and publish the Helm chart repo (after docker) ──────────────
|
||||
# Bumps the WeKan version in wekan/charts, packages the chart and publishes it
|
||||
# to the gh-pages Helm index, then pushes both branches. Runs in parallel with
|
||||
# the platform builds; packaging the chart does not need the built artifacts.
|
||||
# to the gh-pages Helm index, then pushes both branches. Runs only AFTER the
|
||||
# `docker` job succeeds: the chart points users at the freshly published
|
||||
# multi-arch image, so we don't advertise a new chart version until that image
|
||||
# is actually on the registries. If `docker` fails, this job is skipped.
|
||||
charts:
|
||||
needs: bump
|
||||
needs: docker
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout wekan repo (for releases/release-charts.sh + version refs)
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
path: wekan
|
||||
|
||||
- name: Checkout wekan/charts repo (main + gh-pages)
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: wekan/charts
|
||||
token: ${{ secrets.WEKAN_REPO_TOKEN }}
|
||||
@@ -250,7 +254,7 @@ jobs:
|
||||
./wekan/releases/release-charts.sh "${{ inputs.new_version }}"
|
||||
|
||||
# ─── 2. Build amd64 bundle (full Meteor 3 build) ────────────────────────────
|
||||
# Meteor 3.5-beta installer supports only Linux x86_64/aarch64, macOS, Windows.
|
||||
# Meteor 3.5 installer supports only Linux x86_64/aarch64, macOS, Windows.
|
||||
# The full Meteor build always runs here on x86_64; every other platform then
|
||||
# only rebuilds the native npm modules from this bundle.
|
||||
build-amd64:
|
||||
@@ -260,19 +264,19 @@ jobs:
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: refs/tags/v${{ needs.prepare.outputs.version }}
|
||||
|
||||
- name: Cache Meteor installation
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.meteor
|
||||
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
|
||||
restore-keys: meteor-
|
||||
|
||||
- name: Cache npm packages
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ hashFiles('package-lock.json') }}
|
||||
@@ -411,7 +415,7 @@ jobs:
|
||||
# Checked out into src/ so start-wekan.bat and snapcraft.yaml (which pins
|
||||
# the MongoDB tools/mongosh versions) are available; the downloaded bundle
|
||||
# is extracted alongside it in the workspace root.
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: refs/tags/v${{ needs.prepare.outputs.version }}
|
||||
path: src
|
||||
@@ -590,7 +594,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 +654,7 @@ jobs:
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: refs/tags/v${{ needs.prepare.outputs.version }}
|
||||
|
||||
@@ -714,33 +718,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 +793,7 @@ jobs:
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: refs/tags/v${{ needs.prepare.outputs.version }}
|
||||
# remote-build (Launchpad) rejects shallow clones, so fetch full history
|
||||
|
||||
@@ -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
|
||||
standard-minifier-js@3.2.0
|
||||
rspack@1.1.0
|
||||
|
||||
@@ -16,11 +16,11 @@ blaze@3.0.2
|
||||
aldeed:collection2
|
||||
aldeed:schema-index@4.0.0-beta.5
|
||||
reywood:publish-composite
|
||||
mongo@2.4.0-rc350.1
|
||||
mongo@2.4.0
|
||||
|
||||
# Account system
|
||||
accounts-password@3.3.0-rc350.1
|
||||
accounts-express@1.0.0-rc350.1
|
||||
accounts-password@3.3.0
|
||||
accounts-express@1.0.0
|
||||
communitypackages:core
|
||||
communitypackages:unstyled
|
||||
communitypackages:flow-routing
|
||||
@@ -37,7 +37,7 @@ wekan-accounts-oidc
|
||||
check@1.5.0
|
||||
random@1.2.2
|
||||
reactive-dict@1.3.2
|
||||
session@1.2.3-rc350.1
|
||||
session@1.2.3
|
||||
tracker@1.3.4
|
||||
audit-argument-checks@1.0.8
|
||||
|
||||
@@ -45,7 +45,7 @@ audit-argument-checks@1.0.8
|
||||
ostrio:i18n
|
||||
reactive-var@1.0.13
|
||||
shell-server@0.7.0
|
||||
email@3.2.0-rc350.1
|
||||
email@3.2.0
|
||||
dynamic-import@0.7.4
|
||||
wekan-markdown
|
||||
quave:synced-cron
|
||||
@@ -55,8 +55,8 @@ ostrio:files
|
||||
meteortesting:mocha@3.0.0
|
||||
matb33:collection-hooks
|
||||
service-configuration@1.3.5
|
||||
minifier-css@2.0.2-rc350.1
|
||||
ejson@1.2.0-rc350.1
|
||||
minifier-css@2.0.2
|
||||
ejson@1.2.0
|
||||
logging@1.3.6
|
||||
wekan-fullcalendar
|
||||
wekan-fontawesome
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
METEOR@3.5-rc.1
|
||||
METEOR@3.5
|
||||
+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
|
||||
accounts-express@1.0.0
|
||||
accounts-oauth@1.4.7
|
||||
accounts-password@3.3.0
|
||||
aldeed:collection2@4.2.0
|
||||
aldeed:schema-index@4.0.0
|
||||
aldeed:simple-schema@1.13.1
|
||||
allow-deny@2.1.0
|
||||
audit-argument-checks@1.0.8
|
||||
autoupdate@2.0.1
|
||||
babel-compiler@7.14.0
|
||||
babel-compiler@7.14.1
|
||||
babel-runtime@1.5.2
|
||||
base64@1.0.14-rc350.1
|
||||
binary-heap@1.0.13-rc350.1
|
||||
base64@1.0.14
|
||||
binary-heap@1.0.13
|
||||
blaze@3.0.3
|
||||
blaze-tools@2.0.1
|
||||
boilerplate-generator@2.1.0
|
||||
caching-compiler@2.0.1
|
||||
caching-html-compiler@2.0.1
|
||||
callback-hook@1.8.0-rc350.1
|
||||
callback-hook@1.8.0
|
||||
check@1.5.0
|
||||
communitypackages:core@2.0.3
|
||||
communitypackages:flow-routing@2.1.0
|
||||
communitypackages:unstyled@2.0.1
|
||||
core-runtime@1.0.0
|
||||
ddp@1.4.2
|
||||
ddp-client@3.3.0-rc350.1
|
||||
ddp-common@1.4.5-rc350.1
|
||||
ddp-rate-limiter@1.3.0-rc350.1
|
||||
ddp-server@3.3.0-rc350.1
|
||||
ddp-client@3.3.0
|
||||
ddp-common@1.4.5
|
||||
ddp-rate-limiter@1.3.0
|
||||
ddp-server@3.3.0
|
||||
diff-sequence@1.1.3
|
||||
dynamic-import@0.7.4
|
||||
ecmascript@0.18.0
|
||||
ecmascript@0.18.1
|
||||
ecmascript-runtime@0.8.3
|
||||
ecmascript-runtime-client@0.13.0
|
||||
ecmascript-runtime-server@0.11.1
|
||||
ejson@1.2.0-rc350.1
|
||||
email@3.2.0-rc350.1
|
||||
ejson@1.2.0
|
||||
email@3.2.0
|
||||
es5-shim@4.8.1
|
||||
facts-base@1.0.2
|
||||
fetch@0.2.0-rc350.1
|
||||
fetch@0.2.0
|
||||
geojson-utils@1.0.12
|
||||
hot-code-push@1.0.5
|
||||
html-tools@2.0.1
|
||||
@@ -53,23 +53,23 @@ lai:collection-extensions@1.0.0
|
||||
localstorage@1.2.1
|
||||
logging@1.3.6
|
||||
matb33:collection-hooks@2.1.0
|
||||
meteor@2.3.1-rc350.1
|
||||
meteor@2.3.1
|
||||
meteor-base@1.5.2
|
||||
meteortesting:browser-tests@1.8.0
|
||||
meteortesting:mocha@3.3.0
|
||||
meteortesting:mocha-core@8.2.0
|
||||
minifier-css@2.0.2-rc350.1
|
||||
minifier-js@3.2.0
|
||||
minimongo@2.2.0-rc350.1
|
||||
minifier-css@2.0.2
|
||||
minifier-js@3.2.1
|
||||
minimongo@2.2.0
|
||||
modern-browsers@0.2.3
|
||||
modules@0.20.3
|
||||
modules-runtime@0.13.2
|
||||
mongo@2.4.0-rc350.1
|
||||
mongo@2.4.0
|
||||
mongo-decimal@0.2.0
|
||||
mongo-dev-server@1.1.1
|
||||
mongo-id@1.0.9
|
||||
npm-mongo@6.16.1
|
||||
oauth@3.0.4-rc350.1
|
||||
oauth@3.0.4
|
||||
oauth2@1.3.3
|
||||
observe-sequence@2.0.1
|
||||
ordered-dict@1.2.0
|
||||
@@ -82,7 +82,7 @@ promise@1.0.0
|
||||
quave:synced-cron@2.4.0
|
||||
raix:eventemitter@1.0.0
|
||||
random@1.2.2
|
||||
rate-limit@1.2.0-rc350.1
|
||||
rate-limit@1.2.0
|
||||
react-fast-refresh@0.3.0
|
||||
reactive-dict@1.3.2
|
||||
reactive-var@1.0.13
|
||||
@@ -92,10 +92,10 @@ reywood:publish-composite@1.9.0
|
||||
routepolicy@1.1.2
|
||||
rspack@1.1.0
|
||||
service-configuration@1.3.5
|
||||
session@1.2.3-rc350.1
|
||||
session@1.2.3
|
||||
sha@1.0.10
|
||||
shell-server@0.7.0
|
||||
socket-stream-client@0.7.0-rc350.1
|
||||
socket-stream-client@0.7.0
|
||||
spacebars@2.0.1
|
||||
spacebars-compiler@2.0.1
|
||||
standard-minifier-js@3.2.0
|
||||
@@ -105,9 +105,9 @@ templating-runtime@2.0.2
|
||||
templating-tools@2.0.1
|
||||
tools-core@1.1.0
|
||||
tracker@1.3.4
|
||||
typescript@5.10.0
|
||||
url@1.3.6-rc350.1
|
||||
webapp@2.2.0-rc350.1
|
||||
typescript@5.10.1
|
||||
url@1.3.6
|
||||
webapp@2.2.0
|
||||
webapp-hashing@1.1.2
|
||||
wekan-accounts-cas@0.2.0
|
||||
wekan-accounts-lockout@1.1.0
|
||||
|
||||
+8
-1
@@ -1,2 +1,9 @@
|
||||
npm-packages/
|
||||
tests/
|
||||
# Root tests/ holds Playwright specs, Node E2E and node-based unit tests - none
|
||||
# of it is Meteor code, and Meteor's file watcher otherwise churns on the
|
||||
# tests/playwright/test-results/ tree (inotify errors). The leading slash anchors
|
||||
# this to the repo root only, so it does NOT match server/lib/tests/ or
|
||||
# client/lib/tests/ (the Mocha suites, loaded via meteor.testModule in
|
||||
# package.json). A naked "tests/" would match a dir named tests at any depth and
|
||||
# would hide those suites, making `meteor test` report "0 passing".
|
||||
/tests/
|
||||
+1251
-143
File diff suppressed because it is too large.
Load diff
+5
-4
@@ -7,17 +7,17 @@ LABEL org.opencontainers.image.source="https://github.com/wekan/wekan"
|
||||
# TARGETARCH and TARGETVARIANT are automatically provided by Docker Buildx
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG VERSION=9.63
|
||||
ARG VERSION=9.76
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
|
||||
|
||||
ENV \
|
||||
DEBUG=false \
|
||||
NODE_VERSION=v24.17.0 \
|
||||
METEOR_RELEASE=METEOR@3.5-beta.12 \
|
||||
NODE_VERSION=v24.18.0 \
|
||||
METEOR_RELEASE=METEOR@3.5-rc.2 \
|
||||
USE_EDGE=false \
|
||||
NPM_VERSION=11.11.0 \
|
||||
NPM_VERSION=11.12.1 \
|
||||
SRC_PATH=./ \
|
||||
WITH_API=true \
|
||||
MONGO_OPLOG_URL="" \
|
||||
@@ -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.76.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
|
||||
@@ -264,10 +265,9 @@ if arguments == 10:
|
||||
boardid = sys.argv[3]
|
||||
name = sys.argv[4]
|
||||
type1 = sys.argv[5]
|
||||
settings = str(json.loads(sys.argv[6]))
|
||||
# There is error: Settings must be object. So this does not work yet.
|
||||
#settings = {'currencyCode': 'EUR'}
|
||||
print(type(settings))
|
||||
settings_arg = sys.argv[6]
|
||||
# settings is optional; an empty arg -> {} (avoids json.loads('') crashing).
|
||||
settings = json.loads(settings_arg) if settings_arg.strip() else {}
|
||||
showoncard = sys.argv[7]
|
||||
automaticallyoncard = sys.argv[8]
|
||||
showlabelonminicard = sys.argv[9]
|
||||
@@ -275,7 +275,7 @@ if arguments == 10:
|
||||
customfieldtoboard = wekanurl + apiboards + boardid + s + cf
|
||||
# Add Custom Field to Board
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
post_data = {'authorId': '{}'.format(authorid), 'name': '{}'.format(name), 'type': '{}'.format(type1), 'settings': '{}'.format(settings), 'showoncard': '{}'.format(showoncard), 'automaticallyoncard': '{}'.format(automaticallyoncard), 'showlabelonminicard': '{}'.format(showlabelonminicard), 'showsumattopoflist': '{}'.format(showsumattopoflist)}
|
||||
post_data = {'authorId': '{}'.format(authorid), 'name': '{}'.format(name), 'type': '{}'.format(type1), 'settings': json.dumps(settings), 'showoncard': '{}'.format(showoncard), 'automaticallyoncard': '{}'.format(automaticallyoncard), 'showlabelonminicard': '{}'.format(showlabelonminicard), 'showsumattopoflist': '{}'.format(showsumattopoflist)}
|
||||
body = requests.post(customfieldtoboard, data=post_data, headers=headers)
|
||||
print(body.text)
|
||||
# ------- ADD CUSTOM FIELD TO BOARD END -----------
|
||||
@@ -2021,4 +2021,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() {
|
||||
|
||||
@@ -107,17 +107,17 @@
|
||||
overflow-y: scroll !important;
|
||||
}
|
||||
|
||||
/* Mobile - make all text 2x bigger inside #content by default (icons stay same size) */
|
||||
/* Mobile - keep #content text at normal size (reverted #6426 oversizing) */
|
||||
@media screen and (max-width: 800px),
|
||||
screen and (max-device-width: 800px),
|
||||
screen and (-webkit-min-device-pixel-ratio: 2) and (max-width: 800px),
|
||||
screen and (max-width: 800px) and (orientation: portrait),
|
||||
screen and (max-width: 800px) and (orientation: landscape) {
|
||||
#content {
|
||||
font-size: 2em !important; /* 2x bigger base font size for content area */
|
||||
font-size: 1em !important; /* Normal size (reverted #6426 oversizing) */
|
||||
}
|
||||
|
||||
/* Make all text elements 2x bigger */
|
||||
/* Keep all text elements at normal size */
|
||||
#content h1, #content h2, #content h3, #content h4, #content h5, #content h6,
|
||||
#content p, #content span, #content div, #content a, #content button,
|
||||
#content .minicard, #content .list-header-name, #content .board-header-btn,
|
||||
@@ -186,7 +186,7 @@
|
||||
|
||||
/* Fallback for iPhone devices using JavaScript detection */
|
||||
.iphone-device #content {
|
||||
font-size: 2em !important; /* 2x bigger base font size for content area */
|
||||
font-size: 1em !important; /* Normal size (reverted #6426 oversizing) */
|
||||
}
|
||||
|
||||
.iphone-device #content h1, .iphone-device #content h2, .iphone-device #content h3, .iphone-device #content h4, .iphone-device #content h5, .iphone-device #content h6,
|
||||
@@ -231,9 +231,9 @@
|
||||
font-size: 1em !important; /* Keep original icon size */
|
||||
}
|
||||
|
||||
/* Mobile iPhone: scale card details text and icons to 2x */
|
||||
/* Mobile iPhone: keep card details text/icons at normal size (reverted #6426 oversizing) */
|
||||
body.mobile-mode.iphone-device .card-details {
|
||||
font-size: 2em !important;
|
||||
font-size: 1em !important;
|
||||
}
|
||||
body.mobile-mode.iphone-device .card-details .fa,
|
||||
body.mobile-mode.iphone-device .card-details .icon,
|
||||
|
||||
@@ -5,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;
|
||||
}
|
||||
@@ -946,21 +1002,13 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile - make all text and icons 2x bigger above #content on All Boards page */
|
||||
/* Mobile All Boards layout fixes */
|
||||
@media screen and (max-width: 800px),
|
||||
screen and (max-device-width: 800px),
|
||||
screen and (-webkit-min-device-pixel-ratio: 2) and (max-width: 800px),
|
||||
screen and (max-width: 800px) and (orientation: portrait),
|
||||
screen and (max-width: 800px) and (orientation: landscape),
|
||||
screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
|
||||
.wrapper {
|
||||
font-size: 2em !important; /* 2x bigger base font size for All Boards page */
|
||||
}
|
||||
|
||||
.wrapper * {
|
||||
font-size: inherit !important; /* Inherit the 2x scaling */
|
||||
}
|
||||
|
||||
/* Stack the two-column All Boards layout on small screens. The fixed
|
||||
"260px 1fr" grid had no mobile collapse, so on narrow non-iPhone browsers
|
||||
(Fairphone Firefox ESR / Ubuntu Touch Morph, narrow desktop) it overflowed
|
||||
@@ -976,243 +1024,6 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
padding-inline-end: 0 !important;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.wrapper .fa, .wrapper .icon {
|
||||
font-size: 2em !important; /* 2x bigger icons */
|
||||
}
|
||||
|
||||
.board-list-header {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.board-list-header h1 {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.AllBoardTeamsOrgs {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.AllBoardTeamsOrgs select {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.AllBoardTeamsOrgs input {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.AllBoardTeamsOrgs .fa {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.board-list {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.board-list .board-list-item {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.board-list .board-list-item-name {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.board-list .board-list-item-desc {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.board-list .minicard-members {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.board-list .minicard-lists {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.board-list .fa {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.board-list .board-handle {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback for iPhone devices using JavaScript detection - All Boards page */
|
||||
.iphone-device .wrapper {
|
||||
font-size: 2em !important; /* 2x bigger base font size for All Boards page */
|
||||
}
|
||||
|
||||
.iphone-device .wrapper * {
|
||||
font-size: inherit !important; /* Inherit the 2x scaling */
|
||||
}
|
||||
|
||||
.iphone-device .wrapper .fa, .iphone-device .wrapper .icon {
|
||||
font-size: 2em !important; /* 2x bigger icons */
|
||||
}
|
||||
|
||||
.iphone-device .board-list-header {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.iphone-device .board-list-header h1 {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.iphone-device .AllBoardTeamsOrgs {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.iphone-device .AllBoardTeamsOrgs select {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.iphone-device .AllBoardTeamsOrgs input {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.iphone-device .AllBoardTeamsOrgs .fa {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.iphone-device .board-list {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.iphone-device .board-list .board-list-item {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.iphone-device .board-list .board-list-item-name {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.iphone-device .board-list .board-list-item-desc {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.iphone-device .board-list .minicard-members {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.iphone-device .board-list .minicard-lists {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.iphone-device .board-list .fa {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
.iphone-device .board-list .board-handle {
|
||||
font-size: 1em !important; /* Use inherited 2x scaling */
|
||||
}
|
||||
|
||||
/* iPhone 12 Mini and very small screens - make everything much larger */
|
||||
@media screen and (max-width: 400px) and (max-height: 900px) {
|
||||
.board-list {
|
||||
height: calc(100vh - 120px) !important;
|
||||
overflow-y: scroll !important;
|
||||
overflow-x: hidden !important;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.board-list li {
|
||||
width: 100% !important;
|
||||
float: none !important;
|
||||
display: block !important;
|
||||
margin-bottom: 1.5rem !important;
|
||||
}
|
||||
|
||||
.board-list .board-list-item {
|
||||
height: 12rem !important; /* Much taller */
|
||||
width: 100% !important;
|
||||
margin: 0 !important;
|
||||
padding: 1rem !important; /* More padding */
|
||||
font-size: 18px !important; /* Much larger text */
|
||||
line-height: 1.4 !important;
|
||||
}
|
||||
|
||||
.board-list .board-list-item .board-list-item-name {
|
||||
font-size: 20px !important; /* Larger board names */
|
||||
font-weight: bold !important;
|
||||
margin-bottom: 0.5rem !important;
|
||||
}
|
||||
|
||||
.board-list .board-list-item .board-list-item-desc {
|
||||
font-size: 16px !important; /* Larger descriptions */
|
||||
line-height: 1.3 !important;
|
||||
}
|
||||
|
||||
.board-list .board-list-item .minicard-members {
|
||||
font-size: 14px !important; /* Larger member avatars */
|
||||
}
|
||||
|
||||
.board-list .board-list-item .minicard-lists {
|
||||
font-size: 14px !important; /* Larger list counters */
|
||||
}
|
||||
|
||||
.board-list .board-handle {
|
||||
position: absolute;
|
||||
padding: 10px !important;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
inset-inline-end: 15px !important;
|
||||
font-size: 28px !important; /* Much larger drag handle */
|
||||
color: #fff;
|
||||
background: rgba(0,0,0,0.4) !important;
|
||||
border-radius: 50%;
|
||||
width: 50px !important; /* Larger handle */
|
||||
height: 50px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.board-list .board-handle:hover {
|
||||
background: rgba(255, 255, 0, 0.8) !important; /* Yellow hover */
|
||||
}
|
||||
|
||||
/* Force scrollbar to be visible and larger */
|
||||
.board-list::-webkit-scrollbar {
|
||||
width: 16px !important; /* Much wider scrollbar */
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
.board-list::-webkit-scrollbar-track {
|
||||
background: #f1f1f1 !important;
|
||||
border-radius: 8px !important;
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
.board-list::-webkit-scrollbar-thumb {
|
||||
background: #666 !important; /* Darker for better visibility */
|
||||
border-radius: 8px !important;
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
min-height: 50px !important; /* Minimum thumb size */
|
||||
}
|
||||
|
||||
.board-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #333 !important;
|
||||
}
|
||||
|
||||
/* Ensure scrollbar is always visible */
|
||||
.board-list {
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: auto !important;
|
||||
min-height: 100vh !important;
|
||||
}
|
||||
|
||||
.board-list::after {
|
||||
content: '';
|
||||
display: block;
|
||||
height: 200px !important; /* More space to ensure scrolling */
|
||||
}
|
||||
}
|
||||
.AllBoardTeamsOrgs {
|
||||
list-style-type: none;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,8 +2,6 @@ import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import getSlug from 'limax';
|
||||
import '/client/lib/dragscrollTouch';
|
||||
import { enablePageDragscroll, disablePageDragscroll } from '/client/lib/pageDragscroll';
|
||||
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
|
||||
import { BoardMultiSelection } from '/client/lib/boardMultiSelection';
|
||||
import { EscapeActions } from '/client/lib/escapeActions';
|
||||
@@ -57,6 +55,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 +166,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 +271,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
|
||||
@@ -274,12 +327,9 @@ Template.boardList.onCreated(function () {
|
||||
});
|
||||
|
||||
Template.boardList.onRendered(function () {
|
||||
// Enable drag-to-scroll on the All Boards page the same way the board
|
||||
// canvas/swimlanes do. On desktop the page scroll container is <body>, so we
|
||||
// tag it via enablePageDragscroll(); the .board-list.dragscroll element only
|
||||
// becomes a scroll container in mobile view, where its own class handles it.
|
||||
// Finger drag-scrolling is handled by dragscrollTouch.js.
|
||||
enablePageDragscroll();
|
||||
// Drag-to-scroll on the All Boards page is handled centrally for all
|
||||
// non-board pages by defaultLayout's route-aware autorun
|
||||
// (see client/components/main/layouts.js).
|
||||
|
||||
// jQuery sortable is disabled in favor of HTML5 drag-and-drop for space management
|
||||
// The old sortable code has been removed to prevent conflicts
|
||||
@@ -325,13 +375,6 @@ Template.boardList.onRendered(function () {
|
||||
*/
|
||||
});
|
||||
|
||||
Template.boardList.onDestroyed(function () {
|
||||
// Stop tagging <body> as drag-scrollable when leaving the All Boards page so
|
||||
// other layouts (e.g. a board view, whose canvas does its own dragscroll) are
|
||||
// not affected.
|
||||
disablePageDragscroll();
|
||||
});
|
||||
|
||||
Template.boardList.helpers({
|
||||
userHasTeams() {
|
||||
if (ReactiveCache.getCurrentUser()?.teams?.length > 0) return true;
|
||||
@@ -486,27 +529,53 @@ Template.boardList.helpers({
|
||||
|
||||
const boards = ReactiveCache.getBoards(query, {});
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
|
||||
// #5799: in a sorted (non-custom) mode the server already computed the
|
||||
// current page (filtered by menu/search and sorted), so render exactly that
|
||||
// ordered page of board icons. Custom (manual drag order) falls through to
|
||||
// the unpaginated client-side path below so drag-reordering keeps working.
|
||||
const sortMode =
|
||||
currentUser && typeof currentUser.getAllBoardsSortBy === 'function'
|
||||
? currentUser.getAllBoardsSortBy()
|
||||
: 'custom';
|
||||
if (sortMode !== 'custom') {
|
||||
const paged = tpl.pagedBoardsVar.get();
|
||||
return (paged.ids || [])
|
||||
.map((id) => ReactiveCache.getBoard(id))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
let list = boards;
|
||||
// Apply left menu filtering
|
||||
const sel = tpl.selectedMenu.get();
|
||||
const assignments =
|
||||
(currentUser &&
|
||||
currentUser.profile &&
|
||||
currentUser.profile.boardWorkspaceAssignments) ||
|
||||
{};
|
||||
if (sel === 'starred') {
|
||||
// Starred boards are always visible in Starred.
|
||||
list = list.filter((b) => currentUser && currentUser.hasStarred(b._id));
|
||||
} else if (sel === 'templates') {
|
||||
list = list.filter((b) => b.type === 'template-container');
|
||||
} else if (sel === 'remaining') {
|
||||
// Remaining only shows boards not assigned to any workspace.
|
||||
list = list.filter(
|
||||
(b) => !assignments[b._id] && b.type !== 'template-container',
|
||||
);
|
||||
|
||||
// #5799: when a board-name search is active, search across ALL the user's
|
||||
// boards (every menu/workspace) by title and skip the menu filter, so a
|
||||
// board in any category — Starred, Templates, Remaining or a (sub)workspace
|
||||
// — is found from a single search box.
|
||||
const search = (tpl.boardSearchVar.get() || '').trim().toLowerCase();
|
||||
if (search) {
|
||||
list = list.filter((b) => (b.title || '').toLowerCase().includes(search));
|
||||
} else {
|
||||
// Workspace view includes all boards in that workspace, including starred.
|
||||
list = list.filter((b) => assignments[b._id] === sel);
|
||||
// Apply left menu filtering
|
||||
const sel = tpl.selectedMenu.get();
|
||||
if (sel === 'starred') {
|
||||
// Starred boards are always visible in Starred.
|
||||
list = list.filter((b) => currentUser && currentUser.hasStarred(b._id));
|
||||
} else if (sel === 'templates') {
|
||||
list = list.filter((b) => b.type === 'template-container');
|
||||
} else if (sel === 'remaining') {
|
||||
// Remaining only shows boards not assigned to any workspace.
|
||||
list = list.filter(
|
||||
(b) => !assignments[b._id] && b.type !== 'template-container',
|
||||
);
|
||||
} else {
|
||||
// Workspace view includes all boards in that workspace, including starred.
|
||||
list = list.filter((b) => assignments[b._id] === sel);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentUser && typeof currentUser.sortBoardsForUser === 'function') {
|
||||
@@ -650,6 +719,46 @@ Template.boardList.helpers({
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
return currentUser && !currentUser.isCommentOnly();
|
||||
},
|
||||
// #5799: current board-name search text (for the input value and the clear button).
|
||||
boardSearch() {
|
||||
return Template.instance().boardSearchVar.get();
|
||||
},
|
||||
// #5799: pagination controls (only shown in the sorted, non-custom modes).
|
||||
boardsPaginationActive() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const sortMode =
|
||||
currentUser && typeof currentUser.getAllBoardsSortBy === 'function'
|
||||
? currentUser.getAllBoardsSortBy()
|
||||
: 'custom';
|
||||
if (sortMode === 'custom') return false;
|
||||
const total = Template.instance().pagedBoardsVar.get().total || 0;
|
||||
return total > BOARDS_PER_PAGE;
|
||||
},
|
||||
boardsCurrentPage() {
|
||||
return Template.instance().boardsPageVar.get();
|
||||
},
|
||||
boardsTotalPages() {
|
||||
const total = Template.instance().pagedBoardsVar.get().total || 0;
|
||||
return Math.max(1, Math.ceil(total / BOARDS_PER_PAGE));
|
||||
},
|
||||
hasBoardsPrevPage() {
|
||||
return Template.instance().boardsPageVar.get() > 1;
|
||||
},
|
||||
hasBoardsNextPage() {
|
||||
const tpl = Template.instance();
|
||||
const total = tpl.pagedBoardsVar.get().total || 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / BOARDS_PER_PAGE));
|
||||
return tpl.boardsPageVar.get() < totalPages;
|
||||
},
|
||||
// #5799: current All Boards sort mode ('custom' | 'title-asc' | 'title-desc').
|
||||
isBoardsSort(mode) {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const current =
|
||||
currentUser && typeof currentUser.getAllBoardsSortBy === 'function'
|
||||
? currentUser.getAllBoardsSortBy()
|
||||
: 'custom';
|
||||
return current === mode;
|
||||
},
|
||||
hasBoardsSelected() {
|
||||
return BoardMultiSelection.count() > 0;
|
||||
},
|
||||
@@ -745,6 +854,41 @@ Template.boardList.events({
|
||||
Popup.open('createBoard')(evt);
|
||||
}
|
||||
},
|
||||
// #5799: choose how the All Boards page is sorted.
|
||||
'click .js-open-boards-sort': Popup.open('boardsSort'),
|
||||
// #5799: search boards by name across all categories.
|
||||
'input .js-board-search-input'(evt, tpl) {
|
||||
tpl.boardSearchVar.set(evt.currentTarget.value);
|
||||
},
|
||||
'keydown .js-board-search-input'(evt, tpl) {
|
||||
// Esc clears the search.
|
||||
if (evt.keyCode === 27) {
|
||||
tpl.boardSearchVar.set('');
|
||||
evt.currentTarget.value = '';
|
||||
}
|
||||
},
|
||||
'click .js-board-search-clear'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.boardSearchVar.set('');
|
||||
const input = tpl.find('.js-board-search-input');
|
||||
if (input) {
|
||||
input.value = '';
|
||||
input.focus();
|
||||
}
|
||||
},
|
||||
// #5799: board grid pagination (sorted modes only).
|
||||
'click .js-boards-prev-page'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const page = tpl.boardsPageVar.get();
|
||||
if (page > 1) tpl.boardsPageVar.set(page - 1);
|
||||
},
|
||||
'click .js-boards-next-page'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const total = tpl.pagedBoardsVar.get().total || 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / BOARDS_PER_PAGE));
|
||||
const page = tpl.boardsPageVar.get();
|
||||
if (page < totalPages) tpl.boardsPageVar.set(page + 1);
|
||||
},
|
||||
'click .js-star-board'(evt) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
@@ -1157,6 +1301,30 @@ Template.boardList.events({
|
||||
},
|
||||
});
|
||||
|
||||
// #5799: All Boards sort popup — pick custom (manual drag order) or
|
||||
// alphabetical A→Z / Z→A. The choice is stored per user.
|
||||
Template.boardsSortPopup.helpers({
|
||||
isBoardsSort(mode) {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const current =
|
||||
currentUser && typeof currentUser.getAllBoardsSortBy === 'function'
|
||||
? currentUser.getAllBoardsSortBy()
|
||||
: 'custom';
|
||||
return current === mode;
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardsSortPopup.events({
|
||||
'click .js-boards-sort'(evt) {
|
||||
evt.preventDefault();
|
||||
const mode = evt.currentTarget.getAttribute('data-sort');
|
||||
if (mode) {
|
||||
Meteor.call('setAllBoardsSortBy', mode);
|
||||
}
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
|
||||
Template.workspaceActionsPopup.helpers({
|
||||
workspaceName() {
|
||||
return this.name || '';
|
||||
|
||||
@@ -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;
|
||||
@@ -1081,3 +1087,14 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
line-height: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* #6431 follow-up: cover image on the opened card (Card Settings > Cover image
|
||||
on card, opt-in). Mirrors the minicard cover but taller. */
|
||||
.card-details-cover {
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
height: 200px;
|
||||
user-select: none;
|
||||
margin: 8px 0;
|
||||
}
|
||||
@@ -112,6 +112,11 @@ template(name="cardDetails")
|
||||
i.fa.fa-check
|
||||
span {{_ 'upload-completed'}}
|
||||
|
||||
//- #6431 follow-up: cover image on the opened card (opt-in, OFF by default).
|
||||
if currentBoard.allowsCoverAttachmentOnCard
|
||||
if cover
|
||||
.card-details-cover(style="background-image: url('{{cover.link 'original'}}');")
|
||||
|
||||
.card-details-left
|
||||
|
||||
.card-details-items
|
||||
@@ -224,6 +229,9 @@ template(name="cardDetails")
|
||||
unless currentUser.isWorker
|
||||
a.card-label.add-label.js-due-date
|
||||
i.fa.fa-plus
|
||||
if dueDateChangeCount
|
||||
.card-details-due-date-changes(title="{{_ 'due-date-changes'}}")
|
||||
| {{_ 'due-date-changed-times' dueDateChangeCount}}
|
||||
|
||||
if currentBoard.allowsEndDate
|
||||
.card-details-item.card-details-item-end
|
||||
@@ -681,6 +689,14 @@ template(name="cardDetails")
|
||||
.card-checklist-attachmentGallery.card-checklists
|
||||
if currentBoard.allowsChecklists
|
||||
hr
|
||||
//- Total checklist item count (finished/total) shown above all
|
||||
checklists. The board flag is a cheap boolean, so when it is off
|
||||
the checklist counting below is never evaluated.
|
||||
if currentBoard.allowsChecklistCountBadgeOnCard
|
||||
if checklists.length
|
||||
h3.card-details-item-title
|
||||
i.fa.fa-check-square-o
|
||||
| {{checklistFinishedCount}}/{{checklistItemCount}}
|
||||
+checklists(cardId = _id card = this)
|
||||
if currentBoard.allowsSubtasks
|
||||
hr
|
||||
@@ -690,6 +706,8 @@ template(name="cardDetails")
|
||||
h3.card-details-item-title
|
||||
i.fa.fa-paperclip
|
||||
| {{_ 'attachments'}}
|
||||
if currentBoard.allowsAttachmentCountOnCard
|
||||
| ({{attachments.length}})
|
||||
if Meteor.settings.public.attachmentsUploadMaxSize
|
||||
| {{_ 'max-upload-filesize'}} {{Meteor.settings.public.attachmentsUploadMaxSize}}
|
||||
br
|
||||
@@ -945,22 +963,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 +998,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 +1033,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 +1068,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;
|
||||
|
||||
@@ -215,6 +215,15 @@ template(name="minicard")
|
||||
i.fa.fa-globe
|
||||
span.badge-text.check-list-text {{subtasksFinishedCount}}/{{allSubtasksCount}}
|
||||
//{{subtasksFinishedCount}}/{{subtasksCount}} does not work because when a subtaks is archived, the count goes down
|
||||
//- #6431: compact checklist item-count badge (finished/total). The outer
|
||||
guard is a cheap boolean, so when the setting is off the checklist
|
||||
counting below never runs.
|
||||
if showChecklistCountBadge
|
||||
if checklists.length
|
||||
.badge(class="{{#if checklistFinished}}is-finished{{/if}}" title="{{_ 'checklists'}}")
|
||||
span.badge-icon
|
||||
i.fa.fa-check-square-o
|
||||
span.badge-text.check-list-text {{checklistFinishedCount}}/{{checklistItemCount}}
|
||||
if currentBoard.allowsCardSortingByNumber
|
||||
if currentBoard.allowsCardSortingByNumberOnMinicard
|
||||
.badge
|
||||
|
||||
@@ -144,6 +144,14 @@ Template.minicard.helpers({
|
||||
const board = this.board();
|
||||
return getMinicardFlag(board, 'allowsSubtasksOnMinicard', 'allowsSubtasks', true);
|
||||
},
|
||||
// #6431: compact checklist item-count badge (finished/total) on the minicard.
|
||||
// Opt-in, OFF by default. Only a cheap boolean read here; the actual checklist
|
||||
// counting (checklistFinishedCount/checklistItemCount) is gated behind this in
|
||||
// the template, so a board with the badge off spends no resources counting.
|
||||
showChecklistCountBadge() {
|
||||
const board = this.board();
|
||||
return !!(board && board.allowsChecklistCountBadgeOnMinicard);
|
||||
},
|
||||
|
||||
hiddenMinicardLabelText() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
@@ -209,7 +217,11 @@ Template.minicard.helpers({
|
||||
// Show checklist if either:
|
||||
// 1. Board-wide setting is enabled, OR
|
||||
// 2. This specific checklist has the setting enabled
|
||||
if (currentBoard.allowsChecklistAtMinicard || checklist.showChecklistAtMinicard) {
|
||||
// #5565: use the same board field the sidebar toggle writes
|
||||
// (`allowsChecklistsOnMinicard`); this previously checked a different,
|
||||
// UI-less field (`allowsChecklistAtMinicard`), so the board toggle to show
|
||||
// checklists on minicards had no effect.
|
||||
if (currentBoard.allowsChecklistsOnMinicard || checklist.showChecklistAtMinicard) {
|
||||
visibleChecklists.push(checklist);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import Swimlanes from '/models/swimlanes';
|
||||
import { Filter } from '/client/lib/filter';
|
||||
import { MultiSelection } from '/client/lib/multiSelection';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { isLinkableCardTarget } from '/models/lib/linkedCardTarget';
|
||||
import autosize from 'autosize';
|
||||
|
||||
// SubsManager removed for Meteor 3 migration
|
||||
@@ -324,7 +325,20 @@ Template.listBody.helpers({
|
||||
const tpl = Template.instance();
|
||||
const limit = tpl.cardlimit.get();
|
||||
const defaultSort = { sort: 1 };
|
||||
const sortBy = Session.get('sortBy') ? Session.get('sortBy') : defaultSort;
|
||||
// Session is reset on page reload; fall back to the persisted card sort so a
|
||||
// chosen sort (e.g. by due date) is remembered across reloads (#5886).
|
||||
let sortBy = Session.get('sortBy');
|
||||
if (!sortBy) {
|
||||
try {
|
||||
const stored = window.localStorage.getItem('wekan-cards-sortBy');
|
||||
if (stored) {
|
||||
sortBy = JSON.parse(stored);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
if (!sortBy) {
|
||||
sortBy = defaultSort;
|
||||
}
|
||||
const selector = {
|
||||
listId: Template.currentData()._id,
|
||||
archived: false,
|
||||
@@ -693,7 +707,10 @@ Template.linkCardPopup.helpers({
|
||||
archived: false,
|
||||
linkedId: { $nin: ownCardsIds },
|
||||
_id: { $nin: ownCardsIds },
|
||||
type: { $nin: ['template-card'] },
|
||||
// #5808: never offer an existing linked card/board as a link target —
|
||||
// linking to one builds a chain of linkedId pointers that renders the
|
||||
// card inaccessible. Only real cards may be linked.
|
||||
type: { $nin: ['template-card', 'cardType-linkedCard', 'cardType-linkedBoard'] },
|
||||
};
|
||||
if (tpl.selectedBoardId.get()) selector.boardId = tpl.selectedBoardId.get();
|
||||
if (tpl.selectedSwimlaneId.get()) selector.swimlaneId = tpl.selectedSwimlaneId.get();
|
||||
@@ -743,11 +760,48 @@ Template.linkCardPopup.events({
|
||||
tpl.selectedListId.set($(evt.currentTarget).val());
|
||||
},
|
||||
async 'click .js-done'(evt, tpl) {
|
||||
// LINK CARD
|
||||
// LINK CARD (or, when no card is chosen, fall back to a board-level link
|
||||
// so a whole board can be linked even when that board already has cards).
|
||||
// https://github.com/wekan/wekan/issues/5715
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
const linkedId = $('.js-select-cards option:selected').val();
|
||||
if (!linkedId) {
|
||||
const boardId = $('.js-select-boards option:selected').val();
|
||||
// No board and no card selected: nothing to link.
|
||||
if (!boardId) {
|
||||
Popup.back();
|
||||
return;
|
||||
}
|
||||
// A linkedBoard card for this board already exists; avoid duplicates.
|
||||
if (ReactiveCache.getCard({ linkedId: boardId, archived: false })) {
|
||||
Popup.back();
|
||||
return;
|
||||
}
|
||||
const boardCardNumber = await tpl.board.getNextCardNumber();
|
||||
const boardSortIndex = tpl.getSortIndex();
|
||||
const boardCardId = Cards.insert({
|
||||
title: $('.js-select-boards option:selected').text(), //dummy
|
||||
listId: tpl.listId,
|
||||
swimlaneId: tpl.swimlaneId,
|
||||
boardId: tpl.boardId,
|
||||
sort: boardSortIndex,
|
||||
type: 'cardType-linkedBoard',
|
||||
linkedId: boardId,
|
||||
cardNumber: boardCardNumber,
|
||||
});
|
||||
Filter.addException(boardCardId);
|
||||
Popup.back();
|
||||
return;
|
||||
}
|
||||
// #5808: refuse to link to a linked card/board or to a card that links back
|
||||
// to this board — those build a chain/cycle of linkedId pointers that renders
|
||||
// the cards inaccessible. The <select> already filters these out, but its
|
||||
// options can be stale, so re-check the resolved target here.
|
||||
const targetCard = ReactiveCache.getCard(linkedId);
|
||||
const ownCardsIds = tpl.board.cards().map(card => card.getRealId());
|
||||
if (!isLinkableCardTarget(targetCard, ownCardsIds)) {
|
||||
alert(TAPi18n.__('error-linked-card-not-allowed'));
|
||||
Popup.back();
|
||||
return;
|
||||
}
|
||||
@@ -924,13 +978,37 @@ Template.searchElementPopup.events({
|
||||
if (!tpl.isTemplateSearch || tpl.isCardTemplateSearch) {
|
||||
// Card insertion
|
||||
// 1. Common
|
||||
// Compute the sort index BEFORE the first await: getSortIndex() reads
|
||||
// Template.currentData(), and Blaze's current-view context is lost across
|
||||
// an await, so calling it afterwards threw "There is no current view" and
|
||||
// no card was created from the template. Capture it while still in the
|
||||
// synchronous event/view context.
|
||||
const sortIndex = tpl.getSortIndex();
|
||||
element.cardNumber = await tpl.board.getNextCardNumber();
|
||||
element.sort = tpl.getSortIndex();
|
||||
element.sort = sortIndex;
|
||||
// 1.A From template
|
||||
if (tpl.isTemplateSearch) {
|
||||
element.type = 'cardType-card';
|
||||
element.linkedId = '';
|
||||
_id = await element.copy(tpl.boardId, tpl.swimlaneId, tpl.listId);
|
||||
// #5798: instantiate the template into the CURRENT board, not the
|
||||
// templates board. For a template search tpl.boardId is the templates
|
||||
// board (it is the search source), so copying with it created the card
|
||||
// with boardId = templates board. The card still showed in the target
|
||||
// list (the list query is by listId and the templates board is
|
||||
// subscribed), but clicking it navigated to the templates board instead
|
||||
// of opening the card. The target list/swimlane were already resolved on
|
||||
// the current board.
|
||||
// Instantiate into the board that OWNS the target list (where the user
|
||||
// clicked "add card"). Deriving it from tpl.listId is reliable even when
|
||||
// Utils.getCurrentBoard() (route/session state) has not settled yet — as
|
||||
// on the slower CI production bundle, where falling back to tpl.boardId
|
||||
// wrongly created the card on the templates board.
|
||||
const targetList = ReactiveCache.getList(tpl.listId);
|
||||
const targetBoardId =
|
||||
(targetList || {}).boardId ||
|
||||
(Utils.getCurrentBoard() || {})._id ||
|
||||
tpl.boardId;
|
||||
_id = await element.copy(targetBoardId, tpl.swimlaneId, tpl.listId);
|
||||
// 1.B Linked card
|
||||
} else {
|
||||
_id = element.link(tpl.boardId, tpl.swimlaneId, tpl.listId);
|
||||
|
||||
@@ -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;
|
||||
@@ -554,7 +558,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile - make all text and icons 2x bigger above #content by default */
|
||||
/* Mobile - keep header text/icons at normal size (reverted #6426 oversizing) */
|
||||
@media screen and (max-width: 800px),
|
||||
screen and (max-device-width: 800px),
|
||||
screen and (-webkit-min-device-pixel-ratio: 2) and (max-width: 800px),
|
||||
@@ -569,7 +573,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
overflow: hidden !important; /* Prevent content overflow */
|
||||
}
|
||||
#header-quick-access {
|
||||
font-size: 2em !important; /* 2x bigger base font size */
|
||||
font-size: 1em !important; /* Normal size (reverted #6426 oversizing) */
|
||||
}
|
||||
|
||||
#header-quick-access * {
|
||||
@@ -578,7 +582,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
|
||||
#header-quick-access .fa,
|
||||
#header-quick-access .icon {
|
||||
font-size: 2em !important; /* 2x bigger icons */
|
||||
font-size: 1em !important; /* Normal icon size (reverted #6426 oversizing) */
|
||||
}
|
||||
|
||||
#header-quick-access .home-icon a {
|
||||
@@ -648,7 +652,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile All Boards page - make logo row elements 2x bigger */
|
||||
/* Mobile All Boards page - keep logo row at normal size (reverted #6426 oversizing) */
|
||||
@media screen and (max-width: 800px),
|
||||
screen and (max-device-width: 800px),
|
||||
screen and (-webkit-min-device-pixel-ratio: 2) and (max-width: 800px),
|
||||
@@ -656,16 +660,16 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
screen and (max-width: 800px) and (orientation: landscape) {
|
||||
.wrapper ~ #header-quick-access,
|
||||
body:not(.board-view) #header-quick-access {
|
||||
font-size: 2em !important; /* 2x bigger base font size for logo row */
|
||||
font-size: 1em !important; /* Normal size (reverted #6426 oversizing) */
|
||||
}
|
||||
|
||||
/* iPhone 12 Mini specific - 3x bigger for All Boards page */
|
||||
/* iPhone 12 Mini specific - keep All Boards logo row at normal size (reverted #6426) */
|
||||
@media screen and (device-width: 375px) and (device-height: 812px), /* iPhone 12 Mini exact */
|
||||
screen and (max-width: 375px) and (max-height: 812px), /* iPhone 12 Mini viewport */
|
||||
screen and (-webkit-min-device-pixel-ratio: 3) and (max-width: 375px) /* iPhone 12 Mini Retina */ {
|
||||
.wrapper ~ #header-quick-access,
|
||||
body:not(.board-view) #header-quick-access {
|
||||
font-size: 3em !important; /* 3x bigger base font size for iPhone 12 Mini All Boards page */
|
||||
font-size: 1em !important; /* Normal size (reverted #6426 oversizing) */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -678,7 +682,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
.wrapper ~ #header-quick-access .icon,
|
||||
body:not(.board-view) #header-quick-access .fa,
|
||||
body:not(.board-view) #header-quick-access .icon {
|
||||
font-size: 2em !important; /* 2x bigger icons in logo row */
|
||||
font-size: 1em !important; /* Normal icon size (reverted #6426 oversizing) */
|
||||
}
|
||||
|
||||
.wrapper ~ #header-quick-access .home-icon a,
|
||||
@@ -737,19 +741,19 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
}
|
||||
}
|
||||
|
||||
/* iPhone 12 Mini specific - make header elements 3x bigger */
|
||||
/* iPhone 12 Mini specific - keep the header a normal compact single row */
|
||||
@media screen and (device-width: 375px) and (device-height: 812px), /* iPhone 12 Mini exact */
|
||||
screen and (max-width: 375px) and (max-height: 812px), /* iPhone 12 Mini viewport */
|
||||
screen and (-webkit-min-device-pixel-ratio: 3) and (max-width: 375px), /* iPhone 12 Mini Retina */
|
||||
screen and (max-width: 375px) and (orientation: portrait), /* iPhone 12 Mini portrait */
|
||||
screen and (max-width: 375px) and (orientation: landscape) /* iPhone 12 Mini landscape */ {
|
||||
#header-quick-access {
|
||||
font-size: 3em !important; /* 3x bigger base font size for iPhone 12 Mini */
|
||||
height: auto !important; /* Allow height to grow */
|
||||
min-height: 84px !important; /* Much taller minimum height for iPhone 12 Mini */
|
||||
flex-wrap: wrap !important; /* Force wrapping */
|
||||
align-items: flex-start !important; /* Align to top when wrapping */
|
||||
padding: 18px 0px !important; /* More padding for iPhone 12 Mini */
|
||||
font-size: 1em !important; /* Normal size (reverted #6426 oversizing) */
|
||||
height: 48px !important;
|
||||
min-height: 48px !important;
|
||||
flex-wrap: nowrap !important;
|
||||
align-items: center !important;
|
||||
padding: 8px 0px !important;
|
||||
}
|
||||
|
||||
#header-quick-access * {
|
||||
@@ -758,7 +762,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
|
||||
#header-quick-access .fa,
|
||||
#header-quick-access .icon {
|
||||
font-size: 3em !important; /* 3x bigger icons for iPhone 12 Mini */
|
||||
font-size: 1em !important; /* Normal icon size (reverted #6426 oversizing) */
|
||||
}
|
||||
|
||||
#header-quick-access .home-icon a {
|
||||
@@ -844,7 +848,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
}
|
||||
}
|
||||
|
||||
/* iPhone 12 Mini and very small screens - make header elements much larger */
|
||||
/* iPhone 12 Mini and very small screens - header element sizing */
|
||||
@media screen and (max-width: 400px) and (max-height: 900px),
|
||||
screen and (max-device-width: 400px) and (max-device-height: 900px),
|
||||
screen and (-webkit-min-device-pixel-ratio: 2) and (max-width: 400px),
|
||||
@@ -908,12 +912,12 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
|
||||
/* Fallback for iPhone devices using JavaScript detection */
|
||||
.iphone-device #header-quick-access {
|
||||
font-size: 2em !important; /* 2x bigger base font size */
|
||||
height: auto !important; /* Allow height to grow */
|
||||
min-height: 48px !important; /* Minimum height for mobile */
|
||||
flex-wrap: wrap !important; /* Force wrapping */
|
||||
align-items: flex-start !important; /* Align to top when wrapping */
|
||||
padding: 8px 0px !important; /* Adjust padding for mobile */
|
||||
font-size: 1em !important; /* Normal size (reverted #6426 oversizing) */
|
||||
height: 48px !important;
|
||||
min-height: 48px !important;
|
||||
flex-wrap: nowrap !important;
|
||||
align-items: center !important;
|
||||
padding: 8px 0px !important;
|
||||
}
|
||||
|
||||
.iphone-device #header-quick-access * {
|
||||
@@ -922,7 +926,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
|
||||
.iphone-device #header-quick-access .fa,
|
||||
.iphone-device #header-quick-access .icon {
|
||||
font-size: 2em !important; /* 2x bigger icons */
|
||||
font-size: 1em !important; /* Normal icon size (reverted #6426 oversizing) */
|
||||
}
|
||||
|
||||
.iphone-device #header-quick-access .home-icon a {
|
||||
@@ -991,14 +995,14 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
/* iPhone 12 Mini specific - JavaScript detection fallback */
|
||||
/* iPhone 12 Mini specific - JavaScript detection fallback (normal compact row) */
|
||||
.iphone-device #header-quick-access {
|
||||
font-size: 3em !important; /* 3x bigger base font size for iPhone 12 Mini */
|
||||
height: auto !important; /* Allow height to grow */
|
||||
min-height: 84px !important; /* Much taller minimum height for iPhone 12 Mini */
|
||||
flex-wrap: wrap !important; /* Force wrapping */
|
||||
align-items: flex-start !important; /* Align to top when wrapping */
|
||||
padding: 18px 0px !important; /* More padding for iPhone 12 Mini */
|
||||
font-size: 1em !important; /* Normal size (reverted #6426 oversizing) */
|
||||
height: 48px !important;
|
||||
min-height: 48px !important;
|
||||
flex-wrap: nowrap !important;
|
||||
align-items: center !important;
|
||||
padding: 8px 0px !important;
|
||||
}
|
||||
|
||||
.iphone-device #header-quick-access * {
|
||||
@@ -1007,7 +1011,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
|
||||
.iphone-device #header-quick-access .fa,
|
||||
.iphone-device #header-quick-access .icon {
|
||||
font-size: 3em !important; /* 3x bigger icons for iPhone 12 Mini */
|
||||
font-size: 1em !important; /* Normal icon size (reverted #6426 oversizing) */
|
||||
}
|
||||
|
||||
.iphone-device #header-quick-access .home-icon a {
|
||||
@@ -1092,10 +1096,10 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
/* iPhone 12 Mini All Boards page - make logo row elements 3x bigger */
|
||||
/* iPhone 12 Mini All Boards page - keep logo row at normal size (reverted #6426) */
|
||||
.iphone-device .wrapper ~ #header-quick-access,
|
||||
.iphone-device body:not(.board-view) #header-quick-access {
|
||||
font-size: 3em !important; /* 3x bigger base font size for logo row */
|
||||
font-size: 1em !important; /* Normal size (reverted #6426 oversizing) */
|
||||
}
|
||||
|
||||
.iphone-device .wrapper ~ #header-quick-access *,
|
||||
@@ -1107,7 +1111,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
.iphone-device .wrapper ~ #header-quick-access .icon,
|
||||
.iphone-device body:not(.board-view) #header-quick-access .fa,
|
||||
.iphone-device body:not(.board-view) #header-quick-access .icon {
|
||||
font-size: 2em !important; /* 2x bigger icons in logo row */
|
||||
font-size: 1em !important; /* Normal icon size (reverted #6426 oversizing) */
|
||||
}
|
||||
|
||||
.iphone-device .wrapper ~ #header-quick-access .home-icon a,
|
||||
@@ -1205,7 +1209,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
.iphone-device #header-quick-access,
|
||||
.iphone-device .wrapper ~ #header-quick-access,
|
||||
.iphone-device body:not(.board-view) #header-quick-access {
|
||||
font-size: 1.2em !important;
|
||||
font-size: 1em !important;
|
||||
}
|
||||
body:not(.board-view) #header-quick-access .fa,
|
||||
body:not(.board-view) #header-quick-access .icon,
|
||||
@@ -1217,7 +1221,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
.iphone-device .wrapper ~ #header-quick-access .icon,
|
||||
.iphone-device body:not(.board-view) #header-quick-access .fa,
|
||||
.iphone-device body:not(.board-view) #header-quick-access .icon {
|
||||
font-size: 1.5em !important;
|
||||
font-size: 1.2em !important;
|
||||
}
|
||||
/* Zoom (100%), drag-handle toggle and mobile/desktop toggle 30% smaller than
|
||||
the other quick-access controls at small width — for all browsers. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -52,6 +52,13 @@ Template.userFormsLayout.onRendered(() => {
|
||||
// gesture works the same as on the board swimlanes view.
|
||||
enablePageDragscroll();
|
||||
|
||||
// Mark the auth pages so CSS can let <body> scroll as a normal document.
|
||||
// body.mobile-mode locks <body> to position:fixed/100vh (an iOS board-view
|
||||
// anti-bounce fix); without an inner #content scroller that traps these pages
|
||||
// at viewport height, hiding the sign-up link / language selector below the
|
||||
// form (and they sit behind the on-screen keyboard). See userForm.css.
|
||||
document.body.classList.add('userform-layout');
|
||||
|
||||
Meteor.call('getAuthenticationsEnabled', (_, result) => {
|
||||
let enabledAuthenticationMethods = ['password']; // we show/hide this based on isPasswordLoginEnabled
|
||||
|
||||
@@ -200,6 +207,7 @@ Template.userFormsLayout.onDestroyed(() => {
|
||||
// Stop drag-scrolling <body> after leaving login / register (e.g. once logged
|
||||
// in) so other layouts keep their normal behaviour.
|
||||
disablePageDragscroll();
|
||||
document.body.classList.remove('userform-layout');
|
||||
});
|
||||
|
||||
Template.userFormsLayout.helpers({
|
||||
@@ -267,7 +275,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) {
|
||||
@@ -309,6 +322,31 @@ Template.defaultLayout.onRendered(function () {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Drag-to-scroll for every whole-page layout rendered inside defaultLayout
|
||||
// (All Boards, My Cards, Due Cards, Global Search, Public, Bookmarks, Broken
|
||||
// Cards, Settings, People, Admin, Import, board Rules, …). These pages scroll
|
||||
// <body> on desktop and #content in mobile mode (where <body> is
|
||||
// position:fixed), so enablePageDragscroll() tags whichever element actually
|
||||
// scrolls. The board-canvas pages are the exception: their own
|
||||
// `.board-canvas.dragscroll` handles dragging, and the canvas does its own
|
||||
// vertical/horizontal scroll, so page-level tagging is turned off there to
|
||||
// avoid scrolling #content and the canvas at the same time. defaultLayout is
|
||||
// persistent (only its `content` swaps), so this single autorun covers every
|
||||
// route without per-template wiring. Login / Register use userFormsLayout and
|
||||
// keep their own enable/disable. Works the same in mobile and desktop mode.
|
||||
const boardCanvasRoutes = ['board', 'board-short', 'card'];
|
||||
this.autorun(() => {
|
||||
FlowRouter.watchPathChange();
|
||||
const onBoardCanvas = boardCanvasRoutes.includes(FlowRouter.getRouteName());
|
||||
Tracker.afterFlush(() => {
|
||||
if (onBoardCanvas) {
|
||||
disablePageDragscroll();
|
||||
} else {
|
||||
enablePageDragscroll();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function authentication(event, templateInstance) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { CardSearchPaged } from '../../lib/cardSearch';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { enablePageDragscroll, disablePageDragscroll } from '/client/lib/pageDragscroll';
|
||||
|
||||
Template.myCardsHeaderBar.helpers({
|
||||
myCardsSort() {
|
||||
@@ -41,15 +40,8 @@ Template.myCards.onCreated(function () {
|
||||
Meteor.subscribe('setting');
|
||||
});
|
||||
|
||||
Template.myCards.onRendered(function () {
|
||||
// My Cards renders into <body>'s page scroll area (.wrapper inside #content),
|
||||
// so enable drag-to-scroll on <body> like the All Boards page does.
|
||||
enablePageDragscroll();
|
||||
});
|
||||
|
||||
Template.myCards.onDestroyed(function () {
|
||||
disablePageDragscroll();
|
||||
});
|
||||
// Drag-to-scroll is handled centrally for all non-board pages by
|
||||
// defaultLayout's route-aware autorun (see client/components/main/layouts.js).
|
||||
|
||||
Template.myCards.helpers({
|
||||
userId() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@ import AccessibilitySettings from '/models/accessibilitySettings';
|
||||
import AccountSettings from '/models/accountSettings';
|
||||
import Announcements from '/models/announcements';
|
||||
import Settings from '/models/settings';
|
||||
import { resolveDefaultAuthenticationMethod } from '/models/lib/authenticationMethod';
|
||||
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
|
||||
// import {
|
||||
// cronMigrationProgress,
|
||||
@@ -616,6 +617,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');
|
||||
},
|
||||
@@ -793,7 +804,17 @@ Template.setting.events({
|
||||
$('input[name=hideBoardMemberList]:checked').val() === 'true';
|
||||
const displayAuthenticationMethod =
|
||||
$('input[name=displayAuthenticationMethod]:checked').val() === 'true';
|
||||
const defaultAuthenticationMethod = $('#defaultAuthenticationMethod').val();
|
||||
// #5879: the <select> options are populated by an async Meteor.call, so its
|
||||
// value can still be '' / null when Save is clicked. Saving that empty value
|
||||
// over the required `defaultAuthenticationMethod` string silently failed
|
||||
// validation, so the Layout save looked like it hung / did nothing. Fall back
|
||||
// to the currently stored method so a real value is never overwritten by ''.
|
||||
const currentDefaultAuthenticationMethod =
|
||||
ReactiveCache.getCurrentSetting()?.defaultAuthenticationMethod;
|
||||
const defaultAuthenticationMethod = resolveDefaultAuthenticationMethod(
|
||||
$('#defaultAuthenticationMethod').val(),
|
||||
currentDefaultAuthenticationMethod,
|
||||
);
|
||||
const spinnerName = ($('#spinnerName').val() || '').trim();
|
||||
|
||||
try {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -82,6 +82,14 @@ body.grey-icons-enabled .boardSubtaskSettingsPopup .materialCheckBox.is-checked
|
||||
grid-template-columns: 1fr 1fr 2fr;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
/* Keep the "Show on Card / Show on Minicard / Description" titles visible
|
||||
while the rows below scroll (the popup scrolls in .pop-over .content-wrapper). */
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: #fff;
|
||||
padding: 5px 0;
|
||||
border-bottom: 2px solid #ddd;
|
||||
}
|
||||
|
||||
.card-settings-row {
|
||||
@@ -261,9 +269,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
|
||||
@@ -309,21 +315,12 @@ template(name="boardCardSettingsPopup")
|
||||
a.flex.js-field-has-creator(title="{{_ 'creator'}}" class="{{#if allowsCreator}}is-checked{{/if}}")
|
||||
i.fa(class="{{#if allowsCreator}}fa-check{{else}}fa-square-o{{/if}}")
|
||||
.card-settings-column
|
||||
span
|
||||
.card-settings-column
|
||||
span
|
||||
i.fa.fa-user
|
||||
| {{_ 'creator'}}
|
||||
.card-settings-row
|
||||
.card-settings-column
|
||||
span
|
||||
.card-settings-column
|
||||
a.flex.js-field-has-creator-on-minicard(title="{{_ 'creator-on-minicard'}}" class="{{#if allowsCreatorOnMinicard}}is-checked{{/if}}")
|
||||
a.flex.js-field-has-creator-on-minicard(title="{{_ 'creator'}}" class="{{#if allowsCreatorOnMinicard}}is-checked{{/if}}")
|
||||
i.fa(class="{{#if allowsCreatorOnMinicard}}fa-check{{else}}fa-square-o{{/if}}")
|
||||
.card-settings-column
|
||||
span
|
||||
i.fa.fa-user
|
||||
| {{_ 'creator-on-minicard'}}
|
||||
| {{_ 'creator'}}
|
||||
.card-settings-row
|
||||
.card-settings-column
|
||||
a.flex.js-field-has-assignee(title="{{_ 'assignee'}}" class="{{#if allowsAssignee}}is-checked{{/if}}")
|
||||
@@ -363,27 +360,19 @@ template(name="boardCardSettingsPopup")
|
||||
a.flex.js-field-has-card-sorting-by-number(title="{{_ 'card-sorting-by-number'}}" class="{{#if allowsCardSortingByNumber}}is-checked{{/if}}")
|
||||
i.fa(class="{{#if allowsCardSortingByNumber}}fa-check{{else}}fa-square-o{{/if}}")
|
||||
.card-settings-column
|
||||
span
|
||||
a.flex.js-field-has-card-sorting-by-number-on-minicard(title="{{_ 'card-sorting-by-number'}}" class="{{#if allowsCardSortingByNumberOnMinicard}}is-checked{{/if}}")
|
||||
i.fa(class="{{#if allowsCardSortingByNumberOnMinicard}}fa-check{{else}}fa-square-o{{/if}}")
|
||||
.card-settings-column
|
||||
span
|
||||
i.fa.fa-sort-numeric-asc
|
||||
| {{_ 'card-sorting-by-number'}}
|
||||
.card-settings-row
|
||||
.card-settings-column
|
||||
span
|
||||
.card-settings-column
|
||||
a.flex.js-field-has-card-sorting-by-number-on-minicard(title="{{_ 'card-sorting-by-number-on-minicard'}}" class="{{#if allowsCardSortingByNumberOnMinicard}}is-checked{{/if}}")
|
||||
i.fa(class="{{#if allowsCardSortingByNumberOnMinicard}}fa-check{{else}}fa-square-o{{/if}}")
|
||||
.card-settings-column
|
||||
span
|
||||
i.fa.fa-sort-numeric-asc
|
||||
| {{_ 'card-sorting-by-number-on-minicard'}}
|
||||
.card-settings-row
|
||||
.card-settings-column
|
||||
a.flex.js-field-has-card-show-lists(title="{{_ 'card-show-lists'}}" class="{{#if allowsShowLists}}is-checked{{/if}}")
|
||||
i.fa(class="{{#if allowsShowLists}}fa-check{{else}}fa-square-o{{/if}}")
|
||||
.card-settings-column
|
||||
span
|
||||
a.flex.js-field-has-card-show-lists-on-minicard(title="{{_ 'card-show-lists'}}" class="{{#if allowsShowListsOnMinicard}}is-checked{{/if}}")
|
||||
i.fa(class="{{#if allowsShowListsOnMinicard}}fa-check{{else}}fa-square-o{{/if}}")
|
||||
.card-settings-column
|
||||
span
|
||||
i.fa.fa-list
|
||||
@@ -399,16 +388,6 @@ template(name="boardCardSettingsPopup")
|
||||
span
|
||||
i.fa.fa-tag
|
||||
| {{_ 'labels'}}
|
||||
.card-settings-row
|
||||
.card-settings-column
|
||||
span
|
||||
.card-settings-column
|
||||
a.flex.js-field-has-card-show-lists-on-minicard(title="{{_ 'card-show-lists-on-minicard'}}" class="{{#if allowsShowListsOnMinicard}}is-checked{{/if}}")
|
||||
i.fa(class="{{#if allowsShowListsOnMinicard}}fa-check{{else}}fa-square-o{{/if}}")
|
||||
.card-settings-column
|
||||
span
|
||||
i.fa.fa-list
|
||||
| {{_ 'card-show-lists-on-minicard'}}
|
||||
.card-settings-row
|
||||
.card-settings-column
|
||||
a.flex.js-field-has-card-number(title="{{_ 'card'}} {{_ 'number'}}" class="{{#if allowsCardNumber}}is-checked{{/if}}")
|
||||
@@ -456,6 +435,17 @@ template(name="boardCardSettingsPopup")
|
||||
span
|
||||
i.fa.fa-check
|
||||
| {{_ 'checklists'}}
|
||||
.card-settings-row
|
||||
.card-settings-column
|
||||
a.flex.js-field-has-checklist-count(title="{{_ 'checklist-count'}}" class="{{#if allowsChecklistCountBadgeOnCard}}is-checked{{/if}}")
|
||||
i.fa(class="{{#if allowsChecklistCountBadgeOnCard}}fa-check{{else}}fa-square-o{{/if}}")
|
||||
.card-settings-column
|
||||
a.flex.js-field-has-checklist-count-on-minicard(title="{{_ 'checklist-count'}}" class="{{#if allowsChecklistCountBadgeOnMinicard}}is-checked{{/if}}")
|
||||
i.fa(class="{{#if allowsChecklistCountBadgeOnMinicard}}fa-check{{else}}fa-square-o{{/if}}")
|
||||
.card-settings-column
|
||||
span
|
||||
i.fa.fa-check-square-o
|
||||
| {{_ 'checklist-count'}}
|
||||
.card-settings-row
|
||||
.card-settings-column
|
||||
a.flex.js-field-has-subtasks(title="{{_ 'subtasks'}}" class="{{#if allowsSubtasks}}is-checked{{/if}}")
|
||||
@@ -480,24 +470,26 @@ template(name="boardCardSettingsPopup")
|
||||
| {{_ 'attachments'}}
|
||||
.card-settings-row
|
||||
.card-settings-column
|
||||
span
|
||||
a.flex.js-field-has-attachment-count(title="{{_ 'attachment-count'}}" class="{{#if allowsAttachmentCountOnCard}}is-checked{{/if}}")
|
||||
i.fa(class="{{#if allowsAttachmentCountOnCard}}fa-check{{else}}fa-square-o{{/if}}")
|
||||
.card-settings-column
|
||||
a.flex.js-field-has-badge-attachment-on-minicard(title="{{_ 'badge-attachment-on-minicard'}}" class="{{#if allowsBadgeAttachmentOnMinicard}}is-checked{{/if}}")
|
||||
a.flex.js-field-has-badge-attachment-on-minicard(title="{{_ 'attachment-count'}}" class="{{#if allowsBadgeAttachmentOnMinicard}}is-checked{{/if}}")
|
||||
i.fa(class="{{#if allowsBadgeAttachmentOnMinicard}}fa-check{{else}}fa-square-o{{/if}}")
|
||||
.card-settings-column
|
||||
span
|
||||
i.fa.fa-paperclip
|
||||
| {{_ 'badge-attachment-on-minicard'}}
|
||||
| {{_ 'attachment-count'}}
|
||||
.card-settings-row
|
||||
.card-settings-column
|
||||
span
|
||||
a.flex.js-field-has-cover-attachment-on-card(title="{{_ 'cover-image'}}" class="{{#if allowsCoverAttachmentOnCard}}is-checked{{/if}}")
|
||||
i.fa(class="{{#if allowsCoverAttachmentOnCard}}fa-check{{else}}fa-square-o{{/if}}")
|
||||
.card-settings-column
|
||||
a.flex.js-field-has-cover-attachment-on-minicard(title="{{_ 'cover-attachment-on-minicard'}}" class="{{#if allowsCoverAttachmentOnMinicard}}is-checked{{/if}}")
|
||||
a.flex.js-field-has-cover-attachment-on-minicard(title="{{_ 'cover-image'}}" class="{{#if allowsCoverAttachmentOnMinicard}}is-checked{{/if}}")
|
||||
i.fa(class="{{#if allowsCoverAttachmentOnMinicard}}fa-check{{else}}fa-square-o{{/if}}")
|
||||
.card-settings-column
|
||||
span
|
||||
i.fa.fa-picture-o
|
||||
| {{_ 'cover-attachment-on-minicard'}}
|
||||
| {{_ 'cover-image'}}
|
||||
//div.check-div
|
||||
//
|
||||
a.flex.js-field-has-comments(class="{{#if allowsComments}}is-checked{{/if}}")
|
||||
|
||||
@@ -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) {
|
||||
@@ -1454,6 +1496,28 @@ Template.boardCardSettingsPopup.helpers({
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return getMinicardSetting(currentBoard, 'allowsChecklistsOnMinicard', 'allowsChecklists', true);
|
||||
},
|
||||
// #6431: compact checklist count badge on minicard, opt-in and OFF by default.
|
||||
allowsChecklistCountBadgeOnMinicard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.allowsChecklistCountBadgeOnMinicard : false;
|
||||
},
|
||||
// #6431 follow-up: new opt-in (OFF by default) card-side options.
|
||||
allowsChecklistCountBadgeOnCard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.allowsChecklistCountBadgeOnCard : false;
|
||||
},
|
||||
allowsAttachmentCountOnCard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.allowsAttachmentCountOnCard : false;
|
||||
},
|
||||
allowsCoverAttachmentOnCard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.allowsCoverAttachmentOnCard : false;
|
||||
},
|
||||
allowsAttachments() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
@@ -1748,6 +1812,26 @@ Template.boardCardSettingsPopup.events({
|
||||
const newValue = !tpl.currentBoard.allowsChecklistsOnMinicard;
|
||||
Boards.update(tpl.currentBoard._id, { $set: { allowsChecklistsOnMinicard: newValue } });
|
||||
},
|
||||
'click .js-field-has-checklist-count-on-minicard'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const newValue = !tpl.currentBoard.allowsChecklistCountBadgeOnMinicard;
|
||||
Boards.update(tpl.currentBoard._id, { $set: { allowsChecklistCountBadgeOnMinicard: newValue } });
|
||||
},
|
||||
'click .js-field-has-checklist-count'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const newValue = !tpl.currentBoard.allowsChecklistCountBadgeOnCard;
|
||||
Boards.update(tpl.currentBoard._id, { $set: { allowsChecklistCountBadgeOnCard: newValue } });
|
||||
},
|
||||
'click .js-field-has-attachment-count'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const newValue = !tpl.currentBoard.allowsAttachmentCountOnCard;
|
||||
Boards.update(tpl.currentBoard._id, { $set: { allowsAttachmentCountOnCard: newValue } });
|
||||
},
|
||||
'click .js-field-has-cover-attachment-on-card'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const newValue = !tpl.currentBoard.allowsCoverAttachmentOnCard;
|
||||
Boards.update(tpl.currentBoard._id, { $set: { allowsCoverAttachmentOnCard: newValue } });
|
||||
},
|
||||
'click .js-field-has-attachments'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const newValue = !tpl.currentBoard.allowsAttachments;
|
||||
|
||||
@@ -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'}}
|
||||
@@ -113,6 +113,21 @@ body.mobile-mode .auth-layout .auth-dialog .at-form .password-input-container .p
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
/* Auth pages (login / register) must scroll as a normal document on mobile so
|
||||
the content below the form — the sign-up / sign-in link, legal notice and
|
||||
language selector — is reachable, including when the on-screen keyboard is
|
||||
open. body.mobile-mode otherwise locks <body> to position:fixed + 100vh (an
|
||||
iOS board-view anti-bounce fix); the auth layout has no inner #content
|
||||
scroller, so that lock traps the page at viewport height and the on-screen
|
||||
keyboard hides the bottom. .userform-layout is added by userFormsLayout while
|
||||
these pages are mounted (see layouts.js). */
|
||||
body.mobile-mode.userform-layout {
|
||||
position: static !important;
|
||||
height: auto !important;
|
||||
min-height: 100vh;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
.auth-layout {
|
||||
width: 100%;
|
||||
@@ -120,6 +135,14 @@ body.mobile-mode .auth-layout .auth-dialog .at-form .password-input-container .p
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
}
|
||||
/* Let the auth layout/dialog grow to their content instead of being pinned to
|
||||
the viewport height, so everything below the form scrolls into view. */
|
||||
body.userform-layout .auth-layout {
|
||||
height: auto;
|
||||
}
|
||||
body.userform-layout .auth-layout .auth-dialog {
|
||||
height: auto;
|
||||
}
|
||||
.auth-layout .at-form-landing-logo {
|
||||
width: 125px;
|
||||
position: absolute;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,27 +1,45 @@
|
||||
import dragscroll from '@wekanteam/dragscroll';
|
||||
import '/client/lib/dragscrollTouch';
|
||||
|
||||
// Enable / disable mouse drag-to-scroll for whole-page layouts.
|
||||
// Enable / disable mouse drag-to-scroll for whole-page layouts (All Boards,
|
||||
// My Cards, Login, Register).
|
||||
//
|
||||
// On pages like All Boards, Login and Register the scroll container is the
|
||||
// <body> element itself (it has `overflow-y: auto; height: 100vh`), not an
|
||||
// inner board canvas or swimlane. The dragscroll library scrolls whichever
|
||||
// element carries the `dragscroll` class and has a dedicated `el ==
|
||||
// document.body` branch that also scrolls document.documentElement, so tagging
|
||||
// <body> makes dragging empty page space scroll the page — the same gesture
|
||||
// that already works on the board swimlanes view.
|
||||
// The dragscroll library scrolls whichever element carries the `dragscroll`
|
||||
// class AND is the actual scroll container. Which element that is depends on
|
||||
// the layout and on mobile vs desktop mode:
|
||||
//
|
||||
// The class is toggled per template (onRendered / onDestroyed) so it only
|
||||
// applies on those pages; dragscroll.reset() re-scans the DOM and (re)binds the
|
||||
// mouse listeners after the class is added or removed. Touch (one-finger)
|
||||
// scrolling is handled separately by client/lib/dragscrollTouch.js.
|
||||
// * Login / Register (userFormsLayout) have no `#content`; the page scroll
|
||||
// container is `<body>` in both modes (it has `overflow-y: auto` and a
|
||||
// `100vh` height).
|
||||
// * All Boards / My Cards (defaultLayout) render inside `#content`. On
|
||||
// desktop the scroll container is `<body>`; in mobile mode `<body>` becomes
|
||||
// `position: fixed` and `#content` becomes the scroller
|
||||
// (`overflow-y: auto; height: calc(100vh - 48px)`), so tagging only
|
||||
// `<body>` would never scroll on a phone.
|
||||
//
|
||||
// To cover every case we tag BOTH `<body>` and `#content` (when present). Only
|
||||
// the element that actually overflows scrolls, so there is no double-scroll:
|
||||
// on desktop `<body>` scrolls and `#content` (no vertical overflow) is a no-op;
|
||||
// in mobile mode `#content` scrolls and `<body>` (fixed, no overflow) is a
|
||||
// no-op. dragscroll has a dedicated `el == document.body` branch for the body
|
||||
// case. dragscroll.reset() re-scans the DOM and (re)binds the mouse listeners.
|
||||
// One-finger touch scrolling is handled by client/lib/dragscrollTouch.js, which
|
||||
// picks the nearest scrollable `.dragscroll` ancestor (so `#content` wins over
|
||||
// `<body>` when both are tagged).
|
||||
|
||||
function pageScrollEls() {
|
||||
const els = [document.body];
|
||||
const content = document.getElementById('content');
|
||||
if (content) els.push(content);
|
||||
return els;
|
||||
}
|
||||
|
||||
export function enablePageDragscroll() {
|
||||
document.body.classList.add('dragscroll');
|
||||
pageScrollEls().forEach(el => el.classList.add('dragscroll'));
|
||||
dragscroll.reset();
|
||||
}
|
||||
|
||||
export function disablePageDragscroll() {
|
||||
document.body.classList.remove('dragscroll');
|
||||
pageScrollEls().forEach(el => el.classList.remove('dragscroll'));
|
||||
dragscroll.reset();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 580 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user