forked from vikunja/frontend
Compare commits
751 Commits
Author | SHA1 | Date | |
---|---|---|---|
098b5fa2b1 | |||
5e4eb4a728 | |||
8930f61548 | |||
9a736cf65f | |||
2677f6254d | |||
bfcb36e093 | |||
9ec29cad30 | |||
dd3a5fe6b5 | |||
04642ae1ec | |||
eac19e28d6 | |||
11f94e4037 | |||
39cc7a00d8 | |||
02da1e171e | |||
ae177c73ea | |||
e6c4c18974 | |||
95487d7569 | |||
7b2a688b6e | |||
9df6950d1a | |||
6c2dc483a2 | |||
811254e6a9 | |||
85ffed4d9a | |||
5fb45afb12 | |||
fb14eca634 | |||
14e2698833 | |||
0d6c0c8399 | |||
5d38b8327f | |||
f747d5b2fc | |||
8a75790453 | |||
acb212ab24 | |||
4ba02ebbb6 | |||
244da46e38 | |||
f40035dc79 | |||
5f71e406fc | |||
|
3d11a4f03a | ||
1dfd2dc4b7 | |||
e9701660d3 | |||
c8dbb4c7ef | |||
1241d90268 | |||
3de5b65977 | |||
4a353553c3 | |||
1240f31c0a | |||
01ac84ce1e | |||
4c969f0a42 | |||
8e2c76a33e | |||
b3666ec27e | |||
2c6862c509 | |||
9f8c43818c | |||
0debca91c8 | |||
7b6c9fcd24 | |||
55675bf41b | |||
bb24b06031 | |||
dbce0376d5 | |||
40db144a41 | |||
f7ba3bd08f | |||
ac1d374191 | |||
391992effb | |||
2e9ade11c3 | |||
f11a8c543b | |||
e30a4452f2 | |||
6cc11e64ab | |||
7b05ed9d3d | |||
|
dba35c0107 | ||
|
bfbc874b1d | ||
dbccdb239a | |||
f13db9268a | |||
ed8de7e3eb | |||
b34118485c | |||
9c3259c660 | |||
a3e289c06c | |||
31b7c1f217 | |||
c30dcff451 | |||
086f50d4fe | |||
46e825820c | |||
a3e2cbeb27 | |||
a342ae67de | |||
e4d97e0520 | |||
b69a05689b | |||
6b824a49ab | |||
652db56d42 | |||
afaf1846ec | |||
ba452ab883 | |||
39f699a61a | |||
4ab547810c | |||
bbaddb9406 | |||
a2cc9ddc88 | |||
175e31ca62 | |||
d414b65e7d | |||
78158bcba5 | |||
9402344b7e | |||
3eca9f6180 | |||
26e3d42ed5 | |||
6e095436e9 | |||
1344026494 | |||
1a94496801 | |||
48570808e5 | |||
a7440ed296 | |||
12ebefd86a | |||
6c9cbaadc8 | |||
9b10693172 | |||
db1c6d6a41 | |||
c56787443f | |||
cb218ec0c3 | |||
0dd6f82a0e | |||
225091864f | |||
ebd9c4702e | |||
4ad9773022 | |||
0a17df87e9 | |||
b567146d69 | |||
65522a57f1 | |||
1d936618fa | |||
76814a2d3f | |||
4134fcbd75 | |||
49fac7db1c | |||
e25273df48 | |||
638f6bea24 | |||
ddcd6a17dc | |||
4e21b463df | |||
3db4e011d4 | |||
a0d39e6081 | |||
a803bc637e | |||
d4e452545a | |||
9d73ac661f | |||
55e912221b | |||
d85be26761 | |||
ac78e85e17 | |||
131022da42 | |||
336db56316 | |||
b5d9afd0f7 | |||
0be83db40f | |||
03f4d0b8bc | |||
ee8f80cc70 | |||
ce887c38f3 | |||
799c0be830 | |||
760efa854d | |||
26bec05174 | |||
c32a198a34 | |||
6a8c656dbb | |||
63ba2982c9 | |||
9d9fb959d8 | |||
8ed201c83f | |||
bfb40c9166 | |||
5ea450844c | |||
36bec9e64f | |||
a95014dc5d | |||
2579c33ee1 | |||
6f1baa3219 | |||
4dee3a90e9 | |||
326b6eda6f | |||
85e882cc59 | |||
e4379f0a22 | |||
2bb7ff1803 | |||
5dd6e9a077 | |||
f7629c28f4 | |||
be2a38b48e | |||
3ba5f531bb | |||
10f1e69bc3 | |||
fd7d90b017 | |||
d898316918 | |||
a6f524e7af | |||
5e65814b8c | |||
aaa9d553d0 | |||
5685890493 | |||
2e336150e0 | |||
749dcdcd70 | |||
ab94343d07 | |||
fa71cec5c8 | |||
c6f3829387 | |||
7171b63947 | |||
06c4c0d921 | |||
f2ca2d850d | |||
638d187a24 | |||
b188d40d3c | |||
3ad948305f | |||
be1f1d94c9 | |||
06e8cdb9d2 | |||
10311b79df | |||
ad2690b21c | |||
1bd17d6e50 | |||
a5e710bfe5 | |||
e1bdabc8d6 | |||
c6ef99dde2 | |||
49b508a783 | |||
52128925f5 | |||
cf0c7f9d08 | |||
57d5140301 | |||
dbd9106621 | |||
e4fef0e88e | |||
7ef0074ecc | |||
17c35f6d42 | |||
3a0844adba | |||
5b5b9022e0 | |||
|
0b0bd7dff6 | ||
079e3782d1 | |||
a0ae9ae54c | |||
a1b9a0ec4c | |||
1fa690670d | |||
3f0a87a5ec | |||
caf02f78bf | |||
9b9fd14d27 | |||
7f77efbfab | |||
53967d20cc | |||
ef3411f39a | |||
66e63f1363 | |||
2fe21f6b28 | |||
67df372636 | |||
df80e9da23 | |||
13ab2efd0f | |||
0ffe96cf59 | |||
1808d0971d | |||
ec83a28d78 | |||
f0320b3a58 | |||
d93a1a4f4f | |||
a9f9ddf6b9 | |||
6a8fe35fcf | |||
94661e9e09 | |||
318f63d098 | |||
e2c9e83c2a | |||
cd434a0e3e | |||
9f293af804 | |||
b175e00cfe | |||
|
19dd82d62a | ||
b3ddc9465a | |||
6b38f17d32 | |||
86449d4912 | |||
145d756251 | |||
838a11a2f6 | |||
3bfd3210b0 | |||
e933bfa99e | |||
f6a37a54d0 | |||
e00c9bb1af | |||
018707c3d5 | |||
386727f6c5 | |||
a29ce36d6c | |||
7aed16bd6f | |||
b1f3ca6e59 | |||
4c0b8a06c5 | |||
60647c50ac | |||
59eaf1849e | |||
fb57339050 | |||
f9831a6ad8 | |||
f25c67f80a | |||
d3b0b97192 | |||
fa3be219a8 | |||
c22702d911 | |||
b25c5ff547 | |||
2e0a097806 | |||
c2083f7924 | |||
8923261e5b | |||
5391df56b0 | |||
d2b1f5780e | |||
1717e968e1 | |||
2c29bb3971 | |||
37b8218a0a | |||
ca7bbb5b91 | |||
2f3c008d2b | |||
c2722b7c3d | |||
312abd907f | |||
1b73c1ed64 | |||
d442d6653b | |||
758b8d6e2b | |||
416fd2e2a7 | |||
15a8335f1a | |||
c689583669 | |||
7a43a7acc9 | |||
8843418161 | |||
7c1eab13ae | |||
5a69036da7 | |||
2ad3458873 | |||
eb464343e8 | |||
2f18d0cbad | |||
6cd463a514 | |||
05b70632c5 | |||
ca9fe6ff21 | |||
e5754300de | |||
65134048bf | |||
8339a99747 | |||
3e1ae41e70 | |||
f757ba3441 | |||
6499c9cb5b | |||
28e5440d8b | |||
fef8c4d0f4 | |||
99e5059c64 | |||
5df4f39d95 | |||
7ec5a70ccb | |||
72fcab6e78 | |||
292c90425e | |||
b80f070431 | |||
03936c0403 | |||
62825d2e64 | |||
5cd5caef45 | |||
798e8b529d | |||
0e3766c5a5 | |||
90207a4427 | |||
60993a886a | |||
a6b42f9181 | |||
98fbd7c53c | |||
8d533f50e8 | |||
707459ec77 | |||
faf7db649e | |||
202e71be48 | |||
d6e8b418d3 | |||
a9f41f6114 | |||
f6f0d52518 | |||
ccb9be42c2 | |||
179009bfe3 | |||
8c2bd94a9f | |||
7757166d75 | |||
7f03002972 | |||
8555006d9e | |||
713ad64658 | |||
0713d481e3 | |||
ace0cf3588 | |||
bba3bbfe89 | |||
754afc5496 | |||
f1e8892ab5 | |||
c11e192c4e | |||
e9c704075d | |||
35edcb5672 | |||
4695798176 | |||
7a323fd170 | |||
1d6e4b6e32 | |||
5524aa7998 | |||
15ff2008e3 | |||
9bc2e6e165 | |||
344001856c | |||
ad261fcc2f | |||
5142a0ae72 | |||
6d195f96c9 | |||
|
1917b217a8 | ||
1f6b01bc73 | |||
d47a16aa8e | |||
c57d00a74b | |||
77ea7fa0ee | |||
b92d780cda | |||
f14e721caf | |||
1ff6399112 | |||
503fb8da76 | |||
f050cb7015 | |||
3670916f36 | |||
|
838a063eaa | ||
e1b16b11d6 | |||
314cbf471f | |||
a416d26f7c | |||
795b26e1dd | |||
14666cf9d8 | |||
c938f31935 | |||
35a52ef01b | |||
3b05ce3f10 | |||
aec4fd7a2d | |||
2661af3a17 | |||
56f43bae3f | |||
84472d2e9c | |||
c5afcd63b0 | |||
9bdb257814 | |||
5ad9891b16 | |||
7c04064917 | |||
fb5383d86b | |||
68af314ec0 | |||
8b1de5ce09 | |||
724b6fe091 | |||
6648cd30c3 | |||
8b90b45739 | |||
39be67eecf | |||
|
750f0ddeab | ||
6a5ece2f24 | |||
|
4ce33abfe6 | ||
5b7e1af87d | |||
59c6605b14 | |||
820d598ecd | |||
a263ec1273 | |||
b68892492c | |||
7c97695cec | |||
e764f34a2d | |||
6892a28bb6 | |||
74d688b8d2 | |||
ed84651046 | |||
7468ed21fa | |||
d8015913c3 | |||
|
78789834f0 | ||
739fe0caa1 | |||
4703f9c4d5 | |||
fd699ad777 | |||
0acf44778d | |||
8fc254d2db | |||
|
7d3b97d422 | ||
4a34f245db | |||
973ea39a64 | |||
f94a65ce7a | |||
432fbbea78 | |||
e483f1cd2e | |||
eb34f6e136 | |||
91e9eef582 | |||
dea1789a00 | |||
30adad5ae6 | |||
3ed6f939e5 | |||
a337d22c1f | |||
addfcf2510 | |||
303034f02c | |||
0fd44e9484 | |||
04040f20ba | |||
6c999ad148 | |||
cc519e6773 | |||
f9dcae4f65 | |||
ade6c2cb18 | |||
4566b62a93 | |||
37d3ef24d2 | |||
71265769ce | |||
a13c16ca03 | |||
a33fb72ef8 | |||
c5776264c0 | |||
078d8b39a9 | |||
b77c7c2f45 | |||
e369473dd0 | |||
70501f9da1 | |||
9bb7019b09 | |||
df4fe7a644 | |||
2f009d0b27 | |||
70d7def7d7 | |||
0033407f96 | |||
b10a2329ca | |||
6870db4a72 | |||
3643ffe0d0 | |||
02971f6ff9 | |||
7d3c34b004 | |||
f3ea6fd4dc | |||
bed6b81a58 | |||
f9bf9139b8 | |||
96e2c81b7e | |||
e62c00a187 | |||
611419888a | |||
5cc7e282bf | |||
de0b71103c | |||
537e9e8044 | |||
ac95c1fdc8 | |||
b36da9e4d9 | |||
e11ee3c136 | |||
887719ea24 | |||
14f1c3b26e | |||
2142729d38 | |||
9dcc2baae2 | |||
37c88d2974 | |||
36fd0deec4 | |||
4a4438d431 | |||
28a6745346 | |||
9ae0470879 | |||
927aed1161 | |||
7f3d7a656d | |||
040a8ce095 | |||
|
8974939bf2 | ||
|
846de369f2 | ||
|
4d865af423 | ||
62ad01fc8f | |||
da0164b97d | |||
fc8711d6d8 | |||
03cef1f831 | |||
ee4974a494 | |||
bfbfd6a421 | |||
49954abbbe | |||
2f618512cb | |||
|
0086ebed0d | ||
1523ed9a47 | |||
79c7cbedcc | |||
3e128f3966 | |||
fb45483ffc | |||
a9bc7d7a38 | |||
78f032d678 | |||
d73b71a097 | |||
2bdc6155d7 | |||
f60cebf42c | |||
2e4c6673d4 | |||
6e3d64d6ef | |||
2deb66855b | |||
a64c0c19e5 | |||
24b4576c00 | |||
34ad889d90 | |||
af523cfcd7 | |||
842f204123 | |||
9162002e55 | |||
985f998a82 | |||
b93639e14e | |||
a4be973e29 | |||
060a573fe9 | |||
7c43b7385d | |||
befa6f27bb | |||
b9d3b5c756 | |||
ee732684bc | |||
360b530dd5 | |||
713c3a1a08 | |||
81b1e4035d | |||
2cde9341d4 | |||
cdf0690da6 | |||
80335e7b95 | |||
dbc2de14c9 | |||
c0f711d27f | |||
df24522490 | |||
6cf2e574bf | |||
e7b89ae44f | |||
72a1aaa654 | |||
c70c3b6080 | |||
401f2cdd7e | |||
013472e899 | |||
e9d48c442d | |||
6837038922 | |||
6cf7c75954 | |||
|
52d6677d93 | ||
f8f8c8ac6e | |||
97bd5d77b6 | |||
5278bcbac2 | |||
194fef0dab | |||
97b6ba06dd | |||
|
559cfde8da | ||
9db3aedde9 | |||
0eb78e32f9 | |||
b4dd23b85d | |||
2262b49aaf | |||
c887990bad | |||
37c5a88744 | |||
9b7770ade4 | |||
af4a039502 | |||
1b06112db4 | |||
0952f059c0 | |||
0f97ba6ec9 | |||
d4c9edb55d | |||
394f056cf4 | |||
7672676b6e | |||
51b33fd67e | |||
0ed3ebda94 | |||
|
7b6f76d1b4 | ||
ad0029789d | |||
e13f57c30a | |||
6a3518dace | |||
f1ec554d09 | |||
6aa02e29b1 | |||
5f9485414b | |||
149ceaf2e5 | |||
9b3e185dd4 | |||
779fe3e323 | |||
a27b77f24e | |||
41f22a1035 | |||
28d01c5ba0 | |||
|
e272dd8e64 | ||
c002275e7f | |||
1392d7f101 | |||
e5758e21c7 | |||
e0f06999be | |||
3b72acff27 | |||
df1c44aabe | |||
fe764a46e9 | |||
000e3080a5 | |||
f4c568e961 | |||
ef70ead3f0 | |||
7a326d6e03 | |||
afb6383a85 | |||
e49969dcad | |||
81e1d70847 | |||
5226517954 | |||
d5d0f9a8e2 | |||
2337b6c9f3 | |||
289802b13d | |||
5de9a2880f | |||
|
62f6895950 | ||
c198b9a164 | |||
d5f5e2a412 | |||
cabee68bbb | |||
2fd2214a2e | |||
64735e0c3d | |||
1f40b68108 | |||
4033c28a67 | |||
be20a01dd6 | |||
8f5a628e54 | |||
e0c00b306e | |||
10eaacc552 | |||
4b1465955a | |||
1711318212 | |||
b042547aaa | |||
ed0db956eb | |||
a66f8a6484 | |||
47e895149e | |||
8e00014feb | |||
6146340034 | |||
c7b761b0eb | |||
a1e84b3460 | |||
038debaa22 | |||
88faf04251 | |||
04be2b9745 | |||
815e8cce0e | |||
d12f9247ff | |||
85e7a17934 | |||
59c5d43348 | |||
c011f9aa52 | |||
b9f5319a4f | |||
f120ba4169 | |||
b2b70f4a9d | |||
9facffe3e9 | |||
c31aff1d88 | |||
60dea80462 | |||
cd10ccfbc0 | |||
8647402038 | |||
990fd46302 | |||
cf0aafd9e6 | |||
70d2535e93 | |||
|
0c6f1a4083 | ||
29eb42932a | |||
736e9051d8 | |||
4a4c401558 | |||
9198abe24d | |||
|
97c8970dd6 | ||
5303b6bc97 | |||
24a0a8f5eb | |||
|
d07ad495e2 | ||
8465afe421 | |||
d40729cbe7 | |||
fa0e46a399 | |||
b78481f9f6 | |||
cbc9cf6f7f | |||
62fd9a656e | |||
85269b4524 | |||
536d709961 | |||
59d6d7e786 | |||
ae86d0d42a | |||
9a20b7a853 | |||
5687b66ea5 | |||
1da411e1f6 | |||
e8a6d3f31b | |||
a25a795276 | |||
57f6abd99f | |||
84d205f90b | |||
de91e7c9ae | |||
2cf9c35acb | |||
db525db6eb | |||
88525ae7c8 | |||
957bfdc8f1 | |||
c52ae83b75 | |||
df40c4e475 | |||
3f41e9a3a6 | |||
1da510b5dd | |||
536db3fd46 | |||
cefa5250c5 | |||
f697640636 | |||
09b7595b68 | |||
6b7f73f724 | |||
|
d6b55c7570 | ||
|
3f4b08b8be | ||
791c61cabb | |||
e3dd4ef78a | |||
830d0887b9 | |||
e8db2c2b45 | |||
706a13242e | |||
13fab10584 | |||
4b0c8aa66b | |||
bfaf9401f4 | |||
13607124a6 | |||
9fc3d0a965 | |||
4d6286451e | |||
0479d17e69 | |||
5ca272959d | |||
c502f9b840 | |||
a3a313a21f | |||
c58d1ffd2e | |||
99dc5cf34f | |||
3604cb3ec7 | |||
aa01a92278 | |||
|
7b96397e3b | ||
b45a4e1aaf | |||
|
d3365d6add | ||
49cb2b9e6f | |||
d4ce10e79a | |||
345c5e3588 | |||
7ff84bcd29 | |||
d1633ef622 | |||
7e92bc63ac | |||
be076b65cf | |||
|
65b90cbee0 | ||
74aac1b245 | |||
ade791ed43 | |||
55b008c67c | |||
|
1f088cca18 | ||
6fad1e4969 | |||
eaeddda4e4 | |||
7cbf0acac5 | |||
3db5ea45d7 | |||
|
dcd5c3fd6a | ||
61fff44764 | |||
ecdae4e03e | |||
b26ea45fe0 | |||
7cb0cd293d | |||
6572f75e5d | |||
af55992057 | |||
e92559dc00 | |||
3dbf02fd7a | |||
81a4f2d977 | |||
2972d0d400 | |||
c11ebc44c4 | |||
144f90c5f7 | |||
913879604a | |||
1589ed5739 | |||
a991c537ac | |||
69b57aa23a | |||
1a1939963a | |||
3d62c9789c | |||
c18df8687c | |||
d83ba0c158 | |||
cea31d1da7 | |||
12509a7e0f | |||
dd43057a08 | |||
19d3cf01cd | |||
80012bf035 | |||
899d9e1cb7 | |||
56830ddadc | |||
1749d6ba0a | |||
b29008d304 | |||
8ae3054b1a | |||
f9dad79b23 | |||
30f5cb0656 | |||
3f58c983da | |||
8fa8b03aa6 | |||
e4499f44b7 | |||
b799233bca | |||
be0ae4bc29 | |||
60d99f3bba | |||
fa666d2817 | |||
9312aa14fa | |||
68e4f776b9 | |||
2d137d564e | |||
|
fc8824d942 | ||
6d4ca57601 | |||
d2bf4e38b1 | |||
a5f6857a40 | |||
ed3d79fa4c | |||
|
81c5c54aed | ||
793e06c6ac | |||
|
7eb07e92f8 | ||
2a15878b81 | |||
ebd2b1e8c0 | |||
d11fcfa072 | |||
|
8e6e976867 | ||
9adf1aba89 | |||
e67088fdb7 | |||
da241d21f3 | |||
97133010af | |||
4576da0dd3 | |||
fd4a68daf0 | |||
6f02d43801 | |||
2be784766f | |||
|
13a39be3de | ||
d2e07efc7d | |||
a44299e786 | |||
221f73c347 | |||
9b170d0d81 | |||
c6ed925424 | |||
7ed1a37de5 |
21
.drone.yml
21
.drone.yml
|
@ -15,6 +15,7 @@ trigger:
|
|||
services:
|
||||
- name: api
|
||||
image: vikunja/api:unstable
|
||||
pull: always
|
||||
environment:
|
||||
VIKUNJA_SERVICE_TESTINGTOKEN: averyLongSecretToSe33dtheDB
|
||||
VIKUNJA_LOG_LEVEL: DEBUG
|
||||
|
@ -41,11 +42,12 @@ steps:
|
|||
# - .cache
|
||||
|
||||
- name: dependencies
|
||||
image: node:18-alpine
|
||||
image: node:20-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
CYPRESS_CACHE_FOLDER: .cache/cypress
|
||||
PUPPETEER_SKIP_DOWNLOAD: true
|
||||
commands:
|
||||
- corepack enable && pnpm config set store-dir .cache/pnpm
|
||||
- pnpm install --fetch-timeout 100000
|
||||
|
@ -53,7 +55,7 @@ steps:
|
|||
# - restore-cache
|
||||
|
||||
- name: lint
|
||||
image: node:18-alpine
|
||||
image: node:20-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
|
@ -64,7 +66,7 @@ steps:
|
|||
- dependencies
|
||||
|
||||
- name: build-prod
|
||||
image: node:18-alpine
|
||||
image: node:20-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
|
@ -75,7 +77,7 @@ steps:
|
|||
- dependencies
|
||||
|
||||
- name: test-unit
|
||||
image: node:18-alpine
|
||||
image: node:20-alpine
|
||||
pull: always
|
||||
commands:
|
||||
- corepack enable && pnpm config set store-dir .cache/pnpm
|
||||
|
@ -85,7 +87,7 @@ steps:
|
|||
|
||||
- name: typecheck
|
||||
failure: ignore
|
||||
image: node:18-alpine
|
||||
image: node:20-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
|
@ -135,8 +137,9 @@ steps:
|
|||
# - dependencies
|
||||
|
||||
- name: deploy-preview
|
||||
image: node:18-alpine
|
||||
image: williamjackson/netlify-cli
|
||||
pull: always
|
||||
user: root # The rest runs as root and thus the permissions wouldn't work
|
||||
environment:
|
||||
NETLIFY_AUTH_TOKEN:
|
||||
from_secret: netlify_auth_token
|
||||
|
@ -199,7 +202,7 @@ steps:
|
|||
# - .cache
|
||||
|
||||
- name: build
|
||||
image: node:18-alpine
|
||||
image: node:20-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
|
@ -276,7 +279,7 @@ steps:
|
|||
# - .cache
|
||||
|
||||
- name: build
|
||||
image: node:18-alpine
|
||||
image: node:20-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
|
@ -521,6 +524,6 @@ steps:
|
|||
from_secret: crowdin_key
|
||||
---
|
||||
kind: signature
|
||||
hmac: 971875b90c7bb1649d1b00d022d0b594ba9b68f927bf8f0dbe840190816d676b
|
||||
hmac: 511c2a090e9efd4c942980d971204adb6321540bb01c92409dd9bf8463b7f6f4
|
||||
|
||||
...
|
||||
|
|
|
@ -1,8 +1,13 @@
|
|||
# Duplicate this file and remove the '.example' suffix.
|
||||
# Adjust the values as needed.
|
||||
# (1) Duplicate this file and remove the '.example' suffix.
|
||||
# Naming this file '.env.local' is a Vite convention to prevent accidentally
|
||||
# submitting to git.
|
||||
# For more info see: https://vitejs.dev/guide/env-and-mode.html#env-files
|
||||
|
||||
VITE_IS_ONLINE=true
|
||||
VITE_WORKBOX_DEBUG=false
|
||||
SENTRY_AUTH_TOKEN=YOUR_TOKEN
|
||||
SENTRY_ORG=vikunja
|
||||
SENTRY_PROJECT=frontend-oss
|
||||
# (2) Comment in and adjust the values as needed.
|
||||
|
||||
# VITE_IS_ONLINE=true
|
||||
# VITE_WORKBOX_DEBUG=false
|
||||
# SENTRY_AUTH_TOKEN=YOUR_TOKEN
|
||||
# SENTRY_ORG=vikunja
|
||||
# SENTRY_PROJECT=frontend-oss
|
||||
# VIKUNJA_FRONTEND_BASE=/custom-subpath
|
3
.github/FUNDING.yml
vendored
3
.github/FUNDING.yml
vendored
|
@ -1,2 +1,3 @@
|
|||
github: kolaente
|
||||
custom: https://www.buymeacoffee.com/kolaente
|
||||
open_collective: vikunja
|
||||
custom: ["https://vikunja.cloud", "https://www.buymeacoffee.com/kolaente"]
|
||||
|
|
14
.npmrc
14
.npmrc
|
@ -1,2 +1,14 @@
|
|||
fetch-timeout=100000
|
||||
|
||||
# pnpm settings
|
||||
# The following settings prepare for the new default value of pnpm 8
|
||||
# they can be removed directly after having moved to pnpm 8
|
||||
auto-install-peers=true
|
||||
fetch-timeout=100000
|
||||
dedupe-peer-dependents=true
|
||||
resolve-peers-from-workspace-root=true
|
||||
save-workspace-protocol=rolling
|
||||
resolution-mode=lowest-direct
|
||||
publishConfig.linkDirectory=true
|
||||
|
||||
# remove some time after having moved to pnpm 8
|
||||
use-lockfile-v6=true
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
"lokalise.i18n-ally",
|
||||
"mgmcdermott.vscode-language-babel",
|
||||
"mikestead.dotenv",
|
||||
"Syler.sass-indented"
|
||||
"Syler.sass-indented",
|
||||
"zixuanchen.vitest-explorer"
|
||||
]
|
||||
}
|
|
@ -18,6 +18,12 @@
|
|||
"javascriptreact",
|
||||
"vue"
|
||||
],
|
||||
|
||||
"volar.completion.preferredTagNameCase": "pascal",
|
||||
|
||||
// disable vetur in case it is installed
|
||||
"vetur.validation.template": false,
|
||||
|
||||
// i18n ally
|
||||
"i18n-ally.localesPaths": [
|
||||
"src/i18n/lang"
|
||||
|
|
273
CHANGELOG.md
273
CHANGELOG.md
|
@ -9,6 +9,279 @@ All releases can be found on https://code.vikunja.io/frontend/releases.
|
|||
|
||||
The releases aim at the api versions which is why there are missing versions.
|
||||
|
||||
## [0.20.5] - 2023-03-12
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* *(docker)* Add cap_net_bind to the nginx binary in the docker container
|
||||
* *(docker)* Revert unprivileged user
|
||||
|
||||
### Dependencies
|
||||
|
||||
* *(deps)* Update dependency sass to v1.59.2
|
||||
* *(deps)* Update dependency eslint to v8.36.0
|
||||
|
||||
## [0.20.4] - 2023-03-10
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* *(base)* Use Build Time Base Path
|
||||
* *(docker)* Cross compilation with buildx
|
||||
* *(docker)* Default api url
|
||||
* *(docker)* Make sure the service worker and webmanifest are never cached
|
||||
* *(filter)* Validate title before creating or editing a filter
|
||||
* *(filter)* Don't allow marking a filter as favorite
|
||||
* *(i18n)* Load language files before doing anything else (#3218)
|
||||
* *(keyboard-shortcuts)* Use card prop
|
||||
* *(list)* Make sure favorite lists are not duplicated in the menu when renaming them
|
||||
* *(menu)* Don't show drag handle for not draggable menu items
|
||||
* *(postcss-preset-env)* Client side polyfills (#3051)
|
||||
* *(quick actions)* Don't throw an error message when selecting the last items with the arrow keys
|
||||
* *(quick actions)* Hide edges of last entry on hover
|
||||
* *(quick add magic)* Correctly parse "next {weekday}" on the beginning of the text
|
||||
* *(quick-actions)* Nothing happening on team click (#3186)
|
||||
* *(table view)* Correctly load sort order from local storage
|
||||
* *(task)* Allow clicking on the whole task to open the task detail view
|
||||
* *(tests)* Only look in src for tests
|
||||
* Make sure global error handler handles unrejected promises correctly ([4576da0](4576da0dd394ee68801b1dc424c9550896d63737))
|
||||
* Use Build Time Base Path (#2964) ([6572f75](6572f75e5d111f7f2dd06e8c2ad0e0d16091fca6))
|
||||
* Always show update popup on top ([7cbf0ac](7cbf0acac503c508a44e0491ae51e6d5749dfa04))
|
||||
* Button styles ([d40729c](d40729cbe70b760bcc64d56130a410b05ef9d3dc))
|
||||
* Stop revealing elements on hover if hover is not supported (#3191) ([7b6f76d](7b6f76d1b4698d0d6c6889aaab3f1cdad80469f8))
|
||||
* Sync sidebar transition with `<main>` (#3200) ([0f97ba6](0f97ba6ec904226ed91cd3ade8223e2959e9207a))
|
||||
* Collapse menu on mobile when path changes ([1b06112](1b06112db4ba5ad4144b5868dd04e954be1d77f7))
|
||||
* I18ze a string (#3210) ([b4dd23b](b4dd23b85d909f7e629e953f1d8543ccbf963a1c))
|
||||
|
||||
|
||||
### Dependencies
|
||||
|
||||
* *(deps)* Update sentry-javascript monorepo to v7.33.0 (#3004)
|
||||
* *(deps)* Update dependency axios to v1.2.4 (#3005)
|
||||
* *(deps)* Update pnpm to v7.26.0 (#3002)
|
||||
* *(deps)* Update dependency cypress to v12.4.0 (#3006)
|
||||
* *(deps)* Update dependency @infectoone/vue-ganttastic to v2.1.4 (#3009)
|
||||
* *(deps)* Update dependency vitest to v0.28.2 (#3008)
|
||||
* *(deps)* Update dependency rollup to v3.11.0 (#3013)
|
||||
* *(deps)* Update dependency @vitejs/plugin-legacy to v3.0.2 (#3012)
|
||||
* *(deps)* Update dependency axios to v1.2.5
|
||||
* *(deps)* Update sentry-javascript monorepo to v7.34.0
|
||||
* *(deps)* Update pnpm to v7.26.1
|
||||
* *(deps)* Update dependency @vue/test-utils to v2.2.8
|
||||
* *(deps)* Update dependency vitest to v0.28.3 (#3019)
|
||||
* *(deps)* Update dependency cypress to v12.4.1
|
||||
* *(deps)* Update dependency rollup to v3.12.0
|
||||
* *(deps)* Update dependency esbuild to v0.17.5
|
||||
* *(deps)* Update dependency axios to v1.2.6
|
||||
* *(deps)* Update dependency @vueuse/core to v9.12.0
|
||||
* *(deps)* Update pnpm to v7.26.2
|
||||
* *(deps)* Update dependency eslint to v8.33.0
|
||||
* *(deps)* Update dependency netlify-cli to v12.10.0
|
||||
* *(deps)* Update dependency happy-dom to v8.2.0
|
||||
* *(deps)* Update dependency caniuse-lite to v1.0.30001449
|
||||
* *(deps)* Update dependency typescript to v4.9.5
|
||||
* *(deps)* Update typescript-eslint monorepo to v5.50.0
|
||||
* *(deps)* Update dependency axios to v1.3.0 (#3036)
|
||||
* *(deps)* Update dependency sass to v1.58.0
|
||||
* *(deps)* Update dependency cypress to v12.5.0
|
||||
* *(deps)* Update pnpm to v7.26.3
|
||||
* *(deps)* Update dependency rollup to v3.12.1
|
||||
* *(deps)* Update sentry-javascript monorepo to v7.35.0 (#3041)
|
||||
* *(deps)* Update dependency pinia to v2.0.30 (#3042)
|
||||
* *(deps)* Update dependency @vue/test-utils to v2.2.9
|
||||
* *(deps)* Update dependency axios to v1.3.1
|
||||
* *(deps)* Update dependency vue to v3.2.47
|
||||
* *(deps)* Update dependency vite to v4.1.0
|
||||
* *(deps)* Update dependency postcss-preset-env to v8 (#3000)
|
||||
* *(deps)* Update dependency @vitejs/plugin-legacy to v4
|
||||
* *(deps)* Update dependency @vitejs/plugin-legacy to v4.0.1
|
||||
* *(deps)* Update sentry-javascript monorepo to v7.36.0
|
||||
* *(deps)* Update dependency vite to v4.1.1
|
||||
* *(deps)* Update dependency cypress to v12.5.1
|
||||
* *(deps)* Update dependency @vue/test-utils to v2.2.10
|
||||
* *(deps)* Update dependency vitest to v0.28.4
|
||||
* *(deps)* Update dependency rollup to v3.13.0
|
||||
* *(deps)* Update dependency axios to v1.3.2
|
||||
* *(deps)* Update dependency rollup to v3.14.0
|
||||
* *(deps)* Update dependency @types/node to v18.11.19
|
||||
* *(deps)* Update dependency @histoire/plugin-screenshot to v0.13.0
|
||||
* *(deps)* Update dependency histoire to v0.13.0
|
||||
* *(deps)* Update caniuse-and-related
|
||||
* *(deps)* Update dependency @histoire/plugin-vue to v0.13.0
|
||||
* *(deps)* Update dependency happy-dom to v8.2.6
|
||||
* *(deps)* Update typescript-eslint monorepo to v5.51.0
|
||||
* *(deps)* Update dependency esbuild to v0.17.6
|
||||
* *(deps)* Update dependency @cypress/vue to v5.0.4
|
||||
* *(deps)* Update dependency @types/node to v18.13.0
|
||||
* *(deps)* Update dependency vite-plugin-pwa to v0.14.2
|
||||
* *(deps)* Update font awesome to v6.3.0
|
||||
* *(deps)* Update pnpm to v7.27.0
|
||||
* *(deps)* Update dependency @histoire/plugin-screenshot to v0.13.1
|
||||
* *(deps)* Update dependency @histoire/plugin-vue to v0.13.1
|
||||
* *(deps)* Update dependency vite-plugin-pwa to v0.14.3
|
||||
* *(deps)* Update dependency histoire to v0.13.1
|
||||
* *(deps)* Update dependency @histoire/plugin-screenshot to v0.13.2
|
||||
* *(deps)* Update dependency @histoire/plugin-vue to v0.13.2
|
||||
* *(deps)* Update dependency histoire to v0.13.2
|
||||
* *(deps)* Update dependency @intlify/unplugin-vue-i18n to v0.8.2
|
||||
* *(deps)* Update sentry-javascript monorepo to v7.37.0
|
||||
* *(deps)* Update dependency esbuild to v0.17.7
|
||||
* *(deps)* Update dependency rollup to v3.15.0
|
||||
* *(deps)* Create a group for all histoire dependencies
|
||||
* *(deps)* Update dependency @histoire/plugin-vue to v0.14.0
|
||||
* *(deps)* Update dependency @histoire/plugin-screenshot to v0.14.0
|
||||
* *(deps)* Update dependency @histoire/plugin-vue to v0.14.0
|
||||
* *(deps)* Update dependency histoire to v0.14.0
|
||||
* *(deps)* Update sentry-javascript monorepo to v7.37.1
|
||||
* *(deps)* Update dependency histoire to v0.14.2
|
||||
* *(deps)* Include histoire main package in histoire renovate group
|
||||
* *(deps)* Histoire renovate group
|
||||
* *(deps)* Update dependency eslint to v8.34.0
|
||||
* *(deps)* Update histoire to v0.14.2
|
||||
* *(deps)* Update dependency vite-plugin-pwa to v0.14.4
|
||||
* *(deps)* Update dependency esbuild to v0.17.8
|
||||
* *(deps)* Update dependency netlify-cli to v12.12.0
|
||||
* *(deps)* Update dependency caniuse-lite to v1.0.30001451
|
||||
* *(deps)* Update dependency vite-plugin-inject-preload to v1.3.0
|
||||
* *(deps)* Update dependency vitest to v0.28.5
|
||||
* *(deps)* Update sentry-javascript monorepo to v7.37.2
|
||||
* *(deps)* Update dependency dompurify to v3 (#3107)
|
||||
* *(deps)* Update typescript-eslint monorepo to v5.52.0
|
||||
* *(deps)* Update dependency axios to v1.3.3
|
||||
* *(deps)* Update dependency start-server-and-test to v1.15.4 (#3109)
|
||||
* *(deps)* Update dependency sass to v1.58.1
|
||||
* *(deps)* Update dependency vue-flatpickr-component to v11.0.2 (#3112)
|
||||
* *(deps)* Update dependency @kyvg/vue3-notification to v2.9.0 (#3113)
|
||||
* *(deps)* Update histoire to v0.15.1
|
||||
* *(deps)* Update histoire to v0.15.3
|
||||
* *(deps)* Update dependency vue-tsc to v1.1.0
|
||||
* *(deps)* Pin node.js to 18.14.0
|
||||
* *(deps)* Update dependency cypress to v12.6.0 (#3115)
|
||||
* *(deps)* Update histoire to v0.15.4
|
||||
* *(deps)* Update dependency vue-tsc to v1.1.2
|
||||
* *(deps)* Update dependency sass to v1.58.2
|
||||
* *(deps)* Update dependency ufo to v1.1.0
|
||||
* *(deps)* Update node.js to v18.14.1
|
||||
* *(deps)* Update dependency vite to v4.1.2
|
||||
* *(deps)* Update sentry-javascript monorepo to v7.38.0
|
||||
* *(deps)* Update dependency rollup to v3.16.0
|
||||
* *(deps)* Update histoire to v0.15.7
|
||||
* *(deps)* Update dependency blurhash to v2.0.5
|
||||
* *(deps)* Update dependency @cypress/vite-dev-server to v5.0.3
|
||||
* *(deps)* Update dependency @types/node to v18.14.0
|
||||
* *(deps)* Update histoire to v0.15.8
|
||||
* *(deps)* Update dependency @vueuse/core to v9.13.0
|
||||
* *(deps)* Update dependency rollup to v3.17.0
|
||||
* *(deps)* Update pnpm to v7.27.1
|
||||
* *(deps)* Update dependency vue-tsc to v1.1.3
|
||||
* *(deps)* Update dependency sass to v1.58.3
|
||||
* *(deps)* Update dependency rollup to v3.17.1
|
||||
* *(deps)* Update dependency esbuild to v0.17.9
|
||||
* *(deps)* Update dependency vite to v4.1.3
|
||||
* *(deps)* Update dependency @vue/test-utils to v2.3.0
|
||||
* *(deps)* Update dependency caniuse-lite to v1.0.30001457
|
||||
* *(deps)* Update dependency codemirror to v5.65.12
|
||||
* *(deps)* Update dependency pinia to v2.0.31
|
||||
* *(deps)* Update dependency vue-tsc to v1.1.4
|
||||
* *(deps)* Update dependency rollup to v3.17.2
|
||||
* *(deps)* Update dependency happy-dom to v8.6.0
|
||||
* *(deps)* Update dependency netlify-cli to v12.13.2
|
||||
* *(deps)* Update dependency esbuild to v0.17.10
|
||||
* *(deps)* Update typescript-eslint monorepo to v5.53.0
|
||||
* *(deps)* Update dependency vue-tsc to v1.1.5
|
||||
* *(deps)* Update dependency pinia to v2.0.32
|
||||
* *(deps)* Update node.js to v18.14.2
|
||||
* *(deps)* Update dependency vite to v4.1.4
|
||||
* *(deps)* Update dependency vue-tsc to v1.1.7
|
||||
* *(deps)* Update dependency axios to v1.3.4
|
||||
* *(deps)* Update dependency @types/node to v18.14.1
|
||||
* *(deps)* Update dependency @cypress/vite-dev-server to v5.0.4
|
||||
* *(deps)* Update dependency cypress to v12.7.0
|
||||
* *(deps)* Update dependency vue-tsc to v1.2.0
|
||||
* *(deps)* Update dependency vitest to v0.29.1
|
||||
* *(deps)* Update pnpm to v7.28.0
|
||||
* *(deps)* Update dependency eslint to v8.35.0
|
||||
* *(deps)* Update dependency rollup to v3.17.3
|
||||
* *(deps)* Update dependency netlify-cli to v13
|
||||
* *(deps)* Update dependency happy-dom to v8.9.0
|
||||
* *(deps)* Update dependency caniuse-lite to v1.0.30001458
|
||||
* *(deps)* Update dependency start-server-and-test to v1.15.5
|
||||
* *(deps)* Update dependency start-server-and-test to v2
|
||||
* *(deps)* Update dependency @types/node to v18.14.2
|
||||
* *(deps)* Update sentry-javascript monorepo to v7.39.0
|
||||
* *(deps)* Update typescript-eslint monorepo to v5.54.0
|
||||
* *(deps)* Update dependency ufo to v1.1.1
|
||||
* *(deps)* Update dependency vitest to v0.29.2
|
||||
* *(deps)* Update dependency rollup to v3.18.0
|
||||
* *(deps)* Update dependency dompurify to v3.0.1
|
||||
* *(deps)* Update sentry-javascript monorepo to v7.40.0
|
||||
* *(deps)* Update dependency @types/node to v18.14.4
|
||||
* *(deps)* Update dependency @types/node to v18.14.5
|
||||
* *(deps)* Update dependency @types/node to v18.14.6
|
||||
* *(deps)* Update dependency esbuild to v0.17.11
|
||||
* *(deps)* Update dependency netlify-cli to v13.0.1
|
||||
* *(deps)* Update dependency caniuse-lite to v1.0.30001460
|
||||
* *(deps)* Update pnpm to v7.29.0
|
||||
* *(deps)* Update sentry-javascript monorepo to v7.41.0
|
||||
* *(deps)* Update typescript-eslint monorepo to v5.54.1
|
||||
* *(deps)* Update dependency pinia to v2.0.33
|
||||
* *(deps)* Update node.js to v18.15.0
|
||||
* *(deps)* Update dependency @intlify/unplugin-vue-i18n to v0.9.0
|
||||
* *(deps)* Update pnpm to v7.29.1
|
||||
* *(deps)* Update dependency @vue/test-utils to v2.3.1
|
||||
* *(deps)* Update dependency @intlify/unplugin-vue-i18n to v0.9.1
|
||||
* *(deps)* Update sentry-javascript monorepo to v7.42.0
|
||||
* *(deps)* Update dependency rollup to v3.19.0
|
||||
* *(deps)* Update dependency vite-plugin-inject-preload to v1.3.1
|
||||
* *(deps)* Update dependency @types/node to v18.15.0
|
||||
* *(deps)* Update dependency autoprefixer to v10.4.14
|
||||
* *(deps)* Update dependency rollup to v3.19.1
|
||||
|
||||
### Features
|
||||
|
||||
* *(config)* Support Setting Base Path in .env
|
||||
* Use v-show for navigation buttons ([7ed1a37](7ed1a37de53cb8c15994e9524a52080170db5950))
|
||||
* Unindent settings page (#2996) ([13a39be](13a39be3de4d0f7e0f6be9c20e0464e86b87c676))
|
||||
* Small content auth improvements (#2998) ([2be7847](2be784766f54810f8969e48291ce9181f2854a5b))
|
||||
* Move update from navigation to app ([3db5ea4](3db5ea45d768d10458eaab0f5ee9dad0df2996e4))
|
||||
* Improve naming and styles ([eaeddda](eaeddda4e468c2040862d18c9b2d37a1c0ba099e))
|
||||
* Use klona instead of lodash.clonedeep (#3073) ([7b96397](7b96397e3bfa43a393ca84439069290bc4c8a5c8))
|
||||
* Refactor to composable ([c502f9b](c502f9b840ee2d65193aa4ef29c7f260b49db0d2))
|
||||
* Header improvements ([e8db2c2](e8db2c2b458bcae592609d5a5bc3f1b333651b25))
|
||||
* Persistent menuActive state with Local Storage (#3011) ([e3dd4ef](e3dd4ef78ac818add138d0323bf65abe8a4caa29))
|
||||
* Fix calculation of token invalidation (#3077) ([d6b55c7](d6b55c757067413bbc34acd48af9fb553f36db8a))
|
||||
* Use renovate js-app as preset (#3087) ([97c8970](97c8970dd60b2ba1e894ca0039524c8f6a5cd5df))
|
||||
* Improve recommended vscode settings ([e0f0699](e0f06999beb0a9fb5da817323744307401e85e47))
|
||||
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
* *(refactor)* Improve `stores/config` types (#3190)
|
||||
* *(services)* Add examples for some functions
|
||||
* *(services)* Let `getAll`: always return `Model[]`
|
||||
* Move class name to top ([c6ed925](c6ed9254247efeb43e0763e095b145d6ec1965e1))
|
||||
* Simplify error handling for login and OpenId Auth ([e67088f](e67088fdb7bd3b24cea6ee37851ef45f1fb7bdad))
|
||||
* Simplify getting the error text from an exception ([9adf1ab](9adf1aba895a02f416148ddf8b6925689d6e2687))
|
||||
* Typo ([81a4f2d](81a4f2d9775716bc0056348664fc24185af040d4))
|
||||
* Update funding links ([7cb0cd2](7cb0cd293d6d277172eccf2558a62427bc86dfe6))
|
||||
* Update funding links ([b26ea45](b26ea45fe0d1d6f5f070ef42a5d68aa6db8e6b70))
|
||||
* Remove minimist dependency (not used anywhere) ([f697640](f697640636466e8f035c7d31597ee589379fa017))
|
||||
* Remove sponsor ([fa0e46a](fa0e46a3991ab423c9364b65439d9e8e5a28cb7b))
|
||||
* Histoire add logo link ([af4a039](af4a039502b29e9e7e21cf30d44715c7af056c15))
|
||||
* Improve `@/message` `action` type (#3209) ([0eb78e3](0eb78e32f994e7032725e38d564320a5a04cbf2a))
|
||||
* Remove an unused duplicate key ([9db3aed](9db3aedde9566fb94717e1dd66a21abdbda6e84a))
|
||||
|
||||
|
||||
### Other
|
||||
|
||||
* *(other)* Add Ipv6 support to nginx (#100)
|
||||
* *(other)* Added ipv6 control script
|
||||
* *(other)* Disable listening on IPv6 ports when IPv6 is not supported (#102)
|
||||
* *(other)* Docker refactoring (#3018)
|
||||
* *(other)* Persist menuActive state in Local Storage
|
||||
* *(other)* Refactor to only used local storage value when on desktop viewport widths
|
||||
* *(other)* Solve for resize()
|
||||
* *(other)* [skip ci] Updated translations via Crowdin
|
||||
|
||||
## [0.20.3] - 2023-01-24
|
||||
|
||||
### Bug Fixes
|
||||
|
|
88
Dockerfile
88
Dockerfile
|
@ -1,49 +1,69 @@
|
|||
# Stage 1: Build application
|
||||
FROM --platform=$BUILDPLATFORM node:18-alpine AS compile-image
|
||||
# syntax=docker/dockerfile:1
|
||||
# ┬─┐┬ ┐o┬ ┬─┐
|
||||
# │─││ │││ │ │
|
||||
# ┘─┘┘─┘┘┘─┘┘─┘
|
||||
|
||||
FROM --platform=$BUILDPLATFORM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
ARG USE_RELEASE=false
|
||||
ARG RELEASE_VERSION=main
|
||||
|
||||
ENV PNPM_CACHE_FOLDER .cache/pnpm/
|
||||
ADD . ./
|
||||
|
||||
RUN \
|
||||
if [ $USE_RELEASE = true ]; then \
|
||||
wget https://dl.vikunja.io/frontend/vikunja-frontend-$RELEASE_VERSION.zip -O frontend-release.zip && \
|
||||
unzip frontend-release.zip -d dist/ && \
|
||||
exit 0; \
|
||||
fi && \
|
||||
# https://pnpm.io/installation#using-corepack
|
||||
corepack enable && \
|
||||
# we don't use corepack prepare here by intend since
|
||||
# we have renovate to keep our dependencies up to date
|
||||
# Build the frontend
|
||||
pnpm install && \
|
||||
apk add --no-cache git && \
|
||||
echo '{"VERSION": "'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'"}' > src/version.json && \
|
||||
pnpm run build
|
||||
COPY package.json ./
|
||||
COPY pnpm-lock.yaml ./
|
||||
|
||||
# Stage 2: copy
|
||||
FROM nginx:alpine
|
||||
RUN if [ "$USE_RELEASE" != true ]; then \
|
||||
# https://pnpm.io/installation#using-corepack
|
||||
corepack enable && \
|
||||
pnpm install; \
|
||||
fi
|
||||
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY scripts/run.sh /run.sh
|
||||
COPY . ./
|
||||
|
||||
# copy compiled files from stage 1
|
||||
COPY --from=compile-image /build/dist /usr/share/nginx/html
|
||||
RUN if [ "$USE_RELEASE" != true ]; then \
|
||||
apk add --no-cache --virtual .build-deps git jq && \
|
||||
git describe --tags --always --abbrev=10 | sed 's/-/+/; s/^v//; s/-g/-/' | \
|
||||
xargs -0 -I{} jq -Mcnr --arg version {} '{VERSION:$version}' | \
|
||||
tee src/version.json && \
|
||||
apk del .build-deps; \
|
||||
fi
|
||||
|
||||
# Unprivileged user
|
||||
ENV PUID 1000
|
||||
ENV PGID 1000
|
||||
RUN if [ "$USE_RELEASE" = true ]; then \
|
||||
wget "https://dl.vikunja.io/frontend/vikunja-frontend-${RELEASE_VERSION}.zip" -O frontend-release.zip && \
|
||||
unzip frontend-release.zip -d dist/; \
|
||||
else \
|
||||
# we don't use corepack prepare here by intend since
|
||||
# we have renovate to keep our dependencies up to date
|
||||
# Build the frontend
|
||||
pnpm run build; \
|
||||
fi
|
||||
|
||||
# ┌┐┐┌─┐o┌┐┐┐ │
|
||||
# ││││ ┬││││┌┼┘
|
||||
# ┘└┘┘─┘┘┘└┘┘ └
|
||||
|
||||
FROM nginx:stable-alpine AS runner
|
||||
WORKDIR /usr/share/nginx/html
|
||||
LABEL maintainer="maintainers@vikunja.io"
|
||||
|
||||
RUN apk add --no-cache \
|
||||
# for sh file
|
||||
bash \
|
||||
# installs usermod and groupmod
|
||||
shadow
|
||||
ENV VIKUNJA_HTTP_PORT 80
|
||||
ENV VIKUNJA_HTTP2_PORT 81
|
||||
ENV VIKUNJA_LOG_FORMAT main
|
||||
ENV VIKUNJA_API_URL /api/v1
|
||||
ENV VIKUNJA_SENTRY_ENABLED false
|
||||
ENV VIKUNJA_SENTRY_DSN https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480
|
||||
ENV VIKUNJA_PROJECT_INFINITE_NESTING_ENABLED false
|
||||
|
||||
CMD "/run.sh"
|
||||
COPY docker/injector.sh /docker-entrypoint.d/50-injector.sh
|
||||
COPY docker/ipv6-disable.sh /docker-entrypoint.d/60-ipv6-disable.sh
|
||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY docker/templates/. /etc/nginx/templates/
|
||||
# copy compiled files from stage 1
|
||||
COPY --from=builder /build/dist ./
|
||||
# manage permissions
|
||||
RUN chmod 0755 /docker-entrypoint.d/*.sh /etc/nginx/templates && \
|
||||
chmod -R 0644 /etc/nginx/nginx.conf && \
|
||||
chown -R nginx:nginx ./ /etc/nginx/conf.d /etc/nginx/templates && \
|
||||
rm -f /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
|
||||
|
|
13
README.md
13
README.md
|
@ -4,7 +4,7 @@
|
|||
|
||||
[![Build Status](https://drone.kolaente.de/api/badges/vikunja/frontend/status.svg)](https://drone.kolaente.de/vikunja/frontend)
|
||||
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](LICENSE)
|
||||
[![Download](https://img.shields.io/badge/download-v0.20.3-brightgreen.svg)](https://dl.vikunja.io)
|
||||
[![Download](https://img.shields.io/badge/download-v0.20.5-brightgreen.svg)](https://dl.vikunja.io)
|
||||
[![Translation](https://badges.crowdin.net/vikunja/localized.svg)](https://crowdin.com/project/vikunja)
|
||||
|
||||
This is the web frontend for Vikunja, written in Vue.js.
|
||||
|
@ -18,6 +18,14 @@ If you find any security-related issues you don't want to disclose publicly, ple
|
|||
## Docker
|
||||
|
||||
There is a [docker image available](https://hub.docker.com/r/vikunja/api) with support for http/2 and aggressive caching enabled.
|
||||
In order to build it from sources run the command below. (Docker >= v19.03)
|
||||
|
||||
```shell
|
||||
export DOCKER_BUILDKIT=1
|
||||
docker build -t vikunja/frontend .
|
||||
```
|
||||
|
||||
Refer to Refer [to multi-platform documentation](https://docs.docker.com/build/building/multi-platform/) in order to build for the different platform.
|
||||
|
||||
## Project setup
|
||||
|
||||
|
@ -43,6 +51,3 @@ pnpm run build
|
|||
pnpm run lint
|
||||
```
|
||||
|
||||
## Sponsors
|
||||
|
||||
[![Relm](https://vikunja.io/images/sponsors/relm.png)](https://relm.us)
|
||||
|
|
|
@ -24,4 +24,5 @@ export default defineConfig({
|
|||
},
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
experimentalMemoryManagement: true,
|
||||
})
|
||||
|
|
|
@ -1,57 +0,0 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {ListFactory} from '../../factories/list'
|
||||
import {prepareLists} from './prepareLists'
|
||||
|
||||
describe('List History', () => {
|
||||
createFakeUserAndLogin()
|
||||
prepareLists()
|
||||
|
||||
it('should show a list history on the home page', () => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/namespaces*').as('loadNamespaces')
|
||||
cy.intercept(Cypress.env('API_URL') + '/lists/*').as('loadList')
|
||||
|
||||
const lists = ListFactory.create(6)
|
||||
|
||||
cy.visit('/')
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.get('body')
|
||||
.should('not.contain', 'Last viewed')
|
||||
|
||||
cy.visit(`/lists/${lists[0].id}`)
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.wait('@loadList')
|
||||
cy.visit(`/lists/${lists[1].id}`)
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.wait('@loadList')
|
||||
cy.visit(`/lists/${lists[2].id}`)
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.wait('@loadList')
|
||||
cy.visit(`/lists/${lists[3].id}`)
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.wait('@loadList')
|
||||
cy.visit(`/lists/${lists[4].id}`)
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.wait('@loadList')
|
||||
cy.visit(`/lists/${lists[5].id}`)
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.wait('@loadList')
|
||||
|
||||
// cy.visit('/')
|
||||
// cy.wait('@loadNamespaces')
|
||||
// Not using cy.visit here to work around the redirect issue fixed in #1337
|
||||
cy.get('nav.menu.top-menu a')
|
||||
.contains('Overview')
|
||||
.click()
|
||||
|
||||
cy.get('body')
|
||||
.should('contain', 'Last viewed')
|
||||
cy.get('[data-cy="listCardGrid"]')
|
||||
.should('not.contain', lists[0].title)
|
||||
.should('contain', lists[1].title)
|
||||
.should('contain', lists[2].title)
|
||||
.should('contain', lists[3].title)
|
||||
.should('contain', lists[4].title)
|
||||
.should('contain', lists[5].title)
|
||||
})
|
||||
})
|
|
@ -1,122 +0,0 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {prepareLists} from './prepareLists'
|
||||
|
||||
describe('Lists', () => {
|
||||
createFakeUserAndLogin()
|
||||
|
||||
let lists
|
||||
prepareLists((newLists) => (lists = newLists))
|
||||
|
||||
it('Should create a new list', () => {
|
||||
cy.visit('/')
|
||||
cy.get('.namespace-title .dropdown-trigger')
|
||||
.click()
|
||||
cy.get('.namespace-title .dropdown .dropdown-item')
|
||||
.contains('New list')
|
||||
.click()
|
||||
cy.url()
|
||||
.should('contain', '/lists/new/1')
|
||||
cy.get('.card-header-title')
|
||||
.contains('New list')
|
||||
cy.get('input.input')
|
||||
.type('New List')
|
||||
cy.get('.button')
|
||||
.contains('Create')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification', { timeout: 1000 }) // Waiting until the request to create the new list is done
|
||||
.should('contain', 'Success')
|
||||
cy.url()
|
||||
.should('contain', '/lists/')
|
||||
cy.get('.list-title h1')
|
||||
.should('contain', 'New List')
|
||||
})
|
||||
|
||||
it('Should redirect to a specific list view after visited', () => {
|
||||
cy.visit('/lists/1/kanban')
|
||||
cy.url()
|
||||
.should('contain', '/lists/1/kanban')
|
||||
cy.visit('/lists/1')
|
||||
cy.url()
|
||||
.should('contain', '/lists/1/kanban')
|
||||
})
|
||||
|
||||
it('Should rename the list in all places', () => {
|
||||
TaskFactory.create(5, {
|
||||
id: '{increment}',
|
||||
list_id: 1,
|
||||
})
|
||||
const newListName = 'New list name'
|
||||
|
||||
cy.visit('/lists/1')
|
||||
cy.get('.list-title h1')
|
||||
.should('contain', 'First List')
|
||||
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
|
||||
.click()
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .dropdown-content')
|
||||
.contains('Edit')
|
||||
.click()
|
||||
cy.get('#title')
|
||||
.type(`{selectall}${newListName}`)
|
||||
cy.get('footer.card-footer .button')
|
||||
.contains('Save')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
cy.get('.list-title h1')
|
||||
.should('contain', newListName)
|
||||
.should('not.contain', lists[0].title)
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child')
|
||||
.should('contain', newListName)
|
||||
.should('not.contain', lists[0].title)
|
||||
cy.visit('/')
|
||||
cy.get('.card-content')
|
||||
.should('contain', newListName)
|
||||
.should('not.contain', lists[0].title)
|
||||
})
|
||||
|
||||
it('Should remove a list', () => {
|
||||
cy.visit(`/lists/${lists[0].id}`)
|
||||
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
|
||||
.click()
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .dropdown-content')
|
||||
.contains('Delete')
|
||||
.click()
|
||||
cy.url()
|
||||
.should('contain', '/settings/delete')
|
||||
cy.get('[data-cy="modalPrimary"]')
|
||||
.contains('Do it')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list')
|
||||
.should('not.contain', lists[0].title)
|
||||
cy.location('pathname')
|
||||
.should('equal', '/')
|
||||
})
|
||||
|
||||
it('Should archive a list', () => {
|
||||
cy.visit(`/lists/${lists[0].id}`)
|
||||
|
||||
cy.get('.list-title .dropdown')
|
||||
.click()
|
||||
cy.get('.list-title .dropdown .dropdown-menu .dropdown-item')
|
||||
.contains('Archive')
|
||||
.click()
|
||||
cy.get('.modal-content')
|
||||
.should('contain.text', 'Archive this list')
|
||||
cy.get('.modal-content [data-cy=modalPrimary]')
|
||||
.click()
|
||||
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list')
|
||||
.should('not.contain', lists[0].title)
|
||||
cy.get('main.app-content')
|
||||
.should('contain.text', 'This list is archived. It is not possible to create new or edit tasks for it.')
|
||||
})
|
||||
})
|
|
@ -1,145 +0,0 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {ListFactory} from '../../factories/list'
|
||||
import {NamespaceFactory} from '../../factories/namespace'
|
||||
|
||||
describe('Namepaces', () => {
|
||||
createFakeUserAndLogin()
|
||||
|
||||
let namespaces
|
||||
|
||||
beforeEach(() => {
|
||||
namespaces = NamespaceFactory.create(1)
|
||||
ListFactory.create(1)
|
||||
})
|
||||
|
||||
it('Should be all there', () => {
|
||||
cy.visit('/namespaces')
|
||||
cy.get('[data-cy="namespace-title"]')
|
||||
.should('contain', namespaces[0].title)
|
||||
})
|
||||
|
||||
it('Should create a new Namespace', () => {
|
||||
const newNamespaceTitle = 'New Namespace'
|
||||
|
||||
cy.visit('/namespaces')
|
||||
cy.get('[data-cy="new-namespace"]')
|
||||
.should('contain', 'New namespace')
|
||||
.click()
|
||||
|
||||
cy.url()
|
||||
.should('contain', '/namespaces/new')
|
||||
cy.get('.card-header-title')
|
||||
.should('contain', 'New namespace')
|
||||
cy.get('input.input')
|
||||
.type(newNamespaceTitle)
|
||||
cy.get('.button')
|
||||
.contains('Create')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
cy.get('.namespace-container')
|
||||
.should('contain', newNamespaceTitle)
|
||||
cy.url()
|
||||
.should('contain', '/namespaces')
|
||||
})
|
||||
|
||||
it('Should rename the namespace all places', () => {
|
||||
const newNamespaces = NamespaceFactory.create(5)
|
||||
const newNamespaceName = 'New namespace name'
|
||||
|
||||
cy.visit('/namespaces')
|
||||
|
||||
cy.get(`.namespace-container .menu.namespaces-lists .namespace-title:contains(${newNamespaces[0].title}) .dropdown .dropdown-trigger`)
|
||||
.click()
|
||||
cy.get('.namespace-container .menu.namespaces-lists .namespace-title .dropdown .dropdown-content')
|
||||
.contains('Edit')
|
||||
.click()
|
||||
cy.url()
|
||||
.should('contain', '/settings/edit')
|
||||
cy.get('#namespacetext')
|
||||
.invoke('val')
|
||||
.should('equal', newNamespaces[0].title) // wait until the namespace data is loaded
|
||||
cy.get('#namespacetext')
|
||||
.type(`{selectall}${newNamespaceName}`)
|
||||
cy.get('footer.card-footer .button')
|
||||
.contains('Save')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification', { timeout: 1000 })
|
||||
.should('contain', 'Success')
|
||||
cy.get('.namespace-container .menu.namespaces-lists')
|
||||
.should('contain', newNamespaceName)
|
||||
.should('not.contain', newNamespaces[0].title)
|
||||
cy.get('[data-cy="namespaces-list"]')
|
||||
.should('contain', newNamespaceName)
|
||||
.should('not.contain', newNamespaces[0].title)
|
||||
})
|
||||
|
||||
it('Should remove a namespace when deleting it', () => {
|
||||
const newNamespaces = NamespaceFactory.create(5)
|
||||
|
||||
cy.visit('/')
|
||||
|
||||
cy.get(`.namespace-container .menu.namespaces-lists .namespace-title:contains(${newNamespaces[0].title}) .dropdown .dropdown-trigger`)
|
||||
.click()
|
||||
cy.get('.namespace-container .menu.namespaces-lists .namespace-title .dropdown .dropdown-content')
|
||||
.contains('Delete')
|
||||
.click()
|
||||
cy.url()
|
||||
.should('contain', '/settings/delete')
|
||||
cy.get('[data-cy="modalPrimary"]')
|
||||
.contains('Do it')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
cy.get('.namespace-container .menu.namespaces-lists')
|
||||
.should('not.contain', newNamespaces[0].title)
|
||||
})
|
||||
|
||||
it('Should not show archived lists & namespaces if the filter is not checked', () => {
|
||||
const n = NamespaceFactory.create(1, {
|
||||
id: 2,
|
||||
is_archived: true,
|
||||
}, false)
|
||||
ListFactory.create(1, {
|
||||
id: 2,
|
||||
namespace_id: n[0].id,
|
||||
}, false)
|
||||
|
||||
ListFactory.create(1, {
|
||||
id: 3,
|
||||
is_archived: true,
|
||||
}, false)
|
||||
|
||||
// Initial
|
||||
cy.visit('/namespaces')
|
||||
cy.get('.namespace')
|
||||
.should('not.contain', 'Archived')
|
||||
|
||||
// Show archived
|
||||
cy.get('[data-cy="show-archived-check"] label.check span')
|
||||
.should('be.visible')
|
||||
.click()
|
||||
cy.get('[data-cy="show-archived-check"] input')
|
||||
.should('be.checked')
|
||||
cy.get('.namespace')
|
||||
.should('contain', 'Archived')
|
||||
|
||||
// Don't show archived
|
||||
cy.get('[data-cy="show-archived-check"] label.check span')
|
||||
.should('be.visible')
|
||||
.click()
|
||||
cy.get('[data-cy="show-archived-check"] input')
|
||||
.should('not.be.checked')
|
||||
|
||||
// Second time visiting after unchecking
|
||||
cy.visit('/namespaces')
|
||||
cy.get('[data-cy="show-archived-check"] input')
|
||||
.should('not.be.checked')
|
||||
cy.get('.namespace')
|
||||
.should('not.contain', 'Archived')
|
||||
})
|
||||
})
|
|
@ -1,19 +0,0 @@
|
|||
import {ListFactory} from '../../factories/list'
|
||||
import {NamespaceFactory} from '../../factories/namespace'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
|
||||
export function createLists() {
|
||||
NamespaceFactory.create(1)
|
||||
const lists = ListFactory.create(1, {
|
||||
title: 'First List'
|
||||
})
|
||||
TaskFactory.truncate()
|
||||
return lists
|
||||
}
|
||||
|
||||
export function prepareLists(setLists = () => {}) {
|
||||
beforeEach(() => {
|
||||
const lists = createLists()
|
||||
setLists(lists)
|
||||
})
|
||||
}
|
|
@ -1,18 +1,18 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {ListFactory} from '../../factories/list'
|
||||
import {NamespaceFactory} from '../../factories/namespace'
|
||||
import {UserListFactory} from '../../factories/users_list'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {UserProjectFactory} from '../../factories/users_project'
|
||||
import {BucketFactory} from '../../factories/bucket'
|
||||
|
||||
describe('Editor', () => {
|
||||
createFakeUserAndLogin()
|
||||
|
||||
beforeEach(() => {
|
||||
NamespaceFactory.create(1)
|
||||
ListFactory.create(1)
|
||||
ProjectFactory.create(1)
|
||||
BucketFactory.create(1)
|
||||
TaskFactory.truncate()
|
||||
UserListFactory.truncate()
|
||||
UserProjectFactory.truncate()
|
||||
})
|
||||
|
||||
it('Has a preview with checkable checkboxes', () => {
|
||||
|
@ -24,6 +24,7 @@ describe('Editor', () => {
|
|||
* [ ] Checklist
|
||||
* [x] Checklist checked
|
||||
`,
|
||||
bucket_id: 1,
|
||||
})
|
||||
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
|
|
@ -8,20 +8,20 @@ describe('The Menu', () => {
|
|||
})
|
||||
|
||||
it('Is visible by default on desktop', () => {
|
||||
cy.get('.namespace-container')
|
||||
cy.get('.menu-container')
|
||||
.should('have.class', 'is-active')
|
||||
})
|
||||
|
||||
it('Can be hidden on desktop', () => {
|
||||
cy.get('button.menu-show-button:visible')
|
||||
.click()
|
||||
cy.get('.namespace-container')
|
||||
cy.get('.menu-container')
|
||||
.should('not.have.class', 'is-active')
|
||||
})
|
||||
|
||||
it('Is hidden by default on mobile', () => {
|
||||
cy.viewport('iphone-8')
|
||||
cy.get('.namespace-container')
|
||||
cy.get('.menu-container')
|
||||
.should('not.have.class', 'is-active')
|
||||
})
|
||||
|
||||
|
@ -29,7 +29,7 @@ describe('The Menu', () => {
|
|||
cy.viewport('iphone-8')
|
||||
cy.get('button.menu-show-button:visible')
|
||||
.click()
|
||||
cy.get('.namespace-container')
|
||||
cy.get('.menu-container')
|
||||
.should('have.class', 'is-active')
|
||||
})
|
||||
})
|
||||
|
|
17
cypress/e2e/project/prepareProjects.ts
Normal file
17
cypress/e2e/project/prepareProjects.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import {ProjectFactory} from '../../factories/project'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
|
||||
export function createProjects() {
|
||||
const projects = ProjectFactory.create(1, {
|
||||
title: 'First Project'
|
||||
})
|
||||
TaskFactory.truncate()
|
||||
return projects
|
||||
}
|
||||
|
||||
export function prepareProjects(setProjects = (...args: any[]) => {}) {
|
||||
beforeEach(() => {
|
||||
const projects = createProjects()
|
||||
setProjects(projects)
|
||||
})
|
||||
}
|
50
cypress/e2e/project/project-history.spec.ts
Normal file
50
cypress/e2e/project/project-history.spec.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {prepareProjects} from './prepareProjects'
|
||||
|
||||
describe('Project History', () => {
|
||||
createFakeUserAndLogin()
|
||||
prepareProjects()
|
||||
|
||||
it('should show a project history on the home page', () => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects*').as('loadProjectArray')
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/*').as('loadProject')
|
||||
|
||||
const projects = ProjectFactory.create(6)
|
||||
|
||||
cy.visit('/')
|
||||
cy.wait('@loadProjectArray')
|
||||
cy.get('body')
|
||||
.should('not.contain', 'Last viewed')
|
||||
|
||||
cy.visit(`/projects/${projects[0].id}`)
|
||||
cy.wait('@loadProject')
|
||||
cy.visit(`/projects/${projects[1].id}`)
|
||||
cy.wait('@loadProject')
|
||||
cy.visit(`/projects/${projects[2].id}`)
|
||||
cy.wait('@loadProject')
|
||||
cy.visit(`/projects/${projects[3].id}`)
|
||||
cy.wait('@loadProject')
|
||||
cy.visit(`/projects/${projects[4].id}`)
|
||||
cy.wait('@loadProject')
|
||||
cy.visit(`/projects/${projects[5].id}`)
|
||||
cy.wait('@loadProject')
|
||||
|
||||
// cy.visit('/')
|
||||
// Not using cy.visit here to work around the redirect issue fixed in #1337
|
||||
cy.get('nav.menu.top-menu a')
|
||||
.contains('Overview')
|
||||
.click()
|
||||
|
||||
cy.get('body')
|
||||
.should('contain', 'Last viewed')
|
||||
cy.get('[data-cy="projectCardGrid"]')
|
||||
.should('not.contain', projects[0].title)
|
||||
.should('contain', projects[1].title)
|
||||
.should('contain', projects[2].title)
|
||||
.should('contain', projects[3].title)
|
||||
.should('contain', projects[4].title)
|
||||
.should('contain', projects[5].title)
|
||||
})
|
||||
})
|
|
@ -3,15 +3,15 @@ import {formatISO, format} from 'date-fns'
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {prepareLists} from './prepareLists'
|
||||
import {prepareProjects} from './prepareProjects'
|
||||
|
||||
describe('List View Gantt', () => {
|
||||
describe('Project View Gantt', () => {
|
||||
createFakeUserAndLogin()
|
||||
prepareLists()
|
||||
prepareProjects()
|
||||
|
||||
it('Hides tasks with no dates', () => {
|
||||
const tasks = TaskFactory.create(1)
|
||||
cy.visit('/lists/1/gantt')
|
||||
cy.visit('/projects/1/gantt')
|
||||
|
||||
cy.get('.g-gantt-rows-container')
|
||||
.should('not.contain', tasks[0].title)
|
||||
|
@ -25,7 +25,7 @@ describe('List View Gantt', () => {
|
|||
nextMonth.setDate(1)
|
||||
nextMonth.setMonth(9)
|
||||
|
||||
cy.visit('/lists/1/gantt')
|
||||
cy.visit('/projects/1/gantt')
|
||||
|
||||
cy.get('.g-timeunits-container')
|
||||
.should('contain', format(now, 'MMMM'))
|
||||
|
@ -38,7 +38,7 @@ describe('List View Gantt', () => {
|
|||
start_date: now.toISOString(),
|
||||
end_date: new Date(new Date(now).setDate(now.getDate() + 4)).toISOString(),
|
||||
})
|
||||
cy.visit('/lists/1/gantt')
|
||||
cy.visit('/projects/1/gantt')
|
||||
|
||||
cy.get('.g-gantt-rows-container')
|
||||
.should('not.be.empty')
|
||||
|
@ -50,7 +50,7 @@ describe('List View Gantt', () => {
|
|||
start_date: null,
|
||||
end_date: null,
|
||||
})
|
||||
cy.visit('/lists/1/gantt')
|
||||
cy.visit('/projects/1/gantt')
|
||||
|
||||
cy.get('.gantt-options .fancycheckbox')
|
||||
.contains('Show tasks which don\'t have dates set')
|
||||
|
@ -69,7 +69,7 @@ describe('List View Gantt', () => {
|
|||
start_date: now.toISOString(),
|
||||
end_date: new Date(new Date(now).setDate(now.getDate() + 4)).toISOString(),
|
||||
})
|
||||
cy.visit('/lists/1/gantt')
|
||||
cy.visit('/projects/1/gantt')
|
||||
|
||||
cy.get('.g-gantt-rows-container .g-gantt-row .g-gantt-row-bars-container div .g-gantt-bar')
|
||||
.first()
|
||||
|
@ -83,9 +83,9 @@ describe('List View Gantt', () => {
|
|||
const now = Date.UTC(2022, 10, 9)
|
||||
cy.clock(now, ['Date'])
|
||||
|
||||
cy.visit('/lists/1/gantt')
|
||||
cy.visit('/projects/1/gantt')
|
||||
|
||||
cy.get('.list-gantt .gantt-options .field .control input.input.form-control')
|
||||
cy.get('.project-gantt .gantt-options .field .control input.input.form-control')
|
||||
.click()
|
||||
cy.get('.flatpickr-calendar .flatpickr-innerContainer .dayContainer .flatpickr-day')
|
||||
.first()
|
||||
|
@ -99,13 +99,13 @@ describe('List View Gantt', () => {
|
|||
})
|
||||
|
||||
it('Should change the date range based on date query parameters', () => {
|
||||
cy.visit('/lists/1/gantt?dateFrom=2022-09-25&dateTo=2022-11-05')
|
||||
cy.visit('/projects/1/gantt?dateFrom=2022-09-25&dateTo=2022-11-05')
|
||||
|
||||
cy.get('.g-timeunits-container')
|
||||
.should('contain', 'September 2022')
|
||||
.should('contain', 'October 2022')
|
||||
.should('contain', 'November 2022')
|
||||
cy.get('.list-gantt .gantt-options .field .control input.input.form-control')
|
||||
cy.get('.project-gantt .gantt-options .field .control input.input.form-control')
|
||||
.should('have.value', '25 Sep 2022 to 5 Nov 2022')
|
||||
})
|
||||
|
||||
|
@ -115,7 +115,7 @@ describe('List View Gantt', () => {
|
|||
start_date: formatISO(now),
|
||||
end_date: formatISO(now.setDate(now.getDate() + 4)),
|
||||
})
|
||||
cy.visit('/lists/1/gantt')
|
||||
cy.visit('/projects/1/gantt')
|
||||
|
||||
cy.get('.gantt-container .g-gantt-chart .g-gantt-row-bars-container .g-gantt-bar')
|
||||
.dblclick()
|
|
@ -1,13 +1,13 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {BucketFactory} from '../../factories/bucket'
|
||||
import {ListFactory} from '../../factories/list'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {prepareLists} from './prepareLists'
|
||||
import {prepareProjects} from './prepareProjects'
|
||||
|
||||
describe('List View Kanban', () => {
|
||||
describe('Project View Kanban', () => {
|
||||
createFakeUserAndLogin()
|
||||
prepareLists()
|
||||
prepareProjects()
|
||||
|
||||
let buckets
|
||||
beforeEach(() => {
|
||||
|
@ -16,10 +16,10 @@ describe('List View Kanban', () => {
|
|||
|
||||
it('Shows all buckets with their tasks', () => {
|
||||
const data = TaskFactory.create(10, {
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
bucket_id: 1,
|
||||
})
|
||||
cy.visit('/lists/1/kanban')
|
||||
cy.visit('/projects/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket .title')
|
||||
.contains(buckets[0].title)
|
||||
|
@ -34,10 +34,10 @@ describe('List View Kanban', () => {
|
|||
|
||||
it('Can add a new task to a bucket', () => {
|
||||
TaskFactory.create(2, {
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
bucket_id: 1,
|
||||
})
|
||||
cy.visit('/lists/1/kanban')
|
||||
cy.visit('/projects/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket')
|
||||
.contains(buckets[0].title)
|
||||
|
@ -55,7 +55,7 @@ describe('List View Kanban', () => {
|
|||
})
|
||||
|
||||
it('Can create a new bucket', () => {
|
||||
cy.visit('/lists/1/kanban')
|
||||
cy.visit('/projects/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket.new-bucket .button')
|
||||
.click()
|
||||
|
@ -69,7 +69,7 @@ describe('List View Kanban', () => {
|
|||
})
|
||||
|
||||
it('Can set a bucket limit', () => {
|
||||
cy.visit('/lists/1/kanban')
|
||||
cy.visit('/projects/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
|
||||
.first()
|
||||
|
@ -90,7 +90,7 @@ describe('List View Kanban', () => {
|
|||
})
|
||||
|
||||
it('Can rename a bucket', () => {
|
||||
cy.visit('/lists/1/kanban')
|
||||
cy.visit('/projects/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket .bucket-header .title')
|
||||
.first()
|
||||
|
@ -101,7 +101,7 @@ describe('List View Kanban', () => {
|
|||
})
|
||||
|
||||
it('Can delete a bucket', () => {
|
||||
cy.visit('/lists/1/kanban')
|
||||
cy.visit('/projects/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
|
||||
.first()
|
||||
|
@ -125,10 +125,10 @@ describe('List View Kanban', () => {
|
|||
|
||||
it('Can drag tasks around', () => {
|
||||
const tasks = TaskFactory.create(2, {
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
bucket_id: 1,
|
||||
})
|
||||
cy.visit('/lists/1/kanban')
|
||||
cy.visit('/projects/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket .tasks .task')
|
||||
.contains(tasks[0].title)
|
||||
|
@ -144,10 +144,10 @@ describe('List View Kanban', () => {
|
|||
it('Should navigate to the task when the task card is clicked', () => {
|
||||
const tasks = TaskFactory.create(5, {
|
||||
id: '{increment}',
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
bucket_id: 1,
|
||||
})
|
||||
cy.visit('/lists/1/kanban')
|
||||
cy.visit('/projects/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket .tasks .task')
|
||||
.contains(tasks[0].title)
|
||||
|
@ -158,18 +158,18 @@ describe('List View Kanban', () => {
|
|||
.should('contain', `/tasks/${tasks[0].id}`, { timeout: 1000 })
|
||||
})
|
||||
|
||||
it('Should remove a task from the kanban board when moving it to another list', () => {
|
||||
const lists = ListFactory.create(2)
|
||||
it('Should remove a task from the kanban board when moving it to another project', () => {
|
||||
const projects = ProjectFactory.create(2)
|
||||
BucketFactory.create(2, {
|
||||
list_id: '{increment}',
|
||||
project_id: '{increment}',
|
||||
})
|
||||
const tasks = TaskFactory.create(5, {
|
||||
id: '{increment}',
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
bucket_id: 1,
|
||||
})
|
||||
const task = tasks[0]
|
||||
cy.visit('/lists/1/kanban')
|
||||
cy.visit('/projects/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket .tasks .task')
|
||||
.contains(task.title)
|
||||
|
@ -180,7 +180,7 @@ describe('List View Kanban', () => {
|
|||
.contains('Move')
|
||||
.click()
|
||||
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
|
||||
.type(`${lists[1].title}{enter}`)
|
||||
.type(`${projects[1].title}{enter}`)
|
||||
// The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress
|
||||
// presses enter and we can't simulate pressing on enter to select the item.
|
||||
cy.get('.task-view .content.details .field .multiselect.control .search-results')
|
||||
|
@ -197,26 +197,26 @@ describe('List View Kanban', () => {
|
|||
|
||||
it('Shows a button to filter the kanban board', () => {
|
||||
const data = TaskFactory.create(10, {
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
bucket_id: 1,
|
||||
})
|
||||
cy.visit('/lists/1/kanban')
|
||||
cy.visit('/projects/1/kanban')
|
||||
|
||||
cy.get('.list-kanban .filter-container .base-button')
|
||||
cy.get('.project-kanban .filter-container .base-button')
|
||||
.should('exist')
|
||||
})
|
||||
|
||||
it('Should remove a task from the board when deleting it', () => {
|
||||
const lists = ListFactory.create(1)
|
||||
const projects = ProjectFactory.create(1)
|
||||
const buckets = BucketFactory.create(2, {
|
||||
list_id: lists[0].id,
|
||||
project_id: projects[0].id,
|
||||
})
|
||||
const tasks = TaskFactory.create(5, {
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
bucket_id: buckets[0].id,
|
||||
})
|
||||
const task = tasks[0]
|
||||
cy.visit('/lists/1/kanban')
|
||||
cy.visit('/projects/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket .tasks .task')
|
||||
.contains(task.title)
|
|
@ -1,32 +1,32 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {UserListFactory} from '../../factories/users_list'
|
||||
import {UserProjectFactory} from '../../factories/users_project'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {UserFactory} from '../../factories/user'
|
||||
import {ListFactory} from '../../factories/list'
|
||||
import {prepareLists} from './prepareLists'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {prepareProjects} from './prepareProjects'
|
||||
|
||||
describe('List View List', () => {
|
||||
describe('Project View Project', () => {
|
||||
createFakeUserAndLogin()
|
||||
prepareLists()
|
||||
prepareProjects()
|
||||
|
||||
it('Should be an empty list', () => {
|
||||
cy.visit('/lists/1')
|
||||
it('Should be an empty project', () => {
|
||||
cy.visit('/projects/1')
|
||||
cy.url()
|
||||
.should('contain', '/lists/1/list')
|
||||
cy.get('.list-title h1')
|
||||
.should('contain', 'First List')
|
||||
cy.get('.list-title .dropdown')
|
||||
.should('contain', '/projects/1/list')
|
||||
cy.get('.project-title')
|
||||
.should('contain', 'First Project')
|
||||
cy.get('.project-title-dropdown')
|
||||
.should('exist')
|
||||
cy.get('p')
|
||||
.contains('This list is currently empty.')
|
||||
.contains('This project is currently empty.')
|
||||
.should('exist')
|
||||
})
|
||||
|
||||
it('Should create a new task', () => {
|
||||
const newTaskTitle = 'New task'
|
||||
|
||||
cy.visit('/lists/1')
|
||||
cy.visit('/projects/1')
|
||||
cy.get('.task-add textarea')
|
||||
.type(newTaskTitle+'{enter}')
|
||||
cy.get('.tasks')
|
||||
|
@ -36,9 +36,9 @@ describe('List View List', () => {
|
|||
it('Should navigate to the task when the title is clicked', () => {
|
||||
const tasks = TaskFactory.create(5, {
|
||||
id: '{increment}',
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
})
|
||||
cy.visit('/lists/1/list')
|
||||
cy.visit('/projects/1/list')
|
||||
|
||||
cy.get('.tasks .task .tasktext')
|
||||
.contains(tasks[0].title)
|
||||
|
@ -49,33 +49,32 @@ describe('List View List', () => {
|
|||
.should('contain', `/tasks/${tasks[0].id}`)
|
||||
})
|
||||
|
||||
it('Should not see any elements for a list which is shared read only', () => {
|
||||
it('Should not see any elements for a project which is shared read only', () => {
|
||||
UserFactory.create(2)
|
||||
UserListFactory.create(1, {
|
||||
list_id: 2,
|
||||
UserProjectFactory.create(1, {
|
||||
project_id: 2,
|
||||
user_id: 1,
|
||||
right: 0,
|
||||
})
|
||||
const lists = ListFactory.create(2, {
|
||||
const projects = ProjectFactory.create(2, {
|
||||
owner_id: '{increment}',
|
||||
namespace_id: '{increment}',
|
||||
})
|
||||
cy.visit(`/lists/${lists[1].id}/`)
|
||||
cy.visit(`/projects/${projects[1].id}/`)
|
||||
|
||||
cy.get('.list-title .icon')
|
||||
cy.get('.project-title-wrapper .icon')
|
||||
.should('not.exist')
|
||||
cy.get('input.input[placeholder="Add a new task..."')
|
||||
.should('not.exist')
|
||||
})
|
||||
|
||||
it('Should only show the color of a list in the navigation and not in the list view', () => {
|
||||
const lists = ListFactory.create(1, {
|
||||
it('Should only show the color of a project in the navigation and not in the list view', () => {
|
||||
const projects = ProjectFactory.create(1, {
|
||||
hex_color: '00db60',
|
||||
})
|
||||
TaskFactory.create(10, {
|
||||
list_id: lists[0].id,
|
||||
project_id: projects[0].id,
|
||||
})
|
||||
cy.visit(`/lists/${lists[0].id}/`)
|
||||
cy.visit(`/projects/${projects[0].id}/`)
|
||||
|
||||
cy.get('.menu-list li .list-menu-link .color-bubble')
|
||||
.should('have.css', 'background-color', 'rgb(0, 219, 96)')
|
||||
|
@ -87,9 +86,9 @@ describe('List View List', () => {
|
|||
const tasks = TaskFactory.create(100, {
|
||||
id: '{increment}',
|
||||
title: i => `task${i}`,
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
})
|
||||
cy.visit('/lists/1/list')
|
||||
cy.visit('/projects/1/list')
|
||||
|
||||
cy.get('.tasks')
|
||||
.should('contain', tasks[1].title)
|
|
@ -2,37 +2,37 @@ import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
|||
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
|
||||
describe('List View Table', () => {
|
||||
describe('Project View Table', () => {
|
||||
createFakeUserAndLogin()
|
||||
|
||||
it('Should show a table with tasks', () => {
|
||||
const tasks = TaskFactory.create(1)
|
||||
cy.visit('/lists/1/table')
|
||||
cy.visit('/projects/1/table')
|
||||
|
||||
cy.get('.list-table table.table')
|
||||
cy.get('.project-table table.table')
|
||||
.should('exist')
|
||||
cy.get('.list-table table.table')
|
||||
cy.get('.project-table table.table')
|
||||
.should('contain', tasks[0].title)
|
||||
})
|
||||
|
||||
it('Should have working column switches', () => {
|
||||
TaskFactory.create(1)
|
||||
cy.visit('/lists/1/table')
|
||||
cy.visit('/projects/1/table')
|
||||
|
||||
cy.get('.list-table .filter-container .items .button')
|
||||
cy.get('.project-table .filter-container .items .button')
|
||||
.contains('Columns')
|
||||
.click()
|
||||
cy.get('.list-table .filter-container .card.columns-filter .card-content .fancycheckbox .check')
|
||||
cy.get('.project-table .filter-container .card.columns-filter .card-content .fancycheckbox')
|
||||
.contains('Priority')
|
||||
.click()
|
||||
cy.get('.list-table .filter-container .card.columns-filter .card-content .fancycheckbox .check')
|
||||
cy.get('.project-table .filter-container .card.columns-filter .card-content .fancycheckbox')
|
||||
.contains('Done')
|
||||
.click()
|
||||
|
||||
cy.get('.list-table table.table th')
|
||||
cy.get('.project-table table.table th')
|
||||
.contains('Priority')
|
||||
.should('exist')
|
||||
cy.get('.list-table table.table th')
|
||||
cy.get('.project-table table.table th')
|
||||
.contains('Done')
|
||||
.should('not.exist')
|
||||
})
|
||||
|
@ -40,11 +40,11 @@ describe('List View Table', () => {
|
|||
it('Should navigate to the task when the title is clicked', () => {
|
||||
const tasks = TaskFactory.create(5, {
|
||||
id: '{increment}',
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
})
|
||||
cy.visit('/lists/1/table')
|
||||
cy.visit('/projects/1/table')
|
||||
|
||||
cy.get('.list-table table.table')
|
||||
cy.get('.project-table table.table')
|
||||
.contains(tasks[0].title)
|
||||
.click()
|
||||
|
171
cypress/e2e/project/project.spec.ts
Normal file
171
cypress/e2e/project/project.spec.ts
Normal file
|
@ -0,0 +1,171 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {prepareProjects} from './prepareProjects'
|
||||
|
||||
describe('Projects', () => {
|
||||
createFakeUserAndLogin()
|
||||
|
||||
let projects
|
||||
prepareProjects((newProjects) => (projects = newProjects))
|
||||
|
||||
it('Should create a new project', () => {
|
||||
cy.visit('/projects')
|
||||
cy.get('.project-header [data-cy=new-project]')
|
||||
.click()
|
||||
cy.url()
|
||||
.should('contain', '/projects/new')
|
||||
cy.get('.card-header-title')
|
||||
.contains('New project')
|
||||
cy.get('input[name=projectTitle]')
|
||||
.type('New Project')
|
||||
cy.get('.button')
|
||||
.contains('Create')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification', {timeout: 1000}) // Waiting until the request to create the new project is done
|
||||
.should('contain', 'Success')
|
||||
cy.url()
|
||||
.should('contain', '/projects/')
|
||||
cy.get('.project-title')
|
||||
.should('contain', 'New Project')
|
||||
})
|
||||
|
||||
it('Should redirect to a specific project view after visited', () => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/*/buckets*').as('loadBuckets')
|
||||
cy.visit('/projects/1/kanban')
|
||||
cy.url()
|
||||
.should('contain', '/projects/1/kanban')
|
||||
cy.wait('@loadBuckets')
|
||||
cy.visit('/projects/1')
|
||||
cy.url()
|
||||
.should('contain', '/projects/1/kanban')
|
||||
})
|
||||
|
||||
it('Should rename the project in all places', () => {
|
||||
TaskFactory.create(5, {
|
||||
id: '{increment}',
|
||||
project_id: 1,
|
||||
})
|
||||
const newProjectName = 'New project name'
|
||||
|
||||
cy.visit('/projects/1')
|
||||
cy.get('.project-title')
|
||||
.should('contain', 'First Project')
|
||||
|
||||
cy.get('.menu-container .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
|
||||
.click()
|
||||
cy.get('.menu-container .menu-list li:first-child .dropdown .dropdown-content')
|
||||
.contains('Edit')
|
||||
.click()
|
||||
cy.get('#title')
|
||||
.type(`{selectall}${newProjectName}`)
|
||||
cy.get('footer.card-footer .button')
|
||||
.contains('Save')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
cy.get('.project-title')
|
||||
.should('contain', newProjectName)
|
||||
.should('not.contain', projects[0].title)
|
||||
cy.get('.menu-container .menu-list li:first-child')
|
||||
.should('contain', newProjectName)
|
||||
.should('not.contain', projects[0].title)
|
||||
cy.visit('/')
|
||||
cy.get('.project-grid')
|
||||
.should('contain', newProjectName)
|
||||
.should('not.contain', projects[0].title)
|
||||
})
|
||||
|
||||
it('Should remove a project when deleting it', () => {
|
||||
cy.visit(`/projects/${projects[0].id}`)
|
||||
|
||||
cy.get('.menu-container .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
|
||||
.click()
|
||||
cy.get('.menu-container .menu-list li:first-child .dropdown .dropdown-content')
|
||||
.contains('Delete')
|
||||
.click()
|
||||
cy.url()
|
||||
.should('contain', '/settings/delete')
|
||||
cy.get('[data-cy="modalPrimary"]')
|
||||
.contains('Do it')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
cy.get('.menu-container .menu-list')
|
||||
.should('not.contain', projects[0].title)
|
||||
cy.location('pathname')
|
||||
.should('equal', '/')
|
||||
})
|
||||
|
||||
it('Should archive a project', () => {
|
||||
cy.visit(`/projects/${projects[0].id}`)
|
||||
|
||||
cy.get('.project-title-dropdown')
|
||||
.click()
|
||||
cy.get('.project-title-dropdown .dropdown-menu .dropdown-item')
|
||||
.contains('Archive')
|
||||
.click()
|
||||
cy.get('.modal-content')
|
||||
.should('contain.text', 'Archive this project')
|
||||
cy.get('.modal-content [data-cy=modalPrimary]')
|
||||
.click()
|
||||
|
||||
cy.get('.menu-container .menu-list')
|
||||
.should('not.contain', projects[0].title)
|
||||
cy.get('main.app-content')
|
||||
.should('contain.text', 'This project is archived. It is not possible to create new or edit tasks for it.')
|
||||
})
|
||||
|
||||
it('Should show all projects on the projects page', () => {
|
||||
const projects = ProjectFactory.create(10)
|
||||
|
||||
cy.visit('/projects')
|
||||
|
||||
projects.forEach(p => {
|
||||
cy.get('[data-cy="projects-list"]')
|
||||
.should('contain', p.title)
|
||||
})
|
||||
})
|
||||
|
||||
it('Should not show archived projects if the filter is not checked', () => {
|
||||
ProjectFactory.create(1, {
|
||||
id: 2,
|
||||
}, false)
|
||||
ProjectFactory.create(1, {
|
||||
id: 3,
|
||||
is_archived: true,
|
||||
}, false)
|
||||
|
||||
// Initial
|
||||
cy.visit('/projects')
|
||||
cy.get('.project-grid')
|
||||
.should('not.contain', 'Archived')
|
||||
|
||||
// Show archived
|
||||
cy.get('[data-cy="show-archived-check"] label span')
|
||||
.should('be.visible')
|
||||
.click()
|
||||
cy.get('[data-cy="show-archived-check"] input')
|
||||
.should('be.checked')
|
||||
cy.get('.project-grid')
|
||||
.should('contain', 'Archived')
|
||||
|
||||
// Don't show archived
|
||||
cy.get('[data-cy="show-archived-check"] label span')
|
||||
.should('be.visible')
|
||||
.click()
|
||||
cy.get('[data-cy="show-archived-check"] input')
|
||||
.should('not.be.checked')
|
||||
|
||||
// Second time visiting after unchecking
|
||||
cy.visit('/projects')
|
||||
cy.get('[data-cy="show-archived-check"] input')
|
||||
.should('not.be.checked')
|
||||
cy.get('.project-grid')
|
||||
.should('not.contain', 'Archived')
|
||||
})
|
||||
})
|
|
@ -1,22 +1,22 @@
|
|||
import {LinkShareFactory} from '../../factories/link_sharing'
|
||||
import {ListFactory} from '../../factories/list'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
|
||||
describe('Link shares', () => {
|
||||
it('Can view a link share', () => {
|
||||
const lists = ListFactory.create(1)
|
||||
const projects = ProjectFactory.create(1)
|
||||
const tasks = TaskFactory.create(10, {
|
||||
list_id: lists[0].id
|
||||
project_id: projects[0].id
|
||||
})
|
||||
const linkShares = LinkShareFactory.create(1, {
|
||||
list_id: lists[0].id,
|
||||
project_id: projects[0].id,
|
||||
right: 0,
|
||||
})
|
||||
|
||||
cy.visit(`/share/${linkShares[0].hash}/auth`)
|
||||
|
||||
cy.get('h1.title')
|
||||
.should('contain', lists[0].title)
|
||||
.should('contain', projects[0].title)
|
||||
cy.get('input.input[placeholder="Add a new task..."')
|
||||
.should('not.exist')
|
||||
cy.get('.tasks')
|
||||
|
|
|
@ -1,17 +1,15 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {ListFactory} from '../../factories/list'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {seed} from '../../support/seed'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {NamespaceFactory} from '../../factories/namespace'
|
||||
import {BucketFactory} from '../../factories/bucket'
|
||||
import {updateUserSettings} from '../../support/updateUserSettings'
|
||||
|
||||
function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
|
||||
NamespaceFactory.create(1)
|
||||
const list = ListFactory.create()[0]
|
||||
const project = ProjectFactory.create()[0]
|
||||
BucketFactory.create(1, {
|
||||
list_id: list.id,
|
||||
project_id: project.id,
|
||||
})
|
||||
const tasks = []
|
||||
let dueDate = startDueDate
|
||||
|
@ -20,7 +18,7 @@ function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
|
|||
dueDate = new Date(new Date(dueDate).setDate(dueDate.getDate() + 2))
|
||||
tasks.push({
|
||||
id: i + 1,
|
||||
list_id: list.id,
|
||||
project_id: project.id,
|
||||
done: false,
|
||||
created_by_id: 1,
|
||||
title: 'Test Task ' + i,
|
||||
|
@ -31,7 +29,7 @@ function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
|
|||
})
|
||||
}
|
||||
seed(TaskFactory.table, tasks)
|
||||
return {tasks, list}
|
||||
return {tasks, project}
|
||||
}
|
||||
|
||||
describe('Home Page Task Overview', () => {
|
||||
|
@ -73,7 +71,7 @@ describe('Home Page Task Overview', () => {
|
|||
due_date: new Date().toISOString(),
|
||||
}, false)
|
||||
|
||||
cy.visit(`/lists/${tasks[0].list_id}/list`)
|
||||
cy.visit(`/projects/${tasks[0].project_id}/list`)
|
||||
cy.get('.tasks .task')
|
||||
.first()
|
||||
.should('contain.text', newTaskTitle)
|
||||
|
@ -90,7 +88,7 @@ describe('Home Page Task Overview', () => {
|
|||
|
||||
cy.visit('/')
|
||||
|
||||
cy.visit(`/lists/${tasks[0].list_id}/list`)
|
||||
cy.visit(`/projects/${tasks[0].project_id}/list`)
|
||||
cy.get('.task-add textarea')
|
||||
.type(newTaskTitle+'{enter}')
|
||||
cy.visit('/')
|
||||
|
@ -113,10 +111,10 @@ describe('Home Page Task Overview', () => {
|
|||
.should('contain.text', newTaskTitle)
|
||||
})
|
||||
|
||||
it('Should show a task without a due date added via default list at the bottom', () => {
|
||||
const {list} = seedTasks(40)
|
||||
it('Should show a task without a due date added via default project at the bottom', () => {
|
||||
const {project} = seedTasks(40)
|
||||
updateUserSettings({
|
||||
default_list_id: list.id,
|
||||
default_project_id: project.id,
|
||||
overdue_tasks_reminders_time: '9:00',
|
||||
})
|
||||
|
||||
|
@ -131,23 +129,22 @@ describe('Home Page Task Overview', () => {
|
|||
.should('contain.text', newTaskTitle)
|
||||
})
|
||||
|
||||
it('Should show the cta buttons for new list when there are no tasks', () => {
|
||||
it('Should show the cta buttons for new project when there are no tasks', () => {
|
||||
TaskFactory.truncate()
|
||||
|
||||
cy.visit('/')
|
||||
|
||||
cy.get('.home.app-content .content')
|
||||
.should('contain.text', 'You can create a new list for your new tasks:')
|
||||
.should('contain.text', 'Or import your lists and tasks from other services into Vikunja:')
|
||||
.should('contain.text', 'Import your projects and tasks from other services into Vikunja:')
|
||||
})
|
||||
|
||||
it('Should not show the cta buttons for new list when there are tasks', () => {
|
||||
it('Should not show the cta buttons for new project when there are tasks', () => {
|
||||
seedTasks()
|
||||
|
||||
cy.visit('/')
|
||||
|
||||
cy.get('.home.app-content .content')
|
||||
.should('not.contain.text', 'You can create a new list for your new tasks:')
|
||||
.should('not.contain.text', 'Or import your lists and tasks from other services into Vikunja:')
|
||||
.should('not.contain.text', 'You can create a new project for your new tasks:')
|
||||
.should('not.contain.text', 'Or import your projects and tasks from other services into Vikunja:')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {ListFactory} from '../../factories/list'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {TaskCommentFactory} from '../../factories/task_comment'
|
||||
import {UserFactory} from '../../factories/user'
|
||||
import {NamespaceFactory} from '../../factories/namespace'
|
||||
import {UserListFactory} from '../../factories/users_list'
|
||||
import {UserProjectFactory} from '../../factories/users_project'
|
||||
import {TaskAssigneeFactory} from '../../factories/task_assignee'
|
||||
import {LabelFactory} from '../../factories/labels'
|
||||
import {LabelTaskFactory} from '../../factories/label_task'
|
||||
|
@ -47,23 +46,21 @@ function uploadAttachmentAndVerify(taskId: number) {
|
|||
describe('Task', () => {
|
||||
createFakeUserAndLogin()
|
||||
|
||||
let namespaces
|
||||
let lists
|
||||
let projects
|
||||
let buckets
|
||||
|
||||
beforeEach(() => {
|
||||
// UserFactory.create(1)
|
||||
namespaces = NamespaceFactory.create(1)
|
||||
lists = ListFactory.create(1)
|
||||
projects = ProjectFactory.create(1)
|
||||
buckets = BucketFactory.create(1, {
|
||||
list_id: lists[0].id,
|
||||
project_id: projects[0].id,
|
||||
})
|
||||
TaskFactory.truncate()
|
||||
UserListFactory.truncate()
|
||||
UserProjectFactory.truncate()
|
||||
})
|
||||
|
||||
it('Should be created new', () => {
|
||||
cy.visit('/lists/1/list')
|
||||
cy.visit('/projects/1/list')
|
||||
cy.get('.input[placeholder="Add a new task…"')
|
||||
.type('New Task')
|
||||
cy.get('.button')
|
||||
|
@ -74,11 +71,11 @@ describe('Task', () => {
|
|||
.should('contain', 'New Task')
|
||||
})
|
||||
|
||||
it('Inserts new tasks at the top of the list', () => {
|
||||
it('Inserts new tasks at the top of the project', () => {
|
||||
TaskFactory.create(1)
|
||||
|
||||
cy.visit('/lists/1/list')
|
||||
cy.get('.list-is-empty-notice')
|
||||
cy.visit('/projects/1/list')
|
||||
cy.get('.project-is-empty-notice')
|
||||
.should('not.exist')
|
||||
cy.get('.input[placeholder="Add a new task…"')
|
||||
.type('New Task')
|
||||
|
@ -95,8 +92,8 @@ describe('Task', () => {
|
|||
it('Marks a task as done', () => {
|
||||
TaskFactory.create(1)
|
||||
|
||||
cy.visit('/lists/1/list')
|
||||
cy.get('.tasks .task .fancycheckbox label.check')
|
||||
cy.visit('/projects/1/list')
|
||||
cy.get('.tasks .task .fancycheckbox')
|
||||
.first()
|
||||
.click()
|
||||
cy.get('.global-notification')
|
||||
|
@ -106,11 +103,11 @@ describe('Task', () => {
|
|||
it('Can add a task to favorites', () => {
|
||||
TaskFactory.create(1)
|
||||
|
||||
cy.visit('/lists/1/list')
|
||||
cy.visit('/projects/1/list')
|
||||
cy.get('.tasks .task .favorite')
|
||||
.first()
|
||||
.click()
|
||||
cy.get('.menu.namespaces-lists')
|
||||
cy.get('.menu-container')
|
||||
.should('contain', 'Favorites')
|
||||
})
|
||||
|
||||
|
@ -133,8 +130,7 @@ describe('Task', () => {
|
|||
cy.get('.task-view h1.title.task-id')
|
||||
.should('contain', '#1')
|
||||
cy.get('.task-view h6.subtitle')
|
||||
.should('contain', namespaces[0].title)
|
||||
.should('contain', lists[0].title)
|
||||
.should('contain', projects[0].title)
|
||||
cy.get('.task-view .details.content.description')
|
||||
.should('contain', tasks[0].description)
|
||||
cy.get('.task-view .action-buttons p.created')
|
||||
|
@ -179,21 +175,21 @@ describe('Task', () => {
|
|||
.should('contain', 'Mark as undone')
|
||||
})
|
||||
|
||||
it('Shows a task identifier since the list has one', () => {
|
||||
const lists = ListFactory.create(1, {
|
||||
it('Shows a task identifier since the project has one', () => {
|
||||
const projects = ProjectFactory.create(1, {
|
||||
id: 1,
|
||||
identifier: 'TEST',
|
||||
})
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
list_id: lists[0].id,
|
||||
project_id: projects[0].id,
|
||||
index: 1,
|
||||
})
|
||||
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view h1.title.task-id')
|
||||
.should('contain', `${lists[0].identifier}-${tasks[0].index}`)
|
||||
.should('contain', `${projects[0].identifier}-${tasks[0].index}`)
|
||||
})
|
||||
|
||||
it('Can edit the description', () => {
|
||||
|
@ -236,14 +232,14 @@ describe('Task', () => {
|
|||
.should('contain', 'Success')
|
||||
})
|
||||
|
||||
it('Can move a task to another list', () => {
|
||||
const lists = ListFactory.create(2)
|
||||
it('Can move a task to another project', () => {
|
||||
const projects = ProjectFactory.create(2)
|
||||
BucketFactory.create(2, {
|
||||
list_id: '{increment}'
|
||||
project_id: '{increment}'
|
||||
})
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
list_id: lists[0].id,
|
||||
project_id: projects[0].id,
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
|
@ -251,7 +247,7 @@ describe('Task', () => {
|
|||
.contains('Move')
|
||||
.click()
|
||||
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
|
||||
.type(`${lists[1].title}{enter}`)
|
||||
.type(`${projects[1].title}{enter}`)
|
||||
// The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress
|
||||
// presses enter and we can't simulate pressing on enter to select the item.
|
||||
cy.get('.task-view .content.details .field .multiselect.control .search-results')
|
||||
|
@ -260,8 +256,7 @@ describe('Task', () => {
|
|||
.click()
|
||||
|
||||
cy.get('.task-view h6.subtitle')
|
||||
.should('contain', namespaces[0].title)
|
||||
.should('contain', lists[1].title)
|
||||
.should('contain', projects[1].title)
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
})
|
||||
|
@ -269,7 +264,7 @@ describe('Task', () => {
|
|||
it('Can delete a task', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
|
@ -286,17 +281,17 @@ describe('Task', () => {
|
|||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
cy.url()
|
||||
.should('contain', `/lists/${tasks[0].list_id}/`)
|
||||
.should('contain', `/projects/${tasks[0].project_id}/`)
|
||||
})
|
||||
|
||||
it('Can add an assignee to a task', () => {
|
||||
const users = UserFactory.create(5)
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
})
|
||||
UserListFactory.create(5, {
|
||||
list_id: 1,
|
||||
UserProjectFactory.create(5, {
|
||||
project_id: 1,
|
||||
user_id: '{increment}',
|
||||
})
|
||||
|
||||
|
@ -321,10 +316,10 @@ describe('Task', () => {
|
|||
const users = UserFactory.create(2)
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
})
|
||||
UserListFactory.create(5, {
|
||||
list_id: 1,
|
||||
UserProjectFactory.create(5, {
|
||||
project_id: 1,
|
||||
user_id: '{increment}',
|
||||
})
|
||||
TaskAssigneeFactory.create(1, {
|
||||
|
@ -347,7 +342,7 @@ describe('Task', () => {
|
|||
it('Can add a new label to a task', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
})
|
||||
LabelFactory.truncate()
|
||||
const newLabelText = 'some new label'
|
||||
|
@ -375,7 +370,7 @@ describe('Task', () => {
|
|||
it('Can add an existing label to a task', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
})
|
||||
const labels = LabelFactory.create(1)
|
||||
LabelTaskFactory.truncate()
|
||||
|
@ -388,13 +383,13 @@ describe('Task', () => {
|
|||
it('Can add a label to a task and it shows up on the kanban board afterwards', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
list_id: lists[0].id,
|
||||
project_id: projects[0].id,
|
||||
bucket_id: buckets[0].id,
|
||||
})
|
||||
const labels = LabelFactory.create(1)
|
||||
LabelTaskFactory.truncate()
|
||||
|
||||
cy.visit(`/lists/${lists[0].id}/kanban`)
|
||||
cy.visit(`/projects/${projects[0].id}/kanban`)
|
||||
|
||||
cy.get('.bucket .task')
|
||||
.contains(tasks[0].title)
|
||||
|
@ -412,7 +407,7 @@ describe('Task', () => {
|
|||
it('Can remove a label from a task', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
})
|
||||
const labels = LabelFactory.create(1)
|
||||
LabelTaskFactory.create(1, {
|
||||
|
@ -527,13 +522,13 @@ describe('Task', () => {
|
|||
TaskAttachmentFactory.truncate()
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
list_id: lists[0].id,
|
||||
project_id: projects[0].id,
|
||||
bucket_id: buckets[0].id,
|
||||
})
|
||||
const labels = LabelFactory.create(1)
|
||||
LabelTaskFactory.truncate()
|
||||
|
||||
cy.visit(`/lists/${lists[0].id}/kanban`)
|
||||
cy.visit(`/projects/${projects[0].id}/kanban`)
|
||||
|
||||
cy.get('.bucket .task')
|
||||
.contains(tasks[0].title)
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.web.json",
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["./**/*", "../support/**/*", "../factories/**/*"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"isolatedModules": false,
|
||||
"target": "ES2015",
|
||||
"lib": ["ESNext", "dom"],
|
||||
"types": ["cypress"]
|
||||
"types": ["cypress"],
|
||||
"ignoreDeprecations": "5.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
import {UserFactory} from '../../factories/user'
|
||||
|
||||
const testAndAssertFailed = fixture => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/login*').as('login')
|
||||
|
||||
cy.visit('/login')
|
||||
cy.get('input[id=username]').type(fixture.username)
|
||||
cy.get('input[id=password]').type(fixture.password)
|
||||
cy.get('.button').contains('Login').click()
|
||||
|
||||
cy.wait(5000) // It can take waaaayy too long to log the user in
|
||||
cy.wait('@login')
|
||||
cy.url().should('include', '/')
|
||||
cy.get('div.message.danger').contains('Wrong username or password.')
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
import {createLists} from '../list/prepareLists'
|
||||
import {createProjects} from '../project/prepareProjects'
|
||||
|
||||
function logout() {
|
||||
cy.get('.navbar .user .username')
|
||||
cy.get('.navbar .username-dropdown-trigger')
|
||||
.click()
|
||||
cy.get('.navbar .user .dropdown-menu .dropdown-item')
|
||||
cy.get('.navbar .dropdown-item')
|
||||
.contains('Logout')
|
||||
.click()
|
||||
}
|
||||
|
@ -26,21 +26,21 @@ describe('Log out', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it.skip('Should clear the list history after logging the user out', () => {
|
||||
const lists = createLists()
|
||||
cy.visit(`/lists/${lists[0].id}`)
|
||||
it.skip('Should clear the project history after logging the user out', () => {
|
||||
const projects = createProjects()
|
||||
cy.visit(`/projects/${projects[0].id}`)
|
||||
.then(() => {
|
||||
expect(localStorage.getItem('listHistory')).to.not.eq(null)
|
||||
expect(localStorage.getItem('projectHistory')).to.not.eq(null)
|
||||
})
|
||||
|
||||
logout()
|
||||
|
||||
cy.wait(1000) // This makes re-loading of the list and associated entities (and the resulting error) visible
|
||||
cy.wait(1000) // This makes re-loading of the project and associated entities (and the resulting error) visible
|
||||
|
||||
cy.url()
|
||||
.should('contain', '/login')
|
||||
.then(() => {
|
||||
expect(localStorage.getItem('listHistory')).to.eq(null)
|
||||
expect(localStorage.getItem('projectHistory')).to.eq(null)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -37,7 +37,7 @@ describe('User Settings', () => {
|
|||
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
cy.get('.navbar .user .username')
|
||||
cy.get('.navbar .username-dropdown-trigger .username')
|
||||
.should('contain', 'Lorem Ipsum')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -10,7 +10,7 @@ export class BucketFactory extends Factory {
|
|||
return {
|
||||
id: '{increment}',
|
||||
title: faker.lorem.words(3),
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
created_by_id: 1,
|
||||
created: now.toISOString(),
|
||||
updated: now.toISOString(),
|
||||
|
|
|
@ -10,7 +10,7 @@ export class LinkShareFactory extends Factory {
|
|||
return {
|
||||
id: '{increment}',
|
||||
hash: faker.random.word(32),
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
right: 0,
|
||||
sharing_type: 0,
|
||||
shared_by_id: 1,
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
import {Factory} from '../support/factory'
|
||||
import {faker} from '@faker-js/faker'
|
||||
|
||||
export class ListFactory extends Factory {
|
||||
static table = 'lists'
|
||||
|
||||
static factory() {
|
||||
const now = new Date()
|
||||
|
||||
return {
|
||||
id: '{increment}',
|
||||
title: faker.lorem.words(3),
|
||||
owner_id: 1,
|
||||
namespace_id: 1,
|
||||
created: now.toISOString(),
|
||||
updated: now.toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
import {faker} from '@faker-js/faker'
|
||||
import {Factory} from '../support/factory'
|
||||
import {faker} from '@faker-js/faker'
|
||||
|
||||
export class NamespaceFactory extends Factory {
|
||||
static table = 'namespaces'
|
||||
export class ProjectFactory extends Factory {
|
||||
static table = 'projects'
|
||||
|
||||
static factory() {
|
||||
const now = new Date()
|
||||
|
@ -15,4 +15,4 @@ export class NamespaceFactory extends Factory {
|
|||
updated: now.toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -11,7 +11,7 @@ export class TaskFactory extends Factory {
|
|||
id: '{increment}',
|
||||
title: faker.lorem.words(3),
|
||||
done: false,
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
created_by_id: 1,
|
||||
index: '{increment}',
|
||||
position: '{increment}',
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import {Factory} from '../support/factory'
|
||||
|
||||
export class UserListFactory extends Factory {
|
||||
static table = 'users_lists'
|
||||
export class UserProjectFactory extends Factory {
|
||||
static table = 'users_projects'
|
||||
|
||||
static factory() {
|
||||
const now = new Date()
|
||||
|
||||
return {
|
||||
id: '{increment}',
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
user_id: 1,
|
||||
right: 0,
|
||||
created: now.toISOString(),
|
16
docker/injector.sh
Executable file
16
docker/injector.sh
Executable file
|
@ -0,0 +1,16 @@
|
|||
#!/usr/bin/env sh
|
||||
set -e
|
||||
|
||||
echo "info: API URL is $VIKUNJA_API_URL"
|
||||
echo "info: Sentry enabled: $VIKUNJA_SENTRY_ENABLED"
|
||||
|
||||
# Escape the variable to prevent sed from complaining
|
||||
VIKUNJA_API_URL="$(echo "$VIKUNJA_API_URL" | sed -r 's/([:;])/\\\1/g')"
|
||||
VIKUNJA_SENTRY_DSN="$(echo "$VIKUNJA_SENTRY_DSN" | sed -r 's/([:;])/\\\1/g')"
|
||||
|
||||
sed -ri "s:^(\s*window.API_URL\s*=)\s*.+:\1 '${VIKUNJA_API_URL}':g" /usr/share/nginx/html/index.html
|
||||
sed -ri "s:^(\s*window.SENTRY_ENABLED\s*=)\s*.+:\1 ${VIKUNJA_SENTRY_ENABLED}:g" /usr/share/nginx/html/index.html
|
||||
sed -ri "s:^(\s*window.SENTRY_DSN\s*=)\s*.+:\1 '${VIKUNJA_SENTRY_DSN}':g" /usr/share/nginx/html/index.html
|
||||
sed -ri "s:^(\s*window.PROJECT_INFINITE_NESTING_ENABLED\s*=)\s*.+:\1 '${VIKUNJA_PROJECT_INFINITE_NESTING_ENABLED}':g" /usr/share/nginx/html/index.html
|
||||
|
||||
date -uIseconds | xargs echo 'info: started at'
|
19
docker/ipv6-disable.sh
Executable file
19
docker/ipv6-disable.sh
Executable file
|
@ -0,0 +1,19 @@
|
|||
#!/usr/bin/env sh
|
||||
set -e
|
||||
|
||||
if [ ! -f "/proc/net/if_inet6" ]; then
|
||||
echo "info: IPv6 is not available! Removing IPv6 listen configuration"
|
||||
find /etc/nginx/conf.d -name '*.conf' -type f | \
|
||||
while IFS= read -r CONFIG; do
|
||||
sed -r '/^\s*listen\s*\[::\]:.+$/d' "$CONFIG" > "$CONFIG.temp"
|
||||
if ! diff -U 5 "$CONFIG" "$CONFIG.temp" > "$CONFIG.diff"; then
|
||||
echo "info: Removing IPv6 lines from $CONFIG" | \
|
||||
cat - "$CONFIG.diff"
|
||||
echo "# IPv6 is disabled because /proc/net/if_inet6 was not found" | \
|
||||
cat - "$CONFIG.temp" > "$CONFIG"
|
||||
else
|
||||
echo "info: Skipping $CONFIG because it does not have IPv6 listen"
|
||||
fi
|
||||
rm -f "$CONFIG.temp" "$CONFIG.diff"
|
||||
done
|
||||
fi
|
111
docker/nginx.conf
Normal file
111
docker/nginx.conf
Normal file
|
@ -0,0 +1,111 @@
|
|||
# Generated by nginxconfig.io
|
||||
# https://www.digitalocean.com/community/tools/nginx?domains.0.server.domain=localhost&domains.0.server.documentRoot=%2Fusr%2Fshare%2Fnginx%2Fhtml&domains.0.server.cdnSubdomain=true&domains.0.https.https=false&domains.0.php.php=false&domains.0.routing.index=index.html&domains.0.routing.fallbackHtml=true&domains.0.routing.fallbackPhp=false&global.performance.assetsExpiration=1d&global.performance.mediaExpiration=1d&global.performance.svgExpiration=1d&global.performance.fontsExpiration=1d&global.logging.accessLog=%2Fdev%2Fstdout&global.logging.errorLog=%2Fdev%2Fstderr%20warn&global.logging.logNotFound=true&global.nginx.user=nginx&global.nginx.pid=%2Fvar%2Frun%2Fnginx.pid&global.nginx.clientMaxBodySize=50&global.docker.dockerfile=true&global.tools.modularizedStructure=false&global.tools.symlinkVhost=false
|
||||
# and then edited manually ;)
|
||||
|
||||
pid /tmp/nginx.pid;
|
||||
worker_processes auto;
|
||||
|
||||
events {
|
||||
multi_accept on;
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
charset utf-8;
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
server_tokens off;
|
||||
types_hash_max_size 2048;
|
||||
types_hash_bucket_size 64;
|
||||
|
||||
# rootless
|
||||
client_body_temp_path /tmp/client_temp;
|
||||
proxy_temp_path /tmp/proxy_temp_path;
|
||||
fastcgi_temp_path /tmp/fastcgi_temp;
|
||||
uwsgi_temp_path /tmp/uwsgi_temp;
|
||||
scgi_temp_path /tmp/scgi_temp;
|
||||
|
||||
# MIME
|
||||
include mime.types;
|
||||
default_type application/octet-stream;
|
||||
types {
|
||||
application/manifest+json webmanifest;
|
||||
}
|
||||
|
||||
# Logging
|
||||
log_format json escape=json
|
||||
'{'
|
||||
'"bytes_sent": "$bytes_sent",'
|
||||
'"http_user_agent": "$http_user_agent",'
|
||||
'"nginx_version": "$nginx_version",'
|
||||
'"query_string": "$query_string",'
|
||||
'"realip_remote_addr": "$realip_remote_addr",'
|
||||
'"remote_addr": "$remote_addr",'
|
||||
'"remote_user": "$remote_user",'
|
||||
'"request_length": "$request_length",'
|
||||
'"request_method": "$request_method",'
|
||||
'"request_time": "$request_time",'
|
||||
'"server_addr": "$server_addr",'
|
||||
'"server_port": "$server_port",'
|
||||
'"server_protocol": "$server_protocol",'
|
||||
'"status": "$status",'
|
||||
'"time_local": "$time_local",'
|
||||
'"uri": "$uri"'
|
||||
'}';
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /dev/stdout main;
|
||||
error_log /dev/stderr warn;
|
||||
|
||||
keepalive_timeout 65;
|
||||
|
||||
# compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_buffers 16 8k;
|
||||
gzip_http_version 1.1;
|
||||
gzip_min_length 256;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
application/json
|
||||
application/x-javascript
|
||||
application/javascript
|
||||
text/xml
|
||||
application/xml
|
||||
application/xml+rss
|
||||
text/javascript
|
||||
application/vnd.ms-fontobject
|
||||
application/x-font-ttf
|
||||
font/opentype
|
||||
image/svg+xml
|
||||
image/x-icon
|
||||
audio/wav;
|
||||
|
||||
map_hash_max_size 128;
|
||||
map_hash_bucket_size 128;
|
||||
|
||||
map $sent_http_content_type $expires {
|
||||
default off;
|
||||
text/css max;
|
||||
application/javascript max;
|
||||
text/javascript max;
|
||||
application/vnd.ms-fontobject max;
|
||||
application/x-font-ttf max;
|
||||
font/opentype max;
|
||||
font/woff2 max;
|
||||
image/svg+xml max;
|
||||
image/x-icon max;
|
||||
audio/wav max;
|
||||
~images/ max;
|
||||
~font/ max;
|
||||
}
|
||||
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
85
docker/templates/default.conf.template
Normal file
85
docker/templates/default.conf.template
Normal file
|
@ -0,0 +1,85 @@
|
|||
server {
|
||||
listen ${VIKUNJA_HTTP_PORT};
|
||||
listen [::]:${VIKUNJA_HTTP_PORT};
|
||||
## Needed when behind HAProxy with SSL termination + HTTP/2 support
|
||||
listen ${VIKUNJA_HTTP2_PORT} default_server http2 proxy_protocol;
|
||||
listen [::]:${VIKUNJA_HTTP2_PORT} default_server http2 proxy_protocol;
|
||||
|
||||
server_name _;
|
||||
expires $expires;
|
||||
root /usr/share/nginx/html;
|
||||
access_log /dev/stdout ${VIKUNJA_LOG_FORMAT};
|
||||
# security headers
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Content-Security-Policy "default-src 'self' http: https: ws: wss: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always;
|
||||
add_header Permissions-Policy "interest-cohort=()" always;
|
||||
|
||||
# . files
|
||||
location ~ /\.(?!well-known) {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# assume that everything else is handled by the application router, by injecting the index.html.
|
||||
location / {
|
||||
autoindex off;
|
||||
expires off;
|
||||
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
|
||||
try_files $uri /index.html =404;
|
||||
}
|
||||
|
||||
# Disable caching for sw
|
||||
location = /sw.js {
|
||||
autoindex off;
|
||||
expires off;
|
||||
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
|
||||
}
|
||||
|
||||
# Disable caching for webmanifest
|
||||
location = /manifest.webmanifest {
|
||||
autoindex off;
|
||||
expires off;
|
||||
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
|
||||
}
|
||||
|
||||
# favicon.ico
|
||||
location = /favicon.ico {
|
||||
log_not_found off;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# robots.txt
|
||||
location = /robots.txt {
|
||||
log_not_found off;
|
||||
access_log off;
|
||||
expires -1; # no-cache
|
||||
}
|
||||
|
||||
location = /ready {
|
||||
return 200 "";
|
||||
access_log off;
|
||||
expires -1; # no-cache
|
||||
}
|
||||
|
||||
# all assets contain hash in filename, cache forever
|
||||
location ^~ /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# all workbox scripts are compiled with hash in filename, cache forever3
|
||||
location ^~ /workbox- {
|
||||
add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# assets, media
|
||||
location ~* .(txt|webmanifest|css|js|mjs|map|svg|jpg|jpeg|png|ico|ttf|woff|woff2|wav)$ {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html { }
|
||||
|
||||
}
|
|
@ -30,21 +30,21 @@ A basic service can look like this:
|
|||
|
||||
```javascript
|
||||
import AbstractService from './abstractService'
|
||||
import ListModel from '../models/list'
|
||||
import ProjectModel from '../models/project'
|
||||
|
||||
export default class ListService extends AbstractService {
|
||||
export default class ProjectService extends AbstractService {
|
||||
constructor() {
|
||||
super({
|
||||
getAll: '/lists',
|
||||
get: '/lists/{id}',
|
||||
create: '/namespaces/{namespaceID}/lists',
|
||||
update: '/lists/{id}',
|
||||
delete: '/lists/{id}',
|
||||
getAll: '/projects',
|
||||
get: '/projects/{id}',
|
||||
create: '/namespaces/{namespaceID}/projects',
|
||||
update: '/projects/{id}',
|
||||
delete: '/projects/{id}',
|
||||
})
|
||||
}
|
||||
|
||||
modelFactory(data) {
|
||||
return new ListModel(data)
|
||||
return new ProjectModel(data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
@ -132,7 +132,7 @@ import AbstractModel from './abstractModel'
|
|||
import TaskModel from './task'
|
||||
import UserModel from './user'
|
||||
|
||||
export default class ListModel extends AbstractModel {
|
||||
export default class ProjectModel extends AbstractModel {
|
||||
|
||||
constructor(data) {
|
||||
// The constructor of AbstractModel handles all the default parsing.
|
||||
|
|
10
env.d.ts
vendored
10
env.d.ts
vendored
|
@ -3,6 +3,16 @@
|
|||
/// <reference types="cypress" />
|
||||
/// <reference types="@histoire/plugin-vue/components" />
|
||||
|
||||
declare module 'postcss-focus-within/browser' {
|
||||
import focusWithinInit from 'postcss-focus-within/browser'
|
||||
export default focusWithinInit
|
||||
}
|
||||
|
||||
declare module 'css-has-pseudo/browser' {
|
||||
import cssHasPseudo from 'css-has-pseudo/browser'
|
||||
export default cssHasPseudo
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_IS_ONLINE: boolean
|
||||
}
|
||||
|
|
|
@ -2,11 +2,11 @@
|
|||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1664753041,
|
||||
"narHash": "sha256-0ogaD8PaGHluARFeupofvk1Nq9gpVeZdlFM0Kcwguys=",
|
||||
"lastModified": 1685498995,
|
||||
"narHash": "sha256-rdyjnkq87tJp+T2Bm1OD/9NXKSsh/vLlPeqCc/mm7qs=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "a62844b302507c7531ad68a86cb7aa54704c9cb4",
|
||||
"rev": "9cfaa8a1a00830d17487cb60a19bb86f96f09b27",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
|
@ -28,7 +28,7 @@ export default defineConfig({
|
|||
// light: './img/light.png',
|
||||
// dark: './img/dark.png',
|
||||
// },
|
||||
// logoHref: 'https://acme.com',
|
||||
logoHref: 'https://vikunja.io',
|
||||
// favicon: './favicon.ico',
|
||||
},
|
||||
})
|
|
@ -27,6 +27,9 @@
|
|||
// our sentry instance to notify us of potential problems.
|
||||
window.SENTRY_ENABLED = false
|
||||
window.SENTRY_DSN = 'https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480'
|
||||
// If enabled, allows the user to nest projects infinitely, instead of the default 2 levels.
|
||||
// This setting might change in the future or be removed completely.
|
||||
window.PROJECT_INFINITE_NESTING_ENABLED = false
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
115
nginx.conf
115
nginx.conf
|
@ -1,115 +0,0 @@
|
|||
user nginx;
|
||||
worker_processes 1;
|
||||
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
types {
|
||||
application/manifest+json webmanifest;
|
||||
}
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
#tcp_nopush on;
|
||||
|
||||
keepalive_timeout 65;
|
||||
|
||||
gzip on;
|
||||
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_buffers 16 8k;
|
||||
gzip_http_version 1.1;
|
||||
gzip_min_length 256;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
application/json
|
||||
application/x-javascript
|
||||
application/javascript
|
||||
text/xml
|
||||
application/xml
|
||||
application/xml+rss
|
||||
text/javascript
|
||||
application/vnd.ms-fontobject
|
||||
application/x-font-ttf
|
||||
font/opentype
|
||||
image/svg+xml
|
||||
image/x-icon
|
||||
audio/wav;
|
||||
|
||||
map_hash_max_size 128;
|
||||
map_hash_bucket_size 128;
|
||||
|
||||
# Expires map
|
||||
map $sent_http_content_type $expires {
|
||||
default off;
|
||||
text/css max;
|
||||
application/javascript max;
|
||||
text/javascript max;
|
||||
application/vnd.ms-fontobject max;
|
||||
application/x-font-ttf max;
|
||||
font/opentype max;
|
||||
font/woff2 max;
|
||||
image/svg+xml max;
|
||||
image/x-icon max;
|
||||
audio/wav max;
|
||||
~images/ max;
|
||||
~font/ max;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen 81 default_server http2 proxy_protocol; ## Needed when behind HAProxy with SSL termination + HTTP/2 support
|
||||
|
||||
server_name _;
|
||||
|
||||
expires $expires;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
# all assets contain hash in filename, cache forever
|
||||
location ^~ /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# all workbox scripts are compiled with hash in filename, cache forever3
|
||||
location ^~ /workbox- {
|
||||
add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# assume that everything else is handled by the application router, by injecting the index.html.
|
||||
location / {
|
||||
autoindex off;
|
||||
expires off;
|
||||
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
|
||||
try_files $uri /index.html =404;
|
||||
}
|
||||
|
||||
location ~* .(txt|webmanifest|css|js|mjs|map|svg|jpg|jpeg|png|ico|ttf|woff|woff2|wav)$ {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
}
|
||||
}
|
||||
}
|
Binary file not shown.
Binary file not shown.
145
package.json
145
package.json
|
@ -13,7 +13,7 @@
|
|||
},
|
||||
"homepage": "https://vikunja.io/",
|
||||
"funding": "https://opencollective.com/vikunja",
|
||||
"packageManager": "pnpm@7.25.1",
|
||||
"packageManager": "pnpm@8.6.0",
|
||||
"keywords": [
|
||||
"todo",
|
||||
"productivity",
|
||||
|
@ -34,7 +34,7 @@
|
|||
"test:e2e-record": "start-server-and-test preview http://127.0.0.1:4173 'cypress run --e2e --browser chrome --record'",
|
||||
"test:e2e-dev-dev": "start-server-and-test preview:dev http://127.0.0.1:4173 'cypress open --e2e'",
|
||||
"test:e2e-dev": "start-server-and-test preview http://127.0.0.1:4173 'cypress open --e2e'",
|
||||
"test:unit": "vitest",
|
||||
"test:unit": "vitest --dir ./src",
|
||||
"typecheck": "vue-tsc --noEmit && vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
|
||||
"browserslist:update": "pnpm dlx browserslist@latest --update-db",
|
||||
"fonts:update": "pnpm fonts:download && pnpm fonts:subset",
|
||||
|
@ -45,103 +45,106 @@
|
|||
"story:preview": "histoire preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "6.2.1",
|
||||
"@fortawesome/free-regular-svg-icons": "6.2.1",
|
||||
"@fortawesome/free-solid-svg-icons": "6.2.1",
|
||||
"@fortawesome/fontawesome-svg-core": "6.4.0",
|
||||
"@fortawesome/free-regular-svg-icons": "6.4.0",
|
||||
"@fortawesome/free-solid-svg-icons": "6.4.0",
|
||||
"@fortawesome/vue-fontawesome": "3.0.3",
|
||||
"@github/hotkey": "2.0.1",
|
||||
"@infectoone/vue-ganttastic": "2.1.3",
|
||||
"@intlify/unplugin-vue-i18n": "0.8.1",
|
||||
"@kyvg/vue3-notification": "2.8.0",
|
||||
"@sentry/tracing": "7.32.1",
|
||||
"@sentry/vue": "7.32.1",
|
||||
"@types/is-touch-device": "1.0.0",
|
||||
"@types/lodash.clonedeep": "4.5.7",
|
||||
"@types/sortablejs": "1.15.0",
|
||||
"@vueuse/core": "9.11.1",
|
||||
"axios": "1.2.3",
|
||||
"blurhash": "2.0.4",
|
||||
"@infectoone/vue-ganttastic": "2.1.4",
|
||||
"@intlify/unplugin-vue-i18n": "0.11.0",
|
||||
"@kyvg/vue3-notification": "2.9.1",
|
||||
"@sentry/tracing": "7.53.1",
|
||||
"@sentry/vue": "7.53.1",
|
||||
"@vueuse/core": "10.1.2",
|
||||
"axios": "1.4.0",
|
||||
"blurhash": "2.0.5",
|
||||
"bulma-css-variables": "0.9.33",
|
||||
"camel-case": "4.1.2",
|
||||
"codemirror": "5.65.11",
|
||||
"date-fns": "2.29.3",
|
||||
"codemirror": "5.65.13",
|
||||
"date-fns": "2.30.0",
|
||||
"dayjs": "1.11.7",
|
||||
"dompurify": "2.4.3",
|
||||
"dompurify": "3.0.3",
|
||||
"easymde": "2.18.0",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"flatpickr": "4.6.13",
|
||||
"flexsearch": "0.7.21",
|
||||
"flexsearch": "0.7.31",
|
||||
"floating-vue": "2.0.0-beta.20",
|
||||
"focus-within": "3.0.2",
|
||||
"highlight.js": "11.7.0",
|
||||
"highlight.js": "11.8.0",
|
||||
"is-touch-device": "1.0.1",
|
||||
"lodash.clonedeep": "4.5.0",
|
||||
"klona": "2.0.6",
|
||||
"lodash.debounce": "4.0.8",
|
||||
"marked": "4.2.12",
|
||||
"minimist": "1.2.7",
|
||||
"pinia": "2.0.29",
|
||||
"marked": "5.0.4",
|
||||
"pinia": "2.0.36",
|
||||
"register-service-worker": "1.7.2",
|
||||
"snake-case": "3.0.4",
|
||||
"sortablejs": "1.15.0",
|
||||
"ufo": "1.0.1",
|
||||
"vue": "3.2.45",
|
||||
"ufo": "1.1.2",
|
||||
"vue": "3.2.47",
|
||||
"vue-advanced-cropper": "2.8.8",
|
||||
"vue-flatpickr-component": "11.0.1",
|
||||
"vue-flatpickr-component": "11.0.3",
|
||||
"vue-i18n": "9.2.2",
|
||||
"vue-router": "4.1.6",
|
||||
"workbox-precaching": "6.5.4",
|
||||
"vue-router": "4.2.2",
|
||||
"workbox-precaching": "6.6.1",
|
||||
"zhyswan-vuedraggable": "4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@4tw/cypress-drag-drop": "2.2.3",
|
||||
"@cypress/vite-dev-server": "5.0.2",
|
||||
"@cypress/vue": "5.0.3",
|
||||
"@faker-js/faker": "7.6.0",
|
||||
"@histoire/plugin-screenshot": "0.12.4",
|
||||
"@histoire/plugin-vue": "0.12.4",
|
||||
"@rushstack/eslint-patch": "1.2.0",
|
||||
"@cypress/vite-dev-server": "5.0.5",
|
||||
"@cypress/vue": "5.0.5",
|
||||
"@faker-js/faker": "8.0.2",
|
||||
"@histoire/plugin-screenshot": "0.16.1",
|
||||
"@histoire/plugin-vue": "0.16.1",
|
||||
"@rushstack/eslint-patch": "1.3.0",
|
||||
"@tsconfig/node18": "2.0.1",
|
||||
"@types/codemirror": "5.60.7",
|
||||
"@types/dompurify": "2.4.0",
|
||||
"@types/dompurify": "3.0.2",
|
||||
"@types/flexsearch": "0.7.3",
|
||||
"@types/focus-within": "1.0.1",
|
||||
"@types/is-touch-device": "1.0.0",
|
||||
"@types/lodash.debounce": "4.0.7",
|
||||
"@types/marked": "4.0.8",
|
||||
"@types/node": "18.11.18",
|
||||
"@types/marked": "5.0.0",
|
||||
"@types/node": "18.16.16",
|
||||
"@types/postcss-preset-env": "7.7.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.49.0",
|
||||
"@typescript-eslint/parser": "5.49.0",
|
||||
"@vitejs/plugin-legacy": "3.0.1",
|
||||
"@vitejs/plugin-vue": "4.0.0",
|
||||
"@vue/eslint-config-typescript": "11.0.2",
|
||||
"@vue/test-utils": "2.2.7",
|
||||
"@vue/tsconfig": "0.1.3",
|
||||
"autoprefixer": "10.4.13",
|
||||
"browserslist": "4.21.4",
|
||||
"caniuse-lite": "1.0.30001447",
|
||||
"csstype": "3.1.1",
|
||||
"cypress": "12.3.0",
|
||||
"esbuild": "0.17.4",
|
||||
"eslint": "8.32.0",
|
||||
"eslint-plugin-vue": "9.9.0",
|
||||
"happy-dom": "8.1.5",
|
||||
"histoire": "0.12.4",
|
||||
"netlify-cli": "12.9.1",
|
||||
"postcss": "8.4.21",
|
||||
"@types/sortablejs": "1.15.1",
|
||||
"@typescript-eslint/eslint-plugin": "5.59.8",
|
||||
"@typescript-eslint/parser": "5.59.8",
|
||||
"@vitejs/plugin-legacy": "4.0.4",
|
||||
"@vitejs/plugin-vue": "4.2.3",
|
||||
"@vue/eslint-config-typescript": "11.0.3",
|
||||
"@vue/test-utils": "2.3.2",
|
||||
"@vue/tsconfig": "0.4.0",
|
||||
"autoprefixer": "10.4.14",
|
||||
"browserslist": "4.21.5",
|
||||
"caniuse-lite": "1.0.30001489",
|
||||
"css-has-pseudo": "5.0.2",
|
||||
"csstype": "3.1.2",
|
||||
"cypress": "12.13.0",
|
||||
"esbuild": "0.17.19",
|
||||
"eslint": "8.41.0",
|
||||
"eslint-plugin-vue": "9.13.0",
|
||||
"happy-dom": "9.20.1",
|
||||
"histoire": "0.16.1",
|
||||
"postcss": "8.4.24",
|
||||
"postcss-easing-gradients": "3.0.1",
|
||||
"postcss-easings": "3.0.1",
|
||||
"postcss-preset-env": "7.8.3",
|
||||
"rollup": "3.10.1",
|
||||
"postcss-focus-within": "7.0.2",
|
||||
"postcss-preset-env": "8.4.1",
|
||||
"rollup": "3.23.0",
|
||||
"rollup-plugin-visualizer": "5.9.0",
|
||||
"sass": "1.57.1",
|
||||
"start-server-and-test": "1.15.3",
|
||||
"typescript": "4.9.4",
|
||||
"vite": "4.0.4",
|
||||
"vite-plugin-inject-preload": "1.2.0",
|
||||
"vite-plugin-pwa": "0.14.1",
|
||||
"sass": "1.62.1",
|
||||
"start-server-and-test": "2.0.0",
|
||||
"typescript": "5.0.4",
|
||||
"vite": "4.3.9",
|
||||
"vite-plugin-inject-preload": "1.3.1",
|
||||
"vite-plugin-pwa": "0.15.2",
|
||||
"vite-svg-loader": "4.0.0",
|
||||
"vitest": "0.28.1",
|
||||
"vue-tsc": "1.0.24",
|
||||
"vitest": "0.31.2",
|
||||
"vue-tsc": "1.6.5",
|
||||
"wait-on": "7.0.1",
|
||||
"workbox-cli": "6.5.4"
|
||||
"workbox-cli": "6.6.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"flexsearch@0.7.31": "patches/flexsearch@0.7.31.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
16
patches/flexsearch@0.7.31.patch
Normal file
16
patches/flexsearch@0.7.31.patch
Normal file
|
@ -0,0 +1,16 @@
|
|||
diff --git a/index.d.ts b/index.d.ts
|
||||
deleted file mode 100644
|
||||
index 9f39f41073864b83968bdaa242ac4e3c3149685a..0000000000000000000000000000000000000000
|
||||
diff --git a/package.json b/package.json
|
||||
index 8968f5bf8010ff194240591c8b83299f7328e79d..6d84b6f590a841b129ed8b3860cb786df5a185c0 100644
|
||||
--- a/package.json
|
||||
+++ b/package.json
|
||||
@@ -22,8 +22,6 @@
|
||||
},
|
||||
"main": "dist/flexsearch.bundle.js",
|
||||
"browser": "dist/flexsearch.bundle.js",
|
||||
- "module": "dist/module/index.js",
|
||||
- "types": "./index.d.ts",
|
||||
"preferGlobal": false,
|
||||
"repository": {
|
||||
"type": "git",
|
13126
pnpm-lock.yaml
13126
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
@ -2,11 +2,11 @@
|
|||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"labels": ["dependencies"],
|
||||
"extends": [
|
||||
"config:base"
|
||||
"config:js-app"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackageNames": ["netlify-cli", "happy-dom"],
|
||||
"matchPackageNames": ["happy-dom"],
|
||||
"extends": ["schedule:weekly"]
|
||||
},
|
||||
{
|
||||
|
@ -20,6 +20,13 @@
|
|||
"@vueuse/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"groupName": "histoire",
|
||||
"matchPackagePrefixes": [
|
||||
"@histoire/",
|
||||
"histoire"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matchDepTypes": ["devDependencies"],
|
||||
"automerge": true,
|
||||
|
|
|
@ -33,9 +33,9 @@ const promiseExec = cmd => {
|
|||
}
|
||||
|
||||
(async function () {
|
||||
let stdout = await promiseExec(`./node_modules/.bin/netlify link --id ${siteId}`)
|
||||
let stdout = await promiseExec(`/home/node/docker-netlify-cli/node_modules/.bin/netlify link --id ${siteId}`)
|
||||
console.log(stdout)
|
||||
stdout = await promiseExec(`./node_modules/.bin/netlify deploy --alias ${alias}`)
|
||||
stdout = await promiseExec(`/home/node/docker-netlify-cli/node_modules/.bin/netlify deploy --alias ${alias}`)
|
||||
console.log(stdout)
|
||||
|
||||
const data = await fetch(prIssueCommentsUrl).then(response => response.json())
|
||||
|
|
|
@ -1 +1 @@
|
|||
57af69409e66bc87f4f2fc5822dd8d3c2eb47c601f81af1ac4a56f3e2d80837b1a2de06f4ff57695ec379b7c15b881e3 ./scripts/deploy-preview-netlify.mjs
|
||||
4a7c1293c7b12e9ab476cdf35251a407c6a1cd005d22c06df994222cccfb25cde5f47d15866a098c9d739778fee4dc19 ./scripts/deploy-preview-netlify.mjs
|
||||
|
|
|
@ -1,28 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
# This shell script sets the api url based on an environment variable and starts nginx in foreground.
|
||||
|
||||
VIKUNJA_API_URL="${VIKUNJA_API_URL:-"/api/v1"}"
|
||||
VIKUNJA_SENTRY_ENABLED="${VIKUNJA_SENTRY_ENABLED:-"false"}"
|
||||
VIKUNJA_SENTRY_DSN="${VIKUNJA_SENTRY_DSN:-"https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480"}"
|
||||
VIKUNJA_HTTP_PORT="${VIKUNJA_HTTP_PORT:-80}"
|
||||
VIKUNJA_HTTPS_PORT="${VIKUNJA_HTTPS_PORT:-443}"
|
||||
|
||||
echo "Using $VIKUNJA_API_URL as default api url"
|
||||
|
||||
# Escape the variable to prevent sed from complaining
|
||||
VIKUNJA_API_URL=$(echo $VIKUNJA_API_URL |sed 's/\//\\\//g')
|
||||
|
||||
sed -i "s/http\:\/\/localhost\:3456//g" /usr/share/nginx/html/index.html # replacing in two steps to make sure api urls from releases are properly replaced as well
|
||||
sed -i "s/'\/api\/v1/'$VIKUNJA_API_URL/g" /usr/share/nginx/html/index.html
|
||||
sed -i "s/\.SENTRY_ENABLED = false/\.SENTRY_ENABLED = $VIKUNJA_SENTRY_ENABLED/g" /usr/share/nginx/html/index.html
|
||||
sed -i "s|\.SENTRY_DSN = '.*'|\.SENTRY_DSN = '$VIKUNJA_SENTRY_DSN'|g" /usr/share/nginx/html/index.html
|
||||
|
||||
sed -i "s/listen 80/listen $VIKUNJA_HTTP_PORT/g" /etc/nginx/nginx.conf
|
||||
sed -i "s/listen 443/listen $VIKUNJA_HTTPS_PORT/g" /etc/nginx/nginx.conf
|
||||
|
||||
# Set the uid and gid of the nginx run user
|
||||
usermod --non-unique --uid ${PUID} nginx
|
||||
groupmod --non-unique --gid ${PGID} nginx
|
||||
|
||||
nginx -g "daemon off;"
|
23
src/App.vue
23
src/App.vue
|
@ -8,9 +8,13 @@
|
|||
<no-auth-wrapper v-else>
|
||||
<router-view/>
|
||||
</no-auth-wrapper>
|
||||
<Notification/>
|
||||
|
||||
|
||||
<keyboard-shortcuts v-if="keyboardShortcutsActive"/>
|
||||
|
||||
<Teleport to="body">
|
||||
<UpdateNotification/>
|
||||
<Notification/>
|
||||
</Teleport>
|
||||
</ready>
|
||||
</template>
|
||||
|
||||
|
@ -19,23 +23,26 @@ import {computed, watch} from 'vue'
|
|||
import {useRoute, useRouter} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import isTouchDevice from 'is-touch-device'
|
||||
import {success} from '@/message'
|
||||
|
||||
import Notification from '@/components/misc/notification.vue'
|
||||
import KeyboardShortcuts from './components/misc/keyboard-shortcuts/index.vue'
|
||||
import UpdateNotification from '@/components/home/UpdateNotification.vue'
|
||||
import KeyboardShortcuts from '@/components/misc/keyboard-shortcuts/index.vue'
|
||||
|
||||
import TheNavigation from '@/components/home/TheNavigation.vue'
|
||||
import ContentAuth from './components/home/contentAuth.vue'
|
||||
import ContentLinkShare from './components/home/contentLinkShare.vue'
|
||||
import ContentAuth from '@/components/home/contentAuth.vue'
|
||||
import ContentLinkShare from '@/components/home/contentLinkShare.vue'
|
||||
import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
|
||||
import Ready from '@/components/misc/ready.vue'
|
||||
|
||||
import {setLanguage} from './i18n'
|
||||
import {setLanguage} from '@/i18n'
|
||||
import AccountDeleteService from '@/services/accountDelete'
|
||||
import {success} from '@/message'
|
||||
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
|
||||
import {useColorScheme} from '@/composables/useColorScheme'
|
||||
import {useBodyClass} from '@/composables/useBodyClass'
|
||||
import {useAuthStore} from './stores/auth'
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const authStore = useAuthStore()
|
||||
|
|
Binary file not shown.
4
src/assets/checkbox.svg
Normal file
4
src/assets/checkbox.svg
Normal file
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="18px" height="18px" viewBox="0 0 18 18" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5">
|
||||
<path d="M1,9 L1,3.5 C1,2 2,1 3.5,1 L14.5,1 C16,1 17,2 17,3.5 L17,14.5 C17,16 16,17 14.5,17 L3.5,17 C2,17 1,16 1,14.5 L1,9 Z" stroke-dasharray="60"></path>
|
||||
<polyline points="1 9 7 14 15 4" stroke-dasharray="22" stroke-dashoffset="66"></polyline>
|
||||
</svg>
|
After Width: | Height: | Size: 420 B |
54
src/components/base/BaseCheckbox.vue
Normal file
54
src/components/base/BaseCheckbox.vue
Normal file
|
@ -0,0 +1,54 @@
|
|||
<template>
|
||||
<div class="base-checkbox" v-cy="'checkbox'">
|
||||
<input
|
||||
type="checkbox"
|
||||
:id="checkboxId"
|
||||
class="is-sr-only"
|
||||
:checked="modelValue"
|
||||
@change="(event) => emit('update:modelValue', (event.target as HTMLInputElement).checked)"
|
||||
:disabled="disabled || undefined"
|
||||
/>
|
||||
|
||||
<slot name="label" :checkboxId="checkboxId">
|
||||
<label :for="checkboxId" class="base-checkbox__label">
|
||||
<slot/>
|
||||
</label>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import {createRandomID} from '@/helpers/randomId'
|
||||
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const checkboxId = ref(`fancycheckbox_${createRandomID()}`)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.base-checkbox__label {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.base-checkbox:has(input:disabled) .base-checkbox__label {
|
||||
cursor:not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
|
@ -32,7 +32,7 @@ import {computed, ref} from 'vue'
|
|||
import {getInheritedBackgroundColor} from '@/helpers/getInheritedBackgroundColor'
|
||||
|
||||
const props = defineProps({
|
||||
/** Wheather the Expandable is open or not */
|
||||
/** Whether the Expandable is open or not */
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
|
|
11
src/components/date/datemathHelp.story.vue
Normal file
11
src/components/date/datemathHelp.story.vue
Normal file
|
@ -0,0 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import datemathHelp from './datemathHelp.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story>
|
||||
<Variant title="Default">
|
||||
<datemathHelp />
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
|
@ -1,7 +1,8 @@
|
|||
<template>
|
||||
<card
|
||||
class="has-no-shadow how-it-works-modal"
|
||||
:title="$t('input.datemathHelp.title')">
|
||||
:title="$t('input.datemathHelp.title')"
|
||||
>
|
||||
<p>
|
||||
{{ $t('input.datemathHelp.intro') }}
|
||||
</p>
|
||||
|
@ -27,11 +28,11 @@
|
|||
</p>
|
||||
<p>{{ $t('misc.forExample') }}</p>
|
||||
<ul>
|
||||
<li><code>+1d</code>{{ $t('input.datemathHelp.add1Day') }}</li>
|
||||
<li><code>-1d</code>{{ $t('input.datemathHelp.minus1Day') }}</li>
|
||||
<li><code>/d</code>{{ $t('input.datemathHelp.roundDay') }}</li>
|
||||
<li><code>+1d</code> {{ $t('input.datemathHelp.add1Day') }}</li>
|
||||
<li><code>-1d</code> {{ $t('input.datemathHelp.minus1Day') }}</li>
|
||||
<li><code>/d</code> {{ $t('input.datemathHelp.roundDay') }}</li>
|
||||
</ul>
|
||||
<p>{{ $t('input.datemathHelp.supportedUnits') }}</p>
|
||||
<h3>{{ $t('input.datemathHelp.supportedUnits') }}</h3>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
|
@ -69,7 +70,7 @@
|
|||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>{{ $t('input.datemathHelp.someExamples') }}</p>
|
||||
<h3>{{ $t('input.datemathHelp.someExamples') }}</h3>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
|
@ -100,7 +101,7 @@
|
|||
<td><code>{{ exampleDate }}||+1M/d</code></td>
|
||||
<td>
|
||||
<i18n-t keypath="input.datemathHelp.examples.datePlusMonth" scope="global">
|
||||
<code>{{ exampleDate }}</code>
|
||||
<strong>{{ exampleDate }}</strong>
|
||||
</i18n-t>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -110,13 +111,15 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {formatDate} from '@/helpers/time/formatDate'
|
||||
import {formatDateShort} from '@/helpers/time/formatDate'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
const exampleDate = formatDate(new Date(), 'yyyy-MM-dd')
|
||||
const exampleDate = formatDateShort(new Date())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// FIXME: Remove style overwrites
|
||||
.how-it-works-modal {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
|
|
@ -4,8 +4,11 @@ import { useNow } from '@vueuse/core'
|
|||
|
||||
import LogoFull from '@/assets/logo-full.svg?component'
|
||||
import LogoFullPride from '@/assets/logo-full-pride.svg?component'
|
||||
import {MILLISECONDS_A_HOUR} from '@/constants/date'
|
||||
|
||||
const now = useNow()
|
||||
const now = useNow({
|
||||
interval: MILLISECONDS_A_HOUR,
|
||||
})
|
||||
const Logo = computed(() => now.value.getMonth() === 5 ? LogoFullPride : LogoFull)
|
||||
</script>
|
||||
|
||||
|
|
107
src/components/home/ProjectsNavigation.vue
Normal file
107
src/components/home/ProjectsNavigation.vue
Normal file
|
@ -0,0 +1,107 @@
|
|||
<template>
|
||||
<draggable
|
||||
v-model="availableProjects"
|
||||
animation="100"
|
||||
ghostClass="ghost"
|
||||
group="projects"
|
||||
@start="() => drag = true"
|
||||
@end="saveProjectPosition"
|
||||
handle=".handle"
|
||||
tag="menu"
|
||||
item-key="id"
|
||||
:disabled="!canEditOrder"
|
||||
:component-data="{
|
||||
type: 'transition-group',
|
||||
name: !drag ? 'flip-list' : null,
|
||||
class: [
|
||||
'menu-list can-be-hidden',
|
||||
{ 'dragging-disabled': !canEditOrder }
|
||||
]
|
||||
}"
|
||||
>
|
||||
<template #item="{element: project}">
|
||||
<ProjectsNavigationItem
|
||||
:project="project"
|
||||
:is-loading="projectUpdating[project.id]"
|
||||
:can-collapse="canCollapse"
|
||||
:level="level"
|
||||
:data-project-id="project.id"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, watch} from 'vue'
|
||||
import draggable from 'zhyswan-vuedraggable'
|
||||
import type {SortableEvent} from 'sortablejs'
|
||||
|
||||
import ProjectsNavigationItem from '@/components/home/ProjectsNavigationItem.vue'
|
||||
|
||||
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: IProject[],
|
||||
canEditOrder: boolean,
|
||||
canCollapse?: boolean,
|
||||
level?: number,
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', projects: IProject[]): void
|
||||
}>()
|
||||
|
||||
const drag = ref(false)
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
// Vue draggable will modify the projects list as it changes their position which will not work on a prop.
|
||||
// Hence, we'll clone the prop and work on the clone.
|
||||
const availableProjects = ref<IProject[]>([])
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
projects => {
|
||||
availableProjects.value = projects || []
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
const projectUpdating = ref<{ [id: IProject['id']]: boolean }>({})
|
||||
|
||||
async function saveProjectPosition(e: SortableEvent) {
|
||||
if (!e.newIndex && e.newIndex !== 0) return
|
||||
|
||||
const projectsActive = availableProjects.value
|
||||
// If the project was dragged to the last position, Safari will report e.newIndex as the size of the projectsActive
|
||||
// array instead of using the position. Because the index is wrong in that case, dragging the project will fail.
|
||||
// To work around that we're explicitly checking that case here and decrease the index.
|
||||
const newIndex = e.newIndex === projectsActive.length ? e.newIndex - 1 : e.newIndex
|
||||
|
||||
const projectId = parseInt(e.item.dataset.projectId)
|
||||
const project = projectStore.projects[projectId]
|
||||
|
||||
const parentProjectId = e.to.parentNode.dataset.projectId ? parseInt(e.to.parentNode.dataset.projectId) : 0
|
||||
const projectBefore = projectsActive[newIndex - 1] ?? null
|
||||
const projectAfter = projectsActive[newIndex + 1] ?? null
|
||||
projectUpdating.value[project.id] = true
|
||||
|
||||
const position = calculateItemPosition(
|
||||
projectBefore !== null ? projectBefore.position : null,
|
||||
projectAfter !== null ? projectAfter.position : null,
|
||||
)
|
||||
|
||||
try {
|
||||
// create a copy of the project in order to not violate pinia manipulation
|
||||
await projectStore.updateProject({
|
||||
...project,
|
||||
position,
|
||||
parentProjectId,
|
||||
})
|
||||
emit('update:modelValue', availableProjects.value)
|
||||
} finally {
|
||||
projectUpdating.value[project.id] = false
|
||||
}
|
||||
}
|
||||
</script>
|
156
src/components/home/ProjectsNavigationItem.vue
Normal file
156
src/components/home/ProjectsNavigationItem.vue
Normal file
|
@ -0,0 +1,156 @@
|
|||
<template>
|
||||
<li
|
||||
class="list-menu loader-container is-loading-small"
|
||||
:class="{'is-loading': isLoading}"
|
||||
>
|
||||
<div>
|
||||
<BaseButton
|
||||
v-if="canCollapse && childProjects?.length > 0"
|
||||
@click="childProjectsOpen = !childProjectsOpen"
|
||||
class="collapse-project-button"
|
||||
>
|
||||
<icon icon="chevron-down" :class="{ 'project-is-collapsed': !childProjectsOpen }"/>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
:to="{ name: 'project.index', params: { projectId: project.id} }"
|
||||
class="list-menu-link"
|
||||
:class="{'router-link-exact-active': currentProject?.id === project.id}"
|
||||
>
|
||||
<span
|
||||
v-if="!canCollapse || childProjects?.length === 0"
|
||||
class="collapse-project-button-placeholder"
|
||||
></span>
|
||||
<div class="color-bubble-handle-wrapper">
|
||||
<ColorBubble
|
||||
v-if="project.hexColor !== ''"
|
||||
:color="project.hexColor"
|
||||
/>
|
||||
<span
|
||||
class="icon menu-item-icon handle lines-handle"
|
||||
:class="{'has-color-bubble': project.hexColor !== ''}"
|
||||
>
|
||||
<icon icon="grip-lines"/>
|
||||
</span>
|
||||
</div>
|
||||
<span class="list-menu-title">{{ getProjectTitle(project) }}</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="project.id > 0"
|
||||
class="favorite"
|
||||
:class="{'is-favorite': project.isFavorite}"
|
||||
@click="projectStore.toggleProjectFavorite(project)"
|
||||
>
|
||||
<icon :icon="project.isFavorite ? 'star' : ['far', 'star']"/>
|
||||
</BaseButton>
|
||||
<ProjectSettingsDropdown
|
||||
v-if="project.id > 0"
|
||||
class="menu-list-dropdown"
|
||||
:project="project"
|
||||
:level="level"
|
||||
>
|
||||
<template #trigger="{toggleOpen}">
|
||||
<BaseButton class="menu-list-dropdown-trigger" @click="toggleOpen">
|
||||
<icon icon="ellipsis-h" class="icon"/>
|
||||
</BaseButton>
|
||||
</template>
|
||||
</ProjectSettingsDropdown>
|
||||
<span class="list-setting-spacer" v-else></span>
|
||||
</div>
|
||||
<ProjectsNavigation
|
||||
v-if="canNestDeeper && childProjectsOpen && canCollapse"
|
||||
:model-value="childProjects"
|
||||
:can-edit-order="true"
|
||||
:can-collapse="canCollapse"
|
||||
:level="level + 1"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref} from 'vue'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import ProjectSettingsDropdown from '@/components/project/project-settings-dropdown.vue'
|
||||
import {getProjectTitle} from '@/helpers/getProjectTitle'
|
||||
import ColorBubble from '@/components/misc/colorBubble.vue'
|
||||
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
|
||||
import {canNestProjectDeeper} from '@/helpers/canNestProjectDeeper'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
project: IProject,
|
||||
isLoading?: boolean,
|
||||
canCollapse?: boolean,
|
||||
level?: number,
|
||||
}>(), {
|
||||
level: 0,
|
||||
})
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const baseStore = useBaseStore()
|
||||
const currentProject = computed(() => baseStore.currentProject)
|
||||
|
||||
const childProjectsOpen = ref(true)
|
||||
|
||||
const childProjects = computed(() => {
|
||||
if (!canNestDeeper.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
return projectStore.getChildProjects(props.project.id)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
})
|
||||
|
||||
const canNestDeeper = computed(() => canNestProjectDeeper(props.level))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.list-setting-spacer {
|
||||
width: 5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.project-is-collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.favorite {
|
||||
transition: opacity $transition, color $transition;
|
||||
opacity: 0;
|
||||
|
||||
&:hover,
|
||||
&.is-favorite {
|
||||
opacity: 1;
|
||||
color: var(--warning);
|
||||
}
|
||||
}
|
||||
|
||||
.list-menu:hover > div > .favorite {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.list-menu:hover > div > a > .color-bubble-handle-wrapper > .color-bubble {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.color-bubble-handle-wrapper {
|
||||
position: relative;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
margin-right: .25rem;
|
||||
|
||||
.color-bubble, .icon {
|
||||
transition: all $transition;
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,102 +1,79 @@
|
|||
<template>
|
||||
<header
|
||||
:class="{'has-background': background, 'menu-active': menuActive}"
|
||||
aria-label="main navigation"
|
||||
class="navbar main-theme is-fixed-top d-print-none"
|
||||
>
|
||||
<router-link :to="{name: 'home'}" class="logo-link">
|
||||
<Logo width="164" height="48"/>
|
||||
<header :class="{ 'has-background': background, 'menu-active': menuActive }" aria-label="main navigation"
|
||||
class="navbar d-print-none">
|
||||
<router-link :to="{ name: 'home' }" class="logo-link">
|
||||
<Logo width="164" height="48" />
|
||||
</router-link>
|
||||
<MenuButton class="menu-button"/>
|
||||
<div class="list-title" ref="listTitle" v-show="currentList.id">
|
||||
<template v-if="currentList.id">
|
||||
<h1
|
||||
:style="{ 'opacity': currentList.title === '' ? '0': '1' }"
|
||||
class="title">
|
||||
{{ currentList.title === '' ? $t('misc.loading') : getListTitle(currentList) }}
|
||||
</h1>
|
||||
|
||||
<BaseButton :to="{name: 'list.info', params: {listId: currentList.id}}" class="info-button">
|
||||
<icon icon="circle-info"/>
|
||||
</BaseButton>
|
||||
<MenuButton class="menu-button" />
|
||||
|
||||
<list-settings-dropdown v-if="canWriteCurrentList && currentList.id !== -1" :list="currentList"/>
|
||||
</template>
|
||||
<div v-if="currentProject?.id" class="project-title-wrapper">
|
||||
<h1 class="project-title">
|
||||
{{ currentProject.title === '' ? $t('misc.loading') : getProjectTitle(currentProject) }}
|
||||
</h1>
|
||||
|
||||
<BaseButton :to="{ name: 'project.info', params: { projectId: currentProject.id } }" class="project-title-button">
|
||||
<icon icon="circle-info" />
|
||||
</BaseButton>
|
||||
|
||||
<project-settings-dropdown v-if="canWriteCurrentProject && currentProject.id !== -1"
|
||||
class="project-title-dropdown" :project="currentProject">
|
||||
<template #trigger="{ toggleOpen }">
|
||||
<BaseButton class="project-title-button" @click="toggleOpen">
|
||||
<icon icon="ellipsis-h" class="icon" />
|
||||
</BaseButton>
|
||||
</template>
|
||||
</project-settings-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="navbar-end">
|
||||
<update/>
|
||||
<BaseButton
|
||||
@click="openQuickActions"
|
||||
class="trigger-button pr-0"
|
||||
v-shortcut="'Control+k'"
|
||||
:title="$t('keyboardShortcuts.quickSearch')"
|
||||
>
|
||||
<icon icon="search"/>
|
||||
<BaseButton @click="openQuickActions" class="trigger-button" v-shortcut="'Control+k'"
|
||||
:title="$t('keyboardShortcuts.quickSearch')">
|
||||
<icon icon="search" />
|
||||
</BaseButton>
|
||||
<notifications/>
|
||||
<div class="user">
|
||||
<dropdown class="is-right" ref="usernameDropdown">
|
||||
<template #trigger="{toggleOpen}">
|
||||
<x-button
|
||||
class="username-dropdown-trigger"
|
||||
@click="toggleOpen()"
|
||||
variant="secondary"
|
||||
:shadow="false"
|
||||
>
|
||||
<img :src="authStore.avatarUrl" alt="" class="avatar" width="40" height="40"/>
|
||||
<span class="username">{{ authStore.userDisplayName }}</span>
|
||||
<span class="icon is-small">
|
||||
<icon icon="chevron-down"/>
|
||||
</span>
|
||||
</x-button>
|
||||
</template>
|
||||
<Notifications />
|
||||
<dropdown>
|
||||
<template #trigger="{ toggleOpen, open }">
|
||||
<BaseButton class="username-dropdown-trigger" @click="toggleOpen" variant="secondary" :shadow="false">
|
||||
<img :src="authStore.avatarUrl" alt="" class="avatar" width="40" height="40" />
|
||||
<span class="username">{{ authStore.userDisplayName }}</span>
|
||||
<span class="icon is-small" :style="{
|
||||
transform: open ? 'rotate(180deg)' : 'rotate(0)',
|
||||
}">
|
||||
<icon icon="chevron-down" />
|
||||
</span>
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<dropdown-item
|
||||
:to="{name: 'user.settings'}"
|
||||
>
|
||||
{{ $t('user.settings.title') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
v-if="imprintUrl"
|
||||
:href="imprintUrl"
|
||||
>
|
||||
{{ $t('navigation.imprint') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
v-if="privacyPolicyUrl"
|
||||
:href="privacyPolicyUrl"
|
||||
>
|
||||
{{ $t('navigation.privacy') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
@click="baseStore.setKeyboardShortcutsActive(true)"
|
||||
>
|
||||
{{ $t('keyboardShortcuts.title') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
:to="{name: 'about'}"
|
||||
>
|
||||
{{ $t('about.title') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
@click="authStore.logout()"
|
||||
>
|
||||
{{ $t('user.auth.logout') }}
|
||||
</dropdown-item>
|
||||
</dropdown>
|
||||
</div>
|
||||
<dropdown-item :to="{ name: 'user.settings' }">
|
||||
{{ $t('user.settings.title') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item v-if="imprintUrl" :href="imprintUrl">
|
||||
{{ $t('navigation.imprint') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item v-if="privacyPolicyUrl" :href="privacyPolicyUrl">
|
||||
{{ $t('navigation.privacy') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item @click="baseStore.setKeyboardShortcutsActive(true)">
|
||||
{{ $t('keyboardShortcuts.title') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item :to="{ name: 'about' }">
|
||||
{{ $t('about.title') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item @click="authStore.logout()">
|
||||
{{ $t('user.auth.logout') }}
|
||||
</dropdown-item>
|
||||
</dropdown>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, onMounted, nextTick} from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {RIGHTS as Rights} from '@/constants/rights'
|
||||
import { RIGHTS as Rights } from '@/constants/rights'
|
||||
|
||||
import Update from '@/components/home/update.vue'
|
||||
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
|
||||
import ProjectSettingsDropdown from '@/components/project/project-settings-dropdown.vue'
|
||||
import Dropdown from '@/components/misc/dropdown.vue'
|
||||
import DropdownItem from '@/components/misc/dropdown-item.vue'
|
||||
import Notifications from '@/components/notifications/notifications.vue'
|
||||
|
@ -104,16 +81,16 @@ import Logo from '@/components/home/Logo.vue'
|
|||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import MenuButton from '@/components/home/MenuButton.vue'
|
||||
|
||||
import {getListTitle} from '@/helpers/getListTitle'
|
||||
import { getProjectTitle } from '@/helpers/getProjectTitle'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import { useBaseStore } from '@/stores/base'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const currentList = computed(() => baseStore.currentList)
|
||||
const currentProject = computed(() => baseStore.currentProject)
|
||||
const background = computed(() => baseStore.background)
|
||||
const canWriteCurrentList = computed(() => baseStore.currentList.maxRight > Rights.READ)
|
||||
const canWriteCurrentProject = computed(() => baseStore.currentProject?.maxRight > Rights.READ)
|
||||
const menuActive = computed(() => baseStore.menuActive)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
@ -122,182 +99,152 @@ const configStore = useConfigStore()
|
|||
const imprintUrl = computed(() => configStore.legal.imprintUrl)
|
||||
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
|
||||
|
||||
const usernameDropdown = ref()
|
||||
const listTitle = ref()
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
if (typeof usernameDropdown.value === 'undefined' || typeof listTitle.value === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const usernameWidth = usernameDropdown.value.$el.clientWidth
|
||||
listTitle.value.style.setProperty('--nav-username-width', `${usernameWidth}px`)
|
||||
})
|
||||
|
||||
function openQuickActions() {
|
||||
baseStore.setQuickActionsActive(true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$vikunja-nav-logo-full-width: 164px;
|
||||
$user-dropdown-width-mobile: 5rem;
|
||||
|
||||
$hamburger-menu-icon-spacing: 1rem;
|
||||
$hamburger-menu-icon-width: 28px;
|
||||
.navbar {
|
||||
--navbar-button-min-width: 40px;
|
||||
--navbar-gap-width: 1rem;
|
||||
--navbar-icon-size: 1.25rem;
|
||||
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--navbar-gap-width);
|
||||
|
||||
background: var(--site-background);
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
padding-right: .5rem;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
padding-left: 2rem;
|
||||
padding-right: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
&.menu-active {
|
||||
@media screen and (max-width: $tablet) {
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: notifications should provide a slot for the icon instead, so that we can style it as we want
|
||||
:deep() {
|
||||
.trigger-button {
|
||||
color: var(--grey-400);
|
||||
font-size: var(--navbar-icon-size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logo-link {
|
||||
display: none;
|
||||
padding: 0.5rem 0.75rem;
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 2rem;
|
||||
margin-right: 1.5rem;
|
||||
margin-right: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
align-self: stretch;
|
||||
margin-right: auto;
|
||||
align-self: stretch;
|
||||
flex: 0 0 auto;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
margin-left: $hamburger-menu-icon-spacing;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar.main-theme {
|
||||
background: var(--site-background);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
@media screen and (max-width: $desktop) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.navbar-end {
|
||||
margin-left: 0;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
&.menu-active {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.user {
|
||||
width: $user-dropdown-width-mobile;
|
||||
|
||||
.username-dropdown-trigger {
|
||||
line-height: 1;
|
||||
padding: 0 0.25rem;
|
||||
height: 1rem;
|
||||
|
||||
.icon {
|
||||
width: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.username {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navbar {
|
||||
// FIXME: notifications should provide a slot for the icon instead, so that we can style it as we want
|
||||
:deep() {
|
||||
.trigger-button {
|
||||
cursor: pointer;
|
||||
color: var(--grey-400);
|
||||
padding: .5rem;
|
||||
font-size: 1.25rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
> * > .trigger-button {
|
||||
width: $navbar-icon-width;
|
||||
}
|
||||
}
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
font-family: $vikunja-font;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
border-radius: 100%;
|
||||
vertical-align: middle;
|
||||
height: 40px;
|
||||
margin-right: .5rem;
|
||||
}
|
||||
|
||||
.username-dropdown-trigger {
|
||||
background: none;
|
||||
|
||||
&:focus:not(:active), &:active {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-title {
|
||||
.project-title-wrapper {
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
$edit-icon-width: 1rem;
|
||||
// this makes the truncated text of the project title work
|
||||
// inside the flexbox parent
|
||||
min-width: 0;
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
// We need a fixed width for overflowing ellipsis to work
|
||||
--nav-username-width: 0;
|
||||
width: calc(100vw - #{$user-dropdown-width-mobile} - #{2 * $hamburger-menu-icon-spacing} - #{$hamburger-menu-icon-width} - #{$edit-icon-width} - #{2 * $navbar-icon-width} - #{$vikunja-nav-logo-full-width} - var(--nav-username-width));
|
||||
padding-inline: var(--navbar-gap-width);
|
||||
}
|
||||
}
|
||||
|
||||
.project-title {
|
||||
font-size: 1rem;
|
||||
// We need the following for overflowing ellipsis to work
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.project-title-dropdown {
|
||||
align-self: stretch;
|
||||
|
||||
.project-title-button {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.project-title-button {
|
||||
align-self: stretch;
|
||||
min-width: var(--navbar-button-min-width);
|
||||
display: flex;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--navbar-icon-size);
|
||||
color: var(--grey-400);
|
||||
}
|
||||
|
||||
.navbar-end {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
||||
>* {
|
||||
min-width: var(--navbar-button-min-width);
|
||||
}
|
||||
}
|
||||
|
||||
.username-dropdown-trigger {
|
||||
padding-left: 1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-transform: uppercase;
|
||||
font-size: .85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-family: $vikunja-font;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
// We need a fixed width for overflowing ellipsis to work
|
||||
width: calc(100vw - #{$user-dropdown-width-mobile} - #{2 * $hamburger-menu-icon-spacing} - #{$hamburger-menu-icon-width} - #{$edit-icon-width} - #{2 * $navbar-icon-width});
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.dropdown-trigger) {
|
||||
color: var(--grey-400);
|
||||
margin-left: .5rem;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.info-button {
|
||||
text-align: center;
|
||||
height: 1.25rem;
|
||||
line-height: 1.25rem;
|
||||
width: 2rem;
|
||||
margin-top: .25rem;
|
||||
padding: 0 .5rem;
|
||||
color: var(--grey-400);
|
||||
margin-left: .5rem;
|
||||
.avatar {
|
||||
border-radius: 100%;
|
||||
vertical-align: middle;
|
||||
height: 40px;
|
||||
margin-right: .5rem;
|
||||
}
|
||||
</style>
|
|
@ -1,7 +1,11 @@
|
|||
<template>
|
||||
<div class="update-notification" v-if="updateAvailable">
|
||||
<p>{{ $t('update.available') }}</p>
|
||||
<x-button @click="refreshApp()" :shadow="false" class="has-no-text-wrap">
|
||||
<p class="update-notification__message">{{ $t('update.available') }}</p>
|
||||
<x-button
|
||||
@click="refreshApp()"
|
||||
:shadow="false"
|
||||
:wrap="false"
|
||||
>
|
||||
{{ $t('update.do') }}
|
||||
</x-button>
|
||||
</div>
|
||||
|
@ -16,15 +20,13 @@ const refreshing = ref(false)
|
|||
|
||||
document.addEventListener('swUpdated', showRefreshUI, {once: true})
|
||||
|
||||
if (navigator && navigator.serviceWorker) {
|
||||
navigator.serviceWorker.addEventListener(
|
||||
'controllerchange', () => {
|
||||
if (refreshing.value) return
|
||||
refreshing.value = true
|
||||
window.location.reload()
|
||||
},
|
||||
)
|
||||
}
|
||||
navigator?.serviceWorker?.addEventListener(
|
||||
'controllerchange', () => {
|
||||
if (refreshing.value) return
|
||||
refreshing.value = true
|
||||
window.location.reload()
|
||||
},
|
||||
)
|
||||
|
||||
function showRefreshUI(e: Event) {
|
||||
console.log('recieved refresh event', e)
|
||||
|
@ -33,6 +35,7 @@ function showRefreshUI(e: Event) {
|
|||
}
|
||||
|
||||
function refreshApp() {
|
||||
updateAvailable.value = false
|
||||
if (!registration.value || !registration.value.waiting) {
|
||||
return
|
||||
}
|
||||
|
@ -43,39 +46,30 @@ function refreshApp() {
|
|||
|
||||
<style lang="scss" scoped>
|
||||
.update-notification {
|
||||
position: fixed;
|
||||
// FIXME: We should prevent usage of z-index or
|
||||
// at least define it centrally
|
||||
// the highest z-index of a modal is .hint-modal with 4500
|
||||
z-index: 5000;
|
||||
bottom: 1rem;
|
||||
inset-inline: 1rem;
|
||||
max-width: max-content;
|
||||
margin-inline: auto;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: $warning;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: .5rem;
|
||||
background: $warning;
|
||||
border-radius: $radius;
|
||||
font-size: .9rem;
|
||||
color: var(--grey-900);
|
||||
justify-content: space-between;
|
||||
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
width: 450px;
|
||||
left: calc(50vw - 225px);
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
position: fixed;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
p {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
> * + * {
|
||||
margin-left: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dark .update-notification {
|
||||
color: var(--grey-200);
|
||||
.update-notification__message {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
|
@ -1,16 +1,16 @@
|
|||
<template>
|
||||
<div class="content-auth">
|
||||
<BaseButton
|
||||
v-if="menuActive"
|
||||
v-show="menuActive"
|
||||
@click="baseStore.setMenuActive(false)"
|
||||
class="menu-hide-button d-print-none"
|
||||
>
|
||||
<icon icon="times"/>
|
||||
</BaseButton>
|
||||
<div
|
||||
class="app-container"
|
||||
:class="{'has-background': background || blurHash}"
|
||||
:style="{'background-image': blurHash && `url(${blurHash})`}"
|
||||
class="app-container"
|
||||
>
|
||||
<div
|
||||
:class="{'is-visible': background}"
|
||||
|
@ -18,14 +18,14 @@
|
|||
:style="{'background-image': background && `url(${background})`}"></div>
|
||||
<navigation class="d-print-none"/>
|
||||
<main
|
||||
class="app-content"
|
||||
:class="[
|
||||
{ 'is-menu-enabled': menuActive },
|
||||
$route.name,
|
||||
]"
|
||||
class="app-content"
|
||||
>
|
||||
<BaseButton
|
||||
v-if="menuActive"
|
||||
v-show="menuActive"
|
||||
@click="baseStore.setMenuActive(false)"
|
||||
class="mobile-overlay d-print-none"
|
||||
/>
|
||||
|
@ -33,7 +33,7 @@
|
|||
<quick-actions/>
|
||||
|
||||
<router-view :route="routeWithModal" v-slot="{ Component }">
|
||||
<keep-alive :include="['list.list', 'list.gantt', 'list.table', 'list.kanban']">
|
||||
<keep-alive :include="['project.list', 'project.gantt', 'project.table', 'project.kanban']">
|
||||
<component :is="Component"/>
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
|
@ -69,6 +69,7 @@ import BaseButton from '@/components/base/BaseButton.vue'
|
|||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useLabelStore} from '@/stores/labels'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
import {useRouteWithModal} from '@/composables/useRouteWithModal'
|
||||
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
|
||||
|
@ -86,30 +87,26 @@ function showKeyboardShortcuts() {
|
|||
|
||||
const route = useRoute()
|
||||
|
||||
// hide menu on mobile
|
||||
watch(() => route.fullPath, () => window.innerWidth < 769 && baseStore.setMenuActive(false))
|
||||
|
||||
// FIXME: this is really error prone
|
||||
// Reset the current list highlight in menu if the current route is not list related.
|
||||
// Reset the current project highlight in menu if the current route is not project related.
|
||||
watch(() => route.name as string, (routeName) => {
|
||||
if (
|
||||
routeName &&
|
||||
(
|
||||
[
|
||||
'home',
|
||||
'namespace.edit',
|
||||
'teams.index',
|
||||
'teams.edit',
|
||||
'tasks.range',
|
||||
'labels.index',
|
||||
'migrate.start',
|
||||
'migrate.wunderlist',
|
||||
'namespaces.index',
|
||||
'projects.index',
|
||||
].includes(routeName) ||
|
||||
routeName.startsWith('user.settings')
|
||||
)
|
||||
) {
|
||||
baseStore.handleSetCurrentList({list: null})
|
||||
baseStore.handleSetCurrentProject({project: null})
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -119,6 +116,9 @@ useRenewTokenOnFocus()
|
|||
|
||||
const labelStore = useLabelStore()
|
||||
labelStore.loadAllLabels()
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
projectStore.loadProjects()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -159,6 +159,8 @@ labelStore.loadAllLabels()
|
|||
z-index: 10;
|
||||
position: relative;
|
||||
padding: 1.5rem 0.5rem 1rem;
|
||||
// TODO refactor: DRY `transition-timing-function` with `./navigation.vue`.
|
||||
transition: margin-left $transition-duration;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
margin-left: 0;
|
||||
|
@ -224,9 +226,4 @@ labelStore.loadAllLabels()
|
|||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.is-touch .content-auth,
|
||||
.content-auth.z-unset {
|
||||
z-index: unset;
|
||||
}
|
||||
</style>
|
|
@ -9,9 +9,9 @@
|
|||
<Logo class="logo" v-if="logoVisible"/>
|
||||
<h1
|
||||
:class="{'m-0': !logoVisible}"
|
||||
:style="{ 'opacity': currentList.title === '' ? '0': '1' }"
|
||||
:style="{ 'opacity': currentProject?.title === '' ? '0': '1' }"
|
||||
class="title">
|
||||
{{ currentList.title === '' ? $t('misc.loading') : currentList.title }}
|
||||
{{ currentProject?.title === '' ? $t('misc.loading') : currentProject?.title }}
|
||||
</h1>
|
||||
<div class="box has-text-left view">
|
||||
<router-view/>
|
||||
|
@ -31,7 +31,7 @@ import Logo from '@/components/home/Logo.vue'
|
|||
import PoweredByLink from './PoweredByLink.vue'
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const currentList = computed(() => baseStore.currentList)
|
||||
const currentProject = computed(() => baseStore.currentProject)
|
||||
const background = computed(() => baseStore.background)
|
||||
const logoVisible = computed(() => baseStore.logoVisible)
|
||||
</script>
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
<template>
|
||||
<aside :class="{'is-active': menuActive}" class="namespace-container">
|
||||
<aside :class="{'is-active': baseStore.menuActive}" class="menu-container">
|
||||
<nav class="menu top-menu">
|
||||
<router-link :to="{name: 'home'}" class="logo">
|
||||
<Logo width="164" height="48"/>
|
||||
</router-link>
|
||||
<ul class="menu-list">
|
||||
<menu class="menu-list other-menu-items">
|
||||
<li>
|
||||
<router-link :to="{ name: 'home'}" v-shortcut="'g o'">
|
||||
<span class="menu-item-icon icon">
|
||||
|
@ -22,11 +22,11 @@
|
|||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link :to="{ name: 'namespaces.index'}" v-shortcut="'g n'">
|
||||
<router-link :to="{ name: 'projects.index'}" v-shortcut="'g p'">
|
||||
<span class="menu-item-icon icon">
|
||||
<icon icon="layer-group"/>
|
||||
</span>
|
||||
{{ $t('namespace.title') }}
|
||||
{{ $t('project.projects') }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
|
@ -45,247 +45,51 @@
|
|||
{{ $t('team.title') }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</menu>
|
||||
</nav>
|
||||
|
||||
<nav class="menu namespaces-lists loader-container is-loading-small" :class="{'is-loading': loading}">
|
||||
<template v-for="(n, nk) in namespaces" :key="n.id">
|
||||
<div class="namespace-title" :class="{'has-menu': n.id > 0}">
|
||||
<BaseButton
|
||||
@click="toggleLists(n.id)"
|
||||
class="menu-label"
|
||||
v-tooltip="namespaceTitles[nk]"
|
||||
>
|
||||
<ColorBubble
|
||||
v-if="n.hexColor !== ''"
|
||||
:color="n.hexColor"
|
||||
class="mr-1"
|
||||
/>
|
||||
<span class="name">{{ namespaceTitles[nk] }}</span>
|
||||
<div
|
||||
class="icon menu-item-icon is-small toggle-lists-icon pl-2"
|
||||
:class="{'active': typeof listsVisible[n.id] !== 'undefined' ? listsVisible[n.id] : true}"
|
||||
>
|
||||
<icon icon="chevron-down"/>
|
||||
</div>
|
||||
<span class="count" :class="{'ml-2 mr-0': n.id > 0}">
|
||||
({{ namespaceListsCount[nk] }})
|
||||
</span>
|
||||
</BaseButton>
|
||||
<namespace-settings-dropdown class="menu-list-dropdown" :namespace="n" v-if="n.id > 0"/>
|
||||
</div>
|
||||
<!--
|
||||
NOTE: a v-model / computed setter is not possible, since the updateActiveLists function
|
||||
triggered by the change needs to have access to the current namespace
|
||||
-->
|
||||
<draggable
|
||||
v-if="listsVisible[n.id] ?? true"
|
||||
v-bind="dragOptions"
|
||||
:modelValue="activeLists[nk]"
|
||||
@update:modelValue="(lists) => updateActiveLists(n, lists)"
|
||||
group="namespace-lists"
|
||||
@start="() => drag = true"
|
||||
@end="saveListPosition"
|
||||
handle=".handle"
|
||||
:disabled="n.id < 0 || undefined"
|
||||
tag="ul"
|
||||
item-key="id"
|
||||
:data-namespace-id="n.id"
|
||||
:data-namespace-index="nk"
|
||||
:component-data="{
|
||||
type: 'transition-group',
|
||||
name: !drag ? 'flip-list' : null,
|
||||
class: [
|
||||
'menu-list can-be-hidden',
|
||||
{ 'dragging-disabled': n.id < 0 }
|
||||
]
|
||||
}"
|
||||
>
|
||||
<template #item="{element: l}">
|
||||
<li
|
||||
class="list-menu loader-container is-loading-small"
|
||||
:class="{'is-loading': listUpdating[l.id]}"
|
||||
>
|
||||
<BaseButton
|
||||
:to="{ name: 'list.index', params: { listId: l.id} }"
|
||||
class="list-menu-link"
|
||||
:class="{'router-link-exact-active': currentList.id === l.id}"
|
||||
>
|
||||
<span class="icon menu-item-icon handle">
|
||||
<icon icon="grip-lines"/>
|
||||
</span>
|
||||
<ColorBubble
|
||||
v-if="l.hexColor !== ''"
|
||||
:color="l.hexColor"
|
||||
class="mr-1"
|
||||
/>
|
||||
<span class="list-menu-title">{{ getListTitle(l) }}</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="favorite"
|
||||
:class="{'is-favorite': l.isFavorite}"
|
||||
@click="listStore.toggleListFavorite(l)"
|
||||
>
|
||||
<icon :icon="l.isFavorite ? 'star' : ['far', 'star']"/>
|
||||
</BaseButton>
|
||||
<list-settings-dropdown class="menu-list-dropdown" :list="l" v-if="l.id > 0">
|
||||
<template #trigger="{toggleOpen}">
|
||||
<BaseButton class="menu-list-dropdown-trigger" @click="toggleOpen">
|
||||
<icon icon="ellipsis-h" class="icon"/>
|
||||
</BaseButton>
|
||||
</template>
|
||||
</list-settings-dropdown>
|
||||
<span class="list-setting-spacer" v-else></span>
|
||||
</li>
|
||||
</template>
|
||||
</draggable>
|
||||
</template>
|
||||
</nav>
|
||||
<Loading
|
||||
v-if="projectStore.isLoading"
|
||||
variant="small"
|
||||
/>
|
||||
<template v-else>
|
||||
<nav class="menu" v-if="favoriteProjects">
|
||||
<ProjectsNavigation :model-value="favoriteProjects" :can-edit-order="false" :can-collapse="false"/>
|
||||
</nav>
|
||||
|
||||
<nav class="menu">
|
||||
<ProjectsNavigation
|
||||
:model-value="projects"
|
||||
:can-edit-order="true"
|
||||
:can-collapse="true"
|
||||
:level="1"
|
||||
/>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<PoweredByLink/>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, onMounted, onBeforeMount} from 'vue'
|
||||
import draggable from 'zhyswan-vuedraggable'
|
||||
import type {SortableEvent} from 'sortablejs'
|
||||
import {computed} from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
|
||||
import NamespaceSettingsDropdown from '@/components/namespace/namespace-settings-dropdown.vue'
|
||||
import PoweredByLink from '@/components/home/PoweredByLink.vue'
|
||||
import Logo from '@/components/home/Logo.vue'
|
||||
|
||||
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
||||
import {getNamespaceTitle} from '@/helpers/getNamespaceTitle'
|
||||
import {getListTitle} from '@/helpers/getListTitle'
|
||||
import {useEventListener} from '@vueuse/core'
|
||||
import type {IList} from '@/modelTypes/IList'
|
||||
import type {INamespace} from '@/modelTypes/INamespace'
|
||||
import ColorBubble from '@/components/misc/colorBubble.vue'
|
||||
import Loading from '@/components/misc/loading.vue'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useListStore} from '@/stores/lists'
|
||||
import {useNamespaceStore} from '@/stores/namespaces'
|
||||
|
||||
const drag = ref(false)
|
||||
const dragOptions = {
|
||||
animation: 100,
|
||||
ghostClass: 'ghost',
|
||||
}
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const namespaceStore = useNamespaceStore()
|
||||
const currentList = computed(() => baseStore.currentList)
|
||||
const menuActive = computed(() => baseStore.menuActive)
|
||||
const loading = computed(() => namespaceStore.isLoading)
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
|
||||
const namespaces = computed(() => {
|
||||
return namespaceStore.namespaces.filter(n => !n.isArchived)
|
||||
})
|
||||
const activeLists = computed(() => {
|
||||
return namespaces.value.map(({lists}) => {
|
||||
return lists?.filter(item => {
|
||||
return typeof item !== 'undefined' && !item.isArchived
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const namespaceTitles = computed(() => {
|
||||
return namespaces.value.map((namespace) => getNamespaceTitle(namespace))
|
||||
})
|
||||
|
||||
const namespaceListsCount = computed(() => {
|
||||
return namespaces.value.map((_, index) => activeLists.value[index]?.length ?? 0)
|
||||
})
|
||||
|
||||
|
||||
useEventListener('resize', resize)
|
||||
onMounted(() => resize())
|
||||
|
||||
const listStore = useListStore()
|
||||
|
||||
function resize() {
|
||||
// Hide the menu by default on mobile
|
||||
baseStore.setMenuActive(window.innerWidth >= 770)
|
||||
}
|
||||
|
||||
function toggleLists(namespaceId: INamespace['id']) {
|
||||
listsVisible.value[namespaceId] = !listsVisible.value[namespaceId]
|
||||
}
|
||||
|
||||
const listsVisible = ref<{ [id: INamespace['id']]: boolean }>({})
|
||||
// FIXME: async action will be unfinished when component mounts
|
||||
onBeforeMount(async () => {
|
||||
const namespaces = await namespaceStore.loadNamespaces()
|
||||
namespaces.forEach(n => {
|
||||
if (typeof listsVisible.value[n.id] === 'undefined') {
|
||||
listsVisible.value[n.id] = true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function updateActiveLists(namespace: INamespace, activeLists: IList[]) {
|
||||
// This is a bit hacky: since we do have to filter out the archived items from the list
|
||||
// for vue draggable updating it is not as simple as replacing it.
|
||||
// To work around this, we merge the active lists with the archived ones. Doing so breaks the order
|
||||
// because now all archived lists are sorted after the active ones. This is fine because they are sorted
|
||||
// later when showing them anyway, and it makes the merging happening here a lot easier.
|
||||
const lists = [
|
||||
...activeLists,
|
||||
...namespace.lists.filter(l => l.isArchived),
|
||||
]
|
||||
|
||||
namespaceStore.setNamespaceById({
|
||||
...namespace,
|
||||
lists,
|
||||
})
|
||||
}
|
||||
|
||||
const listUpdating = ref<{ [id: INamespace['id']]: boolean }>({})
|
||||
|
||||
async function saveListPosition(e: SortableEvent) {
|
||||
if (!e.newIndex && e.newIndex !== 0) return
|
||||
|
||||
const namespaceId = parseInt(e.to.dataset.namespaceId as string)
|
||||
const newNamespaceIndex = parseInt(e.to.dataset.namespaceIndex as string)
|
||||
|
||||
const listsActive = activeLists.value[newNamespaceIndex]
|
||||
// If the list was dragged to the last position, Safari will report e.newIndex as the size of the listsActive
|
||||
// array instead of using the position. Because the index is wrong in that case, dragging the list will fail.
|
||||
// To work around that we're explicitly checking that case here and decrease the index.
|
||||
const newIndex = e.newIndex === listsActive.length ? e.newIndex - 1 : e.newIndex
|
||||
|
||||
const list = listsActive[newIndex]
|
||||
const listBefore = listsActive[newIndex - 1] ?? null
|
||||
const listAfter = listsActive[newIndex + 1] ?? null
|
||||
listUpdating.value[list.id] = true
|
||||
|
||||
const position = calculateItemPosition(
|
||||
listBefore !== null ? listBefore.position : null,
|
||||
listAfter !== null ? listAfter.position : null,
|
||||
)
|
||||
|
||||
try {
|
||||
// create a copy of the list in order to not violate pinia manipulation
|
||||
await listStore.updateList({
|
||||
...list,
|
||||
position,
|
||||
namespaceId,
|
||||
})
|
||||
} finally {
|
||||
listUpdating.value[list.id] = false
|
||||
}
|
||||
}
|
||||
const projects = computed(() => projectStore.notArchivedRootProjects)
|
||||
const favoriteProjects = computed(() => projectStore.favoriteProjects)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$navbar-padding: 2rem;
|
||||
$vikunja-nav-background: var(--site-background);
|
||||
$vikunja-nav-color: var(--grey-700);
|
||||
$vikunja-nav-selected-width: 0.4rem;
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
|
||||
|
@ -298,8 +102,8 @@ $vikunja-nav-selected-width: 0.4rem;
|
|||
}
|
||||
}
|
||||
|
||||
.namespace-container {
|
||||
background: $vikunja-nav-background;
|
||||
.menu-container {
|
||||
background: var(--site-background);
|
||||
color: $vikunja-nav-color;
|
||||
padding: 0 0 1rem;
|
||||
transition: transform $transition-duration ease-in;
|
||||
|
@ -310,6 +114,7 @@ $vikunja-nav-selected-width: 0.4rem;
|
|||
transform: translateX(-100%);
|
||||
overflow-x: auto;
|
||||
width: $navbar-width;
|
||||
margin-top: 1rem;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
top: 0;
|
||||
|
@ -323,235 +128,24 @@ $vikunja-nav-selected-width: 0.4rem;
|
|||
}
|
||||
}
|
||||
|
||||
// these are general menu styles
|
||||
// should be in own components
|
||||
.menu {
|
||||
.menu-label,
|
||||
.menu-list .list-menu-link,
|
||||
.menu-list a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
|
||||
.color-bubble {
|
||||
height: 12px;
|
||||
flex: 0 0 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-list {
|
||||
li {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background: var(--white);
|
||||
}
|
||||
|
||||
.menu-list-dropdown {
|
||||
opacity: 0;
|
||||
transition: $transition;
|
||||
}
|
||||
|
||||
&:hover .menu-list-dropdown {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-item-icon {
|
||||
color: var(--grey-400);
|
||||
}
|
||||
|
||||
.menu-list-dropdown-trigger {
|
||||
display: flex;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.flip-list-move {
|
||||
transition: transform $transition-duration;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: var(--grey-200);
|
||||
|
||||
* {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
a:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.list-menu-link,
|
||||
li > a {
|
||||
color: $vikunja-nav-color;
|
||||
padding: 0.75rem .5rem 0.75rem ($navbar-padding * 1.5 - 1.75rem);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
border-radius: 0;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
border-left: $vikunja-nav-selected-width solid transparent;
|
||||
|
||||
&:hover {
|
||||
border-left: $vikunja-nav-selected-width solid var(--primary);
|
||||
}
|
||||
|
||||
&.router-link-exact-active {
|
||||
color: var(--primary);
|
||||
border-left: $vikunja-nav-selected-width solid var(--primary);
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: 1rem;
|
||||
vertical-align: middle;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
&.router-link-exact-active .icon:not(.handle) {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.handle {
|
||||
opacity: 0;
|
||||
transition: opacity $transition;
|
||||
margin-right: .25rem;
|
||||
cursor: grab;
|
||||
}
|
||||
&:hover .handle {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.top-menu {
|
||||
margin-top: math.div($navbar-padding, 2);
|
||||
|
||||
.menu-list {
|
||||
li {
|
||||
font-weight: 600;
|
||||
font-family: $vikunja-font;
|
||||
}
|
||||
|
||||
.list-menu-link,
|
||||
li > a {
|
||||
padding-left: 2rem;
|
||||
display: inline-block;
|
||||
|
||||
.icon {
|
||||
padding-bottom: .25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.namespaces-lists {
|
||||
padding-top: math.div($navbar-padding, 2);
|
||||
|
||||
.menu-label {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
font-weight: bold;
|
||||
font-family: $vikunja-font;
|
||||
color: $vikunja-nav-color;
|
||||
.top-menu .menu-list {
|
||||
li {
|
||||
font-weight: 600;
|
||||
min-height: 2.5rem;
|
||||
padding-top: 0;
|
||||
padding-left: $navbar-padding;
|
||||
font-family: $vikunja-font;
|
||||
}
|
||||
|
||||
overflow: hidden;
|
||||
margin-bottom: 0;
|
||||
flex: 1 1 auto;
|
||||
.list-menu-link,
|
||||
li > a {
|
||||
padding-left: 2rem;
|
||||
display: inline-block;
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: auto;
|
||||
.icon {
|
||||
padding-bottom: .25rem;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: var(--grey-500);
|
||||
margin-right: .5rem;
|
||||
// align brackets with number
|
||||
font-feature-settings: "case";
|
||||
}
|
||||
}
|
||||
|
||||
.favorite {
|
||||
margin-left: .25rem;
|
||||
transition: opacity $transition, color $transition;
|
||||
opacity: 0;
|
||||
|
||||
&:hover,
|
||||
&.is-favorite {
|
||||
color: var(--warning);
|
||||
}
|
||||
}
|
||||
|
||||
.favorite.is-favorite,
|
||||
.list-menu:hover .favorite {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.list-menu-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.color-bubble {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.is-archived {
|
||||
min-width: 85px;
|
||||
}
|
||||
}
|
||||
|
||||
.namespace-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: $vikunja-nav-color;
|
||||
padding: 0 .25rem;
|
||||
|
||||
.toggle-lists-icon {
|
||||
svg {
|
||||
transition: all $transition;
|
||||
transform: rotate(90deg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.active svg {
|
||||
transform: rotate(0deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .toggle-lists-icon svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:not(.has-menu) .toggle-lists-icon {
|
||||
padding-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.list-setting-spacer {
|
||||
width: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.namespaces-list.loader-container.is-loading {
|
||||
min-height: calc(100vh - #{$navbar-height + 1.5rem + 1rem + 1.5rem});
|
||||
.menu + .menu {
|
||||
padding-top: math.div($navbar-padding, 2);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,63 +0,0 @@
|
|||
<template>
|
||||
<multiselect
|
||||
v-model="selectedLists"
|
||||
:search-results="foundLists"
|
||||
:loading="listService.loading"
|
||||
:multiple="true"
|
||||
:placeholder="$t('list.search')"
|
||||
label="title"
|
||||
@search="findLists"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, shallowReactive, watchEffect, type PropType} from 'vue'
|
||||
|
||||
import Multiselect from '@/components/input/multiselect.vue'
|
||||
|
||||
import type {IList} from '@/modelTypes/IList'
|
||||
|
||||
import ListService from '@/services/list'
|
||||
import {includesById} from '@/helpers/utils'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as PropType<IList[]>,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: IList[]): void
|
||||
}>()
|
||||
|
||||
const lists = ref<IList[]>([])
|
||||
|
||||
watchEffect(() => {
|
||||
lists.value = props.modelValue
|
||||
})
|
||||
|
||||
const selectedLists = computed({
|
||||
get() {
|
||||
return lists.value
|
||||
},
|
||||
set: (value) => {
|
||||
lists.value = value
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
const listService = shallowReactive(new ListService())
|
||||
const foundLists = ref<IList[]>([])
|
||||
|
||||
async function findLists(query: string) {
|
||||
if (query === '') {
|
||||
foundLists.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const response = await listService.getAll({}, {s: query}) as IList[]
|
||||
|
||||
// Filter selected items from the results
|
||||
foundLists.value = response.filter(({id}) => !includesById(lists.value, id))
|
||||
}
|
||||
</script>
|
|
@ -1,63 +0,0 @@
|
|||
<template>
|
||||
<multiselect
|
||||
v-model="selectedNamespaces"
|
||||
:search-results="foundNamespaces"
|
||||
:loading="namespaceService.loading"
|
||||
:multiple="true"
|
||||
:placeholder="$t('namespace.search')"
|
||||
label="namespace"
|
||||
@search="findNamespaces"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, shallowReactive, watchEffect, type PropType} from 'vue'
|
||||
|
||||
import Multiselect from '@/components/input/multiselect.vue'
|
||||
|
||||
import type {INamespace} from '@/modelTypes/INamespace'
|
||||
|
||||
import NamespaceService from '@/services/namespace'
|
||||
import {includesById} from '@/helpers/utils'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as PropType<INamespace[]>,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: INamespace[]): void
|
||||
}>()
|
||||
|
||||
const namespaces = ref<INamespace[]>([])
|
||||
|
||||
watchEffect(() => {
|
||||
namespaces.value = props.modelValue
|
||||
})
|
||||
|
||||
const selectedNamespaces = computed({
|
||||
get() {
|
||||
return namespaces.value
|
||||
},
|
||||
set: (value) => {
|
||||
namespaces.value = value
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
const namespaceService = shallowReactive(new NamespaceService())
|
||||
const foundNamespaces = ref<INamespace[]>([])
|
||||
|
||||
async function findNamespaces(query: string) {
|
||||
if (query === '') {
|
||||
foundNamespaces.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const response = await namespaceService.getAll({}, {s: query}) as INamespace[]
|
||||
|
||||
// Filter selected items from the results
|
||||
foundNamespaces.value = response.filter(({id}) => !includesById(namespaces.value, id))
|
||||
}
|
||||
</script>
|
63
src/components/input/SelectProject.vue
Normal file
63
src/components/input/SelectProject.vue
Normal file
|
@ -0,0 +1,63 @@
|
|||
<template>
|
||||
<multiselect
|
||||
v-model="selectedProjects"
|
||||
:search-results="foundProjects"
|
||||
:loading="projectService.loading"
|
||||
:multiple="true"
|
||||
:placeholder="$t('project.search')"
|
||||
label="title"
|
||||
@search="findProjects"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, shallowReactive, watchEffect, type PropType} from 'vue'
|
||||
|
||||
import Multiselect from '@/components/input/multiselect.vue'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import ProjectService from '@/services/project'
|
||||
import {includesById} from '@/helpers/utils'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as PropType<IProject[]>,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: IProject[]): void
|
||||
}>()
|
||||
|
||||
const projects = ref<IProject[]>([])
|
||||
|
||||
watchEffect(() => {
|
||||
projects.value = props.modelValue
|
||||
})
|
||||
|
||||
const selectedProjects = computed({
|
||||
get() {
|
||||
return projects.value
|
||||
},
|
||||
set: (value) => {
|
||||
projects.value = value
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
const projectService = shallowReactive(new ProjectService())
|
||||
const foundProjects = ref<IProject[]>([])
|
||||
|
||||
async function findProjects(query: string) {
|
||||
if (query === '') {
|
||||
foundProjects.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const response = await projectService.getAll({}, {s: query}) as IProject[]
|
||||
|
||||
// Filter selected items from the results
|
||||
foundProjects.value = response.filter(({id}) => !includesById(projects.value, id))
|
||||
}
|
||||
</script>
|
26
src/components/input/SimpleButton.vue
Normal file
26
src/components/input/SimpleButton.vue
Normal file
|
@ -0,0 +1,26 @@
|
|||
<template>
|
||||
<BaseButton class="simple-button">
|
||||
<slot/>
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.simple-button {
|
||||
color: var(--text);
|
||||
padding: .25rem .5rem;
|
||||
transition: background-color $transition;
|
||||
border-radius: $radius;
|
||||
display: block;
|
||||
margin: .1rem 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: var(--white);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -8,17 +8,20 @@
|
|||
'has-no-shadow': !shadow || variant === 'tertiary',
|
||||
}
|
||||
]"
|
||||
:style="{
|
||||
'--button-white-space': wrap ? 'break-spaces' : 'nowrap',
|
||||
}"
|
||||
>
|
||||
<template v-if="icon">
|
||||
<icon
|
||||
v-if="showIconOnly"
|
||||
:icon="icon"
|
||||
:style="{'color': iconColor !== '' ? iconColor : false}"
|
||||
:style="{'color': iconColor !== '' ? iconColor : undefined}"
|
||||
/>
|
||||
<span class="icon is-small" v-else>
|
||||
<icon
|
||||
:icon="icon"
|
||||
:style="{'color': iconColor !== '' ? iconColor : false}"
|
||||
:style="{'color': iconColor !== '' ? iconColor : undefined}"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
@ -50,6 +53,7 @@ export interface ButtonProps extends BaseButtonProps {
|
|||
iconColor?: string
|
||||
loading?: boolean
|
||||
shadow?: boolean
|
||||
wrap?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
|
@ -58,6 +62,7 @@ const {
|
|||
iconColor = '',
|
||||
loading = false,
|
||||
shadow = true,
|
||||
wrap = true,
|
||||
} = defineProps<ButtonProps>()
|
||||
|
||||
const variantClass = computed(() => BUTTON_TYPES_MAP[variant])
|
||||
|
@ -77,7 +82,7 @@ const showIconOnly = computed(() => icon !== '' && typeof slots.default === 'und
|
|||
min-height: $button-height;
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: inline-flex;
|
||||
white-space: break-spaces;
|
||||
white-space: var(--button-white-space);
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
|
@ -99,7 +104,6 @@ const showIconOnly = computed(() => icon !== '' && typeof slots.default === 'und
|
|||
&.is-primary.is-outlined:hover {
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.is-small {
|
||||
|
|
|
@ -1,78 +1,15 @@
|
|||
<template>
|
||||
<div class="datepicker">
|
||||
<BaseButton @click.stop="toggleDatePopup" class="show" :disabled="disabled || undefined">
|
||||
<SimpleButton @click.stop="toggleDatePopup" class="show" :disabled="disabled || undefined">
|
||||
{{ date === null ? chooseDateLabel : formatDateShort(date) }}
|
||||
</BaseButton>
|
||||
</SimpleButton>
|
||||
|
||||
<CustomTransition name="fade">
|
||||
<div v-if="show" class="datepicker-popup" ref="datepickerPopup">
|
||||
|
||||
<BaseButton
|
||||
v-if="(new Date()).getHours() < 21"
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('today')"
|
||||
>
|
||||
<span class="icon"><icon :icon="['far', 'calendar-alt']"/></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.today') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('tomorrow')"
|
||||
>
|
||||
<span class="icon"><icon :icon="['far', 'sun']"/></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.tomorrow') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextMonday')"
|
||||
>
|
||||
<span class="icon"><icon icon="coffee"/></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextMonday') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('thisWeekend')"
|
||||
>
|
||||
<span class="icon"><icon icon="cocktail"/></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.thisWeekend') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('laterThisWeek')"
|
||||
>
|
||||
<span class="icon"><icon icon="chess-knight"/></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.laterThisWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextWeek')"
|
||||
>
|
||||
<span class="icon"><icon icon="forward"/></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
|
||||
<flat-pickr
|
||||
:config="flatPickerConfig"
|
||||
class="input"
|
||||
v-model="flatPickrDate"
|
||||
<DatepickerInline
|
||||
v-model="date"
|
||||
@update:model-value="updateData"
|
||||
/>
|
||||
|
||||
<x-button
|
||||
|
@ -89,19 +26,15 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, onMounted, onBeforeUnmount, toRef, watch, computed, type PropType} from 'vue'
|
||||
import flatPickr from 'vue-flatpickr-component'
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
import {ref, onMounted, onBeforeUnmount, toRef, watch, type PropType} from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
import DatepickerInline from '@/components/input/datepickerInline.vue'
|
||||
import SimpleButton from '@/components/input/SimpleButton.vue'
|
||||
|
||||
import {formatDate, formatDateShort} from '@/helpers/time/formatDate'
|
||||
import {calculateDayInterval} from '@/helpers/time/calculateDayInterval'
|
||||
import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
|
||||
import {formatDateShort} from '@/helpers/time/formatDate'
|
||||
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
|
||||
import {createDateFromString} from '@/helpers/time/createDateFromString'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
const props = defineProps({
|
||||
|
@ -125,8 +58,6 @@ const props = defineProps({
|
|||
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'close-on-change'])
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const date = ref<Date | null>()
|
||||
const show = ref(false)
|
||||
const changed = ref(false)
|
||||
|
@ -141,37 +72,6 @@ watch(
|
|||
{immediate: true},
|
||||
)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const weekStart = computed(() => authStore.settings.weekStart)
|
||||
const flatPickerConfig = computed(() => ({
|
||||
altFormat: t('date.altFormatLong'),
|
||||
altInput: true,
|
||||
dateFormat: 'Y-m-d H:i',
|
||||
enableTime: true,
|
||||
time_24hr: true,
|
||||
inline: true,
|
||||
locale: {
|
||||
firstDayOfWeek: weekStart.value,
|
||||
},
|
||||
}))
|
||||
|
||||
// Since flatpickr dates are strings, we need to convert them to native date objects.
|
||||
// To make that work, we need a separate variable since flatpickr does not have a change event.
|
||||
const flatPickrDate = computed({
|
||||
set(newValue: string | Date) {
|
||||
date.value = createDateFromString(newValue)
|
||||
updateData()
|
||||
},
|
||||
get() {
|
||||
if (!date.value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return formatDate(date.value, 'yyy-LL-dd H:mm')
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
function setDateValue(dateString: string | Date | null) {
|
||||
if (dateString === null) {
|
||||
date.value = null
|
||||
|
@ -212,29 +112,6 @@ function close() {
|
|||
}
|
||||
}, 200)
|
||||
}
|
||||
|
||||
function setDate(dateString: string) {
|
||||
if (date.value === null) {
|
||||
date.value = new Date()
|
||||
}
|
||||
|
||||
const interval = calculateDayInterval(dateString)
|
||||
const newDate = new Date()
|
||||
newDate.setDate(newDate.getDate() + interval)
|
||||
newDate.setHours(calculateNearestHours(newDate))
|
||||
newDate.setMinutes(0)
|
||||
newDate.setSeconds(0)
|
||||
date.value = newDate
|
||||
flatPickrDate.value = newDate
|
||||
updateData()
|
||||
}
|
||||
|
||||
function getWeekdayFromStringInterval(dateString: string) {
|
||||
const interval = calculateDayInterval(dateString)
|
||||
const newDate = new Date()
|
||||
newDate.setDate(newDate.getDate() + interval)
|
||||
return formatDate(newDate, 'E')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -257,42 +134,6 @@ function getWeekdayFromStringInterval(dateString: string) {
|
|||
}
|
||||
}
|
||||
|
||||
.datepicker__quick-select-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 .5rem;
|
||||
width: 100%;
|
||||
height: 2.25rem;
|
||||
color: var(--text);
|
||||
transition: all $transition;
|
||||
|
||||
&:first-child {
|
||||
border-radius: $radius $radius 0 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--grey-100);
|
||||
}
|
||||
|
||||
.text {
|
||||
width: 100%;
|
||||
font-size: .85rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-right: .25rem;
|
||||
|
||||
.weekday {
|
||||
color: var(--text-light);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.datepicker__close-button {
|
||||
margin: 1rem;
|
||||
width: calc(100% - 2rem);
|
||||
|
|
228
src/components/input/datepickerInline.vue
Normal file
228
src/components/input/datepickerInline.vue
Normal file
|
@ -0,0 +1,228 @@
|
|||
<template>
|
||||
<BaseButton
|
||||
v-if="(new Date()).getHours() < 21"
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('today')"
|
||||
>
|
||||
<span class="icon"><icon :icon="['far', 'calendar-alt']"/></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.today') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('tomorrow')"
|
||||
>
|
||||
<span class="icon"><icon :icon="['far', 'sun']"/></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.tomorrow') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextMonday')"
|
||||
>
|
||||
<span class="icon"><icon icon="coffee"/></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextMonday') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('thisWeekend')"
|
||||
>
|
||||
<span class="icon"><icon icon="cocktail"/></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.thisWeekend') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('laterThisWeek')"
|
||||
>
|
||||
<span class="icon"><icon icon="chess-knight"/></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.laterThisWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextWeek')"
|
||||
>
|
||||
<span class="icon"><icon icon="forward"/></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
|
||||
<div class="flatpickr-container">
|
||||
<flat-pickr
|
||||
:config="flatPickerConfig"
|
||||
v-model="flatPickrDate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, toRef, watch, computed, type PropType} from 'vue'
|
||||
import flatPickr from 'vue-flatpickr-component'
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
import {formatDate} from '@/helpers/time/formatDate'
|
||||
import {calculateDayInterval} from '@/helpers/time/calculateDayInterval'
|
||||
import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
|
||||
import {createDateFromString} from '@/helpers/time/createDateFromString'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [Date, null, String] as PropType<Date | null | string>,
|
||||
validator: prop => prop instanceof Date || prop === null || typeof prop === 'string',
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'close-on-change'])
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const date = ref<Date | null>()
|
||||
const changed = ref(false)
|
||||
|
||||
const modelValue = toRef(props, 'modelValue')
|
||||
watch(
|
||||
modelValue,
|
||||
setDateValue,
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const weekStart = computed(() => authStore.settings.weekStart)
|
||||
const flatPickerConfig = computed(() => ({
|
||||
altFormat: t('date.altFormatLong'),
|
||||
altInput: true,
|
||||
dateFormat: 'Y-m-d H:i',
|
||||
enableTime: true,
|
||||
time_24hr: true,
|
||||
inline: true,
|
||||
locale: {
|
||||
firstDayOfWeek: weekStart.value,
|
||||
},
|
||||
}))
|
||||
|
||||
// Since flatpickr dates are strings, we need to convert them to native date objects.
|
||||
// To make that work, we need a separate variable since flatpickr does not have a change event.
|
||||
const flatPickrDate = computed({
|
||||
set(newValue: string | Date | null) {
|
||||
if (newValue === null) {
|
||||
date.value = null
|
||||
return
|
||||
}
|
||||
|
||||
date.value = createDateFromString(newValue)
|
||||
updateData()
|
||||
},
|
||||
get() {
|
||||
if (!date.value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return formatDate(date.value, 'yyy-LL-dd H:mm')
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
function setDateValue(dateString: string | Date | null) {
|
||||
if (dateString === null) {
|
||||
date.value = null
|
||||
return
|
||||
}
|
||||
date.value = createDateFromString(dateString)
|
||||
}
|
||||
|
||||
function updateData() {
|
||||
changed.value = true
|
||||
emit('update:modelValue', date.value)
|
||||
}
|
||||
|
||||
function setDate(dateString: string) {
|
||||
if (date.value === null) {
|
||||
date.value = new Date()
|
||||
}
|
||||
|
||||
const interval = calculateDayInterval(dateString)
|
||||
const newDate = new Date()
|
||||
newDate.setDate(newDate.getDate() + interval)
|
||||
newDate.setHours(calculateNearestHours(newDate))
|
||||
newDate.setMinutes(0)
|
||||
newDate.setSeconds(0)
|
||||
date.value = newDate
|
||||
flatPickrDate.value = newDate
|
||||
updateData()
|
||||
}
|
||||
|
||||
function getWeekdayFromStringInterval(dateString: string) {
|
||||
const interval = calculateDayInterval(dateString)
|
||||
const newDate = new Date()
|
||||
newDate.setDate(newDate.getDate() + interval)
|
||||
return formatDate(newDate, 'E')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.datepicker__quick-select-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 .5rem;
|
||||
width: 100%;
|
||||
height: 2.25rem;
|
||||
color: var(--text);
|
||||
transition: all $transition;
|
||||
|
||||
&:first-child {
|
||||
border-radius: $radius $radius 0 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--grey-100);
|
||||
}
|
||||
|
||||
.text {
|
||||
width: 100%;
|
||||
font-size: .85rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-right: .25rem;
|
||||
|
||||
.weekday {
|
||||
color: var(--text-light);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.flatpickr-container {
|
||||
:deep(.flatpickr-calendar) {
|
||||
margin: 0 auto 8px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.input) {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -211,7 +211,7 @@ function handleInput(val: string) {
|
|||
bubble(1000)
|
||||
}
|
||||
|
||||
function bubble(timeout = 500) {
|
||||
function bubble(timeout = 5000) {
|
||||
if (changeTimeout.value !== null) {
|
||||
clearTimeout(changeTimeout.value)
|
||||
}
|
||||
|
@ -286,11 +286,11 @@ function handleCheckboxClick(e: Event) {
|
|||
console.debug('no index found')
|
||||
return
|
||||
}
|
||||
const listPrefix = text.value.substring(index, index + 1)
|
||||
const projectPrefix = text.value.substring(index, index + 1)
|
||||
|
||||
console.debug({index, listPrefix, checked, text: text.value})
|
||||
console.debug({index, projectPrefix, checked, text: text.value})
|
||||
|
||||
text.value = replaceAt(text.value, index, `${listPrefix} ${checked ? '[x]' : '[ ]'} `)
|
||||
text.value = replaceAt(text.value, index, `${projectPrefix} ${checked ? '[x]' : '[ ]'} `)
|
||||
bubble()
|
||||
renderPreview()
|
||||
}
|
||||
|
|
71
src/components/input/fancycheckbox.story.vue
Normal file
71
src/components/input/fancycheckbox.story.vue
Normal file
|
@ -0,0 +1,71 @@
|
|||
<script lang="ts" setup>
|
||||
import {ref} from 'vue'
|
||||
import {logEvent} from 'histoire/client'
|
||||
import FancyCheckbox from './fancycheckbox.vue'
|
||||
|
||||
const isDisabled = ref<boolean | undefined>()
|
||||
|
||||
const isChecked = ref(false)
|
||||
|
||||
const isCheckedInitiallyEnabled = ref(true)
|
||||
|
||||
const isCheckedDisabled = ref(false)
|
||||
|
||||
const withoutInitialState = ref<boolean | undefined>()
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<Story :layout="{ type: 'grid', width: '200px' }">
|
||||
<Variant title="Default">
|
||||
<FancyCheckbox
|
||||
v-model="isChecked"
|
||||
:disabled="isDisabled"
|
||||
>
|
||||
This is probably not important
|
||||
</FancyCheckbox>
|
||||
|
||||
Visualisation
|
||||
<input type="checkbox" v-model="isChecked">
|
||||
{{ isChecked }}
|
||||
</Variant>
|
||||
<Variant title="Enabled Initially">
|
||||
<FancyCheckbox
|
||||
:disabled="isDisabled"
|
||||
v-model="isCheckedInitiallyEnabled"
|
||||
>
|
||||
We want you to use this option
|
||||
</FancyCheckbox>
|
||||
|
||||
Visualisation
|
||||
<input type="checkbox" v-model="isCheckedInitiallyEnabled">
|
||||
{{ isCheckedInitiallyEnabled }}
|
||||
</Variant>
|
||||
<Variant title="Disabled">
|
||||
<FancyCheckbox
|
||||
disabled
|
||||
:modelValue="isCheckedDisabled"
|
||||
@update:model-value="logEvent('Setting disabled: This should never happen', $event)"
|
||||
>
|
||||
You can't change this
|
||||
</FancyCheckbox>
|
||||
|
||||
Visualisation
|
||||
<input type="checkbox" v-model="isCheckedDisabled" disabled>
|
||||
{{ isCheckedDisabled }}
|
||||
</Variant>
|
||||
|
||||
<Variant title="Undefined initial State">
|
||||
<FancyCheckbox
|
||||
v-model="withoutInitialState"
|
||||
:disabled="isDisabled"
|
||||
>
|
||||
Not sure what the value should be
|
||||
</FancyCheckbox>
|
||||
|
||||
Visualisation
|
||||
<input type="checkbox" v-model="withoutInitialState" disabled>
|
||||
{{ withoutInitialState }}
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
|
@ -1,61 +1,42 @@
|
|||
<template>
|
||||
<div :class="{'is-disabled': disabled}" class="fancycheckbox">
|
||||
<input
|
||||
:checked="checked"
|
||||
:disabled="disabled || undefined"
|
||||
:id="checkBoxId"
|
||||
@change="(event: Event) => updateData((event.target as HTMLInputElement).checked)"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label :for="checkBoxId" class="check">
|
||||
<svg height="18px" viewBox="0 0 18 18" width="18px">
|
||||
<path
|
||||
d="M1,9 L1,3.5 C1,2 2,1 3.5,1 L14.5,1 C16,1 17,2 17,3.5 L17,14.5 C17,16 16,17 14.5,17 L3.5,17 C2,17 1,16 1,14.5 L1,9 Z"></path>
|
||||
<polyline points="1 9 7 14 15 4"></polyline>
|
||||
</svg>
|
||||
<span>
|
||||
<slot></slot>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<BaseCheckbox
|
||||
class="fancycheckbox"
|
||||
:class="{
|
||||
'is-disabled': disabled,
|
||||
'is-block': isBlock,
|
||||
}"
|
||||
:disabled="disabled"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="value => emit('update:modelValue', value)"
|
||||
>
|
||||
<CheckboxIcon class="fancycheckbox__icon" />
|
||||
<span v-if="$slots.default" class="fancycheckbox__content">
|
||||
<slot/>
|
||||
</span>
|
||||
</BaseCheckbox>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, toRef, watch} from 'vue'
|
||||
import CheckboxIcon from '@/assets/checkbox.svg?component'
|
||||
|
||||
import {createRandomID} from '@/helpers/randomId'
|
||||
import BaseCheckbox from '@/components/base/BaseCheckbox.vue'
|
||||
|
||||
const checked = ref(false)
|
||||
const checkBoxId = `fancycheckbox_${createRandomID()}`
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
isBlock: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const modelValue = toRef(props, 'modelValue')
|
||||
|
||||
watch(
|
||||
modelValue,
|
||||
newValue => {
|
||||
checked.value = newValue
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
function updateData(newChecked: boolean) {
|
||||
checked.value = newChecked
|
||||
emit('update:modelValue', newChecked)
|
||||
emit('change', newChecked)
|
||||
}
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
|
||||
|
@ -65,75 +46,54 @@ function updateData(newChecked: boolean) {
|
|||
padding-right: 5px;
|
||||
padding-top: 3px;
|
||||
|
||||
// FIXME: should be a prop
|
||||
&.is-block {
|
||||
display: block;
|
||||
margin: .5rem .2rem;
|
||||
}
|
||||
}
|
||||
|
||||
input[type=checkbox] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.check {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
margin: auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
span {
|
||||
.fancycheckbox__content {
|
||||
font-size: 0.8rem;
|
||||
vertical-align: top;
|
||||
padding-left: .5rem;
|
||||
}
|
||||
|
||||
svg {
|
||||
.fancycheckbox__icon:deep() {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke: #c8ccd4;
|
||||
stroke-width: 1.5;
|
||||
stroke: var(--stroke-color, #c8ccd4);
|
||||
transform: translate3d(0, 0, 0);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.check:hover svg {
|
||||
stroke: var(--primary);
|
||||
}
|
||||
|
||||
.is-disabled .check:hover svg {
|
||||
stroke: #c8ccd4;
|
||||
}
|
||||
|
||||
path {
|
||||
stroke-dasharray: 60;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
polyline {
|
||||
stroke-dasharray: 22;
|
||||
stroke-dashoffset: 66;
|
||||
}
|
||||
|
||||
input[type=checkbox]:checked + .check {
|
||||
svg {
|
||||
stroke: var(--primary);
|
||||
path,
|
||||
polyline {
|
||||
transition: all 0.2s linear, color 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.fancycheckbox:not(:has(input:disabled)):hover .fancycheckbox__icon,
|
||||
.fancycheckbox:has(input:checked) .fancycheckbox__icon {
|
||||
--stroke-color: var(--primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
// Since css-has-pseudo doesn't work with deep classes,
|
||||
// the following rules can't be scoped
|
||||
|
||||
.fancycheckbox:has(:not(input:checked)) .fancycheckbox__icon {
|
||||
path {
|
||||
transition-delay: 0.05s;
|
||||
}
|
||||
}
|
||||
|
||||
.fancycheckbox:has(input:checked) .fancycheckbox__icon {
|
||||
path {
|
||||
stroke-dashoffset: 60;
|
||||
transition: all 0.3s linear;
|
||||
}
|
||||
|
||||
polyline {
|
||||
stroke-dashoffset: 42;
|
||||
transition: all 0.2s linear;
|
||||
transition-delay: 0.15s;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,200 +0,0 @@
|
|||
<template>
|
||||
<div
|
||||
:class="{ 'is-loading': listService.loading, 'is-archived': currentList.isArchived}"
|
||||
class="loader-container"
|
||||
>
|
||||
<div class="switch-view-container">
|
||||
<div class="switch-view">
|
||||
<BaseButton
|
||||
v-shortcut="'g l'"
|
||||
:title="$t('keyboardShortcuts.list.switchToListView')"
|
||||
class="switch-view-button"
|
||||
:class="{'is-active': viewName === 'list'}"
|
||||
:to="{ name: 'list.list', params: { listId } }"
|
||||
>
|
||||
{{ $t('list.list.title') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-shortcut="'g g'"
|
||||
:title="$t('keyboardShortcuts.list.switchToGanttView')"
|
||||
class="switch-view-button"
|
||||
:class="{'is-active': viewName === 'gantt'}"
|
||||
:to="{ name: 'list.gantt', params: { listId } }"
|
||||
>
|
||||
{{ $t('list.gantt.title') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-shortcut="'g t'"
|
||||
:title="$t('keyboardShortcuts.list.switchToTableView')"
|
||||
class="switch-view-button"
|
||||
:class="{'is-active': viewName === 'table'}"
|
||||
:to="{ name: 'list.table', params: { listId } }"
|
||||
>
|
||||
{{ $t('list.table.title') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-shortcut="'g k'"
|
||||
:title="$t('keyboardShortcuts.list.switchToKanbanView')"
|
||||
class="switch-view-button"
|
||||
:class="{'is-active': viewName === 'kanban'}"
|
||||
:to="{ name: 'list.kanban', params: { listId } }"
|
||||
>
|
||||
{{ $t('list.kanban.title') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<CustomTransition name="fade">
|
||||
<Message variant="warning" v-if="currentList.isArchived" class="mb-4">
|
||||
{{ $t('list.archived') }}
|
||||
</Message>
|
||||
</CustomTransition>
|
||||
|
||||
<slot v-if="loadedListId"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, watch} from 'vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
|
||||
import ListModel from '@/models/list'
|
||||
import ListService from '@/services/list'
|
||||
|
||||
import {getListTitle} from '@/helpers/getListTitle'
|
||||
import {saveListToHistory} from '@/modules/listHistory'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useListStore} from '@/stores/lists'
|
||||
|
||||
const props = defineProps({
|
||||
listId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
viewName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const listStore = useListStore()
|
||||
const listService = ref(new ListService())
|
||||
const loadedListId = ref(0)
|
||||
|
||||
const currentList = computed(() => {
|
||||
return typeof baseStore.currentList === 'undefined' ? {
|
||||
id: 0,
|
||||
title: '',
|
||||
isArchived: false,
|
||||
maxRight: null,
|
||||
} : baseStore.currentList
|
||||
})
|
||||
useTitle(() => currentList.value.id ? getListTitle(currentList.value) : '')
|
||||
|
||||
// watchEffect would be called every time the prop would get a value assigned, even if that value was the same as before.
|
||||
// This resulted in loading and setting the list multiple times, even when navigating away from it.
|
||||
// This caused wired bugs where the list background would be set on the home page but only right after setting a new
|
||||
// list background and then navigating to home. It also highlighted the list in the menu and didn't allow changing any
|
||||
// of it, most likely due to the rights not being properly populated.
|
||||
watch(
|
||||
() => props.listId,
|
||||
// loadList
|
||||
async (listIdToLoad: number) => {
|
||||
const listData = {id: listIdToLoad}
|
||||
saveListToHistory(listData)
|
||||
|
||||
// Don't load the list if we either already loaded it or aren't dealing with a list at all currently and
|
||||
// the currently loaded list has the right set.
|
||||
if (
|
||||
(
|
||||
listIdToLoad === loadedListId.value ||
|
||||
typeof listIdToLoad === 'undefined' ||
|
||||
listIdToLoad === currentList.value.id
|
||||
)
|
||||
&& typeof currentList.value !== 'undefined' && currentList.value.maxRight !== null
|
||||
) {
|
||||
loadedListId.value = props.listId
|
||||
return
|
||||
}
|
||||
|
||||
console.debug(`Loading list, props.viewName = ${props.viewName}, $route.params =`, route.params, `, loadedListId = ${loadedListId.value}, currentList = `, currentList.value)
|
||||
|
||||
// Set the current list to the one we're about to load so that the title is already shown at the top
|
||||
loadedListId.value = 0
|
||||
const listFromStore = listStore.getListById(listData.id)
|
||||
if (listFromStore !== null) {
|
||||
baseStore.setBackground(null)
|
||||
baseStore.setBlurHash(null)
|
||||
baseStore.handleSetCurrentList({list: listFromStore})
|
||||
}
|
||||
|
||||
// We create an extra list object instead of creating it in list.value because that would trigger a ui update which would result in bad ux.
|
||||
const list = new ListModel(listData)
|
||||
try {
|
||||
const loadedList = await listService.value.get(list)
|
||||
baseStore.handleSetCurrentList({list: loadedList})
|
||||
} finally {
|
||||
loadedListId.value = props.listId
|
||||
}
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.switch-view-container {
|
||||
@media screen and (max-width: $tablet) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.switch-view {
|
||||
background: var(--white);
|
||||
display: inline-flex;
|
||||
border-radius: $radius;
|
||||
font-size: .75rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
height: $switch-view-height;
|
||||
margin: 0 auto 1rem;
|
||||
padding: .5rem;
|
||||
}
|
||||
|
||||
.switch-view-button {
|
||||
padding: .25rem .5rem;
|
||||
display: block;
|
||||
border-radius: $radius;
|
||||
transition: all 100ms;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: .5rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--switch-view-color);
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
color: var(--switch-view-color);
|
||||
background: var(--primary);
|
||||
font-weight: bold;
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: this should be in notification and set via a prop
|
||||
.is-archived .notification.is-warning {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
|
@ -1,77 +0,0 @@
|
|||
<template>
|
||||
<ul class="list-grid">
|
||||
<li
|
||||
v-for="(item, index) in filteredLists"
|
||||
:key="`list_${item.id}_${index}`"
|
||||
class="list-grid-item"
|
||||
>
|
||||
<ListCard :list="item" />
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, type PropType} from 'vue'
|
||||
import type {IList} from '@/modelTypes/IList'
|
||||
|
||||
import ListCard from './ListCard.vue'
|
||||
|
||||
const props = defineProps({
|
||||
lists: {
|
||||
type: Array as PropType<IList[]>,
|
||||
default: () => [],
|
||||
},
|
||||
showArchived: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
itemLimit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const filteredLists = computed(() => {
|
||||
return props.showArchived
|
||||
? props.lists
|
||||
: props.lists.filter(l => !l.isArchived)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$list-height: 150px;
|
||||
$list-spacing: 1rem;
|
||||
|
||||
.list-grid {
|
||||
margin: 0; // reset li
|
||||
list-style-type: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--list-columns), 1fr);
|
||||
grid-auto-rows: $list-height;
|
||||
gap: $list-spacing;
|
||||
|
||||
@media screen and (min-width: $mobile) {
|
||||
--list-rows: 4;
|
||||
--list-columns: 1;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $mobile) and (max-width: $tablet) {
|
||||
--list-columns: 2;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $tablet) and (max-width: $widescreen) {
|
||||
--list-columns: 3;
|
||||
--list-rows: 3;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $widescreen) {
|
||||
--list-columns: 5;
|
||||
--list-rows: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.list-grid-item {
|
||||
display: grid;
|
||||
margin-top: 0; // remove padding coming form .content li + li
|
||||
}
|
||||
</style>
|
|
@ -24,12 +24,12 @@
|
|||
}"
|
||||
>
|
||||
<div :class="{'content': hasContent}">
|
||||
<slot />
|
||||
<slot/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer v-if="$slots.footer" class="card-footer">
|
||||
<slot name="footer" />
|
||||
<slot name="footer"/>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -76,22 +76,27 @@ defineEmits(['close'])
|
|||
|
||||
<style lang="scss" scoped>
|
||||
.card {
|
||||
background-color: var(--white);
|
||||
border-radius: $radius;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--card-border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
background-color: var(--white);
|
||||
border-radius: $radius;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--card-border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
@media print {
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
box-shadow: none;
|
||||
border-bottom: 1px solid var(--card-border-color);
|
||||
border-radius: $radius $radius 0 0;
|
||||
box-shadow: none;
|
||||
border-bottom: 1px solid var(--card-border-color);
|
||||
border-radius: $radius $radius 0 0;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
background-color: var(--grey-50);
|
||||
border-top: 0;
|
||||
background-color: var(--grey-50);
|
||||
border-top: 0;
|
||||
padding: var(--modal-card-head-padding);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="dropdown" ref="dropdown">
|
||||
<slot name="trigger" :close="close" :toggleOpen="toggleOpen">
|
||||
<slot name="trigger" :close="close" :toggleOpen="toggleOpen" :open="open">
|
||||
<BaseButton class="dropdown-trigger is-flex" @click="toggleOpen">
|
||||
<icon :icon="triggerIcon" class="icon"/>
|
||||
</BaseButton>
|
||||
|
@ -56,7 +56,6 @@ onClickOutside(dropdown, (e: Event) => {
|
|||
.dropdown {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<modal @close="close()">
|
||||
<card class="has-background-white has-no-shadow keyboard-shortcuts" :title="$t('keyboardShortcuts.title')">
|
||||
<card class="has-background-white keyboard-shortcuts" :shadow="false" :title="$t('keyboardShortcuts.title')">
|
||||
<template v-for="(s, i) in shortcuts" :key="i">
|
||||
<h3>{{ $t(s.title) }}</h3>
|
||||
|
||||
|
|
|
@ -44,8 +44,8 @@ export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
|
|||
combination: 'then',
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.navigation.namespaces',
|
||||
keys: ['g', 'n'],
|
||||
title: 'keyboardShortcuts.navigation.projects',
|
||||
keys: ['g', 'p'],
|
||||
combination: 'then',
|
||||
},
|
||||
{
|
||||
|
@ -61,8 +61,8 @@ export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
|
|||
],
|
||||
},
|
||||
{
|
||||
title: 'list.kanban.title',
|
||||
available: (route) => route.name === 'list.kanban',
|
||||
title: 'project.kanban.title',
|
||||
available: (route) => route.name === 'project.kanban',
|
||||
shortcuts: [
|
||||
{
|
||||
title: 'keyboardShortcuts.task.done',
|
||||
|
@ -71,26 +71,26 @@ export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
|
|||
],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.list.title',
|
||||
available: (route) => (route.name as string)?.startsWith('list.'),
|
||||
title: 'keyboardShortcuts.project.title',
|
||||
available: (route) => (route.name as string)?.startsWith('project.'),
|
||||
shortcuts: [
|
||||
{
|
||||
title: 'keyboardShortcuts.list.switchToListView',
|
||||
title: 'keyboardShortcuts.project.switchToListView',
|
||||
keys: ['g', 'l'],
|
||||
combination: 'then',
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.list.switchToGanttView',
|
||||
title: 'keyboardShortcuts.project.switchToGanttView',
|
||||
keys: ['g', 'g'],
|
||||
combination: 'then',
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.list.switchToTableView',
|
||||
title: 'keyboardShortcuts.project.switchToTableView',
|
||||
keys: ['g', 't'],
|
||||
combination: 'then',
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.list.switchToKanbanView',
|
||||
title: 'keyboardShortcuts.project.switchToKanbanView',
|
||||
keys: ['g', 'k'],
|
||||
combination: 'then',
|
||||
},
|
||||
|
@ -140,6 +140,18 @@ export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
|
|||
title: 'keyboardShortcuts.task.description',
|
||||
keys: ['e'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.priority',
|
||||
keys: ['p'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.delete',
|
||||
keys: ['shift', 'delete'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.favorite',
|
||||
keys: ['s'],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
]
|
|
@ -1,13 +1,21 @@
|
|||
<template>
|
||||
<div class="loader-container is-loading"></div>
|
||||
<div class="loader-container is-loading" :class="{'is-small': variant === 'small'}"></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
inheritAttrs: true,
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const {
|
||||
variant = 'default',
|
||||
} = defineProps<{
|
||||
variant?: 'default' | 'small'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.loader-container {
|
||||
height: 100%;
|
||||
|
@ -20,5 +28,18 @@ export default {
|
|||
min-height: 50px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
&.is-small {
|
||||
min-width: 100%;
|
||||
height: 150px;
|
||||
|
||||
&.is-loading::after {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
top: calc(50% - 1.5rem);
|
||||
left: calc(50% - 1.5rem);
|
||||
border-width: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -8,7 +8,7 @@
|
|||
}"
|
||||
ref="popup"
|
||||
>
|
||||
<slot name="content" :isOpen="open"/>
|
||||
<slot name="content" :isOpen="open" :toggle="toggle"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -23,11 +23,14 @@ const props = defineProps({
|
|||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const open = ref(false)
|
||||
const popup = ref<HTMLElement | null>(null)
|
||||
|
||||
function close() {
|
||||
open.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
|
|
|
@ -47,7 +47,7 @@ import {success} from '@/message'
|
|||
import type { IconProp } from '@fortawesome/fontawesome-svg-core'
|
||||
|
||||
const props = defineProps({
|
||||
entity: String,
|
||||
entity: String as ISubscription['entity'],
|
||||
entityId: Number,
|
||||
isButton: {
|
||||
type: Boolean,
|
||||
|
@ -73,28 +73,18 @@ const {t} = useI18n({useScope: 'global'})
|
|||
|
||||
const tooltipText = computed(() => {
|
||||
if (disabled.value) {
|
||||
if (props.entity === 'list' && subscriptionEntity.value === 'namespace') {
|
||||
return t('task.subscription.subscribedListThroughParentNamespace')
|
||||
}
|
||||
if (props.entity === 'task' && subscriptionEntity.value === 'namespace') {
|
||||
return t('task.subscription.subscribedTaskThroughParentNamespace')
|
||||
}
|
||||
if (props.entity === 'task' && subscriptionEntity.value === 'list') {
|
||||
return t('task.subscription.subscribedTaskThroughParentList')
|
||||
if (props.entity === 'task' && subscriptionEntity.value === 'project') {
|
||||
return t('task.subscription.subscribedTaskThroughParentProject')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
switch (props.entity) {
|
||||
case 'namespace':
|
||||
case 'project':
|
||||
return props.modelValue !== null ?
|
||||
t('task.subscription.subscribedNamespace') :
|
||||
t('task.subscription.notSubscribedNamespace')
|
||||
case 'list':
|
||||
return props.modelValue !== null ?
|
||||
t('task.subscription.subscribedList') :
|
||||
t('task.subscription.notSubscribedList')
|
||||
t('task.subscription.subscribedProject') :
|
||||
t('task.subscription.notSubscribedProject')
|
||||
case 'task':
|
||||
return props.modelValue !== null ?
|
||||
t('task.subscription.subscribedTask') :
|
||||
|
@ -130,11 +120,8 @@ async function subscribe() {
|
|||
|
||||
let message = ''
|
||||
switch (props.entity) {
|
||||
case 'namespace':
|
||||
message = t('task.subscription.subscribeSuccessNamespace')
|
||||
break
|
||||
case 'list':
|
||||
message = t('task.subscription.subscribeSuccessList')
|
||||
case 'project':
|
||||
message = t('task.subscription.subscribeSuccessProject')
|
||||
break
|
||||
case 'task':
|
||||
message = t('task.subscription.subscribeSuccessTask')
|
||||
|
@ -153,11 +140,8 @@ async function unsubscribe() {
|
|||
|
||||
let message = ''
|
||||
switch (props.entity) {
|
||||
case 'namespace':
|
||||
message = t('task.subscription.unsubscribeSuccessNamespace')
|
||||
break
|
||||
case 'list':
|
||||
message = t('task.subscription.unsubscribeSuccessList')
|
||||
case 'project':
|
||||
message = t('task.subscription.unsubscribeSuccessProject')
|
||||
break
|
||||
case 'task':
|
||||
message = t('task.subscription.unsubscribeSuccessTask')
|
||||
|
|
|
@ -49,9 +49,11 @@ const displayName = computed(() => getDisplayName(props.user))
|
|||
<style lang="scss" scoped>
|
||||
.user {
|
||||
margin: .5rem;
|
||||
display: flex;
|
||||
justify-items: center;
|
||||
|
||||
&.is-inline {
|
||||
display: inline;
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,103 +0,0 @@
|
|||
<template>
|
||||
<dropdown>
|
||||
<template #trigger="triggerProps">
|
||||
<slot name="trigger" v-bind="triggerProps">
|
||||
<BaseButton class="dropdown-trigger" @click="triggerProps.toggleOpen">
|
||||
<icon icon="ellipsis-h" class="icon"/>
|
||||
</BaseButton>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<template v-if="namespace.isArchived">
|
||||
<dropdown-item
|
||||
:to="{ name: 'namespace.settings.archive', params: { id: namespace.id } }"
|
||||
icon="archive"
|
||||
>
|
||||
{{ $t('menu.unarchive') }}
|
||||
</dropdown-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<dropdown-item
|
||||
:to="{ name: 'namespace.settings.edit', params: { id: namespace.id } }"
|
||||
icon="pen"
|
||||
>
|
||||
{{ $t('menu.edit') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
:to="{ name: 'namespace.settings.share', params: { namespaceId: namespace.id } }"
|
||||
icon="share-alt"
|
||||
>
|
||||
{{ $t('menu.share') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
:to="{ name: 'list.create', params: { namespaceId: namespace.id } }"
|
||||
icon="plus"
|
||||
>
|
||||
{{ $t('menu.newList') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
:to="{ name: 'namespace.settings.archive', params: { id: namespace.id } }"
|
||||
icon="archive"
|
||||
>
|
||||
{{ $t('menu.archive') }}
|
||||
</dropdown-item>
|
||||
<Subscription
|
||||
class="has-no-shadow"
|
||||
:is-button="false"
|
||||
entity="namespace"
|
||||
:entity-id="namespace.id"
|
||||
:model-value="subscription"
|
||||
@update:model-value="setSubscriptionInStore"
|
||||
type="dropdown"
|
||||
/>
|
||||
<dropdown-item
|
||||
:to="{ name: 'namespace.settings.delete', params: { id: namespace.id } }"
|
||||
icon="trash-alt"
|
||||
class="has-text-danger"
|
||||
>
|
||||
{{ $t('menu.delete') }}
|
||||
</dropdown-item>
|
||||
</template>
|
||||
</dropdown>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, onMounted, type PropType} from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Dropdown from '@/components/misc/dropdown.vue'
|
||||
import DropdownItem from '@/components/misc/dropdown-item.vue'
|
||||
import Subscription from '@/components/misc/subscription.vue'
|
||||
import type {INamespace} from '@/modelTypes/INamespace'
|
||||
import type {ISubscription} from '@/modelTypes/ISubscription'
|
||||
import {useNamespaceStore} from '@/stores/namespaces'
|
||||
|
||||
const props = defineProps({
|
||||
namespace: {
|
||||
type: Object as PropType<INamespace>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const namespaceStore = useNamespaceStore()
|
||||
|
||||
const subscription = ref<ISubscription | null>(null)
|
||||
onMounted(() => {
|
||||
subscription.value = props.namespace.subscription
|
||||
})
|
||||
|
||||
function setSubscriptionInStore(sub: ISubscription) {
|
||||
subscription.value = sub
|
||||
namespaceStore.setNamespaceById({
|
||||
...props.namespace,
|
||||
subscription: sub,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dropdown-trigger {
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
|
@ -1,11 +1,11 @@
|
|||
<template>
|
||||
<div class="notifications">
|
||||
<div class="is-flex is-justify-content-center">
|
||||
<BaseButton @click.stop="showNotifications = !showNotifications" class="trigger-button">
|
||||
<slot name="trigger" toggleOpen="() => showNotifications = !showNotifications" :has-unread-notifications="unreadNotifications > 0">
|
||||
<BaseButton class="trigger-button" @click.stop="showNotifications = !showNotifications">
|
||||
<span class="unread-indicator" v-if="unreadNotifications > 0"></span>
|
||||
<icon icon="bell"/>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<CustomTransition name="fade">
|
||||
<div class="notifications-list" v-if="showNotifications" ref="popup">
|
||||
|
@ -117,9 +117,9 @@ function to(n, index) {
|
|||
case names.TASK_DELETED:
|
||||
// Nothing
|
||||
break
|
||||
case names.LIST_CREATED:
|
||||
case names.PROJECT_CREATED:
|
||||
to.name = 'task.index'
|
||||
to.params.listId = n.notification.list.id
|
||||
to.params.projectId = n.notification.project.id
|
||||
break
|
||||
case names.TEAM_MEMBER_ADDED:
|
||||
to.name = 'teams.edit'
|
||||
|
@ -141,12 +141,17 @@ function to(n, index) {
|
|||
|
||||
<style lang="scss" scoped>
|
||||
.notifications {
|
||||
width: $navbar-icon-width;
|
||||
display: flex;
|
||||
|
||||
.trigger-button {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.unread-indicator {
|
||||
position: absolute;
|
||||
top: .75rem;
|
||||
right: 1.15rem;
|
||||
top: 1rem;
|
||||
right: .5rem;
|
||||
width: .75rem;
|
||||
height: .75rem;
|
||||
|
||||
|
@ -156,9 +161,9 @@ function to(n, index) {
|
|||
}
|
||||
|
||||
.notifications-list {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
margin-top: 1rem;
|
||||
top: calc(100% + 1rem);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
|
||||
|
|
215
src/components/project/ProjectWrapper.vue
Normal file
215
src/components/project/ProjectWrapper.vue
Normal file
|
@ -0,0 +1,215 @@
|
|||
<template>
|
||||
<div
|
||||
:class="{ 'is-loading': projectService.loading, 'is-archived': currentProject?.isArchived}"
|
||||
class="loader-container"
|
||||
>
|
||||
<h1 class="project-title-print">
|
||||
{{ getProjectTitle(currentProject) }}
|
||||
</h1>
|
||||
|
||||
<div class="switch-view-container d-print-none">
|
||||
<div class="switch-view">
|
||||
<BaseButton
|
||||
v-shortcut="'g l'"
|
||||
:title="$t('keyboardShortcuts.project.switchToListView')"
|
||||
class="switch-view-button"
|
||||
:class="{'is-active': viewName === 'project'}"
|
||||
:to="{ name: 'project.list', params: { projectId } }"
|
||||
>
|
||||
{{ $t('project.list.title') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-shortcut="'g g'"
|
||||
:title="$t('keyboardShortcuts.project.switchToGanttView')"
|
||||
class="switch-view-button"
|
||||
:class="{'is-active': viewName === 'gantt'}"
|
||||
:to="{ name: 'project.gantt', params: { projectId } }"
|
||||
>
|
||||
{{ $t('project.gantt.title') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-shortcut="'g t'"
|
||||
:title="$t('keyboardShortcuts.project.switchToTableView')"
|
||||
class="switch-view-button"
|
||||
:class="{'is-active': viewName === 'table'}"
|
||||
:to="{ name: 'project.table', params: { projectId } }"
|
||||
>
|
||||
{{ $t('project.table.title') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-shortcut="'g k'"
|
||||
:title="$t('keyboardShortcuts.project.switchToKanbanView')"
|
||||
class="switch-view-button"
|
||||
:class="{'is-active': viewName === 'kanban'}"
|
||||
:to="{ name: 'project.kanban', params: { projectId } }"
|
||||
>
|
||||
{{ $t('project.kanban.title') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<CustomTransition name="fade">
|
||||
<Message variant="warning" v-if="currentProject?.isArchived" class="mb-4">
|
||||
{{ $t('project.archivedMessage') }}
|
||||
</Message>
|
||||
</CustomTransition>
|
||||
|
||||
<slot v-if="loadedProjectId"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, watch} from 'vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
|
||||
import ProjectModel from '@/models/project'
|
||||
import ProjectService from '@/services/project'
|
||||
|
||||
import {getProjectTitle} from '@/helpers/getProjectTitle'
|
||||
import {saveProjectToHistory} from '@/modules/projectHistory'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const props = defineProps({
|
||||
projectId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
viewName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const projectStore = useProjectStore()
|
||||
const projectService = ref(new ProjectService())
|
||||
const loadedProjectId = ref(0)
|
||||
|
||||
const currentProject = computed(() => {
|
||||
return typeof baseStore.currentProject === 'undefined' ? {
|
||||
id: 0,
|
||||
title: '',
|
||||
isArchived: false,
|
||||
maxRight: null,
|
||||
} : baseStore.currentProject
|
||||
})
|
||||
useTitle(() => currentProject.value?.id ? getProjectTitle(currentProject.value) : '')
|
||||
|
||||
// watchEffect would be called every time the prop would get a value assigned, even if that value was the same as before.
|
||||
// This resulted in loading and setting the project multiple times, even when navigating away from it.
|
||||
// This caused wired bugs where the project background would be set on the home page but only right after setting a new
|
||||
// project background and then navigating to home. It also highlighted the project in the menu and didn't allow changing any
|
||||
// of it, most likely due to the rights not being properly populated.
|
||||
watch(
|
||||
() => props.projectId,
|
||||
// loadProject
|
||||
async (projectIdToLoad: number) => {
|
||||
const projectData = {id: projectIdToLoad}
|
||||
saveProjectToHistory(projectData)
|
||||
|
||||
// Don't load the project if we either already loaded it or aren't dealing with a project at all currently and
|
||||
// the currently loaded project has the right set.
|
||||
if (
|
||||
(
|
||||
projectIdToLoad === loadedProjectId.value ||
|
||||
typeof projectIdToLoad === 'undefined' ||
|
||||
projectIdToLoad === currentProject.value?.id
|
||||
)
|
||||
&& typeof currentProject.value !== 'undefined' && currentProject.value.maxRight !== null
|
||||
) {
|
||||
loadedProjectId.value = props.projectId
|
||||
return
|
||||
}
|
||||
|
||||
console.debug(`Loading project, props.viewName = ${props.viewName}, $route.params =`, route.params, `, loadedProjectId = ${loadedProjectId.value}, currentProject = `, currentProject.value)
|
||||
|
||||
// Set the current project to the one we're about to load so that the title is already shown at the top
|
||||
loadedProjectId.value = 0
|
||||
const projectFromStore = projectStore.projects[projectData.id]
|
||||
if (projectFromStore) {
|
||||
baseStore.setBackground(null)
|
||||
baseStore.setBlurHash(null)
|
||||
baseStore.handleSetCurrentProject({project: projectFromStore})
|
||||
}
|
||||
|
||||
// We create an extra project object instead of creating it in project.value because that would trigger a ui update which would result in bad ux.
|
||||
const project = new ProjectModel(projectData)
|
||||
try {
|
||||
const loadedProject = await projectService.value.get(project)
|
||||
baseStore.handleSetCurrentProject({project: loadedProject})
|
||||
} finally {
|
||||
loadedProjectId.value = props.projectId
|
||||
}
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.switch-view-container {
|
||||
@media screen and (max-width: $tablet) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.switch-view {
|
||||
background: var(--white);
|
||||
display: inline-flex;
|
||||
border-radius: $radius;
|
||||
font-size: .75rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
height: $switch-view-height;
|
||||
margin: 0 auto 1rem;
|
||||
padding: .5rem;
|
||||
}
|
||||
|
||||
.switch-view-button {
|
||||
padding: .25rem .5rem;
|
||||
display: block;
|
||||
border-radius: $radius;
|
||||
transition: all 100ms;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: .5rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--switch-view-color);
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
color: var(--switch-view-color);
|
||||
background: var(--primary);
|
||||
font-weight: bold;
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: this should be in notification and set via a prop
|
||||
.is-archived .notification.is-warning {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.project-title-print {
|
||||
display: none;
|
||||
font-size: 1.75rem;
|
||||
text-align: center;
|
||||
margin-bottom: .5rem;
|
||||
|
||||
@media print {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,39 +1,39 @@
|
|||
<template>
|
||||
<div
|
||||
class="list-card"
|
||||
class="project-card"
|
||||
:class="{
|
||||
'has-light-text': background !== null,
|
||||
'has-background': blurHashUrl !== '' || background !== null
|
||||
}"
|
||||
:style="{
|
||||
'border-left': list.hexColor ? `0.25rem solid ${list.hexColor}` : undefined,
|
||||
'border-left': project.hexColor ? `0.25rem solid ${project.hexColor}` : undefined,
|
||||
'background-image': blurHashUrl !== '' ? `url(${blurHashUrl})` : undefined,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="list-background background-fade-in"
|
||||
class="project-background background-fade-in"
|
||||
:class="{'is-visible': background}"
|
||||
:style="{'background-image': background !== null ? `url(${background})` : undefined}"
|
||||
/>
|
||||
<span v-if="list.isArchived" class="is-archived" >{{ $t('namespace.archived') }}</span>
|
||||
<span v-if="project.isArchived" class="is-archived" >{{ $t('project.archived') }}</span>
|
||||
|
||||
<div class="list-title" aria-hidden="true">{{ list.title }}</div>
|
||||
<div class="project-title" aria-hidden="true">{{ project.title }}</div>
|
||||
<BaseButton
|
||||
class="list-button"
|
||||
:aria-label="list.title"
|
||||
:title="list.description"
|
||||
class="project-button"
|
||||
:aria-label="project.title"
|
||||
:title="project.description"
|
||||
:to="{
|
||||
name: 'list.index',
|
||||
params: { listId: list.id}
|
||||
name: 'project.index',
|
||||
params: { projectId: project.id}
|
||||
}"
|
||||
/>
|
||||
<BaseButton
|
||||
v-if="!list.isArchived"
|
||||
v-if="!project.isArchived"
|
||||
class="favorite"
|
||||
:class="{'is-favorite': list.isFavorite}"
|
||||
@click.prevent.stop="listStore.toggleListFavorite(list)"
|
||||
:class="{'is-favorite': project.isFavorite}"
|
||||
@click.prevent.stop="projectStore.toggleProjectFavorite(project)"
|
||||
>
|
||||
<icon :icon="list.isFavorite ? 'star' : ['far', 'star']" />
|
||||
<icon :icon="project.isFavorite ? 'star' : ['far', 'star']" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -41,30 +41,30 @@
|
|||
<script lang="ts" setup>
|
||||
import {toRef, type PropType} from 'vue'
|
||||
|
||||
import type {IList} from '@/modelTypes/IList'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
import {useListBackground} from './useListBackground'
|
||||
import {useListStore} from '@/stores/lists'
|
||||
import {useProjectBackground} from './useProjectBackground'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const props = defineProps({
|
||||
list: {
|
||||
type: Object as PropType<IList>,
|
||||
project: {
|
||||
type: Object as PropType<IProject>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const {background, blurHashUrl} = useListBackground(toRef(props, 'list'))
|
||||
const {background, blurHashUrl} = useProjectBackground(toRef(props, 'project'))
|
||||
|
||||
const listStore = useListStore()
|
||||
const projectStore = useProjectStore()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.list-card {
|
||||
--list-card-padding: 1rem;
|
||||
.project-card {
|
||||
--project-card-padding: 1rem;
|
||||
background: var(--white);
|
||||
padding: var(--list-card-padding);
|
||||
padding: var(--project-card-padding);
|
||||
border-radius: $radius;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow $transition;
|
||||
|
@ -91,14 +91,14 @@ const listStore = useListStore()
|
|||
}
|
||||
|
||||
.has-background,
|
||||
.list-background {
|
||||
.project-background {
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.list-background,
|
||||
.list-button {
|
||||
.project-background,
|
||||
.project-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
@ -111,7 +111,7 @@ const listStore = useListStore()
|
|||
float: left;
|
||||
}
|
||||
|
||||
.list-title {
|
||||
.project-title {
|
||||
align-self: flex-end;
|
||||
font-family: $vikunja-font;
|
||||
font-weight: 400;
|
||||
|
@ -120,7 +120,7 @@ const listStore = useListStore()
|
|||
color: var(--text);
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
max-height: calc(100% - (var(--list-card-padding) + 1rem)); // padding & height of the "is archived" badge
|
||||
max-height: calc(100% - (var(--project-card-padding) + 1rem)); // padding & height of the "is archived" badge
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
|
@ -130,11 +130,11 @@ const listStore = useListStore()
|
|||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.has-light-text .list-title {
|
||||
.has-light-text .project-title {
|
||||
color: var(--grey-100);
|
||||
}
|
||||
|
||||
.has-background .list-title {
|
||||
.has-background .project-title {
|
||||
text-shadow:
|
||||
0 0 10px var(--black),
|
||||
1px 1px 5px var(--grey-700),
|
||||
|
@ -144,10 +144,10 @@ const listStore = useListStore()
|
|||
|
||||
.favorite {
|
||||
position: absolute;
|
||||
top: var(--list-card-padding);
|
||||
right: var(--list-card-padding);
|
||||
top: var(--project-card-padding);
|
||||
right: var(--project-card-padding);
|
||||
transition: opacity $transition, color $transition;
|
||||
opacity: 0;
|
||||
opacity: 1;
|
||||
|
||||
&:hover {
|
||||
color: var(--warning);
|
||||
|
@ -160,8 +160,14 @@ const listStore = useListStore()
|
|||
}
|
||||
}
|
||||
|
||||
.list-card:hover .favorite {
|
||||
opacity: 1;
|
||||
@media(hover: hover) and (pointer: fine) {
|
||||
.project-card .favorite {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.project-card:hover .favorite {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.background-fade-in {
|
||||
|
@ -173,4 +179,4 @@ const listStore = useListStore()
|
|||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
73
src/components/project/partials/ProjectCardGrid.vue
Normal file
73
src/components/project/partials/ProjectCardGrid.vue
Normal file
|
@ -0,0 +1,73 @@
|
|||
<template>
|
||||
<ul class="project-grid">
|
||||
<li
|
||||
v-for="(item, index) in filteredProjects"
|
||||
:key="`project_${item.id}_${index}`"
|
||||
class="project-grid-item"
|
||||
>
|
||||
<ProjectCard :project="item" />
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, type PropType} from 'vue'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import ProjectCard from './ProjectCard.vue'
|
||||
|
||||
const props = defineProps({
|
||||
projects: {
|
||||
type: Array as PropType<IProject[]>,
|
||||
default: () => [],
|
||||
},
|
||||
showArchived: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
itemLimit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const filteredProjects = computed(() => {
|
||||
return props.showArchived
|
||||
? props.projects
|
||||
: props.projects.filter(l => !l.isArchived)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.project-grid {
|
||||
--project-grid-item-height: 150px;
|
||||
--project-grid-gap: 1rem;
|
||||
margin: 0; // reset li
|
||||
list-style-type: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--project-grid-columns), 1fr);
|
||||
grid-auto-rows: var(--project-grid-item-height);
|
||||
gap: var(--project-grid-gap);
|
||||
|
||||
@media screen and (min-width: $mobile) {
|
||||
--project-grid-columns: 1;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $mobile) and (max-width: $tablet) {
|
||||
--project-grid-columns: 2;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $tablet) and (max-width: $widescreen) {
|
||||
--project-grid-columns: 3;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $widescreen) {
|
||||
--project-grid-columns: 5;
|
||||
}
|
||||
}
|
||||
|
||||
.project-grid-item {
|
||||
display: grid;
|
||||
margin-top: 0; // remove padding coming form .content li + li
|
||||
}
|
||||
</style>
|
|
@ -32,7 +32,7 @@
|
|||
<script setup lang="ts">
|
||||
import {computed, ref, watch} from 'vue'
|
||||
|
||||
import Filters from '@/components/list/partials/filters.vue'
|
||||
import Filters from '@/components/project/partials/filters.vue'
|
||||
|
||||
import {getDefaultParams} from '@/composables/useTaskList'
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
{{ $t('filters.attributes.showDoneTasks') }}
|
||||
</fancycheckbox>
|
||||
<fancycheckbox
|
||||
v-if="!['list.kanban', 'list.table'].includes($route.name as string)"
|
||||
v-if="!['project.kanban', 'project.table'].includes($route.name as string)"
|
||||
v-model="sortAlphabetically"
|
||||
@update:model-value="change()"
|
||||
>
|
||||
|
@ -147,6 +147,7 @@
|
|||
<label class="label">{{ $t('task.attributes.labels') }}</label>
|
||||
<div class="control labels-list">
|
||||
<edit-labels
|
||||
:creatable="false"
|
||||
v-model="entities.labels"
|
||||
@update:model-value="changeLabelFilter"
|
||||
/>
|
||||
|
@ -154,24 +155,14 @@
|
|||
</div>
|
||||
|
||||
<template
|
||||
v-if="['filters.create', 'list.edit', 'filter.settings.edit'].includes($route.name as string)">
|
||||
v-if="['filters.create', 'project.edit', 'filter.settings.edit'].includes($route.name as string)">
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('list.lists') }}</label>
|
||||
<label class="label">{{ $t('project.lists') }}</label>
|
||||
<div class="control">
|
||||
<SelectList
|
||||
v-model="entities.lists"
|
||||
@select="changeMultiselectFilter('lists', 'list_id')"
|
||||
@remove="changeMultiselectFilter('lists', 'list_id')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('namespace.namespaces') }}</label>
|
||||
<div class="control">
|
||||
<SelectNamespace
|
||||
v-model="entities.namespace"
|
||||
@select="changeMultiselectFilter('namespace', 'namespace')"
|
||||
@remove="changeMultiselectFilter('namespace', 'namespace')"
|
||||
<SelectProject
|
||||
v-model="entities.projects"
|
||||
@select="changeMultiselectFilter('projects', 'project_id')"
|
||||
@remove="changeMultiselectFilter('projects', 'project_id')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -189,8 +180,7 @@ import {camelCase} from 'camel-case'
|
|||
|
||||
import type {ILabel} from '@/modelTypes/ILabel'
|
||||
import type {IUser} from '@/modelTypes/IUser'
|
||||
import type {INamespace} from '@/modelTypes/INamespace'
|
||||
import type {IList} from '@/modelTypes/IList'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import {useLabelStore} from '@/stores/labels'
|
||||
|
||||
|
@ -200,21 +190,19 @@ import PercentDoneSelect from '@/components/tasks/partials/percentDoneSelect.vue
|
|||
import EditLabels from '@/components/tasks/partials/editLabels.vue'
|
||||
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||
import SelectUser from '@/components/input/SelectUser.vue'
|
||||
import SelectList from '@/components/input/SelectList.vue'
|
||||
import SelectNamespace from '@/components/input/SelectNamespace.vue'
|
||||
import SelectProject from '@/components/input/SelectProject.vue'
|
||||
|
||||
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
|
||||
import {dateIsValid, formatISO} from '@/helpers/time/formatDate'
|
||||
import {objectToSnakeCase} from '@/helpers/case'
|
||||
|
||||
import UserService from '@/services/user'
|
||||
import ListService from '@/services/list'
|
||||
import NamespaceService from '@/services/namespace'
|
||||
import ProjectService from '@/services/project'
|
||||
|
||||
// FIXME: do not use this here for now. instead create new version from DEFAULT_PARAMS
|
||||
import {getDefaultParams} from '@/composables/useTaskList'
|
||||
|
||||
// FIXME: merge with DEFAULT_PARAMS in taskList.js
|
||||
// FIXME: merge with DEFAULT_PARAMS in taskProject.js
|
||||
const DEFAULT_PARAMS = {
|
||||
sort_by: [],
|
||||
order_by: [],
|
||||
|
@ -239,8 +227,7 @@ const DEFAULT_FILTERS = {
|
|||
reminders: '',
|
||||
assignees: '',
|
||||
labels: '',
|
||||
list_id: '',
|
||||
namespace: '',
|
||||
project_id: '',
|
||||
} as const
|
||||
|
||||
const props = defineProps({
|
||||
|
@ -264,24 +251,21 @@ const filters = ref({...DEFAULT_FILTERS})
|
|||
|
||||
const services = {
|
||||
users: shallowReactive(new UserService()),
|
||||
lists: shallowReactive(new ListService()),
|
||||
namespace: shallowReactive(new NamespaceService()),
|
||||
projects: shallowReactive(new ProjectService()),
|
||||
}
|
||||
|
||||
interface Entities {
|
||||
users: IUser[]
|
||||
labels: ILabel[]
|
||||
lists: IList[]
|
||||
namespace: INamespace[]
|
||||
projects: IProject[]
|
||||
}
|
||||
|
||||
type EntityType = 'users' | 'labels' | 'lists' | 'namespace'
|
||||
type EntityType = 'users' | 'labels' | 'projects'
|
||||
|
||||
const entities: Entities = reactive({
|
||||
users: [],
|
||||
labels: [],
|
||||
lists: [],
|
||||
namespace: [],
|
||||
projects: [],
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
|
@ -327,8 +311,7 @@ function prepareFilters() {
|
|||
prepareSingleValue('percent_done', 'percentDone', 'usePercentDone', true)
|
||||
prepareDate('reminders')
|
||||
prepareRelatedObjectFilter('users', 'assignees')
|
||||
prepareRelatedObjectFilter('lists', 'list_id')
|
||||
prepareRelatedObjectFilter('namespace')
|
||||
prepareRelatedObjectFilter('projects', 'project_id')
|
||||
|
||||
prepareSingleValue('labels')
|
||||
|
|
@ -1,30 +1,30 @@
|
|||
import {ref, watch, type Ref} from 'vue'
|
||||
import ListService from '@/services/list'
|
||||
import type {IList} from '@/modelTypes/IList'
|
||||
import ProjectService from '@/services/project'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
|
||||
|
||||
export function useListBackground(list: Ref<IList>) {
|
||||
export function useProjectBackground(project: Ref<IProject>) {
|
||||
const background = ref<string | null>(null)
|
||||
const backgroundLoading = ref(false)
|
||||
const blurHashUrl = ref('')
|
||||
|
||||
watch(
|
||||
() => [list.value.id, list.value.backgroundBlurHash] as [IList['id'], IList['backgroundBlurHash']],
|
||||
async ([listId, blurHash], oldValue) => {
|
||||
() => [project.value.id, project.value.backgroundBlurHash] as [IProject['id'], IProject['backgroundBlurHash']],
|
||||
async ([projectId, blurHash], oldValue) => {
|
||||
if (
|
||||
list.value === null ||
|
||||
!list.value.backgroundInformation ||
|
||||
project.value === null ||
|
||||
!project.value.backgroundInformation ||
|
||||
backgroundLoading.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const [oldListId, oldBlurHash] = oldValue || []
|
||||
const [oldProjectId, oldBlurHash] = oldValue || []
|
||||
if (
|
||||
oldValue !== undefined &&
|
||||
listId === oldListId && blurHash === oldBlurHash
|
||||
oldValue !== undefined &&
|
||||
projectId === oldProjectId && blurHash === oldBlurHash
|
||||
) {
|
||||
// list hasn't changed
|
||||
// project hasn't changed
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -35,8 +35,8 @@ export function useListBackground(list: Ref<IList>) {
|
|||
blurHashUrl.value = blurHash ? window.URL.createObjectURL(blurHash) : ''
|
||||
})
|
||||
|
||||
const listService = new ListService()
|
||||
const backgroundPromise = listService.background(list.value).then((result) => {
|
||||
const projectService = new ProjectService()
|
||||
const backgroundPromise = projectService.background(project.value).then((result) => {
|
||||
background.value = result
|
||||
})
|
||||
await Promise.all([blurHashPromise, backgroundPromise])
|
||||
|
@ -44,7 +44,7 @@ export function useListBackground(list: Ref<IList>) {
|
|||
backgroundLoading.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
return {
|
||||
|
@ -52,4 +52,4 @@ export function useListBackground(list: Ref<IList>) {
|
|||
blurHashUrl,
|
||||
backgroundLoading,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -8,24 +8,24 @@
|
|||
</slot>
|
||||
</template>
|
||||
|
||||
<template v-if="isSavedFilter(list)">
|
||||
<template v-if="isSavedFilter(project)">
|
||||
<dropdown-item
|
||||
:to="{ name: 'filter.settings.edit', params: { listId: list.id } }"
|
||||
:to="{ name: 'filter.settings.edit', params: { projectId: project.id } }"
|
||||
icon="pen"
|
||||
>
|
||||
{{ $t('menu.edit') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
:to="{ name: 'filter.settings.delete', params: { listId: list.id } }"
|
||||
:to="{ name: 'filter.settings.delete', params: { projectId: project.id } }"
|
||||
icon="trash-alt"
|
||||
>
|
||||
{{ $t('misc.delete') }}
|
||||
</dropdown-item>
|
||||
</template>
|
||||
|
||||
<template v-else-if="list.isArchived">
|
||||
<template v-else-if="project.isArchived">
|
||||
<dropdown-item
|
||||
:to="{ name: 'list.settings.archive', params: { listId: list.id } }"
|
||||
:to="{ name: 'project.settings.archive', params: { projectId: project.id } }"
|
||||
icon="archive"
|
||||
>
|
||||
{{ $t('menu.unarchive') }}
|
||||
|
@ -33,32 +33,32 @@
|
|||
</template>
|
||||
<template v-else>
|
||||
<dropdown-item
|
||||
:to="{ name: 'list.settings.edit', params: { listId: list.id } }"
|
||||
:to="{ name: 'project.settings.edit', params: { projectId: project.id } }"
|
||||
icon="pen"
|
||||
>
|
||||
{{ $t('menu.edit') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
v-if="backgroundsEnabled"
|
||||
:to="{ name: 'list.settings.background', params: { listId: list.id } }"
|
||||
:to="{ name: 'project.settings.background', params: { projectId: project.id } }"
|
||||
icon="image"
|
||||
>
|
||||
{{ $t('menu.setBackground') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
:to="{ name: 'list.settings.share', params: { listId: list.id } }"
|
||||
:to="{ name: 'project.settings.share', params: { projectId: project.id } }"
|
||||
icon="share-alt"
|
||||
>
|
||||
{{ $t('menu.share') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
:to="{ name: 'list.settings.duplicate', params: { listId: list.id } }"
|
||||
:to="{ name: 'project.settings.duplicate', params: { projectId: project.id } }"
|
||||
icon="paste"
|
||||
>
|
||||
{{ $t('menu.duplicate') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
:to="{ name: 'list.settings.archive', params: { listId: list.id } }"
|
||||
:to="{ name: 'project.settings.archive', params: { projectId: project.id } }"
|
||||
icon="archive"
|
||||
>
|
||||
{{ $t('menu.archive') }}
|
||||
|
@ -66,14 +66,21 @@
|
|||
<Subscription
|
||||
class="has-no-shadow"
|
||||
:is-button="false"
|
||||
entity="list"
|
||||
:entity-id="list.id"
|
||||
:model-value="list.subscription"
|
||||
entity="project"
|
||||
:entity-id="project.id"
|
||||
:model-value="project.subscription"
|
||||
@update:model-value="setSubscriptionInStore"
|
||||
type="dropdown"
|
||||
/>
|
||||
<dropdown-item
|
||||
:to="{ name: 'list.settings.delete', params: { listId: list.id } }"
|
||||
v-if="level < 2"
|
||||
:to="{ name: 'project.createFromParent', params: { parentProjectId: project.id } }"
|
||||
icon="layer-group"
|
||||
>
|
||||
{{ $t('menu.createProject') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
:to="{ name: 'project.settings.delete', params: { projectId: project.id } }"
|
||||
icon="trash-alt"
|
||||
class="has-text-danger"
|
||||
>
|
||||
|
@ -90,26 +97,27 @@ import BaseButton from '@/components/base/BaseButton.vue'
|
|||
import Dropdown from '@/components/misc/dropdown.vue'
|
||||
import DropdownItem from '@/components/misc/dropdown-item.vue'
|
||||
import Subscription from '@/components/misc/subscription.vue'
|
||||
import type {IList} from '@/modelTypes/IList'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import type {ISubscription} from '@/modelTypes/ISubscription'
|
||||
|
||||
import {isSavedFilter} from '@/services/savedFilter'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import {useListStore} from '@/stores/lists'
|
||||
import {useNamespaceStore} from '@/stores/namespaces'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const props = defineProps({
|
||||
list: {
|
||||
type: Object as PropType<IList>,
|
||||
project: {
|
||||
type: Object as PropType<IProject>,
|
||||
required: true,
|
||||
},
|
||||
level: {
|
||||
type: Number,
|
||||
},
|
||||
})
|
||||
|
||||
const listStore = useListStore()
|
||||
const namespaceStore = useNamespaceStore()
|
||||
const projectStore = useProjectStore()
|
||||
const subscription = ref<ISubscription | null>(null)
|
||||
watchEffect(() => {
|
||||
subscription.value = props.list.subscription ?? null
|
||||
subscription.value = props.project.subscription ?? null
|
||||
})
|
||||
|
||||
const configStore = useConfigStore()
|
||||
|
@ -117,11 +125,10 @@ const backgroundsEnabled = computed(() => configStore.enabledBackgroundProviders
|
|||
|
||||
function setSubscriptionInStore(sub: ISubscription) {
|
||||
subscription.value = sub
|
||||
const updatedList = {
|
||||
...props.list,
|
||||
const updatedProject = {
|
||||
...props.project,
|
||||
subscription: sub,
|
||||
}
|
||||
listStore.setList(updatedList)
|
||||
namespaceStore.setListInNamespaceById(updatedList)
|
||||
projectStore.setProject(updatedProject)
|
||||
}
|
||||
</script>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user