mirror of
https://github.com/wekan/wekan.git
synced 2026-11-06 18:45:11 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97cc91cd67 | ||
|
|
33c2ad8fb0 | ||
|
|
c936e7708d | ||
|
|
5c1f6088f4 | ||
|
|
ceb20a81fc | ||
|
|
2128e6e02f | ||
|
|
49ee37a3a8 | ||
|
|
19350f2611 | ||
|
|
2306a36814 | ||
|
|
a4d2313a68 | ||
|
|
570cc34552 | ||
|
|
7ae45651dc | ||
|
|
db52764592 | ||
|
|
2b99ffe288 | ||
|
|
b4b130858f | ||
|
|
0275363043 | ||
|
|
42d8341d47 | ||
|
|
a9aaa22592 | ||
|
|
4d5fe63fc4 | ||
|
|
1f12b20291 | ||
|
|
bf8ecbbc19 | ||
|
|
e89a3450cd | ||
|
|
7e4483b1a9 | ||
|
|
a4eacfd9bf | ||
|
|
b4cb989391 | ||
|
|
60377a4dc2 | ||
|
|
5c45d55d70 | ||
|
|
e5a84cbcef | ||
|
|
1ef7a57ce4 | ||
|
|
9caf0c8653 | ||
|
|
adb73fd8ee | ||
|
|
ea5724babd | ||
|
|
b895e61237 | ||
|
|
3108e017fc | ||
|
|
081690875d | ||
|
|
68b575637d | ||
|
|
6d5f660c27 | ||
|
|
f54aa93dc1 | ||
|
|
e3458004ec | ||
|
|
6ae4ad3545 | ||
|
|
b0a71ba57b | ||
|
|
f3d013339c | ||
|
|
95ea04b049 | ||
|
|
3b33c7d7ba | ||
|
|
4762bed8c1 | ||
|
|
029ecb6b39 | ||
|
|
49c309b324 | ||
|
|
e09267cc90 | ||
|
|
b46f155fa3 | ||
|
|
0c0288a6c5 | ||
|
|
c61a1b7f41 | ||
|
|
9ce35869a7 | ||
|
|
2017d07e37 | ||
|
|
9ea5c81932 | ||
|
|
45ccdd9c3c | ||
|
|
5a21b074d4 | ||
|
|
e9c5888485 | ||
|
|
09c9d72398 | ||
|
|
4f3d45f3de | ||
|
|
3aaa46fdae | ||
|
|
59f1b705e0 | ||
|
|
22e5df55d7 | ||
|
|
016b4bf3bd | ||
|
|
4d44349ab6 | ||
|
|
d3edc56170 | ||
|
|
5134e3d2a8 | ||
|
|
f686b76023 | ||
|
|
cd2adcad02 | ||
|
|
0766a65fef | ||
|
|
58ad81e301 | ||
|
|
2912f4b080 | ||
|
|
97d1eca09d | ||
|
|
95b75177b8 | ||
|
|
880ec2d707 | ||
|
|
dbc7cccea3 | ||
|
|
d8b3579117 | ||
|
|
c7e53e9f38 | ||
|
|
6012521ccc | ||
|
|
0195a5b3d4 | ||
|
|
b12e761afb | ||
|
|
0c0d666ffa | ||
|
|
fc2ad0a48b | ||
|
|
dad03959d9 | ||
|
|
ac960e7d81 | ||
|
|
3213d7fa1d | ||
|
|
4757676abf | ||
|
|
efaf94242e | ||
|
|
d87baa0244 | ||
|
|
006d24502c | ||
|
|
8f4efbca57 | ||
|
|
dde09dead5 | ||
|
|
3621413d6f | ||
|
|
3c3fc07b3c | ||
|
|
c1e8976b28 | ||
|
|
dcce935310 | ||
|
|
1b05673200 | ||
|
|
dafb63299f | ||
|
|
84b0480aa8 | ||
|
|
f6fc97e77a | ||
|
|
d7105e531d | ||
|
|
f25957dcf3 | ||
|
|
984ea93aa8 | ||
|
|
2441563008 | ||
|
|
f3950b9adc | ||
|
|
5e07b8db6c | ||
|
|
eeeb0dbec9 | ||
|
|
96d56ecc6c | ||
|
|
aadfd9b6b3 | ||
|
|
1c44876beb | ||
|
|
bd86b518ea | ||
|
|
f576a0cca9 | ||
|
|
46c0ad43e8 | ||
|
|
499772e4c2 | ||
|
|
90fe133363 | ||
|
|
b812d36871 | ||
|
|
d05a42a5af | ||
|
|
42507de1d0 | ||
|
|
131b0cc509 | ||
|
|
93ae5ac6ee | ||
|
|
34bb91d1f5 | ||
|
|
d231df56fc | ||
|
|
e422ab691e | ||
|
|
3f712a0445 | ||
|
|
d99dadadee | ||
|
|
a58b7bee2c | ||
|
|
62da564a1e | ||
|
|
2ada2e2d61 | ||
|
|
5de1acf496 | ||
|
|
3008bb5606 | ||
|
|
4f7521ae3c | ||
|
|
1c63049913 | ||
|
|
6b27764452 | ||
|
|
ae49937b44 | ||
|
|
7f27ea1f26 | ||
|
|
144777ae6b | ||
|
|
f4b5025a7b | ||
|
|
cdcf8ec39d | ||
|
|
739f9ed7a0 | ||
|
|
f06d082e34 | ||
|
|
4036b16774 | ||
|
|
0110e84ce5 | ||
|
|
00a50ae139 | ||
|
|
c67a67bc9a | ||
|
|
0b8a7face3 | ||
|
|
8b2114cd0f | ||
|
|
0426c7741a | ||
|
|
c2a28a9233 | ||
|
|
a6b10e3592 | ||
|
|
1ac4d1b1f9 | ||
|
|
222492f313 | ||
|
|
4f95b10f0d | ||
|
|
86a18665f0 | ||
|
|
1ded34dfdd | ||
|
|
db791e1d40 | ||
|
|
e8e22dc25e | ||
|
|
08d20930de | ||
|
|
10f244de77 | ||
|
|
4e28465964 | ||
|
|
2936a682ce | ||
|
|
ab80aaf9a3 | ||
|
|
0fb943b6fb | ||
|
|
6d8b0c15ec | ||
|
|
abc23a7eda | ||
|
|
d9aa210012 | ||
|
|
2e8e1e2ce2 | ||
|
|
c8bcc4817d | ||
|
|
2dd25c733e | ||
|
|
cb6c6533c5 | ||
|
|
292b804474 | ||
|
|
623fccf06e | ||
|
|
cfd7ad2e5f | ||
|
|
762e5a9432 | ||
|
|
f1d2a497bc | ||
|
|
2f9c972d46 | ||
|
|
582a22c291 | ||
|
|
d200234535 | ||
|
|
8548bae527 | ||
|
|
d6b2d55c36 | ||
|
|
f7e3533b13 | ||
|
|
e91c14cd9a | ||
|
|
e7439e1d45 | ||
|
|
4a34b91ecf | ||
|
|
d9984d2069 | ||
|
|
e982282954 | ||
|
|
e68938698c | ||
|
|
55299a5172 | ||
|
|
8bbc6fcda1 | ||
|
|
71463fb445 | ||
|
|
b60a1e5a85 | ||
|
|
114e9ecd5a | ||
|
|
c6739fe452 | ||
|
|
efd45c651e | ||
|
|
fdc2c57b8f | ||
|
|
2c75b95908 | ||
|
|
a3630d96ce | ||
|
|
db2aafe16c | ||
|
|
c4b90d08a0 | ||
|
|
cb5278ddb3 | ||
|
|
bc79de8c57 | ||
|
|
81714cd695 | ||
|
|
d42369e96a | ||
|
|
efff590f74 | ||
|
|
64b5762020 | ||
|
|
c29a59eb95 | ||
|
|
dcaa316a01 | ||
|
|
7721c5f655 | ||
|
|
492895200b | ||
|
|
1b346dc465 | ||
|
|
45c291b406 | ||
|
|
c3af2a08a9 | ||
|
|
21c94ce098 | ||
|
|
2d8a28c2f6 | ||
|
|
c58b2896d1 | ||
|
|
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 |
No files matched your search
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module",
|
||||
"project": [
|
||||
"./tsconfig.lint.json"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"@typescript-eslint/no-misused-promises": "error"
|
||||
}
|
||||
}
|
||||
@@ -405,6 +405,10 @@ jobs:
|
||||
APPRUN
|
||||
sed -i "s/@APPIMAGE_ARCH@/$arch/" AppDir/AppRun
|
||||
chmod +x AppDir/AppRun
|
||||
# The AppImage runs as whoever starts it, not as this runner, so
|
||||
# everything in it must be readable by all. v12.69's FerretDB and
|
||||
# mongo-tools were 0711 and appimagetool refused to pack them.
|
||||
chmod -R a+rX AppDir
|
||||
|
||||
- name: Pack the AppImage
|
||||
if: ${{ env.BUILD_THIS == 'true' }}
|
||||
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
app/.tools/meteor-bun/dev_bundle
|
||||
app/.tools/meteor-bun/.babel-cache
|
||||
app/.tools/meteor-bun/packages/*/.npm
|
||||
key: bun-meteor-${{ runner.os }}-${{ hashFiles('app/.meteor/release', 'app/.meteor/versions', 'build-tools/releases/bun/meteor-3.6-rc.0.patch') }}
|
||||
key: bun-meteor-${{ runner.os }}-${{ hashFiles('app/.meteor/release', 'app/.meteor/versions', 'build-tools/releases/bun/meteor-3.6-rc.1.patch') }}
|
||||
- name: Build the release's ESM application once
|
||||
working-directory: app
|
||||
env:
|
||||
@@ -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 }}
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
# 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
|
||||
@@ -66,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
|
||||
|
||||
@@ -97,6 +97,12 @@ jobs:
|
||||
|
||||
plan:
|
||||
needs: recover
|
||||
# The planner runs whatever recover's rerun ended with. A rerun that still
|
||||
# fails - Launchpad answering 503, a fault that is in the tagged code and
|
||||
# so in every rerun of it (v12.68's OpenBSD start) - used to skip the
|
||||
# planner and every build after it, so not even the assets that COULD be
|
||||
# filled were. recover stays red and names the jobs; the rest goes on.
|
||||
if: ${{ !cancelled() }}
|
||||
timeout-minutes: 15
|
||||
name: what the release is missing
|
||||
runs-on: ubuntu-latest
|
||||
@@ -211,7 +217,7 @@ jobs:
|
||||
contents: write
|
||||
name: ${{ matrix.arch }}
|
||||
needs: plan
|
||||
if: ${{ needs.plan.outputs.extra != '[]' }}
|
||||
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.plan.outputs.extra != '[]' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 300
|
||||
strategy:
|
||||
@@ -331,7 +337,7 @@ jobs:
|
||||
bun:
|
||||
name: Missing Bun runtime bundles
|
||||
needs: plan
|
||||
if: ${{ needs.plan.outputs.bun != '[]' }}
|
||||
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.plan.outputs.bun != '[]' }}
|
||||
uses: ./.github/workflows/bun.yml
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -345,7 +351,7 @@ jobs:
|
||||
appimage:
|
||||
name: AppImages
|
||||
needs: plan
|
||||
if: ${{ needs.plan.outputs.appimage != '[]' }}
|
||||
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.plan.outputs.appimage != '[]' }}
|
||||
uses: ./.github/workflows/AppImage.yml
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -357,7 +363,7 @@ jobs:
|
||||
mac:
|
||||
name: Mac apps
|
||||
needs: plan
|
||||
if: ${{ needs.plan.outputs.mac != '[]' }}
|
||||
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.plan.outputs.mac != '[]' }}
|
||||
uses: ./.github/workflows/mac.yml
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -369,7 +375,7 @@ jobs:
|
||||
flatpak:
|
||||
name: Flatpaks
|
||||
needs: plan
|
||||
if: ${{ needs.plan.outputs.flatpak != '[]' }}
|
||||
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.plan.outputs.flatpak != '[]' }}
|
||||
uses: ./.github/workflows/Flatpak.yml
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -381,7 +387,7 @@ jobs:
|
||||
windows-exe:
|
||||
name: Windows single EXE
|
||||
needs: plan
|
||||
if: ${{ needs.plan.outputs.windows != '[]' }}
|
||||
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.plan.outputs.windows != '[]' }}
|
||||
uses: ./.github/workflows/windows.yml
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -425,7 +431,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
name: Helm chart
|
||||
needs: plan
|
||||
if: ${{ !cancelled() && needs.plan.outputs.version != '' }}
|
||||
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.plan.outputs.version != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout wekan (for releases/release-charts.sh)
|
||||
|
||||
@@ -38,7 +38,7 @@ run-name: Release v${{ inputs.new_version }}
|
||||
# only the ferretdb-<arch> binary it needs from the newest wekan/FerretDB
|
||||
# release (FerretDB is built + released separately by its own build.sh —
|
||||
# not compiled here) and embeds it
|
||||
# - release: writes the newest CHANGELOG.md notes to the GitHub Release and
|
||||
# - release: writes the newest CHANGELOG-big.md notes to the GitHub Release and
|
||||
# checks the core wekan-<version>-{amd64,arm64}.zip bundles are on
|
||||
# it. It uploads nothing: EVERY job that builds a release file
|
||||
# attaches it itself, as its last step, the moment it is built and
|
||||
@@ -281,8 +281,8 @@ jobs:
|
||||
# No `changelog:` output. The notes are 170 KB and more, which is past the
|
||||
# 128 KiB MAX_ARG_STRLEN a single environment variable may hold, so any job
|
||||
# consuming them through `env:` fails to start its shell. Jobs read
|
||||
# CHANGELOG.md from their own checkout instead - see
|
||||
# "Check the release notes exist in CHANGELOG.md" below.
|
||||
# CHANGELOG-big.md from their own checkout instead - see
|
||||
# "Check the release notes exist in CHANGELOG-big.md" below.
|
||||
steps:
|
||||
# Shallow: the only thing history was fetched for was finding out whether
|
||||
# the release tag already exists, which the tag step now asks GitHub.
|
||||
@@ -310,8 +310,8 @@ jobs:
|
||||
# string at MAX_ARG_STRLEN - 128 KiB - so execve refused to start bash at
|
||||
# all ("Argument list too long") before a line of the step ran. Each job
|
||||
# that needs the notes now extracts them from its own checkout of
|
||||
# CHANGELOG.md with the same script, straight into a file.
|
||||
- name: Check the release notes exist in CHANGELOG.md
|
||||
# CHANGELOG-big.md with the same script, straight into a file.
|
||||
- name: Check the release notes exist in CHANGELOG-big.md
|
||||
env:
|
||||
STEP_VERSION_VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
@@ -1170,7 +1170,7 @@ jobs:
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
# ref: main, the same ref `prepare` read CHANGELOG.md from, so the notes
|
||||
# ref: main, the same ref `prepare` read CHANGELOG-big.md from, so the notes
|
||||
# composed below are the ones prepare validated. (bump pushes to main and
|
||||
# prepare tags it; nothing else pushes during a release.)
|
||||
#
|
||||
@@ -1203,7 +1203,7 @@ jobs:
|
||||
# published nothing, "Incorrect: command not found"); and `env:` put a
|
||||
# 172 KB string in envp, past the 128 KiB MAX_ARG_STRLEN, so execve
|
||||
# refused to start bash at all (v10.92, "Argument list too long"). Read
|
||||
# from CHANGELOG.md, appended with cat, the text is data all the way.
|
||||
# from CHANGELOG-big.md, appended with cat, the text is data all the way.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash releases/release-notes.sh "$VERSION" > release-notes.md
|
||||
@@ -2624,7 +2624,12 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: [prepare, build-amd64, release]
|
||||
runs-on: macos-14 # macos-14 runners are Apple Silicon (arm64)
|
||||
# Apple Silicon (arm64). It was `macos-14`, and GitHub RETIRED that image:
|
||||
# the v12.98 run's build-mac-arm64 was cancelled with "The configuration
|
||||
# 'macos-14-arm64-us-default' is not supported", so v12.98 shipped no
|
||||
# mac-arm64 bundle. macos-15 is Apple Silicon too (the same label mac.yml
|
||||
# already builds on).
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
@@ -2900,7 +2905,8 @@ jobs:
|
||||
contents: write
|
||||
needs: [prepare, build-amd64, release]
|
||||
# Intel (x86_64) macOS. It was `macos-13`, and GitHub RETIRED that image -
|
||||
# actions/runner-images publishes macos-14, macos-15 and macos-26 only, and
|
||||
# actions/runner-images publishes macos-15 and macos-26 only (macos-14 was
|
||||
# retired later too), and
|
||||
# macos-13 is gone from the hosted-runner table. A retired label does not
|
||||
# fail: the job QUEUES FOREVER waiting for a runner that will never come, so
|
||||
# every release since then had a build-mac-x64 sitting there for hours until
|
||||
@@ -2908,7 +2914,7 @@ jobs:
|
||||
# what skipped the charts jobs (they had not started yet).
|
||||
# No release has ever carried a wekan-<version>-mac-x64.zip because of it.
|
||||
# Intel macOS is still offered, under new labels: macos-15-intel and
|
||||
# macos-26-intel (macos-14/15/26 without the suffix are Apple Silicon). This
|
||||
# macos-26-intel (macos-15/26 without the suffix are Apple Silicon). This
|
||||
# is the same label .tools/TSC already builds its Intel Mac on.
|
||||
runs-on: macos-15-intel
|
||||
timeout-minutes: 60
|
||||
@@ -3427,7 +3433,16 @@ jobs:
|
||||
# architecture, and died on the 404 - four of the six failures in the
|
||||
# v10.57 run. A file has no quoting layer to get wrong, and `bash -n`
|
||||
# can check it.
|
||||
# install-node-for-arch.sh refuses to run without TMPDIR: the
|
||||
# container's own /tmp is a small part of its writable layer, so its
|
||||
# /tmp is this checkout's .tools/tmp/repack-<arch>-tmp, as in
|
||||
# releases/repack-bundle-for-arch.sh. Without it every emulated arch
|
||||
# failed at "TMPDIR must be the mounted .tools/tmp" (v12.66).
|
||||
CONTAINER_TMP="$(pwd)/.tools/tmp/repack-${MATRIX_ARCH}-tmp"
|
||||
mkdir -p "$CONTAINER_TMP" && chmod 1777 "$CONTAINER_TMP"
|
||||
docker run --rm --platform "${MATRIX_PLATFORM}" \
|
||||
-e TMPDIR=/tmp \
|
||||
-v "$CONTAINER_TMP:/tmp" \
|
||||
-e NODE_FULL="$NODE_FULL" \
|
||||
-e NODE_FROM="$NODE_FROM" \
|
||||
-e NODE_URL="$NODE_URL" \
|
||||
@@ -3663,7 +3678,7 @@ jobs:
|
||||
# Ubuntu 24.04's AppArmor defaults kernel.apparmor_restrict_unprivileged_userns=1,
|
||||
# which blocks the unprivileged user namespaces that the Sandstorm install and
|
||||
# the spk supervisor rely on (the "fragile part" noted above). Relax it so
|
||||
# `install.sandstorm.io` and meteor-spk can create sandboxes on the runner.
|
||||
# `install.sandstorm.org` and meteor-spk can create sandboxes on the runner.
|
||||
# `|| true`: on kernels/runners without these knobs, setting them is a no-op.
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
|
||||
@@ -3687,11 +3702,11 @@ jobs:
|
||||
run: |
|
||||
# Downloaded, then run: a `curl | sh` cannot be retried, because by the
|
||||
# time the download fails sh is already half way through the script.
|
||||
bash "$GITHUB_WORKSPACE/releases/fetch.sh" -o /tmp/install-sandstorm.sh https://install.sandstorm.io
|
||||
bash "$GITHUB_WORKSPACE/releases/fetch.sh" -o /tmp/install-sandstorm.sh https://install.sandstorm.org
|
||||
sudo bash /tmp/install-sandstorm.sh -d
|
||||
|
||||
- name: Assemble modern meteor-spk.deps (Node 26 + FerretDB + Mongo 3.x CLIs)
|
||||
# Base = upstream meteor-spk 0.6.0 from dl.sandstorm.io (has mongod 3.0 +
|
||||
# Base = upstream meteor-spk 0.6.0 from dl.sandstorm.org (has mongod 3.0 +
|
||||
# niscud). build-deps.sh swaps in Node 26, adds FerretDB + the Mongo 3.x
|
||||
# CLIs + the migration launcher/importer, keeps niscud, and swaps in the
|
||||
# modern sandstorm-http-bridge from the Sandstorm install above. No dependency
|
||||
@@ -3705,7 +3720,7 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
bash sandstorm-src/build-deps.sh
|
||||
echo "$HOME/projects/meteor-spk/meteor-spk-0.6.0" >> "$GITHUB_PATH"
|
||||
echo "$GITHUB_WORKSPACE/.tools/projects/meteor-spk/meteor-spk-0.6.0" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Restore the Sandstorm signing keyring
|
||||
env:
|
||||
@@ -5052,13 +5067,38 @@ 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
|
||||
}
|
||||
# snapcraft's execution logs. v12.96 s390x: every attempt printed only
|
||||
# "Could not find repository at path ...", because snapcraft reports
|
||||
# the LAST exception; the cause before it - "HTTP Error 503: Service
|
||||
# Unavailable" / "Error: Timeout" creating that repository - was in
|
||||
# the "Full execution log" alone. Unread, three Launchpad outages
|
||||
# counted as three WeKan failures and the arch was not published.
|
||||
snapcraft_log_dir="$HOME/.local/state/snapcraft/log"
|
||||
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
|
||||
# The same v12.96 s390x run's last attempt: the repository the
|
||||
# timed-out requests had half-created refused our push with
|
||||
# "You do not have permission to create refs/heads/main" - the
|
||||
# same credentials pushed every other architecture in that run.
|
||||
grep -qsE 'remote: You do not have permission to create refs/' "$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
|
||||
for log in "$snapcraft_log_dir"/*.log; do
|
||||
this_attempts_log "$log" || continue
|
||||
grep -qsE 'HTTP Error 5[0-9][0-9]|Error: Timeout|Service Unavailable' "$log" && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
# Set once an attempt ends with Launchpad reporting the build as
|
||||
@@ -5081,6 +5121,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 "$snapcraft_log_dir"/*.log 2>/dev/null | tr '\n' ' ')"
|
||||
if [ "$fresh_next" = 1 ]; then
|
||||
echo "The previous attempt's Launchpad build finished without a snap; submitting a new build instead of recovering it."
|
||||
run_remote_build && rc=0 || rc=$?
|
||||
@@ -5137,7 +5178,7 @@ jobs:
|
||||
# and threw it away.
|
||||
new_logs=0
|
||||
for log in snapcraft-wekan-*.txt; do
|
||||
[ -e "$log" ] && [ "$log" -nt "$attempt_marker" ] || continue
|
||||
this_attempts_log "$log" || continue
|
||||
new_logs=1
|
||||
echo "::group::Launchpad build log: $log (what failed, then the last 150 lines)"
|
||||
# The tail alone is not where the cause is. snapcraft's own error is
|
||||
@@ -5675,7 +5716,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 }}"
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
- 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:
|
||||
@@ -140,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"
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
bash sandstorm-src/build-deps.sh
|
||||
echo "$HOME/projects/meteor-spk/meteor-spk-0.6.0" >> "$GITHUB_PATH"
|
||||
echo "$GITHUB_WORKSPACE/.tools/projects/meteor-spk/meteor-spk-0.6.0" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Restore the Sandstorm signing keyring
|
||||
env:
|
||||
|
||||
+27
-27
@@ -6,12 +6,12 @@
|
||||
meteor-base@1.5.2
|
||||
|
||||
# Build system
|
||||
ecmascript@0.19.2-rc360.0
|
||||
standard-minifier-js@3.3.0-rc360.0
|
||||
rspack@1.4.0-rc360.0
|
||||
ecmascript@0.19.2-rc360.1
|
||||
standard-minifier-js@3.3.0-rc360.1
|
||||
rspack@1.4.0-rc360.1
|
||||
# Declared explicitly because server/modernBrowsers.js imports it, to say that
|
||||
# Yandex Browser is modern and must not be served the legacy bundle (#6557).
|
||||
modern-browsers@0.2.4-rc360.0
|
||||
modern-browsers@0.2.4-rc360.1
|
||||
|
||||
blaze@3.0.2
|
||||
|
||||
@@ -19,11 +19,11 @@ blaze@3.0.2
|
||||
aldeed:collection2
|
||||
aldeed:schema-index@4.0.0-beta.5
|
||||
reywood:publish-composite
|
||||
mongo@2.5.2-rc360.0
|
||||
mongo@2.5.2-rc360.1
|
||||
|
||||
# Account system
|
||||
accounts-password@3.3.2-rc360.0
|
||||
accounts-2fa@3.1.1-rc360.0
|
||||
accounts-password@3.3.2-rc360.1
|
||||
accounts-2fa@3.1.1-rc360.1
|
||||
accounts-express@1.0.0
|
||||
communitypackages:core
|
||||
communitypackages:unstyled
|
||||
@@ -39,19 +39,19 @@ wekan-oidc
|
||||
wekan-accounts-oidc
|
||||
|
||||
# Utilities
|
||||
check@1.5.1-rc360.0
|
||||
random@1.2.3-rc360.0
|
||||
reactive-dict@1.3.3-rc360.0
|
||||
session@1.2.4-rc360.0
|
||||
tracker@1.3.5-rc360.0
|
||||
check@1.5.1-rc360.1
|
||||
random@1.2.3-rc360.1
|
||||
reactive-dict@1.3.3-rc360.1
|
||||
session@1.2.4-rc360.1
|
||||
tracker@1.3.5-rc360.1
|
||||
audit-argument-checks@1.0.8
|
||||
|
||||
# UI components
|
||||
ostrio:i18n
|
||||
reactive-var@1.0.13
|
||||
shell-server@0.7.1-rc360.0
|
||||
email@3.2.1-rc360.0
|
||||
dynamic-import@0.7.5-rc360.0
|
||||
shell-server@0.7.2-rc360.1
|
||||
email@3.2.1-rc360.1
|
||||
dynamic-import@0.8.1-rc360.1
|
||||
wekan-markdown
|
||||
quave:synced-cron
|
||||
ostrio:cookies
|
||||
@@ -59,21 +59,21 @@ ostrio:files
|
||||
# lmieulet:meteor-coverage
|
||||
meteortesting:mocha@3.0.0
|
||||
matb33:collection-hooks
|
||||
service-configuration@1.3.6-rc360.0
|
||||
service-configuration@1.3.6-rc360.1
|
||||
minifier-css@2.0.2
|
||||
ejson@1.2.1-rc360.0
|
||||
logging@1.3.7-rc360.0
|
||||
ejson@1.4.1-rc360.1
|
||||
logging@1.3.7-rc360.1
|
||||
wekan-fullcalendar
|
||||
wekan-fontawesome
|
||||
accounts-google@1.4.2-rc360.0
|
||||
accounts-github@1.5.2-rc360.0
|
||||
accounts-facebook@1.3.5-rc360.0
|
||||
accounts-twitter@1.5.3-rc360.0
|
||||
accounts-meteor-developer@1.5.2-rc360.0
|
||||
accounts-weibo@1.4.2-rc360.0
|
||||
accounts-meetup@1.5.2-rc360.0
|
||||
accounts-passwordless@3.1.2-rc360.0
|
||||
instrumentation@0.0.1-rc360.0
|
||||
accounts-google@1.4.2-rc360.1
|
||||
accounts-github@1.5.2-rc360.1
|
||||
accounts-facebook@1.3.5-rc360.1
|
||||
accounts-twitter@1.5.3-rc360.1
|
||||
accounts-meteor-developer@1.5.2-rc360.1
|
||||
accounts-weibo@1.4.2-rc360.1
|
||||
accounts-meetup@1.5.2-rc360.1
|
||||
accounts-passwordless@3.1.2-rc360.1
|
||||
instrumentation@0.0.1-rc360.1
|
||||
|
||||
# Prevent Meteor build/run tools from sending application package statistics.
|
||||
package-stats-opt-out@1.0.8
|
||||
+1
-1
@@ -1 +1 @@
|
||||
METEOR@3.6-rc.0
|
||||
METEOR@3.6-rc.1
|
||||
+74
-74
@@ -1,130 +1,130 @@
|
||||
accounts-2fa@3.1.1-rc360.0
|
||||
accounts-base@3.4.0-rc360.0
|
||||
accounts-2fa@3.1.1-rc360.1
|
||||
accounts-base@3.4.0-rc360.1
|
||||
accounts-express@1.0.0
|
||||
accounts-facebook@1.3.5-rc360.0
|
||||
accounts-github@1.5.2-rc360.0
|
||||
accounts-google@1.4.2-rc360.0
|
||||
accounts-meetup@1.5.2-rc360.0
|
||||
accounts-meteor-developer@1.5.2-rc360.0
|
||||
accounts-oauth@1.4.8-rc360.0
|
||||
accounts-password@3.3.2-rc360.0
|
||||
accounts-passwordless@3.1.2-rc360.0
|
||||
accounts-twitter@1.5.3-rc360.0
|
||||
accounts-weibo@1.4.2-rc360.0
|
||||
accounts-facebook@1.3.5-rc360.1
|
||||
accounts-github@1.5.2-rc360.1
|
||||
accounts-google@1.4.2-rc360.1
|
||||
accounts-meetup@1.5.2-rc360.1
|
||||
accounts-meteor-developer@1.5.2-rc360.1
|
||||
accounts-oauth@1.4.8-rc360.1
|
||||
accounts-password@3.3.2-rc360.1
|
||||
accounts-passwordless@3.1.2-rc360.1
|
||||
accounts-twitter@1.5.3-rc360.1
|
||||
accounts-weibo@1.4.2-rc360.1
|
||||
aldeed:collection2@4.2.2
|
||||
aldeed:schema-index@4.0.0
|
||||
aldeed:simple-schema@1.13.1
|
||||
allow-deny@2.1.1-rc360.0_1
|
||||
allow-deny@2.1.2-rc360.1_1
|
||||
audit-argument-checks@1.0.8
|
||||
autoupdate@2.0.2-rc360.0
|
||||
babel-compiler@7.15.2-rc360.0
|
||||
autoupdate@2.0.2-rc360.1
|
||||
babel-compiler@7.15.2-rc360.1
|
||||
babel-runtime@1.5.2
|
||||
base64@1.0.15-rc360.0
|
||||
binary-heap@1.0.14-rc360.0
|
||||
base64@1.0.15-rc360.1
|
||||
binary-heap@1.0.14-rc360.1
|
||||
blaze@3.0.3
|
||||
blaze-tools@2.0.1
|
||||
boilerplate-generator@2.2.0-rc360.0
|
||||
boilerplate-generator@2.2.0-rc360.1
|
||||
caching-compiler@2.0.1
|
||||
caching-html-compiler@2.0.1
|
||||
callback-hook@1.8.1-rc360.0
|
||||
check@1.5.1-rc360.0
|
||||
callback-hook@1.8.1-rc360.1
|
||||
check@1.5.1-rc360.1
|
||||
communitypackages:core@2.0.3
|
||||
communitypackages:flow-routing@2.1.0
|
||||
communitypackages:unstyled@2.0.1
|
||||
core-runtime@1.0.0
|
||||
ddp@1.4.3-rc360.0
|
||||
ddp-client@3.4.2-rc360.0
|
||||
ddp-common@1.4.6-rc360.0
|
||||
ddp-rate-limiter@1.3.1-rc360.0
|
||||
ddp-server@3.4.1-rc360.0_1
|
||||
diff-sequence@1.1.4-rc360.0
|
||||
dynamic-import@0.7.5-rc360.0_1
|
||||
ecmascript@0.19.2-rc360.0
|
||||
ddp@1.4.3-rc360.1
|
||||
ddp-client@3.4.2-rc360.1
|
||||
ddp-common@1.4.6-rc360.1
|
||||
ddp-rate-limiter@1.3.1-rc360.1
|
||||
ddp-server@3.6.0-rc360.1_1
|
||||
diff-sequence@1.2.1-rc360.1
|
||||
dynamic-import@0.8.1-rc360.1_1
|
||||
ecmascript@0.19.2-rc360.1
|
||||
ecmascript-runtime@0.8.3
|
||||
ecmascript-runtime-client@0.13.0
|
||||
ecmascript-runtime-server@0.11.1
|
||||
ejson@1.2.1-rc360.0
|
||||
email@3.2.1-rc360.0
|
||||
ejson@1.4.1-rc360.1
|
||||
email@3.2.1-rc360.1
|
||||
es5-shim@4.8.1
|
||||
facebook-oauth@1.11.7-rc360.0_1
|
||||
facts-base@1.0.3-rc360.0
|
||||
fetch@0.2.1-rc360.0
|
||||
geojson-utils@1.0.13-rc360.0
|
||||
github-oauth@1.4.3-rc360.0
|
||||
google-oauth@1.4.6-rc360.0_1
|
||||
facebook-oauth@1.12.1-rc360.1_1
|
||||
facts-base@1.0.3-rc360.1
|
||||
fetch@0.2.1-rc360.1
|
||||
geojson-utils@1.0.13-rc360.1
|
||||
github-oauth@1.4.3-rc360.1
|
||||
google-oauth@1.5.1-rc360.1_1
|
||||
hot-code-push@1.0.5
|
||||
html-tools@2.0.1
|
||||
htmljs@2.0.2
|
||||
id-map@1.2.1-rc360.0
|
||||
instrumentation@0.0.1-rc360.0
|
||||
inter-process-messaging@0.1.4-rc360.0
|
||||
id-map@1.2.1-rc360.1
|
||||
instrumentation@0.0.1-rc360.1
|
||||
inter-process-messaging@0.1.4-rc360.1
|
||||
jquery@3.0.2
|
||||
lai:collection-extensions@1.0.0
|
||||
localstorage@1.2.1
|
||||
logging@1.3.7-rc360.0
|
||||
logging@1.3.7-rc360.1
|
||||
matb33:collection-hooks@2.1.0
|
||||
meetup-oauth@1.1.4-rc360.0
|
||||
meteor@2.3.2-rc360.0
|
||||
meetup-oauth@1.1.4-rc360.1
|
||||
meteor@2.3.2-rc360.1
|
||||
meteor-base@1.5.2
|
||||
meteor-developer-oauth@1.3.4-rc360.0
|
||||
meteor-developer-oauth@1.3.4-rc360.1
|
||||
meteortesting:browser-tests@1.8.0
|
||||
meteortesting:mocha@3.4.0
|
||||
meteortesting:mocha-core@8.2.0
|
||||
minifier-css@2.0.2
|
||||
minifier-js@3.3.2-rc360.0
|
||||
minimongo@2.2.1-rc360.0
|
||||
modern-browsers@0.2.4-rc360.0
|
||||
minifier-js@3.3.2-rc360.1
|
||||
minimongo@2.2.1-rc360.1
|
||||
modern-browsers@0.2.4-rc360.1
|
||||
modules@0.20.3
|
||||
modules-runtime@0.13.2
|
||||
mongo@2.5.2-rc360.0
|
||||
mongo@2.5.2-rc360.1
|
||||
mongo-decimal@0.2.0
|
||||
mongo-dev-server@1.1.1
|
||||
mongo-id@1.0.10-rc360.0
|
||||
npm-mongo@6.16.3
|
||||
oauth@3.0.5-rc360.0
|
||||
oauth1@1.5.4-rc360.0
|
||||
mongo-id@1.0.10-rc360.1
|
||||
npm-mongo@6.18.1-rc360.1
|
||||
oauth@3.0.5-rc360.1
|
||||
oauth1@1.5.4-rc360.1
|
||||
oauth2@1.3.3
|
||||
observe-sequence@2.0.1
|
||||
ordered-dict@1.2.1-rc360.0
|
||||
ordered-dict@1.2.1-rc360.1
|
||||
ostrio:cookies@2.9.2
|
||||
ostrio:cstorage@4.0.1
|
||||
ostrio:files@3.0.1
|
||||
ostrio:flow-router-extra@3.15.3
|
||||
ostrio:i18n@3.2.1
|
||||
package-stats-opt-out@1.0.8
|
||||
promise@1.0.1-rc360.0
|
||||
promise@1.0.1-rc360.1
|
||||
quave:synced-cron@2.4.0
|
||||
raix:eventemitter@1.0.0
|
||||
random@1.2.3-rc360.0
|
||||
rate-limit@1.2.1-rc360.0
|
||||
random@1.2.3-rc360.1
|
||||
rate-limit@1.2.1-rc360.1
|
||||
react-fast-refresh@0.3.0
|
||||
reactive-dict@1.3.3-rc360.0
|
||||
reactive-dict@1.3.3-rc360.1
|
||||
reactive-var@1.0.13
|
||||
reload@1.3.3-rc360.0
|
||||
retry@1.1.2-rc360.0
|
||||
reload@1.3.3-rc360.1
|
||||
retry@1.1.2-rc360.1
|
||||
reywood:publish-composite@1.9.0
|
||||
routepolicy@1.1.3-rc360.0
|
||||
rspack@1.4.0-rc360.0
|
||||
service-configuration@1.3.6-rc360.0
|
||||
session@1.2.4-rc360.0
|
||||
sha@1.0.11-rc360.0
|
||||
shell-server@0.7.1-rc360.0_1
|
||||
socket-stream-client@0.7.2-rc360.0
|
||||
routepolicy@1.1.3-rc360.1
|
||||
rspack@1.4.0-rc360.1
|
||||
service-configuration@1.3.6-rc360.1
|
||||
session@1.2.4-rc360.1
|
||||
sha@1.0.11-rc360.1
|
||||
shell-server@0.7.2-rc360.1_1
|
||||
socket-stream-client@0.7.2-rc360.1
|
||||
spacebars@2.0.1
|
||||
spacebars-compiler@2.0.1
|
||||
standard-minifier-js@3.3.0-rc360.0
|
||||
standard-minifier-js@3.3.0-rc360.1
|
||||
templating@1.4.5
|
||||
templating-compiler@2.0.1
|
||||
templating-runtime@2.0.2
|
||||
templating-tools@2.0.1
|
||||
tools-core@1.4.0-rc360.0
|
||||
tracker@1.3.5-rc360.0
|
||||
twitter-oauth@1.3.5-rc360.0
|
||||
typescript@5.11.2-rc360.0
|
||||
url@1.3.7-rc360.0
|
||||
webapp@2.3.1-rc360.0
|
||||
webapp-hashing@1.1.3-rc360.0
|
||||
weibo-oauth@1.3.4-rc360.0
|
||||
tools-core@1.4.0-rc360.1
|
||||
tracker@1.3.5-rc360.1
|
||||
twitter-oauth@1.3.5-rc360.1
|
||||
typescript@5.11.2-rc360.1
|
||||
url@1.3.7-rc360.1
|
||||
webapp@2.3.1-rc360.1
|
||||
webapp-hashing@1.1.3-rc360.1
|
||||
weibo-oauth@1.3.4-rc360.1
|
||||
wekan-accounts-cas@0.2.0
|
||||
wekan-accounts-lockout@1.1.0
|
||||
wekan-accounts-oidc@1.0.10
|
||||
|
||||
@@ -26,7 +26,7 @@ Work out which you are from the git identity (the next section says how), then:
|
||||
| Who | Where the work goes |
|
||||
| --- | --- |
|
||||
| **The maintainer** — `Lauri Ojansivu <x@xet7.org>` | **Directly on `main`.** Never a feature branch, never a topic branch, never a pull request. |
|
||||
| **A contributor** — anybody else | **A branch in your own fork, then a pull request** for the maintainer to review. Never a commit on `main`. |
|
||||
| **A contributor** — anybody else | **A branch in your own fork, then one pull request per feature or fix** for the maintainer to review. Never a commit on `main`, and no changelog changes. |
|
||||
|
||||
Nothing else is a third option. If a tool, a task runner or an agent harness offers
|
||||
some other arrangement — a topic branch for the maintainer, a direct push for a
|
||||
@@ -35,13 +35,15 @@ 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.
|
||||
|
||||
The contributor's half is review. Nobody but the maintainer commits to `main` in
|
||||
wekan/wekan, so a change from anyone else arrives as a pull request — which is also
|
||||
the only place it can be discussed before it lands.
|
||||
the only place it can be discussed before it lands. That is why each pull request
|
||||
holds one feature or one fix, and leaves the changelogs to the maintainer: see
|
||||
"Contributor mode" below.
|
||||
|
||||
Check which branch you are on before committing:
|
||||
|
||||
@@ -143,12 +145,29 @@ git config user.name && git config user.email
|
||||
already exists, on <https://wekan.fi/sponsors>, and belongs nowhere else. The **publishing / release
|
||||
steps** below are available to a human maintainer only: an AI stops after
|
||||
committing locally and never runs a step that pushes or publishes.
|
||||
|
||||
**Maintainer mode is the only mode that commits directly and the only mode that
|
||||
updates changelogs**, in WeKan and in the maintainer's repositories under
|
||||
`.tools/` alike. It applies to the changes the WeKan maintainer makes, and to the
|
||||
changes of an agent working as the maintainer according to this file.
|
||||
- **Contributor mode** — the identity is somebody ELSE, in a fork or a clone of your
|
||||
own — a person, or an AI that files pull requests on its own account. Then: do
|
||||
**not** commit to `main` and do **not** run any release/publishing step. Make the
|
||||
changes on a branch and open a **pull request** for the maintainer to review. The
|
||||
"commit as Lauri Ojansivu", "commit directly to `main`", and all release
|
||||
instructions below are **maintainer-only and do not apply to you**.
|
||||
**not** commit directly to the repository or to `main`, and do **not** run any
|
||||
release/publishing step. Make the changes on a branch and open a **pull request**
|
||||
for the maintainer to review. The "commit as Lauri Ojansivu", "commit directly to
|
||||
`main`", changelog and release instructions below are **maintainer-only and do
|
||||
not apply to you**.
|
||||
|
||||
Every pull request, to wekan/wekan or to a maintainer's repository under `.tools/`,
|
||||
follows two more rules:
|
||||
- **One feature or one fix per pull request.** Changes share a pull request only
|
||||
when they are related to that one feature or fix. Anything unrelated, however
|
||||
small, goes in a pull request of its own, so each can be reviewed, merged or
|
||||
declined without holding up the others.
|
||||
- **No changelog changes.** Do not edit `CHANGELOG-big.md`, `CHANGELOG.md`, or the
|
||||
`CHANGELOG.md` or `CHANGELOG` of a repository under `.tools/` — not an Upcoming
|
||||
entry, not TODO Later. Describe the change in the pull request instead. The
|
||||
maintainer writes the entry after merging, when there is a commit for it to link.
|
||||
|
||||
Who that pull request credits follows the attribution rule above, and the two
|
||||
cases differ:
|
||||
@@ -171,8 +190,8 @@ The one repository under `.tools/` that is somebody else's is `sandstorm-io/sand
|
||||
cloned for reference only.
|
||||
|
||||
Everything below marked as maintainer-specific (committing directly, the exact commit
|
||||
author, and the entire "Making a release" / publishing flow) applies only in maintainer
|
||||
mode. The rest (translation policy, CHANGELOG structure, tests, validating from code) is
|
||||
author, updating changelogs, and the entire "Making a release" / publishing flow) applies
|
||||
only in maintainer mode. The rest (translation policy, tests, validating from code) is
|
||||
good practice for everyone.
|
||||
|
||||
## Process: "Fix open issues"
|
||||
@@ -206,6 +225,9 @@ When asked to fix an open issue (one issue at a time):
|
||||
|
||||
Fixes #1234,
|
||||
```
|
||||
|
||||
A contributor instead commits on a branch for one pull request that fixes this
|
||||
one issue, with no changelog changes (see "Contributor mode" above).
|
||||
6. **If the bug is already fixed**, still add a commit that CLOSES the issue (a commit
|
||||
whose message ends `Fixes #1234,`), noting where/when it was fixed.
|
||||
|
||||
@@ -340,7 +362,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
|
||||
@@ -407,6 +429,10 @@ directly after the merge.
|
||||
|
||||
### CHANGELOG
|
||||
|
||||
- **[maintainer only] Changelogs are updated only in maintainer mode.** This whole
|
||||
section is for the maintainer and for an agent working as the maintainer, in WeKan
|
||||
and in the repositories under `.tools/`. A pull request contributor changes no
|
||||
changelog in any of them (see "Contributor mode" above).
|
||||
- **Every repository writes its changelog in the format that repository's own file
|
||||
already uses.** Open its changelog, read the entries above the place you are
|
||||
adding one, and match them — never import another project's shape into it. The
|
||||
@@ -414,7 +440,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 +452,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 +510,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 +521,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 +536,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 +636,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 +649,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 +708,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 +789,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
|
||||
@@ -771,7 +843,7 @@ have cost a released section its accuracy:
|
||||
### Security issues
|
||||
|
||||
- Fix the vulnerability, add a CRITICAL section to the WeKan CHANGELOG like previous
|
||||
entries, and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
|
||||
entries (maintainer mode only, as with every changelog change), and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
|
||||
subdirectory `index.html` like previous security issues.
|
||||
- **Every security fix gets a TEST and a NEGATIVE TEST, and they are written so the
|
||||
fault cannot exist ANYWHERE in the codebase — not just at the place it was
|
||||
@@ -869,6 +941,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
|
||||
|
||||
+8540
File diff suppressed because it is too large.
Load diff
+39
-5150
File diff suppressed because it is too large.
Load diff
@@ -26,7 +26,7 @@ Work out which you are from the git identity (the next section says how), then:
|
||||
| Who | Where the work goes |
|
||||
| --- | --- |
|
||||
| **The maintainer** — `Lauri Ojansivu <x@xet7.org>` | **Directly on `main`.** Never a feature branch, never a topic branch, never a pull request. |
|
||||
| **A contributor** — anybody else | **A branch in your own fork, then a pull request** for the maintainer to review. Never a commit on `main`. |
|
||||
| **A contributor** — anybody else | **A branch in your own fork, then one pull request per feature or fix** for the maintainer to review. Never a commit on `main`, and no changelog changes. |
|
||||
|
||||
Nothing else is a third option. If a tool, a task runner or an agent harness offers
|
||||
some other arrangement — a topic branch for the maintainer, a direct push for a
|
||||
@@ -35,13 +35,15 @@ 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.
|
||||
|
||||
The contributor's half is review. Nobody but the maintainer commits to `main` in
|
||||
wekan/wekan, so a change from anyone else arrives as a pull request — which is also
|
||||
the only place it can be discussed before it lands.
|
||||
the only place it can be discussed before it lands. That is why each pull request
|
||||
holds one feature or one fix, and leaves the changelogs to the maintainer: see
|
||||
"Contributor mode" below.
|
||||
|
||||
Check which branch you are on before committing:
|
||||
|
||||
@@ -143,12 +145,29 @@ git config user.name && git config user.email
|
||||
already exists, on <https://wekan.fi/sponsors>, and belongs nowhere else. The **publishing / release
|
||||
steps** below are available to a human maintainer only: an AI stops after
|
||||
committing locally and never runs a step that pushes or publishes.
|
||||
|
||||
**Maintainer mode is the only mode that commits directly and the only mode that
|
||||
updates changelogs**, in WeKan and in the maintainer's repositories under
|
||||
`.tools/` alike. It applies to the changes the WeKan maintainer makes, and to the
|
||||
changes of an agent working as the maintainer according to this file.
|
||||
- **Contributor mode** — the identity is somebody ELSE, in a fork or a clone of your
|
||||
own — a person, or an AI that files pull requests on its own account. Then: do
|
||||
**not** commit to `main` and do **not** run any release/publishing step. Make the
|
||||
changes on a branch and open a **pull request** for the maintainer to review. The
|
||||
"commit as Lauri Ojansivu", "commit directly to `main`", and all release
|
||||
instructions below are **maintainer-only and do not apply to you**.
|
||||
**not** commit directly to the repository or to `main`, and do **not** run any
|
||||
release/publishing step. Make the changes on a branch and open a **pull request**
|
||||
for the maintainer to review. The "commit as Lauri Ojansivu", "commit directly to
|
||||
`main`", changelog and release instructions below are **maintainer-only and do
|
||||
not apply to you**.
|
||||
|
||||
Every pull request, to wekan/wekan or to a maintainer's repository under `.tools/`,
|
||||
follows two more rules:
|
||||
- **One feature or one fix per pull request.** Changes share a pull request only
|
||||
when they are related to that one feature or fix. Anything unrelated, however
|
||||
small, goes in a pull request of its own, so each can be reviewed, merged or
|
||||
declined without holding up the others.
|
||||
- **No changelog changes.** Do not edit `CHANGELOG-big.md`, `CHANGELOG.md`, or the
|
||||
`CHANGELOG.md` or `CHANGELOG` of a repository under `.tools/` — not an Upcoming
|
||||
entry, not TODO Later. Describe the change in the pull request instead. The
|
||||
maintainer writes the entry after merging, when there is a commit for it to link.
|
||||
|
||||
Who that pull request credits follows the attribution rule above, and the two
|
||||
cases differ:
|
||||
@@ -171,8 +190,8 @@ The one repository under `.tools/` that is somebody else's is `sandstorm-io/sand
|
||||
cloned for reference only.
|
||||
|
||||
Everything below marked as maintainer-specific (committing directly, the exact commit
|
||||
author, and the entire "Making a release" / publishing flow) applies only in maintainer
|
||||
mode. The rest (translation policy, CHANGELOG structure, tests, validating from code) is
|
||||
author, updating changelogs, and the entire "Making a release" / publishing flow) applies
|
||||
only in maintainer mode. The rest (translation policy, tests, validating from code) is
|
||||
good practice for everyone.
|
||||
|
||||
## Process: "Fix open issues"
|
||||
@@ -206,6 +225,9 @@ When asked to fix an open issue (one issue at a time):
|
||||
|
||||
Fixes #1234,
|
||||
```
|
||||
|
||||
A contributor instead commits on a branch for one pull request that fixes this
|
||||
one issue, with no changelog changes (see "Contributor mode" above).
|
||||
6. **If the bug is already fixed**, still add a commit that CLOSES the issue (a commit
|
||||
whose message ends `Fixes #1234,`), noting where/when it was fixed.
|
||||
|
||||
@@ -340,7 +362,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
|
||||
@@ -407,6 +429,10 @@ directly after the merge.
|
||||
|
||||
### CHANGELOG
|
||||
|
||||
- **[maintainer only] Changelogs are updated only in maintainer mode.** This whole
|
||||
section is for the maintainer and for an agent working as the maintainer, in WeKan
|
||||
and in the repositories under `.tools/`. A pull request contributor changes no
|
||||
changelog in any of them (see "Contributor mode" above).
|
||||
- **Every repository writes its changelog in the format that repository's own file
|
||||
already uses.** Open its changelog, read the entries above the place you are
|
||||
adding one, and match them — never import another project's shape into it. The
|
||||
@@ -414,7 +440,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 +452,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 +510,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 +521,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 +536,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 +636,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 +649,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 +708,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 +789,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
|
||||
@@ -771,7 +843,7 @@ have cost a released section its accuracy:
|
||||
### Security issues
|
||||
|
||||
- Fix the vulnerability, add a CRITICAL section to the WeKan CHANGELOG like previous
|
||||
entries, and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
|
||||
entries (maintainer mode only, as with every changelog change), and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
|
||||
subdirectory `index.html` like previous security issues.
|
||||
- **Every security fix gets a TEST and a NEGATIVE TEST, and they are written so the
|
||||
fault cannot exist ANYWHERE in the codebase — not just at the place it was
|
||||
@@ -869,6 +941,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
|
||||
|
||||
+12
-4
@@ -38,7 +38,7 @@ FROM --platform=$BUILDPLATFORM debian:trixie AS bundle
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG VERSION=12.66
|
||||
ARG VERSION=13.00
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
COPY --chmod=755 releases/check-telemetry.py /tmp/check-telemetry.py
|
||||
@@ -148,10 +148,10 @@ 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 \
|
||||
NPM_VERSION=12.2.2 \
|
||||
SRC_PATH=./ \
|
||||
WITH_API=true \
|
||||
MONGO_OPLOG_URL="" \
|
||||
@@ -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.66.0"
|
||||
appVersion: "v13.00.0"
|
||||
files:
|
||||
userUploads:
|
||||
- README.md
|
||||
|
||||
@@ -8,13 +8,6 @@
|
||||
# TODO:
|
||||
# addcustomfieldtoboard: There is error: Settings must be object. So adding does not work yet.
|
||||
|
||||
try:
|
||||
# python 3
|
||||
from urllib.parse import urlencode
|
||||
except ImportError:
|
||||
# python 2
|
||||
from urllib import urlencode
|
||||
|
||||
import json
|
||||
import requests
|
||||
import sys
|
||||
@@ -163,7 +156,7 @@ If *nix: chmod +x api.py => ./api.py users
|
||||
|
||||
if arguments == 0:
|
||||
print(syntax)
|
||||
exit
|
||||
sys.exit()
|
||||
|
||||
# TODO:
|
||||
# print(" python3 api.py attachmentjson BOARDID ATTACHMENTID # One attachment as JSON base64")
|
||||
@@ -329,7 +322,7 @@ if arguments == 8:
|
||||
if new_card_id:
|
||||
edcard = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + new_card_id
|
||||
put_data = {'labelIds': labelIds}
|
||||
body = requests.put(edcard, data=put_data, headers=headers)
|
||||
requests.put(edcard, data=put_data, headers=headers)
|
||||
print("=== EDIT CARD ===\n")
|
||||
body = requests.get(edcard, headers=headers)
|
||||
data2 = body.text.replace('}', "}\n")
|
||||
@@ -372,7 +365,7 @@ if arguments == 6:
|
||||
print(edcard)
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
put_data = {'title': '{}'.format(newcardtitle), 'description': '{}'.format(newcarddescription)}
|
||||
body = requests.put(edcard, data=put_data, headers=headers)
|
||||
requests.put(edcard, data=put_data, headers=headers)
|
||||
print("=== EDIT CARD ===\n")
|
||||
body = requests.get(edcard, headers=headers)
|
||||
data2 = body.text.replace('}',"}\n")
|
||||
@@ -411,7 +404,7 @@ if arguments == 5:
|
||||
print(edcard)
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
put_data = {'labelIds': labelIds}
|
||||
body = requests.put(edcard, data=put_data, headers=headers)
|
||||
requests.put(edcard, data=put_data, headers=headers)
|
||||
print("=== ADD LABEL ===\n")
|
||||
body = requests.get(edcard, headers=headers)
|
||||
data2 = body.text.replace('}',"}\n")
|
||||
@@ -437,7 +430,7 @@ if arguments == 5:
|
||||
print(edcard)
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
put_data = {'color': '{}'.format(newcolor)}
|
||||
body = requests.put(edcard, data=put_data, headers=headers)
|
||||
requests.put(edcard, data=put_data, headers=headers)
|
||||
print("=== EDIT CARD COLOR ===\n")
|
||||
body = requests.get(edcard, headers=headers)
|
||||
data2 = body.text.replace('}', "}\n")
|
||||
@@ -456,7 +449,7 @@ if arguments >= 4:
|
||||
print(edlist)
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
put_data = {'title': '{}'.format(newlisttitle)}
|
||||
body = requests.put(edlist, data=put_data, headers=headers)
|
||||
requests.put(edlist, data=put_data, headers=headers)
|
||||
print("=== EDIT LIST ===\n")
|
||||
body = requests.get(edlist, headers=headers)
|
||||
data2 = body.text.replace('}', "}\n")
|
||||
@@ -482,7 +475,7 @@ if arguments >= 4:
|
||||
print(edlist)
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
put_data = {'color': '{}'.format(newcolor)}
|
||||
body = requests.put(edlist, data=put_data, headers=headers)
|
||||
requests.put(edlist, data=put_data, headers=headers)
|
||||
print("=== EDIT LIST COLOR ===\n")
|
||||
body = requests.get(edlist, headers=headers)
|
||||
data2 = body.text.replace('}', "}\n")
|
||||
@@ -1050,7 +1043,7 @@ if arguments >= 1:
|
||||
if arguments < 6:
|
||||
print("Usage: python3 api.py uploadattachment BOARDID SWIMLANEID LISTID CARDID FILEPATH [STORAGE_BACKEND]")
|
||||
print("Storage backends: fs, gridfs, s3")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
boardid = sys.argv[2]
|
||||
swimlaneid = sys.argv[3]
|
||||
@@ -1064,7 +1057,7 @@ if arguments >= 1:
|
||||
import os, mimetypes
|
||||
if not os.path.isfile(filepath):
|
||||
print(f"Error: File '{filepath}' not found")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
filename = os.path.basename(filepath)
|
||||
file_type = mimetypes.guess_type(filepath)[0] or 'application/octet-stream'
|
||||
params = {'boardId': boardid, 'swimlaneId': swimlaneid, 'listId': listid, 'cardId': cardid,
|
||||
@@ -1092,7 +1085,7 @@ if arguments >= 1:
|
||||
# ------- DOWNLOAD ATTACHMENT START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py downloadattachment ATTACHMENTID OUTPUTPATH")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
attachmentid = sys.argv[2]
|
||||
outputpath = sys.argv[3]
|
||||
@@ -1121,7 +1114,7 @@ if arguments >= 1:
|
||||
# ------- ATTACHMENT INFO START -----------
|
||||
if arguments < 2:
|
||||
print("Usage: python3 api.py attachmentinfo ATTACHMENTID")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
attachmentid = sys.argv[2]
|
||||
|
||||
@@ -1160,7 +1153,7 @@ if arguments >= 1:
|
||||
# ------- LIST CARD ATTACHMENTS START -----------
|
||||
if arguments < 5:
|
||||
print("Usage: python3 api.py listcardattachments BOARDID SWIMLANEID LISTID CARDID")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
boardid = sys.argv[2]
|
||||
swimlaneid = sys.argv[3]
|
||||
@@ -1199,7 +1192,7 @@ if arguments >= 1:
|
||||
# ------- COPY/MOVE ATTACHMENT START -----------
|
||||
if arguments < 6:
|
||||
print("Usage: python3 api.py copymoveattachment ATTACHMENTID TARGETBOARDID TARGETSWIMLANEID TARGETLISTID TARGETCARDID [copy|move]")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
attachmentid = sys.argv[2]
|
||||
targetboardid = sys.argv[3]
|
||||
@@ -1210,7 +1203,7 @@ if arguments >= 1:
|
||||
|
||||
if operation not in ['copy', 'move']:
|
||||
print("Operation must be 'copy' or 'move'")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
# Prepare request data
|
||||
request_data = {
|
||||
@@ -1253,7 +1246,7 @@ if arguments >= 1:
|
||||
# ------- DELETE ATTACHMENT START -----------
|
||||
if arguments < 2:
|
||||
print("Usage: python3 api.py deleteattachment ATTACHMENTID")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
attachmentid = sys.argv[2]
|
||||
|
||||
@@ -1282,7 +1275,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
|
||||
@@ -1322,7 +1315,7 @@ if arguments >= 1:
|
||||
# ------- ADD BOARD MEMBER START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py addboardmember BOARDID USERID ROLE")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
userid = sys.argv[3]
|
||||
role = sys.argv[4] if arguments > 3 else 'normal'
|
||||
@@ -1339,7 +1332,7 @@ if arguments >= 1:
|
||||
# ------- REMOVE BOARD MEMBER START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py removeboardmember BOARDID USERID")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
userid = sys.argv[3]
|
||||
remove_member_url = wekanurl + apiboards + boardid + s + 'members' + s + userid + s + 'remove'
|
||||
@@ -1354,7 +1347,7 @@ if arguments >= 1:
|
||||
# ------- SET BOARD MEMBER ROLE START -----------
|
||||
if arguments < 4:
|
||||
print("Usage: python3 api.py setboardmemberrole BOARDID USERID ROLE")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
userid = sys.argv[3]
|
||||
role = sys.argv[4]
|
||||
@@ -1370,7 +1363,7 @@ if arguments >= 1:
|
||||
# ------- LIST BOARD DOMAINS START -----------
|
||||
if arguments < 2:
|
||||
print("Usage: python3 api.py boarddomains BOARDID")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
domains_url = wekanurl + apiboards + boardid + s + 'domains'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
@@ -1383,7 +1376,7 @@ if arguments >= 1:
|
||||
# ------- ADD BOARD DOMAIN START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py addboarddomain BOARDID DOMAIN")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
domain = sys.argv[3]
|
||||
add_domain_url = wekanurl + apiboards + boardid + s + 'domains'
|
||||
@@ -1398,7 +1391,7 @@ if arguments >= 1:
|
||||
# ------- REMOVE BOARD DOMAIN START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py removeboarddomain BOARDID DOMAIN")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
domain = sys.argv[3]
|
||||
remove_domain_url = wekanurl + apiboards + boardid + s + 'domains' + s + domain
|
||||
@@ -1413,7 +1406,7 @@ if arguments >= 1:
|
||||
if arguments < 4:
|
||||
print("Usage: python3 api.py {} BOARDID LISTID CARDID IDS".format(sys.argv[1]))
|
||||
print("IDS = comma-separated userIds, or '' to clear")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
@@ -1435,7 +1428,7 @@ if arguments >= 1:
|
||||
print("Usage: python3 api.py setcarddate BOARDID LISTID CARDID DATETYPE DATEVALUE")
|
||||
print("DATETYPE: received, start, due, end")
|
||||
print("DATEVALUE: ISO 8601, e.g. 2026-06-07T12:00:00.000Z")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
@@ -1453,7 +1446,7 @@ if arguments >= 1:
|
||||
edcard = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
put_data = {datemap[datetype]: datevalue}
|
||||
body = requests.put(edcard, data=put_data, headers=headers)
|
||||
requests.put(edcard, data=put_data, headers=headers)
|
||||
print("=== SET CARD DATE {} ===\n".format(datetype.upper()))
|
||||
print(body.text)
|
||||
# ------- SET CARD DATE END -----------
|
||||
@@ -1463,7 +1456,7 @@ if arguments >= 1:
|
||||
if arguments < 4:
|
||||
print("Usage: python3 api.py setcardlabels BOARDID LISTID CARDID LABELIDS")
|
||||
print("LABELIDS = comma-separated labelIds, or '' to clear")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
@@ -1472,7 +1465,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")
|
||||
@@ -1483,7 +1476,7 @@ if arguments >= 1:
|
||||
# ------- MOVE CARD START -----------
|
||||
if arguments < 7:
|
||||
print("Usage: python3 api.py movecard BOARDID LISTID CARDID NEWBOARDID NEWSWIMLANEID NEWLISTID")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
@@ -1497,7 +1490,7 @@ if arguments >= 1:
|
||||
'newSwimlaneId': newswimlaneid,
|
||||
'newListId': newlistid,
|
||||
}
|
||||
body = requests.put(edcard, data=put_data, headers=headers)
|
||||
requests.put(edcard, data=put_data, headers=headers)
|
||||
print("=== MOVE CARD ===\n")
|
||||
print(body.text)
|
||||
# ------- MOVE CARD END -----------
|
||||
@@ -1508,7 +1501,7 @@ if arguments >= 1:
|
||||
print("Usage: python3 api.py bulkaddcards BOARDID LISTID AUTHORID SWIMLANEID CARDSJSONFILE")
|
||||
print("CARDSJSONFILE = JSON file with a list of titles, e.g. [\"Card A\", \"Card B\"],")
|
||||
print(" or a list of card objects [{\"title\": \"Card A\", \"description\": \"...\"}, ...]")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
authorid = sys.argv[4]
|
||||
@@ -1516,16 +1509,16 @@ if arguments >= 1:
|
||||
cardsjsonfile = sys.argv[6] if arguments > 5 else None
|
||||
if not cardsjsonfile:
|
||||
print("CARDSJSONFILE is required")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
try:
|
||||
with open(cardsjsonfile, 'r') as f:
|
||||
raw_cards = json.load(f)
|
||||
except Exception as e:
|
||||
print("Error reading CARDSJSONFILE: {}".format(e))
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
if not isinstance(raw_cards, list) or len(raw_cards) == 0:
|
||||
print("CARDSJSONFILE must contain a non-empty JSON list")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
cards = []
|
||||
for item in raw_cards:
|
||||
if isinstance(item, str):
|
||||
@@ -1534,7 +1527,7 @@ if arguments >= 1:
|
||||
cards.append(item)
|
||||
else:
|
||||
print("Each card must be a string (title) or an object")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
bulk_url = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + 'bulk'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
post_data = {'authorId': authorid, 'swimlaneId': swimlaneid, 'cards': cards}
|
||||
@@ -1548,7 +1541,7 @@ if arguments >= 1:
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py bulkdeletecards BOARDID CARDIDS")
|
||||
print("CARDIDS = comma-separated cardIds")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
cardids_raw = sys.argv[3]
|
||||
cardids = [x for x in cardids_raw.split(',') if x]
|
||||
@@ -1565,7 +1558,7 @@ if arguments >= 1:
|
||||
if arguments < 5:
|
||||
print("Usage: python3 api.py bulkcardlabels BOARDID CARDIDS ADDLABELIDS REMOVELABELIDS")
|
||||
print("CARDIDS / ADDLABELIDS / REMOVELABELIDS = comma-separated, or '' for none")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
cardids = [x for x in sys.argv[3].split(',') if x]
|
||||
add_labelids = [x for x in sys.argv[4].split(',') if x]
|
||||
@@ -1582,7 +1575,7 @@ if arguments >= 1:
|
||||
# ------- CREATE LINKED CARD START -----------
|
||||
if arguments < 6:
|
||||
print("Usage: python3 api.py linkcard BOARDID LISTID SWIMLANEID AUTHORID LINKEDCARDID")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
swimlaneid = sys.argv[4]
|
||||
@@ -1600,7 +1593,7 @@ if arguments >= 1:
|
||||
# ------- ADD/REMOVE CARD MEMBER / ASSIGNEE START -----------
|
||||
if arguments < 5:
|
||||
print("Usage: python3 api.py {} BOARDID LISTID CARDID USERID".format(sys.argv[1]))
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
@@ -1620,14 +1613,14 @@ if arguments >= 1:
|
||||
# ------- COPY CARD START -----------
|
||||
if arguments < 4:
|
||||
print("Usage: python3 api.py copycard BOARDID LISTID CARDID TOSWIMLANEID [TOBOARDID] [TOLISTID] [POSITION]")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
toswimlaneid = sys.argv[5] if arguments > 4 else None
|
||||
if not toswimlaneid:
|
||||
print("TOSWIMLANEID is required")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
toboardid = sys.argv[6] if arguments > 5 else boardid
|
||||
tolistid = sys.argv[7] if arguments > 6 else listid
|
||||
url = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid + s + 'copy'
|
||||
@@ -1644,7 +1637,7 @@ if arguments >= 1:
|
||||
# ------- COPY/MOVE SWIMLANE START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py {} BOARDID SWIMLANEID [TOBOARDID] [POSITION]".format(sys.argv[1]))
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
swimlaneid = sys.argv[3]
|
||||
toboardid = sys.argv[4] if arguments > 3 else boardid
|
||||
@@ -1663,7 +1656,7 @@ if arguments >= 1:
|
||||
# ------- COPY/MOVE LIST START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py {} BOARDID LISTID [TOSWIMLANEID] [TOBOARDID] [POSITION]".format(sys.argv[1]))
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
toswimlaneid = sys.argv[4] if arguments > 3 else None
|
||||
@@ -1702,7 +1695,7 @@ if arguments >= 1:
|
||||
# ------- GET CARD SETTINGS START -----------
|
||||
if arguments < 2:
|
||||
print("Usage: python3 api.py getcardsettings BOARDID")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
url = wekanurl + apiboards + boardid + s + 'cardSettings'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
@@ -1717,7 +1710,7 @@ if arguments >= 1:
|
||||
if arguments < 4:
|
||||
print("Usage: python3 api.py setcardsetting BOARDID KEY VALUE")
|
||||
print("e.g. python3 api.py setcardsetting BOARDID allowsDueDate true")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
key = sys.argv[3]
|
||||
value = sys.argv[4]
|
||||
@@ -1945,7 +1938,7 @@ if arguments >= 3 and sys.argv[1] == 'uploadbackground':
|
||||
response = requests.post(url, headers=headers, params=params, data=f)
|
||||
except Exception as e:
|
||||
print(f"Error reading file: {e}")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
print(response.text)
|
||||
|
||||
if arguments >= 3 and sys.argv[1] == 'downloadbackground':
|
||||
|
||||
@@ -989,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"
|
||||
@@ -1005,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 (
|
||||
@@ -1977,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"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/bin/bash
|
||||
if [ -n "${ZSH_VERSION:-}" ]; then exec /bin/bash "$0" "$@"; fi
|
||||
|
||||
echo "Recommended for development: Newest Debian or Ubuntu amd64 based distro, directly to SSD disk or dual boot, not VM. Works fast."
|
||||
echo "Recommended for development: Newest Debian, Ubuntu, Fedora or Arch/Omarchy amd64 based distro, directly to SSD disk or dual boot, not VM. Works fast."
|
||||
echo "Note1: If you use other locale than en_US.UTF-8 , you need to additionally install en_US.UTF-8"
|
||||
echo " with 'sudo dpkg-reconfigure locales' , so that MongoDB works correctly."
|
||||
echo " so that MongoDB works correctly: Debian/Ubuntu 'sudo dpkg-reconfigure locales',"
|
||||
echo " Arch/Omarchy: uncomment en_US.UTF-8 in /etc/locale.gen and run 'sudo locale-gen'."
|
||||
echo " You can still use any other locale as your main locale."
|
||||
echo "Note2: Console output is also logged to the operation-specific path printed below."
|
||||
echo "Note3: All logs use .tools/log/<operation>/YYYY-MM-DD_HH-MM-SS/."
|
||||
@@ -468,10 +469,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."
|
||||
@@ -598,6 +606,14 @@ function browser_needs_docker(){
|
||||
if [ "$PLATFORM_OS" = "linux" ] && [ "$PLATFORM_ARCH" = "arm64" ]; then
|
||||
return 0
|
||||
fi
|
||||
# Playwright builds WebKit for Debian/Ubuntu only. On Arch and Omarchy its
|
||||
# WebKit is linked against other ICU/libjpeg/libwebp versions than the
|
||||
# rolling release has and does not launch, while Chromium and Firefox do.
|
||||
# So WebKit alone goes to the Docker image there, when Docker is installed.
|
||||
if [ "$browser" = webkit ] && [ "$PLATFORM_OS" = "linux" ] && \
|
||||
[ "$(_et_linux_family)" = arch ] && docker_available; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -625,8 +641,14 @@ function docker_exec(){
|
||||
# Run a Playwright browser project (chromium / firefox / webkit) inside the
|
||||
# official Playwright container. First arg is the browser; any extra args are
|
||||
# passed through to `playwright test`. WeKan must already be running on the host
|
||||
# (default http://127.0.0.1:3000, Meteor's bundled Mongo on 3001); the container
|
||||
# (default http://localhost:3000, Meteor's bundled Mongo on 3001); the container
|
||||
# shares the host network so it can reach both.
|
||||
#
|
||||
# The browser must use the test server's ROOT_URL origin, http://localhost:3000.
|
||||
# Meteor-Files sends every upload to ROOT_URL + /cdn/storage/.../__upload, and
|
||||
# answers no CORS preflight for another origin, so a page opened on
|
||||
# http://127.0.0.1:3000 could not upload anything: the drawing specs' Save never
|
||||
# finished. Health checks with curl may still use 127.0.0.1; a browser may not.
|
||||
function run_playwright_docker(){
|
||||
local browser="$1"; shift
|
||||
local reporoot="$WEKAN_DIR"
|
||||
@@ -642,11 +664,21 @@ function run_playwright_docker(){
|
||||
pwver="$(node -e "console.log(require('$pwdir/node_modules/@playwright/test/package.json').version)" 2>/dev/null)"
|
||||
[ -z "$pwver" ] && pwver="1.60.0"
|
||||
local image="mcr.microsoft.com/playwright:v${pwver}-noble"
|
||||
local docker_base_url="${WEKAN_BASE_URL:-http://127.0.0.1:3000}"
|
||||
local docker_base_url="${WEKAN_BASE_URL:-http://localhost: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
|
||||
@@ -670,35 +702,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" \
|
||||
@@ -710,16 +760,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
|
||||
@@ -732,9 +783,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"
|
||||
}
|
||||
@@ -762,7 +813,7 @@ function run_node_e2e_docker(){
|
||||
-e HOME=/repo/.tools/tmp \
|
||||
-e TMPDIR=/repo/.tools/tmp \
|
||||
-e NODE_OPTIONS="${NODE_OPTIONS:-}" \
|
||||
-e WEKAN_BASE_URL="${WEKAN_BASE_URL:-http://127.0.0.1:3000}" \
|
||||
-e WEKAN_BASE_URL="${WEKAN_BASE_URL:-http://localhost:3000}" \
|
||||
-e WEKAN_MONGO_URL="${WEKAN_MONGO_URL:-mongodb://127.0.0.1:3001/meteor}" \
|
||||
-v "$reporoot":/repo -w /repo \
|
||||
"$image" \
|
||||
@@ -789,8 +840,17 @@ function install_playwright_browsers(){
|
||||
browser_needs_docker "$b" || nativeList="$nativeList $b"
|
||||
done
|
||||
if [ -n "$nativeList" ]; then
|
||||
echo "Installing native Playwright browsers:$nativeList (with system deps; may use sudo)."
|
||||
( cd "$pwdir" && export HOME="$reporoot/.tools" && npx playwright install --with-deps $nativeList )
|
||||
if [ "$PLATFORM_OS" = "linux" ] && [ "$(_et_linux_family)" != debian ]; then
|
||||
# `--with-deps` runs apt-get, which only Debian/Ubuntu have. Elsewhere
|
||||
# install the libraries the browsers load with the native package
|
||||
# manager, then the browsers themselves without --with-deps.
|
||||
echo "Installing native Playwright browsers:$nativeList (system deps from the distribution's packages; may use sudo)."
|
||||
ensure_playwright_system_deps
|
||||
( cd "$pwdir" && export HOME="$reporoot/.tools" && npx playwright install $nativeList )
|
||||
else
|
||||
echo "Installing native Playwright browsers:$nativeList (with system deps; may use sudo)."
|
||||
( cd "$pwdir" && export HOME="$reporoot/.tools" && npx playwright install --with-deps $nativeList )
|
||||
fi
|
||||
else
|
||||
echo "All browsers are configured to run via Docker; skipping native browser install."
|
||||
fi
|
||||
@@ -1506,7 +1566,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 &
|
||||
@@ -1534,7 +1598,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.
|
||||
@@ -2393,8 +2457,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
|
||||
@@ -2605,7 +2669,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|||"
|
||||
@@ -2692,8 +2756,8 @@ RELEASE_SCRIPTS=( "Release|Release All: audit, commit, push and build with Actio
|
||||
"Git and repo|Migrate a MongoDB database to FerretDB (--help first)|releases/migrate-mongodb-to-ferretdb.mjs|Arguments, e.g. --help||"
|
||||
"Git and repo|Export local Git history to a new Fossil repository|releases/fossil.sh|||fossil-export"
|
||||
"Git and repo|Open the local Fossil repository UI|releases/fossil-ui.sh|||fossil-ui"
|
||||
"Server and VM|Enable and start the SSH server|!sudo systemctl enable ssh && sudo systemctl start ssh||linux|ssh-start"
|
||||
"Server and VM|Disable and stop the SSH server|!sudo systemctl disable ssh && sudo systemctl stop ssh||linux|ssh-stop"
|
||||
"Server and VM|Enable and start the SSH server|!sudo systemctl enable \"\$(_et_ssh_unit)\" && sudo systemctl start \"\$(_et_ssh_unit)\"||linux|ssh-start"
|
||||
"Server and VM|Disable and stop the SSH server|!sudo systemctl disable \"\$(_et_ssh_unit)\" && sudo systemctl stop \"\$(_et_ssh_unit)\"||linux|ssh-stop"
|
||||
"Server and VM|Enable the ufw firewall|!sudo ufw enable||linux|ufw-enable"
|
||||
"Server and VM|Disable the ufw firewall|!sudo ufw disable||linux|ufw-disable"
|
||||
"Server and VM|Remove the Multipass VM|!multipass stop --all && multipass delete --all && multipass purge && sudo snap remove multipass||linux|multipass-remove"
|
||||
@@ -2959,7 +3023,8 @@ for _once in 1; do
|
||||
sudo apk add --no-cache bash build-base git curl wget p7zip zip unzip file npm
|
||||
;;
|
||||
arch)
|
||||
sudo pacman -Sy --needed --noconfirm base-devel git curl wget p7zip zip unzip file npm
|
||||
# Arch and Omarchy: no `pacman -Sy` partial upgrade (see _et_pacman).
|
||||
_et_pacman base-devel git curl wget 7zip zip unzip file npm
|
||||
;;
|
||||
fedora)
|
||||
sudo dnf group install -y development-tools
|
||||
|
||||
+26
-1
@@ -62,7 +62,10 @@ try {
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function() {
|
||||
const swPath = new URL('pwa-service-worker.js', Meteor.absoluteUrl()).pathname;
|
||||
navigator.serviceWorker.register(swPath);
|
||||
navigator.serviceWorker.register(swPath).catch(error => {
|
||||
// No service worker (a private window, an old browser): the app works without the offline cache.
|
||||
if (process.env.DEBUG === 'true') console.warn('service worker registration failed:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -111,6 +114,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(() => {
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
background-color: #fff;
|
||||
border: 0;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.23);
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
height: 36px;
|
||||
margin-block: 4px 6px;
|
||||
margin-inline: 0 4px;
|
||||
|
||||
@@ -15,7 +15,7 @@ template(name="commentForm")
|
||||
a.js-cancel-reply(title="{{_ 'close' }}")
|
||||
i.fa.fa-times-thin
|
||||
form.js-new-comment-form
|
||||
+editor(class="js-new-comment-input")
|
||||
+editor(class="js-new-comment-input" labelKey="comment")
|
||||
| {{getUnsavedValue 'cardComment' currentCard._id}}
|
||||
.add-controls
|
||||
button.primary.confirm.clear.js-add-comment(type="submit") {{_ 'comment'}}
|
||||
@@ -40,7 +40,7 @@ template(name="comment")
|
||||
= parentComment.text
|
||||
|
||||
+inlinedForm(classNames='js-edit-comment')
|
||||
+editor(autofocus=true)
|
||||
+editor(autofocus=true labelKey="comment")
|
||||
= text
|
||||
.edit-controls
|
||||
button.primary(type="submit" title="{{_ 'edit'}}" aria-label="{{_ 'edit'}}")
|
||||
|
||||
@@ -112,9 +112,10 @@ template(name="allBoardsMultiSelectionSidebar")
|
||||
a.sidebar-btn.js-archive-selected-boards
|
||||
i.fa.fa-archive
|
||||
span {{_ 'archive-board'}}
|
||||
a.sidebar-btn.js-duplicate-selected-boards
|
||||
i.fa.fa-clipboard
|
||||
span {{_ 'duplicate-board'}}
|
||||
if boardCreationAllowed
|
||||
a.sidebar-btn.js-duplicate-selected-boards
|
||||
i.fa.fa-clipboard
|
||||
span {{_ 'duplicate-board'}}
|
||||
a.sidebar-btn.js-export-selected-boards
|
||||
i.fa.fa-download
|
||||
span {{_ 'export-selected-boards'}}
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
/* Ordinary solid-colour themes. */
|
||||
|
||||
.board-color-nephritis {
|
||||
--board-theme-accent: #27ae60;
|
||||
--board-theme-dark: #1f8b4d;
|
||||
--board-theme-hover: #2cc66d;
|
||||
--board-theme-accent: #217d49;
|
||||
--board-theme-dark: #1c6c3f;
|
||||
--board-theme-hover: #1f7645;
|
||||
--board-theme-emphasis: #ae2775;
|
||||
--board-theme-emphasis-hover: #9d2369;
|
||||
--board-theme-emphasis-close: #8b1f5e;
|
||||
@@ -36,7 +36,10 @@
|
||||
}
|
||||
|
||||
.board-color-belize {
|
||||
--board-theme-accent: #2980b9;
|
||||
/* The default theme. The earlier blue #2980b9 gave the header's white text a ratio of 4.30 and
|
||||
its #f2f2f2 button labels 3.84, below the 4.5 WCAG 2.1 AA (1.4.3) asks of
|
||||
small text; this blue keeps the hue and gives 5.14 and 4.59. */
|
||||
--board-theme-accent: #2573a7;
|
||||
--board-theme-dark: #216694;
|
||||
--board-theme-hover: #2e90d0;
|
||||
--board-theme-emphasis: #b96229;
|
||||
@@ -47,7 +50,7 @@
|
||||
--board-theme-selected-hover-bg: #f8fbfd;
|
||||
--board-theme-bright: #459cd6;
|
||||
--board-theme-canvas-scrollbar: #2573a7f2;
|
||||
--board-theme-sidebar-scrollbar: #2980b9f2;
|
||||
--board-theme-sidebar-scrollbar: #2573a7f2;
|
||||
}
|
||||
|
||||
.board-color-wisteria {
|
||||
@@ -81,12 +84,12 @@
|
||||
}
|
||||
|
||||
.board-color-pumpkin {
|
||||
--board-theme-accent: #e67e22;
|
||||
--board-theme-dark: #be6415;
|
||||
--board-theme-hover: #e98b38;
|
||||
--board-theme-emphasis: #228ae6;
|
||||
--board-theme-emphasis-hover: #187dd5;
|
||||
--board-theme-emphasis-close: #156fbe;
|
||||
--board-theme-accent: #a75919;
|
||||
--board-theme-dark: #904d15;
|
||||
--board-theme-hover: #9d5417;
|
||||
--board-theme-emphasis: #196ec2;
|
||||
--board-theme-emphasis-hover: #1765b2;
|
||||
--board-theme-emphasis-close: #155ca3;
|
||||
--board-theme-checklist-bg: #f9e5d1;
|
||||
--board-theme-selected-bg: #fdf2e9;
|
||||
--board-theme-selected-hover-bg: #fefbf8;
|
||||
@@ -96,12 +99,12 @@
|
||||
}
|
||||
|
||||
.board-color-moderatepink {
|
||||
--board-theme-accent: #cd5a91;
|
||||
--board-theme-dark: #b53773;
|
||||
--board-theme-hover: #d26b9c;
|
||||
--board-theme-emphasis: #5acd96;
|
||||
--board-theme-emphasis-hover: #43c688;
|
||||
--board-theme-emphasis-close: #37b579;
|
||||
--board-theme-accent: #bd3a79;
|
||||
--board-theme-dark: #a33268;
|
||||
--board-theme-hover: #b23772;
|
||||
--board-theme-emphasis: #217b54;
|
||||
--board-theme-emphasis-hover: #1e714d;
|
||||
--board-theme-emphasis-close: #1c6747;
|
||||
--board-theme-checklist-bg: #f4dde8;
|
||||
--board-theme-selected-bg: #faeef4;
|
||||
--board-theme-selected-hover-bg: #fefafc;
|
||||
@@ -111,9 +114,9 @@
|
||||
}
|
||||
|
||||
.board-color-strongcyan {
|
||||
--board-theme-accent: #00aecc;
|
||||
--board-theme-dark: #008ba3;
|
||||
--board-theme-hover: #00c8eb;
|
||||
--board-theme-accent: #00798d;
|
||||
--board-theme-dark: #006879;
|
||||
--board-theme-hover: #007285;
|
||||
--board-theme-emphasis: #cc1e00;
|
||||
--board-theme-emphasis-hover: #b81b00;
|
||||
--board-theme-emphasis-close: #a31800;
|
||||
@@ -126,12 +129,12 @@
|
||||
}
|
||||
|
||||
.board-color-limegreen {
|
||||
--board-theme-accent: #4bbf6b;
|
||||
--board-theme-dark: #389d54;
|
||||
--board-theme-hover: #5dc57a;
|
||||
--board-theme-emphasis: #bf4b9f;
|
||||
--board-theme-emphasis-hover: #b13f91;
|
||||
--board-theme-emphasis-close: #9d3881;
|
||||
--board-theme-accent: #277c48;
|
||||
--board-theme-dark: #226b3e;
|
||||
--board-theme-hover: #257544;
|
||||
--board-theme-emphasis: #b34093;
|
||||
--board-theme-emphasis-hover: #a53b87;
|
||||
--board-theme-emphasis-close: #96367b;
|
||||
--board-theme-checklist-bg: #daf4de;
|
||||
--board-theme-selected-bg: #edf9f0;
|
||||
--board-theme-selected-hover-bg: #fafdfb;
|
||||
@@ -141,12 +144,12 @@
|
||||
}
|
||||
|
||||
.board-color-corteza {
|
||||
--board-theme-accent: #568ba2;
|
||||
--board-theme-dark: #456f82;
|
||||
--board-theme-hover: #6597ad;
|
||||
--board-theme-emphasis: #a26d56;
|
||||
--board-theme-emphasis-hover: #92624d;
|
||||
--board-theme-emphasis-close: #825745;
|
||||
--board-theme-accent: #4a738a;
|
||||
--board-theme-dark: #406377;
|
||||
--board-theme-hover: #466c82;
|
||||
--board-theme-emphasis: #94614f;
|
||||
--board-theme-emphasis-hover: #885949;
|
||||
--board-theme-emphasis-close: #7c5142;
|
||||
--board-theme-checklist-bg: #dce6ec;
|
||||
--board-theme-selected-bg: #eef3f6;
|
||||
--board-theme-selected-hover-bg: #fafcfc;
|
||||
@@ -901,10 +904,10 @@ THEME - Relax
|
||||
.board-list li.board-color-relax,
|
||||
.board-color-relax .setting-content .content-body .side-menu ul li.active,
|
||||
.board-color-relax .setting-content .content-body .side-menu ul li.active:hover {
|
||||
background-color: #27ae61;
|
||||
background-color: #217d4a;
|
||||
}
|
||||
.board-color-relax .is-selected .minicard {
|
||||
border-inline-start: 3px solid #27ae61;
|
||||
border-inline-start: 3px solid #217d4a;
|
||||
}
|
||||
.board-color-relax .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -915,17 +918,17 @@ THEME - Relax
|
||||
.board-color-relax input[type=submit].primary,
|
||||
.board-color-relax .sidebar .sidebar-content .sidebar-btn,
|
||||
.board-color-relax .selected-calendar-picker button.primary {
|
||||
background-color: #1f8b4e;
|
||||
background-color: #1c6c40;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.board-color-relax.pop-over .pop-over-list li a:not(.disabled):hover,
|
||||
.board-color-relax .sidebar .sidebar-content .sidebar-btn:hover,
|
||||
.board-color-relax .sidebar-list li a:hover {
|
||||
background-color: #2cc66f;
|
||||
background-color: #1f7646;
|
||||
}
|
||||
.board-color-relax#header ul li.current,
|
||||
.board-color-relax#header-quick-access ul li.current {
|
||||
border-bottom: 2px solid #2cc66f;
|
||||
border-bottom: 2px solid #1f7646;
|
||||
}
|
||||
.board-color-relax#header-quick-access {
|
||||
color: #fff;
|
||||
@@ -941,14 +944,14 @@ THEME - Relax
|
||||
background: #8b1f5d;
|
||||
}
|
||||
.board-color-relax .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #27ae61;
|
||||
border-right: 2px solid #27ae61;
|
||||
border-bottom: 2px solid #217d4a;
|
||||
border-right: 2px solid #217d4a;
|
||||
}
|
||||
.board-color-relax .checklist-progress-bar {
|
||||
background-color: #d4f1dd !important;
|
||||
}
|
||||
.board-color-relax .checklist-progress-bar .checklist-progress {
|
||||
background-color: #27ae61 !important;
|
||||
background-color: #217d4a !important;
|
||||
}
|
||||
.board-color-relax .is-multiselection-active .multi-selection-checkbox.is-checked + .minicard {
|
||||
background: #e7faef;
|
||||
@@ -957,17 +960,17 @@ THEME - Relax
|
||||
background: #f8fdfa;
|
||||
}
|
||||
.board-color-relax .toggle-label:after {
|
||||
background-color: #1f8b4e;
|
||||
background-color: #1c6c40;
|
||||
}
|
||||
.board-color-relax .toggle-switch:checked ~ .toggle-label {
|
||||
background-color: #3dd37e;
|
||||
}
|
||||
.board-color-relax .toggle-switch:checked ~ .toggle-label:after {
|
||||
background-color: #1f8b4e;
|
||||
background-color: #1c6c40;
|
||||
}
|
||||
@media screen and (max-width: 800px) {
|
||||
.board-color-relax.pop-over .header {
|
||||
background: #27ae61;
|
||||
background: #217d4a;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
@@ -1554,7 +1557,7 @@ THEME - Clear Blue
|
||||
.board-backgrounds-list .board-color-clearblue.background-box,
|
||||
.public-board-row.board-color-clearblue,
|
||||
.board-list li.board-color-clearblue {
|
||||
background: linear-gradient(180deg, #499bea 0%, #00aecc 100%);
|
||||
background: linear-gradient(180deg, #499bea 0%, #00798d 100%);
|
||||
}
|
||||
.board-color-clearblue .is-selected .minicard {
|
||||
border-inline-start: 3px solid #499bea;
|
||||
@@ -1566,7 +1569,7 @@ THEME - Clear Blue
|
||||
}
|
||||
.board-color-clearblue#header ul li.current,
|
||||
.board-color-clearblue#header-quick-access ul li.current {
|
||||
border-bottom: 4px solid #00c8eb;
|
||||
border-bottom: 4px solid #007285;
|
||||
}
|
||||
.board-color-clearblue#header-quick-access {
|
||||
color: #fff;
|
||||
@@ -1585,14 +1588,14 @@ THEME - Clear Blue
|
||||
.board-color-clearblue#header-quick-access,
|
||||
.board-color-clearblue .setting-content .content-body .side-menu ul li.active,
|
||||
.board-color-clearblue .setting-content .content-body .side-menu ul li.active:hover {
|
||||
background-color: #00aecc;
|
||||
border-bottom: 1px solid #008ba3;
|
||||
background-color: #00798d;
|
||||
border-bottom: 1px solid #006879;
|
||||
border-top: 1px solid #00687a;
|
||||
}
|
||||
/* clearblue's bar is a colour SLIDE, and the slide has to be on the bar that is
|
||||
actually on screen. It was only ever on `#header #header-main-bar` - the
|
||||
inner element of the SECOND bar - because that is where the theme used to
|
||||
live. The first bar got the flat `background-color: #00aecc` from the rule
|
||||
live. The first bar got the flat `background-color: #00798d` from the rule
|
||||
above instead, which is exactly strongcyan's colour: picking clearblue
|
||||
painted the header strongcyan.
|
||||
`#header-quick-access` has no inner bar of its own, so the slide goes
|
||||
@@ -1601,7 +1604,7 @@ THEME - Clear Blue
|
||||
The All Boards left menu's selected row is named here too. Every other theme
|
||||
paints that row through `--theme-accent`, which is one colour and is exactly
|
||||
right for them; a slide is not a colour, so the variable cannot carry it and
|
||||
clearblue's row came out flat #00aecc while the Admin Panel's row beside it
|
||||
clearblue's row came out flat #00798d while the Admin Panel's row beside it
|
||||
slid. The Admin Panel's row has always been named in this rule; this is the
|
||||
All Boards row joining it. docs/Features/General/Theme/Change-Color.md */
|
||||
.board-color-clearblue#header #header-main-bar,
|
||||
@@ -1610,13 +1613,13 @@ THEME - Clear Blue
|
||||
.board-color-clearblue .setting-content .content-body .side-menu ul li.active:hover,
|
||||
.board-color-clearblue .boards-left-menu .menu-item.active a,
|
||||
.board-color-clearblue .boards-left-menu .menu-item.active a:hover {
|
||||
background: linear-gradient(180deg, #499bea 0%, #00aecc 100%);
|
||||
background: linear-gradient(180deg, #499bea 0%, #00798d 100%);
|
||||
}
|
||||
.board-color-clearblue#header #header-main-bar p {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.board-color-clearblue#header #header-main-bar .board-header-btn.emphasis {
|
||||
background: #00c8eb;
|
||||
background: #007285;
|
||||
}
|
||||
.board-color-clearblue#header #header-main-bar .board-header-btn.emphasis:hover,
|
||||
.board-color-clearblue#header #header-main-bar .board-header-btn.emphasis .board-header-btn-close {
|
||||
@@ -1648,7 +1651,7 @@ THEME - Clear Blue
|
||||
background-color: #197ddc;
|
||||
}
|
||||
.board-color-clearblue.board-wrapper {
|
||||
background: linear-gradient(135deg, #499bea 0%, #00aecc 100%);
|
||||
background: linear-gradient(135deg, #499bea 0%, #00798d 100%);
|
||||
}
|
||||
.board-color-clearblue .swimlane {
|
||||
background: none;
|
||||
@@ -1740,7 +1743,7 @@ THEME - Clear Blue
|
||||
}
|
||||
.board-color-clearblue .quiet,
|
||||
.board-color-clearblue .quiet a {
|
||||
color: rgba(0,0,0,0.4);
|
||||
color: rgba(0,0,0,0.68);
|
||||
}
|
||||
.board-color-clearblue .list-header .list-header-watch-icon {
|
||||
color: rgba(0,0,0,0.5);
|
||||
@@ -1767,7 +1770,7 @@ THEME - Clear Blue
|
||||
}
|
||||
.board-color-clearblue .sidebar-shadow {
|
||||
box-shadow: none;
|
||||
border-inline-start: 9px solid #00aecc;
|
||||
border-inline-start: 9px solid #00798d;
|
||||
}
|
||||
.board-color-clearblue .is-open .sidebar-shadow {
|
||||
box-shadow: -10px 0 8px rgba(0,0,0,0.3);
|
||||
@@ -1833,7 +1836,7 @@ THEME - Clear Blue
|
||||
|
||||
/* Apply scrollbar to sidebar content*/
|
||||
.board-color-clearblue .sidebar .sidebar-content {
|
||||
scrollbar-color: #00aecc #ffffff00;
|
||||
scrollbar-color: #00798d #ffffff00;
|
||||
}
|
||||
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
@@ -1984,10 +1987,10 @@ THEME - Modern
|
||||
.board-list li.board-color-modern,
|
||||
.board-color-modern .setting-content .content-body .side-menu ul li.active,
|
||||
.board-color-modern .setting-content .content-body .side-menu ul li.active:hover {
|
||||
background-color: #2a80b8;
|
||||
background-color: #2372a4;
|
||||
}
|
||||
.board-color-modern .is-selected .minicard {
|
||||
border-inline-start: 3px solid #2a80b8;
|
||||
border-inline-start: 3px solid #2372a4;
|
||||
}
|
||||
.board-color-modern .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -1998,40 +2001,40 @@ THEME - Modern
|
||||
.board-color-modern input[type=submit].primary,
|
||||
.board-color-modern .sidebar .sidebar-content .sidebar-btn,
|
||||
.board-color-modern .selected-calendar-picker button.primary {
|
||||
background-color: #226693;
|
||||
background-color: #1e628d;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.board-color-modern.pop-over .pop-over-list li a:not(.disabled):hover,
|
||||
.board-color-modern .sidebar .sidebar-content .sidebar-btn:hover,
|
||||
.board-color-modern .sidebar-list li a:hover {
|
||||
background-color: #2f90cf;
|
||||
background-color: #216b9a;
|
||||
}
|
||||
.board-color-modern#header ul li.current,
|
||||
.board-color-modern#header-quick-access ul li.current {
|
||||
border-bottom: 2px solid #2f90cf;
|
||||
border-bottom: 2px solid #216b9a;
|
||||
}
|
||||
.board-color-modern#header-quick-access {
|
||||
color: #fff;
|
||||
}
|
||||
.board-color-modern#header #header-main-bar .board-header-btn.emphasis {
|
||||
background: #b8622a;
|
||||
background: #a45b23;
|
||||
}
|
||||
.board-color-modern#header #header-main-bar .board-header-btn.emphasis:hover,
|
||||
.board-color-modern#header #header-main-bar .board-header-btn.emphasis .board-header-btn-close {
|
||||
background: #a65826;
|
||||
background: #975420;
|
||||
}
|
||||
.board-color-modern#header #header-main-bar .board-header-btn.emphasis:hover .board-header-btn-close {
|
||||
background: #934e22;
|
||||
background: #8a4c1d;
|
||||
}
|
||||
.board-color-modern .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #2a80b8;
|
||||
border-right: 2px solid #2a80b8;
|
||||
border-bottom: 2px solid #2372a4;
|
||||
border-right: 2px solid #2372a4;
|
||||
}
|
||||
.board-color-modern .checklist-progress-bar {
|
||||
background-color: #d1e7f5 !important;
|
||||
}
|
||||
.board-color-modern .checklist-progress-bar .checklist-progress {
|
||||
background-color: #2a80b8 !important;
|
||||
background-color: #2372a4 !important;
|
||||
}
|
||||
.board-color-modern .is-multiselection-active .multi-selection-checkbox.is-checked + .minicard {
|
||||
background: #e8f3fa;
|
||||
@@ -2040,17 +2043,17 @@ THEME - Modern
|
||||
background: #f8fbfd;
|
||||
}
|
||||
.board-color-modern .toggle-label:after {
|
||||
background-color: #226693;
|
||||
background-color: #1e628d;
|
||||
}
|
||||
.board-color-modern .toggle-switch:checked ~ .toggle-label {
|
||||
background-color: #469cd5;
|
||||
}
|
||||
.board-color-modern .toggle-switch:checked ~ .toggle-label:after {
|
||||
background-color: #226693;
|
||||
background-color: #1e628d;
|
||||
}
|
||||
@media screen and (max-width: 800px) {
|
||||
.board-color-modern.pop-over .header {
|
||||
background: #2a80b8;
|
||||
background: #2372a4;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
@@ -2379,7 +2382,7 @@ THEME - Modern Dark
|
||||
.board-color-moderndark button[type=submit].primary,
|
||||
.board-color-moderndark .board-color-modern input[type=submit].primary,
|
||||
.board-color-moderndark .selected-calendar-picker button.primary {
|
||||
background-color: #777;
|
||||
background-color: #6e6e6e;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.board-color-moderndark .toggle-switch:checked~.toggle-label {
|
||||
@@ -3012,7 +3015,15 @@ THEME - Exodark
|
||||
.pop-over.board-color-exodark {
|
||||
background: #2b2b2b;
|
||||
color: #fff;
|
||||
--wekan-quiet-text: #b4b4b4;
|
||||
/* Card Settings opens in this popup: dark sections for its white text. */
|
||||
--card-settings-section-bg: rgba(255, 255, 255, 0.08);
|
||||
--card-settings-helper-bg: #3a3a46;
|
||||
--card-settings-notch-bg: #2c3340;
|
||||
}
|
||||
/* The popup's own links keep the light popup's black otherwise: black on
|
||||
#2b2b2b was 1.48:1 (#6863). */
|
||||
|
||||
.pop-over.board-color-exodark .header {
|
||||
background: #2b2b2b;
|
||||
color: #fff;
|
||||
@@ -3791,7 +3802,7 @@ THEME - Clean Light
|
||||
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
|
||||
font-weight: 400;
|
||||
line-height: calc(24px * var(--wekan-ui-font-scale, 1));
|
||||
color: rgba(10, 10, 20, 0.5);
|
||||
color: rgba(10, 10, 20, 0.7);
|
||||
}
|
||||
|
||||
.board-color-cleanlight .card-details-header,
|
||||
@@ -4614,7 +4625,7 @@ THEME - Clean Light
|
||||
/* ===============
|
||||
THEME - cleargreen
|
||||
|
||||
A colour SLIDE, like clearblue: #8ad59f down to #4bbf6b.
|
||||
A colour SLIDE, like clearblue: #8ad59f down to #277c48.
|
||||
The base is limegreen's own colour, so the flat theme and the slide theme are
|
||||
the same colour family and a reader picking either gets what they expected.
|
||||
|
||||
@@ -4631,7 +4642,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-backgrounds-list .board-color-cleargreen.background-box,
|
||||
.public-board-row.board-color-cleargreen,
|
||||
.board-list li.board-color-cleargreen {
|
||||
background: linear-gradient(180deg, #8ad59f 0%, #4bbf6b 100%);
|
||||
background: linear-gradient(180deg, #8ad59f 0%, #277c48 100%);
|
||||
}
|
||||
.board-color-cleargreen .is-selected .minicard {
|
||||
border-inline-start: 3px solid #8ad59f;
|
||||
@@ -4658,7 +4669,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-color-cleargreen#header-quick-access,
|
||||
.board-color-cleargreen .setting-content .content-body .side-menu ul li.active,
|
||||
.board-color-cleargreen .setting-content .content-body .side-menu ul li.active:hover {
|
||||
background-color: #4bbf6b;
|
||||
background-color: #277c48;
|
||||
border-bottom: 1px solid #3c9956;
|
||||
border-top: 1px solid #2d7340;
|
||||
}
|
||||
@@ -4669,7 +4680,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-color-cleargreen .setting-content .content-body .side-menu ul li.active:hover,
|
||||
.board-color-cleargreen .boards-left-menu .menu-item.active a,
|
||||
.board-color-cleargreen .boards-left-menu .menu-item.active a:hover {
|
||||
background: linear-gradient(180deg, #8ad59f 0%, #4bbf6b 100%);
|
||||
background: linear-gradient(180deg, #8ad59f 0%, #277c48 100%);
|
||||
}
|
||||
.board-color-cleargreen#header #header-main-bar p {
|
||||
margin-bottom: 6px;
|
||||
@@ -4707,7 +4718,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
background-color: #42a85e;
|
||||
}
|
||||
.board-color-cleargreen.board-wrapper {
|
||||
background: linear-gradient(135deg, #8ad59f 0%, #4bbf6b 100%);
|
||||
background: linear-gradient(135deg, #8ad59f 0%, #277c48 100%);
|
||||
}
|
||||
.board-color-cleargreen .swimlane {
|
||||
background: none;
|
||||
@@ -4799,7 +4810,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
.board-color-cleargreen .quiet,
|
||||
.board-color-cleargreen .quiet a {
|
||||
color: rgba(0,0,0,0.4);
|
||||
color: rgba(0,0,0,0.68);
|
||||
}
|
||||
.board-color-cleargreen .list-header .list-header-watch-icon {
|
||||
color: rgba(0,0,0,0.5);
|
||||
@@ -4826,7 +4837,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
.board-color-cleargreen .sidebar-shadow {
|
||||
box-shadow: none;
|
||||
border-inline-start: 9px solid #4bbf6b;
|
||||
border-inline-start: 9px solid #277c48;
|
||||
}
|
||||
.board-color-cleargreen .is-open .sidebar-shadow {
|
||||
box-shadow: -10px 0 8px rgba(0,0,0,0.3);
|
||||
@@ -4891,7 +4902,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
|
||||
/* Apply scrollbar to sidebar content*/
|
||||
.board-color-cleargreen .sidebar .sidebar-content {
|
||||
scrollbar-color: #4bbf6b #ffffff00;
|
||||
scrollbar-color: #277c48 #ffffff00;
|
||||
}
|
||||
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
@@ -4910,7 +4921,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
/* ===============
|
||||
THEME - clearorange
|
||||
|
||||
A colour SLIDE, like clearblue: #efab6f down to #e67e22.
|
||||
A colour SLIDE, like clearblue: #efab6f down to #a75919.
|
||||
The base is pumpkin's own colour, so the flat theme and the slide theme are
|
||||
the same colour family and a reader picking either gets what they expected.
|
||||
|
||||
@@ -4927,7 +4938,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-backgrounds-list .board-color-clearorange.background-box,
|
||||
.public-board-row.board-color-clearorange,
|
||||
.board-list li.board-color-clearorange {
|
||||
background: linear-gradient(180deg, #efab6f 0%, #e67e22 100%);
|
||||
background: linear-gradient(180deg, #efab6f 0%, #a75919 100%);
|
||||
}
|
||||
.board-color-clearorange .is-selected .minicard {
|
||||
border-inline-start: 3px solid #efab6f;
|
||||
@@ -4954,7 +4965,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-color-clearorange#header-quick-access,
|
||||
.board-color-clearorange .setting-content .content-body .side-menu ul li.active,
|
||||
.board-color-clearorange .setting-content .content-body .side-menu ul li.active:hover {
|
||||
background-color: #e67e22;
|
||||
background-color: #a75919;
|
||||
border-bottom: 1px solid #b8651b;
|
||||
border-top: 1px solid #8a4c14;
|
||||
}
|
||||
@@ -4965,7 +4976,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-color-clearorange .setting-content .content-body .side-menu ul li.active:hover,
|
||||
.board-color-clearorange .boards-left-menu .menu-item.active a,
|
||||
.board-color-clearorange .boards-left-menu .menu-item.active a:hover {
|
||||
background: linear-gradient(180deg, #efab6f 0%, #e67e22 100%);
|
||||
background: linear-gradient(180deg, #efab6f 0%, #a75919 100%);
|
||||
}
|
||||
.board-color-clearorange#header #header-main-bar p {
|
||||
margin-bottom: 6px;
|
||||
@@ -5003,7 +5014,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
background-color: #ca6f1e;
|
||||
}
|
||||
.board-color-clearorange.board-wrapper {
|
||||
background: linear-gradient(135deg, #efab6f 0%, #e67e22 100%);
|
||||
background: linear-gradient(135deg, #efab6f 0%, #a75919 100%);
|
||||
}
|
||||
.board-color-clearorange .swimlane {
|
||||
background: none;
|
||||
@@ -5095,7 +5106,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
.board-color-clearorange .quiet,
|
||||
.board-color-clearorange .quiet a {
|
||||
color: rgba(0,0,0,0.4);
|
||||
color: rgba(0,0,0,0.68);
|
||||
}
|
||||
.board-color-clearorange .list-header .list-header-watch-icon {
|
||||
color: rgba(0,0,0,0.5);
|
||||
@@ -5122,7 +5133,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
.board-color-clearorange .sidebar-shadow {
|
||||
box-shadow: none;
|
||||
border-inline-start: 9px solid #e67e22;
|
||||
border-inline-start: 9px solid #a75919;
|
||||
}
|
||||
.board-color-clearorange .is-open .sidebar-shadow {
|
||||
box-shadow: -10px 0 8px rgba(0,0,0,0.3);
|
||||
@@ -5187,7 +5198,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
|
||||
/* Apply scrollbar to sidebar content*/
|
||||
.board-color-clearorange .sidebar .sidebar-content {
|
||||
scrollbar-color: #e67e22 #ffffff00;
|
||||
scrollbar-color: #a75919 #ffffff00;
|
||||
}
|
||||
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
@@ -5206,7 +5217,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
/* ===============
|
||||
THEME - clearpink
|
||||
|
||||
A colour SLIDE, like clearblue: #df94b8 down to #cd5a91.
|
||||
A colour SLIDE, like clearblue: #df94b8 down to #bd3a79.
|
||||
The base is moderatepink's own colour, so the flat theme and the slide theme are
|
||||
the same colour family and a reader picking either gets what they expected.
|
||||
|
||||
@@ -5223,7 +5234,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-backgrounds-list .board-color-clearpink.background-box,
|
||||
.public-board-row.board-color-clearpink,
|
||||
.board-list li.board-color-clearpink {
|
||||
background: linear-gradient(180deg, #df94b8 0%, #cd5a91 100%);
|
||||
background: linear-gradient(180deg, #df94b8 0%, #bd3a79 100%);
|
||||
}
|
||||
.board-color-clearpink .is-selected .minicard {
|
||||
border-inline-start: 3px solid #df94b8;
|
||||
@@ -5250,7 +5261,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-color-clearpink#header-quick-access,
|
||||
.board-color-clearpink .setting-content .content-body .side-menu ul li.active,
|
||||
.board-color-clearpink .setting-content .content-body .side-menu ul li.active:hover {
|
||||
background-color: #cd5a91;
|
||||
background-color: #bd3a79;
|
||||
border-bottom: 1px solid #a44874;
|
||||
border-top: 1px solid #7b3657;
|
||||
}
|
||||
@@ -5261,7 +5272,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-color-clearpink .setting-content .content-body .side-menu ul li.active:hover,
|
||||
.board-color-clearpink .boards-left-menu .menu-item.active a,
|
||||
.board-color-clearpink .boards-left-menu .menu-item.active a:hover {
|
||||
background: linear-gradient(180deg, #df94b8 0%, #cd5a91 100%);
|
||||
background: linear-gradient(180deg, #df94b8 0%, #bd3a79 100%);
|
||||
}
|
||||
.board-color-clearpink#header #header-main-bar p {
|
||||
margin-bottom: 6px;
|
||||
@@ -5299,7 +5310,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
background-color: #b44f80;
|
||||
}
|
||||
.board-color-clearpink.board-wrapper {
|
||||
background: linear-gradient(135deg, #df94b8 0%, #cd5a91 100%);
|
||||
background: linear-gradient(135deg, #df94b8 0%, #bd3a79 100%);
|
||||
}
|
||||
.board-color-clearpink .swimlane {
|
||||
background: none;
|
||||
@@ -5391,7 +5402,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
.board-color-clearpink .quiet,
|
||||
.board-color-clearpink .quiet a {
|
||||
color: rgba(0,0,0,0.4);
|
||||
color: rgba(0,0,0,0.68);
|
||||
}
|
||||
.board-color-clearpink .list-header .list-header-watch-icon {
|
||||
color: rgba(0,0,0,0.5);
|
||||
@@ -5418,7 +5429,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
.board-color-clearpink .sidebar-shadow {
|
||||
box-shadow: none;
|
||||
border-inline-start: 9px solid #cd5a91;
|
||||
border-inline-start: 9px solid #bd3a79;
|
||||
}
|
||||
.board-color-clearpink .is-open .sidebar-shadow {
|
||||
box-shadow: -10px 0 8px rgba(0,0,0,0.3);
|
||||
@@ -5483,7 +5494,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
|
||||
/* Apply scrollbar to sidebar content*/
|
||||
.board-color-clearpink .sidebar .sidebar-content {
|
||||
scrollbar-color: #cd5a91 #ffffff00;
|
||||
scrollbar-color: #bd3a79 #ffffff00;
|
||||
}
|
||||
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
@@ -5687,7 +5698,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
.board-color-clearpurple .quiet,
|
||||
.board-color-clearpurple .quiet a {
|
||||
color: rgba(0,0,0,0.4);
|
||||
color: rgba(0,0,0,0.68);
|
||||
}
|
||||
.board-color-clearpurple .list-header .list-header-watch-icon {
|
||||
color: rgba(0,0,0,0.5);
|
||||
@@ -5983,7 +5994,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
.board-color-clearred .quiet,
|
||||
.board-color-clearred .quiet a {
|
||||
color: rgba(0,0,0,0.4);
|
||||
color: rgba(0,0,0,0.68);
|
||||
}
|
||||
.board-color-clearred .list-header .list-header-watch-icon {
|
||||
color: rgba(0,0,0,0.5);
|
||||
@@ -6090,3 +6101,98 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
|
||||
/* === END clearred THEME === */
|
||||
|
||||
/* The quiet text colour (#595959 on the light themes' greys, WCAG 1.4.3) is a
|
||||
variable, so the themes whose popups and sidebars are DARK can have a light
|
||||
one: a dark grey on a dark surface was nearly invisible (#6863). cleandark and
|
||||
moderndark are those; dark and exodark keep light popups and sidebars, where
|
||||
the dark grey is right. Popups carry the theme class themselves, so the
|
||||
variable reaches them. */
|
||||
:is(
|
||||
.board-color-cleandark,
|
||||
.board-color-moderndark
|
||||
) {
|
||||
--wekan-quiet-text: #b4b4b4;
|
||||
/* Card Settings: its sections are light grey blocks (cardAddFields.css), and
|
||||
these themes' text on them is white (1.07:1 in cleandark). */
|
||||
--card-settings-section-bg: rgba(255, 255, 255, 0.08);
|
||||
--card-settings-helper-bg: #3a3a46;
|
||||
--card-settings-notch-bg: #2c3340;
|
||||
}
|
||||
/* moderndark's surfaces are lighter greys (#545454): the quiet text needs more. */
|
||||
.board-color-moderndark {
|
||||
--wekan-quiet-text: #dcdcdc;
|
||||
}
|
||||
/* The board sidebar's "Keyboard shortcuts" row is outside .sidebar-content, so
|
||||
the white of its buttons does not reach it, and sidebar.css makes it black:
|
||||
black on these sidebars was 1.34:1. */
|
||||
:is(
|
||||
.board-color-cleandark,
|
||||
.board-color-moderndark
|
||||
) .sidebar .sidebar-shortcuts {
|
||||
color: #fff;
|
||||
}
|
||||
/* moderndark: grey (#808080) on its near-black sidebar was 4.02. */
|
||||
.board-color-moderndark :is(.js-open-board-menu, h3[data-fold], .activity-title) {
|
||||
color: var(--wekan-quiet-text);
|
||||
}
|
||||
/* dark: the swimlane header's text was black on its #2c3e50 bar (1.91:1). */
|
||||
.board-color-dark .swimlane-header {
|
||||
color: #fff;
|
||||
}
|
||||
/* dark and exodark: the board, the opened card and the sidebar are dark surfaces
|
||||
(#222, #2b2b2b, #2c3e50). Their popups differ: exodark's is dark too (above),
|
||||
dark's is light, so the dark theme's popups keep the dark quiet text. */
|
||||
:is(
|
||||
.board-color-dark,
|
||||
.board-color-exodark
|
||||
) :is(.board-canvas, .card-details, .sidebar) {
|
||||
--wekan-quiet-text: #b4b4b4;
|
||||
}
|
||||
/* A "quiet button" (Add custom field, Link fields to another card, ...) is #222 in
|
||||
forms.css, for a light surface; on a dark popup or sidebar it was 1.18:1 (#6863). */
|
||||
:is(
|
||||
.board-color-cleandark,
|
||||
.board-color-moderndark
|
||||
) .quiet-button,
|
||||
:is(
|
||||
.board-color-cleandark,
|
||||
.board-color-moderndark,
|
||||
.board-color-exodark
|
||||
).pop-over .quiet-button {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
/* The board sidebar of the dark themes: what is not a button or a heading
|
||||
(a form label, the list of swimlanes' lists and cards) kept the light
|
||||
sidebar's black: 1.34:1 on cleandark's #23232b (#6863). */
|
||||
:is(
|
||||
.board-color-cleandark,
|
||||
.board-color-moderndark
|
||||
) .sidebar .sidebar-content :is(label, .minilists, .minicards) {
|
||||
color: #fff;
|
||||
}
|
||||
/* Popup items and sidebar lists: popup.css forces `.pop-over-list li > a` to black
|
||||
with !important, which is right on a light surface. moderndark's popups and
|
||||
sidebar and exodark's popups are dark: black on #454545 was 2.19:1, on #2b2b2b
|
||||
1.48:1 (#6863). */
|
||||
:is(
|
||||
.board-color-moderndark,
|
||||
.board-color-exodark
|
||||
).pop-over .pop-over-list li > a,
|
||||
:is(
|
||||
.board-color-moderndark,
|
||||
.board-color-exodark
|
||||
).pop-over .pop-over-list li > a *,
|
||||
.board-color-moderndark .sidebar .pop-over-list li > a,
|
||||
.board-color-moderndark .sidebar .pop-over-list li > a * {
|
||||
color: #fff !important;
|
||||
}
|
||||
/* moderndark: the sidebar's headings were #808080 on #222 (4.02:1). */
|
||||
.board-color-moderndark .sidebar .sidebar-content h3 {
|
||||
color: var(--wekan-quiet-text);
|
||||
}
|
||||
/* moderndark: the selected minicard's title was #ccc on #666 (3.57:1). */
|
||||
.board-color-moderndark .is-selected > .minicard .minicard-title,
|
||||
.board-color-moderndark .is-selected > .minicard .minicard-title * {
|
||||
color: #fff;
|
||||
}
|
||||
@@ -173,7 +173,7 @@
|
||||
crossed it - which is the bug that had to be fixed in the Admin Panel's. */
|
||||
.boards-left-menu .menu-item.active a,
|
||||
.boards-left-menu .menu-item.active a:hover {
|
||||
background: var(--theme-accent, #2980b9);
|
||||
background: var(--theme-accent, #2573a7);
|
||||
}
|
||||
/* The Admin Panel puts the selected background on the LI, not the anchor, so
|
||||
the fill reaches the row's full width. Here the anchor is what fills, so it
|
||||
@@ -215,7 +215,7 @@
|
||||
docs/Features/Pages/Starred.md */
|
||||
.board-list .board-list-item-bookmark .board-list-item {
|
||||
border: 4px solid #fff;
|
||||
background: var(--theme-accent, #2980b9);
|
||||
background: var(--theme-accent, #2573a7);
|
||||
color: #fff;
|
||||
/* Room at the end for the unstar button, which sits over the tile. */
|
||||
padding-inline-end: 34px;
|
||||
@@ -481,7 +481,7 @@
|
||||
content: '';
|
||||
display: block;
|
||||
height: 30px;
|
||||
border: 2px dashed var(--theme-accent, #2980b9);
|
||||
border: 2px dashed var(--theme-accent, #2573a7);
|
||||
border-radius: 4px;
|
||||
background: rgba(41, 128, 185, 0.08);
|
||||
margin: 2px 0;
|
||||
@@ -694,7 +694,7 @@
|
||||
as the pointer crossed it. */
|
||||
.workspace-node.active > .workspace-node-content .js-select-space,
|
||||
.workspace-node.active > .workspace-node-content:hover .js-select-space {
|
||||
background: var(--theme-accent, #2980b9);
|
||||
background: var(--theme-accent, #2573a7);
|
||||
}
|
||||
.workspace-node.active > .workspace-node-content .js-select-space,
|
||||
.workspace-node.active > .workspace-node-content .js-select-space i {
|
||||
@@ -1046,7 +1046,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #999; /* Darker background for better text contrast */
|
||||
background-color: #767676; /* Grey with white text: 4.54, above the 4.5 of WCAG 1.4.3 (#999 gave 2.84) */
|
||||
border-radius: 3px;
|
||||
/* Same total vertical padding as .board-list-item (42px -> same tile height),
|
||||
but shifted up (top 24->18, bottom 18->24) so the "+ Add Board" text sits
|
||||
@@ -1661,7 +1661,7 @@
|
||||
|
||||
.js-board {
|
||||
display: block;
|
||||
background-color: #999; /* Default gray background if no color class is applied */
|
||||
background-color: #767676; /* Default gray background if no color class is applied */
|
||||
border-radius: 3px; /* Rounded corners for board items */
|
||||
overflow: hidden; /* Ensure children respect rounded corners */
|
||||
margin: 0; /* grid gap spaces the tiles now (#6523) */
|
||||
@@ -1724,7 +1724,7 @@
|
||||
chose, not a colour, and painting over it would be losing something rather
|
||||
than calming it down. */
|
||||
body.has-theme-board-tiles .board-list > li.js-board {
|
||||
background: var(--theme-accent-fill, var(--theme-accent, #2980b9));
|
||||
background: var(--theme-accent-fill, var(--theme-accent, #2573a7));
|
||||
}
|
||||
body.has-theme-board-tiles .board-list > li.js-board .board-list-item:not(.has-background-image) {
|
||||
background-color: transparent;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { ensureContrast } from '/models/lib/contrastColor';
|
||||
import { surfacesFor } from '/models/lib/uiFonts';
|
||||
import { activeThemeName } from '/client/lib/activeTheme';
|
||||
import {
|
||||
DEPENDENCY_ICON_CHOICES,
|
||||
DEPENDENCY_TYPES,
|
||||
@@ -138,13 +141,17 @@ Template.dependencyOverlay.onCreated(function () {
|
||||
const y2 = dependent.y + dependent.h / 2;
|
||||
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
|
||||
const path = `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
||||
const markerId = markerIdForColor(dep.color);
|
||||
// The colour is the member's free choice (a colour wheel), drawn over the
|
||||
// board: made visible against the theme's surface - a line is a graphic
|
||||
// that says something, 3:1 (WCAG 1.4.11). The stored colour is untouched.
|
||||
const drawColor = ensureContrast(dep.color, surfacesFor(activeThemeName()), 3);
|
||||
const markerId = markerIdForColor(drawColor);
|
||||
if (meta.directed) {
|
||||
markerColors.add(dep.color);
|
||||
markerColors.add(drawColor);
|
||||
}
|
||||
lines.push({
|
||||
path,
|
||||
color: dep.color,
|
||||
color: drawColor,
|
||||
type: dep.type,
|
||||
icon: dep.icon,
|
||||
directed: meta.directed,
|
||||
|
||||
@@ -23,9 +23,9 @@
|
||||
* so the row keeps the table's white background: with the light text below that
|
||||
* is white on white, and only the row's emoji was visible. The All Boards tile
|
||||
* has the same problem and solves it the same way — `.board-list-item` falls
|
||||
* back to a grey (#999) so its white text always has something to sit on. */
|
||||
* back to a grey (#767676) so its white text always has something to sit on. */
|
||||
.public-board-row {
|
||||
background-color: #999;
|
||||
background-color: #767676;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ template(name="attachmentGallery")
|
||||
.attachment-gallery
|
||||
|
||||
//- #6796: a small "+" like every other section's, not a full-width box.
|
||||
if canModifyCard
|
||||
if canAddAttachment
|
||||
a.card-label.add-label.add-attachment.js-add-attachment(title="{{_ 'add-attachment'}}" aria-label="{{_ 'add-attachment'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManag
|
||||
import Attachments from '/models/attachments';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { buildAttachmentUploadConfig } from '/client/lib/attachmentUploadConfig';
|
||||
import { attachmentUploadsBlocked } from '/client/lib/attachmentUploadPolicy';
|
||||
const { cleanFileName } = require('/imports/lib/fileNameDisplay');
|
||||
const { attachmentTileCompact } = require('/models/lib/attachmentTile.cjs');
|
||||
import { formatDateTime } from '/imports/lib/dateUtils';
|
||||
@@ -338,8 +339,8 @@ function openPrevAttachment() {
|
||||
|
||||
function processTouch(){
|
||||
|
||||
xDist = touchEndCoords.x - touchStartCoords.x;
|
||||
yDist = touchEndCoords.y - touchStartCoords.y;
|
||||
const xDist = touchEndCoords.x - touchStartCoords.x;
|
||||
const yDist = touchEndCoords.y - touchStartCoords.y;
|
||||
|
||||
// Left swipe
|
||||
if (Math.abs(xDist) > Math.abs(yDist) && xDist < 0) {
|
||||
@@ -467,6 +468,10 @@ Template.attachmentGallery.onCreated(function () {
|
||||
});
|
||||
|
||||
Template.attachmentGallery.helpers({
|
||||
// #6870: the "+" button - a card the user may edit, unless uploads are blocked site-wide
|
||||
canAddAttachment() {
|
||||
return Utils.canModifyCard() && !attachmentUploadsBlocked();
|
||||
},
|
||||
// #6796: the small file button instead of the preview tile
|
||||
// (models/lib/attachmentTile.cjs).
|
||||
attachmentTileCompact(file) {
|
||||
@@ -672,6 +677,9 @@ export async function handleFileUpload(card, files) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// #6870: the administrator blocked uploads for the whole site; the server refuses them too
|
||||
if (attachmentUploadsBlocked()) return [];
|
||||
|
||||
// Check if board allows attachments
|
||||
const board = card.board();
|
||||
if (!board || !board.allowsAttachments) {
|
||||
|
||||
@@ -1,5 +1,285 @@
|
||||
/* "+ Add" on the opened card (docs/Features/Cards/Card-Fields.md): it opens
|
||||
Board Settings / Card for this card (sidebar.css .card-settings-sections). */
|
||||
.card-details-add-fields { padding: 4px 0 2px; }
|
||||
.card-details-add-fields-button { display: inline-flex; align-items: center; gap: 0.3em; padding: 2px 8px; border-radius: 3px; }
|
||||
.card-details-add-fields-button:hover { background: rgba(0, 0, 0, 0.06); }
|
||||
/* 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: var(--wekan-quiet-text, #595959);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-settings-pane-collapse:hover,
|
||||
.card-settings-pane-collapse:focus-visible { color: #2573a7; }
|
||||
.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 {
|
||||
/* The checklist's grey block; a dark theme has its own (#6863: white text on
|
||||
this light grey was 1.07:1 in cleandark). */
|
||||
background: var(--card-settings-section-bg, #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: var(--wekan-quiet-text, #595959); white-space: nowrap; }
|
||||
.card-settings-inline-collapse,
|
||||
.card-settings-inline-section-move,
|
||||
.card-settings-inline-menu {
|
||||
padding: 2px 4px;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
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: #2573a7; }
|
||||
/* 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: var(--wekan-quiet-text, #595959);
|
||||
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: #2573a7; }
|
||||
/* 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: var(--card-settings-helper-bg, #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: #2573a7;
|
||||
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: var(--card-settings-notch-bg, #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: var(--wekan-quiet-text, #595959);
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.card-settings-inline-panel-close:hover,
|
||||
.card-settings-inline-panel-close:focus-visible { color: #2573a7; }
|
||||
.card-settings-section-action-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
@@ -1,69 +1,29 @@
|
||||
import './cardDetails.jade';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
|
||||
// docs/Features/Cards/Card-Fields.md: an opened card shows only the fields it
|
||||
// has data in - and what its board always shows (by default its description,
|
||||
// attachments and comments), empty or not - and "+ Add" chooses the rest. The rules are models/lib/cardShownFields.js.
|
||||
// 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');
|
||||
|
||||
// What lives in other collections, for "has data".
|
||||
function countOf(list) {
|
||||
if (!list) return 0;
|
||||
if (typeof list.length === 'number') return list.length;
|
||||
return typeof list.count === 'function' ? list.count() : 0;
|
||||
}
|
||||
|
||||
export function cardFieldCounts(card) {
|
||||
if (!card || !card._id) return {};
|
||||
return {
|
||||
checklists: typeof card.checklists === 'function' ? countOf(card.checklists()) : 0,
|
||||
subtasks: typeof card.subtasks === 'function' ? countOf(card.subtasks()) : 0,
|
||||
textNotes: countOf(ReactiveCache.getCardTextNotes({ cardId: card._id })),
|
||||
// For a board that does not always show these: shown when there are some.
|
||||
attachments: typeof card.attachments === 'function' ? countOf(card.attachments()) : 0,
|
||||
comments: typeof card.comments === 'function' ? countOf(card.comments()) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// The card's document, with its methods, for a template's data.
|
||||
function cardOf(data) {
|
||||
if (!data || !data._id) return null;
|
||||
return ReactiveCache.getCard(data._id) || data;
|
||||
}
|
||||
|
||||
// What decides which fields `card` shows: its DATA, which a linked card keeps
|
||||
// on its source card (members, labels, dates, custom fields, checklists ...),
|
||||
// and its own choices (shownFields / hiddenFields, set on the card opened).
|
||||
// Reading the linked card's own empty document hid every field its source
|
||||
// has (the #1946/#6613 browser test). Returns { card, counts }.
|
||||
export function shownFieldsOf(card) {
|
||||
if (!card) return { card: null, counts: {} };
|
||||
const real = typeof card.getRealCard === 'function' ? (card.getRealCard() || card) : card;
|
||||
if (real === card) return { card, counts: cardFieldCounts(card) };
|
||||
return {
|
||||
card: { ...real, shownFields: card.shownFields, hiddenFields: card.hiddenFields },
|
||||
counts: cardFieldCounts(real),
|
||||
};
|
||||
}
|
||||
|
||||
// The board as this card sees it (cardShownFields.js boardForCard).
|
||||
export function cardBoardView(data) {
|
||||
const board = Utils.getCurrentBoard();
|
||||
const card = cardOf(data);
|
||||
if (!board || !card || card.boardId !== board._id) return board;
|
||||
const view = shownFieldsOf(card);
|
||||
return f.boardForCard(board, view.card, view.counts);
|
||||
// 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 this card's view - and a template not
|
||||
// listed here (the minicard, the board) is unchanged.
|
||||
// `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([
|
||||
'cardDetails', 'cardFieldSectionLabels', 'cardFieldSectionDates', 'cardFieldSectionMembers',
|
||||
'cardFieldSectionDependencies', 'cardFieldSectionSort', 'cardFieldSectionCustomFields',
|
||||
'cardFieldSectionVoteAndPoker', 'cardFieldSectionDescription', 'cardCommentsSection',
|
||||
// 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({
|
||||
@@ -73,26 +33,3 @@ CARD_FIELD_TEMPLATES.forEach(name => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Whether this card shows the field `key` - for a section with no board flag
|
||||
// of its own to read (the Scrum fields, cardDetails.js orderedCardFieldSections).
|
||||
export function cardShowsField(data, key) {
|
||||
const card = cardOf(data);
|
||||
if (!card) return true;
|
||||
const view = shownFieldsOf(card);
|
||||
return f.isShown(key, view.card, view.counts, Utils.getCurrentBoard());
|
||||
}
|
||||
|
||||
// "+ Add" opens Board Settings / Card - the same popup - for this card: its
|
||||
// sections, each row's "Show on Minicard" and "Show on Card", and whether this
|
||||
// card shows the field (client/components/sidebar/sidebar.js). Overriding
|
||||
// `currentData` is what reaches the popup's data context (as Board Settings'
|
||||
// own opener does).
|
||||
Template.cardDetails.events({
|
||||
'click .js-open-card-add-fields'(event) {
|
||||
const card = cardOf(Template.currentData());
|
||||
if (!card) return;
|
||||
const data = { card };
|
||||
Popup.open('boardCardSettings').call({ currentData: () => data }, event);
|
||||
},
|
||||
});
|
||||
@@ -38,7 +38,7 @@
|
||||
background-color: #fff;
|
||||
border: 0;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.23);
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
height: 36px;
|
||||
margin: 4px 4px 6px 0;
|
||||
width: 92%;
|
||||
|
||||
@@ -3,5 +3,5 @@ template(name="descriptionForm")
|
||||
.new-description.js-new-description(
|
||||
class="{{#if descriptionFormIsOpen}}is-open{{/if}}")
|
||||
form.js-new-description-form
|
||||
+editor(class="js-new-description-input" autofocus="autofocus")
|
||||
+editor(class="js-new-description-input" autofocus="autofocus" labelKey="description")
|
||||
| {{getUnsavedValue 'cardDescription' _id getDescription}}
|
||||
@@ -185,6 +185,23 @@ body.desktop-mode .card-details:not(.card-details-popup):not(.card-details-colla
|
||||
bottom: 8px;
|
||||
}
|
||||
|
||||
/* A phone in desktop mode: the header bar wraps to several rows (up to five with
|
||||
the board controls open), far taller than the 8px the floating window docks
|
||||
at, so the card's own top row (caret, number, menu, X) sat among the header's
|
||||
rows and was only seen by dragging the card down. Below 801px the card starts
|
||||
BELOW the header, at the height Utils publishes as --wekan-header-height.
|
||||
Only a card WITHOUT an inline top matches (`:not([style*="top"])`), so the
|
||||
place a finger dragged it to is an inline top and is kept when the finger is
|
||||
lifted - this rule never competes with it. */
|
||||
@media screen and (max-width: 800px) {
|
||||
body.desktop-mode .card-details:not(.card-details-popup):not([style*="top"]),
|
||||
/* a card opened as a popup (search result, table, Roadmap bar) docks the same way */
|
||||
body.desktop-mode .card-details.card-details-popup:not([style*="top"]),
|
||||
body.desktop-mode .card-details.card-details-maximized:not(.card-details-popup):not(.card-details-collapsed) {
|
||||
top: calc(var(--wekan-header-height, 0px) + 8px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* #4822: the MAXIMIZED card must be anchored to the VIEWPORT (position: fixed
|
||||
with viewport insets), never laid out inside — or measured from — the
|
||||
scrolled swimlane/board container. Historically the maximized pane was an
|
||||
@@ -250,6 +267,24 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
color: #b3b3b3;
|
||||
display: inline-block;
|
||||
margin-inline-end: 6px;
|
||||
/* Above the title's edit zone (z-index 1, over the whole title): a press on the
|
||||
card number moves the window like the rest of the header row; only the title
|
||||
itself edits. */
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* The areas that move the window by a finger. Without touch-action: none the browser
|
||||
takes a finger drag for a scroll and cancels the pointer, so the window never moves
|
||||
(the page behind it scrolls instead). Only in desktop mode, where the card is a
|
||||
window; the full-screen card of mobile mode is not moved. */
|
||||
body.desktop-mode .card-details .card-details-header,
|
||||
body.desktop-mode .card-details .card-details-header .card-drag-handle {
|
||||
touch-action: none;
|
||||
}
|
||||
body.card-window-dragging {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* Collapse toggle triangle */
|
||||
@@ -366,43 +401,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;
|
||||
@@ -536,6 +571,19 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
resize: both;
|
||||
}
|
||||
|
||||
/* A card opened as a POPUP (Session popupCardId: from a search result, a table
|
||||
or a Roadmap/Gantt bar) docks at the very top of the window like every other
|
||||
opened card, and covers the header bars, as the .pop-over[data-popup=
|
||||
"cardDetailsPopup"] rule near the end of this file says it must. The 97px
|
||||
above is the room the header takes for a card that is NOT docked by the
|
||||
desktop-mode rules (those skip popups), and it opened the popup BELOW the
|
||||
header: collapsed, it was a bar floating over the swimlane. Mobile mode keeps
|
||||
its own full-window rule (top: 0 !important). */
|
||||
.card-details.card-details-popup {
|
||||
top: 8px;
|
||||
z-index: 2001;
|
||||
}
|
||||
|
||||
/* Override for mobile mode even on larger screens */
|
||||
body.mobile-mode .card-details {
|
||||
width: 100vw !important;
|
||||
@@ -846,15 +894,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;
|
||||
@@ -865,15 +904,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;
|
||||
@@ -1114,7 +1144,7 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
}
|
||||
.card-details-item-location .card-location-remove {
|
||||
margin-inline-start: 8px;
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-details-item-location .card-location-remove:hover {
|
||||
@@ -1230,8 +1260,8 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
and Activities - has one rule above it and a caret in its heading that
|
||||
collapses it. The rule comes from the same template as the caret, so eleven
|
||||
sections cannot end up with ten separators.
|
||||
The heading is a button: it says so to a screen reader (role/tabindex in the
|
||||
template) and it looks like one to a mouse. */
|
||||
The heading holds a real button (cardSectionHeader in the template) that a
|
||||
screen reader and the keyboard reach, and it looks like one to a mouse. */
|
||||
/* The GROUP is the full-width row of the card, and the fields inside it share
|
||||
rows within it - which is what puts Assignee and Creator beside Members
|
||||
rather than under it. The class that made an individual field full width is
|
||||
@@ -1266,11 +1296,32 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
changes length, so it gets a fixed box. `text-align: center` rather than a
|
||||
side, because the glyph itself differs between the open and closed states
|
||||
(down, and right or left) and a fixed side would make it jump. */
|
||||
.card-details h3.card-details-item-title.js-toggle-card-section > .fa:first-child {
|
||||
.card-details h3.card-details-item-title.js-toggle-card-section .card-details-section-toggle > .fa:first-child {
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
/* The button inside the heading looks like the heading: no button chrome, the
|
||||
heading's own size, weight and colour, the caret, icon and label in a row. */
|
||||
.card-details h3.card-details-item-title .card-details-section-toggle {
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font: inherit;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-align: start;
|
||||
width: auto;
|
||||
}
|
||||
.card-details h3.card-details-item-title .card-details-section-toggle:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.card-details h3.card-details-item-title.js-toggle-card-section:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
@@ -1449,3 +1500,27 @@ body.desktop-mode .card-details.card-details-position-pending:not(.card-details-
|
||||
/* 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
@@ -24,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';
|
||||
|
||||
@@ -37,26 +38,7 @@ import {
|
||||
datePickerRendered,
|
||||
datePickerHelpers,
|
||||
} from '/client/lib/datepicker';
|
||||
import {
|
||||
formatDateTime,
|
||||
formatDate,
|
||||
formatTime,
|
||||
getISOWeek,
|
||||
isValidDate,
|
||||
isBefore,
|
||||
isAfter,
|
||||
isSame,
|
||||
add,
|
||||
subtract,
|
||||
startOf,
|
||||
endOf,
|
||||
format,
|
||||
parseDate,
|
||||
now,
|
||||
createDate,
|
||||
fromNow,
|
||||
calendar
|
||||
} from '/imports/lib/dateUtils';
|
||||
import { formatDateTime, add, now } from '/imports/lib/dateUtils';
|
||||
import Cards from '/models/cards';
|
||||
import Boards from '/models/boards';
|
||||
import Checklists from '/models/checklists';
|
||||
@@ -99,6 +81,7 @@ const editingLocationId = new ReactiveVar(null);
|
||||
// Shared with Admin Panel, which links the places accounts log in from the same
|
||||
// way - models/lib/mapLink.js.
|
||||
const { mapLinkFor, GCJ } = require('/models/lib/mapLink');
|
||||
const { cardHeaderPress, dragThreshold, passedThreshold, clampWindow } = require('/models/lib/windowDrag');
|
||||
|
||||
|
||||
|
||||
@@ -700,9 +683,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();
|
||||
@@ -754,14 +740,15 @@ Template.registerHelper('cardSwimlaneTitle', function cardSwimlaneTitle() {
|
||||
return swimlane && swimlane.title;
|
||||
});
|
||||
|
||||
// The Custom Fields section: on a card that has a custom field value, or that
|
||||
// ticked Custom Fields in "+ Add" (docs/Features/Cards/Card-Fields.md) - not on
|
||||
// every card, where it was an empty heading. "+ Add" is how a card with none
|
||||
// gets the section, and its hamburger then adds the fields.
|
||||
// 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 { cardShowsField } = require('/client/components/cards/cardAddFields');
|
||||
const board = this?.board?.();
|
||||
return Utils.canModifyCard(this) && board?.allowsCustomFields !== false && cardShowsField(this, 'customFields');
|
||||
const { boardForCard } = require('/models/lib/cardShownFields');
|
||||
const board = boardForCard(this?.board?.());
|
||||
return Utils.canModifyCard(this) && board?.allowsCustomFields !== false;
|
||||
});
|
||||
|
||||
Template.registerHelper('stickers', function stickers() {
|
||||
@@ -891,54 +878,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));
|
||||
});
|
||||
}
|
||||
|
||||
// The card's sections in the board's order, less the Scrum fields this card
|
||||
// does not show - a Scrum field is its own section with no board flag in the
|
||||
// template to read (docs/Features/Cards/Card-Fields.md).
|
||||
function orderedSectionsOf(card) {
|
||||
const board = card?.board?.();
|
||||
let sections;
|
||||
if (board && typeof board.getCardFieldOrder === 'function') {
|
||||
sections = board.getCardFieldOrder();
|
||||
} else {
|
||||
const { applyCardFieldOrder } = require('/models/lib/cardFieldOrder');
|
||||
sections = applyCardFieldOrder(board?.cardFieldOrder);
|
||||
// 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;
|
||||
}
|
||||
const { cardShowsField } = require('/client/components/cards/cardAddFields');
|
||||
return sections.filter(s => !/^scrum[A-Z]/.test(s) || cardShowsField(card, s));
|
||||
}
|
||||
|
||||
Template.cardFieldSectionSort.helpers({
|
||||
// The card's own view of the board (docs/Features/Cards/Card-Fields.md), as
|
||||
// the fields inside read it: reading the board itself drew the group's rule
|
||||
// for fields the card does not show - an empty group, and a second line
|
||||
// under "+ Add".
|
||||
hasVisibleSortFields() {
|
||||
const board = cardBoardView(this);
|
||||
return !!(board && (
|
||||
board.allowsCardSortingByNumber || board.allowsShowLists ||
|
||||
board.allowsFlowtime || board.allowsPomodoro ||
|
||||
// The card's own view already says whether Spent time is shown: with
|
||||
// hours, or ticked "On this card" to be filled in.
|
||||
board.allowsSpentTime
|
||||
));
|
||||
// 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');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -950,18 +1005,10 @@ 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() {
|
||||
return orderedSectionsOf(this);
|
||||
},
|
||||
// The first Scrum field this card shows: where the one "Scrum settings"
|
||||
// heading goes, over all of them (docs/Features/Cards/Card-Fields.md).
|
||||
firstScrumSection() {
|
||||
return orderedSectionsOf(this).find(s => /^scrum[A-Z]/.test(s)) || null;
|
||||
return orderedSectionsOf(this, { maximized: isCardMaximized() });
|
||||
},
|
||||
|
||||
// Placed beside its minicard: card-details-position-pending is off for good.
|
||||
@@ -971,39 +1018,41 @@ Template.cardDetails.helpers({
|
||||
},
|
||||
|
||||
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;
|
||||
@@ -1045,10 +1094,6 @@ Template.cardDetails.helpers({
|
||||
return user && user.isVerticalScrollbars();
|
||||
},
|
||||
|
||||
checklistSectionOpen() {
|
||||
return ReactiveCache.getBoard(this.boardId)?.allowsChecklistTitle === false || isCardSectionOpen('checklists');
|
||||
},
|
||||
|
||||
isLoaded() {
|
||||
return Template.instance().isLoaded;
|
||||
},
|
||||
@@ -1056,9 +1101,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.
|
||||
@@ -1069,9 +1114,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'];
|
||||
|
||||
@@ -1144,18 +1191,68 @@ Template.cardSectionHeader.events({
|
||||
if (!section) return;
|
||||
cardSectionState.set(section, !isCardSectionOpen(section));
|
||||
},
|
||||
// A heading that can be operated with the mouse can be operated with the
|
||||
// keyboard: it carries role="button" and tabindex, so Enter and Space are
|
||||
// what a screen reader user presses.
|
||||
'keydown .js-toggle-card-section'(event) {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
const section = event.currentTarget.dataset.section;
|
||||
if (section) cardSectionState.set(section, !isCardSectionOpen(section));
|
||||
},
|
||||
});
|
||||
|
||||
// Moves the card window under a finger or a mouse. `press` is cardHeaderPress's answer.
|
||||
// A press on a control waits until the pointer has really moved, so a tap still
|
||||
// collapses, closes or opens the menu; once it moved, the click that follows the drag
|
||||
// is swallowed.
|
||||
function beginCardWindowDrag(jqEvent, press) {
|
||||
// Blaze hands over a jQuery event, which does not copy `isPrimary`; the browser's
|
||||
// own event has it.
|
||||
const event = jqEvent.originalEvent || jqEvent;
|
||||
if (!press.start || !event.isPrimary) return;
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||
|
||||
const $card = $(event.target).closest('.card-details');
|
||||
if (!$card.length) return;
|
||||
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const offset = $card.offset();
|
||||
const startLeft = offset.left - window.scrollX;
|
||||
const startTop = offset.top - window.scrollY;
|
||||
const threshold = dragThreshold(event.pointerType, press.deferred);
|
||||
const pointerId = event.pointerId;
|
||||
let moving = false;
|
||||
|
||||
const onMove = (e) => {
|
||||
if (e.pointerId !== pointerId) return;
|
||||
const dx = e.clientX - startX;
|
||||
const dy = e.clientY - startY;
|
||||
if (!moving) {
|
||||
if (!passedThreshold(dx, dy, threshold)) return;
|
||||
moving = true;
|
||||
// #6465: where the user put it wins over where the minicard is, so a later
|
||||
// resize re-places every other window but only clamps this one into view.
|
||||
markCardDetailsUserMoved($card);
|
||||
document.body.classList.add('card-window-dragging');
|
||||
}
|
||||
e.preventDefault();
|
||||
const placed = clampWindow(startLeft + dx, startTop + dy, $card.outerWidth(),
|
||||
{ width: window.innerWidth, height: window.innerHeight });
|
||||
$card.css({ left: `${placed.left}px`, top: `${placed.top}px` });
|
||||
};
|
||||
|
||||
const onEnd = (e) => {
|
||||
if (e.pointerId !== pointerId) return;
|
||||
document.removeEventListener('pointermove', onMove);
|
||||
document.removeEventListener('pointerup', onEnd);
|
||||
document.removeEventListener('pointercancel', onEnd);
|
||||
document.body.classList.remove('card-window-dragging');
|
||||
if (moving && press.deferred) {
|
||||
// The drag started on a link or a button: its click is not a tap.
|
||||
const swallow = (click) => { click.preventDefault(); click.stopPropagation(); };
|
||||
document.addEventListener('click', swallow, { capture: true, once: true });
|
||||
setTimeout(() => document.removeEventListener('click', swallow, { capture: true }), 400);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('pointermove', onMove, { passive: false });
|
||||
document.addEventListener('pointerup', onEnd);
|
||||
document.addEventListener('pointercancel', onEnd);
|
||||
}
|
||||
|
||||
Template.cardDetails.events({
|
||||
// #1591 folded each FIELD by its own title, storing a class per card in the
|
||||
// profile. The section carets replaced it: one handle per section, on the
|
||||
@@ -1185,85 +1282,31 @@ Template.cardDetails.events({
|
||||
Users.setPublicCardCollapsed(!currentState);
|
||||
}
|
||||
},
|
||||
'mousedown .js-card-drag-handle'(event) {
|
||||
event.preventDefault();
|
||||
const $card = $(event.target).closest('.card-details');
|
||||
// #6465: where the user put it wins over where the minicard is, so a later
|
||||
// resize re-places every other window but only clamps this one into view.
|
||||
markCardDetailsUserMoved($card);
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const startLeft = $card.offset().left;
|
||||
const startTop = $card.offset().top;
|
||||
|
||||
const onMouseMove = (e) => {
|
||||
const deltaX = e.clientX - startX;
|
||||
const deltaY = e.clientY - startY;
|
||||
$card.css({
|
||||
left: startLeft + deltaX + 'px',
|
||||
top: startTop + deltaY + 'px'
|
||||
});
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
$(document).off('mousemove', onMouseMove);
|
||||
$(document).off('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
$(document).on('mousemove', onMouseMove);
|
||||
$(document).on('mouseup', onMouseUp);
|
||||
// The opened card's window is moved by a finger as well as a mouse, so these are
|
||||
// Pointer Events (a finger sends no mouse events while it moves, which left the card
|
||||
// fixed on an iPad while the page behind it scrolled) and the areas that start a
|
||||
// drag are `touch-action: none` (cardDetails.css). With drag handles ON the handle
|
||||
// is the only thing that moves the window; with them off the header row moves it,
|
||||
// except the title (it edits). A link or button in the row - the collapse caret, the
|
||||
// menu, the card number's neighbours - still does its job when tapped, and moves the
|
||||
// window when dragged. models/lib/windowDrag.js
|
||||
'pointerdown .js-card-drag-handle'(event) {
|
||||
beginCardWindowDrag(event, { start: true, deferred: false });
|
||||
},
|
||||
'mousedown .js-card-title-drag-handle'(event) {
|
||||
// The title bar drags the window. Don't interfere with text selection.
|
||||
if (
|
||||
$(event.target).closest(
|
||||
'a, input, textarea, button, select, option, .js-card-details-title',
|
||||
).length > 0
|
||||
) {
|
||||
// #6641: while the editor replaces the header, a textarea press still
|
||||
// bubbles to this drag bar. Leave it to the browser so mouse dragging
|
||||
// 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.
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const $card = $(event.target).closest('.card-details');
|
||||
// #6465: same as the drag handle above — a dragged window keeps its place.
|
||||
markCardDetailsUserMoved($card);
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const startLeft = $card.offset().left;
|
||||
const startTop = $card.offset().top;
|
||||
|
||||
const onMouseMove = (e) => {
|
||||
const deltaX = e.clientX - startX;
|
||||
const deltaY = e.clientY - startY;
|
||||
$card.css({
|
||||
left: startLeft + deltaX + 'px',
|
||||
top: startTop + deltaY + 'px'
|
||||
});
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
$(document).off('mousemove', onMouseMove);
|
||||
$(document).off('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
$(document).on('mousemove', onMouseMove);
|
||||
$(document).on('mouseup', onMouseUp);
|
||||
'pointerdown .js-card-title-drag-handle'(event) {
|
||||
const target = event.target;
|
||||
const closest = selector => (target.closest ? target.closest(selector) : null);
|
||||
const press = cardHeaderPress({
|
||||
onEditable: !!closest('input, textarea, select, option, .js-card-details-title'),
|
||||
onTitleEditZone: !!closest('.card-details-title-edit-zone'),
|
||||
// The handle has its own handler above; this one steps aside for it.
|
||||
onHandle: false,
|
||||
cardHasHandle: !!closest('.js-card-drag-handle')
|
||||
|| !!(closest('.card-details') && closest('.card-details').querySelector('.js-card-drag-handle')),
|
||||
onControl: !!closest('a, button'),
|
||||
});
|
||||
if (closest('.js-card-drag-handle')) return;
|
||||
beginCardWindowDrag(event, press);
|
||||
},
|
||||
'click .js-close-card-details'(event) {
|
||||
event.preventDefault();
|
||||
@@ -1912,8 +1955,10 @@ Template.cardSubtasksMenuPopup.events({
|
||||
Popup.open('boardSubtaskSettings')(event);
|
||||
},
|
||||
});
|
||||
Template.cardFieldSectionVoteAndPoker.events({
|
||||
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({
|
||||
@@ -2328,7 +2373,7 @@ const filterMembers = (filterTerm) => {
|
||||
.map(({ member }) => member);
|
||||
}
|
||||
return members;
|
||||
}
|
||||
};
|
||||
|
||||
Template.editCardRequesterForm.onRendered(function () {
|
||||
autosize(this.$('.js-edit-card-requester'));
|
||||
|
||||
@@ -9,5 +9,5 @@ template(name="cardPomodoro")
|
||||
button.js-stop-pomodoro.pomodoro-btn.negate(type="button") {{_ 'stop'}}
|
||||
else
|
||||
if canControlPomodoro
|
||||
input.js-pomodoro-work-minutes.pomodoro-minutes(type="number" min="1" value="{{workMinutesInput}}")
|
||||
input.js-pomodoro-work-minutes.pomodoro-minutes(type="number" min="1" value="{{workMinutesInput}}" aria-label="{{_ 'pomodoro-work'}} ({{_ 'minutes'}})")
|
||||
button.js-start-pomodoro.pomodoro-btn.primary(type="button") {{_ 'pomodoro-start'}}
|
||||
@@ -1,5 +1,5 @@
|
||||
.js-add-checklist {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
}
|
||||
textarea.js-add-checklist-item,
|
||||
textarea.js-edit-checklist-item {
|
||||
@@ -11,7 +11,7 @@ textarea.js-edit-checklist-item {
|
||||
.delete-text,
|
||||
.js-delete-checklist-item,
|
||||
.js-convert-checklist-item-to-card {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
text-decoration: underline;
|
||||
word-wrap: break-word;
|
||||
float: inline-end;
|
||||
@@ -139,7 +139,7 @@ textarea.js-edit-checklist-item {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.checklist-item .item-title.is-checked {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
font-style: italic;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
@@ -167,11 +167,11 @@ textarea.js-edit-checklist-item {
|
||||
.checklist-item .checklist-item-due-date-add {
|
||||
align-self: center;
|
||||
margin-inline-start: 6px;
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
text-decoration: none;
|
||||
}
|
||||
.checklist-item .checklist-item-due-date-add:hover {
|
||||
color: #2980b9;
|
||||
color: #2573a7;
|
||||
}
|
||||
.js-delete-checklist-item,
|
||||
.js-convert-checklist-item-to-card {
|
||||
@@ -235,7 +235,7 @@ textarea.js-edit-checklist-item {
|
||||
|
||||
.checklist-title .checklist-collapse:hover,
|
||||
.checklist-title .checklist-collapse:focus-visible {
|
||||
color: #2980b9;
|
||||
color: #2573a7;
|
||||
}
|
||||
|
||||
.checklist-title .checklist-due-date-add {
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
}
|
||||
.ui-font-btn.active,
|
||||
.ui-font-size-btn.active {
|
||||
background: #2980b9;
|
||||
border-color: #2980b9;
|
||||
background: #2573a7;
|
||||
border-color: #2573a7;
|
||||
color: #fff;
|
||||
}
|
||||
/* The "Unset" colour reset links look like buttons. */
|
||||
@@ -155,6 +155,7 @@
|
||||
}
|
||||
.card-label-green {
|
||||
background-color: #3cb500;
|
||||
color: #000;
|
||||
}
|
||||
.card-label-green:hover {
|
||||
color: #000;
|
||||
@@ -169,6 +170,7 @@
|
||||
}
|
||||
.card-label-red {
|
||||
background-color: #eb4646;
|
||||
color: #000;
|
||||
}
|
||||
.card-label-purple {
|
||||
background-color: #a632db;
|
||||
@@ -182,6 +184,7 @@
|
||||
}
|
||||
.card-label-sky {
|
||||
background-color: #00c2e0;
|
||||
color: #000;
|
||||
}
|
||||
.card-label-black {
|
||||
background-color: #4d4d4d;
|
||||
@@ -213,6 +216,7 @@
|
||||
}
|
||||
.card-label-magenta {
|
||||
background-color: #f0f;
|
||||
color: #000;
|
||||
}
|
||||
.card-label-gold {
|
||||
background-color: #ffd700;
|
||||
@@ -223,6 +227,7 @@
|
||||
}
|
||||
.card-label-gray {
|
||||
background-color: #808080;
|
||||
color: #000;
|
||||
}
|
||||
.card-label-saddlebrown {
|
||||
background-color: #8b4513;
|
||||
|
||||
@@ -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 {
|
||||
@@ -1006,7 +1011,7 @@
|
||||
.minicard-list-name {
|
||||
clear: inline-start;
|
||||
font-size: 0.75em;
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1018,7 +1023,7 @@
|
||||
.minicard-swimlane-name {
|
||||
clear: inline-start;
|
||||
font-size: 0.75em;
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -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,136 +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
|
||||
//- #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 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
|
||||
@@ -344,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
|
||||
@@ -368,104 +276,208 @@ 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}}
|
||||
//- #6872: the + button on the opened card picks board members into
|
||||
requesters / assigners; the free-text name is optional. Show both,
|
||||
as the opened card does, or a picked member never reached here.
|
||||
if $eq field "requestedBy"
|
||||
unless $eq currentBoard.allowsRequestedByOnMinicard false
|
||||
if hasRequestedByOnMinicard
|
||||
.minicard-requested-by(title="{{_ 'requested-by'}}")
|
||||
i.fa.fa-shopping-cart
|
||||
each userId in getRequesters
|
||||
+userAvatar(userId=userId noRemove=true)
|
||||
if getRequestedBy
|
||||
span {{ getRequestedBy }}
|
||||
if $eq field "assignedBy"
|
||||
unless $eq currentBoard.allowsAssignedByOnMinicard false
|
||||
if hasAssignedByOnMinicard
|
||||
.minicard-assigned-by(title="{{_ 'assigned-by'}}")
|
||||
i.fa.fa-user-plus
|
||||
each userId in getAssigners
|
||||
+userAvatar(userId=userId noRemove=true)
|
||||
if getAssignedBy
|
||||
span {{ 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
|
||||
@@ -228,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;
|
||||
@@ -238,56 +261,63 @@ Template.minicard.helpers({
|
||||
isWatching() {
|
||||
return this.getRealCard().findWatcher(Meteor.userId());
|
||||
},
|
||||
// #6872: a picked member (requesters / assigners) or a free-text name.
|
||||
hasRequestedByOnMinicard() {
|
||||
return !!this.getRequestedBy() || (this.getRequesters() || []).length > 0;
|
||||
},
|
||||
hasAssignedByOnMinicard() {
|
||||
return !!this.getAssignedBy() || (this.getAssigners() || []).length > 0;
|
||||
},
|
||||
|
||||
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.
|
||||
@@ -296,23 +326,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.
|
||||
@@ -320,7 +350,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
|
||||
@@ -329,7 +359,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.
|
||||
@@ -384,10 +414,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
|
||||
@@ -429,12 +459,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
|
||||
@@ -442,7 +474,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;
|
||||
},
|
||||
@@ -455,8 +487,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,5 +1,5 @@
|
||||
.js-add-subtask {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
}
|
||||
textarea.js-add-subtask-item,
|
||||
textarea.js-edit-subtask-item {
|
||||
@@ -12,7 +12,7 @@ textarea.js-edit-subtask-item {
|
||||
.subtask-title .js-delete-subtask,
|
||||
.subtask-title .js-view-subtask,
|
||||
.js-delete-subtask-item {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
text-decoration: underline;
|
||||
word-wrap: break-word;
|
||||
flex-shrink: 0;
|
||||
@@ -163,7 +163,7 @@ textarea.js-edit-subtask-item {
|
||||
padding-inline-start: 10px;
|
||||
}
|
||||
.subtasks-item .item-title.is-checked {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
font-style: italic;
|
||||
}
|
||||
.subtasks-item .item-title .viewer p {
|
||||
@@ -186,7 +186,7 @@ textarea.js-edit-subtask-item {
|
||||
instead of disappearing — the same dimmed/italic treatment a checked
|
||||
checklist item gets (.subtasks-item .item-title.is-checked above). */
|
||||
.subtask.is-completed .subtask-title .title {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
font-style: italic;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
@@ -199,7 +199,7 @@ textarea.js-edit-subtask-item {
|
||||
.subtasks-hide-completed-toggle {
|
||||
display: inline-block;
|
||||
margin: 0.2em 0 0.5em 1.33em;
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
cursor: pointer;
|
||||
}
|
||||
.subtasks-hide-completed-toggle:hover {
|
||||
@@ -224,7 +224,7 @@ textarea.js-edit-subtask-item {
|
||||
flex-shrink: 0;
|
||||
margin: 0 0.5em;
|
||||
padding-top: 6px;
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
font-size: 0.85em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -251,5 +251,5 @@ textarea.js-edit-subtask-item {
|
||||
}
|
||||
.subtask-title.is-done .title {
|
||||
text-decoration: line-through;
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
}
|
||||
@@ -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(); },
|
||||
|
||||
@@ -102,6 +102,29 @@
|
||||
padding-inline: 4px;
|
||||
}
|
||||
|
||||
.selected-calendar-heading .selected-calendar-date {
|
||||
margin-inline-start: 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;
|
||||
}
|
||||
/* #6853: year, month and day drop-downs under the calendar. */
|
||||
.selected-calendar-selects {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-block-start: 6px;
|
||||
}
|
||||
.selected-calendar-selects select,
|
||||
.selected-calendar-selects input {
|
||||
flex: 1;
|
||||
box-sizing: border-box;
|
||||
height: auto;
|
||||
margin-bottom: 0;
|
||||
min-width: 0;
|
||||
padding-inline: 4px;
|
||||
}
|
||||
.selected-calendar-selects .js-calendar-year { flex: 1.4; }
|
||||
.calendar-date-input .selected-calendar-selects select:focus-visible,
|
||||
.calendar-date-input .selected-calendar-selects input:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -20,8 +20,9 @@ template(name="calendarDateInput")
|
||||
button.primary.js-calendar-previous(type="button" aria-label="{{_ 'previous'}} {{_ 'month'}}") ‹
|
||||
span(aria-live="polite")
|
||||
| {{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 +35,16 @@ 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}}
|
||||
//- #6853: any year, month and day by a pick, under the calendar.
|
||||
.selected-calendar-selects
|
||||
input.js-calendar-year(type="number" step="1" inputmode="numeric" value=yearValue aria-label="{{_ 'predicate-year'}}")
|
||||
select.js-calendar-month(aria-label="{{_ 'month'}}")
|
||||
each monthOptions
|
||||
option(value=value selected=selected) {{label}}
|
||||
select.js-calendar-day-select(aria-label="{{_ 'day'}}")
|
||||
if noDaySelected
|
||||
option(value="" selected disabled) –
|
||||
each dayOptions
|
||||
option(value=value selected=selected) {{label}}
|
||||
@@ -6,7 +6,10 @@ import { TAPi18n } from '/imports/i18n';
|
||||
import { formatDate } from '/imports/lib/dateUtils';
|
||||
import { formatDateForDisplay, dateDisplayPreferences } from '/client/lib/dateDisplay';
|
||||
const { CALENDAR_SYSTEMS, nativeCalendarParts } = require('/imports/lib/calendarSystems');
|
||||
const { calendarMonthDays, calendarMonthRange, shiftedDay, shiftCalendarMonth, shiftCalendarYear } = require('/imports/lib/calendarMonth');
|
||||
const {
|
||||
calendarMonthDays, calendarMonthRange, shiftedDay, shiftCalendarMonth, shiftCalendarYear,
|
||||
calendarSignedYear, dateInCalendarYear, calendarYearMonths, sameDayInMonth,
|
||||
} = require('/imports/lib/calendarMonth');
|
||||
|
||||
function fromInput(value) {
|
||||
if (!value) return null;
|
||||
@@ -53,9 +56,45 @@ Template.calendarDateInput.helpers({
|
||||
useNativeInput() { return !this.inline && dateDisplayPreferences().calendarSystem === 'gregorian'; },
|
||||
inputType() { return this.withTime ? 'datetime-local' : 'date'; },
|
||||
selectedDateText() { return formatDateForDisplay(Template.instance().selectedDate.get(), false); },
|
||||
headingDateText() {
|
||||
// #6853: the year, month and day drop-downs under the calendar. Each option
|
||||
// carries a date as its value, found in the selected calendar system, so any
|
||||
// past, present or future year, month and day is one pick away.
|
||||
// The year as a number any year can be typed or stepped to, also one before
|
||||
// the era (0 is 1 BC) or thousands of years away.
|
||||
yearValue() { return calendarSignedYear(Template.instance().shownDate.get(), selectedSystem().intl); },
|
||||
monthOptions() {
|
||||
const tpl = Template.instance();
|
||||
return formatDateForDisplay(tpl.selectedDate.get() || tpl.shownDate.get(), false);
|
||||
const calendar = selectedSystem().intl;
|
||||
const language = TAPi18n.getLanguage() || 'en';
|
||||
return calendarYearMonths(tpl.shownDate.get(), calendar).map(month => {
|
||||
let label;
|
||||
try {
|
||||
label = new Intl.DateTimeFormat(language, { calendar, month: 'long', numberingSystem: 'latn' }).format(month.date);
|
||||
} catch (error) { label = formatDateForDisplay(month.date, false); }
|
||||
return { _id: formatDate(month.date), value: formatDate(month.date), label, selected: month.selected };
|
||||
});
|
||||
},
|
||||
dayOptions() {
|
||||
const tpl = Template.instance();
|
||||
const calendar = selectedSystem().intl;
|
||||
const { start, end } = calendarMonthRange(tpl.shownDate.get(), calendar);
|
||||
const selected = tpl.selectedDate.get();
|
||||
const selectedIso = selected ? formatDate(selected) : '';
|
||||
const days = [];
|
||||
for (let day = shiftedDay(start, 0); day < end; day = shiftedDay(day, 1)) {
|
||||
days.push({
|
||||
_id: formatDate(day), value: formatDate(day),
|
||||
label: nativeCalendarParts(day, calendar).day, selected: formatDate(day) === selectedIso,
|
||||
});
|
||||
}
|
||||
return days;
|
||||
},
|
||||
noDaySelected() {
|
||||
const tpl = Template.instance();
|
||||
const selected = tpl.selectedDate.get();
|
||||
if (!selected) return true;
|
||||
const { start, end } = calendarMonthRange(tpl.shownDate.get(), selectedSystem().intl);
|
||||
return selected < start || selected >= end;
|
||||
},
|
||||
expanded() { return !!Template.instance().data.inline || Template.instance().expanded.get(); },
|
||||
calendarName() { return TAPi18n.__(selectedSystem().labelKey); },
|
||||
@@ -63,8 +102,11 @@ Template.calendarDateInput.helpers({
|
||||
const date = Template.instance().shownDate.get();
|
||||
try {
|
||||
return new Intl.DateTimeFormat(TAPi18n.getLanguage() || 'en', {
|
||||
calendar: selectedSystem().intl, month: 'long',
|
||||
...(Template.instance().data.inline ? {} : { year: 'numeric' }), numberingSystem: 'latn',
|
||||
// #6853: the year is always shown, also in the popup: month and year
|
||||
// above the calendar, never the YYYY-MM-DD of the selected day.
|
||||
calendar: selectedSystem().intl, month: 'long', year: 'numeric', numberingSystem: 'latn',
|
||||
// A year before the era needs its era, or 3000 BC reads as 3000.
|
||||
...(calendarSignedYear(date, selectedSystem().intl) <= 0 ? { era: 'short' } : {}),
|
||||
}).format(date);
|
||||
} catch (error) { return formatDateForDisplay(date, false); }
|
||||
},
|
||||
@@ -86,9 +128,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 +147,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,8 +171,40 @@ 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)); },
|
||||
// #6853: a pick in a drop-down selects that date and shows its month.
|
||||
'change .js-calendar-year'(event, tpl) {
|
||||
const input = event.currentTarget;
|
||||
const target = dateInCalendarYear(tpl.shownDate.get(), Number(input.value), selectedSystem().intl);
|
||||
if (!target || input.value.trim() === '') {
|
||||
// A year that is not a whole number, or beyond what a date can hold:
|
||||
// the field goes back to the year being shown.
|
||||
input.value = calendarSignedYear(tpl.shownDate.get(), selectedSystem().intl);
|
||||
return;
|
||||
}
|
||||
tpl.shownDate.set(target); tpl.focusedDate.set(target); writeValue(tpl, target);
|
||||
},
|
||||
// Enter in the year field applies the year, not the popup's Save.
|
||||
'keydown .js-calendar-year'(event) {
|
||||
if (event.key !== 'Enter') return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
event.currentTarget.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
},
|
||||
'change .js-calendar-month'(event, tpl) {
|
||||
const target = sameDayInMonth(tpl.selectedDate.get() || tpl.shownDate.get(), fromInput(event.currentTarget.value), selectedSystem().intl);
|
||||
tpl.shownDate.set(target); tpl.focusedDate.set(target); writeValue(tpl, target);
|
||||
},
|
||||
'change .js-calendar-day-select'(event, tpl) {
|
||||
const target = fromInput(event.currentTarget.value);
|
||||
if (!target) return;
|
||||
tpl.shownDate.set(target); tpl.focusedDate.set(target); writeValue(tpl, target);
|
||||
},
|
||||
'click .js-calendar-day'(event, tpl) {
|
||||
event.preventDefault();
|
||||
writeValue(tpl, fromInput(event.currentTarget.dataset.date));
|
||||
|
||||
@@ -192,9 +192,11 @@ body.mobile-mode [id^="swimlane-"] .list[style*="--list-width"]:not(.list-collap
|
||||
min-height: 30px !important;
|
||||
height: auto !important;
|
||||
padding: 28px 21px 6px !important;
|
||||
/* Make sure the background covers the full height */
|
||||
background-color: #e4e4e4 !important;
|
||||
border-bottom: 8px solid #e4e4e4 !important;
|
||||
/* Make sure the background covers the full height. Not !important: it beat
|
||||
every theme's own list header (moderndark's #6a6a6a, exodark's #222), and
|
||||
left their light text on this grey at 1.09:1 and 1.27:1 (#6863). */
|
||||
background-color: #e4e4e4;
|
||||
border-bottom: 8px solid #e4e4e4;
|
||||
/* Use original display for consistent button positioning */
|
||||
display: block !important;
|
||||
position: relative !important;
|
||||
@@ -470,7 +472,7 @@ form.inlined-form .list-composer {
|
||||
|
||||
.list.list-composer .open-list-composer,
|
||||
.list .list-composer .open-list-composer {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
}
|
||||
.list.list-composer .list-name-input,
|
||||
.list .list-composer .list-name-input {
|
||||
@@ -534,7 +536,7 @@ form.inlined-form .list-composer {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
font-weight: bold;
|
||||
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
|
||||
vertical-align: middle;
|
||||
@@ -722,7 +724,7 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
|
||||
.list-header .cardCount {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -781,7 +783,7 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
.list-body .open-minicard-composer {
|
||||
border-radius: 2px;
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
display: block;
|
||||
/* Smaller than a card title, because it is not a card: it is the affordance
|
||||
for making one. At the inherited 16px it was the largest text in the list
|
||||
|
||||
@@ -179,6 +179,13 @@ 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 = () => {
|
||||
@@ -208,6 +215,7 @@ 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,
|
||||
@@ -374,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);
|
||||
@@ -390,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) => {
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
selected thing. The fill is the slide where the theme has one. */
|
||||
.theme-color-picker ul.pop-over-list.theme-scope-row > li.active > a,
|
||||
.theme-color-picker ul.pop-over-list.theme-scope-row > li.active > a:hover {
|
||||
background: var(--theme-accent-fill, var(--theme-accent, #2980b9));
|
||||
background: var(--theme-accent-fill, var(--theme-accent, #2573a7));
|
||||
color: #fff !important;
|
||||
}
|
||||
.theme-color-picker ul.pop-over-list.theme-scope-row > li.active > a i {
|
||||
|
||||
@@ -39,7 +39,7 @@ template(name="dueCards")
|
||||
else
|
||||
.global-search-results-list-wrapper
|
||||
.no-results
|
||||
h3 {{_ 'dueCards-noResults-title'}}
|
||||
h2 {{_ 'dueCards-noResults-title'}}
|
||||
p {{_ 'dueCards-noResults-description'}}
|
||||
|
||||
template(name="dueCardsViewChangePopup")
|
||||
|
||||
@@ -7,6 +7,7 @@ template(name="editor")
|
||||
dir="auto"
|
||||
class="{{class}}"
|
||||
id=id
|
||||
aria-label=labelText
|
||||
autofocus=autofocus
|
||||
placeholder="{{_ 'comment-placeholder'}}")
|
||||
+Template.contentBlock
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -45,6 +45,16 @@ function syncMarkdownUrlSchemes(setting) {
|
||||
return schemes;
|
||||
}
|
||||
|
||||
// WCAG 4.1.2: a field has a name. The including template passes the KEY of its
|
||||
// name (labelKey="comment"), and the editor translates it, as every included
|
||||
// template does (tests/jadeInclusionArgsNoMustache.test.cjs).
|
||||
Template.editor.helpers({
|
||||
labelText() {
|
||||
const data = Template.currentData() || {};
|
||||
return data.labelKey ? TAPi18n.__(data.labelKey) : null;
|
||||
},
|
||||
});
|
||||
|
||||
const specialHandles = [
|
||||
{userId: 'board_members', username: 'board_members'},
|
||||
{userId: 'card_members', username: 'card_members'},
|
||||
@@ -348,6 +358,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
|
||||
|
||||
@@ -106,6 +106,8 @@ code {
|
||||
}
|
||||
.list-title {
|
||||
background-color: #a9a9a9;
|
||||
/* White on this grey was 2.35; black is 8.94 (WCAG 1.4.3). */
|
||||
color: #000;
|
||||
}
|
||||
.global-search-footer {
|
||||
border: none;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Session } from 'meteor/session';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Template } from 'meteor/templating';
|
||||
import { activeAccent, activeFill } from '/models/lib/themeAccents';
|
||||
import { readableAccent } from '/models/lib/contrastColor';
|
||||
|
||||
// #5778 + docs/Theme/Theme.md: apply the theme color to the whole UI (All Boards,
|
||||
// Search, Admin Panel, My Cards, etc.) via a `board-color-<name>` class on <body>,
|
||||
@@ -99,7 +100,8 @@ Meteor.startup(() => {
|
||||
document.body.classList.remove('has-custom-theme-color');
|
||||
}
|
||||
if (c2) {
|
||||
root.style.setProperty('--theme-accent-2', c2);
|
||||
// Made readable like the first end: white text is drawn across the slide.
|
||||
root.style.setProperty('--theme-accent-2', readableAccent(c2));
|
||||
document.body.classList.add('has-custom-theme-slide');
|
||||
} else {
|
||||
root.style.removeProperty('--theme-accent-2');
|
||||
|
||||
@@ -17,9 +17,21 @@
|
||||
#header {
|
||||
color: #fff;
|
||||
transition: background-color 0.4s;
|
||||
background: #2980b9;
|
||||
background: #2573a7;
|
||||
z-index: 17;
|
||||
}
|
||||
/* An opened card covers the header bars, in every browser. iOS WebKit - iPad Safari and
|
||||
iPad Chrome, which is WebKit too - makes every SCROLLING element (#content, the board
|
||||
wrapper) a stacking context of its own. The card is inside them, so its z-index (2001)
|
||||
is only compared there, while the header bar (#header-quick-access, z-index 1000, a
|
||||
flex item of body and so a stacking context itself) is painted over all of it: the
|
||||
card's top row - the collapse caret, the card number, the menu, maximize and X - sat
|
||||
BEHIND the header bar. While a card is open the header drops to the level of the
|
||||
content, which comes after it in the page and so paints over it; a browser without that
|
||||
trap shows no difference. */
|
||||
body:has(.card-details) #header-quick-access {
|
||||
z-index: 0;
|
||||
}
|
||||
#header #header-main-bar {
|
||||
/* Flex row (was float-based) so the right button group — which ends with the
|
||||
sidebar hamburger — is always pinned to the right edge at every window width,
|
||||
@@ -247,7 +259,7 @@
|
||||
float: inline-start;
|
||||
display: block;
|
||||
line-height: calc(28px * var(--wekan-ui-font-scale, 1));
|
||||
color: #27ae60;
|
||||
color: #217d49;
|
||||
margin: 0 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -298,7 +310,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
of colour now, so the header reads as one header. The per-theme rules do
|
||||
the same, by naming this bar in the selector list the main bar already has
|
||||
(boardColors.css) rather than by restating each hex a second time. */
|
||||
background: #2980b9;
|
||||
background: #2573a7;
|
||||
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
|
||||
display: flex;
|
||||
z-index: 1000;
|
||||
@@ -745,7 +757,7 @@ body.mobile-mode #header-quick-access .allBoards {
|
||||
color: #fff !important;
|
||||
/* The active theme, like the rest of the bar; the stock blue where no theme
|
||||
has published an accent. */
|
||||
background: var(--theme-accent, #2980b9) !important;
|
||||
background: var(--theme-accent, #2573a7) !important;
|
||||
}
|
||||
|
||||
#header-quick-access .mobile-mode-toggle .board-header-btn.mobile-active i.mobile-icon .fa,
|
||||
|
||||
@@ -44,7 +44,9 @@ template(name="header")
|
||||
that do the same job. Board admins only - everybody else gets the
|
||||
plain title. The `is-editable` class also gives the element a minimum
|
||||
width, so a board whose title is EMPTY still has an area to click.
|
||||
span.header-page-title(title="{{headerTitleFullPath}}"
|
||||
//- role="heading" aria-level="1": every page has one level-one heading, and this
|
||||
//- is the page's name (WCAG 1.3.1 / 2.4.6; axe page-has-heading-one).
|
||||
span.header-page-title(title="{{headerTitleFullPath}}" role="heading" aria-level="1"
|
||||
class="{{#if canEditBoardTitle}}js-edit-board-title is-editable{{/if}}")
|
||||
if headerTitleKey
|
||||
| {{_ headerTitleKey}}
|
||||
@@ -67,6 +69,19 @@ template(name="header")
|
||||
div.header-logo
|
||||
img(src="{{pathFor '/logo-header.png'}}" alt="{{headerLogoAlt}}" title="{{headerLogoAlt}}")
|
||||
|
||||
//- Full screen, to the right of the logo (iPad Safari, desktop and Android
|
||||
browsers). Only drawn where the browser has the Fullscreen API and the page
|
||||
may use it: not on iPhone, not in an iframe without allowfullscreen. The
|
||||
label is the existing "Maximize" one. It stays before the first
|
||||
.js-header-collapsible-icon, so "hide the icons" does not hide it.
|
||||
models/lib/fullscreen.js
|
||||
if fullscreenSupported
|
||||
a.board-header-btn.js-toggle-fullscreen(href="#" role="button" title="{{#if isFullscreen}}{{_ 'minimize-window'}}{{else}}{{_ 'maximize-window'}}{{/if}}" aria-label="{{#if isFullscreen}}{{_ 'minimize-window'}}{{else}}{{_ 'maximize-window'}}{{/if}}" aria-pressed="{{#if isFullscreen}}true{{else}}false{{/if}}")
|
||||
if isFullscreen
|
||||
i.fa.fa-compress
|
||||
else
|
||||
i.fa.fa-expand
|
||||
|
||||
// Mobile/desktop mode toggle (swapped with zoom)
|
||||
// #6680 follow-up: .js-header-collapsible-icon marks every icon the new
|
||||
// .js-toggle-header-icons-collapsed button (beside Home) hides as a
|
||||
@@ -119,7 +134,8 @@ template(name="header")
|
||||
//- One GROUP: the two adjacent buttons are about the same thing - how
|
||||
many items you have starred, and whether this one is among them.
|
||||
.header-star-group.js-header-collapsible-icon
|
||||
a.board-header-btn.js-open-starred-boards(title="{{_ 'allboards.starred'}}")
|
||||
//- aria-label: WCAG 2.5.3 Label in Name - the visible text is the star count.
|
||||
a.board-header-btn.js-open-starred-boards(title="{{_ 'allboards.starred'}}" aria-label="{{starredBoardsCount}} {{_ 'allboards.starred'}}")
|
||||
i.fa.fa-caret-down
|
||||
span.board-star-counter {{starredBoardsCount}}
|
||||
|
||||
@@ -245,7 +261,8 @@ template(name="header")
|
||||
if appIsOffline
|
||||
+offlineWarning
|
||||
|
||||
if currentUser.isBoardMember
|
||||
//- #6854: on Home and All Boards too, not only on a board.
|
||||
if announcementForUser
|
||||
if hasAnnouncement
|
||||
.announcement(role="status" aria-live="polite")
|
||||
p
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import Announcements, {
|
||||
announcementVersion,
|
||||
shouldShowAnnouncement,
|
||||
showAnnouncementToUser,
|
||||
} from '/models/announcements';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
// What this bar calls the page you are on. models/lib/pageTitles.js
|
||||
@@ -58,6 +59,7 @@ function customPageTitle(routeName) {
|
||||
// Drag-to-scroll on the two top header bars (they are not scroll containers
|
||||
// themselves, so the drag is forwarded to the board canvas / page scroller).
|
||||
import '/client/lib/headerDragscroll';
|
||||
const fullscreen = require('/models/lib/fullscreen');
|
||||
|
||||
Meteor.subscribe('user-admin');
|
||||
Meteor.subscribe('boards');
|
||||
@@ -69,6 +71,10 @@ Meteor.subscribe('setting');
|
||||
Meteor.subscribe('announcements');
|
||||
Template.header.onCreated(function () {
|
||||
const templateInstance = this;
|
||||
// Full screen: whether the browser offers it here, and whether the page is in it.
|
||||
// Both are set when the header is drawn (onRendered), where there is a document.
|
||||
templateInstance.fullscreenSupported = new ReactiveVar(false);
|
||||
templateInstance.isFullscreen = new ReactiveVar(false);
|
||||
templateInstance.currentSetting = new ReactiveVar();
|
||||
templateInstance.isLoading = new ReactiveVar(false);
|
||||
templateInstance.subscribe('setting');
|
||||
@@ -82,6 +88,14 @@ Template.header.onCreated(function () {
|
||||
|
||||
});
|
||||
Template.header.onRendered(function () {
|
||||
// The Fullscreen API, where it exists and the page may use it (models/lib/fullscreen.js).
|
||||
// The state follows the browser's own events, so leaving with Esc, or with the
|
||||
// browser's own control, changes the icon too.
|
||||
this.fullscreenSupported.set(fullscreen.isSupported(document));
|
||||
this.isFullscreen.set(fullscreen.isFullscreen(document));
|
||||
this.onFullscreenChange = () => this.isFullscreen.set(fullscreen.isFullscreen(document));
|
||||
fullscreen.CHANGE_EVENTS.forEach(name => document.addEventListener(name, this.onFullscreenChange));
|
||||
|
||||
this.autorun(() => {
|
||||
Meteor.userId();
|
||||
FlowRouter.watchPathChange();
|
||||
@@ -91,7 +105,19 @@ Template.header.onRendered(function () {
|
||||
});
|
||||
});
|
||||
|
||||
Template.header.onDestroyed(function () {
|
||||
if (this.onFullscreenChange) {
|
||||
fullscreen.CHANGE_EVENTS.forEach(name => document.removeEventListener(name, this.onFullscreenChange));
|
||||
}
|
||||
});
|
||||
|
||||
Template.header.helpers({
|
||||
fullscreenSupported() {
|
||||
return Template.instance().fullscreenSupported.get();
|
||||
},
|
||||
isFullscreen() {
|
||||
return Template.instance().isFullscreen.get();
|
||||
},
|
||||
logoSettingsReady() {
|
||||
return Template.instance().subscriptionsReady();
|
||||
},
|
||||
@@ -316,6 +342,14 @@ Template.header.helpers({
|
||||
return ['waiting', 'failed', 'offline'].includes(status.status);
|
||||
},
|
||||
|
||||
announcementForUser() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
return showAnnouncementToUser({
|
||||
userId: user && user._id,
|
||||
board: Utils.getCurrentBoard(),
|
||||
});
|
||||
},
|
||||
|
||||
hasAnnouncement() {
|
||||
const announcement = Announcements.findOne();
|
||||
if (!announcement || !announcement.enabled) {
|
||||
@@ -356,6 +390,13 @@ Template.header.helpers({
|
||||
});
|
||||
|
||||
Template.header.events({
|
||||
// Full screen on and off. The request is made inside this click, which is the
|
||||
// user gesture browsers require; a refusal is not an error (models/lib/fullscreen.js).
|
||||
'click .js-toggle-fullscreen'(event) {
|
||||
event.preventDefault();
|
||||
fullscreen.toggle(document);
|
||||
},
|
||||
|
||||
// The starred boards, by name. They were listed inline in this bar; the bar
|
||||
// is one row now and the names are in the popup. docs/Features/Header-Bar/Header.md
|
||||
// Titled "Starred Boards", from the key the app already has for that phrase,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -4,7 +4,8 @@ template(name="shortcutsModalTitle")
|
||||
| {{_ 'keyboard-shortcuts'}}
|
||||
|
||||
template(name="keyboardShortcuts")
|
||||
.wrapper.shortcuts-list
|
||||
//- tabindex: the list scrolls when the window is short, and a scrolling region must be reachable by keyboard (WCAG 2.1.1; axe scrollable-region-focusable).
|
||||
.wrapper.shortcuts-list(tabindex="0" role="region" aria-label="{{_ 'keyboard-shortcuts'}}")
|
||||
each mapping
|
||||
.shortcuts-list-item
|
||||
.shortcuts-list-item-keys
|
||||
|
||||
@@ -49,6 +49,20 @@ summary:focus-visible,
|
||||
box-shadow: 0 0 0 4px rgba(21, 101, 192, 0.35);
|
||||
border-radius: 2px;
|
||||
}
|
||||
/* #6755: a member who turned off "Show focus outline" (Member Settings / Change
|
||||
* Settings) sees no ring: the outline and its halo, for keyboard focus and for the
|
||||
* fallback below. Field borders and the colour a button takes on focus are not the
|
||||
* ring and stay. The ring is an accessibility feature, so it is on unless hidden. */
|
||||
body.has-hide-focus-outline :focus-visible,
|
||||
body.has-hide-focus-outline a:focus,
|
||||
body.has-hide-focus-outline button:focus,
|
||||
body.has-hide-focus-outline [tabindex]:focus {
|
||||
outline: none !important;
|
||||
outline-offset: 0 !important;
|
||||
}
|
||||
body.has-hide-focus-outline :is(a, button, input, select, textarea, summary, [tabindex], [role="button"], [contenteditable="true"]):focus-visible {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
/* Browsers that do not support :focus-visible fall back to :focus so keyboard
|
||||
* users are never left without an indicator. */
|
||||
.no-focus-visible-support a:focus,
|
||||
@@ -378,14 +392,14 @@ h6 {
|
||||
}
|
||||
.quiet,
|
||||
.quiet a {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
}
|
||||
.error,
|
||||
.error a {
|
||||
color: #eb3800;
|
||||
}
|
||||
.no-items-message {
|
||||
color: #666;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
margin: 36px 0;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -711,6 +725,11 @@ a:not(.disabled).is-active i.fa {
|
||||
.viewer code {
|
||||
padding: 10px 12px 7px;
|
||||
background: #dedede;
|
||||
/* Its own text colour with its own light background: a dark theme's light
|
||||
text (#bbb in the dark theme's opened card) was inherited onto #dedede,
|
||||
1.42:1 (46-axe-theme-contrast). #222 on #dedede is 11.8:1, and
|
||||
codeHighlight.css's token colours are chosen for this background too. */
|
||||
color: #222;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.viewer a {
|
||||
@@ -1133,6 +1152,77 @@ a:not(.disabled).is-active i.fa {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
/* The administrator's login page background colour (layouts.js sets the two
|
||||
custom properties and the class while the sign-in layout is shown). The
|
||||
foreground is white on a dark colour and black on a light one. */
|
||||
body.has-login-background {
|
||||
background: var(--login-background);
|
||||
}
|
||||
body.has-login-background .textBelowCustomLoginLogo,
|
||||
body.has-login-background .textBelowCustomLoginLogo a {
|
||||
color: var(--login-foreground);
|
||||
}
|
||||
body.has-login-background hr {
|
||||
border-color: var(--login-foreground);
|
||||
opacity: 0.4;
|
||||
}
|
||||
/* The text under the login logo: one column, real headings, space between
|
||||
paragraphs. It has no card or background of its own, so the page colour shows
|
||||
through. */
|
||||
.textBelowCustomLoginLogo > * {
|
||||
width: 100%;
|
||||
max-width: 860px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.textBelowCustomLoginLogo h1,
|
||||
.textBelowCustomLoginLogo h2,
|
||||
.textBelowCustomLoginLogo h3,
|
||||
.support-page-content h1,
|
||||
.support-page-content h2,
|
||||
.support-page-content h3 {
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
margin: 1em 0 0.5em;
|
||||
}
|
||||
.textBelowCustomLoginLogo h1,
|
||||
.support-page-content h1 { font-size: 1.8em; }
|
||||
.textBelowCustomLoginLogo h2,
|
||||
.support-page-content h2 { font-size: 1.45em; }
|
||||
.textBelowCustomLoginLogo h3,
|
||||
.support-page-content h3 { font-size: 1.2em; }
|
||||
.textBelowCustomLoginLogo p,
|
||||
.textBelowCustomLoginLogo ul,
|
||||
.textBelowCustomLoginLogo ol,
|
||||
.support-page-content p,
|
||||
.support-page-content ul,
|
||||
.support-page-content ol {
|
||||
margin: 0 0 1em;
|
||||
}
|
||||
/* The support page: a centred column instead of text flush against the window's
|
||||
left edge. */
|
||||
.support-page {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
.support-page-content {
|
||||
box-sizing: border-box;
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 28px;
|
||||
background: #fff;
|
||||
color: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.support-page {
|
||||
padding: 8px;
|
||||
}
|
||||
.support-page-content {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
.auth-layout .auth-dialog {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
@@ -27,11 +27,12 @@ template(name="userFormsLayout")
|
||||
section.auth-layout
|
||||
if logoSettingsReady
|
||||
if currentSetting.hideLogo
|
||||
h1.at-form-landing-logo
|
||||
//- A spacer, not a heading: an empty h1 is announced as an empty heading.
|
||||
div.at-form-landing-logo(aria-hidden="true")
|
||||
br
|
||||
br
|
||||
unless currentSetting.hideLogo
|
||||
h1.at-form-landing-logo
|
||||
div.at-form-landing-logo(aria-hidden="true")
|
||||
if currentSetting.customLoginLogoImageUrl
|
||||
if currentSetting.customLoginLogoLinkUrl
|
||||
a(href="{{currentSetting.customLoginLogoLinkUrl}}")
|
||||
@@ -49,13 +50,15 @@ template(name="userFormsLayout")
|
||||
+viewer
|
||||
| {{currentSetting.textBelowCustomLoginLogo}}
|
||||
hr
|
||||
section.auth-layout
|
||||
//- role="main": the sign-in and register form is the page's main landmark
|
||||
(WCAG 1.3.1 / 2.4.1), so the title, the links and the language picker are in one.
|
||||
section.auth-layout(role="main")
|
||||
section.auth-dialog
|
||||
if isLoading
|
||||
+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
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
const { OAUTH_PROVIDERS, providerByKey } = require('/models/lib/oauthProviders');
|
||||
const { loginOriginMismatch, offersProviderLogin } = require('/models/lib/loginOriginMismatch');
|
||||
const { loginBackgroundStyle } = require('/models/lib/loginBackground');
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
import Users from '/models/users';
|
||||
@@ -303,6 +304,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 +559,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({
|
||||
@@ -668,6 +673,56 @@ Template.userFormsLayout.onDestroyed(function () {
|
||||
this.removeAuthenticationSubmit?.();
|
||||
});
|
||||
|
||||
// The administrator's login page background colour (Admin Panel -> Lay-out).
|
||||
// It is put on <body> only while the sign-in layout is shown, as two custom
|
||||
// properties and a class, and taken off again when it closes so the pages after
|
||||
// sign-in keep their own background. loginBackgroundStyle accepts a hex colour
|
||||
// only, so the value can never carry CSS of its own.
|
||||
const LOGIN_BACKGROUND_PROPERTIES = ['--login-background', '--login-foreground'];
|
||||
|
||||
function clearLoginBackground() {
|
||||
document.body.classList.remove('has-login-background');
|
||||
LOGIN_BACKGROUND_PROPERTIES.forEach(name => document.body.style.removeProperty(name));
|
||||
}
|
||||
|
||||
Template.userFormsLayout.onRendered(function () {
|
||||
this.autorun(() => {
|
||||
const setting = this.currentSetting.get();
|
||||
const style = loginBackgroundStyle(setting && setting.loginBackgroundColor);
|
||||
if (!style) {
|
||||
clearLoginBackground();
|
||||
return;
|
||||
}
|
||||
document.body.style.setProperty('--login-background', style.background);
|
||||
document.body.style.setProperty('--login-foreground', style.foreground);
|
||||
document.body.classList.add('has-login-background');
|
||||
});
|
||||
});
|
||||
|
||||
Template.userFormsLayout.onDestroyed(clearLoginBackground);
|
||||
|
||||
// The form's title ("Sign In", "Create an Account") is an h3 of the useraccounts
|
||||
// package and the first heading of the page: a level-one heading, whatever the
|
||||
// package draws it as (WCAG 1.3.1; axe page-has-heading-one and heading-order).
|
||||
// The package redraws the form when the user moves between sign in and register,
|
||||
// so the title is marked again whenever the dialog changes.
|
||||
Template.userFormsLayout.onRendered(function () {
|
||||
const dialog = this.find('.auth-dialog');
|
||||
if (!dialog) return;
|
||||
const mark = () => {
|
||||
dialog.querySelectorAll('.at-title h1, .at-title h2, .at-title h3').forEach(heading => {
|
||||
if (heading.getAttribute('aria-level') !== '1') heading.setAttribute('aria-level', '1');
|
||||
});
|
||||
};
|
||||
mark();
|
||||
this.titleObserver = new MutationObserver(mark);
|
||||
this.titleObserver.observe(dialog, { childList: true, subtree: true });
|
||||
});
|
||||
|
||||
Template.userFormsLayout.onDestroyed(function () {
|
||||
if (this.titleObserver) this.titleObserver.disconnect();
|
||||
});
|
||||
|
||||
function getAuthenticationMethod(
|
||||
settings,
|
||||
match,
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
/* Disabled = the same themed outline, faded. Not a grey block. */
|
||||
/* A disabled pager still has to look like the THEME. It is filled with the
|
||||
theme colour, and dimming it is what takes the theme back out: at 0.4 the
|
||||
belize blue #2980b9 arrived on the page's grey as rgb(122,170,202), a pale
|
||||
belize blue #2573a7 arrived on the page's grey as rgb(122,170,202), a pale
|
||||
wash with no family resemblance to the header above it - and on a one-page
|
||||
list, where BOTH arrows are disabled, that is the whole pager.
|
||||
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
first row of a popup, and it must match the space beside it: `.content` has 18px of
|
||||
side padding, so 6 + 12 = 18 makes the top gap and the left gutter the same. */
|
||||
.pop-over .header {
|
||||
/* A popup is moved by its title bar (popup.js, Pointer Events). Without this a
|
||||
finger drag on it is taken for a scroll and the pointer is cancelled, so only a
|
||||
mouse could move it. */
|
||||
touch-action: none;
|
||||
height: calc(41px * var(--wekan-ui-font-scale, 1));
|
||||
position: relative;
|
||||
margin-bottom: 6px;
|
||||
@@ -367,19 +371,16 @@
|
||||
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, and the opened card's "+ Add": the Admin Panel's
|
||||
left menu and right pane, wide enough for a row's label and both sides
|
||||
(sidebar.css .card-settings-sections). */
|
||||
/* 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, 1200px) !important;
|
||||
width: min(96vw, 1100px) !important;
|
||||
max-width: 96vw !important;
|
||||
max-height: calc(100dvh - 24px) !important;
|
||||
margin-top: 0;
|
||||
@@ -810,7 +811,7 @@ html[dir='rtl'] .pop-over > button.js-date-popup-resize > span {
|
||||
above pins every row's text to `#000 !important`; the icon is named separately
|
||||
for the same reason. */
|
||||
.pop-over-list li > a:not(.disabled):hover {
|
||||
background-color: var(--theme-accent, #2980b9);
|
||||
background-color: var(--theme-accent, #2573a7);
|
||||
color: #fff !important;
|
||||
}
|
||||
.pop-over-list li > a:not(.disabled):hover i,
|
||||
@@ -825,7 +826,7 @@ html[dir='rtl'] .pop-over > button.js-date-popup-resize > span {
|
||||
background: #fff;
|
||||
}
|
||||
.pop-over-list li > a .sub-name {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
display: block;
|
||||
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
|
||||
font-weight: 400;
|
||||
@@ -870,7 +871,7 @@ html[dir='rtl'] .pop-over > button.js-date-popup-resize > span {
|
||||
background-color: #2e85b8;
|
||||
}
|
||||
.pop-over-list li > a.disabled {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
cursor: default;
|
||||
}
|
||||
.pop-over-list li > a.disabled .vis-icon {
|
||||
@@ -881,7 +882,7 @@ html[dir='rtl'] .pop-over > button.js-date-popup-resize > span {
|
||||
}
|
||||
.pop-over-list li > a.disabled:hover .sub-name,
|
||||
.pop-over-list li > a.disabled:hover .quiet {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
}
|
||||
.pop-over-list li > a.disabled:active {
|
||||
background: none;
|
||||
@@ -975,7 +976,7 @@ html[dir='rtl'] .pop-over > button.js-date-popup-resize > span {
|
||||
user's own theme, the board's colour or the site theme - whichever is in
|
||||
force. The fallback is the stock WeKan blue, so an unthemed instance is
|
||||
unchanged. */
|
||||
background: var(--theme-accent, #2980b9);
|
||||
background: var(--theme-accent, #2573a7);
|
||||
height: calc(48px * var(--wekan-ui-font-scale, 1));
|
||||
padding: 0px 0px;
|
||||
border: 0px;
|
||||
@@ -1444,14 +1445,24 @@ body.mobile-mode .pop-over .content-wrapper {
|
||||
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 {
|
||||
touch-action: none;
|
||||
float: inline-start;
|
||||
padding: 12px 0 12px 12px;
|
||||
cursor: move;
|
||||
color: #888;
|
||||
}
|
||||
.pop-over .header:has(.popup-drag-handle) {
|
||||
/* #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;
|
||||
@@ -1499,3 +1510,36 @@ body.mobile-mode .pop-over .content-wrapper {
|
||||
.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;
|
||||
}
|
||||
@@ -29,8 +29,11 @@ function resizeDatePopup(element, width, height) {
|
||||
Object.entries(styles).forEach(([key, value]) => element.style.setProperty(key, value, 'important'));
|
||||
}
|
||||
|
||||
// Popups dragged by their title bar (or the drag handle beside it), as the
|
||||
// opened card is. A date popup is movable too, found by its .edit-date.
|
||||
// #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']);
|
||||
@@ -66,7 +69,7 @@ Popup.template.onCreated(function () {
|
||||
|
||||
Popup.template.helpers({
|
||||
popupMovable() {
|
||||
return MOVABLE_POPUPS.has(this.popupName);
|
||||
return true;
|
||||
},
|
||||
popupMaximizable() {
|
||||
return MAXIMIZABLE_POPUPS.has(this.popupName);
|
||||
@@ -87,21 +90,51 @@ Popup.template.events({
|
||||
'pointerdown .header'(evt, tpl) {
|
||||
const element = evt.currentTarget.closest('.pop-over');
|
||||
// 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') ||
|
||||
(!element.querySelector('.edit-date') && !MOVABLE_POPUPS.has(element.dataset.popup)) ||
|
||||
(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));
|
||||
// The popup's size NOW, not when the drag began: a long menu gets its
|
||||
// 960px column layout a frame after it is drawn (popupMenuLayout.js), so a
|
||||
// drag that starts in that frame would keep clamping for the narrow size
|
||||
// and push the grown popup most of the way out of the window - and undo
|
||||
// the ResizeObserver that had just put it back inside.
|
||||
const size = drag.element.getBoundingClientRect();
|
||||
const width = size.width || drag.width;
|
||||
const height = size.height || drag.height;
|
||||
const boxLeft = Math.max(12, Math.min(window.innerWidth - width - 12, drag.left + drag.dx + evt.clientX - drag.x));
|
||||
const boxTop = Math.max(12, Math.min(window.innerHeight - 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');
|
||||
@@ -205,13 +238,16 @@ Popup.template.onRendered(function () {
|
||||
this._focusTrap = event => trapTabKey(event, this._popupElement);
|
||||
this._popupElement?.addEventListener('keydown', this._focusTrap);
|
||||
if (this._popupElement) {
|
||||
const update = () => {
|
||||
this._menuLayoutFrame = null;
|
||||
updatePopupMenuColumns(this._popupElement);
|
||||
};
|
||||
this._menuLayoutObserver = new MutationObserver(() => {
|
||||
if (this._menuLayoutFrame == null) this._menuLayoutFrame = requestAnimationFrame(update);
|
||||
});
|
||||
const update = () => updatePopupMenuColumns(this._popupElement);
|
||||
// At once, not on the next animation frame: a MutationObserver callback
|
||||
// already runs before the browser paints, and a frame later the popup had
|
||||
// been drawn - and could be clicked or dragged - at its narrow size, then
|
||||
// jumped to the 960px column layout under the pointer. A click aimed at
|
||||
// Board View's "Lists" landed on "Calendar"'s place, and a drag clamped
|
||||
// for the narrow size (06-views-layout, card-title-edit-and-move-6839).
|
||||
// update() toggles a class only when it changes, so the attribute it
|
||||
// observes settles after one more call.
|
||||
this._menuLayoutObserver = new MutationObserver(update);
|
||||
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
|
||||
@@ -221,7 +257,8 @@ Popup.template.onRendered(function () {
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const element = this._popupElement;
|
||||
this._boundsObserver = new ResizeObserver(() => {
|
||||
if (!MOVABLE_POPUPS.has(element.dataset.popup) || element.classList.contains('pop-over--maximized')) return;
|
||||
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;
|
||||
@@ -252,6 +289,5 @@ 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,10 +14,10 @@
|
||||
.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")
|
||||
//- A movable popup (popup.js MOVABLE_POPUPS) is dragged by its title bar
|
||||
or this handle, like the opened card - and, like the card's, the handle
|
||||
is there only while drag handles are on; a maximizable one has the
|
||||
card's maximize / minimize button beside its close button.
|
||||
//- #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'}}")
|
||||
|
||||
@@ -43,3 +43,6 @@ template(name="themeColorPicker")
|
||||
input.js-theme-wheel(type="color" data-index="{{index}}" value="{{value}}" title="{{_ 'custom-color'}}")
|
||||
.theme-preview
|
||||
span.background-box(class="{{previewClass}}" style="{{previewStyle}}")
|
||||
//- WCAG 1.4.3: a colour that white text cannot be read on is darkened; this says so.
|
||||
if customAdjusted
|
||||
p.theme-custom-adjusted.js-theme-custom-adjusted {{_ 'custom-color'}}: {{customAdjusted}} (4.5:1)
|
||||
@@ -2,7 +2,7 @@ import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { isHexColor, toHex } from '/models/lib/contrastColor';
|
||||
import { isHexColor, toHex, readableAccent } from '/models/lib/contrastColor';
|
||||
import { BOARD_COLORS } from '/models/metadata/colors';
|
||||
import {
|
||||
THEME_CATEGORY_ORDER,
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
// 3. the user's own override
|
||||
// See docs/Features/General/Theme/Change-Color.md for the whole design.
|
||||
|
||||
const DEFAULT_WHEEL = ['#2980b9', '#6dd5fa']; // stock flat accent / second slide stop
|
||||
const DEFAULT_WHEEL = ['#2573a7', '#6dd5fa']; // stock flat accent / second slide stop
|
||||
|
||||
function readCurrent(scope) {
|
||||
if (scope === 'board') {
|
||||
@@ -138,7 +138,10 @@ Template.themeColorPicker.helpers({
|
||||
const tpl = Template.instance();
|
||||
const cat = customCategory(tpl);
|
||||
if (!allowsCustomColor(cat)) return '';
|
||||
const cc = tpl.customColors.get();
|
||||
// The colours as the theme applies them: made readable under white text
|
||||
// (models/lib/themeAccents.js activeAccent), so the preview is what the header
|
||||
// will be, not what the wheel showed.
|
||||
const cc = tpl.customColors.get().map(c => (c ? readableAccent(c) : c));
|
||||
if (!cc.some(Boolean)) return '';
|
||||
if (customColorCount(cat) === 2 && cc[0] && cc[1]) {
|
||||
return `background: linear-gradient(135deg, ${cc[0]}, ${cc[1]}) !important;`;
|
||||
@@ -146,6 +149,13 @@ Template.themeColorPicker.helpers({
|
||||
if (cc[0]) return `background: ${cc[0]} !important;`;
|
||||
return '';
|
||||
},
|
||||
// The colours that had to change to be readable, for the note under the preview.
|
||||
customAdjusted() {
|
||||
const tpl = Template.instance();
|
||||
const chosen = tpl.customColors.get().filter(Boolean).map(c => toHex(c)).filter(Boolean);
|
||||
const changed = chosen.map(c => readableAccent(c)).filter((c, i) => c !== chosen[i]);
|
||||
return changed.join(' ');
|
||||
},
|
||||
});
|
||||
|
||||
// Collect the custom colors to save: [] unless the user actually engaged a wheel, in
|
||||
|
||||
@@ -61,9 +61,20 @@ html.has-ui-font-size {
|
||||
* follow this anyway; the ones with a colour of their own - a red alert, a
|
||||
* green tick - keep saying what they mean. */
|
||||
body.has-ui-text-color,
|
||||
body.has-ui-text-color *:not(.fa) {
|
||||
body.has-ui-text-color *:not(.fa):not(
|
||||
#header, #header *,
|
||||
#header-quick-access, #header-quick-access *,
|
||||
.card-label, .card-label *,
|
||||
button.primary, button.primary *
|
||||
) {
|
||||
color: var(--wekan-ui-text-color) !important;
|
||||
}
|
||||
/* The elements left out of the rule above sit on a COLOUR of their own - the
|
||||
* theme's header bars, a label's colour, a primary button's accent - with a text
|
||||
* colour that is chosen to read on it (4.5:1, WCAG 1.4.3). The member's colour is
|
||||
* made legible on the page's surfaces (models/lib/uiFonts.js readableUiTextColor),
|
||||
* which is not the same as legible on those: #626262 on the blue header was
|
||||
* 1.18:1. */
|
||||
|
||||
/* There was a "text background color" here (#4759), a colour painted behind the
|
||||
* text. It is REMOVED. A background needs elements to sit on, and neither choice
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Tracker } from 'meteor/tracker';
|
||||
import { fontFamilyValue, fontSizeValue, fontScaleValue, colorValue } from '/models/lib/uiFonts';
|
||||
import { fontFamilyValue, fontSizeValue, fontScaleValue, readableUiTextColor } from '/models/lib/uiFonts';
|
||||
import { activeThemeName } from '/client/lib/activeTheme';
|
||||
|
||||
// #4759: apply the user's chosen UI font AND font-size preset to the whole UI by
|
||||
// setting CSS variables on :root and toggling marker classes on <body> (uiFont.css
|
||||
@@ -37,6 +38,17 @@ Meteor.startup(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// #6755: a member who hid the focus outline (Member Settings / Change Settings):
|
||||
// a class on <body>, which layouts.css turns into no ring.
|
||||
Tracker.autorun(() => {
|
||||
const user = Meteor.user();
|
||||
try {
|
||||
document.body.classList.toggle('has-hide-focus-outline', !!(user && user.profile && user.profile.hideFocusOutline === true));
|
||||
} catch (_) {
|
||||
// document.body may not exist yet in exotic embeddings; ignore.
|
||||
}
|
||||
});
|
||||
|
||||
Tracker.autorun(() => {
|
||||
const user = Meteor.user();
|
||||
const profile = (user && user.profile) || {};
|
||||
@@ -50,7 +62,9 @@ Meteor.startup(() => {
|
||||
// of it that happened to be written in rem. No class goes with it - a
|
||||
// variable that is not set falls back to 1 in the calc itself.
|
||||
toggle('--wekan-ui-font-scale', null, fontScaleValue(profile.uiFontSize));
|
||||
toggle('--wekan-ui-text-color', 'has-ui-text-color', colorValue(profile.uiTextColor));
|
||||
// The chosen colour, made legible on the surface of the theme in use (4.5:1,
|
||||
// WCAG 1.4.3): a colour that already reads is applied as chosen.
|
||||
toggle('--wekan-ui-text-color', 'has-ui-text-color', readableUiTextColor(profile.uiTextColor, activeThemeName()));
|
||||
// There was a `--wekan-ui-bg-color` here, for "text background color". It is
|
||||
// removed: a colour behind the text needs elements to sit on, and there is no
|
||||
// set of them that looks right - on the boxes it painted the page, on the text
|
||||
|
||||
@@ -22,7 +22,7 @@ section#notifications-drawer {
|
||||
padding-top: 36px;
|
||||
}
|
||||
section#notifications-drawer a:hover {
|
||||
color: #2980b9 !important;
|
||||
color: #2573a7 !important;
|
||||
}
|
||||
section#notifications-drawer .header {
|
||||
position: fixed;
|
||||
@@ -47,7 +47,7 @@ section#notifications-drawer .header .notification-menu-toggle {
|
||||
line-height: calc(24px * var(--wekan-ui-font-scale, 1));
|
||||
}
|
||||
section#notifications-drawer .header .notification-menu-toggle:hover {
|
||||
color: #2980b9;
|
||||
color: #2573a7;
|
||||
}
|
||||
section#notifications-drawer .header .notification-menu {
|
||||
position: absolute;
|
||||
@@ -90,7 +90,7 @@ section#notifications-drawer .header .notification-menu .menu-item .check-icon {
|
||||
min-width: 20px;
|
||||
margin-inline-end: 8px;
|
||||
text-align: center;
|
||||
color: #2980b9;
|
||||
color: #2573a7;
|
||||
font-weight: bold;
|
||||
}
|
||||
section#notifications-drawer .header .notification-menu .menu-item .menu-icon {
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
}
|
||||
/* Drag-and-drop workflow editor */
|
||||
.rules-workflow .workflow-help {
|
||||
color: #727479;
|
||||
color: var(--wekan-quiet-text, #5a5c61);
|
||||
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -257,7 +284,7 @@
|
||||
display: flex;
|
||||
}
|
||||
.triggers-content {
|
||||
color: #727479;
|
||||
color: var(--wekan-quiet-text, #5a5c61);
|
||||
background: #dedede;
|
||||
}
|
||||
.triggers-content .triggers-body {
|
||||
@@ -410,7 +437,7 @@
|
||||
}
|
||||
.rules-import-export .rules-ie-hint {
|
||||
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
|
||||
color: #727479;
|
||||
color: var(--wekan-quiet-text, #5a5c61);
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
.rules-import-export .rules-ie-textarea {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -281,6 +281,17 @@ template(name="attachments")
|
||||
.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'}}
|
||||
|
||||
@@ -32,6 +32,8 @@ const CLOUD_FIELD_IDS = {
|
||||
accessKeyId: '#s3-access-key',
|
||||
secretAccessKey: '#s3-secret-key',
|
||||
forcePathStyle: '#s3-force-path-style',
|
||||
serverSideEncryption: '#s3-sse',
|
||||
sseKmsKeyId: '#s3-sse-kms-key-id',
|
||||
},
|
||||
azure: {
|
||||
enabled: '#azure-enabled',
|
||||
@@ -858,6 +860,11 @@ Template.attachments.helpers({
|
||||
CLOUD_PROVIDERS.forEach(p => { out[p] = cloudConfigFromSettings(tpl, p).read !== false; });
|
||||
return out;
|
||||
},
|
||||
// The S3 server-side encryption option that is stored (models/lib/s3Encryption.js).
|
||||
s3SseSelected(mode) {
|
||||
const stored = cloudConfigFromSettings(Template.instance(), 's3').serverSideEncryption || '';
|
||||
return stored === mode ? 'selected' : null;
|
||||
},
|
||||
cloudValue() {
|
||||
const tpl = Template.instance();
|
||||
const s3 = cloudConfigFromSettings(tpl, 's3');
|
||||
@@ -872,6 +879,8 @@ Template.attachments.helpers({
|
||||
bucket: s3.bucket || '',
|
||||
accessKeyId: s3.accessKeyId || '',
|
||||
forcePathStyle: s3.forcePathStyle !== false,
|
||||
serverSideEncryption: s3.serverSideEncryption || '',
|
||||
sseKmsKeyId: s3.sseKmsKeyId || '',
|
||||
},
|
||||
azure: {
|
||||
accountName: azure.accountName || '',
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
}
|
||||
|
||||
.migration-progress-header {
|
||||
/* WeKan blue (matches the main header #2980b9) */
|
||||
background: linear-gradient(135deg, #2980b9 0%, #1a5276 100%);
|
||||
/* WeKan blue (matches the main header #2573a7) */
|
||||
background: linear-gradient(135deg, #2573a7 0%, #1a5276 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
@@ -87,7 +87,7 @@
|
||||
|
||||
.migration-progress-overall-fill {
|
||||
/* WeKan blue */
|
||||
background: linear-gradient(90deg, #2980b9, #3498db);
|
||||
background: linear-gradient(90deg, #2573a7, #3498db);
|
||||
height: 100%;
|
||||
border-radius: 10px;
|
||||
transition: width 0.3s ease;
|
||||
@@ -138,7 +138,7 @@
|
||||
|
||||
.migration-progress-step-fill {
|
||||
/* WeKan blue */
|
||||
background: linear-gradient(90deg, #3498db, #2980b9);
|
||||
background: linear-gradient(90deg, #3498db, #2573a7);
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
transition: width 0.3s ease;
|
||||
@@ -156,7 +156,7 @@
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
border-inline-start: 4px solid #2980b9;
|
||||
border-inline-start: 4px solid #2573a7;
|
||||
}
|
||||
|
||||
.migration-progress-status-label {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
//- Admin Panel / Problems / Password length (docs/Features/Admin-Panel/Problems/Password-length.md):
|
||||
//- the minimum, whether it is enforced, and who signs in with a shorter
|
||||
//- password - through the shared table page (docs/Features/General/Table.md).
|
||||
template(name="passwordLengthReport")
|
||||
ul.setting-detail.password-length-settings
|
||||
li
|
||||
label(for="password-min-length-input") {{_ 'password-min-length'}}
|
||||
input#password-min-length-input.js-password-min-length(type="number" min="1" max="256" value="{{minLength}}")
|
||||
p.description {{_ 'password-min-length-description'}}
|
||||
li
|
||||
a.flex.js-toggle-password-min-length-enforce
|
||||
.materialCheckBox(class="{{#if enforce}}is-checked{{/if}}")
|
||||
span {{_ 'password-min-length-enforce'}}
|
||||
p.description {{_ 'password-min-length-enforce-description'}}
|
||||
li
|
||||
button.primary.js-save-password-length(type="button") {{_ 'save'}}
|
||||
if saved
|
||||
span.password-length-saved {{_ 'password-length-saved'}}
|
||||
if saveError
|
||||
p.error.password-length-error= saveError
|
||||
if fromEnvironment
|
||||
p.description {{_ 'password-length-from-environment'}}
|
||||
+tablePage(tablePageData)
|
||||
@@ -0,0 +1,157 @@
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { Template } from 'meteor/templating';
|
||||
import { formatDateForDisplay } from '/client/lib/dateDisplay';
|
||||
import { buildFilters, buildHeader, buildRows, pageInfo } from '/models/lib/tablePage';
|
||||
import { errorMessage } from '/client/lib/errorMessage';
|
||||
|
||||
// Admin Panel / Problems / Password length: the two settings and the table of
|
||||
// accounts that sign in with a password shorter than the minimum
|
||||
// (server/lib/shortPasswordLogins.js; docs/Features/Admin-Panel/Problems/Password-length.md).
|
||||
const { addressValue, locationText, locationFlag } = require('/models/lib/addressReportColumns');
|
||||
const { DEFAULT_FILTER } = require('/models/lib/shortPasswordLogins');
|
||||
|
||||
const PER_PAGE = 25;
|
||||
const formatDate = date => (date ? formatDateForDisplay(date) : '');
|
||||
|
||||
// One cell per family: the address, then the city when a CDN in front of
|
||||
// WeKan said where it is, and the country's flag before both.
|
||||
const addressColumn = (labelKey, family) => ({
|
||||
labelKey,
|
||||
nowrap: true,
|
||||
value: row => [addressValue(row, family), locationText(row, family)].filter(Boolean).join(' '),
|
||||
flag: row => locationFlag(row, family),
|
||||
});
|
||||
|
||||
const COLUMNS = [
|
||||
{ labelKey: 'username', value: row => row.username || row.userId },
|
||||
{ labelKey: 'password-length-first-login', nowrap: true, value: row => formatDate(row.firstAt) },
|
||||
addressColumn('event-ipv4', 'ipv4'),
|
||||
addressColumn('event-ipv6', 'ipv6'),
|
||||
{ labelKey: 'password-length-last-login', nowrap: true, value: row => formatDate(row.at) },
|
||||
{ labelKey: 'password-length-login-count', align: 'end', value: row => row.count || 0 },
|
||||
{ labelKey: 'password-length-fixed', nowrap: true, value: row => formatDate(row.fixedAt) },
|
||||
];
|
||||
|
||||
const FILTER_OPTIONS = [
|
||||
{ value: 'short', labelKey: 'password-length-filter-short' },
|
||||
{ value: 'fixed', labelKey: 'password-length-filter-fixed' },
|
||||
{ value: 'all', labelKey: 'password-length-filter-all' },
|
||||
];
|
||||
|
||||
Template.passwordLengthReport.onCreated(function () {
|
||||
this.rows = new ReactiveVar([]);
|
||||
this.total = new ReactiveVar(0);
|
||||
this.page = new ReactiveVar(1);
|
||||
this.search = new ReactiveVar('');
|
||||
this.filter = new ReactiveVar(DEFAULT_FILTER);
|
||||
this.sources = new ReactiveVar(null);
|
||||
// The working copy of the two settings, until Save.
|
||||
this.minLength = new ReactiveVar(8);
|
||||
this.enforce = new ReactiveVar(false);
|
||||
this.saved = new ReactiveVar(false);
|
||||
this.error = new ReactiveVar('');
|
||||
|
||||
this.load = () => {
|
||||
Meteor.call('shortPasswordLoginsPage', {
|
||||
filter: this.filter.get(),
|
||||
search: this.search.get() || undefined,
|
||||
limit: PER_PAGE,
|
||||
skip: (this.page.get() - 1) * PER_PAGE,
|
||||
}, (error, result) => {
|
||||
if (error) return;
|
||||
this.rows.set((result && result.rows) || []);
|
||||
this.total.set((result && result.total) || 0);
|
||||
});
|
||||
};
|
||||
this.loadSettings = () => {
|
||||
Meteor.call('getAuthConfigSources', 'login', (error, result) => {
|
||||
if (error || !result) return;
|
||||
this.sources.set(result.sources || {});
|
||||
this.minLength.set(result.sources?.passwordMinLength?.value || 8);
|
||||
this.enforce.set(result.sources?.passwordMinLengthEnforce?.value === true);
|
||||
});
|
||||
};
|
||||
this.load();
|
||||
this.loadSettings();
|
||||
});
|
||||
|
||||
Template.passwordLengthReport.helpers({
|
||||
minLength() { return Template.instance().minLength.get(); },
|
||||
enforce() { return Template.instance().enforce.get(); },
|
||||
saved() { return Template.instance().saved.get(); },
|
||||
saveError() { return Template.instance().error.get(); },
|
||||
// An environment variable set on the host is overridden by a save here; say
|
||||
// so, rather than let the admin wonder which one is in effect.
|
||||
fromEnvironment() {
|
||||
const sources = Template.instance().sources.get() || {};
|
||||
return ['passwordMinLength', 'passwordMinLengthEnforce'].some(key => sources[key]?.source === 'env');
|
||||
},
|
||||
tablePageData() {
|
||||
const t = Template.instance();
|
||||
const rows = t.rows.get() || [];
|
||||
const info = pageInfo(t.total.get(), t.page.get(), PER_PAGE);
|
||||
return {
|
||||
descKey: 'password-length-report-desc',
|
||||
emptyKey: 'password-length-no-results',
|
||||
searchTerm: t.search.get(),
|
||||
filters: buildFilters([{
|
||||
id: 'password-length-status', labelKey: 'status', current: t.filter.get(), options: FILTER_OPTIONS,
|
||||
}]),
|
||||
header: buildHeader(COLUMNS),
|
||||
rows: buildRows(rows, COLUMNS),
|
||||
rowCount: rows.length,
|
||||
totalCount: t.total.get(),
|
||||
page: info.page,
|
||||
totalPages: info.totalPages,
|
||||
hasPrev: info.hasPrev,
|
||||
hasNext: info.hasNext,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
Template.passwordLengthReport.events({
|
||||
'input .js-password-min-length'(event, tmpl) {
|
||||
tmpl.minLength.set(parseInt(event.currentTarget.value, 10) || '');
|
||||
tmpl.saved.set(false);
|
||||
},
|
||||
'click .js-toggle-password-min-length-enforce'(event, tmpl) {
|
||||
event.preventDefault();
|
||||
tmpl.enforce.set(!tmpl.enforce.get());
|
||||
tmpl.saved.set(false);
|
||||
},
|
||||
'click .js-save-password-length'(event, tmpl) {
|
||||
event.preventDefault();
|
||||
Meteor.call('saveAuthConfigSettings', 'login', {
|
||||
passwordMinLength: tmpl.minLength.get() || null,
|
||||
passwordMinLengthEnforce: tmpl.enforce.get(),
|
||||
}, error => {
|
||||
tmpl.error.set(error ? errorMessage(error) : '');
|
||||
if (error) return;
|
||||
tmpl.saved.set(true);
|
||||
tmpl.loadSettings();
|
||||
});
|
||||
},
|
||||
'change .js-table-page-filter'(event, tmpl) {
|
||||
tmpl.filter.set(event.currentTarget.value || DEFAULT_FILTER);
|
||||
tmpl.page.set(1);
|
||||
tmpl.load();
|
||||
},
|
||||
'click .js-table-page-prev'(event, tmpl) {
|
||||
event.preventDefault();
|
||||
if (tmpl.page.get() > 1) { tmpl.page.set(tmpl.page.get() - 1); tmpl.load(); }
|
||||
},
|
||||
'click .js-table-page-next'(event, tmpl) {
|
||||
event.preventDefault();
|
||||
const info = pageInfo(tmpl.total.get(), tmpl.page.get(), PER_PAGE);
|
||||
if (info.hasNext) { tmpl.page.set(tmpl.page.get() + 1); tmpl.load(); }
|
||||
},
|
||||
'keydown .js-table-page-search'(event, tmpl) {
|
||||
if (event.keyCode === 13 && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
tmpl.search.set(event.currentTarget.value || '');
|
||||
tmpl.page.set(1);
|
||||
tmpl.load();
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -36,6 +36,8 @@ template(name="people")
|
||||
+authProviderSettings(loginOptionsData)
|
||||
//- Windows SSO, which works only on a Windows server.
|
||||
+authProviderSettings(windowsSsoData)
|
||||
//- Strict-Transport-Security, sent when ROOT_URL is https.
|
||||
+authProviderSettings(httpSecurityData)
|
||||
else if samlSetting
|
||||
+authProviderSettings(samlSettingsData)
|
||||
else if authConfigSection
|
||||
@@ -56,6 +58,8 @@ template(name="people")
|
||||
+peopleGeneral(peopleTablePageData)
|
||||
else if lockedUsersSetting.get
|
||||
+lockedUsersGeneral
|
||||
else if auditLogSetting.get
|
||||
+adminAuditLog
|
||||
else if rolesSetting.get
|
||||
+rolesGeneral
|
||||
else if templatesSetting.get
|
||||
|
||||
@@ -142,6 +142,7 @@ Template.people.onCreated(function () {
|
||||
this.teamSetting = new ReactiveVar(false);
|
||||
this.peopleSetting = new ReactiveVar(false);
|
||||
this.lockedUsersSetting = new ReactiveVar(false);
|
||||
this.auditLogSetting = new ReactiveVar(false);
|
||||
this.rolesSetting = new ReactiveVar(false);
|
||||
this.templatesSetting = new ReactiveVar(false);
|
||||
// #5850: Admin Panel > People > Domains tab. The domains table itself
|
||||
@@ -434,6 +435,7 @@ Template.people.onCreated(function () {
|
||||
this.teamSetting.set('team-setting' === targetID);
|
||||
this.peopleSetting.set('people-setting' === targetID);
|
||||
this.lockedUsersSetting.set('locked-users-setting' === targetID);
|
||||
this.auditLogSetting.set('audit-log-setting' === targetID);
|
||||
this.rolesSetting.set('roles-setting' === targetID);
|
||||
this.templatesSetting.set('templates-setting' === targetID);
|
||||
this.domainSetting.set('domains-setting' === targetID);
|
||||
@@ -551,6 +553,9 @@ function peopleMenu(user) {
|
||||
{ id: 'team-setting', icon: 'fa-users', labelKey: 'teams' },
|
||||
{ id: 'people-setting', icon: 'fa-user', labelKey: 'people' },
|
||||
{ id: 'locked-users-setting', icon: 'fa-lock', labelKey: 'accounts-lockout-locked-users', iconWrapCls: 'text-red' },
|
||||
// What site administrators did (models/lib/adminAudit.js). Site admins only:
|
||||
// tenantAdminPeopleMenu keeps a per-tenant admin's menu to its own panes.
|
||||
{ id: 'audit-log-setting', icon: 'fa-list-alt', labelKey: 'admin-audit-log' },
|
||||
{ id: 'roles-setting', icon: 'fa-key', labelKey: 'roles' },
|
||||
{ id: 'templates-setting', icon: 'fa-clone', labelKey: 'shared-templates' },
|
||||
{ id: 'registration-setting', icon: 'fa-key', labelKey: 'login', emoji: true },
|
||||
@@ -581,6 +586,15 @@ function peopleMenu(user) {
|
||||
return items;
|
||||
}
|
||||
|
||||
// A password the server refused (server/lib/passwordPolicy.js), in the
|
||||
// reader's language; the popup that sent it has already closed.
|
||||
function passwordErrorMessage(error) {
|
||||
const key = error && error.error;
|
||||
if (key === 'password-too-short') return TAPi18n.__('password-too-short', { min: error.details?.min });
|
||||
if (['password-pwned', 'password-same-as-username'].includes(key)) return TAPi18n.__(key);
|
||||
return error.reason || error.message;
|
||||
}
|
||||
|
||||
// People panes drawn from models/lib/authConfigCatalog.js, and the data the
|
||||
// catalog form (authProviderSettings) needs for one section.
|
||||
const AUTH_CONFIG_PANES = {
|
||||
@@ -899,6 +913,8 @@ Template.people.helpers({
|
||||
// The Login pane's own login settings (PASSWORD_LOGIN_ENABLED, ...).
|
||||
loginOptionsData() { return authConfigData('login'); },
|
||||
windowsSsoData() { return authConfigData('windowsSso'); },
|
||||
// HSTS (HSTS_MAX_AGE, ...), below the login settings.
|
||||
httpSecurityData() { return authConfigData('httpSecurity'); },
|
||||
ldapPane() { return Template.instance().activeMenuId.get() === 'ldap-setting'; },
|
||||
authenticationSection() {
|
||||
return {
|
||||
@@ -927,6 +943,9 @@ Template.people.helpers({
|
||||
lockedUsersSetting() {
|
||||
return Template.instance().lockedUsersSetting;
|
||||
},
|
||||
auditLogSetting() {
|
||||
return Template.instance().auditLogSetting;
|
||||
},
|
||||
rolesSetting() {
|
||||
return Template.instance().rolesSetting;
|
||||
},
|
||||
@@ -2010,7 +2029,7 @@ Template.modifyTeamsUsers.events({
|
||||
currentUser = ReactiveCache.getUser(selectedUserChkBoxUserIds[i]);
|
||||
// Copy, so the cached minimongo document is not mutated in place.
|
||||
userTms = (currentUser.teams || []).slice();
|
||||
currUserTeamIndex = userTms.findIndex(function(t){ return t.teamId == selectedEltValueId});
|
||||
currUserTeamIndex = userTms.findIndex(function(t){ return t.teamId == selectedEltValueId;});
|
||||
if(currUserTeamIndex == -1){
|
||||
userTms.push({
|
||||
"teamId": selectedEltValueId,
|
||||
@@ -2029,7 +2048,7 @@ Template.modifyTeamsUsers.events({
|
||||
for(let i = 0; i < selectedUserChkBoxUserIds.length; i++){
|
||||
currentUser = ReactiveCache.getUser(selectedUserChkBoxUserIds[i]);
|
||||
userTms = (currentUser.teams || []).slice();
|
||||
currUserTeamIndex = userTms.findIndex(function(t){ return t.teamId == selectedEltValueId});
|
||||
currUserTeamIndex = userTms.findIndex(function(t){ return t.teamId == selectedEltValueId;});
|
||||
if(currUserTeamIndex != -1){
|
||||
userTms.splice(currUserTeamIndex, 1);
|
||||
}
|
||||
@@ -2317,7 +2336,7 @@ Template.editUserPopup.events({
|
||||
userTms.push({
|
||||
"teamId": userTeamsIdsList[i],
|
||||
"teamDisplayName": userTeamsList[i],
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2330,7 +2349,7 @@ Template.editUserPopup.events({
|
||||
userOrganizations.push({
|
||||
"orgId": userOrgsIdsList[i],
|
||||
"orgDisplayName": userOrgsList[i],
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2352,7 +2371,9 @@ Template.editUserPopup.events({
|
||||
});
|
||||
|
||||
if (isChangePassword) {
|
||||
Meteor.call('setPassword', password, this.userId);
|
||||
Meteor.call('setPassword', password, this.userId, error => {
|
||||
if (error) alert(passwordErrorMessage(error));
|
||||
});
|
||||
}
|
||||
|
||||
if (isChangeEmailVerified) {
|
||||
@@ -2532,7 +2553,7 @@ const UpdateUserOrgsOrTeamsElement = function(isNewUser = false){
|
||||
}
|
||||
selectedElt.value = "-1";
|
||||
selectedElt.style.display = "none";
|
||||
}
|
||||
};
|
||||
|
||||
Template.newOrgPopup.events({
|
||||
submit(event, templateInstance) {
|
||||
@@ -2612,7 +2633,7 @@ Template.newUserPopup.events({
|
||||
userTms.push({
|
||||
"teamId": userTeamsIdsList[i],
|
||||
"teamDisplayName": userTeamsList[i],
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2624,7 +2645,7 @@ Template.newUserPopup.events({
|
||||
userOrganizations.push({
|
||||
"orgId": userOrgsIdsList[i],
|
||||
"orgDisplayName": userOrgsList[i],
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2651,6 +2672,8 @@ Template.newUserPopup.events({
|
||||
} else if (errorElement === 'email-already-taken') {
|
||||
usernameMessageElement.hide();
|
||||
emailMessageElement.show();
|
||||
} else if (String(errorElement).startsWith('password-')) {
|
||||
alert(passwordErrorMessage(error));
|
||||
}
|
||||
} else {
|
||||
usernameMessageElement.hide();
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
height: 100%;
|
||||
}
|
||||
.setting-content {
|
||||
color: #727479;
|
||||
color: var(--wekan-quiet-text, #5a5c61);
|
||||
background: #dedede;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -99,7 +99,7 @@
|
||||
selected entry back to white as soon as the pointer crossed it. */
|
||||
.setting-content .content-body .side-menu ul li.active,
|
||||
.setting-content .content-body .side-menu ul li.active:hover {
|
||||
background: var(--theme-accent, #2980b9);
|
||||
background: var(--theme-accent, #2573a7);
|
||||
}
|
||||
|
||||
/* …and when a THEME is active, the row is painted by that theme's own #header rule
|
||||
@@ -488,7 +488,7 @@ body.left-menu-resizing-active {
|
||||
}
|
||||
|
||||
.setting-content label:has(> input[type="checkbox"]:focus-visible) {
|
||||
outline: 2px solid #2980b9;
|
||||
outline: 2px solid #2573a7;
|
||||
outline-offset: 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
@@ -511,7 +511,7 @@ body.left-menu-resizing-active {
|
||||
}
|
||||
|
||||
.setting-content input[type="checkbox"]:focus-visible {
|
||||
outline: 2px solid var(--theme-accent, #2980b9);
|
||||
outline: 2px solid var(--theme-accent, #2573a7);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -562,13 +562,13 @@ body.left-menu-resizing-active {
|
||||
}
|
||||
|
||||
.setting-content .schedule-day-btn.active {
|
||||
background: var(--theme-accent, #2980b9);
|
||||
border-color: var(--theme-accent, #2980b9);
|
||||
background: var(--theme-accent, #2573a7);
|
||||
border-color: var(--theme-accent, #2573a7);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.setting-content .schedule-day-btn:focus-visible {
|
||||
outline: 2px solid var(--theme-accent, #2980b9);
|
||||
outline: 2px solid var(--theme-accent, #2573a7);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -630,6 +630,10 @@ template(name='tableVisibilityModeSettings')
|
||||
.title {{_ 'text-below-custom-login-logo'}}
|
||||
.form-group
|
||||
textarea#text-below-custom-login-logo.wekan-form-control(data-editor-submit=".js-visibility-logo-save")= currentSetting.textBelowCustomLoginLogo
|
||||
li.tableVisibilityMode-form
|
||||
.title {{_ 'login-background-color'}}
|
||||
.form-group
|
||||
input.wekan-form-control#login-background-color(type="text", placeholder="#1b365d" value="{{currentSetting.loginBackgroundColor}}")
|
||||
li.tableVisibilityMode-form
|
||||
.title {{_ 'custom-top-left-corner-logo-image-url'}}
|
||||
.form-group
|
||||
|
||||
@@ -811,6 +811,7 @@ Template.tableVisibilityModeSettings.events({
|
||||
['#custom-login-logo-image-url', 'customLoginLogoImageUrl'],
|
||||
['#custom-login-logo-link-url', 'customLoginLogoLinkUrl'],
|
||||
['#text-below-custom-login-logo', 'textBelowCustomLoginLogo'],
|
||||
['#login-background-color', 'loginBackgroundColor'],
|
||||
['#custom-top-left-corner-logo-image-url', 'customTopLeftCornerLogoImageUrl'],
|
||||
['#custom-top-left-corner-logo-link-url', 'customTopLeftCornerLogoLinkUrl'],
|
||||
['#custom-top-left-corner-logo-height', 'customTopLeftCornerLogoHeight'],
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
display: none;
|
||||
}
|
||||
.sidebar .sidebar-content h3 {
|
||||
color: #808080;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
font-size: 1em;
|
||||
margin-bottom: 10px;
|
||||
font-weight: bold;
|
||||
@@ -147,55 +147,17 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Board Settings / Card - also the opened card's "+ Add" - is the Admin
|
||||
Panel's left menu and right pane (sidebar.jade boardCardSettingsPopup,
|
||||
docs/Features/Cards/Card-Fields.md): the sections on the left, the open
|
||||
section's rows on the right, one line each - the label, then the
|
||||
"Show on Minicard" cell and the "Show on Card" cell (a checkbox, the
|
||||
position and the order arrows of that side), and opened from a card the
|
||||
"On this card" checkbox. */
|
||||
.board-card-settings {
|
||||
container-type: inline-size;
|
||||
}
|
||||
.card-settings-sections .setting-content {
|
||||
position: relative;
|
||||
/* High enough for every entry of the left menu - Card Settings and the
|
||||
twelve sections - where the window has the room, so the menu does not
|
||||
scroll; a shorter window caps it. */
|
||||
height: 52em;
|
||||
max-height: calc(100dvh - 7em);
|
||||
background: transparent;
|
||||
}
|
||||
.card-settings-sections .setting-content .content-body { height: 100%; }
|
||||
.card-settings-sections .main-body { overflow: auto; flex: 1 1 auto; min-width: 16em; }
|
||||
.card-settings-pane-head,
|
||||
/* 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;
|
||||
/* Two lists: Show on Card - opened from a card On this card first, then
|
||||
Show on Card, Show empty (#6806) and the field - and Show on Minicard,
|
||||
then the field. Logical order: a right-to-left page mirrors it. Each
|
||||
line is its own grid, so the side columns have FIXED widths - sized by
|
||||
content, every line and the heading line would size them differently and
|
||||
nothing would line up. */
|
||||
grid-template-columns: 9em 8em minmax(10em, 1fr);
|
||||
column-gap: 14px;
|
||||
align-items: center;
|
||||
/* Every heading and every cell starts at its column's left edge (its start,
|
||||
right-to-left), so a heading and the checkboxes under it line up. */
|
||||
justify-items: start;
|
||||
text-align: start;
|
||||
}
|
||||
.board-card-settings.has-card .card-settings-list-card > .card-settings-pane-head,
|
||||
.board-card-settings.has-card .card-settings-list-card > .card-settings-line {
|
||||
grid-template-columns: 7em 9em 8em minmax(10em, 1fr);
|
||||
}
|
||||
.card-settings-list-minicard > .card-settings-pane-head,
|
||||
.card-settings-list-minicard > .card-settings-line {
|
||||
grid-template-columns: 9em minmax(10em, 1fr);
|
||||
}
|
||||
/* The minicard's list under the card's, a little apart. */
|
||||
.card-settings-list + .card-settings-list {
|
||||
margin-block-start: 14px;
|
||||
padding: calc(4px * min(1, var(--wekan-ui-font-scale, 1))) 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
/* What a row is dragged by: its handle (drag handles on), its icon or name. */
|
||||
.card-field-order-drag-label {
|
||||
@@ -205,30 +167,6 @@
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.card-settings-pane-head {
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-block-end: 4px;
|
||||
align-items: start;
|
||||
}
|
||||
/* The headings' smaller font is on the headings, not on their line: the
|
||||
columns are in em, and a smaller font on the line made every column of the
|
||||
heading narrower than the same column of the rows - each heading drifted
|
||||
further left of its checkboxes, and the rows' labels past the pane. */
|
||||
.card-settings-pane-head > span {
|
||||
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
|
||||
}
|
||||
.card-settings-line {
|
||||
padding: calc(4px * min(1, var(--wekan-ui-font-scale, 1))) 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.card-settings-side-head {
|
||||
/* A heading longer than its column, in another language, wraps in it. */
|
||||
overflow-wrap: anywhere;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.card-settings-line-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -246,86 +184,19 @@
|
||||
.card-settings-side > a.flex {
|
||||
margin: 0;
|
||||
}
|
||||
/* Select all / Select none under a column's heading, for that column's rows
|
||||
of the open section: the Draggable table's buttons
|
||||
(boardSettingsColumnActions.css), a little closer to their heading. */
|
||||
.card-settings-select {
|
||||
margin: 3px 0 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
.card-settings-select button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.card-settings-inline-action {
|
||||
/* The forms float their buttons (Save left, Delete right); flow-root keeps
|
||||
those floats inside the form, or the rows below wrapped around them. */
|
||||
display: flow-root;
|
||||
padding: 6px 0 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.card-settings-inline-action h4 { margin: 0 0 6px; }
|
||||
/* Create Field and Link fields to another card are forms of their own in the
|
||||
Custom Fields pane: the list's two buttons that open them are not repeated. */
|
||||
.card-settings-inline-action .card-custom-fields-more { display: none; }
|
||||
.card-settings-sections .materialCheckBox { transition: none; }
|
||||
.card-settings-sections .card-add-field.is-animating .materialCheckBox,
|
||||
.card-settings-sections .card-field-order-toggle.is-animating .materialCheckBox,
|
||||
.card-settings-sections .card-always-show.is-animating .materialCheckBox,
|
||||
.card-settings-sections .card-show-empty-fields.is-animating .materialCheckBox,
|
||||
.card-settings-sections .card-ask-move-reason.is-animating .materialCheckBox { transition: 0.2s; }
|
||||
|
||||
.card-field-order-toggle,
|
||||
.card-field-order-move {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.card-field-order-move.is-disabled,
|
||||
.card-field-order-toggle.is-disabled,
|
||||
.card-always-show.is-disabled,
|
||||
.card-show-empty-fields.is-disabled,
|
||||
.card-ask-move-reason.is-disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Show empty fields: every field is shown on every card, so the Show empty
|
||||
column is set aside - hidden, and its track taken out of the grid. */
|
||||
.board-card-settings.show-empty-fields .card-settings-side-always,
|
||||
.board-card-settings.show-empty-fields .card-settings-side-head-always {
|
||||
display: none;
|
||||
}
|
||||
.board-card-settings.show-empty-fields .card-settings-list-card > .card-settings-pane-head,
|
||||
.board-card-settings.show-empty-fields .card-settings-list-card > .card-settings-line {
|
||||
grid-template-columns: 9em minmax(10em, 1fr);
|
||||
}
|
||||
.board-card-settings.show-empty-fields.has-card .card-settings-list-card > .card-settings-pane-head,
|
||||
.board-card-settings.show-empty-fields.has-card .card-settings-list-card > .card-settings-line {
|
||||
grid-template-columns: 7em 9em minmax(10em, 1fr);
|
||||
}
|
||||
.card-settings-board-option { margin-block-end: 6px; }
|
||||
|
||||
/* One side of the same popup: a menu that asks for one side hides the other
|
||||
side's list, so there is still ONE set of lists and one set of handlers. */
|
||||
.board-card-settings.show-card-only .card-settings-list-minicard,
|
||||
.board-card-settings.show-minicard-only .card-settings-list-card {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* "Labels text" and "List title" are the user's or the card's own, not the
|
||||
board's: they are not among the board-wide rows of Board Settings / Card,
|
||||
but they are where they belong - opened from a card, from the minicard's
|
||||
own view, and for a reader who may change only those. */
|
||||
.board-card-settings .card-settings-row-personal {
|
||||
display: none;
|
||||
}
|
||||
.board-card-settings.show-minicard-only .card-settings-row-personal,
|
||||
.board-card-settings.has-card .card-settings-row-personal,
|
||||
.board-card-settings.show-personal-only .card-settings-row-personal {
|
||||
display: grid;
|
||||
}
|
||||
.board-card-settings.show-personal-only .card-settings-line:not(.card-settings-row-personal) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 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
|
||||
name) - or three when Admin Panel hides public boards and the popup does
|
||||
@@ -879,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;
|
||||
|
||||
@@ -269,147 +269,39 @@ 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")
|
||||
//- Board Settings / Card, and the opened card's "+ Add": ONE popup, the Admin
|
||||
Panel's left menu and right pane (docs/Features/Cards/Card-Fields.md). The
|
||||
menu lists the sections (models/lib/cardSettingsSections.js); the pane the
|
||||
open section's rows, each with its "Show on Minicard" and "Show on Card"
|
||||
cell - a checkbox and the order arrows of that side - and, opened from a
|
||||
card, whether THIS card shows the field. A side cell is the
|
||||
`.js-card-field-order-row` the handlers read data-key and data-side from.
|
||||
form.board-card-settings.card-settings-sections(class=settingsSideClass)
|
||||
.setting-content
|
||||
.content-body
|
||||
+leftMenu(settingsMenuItems)
|
||||
.main-body
|
||||
+paneTitle(settingsPaneTitle)
|
||||
//- The first entry, Card: what is not a field, then its rows.
|
||||
if openSettingsSectionIsGeneral
|
||||
//- Every card shows all its fields, the empty ones too; the Show
|
||||
empty column is then set aside, its ticks kept.
|
||||
div.check-div.card-settings-board-option.card-settings-show-empty-fields
|
||||
a.flex.card-show-empty-fields(class="{{#if canModifyBoard}}js-show-empty-card-fields{{else}}is-disabled{{/if}} {{#if isAnimating 'showEmptyCardFields'}}is-animating{{/if}}" role="checkbox" aria-checked="{{#if showEmptyCardFields}}true{{else}}false{{/if}}")
|
||||
.materialCheckBox(class="{{#if showEmptyCardFields}}is-checked{{/if}}")
|
||||
span {{_ 'card-settings-show-empty-fields'}}
|
||||
//- The same checkbox, in the same place, as Show empty fields.
|
||||
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'}}
|
||||
//- No select-all of Show on Minicard / Show on Card here: their
|
||||
checkboxes are in the sections, not under it.
|
||||
+boardDragSettings(section="card")
|
||||
.card-settings-pane
|
||||
//- Under the title: everything in the hamburger of this section on
|
||||
the opened card - Filter: Subtasks, Edit voting, Card kind, the
|
||||
section's settings ... (docs/Features/Cards/Card-Fields.md). The
|
||||
card's own actions only when opened from a card.
|
||||
if sectionCardActions.length
|
||||
ul.pop-over-list.card-settings-card-actions
|
||||
each action in sectionCardActions
|
||||
li
|
||||
//- An inline action is its form, right here (Edit voting,
|
||||
Change spent time, Card kind, ...); the others open
|
||||
their popup.
|
||||
if action.inline
|
||||
.card-settings-inline-action
|
||||
h4
|
||||
i.fa(class=action.icon aria-hidden="true")
|
||||
| {{action.title}}
|
||||
+Template.dynamic(template=action.inline data=action.inlineData)
|
||||
else
|
||||
a.js-card-settings-action(data-action="{{action.id}}")
|
||||
i.fa(class=action.icon aria-hidden="true")
|
||||
| {{action.title}}
|
||||
//- Two lists, each in its own order and reordered by drag and
|
||||
drop (sidebar.js reorderOnDrop): Show on Card - the opened
|
||||
card's fields of this section, in the card's order, with On
|
||||
this card (opened from a card) and Show empty beside each - and
|
||||
Show on Minicard, in the minicard's order. Under each heading,
|
||||
Select all / Select none for that column. The columns are
|
||||
mirrored in a right-to-left language by the grid itself.
|
||||
.card-settings-list.card-settings-list-card.js-card-field-order-sortable(data-side="card")
|
||||
.card-settings-pane-head
|
||||
if settingsCardId
|
||||
span.card-settings-side-head.card-settings-side-head-this
|
||||
span {{_ 'card-settings-this-card'}}
|
||||
+cardSettingsSelectButtons(column="this")
|
||||
span.card-settings-side-head.card-settings-side-head-card
|
||||
span {{_ 'show-on-card'}}
|
||||
if canModifyBoard
|
||||
+cardSettingsSelectButtons(column="card")
|
||||
span.card-settings-side-head.card-settings-side-head-always
|
||||
span {{_ 'card-settings-show-empty'}}
|
||||
if canModifyBoard
|
||||
+cardSettingsSelectButtons(column="always")
|
||||
span.card-settings-pane-head-label {{_ 'card-field-order'}}
|
||||
each row in sectionCardRows
|
||||
.card-settings-line.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="card")
|
||||
//- Opened from a card: whether this card shows the field -
|
||||
the field with data is shown, an empty one when ticked
|
||||
here (models/lib/cardShownFields.js).
|
||||
if settingsCardId
|
||||
if row.perCard
|
||||
span.card-settings-side.card-settings-side-this
|
||||
a.flex.card-add-field.js-card-add-field(data-key="{{row.key}}" role="checkbox" aria-checked="{{#if row.shownOnThisCard}}true{{else}}false{{/if}}" class="{{#if isAnimating row.key}}is-animating{{/if}}" title="{{_ 'card-settings-this-card'}}")
|
||||
.materialCheckBox(class="{{#if row.shownOnThisCard}}is-checked{{/if}}")
|
||||
else
|
||||
span.card-settings-side.card-settings-side-this.card-settings-side-empty
|
||||
+cardSettingsSideToggle(row=row)
|
||||
//- #6806: Show empty - every card of the board shows the
|
||||
field, empty or not - by default the description,
|
||||
attachments and comments; "On this card" still hides it
|
||||
from one card. Hidden while Show empty fields is on.
|
||||
if row.alwaysShowable
|
||||
span.card-settings-side.card-settings-side-always
|
||||
a.flex.card-always-show(class="{{#if canModifyBoard}}js-card-always-show{{else}}is-disabled{{/if}} {{#if row.animatingAlways}}is-animating{{/if}}" data-key="{{row.key}}" role="checkbox" aria-checked="{{#if row.alwaysShown}}true{{else}}false{{/if}}" title="{{_ 'card-settings-show-empty'}}")
|
||||
.materialCheckBox(class="{{#if row.alwaysShown}}is-checked{{/if}}")
|
||||
else
|
||||
span.card-settings-side.card-settings-side-always.card-settings-side-empty
|
||||
+cardSettingsRowLabel(row=row)
|
||||
.card-settings-list.card-settings-list-minicard.js-card-field-order-sortable(data-side="minicard")
|
||||
.card-settings-pane-head
|
||||
span.card-settings-side-head.card-settings-side-head-minicard
|
||||
span {{_ 'show-on-minicard'}}
|
||||
if canModifyBoard
|
||||
+cardSettingsSelectButtons(column="minicard")
|
||||
span.card-settings-pane-head-label
|
||||
each row in sectionMinicardRows
|
||||
.card-settings-line.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="minicard")
|
||||
+cardSettingsSideToggle(row=row)
|
||||
+cardSettingsRowLabel(row=row)
|
||||
//- 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).
|
||||
The same animated checkbox as On this card; only the one clicked animates.
|
||||
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}}")
|
||||
@@ -417,12 +309,12 @@ template(name="cardSettingsSideToggle")
|
||||
|
||||
|
||||
//- A row's name in Card Settings, and what it is dragged by: with drag handles
|
||||
on, its handle, its icon or its name; with them off, its icon or 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.
|
||||
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}}js-card-field-order-handle card-field-order-drag-label{{/if}}" tabindex="{{#if row.draggable}}{{#unless row.showDragHandles}}0{{/unless}}{{/if}}" title="{{#if row.draggable}}{{_ 'drag-to-reorder'}}{{/if}}")
|
||||
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'}}")
|
||||
@@ -442,14 +334,169 @@ template(name="cardSettingsRowLabel")
|
||||
| ({{_ 'label-text-follows-board'}})
|
||||
|
||||
|
||||
//- Select all / Select none of one column of Card Settings' pane
|
||||
(boardCardSettingsPopup's 'click .js-card-settings-select').
|
||||
template(name="cardSettingsSelectButtons")
|
||||
//- The Draggable table's buttons (boardSettingsColumnActions): white text
|
||||
on the theme's colour.
|
||||
span.board-settings-column-actions.card-settings-select
|
||||
button.js-card-settings-select(type="button" data-column="{{column}}" data-value="all") {{_ 'select-all'}}
|
||||
button.js-card-settings-select(type="button" data-column="{{column}}" data-value="none") {{_ 'select-none'}}
|
||||
//- 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
|
||||
@@ -745,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
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 1111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user