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 | ||
|
|
9a4719b67a | ||
|
|
76f8d3d3a6 | ||
|
|
bd049075e1 | ||
|
|
70485c38df | ||
|
|
f9e1e90131 | ||
|
|
c64b3a3b1d | ||
|
|
f1e447dd36 | ||
|
|
e76b05b12c | ||
|
|
89da0e5be7 | ||
|
|
2da820f8ff | ||
|
|
e413cd4720 | ||
|
|
2699f4deea |
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' }}
|
||||
|
||||
@@ -131,13 +131,13 @@ jobs:
|
||||
bash releases/fetch.sh -o npm.tar.xz "https://nodejs.org/dist/${NODE_FULL}/node-${NODE_FULL}-linux-x64.tar.xz"
|
||||
tar -xJf npm.tar.xz --strip-components=4 -C bsd-tools/npm "node-${NODE_FULL}-linux-x64/lib/node_modules/npm"
|
||||
rm -f npm.tar.xz
|
||||
ferret="https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-${FERRETDB_ARCH}"
|
||||
bash releases/fetch.sh -o bundle/ferretdb "$ferret"
|
||||
if bash releases/fetch.sh --optional -o ferretdb.sha256sum "${ferret}.sha256sum"; then
|
||||
echo "$(awk '{print $1; exit}' ferretdb.sha256sum) bundle/ferretdb" | sha256sum -c -
|
||||
else
|
||||
echo "::warning::wekan/FerretDB publishes no checksum for ferretdb-${FERRETDB_ARCH}, so it is unverified."
|
||||
fi
|
||||
# The prebuilt SQLite-only ferretdb-sqlite-<bsd>-<arch> from the newest
|
||||
# wekan/FerretDB release that has it (the full ferretdb-<bsd>-<arch> of
|
||||
# the newest release until one does), verified against its .sha256sum;
|
||||
# releases/ferretdb-asset.sh says in the log which one it took.
|
||||
ferret_meta="$(bash releases/ferretdb-asset.sh "$FERRETDB_ARCH" -o bundle/ferretdb)" \
|
||||
|| { echo "::error::FerretDB for ${FERRETDB_ARCH} could not be put in the bundle; the lines above say why."; exit 1; }
|
||||
eval "$ferret_meta"
|
||||
# The inherited amd64 Linux MongoDB Database Tools are the wrong OS
|
||||
# here: replaced by this BSD's, or removed when there are none.
|
||||
rc=0; bash releases/fetch-mongo-tools.sh bundle "$FERRETDB_ARCH" || rc=$?
|
||||
|
||||
@@ -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)
|
||||
|
||||
+197
-311
@@ -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: |
|
||||
@@ -721,8 +721,10 @@ jobs:
|
||||
- name: Check the binaries the amd64 bundle needs exist
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash releases/require-binaries.sh 'amd64 bundle' \
|
||||
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-amd64'
|
||||
# The SQLite-only ferretdb-sqlite-amd64, or the full ferretdb-amd64 of
|
||||
# the newest release until one has it (releases/ferretdb-asset.sh says which).
|
||||
FERRET_URL="$(bash releases/ferretdb-asset.sh --url amd64)"
|
||||
bash releases/require-binaries.sh 'amd64 bundle' "$FERRET_URL"
|
||||
|
||||
- name: Embed Node.js, FerretDB (amd64) and launcher into the bundle
|
||||
# Make the release .zip self-contained (offline): bundle Node.js + FerretDB
|
||||
@@ -737,28 +739,15 @@ jobs:
|
||||
# copying the runner's unnamed, unchecksummed node. The helper exports
|
||||
# NODE_FULL and NODE_SHA256 to $GITHUB_ENV for the provenance step.
|
||||
bash releases/embed-verified-node.sh .build/bundle/node node-x64 "$NODE_VERSION"
|
||||
# Bundled FerretDB (amd64): download only this arch's binary from the
|
||||
# newest wekan/FerretDB release (per-arch assets, no ferretdb.zip).
|
||||
FERRET_URL="https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-amd64"
|
||||
bash "$GITHUB_WORKSPACE/releases/fetch.sh" -o .build/bundle/ferretdb "$FERRET_URL"
|
||||
chmod +x .build/bundle/ferretdb
|
||||
# wekan/FerretDB publishes a .sha256sum beside each binary now. Verify
|
||||
# the download against it and carry the value to the provenance step
|
||||
# (record-provenance runs in the later Create-zip step). Older releases
|
||||
# publish none, so a missing one is unverified, not fatal - the binary
|
||||
# IS present, which is the test.
|
||||
if bash "$GITHUB_WORKSPACE/releases/fetch.sh" --optional -o /tmp/ferretdb.sha256sum "${FERRET_URL}.sha256sum"; then
|
||||
FERRET_SHA="$(awk '{print $1; exit}' /tmp/ferretdb.sha256sum)"
|
||||
GOT="$(sha256sum .build/bundle/ferretdb | cut -d' ' -f1)"
|
||||
if [ "$GOT" != "$FERRET_SHA" ]; then
|
||||
echo "::error::FerretDB amd64 does not match its published SHA256 (published $FERRET_SHA, got $GOT)."
|
||||
exit 1
|
||||
fi
|
||||
echo "FerretDB SHA256 verified: $GOT"
|
||||
echo "FERRET_SHA=$FERRET_SHA" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "::warning::wekan/FerretDB publishes no checksum for ferretdb-amd64 on this release, so it is unverified."
|
||||
fi
|
||||
# The SQLite-only ferretdb-sqlite-amd64 from the newest wekan/FerretDB
|
||||
# release that has it - or, until one does, the full ferretdb-amd64 of the
|
||||
# newest release - verified against its .sha256sum. No FerretDB is built
|
||||
# here. releases/ferretdb-asset.sh says in the log which one it took and
|
||||
# why, and hands back FERRET_ASSET, FERRET_TAG, FERRET_URL and FERRET_SHA
|
||||
# for the provenance record (here, and through $GITHUB_ENV in later steps).
|
||||
ferret_meta="$(bash "$GITHUB_WORKSPACE/releases/ferretdb-asset.sh" amd64 -o .build/bundle/ferretdb)" \
|
||||
|| { echo "::error::FerretDB for amd64 could not be put in the bundle; the lines above say why."; exit 1; }
|
||||
eval "$ferret_meta"
|
||||
# Bundled MongoDB Database Tools (amd64) from wekan/mongo-tools-patches (per-arch,
|
||||
# no mongodb.com). This base amd64 bundle is inherited by the other Linux
|
||||
# arches, which overwrite these with their own arch's tools below.
|
||||
@@ -821,10 +810,9 @@ jobs:
|
||||
# exit 127 - which is what this line did on v10.78 before the path was
|
||||
# made absolute, failing the whole amd64 build after the zip was
|
||||
# already built and checksummed.
|
||||
FERRET_TAG="$(bash "$GITHUB_WORKSPACE/releases/ferretdb-latest-tag.sh" || true)"
|
||||
PROVENANCE_FILE="$GITHUB_WORKSPACE/provenance/amd64.tsv" bash "$GITHUB_WORKSPACE/releases/record-provenance.sh" \
|
||||
'amd64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
|
||||
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-amd64' \
|
||||
"${FERRET_URL:-}" \
|
||||
"${FERRET_SHA:-}" || true
|
||||
# A zip that EXISTS is not a zip that WORKS, and the log should say which of the
|
||||
# two this is. The size answers "was the bundle directory empty when it was
|
||||
@@ -1011,8 +999,10 @@ jobs:
|
||||
if: steps.preflight.outputs.skip != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash releases/require-binaries.sh 'arm64 bundle' \
|
||||
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-arm64'
|
||||
# The SQLite-only ferretdb-sqlite-arm64, or the full ferretdb-arm64 of
|
||||
# the newest release until one has it (releases/ferretdb-asset.sh says which).
|
||||
FERRET_URL="$(bash releases/ferretdb-asset.sh --url arm64)"
|
||||
bash releases/require-binaries.sh 'arm64 bundle' "$FERRET_URL"
|
||||
|
||||
- name: Embed Node.js + FerretDB (arm64) into the bundle
|
||||
if: steps.preflight.outputs.skip != 'true'
|
||||
@@ -1025,24 +1015,15 @@ jobs:
|
||||
# Node 22 on ubuntu-24.04-arm - not the pinned 24, and unchecksummed.
|
||||
# The helper exports NODE_FULL and NODE_SHA256 for the provenance step.
|
||||
bash releases/embed-verified-node.sh bundle/node node-arm64 "$NODE_VERSION"
|
||||
# Download only the arm64 FerretDB binary from the newest wekan/FerretDB
|
||||
# release, and verify it against the .sha256sum wekan/FerretDB now
|
||||
# publishes beside each binary (carry the value to the provenance step).
|
||||
FERRET_URL="https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-arm64"
|
||||
bash "$GITHUB_WORKSPACE/releases/fetch.sh" -o bundle/ferretdb "$FERRET_URL"
|
||||
chmod +x bundle/ferretdb
|
||||
if bash "$GITHUB_WORKSPACE/releases/fetch.sh" --optional -o /tmp/ferretdb.sha256sum "${FERRET_URL}.sha256sum"; then
|
||||
FERRET_SHA="$(awk '{print $1; exit}' /tmp/ferretdb.sha256sum)"
|
||||
GOT="$(sha256sum bundle/ferretdb | cut -d' ' -f1)"
|
||||
if [ "$GOT" != "$FERRET_SHA" ]; then
|
||||
echo "::error::FerretDB arm64 does not match its published SHA256 (published $FERRET_SHA, got $GOT)."
|
||||
exit 1
|
||||
fi
|
||||
echo "FerretDB SHA256 verified: $GOT"
|
||||
echo "FERRET_SHA=$FERRET_SHA" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "::warning::wekan/FerretDB publishes no checksum for ferretdb-arm64 on this release, so it is unverified."
|
||||
fi
|
||||
# The SQLite-only ferretdb-sqlite-arm64 from the newest wekan/FerretDB
|
||||
# release that has it - or, until one does, the full ferretdb-arm64 of the
|
||||
# newest release - verified against its .sha256sum. No FerretDB is built
|
||||
# here. releases/ferretdb-asset.sh says in the log which one it took and
|
||||
# why, and hands back FERRET_ASSET, FERRET_TAG, FERRET_URL and FERRET_SHA
|
||||
# for the provenance record (here, and through $GITHUB_ENV in later steps).
|
||||
ferret_meta="$(bash "$GITHUB_WORKSPACE/releases/ferretdb-asset.sh" arm64 -o bundle/ferretdb)" \
|
||||
|| { echo "::error::FerretDB for arm64 could not be put in the bundle; the lines above say why."; exit 1; }
|
||||
eval "$ferret_meta"
|
||||
# Overwrite the inherited amd64 MongoDB Database Tools with arm64 ones.
|
||||
bash "$GITHUB_WORKSPACE/releases/fetch-mongo-tools.sh" bundle arm64 \
|
||||
|| { echo "::error::The MongoDB Database Tools for arm64 could not be put in the bundle; the lines above say why."; exit 1; }
|
||||
@@ -1069,10 +1050,9 @@ jobs:
|
||||
'arm64' 'Node.js' "${NODE_FROM:-}" "${NODE_FULL:-$NODE_VERSION}" \
|
||||
"${NODE_URL:-}" \
|
||||
"${NODE_SHA256:-}" || true
|
||||
FERRET_TAG="$(bash releases/ferretdb-latest-tag.sh || true)"
|
||||
PROVENANCE_FILE=provenance/arm64.tsv bash releases/record-provenance.sh \
|
||||
'arm64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
|
||||
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-arm64' \
|
||||
"${FERRET_URL:-}" \
|
||||
"${FERRET_SHA:-}" || true
|
||||
# A zip that EXISTS is not a zip that WORKS, and the log should say which of the
|
||||
# two this is. The size answers "was the bundle directory empty when it was
|
||||
@@ -1190,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.)
|
||||
#
|
||||
@@ -1223,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
|
||||
@@ -1245,11 +1225,12 @@ jobs:
|
||||
tag_name: v${{ needs.prepare.outputs.version }}
|
||||
name: v${{ needs.prepare.outputs.version }}
|
||||
body_path: release-notes.md
|
||||
# FerretDB binaries are NOT attached here: the per-arch ferretdb-<arch>
|
||||
# binaries live on the wekan/FerretDB releases
|
||||
# (https://github.com/wekan/FerretDB/releases), which is where the WeKan
|
||||
# builds fetch them from. Each wekan-<version>-<arch>.zip bundle embeds
|
||||
# its own arch's FerretDB binary.
|
||||
# FerretDB binaries are NOT attached here: the per-arch
|
||||
# ferretdb-sqlite-<arch> and ferretdb-<arch> binaries live on the
|
||||
# wekan/FerretDB releases (https://github.com/wekan/FerretDB/releases),
|
||||
# which is where the WeKan builds fetch them from, prebuilt
|
||||
# (releases/ferretdb-asset.sh). Each wekan-<version>-<arch>.zip bundle
|
||||
# embeds its own arch's FerretDB binary.
|
||||
|
||||
- name: Check the amd64 and arm64 bundles are on the GitHub Release
|
||||
env:
|
||||
@@ -1618,8 +1599,10 @@ jobs:
|
||||
# This job checks the repo out into src/ (see the checkout step above),
|
||||
# so the script lives at $SRC/releases/, not the workspace root - the
|
||||
# same path the start-wekan.bat copy below uses.
|
||||
bash $SRC/releases/require-binaries.sh 'win64 bundle' \
|
||||
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-win64.exe'
|
||||
# The SQLite-only ferretdb-sqlite-win64, or the full ferretdb-win64 of
|
||||
# the newest release until one has it (releases/ferretdb-asset.sh says which).
|
||||
FERRET_URL="$(bash $SRC/releases/ferretdb-asset.sh --url win64)"
|
||||
bash $SRC/releases/require-binaries.sh 'win64 bundle' "$FERRET_URL"
|
||||
|
||||
- name: Add MongoDB Database Tools to the bundle
|
||||
if: steps.preflight.outputs.skip != 'true'
|
||||
@@ -1659,27 +1642,20 @@ jobs:
|
||||
node_meta="$(bash $SRC/releases/embed-verified-node.sh bundle/node.exe node-win64.exe "$NODE_VERSION")" \
|
||||
|| { echo "::error::Verified Node.js embed failed for win64."; exit 1; }
|
||||
eval "$node_meta"
|
||||
# Bundled FerretDB (win64): download this arch's binary and verify it
|
||||
# against the .sha256sum wekan/FerretDB publishes beside it.
|
||||
FERRET_URL="https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-win64.exe"
|
||||
if bash "$SRC/releases/fetch.sh" --optional -o bundle/ferretdb.exe "$FERRET_URL"; then
|
||||
echo "Embedded FerretDB win64 -> bundle/ferretdb.exe"
|
||||
if bash "$SRC/releases/fetch.sh" --optional -o /tmp/ferretdb.sha256sum "${FERRET_URL}.sha256sum"; then
|
||||
FERRET_SHA="$(awk '{print $1; exit}' /tmp/ferretdb.sha256sum)"
|
||||
GOT="$(sha256sum bundle/ferretdb.exe | cut -d' ' -f1)"
|
||||
if [ "$GOT" != "$FERRET_SHA" ]; then
|
||||
echo "::error::FerretDB win64 does not match its published SHA256 (published $FERRET_SHA, got $GOT)."
|
||||
exit 1
|
||||
fi
|
||||
echo "FerretDB SHA256 verified: $GOT"
|
||||
echo "FERRET_SHA=$FERRET_SHA" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "::warning::wekan/FerretDB publishes no checksum for ferretdb-win64.exe on this release, so it is unverified."
|
||||
fi
|
||||
else
|
||||
rm -f bundle/ferretdb.exe
|
||||
echo "No win64 FerretDB asset in the release; Windows bundle stays MongoDB-only."
|
||||
fi
|
||||
# The SQLite-only ferretdb-sqlite-win64 from the newest wekan/FerretDB
|
||||
# release that has it - or, until one does, the full ferretdb-win64 of the
|
||||
# newest release - verified against its .sha256sum. No FerretDB is built
|
||||
# here. releases/ferretdb-asset.sh says in the log which one it took and
|
||||
# why, and hands back FERRET_ASSET, FERRET_TAG, FERRET_URL and FERRET_SHA
|
||||
# for the provenance record (here, and through $GITHUB_ENV in later steps).
|
||||
rc=0; ferret_meta="$(bash "$SRC/releases/ferretdb-asset.sh" win64 -o bundle/ferretdb.exe)" || rc=$?
|
||||
case "$rc" in
|
||||
0) eval "$ferret_meta"
|
||||
echo "Embedded FerretDB $FERRET_ASSET ($FERRET_TAG) -> bundle/ferretdb.exe" ;;
|
||||
1) rm -f bundle/ferretdb.exe
|
||||
echo "No win64 FerretDB asset in the release; Windows bundle stays MongoDB-only." ;;
|
||||
*) echo "::error::FerretDB for win64 could not be put in the bundle; the lines above say why."; exit 1 ;;
|
||||
esac
|
||||
# Self-contained Windows launcher (defaults to FerretDB SQLite).
|
||||
cp $SRC/releases/ferretdb/start-wekan.bat bundle/
|
||||
# The in-process archive mount the single EXE preloads. It is inert
|
||||
@@ -1694,10 +1670,9 @@ jobs:
|
||||
"${node_url:-}" \
|
||||
"${node_sha256:-}" || true
|
||||
if [ -s bundle/ferretdb.exe ]; then
|
||||
FERRET_TAG="$(bash $SRC/releases/ferretdb-latest-tag.sh || true)"
|
||||
PROVENANCE_FILE=provenance/win64.tsv bash $SRC/releases/record-provenance.sh \
|
||||
'win64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
|
||||
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-win64.exe' \
|
||||
"${FERRET_URL:-}" \
|
||||
"${FERRET_SHA:-}" || true
|
||||
fi
|
||||
# Check what goes IN, before anything is archived. This is the honest place
|
||||
@@ -2047,8 +2022,10 @@ jobs:
|
||||
# This job checks the repo out into src/ (see the checkout step above),
|
||||
# so the script lives at $SRC/releases/, not the workspace root - the
|
||||
# same path the start-wekan.bat copy below uses.
|
||||
bash $SRC/releases/require-binaries.sh 'win-arm64 bundle' \
|
||||
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-win-arm64.exe'
|
||||
# The SQLite-only ferretdb-sqlite-win-arm64, or the full ferretdb-win-arm64 of
|
||||
# the newest release until one has it (releases/ferretdb-asset.sh says which).
|
||||
FERRET_URL="$(bash $SRC/releases/ferretdb-asset.sh --url win-arm64)"
|
||||
bash $SRC/releases/require-binaries.sh 'win-arm64 bundle' "$FERRET_URL"
|
||||
|
||||
- name: Add MongoDB Database Tools to the bundle
|
||||
if: steps.preflight.outputs.skip != 'true'
|
||||
@@ -2088,27 +2065,20 @@ jobs:
|
||||
node_meta="$(bash $SRC/releases/embed-verified-node.sh bundle/node.exe node-win-arm64.exe "$NODE_VERSION")" \
|
||||
|| { echo "::error::Verified Node.js embed failed for win-arm64."; exit 1; }
|
||||
eval "$node_meta"
|
||||
# Bundled FerretDB (win-arm64): download this arch's binary and verify it
|
||||
# against the .sha256sum wekan/FerretDB publishes beside it.
|
||||
FERRET_URL="https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-win-arm64.exe"
|
||||
if bash "$SRC/releases/fetch.sh" --optional -o bundle/ferretdb.exe "$FERRET_URL"; then
|
||||
echo "Embedded FerretDB win-arm64 -> bundle/ferretdb.exe"
|
||||
if bash "$SRC/releases/fetch.sh" --optional -o /tmp/ferretdb.sha256sum "${FERRET_URL}.sha256sum"; then
|
||||
FERRET_SHA="$(awk '{print $1; exit}' /tmp/ferretdb.sha256sum)"
|
||||
GOT="$(sha256sum bundle/ferretdb.exe | cut -d' ' -f1)"
|
||||
if [ "$GOT" != "$FERRET_SHA" ]; then
|
||||
echo "::error::FerretDB win-arm64 does not match its published SHA256 (published $FERRET_SHA, got $GOT)."
|
||||
exit 1
|
||||
fi
|
||||
echo "FerretDB SHA256 verified: $GOT"
|
||||
echo "FERRET_SHA=$FERRET_SHA" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "::warning::wekan/FerretDB publishes no checksum for ferretdb-win-arm64.exe on this release, so it is unverified."
|
||||
fi
|
||||
else
|
||||
rm -f bundle/ferretdb.exe
|
||||
echo "No win-arm64 FerretDB asset in the release; Windows bundle stays MongoDB-only."
|
||||
fi
|
||||
# The SQLite-only ferretdb-sqlite-win-arm64 from the newest wekan/FerretDB
|
||||
# release that has it - or, until one does, the full ferretdb-win-arm64 of the
|
||||
# newest release - verified against its .sha256sum. No FerretDB is built
|
||||
# here. releases/ferretdb-asset.sh says in the log which one it took and
|
||||
# why, and hands back FERRET_ASSET, FERRET_TAG, FERRET_URL and FERRET_SHA
|
||||
# for the provenance record (here, and through $GITHUB_ENV in later steps).
|
||||
rc=0; ferret_meta="$(bash "$SRC/releases/ferretdb-asset.sh" win-arm64 -o bundle/ferretdb.exe)" || rc=$?
|
||||
case "$rc" in
|
||||
0) eval "$ferret_meta"
|
||||
echo "Embedded FerretDB $FERRET_ASSET ($FERRET_TAG) -> bundle/ferretdb.exe" ;;
|
||||
1) rm -f bundle/ferretdb.exe
|
||||
echo "No win-arm64 FerretDB asset in the release; Windows bundle stays MongoDB-only." ;;
|
||||
*) echo "::error::FerretDB for win-arm64 could not be put in the bundle; the lines above say why."; exit 1 ;;
|
||||
esac
|
||||
# Self-contained Windows launcher (defaults to FerretDB SQLite).
|
||||
cp $SRC/releases/ferretdb/start-wekan.bat bundle/
|
||||
# Where this bundle's binaries came from, for the release notes.
|
||||
@@ -2118,10 +2088,9 @@ jobs:
|
||||
"${node_url:-}" \
|
||||
"${node_sha256:-}" || true
|
||||
if [ -s bundle/ferretdb.exe ]; then
|
||||
FERRET_TAG="$(bash $SRC/releases/ferretdb-latest-tag.sh || true)"
|
||||
PROVENANCE_FILE=provenance/win-arm64.tsv bash $SRC/releases/record-provenance.sh \
|
||||
'win-arm64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
|
||||
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-win-arm64.exe' \
|
||||
"${FERRET_URL:-}" \
|
||||
"${FERRET_SHA:-}" || true
|
||||
fi
|
||||
# Check what goes IN, before anything is archived. This is the honest place
|
||||
@@ -2487,8 +2456,10 @@ jobs:
|
||||
run: |
|
||||
SRC="$PWD/src"
|
||||
set -euo pipefail
|
||||
bash $SRC/releases/require-binaries.sh 'win32 bundle' \
|
||||
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-win32.exe'
|
||||
# The SQLite-only ferretdb-sqlite-win32, or the full ferretdb-win32 of
|
||||
# the newest release until one has it (releases/ferretdb-asset.sh says which).
|
||||
FERRET_URL="$(bash $SRC/releases/ferretdb-asset.sh --url win32)"
|
||||
bash $SRC/releases/require-binaries.sh 'win32 bundle' "$FERRET_URL"
|
||||
|
||||
- name: Add MongoDB Database Tools to the bundle
|
||||
if: steps.preflight.outputs.skip != 'true'
|
||||
@@ -2516,25 +2487,20 @@ jobs:
|
||||
node_meta="$(bash $SRC/releases/embed-verified-node.sh bundle/node.exe node-win32.exe "$NODE_VERSION")" \
|
||||
|| { echo "::error::Verified Node.js embed failed for win32."; exit 1; }
|
||||
eval "$node_meta"
|
||||
FERRET_URL="https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-win32.exe"
|
||||
if bash "$SRC/releases/fetch.sh" --optional -o bundle/ferretdb.exe "$FERRET_URL"; then
|
||||
echo "Embedded FerretDB win32 -> bundle/ferretdb.exe"
|
||||
if bash "$SRC/releases/fetch.sh" --optional -o /tmp/ferretdb.sha256sum "${FERRET_URL}.sha256sum"; then
|
||||
FERRET_SHA="$(awk '{print $1; exit}' /tmp/ferretdb.sha256sum)"
|
||||
GOT="$(sha256sum bundle/ferretdb.exe | cut -d' ' -f1)"
|
||||
if [ "$GOT" != "$FERRET_SHA" ]; then
|
||||
echo "::error::FerretDB win32 does not match its published SHA256 (published $FERRET_SHA, got $GOT)."
|
||||
exit 1
|
||||
fi
|
||||
echo "FerretDB SHA256 verified: $GOT"
|
||||
echo "FERRET_SHA=$FERRET_SHA" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "::warning::wekan/FerretDB publishes no checksum for ferretdb-win32.exe on this release, so it is unverified."
|
||||
fi
|
||||
else
|
||||
rm -f bundle/ferretdb.exe
|
||||
echo "No win32 FerretDB asset in the release; Windows bundle stays MongoDB-only."
|
||||
fi
|
||||
# The SQLite-only ferretdb-sqlite-win32 from the newest wekan/FerretDB
|
||||
# release that has it - or, until one does, the full ferretdb-win32 of the
|
||||
# newest release - verified against its .sha256sum. No FerretDB is built
|
||||
# here. releases/ferretdb-asset.sh says in the log which one it took and
|
||||
# why, and hands back FERRET_ASSET, FERRET_TAG, FERRET_URL and FERRET_SHA
|
||||
# for the provenance record (here, and through $GITHUB_ENV in later steps).
|
||||
rc=0; ferret_meta="$(bash "$SRC/releases/ferretdb-asset.sh" win32 -o bundle/ferretdb.exe)" || rc=$?
|
||||
case "$rc" in
|
||||
0) eval "$ferret_meta"
|
||||
echo "Embedded FerretDB $FERRET_ASSET ($FERRET_TAG) -> bundle/ferretdb.exe" ;;
|
||||
1) rm -f bundle/ferretdb.exe
|
||||
echo "No win32 FerretDB asset in the release; Windows bundle stays MongoDB-only." ;;
|
||||
*) echo "::error::FerretDB for win32 could not be put in the bundle; the lines above say why."; exit 1 ;;
|
||||
esac
|
||||
cp $SRC/releases/ferretdb/start-wekan.bat bundle/
|
||||
mkdir -p provenance
|
||||
PROVENANCE_FILE=provenance/win32.tsv bash $SRC/releases/record-provenance.sh \
|
||||
@@ -2542,10 +2508,9 @@ jobs:
|
||||
"${node_url:-}" \
|
||||
"${node_sha256:-}" || true
|
||||
if [ -s bundle/ferretdb.exe ]; then
|
||||
FERRET_TAG="$(bash $SRC/releases/ferretdb-latest-tag.sh || true)"
|
||||
PROVENANCE_FILE=provenance/win32.tsv bash $SRC/releases/record-provenance.sh \
|
||||
'win32' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
|
||||
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-win32.exe' \
|
||||
"${FERRET_URL:-}" \
|
||||
"${FERRET_SHA:-}" || true
|
||||
fi
|
||||
for want in main.js node.exe start-wekan.bat; do
|
||||
@@ -2768,8 +2733,10 @@ jobs:
|
||||
if: steps.preflight.outputs.skip != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash releases/require-binaries.sh 'mac-arm64 bundle' \
|
||||
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-mac-arm64'
|
||||
# The SQLite-only ferretdb-sqlite-mac-arm64, or the full ferretdb-mac-arm64 of
|
||||
# the newest release until one has it (releases/ferretdb-asset.sh says which).
|
||||
FERRET_URL="$(bash releases/ferretdb-asset.sh --url mac-arm64)"
|
||||
bash releases/require-binaries.sh 'mac-arm64 bundle' "$FERRET_URL"
|
||||
|
||||
- name: Embed Node.js + FerretDB (mac-arm64) into the bundle
|
||||
if: steps.preflight.outputs.skip != 'true'
|
||||
@@ -2787,27 +2754,21 @@ jobs:
|
||||
node_meta="$(bash releases/embed-verified-node.sh bundle/node node-mac-arm64 "$NODE_VERSION")" \
|
||||
|| { echo "::error::Verified Node.js embed failed for mac-arm64."; exit 1; }
|
||||
eval "$node_meta"
|
||||
FERRET_URL="https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-mac-arm64"
|
||||
if bash "$GITHUB_WORKSPACE/releases/fetch.sh" --optional -o bundle/ferretdb "$FERRET_URL"; then
|
||||
chmod +x bundle/ferretdb
|
||||
echo "Embedded FerretDB mac-arm64 -> bundle/ferretdb"
|
||||
if bash "$GITHUB_WORKSPACE/releases/fetch.sh" --optional -o /tmp/ferretdb.sha256sum "${FERRET_URL}.sha256sum"; then
|
||||
FERRET_SHA="$(awk '{print $1; exit}' /tmp/ferretdb.sha256sum)"
|
||||
GOT="$(shasum -a 256 bundle/ferretdb | cut -d' ' -f1)" # macOS has no sha256sum
|
||||
if [ "$GOT" != "$FERRET_SHA" ]; then
|
||||
echo "::error::FerretDB mac-arm64 does not match its published SHA256 (published $FERRET_SHA, got $GOT)."
|
||||
exit 1
|
||||
fi
|
||||
echo "FerretDB SHA256 verified: $GOT"
|
||||
echo "FERRET_SHA=$FERRET_SHA" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "::warning::wekan/FerretDB publishes no checksum for ferretdb-mac-arm64 on this release, so it is unverified."
|
||||
fi
|
||||
else
|
||||
rm -f bundle/ferretdb
|
||||
echo "No mac-arm64 FerretDB asset in the release; macOS bundle stays MongoDB-only."
|
||||
rm -f bundle/start-wekan.sh
|
||||
fi
|
||||
# The SQLite-only ferretdb-sqlite-mac-arm64 from the newest wekan/FerretDB
|
||||
# release that has it - or, until one does, the full ferretdb-mac-arm64 of the
|
||||
# newest release - verified against its .sha256sum. No FerretDB is built
|
||||
# here. releases/ferretdb-asset.sh says in the log which one it took and
|
||||
# why, and hands back FERRET_ASSET, FERRET_TAG, FERRET_URL and FERRET_SHA
|
||||
# for the provenance record (here, and through $GITHUB_ENV in later steps).
|
||||
rc=0; ferret_meta="$(bash "$GITHUB_WORKSPACE/releases/ferretdb-asset.sh" mac-arm64 -o bundle/ferretdb)" || rc=$?
|
||||
case "$rc" in
|
||||
0) eval "$ferret_meta"
|
||||
echo "Embedded FerretDB $FERRET_ASSET ($FERRET_TAG) -> bundle/ferretdb" ;;
|
||||
1) rm -f bundle/ferretdb
|
||||
echo "No mac-arm64 FerretDB asset in the release; macOS bundle stays MongoDB-only."
|
||||
rm -f bundle/start-wekan.sh ;;
|
||||
*) echo "::error::FerretDB for mac-arm64 could not be put in the bundle; the lines above say why."; exit 1 ;;
|
||||
esac
|
||||
# Where this bundle's binaries came from, for the release notes.
|
||||
mkdir -p provenance
|
||||
PROVENANCE_FILE=provenance/mac-arm64.tsv bash releases/record-provenance.sh \
|
||||
@@ -2815,10 +2776,9 @@ jobs:
|
||||
"${node_url:-}" \
|
||||
"${node_sha256:-}" || true
|
||||
if [ -s bundle/ferretdb ]; then
|
||||
FERRET_TAG="$(bash releases/ferretdb-latest-tag.sh || true)"
|
||||
PROVENANCE_FILE=provenance/mac-arm64.tsv bash releases/record-provenance.sh \
|
||||
'mac-arm64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
|
||||
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-mac-arm64' \
|
||||
"${FERRET_URL:-}" \
|
||||
"${FERRET_SHA:-}" || true
|
||||
fi
|
||||
# macOS MongoDB Database Tools (mac-arm64) from wekan/mongo-tools-patches; tolerant
|
||||
@@ -3059,8 +3019,10 @@ jobs:
|
||||
if: steps.preflight.outputs.skip != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash releases/require-binaries.sh 'mac-x64 bundle' \
|
||||
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-mac-amd64'
|
||||
# The SQLite-only ferretdb-sqlite-mac-amd64, or the full ferretdb-mac-amd64 of
|
||||
# the newest release until one has it (releases/ferretdb-asset.sh says which).
|
||||
FERRET_URL="$(bash releases/ferretdb-asset.sh --url mac-amd64)"
|
||||
bash releases/require-binaries.sh 'mac-x64 bundle' "$FERRET_URL"
|
||||
|
||||
- name: Embed Node.js + FerretDB (mac-x64) into the bundle
|
||||
if: steps.preflight.outputs.skip != 'true'
|
||||
@@ -3073,27 +3035,21 @@ jobs:
|
||||
node_meta="$(bash releases/embed-verified-node.sh bundle/node node-mac-x64 "$NODE_VERSION")" \
|
||||
|| { echo "::error::Verified Node.js embed failed for mac-x64."; exit 1; }
|
||||
eval "$node_meta"
|
||||
FERRET_URL="https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-mac-amd64"
|
||||
if bash "$GITHUB_WORKSPACE/releases/fetch.sh" --optional -o bundle/ferretdb "$FERRET_URL"; then
|
||||
chmod +x bundle/ferretdb
|
||||
echo "Embedded FerretDB mac-amd64 -> bundle/ferretdb"
|
||||
if bash "$GITHUB_WORKSPACE/releases/fetch.sh" --optional -o /tmp/ferretdb.sha256sum "${FERRET_URL}.sha256sum"; then
|
||||
FERRET_SHA="$(awk '{print $1; exit}' /tmp/ferretdb.sha256sum)"
|
||||
GOT="$(shasum -a 256 bundle/ferretdb | cut -d' ' -f1)" # macOS has no sha256sum
|
||||
if [ "$GOT" != "$FERRET_SHA" ]; then
|
||||
echo "::error::FerretDB mac-amd64 does not match its published SHA256 (published $FERRET_SHA, got $GOT)."
|
||||
exit 1
|
||||
fi
|
||||
echo "FerretDB SHA256 verified: $GOT"
|
||||
echo "FERRET_SHA=$FERRET_SHA" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "::warning::wekan/FerretDB publishes no checksum for ferretdb-mac-amd64 on this release, so it is unverified."
|
||||
fi
|
||||
else
|
||||
rm -f bundle/ferretdb
|
||||
echo "No mac-amd64 FerretDB asset in the release; macOS bundle stays MongoDB-only."
|
||||
rm -f bundle/start-wekan.sh
|
||||
fi
|
||||
# The SQLite-only ferretdb-sqlite-mac-amd64 from the newest wekan/FerretDB
|
||||
# release that has it - or, until one does, the full ferretdb-mac-amd64 of the
|
||||
# newest release - verified against its .sha256sum. No FerretDB is built
|
||||
# here. releases/ferretdb-asset.sh says in the log which one it took and
|
||||
# why, and hands back FERRET_ASSET, FERRET_TAG, FERRET_URL and FERRET_SHA
|
||||
# for the provenance record (here, and through $GITHUB_ENV in later steps).
|
||||
rc=0; ferret_meta="$(bash "$GITHUB_WORKSPACE/releases/ferretdb-asset.sh" mac-amd64 -o bundle/ferretdb)" || rc=$?
|
||||
case "$rc" in
|
||||
0) eval "$ferret_meta"
|
||||
echo "Embedded FerretDB $FERRET_ASSET ($FERRET_TAG) -> bundle/ferretdb" ;;
|
||||
1) rm -f bundle/ferretdb
|
||||
echo "No mac-amd64 FerretDB asset in the release; macOS bundle stays MongoDB-only."
|
||||
rm -f bundle/start-wekan.sh ;;
|
||||
*) echo "::error::FerretDB for mac-amd64 could not be put in the bundle; the lines above say why."; exit 1 ;;
|
||||
esac
|
||||
# Where this bundle's binaries came from, for the release notes.
|
||||
mkdir -p provenance
|
||||
PROVENANCE_FILE=provenance/mac-x64.tsv bash releases/record-provenance.sh \
|
||||
@@ -3101,10 +3057,9 @@ jobs:
|
||||
"${node_url:-}" \
|
||||
"${node_sha256:-}" || true
|
||||
if [ -s bundle/ferretdb ]; then
|
||||
FERRET_TAG="$(bash releases/ferretdb-latest-tag.sh || true)"
|
||||
PROVENANCE_FILE=provenance/mac-x64.tsv bash releases/record-provenance.sh \
|
||||
'mac-x64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
|
||||
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-mac-amd64' \
|
||||
"${FERRET_URL:-}" \
|
||||
"${FERRET_SHA:-}" || true
|
||||
fi
|
||||
# macOS MongoDB Database Tools (mac-x64) from wekan/mongo-tools-patches; tolerant
|
||||
@@ -3472,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" \
|
||||
@@ -3504,25 +3468,15 @@ jobs:
|
||||
# two names are kept separate here so a platform whose Node.js name,
|
||||
# FerretDB name and our own name disagree cannot silently download the
|
||||
# wrong CPU's binary.
|
||||
FERRET_URL="https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-${MATRIX_FERRETDB_ARCH}"
|
||||
bash "$GITHUB_WORKSPACE/releases/fetch.sh" -o bundle/ferretdb "$FERRET_URL"
|
||||
chmod +x bundle/ferretdb
|
||||
# wekan/FerretDB publishes a .sha256sum beside each binary now. Older
|
||||
# releases do not, so a missing one is recorded as unverified rather
|
||||
# than failing the build - the binary IS available, which is the test.
|
||||
FERRET_SHA=""
|
||||
if bash "$GITHUB_WORKSPACE/releases/fetch.sh" --optional -o /tmp/ferretdb.sha256sum "${FERRET_URL}.sha256sum"; then
|
||||
FERRET_SHA="$(awk '{print $1; exit}' /tmp/ferretdb.sha256sum)"
|
||||
GOT="$(sha256sum bundle/ferretdb | cut -d' ' -f1)"
|
||||
if [ "$GOT" != "$FERRET_SHA" ]; then
|
||||
echo "::error::FerretDB for ${MATRIX_FERRETDB_ARCH} does not match its published SHA256 (published $FERRET_SHA, got $GOT)."
|
||||
exit 1
|
||||
fi
|
||||
echo "FerretDB SHA256 verified: $GOT"
|
||||
else
|
||||
echo "::warning::wekan/FerretDB publishes no checksum for ferretdb-${MATRIX_FERRETDB_ARCH} on this release, so it is unverified."
|
||||
fi
|
||||
FERRET_TAG="$(bash releases/ferretdb-latest-tag.sh || true)"
|
||||
# The SQLite-only ferretdb-sqlite-<arch> from the newest wekan/FerretDB
|
||||
# release that has it - or, until one does, the full ferretdb-<arch> of the
|
||||
# newest release - verified against its .sha256sum. No FerretDB is built
|
||||
# here. releases/ferretdb-asset.sh says in the log which one it took and
|
||||
# why, and hands back FERRET_ASSET, FERRET_TAG, FERRET_URL and FERRET_SHA
|
||||
# for the provenance record (here, and through $GITHUB_ENV in later steps).
|
||||
ferret_meta="$(bash "$GITHUB_WORKSPACE/releases/ferretdb-asset.sh" "${MATRIX_FERRETDB_ARCH}" -o bundle/ferretdb)" \
|
||||
|| { echo "::error::FerretDB for ${MATRIX_FERRETDB_ARCH} could not be put in the bundle; the lines above say why."; exit 1; }
|
||||
eval "$ferret_meta"
|
||||
bash releases/record-provenance.sh "${MATRIX_ARCH}" 'FerretDB' \
|
||||
'wekan/FerretDB' "${FERRET_TAG:-latest}" "$FERRET_URL" "$FERRET_SHA"
|
||||
# Overwrite the inherited amd64 MongoDB Database Tools with this arch's ones.
|
||||
@@ -3718,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
|
||||
@@ -3742,18 +3696,25 @@ 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
|
||||
# on the retired releases.wekan.team or the old projects.7z.
|
||||
# FerretDB is the PREBUILT SQLite-only ferretdb-sqlite-amd64 from the newest
|
||||
# wekan/FerretDB release that has it (the full ferretdb-amd64 of the newest
|
||||
# release until one does), picked by releases/ferretdb-asset.sh - nothing
|
||||
# is compiled here (docs/Platforms/FOSS/Container/Sandstorm/Smaller-wekan-spk.md).
|
||||
env:
|
||||
FERRETDB_SOURCE: download
|
||||
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:
|
||||
@@ -3769,7 +3730,7 @@ jobs:
|
||||
chmod 600 "$HOME/.sandstorm-keyring"
|
||||
echo "Restored ~/.sandstorm-keyring ($(wc -c < "$HOME/.sandstorm-keyring") bytes)."
|
||||
else
|
||||
echo "::error::SANDSTORM_KEYRING secret is not set. meteor-spk pack signs the .spk with the WeKan Sandstorm app's private key, which lives in this keyring, so packing cannot proceed without it. Add the base64 of your ~/.sandstorm-keyring file as the SANDSTORM_KEYRING repository secret (e.g. 'base64 -w0 ~/.sandstorm-keyring')."
|
||||
echo "::error::SANDSTORM_KEYRING secret is not set. spk pack signs the .spk with the WeKan Sandstorm app's private key, which lives in this keyring, so packing cannot proceed without it. Add the base64 of your ~/.sandstorm-keyring file as the SANDSTORM_KEYRING repository secret (e.g. 'base64 -w0 ~/.sandstorm-keyring')."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -3793,29 +3754,17 @@ jobs:
|
||||
# idempotently with --clobber replacing the same asset.
|
||||
SPK="wekan-${VERSION}-sandstorm.spk"
|
||||
|
||||
# WHERE THE GIGABYTE IS — reported AFTER pack, because pack is what creates
|
||||
# the trees to measure.
|
||||
#
|
||||
# v10.93 failed here with "App exceeds uncompressed size limit of 1 GiB" and
|
||||
# nothing else, so v10.94 added a size report BEFORE `meteor-spk pack`. It
|
||||
# printed nothing at all: `meteor-spk pack` BUILDS the Meteor app itself
|
||||
# ("Building Meteor app..." comes after it starts), so .meteor-spk/deps and
|
||||
# .meteor-spk/bundle do not exist until it has run. The report was measuring
|
||||
# two directories that were not there yet.
|
||||
#
|
||||
# So the sizes are taken afterwards — on failure, which is when they are the
|
||||
# whole point, and on success, where the headroom against 1 GiB is worth
|
||||
# knowing before the next thing is added. `du -shL` DEREFERENCES: deps is a
|
||||
# symlink to the meteor-spk.deps tree build-deps.sh assembles, and plain
|
||||
# `du -sh` on a symlink reports the link, not the gigabyte behind it.
|
||||
# WHERE THE GIGABYTE IS. Sandstorm refuses an app over 1 GiB
|
||||
# uncompressed across .meteor-spk/deps and .meteor-spk/bundle, and
|
||||
# v10.93 failed on exactly that with nothing else said - so the trees
|
||||
# are measured after the build, on failure and on success. `du -shL`
|
||||
# DEREFERENCES: deps is a symlink to the meteor-spk.deps tree.
|
||||
sizes() {
|
||||
for tree in .meteor-spk/deps .meteor-spk/bundle; do
|
||||
[ -e "$tree" ] || { echo "--- $tree: not present"; continue; }
|
||||
echo "--- $tree: $(du -shL "$tree" 2>/dev/null | cut -f1) total"
|
||||
du -shL "$tree"/* 2>/dev/null | sort -rh | head -20 || true
|
||||
done
|
||||
# The bundle's server packages are the part every OTHER bundle prunes, so
|
||||
# name the biggest of them too.
|
||||
if [ -d .meteor-spk/bundle/programs/server/node_modules ]; then
|
||||
echo "--- .meteor-spk/bundle/programs/server/node_modules, biggest 20:"
|
||||
du -shL .meteor-spk/bundle/programs/server/node_modules/* 2>/dev/null \
|
||||
@@ -3823,97 +3772,24 @@ jobs:
|
||||
fi
|
||||
}
|
||||
|
||||
echo "--- packing (Sandstorm refuses over 1 GiB uncompressed)"
|
||||
# The smaller .spk (docs/Platforms/FOSS/Container/Sandstorm/Smaller-wekan-spk.md):
|
||||
# built with the Sandstorm package profile - no password, OAuth2, OIDC,
|
||||
# CAS, LDAP, SAML or social-login packages, which a grain never runs -
|
||||
# then trimmed to a linux/x64 sockjs grain (bundle-trim.mjs,
|
||||
# prune-unreachable-npm.mjs, prune-build-only-modules.mjs) and packed
|
||||
# with Sandstorm's own `spk pack`, which packs the trimmed trees as
|
||||
# they are. `meteor-spk pack` is not used: it rebuilds the bundle first
|
||||
# and throws the trim away (v10.98's log: 856M -> 424M, then rebuilt).
|
||||
# The trim used to run only after a FAILED pack; it is the first pack now.
|
||||
set +e
|
||||
meteor-spk pack "$SPK"
|
||||
bash releases/sandstorm-slim-spk.sh "$SPK"
|
||||
pack_rc=$?
|
||||
set -e
|
||||
sizes || true
|
||||
if [ "$pack_rc" -ne 0 ]; then
|
||||
# ONE bounded retry, with the bundle pruned the way every other bundle is.
|
||||
#
|
||||
# `meteor-spk pack` runs `npm install` inside the bundle's programs/server -
|
||||
# that is where tar@6.2.1 and node-gyp's tree come from in the log above - and
|
||||
# the Sandstorm leg is the ONLY one that never removes them afterwards.
|
||||
# prune-build-only-modules.mjs drops 83 of 120 packages everywhere else, and
|
||||
# those packages are pure build toolchain: nothing in a packed app runs them.
|
||||
#
|
||||
# v10.95 ANSWERED the question this comment used to ask - does a second
|
||||
# `meteor-spk pack` reuse .meteor-spk/bundle or rebuild it and undo the
|
||||
# prune? It REUSES it: the bundle went 852M -> 833M across the retry and
|
||||
# stayed pruned. So trimming here works, and the retry now does the whole
|
||||
# trim rather than the prune alone, because 19M was never going to close a
|
||||
# 165M gap. bundle-trim.mjs drops the two big passengers the size report
|
||||
# finally identified (its own header has the numbers):
|
||||
#
|
||||
# * uWebSockets.js, all 121M of it. A grain talks DDP over sockjs -
|
||||
# sandstorm-pkgdef.capnp pins DDP_TRANSPORT=sockjs, and that environ
|
||||
# is the app's ENTIRE environment - and ddp-server's
|
||||
# `Npm.require('uWebSockets.js')` sits inside the uws transport's
|
||||
# setup(), which a sockjs server never calls. So the module is not
|
||||
# merely trimmed to this platform's prebuild, it is not shipped;
|
||||
# * every source map, 4766 files and 188M, which only a debugger reads.
|
||||
#
|
||||
# Measured on a real bundle, and the pruned toolchain on top:
|
||||
# 1188M -> ~860M, comfortably under the 1 GiB limit.
|
||||
if [ -d .meteor-spk/bundle/programs/server/node_modules ] \
|
||||
&& [ -f releases/prune-build-only-modules.mjs ]; then
|
||||
echo "--- pack failed; trimming the bundle to what a grain runs, then packing once more"
|
||||
# A Sandstorm grain is linux/x64, always - it is what the deps tree above
|
||||
# bundles a node and a mongod for.
|
||||
node releases/bundle-trim.mjs .meteor-spk/bundle --platform linux --arch x64 --transport sockjs --drop-legacy-client --trim-prebuilds || true
|
||||
# And the third tree: programs/server/npm/node_modules is a full `npm install`,
|
||||
# devDependencies included, that rspack cannot tree-shake because Atmosphere
|
||||
# packages load it through Npm.require(). This removes only what it can prove
|
||||
# nothing requires, and refuses any policy entry the graph says IS reached.
|
||||
node releases/prune-unreachable-npm.mjs .meteor-spk/bundle
|
||||
node releases/prune-build-only-modules.mjs .meteor-spk/bundle || true
|
||||
sizes || true
|
||||
# PACK AGAIN WITHOUT REBUILDING. `meteor-spk pack` runs the Meteor
|
||||
# build first - the "Building Meteor app..." line - which
|
||||
# regenerates .meteor-spk/bundle and throws the trim away. v10.98's
|
||||
# log is the proof: the trim took the bundle 856M -> 424M, which
|
||||
# with the 336M deps tree is 760M, comfortably under the limit, and
|
||||
# then pack rebuilt it and failed at the same size as before.
|
||||
#
|
||||
# An earlier note here claimed pack REUSES the bundle. It does not.
|
||||
# That was inferred from a size reading taken right after the prune
|
||||
# - this same point in the sequence - and never checked against what
|
||||
# the second pack actually did.
|
||||
#
|
||||
# Sandstorm's own `spk pack` does only the packing: it reads
|
||||
# sandstorm-pkgdef.capnp, whose sourceMap searchPath is exactly the
|
||||
# two trees that have just been trimmed, and signs with
|
||||
# ~/.sandstorm-keyring. So the retry uses that where it exists.
|
||||
SPK_BIN=""
|
||||
for candidate in /opt/sandstorm/latest/bin/spk /opt/sandstorm/spk \
|
||||
"$(command -v spk 2>/dev/null)"; do
|
||||
if [ -n "$candidate" ] && [ -x "$candidate" ]; then SPK_BIN="$candidate"; break; fi
|
||||
done
|
||||
set +e
|
||||
if [ -n "$SPK_BIN" ]; then
|
||||
echo "--- packing the TRIMMED trees with $SPK_BIN (no rebuild)"
|
||||
"$SPK_BIN" pack "$SPK"
|
||||
pack_rc=$?
|
||||
if [ "$pack_rc" -ne 0 ]; then
|
||||
echo "--- spk pack failed (exit $pack_rc); falling back to meteor-spk, which rebuilds"
|
||||
meteor-spk pack "$SPK"
|
||||
pack_rc=$?
|
||||
fi
|
||||
else
|
||||
echo "::warning::no sandstorm spk binary found, so this retry uses meteor-spk, which REBUILDS the bundle and undoes the trim above."
|
||||
meteor-spk pack "$SPK"
|
||||
pack_rc=$?
|
||||
fi
|
||||
set -e
|
||||
[ "$pack_rc" -eq 0 ] && echo "--- the trimmed bundle packed"
|
||||
fi
|
||||
fi
|
||||
if [ "$pack_rc" -ne 0 ]; then
|
||||
echo "::error::meteor-spk pack failed (exit $pack_rc). The sizes above are what it tried to pack; Sandstorm's limit is 1 GiB uncompressed across .meteor-spk/deps and .meteor-spk/bundle together."
|
||||
echo "::error::the Sandstorm .spk did not pack (exit $pack_rc). The sizes above are what it tried to pack; Sandstorm's limit is 1 GiB uncompressed across .meteor-spk/deps and .meteor-spk/bundle together."
|
||||
exit "$pack_rc"
|
||||
fi
|
||||
python3 releases/check-telemetry.py --bundle .meteor-spk/bundle
|
||||
# Report the packed size and warn if it exceeds Cloudflare's 100 MB upload
|
||||
# cap (a proxied browser upload to a Cloudflare-fronted Sandstorm 413s above
|
||||
# this; install via a DNS-only host / SSH tunnel, or trim further).
|
||||
@@ -5185,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
|
||||
@@ -5214,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=$?
|
||||
@@ -5270,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
|
||||
@@ -5808,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"
|
||||
@@ -5,6 +5,11 @@ name: Build Sandstorm .spk
|
||||
# Release as an attachment, so you can download it, test whether the Sandstorm
|
||||
# build works, and see any errors — without running a full release.
|
||||
#
|
||||
# It builds only what a grain runs (docs/Platforms/FOSS/Container/Sandstorm/
|
||||
# Smaller-wekan-spk.md): WeKan with the Sandstorm package profile - Sandstorm
|
||||
# login only, no password/OAuth2/OIDC/CAS/LDAP/SAML packages - FerretDB from
|
||||
# source with only its SQLite handler, and the deps meteor-spk.deps needs.
|
||||
#
|
||||
# gh workflow run sandstorm.yml
|
||||
#
|
||||
# EXPERIMENTAL in CI (same caveats as the build-sandstorm job in release-all.yml):
|
||||
@@ -45,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
|
||||
@@ -65,17 +60,24 @@ 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 + FerretDB + Mongo 3.x CLIs)
|
||||
# Base = upstream meteor-spk 0.6.0 from dl.sandstorm.io (has mongod 3.0 +
|
||||
- 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.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
|
||||
# on the retired releases.wekan.team or the old projects.7z.
|
||||
# FerretDB is the PREBUILT SQLite-only ferretdb-sqlite-amd64 from the newest
|
||||
# wekan/FerretDB release that has it (the full ferretdb-amd64 of the newest
|
||||
# release until one does), picked by releases/ferretdb-asset.sh - nothing
|
||||
# is compiled here (docs/Platforms/FOSS/Container/Sandstorm/Smaller-wekan-spk.md).
|
||||
env:
|
||||
FERRETDB_SOURCE: download
|
||||
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:
|
||||
@@ -91,7 +93,7 @@ jobs:
|
||||
chmod 600 "$HOME/.sandstorm-keyring"
|
||||
echo "Restored ~/.sandstorm-keyring ($(wc -c < "$HOME/.sandstorm-keyring") bytes)."
|
||||
else
|
||||
echo "::error::SANDSTORM_KEYRING secret is not set. meteor-spk pack signs the .spk with the WeKan Sandstorm app's private key, which lives in this keyring, so packing cannot proceed without it. Add the base64 of your ~/.sandstorm-keyring file as the SANDSTORM_KEYRING repository secret (e.g. 'base64 -w0 ~/.sandstorm-keyring')."
|
||||
echo "::error::SANDSTORM_KEYRING secret is not set. spk pack signs the .spk with the WeKan Sandstorm app's private key, which lives in this keyring, so packing cannot proceed without it. Add the base64 of your ~/.sandstorm-keyring file as the SANDSTORM_KEYRING repository secret (e.g. 'base64 -w0 ~/.sandstorm-keyring')."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -113,7 +115,11 @@ jobs:
|
||||
set -euo pipefail
|
||||
STAMP="$(date +'%Y_%m_%d-%H_%M_%S')"
|
||||
SPK="wekan-sandstorm-${STAMP}.spk"
|
||||
meteor-spk pack "$SPK"
|
||||
# The Sandstorm package profile, the linux/x64 sockjs trim and a
|
||||
# `spk pack` of the trimmed trees (not `meteor-spk pack`, which would
|
||||
# rebuild the bundle and undo the trim). The script restores
|
||||
# .meteor/packages and .meteor/versions when it ends.
|
||||
bash releases/sandstorm-slim-spk.sh "$SPK"
|
||||
# Report the packed size and warn if it exceeds Cloudflare's 100 MB upload
|
||||
# cap (a proxied browser upload to a Cloudflare-fronted Sandstorm 413s above
|
||||
# this; install via a DNS-only host / SSH tunnel, or trim further).
|
||||
|
||||
+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
-8783
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.63
|
||||
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.63.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
|
||||
@@ -202,6 +201,8 @@ echo 16^) EVERYTHING source one by one ^(Dev server nobuild, all browsers^)
|
||||
echo 17^) Live tests with your own accounts: GitLab write-back with the mirror's
|
||||
echo glab login ^(only scratch items it creates and removes^) and the API
|
||||
echo imports, read only ^(releases\live-tests\README.md^)
|
||||
echo 18^) Database speed test: the common WeKan queries on every database with an
|
||||
echo image for this CPU, compared, written to SpeedTest\Results.md
|
||||
set "choice="
|
||||
set /p "choice=Choose: "
|
||||
if "%choice%"=="1" goto test_everything_two
|
||||
@@ -221,6 +222,7 @@ if "%choice%"=="14" goto test_all_databases
|
||||
if "%choice%"=="15" goto test_ferretdb
|
||||
if "%choice%"=="16" goto test_everything_source
|
||||
if "%choice%"=="17" goto test_live
|
||||
if "%choice%"=="18" goto test_db_speed
|
||||
if "%choice%"=="0" goto menu
|
||||
goto menu_tests
|
||||
|
||||
@@ -849,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 ...
|
||||
@@ -864,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 ===========================================================================
|
||||
@@ -970,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"
|
||||
@@ -986,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 (
|
||||
@@ -1958,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"
|
||||
@@ -2065,6 +2087,20 @@ if errorlevel 1 (
|
||||
bash ./releases/db-conformance.sh
|
||||
goto end
|
||||
|
||||
REM ===========================================================================
|
||||
:test_db_speed
|
||||
REM build.sh's "Database speed test": db-conformance.sh's databases, started the
|
||||
REM same way, each loaded with the same dummy WeKan data and timed on the common
|
||||
REM WeKan queries. See docs\Databases\FerretDB\1\SpeedTest\README.md.
|
||||
where bash >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ERROR: bash was not found. It comes with Git for Windows ^(Git Bash^) and with WSL.
|
||||
echo Install either, or run this from WSL: ./docs/Databases/FerretDB/1/SpeedTest/speedtest.sh
|
||||
goto end
|
||||
)
|
||||
bash ./docs/Databases/FerretDB/1/SpeedTest/speedtest.sh
|
||||
goto end
|
||||
|
||||
REM ===========================================================================
|
||||
:test_ferretdb
|
||||
REM All of FerretDB's own tests, one at a time: unit, vet, integration.
|
||||
|
||||
@@ -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|||"
|
||||
@@ -2917,7 +2961,8 @@ while [ -z "$opt" ]; do
|
||||
"Count tests by category|Count amount of tests by category" \
|
||||
"Run all FerretDB tests - SEQUENTIAL|Run all FerretDB tests - SEQUENTIAL: unit, vet and the integration suite of the FerretDB subdirectory of this repo, one at a time, logs in log/<datetime>/" \
|
||||
"Live tests|Run live tests against real services with your own accounts: GitLab write-back with the mirror's glab login (only scratch items it creates and removes) and the API imports, read only (releases/live-tests/README.md); a service with no login here is skipped, log in .tools/log/<datetime>/" \
|
||||
"All databases (sequential)|Test all databases that have a Docker image for this CPU, SEQUENTIALLY: build newest FerretDB v1 from source, then run every FerretDB v1 query type against each database and compare that they all answer the same (results in log/<datetime>/)" ;;
|
||||
"All databases (sequential)|Test all databases that have a Docker image for this CPU, SEQUENTIALLY: build newest FerretDB v1 from source, then run every FerretDB v1 query type against each database and compare that they all answer the same (results in log/<datetime>/)" \
|
||||
"Database speed test|Time the common WeKan queries on every database that has a Docker image for this CPU, SEQUENTIALLY, with twice a production WeKan of dummy data; check they all answer the same and write docs/Databases/FerretDB/1/SpeedTest/Results.md" ;;
|
||||
"Tools")
|
||||
choose "Tools" \
|
||||
"Save Meteor deps list|Save Meteor dependency chain to ../meteor-deps.txt" \
|
||||
@@ -2971,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).
|
||||
@@ -2987,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.
|
||||
@@ -3008,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.
|
||||
@@ -3036,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
|
||||
@@ -3370,6 +3414,12 @@ for _once in 1; do
|
||||
./releases/db-conformance.sh
|
||||
;;
|
||||
|
||||
"Time the common WeKan queries on every database that has a Docker image for this CPU, SEQUENTIALLY, with twice a production WeKan of dummy data; check they all answer the same and write docs/Databases/FerretDB/1/SpeedTest/Results.md")
|
||||
# docs/Databases/FerretDB/1/SpeedTest/README.md: db-conformance.sh's databases,
|
||||
# the speed test's queries.
|
||||
./docs/Databases/FerretDB/1/SpeedTest/speedtest.sh
|
||||
;;
|
||||
|
||||
"Count amount of tests by category")
|
||||
LOG="$(one_log test-counts)"
|
||||
echo "Log: $LOG"
|
||||
|
||||
@@ -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'}}
|
||||
|
||||
@@ -6,6 +6,12 @@ import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
// The per-pane URLs of the Admin Panel. docs/Features/Admin-Panel/Admin-Panel-URLs.md
|
||||
import { adminPath } from '/models/lib/adminUrls';
|
||||
import { leftMenuData, paneTitle } from '/models/lib/leftMenu';
|
||||
const {
|
||||
SANDSTORM_HIDDEN_ATTACHMENTS_PANES,
|
||||
isSandstormSettings,
|
||||
sandstormAllowsPane,
|
||||
withoutSandstormPanes,
|
||||
} = require('/models/lib/sandstormSlim');
|
||||
import AttachmentBulkMoveStatus from '/models/attachmentBulkMoveStatus';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { migrationProgressManager } from '/client/components/settings/migrationProgress';
|
||||
@@ -26,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',
|
||||
@@ -269,7 +277,10 @@ Template.attachments.onCreated(function () {
|
||||
// pane id; a bare /attachments opens Backup.
|
||||
this.autorun(() => {
|
||||
const paneId = Session.get('attachmentsOpenPane');
|
||||
if (paneId) this.activeSection.set(paneId);
|
||||
// A pane the Sandstorm menu leaves out does not open by URL either
|
||||
// (models/lib/sandstormSlim.js).
|
||||
if (paneId && sandstormAllowsPane(paneId, SANDSTORM_HIDDEN_ATTACHMENTS_PANES,
|
||||
isSandstormSettings(Meteor.settings))) this.activeSection.set(paneId);
|
||||
});
|
||||
this.storageSettingsSubscription = Meteor.subscribe('attachmentStorageSettings');
|
||||
this.attachmentStorageSettings = new ReactiveVar(null);
|
||||
@@ -557,7 +568,7 @@ const BACKUP_MONTH_DAYS = Array.from({ length: 28 }, (_, i) => i + 1);
|
||||
// The Attachments side menu, as data (docs/Features/All-Boards/Left-Menu.md).
|
||||
// emoji:true reproduces the empty span.emoji-icon this page always rendered.
|
||||
function attachmentsMenu(user) {
|
||||
const items = [
|
||||
let items = [
|
||||
// Backup first: it is what an admin comes to this page for most often, and it
|
||||
// is the one action here that has to be reachable in a hurry.
|
||||
{ id: 'backup', icon: 'fa-archive', labelKey: 'backup', emoji: true },
|
||||
@@ -585,6 +596,11 @@ function attachmentsMenu(user) {
|
||||
// deleting raw database files is not something this page needs to offer.
|
||||
// { id: 'sandstorm', icon: 'fa-hdd-o', label: 'Sandstorm', emoji: true },
|
||||
];
|
||||
// Sandstorm: a grain has one database, FerretDB on SQLite, and nothing to
|
||||
// migrate it to (models/lib/sandstormSlim.js,
|
||||
// docs/Platforms/FOSS/Container/Sandstorm/Smaller-wekan-spk.md).
|
||||
items = withoutSandstormPanes(items, SANDSTORM_HIDDEN_ATTACHMENTS_PANES,
|
||||
isSandstormSettings(Meteor.settings));
|
||||
// Multitenancy option D (docs/Design/Multitenancy/Multitenancy.md, D.7/D.8): a
|
||||
// per-tenant Global Admin gets Backup and nothing else - the storages, their
|
||||
// statistics and the database migration are about the instance, not about one
|
||||
@@ -844,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');
|
||||
@@ -858,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
|
||||
|
||||
@@ -20,10 +20,17 @@ template(name="emailToCardSettings")
|
||||
a.flex.js-etc-admin-enabled(role="checkbox" aria-checked="{{#if enabled}}true{{else}}false{{/if}}")
|
||||
.materialCheckBox(class="{{#if enabled}}is-checked{{/if}}")
|
||||
span {{_ 'email-to-card-admin-enabled'}}
|
||||
if sandstormAddress
|
||||
p.notification-delivery-label {{_ 'email-to-card-sandstorm-address'}}
|
||||
p
|
||||
code.js-etc-copy {{sandstormAddress}}
|
||||
//- On Sandstorm mail reaches a grain only through the grain's own
|
||||
//- address, never IMAP or a provider webhook, so those settings are
|
||||
//- not drawn there - not even before the address is known
|
||||
//- (docs/Platforms/FOSS/Container/Sandstorm/Smaller-wekan-spk.md).
|
||||
if isSandstorm
|
||||
if sandstormAddress
|
||||
p.notification-delivery-label {{_ 'email-to-card-sandstorm-address'}}
|
||||
p
|
||||
code.js-etc-copy {{sandstormAddress}}
|
||||
else
|
||||
p.quiet {{_ 'email-to-card-off-sandstorm-no-session'}}
|
||||
else
|
||||
label.notification-delivery-label
|
||||
| {{_ 'email-to-card-domain'}}
|
||||
|
||||
@@ -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
|
||||
@@ -143,9 +147,12 @@ template(name="newTeamRow")
|
||||
| {{_ 'new'}}
|
||||
|
||||
template(name="newUserRow")
|
||||
a.new-user
|
||||
i.fa.fa-plus
|
||||
| {{_ 'new'}}
|
||||
//- On Sandstorm every account is a Sandstorm user who opened the grain; there
|
||||
//- is no password account to create (Smaller-wekan-spk.md).
|
||||
unless isSandstorm
|
||||
a.new-user
|
||||
i.fa.fa-plus
|
||||
| {{_ 'new'}}
|
||||
|
||||
template(name="orgRow")
|
||||
tr
|
||||
@@ -514,10 +521,11 @@ template(name="editUserPopup")
|
||||
input#jsUserTeamsInPut.js-userteams(type="text" value=user.teamsUserBelongs, disabled)
|
||||
input#jsUserTeamIdsInPut.js-userteamIds.hide(type="hidden" value=user.teamIdsUserBelongs)
|
||||
|
||||
hr
|
||||
label
|
||||
| {{_ 'password'}}
|
||||
input.js-profile-password(type="password")
|
||||
unless isSandstorm
|
||||
hr
|
||||
label
|
||||
| {{_ 'password'}}
|
||||
input.js-profile-password(type="password")
|
||||
div.buttonsContainer
|
||||
input.primary.wide(type="submit" value="{{_ 'save'}}")
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@ import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { Session } from 'meteor/session';
|
||||
import { leftMenuData, paneTitle } from '/models/lib/leftMenu';
|
||||
const {
|
||||
SANDSTORM_HIDDEN_PEOPLE_PANES,
|
||||
isSandstormSettings,
|
||||
sandstormAllowsPane,
|
||||
withoutSandstormPanes,
|
||||
} = require('/models/lib/sandstormSlim');
|
||||
// buildFilters and buildActions are imported like the rest of them. The People
|
||||
// pane declares its filter dropdown and its two action buttons to the shared
|
||||
// controls row with these, and a missing import is not a build error: it is a
|
||||
@@ -136,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
|
||||
@@ -411,6 +418,12 @@ Template.people.onCreated(function () {
|
||||
// every left-menu entry has an address now (/people/roles, /people/domains).
|
||||
// docs/Features/Admin-Panel/Admin-Panel-URLs.md
|
||||
this.openPane = (targetID) => {
|
||||
// A pane the Sandstorm menu leaves out cannot be opened by its URL either
|
||||
// (models/lib/sandstormSlim.js).
|
||||
if (!sandstormAllowsPane(targetID, SANDSTORM_HIDDEN_PEOPLE_PANES,
|
||||
isSandstormSettings(Meteor.settings))) {
|
||||
targetID = firstPeoplePaneId(ReactiveCache.getCurrentUser());
|
||||
}
|
||||
// Re-opening the open pane must do nothing. The active row is rendered from
|
||||
// activeMenuId now, so compare ids instead of reading a DOM class.
|
||||
if (targetID && targetID !== this.activeMenuId.get()) {
|
||||
@@ -422,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);
|
||||
@@ -524,11 +538,9 @@ Template.people.onDestroyed(function () {
|
||||
// A function, not a bare array: the E-mail entry depends on whether this is a
|
||||
// Sandstorm deployment, which has to be read at call time from Meteor.settings.
|
||||
function peopleMenu(user) {
|
||||
const isSandstorm =
|
||||
Meteor.settings && Meteor.settings.public && Meteor.settings.public.sandstorm;
|
||||
const items = [
|
||||
// No e-mail settings on Sandstorm; a null entry is dropped, not rendered empty.
|
||||
isSandstorm ? null : { id: 'email-setting', icon: 'fa-envelope', labelKey: 'email', emoji: true },
|
||||
let items = [
|
||||
// No e-mail settings on Sandstorm - see SANDSTORM_HIDDEN_PEOPLE_PANES below.
|
||||
{ id: 'email-setting', icon: 'fa-envelope', labelKey: 'email', emoji: true },
|
||||
// Admin-level default for the 3-tier Notification Settings system (see
|
||||
// models/lib/notificationSettings.js): admin default -> board override ->
|
||||
// member override. Sits right below E-mail, the setting it is closest to.
|
||||
@@ -541,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 },
|
||||
@@ -554,6 +569,12 @@ function peopleMenu(user) {
|
||||
{ id: 'oauth-setting', icon: 'fa-key', labelKey: 'oauth-providers-title' },
|
||||
{ id: 'passwordless-setting', icon: 'fa-unlock-alt', labelKey: 'passwordless-title' },
|
||||
];
|
||||
// Sandstorm: the grain's login is Sandstorm's own, its mail is the grain's
|
||||
// address, and the .spk is built without the login packages these panes
|
||||
// configure (models/lib/sandstormSlim.js,
|
||||
// docs/Platforms/FOSS/Container/Sandstorm/Smaller-wekan-spk.md).
|
||||
items = withoutSandstormPanes(items, SANDSTORM_HIDDEN_PEOPLE_PANES,
|
||||
isSandstormSettings(Meteor.settings));
|
||||
// Multitenancy option D (docs/Design/Multitenancy/Multitenancy.md, D.7): a
|
||||
// PER-TENANT Global Admin gets the same menu, shorter - only the panes that are
|
||||
// about their own Organization. Everything else here is instance-wide. The site
|
||||
@@ -565,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 = {
|
||||
@@ -883,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 {
|
||||
@@ -911,6 +943,9 @@ Template.people.helpers({
|
||||
lockedUsersSetting() {
|
||||
return Template.instance().lockedUsersSetting;
|
||||
},
|
||||
auditLogSetting() {
|
||||
return Template.instance().auditLogSetting;
|
||||
},
|
||||
rolesSetting() {
|
||||
return Template.instance().rolesSetting;
|
||||
},
|
||||
@@ -2260,7 +2295,9 @@ Template.editUserPopup.events({
|
||||
const username = templateInstance.find('.js-profile-username').value.trim();
|
||||
const fullname = templateInstance.find('.js-profile-fullname').value.trim();
|
||||
const initials = templateInstance.find('.js-profile-initials').value.trim();
|
||||
const password = templateInstance.find('.js-profile-password').value;
|
||||
// No password field on Sandstorm (peopleBody.jade): Sandstorm is the login.
|
||||
const passwordInput = templateInstance.find('.js-profile-password');
|
||||
const password = passwordInput ? passwordInput.value : '';
|
||||
const isAdmin = templateInstance.find('.js-profile-isadmin').value.trim();
|
||||
const isActive = templateInstance.find('.js-profile-isactive').value.trim();
|
||||
const email = templateInstance.find('.js-profile-email').value.trim();
|
||||
@@ -2334,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) {
|
||||
@@ -2633,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
|
||||
|
||||
Loaded 100 of 1137 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user