mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 15:45:01 -05:00
Compare commits
No files matched your search
@@ -126,6 +126,8 @@ ENV \
|
||||
HEADER_LOGIN_FIRSTNAME="" \
|
||||
HEADER_LOGIN_LASTNAME="" \
|
||||
HEADER_LOGIN_EMAIL="" \
|
||||
HEADER_LOGIN_TRUSTED_IPS="" \
|
||||
HEADER_LOGIN_TRUSTED_PROXIES="" \
|
||||
LOGOUT_WITH_TIMER=false \
|
||||
LOGOUT_IN="" \
|
||||
LOGOUT_ON_HOURS="" \
|
||||
|
||||
@@ -4,3 +4,40 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
ignore:
|
||||
# Build-time-only (devDependency) Rspack toolchain. These packages compile
|
||||
# the bundle on the developer / CI machine and are NOT shipped in the
|
||||
# deployed WeKan server or client bundle, so their advisories do not affect
|
||||
# a running WeKan instance. (For example webpack-dev-server / @rspack/dev-
|
||||
# server, pulled via @rspack/cli, and the socket.io/ws chain pulled via
|
||||
# @rsdoctor/rspack-plugin, never run in WeKan — it builds with `meteor run`,
|
||||
# not the Rspack CLI or dev-server.)
|
||||
#
|
||||
# They also cannot simply be removed or bumped:
|
||||
# * @meteorjs/rspack re-pins and re-installs @rspack/cli and
|
||||
# @rsdoctor/rspack-plugin into package.json on every build, so deleting
|
||||
# them does not stick.
|
||||
# * The only upgrades Dependabot/npm offer are build-breaking: a
|
||||
# @meteorjs/rspack downgrade, or @rspack/cli 2.0 / Rspack 2.0, which
|
||||
# needs Meteor 3.6 (see https://github.com/meteor/meteor/pull/14360).
|
||||
# WeKan currently runs METEOR@3.5-rc.2.
|
||||
#
|
||||
# So pin them here until Meteor 3.6 + Rspack 2.0 land, then revisit.
|
||||
- dependency-name: "@meteorjs/rspack"
|
||||
- dependency-name: "@rspack/core"
|
||||
- dependency-name: "@rspack/cli"
|
||||
- dependency-name: "@rsdoctor/rspack-plugin"
|
||||
- dependency-name: "css-loader"
|
||||
- dependency-name: "style-loader"
|
||||
# Playwright end-to-end test harness — pure test-only tooling that never ships in
|
||||
# WeKan. It now depends only on @playwright/test and the official `mongodb` driver
|
||||
# (the heavy, CVE-prone `mongosh` CLI was replaced by helpers/mongo-runner.js).
|
||||
# Watch this directory so those two stay patched.
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/tests/playwright"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -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') }}
|
||||
@@ -53,19 +53,30 @@ jobs:
|
||||
- name: Run Meteor unit tests
|
||||
# First Meteor build is slow; allow time before the test driver starts.
|
||||
timeout-minutes: 45
|
||||
# NOTE: do not set TEST_WATCH here. meteortesting:mocha computes
|
||||
# `testWatch: TEST_WATCH || …`, and the string '0' is truthy in JS, so
|
||||
# TEST_WATCH=0 turns ON watch mode and the process never exits — that is
|
||||
# what made this job hang until the 45-minute timeout. With TEST_WATCH
|
||||
# unset, `meteor test --once` runs the server tests and exits with the
|
||||
# correct status (matching the local rebuild-wekan.sh run).
|
||||
run: meteor test --once --driver-package meteortesting:mocha
|
||||
env:
|
||||
TEST_WATCH: '0'
|
||||
|
||||
e2e-tests:
|
||||
name: Playwright E2E (${{ matrix.project }})
|
||||
name: Playwright E2E (${{ matrix.project }} ${{ matrix.shard }}/2)
|
||||
runs-on: ubuntu-24.04
|
||||
needs: unit-tests
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
project: [chromium, firefox]
|
||||
# webkit excluded from CI (requires macOS runner) — run locally with npm run test:e2e:all
|
||||
# Playwright bundles Chromium, Firefox and WebKit builds that all run
|
||||
# headless on the Linux runner (installed per-job below with --with-deps).
|
||||
project: [chromium, firefox, webkit]
|
||||
# Split each browser's ~245 specs across 2 parallel shards (6 jobs total)
|
||||
# to cut wall-clock time. Each shard job starts its own WeKan + Mongo, so
|
||||
# there is no shared-server contention between shards. Raise the shard
|
||||
# count (here and in the --shard flag below) once the baseline is green
|
||||
# and/or once E2E runs against a prebuilt bundle (cheaper per-job startup).
|
||||
shard: [1, 2]
|
||||
|
||||
services:
|
||||
mongodb:
|
||||
@@ -78,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
|
||||
@@ -87,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') }}
|
||||
@@ -118,45 +129,68 @@ jobs:
|
||||
run: npx playwright install --with-deps ${{ matrix.project }}
|
||||
working-directory: tests/playwright
|
||||
|
||||
- name: Start WeKan in background
|
||||
- name: Build and start WeKan (production bundle)
|
||||
run: |
|
||||
# Run E2E against a PRODUCTION build instead of `meteor run` (dev mode).
|
||||
# In dev the client JS is served by the rspack dev server, which did not
|
||||
# serve the bundle reliably in headless CI: the browser received HTML for
|
||||
# the bundle ("Unexpected token '<'"), Meteor never initialised, and every
|
||||
# spec timed out. A production build bakes the client JS into the bundle,
|
||||
# served statically by the Meteor server at :3000.
|
||||
meteor build ./output --directory
|
||||
( cd output/bundle/programs/server && npm install )
|
||||
# WeKan's server/00checkStartup.js refuses to start unless WRITABLE_PATH
|
||||
# (used for attachment/file storage) is set and writable.
|
||||
mkdir -p "$PWD/wekan-writable"
|
||||
# Match the env the dev runner (rebuild-wekan.sh) and the specs expect:
|
||||
# WITH_API enables the REST API (specs 17/23/24/28), comment-editor
|
||||
# flag matches dev.
|
||||
MONGO_URL=mongodb://localhost:27017/wekan_test \
|
||||
ROOT_URL=http://localhost:3000 \
|
||||
PORT=3000 \
|
||||
meteor run --settings settings.json > wekan-run.log 2>&1 &
|
||||
# The first Meteor build is slow, so wait up to 20 minutes for the
|
||||
# server to start responding before giving up.
|
||||
for i in $(seq 1 120); do
|
||||
WRITABLE_PATH="$PWD/wekan-writable" \
|
||||
WITH_API=true \
|
||||
RICHER_CARD_COMMENT_EDITOR=false \
|
||||
node output/bundle/main.js > wekan-run.log 2>&1 &
|
||||
# The production server serves the client JS statically, so once :3000
|
||||
# responds the bundle is being served. Wait up to 10 minutes.
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf http://localhost:3000 > /dev/null; then
|
||||
echo "WeKan is up after $((i * 10))s"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for WeKan... attempt $i/120"
|
||||
echo "Waiting for WeKan... attempt $i/60"
|
||||
sleep 10
|
||||
done
|
||||
if ! curl -sf http://localhost:3000 > /dev/null; then
|
||||
echo "WeKan failed to start. Last 200 log lines:"
|
||||
tail -n 200 wekan-run.log
|
||||
echo "WeKan failed to start. Last 250 log lines:"
|
||||
tail -n 250 wekan-run.log
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
DISABLE_CAPTCHA: 'true'
|
||||
TOOL_NODE_FLAGS: --max-old-space-size=8192
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
DISABLE_CAPTCHA: 'true'
|
||||
|
||||
- name: Run Playwright tests (${{ matrix.project }})
|
||||
- name: Run Playwright tests (${{ matrix.project }} shard ${{ matrix.shard }}/2)
|
||||
run: |
|
||||
npx playwright test \
|
||||
--project=${{ matrix.project }} \
|
||||
--shard=${{ matrix.shard }}/2 \
|
||||
--reporter=github
|
||||
working-directory: tests/playwright
|
||||
env:
|
||||
WEKAN_BASE_URL: http://localhost:3000
|
||||
WEKAN_MONGO_URL: mongodb://localhost:27017/wekan_test
|
||||
# Make the firefox and webkit projects available to --project
|
||||
# (playwright.config.js only defines them when this is set).
|
||||
WEKAN_PLAYWRIGHT_ALL: '1'
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-report-${{ matrix.project }}
|
||||
name: playwright-report-${{ matrix.project }}-shard${{ matrix.shard }}
|
||||
path: tests/playwright/playwright-report/
|
||||
retention-days: 14
|
||||
|
||||
@@ -164,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
|
||||
|
||||
@@ -184,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
|
||||
@@ -193,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') }}
|
||||
@@ -219,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
|
||||
|
||||
@@ -7,9 +7,13 @@ name: Release All Platforms
|
||||
# Or use the convenience script (bumps versions, commits, pushes, then triggers):
|
||||
# ./releases/release-all.sh 9.35 9.36
|
||||
#
|
||||
# Builds only the platforms MongoDB ships server binaries for (amd64, arm64,
|
||||
# win64, mac); MongoDB 7.x is the default database. s390x/ppc64le are not built
|
||||
# because MongoDB has no server binaries for them.
|
||||
# The Docker image and snap cover the platforms MongoDB ships server binaries for
|
||||
# (amd64, arm64; win64 + mac bundles too); MongoDB 7.x is the default database.
|
||||
# Additionally, WeKan server bundles are built for every other architecture
|
||||
# Node.js 24.x publishes binaries for — s390x and ppc64le — and attached to the
|
||||
# GitHub Release as extra downloads (run a separate MongoDB for those). These
|
||||
# extra-arch bundles are NOT part of the Docker image or snap. (armv7l/32-bit ARM
|
||||
# is not built: Node.js 24 ships no armv7l binaries.)
|
||||
#
|
||||
# This workflow does EVERYTHING for a release remotely (nothing is built or
|
||||
# pushed on the maintainer's machine), running jobs in parallel where possible:
|
||||
@@ -18,11 +22,18 @@ name: Release All Platforms
|
||||
# commits and pushes the bump to wekan/wekan main
|
||||
# - prepare: tags the bumped main as v<new_version> and extracts release notes
|
||||
# - website: updates the wekan/wekan.fi repo (install page + API docs) and pushes
|
||||
# - charts: updates and publishes the wekan/charts Helm chart and pushes
|
||||
# - charts: updates and publishes the wekan/charts Helm chart and pushes,
|
||||
# only AFTER the `docker` job has published the image (skipped if
|
||||
# docker fails)
|
||||
# - build-*: builds the amd64 bundle (full Meteor 3 build), then rebuilds native
|
||||
# modules for arm64, win64, mac-arm64 from that amd64 base
|
||||
# modules for arm64 from that amd64 base
|
||||
# - release: creates the GitHub Release with the newest CHANGELOG.md notes and
|
||||
# uploads every wekan-<version>-<platform>.zip bundle
|
||||
# uploads the core wekan-<version>-{amd64,arm64}.zip bundles
|
||||
# - build-win64 / build-mac-arm64 / build-extra-arches: built AFTER release, each
|
||||
# rebuilds the amd64 bundle's native modules for its platform (Windows,
|
||||
# macOS arm64, and QEMU-emulated s390x/ppc64le) and attaches its
|
||||
# .zip to the Release as an extra download. Decoupled from `release` so
|
||||
# a slow/flaky build never blocks it; not in the Docker image or snap
|
||||
# - docker: builds and pushes the multi-arch image to Docker Hub, Quay.io, GHCR
|
||||
# - snap: builds the snap on Launchpad (amd64 + arm64), pushes it to the Snap
|
||||
# Store and attaches the .snap files to the GitHub Release
|
||||
@@ -55,7 +66,7 @@ permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
# Node.js major version that Meteor 3.5-beta bundles. Used for native module
|
||||
# Node.js major version that Meteor 3.5 bundles. Used for native module
|
||||
# rebuilds on every platform — it must match the amd64 build's Node ABI.
|
||||
env:
|
||||
NODE_VERSION: '24'
|
||||
@@ -72,7 +83,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
@@ -115,7 +126,7 @@ jobs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
changelog: ${{ steps.changelog.outputs.content }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
@@ -177,13 +188,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout wekan repo (bumped main, with rebuilt API docs)
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
path: wekan
|
||||
|
||||
- name: Checkout wekan.fi website repo
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: wekan/wekan.fi
|
||||
token: ${{ secrets.WEKAN_REPO_TOKEN }}
|
||||
@@ -201,22 +212,24 @@ jobs:
|
||||
cd ..
|
||||
./wekan/releases/release-website.sh "${{ inputs.old_version }}" "${{ inputs.new_version }}"
|
||||
|
||||
# ─── 1c. Update and publish the Helm chart repo (parallel) ──────────────────
|
||||
# ─── 1c. Update and publish the Helm chart repo (after docker) ──────────────
|
||||
# Bumps the WeKan version in wekan/charts, packages the chart and publishes it
|
||||
# to the gh-pages Helm index, then pushes both branches. Runs in parallel with
|
||||
# the platform builds; packaging the chart does not need the built artifacts.
|
||||
# to the gh-pages Helm index, then pushes both branches. Runs only AFTER the
|
||||
# `docker` job succeeds: the chart points users at the freshly published
|
||||
# multi-arch image, so we don't advertise a new chart version until that image
|
||||
# is actually on the registries. If `docker` fails, this job is skipped.
|
||||
charts:
|
||||
needs: bump
|
||||
needs: docker
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout wekan repo (for releases/release-charts.sh + version refs)
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
path: wekan
|
||||
|
||||
- name: Checkout wekan/charts repo (main + gh-pages)
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: wekan/charts
|
||||
token: ${{ secrets.WEKAN_REPO_TOKEN }}
|
||||
@@ -224,7 +237,7 @@ jobs:
|
||||
path: charts
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@v4
|
||||
uses: azure/setup-helm@v5
|
||||
|
||||
- name: Fetch gh-pages branch (release-charts.sh switches to it)
|
||||
run: |
|
||||
@@ -241,7 +254,7 @@ jobs:
|
||||
./wekan/releases/release-charts.sh "${{ inputs.new_version }}"
|
||||
|
||||
# ─── 2. Build amd64 bundle (full Meteor 3 build) ────────────────────────────
|
||||
# Meteor 3.5-beta installer supports only Linux x86_64/aarch64, macOS, Windows.
|
||||
# Meteor 3.5 installer supports only Linux x86_64/aarch64, macOS, Windows.
|
||||
# The full Meteor build always runs here on x86_64; every other platform then
|
||||
# only rebuilds the native npm modules from this bundle.
|
||||
build-amd64:
|
||||
@@ -251,19 +264,19 @@ jobs:
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: refs/tags/v${{ needs.prepare.outputs.version }}
|
||||
|
||||
- name: Cache Meteor installation
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.meteor
|
||||
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
|
||||
restore-keys: meteor-
|
||||
|
||||
- name: Cache npm packages
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ hashFiles('package-lock.json') }}
|
||||
@@ -357,9 +370,43 @@ jobs:
|
||||
path: wekan-${{ needs.prepare.outputs.version }}-arm64.zip
|
||||
retention-days: 1
|
||||
|
||||
# ─── 5. Build win64 bundle (rebuild native modules on Windows) ───────────────
|
||||
# ─── 7. Create GitHub Release and upload all bundles ────────────────────────
|
||||
release:
|
||||
needs:
|
||||
- prepare
|
||||
- build-amd64
|
||||
- build-arm64
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- name: Download all bundles
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: bundle-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create GitHub Release with all bundles
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: v${{ needs.prepare.outputs.version }}
|
||||
name: v${{ needs.prepare.outputs.version }}
|
||||
body: ${{ needs.prepare.outputs.changelog }}
|
||||
files: |
|
||||
wekan-${{ needs.prepare.outputs.version }}-amd64.zip
|
||||
wekan-${{ needs.prepare.outputs.version }}-arm64.zip
|
||||
|
||||
# ─── 7b. Extra-platform bundles (win64, mac-arm64, s390x, ppc64le) ───────────
|
||||
# Every bundle below is built AFTER `release` and attaches its own .zip to the
|
||||
# already-created GitHub Release with `gh release upload --clobber`, rather than
|
||||
# being a prerequisite of `release`. This deliberately decouples the slower /
|
||||
# different-OS builds (Windows, macOS, QEMU-emulated arches) from the core
|
||||
# amd64/arm64 release, Docker and snap, so a slow or flaky one never blocks them.
|
||||
# These bundles are NOT part of the Docker image or snap.
|
||||
|
||||
# Build win64 bundle (rebuild native modules on Windows).
|
||||
build-win64:
|
||||
needs: [prepare, build-amd64]
|
||||
needs: [prepare, build-amd64, release]
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -368,7 +415,7 @@ jobs:
|
||||
# Checked out into src/ so start-wekan.bat and snapcraft.yaml (which pins
|
||||
# the MongoDB tools/mongosh versions) are available; the downloaded bundle
|
||||
# is extracted alongside it in the workspace root.
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: refs/tags/v${{ needs.prepare.outputs.version }}
|
||||
path: src
|
||||
@@ -455,9 +502,19 @@ jobs:
|
||||
path: wekan-${{ needs.prepare.outputs.version }}-win64.zip
|
||||
retention-days: 1
|
||||
|
||||
# ─── 6. Build mac-arm64 bundle (rebuild native modules on Apple Silicon) ─────
|
||||
- name: Attach win64 bundle to the GitHub Release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# The repo is checked out into src/, so the workspace root is not a git
|
||||
# repository; set GH_REPO so `gh` targets wekan/wekan without relying on
|
||||
# git remote detection (else: "fatal: not a git repository").
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: gh release upload "v${VERSION}" "wekan-${VERSION}-win64.zip" --clobber
|
||||
|
||||
# Build mac-arm64 bundle (rebuild native modules on Apple Silicon).
|
||||
build-mac-arm64:
|
||||
needs: [prepare, build-amd64]
|
||||
needs: [prepare, build-amd64, release]
|
||||
runs-on: macos-14 # macos-14 runners are Apple Silicon (arm64)
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -503,35 +560,88 @@ jobs:
|
||||
path: wekan-${{ needs.prepare.outputs.version }}-mac-arm64.zip
|
||||
retention-days: 1
|
||||
|
||||
# ─── 7. Create GitHub Release and upload all bundles ────────────────────────
|
||||
release:
|
||||
needs:
|
||||
- prepare
|
||||
- build-amd64
|
||||
- build-arm64
|
||||
- build-win64
|
||||
- build-mac-arm64
|
||||
runs-on: ubuntu-latest
|
||||
- name: Attach mac-arm64 bundle to the GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# No repo checkout in this job, so set GH_REPO so `gh` knows the target
|
||||
# repository instead of trying (and failing) to detect a git remote.
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: gh release upload "v${VERSION}" "wekan-${VERSION}-mac-arm64.zip" --clobber
|
||||
|
||||
# Extra-architecture Linux bundles (s390x, ppc64le).
|
||||
# armv7l (32-bit ARM) is intentionally excluded: Node.js 24 ships no armv7l
|
||||
# binaries (official or unofficial), so there is no node:24 linux/arm/v7 image
|
||||
# to rebuild native modules against and no Node 24 runtime to run a bundle on
|
||||
# an armv7 device. See https://nodejs.org/dist/latest-v24.x/ (no -linux-armv7l).
|
||||
build-extra-arches:
|
||||
needs: [prepare, build-amd64, release]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 300
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: s390x
|
||||
platform: linux/s390x
|
||||
- arch: ppc64le
|
||||
platform: linux/ppc64le
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- name: Download all bundles
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y curl unzip zip
|
||||
|
||||
- name: Set up QEMU for ${{ matrix.arch }} emulation
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Download amd64 bundle
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: bundle-*
|
||||
merge-multiple: true
|
||||
name: bundle-amd64
|
||||
|
||||
- name: Create GitHub Release with all bundles
|
||||
uses: softprops/action-gh-release@v3
|
||||
- name: Rebuild native modules for ${{ matrix.arch }} under emulation
|
||||
run: |
|
||||
unzip -q wekan-${VERSION}-amd64.zip
|
||||
# Same bcrypt fix as the arm64 build: bcrypt 5.0.1 (Meteor
|
||||
# accounts-password) ships node-addon-api@3 which fails to compile on
|
||||
# Node.js 24. Swap in bcrypt 6.0.0 source; the emulated npm install
|
||||
# below compiles it (and any other native module) for the target arch.
|
||||
BCRYPT_DIR="$(pwd)/bundle/programs/server/npm/node_modules/meteor/accounts-password/node_modules/bcrypt"
|
||||
if [ -d "$BCRYPT_DIR" ]; then
|
||||
rm -rf "$BCRYPT_DIR"
|
||||
TMP=$(mktemp -d)
|
||||
pushd "$TMP" > /dev/null
|
||||
npm pack bcrypt@6.0.0
|
||||
mkdir bcrypt && tar xzf bcrypt-*.tgz --strip-components=1 -C bcrypt
|
||||
cd bcrypt && npm install --ignore-scripts && cd ..
|
||||
cp -a bcrypt "$BCRYPT_DIR"
|
||||
popd > /dev/null
|
||||
rm -rf "$TMP"
|
||||
fi
|
||||
docker run --rm --platform ${{ matrix.platform }} \
|
||||
-v "$(pwd)/bundle:/bundle" \
|
||||
-w /bundle/programs/server \
|
||||
node:${NODE_VERSION}-slim \
|
||||
sh -c "apt-get update -qq && apt-get install -y -q build-essential python3 && npm install"
|
||||
|
||||
- name: Create ${{ matrix.arch }} zip
|
||||
run: zip -r wekan-${VERSION}-${{ matrix.arch }}.zip bundle
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
tag_name: v${{ needs.prepare.outputs.version }}
|
||||
name: v${{ needs.prepare.outputs.version }}
|
||||
body: ${{ needs.prepare.outputs.changelog }}
|
||||
files: |
|
||||
wekan-${{ needs.prepare.outputs.version }}-amd64.zip
|
||||
wekan-${{ needs.prepare.outputs.version }}-arm64.zip
|
||||
wekan-${{ needs.prepare.outputs.version }}-win64.zip
|
||||
wekan-${{ needs.prepare.outputs.version }}-mac-arm64.zip
|
||||
name: bundle-${{ matrix.arch }}
|
||||
path: wekan-${{ needs.prepare.outputs.version }}-${{ matrix.arch }}.zip
|
||||
retention-days: 1
|
||||
|
||||
- name: Attach ${{ matrix.arch }} bundle to the GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# No repo checkout in this job, so set GH_REPO so `gh` knows the target
|
||||
# repository instead of trying (and failing) to detect a git remote.
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: gh release upload "v${VERSION}" "wekan-${VERSION}-${{ matrix.arch }}.zip" --clobber
|
||||
|
||||
# ─── 8. Build and push the multi-arch Docker image to all registries ────────
|
||||
# Mirrors releases/docker-build.sh step 3 (multi-arch buildx --push).
|
||||
@@ -544,7 +654,7 @@ jobs:
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: refs/tags/v${{ needs.prepare.outputs.version }}
|
||||
|
||||
@@ -552,7 +662,7 @@ jobs:
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to Docker Hub, Quay.io and GHCR
|
||||
env:
|
||||
@@ -572,10 +682,97 @@ jobs:
|
||||
login quay.io "$QUAY_AUTH"
|
||||
login ghcr.io "$GHCR_AUTH"
|
||||
|
||||
- name: Wait for release bundles to be downloadable
|
||||
# The Dockerfile downloads wekan-<version>-<arch>.zip from the GitHub
|
||||
# Release for each built platform. Even though this job needs `release`
|
||||
# (so the assets are uploaded), GitHub's releases/download/<tag>/<asset>
|
||||
# URL can briefly 404 right after upload (CDN/propagation lag), which used
|
||||
# to fail the multi-arch build mid-way. Poll here until both the amd64 and
|
||||
# arm64 bundles return 200, so the (expensive, emulated) buildx run only
|
||||
# starts once the assets are actually fetchable. The Dockerfile also
|
||||
# retries per-arch as a second layer of safety.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base="https://github.com/wekan/wekan/releases/download/v${VERSION}"
|
||||
for arch in amd64 arm64; do
|
||||
url="${base}/wekan-${VERSION}-${arch}.zip"
|
||||
echo "Waiting for ${url} ..."
|
||||
ok=0
|
||||
for i in $(seq 1 30); do
|
||||
code=$(curl -sSL -o /dev/null -w '%{http_code}' --retry 0 -I "$url" || echo 000)
|
||||
if [ "$code" = "200" ]; then
|
||||
echo " available (HTTP $code) after $((i*10))s"
|
||||
ok=1
|
||||
break
|
||||
fi
|
||||
echo " attempt $i/30: HTTP $code, retrying in 10s..."
|
||||
sleep 10
|
||||
done
|
||||
if [ "$ok" -ne 1 ]; then
|
||||
echo "::error::Release asset not downloadable after retries: $url"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Verify the release bundle reports the release version
|
||||
# Belt-and-suspenders against the "image tagged vX shows vY in the Admin
|
||||
# Panel" bug: the image is just the unzipped wekan-<version>-<arch>.zip
|
||||
# bundle (Dockerfile moves it to /build), and the Admin Panel shows that
|
||||
# bundle's WeKan version (server/statistics.js reads
|
||||
# require('/package.json').version). Check the amd64 bundle's WeKan version
|
||||
# equals the release version BEFORE the expensive multi-arch buildx, so a
|
||||
# stale/mismatched bundle fails fast instead of shipping (and pushing to
|
||||
# three registries) a mislabeled image.
|
||||
#
|
||||
# Meteor does NOT ship the app package.json as a standalone file — it
|
||||
# inlines it into the compiled bundle/programs/server/app/app.js as a JSON
|
||||
# module, e.g. {"name":"wekan","version":"v9.63.0",...}. That inlined
|
||||
# module (anchored on "name":"wekan") is exactly what require('/package.json')
|
||||
# resolves to at runtime, so it is the authoritative thing to check.
|
||||
#
|
||||
# IMPORTANT: only a CONFIRMED mismatch fails the release. If the version
|
||||
# cannot be located (bundle layout changed, app.js minified differently),
|
||||
# warn and continue — inability to verify must not block a good release,
|
||||
# since --build-arg VERSION below is the actual guarantee. (An earlier
|
||||
# version of this guard grepped for a v-prefixed *package.json* and made
|
||||
# "not found" fatal, which false-failed the whole v9.63 docker build.)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
url="https://github.com/wekan/wekan/releases/download/v${VERSION}/wekan-${VERSION}-amd64.zip"
|
||||
wget -q "$url" -O bundle.zip
|
||||
tmp="$(mktemp -d)"
|
||||
# Extract only the compiled app program (not the whole multi-hundred-MB
|
||||
# bundle); the inlined app package.json lives in app/app.js.
|
||||
unzip -o -q bundle.zip 'bundle/programs/server/app/app.js' -d "$tmp" || true
|
||||
appjs="$tmp/bundle/programs/server/app/app.js"
|
||||
found=""
|
||||
if [ -f "$appjs" ]; then
|
||||
match="$(grep -aoE '"name":"wekan","version":"v?[0-9][^"]*"' "$appjs" | head -1 || true)"
|
||||
found="$(printf '%s' "$match" | sed -E 's/.*"version":"([^"]*)".*/\1/')"
|
||||
fi
|
||||
rm -rf "$tmp" bundle.zip
|
||||
if [ -z "$found" ]; then
|
||||
echo "::warning::Could not locate the WeKan version in the bundle (app.js layout may have changed); skipping the bundle-version check. --build-arg VERSION still guarantees the correct bundle."
|
||||
exit 0
|
||||
fi
|
||||
norm="${found#v}"
|
||||
if [ "$norm" != "${VERSION}.0" ] && [ "$norm" != "${VERSION}" ]; then
|
||||
echo "::error::Bundle reports app version ${found} but the release is v${VERSION}. The bundle baked into the image is stale/mismatched — the Admin Panel would show ${norm}. Aborting before build."
|
||||
exit 1
|
||||
fi
|
||||
echo "Bundle app version ${found} matches release v${VERSION}."
|
||||
|
||||
- name: Build and push multi-arch image
|
||||
# Pass VERSION explicitly as a build-arg. The Dockerfile's `ARG VERSION`
|
||||
# default is only a fallback for manual builds; the release version is the
|
||||
# authoritative source here. Without this the image would build against the
|
||||
# Dockerfile default (downloading that version's wekan-<v>-<arch>.zip and
|
||||
# baking its package.json), so a v${VERSION}-tagged image could ship — and
|
||||
# report in the Admin Panel — a stale, different version.
|
||||
run: |
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--build-arg VERSION=${VERSION} \
|
||||
-t wekanteam/wekan:v${VERSION} \
|
||||
-t wekanteam/wekan:latest \
|
||||
-t quay.io/wekan/wekan:v${VERSION} \
|
||||
@@ -596,7 +793,7 @@ jobs:
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: refs/tags/v${{ needs.prepare.outputs.version }}
|
||||
# remote-build (Launchpad) rejects shallow clones, so fetch full history
|
||||
@@ -613,15 +810,74 @@ jobs:
|
||||
printf '%s' "$LP_CREDENTIALS" | base64 -d > ~/.local/share/snapcraft/launchpad-credentials
|
||||
chmod 600 ~/.local/share/snapcraft/launchpad-credentials
|
||||
|
||||
- name: Build snaps on Launchpad (amd64 + arm64)
|
||||
- name: Verify snapcraft.yaml version matches the release
|
||||
# The `bump` job (releases/version.sh) is the single source of truth for
|
||||
# the snap version and updates snapcraft.yaml before the tag is created.
|
||||
# snapcraft.yaml's `version:` names the produced files
|
||||
# (wekan_<version>_<arch>.snap), so if a stale tag carried the wrong
|
||||
# version the build would produce wekan_<old>_*.snap and the upload/attach
|
||||
# globs (wekan_${VERSION}_*.snap) would match nothing — passing the literal
|
||||
# glob to `snapcraft upload` ("is not a valid file", exit 64). Assert it
|
||||
# here so that mismatch fails fast (before a ~20 min Launchpad build) with
|
||||
# a clear pointer, instead of re-bumping the version inside this build job.
|
||||
run: |
|
||||
snapcraft remote-build --launchpad-accept-public-upload
|
||||
set -euo pipefail
|
||||
if ! grep -qxF "version: '${VERSION}'" snapcraft.yaml; then
|
||||
echo "::error::snapcraft.yaml is not at version '${VERSION}' ($(grep -m1 '^version:' snapcraft.yaml)). The bump job (releases/version.sh) did not update the tagged commit."
|
||||
exit 1
|
||||
fi
|
||||
echo "snapcraft.yaml is at version '${VERSION}'."
|
||||
|
||||
- name: Build snaps on Launchpad (amd64 + arm64)
|
||||
# `snapcraft remote-build` is experimental and talks to Launchpad over
|
||||
# HTTPS after pushing the whole repo; it intermittently dies with a
|
||||
# transient TLS drop (e.g. "SSLEOFError: EOF occurred in violation of
|
||||
# protocol", exit 70) that has nothing to do with the build itself.
|
||||
# Retry a few times so one network hiccup doesn't fail the release.
|
||||
run: |
|
||||
set -uo pipefail
|
||||
shopt -s nullglob
|
||||
attempts=3
|
||||
for i in $(seq 1 "$attempts"); do
|
||||
echo "::group::Remote build attempt $i/$attempts"
|
||||
snapcraft remote-build --launchpad-accept-public-upload && rc=0 || rc=$?
|
||||
echo "::endgroup::"
|
||||
# snapcraft remote-build can exit 0 even when the Launchpad build
|
||||
# FAILED (it just downloads the build logs and produces no .snap), so
|
||||
# don't trust the exit code alone — require that .snap files exist.
|
||||
snaps=( wekan_${VERSION}_*.snap )
|
||||
if [ "$rc" -eq 0 ] && [ "${#snaps[@]}" -gt 0 ]; then
|
||||
echo "Remote build succeeded on attempt $i: ${snaps[*]}"
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $i: snapcraft exit=$rc, .snap files produced=${#snaps[@]}."
|
||||
# Surface the Launchpad build logs so a real build failure is
|
||||
# diagnosable here, instead of as a later cryptic upload error.
|
||||
for log in snapcraft-wekan-*.txt; do
|
||||
echo "::group::Launchpad build log: $log (last 150 lines)"
|
||||
tail -n 150 "$log"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
if [ "$i" -lt "$attempts" ]; then
|
||||
echo "Retrying in 60s..."
|
||||
sleep 60
|
||||
fi
|
||||
done
|
||||
echo "::error::Remote build failed after $attempts attempts (no wekan_${VERSION}_*.snap produced)." >&2
|
||||
exit 1
|
||||
|
||||
- name: Push snaps to the Snap Store (edge + beta channels)
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_AUTH }}
|
||||
run: |
|
||||
for f in wekan_${VERSION}_*.snap; do
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
snaps=( wekan_${VERSION}_*.snap )
|
||||
if [ "${#snaps[@]}" -eq 0 ]; then
|
||||
echo "::error::No wekan_${VERSION}_*.snap files to upload."
|
||||
exit 1
|
||||
fi
|
||||
for f in "${snaps[@]}"; do
|
||||
echo "Uploading $f to the Snap Store (edge,beta)..."
|
||||
snapcraft upload --release=edge,beta "$f"
|
||||
done
|
||||
@@ -630,4 +886,11 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release upload "v${VERSION}" wekan_${VERSION}_*.snap --clobber
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
snaps=( wekan_${VERSION}_*.snap )
|
||||
if [ "${#snaps[@]}" -eq 0 ]; then
|
||||
echo "::error::No wekan_${VERSION}_*.snap files to attach."
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "v${VERSION}" "${snaps[@]}" --clobber
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ Thumbs.db
|
||||
ehthumbs.db
|
||||
.eslintcache
|
||||
.meteor/local
|
||||
.meteor/local-test
|
||||
.devcontainer/docker-compose.extend.yml
|
||||
.devcontainer/volumes*/
|
||||
.coverage
|
||||
@@ -64,3 +65,8 @@ test-results/
|
||||
|
||||
# Tools like mongosh etc so that they work inside Flatpak sandbox
|
||||
.tools/
|
||||
|
||||
# Meteor custom local directory (METEOR_LOCAL_DIR)
|
||||
_build-local-test
|
||||
*/build-assets-local-test
|
||||
*/build-chunks-local-test
|
||||
+8
-8
@@ -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/
|
||||
+2784
-80
File diff suppressed because it is too large.
Load diff
+23
-5
@@ -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.41
|
||||
ARG VERSION=9.76
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
|
||||
|
||||
ENV \
|
||||
DEBUG=false \
|
||||
NODE_VERSION=v24.16.0 \
|
||||
METEOR_RELEASE=METEOR@3.5-beta.12 \
|
||||
NODE_VERSION=v24.18.0 \
|
||||
METEOR_RELEASE=METEOR@3.5-rc.2 \
|
||||
USE_EDGE=false \
|
||||
NPM_VERSION=11.11.0 \
|
||||
NPM_VERSION=11.12.1 \
|
||||
SRC_PATH=./ \
|
||||
WITH_API=true \
|
||||
MONGO_OPLOG_URL="" \
|
||||
@@ -42,6 +42,7 @@ ENV \
|
||||
IMAGE_COMPRESS_RATIO="" \
|
||||
NOTIFICATION_TRAY_AFTER_READ_DAYS_BEFORE_REMOVE="" \
|
||||
BIGEVENTS_PATTERN=NONE \
|
||||
NOTIFY_ON_ASSIGN="true" \
|
||||
NOTIFY_DUE_DAYS_BEFORE_AND_AFTER="" \
|
||||
NOTIFY_DUE_AT_HOUR_OF_DAY="" \
|
||||
EMAIL_NOTIFICATION_TIMEOUT=30000 \
|
||||
@@ -66,8 +67,11 @@ ENV \
|
||||
OAUTH2_AUTH_ENDPOINT="" \
|
||||
OAUTH2_USERINFO_ENDPOINT="" \
|
||||
OAUTH2_TOKEN_ENDPOINT="" \
|
||||
OAUTH2_LOGOUT_ENDPOINT="" \
|
||||
OAUTH2_ID_MAP="" \
|
||||
OAUTH2_USERNAME_MAP="" \
|
||||
OAUTH2_AUTO_REGISTRATION="true" \
|
||||
OAUTH2_ADMIN_GROUPS="" \
|
||||
OAUTH2_FULLNAME_MAP="" \
|
||||
OAUTH2_ID_TOKEN_WHITELIST_FIELDS="" \
|
||||
OAUTH2_REQUEST_PERMISSIONS='openid profile email' \
|
||||
@@ -93,6 +97,7 @@ ENV \
|
||||
LDAP_BACKGROUND_SYNC_INTERVAL="" \
|
||||
LDAP_BACKGROUND_SYNC_KEEP_EXISTANT_USERS_UPDATED=false \
|
||||
LDAP_BACKGROUND_SYNC_IMPORT_NEW_USERS=false \
|
||||
LDAP_BACKGROUND_SYNC_DISABLE_NONEXISTANT_USERS=false \
|
||||
LDAP_ENCRYPTION=false \
|
||||
LDAP_CA_CERT="" \
|
||||
LDAP_REJECT_UNAUTHORIZED=false \
|
||||
@@ -122,10 +127,16 @@ ENV \
|
||||
LDAP_DEFAULT_DOMAIN="" \
|
||||
LDAP_SYNC_ADMIN_STATUS="" \
|
||||
LDAP_SYNC_ADMIN_GROUPS="" \
|
||||
LDAP_SYNC_ORGANIZATIONS=false \
|
||||
LDAP_SYNC_ORGANIZATIONS_GROUPS="" \
|
||||
LDAP_SYNC_TEAMS=false \
|
||||
LDAP_SYNC_TEAMS_GROUPS="" \
|
||||
HEADER_LOGIN_ID="" \
|
||||
HEADER_LOGIN_FIRSTNAME="" \
|
||||
HEADER_LOGIN_LASTNAME="" \
|
||||
HEADER_LOGIN_EMAIL="" \
|
||||
HEADER_LOGIN_TRUSTED_IPS="" \
|
||||
HEADER_LOGIN_TRUSTED_PROXIES="" \
|
||||
LOGOUT_WITH_TIMER=false \
|
||||
LOGOUT_IN="" \
|
||||
LOGOUT_ON_HOURS="" \
|
||||
@@ -205,7 +216,14 @@ ln -sf $(which bsdtar) $(which tar)
|
||||
# WeKan Bundle Installation
|
||||
mkdir -p /home/wekan/app
|
||||
cd /home/wekan/app
|
||||
wget "https://github.com/wekan/wekan/releases/download/v${VERSION}/wekan-${VERSION}-${WEKAN_ARCH}.zip"
|
||||
# Retry the release-asset download: even though the CI `docker` job needs the
|
||||
# `release` job (so the asset is already uploaded), GitHub's
|
||||
# releases/download/<tag>/<asset> URL can briefly return 404 right after upload
|
||||
# (CDN/propagation lag). A plain wget treats 404 as fatal, which failed the
|
||||
# build; retry on transient HTTP errors so propagation lag no longer breaks it.
|
||||
WEKAN_ZIP_URL="https://github.com/wekan/wekan/releases/download/v${VERSION}/wekan-${VERSION}-${WEKAN_ARCH}.zip"
|
||||
wget --tries=20 --waitretry=20 --retry-on-http-error=404,403,500,502,503 "${WEKAN_ZIP_URL}" \
|
||||
|| { echo "Failed to download ${WEKAN_ZIP_URL} after retries"; exit 8; }
|
||||
unzip "wekan-${VERSION}-${WEKAN_ARCH}.zip"
|
||||
rm "wekan-${VERSION}-${WEKAN_ARCH}.zip"
|
||||
npm install --prefix ./bundle/programs/server
|
||||
|
||||
@@ -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.41.0"
|
||||
appVersion: "v9.76.0"
|
||||
files:
|
||||
userUploads:
|
||||
- README.md
|
||||
|
||||
@@ -70,12 +70,25 @@ If *nix: chmod +x api.py => ./api.py users
|
||||
python3 api.py listcardattachments BOARDID SWIMLANEID LISTID CARDID # List attachments for specific card
|
||||
python3 api.py copymoveattachment ATTACHMENTID TARGETBOARDID TARGETSWIMLANEID TARGETLISTID TARGETCARDID [copy|move] # Copy or move attachment
|
||||
python3 api.py deleteattachment ATTACHMENTID # Delete attachment
|
||||
python3 api.py uploadbackground BOARDID FILEPATH # Upload a board background image (uses Admin Panel default storage)
|
||||
python3 api.py downloadbackground BOARDID OUTPUTPATH # Download the board's current background image
|
||||
python3 api.py importboard EXPORT.json # Import a whole board (with rules/workflows) from a WeKan export
|
||||
python3 api.py migratefromwekan REMOTE_URL REMOTE_USER REMOTE_PASS # Import ALL boards+workflows+rules from another WeKan
|
||||
python3 api.py exportboardpdf BOARDID OUTPUT.pdf # Export a whole board to PDF
|
||||
python3 api.py importboardfrom SOURCE EXPORT.json # Import from trello/wekan/csv/jira/kanboard/excel/deck/openproject/github/gitlab/gitea/forgejo/asana/zenkit
|
||||
python3 api.py exportboardformat BOARDID FORMAT OUTPUT.json # Export to kanboard/trello/jira/deck/openproject/github/gitlab/gitea/forgejo/asana/zenkit
|
||||
python3 api.py importics BOARDID SWIMLANEID LISTID CALENDAR.ics # Import an iCalendar (.ics) file into a board as cards (one card per VEVENT)
|
||||
|
||||
Board Member API (Issue #5998):
|
||||
python3 api.py addboardmember BOARDID USERID ROLE # Add/activate board member. ROLE: admin, normal, comment, readonly, worker, normalassignedonly, commentassignedonly, readassignedonly, nocomments
|
||||
python3 api.py removeboardmember BOARDID USERID # Remove (deactivate) board member
|
||||
python3 api.py setboardmemberrole BOARDID USERID ROLE # Change permission/role of existing board member
|
||||
|
||||
Board Domain Sharing API (Issue #5850):
|
||||
python3 api.py boarddomains BOARDID # List the email domains a board is shared with
|
||||
python3 api.py addboarddomain BOARDID DOMAIN # Share board with an email domain, e.g. example.com
|
||||
python3 api.py removeboarddomain BOARDID DOMAIN # Stop sharing board with an email domain
|
||||
|
||||
Card Member / Assignee API (board member -> card member/assignee):
|
||||
python3 api.py setcardmembers BOARDID LISTID CARDID MEMBERIDS # Set card members. MEMBERIDS = comma-separated userIds, or '' to clear
|
||||
python3 api.py setcardassignees BOARDID LISTID CARDID ASSIGNEEIDS # Set card assignees. ASSIGNEEIDS = comma-separated userIds, or '' to clear
|
||||
@@ -114,11 +127,24 @@ If *nix: chmod +x api.py => ./api.py users
|
||||
python3 api.py mycards [due] [FROM] [TO] # Current user's cards. 'due' = only cards with a due date; FROM/TO = ISO 8601 due-date range
|
||||
|
||||
Card Settings API (Issue #3062):
|
||||
python3 api.py getcardsettings BOARDID # Get board-level card settings (allows* toggles)
|
||||
python3 api.py setcardsetting BOARDID KEY VALUE # Set one board card setting, e.g. allowsDueDate true
|
||||
python3 api.py getcardsettings BOARDID # Get board-level card settings (allows* toggles, cardAging, cardAgingDays1/2/3)
|
||||
python3 api.py setcardsetting BOARDID KEY VALUE # Set one board card setting, e.g. allowsDueDate true, cardAging true, cardAgingDays1 5
|
||||
|
||||
Admin API:
|
||||
python3 api.py newuser USERNAME EMAIL PASSWORD
|
||||
|
||||
GlobalAdmin REST API (admin token required):
|
||||
python3 api.py getsettings # Get the global Admin Panel settings
|
||||
python3 api.py editsettings FIELD VALUE # Set one global settings field, e.g. productName "My WeKan", disableRegistration true
|
||||
python3 api.py admindomains # List email domains with user counts (#5850)
|
||||
python3 api.py attachmentsettings # Get attachment storage / upload-block settings (secrets masked)
|
||||
python3 api.py editattachmentsetting DOTTED.FIELD VALUE # Set one attachment setting, e.g. limitSettings.avatarsUploadBlocked true (#4740)
|
||||
python3 api.py adminorgs # List organizations with feature toggles (#4737)
|
||||
python3 api.py editorgfeature ORGID FIELD true|false # Set one org feature: orgSharedTemplates, orgPropagateMembersToBoards, orgSyncMembersFromAuth
|
||||
python3 api.py editallorgfeature FIELD true|false # Set one org feature on ALL orgs
|
||||
python3 api.py adminteams # List teams with feature toggles (#4737)
|
||||
python3 api.py editteamfeature TEAMID FIELD true|false # Set one team feature: teamSharedTemplates, teamPropagateMembersToBoards, teamSyncMembersFromAuth
|
||||
python3 api.py editallteamfeature FIELD true|false # Set one team feature on ALL teams
|
||||
"""
|
||||
|
||||
if arguments == 0:
|
||||
@@ -218,6 +244,7 @@ ats = 'attachments'
|
||||
users = wekanurl + apiusers
|
||||
user = wekanurl + apiuser
|
||||
allusers = wekanurl + apiallusers
|
||||
settings = wekanurl + 'api/settings'
|
||||
|
||||
# ------- API URL GENERATION END -----------
|
||||
|
||||
@@ -238,10 +265,9 @@ if arguments == 10:
|
||||
boardid = sys.argv[3]
|
||||
name = sys.argv[4]
|
||||
type1 = sys.argv[5]
|
||||
settings = str(json.loads(sys.argv[6]))
|
||||
# There is error: Settings must be object. So this does not work yet.
|
||||
#settings = {'currencyCode': 'EUR'}
|
||||
print(type(settings))
|
||||
settings_arg = sys.argv[6]
|
||||
# settings is optional; an empty arg -> {} (avoids json.loads('') crashing).
|
||||
settings = json.loads(settings_arg) if settings_arg.strip() else {}
|
||||
showoncard = sys.argv[7]
|
||||
automaticallyoncard = sys.argv[8]
|
||||
showlabelonminicard = sys.argv[9]
|
||||
@@ -249,7 +275,7 @@ if arguments == 10:
|
||||
customfieldtoboard = wekanurl + apiboards + boardid + s + cf
|
||||
# Add Custom Field to Board
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
post_data = {'authorId': '{}'.format(authorid), 'name': '{}'.format(name), 'type': '{}'.format(type1), 'settings': '{}'.format(settings), 'showoncard': '{}'.format(showoncard), 'automaticallyoncard': '{}'.format(automaticallyoncard), 'showlabelonminicard': '{}'.format(showlabelonminicard), 'showsumattopoflist': '{}'.format(showsumattopoflist)}
|
||||
post_data = {'authorId': '{}'.format(authorid), 'name': '{}'.format(name), 'type': '{}'.format(type1), 'settings': json.dumps(settings), 'showoncard': '{}'.format(showoncard), 'automaticallyoncard': '{}'.format(automaticallyoncard), 'showlabelonminicard': '{}'.format(showlabelonminicard), 'showsumattopoflist': '{}'.format(showsumattopoflist)}
|
||||
body = requests.post(customfieldtoboard, data=post_data, headers=headers)
|
||||
print(body.text)
|
||||
# ------- ADD CUSTOM FIELD TO BOARD END -----------
|
||||
@@ -790,6 +816,155 @@ if arguments >= 1:
|
||||
print(data2)
|
||||
# ------- LIST OF USERS END -----------
|
||||
|
||||
if arguments == 1 and sys.argv[1] == 'getsettings':
|
||||
# ------- GET GLOBAL ADMIN SETTINGS START -----------
|
||||
# GlobalAdmin REST API: read the Admin Panel global settings.
|
||||
# Requires an admin API token. Mail-server credentials are never returned.
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
print(settings)
|
||||
print("=== GLOBAL ADMIN SETTINGS ===\n")
|
||||
body = requests.get(settings, headers=headers)
|
||||
print(body.text)
|
||||
# ------- GET GLOBAL ADMIN SETTINGS END -----------
|
||||
|
||||
if arguments >= 3 and sys.argv[1] == 'editsettings':
|
||||
# ------- EDIT GLOBAL ADMIN SETTINGS START -----------
|
||||
# GlobalAdmin REST API: set one Admin Panel global settings field.
|
||||
# Usage: python3 api.py editsettings <field> <value>
|
||||
# e.g. python3 api.py editsettings productName "My WeKan"
|
||||
# python3 api.py editsettings disableRegistration true
|
||||
headers = {'Accept': 'application/json', 'Content-type': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
field = sys.argv[2]
|
||||
raw = sys.argv[3]
|
||||
# Convert true/false to booleans; leave everything else as a string.
|
||||
if raw in ('true', 'false'):
|
||||
value = (raw == 'true')
|
||||
else:
|
||||
value = raw
|
||||
body = requests.put(settings, json={field: value}, headers=headers)
|
||||
print(body.text)
|
||||
# ------- EDIT GLOBAL ADMIN SETTINGS END -----------
|
||||
|
||||
if arguments == 1 and sys.argv[1] == 'admindomains':
|
||||
# ------- LIST ADMIN DOMAINS START -----------
|
||||
# GlobalAdmin REST API: email domains with user counts (#5850).
|
||||
admindomainsurl = wekanurl + 'api/admin/domains'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
print("=== ADMIN DOMAINS ===\n")
|
||||
body = requests.get(admindomainsurl, headers=headers)
|
||||
print(body.text)
|
||||
# ------- LIST ADMIN DOMAINS END -----------
|
||||
|
||||
if arguments == 1 and sys.argv[1] == 'attachmentsettings':
|
||||
# ------- GET ATTACHMENT SETTINGS START -----------
|
||||
# GlobalAdmin REST API: attachment storage / upload-block settings.
|
||||
# Secrets (S3/Azure/GCS) are masked; a <field>Set marker shows existence.
|
||||
attachmentsettingsurl = wekanurl + 'api/admin/attachment-settings'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
print("=== ATTACHMENT SETTINGS ===\n")
|
||||
body = requests.get(attachmentsettingsurl, headers=headers)
|
||||
print(body.text)
|
||||
# ------- GET ATTACHMENT SETTINGS END -----------
|
||||
|
||||
if arguments >= 3 and sys.argv[1] == 'editattachmentsetting':
|
||||
# ------- EDIT ATTACHMENT SETTING START -----------
|
||||
# GlobalAdmin REST API: set one attachment setting by dotted path.
|
||||
# Usage: python3 api.py editattachmentsetting <dotted.field> <value>
|
||||
# e.g. python3 api.py editattachmentsetting limitSettings.avatarsUploadBlocked true
|
||||
attachmentsettingsurl = wekanurl + 'api/admin/attachment-settings'
|
||||
headers = {'Accept': 'application/json', 'Content-type': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
field = sys.argv[2]
|
||||
raw = sys.argv[3]
|
||||
# Convert true/false to booleans; leave everything else as a string.
|
||||
if raw in ('true', 'false'):
|
||||
value = (raw == 'true')
|
||||
else:
|
||||
value = raw
|
||||
# Expand a dotted path (e.g. limitSettings.avatarsUploadBlocked) into
|
||||
# the nested object the partial-update endpoint expects.
|
||||
body_obj = {}
|
||||
cursor = body_obj
|
||||
parts = field.split('.')
|
||||
for part in parts[:-1]:
|
||||
cursor[part] = {}
|
||||
cursor = cursor[part]
|
||||
cursor[parts[-1]] = value
|
||||
body = requests.put(attachmentsettingsurl, json=body_obj, headers=headers)
|
||||
print(body.text)
|
||||
# ------- EDIT ATTACHMENT SETTING END -----------
|
||||
|
||||
if arguments == 1 and sys.argv[1] == 'adminorgs':
|
||||
# ------- LIST ADMIN ORGS START -----------
|
||||
# GlobalAdmin REST API: orgs with feature toggles (#4737).
|
||||
adminorgsurl = wekanurl + 'api/admin/orgs'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
print("=== ADMIN ORGS ===\n")
|
||||
body = requests.get(adminorgsurl, headers=headers)
|
||||
print(body.text)
|
||||
# ------- LIST ADMIN ORGS END -----------
|
||||
|
||||
if arguments >= 3 and sys.argv[1] == 'editorgfeature':
|
||||
# ------- EDIT ORG FEATURE START -----------
|
||||
# Usage: python3 api.py editorgfeature <orgId> <field> <true|false>
|
||||
# field: orgSharedTemplates, orgPropagateMembersToBoards, orgSyncMembersFromAuth
|
||||
orgid = sys.argv[2]
|
||||
field = sys.argv[3]
|
||||
raw = sys.argv[4]
|
||||
value = (raw == 'true')
|
||||
editorgfeatureurl = wekanurl + 'api/admin/orgs/' + orgid + '/features'
|
||||
headers = {'Accept': 'application/json', 'Content-type': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.put(editorgfeatureurl, json={field: value}, headers=headers)
|
||||
print(body.text)
|
||||
# ------- EDIT ORG FEATURE END -----------
|
||||
|
||||
if arguments >= 2 and sys.argv[1] == 'editallorgfeature':
|
||||
# ------- EDIT ALL ORG FEATURE START -----------
|
||||
# Usage: python3 api.py editallorgfeature <field> <true|false>
|
||||
field = sys.argv[2]
|
||||
raw = sys.argv[3]
|
||||
value = (raw == 'true')
|
||||
editallorgfeatureurl = wekanurl + 'api/admin/orgs/features'
|
||||
headers = {'Accept': 'application/json', 'Content-type': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.put(editallorgfeatureurl, json={'field': field, 'value': value}, headers=headers)
|
||||
print(body.text)
|
||||
# ------- EDIT ALL ORG FEATURE END -----------
|
||||
|
||||
if arguments == 1 and sys.argv[1] == 'adminteams':
|
||||
# ------- LIST ADMIN TEAMS START -----------
|
||||
# GlobalAdmin REST API: teams with feature toggles (#4737).
|
||||
adminteamsurl = wekanurl + 'api/admin/teams'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
print("=== ADMIN TEAMS ===\n")
|
||||
body = requests.get(adminteamsurl, headers=headers)
|
||||
print(body.text)
|
||||
# ------- LIST ADMIN TEAMS END -----------
|
||||
|
||||
if arguments >= 3 and sys.argv[1] == 'editteamfeature':
|
||||
# ------- EDIT TEAM FEATURE START -----------
|
||||
# Usage: python3 api.py editteamfeature <teamId> <field> <true|false>
|
||||
# field: teamSharedTemplates, teamPropagateMembersToBoards, teamSyncMembersFromAuth
|
||||
teamid = sys.argv[2]
|
||||
field = sys.argv[3]
|
||||
raw = sys.argv[4]
|
||||
value = (raw == 'true')
|
||||
editteamfeatureurl = wekanurl + 'api/admin/teams/' + teamid + '/features'
|
||||
headers = {'Accept': 'application/json', 'Content-type': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.put(editteamfeatureurl, json={field: value}, headers=headers)
|
||||
print(body.text)
|
||||
# ------- EDIT TEAM FEATURE END -----------
|
||||
|
||||
if arguments >= 2 and sys.argv[1] == 'editallteamfeature':
|
||||
# ------- EDIT ALL TEAM FEATURE START -----------
|
||||
# Usage: python3 api.py editallteamfeature <field> <true|false>
|
||||
field = sys.argv[2]
|
||||
raw = sys.argv[3]
|
||||
value = (raw == 'true')
|
||||
editallteamfeatureurl = wekanurl + 'api/admin/teams/features'
|
||||
headers = {'Accept': 'application/json', 'Content-type': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.put(editallteamfeatureurl, json={'field': field, 'value': value}, headers=headers)
|
||||
print(body.text)
|
||||
# ------- EDIT ALL TEAM FEATURE END -----------
|
||||
|
||||
if arguments == 1 and sys.argv[1] == 'user':
|
||||
# ------- LIST OF ALL USERS START -----------
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
@@ -1167,6 +1342,48 @@ if arguments >= 1:
|
||||
print(body.text)
|
||||
# ------- SET BOARD MEMBER ROLE END -----------
|
||||
|
||||
if sys.argv[1] == 'boarddomains':
|
||||
# ------- LIST BOARD DOMAINS START -----------
|
||||
if arguments < 2:
|
||||
print("Usage: python3 api.py boarddomains BOARDID")
|
||||
exit(1)
|
||||
boardid = sys.argv[2]
|
||||
domains_url = wekanurl + apiboards + boardid + s + 'domains'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.get(domains_url, headers=headers)
|
||||
print("=== BOARD DOMAINS ===\n")
|
||||
print(body.text)
|
||||
# ------- LIST BOARD DOMAINS END -----------
|
||||
|
||||
if sys.argv[1] == 'addboarddomain':
|
||||
# ------- ADD BOARD DOMAIN START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py addboarddomain BOARDID DOMAIN")
|
||||
exit(1)
|
||||
boardid = sys.argv[2]
|
||||
domain = sys.argv[3]
|
||||
add_domain_url = wekanurl + apiboards + boardid + s + 'domains'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
post_data = {'domain': '{}'.format(domain)}
|
||||
body = requests.post(add_domain_url, data=post_data, headers=headers)
|
||||
print("=== ADD BOARD DOMAIN ===\n")
|
||||
print(body.text)
|
||||
# ------- ADD BOARD DOMAIN END -----------
|
||||
|
||||
if sys.argv[1] == 'removeboarddomain':
|
||||
# ------- REMOVE BOARD DOMAIN START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py removeboarddomain BOARDID DOMAIN")
|
||||
exit(1)
|
||||
boardid = sys.argv[2]
|
||||
domain = sys.argv[3]
|
||||
remove_domain_url = wekanurl + apiboards + boardid + s + 'domains' + s + domain
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.delete(remove_domain_url, headers=headers)
|
||||
print("=== REMOVE BOARD DOMAIN ===\n")
|
||||
print(body.text)
|
||||
# ------- REMOVE BOARD DOMAIN END -----------
|
||||
|
||||
if sys.argv[1] in ('setcardmembers', 'setcardassignees'):
|
||||
# ------- SET CARD MEMBERS / ASSIGNEES START -----------
|
||||
if arguments < 4:
|
||||
@@ -1488,3 +1705,337 @@ if arguments >= 1:
|
||||
# ------- SET CARD SETTING END -----------
|
||||
|
||||
# ------- BOARD MEMBER / CARD FIELD API ENDPOINTS END -----------
|
||||
|
||||
# ------- RULES API ENDPOINTS START -----------
|
||||
#
|
||||
# Board automation Rules: add / edit / remove / list via REST.
|
||||
# A rule links a trigger (event or schedule) to an action. The API embeds the
|
||||
# full trigger and action inline so a rule is self-contained.
|
||||
#
|
||||
# python3 api.py listrules BOARDID
|
||||
# python3 api.py getrule BOARDID RULEID
|
||||
# python3 api.py addrule BOARDID 'TITLE' 'TRIGGER_JSON' 'ACTION_JSON'
|
||||
# python3 api.py editrule BOARDID RULEID 'PATCH_JSON' # {"title":..,"trigger":{..},"action":{..}}
|
||||
# python3 api.py removerule BOARDID RULEID
|
||||
#
|
||||
# Example (move a card to the top of its list whenever it is created):
|
||||
# python3 api.py addrule BOARDID 'On create -> top' \
|
||||
# '{"activityType":"createCard","listName":"*","swimlaneName":"*","cardTitle":"*","userId":"*"}' \
|
||||
# '{"actionType":"moveCardToTop","listName":"*","swimlaneName":"*"}'
|
||||
#
|
||||
# Example (archive cards that have sat in "Completed" for 90 days; the scheduled
|
||||
# scan runs this daily):
|
||||
# python3 api.py addrule BOARDID 'Archive after 90 days' \
|
||||
# '{"activityType":"scheduledTrigger","scheduleKind":"aging","listName":"Completed","days":90,"atTime":"03:00"}' \
|
||||
# '{"actionType":"archive"}'
|
||||
|
||||
if arguments >= 2 and sys.argv[1] == 'listrules':
|
||||
boardid = sys.argv[2]
|
||||
url = wekanurl + apiboards + boardid + s + 'rules'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.get(url, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
if arguments >= 3 and sys.argv[1] == 'getrule':
|
||||
boardid = sys.argv[2]
|
||||
ruleid = sys.argv[3]
|
||||
url = wekanurl + apiboards + boardid + s + 'rules' + s + ruleid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.get(url, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
if arguments >= 3 and sys.argv[1] == 'removerule':
|
||||
boardid = sys.argv[2]
|
||||
ruleid = sys.argv[3]
|
||||
url = wekanurl + apiboards + boardid + s + 'rules' + s + ruleid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.delete(url, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
if arguments >= 5 and sys.argv[1] == 'addrule':
|
||||
boardid = sys.argv[2]
|
||||
title = sys.argv[3]
|
||||
trigger = json.loads(sys.argv[4])
|
||||
action = json.loads(sys.argv[5])
|
||||
url = wekanurl + apiboards + boardid + s + 'rules'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
body = requests.post(url, json={'title': title, 'trigger': trigger, 'action': action}, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
if arguments >= 4 and sys.argv[1] == 'editrule':
|
||||
boardid = sys.argv[2]
|
||||
ruleid = sys.argv[3]
|
||||
patch = json.loads(sys.argv[4])
|
||||
url = wekanurl + apiboards + boardid + s + 'rules' + s + ruleid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
body = requests.put(url, json=patch, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
# ------- RULES API ENDPOINTS END -----------
|
||||
|
||||
# ------- CARD DEPENDENCIES ("RED STRINGS") API START -----------
|
||||
#
|
||||
# Typed card-to-card dependencies, visualized on the board as colored connection
|
||||
# lines (SAFe PI-planning "Red Strings"). Each line has:
|
||||
# type : related-to | blocks | is-blocked-by | fixes | is-fixed-by
|
||||
# color : any CSS color, e.g. "#eb144c"
|
||||
# icon : a FontAwesome 4.7 icon name without the "fa-" prefix, e.g. "link"
|
||||
#
|
||||
# python3 api.py listdependencies BOARDID
|
||||
# python3 api.py listcarddependencies BOARDID CARDID
|
||||
# python3 api.py adddependency BOARDID CARDID TARGETCARDID [TYPE] [COLOR] [ICON]
|
||||
# python3 api.py editdependency BOARDID CARDID TARGETCARDID 'PATCH_JSON' # {"type":..,"color":..,"icon":..}
|
||||
# python3 api.py removedependency BOARDID CARDID TARGETCARDID
|
||||
#
|
||||
# Example (card A "blocks" card B, drawn as a thick blue arrow):
|
||||
# python3 api.py adddependency BOARDID CARDA CARDB blocks '#2196f3' lock
|
||||
|
||||
if arguments >= 2 and sys.argv[1] == 'listdependencies':
|
||||
boardid = sys.argv[2]
|
||||
url = wekanurl + apiboards + boardid + s + 'dependencies'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.get(url, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
if arguments >= 3 and sys.argv[1] == 'listcarddependencies':
|
||||
boardid = sys.argv[2]
|
||||
cardid = sys.argv[3]
|
||||
url = wekanurl + apiboards + boardid + s + 'cards' + s + cardid + s + 'dependencies'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.get(url, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
if arguments >= 4 and sys.argv[1] == 'adddependency':
|
||||
boardid = sys.argv[2]
|
||||
cardid = sys.argv[3]
|
||||
targetcardid = sys.argv[4]
|
||||
payload = {'cardId': targetcardid}
|
||||
if arguments >= 5:
|
||||
payload['type'] = sys.argv[5]
|
||||
if arguments >= 6:
|
||||
payload['color'] = sys.argv[6]
|
||||
if arguments >= 7:
|
||||
payload['icon'] = sys.argv[7]
|
||||
url = wekanurl + apiboards + boardid + s + 'cards' + s + cardid + s + 'dependencies'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
body = requests.post(url, json=payload, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
if arguments >= 5 and sys.argv[1] == 'editdependency':
|
||||
boardid = sys.argv[2]
|
||||
cardid = sys.argv[3]
|
||||
targetcardid = sys.argv[4]
|
||||
patch = json.loads(sys.argv[5])
|
||||
url = wekanurl + apiboards + boardid + s + 'cards' + s + cardid + s + 'dependencies' + s + targetcardid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
body = requests.put(url, json=patch, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
if arguments >= 4 and sys.argv[1] == 'removedependency':
|
||||
boardid = sys.argv[2]
|
||||
cardid = sys.argv[3]
|
||||
targetcardid = sys.argv[4]
|
||||
url = wekanurl + apiboards + boardid + s + 'cards' + s + cardid + s + 'dependencies' + s + targetcardid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.delete(url, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
# ------- CARD DEPENDENCIES ("RED STRINGS") API END -----------
|
||||
|
||||
# ------- CARD STICKERS / LOCATIONS / COMPLETE START -----------
|
||||
#
|
||||
# These card fields are set through the card edit endpoint
|
||||
# (PUT /api/boards/:boardId/lists/:listId/cards/:cardId):
|
||||
#
|
||||
# python3 api.py setcardcomplete BOARDID LISTID CARDID true
|
||||
# python3 api.py setcardstickers BOARDID LISTID CARDID '[{"icon":"taco-cool"}]'
|
||||
# python3 api.py setcardlocations BOARDID LISTID CARDID \
|
||||
# '[{"name":"HQ","address":"Helsinki","latitude":60.17,"longitude":24.94}]'
|
||||
|
||||
if arguments >= 5 and sys.argv[1] == 'setcardcomplete':
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
dueComplete = sys.argv[5]
|
||||
url = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
body = requests.put(url, json={'dueComplete': dueComplete}, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
if arguments >= 5 and sys.argv[1] == 'setcardstickers':
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
stickers = json.loads(sys.argv[5])
|
||||
url = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
body = requests.put(url, json={'stickers': stickers}, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
if arguments >= 5 and sys.argv[1] == 'setcardlocations':
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
locations = json.loads(sys.argv[5])
|
||||
url = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
body = requests.put(url, json={'locations': locations}, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
# ------- CARD STICKERS / LOCATIONS / COMPLETE END -----------
|
||||
|
||||
# ------- BOARD BACKGROUND IMAGE UPLOAD / DOWNLOAD START -----------
|
||||
#
|
||||
# Upload/download a board background image. This is the background counterpart
|
||||
# of the "upload attachment to board" API; the upload stores a board-level
|
||||
# attachment using the current Admin Panel / Attachments / Default Storage and
|
||||
# sets it as the board's active background (board admin required).
|
||||
#
|
||||
# python3 api.py uploadbackground BOARDID FILEPATH
|
||||
# python3 api.py downloadbackground BOARDID OUTPUTPATH
|
||||
|
||||
if arguments >= 3 and sys.argv[1] == 'uploadbackground':
|
||||
boardid = sys.argv[2]
|
||||
filepath = sys.argv[3]
|
||||
import base64, os, mimetypes
|
||||
try:
|
||||
with open(filepath, 'rb') as f:
|
||||
base64_data = base64.b64encode(f.read()).decode('utf-8')
|
||||
except Exception as e:
|
||||
print(f"Error reading file: {e}")
|
||||
exit(1)
|
||||
upload_data = {
|
||||
'boardId': boardid,
|
||||
'fileData': base64_data,
|
||||
'fileName': os.path.basename(filepath),
|
||||
'fileType': mimetypes.guess_type(filepath)[0] or 'image/png',
|
||||
}
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
url = wekanurl + 'api/attachment/upload-background'
|
||||
response = requests.post(url, headers=headers, json=upload_data)
|
||||
print(response.text)
|
||||
|
||||
if arguments >= 3 and sys.argv[1] == 'downloadbackground':
|
||||
boardid = sys.argv[2]
|
||||
outputpath = sys.argv[3]
|
||||
import base64
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
url = wekanurl + f'api/attachment/download-background/{boardid}'
|
||||
response = requests.get(url, headers=headers)
|
||||
result = response.json()
|
||||
if result.get('success'):
|
||||
with open(outputpath, 'wb') as f:
|
||||
f.write(base64.b64decode(result.get('base64Data')))
|
||||
print(f"Background saved to: {outputpath}")
|
||||
print(f"Original filename: {result.get('fileName')}")
|
||||
print(f"Storage: {result.get('storageBackend')}")
|
||||
else:
|
||||
print(f"Download failed: {result}")
|
||||
|
||||
# ------- BOARD BACKGROUND IMAGE UPLOAD / DOWNLOAD END -----------
|
||||
|
||||
# ------- IMPORT BOARD / MIGRATE FROM ANOTHER WEKAN START -----------
|
||||
#
|
||||
# Import a whole board WITH its workflows (rules/triggers/actions) and other data
|
||||
# from a WeKan board export, or migrate ALL boards from another WeKan instance
|
||||
# over the REST API.
|
||||
#
|
||||
# python3 api.py importboard EXPORT.json # import one exported board (with rules)
|
||||
# python3 api.py migratefromwekan REMOTE_URL REMOTE_USER REMOTE_PASS # import all boards+workflows+rules
|
||||
|
||||
if arguments >= 2 and sys.argv[1] == 'importboard':
|
||||
filepath = sys.argv[2]
|
||||
with open(filepath) as f:
|
||||
board = json.load(f)
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
response = requests.post(wekanurl + 'api/boards/import', headers=headers, json={'board': board})
|
||||
print(response.text)
|
||||
|
||||
if arguments >= 4 and sys.argv[1] == 'migratefromwekan':
|
||||
remote = sys.argv[2].rstrip('/') + '/'
|
||||
remote_user = sys.argv[3]
|
||||
remote_pass = sys.argv[4]
|
||||
# 1) Log in to the remote WeKan to get its token + user id.
|
||||
rlogin = requests.post(remote + 'users/login', json={'username': remote_user, 'password': remote_pass}).json()
|
||||
rtoken = rlogin['token']
|
||||
ruid = rlogin['id']
|
||||
rheaders = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(rtoken)}
|
||||
lheaders = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
# 2) List the remote user's boards.
|
||||
boards = requests.get(remote + 'api/users/{}/boards'.format(ruid), headers=rheaders).json()
|
||||
print('Found {} board(s) on remote WeKan.'.format(len(boards)))
|
||||
# 3) Export each remote board (full JSON incl. rules/workflows) and import locally.
|
||||
for b in boards:
|
||||
bid = b.get('_id') or b.get('id')
|
||||
if not bid:
|
||||
continue
|
||||
export = requests.get(remote + 'api/boards/{}/export'.format(bid), headers=rheaders).json()
|
||||
resp = requests.post(wekanurl + 'api/boards/import', headers=lheaders, json={'board': export})
|
||||
print('{}: HTTP {} {}'.format(b.get('title', bid), resp.status_code, resp.text[:160]))
|
||||
|
||||
# ------- IMPORT BOARD / MIGRATE FROM ANOTHER WEKAN END -----------
|
||||
|
||||
# ------- EXPORT BOARD TO PDF START -----------
|
||||
#
|
||||
# python3 api.py exportboardpdf BOARDID OUTPUT.pdf
|
||||
|
||||
if arguments >= 3 and sys.argv[1] == 'exportboardpdf':
|
||||
boardid = sys.argv[2]
|
||||
outputpath = sys.argv[3]
|
||||
headers = {'Authorization': 'Bearer {}'.format(apikey)}
|
||||
url = wekanurl + apiboards + boardid + s + 'exportPDF'
|
||||
response = requests.get(url, headers=headers)
|
||||
with open(outputpath, 'wb') as f:
|
||||
f.write(response.content)
|
||||
print('Board PDF saved to: {}'.format(outputpath))
|
||||
|
||||
# ------- EXPORT BOARD TO PDF END -----------
|
||||
|
||||
# ------- IMPORT/EXPORT TO OTHER TOOLS START -----------
|
||||
#
|
||||
# Generalized import from another tool's export, and export to another tool's
|
||||
# JSON shape. Sources/formats: trello, wekan, csv, jira, kanboard, excel, deck,
|
||||
# openproject, github, gitlab, gitea, forgejo.
|
||||
#
|
||||
# python3 api.py importboardfrom SOURCE EXPORT.json # e.g. github issues.json (SOURCE = deck/openproject/github/gitlab/gitea/forgejo/asana/zenkit/trello/jira/kanboard/csv/excel/wekan)
|
||||
# python3 api.py exportboardformat BOARDID FORMAT OUTPUT.json # FORMAT = trello/jira/deck/openproject/github/gitlab/gitea/forgejo/asana/zenkit/kanboard
|
||||
|
||||
if arguments >= 3 and sys.argv[1] == 'importboardfrom':
|
||||
source = sys.argv[2]
|
||||
filepath = sys.argv[3]
|
||||
with open(filepath) as f:
|
||||
board = json.load(f)
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
url = wekanurl + apiboards + 'import/' + source
|
||||
response = requests.post(url, headers=headers, json={'board': board})
|
||||
print(response.text)
|
||||
|
||||
if arguments >= 4 and sys.argv[1] == 'exportboardformat':
|
||||
boardid = sys.argv[2]
|
||||
fmt = sys.argv[3]
|
||||
outputpath = sys.argv[4]
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
url = wekanurl + apiboards + boardid + s + 'export' + s + fmt
|
||||
response = requests.get(url, headers=headers)
|
||||
with open(outputpath, 'w') as f:
|
||||
f.write(response.text)
|
||||
print('Board exported ({}) to: {}'.format(fmt, outputpath))
|
||||
|
||||
if arguments >= 5 and sys.argv[1] == 'importics':
|
||||
# Usage: python3 api.py importics BOARDID SWIMLANEID LISTID CALENDAR.ics
|
||||
# Imports an iCalendar (.ics) file into the board: one card is created per
|
||||
# VEVENT, with startAt (DTSTART) and dueAt (DTEND, or DTSTART when there is
|
||||
# no DTEND), so the events appear on the Calendar / Gantt views (issue #6323).
|
||||
boardid = sys.argv[2]
|
||||
swimlaneid = sys.argv[3]
|
||||
listid = sys.argv[4]
|
||||
icspath = sys.argv[5]
|
||||
with open(icspath) as f:
|
||||
ics_text = f.read()
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
url = wekanurl + apiboards + boardid + s + 'swimlanes' + s + swimlaneid + s + l + s + listid + s + 'ics'
|
||||
response = requests.post(url, headers=headers, json={'ics': ics_text})
|
||||
# Response: { "created": <n>, "cardIds": [ ... ] }
|
||||
print(response.text)
|
||||
|
||||
# ------- IMPORT/EXPORT TO OTHER TOOLS END -----------
|
||||
@@ -14,6 +14,17 @@ try {
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Expose Meteor on window. Under the Meteor 3.5 + rspack build, bare `Meteor`
|
||||
// references in app code are rewritten by rspack's ProvidePlugin into per-module
|
||||
// imports, so `Meteor` is NOT placed on the browser `window` the way the classic
|
||||
// Meteor linker did. Re-expose it so the browser console and the Playwright e2e
|
||||
// tests (which call Meteor.loginWithToken / Meteor.userId / Meteor.subscribe etc.
|
||||
// via page.evaluate in window scope) can reach it. This matches the long-standing
|
||||
// classic-Meteor behaviour where `Meteor` is a global.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.Meteor = Meteor;
|
||||
}
|
||||
|
||||
// Fix bug in jam:offline 0.4.1: s?.message.includes() crashes when s.message is
|
||||
// undefined (e.g. during WebSocket reconnect errors that don't carry a .message).
|
||||
// Wrap _debug so the optional chain is complete: s?.message?.includes().
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
flex: 1;
|
||||
align-self: center;
|
||||
margin: 0;
|
||||
margin-left: 0.4vw;
|
||||
margin-inline-start: 0.4vw;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -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}}.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
opacity: 0.7;
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: -38px;
|
||||
inset-inline-start: -38px;
|
||||
}
|
||||
.new-comment.is-open .member {
|
||||
opacity: 1;
|
||||
@@ -84,7 +84,7 @@
|
||||
flex: 1;
|
||||
align-self: center;
|
||||
margin: 0;
|
||||
margin-left: 3px;
|
||||
margin-inline-start: 3px;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -113,7 +113,7 @@
|
||||
font-size: clamp(14px, 2vw, 18px);
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
margin-left: 4px;
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
.comments .comment .comment-desc .reactions .reaction {
|
||||
cursor: pointer;
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
.board-conversion-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
@@ -52,7 +52,7 @@
|
||||
}
|
||||
|
||||
.board-conversion-header h3 i {
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
color: #2196F3;
|
||||
}
|
||||
|
||||
@@ -91,8 +91,8 @@
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
@@ -127,7 +127,7 @@
|
||||
}
|
||||
|
||||
.conversion-status i {
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
color: #2196F3;
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
}
|
||||
|
||||
.conversion-time i {
|
||||
margin-right: 6px;
|
||||
margin-inline-end: 6px;
|
||||
color: #FF9800;
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
}
|
||||
|
||||
.conversion-info i {
|
||||
margin-right: 6px;
|
||||
margin-inline-end: 6px;
|
||||
color: #2196F3;
|
||||
}
|
||||
|
||||
@@ -174,8 +174,8 @@
|
||||
.board-conversion-header,
|
||||
.board-conversion-content,
|
||||
.board-conversion-footer {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
padding-inline-start: 16px;
|
||||
padding-inline-end: 16px;
|
||||
}
|
||||
|
||||
.board-conversion-header h3 {
|
||||
|
||||
@@ -8,10 +8,10 @@ template(name="archivedBoards")
|
||||
each archivedBoards
|
||||
li.archived-lists-item
|
||||
div.board-header-btns
|
||||
button.board-header-btn.js-delete-board
|
||||
button.primary.board-header-btn.js-delete-board
|
||||
i.fa.fa-trash
|
||||
| {{_ 'delete-board'}}
|
||||
button.board-header-btn.js-restore-board
|
||||
button.primary.board-header-btn.js-restore-board
|
||||
i.fa.fa-undo
|
||||
| {{_ 'restore-board'}}
|
||||
= title
|
||||
|
||||
@@ -9,7 +9,7 @@ Template.archivedBoards.onCreated(function () {
|
||||
|
||||
Template.archivedBoards.helpers({
|
||||
isBoardAdmin() {
|
||||
return ReactiveCache.getCurrentUser().isBoardAdmin();
|
||||
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
|
||||
},
|
||||
|
||||
archivedBoards() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.board-wrapper {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
overflow-x: hidden;
|
||||
@@ -30,8 +30,8 @@
|
||||
}
|
||||
.board-wrapper .board-canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
transition: margin 0.1s;
|
||||
@@ -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,
|
||||
@@ -276,12 +276,12 @@ body.mobile-mode.iphone-device .card-details .card-details-item-title {
|
||||
}
|
||||
.board-wrapper .board-canvas .board-overlay {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
top: -100px;
|
||||
right: -400px;
|
||||
inset-inline-end: -400px;
|
||||
background: #000;
|
||||
opacity: 0.33;
|
||||
animation: fadeIn 0.2s;
|
||||
@@ -325,8 +325,8 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
|
||||
width: 100vw !important;
|
||||
max-width: 100vw !important;
|
||||
min-width: 100vw !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
@@ -335,8 +335,8 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
|
||||
width: 100vw !important;
|
||||
max-width: 100vw !important;
|
||||
min-width: 100vw !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
@@ -360,8 +360,8 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
|
||||
width: 100vw !important;
|
||||
max-width: 100vw !important;
|
||||
min-width: 100vw !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
@@ -370,8 +370,8 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
|
||||
width: 100vw !important;
|
||||
max-width: 100vw !important;
|
||||
min-width: 100vw !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
@@ -560,7 +560,7 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
inset-inline-end: 5px;
|
||||
font-size: 25px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -21,16 +21,17 @@ template(name="boardBody")
|
||||
else
|
||||
// Debug information (remove in production)
|
||||
if debugBoardState
|
||||
.debug-info(style="position: fixed; top: 0; left: 0; background: rgba(0,0,0,0.8); color: white; padding: 10px; z-index: 9999; font-size: 12px;")
|
||||
.debug-info(style="position: fixed; top: 0; inset-inline-start: 0; background: rgba(0,0,0,0.8); color: white; padding: 10px; z-index: 9999; font-size: 12px;")
|
||||
| {{_ 'board'}}: {{currentBoard.title}} | {{_ 'view'}}: {{boardView}} | {{_ 'has-swimlanes'}}: {{hasSwimlanes}} | {{_ 'swimlanes'}}: {{currentBoard.swimlanes.length}}
|
||||
.board-wrapper(class=currentBoard.colorClass class="{{#if isMiniScreen}}mobile-view{{/if}}")
|
||||
.board-canvas.js-swimlanes(
|
||||
class="{{#if hasSwimlanes}}dragscroll{{/if}}"
|
||||
.board-canvas.js-swimlanes.dragscroll(
|
||||
class="{{#if Sidebar.isOpen}}is-sibling-sidebar-open{{/if}}"
|
||||
class="{{#if MultiSelection.isActive}}is-multiselection-active{{/if}}"
|
||||
class="{{#if draggingActive}}is-dragging-active{{/if}}"
|
||||
class="{{#unless isVerticalScrollbars}}no-scrollbars{{/unless}}"
|
||||
class="{{#if isMiniScreen}}mobile-view{{/if}}")
|
||||
if currentBoard.showDependencies
|
||||
+dependencyOverlay
|
||||
if showOverlay
|
||||
.board-overlay
|
||||
if currentBoard.isTemplatesBoard
|
||||
@@ -49,6 +50,8 @@ template(name="boardBody")
|
||||
+calendarView
|
||||
else if isViewGantt
|
||||
+ganttView
|
||||
else if isViewTable
|
||||
+tableView
|
||||
else
|
||||
// Default view - show swimlanes if they exist, otherwise show lists
|
||||
if hasSwimlanes
|
||||
|
||||
@@ -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';
|
||||
@@ -353,8 +354,10 @@ Template.boardBody.onRendered(function () {
|
||||
el.setAttribute('tabindex', '0');
|
||||
el.setAttribute('role', 'button');
|
||||
});
|
||||
// Set the language attribute on the <html> element for accessibility
|
||||
// Set the language and direction attributes on the <html> element for
|
||||
// accessibility and RTL layout.
|
||||
document.documentElement.lang = TAPi18n.getLanguage();
|
||||
document.documentElement.dir = TAPi18n.getLanguageDirection();
|
||||
|
||||
// Ensure the accessible name for the board view switcher matches the visible label "Swimlanes"
|
||||
// This fixes WCAG 2.5.3: Label in Name
|
||||
@@ -672,6 +675,19 @@ Template.boardBody.helpers({
|
||||
return boardView === 'board-view-gantt';
|
||||
},
|
||||
|
||||
isViewTable() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
let boardView;
|
||||
|
||||
if (currentUser) {
|
||||
boardView = (currentUser.profile || {}).boardView;
|
||||
} else {
|
||||
boardView = window.localStorage.getItem('boardView');
|
||||
}
|
||||
|
||||
return boardView === 'board-view-table';
|
||||
},
|
||||
|
||||
hasSwimlanes() {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
if (!currentBoard) {
|
||||
@@ -907,8 +923,10 @@ document.addEventListener('keydown', function (e) {
|
||||
});
|
||||
|
||||
Template.calendarView.onRendered(function () {
|
||||
// Set the language attribute on the <html> element for accessibility
|
||||
// Set the language and direction attributes on the <html> element for
|
||||
// accessibility and RTL layout.
|
||||
document.documentElement.lang = TAPi18n.getLanguage();
|
||||
document.documentElement.dir = TAPi18n.getLanguageDirection();
|
||||
|
||||
this.autorun(function () {
|
||||
const calendarEl = document.getElementById('calendar-view');
|
||||
@@ -925,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,
|
||||
@@ -971,6 +999,7 @@ Template.calendarView.helpers({
|
||||
end: '18:00',
|
||||
},
|
||||
locale: TAPi18n.getLanguage(),
|
||||
isRTL: TAPi18n.isRTL(),
|
||||
events(fetchInfo, callback) {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
const events = [];
|
||||
|
||||
@@ -9,7 +9,7 @@ THEME - NEPHRITIS
|
||||
background-color: #27ae60;
|
||||
}
|
||||
.board-color-nephritis .is-selected .minicard {
|
||||
border-left: 3px solid #27ae60;
|
||||
border-inline-start: 3px solid #27ae60;
|
||||
}
|
||||
.board-color-nephritis .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -47,7 +47,7 @@ THEME - NEPHRITIS
|
||||
}
|
||||
.board-color-nephritis .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #27ae60;
|
||||
border-right: 2px solid #27ae60;
|
||||
border-inline-end: 2px solid #27ae60;
|
||||
}
|
||||
.board-color-nephritis .checklist-progress-bar {
|
||||
background-color: #d4f1dd !important;
|
||||
@@ -95,7 +95,7 @@ THEME - NEPHRITIS
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-nephritis .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ THEME - Pomegranate
|
||||
background-color: #c0392b;
|
||||
}
|
||||
.board-color-pomegranate .is-selected .minicard {
|
||||
border-left: 3px solid #c0392b;
|
||||
border-inline-start: 3px solid #c0392b;
|
||||
}
|
||||
.board-color-pomegranate .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -154,7 +154,7 @@ THEME - Pomegranate
|
||||
}
|
||||
.board-color-pomegranate .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #c0392b;
|
||||
border-right: 2px solid #c0392b;
|
||||
border-inline-end: 2px solid #c0392b;
|
||||
}
|
||||
.board-color-pomegranate .checklist-progress-bar {
|
||||
background-color: #f5d5d2 !important;
|
||||
@@ -202,7 +202,7 @@ THEME - Pomegranate
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-pomegranate .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ THEME - Belize
|
||||
background-color: #2980b9;
|
||||
}
|
||||
.board-color-belize .is-selected .minicard {
|
||||
border-left: 3px solid #2980b9;
|
||||
border-inline-start: 3px solid #2980b9;
|
||||
}
|
||||
.board-color-belize .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -261,7 +261,7 @@ THEME - Belize
|
||||
}
|
||||
.board-color-belize .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #2980b9;
|
||||
border-right: 2px solid #2980b9;
|
||||
border-inline-end: 2px solid #2980b9;
|
||||
}
|
||||
.board-color-belize .checklist-progress-bar {
|
||||
background-color: #d1e7f5 !important;
|
||||
@@ -309,7 +309,7 @@ THEME - Belize
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-belize .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -330,7 +330,7 @@ THEME - Wisteria
|
||||
background-color: #8e44ad;
|
||||
}
|
||||
.board-color-wisteria .is-selected .minicard {
|
||||
border-left: 3px solid #8e44ad;
|
||||
border-inline-start: 3px solid #8e44ad;
|
||||
}
|
||||
.board-color-wisteria .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -368,7 +368,7 @@ THEME - Wisteria
|
||||
}
|
||||
.board-color-wisteria .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #8e44ad;
|
||||
border-right: 2px solid #8e44ad;
|
||||
border-inline-end: 2px solid #8e44ad;
|
||||
}
|
||||
.board-color-wisteria .checklist-progress-bar {
|
||||
background-color: #e8d9f0 !important;
|
||||
@@ -416,7 +416,7 @@ THEME - Wisteria
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-wisteria .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -437,7 +437,7 @@ THEME - Midnight
|
||||
background-color: #2c3e50;
|
||||
}
|
||||
.board-color-midnight .is-selected .minicard {
|
||||
border-left: 3px solid #2c3e50;
|
||||
border-inline-start: 3px solid #2c3e50;
|
||||
}
|
||||
.board-color-midnight .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -475,7 +475,7 @@ THEME - Midnight
|
||||
}
|
||||
.board-color-midnight .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #2c3e50;
|
||||
border-right: 2px solid #2c3e50;
|
||||
border-inline-end: 2px solid #2c3e50;
|
||||
}
|
||||
.board-color-midnight .checklist-progress-bar {
|
||||
background-color: #d2dae2 !important;
|
||||
@@ -523,7 +523,7 @@ THEME - Midnight
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-midnight .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -544,7 +544,7 @@ THEME - Pumpkin
|
||||
background-color: #e67e22;
|
||||
}
|
||||
.board-color-pumpkin .is-selected .minicard {
|
||||
border-left: 3px solid #e67e22;
|
||||
border-inline-start: 3px solid #e67e22;
|
||||
}
|
||||
.board-color-pumpkin .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -582,7 +582,7 @@ THEME - Pumpkin
|
||||
}
|
||||
.board-color-pumpkin .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #e67e22;
|
||||
border-right: 2px solid #e67e22;
|
||||
border-inline-end: 2px solid #e67e22;
|
||||
}
|
||||
.board-color-pumpkin .checklist-progress-bar {
|
||||
background-color: #f9e5d1 !important;
|
||||
@@ -630,7 +630,7 @@ THEME - Pumpkin
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-pumpkin .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -651,7 +651,7 @@ THEME - Moderate Pink
|
||||
background-color: #cd5a91;
|
||||
}
|
||||
.board-color-moderatepink .is-selected .minicard {
|
||||
border-left: 3px solid #cd5a91;
|
||||
border-inline-start: 3px solid #cd5a91;
|
||||
}
|
||||
.board-color-moderatepink .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -689,7 +689,7 @@ THEME - Moderate Pink
|
||||
}
|
||||
.board-color-moderatepink .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #cd5a91;
|
||||
border-right: 2px solid #cd5a91;
|
||||
border-inline-end: 2px solid #cd5a91;
|
||||
}
|
||||
.board-color-moderatepink .checklist-progress-bar {
|
||||
background-color: #f4dde8 !important;
|
||||
@@ -737,7 +737,7 @@ THEME - Moderate Pink
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-moderatepink .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -758,7 +758,7 @@ THEME - Strong Cyan
|
||||
background-color: #00aecc;
|
||||
}
|
||||
.board-color-strongcyan .is-selected .minicard {
|
||||
border-left: 3px solid #00aecc;
|
||||
border-inline-start: 3px solid #00aecc;
|
||||
}
|
||||
.board-color-strongcyan .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -796,7 +796,7 @@ THEME - Strong Cyan
|
||||
}
|
||||
.board-color-strongcyan .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #00aecc;
|
||||
border-right: 2px solid #00aecc;
|
||||
border-inline-end: 2px solid #00aecc;
|
||||
}
|
||||
.board-color-strongcyan .checklist-progress-bar {
|
||||
background-color: #ccf2f9 !important;
|
||||
@@ -844,7 +844,7 @@ THEME - Strong Cyan
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-strongcyan .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -865,7 +865,7 @@ THEME - Lime Green
|
||||
background-color: #4bbf6b;
|
||||
}
|
||||
.board-color-limegreen .is-selected .minicard {
|
||||
border-left: 3px solid #4bbf6b;
|
||||
border-inline-start: 3px solid #4bbf6b;
|
||||
}
|
||||
.board-color-limegreen .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -903,7 +903,7 @@ THEME - Lime Green
|
||||
}
|
||||
.board-color-limegreen .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #4bbf6b;
|
||||
border-right: 2px solid #4bbf6b;
|
||||
border-inline-end: 2px solid #4bbf6b;
|
||||
}
|
||||
.board-color-limegreen .checklist-progress-bar {
|
||||
background-color: #daf4de !important;
|
||||
@@ -951,7 +951,7 @@ THEME - Lime Green
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-limegreen .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -972,7 +972,7 @@ THEME - Dark
|
||||
background-color: #2c3e51;
|
||||
}
|
||||
.board-color-dark .is-selected .minicard {
|
||||
border-left: 3px solid #2c3e51;
|
||||
border-inline-start: 3px solid #2c3e51;
|
||||
}
|
||||
.board-color-dark .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -1011,7 +1011,7 @@ THEME - Dark
|
||||
}
|
||||
.board-color-dark .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #2c3e51;
|
||||
border-right: 2px solid #2c3e51;
|
||||
border-inline-end: 2px solid #2c3e51;
|
||||
}
|
||||
.board-color-dark .checklist-progress-bar {
|
||||
background-color: #d2dae2 !important;
|
||||
@@ -1161,7 +1161,7 @@ THEME - Dark
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-dark .list {
|
||||
border-left: none; /* Remove this property to bring back lines in-between columns if needed*/
|
||||
border-inline-start: none; /* Remove this property to bring back lines in-between columns if needed*/
|
||||
padding: 0px 1px 8px 1px; /* Improves spacing between columns due to no borders, 8px padding at bottom to separate horizontal scrollbar/lists */
|
||||
}
|
||||
|
||||
@@ -1182,7 +1182,7 @@ THEME - Relax
|
||||
background-color: #27ae61;
|
||||
}
|
||||
.board-color-relax .is-selected .minicard {
|
||||
border-left: 3px solid #27ae61;
|
||||
border-inline-start: 3px solid #27ae61;
|
||||
}
|
||||
.board-color-relax .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -1220,7 +1220,7 @@ THEME - Relax
|
||||
}
|
||||
.board-color-relax .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #27ae61;
|
||||
border-right: 2px solid #27ae61;
|
||||
border-inline-end: 2px solid #27ae61;
|
||||
}
|
||||
.board-color-relax .checklist-progress-bar {
|
||||
background-color: #d4f1dd !important;
|
||||
@@ -1263,7 +1263,7 @@ THEME - Relax
|
||||
background-color: #a7e366;
|
||||
}
|
||||
.board-color-relax .list {
|
||||
border-left: 1px dotted #000;
|
||||
border-inline-start: 1px dotted #000;
|
||||
}
|
||||
.board-color-relax .card-details .card-details-items ~ .js-open-inlined-form .viewer {
|
||||
background-color: #fff !important;
|
||||
@@ -1297,7 +1297,7 @@ THEME - Relax
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-relax .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
/* padding-bottom: 8px; - Removed to get rid of grey bars for relax theme */
|
||||
}
|
||||
|
||||
@@ -1318,7 +1318,7 @@ THEME - Corteza
|
||||
background-color: #568ba2;
|
||||
}
|
||||
.board-color-corteza .is-selected .minicard {
|
||||
border-left: 3px solid #568ba2;
|
||||
border-inline-start: 3px solid #568ba2;
|
||||
}
|
||||
.board-color-corteza .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -1356,7 +1356,7 @@ THEME - Corteza
|
||||
}
|
||||
.board-color-corteza .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #568ba2;
|
||||
border-right: 2px solid #568ba2;
|
||||
border-inline-end: 2px solid #568ba2;
|
||||
}
|
||||
.board-color-corteza .checklist-progress-bar {
|
||||
background-color: #dce6ec !important;
|
||||
@@ -1404,7 +1404,7 @@ THEME - Corteza
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-corteza .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -1424,7 +1424,7 @@ THEME - Clear Blue
|
||||
background: linear-gradient(180deg, #499bea 0%, #00aecc 100%);
|
||||
}
|
||||
.board-color-clearblue .is-selected .minicard {
|
||||
border-left: 3px solid #499bea;
|
||||
border-inline-start: 3px solid #499bea;
|
||||
}
|
||||
.board-color-clearblue.pop-over .pop-over-list li a:not(.disabled):hover,
|
||||
.board-color-clearblue .sidebar .sidebar-content .sidebar-btn:hover,
|
||||
@@ -1467,7 +1467,7 @@ THEME - Clear Blue
|
||||
}
|
||||
.board-color-clearblue .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #499bea;
|
||||
border-right: 2px solid #499bea;
|
||||
border-inline-end: 2px solid #499bea;
|
||||
}
|
||||
.board-color-clearblue .checklist-progress-bar {
|
||||
background-color: #daeefb !important;
|
||||
@@ -1495,8 +1495,8 @@ THEME - Clear Blue
|
||||
}
|
||||
.board-color-clearblue .swimlane .list:first-child {
|
||||
min-width: 20px;
|
||||
margin-left: 10px; /* Added 10px margin left to stop lists being butted up against the edge of the screen */
|
||||
border-left: none;
|
||||
margin-inline-start: 10px; /* Added 10px margin left to stop lists being butted up against the edge of the screen */
|
||||
border-inline-start: none;
|
||||
}
|
||||
.board-color-clearblue .swimlane .list:nth-child {
|
||||
flex: 0 0 265px;
|
||||
@@ -1513,8 +1513,8 @@ THEME - Clear Blue
|
||||
flex: unset;
|
||||
padding-bottom: 16px;
|
||||
min-width: 20px;
|
||||
margin-left: 0px;
|
||||
border-left: none;
|
||||
margin-inline-start: 0px;
|
||||
border-inline-start: none;
|
||||
}
|
||||
.board-color-clearblue .list.list-composer .open-list-composer {
|
||||
border-radius: 7px;
|
||||
@@ -1550,7 +1550,7 @@ THEME - Clear Blue
|
||||
.board-color-clearblue .card-details {
|
||||
border-radius: 0 0 14px 14px;
|
||||
box-shadow: 0 0 7px 0 rgba(0,0,0,0.5);
|
||||
margin-left: -10px;
|
||||
margin-inline-start: -10px;
|
||||
}
|
||||
.board-color-clearblue .list-body .open-minicard-composer {
|
||||
border-radius: 7px;
|
||||
@@ -1584,7 +1584,7 @@ THEME - Clear Blue
|
||||
color: rgba(0,0,0,0.5);
|
||||
position: absolute;
|
||||
margin-top: -34px;
|
||||
margin-left: -11px;
|
||||
margin-inline-start: -11px;
|
||||
}
|
||||
.board-color-clearblue a.fa,
|
||||
.board-color-clearblue a i.fa {
|
||||
@@ -1605,7 +1605,7 @@ THEME - Clear Blue
|
||||
}
|
||||
.board-color-clearblue .sidebar-shadow {
|
||||
box-shadow: none;
|
||||
border-left: 9px solid #00aecc;
|
||||
border-inline-start: 9px solid #00aecc;
|
||||
}
|
||||
.board-color-clearblue .is-open .sidebar-shadow {
|
||||
box-shadow: -10px 0 8px rgba(0,0,0,0.3);
|
||||
@@ -1646,7 +1646,7 @@ THEME - Clear Blue
|
||||
vertical-align: middle;
|
||||
}
|
||||
.board-color-clearblue .swimlane-header-wrap .primary.confirm {
|
||||
margin-right: 0;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
.board-color-clearblue .swimlane-header-wrap .fa.fa-times-thin {
|
||||
margin-top: 2px;
|
||||
@@ -1677,7 +1677,7 @@ THEME - Clear Blue
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-clearblue .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -1698,7 +1698,7 @@ THEME - Natural
|
||||
background-color: #596557;
|
||||
}
|
||||
.board-color-natural .is-selected .minicard {
|
||||
border-left: 3px solid #596557;
|
||||
border-inline-start: 3px solid #596557;
|
||||
}
|
||||
.board-color-natural .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -1736,7 +1736,7 @@ THEME - Natural
|
||||
}
|
||||
.board-color-natural .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #596557;
|
||||
border-right: 2px solid #596557;
|
||||
border-inline-end: 2px solid #596557;
|
||||
}
|
||||
.board-color-natural .checklist-progress-bar {
|
||||
background-color: #dee0dd !important;
|
||||
@@ -1793,7 +1793,7 @@ THEME - Natural
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-natural .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -1814,7 +1814,7 @@ THEME - Modern
|
||||
background-color: #2a80b8;
|
||||
}
|
||||
.board-color-modern .is-selected .minicard {
|
||||
border-left: 3px solid #2a80b8;
|
||||
border-inline-start: 3px solid #2a80b8;
|
||||
}
|
||||
.board-color-modern .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -1852,7 +1852,7 @@ THEME - Modern
|
||||
}
|
||||
.board-color-modern .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #2a80b8;
|
||||
border-right: 2px solid #2a80b8;
|
||||
border-inline-end: 2px solid #2a80b8;
|
||||
}
|
||||
.board-color-modern .checklist-progress-bar {
|
||||
background-color: #d1e7f5 !important;
|
||||
@@ -1904,7 +1904,7 @@ THEME - Modern
|
||||
display: none;
|
||||
}
|
||||
.board-color-modern#header-quick-access ul li:nth-child(3) {
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
.board-color-modern#header-quick-access ul li a {
|
||||
padding: 5px 10px;
|
||||
@@ -1952,7 +1952,7 @@ THEME - Modern
|
||||
}
|
||||
.board-color-modern .list {
|
||||
background: transparent;
|
||||
border-left: 0;
|
||||
border-inline-start: 0;
|
||||
margin: 10px 0;
|
||||
padding: 0px;
|
||||
border-radius: 5px;
|
||||
@@ -1964,8 +1964,8 @@ THEME - Modern
|
||||
}
|
||||
.board-color-modern .swimlane .list:first-child {
|
||||
min-width: 20px;
|
||||
margin-left: 0px;
|
||||
border-left: none;
|
||||
margin-inline-start: 0px;
|
||||
border-inline-start: none;
|
||||
}
|
||||
.board-color-modern .swimlane .list:nth-child {
|
||||
flex: 0 0 265px;
|
||||
@@ -2033,7 +2033,7 @@ THEME - Modern
|
||||
.board-color-modern .pop-over .header .close-btn {
|
||||
font-size: 20px;
|
||||
top: 6px;
|
||||
right: 8px;
|
||||
inset-inline-end: 8px;
|
||||
}
|
||||
.board-color-modern .pop-over .content-container .content {
|
||||
padding: 5px 20px 20px;
|
||||
@@ -2043,7 +2043,7 @@ THEME - Modern
|
||||
border-radius: 5px;
|
||||
}
|
||||
.board-color-modern .pop-over-list li > a > i {
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
.board-color-modern .pop-over-list li>a .sub-name {
|
||||
margin-bottom: 8px;
|
||||
@@ -2086,7 +2086,7 @@ THEME - Modern
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-modern .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -2107,7 +2107,7 @@ THEME - Modern Dark
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
.board-color-moderndark .is-selected .minicard {
|
||||
border-left: 3px solid #2a2a2a;
|
||||
border-inline-start: 3px solid #2a2a2a;
|
||||
}
|
||||
.board-color-moderndark .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -2150,7 +2150,7 @@ THEME - Modern Dark
|
||||
}
|
||||
.board-color-moderndark .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #2a2a2a;
|
||||
border-right: 2px solid #2a2a2a;
|
||||
border-inline-end: 2px solid #2a2a2a;
|
||||
}
|
||||
.board-color-moderndark .checklist-progress-bar {
|
||||
background-color: #d1d1d1 !important;
|
||||
@@ -2250,7 +2250,7 @@ THEME - Modern Dark
|
||||
display: none;
|
||||
}
|
||||
.board-color-moderndark#header-quick-access ul li:nth-child(3) {
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
.board-color-moderndark#header-quick-access ul li a {
|
||||
padding: 5px 10px;
|
||||
@@ -2293,8 +2293,8 @@ THEME - Modern Dark
|
||||
.board-color-moderndark .swimlane .list:first-child {
|
||||
color: #eee;
|
||||
min-width: 20px;
|
||||
margin-left: 0px;
|
||||
border-left: none;
|
||||
margin-inline-start: 0px;
|
||||
border-inline-start: none;
|
||||
}
|
||||
.board-color-moderndark .swimlane .list-composer .list-header-add .inlined-form .edit-controls .quiet,
|
||||
.board-color-moderndark .swimlane .list-composer .list-header-add .inlined-form .edit-controls .quiet a.js-list-template {
|
||||
@@ -2315,7 +2315,7 @@ THEME - Modern Dark
|
||||
background: #6a6a6a;
|
||||
}
|
||||
.board-color-moderndark .list-header .viewer {
|
||||
padding-left: 10px;
|
||||
padding-inline-start: 10px;
|
||||
}
|
||||
.board-color-moderndark .list-header .list-header-name,
|
||||
.board-color-moderndark .minicard {
|
||||
@@ -2333,7 +2333,7 @@ THEME - Modern Dark
|
||||
}
|
||||
@media screen and (min-width: 801px) {
|
||||
.board-color-moderndark .list-header .list-header-name {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
.board-color-moderndark .list-header .list-header-menu {
|
||||
padding: 0 10px 10px;
|
||||
@@ -2445,7 +2445,7 @@ THEME - Modern Dark
|
||||
padding: 0px 20px;
|
||||
}
|
||||
.board-color-moderndark .card-details .card-details-header .card-details-menu-mobile-web {
|
||||
margin-right: 0;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
.board-color-moderndark .pop-over > .content-wrapper > .popup-container-depth-0 > .content {
|
||||
width: calc(100% - 20px);
|
||||
@@ -2455,7 +2455,7 @@ THEME - Modern Dark
|
||||
.board-color-moderndark .card-details {
|
||||
position: fixed;
|
||||
top: 82px;
|
||||
left: calc(50% - 384px);
|
||||
inset-inline-start: calc(50% - 384px);
|
||||
width: 768px;
|
||||
max-height: calc(100% - 60px);
|
||||
}
|
||||
@@ -2513,9 +2513,9 @@ THEME - Modern Dark
|
||||
}
|
||||
.board-color-moderndark .card-details .check-box.materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #fff;
|
||||
border-right: 2px solid #fff;
|
||||
border-inline-end: 2px solid #fff;
|
||||
border-top: 0;
|
||||
border-left: 0;
|
||||
border-inline-start: 0;
|
||||
}
|
||||
.board-color-moderndark .card-details .js-add-checklist-item {
|
||||
margin-top: 4px;
|
||||
@@ -2530,7 +2530,7 @@ THEME - Modern Dark
|
||||
.board-color-moderndark .sidebar {
|
||||
background-color: #222;
|
||||
box-shadow: -10px 0 5px -10px #444;
|
||||
border-left: 1px solid #333;
|
||||
border-inline-start: 1px solid #333;
|
||||
color: #ccc;
|
||||
}
|
||||
.board-color-moderndark .activities .activity .activity-desc .activity-comment {
|
||||
@@ -2581,7 +2581,7 @@ THEME - Modern Dark
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-moderndark .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -2603,7 +2603,7 @@ THEME - Exodark
|
||||
background-color: #222;
|
||||
}
|
||||
.board-color-exodark .is-selected .minicard {
|
||||
border-left: 3px solid #222;
|
||||
border-inline-start: 3px solid #222;
|
||||
}
|
||||
.board-color-exodark .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -2641,7 +2641,7 @@ THEME - Exodark
|
||||
}
|
||||
.board-color-exodark .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #dbdbdb!important;/*Fix contrast of checkbox*/
|
||||
border-right: 2px solid #dbdbdb!important;
|
||||
border-inline-end: 2px solid #dbdbdb!important;
|
||||
}
|
||||
.board-color-exodark .checklist-progress-bar {
|
||||
background-color: #cccccc !important;
|
||||
@@ -2698,8 +2698,8 @@ THEME - Exodark
|
||||
}
|
||||
.board-color-exodark .swimlane .list:first-child {
|
||||
min-width: 20px;
|
||||
margin-left: 10px; /*Added 10px margin to prevent butting up against edge of screen */
|
||||
border-left: none;
|
||||
margin-inline-start: 10px; /*Added 10px margin to prevent butting up against edge of screen */
|
||||
border-inline-start: none;
|
||||
}
|
||||
.board-color-exodark .swimlane .list:nth-child {
|
||||
flex: 0 0 265px;
|
||||
@@ -2960,10 +2960,10 @@ THEME - Clean Dark
|
||||
background: #009B64;
|
||||
width: min-content;
|
||||
color: #FFFFFF;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
padding-inline-start: 8px;
|
||||
padding-inline-end: 8px;
|
||||
border-radius: 8px;
|
||||
margin-left: 4px;
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
|
||||
.board-color-cleandark .card-details hr {
|
||||
@@ -3203,7 +3203,7 @@ THEME - Clean Dark
|
||||
|
||||
.board-color-cleandark .card-details .check-box.materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #FFFFFF;
|
||||
border-right: 2px solid #FFFFFF;
|
||||
border-inline-end: 2px solid #FFFFFF;
|
||||
width: 11px;
|
||||
height: 19px;
|
||||
border-radius: 0;
|
||||
@@ -3234,12 +3234,12 @@ THEME - Clean Dark
|
||||
|
||||
.board-color-cleandark #cards .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #FFFFFF;
|
||||
border-right: 2px solid #FFFFFF;
|
||||
border-inline-end: 2px solid #FFFFFF;
|
||||
width: 5px;
|
||||
height: 13px;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
margin-left: 3px;
|
||||
margin-inline-start: 3px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.board-color-cleandark .checklist-progress-bar {
|
||||
@@ -3258,7 +3258,7 @@ THEME - Clean Dark
|
||||
align-items: center;
|
||||
padding-bottom: 4px;
|
||||
padding-top: 4px;
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
|
||||
.board-color-cleandark#header-quick-access ul.header-quick-access-list {
|
||||
@@ -3280,7 +3280,7 @@ THEME - Clean Dark
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-cleandark .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -3371,7 +3371,7 @@ THEME - Clean Light
|
||||
.board-color-cleanlight .list,
|
||||
.board-color-cleandark .list {
|
||||
background: none !important;
|
||||
border-left: none !important;
|
||||
border-inline-start: none !important;
|
||||
}
|
||||
|
||||
.board-color-cleanlight .list .list-header,
|
||||
@@ -3445,11 +3445,11 @@ THEME - Clean Light
|
||||
border-radius: 12px;
|
||||
margin-top: 18px;
|
||||
padding: 8px;
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 10px;
|
||||
margin-inline-start: 10px;
|
||||
}
|
||||
|
||||
.board-color-cleanlight .list-header-add:hover {
|
||||
@@ -3479,7 +3479,7 @@ THEME - Clean Light
|
||||
.board-color-cleandark .card-label {
|
||||
border-radius: 18px;
|
||||
margin-top: 6px;
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
border: none;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
@@ -3529,14 +3529,14 @@ THEME - Clean Light
|
||||
|
||||
.board-color-cleanlight .swimlane .swimlane-header-wrap .swimlane-header-plus-icon,
|
||||
.board-color-cleandark .swimlane .swimlane-header-wrap .swimlane-header-plus-icon {
|
||||
margin-left: 14px;
|
||||
margin-inline-start: 14px;
|
||||
}
|
||||
|
||||
.board-color-cleanlight .swimlane .swimlane-header-wrap .list-composer,
|
||||
.board-color-cleandark .swimlane .swimlane-header-wrap .list-composer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-left: 20px;
|
||||
margin-inline-start: 20px;
|
||||
}
|
||||
|
||||
.board-color-cleanlight .swimlane .swimlane-header-wrap .swimlane-header .viewer p,
|
||||
@@ -3670,10 +3670,10 @@ THEME - Clean Light
|
||||
background: #009B64;
|
||||
width: min-content;
|
||||
color: #FFFFFF;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
padding-inline-start: 8px;
|
||||
padding-inline-end: 8px;
|
||||
border-radius: 8px;
|
||||
margin-left: 4px;
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
|
||||
.board-color-cleanlight .card-details hr {
|
||||
@@ -3962,7 +3962,7 @@ THEME - Clean Light
|
||||
|
||||
.board-color-cleanlight .card-details .check-box.materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #000000;
|
||||
border-right: 2px solid #000000;
|
||||
border-inline-end: 2px solid #000000;
|
||||
width: 11px;
|
||||
height: 19px;
|
||||
border-radius: 0;
|
||||
@@ -3993,12 +3993,12 @@ THEME - Clean Light
|
||||
|
||||
.board-color-cleanlight #cards .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #000000;
|
||||
border-right: 2px solid #000000;
|
||||
border-inline-end: 2px solid #000000;
|
||||
width: 5px;
|
||||
height: 13px;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
margin-left: 3px;
|
||||
margin-inline-start: 3px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.board-color-cleanlight .checklist-progress-bar {
|
||||
@@ -4050,7 +4050,7 @@ THEME - Clean Light
|
||||
align-items: center;
|
||||
padding-bottom: 4px;
|
||||
padding-top: 4px;
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
|
||||
.board-color-cleanlight#header-quick-access ul.header-quick-access-list {
|
||||
@@ -4238,12 +4238,12 @@ THEME - Clean Light
|
||||
|
||||
.board-color-cleanlight .card-details-item-customfield:has(.checklist-item) div,
|
||||
.board-color-cleandark .card-details-item-customfield:has(.checklist-item) div {
|
||||
padding-right: 0 !important;
|
||||
padding-inline-end: 0 !important;
|
||||
}
|
||||
|
||||
.board-color-cleanlight .card-details .card-details-items .card-details-item.custom-fields,
|
||||
.board-color-cleanlight .card-details .card-details-items .card-details-item.custom-fields {
|
||||
margin-left: auto;
|
||||
margin-inline-start: auto;
|
||||
flex-grow: 0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
@@ -4290,11 +4290,11 @@ THEME - Clean Light
|
||||
|
||||
.board-color-cleanlight .pop-over-list li > a i,
|
||||
.board-color-cleandark .pop-over-list li > a i {
|
||||
margin-right: 6px !important;
|
||||
margin-inline-end: 6px !important;
|
||||
}
|
||||
|
||||
.board-color-cleanlight .pop-over .quiet {
|
||||
margin-left: 100px !important;
|
||||
margin-inline-start: 100px !important;
|
||||
}
|
||||
|
||||
.board-color-cleandark .minicard:hover:not(.minicard-composer),
|
||||
@@ -4316,7 +4316,7 @@ THEME - Clean Light
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-cleanlight .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
display: block !important;
|
||||
float: none !important;
|
||||
margin-bottom: 2rem !important;
|
||||
border-left: none !important;
|
||||
border-inline-start: none !important;
|
||||
border-bottom: 2px solid #ccc !important;
|
||||
clear: both !important;
|
||||
}
|
||||
@@ -249,7 +249,7 @@
|
||||
|
||||
.mobile-mode .list-header .list-header-menu-icon {
|
||||
position: static !important;
|
||||
right: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
top: auto !important;
|
||||
transform: none !important;
|
||||
grid-row: 1/3 !important;
|
||||
@@ -264,7 +264,7 @@
|
||||
|
||||
.mobile-mode .list-header .list-header-handle {
|
||||
position: static !important;
|
||||
right: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
top: auto !important;
|
||||
transform: none !important;
|
||||
grid-row: 1/3 !important;
|
||||
@@ -325,9 +325,9 @@
|
||||
width: auto !important;
|
||||
min-width: auto !important;
|
||||
display: flex !important;
|
||||
float: left !important;
|
||||
float: inline-start !important;
|
||||
margin-bottom: 0 !important;
|
||||
border-left: 1px solid #ccc !important;
|
||||
border-inline-start: 1px solid #ccc !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
@@ -356,7 +356,7 @@
|
||||
|
||||
.desktop-mode .list-header .list-header-menu-icon {
|
||||
position: absolute !important;
|
||||
right: 60px !important;
|
||||
inset-inline-end: 60px !important;
|
||||
top: 50% !important;
|
||||
transform: translateY(-50%) !important;
|
||||
grid-row: auto !important;
|
||||
@@ -367,7 +367,7 @@
|
||||
|
||||
.desktop-mode .list-header .list-header-handle {
|
||||
position: absolute !important;
|
||||
right: 10px !important;
|
||||
inset-inline-end: 10px !important;
|
||||
top: 50% !important;
|
||||
transform: translateY(-50%) !important;
|
||||
grid-row: auto !important;
|
||||
@@ -463,7 +463,7 @@
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
position: static !important;
|
||||
right: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
top: auto !important;
|
||||
transform: none !important;
|
||||
}
|
||||
@@ -557,8 +557,8 @@
|
||||
padding: 0 !important;
|
||||
float: none !important;
|
||||
clear: both !important;
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
border-inline-start: none !important;
|
||||
border-inline-end: none !important;
|
||||
border-top: none !important;
|
||||
border-bottom: 2px solid #ccc !important;
|
||||
flex: none !important;
|
||||
@@ -566,20 +566,20 @@
|
||||
flex-grow: 0 !important;
|
||||
flex-shrink: 0 !important;
|
||||
position: static !important;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
top: auto !important;
|
||||
bottom: auto !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.mobile-mode .list:first-child {
|
||||
margin-left: 0 !important;
|
||||
margin-inline-start: 0 !important;
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.mobile-mode .list:last-child {
|
||||
margin-right: 0 !important;
|
||||
margin-inline-end: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
@@ -594,8 +594,8 @@
|
||||
padding: 0 !important;
|
||||
float: none !important;
|
||||
clear: both !important;
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
border-inline-start: none !important;
|
||||
border-inline-end: none !important;
|
||||
border-top: none !important;
|
||||
border-bottom: 2px solid #ccc !important;
|
||||
flex: none !important;
|
||||
@@ -612,8 +612,8 @@
|
||||
padding: 0 !important;
|
||||
float: none !important;
|
||||
clear: both !important;
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
border-inline-start: none !important;
|
||||
border-inline-end: none !important;
|
||||
border-top: none !important;
|
||||
border-bottom: 2px solid #ccc !important;
|
||||
flex: none !important;
|
||||
@@ -643,8 +643,8 @@
|
||||
padding: 0 !important;
|
||||
float: none !important;
|
||||
clear: both !important;
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
border-inline-start: none !important;
|
||||
border-inline-end: none !important;
|
||||
border-top: none !important;
|
||||
border-bottom: 2px solid #ccc !important;
|
||||
flex: none !important;
|
||||
@@ -652,8 +652,8 @@
|
||||
flex-grow: 0 !important;
|
||||
flex-shrink: 0 !important;
|
||||
position: static !important;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
top: auto !important;
|
||||
bottom: auto !important;
|
||||
transform: none !important;
|
||||
@@ -707,8 +707,8 @@
|
||||
padding: 0 !important;
|
||||
float: none !important;
|
||||
clear: both !important;
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
border-inline-start: none !important;
|
||||
border-inline-end: none !important;
|
||||
border-top: none !important;
|
||||
border-bottom: 2px solid #ccc !important;
|
||||
flex: none !important;
|
||||
@@ -716,8 +716,8 @@
|
||||
flex-grow: 0 !important;
|
||||
flex-shrink: 0 !important;
|
||||
position: static !important;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
top: auto !important;
|
||||
bottom: auto !important;
|
||||
transform: none !important;
|
||||
@@ -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
|
||||
@@ -51,6 +51,8 @@ template(name="boardHeaderBar")
|
||||
span {{_ 'log-in'}}
|
||||
|
||||
.board-header-btns.center
|
||||
if currentUser
|
||||
+boardButtons
|
||||
|
||||
.board-header-btns.right
|
||||
if currentBoard
|
||||
@@ -76,7 +78,7 @@ template(name="boardHeaderBar")
|
||||
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
|
||||
title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}} {{_ 'starred-boards-description'}}")
|
||||
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
|
||||
a.board-header-btn(title="{{_ 'sort-cards'}}" class="{{#if isSortActive }}emphasis{{else}} js-sort-cards {{/if}}")
|
||||
a.board-header-btn(title="{{_ 'sort-cards'}}" class="js-sort-cards {{#if isSortActive }}emphasis{{/if}}")
|
||||
i.fa.fa-sort
|
||||
span {{#if isSortActive }}{{_ 'sort-is-on'}}{{else}}{{_ 'sort-cards'}}{{/if}}
|
||||
if isSortActive
|
||||
@@ -125,6 +127,8 @@ template(name="boardHeaderBar")
|
||||
i.fa.fa-calendar
|
||||
if $eq boardView 'board-view-gantt'
|
||||
i.fa.fa-bar-chart
|
||||
if $eq boardView 'board-view-table'
|
||||
i.fa.fa-table
|
||||
span
|
||||
if $eq boardView 'board-view-swimlanes'
|
||||
| {{_ 'swimlanes'}}
|
||||
@@ -134,6 +138,14 @@ template(name="boardHeaderBar")
|
||||
| {{_ 'calendar'}}
|
||||
if $eq boardView 'board-view-gantt'
|
||||
| {{_ 'gantt'}}
|
||||
if $eq boardView 'board-view-table'
|
||||
| {{_ 'board-view-table'}}
|
||||
a.board-header-btn.js-toggle-dependencies(
|
||||
title="{{#if currentBoard.showDependencies}}{{_ 'hide-dependencies'}}{{else}}{{_ 'show-dependencies'}}{{/if}}"
|
||||
class="{{#if currentBoard.showDependencies}}emphasis{{/if}}")
|
||||
i.fa.fa-link
|
||||
span {{#if currentBoard.showDependencies}}{{_ 'hide-dependencies'}}{{else}}{{_ 'show-dependencies'}}{{/if}}
|
||||
|
||||
if canModifyBoard
|
||||
a.board-header-btn.js-multiselection-activate(
|
||||
title="{{#if MultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}"
|
||||
@@ -228,6 +240,13 @@ template(name="boardChangeViewPopup")
|
||||
| {{_ 'board-view-gantt'}}
|
||||
if $eq Utils.boardView "board-view-gantt"
|
||||
i.fa.fa-check
|
||||
li
|
||||
with "board-view-table"
|
||||
a.js-open-table-view
|
||||
i.fa.fa-table
|
||||
| {{_ 'board-view-table'}}
|
||||
if $eq Utils.boardView "board-view-table"
|
||||
i.fa.fa-check
|
||||
|
||||
template(name="createBoard")
|
||||
form
|
||||
@@ -247,9 +266,6 @@ template(name="createBoard")
|
||||
= " "
|
||||
| {{{_ 'board-private-info'}}}
|
||||
a.js-change-visibility {{_ 'change'}}.
|
||||
a.flex.js-toggle-add-template-container
|
||||
.materialCheckBox#add-template-container
|
||||
span {{_ 'add-template-container'}}
|
||||
input.primary.wide(type="submit" value="{{_ 'create'}}")
|
||||
span.quiet
|
||||
| {{_ 'or'}}
|
||||
@@ -276,9 +292,6 @@ template(name="createBoardPopup")
|
||||
= " "
|
||||
| {{{_ 'board-private-info'}}}
|
||||
a.js-change-visibility {{_ 'change'}}.
|
||||
a.flex.js-toggle-add-template-container
|
||||
.materialCheckBox#add-template-container
|
||||
span {{_ 'add-template-container'}}
|
||||
input.primary.wide(type="submit" value="{{_ 'create'}}")
|
||||
span.quiet
|
||||
| {{_ 'or'}}
|
||||
@@ -305,9 +318,6 @@ template(name="headerBarCreateBoardPopup")
|
||||
= " "
|
||||
| {{{_ 'board-private-info'}}}
|
||||
a.js-change-visibility {{_ 'change'}}.
|
||||
a.flex.js-toggle-add-template-container
|
||||
.materialCheckBox#add-template-container
|
||||
span {{_ 'add-template-container'}}
|
||||
input.primary.wide(type="submit" value="{{_ 'create'}}")
|
||||
span.quiet
|
||||
| {{_ 'or'}}
|
||||
@@ -335,9 +345,6 @@ template(name="createTemplateContainerPopup")
|
||||
= " "
|
||||
| {{{_ 'board-private-info'}}}
|
||||
a.js-change-visibility {{_ 'change'}}.
|
||||
a.flex.js-toggle-add-template-container
|
||||
.materialCheckBox#add-template-container
|
||||
span {{_ 'add-template-container'}}
|
||||
input.primary.wide(type="submit" value="{{_ 'create'}}")
|
||||
span.quiet
|
||||
| {{_ 'or'}}
|
||||
|
||||
@@ -17,6 +17,21 @@ const UPCLS = 'fa-sort-up';
|
||||
*/
|
||||
const sortCardsBy = new ReactiveVar('');
|
||||
|
||||
// Persist the card sort so it survives a page reload. Session is in-memory and
|
||||
// is reset on reload, which is why "sort by due date" kept reverting to the
|
||||
// default. See https://github.com/wekan/wekan/issues/5886
|
||||
const CARDS_SORT_BY_STORAGE_KEY = 'wekan-cards-sortBy';
|
||||
function setCardsSortBy(sortBy) {
|
||||
Session.set('sortBy', sortBy);
|
||||
try {
|
||||
if (sortBy) {
|
||||
window.localStorage.setItem(CARDS_SORT_BY_STORAGE_KEY, JSON.stringify(sortBy));
|
||||
} else {
|
||||
window.localStorage.removeItem(CARDS_SORT_BY_STORAGE_KEY);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
Template.boardChangeTitlePopup.events({
|
||||
async submit(event, templateInstance) {
|
||||
event.preventDefault();
|
||||
@@ -41,6 +56,16 @@ Template.boardChangeTitlePopup.events({
|
||||
|
||||
Template.boardHeaderBar.onCreated(function () {
|
||||
Meteor.subscribe('tableVisibilityModeSettings');
|
||||
// Restore the persisted card sort after a reload (Session is in-memory), so
|
||||
// both the sorting and the sort icon are remembered. See #5886.
|
||||
if (!Session.get('sortBy')) {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(CARDS_SORT_BY_STORAGE_KEY);
|
||||
if (stored) {
|
||||
Session.set('sortBy', JSON.parse(stored));
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
});
|
||||
|
||||
Template.boardHeaderBar.helpers({
|
||||
@@ -171,7 +196,7 @@ Template.boardHeaderBar.events({
|
||||
Filter.reset();
|
||||
},
|
||||
'click .js-sort-reset'() {
|
||||
Session.set('sortBy', '');
|
||||
setCardsSortBy('');
|
||||
},
|
||||
'click .js-open-search-view'() {
|
||||
const sidebar = getSidebarInstance();
|
||||
@@ -181,6 +206,12 @@ Template.boardHeaderBar.events({
|
||||
console.warn('Sidebar not available for setView');
|
||||
}
|
||||
},
|
||||
'click .js-toggle-dependencies'() {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
if (currentBoard) {
|
||||
currentBoard.setShowDependencies(!currentBoard.showDependencies);
|
||||
}
|
||||
},
|
||||
'click .js-multiselection-activate'() {
|
||||
const currentCard = Utils.getCurrentCardId();
|
||||
MultiSelection.activate();
|
||||
@@ -214,6 +245,10 @@ Template.boardChangeViewPopup.events({
|
||||
Utils.setBoardView('board-view-gantt');
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-open-table-view'() {
|
||||
Utils.setBoardView('board-view-table');
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
|
||||
// Shared setup for all create board popups
|
||||
@@ -221,6 +256,9 @@ function setupCreateBoardState(tpl) {
|
||||
tpl.visibilityMenuIsOpen = new ReactiveVar(false);
|
||||
tpl.visibility = new ReactiveVar('private');
|
||||
tpl.boardId = new ReactiveVar('');
|
||||
// #5850: default to creating a regular board; the dedicated
|
||||
// createTemplateContainerPopup sets this true in its onRendered.
|
||||
Session.set('createBoardAsTemplate', false);
|
||||
Meteor.subscribe('tableVisibilityModeSettings');
|
||||
}
|
||||
|
||||
@@ -246,7 +284,11 @@ async function createBoardSubmit(tpl, event) {
|
||||
const title = tpl.find('.js-new-board-title').value;
|
||||
const slug = getSlug(title) || 'board';
|
||||
|
||||
const addTemplateContainer = $('#add-template-container.is-checked').length > 0;
|
||||
// #5850: template boards are created via the dedicated "Add Template Board"
|
||||
// flow (createTemplateContainerPopup), signalled by this Session flag, rather
|
||||
// than a checkbox on the generic Create Board popup. Consume it immediately.
|
||||
const addTemplateContainer = Session.get('createBoardAsTemplate') === true;
|
||||
Session.set('createBoardAsTemplate', false);
|
||||
if (addTemplateContainer) {
|
||||
tpl.boardId.set(
|
||||
await Meteor.callAsync('createBoardWithInitialSwimlanes', {
|
||||
@@ -256,9 +298,9 @@ async function createBoardSubmit(tpl, event) {
|
||||
type: 'template-container',
|
||||
migrationVersion: 1,
|
||||
swimlanes: [
|
||||
{ title: 'Card Templates', sort: 1, type: 'template-container' },
|
||||
{ title: 'List Templates', sort: 2, type: 'template-container' },
|
||||
{ title: 'Board Templates', sort: 3, type: 'template-container' },
|
||||
{ title: 'Card Templates', sort: 1, type: 'template-container', role: 'card' },
|
||||
{ title: 'List Templates', sort: 2, type: 'template-container', role: 'list' },
|
||||
{ title: 'Board Templates', sort: 3, type: 'template-container', role: 'board' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -309,15 +351,11 @@ function createBoardEvents() {
|
||||
'click .js-change-visibility'(event, tpl) {
|
||||
tpl.visibilityMenuIsOpen.set(!tpl.visibilityMenuIsOpen.get());
|
||||
},
|
||||
'click .js-import': Popup.open('boardImportBoard'),
|
||||
async 'submit'(event, tpl) {
|
||||
await createBoardSubmit(tpl, event);
|
||||
},
|
||||
'click .js-import-board': Popup.open('chooseBoardSource'),
|
||||
'click .js-board-template': Popup.open('searchElement'),
|
||||
'click .js-toggle-add-template-container'() {
|
||||
$('#add-template-container').toggleClass('is-checked');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -345,8 +383,8 @@ Template.createTemplateContainerPopup.onCreated(function () {
|
||||
});
|
||||
|
||||
Template.createTemplateContainerPopup.onRendered(function () {
|
||||
// Always pre-check the template container checkbox for this popup
|
||||
$('#add-template-container').addClass('is-checked');
|
||||
// #5850: this dedicated popup always creates a template board.
|
||||
Session.set('createBoardAsTemplate', true);
|
||||
});
|
||||
|
||||
Template.createTemplateContainerPopup.helpers(createBoardHelpers());
|
||||
@@ -368,7 +406,6 @@ Template.headerBarCreateBoardPopup.events({
|
||||
'click .js-change-visibility'(event, tpl) {
|
||||
tpl.visibilityMenuIsOpen.set(!tpl.visibilityMenuIsOpen.get());
|
||||
},
|
||||
'click .js-import': Popup.open('boardImportBoard'),
|
||||
async submit(event, tpl) {
|
||||
await createBoardSubmit(tpl, event);
|
||||
// Immediately star boards created with the headerbar popup.
|
||||
@@ -376,9 +413,6 @@ Template.headerBarCreateBoardPopup.events({
|
||||
},
|
||||
'click .js-import-board': Popup.open('chooseBoardSource'),
|
||||
'click .js-board-template': Popup.open('searchElement'),
|
||||
'click .js-toggle-add-template-container'() {
|
||||
$('#add-template-container').toggleClass('is-checked');
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardVisibilityList.helpers({
|
||||
@@ -520,7 +554,7 @@ Template.cardsSortPopup.events({
|
||||
const sortBy = {
|
||||
dueAt: 1,
|
||||
};
|
||||
Session.set('sortBy', sortBy);
|
||||
setCardsSortBy(sortBy);
|
||||
sortCardsBy.set(TAPi18n.__('due-date'));
|
||||
Popup.back();
|
||||
},
|
||||
@@ -528,7 +562,7 @@ Template.cardsSortPopup.events({
|
||||
const sortBy = {
|
||||
title: 1,
|
||||
};
|
||||
Session.set('sortBy', sortBy);
|
||||
setCardsSortBy(sortBy);
|
||||
sortCardsBy.set(TAPi18n.__('title'));
|
||||
Popup.back();
|
||||
},
|
||||
@@ -536,7 +570,7 @@ Template.cardsSortPopup.events({
|
||||
const sortBy = {
|
||||
createdAt: 1,
|
||||
};
|
||||
Session.set('sortBy', sortBy);
|
||||
setCardsSortBy(sortBy);
|
||||
sortCardsBy.set(TAPi18n.__('date-created-newest-first'));
|
||||
Popup.back();
|
||||
},
|
||||
@@ -544,7 +578,7 @@ Template.cardsSortPopup.events({
|
||||
const sortBy = {
|
||||
createdAt: -1,
|
||||
};
|
||||
Session.set('sortBy', sortBy);
|
||||
setCardsSortBy(sortBy);
|
||||
sortCardsBy.set(TAPi18n.__('date-created-oldest-first'));
|
||||
Popup.back();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Pure helpers for building the option labels shown in board / list /
|
||||
* swimlane selectors (e.g. the move-card and copy-card popups).
|
||||
*
|
||||
* Regression #6135 ("numbering boards, list, swim caused some issues"):
|
||||
* a previous change prepended a sequential number to every option label
|
||||
* (e.g. "3. My Board"). That broke two things:
|
||||
*
|
||||
* 1. Boards whose name itself starts with a digit became hard to read.
|
||||
* 2. First-letter keyboard navigation in the native <select> dropdown
|
||||
* stopped working, because every option started with a number instead
|
||||
* of the board name.
|
||||
*
|
||||
* The fix: numbering is OPTIONAL and OFF by default. By default the label is
|
||||
* exactly the entity name, so its first character is the name's first
|
||||
* character and first-letter navigation works again. When numbering is
|
||||
* explicitly enabled, the number is appended as a trailing visual suffix so
|
||||
* the label still STARTS with the name.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build the label for a selectable board/list/swimlane option.
|
||||
*
|
||||
* @param {string} name The entity (board/list/swimlane) name/title.
|
||||
* @param {number} index Zero-based position in the list.
|
||||
* @param {Object} [opts]
|
||||
* @param {boolean} [opts.numbered=false] When true, append the 1-based
|
||||
* number as a trailing suffix. When false (default), return the bare
|
||||
* name so first-letter keyboard navigation keeps working.
|
||||
* @returns {string} The option label.
|
||||
*/
|
||||
export function boardOptionLabel(name, index, { numbered = false } = {}) {
|
||||
const label = name == null ? '' : String(name);
|
||||
if (!numbered) {
|
||||
return label;
|
||||
}
|
||||
const position = Number(index);
|
||||
const number = Number.isFinite(position) ? position + 1 : index;
|
||||
return `${label} (${number})`;
|
||||
}
|
||||
|
||||
export default boardOptionLabel;
|
||||
@@ -16,8 +16,8 @@
|
||||
}
|
||||
|
||||
.boards-left-menu {
|
||||
border-right: 1px solid #e0e0e0;
|
||||
padding-right: 12px;
|
||||
border-inline-end: 1px solid #e0e0e0;
|
||||
padding-inline-end: 12px;
|
||||
}
|
||||
|
||||
.boards-left-menu ul.menu {
|
||||
@@ -46,7 +46,7 @@
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
}
|
||||
.boards-left-menu .menu-item.active a,
|
||||
.boards-left-menu .menu-item a:hover {
|
||||
@@ -87,12 +87,12 @@
|
||||
border: none;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
|
||||
.workspace-tree {
|
||||
list-style: none;
|
||||
padding-left: 10px;
|
||||
padding-inline-start: 10px;
|
||||
}
|
||||
|
||||
.workspace-node {
|
||||
@@ -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;
|
||||
}
|
||||
@@ -345,7 +401,7 @@
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
margin-left: 10px; /* Extra space between MultiSelection toggle and Remove Filter */
|
||||
margin-inline-start: 10px; /* Extra space between MultiSelection toggle and Remove Filter */
|
||||
}
|
||||
|
||||
.boards-path-header .board-header-btn-close:hover {
|
||||
@@ -429,7 +485,7 @@
|
||||
margin: 0 8px;
|
||||
}
|
||||
.board-list li {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
width: 20%;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
@@ -533,7 +589,7 @@
|
||||
opacity: 0;
|
||||
padding: 9px 9px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
transition-duration: 0.15s;
|
||||
transition-property: color, font-size, background;
|
||||
}
|
||||
@@ -544,7 +600,7 @@
|
||||
line-height: 10px;
|
||||
padding: 9px 9px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
transition-duration: 0.15s;
|
||||
transition-property: color, font-size, background;
|
||||
}
|
||||
@@ -564,7 +620,7 @@
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
opacity: 0;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
padding: 9px 9px;
|
||||
transition-duration: 0.15s;
|
||||
transition-property: color, font-size, background;
|
||||
@@ -576,7 +632,7 @@
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
opacity: 0;
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
padding: 9px 9px;
|
||||
transition-duration: 0.15s;
|
||||
transition-property: color, font-size, background;
|
||||
@@ -642,7 +698,7 @@
|
||||
.board-list .board-list-item .multi-selection-checkbox {
|
||||
position: absolute !important;
|
||||
bottom: 4px !important;
|
||||
left: 4px !important;
|
||||
inset-inline-start: 4px !important;
|
||||
top: auto !important;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
@@ -673,7 +729,7 @@
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
top: auto !important;
|
||||
left: 4px !important;
|
||||
inset-inline-start: 4px !important;
|
||||
transform: none !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
@@ -711,7 +767,7 @@ body.grey-icons-enabled .board-list.is-multiselection-active .js-board.is-checke
|
||||
.board-backgrounds-list .board-background-select {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
width: 50%;
|
||||
padding-top: 12px;
|
||||
position: relative;
|
||||
@@ -721,10 +777,10 @@ body.grey-icons-enabled .board-list.is-multiselection-active .js-board.is-checke
|
||||
padding-top: 0;
|
||||
}
|
||||
.board-backgrounds-list .board-background-select:nth-child(2n) {
|
||||
padding-left: 6px;
|
||||
padding-inline-start: 6px;
|
||||
}
|
||||
.board-backgrounds-list .board-background-select:nth-child(2n+1) {
|
||||
padding-right: 6px;
|
||||
padding-inline-end: 6px;
|
||||
}
|
||||
.board-backgrounds-list .board-background-select .background-box {
|
||||
color: #fff;
|
||||
@@ -769,32 +825,32 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
float: none;
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
padding-right: 50px; /* Space for drag handle */
|
||||
padding-inline-end: 50px; /* Space for drag handle */
|
||||
}
|
||||
.board-list.mobile-view .board-list-item {
|
||||
overflow: visible;
|
||||
height: 8rem;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding-right: 50px; /* Ensure content doesn't overlap with drag handle */
|
||||
padding-inline-end: 50px; /* Ensure content doesn't overlap with drag handle */
|
||||
}
|
||||
|
||||
.board-list.mobile-view .board-list-item .details {
|
||||
padding-right: 50px; /* Extra space for drag handle */
|
||||
padding-inline-end: 50px; /* Extra space for drag handle */
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.board-list.mobile-view .board-list-item-sub-name {
|
||||
position: relative;
|
||||
top: -100px;
|
||||
left: -100px;
|
||||
inset-inline-start: -100px;
|
||||
}
|
||||
.board-list.mobile-view .board-handle {
|
||||
position: absolute;
|
||||
padding: 7px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
right: 10px;
|
||||
inset-inline-end: 10px;
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
background: rgba(0,0,0,0.3);
|
||||
@@ -878,32 +934,32 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
float: none;
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
padding-right: 50px; /* Space for drag handle */
|
||||
padding-inline-end: 50px; /* Space for drag handle */
|
||||
}
|
||||
.board-list .board-list-item {
|
||||
overflow: visible;
|
||||
height: 8rem;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding-right: 50px; /* Ensure content doesn't overlap with drag handle */
|
||||
padding-inline-end: 50px; /* Ensure content doesn't overlap with drag handle */
|
||||
}
|
||||
|
||||
.board-list .board-list-item .details {
|
||||
padding-right: 50px; /* Extra space for drag handle */
|
||||
padding-inline-end: 50px; /* Extra space for drag handle */
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.board-list .board-list-item-sub-name {
|
||||
position: relative;
|
||||
top: -100px;
|
||||
left: -100px;
|
||||
inset-inline-start: -100px;
|
||||
}
|
||||
.board-list .board-handle {
|
||||
position: absolute;
|
||||
padding: 7px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
right: 10px;
|
||||
inset-inline-end: 10px;
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
background: rgba(0,0,0,0.3);
|
||||
@@ -933,7 +989,7 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
padding: 7px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
right: 10px;
|
||||
inset-inline-end: 10px;
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
background: rgba(0,0,0,0.3);
|
||||
@@ -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
|
||||
@@ -971,248 +1019,11 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
}
|
||||
|
||||
.boards-left-menu {
|
||||
border-right: none !important;
|
||||
border-inline-end: none !important;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding-right: 0 !important;
|
||||
padding-inline-end: 0 !important;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.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%);
|
||||
right: 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;
|
||||
@@ -1221,13 +1032,13 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
.AllBoardTeams,
|
||||
.AllBoardOrgs,
|
||||
.AllBoardBtns {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
.js-AllBoardOrgs {
|
||||
margin-left: 16px;
|
||||
margin-inline-start: 16px;
|
||||
}
|
||||
.AllBoardTeams {
|
||||
margin-left: 16px;
|
||||
margin-inline-start: 16px;
|
||||
}
|
||||
.AllBoardButtonsContainer {
|
||||
margin: 16px;
|
||||
@@ -1296,7 +1107,7 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
padding: 6px 0 6px 8px;
|
||||
width: 100%;
|
||||
margin-bottom: 2px;
|
||||
margin-left: -4px;
|
||||
margin-inline-start: -4px;
|
||||
display: inline-block;
|
||||
}
|
||||
.minicard-lists {
|
||||
@@ -1312,7 +1123,7 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
}
|
||||
.flex-wrap .item {
|
||||
margin: 2px;
|
||||
padding-right: 6px;
|
||||
padding-inline-end: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -1403,3 +1214,26 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* #5157: show the board's background image on the board list tile, reusing the
|
||||
* existing board.backgroundImageURL. A dark overlay keeps the title readable. */
|
||||
.board-list-item.has-background-image {
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
position: relative;
|
||||
}
|
||||
.board-list-item.has-background-image .details,
|
||||
.board-list-item.has-background-image .board-list-item-name {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
}
|
||||
.board-list-item.has-background-image::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-radius: inherit;
|
||||
z-index: 0;
|
||||
}
|
||||
@@ -27,6 +27,15 @@ template(name="boardList")
|
||||
i.fa.fa-folder
|
||||
| {{_ 'allboards.remaining'}}
|
||||
span.menu-count {{menuItemCount 'remaining'}}
|
||||
if isSelectedMenu 'templates'
|
||||
if shareTargets.length
|
||||
li.menu-section-title {{_ 'share-template-with'}}
|
||||
each shareTargets
|
||||
li.menu-item.js-share-target(data-share-type="{{type}}", data-share-id="{{id}}", data-share-name="{{name}}", title="{{_ 'drag-template-here-to-share'}}")
|
||||
span.menu-label
|
||||
span.emoji-icon
|
||||
i.fa.fa-share-alt
|
||||
| {{name}}
|
||||
.workspaces-header
|
||||
span
|
||||
span.emoji-icon
|
||||
@@ -76,6 +85,18 @@ template(name="boardList")
|
||||
i.fa.fa-thumb-tack
|
||||
| {{_ 'multi-selection-active'}}
|
||||
.path-right
|
||||
.board-search
|
||||
span.emoji-icon
|
||||
i.fa.fa-search
|
||||
input.js-board-search-input(type="text" placeholder="{{_ 'search-boards'}}" value="{{boardSearch}}" autocomplete="off")
|
||||
if boardSearch
|
||||
a.board-search-clear.js-board-search-clear(title="{{_ 'filter-clear'}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-times
|
||||
a.board-header-btn.js-open-boards-sort(title="{{_ 'sort-boards'}}" class="{{#unless isBoardsSort 'custom'}}emphasis{{/unless}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-sort-alpha-asc
|
||||
span {{_ 'sort'}}
|
||||
if canModifyBoards
|
||||
if hasBoardsSelected
|
||||
button.js-archive-selected-boards.board-header-btn
|
||||
@@ -95,6 +116,13 @@ template(name="boardList")
|
||||
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-times
|
||||
if boardsPaginationActive
|
||||
.boards-pagination
|
||||
button.js-boards-prev-page(class="{{#if hasBoardsPrevPage}}{{else}}disabled{{/if}}")
|
||||
| <
|
||||
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'
|
||||
@@ -154,7 +182,9 @@ template(name="boardList")
|
||||
span.emoji-icon
|
||||
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
|
||||
else
|
||||
.board-list-item
|
||||
//- #5157: reuse the board's existing backgroundImageURL as the
|
||||
//- tile background on the board list page.
|
||||
.board-list-item(class="{{#if backgroundImageURL}}has-background-image{{/if}}" style="{{#if backgroundImageURL}}background-image:url('{{backgroundImageURL}}');{{/if}}")
|
||||
if BoardMultiSelection.isActive
|
||||
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
|
||||
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
|
||||
@@ -194,6 +224,24 @@ template(name="boardList")
|
||||
span.emoji-icon
|
||||
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
|
||||
|
||||
template(name="boardsSortPopup")
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-boards-sort(data-sort="custom" class="{{#if isBoardsSort 'custom'}}active{{/if}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-arrows
|
||||
| {{_ '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 dragscroll from '@wekanteam/dragscroll';
|
||||
import '/client/lib/dragscrollTouch';
|
||||
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
|
||||
import { BoardMultiSelection } from '/client/lib/boardMultiSelection';
|
||||
import { EscapeActions } from '/client/lib/escapeActions';
|
||||
@@ -11,8 +9,56 @@ import { Utils } from '/client/lib/utils';
|
||||
|
||||
// SubsManager removed for Meteor 3 migration
|
||||
|
||||
// #5850: which sharing scopes (organizations/teams/domains) the admin enabled
|
||||
// in Admin Panel > People > Shared Templates (stored in localStorage, like the
|
||||
// Shared Templates admin UI). Used to gate the drag-to-share drop targets.
|
||||
function loadSharedTemplatesScopes() {
|
||||
try {
|
||||
const raw = window.localStorage.getItem('sharedTemplatesScopes');
|
||||
const arr = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// #5850: share a (template) board with an Organization / Team / Domain by adding
|
||||
// it to the board's groups, add-only (does not remove existing shares and does
|
||||
// not add individual members). Called when a board is dropped on a share target.
|
||||
function shareBoardWith(boardId, shareType, name, id) {
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
if (!board || !boardId) {
|
||||
return;
|
||||
}
|
||||
if (shareType === 'org') {
|
||||
const orgs = (board.orgs || []).slice();
|
||||
if (!orgs.some(o => o.orgId === id)) {
|
||||
orgs.push({ orgId: id, orgDisplayName: name, isActive: true });
|
||||
Meteor.call('setBoardOrgs', orgs, boardId);
|
||||
}
|
||||
} else if (shareType === 'team') {
|
||||
const teams = (board.teams || []).slice();
|
||||
if (!teams.some(t => t.teamId === id)) {
|
||||
teams.push({ teamId: id, teamDisplayName: name, isActive: true });
|
||||
// Preserve the board's current members (group-only sharing adds no
|
||||
// individual members); setBoardTeams sets both members and teams.
|
||||
Meteor.call('setBoardTeams', teams, board.members || [], boardId);
|
||||
}
|
||||
} else if (shareType === 'domain') {
|
||||
const domains = (board.domains || []).slice();
|
||||
if (!domains.some(d => d.domain === id)) {
|
||||
domains.push({ domain: id, isActive: true });
|
||||
Meteor.call('setBoardDomains', domains, boardId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_WORKSPACE_ICON = '📁';
|
||||
|
||||
// #5799: how many board icons to show per page in the sorted (non-custom) modes.
|
||||
// Matches the Admin Panel > People page size.
|
||||
const BOARDS_PER_PAGE = 25;
|
||||
|
||||
function getCurrentWorkspacesTree() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const tree =
|
||||
@@ -115,9 +161,25 @@ Template.boardListHeaderBar.helpers({
|
||||
Template.boardList.onCreated(function () {
|
||||
Meteor.subscribe('setting');
|
||||
Meteor.subscribe('tableVisibilityModeSettings');
|
||||
this.selectedMenu = new ReactiveVar('starred');
|
||||
// Honor the URL-addressable sub-view (#5850). The route sets
|
||||
// Session 'boardListMenu' to 'starred', 'templates' or 'remaining'.
|
||||
this.selectedMenu = new ReactiveVar(Session.get('boardListMenu') || 'starred');
|
||||
this.selectedWorkspaceIdVar = new ReactiveVar(null);
|
||||
this.workspacesTreeVar = new ReactiveVar([]);
|
||||
// #5799: free-text search by board name. When non-empty it searches across
|
||||
// ALL the user's boards (Starred, Templates, Remaining and every workspace),
|
||||
// ignoring the selected-menu filter.
|
||||
this.boardSearchVar = new ReactiveVar('');
|
||||
// #5799: server-side pagination state for the sorted (non-custom) modes.
|
||||
this.boardsPageVar = new ReactiveVar(1);
|
||||
this.pagedBoardsVar = new ReactiveVar({ ids: [], total: 0 });
|
||||
// #5850: the user's orgs/teams that have the per-org/team Shared Templates
|
||||
// flag set (plus email domains), fetched via a non-admin server method since
|
||||
// the org/team publications are admin-only. Gates the drag-to-share targets.
|
||||
this.shareableGroups = new ReactiveVar({ orgs: [], teams: [], domains: [] });
|
||||
Meteor.call('getMyShareableGroups', (err, res) => {
|
||||
if (!err && res) this.shareableGroups.set(res);
|
||||
});
|
||||
let currUser = ReactiveCache.getCurrentUser();
|
||||
let userLanguage;
|
||||
if (currUser && currUser.profile) {
|
||||
@@ -196,15 +258,78 @@ Template.boardList.onCreated(function () {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Switch the sub-view live when the route changes (e.g. navigating to
|
||||
// /templates or /remaining while boardList is already rendered). Only the
|
||||
// three recognized menu values are honored so a workspace selection is not
|
||||
// clobbered (#5850).
|
||||
this.autorun(() => {
|
||||
const m = Session.get('boardListMenu');
|
||||
if (m === 'starred' || m === 'templates' || m === 'remaining') {
|
||||
this.selectedMenu.set(m);
|
||||
this.selectedWorkspaceIdVar.set(null);
|
||||
}
|
||||
});
|
||||
|
||||
// #5799: reset to the first page whenever the active filter changes (search
|
||||
// text, selected menu/workspace or sort mode). Depends only on those signals,
|
||||
// not on boardsPageVar, so paging next/prev does not reset itself.
|
||||
this.autorun(() => {
|
||||
this.boardSearchVar.get();
|
||||
this.selectedMenu.get();
|
||||
const cu = ReactiveCache.getCurrentUser();
|
||||
if (cu && typeof cu.getAllBoardsSortBy === 'function') {
|
||||
cu.getAllBoardsSortBy();
|
||||
}
|
||||
this.boardsPageVar.set(1);
|
||||
});
|
||||
|
||||
// #5799: fetch the current page of boards from the server for the sorted
|
||||
// (non-custom) modes, so only that page of icons is rendered. Custom (manual
|
||||
// drag order) stays unpaginated client-side so drag-reordering keeps working.
|
||||
// Uses the effective current user server-side, so it also works under
|
||||
// GlobalAdmin impersonation.
|
||||
this.autorun(() => {
|
||||
const cu = ReactiveCache.getCurrentUser();
|
||||
const sortBy =
|
||||
cu && typeof cu.getAllBoardsSortBy === 'function'
|
||||
? cu.getAllBoardsSortBy()
|
||||
: 'custom';
|
||||
if (sortBy === 'custom') {
|
||||
this.pagedBoardsVar.set({ ids: [], total: 0 });
|
||||
return;
|
||||
}
|
||||
const search = (this.boardSearchVar.get() || '').trim();
|
||||
const menu = this.selectedMenu.get();
|
||||
const page = this.boardsPageVar.get();
|
||||
Meteor.call(
|
||||
'getAllBoardsPage',
|
||||
{ search, sortBy, menu, page, perPage: BOARDS_PER_PAGE },
|
||||
(err, res) => {
|
||||
if (err) {
|
||||
console.error('getAllBoardsPage failed:', err);
|
||||
return;
|
||||
}
|
||||
if (res) this.pagedBoardsVar.set(res);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// The templates-container board is no longer auto-created at signup (#2339,
|
||||
// #5850), and it is no longer auto-created when the user merely opens the
|
||||
// Templates sub-view either: opening the view must not leave behind an empty
|
||||
// Template Container board the user never asked for. The container is created
|
||||
// only on demand -- either when the user explicitly adds one via the
|
||||
// "Add Template Board" flow (createTemplateContainerPopup), or lazily by
|
||||
// ensureTemplatesBoard right before a card/list/swimlane/board is actually
|
||||
// saved as a template. ensureTemplatesBoard remains idempotent (a no-op when
|
||||
// one already exists) so those save paths keep working.
|
||||
});
|
||||
|
||||
Template.boardList.onRendered(function () {
|
||||
// Enable drag-to-scroll on the All Boards list (.board-list.dragscroll), the
|
||||
// same way the board canvas/swimlanes do. This makes mouse drag-scrolling
|
||||
// work on desktop; finger drag-scrolling is handled by dragscrollTouch.js.
|
||||
// The .board-list element is stable across menu/workspace switches (only its
|
||||
// li children are re-rendered), so a single reset() binding is enough.
|
||||
dragscroll.reset();
|
||||
// Drag-to-scroll on the All Boards page is handled centrally for all
|
||||
// non-board pages by defaultLayout's route-aware autorun
|
||||
// (see client/components/main/layouts.js).
|
||||
|
||||
// jQuery sortable is disabled in favor of HTML5 drag-and-drop for space management
|
||||
// The old sortable code has been removed to prevent conflicts
|
||||
@@ -360,7 +485,12 @@ Template.boardList.helpers({
|
||||
'tableVisibilityMode-allowPrivateOnly',
|
||||
);
|
||||
|
||||
if (FlowRouter.getRouteName() === 'home') {
|
||||
// #5850: the All Boards sub-views are also reachable via their own routes
|
||||
// (/templates, /remaining), which must apply the same membership filtering
|
||||
// as the home route, otherwise their board list is empty (or falls into the
|
||||
// public-only branch below).
|
||||
const allBoardsRoutes = ['home', 'allboards-templates', 'allboards-remaining'];
|
||||
if (allBoardsRoutes.includes(FlowRouter.getRouteName())) {
|
||||
membershipOrs.push({ 'members.userId': Meteor.userId() });
|
||||
|
||||
const currUser = ReactiveCache.getCurrentUser();
|
||||
@@ -376,6 +506,12 @@ Template.boardList.helpers({
|
||||
let teamsIds = teamIdsUserBelongs.split(',');
|
||||
membershipOrs.push({ 'teams.teamId': { $in: teamsIds } });
|
||||
}
|
||||
|
||||
// #5850: boards shared with the user's email domain.
|
||||
const emailDomains = currUser?.emailDomains?.() || [];
|
||||
if (emailDomains.length) {
|
||||
membershipOrs.push({ 'domains.domain': { $in: emailDomains } });
|
||||
}
|
||||
if (membershipOrs.length) {
|
||||
query.$and.splice(2, 0, { $or: membershipOrs });
|
||||
}
|
||||
@@ -393,27 +529,53 @@ Template.boardList.helpers({
|
||||
|
||||
const boards = ReactiveCache.getBoards(query, {});
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
|
||||
// #5799: in a sorted (non-custom) mode the server already computed the
|
||||
// current page (filtered by menu/search and sorted), so render exactly that
|
||||
// ordered page of board icons. Custom (manual drag order) falls through to
|
||||
// the unpaginated client-side path below so drag-reordering keeps working.
|
||||
const sortMode =
|
||||
currentUser && typeof currentUser.getAllBoardsSortBy === 'function'
|
||||
? currentUser.getAllBoardsSortBy()
|
||||
: 'custom';
|
||||
if (sortMode !== 'custom') {
|
||||
const paged = tpl.pagedBoardsVar.get();
|
||||
return (paged.ids || [])
|
||||
.map((id) => ReactiveCache.getBoard(id))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
let list = boards;
|
||||
// Apply left menu filtering
|
||||
const sel = tpl.selectedMenu.get();
|
||||
const assignments =
|
||||
(currentUser &&
|
||||
currentUser.profile &&
|
||||
currentUser.profile.boardWorkspaceAssignments) ||
|
||||
{};
|
||||
if (sel === 'starred') {
|
||||
// Starred boards are always visible in Starred.
|
||||
list = list.filter((b) => currentUser && currentUser.hasStarred(b._id));
|
||||
} else if (sel === 'templates') {
|
||||
list = list.filter((b) => b.type === 'template-container');
|
||||
} else if (sel === 'remaining') {
|
||||
// Remaining only shows boards not assigned to any workspace.
|
||||
list = list.filter(
|
||||
(b) => !assignments[b._id] && b.type !== 'template-container',
|
||||
);
|
||||
|
||||
// #5799: when a board-name search is active, search across ALL the user's
|
||||
// boards (every menu/workspace) by title and skip the menu filter, so a
|
||||
// board in any category — Starred, Templates, Remaining or a (sub)workspace
|
||||
// — is found from a single search box.
|
||||
const search = (tpl.boardSearchVar.get() || '').trim().toLowerCase();
|
||||
if (search) {
|
||||
list = list.filter((b) => (b.title || '').toLowerCase().includes(search));
|
||||
} else {
|
||||
// Workspace view includes all boards in that workspace, including starred.
|
||||
list = list.filter((b) => assignments[b._id] === sel);
|
||||
// Apply left menu filtering
|
||||
const sel = tpl.selectedMenu.get();
|
||||
if (sel === 'starred') {
|
||||
// Starred boards are always visible in Starred.
|
||||
list = list.filter((b) => currentUser && currentUser.hasStarred(b._id));
|
||||
} else if (sel === 'templates') {
|
||||
list = list.filter((b) => b.type === 'template-container');
|
||||
} else if (sel === 'remaining') {
|
||||
// Remaining only shows boards not assigned to any workspace.
|
||||
list = list.filter(
|
||||
(b) => !assignments[b._id] && b.type !== 'template-container',
|
||||
);
|
||||
} else {
|
||||
// Workspace view includes all boards in that workspace, including starred.
|
||||
list = list.filter((b) => assignments[b._id] === sel);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentUser && typeof currentUser.sortBoardsForUser === 'function') {
|
||||
@@ -476,6 +638,24 @@ Template.boardList.helpers({
|
||||
isSelectedMenu(type) {
|
||||
return Template.instance().selectedMenu.get() === type;
|
||||
},
|
||||
// #5850: drag-to-share drop targets — the user's organizations/teams/domains,
|
||||
// gated by the admin's Shared Templates scopes (localStorage). Shown in the
|
||||
// Templates view so a personal Template Board can be dragged onto one to share.
|
||||
shareTargets() {
|
||||
const scopes = loadSharedTemplatesScopes();
|
||||
const groups = Template.instance().shareableGroups.get() || {};
|
||||
const targets = [];
|
||||
if (scopes.includes('organizations')) {
|
||||
(groups.orgs || []).forEach(o => targets.push(o));
|
||||
}
|
||||
if (scopes.includes('teams')) {
|
||||
(groups.teams || []).forEach(t => targets.push(t));
|
||||
}
|
||||
if (scopes.includes('domains')) {
|
||||
(groups.domains || []).forEach(d => targets.push(d));
|
||||
}
|
||||
return targets;
|
||||
},
|
||||
isSpaceSelected(id) {
|
||||
return Template.instance().selectedWorkspaceIdVar.get() === id;
|
||||
},
|
||||
@@ -539,6 +719,46 @@ Template.boardList.helpers({
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
return currentUser && !currentUser.isCommentOnly();
|
||||
},
|
||||
// #5799: current board-name search text (for the input value and the clear button).
|
||||
boardSearch() {
|
||||
return Template.instance().boardSearchVar.get();
|
||||
},
|
||||
// #5799: pagination controls (only shown in the sorted, non-custom modes).
|
||||
boardsPaginationActive() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const sortMode =
|
||||
currentUser && typeof currentUser.getAllBoardsSortBy === 'function'
|
||||
? currentUser.getAllBoardsSortBy()
|
||||
: 'custom';
|
||||
if (sortMode === 'custom') return false;
|
||||
const total = Template.instance().pagedBoardsVar.get().total || 0;
|
||||
return total > BOARDS_PER_PAGE;
|
||||
},
|
||||
boardsCurrentPage() {
|
||||
return Template.instance().boardsPageVar.get();
|
||||
},
|
||||
boardsTotalPages() {
|
||||
const total = Template.instance().pagedBoardsVar.get().total || 0;
|
||||
return Math.max(1, Math.ceil(total / BOARDS_PER_PAGE));
|
||||
},
|
||||
hasBoardsPrevPage() {
|
||||
return Template.instance().boardsPageVar.get() > 1;
|
||||
},
|
||||
hasBoardsNextPage() {
|
||||
const tpl = Template.instance();
|
||||
const total = tpl.pagedBoardsVar.get().total || 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / BOARDS_PER_PAGE));
|
||||
return tpl.boardsPageVar.get() < totalPages;
|
||||
},
|
||||
// #5799: current All Boards sort mode ('custom' | 'title-asc' | 'title-desc').
|
||||
isBoardsSort(mode) {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const current =
|
||||
currentUser && typeof currentUser.getAllBoardsSortBy === 'function'
|
||||
? currentUser.getAllBoardsSortBy()
|
||||
: 'custom';
|
||||
return current === mode;
|
||||
},
|
||||
hasBoardsSelected() {
|
||||
return BoardMultiSelection.count() > 0;
|
||||
},
|
||||
@@ -634,6 +854,41 @@ Template.boardList.events({
|
||||
Popup.open('createBoard')(evt);
|
||||
}
|
||||
},
|
||||
// #5799: choose how the All Boards page is sorted.
|
||||
'click .js-open-boards-sort': Popup.open('boardsSort'),
|
||||
// #5799: search boards by name across all categories.
|
||||
'input .js-board-search-input'(evt, tpl) {
|
||||
tpl.boardSearchVar.set(evt.currentTarget.value);
|
||||
},
|
||||
'keydown .js-board-search-input'(evt, tpl) {
|
||||
// Esc clears the search.
|
||||
if (evt.keyCode === 27) {
|
||||
tpl.boardSearchVar.set('');
|
||||
evt.currentTarget.value = '';
|
||||
}
|
||||
},
|
||||
'click .js-board-search-clear'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.boardSearchVar.set('');
|
||||
const input = tpl.find('.js-board-search-input');
|
||||
if (input) {
|
||||
input.value = '';
|
||||
input.focus();
|
||||
}
|
||||
},
|
||||
// #5799: board grid pagination (sorted modes only).
|
||||
'click .js-boards-prev-page'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const page = tpl.boardsPageVar.get();
|
||||
if (page > 1) tpl.boardsPageVar.set(page - 1);
|
||||
},
|
||||
'click .js-boards-next-page'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const total = tpl.pagedBoardsVar.get().total || 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / BOARDS_PER_PAGE));
|
||||
const page = tpl.boardsPageVar.get();
|
||||
if (page < totalPages) tpl.boardsPageVar.set(page + 1);
|
||||
},
|
||||
'click .js-star-board'(evt) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
@@ -643,6 +898,43 @@ Template.boardList.events({
|
||||
}
|
||||
},
|
||||
// HTML5 DnD from boards to spaces
|
||||
// #5850: drag a (template) board onto an Org/Team/Domain target to share it.
|
||||
'dragover .js-share-target'(evt) {
|
||||
evt.preventDefault();
|
||||
if (evt.originalEvent.dataTransfer) {
|
||||
evt.originalEvent.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
evt.currentTarget.classList.add('board-drag-hint');
|
||||
},
|
||||
'dragleave .js-share-target'(evt) {
|
||||
evt.currentTarget.classList.remove('board-drag-hint');
|
||||
},
|
||||
'drop .js-share-target'(evt) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
const target = evt.currentTarget;
|
||||
target.classList.remove('board-drag-hint');
|
||||
const dt = evt.originalEvent.dataTransfer;
|
||||
const data = dt.getData('text/plain');
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const isMulti = dt.getData('application/x-board-multi') === 'true';
|
||||
let boardIds;
|
||||
if (isMulti) {
|
||||
try {
|
||||
boardIds = JSON.parse(data);
|
||||
} catch (e) {
|
||||
boardIds = [];
|
||||
}
|
||||
} else {
|
||||
boardIds = [data];
|
||||
}
|
||||
const shareType = target.getAttribute('data-share-type');
|
||||
const name = target.getAttribute('data-share-name');
|
||||
const id = target.getAttribute('data-share-id');
|
||||
boardIds.forEach(boardId => shareBoardWith(boardId, shareType, name, id));
|
||||
},
|
||||
'dragstart .js-board'(evt) {
|
||||
const boardId = this._id;
|
||||
|
||||
@@ -1009,6 +1301,30 @@ Template.boardList.events({
|
||||
},
|
||||
});
|
||||
|
||||
// #5799: All Boards sort popup — pick custom (manual drag order) or
|
||||
// alphabetical A→Z / Z→A. The choice is stored per user.
|
||||
Template.boardsSortPopup.helpers({
|
||||
isBoardsSort(mode) {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const current =
|
||||
currentUser && typeof currentUser.getAllBoardsSortBy === 'function'
|
||||
? currentUser.getAllBoardsSortBy()
|
||||
: 'custom';
|
||||
return current === mode;
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardsSortPopup.events({
|
||||
'click .js-boards-sort'(evt) {
|
||||
evt.preventDefault();
|
||||
const mode = evt.currentTarget.getAttribute('data-sort');
|
||||
if (mode) {
|
||||
Meteor.call('setAllBoardsSortBy', mode);
|
||||
}
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
|
||||
Template.workspaceActionsPopup.helpers({
|
||||
workspaceName() {
|
||||
return this.name || '';
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
.dependency-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* #3392: the overlay SVG stays click-through, but each line is clickable (to
|
||||
open the edit/delete popup) even though its container is pointer-events:none. */
|
||||
.dependency-line {
|
||||
pointer-events: stroke;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dependency-temp-line {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* #3392: drag-to-connect handle on the minicard (no mode — cards stay
|
||||
clickable). Shown on hover; drag it onto another card to create a link. */
|
||||
.minicard .dependency-connect-handle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
inset-inline-end: -7px;
|
||||
right: -7px;
|
||||
transform: translateY(-50%);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #eb144c;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
|
||||
cursor: crosshair;
|
||||
opacity: 0;
|
||||
z-index: 101;
|
||||
}
|
||||
|
||||
.minicard:hover .dependency-connect-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dependency-line-popup .dependency-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.dependency-line-popup .card-dependency-icons li a.is-selected {
|
||||
border-color: #eb144c;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.dependency-line-delete {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #eb144c;
|
||||
}
|
||||
|
||||
/* #3392: card detail "Dependencies" rows */
|
||||
.card-details-dependencies-list .card-dependency {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.card-details-dependencies-list .card-dependency-link {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-details-dependencies-list .js-dependency-type {
|
||||
flex: 0 0 auto;
|
||||
max-width: 130px;
|
||||
}
|
||||
|
||||
.card-details-dependencies-list .js-dependency-color {
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-dependencies-popup .dependency-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.card-dependency-icons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.card-dependency-icons li a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.minicard .badge.minicard-dependencies .badge-icon {
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
template(name="dependencyOverlay")
|
||||
svg.dependency-overlay.js-dependency-overlay(xmlns="http://www.w3.org/2000/svg")
|
||||
defs
|
||||
each dependencyMarkers
|
||||
marker(id="{{markerId}}" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto" markerUnits="userSpaceOnUse")
|
||||
path(d="M0,0 L7,3 L0,6 Z" fill="{{color}}")
|
||||
each dependencyLines
|
||||
path.dependency-line(d="{{path}}" fill="none" stroke="{{color}}" stroke-width="2" marker-end="{{markerUrl}}")
|
||||
if tempLine
|
||||
path.dependency-temp-line(d="{{tempLine}}" fill="none" stroke="#eb144c" stroke-width="2" stroke-dasharray="6,4")
|
||||
|
||||
template(name="dependencyLinePopup")
|
||||
.dependency-line-popup
|
||||
label.dependency-field
|
||||
span {{_ 'dependency-type'}}
|
||||
select.js-line-type
|
||||
each typeOption
|
||||
if selected
|
||||
option(value="{{id}}" selected="selected") {{_ label}}
|
||||
else
|
||||
option(value="{{id}}") {{_ label}}
|
||||
label.dependency-field
|
||||
span {{_ 'dependency-color'}}
|
||||
input.js-line-color(type="color" value="{{color}}")
|
||||
.dependency-field
|
||||
span {{_ 'dependency-icon'}}
|
||||
ul.card-dependency-icons
|
||||
each dependencyIcons
|
||||
li
|
||||
a.js-line-icon(data-icon="{{name}}" title="{{name}}" class="{{#if selected}}is-selected{{/if}}")
|
||||
i.fa(class="fa-{{name}}")
|
||||
hr
|
||||
a.js-line-delete.dependency-line-delete
|
||||
i.fa.fa-times
|
||||
| {{_ 'remove-dependency'}}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import {
|
||||
DEPENDENCY_ICON_CHOICES,
|
||||
DEPENDENCY_TYPES,
|
||||
dependencyTypeMeta,
|
||||
normalizeDependencies,
|
||||
} from '/models/metadata/dependencies';
|
||||
|
||||
// #3392: PI Program Board "Red Strings".
|
||||
// Draw colored, typed connection lines (SVG paths) between cards that declare
|
||||
// cardDependencies. The board stays fully usable (cards clickable) — drawing a
|
||||
// new link is initiated from a small connect handle on each minicard (drag it
|
||||
// onto another card, piplanning.io / Kendis / Miro style), and a link can be
|
||||
// edited/deleted by clicking its line. The overlay SVG itself stays
|
||||
// pointer-events:none; only the lines (and the minicard handles) capture events.
|
||||
//
|
||||
// Coordinates are computed from the live DOM (getBoundingClientRect of
|
||||
// [data-card-id] minicards) translated into the overlay SVG's own coordinate
|
||||
// space, so scrolling/resizing is handled by recomputing relative to the SVG's
|
||||
// current bounding rect.
|
||||
|
||||
// Stable, css-safe marker id for an arrowhead of a given color.
|
||||
function markerIdForColor(color) {
|
||||
return `dependency-arrowhead-${String(color).replace(/[^a-zA-Z0-9]/g, '')}`;
|
||||
}
|
||||
|
||||
Template.dependencyOverlay.onCreated(function () {
|
||||
this.lines = new ReactiveVar([]);
|
||||
this.markers = new ReactiveVar([]);
|
||||
this.tempLine = new ReactiveVar(null); // line being drawn while dragging
|
||||
// Guard against deferred (rAF / setTimeout) callbacks touching the DOM after
|
||||
// the overlay is destroyed ("Can't select in removed DomRange").
|
||||
this._overlayDestroyed = false;
|
||||
|
||||
// Rect of a card relative to the overlay SVG, or null if not rendered.
|
||||
this.rectOf = (cardId, svgRect) => {
|
||||
const el = document.querySelector(`[data-card-id="${cardId}"]`);
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width === 0 && r.height === 0) return null;
|
||||
return { x: r.left - svgRect.left, y: r.top - svgRect.top, w: r.width, h: r.height };
|
||||
};
|
||||
|
||||
this.recompute = () => {
|
||||
if (this._overlayDestroyed || !this.view || this.view.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
const svg = this.find('.js-dependency-overlay');
|
||||
if (!svg) {
|
||||
return;
|
||||
}
|
||||
const board = Utils.getCurrentBoard();
|
||||
if (!board) {
|
||||
this.lines.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
const centerOf = cardId => this.rectOf(cardId, svgRect);
|
||||
|
||||
const cards = ReactiveCache.getCards({
|
||||
boardId: board._id,
|
||||
archived: false,
|
||||
});
|
||||
|
||||
const lines = [];
|
||||
const markerColors = new Set();
|
||||
cards.forEach(card => {
|
||||
const deps = normalizeDependencies(card.cardDependencies);
|
||||
if (deps.length === 0) {
|
||||
return;
|
||||
}
|
||||
const sourceRect = centerOf(card._id);
|
||||
if (!sourceRect) {
|
||||
return;
|
||||
}
|
||||
deps.forEach(dep => {
|
||||
const targetRect = centerOf(dep.cardId);
|
||||
if (!targetRect) {
|
||||
return;
|
||||
}
|
||||
const meta = dependencyTypeMeta(dep.type);
|
||||
// Draw the arrow from the prerequisite card's right edge to the
|
||||
// dependent card's left edge. For forward relations (blocks/fixes/
|
||||
// related-to) the source card is the prerequisite; for the reverse
|
||||
// relations (is-blocked-by/is-fixed-by) the target is.
|
||||
const prereq = meta.forward ? sourceRect : targetRect;
|
||||
const dependent = meta.forward ? targetRect : sourceRect;
|
||||
const x1 = prereq.x + prereq.w;
|
||||
const y1 = prereq.y + prereq.h / 2;
|
||||
const x2 = dependent.x;
|
||||
const y2 = dependent.y + dependent.h / 2;
|
||||
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
|
||||
const path = `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
||||
const markerId = markerIdForColor(dep.color);
|
||||
if (meta.directed) {
|
||||
markerColors.add(dep.color);
|
||||
}
|
||||
lines.push({
|
||||
path,
|
||||
color: dep.color,
|
||||
type: dep.type,
|
||||
icon: dep.icon,
|
||||
directed: meta.directed,
|
||||
markerId: meta.directed ? markerId : '',
|
||||
// Source card (owns the dependency) and target card.
|
||||
fromId: card._id,
|
||||
toId: dep.cardId,
|
||||
});
|
||||
});
|
||||
});
|
||||
this.lines.set(lines);
|
||||
this.markers.set(
|
||||
[...markerColors].map(color => ({
|
||||
color,
|
||||
markerId: markerIdForColor(color),
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
this.scheduleRecompute = () => {
|
||||
if (this._overlayDestroyed) return;
|
||||
if (this._raf) {
|
||||
window.cancelAnimationFrame(this._raf);
|
||||
}
|
||||
this._raf = window.requestAnimationFrame(() => {
|
||||
this._raf = null;
|
||||
this.recompute();
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
Template.dependencyOverlay.onRendered(function () {
|
||||
const instance = this;
|
||||
const svg = this.find('.js-dependency-overlay');
|
||||
this.scrollEl = svg ? this.firstNode.closest('.board-canvas') : null;
|
||||
|
||||
this.autorun(() => {
|
||||
const board = Utils.getCurrentBoard();
|
||||
if (board) {
|
||||
ReactiveCache.getCards({ boardId: board._id, archived: false });
|
||||
}
|
||||
instance.scheduleRecompute();
|
||||
});
|
||||
|
||||
this.onScroll = () => instance.scheduleRecompute();
|
||||
this.onResize = () => instance.scheduleRecompute();
|
||||
|
||||
if (this.scrollEl) {
|
||||
this.scrollEl.addEventListener('scroll', this.onScroll, { passive: true });
|
||||
}
|
||||
window.addEventListener('scroll', this.onScroll, { passive: true });
|
||||
window.addEventListener('resize', this.onResize);
|
||||
|
||||
// --- drag-to-connect (from a minicard's connect handle) -----------------
|
||||
// Find the card under a viewport point. The overlay is pointer-events:none,
|
||||
// so elementsFromPoint returns the minicards directly.
|
||||
const cardIdAtPoint = (clientX, clientY) => {
|
||||
const els = document.elementsFromPoint(clientX, clientY);
|
||||
for (const el of els) {
|
||||
if (svg && svg.contains(el)) continue;
|
||||
const card = el.closest && el.closest('[data-card-id]');
|
||||
if (card) return card.getAttribute('data-card-id');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
this.onMouseDown = e => {
|
||||
if (e.button !== 0) return;
|
||||
const handle =
|
||||
e.target.closest && e.target.closest('.js-dependency-connect-handle');
|
||||
if (!handle) return;
|
||||
const cardEl = handle.closest('[data-card-id]');
|
||||
if (!cardEl) return;
|
||||
// Beat the card drag-sort: capture-phase + stop propagation.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
instance.dragSourceId = cardEl.getAttribute('data-card-id');
|
||||
};
|
||||
|
||||
this.onMouseMove = e => {
|
||||
if (!instance.dragSourceId || !svg) return;
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
const from = instance.rectOf(instance.dragSourceId, svgRect);
|
||||
if (!from) return;
|
||||
const x1 = from.x + from.w;
|
||||
const y1 = from.y + from.h / 2;
|
||||
const x2 = e.clientX - svgRect.left;
|
||||
const y2 = e.clientY - svgRect.top;
|
||||
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
|
||||
instance.tempLine.set(
|
||||
`M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`,
|
||||
);
|
||||
};
|
||||
|
||||
this.onMouseUp = e => {
|
||||
if (!instance.dragSourceId) return;
|
||||
const sourceId = instance.dragSourceId;
|
||||
instance.dragSourceId = null;
|
||||
instance.tempLine.set(null);
|
||||
const targetId = cardIdAtPoint(e.clientX, e.clientY);
|
||||
if (targetId && targetId !== sourceId) {
|
||||
const card = ReactiveCache.getCard(sourceId);
|
||||
if (card && typeof card.addDependency === 'function') {
|
||||
card.addDependency(targetId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Capture phase so the connect drag starts before the sortable grabs the card.
|
||||
document.addEventListener('mousedown', this.onMouseDown, true);
|
||||
document.addEventListener('mousemove', this.onMouseMove);
|
||||
document.addEventListener('mouseup', this.onMouseUp);
|
||||
|
||||
this._initTimeout = Meteor.setTimeout(() => instance.scheduleRecompute(), 300);
|
||||
});
|
||||
|
||||
Template.dependencyOverlay.onDestroyed(function () {
|
||||
this._overlayDestroyed = true;
|
||||
if (this.scrollEl) {
|
||||
this.scrollEl.removeEventListener('scroll', this.onScroll);
|
||||
}
|
||||
window.removeEventListener('scroll', this.onScroll);
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
if (this.onMouseDown) document.removeEventListener('mousedown', this.onMouseDown, true);
|
||||
if (this.onMouseMove) document.removeEventListener('mousemove', this.onMouseMove);
|
||||
if (this.onMouseUp) document.removeEventListener('mouseup', this.onMouseUp);
|
||||
if (this._raf) {
|
||||
window.cancelAnimationFrame(this._raf);
|
||||
}
|
||||
if (this._initTimeout) {
|
||||
Meteor.clearTimeout(this._initTimeout);
|
||||
}
|
||||
});
|
||||
|
||||
Template.dependencyOverlay.helpers({
|
||||
dependencyLines() {
|
||||
return Template.instance().lines.get();
|
||||
},
|
||||
dependencyMarkers() {
|
||||
return Template.instance().markers.get();
|
||||
},
|
||||
tempLine() {
|
||||
return Template.instance().tempLine.get();
|
||||
},
|
||||
markerUrl() {
|
||||
return this.markerId ? `url(#${this.markerId})` : '';
|
||||
},
|
||||
});
|
||||
|
||||
Template.dependencyOverlay.events({
|
||||
'click .dependency-line'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// `this` is the line data object (fromId/toId/type/color/icon).
|
||||
Popup.open('dependencyLine').call(this, event);
|
||||
},
|
||||
});
|
||||
|
||||
// #3392: popup to edit or delete an on-board dependency line. Data context is the
|
||||
// line object { fromId, toId, type, color, icon }.
|
||||
Template.dependencyLinePopup.helpers({
|
||||
typeOption() {
|
||||
const current = this.type;
|
||||
return DEPENDENCY_TYPES.map(t => ({
|
||||
id: t.id,
|
||||
label: `dependency-type-${t.id}`,
|
||||
selected: t.id === current,
|
||||
}));
|
||||
},
|
||||
color() {
|
||||
return this.color;
|
||||
},
|
||||
dependencyIcons() {
|
||||
const current = this.icon;
|
||||
return DEPENDENCY_ICON_CHOICES.map(name => ({ name, selected: name === current }));
|
||||
},
|
||||
});
|
||||
|
||||
Template.dependencyLinePopup.events({
|
||||
'change .js-line-type'(event) {
|
||||
const card = ReactiveCache.getCard(this.fromId);
|
||||
if (card) card.setDependencyProps(this.toId, { type: event.currentTarget.value });
|
||||
},
|
||||
'change .js-line-color'(event) {
|
||||
const card = ReactiveCache.getCard(this.fromId);
|
||||
if (card) card.setDependencyProps(this.toId, { color: event.currentTarget.value });
|
||||
},
|
||||
'click .js-line-icon'(event) {
|
||||
const card = ReactiveCache.getCard(this.fromId);
|
||||
if (card) card.setDependencyProps(this.toId, { icon: event.currentTarget.dataset.icon });
|
||||
},
|
||||
'click .js-line-delete'() {
|
||||
const card = ReactiveCache.getCard(this.fromId);
|
||||
if (card) card.removeDependency(this.toId);
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
@@ -35,7 +35,7 @@
|
||||
}
|
||||
|
||||
.original-positions-loading i {
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
|
||||
.original-positions-filters {
|
||||
@@ -117,7 +117,7 @@
|
||||
}
|
||||
|
||||
.original-position-item-details {
|
||||
margin-left: 24px;
|
||||
margin-inline-start: 24px;
|
||||
}
|
||||
|
||||
.original-position-description {
|
||||
@@ -191,7 +191,7 @@
|
||||
}
|
||||
|
||||
.original-position-item-details {
|
||||
margin-left: 0;
|
||||
margin-inline-start: 0;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/* Board Table view: search + pagination controls, styled to match the
|
||||
Admin Panel / People page (see client/components/settings/peopleBody.css).
|
||||
The table itself reuses the My Cards table styling (.my-cards-board-table). */
|
||||
|
||||
.table-view-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 1vh 0;
|
||||
}
|
||||
|
||||
.table-view-search-box {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.table-view-search-box input {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 0.4vw;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.table-view-search-box button {
|
||||
min-width: 90px;
|
||||
margin-inline-start: 6px;
|
||||
background: #01628c;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.4vw;
|
||||
cursor: pointer;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.table-view-search-box button:hover {
|
||||
background: #005377;
|
||||
}
|
||||
|
||||
.table-view-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.table-view-pagination button {
|
||||
min-width: 32px;
|
||||
height: 30px;
|
||||
border: 1px solid #bbb;
|
||||
background: #fff;
|
||||
border-radius: 0.4vw;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table-view-pagination button.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.table-view-page-info {
|
||||
min-width: 72px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Sortable column headers (Excel-like): clickable, with a sort-direction
|
||||
arrow appended by the sortIndicator helper. */
|
||||
.my-cards-board-table th.js-table-view-sort {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.my-cards-board-table th.js-table-view-sort:hover {
|
||||
color: #01628c;
|
||||
}
|
||||
|
||||
/* Leftmost "Edit" cell: an Edit link (pencil icon + text) styled like the one
|
||||
on the Admin Panel / People page, opening the card when clicked. */
|
||||
.my-cards-board-table td.table-view-cell-edit {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.my-cards-board-table td.table-view-cell-edit .edit-card {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.my-cards-board-table td.table-view-cell-edit .edit-card i.fa {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
|
||||
/* Word-wrap the Card / List / Swimlane cells so long text breaks onto the next
|
||||
line instead of overflowing across the other columns. */
|
||||
.my-cards-board-table td.table-view-cell-wrap {
|
||||
max-width: 250px;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* The +viewer template wraps its markdown in a block-level .viewer div. Inside
|
||||
a table cell it should not add extra vertical margins, and inside a label it
|
||||
must stay inline so the label keeps its pill shape. */
|
||||
.my-cards-board-table .viewer {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.my-cards-board-table .viewer p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.my-cards-board-table .card-label .viewer,
|
||||
.my-cards-board-table .card-label .viewer p {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Word-wrap the Labels cell so each label pill wraps onto the next line instead
|
||||
of stretching the column or overflowing across the date columns. */
|
||||
.my-cards-board-table td.table-view-cell-labels {
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.my-cards-board-table td.table-view-cell-labels .div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.my-cards-board-table td.table-view-cell-labels .card-label {
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Keep each date in a single column; the clickable badge already styles itself
|
||||
like the card detail / Gantt date badges. */
|
||||
.my-cards-board-table td.table-view-cell-date {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.my-cards-board-table td.table-view-cell-date .card-date {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
template(name="tableView")
|
||||
if currentBoard
|
||||
.wrapper
|
||||
.table-view-controls
|
||||
.table-view-search-box
|
||||
input.js-table-view-search(type="text" placeholder="{{_ 'search'}}" aria-label="{{_ 'search'}}")
|
||||
button.js-table-view-search-button(type="button")
|
||||
i.fa.fa-search
|
||||
| {{_ 'search'}}
|
||||
.table-view-pagination
|
||||
button.js-table-view-prev-page(type="button" class="{{#unless hasPrevPage}}disabled{{/unless}}" aria-label="{{_ 'previous'}}")
|
||||
i.fa.fa-chevron-left
|
||||
span.table-view-page-info {{currentPage}} / {{totalPages}}
|
||||
button.js-table-view-next-page(type="button" class="{{#unless hasNextPage}}disabled{{/unless}}" aria-label="{{_ 'next'}}")
|
||||
i.fa.fa-chevron-right
|
||||
table.my-cards-board-table
|
||||
caption.sr-only {{currentBoard.title}}
|
||||
thead
|
||||
tr
|
||||
th(scope="col")
|
||||
th.js-table-view-sort(scope="col" data-sort="card") {{_ "card"}} {{sortIndicator 'card'}}
|
||||
th.js-table-view-sort(scope="col" data-sort="list") {{_ "list"}} {{sortIndicator 'list'}}
|
||||
th.js-table-view-sort(scope="col" data-sort="swimlane") {{_ "swimlane"}} {{sortIndicator 'swimlane'}}
|
||||
unless isMiniScreen
|
||||
th(scope="col") {{_ "assignees"}}
|
||||
th(scope="col") {{_ "members"}}
|
||||
th(scope="col") {{_ "labels"}}
|
||||
if showReceivedColumn
|
||||
th.js-table-view-sort(scope="col" data-sort="received") {{_ "card-received"}} {{sortIndicator 'received'}}
|
||||
if showStartColumn
|
||||
th.js-table-view-sort(scope="col" data-sort="start") {{_ "card-start"}} {{sortIndicator 'start'}}
|
||||
if showDueColumn
|
||||
th.js-table-view-sort(scope="col" data-sort="due") {{_ "card-due"}} {{sortIndicator 'due'}}
|
||||
if showEndColumn
|
||||
th.js-table-view-sort(scope="col" data-sort="end") {{_ "card-end"}} {{sortIndicator 'end'}}
|
||||
tbody
|
||||
each row in rows
|
||||
tr
|
||||
td.table-view-cell-edit
|
||||
a.edit-card.js-table-view-edit-card(href=row.card.originRelativeUrl data-card-id=row.card._id title="{{_ 'edit'}}")
|
||||
i.fa.fa-pencil-square-o
|
||||
| {{_ 'edit'}}
|
||||
td.table-view-cell-wrap
|
||||
unless isMiniScreen
|
||||
.my-cards-board-badge(class=row.colorClass)
|
||||
.my-cards-card-title-table
|
||||
+viewer
|
||||
= row.title
|
||||
td.table-view-cell-wrap
|
||||
+viewer
|
||||
= row.listTitle
|
||||
td.table-view-cell-wrap
|
||||
+viewer
|
||||
= row.swimlaneTitle
|
||||
unless isMiniScreen
|
||||
td
|
||||
.div
|
||||
each assignee in row.card.getAssignees
|
||||
a.name
|
||||
+userAvatar(userId=assignee noRemove=true)
|
||||
td
|
||||
.div
|
||||
each member in row.card.getMembers
|
||||
a.name
|
||||
+userAvatar(userId=member noRemove=true)
|
||||
td.table-view-cell-labels
|
||||
.div
|
||||
each label in row.labels
|
||||
span.card-label(class="card-label-{{label.color}}" title="{{label.name}}")
|
||||
+viewer
|
||||
= label.name
|
||||
if showReceivedColumn
|
||||
td.table-view-cell-date
|
||||
with row.card
|
||||
if getReceived
|
||||
+cardReceivedDate
|
||||
else
|
||||
if canModifyCard
|
||||
a.card-label.add-label.js-received-date(title="{{_ 'card-received'}}")
|
||||
i.fa.fa-plus
|
||||
if showStartColumn
|
||||
td.table-view-cell-date
|
||||
with row.card
|
||||
if getStart
|
||||
+cardStartDate
|
||||
else
|
||||
if canModifyCard
|
||||
a.card-label.add-label.js-start-date(title="{{_ 'card-start'}}")
|
||||
i.fa.fa-plus
|
||||
if showDueColumn
|
||||
td.table-view-cell-date
|
||||
with row.card
|
||||
if getDue
|
||||
+cardDueDate
|
||||
else
|
||||
if canModifyCard
|
||||
a.card-label.add-label.js-due-date(title="{{_ 'card-due'}}")
|
||||
i.fa.fa-plus
|
||||
if showEndColumn
|
||||
td.table-view-cell-date
|
||||
with row.card
|
||||
if getEnd
|
||||
+cardEndDate
|
||||
else
|
||||
if canModifyCard
|
||||
a.card-label.add-label.js-end-date(title="{{_ 'card-end'}}")
|
||||
i.fa.fa-plus
|
||||
@@ -0,0 +1,254 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
|
||||
// Board "Table" view: lists every card of the current board in a table that
|
||||
// reuses the My Cards table styling (the .my-cards-board-table CSS classes in
|
||||
// client/components/main/myCards.css). It is the per-board counterpart of the
|
||||
// My Cards table view, which spans all boards.
|
||||
//
|
||||
// Search, column sorting (Excel-like) and pagination all run client-side: the
|
||||
// board's cards are already loaded reactively via board.cards(), so there is no
|
||||
// need for the server-side limit/skip publication the Admin People page uses.
|
||||
|
||||
const rowsPerPage = 25;
|
||||
|
||||
Template.tableView.onCreated(function () {
|
||||
this.searchQuery = new ReactiveVar('');
|
||||
this.sortField = new ReactiveVar('card'); // card | list | swimlane | due
|
||||
this.sortDirection = new ReactiveVar(1); // 1 ascending, -1 descending
|
||||
this.page = new ReactiveVar(1);
|
||||
this.filteredRows = new ReactiveVar([]);
|
||||
|
||||
// Recompute the flat, filtered and sorted row list whenever the board cards,
|
||||
// search query or sort order change. Pagination is applied separately in the
|
||||
// rows() helper so paging does not rebuild the whole list.
|
||||
this.autorun(() => {
|
||||
const board = Utils.getCurrentBoard();
|
||||
if (!board) {
|
||||
this.filteredRows.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const query = this.searchQuery.get().trim().toLowerCase();
|
||||
const field = this.sortField.get();
|
||||
const direction = this.sortDirection.get();
|
||||
|
||||
const rows = [];
|
||||
board.cards().forEach(card => {
|
||||
const swimlane = card.getSwimlane();
|
||||
const list = card.getList();
|
||||
if (!swimlane || swimlane.archived || !list || list.archived) return;
|
||||
|
||||
const labels = (card.labelIds || [])
|
||||
.map(labelId => {
|
||||
const label = board.getLabelById(labelId);
|
||||
return label ? { name: label.name || '', color: label.color } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
rows.push({
|
||||
card,
|
||||
title: card.title || '',
|
||||
listTitle: list.title || '',
|
||||
swimlaneTitle: swimlane.title || '',
|
||||
colorClass: board.colorClass(),
|
||||
receivedAt: card.getReceived() || null,
|
||||
startAt: card.getStart() || null,
|
||||
dueAt: card.getDue() || null,
|
||||
endAt: card.getEnd() || null,
|
||||
labels,
|
||||
});
|
||||
});
|
||||
|
||||
let filtered = rows;
|
||||
if (query) {
|
||||
filtered = rows.filter(row => {
|
||||
const haystack = [
|
||||
row.title,
|
||||
row.listTitle,
|
||||
row.swimlaneTitle,
|
||||
...row.labels.map(label => label.name),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.indexOf(query) !== -1;
|
||||
});
|
||||
}
|
||||
|
||||
// Map a date sort field to the matching row property.
|
||||
const dateFieldProp = {
|
||||
received: 'receivedAt',
|
||||
start: 'startAt',
|
||||
due: 'dueAt',
|
||||
end: 'endAt',
|
||||
};
|
||||
|
||||
filtered = filtered.slice().sort((a, b) => {
|
||||
const dateProp = dateFieldProp[field];
|
||||
if (dateProp) {
|
||||
// Cards without the date sort last, regardless of direction.
|
||||
const av = a[dateProp] ? new Date(a[dateProp]).getTime() : Infinity;
|
||||
const bv = b[dateProp] ? new Date(b[dateProp]).getTime() : Infinity;
|
||||
return (av - bv) * direction;
|
||||
}
|
||||
let av;
|
||||
let bv;
|
||||
if (field === 'list') {
|
||||
av = a.listTitle;
|
||||
bv = b.listTitle;
|
||||
} else if (field === 'swimlane') {
|
||||
av = a.swimlaneTitle;
|
||||
bv = b.swimlaneTitle;
|
||||
} else {
|
||||
av = a.title;
|
||||
bv = b.title;
|
||||
}
|
||||
return (
|
||||
av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' }) *
|
||||
direction
|
||||
);
|
||||
});
|
||||
|
||||
this.filteredRows.set(filtered);
|
||||
});
|
||||
});
|
||||
|
||||
Template.tableView.helpers({
|
||||
currentBoard() {
|
||||
return Utils.getCurrentBoard();
|
||||
},
|
||||
|
||||
rows() {
|
||||
const tpl = Template.instance();
|
||||
const all = tpl.filteredRows.get();
|
||||
const totalPages = Math.max(1, Math.ceil(all.length / rowsPerPage));
|
||||
// Clamp on read so a shrinking list (deleted cards) never shows an empty
|
||||
// page; no write here, to avoid a reactive loop.
|
||||
const page = Math.min(tpl.page.get(), totalPages);
|
||||
const start = (page - 1) * rowsPerPage;
|
||||
return all.slice(start, start + rowsPerPage);
|
||||
},
|
||||
|
||||
currentPage() {
|
||||
return Template.instance().page.get();
|
||||
},
|
||||
|
||||
totalPages() {
|
||||
const count = Template.instance().filteredRows.get().length;
|
||||
return Math.max(1, Math.ceil(count / rowsPerPage));
|
||||
},
|
||||
|
||||
hasPrevPage() {
|
||||
return Template.instance().page.get() > 1;
|
||||
},
|
||||
|
||||
hasNextPage() {
|
||||
const tpl = Template.instance();
|
||||
const totalPages = Math.max(
|
||||
1,
|
||||
Math.ceil(tpl.filteredRows.get().length / rowsPerPage),
|
||||
);
|
||||
return tpl.page.get() < totalPages;
|
||||
},
|
||||
|
||||
// A date column is shown unless BOTH its "Show at Card" (allowsXxxDate) and
|
||||
// "Show at Minicard" (allowsXxxDateOnMinicard) board settings are unchecked.
|
||||
showReceivedColumn() {
|
||||
const board = Utils.getCurrentBoard();
|
||||
return !!board && (board.allowsReceivedDate || board.allowsReceivedDateOnMinicard);
|
||||
},
|
||||
|
||||
showStartColumn() {
|
||||
const board = Utils.getCurrentBoard();
|
||||
return !!board && (board.allowsStartDate || board.allowsStartDateOnMinicard);
|
||||
},
|
||||
|
||||
showDueColumn() {
|
||||
const board = Utils.getCurrentBoard();
|
||||
return !!board && (board.allowsDueDate || board.allowsDueDateOnMinicard);
|
||||
},
|
||||
|
||||
showEndColumn() {
|
||||
const board = Utils.getCurrentBoard();
|
||||
return !!board && (board.allowsEndDate || board.allowsEndDateOnMinicard);
|
||||
},
|
||||
|
||||
// Excel-like sort arrow shown on the active sort column header.
|
||||
sortIndicator(field) {
|
||||
const tpl = Template.instance();
|
||||
if (tpl.sortField.get() !== field) return '';
|
||||
return tpl.sortDirection.get() === 1 ? '▲' : '▼';
|
||||
},
|
||||
});
|
||||
|
||||
Template.tableView.events({
|
||||
'click .js-table-view-search-button'(event, tpl) {
|
||||
event.preventDefault();
|
||||
tpl.searchQuery.set(tpl.$('.js-table-view-search').val() || '');
|
||||
tpl.page.set(1);
|
||||
},
|
||||
|
||||
'keydown .js-table-view-search'(event, tpl) {
|
||||
if (event.keyCode === 13) {
|
||||
event.preventDefault();
|
||||
tpl.searchQuery.set(tpl.$('.js-table-view-search').val() || '');
|
||||
tpl.page.set(1);
|
||||
}
|
||||
},
|
||||
|
||||
'click .js-table-view-prev-page'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const current = tpl.page.get();
|
||||
if (current > 1) tpl.page.set(current - 1);
|
||||
},
|
||||
|
||||
'click .js-table-view-next-page'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const totalPages = Math.max(
|
||||
1,
|
||||
Math.ceil(tpl.filteredRows.get().length / rowsPerPage),
|
||||
);
|
||||
const current = tpl.page.get();
|
||||
if (current < totalPages) tpl.page.set(current + 1);
|
||||
},
|
||||
|
||||
'click .js-table-view-sort'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const field = event.currentTarget.dataset.sort;
|
||||
if (!field) return;
|
||||
if (tpl.sortField.get() === field) {
|
||||
tpl.sortDirection.set(tpl.sortDirection.get() * -1);
|
||||
} else {
|
||||
tpl.sortField.set(field);
|
||||
tpl.sortDirection.set(1);
|
||||
}
|
||||
tpl.page.set(1);
|
||||
},
|
||||
|
||||
// Clicking the leftmost "Edit" link opens the Card Details popup on top of the
|
||||
// Board Table view (same mechanism as opening a card from search results).
|
||||
'click .js-table-view-edit-card'(event) {
|
||||
event.preventDefault();
|
||||
const cardId = event.currentTarget.dataset.cardId;
|
||||
if (!cardId) return;
|
||||
const board = Utils.getCurrentBoard();
|
||||
Meteor.subscribe('popupCardData', cardId, {
|
||||
onReady() {
|
||||
Session.set('popupCardId', cardId);
|
||||
if (board) Session.set('popupCardBoardId', board._id);
|
||||
if (!Popup.isOpen()) {
|
||||
Popup.open('cardDetails')(event);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// Adding a date to a card that has none. The data context of each add button
|
||||
// is the card (set with `with row.card` in the template), so the popup edits
|
||||
// the right card. Editing an existing date is handled by the cardXxxDate
|
||||
// badge templates themselves (their own .js-edit-date click handlers).
|
||||
'click .js-received-date': Popup.open('editCardReceivedDate'),
|
||||
'click .js-start-date': Popup.open('editCardStartDate'),
|
||||
'click .js-due-date': Popup.open('editCardDueDate'),
|
||||
'click .js-end-date': Popup.open('editCardEndDate'),
|
||||
});
|
||||
@@ -23,7 +23,7 @@
|
||||
width: 150px;
|
||||
min-width: 150px;
|
||||
max-height: 150px;
|
||||
padding-right: 16px;
|
||||
padding-inline-end: 16px;
|
||||
}
|
||||
.attachment-thumbnail {
|
||||
max-width: 150px;
|
||||
@@ -48,7 +48,7 @@
|
||||
.attachment-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-right: 25px; /* Make sure the icons are not to far to the right */
|
||||
margin-inline-end: 25px; /* Make sure the icons are not to far to the right */
|
||||
}
|
||||
.attachment-actions {
|
||||
display: flex;
|
||||
@@ -56,10 +56,10 @@
|
||||
align-items: center;
|
||||
}
|
||||
.attachment-actions a {
|
||||
margin-left: 16px;
|
||||
margin-inline-start: 16px;
|
||||
}
|
||||
.attachment-actions a:first-child {
|
||||
margin-left: 0;
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
.add-attachment {
|
||||
display: flex;
|
||||
@@ -74,7 +74,7 @@
|
||||
.icon {
|
||||
font-size: 1.5em;
|
||||
cursor: pointer;
|
||||
margin-left: 10px;
|
||||
margin-inline-start: 10px;
|
||||
}
|
||||
.icon:hover {
|
||||
color: #666;
|
||||
@@ -84,7 +84,7 @@
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
z-index: 9999 !important;
|
||||
background: rgba(13, 13, 13, 0.95);
|
||||
}
|
||||
@@ -113,7 +113,7 @@
|
||||
cursor: pointer;
|
||||
font-size: 4em;
|
||||
position: absolute;
|
||||
right: 50px;
|
||||
inset-inline-end: 50px;
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
}
|
||||
|
||||
.upload-progress-header i {
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@
|
||||
|
||||
.upload-progress-error i,
|
||||
.upload-progress-success i {
|
||||
margin-right: 4px;
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
|
||||
/* Minicard specific styles */
|
||||
@@ -263,14 +263,14 @@
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
align-self: center;
|
||||
margin-left: 70px;
|
||||
margin-inline-start: 70px;
|
||||
}
|
||||
#next-attachment {
|
||||
font-size: 4em;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
align-self: center;
|
||||
margin-right: 70px;
|
||||
margin-inline-end: 70px;
|
||||
}
|
||||
#viewer-content {
|
||||
display: flex;
|
||||
@@ -325,20 +325,20 @@
|
||||
padding: 16px;
|
||||
}
|
||||
#prev-attachment {
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
position: absolute;
|
||||
bottom: 2.2em;
|
||||
font-size: 1.6em;
|
||||
padding: 16px;
|
||||
margin-left: 0;
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
#next-attachment {
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
position: absolute;
|
||||
bottom: 2.2em;
|
||||
font-size: 1.6em;
|
||||
padding: 16px;
|
||||
margin-right: 0;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
#pdf-viewer {
|
||||
width: 100%;
|
||||
@@ -365,7 +365,7 @@
|
||||
}
|
||||
.attachment-details {
|
||||
flex-direction: column;
|
||||
margin-right: 0px;
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
.attachment-actions {
|
||||
flex-direction: row;
|
||||
|
||||
@@ -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,22 +44,21 @@ 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}}")
|
||||
if canModifyCard
|
||||
span.check-box-unicode
|
||||
i.fa(class="{{#if data.value}}fa-check-square{{else}}fa-square-o{{/if}}")
|
||||
.check-box-container
|
||||
.check-box.materialCheckBox(class="{{#if data.value}}is-checked{{/if}}")
|
||||
else
|
||||
span.check-box-unicode
|
||||
i.fa(class="{{#if data.value}}fa-check-square{{else}}fa-square-o{{/if}}")
|
||||
.materialCheckBox(class="{{#if data.value}}is-checked{{/if}}")
|
||||
|
||||
template(name="cardCustomField-currency")
|
||||
if canModifyCard
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -106,9 +118,6 @@ Template['cardCustomField-checkbox'].onCreated(function () {
|
||||
});
|
||||
|
||||
Template['cardCustomField-checkbox'].events({
|
||||
'click .js-checklist-item .check-box-unicode'(event, tpl) {
|
||||
tpl.card.setCustomField(tpl.customFieldId, !Template.currentData().value);
|
||||
},
|
||||
'click .js-checklist-item .check-box-container'(event, tpl) {
|
||||
tpl.card.setCustomField(tpl.customFieldId, !Template.currentData().value);
|
||||
},
|
||||
|
||||
@@ -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 */
|
||||
@@ -134,7 +138,7 @@
|
||||
}
|
||||
.card-date time::before {
|
||||
font-size: inherit;
|
||||
margin-right: 0.3em;
|
||||
margin-inline-end: 0.3em;
|
||||
display: inline-block;
|
||||
}
|
||||
.customfield-date {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
.assignee {
|
||||
display: block;
|
||||
position: relative;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
height: clamp(24px, 3.5vw, 36px);
|
||||
width: clamp(24px, 3.5vw, 36px);
|
||||
margin: 0.3vh;
|
||||
@@ -73,7 +73,7 @@
|
||||
height: 7px;
|
||||
width: 7px;
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
inset-inline-end: -1px;
|
||||
bottom: -1px;
|
||||
z-index: 15;
|
||||
}
|
||||
@@ -142,36 +142,36 @@ body.desktop-mode .card-details:not(.card-details-popup) {
|
||||
/* Default position for first card or when dragged */
|
||||
body.desktop-mode .card-details:not(.card-details-popup):not([style*="left"]):not([style*="top"]) {
|
||||
top: 8px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
inset-inline-start: 20px;
|
||||
inset-inline-end: 20px;
|
||||
bottom: 20px;
|
||||
}
|
||||
|
||||
/* Stagger positions for multiple cards using nth-of-type */
|
||||
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(1) {
|
||||
top: 8px;
|
||||
left: 20px;
|
||||
inset-inline-start: 20px;
|
||||
}
|
||||
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(2) {
|
||||
top: 38px;
|
||||
left: 50px;
|
||||
inset-inline-start: 50px;
|
||||
}
|
||||
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(3) {
|
||||
top: 68px;
|
||||
left: 80px;
|
||||
inset-inline-start: 80px;
|
||||
}
|
||||
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(4) {
|
||||
top: 98px;
|
||||
left: 110px;
|
||||
inset-inline-start: 110px;
|
||||
}
|
||||
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(5) {
|
||||
top: 128px;
|
||||
left: 140px;
|
||||
inset-inline-start: 140px;
|
||||
}
|
||||
|
||||
/* For expanded cards, set dimensions */
|
||||
body.desktop-mode .card-details:not(.card-details-popup):not(.card-details-collapsed) {
|
||||
right: 20px;
|
||||
inset-inline-end: 20px;
|
||||
bottom: 20px;
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
bottom: auto !important;
|
||||
}
|
||||
.card-details .mCustomScrollBox {
|
||||
padding-left: 0;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
.card-details .card-details-canvas {
|
||||
width: auto;
|
||||
@@ -208,16 +208,16 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
.card-details .card-details-header .card-number {
|
||||
color: #b3b3b3;
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
margin-inline-end: 6px;
|
||||
}
|
||||
|
||||
/* Collapse toggle triangle */
|
||||
.card-details .card-details-header .card-collapse-toggle {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
font-size: 20px;
|
||||
padding: 7px 10px;
|
||||
margin-left: -10px;
|
||||
margin-right: 5px;
|
||||
margin-inline-start: -10px;
|
||||
margin-inline-end: 5px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: #000;
|
||||
@@ -228,11 +228,11 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
.card-details .card-details-header .card-drag-handle {
|
||||
font-size: 20px;
|
||||
padding: 8px 10px;
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
display: inline-block;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
vertical-align: middle;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -246,14 +246,14 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
.card-details .card-details-header .close-card-details-mobile-web,
|
||||
.card-details .card-details-header .card-details-menu-mobile-web,
|
||||
.card-details .card-details-header .copied-tooltip {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
.card-details .card-details-header .close-card-details,
|
||||
.card-details .card-details-header .maximize-card-details,
|
||||
.card-details .card-details-header .minimize-card-details {
|
||||
font-size: 24px;
|
||||
padding: 5px 10px 5px 10px;
|
||||
margin-right: -8px;
|
||||
margin-inline-end: -8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
vertical-align: middle;
|
||||
@@ -264,19 +264,19 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
.card-details .card-details-header .card-mobile-desktop-toggle {
|
||||
font-size: 24px;
|
||||
padding: 5px;
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.card-details .card-details-header .card-copy-button {
|
||||
font-size: 17px;
|
||||
padding: 10px;
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
.card-details .card-details-header .card-copy-mobile-button {
|
||||
font-size: 17px;
|
||||
padding: 10px;
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
.card-details .card-details-header .card-details-menu {
|
||||
font-size: 17px;
|
||||
@@ -287,17 +287,17 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
.card-details .card-details-header .card-details-menu-mobile-web {
|
||||
font-size: 17px;
|
||||
padding: 10px;
|
||||
margin-right: 30px;
|
||||
margin-inline-end: 30px;
|
||||
}
|
||||
.card-details .card-details-header .card-mobile-desktop-toggle,
|
||||
.card-details .card-details-header .card-zoom-in,
|
||||
.card-details .card-details-header .card-zoom-out {
|
||||
font-size: 24px;
|
||||
padding: 5px 10px 5px 10px;
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
|
||||
/* Unify all card text to match title size */
|
||||
@@ -321,7 +321,7 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
}
|
||||
.card-details .card-details-header .card-details-watch {
|
||||
font-size: 17px;
|
||||
padding-left: 7px;
|
||||
padding-inline-start: 7px;
|
||||
color: #a6a6a6;
|
||||
}
|
||||
.card-details .card-details-header .card-details-title {
|
||||
@@ -363,11 +363,11 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
padding: 0 5px;
|
||||
}
|
||||
.card-details .card-details-header .copied-tooltip {
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
.card-details .card-description i.fa.fa-pencil-square-o {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
.card-details .card-description textarea {
|
||||
min-height: 100px;
|
||||
@@ -380,11 +380,11 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
gap: 0.5em;
|
||||
}
|
||||
.card-details .card-details-items .card-details-item {
|
||||
margin-right: 0.5em;
|
||||
margin-inline-end: 0.5em;
|
||||
flex-grow: 1;
|
||||
}
|
||||
.card-details .card-details-items .card-details-item:last-child {
|
||||
margin-right: 0;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
.card-details .card-details-items .card-details-item.card-details-item-labels {
|
||||
display: block;
|
||||
@@ -409,7 +409,7 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
max-width: 28%;
|
||||
}
|
||||
.card-details .card-details-items .card-details-item.custom-fields {
|
||||
padding-left: 10px;
|
||||
padding-inline-start: 10px;
|
||||
}
|
||||
.card-details .card-details-item-title {
|
||||
font-size: 16px;
|
||||
@@ -422,7 +422,7 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
@media screen and (min-width: 801px) {
|
||||
.card-details {
|
||||
top: 97px;
|
||||
left: calc(50% - (600px / 2));
|
||||
inset-inline-start: calc(50% - (600px / 2));
|
||||
width: 600px;
|
||||
bottom: 0;
|
||||
position: fixed;
|
||||
@@ -433,8 +433,8 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
body.mobile-mode .card-details {
|
||||
width: 100vw !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
bottom: 0 !important;
|
||||
height: 100vh !important;
|
||||
max-height: 100vh !important;
|
||||
@@ -456,23 +456,23 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
transition: flex-basis 0.1s;
|
||||
box-sizing: border-box;
|
||||
top: 97px;
|
||||
left: 0px;
|
||||
inset-inline-start: 0px;
|
||||
height: calc(100% - 100px);
|
||||
width: calc(100% - 20px);
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
.card-details-maximized .card-details-left {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
top: 60px;
|
||||
left: 20px;
|
||||
inset-inline-start: 20px;
|
||||
width: 47%;
|
||||
border-right: solid 2px #dbdbdb;
|
||||
padding-right: 10px;
|
||||
border-inline-end: solid 2px #dbdbdb;
|
||||
padding-inline-end: 10px;
|
||||
}
|
||||
.card-details-maximized .card-details-right {
|
||||
position: absolute;
|
||||
float: right;
|
||||
left: 50%;
|
||||
float: inline-end;
|
||||
inset-inline-start: 50%;
|
||||
margin: 15px 0;
|
||||
}
|
||||
.card-details-maximized .card-details-header {
|
||||
@@ -480,12 +480,12 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
}
|
||||
}
|
||||
input[type="text"].attachment-add-link-input {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
margin: 0 0 8px;
|
||||
width: 80%;
|
||||
}
|
||||
input[type="submit"].attachment-add-link-submit {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
margin: 0 0 8px 4px;
|
||||
padding: 6px 12px;
|
||||
width: 18%;
|
||||
@@ -501,52 +501,57 @@ input[type="submit"].attachment-add-link-submit {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
bottom: 0 !important;
|
||||
z-index: 100 !important;
|
||||
/* #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;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
}
|
||||
.card-details .card-details-canvas {
|
||||
width: 100%;
|
||||
padding-left: 0px;
|
||||
padding-inline-start: 0px;
|
||||
padding: 0 15px;
|
||||
}
|
||||
.card-details .card-details-header .close-card-details {
|
||||
margin-right: 0px;
|
||||
margin-inline-end: 0px;
|
||||
display: block !important;
|
||||
}
|
||||
.card-details .card-details-header .close-card-details-mobile-web {
|
||||
display: block !important;
|
||||
margin-right: 5px !important;
|
||||
margin-inline-end: 5px !important;
|
||||
}
|
||||
.card-details .card-details-header .card-mobile-desktop-toggle {
|
||||
display: block !important;
|
||||
margin-right: 5px !important;
|
||||
margin-inline-end: 5px !important;
|
||||
}
|
||||
.card-details .card-details-header .card-mobile-desktop-toggle {
|
||||
display: block !important;
|
||||
margin-right: 5px !important;
|
||||
margin-inline-end: 5px !important;
|
||||
}
|
||||
.card-details .card-details-header .card-details-menu {
|
||||
margin-right: 40px;
|
||||
margin-inline-end: 40px;
|
||||
}
|
||||
.card-details .card-details-header .maximize-card-details {
|
||||
margin-right: 40px;
|
||||
margin-inline-end: 40px;
|
||||
}
|
||||
.card-details .card-details-header .minimize-card-details {
|
||||
margin-right: 40px;
|
||||
margin-inline-end: 40px;
|
||||
}
|
||||
.card-details-popup {
|
||||
padding: 0px 10px;
|
||||
@@ -565,7 +570,7 @@ input[type="submit"].attachment-add-link-submit {
|
||||
}
|
||||
/* iPhone mobile: enlarge header buttons and increase spacing */
|
||||
body.mobile-mode.iphone-device .card-details .card-details-header {
|
||||
padding-right: 16px;
|
||||
padding-inline-end: 16px;
|
||||
}
|
||||
body.mobile-mode.iphone-device .card-details .card-details-header .close-card-details,
|
||||
body.mobile-mode.iphone-device .card-details .card-details-header .maximize-card-details,
|
||||
@@ -577,12 +582,12 @@ input[type="submit"].attachment-add-link-submit {
|
||||
body.mobile-mode.iphone-device .card-details .card-details-header .card-zoom-out {
|
||||
font-size: 2em !important; /* 2x bigger */
|
||||
padding: 0.3em !important;
|
||||
margin-right: 0.75em !important; /* 2x space compared to default */
|
||||
margin-left: 0 !important;
|
||||
margin-inline-end: 0.75em !important; /* 2x space compared to default */
|
||||
margin-inline-start: 0 !important;
|
||||
}
|
||||
/* Avoid clipping of the close button on the right edge */
|
||||
body.mobile-mode.iphone-device .card-details .card-details-header .close-card-details {
|
||||
margin-right: 0.75em !important;
|
||||
margin-inline-end: 0.75em !important;
|
||||
}
|
||||
/* Enlarge the header title too */
|
||||
body.mobile-mode.iphone-device .card-details .card-details-header .card-details-title {
|
||||
@@ -598,10 +603,11 @@ body.mobile-mode .card-details {
|
||||
margin: 0px !important;
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
bottom: 0 !important;
|
||||
z-index: 100 !important;
|
||||
/* #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;
|
||||
@@ -731,7 +737,7 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
}
|
||||
.vote-title .js-edit-date {
|
||||
align-self: flex-start;
|
||||
margin-left: 6px;
|
||||
margin-inline-start: 6px;
|
||||
}
|
||||
.vote-result {
|
||||
display: flex;
|
||||
@@ -750,7 +756,7 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
}
|
||||
.poker-title .js-edit-date {
|
||||
align-self: flex-start;
|
||||
margin-left: 6px;
|
||||
margin-inline-start: 6px;
|
||||
}
|
||||
.poker-result {
|
||||
display: flex;
|
||||
@@ -827,11 +833,11 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
display: inline-block;
|
||||
}
|
||||
.poker-table-side-right {
|
||||
padding-left: 10px;
|
||||
padding-inline-start: 10px;
|
||||
}
|
||||
@media (max-width: 400px) {
|
||||
.poker-table-side-right {
|
||||
padding-left: 0px;
|
||||
padding-inline-start: 0px;
|
||||
}
|
||||
}
|
||||
.estimation-add {
|
||||
@@ -842,16 +848,16 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
}
|
||||
.estimation-add input {
|
||||
display: inline-block;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
margin: auto;
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
width: 100px;
|
||||
border-radius: 2px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
.estimation-add button {
|
||||
display: inline-block;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
margin: auto;
|
||||
border-radius: 2px;
|
||||
padding: 3px 10px;
|
||||
@@ -859,7 +865,7 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
.poker-card {
|
||||
width: 48px;
|
||||
height: 72px;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
display: table;
|
||||
@@ -890,3 +896,205 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
transform: scale(1.01);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Sticker picker + card-detail stickers + due-complete toggle */
|
||||
.card-stickers-picker {
|
||||
max-width: 320px;
|
||||
}
|
||||
.card-sticker-select {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: 2px;
|
||||
border-radius: 3px;
|
||||
font-size: 18px;
|
||||
color: #4d4d4d;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.card-sticker-select:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
.card-sticker-select.active {
|
||||
background: #d6ecd2;
|
||||
color: #3cb500;
|
||||
}
|
||||
.card-sticker-select .card-sticker-check {
|
||||
position: absolute;
|
||||
bottom: 1px;
|
||||
inset-inline-end: 2px;
|
||||
font-size: 10px;
|
||||
color: #3cb500;
|
||||
}
|
||||
.card-stickers .card-sticker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0 8px 4px 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
.card-sticker .card-sticker-remove {
|
||||
margin-inline-start: 3px;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-sticker .card-sticker-remove:hover {
|
||||
color: #eb4646;
|
||||
}
|
||||
.card-due-complete-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-inline-start: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Trello-style "mark complete" toggle, left of the card title */
|
||||
.card-details-complete-toggle {
|
||||
float: inline-start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* Title is 1.35em with 1.3 line-height (~28px box) and margin-top 7px; push
|
||||
the 13px checkbox down so it lands at the vertical middle of the title. */
|
||||
margin: 14px 10px 0 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Card locations: multiple locations, each editable with a remove button */
|
||||
.card-details-item-location .card-locations {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.card-details-item-location .card-location {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
border: 1px solid #e3e3e3;
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.card-details-item-location .card-location-info {
|
||||
flex: 1;
|
||||
}
|
||||
.card-details-item-location .js-edit-location {
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-details-item-location .card-location-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
.card-details-item-location .card-location-address {
|
||||
color: #4d4d4d;
|
||||
}
|
||||
.card-details-item-location .card-location-remove {
|
||||
margin-inline-start: 8px;
|
||||
color: #8c8c8c;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-details-item-location .card-location-remove:hover {
|
||||
color: #eb4646;
|
||||
}
|
||||
|
||||
/* Sticker icon highlight styles (used instead of colour): taco pack => underline,
|
||||
pete pack => round (ring around the icon). Icon keeps its normal colour. */
|
||||
.card-sticker i.sticker-underline {
|
||||
border-bottom: 2px solid currentColor;
|
||||
padding-bottom: 1px;
|
||||
line-height: 1;
|
||||
}
|
||||
.card-sticker i.sticker-round {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.6em;
|
||||
height: 1.6em;
|
||||
line-height: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* "Open map links at" provider selector in the location popup */
|
||||
.js-card-location-form .map-provider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.js-card-location-form .map-provider-row .js-map-provider {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Opened card (card details popup) docks to the very top of the window and
|
||||
overlays the global + board header bars instead of opening below them. The
|
||||
base .pop-over is position:absolute with a small margin/top offset, which left
|
||||
the card's top behind the fixed header bars; fix it to the viewport top with a
|
||||
z-index above every header (headers top out around z-index 1000-2000). */
|
||||
/* The card details content (.card-details) is always position:fixed (see the
|
||||
min-width:801px and max-width:800px media queries above), so it renders as its
|
||||
own framed, viewport-positioned box and escapes this .pop-over wrapper. That
|
||||
leaves the generic .pop-over chrome (white background, border, shadow, header
|
||||
and ~380px width) visible as a redundant empty second popup at the top of the
|
||||
page. Collapse the wrapper to nothing and make it invisible; the fixed card
|
||||
descendant still renders normally on top of the page. Closing is handled by
|
||||
the card's own close button (.js-close-card-details), clicking outside, or
|
||||
pressing Escape. */
|
||||
.pop-over[data-popup="cardDetailsPopup"] {
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
min-height: 0 !important;
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
overflow: visible !important;
|
||||
z-index: 100000 !important;
|
||||
}
|
||||
|
||||
.pop-over[data-popup="cardDetailsPopup"] > .header {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.pop-over[data-popup="cardDetailsPopup"] > .content-wrapper {
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Sticker highlight styles inside the sticker picker (mascot = underline,
|
||||
computer = round), matching how they render on the card. */
|
||||
.card-sticker-select i.sticker-underline {
|
||||
border-bottom: 2px solid currentColor;
|
||||
padding-bottom: 1px;
|
||||
line-height: 1;
|
||||
}
|
||||
.card-sticker-select i.sticker-round {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.6em;
|
||||
height: 1.6em;
|
||||
line-height: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* #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;
|
||||
}
|
||||
@@ -61,6 +61,10 @@ template(name="cardDetails")
|
||||
span.emoji-icon
|
||||
i.fa.fa-link
|
||||
span.copied-tooltip {{_ 'copied'}}
|
||||
if canModifyCard
|
||||
if currentBoard.allowsDueComplete
|
||||
a.card-details-complete-toggle.js-toggle-due-complete(title="{{#if getDueComplete}}{{_ 'card-mark-incomplete'}}{{else}}{{_ 'card-mark-complete'}}{{/if}}")
|
||||
.materialCheckBox(class="{{#if getDueComplete}}is-checked{{/if}}")
|
||||
h2.card-details-title.js-card-title(
|
||||
class="{{#if canModifyCard}}js-open-inlined-form is-editable{{else}}js-card-title-drag-handle{{/if}}")
|
||||
+viewer
|
||||
@@ -108,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
|
||||
@@ -126,6 +135,49 @@ template(name="cardDetails")
|
||||
a.card-label.add-label.js-add-labels(title="{{_ 'card-labels-title'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
.card-details-item.card-details-item-stickers
|
||||
h3.card-details-item-title
|
||||
i.fa.fa-sticky-note-o
|
||||
| {{_ 'stickers'}}
|
||||
.card-details-item-content.card-stickers
|
||||
each stickers
|
||||
span.card-sticker(title="{{#if name}}{{name}}{{else}}{{icon}}{{/if}}")
|
||||
i.fa(class="fa-{{icon}} {{#if highlight}}sticker-{{highlight}}{{/if}}")
|
||||
if canModifyCard
|
||||
a.card-sticker-remove.js-remove-sticker(data-index="{{@index}}" title="{{_ 'remove-sticker'}}")
|
||||
i.fa.fa-times
|
||||
if canModifyCard
|
||||
unless currentUser.isWorker
|
||||
a.card-label.add-label.js-add-stickers(title="{{_ 'add-sticker'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
.card-details-item.card-details-item-location
|
||||
h3.card-details-item-title
|
||||
i.fa.fa-map-marker
|
||||
| {{_ 'location'}}
|
||||
.card-details-item-content.card-locations
|
||||
each getLocations
|
||||
.card-location
|
||||
.card-location-info(class="{{#if canModifyCard}}js-edit-location{{/if}}" data-location-id="{{_id}}")
|
||||
if name
|
||||
.card-location-name= name
|
||||
if address
|
||||
.card-location-address= address
|
||||
if hasCoordinates
|
||||
a.card-location-map(href=mapUrl target="_blank" rel="noopener noreferrer")
|
||||
i.fa.fa-map-o
|
||||
= ' '
|
||||
| {{_ 'location-open-map'}}
|
||||
if canModifyCard
|
||||
a.card-location-remove.js-remove-location(data-location-id="{{_id}}" title="{{_ 'delete'}}")
|
||||
i.fa.fa-times
|
||||
if canModifyCard
|
||||
unless currentUser.isWorker
|
||||
a.card-label.add-label.js-add-location(title="{{_ 'add-location'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
+cardButtons(_id=_id boardId=boardId)
|
||||
|
||||
if currentBoard.hasAnyAllowsDate
|
||||
hr
|
||||
|
||||
@@ -177,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
|
||||
@@ -275,6 +330,36 @@ template(name="cardDetails")
|
||||
+viewer
|
||||
= getAssignedBy
|
||||
|
||||
.card-details-item.card-details-item-dependencies
|
||||
h3.card-details-item-title
|
||||
i.fa.fa-link
|
||||
| {{_ 'card-dependencies'}}
|
||||
.card-details-dependencies-list
|
||||
each getDependencyCards
|
||||
.card-dependency(data-target-id="{{card._id}}")
|
||||
i.fa(class="fa-{{icon}}" style="color:{{color}}")
|
||||
a.card-dependency-link(href="{{linkUrl}}")
|
||||
+viewer
|
||||
= card.title
|
||||
if canModifyCard
|
||||
select.js-dependency-type(data-target-id="{{card._id}}")
|
||||
each typeOption
|
||||
if selected
|
||||
option(value="{{id}}" selected="selected") {{_ label}}
|
||||
else
|
||||
option(value="{{id}}") {{_ label}}
|
||||
input.js-dependency-color(type="color" value="{{color}}" data-target-id="{{card._id}}" title="{{_ 'dependency-color'}}")
|
||||
a.js-dependency-icon(data-target-id="{{card._id}}" title="{{_ 'dependency-icon'}}")
|
||||
i.fa.fa-pencil
|
||||
a.card-dependency-remove.js-remove-dependency(data-target-id="{{card._id}}" title="{{_ 'remove-dependency'}}")
|
||||
i.fa.fa-times
|
||||
else
|
||||
span.card-dependency-type {{_ typeLabel}}
|
||||
if canModifyCard
|
||||
a.js-add-dependency(title="{{_ 'add-dependency'}}")
|
||||
i.fa.fa-plus
|
||||
| {{_ 'add-dependency'}}
|
||||
|
||||
if $or currentBoard.allowsCardSortingByNumber getSpentTime
|
||||
hr
|
||||
|
||||
@@ -604,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
|
||||
@@ -613,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
|
||||
@@ -868,22 +963,22 @@ template(name="moveCardPopup")
|
||||
label {{_ 'boards'}}:
|
||||
select.js-select-boards(autofocus)
|
||||
each boards
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedBoardId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
label {{_ 'swimlanes'}}:
|
||||
select.js-select-swimlanes
|
||||
each swimlanes
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedSwimlaneId _id}}selected{{/if}}") {{isTitleDefault title}}
|
||||
|
||||
label {{_ 'lists'}}:
|
||||
select.js-select-lists
|
||||
each lists
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedListId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
label {{_ 'cards'}}:
|
||||
select.js-select-cards
|
||||
each cards
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
div
|
||||
input(type="radio" name="position" value="above" checked id="position-above" style="display: inline")
|
||||
@@ -903,22 +998,22 @@ template(name="copyCardPopup")
|
||||
label {{_ 'boards'}}:
|
||||
select.js-select-boards(autofocus)
|
||||
each boards
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedBoardId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
label {{_ 'swimlanes'}}:
|
||||
select.js-select-swimlanes
|
||||
each swimlanes
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedSwimlaneId _id}}selected{{/if}}") {{isTitleDefault title}}
|
||||
|
||||
label {{_ 'lists'}}:
|
||||
select.js-select-lists
|
||||
each lists
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedListId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
label {{_ 'cards'}}:
|
||||
select.js-select-cards
|
||||
each cards
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
div
|
||||
input(type="radio" name="position" value="above" checked id="position-above" style="display: inline")
|
||||
@@ -938,22 +1033,22 @@ template(name="copyManyCardsPopup")
|
||||
label {{_ 'boards'}}:
|
||||
select.js-select-boards(autofocus)
|
||||
each boards
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedBoardId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
label {{_ 'swimlanes'}}:
|
||||
select.js-select-swimlanes
|
||||
each swimlanes
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedSwimlaneId _id}}selected{{/if}}") {{isTitleDefault title}}
|
||||
|
||||
label {{_ 'lists'}}:
|
||||
select.js-select-lists
|
||||
each lists
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedListId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
label {{_ 'cards'}}:
|
||||
select.js-select-cards
|
||||
each cards
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
div
|
||||
input(type="radio" name="position" value="above" checked id="position-above" style="display: inline")
|
||||
@@ -973,22 +1068,22 @@ template(name="convertChecklistItemToCardPopup")
|
||||
label {{_ 'boards'}}:
|
||||
select.js-select-boards(autofocus)
|
||||
each boards
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedBoardId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
label {{_ 'swimlanes'}}:
|
||||
select.js-select-swimlanes
|
||||
each swimlanes
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedSwimlaneId _id}}selected{{/if}}") {{isTitleDefault title}}
|
||||
|
||||
label {{_ 'lists'}}:
|
||||
select.js-select-lists
|
||||
each lists
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedListId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
label {{_ 'cards'}}:
|
||||
select.js-select-cards
|
||||
each cards
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
div
|
||||
input(type="radio" name="position" value="above" checked id="position-above" style="display: inline")
|
||||
@@ -1196,3 +1291,78 @@ template(name="cardStartPlanningPokerPopup")
|
||||
if getPokerQuestion
|
||||
if currentUser.isBoardAdmin
|
||||
button.js-remove-poker.negate.wide.right {{_ 'delete'}}
|
||||
|
||||
template(name="cardLocationsPopup")
|
||||
form.js-card-location-form
|
||||
label
|
||||
| {{_ 'location-detect-from-map'}}
|
||||
input.js-location-map-link(type="text" placeholder="https://..." autofocus)
|
||||
button.primary.js-detect-location(type="button") {{_ 'location-detect'}}
|
||||
if detectMessage
|
||||
p.quiet.js-detect-message {{detectMessage}}
|
||||
hr
|
||||
label
|
||||
| {{_ 'location-name'}}
|
||||
input.js-location-name(type="text" value="{{location.name}}")
|
||||
label
|
||||
| {{_ 'location-address'}}
|
||||
input.js-location-address(type="text" value="{{location.address}}")
|
||||
label
|
||||
| {{_ 'location-latitude'}}
|
||||
input.js-location-latitude(type="number" step="any" value="{{location.latitude}}")
|
||||
label
|
||||
| {{_ 'location-longitude'}}
|
||||
input.js-location-longitude(type="number" step="any" value="{{location.longitude}}")
|
||||
button.primary.confirm.js-submit-location(type="submit") {{_ 'save'}}
|
||||
if location._id
|
||||
button.negate.wide.right.js-delete-location(type="button") {{_ 'delete'}}
|
||||
hr
|
||||
.map-provider-row
|
||||
span.map-provider-label {{_ 'location-open-map-at'}}
|
||||
select.js-map-provider
|
||||
optgroup(label="{{_ 'map-region-usa'}}")
|
||||
option(value="google" selected="{{#if isMapProvider 'google'}}selected{{/if}}") Google Maps
|
||||
option(value="bing" selected="{{#if isMapProvider 'bing'}}selected{{/if}}") Bing Maps
|
||||
option(value="apple" selected="{{#if isMapProvider 'apple'}}selected{{/if}}") Apple Maps
|
||||
option(value="waze" selected="{{#if isMapProvider 'waze'}}selected{{/if}}") Waze
|
||||
optgroup(label="{{_ 'map-region-europe'}}")
|
||||
option(value="openstreetmap" selected="{{#if isMapProvider 'openstreetmap'}}selected{{/if}}") OpenStreetMap
|
||||
option(value="here" selected="{{#if isMapProvider 'here'}}selected{{/if}}") HERE WeGo
|
||||
option(value="yandex" selected="{{#if isMapProvider 'yandex'}}selected{{/if}}") Yandex Maps
|
||||
option(value="mapy" selected="{{#if isMapProvider 'mapy'}}selected{{/if}}") Mapy.cz
|
||||
option(value="2gis" selected="{{#if isMapProvider '2gis'}}selected{{/if}}") 2GIS
|
||||
optgroup(label="{{_ 'map-region-asia'}}")
|
||||
option(value="baidu" selected="{{#if isMapProvider 'baidu'}}selected{{/if}}") Baidu Maps
|
||||
option(value="amap" selected="{{#if isMapProvider 'amap'}}selected{{/if}}") Amap (Gaode)
|
||||
button.primary.js-save-map-provider(type="button") {{_ 'save'}}
|
||||
if mapSavedMessage
|
||||
p.quiet.js-map-saved {{mapSavedMessage}}
|
||||
|
||||
template(name="cardDependenciesPopup")
|
||||
.card-dependencies-popup
|
||||
label.dependency-field
|
||||
span {{_ 'dependency-type'}}
|
||||
select.js-new-dependency-type
|
||||
each typeOption
|
||||
if selected
|
||||
option(value="{{id}}" selected="selected") {{_ label}}
|
||||
else
|
||||
option(value="{{id}}") {{_ label}}
|
||||
label.dependency-field
|
||||
span {{_ 'dependency-color'}}
|
||||
input.js-new-dependency-color(type="color" value="{{defaultColor}}")
|
||||
input.js-dependency-search(type="text" placeholder="{{_ 'search'}}" autofocus)
|
||||
ul.pop-over-list
|
||||
each candidateCards
|
||||
li
|
||||
a.js-pick-dependency(data-target-id="{{_id}}")
|
||||
+viewer
|
||||
= title
|
||||
|
||||
template(name="cardDependencyIconPopup")
|
||||
.card-dependency-icon-popup
|
||||
ul.card-dependency-icons
|
||||
each dependencyIcons
|
||||
li
|
||||
a.js-pick-dependency-icon(data-icon="{{name}}" title="{{name}}")
|
||||
i.fa(class="fa-{{name}}")
|
||||
@@ -1,6 +1,21 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import {
|
||||
DEPENDENCY_TYPES,
|
||||
DEPENDENCY_ICON_CHOICES,
|
||||
DEFAULT_DEPENDENCY_COLOR,
|
||||
DEFAULT_DEPENDENCY_ICON,
|
||||
} from '/models/metadata/dependencies';
|
||||
import { canArchiveCard } from '/client/lib/archivePermission';
|
||||
import { isTextSelectionInsideCard } from '/client/lib/cardCloseGuard';
|
||||
|
||||
// Which dependency the icon picker should apply its choice to. The icon popup's
|
||||
// own data context is the dependency row (not the source card), so we capture
|
||||
// the source card + target id here when the picker is opened.
|
||||
let editingDependencyTargetId = null;
|
||||
let editingDependencyCard = null;
|
||||
import {
|
||||
setupDatePicker,
|
||||
datePickerRendered,
|
||||
@@ -53,6 +68,231 @@ import { MultiSelection } from '/client/lib/multiSelection';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import autosize from 'autosize';
|
||||
|
||||
// Id of the location currently being edited in the cardLocationsPopup; null
|
||||
// when adding a new location.
|
||||
const editingLocationId = new ReactiveVar(null);
|
||||
|
||||
// Chinese datum conversions ("eviltransform" algorithm). Baidu Maps uses BD-09
|
||||
// and Amap (Gaode) uses GCJ-02; both are offset from the WGS-84 coordinates
|
||||
// Wekan stores, so a raw lat/lon would land a few hundred metres off. Convert on
|
||||
// the way out (mapLinkFor) and back (parseMapLink). Coordinates outside mainland
|
||||
// China use no offset, so every non-Chinese provider is unaffected.
|
||||
const GCJ = (() => {
|
||||
const PI = Math.PI;
|
||||
const A = 6378245.0; // Krasovsky 1940 semi-major axis
|
||||
const EE = 0.00669342162296594323; // eccentricity squared
|
||||
const XPI = (PI * 3000.0) / 180.0;
|
||||
const outOfChina = (lat, lon) =>
|
||||
lon < 72.004 || lon > 137.8347 || lat < 0.8293 || lat > 55.8271;
|
||||
const tLat = (x, y) => {
|
||||
let r = -100 + 2 * x + 3 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
|
||||
r += ((20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2) / 3;
|
||||
r += ((20 * Math.sin(y * PI) + 40 * Math.sin((y / 3) * PI)) * 2) / 3;
|
||||
r += ((160 * Math.sin((y / 12) * PI) + 320 * Math.sin((y * PI) / 30)) * 2) / 3;
|
||||
return r;
|
||||
};
|
||||
const tLon = (x, y) => {
|
||||
let r = 300 + x + 2 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
|
||||
r += ((20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2) / 3;
|
||||
r += ((20 * Math.sin(x * PI) + 40 * Math.sin((x / 3) * PI)) * 2) / 3;
|
||||
r += ((150 * Math.sin((x / 12) * PI) + 300 * Math.sin((x / 30) * PI)) * 2) / 3;
|
||||
return r;
|
||||
};
|
||||
const wgs2gcj = (lat, lon) => {
|
||||
if (outOfChina(lat, lon)) return [lat, lon];
|
||||
let dLat = tLat(lon - 105, lat - 35);
|
||||
let dLon = tLon(lon - 105, lat - 35);
|
||||
const rad = (lat / 180) * PI;
|
||||
let magic = Math.sin(rad);
|
||||
magic = 1 - EE * magic * magic;
|
||||
const sq = Math.sqrt(magic);
|
||||
dLat = (dLat * 180) / (((A * (1 - EE)) / (magic * sq)) * PI);
|
||||
dLon = (dLon * 180) / ((A / sq) * Math.cos(rad) * PI);
|
||||
return [lat + dLat, lon + dLon];
|
||||
};
|
||||
// Approximate inverse by fixed-point iteration (converges to ~1e-9 deg).
|
||||
const gcj2wgs = (lat, lon) => {
|
||||
if (outOfChina(lat, lon)) return [lat, lon];
|
||||
let wLat = lat;
|
||||
let wLon = lon;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const [gLat, gLon] = wgs2gcj(wLat, wLon);
|
||||
const dLat = gLat - lat;
|
||||
const dLon = gLon - lon;
|
||||
if (Math.abs(dLat) < 1e-9 && Math.abs(dLon) < 1e-9) break;
|
||||
wLat -= dLat;
|
||||
wLon -= dLon;
|
||||
}
|
||||
return [wLat, wLon];
|
||||
};
|
||||
const gcj2bd = (lat, lon) => {
|
||||
const z = Math.sqrt(lon * lon + lat * lat) + 0.00002 * Math.sin(lat * XPI);
|
||||
const theta = Math.atan2(lat, lon) + 0.000003 * Math.cos(lon * XPI);
|
||||
return [z * Math.sin(theta) + 0.006, z * Math.cos(theta) + 0.0065];
|
||||
};
|
||||
const bd2gcj = (lat, lon) => {
|
||||
const x = lon - 0.0065;
|
||||
const y = lat - 0.006;
|
||||
const z = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * XPI);
|
||||
const theta = Math.atan2(y, x) - 0.000003 * Math.cos(x * XPI);
|
||||
return [z * Math.sin(theta), z * Math.cos(theta)];
|
||||
};
|
||||
return {
|
||||
wgs2gcj,
|
||||
gcj2wgs,
|
||||
wgs2bd: (lat, lon) => {
|
||||
if (outOfChina(lat, lon)) return [lat, lon];
|
||||
const [gLat, gLon] = wgs2gcj(lat, lon);
|
||||
return gcj2bd(gLat, gLon);
|
||||
},
|
||||
bd2wgs: (lat, lon) => {
|
||||
if (outOfChina(lat, lon)) return [lat, lon];
|
||||
const [gLat, gLon] = bd2gcj(lat, lon);
|
||||
return gcj2wgs(gLat, gLon);
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
// Parse coordinates (and, when present, a place name / address) out of a map
|
||||
// link from common providers, grouped by region:
|
||||
// USA: Google Maps, Bing Maps, Apple Maps, Waze
|
||||
// Europe: OpenStreetMap, HERE WeGo, Yandex Maps, Mapy.cz, 2GIS
|
||||
// Asia: Baidu Maps, Amap (Gaode)
|
||||
// plus generic `?q=lat,lon` / `?ll=lat,lon` links. Returns whatever it can find
|
||||
// as { latitude, longitude, name, address }.
|
||||
function parseMapLink(url) {
|
||||
const result = {};
|
||||
if (!url) return result;
|
||||
const s = String(url).trim();
|
||||
|
||||
let lat;
|
||||
let lon;
|
||||
const setCoords = (la, lo) => {
|
||||
if (lat !== undefined) return;
|
||||
const a = parseFloat(la);
|
||||
const o = parseFloat(lo);
|
||||
if (!isNaN(a) && !isNaN(o) && Math.abs(a) <= 90 && Math.abs(o) <= 180) {
|
||||
lat = a;
|
||||
lon = o;
|
||||
}
|
||||
};
|
||||
|
||||
const N = '(-?\\d+(?:\\.\\d+)?)';
|
||||
// Coordinates are matched against a normalized copy where percent-encoded
|
||||
// commas/slashes are decoded, since copied URLs often arrive as `%2C`/`%2F`
|
||||
// (e.g. Waze `ll=45.69%2C-120.81`, Yandex `ll=37.6%2C55.7`).
|
||||
const sd = s.replace(/%2c/gi, ',').replace(/%2f/gi, '/');
|
||||
let m;
|
||||
// Provider-specific forms that use lon,lat order must be handled before the
|
||||
// generic lat,lon patterns below, since setCoords keeps only the first match.
|
||||
// Yandex Maps: ?ll=<lon>,<lat> or ?pt=<lon>,<lat>[,style]
|
||||
if (/yandex\./i.test(sd) && (m = sd.match(new RegExp(`[?&](?:ll|pt)=${N},${N}`)))) setCoords(m[2], m[1]);
|
||||
// Mapy.cz: ?x=<lon>&y=<lat>
|
||||
if (/mapy\./i.test(sd) && (m = sd.match(new RegExp(`[?&]y=${N}`)))) {
|
||||
const mx = sd.match(new RegExp(`[?&]x=${N}`));
|
||||
if (mx) setCoords(m[1], mx[1]);
|
||||
}
|
||||
// 2GIS: /geo/<lon>,<lat> or ?m=<lon>,<lat>[/zoom]
|
||||
if (/2gis\./i.test(sd) && (m = sd.match(new RegExp(`(?:/geo/|[?&]m=)${N},${N}`)))) setCoords(m[2], m[1]);
|
||||
// Amap (Gaode): ?position=<lon>,<lat> in GCJ-02 datum -> WGS-84.
|
||||
if (/amap\./i.test(sd) && (m = sd.match(new RegExp(`[?&]position=${N},${N}`)))) {
|
||||
const [wLat, wLon] = GCJ.gcj2wgs(parseFloat(m[2]), parseFloat(m[1]));
|
||||
setCoords(wLat, wLon);
|
||||
}
|
||||
// Baidu Maps: ?location=<lat>,<lon> in BD-09 datum -> WGS-84.
|
||||
if (/baidu\./i.test(sd) && (m = sd.match(new RegExp(`[?&]location=${N},${N}`)))) {
|
||||
const [wLat, wLon] = GCJ.bd2wgs(parseFloat(m[1]), parseFloat(m[2]));
|
||||
setCoords(wLat, wLon);
|
||||
}
|
||||
// HERE WeGo: ?map=<lat>,<lon>,<zoom> or share path /l/<lat>,<lon>
|
||||
if (/here\./i.test(sd) && (m = sd.match(new RegExp(`(?:[?&]map=|/l/)${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
// Google Maps place marker in the data part: !3d<lat>!4d<lon> (most precise).
|
||||
if ((m = sd.match(new RegExp(`!3d${N}!4d${N}`)))) setCoords(m[1], m[2]);
|
||||
// Google Maps view/place: @<lat>,<lon>
|
||||
if ((m = sd.match(new RegExp(`@${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
// OpenStreetMap marker: ?mlat=..&mlon=..
|
||||
if ((m = sd.match(new RegExp(`[?&#]mlat=${N}`)))) {
|
||||
const mlon = sd.match(new RegExp(`[?&#]mlon=${N}`));
|
||||
if (mlon) setCoords(m[1], mlon[1]);
|
||||
}
|
||||
// OpenStreetMap map hash: #map=z/lat/lon
|
||||
if ((m = sd.match(new RegExp(`[#&]map=\\d+(?:\\.\\d+)?/${N}/${N}`)))) setCoords(m[1], m[2]);
|
||||
// Bing Maps: cp=lat~lon
|
||||
if ((m = sd.match(new RegExp(`[?&]cp=${N}~${N}`)))) setCoords(m[1], m[2]);
|
||||
// Apple Maps / Waze and generic ll=lat,lon
|
||||
if ((m = sd.match(new RegExp(`[?&]ll=${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
// Generic q=lat,lon (Google/Apple query form)
|
||||
if ((m = sd.match(new RegExp(`[?&]q=${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
// Generic center=lat,lon
|
||||
if ((m = sd.match(new RegExp(`[?&]center=${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
|
||||
if (lat !== undefined) {
|
||||
result.latitude = lat;
|
||||
result.longitude = lon;
|
||||
}
|
||||
|
||||
const decode = (raw) => {
|
||||
try {
|
||||
return decodeURIComponent(raw.replace(/\+/g, ' ')).trim();
|
||||
} catch (e) {
|
||||
return raw.replace(/\+/g, ' ').trim();
|
||||
}
|
||||
};
|
||||
const isCoordText = (t) => /^-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?$/.test(t);
|
||||
|
||||
// Place name from Google Maps /place/<Name>/
|
||||
if ((m = s.match(/\/place\/([^/@?]+)/))) {
|
||||
const name = decode(m[1]);
|
||||
if (name && !isCoordText(name)) result.name = name;
|
||||
}
|
||||
// Address from a textual q=/query=/destination= parameter.
|
||||
if ((m = s.match(/[?&](?:q|query|destination)=([^&]+)/))) {
|
||||
const q = decode(m[1]);
|
||||
if (q && !isCoordText(q)) result.address = q;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Build an "open in map" link for the given provider and coordinates. Mirrors
|
||||
// the providers offered in the location popup's "Open map links at" selector,
|
||||
// grouped by region (USA, Europe, Asia). Note that several non-US providers
|
||||
// expect the coordinates in lon,lat order rather than lat,lon.
|
||||
function mapLinkFor(provider, lat, lon) {
|
||||
switch (provider) {
|
||||
// --- USA ---
|
||||
case 'google':
|
||||
return `https://www.google.com/maps/search/?api=1&query=${lat},${lon}`;
|
||||
case 'bing':
|
||||
return `https://www.bing.com/maps?cp=${lat}~${lon}&lvl=16`;
|
||||
case 'apple':
|
||||
return `https://maps.apple.com/?ll=${lat},${lon}`;
|
||||
case 'waze':
|
||||
return `https://waze.com/ul?ll=${lat},${lon}`;
|
||||
// --- Europe ---
|
||||
case 'here':
|
||||
return `https://wego.here.com/?map=${lat},${lon},16`;
|
||||
case 'yandex':
|
||||
return `https://yandex.com/maps/?ll=${lon},${lat}&z=16`;
|
||||
case 'mapy':
|
||||
return `https://mapy.cz/zakladni?x=${lon}&y=${lat}&z=16`;
|
||||
case '2gis':
|
||||
return `https://2gis.ru/geo/${lon},${lat}`;
|
||||
// --- Asia (coordinates converted from WGS-84 into the local datum) ---
|
||||
case 'baidu': {
|
||||
const [bLat, bLon] = GCJ.wgs2bd(parseFloat(lat), parseFloat(lon));
|
||||
return `https://api.map.baidu.com/marker?location=${bLat},${bLon}&output=html`;
|
||||
}
|
||||
case 'amap': {
|
||||
const [gLat, gLon] = GCJ.wgs2gcj(parseFloat(lat), parseFloat(lon));
|
||||
return `https://uri.amap.com/marker?position=${gLon},${gLat}`;
|
||||
}
|
||||
case 'openstreetmap':
|
||||
default:
|
||||
return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=16/${lat}/${lon}`;
|
||||
}
|
||||
}
|
||||
|
||||
// SubsManager removed for Meteor 3 migration
|
||||
const { calculateIndexData } = Utils;
|
||||
|
||||
@@ -134,6 +374,11 @@ Template.cardDetails.onCreated(function () {
|
||||
});
|
||||
|
||||
Template.cardDetails.onRendered(function () {
|
||||
// A reactive re-render (e.g. a card moving between the inline swimlane render
|
||||
// and the draggable openCards popup) can create and then remove this instance
|
||||
// within the same flush. If our DOM range is already gone, calling this.$()
|
||||
// below throws "Can't select in removed DomRange", so bail out early.
|
||||
if (this.view && this.view.isDestroyed) return;
|
||||
this.calculateNextPeak();
|
||||
if (Meteor.settings.public.CARD_OPENED_WEBHOOK_ENABLED) {
|
||||
// Send Webhook but not create Activities records ---
|
||||
@@ -273,6 +518,59 @@ 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() {
|
||||
const card = Template.currentData();
|
||||
if (!card || !card.getLocations) return [];
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const provider = user ? user.getMapProvider() : 'openstreetmap';
|
||||
return card.getLocations().map(loc => {
|
||||
const hasCoordinates =
|
||||
typeof loc.latitude === 'number' && typeof loc.longitude === 'number';
|
||||
const mapUrl = hasCoordinates
|
||||
? mapLinkFor(provider, loc.latitude, loc.longitude)
|
||||
: '';
|
||||
return { ...loc, hasCoordinates, mapUrl };
|
||||
});
|
||||
},
|
||||
|
||||
// #3392: PI Program Board "Red Strings". Resolve this card's dependencies
|
||||
// into card objects (with relation type, color, icon and a relative link).
|
||||
getDependencyCards() {
|
||||
const card = Template.currentData();
|
||||
if (!card || typeof card.getDependencies !== 'function') return [];
|
||||
return card
|
||||
.getDependencies()
|
||||
.map(dep => {
|
||||
const target = ReactiveCache.getCard(dep.cardId);
|
||||
if (!target) return null;
|
||||
return {
|
||||
card: target,
|
||||
linkUrl: target.originRelativeUrl(),
|
||||
type: dep.type,
|
||||
color: dep.color,
|
||||
icon: dep.icon,
|
||||
typeLabel: `dependency-type-${dep.type}`,
|
||||
// Per-row relation-type dropdown options with the current one marked.
|
||||
typeOption: DEPENDENCY_TYPES.map(t => ({
|
||||
id: t.id,
|
||||
label: `dependency-type-${t.id}`,
|
||||
selected: t.id === dep.type,
|
||||
})),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
},
|
||||
|
||||
customFieldsGrid() {
|
||||
return ReactiveCache.getCurrentUser().hasCustomFieldsGrid();
|
||||
},
|
||||
@@ -329,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()
|
||||
);
|
||||
},
|
||||
@@ -463,6 +767,17 @@ Template.cardDetails.events({
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// When the card is shown inside a popup (opened from the Board Table view,
|
||||
// search results, or a mini-screen list), the redundant popup title header
|
||||
// is hidden, so this is the only close button. Close the popup and clear its
|
||||
// session state instead of running the board/route close flow below.
|
||||
if (Popup.isOpen() && Utils.getPopupCardId()) {
|
||||
Session.delete('popupCardId');
|
||||
Session.delete('popupCardBoardId');
|
||||
Popup.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve card context defensively because cardDetails can be rendered
|
||||
// from several parents (board, popup, gantt, etc.).
|
||||
const card = getCurrentCardFromContext({ ignorePopupCard: true }) || Template.currentData();
|
||||
@@ -544,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) {
|
||||
@@ -605,14 +928,96 @@ Template.cardDetails.events({
|
||||
const card = Template.currentData();
|
||||
Utils.goCardId(card.linkedId);
|
||||
},
|
||||
'click .js-add-dependency': Popup.open('cardDependencies'),
|
||||
'click .js-remove-dependency'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const targetId = event.currentTarget.dataset.targetId;
|
||||
const card = Template.currentData();
|
||||
if (card && targetId) {
|
||||
card.removeDependency(targetId);
|
||||
}
|
||||
},
|
||||
'change .js-dependency-type'(event) {
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const targetId = event.currentTarget.dataset.targetId;
|
||||
const card = Template.currentData();
|
||||
if (card && targetId) {
|
||||
card.setDependencyProps(targetId, { type: event.currentTarget.value });
|
||||
}
|
||||
},
|
||||
'change .js-dependency-color'(event) {
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const targetId = event.currentTarget.dataset.targetId;
|
||||
const card = Template.currentData();
|
||||
if (card && targetId) {
|
||||
card.setDependencyProps(targetId, { color: event.currentTarget.value });
|
||||
}
|
||||
},
|
||||
'click .js-dependency-icon'(event) {
|
||||
if (!Utils.canModifyCard()) return;
|
||||
// Remember the source card + which dependency the picked icon applies to.
|
||||
editingDependencyCard = Template.currentData();
|
||||
editingDependencyTargetId = event.currentTarget.dataset.targetId;
|
||||
Popup.open('cardDependencyIcon')(event);
|
||||
},
|
||||
'click .js-member': Popup.open('cardMember'),
|
||||
'click .js-add-members': Popup.open('cardMembers'),
|
||||
'click .js-assignee': Popup.open('cardAssignee'),
|
||||
'click .js-add-assignees': Popup.open('cardAssignees'),
|
||||
'click .js-add-labels': Popup.open('cardLabels'),
|
||||
'click .js-add-stickers': Popup.open('cardStickers'),
|
||||
'click .js-remove-sticker'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const index = parseInt(event.currentTarget.dataset.index, 10);
|
||||
const card = Template.currentData();
|
||||
if (card && !Number.isNaN(index)) {
|
||||
card.removeStickerAt(index);
|
||||
}
|
||||
},
|
||||
'click .js-add-location'(event) {
|
||||
event.preventDefault();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const card = Template.currentData();
|
||||
editingLocationId.set(null);
|
||||
Popup.open('cardLocations')(event, {
|
||||
dataContextIfCurrentDataIsUndefined: card,
|
||||
});
|
||||
},
|
||||
'click .js-edit-location'(event) {
|
||||
// Let the "Open in map" link work without also opening the edit popup.
|
||||
if ($(event.target).closest('a.card-location-map').length) return;
|
||||
event.preventDefault();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const card = Template.currentData();
|
||||
editingLocationId.set(event.currentTarget.dataset.locationId || null);
|
||||
Popup.open('cardLocations')(event, {
|
||||
dataContextIfCurrentDataIsUndefined: card,
|
||||
});
|
||||
},
|
||||
'click .js-remove-location'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const locationId = event.currentTarget.dataset.locationId;
|
||||
const card = Template.currentData();
|
||||
if (card && locationId) {
|
||||
card.removeLocation(locationId);
|
||||
}
|
||||
},
|
||||
'click .js-received-date': Popup.open('editCardReceivedDate'),
|
||||
'click .js-start-date': Popup.open('editCardStartDate'),
|
||||
'click .js-due-date': Popup.open('editCardDueDate'),
|
||||
'click .js-toggle-due-complete'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const card = Template.currentData();
|
||||
card.setDueComplete(!card.getDueComplete());
|
||||
},
|
||||
'click .js-end-date': Popup.open('editCardEndDate'),
|
||||
'click .js-show-positive-votes': Popup.open('positiveVoteMembers'),
|
||||
'click .js-show-negative-votes': Popup.open('negativeVoteMembers'),
|
||||
@@ -856,7 +1261,7 @@ const EXCEL_EXPORT_FIELDS = [
|
||||
{ field: 'checklists', label: 'checklists' },
|
||||
{ field: 'subtasks', label: 'export-card-subtasks' },
|
||||
{ field: 'comments', label: 'comments' },
|
||||
{ field: 'attachments', label: 'export-card-attachments' },
|
||||
{ field: 'attachments', label: 'attachments' },
|
||||
];
|
||||
|
||||
Template.exportCardPopup.onCreated(function () {
|
||||
@@ -1011,7 +1416,7 @@ Template.cardDetailsActionsPopup.helpers({
|
||||
},
|
||||
|
||||
isBoardAdmin() {
|
||||
return ReactiveCache.getCurrentUser().isBoardAdmin();
|
||||
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
|
||||
},
|
||||
|
||||
showListOnMinicard() {
|
||||
@@ -1054,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;
|
||||
@@ -1100,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({
|
||||
@@ -1114,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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1129,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);
|
||||
@@ -1144,6 +1553,109 @@ Template.cardMembersPopup.helpers({
|
||||
},
|
||||
});
|
||||
|
||||
// Popup that adds or edits a single card location (name, address, latitude,
|
||||
// longitude). Cards can hold multiple locations, like members.
|
||||
Template.cardLocationsPopup.onCreated(function () {
|
||||
const data = Template.currentData();
|
||||
this.cardId = data && data._id;
|
||||
this.detectMsg = new ReactiveVar('');
|
||||
this.mapSavedMsg = new ReactiveVar('');
|
||||
});
|
||||
|
||||
Template.cardLocationsPopup.helpers({
|
||||
location() {
|
||||
const tpl = Template.instance();
|
||||
const card = ReactiveCache.getCard(tpl.cardId);
|
||||
const id = editingLocationId.get();
|
||||
if (card && id) {
|
||||
const found = card.getLocations().find(loc => loc._id === id);
|
||||
if (found) return found;
|
||||
}
|
||||
return {};
|
||||
},
|
||||
detectMessage() {
|
||||
return Template.instance().detectMsg.get();
|
||||
},
|
||||
isMapProvider(provider) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const current = user ? user.getMapProvider() : 'openstreetmap';
|
||||
return current === provider;
|
||||
},
|
||||
mapSavedMessage() {
|
||||
return Template.instance().mapSavedMsg.get();
|
||||
},
|
||||
});
|
||||
|
||||
Template.cardLocationsPopup.events({
|
||||
'click .js-detect-location'(event) {
|
||||
event.preventDefault();
|
||||
const tpl = Template.instance();
|
||||
const linkInput = tpl.find('.js-location-map-link');
|
||||
const parsed = parseMapLink(linkInput ? linkInput.value : '');
|
||||
let filled = false;
|
||||
if (typeof parsed.latitude === 'number') {
|
||||
tpl.find('.js-location-latitude').value = parsed.latitude;
|
||||
tpl.find('.js-location-longitude').value = parsed.longitude;
|
||||
filled = true;
|
||||
}
|
||||
if (parsed.name) {
|
||||
tpl.find('.js-location-name').value = parsed.name;
|
||||
filled = true;
|
||||
}
|
||||
if (parsed.address) {
|
||||
tpl.find('.js-location-address').value = parsed.address;
|
||||
filled = true;
|
||||
}
|
||||
tpl.detectMsg.set(
|
||||
TAPi18n.__(filled ? 'location-detect-done' : 'location-detect-none'),
|
||||
);
|
||||
},
|
||||
'submit .js-card-location-form'(event) {
|
||||
event.preventDefault();
|
||||
const tpl = Template.instance();
|
||||
const card = ReactiveCache.getCard(tpl.cardId);
|
||||
if (!card) {
|
||||
Popup.back();
|
||||
return;
|
||||
}
|
||||
const name = tpl.find('.js-location-name').value.trim();
|
||||
const address = tpl.find('.js-location-address').value.trim();
|
||||
const latRaw = tpl.find('.js-location-latitude').value.trim();
|
||||
const lonRaw = tpl.find('.js-location-longitude').value.trim();
|
||||
const latitude = latRaw === '' ? undefined : parseFloat(latRaw);
|
||||
const longitude = lonRaw === '' ? undefined : parseFloat(lonRaw);
|
||||
const data = { name, address, latitude, longitude };
|
||||
const id = editingLocationId.get();
|
||||
if (id) {
|
||||
card.updateLocation(id, data);
|
||||
} else {
|
||||
card.addLocation(data);
|
||||
}
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-delete-location'(event) {
|
||||
event.preventDefault();
|
||||
const tpl = Template.instance();
|
||||
const card = ReactiveCache.getCard(tpl.cardId);
|
||||
const id = editingLocationId.get();
|
||||
if (card && id) {
|
||||
card.removeLocation(id);
|
||||
}
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-save-map-provider'(event) {
|
||||
event.preventDefault();
|
||||
const tpl = Template.instance();
|
||||
const select = tpl.find('.js-map-provider');
|
||||
const provider = select ? select.value : 'openstreetmap';
|
||||
Meteor.call('setMapProvider', provider, err => {
|
||||
tpl.mapSavedMsg.set(
|
||||
TAPi18n.__(err ? 'server-error' : 'map-provider-saved'),
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const filterMembers = (filterTerm) => {
|
||||
let currBoard = Utils.getCurrentBoard();
|
||||
let members = currBoard.activeMembers();
|
||||
@@ -1222,6 +1734,15 @@ function registerCardDialogTemplate(templateName) {
|
||||
isDialogOptionListId(listId) {
|
||||
return Template.instance().dialog.isDialogOptionListId(listId);
|
||||
},
|
||||
isSelectedBoardId(boardId) {
|
||||
return Template.instance().dialog.isSelectedBoardId(boardId);
|
||||
},
|
||||
isSelectedSwimlaneId(swimlaneId) {
|
||||
return Template.instance().dialog.isSelectedSwimlaneId(swimlaneId);
|
||||
},
|
||||
isSelectedListId(listId) {
|
||||
return Template.instance().dialog.isSelectedListId(listId);
|
||||
},
|
||||
isDialogOptionCardId(cardId) {
|
||||
return Template.instance().dialog.isDialogOptionCardId(cardId);
|
||||
},
|
||||
@@ -1233,14 +1754,16 @@ function registerCardDialogTemplate(templateName) {
|
||||
Template[templateName].events({
|
||||
async 'click .js-done'(event, tpl) {
|
||||
const dialog = tpl.dialog;
|
||||
const boardSelect = tpl.$('.js-select-boards')[0];
|
||||
const boardId = boardSelect?.options[boardSelect?.selectedIndex]?.value;
|
||||
|
||||
const listSelect = tpl.$('.js-select-lists')[0];
|
||||
const listId = listSelect?.options[listSelect?.selectedIndex]?.value;
|
||||
|
||||
const swimlaneSelect = tpl.$('.js-select-swimlanes')[0];
|
||||
const swimlaneId = swimlaneSelect?.options[swimlaneSelect?.selectedIndex]?.value;
|
||||
// Read the target board/swimlane/list from the dialog's live reactive
|
||||
// selection rather than the DOM <select> elements. A reactive re-render
|
||||
// (e.g. the boards() helper transiently returning [] while a 'board'
|
||||
// subscription re-resolves) can leave a <select> momentarily option-less,
|
||||
// making selectedIndex -1 and the scraped id undefined — which then makes
|
||||
// card.move()/copyCard() fail with a 403 "may only update documents by ID"
|
||||
// validation error. The reactive vars stay correct across those renders.
|
||||
const boardId = dialog.selectedBoardId.get();
|
||||
const swimlaneId = dialog.selectedSwimlaneId.get();
|
||||
const listId = dialog.selectedListId.get();
|
||||
|
||||
const cardSelect = tpl.$('.js-select-cards')[0];
|
||||
const cardId = cardSelect?.options?.length > 0
|
||||
@@ -1570,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');
|
||||
@@ -1608,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 },
|
||||
@@ -1673,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);
|
||||
}
|
||||
@@ -1897,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);
|
||||
@@ -1996,3 +2544,95 @@ Template.cardAssigneePopup.events({
|
||||
},
|
||||
'click .js-edit-profile': Popup.open('editProfile'),
|
||||
});
|
||||
|
||||
// #3392: PI Program Board "Red Strings". Popup to pick another card on the
|
||||
// same board to add as a dependency. The popup's data context is the source
|
||||
// card (set by Popup.open on the .js-add-dependency element inside cardDetails).
|
||||
Template.cardDependenciesPopup.onCreated(function () {
|
||||
this.searchTerm = new ReactiveVar('');
|
||||
this.newType = new ReactiveVar(DEPENDENCY_TYPES[0].id);
|
||||
this.newColor = new ReactiveVar(DEFAULT_DEPENDENCY_COLOR);
|
||||
});
|
||||
|
||||
Template.cardDependenciesPopup.helpers({
|
||||
defaultColor() {
|
||||
return Template.instance().newColor.get();
|
||||
},
|
||||
typeOption() {
|
||||
const current = Template.instance().newType.get();
|
||||
return DEPENDENCY_TYPES.map(t => ({
|
||||
id: t.id,
|
||||
label: `dependency-type-${t.id}`,
|
||||
selected: t.id === current,
|
||||
}));
|
||||
},
|
||||
candidateCards() {
|
||||
const sourceCard = Template.currentData();
|
||||
if (!sourceCard) return [];
|
||||
const term = Template.instance().searchTerm.get().toLowerCase();
|
||||
const existingIds = (sourceCard.getDependencies
|
||||
? sourceCard.getDependencies()
|
||||
: []
|
||||
).map(dep => dep.cardId);
|
||||
const cards = ReactiveCache.getCards({
|
||||
boardId: sourceCard.boardId,
|
||||
archived: false,
|
||||
});
|
||||
return cards.filter(card => {
|
||||
if (card._id === sourceCard._id) return false;
|
||||
if (existingIds.includes(card._id)) return false;
|
||||
if (term && !(card.title || '').toLowerCase().includes(term)) return false;
|
||||
return true;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Template.cardDependenciesPopup.events({
|
||||
'keyup .js-dependency-search'(event) {
|
||||
Template.instance().searchTerm.set(event.currentTarget.value || '');
|
||||
},
|
||||
'change .js-new-dependency-type'(event) {
|
||||
Template.instance().newType.set(event.currentTarget.value);
|
||||
},
|
||||
'change .js-new-dependency-color'(event) {
|
||||
Template.instance().newColor.set(event.currentTarget.value);
|
||||
},
|
||||
'click .js-pick-dependency'(event) {
|
||||
event.preventDefault();
|
||||
const tpl = Template.instance();
|
||||
const sourceCard = Template.currentData();
|
||||
const targetId = event.currentTarget.dataset.targetId;
|
||||
if (sourceCard && targetId) {
|
||||
sourceCard.addDependency(targetId, {
|
||||
type: tpl.newType.get(),
|
||||
color: tpl.newColor.get(),
|
||||
});
|
||||
}
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
|
||||
// #3392: icon picker for an existing dependency. The source card is the popup's
|
||||
// data context; the target id was stashed on the cardDetails instance by the
|
||||
// .js-dependency-icon click handler.
|
||||
Template.cardDependencyIconPopup.helpers({
|
||||
dependencyIcons() {
|
||||
return DEPENDENCY_ICON_CHOICES.map(name => ({ name }));
|
||||
},
|
||||
});
|
||||
|
||||
Template.cardDependencyIconPopup.events({
|
||||
'click .js-pick-dependency-icon'(event) {
|
||||
event.preventDefault();
|
||||
// Use the source card captured when the picker was opened (the popup's own
|
||||
// data context is the dependency row, which has no setDependencyProps).
|
||||
const sourceCard = editingDependencyCard;
|
||||
const icon = event.currentTarget.dataset.icon || DEFAULT_DEPENDENCY_ICON;
|
||||
if (sourceCard && editingDependencyTargetId) {
|
||||
sourceCard.setDependencyProps(editingDependencyTargetId, { icon });
|
||||
}
|
||||
editingDependencyTargetId = null;
|
||||
editingDependencyCard = null;
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
template(name="cardStickersPopup")
|
||||
.card-stickers-picker.clearfix
|
||||
each stickerIcons
|
||||
a.card-sticker-select.js-select-sticker(class="{{#if isSelected}}active{{/if}}" title="{{stickerTitle}}")
|
||||
i.fa(class="fa-{{icon}} {{#if highlight}}sticker-{{highlight}}{{/if}}")
|
||||
if isSelected
|
||||
i.fa.fa-check.card-sticker-check
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { STICKER_PICKER } from '/models/metadata/stickers';
|
||||
|
||||
// Picker popup that lets a user add/remove stickers on a card. Each entry is
|
||||
// { icon, highlight?, name? }: the plain Font Awesome icons plus the mascot
|
||||
// (underlined) and computer (ringed) highlighted stickers. Opened from the card
|
||||
// details stickers section with the card as the data context.
|
||||
|
||||
Template.cardStickersPopup.onCreated(function () {
|
||||
const data = Template.currentData();
|
||||
this.cardId = data && data._id;
|
||||
});
|
||||
|
||||
function pickerCard(tpl) {
|
||||
return ReactiveCache.getCard(tpl.cardId);
|
||||
}
|
||||
|
||||
Template.cardStickersPopup.helpers({
|
||||
stickerIcons() {
|
||||
return STICKER_PICKER;
|
||||
},
|
||||
isSelected() {
|
||||
const card = pickerCard(Template.instance());
|
||||
return !!(card && card.hasSticker(this.icon, this.highlight));
|
||||
},
|
||||
stickerTitle() {
|
||||
return this.name || this.icon;
|
||||
},
|
||||
});
|
||||
|
||||
Template.cardStickersPopup.events({
|
||||
'click .js-select-sticker'(event) {
|
||||
event.preventDefault();
|
||||
const card = pickerCard(Template.instance());
|
||||
if (card) {
|
||||
card.toggleSticker(this.icon, this.highlight, this.name);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -14,5 +14,5 @@
|
||||
font-size: inherit;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
content: "\f017";
|
||||
margin-right: 0.3em;
|
||||
margin-inline-end: 0.3em;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ textarea.js-edit-checklist-item {
|
||||
color: #8c8c8c;
|
||||
text-decoration: underline;
|
||||
word-wrap: break-word;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
padding-top: 6px;
|
||||
}
|
||||
.delete-text:hover,
|
||||
@@ -32,7 +32,7 @@ textarea.js-edit-checklist-item {
|
||||
align-items: center;
|
||||
}
|
||||
.checklist-progress-bar-container .checklist-progress-text {
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
.checklist-progress-bar-container .checklist-progress-bar {
|
||||
width: 80%;
|
||||
@@ -51,7 +51,7 @@ textarea.js-edit-checklist-item {
|
||||
padding: 10px;
|
||||
}
|
||||
.checklist-title .checkbox {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 18px;
|
||||
@@ -63,21 +63,21 @@ textarea.js-edit-checklist-item {
|
||||
}
|
||||
.checklist-title .checklist-stat {
|
||||
margin: 0 0.5em;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
padding-top: 6px;
|
||||
}
|
||||
.checklist-title .checklist-stat.is-finished {
|
||||
color: #3cb500;
|
||||
}
|
||||
.checklist-title span.fa.checklist-handle {
|
||||
padding-right: 20px;
|
||||
padding-inline-end: 20px;
|
||||
padding-top: 3px;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
#card-details-overlay {
|
||||
top: 0;
|
||||
bottom: -600px;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
.checklist {
|
||||
background: #f7f7f7;
|
||||
@@ -123,14 +123,14 @@ textarea.js-edit-checklist-item {
|
||||
background-color: #ebebeb;
|
||||
}
|
||||
.checklist-item .check-box-container {
|
||||
padding-right: 10px;
|
||||
padding-inline-end: 10px;
|
||||
}
|
||||
.checklist-item .check-box {
|
||||
margin: 0.1em 0 0 0;
|
||||
}
|
||||
.checklist-item .check-box.is-checked {
|
||||
border-bottom: 2px solid #3cb500;
|
||||
border-right: 2px solid #3cb500;
|
||||
border-inline-end: 2px solid #3cb500;
|
||||
}
|
||||
.checklist-item .item-title {
|
||||
flex: 1;
|
||||
@@ -148,7 +148,7 @@ textarea.js-edit-checklist-item {
|
||||
}
|
||||
.checklist-item span.fa.checklistitem-handle {
|
||||
padding-top: 2px;
|
||||
padding-right: 10px;
|
||||
padding-inline-end: 10px;
|
||||
}
|
||||
.js-delete-checklist-item,
|
||||
.js-convert-checklist-item-to-card {
|
||||
@@ -171,9 +171,9 @@ textarea.js-edit-checklist-item {
|
||||
}
|
||||
.add-checklist-top {
|
||||
/* more space to checklists title */
|
||||
padding-left: 20px;
|
||||
padding-inline-start: 20px;
|
||||
/* + is easier clickable */
|
||||
padding-right: 20px;
|
||||
padding-inline-end: 20px;
|
||||
}
|
||||
.add-checklist-top.js-open-inlined-form:hover {
|
||||
background: #dbdbdb;
|
||||
@@ -185,9 +185,9 @@ textarea.js-edit-checklist-item {
|
||||
width: 100%;
|
||||
}
|
||||
.checklist-details-menu {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
padding: 6px 10px 6px 10px;
|
||||
}
|
||||
.edit-controls label.toggle-label {
|
||||
margin-left: 2px;
|
||||
margin-inline-start: 2px;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
display: inline-block;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
margin-right: 4px;
|
||||
margin-inline-end: 4px;
|
||||
margin-bottom: 5px;
|
||||
padding: 3px 8px;
|
||||
max-width: 210px;
|
||||
@@ -140,7 +140,7 @@
|
||||
}
|
||||
.edit-label .card-label,
|
||||
.create-label .card-label {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
height: 25px;
|
||||
margin: 0px 3% 7px 0px;
|
||||
width: 10.5%;
|
||||
@@ -153,7 +153,7 @@
|
||||
}
|
||||
.edit-labels .card-label {
|
||||
height: 30px;
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
padding: 1px 5px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -164,7 +164,7 @@
|
||||
margin-bottom: 4px;
|
||||
position: relative;
|
||||
top: auto;
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
width: 260px;
|
||||
}
|
||||
.edit-labels-pop-over {
|
||||
@@ -185,24 +185,24 @@
|
||||
min-height: 18px;
|
||||
padding: 8px;
|
||||
position: relative;
|
||||
transition: margin-right 0.1s;
|
||||
transition: margin-inline-end 0.1s;
|
||||
}
|
||||
.card-label-selectable .card-label-selectable-icon {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: -20px;
|
||||
inset-inline-end: -20px;
|
||||
}
|
||||
.card-label-selectable.active:hover,
|
||||
.card-label-selectable.active,
|
||||
.card-label-selectable.active.selected:hover,
|
||||
.card-label-selectable.active.selected {
|
||||
padding-right: 32px;
|
||||
padding-inline-end: 32px;
|
||||
}
|
||||
.card-label-selectable.active:hover .card-label-selectable-icon,
|
||||
.card-label-selectable.active .card-label-selectable-icon,
|
||||
.card-label-selectable.active.selected:hover .card-label-selectable-icon,
|
||||
.card-label-selectable.active.selected .card-label-selectable-icon {
|
||||
right: 6px;
|
||||
inset-inline-end: 6px;
|
||||
}
|
||||
.card-label-selectable.selected,
|
||||
.card-label-selectable:hover {
|
||||
@@ -210,21 +210,21 @@
|
||||
}
|
||||
.active .card-label-selectable,
|
||||
.active .card-label-selectable:hover {
|
||||
margin-right: 0;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
.active .card-label-selectable .card-label-selectable-icon {
|
||||
right: 8px;
|
||||
inset-inline-end: 8px;
|
||||
}
|
||||
.card-label-edit-button {
|
||||
border-radius: 3px;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
padding: 8px;
|
||||
}
|
||||
.card-label-edit-button:hover {
|
||||
background: #dbdbdb;
|
||||
}
|
||||
ul.edit-labels-pop-over span.label-handle {
|
||||
padding-right: 10px;
|
||||
padding-inline-end: 10px;
|
||||
display: inline-block;
|
||||
width: 1.2em;
|
||||
text-align: center;
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
display: none;
|
||||
}
|
||||
.minicard-wrapper .multi-selection-checkbox + .minicard {
|
||||
margin-left: 1vw;
|
||||
margin-inline-start: 1vw;
|
||||
}
|
||||
/* Enlarge the clickable area of the small selection checkbox so it can be
|
||||
reliably clicked at the left side of the minicard. The visible 13px box (and
|
||||
@@ -36,8 +36,8 @@
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: -8px;
|
||||
right: -8px;
|
||||
inset-inline-start: -8px;
|
||||
inset-inline-end: -8px;
|
||||
bottom: -8px;
|
||||
}
|
||||
@media only screen {
|
||||
@@ -56,16 +56,16 @@
|
||||
}
|
||||
}
|
||||
.minicard-details-menu-with-handle {
|
||||
float: right;
|
||||
padding-left: 0.7vw;
|
||||
float: inline-end;
|
||||
padding-inline-start: 0.7vw;
|
||||
font-size: clamp(14px, 3vw, 18px);
|
||||
padding: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
.minicard-details-menu {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
font-size: clamp(14px, 3vw, 18px);
|
||||
padding-left: 0.7vw;
|
||||
padding-inline-start: 0.7vw;
|
||||
}
|
||||
@media print {
|
||||
.minicard-details-menu,
|
||||
@@ -76,7 +76,7 @@
|
||||
.minicard.linked-board .linked-icon,
|
||||
.minicard.linked-card .linked-icon {
|
||||
display: inline-block;
|
||||
margin-right: 1.5vw;
|
||||
margin-inline-end: 1.5vw;
|
||||
vertical-align: baseline;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
@@ -110,22 +110,38 @@
|
||||
}
|
||||
.minicard .minicard-labels {
|
||||
float: none;
|
||||
margin-right: 6vw;
|
||||
margin-inline-end: 6vw;
|
||||
}
|
||||
.minicard .minicard-labels .minicard-label {
|
||||
width: clamp(12px, 1.5vw, 16px);
|
||||
height: clamp(12px, 1.5vw, 16px);
|
||||
border-radius: 0.3vw;
|
||||
margin-right: 0.4vw;
|
||||
margin-inline-end: 0.4vw;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
/* 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;
|
||||
}
|
||||
.minicard .minicard-custom-fields {
|
||||
display: block;
|
||||
margin-right: 6vw;
|
||||
margin-inline-end: 6vw;
|
||||
}
|
||||
.minicard .minicard-custom-field {
|
||||
display: flex;
|
||||
@@ -135,20 +151,20 @@
|
||||
display: block;
|
||||
word-wrap: break-word;
|
||||
max-width: 13vw;
|
||||
margin-right: 0.5vw;
|
||||
margin-inline-end: 0.5vw;
|
||||
}
|
||||
.minicard .minicard-custom-field-item-fullwidth {
|
||||
flex-grow: 1;
|
||||
display: block;
|
||||
word-wrap: break-word;
|
||||
max-width: 100%;
|
||||
margin-right: 0.5vw;
|
||||
margin-inline-end: 0.5vw;
|
||||
}
|
||||
.minicard .handle {
|
||||
width: clamp(20px, 2.5vw, 28px);
|
||||
height: clamp(20px, 2.5vw, 28px);
|
||||
position: absolute;
|
||||
right: 0vw;
|
||||
inset-inline-end: 0vw;
|
||||
top: 28px;
|
||||
display: none;
|
||||
z-index: 1;
|
||||
@@ -166,12 +182,12 @@
|
||||
text-align: center;
|
||||
}
|
||||
.minicard .minicard-title {
|
||||
margin-right: 1.5vw;
|
||||
margin-inline-end: 1.5vw;
|
||||
}
|
||||
.minicard .minicard-title .card-number {
|
||||
color: #b3b3b3;
|
||||
display: inline-block;
|
||||
margin-right: 0.7vw;
|
||||
margin-inline-end: 0.7vw;
|
||||
}
|
||||
@media only screen {
|
||||
.minicard .minicard-title p:last-child {
|
||||
@@ -188,11 +204,11 @@
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
margin-right: 6vw;
|
||||
margin-inline-end: 6vw;
|
||||
clear: both;
|
||||
}
|
||||
.minicard .date {
|
||||
margin-right: 0.4vw;
|
||||
margin-inline-end: 0.4vw;
|
||||
}
|
||||
|
||||
/* Unicode icons for minicard dates - matching cardDate.css */
|
||||
@@ -211,7 +227,7 @@
|
||||
|
||||
.minicard .card-date time::before {
|
||||
font-size: inherit;
|
||||
margin-right: 0.3em;
|
||||
margin-inline-end: 0.3em;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@@ -324,7 +340,7 @@
|
||||
}
|
||||
|
||||
.minicard .badges {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
margin-top: 8px;
|
||||
color: #808080;
|
||||
}
|
||||
@@ -332,8 +348,8 @@
|
||||
display: none;
|
||||
}
|
||||
.minicard .badges .badge {
|
||||
float: left;
|
||||
margin-right: 1.5vw;
|
||||
float: inline-start;
|
||||
margin-inline-end: 1.5vw;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
@@ -344,7 +360,7 @@
|
||||
color: #fff;
|
||||
}
|
||||
.minicard .badges .badge:last-of-type {
|
||||
margin-right: 0;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
.minicard .badges .badge .badge-icon,
|
||||
.minicard .badges .badge .badge-text {
|
||||
@@ -356,24 +372,24 @@
|
||||
}
|
||||
.minicard .badges .badge .badge-text {
|
||||
font-size: 0.9em;
|
||||
padding-left: 0.3vw;
|
||||
padding-inline-start: 0.3vw;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.minicard .badges .badge .check-list-text {
|
||||
padding-left: 0px;
|
||||
padding-inline-start: 0px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.minicard .minicard-members,
|
||||
.minicard .minicard-assignees,
|
||||
.minicard .minicard-creator {
|
||||
float: right;
|
||||
margin-left: 0.7vw;
|
||||
float: inline-end;
|
||||
margin-inline-start: 0.7vw;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.minicard .minicard-members .member,
|
||||
.minicard .minicard-assignees .member,
|
||||
.minicard .minicard-creator .member {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
border-radius: 50%;
|
||||
height: clamp(24px, 3.5vw, 32px);
|
||||
width: clamp(24px, 3.5vw, 32px);
|
||||
@@ -382,7 +398,7 @@
|
||||
.minicard .minicard-members .assignee,
|
||||
.minicard .minicard-assignees .assignee,
|
||||
.minicard .minicard-creator .assignee {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
border-radius: 50%;
|
||||
height: clamp(24px, 3.5vw, 32px);
|
||||
width: clamp(24px, 3.5vw, 32px);
|
||||
@@ -408,7 +424,7 @@
|
||||
background-color: #eee;
|
||||
width: 100%;
|
||||
margin-bottom: 3px;
|
||||
margin-left: -0.5vw;
|
||||
margin-inline-start: -0.5vw;
|
||||
border-radius: 0.4vw;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -834,3 +850,52 @@
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Trello-style "mark complete" toggle, left of the minicard title */
|
||||
.minicard-title .minicard-complete-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
vertical-align: middle;
|
||||
/* Nudge down so the checkmark sits at the vertical middle of the title text
|
||||
(the checked materialCheckBox is shifted up 4px by its own positioning). */
|
||||
position: relative;
|
||||
top: 4px;
|
||||
margin-inline-end: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Sticker icon highlight styles (used instead of colour): taco pack => underline,
|
||||
pete pack => round (ring around the icon). Icon keeps its normal colour. */
|
||||
.minicard-sticker i.sticker-underline {
|
||||
border-bottom: 2px solid currentColor;
|
||||
padding-bottom: 1px;
|
||||
line-height: 1;
|
||||
}
|
||||
.minicard-sticker i.sticker-round {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.6em;
|
||||
height: 1.6em;
|
||||
line-height: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* #3984: Visual card aging — progressively fade cards that have not been
|
||||
* touched recently, when card aging is enabled for the board. */
|
||||
.minicard.minicard-aging-1 {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.minicard.minicard-aging-2 {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.minicard.minicard-aging-3 {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.minicard.minicard-aging-1:hover,
|
||||
.minicard.minicard-aging-2:hover,
|
||||
.minicard.minicard-aging-3:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -2,14 +2,22 @@ template(name="minicard")
|
||||
.minicard.nodragscroll(
|
||||
class="{{#if isLinkedCard}}linked-card{{/if}}"
|
||||
class="{{#if isLinkedBoard}}linked-board{{/if}}"
|
||||
class="{{agingClass}}"
|
||||
class="{{#if colorClass}}minicard-{{colorClass}}{{/if}}")
|
||||
if canMoveCard
|
||||
if isTouchScreenOrShowDesktopDragHandles
|
||||
.handle
|
||||
i.fa.fa-arrows
|
||||
//- #459: keyboard/screen-reader reordering (visually hidden, focusable).
|
||||
.minicard-move-buttons.sr-only
|
||||
a.js-card-move-up(href="#" role="button" aria-label="{{_ 'move-card-up'}}") {{_ 'move-card-up'}}
|
||||
a.js-card-move-down(href="#" role="button" aria-label="{{_ 'move-card-down'}}") {{_ 'move-card-down'}}
|
||||
if canModifyCard
|
||||
a.minicard-details-menu-with-handle.js-open-minicard-details-menu(title="{{_ 'cardDetailsActionsPopup-title'}}")
|
||||
i.fa.fa-bars
|
||||
if showDependencyConnectHandle
|
||||
a.dependency-connect-handle.js-dependency-connect-handle(title="{{_ 'drag-to-connect'}}")
|
||||
i.fa.fa-dot-circle-o
|
||||
.dates
|
||||
if showReceived
|
||||
if getReceived
|
||||
@@ -21,7 +29,7 @@ template(name="minicard")
|
||||
+minicardStartDate
|
||||
if showDue
|
||||
if getDue
|
||||
.date
|
||||
.date(class="{{#if getDueComplete}}due-complete{{/if}}")
|
||||
+minicardDueDate
|
||||
if showEnd
|
||||
if getEnd
|
||||
@@ -54,6 +62,10 @@ template(name="minicard")
|
||||
span {{_ 'upload-completed'}}
|
||||
|
||||
.minicard-title
|
||||
if showDueComplete
|
||||
if canModifyCard
|
||||
a.minicard-complete-toggle.js-toggle-card-complete(title="{{#if getDueComplete}}{{_ 'card-mark-incomplete'}}{{else}}{{_ 'card-mark-complete'}}{{/if}}")
|
||||
.materialCheckBox(class="{{#if getDueComplete}}is-checked{{/if}}")
|
||||
if $eq 'prefix-with-full-path' currentBoard.presentParentTask
|
||||
.parent-prefix
|
||||
| {{ parentString ' > ' }}
|
||||
@@ -152,6 +164,16 @@ template(name="minicard")
|
||||
each shouldShowChecklistAtMinicard
|
||||
+minicardChecklist(checklist=. card=..)
|
||||
.badges
|
||||
if dependencyBadge
|
||||
.badge.badge-state-image-only.minicard-dependencies(title="{{_ 'card-dependencies'}}")
|
||||
span.badge-icon(style="color:{{dependencyBadge.color}}")
|
||||
i.fa(class="fa-{{dependencyBadge.icon}}")
|
||||
span.badge-text {{ dependencyBadge.count }}
|
||||
if stickers.length
|
||||
.badge.badge-state-image-only.minicard-stickers
|
||||
each stickers
|
||||
span.badge-icon.minicard-sticker(title="{{#if name}}{{name}}{{else}}{{icon}}{{/if}}")
|
||||
i.fa(class="fa-{{icon}} {{#if highlight}}sticker-{{highlight}}{{/if}}")
|
||||
if comments.length
|
||||
.badge(title="{{_ 'card-comments-title' comments.length }}")
|
||||
span.badge-icon.badge-comment.badge-text
|
||||
@@ -193,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
|
||||
|
||||
@@ -5,6 +5,8 @@ import { handleFileUpload } from './attachments';
|
||||
import uploadProgressManager from '../../lib/uploadProgressManager';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import ChecklistItems from '/models/checklistItems';
|
||||
import Cards from '/models/cards';
|
||||
import { normalizeDependencies } from '/models/metadata/dependencies';
|
||||
|
||||
function getMinicardFlag(board, onMinicardField, legacyField, defaultValue) {
|
||||
if (!board) return false;
|
||||
@@ -22,6 +24,43 @@ function getMinicardFlag(board, onMinicardField, legacyField, defaultValue) {
|
||||
// });
|
||||
|
||||
Template.minicard.helpers({
|
||||
// #3392: show a drag-to-connect handle on the minicard when the board's
|
||||
// dependency overlay is on and the user can edit the board. Dragging it onto
|
||||
// another card creates a dependency (handled in dependencyOverlay.js).
|
||||
showDependencyConnectHandle() {
|
||||
const board = ReactiveCache.getBoard(this.boardId);
|
||||
return !!(board && board.showDependencies && Utils.canModifyBoard());
|
||||
},
|
||||
// #3392: PI Program Board "Red Strings". Show a small badge on the minicard
|
||||
// when a card has dependencies: the first dependency's icon and color plus the
|
||||
// total count.
|
||||
dependencyBadge() {
|
||||
const deps = normalizeDependencies(this.cardDependencies);
|
||||
if (deps.length === 0) return null;
|
||||
return {
|
||||
icon: deps[0].icon,
|
||||
color: deps[0].color,
|
||||
count: deps.length,
|
||||
};
|
||||
},
|
||||
// #3984: visual card aging — fade cards that have not been touched recently,
|
||||
// based on dateLastActivity, when the board has card aging enabled.
|
||||
agingClass() {
|
||||
const board = ReactiveCache.getBoard(this.boardId);
|
||||
if (!board || !board.cardAging) return '';
|
||||
const last = this.dateLastActivity || this.modifiedAt || this.createdAt;
|
||||
if (!last) return '';
|
||||
const days = (Date.now() - new Date(last).getTime()) / 86400000;
|
||||
// #3984: thresholds are board-configurable (board settings / cardSettings API),
|
||||
// defaulting to 7 / 14 / 28 days.
|
||||
const d1 = board.cardAgingDays1 != null ? board.cardAgingDays1 : 7;
|
||||
const d2 = board.cardAgingDays2 != null ? board.cardAgingDays2 : 14;
|
||||
const d3 = board.cardAgingDays3 != null ? board.cardAgingDays3 : 28;
|
||||
if (days >= d3) return 'minicard-aging-3';
|
||||
if (days >= d2) return 'minicard-aging-2';
|
||||
if (days >= d1) return 'minicard-aging-1';
|
||||
return '';
|
||||
},
|
||||
formattedCurrencyCustomFieldValue(definition) {
|
||||
const customField = this
|
||||
.customFieldsWD()
|
||||
@@ -94,10 +133,25 @@ Template.minicard.helpers({
|
||||
const board = this.board();
|
||||
return getMinicardFlag(board, 'allowsCardNumberOnMinicard', 'allowsCardNumber', false);
|
||||
},
|
||||
// "Mark as complete" toggle on the minicard. No legacy fallback to the
|
||||
// card-details setting (allowsDueComplete): hidden by default so it only
|
||||
// appears when explicitly enabled in Card Settings > Show at Minicard.
|
||||
showDueComplete() {
|
||||
const board = this.board();
|
||||
return getMinicardFlag(board, 'allowsDueCompleteOnMinicard', null, false);
|
||||
},
|
||||
showSubtasks() {
|
||||
const board = this.board();
|
||||
return getMinicardFlag(board, 'allowsSubtasksOnMinicard', 'allowsSubtasks', true);
|
||||
},
|
||||
// #6431: compact checklist item-count badge (finished/total) on the minicard.
|
||||
// Opt-in, OFF by default. Only a cheap boolean read here; the actual checklist
|
||||
// counting (checklistFinishedCount/checklistItemCount) is gated behind this in
|
||||
// the template, so a board with the badge off spends no resources counting.
|
||||
showChecklistCountBadge() {
|
||||
const board = this.board();
|
||||
return !!(board && board.allowsChecklistCountBadgeOnMinicard);
|
||||
},
|
||||
|
||||
hiddenMinicardLabelText() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
@@ -163,7 +217,11 @@ Template.minicard.helpers({
|
||||
// Show checklist if either:
|
||||
// 1. Board-wide setting is enabled, OR
|
||||
// 2. This specific checklist has the setting enabled
|
||||
if (currentBoard.allowsChecklistAtMinicard || checklist.showChecklistAtMinicard) {
|
||||
// #5565: use the same board field the sidebar toggle writes
|
||||
// (`allowsChecklistsOnMinicard`); this previously checked a different,
|
||||
// UI-less field (`allowsChecklistAtMinicard`), so the board toggle to show
|
||||
// checklists on minicards had no effect.
|
||||
if (currentBoard.allowsChecklistsOnMinicard || checklist.showChecklistAtMinicard) {
|
||||
visibleChecklists.push(checklist);
|
||||
}
|
||||
});
|
||||
@@ -172,12 +230,56 @@ Template.minicard.helpers({
|
||||
}
|
||||
});
|
||||
|
||||
// #459: accessible reordering — keyboard/screen-reader users can move a card up
|
||||
// or down within its list via sr-only buttons (no drag-and-drop required). The
|
||||
// move swaps the card's sort value with its neighbour in the same list+swimlane.
|
||||
function moveCardBy(card, delta) {
|
||||
const siblings = ReactiveCache.getCards(
|
||||
{ listId: card.listId, swimlaneId: card.swimlaneId, archived: false },
|
||||
{ sort: { sort: 1 } },
|
||||
);
|
||||
const idx = siblings.findIndex(c => c._id === card._id);
|
||||
const target = siblings[idx + delta];
|
||||
if (idx < 0 || !target) return;
|
||||
// Capture both sort values before either update; the docs are reactive and
|
||||
// card.sort would otherwise change after the first move.
|
||||
const cardSort = card.sort;
|
||||
const targetSort = target.sort;
|
||||
// Persist through the card model's move() mutation — the canonical client
|
||||
// path (e.g. editCardSortOrderPopup). A raw Cards.update of `sort` is the
|
||||
// wrong path here and would be reverted.
|
||||
card.move(card.boardId, card.swimlaneId, card.listId, targetSort);
|
||||
target.move(target.boardId, target.swimlaneId, target.listId, cardSort);
|
||||
}
|
||||
|
||||
Template.minicard.events({
|
||||
'click .js-linked-link'() {
|
||||
if (this.isLinkedCard()) Utils.goCardId(this.linkedId);
|
||||
else if (this.isLinkedBoard())
|
||||
Utils.goBoardId(this.linkedId);
|
||||
},
|
||||
'click .js-card-move-up'(event) {
|
||||
// The move buttons sit inside the minicard anchor; don't let the click
|
||||
// bubble up and open the card detail view.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
moveCardBy(this, -1);
|
||||
},
|
||||
'click .js-card-move-down'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
moveCardBy(this, 1);
|
||||
},
|
||||
'click .js-toggle-card-complete'(event) {
|
||||
// Trello-style "mark complete" toggle (left of the title). Don't let the
|
||||
// click open the card.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!Utils.canModifyCard()) {
|
||||
return;
|
||||
}
|
||||
this.setDueComplete(!this.getDueComplete());
|
||||
},
|
||||
'click .js-toggle-minicard-label-text'() {
|
||||
if (window.localStorage.getItem('hiddenMinicardLabelText')) {
|
||||
window.localStorage.removeItem('hiddenMinicardLabelText'); //true
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -59,13 +59,13 @@ textarea.js-edit-subtask-item {
|
||||
.js-confirm-subtask-delete {
|
||||
background-color: #f7f7f7;
|
||||
position: absolute;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
width: 60%;
|
||||
margin-top: 0;
|
||||
margin-left: 13%;
|
||||
margin-inline-start: 13%;
|
||||
padding-bottom: 2%;
|
||||
padding-left: 3%;
|
||||
padding-right: 3%;
|
||||
padding-inline-start: 3%;
|
||||
padding-inline-end: 3%;
|
||||
z-index: 17;
|
||||
border-radius: 3px;
|
||||
}
|
||||
@@ -86,17 +86,17 @@ textarea.js-edit-subtask-item {
|
||||
padding: left 2% right 2%;
|
||||
}
|
||||
.js-confirm-subtask-delete .js-subtask-delete-buttons .confirm-subtask-delete {
|
||||
margin-left: 12%;
|
||||
float: left;
|
||||
margin-inline-start: 12%;
|
||||
float: inline-start;
|
||||
}
|
||||
.js-confirm-subtask-delete .js-subtask-delete-buttons .toggle-delete-subtask-dialog {
|
||||
margin-right: 12%;
|
||||
float: right;
|
||||
margin-inline-end: 12%;
|
||||
float: inline-end;
|
||||
}
|
||||
#card-details-overlay {
|
||||
top: 0;
|
||||
bottom: -600px;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
@@ -139,17 +139,20 @@ textarea.js-edit-subtask-item {
|
||||
.subtasks-item:hover {
|
||||
background-color: #ebebeb;
|
||||
}
|
||||
.subtasks-item .check-box-container {
|
||||
padding-inline-end: 10px;
|
||||
}
|
||||
.subtasks-item .check-box {
|
||||
margin: 0.1em 0 0 0;
|
||||
}
|
||||
.subtasks-item .check-box.is-checked {
|
||||
border-bottom: 2px solid #3cb500;
|
||||
border-right: 2px solid #3cb500;
|
||||
border-inline-end: 2px solid #3cb500;
|
||||
}
|
||||
/* Unicode checkbox icons styling */
|
||||
.subtasks-item .check-box-unicode {
|
||||
font-size: 1.3em;
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
@@ -158,7 +161,7 @@ textarea.js-edit-subtask-item {
|
||||
/* Grey checkmarks when grey icons setting is enabled */
|
||||
body.grey-icons-enabled .subtasks-item .check-box.is-checked {
|
||||
border-bottom: 2px solid #7a7a7a;
|
||||
border-right: 2px solid #7a7a7a;
|
||||
border-inline-end: 2px solid #7a7a7a;
|
||||
}
|
||||
body.grey-icons-enabled .subtasks-item .check-box-unicode {
|
||||
filter: grayscale(100%);
|
||||
@@ -167,7 +170,7 @@ body.grey-icons-enabled .subtasks-item .check-box-unicode {
|
||||
}
|
||||
.subtasks-item .item-title {
|
||||
flex: 1;
|
||||
padding-left: 10px;
|
||||
padding-inline-start: 10px;
|
||||
}
|
||||
.subtasks-item .item-title.is-checked {
|
||||
color: #8c8c8c;
|
||||
@@ -189,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")
|
||||
@@ -74,14 +78,13 @@ template(name="subtasksItems")
|
||||
template(name='subtaskItemDetail')
|
||||
.js-subtasks-item.subtasks-item
|
||||
if canModifyCard
|
||||
span.check-box-unicode
|
||||
i.fa(class="{{#if item.isFinished}}fa-check-square{{else}}fa-square-o{{/if}}")
|
||||
.check-box-container
|
||||
.check-box.materialCheckBox(class="{{#if item.isFinished}}is-checked{{/if}}")
|
||||
.item-title.js-open-inlined-form.is-editable(class="{{#if item.isFinished }}is-checked{{/if}}")
|
||||
+viewer
|
||||
= item.title
|
||||
else
|
||||
span.check-box-unicode
|
||||
i.fa(class="{{#if item.isFinished}}fa-check-square{{else}}fa-square-o{{/if}}")
|
||||
.materialCheckBox(class="{{#if item.isFinished}}is-checked{{/if}}")
|
||||
.item-title(class="{{#if item.isFinished }}is-checked{{/if}}")
|
||||
+viewer
|
||||
= item.title
|
||||
|
||||
@@ -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,15 +78,34 @@ 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-unicode'() {
|
||||
async 'click .js-subtasks-item .check-box-container'() {
|
||||
const item = Template.currentData().item;
|
||||
if (item && item._id) {
|
||||
await item.toggleItem();
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
@@ -13,7 +13,7 @@
|
||||
}
|
||||
|
||||
.original-position-loading i {
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
|
||||
.original-position-details {
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
.original-position-moved i {
|
||||
color: #f39c12;
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
|
||||
.original-position-unchanged {
|
||||
@@ -48,7 +48,7 @@
|
||||
|
||||
.original-position-unchanged i {
|
||||
color: #28a745;
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
|
||||
.original-position-text {
|
||||
|
||||
@@ -143,8 +143,8 @@ button {
|
||||
}
|
||||
input[type="submit"] .wide,
|
||||
button .wide {
|
||||
padding-left: 4vw;
|
||||
padding-right: 4vw;
|
||||
padding-inline-start: 4vw;
|
||||
padding-inline-end: 4vw;
|
||||
}
|
||||
input[type="submit"]:hover,
|
||||
button:hover,
|
||||
@@ -209,7 +209,7 @@ button.negate:active {
|
||||
}
|
||||
input[type="submit"] i.fa,
|
||||
button i.fa {
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
input[type="submit"].disabled,
|
||||
input[type="submit"]:disabled,
|
||||
@@ -248,7 +248,7 @@ input[type="hidden"] {
|
||||
}
|
||||
.radio-div input,
|
||||
.check-div input {
|
||||
left: -2.5vw;
|
||||
inset-inline-start: -2.5vw;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -289,7 +289,7 @@ textarea::-moz-placeholder {
|
||||
.add-controls button[type=submit],
|
||||
.edit-controls input[type=button],
|
||||
.add-controls input[type=button] {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
height: 4.5vh;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
@@ -301,7 +301,7 @@ textarea::-moz-placeholder {
|
||||
[type="checkbox"]:not(:checked),
|
||||
[type="checkbox"]:checked {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
inset-inline-start: -9999px;
|
||||
visibility: hidden;
|
||||
display: none;
|
||||
}
|
||||
@@ -318,14 +318,14 @@ textarea::-moz-placeholder {
|
||||
}
|
||||
.materialCheckBox.is-checked {
|
||||
top: -4px;
|
||||
left: -3px;
|
||||
inset-inline-start: -3px;
|
||||
width: 7px;
|
||||
height: 15px;
|
||||
margin-right: 6px;
|
||||
margin-inline-end: 6px;
|
||||
border-top: 2px solid transparent;
|
||||
border-left: 2px solid transparent;
|
||||
border-inline-start: 2px solid transparent;
|
||||
border-bottom: 2px solid #3cb500;
|
||||
border-right: 2px solid #3cb500;
|
||||
border-inline-end: 2px solid #3cb500;
|
||||
transform: rotate(40deg);
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
@@ -334,7 +334,7 @@ textarea::-moz-placeholder {
|
||||
/* Grey checkmarks when grey icons setting is enabled */
|
||||
body.grey-icons-enabled .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #7a7a7a;
|
||||
border-right: 2px solid #7a7a7a;
|
||||
border-inline-end: 2px solid #7a7a7a;
|
||||
}
|
||||
.button-link {
|
||||
background: #fff;
|
||||
@@ -369,12 +369,12 @@ body.grey-icons-enabled .materialCheckBox.is-checked {
|
||||
margin: 0;
|
||||
padding: 2px 4px;
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
inset-inline-end: 5px;
|
||||
top: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
.button-link.is-on {
|
||||
padding-right: 30px;
|
||||
padding-inline-end: 30px;
|
||||
max-width: 196px;
|
||||
}
|
||||
.button-link.is-on .on {
|
||||
@@ -383,11 +383,11 @@ body.grey-icons-enabled .materialCheckBox.is-checked {
|
||||
.button-link.inline {
|
||||
color: #666;
|
||||
padding: 2px 14px;
|
||||
margin-left: 4px;
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
.button-link.setting {
|
||||
height: 52px;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
position: relative;
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -440,7 +440,7 @@ body.grey-icons-enabled .materialCheckBox.is-checked {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
height: 50px;
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
@@ -501,7 +501,7 @@ body.grey-icons-enabled .materialCheckBox.is-checked {
|
||||
button.quiet-button,
|
||||
button.loud-text-button {
|
||||
background: none;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
line-height: normal;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
@@ -535,7 +535,7 @@ button.loud-text-button:hover {
|
||||
}
|
||||
.emphasis-button.w-img,
|
||||
.quiet-button.w-img {
|
||||
padding-left: 28px;
|
||||
padding-inline-start: 28px;
|
||||
}
|
||||
.emphasis-button:hover,
|
||||
.quiet-button:hover {
|
||||
@@ -582,13 +582,13 @@ button.loud-text-button:hover {
|
||||
background: #dcdcdc;
|
||||
}
|
||||
.big-link.options {
|
||||
padding-right: 41px;
|
||||
padding-inline-end: 41px;
|
||||
}
|
||||
.big-link .option {
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
inset-inline-end: 6px;
|
||||
top: 6px;
|
||||
}
|
||||
.big-link.none {
|
||||
@@ -599,7 +599,7 @@ button.loud-text-button:hover {
|
||||
background: transparent;
|
||||
}
|
||||
.big-link.avatar-changer {
|
||||
padding-right: 51px;
|
||||
padding-inline-end: 51px;
|
||||
}
|
||||
.big-link.avatar-changer .member {
|
||||
border: 1px solid #ccc;
|
||||
@@ -607,7 +607,7 @@ button.loud-text-button:hover {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
top: 0;
|
||||
}
|
||||
.big-link.avatar-changer .member .member-avatar {
|
||||
@@ -652,7 +652,7 @@ button.loud-text-button:hover {
|
||||
line-height: 34px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
width: 100%;
|
||||
z-index: 2;
|
||||
font-size: 23px;
|
||||
@@ -687,7 +687,7 @@ button.loud-text-button:hover {
|
||||
.dropdown-menu li a img {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.dropdown-menu li a .minicard-label {
|
||||
@@ -719,7 +719,7 @@ button.loud-text-button:hover {
|
||||
}
|
||||
.toggle-label:after {
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
inset-inline-start: -2px;
|
||||
top: -3px;
|
||||
display: block;
|
||||
width: 26px;
|
||||
@@ -737,7 +737,7 @@ button.loud-text-button:hover {
|
||||
background-color: #6fbeb5;
|
||||
}
|
||||
.toggle-switch:checked ~ .toggle-label:after {
|
||||
left: 20px;
|
||||
inset-inline-start: 20px;
|
||||
background-color: #179588;
|
||||
}
|
||||
.toggle-switch:checked:disabled ~ .toggle-label {
|
||||
|
||||
@@ -67,17 +67,17 @@
|
||||
|
||||
.gantt-container thead th,
|
||||
.gantt-container thead tr > td:first-child {
|
||||
border-right: 2px solid #666;
|
||||
border-inline-end: 2px solid #666;
|
||||
padding: 4px; /* half of 8px */
|
||||
width: 100px; /* half of 200px */
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
font-weight: bold;
|
||||
background-color: #e8e8e8;
|
||||
min-width: 100px; /* half of 200px */
|
||||
}
|
||||
|
||||
.gantt-container thead td {
|
||||
border-right: 1px solid #999;
|
||||
border-inline-end: 1px solid #999;
|
||||
padding: 2px 1px; /* half */
|
||||
text-align: center;
|
||||
background-color: #f5f5f5;
|
||||
@@ -104,7 +104,7 @@
|
||||
}
|
||||
|
||||
.gantt-container tbody td {
|
||||
border-right: 1px solid #ccc;
|
||||
border-inline-end: 1px solid #ccc;
|
||||
padding: 1px; /* half */
|
||||
text-align: center;
|
||||
min-width: 15px; /* half of 30px */
|
||||
@@ -121,12 +121,12 @@
|
||||
}
|
||||
|
||||
.gantt-container tbody td:first-child {
|
||||
border-right: 2px solid #666;
|
||||
border-inline-end: 2px solid #666;
|
||||
padding: 4px; /* half of 8px */
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background-color: #fafafa !important;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
width: 100px; /* half of 200px */
|
||||
min-width: 100px; /* half of 200px */
|
||||
white-space: nowrap;
|
||||
@@ -155,7 +155,7 @@
|
||||
|
||||
.gantt-container .ganttview-today {
|
||||
background-color: #fcf8e3;
|
||||
border-right: 2px solid #ffb347;
|
||||
border-inline-end: 2px solid #ffb347;
|
||||
}
|
||||
|
||||
/* Task bar styling - VERY VISIBLE */
|
||||
|
||||
Vendored
+116
@@ -49,3 +49,119 @@ a.show-mapping {
|
||||
font-size: 90%;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Trello API import: file rows, workspace tree, job progress and errors */
|
||||
/* The shared page spinners are centered with a large top margin (e.g. bounce
|
||||
uses margin:100px auto), which leaves a big empty gap above the spinner in
|
||||
this compact panel. Make spinners here small and inline instead. */
|
||||
.trello-api-import .sk-spinner {
|
||||
margin: 0 8px 0 0;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: auto;
|
||||
}
|
||||
.trello-api-import .import-file-row label {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
}
|
||||
.trello-api-import .import-file-row input,
|
||||
.trello-api-import .import-file-row select {
|
||||
width: 100%;
|
||||
}
|
||||
.trello-workspaces {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.trello-workspace {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.trello-workspace .trello-boards {
|
||||
margin-inline-start: 22px;
|
||||
}
|
||||
.trello-workspace-title,
|
||||
.trello-board {
|
||||
align-items: center;
|
||||
padding: 3px 0;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.trello-workspace-title .materialCheckBox,
|
||||
.trello-board .materialCheckBox {
|
||||
flex: 0 0 auto;
|
||||
margin-inline-end: 12px;
|
||||
}
|
||||
.trello-select-controls button {
|
||||
margin-inline-end: 6px;
|
||||
}
|
||||
.trello-job-controls button {
|
||||
margin-inline-end: 6px;
|
||||
}
|
||||
/* Import progress meter, styled like Admin Panel / Attachments / Move Attachments */
|
||||
.trello-progress-label {
|
||||
margin: 8px 0 6px;
|
||||
}
|
||||
.trello-progress-bar {
|
||||
background: #e9ecef;
|
||||
border-radius: 8px;
|
||||
height: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.trello-progress-fill {
|
||||
background: linear-gradient(90deg, #28a745, #20c997);
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
transition: width 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
.trello-progress-fill.is-running::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
|
||||
animation: trelloProgressShimmer 2s infinite;
|
||||
}
|
||||
@keyframes trelloProgressShimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
.trello-progress-percent {
|
||||
text-align: end;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.trello-import-result.is-ok {
|
||||
color: #3cb500;
|
||||
}
|
||||
.trello-import-result.is-error {
|
||||
color: #eb4646;
|
||||
}
|
||||
.js-trello-errors-text {
|
||||
width: 100%;
|
||||
font-family: monospace;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* Trello API credential Save/Delete buttons */
|
||||
.trello-api-creds-buttons button {
|
||||
margin-inline-end: 6px;
|
||||
}
|
||||
.trello-creds-saved {
|
||||
color: #3cb500;
|
||||
}
|
||||
|
||||
/* Simplified per-field hint text under the Trello file upload labels */
|
||||
.import-file-row .import-file-hint {
|
||||
display: block;
|
||||
font-size: 0.85em;
|
||||
margin: 2px 0 4px;
|
||||
}
|
||||
@@ -2,20 +2,136 @@ template(name="importHeaderBar")
|
||||
h1
|
||||
a.back-btn(href="{{pathFor 'home'}}")
|
||||
i.fa-arrow-left
|
||||
| {{_ title}}
|
||||
| {{title}}
|
||||
|
||||
template(name="import")
|
||||
.wrapper
|
||||
if error.get
|
||||
.warning {{_ error.get}}
|
||||
if zipImporting
|
||||
.warning {{_ 'import-trello-zip-progress'}}
|
||||
+Template.dynamic(template=currentTemplate)
|
||||
|
||||
template(name="importTextarea")
|
||||
form
|
||||
p: label(for='import-textarea') {{_ instruction}} {{_ 'import-board-instruction-about-errors'}}
|
||||
textarea.js-import-json(id='import-textarea' placeholder="{{_ importPlaceHolder}}" autofocus)
|
||||
| {{jsonText}}
|
||||
p: label(for='import-textarea') {{instruction}} {{_ 'import-board-instruction-about-errors'}}
|
||||
if isExcelImport
|
||||
p.import-file-row
|
||||
label(for='import-excel-file') {{_ 'import-excel-file'}}
|
||||
input.js-import-excel-file(id='import-excel-file' type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
else
|
||||
textarea.js-import-json(id='import-textarea' placeholder="{{_ importPlaceHolder}}" autofocus)
|
||||
| {{jsonText}}
|
||||
if isTrelloImport
|
||||
p.import-file-row
|
||||
label(for='import-json-file') {{_ 'import-trello-json-file'}}
|
||||
span.quiet.import-file-hint {{_ 'import-trello-json-file-hint'}}
|
||||
input.js-import-json-file(id='import-json-file' type="file" accept=".json,application/json")
|
||||
p.import-file-row
|
||||
label(for='import-zip-file') {{_ 'import-trello-zip-file'}}
|
||||
span.quiet.import-file-hint {{_ 'import-trello-zip-file-hint'}}
|
||||
input.js-import-zip-file(id='import-zip-file' type="file" accept=".zip,application/zip")
|
||||
p.import-file-row
|
||||
label(for='import-workspace-name') {{_ 'import-trello-workspace'}}
|
||||
input.js-import-workspace-name(id='import-workspace-name' type="text" placeholder="{{_ 'import-trello-workspace-placeholder'}}")
|
||||
input.primary.wide(type="submit" value="{{_ 'import'}}")
|
||||
button.wide.js-import-without-mapping(type="button") {{_ 'import-without-mapping-members'}}
|
||||
if isTrelloImport
|
||||
hr
|
||||
+importTrelloApi
|
||||
|
||||
template(name="importTrelloApi")
|
||||
.trello-api-import
|
||||
h2 {{_ 'trello-api-import'}}
|
||||
p.quiet {{_ 'trello-api-import-desc'}}
|
||||
p.import-file-row
|
||||
label(for='trello-api-key') {{_ 'trello-api-key'}}
|
||||
input.js-trello-key(id='trello-api-key' type="text" autocomplete="off")
|
||||
p.import-file-row
|
||||
label(for='trello-api-token') {{_ 'trello-api-token'}}
|
||||
input.js-trello-token(id='trello-api-token' type="password" autocomplete="off")
|
||||
p.trello-api-creds-buttons
|
||||
button.primary.js-trello-save-creds(type="button") {{_ 'save'}}
|
||||
button.negate.js-trello-delete-creds(type="button") {{_ 'delete'}}
|
||||
if credsSaved
|
||||
p.quiet.trello-creds-saved
|
||||
i.fa.fa-check
|
||||
span {{_ 'trello-api-credentials-saved'}}
|
||||
p
|
||||
button.primary.js-trello-list-workspaces(type="button") {{_ 'trello-list-workspaces'}}
|
||||
if error.get
|
||||
.warning {{_ error.get}}
|
||||
if loading.get
|
||||
+spinner
|
||||
if hasWorkspaces
|
||||
p.import-file-row
|
||||
label(for='trello-parent-workspace') {{_ 'import-trello-parent-workspace'}}
|
||||
select.js-trello-parent-workspace(id='trello-parent-workspace')
|
||||
option(value="") {{_ 'trello-parent-workspace-top'}}
|
||||
each flatWorkspaceNodes
|
||||
option(value=id)= label
|
||||
p.trello-select-controls
|
||||
button.primary.js-trello-select-all(type="button") {{_ 'select-all'}}
|
||||
button.primary.js-trello-unselect-all(type="button") {{_ 'unselect-all'}}
|
||||
.trello-workspaces
|
||||
each workspaceList
|
||||
.trello-workspace
|
||||
a.flex.trello-workspace-title.js-toggle-workspace
|
||||
.materialCheckBox(class="{{#if workspaceSelected}}is-checked{{/if}}")
|
||||
b {{name}}
|
||||
.trello-boards
|
||||
each boards
|
||||
a.flex.trello-board.js-toggle-board(data-board-id="{{id}}")
|
||||
.materialCheckBox(class="{{#if boardSelected}}is-checked{{/if}}")
|
||||
span {{name}}
|
||||
if closed
|
||||
span.quiet ({{_ 'archived'}})
|
||||
if canStartImport
|
||||
p
|
||||
button.primary.wide.js-trello-import-selected(type="button") {{_ 'trello-import-selected'}}
|
||||
if currentJob
|
||||
hr
|
||||
.trello-import-job
|
||||
h3 {{_ 'trello-import-progress'}}
|
||||
with currentJob
|
||||
p.trello-progress-label
|
||||
b {{_ 'status'}}:
|
||||
span {{_ status}} ({{jobProgressText}})
|
||||
.trello-progress-bar
|
||||
.trello-progress-fill(class="{{#if $eq status 'running'}}is-running{{/if}}" style="width: {{jobProgressPercent}}%")
|
||||
.trello-progress-percent {{jobProgressPercent}}%
|
||||
if lastError
|
||||
.warning {{lastError}}
|
||||
p.trello-job-controls
|
||||
if jobIsRunning
|
||||
button.primary.js-trello-cancel(type="button") {{_ 'trello-cancel'}}
|
||||
button.negate.js-trello-cancel-delete(type="button") {{_ 'trello-cancel-delete'}}
|
||||
if jobCanResume
|
||||
button.primary.js-trello-resume(type="button") {{_ 'trello-resume'}}
|
||||
button.negate.js-trello-cancel-delete(type="button") {{_ 'trello-cancel-delete'}}
|
||||
if jobIsFinished
|
||||
button.negate.js-trello-cancel-delete(type="button") {{_ 'trello-delete-imported'}}
|
||||
button.primary.js-trello-clear(type="button") {{_ 'trello-clear-job'}}
|
||||
|
||||
if jobResults.length
|
||||
.trello-import-results
|
||||
each jobResults
|
||||
p.trello-import-result(class="{{#if success}}is-ok{{else}}is-error{{/if}}")
|
||||
if success
|
||||
i.fa.fa-check
|
||||
span {{title}} — {{attachmentsImported}} {{_ 'attachments'}}
|
||||
else
|
||||
i.fa.fa-warning
|
||||
span {{trelloBoardId}}: {{this.error}}
|
||||
|
||||
if jobHasErrors
|
||||
.trello-import-errors
|
||||
p
|
||||
b {{_ 'trello-import-errors'}}
|
||||
button.primary.js-trello-copy-errors(type="button") {{_ 'copy-to-clipboard'}}
|
||||
if copied.get
|
||||
span.quiet {{_ 'copied'}}
|
||||
textarea.js-trello-errors-text(readonly rows="8" wrap="off")= jobErrorText
|
||||
|
||||
template(name="importMapMembers")
|
||||
h2 {{_ 'import-map-members'}}
|
||||
@@ -48,6 +164,7 @@ template(name="importMapMembers")
|
||||
.mapping-item.ghost-item
|
||||
form
|
||||
input.primary.wide(type="submit" value="{{_ 'done'}}")
|
||||
button.wide.js-import-skip-mapping(type="button") {{_ 'import-without-mapping-members'}}
|
||||
else
|
||||
+spinner
|
||||
|
||||
|
||||
@@ -3,15 +3,36 @@ import { trelloGetMembersToMap } from './trelloMembersMapper';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import { wekanGetMembersToMap } from './wekanMembersMapper';
|
||||
import { csvGetMembersToMap } from './csvMembersMapper';
|
||||
import { jiraGetMembersToMap } from './jiraMembersMapper';
|
||||
import { kanboardGetMembersToMap } from './kanboardMembersMapper';
|
||||
import getSlug from 'limax';
|
||||
import { UserSearchIndex } from '/models/users';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import TrelloImportJobs from '/models/trelloImportJobs';
|
||||
|
||||
const Papa = require('papaparse');
|
||||
|
||||
Template.importHeaderBar.helpers({
|
||||
title() {
|
||||
return `import-board-title-${Session.get('importSource')}`;
|
||||
const sourceNameByKey = {
|
||||
trello: 'Trello',
|
||||
wekan: 'JSON',
|
||||
csv: 'CSV-TSV',
|
||||
excel: 'Excel',
|
||||
jira: 'Jira',
|
||||
kanboard: 'Kanboard',
|
||||
deck: 'NextCloud Deck',
|
||||
openproject: 'OpenProject',
|
||||
github: 'GitHub',
|
||||
gitlab: 'GitLab',
|
||||
gitea: 'Gitea',
|
||||
forgejo: 'Forgejo',
|
||||
asana: 'Asana',
|
||||
zenkit: 'Zenkit',
|
||||
};
|
||||
const sourceName = sourceNameByKey[Session.get('importSource')] || 'JSON';
|
||||
return `${TAPi18n.__('import')} / ${sourceName}`;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -40,10 +61,116 @@ function _prepareAdditionalData(dataObject) {
|
||||
case 'csv':
|
||||
membersToMap = csvGetMembersToMap(dataObject);
|
||||
break;
|
||||
case 'jira':
|
||||
membersToMap = jiraGetMembersToMap(dataObject);
|
||||
break;
|
||||
case 'kanboard':
|
||||
membersToMap = kanboardGetMembersToMap(dataObject);
|
||||
break;
|
||||
default:
|
||||
// NextCloud Deck / OpenProject / GitHub / GitLab / Gitea / Forgejo:
|
||||
// these are imported without member mapping (members can be mapped later).
|
||||
membersToMap = [];
|
||||
break;
|
||||
}
|
||||
return membersToMap;
|
||||
}
|
||||
|
||||
// All Trello file/text imports are run on the server over HTTP instead of the
|
||||
// DDP `importBoard` method. This matters for correctness, not just size: Meteor
|
||||
// automatically re-sends an unacknowledged method call on every reconnect, so if
|
||||
// an import is heavy (or the connection hiccups) the WebSocket can enter an
|
||||
// endless drop/retry loop (the "Invalid frame header" flicker). An HTTP request
|
||||
// is never auto-retried, and a .zip's attachment bytes never touch the realtime
|
||||
// connection at all. The body is either a .zip File (application/zip) or a JSON
|
||||
// string { board, membersMapping } (application/json).
|
||||
async function postTrelloImport(body, contentType) {
|
||||
const token =
|
||||
(window.localStorage && window.localStorage.getItem('Meteor.loginToken')) || '';
|
||||
const resp = await fetch('/import-trello', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body,
|
||||
});
|
||||
let result = {};
|
||||
try {
|
||||
result = await resp.json();
|
||||
} catch (e) {
|
||||
result = {};
|
||||
}
|
||||
if (!resp.ok || result.error) {
|
||||
throw new Error(result.error || 'import-trello-failed');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// A safe URL slug for an imported board. Trello exports name the board `name`,
|
||||
// WeKan exports use `title`; getSlug (limax) throws on undefined, so guard it.
|
||||
function boardSlug(data) {
|
||||
const raw = (data && (data.title || data.name)) || '';
|
||||
return (raw && getSlug(raw)) || 'imported-board';
|
||||
}
|
||||
|
||||
// A WeKan export produced by a buggy older version (the `meta.boardId` exporter
|
||||
// regression) contains empty swimlanes/lists/cards arrays. Importing it can only
|
||||
// produce an empty board with a single Default swimlane, so detect that case and
|
||||
// warn instead of silently creating an empty board.
|
||||
function wekanExportIsEmpty(board) {
|
||||
const count = key => (Array.isArray(board && board[key]) ? board[key].length : 0);
|
||||
return count('swimlanes') === 0 && count('lists') === 0 && count('cards') === 0;
|
||||
}
|
||||
|
||||
// Navigate to a freshly server-imported board. The HTTP import (unlike a DDP
|
||||
// method) does not push the new board's documents to this client, so we first
|
||||
// subscribe to the board and wait until its lists/swimlanes/cards are loaded
|
||||
// into Minimongo — otherwise the board opens with an empty Swimlanes view until
|
||||
// the page is reloaded. The subscription is left running so the data stays
|
||||
// available when the board is reopened from All Boards in the same session. A
|
||||
// timeout is the safety net in case the subscription never signals ready.
|
||||
function goToImportedBoard(boardId, slug) {
|
||||
let navigated = false;
|
||||
const go = () => {
|
||||
if (navigated) return;
|
||||
navigated = true;
|
||||
FlowRouter.go('board', { id: boardId, slug });
|
||||
};
|
||||
Meteor.subscribe('board', boardId, false, { onReady: go });
|
||||
Meteor.setTimeout(go, 5000);
|
||||
}
|
||||
|
||||
// Find a workspace node by name anywhere in the user's personal workspace tree.
|
||||
function findWorkspaceByName(nodes, name) {
|
||||
for (const node of nodes || []) {
|
||||
if (node.name === name) return node;
|
||||
if (node.children) {
|
||||
const found = findWorkspaceByName(node.children, name);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Assign an imported board to a personal workspace named `wsName`, creating the
|
||||
// workspace (under an optional parent) only if one with that name doesn't exist.
|
||||
function assignBoardToNamedWorkspace(boardId, wsName, parentId = null) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const tree = (user && user.profile && user.profile.boardWorkspacesTree) || [];
|
||||
const existing = findWorkspaceByName(tree, wsName);
|
||||
if (existing) {
|
||||
Meteor.call('assignBoardToWorkspace', boardId, existing.id);
|
||||
return;
|
||||
}
|
||||
Meteor.call('createWorkspace', { parentId, name: wsName }, (err, node) => {
|
||||
if (!err && node) {
|
||||
Meteor.call('assignBoardToWorkspace', boardId, node.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Template.import.onCreated(function () {
|
||||
this.error = new ReactiveVar('');
|
||||
this.steps = ['importTextarea', 'importMapMembers'];
|
||||
@@ -51,6 +178,8 @@ Template.import.onCreated(function () {
|
||||
this.importedData = new ReactiveVar();
|
||||
this.membersToMap = new ReactiveVar([]);
|
||||
this.importSource = Session.get('importSource');
|
||||
// True while a Trello .zip package is being uploaded/imported server-side.
|
||||
this.zipImporting = new ReactiveVar(false);
|
||||
|
||||
this.nextStep = () => {
|
||||
const nextStepIndex = this._currentStepIndex.get() + 1;
|
||||
@@ -65,63 +194,179 @@ Template.import.onCreated(function () {
|
||||
this.error.set(error);
|
||||
};
|
||||
|
||||
this.importData = (evt, dataSource) => {
|
||||
// When skipMapping is true, the "map members" step is bypassed and the board
|
||||
// is imported immediately with whatever (possibly empty) mapping exists, so
|
||||
// members can be mapped later. This works for wekan, trello, csv and jira.
|
||||
this.importData = async (evt, dataSource, skipMapping = false) => {
|
||||
evt.preventDefault();
|
||||
const input = this.find('.js-import-json').value;
|
||||
const advance = async () => {
|
||||
if (skipMapping) {
|
||||
await this.finishImport();
|
||||
} else {
|
||||
this.nextStep();
|
||||
}
|
||||
};
|
||||
// Excel (.xlsx): the file is parsed on the server (with exceljs) into rows
|
||||
// and imported through the CSV creator. Member mapping is skipped (members
|
||||
// can be mapped later), so we read the file to base64 and import directly.
|
||||
if (dataSource === 'excel') {
|
||||
const el = this.find('.js-import-excel-file');
|
||||
if (!el || !el.files || !el.files[0]) {
|
||||
this.setError('error-json-malformed');
|
||||
return;
|
||||
}
|
||||
const buf = await el.files[0].arrayBuffer();
|
||||
const bytes = new Uint8Array(buf);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
||||
this.importedData.set({ excelBase64: window.btoa(binary) });
|
||||
this.membersToMap.set([]);
|
||||
await this.finishImport();
|
||||
return;
|
||||
}
|
||||
if (dataSource === 'csv') {
|
||||
const input = this.find('.js-import-json').value;
|
||||
const csv = input.indexOf('\t') > 0 ? input.replace(/(\t)/g, ',') : input;
|
||||
const ret = Papa.parse(csv);
|
||||
if (ret && ret.data && ret.data.length) this.importedData.set(ret.data);
|
||||
else throw new Meteor.Error('error-csv-schema');
|
||||
const membersToMap = _prepareAdditionalData(ret.data);
|
||||
this.membersToMap.set(membersToMap);
|
||||
this.nextStep();
|
||||
} else {
|
||||
try {
|
||||
const dataObject = JSON.parse(input);
|
||||
this.setError('');
|
||||
this.importedData.set(dataObject);
|
||||
const membersToMap = _prepareAdditionalData(dataObject);
|
||||
// store members data and mapping in Session
|
||||
// (we go deep and 2-way, so storing in data context is not a viable option)
|
||||
this.membersToMap.set(membersToMap);
|
||||
this.nextStep();
|
||||
} catch (e) {
|
||||
this.setError('error-json-malformed');
|
||||
await advance();
|
||||
return;
|
||||
}
|
||||
// Trello: a .zip package (one or more board .json files plus per-board
|
||||
// attachment subdirectories from the Trello Attachments Downloader) imports
|
||||
// all of its boards at once, through a separate field from the single .json.
|
||||
if (dataSource === 'trello') {
|
||||
const zipEl = this.find('.js-import-zip-file');
|
||||
if (zipEl && zipEl.files && zipEl.files[0]) {
|
||||
await this.importTrelloZip(zipEl.files[0]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
// A single board: JSON may come from an uploaded .json file (large Trello
|
||||
// exports are awkward to paste) or from the textarea.
|
||||
let input = this.find('.js-import-json').value;
|
||||
const jsonFileEl = this.find('.js-import-json-file');
|
||||
if (jsonFileEl && jsonFileEl.files && jsonFileEl.files[0]) {
|
||||
input = await jsonFileEl.files[0].text();
|
||||
}
|
||||
const dataObject = JSON.parse(input);
|
||||
// Guard against importing a broken/old WeKan export that has no board
|
||||
// content (see wekanExportIsEmpty): warn the user to re-export rather than
|
||||
// silently creating an empty board with only a Default swimlane.
|
||||
if (this.importSource === 'wekan' && wekanExportIsEmpty(dataObject)) {
|
||||
this.setError('error-import-empty-board');
|
||||
return;
|
||||
}
|
||||
this.setError('');
|
||||
|
||||
// Trello: remember the target personal-workspace name for finishImport.
|
||||
this.workspaceName = '';
|
||||
if (dataSource === 'trello') {
|
||||
const wsEl = this.find('.js-import-workspace-name');
|
||||
this.workspaceName = wsEl && wsEl.value ? wsEl.value.trim() : '';
|
||||
}
|
||||
|
||||
this.importedData.set(dataObject);
|
||||
const membersToMap = _prepareAdditionalData(dataObject);
|
||||
// store members data and mapping in Session
|
||||
// (we go deep and 2-way, so storing in data context is not a viable option)
|
||||
this.membersToMap.set(membersToMap);
|
||||
await advance();
|
||||
} catch (e) {
|
||||
this.setError('error-json-malformed');
|
||||
}
|
||||
};
|
||||
|
||||
this.finishImport = () => {
|
||||
const additionalData = {};
|
||||
// Upload a Trello .zip package to the server, which extracts it (with
|
||||
// zip-bomb / path-traversal guards), imports every board and streams each
|
||||
// attachment to the Default storage, then go to All Boards.
|
||||
this.importTrelloZip = async (zipFile) => {
|
||||
this.setError('');
|
||||
const wsEl = this.find('.js-import-workspace-name');
|
||||
const workspaceName = wsEl && wsEl.value ? wsEl.value.trim() : '';
|
||||
|
||||
this.zipImporting.set(true);
|
||||
let result;
|
||||
try {
|
||||
result = await postTrelloImport(zipFile, 'application/zip');
|
||||
} catch (e) {
|
||||
this.zipImporting.set(false);
|
||||
this.setError((e && e.message) || 'import-trello-failed');
|
||||
return;
|
||||
}
|
||||
this.zipImporting.set(false);
|
||||
|
||||
(result.boardIds || []).forEach(boardId => {
|
||||
if (workspaceName) {
|
||||
assignBoardToNamedWorkspace(boardId, workspaceName);
|
||||
}
|
||||
});
|
||||
Session.set('fromBoard', null);
|
||||
// Go to All Boards, where the newly imported boards appear.
|
||||
FlowRouter.go('home');
|
||||
};
|
||||
|
||||
this.finishImport = async () => {
|
||||
const membersMapping = this.membersToMap.get();
|
||||
const mappingById = {};
|
||||
if (membersMapping) {
|
||||
const mappingById = {};
|
||||
membersMapping.forEach(member => {
|
||||
if (member.wekanId) {
|
||||
mappingById[member.id] = member.wekanId;
|
||||
}
|
||||
});
|
||||
additionalData.membersMapping = mappingById;
|
||||
}
|
||||
const importedData = this.importedData.get();
|
||||
|
||||
// Trello: import over HTTP (see postTrelloImport) so the realtime DDP
|
||||
// connection is never used for the board payload and can't enter the
|
||||
// drop/retry "Invalid frame header" flicker loop. Do NOT mutate the still-
|
||||
// mounted map-members template (e.g. clearing membersToMap) before
|
||||
// navigating away — that forces an empty re-render mid-teardown and can
|
||||
// throw "Can't select in removed DomRange". We navigate away, which
|
||||
// destroys the import templates.
|
||||
if (this.importSource === 'trello') {
|
||||
let result;
|
||||
try {
|
||||
result = await postTrelloImport(
|
||||
JSON.stringify({ board: importedData, membersMapping: mappingById }),
|
||||
'application/json',
|
||||
);
|
||||
} catch (e) {
|
||||
this.setError((e && e.message) || 'import-trello-failed');
|
||||
return;
|
||||
}
|
||||
const boardId = (result.boardIds || [])[0];
|
||||
if (!boardId) {
|
||||
this.setError('import-trello-failed');
|
||||
return;
|
||||
}
|
||||
Session.set('fromBoard', null);
|
||||
if (this.workspaceName) {
|
||||
assignBoardToNamedWorkspace(boardId, this.workspaceName);
|
||||
}
|
||||
goToImportedBoard(boardId, boardSlug(importedData));
|
||||
return;
|
||||
}
|
||||
|
||||
// wekan / csv: unchanged DDP import.
|
||||
this.membersToMap.set([]);
|
||||
Meteor.call(
|
||||
'importBoard',
|
||||
this.importedData.get(),
|
||||
additionalData,
|
||||
importedData,
|
||||
{ membersMapping: mappingById },
|
||||
this.importSource,
|
||||
Session.get('fromBoard'),
|
||||
(err, res) => {
|
||||
if (err) {
|
||||
this.setError(err.error);
|
||||
} else {
|
||||
let title = getSlug(this.importedData.get().title) || 'imported-board';
|
||||
Session.set('fromBoard', null);
|
||||
FlowRouter.go('board', {
|
||||
id: res,
|
||||
slug: title,
|
||||
});
|
||||
//Utils.goBoardId(res);
|
||||
goToImportedBoard(res, boardSlug(importedData));
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -135,11 +380,42 @@ Template.import.helpers({
|
||||
currentTemplate() {
|
||||
return Template.instance().steps[Template.instance()._currentStepIndex.get()];
|
||||
},
|
||||
zipImporting() {
|
||||
return Template.instance().zipImporting.get();
|
||||
},
|
||||
});
|
||||
|
||||
Template.importTextarea.helpers({
|
||||
instruction() {
|
||||
return `import-board-instruction-${Session.get('importSource')}`;
|
||||
const importSource = Session.get('importSource');
|
||||
const issueSourceConfig = {
|
||||
github: {
|
||||
sourceName: 'GitHub',
|
||||
endpoint: 'GET /repos/OWNER/REPO/issues',
|
||||
},
|
||||
gitlab: {
|
||||
sourceName: 'GitLab',
|
||||
endpoint: 'GET /projects/ID/issues',
|
||||
},
|
||||
gitea: {
|
||||
sourceName: 'Gitea',
|
||||
endpoint: 'GET /repos/OWNER/REPO/issues',
|
||||
},
|
||||
forgejo: {
|
||||
sourceName: 'Forgejo',
|
||||
endpoint: 'GET /repos/OWNER/REPO/issues',
|
||||
},
|
||||
};
|
||||
|
||||
if (issueSourceConfig[importSource]) {
|
||||
const { sourceName, endpoint } = issueSourceConfig[importSource];
|
||||
return TAPi18n.__('import-board-instruction-issues', {
|
||||
sourceName,
|
||||
endpoint,
|
||||
});
|
||||
}
|
||||
|
||||
return TAPi18n.__(`import-board-instruction-${importSource}`);
|
||||
},
|
||||
importPlaceHolder() {
|
||||
const importSource = Session.get('importSource');
|
||||
@@ -149,6 +425,12 @@ Template.importTextarea.helpers({
|
||||
return 'import-json-placeholder';
|
||||
}
|
||||
},
|
||||
isTrelloImport() {
|
||||
return Session.get('importSource') === 'trello';
|
||||
},
|
||||
isExcelImport() {
|
||||
return Session.get('importSource') === 'excel';
|
||||
},
|
||||
});
|
||||
|
||||
Template.importTextarea.events({
|
||||
@@ -158,6 +440,14 @@ Template.importTextarea.events({
|
||||
return importTpl.importData(evt, Session.get('importSource'));
|
||||
}
|
||||
},
|
||||
// Import immediately, skipping the "map members" step (members can be mapped
|
||||
// later). Works for wekan, trello and jira (and csv).
|
||||
'click .js-import-without-mapping'(evt, tpl) {
|
||||
const importTpl = findParentTemplateInstance(tpl, 'import');
|
||||
if (importTpl) {
|
||||
return importTpl.importData(evt, Session.get('importSource'), true);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Module-level reference so popup children can access importMapMembers methods
|
||||
@@ -288,6 +578,15 @@ Template.importMapMembers.events({
|
||||
importTpl.nextStep();
|
||||
}
|
||||
},
|
||||
// Import now without finishing member mapping; only members already mapped
|
||||
// (if any) are applied, the rest can be mapped later.
|
||||
'click .js-import-skip-mapping'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const importTpl = findParentTemplateInstance(tpl, 'import');
|
||||
if (importTpl) {
|
||||
importTpl.finishImport();
|
||||
}
|
||||
},
|
||||
'click .js-select-member'(evt, tpl) {
|
||||
const memberToMap = Template.currentData();
|
||||
if (memberToMap.wekan) {
|
||||
@@ -320,7 +619,11 @@ Template.importMapMembersAddPopup.onCreated(function () {
|
||||
});
|
||||
|
||||
Template.importMapMembersAddPopup.onRendered(function () {
|
||||
this.find('.js-search-member-input').focus();
|
||||
// Guard against the DOM range being gone (e.g. the popup was closed during a
|
||||
// re-render) — calling find/$ then throws "Can't select in removed DomRange".
|
||||
if (this.view && this.view.isDestroyed) return;
|
||||
const input = this.find('.js-search-member-input');
|
||||
if (input) input.focus();
|
||||
});
|
||||
|
||||
Template.importMapMembersAddPopup.onDestroyed(function () {
|
||||
@@ -380,3 +683,310 @@ Template.importMapMembersAddPopup.helpers({
|
||||
return importMemberPopupState.noResults;
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live Trello API import: key/token -> list workspaces & boards -> import
|
||||
// selected boards (with attachments) server-side, placing each under a
|
||||
// personal workspace named after its Trello workspace.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function flattenWorkspaceTree(nodes, depth = 0, acc = []) {
|
||||
(nodes || []).forEach(node => {
|
||||
acc.push({ id: node.id, label: `${'— '.repeat(depth)}${node.name}` });
|
||||
if (node.children && node.children.length) {
|
||||
flattenWorkspaceTree(node.children, depth + 1, acc);
|
||||
}
|
||||
});
|
||||
return acc;
|
||||
}
|
||||
|
||||
// Build the copy-paste-friendly error text for a job: the error log plus a
|
||||
// summary line per failed board.
|
||||
function jobErrorText(job) {
|
||||
if (!job) return '';
|
||||
const lines = [];
|
||||
(job.errorLog || []).forEach(line => lines.push(line));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
Template.importTrelloApi.onCreated(function () {
|
||||
this.error = new ReactiveVar('');
|
||||
this.loading = new ReactiveVar(false);
|
||||
this.workspaces = new ReactiveVar([]);
|
||||
this.copied = new ReactiveVar(false);
|
||||
// Board selection state: map of trelloBoardId -> true. Tracked here (rather
|
||||
// than via DOM checkboxes) because the UI uses animated .materialCheckBox
|
||||
// elements, not native inputs.
|
||||
this.selectedBoards = new ReactiveVar({});
|
||||
// The import itself runs server-side as a persisted job; watch it reactively
|
||||
// so progress survives navigating away and back.
|
||||
this.subscribe('trelloImportJobs');
|
||||
});
|
||||
|
||||
// Collect every board id across all listed workspaces.
|
||||
function allBoardIds(workspaces) {
|
||||
const ids = {};
|
||||
(workspaces || []).forEach(ws => {
|
||||
(ws.boards || []).forEach(b => {
|
||||
ids[b.id] = true;
|
||||
});
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
Template.importTrelloApi.helpers({
|
||||
error() {
|
||||
return Template.instance().error;
|
||||
},
|
||||
loading() {
|
||||
return Template.instance().loading;
|
||||
},
|
||||
copied() {
|
||||
return Template.instance().copied;
|
||||
},
|
||||
hasWorkspaces() {
|
||||
return Template.instance().workspaces.get().length > 0;
|
||||
},
|
||||
workspaceList() {
|
||||
return Template.instance().workspaces.get();
|
||||
},
|
||||
boardSelected() {
|
||||
return !!Template.instance().selectedBoards.get()[this.id];
|
||||
},
|
||||
workspaceSelected() {
|
||||
const sel = Template.instance().selectedBoards.get();
|
||||
const boards = this.boards || [];
|
||||
return boards.length > 0 && boards.every(b => sel[b.id]);
|
||||
},
|
||||
flatWorkspaceNodes() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const tree = (user && user.profile && user.profile.boardWorkspacesTree) || [];
|
||||
return flattenWorkspaceTree(tree);
|
||||
},
|
||||
credsSaved() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
return !!(user && user.profile && user.profile.trelloApiSaved);
|
||||
},
|
||||
|
||||
// --- current background job ---
|
||||
currentJob() {
|
||||
return TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
},
|
||||
jobIsRunning() {
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
return job && job.status === 'running';
|
||||
},
|
||||
jobCanResume() {
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
return job && (job.status === 'paused' || job.status === 'error');
|
||||
},
|
||||
jobIsFinished() {
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
return job && (job.status === 'done' || job.status === 'cancelled');
|
||||
},
|
||||
canStartImport() {
|
||||
// Don't start a second import while one is active (running/paused/error),
|
||||
// which would create a hidden concurrent job.
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
return !job || job.status === 'done' || job.status === 'cancelled';
|
||||
},
|
||||
jobProgressText() {
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
if (!job) return '';
|
||||
return `${job.currentIndex} / ${job.total}`;
|
||||
},
|
||||
jobProgressPercent() {
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
if (!job || !job.total) return 0;
|
||||
return Math.round((job.currentIndex / job.total) * 100);
|
||||
},
|
||||
jobResults() {
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
return (job && job.results) || [];
|
||||
},
|
||||
jobHasErrors() {
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
return !!(job && job.errorLog && job.errorLog.length);
|
||||
},
|
||||
jobErrorText() {
|
||||
return jobErrorText(TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } }));
|
||||
},
|
||||
});
|
||||
|
||||
Template.importTrelloApi.events({
|
||||
'click .js-trello-save-creds'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const key = tpl.find('.js-trello-key').value.trim();
|
||||
const token = tpl.find('.js-trello-token').value.trim();
|
||||
if (!key || !token) {
|
||||
tpl.error.set('trello-api-credentials-required');
|
||||
return;
|
||||
}
|
||||
tpl.error.set('');
|
||||
Meteor.call('saveTrelloCredentials', key, token, err => {
|
||||
if (err) {
|
||||
tpl.error.set(err.reason || err.error || 'trello-api-error');
|
||||
return;
|
||||
}
|
||||
// Don't keep the token sitting in the browser; it now lives server-side.
|
||||
tpl.find('.js-trello-key').value = '';
|
||||
tpl.find('.js-trello-token').value = '';
|
||||
});
|
||||
},
|
||||
'click .js-trello-delete-creds'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.error.set('');
|
||||
Meteor.call('deleteTrelloCredentials', err => {
|
||||
if (err) tpl.error.set(err.reason || err.error || 'trello-api-error');
|
||||
});
|
||||
tpl.find('.js-trello-key').value = '';
|
||||
tpl.find('.js-trello-token').value = '';
|
||||
},
|
||||
'click .js-trello-list-workspaces'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
// key/token may be empty when saved credentials exist; the server falls
|
||||
// back to the saved ones and returns an error if neither is available.
|
||||
const key = tpl.find('.js-trello-key').value.trim();
|
||||
const token = tpl.find('.js-trello-token').value.trim();
|
||||
tpl.error.set('');
|
||||
tpl.loading.set(true);
|
||||
Meteor.call('trelloListWorkspaces', key, token, (err, res) => {
|
||||
tpl.loading.set(false);
|
||||
if (err) {
|
||||
tpl.error.set(err.reason || err.error || 'trello-api-error');
|
||||
tpl.workspaces.set([]);
|
||||
tpl.selectedBoards.set({});
|
||||
} else {
|
||||
const workspaces = res || [];
|
||||
tpl.workspaces.set(workspaces);
|
||||
// Preselect all boards by default.
|
||||
tpl.selectedBoards.set(allBoardIds(workspaces));
|
||||
}
|
||||
});
|
||||
},
|
||||
// Toggle a single board's animated checkbox.
|
||||
'click .js-toggle-board'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const id = this.id;
|
||||
const sel = { ...tpl.selectedBoards.get() };
|
||||
if (sel[id]) {
|
||||
delete sel[id];
|
||||
} else {
|
||||
sel[id] = true;
|
||||
}
|
||||
tpl.selectedBoards.set(sel);
|
||||
},
|
||||
// Toggle all boards in a workspace: if all are selected, clear them; else
|
||||
// select them all.
|
||||
'click .js-toggle-workspace'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const boards = this.boards || [];
|
||||
const sel = { ...tpl.selectedBoards.get() };
|
||||
const allSelected = boards.length > 0 && boards.every(b => sel[b.id]);
|
||||
boards.forEach(b => {
|
||||
if (allSelected) {
|
||||
delete sel[b.id];
|
||||
} else {
|
||||
sel[b.id] = true;
|
||||
}
|
||||
});
|
||||
tpl.selectedBoards.set(sel);
|
||||
},
|
||||
'click .js-trello-select-all'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.selectedBoards.set(allBoardIds(tpl.workspaces.get()));
|
||||
},
|
||||
'click .js-trello-unselect-all'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.selectedBoards.set({});
|
||||
},
|
||||
'click .js-trello-import-selected'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const key = tpl.find('.js-trello-key').value.trim();
|
||||
const token = tpl.find('.js-trello-token').value.trim();
|
||||
const sel = tpl.selectedBoards.get();
|
||||
const boardIds = Object.keys(sel).filter(id => sel[id]);
|
||||
if (!boardIds.length) {
|
||||
tpl.error.set('trello-select-boards');
|
||||
return;
|
||||
}
|
||||
const parentEl = tpl.find('.js-trello-parent-workspace');
|
||||
const parentId = parentEl && parentEl.value ? parentEl.value : null;
|
||||
|
||||
tpl.error.set('');
|
||||
// Start the server-side job; progress shows up via the subscription. The
|
||||
// user is free to navigate away and come back.
|
||||
Meteor.call('trelloStartImport', key, token, boardIds, parentId, err => {
|
||||
if (err) {
|
||||
tpl.error.set(err.reason || err.error || 'trello-api-error');
|
||||
}
|
||||
});
|
||||
},
|
||||
'click .js-trello-resume'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
if (!job) return;
|
||||
const key = tpl.find('.js-trello-key').value.trim();
|
||||
const token = tpl.find('.js-trello-token').value.trim();
|
||||
if (!key || !token) {
|
||||
tpl.error.set('trello-api-credentials-required');
|
||||
return;
|
||||
}
|
||||
tpl.error.set('');
|
||||
Meteor.call('trelloResumeImport', job._id, key, token, err => {
|
||||
if (err) tpl.error.set(err.reason || err.error || 'trello-api-error');
|
||||
});
|
||||
},
|
||||
'click .js-trello-cancel'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
if (!job) return;
|
||||
Meteor.call('trelloCancelImport', job._id, false);
|
||||
},
|
||||
'click .js-trello-cancel-delete'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
if (!job) return;
|
||||
// eslint-disable-next-line no-alert
|
||||
if (!window.confirm(TAPi18n.__('trello-cancel-delete-confirm'))) return;
|
||||
Meteor.call('trelloCancelImport', job._id, true);
|
||||
},
|
||||
'click .js-trello-clear'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
if (!job) return;
|
||||
Meteor.call('trelloClearImportJob', job._id, false);
|
||||
},
|
||||
'click .js-trello-copy-errors'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
const text = jobErrorText(job);
|
||||
if (!text) return;
|
||||
const done = () => {
|
||||
tpl.copied.set(true);
|
||||
setTimeout(() => tpl.copied.set(false), 2000);
|
||||
};
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(done, () => {
|
||||
// Fall back to selecting the textarea so the user can copy manually.
|
||||
const ta = tpl.find('.js-trello-errors-text');
|
||||
if (ta) {
|
||||
ta.focus();
|
||||
ta.select();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const ta = tpl.find('.js-trello-errors-text');
|
||||
if (ta) {
|
||||
ta.focus();
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
done();
|
||||
} catch (e) {
|
||||
// user can copy manually from the selected textarea
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
// Extract the distinct assignees from a Jira export so the import "map members"
|
||||
// step can map each Jira user to a WeKan user.
|
||||
export function jiraGetMembersToMap(data) {
|
||||
const issues = Array.isArray(data) ? data : data.issues || [];
|
||||
const byId = {};
|
||||
issues.forEach(issue => {
|
||||
const assignee = issue.fields && issue.fields.assignee;
|
||||
if (!assignee) return;
|
||||
const id = assignee.accountId || assignee.name || assignee.emailAddress;
|
||||
if (!id || byId[id]) return;
|
||||
byId[id] = {
|
||||
id,
|
||||
username: assignee.name || assignee.displayName || id,
|
||||
fullName: assignee.displayName || assignee.name || id,
|
||||
wekanId: null,
|
||||
};
|
||||
});
|
||||
return Object.values(byId);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Extract the distinct task owners from a Kanboard export so the import
|
||||
// "map members" step can map each Kanboard user to a WeKan user.
|
||||
export function kanboardGetMembersToMap(data) {
|
||||
const tasks = Array.isArray(data) ? data : data.tasks || [];
|
||||
const byId = {};
|
||||
tasks.forEach(task => {
|
||||
const id = task.owner_id || task.owner_username || task.owner_name;
|
||||
if (!id || byId[id]) return;
|
||||
byId[id] = {
|
||||
id: String(id),
|
||||
username: task.owner_username || task.owner_name || String(id),
|
||||
fullName: task.owner_name || task.owner_username || String(id),
|
||||
wekanId: null,
|
||||
};
|
||||
});
|
||||
return Object.values(byId);
|
||||
}
|
||||
@@ -4,15 +4,24 @@ export function wekanGetMembersToMap(data) {
|
||||
// we will work on the list itself (an ordered array of objects) when a
|
||||
// mapping is done, we add a 'wekan' field to the object representing the
|
||||
// imported member
|
||||
const membersToMap = data.members;
|
||||
const users = data.users;
|
||||
const membersToMap = data.members || [];
|
||||
const users = data.users || [];
|
||||
// auto-map based on username
|
||||
const mappable = [];
|
||||
membersToMap.forEach(importedMember => {
|
||||
importedMember.id = importedMember.userId;
|
||||
delete importedMember.userId;
|
||||
const user = users.filter(user => {
|
||||
return user._id === importedMember.id;
|
||||
})[0];
|
||||
// The exported `users` array only contains users that still exist in the
|
||||
// source instance, so a member whose account was deleted (or any other
|
||||
// dangling user reference, e.g. from an older export) has no matching
|
||||
// user. Skip it instead of dereferencing `undefined`, which would throw
|
||||
// and make the whole import fail as "error-json-malformed".
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
if (user.profile && user.profile.fullname) {
|
||||
importedMember.fullName = user.profile.fullname;
|
||||
}
|
||||
@@ -21,6 +30,7 @@ export function wekanGetMembersToMap(data) {
|
||||
if (wekanUser) {
|
||||
importedMember.wekanId = wekanUser._id;
|
||||
}
|
||||
mappable.push(importedMember);
|
||||
});
|
||||
return membersToMap;
|
||||
return mappable;
|
||||
}
|
||||
@@ -4,16 +4,16 @@
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
background: #dedede;
|
||||
border-left: 1px solid #ccc;
|
||||
border-inline-start: 1px solid #ccc;
|
||||
padding: 0;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
|
||||
/* List resize handle */
|
||||
.list-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -3px;
|
||||
inset-inline-end: -3px;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
@@ -84,16 +84,17 @@
|
||||
flex-basis: auto !important;
|
||||
flex-grow: 0 !important;
|
||||
flex-shrink: 0 !important;
|
||||
/* Override any conflicting layout properties */
|
||||
float: left !important;
|
||||
display: block !important;
|
||||
/* Override any conflicting layout properties.
|
||||
Keep the flex column layout so the list-body stays height-bounded and
|
||||
keeps its vertical scrollbar (display:block would collapse it). */
|
||||
float: inline-start !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
position: relative !important;
|
||||
/* Force width to be respected */
|
||||
width: var(--list-width, auto) !important;
|
||||
min-width: var(--list-width, auto) !important;
|
||||
max-width: var(--list-width, auto) !important;
|
||||
/* Ensure the width is applied immediately */
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
body.list-resizing-active {
|
||||
@@ -129,14 +130,19 @@ body.list-resizing-active * {
|
||||
flex-basis: auto !important;
|
||||
flex-grow: 0 !important;
|
||||
flex-shrink: 0 !important;
|
||||
float: left !important;
|
||||
display: block !important;
|
||||
float: inline-start !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
/* Ensure the width persists after resize is complete */
|
||||
.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;
|
||||
@@ -145,8 +151,9 @@ body.list-resizing-active * {
|
||||
flex-basis: auto !important;
|
||||
flex-grow: 0 !important;
|
||||
flex-shrink: 0 !important;
|
||||
float: left !important;
|
||||
display: block !important;
|
||||
float: inline-start !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
/* Ensure consistent header height for all lists */
|
||||
@@ -189,7 +196,7 @@ body.list-resizing-active * {
|
||||
.list-header .js-open-list-menu {
|
||||
position: absolute !important;
|
||||
top: 5px !important;
|
||||
right: 10px !important;
|
||||
inset-inline-end: 10px !important;
|
||||
z-index: 15 !important;
|
||||
display: inline-block !important;
|
||||
padding: 4px !important;
|
||||
@@ -198,7 +205,7 @@ body.list-resizing-active * {
|
||||
.list-header .list-header-plus-top {
|
||||
position: absolute !important;
|
||||
top: 5px !important;
|
||||
right: 30px !important;
|
||||
inset-inline-end: 30px !important;
|
||||
z-index: 15 !important;
|
||||
display: inline-block !important;
|
||||
padding: 4px !important;
|
||||
@@ -207,7 +214,7 @@ body.list-resizing-active * {
|
||||
.list-header .list-header-handle-desktop {
|
||||
position: absolute !important;
|
||||
top: 5px !important;
|
||||
right: 80px !important;
|
||||
inset-inline-end: 80px !important;
|
||||
z-index: 15 !important;
|
||||
display: inline-block !important;
|
||||
cursor: move !important;
|
||||
@@ -229,11 +236,11 @@ body.list-resizing-active * {
|
||||
flex: 1;
|
||||
}
|
||||
.list:first-child {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
flex: none;
|
||||
}
|
||||
.card-details + .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
}
|
||||
.list.ui-sortable-helper {
|
||||
box-shadow: -2px 2px 8px rgba(0,0,0,0.3), 0 0 1px rgba(0,0,0,0.5);
|
||||
@@ -250,10 +257,12 @@ body.list-resizing-active * {
|
||||
height: 15vh;
|
||||
}
|
||||
.list.list-collapsed {
|
||||
flex: none;
|
||||
min-width: 30px;
|
||||
max-width: 30px;
|
||||
width: 30px;
|
||||
flex: none !important;
|
||||
/* !important so a custom per-list --list-width never keeps a collapsed list
|
||||
full width (see https://github.com/wekan/wekan/issues/5892) */
|
||||
min-width: 30px !important;
|
||||
max-width: 30px !important;
|
||||
width: 30px !important;
|
||||
min-height: 60vh;
|
||||
height: 60vh;
|
||||
overflow: visible;
|
||||
@@ -311,7 +320,7 @@ body.list-resizing-active * {
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
width: auto;
|
||||
left: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
top: auto !important;
|
||||
}
|
||||
.list.list-collapsed .list-header .list-header-handle {
|
||||
@@ -322,7 +331,7 @@ body.list-resizing-active * {
|
||||
display: block;
|
||||
width: auto;
|
||||
top: auto !important;
|
||||
right: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
pointer-events: auto !important;
|
||||
@@ -336,8 +345,8 @@ body.list-resizing-active * {
|
||||
display: block;
|
||||
width: auto;
|
||||
top: auto !important;
|
||||
right: auto !important;
|
||||
left: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
transform: none !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
@@ -373,7 +382,7 @@ body.list-resizing-active * {
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
position: static;
|
||||
left: auto;
|
||||
inset-inline-start: auto;
|
||||
top: auto;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
@@ -443,7 +452,7 @@ form.inlined-form .list-composer {
|
||||
/* Sum badge shown before list title */
|
||||
.list-header .list-sum-badge {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
@@ -456,19 +465,19 @@ form.inlined-form .list-composer {
|
||||
width: 1.3vw;
|
||||
height: 35vh;
|
||||
margin-top: -12vh;
|
||||
margin-left: -14vw;
|
||||
margin-right: 0;
|
||||
margin-inline-start: -14vw;
|
||||
margin-inline-end: 0;
|
||||
transform: rotate(90deg);
|
||||
position: relative;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.list-header .list-header-watch-icon {
|
||||
padding-left: 10px;
|
||||
padding-inline-start: 10px;
|
||||
color: #a6a6a6;
|
||||
}
|
||||
.list-header .list-header-menu {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
@media print {
|
||||
.list-header .list-header-menu,
|
||||
@@ -478,7 +487,7 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
.list-header .list-header-plus-top {
|
||||
color: #a6a6a6;
|
||||
margin-right: 15px;
|
||||
margin-inline-end: 15px;
|
||||
vertical-align: middle;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -487,13 +496,13 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
.list-header .list-header-collapse-left {
|
||||
color: #a6a6a6;
|
||||
margin-right: 15px;
|
||||
margin-inline-end: 15px;
|
||||
}
|
||||
/* List header collapse button styling - positioned at top left */
|
||||
.list-header .js-collapse {
|
||||
position: absolute !important;
|
||||
top: 5px !important;
|
||||
left: 10px !important;
|
||||
inset-inline-start: 10px !important;
|
||||
color: #a6a6a6;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
@@ -527,8 +536,8 @@ form.inlined-form .list-composer {
|
||||
.list:not(.list-collapsed):not(.mobile-view) .list-header .js-collapse {
|
||||
position: static !important;
|
||||
top: auto !important;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
transform: none !important;
|
||||
margin: 0 !important;
|
||||
min-width: 24px;
|
||||
@@ -580,7 +589,7 @@ form.inlined-form .list-composer {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
position: static;
|
||||
left: auto;
|
||||
inset-inline-start: auto;
|
||||
top: auto;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
@@ -631,7 +640,7 @@ form.inlined-form .list-composer {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
position: static;
|
||||
left: auto;
|
||||
inset-inline-start: auto;
|
||||
top: auto;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
@@ -682,7 +691,7 @@ form.inlined-form .list-composer {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
position: static;
|
||||
left: auto;
|
||||
inset-inline-start: auto;
|
||||
top: auto;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
@@ -702,7 +711,7 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
.list-header .list-header-collapse {
|
||||
color: #a6a6a6;
|
||||
margin-right: 15px;
|
||||
margin-inline-end: 15px;
|
||||
}
|
||||
.list-header .highlight {
|
||||
color: #ce1414;
|
||||
@@ -716,7 +725,7 @@ form.inlined-form .list-composer {
|
||||
.js-open-list-menu,
|
||||
.list-header-menu a {
|
||||
color: #4d4d4d;
|
||||
padding-left: 4px;
|
||||
padding-inline-start: 4px;
|
||||
}
|
||||
.js-open-list-menu {
|
||||
font-size: 18px;
|
||||
@@ -757,7 +766,7 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
}
|
||||
.list-body .open-minicard-composer i.fa {
|
||||
margin-right: 7px;
|
||||
margin-inline-end: 7px;
|
||||
}
|
||||
.list-body .open-minicard-composer:hover {
|
||||
background: #fafafa;
|
||||
@@ -775,16 +784,16 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
#js-wip-limit-edit .wip-limit-value {
|
||||
width: 20%;
|
||||
margin-right: 5%;
|
||||
margin-inline-end: 5%;
|
||||
}
|
||||
#js-wip-limit-edit .wip-limit-error {
|
||||
display: none;
|
||||
}
|
||||
#js-wip-limit-edit .soft-wip-limit {
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
#js-wip-limit-edit div {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
#js-list-width-edit .list-width-error {
|
||||
display: none;
|
||||
@@ -796,7 +805,7 @@ form.inlined-form .list-composer {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
min-width: 100vw;
|
||||
border-left: 0px !important;
|
||||
border-inline-start: 0px !important;
|
||||
border-bottom: 1px solid #ccc;
|
||||
display: block !important;
|
||||
}
|
||||
@@ -806,19 +815,19 @@ form.inlined-form .list-composer {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
min-width: 100vw;
|
||||
border-left: 0px !important;
|
||||
border-inline-start: 0px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.list.mobile-view:first-child {
|
||||
margin-left: 0px;
|
||||
margin-inline-start: 0px;
|
||||
}
|
||||
.list.mobile-view.ui-sortable-helper {
|
||||
flex: 0 0 60px;
|
||||
height: 60px;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
border-left: 0px !important;
|
||||
border-inline-start: 0px !important;
|
||||
border-bottom: 1px solid #ccc;
|
||||
display: block !important;
|
||||
}
|
||||
@@ -830,7 +839,7 @@ form.inlined-form .list-composer {
|
||||
height: 60px;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
border-left: 0px !important;
|
||||
border-inline-start: 0px !important;
|
||||
border-bottom: 1px solid #ccc;
|
||||
display: block !important;
|
||||
}
|
||||
@@ -857,10 +866,10 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
.list.mobile-view .list-header .list-header-left-icon {
|
||||
padding: 7px;
|
||||
padding-right: 27px;
|
||||
padding-inline-end: 27px;
|
||||
margin-top: 1px;
|
||||
top: -7px;
|
||||
left: -7px;
|
||||
inset-inline-start: -7px;
|
||||
}
|
||||
.list.mobile-view .list-header .list-header-menu-icon {
|
||||
padding: 14px;
|
||||
@@ -868,7 +877,7 @@ form.inlined-form .list-composer {
|
||||
text-align: center;
|
||||
/* Force positioning for iPhone */
|
||||
position: absolute !important;
|
||||
right: 60px !important;
|
||||
inset-inline-end: 60px !important;
|
||||
top: 50% !important;
|
||||
transform: translateY(-50%) !important;
|
||||
z-index: 10;
|
||||
@@ -879,7 +888,7 @@ form.inlined-form .list-composer {
|
||||
text-align: center;
|
||||
/* Force positioning for iPhone */
|
||||
position: absolute !important;
|
||||
right: 10px !important;
|
||||
inset-inline-end: 10px !important;
|
||||
top: 50% !important;
|
||||
transform: translateY(-50%) !important;
|
||||
z-index: 10;
|
||||
@@ -902,9 +911,9 @@ form.inlined-form .list-composer {
|
||||
grid-row: 2;
|
||||
grid-column: 2;
|
||||
align-self: start;
|
||||
text-align: left;
|
||||
padding-left: 0;
|
||||
margin-left: 0;
|
||||
text-align: start;
|
||||
padding-inline-start: 0;
|
||||
margin-inline-start: 0;
|
||||
font-size: 16px !important;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -936,7 +945,7 @@ form.inlined-form .list-composer {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
min-width: 100vw;
|
||||
border-left: 0px !important;
|
||||
border-inline-start: 0px !important;
|
||||
border-bottom: 1px solid #ccc;
|
||||
display: block !important;
|
||||
}
|
||||
@@ -946,19 +955,19 @@ form.inlined-form .list-composer {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
min-width: 100vw;
|
||||
border-left: 0px !important;
|
||||
border-inline-start: 0px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.list:first-child {
|
||||
margin-left: 0px;
|
||||
margin-inline-start: 0px;
|
||||
}
|
||||
.list.ui-sortable-helper {
|
||||
flex: 0 0 60px;
|
||||
height: 60px;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
border-left: 0px !important;
|
||||
border-inline-start: 0px !important;
|
||||
border-bottom: 1px solid #ccc;
|
||||
display: block !important;
|
||||
}
|
||||
@@ -970,7 +979,7 @@ form.inlined-form .list-composer {
|
||||
height: 60px;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
border-left: 0px !important;
|
||||
border-inline-start: 0px !important;
|
||||
border-bottom: 1px solid #ccc;
|
||||
display: block !important;
|
||||
}
|
||||
@@ -993,10 +1002,10 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
.list-header .list-header-left-icon {
|
||||
padding: 7px;
|
||||
padding-right: 27px;
|
||||
padding-inline-end: 27px;
|
||||
margin-top: 1px;
|
||||
top: -7px;
|
||||
left: -7px;
|
||||
inset-inline-start: -7px;
|
||||
}
|
||||
.list-header .list-header-menu-icon {
|
||||
padding: 14px;
|
||||
@@ -1004,7 +1013,7 @@ form.inlined-form .list-composer {
|
||||
text-align: center;
|
||||
/* iOS Safari fallback positioning */
|
||||
position: absolute;
|
||||
right: 60px;
|
||||
inset-inline-end: 60px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
@@ -1014,7 +1023,7 @@ form.inlined-form .list-composer {
|
||||
text-align: center;
|
||||
/* iOS Safari fallback positioning */
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
inset-inline-end: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
@@ -1080,7 +1089,7 @@ form.inlined-form .list-composer {
|
||||
.list.mobile-view .list-header .list-header-menu-icon {
|
||||
/* Remove absolute positioning for iPhone 12 Mini */
|
||||
position: static !important;
|
||||
right: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
top: auto !important;
|
||||
transform: none !important;
|
||||
/* Use grid positioning */
|
||||
@@ -1094,7 +1103,7 @@ form.inlined-form .list-composer {
|
||||
.list.mobile-view .list-header .list-header-handle {
|
||||
/* Remove absolute positioning for iPhone 12 Mini */
|
||||
position: static !important;
|
||||
right: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
top: auto !important;
|
||||
transform: none !important;
|
||||
/* Use grid positioning */
|
||||
@@ -1119,9 +1128,9 @@ form.inlined-form .list-composer {
|
||||
grid-row: 2 !important;
|
||||
grid-column: 2 !important;
|
||||
align-self: start !important;
|
||||
text-align: left !important;
|
||||
padding-left: 0 !important;
|
||||
margin-left: 0 !important;
|
||||
text-align: start !important;
|
||||
padding-inline-start: 0 !important;
|
||||
margin-inline-start: 0 !important;
|
||||
font-size: 16px !important;
|
||||
line-height: 1.2 !important;
|
||||
}
|
||||
@@ -1145,7 +1154,7 @@ form.inlined-form .list-composer {
|
||||
.iphone-device .list.mobile-view .list-header .list-header-menu-icon {
|
||||
/* Remove absolute positioning for iPhone devices */
|
||||
position: static !important;
|
||||
right: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
top: auto !important;
|
||||
transform: none !important;
|
||||
/* Use grid positioning */
|
||||
@@ -1159,7 +1168,7 @@ form.inlined-form .list-composer {
|
||||
.iphone-device .list.mobile-view .list-header .list-header-handle {
|
||||
/* Remove absolute positioning for iPhone devices */
|
||||
position: static !important;
|
||||
right: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
top: auto !important;
|
||||
transform: none !important;
|
||||
/* Use grid positioning */
|
||||
@@ -1216,7 +1225,7 @@ form.inlined-form .list-composer {
|
||||
align-items: baseline;
|
||||
}
|
||||
.link-board-wrapper .js-link-board {
|
||||
margin-left: 15px;
|
||||
margin-inline-start: 15px;
|
||||
}
|
||||
.search-card-results {
|
||||
max-height: 250px;
|
||||
@@ -1321,8 +1330,8 @@ form.inlined-form .list-composer {
|
||||
.list.list-collapsed:not(.mobile-view) .list-header .list-header-handle {
|
||||
position: relative !important;
|
||||
top: auto !important;
|
||||
right: auto !important;
|
||||
left: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
transform: none !important;
|
||||
display: block !important;
|
||||
margin: 4px auto 2px auto !important;
|
||||
@@ -1337,8 +1346,8 @@ form.inlined-form .list-composer {
|
||||
.list.list-collapsed:not(.mobile-view) .list-header .js-collapse {
|
||||
position: relative !important;
|
||||
top: auto !important;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
display: block !important;
|
||||
margin: 2px auto !important;
|
||||
z-index: 400 !important;
|
||||
@@ -1374,7 +1383,7 @@ form.inlined-form .list-composer {
|
||||
transform: none !important;
|
||||
writing-mode: vertical-lr !important;
|
||||
text-orientation: mixed !important;
|
||||
text-align: left !important;
|
||||
text-align: start !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
@@ -1444,8 +1453,8 @@ form.inlined-form .list-composer {
|
||||
flex: 0 0 auto;
|
||||
position: static !important;
|
||||
top: auto !important;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
transform: none !important;
|
||||
margin: 2px 2px 0 0 !important;
|
||||
padding: 2px 4px !important;
|
||||
@@ -1463,7 +1472,7 @@ form.inlined-form .list-composer {
|
||||
flex: 1 0 100%;
|
||||
min-width: 0;
|
||||
padding-top: 4px !important;
|
||||
padding-right: 0 !important;
|
||||
padding-inline-end: 0 !important;
|
||||
}
|
||||
|
||||
/* Hide standalone duplicate drag handle outside the menu group */
|
||||
@@ -1475,7 +1484,7 @@ form.inlined-form .list-composer {
|
||||
.list:not(.list-collapsed):not(.mobile-view) .list-header .list-header-watch-icon {
|
||||
order: 2;
|
||||
flex: 0 0 auto;
|
||||
padding-left: 0;
|
||||
padding-inline-start: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
@@ -1486,7 +1495,7 @@ form.inlined-form .list-composer {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
margin-inline-start: auto;
|
||||
margin-top: 2px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
@@ -1497,8 +1506,8 @@ form.inlined-form .list-composer {
|
||||
.list:not(.list-collapsed):not(.mobile-view) .list-header .list-header-menu .js-open-list-menu {
|
||||
position: static !important;
|
||||
top: auto !important;
|
||||
right: auto !important;
|
||||
left: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
transform: none !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
@@ -1533,7 +1542,7 @@ form.inlined-form .list-composer {
|
||||
|
||||
.list.list-collapsed .list-header .js-collapse {
|
||||
position: relative !important;
|
||||
left: -10px !important;
|
||||
inset-inline-start: -10px !important;
|
||||
color: #333;
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
||||
@@ -1,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;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ template(name="listBody")
|
||||
= formattedCurrencyCustomFieldValue(value)
|
||||
each (cardsWithLimit (idOrNull ../../_id))
|
||||
a.minicard-wrapper.js-minicard(href=originRelativeUrl
|
||||
data-card-id="{{_id}}"
|
||||
class="{{#if cardIsSelected}}is-selected{{/if}}"
|
||||
class="{{#if MultiSelection.isSelected _id}}is-checked{{/if}}")
|
||||
if MultiSelection.isActive
|
||||
|
||||
@@ -9,6 +9,7 @@ import Swimlanes from '/models/swimlanes';
|
||||
import { Filter } from '/client/lib/filter';
|
||||
import { MultiSelection } from '/client/lib/multiSelection';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { isLinkableCardTarget } from '/models/lib/linkedCardTarget';
|
||||
import autosize from 'autosize';
|
||||
|
||||
// SubsManager removed for Meteor 3 migration
|
||||
@@ -324,7 +325,20 @@ Template.listBody.helpers({
|
||||
const tpl = Template.instance();
|
||||
const limit = tpl.cardlimit.get();
|
||||
const defaultSort = { sort: 1 };
|
||||
const sortBy = Session.get('sortBy') ? Session.get('sortBy') : defaultSort;
|
||||
// Session is reset on page reload; fall back to the persisted card sort so a
|
||||
// chosen sort (e.g. by due date) is remembered across reloads (#5886).
|
||||
let sortBy = Session.get('sortBy');
|
||||
if (!sortBy) {
|
||||
try {
|
||||
const stored = window.localStorage.getItem('wekan-cards-sortBy');
|
||||
if (stored) {
|
||||
sortBy = JSON.parse(stored);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
if (!sortBy) {
|
||||
sortBy = defaultSort;
|
||||
}
|
||||
const selector = {
|
||||
listId: Template.currentData()._id,
|
||||
archived: false,
|
||||
@@ -355,9 +369,10 @@ Template.listBody.helpers({
|
||||
canSeeAddCard() {
|
||||
const tpl = Template.instance();
|
||||
const list = Template.currentData();
|
||||
// #2095: count all cards in the list, not only the filtered/visible ones.
|
||||
const reachedWipLimit = !list.getWipLimit('soft') &&
|
||||
list.getWipLimit('enabled') &&
|
||||
list.getWipLimit('value') <= list.cards().length;
|
||||
list.getWipLimit('value') <= list.cardsUnfiltered().length;
|
||||
return (
|
||||
!reachedWipLimit &&
|
||||
Utils.canModifyCard()
|
||||
@@ -369,7 +384,8 @@ Template.listBody.helpers({
|
||||
return (
|
||||
!list.getWipLimit('soft') &&
|
||||
list.getWipLimit('enabled') &&
|
||||
list.getWipLimit('value') <= list.cards().length
|
||||
// #2095: count all cards in the list, not only the filtered/visible ones.
|
||||
list.getWipLimit('value') <= list.cardsUnfiltered().length
|
||||
);
|
||||
},
|
||||
|
||||
@@ -691,7 +707,10 @@ Template.linkCardPopup.helpers({
|
||||
archived: false,
|
||||
linkedId: { $nin: ownCardsIds },
|
||||
_id: { $nin: ownCardsIds },
|
||||
type: { $nin: ['template-card'] },
|
||||
// #5808: never offer an existing linked card/board as a link target —
|
||||
// linking to one builds a chain of linkedId pointers that renders the
|
||||
// card inaccessible. Only real cards may be linked.
|
||||
type: { $nin: ['template-card', 'cardType-linkedCard', 'cardType-linkedBoard'] },
|
||||
};
|
||||
if (tpl.selectedBoardId.get()) selector.boardId = tpl.selectedBoardId.get();
|
||||
if (tpl.selectedSwimlaneId.get()) selector.swimlaneId = tpl.selectedSwimlaneId.get();
|
||||
@@ -741,11 +760,48 @@ Template.linkCardPopup.events({
|
||||
tpl.selectedListId.set($(evt.currentTarget).val());
|
||||
},
|
||||
async 'click .js-done'(evt, tpl) {
|
||||
// LINK CARD
|
||||
// LINK CARD (or, when no card is chosen, fall back to a board-level link
|
||||
// so a whole board can be linked even when that board already has cards).
|
||||
// https://github.com/wekan/wekan/issues/5715
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
const linkedId = $('.js-select-cards option:selected').val();
|
||||
if (!linkedId) {
|
||||
const boardId = $('.js-select-boards option:selected').val();
|
||||
// No board and no card selected: nothing to link.
|
||||
if (!boardId) {
|
||||
Popup.back();
|
||||
return;
|
||||
}
|
||||
// A linkedBoard card for this board already exists; avoid duplicates.
|
||||
if (ReactiveCache.getCard({ linkedId: boardId, archived: false })) {
|
||||
Popup.back();
|
||||
return;
|
||||
}
|
||||
const boardCardNumber = await tpl.board.getNextCardNumber();
|
||||
const boardSortIndex = tpl.getSortIndex();
|
||||
const boardCardId = Cards.insert({
|
||||
title: $('.js-select-boards option:selected').text(), //dummy
|
||||
listId: tpl.listId,
|
||||
swimlaneId: tpl.swimlaneId,
|
||||
boardId: tpl.boardId,
|
||||
sort: boardSortIndex,
|
||||
type: 'cardType-linkedBoard',
|
||||
linkedId: boardId,
|
||||
cardNumber: boardCardNumber,
|
||||
});
|
||||
Filter.addException(boardCardId);
|
||||
Popup.back();
|
||||
return;
|
||||
}
|
||||
// #5808: refuse to link to a linked card/board or to a card that links back
|
||||
// to this board — those build a chain/cycle of linkedId pointers that renders
|
||||
// the cards inaccessible. The <select> already filters these out, but its
|
||||
// options can be stale, so re-check the resolved target here.
|
||||
const targetCard = ReactiveCache.getCard(linkedId);
|
||||
const ownCardsIds = tpl.board.cards().map(card => card.getRealId());
|
||||
if (!isLinkableCardTarget(targetCard, ownCardsIds)) {
|
||||
alert(TAPi18n.__('error-linked-card-not-allowed'));
|
||||
Popup.back();
|
||||
return;
|
||||
}
|
||||
@@ -922,13 +978,37 @@ Template.searchElementPopup.events({
|
||||
if (!tpl.isTemplateSearch || tpl.isCardTemplateSearch) {
|
||||
// Card insertion
|
||||
// 1. Common
|
||||
// Compute the sort index BEFORE the first await: getSortIndex() reads
|
||||
// Template.currentData(), and Blaze's current-view context is lost across
|
||||
// an await, so calling it afterwards threw "There is no current view" and
|
||||
// no card was created from the template. Capture it while still in the
|
||||
// synchronous event/view context.
|
||||
const sortIndex = tpl.getSortIndex();
|
||||
element.cardNumber = await tpl.board.getNextCardNumber();
|
||||
element.sort = tpl.getSortIndex();
|
||||
element.sort = sortIndex;
|
||||
// 1.A From template
|
||||
if (tpl.isTemplateSearch) {
|
||||
element.type = 'cardType-card';
|
||||
element.linkedId = '';
|
||||
_id = await element.copy(tpl.boardId, tpl.swimlaneId, tpl.listId);
|
||||
// #5798: instantiate the template into the CURRENT board, not the
|
||||
// templates board. For a template search tpl.boardId is the templates
|
||||
// board (it is the search source), so copying with it created the card
|
||||
// with boardId = templates board. The card still showed in the target
|
||||
// list (the list query is by listId and the templates board is
|
||||
// subscribed), but clicking it navigated to the templates board instead
|
||||
// of opening the card. The target list/swimlane were already resolved on
|
||||
// the current board.
|
||||
// Instantiate into the board that OWNS the target list (where the user
|
||||
// clicked "add card"). Deriving it from tpl.listId is reliable even when
|
||||
// Utils.getCurrentBoard() (route/session state) has not settled yet — as
|
||||
// on the slower CI production bundle, where falling back to tpl.boardId
|
||||
// wrongly created the card on the templates board.
|
||||
const targetList = ReactiveCache.getList(tpl.listId);
|
||||
const targetBoardId =
|
||||
(targetList || {}).boardId ||
|
||||
(Utils.getCurrentBoard() || {})._id ||
|
||||
tpl.boardId;
|
||||
_id = await element.copy(targetBoardId, tpl.swimlaneId, tpl.listId);
|
||||
// 1.B Linked card
|
||||
} else {
|
||||
_id = element.link(tpl.boardId, tpl.swimlaneId, tpl.listId);
|
||||
|
||||
@@ -2,6 +2,10 @@ template(name="listHeader")
|
||||
.list-header.js-list-header.nodragscroll(
|
||||
class="{{#if limitToShowCardsCount}}list-header-card-count{{/if}}"
|
||||
class=colorClass)
|
||||
//- #459: keyboard/screen-reader list reordering (visually hidden, focusable).
|
||||
.list-move-buttons.sr-only
|
||||
a.js-list-move-left(href="#" role="button" aria-label="{{_ 'move-list-left'}}") {{_ 'move-list-left'}}
|
||||
a.js-list-move-right(href="#" role="button" aria-label="{{_ 'move-list-right'}}") {{_ 'move-list-right'}}
|
||||
+inlinedForm
|
||||
+editListTitleForm
|
||||
else
|
||||
@@ -248,17 +252,20 @@ template(name="wipLimitErrorPopup")
|
||||
|
||||
template(name="setListWidthPopup")
|
||||
#js-list-width-edit
|
||||
label {{_ 'set-list-width-value'}}
|
||||
p
|
||||
input.list-width-value(type="number" value="{{ listWidthValue }}" min="270")
|
||||
input.list-constraint-value(type="number" value="{{ listConstraintValue }}" min="270")
|
||||
input.list-width-apply(type="submit" value="{{_ 'apply'}}")
|
||||
input.list-width-error
|
||||
br
|
||||
a.js-auto-width-board(
|
||||
title="{{#if isAutoWidth}}{{_ 'click-to-disable-auto-width'}}{{else}}{{_ 'click-to-enable-auto-width'}}{{/if}}")
|
||||
| {{#if isAutoWidth}}🗜️{{else}}📏{{/if}}
|
||||
span {{_ 'auto-list-width'}}
|
||||
unless isAutoWidth
|
||||
label {{_ 'set-list-width-value'}}
|
||||
p
|
||||
input.list-width-value(type="number" value="{{ listWidthValue }}" min="270")
|
||||
input.list-width-apply(type="submit" value="{{_ 'apply'}}")
|
||||
input.list-width-error
|
||||
if canChangeAutoWidth
|
||||
a.js-toggle-auto-width(title="{{#if isAutoWidth}}{{_ 'click-to-disable-auto-width'}}{{else}}{{_ 'click-to-enable-auto-width'}}{{/if}}")
|
||||
| {{#if isAutoWidth}}🗜️{{else}}📏{{/if}}
|
||||
span {{_ 'auto-list-width'}}
|
||||
a.js-toggle-fixed-list-width(title="{{#if isFixedListWidth}}{{_ 'click-to-disable-fixed-list-width'}}{{else}}{{_ 'click-to-enable-fixed-list-width'}}{{/if}}")
|
||||
| {{#if isFixedListWidth}}✅{{else}}⬜{{/if}}
|
||||
span {{_ 'fixed-list-width'}}
|
||||
p.list-width-scope-note {{ listWidthScopeNote }}
|
||||
|
||||
template(name="listWidthErrorPopup")
|
||||
.list-width-invalid
|
||||
|
||||
@@ -20,12 +20,12 @@ Template.listHeader.helpers({
|
||||
(!list.getWipLimit('enabled') ||
|
||||
list.getWipLimit('soft') ||
|
||||
!Template.instance().reachedWipLimit()) &&
|
||||
!ReactiveCache.getCurrentUser().isWorker()
|
||||
!ReactiveCache.getCurrentUser()?.isWorker()
|
||||
);
|
||||
},
|
||||
|
||||
isBoardAdmin() {
|
||||
return ReactiveCache.getCurrentUser().isBoardAdmin();
|
||||
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
|
||||
},
|
||||
|
||||
starred() {
|
||||
@@ -148,7 +148,36 @@ Template.listHeader.onCreated(function () {
|
||||
};
|
||||
});
|
||||
|
||||
// #459: accessible reordering — move a list left/right via sr-only buttons by
|
||||
// swapping its sort value with the adjacent list (no drag-and-drop required).
|
||||
function moveListBy(list, delta) {
|
||||
const siblings = ReactiveCache.getLists(
|
||||
{ boardId: list.boardId, archived: false },
|
||||
{ sort: { sort: 1 } },
|
||||
);
|
||||
const idx = siblings.findIndex(l => l._id === list._id);
|
||||
const target = siblings[idx + delta];
|
||||
if (idx < 0 || !target) return;
|
||||
// Capture both sort values before either update; the docs are reactive and
|
||||
// list.sort would otherwise change after the first update.
|
||||
const listSort = list.sort;
|
||||
const targetSort = target.sort;
|
||||
// Lists are a server-restricted collection; persist the swap through the
|
||||
// updateListSort method — the same path the drag-and-drop reorder uses. A raw
|
||||
// client Lists.update of `sort` is reverted by the server.
|
||||
Meteor.call('updateListSort', list._id, list.boardId, { sort: targetSort });
|
||||
Meteor.call('updateListSort', target._id, target.boardId, { sort: listSort });
|
||||
}
|
||||
|
||||
Template.listHeader.events({
|
||||
'click .js-list-move-left'(event) {
|
||||
event.preventDefault();
|
||||
moveListBy(Template.currentData(), -1);
|
||||
},
|
||||
'click .js-list-move-right'(event) {
|
||||
event.preventDefault();
|
||||
moveListBy(Template.currentData(), 1);
|
||||
},
|
||||
async 'click .js-list-star'(event) {
|
||||
event.preventDefault();
|
||||
const list = Template.currentData();
|
||||
@@ -189,7 +218,7 @@ Template.listHeader.events({
|
||||
|
||||
Template.listActionPopup.helpers({
|
||||
isBoardAdmin() {
|
||||
return ReactiveCache.getCurrentUser().isBoardAdmin();
|
||||
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
|
||||
},
|
||||
|
||||
isWipLimitEnabled() {
|
||||
@@ -229,7 +258,15 @@ Template.listActionPopup.events({
|
||||
'click .js-set-list-width': Popup.open('setListWidth'),
|
||||
'click .js-set-color-list': Popup.open('setListColor'),
|
||||
'click .js-select-cards'() {
|
||||
const cardIds = this.allCards().map(card => card._id);
|
||||
// Scope "select all cards" to the current swimlane when invoked from a
|
||||
// swimlane context (#5623). In swimlanes board view the list carries its
|
||||
// swimlaneId; otherwise there is genuinely no swimlane context and we keep
|
||||
// the historical list-wide selection (swimlaneId stays undefined).
|
||||
let swimlaneId;
|
||||
if (Utils.boardView() === 'board-view-swimlanes' && this.swimlaneId) {
|
||||
swimlaneId = this.swimlaneId;
|
||||
}
|
||||
const cardIds = this.allCards(swimlaneId).map(card => card._id);
|
||||
MultiSelection.add(cardIds);
|
||||
Popup.back();
|
||||
},
|
||||
@@ -448,10 +485,8 @@ Template.setListColorPopup.events({
|
||||
},
|
||||
async 'submit form'(event, tpl) {
|
||||
event.preventDefault();
|
||||
console.log('[ListColor] submit form, color:', tpl.currentColor.get(), 'listId:', tpl.currentList?._id, 'setColor type:', typeof tpl.currentList?.setColor);
|
||||
try {
|
||||
const result = await tpl.currentList.setColor(tpl.currentColor.get());
|
||||
console.log('[ListColor] submit form setColor result:', result);
|
||||
await tpl.currentList.setColor(tpl.currentColor.get());
|
||||
} catch (err) {
|
||||
console.error('[ListColor] submit form setColor error:', err);
|
||||
}
|
||||
@@ -459,10 +494,8 @@ Template.setListColorPopup.events({
|
||||
},
|
||||
async 'click .js-submit'(event, tpl) {
|
||||
event.preventDefault();
|
||||
console.log('[ListColor] click submit, color:', tpl.currentColor.get(), 'listId:', tpl.currentList?._id, 'setColor type:', typeof tpl.currentList?.setColor);
|
||||
try {
|
||||
const result = await tpl.currentList.setColor(tpl.currentColor.get());
|
||||
console.log('[ListColor] click submit setColor result:', result);
|
||||
await tpl.currentList.setColor(tpl.currentColor.get());
|
||||
} catch (err) {
|
||||
console.error('[ListColor] click submit setColor error:', err);
|
||||
}
|
||||
@@ -470,7 +503,6 @@ Template.setListColorPopup.events({
|
||||
},
|
||||
async 'click .js-remove-color'(event, tpl) {
|
||||
event.preventDefault();
|
||||
console.log('[ListColor] remove color, listId:', tpl.currentList?._id);
|
||||
try {
|
||||
await tpl.currentList.setColor(null);
|
||||
} catch (err) {
|
||||
@@ -480,50 +512,199 @@ Template.setListColorPopup.events({
|
||||
},
|
||||
});
|
||||
|
||||
// #6409: the per-list width popup is now a single fixed-width value. Whether it
|
||||
// affects everyone (shared) or just the current user (personal) follows the
|
||||
// board setting `allowsPersonalListWidth`.
|
||||
function isPersonalListWidth(boardId) {
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
return !!(board && board.allowsPersonalListWidth);
|
||||
}
|
||||
|
||||
// #5729 Fixed (same) width for all lists is a per-viewer/per-board setting.
|
||||
// Logged-in users store it in their profile; anonymous (public board) users in
|
||||
// localStorage (mirrors the per-list anon storage in list.js).
|
||||
function readAnonFixedListWidthEnabled(boardId) {
|
||||
try {
|
||||
const stored = localStorage.getItem('wekan-fixed-list-width-enabled');
|
||||
if (stored) {
|
||||
const flags = JSON.parse(stored);
|
||||
return flags[boardId] === true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error reading fixed list width flag from localStorage:', e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function readAnonFixedListWidth(boardId) {
|
||||
try {
|
||||
const stored = localStorage.getItem('wekan-fixed-list-width');
|
||||
if (stored) {
|
||||
const widths = JSON.parse(stored);
|
||||
const w = widths[boardId];
|
||||
if (typeof w === 'number' && w >= 270) return w;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error reading fixed list width from localStorage:', e);
|
||||
}
|
||||
return 272;
|
||||
}
|
||||
|
||||
function isFixedListWidth(boardId) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (user) return !!user.isFixedListWidth(boardId);
|
||||
return readAnonFixedListWidthEnabled(boardId);
|
||||
}
|
||||
|
||||
function fixedListWidthValue(boardId) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (user) return user.getFixedListWidth(boardId);
|
||||
return readAnonFixedListWidth(boardId);
|
||||
}
|
||||
|
||||
function setAnonFixedListWidthEnabled(boardId, enabled) {
|
||||
try {
|
||||
const stored = localStorage.getItem('wekan-fixed-list-width-enabled');
|
||||
const flags = stored ? JSON.parse(stored) : {};
|
||||
flags[boardId] = !!enabled;
|
||||
localStorage.setItem('wekan-fixed-list-width-enabled', JSON.stringify(flags));
|
||||
} catch (e) {
|
||||
console.warn('Error saving fixed list width flag to localStorage:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function setAnonFixedListWidth(boardId, width) {
|
||||
try {
|
||||
const stored = localStorage.getItem('wekan-fixed-list-width');
|
||||
const widths = stored ? JSON.parse(stored) : {};
|
||||
widths[boardId] = width;
|
||||
localStorage.setItem('wekan-fixed-list-width', JSON.stringify(widths));
|
||||
} catch (e) {
|
||||
console.warn('Error saving fixed list width to localStorage:', e);
|
||||
}
|
||||
}
|
||||
|
||||
Template.setListWidthPopup.helpers({
|
||||
listWidthValue() {
|
||||
const list = Template.currentData();
|
||||
const board = list.boardId;
|
||||
return ReactiveCache.getCurrentUser().getListWidth(board, list._id);
|
||||
},
|
||||
|
||||
listConstraintValue() {
|
||||
const list = Template.currentData();
|
||||
const board = list.boardId;
|
||||
return ReactiveCache.getCurrentUser().getListConstraint(board, list._id);
|
||||
},
|
||||
|
||||
isAutoWidth() {
|
||||
const boardId = Utils.getCurrentBoardId();
|
||||
// #5729 In fixed width mode the input edits the single per-board value.
|
||||
if (isFixedListWidth(list.boardId)) {
|
||||
return fixedListWidthValue(list.boardId);
|
||||
}
|
||||
const shared =
|
||||
typeof list.width === 'number' && list.width >= 270 ? list.width : 272;
|
||||
if (!isPersonalListWidth(list.boardId)) {
|
||||
return shared;
|
||||
}
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
return user && user.isAutoWidth(boardId);
|
||||
if (user) {
|
||||
const widths = user.getListWidths();
|
||||
const w = widths[list.boardId] && widths[list.boardId][list._id];
|
||||
return typeof w === 'number' && w >= 270 ? w : shared;
|
||||
}
|
||||
return shared;
|
||||
},
|
||||
|
||||
listWidthScopeNote() {
|
||||
const list = Template.currentData();
|
||||
// #5729 In fixed width mode the note explains the all-lists behaviour.
|
||||
if (isFixedListWidth(list.boardId)) {
|
||||
return TAPi18n.__('fixed-list-width-note');
|
||||
}
|
||||
return isPersonalListWidth(list.boardId)
|
||||
? TAPi18n.__('list-width-personal-note')
|
||||
: TAPi18n.__('list-width-shared-note');
|
||||
},
|
||||
|
||||
// #6409: auto-width follows the same scope as fixed widths.
|
||||
isAutoWidth() {
|
||||
const list = Template.currentData();
|
||||
if (isPersonalListWidth(list.boardId)) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
return !!(user && user.isAutoWidth(list.boardId));
|
||||
}
|
||||
const board = ReactiveCache.getBoard(list.boardId);
|
||||
return !!(board && board.autoWidth);
|
||||
},
|
||||
|
||||
// In shared mode only members with board write access may toggle the shared
|
||||
// auto-width; in personal mode any logged-in user may toggle their own.
|
||||
canChangeAutoWidth() {
|
||||
const list = Template.currentData();
|
||||
if (isPersonalListWidth(list.boardId)) {
|
||||
return !!ReactiveCache.getCurrentUser();
|
||||
}
|
||||
return Utils.canModifyBoard();
|
||||
},
|
||||
|
||||
// #5729 Whether "same width for all lists" mode is on for the current viewer.
|
||||
isFixedListWidth() {
|
||||
const list = Template.currentData();
|
||||
return isFixedListWidth(list.boardId);
|
||||
},
|
||||
});
|
||||
|
||||
Template.setListWidthPopup.events({
|
||||
'click .js-auto-width-board'() {
|
||||
dragscroll.reset();
|
||||
ReactiveCache.getCurrentUser().toggleAutoWidth(Utils.getCurrentBoardId());
|
||||
'click .js-toggle-auto-width'(event) {
|
||||
event.preventDefault();
|
||||
const list = Template.currentData();
|
||||
const boardId = list.boardId;
|
||||
if (isPersonalListWidth(boardId)) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (user) user.toggleAutoWidth(boardId);
|
||||
} else {
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
const current = !!(board && board.autoWidth);
|
||||
Meteor.call('setBoardAutoWidth', boardId, !current);
|
||||
}
|
||||
Popup.back();
|
||||
},
|
||||
// #5729 Toggle "same width for all lists" (fixed width) for the current
|
||||
// viewer. Enabling it turns off auto-width (the two modes are exclusive).
|
||||
'click .js-toggle-fixed-list-width'(event) {
|
||||
event.preventDefault();
|
||||
const list = Template.currentData();
|
||||
const boardId = list.boardId;
|
||||
const enabled = !isFixedListWidth(boardId);
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (user) {
|
||||
if (enabled && isPersonalListWidth(boardId) && user.isAutoWidth(boardId)) {
|
||||
user.toggleAutoWidth(boardId);
|
||||
}
|
||||
Meteor.call('setFixedListWidthEnabled', boardId, enabled);
|
||||
} else {
|
||||
setAnonFixedListWidthEnabled(boardId, enabled);
|
||||
}
|
||||
Popup.back();
|
||||
},
|
||||
'click .list-width-apply'(event, tpl) {
|
||||
const list = Template.currentData();
|
||||
const board = list.boardId;
|
||||
const width = parseInt(
|
||||
tpl.$('.list-width-value').val(),
|
||||
10,
|
||||
);
|
||||
const constraint = parseInt(
|
||||
tpl.$('.list-constraint-value').val(),
|
||||
10,
|
||||
);
|
||||
const boardId = list.boardId;
|
||||
const width = parseInt(tpl.$('.list-width-value').val(), 10);
|
||||
|
||||
// FIXME(mark-i-m): where do we put constants?
|
||||
if (width < 270 || !width || constraint < 270 || !constraint) {
|
||||
if (!width || width < 270) {
|
||||
tpl.$('.list-width-error').click();
|
||||
} else {
|
||||
Meteor.call('applyListWidth', board, list._id, width, constraint);
|
||||
Popup.back();
|
||||
return;
|
||||
}
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
// #5729 In fixed width mode the input applies to the single per-board value.
|
||||
if (isFixedListWidth(boardId)) {
|
||||
if (user) {
|
||||
Meteor.call('setFixedListWidth', boardId, width);
|
||||
} else {
|
||||
setAnonFixedListWidth(boardId, width);
|
||||
}
|
||||
Popup.back();
|
||||
return;
|
||||
}
|
||||
if (isPersonalListWidth(boardId)) {
|
||||
if (user) {
|
||||
Meteor.call('applyListWidthToStorage', boardId, list._id, width, width);
|
||||
}
|
||||
} else if (user) {
|
||||
Meteor.call('applyListWidth', boardId, list._id, width, width);
|
||||
}
|
||||
Popup.back();
|
||||
},
|
||||
'click .list-width-error': Popup.open('listWidthError'),
|
||||
});
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
border-radius: 0 0 0.5vw 0.5vw;
|
||||
min-width: min(400px, 52vw);
|
||||
margin-bottom: 2.5vh;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
margin-inline-end: auto;
|
||||
margin-inline-start: auto;
|
||||
border-width: 0.3vw;
|
||||
border-style: solid;
|
||||
border-color: #a2a2a2;
|
||||
@@ -46,8 +46,8 @@
|
||||
}
|
||||
.my-cards-dueat-list-wrapper {
|
||||
max-width: min(500px, 65vw);
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
margin-inline-end: auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
.my-cards-board-table thead {
|
||||
border-bottom: 3px solid #4d4d4d;
|
||||
@@ -62,15 +62,15 @@
|
||||
}
|
||||
.my-cards-card-title-table {
|
||||
font-weight: bold;
|
||||
padding-left: 2px;
|
||||
padding-inline-start: 2px;
|
||||
max-width: 243px;
|
||||
}
|
||||
.my-cards-board-badge {
|
||||
width: 36px;
|
||||
height: 24px;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
border-radius: 5px;
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
|
||||
/* Accessibility page styles */
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
}
|
||||
.broken-cards-wrapper {
|
||||
max-width: min(500px, 65vw);
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
margin-inline-end: auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
.broken-cards-card-title {
|
||||
font-weight: bold;
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
.due-cards-dueat-list-wrapper {
|
||||
max-width: min(500px, 65vw);
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
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;
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
.new-comment a.fa.fa-brands.fa-markdown,
|
||||
.inlined-form a.fa.fa-brands.fa-markdown {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: 60px;
|
||||
inset-inline-end: 60px;
|
||||
}
|
||||
.new-comment a.fa.fa-copy,
|
||||
.inlined-form a.fa.fa-copy {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
position: relative;
|
||||
top: -10px;
|
||||
right: 5px;
|
||||
inset-inline-end: 5px;
|
||||
}
|
||||
.js-inlined-form.viewer.btn-sm {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 6px;
|
||||
inset-inline-end: 6px;
|
||||
}
|
||||
@@ -5,8 +5,8 @@
|
||||
border-color: #808080;
|
||||
border-style: solid;
|
||||
margin-bottom: 2rem;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
margin-inline-end: auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
.global-search-board-title {
|
||||
font-size: 1.4rem;
|
||||
@@ -48,8 +48,8 @@
|
||||
}
|
||||
.global-search-results-list-wrapper {
|
||||
max-width: 500px;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
margin-inline-end: auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
.global-search-field-name {
|
||||
font-weight: bold;
|
||||
@@ -69,8 +69,8 @@
|
||||
.global-search-page {
|
||||
width: 40%;
|
||||
min-width: 400px;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
margin-inline-end: auto;
|
||||
margin-inline-start: auto;
|
||||
line-height: 150%;
|
||||
}
|
||||
.global-search-page h1 {
|
||||
@@ -81,8 +81,8 @@
|
||||
}
|
||||
.global-search-query-input {
|
||||
width: 90% !important;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
margin-inline-end: auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
.js-new-search {
|
||||
cursor: pointer;
|
||||
@@ -121,12 +121,31 @@ code {
|
||||
}
|
||||
.global-search-next-page {
|
||||
border: none;
|
||||
text-align: right;
|
||||
text-align: end;
|
||||
}
|
||||
.global-search-previous-page {
|
||||
border: none;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
}
|
||||
.global-search-instructions li {
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
/* #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);
|
||||
}
|
||||
@@ -4,6 +4,36 @@ template(name="globalSearchHeaderBar")
|
||||
i.fa.fa-search
|
||||
| {{_ 'globalSearch-title'}}
|
||||
|
||||
.board-header-btns.left
|
||||
a.board-header-btn.js-global-search-view-change(title="{{_ 'globalSearchViewChange-title'}}")
|
||||
i.fa.fa-caret-down
|
||||
if $eq globalSearchView 'me'
|
||||
i.fa.fa-user
|
||||
| {{_ 'globalSearchViewChange-choice-me'}}
|
||||
if $eq globalSearchView 'all'
|
||||
i.fa.fa-users
|
||||
| {{_ 'globalSearchViewChange-choice-all'}}
|
||||
|
||||
template(name="globalSearchViewChangePopup")
|
||||
if currentUser
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-global-search-view-me
|
||||
i.fa.fa-user
|
||||
| {{_ 'globalSearchViewChange-choice-me'}}
|
||||
if $eq Utils.globalSearchView "me"
|
||||
i.fa.fa-check
|
||||
hr
|
||||
li
|
||||
a.js-global-search-view-all
|
||||
i.fa.fa-users
|
||||
| {{_ 'globalSearchViewChange-choice-all'}}
|
||||
span.sub-name
|
||||
+viewer
|
||||
| {{_ 'globalSearchViewChange-choice-all-description' }}
|
||||
if $eq Utils.globalSearchView "all"
|
||||
i.fa.fa-check
|
||||
|
||||
template(name="globalSearchModalTitle")
|
||||
if currentUser
|
||||
h2
|
||||
@@ -29,7 +59,8 @@ template(name="resultsPaged")
|
||||
template(name="globalSearch")
|
||||
if currentUser
|
||||
.wrapper(class=boardColorClass)
|
||||
form.global-search-page.js-search-query-form(onsubmit="return false;")
|
||||
form.global-search-page.js-search-query-form(role="search" onsubmit="return false;")
|
||||
label.sr-only(for="global-search-input") {{_ 'globalSearch-title'}}
|
||||
input.global-search-query-input(
|
||||
style="display: inline-block;"
|
||||
id="global-search-input"
|
||||
@@ -37,14 +68,15 @@ template(name="globalSearch")
|
||||
name="searchQuery"
|
||||
placeholder="{{_ 'search-example'}}"
|
||||
value="{{ queryValue }}"
|
||||
aria-label="{{_ 'globalSearch-title'}}"
|
||||
autofocus dir="auto"
|
||||
)
|
||||
button.js-new-search.board-color-belize(type="button" style="background: #216694 !important; background-color: #216694 !important; color: #fff !important; border: none !important;")
|
||||
| 🧹
|
||||
button.js-new-search.board-color-belize(type="button" aria-label="{{_ 'globalSearch-title'}}" style="background: #216694 !important; background-color: #216694 !important; color: #fff !important; border: none !important;")
|
||||
span(aria-hidden="true") 🧹
|
||||
if searching
|
||||
+spinner
|
||||
else if hasResults
|
||||
.global-search-results-list-wrapper
|
||||
.global-search-results-list-wrapper.global-search-results-list
|
||||
if hasQueryErrors
|
||||
ul
|
||||
each msg in errorMessages
|
||||
|
||||
@@ -2,12 +2,34 @@ import { TAPi18n } from '/imports/i18n';
|
||||
import { CardSearchPaged } from '../../lib/cardSearch';
|
||||
import { LABEL_COLORS } from '/models/metadata/colors';
|
||||
import { Query, QueryErrors } from '../../../config/query-classes';
|
||||
import { OPERATOR_USER } from '/config/search-const';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
|
||||
// const subManager = new SubsManager();
|
||||
|
||||
// #3392-adjacent: "my cards / all cards" toggle for global search (mirrors the
|
||||
// due-cards view toggle). When "me", the current user is injected as a `user:`
|
||||
// predicate (member OR assignee), reusing the existing search logic.
|
||||
Template.globalSearchHeaderBar.helpers({
|
||||
globalSearchView() {
|
||||
return Utils.globalSearchView();
|
||||
},
|
||||
});
|
||||
|
||||
Template.globalSearchHeaderBar.events({
|
||||
'click .js-due-cards-view-change': Popup.open('globalSearchViewChange'),
|
||||
'click .js-global-search-view-change': Popup.open('globalSearchViewChange'),
|
||||
});
|
||||
|
||||
Template.globalSearchViewChangePopup.events({
|
||||
'click .js-global-search-view-me'() {
|
||||
Utils.setGlobalSearchView('me');
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-global-search-view-all'() {
|
||||
Utils.setGlobalSearchView('all');
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
|
||||
Template.globalSearch.onCreated(function () {
|
||||
@@ -42,27 +64,171 @@ Template.globalSearch.onCreated(function () {
|
||||
Template.globalSearch.onRendered(function () {
|
||||
Meteor.subscribe('setting');
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
//console.log('lang:', TAPi18n.getLanguage());
|
||||
const tpl = this;
|
||||
// Re-run the search reactively when the query OR the "my/all" view changes,
|
||||
// so toggling the view filters immediately without a page reload.
|
||||
this.autorun(() => {
|
||||
const view = Utils.globalSearchView(); // reactive dependency
|
||||
const queryText = Session.get('globalQuery'); // reactive dependency
|
||||
if (queryText || view === 'me') {
|
||||
searchAllBoards(tpl, queryText || '');
|
||||
}
|
||||
});
|
||||
|
||||
if (Session.get('globalQuery')) {
|
||||
searchAllBoards(this, Session.get('globalQuery'));
|
||||
}
|
||||
// #6034 (MVP): make search-result cards draggable so they can be dropped
|
||||
// into a board list/column. Re-wire whenever the results change.
|
||||
this.autorun(() => {
|
||||
tpl.search.results.get(); // reactive dependency: re-run when results render
|
||||
Tracker.afterFlush(() => setupResultDrag(tpl));
|
||||
});
|
||||
});
|
||||
|
||||
// #6034 (MVP): drag-and-drop search results into board columns.
|
||||
//
|
||||
// We make each `.global-search-results-list` result item a jquery-ui
|
||||
// `draggable` (clone helper), and make every board list's `.js-minicards`
|
||||
// container a `droppable`. On drop we resolve the dragged card and call the
|
||||
// existing `card.move(boardId, swimlaneId, listId, sort)` model method - the
|
||||
// same method the in-board card sortable uses - so the move stays consistent
|
||||
// with normal card drag-drop and is persisted + reactively re-rendered.
|
||||
//
|
||||
// LIMITATION / TODO: this MVP drops the card at the END of the target list
|
||||
// (sort = list cards length) rather than at the exact pixel-precise insertion
|
||||
// index used by the in-board minicard sortable. Pixel-precise positioning and
|
||||
// multi-select drag are intentionally out of scope here; wiring those would
|
||||
// require modifying the (non-owned) list/minicard sortable `receive` handler.
|
||||
// Drag is only enabled when the board is editable and a board is open.
|
||||
function setupResultDrag(tpl) {
|
||||
try {
|
||||
if (typeof window === 'undefined' || !window.jQuery) return;
|
||||
const $ = window.jQuery;
|
||||
|
||||
const $items = tpl.$('.global-search-results-list .result-card-wrapper');
|
||||
if (!$items.length || typeof $items.draggable !== 'function') return;
|
||||
|
||||
// Only allow DnD when we are inside a board the user can modify.
|
||||
const boardId = Session.get('currentBoard');
|
||||
if (!boardId) return;
|
||||
const canModify =
|
||||
Utils && typeof Utils.canModifyBoard === 'function'
|
||||
? Utils.canModifyBoard()
|
||||
: false;
|
||||
if (!canModify) return;
|
||||
|
||||
$items.each(function () {
|
||||
const el = this;
|
||||
// Avoid double-initialising the same element.
|
||||
if ($(el).data('uiDraggable')) return;
|
||||
|
||||
$(el).draggable({
|
||||
helper: 'clone',
|
||||
appendTo: 'body',
|
||||
revert: 'invalid',
|
||||
revertDuration: 150,
|
||||
cursor: 'grabbing',
|
||||
zIndex: 1000,
|
||||
start(evt, ui) {
|
||||
ui.helper.addClass('global-search-result-drag-helper');
|
||||
ui.helper.css('width', $(el).width());
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Make board lists accept the dropped result card.
|
||||
const $lists = $('.list .js-minicards');
|
||||
if (!$lists.length || typeof $lists.droppable !== 'function') return;
|
||||
|
||||
$lists.each(function () {
|
||||
const listEl = this;
|
||||
if ($(listEl).data('uiDroppable')) return;
|
||||
|
||||
$(listEl).droppable({
|
||||
accept: '.result-card-wrapper',
|
||||
tolerance: 'pointer',
|
||||
hoverClass: 'global-search-drop-hover',
|
||||
drop(evt, ui) {
|
||||
onResultDroppedOnList(listEl, ui.draggable.get(0));
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error wiring search-result drag-and-drop (#6034):', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the dragged result card + target list and perform the move using the
|
||||
// existing card model `move()` method (mirrors the in-board card sortable).
|
||||
function onResultDroppedOnList(listEl, draggedEl) {
|
||||
try {
|
||||
if (!listEl || !draggedEl) return;
|
||||
|
||||
// Card data from the dragged result item (Blaze data on the result wrapper,
|
||||
// falling back to the inner minicard).
|
||||
let cardData = Blaze.getData(draggedEl);
|
||||
if (!cardData || !cardData._id) {
|
||||
const inner = draggedEl.querySelector('.js-minicard');
|
||||
if (inner) cardData = Blaze.getData(inner);
|
||||
}
|
||||
if (!cardData || !cardData._id) return;
|
||||
|
||||
const card = ReactiveCache.getCard(cardData._id);
|
||||
if (!card || typeof card.move !== 'function') return;
|
||||
|
||||
// Target list / swimlane / board from the drop target.
|
||||
const listData = Blaze.getData(listEl.closest('.list') || listEl);
|
||||
if (!listData || !listData._id) return;
|
||||
const listId = listData._id;
|
||||
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
if (!currentBoard) return;
|
||||
|
||||
let swimlaneId = listData.swimlaneId;
|
||||
if (!swimlaneId) {
|
||||
const swimlaneEl = listEl.closest('.swimlane');
|
||||
if (swimlaneEl) {
|
||||
const swimlaneData = Blaze.getData(swimlaneEl);
|
||||
swimlaneId = swimlaneData && swimlaneData._id;
|
||||
}
|
||||
}
|
||||
if (!swimlaneId) {
|
||||
const def = currentBoard.getDefaultSwimline && currentBoard.getDefaultSwimline();
|
||||
swimlaneId = (def && def._id) || card.swimlaneId;
|
||||
}
|
||||
|
||||
// MVP: append to the end of the target list.
|
||||
let sort = 0;
|
||||
try {
|
||||
const existing = ReactiveCache.getCards(
|
||||
{ listId, archived: false },
|
||||
{ sort: { sort: -1 }, limit: 1 },
|
||||
);
|
||||
if (existing && existing.length && typeof existing[0].sort === 'number') {
|
||||
sort = existing[0].sort + 1;
|
||||
}
|
||||
} catch (e) {
|
||||
sort = 0;
|
||||
}
|
||||
|
||||
card.move(currentBoard._id, swimlaneId, listId, sort);
|
||||
} catch (e) {
|
||||
console.error('Error moving dropped search result (#6034):', e);
|
||||
}
|
||||
}
|
||||
|
||||
function searchAllBoards(tpl, queryText) {
|
||||
const search = tpl.search;
|
||||
|
||||
queryText = queryText.trim();
|
||||
// eslint-disable-next-line no-console
|
||||
//console.log('queryText:', queryText);
|
||||
queryText = (queryText || '').trim();
|
||||
|
||||
search.query.set(queryText);
|
||||
|
||||
search.resetSearch();
|
||||
tpl.parsingErrors = new QueryErrors();
|
||||
|
||||
if (!queryText) {
|
||||
const mine = Utils.globalSearchView() === 'me';
|
||||
|
||||
// Nothing to search: empty query and not restricting to the current user.
|
||||
if (!queryText && !mine) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -71,10 +237,17 @@ function searchAllBoards(tpl, queryText) {
|
||||
const query = new Query();
|
||||
query.buildParams(queryText);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
// console.log('params:', query.getParams());
|
||||
const queryParams = query.getQueryParams();
|
||||
|
||||
tpl.queryParams = query.getQueryParams().getParams();
|
||||
// "My cards": restrict to cards where the current user is member or assignee.
|
||||
if (mine) {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
if (currentUser && currentUser.username) {
|
||||
queryParams.addPredicate(OPERATOR_USER, currentUser.username);
|
||||
}
|
||||
}
|
||||
|
||||
tpl.queryParams = queryParams.getParams();
|
||||
|
||||
if (query.hasErrors()) {
|
||||
search.searching.set(false);
|
||||
@@ -84,7 +257,7 @@ function searchAllBoards(tpl, queryText) {
|
||||
return;
|
||||
}
|
||||
|
||||
search.runGlobalSearch(query.getQueryParams());
|
||||
search.runGlobalSearch(queryParams);
|
||||
}
|
||||
|
||||
function errorMessages(tpl) {
|
||||
|
||||
@@ -13,12 +13,12 @@
|
||||
line-height: 1.7em;
|
||||
padding: 0 10px;
|
||||
margin: 0;
|
||||
margin-right: 10px;
|
||||
float: left;
|
||||
margin-inline-end: 10px;
|
||||
float: inline-start;
|
||||
border-radius: 3px;
|
||||
}
|
||||
#header #header-main-bar h1 .board-header-watch-icon {
|
||||
padding-left: 7px;
|
||||
padding-inline-start: 7px;
|
||||
}
|
||||
#header #header-main-bar h1 a.fa,
|
||||
#header #header-main-bar h1 a i.fa {
|
||||
@@ -26,14 +26,14 @@
|
||||
}
|
||||
#header #header-main-bar h1 .back-btn {
|
||||
font-size: 0.9em;
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
#header #header-main-bar .wekan-logo {
|
||||
margin: 3px auto auto;
|
||||
width: 97px;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
#header #header-main-bar .wekan-logo:hover {
|
||||
opacity: 0.9;
|
||||
@@ -44,10 +44,10 @@
|
||||
width: auto;
|
||||
}
|
||||
#header #header-main-bar .board-header-btns.left {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
#header #header-main-bar .board-header-btns.right {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
#header #header-main-bar .board-header-btn {
|
||||
border-radius: 3px;
|
||||
@@ -55,13 +55,13 @@
|
||||
padding: 0;
|
||||
height: 28px;
|
||||
font-size: 13px;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
overflow: hidden;
|
||||
line-height: 28px;
|
||||
margin: 0 12px;
|
||||
}
|
||||
#header #header-main-bar .board-header-btn i.fa {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
display: block;
|
||||
line-height: 28px;
|
||||
color: #f2f2f2;
|
||||
@@ -70,16 +70,16 @@
|
||||
#header #header-main-bar .board-header-btn i.fa + span {
|
||||
display: inline-block;
|
||||
margin-top: 1px;
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
#header #header-main-bar .board-header-btn .board-header-btn-close {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
#header #header-main-bar .board-header-btn .board-header-btn-close i.fa {
|
||||
margin: 0 6px;
|
||||
}
|
||||
#header #header-main-bar .board-header-btn .board-header-btn-icon {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
display: block;
|
||||
line-height: 28px;
|
||||
color: #27ae60;
|
||||
@@ -100,9 +100,9 @@
|
||||
}
|
||||
#header #header-main-bar .separator {
|
||||
margin: 2px 4px;
|
||||
border-left: 1px solid rgba(255,255,255,0.3);
|
||||
border-inline-start: 1px solid rgba(255,255,255,0.3);
|
||||
height: 24px;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
|
||||
/* On non-board pages (All Boards / home, public, my cards, …) the second bar
|
||||
@@ -141,7 +141,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
#header-quick-access .home-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 1rem;
|
||||
margin-inline-end: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
|
||||
#header-quick-access .home-icon .fa-home {
|
||||
font-size: 16px;
|
||||
margin-right: 4px;
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
|
||||
#header-quick-access .allBoards {
|
||||
@@ -257,10 +257,10 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
}
|
||||
#header-quick-access ul.header-quick-access-list li:first-child .fa-home,
|
||||
#header-quick-access ul.header-quick-access-list li:nth-child(3) .fa-globe {
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
#header-quick-access ul.header-quick-access-list li a.js-create-board {
|
||||
margin-left: 5px;
|
||||
margin-inline-start: 5px;
|
||||
}
|
||||
#header-quick-access #header-user-bar,
|
||||
#header-quick-access #header-new-board-icon {
|
||||
@@ -270,10 +270,10 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
margin: 2px 0;
|
||||
}
|
||||
#header-quick-access #header-user-bar .header-user-bar-avatar {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
position: relative;
|
||||
top: -5px;
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
#header-quick-access #header-user-bar .header-user-bar-avatar .member,
|
||||
#header-quick-access #header-help {
|
||||
@@ -286,7 +286,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
#header-quick-access #header-user-bar .header-user-bar-name,
|
||||
#header-quick-access #header-help {
|
||||
margin: 4px 8px 0 0;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
|
||||
/* Zoom Controls in Header */
|
||||
@@ -299,7 +299,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
border-radius: 0.5vw;
|
||||
box-shadow: 0 0.2vh 0.5vh rgba(0,0,0,0.1);
|
||||
margin: 0 1vw;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
|
||||
#header-quick-access .zoom-controls .board-header-btn {
|
||||
@@ -401,7 +401,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 1vw;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
|
||||
#header-quick-access .mobile-mode-toggle .board-header-btn {
|
||||
@@ -486,7 +486,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
transform: scale(1.1) !important;
|
||||
}
|
||||
#header-quick-access #header-user-bar .header-user-bar-name i.fa-chevron-down {
|
||||
margin-right: 4px;
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
#header-quick-access #header-new-board-icon {
|
||||
flex-grow: 1;
|
||||
@@ -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;
|
||||
@@ -522,12 +526,12 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
|
||||
/* Mobile home icon styling */
|
||||
#header-quick-access .home-icon {
|
||||
margin-right: 0.5rem;
|
||||
margin-inline-end: 0.5rem;
|
||||
}
|
||||
|
||||
#header-quick-access .home-icon .fa-home {
|
||||
font-size: 16px;
|
||||
margin-right: 4px;
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
|
||||
#header-quick-access .home-icon a {
|
||||
@@ -550,11 +554,11 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
|
||||
#header-quick-access .home-icon .fa-home {
|
||||
font-size: 14px;
|
||||
margin-right: 3px;
|
||||
margin-inline-end: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
@@ -612,7 +616,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
/* Mobile header wrapping and spacing */
|
||||
#header-quick-access .home-icon {
|
||||
flex-shrink: 0 !important;
|
||||
margin-right: 0.5rem !important;
|
||||
margin-inline-end: 0.5rem !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -808,7 +812,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
/* iPhone 12 Mini header wrapping and spacing */
|
||||
#header-quick-access .home-icon {
|
||||
flex-shrink: 0 !important;
|
||||
margin-right: 0.5rem !important;
|
||||
margin-inline-end: 0.5rem !important;
|
||||
margin-bottom: 6px !important;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
@@ -868,11 +872,11 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
|
||||
#header-quick-access .home-icon .fa-home {
|
||||
font-size: 20px !important; /* Much larger icon */
|
||||
margin-right: 6px !important;
|
||||
margin-inline-end: 6px !important;
|
||||
}
|
||||
|
||||
#header-quick-access .home-icon {
|
||||
margin-right: 1rem !important;
|
||||
margin-inline-end: 1rem !important;
|
||||
}
|
||||
|
||||
/* Make zoom controls larger */
|
||||
@@ -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 {
|
||||
@@ -956,7 +960,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
/* iPhone device header wrapping and spacing */
|
||||
.iphone-device #header-quick-access .home-icon {
|
||||
flex-shrink: 0 !important;
|
||||
margin-right: 0.5rem !important;
|
||||
margin-inline-end: 0.5rem !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -1057,7 +1061,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
/* iPhone 12 Mini header wrapping and spacing - JavaScript fallback */
|
||||
.iphone-device #header-quick-access .home-icon {
|
||||
flex-shrink: 0 !important;
|
||||
margin-right: 0.5rem !important;
|
||||
margin-inline-end: 0.5rem !important;
|
||||
margin-bottom: 6px !important;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -1167,7 +1171,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
|
||||
#header-quick-access ul {
|
||||
width: calc(100% - 60px);
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
#header-quick-access ul li {
|
||||
height: 100%;
|
||||
@@ -1179,7 +1183,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
display: none;
|
||||
}
|
||||
#header-quick-access #header-user-bar {
|
||||
right: auto;
|
||||
inset-inline-end: auto;
|
||||
padding: 10px;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
@@ -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. */
|
||||
@@ -1261,7 +1265,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
display: none;
|
||||
}
|
||||
#header #header-user-bar {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
}
|
||||
.announcement .viewer {
|
||||
|
||||
@@ -5,7 +5,7 @@ template(name="header")
|
||||
Reddit "subreddit" bar.
|
||||
The first link goes to the boards page.
|
||||
if currentUser
|
||||
#header-quick-access(class=currentBoard.colorClass)
|
||||
#header-quick-access(class=currentBoard.colorClass role="navigation" aria-label="{{_ 'all-boards'}}")
|
||||
// Home icon - always at left side of logo
|
||||
span.home-icon.allBoards
|
||||
a(href="{{pathFor 'home'}}")
|
||||
@@ -108,14 +108,13 @@ template(name="header")
|
||||
|
||||
if currentUser.isBoardMember
|
||||
if hasAnnouncement
|
||||
.announcement
|
||||
.announcement(role="status" aria-live="polite")
|
||||
p
|
||||
i.fa.fa-bullhorn
|
||||
i.fa.fa-bullhorn(aria-hidden="true")
|
||||
+viewer
|
||||
| #{announcement}
|
||||
a
|
||||
.js-close-announcement
|
||||
i.fa.fa-times-thin
|
||||
a.js-close-announcement(aria-label="{{_ 'close'}}" title="{{_ 'close'}}")
|
||||
i.fa.fa-times-thin(aria-hidden="true")
|
||||
|
||||
template(name="offlineWarning")
|
||||
.offline-warning
|
||||
|
||||
@@ -2,7 +2,10 @@ import { Meteor } from 'meteor/meteor';
|
||||
import { Session } from 'meteor/session';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import Announcements from '/models/announcements';
|
||||
import Announcements, {
|
||||
announcementVersion,
|
||||
shouldShowAnnouncement,
|
||||
} from '/models/announcements';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
|
||||
Meteor.subscribe('user-admin');
|
||||
@@ -71,14 +74,25 @@ Template.header.helpers({
|
||||
},
|
||||
|
||||
hasAnnouncement() {
|
||||
const announcements = Announcements.findOne();
|
||||
return announcements && announcements.enabled;
|
||||
const announcement = Announcements.findOne();
|
||||
if (!announcement || !announcement.enabled) {
|
||||
return false;
|
||||
}
|
||||
const version = announcementVersion(announcement);
|
||||
const user = Meteor.user();
|
||||
const dismissedVersion =
|
||||
(user && user.profile && user.profile.dismissedAnnouncementVersion) || null;
|
||||
return shouldShowAnnouncement({
|
||||
enabled: announcement.enabled,
|
||||
version,
|
||||
dismissedVersion,
|
||||
});
|
||||
},
|
||||
|
||||
announcement() {
|
||||
$('.announcement').show();
|
||||
const announcements = Announcements.findOne();
|
||||
return announcements && announcements.body;
|
||||
const announcement = Announcements.findOne();
|
||||
return announcement && announcement.body;
|
||||
},
|
||||
|
||||
zoomLevel() {
|
||||
@@ -150,6 +164,13 @@ Template.header.events({
|
||||
},
|
||||
'click .js-close-announcement'() {
|
||||
$('.announcement').hide();
|
||||
// Permanently dismiss the current announcement for this user (#6051).
|
||||
// The banner reappears only when the admin changes the announcement text.
|
||||
Meteor.call('dismissAnnouncement', (err) => {
|
||||
if (err && process.env.DEBUG === 'true') {
|
||||
console.error('dismissAnnouncement error', err);
|
||||
}
|
||||
});
|
||||
},
|
||||
'click .js-select-list'() {
|
||||
Session.set('currentList', this._id);
|
||||
@@ -172,7 +193,7 @@ Template.header.events({
|
||||
if (Utils.isMiniScreen()) {
|
||||
FlowRouter.go('bookmarks');
|
||||
} else {
|
||||
Popup.open('bookmarksPopup')(evt);
|
||||
Popup.open('bookmarks')(evt);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
}
|
||||
.shortcuts-list .shortcuts-list-item .shortcuts-list-item-keys {
|
||||
margin-top: 5px;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
.shortcuts-list .shortcuts-list-item .shortcuts-list-item-keys kbd {
|
||||
padding: 5px 8px;
|
||||
|
||||
Loaded 100 of 793 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user