Compare commits
1524 Commits
5f98355948
...
93bedee02f
Author | SHA1 | Date |
---|---|---|
viehlieb | 93bedee02f | |
viehlieb | 2d2fcc5d96 | |
viehlieb | 0b8d8c09c8 | |
viehlieb | 75d6387e20 | |
viehlieb | 070b40076d | |
viehlieb | 77f0776d0b | |
kolaente | 652d3c7384 | |
kolaente | 447641c222 | |
Dominik Pschenitschni | 362be53a47 | |
renovate | 46eabdfe6b | |
kolaente | a0c5a464a5 | |
kolaente | e78ab476fc | |
kolaente | aebb047d18 | |
Dominik Pschenitschni | 7bb110b20e | |
renovate | f148a43390 | |
renovate | aac70d3823 | |
kolaente | 21126793ab | |
kolaente | b057fb2784 | |
kolaente | 58c7da019d | |
kolaente | 70f48eaaca | |
kolaente | 6cc75928d8 | |
kolaente | dc360d4a18 | |
kolaente | 45ca0602f5 | |
kolaente | 9d39ccf15c | |
kolaente | 28e83325d7 | |
kolaente | aff48ddd9d | |
kolaente | 5b2a9a42c0 | |
kolaente | 45f5d522d1 | |
renovate | 4f27e4a477 | |
renovate | d0dc86fd58 | |
renovate | 0484923b8a | |
renovate | 5f2fb01e90 | |
renovate | bd18524f36 | |
renovate | 7375a87f2f | |
renovate | ccff276397 | |
renovate | 30b21fc11c | |
renovate | 7c98ddc20b | |
renovate | 6ba02a0f10 | |
renovate | 676d2b6215 | |
Frederick [Bot] | 85e612451f | |
kolaente | d411de99f1 | |
kolaente | 228d652b03 | |
kolaente | b3e2107503 | |
kolaente | a579a8e65f | |
kolaente | ee980e2a00 | |
renovate | 394dbe0055 | |
renovate | 30d599369f | |
kolaente | 631b02d2ee | |
kolaente | 326bfb557a | |
kolaente | cd0149ef69 | |
kolaente | 78d4a518a3 | |
kolaente | 3c1041902e | |
kolaente | e3cae0ed7f | |
renovate | fc8bd6a9ca | |
renovate | 5a6e5619e3 | |
renovate | 9c9f806e62 | |
kolaente | 67216579bc | |
renovate | a8df935ddb | |
renovate | bb4746f226 | |
renovate | 31590236aa | |
renovate | 00d48a6178 | |
renovate | 5169cca8d8 | |
renovate | 255a7d565c | |
renovate | 8dbaee5dfb | |
renovate | 69b0b19482 | |
renovate | eae89d37f1 | |
renovate | 7d19859816 | |
kolaente | c7b70844c6 | |
kolaente | b8c21c2ade | |
kolaente | 57c99a22a0 | |
kolaente | 8ea97f3ffc | |
kolaente | 0b3604d167 | |
kolaente | c5ba7fcb73 | |
kolaente | 5a25685d53 | |
kolaente | da311fce9e | |
kolaente | 0fdf1ca027 | |
kolaente | f8e907a8c1 | |
kolaente | af7ca8ad8f | |
nor | 92f7d9ded5 | |
renovate | 41ccaea78b | |
renovate | c5696f3e2a | |
renovate | 898707664c | |
renovate | d0b5bef68a | |
renovate | e395d4efdb | |
renovate | ce54132868 | |
renovate | 07d4d1e537 | |
renovate | a701b0452e | |
renovate | af65efcd27 | |
renovate | dc2afb9e8d | |
WofWca | e123d4f825 | |
renovate | b72c963256 | |
renovate | 149bbf17eb | |
renovate | 265d60cf42 | |
renovate | 23c9f51e73 | |
renovate | ff697d0c7a | |
renovate | 00588cf59f | |
renovate | 01089f4f3d | |
kolaente | a7461d1ddd | |
kolaente | a451189bb6 | |
kolaente | bf9af27fc3 | |
kolaente | 5619fda0f2 | |
kolaente | 167953b26b | |
kolaente | 664bf0a5f4 | |
kolaente | 5e991f3024 | |
kolaente | 28050d9cd5 | |
kolaente | e94b71d577 | |
renovate | 336ce217d3 | |
Frederick [Bot] | ce01085951 | |
kolaente | 96a6d43a3f | |
kolaente | 13d63e34aa | |
renovate | a8441c72b8 | |
renovate | 230fa6ce66 | |
renovate | 069c491fbd | |
renovate | a9eae95d67 | |
renovate | 50502d9d11 | |
renovate | 18af6edc82 | |
renovate | d048b61eb3 | |
renovate | 996607e670 | |
renovate | e33ebe1831 | |
renovate | 557b0ffec7 | |
renovate | dae6cdb9d7 | |
renovate | 158e4d690f | |
renovate | 691eb84a99 | |
renovate | 698ee7e163 | |
renovate | ce822573df | |
renovate | 198abee01d | |
Frederick [Bot] | e5bea087be | |
Frederick [Bot] | 4956fbb669 | |
kolaente | 0351148288 | |
kolaente | 654806211e | |
kolaente | 09572dbe61 | |
kolaente | fae5b764dd | |
kolaente | 7f70471894 | |
kolaente | e98e5a0d2f | |
renovate | 21e34d6d54 | |
kolaente | 4b71604513 | |
kolaente | 609b86e614 | |
renovate | 4faffd37a7 | |
renovate | 008585d61f | |
renovate | d0ae285663 | |
renovate | 00d0f88798 | |
renovate | 4cf31080f8 | |
renovate | 21e54de3b8 | |
renovate | 143cabbac5 | |
kolaente | 0607c97da9 | |
kolaente | be925b29e3 | |
kolaente | 2541733c71 | |
kolaente | 4d6fd9ecc4 | |
kolaente | 818f31c220 | |
kolaente | 34e4862c88 | |
renovate | 0d074113f1 | |
kolaente | 0730955403 | |
kolaente | 75035ec1f8 | |
konrad | 923fc4eaa0 | |
kolaente | ea7dab68ae | |
kolaente | 8b9e5e54af | |
kolaente | a4a2b95dc7 | |
kolaente | 9fdb6a8d24 | |
kolaente | fc6b707405 | |
kolaente | 9efe860f26 | |
kolaente | af13d68c48 | |
kolaente | 3cb1e7dede | |
renovate | e27d88785e | |
renovate | b1fc5dbd97 | |
renovate | 1786dee042 | |
renovate | 8491caf419 | |
renovate | 3b15293b47 | |
renovate | e65f13eaa3 | |
renovate | 25561f3229 | |
renovate | 97d149c2f5 | |
kolaente | c8809d899a | |
renovate | 203041ae36 | |
renovate | 11d11012e7 | |
renovate | 30046c7ff5 | |
renovate | 668ff753b3 | |
renovate | 0b68ab93e1 | |
renovate | af22d2e88a | |
kolaente | 611e9feb6d | |
kolaente | e770496524 | |
kolaente | 1cbb93ea9b | |
Frederick [Bot] | f7c06e53b7 | |
Frederick [Bot] | 240906f236 | |
renovate | 282ec3164b | |
Frederick [Bot] | a994264234 | |
kolaente | 4868ac824e | |
kolaente | 0c58ea1ade | |
kolaente | f45303c2e3 | |
kolaente | c3e53970de | |
Frederick [Bot] | 0795c0e448 | |
renovate | cfd46dc39b | |
kolaente | debae2326e | |
renovate | 23d670525d | |
kolaente | 2967019cd9 | |
kolaente | d3497c96d7 | |
kolaente | bd83294ac0 | |
kolaente | 6c4f1e1cbf | |
kolaente | fa269f155a | |
kolaente | 602d15985b | |
renovate | cc3c1a9429 | |
renovate | cfd49864e1 | |
renovate | 6711a08de9 | |
renovate | 7fe33c6662 | |
renovate | e61b215dc1 | |
renovate | 3b5cb1ade3 | |
renovate | 89e28cbdf2 | |
renovate | e9e836f068 | |
kolaente | aa5e11915e | |
kolaente | 7f279c98e1 | |
kolaente | 3c1861eb6a | |
kolaente | 75262b716f | |
ThatHurleyGuy | 7e623d919e | |
kolaente | 3f42ce2b34 | |
renovate | 8b8da40265 | |
Frederick [Bot] | 0f23cc2162 | |
Frederick [Bot] | adf80d9184 | |
Frederick [Bot] | e3dfcafc29 | |
Frederick [Bot] | a9df58109f | |
kolaente | 59a7360608 | |
renovate | 29e128c64c | |
renovate | cec50d912c | |
renovate | 53564ec46c | |
renovate | e9cd7aac69 | |
renovate | a47bfb3ff1 | |
renovate | 86eb4da2e3 | |
renovate | d1882e9c3f | |
renovate | 974755ffc2 | |
Frederick [Bot] | f00d49cada | |
Frederick [Bot] | e41ec4e8b2 | |
kolaente | 218d72494a | |
kolaente | bde212d432 | |
kolaente | 64a8dd189b | |
kolaente | ba766a29af | |
renovate | e02a106c64 | |
renovate | ccdc5d4868 | |
renovate | 9240739a4b | |
renovate | 963d91c4d5 | |
renovate | f33d154b37 | |
Frederick [Bot] | b12db63de0 | |
Frederick [Bot] | a3e729a3c8 | |
kolaente | 8e0ba555ed | |
kolaente | 9cf81e1478 | |
renovate | 4350d78178 | |
renovate | cea27bb754 | |
renovate | 4b7f8c265d | |
renovate | 412e6e77b4 | |
renovate | 45abdda680 | |
renovate | 0b2188d72d | |
renovate | 143a2a105d | |
renovate | 68d18934d8 | |
renovate | cea3274a90 | |
Frederick [Bot] | 72f57a220d | |
Frederick [Bot] | b94acfcc84 | |
kolaente | 5f2787e18d | |
kolaente | 2eac17ed57 | |
renovate | 8d566c9371 | |
renovate | ab5118b51b | |
kolaente | 0b294de132 | |
renovate | c1149273f9 | |
renovate | 7496be5a44 | |
renovate | a35b0f64a2 | |
renovate | 6c2b30f8ef | |
renovate | daa720669a | |
konrad | 26fc9b4e4f | |
kolaente | 37af478811 | |
kolaente | 22223a56bd | |
kolaente | c367b70ccc | |
kolaente | 9103ad8505 | |
kolaente | e4eaca82e1 | |
kolaente | 229beec1d1 | |
kolaente | 803f9c81c2 | |
kolaente | c6b123734b | |
kolaente | 0154b2a475 | |
kolaente | 32ca8853bc | |
kolaente | f6d5cbcf6f | |
kolaente | d7503dc4a2 | |
kolaente | 632e3c5a0b | |
kolaente | c61f1a45fb | |
kolaente | 2f3196ef86 | |
kolaente | 2864854cd4 | |
kolaente | a453449fea | |
kolaente | abb6630b4b | |
kolaente | 63c40b29b0 | |
Frederick [Bot] | b7ff71ba76 | |
kolaente | 19a78f1f75 | |
kolaente | d6a41fa518 | |
kolaente | 859fc1e94e | |
kolaente | aa715dd9e1 | |
kolaente | daa2ed3b1c | |
kolaente | 1443e23f18 | |
kolaente | 34420b623c | |
kolaente | 80dc35eabb | |
kolaente | cb1d2b3834 | |
kolaente | 0ef775e9b9 | |
kolaente | a7e4e3adf9 | |
kolaente | d005875bbf | |
kolaente | dc3ee112bd | |
kolaente | 9b20dc1899 | |
kolaente | 22103626b8 | |
kolaente | 4f2d7b3ce2 | |
kolaente | 76d31c84ad | |
kolaente | 66c37f10e0 | |
kolaente | 0b2aa723a6 | |
kolaente | d75a963d08 | |
kolaente | beefc1d5ef | |
kolaente | 17c23d9463 | |
kolaente | 02ab1b8c0a | |
kolaente | e81c98fe5b | |
kolaente | 3bf806f00c | |
kolaente | aea3f86a8f | |
kolaente | 5297208d92 | |
kolaente | c84bcfddba | |
kolaente | 0772acbead | |
renovate | 123c665d9d | |
kolaente | 4f3efe4454 | |
kolaente | 671c658868 | |
kolaente | 05bf7ccf0b | |
renovate | b76acb15c7 | |
kolaente | 953361c480 | |
kolaente | 8b60e5b2c8 | |
kolaente | faf93a6088 | |
kolaente | 8e07d9647a | |
kolaente | 08959fdb77 | |
kolaente | e716fd1bf9 | |
kolaente | 63865028b8 | |
kolaente | e760ce45e4 | |
kolaente | af9eb358ee | |
kolaente | ddcf6bf0a5 | |
kolaente | 9c71e30efe | |
kolaente | c58ad47782 | |
kolaente | ca0d9e6bd5 | |
kolaente | ad3234b19f | |
Dominik Pschenitschni | 24b8915983 | |
kolaente | 01c2acdf34 | |
kolaente | ff2b4b8bf4 | |
kolaente | d73c62a424 | |
renovate | cac41a1c86 | |
kolaente | aeed4b3a3b | |
renovate | 8992caadf9 | |
renovate | b2b423aee5 | |
konrad | 5d991e539b | |
renovate | accde483cb | |
renovate | 2d5e560b74 | |
kolaente | 5d91134b48 | |
kolaente | 0e5415a2c9 | |
kolaente | 779aad1b2d | |
kolaente | 3d2fe4cf65 | |
renovate | c38421466b | |
Frederick [Bot] | df09bca010 | |
kolaente | b9717f504d | |
Frederick [Bot] | bb7c4f40a0 | |
Frederick [Bot] | 0b9ef50f80 | |
Frederick [Bot] | cd295960a4 | |
renovate | eb591fdd3c | |
renovate | 23e1899fce | |
renovate | 22968ba639 | |
renovate | b345f0ad61 | |
Frederick [Bot] | 4df34701ab | |
renovate | a5f7487bd0 | |
kolaente | ae001c6ca7 | |
kolaente | f0b340a9c7 | |
renovate | 40538df392 | |
renovate | fc17b16c60 | |
renovate | 6c59b4e2d2 | |
renovate | e8a38ed482 | |
renovate | f5604dcac6 | |
renovate | 049c644959 | |
kolaente | 07b1e9a6b7 | |
renovate | 7aedf6ee1f | |
renovate | bc9bfe3300 | |
renovate | c2005c6c71 | |
renovate | d7cbade64e | |
renovate | 06b00b77ed | |
renovate | f2392cef7e | |
renovate | e6f2b36d88 | |
kolaente | 7d2fcd26f2 | |
renovate | 369e22f224 | |
kolaente | 0ff5b90ebd | |
kolaente | e89245e42d | |
kolaente | 35717a1e29 | |
kolaente | e46cf2fa1b | |
kolaente | 1ad6d5a66b | |
kolaente | 4754bb99f0 | |
kolaente | 608e99fffc | |
kolaente | d6741d19e3 | |
kolaente | ec52be0353 | |
renovate | 2d5ab4f5f0 | |
Giacomo Rossetto | a71755e408 | |
kolaente | 66c7a05cdb | |
kolaente | 287daf9125 | |
kolaente | 8507808058 | |
kolaente | 93c155dd2f | |
kolaente | b1c4748969 | |
kolaente | 0887860b2a | |
kolaente | 0b1c8ed4dd | |
kolaente | 3988a3f9f8 | |
Frederick [Bot] | 11b65e844c | |
renovate | 5c23343172 | |
renovate | 01a4335c7c | |
Frederick [Bot] | 4a2ecf5fe7 | |
renovate | 0235b14997 | |
renovate | 8eafa23269 | |
kolaente | 5c95a721f4 | |
kolaente | a6eb804fae | |
Frederick [Bot] | 09ffd9414b | |
kolaente | b126a7f7ff | |
kolaente | f256fc3843 | |
kolaente | e41712647d | |
Frederick [Bot] | 842e2c2811 | |
kolaente | 2d61a349ac | |
kolaente | 54c527c23f | |
kolaente | 4d8c6622d2 | |
kolaente | 3f3d4b1682 | |
kolaente | 9c46d064ac | |
kolaente | 0d3143d465 | |
kolaente | 337c3e5e3e | |
kolaente | 3bb5308141 | |
kolaente | 3fec92283b | |
kolaente | beb016400e | |
kolaente | 7746d39161 | |
kolaente | b187e8c1b6 | |
kolaente | 0ecda46af9 | |
kolaente | 59dc927b5c | |
kolaente | a13953ee14 | |
kolaente | a4b836d395 | |
kolaente | 16b46b0f4d | |
kolaente | 184110b986 | |
kolaente | 1918947c0b | |
kolaente | 4e5823183e | |
kolaente | b9e17ea870 | |
Frederick [Bot] | a8a6ec5ab0 | |
Frederick [Bot] | 3e9b872894 | |
kolaente | c4adcf4655 | |
kolaente | b1fe3fe29b | |
kolaente | 5720a86bc3 | |
kolaente | 86eff7d49e | |
kolaente | 7a9aa7771b | |
kolaente | abbc11528e | |
kolaente | 725fd1ad46 | |
kolaente | 44754fac0f | |
kolaente | 7f2d92138e | |
kolaente | 95be0d1d32 | |
kolaente | f63c39a578 | |
kolaente | 270e32290a | |
kolaente | 9cf8696b84 | |
Frederick [Bot] | b97e13b6b4 | |
konrad | 04ba1011cc | |
kolaente | 52c0efe0ce | |
kolaente | c803020537 | |
kolaente | 3373b5fc45 | |
kolaente | f6d1db3595 | |
Frederick [Bot] | ce6f099912 | |
kolaente | ed8fb71ff0 | |
konrad | 28f2551d87 | |
kolaente | cec480ad80 | |
kolaente | 830a3745ba | |
kolaente | 49104c65b6 | |
kolaente | 984978fe6d | |
kolaente | bd7b973559 | |
kolaente | 0bb85870db | |
kolaente | 021f92303d | |
kolaente | e47ad021a3 | |
kolaente | a20eef2453 | |
Frederick [Bot] | 7b57b10804 | |
Frederick [Bot] | 83a7032b6f | |
renovate | 49261a6fcc | |
kolaente | 5630c90dee | |
konrad | 47d589002c | |
kolaente | 99e2161c09 | |
kolaente | 20f61baf03 | |
kolaente | 4e6b99544e | |
kolaente | d57e1909c4 | |
kolaente | 99d8fbdfa7 | |
kolaente | 442d0342a9 | |
kolaente | a4b369470a | |
kolaente | 0ca73e0851 | |
kolaente | 9fc829115f | |
kolaente | 1e19548563 | |
kolaente | c327d86a71 | |
kolaente | 3044560759 | |
kolaente | c3f85fcb19 | |
renovate | 53434952d3 | |
renovate | e9b0640660 | |
renovate | ae57e5d314 | |
kolaente | 6e7928b2e4 | |
kolaente | 47639b00f8 | |
Frederick [Bot] | e63cecceca | |
Frederick [Bot] | 55e2e323ed | |
kolaente | f7e22c8c56 | |
kolaente | a9fb306e46 | |
kolaente | 58a1f46668 | |
kolaente | 6cbbe17bd8 | |
kolaente | c01957aae2 | |
kolaente | 1ad03877fb | |
kolaente | fc72a82a2a | |
kolaente | 63ef09b020 | |
DIMITRIOS CHRYSOCHERIS | 311b1d7594 | |
davidangel | cade3df3e9 | |
kolaente | 37975c1931 | |
renovate | 0d500182e7 | |
Frederick [Bot] | f647d6e9b4 | |
renovate | dbed4caca7 | |
renovate | 6d79c9b2ed | |
renovate | 24f0822a12 | |
renovate | f3ba778fd3 | |
renovate | 55a7255728 | |
renovate | 2b47e5faec | |
kolaente | 9f82ec4162 | |
kolaente | 64c90c7fe8 | |
renovate | 9fe3d2b2bc | |
Frederick [Bot] | 0b1ec9f287 | |
Frederick [Bot] | baff1c6fc9 | |
Frederick [Bot] | ac3f0cc266 | |
renovate | 7e1cfebf6a | |
Frederick [Bot] | 88203e8b7d | |
renovate | d466d50712 | |
renovate | cf945f2841 | |
renovate | 74df69fc94 | |
Frederick [Bot] | 563ee8f5bc | |
renovate | 026db7acad | |
renovate | 61e97bfe1c | |
renovate | 6530d26b82 | |
renovate | 1e24fe8bab | |
renovate | f786c2b8a2 | |
renovate | e596e2c3bc | |
Frederick [Bot] | a5e49d9417 | |
renovate | 668b910190 | |
renovate | 2bdc532f89 | |
renovate | 933c7d8acc | |
Frederick [Bot] | 253e716390 | |
renovate | d19a5d9714 | |
renovate | 90cad1c8dd | |
Frederick [Bot] | 057017c8eb | |
kolaente | d7ce8dd320 | |
kolaente | 25b110ce48 | |
renovate | 33fe5e4f20 | |
renovate | 129ef769a3 | |
renovate | 9030a9f7c1 | |
Frederick [Bot] | 3748a496d5 | |
renovate | 890e7e1f52 | |
renovate | 9e0f2b0249 | |
renovate | 9a34c522b2 | |
renovate | 60dd698fad | |
kolaente | 15ecafdf04 | |
kolaente | 8902c15f7e | |
kolaente | d5358793de | |
renovate | 33798b8d88 | |
renovate | c686e8677b | |
renovate | 5acc1696a9 | |
renovate | c4976b6a22 | |
renovate | d88ff594e1 | |
kolaente | 66f0df0333 | |
kolaente | b742c55287 | |
kolaente | 82c9a91d39 | |
kolaente | cd820a6cb2 | |
kolaente | 2c4da79c1b | |
renovate | c4f6465569 | |
renovate | 7d84601f6d | |
renovate | 8f94b7490c | |
renovate | cbce7cd142 | |
renovate | 28e775be42 | |
kolaente | 24ad2f892d | |
kolaente | 7c1934aad0 | |
kolaente | ae2b0f97c4 | |
renovate | 70e0696300 | |
Frederick [Bot] | 60e49468cf | |
renovate | 10e566164e | |
renovate | 3c1b54c1a3 | |
renovate | b80c6cf326 | |
kolaente | d2e6ab4505 | |
renovate | 32ed4c7da9 | |
renovate | 70ae19a903 | |
renovate | cc2e0e79d3 | |
Frederick [Bot] | 49bdd00133 | |
renovate | 43eb742352 | |
renovate | 34b6692f25 | |
renovate | 152aefd365 | |
renovate | 2297872879 | |
renovate | 75ca7ecd61 | |
Frederick [Bot] | 0909d2cfe5 | |
renovate | a60662b72b | |
Frederick [Bot] | 478b2c043e | |
renovate | 6bc54d7a92 | |
renovate | e8e664b256 | |
Frederick [Bot] | 72fd932020 | |
renovate | e5090b117f | |
renovate | 1b498a238c | |
kolaente | 2a14325f62 | |
kolaente | ac6c4cf2bc | |
renovate | 66bad4b2b1 | |
renovate | cdac38eabc | |
renovate | 79918620c3 | |
renovate | dd1ae53d00 | |
renovate | 9894490616 | |
Frederick [Bot] | a218eab609 | |
renovate | c046bb95b3 | |
renovate | 12ad0d2ed3 | |
renovate | 8e505b6f51 | |
renovate | 3589251f55 | |
renovate | b3b0d8d6e6 | |
renovate | 06572e8f0a | |
renovate | fe7e06079e | |
renovate | c2b04a2b81 | |
renovate | d2148df6c8 | |
Frederick [Bot] | 305c5b32ee | |
kolaente | a446310986 | |
kolaente | 8a22d1811e | |
Frederick [Bot] | d34a872d40 | |
kolaente | 06126de139 | |
renovate | 047435bb68 | |
renovate | 4be631888a | |
Frederick [Bot] | be11397163 | |
kolaente | f3986c710b | |
kolaente | 9c8266fb0d | |
kolaente | 7148b56eea | |
kolaente | ff6645d2ab | |
renovate | bd17afe466 | |
kolaente | 25bd26bea6 | |
kolaente | 83c0ef4e8b | |
renovate | f55c42f124 | |
renovate | 4189fdadd9 | |
renovate | 2670cecf70 | |
renovate | e54b5e88cc | |
renovate | 03c6e343c2 | |
renovate | 9acde8d017 | |
kolaente | 813d2b56a0 | |
kolaente | 1005182a50 | |
kolaente | 4eba9479b0 | |
kolaente | dbe1ad9353 | |
kolaente | b6cd424aa3 | |
kolaente | 6651adf6de | |
kolaente | 3aa502e07d | |
kolaente | 78a268ab07 | |
renovate | 4661c2e90e | |
renovate | fbfa265dbf | |
renovate | c1ad1b0639 | |
kolaente | c27661107f | |
renovate | 464cc0ed8c | |
renovate | f8e2ef210f | |
Frederick [Bot] | 61379ed4c6 | |
renovate | f1f99d065c | |
konrad | da3eaf0d35 | |
kolaente | 34182b8bbb | |
kolaente | 20660564c1 | |
kolaente | c2ffe3a9dc | |
kolaente | a33e2f6c00 | |
kolaente | 0ce150af23 | |
kolaente | 06a1ff6f4b | |
kolaente | 7c964c29d4 | |
kolaente | b9f0635d9f | |
kolaente | 61baf02e26 | |
kolaente | 59b05e9836 | |
kolaente | f68bb2625e | |
renovate | 25f9f5dceb | |
renovate | 35d68619f4 | |
kolaente | 929d4f4023 | |
kolaente | a92eb31ab3 | |
kolaente | 2006abd0a6 | |
renovate | e4504748c4 | |
kolaente | 854228034d | |
kolaente | 88ce29aa77 | |
kolaente | 0ca1b3a7f5 | |
kolaente | a118580704 | |
kolaente | 771aad5420 | |
kolaente | 411ae58e59 | |
kolaente | c3501f5060 | |
konrad | 5ca31d00ee | |
kolaente | 1e2039325f | |
kolaente | 472cca8ab8 | |
kolaente | 2fad45e016 | |
kolaente | 68a137acf9 | |
kolaente | 95ba8b8a11 | |
kolaente | 96c9407414 | |
kolaente | 653415e764 | |
Dominik Pschenitschni | 73947f0ba4 | |
renovate | 389ca1b692 | |
renovate | 9c0e140e2e | |
renovate | 51d08a1637 | |
kolaente | 35de8a40d8 | |
kolaente | 80772f7578 | |
kolaente | faa62985df | |
kolaente | 154d43a392 | |
renovate | 7fe5565654 | |
kolaente | 1fcd1cdd4b | |
kolaente | ba057f3527 | |
kolaente | dd7b77e12d | |
renovate | 3845a45934 | |
renovate | 564808bd35 | |
renovate | c0a66e4746 | |
renovate | 28242cfb23 | |
renovate | 818fb2b524 | |
Frederick [Bot] | ad95bdd039 | |
renovate | 3faed19298 | |
renovate | 9114a86813 | |
renovate | bae9a5c9be | |
renovate | fe2d6d4467 | |
Frederick [Bot] | 96acea90ed | |
renovate | 21c98d5166 | |
renovate | b3cb36c1e1 | |
kolaente | 79ceaf6a2b | |
kolaente | 5694b39489 | |
kolaente | 32e5f9f757 | |
kolaente | 928b338cf2 | |
kolaente | 1a792e0667 | |
renovate | 6407644138 | |
renovate | 2db88b583b | |
kolaente | bef25c49d5 | |
renovate | f01ea20a38 | |
kolaente | 3c9083b90d | |
konrad | 169feaaf0f | |
kolaente | 5d59392566 | |
kolaente | 6593380013 | |
kolaente | 69e94e58c4 | |
kolaente | cd8e497b24 | |
kolaente | aab2020e68 | |
kolaente | a050419fdf | |
kolaente | f0c3980700 | |
kolaente | 68597c9709 | |
kolaente | 5325f6d7d9 | |
renovate | 8c687350a0 | |
renovate | bac679caf7 | |
renovate | 4f8ff17138 | |
Frederick [Bot] | 83e7138a18 | |
renovate | 8e44b87d07 | |
kolaente | 4b0022664a | |
kolaente | d8ad934643 | |
kolaente | 77ee1bfc3e | |
renovate | 8728647f00 | |
kolaente | bd7d09c17c | |
renovate | 77bedbd1cf | |
renovate | 2773612420 | |
kolaente | 48cfdddff7 | |
konrad | 3f8e457d52 | |
kolaente | 098b5fa2b1 | |
kolaente | 5e4eb4a728 | |
kolaente | 8930f61548 | |
kolaente | 9a736cf65f | |
kolaente | 2677f6254d | |
kolaente | bfcb36e093 | |
kolaente | 9ec29cad30 | |
renovate | c4f609a0c8 | |
renovate | 7e7535b860 | |
renovate | df9181b34e | |
renovate | e6a56f2822 | |
renovate | 3633d68269 | |
kolaente | dd3a5fe6b5 | |
kolaente | 04642ae1ec | |
kolaente | eac19e28d6 | |
kolaente | 11f94e4037 | |
kolaente | 39cc7a00d8 | |
kolaente | 02da1e171e | |
kolaente | ae177c73ea | |
kolaente | e6c4c18974 | |
kolaente | 95487d7569 | |
kolaente | 7b2a688b6e | |
Frederick [Bot] | f5b3b21ce0 | |
kolaente | 979561342a | |
kolaente | ad27f588a2 | |
kolaente | c7a989d7dc | |
renovate | 0e674d8300 | |
renovate | 121fd70235 | |
renovate | d4cd90da45 | |
kolaente | c74612f24a | |
kolaente | 64f9f4fd88 | |
kolaente | b50adaf4b5 | |
renovate | 7b92028e67 | |
renovate | 08d84f7994 | |
renovate | f95b138b9f | |
Frederick [Bot] | e6aecbd8dc | |
renovate | eab0600f63 | |
kolaente | 46f5dcb4dc | |
kolaente | 0dc7e83dc4 | |
kolaente | 82c10b87c8 | |
kolaente | 5888946861 | |
renovate | e24607ed3a | |
Frederick [Bot] | d1ae6a8b84 | |
renovate | fc052cf8f5 | |
konrad | d9f608e8b4 | |
konrad | a988565227 | |
Dominik Pschenitschni | b76fffb788 | |
kolaente | 25c3b7bcbf | |
kolaente | dfa6cd777b | |
kolaente | 21ad8301f2 | |
kolaente | 7110c9a5ce | |
kolaente | a4c8fccb11 | |
Dominik Pschenitschni | c294f9d28d | |
kolaente | 422d7fc693 | |
kolaente | abb5128426 | |
kolaente | 2174608801 | |
kolaente | a6cdf6c4bd | |
kolaente | 2c9693a83e | |
kolaente | 6989558963 | |
kolaente | 7fb85dacec | |
steffeydev | 57218d1454 | |
kolaente | 9df6950d1a | |
kolaente | cd2b7fe185 | |
kolaente | 52987060b1 | |
kolaente | aeb73a374f | |
kolaente | bc416f282f | |
kolaente | f88c373742 | |
kolaente | 10ac1ff66a | |
kolaente | ae025e30c6 | |
kolaente | a1dd1d6664 | |
kolaente | 57c64bbf71 | |
kolaente | 218a19d907 | |
kolaente | 7b6a13dd52 | |
kolaente | 4ff0c81e37 | |
kolaente | 6a15489610 | |
kolaente | 59c942af73 | |
kolaente | 302ba2bec7 | |
kolaente | 34d1e4bddd | |
kolaente | 02c24a4814 | |
kolaente | 0724776ccb | |
renovate | 11979cbee0 | |
renovate | 2a490bf8ef | |
renovate | b5d3d1a7b7 | |
renovate | 554ffe3b9d | |
renovate | 0f57be107b | |
renovate | 269aa6b426 | |
renovate | b316b8f2ba | |
renovate | cad68e269c | |
kolaente | efb3407b87 | |
renovate | 6f1ff02c04 | |
renovate | 93c66b0613 | |
renovate | c14644a300 | |
renovate | 02d2300608 | |
renovate | ff918608c5 | |
renovate | aa591ee2ed | |
kolaente | f4a7943680 | |
kolaente | 68fd4698ac | |
Frederick [Bot] | dd039f31fe | |
kolaente | 6c2dc483a2 | |
Dominik Pschenitschni | 811254e6a9 | |
ce72 | 85ffed4d9a | |
ce72 | 5fb45afb12 | |
ce72 | fb14eca634 | |
ce72 | 14e2698833 | |
ce72 | 0d6c0c8399 | |
ce72 | 5d38b8327f | |
ce72 | f747d5b2fc | |
kolaente | 8a75790453 | |
kolaente | acb212ab24 | |
kolaente | 4ba02ebbb6 | |
kolaente | 244da46e38 | |
kolaente | f40035dc79 | |
renovate | 5f71e406fc | |
Frederick [Bot] | 3d11a4f03a | |
renovate | 1dfd2dc4b7 | |
renovate | e9701660d3 | |
renovate | c8dbb4c7ef | |
renovate | 1241d90268 | |
renovate | 3de5b65977 | |
renovate | 4a353553c3 | |
renovate | 1240f31c0a | |
kolaente | 01ac84ce1e | |
kolaente | 4c969f0a42 | |
kolaente | 8e2c76a33e | |
renovate | b3666ec27e | |
renovate | 2c6862c509 | |
renovate | 9f8c43818c | |
renovate | 0debca91c8 | |
renovate | 7b6c9fcd24 | |
renovate | 55675bf41b | |
renovate | bb24b06031 | |
renovate | dbce0376d5 | |
renovate | 40db144a41 | |
kolaente | f7ba3bd08f | |
konrad | ac1d374191 | |
kolaente | 391992effb | |
kolaente | 2e9ade11c3 | |
kolaente | f11a8c543b | |
kolaente | e30a4452f2 | |
kolaente | 6cc11e64ab | |
kolaente | 7b05ed9d3d | |
Frederick [Bot] | dba35c0107 | |
Frederick [Bot] | bfbc874b1d | |
kolaente | dbccdb239a | |
kolaente | f13db9268a | |
kolaente | ed8de7e3eb | |
kolaente | b34118485c | |
kolaente | 9c3259c660 | |
kolaente | a3e289c06c | |
kolaente | 31b7c1f217 | |
kolaente | c30dcff451 | |
kolaente | 086f50d4fe | |
kolaente | 46e825820c | |
kolaente | a3e2cbeb27 | |
kolaente | a342ae67de | |
kolaente | e4d97e0520 | |
kolaente | b69a05689b | |
kolaente | 6b824a49ab | |
kolaente | 652db56d42 | |
kolaente | afaf1846ec | |
kolaente | ba452ab883 | |
kolaente | 39f699a61a | |
kolaente | 4ab547810c | |
kolaente | bbaddb9406 | |
kolaente | a2cc9ddc88 | |
kolaente | 175e31ca62 | |
kolaente | d414b65e7d | |
kolaente | 78158bcba5 | |
kolaente | 9402344b7e | |
kolaente | 3eca9f6180 | |
kolaente | 26e3d42ed5 | |
kolaente | 6e095436e9 | |
kolaente | 1344026494 | |
kolaente | 1a94496801 | |
kolaente | 48570808e5 | |
kolaente | a7440ed296 | |
kolaente | 12ebefd86a | |
kolaente | 6c9cbaadc8 | |
kolaente | 9b10693172 | |
kolaente | db1c6d6a41 | |
kolaente | c56787443f | |
kolaente | cb218ec0c3 | |
kolaente | 0dd6f82a0e | |
kolaente | 225091864f | |
kolaente | ebd9c4702e | |
kolaente | 4ad9773022 | |
kolaente | 0a17df87e9 | |
kolaente | b567146d69 | |
kolaente | 65522a57f1 | |
kolaente | 1d936618fa | |
kolaente | 76814a2d3f | |
kolaente | 4134fcbd75 | |
kolaente | 49fac7db1c | |
kolaente | e25273df48 | |
kolaente | 638f6bea24 | |
kolaente | ddcd6a17dc | |
kolaente | 4e21b463df | |
kolaente | 3db4e011d4 | |
kolaente | a0d39e6081 | |
kolaente | a803bc637e | |
kolaente | d4e452545a | |
kolaente | 9d73ac661f | |
kolaente | 55e912221b | |
kolaente | d85be26761 | |
kolaente | ac78e85e17 | |
kolaente | 131022da42 | |
kolaente | 336db56316 | |
kolaente | b5d9afd0f7 | |
kolaente | 0be83db40f | |
kolaente | 03f4d0b8bc | |
kolaente | ee8f80cc70 | |
kolaente | ce887c38f3 | |
kolaente | 799c0be830 | |
kolaente | 760efa854d | |
kolaente | 26bec05174 | |
kolaente | c32a198a34 | |
kolaente | 6a8c656dbb | |
kolaente | 63ba2982c9 | |
kolaente | 9d9fb959d8 | |
kolaente | 8ed201c83f | |
kolaente | bfb40c9166 | |
kolaente | 5ea450844c | |
kolaente | 36bec9e64f | |
kolaente | a95014dc5d | |
kolaente | 2579c33ee1 | |
kolaente | 6f1baa3219 | |
kolaente | 4dee3a90e9 | |
kolaente | 326b6eda6f | |
kolaente | 85e882cc59 | |
kolaente | e4379f0a22 | |
kolaente | 2bb7ff1803 | |
kolaente | 5dd6e9a077 | |
kolaente | f7629c28f4 | |
kolaente | be2a38b48e | |
kolaente | 3ba5f531bb | |
kolaente | 10f1e69bc3 | |
kolaente | fd7d90b017 | |
kolaente | d898316918 | |
kolaente | a6f524e7af | |
kolaente | 5e65814b8c | |
kolaente | aaa9d553d0 | |
kolaente | 5685890493 | |
kolaente | 2e336150e0 | |
kolaente | 749dcdcd70 | |
kolaente | ab94343d07 | |
kolaente | fa71cec5c8 | |
kolaente | c6f3829387 | |
kolaente | 7171b63947 | |
kolaente | 06c4c0d921 | |
kolaente | f2ca2d850d | |
kolaente | 638d187a24 | |
kolaente | b188d40d3c | |
kolaente | 3ad948305f | |
kolaente | be1f1d94c9 | |
kolaente | 06e8cdb9d2 | |
kolaente | 10311b79df | |
kolaente | ad2690b21c | |
kolaente | 1bd17d6e50 | |
kolaente | a5e710bfe5 | |
kolaente | e1bdabc8d6 | |
renovate | c6ef99dde2 | |
renovate | 49b508a783 | |
renovate | 52128925f5 | |
renovate | cf0c7f9d08 | |
renovate | 57d5140301 | |
renovate | dbd9106621 | |
renovate | e4fef0e88e | |
renovate | 7ef0074ecc | |
renovate | 17c35f6d42 | |
renovate | 3a0844adba | |
renovate | 5b5b9022e0 | |
Frederick [Bot] | 0b0bd7dff6 | |
renovate | 079e3782d1 | |
renovate | a0ae9ae54c | |
renovate | a1b9a0ec4c | |
renovate | 1fa690670d | |
renovate | 3f0a87a5ec | |
renovate | caf02f78bf | |
renovate | 9b9fd14d27 | |
renovate | 7f77efbfab | |
renovate | 53967d20cc | |
renovate | ef3411f39a | |
renovate | 66e63f1363 | |
renovate | 2fe21f6b28 | |
renovate | 67df372636 | |
renovate | df80e9da23 | |
renovate | 13ab2efd0f | |
renovate | 0ffe96cf59 | |
renovate | 1808d0971d | |
renovate | ec83a28d78 | |
renovate | f0320b3a58 | |
renovate | d93a1a4f4f | |
renovate | a9f9ddf6b9 | |
renovate | 6a8fe35fcf | |
renovate | 94661e9e09 | |
renovate | 318f63d098 | |
renovate | e2c9e83c2a | |
renovate | cd434a0e3e | |
renovate | 9f293af804 | |
renovate | b175e00cfe | |
Frederick [Bot] | 19dd82d62a | |
renovate | b3ddc9465a | |
renovate | 6b38f17d32 | |
renovate | 86449d4912 | |
renovate | 145d756251 | |
renovate | 838a11a2f6 | |
renovate | 3bfd3210b0 | |
renovate | e933bfa99e | |
renovate | f6a37a54d0 | |
primeapple | e00c9bb1af | |
kolaente | 018707c3d5 | |
renovate | 386727f6c5 | |
renovate | a29ce36d6c | |
renovate | 7aed16bd6f | |
renovate | b1f3ca6e59 | |
renovate | 4c0b8a06c5 | |
renovate | 60647c50ac | |
renovate | 59eaf1849e | |
renovate | fb57339050 | |
renovate | f9831a6ad8 | |
renovate | f25c67f80a | |
renovate | d3b0b97192 | |
renovate | fa3be219a8 | |
renovate | c22702d911 | |
renovate | b25c5ff547 | |
renovate | 2e0a097806 | |
renovate | c2083f7924 | |
renovate | 8923261e5b | |
renovate | 5391df56b0 | |
renovate | d2b1f5780e | |
renovate | 1717e968e1 | |
renovate | 2c29bb3971 | |
renovate | 37b8218a0a | |
konrad | ca7bbb5b91 | |
renovate | 2f3c008d2b | |
renovate | c2722b7c3d | |
renovate | 312abd907f | |
renovate | 1b73c1ed64 | |
renovate | d442d6653b | |
renovate | 758b8d6e2b | |
renovate | 416fd2e2a7 | |
renovate | 15a8335f1a | |
renovate | c689583669 | |
renovate | 7a43a7acc9 | |
renovate | 8843418161 | |
renovate | 7c1eab13ae | |
renovate | 5a69036da7 | |
renovate | 2ad3458873 | |
renovate | eb464343e8 | |
kolaente | 2f18d0cbad | |
renovate | 6cd463a514 | |
kolaente | 05b70632c5 | |
kolaente | ca9fe6ff21 | |
renovate | e5754300de | |
renovate | 65134048bf | |
renovate | 8339a99747 | |
renovate | 3e1ae41e70 | |
renovate | f757ba3441 | |
renovate | 6499c9cb5b | |
renovate | 28e5440d8b | |
renovate | fef8c4d0f4 | |
renovate | 99e5059c64 | |
renovate | 5df4f39d95 | |
renovate | 7ec5a70ccb | |
renovate | 72fcab6e78 | |
kolaente | 292c90425e | |
kolaente | b80f070431 | |
renovate | 03936c0403 | |
kolaente | 62825d2e64 | |
renovate | 5cd5caef45 | |
renovate | 798e8b529d | |
renovate | 0e3766c5a5 | |
renovate | 90207a4427 | |
renovate | 60993a886a | |
renovate | a6b42f9181 | |
renovate | 98fbd7c53c | |
renovate | 8d533f50e8 | |
renovate | 707459ec77 | |
renovate | faf7db649e | |
renovate | 202e71be48 | |
renovate | d6e8b418d3 | |
renovate | a9f41f6114 | |
renovate | f6f0d52518 | |
renovate | ccb9be42c2 | |
renovate | 179009bfe3 | |
renovate | 8c2bd94a9f | |
renovate | 7757166d75 | |
renovate | 7f03002972 | |
renovate | 8555006d9e | |
renovate | 713ad64658 | |
renovate | 0713d481e3 | |
renovate | ace0cf3588 | |
renovate | bba3bbfe89 | |
renovate | 754afc5496 | |
renovate | f1e8892ab5 | |
renovate | c11e192c4e | |
renovate | e9c704075d | |
renovate | 35edcb5672 | |
renovate | 4695798176 | |
renovate | 7a323fd170 | |
renovate | 1d6e4b6e32 | |
renovate | 5524aa7998 | |
renovate | 15ff2008e3 | |
renovate | 9bc2e6e165 | |
renovate | 344001856c | |
renovate | ad261fcc2f | |
renovate | 5142a0ae72 | |
renovate | 6d195f96c9 | |
Frederick [Bot] | 1917b217a8 | |
renovate | 1f6b01bc73 | |
renovate | d47a16aa8e | |
renovate | c57d00a74b | |
renovate | 77ea7fa0ee | |
kolaente | b92d780cda | |
kolaente | f14e721caf | |
renovate | 1ff6399112 | |
renovate | 503fb8da76 | |
renovate | f050cb7015 | |
renovate | 3670916f36 | |
Frederick [Bot] | 838a063eaa | |
renovate | e1b16b11d6 | |
Dominik Pschenitschni | 314cbf471f | |
Dominik Pschenitschni | a416d26f7c | |
Dominik Pschenitschni | 795b26e1dd | |
renovate | 14666cf9d8 | |
renovate | c938f31935 | |
kolaente | 35a52ef01b | |
renovate | 3b05ce3f10 | |
renovate | aec4fd7a2d | |
renovate | 2661af3a17 | |
renovate | 56f43bae3f | |
renovate | 84472d2e9c | |
renovate | c5afcd63b0 | |
renovate | 9bdb257814 | |
renovate | 5ad9891b16 | |
renovate | 7c04064917 | |
renovate | fb5383d86b | |
renovate | 68af314ec0 | |
renovate | 8b1de5ce09 | |
renovate | 724b6fe091 | |
renovate | 6648cd30c3 | |
kolaente | 8b90b45739 | |
renovate | 39be67eecf | |
Frederick [Bot] | 750f0ddeab | |
renovate | 6a5ece2f24 | |
Frederick [Bot] | 4ce33abfe6 | |
renovate | 5b7e1af87d | |
renovate | 59c6605b14 | |
Dominik Pschenitschni | 820d598ecd | |
Dominik Pschenitschni | a263ec1273 | |
renovate | b68892492c | |
renovate | 7c97695cec | |
renovate | e764f34a2d | |
renovate | 6892a28bb6 | |
renovate | 74d688b8d2 | |
renovate | ed84651046 | |
renovate | 7468ed21fa | |
renovate | d8015913c3 | |
Frederick [Bot] | 78789834f0 | |
Dominik Pschenitschni | 739fe0caa1 | |
Dominik Pschenitschni | 4703f9c4d5 | |
Dominik Pschenitschni | fd699ad777 | |
Dominik Pschenitschni | 0acf44778d | |
Dominik Pschenitschni | 8fc254d2db | |
Dominik Pschenitschni | 7d3b97d422 | |
kolaente | 4a34f245db | |
renovate | 973ea39a64 | |
renovate | f94a65ce7a | |
renovate | 432fbbea78 | |
renovate | e483f1cd2e | |
renovate | eb34f6e136 | |
Dominik Pschenitschni | 91e9eef582 | |
Dominik Pschenitschni | dea1789a00 | |
WofWca | 30adad5ae6 | |
renovate | 3ed6f939e5 | |
renovate | a337d22c1f | |
renovate | addfcf2510 | |
renovate | 303034f02c | |
renovate | 0fd44e9484 | |
renovate | 04040f20ba | |
konrad | 6c999ad148 | |
renovate | cc519e6773 | |
renovate | f9dcae4f65 | |
renovate | ade6c2cb18 | |
renovate | 4566b62a93 | |
renovate | 37d3ef24d2 | |
kolaente | 71265769ce | |
kolaente | a13c16ca03 | |
kolaente | a33fb72ef8 | |
kolaente | c5776264c0 | |
kolaente | 078d8b39a9 | |
kolaente | b77c7c2f45 | |
renovate | e369473dd0 | |
renovate | 70501f9da1 | |
renovate | 9bb7019b09 | |
renovate | df4fe7a644 | |
renovate | 2f009d0b27 | |
renovate | 70d7def7d7 | |
renovate | 0033407f96 | |
renovate | b10a2329ca | |
WofWca | 6870db4a72 | |
WofWca | 3643ffe0d0 | |
renovate | 02971f6ff9 | |
renovate | 7d3c34b004 | |
renovate | f3ea6fd4dc | |
renovate | bed6b81a58 | |
renovate | f9bf9139b8 | |
Dominik Pschenitschni | 96e2c81b7e | |
renovate | e62c00a187 | |
renovate | 611419888a | |
renovate | 5cc7e282bf | |
renovate | de0b71103c | |
renovate | 537e9e8044 | |
renovate | ac95c1fdc8 | |
renovate | b36da9e4d9 | |
renovate | e11ee3c136 | |
renovate | 887719ea24 | |
renovate | 14f1c3b26e | |
renovate | 2142729d38 | |
renovate | 9dcc2baae2 | |
renovate | 37c88d2974 | |
renovate | 36fd0deec4 | |
renovate | 4a4438d431 | |
renovate | 28a6745346 | |
renovate | 9ae0470879 | |
renovate | 927aed1161 | |
renovate | 7f3d7a656d | |
renovate | 040a8ce095 | |
Frederick [Bot] | 8974939bf2 | |
Dominik Pschenitschni | 846de369f2 | |
Frederick [Bot] | 4d865af423 | |
renovate | 62ad01fc8f | |
renovate | da0164b97d | |
kolaente | fc8711d6d8 | |
renovate | 03cef1f831 | |
WofWca | ee4974a494 | |
WofWca | bfbfd6a421 | |
renovate | 49954abbbe | |
renovate | 2f618512cb | |
Frederick [Bot] | 0086ebed0d | |
renovate | 1523ed9a47 | |
renovate | 79c7cbedcc | |
renovate | 3e128f3966 | |
renovate | fb45483ffc | |
renovate | a9bc7d7a38 | |
renovate | 78f032d678 | |
Dominik Pschenitschni | d73b71a097 | |
renovate | 2bdc6155d7 | |
Dominik Pschenitschni | f60cebf42c | |
renovate | 2e4c6673d4 | |
kolaente | 6e3d64d6ef | |
kolaente | 2deb66855b | |
kolaente | a64c0c19e5 | |
kolaente | 24b4576c00 | |
kolaente | 34ad889d90 | |
Dominik Pschenitschni | af523cfcd7 | |
Dominik Pschenitschni | 842f204123 | |
Dominik Pschenitschni | 9162002e55 | |
Dominik Pschenitschni | 985f998a82 | |
Dominik Pschenitschni | b93639e14e | |
Dominik Pschenitschni | a4be973e29 | |
Dominik Pschenitschni | 060a573fe9 | |
Dominik Pschenitschni | 7c43b7385d | |
kolaente | befa6f27bb | |
Dominik Pschenitschni | b9d3b5c756 | |
renovate | ee732684bc | |
renovate | 360b530dd5 | |
renovate | 713c3a1a08 | |
renovate | 81b1e4035d | |
renovate | 2cde9341d4 | |
renovate | cdf0690da6 | |
renovate | 80335e7b95 | |
renovate | dbc2de14c9 | |
renovate | c0f711d27f | |
kolaente | df24522490 | |
kolaente | 6cf2e574bf | |
kolaente | e7b89ae44f | |
renovate | 72a1aaa654 | |
renovate | c70c3b6080 | |
kolaente | 401f2cdd7e | |
konrad | 013472e899 | |
renovate | e9d48c442d | |
renovate | 6837038922 | |
renovate | 6cf7c75954 | |
Frederick [Bot] | 52d6677d93 | |
renovate | f8f8c8ac6e | |
renovate | 97bd5d77b6 | |
renovate | 5278bcbac2 | |
renovate | 194fef0dab | |
renovate | 97b6ba06dd | |
Frederick [Bot] | 559cfde8da | |
WofWca | 9db3aedde9 | |
WofWca | 0eb78e32f9 | |
WofWca | b4dd23b85d | |
renovate | 2262b49aaf | |
renovate | c887990bad | |
renovate | 37c5a88744 | |
Dominik Pschenitschni | 9b7770ade4 | |
Dominik Pschenitschni | af4a039502 | |
Dan Stewart | 1b06112db4 | |
renovate | 0952f059c0 | |
WofWca | 0f97ba6ec9 | |
renovate | d4c9edb55d | |
renovate | 394f056cf4 | |
renovate | 7672676b6e | |
renovate | 51b33fd67e | |
renovate | 0ed3ebda94 | |
danstewart | 7b6f76d1b4 | |
renovate | ad0029789d | |
renovate | e13f57c30a | |
WofWca | 6a3518dace | |
renovate | f1ec554d09 | |
WofWca | 6aa02e29b1 | |
WofWca | 5f9485414b | |
WofWca | 149ceaf2e5 | |
renovate | 9b3e185dd4 | |
renovate | 779fe3e323 | |
renovate | a27b77f24e | |
renovate | 41f22a1035 | |
renovate | 28d01c5ba0 | |
Frederick [Bot] | e272dd8e64 | |
kolaente | c002275e7f | |
renovate | 1392d7f101 | |
renovate | e5758e21c7 | |
Dominik Pschenitschni | e0f06999be | |
renovate | 3b72acff27 | |
renovate | df1c44aabe | |
kolaente | fe764a46e9 | |
renovate | 000e3080a5 | |
renovate | f4c568e961 | |
renovate | ef70ead3f0 | |
renovate | 7a326d6e03 | |
renovate | afb6383a85 | |
renovate | e49969dcad | |
renovate | 81e1d70847 | |
renovate | 5226517954 | |
renovate | d5d0f9a8e2 | |
renovate | 2337b6c9f3 | |
renovate | 289802b13d | |
renovate | 5de9a2880f | |
Frederick [Bot] | 62f6895950 | |
renovate | c198b9a164 | |
renovate | d5f5e2a412 | |
kolaente | cabee68bbb | |
kolaente | 2fd2214a2e | |
kolaente | 64735e0c3d | |
kolaente | 1f40b68108 | |
renovate | 4033c28a67 | |
renovate | be20a01dd6 | |
renovate | 8f5a628e54 | |
renovate | e0c00b306e | |
renovate | 10eaacc552 | |
renovate | 4b1465955a | |
renovate | 1711318212 | |
renovate | b042547aaa | |
renovate | ed0db956eb | |
renovate | a66f8a6484 | |
renovate | 47e895149e | |
renovate | 8e00014feb | |
renovate | 6146340034 | |
renovate | c7b761b0eb | |
renovate | a1e84b3460 | |
renovate | 038debaa22 | |
renovate | 88faf04251 | |
renovate | 04be2b9745 | |
renovate | 815e8cce0e | |
renovate | d12f9247ff | |
renovate | 85e7a17934 | |
renovate | 59c5d43348 | |
renovate | c011f9aa52 | |
renovate | b9f5319a4f | |
renovate | f120ba4169 | |
renovate | b2b70f4a9d | |
renovate | 9facffe3e9 | |
renovate | c31aff1d88 | |
renovate | 60dea80462 | |
renovate | cd10ccfbc0 | |
renovate | 8647402038 | |
renovate | 990fd46302 | |
renovate | cf0aafd9e6 | |
renovate | 70d2535e93 | |
Frederick [Bot] | 0c6f1a4083 | |
renovate | 29eb42932a | |
renovate | 736e9051d8 | |
renovate | 4a4c401558 | |
renovate | 9198abe24d | |
Dominik Pschenitschni | 97c8970dd6 | |
renovate | 5303b6bc97 | |
renovate | 24a0a8f5eb | |
Dominik Pschenitschni | d07ad495e2 | |
renovate | 8465afe421 | |
kolaente | d40729cbe7 | |
kolaente | fa0e46a399 | |
renovate | b78481f9f6 | |
renovate | cbc9cf6f7f | |
renovate | 62fd9a656e | |
renovate | 85269b4524 | |
renovate | 536d709961 | |
renovate | 59d6d7e786 | |
renovate | ae86d0d42a | |
renovate | 9a20b7a853 | |
renovate | 5687b66ea5 | |
renovate | 1da411e1f6 | |
renovate | e8a6d3f31b | |
renovate | a25a795276 | |
renovate | 57f6abd99f | |
renovate | 84d205f90b | |
renovate | de91e7c9ae | |
renovate | 2cf9c35acb | |
konrad | db525db6eb | |
konrad | 88525ae7c8 | |
renovate | 957bfdc8f1 | |
renovate | c52ae83b75 | |
renovate | df40c4e475 | |
renovate | 3f41e9a3a6 | |
renovate | 1da510b5dd | |
renovate | 536db3fd46 | |
kolaente | cefa5250c5 | |
kolaente | f697640636 | |
renovate | 09b7595b68 | |
renovate | 6b7f73f724 | |
Dominik Pschenitschni | d6b55c7570 | |
Yurii Vlasov | 3f4b08b8be | |
kolaente | 791c61cabb | |
konrad | e3dd4ef78a | |
renovate | 830d0887b9 | |
Dominik Pschenitschni | e8db2c2b45 | |
renovate | 706a13242e | |
renovate | 13fab10584 | |
renovate | 4b0c8aa66b | |
renovate | bfaf9401f4 | |
renovate | 13607124a6 | |
renovate | 9fc3d0a965 | |
renovate | 4d6286451e | |
renovate | 0479d17e69 | |
renovate | 5ca272959d | |
Dominik Pschenitschni | c502f9b840 | |
renovate | a3a313a21f | |
renovate | c58d1ffd2e | |
David Angel | 99dc5cf34f | |
David Angel | 3604cb3ec7 | |
David Angel | aa01a92278 | |
Dominik Pschenitschni | 7b96397e3b | |
renovate | b45a4e1aaf | |
Frederick [Bot] | d3365d6add | |
renovate | 49cb2b9e6f | |
renovate | d4ce10e79a | |
renovate | 345c5e3588 | |
renovate | 7ff84bcd29 | |
renovate | d1633ef622 | |
renovate | 7e92bc63ac | |
renovate | be076b65cf | |
Frederick [Bot] | 65b90cbee0 | |
renovate | 74aac1b245 | |
renovate | ade791ed43 | |
renovate | 55b008c67c | |
Frederick [Bot] | 1f088cca18 | |
renovate | 6fad1e4969 | |
Dominik Pschenitschni | eaeddda4e4 | |
kolaente | 7cbf0acac5 | |
Dominik Pschenitschni | 3db5ea45d7 | |
RoboMagus | dcd5c3fd6a | |
renovate | 61fff44764 | |
renovate | ecdae4e03e | |
kolaente | b26ea45fe0 | |
kolaente | 7cb0cd293d | |
konrad | 6572f75e5d | |
Jef Oliver | af55992057 | |
Jef Oliver | e92559dc00 | |
renovate | 3dbf02fd7a | |
Dominik Pschenitschni | 81a4f2d977 | |
renovate | 2972d0d400 | |
renovate | c11ebc44c4 | |
renovate | 144f90c5f7 | |
renovate | 913879604a | |
renovate | 1589ed5739 | |
renovate | a991c537ac | |
renovate | 69b57aa23a | |
renovate | 1a1939963a | |
renovate | 3d62c9789c | |
renovate | c18df8687c | |
renovate | d83ba0c158 | |
kolaente | cea31d1da7 | |
renovate | 12509a7e0f | |
renovate | dd43057a08 | |
renovate | 19d3cf01cd | |
renovate | 80012bf035 | |
renovate | 899d9e1cb7 | |
renovate | 56830ddadc | |
kolaente | 1749d6ba0a | |
renovate | b29008d304 | |
renovate | 8ae3054b1a |
79
.drone.yml
79
.drone.yml
|
@ -15,6 +15,7 @@ trigger:
|
||||||
services:
|
services:
|
||||||
- name: api
|
- name: api
|
||||||
image: vikunja/api:unstable
|
image: vikunja/api:unstable
|
||||||
|
pull: always
|
||||||
environment:
|
environment:
|
||||||
VIKUNJA_SERVICE_TESTINGTOKEN: averyLongSecretToSe33dtheDB
|
VIKUNJA_SERVICE_TESTINGTOKEN: averyLongSecretToSe33dtheDB
|
||||||
VIKUNJA_LOG_LEVEL: DEBUG
|
VIKUNJA_LOG_LEVEL: DEBUG
|
||||||
|
@ -41,11 +42,12 @@ steps:
|
||||||
# - .cache
|
# - .cache
|
||||||
|
|
||||||
- name: dependencies
|
- name: dependencies
|
||||||
image: node:18-alpine
|
image: node:20.11.0-alpine
|
||||||
pull: always
|
pull: always
|
||||||
environment:
|
environment:
|
||||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||||
CYPRESS_CACHE_FOLDER: .cache/cypress
|
CYPRESS_CACHE_FOLDER: .cache/cypress
|
||||||
|
PUPPETEER_SKIP_DOWNLOAD: true
|
||||||
commands:
|
commands:
|
||||||
- corepack enable && pnpm config set store-dir .cache/pnpm
|
- corepack enable && pnpm config set store-dir .cache/pnpm
|
||||||
- pnpm install --fetch-timeout 100000
|
- pnpm install --fetch-timeout 100000
|
||||||
|
@ -53,7 +55,7 @@ steps:
|
||||||
# - restore-cache
|
# - restore-cache
|
||||||
|
|
||||||
- name: lint
|
- name: lint
|
||||||
image: node:18-alpine
|
image: node:20.11.0-alpine
|
||||||
pull: always
|
pull: always
|
||||||
environment:
|
environment:
|
||||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||||
|
@ -64,7 +66,7 @@ steps:
|
||||||
- dependencies
|
- dependencies
|
||||||
|
|
||||||
- name: build-prod
|
- name: build-prod
|
||||||
image: node:18-alpine
|
image: node:20.11.0-alpine
|
||||||
pull: always
|
pull: always
|
||||||
environment:
|
environment:
|
||||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||||
|
@ -75,7 +77,7 @@ steps:
|
||||||
- dependencies
|
- dependencies
|
||||||
|
|
||||||
- name: test-unit
|
- name: test-unit
|
||||||
image: node:18-alpine
|
image: node:20.11.0-alpine
|
||||||
pull: always
|
pull: always
|
||||||
commands:
|
commands:
|
||||||
- corepack enable && pnpm config set store-dir .cache/pnpm
|
- corepack enable && pnpm config set store-dir .cache/pnpm
|
||||||
|
@ -85,7 +87,7 @@ steps:
|
||||||
|
|
||||||
- name: typecheck
|
- name: typecheck
|
||||||
failure: ignore
|
failure: ignore
|
||||||
image: node:18-alpine
|
image: node:20.11.0-alpine
|
||||||
pull: always
|
pull: always
|
||||||
environment:
|
environment:
|
||||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||||
|
@ -135,8 +137,9 @@ steps:
|
||||||
# - dependencies
|
# - dependencies
|
||||||
|
|
||||||
- name: deploy-preview
|
- name: deploy-preview
|
||||||
image: node:18-alpine
|
image: williamjackson/netlify-cli
|
||||||
pull: always
|
pull: always
|
||||||
|
user: root # The rest runs as root and thus the permissions wouldn't work
|
||||||
environment:
|
environment:
|
||||||
NETLIFY_AUTH_TOKEN:
|
NETLIFY_AUTH_TOKEN:
|
||||||
from_secret: netlify_auth_token
|
from_secret: netlify_auth_token
|
||||||
|
@ -199,10 +202,15 @@ steps:
|
||||||
# - .cache
|
# - .cache
|
||||||
|
|
||||||
- name: build
|
- name: build
|
||||||
image: node:18-alpine
|
image: node:20.11.0-alpine
|
||||||
pull: always
|
pull: always
|
||||||
environment:
|
environment:
|
||||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||||
|
SENTRY_AUTH_TOKEN:
|
||||||
|
from_secret: sentry_auth_token
|
||||||
|
SENTRY_ORG: vikunja
|
||||||
|
SENTRY_PROJECT: frontend-oss
|
||||||
|
PUPPETEER_SKIP_DOWNLOAD: true
|
||||||
commands:
|
commands:
|
||||||
- apk add git
|
- apk add git
|
||||||
- corepack enable && pnpm config set store-dir .cache/pnpm
|
- corepack enable && pnpm config set store-dir .cache/pnpm
|
||||||
|
@ -218,6 +226,7 @@ steps:
|
||||||
image: kolaente/zip
|
image: kolaente/zip
|
||||||
pull: always
|
pull: always
|
||||||
commands:
|
commands:
|
||||||
|
- cp src/version.json dist
|
||||||
- cd dist
|
- cd dist
|
||||||
- zip -r ../vikunja-frontend-unstable.zip *
|
- zip -r ../vikunja-frontend-unstable.zip *
|
||||||
- cd ..
|
- cd ..
|
||||||
|
@ -276,10 +285,14 @@ steps:
|
||||||
# - .cache
|
# - .cache
|
||||||
|
|
||||||
- name: build
|
- name: build
|
||||||
image: node:18-alpine
|
image: node:20.11.0-alpine
|
||||||
pull: always
|
pull: always
|
||||||
environment:
|
environment:
|
||||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||||
|
SENTRY_AUTH_TOKEN:
|
||||||
|
from_secret: sentry_auth_token
|
||||||
|
SENTRY_ORG: vikunja
|
||||||
|
SENTRY_PROJECT: frontend-oss
|
||||||
commands:
|
commands:
|
||||||
- apk add git
|
- apk add git
|
||||||
- corepack enable && pnpm config set store-dir .cache/pnpm
|
- corepack enable && pnpm config set store-dir .cache/pnpm
|
||||||
|
@ -295,6 +308,7 @@ steps:
|
||||||
image: kolaente/zip
|
image: kolaente/zip
|
||||||
pull: always
|
pull: always
|
||||||
commands:
|
commands:
|
||||||
|
- cp src/version.json dist
|
||||||
- cd dist
|
- cd dist
|
||||||
- zip -r ../vikunja-frontend-${DRONE_TAG##v}.zip *
|
- zip -r ../vikunja-frontend-${DRONE_TAG##v}.zip *
|
||||||
- cd ..
|
- cd ..
|
||||||
|
@ -346,8 +360,7 @@ type: docker
|
||||||
name: docker-release
|
name: docker-release
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- release-latest
|
- build
|
||||||
- release-version
|
|
||||||
|
|
||||||
trigger:
|
trigger:
|
||||||
ref:
|
ref:
|
||||||
|
@ -375,8 +388,7 @@ steps:
|
||||||
repo: vikunja/frontend
|
repo: vikunja/frontend
|
||||||
tags: unstable
|
tags: unstable
|
||||||
build_args:
|
build_args:
|
||||||
- USE_RELEASE=true
|
- USE_RELEASE=false
|
||||||
- RELEASE_VERSION=unstable
|
|
||||||
platforms:
|
platforms:
|
||||||
- linux/386
|
- linux/386
|
||||||
- linux/amd64
|
- linux/amd64
|
||||||
|
@ -410,8 +422,7 @@ steps:
|
||||||
from_secret: docker_password
|
from_secret: docker_password
|
||||||
repo: vikunja/frontend
|
repo: vikunja/frontend
|
||||||
build_args:
|
build_args:
|
||||||
- USE_RELEASE=true
|
- USE_RELEASE=false
|
||||||
- RELEASE_VERSION=${DRONE_TAG##v}
|
|
||||||
platforms:
|
platforms:
|
||||||
- linux/386
|
- linux/386
|
||||||
- linux/amd64
|
- linux/amd64
|
||||||
|
@ -464,24 +475,25 @@ name: update-translations
|
||||||
|
|
||||||
trigger:
|
trigger:
|
||||||
branch:
|
branch:
|
||||||
- main
|
include:
|
||||||
|
- main
|
||||||
event:
|
event:
|
||||||
- cron
|
include:
|
||||||
|
- cron
|
||||||
cron:
|
cron:
|
||||||
- update_translations
|
- update_translations
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: download
|
- name: download
|
||||||
pull: always
|
pull: always
|
||||||
image: jonasfranz/crowdin
|
image: ghcr.io/kolaente/kolaente/drone-crowdin-v2:latest
|
||||||
settings:
|
settings:
|
||||||
download: true
|
crowdin_key:
|
||||||
export_dir: src/i18n/lang/
|
|
||||||
ignore_branch: true
|
|
||||||
project_identifier: vikunja
|
|
||||||
environment:
|
|
||||||
CROWDIN_KEY:
|
|
||||||
from_secret: crowdin_key
|
from_secret: crowdin_key
|
||||||
|
project_id: 462614
|
||||||
|
target: download
|
||||||
|
download_to: src/i18n/lang/
|
||||||
|
download_export_approved_only: true
|
||||||
|
|
||||||
- name: move-files
|
- name: move-files
|
||||||
pull: always
|
pull: always
|
||||||
|
@ -501,26 +513,25 @@ steps:
|
||||||
author_name: Frederick [Bot]
|
author_name: Frederick [Bot]
|
||||||
branch: main
|
branch: main
|
||||||
commit: true
|
commit: true
|
||||||
commit_message: "[skip ci] Updated translations via Crowdin"
|
commit_message: "chore(i18n): update translations via Crowdin"
|
||||||
remote: "ssh://git@kolaente.dev:9022/vikunja/frontend.git"
|
remote: "ssh://git@kolaente.dev:9022/vikunja/frontend.git"
|
||||||
ssh_key:
|
ssh_key:
|
||||||
from_secret: translation_git_push_ssh_key
|
from_secret: git_push_ssh_key
|
||||||
|
|
||||||
- name: upload
|
- name: upload
|
||||||
pull: always
|
pull: always
|
||||||
image: jonasfranz/crowdin
|
image: ghcr.io/kolaente/kolaente/drone-crowdin-v2:latest
|
||||||
depends_on:
|
depends_on:
|
||||||
- clone
|
- clone
|
||||||
settings:
|
settings:
|
||||||
files:
|
crowdin_key:
|
||||||
en.json: src/i18n/lang/en.json
|
from_secret: crowdin_key
|
||||||
ignore_branch: true
|
project_id: 462614
|
||||||
project_identifier: vikunja
|
target: upload
|
||||||
environment:
|
upload_files:
|
||||||
CROWDIN_KEY:
|
src/i18n/lang/en.json: en.json
|
||||||
from_secret: crowdin_key
|
|
||||||
---
|
---
|
||||||
kind: signature
|
kind: signature
|
||||||
hmac: 971875b90c7bb1649d1b00d022d0b594ba9b68f927bf8f0dbe840190816d676b
|
hmac: a044c7c4db3c2a11299d4d118397e9d25be36db241723a1bbd0a2f9cc90ffdac
|
||||||
|
|
||||||
...
|
...
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
# Duplicate this file and remove the '.example' suffix.
|
# (1) Duplicate this file and remove the '.example' suffix.
|
||||||
# Adjust the values as needed.
|
# 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
|
# (2) Comment in and adjust the values as needed.
|
||||||
VITE_WORKBOX_DEBUG=false
|
|
||||||
SENTRY_AUTH_TOKEN=YOUR_TOKEN
|
# VITE_IS_ONLINE=true
|
||||||
SENTRY_ORG=vikunja
|
# SENTRY_AUTH_TOKEN=YOUR_TOKEN
|
||||||
SENTRY_PROJECT=frontend-oss
|
# SENTRY_ORG=vikunja
|
||||||
|
# SENTRY_PROJECT=frontend-oss
|
||||||
|
# VIKUNJA_FRONTEND_BASE=/custom-subpath
|
|
@ -1,5 +1,5 @@
|
||||||
/* eslint-env node */
|
/* eslint-env node */
|
||||||
require("@rushstack/eslint-patch/modern-module-resolution")
|
require('@rushstack/eslint-patch/modern-module-resolution')
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
'root': true,
|
'root': true,
|
||||||
|
@ -7,52 +7,54 @@ module.exports = {
|
||||||
'browser': true,
|
'browser': true,
|
||||||
'es2022': true,
|
'es2022': true,
|
||||||
'node': true,
|
'node': true,
|
||||||
'vue/setup-compiler-macros': true,
|
|
||||||
},
|
},
|
||||||
'extends': [
|
'extends': [
|
||||||
'eslint:recommended',
|
'eslint:recommended',
|
||||||
'plugin:vue/vue3-essential',
|
'plugin:vue/vue3-recommended',
|
||||||
'@vue/eslint-config-typescript/recommended',
|
'@vue/eslint-config-typescript/recommended',
|
||||||
],
|
],
|
||||||
'rules': {
|
'rules': {
|
||||||
'vue/html-quotes': [
|
'quotes': ['error', 'single'],
|
||||||
'error',
|
'comma-dangle': ['error', 'always-multiline'],
|
||||||
'double',
|
'semi': ['error', 'never'],
|
||||||
],
|
|
||||||
'quotes': [
|
|
||||||
'error',
|
|
||||||
'single',
|
|
||||||
],
|
|
||||||
'comma-dangle': [
|
|
||||||
'error',
|
|
||||||
'always-multiline',
|
|
||||||
],
|
|
||||||
'semi': [
|
|
||||||
'error',
|
|
||||||
'never',
|
|
||||||
],
|
|
||||||
|
|
||||||
// see https://segmentfault.com/q/1010000040813116/a-1020000041134455 (original in chinese)
|
'vue/v-on-event-hyphenation': ['warn', 'never', { 'autofix': true }],
|
||||||
'no-unused-vars': 'off',
|
'vue/multi-word-component-names': 'off',
|
||||||
'@typescript-eslint/no-unused-vars': ['error', { vars: 'all', args: 'after-used', ignoreRestSiblings: true }],
|
|
||||||
|
|
||||||
'vue/multi-word-component-names': 0,
|
// uncategorized rules:
|
||||||
// disabled until we have support for reactivityTransform
|
'vue/component-api-style': ['error', ['script-setup']],
|
||||||
// See https://github.com/vuejs/eslint-plugin-vue/issues/1948
|
'vue/component-name-in-template-casing': ['warn', 'PascalCase'],
|
||||||
// see also setting in `vite.config`
|
'vue/custom-event-name-casing': ['error', 'camelCase'],
|
||||||
'vue/no-setup-props-destructure': 0,
|
'vue/define-macros-order': 'error',
|
||||||
|
'vue/match-component-file-name': ['error', {
|
||||||
|
'extensions': ['.js', '.jsx', '.ts', '.tsx', '.vue'],
|
||||||
|
'shouldMatchCase': true,
|
||||||
|
}],
|
||||||
|
'vue/no-boolean-default': ['warn', 'default-false'],
|
||||||
|
'vue/match-component-import-name': 'error',
|
||||||
|
'vue/prefer-separate-static-class': 'warn',
|
||||||
|
|
||||||
|
'vue/padding-line-between-blocks': 'error',
|
||||||
|
'vue/next-tick-style': ['error', 'promise'],
|
||||||
|
'vue/block-lang': [
|
||||||
|
'error',
|
||||||
|
{ 'script': { 'lang': 'ts' } },
|
||||||
|
],
|
||||||
|
'vue/no-required-prop-with-default': ['error', { 'autofix': true }],
|
||||||
|
'vue/no-duplicate-attr-inheritance': 'error',
|
||||||
|
'vue/no-empty-component-block': 'error',
|
||||||
|
'vue/html-indent': ['error', 'tab'],
|
||||||
|
|
||||||
|
// vue3
|
||||||
|
'vue/no-ref-object-destructure': 'error',
|
||||||
},
|
},
|
||||||
'parser': 'vue-eslint-parser',
|
'parser': 'vue-eslint-parser',
|
||||||
'parserOptions': {
|
'parserOptions': {
|
||||||
'parser': '@typescript-eslint/parser',
|
'parser': '@typescript-eslint/parser',
|
||||||
'ecmaVersion': 2022,
|
'ecmaVersion': 'latest',
|
||||||
'sourceType': 'module',
|
|
||||||
},
|
},
|
||||||
'ignorePatterns': [
|
'ignorePatterns': [
|
||||||
'*.test.*',
|
'*.test.*',
|
||||||
'cypress/*',
|
'cypress/*',
|
||||||
],
|
],
|
||||||
'globals': {
|
|
||||||
'defineProps': 'readonly',
|
|
||||||
},
|
|
||||||
}
|
}
|
|
@ -1,2 +1,3 @@
|
||||||
github: kolaente
|
github: kolaente
|
||||||
custom: https://www.buymeacoffee.com/kolaente
|
open_collective: vikunja
|
||||||
|
custom: ["https://vikunja.cloud", "https://www.buymeacoffee.com/kolaente"]
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
name: 'Repo Lockdown'
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: opened
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
action:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: dessant/repo-lockdown@v4
|
||||||
|
with:
|
||||||
|
pr-comment: 'Hi! Thank you for your contribution.
|
||||||
|
|
||||||
|
This repo is only a mirror and unfortunately we can''t accept PRs made here. Please re-submit your changes to [our Gitea instance](https://kolaente.dev/vikunja/frontend/pulls).
|
||||||
|
|
||||||
|
Also check out the [contribution guidelines](https://vikunja.io/docs/development/#pull-requests).
|
||||||
|
|
||||||
|
Thank you for your understanding.'
|
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
|
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",
|
"lokalise.i18n-ally",
|
||||||
"mgmcdermott.vscode-language-babel",
|
"mgmcdermott.vscode-language-babel",
|
||||||
"mikestead.dotenv",
|
"mikestead.dotenv",
|
||||||
"Syler.sass-indented"
|
"Syler.sass-indented",
|
||||||
|
"zixuanchen.vitest-explorer"
|
||||||
]
|
]
|
||||||
}
|
}
|
|
@ -18,6 +18,12 @@
|
||||||
"javascriptreact",
|
"javascriptreact",
|
||||||
"vue"
|
"vue"
|
||||||
],
|
],
|
||||||
|
|
||||||
|
"volar.completion.preferredTagNameCase": "pascal",
|
||||||
|
|
||||||
|
// disable vetur in case it is installed
|
||||||
|
"vetur.validation.template": false,
|
||||||
|
|
||||||
// i18n ally
|
// i18n ally
|
||||||
"i18n-ally.localesPaths": [
|
"i18n-ally.localesPaths": [
|
||||||
"src/i18n/lang"
|
"src/i18n/lang"
|
||||||
|
|
1507
CHANGELOG.md
1507
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
17
Dockerfile
17
Dockerfile
|
@ -3,16 +3,18 @@
|
||||||
# │─││ │││ │ │
|
# │─││ │││ │ │
|
||||||
# ┘─┘┘─┘┘┘─┘┘─┘
|
# ┘─┘┘─┘┘┘─┘┘─┘
|
||||||
|
|
||||||
FROM node:18-alpine AS builder
|
FROM --platform=$BUILDPLATFORM node:20.11.0-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
ARG USE_RELEASE=false
|
ARG USE_RELEASE=false
|
||||||
ARG RELEASE_VERSION=main
|
ARG RELEASE_VERSION=unstable
|
||||||
ENV PNPM_CACHE_FOLDER .cache/pnpm/
|
ENV PNPM_CACHE_FOLDER .cache/pnpm/
|
||||||
|
ENV PUPPETEER_SKIP_DOWNLOAD true
|
||||||
|
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
COPY pnpm-lock.yaml ./
|
COPY pnpm-lock.yaml ./
|
||||||
|
COPY patches ./patches/
|
||||||
|
|
||||||
RUN if [ "$USE_RELEASE" != true ]; then \
|
RUN if [ "$USE_RELEASE" != true ]; then \
|
||||||
# https://pnpm.io/installation#using-corepack
|
# https://pnpm.io/installation#using-corepack
|
||||||
|
@ -51,11 +53,15 @@ LABEL maintainer="maintainers@vikunja.io"
|
||||||
ENV VIKUNJA_HTTP_PORT 80
|
ENV VIKUNJA_HTTP_PORT 80
|
||||||
ENV VIKUNJA_HTTP2_PORT 81
|
ENV VIKUNJA_HTTP2_PORT 81
|
||||||
ENV VIKUNJA_LOG_FORMAT main
|
ENV VIKUNJA_LOG_FORMAT main
|
||||||
ENV VIKUNJA_API_URL http://localhost:3456/api/v1
|
ENV VIKUNJA_API_URL /api/v1
|
||||||
ENV VIKUNJA_SENTRY_ENABLED false
|
ENV VIKUNJA_SENTRY_ENABLED false
|
||||||
ENV VIKUNJA_SENTRY_DSN https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480
|
ENV VIKUNJA_SENTRY_DSN https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480
|
||||||
|
ENV VIKUNJA_PROJECT_INFINITE_NESTING_ENABLED false
|
||||||
|
ENV VIKUNJA_ALLOW_ICON_CHANGES true
|
||||||
|
ENV VIKUNJA_CUSTOM_LOGO_URL "''"
|
||||||
|
|
||||||
COPY docker/injector.sh /docker-entrypoint.d/50-injector.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/nginx.conf /etc/nginx/nginx.conf
|
||||||
COPY docker/templates/. /etc/nginx/templates/
|
COPY docker/templates/. /etc/nginx/templates/
|
||||||
# copy compiled files from stage 1
|
# copy compiled files from stage 1
|
||||||
|
@ -63,6 +69,5 @@ COPY --from=builder /build/dist ./
|
||||||
# manage permissions
|
# manage permissions
|
||||||
RUN chmod 0755 /docker-entrypoint.d/*.sh /etc/nginx/templates && \
|
RUN chmod 0755 /docker-entrypoint.d/*.sh /etc/nginx/templates && \
|
||||||
chmod -R 0644 /etc/nginx/nginx.conf && \
|
chmod -R 0644 /etc/nginx/nginx.conf && \
|
||||||
chown -R nginx:nginx ./ /etc/nginx/conf.d /etc/nginx/templates
|
chown -R nginx:nginx ./ /etc/nginx/conf.d /etc/nginx/templates && \
|
||||||
# unprivileged user
|
rm -f /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
|
||||||
USER nginx
|
|
||||||
|
|
14
README.md
14
README.md
|
@ -1,10 +1,14 @@
|
||||||
|
# This repository was merged with the api and is now archived
|
||||||
|
|
||||||
|
You can find the new (old) code over on [vikunja/vikunja](https://kolaente.dev/vikunja/vikunja).
|
||||||
|
|
||||||
# Web frontend for Vikunja
|
# Web frontend for Vikunja
|
||||||
|
|
||||||
> The todo app to organize your life.
|
> The todo app to organize your life.
|
||||||
|
|
||||||
[![Build Status](https://drone.kolaente.de/api/badges/vikunja/frontend/status.svg)](https://drone.kolaente.de/vikunja/frontend)
|
[![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)
|
[![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.22.1-brightgreen.svg)](https://dl.vikunja.io)
|
||||||
[![Translation](https://badges.crowdin.net/vikunja/localized.svg)](https://crowdin.com/project/vikunja)
|
[![Translation](https://badges.crowdin.net/vikunja/localized.svg)](https://crowdin.com/project/vikunja)
|
||||||
|
|
||||||
This is the web frontend for Vikunja, written in Vue.js.
|
This is the web frontend for Vikunja, written in Vue.js.
|
||||||
|
@ -25,7 +29,7 @@ export DOCKER_BUILDKIT=1
|
||||||
docker build -t vikunja/frontend .
|
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.
|
Refer to [multi-platform documentation](https://docs.docker.com/build/building/multi-platform/) in order to build for different platforms.
|
||||||
|
|
||||||
## Project setup
|
## Project setup
|
||||||
|
|
||||||
|
@ -49,8 +53,4 @@ pnpm run build
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
pnpm run lint
|
pnpm run lint
|
||||||
```
|
```
|
||||||
|
|
||||||
## Sponsors
|
|
||||||
|
|
||||||
[![Relm](https://vikunja.io/images/sponsors/relm.png)](https://relm.us)
|
|
|
@ -24,4 +24,5 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
viewportWidth: 1600,
|
viewportWidth: 1600,
|
||||||
viewportHeight: 900,
|
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,39 +0,0 @@
|
||||||
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'
|
|
||||||
|
|
||||||
describe('Editor', () => {
|
|
||||||
createFakeUserAndLogin()
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
NamespaceFactory.create(1)
|
|
||||||
ListFactory.create(1)
|
|
||||||
TaskFactory.truncate()
|
|
||||||
UserListFactory.truncate()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Has a preview with checkable checkboxes', () => {
|
|
||||||
const tasks = TaskFactory.create(1, {
|
|
||||||
description: `# Test Heading
|
|
||||||
* Bullet 1
|
|
||||||
* Bullet 2
|
|
||||||
|
|
||||||
* [ ] Checklist
|
|
||||||
* [x] Checklist checked
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
|
|
||||||
cy.visit(`/tasks/${tasks[0].id}`)
|
|
||||||
cy.get('input[type=checkbox][data-checkbox-num=0]')
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.get('.task-view .details.content.description h3 span.is-small.has-text-success')
|
|
||||||
.contains('Saved!')
|
|
||||||
.should('exist')
|
|
||||||
cy.get('.preview.content')
|
|
||||||
.should('contain', 'Test Heading')
|
|
||||||
})
|
|
||||||
})
|
|
|
@ -8,20 +8,20 @@ describe('The Menu', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Is visible by default on desktop', () => {
|
it('Is visible by default on desktop', () => {
|
||||||
cy.get('.namespace-container')
|
cy.get('.menu-container')
|
||||||
.should('have.class', 'is-active')
|
.should('have.class', 'is-active')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Can be hidden on desktop', () => {
|
it('Can be hidden on desktop', () => {
|
||||||
cy.get('button.menu-show-button:visible')
|
cy.get('button.menu-show-button:visible')
|
||||||
.click()
|
.click()
|
||||||
cy.get('.namespace-container')
|
cy.get('.menu-container')
|
||||||
.should('not.have.class', 'is-active')
|
.should('not.have.class', 'is-active')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Is hidden by default on mobile', () => {
|
it('Is hidden by default on mobile', () => {
|
||||||
cy.viewport('iphone-8')
|
cy.viewport('iphone-8')
|
||||||
cy.get('.namespace-container')
|
cy.get('.menu-container')
|
||||||
.should('not.have.class', 'is-active')
|
.should('not.have.class', 'is-active')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -29,7 +29,7 @@ describe('The Menu', () => {
|
||||||
cy.viewport('iphone-8')
|
cy.viewport('iphone-8')
|
||||||
cy.get('button.menu-show-button:visible')
|
cy.get('button.menu-show-button:visible')
|
||||||
.click()
|
.click()
|
||||||
cy.get('.namespace-container')
|
cy.get('.menu-container')
|
||||||
.should('have.class', 'is-active')
|
.should('have.class', 'is-active')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
|
@ -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 {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||||
|
|
||||||
import {TaskFactory} from '../../factories/task'
|
import {TaskFactory} from '../../factories/task'
|
||||||
import {prepareLists} from './prepareLists'
|
import {prepareProjects} from './prepareProjects'
|
||||||
|
|
||||||
describe('List View Gantt', () => {
|
describe('Project View Gantt', () => {
|
||||||
createFakeUserAndLogin()
|
createFakeUserAndLogin()
|
||||||
prepareLists()
|
prepareProjects()
|
||||||
|
|
||||||
it('Hides tasks with no dates', () => {
|
it('Hides tasks with no dates', () => {
|
||||||
const tasks = TaskFactory.create(1)
|
const tasks = TaskFactory.create(1)
|
||||||
cy.visit('/lists/1/gantt')
|
cy.visit('/projects/1/gantt')
|
||||||
|
|
||||||
cy.get('.g-gantt-rows-container')
|
cy.get('.g-gantt-rows-container')
|
||||||
.should('not.contain', tasks[0].title)
|
.should('not.contain', tasks[0].title)
|
||||||
|
@ -25,7 +25,7 @@ describe('List View Gantt', () => {
|
||||||
nextMonth.setDate(1)
|
nextMonth.setDate(1)
|
||||||
nextMonth.setMonth(9)
|
nextMonth.setMonth(9)
|
||||||
|
|
||||||
cy.visit('/lists/1/gantt')
|
cy.visit('/projects/1/gantt')
|
||||||
|
|
||||||
cy.get('.g-timeunits-container')
|
cy.get('.g-timeunits-container')
|
||||||
.should('contain', format(now, 'MMMM'))
|
.should('contain', format(now, 'MMMM'))
|
||||||
|
@ -38,7 +38,7 @@ describe('List View Gantt', () => {
|
||||||
start_date: now.toISOString(),
|
start_date: now.toISOString(),
|
||||||
end_date: new Date(new Date(now).setDate(now.getDate() + 4)).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')
|
cy.get('.g-gantt-rows-container')
|
||||||
.should('not.be.empty')
|
.should('not.be.empty')
|
||||||
|
@ -50,7 +50,7 @@ describe('List View Gantt', () => {
|
||||||
start_date: null,
|
start_date: null,
|
||||||
end_date: null,
|
end_date: null,
|
||||||
})
|
})
|
||||||
cy.visit('/lists/1/gantt')
|
cy.visit('/projects/1/gantt')
|
||||||
|
|
||||||
cy.get('.gantt-options .fancycheckbox')
|
cy.get('.gantt-options .fancycheckbox')
|
||||||
.contains('Show tasks which don\'t have dates set')
|
.contains('Show tasks which don\'t have dates set')
|
||||||
|
@ -69,7 +69,7 @@ describe('List View Gantt', () => {
|
||||||
start_date: now.toISOString(),
|
start_date: now.toISOString(),
|
||||||
end_date: new Date(new Date(now).setDate(now.getDate() + 4)).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')
|
cy.get('.g-gantt-rows-container .g-gantt-row .g-gantt-row-bars-container div .g-gantt-bar')
|
||||||
.first()
|
.first()
|
||||||
|
@ -83,9 +83,9 @@ describe('List View Gantt', () => {
|
||||||
const now = Date.UTC(2022, 10, 9)
|
const now = Date.UTC(2022, 10, 9)
|
||||||
cy.clock(now, ['Date'])
|
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()
|
.click()
|
||||||
cy.get('.flatpickr-calendar .flatpickr-innerContainer .dayContainer .flatpickr-day')
|
cy.get('.flatpickr-calendar .flatpickr-innerContainer .dayContainer .flatpickr-day')
|
||||||
.first()
|
.first()
|
||||||
|
@ -99,13 +99,13 @@ describe('List View Gantt', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should change the date range based on date query parameters', () => {
|
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')
|
cy.get('.g-timeunits-container')
|
||||||
.should('contain', 'September 2022')
|
.should('contain', 'September 2022')
|
||||||
.should('contain', 'October 2022')
|
.should('contain', 'October 2022')
|
||||||
.should('contain', 'November 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')
|
.should('have.value', '25 Sep 2022 to 5 Nov 2022')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -115,7 +115,7 @@ describe('List View Gantt', () => {
|
||||||
start_date: formatISO(now),
|
start_date: formatISO(now),
|
||||||
end_date: formatISO(now.setDate(now.getDate() + 4)),
|
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')
|
cy.get('.gantt-container .g-gantt-chart .g-gantt-row-bars-container .g-gantt-bar')
|
||||||
.dblclick()
|
.dblclick()
|
|
@ -1,13 +1,26 @@
|
||||||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||||
|
|
||||||
import {BucketFactory} from '../../factories/bucket'
|
import {BucketFactory} from '../../factories/bucket'
|
||||||
import {ListFactory} from '../../factories/list'
|
import {ProjectFactory} from '../../factories/project'
|
||||||
import {TaskFactory} from '../../factories/task'
|
import {TaskFactory} from '../../factories/task'
|
||||||
import {prepareLists} from './prepareLists'
|
import {prepareProjects} from './prepareProjects'
|
||||||
|
|
||||||
describe('List View Kanban', () => {
|
function createSingleTaskInBucket(count = 1, attrs = {}) {
|
||||||
|
const projects = ProjectFactory.create(1)
|
||||||
|
const buckets = BucketFactory.create(2, {
|
||||||
|
project_id: projects[0].id,
|
||||||
|
})
|
||||||
|
const tasks = TaskFactory.create(count, {
|
||||||
|
project_id: projects[0].id,
|
||||||
|
bucket_id: buckets[0].id,
|
||||||
|
...attrs,
|
||||||
|
})
|
||||||
|
return tasks[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Project View Kanban', () => {
|
||||||
createFakeUserAndLogin()
|
createFakeUserAndLogin()
|
||||||
prepareLists()
|
prepareProjects()
|
||||||
|
|
||||||
let buckets
|
let buckets
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
@ -16,10 +29,10 @@ describe('List View Kanban', () => {
|
||||||
|
|
||||||
it('Shows all buckets with their tasks', () => {
|
it('Shows all buckets with their tasks', () => {
|
||||||
const data = TaskFactory.create(10, {
|
const data = TaskFactory.create(10, {
|
||||||
list_id: 1,
|
project_id: 1,
|
||||||
bucket_id: 1,
|
bucket_id: 1,
|
||||||
})
|
})
|
||||||
cy.visit('/lists/1/kanban')
|
cy.visit('/projects/1/kanban')
|
||||||
|
|
||||||
cy.get('.kanban .bucket .title')
|
cy.get('.kanban .bucket .title')
|
||||||
.contains(buckets[0].title)
|
.contains(buckets[0].title)
|
||||||
|
@ -34,10 +47,10 @@ describe('List View Kanban', () => {
|
||||||
|
|
||||||
it('Can add a new task to a bucket', () => {
|
it('Can add a new task to a bucket', () => {
|
||||||
TaskFactory.create(2, {
|
TaskFactory.create(2, {
|
||||||
list_id: 1,
|
project_id: 1,
|
||||||
bucket_id: 1,
|
bucket_id: 1,
|
||||||
})
|
})
|
||||||
cy.visit('/lists/1/kanban')
|
cy.visit('/projects/1/kanban')
|
||||||
|
|
||||||
cy.get('.kanban .bucket')
|
cy.get('.kanban .bucket')
|
||||||
.contains(buckets[0].title)
|
.contains(buckets[0].title)
|
||||||
|
@ -55,7 +68,7 @@ describe('List View Kanban', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Can create a new bucket', () => {
|
it('Can create a new bucket', () => {
|
||||||
cy.visit('/lists/1/kanban')
|
cy.visit('/projects/1/kanban')
|
||||||
|
|
||||||
cy.get('.kanban .bucket.new-bucket .button')
|
cy.get('.kanban .bucket.new-bucket .button')
|
||||||
.click()
|
.click()
|
||||||
|
@ -69,7 +82,7 @@ describe('List View Kanban', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Can set a bucket limit', () => {
|
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')
|
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
|
||||||
.first()
|
.first()
|
||||||
|
@ -90,7 +103,7 @@ describe('List View Kanban', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Can rename a bucket', () => {
|
it('Can rename a bucket', () => {
|
||||||
cy.visit('/lists/1/kanban')
|
cy.visit('/projects/1/kanban')
|
||||||
|
|
||||||
cy.get('.kanban .bucket .bucket-header .title')
|
cy.get('.kanban .bucket .bucket-header .title')
|
||||||
.first()
|
.first()
|
||||||
|
@ -101,7 +114,7 @@ describe('List View Kanban', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Can delete a bucket', () => {
|
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')
|
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
|
||||||
.first()
|
.first()
|
||||||
|
@ -125,10 +138,10 @@ describe('List View Kanban', () => {
|
||||||
|
|
||||||
it('Can drag tasks around', () => {
|
it('Can drag tasks around', () => {
|
||||||
const tasks = TaskFactory.create(2, {
|
const tasks = TaskFactory.create(2, {
|
||||||
list_id: 1,
|
project_id: 1,
|
||||||
bucket_id: 1,
|
bucket_id: 1,
|
||||||
})
|
})
|
||||||
cy.visit('/lists/1/kanban')
|
cy.visit('/projects/1/kanban')
|
||||||
|
|
||||||
cy.get('.kanban .bucket .tasks .task')
|
cy.get('.kanban .bucket .tasks .task')
|
||||||
.contains(tasks[0].title)
|
.contains(tasks[0].title)
|
||||||
|
@ -144,10 +157,10 @@ describe('List View Kanban', () => {
|
||||||
it('Should navigate to the task when the task card is clicked', () => {
|
it('Should navigate to the task when the task card is clicked', () => {
|
||||||
const tasks = TaskFactory.create(5, {
|
const tasks = TaskFactory.create(5, {
|
||||||
id: '{increment}',
|
id: '{increment}',
|
||||||
list_id: 1,
|
project_id: 1,
|
||||||
bucket_id: 1,
|
bucket_id: 1,
|
||||||
})
|
})
|
||||||
cy.visit('/lists/1/kanban')
|
cy.visit('/projects/1/kanban')
|
||||||
|
|
||||||
cy.get('.kanban .bucket .tasks .task')
|
cy.get('.kanban .bucket .tasks .task')
|
||||||
.contains(tasks[0].title)
|
.contains(tasks[0].title)
|
||||||
|
@ -158,18 +171,18 @@ describe('List View Kanban', () => {
|
||||||
.should('contain', `/tasks/${tasks[0].id}`, { timeout: 1000 })
|
.should('contain', `/tasks/${tasks[0].id}`, { timeout: 1000 })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should remove a task from the kanban board when moving it to another list', () => {
|
it('Should remove a task from the kanban board when moving it to another project', () => {
|
||||||
const lists = ListFactory.create(2)
|
const projects = ProjectFactory.create(2)
|
||||||
BucketFactory.create(2, {
|
BucketFactory.create(2, {
|
||||||
list_id: '{increment}',
|
project_id: '{increment}',
|
||||||
})
|
})
|
||||||
const tasks = TaskFactory.create(5, {
|
const tasks = TaskFactory.create(5, {
|
||||||
id: '{increment}',
|
id: '{increment}',
|
||||||
list_id: 1,
|
project_id: 1,
|
||||||
bucket_id: 1,
|
bucket_id: 1,
|
||||||
})
|
})
|
||||||
const task = tasks[0]
|
const task = tasks[0]
|
||||||
cy.visit('/lists/1/kanban')
|
cy.visit('/projects/1/kanban')
|
||||||
|
|
||||||
cy.get('.kanban .bucket .tasks .task')
|
cy.get('.kanban .bucket .tasks .task')
|
||||||
.contains(task.title)
|
.contains(task.title)
|
||||||
|
@ -180,7 +193,7 @@ describe('List View Kanban', () => {
|
||||||
.contains('Move')
|
.contains('Move')
|
||||||
.click()
|
.click()
|
||||||
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
|
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
|
// 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.
|
// 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')
|
cy.get('.task-view .content.details .field .multiselect.control .search-results')
|
||||||
|
@ -197,26 +210,18 @@ describe('List View Kanban', () => {
|
||||||
|
|
||||||
it('Shows a button to filter the kanban board', () => {
|
it('Shows a button to filter the kanban board', () => {
|
||||||
const data = TaskFactory.create(10, {
|
const data = TaskFactory.create(10, {
|
||||||
list_id: 1,
|
project_id: 1,
|
||||||
bucket_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')
|
.should('exist')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should remove a task from the board when deleting it', () => {
|
it('Should remove a task from the board when deleting it', () => {
|
||||||
const lists = ListFactory.create(1)
|
const task = createSingleTaskInBucket(5)
|
||||||
const buckets = BucketFactory.create(2, {
|
cy.visit('/projects/1/kanban')
|
||||||
list_id: lists[0].id,
|
|
||||||
})
|
|
||||||
const tasks = TaskFactory.create(5, {
|
|
||||||
list_id: 1,
|
|
||||||
bucket_id: buckets[0].id,
|
|
||||||
})
|
|
||||||
const task = tasks[0]
|
|
||||||
cy.visit('/lists/1/kanban')
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket .tasks .task')
|
cy.get('.kanban .bucket .tasks .task')
|
||||||
.contains(task.title)
|
.contains(task.title)
|
||||||
|
@ -238,4 +243,43 @@ describe('List View Kanban', () => {
|
||||||
cy.get('.kanban .bucket .tasks')
|
cy.get('.kanban .bucket .tasks')
|
||||||
.should('not.contain', task.title)
|
.should('not.contain', task.title)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should show a task description icon if the task has a description', () => {
|
||||||
|
cy.intercept(Cypress.env('API_URL') + '/projects/1/buckets**').as('loadTasks')
|
||||||
|
const task = createSingleTaskInBucket(1, {
|
||||||
|
description: 'Lorem Ipsum',
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.visit(`/projects/${task.project_id}/kanban`)
|
||||||
|
cy.wait('@loadTasks')
|
||||||
|
|
||||||
|
cy.get('.bucket .tasks .task .footer .icon svg')
|
||||||
|
.should('exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should not show a task description icon if the task has an empty description', () => {
|
||||||
|
cy.intercept(Cypress.env('API_URL') + '/projects/1/buckets**').as('loadTasks')
|
||||||
|
const task = createSingleTaskInBucket(1, {
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.visit(`/projects/${task.project_id}/kanban`)
|
||||||
|
cy.wait('@loadTasks')
|
||||||
|
|
||||||
|
cy.get('.bucket .tasks .task .footer .icon svg')
|
||||||
|
.should('not.exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should not show a task description icon if the task has a description containing only an empty p tag', () => {
|
||||||
|
cy.intercept(Cypress.env('API_URL') + '/projects/1/buckets**').as('loadTasks')
|
||||||
|
const task = createSingleTaskInBucket(1, {
|
||||||
|
description: '<p></p>',
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.visit(`/projects/${task.project_id}/kanban`)
|
||||||
|
cy.wait('@loadTasks')
|
||||||
|
|
||||||
|
cy.get('.bucket .tasks .task .footer .icon svg')
|
||||||
|
.should('not.exist')
|
||||||
|
})
|
||||||
})
|
})
|
|
@ -1,32 +1,32 @@
|
||||||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||||
|
|
||||||
import {UserListFactory} from '../../factories/users_list'
|
import {UserProjectFactory} from '../../factories/users_project'
|
||||||
import {TaskFactory} from '../../factories/task'
|
import {TaskFactory} from '../../factories/task'
|
||||||
import {UserFactory} from '../../factories/user'
|
import {UserFactory} from '../../factories/user'
|
||||||
import {ListFactory} from '../../factories/list'
|
import {ProjectFactory} from '../../factories/project'
|
||||||
import {prepareLists} from './prepareLists'
|
import {prepareProjects} from './prepareProjects'
|
||||||
|
|
||||||
describe('List View List', () => {
|
describe('Project View Project', () => {
|
||||||
createFakeUserAndLogin()
|
createFakeUserAndLogin()
|
||||||
prepareLists()
|
prepareProjects()
|
||||||
|
|
||||||
it('Should be an empty list', () => {
|
it('Should be an empty project', () => {
|
||||||
cy.visit('/lists/1')
|
cy.visit('/projects/1')
|
||||||
cy.url()
|
cy.url()
|
||||||
.should('contain', '/lists/1/list')
|
.should('contain', '/projects/1/list')
|
||||||
cy.get('.list-title h1')
|
cy.get('.project-title')
|
||||||
.should('contain', 'First List')
|
.should('contain', 'First Project')
|
||||||
cy.get('.list-title .dropdown')
|
cy.get('.project-title-dropdown')
|
||||||
.should('exist')
|
.should('exist')
|
||||||
cy.get('p')
|
cy.get('p')
|
||||||
.contains('This list is currently empty.')
|
.contains('This project is currently empty.')
|
||||||
.should('exist')
|
.should('exist')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should create a new task', () => {
|
it('Should create a new task', () => {
|
||||||
const newTaskTitle = 'New task'
|
const newTaskTitle = 'New task'
|
||||||
|
|
||||||
cy.visit('/lists/1')
|
cy.visit('/projects/1')
|
||||||
cy.get('.task-add textarea')
|
cy.get('.task-add textarea')
|
||||||
.type(newTaskTitle+'{enter}')
|
.type(newTaskTitle+'{enter}')
|
||||||
cy.get('.tasks')
|
cy.get('.tasks')
|
||||||
|
@ -36,9 +36,9 @@ describe('List View List', () => {
|
||||||
it('Should navigate to the task when the title is clicked', () => {
|
it('Should navigate to the task when the title is clicked', () => {
|
||||||
const tasks = TaskFactory.create(5, {
|
const tasks = TaskFactory.create(5, {
|
||||||
id: '{increment}',
|
id: '{increment}',
|
||||||
list_id: 1,
|
project_id: 1,
|
||||||
})
|
})
|
||||||
cy.visit('/lists/1/list')
|
cy.visit('/projects/1/list')
|
||||||
|
|
||||||
cy.get('.tasks .task .tasktext')
|
cy.get('.tasks .task .tasktext')
|
||||||
.contains(tasks[0].title)
|
.contains(tasks[0].title)
|
||||||
|
@ -49,33 +49,32 @@ describe('List View List', () => {
|
||||||
.should('contain', `/tasks/${tasks[0].id}`)
|
.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)
|
UserFactory.create(2)
|
||||||
UserListFactory.create(1, {
|
UserProjectFactory.create(1, {
|
||||||
list_id: 2,
|
project_id: 2,
|
||||||
user_id: 1,
|
user_id: 1,
|
||||||
right: 0,
|
right: 0,
|
||||||
})
|
})
|
||||||
const lists = ListFactory.create(2, {
|
const projects = ProjectFactory.create(2, {
|
||||||
owner_id: '{increment}',
|
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')
|
.should('not.exist')
|
||||||
cy.get('input.input[placeholder="Add a new task..."')
|
cy.get('input.input[placeholder="Add a new task..."')
|
||||||
.should('not.exist')
|
.should('not.exist')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should only show the color of a list in the navigation and not in the list view', () => {
|
it('Should only show the color of a project in the navigation and not in the list view', () => {
|
||||||
const lists = ListFactory.create(1, {
|
const projects = ProjectFactory.create(1, {
|
||||||
hex_color: '00db60',
|
hex_color: '00db60',
|
||||||
})
|
})
|
||||||
TaskFactory.create(10, {
|
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')
|
cy.get('.menu-list li .list-menu-link .color-bubble')
|
||||||
.should('have.css', 'background-color', 'rgb(0, 219, 96)')
|
.should('have.css', 'background-color', 'rgb(0, 219, 96)')
|
||||||
|
@ -87,9 +86,9 @@ describe('List View List', () => {
|
||||||
const tasks = TaskFactory.create(100, {
|
const tasks = TaskFactory.create(100, {
|
||||||
id: '{increment}',
|
id: '{increment}',
|
||||||
title: i => `task${i}`,
|
title: i => `task${i}`,
|
||||||
list_id: 1,
|
project_id: 1,
|
||||||
})
|
})
|
||||||
cy.visit('/lists/1/list')
|
cy.visit('/projects/1/list')
|
||||||
|
|
||||||
cy.get('.tasks')
|
cy.get('.tasks')
|
||||||
.should('contain', tasks[1].title)
|
.should('contain', tasks[1].title)
|
|
@ -2,37 +2,37 @@ import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||||
|
|
||||||
import {TaskFactory} from '../../factories/task'
|
import {TaskFactory} from '../../factories/task'
|
||||||
|
|
||||||
describe('List View Table', () => {
|
describe('Project View Table', () => {
|
||||||
createFakeUserAndLogin()
|
createFakeUserAndLogin()
|
||||||
|
|
||||||
it('Should show a table with tasks', () => {
|
it('Should show a table with tasks', () => {
|
||||||
const tasks = TaskFactory.create(1)
|
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')
|
.should('exist')
|
||||||
cy.get('.list-table table.table')
|
cy.get('.project-table table.table')
|
||||||
.should('contain', tasks[0].title)
|
.should('contain', tasks[0].title)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should have working column switches', () => {
|
it('Should have working column switches', () => {
|
||||||
TaskFactory.create(1)
|
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')
|
.contains('Columns')
|
||||||
.click()
|
.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')
|
.contains('Priority')
|
||||||
.click()
|
.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')
|
.contains('Done')
|
||||||
.click()
|
.click()
|
||||||
|
|
||||||
cy.get('.list-table table.table th')
|
cy.get('.project-table table.table th')
|
||||||
.contains('Priority')
|
.contains('Priority')
|
||||||
.should('exist')
|
.should('exist')
|
||||||
cy.get('.list-table table.table th')
|
cy.get('.project-table table.table th')
|
||||||
.contains('Done')
|
.contains('Done')
|
||||||
.should('not.exist')
|
.should('not.exist')
|
||||||
})
|
})
|
||||||
|
@ -40,11 +40,11 @@ describe('List View Table', () => {
|
||||||
it('Should navigate to the task when the title is clicked', () => {
|
it('Should navigate to the task when the title is clicked', () => {
|
||||||
const tasks = TaskFactory.create(5, {
|
const tasks = TaskFactory.create(5, {
|
||||||
id: '{increment}',
|
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)
|
.contains(tasks[0].title)
|
||||||
.click()
|
.click()
|
||||||
|
|
|
@ -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,25 +1,59 @@
|
||||||
import {LinkShareFactory} from '../../factories/link_sharing'
|
import {LinkShareFactory} from '../../factories/link_sharing'
|
||||||
import {ListFactory} from '../../factories/list'
|
import {ProjectFactory} from '../../factories/project'
|
||||||
import {TaskFactory} from '../../factories/task'
|
import {TaskFactory} from '../../factories/task'
|
||||||
|
|
||||||
|
function prepareLinkShare() {
|
||||||
|
const projects = ProjectFactory.create(1)
|
||||||
|
const tasks = TaskFactory.create(10, {
|
||||||
|
project_id: projects[0].id
|
||||||
|
})
|
||||||
|
const linkShares = LinkShareFactory.create(1, {
|
||||||
|
project_id: projects[0].id,
|
||||||
|
right: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
share: linkShares[0],
|
||||||
|
project: projects[0],
|
||||||
|
tasks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('Link shares', () => {
|
describe('Link shares', () => {
|
||||||
it('Can view a link share', () => {
|
it('Can view a link share', () => {
|
||||||
const lists = ListFactory.create(1)
|
const {share, project, tasks} = prepareLinkShare()
|
||||||
const tasks = TaskFactory.create(10, {
|
|
||||||
list_id: lists[0].id
|
|
||||||
})
|
|
||||||
const linkShares = LinkShareFactory.create(1, {
|
|
||||||
list_id: lists[0].id,
|
|
||||||
right: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
cy.visit(`/share/${linkShares[0].hash}/auth`)
|
cy.visit(`/share/${share.hash}/auth`)
|
||||||
|
|
||||||
cy.get('h1.title')
|
cy.get('h1.title')
|
||||||
.should('contain', lists[0].title)
|
.should('contain', project.title)
|
||||||
|
cy.get('input.input[placeholder="Add a new task..."')
|
||||||
|
.should('not.exist')
|
||||||
|
cy.get('.tasks')
|
||||||
|
.should('contain', tasks[0].title)
|
||||||
|
|
||||||
|
cy.url().should('contain', `/projects/${project.id}/list#share-auth-token=${share.hash}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should work when directly viewing a project with share hash present', () => {
|
||||||
|
const {share, project, tasks} = prepareLinkShare()
|
||||||
|
|
||||||
|
cy.visit(`/projects/${project.id}/list#share-auth-token=${share.hash}`)
|
||||||
|
|
||||||
|
cy.get('h1.title')
|
||||||
|
.should('contain', project.title)
|
||||||
cy.get('input.input[placeholder="Add a new task..."')
|
cy.get('input.input[placeholder="Add a new task..."')
|
||||||
.should('not.exist')
|
.should('not.exist')
|
||||||
cy.get('.tasks')
|
cy.get('.tasks')
|
||||||
.should('contain', tasks[0].title)
|
.should('contain', tasks[0].title)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should work when directly viewing a task with share hash present', () => {
|
||||||
|
const {share, project, tasks} = prepareLinkShare()
|
||||||
|
|
||||||
|
cy.visit(`/tasks/${tasks[0].id}#share-auth-token=${share.hash}`)
|
||||||
|
|
||||||
|
cy.get('h1.title')
|
||||||
|
.should('contain', tasks[0].title)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,17 +1,15 @@
|
||||||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||||
|
|
||||||
import {ListFactory} from '../../factories/list'
|
import {ProjectFactory} from '../../factories/project'
|
||||||
import {seed} from '../../support/seed'
|
import {seed} from '../../support/seed'
|
||||||
import {TaskFactory} from '../../factories/task'
|
import {TaskFactory} from '../../factories/task'
|
||||||
import {NamespaceFactory} from '../../factories/namespace'
|
|
||||||
import {BucketFactory} from '../../factories/bucket'
|
import {BucketFactory} from '../../factories/bucket'
|
||||||
import {updateUserSettings} from '../../support/updateUserSettings'
|
import {updateUserSettings} from '../../support/updateUserSettings'
|
||||||
|
|
||||||
function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
|
function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
|
||||||
NamespaceFactory.create(1)
|
const project = ProjectFactory.create()[0]
|
||||||
const list = ListFactory.create()[0]
|
|
||||||
BucketFactory.create(1, {
|
BucketFactory.create(1, {
|
||||||
list_id: list.id,
|
project_id: project.id,
|
||||||
})
|
})
|
||||||
const tasks = []
|
const tasks = []
|
||||||
let dueDate = startDueDate
|
let dueDate = startDueDate
|
||||||
|
@ -20,7 +18,7 @@ function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
|
||||||
dueDate = new Date(new Date(dueDate).setDate(dueDate.getDate() + 2))
|
dueDate = new Date(new Date(dueDate).setDate(dueDate.getDate() + 2))
|
||||||
tasks.push({
|
tasks.push({
|
||||||
id: i + 1,
|
id: i + 1,
|
||||||
list_id: list.id,
|
project_id: project.id,
|
||||||
done: false,
|
done: false,
|
||||||
created_by_id: 1,
|
created_by_id: 1,
|
||||||
title: 'Test Task ' + i,
|
title: 'Test Task ' + i,
|
||||||
|
@ -31,7 +29,7 @@ function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
seed(TaskFactory.table, tasks)
|
seed(TaskFactory.table, tasks)
|
||||||
return {tasks, list}
|
return {tasks, project}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Home Page Task Overview', () => {
|
describe('Home Page Task Overview', () => {
|
||||||
|
@ -73,7 +71,7 @@ describe('Home Page Task Overview', () => {
|
||||||
due_date: new Date().toISOString(),
|
due_date: new Date().toISOString(),
|
||||||
}, false)
|
}, false)
|
||||||
|
|
||||||
cy.visit(`/lists/${tasks[0].list_id}/list`)
|
cy.visit(`/projects/${tasks[0].project_id}/list`)
|
||||||
cy.get('.tasks .task')
|
cy.get('.tasks .task')
|
||||||
.first()
|
.first()
|
||||||
.should('contain.text', newTaskTitle)
|
.should('contain.text', newTaskTitle)
|
||||||
|
@ -90,7 +88,7 @@ describe('Home Page Task Overview', () => {
|
||||||
|
|
||||||
cy.visit('/')
|
cy.visit('/')
|
||||||
|
|
||||||
cy.visit(`/lists/${tasks[0].list_id}/list`)
|
cy.visit(`/projects/${tasks[0].project_id}/list`)
|
||||||
cy.get('.task-add textarea')
|
cy.get('.task-add textarea')
|
||||||
.type(newTaskTitle+'{enter}')
|
.type(newTaskTitle+'{enter}')
|
||||||
cy.visit('/')
|
cy.visit('/')
|
||||||
|
@ -113,10 +111,10 @@ describe('Home Page Task Overview', () => {
|
||||||
.should('contain.text', newTaskTitle)
|
.should('contain.text', newTaskTitle)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should show a task without a due date added via default list at the bottom', () => {
|
it('Should show a task without a due date added via default project at the bottom', () => {
|
||||||
const {list} = seedTasks(40)
|
const {project} = seedTasks(40)
|
||||||
updateUserSettings({
|
updateUserSettings({
|
||||||
default_list_id: list.id,
|
default_project_id: project.id,
|
||||||
overdue_tasks_reminders_time: '9:00',
|
overdue_tasks_reminders_time: '9:00',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -131,23 +129,22 @@ describe('Home Page Task Overview', () => {
|
||||||
.should('contain.text', newTaskTitle)
|
.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()
|
TaskFactory.truncate()
|
||||||
|
|
||||||
cy.visit('/')
|
cy.visit('/')
|
||||||
|
|
||||||
cy.get('.home.app-content .content')
|
cy.get('.home.app-content .content')
|
||||||
.should('contain.text', 'You can create a new list for your new tasks:')
|
.should('contain.text', 'Import your projects and tasks from other services into Vikunja:')
|
||||||
.should('contain.text', 'Or import your lists 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()
|
seedTasks()
|
||||||
|
|
||||||
cy.visit('/')
|
cy.visit('/')
|
||||||
|
|
||||||
cy.get('.home.app-content .content')
|
cy.get('.home.app-content .content')
|
||||||
.should('not.contain.text', 'You can create a new list for your new tasks:')
|
.should('not.contain.text', 'You can create a new project for your new tasks:')
|
||||||
.should('not.contain.text', 'Or import your lists and tasks from other services into Vikunja:')
|
.should('not.contain.text', 'Or import your projects and tasks from other services into Vikunja:')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,17 +1,17 @@
|
||||||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||||
|
|
||||||
import {TaskFactory} from '../../factories/task'
|
import {TaskFactory} from '../../factories/task'
|
||||||
import {ListFactory} from '../../factories/list'
|
import {ProjectFactory} from '../../factories/project'
|
||||||
import {TaskCommentFactory} from '../../factories/task_comment'
|
import {TaskCommentFactory} from '../../factories/task_comment'
|
||||||
import {UserFactory} from '../../factories/user'
|
import {UserFactory} from '../../factories/user'
|
||||||
import {NamespaceFactory} from '../../factories/namespace'
|
import {UserProjectFactory} from '../../factories/users_project'
|
||||||
import {UserListFactory} from '../../factories/users_list'
|
|
||||||
import {TaskAssigneeFactory} from '../../factories/task_assignee'
|
import {TaskAssigneeFactory} from '../../factories/task_assignee'
|
||||||
import {LabelFactory} from '../../factories/labels'
|
import {LabelFactory} from '../../factories/labels'
|
||||||
import {LabelTaskFactory} from '../../factories/label_task'
|
import {LabelTaskFactory} from '../../factories/label_task'
|
||||||
import {BucketFactory} from '../../factories/bucket'
|
import {BucketFactory} from '../../factories/bucket'
|
||||||
|
|
||||||
import {TaskAttachmentFactory} from '../../factories/task_attachments'
|
import {TaskAttachmentFactory} from '../../factories/task_attachments'
|
||||||
|
import {TaskReminderFactory} from '../../factories/task_reminders'
|
||||||
|
|
||||||
function addLabelToTaskAndVerify(labelTitle: string) {
|
function addLabelToTaskAndVerify(labelTitle: string) {
|
||||||
cy.get('.task-view .action-buttons .button')
|
cy.get('.task-view .action-buttons .button')
|
||||||
|
@ -24,7 +24,7 @@ function addLabelToTaskAndVerify(labelTitle: string) {
|
||||||
.first()
|
.first()
|
||||||
.click()
|
.click()
|
||||||
|
|
||||||
cy.get('.global-notification', { timeout: 4000 })
|
cy.get('.global-notification', {timeout: 4000})
|
||||||
.should('contain', 'Success')
|
.should('contain', 'Success')
|
||||||
cy.get('.task-view .details.labels-list .multiselect .input-wrapper span.tag')
|
cy.get('.task-view .details.labels-list .multiselect .input-wrapper span.tag')
|
||||||
.should('exist')
|
.should('exist')
|
||||||
|
@ -36,7 +36,7 @@ function uploadAttachmentAndVerify(taskId: number) {
|
||||||
cy.get('.task-view .action-buttons .button')
|
cy.get('.task-view .action-buttons .button')
|
||||||
.contains('Add Attachments')
|
.contains('Add Attachments')
|
||||||
.click()
|
.click()
|
||||||
cy.get('input[type=file]', {timeout: 1000})
|
cy.get('input[type=file]#files', {timeout: 1000})
|
||||||
.selectFile('cypress/fixtures/image.jpg', {force: true}) // The input is not visible, but on purpose
|
.selectFile('cypress/fixtures/image.jpg', {force: true}) // The input is not visible, but on purpose
|
||||||
cy.wait('@uploadAttachment')
|
cy.wait('@uploadAttachment')
|
||||||
|
|
||||||
|
@ -47,23 +47,21 @@ function uploadAttachmentAndVerify(taskId: number) {
|
||||||
describe('Task', () => {
|
describe('Task', () => {
|
||||||
createFakeUserAndLogin()
|
createFakeUserAndLogin()
|
||||||
|
|
||||||
let namespaces
|
let projects
|
||||||
let lists
|
|
||||||
let buckets
|
let buckets
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// UserFactory.create(1)
|
// UserFactory.create(1)
|
||||||
namespaces = NamespaceFactory.create(1)
|
projects = ProjectFactory.create(1)
|
||||||
lists = ListFactory.create(1)
|
|
||||||
buckets = BucketFactory.create(1, {
|
buckets = BucketFactory.create(1, {
|
||||||
list_id: lists[0].id,
|
project_id: projects[0].id,
|
||||||
})
|
})
|
||||||
TaskFactory.truncate()
|
TaskFactory.truncate()
|
||||||
UserListFactory.truncate()
|
UserProjectFactory.truncate()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should be created new', () => {
|
it('Should be created new', () => {
|
||||||
cy.visit('/lists/1/list')
|
cy.visit('/projects/1/list')
|
||||||
cy.get('.input[placeholder="Add a new task…"')
|
cy.get('.input[placeholder="Add a new task…"')
|
||||||
.type('New Task')
|
.type('New Task')
|
||||||
cy.get('.button')
|
cy.get('.button')
|
||||||
|
@ -74,11 +72,11 @@ describe('Task', () => {
|
||||||
.should('contain', 'New 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)
|
TaskFactory.create(1)
|
||||||
|
|
||||||
cy.visit('/lists/1/list')
|
cy.visit('/projects/1/list')
|
||||||
cy.get('.list-is-empty-notice')
|
cy.get('.project-is-empty-notice')
|
||||||
.should('not.exist')
|
.should('not.exist')
|
||||||
cy.get('.input[placeholder="Add a new task…"')
|
cy.get('.input[placeholder="Add a new task…"')
|
||||||
.type('New Task')
|
.type('New Task')
|
||||||
|
@ -95,8 +93,8 @@ describe('Task', () => {
|
||||||
it('Marks a task as done', () => {
|
it('Marks a task as done', () => {
|
||||||
TaskFactory.create(1)
|
TaskFactory.create(1)
|
||||||
|
|
||||||
cy.visit('/lists/1/list')
|
cy.visit('/projects/1/list')
|
||||||
cy.get('.tasks .task .fancycheckbox label.check')
|
cy.get('.tasks .task .fancycheckbox')
|
||||||
.first()
|
.first()
|
||||||
.click()
|
.click()
|
||||||
cy.get('.global-notification')
|
cy.get('.global-notification')
|
||||||
|
@ -106,25 +104,65 @@ describe('Task', () => {
|
||||||
it('Can add a task to favorites', () => {
|
it('Can add a task to favorites', () => {
|
||||||
TaskFactory.create(1)
|
TaskFactory.create(1)
|
||||||
|
|
||||||
cy.visit('/lists/1/list')
|
cy.visit('/projects/1/list')
|
||||||
cy.get('.tasks .task .favorite')
|
cy.get('.tasks .task .favorite')
|
||||||
.first()
|
.first()
|
||||||
.click()
|
.click()
|
||||||
cy.get('.menu.namespaces-lists')
|
cy.get('.menu-container')
|
||||||
.should('contain', 'Favorites')
|
.should('contain', 'Favorites')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should show a task description icon if the task has a description', () => {
|
||||||
|
cy.intercept(Cypress.env('API_URL') + '/projects/1/tasks**').as('loadTasks')
|
||||||
|
TaskFactory.create(1, {
|
||||||
|
description: 'Lorem Ipsum',
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.visit('/projects/1/list')
|
||||||
|
cy.wait('@loadTasks')
|
||||||
|
|
||||||
|
cy.get('.tasks .task .project-task-icon')
|
||||||
|
.should('exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should not show a task description icon if the task has an empty description', () => {
|
||||||
|
cy.intercept(Cypress.env('API_URL') + '/projects/1/tasks**').as('loadTasks')
|
||||||
|
TaskFactory.create(1, {
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.visit('/projects/1/list')
|
||||||
|
cy.wait('@loadTasks')
|
||||||
|
|
||||||
|
cy.get('.tasks .task .project-task-icon')
|
||||||
|
.should('not.exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should not show a task description icon if the task has a description containing only an empty p tag', () => {
|
||||||
|
cy.intercept(Cypress.env('API_URL') + '/projects/1/tasks**').as('loadTasks')
|
||||||
|
TaskFactory.create(1, {
|
||||||
|
description: '<p></p>',
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.visit('/projects/1/list')
|
||||||
|
cy.wait('@loadTasks')
|
||||||
|
|
||||||
|
cy.get('.tasks .task .project-task-icon')
|
||||||
|
.should('not.exist')
|
||||||
|
})
|
||||||
|
|
||||||
describe('Task Detail View', () => {
|
describe('Task Detail View', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TaskCommentFactory.truncate()
|
TaskCommentFactory.truncate()
|
||||||
LabelTaskFactory.truncate()
|
LabelTaskFactory.truncate()
|
||||||
|
TaskAttachmentFactory.truncate()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Shows all task details', () => {
|
it('Shows all task details', () => {
|
||||||
const tasks = TaskFactory.create(1, {
|
const tasks = TaskFactory.create(1, {
|
||||||
id: 1,
|
id: 1,
|
||||||
index: 1,
|
index: 1,
|
||||||
description: 'Lorem ipsum dolor sit amet.'
|
description: 'Lorem ipsum dolor sit amet.',
|
||||||
})
|
})
|
||||||
cy.visit(`/tasks/${tasks[0].id}`)
|
cy.visit(`/tasks/${tasks[0].id}`)
|
||||||
|
|
||||||
|
@ -133,8 +171,7 @@ describe('Task', () => {
|
||||||
cy.get('.task-view h1.title.task-id')
|
cy.get('.task-view h1.title.task-id')
|
||||||
.should('contain', '#1')
|
.should('contain', '#1')
|
||||||
cy.get('.task-view h6.subtitle')
|
cy.get('.task-view h6.subtitle')
|
||||||
.should('contain', namespaces[0].title)
|
.should('contain', projects[0].title)
|
||||||
.should('contain', lists[0].title)
|
|
||||||
cy.get('.task-view .details.content.description')
|
cy.get('.task-view .details.content.description')
|
||||||
.should('contain', tasks[0].description)
|
.should('contain', tasks[0].description)
|
||||||
cy.get('.task-view .action-buttons p.created')
|
cy.get('.task-view .action-buttons p.created')
|
||||||
|
@ -146,7 +183,7 @@ describe('Task', () => {
|
||||||
id: 1,
|
id: 1,
|
||||||
index: 1,
|
index: 1,
|
||||||
done: true,
|
done: true,
|
||||||
done_at: new Date().toISOString()
|
done_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
cy.visit(`/tasks/${tasks[0].id}`)
|
cy.visit(`/tasks/${tasks[0].id}`)
|
||||||
|
|
||||||
|
@ -179,33 +216,33 @@ describe('Task', () => {
|
||||||
.should('contain', 'Mark as undone')
|
.should('contain', 'Mark as undone')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Shows a task identifier since the list has one', () => {
|
it('Shows a task identifier since the project has one', () => {
|
||||||
const lists = ListFactory.create(1, {
|
const projects = ProjectFactory.create(1, {
|
||||||
id: 1,
|
id: 1,
|
||||||
identifier: 'TEST',
|
identifier: 'TEST',
|
||||||
})
|
})
|
||||||
const tasks = TaskFactory.create(1, {
|
const tasks = TaskFactory.create(1, {
|
||||||
id: 1,
|
id: 1,
|
||||||
list_id: lists[0].id,
|
project_id: projects[0].id,
|
||||||
index: 1,
|
index: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
cy.visit(`/tasks/${tasks[0].id}`)
|
cy.visit(`/tasks/${tasks[0].id}`)
|
||||||
|
|
||||||
cy.get('.task-view h1.title.task-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', () => {
|
it('Can edit the description', () => {
|
||||||
const tasks = TaskFactory.create(1, {
|
const tasks = TaskFactory.create(1, {
|
||||||
id: 1,
|
id: 1,
|
||||||
description: 'Lorem ipsum dolor sit amet.'
|
description: 'Lorem ipsum dolor sit amet.',
|
||||||
})
|
})
|
||||||
cy.visit(`/tasks/${tasks[0].id}`)
|
cy.visit(`/tasks/${tasks[0].id}`)
|
||||||
|
|
||||||
cy.get('.task-view .details.content.description .editor button')
|
cy.get('.task-view .details.content.description .tiptap button.done-edit')
|
||||||
.click()
|
.click()
|
||||||
cy.get('.task-view .details.content.description .editor .vue-easymde .EasyMDEContainer .CodeMirror-scroll')
|
cy.get('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror')
|
||||||
.type('{selectall}New Description')
|
.type('{selectall}New Description')
|
||||||
cy.get('[data-cy="saveEditor"]')
|
cy.get('[data-cy="saveEditor"]')
|
||||||
.contains('Save')
|
.contains('Save')
|
||||||
|
@ -216,13 +253,52 @@ describe('Task', () => {
|
||||||
.should('exist')
|
.should('exist')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Shows an empty editor when the description of a task is empty', () => {
|
||||||
|
const tasks = TaskFactory.create(1, {
|
||||||
|
id: 1,
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
cy.visit(`/tasks/${tasks[0].id}`)
|
||||||
|
|
||||||
|
cy.get('.task-view .details.content.description .tiptap.ProseMirror p')
|
||||||
|
.should('have.attr', 'data-placeholder')
|
||||||
|
cy.get('.task-view .details.content.description .tiptap button.done-edit')
|
||||||
|
.should('not.exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Shows a preview editor when the description of a task is not empty', () => {
|
||||||
|
const tasks = TaskFactory.create(1, {
|
||||||
|
id: 1,
|
||||||
|
description: 'Lorem Ipsum dolor sit amet',
|
||||||
|
})
|
||||||
|
cy.visit(`/tasks/${tasks[0].id}`)
|
||||||
|
|
||||||
|
cy.get('.task-view .details.content.description .tiptap.ProseMirror p')
|
||||||
|
.should('not.have.attr', 'data-placeholder')
|
||||||
|
cy.get('.task-view .details.content.description .tiptap button.done-edit')
|
||||||
|
.should('exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Shows a preview editor when the description of a task contains html', () => {
|
||||||
|
const tasks = TaskFactory.create(1, {
|
||||||
|
id: 1,
|
||||||
|
description: '<p>Lorem Ipsum dolor sit amet</p>',
|
||||||
|
})
|
||||||
|
cy.visit(`/tasks/${tasks[0].id}`)
|
||||||
|
|
||||||
|
cy.get('.task-view .details.content.description .tiptap.ProseMirror p')
|
||||||
|
.should('not.have.attr', 'data-placeholder')
|
||||||
|
cy.get('.task-view .details.content.description .tiptap button.done-edit')
|
||||||
|
.should('exist')
|
||||||
|
})
|
||||||
|
|
||||||
it('Can add a new comment', () => {
|
it('Can add a new comment', () => {
|
||||||
const tasks = TaskFactory.create(1, {
|
const tasks = TaskFactory.create(1, {
|
||||||
id: 1,
|
id: 1,
|
||||||
})
|
})
|
||||||
cy.visit(`/tasks/${tasks[0].id}`)
|
cy.visit(`/tasks/${tasks[0].id}`)
|
||||||
|
|
||||||
cy.get('.task-view .comments .media.comment .editor .vue-easymde .EasyMDEContainer .CodeMirror-scroll')
|
cy.get('.task-view .comments .media.comment .tiptap__editor .tiptap.ProseMirror')
|
||||||
.should('be.visible')
|
.should('be.visible')
|
||||||
.type('{selectall}New Comment')
|
.type('{selectall}New Comment')
|
||||||
cy.get('.task-view .comments .media.comment .button:not([disabled])')
|
cy.get('.task-view .comments .media.comment .button:not([disabled])')
|
||||||
|
@ -230,20 +306,20 @@ describe('Task', () => {
|
||||||
.should('be.visible')
|
.should('be.visible')
|
||||||
.click()
|
.click()
|
||||||
|
|
||||||
cy.get('.task-view .comments .media.comment .editor')
|
cy.get('.task-view .comments .media.comment .tiptap__editor')
|
||||||
.should('contain', 'New Comment')
|
.should('contain', 'New Comment')
|
||||||
cy.get('.global-notification')
|
cy.get('.global-notification')
|
||||||
.should('contain', 'Success')
|
.should('contain', 'Success')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Can move a task to another list', () => {
|
it('Can move a task to another project', () => {
|
||||||
const lists = ListFactory.create(2)
|
const projects = ProjectFactory.create(2)
|
||||||
BucketFactory.create(2, {
|
BucketFactory.create(2, {
|
||||||
list_id: '{increment}'
|
project_id: '{increment}',
|
||||||
})
|
})
|
||||||
const tasks = TaskFactory.create(1, {
|
const tasks = TaskFactory.create(1, {
|
||||||
id: 1,
|
id: 1,
|
||||||
list_id: lists[0].id,
|
project_id: projects[0].id,
|
||||||
})
|
})
|
||||||
cy.visit(`/tasks/${tasks[0].id}`)
|
cy.visit(`/tasks/${tasks[0].id}`)
|
||||||
|
|
||||||
|
@ -251,7 +327,7 @@ describe('Task', () => {
|
||||||
.contains('Move')
|
.contains('Move')
|
||||||
.click()
|
.click()
|
||||||
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
|
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
|
// 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.
|
// 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')
|
cy.get('.task-view .content.details .field .multiselect.control .search-results')
|
||||||
|
@ -260,8 +336,7 @@ describe('Task', () => {
|
||||||
.click()
|
.click()
|
||||||
|
|
||||||
cy.get('.task-view h6.subtitle')
|
cy.get('.task-view h6.subtitle')
|
||||||
.should('contain', namespaces[0].title)
|
.should('contain', projects[1].title)
|
||||||
.should('contain', lists[1].title)
|
|
||||||
cy.get('.global-notification')
|
cy.get('.global-notification')
|
||||||
.should('contain', 'Success')
|
.should('contain', 'Success')
|
||||||
})
|
})
|
||||||
|
@ -269,7 +344,7 @@ describe('Task', () => {
|
||||||
it('Can delete a task', () => {
|
it('Can delete a task', () => {
|
||||||
const tasks = TaskFactory.create(1, {
|
const tasks = TaskFactory.create(1, {
|
||||||
id: 1,
|
id: 1,
|
||||||
list_id: 1,
|
project_id: 1,
|
||||||
})
|
})
|
||||||
cy.visit(`/tasks/${tasks[0].id}`)
|
cy.visit(`/tasks/${tasks[0].id}`)
|
||||||
|
|
||||||
|
@ -286,17 +361,17 @@ describe('Task', () => {
|
||||||
cy.get('.global-notification')
|
cy.get('.global-notification')
|
||||||
.should('contain', 'Success')
|
.should('contain', 'Success')
|
||||||
cy.url()
|
cy.url()
|
||||||
.should('contain', `/lists/${tasks[0].list_id}/`)
|
.should('contain', `/projects/${tasks[0].project_id}/`)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Can add an assignee to a task', () => {
|
it('Can add an assignee to a task', () => {
|
||||||
const users = UserFactory.create(5)
|
const users = UserFactory.create(5)
|
||||||
const tasks = TaskFactory.create(1, {
|
const tasks = TaskFactory.create(1, {
|
||||||
id: 1,
|
id: 1,
|
||||||
list_id: 1,
|
project_id: 1,
|
||||||
})
|
})
|
||||||
UserListFactory.create(5, {
|
UserProjectFactory.create(5, {
|
||||||
list_id: 1,
|
project_id: 1,
|
||||||
user_id: '{increment}',
|
user_id: '{increment}',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -321,10 +396,10 @@ describe('Task', () => {
|
||||||
const users = UserFactory.create(2)
|
const users = UserFactory.create(2)
|
||||||
const tasks = TaskFactory.create(1, {
|
const tasks = TaskFactory.create(1, {
|
||||||
id: 1,
|
id: 1,
|
||||||
list_id: 1,
|
project_id: 1,
|
||||||
})
|
})
|
||||||
UserListFactory.create(5, {
|
UserProjectFactory.create(5, {
|
||||||
list_id: 1,
|
project_id: 1,
|
||||||
user_id: '{increment}',
|
user_id: '{increment}',
|
||||||
})
|
})
|
||||||
TaskAssigneeFactory.create(1, {
|
TaskAssigneeFactory.create(1, {
|
||||||
|
@ -347,7 +422,7 @@ describe('Task', () => {
|
||||||
it('Can add a new label to a task', () => {
|
it('Can add a new label to a task', () => {
|
||||||
const tasks = TaskFactory.create(1, {
|
const tasks = TaskFactory.create(1, {
|
||||||
id: 1,
|
id: 1,
|
||||||
list_id: 1,
|
project_id: 1,
|
||||||
})
|
})
|
||||||
LabelFactory.truncate()
|
LabelFactory.truncate()
|
||||||
const newLabelText = 'some new label'
|
const newLabelText = 'some new label'
|
||||||
|
@ -375,7 +450,7 @@ describe('Task', () => {
|
||||||
it('Can add an existing label to a task', () => {
|
it('Can add an existing label to a task', () => {
|
||||||
const tasks = TaskFactory.create(1, {
|
const tasks = TaskFactory.create(1, {
|
||||||
id: 1,
|
id: 1,
|
||||||
list_id: 1,
|
project_id: 1,
|
||||||
})
|
})
|
||||||
const labels = LabelFactory.create(1)
|
const labels = LabelFactory.create(1)
|
||||||
LabelTaskFactory.truncate()
|
LabelTaskFactory.truncate()
|
||||||
|
@ -384,27 +459,27 @@ describe('Task', () => {
|
||||||
|
|
||||||
addLabelToTaskAndVerify(labels[0].title)
|
addLabelToTaskAndVerify(labels[0].title)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Can add a label to a task and it shows up on the kanban board afterwards', () => {
|
it('Can add a label to a task and it shows up on the kanban board afterwards', () => {
|
||||||
const tasks = TaskFactory.create(1, {
|
const tasks = TaskFactory.create(1, {
|
||||||
id: 1,
|
id: 1,
|
||||||
list_id: lists[0].id,
|
project_id: projects[0].id,
|
||||||
bucket_id: buckets[0].id,
|
bucket_id: buckets[0].id,
|
||||||
})
|
})
|
||||||
const labels = LabelFactory.create(1)
|
const labels = LabelFactory.create(1)
|
||||||
LabelTaskFactory.truncate()
|
LabelTaskFactory.truncate()
|
||||||
|
|
||||||
cy.visit(`/lists/${lists[0].id}/kanban`)
|
cy.visit(`/projects/${projects[0].id}/kanban`)
|
||||||
|
|
||||||
cy.get('.bucket .task')
|
cy.get('.bucket .task')
|
||||||
.contains(tasks[0].title)
|
.contains(tasks[0].title)
|
||||||
.click()
|
.click()
|
||||||
|
|
||||||
addLabelToTaskAndVerify(labels[0].title)
|
addLabelToTaskAndVerify(labels[0].title)
|
||||||
|
|
||||||
cy.get('.modal-content .close')
|
cy.get('.modal-content .close')
|
||||||
.click()
|
.click()
|
||||||
|
|
||||||
cy.get('.bucket .task')
|
cy.get('.bucket .task')
|
||||||
.should('contain.text', labels[0].title)
|
.should('contain.text', labels[0].title)
|
||||||
})
|
})
|
||||||
|
@ -412,7 +487,7 @@ describe('Task', () => {
|
||||||
it('Can remove a label from a task', () => {
|
it('Can remove a label from a task', () => {
|
||||||
const tasks = TaskFactory.create(1, {
|
const tasks = TaskFactory.create(1, {
|
||||||
id: 1,
|
id: 1,
|
||||||
list_id: 1,
|
project_id: 1,
|
||||||
})
|
})
|
||||||
const labels = LabelFactory.create(1)
|
const labels = LabelFactory.create(1)
|
||||||
LabelTaskFactory.create(1, {
|
LabelTaskFactory.create(1, {
|
||||||
|
@ -465,7 +540,235 @@ describe('Task', () => {
|
||||||
cy.get('.global-notification')
|
cy.get('.global-notification')
|
||||||
.should('contain', 'Success')
|
.should('contain', 'Success')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Can set a due date to a specific date for a task', () => {
|
||||||
|
const tasks = TaskFactory.create(1, {
|
||||||
|
id: 1,
|
||||||
|
done: false,
|
||||||
|
})
|
||||||
|
cy.visit(`/tasks/${tasks[0].id}`)
|
||||||
|
|
||||||
|
cy.get('.task-view .action-buttons .button')
|
||||||
|
.contains('Set Due Date')
|
||||||
|
.click()
|
||||||
|
cy.get('.task-view .columns.details .column')
|
||||||
|
.contains('Due Date')
|
||||||
|
.get('.date-input .datepicker .show')
|
||||||
|
.click()
|
||||||
|
cy.get('.datepicker-popup .flatpickr-innerContainer .flatpickr-days .flatpickr-day.today')
|
||||||
|
.click()
|
||||||
|
cy.get('[data-cy="closeDatepicker"]')
|
||||||
|
.contains('Confirm')
|
||||||
|
.click()
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const day = today.toLocaleString('default', {day: 'numeric'})
|
||||||
|
const month = today.toLocaleString('default', {month: 'short'})
|
||||||
|
const year = today.toLocaleString('default', {year: 'numeric'})
|
||||||
|
const date = `${day} ${month} ${year}, 12:00:00`
|
||||||
|
cy.get('.task-view .columns.details .column')
|
||||||
|
.contains('Due Date')
|
||||||
|
.get('.date-input .datepicker-popup')
|
||||||
|
.should('not.exist')
|
||||||
|
cy.get('.task-view .columns.details .column')
|
||||||
|
.contains('Due Date')
|
||||||
|
.get('.date-input')
|
||||||
|
.should('contain.text', date)
|
||||||
|
cy.get('.global-notification')
|
||||||
|
.should('contain', 'Success')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Can change a due date to a specific date for a task', () => {
|
||||||
|
const dueDate = new Date()
|
||||||
|
dueDate.setHours(12)
|
||||||
|
dueDate.setMinutes(0)
|
||||||
|
dueDate.setSeconds(0)
|
||||||
|
dueDate.setDate(1)
|
||||||
|
const tasks = TaskFactory.create(1, {
|
||||||
|
id: 1,
|
||||||
|
done: false,
|
||||||
|
due_date: dueDate.toISOString(),
|
||||||
|
})
|
||||||
|
cy.visit(`/tasks/${tasks[0].id}`)
|
||||||
|
|
||||||
|
cy.get('.task-view .action-buttons .button')
|
||||||
|
.contains('Set Due Date')
|
||||||
|
.click()
|
||||||
|
cy.get('.task-view .columns.details .column')
|
||||||
|
.contains('Due Date')
|
||||||
|
.get('.date-input .datepicker .show')
|
||||||
|
.click()
|
||||||
|
cy.get('.datepicker-popup .flatpickr-innerContainer .flatpickr-days .flatpickr-day.today')
|
||||||
|
.click()
|
||||||
|
cy.get('[data-cy="closeDatepicker"]')
|
||||||
|
.contains('Confirm')
|
||||||
|
.click()
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const day = today.toLocaleString('default', {day: 'numeric'})
|
||||||
|
const month = today.toLocaleString('default', {month: 'short'})
|
||||||
|
const year = today.toLocaleString('default', {year: 'numeric'})
|
||||||
|
const date = `${day} ${month} ${year}, 12:00:00`
|
||||||
|
cy.get('.task-view .columns.details .column')
|
||||||
|
.contains('Due Date')
|
||||||
|
.get('.date-input .datepicker-popup')
|
||||||
|
.should('not.exist')
|
||||||
|
cy.get('.task-view .columns.details .column')
|
||||||
|
.contains('Due Date')
|
||||||
|
.get('.date-input')
|
||||||
|
.should('contain.text', date)
|
||||||
|
cy.get('.global-notification')
|
||||||
|
.should('contain', 'Success')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Can set a reminder', () => {
|
||||||
|
TaskReminderFactory.truncate()
|
||||||
|
const tasks = TaskFactory.create(1, {
|
||||||
|
id: 1,
|
||||||
|
done: false,
|
||||||
|
})
|
||||||
|
cy.visit(`/tasks/${tasks[0].id}`)
|
||||||
|
|
||||||
|
cy.get('.task-view .action-buttons .button')
|
||||||
|
.contains('Set Reminders')
|
||||||
|
.click()
|
||||||
|
cy.get('.task-view .columns.details .column button')
|
||||||
|
.contains('Add a new reminder')
|
||||||
|
.click()
|
||||||
|
cy.get('.datepicker__quick-select-date')
|
||||||
|
.contains('Tomorrow')
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.get('.reminder-options-popup')
|
||||||
|
.should('not.be.visible')
|
||||||
|
cy.get('.global-notification')
|
||||||
|
.should('contain', 'Success')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Allows to set a relative reminder when the task already has a due date', () => {
|
||||||
|
TaskReminderFactory.truncate()
|
||||||
|
const tasks = TaskFactory.create(1, {
|
||||||
|
id: 1,
|
||||||
|
done: false,
|
||||||
|
due_date: (new Date()).toISOString(),
|
||||||
|
})
|
||||||
|
cy.visit(`/tasks/${tasks[0].id}`)
|
||||||
|
|
||||||
|
cy.get('.task-view .action-buttons .button')
|
||||||
|
.contains('Set Reminders')
|
||||||
|
.click()
|
||||||
|
cy.get('.task-view .columns.details .column button')
|
||||||
|
.contains('Add a new reminder')
|
||||||
|
.click()
|
||||||
|
cy.get('.datepicker__quick-select-date')
|
||||||
|
.should('not.exist')
|
||||||
|
cy.get('.reminder-options-popup .card-content')
|
||||||
|
.should('contain', '1 day before Due Date')
|
||||||
|
cy.get('.reminder-options-popup .card-content')
|
||||||
|
.contains('1 day before Due Date')
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.get('.reminder-options-popup')
|
||||||
|
.should('not.be.visible')
|
||||||
|
cy.get('.global-notification')
|
||||||
|
.should('contain', 'Success')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Allows to set a relative reminder when the task already has a start date', () => {
|
||||||
|
TaskReminderFactory.truncate()
|
||||||
|
const tasks = TaskFactory.create(1, {
|
||||||
|
id: 1,
|
||||||
|
done: false,
|
||||||
|
start_date: (new Date()).toISOString(),
|
||||||
|
})
|
||||||
|
cy.visit(`/tasks/${tasks[0].id}`)
|
||||||
|
|
||||||
|
cy.get('.task-view .action-buttons .button')
|
||||||
|
.contains('Set Reminders')
|
||||||
|
.click()
|
||||||
|
cy.get('.task-view .columns.details .column button')
|
||||||
|
.contains('Add a new reminder')
|
||||||
|
.click()
|
||||||
|
cy.get('.datepicker__quick-select-date')
|
||||||
|
.should('not.exist')
|
||||||
|
cy.get('.reminder-options-popup .card-content')
|
||||||
|
.should('contain', '1 day before Start Date')
|
||||||
|
cy.get('.reminder-options-popup .card-content')
|
||||||
|
.contains('1 day before Start Date')
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.get('.reminder-options-popup')
|
||||||
|
.should('not.be.visible')
|
||||||
|
cy.get('.global-notification')
|
||||||
|
.should('contain', 'Success')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Allows to set a custom relative reminder when the task already has a due date', () => {
|
||||||
|
TaskReminderFactory.truncate()
|
||||||
|
const tasks = TaskFactory.create(1, {
|
||||||
|
id: 1,
|
||||||
|
done: false,
|
||||||
|
due_date: (new Date()).toISOString(),
|
||||||
|
})
|
||||||
|
cy.visit(`/tasks/${tasks[0].id}`)
|
||||||
|
|
||||||
|
cy.get('.task-view .action-buttons .button')
|
||||||
|
.contains('Set Reminders')
|
||||||
|
.click()
|
||||||
|
cy.get('.task-view .columns.details .column button')
|
||||||
|
.contains('Add a new reminder')
|
||||||
|
.click()
|
||||||
|
cy.get('.datepicker__quick-select-date')
|
||||||
|
.should('not.exist')
|
||||||
|
cy.get('.reminder-options-popup .card-content')
|
||||||
|
.contains('Custom')
|
||||||
|
.click()
|
||||||
|
cy.get('.reminder-options-popup .card-content .reminder-period input')
|
||||||
|
.first()
|
||||||
|
.type('{selectall}10')
|
||||||
|
cy.get('.reminder-options-popup .card-content .reminder-period select')
|
||||||
|
.first()
|
||||||
|
.select('days')
|
||||||
|
cy.get('.reminder-options-popup .card-content button')
|
||||||
|
.contains('Confirm')
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.get('.reminder-options-popup')
|
||||||
|
.should('not.be.visible')
|
||||||
|
cy.get('.global-notification')
|
||||||
|
.should('contain', 'Success')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Allows to set a fixed reminder when the task already has a due date', () => {
|
||||||
|
TaskReminderFactory.truncate()
|
||||||
|
const tasks = TaskFactory.create(1, {
|
||||||
|
id: 1,
|
||||||
|
done: false,
|
||||||
|
due_date: (new Date()).toISOString(),
|
||||||
|
})
|
||||||
|
cy.visit(`/tasks/${tasks[0].id}`)
|
||||||
|
|
||||||
|
cy.get('.task-view .action-buttons .button')
|
||||||
|
.contains('Set Reminders')
|
||||||
|
.click()
|
||||||
|
cy.get('.task-view .columns.details .column button')
|
||||||
|
.contains('Add a new reminder')
|
||||||
|
.click()
|
||||||
|
cy.get('.datepicker__quick-select-date')
|
||||||
|
.should('not.exist')
|
||||||
|
cy.get('.reminder-options-popup .card-content')
|
||||||
|
.contains('Date and time')
|
||||||
|
.click()
|
||||||
|
cy.get('.datepicker__quick-select-date')
|
||||||
|
.contains('Tomorrow')
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.get('.reminder-options-popup')
|
||||||
|
.should('not.be.visible')
|
||||||
|
cy.get('.global-notification')
|
||||||
|
.should('contain', 'Success')
|
||||||
|
})
|
||||||
|
|
||||||
it('Can set a priority for a task', () => {
|
it('Can set a priority for a task', () => {
|
||||||
const tasks = TaskFactory.create(1, {
|
const tasks = TaskFactory.create(1, {
|
||||||
id: 1,
|
id: 1,
|
||||||
|
@ -503,7 +806,7 @@ describe('Task', () => {
|
||||||
.select('50%')
|
.select('50%')
|
||||||
cy.get('.global-notification')
|
cy.get('.global-notification')
|
||||||
.should('contain', 'Success')
|
.should('contain', 'Success')
|
||||||
|
|
||||||
cy.wait(200)
|
cy.wait(200)
|
||||||
|
|
||||||
cy.get('.task-view .columns.details .column')
|
cy.get('.task-view .columns.details .column')
|
||||||
|
@ -512,7 +815,7 @@ describe('Task', () => {
|
||||||
.should('be.visible')
|
.should('be.visible')
|
||||||
.should('have.value', '0.5')
|
.should('have.value', '0.5')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Can add an attachment to a task', () => {
|
it('Can add an attachment to a task', () => {
|
||||||
TaskAttachmentFactory.truncate()
|
TaskAttachmentFactory.truncate()
|
||||||
const tasks = TaskFactory.create(1, {
|
const tasks = TaskFactory.create(1, {
|
||||||
|
@ -527,13 +830,13 @@ describe('Task', () => {
|
||||||
TaskAttachmentFactory.truncate()
|
TaskAttachmentFactory.truncate()
|
||||||
const tasks = TaskFactory.create(1, {
|
const tasks = TaskFactory.create(1, {
|
||||||
id: 1,
|
id: 1,
|
||||||
list_id: lists[0].id,
|
project_id: projects[0].id,
|
||||||
bucket_id: buckets[0].id,
|
bucket_id: buckets[0].id,
|
||||||
})
|
})
|
||||||
const labels = LabelFactory.create(1)
|
const labels = LabelFactory.create(1)
|
||||||
LabelTaskFactory.truncate()
|
LabelTaskFactory.truncate()
|
||||||
|
|
||||||
cy.visit(`/lists/${lists[0].id}/kanban`)
|
cy.visit(`/projects/${projects[0].id}/kanban`)
|
||||||
|
|
||||||
cy.get('.bucket .task')
|
cy.get('.bucket .task')
|
||||||
.contains(tasks[0].title)
|
.contains(tasks[0].title)
|
||||||
|
@ -547,35 +850,119 @@ describe('Task', () => {
|
||||||
cy.get('.bucket .task .footer .icon svg.fa-paperclip')
|
cy.get('.bucket .task .footer .icon svg.fa-paperclip')
|
||||||
.should('exist')
|
.should('exist')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Can check items off a checklist', () => {
|
it('Can check items off a checklist', () => {
|
||||||
const tasks = TaskFactory.create(1, {
|
const tasks = TaskFactory.create(1, {
|
||||||
id: 1,
|
id: 1,
|
||||||
description: `
|
description: `
|
||||||
This is a checklist:
|
<ul data-type="taskList">
|
||||||
|
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||||
* [ ] one item
|
<div><p>First Item</p></div>
|
||||||
* [ ] another item
|
</li>
|
||||||
* [ ] third item
|
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||||
* [ ] fourth item
|
<div><p>Second Item</p></div>
|
||||||
* [x] and this one is already done
|
</li>
|
||||||
`,
|
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||||
|
<div><p>Third Item</p></div>
|
||||||
|
</li>
|
||||||
|
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||||
|
<div><p>Fourth Item</p></div>
|
||||||
|
</li>
|
||||||
|
<li data-checked="true" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||||
|
<div><p>Fifth Item</p></div>
|
||||||
|
</li>
|
||||||
|
</ul>`,
|
||||||
})
|
})
|
||||||
cy.visit(`/tasks/${tasks[0].id}`)
|
cy.visit(`/tasks/${tasks[0].id}`)
|
||||||
|
|
||||||
cy.get('.task-view .checklist-summary')
|
cy.get('.task-view .checklist-summary')
|
||||||
.should('contain.text', '1 of 5 tasks')
|
.should('contain.text', '1 of 5 tasks')
|
||||||
cy.get('.editor .content ul > li input[type=checkbox]')
|
cy.get('.tiptap__editor ul > li input[type=checkbox]')
|
||||||
.eq(2)
|
.eq(2)
|
||||||
.click()
|
.click()
|
||||||
|
|
||||||
cy.get('.editor .content ul > li input[type=checkbox]')
|
cy.get('.task-view .details.content.description h3 span.is-small.has-text-success')
|
||||||
|
.contains('Saved!')
|
||||||
|
.should('exist')
|
||||||
|
cy.get('.tiptap__editor ul > li input[type=checkbox]')
|
||||||
.eq(2)
|
.eq(2)
|
||||||
.should('be.checked')
|
.should('be.checked')
|
||||||
cy.get('.editor .content input[type=checkbox]')
|
cy.get('.tiptap__editor input[type=checkbox]')
|
||||||
.should('have.length', 5)
|
.should('have.length', 5)
|
||||||
cy.get('.task-view .checklist-summary')
|
cy.get('.task-view .checklist-summary')
|
||||||
.should('contain.text', '2 of 5 tasks')
|
.should('contain.text', '2 of 5 tasks')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should use the editor to render description', () => {
|
||||||
|
const tasks = TaskFactory.create(1, {
|
||||||
|
id: 1,
|
||||||
|
description: `
|
||||||
|
<h1>Lorem Ipsum</h1>
|
||||||
|
<p>Dolor sit amet</p>
|
||||||
|
<ul data-type="taskList">
|
||||||
|
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||||
|
<div><p>First Item</p></div>
|
||||||
|
</li>
|
||||||
|
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||||
|
<div><p>Second Item</p></div>
|
||||||
|
</li>
|
||||||
|
</ul>`,
|
||||||
|
})
|
||||||
|
cy.visit(`/tasks/${tasks[0].id}`)
|
||||||
|
|
||||||
|
cy.get('.tiptap__editor ul > li input[type=checkbox]')
|
||||||
|
.should('exist')
|
||||||
|
cy.get('.tiptap__editor h1')
|
||||||
|
.contains('Lorem Ipsum')
|
||||||
|
.should('exist')
|
||||||
|
cy.get('.tiptap__editor p')
|
||||||
|
.contains('Dolor sit amet')
|
||||||
|
.should('exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should render an image from attachment', async () => {
|
||||||
|
|
||||||
|
TaskAttachmentFactory.truncate()
|
||||||
|
|
||||||
|
const tasks = TaskFactory.create(1, {
|
||||||
|
id: 1,
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.readFile('cypress/fixtures/image.jpg', null).then(file => {
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('files', new Blob([file]), 'image.jpg')
|
||||||
|
|
||||||
|
cy.request({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `${Cypress.env('API_URL')}/tasks/${tasks[0].id}/attachments`,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${window.localStorage.getItem('token')}`,
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
.then(({body}) => {
|
||||||
|
const dec = new TextDecoder('utf-8')
|
||||||
|
const {success} = JSON.parse(dec.decode(body))
|
||||||
|
|
||||||
|
TaskFactory.create(1, {
|
||||||
|
id: 1,
|
||||||
|
description: `<img src="${Cypress.env('API_URL')}/tasks/${tasks[0].id}/attachments/${success[0].id}" alt="test image">`,
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.visit(`/tasks/${tasks[0].id}`)
|
||||||
|
|
||||||
|
cy.get('.tiptap__editor img')
|
||||||
|
.should('be.visible')
|
||||||
|
.and(($img) => {
|
||||||
|
// "naturalWidth" and "naturalHeight" are set when the image loads
|
||||||
|
expect($img[0].naturalWidth).to.be.greaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
{
|
{
|
||||||
"extends": "@vue/tsconfig/tsconfig.web.json",
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
"include": ["./**/*", "../support/**/*", "../factories/**/*"],
|
"include": ["./**/*", "../support/**/*", "../factories/**/*"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"isolatedModules": false,
|
"isolatedModules": false,
|
||||||
"target": "ES2015",
|
"target": "ES2015",
|
||||||
"lib": ["ESNext", "dom"],
|
"lib": ["ESNext", "dom"],
|
||||||
"types": ["cypress"]
|
"types": ["cypress"],
|
||||||
|
"ignoreDeprecations": "5.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,36 +1,41 @@
|
||||||
import {UserFactory} from '../../factories/user'
|
import {UserFactory} from '../../factories/user'
|
||||||
|
import {ProjectFactory} from '../../factories/project'
|
||||||
|
|
||||||
const testAndAssertFailed = fixture => {
|
const testAndAssertFailed = fixture => {
|
||||||
|
cy.intercept(Cypress.env('API_URL') + '/login*').as('login')
|
||||||
|
|
||||||
cy.visit('/login')
|
cy.visit('/login')
|
||||||
cy.get('input[id=username]').type(fixture.username)
|
cy.get('input[id=username]').type(fixture.username)
|
||||||
cy.get('input[id=password]').type(fixture.password)
|
cy.get('input[id=password]').type(fixture.password)
|
||||||
cy.get('.button').contains('Login').click()
|
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.url().should('include', '/')
|
||||||
cy.get('div.message.danger').contains('Wrong username or password.')
|
cy.get('div.message.danger').contains('Wrong username or password.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const username = 'test'
|
const credentials = {
|
||||||
|
username: 'test',
|
||||||
|
password: '1234',
|
||||||
|
}
|
||||||
|
|
||||||
|
function login() {
|
||||||
|
cy.get('input[id=username]').type(credentials.username)
|
||||||
|
cy.get('input[id=password]').type(credentials.password)
|
||||||
|
cy.get('.button').contains('Login').click()
|
||||||
|
cy.url().should('include', '/')
|
||||||
|
}
|
||||||
|
|
||||||
context('Login', () => {
|
context('Login', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
UserFactory.create(1, {username})
|
UserFactory.create(1, {username: credentials.username})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should log in with the right credentials', () => {
|
it('Should log in with the right credentials', () => {
|
||||||
const fixture = {
|
|
||||||
username: 'test',
|
|
||||||
password: '1234',
|
|
||||||
}
|
|
||||||
|
|
||||||
cy.visit('/login')
|
cy.visit('/login')
|
||||||
cy.get('input[id=username]').type(fixture.username)
|
login()
|
||||||
cy.get('input[id=password]').type(fixture.password)
|
|
||||||
cy.get('.button').contains('Login').click()
|
|
||||||
cy.url().should('include', '/')
|
|
||||||
cy.clock(1625656161057) // 13:00
|
cy.clock(1625656161057) // 13:00
|
||||||
cy.get('h2').should('contain', `Hi ${fixture.username}!`)
|
cy.get('h2').should('contain', `Hi ${credentials.username}!`)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should fail with a bad password', () => {
|
it('Should fail with a bad password', () => {
|
||||||
|
@ -55,4 +60,15 @@ context('Login', () => {
|
||||||
cy.visit('/')
|
cy.visit('/')
|
||||||
cy.url().should('include', '/login')
|
cy.url().should('include', '/login')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should redirect to the previous route after logging in', () => {
|
||||||
|
const projects = ProjectFactory.create(1)
|
||||||
|
cy.visit(`/projects/${projects[0].id}/list`)
|
||||||
|
|
||||||
|
cy.url().should('include', '/login')
|
||||||
|
|
||||||
|
login()
|
||||||
|
|
||||||
|
cy.url().should('include', `/projects/${projects[0].id}/list`)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||||
import {createLists} from '../list/prepareLists'
|
import {createProjects} from '../project/prepareProjects'
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
cy.get('.navbar .user .username')
|
cy.get('.navbar .username-dropdown-trigger')
|
||||||
.click()
|
.click()
|
||||||
cy.get('.navbar .user .dropdown-menu .dropdown-item')
|
cy.get('.navbar .dropdown-item')
|
||||||
.contains('Logout')
|
.contains('Logout')
|
||||||
.click()
|
.click()
|
||||||
}
|
}
|
||||||
|
@ -26,21 +26,21 @@ describe('Log out', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it.skip('Should clear the list history after logging the user out', () => {
|
it.skip('Should clear the project history after logging the user out', () => {
|
||||||
const lists = createLists()
|
const projects = createProjects()
|
||||||
cy.visit(`/lists/${lists[0].id}`)
|
cy.visit(`/projects/${projects[0].id}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
expect(localStorage.getItem('listHistory')).to.not.eq(null)
|
expect(localStorage.getItem('projectHistory')).to.not.eq(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
logout()
|
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()
|
cy.url()
|
||||||
.should('contain', '/login')
|
.should('contain', '/login')
|
||||||
.then(() => {
|
.then(() => {
|
||||||
expect(localStorage.getItem('listHistory')).to.eq(null)
|
expect(localStorage.getItem('projectHistory')).to.eq(null)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -17,7 +17,7 @@ context('Registration', () => {
|
||||||
it('Should work without issues', () => {
|
it('Should work without issues', () => {
|
||||||
const fixture = {
|
const fixture = {
|
||||||
username: 'testuser',
|
username: 'testuser',
|
||||||
password: '123456',
|
password: '12345678',
|
||||||
email: 'testuser@example.com',
|
email: 'testuser@example.com',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -31,10 +31,10 @@ context('Registration', () => {
|
||||||
cy.get('h2').should('contain', `Hi ${fixture.username}!`)
|
cy.get('h2').should('contain', `Hi ${fixture.username}!`)
|
||||||
})
|
})
|
||||||
|
|
||||||
it.only('Should fail', () => {
|
it('Should fail', () => {
|
||||||
const fixture = {
|
const fixture = {
|
||||||
username: 'test',
|
username: 'test',
|
||||||
password: '123456',
|
password: '12345678',
|
||||||
email: 'testuser@example.com',
|
email: 'testuser@example.com',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -37,7 +37,7 @@ describe('User Settings', () => {
|
||||||
|
|
||||||
cy.get('.global-notification')
|
cy.get('.global-notification')
|
||||||
.should('contain', 'Success')
|
.should('contain', 'Success')
|
||||||
cy.get('.navbar .user .username')
|
cy.get('.navbar .username-dropdown-trigger .username')
|
||||||
.should('contain', 'Lorem Ipsum')
|
.should('contain', 'Lorem Ipsum')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -10,7 +10,7 @@ export class BucketFactory extends Factory {
|
||||||
return {
|
return {
|
||||||
id: '{increment}',
|
id: '{increment}',
|
||||||
title: faker.lorem.words(3),
|
title: faker.lorem.words(3),
|
||||||
list_id: 1,
|
project_id: 1,
|
||||||
created_by_id: 1,
|
created_by_id: 1,
|
||||||
created: now.toISOString(),
|
created: now.toISOString(),
|
||||||
updated: now.toISOString(),
|
updated: now.toISOString(),
|
||||||
|
|
|
@ -10,7 +10,7 @@ export class LinkShareFactory extends Factory {
|
||||||
return {
|
return {
|
||||||
id: '{increment}',
|
id: '{increment}',
|
||||||
hash: faker.random.word(32),
|
hash: faker.random.word(32),
|
||||||
list_id: 1,
|
project_id: 1,
|
||||||
right: 0,
|
right: 0,
|
||||||
sharing_type: 0,
|
sharing_type: 0,
|
||||||
shared_by_id: 1,
|
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 {Factory} from '../support/factory'
|
||||||
|
import {faker} from '@faker-js/faker'
|
||||||
|
|
||||||
export class NamespaceFactory extends Factory {
|
export class ProjectFactory extends Factory {
|
||||||
static table = 'namespaces'
|
static table = 'projects'
|
||||||
|
|
||||||
static factory() {
|
static factory() {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
@ -15,4 +15,4 @@ export class NamespaceFactory extends Factory {
|
||||||
updated: now.toISOString(),
|
updated: now.toISOString(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -11,7 +11,7 @@ export class TaskFactory extends Factory {
|
||||||
id: '{increment}',
|
id: '{increment}',
|
||||||
title: faker.lorem.words(3),
|
title: faker.lorem.words(3),
|
||||||
done: false,
|
done: false,
|
||||||
list_id: 1,
|
project_id: 1,
|
||||||
created_by_id: 1,
|
created_by_id: 1,
|
||||||
index: '{increment}',
|
index: '{increment}',
|
||||||
position: '{increment}',
|
position: '{increment}',
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
import {Factory} from '../support/factory'
|
||||||
|
|
||||||
|
export class TaskReminderFactory extends Factory {
|
||||||
|
static table = 'task_reminders'
|
||||||
|
|
||||||
|
static factory() {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: '{increment}',
|
||||||
|
task_id: 1,
|
||||||
|
reminder: now.toISOString(),
|
||||||
|
created: now.toISOString(),
|
||||||
|
relative_to: '',
|
||||||
|
relative_period: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,14 +1,14 @@
|
||||||
import {Factory} from '../support/factory'
|
import {Factory} from '../support/factory'
|
||||||
|
|
||||||
export class UserListFactory extends Factory {
|
export class UserProjectFactory extends Factory {
|
||||||
static table = 'users_lists'
|
static table = 'users_projects'
|
||||||
|
|
||||||
static factory() {
|
static factory() {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: '{increment}',
|
id: '{increment}',
|
||||||
list_id: 1,
|
project_id: 1,
|
||||||
user_id: 1,
|
user_id: 1,
|
||||||
right: 0,
|
right: 0,
|
||||||
created: now.toISOString(),
|
created: now.toISOString(),
|
|
@ -4,7 +4,7 @@ import {seed} from './seed'
|
||||||
* A factory makes it easy to seed the database with data.
|
* A factory makes it easy to seed the database with data.
|
||||||
*/
|
*/
|
||||||
export class Factory {
|
export class Factory {
|
||||||
static table = null
|
static table: string | null = null
|
||||||
|
|
||||||
static factory() {
|
static factory() {
|
||||||
return {}
|
return {}
|
||||||
|
|
|
@ -11,5 +11,8 @@ 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.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_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.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
|
||||||
|
sed -ri "s:^(\s*window.ALLOW_ICON_CHANGES\s*=)\s*.+:\1 ${VIKUNJA_ALLOW_ICON_CHANGES}:g" /usr/share/nginx/html/index.html
|
||||||
|
sed -ri "s:^(\s*window.CUSTOM_LOGO_URL\s*=)\s*.+:\1 ${VIKUNJA_CUSTOM_LOGO_URL}:g" /usr/share/nginx/html/index.html
|
||||||
|
|
||||||
date -uIseconds | xargs echo 'info: started at'
|
date -uIseconds | xargs echo 'info: started at'
|
||||||
|
|
|
@ -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
|
|
@ -4,7 +4,6 @@
|
||||||
|
|
||||||
pid /tmp/nginx.pid;
|
pid /tmp/nginx.pid;
|
||||||
worker_processes auto;
|
worker_processes auto;
|
||||||
worker_rlimit_nofile 65535;
|
|
||||||
|
|
||||||
events {
|
events {
|
||||||
multi_accept on;
|
multi_accept on;
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
server {
|
server {
|
||||||
listen ${VIKUNJA_HTTP_PORT};
|
listen ${VIKUNJA_HTTP_PORT};
|
||||||
listen [::]:${VIKUNJA_HTTP_PORT};
|
listen [::]:${VIKUNJA_HTTP_PORT};
|
||||||
## Needed when behind HAProxy with SSL termination + HTTP/2 support
|
## 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;
|
||||||
listen [::]:${VIKUNJA_HTTP2_PORT} default_server http2 proxy_protocol;
|
listen [::]:${VIKUNJA_HTTP2_PORT} default_server http2 proxy_protocol;
|
||||||
|
|
||||||
|
@ -28,6 +28,20 @@ server {
|
||||||
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
|
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
|
||||||
try_files $uri /index.html =404;
|
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
|
# favicon.ico
|
||||||
location = /favicon.ico {
|
location = /favicon.ico {
|
||||||
|
|
|
@ -30,21 +30,21 @@ A basic service can look like this:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import AbstractService from './abstractService'
|
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() {
|
constructor() {
|
||||||
super({
|
super({
|
||||||
getAll: '/lists',
|
getAll: '/projects',
|
||||||
get: '/lists/{id}',
|
get: '/projects/{id}',
|
||||||
create: '/namespaces/{namespaceID}/lists',
|
create: '/namespaces/{namespaceID}/projects',
|
||||||
update: '/lists/{id}',
|
update: '/projects/{id}',
|
||||||
delete: '/lists/{id}',
|
delete: '/projects/{id}',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
modelFactory(data) {
|
modelFactory(data) {
|
||||||
return new ListModel(data)
|
return new ProjectModel(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
@ -132,7 +132,7 @@ import AbstractModel from './abstractModel'
|
||||||
import TaskModel from './task'
|
import TaskModel from './task'
|
||||||
import UserModel from './user'
|
import UserModel from './user'
|
||||||
|
|
||||||
export default class ListModel extends AbstractModel {
|
export default class ProjectModel extends AbstractModel {
|
||||||
|
|
||||||
constructor(data) {
|
constructor(data) {
|
||||||
// The constructor of AbstractModel handles all the default parsing.
|
// The constructor of AbstractModel handles all the default parsing.
|
||||||
|
|
|
@ -3,10 +3,31 @@
|
||||||
/// <reference types="cypress" />
|
/// <reference types="cypress" />
|
||||||
/// <reference types="@histoire/plugin-vue/components" />
|
/// <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 {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_IS_ONLINE: boolean
|
readonly VIKUNJA_API_URL?: string
|
||||||
|
readonly VIKUNJA_HTTP_PORT?: number
|
||||||
|
readonly VIKUNJA_HTTPS_PORT?: number
|
||||||
|
|
||||||
|
readonly VIKUNJA_SENTRY_ENABLED?: boolean
|
||||||
|
readonly VIKUNJA_SENTRY_DSN?: string
|
||||||
|
|
||||||
|
readonly SENTRY_AUTH_TOKEN?: string
|
||||||
|
readonly SENTRY_ORG?: string
|
||||||
|
readonly SENTRY_PROJECT?: string
|
||||||
|
|
||||||
|
readonly VITE_IS_ONLINE: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv
|
readonly env: ImportMetaEnv
|
||||||
}
|
}
|
|
@ -2,11 +2,11 @@
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1664753041,
|
"lastModified": 1701336116,
|
||||||
"narHash": "sha256-0ogaD8PaGHluARFeupofvk1Nq9gpVeZdlFM0Kcwguys=",
|
"narHash": "sha256-kEmpezCR/FpITc6yMbAh4WrOCiT2zg5pSjnKrq51h5Y=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "a62844b302507c7531ad68a86cb7aa54704c9cb4",
|
"rev": "f5c27c6136db4d76c30e533c20517df6864c46ee",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
|
@ -28,7 +28,7 @@ export default defineConfig({
|
||||||
// light: './img/light.png',
|
// light: './img/light.png',
|
||||||
// dark: './img/dark.png',
|
// dark: './img/dark.png',
|
||||||
// },
|
// },
|
||||||
// logoHref: 'https://acme.com',
|
logoHref: 'https://vikunja.io',
|
||||||
// favicon: './favicon.ico',
|
// favicon: './favicon.ico',
|
||||||
},
|
},
|
||||||
})
|
})
|
11
index.html
11
index.html
|
@ -18,15 +18,22 @@
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
<script>
|
<script>
|
||||||
//
|
//
|
||||||
// This variable points the frontend to the api.
|
// This variable points the frontend to the api.
|
||||||
// It has to be the full url, including the last /api/v1 part and port.
|
// It has to be the full url, including the last /api/v1 part and port.
|
||||||
// You can change this if your api is not reachable on the same port as the frontend.
|
// You can change this if your api is not reachable on the same port as the frontend.
|
||||||
window.API_URL = 'http://localhost:3456/api/v1'
|
window.API_URL = 'http://localhost:3456/api/v1'
|
||||||
// Enable error tracking with sentry. If this is set to true, will send anonymized data to
|
// Enable error tracking with sentry. If this is set to true, will send anonymized data to
|
||||||
// our sentry instance to notify us of potential problems.
|
// our sentry instance to notify us of potential problems.
|
||||||
window.SENTRY_ENABLED = false
|
window.SENTRY_ENABLED = false
|
||||||
window.SENTRY_DSN = 'https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480'
|
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
|
||||||
|
// Allow changing the logo and other icons based on various occasions throughout the year.
|
||||||
|
window.ALLOW_ICON_CHANGES = true
|
||||||
|
// Allow using a custom logo via external URL.
|
||||||
|
window.CUSTOM_LOGO_URL = ''
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
Binary file not shown.
Binary file not shown.
212
package.json
212
package.json
|
@ -13,7 +13,7 @@
|
||||||
},
|
},
|
||||||
"homepage": "https://vikunja.io/",
|
"homepage": "https://vikunja.io/",
|
||||||
"funding": "https://opencollective.com/vikunja",
|
"funding": "https://opencollective.com/vikunja",
|
||||||
"packageManager": "pnpm@7.26.2",
|
"packageManager": "pnpm@8.15.1",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"todo",
|
"todo",
|
||||||
"productivity",
|
"productivity",
|
||||||
|
@ -22,6 +22,7 @@
|
||||||
"gantt",
|
"gantt",
|
||||||
"kanban"
|
"kanban"
|
||||||
],
|
],
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"serve": "vite",
|
"serve": "vite",
|
||||||
"preview": "vite preview --port 4173",
|
"preview": "vite preview --port 4173",
|
||||||
|
@ -29,7 +30,8 @@
|
||||||
"build": "vite build && workbox copyLibraries dist/",
|
"build": "vite build && workbox copyLibraries dist/",
|
||||||
"build:modern-only": "BUILD_MODERN_ONLY=true vite build && workbox copyLibraries dist/",
|
"build:modern-only": "BUILD_MODERN_ONLY=true vite build && workbox copyLibraries dist/",
|
||||||
"build:dev": "vite build --mode development --outDir dist-dev/",
|
"build:dev": "vite build --mode development --outDir dist-dev/",
|
||||||
"lint": "eslint --ignore-pattern '*.test.*' ./src --ext .vue,.js,.ts",
|
"lint": "eslint 'src/**/*.{js,ts,vue}'",
|
||||||
|
"lint:fix": "pnpm run lint --fix",
|
||||||
"test:e2e": "start-server-and-test preview http://127.0.0.1:4173 'cypress run --e2e --browser chrome'",
|
"test:e2e": "start-server-and-test preview http://127.0.0.1:4173 'cypress run --e2e --browser chrome'",
|
||||||
"test:e2e-record": "start-server-and-test preview http://127.0.0.1:4173 'cypress run --e2e --browser chrome --record'",
|
"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-dev": "start-server-and-test preview:dev http://127.0.0.1:4173 'cypress open --e2e'",
|
||||||
|
@ -45,103 +47,141 @@
|
||||||
"story:preview": "histoire preview"
|
"story:preview": "histoire preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "6.2.1",
|
"@fortawesome/fontawesome-svg-core": "6.5.1",
|
||||||
"@fortawesome/free-regular-svg-icons": "6.2.1",
|
"@fortawesome/free-regular-svg-icons": "6.5.1",
|
||||||
"@fortawesome/free-solid-svg-icons": "6.2.1",
|
"@fortawesome/free-solid-svg-icons": "6.5.1",
|
||||||
"@fortawesome/vue-fontawesome": "3.0.3",
|
"@fortawesome/vue-fontawesome": "3.0.6",
|
||||||
"@github/hotkey": "2.0.1",
|
"@github/hotkey": "3.1.0",
|
||||||
"@infectoone/vue-ganttastic": "2.1.4",
|
"@infectoone/vue-ganttastic": "2.2.0",
|
||||||
"@intlify/unplugin-vue-i18n": "0.8.1",
|
"@intlify/unplugin-vue-i18n": "2.0.0",
|
||||||
"@kyvg/vue3-notification": "2.8.0",
|
"@kyvg/vue3-notification": "3.1.4",
|
||||||
"@sentry/tracing": "7.34.0",
|
"@sentry/tracing": "7.100.1",
|
||||||
"@sentry/vue": "7.34.0",
|
"@sentry/vue": "7.100.1",
|
||||||
"@types/is-touch-device": "1.0.0",
|
"@tiptap/core": "2.2.1",
|
||||||
"@types/lodash.clonedeep": "4.5.7",
|
"@tiptap/extension-blockquote": "2.2.1",
|
||||||
"@types/sortablejs": "1.15.0",
|
"@tiptap/extension-bold": "2.2.1",
|
||||||
"@vueuse/core": "9.12.0",
|
"@tiptap/extension-bullet-list": "2.2.1",
|
||||||
"axios": "1.2.6",
|
"@tiptap/extension-code": "2.2.1",
|
||||||
"blurhash": "2.0.4",
|
"@tiptap/extension-code-block-lowlight": "2.2.1",
|
||||||
|
"@tiptap/extension-document": "2.2.1",
|
||||||
|
"@tiptap/extension-dropcursor": "2.2.1",
|
||||||
|
"@tiptap/extension-gapcursor": "2.2.1",
|
||||||
|
"@tiptap/extension-hard-break": "2.2.1",
|
||||||
|
"@tiptap/extension-heading": "2.2.1",
|
||||||
|
"@tiptap/extension-history": "2.2.1",
|
||||||
|
"@tiptap/extension-horizontal-rule": "2.2.1",
|
||||||
|
"@tiptap/extension-image": "2.2.1",
|
||||||
|
"@tiptap/extension-italic": "2.2.1",
|
||||||
|
"@tiptap/extension-link": "2.2.1",
|
||||||
|
"@tiptap/extension-list-item": "2.2.1",
|
||||||
|
"@tiptap/extension-ordered-list": "2.2.1",
|
||||||
|
"@tiptap/extension-paragraph": "2.2.1",
|
||||||
|
"@tiptap/extension-placeholder": "2.2.1",
|
||||||
|
"@tiptap/extension-strike": "2.2.1",
|
||||||
|
"@tiptap/extension-table": "2.2.1",
|
||||||
|
"@tiptap/extension-table-cell": "2.2.1",
|
||||||
|
"@tiptap/extension-table-header": "2.2.1",
|
||||||
|
"@tiptap/extension-table-row": "2.2.1",
|
||||||
|
"@tiptap/extension-task-item": "2.2.1",
|
||||||
|
"@tiptap/extension-task-list": "2.2.1",
|
||||||
|
"@tiptap/extension-text": "2.2.1",
|
||||||
|
"@tiptap/extension-typography": "2.2.1",
|
||||||
|
"@tiptap/extension-underline": "2.2.1",
|
||||||
|
"@tiptap/pm": "2.2.1",
|
||||||
|
"@tiptap/suggestion": "2.2.1",
|
||||||
|
"@tiptap/vue-3": "2.2.1",
|
||||||
|
"@types/is-touch-device": "1.0.2",
|
||||||
|
"@types/lodash.clonedeep": "4.5.9",
|
||||||
|
"@vueuse/core": "10.7.2",
|
||||||
|
"@vueuse/router": "10.7.2",
|
||||||
|
"axios": "1.6.7",
|
||||||
|
"blurhash": "2.0.5",
|
||||||
"bulma-css-variables": "0.9.33",
|
"bulma-css-variables": "0.9.33",
|
||||||
"camel-case": "4.1.2",
|
"camel-case": "4.1.2",
|
||||||
"codemirror": "5.65.11",
|
"date-fns": "3.3.1",
|
||||||
"date-fns": "2.29.3",
|
"dayjs": "1.11.10",
|
||||||
"dayjs": "1.11.7",
|
"dompurify": "3.0.8",
|
||||||
"dompurify": "2.4.3",
|
|
||||||
"easymde": "2.18.0",
|
|
||||||
"fast-deep-equal": "3.1.3",
|
"fast-deep-equal": "3.1.3",
|
||||||
"flatpickr": "4.6.13",
|
"flatpickr": "4.6.13",
|
||||||
"flexsearch": "0.7.21",
|
"flexsearch": "0.7.31",
|
||||||
"floating-vue": "2.0.0-beta.20",
|
"floating-vue": "5.2.2",
|
||||||
"focus-within": "3.0.2",
|
|
||||||
"highlight.js": "11.7.0",
|
|
||||||
"is-touch-device": "1.0.1",
|
"is-touch-device": "1.0.1",
|
||||||
"lodash.clonedeep": "4.5.0",
|
"klona": "2.0.6",
|
||||||
"lodash.debounce": "4.0.8",
|
"lodash.debounce": "4.0.8",
|
||||||
"marked": "4.2.12",
|
"lowlight": "2.9.0",
|
||||||
"minimist": "1.2.7",
|
"pinia": "2.1.7",
|
||||||
"pinia": "2.0.29",
|
|
||||||
"register-service-worker": "1.7.2",
|
"register-service-worker": "1.7.2",
|
||||||
"snake-case": "3.0.4",
|
"snake-case": "3.0.4",
|
||||||
"sortablejs": "1.15.0",
|
"sortablejs": "1.15.2",
|
||||||
"ufo": "1.0.1",
|
"tippy.js": "6.3.7",
|
||||||
"vue": "3.2.45",
|
"ufo": "1.4.0",
|
||||||
|
"vue": "3.4.15",
|
||||||
"vue-advanced-cropper": "2.8.8",
|
"vue-advanced-cropper": "2.8.8",
|
||||||
"vue-flatpickr-component": "11.0.1",
|
"vue-flatpickr-component": "11.0.3",
|
||||||
"vue-i18n": "9.2.2",
|
"vue-i18n": "9.9.1",
|
||||||
"vue-router": "4.1.6",
|
"vue-router": "4.2.5",
|
||||||
"workbox-precaching": "6.5.4",
|
"workbox-precaching": "7.0.0",
|
||||||
"zhyswan-vuedraggable": "4.1.3"
|
"zhyswan-vuedraggable": "4.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@4tw/cypress-drag-drop": "2.2.3",
|
"@4tw/cypress-drag-drop": "2.2.5",
|
||||||
"@cypress/vite-dev-server": "5.0.2",
|
"@cypress/vite-dev-server": "5.0.7",
|
||||||
"@cypress/vue": "5.0.3",
|
"@cypress/vue": "6.0.0",
|
||||||
"@faker-js/faker": "7.6.0",
|
"@faker-js/faker": "8.4.0",
|
||||||
"@histoire/plugin-screenshot": "0.12.4",
|
"@histoire/plugin-screenshot": "0.17.8",
|
||||||
"@histoire/plugin-vue": "0.12.4",
|
"@histoire/plugin-vue": "0.17.9",
|
||||||
"@rushstack/eslint-patch": "1.2.0",
|
"@rushstack/eslint-patch": "1.7.2",
|
||||||
"@types/codemirror": "5.60.7",
|
"@tsconfig/node18": "18.2.2",
|
||||||
"@types/dompurify": "2.4.0",
|
"@types/codemirror": "5.60.15",
|
||||||
"@types/flexsearch": "0.7.3",
|
"@types/dompurify": "3.0.5",
|
||||||
"@types/focus-within": "1.0.1",
|
"@types/flexsearch": "0.7.6",
|
||||||
"@types/lodash.debounce": "4.0.7",
|
"@types/is-touch-device": "1.0.2",
|
||||||
"@types/marked": "4.0.8",
|
"@types/lodash.debounce": "4.0.9",
|
||||||
"@types/node": "18.11.18",
|
"@types/marked": "5.0.2",
|
||||||
|
"@types/node": "20.11.10",
|
||||||
"@types/postcss-preset-env": "7.7.0",
|
"@types/postcss-preset-env": "7.7.0",
|
||||||
"@typescript-eslint/eslint-plugin": "5.49.0",
|
"@types/sortablejs": "1.15.7",
|
||||||
"@typescript-eslint/parser": "5.49.0",
|
"@typescript-eslint/eslint-plugin": "6.20.0",
|
||||||
"@vitejs/plugin-legacy": "3.0.2",
|
"@typescript-eslint/parser": "6.20.0",
|
||||||
"@vitejs/plugin-vue": "4.0.0",
|
"@vitejs/plugin-legacy": "5.3.0",
|
||||||
"@vue/eslint-config-typescript": "11.0.2",
|
"@vitejs/plugin-vue": "5.0.3",
|
||||||
"@vue/test-utils": "2.2.8",
|
"@vue/eslint-config-typescript": "12.0.0",
|
||||||
"@vue/tsconfig": "0.1.3",
|
"@vue/test-utils": "2.4.4",
|
||||||
"autoprefixer": "10.4.13",
|
"@vue/tsconfig": "0.5.1",
|
||||||
"browserslist": "4.21.4",
|
"autoprefixer": "10.4.17",
|
||||||
"caniuse-lite": "1.0.30001449",
|
"browserslist": "4.22.3",
|
||||||
"csstype": "3.1.1",
|
"caniuse-lite": "1.0.30001581",
|
||||||
"cypress": "12.4.1",
|
"css-has-pseudo": "6.0.1",
|
||||||
"esbuild": "0.17.5",
|
"csstype": "3.1.3",
|
||||||
"eslint": "8.33.0",
|
"cypress": "13.6.3",
|
||||||
"eslint-plugin-vue": "9.9.0",
|
"esbuild": "0.20.0",
|
||||||
"happy-dom": "8.2.0",
|
"eslint": "8.56.0",
|
||||||
"histoire": "0.12.4",
|
"eslint-plugin-vue": "9.20.1",
|
||||||
"netlify-cli": "12.10.0",
|
"happy-dom": "13.3.5",
|
||||||
"postcss": "8.4.21",
|
"histoire": "0.17.9",
|
||||||
|
"postcss": "8.4.33",
|
||||||
"postcss-easing-gradients": "3.0.1",
|
"postcss-easing-gradients": "3.0.1",
|
||||||
"postcss-easings": "3.0.1",
|
"postcss-easings": "4.0.0",
|
||||||
"postcss-preset-env": "7.8.3",
|
"postcss-focus-within": "8.0.1",
|
||||||
"rollup": "3.12.0",
|
"postcss-preset-env": "9.3.0",
|
||||||
"rollup-plugin-visualizer": "5.9.0",
|
"rollup": "4.9.6",
|
||||||
"sass": "1.57.1",
|
"rollup-plugin-visualizer": "5.12.0",
|
||||||
"start-server-and-test": "1.15.3",
|
"sass": "1.70.0",
|
||||||
"typescript": "4.9.4",
|
"start-server-and-test": "2.0.3",
|
||||||
"vite": "4.0.4",
|
"typescript": "5.3.3",
|
||||||
"vite-plugin-inject-preload": "1.2.0",
|
"vite": "5.0.12",
|
||||||
"vite-plugin-pwa": "0.14.1",
|
"vite-plugin-inject-preload": "1.3.3",
|
||||||
"vite-svg-loader": "4.0.0",
|
"vite-plugin-pwa": "0.17.5",
|
||||||
"vitest": "0.28.3",
|
"vite-plugin-sentry": "1.3.0",
|
||||||
"vue-tsc": "1.0.24",
|
"vite-svg-loader": "5.1.0",
|
||||||
"wait-on": "7.0.1",
|
"vitest": "1.2.2",
|
||||||
"workbox-cli": "6.5.4"
|
"vue-tsc": "1.8.27",
|
||||||
|
"wait-on": "7.2.0",
|
||||||
|
"workbox-cli": "7.0.0"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"patchedDependencies": {
|
||||||
|
"flexsearch@0.7.31": "patches/flexsearch@0.7.31.patch"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
16412
pnpm-lock.yaml
16412
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
@ -2,11 +2,16 @@
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"labels": ["dependencies"],
|
"labels": ["dependencies"],
|
||||||
"extends": [
|
"extends": [
|
||||||
"config:base"
|
"config:js-app"
|
||||||
|
],
|
||||||
|
"hostRules": [
|
||||||
|
{
|
||||||
|
"timeout": 600000
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
"matchPackageNames": ["netlify-cli", "happy-dom"],
|
"matchPackageNames": ["happy-dom"],
|
||||||
"extends": ["schedule:weekly"]
|
"extends": ["schedule:weekly"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -20,11 +25,24 @@
|
||||||
"@vueuse/"
|
"@vueuse/"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"groupName": "histoire",
|
||||||
|
"matchPackagePrefixes": [
|
||||||
|
"@histoire/",
|
||||||
|
"histoire"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"groupName": "tiptap",
|
||||||
|
"matchPackagePrefixes": [
|
||||||
|
"@tiptap/",
|
||||||
|
"tiptap"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"matchDepTypes": ["devDependencies"],
|
"matchDepTypes": ["devDependencies"],
|
||||||
"automerge": true,
|
"groupName": "dev-dependencies",
|
||||||
"automergeStrategy": "squash",
|
"extends": ["schedule:daily"]
|
||||||
"automergeType": "pr"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
|
@ -33,9 +33,9 @@ const promiseExec = cmd => {
|
||||||
}
|
}
|
||||||
|
|
||||||
(async function () {
|
(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)
|
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)
|
console.log(stdout)
|
||||||
|
|
||||||
const data = await fetch(prIssueCommentsUrl).then(response => response.json())
|
const data = await fetch(prIssueCommentsUrl).then(response => response.json())
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
57af69409e66bc87f4f2fc5822dd8d3c2eb47c601f81af1ac4a56f3e2d80837b1a2de06f4ff57695ec379b7c15b881e3 ./scripts/deploy-preview-netlify.mjs
|
4a7c1293c7b12e9ab476cdf35251a407c6a1cd005d22c06df994222cccfb25cde5f47d15866a098c9d739778fee4dc19 ./scripts/deploy-preview-netlify.mjs
|
||||||
|
|
53
src/App.vue
53
src/App.vue
|
@ -1,17 +1,23 @@
|
||||||
<template>
|
<template>
|
||||||
<ready>
|
<Ready>
|
||||||
<template v-if="authUser">
|
<template v-if="authUser">
|
||||||
<TheNavigation/>
|
<TheNavigation />
|
||||||
<content-auth/>
|
<ContentAuth />
|
||||||
</template>
|
</template>
|
||||||
<content-link-share v-else-if="authLinkShare"/>
|
<ContentLinkShare v-else-if="authLinkShare" />
|
||||||
<no-auth-wrapper v-else>
|
<NoAuthWrapper v-else>
|
||||||
<router-view/>
|
<router-view />
|
||||||
</no-auth-wrapper>
|
</NoAuthWrapper>
|
||||||
<Notification/>
|
|
||||||
|
<KeyboardShortcuts v-if="keyboardShortcutsActive" />
|
||||||
<keyboard-shortcuts v-if="keyboardShortcutsActive"/>
|
|
||||||
</ready>
|
<Teleport to="body">
|
||||||
|
<AddToHomeScreen />
|
||||||
|
<UpdateNotification />
|
||||||
|
<Notification />
|
||||||
|
<DemoMode />
|
||||||
|
</Teleport>
|
||||||
|
</Ready>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
@ -19,23 +25,29 @@ import {computed, watch} from 'vue'
|
||||||
import {useRoute, useRouter} from 'vue-router'
|
import {useRoute, useRouter} from 'vue-router'
|
||||||
import {useI18n} from 'vue-i18n'
|
import {useI18n} from 'vue-i18n'
|
||||||
import isTouchDevice from 'is-touch-device'
|
import isTouchDevice from 'is-touch-device'
|
||||||
import {success} from '@/message'
|
|
||||||
|
|
||||||
import Notification from '@/components/misc/notification.vue'
|
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 TheNavigation from '@/components/home/TheNavigation.vue'
|
||||||
import ContentAuth from './components/home/contentAuth.vue'
|
import ContentAuth from '@/components/home/contentAuth.vue'
|
||||||
import ContentLinkShare from './components/home/contentLinkShare.vue'
|
import ContentLinkShare from '@/components/home/contentLinkShare.vue'
|
||||||
import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
|
import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
|
||||||
import Ready from '@/components/misc/ready.vue'
|
import Ready from '@/components/misc/ready.vue'
|
||||||
|
|
||||||
import {setLanguage} from './i18n'
|
import {setLanguage} from '@/i18n'
|
||||||
import AccountDeleteService from '@/services/accountDelete'
|
|
||||||
|
|
||||||
|
import {useAuthStore} from '@/stores/auth'
|
||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
|
|
||||||
import {useColorScheme} from '@/composables/useColorScheme'
|
import {useColorScheme} from '@/composables/useColorScheme'
|
||||||
import {useBodyClass} from '@/composables/useBodyClass'
|
import {useBodyClass} from '@/composables/useBodyClass'
|
||||||
import {useAuthStore} from './stores/auth'
|
import AddToHomeScreen from '@/components/home/AddToHomeScreen.vue'
|
||||||
|
import DemoMode from '@/components/home/DemoMode.vue'
|
||||||
|
|
||||||
|
const importAccountDeleteService = () => import('@/services/accountDelete')
|
||||||
|
const importMessage = () => import('@/message')
|
||||||
|
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
@ -57,8 +69,11 @@ watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messageP = importMessage()
|
||||||
|
const AccountDeleteService = (await importAccountDeleteService()).default
|
||||||
const accountDeletionService = new AccountDeleteService()
|
const accountDeletionService = new AccountDeleteService()
|
||||||
await accountDeletionService.confirm(accountDeletionConfirm)
|
await accountDeletionService.confirm(accountDeletionConfirm)
|
||||||
|
const {success} = await messageP
|
||||||
success({message: t('user.deletion.confirmSuccess')})
|
success({message: t('user.deletion.confirmSuccess')})
|
||||||
authStore.refreshUserInfo()
|
authStore.refreshUserInfo()
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
@ -85,7 +100,7 @@ watch(userEmailConfirm, (userEmailConfirm) => {
|
||||||
router.push({name: 'user.login'})
|
router.push({name: 'user.login'})
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
setLanguage()
|
setLanguage(authStore.settings.language)
|
||||||
useColorScheme()
|
useColorScheme()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
Binary file not shown.
|
@ -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 |
Binary file not shown.
Before Width: | Height: | Size: 519 KiB After Width: | Height: | Size: 313 KiB |
|
@ -21,10 +21,16 @@ const state = reactive({
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Story :setup-app="setupApp" :layout="{ type: 'grid', width: '200px' }">
|
<Story
|
||||||
|
:setup-app="setupApp"
|
||||||
|
:layout="{ type: 'grid', width: '200px' }"
|
||||||
|
>
|
||||||
<Variant title="custom">
|
<Variant title="custom">
|
||||||
<template #controls>
|
<template #controls>
|
||||||
<HstCheckbox v-model="state.disabled" title="Disabled" />
|
<HstCheckbox
|
||||||
|
v-model="state.disabled"
|
||||||
|
title="Disabled"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<BaseButton :disabled="state.disabled">
|
<BaseButton :disabled="state.disabled">
|
||||||
Hello!
|
Hello!
|
||||||
|
|
|
@ -6,38 +6,39 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="disabled === true && (to !== undefined || href !== undefined)"
|
v-if="disabled === true && (to !== undefined || href !== undefined)"
|
||||||
|
ref="button"
|
||||||
class="base-button"
|
class="base-button"
|
||||||
:aria-disabled="disabled || undefined"
|
:aria-disabled="disabled || undefined"
|
||||||
ref="button"
|
|
||||||
>
|
>
|
||||||
<slot/>
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
<router-link
|
<router-link
|
||||||
v-else-if="to !== undefined"
|
v-else-if="to !== undefined"
|
||||||
|
ref="button"
|
||||||
:to="to"
|
:to="to"
|
||||||
class="base-button"
|
class="base-button"
|
||||||
ref="button"
|
|
||||||
>
|
>
|
||||||
<slot/>
|
<slot />
|
||||||
</router-link>
|
</router-link>
|
||||||
<a v-else-if="href !== undefined"
|
<a
|
||||||
|
v-else-if="href !== undefined"
|
||||||
|
ref="button"
|
||||||
class="base-button"
|
class="base-button"
|
||||||
:href="href"
|
:href="href"
|
||||||
rel="noreferrer noopener nofollow"
|
rel="noreferrer noopener nofollow"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
ref="button"
|
|
||||||
>
|
>
|
||||||
<slot/>
|
<slot />
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
|
ref="button"
|
||||||
:type="type"
|
:type="type"
|
||||||
class="base-button base-button--type-button"
|
class="base-button base-button--type-button"
|
||||||
:disabled="disabled || undefined"
|
:disabled="disabled || undefined"
|
||||||
ref="button"
|
|
||||||
@click="(event: MouseEvent) => emit('click', event)"
|
@click="(event: MouseEvent) => emit('click', event)"
|
||||||
>
|
>
|
||||||
<slot/>
|
<slot />
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -63,7 +64,7 @@ import {unrefElement} from '@vueuse/core'
|
||||||
import {ref, type HTMLAttributes} from 'vue'
|
import {ref, type HTMLAttributes} from 'vue'
|
||||||
import type {RouteLocationRaw} from 'vue-router'
|
import type {RouteLocationRaw} from 'vue-router'
|
||||||
|
|
||||||
export interface BaseButtonProps extends HTMLAttributes {
|
export interface BaseButtonProps extends /* @vue-ignore */ HTMLAttributes {
|
||||||
type?: BaseButtonTypes
|
type?: BaseButtonTypes
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
to?: RouteLocationRaw
|
to?: RouteLocationRaw
|
||||||
|
|
|
@ -0,0 +1,63 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-cy="'checkbox'"
|
||||||
|
class="base-checkbox"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:id="checkboxId"
|
||||||
|
type="checkbox"
|
||||||
|
class="is-sr-only"
|
||||||
|
:checked="modelValue"
|
||||||
|
:disabled="disabled || undefined"
|
||||||
|
@change="(event) => emit('update:modelValue', (event.target as HTMLInputElement).checked)"
|
||||||
|
>
|
||||||
|
|
||||||
|
<slot
|
||||||
|
name="label"
|
||||||
|
:checkbox-id="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>
|
|
@ -1,27 +1,30 @@
|
||||||
<template>
|
<template>
|
||||||
<transition
|
<transition
|
||||||
name="expandable-slide"
|
name="expandable-slide"
|
||||||
@before-enter="beforeEnter"
|
@beforeEnter="beforeEnter"
|
||||||
@enter="enter"
|
@enter="enter"
|
||||||
@after-enter="afterEnter"
|
@afterEnter="afterEnter"
|
||||||
@enter-cancelled="enterCancelled"
|
@enterCancelled="enterCancelled"
|
||||||
@before-leave="beforeLeave"
|
@beforeLeave="beforeLeave"
|
||||||
@leave="leave"
|
@leave="leave"
|
||||||
@after-leave="afterLeave"
|
@afterLeave="afterLeave"
|
||||||
@leave-cancelled="leaveCancelled"
|
@leaveCancelled="leaveCancelled"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="initialHeight"
|
v-if="initialHeight"
|
||||||
class="expandable-initial-height"
|
class="expandable-initial-height"
|
||||||
:style="{ maxHeight: `${initialHeight}px` }"
|
:style="{ maxHeight: `${initialHeight}px` }"
|
||||||
:class="{ 'expandable-initial-height--expanded': open }"
|
:class="{ 'expandable-initial-height--expanded': open }"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="open" class="expandable">
|
<div
|
||||||
<slot />
|
v-else-if="open"
|
||||||
</div>
|
class="expandable"
|
||||||
</transition>
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
@ -32,7 +35,7 @@ import {computed, ref} from 'vue'
|
||||||
import {getInheritedBackgroundColor} from '@/helpers/getInheritedBackgroundColor'
|
import {getInheritedBackgroundColor} from '@/helpers/getInheritedBackgroundColor'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
/** Wheather the Expandable is open or not */
|
/** Whether the Expandable is open or not */
|
||||||
open: {
|
open: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import datemathHelp from './datemathHelp.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Story>
|
||||||
|
<Variant title="Default">
|
||||||
|
<datemathHelp />
|
||||||
|
</Variant>
|
||||||
|
</Story>
|
||||||
|
</template>
|
|
@ -1,122 +1,136 @@
|
||||||
<template>
|
<template>
|
||||||
<card
|
<card
|
||||||
class="has-no-shadow how-it-works-modal"
|
class="has-no-shadow how-it-works-modal"
|
||||||
:title="$t('input.datemathHelp.title')">
|
:title="$t('input.datemathHelp.title')"
|
||||||
|
>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('input.datemathHelp.intro') }}
|
{{ $t('input.datemathHelp.intro') }}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<i18n-t keypath="input.datemathHelp.expression" scope="global">
|
<i18n-t
|
||||||
|
keypath="input.datemathHelp.expression"
|
||||||
|
scope="global"
|
||||||
|
>
|
||||||
<code>now</code>
|
<code>now</code>
|
||||||
<code>||</code>
|
<code>||</code>
|
||||||
</i18n-t>
|
</i18n-t>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<i18n-t keypath="input.datemathHelp.similar" scope="global">
|
<i18n-t
|
||||||
|
keypath="input.datemathHelp.similar"
|
||||||
|
scope="global"
|
||||||
|
>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
href="https://grafana.com/docs/grafana/latest/dashboards/time-range-controls/"
|
href="https://grafana.com/docs/grafana/latest/dashboards/time-range-controls/"
|
||||||
target="_blank">
|
target="_blank"
|
||||||
|
>
|
||||||
Grafana
|
Grafana
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
href="https://www.elastic.co/guide/en/elasticsearch/reference/7.3/common-options.html#date-math"
|
href="https://www.elastic.co/guide/en/elasticsearch/reference/7.3/common-options.html#date-math"
|
||||||
target="_blank">
|
target="_blank"
|
||||||
|
>
|
||||||
Elasticsearch
|
Elasticsearch
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</i18n-t>
|
</i18n-t>
|
||||||
</p>
|
</p>
|
||||||
<p>{{ $t('misc.forExample') }}</p>
|
<p>{{ $t('misc.forExample') }}</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><code>+1d</code>{{ $t('input.datemathHelp.add1Day') }}</li>
|
<li><code>+1d</code> {{ $t('input.datemathHelp.add1Day') }}</li>
|
||||||
<li><code>-1d</code>{{ $t('input.datemathHelp.minus1Day') }}</li>
|
<li><code>-1d</code> {{ $t('input.datemathHelp.minus1Day') }}</li>
|
||||||
<li><code>/d</code>{{ $t('input.datemathHelp.roundDay') }}</li>
|
<li><code>/d</code> {{ $t('input.datemathHelp.roundDay') }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>{{ $t('input.datemathHelp.supportedUnits') }}</p>
|
<h3>{{ $t('input.datemathHelp.supportedUnits') }}</h3>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>s</code></td>
|
<td><code>s</code></td>
|
||||||
<td>{{ $t('input.datemathHelp.units.seconds') }}</td>
|
<td>{{ $t('input.datemathHelp.units.seconds') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>m</code></td>
|
<td><code>m</code></td>
|
||||||
<td>{{ $t('input.datemathHelp.units.minutes') }}</td>
|
<td>{{ $t('input.datemathHelp.units.minutes') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>h</code></td>
|
<td><code>h</code></td>
|
||||||
<td>{{ $t('input.datemathHelp.units.hours') }}</td>
|
<td>{{ $t('input.datemathHelp.units.hours') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>H</code></td>
|
<td><code>H</code></td>
|
||||||
<td>{{ $t('input.datemathHelp.units.hours') }}</td>
|
<td>{{ $t('input.datemathHelp.units.hours') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>d</code></td>
|
<td><code>d</code></td>
|
||||||
<td>{{ $t('input.datemathHelp.units.days') }}</td>
|
<td>{{ $t('input.datemathHelp.units.days') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>w</code></td>
|
<td><code>w</code></td>
|
||||||
<td>{{ $t('input.datemathHelp.units.weeks') }}</td>
|
<td>{{ $t('input.datemathHelp.units.weeks') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>M</code></td>
|
<td><code>M</code></td>
|
||||||
<td>{{ $t('input.datemathHelp.units.months') }}</td>
|
<td>{{ $t('input.datemathHelp.units.months') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>y</code></td>
|
<td><code>y</code></td>
|
||||||
<td>{{ $t('input.datemathHelp.units.years') }}</td>
|
<td>{{ $t('input.datemathHelp.units.years') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p>{{ $t('input.datemathHelp.someExamples') }}</p>
|
<h3>{{ $t('input.datemathHelp.someExamples') }}</h3>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>now</code></td>
|
<td><code>now</code></td>
|
||||||
<td>{{ $t('input.datemathHelp.examples.now') }}</td>
|
<td>{{ $t('input.datemathHelp.examples.now') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>now+24h</code></td>
|
<td><code>now+24h</code></td>
|
||||||
<td>{{ $t('input.datemathHelp.examples.in24h') }}</td>
|
<td>{{ $t('input.datemathHelp.examples.in24h') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>now/d</code></td>
|
<td><code>now/d</code></td>
|
||||||
<td>{{ $t('input.datemathHelp.examples.today') }}</td>
|
<td>{{ $t('input.datemathHelp.examples.today') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>now/w</code></td>
|
<td><code>now/w</code></td>
|
||||||
<td>{{ $t('input.datemathHelp.examples.beginningOfThisWeek') }}</td>
|
<td>{{ $t('input.datemathHelp.examples.beginningOfThisWeek') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>now/w+1w</code></td>
|
<td><code>now/w+1w</code></td>
|
||||||
<td>{{ $t('input.datemathHelp.examples.endOfThisWeek') }}</td>
|
<td>{{ $t('input.datemathHelp.examples.endOfThisWeek') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>now+30d</code></td>
|
<td><code>now+30d</code></td>
|
||||||
<td>{{ $t('input.datemathHelp.examples.in30Days') }}</td>
|
<td>{{ $t('input.datemathHelp.examples.in30Days') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>{{ exampleDate }}||+1M/d</code></td>
|
<td><code>{{ exampleDate }}||+1M/d</code></td>
|
||||||
<td>
|
<td>
|
||||||
<i18n-t keypath="input.datemathHelp.examples.datePlusMonth" scope="global">
|
<i18n-t
|
||||||
<code>{{ exampleDate }}</code>
|
keypath="input.datemathHelp.examples.datePlusMonth"
|
||||||
</i18n-t>
|
scope="global"
|
||||||
</td>
|
>
|
||||||
</tr>
|
<strong>{{ exampleDate }}</strong>
|
||||||
|
</i18n-t>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</card>
|
</card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {formatDate} from '@/helpers/time/formatDate'
|
import {formatDateShort} from '@/helpers/time/formatDate'
|
||||||
|
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
|
||||||
const exampleDate = formatDate(new Date(), 'yyyy-MM-dd')
|
const exampleDate = formatDateShort(new Date())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
// FIXME: Remove style overwrites
|
||||||
.how-it-works-modal {
|
.how-it-works-modal {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +1,31 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="datepicker-with-range-container">
|
<div class="datepicker-with-range-container">
|
||||||
<popup>
|
<Popup>
|
||||||
<template #trigger="{toggle}">
|
<template #trigger="{toggle}">
|
||||||
<slot name="trigger" :toggle="toggle" :buttonText="buttonText"></slot>
|
<slot
|
||||||
|
name="trigger"
|
||||||
|
:toggle="toggle"
|
||||||
|
:button-text="buttonText"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #content="{isOpen}">
|
<template #content="{isOpen}">
|
||||||
<div class="datepicker-with-range" :class="{'is-open': isOpen}">
|
<div
|
||||||
|
class="datepicker-with-range"
|
||||||
|
:class="{'is-open': isOpen}"
|
||||||
|
>
|
||||||
<div class="selections">
|
<div class="selections">
|
||||||
<BaseButton @click="setDateRange(null)" :class="{'is-active': customRangeActive}">
|
<BaseButton
|
||||||
|
:class="{'is-active': customRangeActive}"
|
||||||
|
@click="setDateRange(null)"
|
||||||
|
>
|
||||||
{{ $t('misc.custom') }}
|
{{ $t('misc.custom') }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-for="(value, text) in DATE_RANGES"
|
v-for="(value, text) in DATE_RANGES"
|
||||||
:key="text"
|
:key="text"
|
||||||
|
:class="{'is-active': from === value[0] && to === value[1]}"
|
||||||
@click="setDateRange(value)"
|
@click="setDateRange(value)"
|
||||||
:class="{'is-active': from === value[0] && to === value[1]}">
|
>
|
||||||
{{ $t(`input.datepickerRange.ranges.${text}`) }}
|
{{ $t(`input.datepickerRange.ranges.${text}`) }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
|
@ -23,10 +34,18 @@
|
||||||
{{ $t('input.datepickerRange.from') }}
|
{{ $t('input.datepickerRange.from') }}
|
||||||
<div class="field has-addons">
|
<div class="field has-addons">
|
||||||
<div class="control is-fullwidth">
|
<div class="control is-fullwidth">
|
||||||
<input class="input" type="text" v-model="from"/>
|
<input
|
||||||
|
v-model="from"
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<x-button icon="calendar" variant="secondary" data-toggle/>
|
<x-button
|
||||||
|
icon="calendar"
|
||||||
|
variant="secondary"
|
||||||
|
data-toggle
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
@ -34,38 +53,49 @@
|
||||||
{{ $t('input.datepickerRange.to') }}
|
{{ $t('input.datepickerRange.to') }}
|
||||||
<div class="field has-addons">
|
<div class="field has-addons">
|
||||||
<div class="control is-fullwidth">
|
<div class="control is-fullwidth">
|
||||||
<input class="input" type="text" v-model="to"/>
|
<input
|
||||||
|
v-model="to"
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<x-button icon="calendar" variant="secondary" data-toggle/>
|
<x-button
|
||||||
|
icon="calendar"
|
||||||
|
variant="secondary"
|
||||||
|
data-toggle
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<flat-pickr
|
<flat-pickr
|
||||||
:config="flatPickerConfig"
|
|
||||||
v-model="flatpickrRange"
|
v-model="flatpickrRange"
|
||||||
|
:config="flatPickerConfig"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
{{ $t('input.datemathHelp.canuse') }}
|
{{ $t('input.datemathHelp.canuse') }}
|
||||||
<BaseButton class="has-text-primary" @click="showHowItWorks = true">
|
<BaseButton
|
||||||
|
class="has-text-primary"
|
||||||
|
@click="showHowItWorks = true"
|
||||||
|
>
|
||||||
{{ $t('input.datemathHelp.learnhow') }}
|
{{ $t('input.datemathHelp.learnhow') }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<modal
|
<modal
|
||||||
:enabled="showHowItWorks"
|
:enabled="showHowItWorks"
|
||||||
@close="() => showHowItWorks = false"
|
|
||||||
transition-name="fade"
|
transition-name="fade"
|
||||||
:overflow="true"
|
:overflow="true"
|
||||||
variant="hint-modal"
|
variant="hint-modal"
|
||||||
|
@close="() => showHowItWorks = false"
|
||||||
>
|
>
|
||||||
<DatemathHelp/>
|
<DatemathHelp />
|
||||||
</modal>
|
</modal>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</popup>
|
</Popup>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -75,25 +105,24 @@ import {useI18n} from 'vue-i18n'
|
||||||
|
|
||||||
import flatPickr from 'vue-flatpickr-component'
|
import flatPickr from 'vue-flatpickr-component'
|
||||||
import 'flatpickr/dist/flatpickr.css'
|
import 'flatpickr/dist/flatpickr.css'
|
||||||
|
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
|
||||||
|
|
||||||
import Popup from '@/components/misc/popup.vue'
|
import Popup from '@/components/misc/popup.vue'
|
||||||
import {DATE_RANGES} from '@/components/date/dateRanges'
|
import {DATE_RANGES} from '@/components/date/dateRanges'
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
import DatemathHelp from '@/components/date/datemathHelp.vue'
|
import DatemathHelp from '@/components/date/datemathHelp.vue'
|
||||||
import {useAuthStore} from '@/stores/auth'
|
import { getFlatpickrLanguage } from '@/helpers/flatpickrLanguage'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const {t} = useI18n({useScope: 'global'})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
required: false,
|
required: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// FIXME: This seems to always contain the default value - that breaks the picker
|
const emit = defineEmits(['update:modelValue'])
|
||||||
const weekStart = computed(() => authStore.settings.weekStart ?? 0)
|
|
||||||
|
const {t} = useI18n({useScope: 'global'})
|
||||||
|
|
||||||
const flatPickerConfig = computed(() => ({
|
const flatPickerConfig = computed(() => ({
|
||||||
altFormat: t('date.altFormatLong'),
|
altFormat: t('date.altFormatLong'),
|
||||||
altInput: true,
|
altInput: true,
|
||||||
|
@ -101,9 +130,7 @@ const flatPickerConfig = computed(() => ({
|
||||||
enableTime: false,
|
enableTime: false,
|
||||||
wrap: true,
|
wrap: true,
|
||||||
mode: 'range',
|
mode: 'range',
|
||||||
locale: {
|
locale: getFlatpickrLanguage(),
|
||||||
firstDayOf7Days: weekStart.value,
|
|
||||||
},
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const showHowItWorks = ref(false)
|
const showHowItWorks = ref(false)
|
||||||
|
@ -120,9 +147,9 @@ watch(
|
||||||
to.value = newValue.dateTo
|
to.value = newValue.dateTo
|
||||||
// Only set the date back to flatpickr when it's an actual date.
|
// Only set the date back to flatpickr when it's an actual date.
|
||||||
// Otherwise flatpickr runs in an endless loop and slows down the browser.
|
// Otherwise flatpickr runs in an endless loop and slows down the browser.
|
||||||
const dateFrom = new Date(from.value)
|
const dateFrom = parseDateOrString(from.value, false)
|
||||||
const dateTo = new Date(to.value)
|
const dateTo = parseDateOrString(to.value, false)
|
||||||
if (dateTo.getTime() === dateTo.getTime() && dateFrom.getTime() === dateFrom.getTime()) {
|
if (dateFrom instanceof Date && dateTo instanceof Date) {
|
||||||
flatpickrRange.value = `${from.value} to ${to.value}`
|
flatpickrRange.value = `${from.value} to ${to.value}`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -0,0 +1,86 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="shouldShowMessage"
|
||||||
|
class="add-to-home-screen"
|
||||||
|
:class="{'has-update-available': hasUpdateAvailable}"
|
||||||
|
>
|
||||||
|
<icon
|
||||||
|
icon="arrow-up-from-bracket"
|
||||||
|
class="add-icon"
|
||||||
|
/>
|
||||||
|
<p>
|
||||||
|
{{ $t('home.addToHomeScreen') }}
|
||||||
|
</p>
|
||||||
|
<BaseButton
|
||||||
|
class="hide-button"
|
||||||
|
@click="() => hideMessage = true"
|
||||||
|
>
|
||||||
|
<icon icon="x" />
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
import {useLocalStorage} from '@vueuse/core'
|
||||||
|
import {computed} from 'vue'
|
||||||
|
import {useBaseStore} from '@/stores/base'
|
||||||
|
|
||||||
|
const baseStore = useBaseStore()
|
||||||
|
|
||||||
|
const hideMessage = useLocalStorage('hideAddToHomeScreenMessage', false)
|
||||||
|
const hasUpdateAvailable = computed(() => baseStore.updateAvailable)
|
||||||
|
|
||||||
|
const shouldShowMessage = computed(() => {
|
||||||
|
if (hideMessage.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && window.matchMedia('(display-mode: standalone)').matches) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.add-to-home-screen {
|
||||||
|
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;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
background: var(--grey-900);
|
||||||
|
border-radius: $radius;
|
||||||
|
font-size: .9rem;
|
||||||
|
color: var(--grey-200);
|
||||||
|
|
||||||
|
@media screen and (min-width: $tablet) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.has-update-available {
|
||||||
|
bottom: 5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-icon {
|
||||||
|
color: var(--primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide-button {
|
||||||
|
padding: .25rem .5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,52 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {computed, ref} from 'vue'
|
||||||
|
import {useConfigStore} from '@/stores/config'
|
||||||
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
const hide = ref(false)
|
||||||
|
const enabled = computed(() => configStore.demoModeEnabled && !hide.value)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="enabled"
|
||||||
|
class="demo-mode-banner"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
{{ $t('demo.title') }}
|
||||||
|
<strong class="is-uppercase">{{ $t('demo.everythingWillBeDeleted') }}</strong>
|
||||||
|
</p>
|
||||||
|
<BaseButton
|
||||||
|
class="hide-button"
|
||||||
|
@click="() => hide = true"
|
||||||
|
>
|
||||||
|
<icon icon="times" />
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.demo-mode-banner {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--danger);
|
||||||
|
z-index: 100;
|
||||||
|
padding: .5rem;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&, strong {
|
||||||
|
color: hsl(220, 13%, 91%) !important; // --grey-200 in light mode, hardcoded because the color should not change
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide-button {
|
||||||
|
padding: .25rem .5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
position: absolute;
|
||||||
|
right: .5rem;
|
||||||
|
top: .25rem;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -4,17 +4,35 @@ import { useNow } from '@vueuse/core'
|
||||||
|
|
||||||
import LogoFull from '@/assets/logo-full.svg?component'
|
import LogoFull from '@/assets/logo-full.svg?component'
|
||||||
import LogoFullPride from '@/assets/logo-full-pride.svg?component'
|
import LogoFullPride from '@/assets/logo-full-pride.svg?component'
|
||||||
|
import {MILLISECONDS_A_HOUR} from '@/constants/date'
|
||||||
|
|
||||||
const now = useNow()
|
const now = useNow({
|
||||||
const Logo = computed(() => now.value.getMonth() === 5 ? LogoFullPride : LogoFull)
|
interval: MILLISECONDS_A_HOUR,
|
||||||
|
})
|
||||||
|
const Logo = computed(() => window.ALLOW_ICON_CHANGES && now.value.getMonth() === 6 ? LogoFullPride : LogoFull)
|
||||||
|
const CustomLogo = computed(() => window.CUSTOM_LOGO_URL)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Logo alt="Vikunja" class="logo" />
|
<div>
|
||||||
|
<Logo
|
||||||
|
v-if="!CustomLogo"
|
||||||
|
alt="Vikunja"
|
||||||
|
class="logo"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-show="CustomLogo"
|
||||||
|
:src="CustomLogo"
|
||||||
|
alt="Vikunja"
|
||||||
|
class="logo"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.logo {
|
.logo {
|
||||||
color: var(--logo-text-color);
|
color: var(--logo-text-color);
|
||||||
|
max-width: 168px;
|
||||||
|
max-height: 48px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -1,11 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
|
v-shortcut="'Mod+e'"
|
||||||
class="menu-show-button"
|
class="menu-show-button"
|
||||||
@click="baseStore.toggleMenu()"
|
|
||||||
@shortkey="() => baseStore.toggleMenu()"
|
|
||||||
v-shortcut="'Control+e'"
|
|
||||||
:title="$t('keyboardShortcuts.toggleMenu')"
|
:title="$t('keyboardShortcuts.toggleMenu')"
|
||||||
:aria-label="menuActive ? $t('misc.hideMenu') : $t('misc.showMenu')"
|
:aria-label="menuActive ? $t('misc.hideMenu') : $t('misc.showMenu')"
|
||||||
|
@click="baseStore.toggleMenu()"
|
||||||
|
@shortkey="() => baseStore.toggleMenu()"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<BaseButton class="menu-bottom-link" :href="poweredByUrl" target="_blank">
|
<BaseButton
|
||||||
{{ $t('misc.poweredBy') }}
|
class="menu-bottom-link"
|
||||||
</BaseButton>
|
:href="poweredByUrl"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{{ $t('misc.poweredBy') }}
|
||||||
|
</BaseButton>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
|
@ -0,0 +1,109 @@
|
||||||
|
<template>
|
||||||
|
<draggable
|
||||||
|
v-model="availableProjects"
|
||||||
|
animation="100"
|
||||||
|
ghost-class="ghost"
|
||||||
|
group="projects"
|
||||||
|
handle=".handle"
|
||||||
|
tag="menu"
|
||||||
|
item-key="id"
|
||||||
|
:disabled="!canEditOrder"
|
||||||
|
filter=".drag-disabled"
|
||||||
|
:component-data="{
|
||||||
|
type: 'transition-group',
|
||||||
|
name: !drag ? 'flip-list' : null,
|
||||||
|
class: [
|
||||||
|
'menu-list can-be-hidden',
|
||||||
|
{ 'dragging-disabled': !canEditOrder }
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
@start="() => drag = true"
|
||||||
|
@end="saveProjectPosition"
|
||||||
|
>
|
||||||
|
<template #item="{element: project}">
|
||||||
|
<ProjectsNavigationItem
|
||||||
|
:class="{'drag-disabled': project.id < 0}"
|
||||||
|
: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>
|
|
@ -0,0 +1,199 @@
|
||||||
|
<template>
|
||||||
|
<li
|
||||||
|
class="list-menu loader-container is-loading-small"
|
||||||
|
:class="{'is-loading': isLoading}"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<BaseButton
|
||||||
|
v-if="canCollapse && childProjects?.length > 0"
|
||||||
|
class="collapse-project-button"
|
||||||
|
@click="childProjectsOpen = !childProjectsOpen"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="color-bubble-handle-wrapper"
|
||||||
|
:class="{'is-draggable': project.id > 0}"
|
||||||
|
>
|
||||||
|
<ColorBubble
|
||||||
|
v-if="project.hexColor !== ''"
|
||||||
|
:color="project.hexColor"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else-if="project.id < -1"
|
||||||
|
class="saved-filter-icon icon menu-item-icon"
|
||||||
|
>
|
||||||
|
<icon icon="filter" />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="project.id > 0"
|
||||||
|
class="icon menu-item-icon handle"
|
||||||
|
:class="{'has-color-bubble': project.hexColor !== ''}"
|
||||||
|
>
|
||||||
|
<icon icon="grip-lines" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="project-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
|
||||||
|
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>
|
||||||
|
</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 {
|
||||||
|
project,
|
||||||
|
isLoading,
|
||||||
|
canCollapse,
|
||||||
|
level = 0,
|
||||||
|
} = defineProps<{
|
||||||
|
project: IProject,
|
||||||
|
isLoading?: boolean,
|
||||||
|
canCollapse?: boolean,
|
||||||
|
level?: number,
|
||||||
|
}>()
|
||||||
|
|
||||||
|
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(project.id)
|
||||||
|
.filter(p => !p.isArchived)
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
})
|
||||||
|
|
||||||
|
const canNestDeeper = computed(() => canNestProjectDeeper(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.is-draggable > {
|
||||||
|
.saved-filter-icon,
|
||||||
|
.color-bubble {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-touch .color-bubble {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-bubble-handle-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-right: .25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.color-bubble, .icon {
|
||||||
|
transition: all $transition;
|
||||||
|
position: absolute;
|
||||||
|
width: 12px;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-menu-title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saved-filter-icon {
|
||||||
|
color: var(--grey-300) !important;
|
||||||
|
font-size: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-touch .handle.has-color-bubble {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,119 +1,138 @@
|
||||||
<template>
|
<template>
|
||||||
<header
|
<header
|
||||||
:class="{'has-background': background, 'menu-active': menuActive}"
|
:class="{ 'has-background': background, 'menu-active': menuActive }"
|
||||||
aria-label="main navigation"
|
aria-label="main navigation"
|
||||||
class="navbar main-theme is-fixed-top d-print-none"
|
class="navbar d-print-none"
|
||||||
>
|
>
|
||||||
<router-link :to="{name: 'home'}" class="logo-link">
|
<router-link
|
||||||
<Logo width="164" height="48"/>
|
:to="{ name: 'home' }"
|
||||||
|
class="logo-link"
|
||||||
|
>
|
||||||
|
<Logo
|
||||||
|
width="164"
|
||||||
|
height="48"
|
||||||
|
/>
|
||||||
</router-link>
|
</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">
|
<MenuButton class="menu-button" />
|
||||||
<icon icon="circle-info"/>
|
|
||||||
</BaseButton>
|
|
||||||
|
|
||||||
<list-settings-dropdown v-if="canWriteCurrentList && currentList.id !== -1" :list="currentList"/>
|
<div
|
||||||
</template>
|
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>
|
||||||
|
|
||||||
|
<ProjectSettingsDropdown
|
||||||
|
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>
|
||||||
|
</ProjectSettingsDropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="navbar-end">
|
<div class="navbar-end">
|
||||||
<update/>
|
<OpenQuickActions />
|
||||||
<BaseButton
|
<Notifications />
|
||||||
@click="openQuickActions"
|
<Dropdown>
|
||||||
class="trigger-button pr-0"
|
<template #trigger="{ toggleOpen, open }">
|
||||||
v-shortcut="'Control+k'"
|
<BaseButton
|
||||||
:title="$t('keyboardShortcuts.quickSearch')"
|
class="username-dropdown-trigger"
|
||||||
>
|
variant="secondary"
|
||||||
<icon icon="search"/>
|
:shadow="false"
|
||||||
</BaseButton>
|
@click="toggleOpen"
|
||||||
<notifications/>
|
>
|
||||||
<div class="user">
|
<img
|
||||||
<dropdown class="is-right" ref="usernameDropdown">
|
:src="authStore.avatarUrl"
|
||||||
<template #trigger="{toggleOpen}">
|
alt=""
|
||||||
<x-button
|
class="avatar"
|
||||||
class="username-dropdown-trigger"
|
width="40"
|
||||||
@click="toggleOpen()"
|
height="40"
|
||||||
variant="secondary"
|
|
||||||
:shadow="false"
|
|
||||||
>
|
>
|
||||||
<img :src="authStore.avatarUrl" alt="" class="avatar" width="40" height="40"/>
|
<span class="username">{{ authStore.userDisplayName }}</span>
|
||||||
<span class="username">{{ authStore.userDisplayName }}</span>
|
<span
|
||||||
<span class="icon is-small">
|
class="icon is-small"
|
||||||
<icon icon="chevron-down"/>
|
:style="{
|
||||||
</span>
|
transform: open ? 'rotate(180deg)' : 'rotate(0)',
|
||||||
</x-button>
|
}"
|
||||||
</template>
|
>
|
||||||
|
<icon icon="chevron-down" />
|
||||||
|
</span>
|
||||||
|
</BaseButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
<dropdown-item
|
<DropdownItem :to="{ name: 'user.settings' }">
|
||||||
:to="{name: 'user.settings'}"
|
{{ $t('user.settings.title') }}
|
||||||
>
|
</DropdownItem>
|
||||||
{{ $t('user.settings.title') }}
|
<DropdownItem
|
||||||
</dropdown-item>
|
v-if="imprintUrl"
|
||||||
<dropdown-item
|
:href="imprintUrl"
|
||||||
v-if="imprintUrl"
|
>
|
||||||
:href="imprintUrl"
|
{{ $t('navigation.imprint') }}
|
||||||
>
|
</DropdownItem>
|
||||||
{{ $t('navigation.imprint') }}
|
<DropdownItem
|
||||||
</dropdown-item>
|
v-if="privacyPolicyUrl"
|
||||||
<dropdown-item
|
:href="privacyPolicyUrl"
|
||||||
v-if="privacyPolicyUrl"
|
>
|
||||||
:href="privacyPolicyUrl"
|
{{ $t('navigation.privacy') }}
|
||||||
>
|
</DropdownItem>
|
||||||
{{ $t('navigation.privacy') }}
|
<DropdownItem @click="baseStore.setKeyboardShortcutsActive(true)">
|
||||||
</dropdown-item>
|
{{ $t('keyboardShortcuts.title') }}
|
||||||
<dropdown-item
|
</DropdownItem>
|
||||||
@click="baseStore.setKeyboardShortcutsActive(true)"
|
<DropdownItem :to="{ name: 'about' }">
|
||||||
>
|
{{ $t('about.title') }}
|
||||||
{{ $t('keyboardShortcuts.title') }}
|
</DropdownItem>
|
||||||
</dropdown-item>
|
<DropdownItem @click="authStore.logout()">
|
||||||
<dropdown-item
|
{{ $t('user.auth.logout') }}
|
||||||
:to="{name: 'about'}"
|
</DropdownItem>
|
||||||
>
|
</Dropdown>
|
||||||
{{ $t('about.title') }}
|
|
||||||
</dropdown-item>
|
|
||||||
<dropdown-item
|
|
||||||
@click="authStore.logout()"
|
|
||||||
>
|
|
||||||
{{ $t('user.auth.logout') }}
|
|
||||||
</dropdown-item>
|
|
||||||
</dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 ProjectSettingsDropdown from '@/components/project/project-settings-dropdown.vue'
|
||||||
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
|
|
||||||
import Dropdown from '@/components/misc/dropdown.vue'
|
import Dropdown from '@/components/misc/dropdown.vue'
|
||||||
import DropdownItem from '@/components/misc/dropdown-item.vue'
|
import DropdownItem from '@/components/misc/dropdown-item.vue'
|
||||||
import Notifications from '@/components/notifications/notifications.vue'
|
import Notifications from '@/components/notifications/notifications.vue'
|
||||||
import Logo from '@/components/home/Logo.vue'
|
import Logo from '@/components/home/Logo.vue'
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
import MenuButton from '@/components/home/MenuButton.vue'
|
import MenuButton from '@/components/home/MenuButton.vue'
|
||||||
|
import OpenQuickActions from '@/components/misc/OpenQuickActions.vue'
|
||||||
|
|
||||||
import {getListTitle} from '@/helpers/getListTitle'
|
import { getProjectTitle } from '@/helpers/getProjectTitle'
|
||||||
|
|
||||||
import {useBaseStore} from '@/stores/base'
|
import { useBaseStore } from '@/stores/base'
|
||||||
import {useConfigStore} from '@/stores/config'
|
import { useConfigStore } from '@/stores/config'
|
||||||
import {useAuthStore} from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
const currentList = computed(() => baseStore.currentList)
|
const currentProject = computed(() => baseStore.currentProject)
|
||||||
const background = computed(() => baseStore.background)
|
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 menuActive = computed(() => baseStore.menuActive)
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
@ -121,183 +140,149 @@ const authStore = useAuthStore()
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const imprintUrl = computed(() => configStore.legal.imprintUrl)
|
const imprintUrl = computed(() => configStore.legal.imprintUrl)
|
||||||
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
|
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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
$vikunja-nav-logo-full-width: 164px;
|
|
||||||
$user-dropdown-width-mobile: 5rem;
|
$user-dropdown-width-mobile: 5rem;
|
||||||
|
|
||||||
$hamburger-menu-icon-spacing: 1rem;
|
.navbar {
|
||||||
$hamburger-menu-icon-width: 28px;
|
--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 {
|
.logo-link {
|
||||||
display: none;
|
display: none;
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
|
|
||||||
@media screen and (min-width: $tablet) {
|
@media screen and (min-width: $tablet) {
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-left: 2rem;
|
margin-right: .5rem;
|
||||||
margin-right: 1.5rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-button {
|
.menu-button {
|
||||||
align-self: stretch;
|
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
|
align-self: stretch;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
|
||||||
@media screen and (max-width: $tablet) {
|
@media screen and (max-width: $tablet) {
|
||||||
margin-left: $hamburger-menu-icon-spacing;
|
margin-left: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar.main-theme {
|
.project-title-wrapper {
|
||||||
background: var(--site-background);
|
margin-inline: auto;
|
||||||
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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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) {
|
@media screen and (min-width: $tablet) {
|
||||||
// We need a fixed width for overflowing ellipsis to work
|
padding-inline: var(--navbar-gap-width);
|
||||||
--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));
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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) {
|
@media screen and (max-width: $tablet) {
|
||||||
// We need a fixed width for overflowing ellipsis to work
|
display: none;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-button {
|
.avatar {
|
||||||
text-align: center;
|
border-radius: 100%;
|
||||||
height: 1.25rem;
|
vertical-align: middle;
|
||||||
line-height: 1.25rem;
|
height: 40px;
|
||||||
width: 2rem;
|
margin-right: .5rem;
|
||||||
margin-top: .25rem;
|
|
||||||
padding: 0 .5rem;
|
|
||||||
color: var(--grey-400);
|
|
||||||
margin-left: .5rem;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -0,0 +1,82 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="updateAvailable"
|
||||||
|
class="update-notification"
|
||||||
|
>
|
||||||
|
<p class="update-notification__message">
|
||||||
|
{{ $t('update.available') }}
|
||||||
|
</p>
|
||||||
|
<x-button
|
||||||
|
:shadow="false"
|
||||||
|
:wrap="false"
|
||||||
|
@click="refreshApp()"
|
||||||
|
>
|
||||||
|
{{ $t('update.do') }}
|
||||||
|
</x-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {computed, ref} from 'vue'
|
||||||
|
import {useBaseStore} from '@/stores/base'
|
||||||
|
|
||||||
|
const baseStore = useBaseStore()
|
||||||
|
|
||||||
|
const updateAvailable = computed(() => baseStore.updateAvailable)
|
||||||
|
const registration = ref(null)
|
||||||
|
const refreshing = ref(false)
|
||||||
|
|
||||||
|
document.addEventListener('swUpdated', showRefreshUI, {once: true})
|
||||||
|
|
||||||
|
navigator?.serviceWorker?.addEventListener(
|
||||||
|
'controllerchange', () => {
|
||||||
|
if (refreshing.value) return
|
||||||
|
refreshing.value = true
|
||||||
|
window.location.reload()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function showRefreshUI(e: Event) {
|
||||||
|
console.log('recieved refresh event', e)
|
||||||
|
registration.value = e.detail
|
||||||
|
baseStore.setUpdateAvailable(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshApp() {
|
||||||
|
baseStore.setUpdateAvailable(false)
|
||||||
|
if (!registration.value || !registration.value.waiting) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Notify the service worker to actually do the update
|
||||||
|
registration.value.waiting.postMessage('skipWaiting')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: .5rem .5rem .5rem 1rem;
|
||||||
|
background: $warning;
|
||||||
|
border-radius: $radius;
|
||||||
|
font-size: .9rem;
|
||||||
|
color: hsl(220.9, 39.3%, 11%); // color copied to avoid it changing in dark mode
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-notification__message {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -2,10 +2,10 @@
|
||||||
<div class="content-auth">
|
<div class="content-auth">
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-show="menuActive"
|
v-show="menuActive"
|
||||||
@click="baseStore.setMenuActive(false)"
|
|
||||||
class="menu-hide-button d-print-none"
|
class="menu-hide-button d-print-none"
|
||||||
|
@click="baseStore.setMenuActive(false)"
|
||||||
>
|
>
|
||||||
<icon icon="times"/>
|
<icon icon="times" />
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
<div
|
<div
|
||||||
class="app-container"
|
class="app-container"
|
||||||
|
@ -15,8 +15,9 @@
|
||||||
<div
|
<div
|
||||||
:class="{'is-visible': background}"
|
:class="{'is-visible': background}"
|
||||||
class="app-container-background background-fade-in d-print-none"
|
class="app-container-background background-fade-in d-print-none"
|
||||||
:style="{'background-image': background && `url(${background})`}"></div>
|
:style="{'background-image': background && `url(${background})`}"
|
||||||
<navigation class="d-print-none"/>
|
/>
|
||||||
|
<Navigation class="d-print-none" />
|
||||||
<main
|
<main
|
||||||
class="app-content"
|
class="app-content"
|
||||||
:class="[
|
:class="[
|
||||||
|
@ -26,33 +27,36 @@
|
||||||
>
|
>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-show="menuActive"
|
v-show="menuActive"
|
||||||
@click="baseStore.setMenuActive(false)"
|
|
||||||
class="mobile-overlay d-print-none"
|
class="mobile-overlay d-print-none"
|
||||||
|
@click="baseStore.setMenuActive(false)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<quick-actions/>
|
<QuickActions />
|
||||||
|
|
||||||
<router-view :route="routeWithModal" v-slot="{ Component }">
|
<router-view
|
||||||
<keep-alive :include="['list.list', 'list.gantt', 'list.table', 'list.kanban']">
|
v-slot="{ Component }"
|
||||||
<component :is="Component"/>
|
:route="routeWithModal"
|
||||||
|
>
|
||||||
|
<keep-alive :include="['project.list', 'project.gantt', 'project.table', 'project.kanban']">
|
||||||
|
<component :is="Component" />
|
||||||
</keep-alive>
|
</keep-alive>
|
||||||
</router-view>
|
</router-view>
|
||||||
|
|
||||||
<modal
|
<modal
|
||||||
:enabled="Boolean(currentModal)"
|
:enabled="typeof currentModal !== 'undefined'"
|
||||||
@close="closeModal()"
|
|
||||||
variant="scrolling"
|
variant="scrolling"
|
||||||
class="task-detail-view-modal"
|
class="task-detail-view-modal"
|
||||||
|
@close="closeModal()"
|
||||||
>
|
>
|
||||||
<component :is="currentModal"/>
|
<component :is="currentModal" />
|
||||||
</modal>
|
</modal>
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
|
v-shortcut="'?'"
|
||||||
class="keyboard-shortcuts-button d-print-none"
|
class="keyboard-shortcuts-button d-print-none"
|
||||||
@click="showKeyboardShortcuts()"
|
@click="showKeyboardShortcuts()"
|
||||||
v-shortcut="'?'"
|
|
||||||
>
|
>
|
||||||
<icon icon="keyboard"/>
|
<icon icon="keyboard" />
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
@ -69,6 +73,7 @@ import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
|
||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
import {useLabelStore} from '@/stores/labels'
|
import {useLabelStore} from '@/stores/labels'
|
||||||
|
import {useProjectStore} from '@/stores/projects'
|
||||||
|
|
||||||
import {useRouteWithModal} from '@/composables/useRouteWithModal'
|
import {useRouteWithModal} from '@/composables/useRouteWithModal'
|
||||||
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
|
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
|
||||||
|
@ -86,30 +91,26 @@ function showKeyboardShortcuts() {
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
// hide menu on mobile
|
|
||||||
watch(() => route.fullPath, () => window.innerWidth < 769 && baseStore.setMenuActive(false))
|
|
||||||
|
|
||||||
// FIXME: this is really error prone
|
// 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) => {
|
watch(() => route.name as string, (routeName) => {
|
||||||
if (
|
if (
|
||||||
routeName &&
|
routeName &&
|
||||||
(
|
(
|
||||||
[
|
[
|
||||||
'home',
|
'home',
|
||||||
'namespace.edit',
|
|
||||||
'teams.index',
|
'teams.index',
|
||||||
'teams.edit',
|
'teams.edit',
|
||||||
'tasks.range',
|
'tasks.range',
|
||||||
'labels.index',
|
'labels.index',
|
||||||
'migrate.start',
|
'migrate.start',
|
||||||
'migrate.wunderlist',
|
'migrate.wunderlist',
|
||||||
'namespaces.index',
|
'projects.index',
|
||||||
].includes(routeName) ||
|
].includes(routeName) ||
|
||||||
routeName.startsWith('user.settings')
|
routeName.startsWith('user.settings')
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
baseStore.handleSetCurrentList({list: null})
|
baseStore.handleSetCurrentProject({project: null})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -119,6 +120,9 @@ useRenewTokenOnFocus()
|
||||||
|
|
||||||
const labelStore = useLabelStore()
|
const labelStore = useLabelStore()
|
||||||
labelStore.loadAllLabels()
|
labelStore.loadAllLabels()
|
||||||
|
|
||||||
|
const projectStore = useProjectStore()
|
||||||
|
projectStore.loadProjects()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@ -159,6 +163,8 @@ labelStore.loadAllLabels()
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 1.5rem 0.5rem 1rem;
|
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) {
|
@media screen and (max-width: $tablet) {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
|
@ -224,9 +230,4 @@ labelStore.loadAllLabels()
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-touch .content-auth,
|
|
||||||
.content-auth.z-unset {
|
|
||||||
z-index: unset;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
|
@ -6,16 +6,20 @@
|
||||||
>
|
>
|
||||||
<div class="container has-text-centered link-share-view">
|
<div class="container has-text-centered link-share-view">
|
||||||
<div class="column is-10 is-offset-1">
|
<div class="column is-10 is-offset-1">
|
||||||
<Logo class="logo" v-if="logoVisible"/>
|
<Logo
|
||||||
|
v-if="logoVisible"
|
||||||
|
class="logo"
|
||||||
|
/>
|
||||||
<h1
|
<h1
|
||||||
:class="{'m-0': !logoVisible}"
|
:class="{'m-0': !logoVisible}"
|
||||||
:style="{ 'opacity': currentList.title === '' ? '0': '1' }"
|
:style="{ 'opacity': currentProject?.title === '' ? '0': '1' }"
|
||||||
class="title">
|
class="title"
|
||||||
{{ currentList.title === '' ? $t('misc.loading') : currentList.title }}
|
>
|
||||||
|
{{ currentProject?.title === '' ? $t('misc.loading') : currentProject?.title }}
|
||||||
</h1>
|
</h1>
|
||||||
<div class="box has-text-left view">
|
<div class="box has-text-left view">
|
||||||
<router-view/>
|
<router-view />
|
||||||
<PoweredByLink/>
|
<PoweredByLink />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -31,7 +35,7 @@ import Logo from '@/components/home/Logo.vue'
|
||||||
import PoweredByLink from './PoweredByLink.vue'
|
import PoweredByLink from './PoweredByLink.vue'
|
||||||
|
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
const currentList = computed(() => baseStore.currentList)
|
const currentProject = computed(() => baseStore.currentProject)
|
||||||
const background = computed(() => baseStore.background)
|
const background = computed(() => baseStore.background)
|
||||||
const logoVisible = computed(() => baseStore.logoVisible)
|
const logoVisible = computed(() => baseStore.logoVisible)
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,291 +1,138 @@
|
||||||
<template>
|
<template>
|
||||||
<aside :class="{'is-active': menuActive}" class="namespace-container">
|
<aside
|
||||||
|
:class="{'is-active': baseStore.menuActive}"
|
||||||
|
class="menu-container"
|
||||||
|
>
|
||||||
<nav class="menu top-menu">
|
<nav class="menu top-menu">
|
||||||
<router-link :to="{name: 'home'}" class="logo">
|
<router-link
|
||||||
<Logo width="164" height="48"/>
|
:to="{name: 'home'}"
|
||||||
|
class="logo"
|
||||||
|
>
|
||||||
|
<Logo
|
||||||
|
width="164"
|
||||||
|
height="48"
|
||||||
|
/>
|
||||||
</router-link>
|
</router-link>
|
||||||
<ul class="menu-list">
|
<menu class="menu-list other-menu-items">
|
||||||
<li>
|
<li>
|
||||||
<router-link :to="{ name: 'home'}" v-shortcut="'g o'">
|
<router-link
|
||||||
|
v-shortcut="'g o'"
|
||||||
|
:to="{ name: 'home'}"
|
||||||
|
>
|
||||||
<span class="menu-item-icon icon">
|
<span class="menu-item-icon icon">
|
||||||
<icon icon="calendar"/>
|
<icon icon="calendar" />
|
||||||
</span>
|
</span>
|
||||||
{{ $t('navigation.overview') }}
|
{{ $t('navigation.overview') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<router-link :to="{ name: 'tasks.range'}" v-shortcut="'g u'">
|
<router-link
|
||||||
|
v-shortcut="'g u'"
|
||||||
|
:to="{ name: 'tasks.range'}"
|
||||||
|
>
|
||||||
<span class="menu-item-icon icon">
|
<span class="menu-item-icon icon">
|
||||||
<icon :icon="['far', 'calendar-alt']"/>
|
<icon :icon="['far', 'calendar-alt']" />
|
||||||
</span>
|
</span>
|
||||||
{{ $t('navigation.upcoming') }}
|
{{ $t('navigation.upcoming') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<router-link :to="{ name: 'namespaces.index'}" v-shortcut="'g n'">
|
<router-link
|
||||||
|
v-shortcut="'g p'"
|
||||||
|
:to="{ name: 'projects.index'}"
|
||||||
|
>
|
||||||
<span class="menu-item-icon icon">
|
<span class="menu-item-icon icon">
|
||||||
<icon icon="layer-group"/>
|
<icon icon="layer-group" />
|
||||||
</span>
|
</span>
|
||||||
{{ $t('namespace.title') }}
|
{{ $t('project.projects') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<router-link :to="{ name: 'labels.index'}" v-shortcut="'g a'">
|
<router-link
|
||||||
|
v-shortcut="'g a'"
|
||||||
|
:to="{ name: 'labels.index'}"
|
||||||
|
>
|
||||||
<span class="menu-item-icon icon">
|
<span class="menu-item-icon icon">
|
||||||
<icon icon="tags"/>
|
<icon icon="tags" />
|
||||||
</span>
|
</span>
|
||||||
{{ $t('label.title') }}
|
{{ $t('label.title') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<router-link :to="{ name: 'teams.index'}" v-shortcut="'g m'">
|
<router-link
|
||||||
|
v-shortcut="'g m'"
|
||||||
|
:to="{ name: 'teams.index'}"
|
||||||
|
>
|
||||||
<span class="menu-item-icon icon">
|
<span class="menu-item-icon icon">
|
||||||
<icon icon="users"/>
|
<icon icon="users" />
|
||||||
</span>
|
</span>
|
||||||
{{ $t('team.title') }}
|
{{ $t('team.title') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</menu>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<nav class="menu namespaces-lists loader-container is-loading-small" :class="{'is-loading': loading}">
|
<Loading
|
||||||
<template v-for="(n, nk) in namespaces" :key="n.id">
|
v-if="projectStore.isLoading"
|
||||||
<div class="namespace-title" :class="{'has-menu': n.id > 0}">
|
variant="small"
|
||||||
<BaseButton
|
/>
|
||||||
@click="toggleLists(n.id)"
|
<template v-else>
|
||||||
class="menu-label"
|
<nav
|
||||||
v-tooltip="namespaceTitles[nk]"
|
v-if="favoriteProjects"
|
||||||
>
|
class="menu"
|
||||||
<ColorBubble
|
>
|
||||||
v-if="n.hexColor !== ''"
|
<ProjectsNavigation
|
||||||
:color="n.hexColor"
|
:model-value="favoriteProjects"
|
||||||
class="mr-1"
|
:can-edit-order="false"
|
||||||
/>
|
:can-collapse="false"
|
||||||
<span class="name">{{ namespaceTitles[nk] }}</span>
|
/>
|
||||||
<div
|
</nav>
|
||||||
class="icon menu-item-icon is-small toggle-lists-icon pl-2"
|
|
||||||
:class="{'active': typeof listsVisible[n.id] !== 'undefined' ? listsVisible[n.id] : true}"
|
<nav
|
||||||
>
|
v-if="savedFilterProjects"
|
||||||
<icon icon="chevron-down"/>
|
class="menu"
|
||||||
</div>
|
>
|
||||||
<span class="count" :class="{'ml-2 mr-0': n.id > 0}">
|
<ProjectsNavigation
|
||||||
({{ namespaceListsCount[nk] }})
|
:model-value="savedFilterProjects"
|
||||||
</span>
|
:can-edit-order="false"
|
||||||
</BaseButton>
|
:can-collapse="false"
|
||||||
<namespace-settings-dropdown class="menu-list-dropdown" :namespace="n" v-if="n.id > 0"/>
|
/>
|
||||||
</div>
|
</nav>
|
||||||
<!--
|
|
||||||
NOTE: a v-model / computed setter is not possible, since the updateActiveLists function
|
<nav class="menu">
|
||||||
triggered by the change needs to have access to the current namespace
|
<ProjectsNavigation
|
||||||
-->
|
:model-value="projects"
|
||||||
<draggable
|
:can-edit-order="true"
|
||||||
v-if="listsVisible[n.id] ?? true"
|
:can-collapse="true"
|
||||||
v-bind="dragOptions"
|
:level="1"
|
||||||
:modelValue="activeLists[nk]"
|
/>
|
||||||
@update:modelValue="(lists) => updateActiveLists(n, lists)"
|
</nav>
|
||||||
group="namespace-lists"
|
</template>
|
||||||
@start="() => drag = true"
|
|
||||||
@end="saveListPosition"
|
<PoweredByLink />
|
||||||
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>
|
|
||||||
<PoweredByLink/>
|
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref, computed, onMounted, onBeforeMount} from 'vue'
|
import {computed} from 'vue'
|
||||||
import draggable from 'zhyswan-vuedraggable'
|
|
||||||
import type {SortableEvent} from 'sortablejs'
|
|
||||||
|
|
||||||
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 PoweredByLink from '@/components/home/PoweredByLink.vue'
|
||||||
import Logo from '@/components/home/Logo.vue'
|
import Logo from '@/components/home/Logo.vue'
|
||||||
|
import Loading from '@/components/misc/loading.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 {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
import {useListStore} from '@/stores/lists'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
import {useNamespaceStore} from '@/stores/namespaces'
|
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
|
||||||
|
|
||||||
const drag = ref(false)
|
|
||||||
const dragOptions = {
|
|
||||||
animation: 100,
|
|
||||||
ghostClass: 'ghost',
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
const namespaceStore = useNamespaceStore()
|
const projectStore = useProjectStore()
|
||||||
const currentList = computed(() => baseStore.currentList)
|
|
||||||
const menuActive = computed(() => baseStore.menuActive)
|
|
||||||
const loading = computed(() => namespaceStore.isLoading)
|
|
||||||
|
|
||||||
|
const projects = computed(() => projectStore.notArchivedRootProjects)
|
||||||
const namespaces = computed(() => {
|
const favoriteProjects = computed(() => projectStore.favoriteProjects)
|
||||||
return namespaceStore.namespaces.filter(n => !n.isArchived)
|
const savedFilterProjects = computed(() => projectStore.savedFilterProjects)
|
||||||
})
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<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 {
|
.logo {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
@ -298,10 +145,10 @@ $vikunja-nav-selected-width: 0.4rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.namespace-container {
|
.menu-container {
|
||||||
background: $vikunja-nav-background;
|
background: var(--site-background);
|
||||||
color: $vikunja-nav-color;
|
color: $vikunja-nav-color;
|
||||||
padding: 0 0 1rem;
|
padding: 1rem 0;
|
||||||
transition: transform $transition-duration ease-in;
|
transition: transform $transition-duration ease-in;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: $navbar-height;
|
top: $navbar-height;
|
||||||
|
@ -323,235 +170,24 @@ $vikunja-nav-selected-width: 0.4rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// these are general menu styles
|
.top-menu .menu-list {
|
||||||
// should be in own components
|
li {
|
||||||
.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;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
min-height: 2.5rem;
|
font-family: $vikunja-font;
|
||||||
padding-top: 0;
|
}
|
||||||
padding-left: $navbar-padding;
|
|
||||||
|
|
||||||
overflow: hidden;
|
.list-menu-link,
|
||||||
margin-bottom: 0;
|
li > a {
|
||||||
flex: 1 1 auto;
|
padding-left: 2rem;
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
.name {
|
.icon {
|
||||||
overflow: hidden;
|
padding-bottom: .25rem;
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
.menu + .menu {
|
||||||
display: flex;
|
padding-top: math.div($navbar-padding, 2);
|
||||||
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});
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,81 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="update-notification" v-if="updateAvailable">
|
|
||||||
<p>{{ $t('update.available') }}</p>
|
|
||||||
<x-button @click="refreshApp()" :shadow="false" class="has-no-text-wrap">
|
|
||||||
{{ $t('update.do') }}
|
|
||||||
</x-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import {ref} from 'vue'
|
|
||||||
|
|
||||||
const updateAvailable = ref(false)
|
|
||||||
const registration = ref(null)
|
|
||||||
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()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function showRefreshUI(e: Event) {
|
|
||||||
console.log('recieved refresh event', e)
|
|
||||||
registration.value = e.detail
|
|
||||||
updateAvailable.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshApp() {
|
|
||||||
if (!registration.value || !registration.value.waiting) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Notify the service worker to actually do the update
|
|
||||||
registration.value.waiting.postMessage('skipWaiting')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.update-notification {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
background: $warning;
|
|
||||||
padding: .5rem;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,3 +1,5 @@
|
||||||
import {createAsyncComponent} from '@/helpers/createAsyncComponent'
|
import {createAsyncComponent} from '@/helpers/createAsyncComponent'
|
||||||
|
|
||||||
export default createAsyncComponent(() => import('@/components/input/editor.vue'))
|
const TipTap = createAsyncComponent(() => import('@/components/input/editor/TipTap.vue'))
|
||||||
|
|
||||||
|
export default TipTap
|
||||||
|
|
|
@ -6,19 +6,28 @@ import XButton from './button.vue'
|
||||||
<template>
|
<template>
|
||||||
<Story :layout="{ type: 'grid', width: '200px' }">
|
<Story :layout="{ type: 'grid', width: '200px' }">
|
||||||
<Variant title="primary">
|
<Variant title="primary">
|
||||||
<XButton @click="logEvent('Click', $event)" variant="primary">
|
<XButton
|
||||||
|
variant="primary"
|
||||||
|
@click="logEvent('Click', $event)"
|
||||||
|
>
|
||||||
Order pizza!
|
Order pizza!
|
||||||
</XButton>
|
</XButton>
|
||||||
</Variant>
|
</Variant>
|
||||||
|
|
||||||
<Variant title="secondary">
|
<Variant title="secondary">
|
||||||
<XButton @click="logEvent('Click', $event)" variant="secondary">
|
<XButton
|
||||||
|
variant="secondary"
|
||||||
|
@click="logEvent('Click', $event)"
|
||||||
|
>
|
||||||
Order spaghetti!
|
Order spaghetti!
|
||||||
</XButton>
|
</XButton>
|
||||||
</Variant>
|
</Variant>
|
||||||
|
|
||||||
<Variant title="tertiary">
|
<Variant title="tertiary">
|
||||||
<XButton @click="logEvent('Click', $event)" variant="tertiary">
|
<XButton
|
||||||
|
variant="tertiary"
|
||||||
|
@click="logEvent('Click', $event)"
|
||||||
|
>
|
||||||
Order tortellini!
|
Order tortellini!
|
||||||
</XButton>
|
</XButton>
|
||||||
</Variant>
|
</Variant>
|
||||||
|
|
|
@ -1,44 +1,83 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="color-picker-container">
|
<div class="color-picker-container">
|
||||||
<datalist :id="colorListID">
|
<datalist :id="colorListID">
|
||||||
<option v-for="defaultColor in defaultColors" :key="defaultColor" :value="defaultColor" />
|
<option
|
||||||
|
v-for="defaultColor in defaultColors"
|
||||||
|
:key="defaultColor"
|
||||||
|
:value="defaultColor"
|
||||||
|
/>
|
||||||
</datalist>
|
</datalist>
|
||||||
|
|
||||||
<div class="picker">
|
<div class="picker">
|
||||||
<input
|
<input
|
||||||
|
v-model="color"
|
||||||
class="picker__input"
|
class="picker__input"
|
||||||
type="color"
|
type="color"
|
||||||
v-model="color"
|
|
||||||
:list="colorListID"
|
:list="colorListID"
|
||||||
:class="{'is-empty': isEmpty}"
|
:class="{'is-empty': isEmpty}"
|
||||||
/>
|
>
|
||||||
<svg class="picker__pattern" v-show="isEmpty" viewBox="0 0 22 22" fill="fff">
|
<svg
|
||||||
<pattern id="checker" width="11" height="11" patternUnits="userSpaceOnUse" fill="FFF">
|
v-show="isEmpty"
|
||||||
<rect fill="#cccccc" x="0" width="5.5" height="5.5" y="0"></rect>
|
class="picker__pattern"
|
||||||
<rect fill="#cccccc" x="5.5" width="5.5" height="5.5" y="5.5"></rect>
|
viewBox="0 0 22 22"
|
||||||
|
fill="fff"
|
||||||
|
>
|
||||||
|
<pattern
|
||||||
|
id="checker"
|
||||||
|
width="11"
|
||||||
|
height="11"
|
||||||
|
patternUnits="userSpaceOnUse"
|
||||||
|
fill="FFF"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
fill="#cccccc"
|
||||||
|
x="0"
|
||||||
|
width="5.5"
|
||||||
|
height="5.5"
|
||||||
|
y="0"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
fill="#cccccc"
|
||||||
|
x="5.5"
|
||||||
|
width="5.5"
|
||||||
|
height="5.5"
|
||||||
|
y="5.5"
|
||||||
|
/>
|
||||||
</pattern>
|
</pattern>
|
||||||
<rect width="22" height="22" fill="url(#checker)"></rect>
|
<rect
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
fill="url(#checker)"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-button
|
<XButton
|
||||||
v-if="!isEmpty"
|
v-if="!isEmpty"
|
||||||
:disabled="isEmpty"
|
:disabled="isEmpty"
|
||||||
@click="reset"
|
|
||||||
class="is-small ml-2"
|
class="is-small ml-2"
|
||||||
:shadow="false"
|
:shadow="false"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
@click="reset"
|
||||||
>
|
>
|
||||||
{{ $t('input.resetColor') }}
|
{{ $t('input.resetColor') }}
|
||||||
</x-button>
|
</XButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, ref, toRef, watch} from 'vue'
|
import {computed, ref, watch} from 'vue'
|
||||||
import {createRandomID} from '@/helpers/randomId'
|
import {createRandomID} from '@/helpers/randomId'
|
||||||
import XButton from '@/components/input/button.vue'
|
import XButton from '@/components/input/button.vue'
|
||||||
|
|
||||||
|
const {
|
||||||
|
modelValue,
|
||||||
|
} = defineProps<{
|
||||||
|
modelValue: string,
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
const DEFAULT_COLORS = [
|
const DEFAULT_COLORS = [
|
||||||
'#1973ff',
|
'#1973ff',
|
||||||
'#7F23FF',
|
'#7F23FF',
|
||||||
|
@ -53,23 +92,18 @@ const lastChangeTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const defaultColors = ref(DEFAULT_COLORS)
|
const defaultColors = ref(DEFAULT_COLORS)
|
||||||
const colorListID = ref(createRandomID())
|
const colorListID = ref(createRandomID())
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
modelValue: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
menuPosition: {
|
|
||||||
type: String,
|
|
||||||
default: 'top',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
|
||||||
|
|
||||||
const modelValue = toRef(props, 'modelValue')
|
|
||||||
watch(
|
watch(
|
||||||
modelValue,
|
() => modelValue,
|
||||||
(newValue) => {
|
(newValue) => {
|
||||||
|
if (newValue === '' || newValue.startsWith('var(')) {
|
||||||
|
color.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newValue.startsWith('#') && (newValue.length === 6 || newValue.length === 3)) {
|
||||||
|
newValue = `#${newValue}`
|
||||||
|
}
|
||||||
|
|
||||||
color.value = newValue
|
color.value = newValue
|
||||||
},
|
},
|
||||||
{immediate: true},
|
{immediate: true},
|
||||||
|
|
|
@ -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>
|
|
|
@ -0,0 +1,74 @@
|
||||||
|
<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'
|
||||||
|
|
||||||
|
type ProjectFilterFunc = (p: IProject) => boolean
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Array as PropType<IProject[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
projectFilter: {
|
||||||
|
type: Function as PropType<ProjectFilterFunc>,
|
||||||
|
default: () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
return (_: IProject) => true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
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))
|
||||||
|
.filter(props.projectFilter)
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<multiselect
|
<Multiselect
|
||||||
v-model="selectedUsers"
|
v-model="selectedUsers"
|
||||||
:search-results="foundUsers"
|
:search-results="foundUsers"
|
||||||
:loading="userService.loading"
|
:loading="userService.loading"
|
||||||
|
|
|
@ -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,23 @@
|
||||||
'has-no-shadow': !shadow || variant === 'tertiary',
|
'has-no-shadow': !shadow || variant === 'tertiary',
|
||||||
}
|
}
|
||||||
]"
|
]"
|
||||||
|
:style="{
|
||||||
|
'--button-white-space': wrap ? 'break-spaces' : 'nowrap',
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<template v-if="icon">
|
<template v-if="icon">
|
||||||
<icon
|
<icon
|
||||||
v-if="showIconOnly"
|
v-if="showIconOnly"
|
||||||
:icon="icon"
|
:icon="icon"
|
||||||
:style="{'color': iconColor !== '' ? iconColor : false}"
|
:style="{'color': iconColor !== '' ? iconColor : undefined}"
|
||||||
/>
|
/>
|
||||||
<span class="icon is-small" v-else>
|
<span
|
||||||
|
v-else
|
||||||
|
class="icon is-small"
|
||||||
|
>
|
||||||
<icon
|
<icon
|
||||||
:icon="icon"
|
:icon="icon"
|
||||||
:style="{'color': iconColor !== '' ? iconColor : false}"
|
:style="{'color': iconColor !== '' ? iconColor : undefined}"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
@ -35,7 +41,7 @@ const BUTTON_TYPES_MAP = {
|
||||||
|
|
||||||
export type ButtonTypes = keyof typeof BUTTON_TYPES_MAP
|
export type ButtonTypes = keyof typeof BUTTON_TYPES_MAP
|
||||||
|
|
||||||
export default { name: 'x-button' }
|
export default { name: 'XButton' }
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
@ -44,12 +50,13 @@ import BaseButton, {type BaseButtonProps} from '@/components/base/BaseButton.vue
|
||||||
import type { IconProp } from '@fortawesome/fontawesome-svg-core'
|
import type { IconProp } from '@fortawesome/fontawesome-svg-core'
|
||||||
|
|
||||||
// extending the props of the BaseButton
|
// extending the props of the BaseButton
|
||||||
export interface ButtonProps extends BaseButtonProps {
|
export interface ButtonProps extends /* @vue-ignore */ BaseButtonProps {
|
||||||
variant?: ButtonTypes
|
variant?: ButtonTypes
|
||||||
icon?: IconProp
|
icon?: IconProp
|
||||||
iconColor?: string
|
iconColor?: string
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
shadow?: boolean
|
shadow?: boolean
|
||||||
|
wrap?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
@ -58,6 +65,7 @@ const {
|
||||||
iconColor = '',
|
iconColor = '',
|
||||||
loading = false,
|
loading = false,
|
||||||
shadow = true,
|
shadow = true,
|
||||||
|
wrap = true,
|
||||||
} = defineProps<ButtonProps>()
|
} = defineProps<ButtonProps>()
|
||||||
|
|
||||||
const variantClass = computed(() => BUTTON_TYPES_MAP[variant])
|
const variantClass = computed(() => BUTTON_TYPES_MAP[variant])
|
||||||
|
@ -77,7 +85,7 @@ const showIconOnly = computed(() => icon !== '' && typeof slots.default === 'und
|
||||||
min-height: $button-height;
|
min-height: $button-height;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
white-space: break-spaces;
|
white-space: var(--button-white-space);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
|
@ -99,7 +107,6 @@ const showIconOnly = computed(() => icon !== '' && typeof slots.default === 'und
|
||||||
&.is-primary.is-outlined:hover {
|
&.is-primary.is-outlined:hover {
|
||||||
color: var(--white);
|
color: var(--white);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-small {
|
.is-small {
|
||||||
|
|
|
@ -1,85 +1,29 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="datepicker">
|
<div class="datepicker">
|
||||||
<BaseButton @click.stop="toggleDatePopup" class="show" :disabled="disabled || undefined">
|
<SimpleButton
|
||||||
|
class="show"
|
||||||
|
:disabled="disabled || undefined"
|
||||||
|
@click.stop="toggleDatePopup"
|
||||||
|
>
|
||||||
{{ date === null ? chooseDateLabel : formatDateShort(date) }}
|
{{ date === null ? chooseDateLabel : formatDateShort(date) }}
|
||||||
</BaseButton>
|
</SimpleButton>
|
||||||
|
|
||||||
<CustomTransition name="fade">
|
<CustomTransition name="fade">
|
||||||
<div v-if="show" class="datepicker-popup" ref="datepickerPopup">
|
<div
|
||||||
|
v-if="show"
|
||||||
<BaseButton
|
ref="datepickerPopup"
|
||||||
v-if="(new Date()).getHours() < 21"
|
class="datepicker-popup"
|
||||||
class="datepicker__quick-select-date"
|
>
|
||||||
@click.stop="setDate('today')"
|
<DatepickerInline
|
||||||
>
|
v-model="date"
|
||||||
<span class="icon"><icon :icon="['far', 'calendar-alt']"/></span>
|
@update:modelValue="updateData"
|
||||||
<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"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<x-button
|
<x-button
|
||||||
|
v-cy="'closeDatepicker'"
|
||||||
class="datepicker__close-button"
|
class="datepicker__close-button"
|
||||||
:shadow="false"
|
:shadow="false"
|
||||||
@click="close"
|
@click="close"
|
||||||
v-cy="'closeDatepicker'"
|
|
||||||
>
|
>
|
||||||
{{ $t('misc.confirm') }}
|
{{ $t('misc.confirm') }}
|
||||||
</x-button>
|
</x-button>
|
||||||
|
@ -89,19 +33,15 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref, onMounted, onBeforeUnmount, toRef, watch, computed, type PropType} from 'vue'
|
import {ref, onMounted, onBeforeUnmount, toRef, watch, type PropType} from 'vue'
|
||||||
import flatPickr from 'vue-flatpickr-component'
|
|
||||||
import 'flatpickr/dist/flatpickr.css'
|
|
||||||
|
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
|
||||||
import CustomTransition from '@/components/misc/CustomTransition.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 {formatDateShort} from '@/helpers/time/formatDate'
|
||||||
import {calculateDayInterval} from '@/helpers/time/calculateDayInterval'
|
|
||||||
import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
|
|
||||||
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
|
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
|
||||||
import {createDateFromString} from '@/helpers/time/createDateFromString'
|
import {createDateFromString} from '@/helpers/time/createDateFromString'
|
||||||
import {useAuthStore} from '@/stores/auth'
|
|
||||||
import {useI18n} from 'vue-i18n'
|
import {useI18n} from 'vue-i18n'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
@ -123,9 +63,7 @@ const props = defineProps({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'close', 'close-on-change'])
|
const emit = defineEmits(['update:modelValue', 'close', 'closeOnChange'])
|
||||||
|
|
||||||
const {t} = useI18n({useScope: 'global'})
|
|
||||||
|
|
||||||
const date = ref<Date | null>()
|
const date = ref<Date | null>()
|
||||||
const show = ref(false)
|
const show = ref(false)
|
||||||
|
@ -141,37 +79,6 @@ watch(
|
||||||
{immediate: true},
|
{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) {
|
function setDateValue(dateString: string | Date | null) {
|
||||||
if (dateString === null) {
|
if (dateString === null) {
|
||||||
date.value = null
|
date.value = null
|
||||||
|
@ -208,33 +115,10 @@ function close() {
|
||||||
emit('close', changed.value)
|
emit('close', changed.value)
|
||||||
if (changed.value) {
|
if (changed.value) {
|
||||||
changed.value = false
|
changed.value = false
|
||||||
emit('close-on-change', changed.value)
|
emit('closeOnChange', changed.value)
|
||||||
}
|
}
|
||||||
}, 200)
|
}, 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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@ -257,42 +141,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 {
|
.datepicker__close-button {
|
||||||
margin: 1rem;
|
margin: 1rem;
|
||||||
width: calc(100% - 2rem);
|
width: calc(100% - 2rem);
|
||||||
|
|
|
@ -0,0 +1,225 @@
|
||||||
|
<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
|
||||||
|
v-model="flatPickrDate"
|
||||||
|
:config="flatPickerConfig"
|
||||||
|
/>
|
||||||
|
</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 {useI18n} from 'vue-i18n'
|
||||||
|
import { getFlatpickrLanguage } from '@/helpers/flatpickrLanguage'
|
||||||
|
|
||||||
|
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 flatPickerConfig = computed(() => ({
|
||||||
|
altFormat: t('date.altFormatLong'),
|
||||||
|
altInput: true,
|
||||||
|
dateFormat: 'Y-m-d H:i',
|
||||||
|
enableTime: true,
|
||||||
|
time_24hr: true,
|
||||||
|
inline: true,
|
||||||
|
locale: getFlatpickrLanguage(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date.value !== null) {
|
||||||
|
const oldDate = formatDate(date.value, 'yyy-LL-dd H:mm')
|
||||||
|
if (oldDate === newValue) {
|
||||||
|
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) {
|
||||||
|
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
|
||||||
|
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>
|
|
@ -1,446 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="editor">
|
|
||||||
<div class="clear"></div>
|
|
||||||
|
|
||||||
<vue-easymde
|
|
||||||
:configs="config"
|
|
||||||
@change="() => bubble()"
|
|
||||||
@update:modelValue="handleInput"
|
|
||||||
class="content"
|
|
||||||
v-if="isEditActive"
|
|
||||||
v-model="text"/>
|
|
||||||
|
|
||||||
<div class="preview content" v-html="preview" v-if="isPreviewActive && text !== ''">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="has-text-centered has-text-grey is-italic my-5" v-if="showPreviewText">
|
|
||||||
{{ emptyText }}
|
|
||||||
<template v-if="isEditEnabled">
|
|
||||||
<ButtonLink
|
|
||||||
@click="toggleEdit"
|
|
||||||
v-shortcut="editShortcut"
|
|
||||||
class="d-print-none">
|
|
||||||
{{ $t('input.editor.edit') }}
|
|
||||||
</ButtonLink>.
|
|
||||||
</template>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul class="actions d-print-none" v-if="bottomActions.length > 0">
|
|
||||||
<li v-if="isEditEnabled && !showPreviewText && showSave">
|
|
||||||
<BaseButton
|
|
||||||
v-if="showEditButton"
|
|
||||||
@click="toggleEdit"
|
|
||||||
v-shortcut="editShortcut">
|
|
||||||
{{ $t('input.editor.edit') }}
|
|
||||||
</BaseButton>
|
|
||||||
<BaseButton
|
|
||||||
v-else-if="isEditActive"
|
|
||||||
@click="toggleEdit"
|
|
||||||
class="done-edit">
|
|
||||||
{{ $t('misc.save') }}
|
|
||||||
</BaseButton>
|
|
||||||
</li>
|
|
||||||
<li v-for="(action, k) in bottomActions" :key="k">
|
|
||||||
<BaseButton @click="action.action">{{ action.title }}</BaseButton>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<template v-else-if="isEditEnabled && showSave">
|
|
||||||
<ul v-if="showEditButton" class="actions d-print-none">
|
|
||||||
<li>
|
|
||||||
<BaseButton
|
|
||||||
@click="toggleEdit"
|
|
||||||
v-shortcut="editShortcut">
|
|
||||||
{{ $t('input.editor.edit') }}
|
|
||||||
</BaseButton>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<x-button
|
|
||||||
v-else-if="isEditActive"
|
|
||||||
@click="toggleEdit"
|
|
||||||
variant="secondary"
|
|
||||||
:shadow="false"
|
|
||||||
v-cy="'saveEditor'">
|
|
||||||
{{ $t('misc.save') }}
|
|
||||||
</x-button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {computed, nextTick, onMounted, ref, toRefs, watch} from 'vue'
|
|
||||||
|
|
||||||
import VueEasymde from './vue-easymde.vue'
|
|
||||||
import {marked} from 'marked'
|
|
||||||
import DOMPurify from 'dompurify'
|
|
||||||
|
|
||||||
import {createEasyMDEConfig} from './editorConfig'
|
|
||||||
|
|
||||||
import AttachmentModel from '@/models/attachment'
|
|
||||||
import AttachmentService from '@/services/attachment'
|
|
||||||
|
|
||||||
import {setupMarkdownRenderer} from '@/helpers/markdownRenderer'
|
|
||||||
import {findCheckboxesInText} from '@/helpers/checklistFromText'
|
|
||||||
import {createRandomID} from '@/helpers/randomId'
|
|
||||||
|
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
|
||||||
import ButtonLink from '@/components/misc/ButtonLink.vue'
|
|
||||||
import type { IAttachment } from '@/modelTypes/IAttachment'
|
|
||||||
import type { ITask } from '@/modelTypes/ITask'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
modelValue: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
placeholder: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
uploadEnabled: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
uploadCallback: {
|
|
||||||
type: Function,
|
|
||||||
},
|
|
||||||
hasPreview: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
previewIsDefault: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
isEditEnabled: {
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
bottomActions: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
emptyText: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
showSave: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
// If a key is passed the editor will go in "edit" mode when the key is pressed.
|
|
||||||
// Disabled if an empty string is passed.
|
|
||||||
editShortcut: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
|
||||||
|
|
||||||
const text = ref('')
|
|
||||||
const changeTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
const isEditActive = ref(false)
|
|
||||||
const isPreviewActive = ref(true)
|
|
||||||
|
|
||||||
const showPreviewText = computed(() => isPreviewActive.value && text.value === '' && props.emptyText !== '')
|
|
||||||
const showEditButton = computed(() => !isEditActive.value && text.value !== '')
|
|
||||||
|
|
||||||
const preview = ref('')
|
|
||||||
const attachmentService = new AttachmentService()
|
|
||||||
|
|
||||||
type CacheKey = `${ITask['id']}-${IAttachment['id']}`
|
|
||||||
const loadedAttachments = ref<{[key: CacheKey]: string}>({})
|
|
||||||
const config = ref(createEasyMDEConfig({
|
|
||||||
placeholder: props.placeholder,
|
|
||||||
uploadImage: props.uploadEnabled,
|
|
||||||
imageUploadFunction: props.uploadCallback,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const checkboxId = ref(createRandomID())
|
|
||||||
|
|
||||||
const {modelValue} = toRefs(props)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
modelValue,
|
|
||||||
async (value) => {
|
|
||||||
text.value = value
|
|
||||||
await nextTick()
|
|
||||||
renderPreview()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
text,
|
|
||||||
(newVal, oldVal) => {
|
|
||||||
// Only bubble the new value if it actually changed, but not if the component just got mounted and the text changed from the outside.
|
|
||||||
if (oldVal === '' && text.value === modelValue.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
bubble()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (modelValue.value !== '') {
|
|
||||||
text.value = modelValue.value
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.previewIsDefault && props.hasPreview) {
|
|
||||||
nextTick(() => renderPreview())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isPreviewActive.value = false
|
|
||||||
isEditActive.value = true
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
// This gets triggered when only pasting content into the editor.
|
|
||||||
// A change event would not get generated by that, an input event does.
|
|
||||||
// Therefore, we're using this handler to catch paste events.
|
|
||||||
// But because this also gets triggered when typing into the editor, we give
|
|
||||||
// it a higher timeout to make the timouts cancel each other in that case so
|
|
||||||
// that in the end, only one change event is triggered to the outside per change.
|
|
||||||
function handleInput(val: string) {
|
|
||||||
// Don't bubble if the text is up to date
|
|
||||||
if (val === text.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
text.value = val
|
|
||||||
bubble(1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function bubble(timeout = 500) {
|
|
||||||
if (changeTimeout.value !== null) {
|
|
||||||
clearTimeout(changeTimeout.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
changeTimeout.value = setTimeout(() => {
|
|
||||||
emit('update:modelValue', text.value)
|
|
||||||
}, timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceAt(str: string, index: number, replacement: string) {
|
|
||||||
return str.slice(0, index) + replacement + str.slice(index + replacement.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
function findNthIndex(str: string, n: number) {
|
|
||||||
const checkboxes = findCheckboxesInText(str)
|
|
||||||
return checkboxes[n]
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPreview() {
|
|
||||||
setupMarkdownRenderer(checkboxId.value)
|
|
||||||
|
|
||||||
preview.value = DOMPurify.sanitize(marked(text.value), {ADD_ATTR: ['target']})
|
|
||||||
|
|
||||||
// Since the render function is synchronous, we can't do async http requests in it.
|
|
||||||
// Therefore, we can't resolve the blob url at (markdown) compile time.
|
|
||||||
// To work around this, we modify the url after rendering it in the vue component.
|
|
||||||
// We're doing the whole thing in the next tick to ensure the image elements are available in the
|
|
||||||
// dom tree. If we're calling this right after setting this.preview it could be the images were
|
|
||||||
// not already made available.
|
|
||||||
// Some docs at https://stackoverflow.com/q/62865160/10924593
|
|
||||||
nextTick().then(async () => {
|
|
||||||
const attachmentImage = document.querySelectorAll<HTMLImageElement>('.attachment-image')
|
|
||||||
if (attachmentImage) {
|
|
||||||
Array.from(attachmentImage).forEach(async (img) => {
|
|
||||||
// The url is something like /tasks/<id>/attachments/<id>
|
|
||||||
const parts = img.dataset.src?.slice(window.API_URL.length + 1).split('/')
|
|
||||||
const taskId = Number(parts[1])
|
|
||||||
const attachmentId = Number(parts[3])
|
|
||||||
const cacheKey: CacheKey = `${taskId}-${attachmentId}`
|
|
||||||
|
|
||||||
if (typeof loadedAttachments.value[cacheKey] !== 'undefined') {
|
|
||||||
img.src = loadedAttachments.value[cacheKey]
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachment = new AttachmentModel({taskId: taskId, id: attachmentId})
|
|
||||||
|
|
||||||
const url = await attachmentService.getBlobUrl(attachment)
|
|
||||||
img.src = url
|
|
||||||
loadedAttachments.value[cacheKey] = url
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const textCheckbox = document.querySelectorAll<HTMLInputElement>(`.text-checkbox-${checkboxId.value}`)
|
|
||||||
if (textCheckbox) {
|
|
||||||
Array.from(textCheckbox).forEach(check => {
|
|
||||||
check.removeEventListener('change', handleCheckboxClick)
|
|
||||||
check.addEventListener('change', handleCheckboxClick)
|
|
||||||
check.parentElement?.classList.add('has-checkbox')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCheckboxClick(e: Event) {
|
|
||||||
// Find the original markdown checkbox this is targeting
|
|
||||||
const checked = (e.target as HTMLInputElement).checked
|
|
||||||
const numMarkdownCheck = Number((e.target as HTMLInputElement).dataset.checkboxNum)
|
|
||||||
|
|
||||||
const index = findNthIndex(text.value, numMarkdownCheck)
|
|
||||||
if (index < 0 || typeof index === 'undefined') {
|
|
||||||
console.debug('no index found')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const listPrefix = text.value.substring(index, index + 1)
|
|
||||||
|
|
||||||
console.debug({index, listPrefix, checked, text: text.value})
|
|
||||||
|
|
||||||
text.value = replaceAt(text.value, index, `${listPrefix} ${checked ? '[x]' : '[ ]'} `)
|
|
||||||
bubble()
|
|
||||||
renderPreview()
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleEdit() {
|
|
||||||
if (isEditActive.value) {
|
|
||||||
isPreviewActive.value = true
|
|
||||||
isEditActive.value = false
|
|
||||||
renderPreview()
|
|
||||||
bubble(0) // save instantly
|
|
||||||
} else {
|
|
||||||
isPreviewActive.value = false
|
|
||||||
isEditActive.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
@import 'codemirror/lib/codemirror.css';
|
|
||||||
@import 'highlight.js/scss/base16/equilibrium-gray-light';
|
|
||||||
|
|
||||||
.editor {
|
|
||||||
.clear {
|
|
||||||
clear: both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview.content {
|
|
||||||
margin-bottom: .5rem;
|
|
||||||
overflow-wrap: anywhere; // Safari does not understand "break-word" so we put that first to make sure it at least is able to show it somewhat properly there.
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
|
|
||||||
ul li {
|
|
||||||
input[type="checkbox"] {
|
|
||||||
margin-right: .5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.has-checkbox {
|
|
||||||
margin-left: -1.25rem;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.CodeMirror {
|
|
||||||
padding: .5rem;
|
|
||||||
border: 1px solid var(--grey-200) !important;
|
|
||||||
background: var(--white);
|
|
||||||
|
|
||||||
&-lines pre {
|
|
||||||
margin: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-placeholder {
|
|
||||||
color: var(--grey-400) !important;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-cursor {
|
|
||||||
border-color: var(--grey-700);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-preview {
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
&-side {
|
|
||||||
padding: .5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-toolbar {
|
|
||||||
background: var(--grey-50);
|
|
||||||
border: 1px solid var(--grey-200);
|
|
||||||
border-bottom: none;
|
|
||||||
|
|
||||||
button {
|
|
||||||
color: var(--grey-700);
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: var(--grey-200);
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
vertical-align: middle;
|
|
||||||
|
|
||||||
&, rect {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
position: absolute;
|
|
||||||
top: 24px;
|
|
||||||
margin-left: -3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--grey-200);
|
|
||||||
border-color: var(--grey-300);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
i.separator {
|
|
||||||
border-color: var(--grey-200) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pre.CodeMirror-line {
|
|
||||||
margin-bottom: 0 !important;
|
|
||||||
color: var(--grey-700) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-header {
|
|
||||||
font-family: $vikunja-font;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul.actions {
|
|
||||||
font-size: .8rem;
|
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
li {
|
|
||||||
display: inline-block;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: '·';
|
|
||||||
padding: 0 .25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child:after {
|
|
||||||
content: '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&, a {
|
|
||||||
color: var(--grey-500);
|
|
||||||
|
|
||||||
&.done-edit {
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.vue-easymde.content {
|
|
||||||
margin-bottom: 0 !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -0,0 +1,149 @@
|
||||||
|
<template>
|
||||||
|
<div class="items">
|
||||||
|
<template v-if="items.length">
|
||||||
|
<button
|
||||||
|
v-for="(item, index) in items"
|
||||||
|
:key="index"
|
||||||
|
class="item"
|
||||||
|
:class="{ 'is-selected': index === selectedIndex }"
|
||||||
|
@click="selectItem(index)"
|
||||||
|
>
|
||||||
|
<icon :icon="item.icon" />
|
||||||
|
<div class="description">
|
||||||
|
<p>{{ item.title }}</p>
|
||||||
|
<p>{{ item.description }}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="item"
|
||||||
|
>
|
||||||
|
No result
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
/* eslint-disable vue/component-api-style */
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
command: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selectedIndex: 0,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
items() {
|
||||||
|
this.selectedIndex = 0
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
onKeyDown({event}) {
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
this.upHandler()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
this.downHandler()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
this.enterHandler()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
|
||||||
|
upHandler() {
|
||||||
|
this.selectedIndex = ((this.selectedIndex + this.items.length) - 1) % this.items.length
|
||||||
|
},
|
||||||
|
|
||||||
|
downHandler() {
|
||||||
|
this.selectedIndex = (this.selectedIndex + 1) % this.items.length
|
||||||
|
},
|
||||||
|
|
||||||
|
enterHandler() {
|
||||||
|
this.selectItem(this.selectedIndex)
|
||||||
|
},
|
||||||
|
|
||||||
|
selectItem(index) {
|
||||||
|
const item = this.items[index]
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
this.command(item)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.items {
|
||||||
|
padding: 0.2rem;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--white);
|
||||||
|
color: var(--grey-900);
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: $radius;
|
||||||
|
border: 0;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
transition: background-color $transition;
|
||||||
|
|
||||||
|
&.is-selected, &:hover {
|
||||||
|
background: var(--grey-100);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
> svg {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border: 1px solid var(--grey-300);
|
||||||
|
padding: .5rem;
|
||||||
|
margin-right: .5rem;
|
||||||
|
border-radius: $radius;
|
||||||
|
color: var(--grey-700);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: .9rem;
|
||||||
|
color: var(--grey-800);
|
||||||
|
|
||||||
|
p:last-child {
|
||||||
|
font-size: .75rem;
|
||||||
|
color: var(--grey-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,422 @@
|
||||||
|
<template>
|
||||||
|
<div class="editor-toolbar">
|
||||||
|
<div class="editor-toolbar__segment">
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('input.editor.heading1')"
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
|
||||||
|
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<icon :icon="['fa', 'fa-header']" />
|
||||||
|
<span class="icon__lower-text">1</span>
|
||||||
|
</span>
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('input.editor.heading2')"
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
|
||||||
|
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<icon :icon="['fa', 'fa-header']" />
|
||||||
|
<span class="icon__lower-text">2</span>
|
||||||
|
</span>
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('input.editor.heading3')"
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
|
||||||
|
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<icon :icon="['fa', 'fa-header']" />
|
||||||
|
<span class="icon__lower-text">3</span>
|
||||||
|
</span>
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-toolbar__segment">
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('input.editor.bold')"
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:class="{ 'is-active': editor.isActive('bold') }"
|
||||||
|
@click="editor.chain().focus().toggleBold().run()"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<icon :icon="['fa', 'fa-bold']" />
|
||||||
|
</span>
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('input.editor.italic')"
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:class="{ 'is-active': editor.isActive('italic') }"
|
||||||
|
@click="editor.chain().focus().toggleItalic().run()"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<icon :icon="['fa', 'fa-italic']" />
|
||||||
|
</span>
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('input.editor.underline')"
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:class="{ 'is-active': editor.isActive('underline') }"
|
||||||
|
@click="editor.chain().focus().toggleUnderline().run()"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<icon :icon="['fa', 'fa-underline']" />
|
||||||
|
</span>
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('input.editor.strikethrough')"
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:class="{ 'is-active': editor.isActive('strike') }"
|
||||||
|
@click="editor.chain().focus().toggleStrike().run()"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<icon :icon="['fa', 'fa-strikethrough']" />
|
||||||
|
</span>
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-toolbar__segment">
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('input.editor.code')"
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:class="{ 'is-active': editor.isActive('codeBlock') }"
|
||||||
|
@click="editor.chain().focus().toggleCodeBlock().run()"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<icon :icon="['fa', 'fa-code']" />
|
||||||
|
</span>
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('input.editor.quote')"
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:class="{ 'is-active': editor.isActive('blockquote') }"
|
||||||
|
@click="editor.chain().focus().toggleBlockquote().run()"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<icon :icon="['fa', 'fa-quote-right']" />
|
||||||
|
</span>
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-toolbar__segment">
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('input.editor.bulletList')"
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:class="{ 'is-active': editor.isActive('bulletList') }"
|
||||||
|
@click="editor.chain().focus().toggleBulletList().run()"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<icon :icon="['fa', 'fa-list-ul']" />
|
||||||
|
</span>
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('input.editor.orderedList')"
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:class="{ 'is-active': editor.isActive('orderedList') }"
|
||||||
|
@click="editor.chain().focus().toggleOrderedList().run()"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<icon :icon="['fa', 'fa-list-ol']" />
|
||||||
|
</span>
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('input.editor.taskList')"
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:class="{ 'is-active': editor.isActive('taskList') }"
|
||||||
|
@click="editor.chain().focus().toggleTaskList().run()"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<icon icon="fa-list-check" />
|
||||||
|
</span>
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-toolbar__segment">
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('input.editor.image')"
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="openImagePicker"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<icon icon="fa-image" />
|
||||||
|
</span>
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-toolbar__segment">
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('input.editor.link')"
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:class="{ 'is-active': editor.isActive('link') }"
|
||||||
|
title="set link"
|
||||||
|
@click="setLink"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<icon :icon="['fa', 'fa-link']" />
|
||||||
|
</span>
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('input.editor.text')"
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:class="{ 'is-active': editor.isActive('paragraph') }"
|
||||||
|
title="paragraph"
|
||||||
|
@click="editor.chain().focus().setParagraph().run()"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<icon :icon="['fa', 'fa-paragraph']" />
|
||||||
|
</span>
|
||||||
|
</BaseButton>
|
||||||
|
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('input.editor.horizontalRule')"
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="editor.chain().focus().setHorizontalRule().run()"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<icon :icon="['fa', 'fa-ruler-horizontal']" />
|
||||||
|
</span>
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-toolbar__segment">
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('input.editor.undo')"
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="editor.chain().focus().undo().run()"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<icon :icon="['fa', 'fa-undo']" />
|
||||||
|
</span>
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('input.editor.redo')"
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="editor.chain().focus().redo().run()"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<icon :icon="['fa', 'fa-redo']" />
|
||||||
|
</span>
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-toolbar__segment">
|
||||||
|
<!-- table -->
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('input.editor.table.title')"
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:class="{ 'is-active': editor.isActive('table') }"
|
||||||
|
@click="toggleTableMode"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<icon :icon="['fa', 'fa-table']" />
|
||||||
|
</span>
|
||||||
|
</BaseButton>
|
||||||
|
<div
|
||||||
|
v-if="tableMode"
|
||||||
|
class="editor-toolbar__table-buttons"
|
||||||
|
>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||||
|
.run()
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ $t('input.editor.table.insert') }}
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:disabled="!editor.can().addColumnBefore"
|
||||||
|
@click="editor.chain().focus().addColumnBefore().run()"
|
||||||
|
>
|
||||||
|
{{ $t('input.editor.table.addColumnBefore') }}
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:disabled="!editor.can().addColumnAfter"
|
||||||
|
@click="editor.chain().focus().addColumnAfter().run()"
|
||||||
|
>
|
||||||
|
{{ $t('input.editor.table.addColumnAfter') }}
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:disabled="!editor.can().deleteColumn"
|
||||||
|
@click="editor.chain().focus().deleteColumn().run()"
|
||||||
|
>
|
||||||
|
{{ $t('input.editor.table.deleteColumn') }}
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:disabled="!editor.can().addRowBefore"
|
||||||
|
@click="editor.chain().focus().addRowBefore().run()"
|
||||||
|
>
|
||||||
|
{{ $t('input.editor.table.addRowBefore') }}
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:disabled="!editor.can().addRowAfter"
|
||||||
|
@click="editor.chain().focus().addRowAfter().run()"
|
||||||
|
>
|
||||||
|
{{ $t('input.editor.table.addRowAfter') }}
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:disabled="!editor.can().deleteRow"
|
||||||
|
@click="editor.chain().focus().deleteRow().run()"
|
||||||
|
>
|
||||||
|
{{ $t('input.editor.table.deleteRow') }}
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:disabled="!editor.can().deleteTable"
|
||||||
|
@click="editor.chain().focus().deleteTable().run()"
|
||||||
|
>
|
||||||
|
{{ $t('input.editor.table.deleteTable') }}
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:disabled="!editor.can().mergeCells"
|
||||||
|
@click="editor.chain().focus().mergeCells().run()"
|
||||||
|
>
|
||||||
|
{{ $t('input.editor.table.mergeCells') }}
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:disabled="!editor.can().splitCell"
|
||||||
|
@click="editor.chain().focus().splitCell().run()"
|
||||||
|
>
|
||||||
|
{{ $t('input.editor.table.splitCell') }}
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:disabled="!editor.can().toggleHeaderColumn"
|
||||||
|
@click="editor.chain().focus().toggleHeaderColumn().run()"
|
||||||
|
>
|
||||||
|
{{ $t('input.editor.table.toggleHeaderColumn') }}
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:disabled="!editor.can().toggleHeaderRow"
|
||||||
|
@click="editor.chain().focus().toggleHeaderRow().run()"
|
||||||
|
>
|
||||||
|
{{ $t('input.editor.table.toggleHeaderRow') }}
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:disabled="!editor.can().toggleHeaderCell"
|
||||||
|
@click="editor.chain().focus().toggleHeaderCell().run()"
|
||||||
|
>
|
||||||
|
{{ $t('input.editor.table.toggleHeaderCell') }}
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:disabled="!editor.can().mergeOrSplit"
|
||||||
|
@click="editor.chain().focus().mergeOrSplit().run()"
|
||||||
|
>
|
||||||
|
{{ $t('input.editor.table.mergeOrSplit') }}
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
:disabled="!editor.can().fixTables"
|
||||||
|
@click="editor.chain().focus().fixTables().run()"
|
||||||
|
>
|
||||||
|
{{ $t('input.editor.table.fixTables') }}
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref} from 'vue'
|
||||||
|
import {Editor} from '@tiptap/vue-3'
|
||||||
|
|
||||||
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
import {setLinkInEditor} from '@/components/input/editor/setLinkInEditor'
|
||||||
|
|
||||||
|
const {
|
||||||
|
editor = null,
|
||||||
|
} = defineProps<{
|
||||||
|
editor: Editor,
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const tableMode = ref(false)
|
||||||
|
|
||||||
|
function toggleTableMode() {
|
||||||
|
tableMode.value = !tableMode.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImagePicker() {
|
||||||
|
document.getElementById('tiptap__image-upload').click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLink(event) {
|
||||||
|
setLinkInEditor(event.target.getBoundingClientRect(), editor)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.editor-toolbar {
|
||||||
|
background: var(--white);
|
||||||
|
border: 1px solid var(--grey-200);
|
||||||
|
user-select: none;
|
||||||
|
padding: .5rem;
|
||||||
|
border-radius: $radius;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
> * + * {
|
||||||
|
border-left: 1px solid var(--grey-200);
|
||||||
|
margin-left: 6px;
|
||||||
|
padding-left: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar__button {
|
||||||
|
min-width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: $radius;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--grey-700);
|
||||||
|
transition: all $transition;
|
||||||
|
background: transparent;
|
||||||
|
margin-right: .25rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--grey-100);
|
||||||
|
border-color: var(--grey-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.icon__lower-text {
|
||||||
|
font-size: .75rem;
|
||||||
|
position: absolute;
|
||||||
|
bottom: -3px;
|
||||||
|
right: -2px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar__table-buttons {
|
||||||
|
margin-top: .5rem;
|
||||||
|
|
||||||
|
> .editor-toolbar__button {
|
||||||
|
margin-right: .5rem;
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
padding: 0 .25rem;
|
||||||
|
border: 1px solid var(--grey-400);
|
||||||
|
font-size: .75rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,945 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="tiptapInstanceRef"
|
||||||
|
class="tiptap"
|
||||||
|
>
|
||||||
|
<EditorToolbar
|
||||||
|
v-if="editor && isEditing"
|
||||||
|
:editor="editor"
|
||||||
|
:upload-callback="uploadCallback"
|
||||||
|
/>
|
||||||
|
<BubbleMenu
|
||||||
|
v-if="editor && isEditing"
|
||||||
|
:editor="editor"
|
||||||
|
class="editor-bubble__wrapper"
|
||||||
|
>
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('input.editor.bold')"
|
||||||
|
class="editor-bubble__button"
|
||||||
|
:class="{ 'is-active': editor.isActive('bold') }"
|
||||||
|
@click="editor.chain().focus().toggleBold().run()"
|
||||||
|
>
|
||||||
|
<icon :icon="['fa', 'fa-bold']" />
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('input.editor.italic')"
|
||||||
|
class="editor-bubble__button"
|
||||||
|
:class="{ 'is-active': editor.isActive('italic') }"
|
||||||
|
@click="editor.chain().focus().toggleItalic().run()"
|
||||||
|
>
|
||||||
|
<icon :icon="['fa', 'fa-italic']" />
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('input.editor.underline')"
|
||||||
|
class="editor-bubble__button"
|
||||||
|
:class="{ 'is-active': editor.isActive('underline') }"
|
||||||
|
@click="editor.chain().focus().toggleUnderline().run()"
|
||||||
|
>
|
||||||
|
<icon :icon="['fa', 'fa-underline']" />
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('input.editor.strikethrough')"
|
||||||
|
class="editor-bubble__button"
|
||||||
|
:class="{ 'is-active': editor.isActive('strike') }"
|
||||||
|
@click="editor.chain().focus().toggleStrike().run()"
|
||||||
|
>
|
||||||
|
<icon :icon="['fa', 'fa-strikethrough']" />
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('input.editor.code')"
|
||||||
|
class="editor-bubble__button"
|
||||||
|
:class="{ 'is-active': editor.isActive('code') }"
|
||||||
|
@click="editor.chain().focus().toggleCode().run()"
|
||||||
|
>
|
||||||
|
<icon :icon="['fa', 'fa-code']" />
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('input.editor.link')"
|
||||||
|
class="editor-bubble__button"
|
||||||
|
:class="{ 'is-active': editor.isActive('link') }"
|
||||||
|
@click="setLink"
|
||||||
|
>
|
||||||
|
<icon :icon="['fa', 'fa-link']" />
|
||||||
|
</BaseButton>
|
||||||
|
</BubbleMenu>
|
||||||
|
|
||||||
|
<EditorContent
|
||||||
|
class="tiptap__editor"
|
||||||
|
:class="{'tiptap__editor-is-edit-enabled': isEditing}"
|
||||||
|
:editor="editor"
|
||||||
|
@click="focusIfEditing()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-if="isEditing"
|
||||||
|
id="tiptap__image-upload"
|
||||||
|
ref="uploadInputRef"
|
||||||
|
type="file"
|
||||||
|
class="is-hidden"
|
||||||
|
@change="addImage"
|
||||||
|
>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
v-if="bottomActions.length === 0 && !isEditing && isEditEnabled"
|
||||||
|
class="tiptap__editor-actions d-print-none"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<BaseButton
|
||||||
|
class="done-edit"
|
||||||
|
@click="setEdit"
|
||||||
|
>
|
||||||
|
{{ $t('input.editor.edit') }}
|
||||||
|
</BaseButton>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<ul
|
||||||
|
v-if="bottomActions.length > 0"
|
||||||
|
class="tiptap__editor-actions d-print-none"
|
||||||
|
>
|
||||||
|
<li v-if="isEditing && showSave">
|
||||||
|
<BaseButton
|
||||||
|
class="done-edit"
|
||||||
|
@click="bubbleSave"
|
||||||
|
>
|
||||||
|
{{ $t('misc.save') }}
|
||||||
|
</BaseButton>
|
||||||
|
</li>
|
||||||
|
<li v-if="!isEditing">
|
||||||
|
<BaseButton
|
||||||
|
class="done-edit"
|
||||||
|
@click="setEdit"
|
||||||
|
>
|
||||||
|
{{ $t('input.editor.edit') }}
|
||||||
|
</BaseButton>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
v-for="(action, k) in bottomActions"
|
||||||
|
:key="k"
|
||||||
|
>
|
||||||
|
<BaseButton @click="action.action">
|
||||||
|
{{ action.title }}
|
||||||
|
</BaseButton>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<XButton
|
||||||
|
v-else-if="isEditing && showSave"
|
||||||
|
v-cy="'saveEditor'"
|
||||||
|
class="mt-4"
|
||||||
|
variant="secondary"
|
||||||
|
:shadow="false"
|
||||||
|
:disabled="!contentHasChanged"
|
||||||
|
@click="bubbleSave"
|
||||||
|
>
|
||||||
|
{{ $t('misc.save') }}
|
||||||
|
</XButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {computed, nextTick, onBeforeUnmount, onMounted, ref, watch} from 'vue'
|
||||||
|
|
||||||
|
import EditorToolbar from './EditorToolbar.vue'
|
||||||
|
|
||||||
|
import Link from '@tiptap/extension-link'
|
||||||
|
|
||||||
|
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
|
||||||
|
import Table from '@tiptap/extension-table'
|
||||||
|
import TableCell from '@tiptap/extension-table-cell'
|
||||||
|
import TableHeader from '@tiptap/extension-table-header'
|
||||||
|
import TableRow from '@tiptap/extension-table-row'
|
||||||
|
import Typography from '@tiptap/extension-typography'
|
||||||
|
import Image from '@tiptap/extension-image'
|
||||||
|
import Underline from '@tiptap/extension-underline'
|
||||||
|
|
||||||
|
import TaskItem from '@tiptap/extension-task-item'
|
||||||
|
import TaskList from '@tiptap/extension-task-list'
|
||||||
|
|
||||||
|
import {Blockquote} from '@tiptap/extension-blockquote'
|
||||||
|
import {Bold} from '@tiptap/extension-bold'
|
||||||
|
import {BulletList} from '@tiptap/extension-bullet-list'
|
||||||
|
import {Code} from '@tiptap/extension-code'
|
||||||
|
import {Document} from '@tiptap/extension-document'
|
||||||
|
import {Dropcursor} from '@tiptap/extension-dropcursor'
|
||||||
|
import {Gapcursor} from '@tiptap/extension-gapcursor'
|
||||||
|
import {HardBreak} from '@tiptap/extension-hard-break'
|
||||||
|
import {Heading} from '@tiptap/extension-heading'
|
||||||
|
import {History} from '@tiptap/extension-history'
|
||||||
|
import {HorizontalRule} from '@tiptap/extension-horizontal-rule'
|
||||||
|
import {Italic} from '@tiptap/extension-italic'
|
||||||
|
import {ListItem} from '@tiptap/extension-list-item'
|
||||||
|
import {OrderedList} from '@tiptap/extension-ordered-list'
|
||||||
|
import {Paragraph} from '@tiptap/extension-paragraph'
|
||||||
|
import {Strike} from '@tiptap/extension-strike'
|
||||||
|
import {Text} from '@tiptap/extension-text'
|
||||||
|
import {BubbleMenu, EditorContent, useEditor} from '@tiptap/vue-3'
|
||||||
|
import {Node} from '@tiptap/pm/model'
|
||||||
|
|
||||||
|
import Commands from './commands'
|
||||||
|
import suggestionSetup from './suggestion'
|
||||||
|
|
||||||
|
import {lowlight} from 'lowlight'
|
||||||
|
|
||||||
|
import type {BottomAction, UploadCallback} from './types'
|
||||||
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
|
import type {IAttachment} from '@/modelTypes/IAttachment'
|
||||||
|
import AttachmentModel from '@/models/attachment'
|
||||||
|
import AttachmentService from '@/services/attachment'
|
||||||
|
import {useI18n} from 'vue-i18n'
|
||||||
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
import XButton from '@/components/input/button.vue'
|
||||||
|
import {Placeholder} from '@tiptap/extension-placeholder'
|
||||||
|
import {eventToHotkeyString} from '@github/hotkey'
|
||||||
|
import {mergeAttributes} from '@tiptap/core'
|
||||||
|
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
|
||||||
|
import inputPrompt from '@/helpers/inputPrompt'
|
||||||
|
import {setLinkInEditor} from '@/components/input/editor/setLinkInEditor'
|
||||||
|
|
||||||
|
const {
|
||||||
|
modelValue,
|
||||||
|
uploadCallback,
|
||||||
|
isEditEnabled = true,
|
||||||
|
bottomActions = [],
|
||||||
|
showSave = false,
|
||||||
|
placeholder = '',
|
||||||
|
editShortcut = '',
|
||||||
|
} = defineProps<{
|
||||||
|
modelValue: string,
|
||||||
|
uploadCallback?: UploadCallback,
|
||||||
|
isEditEnabled?: boolean,
|
||||||
|
bottomActions?: BottomAction[],
|
||||||
|
showSave?: boolean,
|
||||||
|
placeholder?: string,
|
||||||
|
editShortcut?: string,
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'save'])
|
||||||
|
|
||||||
|
const tiptapInstanceRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const {t} = useI18n()
|
||||||
|
|
||||||
|
const CustomTableCell = TableCell.extend({
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
// extend the existing attributes …
|
||||||
|
...this.parent?.(),
|
||||||
|
|
||||||
|
// and add a new one …
|
||||||
|
backgroundColor: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element: HTMLElement) => element.getAttribute('data-background-color'),
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
return {
|
||||||
|
'data-background-color': attributes.backgroundColor,
|
||||||
|
style: `background-color: ${attributes.backgroundColor}`,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type CacheKey = `${ITask['id']}-${IAttachment['id']}`
|
||||||
|
const loadedAttachments = ref<{
|
||||||
|
[key: CacheKey]: string
|
||||||
|
}>({})
|
||||||
|
|
||||||
|
const CustomImage = Image.extend({
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
src: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
alt: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
id: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
'data-src': {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderHTML({HTMLAttributes}) {
|
||||||
|
if (HTMLAttributes.src?.startsWith(window.API_URL) || HTMLAttributes['data-src']?.startsWith(window.API_URL)) {
|
||||||
|
const imageUrl = HTMLAttributes['data-src'] ?? HTMLAttributes.src
|
||||||
|
|
||||||
|
// The url is something like /tasks/<id>/attachments/<id>
|
||||||
|
const parts = imageUrl.slice(window.API_URL.length + 1).split('/')
|
||||||
|
const taskId = Number(parts[1])
|
||||||
|
const attachmentId = Number(parts[3])
|
||||||
|
const cacheKey: CacheKey = `${taskId}-${attachmentId}`
|
||||||
|
const id = 'tiptap-image-' + cacheKey
|
||||||
|
|
||||||
|
nextTick(async () => {
|
||||||
|
|
||||||
|
const img = document.getElementById(id)
|
||||||
|
|
||||||
|
if (!img) return
|
||||||
|
|
||||||
|
if (typeof loadedAttachments.value[cacheKey] === 'undefined') {
|
||||||
|
|
||||||
|
const attachment = new AttachmentModel({taskId: taskId, id: attachmentId})
|
||||||
|
|
||||||
|
const attachmentService = new AttachmentService()
|
||||||
|
loadedAttachments.value[cacheKey] = await attachmentService.getBlobUrl(attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
img.src = loadedAttachments.value[cacheKey]
|
||||||
|
})
|
||||||
|
|
||||||
|
return ['img', mergeAttributes(this.options.HTMLAttributes, {
|
||||||
|
'data-src': imageUrl,
|
||||||
|
src: '#',
|
||||||
|
alt: HTMLAttributes.alt,
|
||||||
|
title: HTMLAttributes.title,
|
||||||
|
id,
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['img', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type Mode = 'edit' | 'preview'
|
||||||
|
|
||||||
|
const internalMode = ref<Mode>('preview')
|
||||||
|
const isEditing = computed(() => internalMode.value === 'edit' && isEditEnabled)
|
||||||
|
const contentHasChanged = ref<boolean>(false)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => internalMode.value,
|
||||||
|
mode => {
|
||||||
|
if (mode === 'preview') {
|
||||||
|
contentHasChanged.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
// eslint-disable-next-line vue/no-ref-object-destructure
|
||||||
|
editable: isEditing.value,
|
||||||
|
extensions: [
|
||||||
|
// Starterkit:
|
||||||
|
Blockquote,
|
||||||
|
Bold,
|
||||||
|
BulletList,
|
||||||
|
Code,
|
||||||
|
CodeBlockLowlight.configure({
|
||||||
|
lowlight,
|
||||||
|
}),
|
||||||
|
Document,
|
||||||
|
Dropcursor,
|
||||||
|
Gapcursor,
|
||||||
|
HardBreak.extend({
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
return {
|
||||||
|
'Mod-Enter': () => {
|
||||||
|
if (contentHasChanged.value) {
|
||||||
|
bubbleSave()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Heading,
|
||||||
|
History,
|
||||||
|
HorizontalRule,
|
||||||
|
Italic,
|
||||||
|
ListItem,
|
||||||
|
OrderedList,
|
||||||
|
Paragraph,
|
||||||
|
Strike,
|
||||||
|
Text,
|
||||||
|
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder: ({editor}) => {
|
||||||
|
if (!isEditing.value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editor.getText() !== '' && !editor.isFocused) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return placeholder !== ''
|
||||||
|
? placeholder
|
||||||
|
: t('input.editor.placeholder')
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Typography,
|
||||||
|
Underline,
|
||||||
|
Link.configure({
|
||||||
|
openOnClick: true,
|
||||||
|
validate: (href: string) => /^https?:\/\//.test(href),
|
||||||
|
}),
|
||||||
|
Table.configure({
|
||||||
|
resizable: true,
|
||||||
|
}),
|
||||||
|
TableRow,
|
||||||
|
TableHeader,
|
||||||
|
// Custom TableCell with backgroundColor attribute
|
||||||
|
CustomTableCell,
|
||||||
|
|
||||||
|
CustomImage,
|
||||||
|
|
||||||
|
TaskList,
|
||||||
|
TaskItem.configure({
|
||||||
|
nested: true,
|
||||||
|
onReadOnlyChecked: (node: Node, checked: boolean): boolean => {
|
||||||
|
if (!isEditEnabled) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// The following is a workaround for this bug:
|
||||||
|
// https://github.com/ueberdosis/tiptap/issues/4521
|
||||||
|
// https://github.com/ueberdosis/tiptap/issues/3676
|
||||||
|
|
||||||
|
editor.value!.state.doc.descendants((subnode, pos) => {
|
||||||
|
if (node.eq(subnode)) {
|
||||||
|
const {tr} = editor.value!.state
|
||||||
|
tr.setNodeMarkup(pos, undefined, {
|
||||||
|
...node.attrs,
|
||||||
|
checked,
|
||||||
|
})
|
||||||
|
editor.value!.view.dispatch(tr)
|
||||||
|
bubbleSave()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
Commands.configure({
|
||||||
|
suggestion: suggestionSetup(t),
|
||||||
|
}),
|
||||||
|
BubbleMenu,
|
||||||
|
],
|
||||||
|
onUpdate: () => {
|
||||||
|
bubbleNow()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => isEditing.value,
|
||||||
|
() => {
|
||||||
|
editor.value?.setEditable(isEditing.value)
|
||||||
|
},
|
||||||
|
{immediate: true},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => modelValue,
|
||||||
|
value => {
|
||||||
|
if (!editor?.value) return
|
||||||
|
|
||||||
|
if (editor.value.getHTML() === value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setModeAndValue(value)
|
||||||
|
},
|
||||||
|
{immediate: true},
|
||||||
|
)
|
||||||
|
|
||||||
|
function bubbleNow() {
|
||||||
|
if (editor.value?.getHTML() === modelValue) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contentHasChanged.value = true
|
||||||
|
emit('update:modelValue', editor.value?.getHTML())
|
||||||
|
}
|
||||||
|
|
||||||
|
function bubbleSave() {
|
||||||
|
bubbleNow()
|
||||||
|
emit('save', editor.value?.getHTML())
|
||||||
|
if (isEditing.value) {
|
||||||
|
internalMode.value = 'preview'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEdit(focus: boolean = true) {
|
||||||
|
internalMode.value = 'edit'
|
||||||
|
if (focus) {
|
||||||
|
editor.value?.commands.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => editor.value?.destroy())
|
||||||
|
|
||||||
|
const uploadInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
function uploadAndInsertFiles(files: File[] | FileList) {
|
||||||
|
uploadCallback(files).then(urls => {
|
||||||
|
urls?.forEach(url => {
|
||||||
|
editor.value
|
||||||
|
?.chain()
|
||||||
|
.focus()
|
||||||
|
.setImage({src: url})
|
||||||
|
.run()
|
||||||
|
})
|
||||||
|
bubbleSave()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addImage(event) {
|
||||||
|
|
||||||
|
if (typeof uploadCallback !== 'undefined') {
|
||||||
|
const files = uploadInputRef.value?.files
|
||||||
|
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadAndInsertFiles(files)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = await inputPrompt(event.target.getBoundingClientRect())
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
editor.value?.chain().focus().setImage({src: url}).run()
|
||||||
|
bubbleSave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLink(event) {
|
||||||
|
setLinkInEditor(event.target.getBoundingClientRect(), editor.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (editShortcut !== '') {
|
||||||
|
document.addEventListener('keydown', setFocusToEditor)
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const input = tiptapInstanceRef.value?.querySelectorAll('.tiptap__editor')[0]?.children[0]
|
||||||
|
input?.addEventListener('paste', handleImagePaste)
|
||||||
|
|
||||||
|
setModeAndValue(modelValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
const input = tiptapInstanceRef.value?.querySelectorAll('.tiptap__editor')[0]?.children[0]
|
||||||
|
input?.removeEventListener('paste', handleImagePaste)
|
||||||
|
})
|
||||||
|
if (editShortcut !== '') {
|
||||||
|
document.removeEventListener('keydown', setFocusToEditor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function setModeAndValue(value: string) {
|
||||||
|
internalMode.value = isEditorContentEmpty(value) ? 'edit' : 'preview'
|
||||||
|
editor.value?.commands.setContent(value, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImagePaste(event) {
|
||||||
|
if (event?.clipboardData?.items?.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const image = event.clipboardData.items[0]
|
||||||
|
if (image.kind === 'file' && image.type.startsWith('image/')) {
|
||||||
|
uploadAndInsertFiles([image.getAsFile()])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// See https://github.com/github/hotkey/discussions/85#discussioncomment-5214660
|
||||||
|
function setFocusToEditor(event) {
|
||||||
|
const hotkeyString = eventToHotkeyString(event)
|
||||||
|
if (!hotkeyString) return
|
||||||
|
if (hotkeyString !== editShortcut ||
|
||||||
|
event.target.tagName.toLowerCase() === 'input' ||
|
||||||
|
event.target.tagName.toLowerCase() === 'textarea' ||
|
||||||
|
event.target.contentEditable === 'true') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
if (!isEditing.value && isEditEnabled) {
|
||||||
|
internalMode.value = 'edit'
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.value?.commands.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusIfEditing() {
|
||||||
|
if (isEditing.value) {
|
||||||
|
editor.value?.commands.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickTasklistCheckbox(event) {
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
|
||||||
|
if (event.target.localName !== 'p') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.target.parentNode.parentNode.firstChild.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => isEditing.value,
|
||||||
|
editing => {
|
||||||
|
nextTick(() => {
|
||||||
|
const checkboxes = tiptapInstanceRef.value?.querySelectorAll('[data-checked]')
|
||||||
|
if (typeof checkboxes === 'undefined' || checkboxes.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
checkboxes.forEach(check => {
|
||||||
|
if (check.children.length < 2) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// We assume the first child contains the label element with the checkbox and the second child the actual label
|
||||||
|
// When the actual label is clicked, we forward that click to the checkbox.
|
||||||
|
check.children[1].removeEventListener('click', clickTasklistCheckbox)
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
checkboxes.forEach(check => {
|
||||||
|
if (check.children.length < 2) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// We assume the first child contains the label element with the checkbox and the second child the actual label
|
||||||
|
// When the actual label is clicked, we forward that click to the checkbox.
|
||||||
|
check.children[1].addEventListener('click', clickTasklistCheckbox)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{immediate: true},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.tiptap__editor {
|
||||||
|
&.tiptap__editor-is-edit-enabled {
|
||||||
|
min-height: 10rem;
|
||||||
|
|
||||||
|
.ProseMirror {
|
||||||
|
padding: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-within, &:focus {
|
||||||
|
box-shadow: 0 0 0 2px hsla(var(--primary-hsl), 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul[data-type='taskList'] li > div {
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transition: box-shadow $transition;
|
||||||
|
border-radius: $radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap p::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
color: var(--grey-400);
|
||||||
|
pointer-events: none;
|
||||||
|
height: 0;
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic editor styles
|
||||||
|
.ProseMirror {
|
||||||
|
padding: .5rem .5rem .5rem 0;
|
||||||
|
|
||||||
|
&:focus-within, &:focus {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
> * + * {
|
||||||
|
margin-top: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background-color: var(--grey-200);
|
||||||
|
color: var(--grey-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: var(--grey-200);
|
||||||
|
color: var(--grey-700);
|
||||||
|
font-family: 'JetBrainsMono', monospace;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: $radius;
|
||||||
|
|
||||||
|
code {
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-comment,
|
||||||
|
.hljs-quote {
|
||||||
|
color: var(--grey-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-variable,
|
||||||
|
.hljs-template-variable,
|
||||||
|
.hljs-attribute,
|
||||||
|
.hljs-tag,
|
||||||
|
.hljs-name,
|
||||||
|
.hljs-regexp,
|
||||||
|
.hljs-link,
|
||||||
|
.hljs-name,
|
||||||
|
.hljs-selector-id,
|
||||||
|
.hljs-selector-class {
|
||||||
|
color: var(--code-variable);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-number,
|
||||||
|
.hljs-meta,
|
||||||
|
.hljs-built_in,
|
||||||
|
.hljs-builtin-name,
|
||||||
|
.hljs-literal,
|
||||||
|
.hljs-type,
|
||||||
|
.hljs-params {
|
||||||
|
color: var(--code-literal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-string,
|
||||||
|
.hljs-symbol,
|
||||||
|
.hljs-bullet {
|
||||||
|
color: var(--code-symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-title,
|
||||||
|
.hljs-section {
|
||||||
|
color: var(--code-section);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-keyword,
|
||||||
|
.hljs-selector-tag {
|
||||||
|
color: var(--code-keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-emphasis {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
|
||||||
|
&.ProseMirror-selectednode {
|
||||||
|
outline: 3px solid var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
padding-left: 1rem;
|
||||||
|
border-left: 2px solid rgba(#0d0d0d, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 2px solid rgba(#0d0d0d, 0.1);
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror {
|
||||||
|
/* Table-specific styling */
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
min-width: 1em;
|
||||||
|
border: 2px solid #ced4da;
|
||||||
|
padding: 3px 5px;
|
||||||
|
vertical-align: top;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
background-color: #f1f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedCell:after {
|
||||||
|
z-index: 2;
|
||||||
|
position: absolute;
|
||||||
|
content: '';
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(200, 200, 255, 0.4);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
right: -2px;
|
||||||
|
top: 0;
|
||||||
|
bottom: -2px;
|
||||||
|
width: 4px;
|
||||||
|
background-color: #adf;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lists
|
||||||
|
ul {
|
||||||
|
margin-left: .5rem;
|
||||||
|
margin-top: 0 !important;
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-cursor {
|
||||||
|
cursor: ew-resize;
|
||||||
|
cursor: col-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
// tasklist
|
||||||
|
ul[data-type='taskList'] {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
> label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='checkbox'] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-bubble__wrapper {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: $radius;
|
||||||
|
border: 1px solid var(--grey-200);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-bubble__button {
|
||||||
|
color: var(--grey-700);
|
||||||
|
transition: all $transition;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: block;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
padding: .5rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--grey-200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.tiptap__editor-actions {
|
||||||
|
font-size: .8rem;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '·';
|
||||||
|
padding: 0 .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child:after {
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&, a {
|
||||||
|
color: var(--grey-500);
|
||||||
|
|
||||||
|
&.done-edit {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,28 @@
|
||||||
|
import {Extension} from '@tiptap/core'
|
||||||
|
import Suggestion from '@tiptap/suggestion'
|
||||||
|
|
||||||
|
// Copied and adjusted from https://github.com/ueberdosis/tiptap/tree/252acb32d27a0f9af14813eeed83d8a50059a43a/demos/src/Experiments/Commands/Vue
|
||||||
|
|
||||||
|
export default Extension.create({
|
||||||
|
name: 'slash-menu-commands',
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
suggestion: {
|
||||||
|
char: '/',
|
||||||
|
command: ({editor, range, props}) => {
|
||||||
|
props.command({editor, range})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
Suggestion({
|
||||||
|
editor: this.editor,
|
||||||
|
...this.options.suggestion,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue