mirror of
https://github.com/wekan/wekan.git
synced 2026-11-06 12:45:03 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b98272272 | ||
|
|
ee22fb1a3b | ||
|
|
154c9a6e52 | ||
|
|
121f37be91 | ||
|
|
79c40407d1 | ||
|
|
148d18d456 | ||
|
|
15877dd1bc | ||
|
|
3ccb465c3f | ||
|
|
71d9776b61 | ||
|
|
34d9817110 | ||
|
|
aa939d7bec | ||
|
|
194e5b0a51 | ||
|
|
532a96da11 | ||
|
|
ba0de93f79 | ||
|
|
9a15491985 | ||
|
|
c8ed3e0fd0 | ||
|
|
4a9c3f21d2 | ||
|
|
3a2fef4dc7 | ||
|
|
f241681e80 | ||
|
|
c71c875649 | ||
|
|
0da86171e9 | ||
|
|
b80e21fd48 | ||
|
|
24a7270b7f | ||
|
|
73ea323da1 | ||
|
|
be6727607c | ||
|
|
9c5fa760c3 | ||
|
|
27040c6f2f | ||
|
|
c15e1ab4b2 | ||
|
|
b676cdc169 | ||
|
|
4ad13db5ef | ||
|
|
efe0e9287c | ||
|
|
cfcead6a1a | ||
|
|
40f60582b7 | ||
|
|
96845f6f4b | ||
|
|
c5f5ad2338 | ||
|
|
0c8c254dc5 | ||
|
|
0a1f8584ee | ||
|
|
2042fd5102 | ||
|
|
0403e27c62 | ||
|
|
d9e48a909d | ||
|
|
0cea141702 | ||
|
|
9188843369 | ||
|
|
dbf07aadf9 | ||
|
|
e001c4f9c2 | ||
|
|
ffe207e191 | ||
|
|
b2198cb61b | ||
|
|
a6bec88266 | ||
|
|
8a1f7a14a9 | ||
|
|
7f948bf438 | ||
|
|
6d0ab6cf11 | ||
|
|
89b64966c6 | ||
|
|
d5e3325e87 | ||
|
|
897376ba54 | ||
|
|
16103f9ad2 | ||
|
|
8cbe2605fe | ||
|
|
f5eb4ecf61 | ||
|
|
8d4f4caa2f | ||
|
|
9a74138a84 | ||
|
|
fa7e447536 | ||
|
|
05cc2460ea | ||
|
|
5c50f38e25 | ||
|
|
338afc15f8 | ||
|
|
dd2082b0ea | ||
|
|
4a86e9c19e | ||
|
|
568cd9b6ec | ||
|
|
fea468471d | ||
|
|
83199b9af7 | ||
|
|
3839ee698d | ||
|
|
a60b996c92 | ||
|
|
1230164c16 | ||
|
|
8008f410fe | ||
|
|
bd90782da2 | ||
|
|
5eadc45563 | ||
|
|
3785a13f6a | ||
|
|
97cabdeca4 | ||
|
|
2802326888 | ||
|
|
1f46af0639 | ||
|
|
1bcb1da92f | ||
|
|
067efac7f8 | ||
|
|
2bdd16060e | ||
|
|
433bc62211 | ||
|
|
29f9c62a64 | ||
|
|
21766cf38d | ||
|
|
101cdb7579 | ||
|
|
54cf951d9c | ||
|
|
57a43ee8e1 | ||
|
|
ff2c0ed7f6 | ||
|
|
63d022fe5f | ||
|
|
e9cf825b22 | ||
|
|
4ce8514e36 | ||
|
|
429fdc5319 | ||
|
|
ffd5cbd2ad | ||
|
|
cf4b48df09 | ||
|
|
73c21dfe3e | ||
|
|
22a12dc7f8 | ||
|
|
b1403ca7af | ||
|
|
bc21c901f4 | ||
|
|
88fef5dc08 | ||
|
|
3f9379ac86 | ||
|
|
85af731c1b | ||
|
|
bb32a50550 | ||
|
|
c99aa7f845 | ||
|
|
508484ef1e | ||
|
|
548196c9a9 | ||
|
|
9a118c83b2 | ||
|
|
d442bd8a7e | ||
|
|
5a13e927bc | ||
|
|
c78af24a36 | ||
|
|
6551062492 | ||
|
|
a0ed5de6f1 | ||
|
|
f406d46fe7 | ||
|
|
4b0aa715eb | ||
|
|
f5af33ac38 | ||
|
|
f165a24192 | ||
|
|
e6ea524541 | ||
|
|
de23dece66 | ||
|
|
2017f2871f | ||
|
|
302b47db6f | ||
|
|
482c2c29f4 | ||
|
|
4d5b832499 | ||
|
|
41173f0f6b | ||
|
|
d7abe9910a | ||
|
|
48a2e97ea3 | ||
|
|
4993242c5f | ||
|
|
12eef31162 | ||
|
|
0b5c855820 | ||
|
|
0c53ef6960 | ||
|
|
2ab0afdc93 | ||
|
|
f2a1d7b9ba | ||
|
|
cd1f1b8f05 | ||
|
|
b941f36e4c | ||
|
|
487a528806 | ||
|
|
dc9a7bb1ac | ||
|
|
696a1a2c8a | ||
|
|
ef1e683cdd | ||
|
|
55163d0ce6 | ||
|
|
b974ba426d | ||
|
|
e9751b65d4 | ||
|
|
5abc2809b9 | ||
|
|
03f3a197cf | ||
|
|
6ed3f5cfd0 | ||
|
|
e372016826 | ||
|
|
fd699b67e9 | ||
|
|
827641193f | ||
|
|
fb7bd9c6de | ||
|
|
fcd995095d | ||
|
|
b7b5ec3711 | ||
|
|
1af642069c | ||
|
|
0343e6614b | ||
|
|
f1c0db247a | ||
|
|
61e838df46 | ||
|
|
057cbc26d9 | ||
|
|
22e8b9bc86 | ||
|
|
602b47f06a | ||
|
|
a086323a1f | ||
|
|
22140739ab | ||
|
|
55b71ce19e | ||
|
|
1a97c8bd86 | ||
|
|
b4e5f90afb | ||
|
|
2c1fc39f61 | ||
|
|
ee82c21718 | ||
|
|
10851cd3f4 | ||
|
|
a937ed2180 | ||
|
|
22be2f2dc0 | ||
|
|
833c0bc021 | ||
|
|
1251c1c8b3 | ||
|
|
5b428faea6 | ||
|
|
ca2dc5fae5 | ||
|
|
b9f6eff3d9 | ||
|
|
43c4d41877 | ||
|
|
609c6d3d64 | ||
|
|
8097935f5d | ||
|
|
4f7222f541 | ||
|
|
6bbb4d39bc | ||
|
|
a1f2c006b4 | ||
|
|
dfdadaa8ca | ||
|
|
856a32968b | ||
|
|
e4b83d2274 | ||
|
|
d06b468629 | ||
|
|
379058bc9d | ||
|
|
5534c8830c | ||
|
|
48a8937ff0 | ||
|
|
2dcccf0d4a | ||
|
|
bf266fb4da | ||
|
|
ffe49dd2fd | ||
|
|
ee16bdce6b | ||
|
|
44bae91f8e | ||
|
|
ae13eab23e | ||
|
|
f5f6ad4d4d | ||
|
|
0d6d481fcb | ||
|
|
99d9958e39 | ||
|
|
daae36b313 | ||
|
|
8556459f97 | ||
|
|
7f9e8ea7a3 | ||
|
|
01968fc637 | ||
|
|
04c2598cf4 | ||
|
|
77ded27994 | ||
|
|
ec6cf66bff | ||
|
|
8561f49ed9 | ||
|
|
749e9b76a7 | ||
|
|
ad43de3c01 | ||
|
|
2bd5d1180a | ||
|
|
eddfd515ea | ||
|
|
9c9742e09d | ||
|
|
0250e54a07 | ||
|
|
3e5bc58105 | ||
|
|
039935dd0b | ||
|
|
5adfc1b9db | ||
|
|
f269b1ef16 | ||
|
|
52de57c844 | ||
|
|
81e6988491 | ||
|
|
8b4987d5bd | ||
|
|
e12106ce7a | ||
|
|
120f484a30 | ||
|
|
08ff3327c4 | ||
|
|
d74dbbe409 | ||
|
|
2fd0c77164 | ||
|
|
6e18251e5f | ||
|
|
0a224b3cea | ||
|
|
a834211ab6 | ||
|
|
c0ec28e82f | ||
|
|
6dcb9fa6e2 | ||
|
|
9dc3fc3f31 | ||
|
|
e0eeae82d6 | ||
|
|
b349576a15 | ||
|
|
d519091909 | ||
|
|
55d64e6493 | ||
|
|
cbdff47edb | ||
|
|
b36092896b | ||
|
|
70de255333 | ||
|
|
bce686ce46 | ||
|
|
facfc125ee | ||
|
|
1167cbd3b0 | ||
|
|
d24598ce25 | ||
|
|
f68b130f15 | ||
|
|
290e525b46 | ||
|
|
427d212627 | ||
|
|
c1fe35bf6c | ||
|
|
0dee1da686 | ||
|
|
8ff34b423b | ||
|
|
0c40503a2b | ||
|
|
35ba67ee69 | ||
|
|
58751aa339 | ||
|
|
2844fa07f7 | ||
|
|
bb614743fd | ||
|
|
9549388dd0 | ||
|
|
9c0f408a00 | ||
|
|
3f72fcd7f9 | ||
|
|
3e998651ea | ||
|
|
cbf9d2f843 | ||
|
|
9994e82bae | ||
|
|
e13a5a59ad | ||
|
|
6ac70c4cdd | ||
|
|
42ed50413f | ||
|
|
8af1e8aec8 | ||
|
|
e79ec61bcc | ||
|
|
aeff4900df | ||
|
|
d1b96f65c3 | ||
|
|
96ad680cd4 | ||
|
|
9927adcb44 | ||
|
|
6675f0e5d3 | ||
|
|
43e09631c4 | ||
|
|
1f9ada5599 | ||
|
|
0ed8fb20f2 | ||
|
|
283e51eac0 | ||
|
|
fa1bb293f9 | ||
|
|
d8d7b01334 | ||
|
|
b1370fbc5e | ||
|
|
a40c4495d4 | ||
|
|
02bb0814c3 | ||
|
|
77956c98ed | ||
|
|
314c153c9b | ||
|
|
e7505a4fef | ||
|
|
d3e80af1ac | ||
|
|
c355926767 | ||
|
|
6abcc8fa1f | ||
|
|
b87858baaf | ||
|
|
d481bffdfe | ||
|
|
dad16609c9 | ||
|
|
5f3b211b37 | ||
|
|
2be842351b | ||
|
|
e9440d503e | ||
|
|
66b4ad8dcb | ||
|
|
aae0490b71 | ||
|
|
4a656baea3 | ||
|
|
e8a1106782 | ||
|
|
a599e066b3 | ||
|
|
9679aaf636 | ||
|
|
13d3a74b3f | ||
|
|
06e27909df | ||
|
|
66ee2d12f3 | ||
|
|
9e47a188ce | ||
|
|
a8f65adff2 | ||
|
|
2929b61c85 | ||
|
|
69a8bae0c6 | ||
|
|
978af29b33 | ||
|
|
e6f7d226ca | ||
|
|
437da1d9c7 | ||
|
|
a4197f228f | ||
|
|
f852cb7032 | ||
|
|
8f9103c336 | ||
|
|
6241be9053 | ||
|
|
c438682700 | ||
|
|
30e960e129 | ||
|
|
9bea13f152 | ||
|
|
d9cf523f9e | ||
|
|
c1c9d3fb03 | ||
|
|
00cf8f0958 | ||
|
|
125e1659f8 | ||
|
|
6c2edd6752 | ||
|
|
8788458a87 | ||
|
|
1f5a7b8220 | ||
|
|
036ac4f56b | ||
|
|
c7145402af | ||
|
|
f615d06e47 | ||
|
|
ebcd02be6f | ||
|
|
2799eb900f | ||
|
|
ccc67ddf68 | ||
|
|
51111e1958 | ||
|
|
eade07c914 | ||
|
|
595e26cfa8 | ||
|
|
32031c64c4 | ||
|
|
f2908f34e7 | ||
|
|
742996dcce | ||
|
|
f89c8f8030 | ||
|
|
6f5aab875b | ||
|
|
4bd6a7f088 | ||
|
|
2dfdf77c2b | ||
|
|
f6fd560817 | ||
|
|
a24da2a039 | ||
|
|
d05e4db865 | ||
|
|
7ed3ca7c5c | ||
|
|
8224852de1 | ||
|
|
8d61402f5b | ||
|
|
37b3c8f599 | ||
|
|
9b65aed902 | ||
|
|
37e9253c93 | ||
|
|
3f718859bc | ||
|
|
831b2bd8b5 | ||
|
|
c8a15b514f | ||
|
|
c687fe5577 | ||
|
|
1aa9df6075 | ||
|
|
cca9ce36cf | ||
|
|
b54525abbe | ||
|
|
5056790c05 | ||
|
|
80b936223f | ||
|
|
21b2da9ad2 | ||
|
|
43c8606548 | ||
|
|
e5ed5d16dc | ||
|
|
213af1c1bd | ||
|
|
ca53019d96 | ||
|
|
37024aa9f9 | ||
|
|
f33987931f | ||
|
|
80d0b2c47e | ||
|
|
ede0d68b27 | ||
|
|
a433d20189 | ||
|
|
c69f3fe25a | ||
|
|
65621f44cc | ||
|
|
4f20f24201 | ||
|
|
20e2273c07 | ||
|
|
cef5ddc506 | ||
|
|
5d80b07bed | ||
|
|
c4f528159b | ||
|
|
0782bda994 | ||
|
|
6fe0a7ef94 | ||
|
|
40dbb829c9 | ||
|
|
91c12ebcce | ||
|
|
c486518f03 | ||
|
|
d5d027a6f2 | ||
|
|
d3dead539b | ||
|
|
da4a0cf013 | ||
|
|
1c26e1bcd0 | ||
|
|
0390468e07 | ||
|
|
e748559838 | ||
|
|
9e9e1e271f | ||
|
|
54726264d3 | ||
|
|
0afb1336db | ||
|
|
4bea319239 | ||
|
|
c11976a2e1 | ||
|
|
c6dfb95002 | ||
|
|
2c90b26401 | ||
|
|
a90576bafa | ||
|
|
4c8531589b | ||
|
|
18878f6f29 | ||
|
|
ac8889e61c | ||
|
|
31332bfed4 | ||
|
|
d906733f60 | ||
|
|
bf7520f972 | ||
|
|
d5395fbb4c | ||
|
|
e81d17da80 | ||
|
|
56292d0a0e | ||
|
|
494cbee598 | ||
|
|
47dcbb5dd2 | ||
|
|
353f01a03a | ||
|
|
164aaa03c9 | ||
|
|
e2efc8ebf9 | ||
|
|
9d2b64273b | ||
|
|
e9fa734853 | ||
|
|
b3233e5665 | ||
|
|
52bcd74efa | ||
|
|
132fecbfd8 | ||
|
|
ae2bb47d3a | ||
|
|
80208a7141 | ||
|
|
16b9b14898 | ||
|
|
6d68e9a6ea | ||
|
|
6bb926af19 |
No files matched your search
@@ -405,6 +405,10 @@ jobs:
|
||||
APPRUN
|
||||
sed -i "s/@APPIMAGE_ARCH@/$arch/" AppDir/AppRun
|
||||
chmod +x AppDir/AppRun
|
||||
# The AppImage runs as whoever starts it, not as this runner, so
|
||||
# everything in it must be readable by all. v12.69's FerretDB and
|
||||
# mongo-tools were 0711 and appimagetool refused to pack them.
|
||||
chmod -R a+rX AppDir
|
||||
|
||||
- name: Pack the AppImage
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
app/.tools/meteor-bun/dev_bundle
|
||||
app/.tools/meteor-bun/.babel-cache
|
||||
app/.tools/meteor-bun/packages/*/.npm
|
||||
key: bun-meteor-${{ runner.os }}-${{ hashFiles('app/.meteor/release', 'app/.meteor/versions', 'build-tools/releases/bun/meteor-3.6-rc.0.patch') }}
|
||||
key: bun-meteor-${{ runner.os }}-${{ hashFiles('app/.meteor/release', 'app/.meteor/versions', 'build-tools/releases/bun/meteor-3.6-rc.1.patch') }}
|
||||
- name: Build the release's ESM application once
|
||||
working-directory: app
|
||||
env:
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: bun-esm-source
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- name: Unpack ESM and fetch verified target Bun
|
||||
@@ -172,6 +172,10 @@ jobs:
|
||||
run: |
|
||||
mkdir -p .tools/tmp
|
||||
export TMPDIR="$PWD/.tools/tmp"
|
||||
# Where releases/tools-env.sh, which build-target.sh loads, looks for
|
||||
# them. Installed in ~/.cache instead, the smoke test found no browser
|
||||
# (linux-arm64, v12.66).
|
||||
export PLAYWRIGHT_BROWSERS_PATH="$PWD/.tools/ms-playwright"
|
||||
npm ci --prefix tests/playwright --ignore-scripts
|
||||
tests/playwright/node_modules/.bin/playwright install --with-deps chromium
|
||||
- name: Start an isolated MongoDB for Linux HTTP and DDP tests
|
||||
@@ -180,7 +184,11 @@ jobs:
|
||||
# Not --rm: a container that dies must leave its log behind. v12.56
|
||||
# linux-x64 found nothing on 27019 ("Topology is closed") and there
|
||||
# was no way to see why. Wait until it answers, or show why not.
|
||||
docker run -d --name bun-smoke-mongo -p 127.0.0.1:27019:27017 mongo:8
|
||||
# 9.0.4, not mongo:8: on the runner's 6.19+ kernel mongo:8 refused to
|
||||
# start ("Linux kernel versions 6.19 and newer has a known
|
||||
# incompatibility", v12.66). 9.0.4 recognises Ubuntu kernels that
|
||||
# have the fix; see docs/Databases/Supported.md.
|
||||
docker run -d --name bun-smoke-mongo -p 127.0.0.1:27019:27017 mongo:9.0.4
|
||||
for i in $(seq 1 60); do
|
||||
if docker exec bun-smoke-mongo mongosh --quiet --eval 'db.runCommand({ ping: 1 }).ok' 2>/dev/null | grep -qx 1; then
|
||||
echo "MongoDB answers on 127.0.0.1:27019 after ${i}s."; exit 0
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -43,14 +43,6 @@ jobs:
|
||||
- name: Use release npm version
|
||||
run: bash releases/use-release-npm.sh
|
||||
|
||||
- name: Enable unprivileged user namespaces (Ubuntu 24.04 restricts them)
|
||||
# Same as sandstorm.yml: Ubuntu 24.04's AppArmor blocks the unprivileged user
|
||||
# namespaces the Sandstorm install relies on; relax it so the install (whose
|
||||
# sandstorm-http-bridge build-deps.sh copies in) can run on the runner.
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
|
||||
sudo sysctl -w kernel.unprivileged_userns_clone=1 2>/dev/null || true
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
bash "$GITHUB_WORKSPACE/releases/apt-install.sh" p7zip-full wget curl libatomic1 build-essential g++ make python3 unzip xz-utils git zip
|
||||
@@ -59,13 +51,13 @@ jobs:
|
||||
# build-deps.sh's [bridge] step copies the modern
|
||||
# /opt/sandstorm/latest/bin/sandstorm-http-bridge into the deps, so Sandstorm
|
||||
# must be installed first — same ordering as sandstorm.yml.
|
||||
run: curl https://install.sandstorm.io | sudo bash -s -- -d
|
||||
run: curl https://install.sandstorm.org | sudo bash -s -- -d
|
||||
|
||||
- name: Assemble modern meteor-spk.deps (Node 26 + FerretDB + Mongo 3.x CLIs)
|
||||
# Base = upstream meteor-spk 0.6.0 from dl.sandstorm.io; build-deps.sh swaps in
|
||||
# Base = upstream meteor-spk 0.6.0 from dl.sandstorm.org; build-deps.sh swaps in
|
||||
# Node 26, FerretDB, the Mongo 3.x CLIs, the migration launcher/importer, keeps
|
||||
# niscud, and swaps in the modern sandstorm-http-bridge. Produces
|
||||
# $HOME/projects/meteor-spk/meteor-spk-0.6.0 (the meteor-spk tool + deps).
|
||||
# $GITHUB_WORKSPACE/.tools/projects/meteor-spk/meteor-spk-0.6.0 (the meteor-spk tool + deps).
|
||||
run: bash sandstorm-src/build-deps.sh
|
||||
|
||||
- name: Zip the meteor-spk bundle
|
||||
@@ -74,7 +66,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
STAMP="$(date +'%Y-%m-%d_%H_%M_%S')"
|
||||
ZIP="meteor-spk-${STAMP}.zip"
|
||||
BUNDLE_DIR="$HOME/projects/meteor-spk"
|
||||
BUNDLE_DIR="$GITHUB_WORKSPACE/.tools/projects/meteor-spk"
|
||||
if [ ! -d "$BUNDLE_DIR/meteor-spk-0.6.0" ]; then
|
||||
echo "::error::Expected meteor-spk bundle at $BUNDLE_DIR/meteor-spk-0.6.0 was not produced by build-deps.sh."
|
||||
exit 1
|
||||
|
||||
@@ -130,8 +130,7 @@ jobs:
|
||||
if jq -e --arg name "$archive" '.assets | any(.name == $name)' "$task_dir/assets.json" >/dev/null; then
|
||||
gh release download --repo wekan/wekan "v$VERSION" --pattern "$archive" --dir "$task_dir"
|
||||
else
|
||||
tar --exclude=.git --exclude=.tools --exclude=.env --exclude=node_modules \
|
||||
-czf "$task_dir/$archive" --transform "s,^\.$,${APP_ID},;s,^\./,${APP_ID}/," -C exapp .
|
||||
releases/nextcloud-pack.sh exapp "$APP_ID" "$task_dir/$archive"
|
||||
new_archive=true
|
||||
fi
|
||||
python3 releases/nextcloud-release.py --version "$VERSION" --archive "$task_dir/$archive" --app-id "$APP_ID"
|
||||
|
||||
@@ -97,6 +97,12 @@ jobs:
|
||||
|
||||
plan:
|
||||
needs: recover
|
||||
# The planner runs whatever recover's rerun ended with. A rerun that still
|
||||
# fails - Launchpad answering 503, a fault that is in the tagged code and
|
||||
# so in every rerun of it (v12.68's OpenBSD start) - used to skip the
|
||||
# planner and every build after it, so not even the assets that COULD be
|
||||
# filled were. recover stays red and names the jobs; the rest goes on.
|
||||
if: ${{ !cancelled() }}
|
||||
timeout-minutes: 15
|
||||
name: what the release is missing
|
||||
runs-on: ubuntu-latest
|
||||
@@ -211,7 +217,7 @@ jobs:
|
||||
contents: write
|
||||
name: ${{ matrix.arch }}
|
||||
needs: plan
|
||||
if: ${{ needs.plan.outputs.extra != '[]' }}
|
||||
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.plan.outputs.extra != '[]' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 300
|
||||
strategy:
|
||||
@@ -331,7 +337,7 @@ jobs:
|
||||
bun:
|
||||
name: Missing Bun runtime bundles
|
||||
needs: plan
|
||||
if: ${{ needs.plan.outputs.bun != '[]' }}
|
||||
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.plan.outputs.bun != '[]' }}
|
||||
uses: ./.github/workflows/bun.yml
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -345,7 +351,7 @@ jobs:
|
||||
appimage:
|
||||
name: AppImages
|
||||
needs: plan
|
||||
if: ${{ needs.plan.outputs.appimage != '[]' }}
|
||||
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.plan.outputs.appimage != '[]' }}
|
||||
uses: ./.github/workflows/AppImage.yml
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -357,7 +363,7 @@ jobs:
|
||||
mac:
|
||||
name: Mac apps
|
||||
needs: plan
|
||||
if: ${{ needs.plan.outputs.mac != '[]' }}
|
||||
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.plan.outputs.mac != '[]' }}
|
||||
uses: ./.github/workflows/mac.yml
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -369,7 +375,7 @@ jobs:
|
||||
flatpak:
|
||||
name: Flatpaks
|
||||
needs: plan
|
||||
if: ${{ needs.plan.outputs.flatpak != '[]' }}
|
||||
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.plan.outputs.flatpak != '[]' }}
|
||||
uses: ./.github/workflows/Flatpak.yml
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -381,7 +387,7 @@ jobs:
|
||||
windows-exe:
|
||||
name: Windows single EXE
|
||||
needs: plan
|
||||
if: ${{ needs.plan.outputs.windows != '[]' }}
|
||||
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.plan.outputs.windows != '[]' }}
|
||||
uses: ./.github/workflows/windows.yml
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -425,7 +431,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
name: Helm chart
|
||||
needs: plan
|
||||
if: ${{ !cancelled() && needs.plan.outputs.version != '' }}
|
||||
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.plan.outputs.version != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout wekan (for releases/release-charts.sh)
|
||||
|
||||
@@ -38,7 +38,7 @@ run-name: Release v${{ inputs.new_version }}
|
||||
# only the ferretdb-<arch> binary it needs from the newest wekan/FerretDB
|
||||
# release (FerretDB is built + released separately by its own build.sh —
|
||||
# not compiled here) and embeds it
|
||||
# - release: writes the newest CHANGELOG.md notes to the GitHub Release and
|
||||
# - release: writes the newest CHANGELOG-big.md notes to the GitHub Release and
|
||||
# checks the core wekan-<version>-{amd64,arm64}.zip bundles are on
|
||||
# it. It uploads nothing: EVERY job that builds a release file
|
||||
# attaches it itself, as its last step, the moment it is built and
|
||||
@@ -281,8 +281,8 @@ jobs:
|
||||
# No `changelog:` output. The notes are 170 KB and more, which is past the
|
||||
# 128 KiB MAX_ARG_STRLEN a single environment variable may hold, so any job
|
||||
# consuming them through `env:` fails to start its shell. Jobs read
|
||||
# CHANGELOG.md from their own checkout instead - see
|
||||
# "Check the release notes exist in CHANGELOG.md" below.
|
||||
# CHANGELOG-big.md from their own checkout instead - see
|
||||
# "Check the release notes exist in CHANGELOG-big.md" below.
|
||||
steps:
|
||||
# Shallow: the only thing history was fetched for was finding out whether
|
||||
# the release tag already exists, which the tag step now asks GitHub.
|
||||
@@ -310,8 +310,8 @@ jobs:
|
||||
# string at MAX_ARG_STRLEN - 128 KiB - so execve refused to start bash at
|
||||
# all ("Argument list too long") before a line of the step ran. Each job
|
||||
# that needs the notes now extracts them from its own checkout of
|
||||
# CHANGELOG.md with the same script, straight into a file.
|
||||
- name: Check the release notes exist in CHANGELOG.md
|
||||
# CHANGELOG-big.md with the same script, straight into a file.
|
||||
- name: Check the release notes exist in CHANGELOG-big.md
|
||||
env:
|
||||
STEP_VERSION_VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
@@ -1170,7 +1170,7 @@ jobs:
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
# ref: main, the same ref `prepare` read CHANGELOG.md from, so the notes
|
||||
# ref: main, the same ref `prepare` read CHANGELOG-big.md from, so the notes
|
||||
# composed below are the ones prepare validated. (bump pushes to main and
|
||||
# prepare tags it; nothing else pushes during a release.)
|
||||
#
|
||||
@@ -1203,7 +1203,7 @@ jobs:
|
||||
# published nothing, "Incorrect: command not found"); and `env:` put a
|
||||
# 172 KB string in envp, past the 128 KiB MAX_ARG_STRLEN, so execve
|
||||
# refused to start bash at all (v10.92, "Argument list too long"). Read
|
||||
# from CHANGELOG.md, appended with cat, the text is data all the way.
|
||||
# from CHANGELOG-big.md, appended with cat, the text is data all the way.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash releases/release-notes.sh "$VERSION" > release-notes.md
|
||||
@@ -3427,7 +3427,16 @@ jobs:
|
||||
# architecture, and died on the 404 - four of the six failures in the
|
||||
# v10.57 run. A file has no quoting layer to get wrong, and `bash -n`
|
||||
# can check it.
|
||||
# install-node-for-arch.sh refuses to run without TMPDIR: the
|
||||
# container's own /tmp is a small part of its writable layer, so its
|
||||
# /tmp is this checkout's .tools/tmp/repack-<arch>-tmp, as in
|
||||
# releases/repack-bundle-for-arch.sh. Without it every emulated arch
|
||||
# failed at "TMPDIR must be the mounted .tools/tmp" (v12.66).
|
||||
CONTAINER_TMP="$(pwd)/.tools/tmp/repack-${MATRIX_ARCH}-tmp"
|
||||
mkdir -p "$CONTAINER_TMP" && chmod 1777 "$CONTAINER_TMP"
|
||||
docker run --rm --platform "${MATRIX_PLATFORM}" \
|
||||
-e TMPDIR=/tmp \
|
||||
-v "$CONTAINER_TMP:/tmp" \
|
||||
-e NODE_FULL="$NODE_FULL" \
|
||||
-e NODE_FROM="$NODE_FROM" \
|
||||
-e NODE_URL="$NODE_URL" \
|
||||
@@ -3663,7 +3672,7 @@ jobs:
|
||||
# Ubuntu 24.04's AppArmor defaults kernel.apparmor_restrict_unprivileged_userns=1,
|
||||
# which blocks the unprivileged user namespaces that the Sandstorm install and
|
||||
# the spk supervisor rely on (the "fragile part" noted above). Relax it so
|
||||
# `install.sandstorm.io` and meteor-spk can create sandboxes on the runner.
|
||||
# `install.sandstorm.org` and meteor-spk can create sandboxes on the runner.
|
||||
# `|| true`: on kernels/runners without these knobs, setting them is a no-op.
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
|
||||
@@ -3687,11 +3696,11 @@ jobs:
|
||||
run: |
|
||||
# Downloaded, then run: a `curl | sh` cannot be retried, because by the
|
||||
# time the download fails sh is already half way through the script.
|
||||
bash "$GITHUB_WORKSPACE/releases/fetch.sh" -o /tmp/install-sandstorm.sh https://install.sandstorm.io
|
||||
bash "$GITHUB_WORKSPACE/releases/fetch.sh" -o /tmp/install-sandstorm.sh https://install.sandstorm.org
|
||||
sudo bash /tmp/install-sandstorm.sh -d
|
||||
|
||||
- name: Assemble modern meteor-spk.deps (Node 26 + FerretDB + Mongo 3.x CLIs)
|
||||
# Base = upstream meteor-spk 0.6.0 from dl.sandstorm.io (has mongod 3.0 +
|
||||
# Base = upstream meteor-spk 0.6.0 from dl.sandstorm.org (has mongod 3.0 +
|
||||
# niscud). build-deps.sh swaps in Node 26, adds FerretDB + the Mongo 3.x
|
||||
# CLIs + the migration launcher/importer, keeps niscud, and swaps in the
|
||||
# modern sandstorm-http-bridge from the Sandstorm install above. No dependency
|
||||
@@ -3705,7 +3714,7 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
bash sandstorm-src/build-deps.sh
|
||||
echo "$HOME/projects/meteor-spk/meteor-spk-0.6.0" >> "$GITHUB_PATH"
|
||||
echo "$GITHUB_WORKSPACE/.tools/projects/meteor-spk/meteor-spk-0.6.0" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Restore the Sandstorm signing keyring
|
||||
env:
|
||||
@@ -5052,11 +5061,20 @@ jobs:
|
||||
# infrastructure earns another one, up to this many, after a pause
|
||||
# that grows each time.
|
||||
max_attempts=6
|
||||
# A Launchpad build log is THIS attempt's when its name did not exist
|
||||
# before the attempt, or it was rewritten after the marker. The name
|
||||
# alone decides it where -nt compares whole seconds (bash 3.2): a log
|
||||
# written in the marker's second was not "newer", and was missed.
|
||||
logs_before=" "
|
||||
this_attempts_log() {
|
||||
[ -e "$1" ] || return 1
|
||||
case "$logs_before" in *" $1 "*) [ "$1" -nt "$attempt_marker" ] ;; *) return 0 ;; esac
|
||||
}
|
||||
infra_failure() { # did THIS attempt fail on Launchpad, not on WeKan?
|
||||
grep -qsE 'HTTP Error 5[0-9][0-9]|Error: Timeout|Service Unavailable' "$remote_log" && return 0
|
||||
local log
|
||||
for log in snapcraft-wekan-*.txt; do
|
||||
[ -e "$log" ] && [ "$log" -nt "$attempt_marker" ] || continue
|
||||
this_attempts_log "$log" || continue
|
||||
grep -qsE 'RPC failed;|GnuTLS recv error|fatal: early EOF|Gateway error \[IP: ' "$log" && return 0
|
||||
done
|
||||
return 1
|
||||
@@ -5081,6 +5099,7 @@ jobs:
|
||||
# earlier attempts' logs stay in the directory for the diagnostics
|
||||
# below, but must not be printed again as if they were this one's.
|
||||
touch "$attempt_marker"
|
||||
logs_before=" $(ls snapcraft-wekan-*.txt 2>/dev/null | tr '\n' ' ')"
|
||||
if [ "$fresh_next" = 1 ]; then
|
||||
echo "The previous attempt's Launchpad build finished without a snap; submitting a new build instead of recovering it."
|
||||
run_remote_build && rc=0 || rc=$?
|
||||
@@ -5137,7 +5156,7 @@ jobs:
|
||||
# and threw it away.
|
||||
new_logs=0
|
||||
for log in snapcraft-wekan-*.txt; do
|
||||
[ -e "$log" ] && [ "$log" -nt "$attempt_marker" ] || continue
|
||||
this_attempts_log "$log" || continue
|
||||
new_logs=1
|
||||
echo "::group::Launchpad build log: $log (what failed, then the last 150 lines)"
|
||||
# The tail alone is not where the cause is. snapcraft's own error is
|
||||
@@ -5675,7 +5694,7 @@ jobs:
|
||||
# rewrites the notes of a release that is already published.
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
# ref: main, so this job reads the same CHANGELOG.md the release job did.
|
||||
# ref: main, so this job reads the same CHANGELOG-big.md the release job did.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
|
||||
@@ -32,6 +32,6 @@ jobs:
|
||||
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
|
||||
|
||||
- name: Run chart-releaser
|
||||
uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0
|
||||
uses: helm/chart-releaser-action@df287104677278f869ca5480bd260358397cc3f9 # v1.8.0
|
||||
env:
|
||||
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
@@ -48,11 +48,6 @@ jobs:
|
||||
- name: Use release npm version
|
||||
run: bash releases/use-release-npm.sh
|
||||
|
||||
- name: Enable unprivileged user namespaces (Ubuntu 24.04 restricts them)
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
|
||||
sudo sysctl -w kernel.unprivileged_userns_clone=1 2>/dev/null || true
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
bash "$GITHUB_WORKSPACE/releases/apt-install.sh" p7zip-full wget curl libatomic1 build-essential g++ make python3 unzip xz-utils git
|
||||
@@ -60,12 +55,12 @@ jobs:
|
||||
- name: Install Sandstorm (dev, unattended)
|
||||
# Provides the `spk` command used to publish, and the sandstorm-http-bridge that
|
||||
# build-deps.sh copies into the meteor-spk deps.
|
||||
run: curl https://install.sandstorm.io | sudo bash -s -- -d
|
||||
run: curl https://install.sandstorm.org | sudo bash -s -- -d
|
||||
|
||||
- name: Assemble modern meteor-spk.deps (same as sandstorm.yml)
|
||||
run: |
|
||||
bash sandstorm-src/build-deps.sh
|
||||
echo "$HOME/projects/meteor-spk/meteor-spk-0.6.0" >> "$GITHUB_PATH"
|
||||
echo "$GITHUB_WORKSPACE/.tools/projects/meteor-spk/meteor-spk-0.6.0" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Restore the Sandstorm signing keyring
|
||||
env:
|
||||
@@ -145,4 +140,7 @@ jobs:
|
||||
fi
|
||||
echo "Publishing $SPK with $SPK_BIN ..."
|
||||
# Same command as releases/release-sandstorm.sh: spk publish wekan-<VERSION>.spk
|
||||
"$SPK_BIN" publish "$SPK"
|
||||
# Publish to apps.sandstorm.org
|
||||
"$SPK_BIN" publish --webkey=https://api-b72c46193b266fd6c33e1299dccc9bfa.oasis.sandstorm.org#iCDu7VYl4qZxVbDLXLIE2XSU0bEadaBgApEMqgCkLmP "$SPK"
|
||||
# Publish to apps.sandstorm.io
|
||||
"$SPK_BIN" publish --webkey=https://alpha-api.sandstorm.io/#Rs-0TT13YrNSbv7Fiz5K9bBkLaJn3E5TB0PU1GSn1HE "$SPK"
|
||||
@@ -50,16 +50,6 @@ jobs:
|
||||
- name: Use release npm version
|
||||
run: bash releases/use-release-npm.sh
|
||||
|
||||
- name: Enable unprivileged user namespaces (Ubuntu 24.04 restricts them)
|
||||
# Ubuntu 24.04's AppArmor defaults kernel.apparmor_restrict_unprivileged_userns=1,
|
||||
# which blocks the unprivileged user namespaces that the Sandstorm install and
|
||||
# the spk supervisor rely on (the "fragile part" noted above). Relax it so
|
||||
# `install.sandstorm.io` and meteor-spk can create sandboxes on the runner.
|
||||
# `|| true`: on kernels/runners without these knobs, setting them is a no-op.
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
|
||||
sudo sysctl -w kernel.unprivileged_userns_clone=1 2>/dev/null || true
|
||||
|
||||
- name: Install system dependencies and Meteor
|
||||
run: |
|
||||
bash "$GITHUB_WORKSPACE/releases/apt-install.sh" p7zip-full wget curl libatomic1 build-essential g++ make python3 unzip xz-utils git qemu-user-static
|
||||
@@ -70,10 +60,10 @@ jobs:
|
||||
# Installed BEFORE assembling the deps: build-deps.sh's [bridge] step copies
|
||||
# the modern /opt/sandstorm/latest/bin/sandstorm-http-bridge over the ancient
|
||||
# bundled one, so the Sandstorm install must be in place first.
|
||||
run: curl https://install.sandstorm.io | sudo bash -s -- -d
|
||||
run: curl https://install.sandstorm.org | sudo bash -s -- -d
|
||||
|
||||
- name: Assemble modern meteor-spk.deps (Node 26 + SQLite-only FerretDB + Mongo 3.x CLIs)
|
||||
# Base = upstream meteor-spk 0.6.0 from dl.sandstorm.io (has mongod 3.0 +
|
||||
# Base = upstream meteor-spk 0.6.0 from dl.sandstorm.org (has mongod 3.0 +
|
||||
# niscud). build-deps.sh swaps in Node 26, adds FerretDB + the Mongo 3.x
|
||||
# CLIs + the migration launcher/importer, keeps niscud, and swaps in the
|
||||
# modern sandstorm-http-bridge from the Sandstorm install above. No dependency
|
||||
@@ -87,7 +77,7 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
bash sandstorm-src/build-deps.sh
|
||||
echo "$HOME/projects/meteor-spk/meteor-spk-0.6.0" >> "$GITHUB_PATH"
|
||||
echo "$GITHUB_WORKSPACE/.tools/projects/meteor-spk/meteor-spk-0.6.0" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Restore the Sandstorm signing keyring
|
||||
env:
|
||||
|
||||
+27
-27
@@ -6,12 +6,12 @@
|
||||
meteor-base@1.5.2
|
||||
|
||||
# Build system
|
||||
ecmascript@0.19.2-rc360.0
|
||||
standard-minifier-js@3.3.0-rc360.0
|
||||
rspack@1.4.0-rc360.0
|
||||
ecmascript@0.19.2-rc360.1
|
||||
standard-minifier-js@3.3.0-rc360.1
|
||||
rspack@1.4.0-rc360.1
|
||||
# Declared explicitly because server/modernBrowsers.js imports it, to say that
|
||||
# Yandex Browser is modern and must not be served the legacy bundle (#6557).
|
||||
modern-browsers@0.2.4-rc360.0
|
||||
modern-browsers@0.2.4-rc360.1
|
||||
|
||||
blaze@3.0.2
|
||||
|
||||
@@ -19,11 +19,11 @@ blaze@3.0.2
|
||||
aldeed:collection2
|
||||
aldeed:schema-index@4.0.0-beta.5
|
||||
reywood:publish-composite
|
||||
mongo@2.5.2-rc360.0
|
||||
mongo@2.5.2-rc360.1
|
||||
|
||||
# Account system
|
||||
accounts-password@3.3.2-rc360.0
|
||||
accounts-2fa@3.1.1-rc360.0
|
||||
accounts-password@3.3.2-rc360.1
|
||||
accounts-2fa@3.1.1-rc360.1
|
||||
accounts-express@1.0.0
|
||||
communitypackages:core
|
||||
communitypackages:unstyled
|
||||
@@ -39,19 +39,19 @@ wekan-oidc
|
||||
wekan-accounts-oidc
|
||||
|
||||
# Utilities
|
||||
check@1.5.1-rc360.0
|
||||
random@1.2.3-rc360.0
|
||||
reactive-dict@1.3.3-rc360.0
|
||||
session@1.2.4-rc360.0
|
||||
tracker@1.3.5-rc360.0
|
||||
check@1.5.1-rc360.1
|
||||
random@1.2.3-rc360.1
|
||||
reactive-dict@1.3.3-rc360.1
|
||||
session@1.2.4-rc360.1
|
||||
tracker@1.3.5-rc360.1
|
||||
audit-argument-checks@1.0.8
|
||||
|
||||
# UI components
|
||||
ostrio:i18n
|
||||
reactive-var@1.0.13
|
||||
shell-server@0.7.1-rc360.0
|
||||
email@3.2.1-rc360.0
|
||||
dynamic-import@0.7.5-rc360.0
|
||||
shell-server@0.7.2-rc360.1
|
||||
email@3.2.1-rc360.1
|
||||
dynamic-import@0.8.1-rc360.1
|
||||
wekan-markdown
|
||||
quave:synced-cron
|
||||
ostrio:cookies
|
||||
@@ -59,21 +59,21 @@ ostrio:files
|
||||
# lmieulet:meteor-coverage
|
||||
meteortesting:mocha@3.0.0
|
||||
matb33:collection-hooks
|
||||
service-configuration@1.3.6-rc360.0
|
||||
service-configuration@1.3.6-rc360.1
|
||||
minifier-css@2.0.2
|
||||
ejson@1.2.1-rc360.0
|
||||
logging@1.3.7-rc360.0
|
||||
ejson@1.4.1-rc360.1
|
||||
logging@1.3.7-rc360.1
|
||||
wekan-fullcalendar
|
||||
wekan-fontawesome
|
||||
accounts-google@1.4.2-rc360.0
|
||||
accounts-github@1.5.2-rc360.0
|
||||
accounts-facebook@1.3.5-rc360.0
|
||||
accounts-twitter@1.5.3-rc360.0
|
||||
accounts-meteor-developer@1.5.2-rc360.0
|
||||
accounts-weibo@1.4.2-rc360.0
|
||||
accounts-meetup@1.5.2-rc360.0
|
||||
accounts-passwordless@3.1.2-rc360.0
|
||||
instrumentation@0.0.1-rc360.0
|
||||
accounts-google@1.4.2-rc360.1
|
||||
accounts-github@1.5.2-rc360.1
|
||||
accounts-facebook@1.3.5-rc360.1
|
||||
accounts-twitter@1.5.3-rc360.1
|
||||
accounts-meteor-developer@1.5.2-rc360.1
|
||||
accounts-weibo@1.4.2-rc360.1
|
||||
accounts-meetup@1.5.2-rc360.1
|
||||
accounts-passwordless@3.1.2-rc360.1
|
||||
instrumentation@0.0.1-rc360.1
|
||||
|
||||
# Prevent Meteor build/run tools from sending application package statistics.
|
||||
package-stats-opt-out@1.0.8
|
||||
+1
-1
@@ -1 +1 @@
|
||||
METEOR@3.6-rc.0
|
||||
METEOR@3.6-rc.1
|
||||
+74
-74
@@ -1,130 +1,130 @@
|
||||
accounts-2fa@3.1.1-rc360.0
|
||||
accounts-base@3.4.0-rc360.0
|
||||
accounts-2fa@3.1.1-rc360.1
|
||||
accounts-base@3.4.0-rc360.1
|
||||
accounts-express@1.0.0
|
||||
accounts-facebook@1.3.5-rc360.0
|
||||
accounts-github@1.5.2-rc360.0
|
||||
accounts-google@1.4.2-rc360.0
|
||||
accounts-meetup@1.5.2-rc360.0
|
||||
accounts-meteor-developer@1.5.2-rc360.0
|
||||
accounts-oauth@1.4.8-rc360.0
|
||||
accounts-password@3.3.2-rc360.0
|
||||
accounts-passwordless@3.1.2-rc360.0
|
||||
accounts-twitter@1.5.3-rc360.0
|
||||
accounts-weibo@1.4.2-rc360.0
|
||||
accounts-facebook@1.3.5-rc360.1
|
||||
accounts-github@1.5.2-rc360.1
|
||||
accounts-google@1.4.2-rc360.1
|
||||
accounts-meetup@1.5.2-rc360.1
|
||||
accounts-meteor-developer@1.5.2-rc360.1
|
||||
accounts-oauth@1.4.8-rc360.1
|
||||
accounts-password@3.3.2-rc360.1
|
||||
accounts-passwordless@3.1.2-rc360.1
|
||||
accounts-twitter@1.5.3-rc360.1
|
||||
accounts-weibo@1.4.2-rc360.1
|
||||
aldeed:collection2@4.2.2
|
||||
aldeed:schema-index@4.0.0
|
||||
aldeed:simple-schema@1.13.1
|
||||
allow-deny@2.1.1-rc360.0_1
|
||||
allow-deny@2.1.2-rc360.1_1
|
||||
audit-argument-checks@1.0.8
|
||||
autoupdate@2.0.2-rc360.0
|
||||
babel-compiler@7.15.2-rc360.0
|
||||
autoupdate@2.0.2-rc360.1
|
||||
babel-compiler@7.15.2-rc360.1
|
||||
babel-runtime@1.5.2
|
||||
base64@1.0.15-rc360.0
|
||||
binary-heap@1.0.14-rc360.0
|
||||
base64@1.0.15-rc360.1
|
||||
binary-heap@1.0.14-rc360.1
|
||||
blaze@3.0.3
|
||||
blaze-tools@2.0.1
|
||||
boilerplate-generator@2.2.0-rc360.0
|
||||
boilerplate-generator@2.2.0-rc360.1
|
||||
caching-compiler@2.0.1
|
||||
caching-html-compiler@2.0.1
|
||||
callback-hook@1.8.1-rc360.0
|
||||
check@1.5.1-rc360.0
|
||||
callback-hook@1.8.1-rc360.1
|
||||
check@1.5.1-rc360.1
|
||||
communitypackages:core@2.0.3
|
||||
communitypackages:flow-routing@2.1.0
|
||||
communitypackages:unstyled@2.0.1
|
||||
core-runtime@1.0.0
|
||||
ddp@1.4.3-rc360.0
|
||||
ddp-client@3.4.2-rc360.0
|
||||
ddp-common@1.4.6-rc360.0
|
||||
ddp-rate-limiter@1.3.1-rc360.0
|
||||
ddp-server@3.4.1-rc360.0_1
|
||||
diff-sequence@1.1.4-rc360.0
|
||||
dynamic-import@0.7.5-rc360.0_1
|
||||
ecmascript@0.19.2-rc360.0
|
||||
ddp@1.4.3-rc360.1
|
||||
ddp-client@3.4.2-rc360.1
|
||||
ddp-common@1.4.6-rc360.1
|
||||
ddp-rate-limiter@1.3.1-rc360.1
|
||||
ddp-server@3.6.0-rc360.1_1
|
||||
diff-sequence@1.2.1-rc360.1
|
||||
dynamic-import@0.8.1-rc360.1_1
|
||||
ecmascript@0.19.2-rc360.1
|
||||
ecmascript-runtime@0.8.3
|
||||
ecmascript-runtime-client@0.13.0
|
||||
ecmascript-runtime-server@0.11.1
|
||||
ejson@1.2.1-rc360.0
|
||||
email@3.2.1-rc360.0
|
||||
ejson@1.4.1-rc360.1
|
||||
email@3.2.1-rc360.1
|
||||
es5-shim@4.8.1
|
||||
facebook-oauth@1.11.7-rc360.0_1
|
||||
facts-base@1.0.3-rc360.0
|
||||
fetch@0.2.1-rc360.0
|
||||
geojson-utils@1.0.13-rc360.0
|
||||
github-oauth@1.4.3-rc360.0
|
||||
google-oauth@1.4.6-rc360.0_1
|
||||
facebook-oauth@1.12.1-rc360.1_1
|
||||
facts-base@1.0.3-rc360.1
|
||||
fetch@0.2.1-rc360.1
|
||||
geojson-utils@1.0.13-rc360.1
|
||||
github-oauth@1.4.3-rc360.1
|
||||
google-oauth@1.5.1-rc360.1_1
|
||||
hot-code-push@1.0.5
|
||||
html-tools@2.0.1
|
||||
htmljs@2.0.2
|
||||
id-map@1.2.1-rc360.0
|
||||
instrumentation@0.0.1-rc360.0
|
||||
inter-process-messaging@0.1.4-rc360.0
|
||||
id-map@1.2.1-rc360.1
|
||||
instrumentation@0.0.1-rc360.1
|
||||
inter-process-messaging@0.1.4-rc360.1
|
||||
jquery@3.0.2
|
||||
lai:collection-extensions@1.0.0
|
||||
localstorage@1.2.1
|
||||
logging@1.3.7-rc360.0
|
||||
logging@1.3.7-rc360.1
|
||||
matb33:collection-hooks@2.1.0
|
||||
meetup-oauth@1.1.4-rc360.0
|
||||
meteor@2.3.2-rc360.0
|
||||
meetup-oauth@1.1.4-rc360.1
|
||||
meteor@2.3.2-rc360.1
|
||||
meteor-base@1.5.2
|
||||
meteor-developer-oauth@1.3.4-rc360.0
|
||||
meteor-developer-oauth@1.3.4-rc360.1
|
||||
meteortesting:browser-tests@1.8.0
|
||||
meteortesting:mocha@3.4.0
|
||||
meteortesting:mocha-core@8.2.0
|
||||
minifier-css@2.0.2
|
||||
minifier-js@3.3.2-rc360.0
|
||||
minimongo@2.2.1-rc360.0
|
||||
modern-browsers@0.2.4-rc360.0
|
||||
minifier-js@3.3.2-rc360.1
|
||||
minimongo@2.2.1-rc360.1
|
||||
modern-browsers@0.2.4-rc360.1
|
||||
modules@0.20.3
|
||||
modules-runtime@0.13.2
|
||||
mongo@2.5.2-rc360.0
|
||||
mongo@2.5.2-rc360.1
|
||||
mongo-decimal@0.2.0
|
||||
mongo-dev-server@1.1.1
|
||||
mongo-id@1.0.10-rc360.0
|
||||
npm-mongo@6.16.3
|
||||
oauth@3.0.5-rc360.0
|
||||
oauth1@1.5.4-rc360.0
|
||||
mongo-id@1.0.10-rc360.1
|
||||
npm-mongo@6.18.1-rc360.1
|
||||
oauth@3.0.5-rc360.1
|
||||
oauth1@1.5.4-rc360.1
|
||||
oauth2@1.3.3
|
||||
observe-sequence@2.0.1
|
||||
ordered-dict@1.2.1-rc360.0
|
||||
ordered-dict@1.2.1-rc360.1
|
||||
ostrio:cookies@2.9.2
|
||||
ostrio:cstorage@4.0.1
|
||||
ostrio:files@3.0.1
|
||||
ostrio:flow-router-extra@3.15.3
|
||||
ostrio:i18n@3.2.1
|
||||
package-stats-opt-out@1.0.8
|
||||
promise@1.0.1-rc360.0
|
||||
promise@1.0.1-rc360.1
|
||||
quave:synced-cron@2.4.0
|
||||
raix:eventemitter@1.0.0
|
||||
random@1.2.3-rc360.0
|
||||
rate-limit@1.2.1-rc360.0
|
||||
random@1.2.3-rc360.1
|
||||
rate-limit@1.2.1-rc360.1
|
||||
react-fast-refresh@0.3.0
|
||||
reactive-dict@1.3.3-rc360.0
|
||||
reactive-dict@1.3.3-rc360.1
|
||||
reactive-var@1.0.13
|
||||
reload@1.3.3-rc360.0
|
||||
retry@1.1.2-rc360.0
|
||||
reload@1.3.3-rc360.1
|
||||
retry@1.1.2-rc360.1
|
||||
reywood:publish-composite@1.9.0
|
||||
routepolicy@1.1.3-rc360.0
|
||||
rspack@1.4.0-rc360.0
|
||||
service-configuration@1.3.6-rc360.0
|
||||
session@1.2.4-rc360.0
|
||||
sha@1.0.11-rc360.0
|
||||
shell-server@0.7.1-rc360.0_1
|
||||
socket-stream-client@0.7.2-rc360.0
|
||||
routepolicy@1.1.3-rc360.1
|
||||
rspack@1.4.0-rc360.1
|
||||
service-configuration@1.3.6-rc360.1
|
||||
session@1.2.4-rc360.1
|
||||
sha@1.0.11-rc360.1
|
||||
shell-server@0.7.2-rc360.1_1
|
||||
socket-stream-client@0.7.2-rc360.1
|
||||
spacebars@2.0.1
|
||||
spacebars-compiler@2.0.1
|
||||
standard-minifier-js@3.3.0-rc360.0
|
||||
standard-minifier-js@3.3.0-rc360.1
|
||||
templating@1.4.5
|
||||
templating-compiler@2.0.1
|
||||
templating-runtime@2.0.2
|
||||
templating-tools@2.0.1
|
||||
tools-core@1.4.0-rc360.0
|
||||
tracker@1.3.5-rc360.0
|
||||
twitter-oauth@1.3.5-rc360.0
|
||||
typescript@5.11.2-rc360.0
|
||||
url@1.3.7-rc360.0
|
||||
webapp@2.3.1-rc360.0
|
||||
webapp-hashing@1.1.3-rc360.0
|
||||
weibo-oauth@1.3.4-rc360.0
|
||||
tools-core@1.4.0-rc360.1
|
||||
tracker@1.3.5-rc360.1
|
||||
twitter-oauth@1.3.5-rc360.1
|
||||
typescript@5.11.2-rc360.1
|
||||
url@1.3.7-rc360.1
|
||||
webapp@2.3.1-rc360.1
|
||||
webapp-hashing@1.1.3-rc360.1
|
||||
weibo-oauth@1.3.4-rc360.1
|
||||
wekan-accounts-cas@0.2.0
|
||||
wekan-accounts-lockout@1.1.0
|
||||
wekan-accounts-oidc@1.0.10
|
||||
|
||||
@@ -35,7 +35,7 @@ contributor — that offer is the mistake, not the rule.
|
||||
The maintainer's half is the release flow, not taste. Releases here are frequent —
|
||||
several a day when a fault is being chased — and `releases/release-all.sh` cuts one
|
||||
from whatever is on `main`, taking the version from the `# Upcoming WeKan ® release`
|
||||
section of `CHANGELOG.md`. Work parked on a branch is work that is not in the next
|
||||
section of `CHANGELOG-big.md`. Work parked on a branch is work that is not in the next
|
||||
release, and `tests/changelogEntriesBelongToTheirRelease.test.cjs` fails when a
|
||||
released section links a commit that release does not contain.
|
||||
|
||||
@@ -340,7 +340,7 @@ directly after the merge.
|
||||
the two patch repositories under `.tools/` — node-patches and mongo-tools-patches.
|
||||
- Directory structure:
|
||||
- `wekan` — this repo (https://github.com/wekan/wekan); see
|
||||
`docs/DeveloperDocs/Directory-Structure.md`; `CHANGELOG.md` at root. It is at
|
||||
`docs/DeveloperDocs/Directory-Structure.md`; `CHANGELOG-big.md` and `CHANGELOG.md` at root. It is at
|
||||
`~/repos/wekan` on Linux, `~/Documents/repos/wekan` on macOS and
|
||||
`Downloads\repos\wekan` on Windows. The maintainer commits on `main`; a
|
||||
contributor works on a branch and opens a pull request — see "Where this
|
||||
@@ -414,7 +414,7 @@ directly after the merge.
|
||||
|
||||
| Repository | File | Format |
|
||||
| --- | --- | --- |
|
||||
| `wekan/wekan` | `CHANGELOG.md` | the WeKan format this section describes: `# Status` (Platforms/Version/TODO Later nested as `<details>`), then `# v<MAJOR>.<MINOR> YYYY-MM-DD WeKan ® release` sections of `<details>` entries whose `<summary>` links the commit |
|
||||
| `wekan/wekan` | `CHANGELOG-big.md` (and the generated `CHANGELOG.md`, below) | the WeKan format this section describes: `# Status` (Platforms/Version/TODO Later nested as `<details>`), then `# v<MAJOR>.<MINOR> YYYY-MM-DD WeKan ® release` sections of `<details>` entries whose `<summary>` links the commit |
|
||||
| `wekan/node-patches` | `CHANGELOG.md` | the same WeKan format, with `# Upcoming node-patches release` |
|
||||
| `wekan/mongo-tools-patches` | `CHANGELOG.md` | the same WeKan format, with `# Upcoming mongo-tools-patches release` |
|
||||
| `wekan/FerretDB` | `CHANGELOG.md` | **upstream FerretDB's** format, not WeKan's: `## [v1.48.0](tag URL) (YYYY-MM-DD)` and `### New Features 🎉` / `### Fixed 🐛` / `### Other Changes 🤖` bullets ending `by @xet7. Thanks to xet7.` |
|
||||
@@ -426,6 +426,52 @@ directly after the merge.
|
||||
out and would break the tooling that parses them. When this file and the file being
|
||||
edited disagree, **the file being edited wins** — and everything below in this
|
||||
section is about the WeKan format specifically.
|
||||
- **WeKan has TWO changelogs, and only one is written.** `CHANGELOG-big.md` is
|
||||
the changelog: `# Status`, `# Upcoming WeKan ® release` and every release in
|
||||
full, in the `<details>` format below - every rule in the rest of this section
|
||||
is about it, and every entry, fix and release goes into it. `CHANGELOG.md` is
|
||||
the SHORT changelog, generated from it by `node releases/changelog-short.mjs`
|
||||
and never edited by hand:
|
||||
|
||||
| | `CHANGELOG.md` |
|
||||
| --- | --- |
|
||||
| Status, TODO Later | none |
|
||||
| First | the unreleased `# Upcoming WeKan ® release`, when there is one: its **In short** paragraph only, then `[All changes so far](CHANGELOG-big.md#upcoming-wekan--release)` |
|
||||
| Below it | the five newest `# v<MAJOR>.<MINOR> YYYY-MM-DD WeKan ® release` |
|
||||
| Per release | its **In short** paragraph only, then `[All changes in v…](CHANGELOG-big.md#anchor)` |
|
||||
| Markup | plain Markdown: no `<details>`, `<summary>`, `</summary>` or `</details>`, and every HTML link a Markdown link |
|
||||
|
||||
It exists because the big file is for whoever writes and audits a release,
|
||||
and most readers - GitHub's front page, the Sandstorm App Market
|
||||
(`sandstorm-pkgdef.capnp` embeds it), the Helm chart's changelog link - want
|
||||
what changed lately, at a glance. `releases/release-all.sh` regenerates it
|
||||
right after naming the new release, so it is in the same release commit, and
|
||||
`releases/changelog.sh` does the same; `tests/changelogShort.test.cjs` fails
|
||||
when it is not exactly what the generator writes. So: write
|
||||
`CHANGELOG-big.md`, run the generator, commit both - and never edit
|
||||
`CHANGELOG.md` to fix something, because the next run overwrites it.
|
||||
- **"Update wekan changelog" means both files, in this order:**
|
||||
1. Find what is not in the changelog yet: `git log` since the newest commit
|
||||
`CHANGELOG-big.md`'s Upcoming section (or, when it has none, the newest
|
||||
release's `Prepare v… release` commit) already covers, by anybody - a
|
||||
merged pull request and the maintainer's own commits alike.
|
||||
2. Check whether a release was cut since the last update (`release-all.sh`
|
||||
renames Upcoming to `# v<NEW> …` and opens no new one). If there is no
|
||||
`# Upcoming WeKan ® release` heading, add it from
|
||||
[docs/DeveloperDocs/Changelog-Upcoming-Template.md](docs/DeveloperDocs/Changelog-Upcoming-Template.md)
|
||||
above the newest release - never add an entry to a released section.
|
||||
3. Write the entries into that Upcoming section of `CHANGELOG-big.md`, in the
|
||||
format described below (subsections, topic groups, `<details>` entries
|
||||
linking each commit), and update its **In short** paragraph so it still
|
||||
names the release's main outcomes.
|
||||
4. Run `node releases/changelog-short.mjs`: `CHANGELOG.md` then shows that
|
||||
Upcoming **In short** first and the five newest releases below it, with the
|
||||
longer details only in `CHANGELOG-big.md`.
|
||||
5. Run the changelog tests - `tests/changelogShort.test.cjs`,
|
||||
`tests/changelogFormat.test.cjs`,
|
||||
`tests/changelogEntriesBelongToTheirRelease.test.cjs` (or
|
||||
`node tests/run-node-suites.cjs changelog`) - and commit
|
||||
`CHANGELOG-big.md` and `CHANGELOG.md` together.
|
||||
- During development, add entries under a new `# Upcoming WeKan ® release` section above
|
||||
the newest release (FerretDB uses `## Upcoming FerretDB release`; the patch repos use
|
||||
`# Upcoming <repo> release`). Do **not** hand-edit
|
||||
@@ -438,10 +484,10 @@ directly after the merge.
|
||||
add and syntax-check/register the test and state clearly what could not be run.
|
||||
Do not duplicate an existing test merely to satisfy the audit — extend it when
|
||||
that keeps one behavior in one suite.
|
||||
- **CHANGELOG.md stays small enough for GitHub to show it.** GitHub renders
|
||||
- **CHANGELOG-big.md stays small enough for GitHub to show it.** GitHub renders
|
||||
Markdown up to 500 KiB; above that it shows only plain source text, and above
|
||||
about 2 MB nothing at all ("we can't show files that are this big right
|
||||
now"). CHANGELOG.md reached 2.6 MB over 1,100 releases back to 2015 (#6580),
|
||||
now"). The changelog reached 2.6 MB over 1,100 releases back to 2015 (#6580),
|
||||
and keeping only the current month still came to 2.1 MB, because releases
|
||||
here are frequent and large. So the cut is by SIZE: 450,000 bytes
|
||||
(`BUDGET` in `releases/changelog-archive.mjs`), under GitHub's limit with
|
||||
@@ -449,7 +495,7 @@ directly after the merge.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `CHANGELOG.md` | `# Status` (Platforms/TODO Later inside it), `# Upcoming`, and the newest releases that fit the budget |
|
||||
| `CHANGELOG-big.md` | `# Status` (Platforms/TODO Later inside it), `# Upcoming`, and the newest releases that fit the budget |
|
||||
| `old-CHANGELOG/<year>/<MM>.md` | a month's releases, newest first; a month over the budget continues in `<MM>-part2.md`, `<MM>-part3.md`, ... |
|
||||
| `old-CHANGELOG/<year>.md` | 2015–2025, whole |
|
||||
|
||||
@@ -464,11 +510,11 @@ directly after the merge.
|
||||
still in git (`gitk`, `git-gui`, `git log --follow`), and being small enough
|
||||
to open is worth more.
|
||||
|
||||
Run `node releases/changelog-archive.mjs` whenever CHANGELOG.md has grown -
|
||||
Run `node releases/changelog-archive.mjs` whenever CHANGELOG-big.md has grown -
|
||||
after a release is the natural moment. It is idempotent — a run with nothing
|
||||
to move only refreshes the tables and parts — and it decides from the FILE
|
||||
rather than the clock, so two people running it agree.
|
||||
`tests/changelogArchive.test.cjs` fails when CHANGELOG.md is over the budget.
|
||||
`tests/changelogArchive.test.cjs` fails when CHANGELOG-big.md is over the budget.
|
||||
An archived section is never edited, for the same reason a released one is
|
||||
not.
|
||||
- **The file's shape, top to bottom** — keep it exactly as it is now. There is
|
||||
@@ -564,7 +610,7 @@ directly after the merge.
|
||||
`<details>` blocks below carry that information. Keep the paragraph current as
|
||||
topics change, and shorten it when added commits make it grow. A finished release
|
||||
keeps the paragraph it was written with.
|
||||
- **Neither CHANGELOG.md nor release notes may contain a binaries provenance
|
||||
- **Neither changelog nor the release notes may contain a binaries provenance
|
||||
section or a Platform/Bundle/Binary/From/Version/Checked/SHA256 table.**
|
||||
Do not add "Binaries in these bundles" at the top or bottom of either.
|
||||
Release notes summarize each Translations group as only a list of updated
|
||||
@@ -577,7 +623,7 @@ directly after the merge.
|
||||
no security fixes - is left out rather than added empty.
|
||||
Release notes have only In short, Security, Translations (language list),
|
||||
the standard thanks line and More details at ChangeLog with the release
|
||||
anchor link. Other details stay in CHANGELOG.md. Keep download
|
||||
anchor link into CHANGELOG-big.md. Other details stay in CHANGELOG-big.md. Keep download
|
||||
verification and `provenance.tsv` build artifacts for diagnostics; do not
|
||||
append their contents or `releases/provenance-table.sh` output to notes.
|
||||
- **Inside a subsection, entries are GROUPED BY TOPIC/AREA.** A release touches a handful
|
||||
@@ -636,7 +682,7 @@ directly after the merge.
|
||||
Because CRITICAL comes first, it keeps the `This release ` prefix; a following non-security
|
||||
subsection becomes `and …` per the rule above.
|
||||
- **TODO Later** — a `<details>` nested inside `# Status` near the TOP of
|
||||
`CHANGELOG.md` (above the version sections), a triage backlog for open
|
||||
`CHANGELOG-big.md` (above the version sections), a triage backlog for open
|
||||
issues that were **investigated but not fixed here**, each
|
||||
recorded with a concrete REASON so whoever picks it up next knows why. Use it when working
|
||||
through open issues (the "Fix open issues" process): for each issue, either **fix it** (commit
|
||||
@@ -717,7 +763,7 @@ have cost a released section its accuracy:
|
||||
- **Work continues immediately after a release**, so an entry written right
|
||||
after one has to go somewhere. `release-all.sh` renames `# Upcoming WeKan ®
|
||||
release` to `# v<NEW> …` and stops there — it does **not** create a new,
|
||||
empty `# Upcoming` section. CHANGELOG.md never carries an
|
||||
empty `# Upcoming` section. CHANGELOG-big.md never carries an
|
||||
`**In short:** nothing here yet.` placeholder sitting between releases; it
|
||||
used to (via a now-deleted `releases/changelog-open-next.mjs`), and that
|
||||
meant the file always had a section saying nothing, from the moment a
|
||||
@@ -869,6 +915,21 @@ have cost a released section its accuracy:
|
||||
sizes them at 24px, and keeps the icon and its red drop on one line at every width
|
||||
(they are one icon) — the table itself stays percentage-sized with no fixed or
|
||||
minimum width, so it fills the page at every browser width.
|
||||
- **A report that arrives WITHOUT a GHSA is upgraded when its GHSA arrives.** The
|
||||
stars follow `.tools/wekan.fi/hall-of-fame/Audit-Stars.md`: a GHSA with a
|
||||
suggested fix is 5, a report sent to security@wekan.fi or any other way, with a
|
||||
suggested fix, is 3. When that researcher is later able to add a GitHub security
|
||||
advisory for the same report, the maintainer requests the CVE through the GHSA
|
||||
process, and the row's 3 stars become 5. So whenever an AI updates or audits the
|
||||
Hall of Fame, it checks each 3-star row whose Process says the report came
|
||||
without a GHSA against [WeKan Security
|
||||
Advisories](https://github.com/wekan/wekan/security/advisories): when a GHSA for
|
||||
that report now exists, it sets the row to 5 stars on the index AND on the row's
|
||||
own page, links the GHSA (and the CVE in the CVE cell once one is assigned),
|
||||
rewrites the Process cell to say the report later arrived as a GHSA, and records
|
||||
the change in Audit-Stars.md. Match by the report's subject and reporter, not by
|
||||
a nearby advisory - a GHSA about something else is no upgrade. Until the GHSA
|
||||
exists, the row stays at 3 and its Process cell says the GHSA is awaited.
|
||||
- **On the Hall of Fame index page, the Process and Vulnerabilities cells are
|
||||
collapsed.** Those two columns carry the prose — how the report arrived, and the
|
||||
whole story of the vulnerability — so the page would be one wall of text and the
|
||||
|
||||
+6927
File diff suppressed because it is too large.
Load diff
+37
-8736
File diff suppressed because it is too large.
Load diff
@@ -35,7 +35,7 @@ contributor — that offer is the mistake, not the rule.
|
||||
The maintainer's half is the release flow, not taste. Releases here are frequent —
|
||||
several a day when a fault is being chased — and `releases/release-all.sh` cuts one
|
||||
from whatever is on `main`, taking the version from the `# Upcoming WeKan ® release`
|
||||
section of `CHANGELOG.md`. Work parked on a branch is work that is not in the next
|
||||
section of `CHANGELOG-big.md`. Work parked on a branch is work that is not in the next
|
||||
release, and `tests/changelogEntriesBelongToTheirRelease.test.cjs` fails when a
|
||||
released section links a commit that release does not contain.
|
||||
|
||||
@@ -340,7 +340,7 @@ directly after the merge.
|
||||
the two patch repositories under `.tools/` — node-patches and mongo-tools-patches.
|
||||
- Directory structure:
|
||||
- `wekan` — this repo (https://github.com/wekan/wekan); see
|
||||
`docs/DeveloperDocs/Directory-Structure.md`; `CHANGELOG.md` at root. It is at
|
||||
`docs/DeveloperDocs/Directory-Structure.md`; `CHANGELOG-big.md` and `CHANGELOG.md` at root. It is at
|
||||
`~/repos/wekan` on Linux, `~/Documents/repos/wekan` on macOS and
|
||||
`Downloads\repos\wekan` on Windows. The maintainer commits on `main`; a
|
||||
contributor works on a branch and opens a pull request — see "Where this
|
||||
@@ -414,7 +414,7 @@ directly after the merge.
|
||||
|
||||
| Repository | File | Format |
|
||||
| --- | --- | --- |
|
||||
| `wekan/wekan` | `CHANGELOG.md` | the WeKan format this section describes: `# Status` (Platforms/Version/TODO Later nested as `<details>`), then `# v<MAJOR>.<MINOR> YYYY-MM-DD WeKan ® release` sections of `<details>` entries whose `<summary>` links the commit |
|
||||
| `wekan/wekan` | `CHANGELOG-big.md` (and the generated `CHANGELOG.md`, below) | the WeKan format this section describes: `# Status` (Platforms/Version/TODO Later nested as `<details>`), then `# v<MAJOR>.<MINOR> YYYY-MM-DD WeKan ® release` sections of `<details>` entries whose `<summary>` links the commit |
|
||||
| `wekan/node-patches` | `CHANGELOG.md` | the same WeKan format, with `# Upcoming node-patches release` |
|
||||
| `wekan/mongo-tools-patches` | `CHANGELOG.md` | the same WeKan format, with `# Upcoming mongo-tools-patches release` |
|
||||
| `wekan/FerretDB` | `CHANGELOG.md` | **upstream FerretDB's** format, not WeKan's: `## [v1.48.0](tag URL) (YYYY-MM-DD)` and `### New Features 🎉` / `### Fixed 🐛` / `### Other Changes 🤖` bullets ending `by @xet7. Thanks to xet7.` |
|
||||
@@ -426,6 +426,52 @@ directly after the merge.
|
||||
out and would break the tooling that parses them. When this file and the file being
|
||||
edited disagree, **the file being edited wins** — and everything below in this
|
||||
section is about the WeKan format specifically.
|
||||
- **WeKan has TWO changelogs, and only one is written.** `CHANGELOG-big.md` is
|
||||
the changelog: `# Status`, `# Upcoming WeKan ® release` and every release in
|
||||
full, in the `<details>` format below - every rule in the rest of this section
|
||||
is about it, and every entry, fix and release goes into it. `CHANGELOG.md` is
|
||||
the SHORT changelog, generated from it by `node releases/changelog-short.mjs`
|
||||
and never edited by hand:
|
||||
|
||||
| | `CHANGELOG.md` |
|
||||
| --- | --- |
|
||||
| Status, TODO Later | none |
|
||||
| First | the unreleased `# Upcoming WeKan ® release`, when there is one: its **In short** paragraph only, then `[All changes so far](CHANGELOG-big.md#upcoming-wekan--release)` |
|
||||
| Below it | the five newest `# v<MAJOR>.<MINOR> YYYY-MM-DD WeKan ® release` |
|
||||
| Per release | its **In short** paragraph only, then `[All changes in v…](CHANGELOG-big.md#anchor)` |
|
||||
| Markup | plain Markdown: no `<details>`, `<summary>`, `</summary>` or `</details>`, and every HTML link a Markdown link |
|
||||
|
||||
It exists because the big file is for whoever writes and audits a release,
|
||||
and most readers - GitHub's front page, the Sandstorm App Market
|
||||
(`sandstorm-pkgdef.capnp` embeds it), the Helm chart's changelog link - want
|
||||
what changed lately, at a glance. `releases/release-all.sh` regenerates it
|
||||
right after naming the new release, so it is in the same release commit, and
|
||||
`releases/changelog.sh` does the same; `tests/changelogShort.test.cjs` fails
|
||||
when it is not exactly what the generator writes. So: write
|
||||
`CHANGELOG-big.md`, run the generator, commit both - and never edit
|
||||
`CHANGELOG.md` to fix something, because the next run overwrites it.
|
||||
- **"Update wekan changelog" means both files, in this order:**
|
||||
1. Find what is not in the changelog yet: `git log` since the newest commit
|
||||
`CHANGELOG-big.md`'s Upcoming section (or, when it has none, the newest
|
||||
release's `Prepare v… release` commit) already covers, by anybody - a
|
||||
merged pull request and the maintainer's own commits alike.
|
||||
2. Check whether a release was cut since the last update (`release-all.sh`
|
||||
renames Upcoming to `# v<NEW> …` and opens no new one). If there is no
|
||||
`# Upcoming WeKan ® release` heading, add it from
|
||||
[docs/DeveloperDocs/Changelog-Upcoming-Template.md](docs/DeveloperDocs/Changelog-Upcoming-Template.md)
|
||||
above the newest release - never add an entry to a released section.
|
||||
3. Write the entries into that Upcoming section of `CHANGELOG-big.md`, in the
|
||||
format described below (subsections, topic groups, `<details>` entries
|
||||
linking each commit), and update its **In short** paragraph so it still
|
||||
names the release's main outcomes.
|
||||
4. Run `node releases/changelog-short.mjs`: `CHANGELOG.md` then shows that
|
||||
Upcoming **In short** first and the five newest releases below it, with the
|
||||
longer details only in `CHANGELOG-big.md`.
|
||||
5. Run the changelog tests - `tests/changelogShort.test.cjs`,
|
||||
`tests/changelogFormat.test.cjs`,
|
||||
`tests/changelogEntriesBelongToTheirRelease.test.cjs` (or
|
||||
`node tests/run-node-suites.cjs changelog`) - and commit
|
||||
`CHANGELOG-big.md` and `CHANGELOG.md` together.
|
||||
- During development, add entries under a new `# Upcoming WeKan ® release` section above
|
||||
the newest release (FerretDB uses `## Upcoming FerretDB release`; the patch repos use
|
||||
`# Upcoming <repo> release`). Do **not** hand-edit
|
||||
@@ -438,10 +484,10 @@ directly after the merge.
|
||||
add and syntax-check/register the test and state clearly what could not be run.
|
||||
Do not duplicate an existing test merely to satisfy the audit — extend it when
|
||||
that keeps one behavior in one suite.
|
||||
- **CHANGELOG.md stays small enough for GitHub to show it.** GitHub renders
|
||||
- **CHANGELOG-big.md stays small enough for GitHub to show it.** GitHub renders
|
||||
Markdown up to 500 KiB; above that it shows only plain source text, and above
|
||||
about 2 MB nothing at all ("we can't show files that are this big right
|
||||
now"). CHANGELOG.md reached 2.6 MB over 1,100 releases back to 2015 (#6580),
|
||||
now"). The changelog reached 2.6 MB over 1,100 releases back to 2015 (#6580),
|
||||
and keeping only the current month still came to 2.1 MB, because releases
|
||||
here are frequent and large. So the cut is by SIZE: 450,000 bytes
|
||||
(`BUDGET` in `releases/changelog-archive.mjs`), under GitHub's limit with
|
||||
@@ -449,7 +495,7 @@ directly after the merge.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `CHANGELOG.md` | `# Status` (Platforms/TODO Later inside it), `# Upcoming`, and the newest releases that fit the budget |
|
||||
| `CHANGELOG-big.md` | `# Status` (Platforms/TODO Later inside it), `# Upcoming`, and the newest releases that fit the budget |
|
||||
| `old-CHANGELOG/<year>/<MM>.md` | a month's releases, newest first; a month over the budget continues in `<MM>-part2.md`, `<MM>-part3.md`, ... |
|
||||
| `old-CHANGELOG/<year>.md` | 2015–2025, whole |
|
||||
|
||||
@@ -464,11 +510,11 @@ directly after the merge.
|
||||
still in git (`gitk`, `git-gui`, `git log --follow`), and being small enough
|
||||
to open is worth more.
|
||||
|
||||
Run `node releases/changelog-archive.mjs` whenever CHANGELOG.md has grown -
|
||||
Run `node releases/changelog-archive.mjs` whenever CHANGELOG-big.md has grown -
|
||||
after a release is the natural moment. It is idempotent — a run with nothing
|
||||
to move only refreshes the tables and parts — and it decides from the FILE
|
||||
rather than the clock, so two people running it agree.
|
||||
`tests/changelogArchive.test.cjs` fails when CHANGELOG.md is over the budget.
|
||||
`tests/changelogArchive.test.cjs` fails when CHANGELOG-big.md is over the budget.
|
||||
An archived section is never edited, for the same reason a released one is
|
||||
not.
|
||||
- **The file's shape, top to bottom** — keep it exactly as it is now. There is
|
||||
@@ -564,7 +610,7 @@ directly after the merge.
|
||||
`<details>` blocks below carry that information. Keep the paragraph current as
|
||||
topics change, and shorten it when added commits make it grow. A finished release
|
||||
keeps the paragraph it was written with.
|
||||
- **Neither CHANGELOG.md nor release notes may contain a binaries provenance
|
||||
- **Neither changelog nor the release notes may contain a binaries provenance
|
||||
section or a Platform/Bundle/Binary/From/Version/Checked/SHA256 table.**
|
||||
Do not add "Binaries in these bundles" at the top or bottom of either.
|
||||
Release notes summarize each Translations group as only a list of updated
|
||||
@@ -577,7 +623,7 @@ directly after the merge.
|
||||
no security fixes - is left out rather than added empty.
|
||||
Release notes have only In short, Security, Translations (language list),
|
||||
the standard thanks line and More details at ChangeLog with the release
|
||||
anchor link. Other details stay in CHANGELOG.md. Keep download
|
||||
anchor link into CHANGELOG-big.md. Other details stay in CHANGELOG-big.md. Keep download
|
||||
verification and `provenance.tsv` build artifacts for diagnostics; do not
|
||||
append their contents or `releases/provenance-table.sh` output to notes.
|
||||
- **Inside a subsection, entries are GROUPED BY TOPIC/AREA.** A release touches a handful
|
||||
@@ -636,7 +682,7 @@ directly after the merge.
|
||||
Because CRITICAL comes first, it keeps the `This release ` prefix; a following non-security
|
||||
subsection becomes `and …` per the rule above.
|
||||
- **TODO Later** — a `<details>` nested inside `# Status` near the TOP of
|
||||
`CHANGELOG.md` (above the version sections), a triage backlog for open
|
||||
`CHANGELOG-big.md` (above the version sections), a triage backlog for open
|
||||
issues that were **investigated but not fixed here**, each
|
||||
recorded with a concrete REASON so whoever picks it up next knows why. Use it when working
|
||||
through open issues (the "Fix open issues" process): for each issue, either **fix it** (commit
|
||||
@@ -717,7 +763,7 @@ have cost a released section its accuracy:
|
||||
- **Work continues immediately after a release**, so an entry written right
|
||||
after one has to go somewhere. `release-all.sh` renames `# Upcoming WeKan ®
|
||||
release` to `# v<NEW> …` and stops there — it does **not** create a new,
|
||||
empty `# Upcoming` section. CHANGELOG.md never carries an
|
||||
empty `# Upcoming` section. CHANGELOG-big.md never carries an
|
||||
`**In short:** nothing here yet.` placeholder sitting between releases; it
|
||||
used to (via a now-deleted `releases/changelog-open-next.mjs`), and that
|
||||
meant the file always had a section saying nothing, from the moment a
|
||||
@@ -869,6 +915,21 @@ have cost a released section its accuracy:
|
||||
sizes them at 24px, and keeps the icon and its red drop on one line at every width
|
||||
(they are one icon) — the table itself stays percentage-sized with no fixed or
|
||||
minimum width, so it fills the page at every browser width.
|
||||
- **A report that arrives WITHOUT a GHSA is upgraded when its GHSA arrives.** The
|
||||
stars follow `.tools/wekan.fi/hall-of-fame/Audit-Stars.md`: a GHSA with a
|
||||
suggested fix is 5, a report sent to security@wekan.fi or any other way, with a
|
||||
suggested fix, is 3. When that researcher is later able to add a GitHub security
|
||||
advisory for the same report, the maintainer requests the CVE through the GHSA
|
||||
process, and the row's 3 stars become 5. So whenever an AI updates or audits the
|
||||
Hall of Fame, it checks each 3-star row whose Process says the report came
|
||||
without a GHSA against [WeKan Security
|
||||
Advisories](https://github.com/wekan/wekan/security/advisories): when a GHSA for
|
||||
that report now exists, it sets the row to 5 stars on the index AND on the row's
|
||||
own page, links the GHSA (and the CVE in the CVE cell once one is assigned),
|
||||
rewrites the Process cell to say the report later arrived as a GHSA, and records
|
||||
the change in Audit-Stars.md. Match by the report's subject and reporter, not by
|
||||
a nearby advisory - a GHSA about something else is no upgrade. Until the GHSA
|
||||
exists, the row stays at 3 and its Process cell says the GHSA is awaited.
|
||||
- **On the Hall of Fame index page, the Process and Vulnerabilities cells are
|
||||
collapsed.** Those two columns carry the prose — how the report arrived, and the
|
||||
whole story of the vulnerability — so the page would be one wall of text and the
|
||||
|
||||
+11
-3
@@ -38,7 +38,7 @@ FROM --platform=$BUILDPLATFORM debian:trixie AS bundle
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG VERSION=12.64
|
||||
ARG VERSION=12.78
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
COPY --chmod=755 releases/check-telemetry.py /tmp/check-telemetry.py
|
||||
@@ -148,8 +148,8 @@ ARG DEBIAN_FRONTEND=noninteractive
|
||||
ENV \
|
||||
DEBUG=false \
|
||||
DDP_TRANSPORT=sockjs \
|
||||
NODE_VERSION=v26.12.1 \
|
||||
METEOR_RELEASE=METEOR@3.6-rc.0 \
|
||||
NODE_VERSION=v26.13.0 \
|
||||
METEOR_RELEASE=METEOR@3.6-rc.1 \
|
||||
USE_EDGE=false \
|
||||
NPM_VERSION=12.2.1 \
|
||||
SRC_PATH=./ \
|
||||
@@ -292,6 +292,14 @@ ENV \
|
||||
LOGOUT_ON_HOURS="" \
|
||||
LOGOUT_ON_MINUTES="" \
|
||||
USERS_DISABLE_INACTIVE_AFTER_DAYS="" \
|
||||
LOGOUT_IDLE_MINUTES="" \
|
||||
REQUIRE_2FA="" \
|
||||
PASSWORD_MIN_LENGTH="" \
|
||||
PASSWORD_MIN_LENGTH_ENFORCE="" \
|
||||
PASSWORD_CHECK_PWNED="" \
|
||||
HSTS_MAX_AGE="" \
|
||||
HSTS_INCLUDE_SUBDOMAINS="" \
|
||||
HSTS_PRELOAD="" \
|
||||
ATTACHMENTS_RETENTION_DAYS="" \
|
||||
ATTACHMENTS_RETENTION_MODE="" \
|
||||
ATTACHMENTS_PURGE_DELETED_AFTER_DAYS="" \
|
||||
|
||||
+13
-5
@@ -29,7 +29,15 @@ because WeKan is NOT Big Tech. WeKan is FLOSS.
|
||||
- **Remediation**: Please wait for a new WeKan release that addresses the issue.
|
||||
Fixes are announced at the top of the [ChangeLog](https://github.com/wekan/wekan/blob/main/CHANGELOG.md).
|
||||
- **Recognition**: We will acknowledge your contribution by adding you to our
|
||||
[Hall of Fame](https://wekan.fi/hall-of-fame/).
|
||||
[Hall of Fame](https://wekan.fi/hall-of-fame/). The stars follow the
|
||||
[Stars Audit](https://github.com/wekan/wekan.fi/blob/main/hall-of-fame/Audit-Stars.md):
|
||||
a GitHub security advisory (GHSA) with details and a suggested fix gets 5 stars,
|
||||
a report sent to security@wekan.fi or some other way, with a suggested fix, gets 3.
|
||||
- **Adding the GHSA later**: if you sent your report to security@wekan.fi, or some
|
||||
other way, and are later able to add it as a "New draft security advisory" at
|
||||
[WeKan Security Advisories](https://github.com/wekan/wekan/security/advisories),
|
||||
please do. The maintainer of WeKan then requests the CVE through that GHSA, and
|
||||
your Hall of Fame row is upgraded from 3 stars to 5.
|
||||
- **CVE Policy**: WeKan Security requests CVE at GitHub when releasing Security Advisory.
|
||||
|
||||
### 3. Post-Release and Public Disclosure:
|
||||
@@ -264,13 +272,13 @@ Meteor.startup(() => {
|
||||
|
||||
On the Sandstorm platform, Standalone WeKan features like the Admin Panel are turned off using environment variables, because Sandstorm platform provides SSO for all apps running on Sandstorm.
|
||||
|
||||
[Sandstorm](https://sandstorm.io) is a separate Open Source platform that has been
|
||||
[security audited](https://sandstorm.io/news/2017-03-02-security-review) and found bugs fixed.
|
||||
[Sandstorm](https://sandstorm.org) is a separate Open Source platform that has been
|
||||
[security audited](https://sandstorm.org/news/2017-03-02-security-review) and found bugs fixed.
|
||||
Sandstorm also has passwordless login, LDAP, SAML, Google, and other auth options already.
|
||||
On Sandstorm, code is read-only and signed by app maintainers; only grain content can be modified.
|
||||
WeKan on Sandstorm runs in a sandboxed grain; it does not have access elsewhere without a user-visible PowerBox request or opening a randomly-generated API key URL.
|
||||
Also read [Sandstorm Security Practices](https://docs.sandstorm.io/en/latest/using/security-practices/) and
|
||||
[Sandstorm Security non-events](https://docs.sandstorm.io/en/latest/using/security-non-events/).
|
||||
Also read [Sandstorm Security Practices](https://docs.sandstorm.org/en/latest/using/security-practices/) and
|
||||
[Sandstorm Security non-events](https://docs.sandstorm.org/en/latest/using/security-non-events/).
|
||||
For Sandstorm specific security issues you can contact [kentonv](https://github.com/kentonv) by email.
|
||||
|
||||
## What WeKan bugs are eligible?
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
|
||||
appVersion: "v12.64.0"
|
||||
appVersion: "v12.78.0"
|
||||
files:
|
||||
userUploads:
|
||||
- README.md
|
||||
|
||||
@@ -1282,7 +1282,7 @@ if arguments >= 1:
|
||||
|
||||
# ------- BOARD MEMBER / CARD FIELD API ENDPOINTS START -----------
|
||||
# These commands wrap REST endpoints that already exist in Wekan.
|
||||
# See CHANGELOG.md "Upcoming" section for related issues and design notes.
|
||||
# See CHANGELOG-big.md "Upcoming" section for related issues and design notes.
|
||||
|
||||
# Map a human readable ROLE to the board member permission boolean flags
|
||||
# used by /api/boards/:boardId/members/:userId/add and .../members/:memberId
|
||||
@@ -1472,7 +1472,7 @@ if arguments >= 1:
|
||||
edcard = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
# NOTE: this REPLACES the card's labels. Bulk add/remove (merge) is not yet
|
||||
# implemented server-side, see Issue #5819 in CHANGELOG.md "Upcoming".
|
||||
# implemented server-side, see Issue #5819 in CHANGELOG-big.md "Upcoming".
|
||||
put_data = {'labelIds': labelids if labelids else []}
|
||||
body = requests.put(edcard, json=put_data, headers=headers)
|
||||
print("=== SET CARD LABELS ===\n")
|
||||
|
||||
@@ -60,11 +60,10 @@ exit /b 0
|
||||
REM --- Repo root = folder of this script (strip trailing backslash) ---
|
||||
set "REPO=%~dp0"
|
||||
if "%REPO:~-1%"=="\" set "REPO=%REPO:~0,-1%"
|
||||
REM As build.sh: Meteor installed into the repo's .tools is on PATH, and the
|
||||
REM Playwright browsers live in .tools\ms-playwright, not in the user's cache,
|
||||
REM so a clean checkout and EVERYTHING use the same browsers.
|
||||
if exist "%REPO%\.tools\.meteor" set "PATH=%REPO%\.tools\.meteor;%PATH%"
|
||||
if not defined PLAYWRIGHT_BROWSERS_PATH set "PLAYWRIGHT_BROWSERS_PATH=%REPO%\.tools\ms-playwright"
|
||||
REM As build.sh: everything this script writes - temporary files, downloads,
|
||||
REM Node.js, Meteor, the npm cache, Go and the Playwright browsers - stays in
|
||||
REM .tools\, and those tools come first on PATH (releases\tools-env.bat).
|
||||
call "%REPO%\releases\tools-env.bat"
|
||||
cd /d "%REPO%"
|
||||
|
||||
REM Give the Meteor build tool and Node processes a larger heap so long
|
||||
@@ -852,8 +851,8 @@ REM ===========================================================================
|
||||
echo Installing WeKan dependencies for Windows.
|
||||
rem Full MIME detection uses the libmagic WASM engine/database installed by npm.
|
||||
rem No external file.exe installation is needed.
|
||||
echo This uses Chocolatey to install git, curl, Node.js LTS and MongoDB tools,
|
||||
echo then installs Meteor with npx meteor.
|
||||
echo This uses Chocolatey to install git, curl and the MongoDB tools, then puts
|
||||
echo Node.js and Meteor into .tools\ of this checkout - not into the system.
|
||||
where choco >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo Installing Chocolatey package manager ...
|
||||
@@ -867,33 +866,50 @@ for /f "tokens=2 delims== " %%v in ('findstr /r /c:"NODE_VERSION=v[0-9]" "%REPO%
|
||||
for /f "tokens=2 delims== " %%v in ('findstr /r /c:"NPM_VERSION=[0-9]" "%REPO%\Dockerfile"') do if not defined WK_NPM_VERSION set "WK_NPM_VERSION=%%v"
|
||||
if defined WK_NODE_VERSION set "WK_NODE_VERSION=%WK_NODE_VERSION:v=%"
|
||||
call choco install -y git curl mongodb-shell
|
||||
if defined WK_NODE_VERSION (
|
||||
echo Installing Node.js %WK_NODE_VERSION% ^(Dockerfile NODE_VERSION^) ...
|
||||
call choco install -y nodejs --version=%WK_NODE_VERSION% --allow-downgrade
|
||||
if errorlevel 1 (
|
||||
echo Chocolatey has no Node.js %WK_NODE_VERSION% yet; installing its newest Node.js instead.
|
||||
call choco install -y nodejs
|
||||
)
|
||||
) else (
|
||||
call choco install -y nodejs-lts
|
||||
REM Node.js from nodejs.org's zip, unpacked into .tools\node-v<version>-win-<arch>
|
||||
REM and verified against its published SHASUMS256.txt first - not a system-wide
|
||||
REM Chocolatey install. curl.exe and tar.exe come with Windows 10 and 11.
|
||||
if not defined WK_NODE_VERSION (
|
||||
echo ERROR: could not read NODE_VERSION from the Dockerfile.
|
||||
goto end
|
||||
)
|
||||
set "WK_NODE_ARCH=x64"
|
||||
if /I "%PROCESSOR_ARCHITECTURE%"=="ARM64" set "WK_NODE_ARCH=arm64"
|
||||
set "WK_NODE_NAME=node-v%WK_NODE_VERSION%-win-%WK_NODE_ARCH%"
|
||||
if exist "%WEKAN_TOOLS_DIR%\%WK_NODE_NAME%\node.exe" (
|
||||
echo Node.js %WK_NODE_VERSION% is already in .tools\%WK_NODE_NAME%.
|
||||
) else (
|
||||
echo Installing Node.js %WK_NODE_VERSION% ^(Dockerfile NODE_VERSION^) into .tools ...
|
||||
curl -fsSL -o "%TEMP%\%WK_NODE_NAME%.zip" "https://nodejs.org/dist/v%WK_NODE_VERSION%/%WK_NODE_NAME%.zip" || goto end
|
||||
curl -fsSL -o "%TEMP%\SHASUMS256.txt" "https://nodejs.org/dist/v%WK_NODE_VERSION%/SHASUMS256.txt" || goto end
|
||||
powershell -NoProfile -Command "$want = ((Select-String -Path $env:TEMP\SHASUMS256.txt -Pattern ([regex]::Escape($env:WK_NODE_NAME + '.zip') + '$')).Line -split '\s+')[0]; $got = (Get-FileHash -Algorithm SHA256 ($env:TEMP + '\' + $env:WK_NODE_NAME + '.zip')).Hash; if (-not $want -or $got -ne $want.ToUpper()) { Write-Error ('SHA256 mismatch for ' + $env:WK_NODE_NAME + '.zip'); exit 1 }" || goto end
|
||||
tar -xf "%TEMP%\%WK_NODE_NAME%.zip" -C "%WEKAN_TOOLS_DIR%" || goto end
|
||||
del "%TEMP%\%WK_NODE_NAME%.zip" "%TEMP%\SHASUMS256.txt"
|
||||
)
|
||||
REM npm's global installs (.tools\npm-global) before Node.js's own npm, so the
|
||||
REM npm installed next is the one found.
|
||||
set "PATH=%npm_config_prefix%;%WEKAN_TOOLS_DIR%\%WK_NODE_NAME%;%PATH%"
|
||||
if defined WK_NPM_VERSION (
|
||||
echo Installing npm %WK_NPM_VERSION% ^(Dockerfile NPM_VERSION^) ...
|
||||
call npm install -g npm@%WK_NPM_VERSION%
|
||||
)
|
||||
REM Meteor as https://docs.meteor.com/about/install.html installs it on Windows,
|
||||
REM Linux and macOS alike: npx meteor. The meteor npm package is only an
|
||||
REM installer; npx runs it directly, into %LOCALAPPDATA%\.meteor, and it adds
|
||||
REM that to the user's PATH. npm -g install meteor ran the same installer as an
|
||||
REM install script, which npm 12 refuses to run.
|
||||
where meteor >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo Installing Meteor ^(npx meteor^) ...
|
||||
call npx -y meteor
|
||||
REM installer; npx runs it directly, into %LOCALAPPDATA%\.meteor - so
|
||||
REM LOCALAPPDATA is .tools for the installer only, and Meteor lands in
|
||||
REM .tools\.meteor. npm -g install meteor ran the same installer as an install
|
||||
REM script, which npm 12 refuses to run.
|
||||
if exist "%WEKAN_TOOLS_DIR%\.meteor\meteor.bat" (
|
||||
echo Meteor is already installed in .tools\.meteor.
|
||||
) else (
|
||||
echo Meteor is already installed.
|
||||
echo Installing Meteor ^(npx meteor^) into .tools\.meteor ...
|
||||
setlocal
|
||||
set "LOCALAPPDATA=%WEKAN_TOOLS_DIR%"
|
||||
call npx -y meteor
|
||||
endlocal
|
||||
)
|
||||
echo Done. Open a new terminal so PATH changes take effect, then re-run this script.
|
||||
call "%REPO%\releases\tools-env.bat"
|
||||
echo Done. build.bat puts .tools\ Node.js and Meteor on PATH itself; re-run this script.
|
||||
goto end
|
||||
|
||||
REM ===========================================================================
|
||||
@@ -973,15 +989,18 @@ exit /b %errorlevel%
|
||||
|
||||
:buildcommon
|
||||
REM The same diagnostics as build.sh build_wekan: npm verbose output and
|
||||
REM foreground install scripts, METEOR_PROFILE, Node module resolution and the
|
||||
REM resolver command/output tracer, so a failed Windows build leaves the same
|
||||
REM evidence in its log. WEKAN_BUILD_HEAP_SNAPSHOT=1 caps the heap at
|
||||
REM foreground install scripts, METEOR_PROFILE and the resolver command/output
|
||||
REM tracer, so a failed Windows build leaves the same evidence in its log. Node
|
||||
REM module resolution (one line per require, gigabytes a minute) only with
|
||||
REM WEKAN_BUILD_TRACE_MODULES=1. WEKAN_BUILD_HEAP_SNAPSHOT=1 caps the heap at
|
||||
REM WEKAN_BUILD_HEAP_SNAPSHOT_MB (4096) and writes a heap snapshot near the limit.
|
||||
setlocal
|
||||
set "npm_config_loglevel=verbose"
|
||||
set "npm_config_foreground_scripts=true"
|
||||
if not defined METEOR_PROFILE set "METEOR_PROFILE=100"
|
||||
if defined NODE_DEBUG (set "NODE_DEBUG=%NODE_DEBUG%,module") else (set "NODE_DEBUG=module")
|
||||
if "%WEKAN_BUILD_TRACE_MODULES%"=="1" (
|
||||
if defined NODE_DEBUG (set "NODE_DEBUG=!NODE_DEBUG!,module") else (set "NODE_DEBUG=module")
|
||||
)
|
||||
if "%WEKAN_BUILD_HEAP_SNAPSHOT%"=="1" (
|
||||
if not defined WEKAN_BUILD_HEAP_SNAPSHOT_MB set "WEKAN_BUILD_HEAP_SNAPSHOT_MB=4096"
|
||||
set "TOOL_NODE_FLAGS=--max-old-space-size=!WEKAN_BUILD_HEAP_SNAPSHOT_MB! --heapsnapshot-near-heap-limit=1"
|
||||
@@ -989,7 +1008,7 @@ if "%WEKAN_BUILD_HEAP_SNAPSHOT%"=="1" (
|
||||
echo Heap snapshot: ON, heap capped at !WEKAN_BUILD_HEAP_SNAPSHOT_MB! MB; the build will fail sooner and write Heap.*.heapsnapshot.
|
||||
)
|
||||
set "NODE_OPTIONS=%NODE_OPTIONS% --require="%REPO%\tools\build-command-output.cjs""
|
||||
echo Build diagnostics: resolver command/output tracing, Node module resolution, npm verbose output, foreground install scripts, METEOR_PROFILE=%METEOR_PROFILE% ^(milliseconds^).
|
||||
echo Build diagnostics: resolver command/output tracing, npm verbose output, foreground install scripts, METEOR_PROFILE=%METEOR_PROFILE% ^(milliseconds^).
|
||||
call :buildcommon_steps
|
||||
set "BUILD_RC=%errorlevel%"
|
||||
if not "%BUILD_RC%"=="0" if defined WEKAN_BUILD_LOG (
|
||||
@@ -1961,7 +1980,7 @@ netstat -ano | findstr /r /c:":3001 .*LISTENING" >nul 2>&1
|
||||
if not errorlevel 1 (
|
||||
echo ==^> Reusing the MongoDB already listening on :3001 ^(not started or stopped by this run^).
|
||||
) else (
|
||||
for %%i in ("%REPO%\..\mongodb-test-3001") do set "DBPATH=%%~fi"
|
||||
for %%i in ("%REPO%\.tools\mongodb-test-3001") do set "DBPATH=%%~fi"
|
||||
if not exist "!DBPATH!" md "!DBPATH!"
|
||||
echo ==^> Starting MongoDB ^(Meteor's mongod^) on :3001, dbpath !DBPATH!.
|
||||
start "WekanTestMongo" /MIN "%MONGOD_BIN%" --port 3001 --dbpath "!DBPATH!" --bind_ip 127.0.0.1 --logpath "%RUN_LOGDIR%\wekan-test-mongod.log"
|
||||
|
||||
@@ -103,6 +103,10 @@ WEKAN_TOOLS_DIR="$WEKAN_DIR/.tools"
|
||||
export WEKAN_DIR WEKAN_TOOLS_DIR
|
||||
export PATH="$WEKAN_TOOLS_DIR/bin:${GOBIN:+$GOBIN:}$PATH"
|
||||
. "$WEKAN_DIR/releases/ensure-tools.sh"
|
||||
# Everything this script and the releases/ scripts write - temporary files,
|
||||
# downloads, Meteor, Node.js, the npm cache - stays in .tools/, on Linux, macOS
|
||||
# and Windows alike (releases/tools-env.sh).
|
||||
. "$WEKAN_DIR/releases/tools-env.sh"
|
||||
|
||||
# Prefer the repository-local toolchain installed under .tools. A fresh shell
|
||||
# does not normally have .tools/.meteor on PATH, which made unattended test and
|
||||
@@ -464,10 +468,17 @@ function build_wekan(){
|
||||
echo "===== wekan build started $(date '+%F %T') ====="
|
||||
export npm_config_loglevel=verbose npm_config_foreground_scripts=true
|
||||
export METEOR_PROFILE="${METEOR_PROFILE:-100}"
|
||||
export NODE_DEBUG="${NODE_DEBUG:+$NODE_DEBUG,}module"
|
||||
# Node module resolution, one line per require. OFF unless
|
||||
# WEKAN_BUILD_TRACE_MODULES=1: a Meteor build writes it at gigabytes a
|
||||
# minute - 1.1 GB in the first three minutes of a menu dev build, over
|
||||
# 12 GB in the first half hour of EVERYTHING on macOS, written twice
|
||||
# through tee - and slows the build it is tracing.
|
||||
if [ "${WEKAN_BUILD_TRACE_MODULES:-0}" = 1 ]; then
|
||||
export NODE_DEBUG="${NODE_DEBUG:+$NODE_DEBUG,}module"
|
||||
fi
|
||||
# NODE_OPTIONS parses quoted paths, including checkouts with spaces.
|
||||
export NODE_OPTIONS="${NODE_OPTIONS:-} --require=\"$(pwd)/tools/build-command-output.cjs\""
|
||||
echo "Build diagnostics: resolver command/output tracing, Node module resolution, npm verbose output, foreground install scripts, METEOR_PROFILE=$METEOR_PROFILE (milliseconds)."
|
||||
echo "Build diagnostics: resolver command/output tracing, npm verbose output, foreground install scripts, METEOR_PROFILE=$METEOR_PROFILE (milliseconds), Node module resolution $([ "${WEKAN_BUILD_TRACE_MODULES:-0}" = 1 ] && echo on || echo 'off (WEKAN_BUILD_TRACE_MODULES=1 turns it on)')."
|
||||
build_stage "1/4 Remove dependencies and build caches" rm -rf node_modules node_modules/.cache .meteor/local .build _build || return $?
|
||||
# Updating npm metadata has historically been best effort; installation is required.
|
||||
build_stage "2/4 Compile app to resolve Meteor plugin npm dependencies" meteor update --npm || echo "WARNING: Meteor plugin npm dependency compilation failed; trying dependency installation."
|
||||
@@ -641,8 +652,18 @@ function run_playwright_docker(){
|
||||
local docker_base_url="${WEKAN_BASE_URL:-http://127.0.0.1:3000}"
|
||||
local docker_mongo_url="${WEKAN_MONGO_URL:-mongodb://127.0.0.1:3001/meteor}"
|
||||
local docker_host_proxy_port=""
|
||||
local docker_network="host"
|
||||
# Docker Desktop on macOS keeps the host outside the container's loopback.
|
||||
if [ "$(uname -s)" = Darwin ]; then
|
||||
# Its host network loses connections to host.docker.internal: from a
|
||||
# container on that network about 1 connect in 75 never completed, so a
|
||||
# test page waited 60 s for a document that never came (Firefox, 2026-11-01:
|
||||
# 8 of 600 direct connects, against 0 of 1000 from the bridge network).
|
||||
# Nothing here needs the host network on macOS - WeKan and Mongo are reached
|
||||
# through host.docker.internal and SMTP through docker exec - so the
|
||||
# container uses Docker's bridge network, where the proxy below still
|
||||
# serves localhost.
|
||||
docker_network="bridge"
|
||||
# Meteor's dynamic imports use ROOT_URL (localhost in the test server).
|
||||
# Keep the browser on that same origin and bridge its loopback to the host.
|
||||
# Browsing host.docker.internal while ROOT_URL is localhost makes Firefox
|
||||
@@ -666,35 +687,53 @@ function run_playwright_docker(){
|
||||
mkdir -p "$filesroot"
|
||||
echo "Running Playwright $browser in Docker ($image)."
|
||||
echo "Expecting WeKan at $docker_base_url."
|
||||
local smtp_proxy_pid="" smtp_proxy_ready="" container_name="wekan-playwright-${browser}-$$"
|
||||
if [ "$(uname -s)" = Darwin ] && [ -n "${WEKAN_TEST_SMTP_PORT:-}" ]; then
|
||||
smtp_proxy_ready="$(mktemp "$WEKAN_DIR/.tools/tmp/smtp-proxy.XXXXXX")"
|
||||
node "$pwdir/helpers/docker-smtp-proxy.cjs" "$container_name" "$WEKAN_TEST_SMTP_PORT" "$smtp_proxy_ready" &
|
||||
smtp_proxy_pid=$!
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
[ -s "$smtp_proxy_ready" ] && break
|
||||
kill -0 "$smtp_proxy_pid" 2>/dev/null || break
|
||||
sleep 0.1
|
||||
local container_name="wekan-playwright-${browser}-$$"
|
||||
# Docker Desktop keeps the container's loopback from the host, so a server a
|
||||
# spec starts in the container cannot be reached by WeKan outside it. Each
|
||||
# port below is forwarded from the host into the container through docker
|
||||
# exec (helpers/docker-smtp-proxy.cjs, which carries any TCP): the test SMTP
|
||||
# server, and a small pool for the mock Jira/GitLab servers of the list-sync
|
||||
# specs, which take a free port from WEKAN_TEST_REVERSE_PORTS
|
||||
# (helpers/reachable-server.cjs). Elsewhere they listen on any port.
|
||||
local forward_pids="" forward_ready="" reverse_ports=""
|
||||
if [ "$(uname -s)" = Darwin ]; then
|
||||
reverse_ports="${WEKAN_TEST_REVERSE_PORTS:-39101 39102 39103 39104}"
|
||||
local fport fready fpid i
|
||||
for fport in ${WEKAN_TEST_SMTP_PORT:-} $reverse_ports; do
|
||||
fready="$(mktemp "$WEKAN_DIR/.tools/tmp/docker-port-proxy.XXXXXX")"
|
||||
node "$pwdir/helpers/docker-smtp-proxy.cjs" "$container_name" "$fport" "$fready" &
|
||||
fpid=$!
|
||||
forward_pids="$forward_pids $fpid"; forward_ready="$forward_ready $fready"
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
[ -s "$fready" ] && break
|
||||
kill -0 "$fpid" 2>/dev/null || break
|
||||
sleep 0.1
|
||||
done
|
||||
if [ ! -s "$fready" ]; then
|
||||
kill $forward_pids 2>/dev/null; wait $forward_pids 2>/dev/null
|
||||
rm -f $forward_ready
|
||||
echo "ERROR: local test proxy for port $fport did not start."
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
if [ ! -s "$smtp_proxy_ready" ]; then
|
||||
kill "$smtp_proxy_pid" 2>/dev/null; wait "$smtp_proxy_pid" 2>/dev/null
|
||||
rm -f "$smtp_proxy_ready"
|
||||
echo "ERROR: local SMTP test proxy did not start."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
# Mount the whole repo so specs that reach the repo-root node_modules
|
||||
# (e.g. @wekanteam/exceljs) and .tools resolve; run from tests/playwright.
|
||||
# The repo and the files root are mounted at their OWN host paths, so a path
|
||||
# names the same file for the specs in here and for the server outside:
|
||||
# admin-continuous-backup gives the server a key file it wrote and reads the
|
||||
# stream the server wrote, and with the repo at /repo neither side found the
|
||||
# other's file.
|
||||
# Run as the host user (--user) with a writable HOME so the container does
|
||||
# not leave root-owned files under test-results/ (which would later make
|
||||
# native Chromium/Firefox runs fail with "EACCES: permission denied, mkdir
|
||||
# .../test-results/.playwright-artifacts-N").
|
||||
docker_exec run --rm --init --ipc=host --network host \
|
||||
docker_exec run --rm --init --ipc=host --network "$docker_network" \
|
||||
--name "$container_name" \
|
||||
--label org.wekan.test-run=everything \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
-e HOME=/repo/.tools/tmp \
|
||||
-e TMPDIR=/repo/.tools/tmp \
|
||||
-e HOME="$reporoot/.tools/tmp" \
|
||||
-e TMPDIR="$reporoot/.tools/tmp" \
|
||||
-e WEKAN_BASE_URL="$docker_base_url" \
|
||||
-e WEKAN_DOCKER_HOST_PROXY_PORT="$docker_host_proxy_port" \
|
||||
-e WEKAN_MONGO_URL="$docker_mongo_url" \
|
||||
@@ -706,16 +745,17 @@ function run_playwright_docker(){
|
||||
-e WEKAN_TEST_SERIAL="${WEKAN_TEST_SERIAL:-0}" \
|
||||
-e WEKAN_TEST_SERVER_MODE="${WEKAN_TEST_SERVER_MODE:-bundle}" \
|
||||
-e WEKAN_TEST_SMTP_PORT="${WEKAN_TEST_SMTP_PORT:-}" \
|
||||
-e WEKAN_TEST_REVERSE_PORTS="$reverse_ports" \
|
||||
-e PLAYWRIGHT_HTML_OPEN=never \
|
||||
-e WEKAN_FILES_PATH=/wekan-files \
|
||||
-e WEKAN_FILES_PATH="$filesroot" \
|
||||
-e PLAYWRIGHT_JSON_OUTPUT_NAME="${PLAYWRIGHT_JSON_OUTPUT_NAME:-}" \
|
||||
-v "$filesroot":/wekan-files \
|
||||
-v "$reporoot":/repo -w /repo/tests/playwright \
|
||||
-v "$filesroot":"$filesroot" \
|
||||
-v "$reporoot":"$reporoot" -w "$pwdir" \
|
||||
"$image" \
|
||||
sh -c '
|
||||
export PATH=/repo/tests/playwright/node_modules/.bin:$PATH
|
||||
export PATH="$PWD/node_modules/.bin:$PATH"
|
||||
if [ -n "$WEKAN_DOCKER_HOST_PROXY_PORT" ]; then
|
||||
node /repo/tests/playwright/helpers/docker-host-proxy.cjs &
|
||||
node "$PWD/helpers/docker-host-proxy.cjs" &
|
||||
proxy_pid=$!
|
||||
trap '\''kill "$proxy_pid" 2>/dev/null || true; wait "$proxy_pid" 2>/dev/null || true'\'' EXIT
|
||||
ready=0
|
||||
@@ -728,9 +768,9 @@ function run_playwright_docker(){
|
||||
npx playwright test --project="$0" "$@"
|
||||
' "$browser" "$@"
|
||||
local playwright_rc=$?
|
||||
if [ -n "$smtp_proxy_pid" ]; then
|
||||
kill "$smtp_proxy_pid" 2>/dev/null; wait "$smtp_proxy_pid" 2>/dev/null
|
||||
rm -f "$smtp_proxy_ready"
|
||||
if [ -n "$forward_pids" ]; then
|
||||
kill $forward_pids 2>/dev/null; wait $forward_pids 2>/dev/null
|
||||
rm -f $forward_ready
|
||||
fi
|
||||
return "$playwright_rc"
|
||||
}
|
||||
@@ -1216,7 +1256,7 @@ function run_all_tests(){
|
||||
PW_FAILURES=""
|
||||
TEST_SERVER_PID=""
|
||||
MONGOD_PID=""
|
||||
STATDIR="$(mktemp -d)"
|
||||
STATDIR="$(mktemp -d "$TMPDIR/wekan-stat.XXXXXX")"
|
||||
BPIDS=""
|
||||
|
||||
# STOP WHAT THIS RUN STARTED, on EVERY way out - not only the happy path.
|
||||
@@ -1502,7 +1542,11 @@ function run_all_tests(){
|
||||
echo "ERROR: nothing is listening on :$TEST_DB_PORT and no mongod was found (Meteor dev_bundle or PATH)."
|
||||
echo " Meteor's bundled mongod is normally at <dev_bundle>/mongodb/bin/mongod."; rm -rf "$STATDIR"; return 1
|
||||
fi
|
||||
local DBPATH="../mongodb-test-$TEST_DB_PORT"
|
||||
# In .tools/, where everything this script writes lives (CLAUDE.md;
|
||||
# tests/toolsDirOnly.test.cjs). It was in the checkout's PARENT directory,
|
||||
# which a sandboxed editor cannot write - the test database never
|
||||
# started there.
|
||||
local DBPATH="$WEKAN_DIR/.tools/mongodb-test-$TEST_DB_PORT"
|
||||
mkdir -p "$DBPATH"
|
||||
echo "==> Starting MongoDB (Meteor's mongod) on :$TEST_DB_PORT, dbpath $DBPATH."
|
||||
{ echo "===== mongod :$TEST_DB_PORT - started $(date '+%Y-%m-%d %H:%M:%S %Z') ====="; exec "$MONGOD_BIN" --port "$TEST_DB_PORT" --dbpath "$DBPATH" --bind_ip 127.0.0.1 --nounixsocket; } > "$RUN_LOGDIR/wekan-test-mongod.log" 2>&1 &
|
||||
@@ -1530,7 +1574,7 @@ function run_all_tests(){
|
||||
export WEKAN_FILES_PATH_HOST="$WRITABLE_ABS/files"
|
||||
# ...and the same path for a LOCAL playwright run, not only the Docker one.
|
||||
# Only WEKAN_FILES_PATH_HOST was exported, which is read when the browsers run
|
||||
# in a container (it is mounted there as /wekan-files). A local run fell back
|
||||
# in a container (it is mounted there at the same path). A local run fell back
|
||||
# to the spec's own default of .build/bundle/files, so a spec that writes a
|
||||
# stored attachment wrote it OUTSIDE the root this server reads - and then
|
||||
# tested what WeKan does with a file it does not own.
|
||||
@@ -2389,8 +2433,8 @@ function git_fix_changelog_links(){
|
||||
local script="$WEKAN_DIR/releases/fix-changelog-hashes.sh"
|
||||
[ -f "$script" ] || return 1
|
||||
bash "$script" || return 1
|
||||
if [ -n "$(git status --porcelain -- CHANGELOG.md 2>/dev/null)" ]; then
|
||||
git add CHANGELOG.md
|
||||
if [ -n "$(git status --porcelain -- CHANGELOG-big.md CHANGELOG.md 2>/dev/null)" ]; then
|
||||
git add CHANGELOG-big.md CHANGELOG.md
|
||||
git commit -q -m "CHANGELOG: repoint commit links after history moved.
|
||||
|
||||
A rebase rewrote the commits these entries link to, so the links named commits
|
||||
@@ -2601,7 +2645,7 @@ choose() {
|
||||
# snap/bundle, not on a maintainer's machine) and the superseded old-*.sh and
|
||||
# translations/fill_translations.py.
|
||||
# Release notes summarize translations as affected languages; other changelog
|
||||
# details stay in CHANGELOG.md. Notes include only In short, Security,
|
||||
# details stay in CHANGELOG-big.md. Notes include only In short, Security,
|
||||
# Translations language list, thanks and the changelog link. Binary provenance remains
|
||||
# a build artifact and must not be appended by any release entry point.
|
||||
RELEASE_SCRIPTS=( "Release|Release All: audit, commit, push and build with Actions|releases/release-all.sh|||"
|
||||
@@ -2972,15 +3016,20 @@ for _once in 1; do
|
||||
;;
|
||||
*) echo "Unsupported Linux distribution; install a C/C++ toolchain, git, curl, wget, 7zip, zip, unzip, file and npm." >&2; exit 1 ;;
|
||||
esac
|
||||
sudo npm -g install n
|
||||
sudo n "$_wekan_node_version"
|
||||
sudo npm install -g "npm@${_wekan_npm_version:-latest}"
|
||||
# Node.js into .tools/n with n's N_PREFIX, as this user: not into
|
||||
# /usr/local with sudo. releases/tools-env.sh puts .tools/n/bin on
|
||||
# PATH, and npm's global installs go to that same prefix.
|
||||
N_PREFIX="$WEKAN_TOOLS_DIR/n" npx -y n "$_wekan_node_version"
|
||||
export PATH="$WEKAN_TOOLS_DIR/n/bin:$PATH"
|
||||
hash -r
|
||||
npm install -g "npm@${_wekan_npm_version:-latest}"
|
||||
hash -r
|
||||
# Meteor the way https://docs.meteor.com/about/install.html installs
|
||||
# it on Linux, macOS and Windows alike: `npx meteor`, as this user and
|
||||
# never with sudo. The meteor npm package is only an installer; npx
|
||||
# runs its cli.js, which installs into ~/.meteor and adds that to
|
||||
# PATH in the shell rc files. `sudo npm -g install meteor` ran the
|
||||
# runs its cli.js, which installs into $HOME/.meteor and adds that to
|
||||
# PATH in the shell rc files - so HOME is .tools for the installer
|
||||
# only, and Meteor and those rc lines land in .tools/. `sudo npm -g install meteor` ran the
|
||||
# same installer as an install script instead, which npm 12 refuses
|
||||
# to run (and which also refused --unsafe-perm and the documented
|
||||
# --foreground-script).
|
||||
@@ -2988,16 +3037,14 @@ for _once in 1; do
|
||||
# The global meteor package such a setup left behind is only that
|
||||
# installer, never Meteor itself (~/.meteor is not touched).
|
||||
sudo npm -g uninstall meteor >/dev/null 2>&1 || true
|
||||
# npx caches in ~/.npm, which an old `sudo npm` could leave root-owned.
|
||||
if [ -d "$HOME/.npm" ] && [ ! -w "$HOME/.npm" ]; then
|
||||
sudo chown -R "$(id -u):$(id -g)" "$HOME/.npm"
|
||||
fi
|
||||
if [ -x "$HOME/.meteor/meteor" ]; then
|
||||
echo "Meteor is already installed in $HOME/.meteor"
|
||||
# npx caches in npm_config_cache (.tools/npm-cache).
|
||||
mkdir -p "$npm_config_cache"
|
||||
if [ -x "$WEKAN_TOOLS_DIR/.meteor/meteor" ]; then
|
||||
echo "Meteor is already installed in $WEKAN_TOOLS_DIR/.meteor"
|
||||
else
|
||||
npx -y meteor
|
||||
HOME="$WEKAN_TOOLS_DIR" npx -y meteor
|
||||
fi
|
||||
export PATH="$HOME/.meteor:$PATH"
|
||||
. "$WEKAN_DIR/releases/tools-env.sh"
|
||||
elif [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
echo "macOS"
|
||||
# Node comes from nvm, not from Homebrew's node@26 keg.
|
||||
@@ -3009,13 +3056,14 @@ for _once in 1; do
|
||||
# puts that on PATH itself, so this does not go stale the way a
|
||||
# pinned version does. npm comes with the Node it installs, so
|
||||
# there is no `brew install npm` either.
|
||||
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||
# NVM_DIR is .tools/nvm (releases/tools-env.sh).
|
||||
mkdir -p "$NVM_DIR"
|
||||
if [ ! -s "$NVM_DIR/nvm.sh" ]; then
|
||||
echo "Installing nvm into $NVM_DIR ..."
|
||||
# A PINNED tag, not master: this pipes a downloaded script into
|
||||
# a shell, so it must be a revision somebody has looked at.
|
||||
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.6/install.sh | bash
|
||||
# PROFILE=/dev/null: the installer edits no shell rc file.
|
||||
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.6/install.sh | PROFILE=/dev/null bash
|
||||
fi
|
||||
# nvm is a shell FUNCTION, not a binary, so it has to be sourced -
|
||||
# `command -v nvm` finds nothing until this line has run.
|
||||
@@ -3037,23 +3085,18 @@ for _once in 1; do
|
||||
nvm use 26
|
||||
npm install -g "npm@${_wekan_npm_version:-latest}"
|
||||
echo "Node $(node --version), npm $(npm --version)"
|
||||
# Let new shells find nvm too. Its installer appends these itself,
|
||||
# but only to the rc file it detects and only when IT did the
|
||||
# install - so a machine that already had nvm keeps working.
|
||||
touch "$HOME/.zshrc"
|
||||
for rc in "$HOME/.zshrc" "$HOME/.bashrc"; do
|
||||
[ -e "$rc" ] || continue
|
||||
grep -qxF 'export NVM_DIR="$HOME/.nvm"' "$rc" || echo 'export NVM_DIR="$HOME/.nvm"' >> "$rc"
|
||||
grep -qxF '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"' "$rc" || echo '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"' >> "$rc"
|
||||
done
|
||||
# No shell rc file is edited: build.sh and the releases/ scripts put
|
||||
# .tools/nvm's Node.js and .tools/.meteor on PATH themselves
|
||||
# (releases/tools-env.sh).
|
||||
# Meteor as https://docs.meteor.com/about/install.html installs it:
|
||||
# `npx meteor`, as this user, never sudo (see the Linux branch).
|
||||
if [ -x "$HOME/.meteor/meteor" ]; then
|
||||
echo "Meteor is already installed in $HOME/.meteor"
|
||||
# `npx meteor`, as this user, never sudo, with HOME=.tools for the
|
||||
# installer only (see the Linux branch).
|
||||
if [ -x "$WEKAN_TOOLS_DIR/.meteor/meteor" ]; then
|
||||
echo "Meteor is already installed in $WEKAN_TOOLS_DIR/.meteor"
|
||||
else
|
||||
npx -y meteor
|
||||
HOME="$WEKAN_TOOLS_DIR" npx -y meteor
|
||||
fi
|
||||
export PATH="$HOME/.meteor:$PATH"
|
||||
. "$WEKAN_DIR/releases/tools-env.sh"
|
||||
exit;
|
||||
elif [[ "$OSTYPE" == "cygwin" ]]; then
|
||||
# POSIX compatibility layer and Linux environment emulation for Windows
|
||||
|
||||
@@ -111,6 +111,28 @@ Meteor.startup(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// LOGOUT_IDLE_MINUTES: report the user's activity while signed in, if the
|
||||
// limit is set (models/lib/idleLogout.js).
|
||||
const { createIdleActivityReporter } = require('/client/lib/idleActivity');
|
||||
const idleReporter = createIdleActivityReporter({
|
||||
target: document,
|
||||
touch: () => Meteor.call('touchActivity', () => {}),
|
||||
});
|
||||
let idleCheckedFor = null;
|
||||
Tracker.autorun(() => {
|
||||
const userId = Meteor.userId();
|
||||
if (!userId) {
|
||||
idleCheckedFor = null;
|
||||
idleReporter.stop();
|
||||
return;
|
||||
}
|
||||
if (idleCheckedFor === userId) return;
|
||||
idleCheckedFor = userId;
|
||||
Meteor.call('getIdleLogout', (error, result) => {
|
||||
if (!error && result && result.enabled && Meteor.userId() === userId) idleReporter.start();
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize mobile mode on startup for iOS devices
|
||||
// This ensures mobile mode is applied correctly on page load
|
||||
Tracker.afterFlush(() => {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
.new-comment {
|
||||
position: relative;
|
||||
margin: 0 0 20px 38px;
|
||||
/* #6805: the room for the avatar is on the side the avatar is drawn - the
|
||||
reading start (inset-inline-start below). A physical left margin kept it on
|
||||
the left while a right-to-left page drew the avatar on the right, outside
|
||||
the card. */
|
||||
margin-block: 0 20px;
|
||||
margin-inline: 38px 0;
|
||||
}
|
||||
.new-comment .member {
|
||||
opacity: 0.7;
|
||||
@@ -29,7 +34,8 @@
|
||||
border: 0;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.23);
|
||||
height: 36px;
|
||||
margin: 4px 4px 6px 0;
|
||||
margin-block: 4px 6px;
|
||||
margin-inline: 0 4px;
|
||||
padding: 9px 11px;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -49,7 +55,8 @@
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.23);
|
||||
color: #8c8c8c;
|
||||
height: 36px;
|
||||
margin: 4px 4px 6px 0;
|
||||
margin-block: 4px 6px;
|
||||
margin-inline: 0 4px;
|
||||
width: 92%;
|
||||
}
|
||||
.comment-item:hover {
|
||||
@@ -161,7 +168,8 @@
|
||||
background: #f4f4f4;
|
||||
border-inline-start: 2px solid #ccc;
|
||||
padding: 4px 6px;
|
||||
margin: 0 4px 6px 0;
|
||||
margin-block: 0 6px;
|
||||
margin-inline: 0 4px;
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
display: flex;
|
||||
|
||||
@@ -1327,11 +1327,15 @@ THEME - Apple Glass Pastel
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
/* Quiet text - the forecast's "Not enough finished cards", empty-field hints,
|
||||
activity lines. #6b7280 was 4.51:1 on a plain #f7f7f7 and 4.4:1 on the
|
||||
blue-tinted card header, on the edge of the 4.5:1 ordinary text needs;
|
||||
#4b5563 is 7.05:1 and 6.95:1 there. */
|
||||
.board-color-appleglasspastel .quiet,
|
||||
.board-color-appleglasspastel .quiet a,
|
||||
.board-color-appleglasspastel .activity-desc,
|
||||
.board-color-appleglasspastel .sidebar-list-item-description {
|
||||
color: #6b7280;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.board-color-appleglasspastel .list.list-composer,
|
||||
@@ -3428,7 +3432,7 @@ THEME - Clean Dark
|
||||
height: 13px;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
margin-inline-start: 3px;
|
||||
margin-left: 3px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.board-color-cleandark .checklist-progress-bar {
|
||||
@@ -4197,7 +4201,7 @@ THEME - Clean Light
|
||||
height: 13px;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
margin-inline-start: 3px;
|
||||
margin-left: 3px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.board-color-cleanlight .checklist-progress-bar {
|
||||
|
||||
@@ -692,13 +692,16 @@ Template.boardVisibilityList.helpers({
|
||||
// Settings / Visibility (Settings.customPrivateBoardDesc /
|
||||
// customPublicBoardDesc); empty/unset falls back to the i18n default,
|
||||
// byte-identical to before this setting existed.
|
||||
// TAPi18n.__ reads `this` (its reactive language dependency), so it is
|
||||
// passed bound: handed over bare it threw inside the helper and both
|
||||
// descriptions rendered as an empty span in every language.
|
||||
privateDesc() {
|
||||
const setting = ReactiveCache.getCurrentSetting();
|
||||
return visibilityDesc(setting?.customPrivateBoardDesc, TAPi18n.__, 'private-desc');
|
||||
return visibilityDesc(setting?.customPrivateBoardDesc, key => TAPi18n.__(key), 'private-desc');
|
||||
},
|
||||
publicDesc() {
|
||||
const setting = ReactiveCache.getCurrentSetting();
|
||||
return visibilityDesc(setting?.customPublicBoardDesc, TAPi18n.__, 'public-desc');
|
||||
return visibilityDesc(setting?.customPublicBoardDesc, key => TAPi18n.__(key), 'public-desc');
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -52,8 +52,14 @@ const boardFor = context => ReactiveCache.getBoard(context?.record?.boardId || S
|
||||
// `field` (a definition suffix, 'Sprint') draws one field alone: on a card and
|
||||
// a minicard each Scrum field sits where the board's Board Settings / Card
|
||||
// order puts it (models/lib/cardFieldOrder.js SCRUM_FIELD_KEYS).
|
||||
// Read through the board as that side sees it (models/lib/cardShownFields.js
|
||||
// boardForSide): a Scrum section hidden whole on Show on Card or Show on
|
||||
// Minicard in Card Settings hides these fields there too. The settings above
|
||||
// (scrumFieldSettings) read the board itself.
|
||||
function visibleFields(context) {
|
||||
const board = boardFor(context);
|
||||
const { boardForSide } = require('/models/lib/cardShownFields');
|
||||
const side = context?.kind === 'card' || context?.kind === 'minicard' ? context.kind : null;
|
||||
const board = side ? boardForSide(boardFor(context), side) : boardFor(context);
|
||||
return (definitions[context?.kind] || []).filter(([suffix]) => (!context.field || context.field === suffix) &&
|
||||
board?.scrum?.visibility?.[`${context.kind}${suffix}`] === true);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ template(name="cardAttachmentsPopup")
|
||||
//- #3257: a card attached to this card, as Trello's card attachments.
|
||||
li
|
||||
a.js-attach-card {{_ 'attach-card'}}
|
||||
//- #6802: a new drawing, the whole window, saved as an SVG attachment.
|
||||
li
|
||||
a.js-attach-drawing
|
||||
i.fa.fa-pencil
|
||||
| {{_ 'cardDrawingPopup-title'}}
|
||||
//- #6796: the upload rules, where the upload is chosen - they used to be
|
||||
body text above the opened card's attachment list.
|
||||
.attachment-upload-help.quiet
|
||||
|
||||
@@ -20,6 +20,7 @@ const { cleanFileName } = require('/imports/lib/fileNameDisplay');
|
||||
const { attachmentTileCompact } = require('/models/lib/attachmentTile.cjs');
|
||||
import { formatDateTime } from '/imports/lib/dateUtils';
|
||||
import { EscapeActions } from '/client/lib/escapeActions';
|
||||
import { openNewDrawing } from '/client/lib/newDrawing';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
|
||||
@@ -599,6 +600,17 @@ Template.cardAttachmentsPopup.events({
|
||||
},
|
||||
'click .js-upload-clipboard-image': Popup.open('previewClipboardImage'),
|
||||
'click .js-attach-card': Popup.open('attachCard'),
|
||||
// #6802: draw a new file - the whole window - saved as an SVG attachment.
|
||||
'click .js-attach-drawing'(event) {
|
||||
event.preventDefault();
|
||||
// This popup goes, and the drawing opens once the click is over: the
|
||||
// document's click-outside handler would otherwise see a click on a popup
|
||||
// that no longer exists and close the new one at once.
|
||||
event.stopPropagation();
|
||||
const cardId = this && this._id;
|
||||
Popup.close();
|
||||
setTimeout(() => openNewDrawing(cardId), 0);
|
||||
},
|
||||
});
|
||||
|
||||
// #3257: attach a card by a pasted card link, or one of this board's cards
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
/* Card Settings (Board Settings / Card, docs/Features/Cards/Card-Settings.md):
|
||||
"Show" and its two choices, then the sections drawn like checklists. */
|
||||
.card-settings-inline { padding: 4px 0 8px; }
|
||||
|
||||
/* Two panes, Minicard first (the start: left in a left-to-right page, right in
|
||||
a right-to-left one) and Card after it (sidebar.jade boardCardSettingsPopup).
|
||||
STACKED by default - Minicard above Card - and the container scrolls as one;
|
||||
SIDE BY SIDE once the popup is wide enough for two panes of about 20em,
|
||||
and then each pane's body scrolls on its own while the container does not.
|
||||
A collapsed pane is its header only, and the other pane takes the width.
|
||||
The popup's content wrapper does not scroll here (popup.css), so the
|
||||
container is the one scroller in the stacked layout. */
|
||||
.card-settings-panes-container {
|
||||
container-type: inline-size;
|
||||
container-name: card-settings-panes;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.card-settings-panes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 0 auto;
|
||||
gap: 8px;
|
||||
}
|
||||
.card-settings-pane { min-width: 0; }
|
||||
.card-settings-pane-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-block: 4px 6px;
|
||||
border-block-end: 1px solid #ddd;
|
||||
margin-block-end: 8px;
|
||||
}
|
||||
.card-settings-pane-collapse {
|
||||
padding: 2px 4px;
|
||||
color: #808080;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-settings-pane-collapse:hover,
|
||||
.card-settings-pane-collapse:focus-visible { color: #2980b9; }
|
||||
.card-settings-pane-title {
|
||||
margin: 0;
|
||||
font-size: calc(17px * var(--wekan-ui-font-scale, 1));
|
||||
font-weight: bold;
|
||||
}
|
||||
.card-settings-pane-body[hidden] { display: none !important; }
|
||||
/* The splitter: only side by side with neither pane collapsed (the container
|
||||
query below shows it). A thin line with a wider grab area, the Admin Panel
|
||||
left menu's grip (settingBody.css .left-menu-resize-handle) in look. */
|
||||
.card-settings-splitter {
|
||||
display: none;
|
||||
flex: 0 0 10px;
|
||||
align-self: stretch;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
position: relative;
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.card-settings-splitter::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 4px;
|
||||
width: 2px;
|
||||
background: #ddd;
|
||||
}
|
||||
.card-settings-splitter:hover,
|
||||
.card-settings-splitter:focus-visible {
|
||||
background: rgba(0, 123, 255, 0.4);
|
||||
outline: none;
|
||||
}
|
||||
.card-settings-splitter:active { background: rgba(0, 123, 255, 0.6); }
|
||||
body.card-settings-splitting-active {
|
||||
user-select: none;
|
||||
cursor: col-resize;
|
||||
}
|
||||
.card-settings-pane.is-pane-collapsed .card-settings-pane-head { margin-block-end: 0; }
|
||||
@container card-settings-panes (min-width: 40em) {
|
||||
.card-settings-panes {
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
gap: 0;
|
||||
}
|
||||
.card-settings-pane {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
/* The Minicard pane takes its share of the width (the splitter between
|
||||
them, models/lib/cardSettingsSplit.js); the Card pane takes the rest. */
|
||||
.card-settings-panes:not(.has-collapsed-pane) > .card-settings-pane-minicard {
|
||||
flex: 0 0 calc((100% - 10px) * var(--card-settings-split, 0.5));
|
||||
}
|
||||
.card-settings-pane.is-pane-collapsed { flex: 0 0 auto; }
|
||||
.card-settings-panes:not(.has-collapsed-pane) > .card-settings-splitter { display: block; }
|
||||
.card-settings-pane-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-inline-end: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* A section: the checklist's grey block, its title line and its items. */
|
||||
.card-settings-inline-section {
|
||||
background: #f7f7f7;
|
||||
border-radius: 3px;
|
||||
margin-block-end: 8px;
|
||||
padding: 4px 8px 6px;
|
||||
}
|
||||
.card-settings-inline-section.ui-sortable-helper {
|
||||
box-shadow: -2px 2px 8px rgba(0, 0, 0, 0.3), 0 0 1px rgba(0, 0, 0, 0.5);
|
||||
cursor: grabbing;
|
||||
}
|
||||
.card-settings-inline-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-block: 4px;
|
||||
}
|
||||
.card-settings-inline-section-title {
|
||||
flex: 1 1 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.card-settings-inline-section-title.js-card-settings-section-handle { cursor: grab; }
|
||||
.card-settings-inline-section-count { color: #808080; white-space: nowrap; }
|
||||
.card-settings-inline-collapse,
|
||||
.card-settings-inline-section-move,
|
||||
.card-settings-inline-menu {
|
||||
padding: 2px 4px;
|
||||
color: #808080;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-settings-inline-collapse:hover,
|
||||
.card-settings-inline-collapse:focus-visible,
|
||||
.card-settings-inline-menu:hover,
|
||||
.card-settings-inline-menu:focus-visible { color: #2980b9; }
|
||||
/* The section's own checkbox, left of its title: the rows' look. A section
|
||||
hidden whole on this side keeps its rows' ticks, drawn faded. */
|
||||
.card-settings-section-side { flex: 0 0 auto; }
|
||||
.card-settings-section-side > a.flex { margin: 0; }
|
||||
.card-settings-inline-section.is-section-hidden .card-settings-inline-items { opacity: 0.55; }
|
||||
/* One row on this side: one line, no caret - its checkbox where the other
|
||||
sections' checkboxes are. */
|
||||
.card-settings-inline-section.is-single .card-settings-inline-section-head {
|
||||
padding-inline-start: calc(0.5em + 8px);
|
||||
}
|
||||
/* "+" under Custom Fields' items: a new custom field. */
|
||||
.card-settings-add-custom-field { padding: 2px 0 0 22px; }
|
||||
.card-settings-add-custom-field-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
border-radius: 3px;
|
||||
color: #808080;
|
||||
text-decoration: none;
|
||||
}
|
||||
.card-settings-add-custom-field-button:hover,
|
||||
.card-settings-add-custom-field-button:focus-visible { background: rgba(0, 0, 0, 0.06); color: #2980b9; }
|
||||
/* Collapsed: the items hidden, not removed, so their drag stays bound. */
|
||||
.card-settings-inline-section.is-collapsed .card-settings-inline-items,
|
||||
.card-settings-inline-section.is-collapsed .card-settings-add-custom-field { display: none; }
|
||||
|
||||
/* An item: the checkbox, the name, and its hamburger at the end
|
||||
(sidebar.css .card-settings-line is the line itself). */
|
||||
.card-settings-inline .card-settings-line {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
/* A space's width between the checkbox and the name, as in text. */
|
||||
column-gap: 0.3em;
|
||||
border-bottom: 0;
|
||||
padding-inline-start: 22px;
|
||||
}
|
||||
.card-settings-inline .card-settings-line.ui-sortable-helper { background: #fff; }
|
||||
.card-settings-inline .materialCheckBox { transition: none; }
|
||||
.card-settings-inline .card-field-order-toggle.is-animating .materialCheckBox { transition: 0.2s; }
|
||||
/* The hamburgers' checkboxes, likewise: only the one just clicked animates. */
|
||||
.card-settings-section-menu .materialCheckBox,
|
||||
.card-settings-item-menu .materialCheckBox { transition: none; }
|
||||
.card-settings-section-menu .is-animating .materialCheckBox,
|
||||
.card-settings-item-menu .is-animating .materialCheckBox { transition: 0.2s; }
|
||||
|
||||
/* The hamburgers' popups. */
|
||||
.card-settings-item-menu,
|
||||
.card-settings-section-menu { min-width: 16em; }
|
||||
/* The hamburgers' checkboxes: a space's width before their label, as on
|
||||
the rows. */
|
||||
.card-settings-item-menu .card-settings-board-option > a.flex,
|
||||
.card-settings-section-menu .card-settings-board-option > a.flex {
|
||||
align-items: center;
|
||||
column-gap: 0.3em;
|
||||
}
|
||||
/* A menu's options start at its edge, like its table: not .check-div's
|
||||
indent for a form's checkboxes. */
|
||||
.card-settings-item-menu .card-settings-board-option,
|
||||
.card-settings-section-menu .card-settings-board-option {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
/* A tick is drawn rotated (forms.css #6786) and reaches about 3px past its
|
||||
box toward the label, which ate the space: drawn 3px further left, it keeps
|
||||
it - the box, and so the label, do not move when it is ticked. Right to
|
||||
left the label is on the other side of the box and the tick leans away
|
||||
from it, so it stays as it is. */
|
||||
:root:not([dir="rtl"]) .card-settings-inline .materialCheckBox.is-checked,
|
||||
:root:not([dir="rtl"]) .card-settings-item-menu .materialCheckBox.is-checked,
|
||||
:root:not([dir="rtl"]) .card-settings-section-menu .materialCheckBox.is-checked {
|
||||
left: -6px;
|
||||
}
|
||||
|
||||
/* A hamburger's panel (sidebar.jade cardSettingsInlinePanel): what the
|
||||
hamburger holds, opened UNDER its line inside the pane, pushing the rest
|
||||
down - not a popup. The open hamburger is tinted, and the panel hangs from
|
||||
it in the same tint, with a notch pointing up at the line it belongs to. */
|
||||
.card-settings-inline-menu.is-open,
|
||||
.card-settings-add-custom-field-button.is-open {
|
||||
background: rgba(41, 128, 185, 0.15);
|
||||
color: #2980b9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.card-settings-inline-panel {
|
||||
position: relative;
|
||||
margin-block: 6px 8px;
|
||||
padding: 10px 12px 8px;
|
||||
border: 1px solid rgba(41, 128, 185, 0.45);
|
||||
border-radius: 4px;
|
||||
background: rgba(41, 128, 185, 0.06);
|
||||
/* Its own block, the width of what it hangs from. */
|
||||
flex: 0 0 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.card-settings-inline-panel::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-block-start: -7px;
|
||||
inset-inline-end: 10px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: inherit;
|
||||
border-inline-start: 1px solid rgba(41, 128, 185, 0.45);
|
||||
border-block-start: 1px solid rgba(41, 128, 185, 0.45);
|
||||
transform: rotate(45deg);
|
||||
background-color: #eef4f9;
|
||||
}
|
||||
/* The + is at the start of its line: the notch is under it there. */
|
||||
.card-settings-inline-panel-create::before {
|
||||
inset-inline-end: auto;
|
||||
inset-inline-start: 6px;
|
||||
}
|
||||
.card-settings-inline-panel-close {
|
||||
/* Above the notch, which is drawn at the same end of the panel's top. */
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
inset-block-start: 4px;
|
||||
inset-inline-end: 6px;
|
||||
color: #808080;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.card-settings-inline-panel-close:hover,
|
||||
.card-settings-inline-panel-close:focus-visible { color: #2980b9; }
|
||||
.card-settings-section-action-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import './cardDetails.jade';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
|
||||
// docs/Features/Cards/Card-Fields.md: an opened card shows every field its
|
||||
// board shows on cards, empty or not, less the Card Settings sections the
|
||||
// board hides on Show on Card. The rules are models/lib/cardShownFields.js.
|
||||
const f = require('/models/lib/cardShownFields');
|
||||
|
||||
// The board as its opened cards see it (cardShownFields.js boardForCard): the
|
||||
// same for every card of the board, empty or not, so `data` (the card) does
|
||||
// not change it - it is kept so the callers read as before.
|
||||
export function cardBoardView(data) { // eslint-disable-line no-unused-vars
|
||||
return f.boardForCard(Utils.getCurrentBoard());
|
||||
}
|
||||
|
||||
// The opened card and each of its section templates read `currentBoard`; this
|
||||
// template-level helper answers before the global one, so every
|
||||
// `currentBoard.allowsX` in them asks the card's view - and a template not
|
||||
// listed here (the board, Card Settings itself) is unchanged. The minicard has
|
||||
// its own (client/components/cards/minicard.js MINICARD_FIELD_TEMPLATES).
|
||||
export const CARD_FIELD_TEMPLATES = Object.freeze([
|
||||
// One per Card Settings section the opened card draws (cardDetails.jade).
|
||||
'cardDetails', 'cardFieldSectionLabels', 'cardFieldSectionDate', 'cardFieldSectionMembers',
|
||||
'cardFieldSectionCard', 'cardFieldSectionTime', 'cardFieldSectionCustomFields',
|
||||
'cardFieldSectionVote', 'cardFieldSectionPoker', 'cardFieldSectionDescription', 'cardFieldSectionScrum',
|
||||
'cardFieldSectionChecklists', 'cardFieldSectionSubtasks', 'cardFieldSectionAttachments', 'cardCommentsSection',
|
||||
]);
|
||||
CARD_FIELD_TEMPLATES.forEach(name => {
|
||||
Template[name].helpers({
|
||||
currentBoard() {
|
||||
const tpl = Template.instance();
|
||||
return cardBoardView(tpl && tpl.data);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -19,17 +19,20 @@ template(name="cardCustomFieldsPopup")
|
||||
= name
|
||||
a.js-edit-custom-field(title="{{_ 'edit'}}" aria-label="{{_ 'edit'}}")
|
||||
i.fa.fa-pencil-square-o(aria-hidden="true")
|
||||
//- The rule separates the list above from "Add custom field" below - with
|
||||
no custom field yet, there is nothing above it to separate from.
|
||||
if customFields.length
|
||||
hr
|
||||
a.quiet-button.full.js-open-create-custom-field
|
||||
i.fa.fa-plus
|
||||
span {{_ 'createCustomField'}}
|
||||
//- #5681: this card's fields linked, by name, to another card's.
|
||||
a.quiet-button.full.js-open-custom-field-links
|
||||
i.fa.fa-link
|
||||
span {{_ 'custom-field-links'}}
|
||||
//- In Card Settings' Custom Fields pane these two are forms of their own
|
||||
under the list, so they are not repeated here (sidebar.css).
|
||||
.card-custom-fields-more
|
||||
//- The rule separates the list above from "Add custom field" below -
|
||||
with no custom field yet, there is nothing above it to separate from.
|
||||
if customFields.length
|
||||
hr
|
||||
a.quiet-button.full.js-open-create-custom-field
|
||||
i.fa.fa-plus
|
||||
span {{_ 'createCustomField'}}
|
||||
//- #5681: this card's fields linked, by name, to another card's.
|
||||
a.quiet-button.full.js-open-custom-field-links
|
||||
i.fa.fa-link
|
||||
span {{_ 'custom-field-links'}}
|
||||
|
||||
//- #5681: the cards this card's custom fields are linked to, and the form that
|
||||
links one more - a pasted card link (Copy card link gives one) or one of this
|
||||
|
||||
@@ -366,43 +366,43 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* The title is two things at once, split like a minicard's: the LEADING half
|
||||
opens the editor, the trailing half drags the window. With drag handles OFF
|
||||
there is no handle, so the title bar is what the window is moved by - and a
|
||||
heading that is entirely an edit target leaves nothing to take hold of.
|
||||
/* #6839: the whole title edits - every word of it and the space beside a
|
||||
short last line, which is what anybody clicks to change the title. The window
|
||||
is moved from the empty space ABOVE the title, in the row of the collapse
|
||||
caret and the menu (cardDetails.js), or by the drag handle when drag handles
|
||||
are on. It was split like a minicard's - the trailing half dragged - and a
|
||||
click on the right of the title did nothing anybody expected.
|
||||
|
||||
An overlay rather than a split of the text, because the text is a `.viewer`
|
||||
and cutting it in two would cut the sentence in two. `inset-inline-start`, so
|
||||
the edit half is the left in English and the right in Arabic from one rule.
|
||||
The drag handler steps aside for this zone (cardDetails.js). */
|
||||
An overlay rather than a link around the text, because the text is a
|
||||
`.viewer` with its own links. */
|
||||
.card-details .card-details-header .card-details-title .card-details-title-edit-zone {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
inset-inline-start: 0;
|
||||
/* Half of the heading - and the heading is stretched to the row below, so
|
||||
this is half of the BAR however long the title happens to be. */
|
||||
width: 50%;
|
||||
width: 100%;
|
||||
cursor: text;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* The heading fills the row rather than shrinking to its text, so "half the
|
||||
title" and "half the bar" are the same half however long the title is. A
|
||||
heading only as wide as its words leaves the empty space beside it belonging
|
||||
to nothing - and that space is where anybody would grab to move the window.
|
||||
It stays inline-block so the complete-toggle beside it keeps its place; the
|
||||
buttons floating at the trailing edge take their room out of this width. */
|
||||
/* The heading fills the row rather than shrinking to its text, so the edit
|
||||
zone covers the space beside a short title too. It stays inline-block so
|
||||
the complete-toggle beside it keeps its place. */
|
||||
.card-details .card-details-header .card-details-title {
|
||||
min-width: 60%;
|
||||
}
|
||||
|
||||
/* With drag handles ON the handle is the only drag source, so the whole title
|
||||
is the edit target - the same rule the minicard follows, from the same
|
||||
question: `card-details-with-handle` is set by the template from
|
||||
isTouchScreenOrShowDesktopDragHandles, which is Utils.showDragHandles(). */
|
||||
.card-details.card-details-with-handle .card-details-header .card-details-title .card-details-title-edit-zone {
|
||||
width: 100%;
|
||||
/* The empty part of the header row is what moves the window when there is no
|
||||
drag handle; with one, the handle alone does. */
|
||||
.card-details:not(.card-details-with-handle) .card-details-header {
|
||||
cursor: move;
|
||||
}
|
||||
.card-details .card-details-header .card-details-title,
|
||||
.card-details .card-details-header a {
|
||||
cursor: auto;
|
||||
}
|
||||
.card-details .card-details-header a {
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-details .card-details-header .linked-card-location {
|
||||
font-style: italic;
|
||||
@@ -587,6 +587,9 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
float: inline-end;
|
||||
inset-inline-start: 50%;
|
||||
margin: 15px 0;
|
||||
/* The comments are here when maximized (cardCommentsSection): the column
|
||||
is as wide as the left one, beside it. */
|
||||
width: calc(50% - 40px);
|
||||
}
|
||||
.card-details-maximized .card-details-header {
|
||||
width: 100%;
|
||||
@@ -843,15 +846,6 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
.voted {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.vote-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.vote-title .js-edit-date {
|
||||
align-self: flex-start;
|
||||
margin-inline-start: 6px;
|
||||
}
|
||||
.vote-result {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
@@ -862,15 +856,6 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
.poker-voted {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.poker-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.poker-title .js-edit-date {
|
||||
align-self: flex-start;
|
||||
margin-inline-start: 6px;
|
||||
}
|
||||
.poker-result {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1435,3 +1420,38 @@ body.desktop-mode .card-details.card-details-position-pending:not(.card-details-
|
||||
font-size: 0.8em;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* A maximized card has its comments at the top of the right column
|
||||
(cardCommentsSection): nothing is above them there to be separated from, so
|
||||
their heading has no rule. */
|
||||
.card-details-right .card-details-comments .card-details-section-rule {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Card kind: the selected one is marked, at the end of its line, and bold. */
|
||||
.card-kind-list .js-select-card-kind.is-active { font-weight: bold; }
|
||||
.card-kind-list .card-kind-selected { margin-inline-start: 0.5em; color: #3cb500; }
|
||||
|
||||
/* ONE SECTION MODEL (2026-10-28): the opened card draws Card Settings'
|
||||
sections (cardDetails.jade `.card-details-section`), each a full-width row
|
||||
of the items, headed by cardSectionHeader with the rule above it. The FIRST
|
||||
section has nothing above it to be separated from, so its rule is not drawn
|
||||
- whichever section the board put first (the template cannot know). */
|
||||
.card-details .card-details-items > .card-details-section {
|
||||
flex: 0 0 100%;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.card-details .card-details-items > .card-details-section:first-child > hr.card-details-section-rule:first-child,
|
||||
.card-details .card-details-items > .card-details-section:first-child > * > hr.card-details-section-rule:first-child,
|
||||
.card-details .card-details-items > .card-details-section:first-child > * > .comment-title > hr.card-details-section-rule:first-child,
|
||||
.card-details .card-details-items > .card-details-section:first-child > .card-details-group-vote-poker > hr:first-child {
|
||||
display: none;
|
||||
}
|
||||
/* Lists that were sections of their own and are a field of one now: a row of
|
||||
their own inside the section. */
|
||||
.card-details .card-details-group-body > .card-details-item-similar-cards,
|
||||
.card-details .card-details-group-body > .card-details-item-buttons,
|
||||
.card-details .card-details-section .card-details-item-text-notes {
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,5 @@
|
||||
import { getSidebarInstance } from '/client/features/sidebar/service';
|
||||
import { filterSubtasksOf } from '/client/lib/filterSubtasks';
|
||||
import { backIfOwnPopup } from '/client/lib/popupBack';
|
||||
import { canEditCardDependenciesHere } from '/client/lib/dependencyLayers';
|
||||
import { relativeCardSort } from '/client/lib/relativeCardPosition';
|
||||
import { Random } from 'meteor/random';
|
||||
@@ -10,6 +11,7 @@ import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { resolveShowCardLabelText } from '/client/lib/minicardLabelText';
|
||||
const { isCardFieldHiddenOnBoard } = require('/models/lib/cardFieldVisibility');
|
||||
import { cardBoardView } from '/client/components/cards/cardAddFields';
|
||||
import { hasUnreadComments } from '/models/lib/unreadComments';
|
||||
import {
|
||||
DEPENDENCY_TYPES,
|
||||
@@ -22,6 +24,7 @@ import { isTextSelectionInsideCard } from '/client/lib/cardCloseGuard';
|
||||
import { placeCardDetailsX, MARGIN } from '/client/lib/cardDetailsPlacement';
|
||||
import { dueFromTitle } from '/client/components/forms/titleDueHint';
|
||||
import { riskOf } from '/client/lib/cardRiskOf';
|
||||
import Rules from '/models/rules';
|
||||
import { suggestedMembersFor } from '/client/lib/suggestedMembers';
|
||||
import '/client/components/cards/similarCards';
|
||||
|
||||
@@ -87,7 +90,7 @@ import { Utils } from '/client/lib/utils';
|
||||
import autosize from 'autosize';
|
||||
import { caretClassFor } from '/client/lib/sectionCaret';
|
||||
import { restoreArchivedCard } from '/client/components/sidebar/sidebarArchives';
|
||||
const { openCardIsUnavailable } = require('/models/lib/openCardPresence');
|
||||
const { openCardIsUnavailable, openCardCloseDecision } = require('/models/lib/openCardPresence');
|
||||
|
||||
// Id of the location currently being edited in the cardLocationsPopup; null
|
||||
// when adding a new location.
|
||||
@@ -354,9 +357,68 @@ function bindCardDetailsResize() {
|
||||
});
|
||||
}
|
||||
|
||||
// #3114 / #6808: close every way the card `cardId` is open - the window,
|
||||
// the popup, the card's address - once it no longer belongs on `boardId`.
|
||||
function closeGoneCard(cardId, boardId) {
|
||||
Session.set(
|
||||
'openCards',
|
||||
(Session.get('openCards') || []).filter(id => id !== cardId),
|
||||
);
|
||||
if (Session.get('currentCard') === cardId) {
|
||||
Session.set('currentCard', null);
|
||||
}
|
||||
if (Session.get('popupCardId') === cardId) {
|
||||
Session.set('popupCardId', null);
|
||||
Session.set('popupCardBoardId', null);
|
||||
Popup.close();
|
||||
}
|
||||
const routeCardId = FlowRouter.current()?.params?.cardId;
|
||||
if (routeCardId === cardId) {
|
||||
Utils.goBoardId(boardId);
|
||||
}
|
||||
}
|
||||
|
||||
// #6808: a card missing from Minimongo is closed only when the server
|
||||
// confirms it is gone, asked once the connection and the login have settled,
|
||||
// and only if Minimongo still agrees when it answers. Kept OUTSIDE the
|
||||
// window's lifecycle on purpose: when a card really is deleted, its window is
|
||||
// destroyed with its data, often before the answer comes back - the close (and
|
||||
// the step back from the card's address) must still happen then. A dropped
|
||||
// connection that comes back answers "available", and nothing is closed.
|
||||
const confirmingGoneCards = new Set();
|
||||
function confirmGoneCard(cardId, boardId) {
|
||||
if (!cardId || !boardId || confirmingGoneCards.has(cardId)) return;
|
||||
confirmingGoneCards.add(cardId);
|
||||
// Not a child of whatever computation calls this, so it outlives it.
|
||||
Tracker.nonreactive(() => Tracker.autorun(computation => {
|
||||
if (!Meteor.status().connected || Meteor.loggingIn()) return;
|
||||
computation.stop();
|
||||
Meteor.callAsync('cards.openCardAvailable', cardId, boardId)
|
||||
.then(available => {
|
||||
confirmingGoneCards.delete(cardId);
|
||||
if (available) return;
|
||||
// Still gone now that the server has answered.
|
||||
if (!openCardIsUnavailable(ReactiveCache.getCard(cardId), boardId)) return;
|
||||
closeGoneCard(cardId, boardId);
|
||||
})
|
||||
.catch(() => {
|
||||
// No answer (the connection dropped again): keep the card; it is
|
||||
// asked again the next time it looks gone.
|
||||
confirmingGoneCards.delete(cardId);
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
Template.cardDetails.onCreated(function () {
|
||||
this.currentBoard = Utils.getCurrentBoard();
|
||||
this.isLoaded = new ReactiveVar(false);
|
||||
// Whether the desktop window has been placed beside its minicard: until
|
||||
// then it carries card-details-position-pending (unpainted). A reactive
|
||||
// class rather than a static one that onRendered removes: Blaze writes a
|
||||
// tag's static classes again whenever its class attribute changes, so the
|
||||
// removed class came back on Maximize / Minimize and Collapse, and the
|
||||
// opened card turned invisible (latin-card-archive.e2e.js).
|
||||
this.positionSettled = new ReactiveVar(false);
|
||||
this.infiniteScrolling = new InfiniteScrolling();
|
||||
const openedCardId = this.data?._id;
|
||||
const openedBoardId = this.data?.boardId;
|
||||
@@ -366,32 +428,24 @@ Template.cardDetails.onCreated(function () {
|
||||
// board. Minimongo then removed the data while the mobile details view kept
|
||||
// rendering an empty shell. Observe the card that created this instance and
|
||||
// close every way it can be open as soon as it no longer belongs here.
|
||||
// #6808: but a card that only LOOKS gone - Minimongo emptied for a moment
|
||||
// while a dropped connection comes back and its subscriptions re-run, as
|
||||
// behind a reverse proxy while the page loads - is not closed: the server
|
||||
// is asked first (models/lib/openCardPresence.js openCardCloseDecision).
|
||||
this.autorun(() => {
|
||||
if (!openedCardId || !openedBoardId) return;
|
||||
// A same-tab card link can render the details before the destination card
|
||||
// publication arrives. Only a ready subscription can prove it is gone.
|
||||
if (!cardSubscription?.ready()) return;
|
||||
const card = ReactiveCache.getCard(openedCardId);
|
||||
if (!openCardIsUnavailable(card, openedBoardId)) return;
|
||||
|
||||
Session.set(
|
||||
'openCards',
|
||||
(Session.get('openCards') || []).filter(id => id !== openedCardId),
|
||||
);
|
||||
if (Session.get('currentCard') === openedCardId) {
|
||||
Session.set('currentCard', null);
|
||||
}
|
||||
if (Session.get('popupCardId') === openedCardId) {
|
||||
Session.set('popupCardId', null);
|
||||
Session.set('popupCardBoardId', null);
|
||||
Popup.close();
|
||||
}
|
||||
|
||||
const routeCardId = FlowRouter.current()?.params?.cardId;
|
||||
if (routeCardId === openedCardId) {
|
||||
Utils.goBoardId(openedBoardId);
|
||||
}
|
||||
const decision = openCardCloseDecision({
|
||||
// A same-tab card link can render the details before the destination
|
||||
// card publication arrives. Only a ready subscription can prove it is gone.
|
||||
subReady: Boolean(cardSubscription?.ready()),
|
||||
connected: Meteor.status().connected,
|
||||
loggingIn: Meteor.loggingIn(),
|
||||
card: ReactiveCache.getCard(openedCardId),
|
||||
openedBoardId,
|
||||
});
|
||||
if (decision === 'confirm') confirmGoneCard(openedCardId, openedBoardId);
|
||||
});
|
||||
this.openedBoardId = openedBoardId;
|
||||
|
||||
// #6686: a sub-popup opened from this card (Labels, Members, ...) is a
|
||||
// global singleton (client/lib/popup.js) that is not tied to this card
|
||||
@@ -488,6 +542,7 @@ Template.cardDetails.onRendered(function () {
|
||||
// #6465: place the window beside its minicard (X only) once it has been laid
|
||||
// out, so the width measured here is the one the stylesheet gave it.
|
||||
const $cardDetails = this.$('.card-details').first();
|
||||
const tpl = this;
|
||||
if ($cardDetails.length) {
|
||||
Tracker.afterFlush(() => {
|
||||
const element = $cardDetails.get(0);
|
||||
@@ -497,6 +552,9 @@ Template.cardDetails.onRendered(function () {
|
||||
// The desktop window must never paint at the stylesheet's fallback
|
||||
// dock before its minicard-relative geometry has been measured.
|
||||
element.classList.remove('card-details-position-pending');
|
||||
// And it stays removed: the class is drawn from this, so a later
|
||||
// change of the window's classes does not bring it back.
|
||||
tpl.positionSettled.set(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -629,6 +687,13 @@ Template.cardDetails.onRendered(function () {
|
||||
});
|
||||
|
||||
Template.cardDetails.onDestroyed(function () {
|
||||
// #6808: the window is destroyed with its data when the card is deleted,
|
||||
// often before the autorun above could ask the server - ask now. A card that
|
||||
// merely left for a moment (a reconnect) comes back and answers "available".
|
||||
if (this.openedCardId && this.openedBoardId
|
||||
&& openCardIsUnavailable(ReactiveCache.getCard(this.openedCardId), this.openedBoardId)) {
|
||||
confirmGoneCard(this.openedCardId, this.openedBoardId);
|
||||
}
|
||||
const boardBody = getBoardBodyInstance();
|
||||
if (boardBody !== null) {
|
||||
boardBody.showOverlay.set(false);
|
||||
@@ -636,9 +701,12 @@ Template.cardDetails.onDestroyed(function () {
|
||||
|
||||
// #6686: see the comment in onCreated above. Only close a popup that is
|
||||
// still showing THIS card's data at the base of its stack - a popup opened
|
||||
// for an unrelated card/context must not be touched.
|
||||
// for an unrelated card/context must not be touched. #6840: and only when
|
||||
// this card is no longer the open one - closed, or another card opened. A
|
||||
// card whose details are merely rendered again is still open, and its popup
|
||||
// (the Due date one `d` just opened) must not flash shut.
|
||||
const openedCardId = this.openedCardId;
|
||||
if (openedCardId && Popup.isOpen()) {
|
||||
if (openedCardId && Popup.isOpen() && Utils.getCurrentCardId() !== openedCardId) {
|
||||
const baseStackEntry = Popup._stack?.[0];
|
||||
if (baseStackEntry?.dataContext?._id === openedCardId) {
|
||||
Popup.close();
|
||||
@@ -690,8 +758,14 @@ Template.registerHelper('cardSwimlaneTitle', function cardSwimlaneTitle() {
|
||||
return swimlane && swimlane.title;
|
||||
});
|
||||
|
||||
// The Custom Fields section: on every card the reader may change whose board
|
||||
// shows Custom Fields on cards, empty or not (docs/Features/Cards/Card-Fields.md)
|
||||
// - its hamburger is where the fields are added. The board as its cards see it,
|
||||
// so a board that hides the whole Custom Fields section on Show on Card
|
||||
// (models/lib/cardShownFields.js boardForCard) hides it here too.
|
||||
Template.registerHelper('canShowCustomFieldsOnCard', function canShowCustomFieldsOnCard() {
|
||||
const board = this?.board?.();
|
||||
const { boardForCard } = require('/models/lib/cardShownFields');
|
||||
const board = boardForCard(this?.board?.());
|
||||
return Utils.canModifyCard(this) && board?.allowsCustomFields !== false;
|
||||
});
|
||||
|
||||
@@ -822,32 +896,122 @@ Template.registerHelper('isCurrentListId', function isCurrentListId(listId) {
|
||||
return data.listId == listId;
|
||||
});
|
||||
|
||||
// #4448: the fields INSIDE a reorderable section (the four dates, the people
|
||||
// of Members, ...) render in the board's order too. One argument-less helper
|
||||
// per section, registered globally like isDateFormat above because each
|
||||
// section is its own template. The card is the data context (`each field in`
|
||||
// keeps it), so `this.board()` is the board. models/lib/cardFieldOrder.js
|
||||
// The opened card draws Card Settings' sections (models/lib/cardSettingsSections.js)
|
||||
// in the board's Show on Card section order, and the fields INSIDE each in the
|
||||
// order Card Settings lists that section's rows (board.cardFieldOrder through
|
||||
// models/lib/cardFieldOrder.js and cardSettingsSections.sectionFieldsOn) - one
|
||||
// order, so the card and Card Settings cannot disagree. One argument-less
|
||||
// helper per section, registered globally because each section is its own
|
||||
// template. The card is the data context (`each field in` keeps it), so
|
||||
// `this.board()` is the board.
|
||||
const cardSettingsSections = require('/models/lib/cardSettingsSections');
|
||||
const { applyCardOrder } = require('/models/lib/cardFieldOrder');
|
||||
|
||||
function cardSectionFields(card, sectionId) {
|
||||
const board = card?.board?.();
|
||||
return cardSettingsSections.sectionFieldsOn('card', applyCardOrder(board?.cardFieldOrder), sectionId);
|
||||
}
|
||||
{
|
||||
const { orderedCardFieldsOf } = require('/models/lib/cardFieldOrder');
|
||||
const { boardAllows, boardForCard } = require('/models/lib/cardShownFields');
|
||||
const sectionHelper = section => function orderedFields() {
|
||||
const board = this?.board?.();
|
||||
return orderedCardFieldsOf(board?.cardFieldOrder, section);
|
||||
return cardSectionFields(this, section);
|
||||
};
|
||||
Template.registerHelper('orderedLabelsFields', sectionHelper('labels'));
|
||||
Template.registerHelper('orderedDatesFields', sectionHelper('dates'));
|
||||
Template.registerHelper('orderedDateFields', sectionHelper('date'));
|
||||
Template.registerHelper('orderedMembersFields', sectionHelper('members'));
|
||||
Template.registerHelper('orderedSortFields', sectionHelper('sort'));
|
||||
Template.registerHelper('orderedVoteAndPokerFields', sectionHelper('voteAndPoker'));
|
||||
Template.registerHelper('orderedCardFields', sectionHelper('card'));
|
||||
Template.registerHelper('orderedTimeFields', sectionHelper('time'));
|
||||
Template.registerHelper('orderedChecklistsFields', sectionHelper('checklists'));
|
||||
Template.registerHelper('orderedAttachmentsFields', sectionHelper('attachments'));
|
||||
// Only the Scrum fields the board shows on cards (Board Settings / Scrum,
|
||||
// #6830): a Scrum field has no board flag in the template to read.
|
||||
Template.registerHelper('orderedScrumFields', function orderedScrumFields() {
|
||||
const board = boardForCard(this?.board?.());
|
||||
return cardSectionFields(this, 'scrum').filter(key => boardAllows(board, key));
|
||||
});
|
||||
}
|
||||
|
||||
Template.cardFieldSectionSort.helpers({
|
||||
hasVisibleSortFields() {
|
||||
const board = this.board();
|
||||
return !!(board && (
|
||||
board.allowsCardSortingByNumber || board.allowsShowLists ||
|
||||
board.allowsFlowtime || board.allowsPomodoro ||
|
||||
(board.allowsSpentTime && this.getSpentTime())
|
||||
));
|
||||
// Whether the card has anything to show in section `id`, read through the
|
||||
// board as its cards see it (cardShownFields.js boardForCard, which already
|
||||
// takes away the rows of a section hidden whole on Show on Card): a section
|
||||
// with nothing in it draws no heading. The same gates its template had before
|
||||
// the card used Card Settings' sections (docs/Features/Cards/Card-Fields.md).
|
||||
function cardSectionHasContent(card, board, id) {
|
||||
const { boardAllows } = require('/models/lib/cardShownFields');
|
||||
if (!board) return false;
|
||||
const may = Utils.canModifyCard(card);
|
||||
switch (id) {
|
||||
case 'labels': return Boolean(board.allowsLabels || board.allowsStickers || board.allowsLocation);
|
||||
case 'date':
|
||||
return Boolean(board.allowsReceivedDate || board.allowsStartDate || board.allowsDueDate || board.allowsEndDate
|
||||
|| (board.allowsAtRiskOnCard !== false && riskOf(card, { withMoves: true })));
|
||||
case 'members':
|
||||
return Boolean(board.allowsMembers || board.allowsAssignee || board.allowsCreator
|
||||
|| board.allowsRequestedBy || board.allowsAssignedBy);
|
||||
case 'card': {
|
||||
const swimlane = typeof card?.swimlane === 'function' ? card.swimlane() : null;
|
||||
return Boolean(board.allowsDependencies || board.allowsCardSortingByNumber || board.allowsShowLists
|
||||
|| (board.allowsSwimlaneNameOnCard && swimlane && swimlane.title)
|
||||
|| (board.allowsCardButtonsOnCard !== false && cardHasButtons(card)));
|
||||
}
|
||||
case 'time': return Boolean(board.allowsSpentTime || board.allowsFlowtime || board.allowsPomodoro);
|
||||
case 'customFields': return may && board.allowsCustomFields !== false;
|
||||
case 'vote': return Boolean(board.allowsVote);
|
||||
case 'poker': return Boolean(board.allowsPoker);
|
||||
case 'description': {
|
||||
const shows = board.allowsDescriptionTitle || board.allowsDescriptionText;
|
||||
const writer = may && !ReactiveCache.getCurrentUser()?.isWorker?.();
|
||||
return Boolean(shows && (writer || (card && card.getDescription && card.getDescription())));
|
||||
}
|
||||
case 'scrum': return cardSectionFields(card, 'scrum').some(key => boardAllows(board, key));
|
||||
case 'checklists': return Boolean(board.allowsChecklists);
|
||||
case 'subtasks': return Boolean(board.allowsSubtasks);
|
||||
case 'attachments': return Boolean(board.allowsAttachments || board.allowsTextNotes);
|
||||
case 'comments': return Boolean(board.allowsComments);
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Whether the board has card buttons (rules) - what the cardButtons template
|
||||
// draws (client/components/rules/cardButtons.js buttonRulesForBoard); without
|
||||
// any, Card is not headed for them alone.
|
||||
function cardHasButtons(card) {
|
||||
return Boolean(card && card.boardId && Rules.find({ boardId: card.boardId, buttonType: 'card' }).count());
|
||||
}
|
||||
|
||||
// The card's sections, in the board's Show on Card section order
|
||||
// (cardSettingsSections.sectionOrderOf), less those hidden whole on Show on
|
||||
// Card and those with nothing to show for this card - and less Comments on a
|
||||
// maximized card, which has them at the top of the right column.
|
||||
function orderedSectionsOf(card, { maximized = false } = {}) {
|
||||
const { boardForCard } = require('/models/lib/cardShownFields');
|
||||
const raw = card?.board?.();
|
||||
const board = boardForCard(raw);
|
||||
const hidden = cardSettingsSections.hiddenSectionsOf(raw, 'card');
|
||||
return cardSettingsSections.sectionOrderOf(raw, 'card')
|
||||
.filter(id => !hidden.includes(id))
|
||||
.filter(id => !(maximized && id === 'comments'))
|
||||
.filter(id => cardSectionHasContent(card, board, id));
|
||||
}
|
||||
|
||||
function isCardMaximized() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const maximized = currentUser
|
||||
? currentUser.hasCardMaximized()
|
||||
: window.localStorage.getItem('cardMaximized') === 'true';
|
||||
return !Utils.getPopupCardId() && maximized;
|
||||
}
|
||||
|
||||
// What the Date and Checklists sections read that used to be the opened
|
||||
// card's own helpers.
|
||||
Template.cardFieldSectionDate.helpers({
|
||||
cardAtRisk() {
|
||||
return riskOf(Template.currentData(), { withMoves: true });
|
||||
},
|
||||
});
|
||||
Template.cardFieldSectionChecklists.helpers({
|
||||
checklistSectionOpen() {
|
||||
return ReactiveCache.getBoard(this.boardId)?.allowsChecklistTitle === false || isCardSectionOpen('checklists');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -859,54 +1023,54 @@ Template.cardDetails.helpers({
|
||||
? Meteor.connection._lastSessionId
|
||||
: null;
|
||||
},
|
||||
// #4448: the order the reorderable card-detail sections (Labels, Dates,
|
||||
// Members, Dependencies, Sort, Custom Fields, Vote/Poker, Description)
|
||||
// render in, resolved from the board's stored setting - the section order
|
||||
// of the flat field order Board Settings / Card writes.
|
||||
// models/lib/cardFieldOrder.js
|
||||
// The sections the card draws, in Card Settings' Show on Card section order
|
||||
// (orderedSectionsOf above): the ids of models/lib/cardSettingsSections.js.
|
||||
orderedCardFieldSections() {
|
||||
const board = this?.board?.();
|
||||
if (board && typeof board.getCardFieldOrder === 'function') {
|
||||
return board.getCardFieldOrder();
|
||||
}
|
||||
const { applyCardFieldOrder } = require('/models/lib/cardFieldOrder');
|
||||
return applyCardFieldOrder(board?.cardFieldOrder);
|
||||
return orderedSectionsOf(this, { maximized: isCardMaximized() });
|
||||
},
|
||||
|
||||
// Placed beside its minicard: card-details-position-pending is off for good.
|
||||
positionSettled() {
|
||||
const tpl = Template.instance();
|
||||
return Boolean(tpl && tpl.positionSettled && tpl.positionSettled.get());
|
||||
},
|
||||
|
||||
cardMaximized() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const maximized = currentUser
|
||||
? currentUser.hasCardMaximized()
|
||||
: window.localStorage.getItem('cardMaximized') === 'true';
|
||||
return !Utils.getPopupCardId() && maximized;
|
||||
return isCardMaximized();
|
||||
},
|
||||
|
||||
// List title, Show on Card: the card's own choice, unless the
|
||||
// administrator unticked List title in Admin Panel / Settings / Visibility /
|
||||
// Features (models/lib/cardFieldVisibility.js). The card keeps its choice.
|
||||
// Its section (Card) hidden whole on Show on Card hides it as well.
|
||||
showListOnCard() {
|
||||
const board = this?.board?.();
|
||||
return Boolean(this?.showListOnCard) && !isCardFieldHiddenOnBoard(board, 'listTitle');
|
||||
const { hidesRow } = require('/models/lib/cardShownFields');
|
||||
return Boolean(this?.showListOnCard) && !isCardFieldHiddenOnBoard(board, 'listTitle')
|
||||
&& !hidesRow(board, 'card', 'listTitle');
|
||||
},
|
||||
// The header's own flags, read through the board as its cards see it
|
||||
// (cardBoardView), so a Card Settings section hidden whole on Show on Card
|
||||
// takes them away too.
|
||||
// Card color, Show on Card: the header's color, on unless turned off.
|
||||
showCardColorOnCard() {
|
||||
return Utils.getCurrentBoard()?.allowsCardColorOnCard !== false;
|
||||
return cardBoardView(this)?.allowsCardColorOnCard !== false;
|
||||
},
|
||||
// Collapse, Show on Card: the header's collapse caret, on unless turned off.
|
||||
allowsCardCollapse() {
|
||||
return Utils.getCurrentBoard()?.allowsCardCollapse !== false;
|
||||
return cardBoardView(this)?.allowsCardCollapse !== false;
|
||||
},
|
||||
// Labels title, Show on Card: the labels above the title, as on a minicard.
|
||||
showLabelsAboveTitleOnCard() {
|
||||
return Utils.getCurrentBoard()?.labelsAboveTitleOnCard === true;
|
||||
return cardBoardView(this)?.labelsAboveTitleOnCard === true;
|
||||
},
|
||||
// ...as long as the board shows labels at all.
|
||||
labelsShownAboveTitle() {
|
||||
return Utils.getCurrentBoard()?.allowsLabels !== false;
|
||||
return cardBoardView(this)?.allowsLabels !== false;
|
||||
},
|
||||
cardCollapsed() {
|
||||
// A board that turned the caret off has no collapsed cards.
|
||||
if (Utils.getCurrentBoard()?.allowsCardCollapse === false) return false;
|
||||
if (cardBoardView(this)?.allowsCardCollapse === false) return false;
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (user && user.profile) {
|
||||
return !!user.profile.cardCollapsed;
|
||||
@@ -948,10 +1112,6 @@ Template.cardDetails.helpers({
|
||||
return user && user.isVerticalScrollbars();
|
||||
},
|
||||
|
||||
checklistSectionOpen() {
|
||||
return ReactiveCache.getBoard(this.boardId)?.allowsChecklistTitle === false || isCardSectionOpen('checklists');
|
||||
},
|
||||
|
||||
isLoaded() {
|
||||
return Template.instance().isLoaded;
|
||||
},
|
||||
@@ -959,9 +1119,9 @@ Template.cardDetails.helpers({
|
||||
|
||||
// WHICH SECTIONS OF A CARD ARE OPEN.
|
||||
//
|
||||
// Every section of an opened card - Labels, Date Format, Members, Dependencies,
|
||||
// Sort, Custom Fields, Description, Checklists, Subtasks, Attachments, Comments
|
||||
// and Activities - carries a caret that collapses it, and one rule above it.
|
||||
// Every section of an opened card - Card Settings' sections, Labels, Date,
|
||||
// Members, Card, Time, Custom Fields, ..., Comments, and Activities - carries a
|
||||
// caret that collapses it, and one rule above it.
|
||||
// Before this there was one caret for the whole card and an EYE on Activities
|
||||
// that showed and hid exactly what a caret would: two controls for one idea,
|
||||
// able to disagree with each other.
|
||||
@@ -972,9 +1132,11 @@ Template.cardDetails.helpers({
|
||||
//
|
||||
// ACTIVITIES starts CLOSED and everything else open. A card is opened to read
|
||||
// the card; its history is the thing you go looking for.
|
||||
// The sections are Card Settings' (models/lib/cardSettingsSections.js ids,
|
||||
// 2026-10-28), plus the right column's Activities and the Similar cards list
|
||||
// inside Card.
|
||||
const CARD_SECTIONS = [
|
||||
'labels', 'date-format', 'members', 'dependencies', 'sort', 'custom-fields',
|
||||
'description', 'checklists', 'subtasks', 'attachments', 'comments', 'activities',
|
||||
...require('/models/lib/cardSettingsSections').SECTION_IDS, 'activities', 'similar-cards',
|
||||
];
|
||||
const COLLAPSED_BY_DEFAULT = ['activities'];
|
||||
|
||||
@@ -1011,10 +1173,18 @@ Template.cardSectionHeader.events({
|
||||
// A hamburger at the end of a heading opens that section's settings. Neither
|
||||
// may reach the heading itself: a click there folds the section, so opening
|
||||
// the menu would close what you opened it from.
|
||||
'click .js-open-subtasks-settings'(event) {
|
||||
// Subtasks: Filter: Subtasks, and the subtask settings
|
||||
// (docs/Features/Cards/Card-Fields.md).
|
||||
'click .js-open-subtasks-menu'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
Popup.open('boardSubtaskSettings')(event);
|
||||
Popup.open('cardSubtasksMenu').call(Utils.getCurrentCard(), event);
|
||||
},
|
||||
// Scrum settings: Card kind - a Story or a Task (#2012).
|
||||
'click .js-section-card-kind'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
Popup.open('cardKind').call(Utils.getCurrentCard(), event);
|
||||
},
|
||||
// Every custom field the board has, ticked when it is on this card, with a
|
||||
// pencil each and "Add custom field" under a rule. The card is its data
|
||||
@@ -1120,17 +1290,18 @@ Template.cardDetails.events({
|
||||
// selects text instead of moving the card window.
|
||||
return;
|
||||
}
|
||||
// ...and don't drag from the half that EDITS. The title is split like a
|
||||
// minicard's: the leading half opens the editor, the trailing half is what
|
||||
// you take hold of. With drag handles ON the zone covers the whole title
|
||||
// (cardDetails.css), so the window is dragged by its handle alone - which is
|
||||
// what turning handles on means.
|
||||
// #6839: ...and never from the title, which edits - all of it
|
||||
// (cardDetails.css). The window is moved from the empty space of the
|
||||
// header row above it, beside the collapse caret and the menu.
|
||||
if ($(event.target).closest('.card-details-title-edit-zone').length > 0) {
|
||||
return;
|
||||
}
|
||||
// The drag handle has its own handler; without this both would run and the
|
||||
// window would be moved twice by one press.
|
||||
if ($(event.target).closest('.js-card-drag-handle').length > 0) {
|
||||
// window would be moved twice by one press. And when there IS a handle -
|
||||
// drag handles on - it is the only thing that moves the window, which is
|
||||
// what turning handles on means.
|
||||
if ($(event.target).closest('.js-card-drag-handle').length > 0
|
||||
|| $(event.target).closest('.card-details').find('.js-card-drag-handle').length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1767,6 +1938,13 @@ Template.inlinedCardDescription.events({
|
||||
});
|
||||
|
||||
Template.cardDetailsActionsPopup.helpers({
|
||||
// The moving and copying items: for who may change the card, and - as they
|
||||
// always were - for a member who is not read-only.
|
||||
cardMenuWrites() {
|
||||
if (Utils.canModifyCard(this)) return true;
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
return Boolean(user && !(user.isReadOnly && user.isReadOnly()) && !(user.isReadAssignedOnly && user.isReadAssignedOnly()));
|
||||
},
|
||||
isWatching() {
|
||||
if (!this || typeof this.findWatcher !== 'function') return false;
|
||||
return this.findWatcher(Meteor.userId());
|
||||
@@ -1788,22 +1966,29 @@ Template.cardDetailsActionsPopup.helpers({
|
||||
},
|
||||
});
|
||||
|
||||
Template.cardDetailsActionsPopup.events({
|
||||
// The section hamburgers of the opened card (docs/Features/Cards/Card-Fields.md):
|
||||
// what the card's actions menu had, where the section is.
|
||||
Template.cardSubtasksMenuPopup.events({
|
||||
'click .js-filter-subtasks'(event) {
|
||||
event.preventDefault();
|
||||
const card = this.card || this;
|
||||
if (!card?._id || card.boardId !== Session.get('currentBoard')) return;
|
||||
Filter.parentId.reset();
|
||||
Filter.parentId.add(card._id);
|
||||
Filter.resetExceptions();
|
||||
Session.set('openCards', (Session.get('openCards') || []).filter(id => id !== card._id));
|
||||
if (Session.get('currentCard') === card._id) Session.set('currentCard', null);
|
||||
Session.set('popupCardId', null);
|
||||
Session.set('popupCardBoardId', null);
|
||||
Popup.close();
|
||||
Utils.goBoardId(card.boardId);
|
||||
getSidebarInstance().setView('filter');
|
||||
filterSubtasksOf(this.card || this);
|
||||
},
|
||||
'click .js-open-subtask-settings'(event) {
|
||||
event.preventDefault();
|
||||
Popup.open('boardSubtaskSettings')(event);
|
||||
},
|
||||
});
|
||||
Template.cardFieldSectionVote.events({
|
||||
'click .js-section-edit-voting': Popup.open('cardStartVoting'),
|
||||
});
|
||||
Template.cardFieldSectionPoker.events({
|
||||
'click .js-section-edit-poker': Popup.open('cardStartPlanningPoker'),
|
||||
});
|
||||
Template.cardSpentTimeMenu.events({
|
||||
'click .js-section-spent-time': Popup.open('editCardSpentTime'),
|
||||
});
|
||||
|
||||
Template.cardDetailsActionsPopup.events({
|
||||
// docs/Features/Cards/Action-Items.md: the description's action items.
|
||||
'click .js-description-action-items': Popup.open('actionItems', { titleKey: 'action-items' }),
|
||||
// History.md §7a: adding History to a menu is a menu item and this handler,
|
||||
@@ -1856,13 +2041,10 @@ Template.cardDetailsActionsPopup.events({
|
||||
'click .js-members': Popup.open('cardMembers'),
|
||||
'click .js-assignees': Popup.open('cardAssignees'),
|
||||
'click .js-attachments': Popup.open('cardAttachments'),
|
||||
'click .js-start-voting': Popup.open('cardStartVoting'),
|
||||
'click .js-start-planning-poker': Popup.open('cardStartPlanningPoker'),
|
||||
'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'),
|
||||
'click .js-spent-time': Popup.open('editCardSpentTime'),
|
||||
'click .js-move-card': Popup.open('moveCard'),
|
||||
'click .js-copy-card': Popup.open('copyCard'),
|
||||
// #2209: "Create template from element" - save THIS card as a card template
|
||||
@@ -1887,7 +2069,6 @@ Template.cardDetailsActionsPopup.events({
|
||||
'click .js-convert-checklist-item-to-card': Popup.open('convertChecklistItemToCard'),
|
||||
'click .js-copy-checklist-cards': Popup.open('copyManyCards'),
|
||||
'click .js-set-card-color': Popup.open('setCardColor'),
|
||||
'click .js-set-card-kind': Popup.open('cardKind'),
|
||||
'click .js-card-drawing': Popup.open('cardDrawing'),
|
||||
async 'click .js-move-card-to-top'(event) {
|
||||
event.preventDefault();
|
||||
@@ -3020,7 +3201,7 @@ Template.cardStartVotingPopup.events({
|
||||
if (endString) {
|
||||
Meteor.call('cards.setVoteEnd', card.getRealId(), new Date(endString));
|
||||
}
|
||||
Popup.back();
|
||||
backIfOwnPopup(evt, 'cardStartVotingPopup');
|
||||
},
|
||||
'click .js-remove-vote': Popup.afterConfirm('deleteVote', function () {
|
||||
const card = Cards.findOne(getCardId());
|
||||
@@ -3124,7 +3305,7 @@ Template.cardStartPlanningPokerPopup.events({
|
||||
if (endString) {
|
||||
Meteor.call('cards.setPokerEnd', card.getRealId(), new Date(endString));
|
||||
}
|
||||
Popup.back();
|
||||
backIfOwnPopup(evt, 'cardStartPlanningPokerPopup');
|
||||
},
|
||||
'click .js-remove-poker': Popup.afterConfirm('deletePoker', function () {
|
||||
const card = Cards.findOne(getCardId());
|
||||
@@ -3439,6 +3620,6 @@ Template.cardKindPopup.events({
|
||||
if (card && typeof card.setCardKind === 'function') {
|
||||
try { await card.setCardKind(event.currentTarget.dataset.kind || null); } catch (error) { console.error(error); }
|
||||
}
|
||||
Popup.back();
|
||||
backIfOwnPopup(event, 'cardKindPopup');
|
||||
},
|
||||
});
|
||||
@@ -1,8 +1,47 @@
|
||||
/* #1178: drawing on a card. */
|
||||
.card-drawing { min-width: min(820px, 90vw); }
|
||||
.card-drawing-surface { border: 1px solid #ccc; touch-action: none; cursor: crosshair; background: #fff; display: block; }
|
||||
.card-drawing-tools, .card-drawing-actions, .card-drawing-list { display: flex; flex-wrap: wrap; gap: 0.4em; align-items: center; margin: 0.4em 0; }
|
||||
/* #6802: the popup is a column - tools, then the drawing area taking the room
|
||||
that is left: the whole window when maximized. */
|
||||
.card-drawing { min-width: min(820px, 90vw); display: flex; flex-direction: column; height: min(75vh, 720px); }
|
||||
.pop-over--maximized .card-drawing { height: calc(100vh - 6.5em); }
|
||||
/* An outline, not a border: the surface's box is exactly the drawing, so a
|
||||
pointer position maps to it without an offset. */
|
||||
.card-drawing-surface { outline: 1px solid #ccc; touch-action: none; cursor: crosshair; background: #fff; display: block; }
|
||||
.card-drawing-tools, .card-drawing-list { display: flex; flex-wrap: wrap; gap: 0.4em; align-items: center; margin: 0.4em 0; }
|
||||
/* Save at the end of the tools, above the drawing. */
|
||||
.card-drawing-actions { display: flex; flex-wrap: wrap; gap: 0.6em; align-items: center; margin-inline-start: auto; }
|
||||
.card-drawing-actions .js-drawing-save { margin: 0; }
|
||||
.card-drawing-color { width: 1.4em; height: 1.4em; border-radius: 50%; border: 2px solid #999; display: inline-block; }
|
||||
.card-drawing-color--custom { border-style: dashed; }
|
||||
.card-drawing-color.is-active, .card-drawing-width.is-active, .card-drawing-thumb.is-active { outline: 2px solid #2b6cb0; }
|
||||
.card-drawing-width { padding: 0 0.4em; border: 1px solid #ccc; border-radius: 3px; }
|
||||
.card-drawing-thumb svg { width: 6em; height: auto; border: 1px solid #ddd; display: block; }
|
||||
/* #6802: the eraser, and the surface while it is on. */
|
||||
.card-drawing-eraser { padding: 0 0.3em; }
|
||||
.card-drawing-eraser.is-active { outline: 2px solid #2b6cb0; border-radius: 3px; }
|
||||
.card-drawing-surface.is-erasing { cursor: cell; }
|
||||
/* #6802: the lasso, its loop, the selection and its resize handle. */
|
||||
.card-drawing-lasso { padding: 0 0.3em; }
|
||||
.card-drawing-lasso.is-active { outline: 2px solid #2b6cb0; border-radius: 3px; }
|
||||
.card-drawing-surface.is-lasso { cursor: default; }
|
||||
.card-drawing-selection-tools { display: flex; flex-wrap: wrap; gap: 0.6em; align-items: center; margin: 0.2em 0 0.4em; }
|
||||
.card-drawing-lasso-loop { fill: rgba(43, 108, 176, 0.08); stroke: #2b6cb0; stroke-width: 1; stroke-dasharray: 4 3; pointer-events: none; }
|
||||
.card-drawing-selection { fill: none; stroke: #2b6cb0; stroke-width: 1; stroke-dasharray: 5 3; cursor: move; pointer-events: none; }
|
||||
.card-drawing-resize-handle { fill: #ffffff; stroke: #2b6cb0; stroke-width: 2; cursor: nwse-resize; }
|
||||
/* #6802: colors - the custom-color button, the eyedropper, the wheel. */
|
||||
.card-drawing-add-custom, .card-drawing-eyedropper { padding: 0 0.3em; }
|
||||
.card-drawing-eyedropper.is-active { outline: 2px solid #2b6cb0; border-radius: 3px; }
|
||||
.card-drawing-surface.is-picking { cursor: copy; }
|
||||
.card-drawing-color-editor { display: flex; flex-wrap: wrap; gap: 1em; align-items: center; margin: 0.2em 0 0.4em; }
|
||||
.card-drawing-wheel { width: 160px; height: 160px; border-radius: 50%; touch-action: none; cursor: crosshair; }
|
||||
.card-drawing-color-fields { display: flex; flex-direction: column; gap: 0.4em; align-items: flex-start; }
|
||||
.card-drawing-color-fields label { display: flex; flex-direction: column; gap: 0.2em; }
|
||||
.card-drawing-color-fields .js-drawing-hex { width: 7em; font-family: monospace; }
|
||||
.card-drawing-color-preview { width: 3em; height: 1.4em; border: 1px solid #999; border-radius: 3px; display: inline-block; }
|
||||
/* #6802: the drawing area - it scrolls when zoomed past the room it has, and
|
||||
grows from the handle at its bottom-right corner. */
|
||||
.card-drawing-zoom { min-width: 3em; text-align: center; }
|
||||
.card-drawing-stage { flex: 1 1 auto; min-height: 0; overflow: auto; position: relative; padding-inline-end: 10px; padding-block-end: 10px; box-sizing: border-box; }
|
||||
.card-drawing-canvas { position: relative; }
|
||||
/* At the inline end: bottom-right, or bottom-left in a right-to-left language. */
|
||||
.card-drawing-canvas-resize { position: absolute; inset-inline-end: -6px; bottom: -6px; width: 14px; height: 14px; background: #fff; border: 2px solid #2b6cb0; border-radius: 2px; cursor: nwse-resize; touch-action: none; }
|
||||
.card-drawing-canvas-resize:focus-visible { outline: 2px solid #2b6cb0; outline-offset: 2px; }
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { Random } from 'meteor/random';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
@@ -19,20 +20,90 @@ function pathD(points) {
|
||||
|
||||
// The drawing as a PNG file, the size it is drawn at - for the popup's Save
|
||||
// and the whiteboard's Save picture (docs/Features/Cards/Whiteboard.md).
|
||||
export async function drawingPngFile(target, strokes) {
|
||||
const svg = d.strokesToSvg(strokes, { width: W, height: H });
|
||||
export async function drawingPngFile(target, strokes, { width = W, height = H } = {}) {
|
||||
const svg = d.strokesToSvg(strokes, { width, height });
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
|
||||
await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; img.src = url; });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.getContext('2d').drawImage(img, 0, 0);
|
||||
URL.revokeObjectURL(url);
|
||||
const png = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
|
||||
return new File([png], `${(target.title || 'drawing').slice(0, 60)}.png`, { type: 'image/png' });
|
||||
}
|
||||
|
||||
// #6802: the drawing as an SVG file, for a new drawing from Add Card or Add
|
||||
// attachment. The server sanitizes every SVG it is given (models/lib/sanitizeSvg.js);
|
||||
// this one is lines only.
|
||||
export function drawingSvgFile(target, strokes, { width = W, height = H } = {}) {
|
||||
const svg = d.strokesToSvg(strokes, { width, height });
|
||||
return new File([svg], `${(target.title || 'drawing').slice(0, 60)}.svg`, { type: 'image/svg+xml' });
|
||||
}
|
||||
|
||||
// #6802: the custom colors beside the palette. A signed-in user keeps them
|
||||
// with the account (profile.drawingColors, the setDrawingColors method), so
|
||||
// they follow the user to every browser; someone drawing on a public board
|
||||
// without signing in keeps them in this browser.
|
||||
const CUSTOM_COLORS_KEY = 'wekan-drawing-custom-colors';
|
||||
function loadBrowserColors() {
|
||||
try { return d.addCustomColor(JSON.parse(window.localStorage.getItem(CUSTOM_COLORS_KEY) || '[]'), null); } catch (e) { return []; }
|
||||
}
|
||||
|
||||
// Reactive: the account's colors, or this browser's.
|
||||
function customColorsOf(tpl) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (user) return d.addCustomColor((user.profile && user.profile.drawingColors) || [], null);
|
||||
return tpl.customColors.get();
|
||||
}
|
||||
|
||||
function saveCustomColors(tpl, list) {
|
||||
if (Meteor.userId()) {
|
||||
Meteor.callAsync('setDrawingColors', list).catch(error => {
|
||||
tpl.message.set((error && (error.reason || error.message)) || 'error');
|
||||
});
|
||||
return;
|
||||
}
|
||||
tpl.customColors.set(list);
|
||||
try { window.localStorage.setItem(CUSTOM_COLORS_KEY, JSON.stringify(list)); } catch (e) { /* a preference */ }
|
||||
}
|
||||
|
||||
const WHEEL = 160;
|
||||
|
||||
// The color wheel at brightness `v`, with a ring where `hex` sits on it.
|
||||
function paintWheel(canvas, v, hex) {
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const r = WHEEL / 2;
|
||||
const image = ctx.createImageData(WHEEL, WHEEL);
|
||||
for (let y = 0; y < WHEEL; y += 1) {
|
||||
for (let x = 0; x < WHEEL; x += 1) {
|
||||
const i = (y * WHEEL + x) * 4;
|
||||
if (Math.hypot(x - r, y - r) > r) { image.data[i + 3] = 0; continue; }
|
||||
const c = d.wheelColor(x, y, r, v);
|
||||
image.data[i] = parseInt(c.slice(1, 3), 16);
|
||||
image.data[i + 1] = parseInt(c.slice(3, 5), 16);
|
||||
image.data[i + 2] = parseInt(c.slice(5, 7), 16);
|
||||
image.data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(image, 0, 0);
|
||||
const at = d.wheelPoint(hex, r);
|
||||
if (at) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(at.x, at.y, 6, 0, Math.PI * 2);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = v > 0.5 ? '#000000' : '#ffffff';
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// A new drawing is the card's cover image (docs/Features/Cards/Drawing.md).
|
||||
function coverOf(attachmentId) {
|
||||
return attachmentId ? { coverId: attachmentId } : {};
|
||||
}
|
||||
|
||||
// Upload a drawing's PNG as a card attachment; resolves with its id.
|
||||
export async function uploadDrawingPng(target, file) {
|
||||
const uploader = await Attachments.insertAsync(buildAttachmentUploadConfig({ file, meta: Utils.getCommonAttachmentMetaFrom(target) }), false);
|
||||
@@ -51,24 +122,166 @@ Template.cardDrawingPopup.onCreated(function () {
|
||||
this.editingId = new ReactiveVar(null);
|
||||
this.saving = new ReactiveVar(false);
|
||||
this.message = new ReactiveVar('');
|
||||
this.erasing = new ReactiveVar(false);
|
||||
// #6802, the lasso: a loop selects lines; the selection moves, resizes,
|
||||
// groups and changes layer (models/lib/cardDrawing.js).
|
||||
this.lasso = new ReactiveVar(false);
|
||||
this.loop = new ReactiveVar(null);
|
||||
this.selection = new ReactiveVar([]);
|
||||
this.drag = null;
|
||||
// Undo steps back through every change - a line, an erase, a move - and,
|
||||
// with nothing left to step back to, takes away the last line as before.
|
||||
this.history = [];
|
||||
// Palm rejection (models/lib/cardDrawing.js pointerDraws).
|
||||
this.pointer = { penSeen: false, activePointerId: null };
|
||||
// #6802: the drawing's own size, in drawing units, and how large it is shown.
|
||||
this.size = new ReactiveVar({ width: W, height: H });
|
||||
this.zoom = new ReactiveVar(1);
|
||||
// Colors: the custom ones, the wheel and code editor, the eyedropper.
|
||||
this.customColors = new ReactiveVar(loadBrowserColors());
|
||||
this.colorEditor = new ReactiveVar(false);
|
||||
this.brightness = new ReactiveVar(1);
|
||||
this.picking = new ReactiveVar(false);
|
||||
});
|
||||
|
||||
// The room the drawing area has on the screen.
|
||||
function stageRoom(tpl) {
|
||||
const stage = tpl.find('.js-drawing-stage');
|
||||
if (!stage) return null;
|
||||
// Less its padding (room for the corner handle) and the surface's border.
|
||||
return { width: Math.max(0, stage.clientWidth - 14), height: Math.max(0, stage.clientHeight - 14) };
|
||||
}
|
||||
|
||||
// A NEW drawing takes the room there is; an existing one keeps its size and
|
||||
// is zoomed to fit.
|
||||
function fitToStage(tpl, size) {
|
||||
const room = stageRoom(tpl);
|
||||
if (!room || room.width < 50 || room.height < 50) return;
|
||||
if (!size) {
|
||||
tpl.size.set(d.drawingSize(room.width, room.height));
|
||||
tpl.zoom.set(1);
|
||||
return;
|
||||
}
|
||||
tpl.size.set(d.drawingSize(size.width, size.height));
|
||||
const z = Math.min(room.width / size.width, room.height / size.height);
|
||||
tpl.zoom.set(Math.round(Math.min(d.ZOOM_MAX, Math.max(d.ZOOM_MIN, z)) * 1000) / 1000);
|
||||
}
|
||||
|
||||
Template.cardDrawingPopup.onRendered(function () {
|
||||
// After the popup has maximized (popup.js does that after a flush), and once
|
||||
// the layout has settled.
|
||||
const tpl = this;
|
||||
Tracker.afterFlush(() => requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (!tpl.view.isDestroyed && !tpl.strokes.get().length && !tpl.editingId.get()) fitToStage(tpl, null);
|
||||
})));
|
||||
// ...and again whenever that room changes while the drawing is still new,
|
||||
// empty and not sized or zoomed by hand. Opened from another popup (the
|
||||
// card's + Add, Add attachment) the popup is the SAME rendered one, and its
|
||||
// maximize follows reactively - after the fit above had already taken the
|
||||
// un-maximized 820px, so a new drawing kept about 784px of a 1280px window.
|
||||
// The same refit follows the window being resized or the popup maximized.
|
||||
const stage = this.find('.js-drawing-stage');
|
||||
if (stage && typeof ResizeObserver !== 'undefined') {
|
||||
let lastRoom = '';
|
||||
this.stageObserver = new ResizeObserver(() => {
|
||||
const room = stageRoom(tpl);
|
||||
const key = room ? `${room.width}x${room.height}` : '';
|
||||
if (key === lastRoom) return;
|
||||
lastRoom = key;
|
||||
if (tpl.view.isDestroyed || tpl.sizedByHand || tpl.strokes.get().length || tpl.editingId.get()) return;
|
||||
fitToStage(tpl, null);
|
||||
});
|
||||
this.stageObserver.observe(stage);
|
||||
}
|
||||
// The color wheel follows the brightness and the color.
|
||||
this.autorun(() => {
|
||||
if (!tpl.colorEditor.get()) return;
|
||||
const v = tpl.brightness.get();
|
||||
const hex = tpl.color.get();
|
||||
Tracker.afterFlush(() => paintWheel(tpl.find('.js-drawing-wheel'), v, hex));
|
||||
});
|
||||
});
|
||||
|
||||
// Picks `hex` as the pen color, and shows it in the editor.
|
||||
function useColor(tpl, hex) {
|
||||
const c = d.cleanColor(hex);
|
||||
if (!c) return;
|
||||
tpl.color.set(c);
|
||||
const hsv = d.hexToHsv(c);
|
||||
if (hsv) tpl.brightness.set(hsv.v);
|
||||
}
|
||||
|
||||
const HISTORY = 100;
|
||||
const HANDLE = 12;
|
||||
|
||||
// Before a change: remember the strokes, for Undo.
|
||||
function remember(tpl, strokes = tpl.strokes.get()) {
|
||||
tpl.history.push(strokes);
|
||||
if (tpl.history.length > HISTORY) tpl.history.shift();
|
||||
}
|
||||
|
||||
function setTool(tpl, tool) {
|
||||
tpl.erasing.set(tool === 'eraser');
|
||||
tpl.lasso.set(tool === 'lasso');
|
||||
tpl.picking.set(false);
|
||||
tpl.selection.set([]);
|
||||
tpl.loop.set(null);
|
||||
}
|
||||
|
||||
// The selection's box, with the resize handle at its bottom-right corner.
|
||||
function selectionBox(tpl) {
|
||||
const b = d.selectionBounds(tpl.strokes.get(), tpl.selection.get());
|
||||
return b && { ...b, hx: b.x + b.width - HANDLE / 2, hy: b.y + b.height - HANDLE / 2, size: HANDLE };
|
||||
}
|
||||
|
||||
// What a press at `p` does with the lasso: resize by the handle, move inside
|
||||
// the selection, or start a new loop.
|
||||
function lassoAction(box, p) {
|
||||
if (box && Math.abs(p[0] - (box.x + box.width)) <= HANDLE && Math.abs(p[1] - (box.y + box.height)) <= HANDLE) return 'resize';
|
||||
if (box && p[0] >= box.x - 4 && p[0] <= box.x + box.width + 4 && p[1] >= box.y - 4 && p[1] <= box.y + box.height + 4) return 'move';
|
||||
return 'loop';
|
||||
}
|
||||
|
||||
function card(tpl) {
|
||||
return ReactiveCache.getCard(tpl.data._id) || tpl.data;
|
||||
}
|
||||
|
||||
// The drawing's size, for every operation that keeps lines inside it.
|
||||
function bounds(tpl) {
|
||||
return tpl.size.get();
|
||||
}
|
||||
|
||||
// A pointer position in drawing units: the surface is shown at the drawing's
|
||||
// size times the zoom, so a fraction of its box is the same fraction of the
|
||||
// drawing.
|
||||
function pointFrom(tpl, event) {
|
||||
const svg = tpl.find('.js-drawing-surface');
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const e = event.originalEvent || event;
|
||||
return [Math.round(((e.clientX - rect.left) / rect.width) * W * 10) / 10, Math.round(((e.clientY - rect.top) / rect.height) * H * 10) / 10];
|
||||
const { width, height } = bounds(tpl);
|
||||
return [Math.round(((e.clientX - rect.left) / rect.width) * width * 10) / 10, Math.round(((e.clientY - rect.top) / rect.height) * height * 10) / 10];
|
||||
}
|
||||
|
||||
Template.cardDrawingPopup.onDestroyed(function () {
|
||||
if (this.stageObserver) this.stageObserver.disconnect();
|
||||
});
|
||||
|
||||
Template.cardDrawingPopup.helpers({
|
||||
drawings() { return (card(Template.instance()).drawings || []); },
|
||||
isEditing() { return Template.instance().editingId.get() === this._id; },
|
||||
thumbSvg() { return d.strokesToSvg(this.strokes, { width: this.width || W, height: this.height || H }); },
|
||||
colors() { return d.COLORS; },
|
||||
customColors() { return customColorsOf(Template.instance()); },
|
||||
isCustom(c) { return customColorsOf(Template.instance()).includes(c); },
|
||||
currentColor() { return Template.instance().color.get(); },
|
||||
colorEditorOpen() { return Template.instance().colorEditor.get(); },
|
||||
brightnessPercent() { return Math.round(Template.instance().brightness.get() * 100); },
|
||||
picking() { return Template.instance().picking.get(); },
|
||||
drawingWidth() { return Template.instance().size.get().width; },
|
||||
drawingHeight() { return Template.instance().size.get().height; },
|
||||
canvasWidth() { const tpl = Template.instance(); return Math.round(tpl.size.get().width * tpl.zoom.get()); },
|
||||
canvasHeight() { const tpl = Template.instance(); return Math.round(tpl.size.get().height * tpl.zoom.get()); },
|
||||
zoomPercent() { return Math.round(Template.instance().zoom.get() * 100); },
|
||||
widths() { return d.WIDTHS; },
|
||||
isColor(c) { return Template.instance().color.get() === c; },
|
||||
isWidth(w) { return Template.instance().width.get() === w; },
|
||||
@@ -76,46 +289,258 @@ Template.cardDrawingPopup.helpers({
|
||||
return Template.instance().strokes.get().map(s => ({ ...s, pathD: pathD(s.points) }));
|
||||
},
|
||||
saving() { return Template.instance().saving.get(); },
|
||||
newFile() { return Boolean(Template.instance().data && Template.instance().data.drawingNewFile); },
|
||||
showDrawingList() {
|
||||
const tpl = Template.instance();
|
||||
return !(tpl.data && tpl.data.drawingNewFile) && (card(tpl).drawings || []).length > 0;
|
||||
},
|
||||
erasing() { return Template.instance().erasing.get(); },
|
||||
lassoOn() { return Template.instance().lasso.get(); },
|
||||
lassoPath() {
|
||||
const loop = Template.instance().loop.get();
|
||||
return loop && loop.length > 1 ? `${pathD(loop)}Z` : null;
|
||||
},
|
||||
selectionBox() { return selectionBox(Template.instance()); },
|
||||
hasSelection() { return Template.instance().selection.get().length > 0; },
|
||||
selectionGrouped() {
|
||||
const tpl = Template.instance();
|
||||
const strokes = tpl.strokes.get();
|
||||
return tpl.selection.get().some(i => strokes[i] && strokes[i].group);
|
||||
},
|
||||
message() { return Template.instance().message.get(); },
|
||||
});
|
||||
|
||||
Template.cardDrawingPopup.events({
|
||||
'click .js-drawing-color'(event, tpl) { event.preventDefault(); tpl.color.set(event.currentTarget.dataset.color); },
|
||||
// A color is picked, and the wheel and the HTML code open to change it.
|
||||
'click .js-drawing-color'(event, tpl) {
|
||||
event.preventDefault();
|
||||
useColor(tpl, event.currentTarget.dataset.color);
|
||||
tpl.colorEditor.set(true);
|
||||
},
|
||||
'click .js-drawing-custom-color'(event, tpl) { event.preventDefault(); tpl.colorEditor.set(!tpl.colorEditor.get()); },
|
||||
'click .js-drawing-close-colors'(event, tpl) { event.preventDefault(); tpl.colorEditor.set(false); },
|
||||
'click .js-drawing-add-color'(event, tpl) {
|
||||
event.preventDefault();
|
||||
saveCustomColors(tpl, d.addCustomColor(customColorsOf(tpl), tpl.color.get()));
|
||||
},
|
||||
'click .js-drawing-remove-color'(event, tpl) {
|
||||
event.preventDefault();
|
||||
saveCustomColors(tpl, d.removeCustomColor(customColorsOf(tpl), tpl.color.get()));
|
||||
},
|
||||
'input .js-drawing-hex, change .js-drawing-hex'(event, tpl) {
|
||||
let v = String(event.currentTarget.value || '').trim();
|
||||
if (/^[0-9a-f]{6}$/i.test(v)) v = `#${v}`;
|
||||
if (d.cleanColor(v)) useColor(tpl, v);
|
||||
},
|
||||
'input .js-drawing-brightness'(event, tpl) {
|
||||
const v = Number(event.currentTarget.value) / 100;
|
||||
const hsv = d.hexToHsv(tpl.color.get()) || { h: 0, s: 0 };
|
||||
tpl.brightness.set(v);
|
||||
tpl.color.set(d.hsvToHex(hsv.h, hsv.s, v));
|
||||
},
|
||||
'pointerdown .js-drawing-wheel, pointermove .js-drawing-wheel'(event, tpl) {
|
||||
const e = event.originalEvent || event;
|
||||
if (event.type === 'pointermove' && !e.buttons) return;
|
||||
event.preventDefault();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * WHEEL;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * WHEEL;
|
||||
tpl.color.set(d.wheelColor(x, y, WHEEL / 2, tpl.brightness.get()));
|
||||
},
|
||||
// The eyedropper: from anywhere on the screen where the browser allows it,
|
||||
// from the drawing otherwise.
|
||||
async 'click .js-drawing-eyedropper'(event, tpl) {
|
||||
event.preventDefault();
|
||||
if (typeof window.EyeDropper === 'function') {
|
||||
try {
|
||||
const result = await new window.EyeDropper().open();
|
||||
useColor(tpl, result && result.sRGBHex);
|
||||
} catch (error) { /* cancelled */ }
|
||||
return;
|
||||
}
|
||||
tpl.picking.set(!tpl.picking.get());
|
||||
},
|
||||
// A zoom or a corner resize is the user's choice of size: from then on a
|
||||
// change of the room no longer refits the drawing (onRendered's observer).
|
||||
'click .js-drawing-zoom-in'(event, tpl) { event.preventDefault(); tpl.sizedByHand = true; tpl.zoom.set(d.nextZoom(tpl.zoom.get(), 1)); },
|
||||
'click .js-drawing-zoom-out'(event, tpl) { event.preventDefault(); tpl.sizedByHand = true; tpl.zoom.set(d.nextZoom(tpl.zoom.get(), -1)); },
|
||||
// The drawing area grows (or shrinks, never cutting a line) from its corner.
|
||||
'pointerdown .js-drawing-canvas-resize'(event, tpl) {
|
||||
const e = event.originalEvent || event;
|
||||
if (e.button !== 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
try { event.currentTarget.setPointerCapture?.(e.pointerId); } catch (error) { /* resized without capture */ }
|
||||
// Right to left, the handle is at the bottom-left and grows the drawing leftwards.
|
||||
const direction = window.getComputedStyle(event.currentTarget).direction === 'rtl' ? -1 : 1;
|
||||
tpl.sizedByHand = true;
|
||||
tpl.canvasResize = { pointerId: e.pointerId, x: e.clientX, y: e.clientY, size: tpl.size.get(), zoom: tpl.zoom.get(), direction };
|
||||
},
|
||||
'pointermove .js-drawing-canvas-resize'(event, tpl) {
|
||||
const e = event.originalEvent || event;
|
||||
const r = tpl.canvasResize;
|
||||
if (!r || r.pointerId !== e.pointerId) return;
|
||||
event.preventDefault();
|
||||
const min = d.minimumDrawingSize(tpl.strokes.get());
|
||||
const next = d.drawingSize(r.size.width + (r.direction * (e.clientX - r.x)) / r.zoom, r.size.height + (e.clientY - r.y) / r.zoom);
|
||||
tpl.size.set({ width: Math.max(min.width, next.width), height: Math.max(min.height, next.height) });
|
||||
},
|
||||
'pointerup .js-drawing-canvas-resize, pointercancel .js-drawing-canvas-resize, lostpointercapture .js-drawing-canvas-resize'(event, tpl) {
|
||||
tpl.canvasResize = null;
|
||||
},
|
||||
'keydown .js-drawing-canvas-resize'(event, tpl) {
|
||||
const steps = { ArrowRight: [50, 0], ArrowLeft: [-50, 0], ArrowDown: [0, 50], ArrowUp: [0, -50] };
|
||||
const step = steps[event.key];
|
||||
if (!step) return;
|
||||
event.preventDefault();
|
||||
tpl.sizedByHand = true;
|
||||
const min = d.minimumDrawingSize(tpl.strokes.get());
|
||||
const size = tpl.size.get();
|
||||
const next = d.drawingSize(size.width + step[0], size.height + step[1]);
|
||||
tpl.size.set({ width: Math.max(min.width, next.width), height: Math.max(min.height, next.height) });
|
||||
},
|
||||
'click .js-drawing-width'(event, tpl) { event.preventDefault(); tpl.width.set(Number(event.currentTarget.dataset.width)); },
|
||||
'click .js-drawing-undo'(event, tpl) { event.preventDefault(); tpl.strokes.set(tpl.strokes.get().slice(0, -1)); },
|
||||
'click .js-drawing-clear'(event, tpl) { event.preventDefault(); tpl.strokes.set([]); },
|
||||
'click .js-drawing-undo'(event, tpl) {
|
||||
event.preventDefault();
|
||||
tpl.selection.set([]);
|
||||
tpl.strokes.set(tpl.history.length ? tpl.history.pop() : tpl.strokes.get().slice(0, -1));
|
||||
},
|
||||
'click .js-drawing-clear'(event, tpl) { event.preventDefault(); remember(tpl); tpl.selection.set([]); tpl.strokes.set([]); },
|
||||
'click .js-drawing-eraser'(event, tpl) { event.preventDefault(); setTool(tpl, tpl.erasing.get() ? 'pen' : 'eraser'); },
|
||||
'click .js-drawing-lasso'(event, tpl) { event.preventDefault(); setTool(tpl, tpl.lasso.get() ? 'pen' : 'lasso'); },
|
||||
'click .js-drawing-group'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const sel = tpl.selection.get();
|
||||
if (sel.length < 2) return;
|
||||
remember(tpl);
|
||||
tpl.strokes.set(d.groupStrokes(tpl.strokes.get(), sel, Random.id()));
|
||||
},
|
||||
'click .js-drawing-ungroup'(event, tpl) {
|
||||
event.preventDefault();
|
||||
remember(tpl);
|
||||
tpl.strokes.set(d.ungroupStrokes(tpl.strokes.get(), tpl.selection.get()));
|
||||
},
|
||||
'click .js-drawing-layer'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const sel = tpl.selection.get();
|
||||
if (!sel.length) return;
|
||||
remember(tpl);
|
||||
const { strokes, selection } = d.reorderStrokes(tpl.strokes.get(), sel, event.currentTarget.dataset.layer);
|
||||
tpl.strokes.set(strokes);
|
||||
tpl.selection.set(selection);
|
||||
},
|
||||
// docs/Features/Cards/Whiteboard.md
|
||||
'click .js-open-whiteboard'(event, tpl) {
|
||||
event.preventDefault();
|
||||
openWhiteboard(card(tpl)._id, event.currentTarget.dataset.id);
|
||||
Popup.close();
|
||||
},
|
||||
'click .js-new-drawing'(event, tpl) { event.preventDefault(); tpl.editingId.set(null); tpl.strokes.set([]); },
|
||||
'click .js-new-drawing'(event, tpl) {
|
||||
event.preventDefault();
|
||||
tpl.editingId.set(null);
|
||||
tpl.history = [];
|
||||
tpl.selection.set([]);
|
||||
tpl.strokes.set([]);
|
||||
tpl.sizedByHand = false;
|
||||
fitToStage(tpl, null);
|
||||
},
|
||||
'click .js-edit-drawing'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const drawing = (card(tpl).drawings || []).find(x => x._id === event.currentTarget.dataset.id);
|
||||
if (!drawing) return;
|
||||
// A drawing keeps the size it was made at (older ones are 800 x 500).
|
||||
const size = d.drawingSize(drawing.width || W, drawing.height || H);
|
||||
tpl.editingId.set(drawing._id);
|
||||
tpl.strokes.set(d.cleanStrokes(drawing.strokes, { width: W, height: H }));
|
||||
tpl.history = [];
|
||||
tpl.selection.set([]);
|
||||
tpl.size.set(size);
|
||||
tpl.strokes.set(d.cleanStrokes(drawing.strokes, size));
|
||||
fitToStage(tpl, size);
|
||||
},
|
||||
'pointerdown .js-drawing-surface'(event, tpl) {
|
||||
const e = event.originalEvent || event;
|
||||
if (e.pointerType === 'pen') tpl.pointer.penSeen = true;
|
||||
if (!d.pointerDraws(e, tpl.pointer)) return;
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture?.((event.originalEvent || event).pointerId);
|
||||
tpl.pointer.activePointerId = e.pointerId;
|
||||
// Capture keeps a line going past the edge; failing to capture must not
|
||||
// stop the line from starting.
|
||||
try { event.currentTarget.setPointerCapture?.(e.pointerId); } catch (error) { /* drawn without capture */ }
|
||||
const point = pointFrom(tpl, event);
|
||||
// The eyedropper on the drawing: the color under the pointer.
|
||||
if (tpl.picking.get()) {
|
||||
useColor(tpl, d.colorAt(tpl.strokes.get(), point));
|
||||
tpl.picking.set(false);
|
||||
tpl.pointer.activePointerId = null;
|
||||
return;
|
||||
}
|
||||
if (tpl.lasso.get()) {
|
||||
const box = selectionBox(tpl);
|
||||
const kind = lassoAction(box, point);
|
||||
if (kind === 'loop') {
|
||||
tpl.selection.set([]);
|
||||
tpl.loop.set([point]);
|
||||
}
|
||||
tpl.drag = { kind, start: point, base: tpl.strokes.get(), box };
|
||||
return;
|
||||
}
|
||||
remember(tpl);
|
||||
tpl.selection.set([]);
|
||||
if (tpl.erasing.get()) {
|
||||
tpl.erasingNow = true;
|
||||
tpl.strokes.set(d.eraseAt(tpl.strokes.get(), pointFrom(tpl, event)));
|
||||
return;
|
||||
}
|
||||
tpl.current = { color: tpl.color.get(), width: tpl.width.get(), points: [pointFrom(tpl, event)] };
|
||||
tpl.strokes.set([...tpl.strokes.get(), tpl.current]);
|
||||
},
|
||||
'pointermove .js-drawing-surface'(event, tpl) {
|
||||
const e = event.originalEvent || event;
|
||||
if (e.pointerId !== tpl.pointer.activePointerId) return;
|
||||
if (tpl.drag) {
|
||||
const p = pointFrom(tpl, event);
|
||||
const { kind, start, base, box } = tpl.drag;
|
||||
if (kind === 'loop') {
|
||||
tpl.loop.set([...(tpl.loop.get() || []), p]);
|
||||
return;
|
||||
}
|
||||
// A move or a resize is one Undo step, from where it started.
|
||||
if (!tpl.drag.changed) { remember(tpl, base); tpl.drag.changed = true; }
|
||||
if (kind === 'move') {
|
||||
tpl.strokes.set(d.moveStrokes(base, tpl.selection.get(), p[0] - start[0], p[1] - start[1], bounds(tpl)));
|
||||
} else if (kind === 'resize' && box) {
|
||||
// From the box's top-left corner, as far as the pointer went.
|
||||
const fx = box.width > 0 ? (p[0] - box.x) / box.width : 0;
|
||||
const fy = box.height > 0 ? (p[1] - box.y) / box.height : 0;
|
||||
const factor = Math.max(fx, fy);
|
||||
if (factor > 0) tpl.strokes.set(d.scaleStrokes(base, tpl.selection.get(), factor, bounds(tpl)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (tpl.erasingNow) {
|
||||
tpl.strokes.set(d.eraseAt(tpl.strokes.get(), pointFrom(tpl, event)));
|
||||
return;
|
||||
}
|
||||
if (!tpl.current) return;
|
||||
tpl.current.points.push(pointFrom(tpl, event));
|
||||
const list = tpl.strokes.get().slice(0, -1);
|
||||
tpl.strokes.set([...list, { ...tpl.current }]);
|
||||
},
|
||||
'pointerup .js-drawing-surface, pointerleave .js-drawing-surface'(event, tpl) {
|
||||
'pointerup .js-drawing-surface, pointerleave .js-drawing-surface, pointercancel .js-drawing-surface'(event, tpl) {
|
||||
const e = event.originalEvent || event;
|
||||
if (tpl.pointer.activePointerId !== null && e.pointerId !== tpl.pointer.activePointerId) return;
|
||||
if (tpl.drag && tpl.drag.kind === 'loop') {
|
||||
tpl.selection.set(d.lassoSelect(tpl.strokes.get(), tpl.loop.get() || []));
|
||||
tpl.loop.set(null);
|
||||
}
|
||||
tpl.drag = null;
|
||||
tpl.current = null;
|
||||
tpl.erasingNow = false;
|
||||
tpl.pointer.activePointerId = null;
|
||||
},
|
||||
'click .js-drawing-download-svg'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const blob = new Blob([d.strokesToSvg(tpl.strokes.get(), { width: W, height: H })], { type: 'image/svg+xml' });
|
||||
const blob = new Blob([d.strokesToSvg(tpl.strokes.get(), bounds(tpl))], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
@@ -127,16 +552,36 @@ Template.cardDrawingPopup.events({
|
||||
async 'click .js-drawing-save'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const target = card(tpl);
|
||||
const strokes = d.cleanStrokes(tpl.strokes.get(), { width: W, height: H });
|
||||
const size = bounds(tpl);
|
||||
const strokes = d.cleanStrokes(tpl.strokes.get(), size);
|
||||
if (!target || !strokes.length) return;
|
||||
tpl.saving.set(true);
|
||||
// #6802: a new drawing becomes an SVG attachment of the card, and is kept
|
||||
// on the card too, so the Drawing popup can edit it later.
|
||||
if (tpl.data && tpl.data.drawingNewFile) {
|
||||
try {
|
||||
const id = Random.id();
|
||||
const svgAttachmentId = await uploadDrawingPng(target, drawingSvgFile(target, strokes, size));
|
||||
const drawing = { _id: id, strokes, width: size.width, height: size.height, svgAttachmentId, updatedAt: new Date() };
|
||||
// A new drawing is the card's cover image.
|
||||
await Cards.updateAsync(target._id, { $set: { drawings: d.drawingsAfterSave(target.drawings, drawing), ...coverOf(svgAttachmentId) } });
|
||||
tpl.saving.set(false);
|
||||
Popup.close();
|
||||
} catch (error) {
|
||||
tpl.saving.set(false);
|
||||
tpl.message.set((error && (error.reason || error.message)) || 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const file = await drawingPngFile(target, strokes);
|
||||
const file = await drawingPngFile(target, strokes, size);
|
||||
const isNew = !tpl.editingId.get();
|
||||
const id = tpl.editingId.get() || Random.id();
|
||||
const pngAttachmentId = await uploadDrawingPng(target, file);
|
||||
tpl.saving.set(false);
|
||||
const drawing = { _id: id, strokes, width: W, height: H, pngAttachmentId, updatedAt: new Date() };
|
||||
await Cards.updateAsync(target._id, { $set: { drawings: d.drawingsAfterSave(target.drawings, drawing) } });
|
||||
const drawing = { _id: id, strokes, width: size.width, height: size.height, pngAttachmentId, updatedAt: new Date() };
|
||||
// A NEW drawing is the card's cover image; saving an edit leaves the cover.
|
||||
await Cards.updateAsync(target._id, { $set: { drawings: d.drawingsAfterSave(target.drawings, drawing), ...(isNew ? coverOf(pngAttachmentId) : {}) } });
|
||||
tpl.editingId.set(id);
|
||||
tpl.message.set(TAPi18n.__('card-drawing-saved'));
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import Cards from '/models/cards';
|
||||
import { getCurrentCardIdFromContext } from '/client/lib/currentCard';
|
||||
import { backIfOwnPopup } from '/client/lib/popupBack';
|
||||
|
||||
function getCardId() {
|
||||
return getCurrentCardIdFromContext();
|
||||
@@ -36,7 +37,7 @@ Template.editCardSpentTimePopup.events({
|
||||
if (spentTime >= 0) {
|
||||
card.setSpentTime(spentTime);
|
||||
card.setIsOvertime(isOvertime);
|
||||
Popup.back();
|
||||
backIfOwnPopup(evt, 'editCardSpentTimePopup');
|
||||
} else {
|
||||
tpl.error.set('invalid-time');
|
||||
evt.target.time.focus();
|
||||
@@ -48,7 +49,7 @@ Template.editCardSpentTimePopup.events({
|
||||
if (!card) return;
|
||||
card.setSpentTime(null);
|
||||
card.setIsOvertime(false);
|
||||
Popup.back();
|
||||
backIfOwnPopup(evt, 'editCardSpentTimePopup');
|
||||
},
|
||||
'click a.js-toggle-overtime'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
|
||||
@@ -374,7 +374,12 @@
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
margin-inline-end: 84px;
|
||||
/* #6834: room for the drag handle beside it (20px, 1px from the edge), and
|
||||
no more. It reserved 84px - a third of a 250px minicard - for nothing:
|
||||
the avatars float AFTER this row and `clear: both` starts it below the
|
||||
menu, so a due date "1405-08-10 12:00 (1 day left)" was broken over three
|
||||
lines beside an empty strip. */
|
||||
margin-inline-end: 24px;
|
||||
clear: both;
|
||||
}
|
||||
.minicard .date {
|
||||
@@ -1214,3 +1219,20 @@
|
||||
.minicard.minicard-unread-changes {
|
||||
box-shadow: inset 4px 0 0 #2b6cb0;
|
||||
}
|
||||
|
||||
/* Card Settings' sections on the minicard (2026-10-28, models/lib/minicardGroups.js):
|
||||
At risk and Forecast are badges drawn in the dates line, after the date
|
||||
their row follows; and a section boundary can start a second badge strip,
|
||||
which floats beside the first with the gap two badges have. */
|
||||
.minicard .dates .badge {
|
||||
align-self: center;
|
||||
margin-inline-end: 6px;
|
||||
font-size: 0.9em;
|
||||
color: #808080;
|
||||
}
|
||||
.minicard .dates .badge .badge-text {
|
||||
padding-inline-start: 4px;
|
||||
}
|
||||
.minicard .badges + .badges {
|
||||
margin-inline-start: 21px;
|
||||
}
|
||||
@@ -119,22 +119,30 @@ template(name="minicard")
|
||||
+editMinicardTitleForm
|
||||
else
|
||||
|
||||
each section in orderedMinicardSections
|
||||
//- Each Scrum field where the board's order puts it ("Scrum settings:
|
||||
Sprint" ... in Board Settings / Card).
|
||||
if $eq section "scrumSprint"
|
||||
+scrumMetadata(kind="minicard" record=this field="Sprint")
|
||||
if $eq section "scrumPastSprints"
|
||||
+scrumMetadata(kind="minicard" record=this field="PastSprints")
|
||||
if $eq section "scrumRelease"
|
||||
+scrumMetadata(kind="minicard" record=this field="Release")
|
||||
if $eq section "scrumAcceptanceCriteria"
|
||||
+scrumMetadata(kind="minicard" record=this field="AcceptanceCriteria")
|
||||
if $eq section "scrumBacklogRank"
|
||||
+scrumMetadata(kind="minicard" record=this field="BacklogRank")
|
||||
if $eq section "dates"
|
||||
//- The cover is fixed, under the title (MINICARD_LAYOUT head).
|
||||
if cover
|
||||
if currentBoard.allowsCoverAttachmentOnMinicard
|
||||
//- An img, not a CSS background: it can fall back to the original
|
||||
//- once (attachments.js previewFallback), and it is drawn only
|
||||
//- when there is a URL - never url('?dummyReload...').
|
||||
if attachmentPreviewUrl cover
|
||||
.minicard-cover
|
||||
img.minicard-cover-image(alt="" src="{{attachmentPreviewUrl cover}}?dummyReloadAfterSessionEstablished={{sess}}" width="512" height="120" decoding="async")
|
||||
|
||||
//- Card Settings' sections (models/lib/cardSettingsSections.js) in the
|
||||
board's Show on Minicard section order (minicardSettingsSectionOrder), each
|
||||
section's fields in the order of its rows there (board.minicardFieldOrder,
|
||||
models/lib/cardFieldOrder.js) - with no headings: the minicard is compact.
|
||||
What shares a container still does: consecutive date fields are one
|
||||
`.dates` line and consecutive badges one `.badges` strip, also across a
|
||||
section boundary (minicard.js minicardGroups), so a board that never set
|
||||
an order looks as it did. Every gate (the show* helpers, the
|
||||
currentBoard.allows* flags) is unchanged; only where a block sits is
|
||||
decided by the loop.
|
||||
each group in minicardGroups
|
||||
if group.dates
|
||||
.dates
|
||||
each field in orderedMinicardDates
|
||||
each field in group.fields
|
||||
if $eq field "receivedDate"
|
||||
if showReceived
|
||||
if getReceived
|
||||
@@ -159,132 +167,36 @@ template(name="minicard")
|
||||
if getSpentTime
|
||||
.date
|
||||
+cardSpentTime
|
||||
if $eq section "cover"
|
||||
if cover
|
||||
if currentBoard.allowsCoverAttachmentOnMinicard
|
||||
//- An img, not a CSS background: it can fall back to the original
|
||||
//- once (attachments.js previewFallback), and it is drawn only
|
||||
//- when there is a URL - never url('?dummyReload...').
|
||||
if attachmentPreviewUrl cover
|
||||
.minicard-cover
|
||||
img.minicard-cover-image(alt="" src="{{attachmentPreviewUrl cover}}?dummyReloadAfterSessionEstablished={{sess}}" width="512" height="120" decoding="async")
|
||||
|
||||
if $eq section "labels"
|
||||
unless labelsAboveTitle
|
||||
+minicardLabels(show=showLabels labels=labels hideText=hiddenMinicardLabelText)
|
||||
|
||||
if $eq section "customFields"
|
||||
if showCustomFieldsOnMinicard
|
||||
.minicard-custom-fields
|
||||
each customFieldsWD
|
||||
if definition.showOnCard
|
||||
.minicard-custom-field
|
||||
// If there is custom field label, show label at left,
|
||||
// and value at right
|
||||
if definition.showLabelOnMiniCard
|
||||
.minicard-custom-field-item
|
||||
+viewer
|
||||
= definition.name
|
||||
.minicard-custom-field-item
|
||||
if $eq definition.type "currency"
|
||||
+viewer
|
||||
= formattedCurrencyCustomFieldValue(definition)
|
||||
else if $eq definition.type "date"
|
||||
.date
|
||||
+minicardCustomFieldDate
|
||||
else if $eq definition.type "checkbox"
|
||||
i.fa.minicard-custom-field-checkbox-icon(class="{{#if value}}fa-check-circle minicard-custom-field-checkbox-true{{else}}fa-times-circle minicard-custom-field-checkbox-false{{/if}}" title="{{#if value}}{{_ 'yes'}}{{else}}{{_ 'no'}}{{/if}}")
|
||||
else if $eq definition.type "stringtemplate"
|
||||
+viewer
|
||||
= formattedStringtemplateCustomFieldValue(definition)
|
||||
else
|
||||
+viewer
|
||||
= trueValue
|
||||
else
|
||||
// If there is no custom field label,
|
||||
// show value full width
|
||||
.minicard-custom-field-item-fullwidth
|
||||
if $eq definition.type "currency"
|
||||
+viewer
|
||||
= formattedCurrencyCustomFieldValue(definition)
|
||||
else if $eq definition.type "date"
|
||||
.date
|
||||
+minicardCustomFieldDate
|
||||
else if $eq definition.type "checkbox"
|
||||
i.fa.minicard-custom-field-checkbox-icon(class="{{#if value}}fa-check-circle minicard-custom-field-checkbox-true{{else}}fa-times-circle minicard-custom-field-checkbox-false{{/if}}" title="{{#if value}}{{_ 'yes'}}{{else}}{{_ 'no'}}{{/if}}")
|
||||
else if $eq definition.type "stringtemplate"
|
||||
+viewer
|
||||
= formattedStringtemplateCustomFieldValue(definition)
|
||||
else
|
||||
+viewer
|
||||
= trueValue
|
||||
|
||||
if $eq section "assignee"
|
||||
if showAssignee
|
||||
if getAssignees
|
||||
.minicard-assignees.js-minicard-assignees
|
||||
each getAssignees
|
||||
+userAvatar(userId=this)
|
||||
|
||||
if $eq section "members"
|
||||
if showMembers
|
||||
if getMembers
|
||||
.minicard-members.js-minicard-members
|
||||
each getMembers
|
||||
+userAvatar(userId=this)
|
||||
|
||||
if $eq section "creator"
|
||||
if showCreatorOnMinicard
|
||||
.minicard-creator
|
||||
+userAvatar(userId=getCreatorId noRemove=true)
|
||||
|
||||
if $eq section "checklists"
|
||||
if shouldShowChecklistAtMinicard
|
||||
each shouldShowChecklistAtMinicard
|
||||
+minicardChecklist(checklist=. card=..)
|
||||
if $eq section "badges"
|
||||
//- At risk and Forecast, Show on Minicard: badges in the dates line,
|
||||
after the date their row follows in Card Settings.
|
||||
if $eq field "atRisk"
|
||||
unless $eq currentBoard.allowsAtRiskOnMinicard false
|
||||
with atRisk
|
||||
.badge.badge-state-image-only.minicard-at-risk.js-minicard-at-risk(title=text)
|
||||
span.badge-icon
|
||||
i.fa.fa-exclamation-triangle
|
||||
if $eq field "forecast"
|
||||
if currentBoard.allowsForecastOnMinicard
|
||||
with cardForecastOf this
|
||||
if enough
|
||||
.badge.minicard-forecast.js-minicard-forecast(title="{{_ 'forecast-done' p50=(forecastDate p50) p85=(forecastDate p85)}}")
|
||||
span.badge-icon
|
||||
i.fa.fa-line-chart
|
||||
span.badge-text {{forecastDate p85}}
|
||||
else if group.badges
|
||||
.badges
|
||||
//- #2012: a Story or a Task.
|
||||
if cardKindIcon
|
||||
.badge.card-kind-badge(title="{{cardKindTitle}}")
|
||||
span.badge-icon
|
||||
i.fa(class=cardKindIcon)
|
||||
//- At risk, Show on Minicard (docs/Features/Cards/At-Risk.md): the
|
||||
reasons are its title.
|
||||
unless $eq currentBoard.allowsAtRiskOnMinicard false
|
||||
with atRisk
|
||||
.badge.badge-state-image-only.minicard-at-risk.js-minicard-at-risk(title=text)
|
||||
span.badge-icon
|
||||
i.fa.fa-exclamation-triangle
|
||||
//- Forecast, Show on Minicard: off until a board turns it on
|
||||
(docs/Features/Cards/Dates/Forecast.md).
|
||||
if currentBoard.allowsForecastOnMinicard
|
||||
with cardForecastOf this
|
||||
if enough
|
||||
.badge.minicard-forecast.js-minicard-forecast(title="{{_ 'forecast-done' p50=(forecastDate p50) p85=(forecastDate p85)}}")
|
||||
each field in group.fields
|
||||
//- #2012: a Story or a Task - first of the Scrum section.
|
||||
if $eq field "cardKind"
|
||||
if cardKindIcon
|
||||
.badge.card-kind-badge(title="{{cardKindTitle}}")
|
||||
span.badge-icon
|
||||
i.fa.fa-line-chart
|
||||
span.badge-text {{forecastDate p85}}
|
||||
//- The description BADGE - shown when the description's text is not
|
||||
on the minicard - belongs to the Description text row, whose own
|
||||
position is the full text further down; it keeps the first place
|
||||
of the strip.
|
||||
if getDescription
|
||||
unless currentBoard.allowsDescriptionTextOnMinicard
|
||||
//- Description badge, Show on Minicard.
|
||||
unless $eq currentBoard.allowsDescriptionBadgeOnMinicard false
|
||||
.badge.badge-state-image-only(title=getDescription)
|
||||
span.badge-icon
|
||||
i.fa.fa-file-text-o
|
||||
//- #6688: every badge has a Board Settings / Card "Show on Minicard"
|
||||
toggle (the cheap board flag is read first, so a board that turned a
|
||||
badge off never counts what it would have shown). Dependencies,
|
||||
stickers, the comment count, vote and poker rendered unconditionally
|
||||
before; all default on. client/components/sidebar/sidebar.jade
|
||||
each field in orderedMinicardBadges
|
||||
//- Jira issue type (models/lib/issueTypeIcon.js): first by default,
|
||||
because it says what kind of card this is; its row is "Scrum
|
||||
settings: Work item type".
|
||||
i.fa(class=cardKindIcon)
|
||||
//- #6688: every badge has a Board Settings / Card "Show on Minicard"
|
||||
toggle (the cheap board flag is read first, so a board that turned a
|
||||
badge off never counts what it would have shown). Dependencies,
|
||||
stickers, the comment count, vote and poker rendered unconditionally
|
||||
before; all default on. client/components/sidebar/sidebar.jade
|
||||
if $eq field "scrumIssueType"
|
||||
if showIssueTypeOnMinicard
|
||||
if issueTypeBadge
|
||||
@@ -340,16 +252,16 @@ template(name="minicard")
|
||||
i.fa.fa-paperclip
|
||||
span.badge-text= attachments.length
|
||||
if $eq field "subtasks"
|
||||
if showSubtasks
|
||||
if allSubtasks.count
|
||||
.badge
|
||||
span.badge-icon
|
||||
i.fa.fa-globe
|
||||
span.badge-text.check-list-text {{subtasksFinishedCount}}/{{allSubtasksCount}}
|
||||
//{{subtasksFinishedCount}}/{{subtasksCount}} does not work because when a subtaks is archived, the count goes down
|
||||
//- #6431: compact checklist item-count badge (finished/total). The outer
|
||||
guard is a cheap boolean, so when the setting is off the checklist
|
||||
counting below never runs.
|
||||
if showSubtasks
|
||||
if allSubtasks.count
|
||||
.badge
|
||||
span.badge-icon
|
||||
i.fa.fa-globe
|
||||
span.badge-text.check-list-text {{subtasksFinishedCount}}/{{allSubtasksCount}}
|
||||
//{{subtasksFinishedCount}}/{{subtasksCount}} does not work because when a subtaks is archived, the count goes down
|
||||
//- #6431: compact checklist item-count badge (finished/total). The outer
|
||||
guard is a cheap boolean, so when the setting is off the checklist
|
||||
counting below never runs.
|
||||
if $eq field "checklistCount"
|
||||
if showChecklistCountBadge
|
||||
if checklists.length
|
||||
@@ -364,104 +276,199 @@ template(name="minicard")
|
||||
span.badge-icon
|
||||
i.fa.fa-sort-numeric-asc
|
||||
span.badge-text.check-list-sort {{ sort }}
|
||||
//- Card buttons, Show on Minicard: the board's rule buttons, under the
|
||||
badges. Off until chosen; the board subscribes to its rules once.
|
||||
if currentBoard.allowsCardButtonsOnMinicard
|
||||
+minicardCardButtons(_id=_id boardId=boardId)
|
||||
if $eq section "descriptionText"
|
||||
if currentBoard.allowsDescriptionTextOnMinicard
|
||||
if getDescription
|
||||
//- Description title, Show on Minicard: the heading over the text.
|
||||
unless $eq currentBoard.allowsDescriptionTitleOnMinicard false
|
||||
.minicard-description-title
|
||||
i.fa.fa-file-text-o
|
||||
| {{_ 'description'}}
|
||||
.minicard-description
|
||||
+viewer
|
||||
| {{ getDescription }}
|
||||
//- #4285: compact preview of the most recent comments on the minicard.
|
||||
Opt-in (allowsCommentsOnMinicard), display-only - no reply/edit here,
|
||||
just enough to see at a glance, with a "more" link that opens the
|
||||
full card (the whole minicard is already a link to it, see
|
||||
listBody.jade's a.minicard-wrapper.js-minicard).
|
||||
if $eq section "comments"
|
||||
if showCommentsOnMinicard
|
||||
with commentsForMinicard
|
||||
if comments.length
|
||||
.minicard-comments
|
||||
each comments
|
||||
.minicard-comment
|
||||
span.minicard-comment-icon
|
||||
i.fa.fa-comment-o
|
||||
span.minicard-comment-text {{ text }}
|
||||
if hasMore
|
||||
a.minicard-comments-more(title="{{_ 'card-comments-on-minicard'}}")
|
||||
i.fa.fa-caret-down
|
||||
| {{_ 'card-comments-more'}}
|
||||
if $eq section "showLists"
|
||||
if shouldShowListOnMinicard
|
||||
.minicard-list-name
|
||||
i.fa.fa-list
|
||||
| {{ listName }}
|
||||
if $eq section "swimlaneName"
|
||||
if shouldShowSwimlaneNameOnMinicard
|
||||
.minicard-swimlane-name
|
||||
i.fa.fa-list-alt
|
||||
| {{ swimlaneName }}
|
||||
//- What the opened card shows that the minicard did not, each with its
|
||||
Board Settings / Card "Show on Minicard" row (2026-10-02). Compact and
|
||||
read-only: the minicard is a link to the card, which edits them.
|
||||
if $eq section "location"
|
||||
if currentBoard.allowsLocationOnMinicard
|
||||
each getLocations
|
||||
.minicard-location
|
||||
i.fa.fa-map-marker
|
||||
| {{#if name}}{{name}}{{else}}{{address}}{{/if}}
|
||||
if $eq section "requestedBy"
|
||||
unless $eq currentBoard.allowsRequestedByOnMinicard false
|
||||
if getRequestedBy
|
||||
.minicard-requested-by(title="{{_ 'requested-by'}}")
|
||||
i.fa.fa-shopping-cart
|
||||
| {{ getRequestedBy }}
|
||||
if $eq section "assignedBy"
|
||||
unless $eq currentBoard.allowsAssignedByOnMinicard false
|
||||
if getAssignedBy
|
||||
.minicard-assigned-by(title="{{_ 'assigned-by'}}")
|
||||
i.fa.fa-user-plus
|
||||
| {{ getAssignedBy }}
|
||||
if $eq section "flowtime"
|
||||
if currentBoard.allowsFlowtimeOnMinicard
|
||||
if isFlowActive
|
||||
.badge.minicard-flowtime(title="{{_ 'flowtime'}}")
|
||||
span.badge-icon
|
||||
i.fa.fa-bolt
|
||||
span.badge-text {{_ 'flow-interruptions' getFlowInterruptions}}
|
||||
if $eq section "pomodoro"
|
||||
if currentBoard.allowsPomodoroOnMinicard
|
||||
if isPomodoroActive
|
||||
.badge.minicard-pomodoro(title="{{_ 'pomodoro'}}")
|
||||
span.badge-icon
|
||||
i.fa.fa-clock-o
|
||||
span.badge-text {{_ 'pomodoro-completed' getPomodoroCount}}
|
||||
if $eq section "attachments"
|
||||
if currentBoard.allowsAttachmentListOnMinicard
|
||||
each minicardAttachmentNames
|
||||
.minicard-attachment-name
|
||||
i.fa.fa-paperclip
|
||||
| {{cleanFilename this}}
|
||||
if $eq section "textNotes"
|
||||
if currentBoard.allowsTextNotesOnMinicard
|
||||
if textNotesCount
|
||||
.badge.minicard-text-notes(title="{{_ 'text-notes'}}")
|
||||
span.badge-icon
|
||||
i.fa.fa-file-text-o
|
||||
span.badge-text {{ textNotesCount }}
|
||||
if $eq section "activities"
|
||||
if currentBoard.allowsActivitiesOnMinicard
|
||||
if dateLastActivity
|
||||
.minicard-last-activity(title="{{_ 'activities'}}")
|
||||
i.fa.fa-history
|
||||
| {{ minicardLastActivity }}
|
||||
//- The description BADGE, shown when the description's text is not on
|
||||
the minicard: in Description, after the text's row.
|
||||
if $eq field "descriptionBadge"
|
||||
if getDescription
|
||||
unless currentBoard.allowsDescriptionTextOnMinicard
|
||||
//- Description badge, Show on Minicard.
|
||||
unless $eq currentBoard.allowsDescriptionBadgeOnMinicard false
|
||||
.badge.badge-state-image-only(title=getDescription)
|
||||
span.badge-icon
|
||||
i.fa.fa-file-text-o
|
||||
else
|
||||
each field in group.fields
|
||||
if $eq field "scrumSprint"
|
||||
+scrumMetadata(kind="minicard" record=this field="Sprint")
|
||||
if $eq field "scrumPastSprints"
|
||||
+scrumMetadata(kind="minicard" record=this field="PastSprints")
|
||||
if $eq field "scrumRelease"
|
||||
+scrumMetadata(kind="minicard" record=this field="Release")
|
||||
if $eq field "scrumAcceptanceCriteria"
|
||||
+scrumMetadata(kind="minicard" record=this field="AcceptanceCriteria")
|
||||
if $eq field "scrumBacklogRank"
|
||||
+scrumMetadata(kind="minicard" record=this field="BacklogRank")
|
||||
if $eq field "labels"
|
||||
unless labelsAboveTitle
|
||||
+minicardLabels(show=showLabels labels=labels hideText=hiddenMinicardLabelText)
|
||||
if $eq field "customFields"
|
||||
if showCustomFieldsOnMinicard
|
||||
.minicard-custom-fields
|
||||
each customFieldsWD
|
||||
if definition.showOnCard
|
||||
//- #6811: only a field with something in it, as before the
|
||||
minicard's sections were rewritten - a field a card carries
|
||||
because it is on every card is empty until filled in.
|
||||
if minicardCustomFieldHasValue
|
||||
.minicard-custom-field
|
||||
// If there is custom field label, show label at left,
|
||||
// and value at right
|
||||
if definition.showLabelOnMiniCard
|
||||
.minicard-custom-field-item
|
||||
+viewer
|
||||
= definition.name
|
||||
.minicard-custom-field-item
|
||||
if $eq definition.type "currency"
|
||||
+viewer
|
||||
= formattedCurrencyCustomFieldValue(definition)
|
||||
else if $eq definition.type "date"
|
||||
.date
|
||||
+minicardCustomFieldDate
|
||||
else if $eq definition.type "checkbox"
|
||||
i.fa.minicard-custom-field-checkbox-icon(class="{{#if value}}fa-check-circle minicard-custom-field-checkbox-true{{else}}fa-times-circle minicard-custom-field-checkbox-false{{/if}}" title="{{#if value}}{{_ 'yes'}}{{else}}{{_ 'no'}}{{/if}}")
|
||||
else if $eq definition.type "stringtemplate"
|
||||
+viewer
|
||||
= formattedStringtemplateCustomFieldValue(definition)
|
||||
else
|
||||
+viewer
|
||||
= trueValue
|
||||
else
|
||||
// If there is no custom field label,
|
||||
// show value full width
|
||||
.minicard-custom-field-item-fullwidth
|
||||
if $eq definition.type "currency"
|
||||
+viewer
|
||||
= formattedCurrencyCustomFieldValue(definition)
|
||||
else if $eq definition.type "date"
|
||||
.date
|
||||
+minicardCustomFieldDate
|
||||
else if $eq definition.type "checkbox"
|
||||
i.fa.minicard-custom-field-checkbox-icon(class="{{#if value}}fa-check-circle minicard-custom-field-checkbox-true{{else}}fa-times-circle minicard-custom-field-checkbox-false{{/if}}" title="{{#if value}}{{_ 'yes'}}{{else}}{{_ 'no'}}{{/if}}")
|
||||
else if $eq definition.type "stringtemplate"
|
||||
+viewer
|
||||
= formattedStringtemplateCustomFieldValue(definition)
|
||||
else
|
||||
+viewer
|
||||
= trueValue
|
||||
if $eq field "assignee"
|
||||
if showAssignee
|
||||
if getAssignees
|
||||
.minicard-assignees.js-minicard-assignees
|
||||
each getAssignees
|
||||
+userAvatar(userId=this)
|
||||
if $eq field "members"
|
||||
if showMembers
|
||||
if getMembers
|
||||
.minicard-members.js-minicard-members
|
||||
each getMembers
|
||||
+userAvatar(userId=this)
|
||||
if $eq field "creator"
|
||||
if showCreatorOnMinicard
|
||||
.minicard-creator
|
||||
+userAvatar(userId=getCreatorId noRemove=true)
|
||||
if $eq field "checklists"
|
||||
if shouldShowChecklistAtMinicard
|
||||
each shouldShowChecklistAtMinicard
|
||||
+minicardChecklist(checklist=. card=..)
|
||||
if $eq field "descriptionText"
|
||||
if currentBoard.allowsDescriptionTextOnMinicard
|
||||
if getDescription
|
||||
//- Description title, Show on Minicard: the heading over the text.
|
||||
unless $eq currentBoard.allowsDescriptionTitleOnMinicard false
|
||||
.minicard-description-title
|
||||
i.fa.fa-file-text-o
|
||||
| {{_ 'description'}}
|
||||
.minicard-description
|
||||
+viewer
|
||||
| {{ getDescription }}
|
||||
//- #4285: compact preview of the most recent comments on the minicard.
|
||||
Opt-in (allowsCommentsOnMinicard), display-only - no reply/edit here,
|
||||
just enough to see at a glance, with a "more" link that opens the
|
||||
full card (the whole minicard is already a link to it, see
|
||||
listBody.jade's a.minicard-wrapper.js-minicard).
|
||||
if $eq field "comments"
|
||||
if showCommentsOnMinicard
|
||||
with commentsForMinicard
|
||||
if comments.length
|
||||
.minicard-comments
|
||||
each comments
|
||||
.minicard-comment
|
||||
span.minicard-comment-icon
|
||||
i.fa.fa-comment-o
|
||||
span.minicard-comment-text {{ text }}
|
||||
if hasMore
|
||||
a.minicard-comments-more(title="{{_ 'card-comments-on-minicard'}}")
|
||||
i.fa.fa-caret-down
|
||||
| {{_ 'card-comments-more'}}
|
||||
if $eq field "showLists"
|
||||
if shouldShowListOnMinicard
|
||||
.minicard-list-name
|
||||
i.fa.fa-list
|
||||
| {{ listName }}
|
||||
if $eq field "swimlaneName"
|
||||
if shouldShowSwimlaneNameOnMinicard
|
||||
.minicard-swimlane-name
|
||||
i.fa.fa-list-alt
|
||||
| {{ swimlaneName }}
|
||||
//- What the opened card shows that the minicard did not, each with its
|
||||
Board Settings / Card "Show on Minicard" row (2026-10-02). Compact and
|
||||
read-only: the minicard is a link to the card, which edits them.
|
||||
if $eq field "location"
|
||||
if currentBoard.allowsLocationOnMinicard
|
||||
each getLocations
|
||||
.minicard-location
|
||||
i.fa.fa-map-marker
|
||||
| {{#if name}}{{name}}{{else}}{{address}}{{/if}}
|
||||
if $eq field "requestedBy"
|
||||
unless $eq currentBoard.allowsRequestedByOnMinicard false
|
||||
if getRequestedBy
|
||||
.minicard-requested-by(title="{{_ 'requested-by'}}")
|
||||
i.fa.fa-shopping-cart
|
||||
| {{ getRequestedBy }}
|
||||
if $eq field "assignedBy"
|
||||
unless $eq currentBoard.allowsAssignedByOnMinicard false
|
||||
if getAssignedBy
|
||||
.minicard-assigned-by(title="{{_ 'assigned-by'}}")
|
||||
i.fa.fa-user-plus
|
||||
| {{ getAssignedBy }}
|
||||
if $eq field "flowtime"
|
||||
if currentBoard.allowsFlowtimeOnMinicard
|
||||
if isFlowActive
|
||||
.badge.minicard-flowtime(title="{{_ 'flowtime'}}")
|
||||
span.badge-icon
|
||||
i.fa.fa-bolt
|
||||
span.badge-text {{_ 'flow-interruptions' getFlowInterruptions}}
|
||||
if $eq field "pomodoro"
|
||||
if currentBoard.allowsPomodoroOnMinicard
|
||||
if isPomodoroActive
|
||||
.badge.minicard-pomodoro(title="{{_ 'pomodoro'}}")
|
||||
span.badge-icon
|
||||
i.fa.fa-clock-o
|
||||
span.badge-text {{_ 'pomodoro-completed' getPomodoroCount}}
|
||||
if $eq field "attachments"
|
||||
if currentBoard.allowsAttachmentListOnMinicard
|
||||
each minicardAttachmentNames
|
||||
.minicard-attachment-name
|
||||
i.fa.fa-paperclip
|
||||
| {{cleanFilename this}}
|
||||
if $eq field "textNotes"
|
||||
if currentBoard.allowsTextNotesOnMinicard
|
||||
if textNotesCount
|
||||
.badge.minicard-text-notes(title="{{_ 'text-notes'}}")
|
||||
span.badge-icon
|
||||
i.fa.fa-file-text-o
|
||||
span.badge-text {{ textNotesCount }}
|
||||
if $eq field "activities"
|
||||
if currentBoard.allowsActivitiesOnMinicard
|
||||
if dateLastActivity
|
||||
.minicard-last-activity(title="{{_ 'activities'}}")
|
||||
i.fa.fa-history
|
||||
| {{ minicardLastActivity }}
|
||||
//- Card buttons, Show on Minicard: the board's rule buttons, after the
|
||||
sorting number. Off until chosen; the board subscribes to its rules once.
|
||||
if $eq field "cardButtons"
|
||||
if currentBoard.allowsCardButtonsOnMinicard
|
||||
+minicardCardButtons(_id=_id boardId=boardId)
|
||||
unless $eq currentBoard.allowsParentCardOnMinicard false
|
||||
if $eq 'subtext-with-full-path' currentBoard.presentParentTask
|
||||
.parent-subtext
|
||||
|
||||
@@ -27,12 +27,37 @@ import {
|
||||
hiddenMinicardLabelText,
|
||||
toggleMinicardLabelText,
|
||||
} from '/client/lib/minicardLabelText';
|
||||
const {
|
||||
orderedMinicardSections,
|
||||
orderedMinicardFieldsOf,
|
||||
} = require('/models/lib/cardFieldOrder');
|
||||
const { applyMinicardOrder } = require('/models/lib/cardFieldOrder');
|
||||
const cardSettingsSections = require('/models/lib/cardSettingsSections');
|
||||
const { minicardGroups } = require('/models/lib/minicardGroups');
|
||||
const { isCardFieldHiddenOnBoard } = require('/models/lib/cardFieldVisibility');
|
||||
|
||||
// The board as every minicard of it sees it (models/lib/cardShownFields.js
|
||||
// boardForMinicard): the rows of a Card Settings section hidden whole on Show
|
||||
// on Minicard read false. Every helper below that decides whether the minicard
|
||||
// draws a field reads this, not the board itself; Card Settings reads the
|
||||
// board, so its checkboxes keep showing each row's own flag.
|
||||
const { boardForMinicard, hidesRow } = require('/models/lib/cardShownFields');
|
||||
|
||||
function minicardBoardOf(data) {
|
||||
const boardId = (data && (data.boardId || (data.card && data.card.boardId))) || null;
|
||||
const board = boardId ? ReactiveCache.getBoard(boardId) : Utils.getCurrentBoard();
|
||||
return boardForMinicard(board || Utils.getCurrentBoard());
|
||||
}
|
||||
|
||||
// The minicard and its sub-templates read `currentBoard.allowsXOnMinicard`;
|
||||
// this template-level helper answers before the global one, as the opened
|
||||
// card's CARD_FIELD_TEMPLATES do (client/components/cards/cardAddFields.js).
|
||||
export const MINICARD_FIELD_TEMPLATES = Object.freeze(['minicard', 'minicardChecklist']);
|
||||
MINICARD_FIELD_TEMPLATES.forEach(name => {
|
||||
Template[name].helpers({
|
||||
currentBoard() {
|
||||
const tpl = Template.instance();
|
||||
return minicardBoardOf(tpl && tpl.data);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
function getMinicardFlag(board, onMinicardField, legacyField, defaultValue) {
|
||||
if (!board) return false;
|
||||
if (board[onMinicardField] !== null && board[onMinicardField] !== undefined) {
|
||||
@@ -61,34 +86,32 @@ Template.minicard.helpers({
|
||||
return commentBadgeTitle(this.comments(), count => TAPi18n.__('card-comments-title', count));
|
||||
},
|
||||
// #1591: the whole-minicard fold, same shape as a list's collapsed() helper.
|
||||
showCollapseControl() { return ReactiveCache.getBoard(this.boardId)?.allowsMinicardCollapse !== false; },
|
||||
showCollapseControl() { return minicardBoardOf(this)?.allowsMinicardCollapse !== false; },
|
||||
showLabelsAboveTitle() {
|
||||
const board = ReactiveCache.getBoard(this.boardId);
|
||||
const board = minicardBoardOf(this);
|
||||
return board?.labelsAboveTitleOnMinicard === true && (board.allowsMinicardCollapse === false || !Utils.getCardCollapseState(this));
|
||||
},
|
||||
labelsAboveTitle() { return ReactiveCache.getBoard(this.boardId)?.labelsAboveTitleOnMinicard === true; },
|
||||
labelsAboveTitle() { return minicardBoardOf(this)?.labelsAboveTitleOnMinicard === true; },
|
||||
minicardCollapsed() {
|
||||
return ReactiveCache.getBoard(this.boardId)?.allowsMinicardCollapse !== false && Utils.getCardCollapseState(this);
|
||||
return minicardBoardOf(this)?.allowsMinicardCollapse !== false && Utils.getCardCollapseState(this);
|
||||
},
|
||||
// The minicard's field order from Board Settings / Card's "Show on
|
||||
// Minicard" column (board.minicardFieldOrder): the sections top to bottom,
|
||||
// and the fields inside the two grouped ones - the `.dates` line and the
|
||||
// `.badges` strip. A board that never set it gets the historical order.
|
||||
// models/lib/cardFieldOrder.js
|
||||
orderedMinicardSections() {
|
||||
const board = this.board();
|
||||
return orderedMinicardSections(board?.minicardFieldOrder);
|
||||
},
|
||||
orderedMinicardDates() {
|
||||
const board = this.board();
|
||||
return orderedMinicardFieldsOf(board?.minicardFieldOrder, 'dates');
|
||||
},
|
||||
orderedMinicardBadges() {
|
||||
const board = this.board();
|
||||
return orderedMinicardFieldsOf(board?.minicardFieldOrder, 'badges');
|
||||
// Card Settings' sections in the board's Show on Minicard section order
|
||||
// (minicardSettingsSectionOrder), less those hidden whole on Show on
|
||||
// Minicard, each section's fields in its Show on Minicard row order
|
||||
// (board.minicardFieldOrder, models/lib/cardFieldOrder.js) - grouped into
|
||||
// the `.dates` line, the `.badges` strips and blocks
|
||||
// (models/lib/minicardGroups.js). The raw board for the orders, the
|
||||
// minicard's view of it for nothing here: the gates are in the template.
|
||||
minicardGroups() {
|
||||
const boardId = this && this.boardId;
|
||||
const board = (boardId && ReactiveCache.getBoard(boardId)) || Utils.getCurrentBoard();
|
||||
const hidden = cardSettingsSections.hiddenSectionsOf(board, 'minicard');
|
||||
const flat = applyMinicardOrder(board?.minicardFieldOrder);
|
||||
const ids = cardSettingsSections.sectionOrderOf(board, 'minicard').filter(id => !hidden.includes(id));
|
||||
return minicardGroups(ids, id => cardSettingsSections.sectionFieldsOn('minicard', flat, id));
|
||||
},
|
||||
showCustomFieldsOnMinicard() {
|
||||
const board = this.board();
|
||||
const board = minicardBoardOf(this);
|
||||
return board?.allowsCustomFieldsOnMinicard === true;
|
||||
},
|
||||
// #3078: highlight the minicard when it has comments the current user has
|
||||
@@ -118,7 +141,7 @@ Template.minicard.helpers({
|
||||
const card = this;
|
||||
if (!card || !card._id) return false;
|
||||
// Unread comments, Show on Minicard: on unless turned off.
|
||||
if (Utils.getCurrentBoard()?.allowsUnreadCommentsOnMinicard === false) return false;
|
||||
if (minicardBoardOf(this)?.allowsUnreadCommentsOnMinicard === false) return false;
|
||||
const comments = ReactiveCache.getCardComments({ cardId: card._id }) || [];
|
||||
if (!comments.length) return false;
|
||||
// #6745: only this card's own last-viewed time, so opening ANOTHER card
|
||||
@@ -177,6 +200,12 @@ Template.minicard.helpers({
|
||||
if (days >= d1) return 'minicard-aging-1';
|
||||
return '';
|
||||
},
|
||||
// #6811: called inside `each customFieldsWD`, so `this` is the custom-field
|
||||
// row: drawn on the minicard only when it holds a value.
|
||||
minicardCustomFieldHasValue() {
|
||||
const { hasCustomFieldValue } = require('/models/lib/customFieldsWD');
|
||||
return hasCustomFieldValue(this && this.value);
|
||||
},
|
||||
formattedCurrencyCustomFieldValue(definition) {
|
||||
// This helper is called from `each customFieldsWD`, so `this` is already
|
||||
// the rendered custom-field row, not the Card. Calling this.customFieldsWD
|
||||
@@ -222,7 +251,7 @@ Template.minicard.helpers({
|
||||
|
||||
showCreatorOnMinicard() {
|
||||
// cache "board" to reduce the mini-mongodb access
|
||||
const board = this.board();
|
||||
const board = minicardBoardOf(this);
|
||||
let ret = false;
|
||||
if (board) {
|
||||
ret = board.allowsCreatorOnMinicard ?? false;
|
||||
@@ -234,54 +263,54 @@ Template.minicard.helpers({
|
||||
},
|
||||
|
||||
showMembers() {
|
||||
const board = this.board();
|
||||
const board = minicardBoardOf(this);
|
||||
return getMinicardFlag(board, 'allowsMembersOnMinicard', 'allowsMembers', true);
|
||||
},
|
||||
|
||||
showAssignee() {
|
||||
const board = this.board();
|
||||
const board = minicardBoardOf(this);
|
||||
return getMinicardFlag(board, 'allowsAssigneeOnMinicard', 'allowsAssignee', true);
|
||||
},
|
||||
showReceived() {
|
||||
const board = this.board();
|
||||
const board = minicardBoardOf(this);
|
||||
return getMinicardFlag(board, 'allowsReceivedDateOnMinicard', 'allowsReceivedDate', true);
|
||||
},
|
||||
showStart() {
|
||||
const board = this.board();
|
||||
const board = minicardBoardOf(this);
|
||||
return getMinicardFlag(board, 'allowsStartDateOnMinicard', 'allowsStartDate', true);
|
||||
},
|
||||
showDue() {
|
||||
const board = this.board();
|
||||
const board = minicardBoardOf(this);
|
||||
return getMinicardFlag(board, 'allowsDueDateOnMinicard', 'allowsDueDate', true);
|
||||
},
|
||||
showEnd() {
|
||||
const board = this.board();
|
||||
const board = minicardBoardOf(this);
|
||||
return getMinicardFlag(board, 'allowsEndDateOnMinicard', 'allowsEndDate', true);
|
||||
},
|
||||
// #2530: accumulated spent-time badge on the minicard. Defaults to true
|
||||
// (models/boards.js) since it was already shown unconditionally; this only
|
||||
// lets an admin turn it off via Card Settings.
|
||||
showSpentTime() {
|
||||
const board = this.board();
|
||||
const board = minicardBoardOf(this);
|
||||
return getMinicardFlag(board, 'allowsSpentTimeOnMinicard', 'allowsSpentTime', true);
|
||||
},
|
||||
showLabels() {
|
||||
const board = this.board();
|
||||
const board = minicardBoardOf(this);
|
||||
return getMinicardFlag(board, 'allowsLabelsOnMinicard', 'allowsLabels', true);
|
||||
},
|
||||
showCardNumber() {
|
||||
const board = this.board();
|
||||
const board = minicardBoardOf(this);
|
||||
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();
|
||||
const board = minicardBoardOf(this);
|
||||
return getMinicardFlag(board, 'allowsDueCompleteOnMinicard', null, false);
|
||||
},
|
||||
showSubtasks() {
|
||||
const board = this.board();
|
||||
const board = minicardBoardOf(this);
|
||||
return getMinicardFlag(board, 'allowsSubtasksOnMinicard', 'allowsSubtasks', true);
|
||||
},
|
||||
// #6688: the minicard badges that had no Board Settings / Card toggle.
|
||||
@@ -290,23 +319,23 @@ Template.minicard.helpers({
|
||||
// flag is NOT a fallback here - hiding the Dependencies section of the
|
||||
// opened card is a different decision from hiding its badge.
|
||||
showDependenciesOnMinicard() {
|
||||
const board = this.board();
|
||||
const board = minicardBoardOf(this);
|
||||
return getMinicardFlag(board, 'allowsDependenciesOnMinicard', null, true);
|
||||
},
|
||||
showStickersOnMinicard() {
|
||||
const board = this.board();
|
||||
const board = minicardBoardOf(this);
|
||||
return getMinicardFlag(board, 'allowsStickersOnMinicard', null, true);
|
||||
},
|
||||
showCommentCountOnMinicard() {
|
||||
const board = this.board();
|
||||
const board = minicardBoardOf(this);
|
||||
return getMinicardFlag(board, 'allowsCommentCountOnMinicard', null, true);
|
||||
},
|
||||
showVoteOnMinicard() {
|
||||
const board = this.board();
|
||||
const board = minicardBoardOf(this);
|
||||
return getMinicardFlag(board, 'allowsVoteOnMinicard', null, true);
|
||||
},
|
||||
showPokerOnMinicard() {
|
||||
const board = this.board();
|
||||
const board = minicardBoardOf(this);
|
||||
return getMinicardFlag(board, 'allowsPokerOnMinicard', null, true);
|
||||
},
|
||||
// #6431: compact checklist item-count badge (finished/total) on the minicard.
|
||||
@@ -314,7 +343,7 @@ Template.minicard.helpers({
|
||||
// counting (checklistFinishedCount/checklistItemCount) is gated behind this in
|
||||
// the template, so a board with the badge off spends no resources counting.
|
||||
showChecklistCountBadge() {
|
||||
const board = this.board();
|
||||
const board = minicardBoardOf(this);
|
||||
return !!(board && board.allowsChecklistCountBadgeOnMinicard);
|
||||
},
|
||||
// #4285: show a card's comments directly on the minicard. Opt-in and OFF by
|
||||
@@ -323,7 +352,7 @@ Template.minicard.helpers({
|
||||
// built (and the comment badge's `comments` helper already runs
|
||||
// unconditionally for the count badge) when this returns true.
|
||||
showCommentsOnMinicard() {
|
||||
const board = this.board();
|
||||
const board = minicardBoardOf(this);
|
||||
return !!(board && board.allowsCommentsOnMinicard);
|
||||
},
|
||||
// Compact, truncated preview of the most recent comments for the minicard.
|
||||
@@ -378,10 +407,10 @@ Template.minicard.helpers({
|
||||
// as it was on every board before it had a row.
|
||||
// Card color, Show on Minicard: on unless turned off.
|
||||
showCardColorOnMinicard() {
|
||||
return Utils.getCurrentBoard()?.allowsCardColorOnMinicard !== false;
|
||||
return minicardBoardOf(this)?.allowsCardColorOnMinicard !== false;
|
||||
},
|
||||
showIssueTypeOnMinicard() {
|
||||
return Utils.getCurrentBoard()?.allowsIssueTypeOnMinicard !== false;
|
||||
return minicardBoardOf(this)?.allowsIssueTypeOnMinicard !== false;
|
||||
},
|
||||
cover() {
|
||||
// #5666: for a linked card the cover lives on the real card it points at, so
|
||||
@@ -423,12 +452,14 @@ Template.minicard.helpers({
|
||||
// Show list name if either:
|
||||
// 1. Board-wide setting is enabled, OR
|
||||
// 2. This specific card has the setting enabled
|
||||
const currentBoard = this.board();
|
||||
const currentBoard = minicardBoardOf(this);
|
||||
if (!currentBoard) return false;
|
||||
if (currentBoard.allowsShowListsOnMinicard) return true;
|
||||
// The card's own List title, unless unticked in Admin Panel / Settings /
|
||||
// Visibility / Features (models/lib/cardFieldVisibility.js).
|
||||
return !isCardFieldHiddenOnBoard(currentBoard, 'listTitle') && Boolean(this.getRealCard().showListOnMinicard);
|
||||
// ...and unless its section (Card) is hidden whole on Show on Minicard.
|
||||
return !isCardFieldHiddenOnBoard(currentBoard, 'listTitle') && !hidesRow(currentBoard, 'minicard', 'listTitle')
|
||||
&& Boolean(this.getRealCard().showListOnMinicard);
|
||||
},
|
||||
|
||||
// #2426: the swimlane a card belongs to isn't otherwise visible on its
|
||||
@@ -436,7 +467,7 @@ Template.minicard.helpers({
|
||||
// swimlane), so this board-wide toggle shows the swimlane's name at the
|
||||
// bottom of the minicard, the same place/style as the list name above.
|
||||
shouldShowSwimlaneNameOnMinicard() {
|
||||
const currentBoard = this.board();
|
||||
const currentBoard = minicardBoardOf(this);
|
||||
if (!currentBoard) return false;
|
||||
return currentBoard.allowsSwimlaneNameOnMinicard === true;
|
||||
},
|
||||
@@ -449,8 +480,11 @@ Template.minicard.helpers({
|
||||
|
||||
shouldShowChecklistAtMinicard() {
|
||||
// Return checklists that should be shown on minicard
|
||||
const currentBoard = this.board();
|
||||
const currentBoard = minicardBoardOf(this);
|
||||
if (!currentBoard) return [];
|
||||
// A checklist's own "Show at minicard" overrides the board's default, so
|
||||
// the Checklists section hidden whole on Show on Minicard is asked first.
|
||||
if (hidesRow(currentBoard, 'minicard', 'checklists')) return [];
|
||||
|
||||
const checklists = this.checklists();
|
||||
const visibleChecklists = [];
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
//- Similar cards and possible duplicates (docs/Features/Cards/Similar-Cards.md).
|
||||
//- `similarCards` is the opened card's section, `similarCardsHint` the add-card
|
||||
//- `cardSimilarCardsSection` is the opened card's section, `similarCardsHint` the add-card
|
||||
//- form's line under the title. The ranking is the server's (cards.similar), so
|
||||
//- neither lists a card the reader may not see.
|
||||
template(name="similarCards")
|
||||
.similar-cards.js-similar-cards
|
||||
if loading
|
||||
span.quiet {{_ 'loading'}}
|
||||
else
|
||||
if results.length
|
||||
//- The opened card's section: only when there are similar cards, with how many.
|
||||
template(name="cardSimilarCardsSection")
|
||||
if results.length
|
||||
+cardSectionHeader(section="similar-cards" icon="fa-clone" label="similar-cards" count=results.length)
|
||||
if isSectionOpen "similar-cards"
|
||||
.similar-cards.js-similar-cards
|
||||
ul.similar-cards-list
|
||||
each results
|
||||
+similarCardRow
|
||||
else
|
||||
span.quiet {{_ 'similar-cards-none'}}
|
||||
|
||||
template(name="similarCardsHint")
|
||||
if results.length
|
||||
|
||||
@@ -26,7 +26,7 @@ Template.similarCardRow.events({
|
||||
});
|
||||
|
||||
// The opened card: asked again when its title or description changes.
|
||||
Template.similarCards.onCreated(function () {
|
||||
function askForSimilarCards() {
|
||||
this.results = new ReactiveVar([]);
|
||||
this.loading = new ReactiveVar(true);
|
||||
this.autorun(() => {
|
||||
@@ -45,16 +45,23 @@ Template.similarCards.onCreated(function () {
|
||||
});
|
||||
}, this.signature === undefined ? 0 : WAIT_MS);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Template.similarCards.onDestroyed(function () {
|
||||
function stopAsking() {
|
||||
clearTimeout(this.timer);
|
||||
});
|
||||
}
|
||||
|
||||
Template.similarCards.helpers({
|
||||
const similarHelpers = {
|
||||
results() { return Template.instance().results.get(); },
|
||||
loading() { return Template.instance().loading.get(); },
|
||||
});
|
||||
};
|
||||
|
||||
// The opened card's Similar cards SECTION (docs/Features/Cards/Card-Fields.md):
|
||||
// asked when the card opens, and drawn - heading and all - only when there
|
||||
// are similar cards. A card with none has no section to look at.
|
||||
Template.cardSimilarCardsSection.onCreated(askForSimilarCards);
|
||||
Template.cardSimilarCardsSection.onDestroyed(stopAsking);
|
||||
Template.cardSimilarCardsSection.helpers(similarHelpers);
|
||||
|
||||
// The add-card form: asked when typing pauses, for the one title being typed.
|
||||
Template.similarCardsHint.onCreated(function () {
|
||||
|
||||
@@ -28,4 +28,4 @@ template(name="cardWhiteboard")
|
||||
svg.card-whiteboard-surface.js-whiteboard-surface(viewBox="0 0 {{width}} {{height}}" preserveAspectRatio="xMidYMid meet" class="{{#if canDraw}}can-draw{{/if}}")
|
||||
rect(width=width height=height fill="#ffffff")
|
||||
each strokes
|
||||
path(d=pathD stroke=color stroke-width=width fill="none" stroke-linecap="round" stroke-linejoin="round")
|
||||
path(d=pathD stroke=color stroke-width=strokeWidth fill="none" stroke-linecap="round" stroke-linejoin="round")
|
||||
@@ -116,7 +116,11 @@ Template.cardWhiteboard.helpers({
|
||||
const current = tpl.current.get();
|
||||
return [...saved, ...pending, ...(current ? [current] : [])]
|
||||
.filter(s => s && Array.isArray(s.points) && s.points.length)
|
||||
.map(s => ({ color: s.color, width: s.width, pathD: pathD(s.points) }));
|
||||
// strokeWidth, not width: inside `each strokes` Spacebars looks a name up
|
||||
// among this template's helpers before the stroke's own fields, and the
|
||||
// width helper is the drawing's (800) - every line was drawn 800 wide and
|
||||
// covered the whiteboard.
|
||||
.map(s => ({ color: s.color, strokeWidth: s.width, pathD: pathD(s.points) }));
|
||||
},
|
||||
saving() { return Template.instance().saving.get(); },
|
||||
message() { return Template.instance().message.get(); },
|
||||
@@ -165,7 +169,7 @@ Template.cardWhiteboard.events({
|
||||
if (!t || !card || !drawing) return;
|
||||
tpl.saving.set(true);
|
||||
try {
|
||||
const file = await drawingPngFile(card, d.cleanStrokes(drawing.strokes, size(drawing)));
|
||||
const file = await drawingPngFile(card, d.cleanStrokes(drawing.strokes, size(drawing)), size(drawing));
|
||||
const attachmentId = await uploadDrawingPng(card, file);
|
||||
await Meteor.callAsync('cards.whiteboardSetPicture', t.cardId, t.drawingId, attachmentId);
|
||||
tpl.message.set(TAPi18n.__('card-drawing-saved'));
|
||||
|
||||
@@ -102,6 +102,11 @@
|
||||
padding-inline: 4px;
|
||||
}
|
||||
|
||||
/* #6838: a word, not an arrow - it may be wider than the arrows beside it. */
|
||||
.selected-calendar-heading .js-calendar-today {
|
||||
white-space: nowrap;
|
||||
padding-inline: 6px;
|
||||
}
|
||||
.selected-calendar-heading .selected-calendar-date {
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
@@ -22,6 +22,9 @@ template(name="calendarDateInput")
|
||||
| {{monthLabel}}
|
||||
if inline
|
||||
span.selected-calendar-date {{headingDateText}}
|
||||
//- #6838: back to today's month from wherever the arrows went. It only
|
||||
//- shows the month; the date stays as it is until a day is clicked.
|
||||
button.primary.js-calendar-today(type="button" aria-label="{{_ 'today'}}") {{_ 'today'}}
|
||||
button.primary.js-calendar-next(type="button" aria-label="{{_ 'next'}} {{_ 'month'}}") ›
|
||||
button.primary.js-calendar-year-next(type="button" aria-label="{{_ 'next'}} {{_ 'predicate-year'}}") »
|
||||
table
|
||||
@@ -34,5 +37,5 @@ template(name="calendarDateInput")
|
||||
tr
|
||||
each days
|
||||
td(class="{{#if this.today}}is-today-cell{{/if}}")
|
||||
if this
|
||||
unless this.empty
|
||||
button.primary.js-calendar-day(type="button" class="{{#if today}}is-today{{/if}}" data-date=iso title=title aria-label=title aria-pressed=selected aria-current=ariaCurrent tabindex=tabIndex) {{label}}
|
||||
@@ -86,9 +86,17 @@ Template.calendarDateInput.helpers({
|
||||
// the browser's local day, the same local calendar day formatDate() gives
|
||||
// every other cell, so it is the cell the user means by "today".
|
||||
const todayIso = formatDate(new Date());
|
||||
// #6838: every row and every cell - an empty one too - has its own _id.
|
||||
// Blaze matches list items by _id; without one, going from one month to
|
||||
// another reused cells by position and the empty cells, all alike, were
|
||||
// moved around: October came back as "29, empty, 30, empty", without the
|
||||
// 31st. The month is part of each id, so a new month is new cells.
|
||||
const month = days.find(Boolean);
|
||||
const monthId = month ? formatDate(month) : 'empty';
|
||||
const rows = [];
|
||||
for (let index = 0; index < days.length; index += 7) {
|
||||
rows.push({ days: days.slice(index, index + 7).map(date => date && ({
|
||||
rows.push({ _id: `${monthId}-week-${index / 7}`, days: days.slice(index, index + 7).map((date, cell) => (date ? {
|
||||
_id: formatDate(date),
|
||||
iso: formatDate(date),
|
||||
today: formatDate(date) === todayIso,
|
||||
// null omits the attribute: only today's button carries aria-current.
|
||||
@@ -97,7 +105,7 @@ Template.calendarDateInput.helpers({
|
||||
title: formatDateForDisplay(date, false),
|
||||
selected: tpl.selectedDate.get() && formatDate(tpl.selectedDate.get()) === formatDate(date),
|
||||
tabIndex: focusDate && formatDate(focusDate) === formatDate(date) ? 0 : -1,
|
||||
})) });
|
||||
} : { _id: `${monthId}-empty-${index + cell}`, empty: true })) });
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
@@ -121,6 +129,11 @@ Template.calendarDateInput.events({
|
||||
event.preventDefault();
|
||||
tpl.shownDate.set(calendarMonthRange(tpl.shownDate.get(), selectedSystem().intl).end);
|
||||
},
|
||||
'click .js-calendar-today'(event, tpl) {
|
||||
event.preventDefault();
|
||||
tpl.shownDate.set(new Date());
|
||||
Tracker.afterFlush(() => tpl.find('.js-calendar-day.is-today')?.focus());
|
||||
},
|
||||
'click .js-calendar-year-previous'(event, tpl) { event.preventDefault(); tpl.shownDate.set(shiftCalendarYear(tpl.shownDate.get(), -1, selectedSystem().intl)); },
|
||||
'click .js-calendar-year-next'(event, tpl) { event.preventDefault(); tpl.shownDate.set(shiftCalendarYear(tpl.shownDate.get(), 1, selectedSystem().intl)); },
|
||||
'click .js-calendar-day'(event, tpl) {
|
||||
|
||||
@@ -385,12 +385,20 @@ form.inlined-form .custom-field-copy-control > a.fa-copy,
|
||||
templates. */
|
||||
flex: none;
|
||||
}
|
||||
/* #6786: the tick is physical geometry - a box narrowed from 13px to 7px and
|
||||
rotated 40deg about its bottom-right corner, drawn with its bottom and right
|
||||
borders - so where it sits is physical too. With `inset-inline-start` and
|
||||
`margin-inline-end` a right-to-left page started the narrowed box at the
|
||||
RIGHT edge of the square and pushed it further right: the tick sat beside
|
||||
its box, "out of box". `left: -3px` and `margin-right: 6px` put it on the
|
||||
square's left edge in both directions, and 7 + 4 + 6 = 17px is still the
|
||||
square's width, so the label beside it does not move. */
|
||||
.materialCheckBox.is-checked {
|
||||
top: -4px;
|
||||
inset-inline-start: -3px;
|
||||
left: -3px;
|
||||
width: 7px;
|
||||
height: 15px;
|
||||
margin-inline-end: 6px;
|
||||
margin-right: 6px;
|
||||
border-top: 2px solid transparent;
|
||||
border-left: 2px solid transparent;
|
||||
border-bottom: 2px solid #3cb500;
|
||||
|
||||
@@ -61,6 +61,11 @@ Template.titleDueHint.onRendered(function () {
|
||||
const parsed = text.includes('\n') ? null : dueFromTitle(form, text, field);
|
||||
tpl.due.set(parsed ? formatDue(parsed.dueAt) : '');
|
||||
};
|
||||
// Not only on typing: the board's switch (Board Settings / Card, Admin
|
||||
// Panel / Visibility) and the language can change while the form is open,
|
||||
// and the hint follows them - a switch turned off takes away a hint already
|
||||
// shown, as it takes away the due date the submit would set.
|
||||
tpl.autorun(() => tpl.update());
|
||||
tpl.listener = event => {
|
||||
if (event.target && event.target.matches && event.target.matches(selector)) tpl.update();
|
||||
};
|
||||
|
||||
Vendored
+14
-4
@@ -212,7 +212,7 @@ a.show-mapping {
|
||||
`a.flex > .materialCheckBox + span`, and no geometry of its own. That matters
|
||||
more than it looks: `.materialCheckBox.is-checked` becomes the tick by
|
||||
turning two borders transparent, rotating 40deg and shifting itself
|
||||
(`top: -4px; inset-inline-start: -3px; margin-inline-end: 6px`) - offsets
|
||||
(`top: -4px; left: -3px; margin-right: 6px`) - offsets
|
||||
written for the plain flow `.flex` gives it. An `align-items: center` and a
|
||||
`gap` on top of that fight those offsets, and the tick lands on the first
|
||||
word of the label, which is what this page did before.
|
||||
@@ -236,8 +236,8 @@ a.show-mapping {
|
||||
/* A space between the box and its label, in BOTH states.
|
||||
`.materialCheckBox` is 13px plus a 2px border on each side, and carries no
|
||||
margin, so an unchecked row's text starts right against the box. Checked, it
|
||||
is a different shape entirely - 7px wide, shifted `inset-inline-start: -3px`,
|
||||
with `margin-inline-end: 6px` - so its text starts 8px + 6px in.
|
||||
is a different shape entirely - 7px wide, shifted `left: -3px`,
|
||||
with `margin-right: 6px` - so its text starts 8px + 6px in.
|
||||
Lining the two up is arithmetic: 17 + 8 = 25 for the unchecked box, and
|
||||
8 + 17 = 25 for the checked one, so a row's label sits in the same place
|
||||
whether it is ticked or not and ticking one no longer nudges its text. */
|
||||
@@ -248,7 +248,17 @@ a.show-mapping {
|
||||
|
||||
.import-page .import-source-list a.flex .materialCheckBox.is-checked,
|
||||
.import-page .import-part-list a.flex .materialCheckBox.is-checked {
|
||||
margin-inline-end: 17px;
|
||||
margin-right: 17px;
|
||||
}
|
||||
|
||||
/* #6786: the tick is placed physically (forms.css), but the gap before the
|
||||
label is the row's, so right-to-left it is on the left: the tick keeps its 6px
|
||||
on the right - which puts it on the square's left edge, as left-to-right - and
|
||||
the other 11px of the 17 are the gap. */
|
||||
[dir="rtl"] .import-page .import-source-list a.flex .materialCheckBox.is-checked,
|
||||
[dir="rtl"] .import-page .import-part-list a.flex .materialCheckBox.is-checked {
|
||||
margin-right: 6px;
|
||||
margin-left: 11px;
|
||||
}
|
||||
|
||||
/* The loss report replaces the page until the board is opened. */
|
||||
|
||||
@@ -783,6 +783,7 @@ Template.list.onCreated(function () {
|
||||
let isResizing = false;
|
||||
let startX = 0;
|
||||
let startWidth = 0;
|
||||
let inlineDirection = 1;
|
||||
let minWidth = MIN_LIST_WIDTH; // Minimum width matching system default
|
||||
|
||||
// Read the horizontal page coordinate from either a jQuery mouse event
|
||||
@@ -800,6 +801,13 @@ Template.list.onCreated(function () {
|
||||
isResizing = true;
|
||||
startX = getEventPageX(e);
|
||||
startWidth = $list.outerWidth();
|
||||
// #6801: the handle is on the list's inline END (list.css), which is its
|
||||
// LEFT edge in a right-to-left language. Dragging it left widens the list
|
||||
// there, so the pointer's movement counts the other way round - the list
|
||||
// narrowed when dragged out and widened when dragged in. The list's own
|
||||
// direction decides, because that is what places the handle.
|
||||
inlineDirection =
|
||||
window.getComputedStyle($list[0]).direction === 'rtl' ? -1 : 1;
|
||||
|
||||
// Add visual feedback
|
||||
$list.addClass('list-resizing');
|
||||
@@ -818,7 +826,7 @@ Template.list.onCreated(function () {
|
||||
}
|
||||
|
||||
const currentX = getEventPageX(e);
|
||||
const deltaX = currentX - startX;
|
||||
const deltaX = (currentX - startX) * inlineDirection;
|
||||
const newWidth = Math.max(minWidth, startWidth + deltaX);
|
||||
|
||||
// Apply the new width immediately for real-time feedback
|
||||
@@ -842,7 +850,7 @@ Template.list.onCreated(function () {
|
||||
|
||||
// Calculate final width
|
||||
const currentX = getEventPageX(e);
|
||||
const deltaX = currentX - startX;
|
||||
const deltaX = (currentX - startX) * inlineDirection;
|
||||
const finalWidth = Math.max(minWidth, startWidth + deltaX);
|
||||
|
||||
// Ensure the final width is applied
|
||||
|
||||
@@ -93,6 +93,14 @@ template(name="addCardForm")
|
||||
|
|
||||
| /
|
||||
a.js-card-template {{_ 'template'}}
|
||||
//- #6802: Search / Template / Drawing / More options - while the text adds
|
||||
ONE card, Drawing adds it and draws on it at once, on the whole window,
|
||||
saved as an SVG attachment of the new card.
|
||||
if singleCardTitle
|
||||
span.quiet
|
||||
|
|
||||
| /
|
||||
a.add-card-drawing.js-add-card-drawing(href="#" role="button" title="{{_ 'cardDrawingPopup-title'}}") {{_ 'cardDrawingPopup-title'}}
|
||||
span.quiet
|
||||
|
|
||||
| /
|
||||
|
||||
@@ -2,6 +2,7 @@ import { isSubmitKey } from '/models/lib/editorSubmitKey';
|
||||
import { titleSortIndexes } from '/models/lib/multilineTitles';
|
||||
import { titleTagText } from '/models/lib/titleTags';
|
||||
import { titlesFromComposer } from '/client/components/forms/multilineTitleChoice';
|
||||
import { openNewDrawingWhenOpen } from '/client/lib/newDrawing';
|
||||
import { dueFromTitle, resetDueFromTitle } from '/client/components/forms/titleDueHint';
|
||||
import '/client/components/cards/similarCards';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
@@ -25,7 +26,7 @@ import { sortCardsByVotes } from '/models/lib/voteSortCards';
|
||||
import { labelMatchesTerm } from '/models/lib/labelAutocomplete';
|
||||
import { memberMatchesTerm } from '/models/lib/memberAutocomplete';
|
||||
import {
|
||||
parseQuickAddCardLabel,
|
||||
quickAddTitleParts,
|
||||
findExistingLabelIdByName,
|
||||
pickDefaultLabelColor,
|
||||
} from '/models/lib/quickAddCardLabel';
|
||||
@@ -178,6 +179,19 @@ Template.listBody.onCreated(function () {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// #6827: Member Settings' "Keep the Add card form open" - off unless the
|
||||
// user turned it on, so the form closes once its cards are added.
|
||||
const keepAddCardFormOpen = () => {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
return Boolean(user && typeof user.hasKeepAddCardFormOpen === 'function' && user.hasKeepAddCardFormOpen());
|
||||
};
|
||||
|
||||
// #6810: Member Settings' "[Text] at the start of a new card title becomes
|
||||
// a label" - off unless the user turned it on.
|
||||
const titleBracketLabelOn = () => {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
return Boolean(user && typeof user.hasTitleBracketLabel === 'function' && user.hasTitleBracketLabel());
|
||||
};
|
||||
this.addCard = async (evt) => {
|
||||
evt.preventDefault();
|
||||
const firstCardDom = this.find('.js-minicard:first');
|
||||
@@ -190,6 +204,9 @@ Template.listBody.onCreated(function () {
|
||||
const position = Blaze.getData(submittedForm)?.position;
|
||||
if (this.creatingCards) return;
|
||||
const titles = titlesFromComposer(textarea[0]);
|
||||
// #6802: the composer's Drawing button - one card, then its drawing.
|
||||
const drawAfterAdd = submittedForm.dataset.drawAfterAdd === 'true' && titles.length === 1;
|
||||
delete submittedForm.dataset.drawAfterAdd;
|
||||
const indexes = titleSortIndexes(
|
||||
position === 'bottom' && lastCardDom ? Blaze.getData(lastCardDom).sort : null,
|
||||
position === 'top' && firstCardDom ? Blaze.getData(firstCardDom).sort : null,
|
||||
@@ -198,13 +215,17 @@ Template.listBody.onCreated(function () {
|
||||
this.creatingCards = true;
|
||||
textarea[0].disabled = true;
|
||||
let titleIndex = 0;
|
||||
let closeAfterAdd = false;
|
||||
try {
|
||||
for (const rawTitle of titles) {
|
||||
// #3986: a leading "[LabelName] " prefix on the typed title applies (and,
|
||||
// if needed, creates) that label instead of becoming part of the card
|
||||
// title - e.g. "[Fedora] Do a thing" creates "Do a thing" labeled
|
||||
// "Fedora". Only a single bracket prefix at the very start is parsed.
|
||||
const { title: labelledTitle, labelName: quickAddLabelName } = parseQuickAddCardLabel(rawTitle);
|
||||
// #6810: only for who turned it on in Member Settings - off, a title
|
||||
// pasted from a ticket system or a mail ("[EXTERNAL] ...") is kept as
|
||||
// it is and no label is created.
|
||||
const { title: labelledTitle, labelName: quickAddLabelName } = quickAddTitleParts(rawTitle, titleBracketLabelOn());
|
||||
|
||||
const sortIndex = indexes[titleIndex];
|
||||
|
||||
@@ -340,6 +361,10 @@ Template.listBody.onCreated(function () {
|
||||
const _id = await Cards.insertAsync(cardFields);
|
||||
titleIndex += 1;
|
||||
formComponent.saveTitleDraft(titles.slice(titleIndex).join('\n'));
|
||||
if (drawAfterAdd) {
|
||||
Utils.goCardId(_id);
|
||||
openNewDrawingWhenOpen(_id);
|
||||
}
|
||||
|
||||
// if the displayed card count is less than the total cards in the list,
|
||||
// we need to increment the displayed card count to prevent the spinner
|
||||
@@ -357,12 +382,16 @@ Template.listBody.onCreated(function () {
|
||||
// See https://github.com/wekan/wekan/issues/80
|
||||
Filter.addException(_id);
|
||||
|
||||
// We keep the form opened and scroll to it.
|
||||
// Kept open (Member Settings), the form is scrolled to.
|
||||
if (position === 'bottom') {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
}
|
||||
}
|
||||
// #6827: once every title of the form is added, it closes, unless the
|
||||
// user keeps it open to type the next card. Not before: the loop above
|
||||
// still reads the form between the titles of a multi-line paste.
|
||||
closeAfterAdd = titleIndex > 0 && !keepAddCardFormOpen();
|
||||
} catch (error) {
|
||||
textarea.val(titles.slice(titleIndex).join('\n'));
|
||||
autosize.update(textarea);
|
||||
@@ -373,6 +402,10 @@ Template.listBody.onCreated(function () {
|
||||
textarea[0].focus();
|
||||
textarea[0].dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
if (closeAfterAdd) {
|
||||
// The form's own close: it also clears the one-open-form tracker.
|
||||
submittedForm.querySelector('.js-close-inlined-form')?.click();
|
||||
}
|
||||
};
|
||||
|
||||
this.clickOnMiniCard = (evt) => {
|
||||
@@ -891,6 +924,9 @@ Template.addCardForm.onCreated(function () {
|
||||
this.labels = new ReactiveVar([]);
|
||||
this.members = new ReactiveVar([]);
|
||||
this.customFields = new ReactiveVar([]);
|
||||
// #6802: how many cards the composer would add, for its Drawing button.
|
||||
this.titleCount = new ReactiveVar(0);
|
||||
this.countTitles = () => this.titleCount.set(titlesFromComposer(this.find('textarea.js-card-title')).length);
|
||||
// #3967: "More options" — a handful of common fields that can be filled in
|
||||
// before the card is created, so they travel in the SAME Cards.insert call
|
||||
// as the title instead of as separate edits (and separate watcher-
|
||||
@@ -986,9 +1022,23 @@ Template.addCardForm.helpers({
|
||||
isAssignee(userId) {
|
||||
return Template.instance().assignees.get().includes(userId);
|
||||
},
|
||||
singleCardTitle() {
|
||||
return Template.instance().titleCount.get() === 1;
|
||||
},
|
||||
});
|
||||
|
||||
Template.addCardForm.events({
|
||||
'input .js-card-title, change .js-multiline-title-mode'(event, tpl) {
|
||||
tpl.countTitles();
|
||||
},
|
||||
// #6802: the same submit as Add, marked to open the card and its drawing.
|
||||
'click .js-add-card-drawing'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget.closest('form');
|
||||
if (!form || tpl.titleCount.get() !== 1) return;
|
||||
form.dataset.drawAfterAdd = 'true';
|
||||
form.requestSubmit();
|
||||
},
|
||||
'input .js-card-title'(event, tpl) {
|
||||
tpl.titleDraftEdited = true;
|
||||
tpl.titleDraftPending = event.currentTarget.value;
|
||||
@@ -1019,6 +1069,8 @@ Template.addCardForm.events({
|
||||
Template.addCardForm.onRendered(function () {
|
||||
const tpl = this;
|
||||
const $textarea = this.$('textarea');
|
||||
// A draft restored into the composer counts too (#6802 Drawing button).
|
||||
Meteor.defer(() => tpl.countTitles());
|
||||
|
||||
this.autorun(() => {
|
||||
if (!this.subscriptionsReady() || this.titleDraftEdited) return;
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
.board-drag-settings th { text-align: start; }
|
||||
.board-drag-settings th:last-child, .board-drag-settings td:last-child { text-align: center; }
|
||||
.board-drag-settings input[type=checkbox] { width: auto; min-height: 0; margin: 4px; }
|
||||
/* #6820: forms.css gives every input 9px 14px of padding and a border; on a
|
||||
16px native checkbox that left the browser about 2px to draw in, so a ticked
|
||||
box was a small blue dot beside an unticked square. A native checkbox keeps
|
||||
no padding, border or background of a text field. */
|
||||
.board-drag-settings input[type=checkbox] {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
display: inline-block;
|
||||
visibility: visible;
|
||||
position: static;
|
||||
@@ -12,3 +20,6 @@
|
||||
appearance: auto;
|
||||
transform: none;
|
||||
}
|
||||
/* Select all / Unselect all centred under their "Draggable" heading, as the
|
||||
heading and its checkboxes are. */
|
||||
.board-drag-settings th .board-settings-column-actions { justify-content: center; }
|
||||
@@ -6,7 +6,7 @@ import { Markdown } from 'meteor/wekan-markdown';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { memberMatchesTerm } from '/models/lib/memberAutocomplete';
|
||||
import { internalCardPath } from '/models/lib/internalCardLink';
|
||||
import { parseAllowedUrlSchemes, hrefScheme, BUILT_IN } from '/models/lib/urlSchemeAllowlist';
|
||||
import { parseAllowedUrlSchemes, hrefScheme, BUILT_IN, NEVER_LINKED } from '/models/lib/urlSchemeAllowlist';
|
||||
import autosize from 'autosize';
|
||||
var converter = require('@wekanteam/html-to-markdown');
|
||||
|
||||
@@ -348,6 +348,10 @@ Template.viewer.events({
|
||||
const scheme = hrefScheme(href);
|
||||
if (cardPath) {
|
||||
FlowRouter.go(cardPath);
|
||||
} else if (NEVER_LINKED.has(scheme)) {
|
||||
// The sanitizer never lets one of these be a link; should one reach
|
||||
// the page some other way, a click still does not launch its
|
||||
// handler (ms-msdt:, search-ms:, telnet: ...).
|
||||
} else if (scheme && !BUILT_IN.includes(scheme)) {
|
||||
// #3218: an allowed custom scheme (thunderlink:, onenote:, ...) is
|
||||
// handed to the application registered for it by following the
|
||||
|
||||
@@ -7,7 +7,7 @@ template(name="historyRecoveryNotice")
|
||||
.history-recovery-notice.js-history-recovery-notice(role="status" aria-live="polite")
|
||||
p {{_ pending.messageKey}}
|
||||
p.quiet {{_ 'history-request-hint'}}
|
||||
button.primary.js-history-retry(type="button" disabled=busy) {{_ 'history-request-retry'}}
|
||||
button.primary.js-history-retry(type="button" disabled=retryDisabled) {{_ 'history-request-retry'}}
|
||||
button.js-history-forget(type="button" disabled=busy) {{_ 'history-request-forget'}}
|
||||
if checkpoint
|
||||
.history-recovery-notice.history-checkpoint-notice.js-history-checkpoint-notice(role="status" aria-live="polite" class=checkpointPlacement)
|
||||
|
||||
@@ -52,6 +52,13 @@ Template.historyRecoveryNotice.helpers({
|
||||
busy() {
|
||||
return Template.instance().busy.get();
|
||||
},
|
||||
// undoRedoLast does nothing for someone who may not write on the board, and
|
||||
// right after a reload that is everyone until the board and its members
|
||||
// arrive: the notice shows first, and a Retry clicked then was dropped
|
||||
// without a word. It is enabled once the retry can act.
|
||||
retryDisabled() {
|
||||
return Template.instance().busy.get() || !Utils.canModifyBoard();
|
||||
},
|
||||
});
|
||||
|
||||
async function resolve(tpl, action) {
|
||||
|
||||
@@ -693,6 +693,9 @@ a:not(.disabled).is-active i.fa {
|
||||
checkbox in the app. */
|
||||
.viewer input[type="checkbox"]:not(:checked),
|
||||
.viewer input[type="checkbox"]:checked {
|
||||
/* #6820: no text-field padding, or a ticked box is drawn as a dot. */
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
display: inline-block;
|
||||
visibility: visible;
|
||||
position: static;
|
||||
|
||||
@@ -55,7 +55,7 @@ template(name="userFormsLayout")
|
||||
+loader
|
||||
else
|
||||
// ARIA live region for error messages
|
||||
div#login-error-message(role="alert" aria-live="assertive" style="color: #d32f2f; margin-bottom: 1em;")
|
||||
div#login-error-message(role="alert" aria-live="assertive" style="color: #d32f2f; margin-bottom: 1em;")= loginErrorText
|
||||
// Second sign-in step for accounts-2fa (issue #3058). Hidden unless a
|
||||
// password login comes back with the "no-2fa-code" / "invalid-2fa-code"
|
||||
// error - see decideTwoFactorLoginStep() in
|
||||
|
||||
@@ -303,6 +303,7 @@ Template.userFormsLayout.onDestroyed(function () {
|
||||
});
|
||||
|
||||
Template.userFormsLayout.helpers({
|
||||
loginErrorText() { return loginErrorText.get(); },
|
||||
passkeysSupported() { return require('/client/lib/passkeys').passkeysSupported(); },
|
||||
passkeyMessage() { return Template.instance().passkeyMessage.get(); },
|
||||
windowsSsoOffered() { return Meteor.settings?.public?.windowsSsoEnabled === true; },
|
||||
@@ -557,20 +558,23 @@ Tracker.autorun(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// The message in #login-error-message, rendered by the template as text. Not
|
||||
// written into the element: it is inside `if isLoading`, and when that flips
|
||||
// Blaze draws a new, empty element - a SAML popup error shown just before
|
||||
// then was gone (saml-popup-error.e2e.js, Firefox). Text, never HTML.
|
||||
const loginErrorText = new ReactiveVar('');
|
||||
function showLoginError(err) {
|
||||
const errorDiv = document.getElementById('login-error-message');
|
||||
if (!errorDiv) return;
|
||||
if (!err) {
|
||||
errorDiv.textContent = '';
|
||||
loginErrorText.set('');
|
||||
return;
|
||||
}
|
||||
const reason = err.reason || err.message || '';
|
||||
// #2460: an account its member locked to SQRL says so in their language.
|
||||
const translated = ['oauth-account-conflict', 'sqrl-only', 'sqrl-hardlock']
|
||||
.find(key => err.error === key || reason === key);
|
||||
errorDiv.textContent = translated
|
||||
loginErrorText.set(translated
|
||||
? TAPi18n.__(translated)
|
||||
: reason || String(err.error || err);
|
||||
: reason || String(err.error || err));
|
||||
}
|
||||
|
||||
Template.defaultLayout.events({
|
||||
|
||||
@@ -367,20 +367,24 @@
|
||||
max-width: 90vw !important;
|
||||
}
|
||||
|
||||
/* Board Settings / Card: Draggable, Show on Minicard and Show on Card.
|
||||
#6796: as wide as the window allows, up to 1800px - width buys COLUMNS
|
||||
inside each list (sidebar.css), and enough of them means the whole of
|
||||
both lists is read without scrolling. client/lib/popupOffset.js centres
|
||||
it at this width (CARD_SETTINGS_MAX_WIDTH) and gives it the window's
|
||||
height. */
|
||||
/* Board Settings / Card: Card Settings, TWO panes of checklists - the
|
||||
Minicard's and the Card's (sidebar.jade boardCardSettingsPopup) - side by
|
||||
side, each about as wide as the one column was (600px until 2026-10-28,
|
||||
when the two sides became two panes instead of a Show switch).
|
||||
client/lib/popupOffset.js centres it at this width
|
||||
(CARD_SETTINGS_MAX_WIDTH) and gives it the window's height (#6796). */
|
||||
.pop-over[data-popup='boardCardSettingsPopup'] {
|
||||
position: fixed;
|
||||
box-sizing: border-box;
|
||||
width: min(96vw, 1800px) !important;
|
||||
width: min(96vw, 1100px) !important;
|
||||
max-width: 96vw !important;
|
||||
max-height: calc(100dvh - 24px) !important;
|
||||
margin-top: 0;
|
||||
}
|
||||
.pop-over[data-popup='boardCardSettingsPopup'] .content-wrapper,
|
||||
.pop-over[data-popup='boardCardSettingsPopup'] .content-container {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
/* Board Settings / Board View: five columns, four of them headed by a
|
||||
three-word title. Same width as Card Settings for the same reason. */
|
||||
@@ -1432,3 +1436,105 @@ body.mobile-mode .pop-over .content-wrapper {
|
||||
column-span: all;
|
||||
}
|
||||
}
|
||||
|
||||
/* A movable popup's drag handle and a maximizable popup's maximize / minimize
|
||||
button (popup.js MOVABLE_POPUPS / MAXIMIZABLE_POPUPS), placed like the opened
|
||||
card's: the handle before the title, the button beside the close button. */
|
||||
.pop-over .header .popup-drag-handle {
|
||||
float: inline-start;
|
||||
padding: 12px 0 12px 12px;
|
||||
cursor: move;
|
||||
color: #888;
|
||||
}
|
||||
/* #6839: the title bar moves every popup while drag handles are off; with
|
||||
them on, the handle does, and the bar is an ordinary title. */
|
||||
.pop-over .header {
|
||||
cursor: move;
|
||||
}
|
||||
.pop-over .header:has(.popup-drag-handle) {
|
||||
cursor: auto;
|
||||
}
|
||||
.pop-over .header a,
|
||||
.pop-over .header button {
|
||||
cursor: pointer;
|
||||
}
|
||||
.pop-over .header .popup-maximize-btn {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline-end: 40px;
|
||||
padding: 12px 8px;
|
||||
color: inherit;
|
||||
}
|
||||
.pop-over .header:has(.popup-maximize-btn) .header-title {
|
||||
margin-inline-end: 72px;
|
||||
}
|
||||
/* Maximized: the whole window, less a margin, whatever it was dragged or resized
|
||||
to; minimizing restores that (popup.js setPopupMaximized). */
|
||||
.pop-over.pop-over--maximized {
|
||||
position: fixed !important;
|
||||
inset: 8px !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
margin: 0 !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.pop-over.pop-over--maximized .header {
|
||||
cursor: default;
|
||||
}
|
||||
.pop-over.pop-over--maximized .content-wrapper {
|
||||
flex: 1 1 auto;
|
||||
max-height: none;
|
||||
}
|
||||
/* The Drawing popup is as wide as its drawing (cardDrawing.css: min(820px,
|
||||
90vw)) plus the popup's padding. At the default min(380px, 55vw) the drawing
|
||||
overflowed a 380px popup that clipped it - the larger pen sizes and Save were
|
||||
cut off - and the popup's own box never changed, so nothing moved it back
|
||||
inside the window either (popup.js keeps a movable popup inside when its size
|
||||
changes). Not !important: maximized, `width: auto !important` above wins. */
|
||||
.pop-over[data-popup='cardDrawingPopup'] {
|
||||
width: min(860px, calc(100vw - 20px));
|
||||
}
|
||||
/* A maximized drawing takes the room the window has: cardDrawing.js sizes the
|
||||
drawing to its stage and gives the surface that width and height. The rule
|
||||
that kept it at 800 x 500's shape here (min(100%, (100vh - 230px) * 1.6),
|
||||
aspect-ratio 8 / 5) outlived that design and drew a 1212px drawing 784px
|
||||
wide in a 1280 x 720 window (card-drawing-canvas.e2e.js). */
|
||||
.pop-over.pop-over--maximized .card-drawing {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Card Settings' panes scroll on their own (client/components/cards
|
||||
/cardAddFields.css .card-settings-panes-container): the popup's content
|
||||
does not scroll as a whole, it hands its height down to the panes. At every
|
||||
width - stacked, the panes' container is the one scroller. Also after the
|
||||
corner grip resized it (popup.js resizeDatePopup, `data-user-resized`,
|
||||
whose rule above would make the wrapper scroll again). The bottom padding
|
||||
keeps the grip in a strip of its own, under the panes' scrollbars rather
|
||||
than over the Card pane's. */
|
||||
.pop-over[data-popup='boardCardSettingsPopup'] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
.pop-over[data-popup='boardCardSettingsPopup'] .content-wrapper,
|
||||
.pop-over[data-popup='boardCardSettingsPopup'][data-user-resized] > .content-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.pop-over[data-popup='boardCardSettingsPopup'] .content-container,
|
||||
.pop-over[data-popup='boardCardSettingsPopup'] .content-container > .content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.pop-over[data-popup='boardCardSettingsPopup'] .content-container > .clearfix {
|
||||
display: none;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { updatePopupMenuColumns } from '/client/lib/popupMenuLayout';
|
||||
import { CSSEvents } from '/client/lib/cssEvents';
|
||||
import { isMobileViewportNow } from '/client/lib/responsiveUtils';
|
||||
import { trapTabKey } from '/client/lib/accessibility';
|
||||
import { keptInside } from '/client/lib/popupBounds';
|
||||
|
||||
// Anchor the opposite edge while dragging the bottom inline-end corner.
|
||||
function resizeDatePopup(element, width, height) {
|
||||
@@ -27,23 +29,104 @@ function resizeDatePopup(element, width, height) {
|
||||
Object.entries(styles).forEach(([key, value]) => element.style.setProperty(key, value, 'important'));
|
||||
}
|
||||
|
||||
// #6839: EVERY popup is moved like the opened card: by its title bar when drag
|
||||
// handles are off, by the drag handle before its title when they are on - the
|
||||
// handle alone, then, as the card's. These are the popups that may also GROW
|
||||
// past the window while open (Drawing replacing a menu), so they are kept
|
||||
// inside it as they grow; so is any popup the user has dragged.
|
||||
const MOVABLE_POPUPS = new Set(['boardViewSettingsPopup', 'cardDrawingPopup', 'boardCardSettingsPopup']);
|
||||
// Popups with the opened card's maximize / minimize button.
|
||||
const MAXIMIZABLE_POPUPS = new Set(['cardDrawingPopup', 'boardCardSettingsPopup']);
|
||||
|
||||
const maximizedKey = name => `wekan-popup-maximized-${name}`;
|
||||
function rememberedMaximized(name) {
|
||||
try { return window.localStorage.getItem(maximizedKey(name)) === 'true'; } catch (e) { return false; }
|
||||
}
|
||||
function rememberMaximized(name, on) {
|
||||
try { window.localStorage.setItem(maximizedKey(name), on ? 'true' : 'false'); } catch (e) { /* a preference */ }
|
||||
}
|
||||
|
||||
// Maximizing takes the popup out of wherever it was dragged or resized to: the
|
||||
// inline !important coordinates those leave would beat the stylesheet. They are
|
||||
// kept, and minimizing puts the popup back exactly there.
|
||||
function setPopupMaximized(element, on) {
|
||||
if (!element) return;
|
||||
if (on && !element.classList.contains('pop-over--maximized')) {
|
||||
element.dataset.restoreStyle = element.getAttribute('style') || '';
|
||||
['left', 'top', 'right', 'bottom', 'width', 'height', 'max-width', 'max-height', 'min-width', 'min-height',
|
||||
'inset-inline-start', 'inset-inline-end', 'margin', 'position'].forEach(p => element.style.removeProperty(p));
|
||||
element.classList.add('pop-over--maximized');
|
||||
} else if (!on && element.classList.contains('pop-over--maximized')) {
|
||||
element.classList.remove('pop-over--maximized');
|
||||
element.setAttribute('style', element.dataset.restoreStyle || '');
|
||||
delete element.dataset.restoreStyle;
|
||||
}
|
||||
}
|
||||
|
||||
Popup.template.onCreated(function () {
|
||||
this.popupMaximized = new ReactiveVar(false);
|
||||
});
|
||||
|
||||
Popup.template.helpers({
|
||||
popupMovable() {
|
||||
return true;
|
||||
},
|
||||
popupMaximizable() {
|
||||
return MAXIMIZABLE_POPUPS.has(this.popupName);
|
||||
},
|
||||
popupMaximized() {
|
||||
return Template.instance().popupMaximized.get();
|
||||
},
|
||||
});
|
||||
|
||||
Popup.template.events({
|
||||
'click .js-popup-maximize, click .js-popup-minimize'(evt, tpl) {
|
||||
evt.preventDefault(); evt.stopPropagation();
|
||||
const on = evt.currentTarget.classList.contains('js-popup-maximize');
|
||||
setPopupMaximized(evt.currentTarget.closest('.pop-over'), on);
|
||||
tpl.popupMaximized.set(on);
|
||||
rememberMaximized(this.popupName, on);
|
||||
},
|
||||
'pointerdown .header'(evt, tpl) {
|
||||
const element = evt.currentTarget.closest('.pop-over');
|
||||
if (evt.button !== 0 || (!element.querySelector('.edit-date') && element.dataset.popup !== 'boardViewSettingsPopup') ||
|
||||
// A maximized popup has no place to be dragged to.
|
||||
// With drag handles on, the handle is the only thing that moves it.
|
||||
const handle = element.querySelector('.js-popup-drag-handle');
|
||||
if (evt.button !== 0 || element.classList.contains('pop-over--maximized') ||
|
||||
(handle && !evt.target.closest('.js-popup-drag-handle')) ||
|
||||
evt.target.closest('a, button, input, select')) return;
|
||||
evt.preventDefault(); evt.stopPropagation();
|
||||
const bounds = element.getBoundingClientRect();
|
||||
// Pin it where it is before it moves. A popup is placed by its margins, by
|
||||
// inset-inline-end (the member menu, at the right edge) or by left/top, so
|
||||
// `left: bounds.left` alone put some of them elsewhere - one jumped across
|
||||
// the window as the drag began. Set it, see where it went, and correct.
|
||||
const place = (left, top) => {
|
||||
element.style.setProperty('inset-inline-start', 'auto', 'important');
|
||||
element.style.setProperty('inset-inline-end', 'auto', 'important');
|
||||
element.style.setProperty('left', `${left}px`, 'important');
|
||||
element.style.setProperty('top', `${top}px`, 'important');
|
||||
};
|
||||
place(bounds.left, bounds.top);
|
||||
const placed = element.getBoundingClientRect();
|
||||
const left = bounds.left + (bounds.left - placed.left);
|
||||
const top = bounds.top + (bounds.top - placed.top);
|
||||
place(left, top);
|
||||
element.dataset.userMoved = 'true';
|
||||
tpl._dateMove = { element, pointerId: evt.pointerId, x: evt.clientX, y: evt.clientY,
|
||||
left: bounds.left, top: bounds.top, width: bounds.width, height: bounds.height };
|
||||
left, top, width: bounds.width, height: bounds.height,
|
||||
// The clamp keeps the popup's VISIBLE box 12px inside the window.
|
||||
dx: bounds.left - left, dy: bounds.top - top };
|
||||
evt.currentTarget.setPointerCapture(evt.pointerId);
|
||||
},
|
||||
'pointermove .header'(evt, tpl) {
|
||||
const drag = tpl._dateMove;
|
||||
if (!drag || drag.pointerId !== evt.pointerId) return;
|
||||
evt.preventDefault(); evt.stopPropagation();
|
||||
const left = Math.max(12, Math.min(window.innerWidth - drag.width - 12, drag.left + evt.clientX - drag.x));
|
||||
const top = Math.max(12, Math.min(window.innerHeight - drag.height - 12, drag.top + evt.clientY - drag.y));
|
||||
const boxLeft = Math.max(12, Math.min(window.innerWidth - drag.width - 12, drag.left + drag.dx + evt.clientX - drag.x));
|
||||
const boxTop = Math.max(12, Math.min(window.innerHeight - drag.height - 12, drag.top + drag.dy + evt.clientY - drag.y));
|
||||
const left = boxLeft - drag.dx;
|
||||
const top = boxTop - drag.dy;
|
||||
// Dragging uses physical pointer coordinates in either text direction.
|
||||
drag.element.style.setProperty('inset-inline-start', 'auto', 'important');
|
||||
drag.element.style.setProperty('inset-inline-end', 'auto', 'important');
|
||||
@@ -132,6 +215,18 @@ Popup.template.events({
|
||||
// actual DOM element. For that purpose we use the undocumented `_uihooks` API.
|
||||
Popup.template.onRendered(function () {
|
||||
this._popupElement = this.find('.js-pop-over');
|
||||
// A popup left maximized opens maximized again, and a new drawing (#6802,
|
||||
// client/lib/newDrawing.js) always does: the whole window. Reactive, because a
|
||||
// popup opened from another one - Drawing from the card's menu - replaces it in
|
||||
// the SAME rendered popup, and going back to the menu is not maximizable.
|
||||
this.autorun(() => {
|
||||
const data = Template.currentData() || {};
|
||||
const name = data.popupName;
|
||||
const newDrawing = Boolean(data.dataContext && data.dataContext.drawingNewFile);
|
||||
const maximized = MAXIMIZABLE_POPUPS.has(name) && (newDrawing || rememberedMaximized(name));
|
||||
this.popupMaximized.set(maximized);
|
||||
Tracker.afterFlush(() => setPopupMaximized(this._popupElement, maximized));
|
||||
});
|
||||
this._focusTrap = event => trapTabKey(event, this._popupElement);
|
||||
this._popupElement?.addEventListener('keydown', this._focusTrap);
|
||||
if (this._popupElement) {
|
||||
@@ -144,6 +239,25 @@ Popup.template.onRendered(function () {
|
||||
});
|
||||
this._menuLayoutObserver.observe(this._popupElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
|
||||
update();
|
||||
// A movable popup that grows - Drawing replacing the card's menu in this
|
||||
// same popup, at min(820px, 90vw) - is kept inside the window, by the
|
||||
// rule a drag keeps it there (client/lib/popupBounds.js). Not when
|
||||
// maximized: then it is the window.
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const element = this._popupElement;
|
||||
this._boundsObserver = new ResizeObserver(() => {
|
||||
if (!(MOVABLE_POPUPS.has(element.dataset.popup) || element.dataset.userMoved)
|
||||
|| element.classList.contains('pop-over--maximized')) return;
|
||||
const box = element.getBoundingClientRect();
|
||||
const moved = keptInside(box, window.innerWidth, window.innerHeight);
|
||||
if (!moved) return;
|
||||
element.style.setProperty('inset-inline-start', 'auto', 'important');
|
||||
element.style.setProperty('inset-inline-end', 'auto', 'important');
|
||||
element.style.setProperty('left', `${moved.left}px`, 'important');
|
||||
element.style.setProperty('top', `${moved.top}px`, 'important');
|
||||
});
|
||||
this._boundsObserver.observe(element);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +277,7 @@ Popup.template.onRendered(function () {
|
||||
|
||||
Popup.template.onDestroyed(function () {
|
||||
this._menuLayoutObserver?.disconnect();
|
||||
this._boundsObserver?.disconnect();
|
||||
if (this._menuLayoutFrame != null) cancelAnimationFrame(this._menuLayoutFrame);
|
||||
this._popupElement?.removeEventListener('keydown', this._focusTrap);
|
||||
});
|
||||
@@ -14,7 +14,22 @@
|
||||
.header
|
||||
a.back-btn.js-back-view(class="{{#unless hasPopupParent}}is-hidden{{/unless}}" aria-label="{{_ 'go-back'}}")
|
||||
i.fa.fa-caret-left(aria-hidden="true")
|
||||
//- #6839: every popup is moved like the opened card - by its title bar
|
||||
while drag handles are off, by this handle alone while they are on
|
||||
(popup.js); a maximizable one has the card's maximize / minimize button
|
||||
beside its close button.
|
||||
if popupMovable
|
||||
if isTouchScreenOrShowDesktopDragHandles
|
||||
span.popup-drag-handle.js-popup-drag-handle(title="{{_ 'drag-to-move'}}")
|
||||
i.fa.fa-arrows(aria-hidden="true")
|
||||
span.header-title= title
|
||||
if popupMaximizable
|
||||
if popupMaximized
|
||||
a.popup-maximize-btn.js-popup-minimize(href="#" role="button" title="{{_ 'minimize-window'}}" aria-label="{{_ 'minimize-window'}}")
|
||||
i.fa.fa-window-minimize(aria-hidden="true")
|
||||
else
|
||||
a.popup-maximize-btn.js-popup-maximize(href="#" role="button" title="{{_ 'maximize-window'}}" aria-label="{{_ 'maximize-window'}}")
|
||||
i.fa.fa-window-maximize(aria-hidden="true")
|
||||
a.close-btn.js-close-pop-over(aria-label="{{_ 'close-popup'}}")
|
||||
i.fa.fa-times-thin(aria-hidden="true")
|
||||
.content-wrapper
|
||||
|
||||
@@ -247,6 +247,33 @@
|
||||
.rules-back .js-goback .fa-arrow-left {
|
||||
color: #fff !important;
|
||||
}
|
||||
/* #6852: Back to the board, at the top of the Rules page. */
|
||||
.rules-back-to-board {
|
||||
margin-block: 8px;
|
||||
margin-inline: 14px;
|
||||
}
|
||||
/* A link, so it opens the board as a link does (also in a new tab), drawn as
|
||||
the primary buttons around it: forms.css styles only button.primary. */
|
||||
.rules-back-to-board .js-rules-back-to-board {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 20px;
|
||||
border-radius: 3px;
|
||||
background: var(--theme-accent, #005377);
|
||||
box-shadow: 0 1px 0 #4d4d4d;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
.rules-back-to-board .js-rules-back-to-board:hover,
|
||||
.rules-back-to-board .js-rules-back-to-board:focus {
|
||||
background: var(--theme-accent, #004766);
|
||||
color: #fff;
|
||||
}
|
||||
.rules-back-to-board .js-rules-back-to-board .fa-arrow-left {
|
||||
color: #fff !important;
|
||||
}
|
||||
.flex,
|
||||
.triggers-content .triggers-body .triggers-side-menu ul li a {
|
||||
display: -webkit-box;
|
||||
|
||||
@@ -6,6 +6,15 @@
|
||||
template(name="rulesMain")
|
||||
.wrapper
|
||||
.rules-page(class=currentBoard.colorClass)
|
||||
//- #6852: the way back to the board. The trigger, action and rule
|
||||
details pages have their own Back, to the rules list, so this one is
|
||||
shown only where there is none - two Backs going to two places would
|
||||
be one too many.
|
||||
if showBackToBoard
|
||||
.rules-back-to-board
|
||||
a.js-rules-back-to-board(href="{{pathFor 'board' id=currentBoard._id slug=currentBoard.slug}}" title="{{currentBoard.title}}")
|
||||
i.fa.fa-arrow-left
|
||||
| {{_ 'back'}}
|
||||
//- #2217: who may edit sees every view; a member a board shows its Rules
|
||||
to reads the rules list and rule details, with no control that
|
||||
changes a rule; anybody else sees nothing (client/lib/rulesAccess.js).
|
||||
|
||||
@@ -44,6 +44,10 @@ Template.rulesMain.helpers({
|
||||
rulesCurrentTab() {
|
||||
return Template.instance().rulesCurrentTab;
|
||||
},
|
||||
// #6852: every Rules page but the three that already go Back to the list.
|
||||
showBackToBoard() {
|
||||
return !['trigger', 'action', 'ruleDetails'].includes(Template.instance().rulesCurrentTab.get());
|
||||
},
|
||||
ruleName() {
|
||||
return Template.instance().ruleName;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.admin-audit-log-search { margin: 0.5rem 0; max-width: 24rem; }
|
||||
.admin-audit-log-table { width: 100%; border-collapse: collapse; }
|
||||
.admin-audit-log-table th, .admin-audit-log-table td {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.3);
|
||||
text-align: start;
|
||||
vertical-align: top;
|
||||
}
|
||||
.admin-audit-args { white-space: pre-wrap; word-break: break-all; font-size: 0.85em; }
|
||||
.admin-audit-failed .admin-audit-error { color: #c0392b; }
|
||||
.admin-audit-log-pager { margin-top: 0.5rem; display: flex; gap: 0.5rem; align-items: center; }
|
||||
@@ -0,0 +1,39 @@
|
||||
template(name="adminAuditLog")
|
||||
//- Admin Panel / People / Audit log (models/lib/adminAudit.js): every
|
||||
//- administrative action of a site administrator, newest first. The
|
||||
//- arguments are shown as stored - passwords and secrets never are.
|
||||
.admin-audit-log
|
||||
p {{_ 'admin-audit-log-info'}}
|
||||
.admin-audit-log-search
|
||||
input.wekan-form-control.js-admin-audit-search(type="search" placeholder="{{_ 'search'}}" value=search)
|
||||
if loading
|
||||
+spinner
|
||||
else
|
||||
table.admin-audit-log-table
|
||||
thead
|
||||
tr
|
||||
th {{_ 'admin-audit-log-when'}}
|
||||
th {{_ 'admin-audit-log-who'}}
|
||||
th {{_ 'admin-audit-log-action'}}
|
||||
th {{_ 'admin-audit-log-details'}}
|
||||
th IP
|
||||
tbody
|
||||
each rows
|
||||
tr(class="{{#unless ok}}admin-audit-failed{{/unless}}")
|
||||
td {{when at}}
|
||||
td {{actorUsername}}
|
||||
td
|
||||
code {{method}}
|
||||
unless ok
|
||||
|
|
||||
span.admin-audit-error {{error}}
|
||||
td
|
||||
code.admin-audit-args {{args}}
|
||||
td {{ip}}
|
||||
else
|
||||
tr
|
||||
td(colspan="5") {{_ 'admin-audit-log-empty'}}
|
||||
.admin-audit-log-pager
|
||||
button.js-admin-audit-prev(type="button" disabled=noPrev) ‹
|
||||
span {{page}} / {{pages}} ({{total}})
|
||||
button.js-admin-audit-next(type="button" disabled=noNext) ›
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { Template } from 'meteor/templating';
|
||||
|
||||
// Admin Panel / People / Audit log: pages of server/adminAudit.js's trail.
|
||||
Template.adminAuditLog.onCreated(function () {
|
||||
this.page = new ReactiveVar(1);
|
||||
this.search = new ReactiveVar('');
|
||||
this.result = new ReactiveVar({ rows: [], total: 0, page: 1, perPage: 50 });
|
||||
this.loading = new ReactiveVar(true);
|
||||
this.autorun(() => {
|
||||
const options = { page: this.page.get(), search: this.search.get() };
|
||||
this.loading.set(true);
|
||||
Meteor.call('adminAuditLogPage', options, (error, result) => {
|
||||
this.loading.set(false);
|
||||
if (!error && result) this.result.set(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Template.adminAuditLog.helpers({
|
||||
loading() { return Template.instance().loading.get(); },
|
||||
rows() { return Template.instance().result.get().rows; },
|
||||
total() { return Template.instance().result.get().total; },
|
||||
page() { return Template.instance().page.get(); },
|
||||
search() { return Template.instance().search.get(); },
|
||||
pages() {
|
||||
const { total, perPage } = Template.instance().result.get();
|
||||
return Math.max(1, Math.ceil(total / perPage));
|
||||
},
|
||||
noPrev() { return Template.instance().page.get() <= 1; },
|
||||
noNext() {
|
||||
const { total, perPage } = Template.instance().result.get();
|
||||
return Template.instance().page.get() >= Math.ceil(total / perPage);
|
||||
},
|
||||
when(at) { return at ? new Date(at).toLocaleString() : ''; },
|
||||
});
|
||||
|
||||
let searchTimer = null;
|
||||
Template.adminAuditLog.events({
|
||||
'input .js-admin-audit-search'(event, tpl) {
|
||||
const value = event.currentTarget.value;
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => { tpl.page.set(1); tpl.search.set(value); }, 300);
|
||||
},
|
||||
'click .js-admin-audit-prev'(event, tpl) { tpl.page.set(Math.max(1, tpl.page.get() - 1)); },
|
||||
'click .js-admin-audit-next'(event, tpl) { tpl.page.set(tpl.page.get() + 1); },
|
||||
});
|
||||
@@ -58,6 +58,8 @@ template(name="adminProblems")
|
||||
+eventStreamReport(stream="api")
|
||||
else if isPane 'report-office'
|
||||
+officeReport
|
||||
else if isPane 'report-password-length'
|
||||
+passwordLengthReport
|
||||
else if tablePageData
|
||||
+tablePage(tablePageData)
|
||||
|
||||
|
||||
@@ -321,6 +321,9 @@ const PROBLEMS_MENU = [
|
||||
// Security, and the two sit in the same menu.
|
||||
{ id: 'report-security', icon: 'fa-shield', labelKey: 'securityReportTitle' },
|
||||
{ id: 'report-impersonation', icon: 'fa-user-secret', labelKey: 'impersonationReportTitle' },
|
||||
// Who signs in with a password shorter than PASSWORD_MIN_LENGTH, and the two
|
||||
// settings that decide it (passwordLengthReport.js).
|
||||
{ id: 'report-password-length', icon: 'fa-key', labelKey: 'password-length-report-title' },
|
||||
{ id: 'features-performance', icon: 'fa-tachometer', labelKey: 'features-performance', emoji: true },
|
||||
{ id: 'report-speed', icon: 'fa-tachometer', labelKey: 'speedReportTitle' },
|
||||
{ id: 'report-tests', icon: 'fa-flask', labelKey: 'testsReportTitle' },
|
||||
@@ -538,6 +541,8 @@ const SELF_LOADING_PANES = [
|
||||
'report-security', 'report-speed', 'report-tests', 'report-cpu', 'report-ram', 'report-disk',
|
||||
'report-instrumentation',
|
||||
'report-database', 'report-integrity', 'report-office', 'report-api', 'report-mail',
|
||||
// passwordLengthReport.js loads its own rows and settings through methods.
|
||||
'report-password-length',
|
||||
];
|
||||
|
||||
function openReportPane(tmpl, targetID) {
|
||||
|
||||
@@ -225,52 +225,73 @@ template(name="attachments")
|
||||
small.form-text.cloud-input-label-tr {{_ 's3-endpoint'}}
|
||||
input.wekan-form-control#s3-endpoint(type="text" value=cloudValue.s3.endpoint)
|
||||
small.form-text.cloud-input-example https://s3.eu-west-1.amazonaws.com
|
||||
small.form-text.cloud-input-desc S3 endpoint URL (e.g., s3.amazonaws.com or minio.example.com)
|
||||
small.form-text.cloud-input-desc {{_ 's3-endpoint-description'}}
|
||||
small.form-text.cloud-input-path AWS: leave blank (uses the region endpoint). S3-compatible: the Endpoint URL from your MinIO / Cloudflare R2 / Backblaze B2 / Wasabi / DigitalOcean Spaces console.
|
||||
small.form-text.cloud-input-path {{_ 's3-endpoint-menu-path'}}
|
||||
small.form-text.cloud-input-desc {{englishText 's3-endpoint-description'}}
|
||||
if translatedDiffers 's3-endpoint-description'
|
||||
small.form-text.cloud-input-desc {{_ 's3-endpoint-description'}}
|
||||
small.form-text.cloud-input-path {{englishText 's3-endpoint-menu-path'}}
|
||||
if translatedDiffers 's3-endpoint-menu-path'
|
||||
small.form-text.cloud-input-path {{_ 's3-endpoint-menu-path'}}
|
||||
.form-group
|
||||
label AWS Region
|
||||
small.form-text.cloud-input-label-tr {{_ 's3-region'}}
|
||||
input.wekan-form-control#s3-region(type="text" value=cloudValue.s3.region)
|
||||
small.form-text.cloud-input-example eu-west-1
|
||||
small.form-text.cloud-input-desc AWS S3 region (e.g., us-east-1)
|
||||
small.form-text.cloud-input-desc {{_ 's3-region-description'}}
|
||||
small.form-text.cloud-input-path AWS Console → S3 → Buckets → your bucket → Properties → AWS Region.
|
||||
small.form-text.cloud-input-path {{_ 's3-region-menu-path'}}
|
||||
small.form-text.cloud-input-desc {{englishText 's3-region-description'}}
|
||||
if translatedDiffers 's3-region-description'
|
||||
small.form-text.cloud-input-desc {{_ 's3-region-description'}}
|
||||
small.form-text.cloud-input-path {{englishText 's3-region-menu-path'}}
|
||||
if translatedDiffers 's3-region-menu-path'
|
||||
small.form-text.cloud-input-path {{_ 's3-region-menu-path'}}
|
||||
.form-group
|
||||
label Bucket name
|
||||
small.form-text.cloud-input-label-tr {{_ 's3-bucket'}}
|
||||
input.wekan-form-control#s3-bucket(type="text" value=cloudValue.s3.bucket)
|
||||
small.form-text.cloud-input-example wekan-s3-test-369341155299-eu-west-1-an
|
||||
small.form-text.cloud-input-desc S3 bucket name for storing files
|
||||
small.form-text.cloud-input-desc {{_ 's3-bucket-description'}}
|
||||
small.form-text.cloud-input-path AWS Console → S3 → Buckets → your bucket name (or Create bucket → Bucket name).
|
||||
small.form-text.cloud-input-path {{_ 's3-bucket-menu-path'}}
|
||||
small.form-text.cloud-input-desc {{englishText 's3-bucket-description'}}
|
||||
if translatedDiffers 's3-bucket-description'
|
||||
small.form-text.cloud-input-desc {{_ 's3-bucket-description'}}
|
||||
small.form-text.cloud-input-path {{englishText 's3-bucket-menu-path'}}
|
||||
if translatedDiffers 's3-bucket-menu-path'
|
||||
small.form-text.cloud-input-path {{_ 's3-bucket-menu-path'}}
|
||||
.form-group
|
||||
label Access key ID
|
||||
small.form-text.cloud-input-label-tr {{_ 's3-access-key'}}
|
||||
input.wekan-form-control#s3-access-key(type="text" value=cloudValue.s3.accessKeyId)
|
||||
small.form-text.cloud-input-example AKIAIOSFODNN7EXAMPLE
|
||||
small.form-text.cloud-input-desc AWS S3 access key for authentication
|
||||
small.form-text.cloud-input-desc {{_ 's3-access-key-description'}}
|
||||
small.form-text.cloud-input-path AWS Console → IAM → Users → your user → Security credentials → Access keys → Create access key → choose use case 'Application running outside AWS' → Create access key. Shown as 'Access key ID'.
|
||||
small.form-text.cloud-input-path {{_ 's3-access-key-menu-path'}}
|
||||
small.form-text.cloud-input-desc {{englishText 's3-access-key-description'}}
|
||||
if translatedDiffers 's3-access-key-description'
|
||||
small.form-text.cloud-input-desc {{_ 's3-access-key-description'}}
|
||||
small.form-text.cloud-input-path {{englishText 's3-access-key-menu-path'}}
|
||||
if translatedDiffers 's3-access-key-menu-path'
|
||||
small.form-text.cloud-input-path {{_ 's3-access-key-menu-path'}}
|
||||
.form-group
|
||||
label Secret access key
|
||||
small.form-text.cloud-input-label-tr {{_ 's3-secret-key'}}
|
||||
input.wekan-form-control#s3-secret-key(type="password" placeholder=cloudSecretPlaceholder.s3.secretAccessKey)
|
||||
small.form-text.cloud-input-example wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
||||
small.form-text.cloud-input-desc Leave blank to keep the current value.
|
||||
small.form-text.cloud-input-desc {{_ 'cloud-secret-keep-blank'}}
|
||||
small.form-text.cloud-input-path Same screen as the Access key ID; 'Secret access key' is shown only once when the key is created — copy it then (or Download .csv).
|
||||
small.form-text.cloud-input-path {{_ 's3-secret-key-menu-path'}}
|
||||
small.form-text.cloud-input-desc {{englishText 'cloud-secret-keep-blank'}}
|
||||
if translatedDiffers 'cloud-secret-keep-blank'
|
||||
small.form-text.cloud-input-desc {{_ 'cloud-secret-keep-blank'}}
|
||||
small.form-text.cloud-input-path {{englishText 's3-secret-key-menu-path'}}
|
||||
if translatedDiffers 's3-secret-key-menu-path'
|
||||
small.form-text.cloud-input-path {{_ 's3-secret-key-menu-path'}}
|
||||
.form-group
|
||||
.storage-rw-item
|
||||
a.flex.js-toggle-checkbox
|
||||
.materialCheckBox#s3-force-path-style(class="{{#if cloudValue.s3.forcePathStyle}}is-checked{{/if}}")
|
||||
span {{_ 's3-force-path-style'}}
|
||||
small.form-text.text-muted {{_ 's3-force-path-style-description'}}
|
||||
//- Encryption at rest (models/lib/s3Encryption.js).
|
||||
.form-group
|
||||
label(for="s3-sse") {{_ 's3-sse'}}
|
||||
select.wekan-form-control#s3-sse
|
||||
option(value="" selected="{{s3SseSelected ''}}") {{_ 's3-sse-none'}}
|
||||
option(value="AES256" selected="{{s3SseSelected 'AES256'}}") SSE-S3 (AES256)
|
||||
option(value="aws:kms" selected="{{s3SseSelected 'aws:kms'}}") SSE-KMS (aws:kms)
|
||||
small.form-text.text-muted {{_ 's3-sse-description'}}
|
||||
.form-group
|
||||
label(for="s3-sse-kms-key-id") {{_ 's3-sse-kms-key-id'}}
|
||||
input.wekan-form-control#s3-sse-kms-key-id(type="text" value="{{cloudValue.s3.sseKmsKeyId}}" placeholder="arn:aws:kms:eu-north-1:111122223333:key/…")
|
||||
|
||||
.form-group
|
||||
button.js-test-cloud-connection.btn.btn-secondary(data-provider="s3") {{_ 'test-cloud-connection'}}
|
||||
@@ -319,37 +340,45 @@ template(name="attachments")
|
||||
small.form-text.cloud-input-label-tr {{_ 'azure-account-name'}}
|
||||
input.wekan-form-control#azure-account-name(type="text" value=cloudValue.azure.accountName)
|
||||
small.form-text.cloud-input-example wekanstorage
|
||||
small.form-text.cloud-input-desc The name of your Azure Storage account.
|
||||
small.form-text.cloud-input-desc {{_ 'azure-account-name-description'}}
|
||||
small.form-text.cloud-input-path Azure Portal → Storage accounts → your account → name shown at the top (also under Security + networking → Access keys → Storage account name).
|
||||
small.form-text.cloud-input-path {{_ 'azure-account-name-menu-path'}}
|
||||
small.form-text.cloud-input-desc {{englishText 'azure-account-name-description'}}
|
||||
if translatedDiffers 'azure-account-name-description'
|
||||
small.form-text.cloud-input-desc {{_ 'azure-account-name-description'}}
|
||||
small.form-text.cloud-input-path {{englishText 'azure-account-name-menu-path'}}
|
||||
if translatedDiffers 'azure-account-name-menu-path'
|
||||
small.form-text.cloud-input-path {{_ 'azure-account-name-menu-path'}}
|
||||
.form-group
|
||||
label Storage account key
|
||||
small.form-text.cloud-input-label-tr {{_ 'azure-account-key'}}
|
||||
input.wekan-form-control#azure-account-key(type="password" placeholder=cloudSecretPlaceholder.azure.accountKey)
|
||||
small.form-text.cloud-input-example base64AccountKey==
|
||||
small.form-text.cloud-input-desc Leave blank to keep the current value.
|
||||
small.form-text.cloud-input-desc {{_ 'cloud-secret-keep-blank'}}
|
||||
small.form-text.cloud-input-path Azure Portal → Storage accounts → your account → Security + networking → Access keys → key1 → Show → Key.
|
||||
small.form-text.cloud-input-path {{_ 'azure-account-key-menu-path'}}
|
||||
small.form-text.cloud-input-desc {{englishText 'cloud-secret-keep-blank'}}
|
||||
if translatedDiffers 'cloud-secret-keep-blank'
|
||||
small.form-text.cloud-input-desc {{_ 'cloud-secret-keep-blank'}}
|
||||
small.form-text.cloud-input-path {{englishText 'azure-account-key-menu-path'}}
|
||||
if translatedDiffers 'azure-account-key-menu-path'
|
||||
small.form-text.cloud-input-path {{_ 'azure-account-key-menu-path'}}
|
||||
.form-group
|
||||
label Connection string
|
||||
small.form-text.cloud-input-label-tr {{_ 'azure-connection-string'}}
|
||||
input.wekan-form-control#azure-connection-string(type="password" placeholder=cloudSecretPlaceholder.azure.connectionString)
|
||||
small.form-text.cloud-input-example DefaultEndpointsProtocol=https;AccountName=wekanstorage;AccountKey=...;EndpointSuffix=core.windows.net
|
||||
small.form-text.cloud-input-desc Optional: use a full connection string instead of account name and key.
|
||||
small.form-text.cloud-input-desc {{_ 'azure-connection-string-description'}}
|
||||
small.form-text.cloud-input-path Azure Portal → Storage accounts → your account → Security + networking → Access keys → key1 → Connection string → Show.
|
||||
small.form-text.cloud-input-path {{_ 'azure-connection-string-menu-path'}}
|
||||
small.form-text.cloud-input-desc {{englishText 'azure-connection-string-description'}}
|
||||
if translatedDiffers 'azure-connection-string-description'
|
||||
small.form-text.cloud-input-desc {{_ 'azure-connection-string-description'}}
|
||||
small.form-text.cloud-input-path {{englishText 'azure-connection-string-menu-path'}}
|
||||
if translatedDiffers 'azure-connection-string-menu-path'
|
||||
small.form-text.cloud-input-path {{_ 'azure-connection-string-menu-path'}}
|
||||
.form-group
|
||||
label Container name
|
||||
small.form-text.cloud-input-label-tr {{_ 'azure-container'}}
|
||||
input.wekan-form-control#azure-bucket(type="text" value=cloudValue.azure.bucket)
|
||||
small.form-text.cloud-input-example wekan-attachments
|
||||
small.form-text.cloud-input-desc The blob container to store files in.
|
||||
small.form-text.cloud-input-desc {{_ 'azure-container-description'}}
|
||||
small.form-text.cloud-input-path Azure Portal → Storage accounts → your account → Data storage → Containers → your container (or + Container to create one).
|
||||
small.form-text.cloud-input-path {{_ 'azure-container-menu-path'}}
|
||||
small.form-text.cloud-input-desc {{englishText 'azure-container-description'}}
|
||||
if translatedDiffers 'azure-container-description'
|
||||
small.form-text.cloud-input-desc {{_ 'azure-container-description'}}
|
||||
small.form-text.cloud-input-path {{englishText 'azure-container-menu-path'}}
|
||||
if translatedDiffers 'azure-container-menu-path'
|
||||
small.form-text.cloud-input-path {{_ 'azure-container-menu-path'}}
|
||||
|
||||
.form-group
|
||||
button.js-test-cloud-connection.btn.btn-secondary(data-provider="azure") {{_ 'test-cloud-connection'}}
|
||||
@@ -400,38 +429,48 @@ template(name="attachments")
|
||||
small.form-text.cloud-input-label-tr {{_ 'gcs-project-id'}}
|
||||
input.wekan-form-control#gcs-project-id(type="text" value=cloudValue.gcs.projectId)
|
||||
small.form-text.cloud-input-example my-gcp-project-123456
|
||||
small.form-text.cloud-input-desc Your Google Cloud project id.
|
||||
small.form-text.cloud-input-desc {{_ 'gcs-project-id-description'}}
|
||||
small.form-text.cloud-input-path Google Cloud Console → project selector at the top → your project's ID (Cloud overview → Dashboard → Project info → Project ID).
|
||||
small.form-text.cloud-input-path {{_ 'gcs-project-id-menu-path'}}
|
||||
small.form-text.cloud-input-desc {{englishText 'gcs-project-id-description'}}
|
||||
if translatedDiffers 'gcs-project-id-description'
|
||||
small.form-text.cloud-input-desc {{_ 'gcs-project-id-description'}}
|
||||
small.form-text.cloud-input-path {{englishText 'gcs-project-id-menu-path'}}
|
||||
if translatedDiffers 'gcs-project-id-menu-path'
|
||||
small.form-text.cloud-input-path {{_ 'gcs-project-id-menu-path'}}
|
||||
.form-group
|
||||
label Bucket name
|
||||
small.form-text.cloud-input-label-tr {{_ 'gcs-bucket'}}
|
||||
input.wekan-form-control#gcs-bucket(type="text" value=cloudValue.gcs.bucket)
|
||||
small.form-text.cloud-input-example wekan-attachments
|
||||
small.form-text.cloud-input-desc The name of the Cloud Storage bucket to store files in.
|
||||
small.form-text.cloud-input-desc {{_ 'gcs-bucket-description'}}
|
||||
small.form-text.cloud-input-path Google Cloud Console → Cloud Storage → Buckets → your bucket name (or Create to make one).
|
||||
small.form-text.cloud-input-path {{_ 'gcs-bucket-menu-path'}}
|
||||
small.form-text.cloud-input-desc {{englishText 'gcs-bucket-description'}}
|
||||
if translatedDiffers 'gcs-bucket-description'
|
||||
small.form-text.cloud-input-desc {{_ 'gcs-bucket-description'}}
|
||||
small.form-text.cloud-input-path {{englishText 'gcs-bucket-menu-path'}}
|
||||
if translatedDiffers 'gcs-bucket-menu-path'
|
||||
small.form-text.cloud-input-path {{_ 'gcs-bucket-menu-path'}}
|
||||
.form-group
|
||||
label Service account key file path
|
||||
small.form-text.cloud-input-label-tr {{_ 'gcs-key-filename'}}
|
||||
input.wekan-form-control#gcs-key-filename(type="text" value=cloudValue.gcs.keyFilename)
|
||||
small.form-text.cloud-input-example /data/gcs-service-account.json
|
||||
small.form-text.cloud-input-desc Optional: path to a service account JSON key file on the server.
|
||||
small.form-text.cloud-input-desc {{_ 'gcs-key-filename-description'}}
|
||||
small.form-text.cloud-input-path Path on the WeKan server to the downloaded service account JSON key file (created below). Use this OR paste the JSON into the field below.
|
||||
small.form-text.cloud-input-path {{_ 'gcs-key-filename-menu-path'}}
|
||||
small.form-text.cloud-input-desc {{englishText 'gcs-key-filename-description'}}
|
||||
if translatedDiffers 'gcs-key-filename-description'
|
||||
small.form-text.cloud-input-desc {{_ 'gcs-key-filename-description'}}
|
||||
small.form-text.cloud-input-path {{englishText 'gcs-key-filename-menu-path'}}
|
||||
if translatedDiffers 'gcs-key-filename-menu-path'
|
||||
small.form-text.cloud-input-path {{_ 'gcs-key-filename-menu-path'}}
|
||||
.form-group
|
||||
label Service account key (JSON)
|
||||
small.form-text.cloud-input-label-tr {{_ 'gcs-credentials'}}
|
||||
textarea.wekan-form-control#gcs-credentials(data-editor-submit=".js-save-cloud-settings" rows="4" placeholder=cloudSecretPlaceholder.gcs.credentials)
|
||||
small.form-text.cloud-input-example { "type": "service_account", "project_id": "my-gcp-project", "client_email": "wekan@my-gcp-project.iam.gserviceaccount.com", "private_key": "-----BEGIN PRIVATE KEY-----..." }
|
||||
small.form-text.cloud-input-desc Optional: paste the service account JSON key. Leave blank to use the key file or default credentials.
|
||||
small.form-text.cloud-input-desc {{_ 'gcs-credentials-description'}}
|
||||
small.form-text.cloud-input-path Google Cloud Console → IAM & Admin → Service accounts → your service account → Keys → Add key → Create new key → JSON → Create (downloads the file). Paste its contents here, or save the file and set its path above.
|
||||
small.form-text.cloud-input-path {{_ 'gcs-credentials-menu-path'}}
|
||||
small.form-text.text-muted {{_ 'gcs-credentials-description'}}
|
||||
small.form-text.cloud-input-desc {{englishText 'gcs-credentials-description'}}
|
||||
if translatedDiffers 'gcs-credentials-description'
|
||||
small.form-text.cloud-input-desc {{_ 'gcs-credentials-description'}}
|
||||
small.form-text.cloud-input-path {{englishText 'gcs-credentials-menu-path'}}
|
||||
if translatedDiffers 'gcs-credentials-menu-path'
|
||||
small.form-text.cloud-input-path {{_ 'gcs-credentials-menu-path'}}
|
||||
//- The translated description is the cloud-input-desc above; a
|
||||
//- second text-muted copy (left from before the English+local
|
||||
//- pairs) showed the same sentence twice under the field.
|
||||
|
||||
.form-group
|
||||
button.js-test-cloud-connection.btn.btn-secondary(data-provider="gcs") {{_ 'test-cloud-connection'}}
|
||||
|
||||
@@ -32,6 +32,8 @@ const CLOUD_FIELD_IDS = {
|
||||
accessKeyId: '#s3-access-key',
|
||||
secretAccessKey: '#s3-secret-key',
|
||||
forcePathStyle: '#s3-force-path-style',
|
||||
serverSideEncryption: '#s3-sse',
|
||||
sseKmsKeyId: '#s3-sse-kms-key-id',
|
||||
},
|
||||
azure: {
|
||||
enabled: '#azure-enabled',
|
||||
@@ -858,6 +860,11 @@ Template.attachments.helpers({
|
||||
CLOUD_PROVIDERS.forEach(p => { out[p] = cloudConfigFromSettings(tpl, p).read !== false; });
|
||||
return out;
|
||||
},
|
||||
// The S3 server-side encryption option that is stored (models/lib/s3Encryption.js).
|
||||
s3SseSelected(mode) {
|
||||
const stored = cloudConfigFromSettings(Template.instance(), 's3').serverSideEncryption || '';
|
||||
return stored === mode ? 'selected' : null;
|
||||
},
|
||||
cloudValue() {
|
||||
const tpl = Template.instance();
|
||||
const s3 = cloudConfigFromSettings(tpl, 's3');
|
||||
@@ -872,6 +879,8 @@ Template.attachments.helpers({
|
||||
bucket: s3.bucket || '',
|
||||
accessKeyId: s3.accessKeyId || '',
|
||||
forcePathStyle: s3.forcePathStyle !== false,
|
||||
serverSideEncryption: s3.serverSideEncryption || '',
|
||||
sseKmsKeyId: s3.sseKmsKeyId || '',
|
||||
},
|
||||
azure: {
|
||||
accountName: azure.accountName || '',
|
||||
|
||||
@@ -14,7 +14,12 @@ template(name="authProviderSettings")
|
||||
ul.setting-detail
|
||||
each fields
|
||||
li.accounts-form
|
||||
label(for="auth-{{key}}")= envVar
|
||||
if labelKey
|
||||
label(for="auth-{{key}}")
|
||||
| {{_ labelKey}}
|
||||
span.quiet ({{envVar}})
|
||||
else
|
||||
label(for="auth-{{key}}")= envVar
|
||||
.form-group
|
||||
//- Environment-only (Header login): the value in effect,
|
||||
//- read-only, and no js-auth-config-field so nothing is saved.
|
||||
@@ -29,7 +34,7 @@ template(name="authProviderSettings")
|
||||
select.wekan-form-control.js-auth-config-field(id="auth-{{key}}" data-key="{{key}}")
|
||||
option(value="" selected=inherit) {{_ 'default'}} ({{effective}})
|
||||
each choiceOptions
|
||||
option(value="{{value}}" selected=selected)= value
|
||||
option(value="{{value}}" selected=selected)= label
|
||||
else if secretField
|
||||
.description.js-auth-secret-status(data-key="{{key}}")= secretStatus
|
||||
input.wekan-form-control.js-auth-config-field(id="auth-{{key}}" data-key="{{key}}" type="password" value="" autocomplete="new-password" placeholder="{{_ 'password'}}")
|
||||
@@ -45,6 +50,8 @@ template(name="authProviderSettings")
|
||||
input.wekan-form-control.js-auth-config-field(id="auth-{{key}}" data-key="{{key}}" type="text" value="{{value}}" placeholder="{{effective}}")
|
||||
unless secretField
|
||||
span.ldap-source-badge= sourceLabel
|
||||
if helpKey
|
||||
.description {{_ helpKey}}
|
||||
if restart
|
||||
.description {{_ 'login-setting-after-restart'}}
|
||||
unless sectionEnvOnly
|
||||
|
||||
@@ -34,7 +34,14 @@ Template.authProviderSettings.helpers({
|
||||
const value = overrides[field.key];
|
||||
const source = sources[field.key] || {};
|
||||
const secretField = field.type === 'secret';
|
||||
return { ...field, value: value ?? '', effective: String(source.value ?? ''),
|
||||
// A choice is shown by its translated name when the catalog gives one.
|
||||
const choiceLabel = choice => (field.choiceLabelKeys && field.choiceLabelKeys[choice]
|
||||
? TAPi18n.__(field.choiceLabelKeys[choice]) : choice);
|
||||
const effectiveRaw = String(source.value ?? '');
|
||||
return { ...field, value: value ?? '',
|
||||
effective: field.type === 'choice' ? choiceLabel(effectiveRaw)
|
||||
: field.type === 'boolean' && ['true', 'false'].includes(effectiveRaw)
|
||||
? TAPi18n.__(effectiveRaw === 'true' ? 'yes' : 'no') : effectiveRaw,
|
||||
booleanField: field.type === 'boolean', multiline: field.type === 'textarea',
|
||||
choiceField: field.type === 'choice', numberField: field.type === 'number',
|
||||
secretField,
|
||||
@@ -48,7 +55,7 @@ Template.authProviderSettings.helpers({
|
||||
: !source.hasValue ? TAPi18n.__('unset-color')
|
||||
: `${TAPi18n.__('password')}: ${source.source === 'admin' ? TAPi18n.__('admin-panel')
|
||||
: source.source === 'file' ? field.fileVar : field.envVar}`,
|
||||
choiceOptions: (field.choices || []).map(choice => ({ value: choice, selected: value === choice })),
|
||||
choiceOptions: (field.choices || []).map(choice => ({ value: choice, label: choiceLabel(choice), selected: value === choice })),
|
||||
inherit: value === undefined, enabled: value === true, disabled: value === false,
|
||||
sourceLabel: source.source === 'admin' ? TAPi18n.__('admin-panel')
|
||||
: source.source === 'env' ? field.envVar
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
//- Admin Panel / Problems / Password length (docs/Features/Admin-Panel/Problems/Password-length.md):
|
||||
//- the minimum, whether it is enforced, and who signs in with a shorter
|
||||
//- password - through the shared table page (docs/Features/General/Table.md).
|
||||
template(name="passwordLengthReport")
|
||||
ul.setting-detail.password-length-settings
|
||||
li
|
||||
label(for="password-min-length-input") {{_ 'password-min-length'}}
|
||||
input#password-min-length-input.js-password-min-length(type="number" min="1" max="256" value="{{minLength}}")
|
||||
p.description {{_ 'password-min-length-description'}}
|
||||
li
|
||||
a.flex.js-toggle-password-min-length-enforce
|
||||
.materialCheckBox(class="{{#if enforce}}is-checked{{/if}}")
|
||||
span {{_ 'password-min-length-enforce'}}
|
||||
p.description {{_ 'password-min-length-enforce-description'}}
|
||||
li
|
||||
button.primary.js-save-password-length(type="button") {{_ 'save'}}
|
||||
if saved
|
||||
span.password-length-saved {{_ 'password-length-saved'}}
|
||||
if saveError
|
||||
p.error.password-length-error= saveError
|
||||
if fromEnvironment
|
||||
p.description {{_ 'password-length-from-environment'}}
|
||||
+tablePage(tablePageData)
|
||||
@@ -0,0 +1,157 @@
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { Template } from 'meteor/templating';
|
||||
import { formatDateForDisplay } from '/client/lib/dateDisplay';
|
||||
import { buildFilters, buildHeader, buildRows, pageInfo } from '/models/lib/tablePage';
|
||||
import { errorMessage } from '/client/lib/errorMessage';
|
||||
|
||||
// Admin Panel / Problems / Password length: the two settings and the table of
|
||||
// accounts that sign in with a password shorter than the minimum
|
||||
// (server/lib/shortPasswordLogins.js; docs/Features/Admin-Panel/Problems/Password-length.md).
|
||||
const { addressValue, locationText, locationFlag } = require('/models/lib/addressReportColumns');
|
||||
const { DEFAULT_FILTER } = require('/models/lib/shortPasswordLogins');
|
||||
|
||||
const PER_PAGE = 25;
|
||||
const formatDate = date => (date ? formatDateForDisplay(date) : '');
|
||||
|
||||
// One cell per family: the address, then the city when a CDN in front of
|
||||
// WeKan said where it is, and the country's flag before both.
|
||||
const addressColumn = (labelKey, family) => ({
|
||||
labelKey,
|
||||
nowrap: true,
|
||||
value: row => [addressValue(row, family), locationText(row, family)].filter(Boolean).join(' '),
|
||||
flag: row => locationFlag(row, family),
|
||||
});
|
||||
|
||||
const COLUMNS = [
|
||||
{ labelKey: 'username', value: row => row.username || row.userId },
|
||||
{ labelKey: 'password-length-first-login', nowrap: true, value: row => formatDate(row.firstAt) },
|
||||
addressColumn('event-ipv4', 'ipv4'),
|
||||
addressColumn('event-ipv6', 'ipv6'),
|
||||
{ labelKey: 'password-length-last-login', nowrap: true, value: row => formatDate(row.at) },
|
||||
{ labelKey: 'password-length-login-count', align: 'end', value: row => row.count || 0 },
|
||||
{ labelKey: 'password-length-fixed', nowrap: true, value: row => formatDate(row.fixedAt) },
|
||||
];
|
||||
|
||||
const FILTER_OPTIONS = [
|
||||
{ value: 'short', labelKey: 'password-length-filter-short' },
|
||||
{ value: 'fixed', labelKey: 'password-length-filter-fixed' },
|
||||
{ value: 'all', labelKey: 'password-length-filter-all' },
|
||||
];
|
||||
|
||||
Template.passwordLengthReport.onCreated(function () {
|
||||
this.rows = new ReactiveVar([]);
|
||||
this.total = new ReactiveVar(0);
|
||||
this.page = new ReactiveVar(1);
|
||||
this.search = new ReactiveVar('');
|
||||
this.filter = new ReactiveVar(DEFAULT_FILTER);
|
||||
this.sources = new ReactiveVar(null);
|
||||
// The working copy of the two settings, until Save.
|
||||
this.minLength = new ReactiveVar(8);
|
||||
this.enforce = new ReactiveVar(false);
|
||||
this.saved = new ReactiveVar(false);
|
||||
this.error = new ReactiveVar('');
|
||||
|
||||
this.load = () => {
|
||||
Meteor.call('shortPasswordLoginsPage', {
|
||||
filter: this.filter.get(),
|
||||
search: this.search.get() || undefined,
|
||||
limit: PER_PAGE,
|
||||
skip: (this.page.get() - 1) * PER_PAGE,
|
||||
}, (error, result) => {
|
||||
if (error) return;
|
||||
this.rows.set((result && result.rows) || []);
|
||||
this.total.set((result && result.total) || 0);
|
||||
});
|
||||
};
|
||||
this.loadSettings = () => {
|
||||
Meteor.call('getAuthConfigSources', 'login', (error, result) => {
|
||||
if (error || !result) return;
|
||||
this.sources.set(result.sources || {});
|
||||
this.minLength.set(result.sources?.passwordMinLength?.value || 8);
|
||||
this.enforce.set(result.sources?.passwordMinLengthEnforce?.value === true);
|
||||
});
|
||||
};
|
||||
this.load();
|
||||
this.loadSettings();
|
||||
});
|
||||
|
||||
Template.passwordLengthReport.helpers({
|
||||
minLength() { return Template.instance().minLength.get(); },
|
||||
enforce() { return Template.instance().enforce.get(); },
|
||||
saved() { return Template.instance().saved.get(); },
|
||||
saveError() { return Template.instance().error.get(); },
|
||||
// An environment variable set on the host is overridden by a save here; say
|
||||
// so, rather than let the admin wonder which one is in effect.
|
||||
fromEnvironment() {
|
||||
const sources = Template.instance().sources.get() || {};
|
||||
return ['passwordMinLength', 'passwordMinLengthEnforce'].some(key => sources[key]?.source === 'env');
|
||||
},
|
||||
tablePageData() {
|
||||
const t = Template.instance();
|
||||
const rows = t.rows.get() || [];
|
||||
const info = pageInfo(t.total.get(), t.page.get(), PER_PAGE);
|
||||
return {
|
||||
descKey: 'password-length-report-desc',
|
||||
emptyKey: 'password-length-no-results',
|
||||
searchTerm: t.search.get(),
|
||||
filters: buildFilters([{
|
||||
id: 'password-length-status', labelKey: 'status', current: t.filter.get(), options: FILTER_OPTIONS,
|
||||
}]),
|
||||
header: buildHeader(COLUMNS),
|
||||
rows: buildRows(rows, COLUMNS),
|
||||
rowCount: rows.length,
|
||||
totalCount: t.total.get(),
|
||||
page: info.page,
|
||||
totalPages: info.totalPages,
|
||||
hasPrev: info.hasPrev,
|
||||
hasNext: info.hasNext,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
Template.passwordLengthReport.events({
|
||||
'input .js-password-min-length'(event, tmpl) {
|
||||
tmpl.minLength.set(parseInt(event.currentTarget.value, 10) || '');
|
||||
tmpl.saved.set(false);
|
||||
},
|
||||
'click .js-toggle-password-min-length-enforce'(event, tmpl) {
|
||||
event.preventDefault();
|
||||
tmpl.enforce.set(!tmpl.enforce.get());
|
||||
tmpl.saved.set(false);
|
||||
},
|
||||
'click .js-save-password-length'(event, tmpl) {
|
||||
event.preventDefault();
|
||||
Meteor.call('saveAuthConfigSettings', 'login', {
|
||||
passwordMinLength: tmpl.minLength.get() || null,
|
||||
passwordMinLengthEnforce: tmpl.enforce.get(),
|
||||
}, error => {
|
||||
tmpl.error.set(error ? errorMessage(error) : '');
|
||||
if (error) return;
|
||||
tmpl.saved.set(true);
|
||||
tmpl.loadSettings();
|
||||
});
|
||||
},
|
||||
'change .js-table-page-filter'(event, tmpl) {
|
||||
tmpl.filter.set(event.currentTarget.value || DEFAULT_FILTER);
|
||||
tmpl.page.set(1);
|
||||
tmpl.load();
|
||||
},
|
||||
'click .js-table-page-prev'(event, tmpl) {
|
||||
event.preventDefault();
|
||||
if (tmpl.page.get() > 1) { tmpl.page.set(tmpl.page.get() - 1); tmpl.load(); }
|
||||
},
|
||||
'click .js-table-page-next'(event, tmpl) {
|
||||
event.preventDefault();
|
||||
const info = pageInfo(tmpl.total.get(), tmpl.page.get(), PER_PAGE);
|
||||
if (info.hasNext) { tmpl.page.set(tmpl.page.get() + 1); tmpl.load(); }
|
||||
},
|
||||
'keydown .js-table-page-search'(event, tmpl) {
|
||||
if (event.keyCode === 13 && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
tmpl.search.set(event.currentTarget.value || '');
|
||||
tmpl.page.set(1);
|
||||
tmpl.load();
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -36,6 +36,8 @@ template(name="people")
|
||||
+authProviderSettings(loginOptionsData)
|
||||
//- Windows SSO, which works only on a Windows server.
|
||||
+authProviderSettings(windowsSsoData)
|
||||
//- Strict-Transport-Security, sent when ROOT_URL is https.
|
||||
+authProviderSettings(httpSecurityData)
|
||||
else if samlSetting
|
||||
+authProviderSettings(samlSettingsData)
|
||||
else if authConfigSection
|
||||
@@ -56,6 +58,8 @@ template(name="people")
|
||||
+peopleGeneral(peopleTablePageData)
|
||||
else if lockedUsersSetting.get
|
||||
+lockedUsersGeneral
|
||||
else if auditLogSetting.get
|
||||
+adminAuditLog
|
||||
else if rolesSetting.get
|
||||
+rolesGeneral
|
||||
else if templatesSetting.get
|
||||
|
||||
@@ -142,6 +142,7 @@ Template.people.onCreated(function () {
|
||||
this.teamSetting = new ReactiveVar(false);
|
||||
this.peopleSetting = new ReactiveVar(false);
|
||||
this.lockedUsersSetting = new ReactiveVar(false);
|
||||
this.auditLogSetting = new ReactiveVar(false);
|
||||
this.rolesSetting = new ReactiveVar(false);
|
||||
this.templatesSetting = new ReactiveVar(false);
|
||||
// #5850: Admin Panel > People > Domains tab. The domains table itself
|
||||
@@ -434,6 +435,7 @@ Template.people.onCreated(function () {
|
||||
this.teamSetting.set('team-setting' === targetID);
|
||||
this.peopleSetting.set('people-setting' === targetID);
|
||||
this.lockedUsersSetting.set('locked-users-setting' === targetID);
|
||||
this.auditLogSetting.set('audit-log-setting' === targetID);
|
||||
this.rolesSetting.set('roles-setting' === targetID);
|
||||
this.templatesSetting.set('templates-setting' === targetID);
|
||||
this.domainSetting.set('domains-setting' === targetID);
|
||||
@@ -551,6 +553,9 @@ function peopleMenu(user) {
|
||||
{ id: 'team-setting', icon: 'fa-users', labelKey: 'teams' },
|
||||
{ id: 'people-setting', icon: 'fa-user', labelKey: 'people' },
|
||||
{ id: 'locked-users-setting', icon: 'fa-lock', labelKey: 'accounts-lockout-locked-users', iconWrapCls: 'text-red' },
|
||||
// What site administrators did (models/lib/adminAudit.js). Site admins only:
|
||||
// tenantAdminPeopleMenu keeps a per-tenant admin's menu to its own panes.
|
||||
{ id: 'audit-log-setting', icon: 'fa-list-alt', labelKey: 'admin-audit-log' },
|
||||
{ id: 'roles-setting', icon: 'fa-key', labelKey: 'roles' },
|
||||
{ id: 'templates-setting', icon: 'fa-clone', labelKey: 'shared-templates' },
|
||||
{ id: 'registration-setting', icon: 'fa-key', labelKey: 'login', emoji: true },
|
||||
@@ -581,6 +586,15 @@ function peopleMenu(user) {
|
||||
return items;
|
||||
}
|
||||
|
||||
// A password the server refused (server/lib/passwordPolicy.js), in the
|
||||
// reader's language; the popup that sent it has already closed.
|
||||
function passwordErrorMessage(error) {
|
||||
const key = error && error.error;
|
||||
if (key === 'password-too-short') return TAPi18n.__('password-too-short', { min: error.details?.min });
|
||||
if (['password-pwned', 'password-same-as-username'].includes(key)) return TAPi18n.__(key);
|
||||
return error.reason || error.message;
|
||||
}
|
||||
|
||||
// People panes drawn from models/lib/authConfigCatalog.js, and the data the
|
||||
// catalog form (authProviderSettings) needs for one section.
|
||||
const AUTH_CONFIG_PANES = {
|
||||
@@ -899,6 +913,8 @@ Template.people.helpers({
|
||||
// The Login pane's own login settings (PASSWORD_LOGIN_ENABLED, ...).
|
||||
loginOptionsData() { return authConfigData('login'); },
|
||||
windowsSsoData() { return authConfigData('windowsSso'); },
|
||||
// HSTS (HSTS_MAX_AGE, ...), below the login settings.
|
||||
httpSecurityData() { return authConfigData('httpSecurity'); },
|
||||
ldapPane() { return Template.instance().activeMenuId.get() === 'ldap-setting'; },
|
||||
authenticationSection() {
|
||||
return {
|
||||
@@ -927,6 +943,9 @@ Template.people.helpers({
|
||||
lockedUsersSetting() {
|
||||
return Template.instance().lockedUsersSetting;
|
||||
},
|
||||
auditLogSetting() {
|
||||
return Template.instance().auditLogSetting;
|
||||
},
|
||||
rolesSetting() {
|
||||
return Template.instance().rolesSetting;
|
||||
},
|
||||
@@ -2352,7 +2371,9 @@ Template.editUserPopup.events({
|
||||
});
|
||||
|
||||
if (isChangePassword) {
|
||||
Meteor.call('setPassword', password, this.userId);
|
||||
Meteor.call('setPassword', password, this.userId, error => {
|
||||
if (error) alert(passwordErrorMessage(error));
|
||||
});
|
||||
}
|
||||
|
||||
if (isChangeEmailVerified) {
|
||||
@@ -2651,6 +2672,8 @@ Template.newUserPopup.events({
|
||||
} else if (errorElement === 'email-already-taken') {
|
||||
usernameMessageElement.hide();
|
||||
emailMessageElement.show();
|
||||
} else if (String(errorElement).startsWith('password-')) {
|
||||
alert(passwordErrorMessage(error));
|
||||
}
|
||||
} else {
|
||||
usernameMessageElement.hide();
|
||||
|
||||
@@ -56,6 +56,37 @@ template(name="problemsSummary")
|
||||
if restoreResult
|
||||
br
|
||||
span.quiet {{restoreResult}}
|
||||
//- Data health (models/lib/dataHealth.js): a Repair button where
|
||||
//- the repair is safe; one that deletes data asks for a second click.
|
||||
if problem.repair
|
||||
.admin-problem-actions
|
||||
button.js-data-health-repair.primary(data-check="{{problem.checkId}}" data-repair="{{problem.repair}}" disabled="{{#if dataHealthRepairing problem.checkId}}disabled{{/if}}")
|
||||
if dataHealthRepairing problem.checkId
|
||||
i.fa.fa-spinner.fa-spin
|
||||
| {{_ 'repairing'}}
|
||||
else if dataHealthConfirming problem.checkId
|
||||
i.fa.fa-exclamation-triangle
|
||||
| {{_ 'confirm'}}
|
||||
else
|
||||
i.fa.fa-wrench
|
||||
| {{_ 'data-health-repair'}}
|
||||
if dataHealthResult problem.checkId
|
||||
br
|
||||
span.quiet {{dataHealthResult problem.checkId}}
|
||||
|
||||
//- Data health: the nightly read-only scan behind the "Data health: ..."
|
||||
//- problems above.
|
||||
//- What the checks are and when it runs: docs/Features/Admin-Panel/
|
||||
//- Problems/Data-Health.md; each finding explains itself above.
|
||||
h2 {{_ 'data-health'}}
|
||||
p.quiet {{_ 'last-run'}}: {{dataHealthStatus}}
|
||||
.admin-problem-actions
|
||||
button.js-data-health-scan.primary(disabled="{{#if dataHealthScanning}}disabled{{/if}}")
|
||||
if dataHealthScanning
|
||||
i.fa.fa-spinner.fa-spin
|
||||
else
|
||||
i.fa.fa-search
|
||||
| {{_ 'start'}}
|
||||
|
||||
h2 {{_ 'problems'}}
|
||||
if hasProblems
|
||||
|
||||
@@ -32,6 +32,12 @@ Template.problemsSummary.onCreated(function () {
|
||||
// problems (broken cards, login-page "Must be logged in" causes). Same data as
|
||||
// `snap run wekan.problems`.
|
||||
this.status = new ReactiveVar(null);
|
||||
// Data health: which check is being repaired, which waits for its
|
||||
// confirming second click, and what each repair said.
|
||||
this.dataHealthRepairing = new ReactiveVar(null);
|
||||
this.dataHealthConfirming = new ReactiveVar(null);
|
||||
this.dataHealthResults = new ReactiveVar({});
|
||||
this.dataHealthScanRequested = new ReactiveVar(false);
|
||||
this.reload = () => {
|
||||
Meteor.call('eventLogProblemAreas', (err, res) => {
|
||||
if (!err) this.areas.set(Array.isArray(res) ? res : []);
|
||||
@@ -78,6 +84,31 @@ Template.problemsSummary.helpers({
|
||||
isUnboundLists(problem) {
|
||||
return !!problem && problem.id === 'unbound-lists';
|
||||
},
|
||||
dataHealthRepairing(checkId) {
|
||||
return Template.instance().dataHealthRepairing.get() === checkId;
|
||||
},
|
||||
dataHealthConfirming(checkId) {
|
||||
return Template.instance().dataHealthConfirming.get() === checkId;
|
||||
},
|
||||
dataHealthResult(checkId) {
|
||||
return Template.instance().dataHealthResults.get()[checkId] || '';
|
||||
},
|
||||
dataHealthScanning() {
|
||||
const tpl = Template.instance();
|
||||
const s = tpl.status.get();
|
||||
return tpl.dataHealthScanRequested.get() || !!(s && s.dataHealth && s.dataHealth.running);
|
||||
},
|
||||
dataHealthStatus() {
|
||||
const s = Template.instance().status.get();
|
||||
const d = s && s.dataHealth;
|
||||
// A key of its own: List Sync's "never synced" may be translated as
|
||||
// "never synced", which reads wrong beside "Last Run".
|
||||
if (!d || !d.at) return TAPi18n.__('data-health-never-run');
|
||||
// "<when> - <checks that found something>/<checks>"
|
||||
const at = new Date(d.at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC';
|
||||
const text = `${at} - ${d.found}/${d.checks}`;
|
||||
return d.error ? `${text} - ${d.error}` : text;
|
||||
},
|
||||
repairRunning() {
|
||||
return Template.instance().repairRunning.get();
|
||||
},
|
||||
@@ -154,6 +185,46 @@ Template.problemsSummary.events({
|
||||
});
|
||||
},
|
||||
|
||||
// A repair that deletes data ('confirm') needs a second click on the same
|
||||
// button; any other click cancels the pending confirmation.
|
||||
'click .js-data-health-repair'(event, templateInstance) {
|
||||
event.preventDefault();
|
||||
const checkId = event.currentTarget.getAttribute('data-check');
|
||||
const kind = event.currentTarget.getAttribute('data-repair');
|
||||
if (!checkId || templateInstance.dataHealthRepairing.get()) return;
|
||||
if (kind === 'confirm' && templateInstance.dataHealthConfirming.get() !== checkId) {
|
||||
templateInstance.dataHealthConfirming.set(checkId);
|
||||
return;
|
||||
}
|
||||
templateInstance.dataHealthConfirming.set(null);
|
||||
templateInstance.dataHealthRepairing.set(checkId);
|
||||
// The second click of a deleting repair is the confirmation the server
|
||||
// requires (server/methods/dataHealth.js).
|
||||
Meteor.call('repairDataHealth', checkId, kind === 'confirm', (error, res) => {
|
||||
templateInstance.dataHealthRepairing.set(null);
|
||||
const results = { ...templateInstance.dataHealthResults.get() };
|
||||
results[checkId] = error
|
||||
? (error.reason || error.message || String(error))
|
||||
: `${TAPi18n.__('attachment-repair-repaired')}: ${(res && res.fixed) || 0}`;
|
||||
templateInstance.dataHealthResults.set(results);
|
||||
templateInstance.reload();
|
||||
});
|
||||
},
|
||||
|
||||
'click .js-data-health-scan'(event, templateInstance) {
|
||||
event.preventDefault();
|
||||
templateInstance.dataHealthConfirming.set(null);
|
||||
templateInstance.dataHealthScanRequested.set(true);
|
||||
Meteor.call('dataHealthScanNow', () => {
|
||||
// The scan runs in the background; the 30 s reload shows it running and,
|
||||
// when it is done, its findings.
|
||||
Meteor.setTimeout(() => {
|
||||
templateInstance.dataHealthScanRequested.set(false);
|
||||
templateInstance.reload();
|
||||
}, 3000);
|
||||
});
|
||||
},
|
||||
|
||||
'click .js-ack-checked'(event, templateInstance) {
|
||||
const streams = Array.from(templateInstance.findAll('.js-problem-check:checked'))
|
||||
.map(el => el.getAttribute('data-stream'))
|
||||
|
||||
@@ -495,10 +495,10 @@ body.left-menu-resizing-active {
|
||||
|
||||
.setting-content input[type="checkbox"]:checked {
|
||||
top: -4px;
|
||||
inset-inline-start: -3px;
|
||||
left: -3px;
|
||||
width: 7px;
|
||||
height: 15px;
|
||||
margin-inline-end: 6px;
|
||||
margin-right: 6px;
|
||||
border-top: 2px solid transparent;
|
||||
border-left: 2px solid transparent;
|
||||
border-bottom: 2px solid #3cb500;
|
||||
|
||||
@@ -116,10 +116,10 @@
|
||||
.pop-over[data-popup='createCustomFieldPopup'] .materialCheckBox.is-checked,
|
||||
.pop-over[data-popup='editCustomFieldPopup'] .materialCheckBox.is-checked {
|
||||
top: -4px !important;
|
||||
inset-inline-start: -3px !important;
|
||||
left: -3px !important;
|
||||
width: 7px !important;
|
||||
height: 15px !important;
|
||||
margin-inline-end: 6px !important;
|
||||
margin-right: 6px !important;
|
||||
border-top: 2px solid transparent !important;
|
||||
border-left: 2px solid transparent !important;
|
||||
border-bottom: 2px solid #3cb500 !important;
|
||||
@@ -147,114 +147,55 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Board Settings / Card: Draggable precedes the two independent lists -
|
||||
"Show on Minicard" in the board's minicard order, "Show on Card" in its
|
||||
card order (sidebar.jade boardCardSettingsPopup). Two lists rather than
|
||||
one table of rows, because the two orders are independent: a field can be
|
||||
third on the minicard and last on the card. Minicard reads to the LEFT of
|
||||
Card, as the old table drew it. */
|
||||
.card-field-order-heading {
|
||||
margin: 0 0 6px;
|
||||
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.board-card-settings {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.card-field-order-columns {
|
||||
/* Card Settings - on the opened card and in Board Settings / Card, one
|
||||
template (sidebar.jade cardSettingsInline; its checklist look is
|
||||
client/components/cards/cardAddFields.css): a row's line, its checkbox and
|
||||
its name. */
|
||||
.card-settings-line {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
row-gap: 18px;
|
||||
column-gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.card-field-order-column-heading {
|
||||
margin: 0;
|
||||
padding: 5px 0;
|
||||
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
/* A row: [checkbox] [up] [down] icon label. */
|
||||
.card-field-order-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-items: start;
|
||||
text-align: start;
|
||||
padding: calc(4px * min(1, var(--wekan-ui-font-scale, 1))) 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.card-field-order-toggle,
|
||||
.card-field-order-move {
|
||||
flex-shrink: 0;
|
||||
/* What a row is dragged by: its handle (drag handles on), its icon or name. */
|
||||
.card-field-order-drag-label {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.card-field-order-move.is-disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
.card-settings-line.ui-sortable-helper {
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.card-field-order-label {
|
||||
.card-settings-line-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.card-settings-side {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
justify-self: start;
|
||||
gap: 6px;
|
||||
}
|
||||
.card-settings-side > a.flex {
|
||||
margin: 0;
|
||||
}
|
||||
.card-field-order-toggle,
|
||||
.card-field-order-move {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.card-field-order-move.is-disabled,
|
||||
.card-field-order-toggle.is-disabled,
|
||||
.card-ask-move-reason.is-disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* One side of the same popup. Card menu / Show on Card and Minicard menu /
|
||||
Show on Minicard open this popup asking for one column; the other list is
|
||||
hidden here, so there is still ONE set of rows and one set of handlers.
|
||||
With a single list left, the width the popup already has is enough for two
|
||||
or three columns of rows side by side (popup.css gives these popups more of
|
||||
it), `auto-fill` so a narrow window still gets one column. */
|
||||
.board-card-settings.show-card-only .card-field-order-column-minicard,
|
||||
.board-card-settings.show-minicard-only .card-field-order-column-card {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.board-card-settings.show-card-only .card-field-order-columns,
|
||||
.board-card-settings.show-minicard-only .card-field-order-columns {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 801px) {
|
||||
.board-card-settings.show-card-only .card-field-order-column-card,
|
||||
.board-card-settings.show-minicard-only .card-field-order-column-minicard {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
column-gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
.board-card-settings.show-card-only .card-field-order-column-heading,
|
||||
.board-card-settings.show-minicard-only .card-field-order-column-heading {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* "Labels text" is the user's own setting, not the board's: it shows in the
|
||||
minicard's own view and nowhere else - not in Board Settings / Card Settings,
|
||||
where every other row is board-wide, and not in Show on Card, which is about
|
||||
the opened card. And when the reader is not a board admin, it is the only row
|
||||
they may change, so it is the only row they get - the headings and the card
|
||||
list go with the rows they would have headed. */
|
||||
.board-card-settings .card-settings-row-personal {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.board-card-settings.show-minicard-only .card-settings-row-personal {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.board-card-settings.show-personal-only .card-field-order-row:not(.card-settings-row-personal),
|
||||
.board-card-settings.show-personal-only .card-field-order-heading,
|
||||
.board-card-settings.show-personal-only .card-field-order-column-heading,
|
||||
.board-card-settings.show-personal-only .card-field-order-column-card {
|
||||
display: none;
|
||||
}
|
||||
.card-settings-board-option { margin-block-end: 6px; }
|
||||
|
||||
/* Board Settings / Board View: the same table as Card Settings above, with
|
||||
FIVE columns (Default/Show on Public, Default/Show on Private, the view's
|
||||
@@ -773,6 +714,9 @@ body.sidebar-resizing-active {
|
||||
/* #6699: this editor uses real, labelled checkboxes. Undo all three parts
|
||||
of forms.css's material-checkbox hiding rule in this popup only. */
|
||||
.wip-limit-groups input[type="checkbox"] {
|
||||
/* #6820: no text-field padding, or a ticked box is drawn as a dot. */
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
display: inline-block;
|
||||
visibility: visible;
|
||||
position: static;
|
||||
@@ -791,36 +735,6 @@ body.sidebar-resizing-active {
|
||||
}
|
||||
|
||||
|
||||
/* Switch all three sections together. Measure the form, not the viewport:
|
||||
a narrow popup must stack even on a wide display. */
|
||||
@container (min-width: 780px) {
|
||||
.card-field-order-columns { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
/* #6796: when the form is wide, each list spreads into as many columns as fit
|
||||
(10em each), read top to bottom, so its order is still the card's and the
|
||||
minicard's order, and the whole of it is visible without scrolling. The
|
||||
Draggable table keeps only the width it needs. */
|
||||
@container (min-width: 1100px) {
|
||||
.card-field-order-columns {
|
||||
grid-template-columns: minmax(12em, max-content) minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
.card-field-order-column-minicard,
|
||||
.card-field-order-column-card {
|
||||
column-width: 10em;
|
||||
column-gap: 16px;
|
||||
}
|
||||
.card-field-order-column-minicard > :not(.card-field-order-row),
|
||||
.card-field-order-column-card > :not(.card-field-order-row) {
|
||||
column-span: all;
|
||||
}
|
||||
.card-field-order-column .card-field-order-row {
|
||||
break-inside: avoid;
|
||||
padding-block: calc(2px * min(1, var(--wekan-ui-font-scale, 1)));
|
||||
}
|
||||
}
|
||||
.card-field-order-column { min-width: 0; }
|
||||
.card-field-order-label { overflow-wrap: anywhere; }
|
||||
|
||||
/* Keep native filter controls visible despite the application's custom inputs. */
|
||||
.filter-native-choice input[type="checkbox"],
|
||||
.filter-native-choice input[type="radio"],
|
||||
@@ -836,6 +750,12 @@ body.sidebar-resizing-active {
|
||||
margin-inline-end: 6px;
|
||||
appearance: auto;
|
||||
}
|
||||
/* Why a date range was refused, below its buttons (sidebarFilters.js,
|
||||
rangeRefused): the browser's own bubble covered those buttons in Firefox. */
|
||||
.filter-date-range-error {
|
||||
color: #c0392b;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
.filter-native-group {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
@@ -853,20 +773,13 @@ body.sidebar-resizing-active {
|
||||
.filter-native-choice input { flex-shrink: 0; }
|
||||
.filter-native-choice:focus-within { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||
|
||||
/* Board Settings / Card and Board View reorder by drag and drop: the handle
|
||||
where the up/down arrows were when drag handles are on, else the icon and
|
||||
label themselves. */
|
||||
.card-field-order-handle.js-card-field-order-handle,
|
||||
/* Board View reorders by drag and drop: the handle when drag handles are on,
|
||||
else the icon and label themselves. Board Settings / Card has order arrows. */
|
||||
.board-view-order-handle,
|
||||
.card-field-order-drag-label,
|
||||
.board-view-drag-label {
|
||||
cursor: grab;
|
||||
}
|
||||
.card-field-order-handle.is-disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.js-card-field-order-sortable .ui-sortable-helper,
|
||||
.js-board-view-sortable .ui-sortable-helper {
|
||||
cursor: grabbing;
|
||||
background: var(--wekan-popup-background, #fff);
|
||||
|
||||
@@ -269,103 +269,234 @@ template(name="boardInfoOnMyBoardsPopup")
|
||||
i.fa.fa-arrows-h
|
||||
| {{_ 'personal-list-width'}}
|
||||
|
||||
//- Board Settings / Card - what the opened card and the minicard show, and
|
||||
in what order. Opened from the board's cog menu (boardMenuPopup above); it
|
||||
used to be split across the CARD's own menu ("Show on Card", side="card")
|
||||
and the MINICARD's own menu ("Show on Minicard", side="minicard"), and
|
||||
settingsSideClass() still hides one column when a side is passed.
|
||||
"Labels text" - the one PERSONAL setting here - stays reachable by a
|
||||
non-admin, who is passed `personalOnly=true` so every board-wide,
|
||||
admin-only row is hidden and only that one shows. client/lib/minicardLabelText.js
|
||||
//- ONE heading, "Card field order", over TWO lists: "Show on Minicard" in
|
||||
the board's minicard order and "Show on Card" in its card order. The lists
|
||||
are independent because the orders are: a field can sit third on the
|
||||
minicard and last on the card. Every row of either list is
|
||||
`[checkbox] [up] [down] icon label` - the checkbox is whether that side
|
||||
shows the field, the arrows move it on THAT side only, and the icon and
|
||||
label are the field's own (models/lib/cardSettingsRows.js). The rows are
|
||||
not written out here: the two `each` loops draw them from that table in
|
||||
the order models/lib/cardFieldOrder.js resolves for the board, the SAME
|
||||
source cardDetails.jade and minicard.jade render from, so what the popup
|
||||
lists top to bottom is what the card and the minicard draw top to bottom.
|
||||
A row whose arrows would do nothing (the card's title bar and its
|
||||
galleries, a minicard row that modifies another element rather than being
|
||||
one) keeps them, disabled, so every row reads the same.
|
||||
Up/down buttons rather than a drag library: no sortable-based reorder of a
|
||||
plain settings LIST exists elsewhere in this codebase to reuse, so buttons
|
||||
are the simpler, safer mechanism. Keyboard-reachable: `href="#"` and
|
||||
`role="button"`, titled with the existing move-up/move-down keys.
|
||||
client/components/sidebar/sidebar.js, tests/cardSettingsCoverage.test.cjs
|
||||
//- Board Settings / Card - Card Settings: what every opened card and every
|
||||
minicard of the board show, and in what order. Opened from the board's cog
|
||||
menu (boardMenuPopup above); the opened card has no Card Settings of its
|
||||
own. Whoever may not change the board sees the board's rows disabled and
|
||||
may still set their own ("Labels text").
|
||||
template(name="boardCardSettingsPopup")
|
||||
form.board-card-settings(class=settingsSideClass)
|
||||
div.check-div
|
||||
a.flex.js-ask-move-reason(class="{{#if askForMoveReason}}is-checked{{/if}}")
|
||||
i.fa(class="{{#if askForMoveReason}}fa-check{{else}}fa-square-o{{/if}}")
|
||||
span {{_ 'ask-move-reason'}}
|
||||
h4.card-field-order-heading {{_ 'card-field-order'}}
|
||||
.card-field-order-columns
|
||||
.card-field-order-column.card-field-order-column-draggable
|
||||
+boardDragSettings(section="card")
|
||||
.card-field-order-column.card-field-order-column-minicard.js-card-field-order-sortable(data-side="minicard")
|
||||
h4.card-field-order-column-heading {{_ 'show-on-minicard'}}
|
||||
+boardSettingsColumnActions(section="card" column="minicard")
|
||||
each row in minicardSettingsRows
|
||||
.card-field-order-row.js-card-field-order-row(class="{{#if row.personal}}card-settings-row-personal{{/if}}" class="{{#if row.positioned}}is-positioned{{/if}}" data-key="{{row.key}}" data-side="minicard")
|
||||
a.flex.card-field-order-toggle(class=row.toggle class="{{#if row.checked}}is-checked{{/if}}" title="{{row.title}}")
|
||||
i.fa(class="{{#if row.checked}}fa-check{{else}}fa-square-o{{/if}}")
|
||||
//- Reordered by drag and drop (client/components/sidebar/sidebar.js
|
||||
reorderOnDrop). With drag handles on, a row is dragged by its
|
||||
handle, its icon or its label; with them off there is no handle,
|
||||
and it is dragged by its icon or its label. Either way the Up and
|
||||
Down arrow keys move a focused row one step. A row that only
|
||||
modifies another (`after`) has no place of its own and no handle.
|
||||
if canModifyBoard
|
||||
if showDragHandles
|
||||
a.flex.card-field-order-move.card-field-order-handle(class="{{#if row.positioned}}js-card-field-order-handle{{else}}is-disabled{{/if}}" href="#" draggable="false" role="button" tabindex="{{#if row.positioned}}0{{else}}-1{{/if}}" aria-disabled="{{#if row.positioned}}false{{else}}true{{/if}}" title="{{#if row.positioned}}{{_ 'drag-to-reorder'}}{{else}}{{_ 'r-rule-disabled'}}{{/if}}" aria-label="{{_ 'drag-to-reorder'}}")
|
||||
i.fa.fa-arrows
|
||||
span.card-field-order-label(class="{{#if canModifyBoard}}{{#if row.positioned}}js-card-field-order-handle card-field-order-drag-label{{/if}}{{/if}}" tabindex="{{#if canModifyBoard}}{{#unless showDragHandles}}{{#if row.positioned}}0{{/if}}{{/unless}}{{/if}}" title="{{#if canModifyBoard}}{{#if row.positioned}}{{_ 'drag-to-reorder'}}{{/if}}{{/if}}")
|
||||
each icon in row.icons
|
||||
i.fa(class=icon)
|
||||
| {{row.title}}
|
||||
//- The user's own override of the board's "Labels text" default,
|
||||
tri-state: it says whether it is following the board or
|
||||
overriding it, with a way back to "follow the board" rather
|
||||
than cycling the checkbox through both states again.
|
||||
if row.labelTextOverride
|
||||
if row.labelTextOverridden
|
||||
|
|
||||
a(class=row.labelTextResetClass title="{{_ 'label-text-use-board-default'}}")
|
||||
| ({{_ 'label-text-overridden'}})
|
||||
else
|
||||
| ({{_ 'label-text-follows-board'}})
|
||||
.card-field-order-column.card-field-order-column-card.js-card-field-order-sortable(data-side="card")
|
||||
h4.card-field-order-column-heading {{_ 'show-on-card'}}
|
||||
+boardSettingsColumnActions(section="card" column="card")
|
||||
each row in cardSettingsRows
|
||||
.card-field-order-row.js-card-field-order-row(class="{{#if row.personal}}card-settings-row-personal{{/if}}" class="{{#if row.positioned}}is-positioned{{/if}}" data-key="{{row.key}}" data-side="card")
|
||||
a.flex.card-field-order-toggle(class=row.toggle class="{{#if row.checked}}is-checked{{/if}}" title="{{row.title}}")
|
||||
i.fa(class="{{#if row.checked}}fa-check{{else}}fa-square-o{{/if}}")
|
||||
//- Reordered by drag and drop (client/components/sidebar/sidebar.js
|
||||
reorderOnDrop). With drag handles on, a row is dragged by its
|
||||
handle, its icon or its label; with them off there is no handle,
|
||||
and it is dragged by its icon or its label. Either way the Up and
|
||||
Down arrow keys move a focused row one step. A row that only
|
||||
modifies another (`after`) has no place of its own and no handle.
|
||||
if canModifyBoard
|
||||
if showDragHandles
|
||||
a.flex.card-field-order-move.card-field-order-handle(class="{{#if row.positioned}}js-card-field-order-handle{{else}}is-disabled{{/if}}" href="#" draggable="false" role="button" tabindex="{{#if row.positioned}}0{{else}}-1{{/if}}" aria-disabled="{{#if row.positioned}}false{{else}}true{{/if}}" title="{{#if row.positioned}}{{_ 'drag-to-reorder'}}{{else}}{{_ 'r-rule-disabled'}}{{/if}}" aria-label="{{_ 'drag-to-reorder'}}")
|
||||
i.fa.fa-arrows
|
||||
span.card-field-order-label(class="{{#if canModifyBoard}}{{#if row.positioned}}js-card-field-order-handle card-field-order-drag-label{{/if}}{{/if}}" tabindex="{{#if canModifyBoard}}{{#unless showDragHandles}}{{#if row.positioned}}0{{/if}}{{/unless}}{{/if}}" title="{{#if canModifyBoard}}{{#if row.positioned}}{{_ 'drag-to-reorder'}}{{/if}}{{/if}}")
|
||||
each icon in row.icons
|
||||
i.fa(class=icon)
|
||||
| {{row.title}}
|
||||
if row.labelTextOverride
|
||||
if row.labelTextOverridden
|
||||
|
|
||||
a(class=row.labelTextResetClass title="{{_ 'label-text-use-board-default'}}")
|
||||
| ({{_ 'label-text-overridden'}})
|
||||
else
|
||||
| ({{_ 'label-text-follows-board'}})
|
||||
//- BOTH sides at once (docs/Features/Cards/Card-Settings.md): the Minicard
|
||||
pane first - at the start, the left in a left-to-right page - and the Card
|
||||
pane after it, side by side when the popup is wide enough and stacked
|
||||
(Minicard above Card) when it is not. Two instances of ONE template, each
|
||||
with its own fixed side: its sections in that side's order, its rows,
|
||||
checkboxes, hamburgers and drags all that side's. Each pane collapses by
|
||||
the caret at its top and scrolls on its own. The popup's own header is its
|
||||
title and its close button. client/components/sidebar/sidebar.css
|
||||
.card-settings-panes
|
||||
//- The splitter between the panes is dragged like the edge between two
|
||||
spreadsheet columns (or the Admin Panel left menu's): the Minicard pane's
|
||||
share of the width, kept per user (models/lib/cardSettingsSplit.js). Only
|
||||
side by side, and only while neither pane is collapsed. `nodragscroll`,
|
||||
like the left menu's grip, so a drag on it never pans the board.
|
||||
.card-settings-panes-container.js-card-settings-panes-container
|
||||
.card-settings-panes.js-card-settings-panes(class="{{#if anyPaneCollapsed}}has-collapsed-pane{{/if}}" style="--card-settings-split: {{splitFraction}}")
|
||||
+cardSettingsInline(side="minicard")
|
||||
.card-settings-splitter.js-card-settings-splitter.nodragscroll(
|
||||
role="separator" aria-orientation="vertical" tabindex="0"
|
||||
aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{splitPercent}}"
|
||||
title="{{_ 'card-drawing-resize'}}" aria-label="{{_ 'card-drawing-resize'}}")
|
||||
+cardSettingsInline(side="card")
|
||||
|
||||
|
||||
//- One side's checkbox of a row of Card Settings: the board's Show on Card or
|
||||
Show on Minicard, through the handler the row names (cardSettingsRows.js).
|
||||
Only the one clicked animates.
|
||||
template(name="cardSettingsSideToggle")
|
||||
span.card-settings-side(class="card-settings-side-{{row.side}}")
|
||||
a.flex.card-field-order-toggle(class="{{#if row.writable}}{{row.toggle}}{{else}}is-disabled{{/if}} {{#if row.checked}}is-checked{{/if}} {{#if row.animating}}is-animating{{/if}}" title="{{row.title}}" role="checkbox" aria-checked="{{#if row.checked}}true{{else}}false{{/if}}")
|
||||
.materialCheckBox(class="{{#if row.checked}}is-checked{{/if}}")
|
||||
|
||||
|
||||
//- A row's name in Card Settings, and what it is dragged by: with drag handles
|
||||
on, ONLY its handle - the rest of the row then scrolls the page, as a touch
|
||||
there means to; with them off, its icon and its name. The Up and Down arrow
|
||||
keys move a focused row one step. A row with no place of its own (one that
|
||||
only modifies another, or is drawn at a fixed place) is not dragged.
|
||||
template(name="cardSettingsRowLabel")
|
||||
span.card-settings-line-label(class="{{#if row.draggable}}{{#unless row.showDragHandles}}js-card-field-order-handle card-field-order-drag-label{{/unless}}{{/if}}" tabindex="{{#if row.draggable}}{{#unless row.showDragHandles}}0{{/unless}}{{/if}}" title="{{#if row.draggable}}{{#unless row.showDragHandles}}{{_ 'drag-to-reorder'}}{{/unless}}{{/if}}")
|
||||
if row.draggable
|
||||
if row.showDragHandles
|
||||
a.flex.card-field-order-move.card-field-order-handle.js-card-field-order-handle(href="#" draggable="false" role="button" tabindex="0" title="{{_ 'drag-to-reorder'}}" aria-label="{{_ 'drag-to-reorder'}}")
|
||||
i.fa.fa-arrows
|
||||
each icon in row.icons
|
||||
i.fa(class=icon aria-hidden="true")
|
||||
| {{row.title}}
|
||||
//- The user's own override of the board's "Labels text" default,
|
||||
tri-state: following the board or overriding it, with a way back to
|
||||
following it.
|
||||
if row.labelTextOverride
|
||||
if row.labelTextOverridden
|
||||
|
|
||||
a(class=row.labelTextResetClass title="{{_ 'label-text-use-board-default'}}")
|
||||
| ({{_ 'label-text-overridden'}})
|
||||
else
|
||||
| ({{_ 'label-text-follows-board'}})
|
||||
|
||||
|
||||
//- One pane of Card Settings, the body of Board Settings / Card
|
||||
(docs/Features/Cards/Card-Settings.md), for ONE side - `side` is "minicard"
|
||||
or "card", fixed by boardCardSettingsPopup: a header line with the caret
|
||||
that collapses the whole pane at its top start and the side's title, then
|
||||
the sections as checklists - a title per section with its own checkbox (the
|
||||
whole section shown on this side or not), its rows as the items, the
|
||||
checkbox this side's board-wide setting; a section with one row is one
|
||||
line. Sections and rows are dragged like checklists and their items and
|
||||
collapse like them; the sections in THIS side's order (the opened card and
|
||||
the minicard draw them in it); the rest of what a section or a row has is in
|
||||
its hamburger (cardSettingsSectionPopup, cardSettingsItemPopup) - drawn only
|
||||
when it has something in it. The body is hidden, not removed, while the pane
|
||||
is collapsed, so the drags bound to it stay bound.
|
||||
client/components/sidebar/sidebar.js inlineSectionsOf, cardSettingsEvents.
|
||||
template(name="cardSettingsInline")
|
||||
.card-settings-inline.card-settings-pane.js-card-settings-inline.js-card-settings-pane(data-side="{{showSide}}" class="card-settings-pane-{{showSide}} {{#if paneCollapsed}}is-pane-collapsed{{/if}}")
|
||||
.card-settings-pane-head
|
||||
a.card-settings-pane-collapse.js-toggle-card-settings-pane(href="#" role="button" aria-expanded="{{#if paneCollapsed}}false{{else}}true{{/if}}" title="{{_ 'collapse'}}" aria-label="{{_ 'collapse'}}")
|
||||
i.fa(class="{{#if paneCollapsed}}fa-caret-right{{else}}fa-caret-down{{/if}}" aria-hidden="true")
|
||||
h3.card-settings-pane-title {{paneTitle}}
|
||||
.card-settings-pane-body.js-card-settings-pane-body(hidden=paneCollapsed)
|
||||
.card-settings-inline-sections.js-card-settings-sections-sortable(data-side="{{showSide}}")
|
||||
each section in inlineSections
|
||||
+cardSettingsInlineSection(section=section side=showSide)
|
||||
|
||||
|
||||
//- One section of Card Settings, drawn like a checklist: a
|
||||
title that drags and collapses it, left of it a checkbox - whether the WHOLE
|
||||
section is shown on the side Card Settings shows - a count of the ticked
|
||||
rows, and its hamburger when it has anything in it; under it the rows, each
|
||||
a checkbox, its name and - when it has one - its own hamburger. Collapsed,
|
||||
the rows are hidden, not removed, so the drag bound to the list stays bound.
|
||||
A section with ONE row on this side is one line (`section.single`): one
|
||||
checkbox - the field's own, through its real checkbox kept out of sight -
|
||||
its name, the section's hamburger. Custom Fields' items are the board's
|
||||
custom fields, with a "+" under them (cardSettingsCustomFields.js).
|
||||
template(name="cardSettingsInlineSection")
|
||||
.card-settings-inline-section.js-card-settings-section(data-section="{{section.id}}" class="{{#if section.collapsed}}is-collapsed{{/if}} {{#if section.draggable}}is-draggable{{/if}} {{#if section.single}}is-single{{/if}} {{#unless section.shown}}is-section-hidden{{/unless}}")
|
||||
.card-settings-inline-section-head(class="{{#if section.flagRow}}js-card-field-order-row{{/if}}" data-key="{{#if section.flagRow}}{{section.flagRow.key}}{{/if}}" data-side="{{#if section.flagRow}}{{section.flagRow.side}}{{/if}}")
|
||||
unless section.single
|
||||
a.card-settings-inline-collapse.js-toggle-card-settings-section(href="#" role="button" aria-expanded="{{#if section.collapsed}}false{{else}}true{{/if}}" title="{{_ 'collapse'}}" aria-label="{{_ 'collapse'}}")
|
||||
i.fa(class="{{#if section.collapsed}}fa-caret-right{{else}}fa-caret-down{{/if}}" aria-hidden="true")
|
||||
if section.draggable
|
||||
if section.showDragHandles
|
||||
a.card-settings-inline-section-move.js-card-settings-section-handle(href="#" draggable="false" role="button" tabindex="0" title="{{_ 'drag-to-reorder'}}" aria-label="{{_ 'drag-to-reorder'}}")
|
||||
i.fa.fa-arrows(aria-hidden="true")
|
||||
//- The section's checkbox: for one line and for Custom Fields the
|
||||
field's own flag (and the section shown); otherwise whether the whole
|
||||
section is shown on this side. A board admin's, like the rows.
|
||||
span.card-settings-side.card-settings-section-side
|
||||
if section.flagRow
|
||||
a.flex.card-field-order-toggle.card-settings-section-toggle(class="{{#if section.flagWritable}}js-card-settings-single-toggle{{else}}is-disabled{{/if}} {{#if section.flagChecked}}is-checked{{/if}} {{#if section.flagRow.animating}}is-animating{{/if}}" data-side="{{section.side}}" title="{{section.sideTitle}}" aria-label="{{section.sideTitle}}" role="checkbox" aria-checked="{{#if section.flagChecked}}true{{else}}false{{/if}}")
|
||||
.materialCheckBox(class="{{#if section.flagChecked}}is-checked{{/if}}")
|
||||
else
|
||||
a.flex.card-field-order-toggle.card-settings-section-toggle(class="{{#if section.writable}}js-card-settings-section-shown{{else}}is-disabled{{/if}} {{#if section.shown}}is-checked{{/if}} {{#if section.animatingShown}}is-animating{{/if}}" data-side="{{section.side}}" title="{{section.sideTitle}}" aria-label="{{section.sideTitle}}" role="checkbox" aria-checked="{{#if section.shown}}true{{else}}false{{/if}}")
|
||||
.materialCheckBox(class="{{#if section.shown}}is-checked{{/if}}")
|
||||
if section.flagRow
|
||||
//- The field's real checkbox, out of sight: the line's checkbox
|
||||
clicks it, so the field is set by its own handler.
|
||||
span.card-settings-single-real.js-card-settings-single-real(hidden aria-hidden="true")
|
||||
+cardSettingsSideToggle(row=section.flagRow)
|
||||
//- Like a row: with drag handles on, only the handle drags the section.
|
||||
h4.card-settings-inline-section-title(class="{{#if section.draggable}}{{#unless section.showDragHandles}}js-card-settings-section-handle{{/unless}}{{/if}}" tabindex="{{#if section.draggable}}{{#unless section.showDragHandles}}0{{/unless}}{{/if}}" title="{{#if section.draggable}}{{#unless section.showDragHandles}}{{_ 'drag-to-reorder'}}{{/unless}}{{/if}}")
|
||||
i.fa(class=section.icon aria-hidden="true")
|
||||
| {{section.title}}
|
||||
unless section.single
|
||||
span.card-settings-inline-section-count {{section.checkedCount}}/{{section.total}}
|
||||
if section.hasMenu
|
||||
a.card-settings-inline-menu.js-open-card-settings-section-menu(href="#" role="button" class="{{#if section.menuOpen}}is-open{{/if}}" aria-expanded="{{#if section.menuOpen}}true{{else}}false{{/if}}" title="{{_ 'actions'}}" aria-label="{{_ 'actions'}}")
|
||||
i.fa.fa-bars(aria-hidden="true")
|
||||
//- What the hamburger holds opens HERE, under the section's line, and
|
||||
pushes the rest down - not in a popup (cardSettingsInlinePanel).
|
||||
if section.menuOpen
|
||||
+cardSettingsInlinePanel(kind="section" sectionId=section.id side=side)
|
||||
unless section.single
|
||||
if section.customFields
|
||||
.card-settings-inline-items.js-card-settings-custom-fields-sortable(data-side="{{side}}")
|
||||
each field in section.customFieldItems
|
||||
.card-settings-line.card-settings-inline-item.card-settings-custom-field.js-card-settings-custom-field(class="{{#if field.draggable}}is-positioned{{/if}}" data-id="{{field.id}}" data-side="{{field.side}}")
|
||||
span.card-settings-side
|
||||
a.flex.card-field-order-toggle(class="{{#if field.writable}}js-card-settings-custom-field-toggle{{else}}is-disabled{{/if}} {{#if field.checked}}is-checked{{/if}} {{#if field.animating}}is-animating{{/if}}" title="{{field.sideTitle}}" role="checkbox" aria-checked="{{#if field.checked}}true{{else}}false{{/if}}")
|
||||
.materialCheckBox(class="{{#if field.checked}}is-checked{{/if}}")
|
||||
span.card-settings-line-label(class="{{#if field.draggable}}{{#unless field.showDragHandles}}js-card-settings-custom-field-handle card-field-order-drag-label{{/unless}}{{/if}}" tabindex="{{#if field.draggable}}{{#unless field.showDragHandles}}0{{/unless}}{{/if}}" title="{{#if field.draggable}}{{#unless field.showDragHandles}}{{_ 'drag-to-reorder'}}{{/unless}}{{/if}}")
|
||||
if field.draggable
|
||||
if field.showDragHandles
|
||||
a.flex.card-field-order-move.card-field-order-handle.js-card-settings-custom-field-handle(href="#" draggable="false" role="button" tabindex="0" title="{{_ 'drag-to-reorder'}}" aria-label="{{_ 'drag-to-reorder'}}")
|
||||
i.fa.fa-arrows
|
||||
i.fa.fa-list-alt(aria-hidden="true")
|
||||
| {{field.name}}
|
||||
if field.hasMenu
|
||||
a.card-settings-inline-menu.js-card-settings-edit-custom-field(href="#" role="button" class="{{#if field.menuOpen}}is-open{{/if}}" aria-expanded="{{#if field.menuOpen}}true{{else}}false{{/if}}" title="{{_ 'actions'}}" aria-label="{{_ 'actions'}}")
|
||||
i.fa.fa-bars(aria-hidden="true")
|
||||
if field.menuOpen
|
||||
+cardSettingsInlinePanel(kind="field" fieldId=field.id side=side)
|
||||
//- Under the items, also when there are none: a new custom field of
|
||||
the board, which then is the last item.
|
||||
if section.writable
|
||||
.card-settings-add-custom-field
|
||||
a.card-settings-add-custom-field-button.js-card-settings-add-custom-field(href="#" role="button" class="{{#if section.createOpen}}is-open{{/if}}" aria-expanded="{{#if section.createOpen}}true{{else}}false{{/if}}" title="{{_ 'createCustomField'}}" aria-label="{{_ 'createCustomField'}}")
|
||||
i.fa.fa-plus(aria-hidden="true")
|
||||
if section.createOpen
|
||||
+cardSettingsInlinePanel(kind="create" side=side)
|
||||
else
|
||||
.card-settings-inline-items.js-card-field-order-sortable(data-side="{{side}}")
|
||||
each row in section.rows
|
||||
.card-settings-line.card-settings-inline-item.js-card-field-order-row(class="{{#if row.personal}}card-settings-row-personal{{/if}} {{#if row.positioned}}is-positioned{{/if}}" data-key="{{row.key}}" data-side="{{row.side}}")
|
||||
+cardSettingsSideToggle(row=row)
|
||||
+cardSettingsRowLabel(row=row)
|
||||
if row.hasMenu
|
||||
a.card-settings-inline-menu.js-open-card-settings-item-menu(href="#" role="button" class="{{#if row.menuOpen}}is-open{{/if}}" aria-expanded="{{#if row.menuOpen}}true{{else}}false{{/if}}" title="{{_ 'actions'}}" aria-label="{{_ 'actions'}}")
|
||||
i.fa.fa-bars(aria-hidden="true")
|
||||
if row.menuOpen
|
||||
+cardSettingsInlinePanel(kind="row" rowKey=row.key sectionId=section.id side=side)
|
||||
|
||||
|
||||
//- What a hamburger of Card Settings holds, opened UNDER its line inside the
|
||||
pane - an accordion, not a popup (docs/Features/Cards/Card-Settings.md
|
||||
Hamburgers): its hamburger shows it is open (`is-open`, aria-expanded) and
|
||||
the panel hangs from it. One open at a time per pane; the same hamburger
|
||||
again, its X or Escape closes it. `kind`:
|
||||
section - the section's board actions (Subtasks: the subtask settings
|
||||
themselves); for Card, what is not a field - asking for a move
|
||||
reason and the Draggable table;
|
||||
row - Labels text's way back to the board's default;
|
||||
field - the custom field's edit form; create - the + under Custom
|
||||
Fields, a new custom field's form.
|
||||
No event map of its own: its checkboxes are handled by the pane's
|
||||
(sidebar.js cardSettingsEvents), so a click is handled once. The forms and
|
||||
the subtask settings keep their own handlers, inside
|
||||
`.js-card-settings-panel-own-handlers`, which the pane's leave alone.
|
||||
template(name="cardSettingsInlinePanel")
|
||||
.card-settings-inline-panel.js-card-settings-panel(class="card-settings-inline-panel-{{kind}}" data-kind="{{kind}}")
|
||||
a.card-settings-inline-panel-close.js-close-card-settings-panel(href="#" role="button" title="{{_ 'close'}}" aria-label="{{_ 'close'}}")
|
||||
i.fa.fa-times(aria-hidden="true")
|
||||
if $eq kind "section"
|
||||
.card-settings-section-menu
|
||||
if sectionIsGeneral
|
||||
//- What is not a field.
|
||||
div.check-div.card-settings-board-option.card-settings-ask-move-reason
|
||||
a.flex.card-ask-move-reason(class="{{#if canModifyBoard}}js-ask-move-reason{{else}}is-disabled{{/if}} {{#if isAnimating 'askForMoveReason'}}is-animating{{/if}}" role="checkbox" aria-checked="{{#if askForMoveReason}}true{{else}}false{{/if}}")
|
||||
.materialCheckBox(class="{{#if askForMoveReason}}is-checked{{/if}}")
|
||||
span {{_ 'ask-move-reason'}}
|
||||
each action in sectionActions
|
||||
.card-settings-section-action.js-card-settings-panel-own-handlers(data-action="{{action.id}}")
|
||||
h4.card-settings-section-action-title
|
||||
i.fa(class=action.icon aria-hidden="true")
|
||||
| {{action.title}}
|
||||
+Template.dynamic(template=action.template)
|
||||
if sectionIsGeneral
|
||||
+boardDragSettings(section="card")
|
||||
if $eq kind "row"
|
||||
with itemRow
|
||||
.card-settings-item-menu.js-card-field-order-row(data-key="{{key}}" data-side="{{side}}")
|
||||
if menu.labelTextReset
|
||||
div.check-div.card-settings-board-option
|
||||
a(class=labelTextResetClass) {{_ 'label-text-use-board-default'}}
|
||||
if $eq kind "field"
|
||||
if fieldDefinition
|
||||
.card-settings-panel-form.js-card-settings-panel-own-handlers
|
||||
+Template.dynamic(template="createCustomFieldPopup" data=fieldDefinition)
|
||||
if $eq kind "create"
|
||||
.card-settings-panel-form.js-card-settings-panel-own-handlers
|
||||
+Template.dynamic(template="createCustomFieldPopup" data=createData)
|
||||
|
||||
|
||||
//- The board's Subtasks Settings. Its own template, because the same settings
|
||||
@@ -661,8 +792,8 @@ template(name="boardMenuPopup")
|
||||
board-wide list of toggles behind two different places depending on
|
||||
what you were looking at, and which also let anyone open it for the one
|
||||
PERSONAL row in it ("Labels text" - stored on the user's own profile,
|
||||
not the board). It is back here as one board setting, and keeps that
|
||||
same personal-only fallback for a non-admin.
|
||||
not the board). It is back here as one board setting; a non-admin sees
|
||||
the board's rows disabled and may still set that personal row.
|
||||
client/components/cards/cardDetails.jade
|
||||
ul.pop-over-list
|
||||
if currentUser.isBoardAdmin
|
||||
|
||||
@@ -17,17 +17,20 @@ const {
|
||||
CARD_LAYOUT,
|
||||
MINICARD_LAYOUT,
|
||||
applyLayoutOrder,
|
||||
moveKey,
|
||||
placeKey,
|
||||
isMovableKey,
|
||||
} = require('/models/lib/cardFieldOrder');
|
||||
const { rowsForSide, CARD_SETTINGS_ROWS } = require('/models/lib/cardSettingsRows');
|
||||
const { isRowVisible } = require('/models/lib/cardFieldVisibility');
|
||||
const cardSettingsSections = require('/models/lib/cardSettingsSections');
|
||||
const cardSettingsCustomFields = require('/models/lib/cardSettingsCustomFields');
|
||||
const cardSettingsSplit = require('/models/lib/cardSettingsSplit');
|
||||
import { invalidateScrumNames } from '/client/components/boards/scrum/scrumFields';
|
||||
import { InfiniteScrolling } from '/client/lib/infiniteScrolling';
|
||||
import '/client/components/boards/exportScope';
|
||||
import AccessibilitySettings from '/models/accessibilitySettings';
|
||||
import Boards from '/models/boards';
|
||||
import CustomFields from '/models/customFields';
|
||||
import CustomBoardRoles from '/models/customBoardRoles';
|
||||
const { normalizeCustomCapabilities } = require('/models/lib/boardRoleCapabilities');
|
||||
// What a custom role's description lists, in this order (#3022).
|
||||
@@ -693,17 +696,13 @@ Template.boardMenuPopup.events({
|
||||
'click .js-open-board-list-settings': Popup.open('boardListSettings', { titleKey: 'list' }),
|
||||
// #2489: WIP Limit Groups is reached from Board Settings / Swimlane now -
|
||||
// its click handler sits with the Swimlane popup's events below.
|
||||
// A non-admin may still open this for the one PERSONAL row in it ("Labels
|
||||
// text"), same as the old showOnMinicardPopup did - `personalOnly` hides
|
||||
// every other row (client/components/sidebar/sidebar.css). Overriding
|
||||
// Board Settings / Card: Card Settings with no card. Overriding
|
||||
// `currentData` (rather than passing openOptions) is what actually reaches
|
||||
// boardCardSettingsPopup's data context: Popup.open only falls back to
|
||||
// `dataContextIfCurrentDataIsUndefined` when currentData() is falsy, and
|
||||
// here it is always the board.
|
||||
// here it is always the board - whose fields are not Card Settings' data.
|
||||
'click .js-open-board-card-settings'(event) {
|
||||
const isAdmin = Boolean(ReactiveCache.getCurrentUser()?.isBoardAdmin());
|
||||
const data = isAdmin ? {} : { personalOnly: true };
|
||||
Popup.open('boardCardSettings').call({ currentData: () => data }, event);
|
||||
Popup.open('boardCardSettings').call({ currentData: () => ({}) }, event);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -732,15 +731,28 @@ Template.boardMenuPopup.onCreated(function() {
|
||||
// `handle` is the drag handle when drag handles are on, else the icon and
|
||||
// label (the templates put the class on whichever it is).
|
||||
// `axis`: 'y' for a single column; false where the rows flow into several
|
||||
// columns (Board Settings / Card, #6796), so a row can be dropped sideways.
|
||||
function reorderOnDrop($list, { items, handle, keyOf, drop, axis = 'y' }) {
|
||||
// columns, so a row can be dropped sideways.
|
||||
// `ownListOnly`: an item dropped outside its own list - on another, unrelated
|
||||
// list - goes back to where it was, and nothing is written (Card Settings: a
|
||||
// row belongs to its section).
|
||||
function reorderOnDrop($list, { items, handle, keyOf, drop, axis = 'y', ownListOnly = false }) {
|
||||
if (!$list.length || typeof $list.sortable !== 'function') return;
|
||||
let outside = false;
|
||||
let droppedOutside = false;
|
||||
$list.sortable({
|
||||
items, handle, axis, tolerance: 'pointer', distance: 5,
|
||||
start() { outside = false; droppedOutside = false; },
|
||||
out() { outside = true; },
|
||||
over() { outside = false; },
|
||||
// Read where it was dropped HERE: on release jQuery UI fires a delayed
|
||||
// "out" for the list it was over just before "stop", so in stop the item
|
||||
// always reads as outside.
|
||||
beforeStop() { droppedOutside = outside; },
|
||||
stop(evt, ui) {
|
||||
const key = keyOf(ui.item[0]);
|
||||
const keys = $list.find(items).toArray().map(keyOf);
|
||||
$list.sortable('cancel');
|
||||
if (ownListOnly && droppedOutside) return;
|
||||
if (key) drop(key, keys);
|
||||
},
|
||||
});
|
||||
@@ -2018,7 +2030,12 @@ Template.boardSubtaskSettingsBody.events({
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardCardSettingsPopup.onCreated(function() {
|
||||
// Every template that draws Card Settings - the popup, the opened card's own
|
||||
// Card Settings and its hamburgers - has the state its shared handlers read
|
||||
// (cardSettingsEvents below).
|
||||
function setUpCardSettings() {
|
||||
// The one checkbox that animates: the one just clicked.
|
||||
this.animating = new ReactiveVar(null);
|
||||
// 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
|
||||
@@ -2028,23 +2045,15 @@ Template.boardCardSettingsPopup.onCreated(function() {
|
||||
this.autorun(() => {
|
||||
this.currentBoard = Utils.getCurrentBoard();
|
||||
});
|
||||
});
|
||||
|
||||
// The card this popup was opened for, when it was opened for one. Board
|
||||
// Settings / Card Settings has no card - it is about every card of the board -
|
||||
// so the two rows that belong to a card are only ever drawn from the minicard's
|
||||
// own menu, which passes it in. client/components/sidebar/sidebar.jade
|
||||
function settingsCard() {
|
||||
const passed = (Template.currentData() || {}).card;
|
||||
return passed && passed._id ? ReactiveCache.getCard(passed._id) : null;
|
||||
}
|
||||
|
||||
|
||||
// The rows of one column of Board Settings / Card, ready for the template:
|
||||
// the board's order for that side (models/lib/cardFieldOrder.js) applied to
|
||||
// the row table (models/lib/cardSettingsRows.js), each row with the checkbox
|
||||
// state its own helper reports, its icons, its translated label, and whether
|
||||
// each arrow would do anything. `data` is the popup's data context, so the
|
||||
// helpers that read it (showsListOnMinicard, settingsSideClass) see what they
|
||||
// helpers that read it (showsListOnMinicard) see what they
|
||||
// saw when the rows were written out by hand.
|
||||
function buildCardSettingsRows(side, data) {
|
||||
const boardId = Session.get('currentBoard');
|
||||
@@ -2053,11 +2062,11 @@ function buildCardSettingsRows(side, data) {
|
||||
const stored = side === 'card' ? currentBoard?.cardFieldOrder : currentBoard?.minicardFieldOrder;
|
||||
const order = applyLayoutOrder(stored, layout);
|
||||
return rowsForSide(side, order)
|
||||
// "List title" is a CARD's setting: only for somebody who may change the
|
||||
// card, the same gate the hand-written row had (`if canModifyCard`).
|
||||
// Only when the popup was opened for a card (its own menu passes it in):
|
||||
// from Board Settings there is no card for the row to change.
|
||||
.filter(row => !row[side].needsCard || (Boolean(data?.card?._id) && Utils.canModifyCard(data)))
|
||||
// "List title" is a CARD's own setting (`needsCard`): Card Settings is
|
||||
// Board Settings / Card, about every card of the board, with no card for
|
||||
// the row to change - so it is never drawn here; each card sets it in its
|
||||
// own menu (cardDetails.js js-toggle-show-list-on-minicard).
|
||||
.filter(row => !row[side].needsCard)
|
||||
// A field the administrator unticked in Admin Panel / Settings /
|
||||
// Visibility / Features is not offered on any board; the board keeps its
|
||||
// own choice for when it is ticked again. models/lib/cardFieldVisibility.js
|
||||
@@ -2090,50 +2099,70 @@ function buildCardSettingsRows(side, data) {
|
||||
});
|
||||
}
|
||||
|
||||
// A named object rather than an inline `.helpers({...})`: the two row
|
||||
// builders below (cardSettingsRows / minicardSettingsRows) ask each row's
|
||||
// A named object rather than an inline `.helpers({...})`: the row builder
|
||||
// (buildCardSettingsRows) asks each row's
|
||||
// "is it checked" helper by name - allowsLabels(), showsListOnMinicard(), ...
|
||||
// - so a row's checkbox reads exactly what its old hand-written `{{#if
|
||||
// allowsLabels}}` read, defaults and fallbacks included, without a second
|
||||
// copy of that logic. Registered at the end of the object.
|
||||
const boardCardSettingsHelpers = {
|
||||
askForMoveReason() { return Template.instance().currentBoard.askForMoveReason; },
|
||||
// The rows of section `id` on one side, in that side's own order - the open
|
||||
// pane's rows in the popup, and each checklist of the opened card's Card
|
||||
// Settings. `animatingKey` is the checkbox just clicked.
|
||||
function sectionRowsOf(id, data, side, animatingKey) {
|
||||
const section = cardSettingsSections.SECTIONS.find(s => s.id === id);
|
||||
const keys = new Set(section ? section.keys : []);
|
||||
const board = ReactiveCache.getBoard(Session.get('currentBoard'));
|
||||
const writable = Boolean(board && Utils.canModifyBoard && Utils.canModifyBoard());
|
||||
const showDragHandles = Utils.showDragHandles();
|
||||
const rowOf = key => CARD_SETTINGS_ROWS.find(r => r.key === key);
|
||||
return buildCardSettingsRows(side, data)
|
||||
.filter(built => keys.has(built.key))
|
||||
.map(built => ({
|
||||
key: built.key,
|
||||
side,
|
||||
icons: built.icons,
|
||||
title: cardSettingsSections.labelInSection(rowOf(built.key), section).map(k => TAPi18n.__(k)).join(rowOf(built.key).labelSeparator || ' '),
|
||||
toggle: built.toggle,
|
||||
checked: built.checked,
|
||||
personal: Boolean(built.personal),
|
||||
// The board's rows for a board admin; a personal row for its owner.
|
||||
writable: writable || Boolean(built.personal),
|
||||
animating: animatingKey === `${built.key}:${side}`,
|
||||
positioned: built.positioned,
|
||||
// Dragged: a row with a place of its own, by a board admin.
|
||||
draggable: writable && built.positioned,
|
||||
showDragHandles,
|
||||
labelTextOverride: built.labelTextOverride,
|
||||
labelTextOverridden: built.labelTextOverridden,
|
||||
labelTextResetClass: built.labelTextResetClass,
|
||||
}));
|
||||
}
|
||||
|
||||
// The board actions of section `id`, in that section's hamburger in Card
|
||||
// Settings (models/lib/cardSettingsSections.js CARD_ACTIONS): for who may
|
||||
// change the board.
|
||||
function sectionCardActionsOf(id) {
|
||||
const mayBoard = Boolean(Utils.canModifyBoard && Utils.canModifyBoard());
|
||||
return cardSettingsSections.cardActionsOf(id)
|
||||
.filter(action => action.board && mayBoard)
|
||||
.map(action => ({
|
||||
...action,
|
||||
title: action.label.map(k => TAPi18n.__(k)).join(action.labelSeparator || ' '),
|
||||
}));
|
||||
}
|
||||
|
||||
const boardCardSettingsHelpers = {
|
||||
// Read from the board, so the checkbox follows its own click (tpl.currentBoard
|
||||
// is a plain property, which no helper re-runs for).
|
||||
askForMoveReason() { return ReactiveCache.getBoard(Session.get('currentBoard'))?.askForMoveReason === true; },
|
||||
|
||||
isAnimating(key) { return Template.instance().animating.get() === key; },
|
||||
allowsMinicardCollapse() { return ReactiveCache.getBoard(Session.get('currentBoard'))?.allowsMinicardCollapse !== false; },
|
||||
labelsAboveTitleOnMinicard() { return ReactiveCache.getBoard(Session.get('currentBoard'))?.labelsAboveTitleOnMinicard === true; },
|
||||
allowsChecklistDueDate() { return ReactiveCache.getBoard(Session.get('currentBoard'))?.allowsChecklistDueDate !== false; },
|
||||
allowsChecklistTitle() { return ReactiveCache.getBoard(Session.get('currentBoard'))?.allowsChecklistTitle !== false; },
|
||||
// The work item type badge was on every minicard before it had a row: on unless turned off.
|
||||
allowsIssueTypeOnMinicard() { return ReactiveCache.getBoard(Session.get('currentBoard'))?.allowsIssueTypeOnMinicard !== false; },
|
||||
// Board Settings / Card Settings shows both columns - "Show on Card" and
|
||||
// "Show on Minicard" beside each other. The card's own menu and the
|
||||
// minicard's menu open the SAME popup asking for one of them, and the other
|
||||
// column is hidden by this class rather than by a second copy of the list:
|
||||
// twenty-four settings written out twice would drift the first time one is
|
||||
// added. client/components/sidebar/sidebar.css
|
||||
settingsSideClass() {
|
||||
const data = Template.currentData() || {};
|
||||
const classes = [];
|
||||
if (data.side === 'card') classes.push('show-card-only');
|
||||
if (data.side === 'minicard') classes.push('show-minicard-only');
|
||||
// A user who is not a board admin may still set the one PERSONAL row of
|
||||
// this table - "Labels text" - and none of the board's own. They get that
|
||||
// row alone rather than a table of checkboxes the server would refuse.
|
||||
if (data.personalOnly) classes.push('show-personal-only');
|
||||
return classes.join(' ');
|
||||
},
|
||||
|
||||
// The rows of the "Show on Card" list, in the board's card order, and of
|
||||
// the "Show on Minicard" list, in its minicard order - each row with its
|
||||
// checkbox state, its handler class, its icons and its translated label,
|
||||
// and whether each arrow does anything. models/lib/cardSettingsRows.js
|
||||
// is the table, models/lib/cardFieldOrder.js the order.
|
||||
cardSettingsRows() {
|
||||
return buildCardSettingsRows('card', this);
|
||||
},
|
||||
minicardSettingsRows() {
|
||||
return buildCardSettingsRows('minicard', this);
|
||||
},
|
||||
|
||||
// Board-level DEFAULT (#4256, Board Settings): whether this board shows
|
||||
// label text on its minicards unless a user overrides it. Checked means
|
||||
@@ -2163,19 +2192,6 @@ const boardCardSettingsHelpers = {
|
||||
return typeof (currentUser.profile || {}).showLabelTextOverride === 'boolean';
|
||||
},
|
||||
|
||||
// "List title" is the CARD's own setting, not the board's, so the card is
|
||||
// passed in with the side (showOnMinicardPopup) rather than looked up: a
|
||||
// minicard's menu is opened from the board, where there is no "current card".
|
||||
// Re-read from the collection so the checkbox follows its own click.
|
||||
showsListOnMinicard() {
|
||||
const card = settingsCard();
|
||||
return Boolean(card && card.showListOnMinicard);
|
||||
},
|
||||
// ...and on the opened card.
|
||||
showsListOnCard() {
|
||||
const card = settingsCard();
|
||||
return Boolean(card && card.showListOnCard);
|
||||
},
|
||||
// The labels text of the OPENED card, the user's override included.
|
||||
showsCardLabelText() {
|
||||
return resolveShowCardLabelText(ReactiveCache.getBoard(Session.get('currentBoard')));
|
||||
@@ -2437,7 +2453,7 @@ const boardCardSettingsHelpers = {
|
||||
allowsCoverAttachmentOnCard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.allowsCoverAttachmentOnCard : false;
|
||||
return currentBoard ? currentBoard.allowsCoverAttachmentOnCard !== false : false;
|
||||
},
|
||||
allowsAttachments() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
@@ -2564,33 +2580,11 @@ const boardCardSettingsHelpers = {
|
||||
},
|
||||
};
|
||||
boardCardSettingsHelpers.showDragHandles = () => Utils.showDragHandles();
|
||||
Template.boardCardSettingsPopup.helpers(boardCardSettingsHelpers);
|
||||
|
||||
// Each column reorders by drag and drop. The drop says where the row should
|
||||
// be among the rows that have a place; placeKey walks the same steps the
|
||||
// arrows took there, so the layout's rules - a pinned header first, a section
|
||||
// moving as one - still hold (models/lib/cardFieldOrder.js).
|
||||
Template.boardCardSettingsPopup.onRendered(function () {
|
||||
this.$('.js-card-field-order-sortable').each((i, column) => {
|
||||
const side = column.dataset.side;
|
||||
reorderOnDrop(this.$(column), {
|
||||
items: '> .js-card-field-order-row.is-positioned', handle: '.js-card-field-order-handle',
|
||||
axis: false,
|
||||
keyOf: el => el.dataset.key,
|
||||
drop: (key, keys) => {
|
||||
const board = ReactiveCache.getBoard(Session.get('currentBoard'));
|
||||
if (!board) return;
|
||||
const layout = side === 'minicard' ? MINICARD_LAYOUT : CARD_LAYOUT;
|
||||
const stored = side === 'minicard' ? board.minicardFieldOrder : board.cardFieldOrder;
|
||||
const movable = keys.filter(k => isMovableKey(layout, k));
|
||||
const order = placeKey(stored, key, movable.indexOf(key), layout);
|
||||
if (order.join(' ') === applyLayoutOrder(stored, layout).join(' ')) return;
|
||||
if (side === 'minicard') board.setMinicardFieldOrder(order);
|
||||
else board.setCardFieldOrder(order);
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
// The order of each side is set with the up and down arrows of a row's side
|
||||
// cell (moveCardSettingsRow below), or the Up and Down keys on a focused
|
||||
// arrow. The sections show a few rows at a time, so a drag between them had
|
||||
// nowhere to drop.
|
||||
|
||||
// #4448: an up/down arrow of Board Settings / Card. The row says which field
|
||||
// and which side (data-key / data-side, because `each row in` keeps the
|
||||
@@ -2598,35 +2592,124 @@ Template.boardCardSettingsPopup.onRendered(function () {
|
||||
// click rather than taken from the row, so two quick clicks each move from
|
||||
// where the previous one actually left it; and the move goes through the
|
||||
// board's setter, which normalises it. models/lib/cardFieldOrder.js
|
||||
function moveCardSettingsRow(evt, direction) {
|
||||
evt.preventDefault();
|
||||
if (evt.currentTarget.classList.contains('is-disabled')) return;
|
||||
const rowEl = evt.currentTarget.closest('.js-card-field-order-row');
|
||||
if (!rowEl) return;
|
||||
const { key, side } = rowEl.dataset;
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
if (!currentBoard || !key) return;
|
||||
if (side === 'minicard') {
|
||||
currentBoard.setMinicardFieldOrder(moveKey(currentBoard.minicardFieldOrder, key, direction, MINICARD_LAYOUT));
|
||||
} else {
|
||||
currentBoard.setCardFieldOrder(moveKey(currentBoard.cardFieldOrder, key, direction, CARD_LAYOUT));
|
||||
}
|
||||
// Each list of Card Settings - Show on Card, Show on Minicard - reorders by
|
||||
// drag and drop. A list shows one section's rows, so where the row was
|
||||
// dropped is turned into a place among ALL the side's fields that have one:
|
||||
// right after the row now above it (or right before the row now below it, at
|
||||
// the top). placeKey walks the same steps one at a time, so the layout's rules
|
||||
// - a pinned header first, a section moving as one - still hold
|
||||
// (models/lib/cardFieldOrder.js), and the field lands as near as they allow.
|
||||
function dropTargetIndex(stored, layout, key, listKeys) {
|
||||
const movable = applyLayoutOrder(stored, layout).filter(k => isMovableKey(layout, k) && k !== key);
|
||||
const keys = listKeys.filter(k => isMovableKey(layout, k));
|
||||
const at = keys.indexOf(key);
|
||||
if (at > 0) return movable.indexOf(keys[at - 1]) + 1;
|
||||
if (keys.length > 1) return movable.indexOf(keys[1]);
|
||||
return -1;
|
||||
}
|
||||
|
||||
Template.boardCardSettingsPopup.events({
|
||||
// `keys`: the list's rows in their new order, `key` the one that moved.
|
||||
function placeInListOrder(side, key, keys) {
|
||||
const board = ReactiveCache.getBoard(Session.get('currentBoard'));
|
||||
if (!board || !(Utils.canModifyBoard && Utils.canModifyBoard())) return;
|
||||
const layout = side === 'minicard' ? MINICARD_LAYOUT : CARD_LAYOUT;
|
||||
const stored = side === 'minicard' ? board.minicardFieldOrder : board.cardFieldOrder;
|
||||
const target = dropTargetIndex(stored, layout, key, keys);
|
||||
if (target < 0) return;
|
||||
const order = placeKey(stored, key, target, layout);
|
||||
if (order.join(' ') === applyLayoutOrder(stored, layout).join(' ')) return;
|
||||
if (side === 'minicard') board.setMinicardFieldOrder(order);
|
||||
else board.setCardFieldOrder(order);
|
||||
}
|
||||
|
||||
// The keyboard way to drag: one step up or down in the list as it is shown -
|
||||
// past the row above or below it - placed the same way as a drop.
|
||||
function moveCardSettingsRow(evt, direction) {
|
||||
evt.preventDefault();
|
||||
const rowEl = evt.currentTarget.closest('.js-card-field-order-row');
|
||||
const list = rowEl && rowEl.closest('.js-card-field-order-sortable');
|
||||
if (!rowEl || !list || !rowEl.classList.contains('is-positioned')) return;
|
||||
const keys = [...list.querySelectorAll(':scope > .js-card-field-order-row.is-positioned')].map(el => el.dataset.key);
|
||||
const at = keys.indexOf(rowEl.dataset.key);
|
||||
const to = direction === 'up' ? at - 1 : at + 1;
|
||||
if (at < 0 || to < 0 || to >= keys.length) return;
|
||||
keys.splice(at, 1);
|
||||
keys.splice(to, 0, rowEl.dataset.key);
|
||||
placeInListOrder(list.dataset.side, rowEl.dataset.key, keys);
|
||||
}
|
||||
|
||||
// The handlers of Card Settings: the popup Board Settings opens, and the same
|
||||
// handlers on the opened card's own Card Settings (cardSettingsInline) and its
|
||||
// hamburgers, so a checkbox does the same thing wherever it is drawn.
|
||||
const cardSettingsEvents = {
|
||||
// The same animated checkbox as the rows; a board admin's.
|
||||
async 'click .js-ask-move-reason'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const value = !tpl.currentBoard.askForMoveReason;
|
||||
await tpl.currentBoard.setAskForMoveReason(value);
|
||||
tpl.currentBoard.askForMoveReason = value;
|
||||
tpl.$('.js-ask-move-reason').toggleClass('is-checked', value);
|
||||
tpl.$('.js-ask-move-reason i').toggleClass('fa-check', value).toggleClass('fa-square-o', !value);
|
||||
const board = ReactiveCache.getBoard(Session.get('currentBoard'));
|
||||
if (!board || !(Utils.canModifyBoard && Utils.canModifyBoard())) return;
|
||||
tpl.animating.set('askForMoveReason');
|
||||
try {
|
||||
await board.setAskForMoveReason(board.askForMoveReason !== true);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
|
||||
'click .js-card-field-order-handle'(evt) {
|
||||
evt.preventDefault();
|
||||
// A row is dragged by its handle or its name: a click on them does nothing.
|
||||
'click .js-card-field-order-handle'(evt) { evt.preventDefault(); },
|
||||
// Only the checkbox just clicked animates; its own handler does the change.
|
||||
'click .card-field-order-toggle'(evt, tpl) {
|
||||
const cell = evt.currentTarget.closest('.js-card-field-order-row');
|
||||
if (cell) tpl.animating.set(`${cell.dataset.key}:${cell.dataset.side}`);
|
||||
},
|
||||
// The checkbox left of a section's title: whether the whole section is
|
||||
// shown on the side Card Settings shows (models/lib/cardSettingsSections.js
|
||||
// hiddenSectionsOf). Its rows keep their own ticks. A board admin's.
|
||||
async 'click .js-card-settings-section-shown'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const sectionEl = evt.currentTarget.closest('.js-card-settings-section');
|
||||
const id = sectionEl && sectionEl.dataset.section;
|
||||
const side = evt.currentTarget.dataset.side;
|
||||
const board = ReactiveCache.getBoard(Session.get('currentBoard'));
|
||||
if (!board || !id || !cardSettingsSections.SIDES.includes(side)) return;
|
||||
if (!(Utils.canModifyBoard && Utils.canModifyBoard())) return;
|
||||
tpl.animating.set(`section:${id}:${side}`);
|
||||
try {
|
||||
await board.setCardSettingsSectionShown(side, id, !cardSettingsSections.sectionShown(board, side, id));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
// A section with one row on this side is one line (inlineSectionsOf
|
||||
// `single`): its one checkbox is "this field is shown on this side". It sets
|
||||
// the row's own flag through the row's own handler - the row's real
|
||||
// checkbox, kept in the line out of sight - and, turning it on, also shows
|
||||
// the section again if it was hidden whole.
|
||||
async 'click .js-card-settings-single-toggle'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const sectionEl = evt.currentTarget.closest('.js-card-settings-section');
|
||||
const real = sectionEl && sectionEl.querySelector('.js-card-settings-single-real .card-field-order-toggle');
|
||||
const id = sectionEl && sectionEl.dataset.section;
|
||||
const side = evt.currentTarget.dataset.side;
|
||||
const board = ReactiveCache.getBoard(Session.get('currentBoard'));
|
||||
if (!board || !real || real.classList.contains('is-disabled') || !cardSettingsSections.SIDES.includes(side)) return;
|
||||
const rowOn = real.getAttribute('aria-checked') === 'true';
|
||||
const shown = cardSettingsSections.sectionShown(board, side, id);
|
||||
if (rowOn && shown) {
|
||||
real.click();
|
||||
return;
|
||||
}
|
||||
if (!rowOn) real.click();
|
||||
if (!shown) {
|
||||
try {
|
||||
await board.setCardSettingsSectionShown(side, id, true);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
},
|
||||
// The keyboard way to drag: Up and Down on a focused handle or name move
|
||||
// the row one step in its list's order.
|
||||
'keydown .js-card-field-order-handle'(evt) {
|
||||
const direction = ARROW_KEYS[evt.key];
|
||||
if (direction) moveCardSettingsRow(evt, direction);
|
||||
@@ -2693,20 +2776,6 @@ Template.boardCardSettingsPopup.events({
|
||||
evt.stopPropagation();
|
||||
resetCardLabelTextOverride();
|
||||
},
|
||||
'click .js-toggle-show-list-on-card'(evt) {
|
||||
evt.preventDefault();
|
||||
const card = settingsCard();
|
||||
if (!card) return;
|
||||
Cards.update(card._id, { $set: { showListOnCard: !card.showListOnCard } });
|
||||
},
|
||||
'click .js-toggle-show-list-on-minicard'(evt) {
|
||||
evt.preventDefault();
|
||||
const card = settingsCard();
|
||||
if (!card) return;
|
||||
Cards.update(card._id, {
|
||||
$set: { showListOnMinicard: !card.showListOnMinicard },
|
||||
});
|
||||
},
|
||||
'click .js-field-has-receiveddate'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const newValue = !tpl.currentBoard.allowsReceivedDate;
|
||||
@@ -3059,6 +3128,521 @@ Template.boardCardSettingsPopup.events({
|
||||
const newValue = !tpl.currentBoard.allowsCardSortingByNumberOnMinicard;
|
||||
Boards.update(tpl.currentBoard._id, { $set: { allowsCardSortingByNumberOnMinicard: newValue } });
|
||||
},
|
||||
};
|
||||
|
||||
// ── Card Settings: Board Settings / Card ────────────────────────────────────
|
||||
// docs/Features/Cards/Card-Settings.md. Two panes, Show on Minicard and Show
|
||||
// on Card, each the same template with its own fixed side, and in each the
|
||||
// sections as checklists - each a title with its own checkbox (the whole
|
||||
// section shown on that side) and its rows as items, the checkbox the side's
|
||||
// board-wide Show on Minicard or Show on Card. Sections and
|
||||
// rows are dragged like checklists and their items, and collapse like them;
|
||||
// what else a section or a row has is in its hamburger, drawn only when it has
|
||||
// something in it. The opened card has no Card Settings of its own.
|
||||
|
||||
// Where the collapsed sections are kept: the user's collapsed card sections
|
||||
// (models/users.js), under the BOARD's id, because Card Settings is the
|
||||
// board's and not one card's.
|
||||
// Per side, since each pane is its own list: the Card pane keeps the key the
|
||||
// one list had (`card-settings-<id>`), so what a user collapsed before stays
|
||||
// collapsed there. A whole pane is `card-settings-pane-<side>`.
|
||||
const inlineCollapseKey = (id, side) => (side === 'minicard' ? `card-settings-minicard-${id}` : `card-settings-${id}`);
|
||||
const paneCollapseKey = side => `card-settings-pane-${side === 'minicard' ? 'minicard' : 'card'}`;
|
||||
|
||||
function collapsedOnBoard(key) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const boardId = Session.get('currentBoard');
|
||||
return Boolean(user && boardId && user.getCollapsedCardSection(boardId, key) === true);
|
||||
}
|
||||
|
||||
function inlineSectionCollapsed(id, side) {
|
||||
return collapsedOnBoard(inlineCollapseKey(id, side));
|
||||
}
|
||||
|
||||
// What a row's hamburger holds: only Labels text's way back to the board's
|
||||
// default, while the user overrides it. A row with nothing in it has no
|
||||
// hamburger at all - the icon is not drawn.
|
||||
function inlineRowMenu(row) {
|
||||
return {
|
||||
labelTextReset: Boolean(row.labelTextOverride && row.labelTextOverridden),
|
||||
};
|
||||
}
|
||||
|
||||
// Whether a section's hamburger has anything in it: Card's always does (Ask
|
||||
// for a reason when moving cards, the Draggable table); any other section only
|
||||
// when it has card or board actions for this user here (sectionCardActionsOf).
|
||||
// Without, the icon is not drawn.
|
||||
function sectionHasMenu(section) {
|
||||
if (section && section.general) return true;
|
||||
return Boolean(section) && sectionCardActionsOf(section.id).length > 0;
|
||||
}
|
||||
|
||||
function inlineSectionsOf(tpl, data) {
|
||||
const board = ReactiveCache.getBoard(Session.get('currentBoard'));
|
||||
const side = tpl.showSide.get();
|
||||
const mayBoard = Boolean(Utils.canModifyBoard && Utils.canModifyBoard());
|
||||
const showDragHandles = Utils.showDragHandles();
|
||||
const animating = tpl.animating.get();
|
||||
// The one hamburger of this pane whose panel is open (cardSettingsInlinePanel).
|
||||
const open = tpl.openPanel ? tpl.openPanel.get() : null;
|
||||
// This pane's side's own section order: the order its surface draws them in.
|
||||
return cardSettingsSections.orderedSections(cardSettingsSections.SECTIONS,
|
||||
board && board[cardSettingsSections.SECTION_ORDER_FIELD[side]], side)
|
||||
.map(section => {
|
||||
const rows = sectionRowsOf(section.id, data, side, animating).map(row => {
|
||||
const menu = inlineRowMenu(row);
|
||||
// Blaze keeps a row's element by _id, so switching Show moves each
|
||||
// row to its place on the other side rather than relabelling the rows
|
||||
// where they stand - a click in between would land on another field.
|
||||
return { ...row, _id: row.key, menu, hasMenu: menu.labelTextReset, menuOpen: open === `row:${row.key}` };
|
||||
});
|
||||
// Whether the WHOLE section is shown on this side - the checkbox left
|
||||
// of its title; its rows keep their own ticks while it is not.
|
||||
const shown = cardSettingsSections.sectionShown(board, side, section.id);
|
||||
// Custom Fields: its items are the board's custom fields, its own
|
||||
// checkbox the board's Custom Fields flag of this side - the row that
|
||||
// was its only item (models/lib/cardSettingsCustomFields.js).
|
||||
const customFields = section.id === cardSettingsCustomFields.CUSTOM_FIELDS_SECTION;
|
||||
const customFieldItems = customFields ? customFieldItemsOf(side, animating, mayBoard, showDragHandles)
|
||||
.map(item => ({ ...item, menuOpen: open === `field:${item.id}` })) : [];
|
||||
// One row on this side: one line - its checkbox, its name, the
|
||||
// section's hamburger (cardSettingsSections.js isSingleRowSection).
|
||||
const single = !customFields && cardSettingsSections.isSingleRowSection(rows);
|
||||
const flagRow = single ? rows[0]
|
||||
: customFields ? rows.find(r => r.key === cardSettingsCustomFields.CUSTOM_FIELDS_ROW) || null : null;
|
||||
const items = customFields ? customFieldItems : rows;
|
||||
return {
|
||||
// Blaze keeps a section's element (and the drag bound to it) by _id.
|
||||
// A section turning into one line (or back) is another element.
|
||||
_id: `${section.id}${single ? ':single' : ''}`,
|
||||
id: section.id,
|
||||
icon: section.icon,
|
||||
title: TAPi18n.__(section.labelKey),
|
||||
side,
|
||||
sideTitle: TAPi18n.__(side === 'minicard' ? 'show-on-minicard' : 'show-on-card'),
|
||||
rows: customFields ? [] : rows,
|
||||
shown,
|
||||
writable: mayBoard,
|
||||
animatingShown: animating === `section:${section.id}:${side}`,
|
||||
single,
|
||||
customFields,
|
||||
customFieldItems,
|
||||
// The checkbox of one line and of Custom Fields: the row's flag, and
|
||||
// the section not hidden whole - "this field is shown on this side".
|
||||
flagRow,
|
||||
flagChecked: Boolean(flagRow) && cardSettingsSections.singleLineChecked(board, side, section.id, flagRow.checked),
|
||||
flagWritable: Boolean(flagRow && flagRow.writable),
|
||||
hasMenu: sectionHasMenu(section),
|
||||
menuOpen: open === `section:${section.id}`,
|
||||
createOpen: customFields && open === 'create',
|
||||
checkedCount: items.filter(r => r.checked).length,
|
||||
total: items.length,
|
||||
collapsed: inlineSectionCollapsed(section.id, side),
|
||||
draggable: mayBoard,
|
||||
showDragHandles,
|
||||
};
|
||||
})
|
||||
// A section with no row on this side is left out - except Card, whose
|
||||
// hamburger holds what is not a field, and Custom Fields while its flag
|
||||
// row is offered.
|
||||
.filter(section => section.rows.length || section.flagRow
|
||||
|| cardSettingsSections.SECTIONS.find(s => s.id === section.id)?.general);
|
||||
}
|
||||
|
||||
// The board's custom fields as Custom Fields' items in Card Settings, in their
|
||||
// `sort` order (models/lib/cardSettingsCustomFields.js): each a checkbox - its
|
||||
// Show on Card (alwaysOnCard) or Show on Minicard (showOnCard) - its name, and
|
||||
// a hamburger opening its edit form, for a board admin.
|
||||
function boardCustomFieldDefinitions() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
if (!boardId) return [];
|
||||
return cardSettingsCustomFields.orderedDefinitions(
|
||||
ReactiveCache.getCustomFields({ boardIds: { $in: [boardId] } }, { sort: { sort: 1, name: 1 } }) || []);
|
||||
}
|
||||
|
||||
function customFieldItemsOf(side, animating, mayBoard, showDragHandles) {
|
||||
const sideTitle = TAPi18n.__(side === 'minicard' ? 'show-on-minicard' : 'show-on-card');
|
||||
return boardCustomFieldDefinitions().map(definition => ({
|
||||
// Blaze keeps an item's element by _id, as it keeps the rows'.
|
||||
_id: `customField:${definition._id}`,
|
||||
id: definition._id,
|
||||
name: definition.name,
|
||||
side,
|
||||
sideTitle,
|
||||
checked: cardSettingsCustomFields.definitionChecked(definition, side),
|
||||
writable: mayBoard,
|
||||
animating: animating === `customField:${definition._id}:${side}`,
|
||||
draggable: mayBoard,
|
||||
showDragHandles,
|
||||
hasMenu: mayBoard,
|
||||
}));
|
||||
}
|
||||
|
||||
// Custom Fields' items dropped in a new order, or one moved a step by Up and
|
||||
// Down: each definition whose place changed gets its new `sort`, through the
|
||||
// same CustomFields write as the board sidebar's own list.
|
||||
function placeCustomFieldOrder(ids) {
|
||||
if (!(Utils.canModifyBoard && Utils.canModifyBoard())) return;
|
||||
const sorts = cardSettingsCustomFields.sortsAfterDrop(boardCustomFieldDefinitions(), ids);
|
||||
Object.entries(sorts).forEach(([id, sort]) => CustomFields.update(id, { $set: { sort } }));
|
||||
}
|
||||
|
||||
// Each pane's side is fixed by boardCardSettingsPopup (`side="minicard"` or
|
||||
// `side="card"`); everything that depends on the side reads it from here.
|
||||
function setUpCardSettingsInline() {
|
||||
setUpCardSettings.call(this);
|
||||
const side = Template.currentData() && Template.currentData().side;
|
||||
this.showSide = new ReactiveVar(side === 'minicard' ? 'minicard' : 'card');
|
||||
// Which hamburger's panel is open in this pane: `section:<id>`,
|
||||
// `row:<key>`, `field:<customFieldId>` or `create` (the + under Custom
|
||||
// Fields); null when none. Not kept: a reopened Card Settings starts closed.
|
||||
this.openPanel = new ReactiveVar(null);
|
||||
}
|
||||
|
||||
// A hamburger toggles its panel: open, or closed when it is the one open -
|
||||
// and opening one closes the pane's other.
|
||||
function togglePanel(tpl, key) {
|
||||
tpl.animating.set(null);
|
||||
tpl.openPanel.set(tpl.openPanel.get() === key ? null : key);
|
||||
}
|
||||
|
||||
// The Card Settings pane template instance around a view, for a panel's
|
||||
// helpers (its checkboxes animate in the pane's state).
|
||||
function paneInstanceOf(view) {
|
||||
let v = view;
|
||||
while (v && v.name !== 'Template.cardSettingsInline') v = v.parentView;
|
||||
return v && v.templateInstance ? v.templateInstance() : null;
|
||||
}
|
||||
|
||||
Template.cardSettingsInline.onCreated(setUpCardSettingsInline);
|
||||
Template.cardSettingsInline.onDestroyed(function () {
|
||||
if (this._onPanelEscape) document.removeEventListener('keydown', this._onPanelEscape, true);
|
||||
});
|
||||
Template.cardSettingsInline.helpers({
|
||||
...boardCardSettingsHelpers,
|
||||
inlineSections() { return inlineSectionsOf(Template.instance(), Template.currentData()); },
|
||||
showSide() { return Template.instance().showSide.get(); },
|
||||
paneTitle() {
|
||||
return TAPi18n.__(Template.instance().showSide.get() === 'minicard' ? 'show-on-minicard' : 'show-on-card');
|
||||
},
|
||||
paneCollapsed() { return collapsedOnBoard(paneCollapseKey(Template.instance().showSide.get())); },
|
||||
});
|
||||
|
||||
// ── The two panes and the splitter between them ─────────────────────────────
|
||||
// Board Settings / Card shows the Minicard pane and the Card pane at once
|
||||
// (sidebar.jade boardCardSettingsPopup). Side by side, the splitter between
|
||||
// them is dragged like the Admin Panel left menu's inner edge
|
||||
// (client/components/settings/leftMenu.js): pointer down on it, move, and the
|
||||
// Minicard pane's share of the width follows; it is saved once, at the end, in
|
||||
// the user's profile as a fraction (models/lib/cardSettingsSplit.js), with a
|
||||
// Session value in front so it does not jump back while the server answers.
|
||||
// The Arrow keys, Home and End move it too; a double-click goes back to half
|
||||
// and half.
|
||||
const CARD_SETTINGS_SPLIT_KEY = 'cardSettingsSplit';
|
||||
|
||||
function savedCardSettingsSplit() {
|
||||
const session = Session.get(CARD_SETTINGS_SPLIT_KEY);
|
||||
if (typeof session === 'number') return session;
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const stored = user && typeof user.getCardSettingsSplit === 'function' ? user.getCardSettingsSplit() : undefined;
|
||||
return typeof stored === 'number' ? stored : cardSettingsSplit.DEFAULT_SPLIT;
|
||||
}
|
||||
|
||||
function saveCardSettingsSplit(fraction) {
|
||||
const value = cardSettingsSplit.normaliseSplit(fraction);
|
||||
Session.set(CARD_SETTINGS_SPLIT_KEY, value === undefined ? cardSettingsSplit.DEFAULT_SPLIT : value);
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (user && typeof user.setCardSettingsSplit === 'function') {
|
||||
Promise.resolve(user.setCardSettingsSplit(value)).catch(error => console.error(error));
|
||||
}
|
||||
}
|
||||
|
||||
const isRtlPage = () =>
|
||||
(document.documentElement.getAttribute('dir') || getComputedStyle(document.documentElement).direction) === 'rtl';
|
||||
|
||||
// The panes' geometry: the width the two panes share (the splitter's own
|
||||
// width left out) and the minimum of one pane in pixels, at the popup's font.
|
||||
function panesGeometry(panesEl, splitterEl) {
|
||||
const total = panesEl.getBoundingClientRect().width - (splitterEl ? splitterEl.getBoundingClientRect().width : 0);
|
||||
const em = parseFloat(getComputedStyle(panesEl).fontSize) || 16;
|
||||
return { total, min: cardSettingsSplit.MIN_PANE_EM * em };
|
||||
}
|
||||
|
||||
Template.boardCardSettingsPopup.helpers({
|
||||
splitFraction() { return savedCardSettingsSplit(); },
|
||||
splitPercent() { return cardSettingsSplit.splitPercent(savedCardSettingsSplit()); },
|
||||
// With a pane collapsed the other takes the width and there is no splitter.
|
||||
anyPaneCollapsed() {
|
||||
return cardSettingsSections.SIDES.some(side => collapsedOnBoard(paneCollapseKey(side)));
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardCardSettingsPopup.onRendered(function () {
|
||||
const panesEl = this.find('.js-card-settings-panes');
|
||||
const splitter = this.find('.js-card-settings-splitter');
|
||||
if (!panesEl || !splitter) return;
|
||||
let drag = null;
|
||||
const show = fraction => {
|
||||
panesEl.style.setProperty('--card-settings-split', String(fraction));
|
||||
splitter.setAttribute('aria-valuenow', String(cardSettingsSplit.splitPercent(fraction)));
|
||||
};
|
||||
// The saved split, clamped against the width the popup has now.
|
||||
const current = () => {
|
||||
const { total, min } = panesGeometry(panesEl, splitter);
|
||||
return cardSettingsSplit.clampSplit(savedCardSettingsSplit(), total, min);
|
||||
};
|
||||
const onMove = evt => {
|
||||
if (!drag) return;
|
||||
const { total, min } = panesGeometry(panesEl, splitter);
|
||||
drag.last = cardSettingsSplit.splitAfterDrag(drag.start, evt.clientX - drag.x, total, min, isRtlPage());
|
||||
// Straight onto the property while dragging: no write per pixel.
|
||||
show(drag.last);
|
||||
evt.preventDefault();
|
||||
};
|
||||
const onEnd = evt => {
|
||||
if (!drag) return;
|
||||
const { last, start, pointerId } = drag;
|
||||
drag = null;
|
||||
document.body.classList.remove('card-settings-splitting-active');
|
||||
try { splitter.releasePointerCapture(pointerId); } catch (e) { /* already released */ }
|
||||
// A press that did not move (half of a double-click) saves nothing.
|
||||
if (last !== start) saveCardSettingsSplit(last);
|
||||
if (evt && evt.preventDefault) evt.preventDefault();
|
||||
};
|
||||
const onDown = evt => {
|
||||
if (evt.button !== undefined && evt.button !== 0) return;
|
||||
const start = current();
|
||||
drag = { x: evt.clientX, start, last: start, pointerId: evt.pointerId };
|
||||
// Mouse, touch and pen alike; the splitter keeps the pointer while it is
|
||||
// dragged, so a fast move off it does not end the drag.
|
||||
try { splitter.setPointerCapture(evt.pointerId); } catch (e) { /* not capturable */ }
|
||||
document.body.classList.add('card-settings-splitting-active');
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
};
|
||||
const onKey = evt => {
|
||||
const { total, min } = panesGeometry(panesEl, splitter);
|
||||
const next = cardSettingsSplit.splitAfterKey(current(), evt.key, total, min, isRtlPage());
|
||||
if (next === null) return;
|
||||
evt.preventDefault();
|
||||
show(next);
|
||||
saveCardSettingsSplit(next);
|
||||
};
|
||||
const onDouble = evt => {
|
||||
evt.preventDefault();
|
||||
show(cardSettingsSplit.DEFAULT_SPLIT);
|
||||
saveCardSettingsSplit(undefined);
|
||||
};
|
||||
splitter.addEventListener('pointerdown', onDown, { passive: false });
|
||||
splitter.addEventListener('pointermove', onMove, { passive: false });
|
||||
splitter.addEventListener('pointerup', onEnd, { passive: false });
|
||||
splitter.addEventListener('pointercancel', onEnd, { passive: false });
|
||||
splitter.addEventListener('keydown', onKey);
|
||||
splitter.addEventListener('dblclick', onDouble);
|
||||
this._cardSettingsSplitCleanup = () => {
|
||||
drag = null;
|
||||
document.body.classList.remove('card-settings-splitting-active');
|
||||
};
|
||||
});
|
||||
|
||||
Template.boardCardSettingsPopup.onDestroyed(function () {
|
||||
// A drag still going when the popup closed is not what the reader chose.
|
||||
if (this._cardSettingsSplitCleanup) this._cardSettingsSplitCleanup();
|
||||
});
|
||||
|
||||
// A section dropped above or below another: the shown sections in their new
|
||||
// order, written to the board as `side`'s section order - the Minicard pane's
|
||||
// to the minicard's, the Card pane's to the opened card's; the other side's
|
||||
// is untouched (models/lib/cardSettingsSections.js).
|
||||
function placeSectionOrder(ids, side) {
|
||||
const board = ReactiveCache.getBoard(Session.get('currentBoard'));
|
||||
if (!board || !(Utils.canModifyBoard && Utils.canModifyBoard())) return;
|
||||
const which = side === 'minicard' ? 'minicard' : 'card';
|
||||
const stored = board[cardSettingsSections.SECTION_ORDER_FIELD[which]];
|
||||
const order = cardSettingsSections.sectionOrderAfterDrop(stored, ids, which);
|
||||
if (order.join(' ') === cardSettingsSections.sectionOrder(stored, which).join(' ')) return;
|
||||
board.setCardSettingsSectionOrder(order, which);
|
||||
}
|
||||
|
||||
Template.cardSettingsInline.onRendered(function () {
|
||||
const pane = this.find('.js-card-settings-pane');
|
||||
// A form in a panel saved (sidebarCustomFields.js closeCardSettingsPanel):
|
||||
// the panel closes, Card Settings stays.
|
||||
this._onPanelDone = () => this.openPanel.set(null);
|
||||
if (pane) pane.addEventListener('cardsettingspaneldone', this._onPanelDone);
|
||||
// Escape closes the open panel, not Card Settings: before the popup's own
|
||||
// Escape (EscapeActions on the document), in the capture phase.
|
||||
this._onPanelEscape = evt => {
|
||||
if (evt.key !== 'Escape' || !this.openPanel.get()) return;
|
||||
evt.preventDefault();
|
||||
evt.stopImmediatePropagation();
|
||||
this.openPanel.set(null);
|
||||
};
|
||||
document.addEventListener('keydown', this._onPanelEscape, true);
|
||||
reorderOnDrop(this.$('.js-card-settings-sections-sortable'), {
|
||||
items: '> .js-card-settings-section.is-draggable', handle: '.js-card-settings-section-handle',
|
||||
keyOf: el => el.dataset.section,
|
||||
drop: (id, ids) => placeSectionOrder(ids, this.showSide.get()),
|
||||
});
|
||||
});
|
||||
|
||||
// Each section's rows reorder within it, as checklist items do, through the
|
||||
// board's order of that side (placeInListOrder). A row dropped on another
|
||||
// section goes back: the sections are unrelated kinds of fields. Dragged by
|
||||
// the handle with drag handles on, by the icon and name with them off
|
||||
// (cardSettingsRowLabel).
|
||||
Template.cardSettingsInlineSection.onRendered(function () {
|
||||
// Custom Fields' items reorder within Custom Fields only, as rows do.
|
||||
const fields = this.find('.js-card-settings-custom-fields-sortable');
|
||||
if (fields) {
|
||||
reorderOnDrop(this.$(fields), {
|
||||
ownListOnly: true,
|
||||
items: '> .js-card-settings-custom-field.is-positioned', handle: '.js-card-settings-custom-field-handle',
|
||||
keyOf: el => el.dataset.id,
|
||||
drop: (id, ids) => placeCustomFieldOrder(ids),
|
||||
});
|
||||
}
|
||||
const list = this.find('.js-card-field-order-sortable');
|
||||
if (!list) return;
|
||||
reorderOnDrop(this.$(list), {
|
||||
ownListOnly: true,
|
||||
items: '> .js-card-field-order-row.is-positioned', handle: '.js-card-field-order-handle',
|
||||
keyOf: el => el.dataset.key,
|
||||
drop: (key, keys) => placeInListOrder(list.dataset.side, key, keys),
|
||||
});
|
||||
});
|
||||
|
||||
// The pane's handlers leave alone what a panel's own templates handle - the
|
||||
// custom field form, the subtask settings (`.js-card-settings-panel-own-handlers`):
|
||||
// the subtask settings' "Subtasks" checkbox has the same class as the
|
||||
// Subtasks row's, and both handlers toggling it was a click that did nothing.
|
||||
const paneEvents = Object.fromEntries(Object.entries(cardSettingsEvents).map(([key, handler]) => [key,
|
||||
function paneHandler(evt, ...rest) {
|
||||
if (evt && evt.target && evt.target.closest && evt.target.closest('.js-card-settings-panel-own-handlers')) return undefined;
|
||||
return handler.call(this, evt, ...rest);
|
||||
}]));
|
||||
|
||||
Template.cardSettingsInline.events({
|
||||
...paneEvents,
|
||||
// The caret at the top of a pane: the whole pane, only its header left.
|
||||
async 'click .js-toggle-card-settings-pane'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const boardId = Session.get('currentBoard');
|
||||
if (!user || !boardId) return;
|
||||
const key = paneCollapseKey(tpl.showSide.get());
|
||||
await user.setCollapsedCardSection(boardId, key, !collapsedOnBoard(key));
|
||||
},
|
||||
async 'click .js-toggle-card-settings-section'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const id = evt.currentTarget.closest('.js-card-settings-section')?.dataset.section;
|
||||
const side = tpl.showSide.get();
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const boardId = Session.get('currentBoard');
|
||||
if (!id || !user || !boardId) return;
|
||||
await user.setCollapsedCardSection(boardId, inlineCollapseKey(id, side), !inlineSectionCollapsed(id, side));
|
||||
},
|
||||
// A section is dragged by its handle or its title: a click there does nothing.
|
||||
'click .js-card-settings-section-handle'(evt) { evt.preventDefault(); },
|
||||
// The keyboard way to drag a section: Up and Down move it one step.
|
||||
'keydown .js-card-settings-section-handle'(evt, tpl) {
|
||||
const direction = ARROW_KEYS[evt.key];
|
||||
if (!direction) return;
|
||||
evt.preventDefault();
|
||||
const sectionEl = evt.currentTarget.closest('.js-card-settings-section');
|
||||
const list = sectionEl && sectionEl.closest('.js-card-settings-sections-sortable');
|
||||
if (!list) return;
|
||||
const ids = [...list.querySelectorAll(':scope > .js-card-settings-section')].map(el => el.dataset.section);
|
||||
const at = ids.indexOf(sectionEl.dataset.section);
|
||||
const to = direction === 'up' ? at - 1 : at + 1;
|
||||
if (at < 0 || to < 0 || to >= ids.length) return;
|
||||
ids.splice(at, 1);
|
||||
ids.splice(to, 0, sectionEl.dataset.section);
|
||||
placeSectionOrder(ids, tpl.showSide.get());
|
||||
},
|
||||
'click .js-open-card-settings-section-menu'(evt, tpl) {
|
||||
const id = evt.currentTarget.closest('.js-card-settings-section')?.dataset.section;
|
||||
evt.preventDefault();
|
||||
if (!cardSettingsSections.SECTIONS.some(s => s.id === id)) return;
|
||||
togglePanel(tpl, `section:${id}`);
|
||||
},
|
||||
// A panel's X.
|
||||
'click .js-close-card-settings-panel'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.openPanel.set(null);
|
||||
},
|
||||
// Custom Fields' items: the checkbox is the definition's flag of this side.
|
||||
'click .js-card-settings-custom-field-toggle'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const item = evt.currentTarget.closest('.js-card-settings-custom-field');
|
||||
const id = item && item.dataset.id;
|
||||
const side = item && item.dataset.side;
|
||||
if (!id || !(Utils.canModifyBoard && Utils.canModifyBoard())) return;
|
||||
// From what the checkbox shows: the cached definition can still be the
|
||||
// one before a click just made, and two quick clicks would both write on.
|
||||
const definition = ReactiveCache.getCustomField(id);
|
||||
const set = definition && cardSettingsCustomFields.definitionToggleSet(side,
|
||||
evt.currentTarget.getAttribute('aria-checked') !== 'true');
|
||||
if (!set) return;
|
||||
tpl.animating.set(`customField:${id}:${side}`);
|
||||
CustomFields.update(id, { $set: set });
|
||||
},
|
||||
// Dragged by its handle or its name: a click there does nothing.
|
||||
'click .js-card-settings-custom-field-handle'(evt) { evt.preventDefault(); },
|
||||
'keydown .js-card-settings-custom-field-handle'(evt) {
|
||||
const direction = ARROW_KEYS[evt.key];
|
||||
const item = evt.currentTarget.closest('.js-card-settings-custom-field');
|
||||
if (!direction || !item) return;
|
||||
evt.preventDefault();
|
||||
placeCustomFieldOrder(cardSettingsCustomFields.idsAfterStep(boardCustomFieldDefinitions(), item.dataset.id, direction));
|
||||
},
|
||||
// Its hamburger: the custom field's own edit form - the board sidebar's -
|
||||
// under the item.
|
||||
'click .js-card-settings-edit-custom-field'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const item = evt.currentTarget.closest('.js-card-settings-custom-field');
|
||||
const definition = item && ReactiveCache.getCustomField(item.dataset.id);
|
||||
if (!definition || !(Utils.canModifyBoard && Utils.canModifyBoard())) return;
|
||||
togglePanel(tpl, `field:${definition._id}`);
|
||||
},
|
||||
// "+" under the items: a new custom field of the board's form, under the +.
|
||||
'click .js-card-settings-add-custom-field'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
if (!(Utils.canModifyBoard && Utils.canModifyBoard())) return;
|
||||
togglePanel(tpl, 'create');
|
||||
},
|
||||
'click .js-open-card-settings-item-menu'(evt, tpl) {
|
||||
const row = evt.currentTarget.closest('.js-card-field-order-row');
|
||||
if (!row) return;
|
||||
evt.preventDefault();
|
||||
togglePanel(tpl, `row:${row.dataset.key}`);
|
||||
},
|
||||
});
|
||||
|
||||
// What a hamburger holds, opened under its line inside the pane
|
||||
// (sidebar.jade cardSettingsInlinePanel). It has NO event map: the pane's
|
||||
// (cardSettingsEvents, registered once, on cardSettingsInline) handles its
|
||||
// checkboxes, so each click is handled exactly once; Blaze runs the event map
|
||||
// of every enclosing template, and a second registration here toggled every
|
||||
// checkbox twice. tests/cardSettingsInlinePanels.test.cjs.
|
||||
Template.cardSettingsInlinePanel.helpers({
|
||||
...boardCardSettingsHelpers,
|
||||
isAnimating(key) {
|
||||
const pane = paneInstanceOf(Template.instance().view);
|
||||
return Boolean(pane && pane.animating.get() === key);
|
||||
},
|
||||
sectionIsGeneral() {
|
||||
return Boolean(cardSettingsSections.SECTIONS.find(s => s.id === this.sectionId)?.general);
|
||||
},
|
||||
sectionActions() { return sectionCardActionsOf(this.sectionId); },
|
||||
itemRow() {
|
||||
const pane = paneInstanceOf(Template.instance().view);
|
||||
const animating = pane ? pane.animating.get() : null;
|
||||
const row = sectionRowsOf(this.sectionId, this, this.side, animating).find(r => r.key === this.rowKey);
|
||||
return row ? { ...row, menu: inlineRowMenu(row) } : null;
|
||||
},
|
||||
fieldDefinition() { return this.fieldId ? ReactiveCache.getCustomField(this.fieldId) : null; },
|
||||
createData() { return { boardId: Session.get('currentBoard') }; },
|
||||
});
|
||||
|
||||
// Use Session variables instead of global ReactiveVars
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import CustomFields from '/models/customFields';
|
||||
import { backIfOwnPopup } from '/client/lib/popupBack';
|
||||
import { computeSortIndexMapping } from '/models/lib/boardSortReorder';
|
||||
|
||||
Template.customFieldsSidebar.helpers({
|
||||
@@ -127,6 +128,16 @@ function getSettings(tpl) {
|
||||
return settings;
|
||||
}
|
||||
|
||||
// The form drawn inline in a Card Settings panel: tell the pane it is done
|
||||
// (sidebar.js listens for `cardsettingspaneldone`). Returns whether it was.
|
||||
function closeCardSettingsPanel(evt) {
|
||||
const el = evt && (evt.currentTarget || evt.target);
|
||||
const panel = el && el.closest && el.closest('.js-card-settings-panel');
|
||||
if (!panel) return false;
|
||||
panel.dispatchEvent(new CustomEvent('cardsettingspaneldone', { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
|
||||
Template.createCustomFieldPopup.onCreated(function () {
|
||||
const data = Template.currentData();
|
||||
this.type = new ReactiveVar(
|
||||
@@ -373,7 +384,17 @@ Template.createCustomFieldPopup.events({
|
||||
CustomFields.update(currentData._id, { $set: data });
|
||||
}
|
||||
|
||||
Popup.back();
|
||||
// Its own popup steps back. In a Card Settings panel - under the field's
|
||||
// hamburger or the + (sidebar.jade cardSettingsInlinePanel) - the panel
|
||||
// closes and Card Settings stays open: Popup.back() there would close
|
||||
// Card Settings itself.
|
||||
if (closeCardSettingsPanel(evt)) return;
|
||||
// Elsewhere inside another popup the form stays, emptied for the next field.
|
||||
if (!backIfOwnPopup(evt, ['createCustomFieldPopup', 'editCustomFieldPopup']) && !editing) {
|
||||
const name = tpl.find('.js-field-name');
|
||||
if (name) name.value = '';
|
||||
tpl.dropdownItems.set([]);
|
||||
}
|
||||
},
|
||||
'click .js-delete-custom-field': Popup.afterConfirm(
|
||||
'deleteCustomField',
|
||||
|
||||
@@ -140,7 +140,10 @@ template(name="filterSidebar")
|
||||
| {{_ 'filter-dates-label' }}
|
||||
each dateFilterProviderViews
|
||||
+Template.dynamic(template=template data=data)
|
||||
form.js-card-movement-range
|
||||
//- novalidate: the range is checked in the submit handler (rangeRefused in
|
||||
sidebarFilters.js). WebKit keeps a half-edited date segment after Clear
|
||||
empties a field and then refused every submit of the form, with no message.
|
||||
form.js-card-movement-range(novalidate)
|
||||
h4 {{_ 'filter-movement-range'}}
|
||||
label(for="filter-card-movement-from") {{_ 'filter-date-range-from'}}
|
||||
input.js-card-movement-from(id="filter-card-movement-from" type="date" value=movementFrom)
|
||||
@@ -148,7 +151,9 @@ template(name="filterSidebar")
|
||||
input.js-card-movement-to(id="filter-card-movement-to" type="date" value=movementTo)
|
||||
button.primary(type="submit") {{_ 'apply'}}
|
||||
button.js-clear-movement-range(type="button") {{_ 'filter-clear'}}
|
||||
form.js-card-date-range
|
||||
if movementRangeError
|
||||
p.js-card-movement-range-error.filter-date-range-error(role="alert") {{_ 'filter-date-range-invalid'}}
|
||||
form.js-card-date-range(novalidate)
|
||||
label(for="filter-card-date-field") {{_ 'filter-date-range-field'}}
|
||||
select.js-card-date-field(id="filter-card-date-field")
|
||||
each dateRangeFields
|
||||
@@ -162,6 +167,8 @@ template(name="filterSidebar")
|
||||
| {{_ 'filter-date-range-missing'}}
|
||||
button.primary(type="submit") {{_ 'apply'}}
|
||||
button.js-clear-date-range(type="button") {{_ 'filter-clear'}}
|
||||
if dateRangeError
|
||||
p.js-card-date-range-error.filter-date-range-error(role="alert") {{_ 'filter-date-range-invalid'}}
|
||||
hr
|
||||
fieldset.filter-native-group
|
||||
legend {{_ 'due-date'}}
|
||||
|
||||
@@ -13,9 +13,23 @@ import { getSidebarInstance } from '/client/features/sidebar/service';
|
||||
import { CARD_DATE_RANGE_FIELDS, CARD_RECENCY_PRESETS } from '/models/lib/cardDateRange';
|
||||
import { DEPENDENCY_TYPES } from '/models/metadata/dependencies';
|
||||
|
||||
// A refused date range marks its end field invalid - so it is :invalid and
|
||||
// screen readers announce the reason - and says why in the form itself. Not
|
||||
// with reportValidity(): Firefox's validation bubble opens over the Apply and
|
||||
// Clear buttons below the field and takes every click on them while the field
|
||||
// has focus, so the range could not be cleared at all.
|
||||
function rangeRefused(end, error, refused) {
|
||||
end.setCustomValidity(refused ? TAPi18n.__('filter-date-range-invalid') : '');
|
||||
error.set(refused);
|
||||
}
|
||||
|
||||
Template.filterSidebar.onCreated(function () {
|
||||
this.presetMessage = new ReactiveVar('');
|
||||
this.presetBusy = new ReactiveVar(false);
|
||||
// Why a date range was refused, shown in the form rather than in the
|
||||
// browser's own validation bubble (see rangeRefused below).
|
||||
this.movementRangeError = new ReactiveVar(false);
|
||||
this.dateRangeError = new ReactiveVar(false);
|
||||
this.autorun(() => {
|
||||
const boardId = Session.get('currentBoard');
|
||||
if (Meteor.userId() && boardId) this.subscribe('savedCardFilters', boardId);
|
||||
@@ -28,6 +42,8 @@ Template.filterSidebar.helpers({
|
||||
},
|
||||
presetMessage() { return Template.instance().presetMessage.get(); },
|
||||
presetBusy() { return Template.instance().presetBusy.get(); },
|
||||
movementRangeError() { return Template.instance().movementRangeError.get(); },
|
||||
dateRangeError() { return Template.instance().dateRangeError.get(); },
|
||||
filterProviderViews() { return Filter.providers.views(); },
|
||||
dateFilterProviderViews() { return Filter.providers.views('dates'); },
|
||||
dueUnrestricted() { return Filter.dueAt.state() === null; },
|
||||
@@ -102,15 +118,15 @@ Template.filterSidebar.events({
|
||||
const end = tpl.find('.js-card-movement-to');
|
||||
const valid = Filter.movementDate.set({ field: 'createdAt',
|
||||
from: tpl.find('.js-card-movement-from').value, to: end.value, includeMissing: false });
|
||||
end.setCustomValidity(valid ? '' : TAPi18n.__('filter-date-range-invalid'));
|
||||
if (!valid) end.reportValidity();
|
||||
else Filter.resetExceptions();
|
||||
rangeRefused(end, tpl.movementRangeError, !valid);
|
||||
if (valid) Filter.resetExceptions();
|
||||
},
|
||||
'input .js-card-movement-range input'(event, tpl) {
|
||||
tpl.find('.js-card-movement-to').setCustomValidity('');
|
||||
rangeRefused(tpl.find('.js-card-movement-to'), tpl.movementRangeError, false);
|
||||
},
|
||||
'click .js-clear-movement-range'(event, tpl) {
|
||||
event.preventDefault(); tpl.find('.js-card-movement-to').setCustomValidity('');
|
||||
event.preventDefault();
|
||||
rangeRefused(tpl.find('.js-card-movement-to'), tpl.movementRangeError, false);
|
||||
Filter.movementDate.reset(); Filter.resetExceptions();
|
||||
},
|
||||
'submit .js-card-date-range'(event, tpl) {
|
||||
@@ -119,22 +135,20 @@ Template.filterSidebar.events({
|
||||
const valid = Filter.dateRange.set({ field: tpl.find('.js-card-date-field').value,
|
||||
from: tpl.find('.js-card-date-from').value, to: end.value,
|
||||
includeMissing: tpl.find('.js-card-date-missing').checked });
|
||||
end.setCustomValidity(valid ? '' : TAPi18n.__('filter-date-range-invalid'));
|
||||
if (!valid) end.reportValidity();
|
||||
else Filter.resetExceptions();
|
||||
rangeRefused(end, tpl.dateRangeError, !valid);
|
||||
if (valid) Filter.resetExceptions();
|
||||
},
|
||||
'input .js-card-date-range input'(event, tpl) {
|
||||
tpl.find('.js-card-date-to').setCustomValidity('');
|
||||
rangeRefused(tpl.find('.js-card-date-to'), tpl.dateRangeError, false);
|
||||
},
|
||||
'click .js-clear-date-range'(event, tpl) {
|
||||
event.preventDefault();
|
||||
// Reset the native date controls as well as the reactive model. WebKit can
|
||||
// retain an edited date segment after Blaze changes the value attribute.
|
||||
for (const selector of ['.js-card-date-from', '.js-card-date-to']) {
|
||||
const input = tpl.find(selector);
|
||||
input.value = '';
|
||||
input.setCustomValidity('');
|
||||
tpl.find(selector).value = '';
|
||||
}
|
||||
rangeRefused(tpl.find('.js-card-date-to'), tpl.dateRangeError, false);
|
||||
Filter.dateRange.reset(); Filter.resetExceptions();
|
||||
},
|
||||
'submit .js-column-age-filter'(event, tpl) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/* PASSWORD_MIN_LENGTH: the Change Password form over everything, until done. */
|
||||
.password-change-required {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: auto;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.password-change-required-box {
|
||||
max-width: 32rem;
|
||||
width: calc(100% - 2rem);
|
||||
margin: 1rem;
|
||||
padding: 1.5rem;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #222;
|
||||
}
|
||||
.password-change-required-box h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
/* layouts.css hides the password form until a page shows it (#6380). */
|
||||
.password-change-required .at-pwd-form {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
template(name="passwordChangeRequired")
|
||||
//- PASSWORD_MIN_LENGTH (server/passwordChangeRequired.js): an account whose
|
||||
//- password is shorter than the minimum signed in, and sees only this until
|
||||
//- it has a longer one. The form is the same Change Password Member
|
||||
//- Settings opens; changing it signs every session out, this one too.
|
||||
.password-change-required(role="dialog" aria-modal="true" aria-labelledby="password-change-required-title")
|
||||
.password-change-required-box
|
||||
h2#password-change-required-title {{_ 'password-change-required-title'}}
|
||||
p {{_ 'password-change-required-text' min=min}}
|
||||
+atForm(state='changePwd')
|
||||
.buttonsContainer
|
||||
button.js-password-change-required-logout(type="button") {{_ 'log-out'}}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Blaze } from 'meteor/blaze';
|
||||
import { Template } from 'meteor/templating';
|
||||
import { Tracker } from 'meteor/tracker';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
|
||||
// PASSWORD_MIN_LENGTH: after signing in, ask whether this account must change
|
||||
// a password shorter than the minimum, and if so show nothing but the Change
|
||||
// Password form (passwordChangeRequired.jade). The server asks the question
|
||||
// (server/passwordChangeRequired.js); config/accounts.js tells it when the
|
||||
// password just typed was too short. Changing a password signs every session
|
||||
// out, so the account then signs in again, with the new password.
|
||||
let overlay = null;
|
||||
|
||||
function closeOverlay() {
|
||||
if (!overlay) return;
|
||||
Blaze.remove(overlay.view);
|
||||
overlay.node.remove();
|
||||
overlay = null;
|
||||
}
|
||||
|
||||
function openOverlay(min) {
|
||||
if (overlay) return;
|
||||
const node = document.createElement('div');
|
||||
document.body.appendChild(node);
|
||||
overlay = { node, view: Blaze.renderWithData(Template.passwordChangeRequired, { min }, node) };
|
||||
}
|
||||
|
||||
Template.passwordChangeRequired.events({
|
||||
'click .js-password-change-required-logout'() {
|
||||
Meteor.logout(() => closeOverlay());
|
||||
},
|
||||
});
|
||||
|
||||
export function checkPasswordChangeRequirement() {
|
||||
const userId = Meteor.userId();
|
||||
if (!userId) return;
|
||||
Meteor.call('getPasswordChangeRequirement', (error, result) => {
|
||||
if (error || !result || Meteor.userId() !== userId) return;
|
||||
if (result.required) openOverlay(result.minLength);
|
||||
});
|
||||
}
|
||||
|
||||
// A changed password signs this session out too (server/passwordChangeRequired.js).
|
||||
// The server closes the connection as it removes the token, so the answer to
|
||||
// the change - and useraccounts' onSubmitHook - may never arrive; being signed
|
||||
// out soon after submitting a change is the answer, and the next stop is the
|
||||
// sign-in page, from Member Settings and from this overlay alike.
|
||||
const SIGN_OUT_WAIT_MS = 2 * 60 * 1000;
|
||||
let changeSubmittedAt = 0;
|
||||
document.addEventListener('submit', event => {
|
||||
const form = event.target;
|
||||
if (form && form.id === 'at-pwd-form' && form.querySelector('#at-field-current_password')) {
|
||||
changeSubmittedAt = Date.now();
|
||||
}
|
||||
}, true);
|
||||
|
||||
Meteor.startup(() => {
|
||||
let askedFor = null;
|
||||
Tracker.autorun(() => {
|
||||
const userId = Meteor.userId();
|
||||
if (!userId) {
|
||||
askedFor = null;
|
||||
const changed = Date.now() - changeSubmittedAt < SIGN_OUT_WAIT_MS;
|
||||
if (overlay || changed) {
|
||||
// The change signed every session out: sign in with the new password.
|
||||
changeSubmittedAt = 0;
|
||||
closeOverlay();
|
||||
Popup.close?.();
|
||||
FlowRouter.go('atSignIn');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (askedFor === userId) return;
|
||||
askedFor = userId;
|
||||
Tracker.nonreactive(checkPasswordChangeRequirement);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/* REQUIRE_2FA: the set-up over everything, until it is done. */
|
||||
.two-factor-required {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: auto;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.two-factor-required-box {
|
||||
max-width: 32rem;
|
||||
width: calc(100% - 2rem);
|
||||
margin: 1rem;
|
||||
padding: 1.5rem;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #222;
|
||||
}
|
||||
.two-factor-required-box h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
template(name="twoFactorRequired")
|
||||
//- REQUIRE_2FA (models/lib/twoFactorRequirement.js): an account that must
|
||||
//- have two-factor authentication sees only this until it has set it up.
|
||||
//- The server refuses everything else meanwhile (server/requireTwoFactor.js);
|
||||
//- this is what the account can do about it. The set-up is the same
|
||||
//- twoFactorAuthPopup Member Settings opens.
|
||||
.two-factor-required(role="dialog" aria-modal="true" aria-labelledby="two-factor-required-title")
|
||||
.two-factor-required-box
|
||||
h2#two-factor-required-title {{_ 'two-factor-required-title'}}
|
||||
p {{_ 'two-factor-required-text'}}
|
||||
+twoFactorAuthPopup
|
||||
.buttonsContainer
|
||||
button.js-two-factor-required-logout(type="button") {{_ 'log-out'}}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Blaze } from 'meteor/blaze';
|
||||
import { Template } from 'meteor/templating';
|
||||
import { Tracker } from 'meteor/tracker';
|
||||
|
||||
// REQUIRE_2FA: after signing in, ask whether this account must still set up
|
||||
// two-factor authentication, and if so show nothing but the set-up
|
||||
// (twoFactorRequired.jade). accounts-2fa publishes the account's own
|
||||
// services.twoFactorAuthentication.type, so finishing the set-up is seen at
|
||||
// once; the page then reloads, because every subscription the server left
|
||||
// empty meanwhile has to be made again.
|
||||
let overlay = null;
|
||||
|
||||
function closeOverlay() {
|
||||
if (!overlay) return;
|
||||
Blaze.remove(overlay.view);
|
||||
overlay.node.remove();
|
||||
overlay = null;
|
||||
}
|
||||
|
||||
function openOverlay() {
|
||||
if (overlay) return;
|
||||
const node = document.createElement('div');
|
||||
document.body.appendChild(node);
|
||||
overlay = { node, view: Blaze.render(Template.twoFactorRequired, node) };
|
||||
}
|
||||
|
||||
Template.twoFactorRequired.events({
|
||||
'click .js-two-factor-required-logout'() {
|
||||
Meteor.logout(() => closeOverlay());
|
||||
},
|
||||
});
|
||||
|
||||
Meteor.startup(() => {
|
||||
let askedFor = null;
|
||||
Tracker.autorun(() => {
|
||||
const userId = Meteor.userId();
|
||||
const user = Meteor.user({ fields: { 'services.twoFactorAuthentication.type': 1 } });
|
||||
if (!userId) {
|
||||
askedFor = null;
|
||||
closeOverlay();
|
||||
return;
|
||||
}
|
||||
const enabled = user?.services?.twoFactorAuthentication?.type === 'otp';
|
||||
if (overlay && enabled) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
if (askedFor === userId) return;
|
||||
askedFor = userId;
|
||||
Meteor.call('getTwoFactorRequirement', (error, result) => {
|
||||
if (error || !result || Meteor.userId() !== userId) return;
|
||||
if (result.required && !result.enabled) openOverlay();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -204,3 +204,57 @@ body.mobile-mode.userform-layout {
|
||||
#at-meteor-developer, #at-weibo, #at-meetup) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* #6835: Member Settings / Change Settings. Its rows are the shared popup list
|
||||
(popup.css: a flex row, every line 33px high, the icon centred), which reads
|
||||
well for one-line items and badly for these: a setting whose words wrap -
|
||||
most of them, in Persian and German - became two 33px lines with its icon
|
||||
floating between them and the next setting right below, with nothing to
|
||||
tell one from the other. Here a row is an icon column, the words, and the
|
||||
check mark at the end; the icon sits on the first line of the words, a
|
||||
wrapped line is an ordinary line high, and a rule divides the rows. Scoped
|
||||
to the list's own class: the member menu before it shares its .pop-over. */
|
||||
.pop-over-list.change-settings-list li + li {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.pop-over-list.change-settings-list li > a {
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
line-height: 1.35;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.pop-over-list.change-settings-list li > a > i.fa:first-child {
|
||||
flex: 0 0 1.4em;
|
||||
margin-inline-end: 0.6em;
|
||||
line-height: inherit;
|
||||
text-align: center;
|
||||
}
|
||||
.pop-over-list.change-settings-list li > a > i.fa-check {
|
||||
flex: 0 0 auto;
|
||||
margin-inline-start: auto;
|
||||
padding-inline-start: 0.6em;
|
||||
line-height: inherit;
|
||||
}
|
||||
.pop-over-list.change-settings-list li > label.bold {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
line-height: 1.35;
|
||||
padding: 8px 10px 4px;
|
||||
}
|
||||
.pop-over-list.change-settings-list li > label.bold > i.fa {
|
||||
flex: 0 0 1.4em;
|
||||
margin-inline-end: 0.6em;
|
||||
text-align: center;
|
||||
line-height: inherit;
|
||||
}
|
||||
/* The Card settings checkbox row: the box on the first line of its words,
|
||||
with the same space between them as an icon has. */
|
||||
.pop-over-list.change-settings-list .js-rescue-card-description {
|
||||
align-items: flex-start;
|
||||
gap: 0.6em;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.pop-over-list.change-settings-list .js-rescue-card-description .materialCheckBox {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 0.15em;
|
||||
}
|
||||
@@ -410,7 +410,10 @@ template(name="changeFontPopup")
|
||||
a.js-reset-text-color(class="{{#unless hasTextColor}}disabled{{/unless}}") {{_ 'remove-btn'}}
|
||||
|
||||
template(name="changeSettingsPopup")
|
||||
ul.pop-over-list
|
||||
//- #6835: its own class - a popup keeps the screens before it (the member
|
||||
menu) side by side in the same .pop-over, so the popup's data-popup would
|
||||
restyle those too. userForm.css
|
||||
ul.pop-over-list.change-settings-list
|
||||
li
|
||||
a.js-toggle-desktop-drag-handles
|
||||
i.fa.fa-arrows
|
||||
@@ -431,6 +434,20 @@ template(name="changeSettingsPopup")
|
||||
| {{_ 'keep-title-tags'}}
|
||||
if isKeepTitleTags
|
||||
i.fa.fa-check
|
||||
//- #6827: the Add card form stays open after a card is added.
|
||||
li
|
||||
a.js-toggle-keep-add-card-form-open(title="{{_ 'keep-add-card-form-open-description'}}")
|
||||
i.fa.fa-plus-square
|
||||
| {{_ 'keep-add-card-form-open'}}
|
||||
if isKeepAddCardFormOpen
|
||||
i.fa.fa-check
|
||||
//- #6810: a leading "[Text] " of a new card's title becomes a label (#3986).
|
||||
li
|
||||
a.js-toggle-title-bracket-label(title="{{_ 'title-bracket-label-description'}}")
|
||||
i.fa.fa-tag
|
||||
| {{_ 'title-bracket-label'}}
|
||||
if isTitleBracketLabel
|
||||
i.fa.fa-check
|
||||
li
|
||||
a.js-toggle-submit-on-enter(title="{{_ 'submit-on-enter-description'}}")
|
||||
i.fa.fa-level-down
|
||||
|
||||
@@ -489,6 +489,14 @@ Template.changeSettingsPopup.helpers({
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
return currentUser ? currentUser.hasKeepTitleTags() : false;
|
||||
},
|
||||
isKeepAddCardFormOpen() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
return currentUser ? currentUser.hasKeepAddCardFormOpen() : false;
|
||||
},
|
||||
isTitleBracketLabel() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
return currentUser ? currentUser.hasTitleBracketLabel() : false;
|
||||
},
|
||||
isSubmitOnEnter() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
return currentUser ? currentUser.hasSubmitOnEnter() : false;
|
||||
@@ -547,6 +555,12 @@ Template.changeSettingsPopup.events({
|
||||
'click .js-toggle-unread-changes'() {
|
||||
if (ReactiveCache.getCurrentUser()) Meteor.call('toggleUnreadChanges');
|
||||
},
|
||||
'click .js-toggle-keep-add-card-form-open'() {
|
||||
if (ReactiveCache.getCurrentUser()) Meteor.call('toggleKeepAddCardFormOpen');
|
||||
},
|
||||
'click .js-toggle-title-bracket-label'() {
|
||||
if (ReactiveCache.getCurrentUser()) Meteor.call('toggleTitleBracketLabel');
|
||||
},
|
||||
'click .js-toggle-keep-title-tags'() {
|
||||
if (ReactiveCache.getCurrentUser()) Meteor.call('toggleKeepTitleTags');
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@ import '/client/components/cards/cardCustomFields.js';
|
||||
import '/client/components/cards/cardDate.js';
|
||||
import '/client/components/cards/cardDescription.js';
|
||||
import '/client/components/cards/cardDetails.js';
|
||||
import '/client/components/cards/cardAddFields.js';
|
||||
import '/client/components/cards/cardDrawing.js';
|
||||
import '/client/components/cards/cardFlowtime.js';
|
||||
import '/client/components/cards/cardPomodoro.js';
|
||||
@@ -40,6 +41,7 @@ import '/client/components/cards/attachments.css';
|
||||
import '/client/components/cards/cardDate.css';
|
||||
import '/client/components/cards/cardDescription.css';
|
||||
import '/client/components/cards/cardDetails.css';
|
||||
import '/client/components/cards/cardAddFields.css';
|
||||
import '/client/components/cards/cardDrawing.css';
|
||||
import '/client/components/cards/cardFlowtime.css';
|
||||
import '/client/components/cards/cardPomodoro.css';
|
||||
|
||||
Loaded 100 of 1100 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user