mirror of
https://github.com/wekan/wekan.git
synced 2026-11-09 03:45:00 -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.1.
|
||||
#
|
||||
# So pin them here until Meteor 3.6 + Rspack 2.0 land, then revisit.
|
||||
- dependency-name: "@meteorjs/rspack"
|
||||
- dependency-name: "@rspack/core"
|
||||
- dependency-name: "@rspack/cli"
|
||||
- dependency-name: "@rsdoctor/rspack-plugin"
|
||||
- dependency-name: "css-loader"
|
||||
- dependency-name: "style-loader"
|
||||
# Playwright end-to-end test harness — pure test-only tooling that never ships in
|
||||
# WeKan. It now depends only on @playwright/test and the official `mongodb` driver
|
||||
# (the heavy, CVE-prone `mongosh` CLI was replaced by helpers/mongo-runner.js).
|
||||
# Watch this directory so those two stay patched.
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/tests/playwright"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -53,9 +53,13 @@ jobs:
|
||||
- name: Run Meteor unit tests
|
||||
# First Meteor build is slow; allow time before the test driver starts.
|
||||
timeout-minutes: 45
|
||||
# NOTE: do not set TEST_WATCH here. meteortesting:mocha computes
|
||||
# `testWatch: TEST_WATCH || …`, and the string '0' is truthy in JS, so
|
||||
# TEST_WATCH=0 turns ON watch mode and the process never exits — that is
|
||||
# what made this job hang until the 45-minute timeout. With TEST_WATCH
|
||||
# unset, `meteor test --once` runs the server tests and exits with the
|
||||
# correct status (matching the local rebuild-wekan.sh run).
|
||||
run: meteor test --once --driver-package meteortesting:mocha
|
||||
env:
|
||||
TEST_WATCH: '0'
|
||||
|
||||
e2e-tests:
|
||||
name: Playwright E2E (${{ matrix.project }})
|
||||
@@ -64,8 +68,9 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
project: [chromium, firefox]
|
||||
# webkit excluded from CI (requires macOS runner) — run locally with npm run test:e2e:all
|
||||
# Playwright bundles Chromium, Firefox and WebKit builds that all run
|
||||
# headless on the Linux runner (installed per-job below with --with-deps).
|
||||
project: [chromium, firefox, webkit]
|
||||
|
||||
services:
|
||||
mongodb:
|
||||
@@ -151,6 +156,9 @@ jobs:
|
||||
env:
|
||||
WEKAN_BASE_URL: http://localhost:3000
|
||||
WEKAN_MONGO_URL: mongodb://localhost:27017/wekan_test
|
||||
# Make the firefox and webkit projects available to --project
|
||||
# (playwright.config.js only defines them when this is set).
|
||||
WEKAN_PLAYWRIGHT_ALL: '1'
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
|
||||
@@ -7,9 +7,13 @@ name: Release All Platforms
|
||||
# Or use the convenience script (bumps versions, commits, pushes, then triggers):
|
||||
# ./releases/release-all.sh 9.35 9.36
|
||||
#
|
||||
# Builds only the platforms MongoDB ships server binaries for (amd64, arm64,
|
||||
# win64, mac); MongoDB 7.x is the default database. s390x/ppc64le are not built
|
||||
# because MongoDB has no server binaries for them.
|
||||
# The Docker image and snap cover the platforms MongoDB ships server binaries for
|
||||
# (amd64, arm64; win64 + mac bundles too); MongoDB 7.x is the default database.
|
||||
# Additionally, WeKan server bundles are built for every other architecture
|
||||
# Node.js 24.x publishes binaries for — s390x and ppc64le — and attached to the
|
||||
# GitHub Release as extra downloads (run a separate MongoDB for those). These
|
||||
# extra-arch bundles are NOT part of the Docker image or snap. (armv7l/32-bit ARM
|
||||
# is not built: Node.js 24 ships no armv7l binaries.)
|
||||
#
|
||||
# This workflow does EVERYTHING for a release remotely (nothing is built or
|
||||
# pushed on the maintainer's machine), running jobs in parallel where possible:
|
||||
@@ -20,9 +24,14 @@ name: Release All Platforms
|
||||
# - website: updates the wekan/wekan.fi repo (install page + API docs) and pushes
|
||||
# - charts: updates and publishes the wekan/charts Helm chart and pushes
|
||||
# - build-*: builds the amd64 bundle (full Meteor 3 build), then rebuilds native
|
||||
# modules for arm64, win64, mac-arm64 from that amd64 base
|
||||
# modules for arm64 from that amd64 base
|
||||
# - release: creates the GitHub Release with the newest CHANGELOG.md notes and
|
||||
# uploads every wekan-<version>-<platform>.zip bundle
|
||||
# uploads the core wekan-<version>-{amd64,arm64}.zip bundles
|
||||
# - build-win64 / build-mac-arm64 / build-extra-arches: built AFTER release, each
|
||||
# rebuilds the amd64 bundle's native modules for its platform (Windows,
|
||||
# macOS arm64, and QEMU-emulated s390x/ppc64le) and attaches its
|
||||
# .zip to the Release as an extra download. Decoupled from `release` so
|
||||
# a slow/flaky build never blocks it; not in the Docker image or snap
|
||||
# - docker: builds and pushes the multi-arch image to Docker Hub, Quay.io, GHCR
|
||||
# - snap: builds the snap on Launchpad (amd64 + arm64), pushes it to the Snap
|
||||
# Store and attaches the .snap files to the GitHub Release
|
||||
@@ -224,7 +233,7 @@ jobs:
|
||||
path: charts
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@v4
|
||||
uses: azure/setup-helm@v5
|
||||
|
||||
- name: Fetch gh-pages branch (release-charts.sh switches to it)
|
||||
run: |
|
||||
@@ -357,9 +366,43 @@ jobs:
|
||||
path: wekan-${{ needs.prepare.outputs.version }}-arm64.zip
|
||||
retention-days: 1
|
||||
|
||||
# ─── 5. Build win64 bundle (rebuild native modules on Windows) ───────────────
|
||||
# ─── 7. Create GitHub Release and upload all bundles ────────────────────────
|
||||
release:
|
||||
needs:
|
||||
- prepare
|
||||
- build-amd64
|
||||
- build-arm64
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- name: Download all bundles
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: bundle-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create GitHub Release with all bundles
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: v${{ needs.prepare.outputs.version }}
|
||||
name: v${{ needs.prepare.outputs.version }}
|
||||
body: ${{ needs.prepare.outputs.changelog }}
|
||||
files: |
|
||||
wekan-${{ needs.prepare.outputs.version }}-amd64.zip
|
||||
wekan-${{ needs.prepare.outputs.version }}-arm64.zip
|
||||
|
||||
# ─── 7b. Extra-platform bundles (win64, mac-arm64, s390x, ppc64le) ───────────
|
||||
# Every bundle below is built AFTER `release` and attaches its own .zip to the
|
||||
# already-created GitHub Release with `gh release upload --clobber`, rather than
|
||||
# being a prerequisite of `release`. This deliberately decouples the slower /
|
||||
# different-OS builds (Windows, macOS, QEMU-emulated arches) from the core
|
||||
# amd64/arm64 release, Docker and snap, so a slow or flaky one never blocks them.
|
||||
# These bundles are NOT part of the Docker image or snap.
|
||||
|
||||
# Build win64 bundle (rebuild native modules on Windows).
|
||||
build-win64:
|
||||
needs: [prepare, build-amd64]
|
||||
needs: [prepare, build-amd64, release]
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -455,9 +498,19 @@ jobs:
|
||||
path: wekan-${{ needs.prepare.outputs.version }}-win64.zip
|
||||
retention-days: 1
|
||||
|
||||
# ─── 6. Build mac-arm64 bundle (rebuild native modules on Apple Silicon) ─────
|
||||
- name: Attach win64 bundle to the GitHub Release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# The repo is checked out into src/, so the workspace root is not a git
|
||||
# repository; set GH_REPO so `gh` targets wekan/wekan without relying on
|
||||
# git remote detection (else: "fatal: not a git repository").
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: gh release upload "v${VERSION}" "wekan-${VERSION}-win64.zip" --clobber
|
||||
|
||||
# Build mac-arm64 bundle (rebuild native modules on Apple Silicon).
|
||||
build-mac-arm64:
|
||||
needs: [prepare, build-amd64]
|
||||
needs: [prepare, build-amd64, release]
|
||||
runs-on: macos-14 # macos-14 runners are Apple Silicon (arm64)
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -503,35 +556,88 @@ jobs:
|
||||
path: wekan-${{ needs.prepare.outputs.version }}-mac-arm64.zip
|
||||
retention-days: 1
|
||||
|
||||
# ─── 7. Create GitHub Release and upload all bundles ────────────────────────
|
||||
release:
|
||||
needs:
|
||||
- prepare
|
||||
- build-amd64
|
||||
- build-arm64
|
||||
- build-win64
|
||||
- build-mac-arm64
|
||||
runs-on: ubuntu-latest
|
||||
- name: Attach mac-arm64 bundle to the GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# No repo checkout in this job, so set GH_REPO so `gh` knows the target
|
||||
# repository instead of trying (and failing) to detect a git remote.
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: gh release upload "v${VERSION}" "wekan-${VERSION}-mac-arm64.zip" --clobber
|
||||
|
||||
# Extra-architecture Linux bundles (s390x, ppc64le).
|
||||
# armv7l (32-bit ARM) is intentionally excluded: Node.js 24 ships no armv7l
|
||||
# binaries (official or unofficial), so there is no node:24 linux/arm/v7 image
|
||||
# to rebuild native modules against and no Node 24 runtime to run a bundle on
|
||||
# an armv7 device. See https://nodejs.org/dist/latest-v24.x/ (no -linux-armv7l).
|
||||
build-extra-arches:
|
||||
needs: [prepare, build-amd64, release]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 300
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: s390x
|
||||
platform: linux/s390x
|
||||
- arch: ppc64le
|
||||
platform: linux/ppc64le
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- name: Download all bundles
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y curl unzip zip
|
||||
|
||||
- name: Set up QEMU for ${{ matrix.arch }} emulation
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Download amd64 bundle
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: bundle-*
|
||||
merge-multiple: true
|
||||
name: bundle-amd64
|
||||
|
||||
- name: Create GitHub Release with all bundles
|
||||
uses: softprops/action-gh-release@v3
|
||||
- name: Rebuild native modules for ${{ matrix.arch }} under emulation
|
||||
run: |
|
||||
unzip -q wekan-${VERSION}-amd64.zip
|
||||
# Same bcrypt fix as the arm64 build: bcrypt 5.0.1 (Meteor
|
||||
# accounts-password) ships node-addon-api@3 which fails to compile on
|
||||
# Node.js 24. Swap in bcrypt 6.0.0 source; the emulated npm install
|
||||
# below compiles it (and any other native module) for the target arch.
|
||||
BCRYPT_DIR="$(pwd)/bundle/programs/server/npm/node_modules/meteor/accounts-password/node_modules/bcrypt"
|
||||
if [ -d "$BCRYPT_DIR" ]; then
|
||||
rm -rf "$BCRYPT_DIR"
|
||||
TMP=$(mktemp -d)
|
||||
pushd "$TMP" > /dev/null
|
||||
npm pack bcrypt@6.0.0
|
||||
mkdir bcrypt && tar xzf bcrypt-*.tgz --strip-components=1 -C bcrypt
|
||||
cd bcrypt && npm install --ignore-scripts && cd ..
|
||||
cp -a bcrypt "$BCRYPT_DIR"
|
||||
popd > /dev/null
|
||||
rm -rf "$TMP"
|
||||
fi
|
||||
docker run --rm --platform ${{ matrix.platform }} \
|
||||
-v "$(pwd)/bundle:/bundle" \
|
||||
-w /bundle/programs/server \
|
||||
node:${NODE_VERSION}-slim \
|
||||
sh -c "apt-get update -qq && apt-get install -y -q build-essential python3 && npm install"
|
||||
|
||||
- name: Create ${{ matrix.arch }} zip
|
||||
run: zip -r wekan-${VERSION}-${{ matrix.arch }}.zip bundle
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
tag_name: v${{ needs.prepare.outputs.version }}
|
||||
name: v${{ needs.prepare.outputs.version }}
|
||||
body: ${{ needs.prepare.outputs.changelog }}
|
||||
files: |
|
||||
wekan-${{ needs.prepare.outputs.version }}-amd64.zip
|
||||
wekan-${{ needs.prepare.outputs.version }}-arm64.zip
|
||||
wekan-${{ needs.prepare.outputs.version }}-win64.zip
|
||||
wekan-${{ needs.prepare.outputs.version }}-mac-arm64.zip
|
||||
name: bundle-${{ matrix.arch }}
|
||||
path: wekan-${{ needs.prepare.outputs.version }}-${{ matrix.arch }}.zip
|
||||
retention-days: 1
|
||||
|
||||
- name: Attach ${{ matrix.arch }} bundle to the GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# No repo checkout in this job, so set GH_REPO so `gh` knows the target
|
||||
# repository instead of trying (and failing) to detect a git remote.
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: gh release upload "v${VERSION}" "wekan-${VERSION}-${{ matrix.arch }}.zip" --clobber
|
||||
|
||||
# ─── 8. Build and push the multi-arch Docker image to all registries ────────
|
||||
# Mirrors releases/docker-build.sh step 3 (multi-arch buildx --push).
|
||||
@@ -552,7 +658,7 @@ jobs:
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to Docker Hub, Quay.io and GHCR
|
||||
env:
|
||||
@@ -572,10 +678,81 @@ jobs:
|
||||
login quay.io "$QUAY_AUTH"
|
||||
login ghcr.io "$GHCR_AUTH"
|
||||
|
||||
- name: Wait for release bundles to be downloadable
|
||||
# The Dockerfile downloads wekan-<version>-<arch>.zip from the GitHub
|
||||
# Release for each built platform. Even though this job needs `release`
|
||||
# (so the assets are uploaded), GitHub's releases/download/<tag>/<asset>
|
||||
# URL can briefly 404 right after upload (CDN/propagation lag), which used
|
||||
# to fail the multi-arch build mid-way. Poll here until both the amd64 and
|
||||
# arm64 bundles return 200, so the (expensive, emulated) buildx run only
|
||||
# starts once the assets are actually fetchable. The Dockerfile also
|
||||
# retries per-arch as a second layer of safety.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base="https://github.com/wekan/wekan/releases/download/v${VERSION}"
|
||||
for arch in amd64 arm64; do
|
||||
url="${base}/wekan-${VERSION}-${arch}.zip"
|
||||
echo "Waiting for ${url} ..."
|
||||
ok=0
|
||||
for i in $(seq 1 30); do
|
||||
code=$(curl -sSL -o /dev/null -w '%{http_code}' --retry 0 -I "$url" || echo 000)
|
||||
if [ "$code" = "200" ]; then
|
||||
echo " available (HTTP $code) after $((i*10))s"
|
||||
ok=1
|
||||
break
|
||||
fi
|
||||
echo " attempt $i/30: HTTP $code, retrying in 10s..."
|
||||
sleep 10
|
||||
done
|
||||
if [ "$ok" -ne 1 ]; then
|
||||
echo "::error::Release asset not downloadable after retries: $url"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Verify the release bundle reports the release version
|
||||
# Belt-and-suspenders against the "image tagged vX shows vY in the Admin
|
||||
# Panel" bug: the image is just the unzipped wekan-<version>-<arch>.zip
|
||||
# bundle (Dockerfile moves it to /build), and the Admin Panel shows that
|
||||
# bundle's app package.json version (server/statistics.js reads
|
||||
# require('/package.json').version). Assert the amd64 bundle's WeKan
|
||||
# version equals the release version BEFORE the expensive multi-arch
|
||||
# buildx, so a stale/mismatched bundle fails fast with a clear message
|
||||
# instead of shipping (and pushing to three registries) a mislabeled image.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
url="https://github.com/wekan/wekan/releases/download/v${VERSION}/wekan-${VERSION}-amd64.zip"
|
||||
wget -q "$url" -O bundle.zip
|
||||
tmp="$(mktemp -d)"
|
||||
# Extract only package.json files (not the whole multi-hundred-MB bundle).
|
||||
unzip -o -q bundle.zip '*package.json' -d "$tmp"
|
||||
# The WeKan app package.json is the only one whose "version" carries a
|
||||
# leading 'v' (e.g. "v9.61.0"); every dependency uses plain semver. So a
|
||||
# v-prefixed "version" uniquely identifies the app's own package.json.
|
||||
found="$(grep -rhoE '"version": "v[0-9][^"]*"' "$tmp" \
|
||||
| grep -oE 'v[0-9][^"]*' | sort -u | head -1 || true)"
|
||||
rm -rf "$tmp" bundle.zip
|
||||
if [ -z "$found" ]; then
|
||||
echo "::error::Could not find a v-prefixed WeKan version in the bundle's package.json files."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$found" != "v${VERSION}.0" ] && [ "$found" != "v${VERSION}" ]; then
|
||||
echo "::error::Bundle reports app version $found but the release is v${VERSION}. The bundle baked into the image is stale/mismatched — the Admin Panel would show $found. Aborting before build."
|
||||
exit 1
|
||||
fi
|
||||
echo "Bundle app version $found matches release v${VERSION}."
|
||||
|
||||
- name: Build and push multi-arch image
|
||||
# Pass VERSION explicitly as a build-arg. The Dockerfile's `ARG VERSION`
|
||||
# default is only a fallback for manual builds; the release version is the
|
||||
# authoritative source here. Without this the image would build against the
|
||||
# Dockerfile default (downloading that version's wekan-<v>-<arch>.zip and
|
||||
# baking its package.json), so a v${VERSION}-tagged image could ship — and
|
||||
# report in the Admin Panel — a stale, different version.
|
||||
run: |
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--build-arg VERSION=${VERSION} \
|
||||
-t wekanteam/wekan:v${VERSION} \
|
||||
-t wekanteam/wekan:latest \
|
||||
-t quay.io/wekan/wekan:v${VERSION} \
|
||||
@@ -613,15 +790,74 @@ jobs:
|
||||
printf '%s' "$LP_CREDENTIALS" | base64 -d > ~/.local/share/snapcraft/launchpad-credentials
|
||||
chmod 600 ~/.local/share/snapcraft/launchpad-credentials
|
||||
|
||||
- name: Build snaps on Launchpad (amd64 + arm64)
|
||||
- name: Verify snapcraft.yaml version matches the release
|
||||
# The `bump` job (releases/version.sh) is the single source of truth for
|
||||
# the snap version and updates snapcraft.yaml before the tag is created.
|
||||
# snapcraft.yaml's `version:` names the produced files
|
||||
# (wekan_<version>_<arch>.snap), so if a stale tag carried the wrong
|
||||
# version the build would produce wekan_<old>_*.snap and the upload/attach
|
||||
# globs (wekan_${VERSION}_*.snap) would match nothing — passing the literal
|
||||
# glob to `snapcraft upload` ("is not a valid file", exit 64). Assert it
|
||||
# here so that mismatch fails fast (before a ~20 min Launchpad build) with
|
||||
# a clear pointer, instead of re-bumping the version inside this build job.
|
||||
run: |
|
||||
snapcraft remote-build --launchpad-accept-public-upload
|
||||
set -euo pipefail
|
||||
if ! grep -qxF "version: '${VERSION}'" snapcraft.yaml; then
|
||||
echo "::error::snapcraft.yaml is not at version '${VERSION}' ($(grep -m1 '^version:' snapcraft.yaml)). The bump job (releases/version.sh) did not update the tagged commit."
|
||||
exit 1
|
||||
fi
|
||||
echo "snapcraft.yaml is at version '${VERSION}'."
|
||||
|
||||
- name: Build snaps on Launchpad (amd64 + arm64)
|
||||
# `snapcraft remote-build` is experimental and talks to Launchpad over
|
||||
# HTTPS after pushing the whole repo; it intermittently dies with a
|
||||
# transient TLS drop (e.g. "SSLEOFError: EOF occurred in violation of
|
||||
# protocol", exit 70) that has nothing to do with the build itself.
|
||||
# Retry a few times so one network hiccup doesn't fail the release.
|
||||
run: |
|
||||
set -uo pipefail
|
||||
shopt -s nullglob
|
||||
attempts=3
|
||||
for i in $(seq 1 "$attempts"); do
|
||||
echo "::group::Remote build attempt $i/$attempts"
|
||||
snapcraft remote-build --launchpad-accept-public-upload && rc=0 || rc=$?
|
||||
echo "::endgroup::"
|
||||
# snapcraft remote-build can exit 0 even when the Launchpad build
|
||||
# FAILED (it just downloads the build logs and produces no .snap), so
|
||||
# don't trust the exit code alone — require that .snap files exist.
|
||||
snaps=( wekan_${VERSION}_*.snap )
|
||||
if [ "$rc" -eq 0 ] && [ "${#snaps[@]}" -gt 0 ]; then
|
||||
echo "Remote build succeeded on attempt $i: ${snaps[*]}"
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $i: snapcraft exit=$rc, .snap files produced=${#snaps[@]}."
|
||||
# Surface the Launchpad build logs so a real build failure is
|
||||
# diagnosable here, instead of as a later cryptic upload error.
|
||||
for log in snapcraft-wekan-*.txt; do
|
||||
echo "::group::Launchpad build log: $log (last 150 lines)"
|
||||
tail -n 150 "$log"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
if [ "$i" -lt "$attempts" ]; then
|
||||
echo "Retrying in 60s..."
|
||||
sleep 60
|
||||
fi
|
||||
done
|
||||
echo "::error::Remote build failed after $attempts attempts (no wekan_${VERSION}_*.snap produced)." >&2
|
||||
exit 1
|
||||
|
||||
- name: Push snaps to the Snap Store (edge + beta channels)
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_AUTH }}
|
||||
run: |
|
||||
for f in wekan_${VERSION}_*.snap; do
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
snaps=( wekan_${VERSION}_*.snap )
|
||||
if [ "${#snaps[@]}" -eq 0 ]; then
|
||||
echo "::error::No wekan_${VERSION}_*.snap files to upload."
|
||||
exit 1
|
||||
fi
|
||||
for f in "${snaps[@]}"; do
|
||||
echo "Uploading $f to the Snap Store (edge,beta)..."
|
||||
snapcraft upload --release=edge,beta "$f"
|
||||
done
|
||||
@@ -630,4 +866,11 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release upload "v${VERSION}" wekan_${VERSION}_*.snap --clobber
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
snaps=( wekan_${VERSION}_*.snap )
|
||||
if [ "${#snaps[@]}" -eq 0 ]; then
|
||||
echo "::error::No wekan_${VERSION}_*.snap files to attach."
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "v${VERSION}" "${snaps[@]}" --clobber
|
||||
@@ -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
|
||||
+1605
File diff suppressed because it is too large.
Load diff
+20
-3
@@ -7,14 +7,14 @@ LABEL org.opencontainers.image.source="https://github.com/wekan/wekan"
|
||||
# TARGETARCH and TARGETVARIANT are automatically provided by Docker Buildx
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG VERSION=9.40
|
||||
ARG VERSION=9.63
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
|
||||
|
||||
ENV \
|
||||
DEBUG=false \
|
||||
NODE_VERSION=v24.16.0 \
|
||||
NODE_VERSION=v24.17.0 \
|
||||
METEOR_RELEASE=METEOR@3.5-beta.12 \
|
||||
USE_EDGE=false \
|
||||
NPM_VERSION=11.11.0 \
|
||||
@@ -42,6 +42,7 @@ ENV \
|
||||
IMAGE_COMPRESS_RATIO="" \
|
||||
NOTIFICATION_TRAY_AFTER_READ_DAYS_BEFORE_REMOVE="" \
|
||||
BIGEVENTS_PATTERN=NONE \
|
||||
NOTIFY_ON_ASSIGN="true" \
|
||||
NOTIFY_DUE_DAYS_BEFORE_AND_AFTER="" \
|
||||
NOTIFY_DUE_AT_HOUR_OF_DAY="" \
|
||||
EMAIL_NOTIFICATION_TIMEOUT=30000 \
|
||||
@@ -68,6 +69,8 @@ ENV \
|
||||
OAUTH2_TOKEN_ENDPOINT="" \
|
||||
OAUTH2_ID_MAP="" \
|
||||
OAUTH2_USERNAME_MAP="" \
|
||||
OAUTH2_AUTO_REGISTRATION="true" \
|
||||
OAUTH2_ADMIN_GROUPS="" \
|
||||
OAUTH2_FULLNAME_MAP="" \
|
||||
OAUTH2_ID_TOKEN_WHITELIST_FIELDS="" \
|
||||
OAUTH2_REQUEST_PERMISSIONS='openid profile email' \
|
||||
@@ -93,6 +96,7 @@ ENV \
|
||||
LDAP_BACKGROUND_SYNC_INTERVAL="" \
|
||||
LDAP_BACKGROUND_SYNC_KEEP_EXISTANT_USERS_UPDATED=false \
|
||||
LDAP_BACKGROUND_SYNC_IMPORT_NEW_USERS=false \
|
||||
LDAP_BACKGROUND_SYNC_DISABLE_NONEXISTANT_USERS=false \
|
||||
LDAP_ENCRYPTION=false \
|
||||
LDAP_CA_CERT="" \
|
||||
LDAP_REJECT_UNAUTHORIZED=false \
|
||||
@@ -122,10 +126,16 @@ ENV \
|
||||
LDAP_DEFAULT_DOMAIN="" \
|
||||
LDAP_SYNC_ADMIN_STATUS="" \
|
||||
LDAP_SYNC_ADMIN_GROUPS="" \
|
||||
LDAP_SYNC_ORGANIZATIONS=false \
|
||||
LDAP_SYNC_ORGANIZATIONS_GROUPS="" \
|
||||
LDAP_SYNC_TEAMS=false \
|
||||
LDAP_SYNC_TEAMS_GROUPS="" \
|
||||
HEADER_LOGIN_ID="" \
|
||||
HEADER_LOGIN_FIRSTNAME="" \
|
||||
HEADER_LOGIN_LASTNAME="" \
|
||||
HEADER_LOGIN_EMAIL="" \
|
||||
HEADER_LOGIN_TRUSTED_IPS="" \
|
||||
HEADER_LOGIN_TRUSTED_PROXIES="" \
|
||||
LOGOUT_WITH_TIMER=false \
|
||||
LOGOUT_IN="" \
|
||||
LOGOUT_ON_HOURS="" \
|
||||
@@ -205,7 +215,14 @@ ln -sf $(which bsdtar) $(which tar)
|
||||
# WeKan Bundle Installation
|
||||
mkdir -p /home/wekan/app
|
||||
cd /home/wekan/app
|
||||
wget "https://github.com/wekan/wekan/releases/download/v${VERSION}/wekan-${VERSION}-${WEKAN_ARCH}.zip"
|
||||
# Retry the release-asset download: even though the CI `docker` job needs the
|
||||
# `release` job (so the asset is already uploaded), GitHub's
|
||||
# releases/download/<tag>/<asset> URL can briefly return 404 right after upload
|
||||
# (CDN/propagation lag). A plain wget treats 404 as fatal, which failed the
|
||||
# build; retry on transient HTTP errors so propagation lag no longer breaks it.
|
||||
WEKAN_ZIP_URL="https://github.com/wekan/wekan/releases/download/v${VERSION}/wekan-${VERSION}-${WEKAN_ARCH}.zip"
|
||||
wget --tries=20 --waitretry=20 --retry-on-http-error=404,403,500,502,503 "${WEKAN_ZIP_URL}" \
|
||||
|| { echo "Failed to download ${WEKAN_ZIP_URL} after retries"; exit 8; }
|
||||
unzip "wekan-${VERSION}-${WEKAN_ARCH}.zip"
|
||||
rm "wekan-${VERSION}-${WEKAN_ARCH}.zip"
|
||||
npm install --prefix ./bundle/programs/server
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
|
||||
appVersion: "v9.40.0"
|
||||
appVersion: "v9.63.0"
|
||||
files:
|
||||
userUploads:
|
||||
- README.md
|
||||
|
||||
@@ -70,12 +70,24 @@ If *nix: chmod +x api.py => ./api.py users
|
||||
python3 api.py listcardattachments BOARDID SWIMLANEID LISTID CARDID # List attachments for specific card
|
||||
python3 api.py copymoveattachment ATTACHMENTID TARGETBOARDID TARGETSWIMLANEID TARGETLISTID TARGETCARDID [copy|move] # Copy or move attachment
|
||||
python3 api.py deleteattachment ATTACHMENTID # Delete attachment
|
||||
python3 api.py uploadbackground BOARDID FILEPATH # Upload a board background image (uses Admin Panel default storage)
|
||||
python3 api.py downloadbackground BOARDID OUTPUTPATH # Download the board's current background image
|
||||
python3 api.py importboard EXPORT.json # Import a whole board (with rules/workflows) from a WeKan export
|
||||
python3 api.py migratefromwekan REMOTE_URL REMOTE_USER REMOTE_PASS # Import ALL boards+workflows+rules from another WeKan
|
||||
python3 api.py exportboardpdf BOARDID OUTPUT.pdf # Export a whole board to PDF
|
||||
python3 api.py importboardfrom SOURCE EXPORT.json # Import from trello/wekan/csv/jira/kanboard/excel/deck/openproject/github/gitlab/gitea/forgejo/asana/zenkit
|
||||
python3 api.py exportboardformat BOARDID FORMAT OUTPUT.json # Export to kanboard/trello/jira/deck/openproject/github/gitlab/gitea/forgejo/asana/zenkit
|
||||
|
||||
Board Member API (Issue #5998):
|
||||
python3 api.py addboardmember BOARDID USERID ROLE # Add/activate board member. ROLE: admin, normal, comment, readonly, worker, normalassignedonly, commentassignedonly, readassignedonly, nocomments
|
||||
python3 api.py removeboardmember BOARDID USERID # Remove (deactivate) board member
|
||||
python3 api.py setboardmemberrole BOARDID USERID ROLE # Change permission/role of existing board member
|
||||
|
||||
Board Domain Sharing API (Issue #5850):
|
||||
python3 api.py boarddomains BOARDID # List the email domains a board is shared with
|
||||
python3 api.py addboarddomain BOARDID DOMAIN # Share board with an email domain, e.g. example.com
|
||||
python3 api.py removeboarddomain BOARDID DOMAIN # Stop sharing board with an email domain
|
||||
|
||||
Card Member / Assignee API (board member -> card member/assignee):
|
||||
python3 api.py setcardmembers BOARDID LISTID CARDID MEMBERIDS # Set card members. MEMBERIDS = comma-separated userIds, or '' to clear
|
||||
python3 api.py setcardassignees BOARDID LISTID CARDID ASSIGNEEIDS # Set card assignees. ASSIGNEEIDS = comma-separated userIds, or '' to clear
|
||||
@@ -114,11 +126,24 @@ If *nix: chmod +x api.py => ./api.py users
|
||||
python3 api.py mycards [due] [FROM] [TO] # Current user's cards. 'due' = only cards with a due date; FROM/TO = ISO 8601 due-date range
|
||||
|
||||
Card Settings API (Issue #3062):
|
||||
python3 api.py getcardsettings BOARDID # Get board-level card settings (allows* toggles)
|
||||
python3 api.py setcardsetting BOARDID KEY VALUE # Set one board card setting, e.g. allowsDueDate true
|
||||
python3 api.py getcardsettings BOARDID # Get board-level card settings (allows* toggles, cardAging, cardAgingDays1/2/3)
|
||||
python3 api.py setcardsetting BOARDID KEY VALUE # Set one board card setting, e.g. allowsDueDate true, cardAging true, cardAgingDays1 5
|
||||
|
||||
Admin API:
|
||||
python3 api.py newuser USERNAME EMAIL PASSWORD
|
||||
|
||||
GlobalAdmin REST API (admin token required):
|
||||
python3 api.py getsettings # Get the global Admin Panel settings
|
||||
python3 api.py editsettings FIELD VALUE # Set one global settings field, e.g. productName "My WeKan", disableRegistration true
|
||||
python3 api.py admindomains # List email domains with user counts (#5850)
|
||||
python3 api.py attachmentsettings # Get attachment storage / upload-block settings (secrets masked)
|
||||
python3 api.py editattachmentsetting DOTTED.FIELD VALUE # Set one attachment setting, e.g. limitSettings.avatarsUploadBlocked true (#4740)
|
||||
python3 api.py adminorgs # List organizations with feature toggles (#4737)
|
||||
python3 api.py editorgfeature ORGID FIELD true|false # Set one org feature: orgSharedTemplates, orgPropagateMembersToBoards, orgSyncMembersFromAuth
|
||||
python3 api.py editallorgfeature FIELD true|false # Set one org feature on ALL orgs
|
||||
python3 api.py adminteams # List teams with feature toggles (#4737)
|
||||
python3 api.py editteamfeature TEAMID FIELD true|false # Set one team feature: teamSharedTemplates, teamPropagateMembersToBoards, teamSyncMembersFromAuth
|
||||
python3 api.py editallteamfeature FIELD true|false # Set one team feature on ALL teams
|
||||
"""
|
||||
|
||||
if arguments == 0:
|
||||
@@ -218,6 +243,7 @@ ats = 'attachments'
|
||||
users = wekanurl + apiusers
|
||||
user = wekanurl + apiuser
|
||||
allusers = wekanurl + apiallusers
|
||||
settings = wekanurl + 'api/settings'
|
||||
|
||||
# ------- API URL GENERATION END -----------
|
||||
|
||||
@@ -790,6 +816,155 @@ if arguments >= 1:
|
||||
print(data2)
|
||||
# ------- LIST OF USERS END -----------
|
||||
|
||||
if arguments == 1 and sys.argv[1] == 'getsettings':
|
||||
# ------- GET GLOBAL ADMIN SETTINGS START -----------
|
||||
# GlobalAdmin REST API: read the Admin Panel global settings.
|
||||
# Requires an admin API token. Mail-server credentials are never returned.
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
print(settings)
|
||||
print("=== GLOBAL ADMIN SETTINGS ===\n")
|
||||
body = requests.get(settings, headers=headers)
|
||||
print(body.text)
|
||||
# ------- GET GLOBAL ADMIN SETTINGS END -----------
|
||||
|
||||
if arguments >= 3 and sys.argv[1] == 'editsettings':
|
||||
# ------- EDIT GLOBAL ADMIN SETTINGS START -----------
|
||||
# GlobalAdmin REST API: set one Admin Panel global settings field.
|
||||
# Usage: python3 api.py editsettings <field> <value>
|
||||
# e.g. python3 api.py editsettings productName "My WeKan"
|
||||
# python3 api.py editsettings disableRegistration true
|
||||
headers = {'Accept': 'application/json', 'Content-type': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
field = sys.argv[2]
|
||||
raw = sys.argv[3]
|
||||
# Convert true/false to booleans; leave everything else as a string.
|
||||
if raw in ('true', 'false'):
|
||||
value = (raw == 'true')
|
||||
else:
|
||||
value = raw
|
||||
body = requests.put(settings, json={field: value}, headers=headers)
|
||||
print(body.text)
|
||||
# ------- EDIT GLOBAL ADMIN SETTINGS END -----------
|
||||
|
||||
if arguments == 1 and sys.argv[1] == 'admindomains':
|
||||
# ------- LIST ADMIN DOMAINS START -----------
|
||||
# GlobalAdmin REST API: email domains with user counts (#5850).
|
||||
admindomainsurl = wekanurl + 'api/admin/domains'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
print("=== ADMIN DOMAINS ===\n")
|
||||
body = requests.get(admindomainsurl, headers=headers)
|
||||
print(body.text)
|
||||
# ------- LIST ADMIN DOMAINS END -----------
|
||||
|
||||
if arguments == 1 and sys.argv[1] == 'attachmentsettings':
|
||||
# ------- GET ATTACHMENT SETTINGS START -----------
|
||||
# GlobalAdmin REST API: attachment storage / upload-block settings.
|
||||
# Secrets (S3/Azure/GCS) are masked; a <field>Set marker shows existence.
|
||||
attachmentsettingsurl = wekanurl + 'api/admin/attachment-settings'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
print("=== ATTACHMENT SETTINGS ===\n")
|
||||
body = requests.get(attachmentsettingsurl, headers=headers)
|
||||
print(body.text)
|
||||
# ------- GET ATTACHMENT SETTINGS END -----------
|
||||
|
||||
if arguments >= 3 and sys.argv[1] == 'editattachmentsetting':
|
||||
# ------- EDIT ATTACHMENT SETTING START -----------
|
||||
# GlobalAdmin REST API: set one attachment setting by dotted path.
|
||||
# Usage: python3 api.py editattachmentsetting <dotted.field> <value>
|
||||
# e.g. python3 api.py editattachmentsetting limitSettings.avatarsUploadBlocked true
|
||||
attachmentsettingsurl = wekanurl + 'api/admin/attachment-settings'
|
||||
headers = {'Accept': 'application/json', 'Content-type': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
field = sys.argv[2]
|
||||
raw = sys.argv[3]
|
||||
# Convert true/false to booleans; leave everything else as a string.
|
||||
if raw in ('true', 'false'):
|
||||
value = (raw == 'true')
|
||||
else:
|
||||
value = raw
|
||||
# Expand a dotted path (e.g. limitSettings.avatarsUploadBlocked) into
|
||||
# the nested object the partial-update endpoint expects.
|
||||
body_obj = {}
|
||||
cursor = body_obj
|
||||
parts = field.split('.')
|
||||
for part in parts[:-1]:
|
||||
cursor[part] = {}
|
||||
cursor = cursor[part]
|
||||
cursor[parts[-1]] = value
|
||||
body = requests.put(attachmentsettingsurl, json=body_obj, headers=headers)
|
||||
print(body.text)
|
||||
# ------- EDIT ATTACHMENT SETTING END -----------
|
||||
|
||||
if arguments == 1 and sys.argv[1] == 'adminorgs':
|
||||
# ------- LIST ADMIN ORGS START -----------
|
||||
# GlobalAdmin REST API: orgs with feature toggles (#4737).
|
||||
adminorgsurl = wekanurl + 'api/admin/orgs'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
print("=== ADMIN ORGS ===\n")
|
||||
body = requests.get(adminorgsurl, headers=headers)
|
||||
print(body.text)
|
||||
# ------- LIST ADMIN ORGS END -----------
|
||||
|
||||
if arguments >= 3 and sys.argv[1] == 'editorgfeature':
|
||||
# ------- EDIT ORG FEATURE START -----------
|
||||
# Usage: python3 api.py editorgfeature <orgId> <field> <true|false>
|
||||
# field: orgSharedTemplates, orgPropagateMembersToBoards, orgSyncMembersFromAuth
|
||||
orgid = sys.argv[2]
|
||||
field = sys.argv[3]
|
||||
raw = sys.argv[4]
|
||||
value = (raw == 'true')
|
||||
editorgfeatureurl = wekanurl + 'api/admin/orgs/' + orgid + '/features'
|
||||
headers = {'Accept': 'application/json', 'Content-type': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.put(editorgfeatureurl, json={field: value}, headers=headers)
|
||||
print(body.text)
|
||||
# ------- EDIT ORG FEATURE END -----------
|
||||
|
||||
if arguments >= 2 and sys.argv[1] == 'editallorgfeature':
|
||||
# ------- EDIT ALL ORG FEATURE START -----------
|
||||
# Usage: python3 api.py editallorgfeature <field> <true|false>
|
||||
field = sys.argv[2]
|
||||
raw = sys.argv[3]
|
||||
value = (raw == 'true')
|
||||
editallorgfeatureurl = wekanurl + 'api/admin/orgs/features'
|
||||
headers = {'Accept': 'application/json', 'Content-type': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.put(editallorgfeatureurl, json={'field': field, 'value': value}, headers=headers)
|
||||
print(body.text)
|
||||
# ------- EDIT ALL ORG FEATURE END -----------
|
||||
|
||||
if arguments == 1 and sys.argv[1] == 'adminteams':
|
||||
# ------- LIST ADMIN TEAMS START -----------
|
||||
# GlobalAdmin REST API: teams with feature toggles (#4737).
|
||||
adminteamsurl = wekanurl + 'api/admin/teams'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
print("=== ADMIN TEAMS ===\n")
|
||||
body = requests.get(adminteamsurl, headers=headers)
|
||||
print(body.text)
|
||||
# ------- LIST ADMIN TEAMS END -----------
|
||||
|
||||
if arguments >= 3 and sys.argv[1] == 'editteamfeature':
|
||||
# ------- EDIT TEAM FEATURE START -----------
|
||||
# Usage: python3 api.py editteamfeature <teamId> <field> <true|false>
|
||||
# field: teamSharedTemplates, teamPropagateMembersToBoards, teamSyncMembersFromAuth
|
||||
teamid = sys.argv[2]
|
||||
field = sys.argv[3]
|
||||
raw = sys.argv[4]
|
||||
value = (raw == 'true')
|
||||
editteamfeatureurl = wekanurl + 'api/admin/teams/' + teamid + '/features'
|
||||
headers = {'Accept': 'application/json', 'Content-type': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.put(editteamfeatureurl, json={field: value}, headers=headers)
|
||||
print(body.text)
|
||||
# ------- EDIT TEAM FEATURE END -----------
|
||||
|
||||
if arguments >= 2 and sys.argv[1] == 'editallteamfeature':
|
||||
# ------- EDIT ALL TEAM FEATURE START -----------
|
||||
# Usage: python3 api.py editallteamfeature <field> <true|false>
|
||||
field = sys.argv[2]
|
||||
raw = sys.argv[3]
|
||||
value = (raw == 'true')
|
||||
editallteamfeatureurl = wekanurl + 'api/admin/teams/features'
|
||||
headers = {'Accept': 'application/json', 'Content-type': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.put(editallteamfeatureurl, json={'field': field, 'value': value}, headers=headers)
|
||||
print(body.text)
|
||||
# ------- EDIT ALL TEAM FEATURE END -----------
|
||||
|
||||
if arguments == 1 and sys.argv[1] == 'user':
|
||||
# ------- LIST OF ALL USERS START -----------
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
@@ -1167,6 +1342,48 @@ if arguments >= 1:
|
||||
print(body.text)
|
||||
# ------- SET BOARD MEMBER ROLE END -----------
|
||||
|
||||
if sys.argv[1] == 'boarddomains':
|
||||
# ------- LIST BOARD DOMAINS START -----------
|
||||
if arguments < 2:
|
||||
print("Usage: python3 api.py boarddomains BOARDID")
|
||||
exit(1)
|
||||
boardid = sys.argv[2]
|
||||
domains_url = wekanurl + apiboards + boardid + s + 'domains'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.get(domains_url, headers=headers)
|
||||
print("=== BOARD DOMAINS ===\n")
|
||||
print(body.text)
|
||||
# ------- LIST BOARD DOMAINS END -----------
|
||||
|
||||
if sys.argv[1] == 'addboarddomain':
|
||||
# ------- ADD BOARD DOMAIN START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py addboarddomain BOARDID DOMAIN")
|
||||
exit(1)
|
||||
boardid = sys.argv[2]
|
||||
domain = sys.argv[3]
|
||||
add_domain_url = wekanurl + apiboards + boardid + s + 'domains'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
post_data = {'domain': '{}'.format(domain)}
|
||||
body = requests.post(add_domain_url, data=post_data, headers=headers)
|
||||
print("=== ADD BOARD DOMAIN ===\n")
|
||||
print(body.text)
|
||||
# ------- ADD BOARD DOMAIN END -----------
|
||||
|
||||
if sys.argv[1] == 'removeboarddomain':
|
||||
# ------- REMOVE BOARD DOMAIN START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py removeboarddomain BOARDID DOMAIN")
|
||||
exit(1)
|
||||
boardid = sys.argv[2]
|
||||
domain = sys.argv[3]
|
||||
remove_domain_url = wekanurl + apiboards + boardid + s + 'domains' + s + domain
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.delete(remove_domain_url, headers=headers)
|
||||
print("=== REMOVE BOARD DOMAIN ===\n")
|
||||
print(body.text)
|
||||
# ------- REMOVE BOARD DOMAIN END -----------
|
||||
|
||||
if sys.argv[1] in ('setcardmembers', 'setcardassignees'):
|
||||
# ------- SET CARD MEMBERS / ASSIGNEES START -----------
|
||||
if arguments < 4:
|
||||
@@ -1488,3 +1705,320 @@ if arguments >= 1:
|
||||
# ------- SET CARD SETTING END -----------
|
||||
|
||||
# ------- BOARD MEMBER / CARD FIELD API ENDPOINTS END -----------
|
||||
|
||||
# ------- RULES API ENDPOINTS START -----------
|
||||
#
|
||||
# Board automation Rules: add / edit / remove / list via REST.
|
||||
# A rule links a trigger (event or schedule) to an action. The API embeds the
|
||||
# full trigger and action inline so a rule is self-contained.
|
||||
#
|
||||
# python3 api.py listrules BOARDID
|
||||
# python3 api.py getrule BOARDID RULEID
|
||||
# python3 api.py addrule BOARDID 'TITLE' 'TRIGGER_JSON' 'ACTION_JSON'
|
||||
# python3 api.py editrule BOARDID RULEID 'PATCH_JSON' # {"title":..,"trigger":{..},"action":{..}}
|
||||
# python3 api.py removerule BOARDID RULEID
|
||||
#
|
||||
# Example (move a card to the top of its list whenever it is created):
|
||||
# python3 api.py addrule BOARDID 'On create -> top' \
|
||||
# '{"activityType":"createCard","listName":"*","swimlaneName":"*","cardTitle":"*","userId":"*"}' \
|
||||
# '{"actionType":"moveCardToTop","listName":"*","swimlaneName":"*"}'
|
||||
#
|
||||
# Example (archive cards that have sat in "Completed" for 90 days; the scheduled
|
||||
# scan runs this daily):
|
||||
# python3 api.py addrule BOARDID 'Archive after 90 days' \
|
||||
# '{"activityType":"scheduledTrigger","scheduleKind":"aging","listName":"Completed","days":90,"atTime":"03:00"}' \
|
||||
# '{"actionType":"archive"}'
|
||||
|
||||
if arguments >= 2 and sys.argv[1] == 'listrules':
|
||||
boardid = sys.argv[2]
|
||||
url = wekanurl + apiboards + boardid + s + 'rules'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.get(url, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
if arguments >= 3 and sys.argv[1] == 'getrule':
|
||||
boardid = sys.argv[2]
|
||||
ruleid = sys.argv[3]
|
||||
url = wekanurl + apiboards + boardid + s + 'rules' + s + ruleid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.get(url, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
if arguments >= 3 and sys.argv[1] == 'removerule':
|
||||
boardid = sys.argv[2]
|
||||
ruleid = sys.argv[3]
|
||||
url = wekanurl + apiboards + boardid + s + 'rules' + s + ruleid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.delete(url, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
if arguments >= 5 and sys.argv[1] == 'addrule':
|
||||
boardid = sys.argv[2]
|
||||
title = sys.argv[3]
|
||||
trigger = json.loads(sys.argv[4])
|
||||
action = json.loads(sys.argv[5])
|
||||
url = wekanurl + apiboards + boardid + s + 'rules'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
body = requests.post(url, json={'title': title, 'trigger': trigger, 'action': action}, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
if arguments >= 4 and sys.argv[1] == 'editrule':
|
||||
boardid = sys.argv[2]
|
||||
ruleid = sys.argv[3]
|
||||
patch = json.loads(sys.argv[4])
|
||||
url = wekanurl + apiboards + boardid + s + 'rules' + s + ruleid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
body = requests.put(url, json=patch, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
# ------- RULES API ENDPOINTS END -----------
|
||||
|
||||
# ------- CARD DEPENDENCIES ("RED STRINGS") API START -----------
|
||||
#
|
||||
# Typed card-to-card dependencies, visualized on the board as colored connection
|
||||
# lines (SAFe PI-planning "Red Strings"). Each line has:
|
||||
# type : related-to | blocks | is-blocked-by | fixes | is-fixed-by
|
||||
# color : any CSS color, e.g. "#eb144c"
|
||||
# icon : a FontAwesome 4.7 icon name without the "fa-" prefix, e.g. "link"
|
||||
#
|
||||
# python3 api.py listdependencies BOARDID
|
||||
# python3 api.py listcarddependencies BOARDID CARDID
|
||||
# python3 api.py adddependency BOARDID CARDID TARGETCARDID [TYPE] [COLOR] [ICON]
|
||||
# python3 api.py editdependency BOARDID CARDID TARGETCARDID 'PATCH_JSON' # {"type":..,"color":..,"icon":..}
|
||||
# python3 api.py removedependency BOARDID CARDID TARGETCARDID
|
||||
#
|
||||
# Example (card A "blocks" card B, drawn as a thick blue arrow):
|
||||
# python3 api.py adddependency BOARDID CARDA CARDB blocks '#2196f3' lock
|
||||
|
||||
if arguments >= 2 and sys.argv[1] == 'listdependencies':
|
||||
boardid = sys.argv[2]
|
||||
url = wekanurl + apiboards + boardid + s + 'dependencies'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.get(url, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
if arguments >= 3 and sys.argv[1] == 'listcarddependencies':
|
||||
boardid = sys.argv[2]
|
||||
cardid = sys.argv[3]
|
||||
url = wekanurl + apiboards + boardid + s + 'cards' + s + cardid + s + 'dependencies'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.get(url, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
if arguments >= 4 and sys.argv[1] == 'adddependency':
|
||||
boardid = sys.argv[2]
|
||||
cardid = sys.argv[3]
|
||||
targetcardid = sys.argv[4]
|
||||
payload = {'cardId': targetcardid}
|
||||
if arguments >= 5:
|
||||
payload['type'] = sys.argv[5]
|
||||
if arguments >= 6:
|
||||
payload['color'] = sys.argv[6]
|
||||
if arguments >= 7:
|
||||
payload['icon'] = sys.argv[7]
|
||||
url = wekanurl + apiboards + boardid + s + 'cards' + s + cardid + s + 'dependencies'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
body = requests.post(url, json=payload, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
if arguments >= 5 and sys.argv[1] == 'editdependency':
|
||||
boardid = sys.argv[2]
|
||||
cardid = sys.argv[3]
|
||||
targetcardid = sys.argv[4]
|
||||
patch = json.loads(sys.argv[5])
|
||||
url = wekanurl + apiboards + boardid + s + 'cards' + s + cardid + s + 'dependencies' + s + targetcardid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
body = requests.put(url, json=patch, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
if arguments >= 4 and sys.argv[1] == 'removedependency':
|
||||
boardid = sys.argv[2]
|
||||
cardid = sys.argv[3]
|
||||
targetcardid = sys.argv[4]
|
||||
url = wekanurl + apiboards + boardid + s + 'cards' + s + cardid + s + 'dependencies' + s + targetcardid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
body = requests.delete(url, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
# ------- CARD DEPENDENCIES ("RED STRINGS") API END -----------
|
||||
|
||||
# ------- CARD STICKERS / LOCATIONS / COMPLETE START -----------
|
||||
#
|
||||
# These card fields are set through the card edit endpoint
|
||||
# (PUT /api/boards/:boardId/lists/:listId/cards/:cardId):
|
||||
#
|
||||
# python3 api.py setcardcomplete BOARDID LISTID CARDID true
|
||||
# python3 api.py setcardstickers BOARDID LISTID CARDID '[{"icon":"taco-cool"}]'
|
||||
# python3 api.py setcardlocations BOARDID LISTID CARDID \
|
||||
# '[{"name":"HQ","address":"Helsinki","latitude":60.17,"longitude":24.94}]'
|
||||
|
||||
if arguments >= 5 and sys.argv[1] == 'setcardcomplete':
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
dueComplete = sys.argv[5]
|
||||
url = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
body = requests.put(url, json={'dueComplete': dueComplete}, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
if arguments >= 5 and sys.argv[1] == 'setcardstickers':
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
stickers = json.loads(sys.argv[5])
|
||||
url = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
body = requests.put(url, json={'stickers': stickers}, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
if arguments >= 5 and sys.argv[1] == 'setcardlocations':
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
locations = json.loads(sys.argv[5])
|
||||
url = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
body = requests.put(url, json={'locations': locations}, headers=headers)
|
||||
print(body.text)
|
||||
|
||||
# ------- CARD STICKERS / LOCATIONS / COMPLETE END -----------
|
||||
|
||||
# ------- BOARD BACKGROUND IMAGE UPLOAD / DOWNLOAD START -----------
|
||||
#
|
||||
# Upload/download a board background image. This is the background counterpart
|
||||
# of the "upload attachment to board" API; the upload stores a board-level
|
||||
# attachment using the current Admin Panel / Attachments / Default Storage and
|
||||
# sets it as the board's active background (board admin required).
|
||||
#
|
||||
# python3 api.py uploadbackground BOARDID FILEPATH
|
||||
# python3 api.py downloadbackground BOARDID OUTPUTPATH
|
||||
|
||||
if arguments >= 3 and sys.argv[1] == 'uploadbackground':
|
||||
boardid = sys.argv[2]
|
||||
filepath = sys.argv[3]
|
||||
import base64, os, mimetypes
|
||||
try:
|
||||
with open(filepath, 'rb') as f:
|
||||
base64_data = base64.b64encode(f.read()).decode('utf-8')
|
||||
except Exception as e:
|
||||
print(f"Error reading file: {e}")
|
||||
exit(1)
|
||||
upload_data = {
|
||||
'boardId': boardid,
|
||||
'fileData': base64_data,
|
||||
'fileName': os.path.basename(filepath),
|
||||
'fileType': mimetypes.guess_type(filepath)[0] or 'image/png',
|
||||
}
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
url = wekanurl + 'api/attachment/upload-background'
|
||||
response = requests.post(url, headers=headers, json=upload_data)
|
||||
print(response.text)
|
||||
|
||||
if arguments >= 3 and sys.argv[1] == 'downloadbackground':
|
||||
boardid = sys.argv[2]
|
||||
outputpath = sys.argv[3]
|
||||
import base64
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
url = wekanurl + f'api/attachment/download-background/{boardid}'
|
||||
response = requests.get(url, headers=headers)
|
||||
result = response.json()
|
||||
if result.get('success'):
|
||||
with open(outputpath, 'wb') as f:
|
||||
f.write(base64.b64decode(result.get('base64Data')))
|
||||
print(f"Background saved to: {outputpath}")
|
||||
print(f"Original filename: {result.get('fileName')}")
|
||||
print(f"Storage: {result.get('storageBackend')}")
|
||||
else:
|
||||
print(f"Download failed: {result}")
|
||||
|
||||
# ------- BOARD BACKGROUND IMAGE UPLOAD / DOWNLOAD END -----------
|
||||
|
||||
# ------- IMPORT BOARD / MIGRATE FROM ANOTHER WEKAN START -----------
|
||||
#
|
||||
# Import a whole board WITH its workflows (rules/triggers/actions) and other data
|
||||
# from a WeKan board export, or migrate ALL boards from another WeKan instance
|
||||
# over the REST API.
|
||||
#
|
||||
# python3 api.py importboard EXPORT.json # import one exported board (with rules)
|
||||
# python3 api.py migratefromwekan REMOTE_URL REMOTE_USER REMOTE_PASS # import all boards+workflows+rules
|
||||
|
||||
if arguments >= 2 and sys.argv[1] == 'importboard':
|
||||
filepath = sys.argv[2]
|
||||
with open(filepath) as f:
|
||||
board = json.load(f)
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
response = requests.post(wekanurl + 'api/boards/import', headers=headers, json={'board': board})
|
||||
print(response.text)
|
||||
|
||||
if arguments >= 4 and sys.argv[1] == 'migratefromwekan':
|
||||
remote = sys.argv[2].rstrip('/') + '/'
|
||||
remote_user = sys.argv[3]
|
||||
remote_pass = sys.argv[4]
|
||||
# 1) Log in to the remote WeKan to get its token + user id.
|
||||
rlogin = requests.post(remote + 'users/login', json={'username': remote_user, 'password': remote_pass}).json()
|
||||
rtoken = rlogin['token']
|
||||
ruid = rlogin['id']
|
||||
rheaders = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(rtoken)}
|
||||
lheaders = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
# 2) List the remote user's boards.
|
||||
boards = requests.get(remote + 'api/users/{}/boards'.format(ruid), headers=rheaders).json()
|
||||
print('Found {} board(s) on remote WeKan.'.format(len(boards)))
|
||||
# 3) Export each remote board (full JSON incl. rules/workflows) and import locally.
|
||||
for b in boards:
|
||||
bid = b.get('_id') or b.get('id')
|
||||
if not bid:
|
||||
continue
|
||||
export = requests.get(remote + 'api/boards/{}/export'.format(bid), headers=rheaders).json()
|
||||
resp = requests.post(wekanurl + 'api/boards/import', headers=lheaders, json={'board': export})
|
||||
print('{}: HTTP {} {}'.format(b.get('title', bid), resp.status_code, resp.text[:160]))
|
||||
|
||||
# ------- IMPORT BOARD / MIGRATE FROM ANOTHER WEKAN END -----------
|
||||
|
||||
# ------- EXPORT BOARD TO PDF START -----------
|
||||
#
|
||||
# python3 api.py exportboardpdf BOARDID OUTPUT.pdf
|
||||
|
||||
if arguments >= 3 and sys.argv[1] == 'exportboardpdf':
|
||||
boardid = sys.argv[2]
|
||||
outputpath = sys.argv[3]
|
||||
headers = {'Authorization': 'Bearer {}'.format(apikey)}
|
||||
url = wekanurl + apiboards + boardid + s + 'exportPDF'
|
||||
response = requests.get(url, headers=headers)
|
||||
with open(outputpath, 'wb') as f:
|
||||
f.write(response.content)
|
||||
print('Board PDF saved to: {}'.format(outputpath))
|
||||
|
||||
# ------- EXPORT BOARD TO PDF END -----------
|
||||
|
||||
# ------- IMPORT/EXPORT TO OTHER TOOLS START -----------
|
||||
#
|
||||
# Generalized import from another tool's export, and export to another tool's
|
||||
# JSON shape. Sources/formats: trello, wekan, csv, jira, kanboard, excel, deck,
|
||||
# openproject, github, gitlab, gitea, forgejo.
|
||||
#
|
||||
# python3 api.py importboardfrom SOURCE EXPORT.json # e.g. github issues.json (SOURCE = deck/openproject/github/gitlab/gitea/forgejo/asana/zenkit/trello/jira/kanboard/csv/excel/wekan)
|
||||
# python3 api.py exportboardformat BOARDID FORMAT OUTPUT.json # FORMAT = trello/jira/deck/openproject/github/gitlab/gitea/forgejo/asana/zenkit/kanboard
|
||||
|
||||
if arguments >= 3 and sys.argv[1] == 'importboardfrom':
|
||||
source = sys.argv[2]
|
||||
filepath = sys.argv[3]
|
||||
with open(filepath) as f:
|
||||
board = json.load(f)
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
url = wekanurl + apiboards + 'import/' + source
|
||||
response = requests.post(url, headers=headers, json={'board': board})
|
||||
print(response.text)
|
||||
|
||||
if arguments >= 4 and sys.argv[1] == 'exportboardformat':
|
||||
boardid = sys.argv[2]
|
||||
fmt = sys.argv[3]
|
||||
outputpath = sys.argv[4]
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
url = wekanurl + apiboards + boardid + s + 'export' + s + fmt
|
||||
response = requests.get(url, headers=headers)
|
||||
with open(outputpath, 'w') as f:
|
||||
f.write(response.text)
|
||||
print('Board exported ({}) to: {}'.format(fmt, outputpath))
|
||||
|
||||
# ------- IMPORT/EXPORT TO OTHER TOOLS END -----------
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
opacity: 0.7;
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: -38px;
|
||||
inset-inline-start: -38px;
|
||||
}
|
||||
.new-comment.is-open .member {
|
||||
opacity: 1;
|
||||
@@ -84,7 +84,7 @@
|
||||
flex: 1;
|
||||
align-self: center;
|
||||
margin: 0;
|
||||
margin-left: 3px;
|
||||
margin-inline-start: 3px;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -113,7 +113,7 @@
|
||||
font-size: clamp(14px, 2vw, 18px);
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
margin-left: 4px;
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
.comments .comment .comment-desc .reactions .reaction {
|
||||
cursor: pointer;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
.board-conversion-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
@@ -52,7 +52,7 @@
|
||||
}
|
||||
|
||||
.board-conversion-header h3 i {
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
color: #2196F3;
|
||||
}
|
||||
|
||||
@@ -91,8 +91,8 @@
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
@@ -127,7 +127,7 @@
|
||||
}
|
||||
|
||||
.conversion-status i {
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
color: #2196F3;
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
}
|
||||
|
||||
.conversion-time i {
|
||||
margin-right: 6px;
|
||||
margin-inline-end: 6px;
|
||||
color: #FF9800;
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
}
|
||||
|
||||
.conversion-info i {
|
||||
margin-right: 6px;
|
||||
margin-inline-end: 6px;
|
||||
color: #2196F3;
|
||||
}
|
||||
|
||||
@@ -174,8 +174,8 @@
|
||||
.board-conversion-header,
|
||||
.board-conversion-content,
|
||||
.board-conversion-footer {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
padding-inline-start: 16px;
|
||||
padding-inline-end: 16px;
|
||||
}
|
||||
|
||||
.board-conversion-header h3 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.board-wrapper {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
overflow-x: hidden;
|
||||
@@ -30,8 +30,8 @@
|
||||
}
|
||||
.board-wrapper .board-canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
transition: margin 0.1s;
|
||||
@@ -276,12 +276,12 @@ body.mobile-mode.iphone-device .card-details .card-details-item-title {
|
||||
}
|
||||
.board-wrapper .board-canvas .board-overlay {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
top: -100px;
|
||||
right: -400px;
|
||||
inset-inline-end: -400px;
|
||||
background: #000;
|
||||
opacity: 0.33;
|
||||
animation: fadeIn 0.2s;
|
||||
@@ -325,8 +325,8 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
|
||||
width: 100vw !important;
|
||||
max-width: 100vw !important;
|
||||
min-width: 100vw !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
@@ -335,8 +335,8 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
|
||||
width: 100vw !important;
|
||||
max-width: 100vw !important;
|
||||
min-width: 100vw !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
@@ -360,8 +360,8 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
|
||||
width: 100vw !important;
|
||||
max-width: 100vw !important;
|
||||
min-width: 100vw !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
@@ -370,8 +370,8 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
|
||||
width: 100vw !important;
|
||||
max-width: 100vw !important;
|
||||
min-width: 100vw !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
@@ -560,7 +560,7 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
inset-inline-end: 5px;
|
||||
font-size: 25px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -353,8 +353,10 @@ Template.boardBody.onRendered(function () {
|
||||
el.setAttribute('tabindex', '0');
|
||||
el.setAttribute('role', 'button');
|
||||
});
|
||||
// Set the language attribute on the <html> element for accessibility
|
||||
// Set the language and direction attributes on the <html> element for
|
||||
// accessibility and RTL layout.
|
||||
document.documentElement.lang = TAPi18n.getLanguage();
|
||||
document.documentElement.dir = TAPi18n.getLanguageDirection();
|
||||
|
||||
// Ensure the accessible name for the board view switcher matches the visible label "Swimlanes"
|
||||
// This fixes WCAG 2.5.3: Label in Name
|
||||
@@ -672,6 +674,19 @@ Template.boardBody.helpers({
|
||||
return boardView === 'board-view-gantt';
|
||||
},
|
||||
|
||||
isViewTable() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
let boardView;
|
||||
|
||||
if (currentUser) {
|
||||
boardView = (currentUser.profile || {}).boardView;
|
||||
} else {
|
||||
boardView = window.localStorage.getItem('boardView');
|
||||
}
|
||||
|
||||
return boardView === 'board-view-table';
|
||||
},
|
||||
|
||||
hasSwimlanes() {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
if (!currentBoard) {
|
||||
@@ -907,8 +922,10 @@ document.addEventListener('keydown', function (e) {
|
||||
});
|
||||
|
||||
Template.calendarView.onRendered(function () {
|
||||
// Set the language attribute on the <html> element for accessibility
|
||||
// Set the language and direction attributes on the <html> element for
|
||||
// accessibility and RTL layout.
|
||||
document.documentElement.lang = TAPi18n.getLanguage();
|
||||
document.documentElement.dir = TAPi18n.getLanguageDirection();
|
||||
|
||||
this.autorun(function () {
|
||||
const calendarEl = document.getElementById('calendar-view');
|
||||
@@ -971,6 +988,7 @@ Template.calendarView.helpers({
|
||||
end: '18:00',
|
||||
},
|
||||
locale: TAPi18n.getLanguage(),
|
||||
isRTL: TAPi18n.isRTL(),
|
||||
events(fetchInfo, callback) {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
const events = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -51,6 +51,8 @@ template(name="boardHeaderBar")
|
||||
span {{_ 'log-in'}}
|
||||
|
||||
.board-header-btns.center
|
||||
if currentUser
|
||||
+boardButtons
|
||||
|
||||
.board-header-btns.right
|
||||
if currentBoard
|
||||
@@ -125,6 +127,8 @@ template(name="boardHeaderBar")
|
||||
i.fa.fa-calendar
|
||||
if $eq boardView 'board-view-gantt'
|
||||
i.fa.fa-bar-chart
|
||||
if $eq boardView 'board-view-table'
|
||||
i.fa.fa-table
|
||||
span
|
||||
if $eq boardView 'board-view-swimlanes'
|
||||
| {{_ 'swimlanes'}}
|
||||
@@ -134,6 +138,14 @@ template(name="boardHeaderBar")
|
||||
| {{_ 'calendar'}}
|
||||
if $eq boardView 'board-view-gantt'
|
||||
| {{_ 'gantt'}}
|
||||
if $eq boardView 'board-view-table'
|
||||
| {{_ 'board-view-table'}}
|
||||
a.board-header-btn.js-toggle-dependencies(
|
||||
title="{{#if currentBoard.showDependencies}}{{_ 'hide-dependencies'}}{{else}}{{_ 'show-dependencies'}}{{/if}}"
|
||||
class="{{#if currentBoard.showDependencies}}emphasis{{/if}}")
|
||||
i.fa.fa-link
|
||||
span {{#if currentBoard.showDependencies}}{{_ 'hide-dependencies'}}{{else}}{{_ 'show-dependencies'}}{{/if}}
|
||||
|
||||
if canModifyBoard
|
||||
a.board-header-btn.js-multiselection-activate(
|
||||
title="{{#if MultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}"
|
||||
@@ -228,6 +240,13 @@ template(name="boardChangeViewPopup")
|
||||
| {{_ 'board-view-gantt'}}
|
||||
if $eq Utils.boardView "board-view-gantt"
|
||||
i.fa.fa-check
|
||||
li
|
||||
with "board-view-table"
|
||||
a.js-open-table-view
|
||||
i.fa.fa-table
|
||||
| {{_ 'board-view-table'}}
|
||||
if $eq Utils.boardView "board-view-table"
|
||||
i.fa.fa-check
|
||||
|
||||
template(name="createBoard")
|
||||
form
|
||||
@@ -247,9 +266,6 @@ template(name="createBoard")
|
||||
= " "
|
||||
| {{{_ 'board-private-info'}}}
|
||||
a.js-change-visibility {{_ 'change'}}.
|
||||
a.flex.js-toggle-add-template-container
|
||||
.materialCheckBox#add-template-container
|
||||
span {{_ 'add-template-container'}}
|
||||
input.primary.wide(type="submit" value="{{_ 'create'}}")
|
||||
span.quiet
|
||||
| {{_ 'or'}}
|
||||
@@ -276,9 +292,6 @@ template(name="createBoardPopup")
|
||||
= " "
|
||||
| {{{_ 'board-private-info'}}}
|
||||
a.js-change-visibility {{_ 'change'}}.
|
||||
a.flex.js-toggle-add-template-container
|
||||
.materialCheckBox#add-template-container
|
||||
span {{_ 'add-template-container'}}
|
||||
input.primary.wide(type="submit" value="{{_ 'create'}}")
|
||||
span.quiet
|
||||
| {{_ 'or'}}
|
||||
@@ -305,9 +318,6 @@ template(name="headerBarCreateBoardPopup")
|
||||
= " "
|
||||
| {{{_ 'board-private-info'}}}
|
||||
a.js-change-visibility {{_ 'change'}}.
|
||||
a.flex.js-toggle-add-template-container
|
||||
.materialCheckBox#add-template-container
|
||||
span {{_ 'add-template-container'}}
|
||||
input.primary.wide(type="submit" value="{{_ 'create'}}")
|
||||
span.quiet
|
||||
| {{_ 'or'}}
|
||||
@@ -335,9 +345,6 @@ template(name="createTemplateContainerPopup")
|
||||
= " "
|
||||
| {{{_ 'board-private-info'}}}
|
||||
a.js-change-visibility {{_ 'change'}}.
|
||||
a.flex.js-toggle-add-template-container
|
||||
.materialCheckBox#add-template-container
|
||||
span {{_ 'add-template-container'}}
|
||||
input.primary.wide(type="submit" value="{{_ 'create'}}")
|
||||
span.quiet
|
||||
| {{_ 'or'}}
|
||||
|
||||
@@ -181,6 +181,12 @@ Template.boardHeaderBar.events({
|
||||
console.warn('Sidebar not available for setView');
|
||||
}
|
||||
},
|
||||
'click .js-toggle-dependencies'() {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
if (currentBoard) {
|
||||
currentBoard.setShowDependencies(!currentBoard.showDependencies);
|
||||
}
|
||||
},
|
||||
'click .js-multiselection-activate'() {
|
||||
const currentCard = Utils.getCurrentCardId();
|
||||
MultiSelection.activate();
|
||||
@@ -214,6 +220,10 @@ Template.boardChangeViewPopup.events({
|
||||
Utils.setBoardView('board-view-gantt');
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-open-table-view'() {
|
||||
Utils.setBoardView('board-view-table');
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
|
||||
// Shared setup for all create board popups
|
||||
@@ -221,6 +231,9 @@ function setupCreateBoardState(tpl) {
|
||||
tpl.visibilityMenuIsOpen = new ReactiveVar(false);
|
||||
tpl.visibility = new ReactiveVar('private');
|
||||
tpl.boardId = new ReactiveVar('');
|
||||
// #5850: default to creating a regular board; the dedicated
|
||||
// createTemplateContainerPopup sets this true in its onRendered.
|
||||
Session.set('createBoardAsTemplate', false);
|
||||
Meteor.subscribe('tableVisibilityModeSettings');
|
||||
}
|
||||
|
||||
@@ -246,7 +259,11 @@ async function createBoardSubmit(tpl, event) {
|
||||
const title = tpl.find('.js-new-board-title').value;
|
||||
const slug = getSlug(title) || 'board';
|
||||
|
||||
const addTemplateContainer = $('#add-template-container.is-checked').length > 0;
|
||||
// #5850: template boards are created via the dedicated "Add Template Board"
|
||||
// flow (createTemplateContainerPopup), signalled by this Session flag, rather
|
||||
// than a checkbox on the generic Create Board popup. Consume it immediately.
|
||||
const addTemplateContainer = Session.get('createBoardAsTemplate') === true;
|
||||
Session.set('createBoardAsTemplate', false);
|
||||
if (addTemplateContainer) {
|
||||
tpl.boardId.set(
|
||||
await Meteor.callAsync('createBoardWithInitialSwimlanes', {
|
||||
@@ -256,9 +273,9 @@ async function createBoardSubmit(tpl, event) {
|
||||
type: 'template-container',
|
||||
migrationVersion: 1,
|
||||
swimlanes: [
|
||||
{ title: 'Card Templates', sort: 1, type: 'template-container' },
|
||||
{ title: 'List Templates', sort: 2, type: 'template-container' },
|
||||
{ title: 'Board Templates', sort: 3, type: 'template-container' },
|
||||
{ title: 'Card Templates', sort: 1, type: 'template-container', role: 'card' },
|
||||
{ title: 'List Templates', sort: 2, type: 'template-container', role: 'list' },
|
||||
{ title: 'Board Templates', sort: 3, type: 'template-container', role: 'board' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -309,15 +326,11 @@ function createBoardEvents() {
|
||||
'click .js-change-visibility'(event, tpl) {
|
||||
tpl.visibilityMenuIsOpen.set(!tpl.visibilityMenuIsOpen.get());
|
||||
},
|
||||
'click .js-import': Popup.open('boardImportBoard'),
|
||||
async 'submit'(event, tpl) {
|
||||
await createBoardSubmit(tpl, event);
|
||||
},
|
||||
'click .js-import-board': Popup.open('chooseBoardSource'),
|
||||
'click .js-board-template': Popup.open('searchElement'),
|
||||
'click .js-toggle-add-template-container'() {
|
||||
$('#add-template-container').toggleClass('is-checked');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -345,8 +358,8 @@ Template.createTemplateContainerPopup.onCreated(function () {
|
||||
});
|
||||
|
||||
Template.createTemplateContainerPopup.onRendered(function () {
|
||||
// Always pre-check the template container checkbox for this popup
|
||||
$('#add-template-container').addClass('is-checked');
|
||||
// #5850: this dedicated popup always creates a template board.
|
||||
Session.set('createBoardAsTemplate', true);
|
||||
});
|
||||
|
||||
Template.createTemplateContainerPopup.helpers(createBoardHelpers());
|
||||
@@ -368,7 +381,6 @@ Template.headerBarCreateBoardPopup.events({
|
||||
'click .js-change-visibility'(event, tpl) {
|
||||
tpl.visibilityMenuIsOpen.set(!tpl.visibilityMenuIsOpen.get());
|
||||
},
|
||||
'click .js-import': Popup.open('boardImportBoard'),
|
||||
async submit(event, tpl) {
|
||||
await createBoardSubmit(tpl, event);
|
||||
// Immediately star boards created with the headerbar popup.
|
||||
@@ -376,9 +388,6 @@ Template.headerBarCreateBoardPopup.events({
|
||||
},
|
||||
'click .js-import-board': Popup.open('chooseBoardSource'),
|
||||
'click .js-board-template': Popup.open('searchElement'),
|
||||
'click .js-toggle-add-template-container'() {
|
||||
$('#add-template-container').toggleClass('is-checked');
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardVisibilityList.helpers({
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
}
|
||||
|
||||
.boards-left-menu {
|
||||
border-right: 1px solid #e0e0e0;
|
||||
padding-right: 12px;
|
||||
border-inline-end: 1px solid #e0e0e0;
|
||||
padding-inline-end: 12px;
|
||||
}
|
||||
|
||||
.boards-left-menu ul.menu {
|
||||
@@ -46,7 +46,7 @@
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
}
|
||||
.boards-left-menu .menu-item.active a,
|
||||
.boards-left-menu .menu-item a:hover {
|
||||
@@ -87,12 +87,12 @@
|
||||
border: none;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
|
||||
.workspace-tree {
|
||||
list-style: none;
|
||||
padding-left: 10px;
|
||||
padding-inline-start: 10px;
|
||||
}
|
||||
|
||||
.workspace-node {
|
||||
@@ -345,7 +345,7 @@
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
margin-left: 10px; /* Extra space between MultiSelection toggle and Remove Filter */
|
||||
margin-inline-start: 10px; /* Extra space between MultiSelection toggle and Remove Filter */
|
||||
}
|
||||
|
||||
.boards-path-header .board-header-btn-close:hover {
|
||||
@@ -429,7 +429,7 @@
|
||||
margin: 0 8px;
|
||||
}
|
||||
.board-list li {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
width: 20%;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
@@ -533,7 +533,7 @@
|
||||
opacity: 0;
|
||||
padding: 9px 9px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
transition-duration: 0.15s;
|
||||
transition-property: color, font-size, background;
|
||||
}
|
||||
@@ -544,7 +544,7 @@
|
||||
line-height: 10px;
|
||||
padding: 9px 9px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
transition-duration: 0.15s;
|
||||
transition-property: color, font-size, background;
|
||||
}
|
||||
@@ -564,7 +564,7 @@
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
opacity: 0;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
padding: 9px 9px;
|
||||
transition-duration: 0.15s;
|
||||
transition-property: color, font-size, background;
|
||||
@@ -576,7 +576,7 @@
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
opacity: 0;
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
padding: 9px 9px;
|
||||
transition-duration: 0.15s;
|
||||
transition-property: color, font-size, background;
|
||||
@@ -642,7 +642,7 @@
|
||||
.board-list .board-list-item .multi-selection-checkbox {
|
||||
position: absolute !important;
|
||||
bottom: 4px !important;
|
||||
left: 4px !important;
|
||||
inset-inline-start: 4px !important;
|
||||
top: auto !important;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
@@ -673,7 +673,7 @@
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
top: auto !important;
|
||||
left: 4px !important;
|
||||
inset-inline-start: 4px !important;
|
||||
transform: none !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
@@ -711,7 +711,7 @@ body.grey-icons-enabled .board-list.is-multiselection-active .js-board.is-checke
|
||||
.board-backgrounds-list .board-background-select {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
width: 50%;
|
||||
padding-top: 12px;
|
||||
position: relative;
|
||||
@@ -721,10 +721,10 @@ body.grey-icons-enabled .board-list.is-multiselection-active .js-board.is-checke
|
||||
padding-top: 0;
|
||||
}
|
||||
.board-backgrounds-list .board-background-select:nth-child(2n) {
|
||||
padding-left: 6px;
|
||||
padding-inline-start: 6px;
|
||||
}
|
||||
.board-backgrounds-list .board-background-select:nth-child(2n+1) {
|
||||
padding-right: 6px;
|
||||
padding-inline-end: 6px;
|
||||
}
|
||||
.board-backgrounds-list .board-background-select .background-box {
|
||||
color: #fff;
|
||||
@@ -769,32 +769,32 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
float: none;
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
padding-right: 50px; /* Space for drag handle */
|
||||
padding-inline-end: 50px; /* Space for drag handle */
|
||||
}
|
||||
.board-list.mobile-view .board-list-item {
|
||||
overflow: visible;
|
||||
height: 8rem;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding-right: 50px; /* Ensure content doesn't overlap with drag handle */
|
||||
padding-inline-end: 50px; /* Ensure content doesn't overlap with drag handle */
|
||||
}
|
||||
|
||||
.board-list.mobile-view .board-list-item .details {
|
||||
padding-right: 50px; /* Extra space for drag handle */
|
||||
padding-inline-end: 50px; /* Extra space for drag handle */
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.board-list.mobile-view .board-list-item-sub-name {
|
||||
position: relative;
|
||||
top: -100px;
|
||||
left: -100px;
|
||||
inset-inline-start: -100px;
|
||||
}
|
||||
.board-list.mobile-view .board-handle {
|
||||
position: absolute;
|
||||
padding: 7px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
right: 10px;
|
||||
inset-inline-end: 10px;
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
background: rgba(0,0,0,0.3);
|
||||
@@ -878,32 +878,32 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
float: none;
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
padding-right: 50px; /* Space for drag handle */
|
||||
padding-inline-end: 50px; /* Space for drag handle */
|
||||
}
|
||||
.board-list .board-list-item {
|
||||
overflow: visible;
|
||||
height: 8rem;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding-right: 50px; /* Ensure content doesn't overlap with drag handle */
|
||||
padding-inline-end: 50px; /* Ensure content doesn't overlap with drag handle */
|
||||
}
|
||||
|
||||
.board-list .board-list-item .details {
|
||||
padding-right: 50px; /* Extra space for drag handle */
|
||||
padding-inline-end: 50px; /* Extra space for drag handle */
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.board-list .board-list-item-sub-name {
|
||||
position: relative;
|
||||
top: -100px;
|
||||
left: -100px;
|
||||
inset-inline-start: -100px;
|
||||
}
|
||||
.board-list .board-handle {
|
||||
position: absolute;
|
||||
padding: 7px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
right: 10px;
|
||||
inset-inline-end: 10px;
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
background: rgba(0,0,0,0.3);
|
||||
@@ -933,7 +933,7 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
padding: 7px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
right: 10px;
|
||||
inset-inline-end: 10px;
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
background: rgba(0,0,0,0.3);
|
||||
@@ -971,9 +971,9 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
}
|
||||
|
||||
.boards-left-menu {
|
||||
border-right: none !important;
|
||||
border-inline-end: none !important;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding-right: 0 !important;
|
||||
padding-inline-end: 0 !important;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -1157,7 +1157,7 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
padding: 10px !important;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
right: 15px !important;
|
||||
inset-inline-end: 15px !important;
|
||||
font-size: 28px !important; /* Much larger drag handle */
|
||||
color: #fff;
|
||||
background: rgba(0,0,0,0.4) !important;
|
||||
@@ -1221,13 +1221,13 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
.AllBoardTeams,
|
||||
.AllBoardOrgs,
|
||||
.AllBoardBtns {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
.js-AllBoardOrgs {
|
||||
margin-left: 16px;
|
||||
margin-inline-start: 16px;
|
||||
}
|
||||
.AllBoardTeams {
|
||||
margin-left: 16px;
|
||||
margin-inline-start: 16px;
|
||||
}
|
||||
.AllBoardButtonsContainer {
|
||||
margin: 16px;
|
||||
@@ -1296,7 +1296,7 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
padding: 6px 0 6px 8px;
|
||||
width: 100%;
|
||||
margin-bottom: 2px;
|
||||
margin-left: -4px;
|
||||
margin-inline-start: -4px;
|
||||
display: inline-block;
|
||||
}
|
||||
.minicard-lists {
|
||||
@@ -1312,7 +1312,7 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
}
|
||||
.flex-wrap .item {
|
||||
margin: 2px;
|
||||
padding-right: 6px;
|
||||
padding-inline-end: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -1403,3 +1403,26 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* #5157: show the board's background image on the board list tile, reusing the
|
||||
* existing board.backgroundImageURL. A dark overlay keeps the title readable. */
|
||||
.board-list-item.has-background-image {
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
position: relative;
|
||||
}
|
||||
.board-list-item.has-background-image .details,
|
||||
.board-list-item.has-background-image .board-list-item-name {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
}
|
||||
.board-list-item.has-background-image::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-radius: inherit;
|
||||
z-index: 0;
|
||||
}
|
||||
@@ -27,6 +27,15 @@ template(name="boardList")
|
||||
i.fa.fa-folder
|
||||
| {{_ 'allboards.remaining'}}
|
||||
span.menu-count {{menuItemCount 'remaining'}}
|
||||
if isSelectedMenu 'templates'
|
||||
if shareTargets.length
|
||||
li.menu-section-title {{_ 'share-template-with'}}
|
||||
each shareTargets
|
||||
li.menu-item.js-share-target(data-share-type="{{type}}", data-share-id="{{id}}", data-share-name="{{name}}", title="{{_ 'drag-template-here-to-share'}}")
|
||||
span.menu-label
|
||||
span.emoji-icon
|
||||
i.fa.fa-share-alt
|
||||
| {{name}}
|
||||
.workspaces-header
|
||||
span
|
||||
span.emoji-icon
|
||||
@@ -154,7 +163,9 @@ template(name="boardList")
|
||||
span.emoji-icon
|
||||
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
|
||||
else
|
||||
.board-list-item
|
||||
//- #5157: reuse the board's existing backgroundImageURL as the
|
||||
//- tile background on the board list page.
|
||||
.board-list-item(class="{{#if backgroundImageURL}}has-background-image{{/if}}" style="{{#if backgroundImageURL}}background-image:url('{{backgroundImageURL}}');{{/if}}")
|
||||
if BoardMultiSelection.isActive
|
||||
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
|
||||
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
|
||||
|
||||
@@ -2,8 +2,8 @@ import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import getSlug from 'limax';
|
||||
import dragscroll from '@wekanteam/dragscroll';
|
||||
import '/client/lib/dragscrollTouch';
|
||||
import { enablePageDragscroll, disablePageDragscroll } from '/client/lib/pageDragscroll';
|
||||
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
|
||||
import { BoardMultiSelection } from '/client/lib/boardMultiSelection';
|
||||
import { EscapeActions } from '/client/lib/escapeActions';
|
||||
@@ -11,6 +11,50 @@ import { Utils } from '/client/lib/utils';
|
||||
|
||||
// SubsManager removed for Meteor 3 migration
|
||||
|
||||
// #5850: which sharing scopes (organizations/teams/domains) the admin enabled
|
||||
// in Admin Panel > People > Shared Templates (stored in localStorage, like the
|
||||
// Shared Templates admin UI). Used to gate the drag-to-share drop targets.
|
||||
function loadSharedTemplatesScopes() {
|
||||
try {
|
||||
const raw = window.localStorage.getItem('sharedTemplatesScopes');
|
||||
const arr = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// #5850: share a (template) board with an Organization / Team / Domain by adding
|
||||
// it to the board's groups, add-only (does not remove existing shares and does
|
||||
// not add individual members). Called when a board is dropped on a share target.
|
||||
function shareBoardWith(boardId, shareType, name, id) {
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
if (!board || !boardId) {
|
||||
return;
|
||||
}
|
||||
if (shareType === 'org') {
|
||||
const orgs = (board.orgs || []).slice();
|
||||
if (!orgs.some(o => o.orgId === id)) {
|
||||
orgs.push({ orgId: id, orgDisplayName: name, isActive: true });
|
||||
Meteor.call('setBoardOrgs', orgs, boardId);
|
||||
}
|
||||
} else if (shareType === 'team') {
|
||||
const teams = (board.teams || []).slice();
|
||||
if (!teams.some(t => t.teamId === id)) {
|
||||
teams.push({ teamId: id, teamDisplayName: name, isActive: true });
|
||||
// Preserve the board's current members (group-only sharing adds no
|
||||
// individual members); setBoardTeams sets both members and teams.
|
||||
Meteor.call('setBoardTeams', teams, board.members || [], boardId);
|
||||
}
|
||||
} else if (shareType === 'domain') {
|
||||
const domains = (board.domains || []).slice();
|
||||
if (!domains.some(d => d.domain === id)) {
|
||||
domains.push({ domain: id, isActive: true });
|
||||
Meteor.call('setBoardDomains', domains, boardId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_WORKSPACE_ICON = '📁';
|
||||
|
||||
function getCurrentWorkspacesTree() {
|
||||
@@ -115,9 +159,18 @@ Template.boardListHeaderBar.helpers({
|
||||
Template.boardList.onCreated(function () {
|
||||
Meteor.subscribe('setting');
|
||||
Meteor.subscribe('tableVisibilityModeSettings');
|
||||
this.selectedMenu = new ReactiveVar('starred');
|
||||
// Honor the URL-addressable sub-view (#5850). The route sets
|
||||
// Session 'boardListMenu' to 'starred', 'templates' or 'remaining'.
|
||||
this.selectedMenu = new ReactiveVar(Session.get('boardListMenu') || 'starred');
|
||||
this.selectedWorkspaceIdVar = new ReactiveVar(null);
|
||||
this.workspacesTreeVar = new ReactiveVar([]);
|
||||
// #5850: the user's orgs/teams that have the per-org/team Shared Templates
|
||||
// flag set (plus email domains), fetched via a non-admin server method since
|
||||
// the org/team publications are admin-only. Gates the drag-to-share targets.
|
||||
this.shareableGroups = new ReactiveVar({ orgs: [], teams: [], domains: [] });
|
||||
Meteor.call('getMyShareableGroups', (err, res) => {
|
||||
if (!err && res) this.shareableGroups.set(res);
|
||||
});
|
||||
let currUser = ReactiveCache.getCurrentUser();
|
||||
let userLanguage;
|
||||
if (currUser && currUser.profile) {
|
||||
@@ -196,15 +249,37 @@ Template.boardList.onCreated(function () {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Switch the sub-view live when the route changes (e.g. navigating to
|
||||
// /templates or /remaining while boardList is already rendered). Only the
|
||||
// three recognized menu values are honored so a workspace selection is not
|
||||
// clobbered (#5850).
|
||||
this.autorun(() => {
|
||||
const m = Session.get('boardListMenu');
|
||||
if (m === 'starred' || m === 'templates' || m === 'remaining') {
|
||||
this.selectedMenu.set(m);
|
||||
this.selectedWorkspaceIdVar.set(null);
|
||||
}
|
||||
});
|
||||
|
||||
// The templates-container board is no longer auto-created at signup (#2339,
|
||||
// #5850), and it is no longer auto-created when the user merely opens the
|
||||
// Templates sub-view either: opening the view must not leave behind an empty
|
||||
// Template Container board the user never asked for. The container is created
|
||||
// only on demand -- either when the user explicitly adds one via the
|
||||
// "Add Template Board" flow (createTemplateContainerPopup), or lazily by
|
||||
// ensureTemplatesBoard right before a card/list/swimlane/board is actually
|
||||
// saved as a template. ensureTemplatesBoard remains idempotent (a no-op when
|
||||
// one already exists) so those save paths keep working.
|
||||
});
|
||||
|
||||
Template.boardList.onRendered(function () {
|
||||
// Enable drag-to-scroll on the All Boards list (.board-list.dragscroll), the
|
||||
// same way the board canvas/swimlanes do. This makes mouse drag-scrolling
|
||||
// work on desktop; finger drag-scrolling is handled by dragscrollTouch.js.
|
||||
// The .board-list element is stable across menu/workspace switches (only its
|
||||
// li children are re-rendered), so a single reset() binding is enough.
|
||||
dragscroll.reset();
|
||||
// Enable drag-to-scroll on the All Boards page the same way the board
|
||||
// canvas/swimlanes do. On desktop the page scroll container is <body>, so we
|
||||
// tag it via enablePageDragscroll(); the .board-list.dragscroll element only
|
||||
// becomes a scroll container in mobile view, where its own class handles it.
|
||||
// Finger drag-scrolling is handled by dragscrollTouch.js.
|
||||
enablePageDragscroll();
|
||||
|
||||
// jQuery sortable is disabled in favor of HTML5 drag-and-drop for space management
|
||||
// The old sortable code has been removed to prevent conflicts
|
||||
@@ -250,6 +325,13 @@ Template.boardList.onRendered(function () {
|
||||
*/
|
||||
});
|
||||
|
||||
Template.boardList.onDestroyed(function () {
|
||||
// Stop tagging <body> as drag-scrollable when leaving the All Boards page so
|
||||
// other layouts (e.g. a board view, whose canvas does its own dragscroll) are
|
||||
// not affected.
|
||||
disablePageDragscroll();
|
||||
});
|
||||
|
||||
Template.boardList.helpers({
|
||||
userHasTeams() {
|
||||
if (ReactiveCache.getCurrentUser()?.teams?.length > 0) return true;
|
||||
@@ -360,7 +442,12 @@ Template.boardList.helpers({
|
||||
'tableVisibilityMode-allowPrivateOnly',
|
||||
);
|
||||
|
||||
if (FlowRouter.getRouteName() === 'home') {
|
||||
// #5850: the All Boards sub-views are also reachable via their own routes
|
||||
// (/templates, /remaining), which must apply the same membership filtering
|
||||
// as the home route, otherwise their board list is empty (or falls into the
|
||||
// public-only branch below).
|
||||
const allBoardsRoutes = ['home', 'allboards-templates', 'allboards-remaining'];
|
||||
if (allBoardsRoutes.includes(FlowRouter.getRouteName())) {
|
||||
membershipOrs.push({ 'members.userId': Meteor.userId() });
|
||||
|
||||
const currUser = ReactiveCache.getCurrentUser();
|
||||
@@ -376,6 +463,12 @@ Template.boardList.helpers({
|
||||
let teamsIds = teamIdsUserBelongs.split(',');
|
||||
membershipOrs.push({ 'teams.teamId': { $in: teamsIds } });
|
||||
}
|
||||
|
||||
// #5850: boards shared with the user's email domain.
|
||||
const emailDomains = currUser?.emailDomains?.() || [];
|
||||
if (emailDomains.length) {
|
||||
membershipOrs.push({ 'domains.domain': { $in: emailDomains } });
|
||||
}
|
||||
if (membershipOrs.length) {
|
||||
query.$and.splice(2, 0, { $or: membershipOrs });
|
||||
}
|
||||
@@ -476,6 +569,24 @@ Template.boardList.helpers({
|
||||
isSelectedMenu(type) {
|
||||
return Template.instance().selectedMenu.get() === type;
|
||||
},
|
||||
// #5850: drag-to-share drop targets — the user's organizations/teams/domains,
|
||||
// gated by the admin's Shared Templates scopes (localStorage). Shown in the
|
||||
// Templates view so a personal Template Board can be dragged onto one to share.
|
||||
shareTargets() {
|
||||
const scopes = loadSharedTemplatesScopes();
|
||||
const groups = Template.instance().shareableGroups.get() || {};
|
||||
const targets = [];
|
||||
if (scopes.includes('organizations')) {
|
||||
(groups.orgs || []).forEach(o => targets.push(o));
|
||||
}
|
||||
if (scopes.includes('teams')) {
|
||||
(groups.teams || []).forEach(t => targets.push(t));
|
||||
}
|
||||
if (scopes.includes('domains')) {
|
||||
(groups.domains || []).forEach(d => targets.push(d));
|
||||
}
|
||||
return targets;
|
||||
},
|
||||
isSpaceSelected(id) {
|
||||
return Template.instance().selectedWorkspaceIdVar.get() === id;
|
||||
},
|
||||
@@ -643,6 +754,43 @@ Template.boardList.events({
|
||||
}
|
||||
},
|
||||
// HTML5 DnD from boards to spaces
|
||||
// #5850: drag a (template) board onto an Org/Team/Domain target to share it.
|
||||
'dragover .js-share-target'(evt) {
|
||||
evt.preventDefault();
|
||||
if (evt.originalEvent.dataTransfer) {
|
||||
evt.originalEvent.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
evt.currentTarget.classList.add('board-drag-hint');
|
||||
},
|
||||
'dragleave .js-share-target'(evt) {
|
||||
evt.currentTarget.classList.remove('board-drag-hint');
|
||||
},
|
||||
'drop .js-share-target'(evt) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
const target = evt.currentTarget;
|
||||
target.classList.remove('board-drag-hint');
|
||||
const dt = evt.originalEvent.dataTransfer;
|
||||
const data = dt.getData('text/plain');
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const isMulti = dt.getData('application/x-board-multi') === 'true';
|
||||
let boardIds;
|
||||
if (isMulti) {
|
||||
try {
|
||||
boardIds = JSON.parse(data);
|
||||
} catch (e) {
|
||||
boardIds = [];
|
||||
}
|
||||
} else {
|
||||
boardIds = [data];
|
||||
}
|
||||
const shareType = target.getAttribute('data-share-type');
|
||||
const name = target.getAttribute('data-share-name');
|
||||
const id = target.getAttribute('data-share-id');
|
||||
boardIds.forEach(boardId => shareBoardWith(boardId, shareType, name, id));
|
||||
},
|
||||
'dragstart .js-board'(evt) {
|
||||
const boardId = this._id;
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
.dependency-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* #3392: the overlay SVG stays click-through, but each line is clickable (to
|
||||
open the edit/delete popup) even though its container is pointer-events:none. */
|
||||
.dependency-line {
|
||||
pointer-events: stroke;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dependency-temp-line {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* #3392: drag-to-connect handle on the minicard (no mode — cards stay
|
||||
clickable). Shown on hover; drag it onto another card to create a link. */
|
||||
.minicard .dependency-connect-handle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
inset-inline-end: -7px;
|
||||
right: -7px;
|
||||
transform: translateY(-50%);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #eb144c;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
|
||||
cursor: crosshair;
|
||||
opacity: 0;
|
||||
z-index: 101;
|
||||
}
|
||||
|
||||
.minicard:hover .dependency-connect-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dependency-line-popup .dependency-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.dependency-line-popup .card-dependency-icons li a.is-selected {
|
||||
border-color: #eb144c;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.dependency-line-delete {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #eb144c;
|
||||
}
|
||||
|
||||
/* #3392: card detail "Dependencies" rows */
|
||||
.card-details-dependencies-list .card-dependency {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.card-details-dependencies-list .card-dependency-link {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-details-dependencies-list .js-dependency-type {
|
||||
flex: 0 0 auto;
|
||||
max-width: 130px;
|
||||
}
|
||||
|
||||
.card-details-dependencies-list .js-dependency-color {
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-dependencies-popup .dependency-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.card-dependency-icons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.card-dependency-icons li a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.minicard .badge.minicard-dependencies .badge-icon {
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
template(name="dependencyOverlay")
|
||||
svg.dependency-overlay.js-dependency-overlay(xmlns="http://www.w3.org/2000/svg")
|
||||
defs
|
||||
each dependencyMarkers
|
||||
marker(id="{{markerId}}" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto" markerUnits="userSpaceOnUse")
|
||||
path(d="M0,0 L7,3 L0,6 Z" fill="{{color}}")
|
||||
each dependencyLines
|
||||
path.dependency-line(d="{{path}}" fill="none" stroke="{{color}}" stroke-width="2" marker-end="{{markerUrl}}")
|
||||
if tempLine
|
||||
path.dependency-temp-line(d="{{tempLine}}" fill="none" stroke="#eb144c" stroke-width="2" stroke-dasharray="6,4")
|
||||
|
||||
template(name="dependencyLinePopup")
|
||||
.dependency-line-popup
|
||||
label.dependency-field
|
||||
span {{_ 'dependency-type'}}
|
||||
select.js-line-type
|
||||
each typeOption
|
||||
if selected
|
||||
option(value="{{id}}" selected="selected") {{_ label}}
|
||||
else
|
||||
option(value="{{id}}") {{_ label}}
|
||||
label.dependency-field
|
||||
span {{_ 'dependency-color'}}
|
||||
input.js-line-color(type="color" value="{{color}}")
|
||||
.dependency-field
|
||||
span {{_ 'dependency-icon'}}
|
||||
ul.card-dependency-icons
|
||||
each dependencyIcons
|
||||
li
|
||||
a.js-line-icon(data-icon="{{name}}" title="{{name}}" class="{{#if selected}}is-selected{{/if}}")
|
||||
i.fa(class="fa-{{name}}")
|
||||
hr
|
||||
a.js-line-delete.dependency-line-delete
|
||||
i.fa.fa-times
|
||||
| {{_ 'remove-dependency'}}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import {
|
||||
DEPENDENCY_ICON_CHOICES,
|
||||
DEPENDENCY_TYPES,
|
||||
dependencyTypeMeta,
|
||||
normalizeDependencies,
|
||||
} from '/models/metadata/dependencies';
|
||||
|
||||
// #3392: PI Program Board "Red Strings".
|
||||
// Draw colored, typed connection lines (SVG paths) between cards that declare
|
||||
// cardDependencies. The board stays fully usable (cards clickable) — drawing a
|
||||
// new link is initiated from a small connect handle on each minicard (drag it
|
||||
// onto another card, piplanning.io / Kendis / Miro style), and a link can be
|
||||
// edited/deleted by clicking its line. The overlay SVG itself stays
|
||||
// pointer-events:none; only the lines (and the minicard handles) capture events.
|
||||
//
|
||||
// Coordinates are computed from the live DOM (getBoundingClientRect of
|
||||
// [data-card-id] minicards) translated into the overlay SVG's own coordinate
|
||||
// space, so scrolling/resizing is handled by recomputing relative to the SVG's
|
||||
// current bounding rect.
|
||||
|
||||
// Stable, css-safe marker id for an arrowhead of a given color.
|
||||
function markerIdForColor(color) {
|
||||
return `dependency-arrowhead-${String(color).replace(/[^a-zA-Z0-9]/g, '')}`;
|
||||
}
|
||||
|
||||
Template.dependencyOverlay.onCreated(function () {
|
||||
this.lines = new ReactiveVar([]);
|
||||
this.markers = new ReactiveVar([]);
|
||||
this.tempLine = new ReactiveVar(null); // line being drawn while dragging
|
||||
// Guard against deferred (rAF / setTimeout) callbacks touching the DOM after
|
||||
// the overlay is destroyed ("Can't select in removed DomRange").
|
||||
this._overlayDestroyed = false;
|
||||
|
||||
// Rect of a card relative to the overlay SVG, or null if not rendered.
|
||||
this.rectOf = (cardId, svgRect) => {
|
||||
const el = document.querySelector(`[data-card-id="${cardId}"]`);
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width === 0 && r.height === 0) return null;
|
||||
return { x: r.left - svgRect.left, y: r.top - svgRect.top, w: r.width, h: r.height };
|
||||
};
|
||||
|
||||
this.recompute = () => {
|
||||
if (this._overlayDestroyed || !this.view || this.view.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
const svg = this.find('.js-dependency-overlay');
|
||||
if (!svg) {
|
||||
return;
|
||||
}
|
||||
const board = Utils.getCurrentBoard();
|
||||
if (!board) {
|
||||
this.lines.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
const centerOf = cardId => this.rectOf(cardId, svgRect);
|
||||
|
||||
const cards = ReactiveCache.getCards({
|
||||
boardId: board._id,
|
||||
archived: false,
|
||||
});
|
||||
|
||||
const lines = [];
|
||||
const markerColors = new Set();
|
||||
cards.forEach(card => {
|
||||
const deps = normalizeDependencies(card.cardDependencies);
|
||||
if (deps.length === 0) {
|
||||
return;
|
||||
}
|
||||
const sourceRect = centerOf(card._id);
|
||||
if (!sourceRect) {
|
||||
return;
|
||||
}
|
||||
deps.forEach(dep => {
|
||||
const targetRect = centerOf(dep.cardId);
|
||||
if (!targetRect) {
|
||||
return;
|
||||
}
|
||||
const meta = dependencyTypeMeta(dep.type);
|
||||
// Draw the arrow from the prerequisite card's right edge to the
|
||||
// dependent card's left edge. For forward relations (blocks/fixes/
|
||||
// related-to) the source card is the prerequisite; for the reverse
|
||||
// relations (is-blocked-by/is-fixed-by) the target is.
|
||||
const prereq = meta.forward ? sourceRect : targetRect;
|
||||
const dependent = meta.forward ? targetRect : sourceRect;
|
||||
const x1 = prereq.x + prereq.w;
|
||||
const y1 = prereq.y + prereq.h / 2;
|
||||
const x2 = dependent.x;
|
||||
const y2 = dependent.y + dependent.h / 2;
|
||||
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
|
||||
const path = `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
||||
const markerId = markerIdForColor(dep.color);
|
||||
if (meta.directed) {
|
||||
markerColors.add(dep.color);
|
||||
}
|
||||
lines.push({
|
||||
path,
|
||||
color: dep.color,
|
||||
type: dep.type,
|
||||
icon: dep.icon,
|
||||
directed: meta.directed,
|
||||
markerId: meta.directed ? markerId : '',
|
||||
// Source card (owns the dependency) and target card.
|
||||
fromId: card._id,
|
||||
toId: dep.cardId,
|
||||
});
|
||||
});
|
||||
});
|
||||
this.lines.set(lines);
|
||||
this.markers.set(
|
||||
[...markerColors].map(color => ({
|
||||
color,
|
||||
markerId: markerIdForColor(color),
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
this.scheduleRecompute = () => {
|
||||
if (this._overlayDestroyed) return;
|
||||
if (this._raf) {
|
||||
window.cancelAnimationFrame(this._raf);
|
||||
}
|
||||
this._raf = window.requestAnimationFrame(() => {
|
||||
this._raf = null;
|
||||
this.recompute();
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
Template.dependencyOverlay.onRendered(function () {
|
||||
const instance = this;
|
||||
const svg = this.find('.js-dependency-overlay');
|
||||
this.scrollEl = svg ? this.firstNode.closest('.board-canvas') : null;
|
||||
|
||||
this.autorun(() => {
|
||||
const board = Utils.getCurrentBoard();
|
||||
if (board) {
|
||||
ReactiveCache.getCards({ boardId: board._id, archived: false });
|
||||
}
|
||||
instance.scheduleRecompute();
|
||||
});
|
||||
|
||||
this.onScroll = () => instance.scheduleRecompute();
|
||||
this.onResize = () => instance.scheduleRecompute();
|
||||
|
||||
if (this.scrollEl) {
|
||||
this.scrollEl.addEventListener('scroll', this.onScroll, { passive: true });
|
||||
}
|
||||
window.addEventListener('scroll', this.onScroll, { passive: true });
|
||||
window.addEventListener('resize', this.onResize);
|
||||
|
||||
// --- drag-to-connect (from a minicard's connect handle) -----------------
|
||||
// Find the card under a viewport point. The overlay is pointer-events:none,
|
||||
// so elementsFromPoint returns the minicards directly.
|
||||
const cardIdAtPoint = (clientX, clientY) => {
|
||||
const els = document.elementsFromPoint(clientX, clientY);
|
||||
for (const el of els) {
|
||||
if (svg && svg.contains(el)) continue;
|
||||
const card = el.closest && el.closest('[data-card-id]');
|
||||
if (card) return card.getAttribute('data-card-id');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
this.onMouseDown = e => {
|
||||
if (e.button !== 0) return;
|
||||
const handle =
|
||||
e.target.closest && e.target.closest('.js-dependency-connect-handle');
|
||||
if (!handle) return;
|
||||
const cardEl = handle.closest('[data-card-id]');
|
||||
if (!cardEl) return;
|
||||
// Beat the card drag-sort: capture-phase + stop propagation.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
instance.dragSourceId = cardEl.getAttribute('data-card-id');
|
||||
};
|
||||
|
||||
this.onMouseMove = e => {
|
||||
if (!instance.dragSourceId || !svg) return;
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
const from = instance.rectOf(instance.dragSourceId, svgRect);
|
||||
if (!from) return;
|
||||
const x1 = from.x + from.w;
|
||||
const y1 = from.y + from.h / 2;
|
||||
const x2 = e.clientX - svgRect.left;
|
||||
const y2 = e.clientY - svgRect.top;
|
||||
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
|
||||
instance.tempLine.set(
|
||||
`M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`,
|
||||
);
|
||||
};
|
||||
|
||||
this.onMouseUp = e => {
|
||||
if (!instance.dragSourceId) return;
|
||||
const sourceId = instance.dragSourceId;
|
||||
instance.dragSourceId = null;
|
||||
instance.tempLine.set(null);
|
||||
const targetId = cardIdAtPoint(e.clientX, e.clientY);
|
||||
if (targetId && targetId !== sourceId) {
|
||||
const card = ReactiveCache.getCard(sourceId);
|
||||
if (card && typeof card.addDependency === 'function') {
|
||||
card.addDependency(targetId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Capture phase so the connect drag starts before the sortable grabs the card.
|
||||
document.addEventListener('mousedown', this.onMouseDown, true);
|
||||
document.addEventListener('mousemove', this.onMouseMove);
|
||||
document.addEventListener('mouseup', this.onMouseUp);
|
||||
|
||||
this._initTimeout = Meteor.setTimeout(() => instance.scheduleRecompute(), 300);
|
||||
});
|
||||
|
||||
Template.dependencyOverlay.onDestroyed(function () {
|
||||
this._overlayDestroyed = true;
|
||||
if (this.scrollEl) {
|
||||
this.scrollEl.removeEventListener('scroll', this.onScroll);
|
||||
}
|
||||
window.removeEventListener('scroll', this.onScroll);
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
if (this.onMouseDown) document.removeEventListener('mousedown', this.onMouseDown, true);
|
||||
if (this.onMouseMove) document.removeEventListener('mousemove', this.onMouseMove);
|
||||
if (this.onMouseUp) document.removeEventListener('mouseup', this.onMouseUp);
|
||||
if (this._raf) {
|
||||
window.cancelAnimationFrame(this._raf);
|
||||
}
|
||||
if (this._initTimeout) {
|
||||
Meteor.clearTimeout(this._initTimeout);
|
||||
}
|
||||
});
|
||||
|
||||
Template.dependencyOverlay.helpers({
|
||||
dependencyLines() {
|
||||
return Template.instance().lines.get();
|
||||
},
|
||||
dependencyMarkers() {
|
||||
return Template.instance().markers.get();
|
||||
},
|
||||
tempLine() {
|
||||
return Template.instance().tempLine.get();
|
||||
},
|
||||
markerUrl() {
|
||||
return this.markerId ? `url(#${this.markerId})` : '';
|
||||
},
|
||||
});
|
||||
|
||||
Template.dependencyOverlay.events({
|
||||
'click .dependency-line'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// `this` is the line data object (fromId/toId/type/color/icon).
|
||||
Popup.open('dependencyLine').call(this, event);
|
||||
},
|
||||
});
|
||||
|
||||
// #3392: popup to edit or delete an on-board dependency line. Data context is the
|
||||
// line object { fromId, toId, type, color, icon }.
|
||||
Template.dependencyLinePopup.helpers({
|
||||
typeOption() {
|
||||
const current = this.type;
|
||||
return DEPENDENCY_TYPES.map(t => ({
|
||||
id: t.id,
|
||||
label: `dependency-type-${t.id}`,
|
||||
selected: t.id === current,
|
||||
}));
|
||||
},
|
||||
color() {
|
||||
return this.color;
|
||||
},
|
||||
dependencyIcons() {
|
||||
const current = this.icon;
|
||||
return DEPENDENCY_ICON_CHOICES.map(name => ({ name, selected: name === current }));
|
||||
},
|
||||
});
|
||||
|
||||
Template.dependencyLinePopup.events({
|
||||
'change .js-line-type'(event) {
|
||||
const card = ReactiveCache.getCard(this.fromId);
|
||||
if (card) card.setDependencyProps(this.toId, { type: event.currentTarget.value });
|
||||
},
|
||||
'change .js-line-color'(event) {
|
||||
const card = ReactiveCache.getCard(this.fromId);
|
||||
if (card) card.setDependencyProps(this.toId, { color: event.currentTarget.value });
|
||||
},
|
||||
'click .js-line-icon'(event) {
|
||||
const card = ReactiveCache.getCard(this.fromId);
|
||||
if (card) card.setDependencyProps(this.toId, { icon: event.currentTarget.dataset.icon });
|
||||
},
|
||||
'click .js-line-delete'() {
|
||||
const card = ReactiveCache.getCard(this.fromId);
|
||||
if (card) card.removeDependency(this.toId);
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
@@ -35,7 +35,7 @@
|
||||
}
|
||||
|
||||
.original-positions-loading i {
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
|
||||
.original-positions-filters {
|
||||
@@ -117,7 +117,7 @@
|
||||
}
|
||||
|
||||
.original-position-item-details {
|
||||
margin-left: 24px;
|
||||
margin-inline-start: 24px;
|
||||
}
|
||||
|
||||
.original-position-description {
|
||||
@@ -191,7 +191,7 @@
|
||||
}
|
||||
|
||||
.original-position-item-details {
|
||||
margin-left: 0;
|
||||
margin-inline-start: 0;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -55,11 +55,10 @@ template(name="cardCustomField-number")
|
||||
template(name="cardCustomField-checkbox")
|
||||
.js-checklist-item.checklist-item(class="{{#if data.value }}is-checked{{/if}}")
|
||||
if canModifyCard
|
||||
span.check-box-unicode
|
||||
i.fa(class="{{#if data.value}}fa-check-square{{else}}fa-square-o{{/if}}")
|
||||
.check-box-container
|
||||
.check-box.materialCheckBox(class="{{#if data.value}}is-checked{{/if}}")
|
||||
else
|
||||
span.check-box-unicode
|
||||
i.fa(class="{{#if data.value}}fa-check-square{{else}}fa-square-o{{/if}}")
|
||||
.materialCheckBox(class="{{#if data.value}}is-checked{{/if}}")
|
||||
|
||||
template(name="cardCustomField-currency")
|
||||
if canModifyCard
|
||||
|
||||
@@ -106,9 +106,6 @@ Template['cardCustomField-checkbox'].onCreated(function () {
|
||||
});
|
||||
|
||||
Template['cardCustomField-checkbox'].events({
|
||||
'click .js-checklist-item .check-box-unicode'(event, tpl) {
|
||||
tpl.card.setCustomField(tpl.customFieldId, !Template.currentData().value);
|
||||
},
|
||||
'click .js-checklist-item .check-box-container'(event, tpl) {
|
||||
tpl.card.setCustomField(tpl.customFieldId, !Template.currentData().value);
|
||||
},
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
}
|
||||
.card-date time::before {
|
||||
font-size: inherit;
|
||||
margin-right: 0.3em;
|
||||
margin-inline-end: 0.3em;
|
||||
display: inline-block;
|
||||
}
|
||||
.customfield-date {
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
.assignee {
|
||||
display: block;
|
||||
position: relative;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
height: clamp(24px, 3.5vw, 36px);
|
||||
width: clamp(24px, 3.5vw, 36px);
|
||||
margin: 0.3vh;
|
||||
@@ -73,7 +73,7 @@
|
||||
height: 7px;
|
||||
width: 7px;
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
inset-inline-end: -1px;
|
||||
bottom: -1px;
|
||||
z-index: 15;
|
||||
}
|
||||
@@ -142,36 +142,36 @@ body.desktop-mode .card-details:not(.card-details-popup) {
|
||||
/* Default position for first card or when dragged */
|
||||
body.desktop-mode .card-details:not(.card-details-popup):not([style*="left"]):not([style*="top"]) {
|
||||
top: 8px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
inset-inline-start: 20px;
|
||||
inset-inline-end: 20px;
|
||||
bottom: 20px;
|
||||
}
|
||||
|
||||
/* Stagger positions for multiple cards using nth-of-type */
|
||||
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(1) {
|
||||
top: 8px;
|
||||
left: 20px;
|
||||
inset-inline-start: 20px;
|
||||
}
|
||||
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(2) {
|
||||
top: 38px;
|
||||
left: 50px;
|
||||
inset-inline-start: 50px;
|
||||
}
|
||||
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(3) {
|
||||
top: 68px;
|
||||
left: 80px;
|
||||
inset-inline-start: 80px;
|
||||
}
|
||||
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(4) {
|
||||
top: 98px;
|
||||
left: 110px;
|
||||
inset-inline-start: 110px;
|
||||
}
|
||||
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(5) {
|
||||
top: 128px;
|
||||
left: 140px;
|
||||
inset-inline-start: 140px;
|
||||
}
|
||||
|
||||
/* For expanded cards, set dimensions */
|
||||
body.desktop-mode .card-details:not(.card-details-popup):not(.card-details-collapsed) {
|
||||
right: 20px;
|
||||
inset-inline-end: 20px;
|
||||
bottom: 20px;
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
bottom: auto !important;
|
||||
}
|
||||
.card-details .mCustomScrollBox {
|
||||
padding-left: 0;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
.card-details .card-details-canvas {
|
||||
width: auto;
|
||||
@@ -208,16 +208,16 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
.card-details .card-details-header .card-number {
|
||||
color: #b3b3b3;
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
margin-inline-end: 6px;
|
||||
}
|
||||
|
||||
/* Collapse toggle triangle */
|
||||
.card-details .card-details-header .card-collapse-toggle {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
font-size: 20px;
|
||||
padding: 7px 10px;
|
||||
margin-left: -10px;
|
||||
margin-right: 5px;
|
||||
margin-inline-start: -10px;
|
||||
margin-inline-end: 5px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: #000;
|
||||
@@ -228,11 +228,11 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
.card-details .card-details-header .card-drag-handle {
|
||||
font-size: 20px;
|
||||
padding: 8px 10px;
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
display: inline-block;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
vertical-align: middle;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -246,14 +246,14 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
.card-details .card-details-header .close-card-details-mobile-web,
|
||||
.card-details .card-details-header .card-details-menu-mobile-web,
|
||||
.card-details .card-details-header .copied-tooltip {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
.card-details .card-details-header .close-card-details,
|
||||
.card-details .card-details-header .maximize-card-details,
|
||||
.card-details .card-details-header .minimize-card-details {
|
||||
font-size: 24px;
|
||||
padding: 5px 10px 5px 10px;
|
||||
margin-right: -8px;
|
||||
margin-inline-end: -8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
vertical-align: middle;
|
||||
@@ -264,19 +264,19 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
.card-details .card-details-header .card-mobile-desktop-toggle {
|
||||
font-size: 24px;
|
||||
padding: 5px;
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.card-details .card-details-header .card-copy-button {
|
||||
font-size: 17px;
|
||||
padding: 10px;
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
.card-details .card-details-header .card-copy-mobile-button {
|
||||
font-size: 17px;
|
||||
padding: 10px;
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
.card-details .card-details-header .card-details-menu {
|
||||
font-size: 17px;
|
||||
@@ -287,17 +287,17 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
.card-details .card-details-header .card-details-menu-mobile-web {
|
||||
font-size: 17px;
|
||||
padding: 10px;
|
||||
margin-right: 30px;
|
||||
margin-inline-end: 30px;
|
||||
}
|
||||
.card-details .card-details-header .card-mobile-desktop-toggle,
|
||||
.card-details .card-details-header .card-zoom-in,
|
||||
.card-details .card-details-header .card-zoom-out {
|
||||
font-size: 24px;
|
||||
padding: 5px 10px 5px 10px;
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
|
||||
/* Unify all card text to match title size */
|
||||
@@ -321,7 +321,7 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
}
|
||||
.card-details .card-details-header .card-details-watch {
|
||||
font-size: 17px;
|
||||
padding-left: 7px;
|
||||
padding-inline-start: 7px;
|
||||
color: #a6a6a6;
|
||||
}
|
||||
.card-details .card-details-header .card-details-title {
|
||||
@@ -363,11 +363,11 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
padding: 0 5px;
|
||||
}
|
||||
.card-details .card-details-header .copied-tooltip {
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
.card-details .card-description i.fa.fa-pencil-square-o {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
.card-details .card-description textarea {
|
||||
min-height: 100px;
|
||||
@@ -380,11 +380,11 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
gap: 0.5em;
|
||||
}
|
||||
.card-details .card-details-items .card-details-item {
|
||||
margin-right: 0.5em;
|
||||
margin-inline-end: 0.5em;
|
||||
flex-grow: 1;
|
||||
}
|
||||
.card-details .card-details-items .card-details-item:last-child {
|
||||
margin-right: 0;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
.card-details .card-details-items .card-details-item.card-details-item-labels {
|
||||
display: block;
|
||||
@@ -409,7 +409,7 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
max-width: 28%;
|
||||
}
|
||||
.card-details .card-details-items .card-details-item.custom-fields {
|
||||
padding-left: 10px;
|
||||
padding-inline-start: 10px;
|
||||
}
|
||||
.card-details .card-details-item-title {
|
||||
font-size: 16px;
|
||||
@@ -422,7 +422,7 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
@media screen and (min-width: 801px) {
|
||||
.card-details {
|
||||
top: 97px;
|
||||
left: calc(50% - (600px / 2));
|
||||
inset-inline-start: calc(50% - (600px / 2));
|
||||
width: 600px;
|
||||
bottom: 0;
|
||||
position: fixed;
|
||||
@@ -433,8 +433,8 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
body.mobile-mode .card-details {
|
||||
width: 100vw !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
bottom: 0 !important;
|
||||
height: 100vh !important;
|
||||
max-height: 100vh !important;
|
||||
@@ -456,23 +456,23 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
transition: flex-basis 0.1s;
|
||||
box-sizing: border-box;
|
||||
top: 97px;
|
||||
left: 0px;
|
||||
inset-inline-start: 0px;
|
||||
height: calc(100% - 100px);
|
||||
width: calc(100% - 20px);
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
.card-details-maximized .card-details-left {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
top: 60px;
|
||||
left: 20px;
|
||||
inset-inline-start: 20px;
|
||||
width: 47%;
|
||||
border-right: solid 2px #dbdbdb;
|
||||
padding-right: 10px;
|
||||
border-inline-end: solid 2px #dbdbdb;
|
||||
padding-inline-end: 10px;
|
||||
}
|
||||
.card-details-maximized .card-details-right {
|
||||
position: absolute;
|
||||
float: right;
|
||||
left: 50%;
|
||||
float: inline-end;
|
||||
inset-inline-start: 50%;
|
||||
margin: 15px 0;
|
||||
}
|
||||
.card-details-maximized .card-details-header {
|
||||
@@ -480,12 +480,12 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
}
|
||||
}
|
||||
input[type="text"].attachment-add-link-input {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
margin: 0 0 8px;
|
||||
width: 80%;
|
||||
}
|
||||
input[type="submit"].attachment-add-link-submit {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
margin: 0 0 8px 4px;
|
||||
padding: 6px 12px;
|
||||
width: 18%;
|
||||
@@ -501,8 +501,8 @@ input[type="submit"].attachment-add-link-submit {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
bottom: 0 !important;
|
||||
z-index: 100 !important;
|
||||
height: 100vh !important;
|
||||
@@ -515,38 +515,38 @@ input[type="submit"].attachment-add-link-submit {
|
||||
body.mobile-mode .card-details {
|
||||
z-index: 100 !important;
|
||||
width: 100vw !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
}
|
||||
.card-details .card-details-canvas {
|
||||
width: 100%;
|
||||
padding-left: 0px;
|
||||
padding-inline-start: 0px;
|
||||
padding: 0 15px;
|
||||
}
|
||||
.card-details .card-details-header .close-card-details {
|
||||
margin-right: 0px;
|
||||
margin-inline-end: 0px;
|
||||
display: block !important;
|
||||
}
|
||||
.card-details .card-details-header .close-card-details-mobile-web {
|
||||
display: block !important;
|
||||
margin-right: 5px !important;
|
||||
margin-inline-end: 5px !important;
|
||||
}
|
||||
.card-details .card-details-header .card-mobile-desktop-toggle {
|
||||
display: block !important;
|
||||
margin-right: 5px !important;
|
||||
margin-inline-end: 5px !important;
|
||||
}
|
||||
.card-details .card-details-header .card-mobile-desktop-toggle {
|
||||
display: block !important;
|
||||
margin-right: 5px !important;
|
||||
margin-inline-end: 5px !important;
|
||||
}
|
||||
.card-details .card-details-header .card-details-menu {
|
||||
margin-right: 40px;
|
||||
margin-inline-end: 40px;
|
||||
}
|
||||
.card-details .card-details-header .maximize-card-details {
|
||||
margin-right: 40px;
|
||||
margin-inline-end: 40px;
|
||||
}
|
||||
.card-details .card-details-header .minimize-card-details {
|
||||
margin-right: 40px;
|
||||
margin-inline-end: 40px;
|
||||
}
|
||||
.card-details-popup {
|
||||
padding: 0px 10px;
|
||||
@@ -565,7 +565,7 @@ input[type="submit"].attachment-add-link-submit {
|
||||
}
|
||||
/* iPhone mobile: enlarge header buttons and increase spacing */
|
||||
body.mobile-mode.iphone-device .card-details .card-details-header {
|
||||
padding-right: 16px;
|
||||
padding-inline-end: 16px;
|
||||
}
|
||||
body.mobile-mode.iphone-device .card-details .card-details-header .close-card-details,
|
||||
body.mobile-mode.iphone-device .card-details .card-details-header .maximize-card-details,
|
||||
@@ -577,12 +577,12 @@ input[type="submit"].attachment-add-link-submit {
|
||||
body.mobile-mode.iphone-device .card-details .card-details-header .card-zoom-out {
|
||||
font-size: 2em !important; /* 2x bigger */
|
||||
padding: 0.3em !important;
|
||||
margin-right: 0.75em !important; /* 2x space compared to default */
|
||||
margin-left: 0 !important;
|
||||
margin-inline-end: 0.75em !important; /* 2x space compared to default */
|
||||
margin-inline-start: 0 !important;
|
||||
}
|
||||
/* Avoid clipping of the close button on the right edge */
|
||||
body.mobile-mode.iphone-device .card-details .card-details-header .close-card-details {
|
||||
margin-right: 0.75em !important;
|
||||
margin-inline-end: 0.75em !important;
|
||||
}
|
||||
/* Enlarge the header title too */
|
||||
body.mobile-mode.iphone-device .card-details .card-details-header .card-details-title {
|
||||
@@ -598,8 +598,8 @@ body.mobile-mode .card-details {
|
||||
margin: 0px !important;
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
bottom: 0 !important;
|
||||
z-index: 100 !important;
|
||||
height: 100vh !important;
|
||||
@@ -731,7 +731,7 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
}
|
||||
.vote-title .js-edit-date {
|
||||
align-self: flex-start;
|
||||
margin-left: 6px;
|
||||
margin-inline-start: 6px;
|
||||
}
|
||||
.vote-result {
|
||||
display: flex;
|
||||
@@ -750,7 +750,7 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
}
|
||||
.poker-title .js-edit-date {
|
||||
align-self: flex-start;
|
||||
margin-left: 6px;
|
||||
margin-inline-start: 6px;
|
||||
}
|
||||
.poker-result {
|
||||
display: flex;
|
||||
@@ -827,11 +827,11 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
display: inline-block;
|
||||
}
|
||||
.poker-table-side-right {
|
||||
padding-left: 10px;
|
||||
padding-inline-start: 10px;
|
||||
}
|
||||
@media (max-width: 400px) {
|
||||
.poker-table-side-right {
|
||||
padding-left: 0px;
|
||||
padding-inline-start: 0px;
|
||||
}
|
||||
}
|
||||
.estimation-add {
|
||||
@@ -842,16 +842,16 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
}
|
||||
.estimation-add input {
|
||||
display: inline-block;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
margin: auto;
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
width: 100px;
|
||||
border-radius: 2px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
.estimation-add button {
|
||||
display: inline-block;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
margin: auto;
|
||||
border-radius: 2px;
|
||||
padding: 3px 10px;
|
||||
@@ -859,7 +859,7 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
.poker-card {
|
||||
width: 48px;
|
||||
height: 72px;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
display: table;
|
||||
@@ -890,3 +890,194 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
transform: scale(1.01);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Sticker picker + card-detail stickers + due-complete toggle */
|
||||
.card-stickers-picker {
|
||||
max-width: 320px;
|
||||
}
|
||||
.card-sticker-select {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: 2px;
|
||||
border-radius: 3px;
|
||||
font-size: 18px;
|
||||
color: #4d4d4d;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.card-sticker-select:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
.card-sticker-select.active {
|
||||
background: #d6ecd2;
|
||||
color: #3cb500;
|
||||
}
|
||||
.card-sticker-select .card-sticker-check {
|
||||
position: absolute;
|
||||
bottom: 1px;
|
||||
inset-inline-end: 2px;
|
||||
font-size: 10px;
|
||||
color: #3cb500;
|
||||
}
|
||||
.card-stickers .card-sticker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0 8px 4px 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
.card-sticker .card-sticker-remove {
|
||||
margin-inline-start: 3px;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-sticker .card-sticker-remove:hover {
|
||||
color: #eb4646;
|
||||
}
|
||||
.card-due-complete-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-inline-start: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Trello-style "mark complete" toggle, left of the card title */
|
||||
.card-details-complete-toggle {
|
||||
float: inline-start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* Title is 1.35em with 1.3 line-height (~28px box) and margin-top 7px; push
|
||||
the 13px checkbox down so it lands at the vertical middle of the title. */
|
||||
margin: 14px 10px 0 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Card locations: multiple locations, each editable with a remove button */
|
||||
.card-details-item-location .card-locations {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.card-details-item-location .card-location {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
border: 1px solid #e3e3e3;
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.card-details-item-location .card-location-info {
|
||||
flex: 1;
|
||||
}
|
||||
.card-details-item-location .js-edit-location {
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-details-item-location .card-location-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
.card-details-item-location .card-location-address {
|
||||
color: #4d4d4d;
|
||||
}
|
||||
.card-details-item-location .card-location-remove {
|
||||
margin-inline-start: 8px;
|
||||
color: #8c8c8c;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-details-item-location .card-location-remove:hover {
|
||||
color: #eb4646;
|
||||
}
|
||||
|
||||
/* Sticker icon highlight styles (used instead of colour): taco pack => underline,
|
||||
pete pack => round (ring around the icon). Icon keeps its normal colour. */
|
||||
.card-sticker i.sticker-underline {
|
||||
border-bottom: 2px solid currentColor;
|
||||
padding-bottom: 1px;
|
||||
line-height: 1;
|
||||
}
|
||||
.card-sticker i.sticker-round {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.6em;
|
||||
height: 1.6em;
|
||||
line-height: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* "Open map links at" provider selector in the location popup */
|
||||
.js-card-location-form .map-provider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.js-card-location-form .map-provider-row .js-map-provider {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Opened card (card details popup) docks to the very top of the window and
|
||||
overlays the global + board header bars instead of opening below them. The
|
||||
base .pop-over is position:absolute with a small margin/top offset, which left
|
||||
the card's top behind the fixed header bars; fix it to the viewport top with a
|
||||
z-index above every header (headers top out around z-index 1000-2000). */
|
||||
/* The card details content (.card-details) is always position:fixed (see the
|
||||
min-width:801px and max-width:800px media queries above), so it renders as its
|
||||
own framed, viewport-positioned box and escapes this .pop-over wrapper. That
|
||||
leaves the generic .pop-over chrome (white background, border, shadow, header
|
||||
and ~380px width) visible as a redundant empty second popup at the top of the
|
||||
page. Collapse the wrapper to nothing and make it invisible; the fixed card
|
||||
descendant still renders normally on top of the page. Closing is handled by
|
||||
the card's own close button (.js-close-card-details), clicking outside, or
|
||||
pressing Escape. */
|
||||
.pop-over[data-popup="cardDetailsPopup"] {
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
min-height: 0 !important;
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
overflow: visible !important;
|
||||
z-index: 100000 !important;
|
||||
}
|
||||
|
||||
.pop-over[data-popup="cardDetailsPopup"] > .header {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.pop-over[data-popup="cardDetailsPopup"] > .content-wrapper {
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Sticker highlight styles inside the sticker picker (mascot = underline,
|
||||
computer = round), matching how they render on the card. */
|
||||
.card-sticker-select i.sticker-underline {
|
||||
border-bottom: 2px solid currentColor;
|
||||
padding-bottom: 1px;
|
||||
line-height: 1;
|
||||
}
|
||||
.card-sticker-select i.sticker-round {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.6em;
|
||||
height: 1.6em;
|
||||
line-height: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -61,6 +61,10 @@ template(name="cardDetails")
|
||||
span.emoji-icon
|
||||
i.fa.fa-link
|
||||
span.copied-tooltip {{_ 'copied'}}
|
||||
if canModifyCard
|
||||
if currentBoard.allowsDueComplete
|
||||
a.card-details-complete-toggle.js-toggle-due-complete(title="{{#if getDueComplete}}{{_ 'card-mark-incomplete'}}{{else}}{{_ 'card-mark-complete'}}{{/if}}")
|
||||
.materialCheckBox(class="{{#if getDueComplete}}is-checked{{/if}}")
|
||||
h2.card-details-title.js-card-title(
|
||||
class="{{#if canModifyCard}}js-open-inlined-form is-editable{{else}}js-card-title-drag-handle{{/if}}")
|
||||
+viewer
|
||||
@@ -126,6 +130,49 @@ template(name="cardDetails")
|
||||
a.card-label.add-label.js-add-labels(title="{{_ 'card-labels-title'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
.card-details-item.card-details-item-stickers
|
||||
h3.card-details-item-title
|
||||
i.fa.fa-sticky-note-o
|
||||
| {{_ 'stickers'}}
|
||||
.card-details-item-content.card-stickers
|
||||
each stickers
|
||||
span.card-sticker(title="{{#if name}}{{name}}{{else}}{{icon}}{{/if}}")
|
||||
i.fa(class="fa-{{icon}} {{#if highlight}}sticker-{{highlight}}{{/if}}")
|
||||
if canModifyCard
|
||||
a.card-sticker-remove.js-remove-sticker(data-index="{{@index}}" title="{{_ 'remove-sticker'}}")
|
||||
i.fa.fa-times
|
||||
if canModifyCard
|
||||
unless currentUser.isWorker
|
||||
a.card-label.add-label.js-add-stickers(title="{{_ 'add-sticker'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
.card-details-item.card-details-item-location
|
||||
h3.card-details-item-title
|
||||
i.fa.fa-map-marker
|
||||
| {{_ 'location'}}
|
||||
.card-details-item-content.card-locations
|
||||
each getLocations
|
||||
.card-location
|
||||
.card-location-info(class="{{#if canModifyCard}}js-edit-location{{/if}}" data-location-id="{{_id}}")
|
||||
if name
|
||||
.card-location-name= name
|
||||
if address
|
||||
.card-location-address= address
|
||||
if hasCoordinates
|
||||
a.card-location-map(href=mapUrl target="_blank" rel="noopener noreferrer")
|
||||
i.fa.fa-map-o
|
||||
= ' '
|
||||
| {{_ 'location-open-map'}}
|
||||
if canModifyCard
|
||||
a.card-location-remove.js-remove-location(data-location-id="{{_id}}" title="{{_ 'delete'}}")
|
||||
i.fa.fa-times
|
||||
if canModifyCard
|
||||
unless currentUser.isWorker
|
||||
a.card-label.add-label.js-add-location(title="{{_ 'add-location'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
+cardButtons(_id=_id boardId=boardId)
|
||||
|
||||
if currentBoard.hasAnyAllowsDate
|
||||
hr
|
||||
|
||||
@@ -275,6 +322,36 @@ template(name="cardDetails")
|
||||
+viewer
|
||||
= getAssignedBy
|
||||
|
||||
.card-details-item.card-details-item-dependencies
|
||||
h3.card-details-item-title
|
||||
i.fa.fa-link
|
||||
| {{_ 'card-dependencies'}}
|
||||
.card-details-dependencies-list
|
||||
each getDependencyCards
|
||||
.card-dependency(data-target-id="{{card._id}}")
|
||||
i.fa(class="fa-{{icon}}" style="color:{{color}}")
|
||||
a.card-dependency-link(href="{{linkUrl}}")
|
||||
+viewer
|
||||
= card.title
|
||||
if canModifyCard
|
||||
select.js-dependency-type(data-target-id="{{card._id}}")
|
||||
each typeOption
|
||||
if selected
|
||||
option(value="{{id}}" selected="selected") {{_ label}}
|
||||
else
|
||||
option(value="{{id}}") {{_ label}}
|
||||
input.js-dependency-color(type="color" value="{{color}}" data-target-id="{{card._id}}" title="{{_ 'dependency-color'}}")
|
||||
a.js-dependency-icon(data-target-id="{{card._id}}" title="{{_ 'dependency-icon'}}")
|
||||
i.fa.fa-pencil
|
||||
a.card-dependency-remove.js-remove-dependency(data-target-id="{{card._id}}" title="{{_ 'remove-dependency'}}")
|
||||
i.fa.fa-times
|
||||
else
|
||||
span.card-dependency-type {{_ typeLabel}}
|
||||
if canModifyCard
|
||||
a.js-add-dependency(title="{{_ 'add-dependency'}}")
|
||||
i.fa.fa-plus
|
||||
| {{_ 'add-dependency'}}
|
||||
|
||||
if $or currentBoard.allowsCardSortingByNumber getSpentTime
|
||||
hr
|
||||
|
||||
@@ -868,17 +945,17 @@ template(name="moveCardPopup")
|
||||
label {{_ 'boards'}}:
|
||||
select.js-select-boards(autofocus)
|
||||
each boards
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
|
||||
label {{_ 'swimlanes'}}:
|
||||
select.js-select-swimlanes
|
||||
each swimlanes
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
|
||||
|
||||
label {{_ 'lists'}}:
|
||||
select.js-select-lists
|
||||
each lists
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
|
||||
label {{_ 'cards'}}:
|
||||
select.js-select-cards
|
||||
@@ -903,17 +980,17 @@ template(name="copyCardPopup")
|
||||
label {{_ 'boards'}}:
|
||||
select.js-select-boards(autofocus)
|
||||
each boards
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
|
||||
label {{_ 'swimlanes'}}:
|
||||
select.js-select-swimlanes
|
||||
each swimlanes
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
|
||||
|
||||
label {{_ 'lists'}}:
|
||||
select.js-select-lists
|
||||
each lists
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
|
||||
label {{_ 'cards'}}:
|
||||
select.js-select-cards
|
||||
@@ -938,17 +1015,17 @@ template(name="copyManyCardsPopup")
|
||||
label {{_ 'boards'}}:
|
||||
select.js-select-boards(autofocus)
|
||||
each boards
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
|
||||
label {{_ 'swimlanes'}}:
|
||||
select.js-select-swimlanes
|
||||
each swimlanes
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
|
||||
|
||||
label {{_ 'lists'}}:
|
||||
select.js-select-lists
|
||||
each lists
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
|
||||
label {{_ 'cards'}}:
|
||||
select.js-select-cards
|
||||
@@ -973,17 +1050,17 @@ template(name="convertChecklistItemToCardPopup")
|
||||
label {{_ 'boards'}}:
|
||||
select.js-select-boards(autofocus)
|
||||
each boards
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
|
||||
label {{_ 'swimlanes'}}:
|
||||
select.js-select-swimlanes
|
||||
each swimlanes
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
|
||||
|
||||
label {{_ 'lists'}}:
|
||||
select.js-select-lists
|
||||
each lists
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
|
||||
label {{_ 'cards'}}:
|
||||
select.js-select-cards
|
||||
@@ -1196,3 +1273,78 @@ template(name="cardStartPlanningPokerPopup")
|
||||
if getPokerQuestion
|
||||
if currentUser.isBoardAdmin
|
||||
button.js-remove-poker.negate.wide.right {{_ 'delete'}}
|
||||
|
||||
template(name="cardLocationsPopup")
|
||||
form.js-card-location-form
|
||||
label
|
||||
| {{_ 'location-detect-from-map'}}
|
||||
input.js-location-map-link(type="text" placeholder="https://..." autofocus)
|
||||
button.primary.js-detect-location(type="button") {{_ 'location-detect'}}
|
||||
if detectMessage
|
||||
p.quiet.js-detect-message {{detectMessage}}
|
||||
hr
|
||||
label
|
||||
| {{_ 'location-name'}}
|
||||
input.js-location-name(type="text" value="{{location.name}}")
|
||||
label
|
||||
| {{_ 'location-address'}}
|
||||
input.js-location-address(type="text" value="{{location.address}}")
|
||||
label
|
||||
| {{_ 'location-latitude'}}
|
||||
input.js-location-latitude(type="number" step="any" value="{{location.latitude}}")
|
||||
label
|
||||
| {{_ 'location-longitude'}}
|
||||
input.js-location-longitude(type="number" step="any" value="{{location.longitude}}")
|
||||
button.primary.confirm.js-submit-location(type="submit") {{_ 'save'}}
|
||||
if location._id
|
||||
button.negate.wide.right.js-delete-location(type="button") {{_ 'delete'}}
|
||||
hr
|
||||
.map-provider-row
|
||||
span.map-provider-label {{_ 'location-open-map-at'}}
|
||||
select.js-map-provider
|
||||
optgroup(label="{{_ 'map-region-usa'}}")
|
||||
option(value="google" selected="{{#if isMapProvider 'google'}}selected{{/if}}") Google Maps
|
||||
option(value="bing" selected="{{#if isMapProvider 'bing'}}selected{{/if}}") Bing Maps
|
||||
option(value="apple" selected="{{#if isMapProvider 'apple'}}selected{{/if}}") Apple Maps
|
||||
option(value="waze" selected="{{#if isMapProvider 'waze'}}selected{{/if}}") Waze
|
||||
optgroup(label="{{_ 'map-region-europe'}}")
|
||||
option(value="openstreetmap" selected="{{#if isMapProvider 'openstreetmap'}}selected{{/if}}") OpenStreetMap
|
||||
option(value="here" selected="{{#if isMapProvider 'here'}}selected{{/if}}") HERE WeGo
|
||||
option(value="yandex" selected="{{#if isMapProvider 'yandex'}}selected{{/if}}") Yandex Maps
|
||||
option(value="mapy" selected="{{#if isMapProvider 'mapy'}}selected{{/if}}") Mapy.cz
|
||||
option(value="2gis" selected="{{#if isMapProvider '2gis'}}selected{{/if}}") 2GIS
|
||||
optgroup(label="{{_ 'map-region-asia'}}")
|
||||
option(value="baidu" selected="{{#if isMapProvider 'baidu'}}selected{{/if}}") Baidu Maps
|
||||
option(value="amap" selected="{{#if isMapProvider 'amap'}}selected{{/if}}") Amap (Gaode)
|
||||
button.primary.js-save-map-provider(type="button") {{_ 'save'}}
|
||||
if mapSavedMessage
|
||||
p.quiet.js-map-saved {{mapSavedMessage}}
|
||||
|
||||
template(name="cardDependenciesPopup")
|
||||
.card-dependencies-popup
|
||||
label.dependency-field
|
||||
span {{_ 'dependency-type'}}
|
||||
select.js-new-dependency-type
|
||||
each typeOption
|
||||
if selected
|
||||
option(value="{{id}}" selected="selected") {{_ label}}
|
||||
else
|
||||
option(value="{{id}}") {{_ label}}
|
||||
label.dependency-field
|
||||
span {{_ 'dependency-color'}}
|
||||
input.js-new-dependency-color(type="color" value="{{defaultColor}}")
|
||||
input.js-dependency-search(type="text" placeholder="{{_ 'search'}}" autofocus)
|
||||
ul.pop-over-list
|
||||
each candidateCards
|
||||
li
|
||||
a.js-pick-dependency(data-target-id="{{_id}}")
|
||||
+viewer
|
||||
= title
|
||||
|
||||
template(name="cardDependencyIconPopup")
|
||||
.card-dependency-icon-popup
|
||||
ul.card-dependency-icons
|
||||
each dependencyIcons
|
||||
li
|
||||
a.js-pick-dependency-icon(data-icon="{{name}}" title="{{name}}")
|
||||
i.fa(class="fa-{{name}}")
|
||||
@@ -1,6 +1,19 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import {
|
||||
DEPENDENCY_TYPES,
|
||||
DEPENDENCY_ICON_CHOICES,
|
||||
DEFAULT_DEPENDENCY_COLOR,
|
||||
DEFAULT_DEPENDENCY_ICON,
|
||||
} from '/models/metadata/dependencies';
|
||||
|
||||
// Which dependency the icon picker should apply its choice to. The icon popup's
|
||||
// own data context is the dependency row (not the source card), so we capture
|
||||
// the source card + target id here when the picker is opened.
|
||||
let editingDependencyTargetId = null;
|
||||
let editingDependencyCard = null;
|
||||
import {
|
||||
setupDatePicker,
|
||||
datePickerRendered,
|
||||
@@ -53,6 +66,231 @@ import { MultiSelection } from '/client/lib/multiSelection';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import autosize from 'autosize';
|
||||
|
||||
// Id of the location currently being edited in the cardLocationsPopup; null
|
||||
// when adding a new location.
|
||||
const editingLocationId = new ReactiveVar(null);
|
||||
|
||||
// Chinese datum conversions ("eviltransform" algorithm). Baidu Maps uses BD-09
|
||||
// and Amap (Gaode) uses GCJ-02; both are offset from the WGS-84 coordinates
|
||||
// Wekan stores, so a raw lat/lon would land a few hundred metres off. Convert on
|
||||
// the way out (mapLinkFor) and back (parseMapLink). Coordinates outside mainland
|
||||
// China use no offset, so every non-Chinese provider is unaffected.
|
||||
const GCJ = (() => {
|
||||
const PI = Math.PI;
|
||||
const A = 6378245.0; // Krasovsky 1940 semi-major axis
|
||||
const EE = 0.00669342162296594323; // eccentricity squared
|
||||
const XPI = (PI * 3000.0) / 180.0;
|
||||
const outOfChina = (lat, lon) =>
|
||||
lon < 72.004 || lon > 137.8347 || lat < 0.8293 || lat > 55.8271;
|
||||
const tLat = (x, y) => {
|
||||
let r = -100 + 2 * x + 3 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
|
||||
r += ((20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2) / 3;
|
||||
r += ((20 * Math.sin(y * PI) + 40 * Math.sin((y / 3) * PI)) * 2) / 3;
|
||||
r += ((160 * Math.sin((y / 12) * PI) + 320 * Math.sin((y * PI) / 30)) * 2) / 3;
|
||||
return r;
|
||||
};
|
||||
const tLon = (x, y) => {
|
||||
let r = 300 + x + 2 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
|
||||
r += ((20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2) / 3;
|
||||
r += ((20 * Math.sin(x * PI) + 40 * Math.sin((x / 3) * PI)) * 2) / 3;
|
||||
r += ((150 * Math.sin((x / 12) * PI) + 300 * Math.sin((x / 30) * PI)) * 2) / 3;
|
||||
return r;
|
||||
};
|
||||
const wgs2gcj = (lat, lon) => {
|
||||
if (outOfChina(lat, lon)) return [lat, lon];
|
||||
let dLat = tLat(lon - 105, lat - 35);
|
||||
let dLon = tLon(lon - 105, lat - 35);
|
||||
const rad = (lat / 180) * PI;
|
||||
let magic = Math.sin(rad);
|
||||
magic = 1 - EE * magic * magic;
|
||||
const sq = Math.sqrt(magic);
|
||||
dLat = (dLat * 180) / (((A * (1 - EE)) / (magic * sq)) * PI);
|
||||
dLon = (dLon * 180) / ((A / sq) * Math.cos(rad) * PI);
|
||||
return [lat + dLat, lon + dLon];
|
||||
};
|
||||
// Approximate inverse by fixed-point iteration (converges to ~1e-9 deg).
|
||||
const gcj2wgs = (lat, lon) => {
|
||||
if (outOfChina(lat, lon)) return [lat, lon];
|
||||
let wLat = lat;
|
||||
let wLon = lon;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const [gLat, gLon] = wgs2gcj(wLat, wLon);
|
||||
const dLat = gLat - lat;
|
||||
const dLon = gLon - lon;
|
||||
if (Math.abs(dLat) < 1e-9 && Math.abs(dLon) < 1e-9) break;
|
||||
wLat -= dLat;
|
||||
wLon -= dLon;
|
||||
}
|
||||
return [wLat, wLon];
|
||||
};
|
||||
const gcj2bd = (lat, lon) => {
|
||||
const z = Math.sqrt(lon * lon + lat * lat) + 0.00002 * Math.sin(lat * XPI);
|
||||
const theta = Math.atan2(lat, lon) + 0.000003 * Math.cos(lon * XPI);
|
||||
return [z * Math.sin(theta) + 0.006, z * Math.cos(theta) + 0.0065];
|
||||
};
|
||||
const bd2gcj = (lat, lon) => {
|
||||
const x = lon - 0.0065;
|
||||
const y = lat - 0.006;
|
||||
const z = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * XPI);
|
||||
const theta = Math.atan2(y, x) - 0.000003 * Math.cos(x * XPI);
|
||||
return [z * Math.sin(theta), z * Math.cos(theta)];
|
||||
};
|
||||
return {
|
||||
wgs2gcj,
|
||||
gcj2wgs,
|
||||
wgs2bd: (lat, lon) => {
|
||||
if (outOfChina(lat, lon)) return [lat, lon];
|
||||
const [gLat, gLon] = wgs2gcj(lat, lon);
|
||||
return gcj2bd(gLat, gLon);
|
||||
},
|
||||
bd2wgs: (lat, lon) => {
|
||||
if (outOfChina(lat, lon)) return [lat, lon];
|
||||
const [gLat, gLon] = bd2gcj(lat, lon);
|
||||
return gcj2wgs(gLat, gLon);
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
// Parse coordinates (and, when present, a place name / address) out of a map
|
||||
// link from common providers, grouped by region:
|
||||
// USA: Google Maps, Bing Maps, Apple Maps, Waze
|
||||
// Europe: OpenStreetMap, HERE WeGo, Yandex Maps, Mapy.cz, 2GIS
|
||||
// Asia: Baidu Maps, Amap (Gaode)
|
||||
// plus generic `?q=lat,lon` / `?ll=lat,lon` links. Returns whatever it can find
|
||||
// as { latitude, longitude, name, address }.
|
||||
function parseMapLink(url) {
|
||||
const result = {};
|
||||
if (!url) return result;
|
||||
const s = String(url).trim();
|
||||
|
||||
let lat;
|
||||
let lon;
|
||||
const setCoords = (la, lo) => {
|
||||
if (lat !== undefined) return;
|
||||
const a = parseFloat(la);
|
||||
const o = parseFloat(lo);
|
||||
if (!isNaN(a) && !isNaN(o) && Math.abs(a) <= 90 && Math.abs(o) <= 180) {
|
||||
lat = a;
|
||||
lon = o;
|
||||
}
|
||||
};
|
||||
|
||||
const N = '(-?\\d+(?:\\.\\d+)?)';
|
||||
// Coordinates are matched against a normalized copy where percent-encoded
|
||||
// commas/slashes are decoded, since copied URLs often arrive as `%2C`/`%2F`
|
||||
// (e.g. Waze `ll=45.69%2C-120.81`, Yandex `ll=37.6%2C55.7`).
|
||||
const sd = s.replace(/%2c/gi, ',').replace(/%2f/gi, '/');
|
||||
let m;
|
||||
// Provider-specific forms that use lon,lat order must be handled before the
|
||||
// generic lat,lon patterns below, since setCoords keeps only the first match.
|
||||
// Yandex Maps: ?ll=<lon>,<lat> or ?pt=<lon>,<lat>[,style]
|
||||
if (/yandex\./i.test(sd) && (m = sd.match(new RegExp(`[?&](?:ll|pt)=${N},${N}`)))) setCoords(m[2], m[1]);
|
||||
// Mapy.cz: ?x=<lon>&y=<lat>
|
||||
if (/mapy\./i.test(sd) && (m = sd.match(new RegExp(`[?&]y=${N}`)))) {
|
||||
const mx = sd.match(new RegExp(`[?&]x=${N}`));
|
||||
if (mx) setCoords(m[1], mx[1]);
|
||||
}
|
||||
// 2GIS: /geo/<lon>,<lat> or ?m=<lon>,<lat>[/zoom]
|
||||
if (/2gis\./i.test(sd) && (m = sd.match(new RegExp(`(?:/geo/|[?&]m=)${N},${N}`)))) setCoords(m[2], m[1]);
|
||||
// Amap (Gaode): ?position=<lon>,<lat> in GCJ-02 datum -> WGS-84.
|
||||
if (/amap\./i.test(sd) && (m = sd.match(new RegExp(`[?&]position=${N},${N}`)))) {
|
||||
const [wLat, wLon] = GCJ.gcj2wgs(parseFloat(m[2]), parseFloat(m[1]));
|
||||
setCoords(wLat, wLon);
|
||||
}
|
||||
// Baidu Maps: ?location=<lat>,<lon> in BD-09 datum -> WGS-84.
|
||||
if (/baidu\./i.test(sd) && (m = sd.match(new RegExp(`[?&]location=${N},${N}`)))) {
|
||||
const [wLat, wLon] = GCJ.bd2wgs(parseFloat(m[1]), parseFloat(m[2]));
|
||||
setCoords(wLat, wLon);
|
||||
}
|
||||
// HERE WeGo: ?map=<lat>,<lon>,<zoom> or share path /l/<lat>,<lon>
|
||||
if (/here\./i.test(sd) && (m = sd.match(new RegExp(`(?:[?&]map=|/l/)${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
// Google Maps place marker in the data part: !3d<lat>!4d<lon> (most precise).
|
||||
if ((m = sd.match(new RegExp(`!3d${N}!4d${N}`)))) setCoords(m[1], m[2]);
|
||||
// Google Maps view/place: @<lat>,<lon>
|
||||
if ((m = sd.match(new RegExp(`@${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
// OpenStreetMap marker: ?mlat=..&mlon=..
|
||||
if ((m = sd.match(new RegExp(`[?&#]mlat=${N}`)))) {
|
||||
const mlon = sd.match(new RegExp(`[?&#]mlon=${N}`));
|
||||
if (mlon) setCoords(m[1], mlon[1]);
|
||||
}
|
||||
// OpenStreetMap map hash: #map=z/lat/lon
|
||||
if ((m = sd.match(new RegExp(`[#&]map=\\d+(?:\\.\\d+)?/${N}/${N}`)))) setCoords(m[1], m[2]);
|
||||
// Bing Maps: cp=lat~lon
|
||||
if ((m = sd.match(new RegExp(`[?&]cp=${N}~${N}`)))) setCoords(m[1], m[2]);
|
||||
// Apple Maps / Waze and generic ll=lat,lon
|
||||
if ((m = sd.match(new RegExp(`[?&]ll=${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
// Generic q=lat,lon (Google/Apple query form)
|
||||
if ((m = sd.match(new RegExp(`[?&]q=${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
// Generic center=lat,lon
|
||||
if ((m = sd.match(new RegExp(`[?&]center=${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
|
||||
if (lat !== undefined) {
|
||||
result.latitude = lat;
|
||||
result.longitude = lon;
|
||||
}
|
||||
|
||||
const decode = (raw) => {
|
||||
try {
|
||||
return decodeURIComponent(raw.replace(/\+/g, ' ')).trim();
|
||||
} catch (e) {
|
||||
return raw.replace(/\+/g, ' ').trim();
|
||||
}
|
||||
};
|
||||
const isCoordText = (t) => /^-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?$/.test(t);
|
||||
|
||||
// Place name from Google Maps /place/<Name>/
|
||||
if ((m = s.match(/\/place\/([^/@?]+)/))) {
|
||||
const name = decode(m[1]);
|
||||
if (name && !isCoordText(name)) result.name = name;
|
||||
}
|
||||
// Address from a textual q=/query=/destination= parameter.
|
||||
if ((m = s.match(/[?&](?:q|query|destination)=([^&]+)/))) {
|
||||
const q = decode(m[1]);
|
||||
if (q && !isCoordText(q)) result.address = q;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Build an "open in map" link for the given provider and coordinates. Mirrors
|
||||
// the providers offered in the location popup's "Open map links at" selector,
|
||||
// grouped by region (USA, Europe, Asia). Note that several non-US providers
|
||||
// expect the coordinates in lon,lat order rather than lat,lon.
|
||||
function mapLinkFor(provider, lat, lon) {
|
||||
switch (provider) {
|
||||
// --- USA ---
|
||||
case 'google':
|
||||
return `https://www.google.com/maps/search/?api=1&query=${lat},${lon}`;
|
||||
case 'bing':
|
||||
return `https://www.bing.com/maps?cp=${lat}~${lon}&lvl=16`;
|
||||
case 'apple':
|
||||
return `https://maps.apple.com/?ll=${lat},${lon}`;
|
||||
case 'waze':
|
||||
return `https://waze.com/ul?ll=${lat},${lon}`;
|
||||
// --- Europe ---
|
||||
case 'here':
|
||||
return `https://wego.here.com/?map=${lat},${lon},16`;
|
||||
case 'yandex':
|
||||
return `https://yandex.com/maps/?ll=${lon},${lat}&z=16`;
|
||||
case 'mapy':
|
||||
return `https://mapy.cz/zakladni?x=${lon}&y=${lat}&z=16`;
|
||||
case '2gis':
|
||||
return `https://2gis.ru/geo/${lon},${lat}`;
|
||||
// --- Asia (coordinates converted from WGS-84 into the local datum) ---
|
||||
case 'baidu': {
|
||||
const [bLat, bLon] = GCJ.wgs2bd(parseFloat(lat), parseFloat(lon));
|
||||
return `https://api.map.baidu.com/marker?location=${bLat},${bLon}&output=html`;
|
||||
}
|
||||
case 'amap': {
|
||||
const [gLat, gLon] = GCJ.wgs2gcj(parseFloat(lat), parseFloat(lon));
|
||||
return `https://uri.amap.com/marker?position=${gLon},${gLat}`;
|
||||
}
|
||||
case 'openstreetmap':
|
||||
default:
|
||||
return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=16/${lat}/${lon}`;
|
||||
}
|
||||
}
|
||||
|
||||
// SubsManager removed for Meteor 3 migration
|
||||
const { calculateIndexData } = Utils;
|
||||
|
||||
@@ -134,6 +372,11 @@ Template.cardDetails.onCreated(function () {
|
||||
});
|
||||
|
||||
Template.cardDetails.onRendered(function () {
|
||||
// A reactive re-render (e.g. a card moving between the inline swimlane render
|
||||
// and the draggable openCards popup) can create and then remove this instance
|
||||
// within the same flush. If our DOM range is already gone, calling this.$()
|
||||
// below throws "Can't select in removed DomRange", so bail out early.
|
||||
if (this.view && this.view.isDestroyed) return;
|
||||
this.calculateNextPeak();
|
||||
if (Meteor.settings.public.CARD_OPENED_WEBHOOK_ENABLED) {
|
||||
// Send Webhook but not create Activities records ---
|
||||
@@ -273,6 +516,51 @@ Template.cardDetails.helpers({
|
||||
return card.findWatcher(Meteor.userId());
|
||||
},
|
||||
|
||||
// Returns the card's locations (multiple supported), each enriched with the
|
||||
// coordinate flag and OpenStreetMap link used by the template.
|
||||
getLocations() {
|
||||
const card = Template.currentData();
|
||||
if (!card || !card.getLocations) return [];
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const provider = user ? user.getMapProvider() : 'openstreetmap';
|
||||
return card.getLocations().map(loc => {
|
||||
const hasCoordinates =
|
||||
typeof loc.latitude === 'number' && typeof loc.longitude === 'number';
|
||||
const mapUrl = hasCoordinates
|
||||
? mapLinkFor(provider, loc.latitude, loc.longitude)
|
||||
: '';
|
||||
return { ...loc, hasCoordinates, mapUrl };
|
||||
});
|
||||
},
|
||||
|
||||
// #3392: PI Program Board "Red Strings". Resolve this card's dependencies
|
||||
// into card objects (with relation type, color, icon and a relative link).
|
||||
getDependencyCards() {
|
||||
const card = Template.currentData();
|
||||
if (!card || typeof card.getDependencies !== 'function') return [];
|
||||
return card
|
||||
.getDependencies()
|
||||
.map(dep => {
|
||||
const target = ReactiveCache.getCard(dep.cardId);
|
||||
if (!target) return null;
|
||||
return {
|
||||
card: target,
|
||||
linkUrl: target.originRelativeUrl(),
|
||||
type: dep.type,
|
||||
color: dep.color,
|
||||
icon: dep.icon,
|
||||
typeLabel: `dependency-type-${dep.type}`,
|
||||
// Per-row relation-type dropdown options with the current one marked.
|
||||
typeOption: DEPENDENCY_TYPES.map(t => ({
|
||||
id: t.id,
|
||||
label: `dependency-type-${t.id}`,
|
||||
selected: t.id === dep.type,
|
||||
})),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
},
|
||||
|
||||
customFieldsGrid() {
|
||||
return ReactiveCache.getCurrentUser().hasCustomFieldsGrid();
|
||||
},
|
||||
@@ -463,6 +751,17 @@ Template.cardDetails.events({
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// When the card is shown inside a popup (opened from the Board Table view,
|
||||
// search results, or a mini-screen list), the redundant popup title header
|
||||
// is hidden, so this is the only close button. Close the popup and clear its
|
||||
// session state instead of running the board/route close flow below.
|
||||
if (Popup.isOpen() && Utils.getPopupCardId()) {
|
||||
Session.delete('popupCardId');
|
||||
Session.delete('popupCardBoardId');
|
||||
Popup.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve card context defensively because cardDetails can be rendered
|
||||
// from several parents (board, popup, gantt, etc.).
|
||||
const card = getCurrentCardFromContext({ ignorePopupCard: true }) || Template.currentData();
|
||||
@@ -605,14 +904,96 @@ Template.cardDetails.events({
|
||||
const card = Template.currentData();
|
||||
Utils.goCardId(card.linkedId);
|
||||
},
|
||||
'click .js-add-dependency': Popup.open('cardDependencies'),
|
||||
'click .js-remove-dependency'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const targetId = event.currentTarget.dataset.targetId;
|
||||
const card = Template.currentData();
|
||||
if (card && targetId) {
|
||||
card.removeDependency(targetId);
|
||||
}
|
||||
},
|
||||
'change .js-dependency-type'(event) {
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const targetId = event.currentTarget.dataset.targetId;
|
||||
const card = Template.currentData();
|
||||
if (card && targetId) {
|
||||
card.setDependencyProps(targetId, { type: event.currentTarget.value });
|
||||
}
|
||||
},
|
||||
'change .js-dependency-color'(event) {
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const targetId = event.currentTarget.dataset.targetId;
|
||||
const card = Template.currentData();
|
||||
if (card && targetId) {
|
||||
card.setDependencyProps(targetId, { color: event.currentTarget.value });
|
||||
}
|
||||
},
|
||||
'click .js-dependency-icon'(event) {
|
||||
if (!Utils.canModifyCard()) return;
|
||||
// Remember the source card + which dependency the picked icon applies to.
|
||||
editingDependencyCard = Template.currentData();
|
||||
editingDependencyTargetId = event.currentTarget.dataset.targetId;
|
||||
Popup.open('cardDependencyIcon')(event);
|
||||
},
|
||||
'click .js-member': Popup.open('cardMember'),
|
||||
'click .js-add-members': Popup.open('cardMembers'),
|
||||
'click .js-assignee': Popup.open('cardAssignee'),
|
||||
'click .js-add-assignees': Popup.open('cardAssignees'),
|
||||
'click .js-add-labels': Popup.open('cardLabels'),
|
||||
'click .js-add-stickers': Popup.open('cardStickers'),
|
||||
'click .js-remove-sticker'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const index = parseInt(event.currentTarget.dataset.index, 10);
|
||||
const card = Template.currentData();
|
||||
if (card && !Number.isNaN(index)) {
|
||||
card.removeStickerAt(index);
|
||||
}
|
||||
},
|
||||
'click .js-add-location'(event) {
|
||||
event.preventDefault();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const card = Template.currentData();
|
||||
editingLocationId.set(null);
|
||||
Popup.open('cardLocations')(event, {
|
||||
dataContextIfCurrentDataIsUndefined: card,
|
||||
});
|
||||
},
|
||||
'click .js-edit-location'(event) {
|
||||
// Let the "Open in map" link work without also opening the edit popup.
|
||||
if ($(event.target).closest('a.card-location-map').length) return;
|
||||
event.preventDefault();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const card = Template.currentData();
|
||||
editingLocationId.set(event.currentTarget.dataset.locationId || null);
|
||||
Popup.open('cardLocations')(event, {
|
||||
dataContextIfCurrentDataIsUndefined: card,
|
||||
});
|
||||
},
|
||||
'click .js-remove-location'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const locationId = event.currentTarget.dataset.locationId;
|
||||
const card = Template.currentData();
|
||||
if (card && locationId) {
|
||||
card.removeLocation(locationId);
|
||||
}
|
||||
},
|
||||
'click .js-received-date': Popup.open('editCardReceivedDate'),
|
||||
'click .js-start-date': Popup.open('editCardStartDate'),
|
||||
'click .js-due-date': Popup.open('editCardDueDate'),
|
||||
'click .js-toggle-due-complete'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const card = Template.currentData();
|
||||
card.setDueComplete(!card.getDueComplete());
|
||||
},
|
||||
'click .js-end-date': Popup.open('editCardEndDate'),
|
||||
'click .js-show-positive-votes': Popup.open('positiveVoteMembers'),
|
||||
'click .js-show-negative-votes': Popup.open('negativeVoteMembers'),
|
||||
@@ -856,7 +1237,7 @@ const EXCEL_EXPORT_FIELDS = [
|
||||
{ field: 'checklists', label: 'checklists' },
|
||||
{ field: 'subtasks', label: 'export-card-subtasks' },
|
||||
{ field: 'comments', label: 'comments' },
|
||||
{ field: 'attachments', label: 'export-card-attachments' },
|
||||
{ field: 'attachments', label: 'attachments' },
|
||||
];
|
||||
|
||||
Template.exportCardPopup.onCreated(function () {
|
||||
@@ -1144,6 +1525,109 @@ Template.cardMembersPopup.helpers({
|
||||
},
|
||||
});
|
||||
|
||||
// Popup that adds or edits a single card location (name, address, latitude,
|
||||
// longitude). Cards can hold multiple locations, like members.
|
||||
Template.cardLocationsPopup.onCreated(function () {
|
||||
const data = Template.currentData();
|
||||
this.cardId = data && data._id;
|
||||
this.detectMsg = new ReactiveVar('');
|
||||
this.mapSavedMsg = new ReactiveVar('');
|
||||
});
|
||||
|
||||
Template.cardLocationsPopup.helpers({
|
||||
location() {
|
||||
const tpl = Template.instance();
|
||||
const card = ReactiveCache.getCard(tpl.cardId);
|
||||
const id = editingLocationId.get();
|
||||
if (card && id) {
|
||||
const found = card.getLocations().find(loc => loc._id === id);
|
||||
if (found) return found;
|
||||
}
|
||||
return {};
|
||||
},
|
||||
detectMessage() {
|
||||
return Template.instance().detectMsg.get();
|
||||
},
|
||||
isMapProvider(provider) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const current = user ? user.getMapProvider() : 'openstreetmap';
|
||||
return current === provider;
|
||||
},
|
||||
mapSavedMessage() {
|
||||
return Template.instance().mapSavedMsg.get();
|
||||
},
|
||||
});
|
||||
|
||||
Template.cardLocationsPopup.events({
|
||||
'click .js-detect-location'(event) {
|
||||
event.preventDefault();
|
||||
const tpl = Template.instance();
|
||||
const linkInput = tpl.find('.js-location-map-link');
|
||||
const parsed = parseMapLink(linkInput ? linkInput.value : '');
|
||||
let filled = false;
|
||||
if (typeof parsed.latitude === 'number') {
|
||||
tpl.find('.js-location-latitude').value = parsed.latitude;
|
||||
tpl.find('.js-location-longitude').value = parsed.longitude;
|
||||
filled = true;
|
||||
}
|
||||
if (parsed.name) {
|
||||
tpl.find('.js-location-name').value = parsed.name;
|
||||
filled = true;
|
||||
}
|
||||
if (parsed.address) {
|
||||
tpl.find('.js-location-address').value = parsed.address;
|
||||
filled = true;
|
||||
}
|
||||
tpl.detectMsg.set(
|
||||
TAPi18n.__(filled ? 'location-detect-done' : 'location-detect-none'),
|
||||
);
|
||||
},
|
||||
'submit .js-card-location-form'(event) {
|
||||
event.preventDefault();
|
||||
const tpl = Template.instance();
|
||||
const card = ReactiveCache.getCard(tpl.cardId);
|
||||
if (!card) {
|
||||
Popup.back();
|
||||
return;
|
||||
}
|
||||
const name = tpl.find('.js-location-name').value.trim();
|
||||
const address = tpl.find('.js-location-address').value.trim();
|
||||
const latRaw = tpl.find('.js-location-latitude').value.trim();
|
||||
const lonRaw = tpl.find('.js-location-longitude').value.trim();
|
||||
const latitude = latRaw === '' ? undefined : parseFloat(latRaw);
|
||||
const longitude = lonRaw === '' ? undefined : parseFloat(lonRaw);
|
||||
const data = { name, address, latitude, longitude };
|
||||
const id = editingLocationId.get();
|
||||
if (id) {
|
||||
card.updateLocation(id, data);
|
||||
} else {
|
||||
card.addLocation(data);
|
||||
}
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-delete-location'(event) {
|
||||
event.preventDefault();
|
||||
const tpl = Template.instance();
|
||||
const card = ReactiveCache.getCard(tpl.cardId);
|
||||
const id = editingLocationId.get();
|
||||
if (card && id) {
|
||||
card.removeLocation(id);
|
||||
}
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-save-map-provider'(event) {
|
||||
event.preventDefault();
|
||||
const tpl = Template.instance();
|
||||
const select = tpl.find('.js-map-provider');
|
||||
const provider = select ? select.value : 'openstreetmap';
|
||||
Meteor.call('setMapProvider', provider, err => {
|
||||
tpl.mapSavedMsg.set(
|
||||
TAPi18n.__(err ? 'server-error' : 'map-provider-saved'),
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const filterMembers = (filterTerm) => {
|
||||
let currBoard = Utils.getCurrentBoard();
|
||||
let members = currBoard.activeMembers();
|
||||
@@ -1222,6 +1706,15 @@ function registerCardDialogTemplate(templateName) {
|
||||
isDialogOptionListId(listId) {
|
||||
return Template.instance().dialog.isDialogOptionListId(listId);
|
||||
},
|
||||
isSelectedBoardId(boardId) {
|
||||
return Template.instance().dialog.isSelectedBoardId(boardId);
|
||||
},
|
||||
isSelectedSwimlaneId(swimlaneId) {
|
||||
return Template.instance().dialog.isSelectedSwimlaneId(swimlaneId);
|
||||
},
|
||||
isSelectedListId(listId) {
|
||||
return Template.instance().dialog.isSelectedListId(listId);
|
||||
},
|
||||
isDialogOptionCardId(cardId) {
|
||||
return Template.instance().dialog.isDialogOptionCardId(cardId);
|
||||
},
|
||||
@@ -1233,14 +1726,16 @@ function registerCardDialogTemplate(templateName) {
|
||||
Template[templateName].events({
|
||||
async 'click .js-done'(event, tpl) {
|
||||
const dialog = tpl.dialog;
|
||||
const boardSelect = tpl.$('.js-select-boards')[0];
|
||||
const boardId = boardSelect?.options[boardSelect?.selectedIndex]?.value;
|
||||
|
||||
const listSelect = tpl.$('.js-select-lists')[0];
|
||||
const listId = listSelect?.options[listSelect?.selectedIndex]?.value;
|
||||
|
||||
const swimlaneSelect = tpl.$('.js-select-swimlanes')[0];
|
||||
const swimlaneId = swimlaneSelect?.options[swimlaneSelect?.selectedIndex]?.value;
|
||||
// Read the target board/swimlane/list from the dialog's live reactive
|
||||
// selection rather than the DOM <select> elements. A reactive re-render
|
||||
// (e.g. the boards() helper transiently returning [] while a 'board'
|
||||
// subscription re-resolves) can leave a <select> momentarily option-less,
|
||||
// making selectedIndex -1 and the scraped id undefined — which then makes
|
||||
// card.move()/copyCard() fail with a 403 "may only update documents by ID"
|
||||
// validation error. The reactive vars stay correct across those renders.
|
||||
const boardId = dialog.selectedBoardId.get();
|
||||
const swimlaneId = dialog.selectedSwimlaneId.get();
|
||||
const listId = dialog.selectedListId.get();
|
||||
|
||||
const cardSelect = tpl.$('.js-select-cards')[0];
|
||||
const cardId = cardSelect?.options?.length > 0
|
||||
@@ -1996,3 +2491,95 @@ Template.cardAssigneePopup.events({
|
||||
},
|
||||
'click .js-edit-profile': Popup.open('editProfile'),
|
||||
});
|
||||
|
||||
// #3392: PI Program Board "Red Strings". Popup to pick another card on the
|
||||
// same board to add as a dependency. The popup's data context is the source
|
||||
// card (set by Popup.open on the .js-add-dependency element inside cardDetails).
|
||||
Template.cardDependenciesPopup.onCreated(function () {
|
||||
this.searchTerm = new ReactiveVar('');
|
||||
this.newType = new ReactiveVar(DEPENDENCY_TYPES[0].id);
|
||||
this.newColor = new ReactiveVar(DEFAULT_DEPENDENCY_COLOR);
|
||||
});
|
||||
|
||||
Template.cardDependenciesPopup.helpers({
|
||||
defaultColor() {
|
||||
return Template.instance().newColor.get();
|
||||
},
|
||||
typeOption() {
|
||||
const current = Template.instance().newType.get();
|
||||
return DEPENDENCY_TYPES.map(t => ({
|
||||
id: t.id,
|
||||
label: `dependency-type-${t.id}`,
|
||||
selected: t.id === current,
|
||||
}));
|
||||
},
|
||||
candidateCards() {
|
||||
const sourceCard = Template.currentData();
|
||||
if (!sourceCard) return [];
|
||||
const term = Template.instance().searchTerm.get().toLowerCase();
|
||||
const existingIds = (sourceCard.getDependencies
|
||||
? sourceCard.getDependencies()
|
||||
: []
|
||||
).map(dep => dep.cardId);
|
||||
const cards = ReactiveCache.getCards({
|
||||
boardId: sourceCard.boardId,
|
||||
archived: false,
|
||||
});
|
||||
return cards.filter(card => {
|
||||
if (card._id === sourceCard._id) return false;
|
||||
if (existingIds.includes(card._id)) return false;
|
||||
if (term && !(card.title || '').toLowerCase().includes(term)) return false;
|
||||
return true;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Template.cardDependenciesPopup.events({
|
||||
'keyup .js-dependency-search'(event) {
|
||||
Template.instance().searchTerm.set(event.currentTarget.value || '');
|
||||
},
|
||||
'change .js-new-dependency-type'(event) {
|
||||
Template.instance().newType.set(event.currentTarget.value);
|
||||
},
|
||||
'change .js-new-dependency-color'(event) {
|
||||
Template.instance().newColor.set(event.currentTarget.value);
|
||||
},
|
||||
'click .js-pick-dependency'(event) {
|
||||
event.preventDefault();
|
||||
const tpl = Template.instance();
|
||||
const sourceCard = Template.currentData();
|
||||
const targetId = event.currentTarget.dataset.targetId;
|
||||
if (sourceCard && targetId) {
|
||||
sourceCard.addDependency(targetId, {
|
||||
type: tpl.newType.get(),
|
||||
color: tpl.newColor.get(),
|
||||
});
|
||||
}
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
|
||||
// #3392: icon picker for an existing dependency. The source card is the popup's
|
||||
// data context; the target id was stashed on the cardDetails instance by the
|
||||
// .js-dependency-icon click handler.
|
||||
Template.cardDependencyIconPopup.helpers({
|
||||
dependencyIcons() {
|
||||
return DEPENDENCY_ICON_CHOICES.map(name => ({ name }));
|
||||
},
|
||||
});
|
||||
|
||||
Template.cardDependencyIconPopup.events({
|
||||
'click .js-pick-dependency-icon'(event) {
|
||||
event.preventDefault();
|
||||
// Use the source card captured when the picker was opened (the popup's own
|
||||
// data context is the dependency row, which has no setDependencyProps).
|
||||
const sourceCard = editingDependencyCard;
|
||||
const icon = event.currentTarget.dataset.icon || DEFAULT_DEPENDENCY_ICON;
|
||||
if (sourceCard && editingDependencyTargetId) {
|
||||
sourceCard.setDependencyProps(editingDependencyTargetId, { icon });
|
||||
}
|
||||
editingDependencyTargetId = null;
|
||||
editingDependencyCard = null;
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
template(name="cardStickersPopup")
|
||||
.card-stickers-picker.clearfix
|
||||
each stickerIcons
|
||||
a.card-sticker-select.js-select-sticker(class="{{#if isSelected}}active{{/if}}" title="{{stickerTitle}}")
|
||||
i.fa(class="fa-{{icon}} {{#if highlight}}sticker-{{highlight}}{{/if}}")
|
||||
if isSelected
|
||||
i.fa.fa-check.card-sticker-check
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,13 +110,13 @@
|
||||
}
|
||||
.minicard .minicard-labels {
|
||||
float: none;
|
||||
margin-right: 6vw;
|
||||
margin-inline-end: 6vw;
|
||||
}
|
||||
.minicard .minicard-labels .minicard-label {
|
||||
width: clamp(12px, 1.5vw, 16px);
|
||||
height: clamp(12px, 1.5vw, 16px);
|
||||
border-radius: 0.3vw;
|
||||
margin-right: 0.4vw;
|
||||
margin-inline-end: 0.4vw;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.minicard .minicard-labels-no-text {
|
||||
@@ -125,7 +125,7 @@
|
||||
}
|
||||
.minicard .minicard-custom-fields {
|
||||
display: block;
|
||||
margin-right: 6vw;
|
||||
margin-inline-end: 6vw;
|
||||
}
|
||||
.minicard .minicard-custom-field {
|
||||
display: flex;
|
||||
@@ -135,20 +135,20 @@
|
||||
display: block;
|
||||
word-wrap: break-word;
|
||||
max-width: 13vw;
|
||||
margin-right: 0.5vw;
|
||||
margin-inline-end: 0.5vw;
|
||||
}
|
||||
.minicard .minicard-custom-field-item-fullwidth {
|
||||
flex-grow: 1;
|
||||
display: block;
|
||||
word-wrap: break-word;
|
||||
max-width: 100%;
|
||||
margin-right: 0.5vw;
|
||||
margin-inline-end: 0.5vw;
|
||||
}
|
||||
.minicard .handle {
|
||||
width: clamp(20px, 2.5vw, 28px);
|
||||
height: clamp(20px, 2.5vw, 28px);
|
||||
position: absolute;
|
||||
right: 0vw;
|
||||
inset-inline-end: 0vw;
|
||||
top: 28px;
|
||||
display: none;
|
||||
z-index: 1;
|
||||
@@ -166,12 +166,12 @@
|
||||
text-align: center;
|
||||
}
|
||||
.minicard .minicard-title {
|
||||
margin-right: 1.5vw;
|
||||
margin-inline-end: 1.5vw;
|
||||
}
|
||||
.minicard .minicard-title .card-number {
|
||||
color: #b3b3b3;
|
||||
display: inline-block;
|
||||
margin-right: 0.7vw;
|
||||
margin-inline-end: 0.7vw;
|
||||
}
|
||||
@media only screen {
|
||||
.minicard .minicard-title p:last-child {
|
||||
@@ -188,11 +188,11 @@
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
margin-right: 6vw;
|
||||
margin-inline-end: 6vw;
|
||||
clear: both;
|
||||
}
|
||||
.minicard .date {
|
||||
margin-right: 0.4vw;
|
||||
margin-inline-end: 0.4vw;
|
||||
}
|
||||
|
||||
/* Unicode icons for minicard dates - matching cardDate.css */
|
||||
@@ -211,7 +211,7 @@
|
||||
|
||||
.minicard .card-date time::before {
|
||||
font-size: inherit;
|
||||
margin-right: 0.3em;
|
||||
margin-inline-end: 0.3em;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@@ -324,7 +324,7 @@
|
||||
}
|
||||
|
||||
.minicard .badges {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
margin-top: 8px;
|
||||
color: #808080;
|
||||
}
|
||||
@@ -332,8 +332,8 @@
|
||||
display: none;
|
||||
}
|
||||
.minicard .badges .badge {
|
||||
float: left;
|
||||
margin-right: 1.5vw;
|
||||
float: inline-start;
|
||||
margin-inline-end: 1.5vw;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
@@ -344,7 +344,7 @@
|
||||
color: #fff;
|
||||
}
|
||||
.minicard .badges .badge:last-of-type {
|
||||
margin-right: 0;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
.minicard .badges .badge .badge-icon,
|
||||
.minicard .badges .badge .badge-text {
|
||||
@@ -356,24 +356,24 @@
|
||||
}
|
||||
.minicard .badges .badge .badge-text {
|
||||
font-size: 0.9em;
|
||||
padding-left: 0.3vw;
|
||||
padding-inline-start: 0.3vw;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.minicard .badges .badge .check-list-text {
|
||||
padding-left: 0px;
|
||||
padding-inline-start: 0px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.minicard .minicard-members,
|
||||
.minicard .minicard-assignees,
|
||||
.minicard .minicard-creator {
|
||||
float: right;
|
||||
margin-left: 0.7vw;
|
||||
float: inline-end;
|
||||
margin-inline-start: 0.7vw;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.minicard .minicard-members .member,
|
||||
.minicard .minicard-assignees .member,
|
||||
.minicard .minicard-creator .member {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
border-radius: 50%;
|
||||
height: clamp(24px, 3.5vw, 32px);
|
||||
width: clamp(24px, 3.5vw, 32px);
|
||||
@@ -382,7 +382,7 @@
|
||||
.minicard .minicard-members .assignee,
|
||||
.minicard .minicard-assignees .assignee,
|
||||
.minicard .minicard-creator .assignee {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
border-radius: 50%;
|
||||
height: clamp(24px, 3.5vw, 32px);
|
||||
width: clamp(24px, 3.5vw, 32px);
|
||||
@@ -408,7 +408,7 @@
|
||||
background-color: #eee;
|
||||
width: 100%;
|
||||
margin-bottom: 3px;
|
||||
margin-left: -0.5vw;
|
||||
margin-inline-start: -0.5vw;
|
||||
border-radius: 0.4vw;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -834,3 +834,52 @@
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Trello-style "mark complete" toggle, left of the minicard title */
|
||||
.minicard-title .minicard-complete-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
vertical-align: middle;
|
||||
/* Nudge down so the checkmark sits at the vertical middle of the title text
|
||||
(the checked materialCheckBox is shifted up 4px by its own positioning). */
|
||||
position: relative;
|
||||
top: 4px;
|
||||
margin-inline-end: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Sticker icon highlight styles (used instead of colour): taco pack => underline,
|
||||
pete pack => round (ring around the icon). Icon keeps its normal colour. */
|
||||
.minicard-sticker i.sticker-underline {
|
||||
border-bottom: 2px solid currentColor;
|
||||
padding-bottom: 1px;
|
||||
line-height: 1;
|
||||
}
|
||||
.minicard-sticker i.sticker-round {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.6em;
|
||||
height: 1.6em;
|
||||
line-height: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* #3984: Visual card aging — progressively fade cards that have not been
|
||||
* touched recently, when card aging is enabled for the board. */
|
||||
.minicard.minicard-aging-1 {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.minicard.minicard-aging-2 {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.minicard.minicard-aging-3 {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.minicard.minicard-aging-1:hover,
|
||||
.minicard.minicard-aging-2:hover,
|
||||
.minicard.minicard-aging-3:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,8 @@ import { handleFileUpload } from './attachments';
|
||||
import uploadProgressManager from '../../lib/uploadProgressManager';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import ChecklistItems from '/models/checklistItems';
|
||||
import Cards from '/models/cards';
|
||||
import { normalizeDependencies } from '/models/metadata/dependencies';
|
||||
|
||||
function getMinicardFlag(board, onMinicardField, legacyField, defaultValue) {
|
||||
if (!board) return false;
|
||||
@@ -22,6 +24,43 @@ function getMinicardFlag(board, onMinicardField, legacyField, defaultValue) {
|
||||
// });
|
||||
|
||||
Template.minicard.helpers({
|
||||
// #3392: show a drag-to-connect handle on the minicard when the board's
|
||||
// dependency overlay is on and the user can edit the board. Dragging it onto
|
||||
// another card creates a dependency (handled in dependencyOverlay.js).
|
||||
showDependencyConnectHandle() {
|
||||
const board = ReactiveCache.getBoard(this.boardId);
|
||||
return !!(board && board.showDependencies && Utils.canModifyBoard());
|
||||
},
|
||||
// #3392: PI Program Board "Red Strings". Show a small badge on the minicard
|
||||
// when a card has dependencies: the first dependency's icon and color plus the
|
||||
// total count.
|
||||
dependencyBadge() {
|
||||
const deps = normalizeDependencies(this.cardDependencies);
|
||||
if (deps.length === 0) return null;
|
||||
return {
|
||||
icon: deps[0].icon,
|
||||
color: deps[0].color,
|
||||
count: deps.length,
|
||||
};
|
||||
},
|
||||
// #3984: visual card aging — fade cards that have not been touched recently,
|
||||
// based on dateLastActivity, when the board has card aging enabled.
|
||||
agingClass() {
|
||||
const board = ReactiveCache.getBoard(this.boardId);
|
||||
if (!board || !board.cardAging) return '';
|
||||
const last = this.dateLastActivity || this.modifiedAt || this.createdAt;
|
||||
if (!last) return '';
|
||||
const days = (Date.now() - new Date(last).getTime()) / 86400000;
|
||||
// #3984: thresholds are board-configurable (board settings / cardSettings API),
|
||||
// defaulting to 7 / 14 / 28 days.
|
||||
const d1 = board.cardAgingDays1 != null ? board.cardAgingDays1 : 7;
|
||||
const d2 = board.cardAgingDays2 != null ? board.cardAgingDays2 : 14;
|
||||
const d3 = board.cardAgingDays3 != null ? board.cardAgingDays3 : 28;
|
||||
if (days >= d3) return 'minicard-aging-3';
|
||||
if (days >= d2) return 'minicard-aging-2';
|
||||
if (days >= d1) return 'minicard-aging-1';
|
||||
return '';
|
||||
},
|
||||
formattedCurrencyCustomFieldValue(definition) {
|
||||
const customField = this
|
||||
.customFieldsWD()
|
||||
@@ -94,6 +133,13 @@ Template.minicard.helpers({
|
||||
const board = this.board();
|
||||
return getMinicardFlag(board, 'allowsCardNumberOnMinicard', 'allowsCardNumber', false);
|
||||
},
|
||||
// "Mark as complete" toggle on the minicard. No legacy fallback to the
|
||||
// card-details setting (allowsDueComplete): hidden by default so it only
|
||||
// appears when explicitly enabled in Card Settings > Show at Minicard.
|
||||
showDueComplete() {
|
||||
const board = this.board();
|
||||
return getMinicardFlag(board, 'allowsDueCompleteOnMinicard', null, false);
|
||||
},
|
||||
showSubtasks() {
|
||||
const board = this.board();
|
||||
return getMinicardFlag(board, 'allowsSubtasksOnMinicard', 'allowsSubtasks', true);
|
||||
@@ -172,12 +218,56 @@ Template.minicard.helpers({
|
||||
}
|
||||
});
|
||||
|
||||
// #459: accessible reordering — keyboard/screen-reader users can move a card up
|
||||
// or down within its list via sr-only buttons (no drag-and-drop required). The
|
||||
// move swaps the card's sort value with its neighbour in the same list+swimlane.
|
||||
function moveCardBy(card, delta) {
|
||||
const siblings = ReactiveCache.getCards(
|
||||
{ listId: card.listId, swimlaneId: card.swimlaneId, archived: false },
|
||||
{ sort: { sort: 1 } },
|
||||
);
|
||||
const idx = siblings.findIndex(c => c._id === card._id);
|
||||
const target = siblings[idx + delta];
|
||||
if (idx < 0 || !target) return;
|
||||
// Capture both sort values before either update; the docs are reactive and
|
||||
// card.sort would otherwise change after the first move.
|
||||
const cardSort = card.sort;
|
||||
const targetSort = target.sort;
|
||||
// Persist through the card model's move() mutation — the canonical client
|
||||
// path (e.g. editCardSortOrderPopup). A raw Cards.update of `sort` is the
|
||||
// wrong path here and would be reverted.
|
||||
card.move(card.boardId, card.swimlaneId, card.listId, targetSort);
|
||||
target.move(target.boardId, target.swimlaneId, target.listId, cardSort);
|
||||
}
|
||||
|
||||
Template.minicard.events({
|
||||
'click .js-linked-link'() {
|
||||
if (this.isLinkedCard()) Utils.goCardId(this.linkedId);
|
||||
else if (this.isLinkedBoard())
|
||||
Utils.goBoardId(this.linkedId);
|
||||
},
|
||||
'click .js-card-move-up'(event) {
|
||||
// The move buttons sit inside the minicard anchor; don't let the click
|
||||
// bubble up and open the card detail view.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
moveCardBy(this, -1);
|
||||
},
|
||||
'click .js-card-move-down'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
moveCardBy(this, 1);
|
||||
},
|
||||
'click .js-toggle-card-complete'(event) {
|
||||
// Trello-style "mark complete" toggle (left of the title). Don't let the
|
||||
// click open the card.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!Utils.canModifyCard()) {
|
||||
return;
|
||||
}
|
||||
this.setDueComplete(!this.getDueComplete());
|
||||
},
|
||||
'click .js-toggle-minicard-label-text'() {
|
||||
if (window.localStorage.getItem('hiddenMinicardLabelText')) {
|
||||
window.localStorage.removeItem('hiddenMinicardLabelText'); //true
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -74,14 +74,13 @@ template(name="subtasksItems")
|
||||
template(name='subtaskItemDetail')
|
||||
.js-subtasks-item.subtasks-item
|
||||
if canModifyCard
|
||||
span.check-box-unicode
|
||||
i.fa(class="{{#if item.isFinished}}fa-check-square{{else}}fa-square-o{{/if}}")
|
||||
.check-box-container
|
||||
.check-box.materialCheckBox(class="{{#if item.isFinished}}is-checked{{/if}}")
|
||||
.item-title.js-open-inlined-form.is-editable(class="{{#if item.isFinished }}is-checked{{/if}}")
|
||||
+viewer
|
||||
= item.title
|
||||
else
|
||||
span.check-box-unicode
|
||||
i.fa(class="{{#if item.isFinished}}fa-check-square{{else}}fa-square-o{{/if}}")
|
||||
.materialCheckBox(class="{{#if item.isFinished}}is-checked{{/if}}")
|
||||
.item-title(class="{{#if item.isFinished }}is-checked{{/if}}")
|
||||
+viewer
|
||||
= item.title
|
||||
|
||||
@@ -119,7 +119,7 @@ Template.subtasks.helpers({
|
||||
});
|
||||
|
||||
Template.subtaskItemDetail.events({
|
||||
async 'click .js-subtasks-item .check-box-unicode'() {
|
||||
async 'click .js-subtasks-item .check-box-container'() {
|
||||
const item = Template.currentData().item;
|
||||
if (item && item._id) {
|
||||
await item.toggleItem();
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
}
|
||||
|
||||
.original-position-loading i {
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
|
||||
.original-position-details {
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
.original-position-moved i {
|
||||
color: #f39c12;
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
|
||||
.original-position-unchanged {
|
||||
@@ -48,7 +48,7 @@
|
||||
|
||||
.original-position-unchanged i {
|
||||
color: #28a745;
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
|
||||
.original-position-text {
|
||||
|
||||
@@ -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,8 +130,9 @@ body.list-resizing-active * {
|
||||
flex-basis: auto !important;
|
||||
flex-grow: 0 !important;
|
||||
flex-shrink: 0 !important;
|
||||
float: left !important;
|
||||
display: block !important;
|
||||
float: inline-start !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
/* Ensure the width persists after resize is complete */
|
||||
@@ -145,8 +147,9 @@ body.list-resizing-active * {
|
||||
flex-basis: auto !important;
|
||||
flex-grow: 0 !important;
|
||||
flex-shrink: 0 !important;
|
||||
float: left !important;
|
||||
display: block !important;
|
||||
float: inline-start !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
/* Ensure consistent header height for all lists */
|
||||
@@ -189,7 +192,7 @@ body.list-resizing-active * {
|
||||
.list-header .js-open-list-menu {
|
||||
position: absolute !important;
|
||||
top: 5px !important;
|
||||
right: 10px !important;
|
||||
inset-inline-end: 10px !important;
|
||||
z-index: 15 !important;
|
||||
display: inline-block !important;
|
||||
padding: 4px !important;
|
||||
@@ -198,7 +201,7 @@ body.list-resizing-active * {
|
||||
.list-header .list-header-plus-top {
|
||||
position: absolute !important;
|
||||
top: 5px !important;
|
||||
right: 30px !important;
|
||||
inset-inline-end: 30px !important;
|
||||
z-index: 15 !important;
|
||||
display: inline-block !important;
|
||||
padding: 4px !important;
|
||||
@@ -207,7 +210,7 @@ body.list-resizing-active * {
|
||||
.list-header .list-header-handle-desktop {
|
||||
position: absolute !important;
|
||||
top: 5px !important;
|
||||
right: 80px !important;
|
||||
inset-inline-end: 80px !important;
|
||||
z-index: 15 !important;
|
||||
display: inline-block !important;
|
||||
cursor: move !important;
|
||||
@@ -229,11 +232,11 @@ body.list-resizing-active * {
|
||||
flex: 1;
|
||||
}
|
||||
.list:first-child {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
flex: none;
|
||||
}
|
||||
.card-details + .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
}
|
||||
.list.ui-sortable-helper {
|
||||
box-shadow: -2px 2px 8px rgba(0,0,0,0.3), 0 0 1px rgba(0,0,0,0.5);
|
||||
@@ -311,7 +314,7 @@ body.list-resizing-active * {
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
width: auto;
|
||||
left: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
top: auto !important;
|
||||
}
|
||||
.list.list-collapsed .list-header .list-header-handle {
|
||||
@@ -322,7 +325,7 @@ body.list-resizing-active * {
|
||||
display: block;
|
||||
width: auto;
|
||||
top: auto !important;
|
||||
right: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
pointer-events: auto !important;
|
||||
@@ -336,8 +339,8 @@ body.list-resizing-active * {
|
||||
display: block;
|
||||
width: auto;
|
||||
top: auto !important;
|
||||
right: auto !important;
|
||||
left: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
transform: none !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
@@ -373,7 +376,7 @@ body.list-resizing-active * {
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
position: static;
|
||||
left: auto;
|
||||
inset-inline-start: auto;
|
||||
top: auto;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
@@ -443,7 +446,7 @@ form.inlined-form .list-composer {
|
||||
/* Sum badge shown before list title */
|
||||
.list-header .list-sum-badge {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
@@ -456,19 +459,19 @@ form.inlined-form .list-composer {
|
||||
width: 1.3vw;
|
||||
height: 35vh;
|
||||
margin-top: -12vh;
|
||||
margin-left: -14vw;
|
||||
margin-right: 0;
|
||||
margin-inline-start: -14vw;
|
||||
margin-inline-end: 0;
|
||||
transform: rotate(90deg);
|
||||
position: relative;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.list-header .list-header-watch-icon {
|
||||
padding-left: 10px;
|
||||
padding-inline-start: 10px;
|
||||
color: #a6a6a6;
|
||||
}
|
||||
.list-header .list-header-menu {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
@media print {
|
||||
.list-header .list-header-menu,
|
||||
@@ -478,7 +481,7 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
.list-header .list-header-plus-top {
|
||||
color: #a6a6a6;
|
||||
margin-right: 15px;
|
||||
margin-inline-end: 15px;
|
||||
vertical-align: middle;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -487,13 +490,13 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
.list-header .list-header-collapse-left {
|
||||
color: #a6a6a6;
|
||||
margin-right: 15px;
|
||||
margin-inline-end: 15px;
|
||||
}
|
||||
/* List header collapse button styling - positioned at top left */
|
||||
.list-header .js-collapse {
|
||||
position: absolute !important;
|
||||
top: 5px !important;
|
||||
left: 10px !important;
|
||||
inset-inline-start: 10px !important;
|
||||
color: #a6a6a6;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
@@ -527,8 +530,8 @@ form.inlined-form .list-composer {
|
||||
.list:not(.list-collapsed):not(.mobile-view) .list-header .js-collapse {
|
||||
position: static !important;
|
||||
top: auto !important;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
transform: none !important;
|
||||
margin: 0 !important;
|
||||
min-width: 24px;
|
||||
@@ -580,7 +583,7 @@ form.inlined-form .list-composer {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
position: static;
|
||||
left: auto;
|
||||
inset-inline-start: auto;
|
||||
top: auto;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
@@ -631,7 +634,7 @@ form.inlined-form .list-composer {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
position: static;
|
||||
left: auto;
|
||||
inset-inline-start: auto;
|
||||
top: auto;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
@@ -682,7 +685,7 @@ form.inlined-form .list-composer {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
position: static;
|
||||
left: auto;
|
||||
inset-inline-start: auto;
|
||||
top: auto;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
@@ -702,7 +705,7 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
.list-header .list-header-collapse {
|
||||
color: #a6a6a6;
|
||||
margin-right: 15px;
|
||||
margin-inline-end: 15px;
|
||||
}
|
||||
.list-header .highlight {
|
||||
color: #ce1414;
|
||||
@@ -716,7 +719,7 @@ form.inlined-form .list-composer {
|
||||
.js-open-list-menu,
|
||||
.list-header-menu a {
|
||||
color: #4d4d4d;
|
||||
padding-left: 4px;
|
||||
padding-inline-start: 4px;
|
||||
}
|
||||
.js-open-list-menu {
|
||||
font-size: 18px;
|
||||
@@ -757,7 +760,7 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
}
|
||||
.list-body .open-minicard-composer i.fa {
|
||||
margin-right: 7px;
|
||||
margin-inline-end: 7px;
|
||||
}
|
||||
.list-body .open-minicard-composer:hover {
|
||||
background: #fafafa;
|
||||
@@ -775,16 +778,16 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
#js-wip-limit-edit .wip-limit-value {
|
||||
width: 20%;
|
||||
margin-right: 5%;
|
||||
margin-inline-end: 5%;
|
||||
}
|
||||
#js-wip-limit-edit .wip-limit-error {
|
||||
display: none;
|
||||
}
|
||||
#js-wip-limit-edit .soft-wip-limit {
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
#js-wip-limit-edit div {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
#js-list-width-edit .list-width-error {
|
||||
display: none;
|
||||
@@ -796,7 +799,7 @@ form.inlined-form .list-composer {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
min-width: 100vw;
|
||||
border-left: 0px !important;
|
||||
border-inline-start: 0px !important;
|
||||
border-bottom: 1px solid #ccc;
|
||||
display: block !important;
|
||||
}
|
||||
@@ -806,19 +809,19 @@ form.inlined-form .list-composer {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
min-width: 100vw;
|
||||
border-left: 0px !important;
|
||||
border-inline-start: 0px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.list.mobile-view:first-child {
|
||||
margin-left: 0px;
|
||||
margin-inline-start: 0px;
|
||||
}
|
||||
.list.mobile-view.ui-sortable-helper {
|
||||
flex: 0 0 60px;
|
||||
height: 60px;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
border-left: 0px !important;
|
||||
border-inline-start: 0px !important;
|
||||
border-bottom: 1px solid #ccc;
|
||||
display: block !important;
|
||||
}
|
||||
@@ -830,7 +833,7 @@ form.inlined-form .list-composer {
|
||||
height: 60px;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
border-left: 0px !important;
|
||||
border-inline-start: 0px !important;
|
||||
border-bottom: 1px solid #ccc;
|
||||
display: block !important;
|
||||
}
|
||||
@@ -857,10 +860,10 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
.list.mobile-view .list-header .list-header-left-icon {
|
||||
padding: 7px;
|
||||
padding-right: 27px;
|
||||
padding-inline-end: 27px;
|
||||
margin-top: 1px;
|
||||
top: -7px;
|
||||
left: -7px;
|
||||
inset-inline-start: -7px;
|
||||
}
|
||||
.list.mobile-view .list-header .list-header-menu-icon {
|
||||
padding: 14px;
|
||||
@@ -868,7 +871,7 @@ form.inlined-form .list-composer {
|
||||
text-align: center;
|
||||
/* Force positioning for iPhone */
|
||||
position: absolute !important;
|
||||
right: 60px !important;
|
||||
inset-inline-end: 60px !important;
|
||||
top: 50% !important;
|
||||
transform: translateY(-50%) !important;
|
||||
z-index: 10;
|
||||
@@ -879,7 +882,7 @@ form.inlined-form .list-composer {
|
||||
text-align: center;
|
||||
/* Force positioning for iPhone */
|
||||
position: absolute !important;
|
||||
right: 10px !important;
|
||||
inset-inline-end: 10px !important;
|
||||
top: 50% !important;
|
||||
transform: translateY(-50%) !important;
|
||||
z-index: 10;
|
||||
@@ -902,9 +905,9 @@ form.inlined-form .list-composer {
|
||||
grid-row: 2;
|
||||
grid-column: 2;
|
||||
align-self: start;
|
||||
text-align: left;
|
||||
padding-left: 0;
|
||||
margin-left: 0;
|
||||
text-align: start;
|
||||
padding-inline-start: 0;
|
||||
margin-inline-start: 0;
|
||||
font-size: 16px !important;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -936,7 +939,7 @@ form.inlined-form .list-composer {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
min-width: 100vw;
|
||||
border-left: 0px !important;
|
||||
border-inline-start: 0px !important;
|
||||
border-bottom: 1px solid #ccc;
|
||||
display: block !important;
|
||||
}
|
||||
@@ -946,19 +949,19 @@ form.inlined-form .list-composer {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
min-width: 100vw;
|
||||
border-left: 0px !important;
|
||||
border-inline-start: 0px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.list:first-child {
|
||||
margin-left: 0px;
|
||||
margin-inline-start: 0px;
|
||||
}
|
||||
.list.ui-sortable-helper {
|
||||
flex: 0 0 60px;
|
||||
height: 60px;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
border-left: 0px !important;
|
||||
border-inline-start: 0px !important;
|
||||
border-bottom: 1px solid #ccc;
|
||||
display: block !important;
|
||||
}
|
||||
@@ -970,7 +973,7 @@ form.inlined-form .list-composer {
|
||||
height: 60px;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
border-left: 0px !important;
|
||||
border-inline-start: 0px !important;
|
||||
border-bottom: 1px solid #ccc;
|
||||
display: block !important;
|
||||
}
|
||||
@@ -993,10 +996,10 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
.list-header .list-header-left-icon {
|
||||
padding: 7px;
|
||||
padding-right: 27px;
|
||||
padding-inline-end: 27px;
|
||||
margin-top: 1px;
|
||||
top: -7px;
|
||||
left: -7px;
|
||||
inset-inline-start: -7px;
|
||||
}
|
||||
.list-header .list-header-menu-icon {
|
||||
padding: 14px;
|
||||
@@ -1004,7 +1007,7 @@ form.inlined-form .list-composer {
|
||||
text-align: center;
|
||||
/* iOS Safari fallback positioning */
|
||||
position: absolute;
|
||||
right: 60px;
|
||||
inset-inline-end: 60px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
@@ -1014,7 +1017,7 @@ form.inlined-form .list-composer {
|
||||
text-align: center;
|
||||
/* iOS Safari fallback positioning */
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
inset-inline-end: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
@@ -1080,7 +1083,7 @@ form.inlined-form .list-composer {
|
||||
.list.mobile-view .list-header .list-header-menu-icon {
|
||||
/* Remove absolute positioning for iPhone 12 Mini */
|
||||
position: static !important;
|
||||
right: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
top: auto !important;
|
||||
transform: none !important;
|
||||
/* Use grid positioning */
|
||||
@@ -1094,7 +1097,7 @@ form.inlined-form .list-composer {
|
||||
.list.mobile-view .list-header .list-header-handle {
|
||||
/* Remove absolute positioning for iPhone 12 Mini */
|
||||
position: static !important;
|
||||
right: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
top: auto !important;
|
||||
transform: none !important;
|
||||
/* Use grid positioning */
|
||||
@@ -1119,9 +1122,9 @@ form.inlined-form .list-composer {
|
||||
grid-row: 2 !important;
|
||||
grid-column: 2 !important;
|
||||
align-self: start !important;
|
||||
text-align: left !important;
|
||||
padding-left: 0 !important;
|
||||
margin-left: 0 !important;
|
||||
text-align: start !important;
|
||||
padding-inline-start: 0 !important;
|
||||
margin-inline-start: 0 !important;
|
||||
font-size: 16px !important;
|
||||
line-height: 1.2 !important;
|
||||
}
|
||||
@@ -1145,7 +1148,7 @@ form.inlined-form .list-composer {
|
||||
.iphone-device .list.mobile-view .list-header .list-header-menu-icon {
|
||||
/* Remove absolute positioning for iPhone devices */
|
||||
position: static !important;
|
||||
right: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
top: auto !important;
|
||||
transform: none !important;
|
||||
/* Use grid positioning */
|
||||
@@ -1159,7 +1162,7 @@ form.inlined-form .list-composer {
|
||||
.iphone-device .list.mobile-view .list-header .list-header-handle {
|
||||
/* Remove absolute positioning for iPhone devices */
|
||||
position: static !important;
|
||||
right: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
top: auto !important;
|
||||
transform: none !important;
|
||||
/* Use grid positioning */
|
||||
@@ -1216,7 +1219,7 @@ form.inlined-form .list-composer {
|
||||
align-items: baseline;
|
||||
}
|
||||
.link-board-wrapper .js-link-board {
|
||||
margin-left: 15px;
|
||||
margin-inline-start: 15px;
|
||||
}
|
||||
.search-card-results {
|
||||
max-height: 250px;
|
||||
@@ -1321,8 +1324,8 @@ form.inlined-form .list-composer {
|
||||
.list.list-collapsed:not(.mobile-view) .list-header .list-header-handle {
|
||||
position: relative !important;
|
||||
top: auto !important;
|
||||
right: auto !important;
|
||||
left: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
transform: none !important;
|
||||
display: block !important;
|
||||
margin: 4px auto 2px auto !important;
|
||||
@@ -1337,8 +1340,8 @@ form.inlined-form .list-composer {
|
||||
.list.list-collapsed:not(.mobile-view) .list-header .js-collapse {
|
||||
position: relative !important;
|
||||
top: auto !important;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
display: block !important;
|
||||
margin: 2px auto !important;
|
||||
z-index: 400 !important;
|
||||
@@ -1374,7 +1377,7 @@ form.inlined-form .list-composer {
|
||||
transform: none !important;
|
||||
writing-mode: vertical-lr !important;
|
||||
text-orientation: mixed !important;
|
||||
text-align: left !important;
|
||||
text-align: start !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
@@ -1444,8 +1447,8 @@ form.inlined-form .list-composer {
|
||||
flex: 0 0 auto;
|
||||
position: static !important;
|
||||
top: auto !important;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
transform: none !important;
|
||||
margin: 2px 2px 0 0 !important;
|
||||
padding: 2px 4px !important;
|
||||
@@ -1463,7 +1466,7 @@ form.inlined-form .list-composer {
|
||||
flex: 1 0 100%;
|
||||
min-width: 0;
|
||||
padding-top: 4px !important;
|
||||
padding-right: 0 !important;
|
||||
padding-inline-end: 0 !important;
|
||||
}
|
||||
|
||||
/* Hide standalone duplicate drag handle outside the menu group */
|
||||
@@ -1475,7 +1478,7 @@ form.inlined-form .list-composer {
|
||||
.list:not(.list-collapsed):not(.mobile-view) .list-header .list-header-watch-icon {
|
||||
order: 2;
|
||||
flex: 0 0 auto;
|
||||
padding-left: 0;
|
||||
padding-inline-start: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
@@ -1486,7 +1489,7 @@ form.inlined-form .list-composer {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
margin-inline-start: auto;
|
||||
margin-top: 2px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
@@ -1497,8 +1500,8 @@ form.inlined-form .list-composer {
|
||||
.list:not(.list-collapsed):not(.mobile-view) .list-header .list-header-menu .js-open-list-menu {
|
||||
position: static !important;
|
||||
top: auto !important;
|
||||
right: auto !important;
|
||||
left: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
transform: none !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
@@ -1533,7 +1536,7 @@ form.inlined-form .list-composer {
|
||||
|
||||
.list.list-collapsed .list-header .js-collapse {
|
||||
position: relative !important;
|
||||
left: -10px !important;
|
||||
inset-inline-start: -10px !important;
|
||||
color: #333;
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -355,9 +355,10 @@ Template.listBody.helpers({
|
||||
canSeeAddCard() {
|
||||
const tpl = Template.instance();
|
||||
const list = Template.currentData();
|
||||
// #2095: count all cards in the list, not only the filtered/visible ones.
|
||||
const reachedWipLimit = !list.getWipLimit('soft') &&
|
||||
list.getWipLimit('enabled') &&
|
||||
list.getWipLimit('value') <= list.cards().length;
|
||||
list.getWipLimit('value') <= list.cardsUnfiltered().length;
|
||||
return (
|
||||
!reachedWipLimit &&
|
||||
Utils.canModifyCard()
|
||||
@@ -369,7 +370,8 @@ Template.listBody.helpers({
|
||||
return (
|
||||
!list.getWipLimit('soft') &&
|
||||
list.getWipLimit('enabled') &&
|
||||
list.getWipLimit('value') <= list.cards().length
|
||||
// #2095: count all cards in the list, not only the filtered/visible ones.
|
||||
list.getWipLimit('value') <= list.cardsUnfiltered().length
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,5 @@
|
||||
.due-cards-dueat-list-wrapper {
|
||||
max-width: min(500px, 65vw);
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
margin-inline-end: auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
@@ -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,11 +121,11 @@ code {
|
||||
}
|
||||
.global-search-next-page {
|
||||
border: none;
|
||||
text-align: right;
|
||||
text-align: end;
|
||||
}
|
||||
.global-search-previous-page {
|
||||
border: none;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
}
|
||||
.global-search-instructions li {
|
||||
margin-bottom: 0.3rem;
|
||||
|
||||
@@ -4,6 +4,36 @@ template(name="globalSearchHeaderBar")
|
||||
i.fa.fa-search
|
||||
| {{_ 'globalSearch-title'}}
|
||||
|
||||
.board-header-btns.left
|
||||
a.board-header-btn.js-global-search-view-change(title="{{_ 'globalSearchViewChange-title'}}")
|
||||
i.fa.fa-caret-down
|
||||
if $eq globalSearchView 'me'
|
||||
i.fa.fa-user
|
||||
| {{_ 'globalSearchViewChange-choice-me'}}
|
||||
if $eq globalSearchView 'all'
|
||||
i.fa.fa-users
|
||||
| {{_ 'globalSearchViewChange-choice-all'}}
|
||||
|
||||
template(name="globalSearchViewChangePopup")
|
||||
if currentUser
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-global-search-view-me
|
||||
i.fa.fa-user
|
||||
| {{_ 'globalSearchViewChange-choice-me'}}
|
||||
if $eq Utils.globalSearchView "me"
|
||||
i.fa.fa-check
|
||||
hr
|
||||
li
|
||||
a.js-global-search-view-all
|
||||
i.fa.fa-users
|
||||
| {{_ 'globalSearchViewChange-choice-all'}}
|
||||
span.sub-name
|
||||
+viewer
|
||||
| {{_ 'globalSearchViewChange-choice-all-description' }}
|
||||
if $eq Utils.globalSearchView "all"
|
||||
i.fa.fa-check
|
||||
|
||||
template(name="globalSearchModalTitle")
|
||||
if currentUser
|
||||
h2
|
||||
@@ -29,7 +59,8 @@ template(name="resultsPaged")
|
||||
template(name="globalSearch")
|
||||
if currentUser
|
||||
.wrapper(class=boardColorClass)
|
||||
form.global-search-page.js-search-query-form(onsubmit="return false;")
|
||||
form.global-search-page.js-search-query-form(role="search" onsubmit="return false;")
|
||||
label.sr-only(for="global-search-input") {{_ 'globalSearch-title'}}
|
||||
input.global-search-query-input(
|
||||
style="display: inline-block;"
|
||||
id="global-search-input"
|
||||
@@ -37,10 +68,11 @@ template(name="globalSearch")
|
||||
name="searchQuery"
|
||||
placeholder="{{_ 'search-example'}}"
|
||||
value="{{ queryValue }}"
|
||||
aria-label="{{_ 'globalSearch-title'}}"
|
||||
autofocus dir="auto"
|
||||
)
|
||||
button.js-new-search.board-color-belize(type="button" style="background: #216694 !important; background-color: #216694 !important; color: #fff !important; border: none !important;")
|
||||
| 🧹
|
||||
button.js-new-search.board-color-belize(type="button" aria-label="{{_ 'globalSearch-title'}}" style="background: #216694 !important; background-color: #216694 !important; color: #fff !important; border: none !important;")
|
||||
span(aria-hidden="true") 🧹
|
||||
if searching
|
||||
+spinner
|
||||
else if hasResults
|
||||
|
||||
@@ -2,12 +2,34 @@ import { TAPi18n } from '/imports/i18n';
|
||||
import { CardSearchPaged } from '../../lib/cardSearch';
|
||||
import { LABEL_COLORS } from '/models/metadata/colors';
|
||||
import { Query, QueryErrors } from '../../../config/query-classes';
|
||||
import { OPERATOR_USER } from '/config/search-const';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
|
||||
// const subManager = new SubsManager();
|
||||
|
||||
// #3392-adjacent: "my cards / all cards" toggle for global search (mirrors the
|
||||
// due-cards view toggle). When "me", the current user is injected as a `user:`
|
||||
// predicate (member OR assignee), reusing the existing search logic.
|
||||
Template.globalSearchHeaderBar.helpers({
|
||||
globalSearchView() {
|
||||
return Utils.globalSearchView();
|
||||
},
|
||||
});
|
||||
|
||||
Template.globalSearchHeaderBar.events({
|
||||
'click .js-due-cards-view-change': Popup.open('globalSearchViewChange'),
|
||||
'click .js-global-search-view-change': Popup.open('globalSearchViewChange'),
|
||||
});
|
||||
|
||||
Template.globalSearchViewChangePopup.events({
|
||||
'click .js-global-search-view-me'() {
|
||||
Utils.setGlobalSearchView('me');
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-global-search-view-all'() {
|
||||
Utils.setGlobalSearchView('all');
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
|
||||
Template.globalSearch.onCreated(function () {
|
||||
@@ -42,27 +64,32 @@ Template.globalSearch.onCreated(function () {
|
||||
Template.globalSearch.onRendered(function () {
|
||||
Meteor.subscribe('setting');
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
//console.log('lang:', TAPi18n.getLanguage());
|
||||
|
||||
if (Session.get('globalQuery')) {
|
||||
searchAllBoards(this, Session.get('globalQuery'));
|
||||
}
|
||||
const tpl = this;
|
||||
// Re-run the search reactively when the query OR the "my/all" view changes,
|
||||
// so toggling the view filters immediately without a page reload.
|
||||
this.autorun(() => {
|
||||
const view = Utils.globalSearchView(); // reactive dependency
|
||||
const queryText = Session.get('globalQuery'); // reactive dependency
|
||||
if (queryText || view === 'me') {
|
||||
searchAllBoards(tpl, queryText || '');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function searchAllBoards(tpl, queryText) {
|
||||
const search = tpl.search;
|
||||
|
||||
queryText = queryText.trim();
|
||||
// eslint-disable-next-line no-console
|
||||
//console.log('queryText:', queryText);
|
||||
queryText = (queryText || '').trim();
|
||||
|
||||
search.query.set(queryText);
|
||||
|
||||
search.resetSearch();
|
||||
tpl.parsingErrors = new QueryErrors();
|
||||
|
||||
if (!queryText) {
|
||||
const mine = Utils.globalSearchView() === 'me';
|
||||
|
||||
// Nothing to search: empty query and not restricting to the current user.
|
||||
if (!queryText && !mine) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -71,10 +98,17 @@ function searchAllBoards(tpl, queryText) {
|
||||
const query = new Query();
|
||||
query.buildParams(queryText);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
// console.log('params:', query.getParams());
|
||||
const queryParams = query.getQueryParams();
|
||||
|
||||
tpl.queryParams = query.getQueryParams().getParams();
|
||||
// "My cards": restrict to cards where the current user is member or assignee.
|
||||
if (mine) {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
if (currentUser && currentUser.username) {
|
||||
queryParams.addPredicate(OPERATOR_USER, currentUser.username);
|
||||
}
|
||||
}
|
||||
|
||||
tpl.queryParams = queryParams.getParams();
|
||||
|
||||
if (query.hasErrors()) {
|
||||
search.searching.set(false);
|
||||
@@ -84,7 +118,7 @@ function searchAllBoards(tpl, queryText) {
|
||||
return;
|
||||
}
|
||||
|
||||
search.runGlobalSearch(query.getQueryParams());
|
||||
search.runGlobalSearch(queryParams);
|
||||
}
|
||||
|
||||
function errorMessages(tpl) {
|
||||
|
||||
@@ -13,12 +13,12 @@
|
||||
line-height: 1.7em;
|
||||
padding: 0 10px;
|
||||
margin: 0;
|
||||
margin-right: 10px;
|
||||
float: left;
|
||||
margin-inline-end: 10px;
|
||||
float: inline-start;
|
||||
border-radius: 3px;
|
||||
}
|
||||
#header #header-main-bar h1 .board-header-watch-icon {
|
||||
padding-left: 7px;
|
||||
padding-inline-start: 7px;
|
||||
}
|
||||
#header #header-main-bar h1 a.fa,
|
||||
#header #header-main-bar h1 a i.fa {
|
||||
@@ -26,14 +26,14 @@
|
||||
}
|
||||
#header #header-main-bar h1 .back-btn {
|
||||
font-size: 0.9em;
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
#header #header-main-bar .wekan-logo {
|
||||
margin: 3px auto auto;
|
||||
width: 97px;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
#header #header-main-bar .wekan-logo:hover {
|
||||
opacity: 0.9;
|
||||
@@ -44,10 +44,10 @@
|
||||
width: auto;
|
||||
}
|
||||
#header #header-main-bar .board-header-btns.left {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
#header #header-main-bar .board-header-btns.right {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
#header #header-main-bar .board-header-btn {
|
||||
border-radius: 3px;
|
||||
@@ -55,13 +55,13 @@
|
||||
padding: 0;
|
||||
height: 28px;
|
||||
font-size: 13px;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
overflow: hidden;
|
||||
line-height: 28px;
|
||||
margin: 0 12px;
|
||||
}
|
||||
#header #header-main-bar .board-header-btn i.fa {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
display: block;
|
||||
line-height: 28px;
|
||||
color: #f2f2f2;
|
||||
@@ -70,16 +70,16 @@
|
||||
#header #header-main-bar .board-header-btn i.fa + span {
|
||||
display: inline-block;
|
||||
margin-top: 1px;
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
#header #header-main-bar .board-header-btn .board-header-btn-close {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
#header #header-main-bar .board-header-btn .board-header-btn-close i.fa {
|
||||
margin: 0 6px;
|
||||
}
|
||||
#header #header-main-bar .board-header-btn .board-header-btn-icon {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
display: block;
|
||||
line-height: 28px;
|
||||
color: #27ae60;
|
||||
@@ -100,9 +100,9 @@
|
||||
}
|
||||
#header #header-main-bar .separator {
|
||||
margin: 2px 4px;
|
||||
border-left: 1px solid rgba(255,255,255,0.3);
|
||||
border-inline-start: 1px solid rgba(255,255,255,0.3);
|
||||
height: 24px;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
|
||||
/* On non-board pages (All Boards / home, public, my cards, …) the second bar
|
||||
@@ -141,7 +141,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
#header-quick-access .home-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 1rem;
|
||||
margin-inline-end: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
|
||||
#header-quick-access .home-icon .fa-home {
|
||||
font-size: 16px;
|
||||
margin-right: 4px;
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
|
||||
#header-quick-access .allBoards {
|
||||
@@ -257,10 +257,10 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
}
|
||||
#header-quick-access ul.header-quick-access-list li:first-child .fa-home,
|
||||
#header-quick-access ul.header-quick-access-list li:nth-child(3) .fa-globe {
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
#header-quick-access ul.header-quick-access-list li a.js-create-board {
|
||||
margin-left: 5px;
|
||||
margin-inline-start: 5px;
|
||||
}
|
||||
#header-quick-access #header-user-bar,
|
||||
#header-quick-access #header-new-board-icon {
|
||||
@@ -270,10 +270,10 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
margin: 2px 0;
|
||||
}
|
||||
#header-quick-access #header-user-bar .header-user-bar-avatar {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
position: relative;
|
||||
top: -5px;
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
#header-quick-access #header-user-bar .header-user-bar-avatar .member,
|
||||
#header-quick-access #header-help {
|
||||
@@ -286,7 +286,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
#header-quick-access #header-user-bar .header-user-bar-name,
|
||||
#header-quick-access #header-help {
|
||||
margin: 4px 8px 0 0;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
|
||||
/* Zoom Controls in Header */
|
||||
@@ -299,7 +299,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
border-radius: 0.5vw;
|
||||
box-shadow: 0 0.2vh 0.5vh rgba(0,0,0,0.1);
|
||||
margin: 0 1vw;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
|
||||
#header-quick-access .zoom-controls .board-header-btn {
|
||||
@@ -401,7 +401,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 1vw;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
|
||||
#header-quick-access .mobile-mode-toggle .board-header-btn {
|
||||
@@ -486,7 +486,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
transform: scale(1.1) !important;
|
||||
}
|
||||
#header-quick-access #header-user-bar .header-user-bar-name i.fa-chevron-down {
|
||||
margin-right: 4px;
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
#header-quick-access #header-new-board-icon {
|
||||
flex-grow: 1;
|
||||
@@ -522,12 +522,12 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
|
||||
/* Mobile home icon styling */
|
||||
#header-quick-access .home-icon {
|
||||
margin-right: 0.5rem;
|
||||
margin-inline-end: 0.5rem;
|
||||
}
|
||||
|
||||
#header-quick-access .home-icon .fa-home {
|
||||
font-size: 16px;
|
||||
margin-right: 4px;
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
|
||||
#header-quick-access .home-icon a {
|
||||
@@ -550,7 +550,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
|
||||
#header-quick-access .home-icon .fa-home {
|
||||
font-size: 14px;
|
||||
margin-right: 3px;
|
||||
margin-inline-end: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,7 +612,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
/* Mobile header wrapping and spacing */
|
||||
#header-quick-access .home-icon {
|
||||
flex-shrink: 0 !important;
|
||||
margin-right: 0.5rem !important;
|
||||
margin-inline-end: 0.5rem !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
|
||||
@@ -808,7 +808,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
/* iPhone 12 Mini header wrapping and spacing */
|
||||
#header-quick-access .home-icon {
|
||||
flex-shrink: 0 !important;
|
||||
margin-right: 0.5rem !important;
|
||||
margin-inline-end: 0.5rem !important;
|
||||
margin-bottom: 6px !important;
|
||||
}
|
||||
|
||||
@@ -868,11 +868,11 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
|
||||
#header-quick-access .home-icon .fa-home {
|
||||
font-size: 20px !important; /* Much larger icon */
|
||||
margin-right: 6px !important;
|
||||
margin-inline-end: 6px !important;
|
||||
}
|
||||
|
||||
#header-quick-access .home-icon {
|
||||
margin-right: 1rem !important;
|
||||
margin-inline-end: 1rem !important;
|
||||
}
|
||||
|
||||
/* Make zoom controls larger */
|
||||
@@ -956,7 +956,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
/* iPhone device header wrapping and spacing */
|
||||
.iphone-device #header-quick-access .home-icon {
|
||||
flex-shrink: 0 !important;
|
||||
margin-right: 0.5rem !important;
|
||||
margin-inline-end: 0.5rem !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
|
||||
@@ -1057,7 +1057,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
/* iPhone 12 Mini header wrapping and spacing - JavaScript fallback */
|
||||
.iphone-device #header-quick-access .home-icon {
|
||||
flex-shrink: 0 !important;
|
||||
margin-right: 0.5rem !important;
|
||||
margin-inline-end: 0.5rem !important;
|
||||
margin-bottom: 6px !important;
|
||||
}
|
||||
|
||||
@@ -1167,7 +1167,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
|
||||
#header-quick-access ul {
|
||||
width: calc(100% - 60px);
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
#header-quick-access ul li {
|
||||
height: 100%;
|
||||
@@ -1179,7 +1179,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
display: none;
|
||||
}
|
||||
#header-quick-access #header-user-bar {
|
||||
right: auto;
|
||||
inset-inline-end: auto;
|
||||
padding: 10px;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
@@ -1261,7 +1261,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
display: none;
|
||||
}
|
||||
#header #header-user-bar {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
}
|
||||
.announcement .viewer {
|
||||
|
||||
@@ -5,7 +5,7 @@ template(name="header")
|
||||
Reddit "subreddit" bar.
|
||||
The first link goes to the boards page.
|
||||
if currentUser
|
||||
#header-quick-access(class=currentBoard.colorClass)
|
||||
#header-quick-access(class=currentBoard.colorClass role="navigation" aria-label="{{_ 'all-boards'}}")
|
||||
// Home icon - always at left side of logo
|
||||
span.home-icon.allBoards
|
||||
a(href="{{pathFor 'home'}}")
|
||||
@@ -108,14 +108,13 @@ template(name="header")
|
||||
|
||||
if currentUser.isBoardMember
|
||||
if hasAnnouncement
|
||||
.announcement
|
||||
.announcement(role="status" aria-live="polite")
|
||||
p
|
||||
i.fa.fa-bullhorn
|
||||
i.fa.fa-bullhorn(aria-hidden="true")
|
||||
+viewer
|
||||
| #{announcement}
|
||||
a
|
||||
.js-close-announcement
|
||||
i.fa.fa-times-thin
|
||||
a.js-close-announcement(aria-label="{{_ 'close'}}")
|
||||
i.fa.fa-times-thin(aria-hidden="true")
|
||||
|
||||
template(name="offlineWarning")
|
||||
.offline-warning
|
||||
|
||||
@@ -172,7 +172,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;
|
||||
|
||||
@@ -32,6 +32,111 @@ a:focus {
|
||||
color: unset;
|
||||
text-decoration: unset;
|
||||
}
|
||||
/* Accessibility (WCAG 2.4.7 Focus Visible): the resets above strip focus
|
||||
* outlines from every element, which leaves keyboard users with no indication
|
||||
* of where focus is. Restore a clearly visible focus indicator for keyboard
|
||||
* navigation. :focus-visible only paints the outline for keyboard focus, so
|
||||
* mouse clicks stay visually unchanged. */
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible,
|
||||
summary:focus-visible,
|
||||
[tabindex]:focus-visible,
|
||||
[role="button"]:focus-visible,
|
||||
[contenteditable="true"]:focus-visible {
|
||||
outline: 2px solid #1565c0;
|
||||
outline-offset: 2px;
|
||||
/* Add a light halo so the outline is visible on both light and dark colored
|
||||
* board headers. */
|
||||
box-shadow: 0 0 0 4px rgba(21, 101, 192, 0.35);
|
||||
border-radius: 2px;
|
||||
}
|
||||
/* Browsers that do not support :focus-visible fall back to :focus so keyboard
|
||||
* users are never left without an indicator. */
|
||||
.no-focus-visible-support a:focus,
|
||||
.no-focus-visible-support button:focus,
|
||||
.no-focus-visible-support [tabindex]:focus {
|
||||
outline: 2px solid #1565c0;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Screen-reader-only helper: visually hidden but available to assistive tech. */
|
||||
.sr-only {
|
||||
position: absolute !important;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Visually-hidden but still *activatable* controls (#459 accessible reordering).
|
||||
* The plain `.sr-only` pattern clips its content to a 1x1 box with
|
||||
* `clip: rect(0,0,0,0)` + `overflow:hidden`, which removes the element from the
|
||||
* hit-test stack: a real pointer click (or Playwright's coordinate-based click)
|
||||
* at the control's own position resolves to whatever sits *behind* it, so the
|
||||
* click handler never fires. For interactive controls we instead keep a real,
|
||||
* hit-testable box that is merely transparent and out of flow. Keyboard/AT users
|
||||
* still reach it via Tab / the accessibility tree; sighted users don't see it. */
|
||||
.minicard-move-buttons.sr-only,
|
||||
.list-move-buttons.sr-only {
|
||||
position: static !important;
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
clip-path: none;
|
||||
white-space: normal;
|
||||
/* Keep the group flush so the (transparent) controls don't visibly nudge the
|
||||
* surrounding layout. */
|
||||
display: inline-flex;
|
||||
vertical-align: top;
|
||||
line-height: 0;
|
||||
}
|
||||
.minicard-move-buttons.sr-only a,
|
||||
.list-move-buttons.sr-only a {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
overflow: hidden;
|
||||
text-indent: -9999px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
/* Skip-to-content link (WCAG 2.4.1 Bypass Blocks): hidden until focused, then
|
||||
* pinned to the top-left so keyboard users can jump past the header chrome. */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
z-index: 10000;
|
||||
padding: 10px 16px;
|
||||
background: #1565c0;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
border-radius: 0 0 4px 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
.skip-link:focus,
|
||||
.skip-link:focus-visible {
|
||||
left: 0;
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: -4px;
|
||||
}
|
||||
/* The skip-link target must not show a persistent outline when focused
|
||||
* programmatically. */
|
||||
#content:focus {
|
||||
outline: none;
|
||||
}
|
||||
.badge {
|
||||
display: unset;
|
||||
min-width: unset;
|
||||
@@ -119,8 +224,8 @@ body.mobile-mode #content {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 100;
|
||||
overflow-y: auto;
|
||||
@@ -139,7 +244,7 @@ body.mobile-mode #content {
|
||||
}
|
||||
#modal .modal-content .modal-close-btn {
|
||||
display: block;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
font-size: clamp(18px, 4vw, 24px);
|
||||
}
|
||||
#modal .modal-content-wide {
|
||||
@@ -156,7 +261,7 @@ body.mobile-mode #content {
|
||||
}
|
||||
#modal .modal-content-wide .modal-close-btn {
|
||||
display: block;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
font-size: clamp(18px, 4vw, 24px);
|
||||
}
|
||||
h1 {
|
||||
@@ -245,7 +350,7 @@ tt {
|
||||
}
|
||||
blockquote {
|
||||
margin: 1vh 0 1vh 1vw;
|
||||
border-left: 1px solid #ccc;
|
||||
border-inline-start: 1px solid #ccc;
|
||||
color: #666;
|
||||
padding: 0 0 0 1vw;
|
||||
}
|
||||
@@ -264,12 +369,12 @@ td,
|
||||
th {
|
||||
vertical-align: top;
|
||||
border-top: 1px solid #ccc;
|
||||
border-left: 1px solid #ccc;
|
||||
border-inline-start: 1px solid #ccc;
|
||||
}
|
||||
td,
|
||||
th {
|
||||
padding: 0.8vh 0.8vw;
|
||||
border-right: 1px solid #ccc;
|
||||
border-inline-end: 1px solid #ccc;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
th {
|
||||
@@ -315,18 +420,18 @@ kbd {
|
||||
text-align: center;
|
||||
}
|
||||
.left {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
.right {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
.first {
|
||||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
margin-inline-start: 0;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
.last {
|
||||
margin-right: 0;
|
||||
padding-right: 0;
|
||||
margin-inline-end: 0;
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
.top {
|
||||
margin-top: 0;
|
||||
@@ -441,7 +546,7 @@ a:not(.disabled).is-active i.fa {
|
||||
line-height: 1.3;
|
||||
}
|
||||
.gutter {
|
||||
margin-left: 5vw;
|
||||
margin-inline-start: 5vw;
|
||||
}
|
||||
.viewer {
|
||||
min-height: 2.5vh;
|
||||
@@ -454,12 +559,12 @@ a:not(.disabled).is-active i.fa {
|
||||
}
|
||||
.viewer ol {
|
||||
list-style-type: decimal;
|
||||
padding-left: 3vw;
|
||||
padding-inline-start: 3vw;
|
||||
padding-bottom: 1.5vh;
|
||||
}
|
||||
.viewer ul {
|
||||
list-style-type: initial;
|
||||
padding-left: 3vw;
|
||||
padding-inline-start: 3vw;
|
||||
padding-bottom: 1.5vh;
|
||||
}
|
||||
.viewer em {
|
||||
@@ -478,7 +583,7 @@ a:not(.disabled).is-active i.fa {
|
||||
color: #333;
|
||||
}
|
||||
.basicTabs-container .tabs-list {
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
width: 100%;
|
||||
}
|
||||
.basicTabs-container .tabs-list .tab-item {
|
||||
@@ -629,16 +734,16 @@ a:not(.disabled).is-active i.fa {
|
||||
|
||||
/* Ensure proper spacing for mobile header elements */
|
||||
#header-quick-access .zoom-controls {
|
||||
margin-left: auto;
|
||||
margin-right: 8px;
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
|
||||
.mobile-mode-toggle {
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
|
||||
#header-user-bar {
|
||||
margin-left: auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
/* Ensure header elements don't wrap on very small screens */
|
||||
@@ -858,56 +963,56 @@ a:not(.disabled).is-active i.fa {
|
||||
}
|
||||
.lds-roller div:nth-child(1):after {
|
||||
top: 50px;
|
||||
left: 50px;
|
||||
inset-inline-start: 50px;
|
||||
}
|
||||
.lds-roller div:nth-child(2) {
|
||||
animation-delay: -0.072s;
|
||||
}
|
||||
.lds-roller div:nth-child(2):after {
|
||||
top: 54px;
|
||||
left: 45px;
|
||||
inset-inline-start: 45px;
|
||||
}
|
||||
.lds-roller div:nth-child(3) {
|
||||
animation-delay: -0.108s;
|
||||
}
|
||||
.lds-roller div:nth-child(3):after {
|
||||
top: 57px;
|
||||
left: 39px;
|
||||
inset-inline-start: 39px;
|
||||
}
|
||||
.lds-roller div:nth-child(4) {
|
||||
animation-delay: -0.144s;
|
||||
}
|
||||
.lds-roller div:nth-child(4):after {
|
||||
top: 58px;
|
||||
left: 32px;
|
||||
inset-inline-start: 32px;
|
||||
}
|
||||
.lds-roller div:nth-child(5) {
|
||||
animation-delay: -0.18s;
|
||||
}
|
||||
.lds-roller div:nth-child(5):after {
|
||||
top: 57px;
|
||||
left: 25px;
|
||||
inset-inline-start: 25px;
|
||||
}
|
||||
.lds-roller div:nth-child(6) {
|
||||
animation-delay: -0.216s;
|
||||
}
|
||||
.lds-roller div:nth-child(6):after {
|
||||
top: 54px;
|
||||
left: 19px;
|
||||
inset-inline-start: 19px;
|
||||
}
|
||||
.lds-roller div:nth-child(7) {
|
||||
animation-delay: -0.252s;
|
||||
}
|
||||
.lds-roller div:nth-child(7):after {
|
||||
top: 50px;
|
||||
left: 14px;
|
||||
inset-inline-start: 14px;
|
||||
}
|
||||
.lds-roller div:nth-child(8) {
|
||||
animation-delay: -0.288s;
|
||||
}
|
||||
.lds-roller div:nth-child(8):after {
|
||||
top: 45px;
|
||||
left: 10px;
|
||||
inset-inline-start: 10px;
|
||||
}
|
||||
.at-link {
|
||||
color: #17683a;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
template(name="main")
|
||||
html(lang="{{TAPi18n.getLanguage}}")
|
||||
html(lang="{{htmlLang}}" dir="{{htmlDir}}")
|
||||
head
|
||||
title
|
||||
meta(name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, user-scalable=yes, viewport-fit=cover")
|
||||
@@ -78,27 +78,32 @@ template(name="userFormsLayout")
|
||||
option(value="{{tag}}") {{name}} (RTL)
|
||||
else
|
||||
option(value="{{tag}}") {{name}}
|
||||
//- Extra space below the login / register dialog so its bottom scrolls
|
||||
//- fully into view and scrolling continues a little past the form.
|
||||
br
|
||||
br
|
||||
|
||||
template(name="defaultLayout")
|
||||
a.skip-link(href="#content") {{_ 'skip-to-content'}}
|
||||
+header
|
||||
#content
|
||||
#content(role="main" tabindex="-1")
|
||||
| {{{afterBodyStart}}}
|
||||
+Template.dynamic(template=content)
|
||||
| {{{beforeBodyEnd}}}
|
||||
+boardConversionProgress
|
||||
if (Modal.isOpen)
|
||||
#modal
|
||||
#modal(role="dialog" aria-modal="true" aria-label="{{_ 'modal-title'}}")
|
||||
.overlay
|
||||
if (Modal.isWide)
|
||||
.modal-content-wide.modal-container
|
||||
a.modal-close-btn.js-close-modal
|
||||
i.fa.fa-times-thin
|
||||
a.modal-close-btn.js-close-modal(aria-label="{{_ 'close-dialog'}}")
|
||||
i.fa.fa-times-thin(aria-hidden="true")
|
||||
+Template.dynamic(template=Modal.getHeaderName)
|
||||
+Template.dynamic(template=Modal.getTemplateName)
|
||||
else
|
||||
.modal-content.modal-container
|
||||
a.modal-close-btn.js-close-modal
|
||||
i.fa.fa-times-thin
|
||||
a.modal-close-btn.js-close-modal(aria-label="{{_ 'close-dialog'}}")
|
||||
i.fa.fa-times-thin(aria-hidden="true")
|
||||
+Template.dynamic(template=Modal.getHeaderName)
|
||||
+Template.dynamic(template=Modal.getTemplateName)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TAPi18n } from '/imports/i18n';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import Users from '/models/users';
|
||||
import { EscapeActions } from '/client/lib/escapeActions';
|
||||
import { enablePageDragscroll, disablePageDragscroll } from '/client/lib/pageDragscroll';
|
||||
|
||||
let alreadyCheck = 1;
|
||||
let isCheckDone = false;
|
||||
@@ -47,6 +48,10 @@ Template.userFormsLayout.onCreated(function () {
|
||||
});
|
||||
|
||||
Template.userFormsLayout.onRendered(() => {
|
||||
// Login / register pages scroll on <body>; enable drag-to-scroll there so the
|
||||
// gesture works the same as on the board swimlanes view.
|
||||
enablePageDragscroll();
|
||||
|
||||
Meteor.call('getAuthenticationsEnabled', (_, result) => {
|
||||
let enabledAuthenticationMethods = ['password']; // we show/hide this based on isPasswordLoginEnabled
|
||||
|
||||
@@ -57,9 +62,18 @@ Template.userFormsLayout.onRendered(() => {
|
||||
}
|
||||
|
||||
Meteor.call('isPasswordLoginEnabled', (_, result) => {
|
||||
if (result) {
|
||||
// Issue #6380: the password form (.at-pwd-form) is hidden by default in CSS
|
||||
// and only revealed here. If this method call errors / returns late, or the
|
||||
// accounts-templates form has not rendered its fields yet when this runs,
|
||||
// the login page is left without username and password fields. Treat
|
||||
// password login as enabled unless it is explicitly disabled
|
||||
// (result === false), and wait for the form element to appear before
|
||||
// showing it (same pattern as the OAuth button above).
|
||||
if (result === false) return;
|
||||
(function showPwdFormWhenReady() {
|
||||
if (!$('.at-pwd-form')[0]) return setTimeout(showPwdFormWhenReady, 100);
|
||||
$('.at-pwd-form').show();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
Meteor.call('isDisableRegistration', (_, result) => {
|
||||
@@ -182,6 +196,12 @@ Template.userFormsLayout.onRendered(() => {
|
||||
});
|
||||
});
|
||||
|
||||
Template.userFormsLayout.onDestroyed(() => {
|
||||
// Stop drag-scrolling <body> after leaving login / register (e.g. once logged
|
||||
// in) so other layouts keep their normal behaviour.
|
||||
disablePageDragscroll();
|
||||
});
|
||||
|
||||
Template.userFormsLayout.helpers({
|
||||
isLegalNoticeLinkExist() {
|
||||
const currSet = Template.instance().currentSetting.get();
|
||||
@@ -234,6 +254,16 @@ Template.userFormsLayout.helpers({
|
||||
},
|
||||
});
|
||||
|
||||
Template.main.helpers({
|
||||
htmlLang() {
|
||||
return TAPi18n.getLanguage() || 'en';
|
||||
},
|
||||
|
||||
htmlDir() {
|
||||
return TAPi18n.getLanguageDirection() || 'ltr';
|
||||
},
|
||||
});
|
||||
|
||||
Template.userFormsLayout.events({
|
||||
'change .js-userform-set-language'(event) {
|
||||
const tag = $(event.currentTarget).val();
|
||||
@@ -257,6 +287,30 @@ Template.defaultLayout.events({
|
||||
},
|
||||
});
|
||||
|
||||
// Accessibility (WCAG 2.4.3 Focus Order): when a modal dialog opens, move
|
||||
// keyboard focus into it so screen-reader and keyboard users land inside the
|
||||
// dialog instead of being left behind in the page. When it closes, restore
|
||||
// focus to whatever element was focused before it opened.
|
||||
Template.defaultLayout.onRendered(function () {
|
||||
let lastFocused = null;
|
||||
this.autorun(() => {
|
||||
const isOpen = Modal.isOpen();
|
||||
Tracker.afterFlush(() => {
|
||||
if (isOpen) {
|
||||
lastFocused = document.activeElement;
|
||||
const modal = document.getElementById('modal');
|
||||
if (modal) {
|
||||
const focusTarget = modal.querySelector('.modal-close-btn') || modal;
|
||||
focusTarget.focus();
|
||||
}
|
||||
} else if (lastFocused && typeof lastFocused.focus === 'function') {
|
||||
lastFocused.focus();
|
||||
lastFocused = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function authentication(event, templateInstance) {
|
||||
const match = $('#at-field-username_and_email').val();
|
||||
const password = $('#at-field-password').val();
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
border-radius: 0 0 0.5vw 0.5vw;
|
||||
min-width: min(400px, 52vw);
|
||||
margin-bottom: 2.5vh;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
margin-inline-end: auto;
|
||||
margin-inline-start: auto;
|
||||
border-width: 0.3vw;
|
||||
border-style: solid;
|
||||
border-color: #a2a2a2;
|
||||
@@ -46,8 +46,8 @@
|
||||
}
|
||||
.my-cards-dueat-list-wrapper {
|
||||
max-width: min(500px, 65vw);
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
margin-inline-end: auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
.my-cards-board-table thead {
|
||||
border-bottom: 3px solid #4d4d4d;
|
||||
@@ -62,13 +62,13 @@
|
||||
}
|
||||
.my-cards-card-title-table {
|
||||
font-weight: bold;
|
||||
padding-left: 2px;
|
||||
padding-inline-start: 2px;
|
||||
max-width: 243px;
|
||||
}
|
||||
.my-cards-board-badge {
|
||||
width: 36px;
|
||||
height: 24px;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
border-radius: 5px;
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
@@ -32,7 +32,7 @@ template(name="myCards")
|
||||
.wrapper
|
||||
each board in myCardsList
|
||||
.my-cards-board-wrapper
|
||||
.my-cards-board-title(class=board.colorClass, id="header")
|
||||
.my-cards-board-title(class=board.colorClass)
|
||||
a(href=board.originRelativeUrl)
|
||||
+viewer
|
||||
= board.title
|
||||
@@ -52,15 +52,17 @@ template(name="myCards")
|
||||
if $eq myCardsView 'table'
|
||||
.wrapper
|
||||
table.my-cards-board-table
|
||||
caption.sr-only {{_ 'my-cards'}}
|
||||
thead
|
||||
th {{_ "Card"}}
|
||||
th {{_ "List"}}
|
||||
th {{_ "Board"}}
|
||||
th {{_ "Swimlane"}}
|
||||
unless isMiniScreen
|
||||
th {{_ "Members"}}
|
||||
th {{_ "Labels"}}
|
||||
th {{_ "Due Date"}}
|
||||
tr
|
||||
th(scope="col") {{_ "Card"}}
|
||||
th(scope="col") {{_ "List"}}
|
||||
th(scope="col") {{_ "Board"}}
|
||||
th(scope="col") {{_ "Swimlane"}}
|
||||
unless isMiniScreen
|
||||
th(scope="col") {{_ "Members"}}
|
||||
th(scope="col") {{_ "Labels"}}
|
||||
th(scope="col") {{_ "Due Date"}}
|
||||
tbody
|
||||
each board in myCardsList
|
||||
each swimlane in board.mySwimlanes
|
||||
@@ -69,7 +71,7 @@ template(name="myCards")
|
||||
tr
|
||||
td
|
||||
unless isMiniScreen
|
||||
.my-cards-board-badge(class=board.colorClass, id="header")
|
||||
.my-cards-board-badge(class=board.colorClass)
|
||||
.my-cards-card-title-table
|
||||
| {{card.title}}
|
||||
//a.minicard-wrapper(href=card.originRelativeUrl)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CardSearchPaged } from '../../lib/cardSearch';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { enablePageDragscroll, disablePageDragscroll } from '/client/lib/pageDragscroll';
|
||||
|
||||
Template.myCardsHeaderBar.helpers({
|
||||
myCardsSort() {
|
||||
@@ -40,6 +41,16 @@ Template.myCards.onCreated(function () {
|
||||
Meteor.subscribe('setting');
|
||||
});
|
||||
|
||||
Template.myCards.onRendered(function () {
|
||||
// My Cards renders into <body>'s page scroll area (.wrapper inside #content),
|
||||
// so enable drag-to-scroll on <body> like the All Boards page does.
|
||||
enablePageDragscroll();
|
||||
});
|
||||
|
||||
Template.myCards.onDestroyed(function () {
|
||||
disablePageDragscroll();
|
||||
});
|
||||
|
||||
Template.myCards.helpers({
|
||||
userId() {
|
||||
return Meteor.userId();
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pop-over .header .back-btn {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
overflow: hidden;
|
||||
width: 4vw;
|
||||
transition: width 0.2s;
|
||||
@@ -66,7 +66,7 @@
|
||||
padding: 1.3vh 1.3vw 1.3vh 0.5vw;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
.pop-over.no-title .header {
|
||||
background: none;
|
||||
@@ -126,8 +126,8 @@
|
||||
.pop-over[data-popup='memberMenuPopup'] > .header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
margin-bottom: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -195,8 +195,8 @@
|
||||
position: fixed !important;
|
||||
bottom: 0 !important;
|
||||
top: auto !important;
|
||||
left: auto !important;
|
||||
right: 20px !important;
|
||||
inset-inline-start: auto !important;
|
||||
inset-inline-end: 20px !important;
|
||||
width: auto !important;
|
||||
max-width: 450px !important;
|
||||
height: 100vh !important;
|
||||
@@ -415,21 +415,21 @@
|
||||
padding: 1.3vh 1.3vw 1.3vh;
|
||||
box-sizing: border-box;
|
||||
/* Ensure content is not shifted left */
|
||||
margin-left: 0 !important;
|
||||
margin-inline-start: 0 !important;
|
||||
}
|
||||
|
||||
/* Utility: remove left gutter inside specific popups */
|
||||
.pop-over .content .flush-left {
|
||||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
margin-inline-start: 0;
|
||||
padding-inline-start: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Swimlane popups: remove left gutter, align content fully left */
|
||||
.pop-over .content form.swimlane-color-popup,
|
||||
.pop-over .content .swimlane-height-popup {
|
||||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
margin-inline-start: 0;
|
||||
padding-inline-start: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -437,15 +437,15 @@
|
||||
.pop-over .content form.swimlane-color-popup .palette-colors,
|
||||
.pop-over .content form.edit-label .palette-colors,
|
||||
.pop-over .content form.create-label .palette-colors {
|
||||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
margin-inline-start: 0;
|
||||
padding-inline-start: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Color palette items: ensure proper positioning */
|
||||
.pop-over .content .palette-colors .palette-color {
|
||||
margin-left: 0;
|
||||
margin-right: 2px;
|
||||
margin-inline-start: 0;
|
||||
margin-inline-end: 2px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
@@ -456,8 +456,8 @@
|
||||
.pop-over .popup-container-depth-4,
|
||||
.pop-over .popup-container-depth-5,
|
||||
.pop-over .popup-container-depth-6 {
|
||||
margin-left: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
margin-inline-start: 0 !important;
|
||||
padding-inline-start: 0 !important;
|
||||
}
|
||||
|
||||
/* Ensure buttons don’t reserve left space; align to flow */
|
||||
@@ -466,7 +466,7 @@
|
||||
.pop-over .content .swimlane-height-popup .primary.confirm,
|
||||
.pop-over .content .swimlane-height-popup .negate.wide.right {
|
||||
float: none;
|
||||
margin-left: 0;
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
.pop-over .content-container .content.no-height {
|
||||
height: 0;
|
||||
@@ -484,7 +484,7 @@
|
||||
}
|
||||
.pop-over.search-over .content {
|
||||
padding: 8px 4px 8px 10px;
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
.pop-over .at-form .at-error,
|
||||
.pop-over .at-form .at-result {
|
||||
@@ -550,7 +550,7 @@
|
||||
.pop-over-list li > a .item-name {
|
||||
display: block;
|
||||
width: auto;
|
||||
padding-right: 22px;
|
||||
padding-inline-end: 22px;
|
||||
}
|
||||
.pop-over-list li > a:not(.disabled):hover {
|
||||
background-color: #005377;
|
||||
@@ -601,11 +601,11 @@
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 12px;
|
||||
inset-inline-end: 12px;
|
||||
color: #3cb500;
|
||||
}
|
||||
.pop-over-list .pop-over-list.checkable li.active a {
|
||||
padding-right: 28px;
|
||||
padding-inline-end: 28px;
|
||||
}
|
||||
.pop-over-list .pop-over-list.checkable li.active a .fa-check {
|
||||
display: block;
|
||||
@@ -618,7 +618,7 @@ body.grey-icons-enabled .pop-over-list .pop-over-list.checkable .fa-check {
|
||||
border-bottom-color: transparent;
|
||||
height: 30px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
top: 0;
|
||||
width: 60px;
|
||||
z-index: 1;
|
||||
@@ -638,7 +638,7 @@ body.grey-icons-enabled .pop-over-list .pop-over-list.checkable .fa-check {
|
||||
.pop-over.miniprofile .miniprofile-header .avatar {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
inset-inline-start: 2px;
|
||||
height: 50px;
|
||||
width: 50px;
|
||||
}
|
||||
@@ -752,8 +752,8 @@ body.grey-icons-enabled .pop-over-list .pop-over-list.checkable .fa-check {
|
||||
body.mobile-mode .pop-over {
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
bottom: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
class="{{#unless title}}no-title{{/unless}}"
|
||||
class="{{#if offset.maxHeight}}pop-over--bounded{{/if}}"
|
||||
data-popup="{{popupName}}"
|
||||
role="dialog"
|
||||
aria-label="{{#if title}}{{title}}{{else}}{{_ 'modal-title'}}{{/if}}"
|
||||
style="left:{{offset.left}}px; top:{{offset.top}}px;{{#if offset.maxHeight}} max-height:{{offset.maxHeight}}px;{{/if}}")
|
||||
.header
|
||||
a.back-btn.js-back-view(class="{{#unless hasPopupParent}}is-hidden{{/unless}}")
|
||||
i.fa.fa-caret-left
|
||||
a.back-btn.js-back-view(class="{{#unless hasPopupParent}}is-hidden{{/unless}}" aria-label="{{_ 'go-back'}}")
|
||||
i.fa.fa-caret-left(aria-hidden="true")
|
||||
span.header-title= title
|
||||
a.close-btn.js-close-pop-over
|
||||
i.fa.fa-times-thin
|
||||
a.close-btn.js-close-pop-over(aria-label="{{_ 'close-popup'}}")
|
||||
i.fa.fa-times-thin(aria-hidden="true")
|
||||
.content-wrapper
|
||||
//-
|
||||
We display the all stack of popup content next to each other and move
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
height: 15px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
-webkit-animation: sk-cubemove 1.8s infinite ease-in-out;
|
||||
animation: sk-cubemove 1.8s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
width: 33%;
|
||||
height: 33%;
|
||||
background-color: #333;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
-webkit-animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out;
|
||||
animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
opacity: 0.6;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
-webkit-animation: sk-bounce 2s infinite ease-in-out;
|
||||
animation: sk-bounce 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
section#notifications-drawer {
|
||||
position: fixed;
|
||||
top: 48px;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
width: 400px;
|
||||
background-color: #fafafa;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.15);
|
||||
@@ -16,7 +16,7 @@ section#notifications-drawer a:hover {
|
||||
section#notifications-drawer .header {
|
||||
position: fixed;
|
||||
top: 48px;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
width: calc(400px - 32px);
|
||||
padding: 8px 16px;
|
||||
background: #ededed;
|
||||
@@ -25,7 +25,7 @@ section#notifications-drawer .header {
|
||||
}
|
||||
section#notifications-drawer .header .notification-menu-toggle {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
inset-inline-start: 16px;
|
||||
top: calc(50% - 12px);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
@@ -37,7 +37,7 @@ section#notifications-drawer .header .notification-menu-toggle:hover {
|
||||
}
|
||||
section#notifications-drawer .header .notification-menu {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
inset-inline-start: 16px;
|
||||
top: 44px;
|
||||
background: white;
|
||||
border: 1px solid #dbdbdb;
|
||||
@@ -74,13 +74,13 @@ section#notifications-drawer .header .notification-menu .menu-item.selected {
|
||||
section#notifications-drawer .header .notification-menu .menu-item .check-icon {
|
||||
width: 20px;
|
||||
min-width: 20px;
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
text-align: center;
|
||||
color: #2980b9;
|
||||
font-weight: bold;
|
||||
}
|
||||
section#notifications-drawer .header .notification-menu .menu-item .menu-icon {
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
section#notifications-drawer .header h5 {
|
||||
@@ -90,7 +90,7 @@ section#notifications-drawer .header h5 {
|
||||
section#notifications-drawer .header .close {
|
||||
position: absolute;
|
||||
top: calc(50% - 12px);
|
||||
right: 12px;
|
||||
inset-inline-end: 12px;
|
||||
font-size: 24px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
|
||||
@@ -103,6 +103,36 @@ template(name="boardActions")
|
||||
div.trigger-button.js-link-card-action.js-goto-rules
|
||||
i.fa.fa-plus
|
||||
|
||||
div.trigger-item
|
||||
div.trigger-content
|
||||
div.trigger-text
|
||||
| {{_'r-sort-list'}}
|
||||
div.trigger-dropdown
|
||||
input(id="sort-list-name",type=text,placeholder="{{_'r-list-name'}}")
|
||||
div.trigger-text
|
||||
| {{_'r-sort-by'}}
|
||||
div.trigger-dropdown
|
||||
select(id="sort-field")
|
||||
option(value="due") {{_'r-sort-due'}}
|
||||
option(value="name") {{_'r-sort-name'}}
|
||||
option(value="created") {{_'createdAt'}}
|
||||
option(value="modified") {{_'modifiedAt'}}
|
||||
div.trigger-button.js-sort-list-action.js-goto-rules
|
||||
i.fa.fa-plus
|
||||
|
||||
div.trigger-item
|
||||
div.trigger-content
|
||||
div.trigger-text
|
||||
| {{_'r-move-all-cards'}}
|
||||
div.trigger-dropdown
|
||||
input(id="moveall-from-list",type=text,placeholder="{{_'r-list-name'}}")
|
||||
div.trigger-text
|
||||
| {{_'r-in-list'}}
|
||||
div.trigger-dropdown
|
||||
input(id="moveall-to-list",type=text,placeholder="{{_'r-list-name'}}")
|
||||
div.trigger-button.js-move-all-cards-action.js-goto-rules
|
||||
i.fa.fa-plus
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -134,36 +134,17 @@ Template.boardActions.events({
|
||||
const ruleName = data.ruleName.get();
|
||||
const trigger = data.triggerVar.get();
|
||||
const actionSelected = tpl.find('#move-gen-action').value;
|
||||
if (actionSelected === 'top') {
|
||||
const triggerId = Triggers.insert(trigger);
|
||||
const actionId = Actions.insert({
|
||||
actionType: 'moveCardToTop',
|
||||
listTitle: '*',
|
||||
boardId,
|
||||
desc,
|
||||
});
|
||||
Rules.insert({
|
||||
title: ruleName,
|
||||
triggerId,
|
||||
actionId,
|
||||
boardId,
|
||||
});
|
||||
}
|
||||
if (actionSelected === 'bottom') {
|
||||
const triggerId = Triggers.insert(trigger);
|
||||
const actionId = Actions.insert({
|
||||
actionType: 'moveCardToBottom',
|
||||
listTitle: '*',
|
||||
boardId,
|
||||
desc,
|
||||
});
|
||||
Rules.insert({
|
||||
title: ruleName,
|
||||
triggerId,
|
||||
actionId,
|
||||
boardId,
|
||||
});
|
||||
}
|
||||
const actionType =
|
||||
actionSelected === 'bottom' ? 'moveCardToBottom' : 'moveCardToTop';
|
||||
// Insert the rule on the server (see server/rulesButton.js → rules.createRule)
|
||||
// rather than via three optimistic client Collection.insert() calls, whose
|
||||
// documents land in minimongo limbo when the wizard subscription stops.
|
||||
Meteor.call('rules.createRule', boardId, ruleName, trigger, {
|
||||
actionType,
|
||||
listTitle: '*',
|
||||
boardId,
|
||||
desc,
|
||||
});
|
||||
},
|
||||
'click .js-add-arch-action'(event, tpl) {
|
||||
const desc = Utils.getTriggerActionDesc(event, tpl);
|
||||
@@ -225,5 +206,41 @@ Template.boardActions.events({
|
||||
boardId,
|
||||
});
|
||||
},
|
||||
'click .js-sort-list-action'(event, tpl) {
|
||||
const data = Template.currentData();
|
||||
const ruleName = data.ruleName.get();
|
||||
const trigger = data.triggerVar.get();
|
||||
const boardId = Session.get('currentBoard');
|
||||
const desc = Utils.getTriggerActionDesc(event, tpl);
|
||||
const listName = tpl.find('#sort-list-name').value || '*';
|
||||
const sortField = tpl.find('#sort-field').value;
|
||||
const triggerId = Triggers.insert(trigger);
|
||||
const actionId = Actions.insert({
|
||||
actionType: 'sortList',
|
||||
listName,
|
||||
sortField,
|
||||
boardId,
|
||||
desc,
|
||||
});
|
||||
Rules.insert({ title: ruleName, triggerId, actionId, boardId });
|
||||
},
|
||||
'click .js-move-all-cards-action'(event, tpl) {
|
||||
const data = Template.currentData();
|
||||
const ruleName = data.ruleName.get();
|
||||
const trigger = data.triggerVar.get();
|
||||
const boardId = Session.get('currentBoard');
|
||||
const desc = Utils.getTriggerActionDesc(event, tpl);
|
||||
const fromListName = tpl.find('#moveall-from-list').value || '*';
|
||||
const listName = tpl.find('#moveall-to-list').value || '*';
|
||||
const triggerId = Triggers.insert(trigger);
|
||||
const actionId = Actions.insert({
|
||||
actionType: 'moveAllCardsInList',
|
||||
fromListName,
|
||||
listName,
|
||||
boardId,
|
||||
desc,
|
||||
});
|
||||
Rules.insert({ title: ruleName, triggerId, actionId, boardId });
|
||||
},
|
||||
});
|
||||
/* eslint-no-undef */
|
||||
@@ -79,6 +79,32 @@ template(name="cardActions")
|
||||
div.trigger-button.js-set-color-action.js-goto-rules
|
||||
i.fa.fa-plus
|
||||
|
||||
div.trigger-item
|
||||
div.trigger-content
|
||||
div.trigger-dropdown
|
||||
select(id="complete-action")
|
||||
option(value="markCardComplete") {{_'r-mark-complete'}}
|
||||
option(value="markCardIncomplete") {{_'r-mark-incomplete'}}
|
||||
div.trigger-button.js-set-complete-action.js-goto-rules
|
||||
i.fa.fa-plus
|
||||
|
||||
div.trigger-item
|
||||
div.trigger-content
|
||||
div.trigger-text
|
||||
| {{_'r-set-date-relative'}}
|
||||
div.trigger-dropdown
|
||||
select(id="reldate-datefield")
|
||||
option(value="startAt") {{_'r-df-start-at'}}
|
||||
option(value="dueAt") {{_'r-df-due-at'}}
|
||||
option(value="endAt") {{_'r-df-end-at'}}
|
||||
option(value="receivedAt") {{_'r-df-received-at'}}
|
||||
div.trigger-dropdown
|
||||
input(id="reldate-days",type="number",value="0")
|
||||
div.trigger-text
|
||||
| {{_'r-days-after'}}
|
||||
div.trigger-button.js-set-reldate-action.js-goto-rules
|
||||
i.fa.fa-plus
|
||||
|
||||
template(name="setCardActionsColorPopup")
|
||||
form.edit-label
|
||||
.palette-colors: each colors
|
||||
|
||||
@@ -220,6 +220,35 @@ Template.cardActions.events({
|
||||
boardId,
|
||||
});
|
||||
},
|
||||
'click .js-set-complete-action'(event, tpl) {
|
||||
const data = Template.currentData();
|
||||
const ruleName = data.ruleName.get();
|
||||
const trigger = data.triggerVar.get();
|
||||
const boardId = Session.get('currentBoard');
|
||||
const desc = Utils.getTriggerActionDesc(event, tpl);
|
||||
const actionType = tpl.find('#complete-action').value;
|
||||
const triggerId = Triggers.insert(trigger);
|
||||
const actionId = Actions.insert({ actionType, boardId, desc });
|
||||
Rules.insert({ title: ruleName, triggerId, actionId, boardId });
|
||||
},
|
||||
'click .js-set-reldate-action'(event, tpl) {
|
||||
const data = Template.currentData();
|
||||
const ruleName = data.ruleName.get();
|
||||
const trigger = data.triggerVar.get();
|
||||
const boardId = Session.get('currentBoard');
|
||||
const desc = Utils.getTriggerActionDesc(event, tpl);
|
||||
const dateField = tpl.find('#reldate-datefield').value;
|
||||
const days = parseInt(tpl.find('#reldate-days').value, 10) || 0;
|
||||
const triggerId = Triggers.insert(trigger);
|
||||
const actionId = Actions.insert({
|
||||
actionType: 'setDateRelative',
|
||||
dateField,
|
||||
days,
|
||||
boardId,
|
||||
desc,
|
||||
});
|
||||
Rules.insert({ title: ruleName, triggerId, actionId, boardId });
|
||||
},
|
||||
});
|
||||
|
||||
Template.setCardActionsColorPopup.onCreated(function () {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
template(name="boardButtons")
|
||||
each boardButtonRules
|
||||
a.board-header-btn.js-run-board-button(data-rule-id="{{ruleId}}" title="{{label}}")
|
||||
i.fa.fa-magic
|
||||
span= label
|
||||
@@ -0,0 +1,39 @@
|
||||
import Rules from '/models/rules';
|
||||
|
||||
// Renders the board's "board button" rules in the board header and runs one
|
||||
// (board-level, no card) when clicked. The button metadata (type + label) is
|
||||
// denormalised onto the rule document (see server/rulesButton.js), so the board
|
||||
// view reads it from the published `rules` collection alone — the schemaless
|
||||
// `triggers` collection's documents do not reach the client over the board
|
||||
// subscription in this Meteor 3 setup.
|
||||
Template.boardButtons.onCreated(function () {
|
||||
this.autorun(() => {
|
||||
const boardId = Session.get('currentBoard');
|
||||
if (boardId) this.subscribe('boardRules', boardId);
|
||||
});
|
||||
});
|
||||
|
||||
Template.boardButtons.helpers({
|
||||
boardButtonRules() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
if (!boardId) return [];
|
||||
// Read Minimongo directly (not the memoizing ReactiveCache, which can latch a
|
||||
// transient empty result during subscription churn). A stable `_id` lets
|
||||
// Blaze reuse the node across re-renders.
|
||||
return Rules.find({ boardId, buttonType: 'board' })
|
||||
.fetch()
|
||||
.map(rule => ({
|
||||
_id: rule._id,
|
||||
ruleId: rule._id,
|
||||
label: rule.buttonLabel || rule.title,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardButtons.events({
|
||||
'click .js-run-board-button'(event) {
|
||||
event.preventDefault();
|
||||
const ruleId = event.currentTarget.getAttribute('data-rule-id');
|
||||
Meteor.call('rules.runButton', ruleId);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
template(name="cardButtons")
|
||||
if hasCardButtons
|
||||
.card-details-item.card-details-item-buttons
|
||||
h3.card-details-item-title
|
||||
i.fa.fa-hand-pointer-o
|
||||
| {{_ 'r-card-button'}}
|
||||
.card-details-item-content.card-buttons
|
||||
each cardButtonRules
|
||||
button.primary.js-run-card-button(data-rule-id="{{ruleId}}")
|
||||
i.fa.fa-magic
|
||||
= ' '
|
||||
= label
|
||||
@@ -0,0 +1,43 @@
|
||||
import Rules from '/models/rules';
|
||||
|
||||
// Lists the board's "card button" rules and runs one on the current card when
|
||||
// clicked. Expects the card data context (cardId + boardId) inherited from the
|
||||
// card detail template. The button metadata is denormalised onto the rule (see
|
||||
// server/rulesButton.js), so this reads from the published `rules` collection
|
||||
// alone — the schemaless `triggers` collection does not reach the client over
|
||||
// the board subscription in this Meteor 3 setup.
|
||||
Template.cardButtons.onCreated(function () {
|
||||
this.autorun(() => {
|
||||
const boardId = this.data && this.data.boardId;
|
||||
if (boardId) this.subscribe('boardRules', boardId);
|
||||
});
|
||||
});
|
||||
|
||||
function buttonRulesForBoard(boardId) {
|
||||
if (!boardId) return [];
|
||||
return Rules.find({ boardId, buttonType: 'card' })
|
||||
.fetch()
|
||||
.map(rule => ({
|
||||
_id: rule._id,
|
||||
ruleId: rule._id,
|
||||
label: rule.buttonLabel || rule.title,
|
||||
}));
|
||||
}
|
||||
|
||||
Template.cardButtons.helpers({
|
||||
hasCardButtons() {
|
||||
return buttonRulesForBoard(this.boardId).length > 0;
|
||||
},
|
||||
cardButtonRules() {
|
||||
return buttonRulesForBoard(this.boardId);
|
||||
},
|
||||
});
|
||||
|
||||
Template.cardButtons.events({
|
||||
'click .js-run-card-button'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const ruleId = event.currentTarget.getAttribute('data-rule-id');
|
||||
const cardId = tpl.data && tpl.data._id;
|
||||
Meteor.call('rules.runButton', ruleId, cardId);
|
||||
},
|
||||
});
|
||||
@@ -1,8 +1,170 @@
|
||||
/* Rules is now a fullscreen board-scoped page (below the single top bar),
|
||||
* no longer a wide modal. .rules-page centers the content and gives it room. */
|
||||
.rules-page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 1vh 1vw 4vh;
|
||||
}
|
||||
.rules-page h2 {
|
||||
margin-bottom: 1vh;
|
||||
}
|
||||
.rules-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 1vh;
|
||||
}
|
||||
.rules-toolbar button {
|
||||
cursor: pointer;
|
||||
}
|
||||
.rules-list {
|
||||
overflow: hidden;
|
||||
overflow-y: scroll;
|
||||
max-height: 50vh;
|
||||
padding-right: 0.7vw;
|
||||
overflow-y: auto;
|
||||
max-height: 65vh;
|
||||
padding-inline-end: 0.7vw;
|
||||
}
|
||||
.rules-lists-item .js-rule-select {
|
||||
float: inline-start;
|
||||
margin: 6px 8px 0 0;
|
||||
}
|
||||
.rules-lists-item .js-edit-rule-input {
|
||||
width: 60%;
|
||||
}
|
||||
/* Visual workflow view */
|
||||
.rules-workflow .workflow-group {
|
||||
margin-bottom: 2vh;
|
||||
}
|
||||
.rules-workflow .workflow-group-title {
|
||||
border-bottom: 2px solid #cfcfcf;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 1vh;
|
||||
}
|
||||
.rules-workflow .workflow-rule {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.rules-workflow .workflow-node {
|
||||
flex: 1 1 200px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.rules-workflow .workflow-node-when {
|
||||
border-inline-start: 4px solid #1565c0;
|
||||
}
|
||||
.rules-workflow .workflow-node-then {
|
||||
border-inline-start: 4px solid #2e7d32;
|
||||
}
|
||||
.rules-workflow .workflow-node-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
color: #888;
|
||||
}
|
||||
.rules-workflow .workflow-node-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
.rules-workflow .workflow-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 20px;
|
||||
color: #888;
|
||||
}
|
||||
/* Drag-and-drop workflow editor */
|
||||
.rules-workflow .workflow-help {
|
||||
color: #727479;
|
||||
font-size: 13px;
|
||||
margin-bottom: 1vh;
|
||||
}
|
||||
.rules-workflow .workflow-builder {
|
||||
background: #f7f7f7;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 1.5vh;
|
||||
}
|
||||
.rules-workflow .workflow-builder-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.rules-workflow .workflow-slot {
|
||||
flex: 1 1 220px;
|
||||
min-height: 52px;
|
||||
border: 2px dashed #b0b0b0;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
background: #fff;
|
||||
}
|
||||
.rules-workflow .workflow-slot-placeholder {
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
.rules-workflow .workflow-slot-clear {
|
||||
margin-inline-start: auto;
|
||||
color: #b00;
|
||||
cursor: pointer;
|
||||
padding-inline-start: 8px;
|
||||
}
|
||||
.rules-workflow .workflow-builder-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.rules-workflow .workflow-builder-controls input {
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
.rules-workflow .workflow-palettes {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1.5vh;
|
||||
}
|
||||
.rules-workflow .workflow-palette {
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
.rules-workflow .workflow-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: #fff;
|
||||
border: 1px solid #cfcfcf;
|
||||
border-radius: 16px;
|
||||
padding: 6px 12px;
|
||||
margin: 0 6px 6px 0;
|
||||
cursor: grab;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.rules-workflow .workflow-chip:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.rules-workflow .js-trigger-chip {
|
||||
border-inline-start: 4px solid #1565c0;
|
||||
}
|
||||
.rules-workflow .js-action-chip {
|
||||
border-inline-start: 4px solid #2e7d32;
|
||||
}
|
||||
.rules-workflow .workflow-existing-title {
|
||||
margin-top: 1vh;
|
||||
}
|
||||
.rules-workflow .workflow-rule {
|
||||
position: relative;
|
||||
}
|
||||
.rules-workflow .workflow-rule-delete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #b00;
|
||||
padding-inline-start: 8px;
|
||||
}
|
||||
.rules-lists-item {
|
||||
display: block;
|
||||
@@ -12,7 +174,7 @@
|
||||
}
|
||||
.rules-lists-item p {
|
||||
display: inline-block;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
margin: revert;
|
||||
}
|
||||
.hide-element {
|
||||
@@ -23,7 +185,7 @@
|
||||
}
|
||||
.rules-btns-group {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
@@ -38,13 +200,13 @@
|
||||
}
|
||||
.rules-add input {
|
||||
display: inline-block;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
margin: auto;
|
||||
margin-right: 1.3vw;
|
||||
margin-inline-end: 1.3vw;
|
||||
}
|
||||
.rules-add button {
|
||||
display: inline-block;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
margin: auto;
|
||||
}
|
||||
.rules-back {
|
||||
@@ -55,9 +217,9 @@
|
||||
}
|
||||
.rules-back button {
|
||||
display: inline-block;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
margin: auto;
|
||||
margin-right: 14px;
|
||||
margin-inline-end: 14px;
|
||||
}
|
||||
.rules-back .js-goback .fa-arrow-left {
|
||||
color: #fff !important;
|
||||
@@ -134,7 +296,7 @@
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
left: 10px;
|
||||
inset-inline-start: 10px;
|
||||
}
|
||||
.triggers-content .triggers-body .triggers-main-body .trigger-item .trigger-content .trigger-text {
|
||||
font-size: 16px;
|
||||
@@ -153,7 +315,7 @@
|
||||
box-shadow: 0 0 0 2px #666 inset;
|
||||
}
|
||||
.triggers-content .triggers-body .triggers-main-body .trigger-item .trigger-content .trigger-text.trigger-text-email {
|
||||
margin-left: 5px;
|
||||
margin-inline-start: 5px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
@@ -164,7 +326,7 @@
|
||||
width: auto;
|
||||
height: 30px;
|
||||
margin: 0px;
|
||||
margin-left: 5px;
|
||||
margin-inline-start: 5px;
|
||||
}
|
||||
.triggers-content .triggers-body .triggers-main-body .trigger-item .trigger-content .trigger-dropdown input {
|
||||
display: inline-block;
|
||||
@@ -172,8 +334,8 @@
|
||||
margin: 0;
|
||||
}
|
||||
.triggers-content .triggers-body .triggers-main-body .trigger-item .trigger-content-mail {
|
||||
left: 20px;
|
||||
right: 100px;
|
||||
inset-inline-start: 20px;
|
||||
inset-inline-end: 100px;
|
||||
}
|
||||
.triggers-content .triggers-body .triggers-main-body .trigger-item .trigger-button {
|
||||
position: absolute;
|
||||
@@ -186,7 +348,7 @@
|
||||
box-shadow: inset -1px -1px 3px rgba(0,0,0,0.05);
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
right: 10px;
|
||||
inset-inline-end: 10px;
|
||||
}
|
||||
.triggers-content .triggers-body .triggers-main-body .trigger-item .trigger-button i {
|
||||
position: absolute;
|
||||
@@ -203,7 +365,7 @@
|
||||
top: 30px;
|
||||
}
|
||||
.triggers-content .triggers-body .triggers-main-body .trigger-item .trigger-button.trigger-button-person {
|
||||
right: -40px;
|
||||
inset-inline-end: -40px;
|
||||
}
|
||||
.triggers-content .triggers-body .triggers-main-body .trigger-item .trigger-button.trigger-button-color {
|
||||
top: unset;
|
||||
@@ -211,10 +373,39 @@
|
||||
transform: unset;
|
||||
font-size: 16px;
|
||||
width: auto;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
padding-inline-start: 10px;
|
||||
padding-inline-end: 10px;
|
||||
height: 40px;
|
||||
}
|
||||
.triggers-content .triggers-body .triggers-main-body .trigger-item.trigger-item-mail {
|
||||
height: 300px;
|
||||
}
|
||||
/* Rules import/export popup */
|
||||
.rules-import-export h3 {
|
||||
margin: 0.6em 0 0.3em;
|
||||
font-size: 15px;
|
||||
}
|
||||
.rules-import-export .rules-ie-hint {
|
||||
font-size: 12px;
|
||||
color: #727479;
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
.rules-import-export .rules-ie-textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.rules-import-export .rules-ie-btns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.rules-import-export .rules-ie-btns button {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.rules-import-export .rules-ie-message {
|
||||
margin-top: 0.6em;
|
||||
padding: 6px 8px;
|
||||
background: #e8f0fe;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
template(name="rulesImportExportPopup")
|
||||
.rules-import-export
|
||||
h3 {{_ 'r-export'}}
|
||||
.rules-ie-btns
|
||||
button.primary.full.js-rules-export-json(type="button") {{_ 'r-export-json'}}
|
||||
button.primary.full.js-rules-export-csv(type="button") {{_ 'r-export-csv'}}
|
||||
hr
|
||||
h3 {{_ 'r-import'}}
|
||||
.rules-ie-target
|
||||
label {{_ 'r-import-target'}}
|
||||
label.rules-ie-target-row
|
||||
span {{_ 'r-workspace'}}
|
||||
select.js-import-workspace
|
||||
option(value="") {{_ 'r-all-boards'}}
|
||||
each workspaces
|
||||
option(value="{{id}}")= label
|
||||
label.rules-ie-target-row
|
||||
span {{_ 'r-board'}}
|
||||
select.js-import-board
|
||||
each boardsForImport
|
||||
if selected
|
||||
option(value="{{_id}}" selected="selected")= title
|
||||
else
|
||||
option(value="{{_id}}")= title
|
||||
p.rules-ie-hint {{_ 'r-import-paste'}}
|
||||
textarea.rules-ie-textarea.js-rules-import-text(rows="8")
|
||||
.rules-ie-btns
|
||||
button.primary.full.js-rules-import-json(type="button") {{_ 'r-import-json'}}
|
||||
button.primary.full.js-rules-import-csv(type="button") {{_ 'r-import-csv'}}
|
||||
hr
|
||||
h3 {{_ 'r-import-workflow'}}
|
||||
p.rules-ie-hint {{_ 'r-import-workflow-note'}}
|
||||
.rules-ie-btns
|
||||
select.js-workflow-format
|
||||
option(value="auto") {{_ 'r-format-auto'}}
|
||||
option(value="n8n") n8n
|
||||
option(value="nodered") Node-RED
|
||||
button.primary.full.js-rules-import-workflow(type="button") {{_ 'r-import-workflow'}}
|
||||
hr
|
||||
h3 {{_ 'r-import-trello'}}
|
||||
p.rules-ie-hint {{_ 'r-import-trello-note'}}
|
||||
.rules-ie-btns
|
||||
button.primary.full.js-rules-import-trello(type="button") {{_ 'r-import-trello'}}
|
||||
if message.get
|
||||
p.rules-ie-message= message.get
|
||||
@@ -0,0 +1,302 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import Papa from 'papaparse';
|
||||
import Actions from '/models/actions';
|
||||
import Rules from '/models/rules';
|
||||
import Triggers from '/models/triggers';
|
||||
|
||||
const RULES_FORMAT = 'wekan-rules-1.0.0';
|
||||
const STRIP_FIELDS = ['_id', 'boardId', 'createdAt', 'modifiedAt', 'updatedAt'];
|
||||
|
||||
function stripDoc(doc) {
|
||||
const out = {};
|
||||
Object.keys(doc || {}).forEach(key => {
|
||||
if (!STRIP_FIELDS.includes(key)) out[key] = doc[key];
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// Build a portable, board-independent list of rules from the CURRENT board.
|
||||
function collectBoardRules(boardId) {
|
||||
let rules = ReactiveCache.getRules({ boardId });
|
||||
const selected = Session.get('selectedRuleIds') || [];
|
||||
if (selected.length) {
|
||||
rules = rules.filter(r => selected.includes(r._id));
|
||||
}
|
||||
return rules
|
||||
.map(rule => {
|
||||
const trigger = ReactiveCache.getTrigger(rule.triggerId);
|
||||
const action = ReactiveCache.getAction(rule.actionId);
|
||||
if (!trigger || !action) return null;
|
||||
return { title: rule.title, trigger: stripDoc(trigger), action: stripDoc(action) };
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// Insert an array of {title, trigger, action} onto the given target board.
|
||||
function importRules(rulesArray, boardId) {
|
||||
let count = 0;
|
||||
(rulesArray || []).forEach(entry => {
|
||||
if (!entry || !entry.trigger || !entry.action) return;
|
||||
const triggerId = Triggers.insert({ ...stripDoc(entry.trigger), boardId });
|
||||
const actionId = Actions.insert({ ...stripDoc(entry.action), boardId });
|
||||
Rules.insert({ title: entry.title || 'Imported rule', triggerId, actionId, boardId });
|
||||
count += 1;
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
function download(filename, text, mime) {
|
||||
const blob = new Blob([text], { type: `${mime};charset=utf-8` });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// --- CSV (round-trippable) --------------------------------------------------
|
||||
function rulesToCsv(rulesArray) {
|
||||
const rows = rulesArray.map(entry => {
|
||||
const { activityType, ...triggerFields } = entry.trigger || {};
|
||||
const { actionType, ...actionFields } = entry.action || {};
|
||||
return {
|
||||
title: entry.title || '',
|
||||
triggerType: activityType || '',
|
||||
triggerFields: JSON.stringify(triggerFields),
|
||||
actionType: actionType || '',
|
||||
actionFields: JSON.stringify(actionFields),
|
||||
};
|
||||
});
|
||||
return Papa.unparse(rows, {
|
||||
columns: ['title', 'triggerType', 'triggerFields', 'actionType', 'actionFields'],
|
||||
});
|
||||
}
|
||||
|
||||
function csvToRules(text) {
|
||||
const parsed = Papa.parse(text.trim(), { header: true, skipEmptyLines: true });
|
||||
return (parsed.data || [])
|
||||
.map(row => {
|
||||
let triggerFields = {};
|
||||
let actionFields = {};
|
||||
try { triggerFields = row.triggerFields ? JSON.parse(row.triggerFields) : {}; } catch (e) { triggerFields = {}; }
|
||||
try { actionFields = row.actionFields ? JSON.parse(row.actionFields) : {}; } catch (e) { actionFields = {}; }
|
||||
if (!row.triggerType || !row.actionType) return null;
|
||||
return {
|
||||
title: row.title,
|
||||
trigger: { activityType: row.triggerType, ...triggerFields },
|
||||
action: { actionType: row.actionType, ...actionFields },
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// --- Best-effort Trello Butler parser ---------------------------------------
|
||||
export function parseTrelloButler(text) {
|
||||
const rules = [];
|
||||
const unmapped = [];
|
||||
(text || '').split('\n').map(l => l.trim()).filter(Boolean).forEach(line => {
|
||||
const added = line.toLowerCase().match(/when a card is added to list ["“](.+?)["”].*move the card to the (top|bottom)/);
|
||||
if (added) {
|
||||
rules.push({
|
||||
title: line,
|
||||
trigger: { activityType: 'createCard', listName: added[1], swimlaneName: '*', cardTitle: '*', userId: '*' },
|
||||
action: { actionType: added[2] === 'top' ? 'moveCardToTop' : 'moveCardToBottom', listName: '*', swimlaneName: '*' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
unmapped.push(line);
|
||||
});
|
||||
return { rules, unmapped };
|
||||
}
|
||||
|
||||
// --- Best-effort visual-workflow parsers (n8n, Node-RED) --------------------
|
||||
// These map a workflow graph's trigger→action edges to WeKan rules by keyword.
|
||||
// n8n and Node-RED nodes are arbitrary integrations, so only recognized
|
||||
// trigger/action node types are mapped; unmapped edges are reported.
|
||||
function mapTriggerType(type = '', name = '') {
|
||||
const s = `${type} ${name}`.toLowerCase();
|
||||
if (/schedule|cron|interval|inject/.test(s)) {
|
||||
return { activityType: 'scheduledTrigger', scheduleKind: 'calendar', scheduleType: 'daily', atTime: '09:00', listName: '*', swimlaneName: '*' };
|
||||
}
|
||||
if (/trigger|webhook|http in|http-in|start/.test(s)) {
|
||||
return { activityType: 'createCard', listName: '*', swimlaneName: '*', cardTitle: '*', userId: '*' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mapActionType(type = '', name = '') {
|
||||
const s = `${type} ${name}`.toLowerCase();
|
||||
if (/archive/.test(s)) return { actionType: 'archive' };
|
||||
if (/move.*top|to top/.test(s)) return { actionType: 'moveCardToTop', listName: '*', swimlaneName: '*' };
|
||||
if (/move.*bottom/.test(s)) return { actionType: 'moveCardToBottom', listName: '*', swimlaneName: '*' };
|
||||
if (/complete|done/.test(s)) return { actionType: 'markCardComplete' };
|
||||
if (/email|mail|smtp|gmail/.test(s)) return { actionType: 'sendEmail', emailTo: '', emailSubject: 'Imported workflow', emailMsg: '' };
|
||||
if (/create.*card|wekan|card/.test(s)) return { actionType: 'createCard', cardName: name || 'Imported card', listName: '*', swimlaneName: '*' };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseN8n(data) {
|
||||
const nodes = data.nodes || [];
|
||||
const byName = {};
|
||||
nodes.forEach(n => { byName[n.name] = n; });
|
||||
const rules = [];
|
||||
const unmapped = [];
|
||||
const conns = data.connections || {};
|
||||
Object.keys(conns).forEach(srcName => {
|
||||
const src = byName[srcName];
|
||||
if (!src) return;
|
||||
const trig = mapTriggerType(src.type, srcName);
|
||||
const outs = (conns[srcName].main || []).flat();
|
||||
outs.forEach(o => {
|
||||
const tgt = o && byName[o.node];
|
||||
if (!tgt) return;
|
||||
const act = mapActionType(tgt.type, tgt.name);
|
||||
if (trig && act) {
|
||||
rules.push({ title: `${srcName} → ${tgt.name || o.node}`, trigger: trig, action: act });
|
||||
} else {
|
||||
unmapped.push(`${srcName} → ${o.node}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
return { rules, unmapped };
|
||||
}
|
||||
|
||||
export function parseNodeRed(data) {
|
||||
const nodes = Array.isArray(data) ? data : (data.flows || []);
|
||||
const byId = {};
|
||||
nodes.forEach(n => { byId[n.id] = n; });
|
||||
const rules = [];
|
||||
const unmapped = [];
|
||||
nodes.forEach(n => {
|
||||
const trig = mapTriggerType(n.type, n.name);
|
||||
if (!trig) return;
|
||||
((n.wires || []).flat()).forEach(tid => {
|
||||
const tgt = byId[tid];
|
||||
if (!tgt) return;
|
||||
const act = mapActionType(tgt.type, tgt.name);
|
||||
const label = `${n.name || n.type} → ${tgt.name || tgt.type}`;
|
||||
if (act) rules.push({ title: label, trigger: trig, action: act });
|
||||
else unmapped.push(label);
|
||||
});
|
||||
});
|
||||
return { rules, unmapped };
|
||||
}
|
||||
|
||||
function parseWorkflow(text, format) {
|
||||
let data;
|
||||
try { data = JSON.parse(text); } catch (e) { return { rules: [], unmapped: [], error: 'invalid JSON' }; }
|
||||
let fmt = format;
|
||||
if (!fmt || fmt === 'auto') {
|
||||
if (data && data.nodes && data.connections) fmt = 'n8n';
|
||||
else if (Array.isArray(data) || data.flows) fmt = 'nodered';
|
||||
}
|
||||
if (fmt === 'n8n') return parseN8n(data);
|
||||
if (fmt === 'nodered') return parseNodeRed(data);
|
||||
return { rules: [], unmapped: [], error: 'unknown format' };
|
||||
}
|
||||
|
||||
// --- Workspace + board selection helpers ------------------------------------
|
||||
function flattenWorkspaces(nodes, depth, out) {
|
||||
(nodes || []).forEach(node => {
|
||||
out.push({ id: node.id, label: `${' '.repeat(depth)}${node.name}` });
|
||||
if (node.children && node.children.length) flattenWorkspaces(node.children, depth + 1, out);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
Template.rulesImportExportPopup.onCreated(function () {
|
||||
this.message = new ReactiveVar('');
|
||||
this.selectedWorkspace = new ReactiveVar('');
|
||||
this.selectedBoard = new ReactiveVar(Session.get('currentBoard'));
|
||||
this.subscribe('boards'); // the user's boards (also subscribed globally)
|
||||
});
|
||||
|
||||
Template.rulesImportExportPopup.helpers({
|
||||
message() {
|
||||
return Template.instance().message;
|
||||
},
|
||||
workspaces() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const tree = (user && user.profile && user.profile.boardWorkspacesTree) || [];
|
||||
return flattenWorkspaces(tree, 0, []);
|
||||
},
|
||||
boardsForImport() {
|
||||
const tpl = Template.instance();
|
||||
const userId = Meteor.userId();
|
||||
const ws = tpl.selectedWorkspace.get();
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const assignments = (user && user.profile && user.profile.boardWorkspaceAssignments) || {};
|
||||
let boards = ReactiveCache.getBoards(
|
||||
{ archived: false, 'members.userId': userId },
|
||||
{ sort: { title: 1 } },
|
||||
);
|
||||
if (ws) boards = boards.filter(b => assignments[b._id] === ws);
|
||||
const selectedBoard = tpl.selectedBoard.get();
|
||||
return boards.map(b => ({ _id: b._id, title: b.title, selected: b._id === selectedBoard }));
|
||||
},
|
||||
});
|
||||
|
||||
function targetBoardId(tpl) {
|
||||
return tpl.selectedBoard.get() || Session.get('currentBoard');
|
||||
}
|
||||
|
||||
function reportImport(tpl, count, unmapped) {
|
||||
let msg = TAPi18n.__('r-import-done', { count });
|
||||
if (unmapped && unmapped.length) {
|
||||
msg += ` — ${TAPi18n.__('r-import-unmapped', { count: unmapped.length })}`;
|
||||
}
|
||||
tpl.message.set(msg);
|
||||
}
|
||||
|
||||
Template.rulesImportExportPopup.events({
|
||||
'change .js-import-workspace'(event, tpl) {
|
||||
tpl.selectedWorkspace.set(event.currentTarget.value);
|
||||
},
|
||||
'change .js-import-board'(event, tpl) {
|
||||
tpl.selectedBoard.set(event.currentTarget.value);
|
||||
},
|
||||
'click .js-rules-export-json'() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const data = { _format: RULES_FORMAT, boardId, rules: collectBoardRules(boardId) };
|
||||
download('wekan-rules.json', JSON.stringify(data, null, 2), 'application/json');
|
||||
},
|
||||
'click .js-rules-export-csv'() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
download('wekan-rules.csv', rulesToCsv(collectBoardRules(boardId)), 'text/csv');
|
||||
},
|
||||
'click .js-rules-import-json'(event, tpl) {
|
||||
const text = tpl.find('.js-rules-import-text').value;
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
const rulesArray = Array.isArray(parsed) ? parsed : parsed.rules;
|
||||
reportImport(tpl, importRules(rulesArray, targetBoardId(tpl)));
|
||||
} catch (e) {
|
||||
tpl.message.set(String(e.message || e));
|
||||
}
|
||||
},
|
||||
'click .js-rules-import-csv'(event, tpl) {
|
||||
const text = tpl.find('.js-rules-import-text').value;
|
||||
try {
|
||||
reportImport(tpl, importRules(csvToRules(text), targetBoardId(tpl)));
|
||||
} catch (e) {
|
||||
tpl.message.set(String(e.message || e));
|
||||
}
|
||||
},
|
||||
'click .js-rules-import-trello'(event, tpl) {
|
||||
const { rules, unmapped } = parseTrelloButler(tpl.find('.js-rules-import-text').value);
|
||||
reportImport(tpl, importRules(rules, targetBoardId(tpl)), unmapped);
|
||||
},
|
||||
'click .js-rules-import-workflow'(event, tpl) {
|
||||
const format = tpl.find('.js-workflow-format').value;
|
||||
const { rules, unmapped, error } = parseWorkflow(tpl.find('.js-rules-import-text').value, format);
|
||||
if (error) {
|
||||
tpl.message.set(error);
|
||||
return;
|
||||
}
|
||||
reportImport(tpl, importRules(rules, targetBoardId(tpl)), unmapped);
|
||||
},
|
||||
});
|
||||
@@ -4,32 +4,45 @@ template(name="rulesList")
|
||||
i.fa.fa-magic
|
||||
| {{_ 'r-board-rules' }}
|
||||
|
||||
if rules.length
|
||||
.rules-toolbar
|
||||
button.js-rules-select-all(type="button")
|
||||
i.fa.fa-check-square-o
|
||||
| {{_ 'r-select-all'}}
|
||||
button.js-rules-select-none(type="button")
|
||||
i.fa.fa-square-o
|
||||
| {{_ 'r-unselect-all'}}
|
||||
if canAdmin
|
||||
button.js-rules-delete-selected(type="button")
|
||||
i.fa.fa-trash
|
||||
| {{_ 'r-delete-selected'}}
|
||||
button.js-rules-export-selected(type="button")
|
||||
i.fa.fa-download
|
||||
| {{_ 'r-export-selected'}}
|
||||
|
||||
ul.rules-list
|
||||
each rules
|
||||
li.rules-lists-item
|
||||
input.js-rule-select(type="checkbox" data-rule-id="{{_id}}" checked=isSelected)
|
||||
p
|
||||
= title
|
||||
if isEditing
|
||||
input.js-edit-rule-input(type=text value="{{title}}" autofocus)
|
||||
else
|
||||
= title
|
||||
div.rules-btns-group
|
||||
button.primary.wide.js-goto-details(type="submit")
|
||||
i.fa.fa-eye
|
||||
| {{_ 'r-view-rule'}}
|
||||
if currentUser.isAdmin
|
||||
button.primary.wide.js-delete-rule(type="submit")
|
||||
i.fa.fa-trash
|
||||
| {{_ 'r-delete-rule'}}
|
||||
else if currentUser.isBoardAdmin
|
||||
if canAdmin
|
||||
button.primary.wide.js-edit-rule(type="submit")
|
||||
i.fa.fa-pencil
|
||||
| {{_ 'r-edit-rule'}}
|
||||
button.primary.wide.js-delete-rule(type="submit")
|
||||
i.fa.fa-trash
|
||||
| {{_ 'r-delete-rule'}}
|
||||
else
|
||||
li.no-items-message {{_ 'r-no-rules' }}
|
||||
if currentUser.isAdmin
|
||||
div.rules-add
|
||||
button.primary.wide.rules-add-trigger.js-goto-trigger(type="submit")
|
||||
i.fa.fa-plus
|
||||
| {{_ 'r-add-rule'}}
|
||||
input(type=text,placeholder="{{_ 'r-new-rule-name' }}",id="ruleTitle")
|
||||
else if currentUser.isBoardAdmin
|
||||
if canAdmin
|
||||
div.rules-add
|
||||
button.primary.wide.rules-add-trigger.js-goto-trigger(type="submit")
|
||||
i.fa.fa-plus
|
||||
|
||||
@@ -1,15 +1,91 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import Actions from '/models/actions';
|
||||
import Rules from '/models/rules';
|
||||
import Triggers from '/models/triggers';
|
||||
|
||||
function boardRuleIds() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
return ReactiveCache.getRules({ boardId }).map(r => r._id);
|
||||
}
|
||||
|
||||
function getSelected() {
|
||||
return Session.get('selectedRuleIds') || [];
|
||||
}
|
||||
|
||||
function setSelected(ids) {
|
||||
Session.set('selectedRuleIds', ids);
|
||||
}
|
||||
|
||||
Template.rulesList.onCreated(function () {
|
||||
this.subscribe('allRules');
|
||||
this.autorun(() => {
|
||||
const boardId = Session.get('currentBoard');
|
||||
if (boardId) this.subscribe('boardRules', boardId);
|
||||
});
|
||||
this.editingRuleId = new ReactiveVar(null);
|
||||
});
|
||||
|
||||
Template.rulesList.helpers({
|
||||
rules() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const ret = ReactiveCache.getRules({
|
||||
boardId,
|
||||
});
|
||||
return ret;
|
||||
return ReactiveCache.getRules({ boardId });
|
||||
},
|
||||
canAdmin() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
return user && (user.isAdmin || user.isBoardAdmin);
|
||||
},
|
||||
isSelected() {
|
||||
return getSelected().includes(this._id);
|
||||
},
|
||||
isEditing() {
|
||||
return Template.instance().editingRuleId.get() === this._id;
|
||||
},
|
||||
});
|
||||
|
||||
Template.rulesList.events({
|
||||
'change .js-rule-select'(event) {
|
||||
const ruleId = event.currentTarget.getAttribute('data-rule-id');
|
||||
const selected = new Set(getSelected());
|
||||
if (event.currentTarget.checked) {
|
||||
selected.add(ruleId);
|
||||
} else {
|
||||
selected.delete(ruleId);
|
||||
}
|
||||
setSelected([...selected]);
|
||||
},
|
||||
'click .js-rules-select-all'() {
|
||||
setSelected(boardRuleIds());
|
||||
},
|
||||
'click .js-rules-select-none'() {
|
||||
setSelected([]);
|
||||
},
|
||||
'click .js-rules-delete-selected'() {
|
||||
getSelected().forEach(ruleId => {
|
||||
const rule = ReactiveCache.getRule(ruleId);
|
||||
if (rule) {
|
||||
Rules.remove(rule._id);
|
||||
Actions.remove(rule.actionId);
|
||||
Triggers.remove(rule.triggerId);
|
||||
}
|
||||
});
|
||||
setSelected([]);
|
||||
},
|
||||
'click .js-rules-export-selected': Popup.open('rulesImportExport'),
|
||||
// Inline rename of a rule.
|
||||
'click .js-edit-rule'(event, tpl) {
|
||||
tpl.editingRuleId.set(this._id);
|
||||
},
|
||||
'keydown .js-edit-rule-input'(event, tpl) {
|
||||
if (event.key === 'Enter') {
|
||||
const title = event.currentTarget.value.trim();
|
||||
if (title) Rules.update(this._id, { $set: { title } });
|
||||
tpl.editingRuleId.set(null);
|
||||
} else if (event.key === 'Escape') {
|
||||
tpl.editingRuleId.set(null);
|
||||
}
|
||||
},
|
||||
'blur .js-edit-rule-input'(event, tpl) {
|
||||
const title = event.currentTarget.value.trim();
|
||||
if (title) Rules.update(this._id, { $set: { title } });
|
||||
tpl.editingRuleId.set(null);
|
||||
},
|
||||
});
|
||||
@@ -1,9 +1,32 @@
|
||||
template(name="rulesHeaderBar")
|
||||
h1
|
||||
i.fa.fa-magic
|
||||
| {{_ 'r-board-rules'}}
|
||||
.board-header-btns.left
|
||||
a.board-header-btn.js-rules-back-to-board(title="{{_ 'back'}}")
|
||||
i.fa.fa-arrow-left
|
||||
| {{_ 'board'}}
|
||||
a.board-header-btn.js-rules-toggle-view(title="{{_ 'r-workflow-view'}}")
|
||||
i.fa.fa-sitemap
|
||||
if isWorkflowView
|
||||
| {{_ 'r-list-view'}}
|
||||
else
|
||||
| {{_ 'r-workflow-view'}}
|
||||
a.board-header-btn.js-rules-import-export(title="{{_ 'r-import-export'}}")
|
||||
i.fa.fa-exchange
|
||||
| {{_ 'r-import-export'}}
|
||||
|
||||
template(name="rulesMain")
|
||||
if($eq rulesCurrentTab.get 'rulesList')
|
||||
+rulesList
|
||||
if($eq rulesCurrentTab.get 'trigger')
|
||||
+rulesTriggers(ruleName=ruleName triggerVar=triggerVar)
|
||||
if($eq rulesCurrentTab.get 'action')
|
||||
+rulesActions(ruleName=ruleName triggerVar=triggerVar)
|
||||
if($eq rulesCurrentTab.get 'ruleDetails')
|
||||
+ruleDetails(ruleId=ruleId)
|
||||
.wrapper
|
||||
.rules-page(class=currentBoard.colorClass)
|
||||
if($eq rulesCurrentTab.get 'rulesList')
|
||||
if isWorkflowView
|
||||
+rulesWorkflow
|
||||
else
|
||||
+rulesList
|
||||
if($eq rulesCurrentTab.get 'trigger')
|
||||
+rulesTriggers(ruleName=ruleName triggerVar=triggerVar)
|
||||
if($eq rulesCurrentTab.get 'action')
|
||||
+rulesActions(ruleName=ruleName triggerVar=triggerVar)
|
||||
if($eq rulesCurrentTab.get 'ruleDetails')
|
||||
+ruleDetails(ruleId=ruleId)
|
||||
Loaded 100 of 486 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user