mirror of
https://github.com/wekan/wekan.git
synced 2026-11-08 21:44:59 -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"
|
||||
@@ -68,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:
|
||||
@@ -155,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,6 +790,24 @@ jobs:
|
||||
printf '%s' "$LP_CREDENTIALS" | base64 -d > ~/.local/share/snapcraft/launchpad-credentials
|
||||
chmod 600 ~/.local/share/snapcraft/launchpad-credentials
|
||||
|
||||
- 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: |
|
||||
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
|
||||
@@ -621,29 +816,48 @@ jobs:
|
||||
# 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"
|
||||
if snapcraft remote-build --launchpad-accept-public-upload; then
|
||||
echo "::endgroup::"
|
||||
echo "Remote build succeeded on attempt $i."
|
||||
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 "::endgroup::"
|
||||
echo "Remote build attempt $i failed (likely a transient Launchpad/TLS error)."
|
||||
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 "Remote build failed after $attempts attempts." >&2
|
||||
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
|
||||
@@ -652,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
|
||||
+1348
-1
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.45
|
||||
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.45.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().
|
||||
|
||||
@@ -24,13 +24,14 @@ template(name="boardBody")
|
||||
.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
|
||||
|
||||
@@ -674,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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
@@ -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'),
|
||||
});
|
||||
@@ -1029,13 +1029,40 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
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;
|
||||
margin-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 {
|
||||
|
||||
@@ -62,8 +62,9 @@ template(name="cardDetails")
|
||||
i.fa.fa-link
|
||||
span.copied-tooltip {{_ 'copied'}}
|
||||
if canModifyCard
|
||||
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}}")
|
||||
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
|
||||
@@ -170,6 +171,8 @@ template(name="cardDetails")
|
||||
a.card-label.add-label.js-add-location(title="{{_ 'add-location'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
+cardButtons(_id=_id boardId=boardId)
|
||||
|
||||
if currentBoard.hasAnyAllowsDate
|
||||
hr
|
||||
|
||||
@@ -319,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
|
||||
|
||||
@@ -912,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
|
||||
@@ -947,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
|
||||
@@ -982,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
|
||||
@@ -1017,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
|
||||
@@ -1286,3 +1319,32 @@ template(name="cardLocationsPopup")
|
||||
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,
|
||||
@@ -51,7 +64,6 @@ import { UnsavedEdits } from '/client/lib/unsavedEdits';
|
||||
import { EscapeActions } from '/client/lib/escapeActions';
|
||||
import { MultiSelection } from '/client/lib/multiSelection';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import autosize from 'autosize';
|
||||
|
||||
// Id of the location currently being edited in the cardLocationsPopup; null
|
||||
@@ -521,6 +533,34 @@ Template.cardDetails.helpers({
|
||||
});
|
||||
},
|
||||
|
||||
// #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();
|
||||
},
|
||||
@@ -711,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();
|
||||
@@ -853,6 +904,40 @@ 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'),
|
||||
@@ -1152,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 () {
|
||||
@@ -1621,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);
|
||||
},
|
||||
@@ -1632,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
|
||||
@@ -2395,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();
|
||||
},
|
||||
});
|
||||
@@ -866,3 +866,20 @@
|
||||
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
|
||||
@@ -54,9 +62,10 @@ template(name="minicard")
|
||||
span {{_ 'upload-completed'}}
|
||||
|
||||
.minicard-title
|
||||
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 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 ' > ' }}
|
||||
@@ -155,6 +164,11 @@ 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
|
||||
|
||||
@@ -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,46 @@ 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.
|
||||
|
||||
@@ -2,7 +2,7 @@ template(name="importHeaderBar")
|
||||
h1
|
||||
a.back-btn(href="{{pathFor 'home'}}")
|
||||
i.fa-arrow-left
|
||||
| {{_ title}}
|
||||
| {{title}}
|
||||
|
||||
template(name="import")
|
||||
.wrapper
|
||||
@@ -14,9 +14,14 @@ template(name="import")
|
||||
|
||||
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'}}
|
||||
@@ -30,6 +35,7 @@ template(name="importTextarea")
|
||||
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
|
||||
@@ -158,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,6 +3,8 @@ 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';
|
||||
@@ -13,7 +15,24 @@ 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}`;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -42,6 +61,17 @@ 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;
|
||||
}
|
||||
@@ -85,6 +115,15 @@ function boardSlug(data) {
|
||||
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
|
||||
@@ -155,8 +194,36 @@ Template.import.onCreated(function () {
|
||||
this.error.set(error);
|
||||
};
|
||||
|
||||
this.importData = async (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 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;
|
||||
@@ -165,7 +232,7 @@ Template.import.onCreated(function () {
|
||||
else throw new Meteor.Error('error-csv-schema');
|
||||
const membersToMap = _prepareAdditionalData(ret.data);
|
||||
this.membersToMap.set(membersToMap);
|
||||
this.nextStep();
|
||||
await advance();
|
||||
return;
|
||||
}
|
||||
// Trello: a .zip package (one or more board .json files plus per-board
|
||||
@@ -187,6 +254,13 @@ Template.import.onCreated(function () {
|
||||
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.
|
||||
@@ -201,7 +275,7 @@ Template.import.onCreated(function () {
|
||||
// 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();
|
||||
await advance();
|
||||
} catch (e) {
|
||||
this.setError('error-json-malformed');
|
||||
}
|
||||
@@ -292,10 +366,7 @@ Template.import.onCreated(function () {
|
||||
this.setError(err.error);
|
||||
} else {
|
||||
Session.set('fromBoard', null);
|
||||
FlowRouter.go('board', {
|
||||
id: res,
|
||||
slug: boardSlug(importedData),
|
||||
});
|
||||
goToImportedBoard(res, boardSlug(importedData));
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -316,7 +387,35 @@ Template.import.helpers({
|
||||
|
||||
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');
|
||||
@@ -329,6 +428,9 @@ Template.importTextarea.helpers({
|
||||
isTrelloImport() {
|
||||
return Session.get('importSource') === 'trello';
|
||||
},
|
||||
isExcelImport() {
|
||||
return Session.get('importSource') === 'excel';
|
||||
},
|
||||
});
|
||||
|
||||
Template.importTextarea.events({
|
||||
@@ -338,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
|
||||
@@ -468,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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -84,16 +84,17 @@
|
||||
flex-basis: auto !important;
|
||||
flex-grow: 0 !important;
|
||||
flex-shrink: 0 !important;
|
||||
/* Override any conflicting layout properties */
|
||||
/* 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: block !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 {
|
||||
@@ -130,7 +131,8 @@ body.list-resizing-active * {
|
||||
flex-grow: 0 !important;
|
||||
flex-shrink: 0 !important;
|
||||
float: inline-start !important;
|
||||
display: block !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
/* Ensure the width persists after resize is complete */
|
||||
@@ -146,7 +148,8 @@ body.list-resizing-active * {
|
||||
flex-grow: 0 !important;
|
||||
flex-shrink: 0 !important;
|
||||
float: inline-start !important;
|
||||
display: block !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
/* Ensure consistent header height for all lists */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
template(name="main")
|
||||
html(lang="{{TAPi18n.getLanguage}}" dir="{{TAPi18n.getLanguageDirection}}")
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,9 +1,171 @@
|
||||
/* 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;
|
||||
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;
|
||||
position: relative;
|
||||
@@ -218,3 +380,32 @@
|
||||
.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)
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import Actions from '/models/actions';
|
||||
import Rules from '/models/rules';
|
||||
import Triggers from '/models/triggers';
|
||||
@@ -8,6 +10,17 @@ Template.rulesMain.onCreated(function () {
|
||||
this.ruleName = new ReactiveVar('');
|
||||
this.triggerVar = new ReactiveVar();
|
||||
this.ruleId = new ReactiveVar();
|
||||
|
||||
// The Rules page is now a standalone board-scoped route, so subscribe to the
|
||||
// board data (lists, swimlanes, labels, members) the trigger/action forms need,
|
||||
// in addition to the rules themselves.
|
||||
this.autorun(() => {
|
||||
const boardId = Session.get('currentBoard');
|
||||
if (boardId) {
|
||||
this.subscribe('board', boardId, false);
|
||||
this.subscribe('boardRules', boardId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Template.rulesMain.helpers({
|
||||
@@ -23,6 +36,40 @@ Template.rulesMain.helpers({
|
||||
ruleId() {
|
||||
return Template.instance().ruleId;
|
||||
},
|
||||
currentBoard() {
|
||||
return Utils.getCurrentBoard();
|
||||
},
|
||||
isWorkflowView() {
|
||||
return Session.get('rulesViewMode') === 'workflow';
|
||||
},
|
||||
});
|
||||
|
||||
Template.rulesHeaderBar.helpers({
|
||||
currentBoard() {
|
||||
return Utils.getCurrentBoard();
|
||||
},
|
||||
isWorkflowView() {
|
||||
return Session.get('rulesViewMode') === 'workflow';
|
||||
},
|
||||
});
|
||||
|
||||
Template.rulesHeaderBar.events({
|
||||
'click .js-rules-back-to-board'(event) {
|
||||
event.preventDefault();
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
if (currentBoard) {
|
||||
FlowRouter.go('board', {
|
||||
id: currentBoard._id,
|
||||
slug: currentBoard.slug,
|
||||
});
|
||||
}
|
||||
},
|
||||
'click .js-rules-toggle-view'(event) {
|
||||
event.preventDefault();
|
||||
const mode = Session.get('rulesViewMode') === 'workflow' ? 'list' : 'workflow';
|
||||
Session.set('rulesViewMode', mode);
|
||||
},
|
||||
'click .js-rules-import-export': Popup.open('rulesImportExport'),
|
||||
});
|
||||
|
||||
function sanitizeObject(obj) {
|
||||
|
||||
@@ -12,6 +12,10 @@ template(name="rulesTriggers")
|
||||
i.fa.fa-file-text-o
|
||||
li.js-set-checklist-triggers
|
||||
i.fa.fa-check
|
||||
li.js-set-scheduled-triggers(title="{{_ 'r-set-scheduled-triggers'}}")
|
||||
i.fa.fa-clock-o
|
||||
li.js-set-button-triggers(title="{{_ 'r-set-button-triggers'}}")
|
||||
i.fa.fa-hand-pointer-o
|
||||
.triggers-main-body
|
||||
if showBoardTrigger.get
|
||||
+boardTriggers
|
||||
@@ -19,6 +23,10 @@ template(name="rulesTriggers")
|
||||
+cardTriggers
|
||||
else if showChecklistTrigger.get
|
||||
+checklistTriggers
|
||||
else if showScheduledTrigger.get
|
||||
+scheduledTriggers
|
||||
else if showButtonTrigger.get
|
||||
+buttonTriggers
|
||||
div.rules-back
|
||||
button.primary.wide.js-goback(type="submit")
|
||||
i.fa.fa-arrow-left
|
||||
|
||||
@@ -4,6 +4,8 @@ Template.rulesTriggers.onCreated(function () {
|
||||
this.showBoardTrigger = new ReactiveVar(true);
|
||||
this.showCardTrigger = new ReactiveVar(false);
|
||||
this.showChecklistTrigger = new ReactiveVar(false);
|
||||
this.showScheduledTrigger = new ReactiveVar(false);
|
||||
this.showButtonTrigger = new ReactiveVar(false);
|
||||
});
|
||||
|
||||
Template.rulesTriggers.helpers({
|
||||
@@ -28,6 +30,14 @@ Template.rulesTriggers.helpers({
|
||||
return Template.instance().showChecklistTrigger;
|
||||
},
|
||||
|
||||
showScheduledTrigger() {
|
||||
return Template.instance().showScheduledTrigger;
|
||||
},
|
||||
|
||||
showButtonTrigger() {
|
||||
return Template.instance().showButtonTrigger;
|
||||
},
|
||||
|
||||
rules() {
|
||||
const ret = ReactiveCache.getRules({});
|
||||
return ret;
|
||||
@@ -38,41 +48,42 @@ Template.rulesTriggers.helpers({
|
||||
},
|
||||
});
|
||||
|
||||
function setBoardTriggers(tpl) {
|
||||
tpl.showBoardTrigger.set(true);
|
||||
tpl.showCardTrigger.set(false);
|
||||
tpl.showChecklistTrigger.set(false);
|
||||
$('.js-set-card-triggers').removeClass('active');
|
||||
$('.js-set-board-triggers').addClass('active');
|
||||
$('.js-set-checklist-triggers').removeClass('active');
|
||||
}
|
||||
|
||||
function setCardTriggers(tpl) {
|
||||
tpl.showBoardTrigger.set(false);
|
||||
tpl.showCardTrigger.set(true);
|
||||
tpl.showChecklistTrigger.set(false);
|
||||
$('.js-set-card-triggers').addClass('active');
|
||||
$('.js-set-board-triggers').removeClass('active');
|
||||
$('.js-set-checklist-triggers').removeClass('active');
|
||||
}
|
||||
|
||||
function setChecklistTriggers(tpl) {
|
||||
tpl.showBoardTrigger.set(false);
|
||||
tpl.showCardTrigger.set(false);
|
||||
tpl.showChecklistTrigger.set(true);
|
||||
$('.js-set-card-triggers').removeClass('active');
|
||||
$('.js-set-board-triggers').removeClass('active');
|
||||
$('.js-set-checklist-triggers').addClass('active');
|
||||
// Show only the chosen trigger category and highlight its side-menu item.
|
||||
function selectTriggerTab(tpl, active) {
|
||||
const tabs = {
|
||||
board: tpl.showBoardTrigger,
|
||||
card: tpl.showCardTrigger,
|
||||
checklist: tpl.showChecklistTrigger,
|
||||
scheduled: tpl.showScheduledTrigger,
|
||||
button: tpl.showButtonTrigger,
|
||||
};
|
||||
Object.keys(tabs).forEach(key => tabs[key].set(key === active));
|
||||
const classes = {
|
||||
board: '.js-set-board-triggers',
|
||||
card: '.js-set-card-triggers',
|
||||
checklist: '.js-set-checklist-triggers',
|
||||
scheduled: '.js-set-scheduled-triggers',
|
||||
button: '.js-set-button-triggers',
|
||||
};
|
||||
Object.keys(classes).forEach(key =>
|
||||
$(classes[key]).toggleClass('active', key === active),
|
||||
);
|
||||
}
|
||||
|
||||
Template.rulesTriggers.events({
|
||||
'click .js-set-board-triggers'(event, tpl) {
|
||||
setBoardTriggers(tpl);
|
||||
selectTriggerTab(tpl, 'board');
|
||||
},
|
||||
'click .js-set-card-triggers'(event, tpl) {
|
||||
setCardTriggers(tpl);
|
||||
selectTriggerTab(tpl, 'card');
|
||||
},
|
||||
'click .js-set-checklist-triggers'(event, tpl) {
|
||||
setChecklistTriggers(tpl);
|
||||
selectTriggerTab(tpl, 'checklist');
|
||||
},
|
||||
'click .js-set-scheduled-triggers'(event, tpl) {
|
||||
selectTriggerTab(tpl, 'scheduled');
|
||||
},
|
||||
'click .js-set-button-triggers'(event, tpl) {
|
||||
selectTriggerTab(tpl, 'button');
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
template(name="rulesWorkflow")
|
||||
.rules.rules-workflow(class=currentBoard.colorClass)
|
||||
h2
|
||||
i.fa.fa-sitemap
|
||||
| {{_ 'r-workflow-view'}}
|
||||
p.workflow-help {{_ 'r-workflow-help'}}
|
||||
|
||||
.workflow-builder
|
||||
.workflow-builder-row
|
||||
.workflow-slot.js-when-slot
|
||||
if builderTrigger
|
||||
.workflow-node.workflow-node-when
|
||||
span.workflow-node-label {{_ 'r-when'}}
|
||||
span.workflow-node-text= builderTriggerLabel
|
||||
a.workflow-slot-clear.js-clear-when(title="{{_ 'remove'}}") ✕
|
||||
else
|
||||
span.workflow-slot-placeholder {{_ 'r-when'}}: {{_ 'r-drop-trigger'}}
|
||||
.workflow-arrow
|
||||
i.fa.fa-long-arrow-right
|
||||
.workflow-slot.js-then-slot
|
||||
if builderAction
|
||||
.workflow-node.workflow-node-then
|
||||
span.workflow-node-label {{_ 'r-action'}}
|
||||
span.workflow-node-text= builderActionLabel
|
||||
a.workflow-slot-clear.js-clear-then(title="{{_ 'remove'}}") ✕
|
||||
else
|
||||
span.workflow-slot-placeholder {{_ 'r-action'}}: {{_ 'r-drop-action'}}
|
||||
.workflow-builder-controls
|
||||
input.js-workflow-rule-title(type=text placeholder="{{_ 'r-new-rule-name'}}")
|
||||
button.primary.js-create-workflow-rule(disabled=createDisabled)
|
||||
i.fa.fa-plus
|
||||
| {{_ 'r-add-rule'}}
|
||||
|
||||
.workflow-palettes
|
||||
.workflow-palette
|
||||
h3 {{_ 'r-trigger'}}
|
||||
each triggerPalette
|
||||
.workflow-chip.js-trigger-chip(draggable="true" data-idx="{{idx}}")
|
||||
i.fa.fa-bolt
|
||||
span= label
|
||||
.workflow-palette
|
||||
h3 {{_ 'r-action'}}
|
||||
each actionPalette
|
||||
.workflow-chip.js-action-chip(draggable="true" data-idx="{{idx}}")
|
||||
i.fa.fa-play
|
||||
span= label
|
||||
|
||||
h3.workflow-existing-title {{_ 'r-board-rules'}}
|
||||
if rules.length
|
||||
.workflow-rules
|
||||
each rules
|
||||
.workflow-rule.js-rule-node(data-rule-id="{{ruleId}}")
|
||||
.workflow-node.workflow-node-when
|
||||
span.workflow-node-label {{_ 'r-when'}}
|
||||
span.workflow-node-text= whenText
|
||||
.workflow-arrow
|
||||
i.fa.fa-long-arrow-right
|
||||
.workflow-node.workflow-node-then
|
||||
span.workflow-node-label {{_ 'r-action'}}
|
||||
span.workflow-node-text= thenText
|
||||
a.workflow-rule-delete.js-delete-workflow-rule(title="{{_ 'r-delete-rule'}}")
|
||||
i.fa.fa-trash
|
||||
else
|
||||
p.no-items-message {{_ 'r-no-rules'}}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import Actions from '/models/actions';
|
||||
import Rules from '/models/rules';
|
||||
import Triggers from '/models/triggers';
|
||||
|
||||
// A drag-and-drop visual workflow editor for board rules. Triggers and actions
|
||||
// are draggable chips; drag one of each into the builder (When → Then) and add
|
||||
// the rule, or drag an action onto an existing rule to change what it does.
|
||||
//
|
||||
// The palette offers the parameter-free / wildcard ("any") variants so a rule
|
||||
// can be built entirely by dragging. Parameterized rules (specific list, label,
|
||||
// member, schedule time, …) are still created with the form builder on the
|
||||
// List view.
|
||||
const TRIGGER_PALETTE = [
|
||||
{ label: 'Card is created', doc: { activityType: 'createCard', listName: '*', swimlaneName: '*', cardTitle: '*', userId: '*' } },
|
||||
{ label: 'Card is moved', doc: { activityType: 'moveCard', listName: '*', oldListName: '*', swimlaneName: '*', cardTitle: '*', userId: '*' } },
|
||||
{ label: 'Card is archived', doc: { activityType: 'archivedCard', userId: '*' } },
|
||||
{ label: 'Card is unarchived', doc: { activityType: 'restoredCard', userId: '*' } },
|
||||
{ label: 'Any label is added', doc: { activityType: 'addedLabel', labelId: '*', userId: '*' } },
|
||||
{ label: 'Any label is removed', doc: { activityType: 'removedLabel', labelId: '*', userId: '*' } },
|
||||
{ label: 'A member is added', doc: { activityType: 'joinMember', username: '*', userId: '*' } },
|
||||
{ label: 'A member is removed', doc: { activityType: 'unjoinMember', username: '*', userId: '*' } },
|
||||
{ label: 'A checklist is added', doc: { activityType: 'addChecklist', checklistName: '*', userId: '*' } },
|
||||
{ label: 'An attachment is added', doc: { activityType: 'addAttachment', userId: '*' } },
|
||||
{ label: 'Every day at 09:00', doc: { activityType: 'scheduledTrigger', scheduleKind: 'calendar', scheduleType: 'daily', atTime: '09:00', listName: '*', swimlaneName: '*' } },
|
||||
];
|
||||
|
||||
const ACTION_PALETTE = [
|
||||
{ label: 'Move card to top', doc: { actionType: 'moveCardToTop', listName: '*', swimlaneName: '*' } },
|
||||
{ label: 'Move card to bottom', doc: { actionType: 'moveCardToBottom', listName: '*', swimlaneName: '*' } },
|
||||
{ label: 'Archive card', doc: { actionType: 'archive' } },
|
||||
{ label: 'Unarchive card', doc: { actionType: 'unarchive' } },
|
||||
{ label: 'Mark card complete', doc: { actionType: 'markCardComplete' } },
|
||||
{ label: 'Mark card incomplete', doc: { actionType: 'markCardIncomplete' } },
|
||||
{ label: 'Remove all members', doc: { actionType: 'removeMember', username: '*' } },
|
||||
{ label: 'Set received date to now', doc: { actionType: 'setDate', dateField: 'receivedAt' } },
|
||||
];
|
||||
|
||||
Template.rulesWorkflow.onCreated(function () {
|
||||
this.builderTrigger = new ReactiveVar(null);
|
||||
this.builderAction = new ReactiveVar(null);
|
||||
this.dragItem = null; // {type:'trigger'|'action', idx}
|
||||
this.autorun(() => {
|
||||
const boardId = Session.get('currentBoard');
|
||||
if (boardId) this.subscribe('boardRules', boardId);
|
||||
});
|
||||
});
|
||||
|
||||
Template.rulesWorkflow.helpers({
|
||||
currentBoard() {
|
||||
return Utils.getCurrentBoard();
|
||||
},
|
||||
triggerPalette() {
|
||||
return TRIGGER_PALETTE.map((t, idx) => ({ idx, label: t.label }));
|
||||
},
|
||||
actionPalette() {
|
||||
return ACTION_PALETTE.map((a, idx) => ({ idx, label: a.label }));
|
||||
},
|
||||
builderTrigger() {
|
||||
return Template.instance().builderTrigger.get();
|
||||
},
|
||||
builderTriggerLabel() {
|
||||
const t = Template.instance().builderTrigger.get();
|
||||
return t ? t.label : '';
|
||||
},
|
||||
builderAction() {
|
||||
return Template.instance().builderAction.get();
|
||||
},
|
||||
builderActionLabel() {
|
||||
const a = Template.instance().builderAction.get();
|
||||
return a ? a.label : '';
|
||||
},
|
||||
createDisabled() {
|
||||
const tpl = Template.instance();
|
||||
return tpl.builderTrigger.get() && tpl.builderAction.get() ? false : true;
|
||||
},
|
||||
rules() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const rules = ReactiveCache.getRules({ boardId });
|
||||
return rules.map(rule => {
|
||||
const trigger = ReactiveCache.getTrigger(rule.triggerId);
|
||||
const action = ReactiveCache.getAction(rule.actionId);
|
||||
return {
|
||||
ruleId: rule._id,
|
||||
whenText: (trigger && trigger.desc) || (trigger && trigger.activityType) || '',
|
||||
thenText: (action && action.desc) || (action && action.actionType) || '',
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function persistRule(tpl) {
|
||||
const t = tpl.builderTrigger.get();
|
||||
const a = tpl.builderAction.get();
|
||||
if (!t || !a) return;
|
||||
const boardId = Session.get('currentBoard');
|
||||
const titleField = tpl.find('.js-workflow-rule-title');
|
||||
const title = (titleField.value || '').trim() || `${t.label} → ${a.label}`;
|
||||
const triggerId = Triggers.insert({ ...t.doc, boardId, desc: t.label });
|
||||
const actionId = Actions.insert({ ...a.doc, boardId, desc: a.label });
|
||||
Rules.insert({ title, triggerId, actionId, boardId });
|
||||
tpl.builderTrigger.set(null);
|
||||
tpl.builderAction.set(null);
|
||||
titleField.value = '';
|
||||
}
|
||||
|
||||
Template.rulesWorkflow.events({
|
||||
'dragstart .js-trigger-chip'(event, tpl) {
|
||||
tpl.dragItem = { type: 'trigger', idx: Number(event.currentTarget.dataset.idx) };
|
||||
},
|
||||
'dragstart .js-action-chip'(event, tpl) {
|
||||
tpl.dragItem = { type: 'action', idx: Number(event.currentTarget.dataset.idx) };
|
||||
},
|
||||
// Allow dropping by preventing the default (which disables drop) on dragover.
|
||||
'dragover .js-when-slot, dragover .js-then-slot, dragover .js-rule-node'(event) {
|
||||
event.preventDefault();
|
||||
},
|
||||
'drop .js-when-slot'(event, tpl) {
|
||||
event.preventDefault();
|
||||
if (tpl.dragItem && tpl.dragItem.type === 'trigger') {
|
||||
tpl.builderTrigger.set(TRIGGER_PALETTE[tpl.dragItem.idx]);
|
||||
}
|
||||
tpl.dragItem = null;
|
||||
},
|
||||
'drop .js-then-slot'(event, tpl) {
|
||||
event.preventDefault();
|
||||
if (tpl.dragItem && tpl.dragItem.type === 'action') {
|
||||
tpl.builderAction.set(ACTION_PALETTE[tpl.dragItem.idx]);
|
||||
}
|
||||
tpl.dragItem = null;
|
||||
},
|
||||
// Drop an action onto an existing rule to replace what it does.
|
||||
'drop .js-rule-node'(event, tpl) {
|
||||
event.preventDefault();
|
||||
if (!tpl.dragItem || tpl.dragItem.type !== 'action') return;
|
||||
const ruleId = event.currentTarget.dataset.ruleId;
|
||||
const rule = ReactiveCache.getRule(ruleId);
|
||||
if (!rule) return;
|
||||
const a = ACTION_PALETTE[tpl.dragItem.idx];
|
||||
const boardId = Session.get('currentBoard');
|
||||
const oldActionId = rule.actionId;
|
||||
const newActionId = Actions.insert({ ...a.doc, boardId, desc: a.label });
|
||||
Rules.update(ruleId, { $set: { actionId: newActionId } });
|
||||
if (oldActionId) Actions.remove(oldActionId);
|
||||
tpl.dragItem = null;
|
||||
},
|
||||
'click .js-clear-when'(event, tpl) {
|
||||
event.preventDefault();
|
||||
tpl.builderTrigger.set(null);
|
||||
},
|
||||
'click .js-clear-then'(event, tpl) {
|
||||
event.preventDefault();
|
||||
tpl.builderAction.set(null);
|
||||
},
|
||||
'click .js-create-workflow-rule'(event, tpl) {
|
||||
event.preventDefault();
|
||||
persistRule(tpl);
|
||||
},
|
||||
'click .js-delete-workflow-rule'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const node = event.currentTarget.closest('.js-rule-node');
|
||||
const ruleId = node && node.dataset.ruleId;
|
||||
const rule = ReactiveCache.getRule(ruleId);
|
||||
if (!rule) return;
|
||||
Rules.remove(rule._id);
|
||||
Actions.remove(rule.actionId);
|
||||
Triggers.remove(rule.triggerId);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
template(name="buttonTriggers")
|
||||
div.trigger-item#trigger-button
|
||||
div.trigger-content
|
||||
div.trigger-text
|
||||
| {{_'r-button-label'}}
|
||||
div.trigger-dropdown
|
||||
input#button-label(type=text placeholder="{{_'r-button-label'}}")
|
||||
div.trigger-dropdown
|
||||
select#button-type.js-button-type
|
||||
option(value="card" selected="selected") {{_'r-card-button'}}
|
||||
option(value="board") {{_'r-board-button'}}
|
||||
div.trigger-button.js-add-button-trigger.js-goto-action
|
||||
i.fa.fa-plus
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Utils } from '/client/lib/utils';
|
||||
|
||||
// Button "triggers" are manual: the rule runs only when a user clicks the button
|
||||
// (rendered on the card detail for card buttons, or the board for board buttons).
|
||||
// They are never matched against activities; the rules.runButton method runs them.
|
||||
|
||||
Template.buttonTriggers.events({
|
||||
'click .js-add-button-trigger'(event, tpl) {
|
||||
const datas = Template.currentData();
|
||||
const desc = Utils.getTriggerActionDesc(event, tpl);
|
||||
const boardId = Session.get('currentBoard');
|
||||
const buttonLabel = tpl.find('#button-label').value || 'Run';
|
||||
const buttonType = tpl.find('#button-type').value || 'card';
|
||||
datas.triggerVar.set({
|
||||
activityType: 'button',
|
||||
buttonType,
|
||||
buttonLabel,
|
||||
boardId,
|
||||
desc,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
template(name="scheduledTriggers")
|
||||
div.trigger-item#trigger-scheduled
|
||||
div.trigger-content
|
||||
div.trigger-text
|
||||
| {{_'r-when-scheduled'}}
|
||||
div.trigger-text
|
||||
| {{_'r-schedule-type'}}
|
||||
div.trigger-dropdown
|
||||
select#schedule-type.js-schedule-type
|
||||
option(value="once") {{_'r-schedule-once'}}
|
||||
option(value="daily" selected="selected") {{_'r-schedule-daily'}}
|
||||
option(value="weekday") {{_'r-schedule-weekday'}}
|
||||
option(value="weekly") {{_'r-schedule-weekly'}}
|
||||
option(value="monthly") {{_'r-schedule-monthly'}}
|
||||
div.trigger-text
|
||||
| {{_'r-schedule-at-time'}}
|
||||
div.trigger-dropdown
|
||||
input#schedule-time(type="time" value="09:00")
|
||||
div.trigger-text
|
||||
| {{_'r-schedule-on-weekday'}}
|
||||
div.trigger-dropdown
|
||||
select#schedule-weekday
|
||||
option(value="1" selected="selected") {{_'monday'}}
|
||||
option(value="2") {{_'tuesday'}}
|
||||
option(value="3") {{_'wednesday'}}
|
||||
option(value="4") {{_'thursday'}}
|
||||
option(value="5") {{_'friday'}}
|
||||
option(value="6") {{_'saturday'}}
|
||||
option(value="0") {{_'sunday'}}
|
||||
div.trigger-text
|
||||
| {{_'r-schedule-on-day'}}
|
||||
div.trigger-dropdown
|
||||
input#schedule-dom(type="number" min="1" max="31" value="1")
|
||||
div.trigger-text
|
||||
| {{_'r-schedule-on-date'}}
|
||||
div.trigger-dropdown
|
||||
input#schedule-date(type="date")
|
||||
div.trigger-text
|
||||
| {{_'r-of-cards-in-list'}}
|
||||
div.trigger-dropdown
|
||||
input#schedule-list-name(type=text placeholder="{{_'r-list-name'}}")
|
||||
div.trigger-button.js-add-scheduled-trigger.js-goto-action
|
||||
i.fa.fa-plus
|
||||
|
||||
div.trigger-item#trigger-due
|
||||
div.trigger-content
|
||||
div.trigger-text
|
||||
| {{_'r-when-due'}}
|
||||
div.trigger-dropdown
|
||||
select#due-condition.js-due-condition
|
||||
option(value="set") {{_'r-due-is-set'}}
|
||||
option(value="soon" selected="selected") {{_'r-due-soon'}}
|
||||
option(value="overdue") {{_'r-due-overdue'}}
|
||||
div.trigger-dropdown
|
||||
input#due-days(type="number" min="0" value="1")
|
||||
div.trigger-text
|
||||
| {{_'r-days-before'}} / {{_'r-days-after'}}
|
||||
div.trigger-text
|
||||
| {{_'r-schedule-at-time'}}
|
||||
div.trigger-dropdown
|
||||
input#due-time(type="time" value="09:00")
|
||||
div.trigger-button.js-add-due-trigger.js-goto-action
|
||||
i.fa.fa-plus
|
||||
|
||||
div.trigger-item#trigger-aging
|
||||
div.trigger-content
|
||||
div.trigger-text
|
||||
| {{_'r-when-card-in-list'}}
|
||||
div.trigger-dropdown
|
||||
input#aging-list-name(type=text placeholder="{{_'r-list-name'}}")
|
||||
div.trigger-text
|
||||
| {{_'r-for-n-days'}}
|
||||
div.trigger-dropdown
|
||||
input#aging-days(type="number" min="1" value="7")
|
||||
div.trigger-text
|
||||
| {{_'r-schedule-at-time'}}
|
||||
div.trigger-dropdown
|
||||
input#aging-time(type="time" value="09:00")
|
||||
div.trigger-button.js-add-aging-trigger.js-goto-action
|
||||
i.fa.fa-plus
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Utils } from '/client/lib/utils';
|
||||
|
||||
// All triggers here are time-based: they are evaluated by the server SyncedCron
|
||||
// scanner in server/scheduledRules.js rather than by activity matching. They all
|
||||
// share activityType 'scheduledTrigger' with a `scheduleKind` discriminator.
|
||||
|
||||
Template.scheduledTriggers.events({
|
||||
'click .js-add-scheduled-trigger'(event, tpl) {
|
||||
const datas = Template.currentData();
|
||||
const desc = Utils.getTriggerActionDesc(event, tpl);
|
||||
const boardId = Session.get('currentBoard');
|
||||
const scheduleType = tpl.find('#schedule-type').value;
|
||||
const atTime = tpl.find('#schedule-time').value || '09:00';
|
||||
const weekday = parseInt(tpl.find('#schedule-weekday').value, 10);
|
||||
const dayOfMonth = parseInt(tpl.find('#schedule-dom').value, 10) || 1;
|
||||
const onDate = tpl.find('#schedule-date').value || '';
|
||||
const listName = tpl.find('#schedule-list-name').value || '*';
|
||||
datas.triggerVar.set({
|
||||
activityType: 'scheduledTrigger',
|
||||
scheduleKind: 'calendar',
|
||||
scheduleType,
|
||||
atTime,
|
||||
weekday,
|
||||
dayOfMonth,
|
||||
onDate,
|
||||
listName,
|
||||
swimlaneName: '*',
|
||||
boardId,
|
||||
desc,
|
||||
});
|
||||
},
|
||||
'click .js-add-due-trigger'(event, tpl) {
|
||||
const datas = Template.currentData();
|
||||
const desc = Utils.getTriggerActionDesc(event, tpl);
|
||||
const boardId = Session.get('currentBoard');
|
||||
const dueCondition = tpl.find('#due-condition').value;
|
||||
const days = parseInt(tpl.find('#due-days').value, 10) || 0;
|
||||
const atTime = tpl.find('#due-time').value || '09:00';
|
||||
datas.triggerVar.set({
|
||||
activityType: 'scheduledTrigger',
|
||||
scheduleKind: 'due',
|
||||
dueCondition,
|
||||
days,
|
||||
atTime,
|
||||
listName: '*',
|
||||
swimlaneName: '*',
|
||||
boardId,
|
||||
desc,
|
||||
});
|
||||
},
|
||||
'click .js-add-aging-trigger'(event, tpl) {
|
||||
const datas = Template.currentData();
|
||||
const desc = Utils.getTriggerActionDesc(event, tpl);
|
||||
const boardId = Session.get('currentBoard');
|
||||
const listName = tpl.find('#aging-list-name').value || '*';
|
||||
const days = parseInt(tpl.find('#aging-days').value, 10) || 7;
|
||||
const atTime = tpl.find('#aging-time').value || '09:00';
|
||||
datas.triggerVar.set({
|
||||
activityType: 'scheduledTrigger',
|
||||
scheduleKind: 'aging',
|
||||
days,
|
||||
atTime,
|
||||
listName,
|
||||
swimlaneName: '*',
|
||||
boardId,
|
||||
desc,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -125,6 +125,13 @@ template(name="attachments")
|
||||
each attachmentTransferLimitUnitOptions 'apiDownloadMaxBytes'
|
||||
option(value=value selected=selected) {{_ labelKey}}
|
||||
|
||||
li
|
||||
.title {{_ 'avatars-upload-blocked-label'}}
|
||||
.form-group
|
||||
label
|
||||
input.js-avatars-upload-blocked(type="checkbox" checked=avatarsUploadBlocked)
|
||||
| {{_ 'avatars-upload-blocked-description'}}
|
||||
|
||||
li
|
||||
button.js-save-attachment-transfer-limits.primary {{_ 'save'}}
|
||||
else if isFilesystemActive
|
||||
@@ -474,7 +481,7 @@ template(name="moveAttachments")
|
||||
.form-group
|
||||
label {{_ 'move-scope'}}
|
||||
select.wekan-form-control.js-move-scope
|
||||
option(value="attachments") {{_ 'move-scope-attachments'}}
|
||||
option(value="attachments") {{_ 'attachments'}}
|
||||
option(value="avatars") {{_ 'move-scope-avatars'}}
|
||||
option(value="both") {{_ 'move-scope-both'}}
|
||||
.form-group
|
||||
|
||||
@@ -82,6 +82,7 @@ const DEFAULT_LIMIT_SETTINGS = {
|
||||
attachmentsDownloadMaxBytes: 0,
|
||||
apiUploadMaxBytes: 0,
|
||||
apiDownloadMaxBytes: 0,
|
||||
avatarsUploadBlocked: false,
|
||||
};
|
||||
|
||||
function toNonNegativeInteger(value, fallback = 0) {
|
||||
@@ -114,6 +115,7 @@ function normalizeLimitSettings(settingsDoc) {
|
||||
? toNonNegativeInteger(fromDoc.apiDownloadMaxBytes, DEFAULT_LIMIT_SETTINGS.apiDownloadMaxBytes)
|
||||
: DEFAULT_LIMIT_SETTINGS.apiDownloadMaxBytes,
|
||||
attachmentsUploadBlocked: fromDoc.attachmentsUploadBlocked === true,
|
||||
avatarsUploadBlocked: fromDoc.avatarsUploadBlocked === true,
|
||||
attachmentsDownloadBlocked: fromDoc.attachmentsDownloadBlocked === true,
|
||||
apiUploadBlocked: fromDoc.apiUploadBlocked === true,
|
||||
apiDownloadBlocked: fromDoc.apiDownloadBlocked === true,
|
||||
@@ -296,6 +298,10 @@ Template.attachments.helpers({
|
||||
isGcsActive() {
|
||||
return Template.instance().activeSection.get() === 'gcs';
|
||||
},
|
||||
avatarsUploadBlocked() {
|
||||
const settings = Template.instance().attachmentStorageSettings.get();
|
||||
return settings?.limitSettings?.avatarsUploadBlocked === true;
|
||||
},
|
||||
defaultStorageOptions() {
|
||||
const settings = Template.instance().attachmentStorageSettings.get();
|
||||
const selected = settings?.defaultStorage || 'fs';
|
||||
@@ -631,6 +637,10 @@ Template.attachments.events({
|
||||
nextLimitSettings[field.fieldName] = bytesValue;
|
||||
}
|
||||
|
||||
// Avatar uploads are a simple on/off block (no size mode). Default off so
|
||||
// avatars stay enabled unless an admin explicitly blocks them.
|
||||
nextLimitSettings.avatarsUploadBlocked = tpl.$('.js-avatars-upload-blocked').is(':checked');
|
||||
|
||||
const nextSettings = {
|
||||
...currentSettings,
|
||||
uploadSettings: {
|
||||
|
||||
@@ -68,6 +68,11 @@ template(name="people")
|
||||
i.fa.fa-key
|
||||
unless isMiniScreen
|
||||
| {{_ 'roles'}}
|
||||
else if templatesSetting.get
|
||||
span
|
||||
i.fa.fa-clone
|
||||
unless isMiniScreen
|
||||
| {{_ 'shared-templates'}}
|
||||
|
||||
.content-body
|
||||
.side-menu
|
||||
@@ -93,6 +98,14 @@ template(name="people")
|
||||
a.js-roles-menu(data-id="roles-setting")
|
||||
i.fa.fa-key
|
||||
| {{_ 'roles'}}
|
||||
li
|
||||
a.js-templates-menu(data-id="templates-setting")
|
||||
i.fa.fa-clone
|
||||
| {{_ 'shared-templates'}}
|
||||
li
|
||||
a.js-domains-menu(data-id="domains-setting")
|
||||
i.fa.fa-at
|
||||
| {{_ 'domains'}}
|
||||
.main-body
|
||||
if loading.get
|
||||
+spinner
|
||||
@@ -106,6 +119,10 @@ template(name="people")
|
||||
+lockedUsersGeneral
|
||||
else if rolesSetting.get
|
||||
+rolesGeneral
|
||||
else if templatesSetting.get
|
||||
+templatesGeneral
|
||||
else if domainSetting.get
|
||||
+domainGeneral(domainList=domainList)
|
||||
|
||||
|
||||
template(name="orgGeneral")
|
||||
@@ -126,6 +143,24 @@ template(name="orgGeneral")
|
||||
th {{_ 'website'}}
|
||||
th {{_ 'createdAt'}}
|
||||
th {{_ 'active-org'}}
|
||||
th
|
||||
| {{_ 'org-shared-templates'}}
|
||||
br
|
||||
a.js-org-feature-all(href="#", data-feature="orgSharedTemplates", data-value="true") {{_ 'select-all'}}
|
||||
| /
|
||||
a.js-org-feature-all(href="#", data-feature="orgSharedTemplates", data-value="false") {{_ 'unselect-all'}}
|
||||
th
|
||||
| {{_ 'org-propagate-members-to-boards'}}
|
||||
br
|
||||
a.js-org-feature-all(href="#", data-feature="orgPropagateMembersToBoards", data-value="true") {{_ 'select-all'}}
|
||||
| /
|
||||
a.js-org-feature-all(href="#", data-feature="orgPropagateMembersToBoards", data-value="false") {{_ 'unselect-all'}}
|
||||
th
|
||||
| {{_ 'org-sync-members-from-auth'}}
|
||||
br
|
||||
a.js-org-feature-all(href="#", data-feature="orgSyncMembersFromAuth", data-value="true") {{_ 'select-all'}}
|
||||
| /
|
||||
a.js-org-feature-all(href="#", data-feature="orgSyncMembersFromAuth", data-value="false") {{_ 'unselect-all'}}
|
||||
tbody
|
||||
tr
|
||||
each org in orgList
|
||||
@@ -149,6 +184,24 @@ template(name="teamGeneral")
|
||||
th {{_ 'website'}}
|
||||
th {{_ 'createdAt'}}
|
||||
th {{_ 'active-team'}}
|
||||
th
|
||||
| {{_ 'team-shared-templates'}}
|
||||
br
|
||||
a.js-team-feature-all(href="#", data-feature="teamSharedTemplates", data-value="true") {{_ 'select-all'}}
|
||||
| /
|
||||
a.js-team-feature-all(href="#", data-feature="teamSharedTemplates", data-value="false") {{_ 'unselect-all'}}
|
||||
th
|
||||
| {{_ 'team-propagate-members-to-boards'}}
|
||||
br
|
||||
a.js-team-feature-all(href="#", data-feature="teamPropagateMembersToBoards", data-value="true") {{_ 'select-all'}}
|
||||
| /
|
||||
a.js-team-feature-all(href="#", data-feature="teamPropagateMembersToBoards", data-value="false") {{_ 'unselect-all'}}
|
||||
th
|
||||
| {{_ 'team-sync-members-from-auth'}}
|
||||
br
|
||||
a.js-team-feature-all(href="#", data-feature="teamSyncMembersFromAuth", data-value="true") {{_ 'select-all'}}
|
||||
| /
|
||||
a.js-team-feature-all(href="#", data-feature="teamSyncMembersFromAuth", data-value="false") {{_ 'unselect-all'}}
|
||||
tbody
|
||||
tr
|
||||
each team in teamList
|
||||
@@ -185,6 +238,22 @@ template(name="selectAllUser")
|
||||
| {{_ 'dueCardsViewChange-choice-all'}}
|
||||
input.allUserChkBox(type="checkbox", id="chkSelectAll")
|
||||
|
||||
template(name="domainGeneral")
|
||||
table
|
||||
thead
|
||||
tr
|
||||
th {{_ 'domain'}}
|
||||
th {{_ 'domain-user-count'}}
|
||||
tbody
|
||||
if domainList.length
|
||||
each domainList
|
||||
tr
|
||||
td {{ domain }}
|
||||
td {{ count }}
|
||||
else
|
||||
tr
|
||||
td(colspan="2") {{_ 'no-items-message'}}
|
||||
|
||||
template(name="newOrgRow")
|
||||
a.new-org
|
||||
i.fa.fa-plus
|
||||
@@ -233,6 +302,12 @@ template(name="orgRow")
|
||||
| {{_ 'yes'}}
|
||||
else
|
||||
| {{_ 'no'}}
|
||||
td.text-center
|
||||
input.js-toggle-org-feature(type="checkbox", data-feature="orgSharedTemplates", checked=orgData.orgSharedTemplates)
|
||||
td.text-center
|
||||
input.js-toggle-org-feature(type="checkbox", data-feature="orgPropagateMembersToBoards", checked=orgData.orgPropagateMembersToBoards)
|
||||
td.text-center
|
||||
input.js-toggle-org-feature(type="checkbox", data-feature="orgSyncMembersFromAuth", checked=orgData.orgSyncMembersFromAuth)
|
||||
|
||||
template(name="teamRow")
|
||||
tr
|
||||
@@ -267,6 +342,12 @@ template(name="teamRow")
|
||||
| {{_ 'yes'}}
|
||||
else
|
||||
| {{_ 'no'}}
|
||||
td.text-center
|
||||
input.js-toggle-team-feature(type="checkbox", data-feature="teamSharedTemplates", checked=teamData.teamSharedTemplates)
|
||||
td.text-center
|
||||
input.js-toggle-team-feature(type="checkbox", data-feature="teamPropagateMembersToBoards", checked=teamData.teamPropagateMembersToBoards)
|
||||
td.text-center
|
||||
input.js-toggle-team-feature(type="checkbox", data-feature="teamSyncMembersFromAuth", checked=teamData.teamSyncMembersFromAuth)
|
||||
|
||||
template(name="peopleRow")
|
||||
tr
|
||||
@@ -749,3 +830,52 @@ template(name="rolesGeneral")
|
||||
span {{_ label}}
|
||||
|
||||
button.js-roles-save.primary {{_ 'save'}}
|
||||
|
||||
template(name="templatesGeneral")
|
||||
.templates-settings
|
||||
h3 {{_ 'shared-templates'}}
|
||||
p.templates-description {{_ 'shared-templates-info'}}
|
||||
|
||||
ul#templates-setting.setting-detail
|
||||
li
|
||||
a.flex.js-toggle-template-scope(data-scope="organizations")
|
||||
.materialCheckBox(class="{{#if scopeChecked 'organizations'}}is-checked{{/if}}")
|
||||
span {{_ 'organizations'}}
|
||||
li
|
||||
a.flex.js-toggle-template-scope(data-scope="teams")
|
||||
.materialCheckBox(class="{{#if scopeChecked 'teams'}}is-checked{{/if}}")
|
||||
span {{_ 'teams'}}
|
||||
li
|
||||
a.flex.js-toggle-template-scope(data-scope="domains")
|
||||
.materialCheckBox(class="{{#if scopeChecked 'domains'}}is-checked{{/if}}")
|
||||
span {{_ 'domains'}}
|
||||
|
||||
.shared-templates-results
|
||||
if loading.get
|
||||
+spinner
|
||||
else if hasAnyScope
|
||||
each scopeBlock in scopeBlocks
|
||||
h4 {{_ scopeBlock.scopeLabel}}
|
||||
if scopeBlock.groups.length
|
||||
each group in scopeBlock.groups
|
||||
.shared-templates-group
|
||||
h5 {{group.groupName}}
|
||||
ul.setting-detail
|
||||
each member in group.members
|
||||
li
|
||||
span.shared-templates-user {{member.label}}
|
||||
ul.shared-templates-boards
|
||||
each board in member.templateBoards
|
||||
li
|
||||
if board.boardId
|
||||
a.js-shared-template-link(href="{{board.url}}" data-board-id="{{board.boardId}}" data-slug="{{board.slug}}")
|
||||
i.fa.fa-clone
|
||||
| {{board.title}}
|
||||
else
|
||||
span
|
||||
i.fa.fa-clone
|
||||
| {{board.title}}
|
||||
else
|
||||
p.shared-templates-none {{_ 'no-shared-templates'}}
|
||||
else
|
||||
p.shared-templates-hint {{_ 'shared-templates-select-scope'}}
|
||||
@@ -27,6 +27,10 @@ Template.people.onCreated(function () {
|
||||
this.peopleSetting = new ReactiveVar(false);
|
||||
this.lockedUsersSetting = new ReactiveVar(false);
|
||||
this.rolesSetting = new ReactiveVar(false);
|
||||
this.templatesSetting = new ReactiveVar(false);
|
||||
// #5850: Admin Panel > People > Domains tab.
|
||||
this.domainSetting = new ReactiveVar(false);
|
||||
this.domains = new ReactiveVar([]);
|
||||
this.subscribe('inviteToBoardRolesSettings');
|
||||
this.findOrgsOptions = new ReactiveVar({});
|
||||
this.findTeamsOptions = new ReactiveVar({});
|
||||
@@ -198,6 +202,15 @@ Template.people.onCreated(function () {
|
||||
this.peopleSetting.set('people-setting' === targetID);
|
||||
this.lockedUsersSetting.set('locked-users-setting' === targetID);
|
||||
this.rolesSetting.set('roles-setting' === targetID);
|
||||
this.templatesSetting.set('templates-setting' === targetID);
|
||||
this.domainSetting.set('domains-setting' === targetID);
|
||||
|
||||
// #5850: load the domains + per-domain user counts when the tab opens.
|
||||
if ('domains-setting' === targetID) {
|
||||
Meteor.call('getDomainsWithUserCounts', (err, res) => {
|
||||
if (!err) this.domains.set(res || []);
|
||||
});
|
||||
}
|
||||
|
||||
// When switching to locked users tab, refresh the locked users list
|
||||
if ('locked-users-setting' === targetID) {
|
||||
@@ -273,6 +286,15 @@ Template.people.helpers({
|
||||
rolesSetting() {
|
||||
return Template.instance().rolesSetting;
|
||||
},
|
||||
templatesSetting() {
|
||||
return Template.instance().templatesSetting;
|
||||
},
|
||||
domainSetting() {
|
||||
return Template.instance().domainSetting;
|
||||
},
|
||||
domainList() {
|
||||
return Template.instance().domains.get();
|
||||
},
|
||||
orgList() {
|
||||
const tpl = Template.instance();
|
||||
// The 'org' publication already returns only the current page (server-side
|
||||
@@ -289,6 +311,9 @@ Template.people.helpers({
|
||||
orgWebsite: 1,
|
||||
createdAt: 1,
|
||||
orgIsActive: 1,
|
||||
orgSharedTemplates: 1,
|
||||
orgPropagateMembersToBoards: 1,
|
||||
orgSyncMembersFromAuth: 1,
|
||||
},
|
||||
}).fetch();
|
||||
return orgs;
|
||||
@@ -309,6 +334,9 @@ Template.people.helpers({
|
||||
teamWebsite: 1,
|
||||
createdAt: 1,
|
||||
teamIsActive: 1,
|
||||
teamSharedTemplates: 1,
|
||||
teamPropagateMembersToBoards: 1,
|
||||
teamSyncMembersFromAuth: 1,
|
||||
},
|
||||
}).fetch();
|
||||
return teams;
|
||||
@@ -453,6 +481,20 @@ Template.people.events({
|
||||
tpl.peoplePage.set(current + 1);
|
||||
}
|
||||
},
|
||||
// #4737/#5850: select-all / unselect-all for an org feature column.
|
||||
'click .js-org-feature-all'(event) {
|
||||
event.preventDefault();
|
||||
const field = event.currentTarget.getAttribute('data-feature');
|
||||
const value = event.currentTarget.getAttribute('data-value') === 'true';
|
||||
Meteor.call('setAllOrgsFeature', field, value);
|
||||
},
|
||||
// #4737/#5850: select-all / unselect-all for a team feature column.
|
||||
'click .js-team-feature-all'(event) {
|
||||
event.preventDefault();
|
||||
const field = event.currentTarget.getAttribute('data-feature');
|
||||
const value = event.currentTarget.getAttribute('data-value') === 'true';
|
||||
Meteor.call('setAllTeamsFeature', field, value);
|
||||
},
|
||||
'click .js-org-prev-page'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const current = tpl.orgPage.get();
|
||||
@@ -538,6 +580,12 @@ Template.people.events({
|
||||
'click a.js-roles-menu'(event, tpl) {
|
||||
tpl.switchMenu(event);
|
||||
},
|
||||
'click a.js-templates-menu'(event, tpl) {
|
||||
tpl.switchMenu(event);
|
||||
},
|
||||
'click a.js-domains-menu'(event, tpl) {
|
||||
tpl.switchMenu(event);
|
||||
},
|
||||
});
|
||||
|
||||
Template.rolesGeneral.onCreated(function () {
|
||||
@@ -597,6 +645,143 @@ Template.rolesGeneral.events({
|
||||
},
|
||||
});
|
||||
|
||||
// Feature #3313 "Shared templates": admin view of users' shareable template
|
||||
// boards, grouped by Organization / Team / email Domain. The three checkboxes
|
||||
// are LIVE view filters (no Save button) — each toggles a grouping dimension and
|
||||
// the selection is remembered (persisted in localStorage) so it survives reload.
|
||||
const SHARED_TEMPLATES_SCOPE_KEY = 'sharedTemplatesScopes';
|
||||
const SHARED_TEMPLATES_VALID_SCOPES = ['organizations', 'teams', 'domains'];
|
||||
|
||||
function loadSharedTemplatesScopes() {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SHARED_TEMPLATES_SCOPE_KEY);
|
||||
const arr = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(arr)
|
||||
? arr.filter(s => SHARED_TEMPLATES_VALID_SCOPES.includes(s))
|
||||
: [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveSharedTemplatesScopes(scopes) {
|
||||
try {
|
||||
window.localStorage.setItem(SHARED_TEMPLATES_SCOPE_KEY, JSON.stringify(scopes));
|
||||
} catch (e) {
|
||||
// ignore storage errors (e.g. private mode)
|
||||
}
|
||||
}
|
||||
|
||||
Template.templatesGeneral.onCreated(function () {
|
||||
// Restore the previously-checked scopes (empty by default → nothing shown).
|
||||
this.selectedScopes = new ReactiveVar(loadSharedTemplatesScopes());
|
||||
// Raw rows returned by the admin-only method (one entry per user whose
|
||||
// Templates board is non-empty).
|
||||
this.sharedTemplates = new ReactiveVar([]);
|
||||
this.loading = new ReactiveVar(false);
|
||||
|
||||
this.loadSharedTemplates = () => {
|
||||
this.loading.set(true);
|
||||
Meteor.call('adminSharedTemplates', (error, result) => {
|
||||
this.loading.set(false);
|
||||
if (error) {
|
||||
console.error('Failed to load shared templates:', error);
|
||||
this.sharedTemplates.set([]);
|
||||
return;
|
||||
}
|
||||
this.sharedTemplates.set(result || []);
|
||||
});
|
||||
};
|
||||
|
||||
this.loadSharedTemplates();
|
||||
});
|
||||
|
||||
const SCOPE_LABELS = {
|
||||
organizations: 'organizations',
|
||||
teams: 'teams',
|
||||
domains: 'domains',
|
||||
};
|
||||
|
||||
// Build the grouped structure for a single scope dimension.
|
||||
function buildScopeGroups(scope, rows) {
|
||||
// group key -> { groupName, members: [] }
|
||||
const groups = {};
|
||||
|
||||
const addToGroup = (key, name, row) => {
|
||||
if (!groups[key]) {
|
||||
groups[key] = { groupKey: key, groupName: name, members: [] };
|
||||
}
|
||||
groups[key].members.push({
|
||||
userId: row.userId,
|
||||
label: row.fullname ? `${row.fullname} (${row.username})` : row.username,
|
||||
templateBoards: row.templateBoards.map(b => ({
|
||||
title: b.title,
|
||||
boardId: b.boardId,
|
||||
slug: b.slug,
|
||||
url: b.boardId ? `/b/${b.boardId}/${b.slug || 'template'}` : '',
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
rows.forEach(row => {
|
||||
if (scope === 'organizations') {
|
||||
(row.orgs || []).forEach(o => {
|
||||
if (o.orgId) addToGroup(o.orgId, o.orgDisplayName || o.orgId, row);
|
||||
});
|
||||
} else if (scope === 'teams') {
|
||||
(row.teams || []).forEach(t => {
|
||||
if (t.teamId) addToGroup(t.teamId, t.teamDisplayName || t.teamId, row);
|
||||
});
|
||||
} else if (scope === 'domains') {
|
||||
(row.domains || []).forEach(d => {
|
||||
if (d) addToGroup(d, d, row);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Object.values(groups).sort((a, b) =>
|
||||
String(a.groupName).localeCompare(String(b.groupName)),
|
||||
);
|
||||
}
|
||||
|
||||
Template.templatesGeneral.helpers({
|
||||
loading() {
|
||||
return Template.instance().loading;
|
||||
},
|
||||
scopeChecked(scope) {
|
||||
return Template.instance().selectedScopes.get().includes(scope);
|
||||
},
|
||||
hasAnyScope() {
|
||||
return Template.instance().selectedScopes.get().length > 0;
|
||||
},
|
||||
scopeBlocks() {
|
||||
const tpl = Template.instance();
|
||||
const scopes = tpl.selectedScopes.get();
|
||||
const rows = tpl.sharedTemplates.get() || [];
|
||||
return scopes.map(scope => ({
|
||||
scope,
|
||||
scopeLabel: SCOPE_LABELS[scope] || scope,
|
||||
groups: buildScopeGroups(scope, rows),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
Template.templatesGeneral.events({
|
||||
'click a.js-toggle-template-scope'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const scope = $(event.currentTarget).data('scope');
|
||||
const current = tpl.selectedScopes.get().slice();
|
||||
const idx = current.indexOf(scope);
|
||||
if (idx >= 0) {
|
||||
current.splice(idx, 1);
|
||||
} else {
|
||||
current.push(scope);
|
||||
}
|
||||
tpl.selectedScopes.set(current);
|
||||
saveSharedTemplatesScopes(current);
|
||||
},
|
||||
});
|
||||
|
||||
Template.orgRow.helpers({
|
||||
orgData() {
|
||||
return this.org || ReactiveCache.getOrg(this.orgId);
|
||||
@@ -822,14 +1007,44 @@ Template.newUserPopup.helpers({
|
||||
},
|
||||
});
|
||||
|
||||
const ORG_FEATURE_METHODS = {
|
||||
orgSharedTemplates: 'setOrgSharedTemplates',
|
||||
orgPropagateMembersToBoards: 'setOrgPropagateMembersToBoards',
|
||||
orgSyncMembersFromAuth: 'setOrgSyncMembersFromAuth',
|
||||
};
|
||||
|
||||
const TEAM_FEATURE_METHODS = {
|
||||
teamSharedTemplates: 'setTeamSharedTemplates',
|
||||
teamPropagateMembersToBoards: 'setTeamPropagateMembersToBoards',
|
||||
teamSyncMembersFromAuth: 'setTeamSyncMembersFromAuth',
|
||||
};
|
||||
|
||||
Template.orgRow.events({
|
||||
'click a.edit-org': Popup.open('editOrg'),
|
||||
'click a.more-settings-org': Popup.open('settingsOrg'),
|
||||
// #4737/#5850: per-org feature checkbox columns.
|
||||
'change .js-toggle-org-feature'(event) {
|
||||
const org = this.org || ReactiveCache.getOrg(this.orgId);
|
||||
const field = event.currentTarget.getAttribute('data-feature');
|
||||
const method = ORG_FEATURE_METHODS[field];
|
||||
if (org && method) {
|
||||
Meteor.call(method, { _id: org._id }, event.currentTarget.checked);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Template.teamRow.events({
|
||||
'click a.edit-team': Popup.open('editTeam'),
|
||||
'click a.more-settings-team': Popup.open('settingsTeam'),
|
||||
// #4737/#5850: per-team feature checkbox columns.
|
||||
'change .js-toggle-team-feature'(event) {
|
||||
const team = this.team || ReactiveCache.getTeam(this.teamId);
|
||||
const field = event.currentTarget.getAttribute('data-feature');
|
||||
const method = TEAM_FEATURE_METHODS[field];
|
||||
if (team && method) {
|
||||
Meteor.call(method, { _id: team._id }, event.currentTarget.checked);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Template.peopleRow.events({
|
||||
|
||||
@@ -19,14 +19,14 @@ template(name="sidebar")
|
||||
a.sidebar-accessibility
|
||||
i.fa.fa-universal-access
|
||||
span {{_ 'accessibility'}}
|
||||
a.sidebar-xmark.js-close-sidebar ✕
|
||||
a.sidebar-xmark.js-close-sidebar(aria-label="{{_ 'close'}}") ✕
|
||||
.sidebar-content.js-board-sidebar-content
|
||||
//a.hide-btn.js-hide-sidebar
|
||||
//
|
||||
i.fa.fa-navicon
|
||||
unless isDefaultView
|
||||
h2
|
||||
a.fa.fa-arrow-left.js-back-home
|
||||
a.fa.fa-arrow-left.js-back-home(aria-label="{{_ 'go-back'}}")
|
||||
= getViewTitle
|
||||
if isOpen
|
||||
+Template.dynamic(template=getViewTemplate)
|
||||
@@ -76,23 +76,27 @@ template(name="membersWidget")
|
||||
unless currentUser.isWorker
|
||||
p.quiet
|
||||
.board-widget-content
|
||||
each currentBoard.activeMembers
|
||||
+userAvatar(userId=this.userId showStatus=true)
|
||||
if isSandstorm
|
||||
if currentUser.isBoardMember
|
||||
a.member.add-member.sandstorm-powerbox-request-identity(title="{{_ 'add-members'}}")
|
||||
if currentBoard.isAnyTemplateBoard
|
||||
+userAvatar(userId=currentBoard.userId showStatus=true)
|
||||
.clearfix
|
||||
else
|
||||
each currentBoard.activeMembers
|
||||
+userAvatar(userId=this.userId showStatus=true)
|
||||
if isSandstorm
|
||||
if currentUser.isBoardMember
|
||||
a.member.add-member.sandstorm-powerbox-request-identity(title="{{_ 'add-members'}}")
|
||||
i.fa.fa-plus
|
||||
else if currentUser.canInviteToBoard
|
||||
a.member.add-member.js-manage-board-members(title="{{_ 'add-members'}}")
|
||||
i.fa.fa-plus
|
||||
else if currentUser.canInviteToBoard
|
||||
a.member.add-member.js-manage-board-members(title="{{_ 'add-members'}}")
|
||||
i.fa.fa-plus
|
||||
.clearfix
|
||||
if isInvited
|
||||
hr
|
||||
p
|
||||
i.fa.fa-exclamation-triangle
|
||||
| {{_ 'just-invited'}}
|
||||
button.js-member-invite-accept.primary {{_ 'accept'}}
|
||||
button.js-member-invite-decline {{_ 'decline'}}
|
||||
.clearfix
|
||||
if isInvited
|
||||
hr
|
||||
p
|
||||
i.fa.fa-exclamation-triangle
|
||||
| {{_ 'just-invited'}}
|
||||
button.js-member-invite-accept.primary {{_ 'accept'}}
|
||||
button.js-member-invite-decline {{_ 'decline'}}
|
||||
+tabContent(slug='organizations')
|
||||
unless currentUser.isWorker
|
||||
p.quiet
|
||||
@@ -105,6 +109,12 @@ template(name="membersWidget")
|
||||
.board-widget-content
|
||||
+boardTeamGeneral
|
||||
.clearfix
|
||||
+tabContent(slug="domains")
|
||||
unless currentUser.isWorker
|
||||
p.quiet
|
||||
.board-widget-content
|
||||
+boardDomainGeneral
|
||||
.clearfix
|
||||
|
||||
template(name="boardOrgGeneral")
|
||||
each org in currentBoard.activeOrgs
|
||||
@@ -120,6 +130,18 @@ template(name="boardTeamGeneral")
|
||||
a.member.orgOrTeamMember.add-member.js-manage-board-addTeam(title="{{_ 'add-members'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
template(name="boardDomainGeneral")
|
||||
each currentBoard.activeDomains
|
||||
+boardDomainRow(domain=this.domain)
|
||||
if currentUser.isBoardAdmin
|
||||
a.member.orgOrTeamMember.add-member.js-manage-board-addDomain(title="{{_ 'add-domain'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
template(name="boardDomainRow")
|
||||
a.member.orgOrTeamMember.js-manage-board-removeDomain(title="{{domain}}" data-domain="{{domain}}")
|
||||
svg.avatar.avatar-initials(viewBox="0 0 {{domainViewPortWidth}} 15")
|
||||
text(x="50%" y="11" text-anchor="middle" dominant-baseline="middle" font-size="16")= domain
|
||||
|
||||
template(name="boardChangeColorPopup")
|
||||
.board-backgrounds-list.clearfix
|
||||
each backgroundColors
|
||||
@@ -187,6 +209,18 @@ template(name="boardInfoOnMyBoardsPopup")
|
||||
span
|
||||
i.fa.fa-sign-in
|
||||
| {{_ 'show-card-counter-per-list'}}
|
||||
div.check-div
|
||||
a.flex.js-field-has-cardaging(class="{{#if cardAging}}is-checked{{/if}}")
|
||||
i.fa(class="{{#if cardAging}}fa-check{{else}}fa-square-o{{/if}}")
|
||||
span
|
||||
i.fa.fa-clock-o
|
||||
| {{_ 'card-aging'}}
|
||||
if cardAging
|
||||
div.card-aging-days
|
||||
span {{_ 'card-aging-days'}}
|
||||
input.js-card-aging-days(type="number" min="1" value="{{cardAgingDays1}}" title="{{_ 'card-aging-tier1'}}")
|
||||
input.js-card-aging-days(type="number" min="1" value="{{cardAgingDays2}}" title="{{_ 'card-aging-tier2'}}")
|
||||
input.js-card-aging-days(type="number" min="1" value="{{cardAgingDays3}}" title="{{_ 'card-aging-tier3'}}")
|
||||
unless currentSetting.hideBoardMemberList
|
||||
div.check-div
|
||||
a.flex.js-field-has-boardmemberlist(class="{{#if allowsBoardMemberList}}is-checked{{/if}}")
|
||||
@@ -248,6 +282,17 @@ template(name="boardCardSettingsPopup")
|
||||
span
|
||||
i.fa.fa-clock-o
|
||||
| {{_ 'card-end'}}
|
||||
.card-settings-row
|
||||
.card-settings-column
|
||||
a.flex.js-field-has-duecomplete(title="{{_ 'card-mark-complete'}}" class="{{#if allowsDueComplete}}is-checked{{/if}}")
|
||||
i.fa(class="{{#if allowsDueComplete}}fa-check{{else}}fa-square-o{{/if}}")
|
||||
.card-settings-column
|
||||
a.flex.js-field-has-duecomplete-on-minicard(title="{{_ 'card-mark-complete'}}" class="{{#if allowsDueCompleteOnMinicard}}is-checked{{/if}}")
|
||||
i.fa(class="{{#if allowsDueCompleteOnMinicard}}fa-check{{else}}fa-square-o{{/if}}")
|
||||
.card-settings-column
|
||||
span
|
||||
i.fa.fa-check-square-o
|
||||
| {{_ 'card-mark-complete'}}
|
||||
.card-settings-row
|
||||
.card-settings-column
|
||||
a.flex.js-field-has-members(title="{{_ 'members'}}" class="{{#if allowsMembers}}is-checked{{/if}}")
|
||||
@@ -529,11 +574,50 @@ template(name="boardSubtaskSettingsPopup")
|
||||
template(name="chooseBoardSourcePopup")
|
||||
ul.pop-over-list
|
||||
li
|
||||
a(href="{{pathFor '/import/trello'}}") {{_ 'from-trello'}}
|
||||
a(href="{{pathFor '/import/trello'}}") {{_ 'import'}} / Trello
|
||||
li
|
||||
a(href="{{pathFor '/import/wekan'}}") {{_ 'from-wekan'}}
|
||||
a(href="{{pathFor '/import/wekan'}}") {{_ 'import'}} / JSON
|
||||
li
|
||||
a(href="{{pathFor '/import/csv'}}") {{_ 'from-csv'}}
|
||||
a(href="{{pathFor '/import/csv'}}") {{_ 'import'}} / CSV-TSV
|
||||
li
|
||||
a(href="{{pathFor '/import/excel'}}") {{_ 'import'}} / Excel
|
||||
li
|
||||
a(href="{{pathFor '/import/jira'}}") {{_ 'import'}} / Jira
|
||||
li
|
||||
a(href="{{pathFor '/import/kanboard'}}") {{_ 'import'}} / Kanboard
|
||||
li
|
||||
a(href="{{pathFor '/import/deck'}}") {{_ 'import'}} / NextCloud Deck
|
||||
li
|
||||
a(href="{{pathFor '/import/openproject'}}") {{_ 'import'}} / OpenProject
|
||||
li
|
||||
a(href="{{pathFor '/import/github'}}") {{_ 'import'}} / GitHub
|
||||
li
|
||||
a(href="{{pathFor '/import/gitlab'}}") {{_ 'import'}} / GitLab
|
||||
li
|
||||
a(href="{{pathFor '/import/gitea'}}") {{_ 'import'}} / Gitea
|
||||
li
|
||||
a(href="{{pathFor '/import/forgejo'}}") {{_ 'import'}} / Forgejo
|
||||
li
|
||||
a(href="{{pathFor '/import/asana'}}") {{_ 'import'}} / Asana
|
||||
li
|
||||
a(href="{{pathFor '/import/zenkit'}}") {{_ 'import'}} / Zenkit
|
||||
li
|
||||
a.js-import-dependencies {{_ 'import'}} / {{_ 'card-dependencies'}} (JSON/SVG)
|
||||
|
||||
template(name="importDependenciesPopup")
|
||||
form.js-import-dependencies-form
|
||||
label
|
||||
| {{_ 'r-board'}}
|
||||
select.js-import-dependencies-board
|
||||
each boardsForDependencyImport
|
||||
option(value="{{_id}}") {{title}}
|
||||
label
|
||||
| {{_ 'import-dependencies-file'}}
|
||||
input.js-import-dependencies-file(type="file" accept=".json,.svg,application/json,image/svg+xml")
|
||||
textarea.js-import-dependencies-text(rows="6" placeholder="{{_ 'import-dependencies-placeholder'}}")
|
||||
button.primary.confirm.js-import-dependencies-submit(type="submit") {{_ 'import'}}
|
||||
if importResult
|
||||
p.quiet.js-import-dependencies-result {{importResult}}
|
||||
|
||||
template(name="archiveBoardPopup")
|
||||
p {{_ 'close-board-pop'}}
|
||||
@@ -658,27 +742,87 @@ template(name="exportBoardPopup")
|
||||
li
|
||||
a.download-json-link(href="{{exportUrl}}", download="{{exportJsonFilename}}")
|
||||
i.fa.fa-upload
|
||||
| {{_ 'export-board-json'}}
|
||||
| {{_ 'export'}} / JSON
|
||||
li
|
||||
a.download-json-link(href="{{exportUrlNoAttachments}}", download="{{exportJsonFilename}}")
|
||||
i.fa.fa-upload
|
||||
| {{_ 'export'}} / JSON ({{_ 'export-board-without-attachments'}})
|
||||
li
|
||||
a(href="{{exportUrlExcel}}", download="{{exportFilenameExcel}}")
|
||||
i.fa.fa-upload
|
||||
| {{_ 'export-board-excel'}}
|
||||
| {{_ 'export'}} / Excel
|
||||
li
|
||||
a(href="{{exportCsvUrl}}", download="{{exportCsvFilename}}")
|
||||
i.fa.fa-upload
|
||||
| {{_ 'export-board-csv-comma'}}
|
||||
| {{_ 'export'}} / CSV (,)
|
||||
li
|
||||
a(href="{{exportScsvUrl}}", download="{{exportCsvFilename}}")
|
||||
i.fa.fa-upload
|
||||
| {{_ 'export-board-csv-semicolon'}}
|
||||
| {{_ 'export'}} / CSV (;)
|
||||
li
|
||||
a(href="{{exportTsvUrl}}", download="{{exportTsvFilename}}")
|
||||
i.fa.fa-upload
|
||||
| {{_ 'export-board-tsv'}}
|
||||
| {{_ 'export'}} / TSV
|
||||
li
|
||||
a(href="{{exportUrlPDF}}", download="{{exportFilenamePDF}}")
|
||||
i.fa.fa-upload
|
||||
| {{_ 'export'}} / PDF
|
||||
li
|
||||
a(href="{{exportUrlKanboard}}", download="{{exportFilenameKanboard}}")
|
||||
i.fa.fa-upload
|
||||
| {{_ 'export'}} / JSON / Kanboard
|
||||
li
|
||||
a(href="{{exportUrlExternal 'trello'}}", download="{{exportFilenameExternal 'trello'}}")
|
||||
i.fa.fa-upload
|
||||
| {{_ 'export'}} / JSON / Trello
|
||||
li
|
||||
a(href="{{exportUrlExternal 'jira'}}", download="{{exportFilenameExternal 'jira'}}")
|
||||
i.fa.fa-upload
|
||||
| {{_ 'export'}} / JSON / Jira
|
||||
li
|
||||
a(href="{{exportUrlExternal 'deck'}}", download="{{exportFilenameExternal 'deck'}}")
|
||||
i.fa.fa-upload
|
||||
| {{_ 'export'}} / JSON / NextCloud Deck
|
||||
li
|
||||
a(href="{{exportUrlExternal 'openproject'}}", download="{{exportFilenameExternal 'openproject'}}")
|
||||
i.fa.fa-upload
|
||||
| {{_ 'export'}} / JSON / OpenProject
|
||||
li
|
||||
a(href="{{exportUrlExternal 'github'}}", download="{{exportFilenameExternal 'github'}}")
|
||||
i.fa.fa-upload
|
||||
| {{_ 'export'}} / JSON / GitHub
|
||||
li
|
||||
a(href="{{exportUrlExternal 'gitlab'}}", download="{{exportFilenameExternal 'gitlab'}}")
|
||||
i.fa.fa-upload
|
||||
| {{_ 'export'}} / JSON / GitLab
|
||||
li
|
||||
a(href="{{exportUrlExternal 'gitea'}}", download="{{exportFilenameExternal 'gitea'}}")
|
||||
i.fa.fa-upload
|
||||
| {{_ 'export'}} / JSON / Gitea
|
||||
li
|
||||
a(href="{{exportUrlExternal 'forgejo'}}", download="{{exportFilenameExternal 'forgejo'}}")
|
||||
i.fa.fa-upload
|
||||
| {{_ 'export'}} / JSON / Forgejo
|
||||
li
|
||||
a(href="{{exportUrlExternal 'asana'}}", download="{{exportFilenameExternal 'asana'}}")
|
||||
i.fa.fa-upload
|
||||
| {{_ 'export'}} / JSON / Asana
|
||||
li
|
||||
a(href="{{exportUrlExternal 'zenkit'}}", download="{{exportFilenameExternal 'zenkit'}}")
|
||||
i.fa.fa-upload
|
||||
| {{_ 'export'}} / JSON / Zenkit
|
||||
li
|
||||
a.html-export-board
|
||||
i.fa.fa-archive
|
||||
| {{_ 'export-board-html'}}
|
||||
| {{_ 'export'}} / HTML
|
||||
li
|
||||
a.js-export-dependencies-json
|
||||
i.fa.fa-link
|
||||
| {{_ 'export'}} / {{_ 'card-dependencies'}} / JSON
|
||||
li
|
||||
a.js-export-dependencies-svg
|
||||
i.fa.fa-link
|
||||
| {{_ 'export'}} / {{_ 'card-dependencies'}} / SVG
|
||||
|
||||
template(name="labelsWidget")
|
||||
.board-widget.board-widget-labels
|
||||
@@ -766,6 +910,27 @@ template(name="removeBoardTeamPopup")
|
||||
input.primary.wide.leaveBoardBtn#leaveBoardTeamBtn(type="submit" value="{{_ 'confirm-btn'}}")
|
||||
input.primary.wide.cancelLeaveBoardBtn#cancelLeaveBoardTeamBtn(type="submit" value="{{_ 'cancel'}}")
|
||||
|
||||
template(name="addBoardDomainPopup")
|
||||
form.js-add-board-domain
|
||||
label
|
||||
| {{_ 'domain'}}
|
||||
input.js-board-domain-input#jsBoardDomainInput(type="text" autofocus placeholder="example.com")
|
||||
if error.get
|
||||
.warning {{_ error.get}}
|
||||
div.buttonsContainer
|
||||
input.primary.wide#jsAddBoardDomainBtn(type="submit" value="{{_ 'add-domain'}}")
|
||||
|
||||
template(name="removeBoardDomainPopup")
|
||||
form
|
||||
input.hide#hideDomain(type="text" value=domain)
|
||||
label
|
||||
| {{_ 'remove-domain-from-board'}}
|
||||
br
|
||||
hr
|
||||
div.buttonsContainer
|
||||
input.primary.wide.leaveBoardBtn#leaveBoardDomainBtn(type="submit" value="{{_ 'confirm-btn'}}")
|
||||
input.primary.wide.cancelLeaveBoardBtn#cancelLeaveBoardDomainBtn(type="submit" value="{{_ 'cancel'}}")
|
||||
|
||||
template(name="addMemberPopup")
|
||||
.js-search-member
|
||||
input.js-search-member-input(type="text" placeholder="{{_ 'email-address'}}")
|
||||
|
||||
@@ -15,6 +15,11 @@ import { BOARD_COLORS } from '/models/metadata/colors';
|
||||
import { Filter } from '/client/lib/filter';
|
||||
import { EscapeActions } from '/client/lib/escapeActions';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import {
|
||||
exportDependenciesJson,
|
||||
exportDependenciesSvg,
|
||||
} from '/client/lib/exportDependencies';
|
||||
import { parseDependencyLines } from '/client/lib/importDependencies';
|
||||
import {
|
||||
clearSidebarInstance,
|
||||
setSidebarInstance,
|
||||
@@ -332,8 +337,14 @@ Template.memberPopup.helpers({
|
||||
Template.boardMenuPopup.events({
|
||||
'click .js-rename-board': Popup.open('boardChangeTitle'),
|
||||
'click .js-open-rules-view'() {
|
||||
Modal.openWide('rulesMain');
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
Popup.back();
|
||||
if (currentBoard) {
|
||||
FlowRouter.go('board-rules', {
|
||||
id: currentBoard._id,
|
||||
slug: currentBoard.slug,
|
||||
});
|
||||
}
|
||||
},
|
||||
'click .js-custom-fields'() {
|
||||
if (Sidebar) {
|
||||
@@ -558,6 +569,7 @@ Template.membersWidget.helpers({
|
||||
{ name: TAPi18n.__('people'), slug: 'people' },
|
||||
{ name: TAPi18n.__('organizations'), slug: 'organizations' },
|
||||
{ name: TAPi18n.__('teams'), slug: 'teams' },
|
||||
{ name: TAPi18n.__('domains'), slug: 'domains' },
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -568,7 +580,7 @@ Template.membersWidget.events({
|
||||
'click .js-manage-board-members': Popup.open('addMember'),
|
||||
'click .js-manage-board-addOrg': Popup.open('addBoardOrg'),
|
||||
'click .js-manage-board-addTeam': Popup.open('addBoardTeam'),
|
||||
'click .js-import': Popup.open('boardImportBoard'),
|
||||
'click .js-manage-board-addDomain': Popup.open('addBoardDomain'),
|
||||
'click .js-import-board': Popup.open('chooseBoardSource'),
|
||||
'click .js-open-archived-board'() {
|
||||
Modal.open('archivedBoards');
|
||||
@@ -692,6 +704,19 @@ Template.exportBoardPopup.helpers({
|
||||
};
|
||||
return FlowRouter.path('/api/boards/:boardId/export', params, queryParams);
|
||||
},
|
||||
// #5870: JSON export omitting base64 attachment data, so very large boards
|
||||
// (whose inlined attachments would otherwise overflow the JSON serializer)
|
||||
// can still be exported.
|
||||
exportUrlNoAttachments() {
|
||||
const params = {
|
||||
boardId: Session.get('currentBoard'),
|
||||
};
|
||||
const queryParams = {
|
||||
authToken: Accounts._storedLoginToken(),
|
||||
attachments: 'false',
|
||||
};
|
||||
return FlowRouter.path('/api/boards/:boardId/export', params, queryParams);
|
||||
},
|
||||
exportUrlExcel() {
|
||||
const params = {
|
||||
boardId: Session.get('currentBoard'),
|
||||
@@ -705,6 +730,44 @@ Template.exportBoardPopup.helpers({
|
||||
queryParams,
|
||||
);
|
||||
},
|
||||
exportUrlPDF() {
|
||||
const params = {
|
||||
boardId: Session.get('currentBoard'),
|
||||
};
|
||||
const queryParams = {
|
||||
authToken: Accounts._storedLoginToken(),
|
||||
};
|
||||
return FlowRouter.path('/api/boards/:boardId/exportPDF', params, queryParams);
|
||||
},
|
||||
exportFilenamePDF() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
return `export-board-${boardId}.pdf`;
|
||||
},
|
||||
exportUrlKanboard() {
|
||||
const params = {
|
||||
boardId: Session.get('currentBoard'),
|
||||
};
|
||||
const queryParams = {
|
||||
authToken: Accounts._storedLoginToken(),
|
||||
};
|
||||
return FlowRouter.path('/api/boards/:boardId/export/kanboard', params, queryParams);
|
||||
},
|
||||
exportFilenameKanboard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
return `export-board-kanboard-${boardId}.json`;
|
||||
},
|
||||
// Generalized export URL/filename for the external tools (Deck, OpenProject,
|
||||
// GitHub, GitLab, Gitea, Forgejo).
|
||||
exportUrlExternal(format) {
|
||||
return FlowRouter.path(
|
||||
`/api/boards/:boardId/export/${format}`,
|
||||
{ boardId: Session.get('currentBoard') },
|
||||
{ authToken: Accounts._storedLoginToken() },
|
||||
);
|
||||
},
|
||||
exportFilenameExternal(format) {
|
||||
return `export-board-${format}-${Session.get('currentBoard')}.json`;
|
||||
},
|
||||
exportFilenameExcel() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
return `export-board-excel-${boardId}.xlsx`;
|
||||
@@ -770,6 +833,83 @@ Template.exportBoardPopup.events({
|
||||
event.preventDefault();
|
||||
await ExportHtml(Popup)();
|
||||
},
|
||||
// #3392: export the board's card dependency ("Red Strings") lines.
|
||||
'click .js-export-dependencies-json'(event) {
|
||||
event.preventDefault();
|
||||
exportDependenciesJson(Session.get('currentBoard'));
|
||||
Popup.close();
|
||||
},
|
||||
'click .js-export-dependencies-svg'(event) {
|
||||
event.preventDefault();
|
||||
exportDependenciesSvg(Session.get('currentBoard'));
|
||||
Popup.close();
|
||||
},
|
||||
});
|
||||
|
||||
// #3392: import dependency ("Red Strings") lines from a JSON/SVG file into a
|
||||
// chosen board. Reachable from All Boards / New / Import / Dependencies.
|
||||
Template.chooseBoardSourcePopup.events({
|
||||
'click .js-import-dependencies': Popup.open('importDependencies'),
|
||||
});
|
||||
|
||||
Template.importDependenciesPopup.onCreated(function () {
|
||||
this.fileText = new ReactiveVar('');
|
||||
this.importResult = new ReactiveVar('');
|
||||
});
|
||||
|
||||
Template.importDependenciesPopup.helpers({
|
||||
boardsForDependencyImport() {
|
||||
const userId = Meteor.userId();
|
||||
return ReactiveCache.getBoards(
|
||||
{ 'members.userId': userId, archived: false },
|
||||
{ sort: { sort: 1, title: 1 } },
|
||||
);
|
||||
},
|
||||
importResult() {
|
||||
return Template.instance().importResult.get();
|
||||
},
|
||||
});
|
||||
|
||||
Template.importDependenciesPopup.events({
|
||||
'change .js-import-dependencies-file'(event, tpl) {
|
||||
const file = event.currentTarget.files && event.currentTarget.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => tpl.fileText.set(e.target.result || '');
|
||||
reader.readAsText(file);
|
||||
},
|
||||
'submit .js-import-dependencies-form'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const boardId = tpl.find('.js-import-dependencies-board').value;
|
||||
const fileEl = tpl.find('.js-import-dependencies-file');
|
||||
const filename =
|
||||
fileEl && fileEl.files && fileEl.files[0] ? fileEl.files[0].name : '';
|
||||
const text =
|
||||
tpl.fileText.get() || tpl.find('.js-import-dependencies-text').value || '';
|
||||
let lines = [];
|
||||
try {
|
||||
lines = parseDependencyLines(text, filename);
|
||||
} catch (e) {
|
||||
tpl.importResult.set(TAPi18n.__('import-dependencies-parse-error'));
|
||||
return;
|
||||
}
|
||||
if (!boardId || lines.length === 0) {
|
||||
tpl.importResult.set(TAPi18n.__('import-dependencies-empty'));
|
||||
return;
|
||||
}
|
||||
Meteor.call('importBoardDependencies', boardId, lines, (err, res) => {
|
||||
if (err) {
|
||||
tpl.importResult.set(err.reason || err.message || String(err));
|
||||
return;
|
||||
}
|
||||
tpl.importResult.set(
|
||||
TAPi18n.__('import-dependencies-done', {
|
||||
imported: res.imported,
|
||||
unmatched: res.unmatched,
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Template.labelsWidget.events({
|
||||
@@ -968,6 +1108,19 @@ Template.boardInfoOnMyBoardsPopup.helpers({
|
||||
const tpl = Template.instance();
|
||||
return tpl.currentBoard.allowsCardCounterList;
|
||||
},
|
||||
cardAging() {
|
||||
const tpl = Template.instance();
|
||||
return tpl.currentBoard.cardAging;
|
||||
},
|
||||
cardAgingDays1() {
|
||||
return Template.instance().currentBoard.cardAgingDays1 ?? 7;
|
||||
},
|
||||
cardAgingDays2() {
|
||||
return Template.instance().currentBoard.cardAgingDays2 ?? 14;
|
||||
},
|
||||
cardAgingDays3() {
|
||||
return Template.instance().currentBoard.cardAgingDays3 ?? 28;
|
||||
},
|
||||
allowsBoardMemberList() {
|
||||
const tpl = Template.instance();
|
||||
return tpl.currentBoard.allowsBoardMemberList;
|
||||
@@ -991,6 +1144,20 @@ Template.boardInfoOnMyBoardsPopup.events({
|
||||
tpl.currentBoard.allowsCardCounterList,
|
||||
);
|
||||
},
|
||||
'click .js-field-has-cardaging'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.currentBoard.cardAging = !tpl.currentBoard.cardAging;
|
||||
tpl.currentBoard.setCardAging(tpl.currentBoard.cardAging);
|
||||
$(`.js-field-has-cardaging ${MCB}`).toggleClass(CKCLS, tpl.currentBoard.cardAging);
|
||||
$('.js-field-has-cardaging').toggleClass(CKCLS, tpl.currentBoard.cardAging);
|
||||
},
|
||||
'change .js-card-aging-days'(evt, tpl) {
|
||||
// #3984: save the three board-configurable card-aging day thresholds.
|
||||
const vals = $('.js-card-aging-days')
|
||||
.map((i, el) => parseInt(el.value, 10) || 0)
|
||||
.get();
|
||||
tpl.currentBoard.setCardAgingDays(vals[0], vals[1], vals[2]);
|
||||
},
|
||||
'click .js-field-has-boardmemberlist'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.currentBoard.allowsBoardMemberList = !tpl.currentBoard
|
||||
@@ -1010,7 +1177,12 @@ Template.boardInfoOnMyBoardsPopup.events({
|
||||
});
|
||||
|
||||
Template.boardSubtaskSettingsPopup.onCreated(function() {
|
||||
this.currentBoard = Utils.getCurrentBoard();
|
||||
// Same reactive-snapshot fix as boardCardSettingsPopup (#6385): the
|
||||
// allowsSubtasks toggle reads tpl.currentBoard, so keep it current in an
|
||||
// autorun so the setting can be reversed without a page refresh.
|
||||
this.autorun(() => {
|
||||
this.currentBoard = Utils.getCurrentBoard();
|
||||
});
|
||||
});
|
||||
|
||||
Template.boardSubtaskSettingsPopup.helpers({
|
||||
@@ -1125,7 +1297,15 @@ Template.boardSubtaskSettingsPopup.events({
|
||||
});
|
||||
|
||||
Template.boardCardSettingsPopup.onCreated(function() {
|
||||
this.currentBoard = Utils.getCurrentBoard();
|
||||
// Keep currentBoard reactive. The toggle handlers compute the new value from
|
||||
// tpl.currentBoard.allowsX, so a one-time snapshot went stale after the first
|
||||
// toggle: reversing a setting (e.g. "Mark as complete") recomputed !oldValue
|
||||
// and never switched back until the page was refreshed (#6385). Re-reading the
|
||||
// board in an autorun keeps it current, so every Card Settings toggle works
|
||||
// both ways without a refresh.
|
||||
this.autorun(() => {
|
||||
this.currentBoard = Utils.getCurrentBoard();
|
||||
});
|
||||
});
|
||||
|
||||
Template.boardCardSettingsPopup.helpers({
|
||||
@@ -1134,6 +1314,16 @@ Template.boardCardSettingsPopup.helpers({
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.allowsReceivedDate : false;
|
||||
},
|
||||
allowsDueComplete() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.allowsDueComplete : false;
|
||||
},
|
||||
allowsDueCompleteOnMinicard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.allowsDueCompleteOnMinicard : false;
|
||||
},
|
||||
allowsReceivedDateOnMinicard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
@@ -1388,6 +1578,16 @@ Template.boardCardSettingsPopup.events({
|
||||
const newValue = !tpl.currentBoard.allowsReceivedDate;
|
||||
Boards.update(tpl.currentBoard._id, { $set: { allowsReceivedDate: newValue } });
|
||||
},
|
||||
'click .js-field-has-duecomplete'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const newValue = !tpl.currentBoard.allowsDueComplete;
|
||||
Boards.update(tpl.currentBoard._id, { $set: { allowsDueComplete: newValue } });
|
||||
},
|
||||
'click .js-field-has-duecomplete-on-minicard'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const newValue = !tpl.currentBoard.allowsDueCompleteOnMinicard;
|
||||
Boards.update(tpl.currentBoard._id, { $set: { allowsDueCompleteOnMinicard: newValue } });
|
||||
},
|
||||
'click .js-field-has-receiveddate-on-minicard'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const newValue = !tpl.currentBoard.allowsReceivedDateOnMinicard;
|
||||
@@ -1863,6 +2063,88 @@ Template.removeBoardOrgPopup.events({
|
||||
},
|
||||
});
|
||||
|
||||
// #5850: free-form "share board with an email domain" popups. Unlike orgs/teams
|
||||
// (which are a managed collection shown in a <select>), the owner simply types a
|
||||
// domain such as example.com.
|
||||
Template.addBoardDomainPopup.onCreated(function() {
|
||||
this.error = new ReactiveVar('');
|
||||
|
||||
this.setError = function(error) {
|
||||
this.error.set(error);
|
||||
};
|
||||
});
|
||||
|
||||
Template.addBoardDomainPopup.helpers({
|
||||
error() {
|
||||
return { get: () => Template.instance().error.get() };
|
||||
},
|
||||
});
|
||||
|
||||
Template.addBoardDomainPopup.events({
|
||||
'keyup input'(event, tpl) {
|
||||
tpl.setError('');
|
||||
},
|
||||
'submit .js-add-board-domain'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const input = document.getElementById('jsBoardDomainInput');
|
||||
const domain = (input ? input.value : '').trim().toLowerCase();
|
||||
|
||||
// Basic validation: must contain a '.', and no '@' or whitespace.
|
||||
if (
|
||||
domain.length === 0 ||
|
||||
domain.indexOf('.') < 0 ||
|
||||
domain.indexOf('@') >= 0 ||
|
||||
/\s/.test(domain)
|
||||
) {
|
||||
tpl.setError('invalid-domain');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
const boardDomains = [];
|
||||
if (currentBoard.domains !== undefined) {
|
||||
for (let i = 0; i < currentBoard.domains.length; i++) {
|
||||
boardDomains.push(currentBoard.domains[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!boardDomains.some(d => d.domain === domain)) {
|
||||
boardDomains.push({
|
||||
domain,
|
||||
isActive: true,
|
||||
});
|
||||
Meteor.call('setBoardDomains', boardDomains, currentBoard._id);
|
||||
}
|
||||
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
|
||||
Template.removeBoardDomainPopup.events({
|
||||
'keyup input'(event, tpl) {
|
||||
// no-op, kept for parity with the org/team remove popups
|
||||
},
|
||||
'click #leaveBoardDomainBtn'(){
|
||||
const stringDomain = document.getElementById('hideDomain').value;
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
const boardDomains = [];
|
||||
if (currentBoard.domains !== undefined) {
|
||||
for (let i = 0; i < currentBoard.domains.length; i++) {
|
||||
if (currentBoard.domains[i].domain != stringDomain) {
|
||||
boardDomains.push(currentBoard.domains[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Meteor.call('setBoardDomains', boardDomains, currentBoard._id);
|
||||
|
||||
Popup.back();
|
||||
},
|
||||
'click #cancelLeaveBoardDomainBtn'(){
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
|
||||
Template.addBoardTeamPopup.onCreated(function() {
|
||||
this.error = new ReactiveVar('');
|
||||
this.loading = new ReactiveVar(false);
|
||||
|
||||
@@ -139,6 +139,18 @@ template(name="filterSidebar")
|
||||
if Filter.customFields.isSelected _id
|
||||
i.fa.fa-check
|
||||
hr
|
||||
h3
|
||||
i.fa.fa-link
|
||||
| {{_ 'filter-dependencies-label'}}
|
||||
ul.sidebar-list
|
||||
each dependencyTypes
|
||||
li(class="{{#if Filter.cardDependencies.isSelected id}}active{{/if}}")
|
||||
a.name.js-toggle-dependency-filter(data-filter-id="{{id}}")
|
||||
span.sidebar-list-item-description
|
||||
| {{_ label}}
|
||||
if Filter.cardDependencies.isSelected id
|
||||
i.fa.fa-check
|
||||
hr
|
||||
h3
|
||||
| {{_ 'other-filters-label'}}
|
||||
ul.sidebar-list
|
||||
|
||||
@@ -4,6 +4,17 @@ import { Filter } from '/client/lib/filter';
|
||||
import { EscapeActions } from '/client/lib/escapeActions';
|
||||
import { MultiSelection } from '/client/lib/multiSelection';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { DEPENDENCY_TYPES } from '/models/metadata/dependencies';
|
||||
|
||||
Template.filterSidebar.helpers({
|
||||
// #3392: relation types offered in the dependency ("Red Strings") filter.
|
||||
dependencyTypes() {
|
||||
return DEPENDENCY_TYPES.map(t => ({
|
||||
id: t.id,
|
||||
label: `dependency-type-${t.id}`,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// SubsManager removed for Meteor 3 migration
|
||||
|
||||
@@ -95,6 +106,11 @@ Template.filterSidebar.events({
|
||||
Filter.customFields.toggle(getFilterIdFromEvent(evt, this?._id));
|
||||
Filter.resetExceptions();
|
||||
},
|
||||
'click .js-toggle-dependency-filter'(evt) {
|
||||
evt.preventDefault();
|
||||
Filter.cardDependencies.toggle(getFilterIdFromEvent(evt, this?._id));
|
||||
Filter.resetExceptions();
|
||||
},
|
||||
'change .js-field-advanced-filter'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
Filter.advanced.set(
|
||||
|
||||
@@ -2,7 +2,7 @@ template(name="userAvatar")
|
||||
a.member(class="js-{{#if assignee}}assignee{{else}}member{{/if}}" title="{{#if userData}}{{userData.profile.fullname}} ({{userData.username}}) {{_ memberType}}{{/if}}")
|
||||
if userData
|
||||
if userData.profile.avatarUrl
|
||||
img.avatar.avatar-image(src="{{avatarUrl}}")
|
||||
img.avatar.avatar-image(src="{{avatarUrl}}" alt="")
|
||||
else
|
||||
+userAvatarInitials(userId=userData._id)
|
||||
else
|
||||
@@ -84,17 +84,18 @@ template(name="changeAvatarPopup")
|
||||
if noAvatarUrl
|
||||
i.fa.fa-check
|
||||
p.sub-name {{_ 'default-avatar'}}
|
||||
input.hide.js-upload-avatar-input(accept="image/*;capture=camera" type="file")
|
||||
if Meteor.settings.public.avatarsUploadMaxSize
|
||||
| {{_ 'max-avatar-filesize'}} {{Meteor.settings.public.avatarsUploadMaxSize}}
|
||||
br
|
||||
if Meteor.settings.public.avatarsUploadMimeTypes
|
||||
| {{_ 'allowed-avatar-filetypes'}} {{Meteor.settings.public.avatarsUploadMimeTypes}}
|
||||
br
|
||||
| {{_ 'invalid-file'}}
|
||||
button.full.js-upload-avatar
|
||||
i.fa.fa-upload
|
||||
| {{_ 'upload-avatar' }}
|
||||
unless avatarUploadBlocked
|
||||
input.hide.js-upload-avatar-input(accept="image/*;capture=camera" type="file")
|
||||
if Meteor.settings.public.avatarsUploadMaxSize
|
||||
| {{_ 'max-avatar-filesize'}} {{Meteor.settings.public.avatarsUploadMaxSize}}
|
||||
br
|
||||
if Meteor.settings.public.avatarsUploadMimeTypes
|
||||
| {{_ 'allowed-avatar-filetypes'}} {{Meteor.settings.public.avatarsUploadMimeTypes}}
|
||||
br
|
||||
| {{_ 'invalid-file'}}
|
||||
button.full.js-upload-avatar
|
||||
i.fa.fa-upload
|
||||
| {{_ 'upload-avatar' }}
|
||||
|
||||
template(name="deleteAvatarPopup")
|
||||
p {{_ 'delete-avatar-confirm'}}
|
||||
|
||||
@@ -164,9 +164,29 @@ Template.boardTeamName.helpers({
|
||||
},
|
||||
});
|
||||
|
||||
// #5850: row showing a single board email-domain share, with a remove affordance.
|
||||
Template.boardDomainRow.helpers({
|
||||
domainViewPortWidth() {
|
||||
return ((this.domain && this.domain.length) || 1) * 12;
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardDomainRow.events({
|
||||
// The row's data context is `{ domain }`, which becomes removeBoardDomainPopup's
|
||||
// data context, so the popup can read `this.domain` directly.
|
||||
'click .js-manage-board-removeDomain': Popup.open('removeBoardDomain'),
|
||||
});
|
||||
|
||||
Template.changeAvatarPopup.onCreated(function () {
|
||||
this.error = new ReactiveVar('');
|
||||
this.avatarUpdateCounter = new ReactiveVar(0); // Trigger to force helper re-evaluation
|
||||
// Whether an admin has blocked avatar uploads (Admin Panel > Attachments >
|
||||
// Transfer limits). Default false (avatars enabled); when true the upload
|
||||
// option is hidden and the upload is also rejected server-side.
|
||||
this.avatarUploadBlocked = new ReactiveVar(false);
|
||||
Meteor.call('isAvatarUploadBlocked', (err, blocked) => {
|
||||
if (!err) this.avatarUploadBlocked.set(blocked === true);
|
||||
});
|
||||
Meteor.subscribe('my-avatars');
|
||||
});
|
||||
|
||||
@@ -174,6 +194,9 @@ Template.changeAvatarPopup.helpers({
|
||||
error() {
|
||||
return Template.instance().error;
|
||||
},
|
||||
avatarUploadBlocked() {
|
||||
return Template.instance().avatarUploadBlocked.get();
|
||||
},
|
||||
uploadedAvatars() {
|
||||
Template.instance().avatarUpdateCounter.get(); // Create dependency on update counter
|
||||
const ret = ReactiveCache.getAvatars({ userId: Meteor.userId() }, {}, true);
|
||||
|
||||
@@ -56,7 +56,7 @@ template(name="memberMenuPopup")
|
||||
unless currentUser.isWorker
|
||||
ul.pop-over-list
|
||||
li
|
||||
a(href="{{pathFor 'board' id=templatesBoardId slug=templatesBoardSlug}}")
|
||||
a(href="{{pathFor 'allboards-templates'}}")
|
||||
i.fa.fa-list
|
||||
| {{_ 'templates'}}
|
||||
if currentUser.isAdmin
|
||||
@@ -82,7 +82,7 @@ template(name="memberMenuPopup")
|
||||
li
|
||||
a.js-change-avatar
|
||||
i.fa.fa-picture-o
|
||||
| {{_ 'edit-avatar'}}
|
||||
| {{_ 'change-avatar'}}
|
||||
unless isSandstorm
|
||||
if isNotOAuth2AuthenticationMethod
|
||||
li
|
||||
|
||||
@@ -15,24 +15,6 @@ Template.memberMenuPopup.onCreated(function () {
|
||||
});
|
||||
|
||||
Template.memberMenuPopup.helpers({
|
||||
templatesBoardId() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
if (currentUser) {
|
||||
return currentUser.getTemplatesBoardId();
|
||||
} else {
|
||||
// No need to getTemplatesBoardId on public board
|
||||
return false;
|
||||
}
|
||||
},
|
||||
templatesBoardSlug() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
if (currentUser) {
|
||||
return currentUser.getTemplatesBoardSlug();
|
||||
} else {
|
||||
// No need to getTemplatesBoardSlug() on public board
|
||||
return false;
|
||||
}
|
||||
},
|
||||
isSupportPageEnabled() {
|
||||
const setting = ReactiveCache.getCurrentSetting();
|
||||
return setting && setting.supportPageEnabled;
|
||||
@@ -75,7 +57,7 @@ Template.memberMenuPopup.events({
|
||||
FlowRouter.go('bookmarks');
|
||||
Popup.back();
|
||||
} else {
|
||||
Popup.open('bookmarksPopup')(e);
|
||||
Popup.open('bookmarks')(e);
|
||||
}
|
||||
},
|
||||
'click .js-my-cards'() {
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import '/client/components/boards/boardArchive.jade';
|
||||
import '/client/components/boards/boardBody.jade';
|
||||
import '/client/components/boards/dependencyOverlay.jade';
|
||||
import '/client/components/boards/boardHeader.jade';
|
||||
import '/client/components/boards/boardsList.jade';
|
||||
import '/client/components/boards/miniboard.jade';
|
||||
import '/client/components/boards/originalPositionsView.jade';
|
||||
import '/client/components/boards/tableView.jade';
|
||||
|
||||
import '/client/components/boards/boardArchive.js';
|
||||
import '/client/components/boards/boardBody.js';
|
||||
import '/client/components/boards/dependencyOverlay.js';
|
||||
import '/client/components/boards/boardHeader.js';
|
||||
import '/client/components/boards/boardsList.js';
|
||||
import '/client/components/boards/originalPositionsView.js';
|
||||
import '/client/components/boards/tableView.js';
|
||||
|
||||
import '/client/components/boards/boardBody.css';
|
||||
import '/client/components/boards/dependencyOverlay.css';
|
||||
import '/client/components/boards/boardColors.css';
|
||||
import '/client/components/boards/boardHeader.css';
|
||||
import '/client/components/boards/boardsList.css';
|
||||
import '/client/components/boards/calendarView.css';
|
||||
import '/client/components/boards/originalPositionsView.css';
|
||||
import '/client/components/boards/tableView.css';
|
||||
|
||||
@@ -2,6 +2,10 @@ import '/client/components/rules/ruleDetails.jade';
|
||||
import '/client/components/rules/rulesActions.jade';
|
||||
import '/client/components/rules/rulesList.jade';
|
||||
import '/client/components/rules/rulesMain.jade';
|
||||
import '/client/components/rules/rulesImportExport.jade';
|
||||
import '/client/components/rules/rulesWorkflow.jade';
|
||||
import '/client/components/rules/cardButtons.jade';
|
||||
import '/client/components/rules/boardButtons.jade';
|
||||
import '/client/components/rules/rulesTriggers.jade';
|
||||
import '/client/components/rules/actions/boardActions.jade';
|
||||
import '/client/components/rules/actions/cardActions.jade';
|
||||
@@ -10,11 +14,17 @@ import '/client/components/rules/actions/mailActions.jade';
|
||||
import '/client/components/rules/triggers/boardTriggers.jade';
|
||||
import '/client/components/rules/triggers/cardTriggers.jade';
|
||||
import '/client/components/rules/triggers/checklistTriggers.jade';
|
||||
import '/client/components/rules/triggers/scheduledTriggers.jade';
|
||||
import '/client/components/rules/triggers/buttonTriggers.jade';
|
||||
|
||||
import '/client/components/rules/ruleDetails.js';
|
||||
import '/client/components/rules/rulesActions.js';
|
||||
import '/client/components/rules/rulesList.js';
|
||||
import '/client/components/rules/rulesMain.js';
|
||||
import '/client/components/rules/rulesImportExport.js';
|
||||
import '/client/components/rules/rulesWorkflow.js';
|
||||
import '/client/components/rules/cardButtons.js';
|
||||
import '/client/components/rules/boardButtons.js';
|
||||
import '/client/components/rules/rulesTriggers.js';
|
||||
import '/client/components/rules/actions/boardActions.js';
|
||||
import '/client/components/rules/actions/cardActions.js';
|
||||
@@ -23,6 +33,7 @@ import '/client/components/rules/actions/mailActions.js';
|
||||
import '/client/components/rules/triggers/boardTriggers.js';
|
||||
import '/client/components/rules/triggers/cardTriggers.js';
|
||||
import '/client/components/rules/triggers/checklistTriggers.js';
|
||||
import '/client/components/rules/triggers/scheduledTriggers.js';
|
||||
import '/client/components/rules/triggers/buttonTriggers.js';
|
||||
|
||||
import '/client/components/rules/rules.css';
|
||||
|
||||
@@ -150,6 +150,33 @@ export class BoardSwimlaneListDialog {
|
||||
return this.cardOption.listId == listId;
|
||||
}
|
||||
|
||||
/** returns if the board id is the currently selected one (reactive).
|
||||
* Like isSelectedSwimlaneId/isSelectedListId, binds the board <option selected>
|
||||
* attribute to the live selectedBoardId rather than the last-confirmed option,
|
||||
* which is empty for a fresh dialog. Without this, a Blaze re-render of the
|
||||
* board <select> leaves no option selected, the DOM selectedIndex falls back to
|
||||
* -1, and the move/copy reads an undefined boardId — making card.move fail with
|
||||
* a 403 "may only update documents by ID" validation error. */
|
||||
isSelectedBoardId(boardId) {
|
||||
return this.selectedBoardId.get() == boardId;
|
||||
}
|
||||
|
||||
/** returns if the swimlane id is the currently selected one (reactive).
|
||||
* Used to bind the <option selected> attribute to the live selection rather
|
||||
* than the last-confirmed option, so a Blaze re-render of the swimlane <select>
|
||||
* cannot silently revert the user's in-progress choice. */
|
||||
isSelectedSwimlaneId(swimlaneId) {
|
||||
return this.selectedSwimlaneId.get() == swimlaneId;
|
||||
}
|
||||
|
||||
/** returns if the list id is the currently selected one (reactive).
|
||||
* See isSelectedSwimlaneId — binding the <option selected> attribute to the
|
||||
* live selectedListId keeps the DOM <select> in sync with the user's choice
|
||||
* across reactive re-renders (e.g. when the board subscription data arrives). */
|
||||
isSelectedListId(listId) {
|
||||
return this.selectedListId.get() == listId;
|
||||
}
|
||||
|
||||
/** returns all available boards */
|
||||
boards() {
|
||||
return ReactiveCache.getBoards(
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import {
|
||||
dependencyTypeMeta,
|
||||
normalizeDependencies,
|
||||
} from '/models/metadata/dependencies';
|
||||
|
||||
// #3392: PI Program Board "Red Strings" — client-side export of a board's
|
||||
// card-to-card dependency lines as JSON or as a standalone, round-trippable SVG.
|
||||
//
|
||||
// The SVG embeds each card and each line's metadata as data-* attributes so the
|
||||
// same file can be re-imported (All Boards / New / Import → Dependencies).
|
||||
|
||||
function saveAs(blob, filename) {
|
||||
const dl = document.createElement('a');
|
||||
dl.href = window.URL.createObjectURL(blob);
|
||||
dl.style.display = 'none';
|
||||
dl.download = filename;
|
||||
document.body.appendChild(dl);
|
||||
dl.click();
|
||||
document.body.removeChild(dl);
|
||||
}
|
||||
|
||||
function xmlEscape(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// Collect the board's dependency lines as plain objects.
|
||||
export function collectDependencyLines(boardId) {
|
||||
const cards = ReactiveCache.getCards({ boardId, archived: false });
|
||||
const cardById = {};
|
||||
cards.forEach(card => {
|
||||
cardById[card._id] = card;
|
||||
});
|
||||
const lines = [];
|
||||
cards.forEach(card => {
|
||||
normalizeDependencies(card.cardDependencies).forEach(dep => {
|
||||
const target = cardById[dep.cardId];
|
||||
lines.push({
|
||||
from: card._id,
|
||||
fromTitle: card.title,
|
||||
fromCardNumber: card.cardNumber,
|
||||
to: dep.cardId,
|
||||
toTitle: target ? target.title : null,
|
||||
toCardNumber: target ? target.cardNumber : null,
|
||||
type: dep.type,
|
||||
color: dep.color,
|
||||
icon: dep.icon,
|
||||
});
|
||||
});
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function exportDependenciesJson(boardId) {
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
const payload = {
|
||||
_format: 'wekan-dependencies-1.0.0',
|
||||
boardId,
|
||||
boardTitle: board ? board.title : '',
|
||||
lines: collectDependencyLines(boardId),
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
saveAs(blob, `wekan-dependencies-${boardId}.json`);
|
||||
}
|
||||
|
||||
// Lay the participating cards out on a simple grid (one column per list, rows by
|
||||
// card sort order) so the SVG is meaningful without needing the live DOM.
|
||||
function layoutCards(boardId) {
|
||||
const lists = ReactiveCache.getLists(
|
||||
{ boardId, archived: false },
|
||||
{ sort: { sort: 1 } },
|
||||
);
|
||||
const boxW = 180;
|
||||
const boxH = 44;
|
||||
const gapX = 60;
|
||||
const gapY = 20;
|
||||
const marginX = 20;
|
||||
const marginY = 20;
|
||||
const pos = {};
|
||||
lists.forEach((list, colIndex) => {
|
||||
const cards = ReactiveCache.getCards(
|
||||
{ boardId, listId: list._id, archived: false },
|
||||
{ sort: { sort: 1 } },
|
||||
);
|
||||
cards.forEach((card, rowIndex) => {
|
||||
pos[card._id] = {
|
||||
x: marginX + colIndex * (boxW + gapX),
|
||||
y: marginY + rowIndex * (boxH + gapY),
|
||||
w: boxW,
|
||||
h: boxH,
|
||||
title: card.title || '',
|
||||
cardNumber: card.cardNumber,
|
||||
};
|
||||
});
|
||||
});
|
||||
return { pos, boxW, boxH };
|
||||
}
|
||||
|
||||
export function exportDependenciesSvg(boardId) {
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
const lines = collectDependencyLines(boardId);
|
||||
const { pos } = layoutCards(boardId);
|
||||
|
||||
let width = 400;
|
||||
let height = 200;
|
||||
Object.values(pos).forEach(p => {
|
||||
width = Math.max(width, p.x + p.w + 20);
|
||||
height = Math.max(height, p.y + p.h + 20);
|
||||
});
|
||||
|
||||
const markers = {};
|
||||
const pathEls = [];
|
||||
lines.forEach(line => {
|
||||
const from = pos[line.from];
|
||||
const to = pos[line.to];
|
||||
if (!from || !to) return; // only draw lines between cards on this board
|
||||
const meta = dependencyTypeMeta(line.type);
|
||||
const prereq = meta.forward ? from : to;
|
||||
const dependent = meta.forward ? to : from;
|
||||
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 d = `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
||||
const markerId = `arrow-${String(line.color).replace(/[^a-zA-Z0-9]/g, '')}`;
|
||||
if (meta.directed) markers[markerId] = line.color;
|
||||
pathEls.push(
|
||||
`<path class="dependency-line" d="${d}" fill="none" stroke="${xmlEscape(
|
||||
line.color,
|
||||
)}" stroke-width="2"${meta.directed ? ` marker-end="url(#${markerId})"` : ''}` +
|
||||
` data-from="${xmlEscape(line.from)}" data-to="${xmlEscape(line.to)}"` +
|
||||
` data-from-number="${xmlEscape(line.fromCardNumber)}" data-to-number="${xmlEscape(line.toCardNumber)}"` +
|
||||
` data-from-title="${xmlEscape(line.fromTitle)}" data-to-title="${xmlEscape(line.toTitle)}"` +
|
||||
` data-type="${xmlEscape(line.type)}" data-color="${xmlEscape(line.color)}" data-icon="${xmlEscape(line.icon)}" />`,
|
||||
);
|
||||
});
|
||||
|
||||
const markerEls = Object.keys(markers)
|
||||
.map(
|
||||
id =>
|
||||
`<marker id="${id}" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto" markerUnits="userSpaceOnUse"><path d="M0,0 L7,3 L0,6 Z" fill="${xmlEscape(
|
||||
markers[id],
|
||||
)}" /></marker>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
const cardEls = Object.keys(pos)
|
||||
.map(cardId => {
|
||||
const p = pos[cardId];
|
||||
return (
|
||||
`<g class="dependency-card" data-card-id="${xmlEscape(cardId)}" data-card-number="${xmlEscape(
|
||||
p.cardNumber,
|
||||
)}" data-card-title="${xmlEscape(p.title)}">` +
|
||||
`<rect x="${p.x}" y="${p.y}" width="${p.w}" height="${p.h}" rx="4" fill="#ffffff" stroke="#b0b0b0" />` +
|
||||
`<text x="${p.x + 8}" y="${p.y + p.h / 2 + 4}" font-family="sans-serif" font-size="12" fill="#222">${xmlEscape(
|
||||
p.title.length > 26 ? `${p.title.slice(0, 25)}…` : p.title,
|
||||
)}</text></g>`
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
|
||||
const svg =
|
||||
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" ` +
|
||||
`data-wekan-format="wekan-dependencies-1.0.0" data-board-id="${xmlEscape(boardId)}" data-board-title="${xmlEscape(
|
||||
board ? board.title : '',
|
||||
)}">\n` +
|
||||
`<defs>${markerEls}</defs>\n${cardEls}\n${pathEls.join('\n')}\n</svg>\n`;
|
||||
|
||||
const blob = new Blob([svg], { type: 'image/svg+xml' });
|
||||
saveAs(blob, `wekan-dependencies-${boardId}.svg`);
|
||||
}
|
||||
@@ -687,6 +687,8 @@ export const Filter = {
|
||||
dueAt: new DateFilter(),
|
||||
title: new StringFilter(),
|
||||
customFields: new SetFilter('_id'),
|
||||
// #3392: filter cards by their dependency ("Red Strings") relation type.
|
||||
cardDependencies: new SetFilter('type'),
|
||||
advanced: new AdvancedFilter(),
|
||||
lists: new AdvancedFilter(), // we need the ability to filter list by name as well
|
||||
|
||||
@@ -699,6 +701,7 @@ export const Filter = {
|
||||
'dueAt',
|
||||
'title',
|
||||
'customFields',
|
||||
'cardDependencies',
|
||||
],
|
||||
|
||||
// We don't filter cards that have been added after the last filter change. To
|
||||
|
||||
@@ -14,6 +14,15 @@ import { TAPi18n } from '/imports/i18n';
|
||||
// logical properties (margin-inline-start, inset-inline-start, text-align:start,
|
||||
// …), so flipping `dir` mirrors the whole UI without per-page handling.
|
||||
Meteor.startup(() => {
|
||||
if (typeof document !== 'undefined' && document.documentElement) {
|
||||
if (!document.documentElement.getAttribute('lang')) {
|
||||
document.documentElement.lang = 'en';
|
||||
}
|
||||
if (!document.documentElement.getAttribute('dir')) {
|
||||
document.documentElement.dir = 'ltr';
|
||||
}
|
||||
}
|
||||
|
||||
Tracker.autorun(() => {
|
||||
const lang = TAPi18n.getLanguage();
|
||||
const dir = TAPi18n.getLanguageDirection();
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// #3392: parse a card-dependency ("Red Strings") file into a flat list of
|
||||
// lines: { from, to, type, color, icon, fromCardNumber, toCardNumber,
|
||||
// fromTitle, toTitle }. Supports the WeKan dependencies JSON export, the WeKan
|
||||
// dependencies SVG export (round-trippable via its data-* attributes), a
|
||||
// generic JSON array/`lines` of line objects, and a best-effort mapping of
|
||||
// Miro REST API connectors (items + connectors). Cards are matched in the
|
||||
// target board by id, then card number, then title (see importBoardDependencies).
|
||||
//
|
||||
// Kendis and piplanning.io are proprietary tools without a documented public
|
||||
// dependency file format; export their links from their API as the generic JSON
|
||||
// shape `{ "lines": [ { "fromTitle": "...", "toTitle": "...", "type": "blocks" } ] }`
|
||||
// and import that here.
|
||||
|
||||
// Strip HTML tags safely. A single `replace(/<[^>]*>/g, '')` is an incomplete
|
||||
// multi-character sanitization (CodeQL js/incomplete-multi-character-sanitization):
|
||||
// 1. removing one match can splice surrounding text into a new match, so the
|
||||
// replacement is looped to a fixed point; and
|
||||
// 2. a dangling, unclosed tag with no ">" (e.g. a trailing "<script" or
|
||||
// "<svg/onload=...") is never matched by the regex at all and would survive,
|
||||
// so any stray "<"/">" left over is removed afterwards.
|
||||
// Together these guarantee no "<tag" (complete or partial) can remain.
|
||||
function stripHtml(s) {
|
||||
let str = String(s == null ? '' : s);
|
||||
let prev;
|
||||
do {
|
||||
prev = str;
|
||||
str = str.replace(/<[^>]*>/g, '');
|
||||
} while (str !== prev);
|
||||
return str.replace(/[<>]/g, '').trim();
|
||||
}
|
||||
|
||||
function lineFromObject(o) {
|
||||
return {
|
||||
from: o.from || o.fromId || o.source || null,
|
||||
to: o.to || o.toId || o.target || null,
|
||||
fromCardNumber: o.fromCardNumber != null ? o.fromCardNumber : o.fromNumber,
|
||||
toCardNumber: o.toCardNumber != null ? o.toCardNumber : o.toNumber,
|
||||
fromTitle: o.fromTitle || null,
|
||||
toTitle: o.toTitle || null,
|
||||
type: o.type || undefined,
|
||||
color: o.color || undefined,
|
||||
icon: o.icon || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Best-effort mapping of Miro REST API data: an `items` (or `data`) array with
|
||||
// titles, and a `connectors` array whose entries reference startItem/endItem ids.
|
||||
// Connectors are mapped to lines and item ids resolved to titles so they can be
|
||||
// matched to WeKan cards by title. A connector caption containing "block"/"fix"
|
||||
// is mapped to the corresponding relation type.
|
||||
function parseMiro(data) {
|
||||
const items = data.items || data.data || [];
|
||||
const titleById = {};
|
||||
(Array.isArray(items) ? items : []).forEach(it => {
|
||||
if (!it || !it.id) return;
|
||||
const title = stripHtml(
|
||||
(it.data && (it.data.title || it.data.content)) || it.title || it.content,
|
||||
);
|
||||
if (title) titleById[it.id] = title;
|
||||
});
|
||||
const connectors = Array.isArray(data) ? data : data.connectors || [];
|
||||
return (connectors || [])
|
||||
.map(c => {
|
||||
const from = c.startItem && c.startItem.id;
|
||||
const to = c.endItem && c.endItem.id;
|
||||
const caption =
|
||||
(c.captions && c.captions[0] && stripHtml(c.captions[0].content)) || '';
|
||||
const cl = caption.toLowerCase();
|
||||
let type;
|
||||
if (cl.includes('block')) type = 'blocks';
|
||||
else if (cl.includes('fix')) type = 'fixes';
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
fromTitle: titleById[from] || null,
|
||||
toTitle: titleById[to] || null,
|
||||
type,
|
||||
};
|
||||
})
|
||||
.filter(l => l.from || l.to || l.fromTitle || l.toTitle);
|
||||
}
|
||||
|
||||
function looksLikeMiro(data) {
|
||||
if (data && Array.isArray(data.connectors)) return true;
|
||||
if (
|
||||
Array.isArray(data) &&
|
||||
data.some(o => o && (o.startItem || o.endItem))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseJson(text) {
|
||||
const data = JSON.parse(text);
|
||||
if (looksLikeMiro(data)) return parseMiro(data);
|
||||
if (Array.isArray(data)) return data.map(lineFromObject);
|
||||
if (data && Array.isArray(data.lines)) return data.lines.map(lineFromObject);
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseSvg(text) {
|
||||
const doc = new DOMParser().parseFromString(text, 'image/svg+xml');
|
||||
const paths = doc.querySelectorAll('path.dependency-line, path[data-from]');
|
||||
const lines = [];
|
||||
paths.forEach(p => {
|
||||
const from = p.getAttribute('data-from');
|
||||
const to = p.getAttribute('data-to');
|
||||
if (!from && !to) return;
|
||||
lines.push({
|
||||
from,
|
||||
to,
|
||||
fromCardNumber: p.getAttribute('data-from-number') || undefined,
|
||||
toCardNumber: p.getAttribute('data-to-number') || undefined,
|
||||
fromTitle: p.getAttribute('data-from-title') || undefined,
|
||||
toTitle: p.getAttribute('data-to-title') || undefined,
|
||||
type: p.getAttribute('data-type') || undefined,
|
||||
color: p.getAttribute('data-color') || undefined,
|
||||
icon: p.getAttribute('data-icon') || undefined,
|
||||
});
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Auto-detect by content (SVG starts with '<'; JSON otherwise). `filename` is an
|
||||
// optional hint used to disambiguate.
|
||||
export function parseDependencyLines(text, filename = '') {
|
||||
const trimmed = (text || '').trim();
|
||||
if (!trimmed) return [];
|
||||
const looksSvg =
|
||||
/\.svg$/i.test(filename) || trimmed.startsWith('<');
|
||||
if (looksSvg) return parseSvg(trimmed);
|
||||
return parseJson(trimmed);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import dragscroll from '@wekanteam/dragscroll';
|
||||
import '/client/lib/dragscrollTouch';
|
||||
|
||||
// Enable / disable mouse drag-to-scroll for whole-page layouts.
|
||||
//
|
||||
// On pages like All Boards, Login and Register the scroll container is the
|
||||
// <body> element itself (it has `overflow-y: auto; height: 100vh`), not an
|
||||
// inner board canvas or swimlane. The dragscroll library scrolls whichever
|
||||
// element carries the `dragscroll` class and has a dedicated `el ==
|
||||
// document.body` branch that also scrolls document.documentElement, so tagging
|
||||
// <body> makes dragging empty page space scroll the page — the same gesture
|
||||
// that already works on the board swimlanes view.
|
||||
//
|
||||
// The class is toggled per template (onRendered / onDestroyed) so it only
|
||||
// applies on those pages; dragscroll.reset() re-scans the DOM and (re)binds the
|
||||
// mouse listeners after the class is added or removed. Touch (one-finger)
|
||||
// scrolling is handled separately by client/lib/dragscrollTouch.js.
|
||||
|
||||
export function enablePageDragscroll() {
|
||||
document.body.classList.add('dragscroll');
|
||||
dragscroll.reset();
|
||||
}
|
||||
|
||||
export function disablePageDragscroll() {
|
||||
document.body.classList.remove('dragscroll');
|
||||
dragscroll.reset();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/* eslint-env mocha */
|
||||
import { expect } from 'chai';
|
||||
import { Filter } from '/client/lib/filter';
|
||||
|
||||
// #3392: the board Filter sidebar can filter cards by dependency relation type.
|
||||
describe('Filter.cardDependencies', function () {
|
||||
afterEach(function () {
|
||||
// Leave the global Filter clean for other tests.
|
||||
if (Filter.cardDependencies._isActive()) {
|
||||
Filter.cardDependencies.reset();
|
||||
}
|
||||
});
|
||||
|
||||
it('is registered as a filterable field', function () {
|
||||
expect(Filter._fields).to.include('cardDependencies');
|
||||
expect(Filter.cardDependencies).to.be.an('object');
|
||||
});
|
||||
|
||||
it('toggles a relation type on and off', function () {
|
||||
expect(Filter.cardDependencies._isActive()).to.equal(false);
|
||||
Filter.cardDependencies.toggle('blocks');
|
||||
expect(Filter.cardDependencies.isSelected('blocks')).to.equal(true);
|
||||
expect(Filter.cardDependencies._isActive()).to.equal(true);
|
||||
Filter.cardDependencies.toggle('blocks');
|
||||
expect(Filter.cardDependencies.isSelected('blocks')).to.equal(false);
|
||||
expect(Filter.cardDependencies._isActive()).to.equal(false);
|
||||
});
|
||||
|
||||
it('builds a mongo selector on cardDependencies.type', function () {
|
||||
Filter.cardDependencies.toggle('blocks');
|
||||
const selector = JSON.stringify(Filter._getMongoSelector());
|
||||
expect(selector).to.contain('cardDependencies.type');
|
||||
expect(selector).to.contain('blocks');
|
||||
});
|
||||
});
|
||||
@@ -1 +1,2 @@
|
||||
import './Utils.tests';
|
||||
import './filter.dependencies.tests';
|
||||
@@ -345,6 +345,9 @@ export const Utils = {
|
||||
} else if (view === 'board-view-gantt') {
|
||||
window.localStorage.setItem('boardView', 'board-view-gantt'); //true
|
||||
Utils.reload();
|
||||
} else if (view === 'board-view-table') {
|
||||
window.localStorage.setItem('boardView', 'board-view-table'); //true
|
||||
Utils.reload();
|
||||
} else {
|
||||
window.localStorage.setItem('boardView', 'board-view-swimlanes'); //true
|
||||
Utils.reload();
|
||||
@@ -372,6 +375,10 @@ export const Utils = {
|
||||
return 'board-view-cal';
|
||||
} else if (window.localStorage.getItem('boardView') === 'board-view-gantt') {
|
||||
return 'board-view-gantt';
|
||||
} else if (
|
||||
window.localStorage.getItem('boardView') === 'board-view-table'
|
||||
) {
|
||||
return 'board-view-table';
|
||||
} else {
|
||||
window.localStorage.setItem('boardView', 'board-view-swimlanes'); //true
|
||||
Utils.reload();
|
||||
@@ -501,6 +508,22 @@ export const Utils = {
|
||||
Utils.reload();
|
||||
},
|
||||
|
||||
// #global search "my cards / all cards" toggle. Reactive (Session) so the
|
||||
// search re-runs immediately without a full page reload; persisted in
|
||||
// localStorage. Defaults to 'all' (search everything).
|
||||
globalSearchView() {
|
||||
const view =
|
||||
Session.get('globalSearchView') ||
|
||||
window.localStorage.getItem('globalSearchView');
|
||||
return view && ['me', 'all'].includes(view) ? view : 'all';
|
||||
},
|
||||
|
||||
setGlobalSearchView(view) {
|
||||
const next = ['me', 'all'].includes(view) ? view : 'all';
|
||||
window.localStorage.setItem('globalSearchView', next);
|
||||
Session.set('globalSearchView', next);
|
||||
},
|
||||
|
||||
myCardsView() {
|
||||
let view = window.localStorage.getItem('myCardsView');
|
||||
|
||||
|
||||
+67
-20
@@ -16,32 +16,57 @@ FlowRouter.triggers.exit([
|
||||
},
|
||||
]);
|
||||
|
||||
// All Boards page. The left-menu sub-views (Templates, Remaining, Starred) are
|
||||
// addressable via their own URL suffixes (#5850) so they can be linked and
|
||||
// redirected to; the chosen view is passed to boardList through the
|
||||
// `boardListMenu` Session value.
|
||||
function renderBoardList(ctx, menu) {
|
||||
// Redirect to sign-in immediately if user is not logged in
|
||||
if (!Meteor.userId()) {
|
||||
FlowRouter.go('atSignIn');
|
||||
return;
|
||||
}
|
||||
|
||||
Session.set('currentBoard', null);
|
||||
Session.set('currentList', null);
|
||||
Session.set('currentCard', null);
|
||||
Session.set('popupCardId', null);
|
||||
Session.set('popupCardBoardId', null);
|
||||
Session.set('boardListMenu', menu);
|
||||
Filter.reset();
|
||||
Session.set('sortBy', '');
|
||||
EscapeActions.executeAll();
|
||||
|
||||
Utils.manageCustomUI();
|
||||
Utils.manageMatomo();
|
||||
|
||||
ctx.render('defaultLayout', {
|
||||
headerBar: 'boardListHeaderBar',
|
||||
content: 'boardList',
|
||||
});
|
||||
}
|
||||
|
||||
FlowRouter.route('/', {
|
||||
name: 'home',
|
||||
triggersEnter: [AccountsTemplates.ensureSignedIn],
|
||||
action() {
|
||||
// Redirect to sign-in immediately if user is not logged in
|
||||
if (!Meteor.userId()) {
|
||||
FlowRouter.go('atSignIn');
|
||||
return;
|
||||
}
|
||||
renderBoardList(this, 'starred');
|
||||
},
|
||||
});
|
||||
|
||||
Session.set('currentBoard', null);
|
||||
Session.set('currentList', null);
|
||||
Session.set('currentCard', null);
|
||||
Session.set('popupCardId', null);
|
||||
Session.set('popupCardBoardId', null);
|
||||
Filter.reset();
|
||||
Session.set('sortBy', '');
|
||||
EscapeActions.executeAll();
|
||||
FlowRouter.route('/templates', {
|
||||
name: 'allboards-templates',
|
||||
triggersEnter: [AccountsTemplates.ensureSignedIn],
|
||||
action() {
|
||||
renderBoardList(this, 'templates');
|
||||
},
|
||||
});
|
||||
|
||||
Utils.manageCustomUI();
|
||||
Utils.manageMatomo();
|
||||
|
||||
this.render('defaultLayout', {
|
||||
headerBar: 'boardListHeaderBar',
|
||||
content: 'boardList',
|
||||
});
|
||||
FlowRouter.route('/remaining', {
|
||||
name: 'allboards-remaining',
|
||||
triggersEnter: [AccountsTemplates.ensureSignedIn],
|
||||
action() {
|
||||
renderBoardList(this, 'remaining');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -117,6 +142,28 @@ FlowRouter.route('/support', {
|
||||
},
|
||||
});
|
||||
|
||||
FlowRouter.route('/b/:id/:slug/rules', {
|
||||
name: 'board-rules',
|
||||
triggersEnter: [AccountsTemplates.ensureSignedIn],
|
||||
action(params) {
|
||||
const currentBoard = params.id;
|
||||
Session.set('currentBoard', currentBoard);
|
||||
Session.set('currentCard', null);
|
||||
Session.set('popupCardId', null);
|
||||
Session.set('popupCardBoardId', null);
|
||||
|
||||
EscapeActions.executeUpTo('popup-close');
|
||||
|
||||
Utils.manageCustomUI();
|
||||
Utils.manageMatomo();
|
||||
|
||||
this.render('defaultLayout', {
|
||||
headerBar: 'rulesHeaderBar',
|
||||
content: 'rulesMain',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Card route MUST be registered BEFORE board route so it matches first
|
||||
FlowRouter.route('/b/:boardId/:slug/:cardId', {
|
||||
name: 'card',
|
||||
|
||||
+39
-2
@@ -494,6 +494,11 @@ services:
|
||||
# c) Disabled
|
||||
- BIGEVENTS_PATTERN=NONE
|
||||
#---------------------------------------------------------------
|
||||
# ==== NOTIFY ON ASSIGN =====
|
||||
# Notify the user directly when they are added as a card member or
|
||||
# assignee. Set false to disable.
|
||||
#- NOTIFY_ON_ASSIGN=true
|
||||
#---------------------------------------------------------------
|
||||
# ==== EMAIL DUE DATE NOTIFICATION =====
|
||||
# https://github.com/wekan/wekan/pull/2536
|
||||
# System timelines will be showing any user modification for
|
||||
@@ -576,6 +581,12 @@ services:
|
||||
# enable if you fully trust your OIDC provider's email claims, and even
|
||||
# then the provider must send email_verified=true for the merge to happen.
|
||||
#- OAUTH2_MERGE_EXISTING_USERS=false
|
||||
# When false, OAuth2/OIDC login is refused for users that do not already
|
||||
# have a Wekan account (matched by verified email).
|
||||
#- OAUTH2_AUTO_REGISTRATION=true
|
||||
# Comma/space separated OAuth2/OIDC group names whose members become Wekan
|
||||
# admins. Empty = off.
|
||||
#- OAUTH2_ADMIN_GROUPS=
|
||||
# OAuth2 login style: popup or redirect.
|
||||
#- OAUTH2_LOGIN_STYLE=redirect
|
||||
# Application GUID captured during app registration:
|
||||
@@ -780,6 +791,11 @@ services:
|
||||
#
|
||||
#- LDAP_BACKGROUND_SYNC_IMPORT_NEW_USERS=false
|
||||
#
|
||||
# LDAP_BACKGROUND_SYNC_DISABLE_NONEXISTANT_USERS : When true, the LDAP
|
||||
# background sync disables users no longer present in LDAP (and re-enables
|
||||
# them when they reappear).
|
||||
#- LDAP_BACKGROUND_SYNC_DISABLE_NONEXISTANT_USERS=false
|
||||
#
|
||||
# If using LDAPS: LDAP_ENCRYPTION=ssl
|
||||
#- LDAP_ENCRYPTION=false
|
||||
#
|
||||
@@ -876,6 +892,18 @@ services:
|
||||
#
|
||||
# Comma separated list of admin group names to sync.
|
||||
#- LDAP_SYNC_ADMIN_GROUPS=group1,group2
|
||||
#
|
||||
# When true, sync a user's LDAP groups as Wekan Organizations.
|
||||
#- LDAP_SYNC_ORGANIZATIONS=false
|
||||
#
|
||||
# Comma separated allowlist of LDAP group names to sync as Organizations. Empty = all of the user's groups.
|
||||
#- LDAP_SYNC_ORGANIZATIONS_GROUPS=
|
||||
#
|
||||
# When true, sync a user's LDAP groups as Wekan Teams.
|
||||
#- LDAP_SYNC_TEAMS=false
|
||||
#
|
||||
# Comma separated allowlist of LDAP group names to sync as Teams. Empty = all of the user's groups.
|
||||
#- LDAP_SYNC_TEAMS_GROUPS=
|
||||
#---------------------------------------------------------------------
|
||||
# Login to LDAP automatically with HTTP header.
|
||||
# In below example for siteminder, at right side of = is header name.
|
||||
@@ -883,9 +911,18 @@ services:
|
||||
#- HEADER_LOGIN_FIRSTNAME=HEADERFIRSTNAME
|
||||
#- HEADER_LOGIN_LASTNAME=HEADERLASTNAME
|
||||
#- HEADER_LOGIN_EMAIL=HEADEREMAILADDRESS
|
||||
# Optional comma-separated trusted proxy source IPs for header login.
|
||||
# If empty, all source IPs are accepted (not recommended).
|
||||
# SECURITY (GHSA-jggc-qvfc-jr6x): comma-separated allowlist of source IPs
|
||||
# permitted to use header login. The source IP is the real TCP peer of the
|
||||
# connection (your reverse proxy), NOT the spoofable X-Forwarded-For header.
|
||||
# REQUIRED when header login is enabled: if empty/unset, header login fails
|
||||
# CLOSED and authenticates no one.
|
||||
#- HEADER_LOGIN_TRUSTED_IPS=127.0.0.1,10.0.0.2
|
||||
# Optional: if WeKan sits behind MULTIPLE proxy hops, list the intermediate
|
||||
# proxy IPs here. X-Forwarded-For is then honored ONLY when the immediate
|
||||
# TCP peer is one of these trusted proxies, and the right-most hop that is
|
||||
# not itself a trusted proxy (the real client) is matched against
|
||||
# HEADER_LOGIN_TRUSTED_IPS above.
|
||||
#- HEADER_LOGIN_TRUSTED_PROXIES=10.0.0.1,10.0.0.2
|
||||
#---------------------------------------------------------------------
|
||||
# ==== LOGOUT TIMER, probably does not work yet ====
|
||||
# LOGOUT_WITH_TIMER : Enables or not the option logout with timer
|
||||
|
||||
@@ -46,6 +46,17 @@ When calling a production Wekan server, ensure it is running via HTTPS and has a
|
||||
| `POST` | `/api/boards/:boardId/lists/:listId/cards` | [Add a card to a list, board, and swimlane.](REST-API-Cards#add-card-to-list-board-swimlane) |
|
||||
| `PUT` | `/api/boards/:boardId/lists/:fromListId/cards/:cardId` | [Update a card.](REST-API-Cards#update-a-card) |
|
||||
| `DELETE` | `/api/boards/:boardId/lists/:listId/cards/:cardId` | [Delete a card.](REST-API-Cards#update-a-card) |
|
||||
### Board Domain Sharing
|
||||
| HTTP Method | Url | Short Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `GET` | `/api/boards/:boardId/domains` | [List the email domains a board is shared with.](#list-board-domains) |
|
||||
| `POST` | `/api/boards/:boardId/domains` | [Share a board with an email domain.](#add-a-board-domain) |
|
||||
| `DELETE` | `/api/boards/:boardId/domains/:domain` | [Stop sharing a board with an email domain.](#remove-a-board-domain) |
|
||||
### GlobalAdmin Settings
|
||||
| HTTP Method | Url | Short Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `GET` | `/api/settings` | [Read the Admin Panel global settings.](#get-settings) |
|
||||
| `PUT` | `/api/settings` | [Update the Admin Panel global settings.](#update-settings) |
|
||||
|
||||
|
||||
---
|
||||
@@ -124,3 +135,89 @@ curl -H "Content-type:application/json" \
|
||||
"tokenExpires": "2017-12-15T00:47:26.303Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Board Domain Sharing
|
||||
|
||||
Boards can be shared with every user on an email **domain** (for example everyone
|
||||
with an `@example.com` address). These endpoints list, add and remove the domains a
|
||||
board is shared with.
|
||||
|
||||
A board admin (or a site admin) is required to add or remove domains. Domains are
|
||||
validated: they are stored lowercase, must contain a `.`, and must not contain `@`
|
||||
or whitespace.
|
||||
|
||||
The matching `api.py` commands are `boarddomains`, `addboarddomain` and
|
||||
`removeboarddomain`.
|
||||
|
||||
## List board domains
|
||||
| URL | Requires Auth | HTTP Method |
|
||||
| :--- | :--- | :--- |
|
||||
| `/api/boards/:boardId/domains` | `yes` | `GET` |
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer t7iYB86mXoLfP_XsMegxF41oKT7iiA9lDYiKVtXcctl" \
|
||||
http://localhost:3000/api/boards/abcd1234/domains
|
||||
```
|
||||
|
||||
## Add a board domain
|
||||
| URL | Requires Auth (board admin) | HTTP Method |
|
||||
| :--- | :--- | :--- |
|
||||
| `/api/boards/:boardId/domains` | `yes` | `POST` |
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer t7iYB86mXoLfP_XsMegxF41oKT7iiA9lDYiKVtXcctl" \
|
||||
-H "Content-type:application/json" \
|
||||
-X POST \
|
||||
http://localhost:3000/api/boards/abcd1234/domains \
|
||||
-d '{ "domain": "example.com" }'
|
||||
```
|
||||
|
||||
## Remove a board domain
|
||||
| URL | Requires Auth (board admin) | HTTP Method |
|
||||
| :--- | :--- | :--- |
|
||||
| `/api/boards/:boardId/domains/:domain` | `yes` | `DELETE` |
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer t7iYB86mXoLfP_XsMegxF41oKT7iiA9lDYiKVtXcctl" \
|
||||
-X DELETE \
|
||||
http://localhost:3000/api/boards/abcd1234/domains/example.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# GlobalAdmin Settings
|
||||
|
||||
These endpoints read and update the **Admin Panel** global settings (registration,
|
||||
product name, logos, custom head / manifest, accessibility and support pages, and
|
||||
so on). They are **global-admin only**.
|
||||
|
||||
Updates are applied through a field **whitelist**, so only the supported settings
|
||||
fields can be changed. For security, `mailServer` / SMTP credentials are **never
|
||||
returned** by `GET` and are **never writable** by `PUT`.
|
||||
|
||||
The matching `api.py` commands are `getsettings` and `editsettings <field> <value>`.
|
||||
|
||||
## Get settings
|
||||
| URL | Requires Auth (global admin) | HTTP Method |
|
||||
| :--- | :--- | :--- |
|
||||
| `/api/settings` | `yes` | `GET` |
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer t7iYB86mXoLfP_XsMegxF41oKT7iiA9lDYiKVtXcctl" \
|
||||
http://localhost:3000/api/settings
|
||||
```
|
||||
|
||||
## Update settings
|
||||
| URL | Requires Auth (global admin) | HTTP Method |
|
||||
| :--- | :--- | :--- |
|
||||
| `/api/settings` | `yes` | `PUT` |
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer t7iYB86mXoLfP_XsMegxF41oKT7iiA9lDYiKVtXcctl" \
|
||||
-H "Content-type:application/json" \
|
||||
-X PUT \
|
||||
http://localhost:3000/api/settings \
|
||||
-d '{ "productName": "My WeKan", "disableRegistration": true }'
|
||||
```
|
||||
@@ -1,3 +1,6 @@
|
||||
{% raw %}
|
||||
<!-- Contains literal {{> ...}} template snippets; wrap in raw so Jekyll's
|
||||
Liquid engine does not try to evaluate them when building GitHub Pages. -->
|
||||
# Original Positions Tracking Feature
|
||||
|
||||
This feature allows users to see the original positions of swimlanes, lists, and cards before the list naming feature was added in commit [719ef87efceacfe91461a8eeca7cf74d11f4cc0a](https://github.com/wekan/wekan/commit/719ef87efceacfe91461a8eeca7cf74d11f4cc0a).
|
||||
@@ -246,3 +249,4 @@ For issues or questions about the original positions tracking feature, please:
|
||||
2. Verify that all required files are present
|
||||
3. Test with a new board to ensure the feature works correctly
|
||||
4. Report issues with detailed error messages and steps to reproduce
|
||||
{% endraw %}
|
||||
@@ -1,36 +1,18 @@
|
||||
## About markdown changes
|
||||
# Build WeKan and Create Pull Request
|
||||
|
||||
Wekan v4.29 changes markdown rendering from [marked](https://github.com/markedjs/marked) to [markdown-it](https://github.com/markdown-it/markdown-it).
|
||||
|
||||
## About emoji
|
||||
|
||||
With markdown-it, also [markdown-it-emoji](https://github.com/markdown-it/markdown-it-emoji) plugin has been added, supporting [full list of GitHub emojis](https://github.com/markdown-it/markdown-it-emoji/blob/master/lib/data/full.json).
|
||||
|
||||
Example emoji code, that you can add for example to card name, card description etc:
|
||||
```
|
||||
:rainbow: :thumbsup: :100:
|
||||
```
|
||||
That shows emojis :rainbow: :thumbsup: :100:
|
||||
|
||||
## About other markdown-it plugins
|
||||
|
||||
For markdown-it, there are also other [syntax extensions](https://github.com/markdown-it/markdown-it#syntax-extensions) where some are listed at that markdown-it page, and [others at npmjs.com](https://www.npmjs.org/browse/keyword/markdown-it-plugin).
|
||||
|
||||
For example, how to get some [mermaid plugin](https://www.npmjs.com/search?q=markdown-it-mermaid) working so that some syntax works for https://mermaid-js.github.io/mermaid/ ?
|
||||
|
||||
## How you could add another plugin
|
||||
WeKan currently uses Meteor 3.
|
||||
|
||||
Using newest Ubuntu amd64:
|
||||
|
||||
# Meteor 2
|
||||
# Meteor 3
|
||||
|
||||
### 1. Install git and configure it
|
||||
```
|
||||
sudo apt -y install git
|
||||
|
||||
git config --global user.name "Yourfirstname Yourlastname"
|
||||
git config --global user.name "YOUR-FIRSTNAME YOUR-LASTNAME"
|
||||
|
||||
git config --global user.email email-address-you-use-at-github@example.com
|
||||
git config --global user.email YOUR-EMAIL-ADDRESS@gmail.com
|
||||
|
||||
git config --global push.default simple
|
||||
|
||||
@@ -44,47 +26,34 @@ Host *
|
||||
|
||||
Host github.com
|
||||
Hostname github.com
|
||||
User xet7
|
||||
IdentityFile ~/.ssh/id_xet7ed
|
||||
User YOUR-GITHUB-USERNAME
|
||||
IdentityFile ~/.ssh/id_github
|
||||
```
|
||||
Save and Exit with Ctrl-o Enter Ctrl-x Enter
|
||||
|
||||
If you do not have ssh key, create it:
|
||||
```
|
||||
ssh-keygen
|
||||
ssh-keygen -t ed25519 -f ~/.ssh/id_github
|
||||
```
|
||||
And press Enter about 3 times, until you have private key at `~/.ssh/id_rsa` and public key at `~/.ssh/id_rsa.pub`
|
||||
And press Enter about 3 times, until you have private key at `~/.ssh/id_github` and public key at `~/.ssh/id_github.pub`
|
||||
|
||||
Add public key `.pub` to your github account web interface.
|
||||
Add public key `.pub` to your GitHub SSH Keys at https://github.com/settings/keys
|
||||
|
||||
Add path to Meteor:
|
||||
```
|
||||
nano .bashrc
|
||||
```
|
||||
There at bottom add:
|
||||
```
|
||||
export PATH=~/.meteor:$PATH
|
||||
```
|
||||
Save and Exit with Ctrl-o Enter Ctrl-x Enter
|
||||
|
||||
### 2. Create fork of `https://github.com/wekan/wekan` at GitHub web page
|
||||
### 2. Click Create fork at https://github.com/wekan/wekan webpage
|
||||
|
||||
```
|
||||
mkdir repos
|
||||
|
||||
cd repos
|
||||
|
||||
git clone git@github.com:YourGithubUsername/wekan.git
|
||||
git clone git@github.com:YOUR-GITHUB-USERNAME/wekan.git
|
||||
|
||||
cd wekan
|
||||
```
|
||||
|
||||
### IMPORTANT: Do NOT `npm audit fix --force`
|
||||
|
||||
IMPORTANT: Do NOT `npm audit fix --force`, it downgrades @meteorjs/rspack and breaks builds.
|
||||
`npm audit` shows false info, it does not recognize that elliptic is already updated
|
||||
to 6.6.1 with override at package.json like you can see with `npm list elliptic`.
|
||||
https://github.com/wekan/wekan/blob/main/CHANGELOG.md#v850-2026-04-03-wekan--release
|
||||
Because it downgrades @meteorjs/rspack and breaks builds.
|
||||
|
||||
### 3. Select option 1 to install dependencies, and then Enter.
|
||||
|
||||
@@ -102,47 +71,25 @@ https://github.com/wekan/wekan/blob/main/CHANGELOG.md#v850-2026-04-03-wekan--rel
|
||||
|
||||
3
|
||||
```
|
||||
That does: 1 install dependencies, 2 builds wekan, 3 starts wekan in development mode with command `meteor`, so it can detect if some file changes and try to rebuild automatically and reload webbroser. But, still sometimes, it may need stopping with Ctrl-c and full build with option 2.
|
||||
That does:
|
||||
- 1 install dependencies,
|
||||
- 2 builds wekan
|
||||
- 3 starts wekan in development mode with command `meteor`, so it can detect if some file changes and try to rebuild automatically.
|
||||
|
||||
And then [register and login](Adding-users) at http://localhost:4000
|
||||
And then [register and login](Adding-users) with webbrowser at http://localhost:4000
|
||||
|
||||
### OPTIONAL, NOT NEEDED: 5. Add new plugin package
|
||||
```
|
||||
meteor npm install markdown-it-something --save
|
||||
```
|
||||
### 4. Make and test your changes
|
||||
|
||||
Edit file `wekan/packages/markdown/src-integration.js` and add using that new package, using code example from that new plugin page, or similar like emoji plugin was added.
|
||||
Use `rebuild-wekan.sh` to run all tests.
|
||||
|
||||
### 7. Test
|
||||
### 5. If it works, create pull request
|
||||
|
||||
Test does that new plugin syntax work, for example in card title, card description etc on other input fields.
|
||||
|
||||
### 8. If it works, create pull request
|
||||
|
||||
If normal markdown, emoji, and your new added plugin syntax all work, commit your changes:
|
||||
When your changes work, commit them:
|
||||
```
|
||||
git add --all
|
||||
|
||||
git commit -m "Added plugin markdown-it-something."
|
||||
git commit -m "Describe your change here."
|
||||
|
||||
git push
|
||||
```
|
||||
And then at your GitHub for `https://github.com/YOUR-GITHUB-USERNAME/wekan` click `Create pull request`.
|
||||
|
||||
## Updating
|
||||
|
||||
Updating to next Meteor release:
|
||||
```
|
||||
meteor update
|
||||
|
||||
meteor npm update
|
||||
```
|
||||
Updating to specific Meteor release:
|
||||
```
|
||||
meteor update --release METEOR@3.4
|
||||
```
|
||||
If you are changing Meteor and Node.js versions, you may need to reset Meteor:
|
||||
```
|
||||
meteor reset
|
||||
```
|
||||
Or alternatively, delete wekan repo (if you did not need any changes you made), and clone wekan repo again, and then build etc.
|
||||
@@ -1,3 +1,6 @@
|
||||
{% raw %}
|
||||
<!-- Contains a literal {{variable}} code example; wrap in raw so Jekyll's
|
||||
Liquid engine does not try to evaluate it when building GitHub Pages. -->
|
||||
[Azure Email Communication Service](https://github.com/wekan/wekan/issues/5453)
|
||||
|
||||
[Uberspace Email](https://github.com/wekan/wekan/issues/2009#issuecomment-1017630758)
|
||||
@@ -568,4 +571,5 @@ By default zoho uses port number 465 with TLS enabled.
|
||||
|
||||
Step 3: form MAIL_FROM
|
||||
|
||||
MAIL_FROM=Wekan Notifications <user1@example.com>
|
||||
MAIL_FROM=Wekan Notifications <user1@example.com>
|
||||
{% endraw %}
|
||||
Loaded 100 of 405 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user