mirror of
https://github.com/opensupports/opensupports.git
synced 2025-04-08 18:35:06 +02:00
Compare commits
839 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
20720ca4f9 | ||
|
40016635a8 | ||
|
68b2d2bf63 | ||
|
36c5f3264b | ||
|
82fd54ffd9 | ||
|
8c732f5dda | ||
|
b620baf5ed | ||
|
d3b47eaa73 | ||
|
90e7923aec | ||
|
0ecf88237f | ||
|
713a5b5ee1 | ||
|
b578a26225 | ||
|
93fa9e12a3 | ||
|
3720baf370 | ||
|
b1ef69c60c | ||
|
615f42a91b | ||
|
39f8a601db | ||
|
c82aaa001e | ||
|
04923b0e9d | ||
|
5e7f39df05 | ||
|
e190710252 | ||
|
a74d6ab4d7 | ||
|
a5da776d27 | ||
|
41d7aa5406 | ||
|
f2adb160be | ||
|
62bd70cc3b | ||
|
0f6c64674e | ||
|
861f7dc254 | ||
|
83b8e8094b | ||
|
81cc579d57 | ||
|
b3c8819d83 | ||
|
86cad910ec | ||
|
f890fdc2d3 | ||
|
c64eb9930b | ||
|
74870c632a | ||
|
e0738f1c88 | ||
|
545f88236d | ||
|
2e5bfa611e | ||
|
784d470ba5 | ||
|
54a5a9803d | ||
|
d5552c0f73 | ||
|
daf1db847c | ||
|
2df12aa5e3 | ||
|
c80c026617 | ||
|
9cf71dcf66 | ||
|
a264d384a1 | ||
|
d90b7d48c3 | ||
|
527843f00f | ||
|
3e3a95f518 | ||
|
639d40ddb0 | ||
|
fbee7275d5 | ||
|
7df190ea01 | ||
|
1228d593d0 | ||
|
c1a7befbed | ||
|
6e195a9109 | ||
|
bf7c1ba8f9 | ||
|
8b4b73402e | ||
|
4cf446483d | ||
|
ea6bc7f436 | ||
|
37209ef3fc | ||
|
83e75cc572 | ||
|
9291aa66a4 | ||
|
b9f5f7fcf1 | ||
|
fe1dd1bd48 | ||
|
5dd6b7acdc | ||
|
4bd8df1d5e | ||
|
8d7b178fa1 | ||
|
237801e9ed | ||
|
950439bf47 | ||
|
6cb538616d | ||
|
156b285344 | ||
|
b39e4c2a5f | ||
|
5a1b558a6d | ||
|
4b9a55b334 | ||
|
e7daf76274 | ||
|
402af565a9 | ||
|
f5e9b2602c | ||
|
f1d746f9f9 | ||
|
c0fc7933ed | ||
|
f31586874e | ||
|
d2c126aad9 | ||
|
ffd09b841f | ||
|
e23468d3be | ||
|
84f36e89dc | ||
|
9715cdc9a1 | ||
|
b39dca642b | ||
|
6f6acc925d | ||
|
d2e6dc2fbe | ||
|
0df57af11e | ||
|
8400a1caf0 | ||
|
f045c08b2e | ||
|
645e64532b | ||
|
b9f935df5f | ||
|
edddc8d2c5 | ||
|
eaeaaa647e | ||
|
d7ccff1a5a | ||
|
c5f1aa2b92 | ||
|
9ed4caf202 | ||
|
018863ab3e | ||
|
7015e21966 | ||
|
7cdb6d3603 | ||
|
1836849fa5 | ||
|
9f9e1dbd91 | ||
|
60b1b5eec5 | ||
|
7b4427d3e3 | ||
|
09150d6940 | ||
|
02cf8f0da3 | ||
|
e15bd15f07 | ||
|
c657d8291f | ||
|
b2e43430b1 | ||
|
8c17d22ab3 | ||
|
143776febe | ||
|
6536050fdd | ||
|
9a4374d371 | ||
|
b8be664809 | ||
|
e9a1a2e5be | ||
|
0f976ebde9 | ||
|
c64f1f1ea6 | ||
|
5d4fe0250b | ||
|
af15d0116d | ||
|
6ccb389492 | ||
|
ae076de88f | ||
|
59fb9eaef3 | ||
|
ffe7ef8e0b | ||
|
27e86c934c | ||
|
064f00388a | ||
|
fc93ad4c00 | ||
|
c22a1cdbb3 | ||
|
89cb4c18fd | ||
|
167d7927db | ||
|
55c89d58cc | ||
|
0bcc775944 | ||
|
e6441179c9 | ||
|
45a59e0b20 | ||
|
9f7b11413c | ||
|
e554bb64d1 | ||
|
0088332562 | ||
|
42c5dd7210 | ||
|
1a9e1a852c | ||
|
49dc1ab56c | ||
|
1ea4509e4f | ||
|
c5d6068e97 | ||
|
51eee4ed7b | ||
|
1e0e0134a3 | ||
|
bc9023b8a6 | ||
|
534bf3624a | ||
|
a7cb7f376c | ||
|
c3088406da | ||
|
ea8d0719eb | ||
|
e4a7fe8783 | ||
|
ca54d19bd9 | ||
|
5416ef4009 | ||
|
af3d95cf4d | ||
|
3dd76f214d | ||
|
16435925b6 | ||
|
ea273970d1 | ||
|
b9b21ef950 | ||
|
bb1f5d0ade | ||
|
15f765cf85 | ||
|
9feb7d6cd4 | ||
|
94926f90e6 | ||
|
b02acfdf7b | ||
|
560f231e51 | ||
|
7fb7be3860 | ||
|
b8944a3f04 | ||
|
a64c9f2255 | ||
|
994a39ad6d | ||
|
b73d6d534d | ||
|
d4cdbab203 | ||
|
80a9a958a8 | ||
|
9125944bc3 | ||
|
c00720d6a2 | ||
|
5184c31907 | ||
|
817240e0b4 | ||
|
52eae4d242 | ||
|
d3638787e6 | ||
|
68c3975ea4 | ||
|
6e6d2d83e7 | ||
|
371f111706 | ||
|
bb89956e9a | ||
|
a89a465ac9 | ||
|
9f915a3291 | ||
|
0fd23ac6b8 | ||
|
5021781d25 | ||
|
47f92569ef | ||
|
c92ecf25dc | ||
|
38136ade4d | ||
|
da534e3018 | ||
|
0518b0ac17 | ||
|
5d2e4fcb1f | ||
|
7a803dd7ff | ||
|
4077dac8c7 | ||
|
c4e211518c | ||
|
56ca30a7c3 | ||
|
fad3dce646 | ||
|
deb590ceee | ||
|
e36b984b23 | ||
|
e397d45c53 | ||
|
e4a9366b07 | ||
|
bbbc845fe7 | ||
|
a46140381b | ||
|
d72aec3976 | ||
|
1c5d156723 | ||
|
f72f2ac074 | ||
|
01a494ac23 | ||
|
4fd9db8651 | ||
|
01718cf92b | ||
|
96990c8c04 | ||
|
bb9873be4f | ||
|
4970b18c2d | ||
|
92e222f10d | ||
|
76b7e2c6e7 | ||
|
38c90e2c07 | ||
|
938e25b2fa | ||
|
1ecf619892 | ||
|
57b5ea8820 | ||
|
7866880152 | ||
|
6d938bead4 | ||
|
5b16393659 | ||
|
e3b57bd106 | ||
|
2bf6b2c23f | ||
|
384f7c93d7 | ||
|
785e2d8ac5 | ||
|
47ea47f971 | ||
|
c4a2c48eae | ||
|
791e0969e9 | ||
|
302a29db41 | ||
|
2e37c35b41 | ||
|
03df5725f7 | ||
|
9634fdfeb0 | ||
|
5a62ce10b7 | ||
|
cd73606852 | ||
|
507be7bff2 | ||
|
f8342ffa16 | ||
|
79527c690d | ||
|
14d81cff24 | ||
|
62497d1263 | ||
|
57434e2ef7 | ||
|
f5c96f814a | ||
|
d3f95bde05 | ||
|
82d6f47eea | ||
|
3f68febdd7 | ||
|
6d476d5d5d | ||
|
7daa53e47d | ||
|
b0b3ed7238 | ||
|
c7af383ef2 | ||
|
17e9dcf53b | ||
|
91d2c203d3 | ||
|
6a34c24d7d | ||
|
ec51024f93 | ||
|
978a988035 | ||
|
1e7a3e2f4a | ||
|
21290c600f | ||
|
13019ab446 | ||
|
09b7a049ed | ||
|
6fd392bc15 | ||
|
1d19d5578b | ||
|
eca89ea9b4 | ||
|
1f8a95b4da | ||
|
92a96c276b | ||
|
923b9c47c0 | ||
|
3d06020d5d | ||
|
482167f765 | ||
|
a9a0d61a69 | ||
|
50f5dd24b9 | ||
|
212bf0b1d0 | ||
|
64cfd5a8f1 | ||
|
2aded07b56 | ||
|
f31d0aa377 | ||
|
10adc62d90 | ||
|
33bf2c42dd | ||
|
5b75e5d732 | ||
|
c6c1a57c57 | ||
|
c2ce516b4d | ||
|
bd5db08ba5 | ||
|
55e88f2ac6 | ||
|
22b378e0ba | ||
|
31b58308e8 | ||
|
954fc27c54 | ||
|
57098c661e | ||
|
e947e72e09 | ||
|
0a4484133b | ||
|
5b1d3d8b50 | ||
|
aedae876d6 | ||
|
364aca247e | ||
|
a2502bc1c6 | ||
|
dc278db845 | ||
|
d520b96932 | ||
|
af0071dec2 | ||
|
ca63c3d08b | ||
|
74de20641f | ||
|
e44559618f | ||
|
72a9b1ef0e | ||
|
c941a4792d | ||
|
2d75f87def | ||
|
a06681085b | ||
|
f5ef43b020 | ||
|
ae34631bca | ||
|
f4b3ea2a65 | ||
|
46ca19c2cc | ||
|
7ccadadb81 | ||
|
9cff8cd789 | ||
|
b28e744ff7 | ||
|
c55d8ea964 | ||
|
b5639051f4 | ||
|
03d9127a8b | ||
|
23c750e6b9 | ||
|
fd8b431f55 | ||
|
c862c0a6fa | ||
|
8a6099174f | ||
|
a5078ddbc3 | ||
|
e24ec402e4 | ||
|
cd4e86c7bd | ||
|
e6466b76ac | ||
|
a6c3cfeba9 | ||
|
ae4aa4ead9 | ||
|
a77432ab12 | ||
|
e7aed6979f | ||
|
71a1d434ef | ||
|
fe31163a8a | ||
|
e63809400c | ||
|
42f8be1870 | ||
|
118b7e97cf | ||
|
0dd8e75241 | ||
|
780aa64d19 | ||
|
546fdb8ab5 | ||
|
179252303c | ||
|
b267a29d06 | ||
|
059a6325e9 | ||
|
880caa0388 | ||
|
bbe66b4dea | ||
|
8b32d5e86b | ||
|
8248ec4d03 | ||
|
f3b70ce497 | ||
|
f87f809b15 | ||
|
6ff46163b3 | ||
|
7c1315c244 | ||
|
0174233a24 | ||
|
5705115f73 | ||
|
943c910181 | ||
|
d56c46dba6 | ||
|
56b50596cb | ||
|
4141b31992 | ||
|
7b608a6d06 | ||
|
eb9a970353 | ||
|
9ca6632ee9 | ||
|
19df5857b3 | ||
|
ec98767e25 | ||
|
51e498f8c9 | ||
|
4d18bc9aa6 | ||
|
b52a65497e | ||
|
76cfbc8753 | ||
|
3435a8f2df | ||
|
5f1d002339 | ||
|
63313c4675 | ||
|
97d6bd6a36 | ||
|
af92a6bbf2 | ||
|
9f4a293107 | ||
|
cc9c51b856 | ||
|
8be43bb7ea | ||
|
702b786202 | ||
|
d5f5d988fb | ||
|
e64e8e65f1 | ||
|
9f84239c3e | ||
|
d25da2c5ff | ||
|
38c6295a7b | ||
|
f9e8a0abec | ||
|
843bd13281 | ||
|
b9e4a55b91 | ||
|
0bd099d05a | ||
|
c8634d457c | ||
|
7de248e5b1 | ||
|
61ce46d5c7 | ||
|
870f5fea46 | ||
|
2099af5f3a | ||
|
58be6127fb | ||
|
bac138757e | ||
|
2678947f85 | ||
|
682eedba0a | ||
|
1bbf4c49d7 | ||
|
6c04e50ea8 | ||
|
33af34ab12 | ||
|
5219b91388 | ||
|
ec88e02ad4 | ||
|
e8dab6922b | ||
|
01a0435f57 | ||
|
7e1749dbd1 | ||
|
44cab04157 | ||
|
8ea9c6ee9b | ||
|
57d3766c9d | ||
|
bc38411275 | ||
|
8aaacc5b2a | ||
|
995d302a49 | ||
|
c56be37340 | ||
|
545c910d5e | ||
|
b81c628faa | ||
|
1d84aa488c | ||
|
1c4bd7df17 | ||
|
5ab155f8ce | ||
|
fa5300a731 | ||
|
a883f4d430 | ||
|
bedf55d1ad | ||
|
f5f78a549c | ||
|
431ef44c8d | ||
|
e12857392b | ||
|
364f10f03a | ||
|
87e2984fa4 | ||
|
afa76ce059 | ||
|
643f3c81a9 | ||
|
93ade9cf0f | ||
|
18be18ebf8 | ||
|
7eae717b0c | ||
|
f3a0fbf7da | ||
|
d7e1edea01 | ||
|
5331c3363e | ||
|
aa7cefc959 | ||
|
0bfb36afd6 | ||
|
f558e64afc | ||
|
fb80336583 | ||
|
7e42cd97ea | ||
|
a477941b18 | ||
|
81a7300b14 | ||
|
d9becc4e45 | ||
|
ec3b1ec32a | ||
|
22efd7ea93 | ||
|
d0a6dd06d8 | ||
|
9041c21b8b | ||
|
4b2d27e757 | ||
|
620cd6b876 | ||
|
b2483f58c7 | ||
|
efbe553076 | ||
|
f2421b19f0 | ||
|
b9104c4c8a | ||
|
bbf20bb1de | ||
|
a992cb3f31 | ||
|
89fa93bc56 | ||
|
2ffb88c979 | ||
|
c475d0e35c | ||
|
68281e0985 | ||
|
eb055b1f79 | ||
|
896802f793 | ||
|
201d6367de | ||
|
8c35449ce4 | ||
|
ec0b50fd73 | ||
|
60de566446 | ||
|
d307b01832 | ||
|
63ef66198a | ||
|
875a45451a | ||
|
fdfe3a0ed2 | ||
|
97aa52e0db | ||
|
5ff7d114e5 | ||
|
309440caf7 | ||
|
844de1e10f | ||
|
c0f1f932c6 | ||
|
2e4817b144 | ||
|
5fe5ae7f32 | ||
|
514a278574 | ||
|
051f2f567c | ||
|
46db3bf27e | ||
|
fbfc32c4f8 | ||
|
a8859fa6a1 | ||
|
3c82e87d08 | ||
|
73f62d5463 | ||
|
45f846c2d9 | ||
|
b7b4161cd9 | ||
|
0278da8942 | ||
|
596aaf6a7f | ||
|
b81a28114f | ||
|
59413434b0 | ||
|
4c3049a4fa | ||
|
c70e9a444d | ||
|
5073188d71 | ||
|
36cfb1bcbe | ||
|
1106cd89b8 | ||
|
53e88a78f7 | ||
|
b495b83a93 | ||
|
74f26fb3f5 | ||
|
69d0c58172 | ||
|
94a8cd4431 | ||
|
37ab1c5817 | ||
|
6e0b7b7c80 | ||
|
5e356da79a | ||
|
863a43c501 | ||
|
28d50e7d24 | ||
|
0a5928f0bb | ||
|
f40e2228d5 | ||
|
3e94369f6b | ||
|
600ea95b16 | ||
|
a89973d366 | ||
|
f99767e884 | ||
|
223a39ace9 | ||
|
19a083996c | ||
|
a355fc30da | ||
|
3918bdcd61 | ||
|
5d3a805285 | ||
|
3b84142004 | ||
|
9cce0ccc84 | ||
|
30dcca3ea3 | ||
|
133d59e4a7 | ||
|
56f6aabd01 | ||
|
2b0f71d158 | ||
|
1f97a63983 | ||
|
d96e8b44b1 | ||
|
36d09ec919 | ||
|
1d3d18b345 | ||
|
5bf8ff94bc | ||
|
fad0e8eafb | ||
|
7092d19c27 | ||
|
c970c9923f | ||
|
4627405242 | ||
|
4b80a9b397 | ||
|
eed0fdce03 | ||
|
a43829c288 | ||
|
956dac1600 | ||
|
e35e843a29 | ||
|
ba5750a20d | ||
|
9a116c5c29 | ||
|
28f26d0956 | ||
|
ef023d139b | ||
|
f6f8262880 | ||
|
673a92c196 | ||
|
fcf6b887db | ||
|
a4c44fb9ab | ||
|
623a81b51d | ||
|
8a195f829b | ||
|
58c6f2e63f | ||
|
57feafd453 | ||
|
0339ef366f | ||
|
08a6f7b8d0 | ||
|
2ceb73edd7 | ||
|
466c37cfeb | ||
|
17e2dabeae | ||
|
c7f489d988 | ||
|
5c03a29f3a | ||
|
429796aee8 | ||
|
a85386a162 | ||
|
234de7ed2c | ||
|
31dd690cf2 | ||
|
7df893f18a | ||
|
004be8a55a | ||
|
0780863257 | ||
|
c6435dbe6b | ||
|
a309314e16 | ||
|
7b7dc334d8 | ||
|
e8365fc24e | ||
|
287c5afb2a | ||
|
e47df8e6dc | ||
|
761c3e4792 | ||
|
60036873b0 | ||
|
7439afff63 | ||
|
cc2fc2c295 | ||
|
e0d4a46929 | ||
|
aa795c3099 | ||
|
80934ff3f8 | ||
|
50818d162d | ||
|
d2b6f1cc30 | ||
|
1bc30240bd | ||
|
cbdda1e1dc | ||
|
de47dea0c7 | ||
|
7fcfe6a283 | ||
|
f9e74d8758 | ||
|
ba1d72d004 | ||
|
6596e29d9c | ||
|
80b6bcea8a | ||
|
79569fcfde | ||
|
b4dc92b8f1 | ||
|
540a91ccd5 | ||
|
ca80e3ed1f | ||
|
fb3f1a9c16 | ||
|
e28e1bbeb1 | ||
|
ace895a4a2 | ||
|
b2f9663001 | ||
|
1a5f38f6de | ||
|
5c88c8ac2a | ||
|
245254fa57 | ||
|
4cfa152164 | ||
|
77a388e225 | ||
|
54cc704dc6 | ||
|
7dd88a8f82 | ||
|
e1dffaef16 | ||
|
b56ff24a7d | ||
|
4251e3b5e7 | ||
|
59d59b6a7e | ||
|
048d18e3cb | ||
|
85eced56ff | ||
|
a187e06ce3 | ||
|
1533331e1d | ||
|
a756ac7210 | ||
|
7d949c0719 | ||
|
e855966561 | ||
|
e9dfbfb97b | ||
|
e8848d898e | ||
|
5f0996f243 | ||
|
221fee715d | ||
|
4d2d0eac31 | ||
|
0657857981 | ||
|
886d372f38 | ||
|
c436365c07 | ||
|
aa86fd9763 | ||
|
a2e505c33d | ||
|
9d67a19c35 | ||
|
e8c1ffeea7 | ||
|
8654433702 | ||
|
3cc622876c | ||
|
614b5a1a67 | ||
|
e6b23c7f29 | ||
|
13bd53b326 | ||
|
f5d77bdd54 | ||
|
5d83143664 | ||
|
9808ff5923 | ||
|
b09ccdec02 | ||
|
6e8c3279da | ||
|
917520601e | ||
|
c70868d3fa | ||
|
71689e021e | ||
|
c850fb30a9 | ||
|
099dd5a5a0 | ||
|
11c4401bfc | ||
|
b8bac44d43 | ||
|
5680660e5e | ||
|
b2fb45262b | ||
|
1edf282942 | ||
|
debe851546 | ||
|
d3fc148920 | ||
|
ac11db5505 | ||
|
8605001c44 | ||
|
9a40c63cf4 | ||
|
2adc708045 | ||
|
c3d1637615 | ||
|
5405f2b9ec | ||
|
b283748c42 | ||
|
ceb2717bd2 | ||
|
bd5fa9d520 | ||
|
c9ef8f1766 | ||
|
73b92bba86 | ||
|
a2d3908c4d | ||
|
4c52f6c6f5 | ||
|
4111401518 | ||
|
64b3820bd4 | ||
|
8882b53b3b | ||
|
60066bb9ca | ||
|
e33c97116a | ||
|
36b9c99243 | ||
|
80589c9ac5 | ||
|
8181c51da9 | ||
|
becb41d3ce | ||
|
28839575a2 | ||
|
b4232e5fd6 | ||
|
3f7dcc8d2c | ||
|
7740952956 | ||
|
621c44e28c | ||
|
72a7200e77 | ||
|
515e615418 | ||
|
303f71dab3 | ||
|
78b30eea0a | ||
|
56c23bf375 | ||
|
b5c3d7ee23 | ||
|
158bb099e5 | ||
|
77e302f191 | ||
|
c2148f48f9 | ||
|
7dcc58f88e | ||
|
b4342167e4 | ||
|
285b62832a | ||
|
4df3bab1ff | ||
|
03778f0e95 | ||
|
3b5d23b78b | ||
|
9d27912233 | ||
|
1aa43bf32e | ||
|
28f5b624ca | ||
|
89c42be18b | ||
|
e43835b2bc | ||
|
20fbfa1e05 | ||
|
e3a7f6c20a | ||
|
5335f67746 | ||
|
47375a0a2d | ||
|
1352587ec7 | ||
|
2838a76438 | ||
|
f052e7a568 | ||
|
eef4c8622d | ||
|
c994d6bc8b | ||
|
b80a526b7c | ||
|
51b086d63e | ||
|
8c6a5dee4a | ||
|
9c2b688ec4 | ||
|
25117074a0 | ||
|
6383b52fd0 | ||
|
dda8e28e93 | ||
|
2cfa455087 | ||
|
99ea7e0f8a | ||
|
876fd8d52f | ||
|
80ccccb951 | ||
|
949334fa71 | ||
|
da3cf30192 | ||
|
e60105cccc | ||
|
c0e0ba0d77 | ||
|
edb8ba1de0 | ||
|
ce58e064cf | ||
|
a093bb41a5 | ||
|
e204b1877c | ||
|
ac5b72f35d | ||
|
33c73988f2 | ||
|
fe05cc5f2c | ||
|
0b38addd58 | ||
|
305b94c567 | ||
|
cd2e1cd3df | ||
|
0a5d444186 | ||
|
050623713c | ||
|
96868abd92 | ||
|
92012d639b | ||
|
52934bc473 | ||
|
787bd9a42b | ||
|
2c1e5f1a61 | ||
|
46da0c6719 | ||
|
428a126ab1 | ||
|
254135dbf4 | ||
|
07e68b0a49 | ||
|
c9520d8a1e | ||
|
352cfe0ca5 | ||
|
0e0fbe324c | ||
|
a63991ad2d | ||
|
cd16026f31 | ||
|
0a88bbccd6 | ||
|
775d9a65c3 | ||
|
e52d4ca151 | ||
|
24e9352d44 | ||
|
dade0c995b | ||
|
e02c5e5021 | ||
|
aafc20291f | ||
|
e8c50339c1 | ||
|
a625971ec8 | ||
|
8418eeb872 | ||
|
1497fae844 | ||
|
29d1880d57 | ||
|
2e5c5ac9e0 | ||
|
7076d01a24 | ||
|
c26faaa902 | ||
|
1b8444ae18 | ||
|
d62b50dc23 | ||
|
a58c36c9b4 | ||
|
ad7051f2e0 | ||
|
5f07b85975 | ||
|
38d7474ef5 | ||
|
7ec6f96879 | ||
|
a5555b2596 | ||
|
17bdcc66d5 | ||
|
9c8052439c | ||
|
84f213454a | ||
|
1903ea5757 | ||
|
7ec01b43c8 | ||
|
5c81b67e7c | ||
|
423a366149 | ||
|
4fe9676318 | ||
|
7c52aa02bb | ||
|
c295371f4d | ||
|
7be7f06754 | ||
|
d0809ddc50 | ||
|
6471ca6b13 | ||
|
b3b20c443b | ||
|
43ef3e4e19 | ||
|
b915f28a59 | ||
|
ce3d2a249b | ||
|
635ccea020 | ||
|
d29f379ff6 | ||
|
d05a398ea8 | ||
|
8bf8ed5223 | ||
|
43f8b6017a | ||
|
f7ef552da1 | ||
|
e573a5460d | ||
|
909525d630 | ||
|
b8740f3aaa | ||
|
accc5ba7ae | ||
|
0c0e9c3e59 | ||
|
bc5968126d | ||
|
23f9fb7833 | ||
|
d0d6db77b7 | ||
|
4ea1b925a9 | ||
|
30fdb384f3 | ||
|
d26d511ebd | ||
|
48e0aabeef | ||
|
695149c497 | ||
|
7134f82b5f | ||
|
66de1f176d | ||
|
b54fadc75f | ||
|
d19443d94d | ||
|
266e0b23f5 | ||
|
9e3b6ef47f | ||
|
5460e5ad49 | ||
|
39fd45dced | ||
|
f0e1afc1ac | ||
|
2cabfe1887 | ||
|
8e750dbad5 | ||
|
8f8c481d58 | ||
|
f211bd9cea | ||
|
e088114b17 | ||
|
f09f86f7b3 | ||
|
a98d7ecdd1 | ||
|
824aecd501 | ||
|
3d416f82bd | ||
|
f12dfc3754 | ||
|
a68aa76b2f | ||
|
2d83116520 | ||
|
f227c9a9fa | ||
|
4b45a45a23 | ||
|
feea28a159 | ||
|
6f694e387c | ||
|
4b3ea709c8 | ||
|
e1d4ae7be4 | ||
|
02b6993615 | ||
|
0837754ffb | ||
|
472dd6e8bb | ||
|
c002fdf00f | ||
|
923571c5a2 | ||
|
9d4d1bb0b4 | ||
|
fc05f9b905 | ||
|
193bc0ec04 | ||
|
e3ca2c5e52 | ||
|
4528c74022 | ||
|
bb48dd234a | ||
|
8e6d28fff8 | ||
|
64986a70e1 | ||
|
83b1a7c69b | ||
|
a81e7320b7 | ||
|
49f80f642f | ||
|
c3bf5e9fe5 | ||
|
1b48369e27 | ||
|
3fabdfbe2f | ||
|
aa533856d5 | ||
|
84b128326c | ||
|
911f457fbf | ||
|
5e0d42ea03 | ||
|
e9803caeb5 | ||
|
a93bf5f3e7 | ||
|
052f4dc62a | ||
|
0c45fe9028 | ||
|
199a7f3339 | ||
|
abf9aba17b | ||
|
0aeb04c642 | ||
|
ea2c750515 | ||
|
33573550a0 |
190
.circleci/config.yml
Normal file
190
.circleci/config.yml
Normal file
@ -0,0 +1,190 @@
|
||||
version: 2.1
|
||||
orbs:
|
||||
php: circleci/php@1.0.2
|
||||
node: circleci/node@1.1.6
|
||||
aws-cli: circleci/aws-cli@1.0.0
|
||||
jobs:
|
||||
install_composer_packages:
|
||||
docker:
|
||||
- image: 'cimg/base:edge'
|
||||
steps:
|
||||
- checkout
|
||||
- php/install-php:
|
||||
version: '7.0'
|
||||
- php/install-composer
|
||||
|
||||
- run:
|
||||
name: Install php extensions
|
||||
command: |
|
||||
sudo add-apt-repository ppa:ondrej/php
|
||||
sudo apt update
|
||||
sudo apt install php7.0-imap -y
|
||||
sudo apt install php7.0-xml -y
|
||||
sudo apt install zip unzip php7.0-zip php7.0-mbstring -y
|
||||
|
||||
- php/install-packages:
|
||||
app-dir: server/
|
||||
|
||||
- persist_to_workspace:
|
||||
root: .
|
||||
paths:
|
||||
- .
|
||||
|
||||
install_node_packages:
|
||||
docker:
|
||||
- image: circleci/node:11.15.0-stretch
|
||||
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: .
|
||||
|
||||
- restore_cache:
|
||||
keys:
|
||||
- node-cache-{{ checksum "client/package.json" }}
|
||||
|
||||
- run:
|
||||
name: Install dependencies
|
||||
command: |
|
||||
sudo npm install -g npm@6.7.0
|
||||
sudo npm install -g mocha@6.2.0
|
||||
cd client && npm install
|
||||
|
||||
- save_cache:
|
||||
paths:
|
||||
- client/node_modules
|
||||
key: node-cache-{{ checksum "client/package.json" }}
|
||||
|
||||
- persist_to_workspace:
|
||||
root: .
|
||||
paths:
|
||||
- .
|
||||
|
||||
deploy_staging_files:
|
||||
docker:
|
||||
- image: circleci/node:11.15.0-stretch
|
||||
|
||||
environment:
|
||||
- GIT_COMMIT_DESC: git log --format=oneline -n 1 $CIRCLE_SHA1
|
||||
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: .
|
||||
|
||||
- deploy:
|
||||
name: Deploy staging files
|
||||
command: |
|
||||
if [ ! "$CIRCLE_BRANCH" = "master" ]; then exit 0; fi
|
||||
if [[ "$GIT_COMMIT_DESC" = Release* ]]; then exit 0; fi
|
||||
sudo apt update
|
||||
sudo apt install -y lftp
|
||||
make deploy-staging-files
|
||||
make deploy-staging-population
|
||||
|
||||
add_release_commit:
|
||||
docker:
|
||||
- image: circleci/node:11.15.0-stretch
|
||||
|
||||
parameters:
|
||||
version:
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: .
|
||||
|
||||
- add_ssh_keys:
|
||||
fingerprints:
|
||||
- "45:1e:cf:38:3f:9f:97:87:5b:b8:fd:e1:6c:71:11:41"
|
||||
|
||||
- run:
|
||||
name: Commit new version
|
||||
command: |
|
||||
export VERSION=<< parameters.version >>
|
||||
cd version_upgrades/release_script
|
||||
npm i
|
||||
npm run modify-files
|
||||
cd ../..
|
||||
ssh-keyscan -H github.com >> ~/.ssh/known_hosts
|
||||
git config --global user.email "ivan@opensupports.com"
|
||||
git config --global user.name "CircleCI-BOT"
|
||||
git add .
|
||||
git commit -m "Release $VERSION"
|
||||
git checkout -b release-${VERSION}
|
||||
git push origin release-${VERSION}
|
||||
|
||||
- persist_to_workspace:
|
||||
root: .
|
||||
paths:
|
||||
- .
|
||||
|
||||
add_release_tag:
|
||||
docker:
|
||||
- image: circleci/node:11.15.0-stretch
|
||||
|
||||
parameters:
|
||||
version:
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: .
|
||||
|
||||
- add_ssh_keys:
|
||||
fingerprints:
|
||||
- "45:1e:cf:38:3f:9f:97:87:5b:b8:fd:e1:6c:71:11:41"
|
||||
|
||||
- run:
|
||||
name: Add Release tag
|
||||
command: |
|
||||
export VERSION=<< parameters.version >>
|
||||
sudo apt-get update
|
||||
sudo apt-get install lftp
|
||||
make build-release-bundles
|
||||
make upload-bundles
|
||||
# make push-prerelease-tag
|
||||
make populate-staging-release
|
||||
|
||||
- persist_to_workspace:
|
||||
root: .
|
||||
paths:
|
||||
- .
|
||||
|
||||
parameters:
|
||||
version:
|
||||
type: string
|
||||
default: ""
|
||||
run_build:
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
workflows:
|
||||
build:
|
||||
when:
|
||||
and:
|
||||
- equal: [ master, << pipeline.git.branch >> ]
|
||||
- << pipeline.parameters.run_build >>
|
||||
jobs:
|
||||
- install_composer_packages
|
||||
- install_node_packages:
|
||||
requires:
|
||||
- install_composer_packages
|
||||
- deploy_staging_files:
|
||||
requires:
|
||||
- install_node_packages
|
||||
release:
|
||||
when: << pipeline.parameters.version >>
|
||||
jobs:
|
||||
- install_composer_packages
|
||||
- install_node_packages:
|
||||
requires:
|
||||
- install_composer_packages
|
||||
- add_release_commit:
|
||||
version: << pipeline.parameters.version >>
|
||||
requires:
|
||||
- install_node_packages
|
||||
- add_release_tag:
|
||||
version: << pipeline.parameters.version >>
|
||||
requires:
|
||||
- add_release_commit
|
14
.github/workflows/run-tests.yml
vendored
Normal file
14
.github/workflows/run-tests.yml
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
name: run-tests
|
||||
on: [push]
|
||||
jobs:
|
||||
run-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- run: cd server && make build
|
||||
- run: cd server && make run
|
||||
- run: cd server && make install-not-interactive
|
||||
- run: cd server && make setup-vendor-permissions
|
||||
- run: cd server && make test-not-interactive
|
||||
- run: cd tests && make build
|
||||
- run: cd tests && make run-not-interactive
|
14
.gitignore
vendored
14
.gitignore
vendored
@ -1,4 +1,12 @@
|
||||
server/composer.lock
|
||||
server/vendor
|
||||
.idea
|
||||
.jshintrc
|
||||
.jshintrc
|
||||
.DS_Store
|
||||
server/vendor
|
||||
server/files/
|
||||
!server/files/.gitkeep
|
||||
!server/files/.htaccess
|
||||
server/.dbdata
|
||||
server/.fakemail
|
||||
server/apidoc
|
||||
dist/
|
||||
.env
|
||||
|
15
.travis.yml
15
.travis.yml
@ -3,20 +3,23 @@ language: php
|
||||
php:
|
||||
- '5.6'
|
||||
- '7.0'
|
||||
- '7.1'
|
||||
- '7.2'
|
||||
|
||||
services:
|
||||
- mysql
|
||||
|
||||
|
||||
before_install:
|
||||
- rvm use 2.2 --install --binary --fuzzy
|
||||
- rvm use 2.3 --install --binary --fuzzy
|
||||
- ruby --version
|
||||
- mysql -e 'CREATE DATABASE development;'
|
||||
- nvm install 4.4.7
|
||||
- npm install -g npm@2.15.8
|
||||
- npm install -g mocha
|
||||
- nvm install 6.14.4
|
||||
- npm install -g npm@6.1.0
|
||||
- npm install -g mocha@6.2.0
|
||||
- cd client
|
||||
- npm install
|
||||
- cd ../tests
|
||||
- gem install bundler
|
||||
- bundle install
|
||||
- gem install bacon
|
||||
- cd ../server
|
||||
@ -27,5 +30,7 @@ before_install:
|
||||
script:
|
||||
- cd client
|
||||
- npm test
|
||||
- cd ../server
|
||||
- ./run-tests.sh
|
||||
- cd ../tests
|
||||
- bacon init.rb
|
||||
|
80
DEVELOPMENT.md
Normal file
80
DEVELOPMENT.md
Normal file
@ -0,0 +1,80 @@
|
||||
# Development
|
||||
|
||||
Here is a guide of how to set up the development environment in OpenSupports.
|
||||
|
||||
## Requirements
|
||||
* PHP 5.6+
|
||||
* MySQL 4.1+
|
||||
|
||||
### Getting up and running FRONT-END (client folder)
|
||||
1. Update: `sudo apt update`
|
||||
2. Clone this repo: `git clone https://github.com/opensupports/opensupports.git`
|
||||
3. Install `nvm`: https://github.com/nvm-sh/nvm
|
||||
4. Use node version 11.15.0: `nvm install 11` followed by `nvm use 11`
|
||||
5. Go to client: `cd opensupports/client`
|
||||
6. Install dependencies: `npm install`
|
||||
7. Rebuild node-sass: `npm rebuild node-sass`
|
||||
8. Run: `npm start` (PHP server api it must be running at :8080)
|
||||
10. Go to the main app: `http://localhost:3000/app`
|
||||
11. Your browser will automatically be opened and directed to the browser-sync proxy address.
|
||||
12. Use `npm start-fixtures` to enable fixtures and not require php server to be running.
|
||||
|
||||
OpenSupport uses by default the port 3000, but this port could already be used. If this is the case, you can modify this in the file: `client/webpack.config.js`.
|
||||
|
||||
##### Production Task
|
||||
|
||||
Just as there is a task for development, there is also a `npm build` task for putting the project into a production-ready state. This will run each of the tasks, while also adding the image minification task discussed above and the result store in `dist/` folder.
|
||||
|
||||
**Reminder:** Notice there is `index.html` and `index.php`. The first one searches the backend server where `config.js` says it, the second one uses `/api` to find the server. If you want to run OpenSupports in a single server, then use `index.php`.
|
||||
|
||||
#### Frontend Unit Testing
|
||||
1. Do the steps described before.
|
||||
2. Install mocha: `npm install -g mocha@6.2.0`
|
||||
3. Run `npm test` to run the tests.
|
||||
|
||||
### Getting up and running BACK-END (server folder)
|
||||
1. Install [Docker CE](https://docs.docker.com/install/)
|
||||
2. Go to the server folder: `cd opensupports/server`
|
||||
3. Run `make build` to build the images
|
||||
4. Run `make install` to install composer dependencies
|
||||
|
||||
- `make run` runs the backend and database
|
||||
- `make stop` stop backend and database server
|
||||
- `make log` show live server logs
|
||||
- `make db` access to mysql database console
|
||||
- `make sh` access to backend docker container bash
|
||||
- `make test` run phpunit tests
|
||||
- `make doc` to build the documentation (requires `apidoc`)
|
||||
|
||||
Server api runs on `http://localhost:8080/`
|
||||
Also, there's a *phpmyadmin* instance running on `http://localhost:6060/`,
|
||||
you can access with the username `root` and empty password
|
||||
|
||||
##### Building
|
||||
Once you've installed dependencies for frontend and backend, you can run `./build.sh` and it will generate a zip file inside `dist/` ready for distribution. You can use this file to install OpenSupports on a serving following the [installation instructions](https://github.com/opensupports/opensupports/wiki/Installation)
|
||||
|
||||
##### BACKEND API RUBY TESTING
|
||||
|
||||
1. Go to tests folder: `cd opensupports/tests`
|
||||
2. Run `make build` to install ruby container and its required dependencies
|
||||
|
||||
- `make run` for running tests (database will be cleared)
|
||||
- `make clear` for clearing database
|
||||
|
||||
##### BACKEND FAKE SMTP SERVER
|
||||
If you're doing development, you can use a FakeSMTP server to see the mails that are being sent.
|
||||
|
||||
1. Install Java if you don't have it yet:
|
||||
|
||||
`sudo apt-get install default-jre`
|
||||
`sudo apt-get install default-jdk`
|
||||
|
||||
2. [Download FakeSMTP](https://nilhcem.github.io/FakeSMTP/download.html)
|
||||
|
||||
3. Extract the file from the zip and run it:
|
||||
|
||||
`java -jar fakeSMTP-2.0.jar`
|
||||
|
||||
4. Set the port to 7070 and start the SMTP server.
|
||||
|
||||
5. Every time the application sends an email, it will be reflected there.
|
53
LICENSE
53
LICENSE
@ -619,56 +619,3 @@ Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
|
71
Makefile
Normal file
71
Makefile
Normal file
@ -0,0 +1,71 @@
|
||||
#!make
|
||||
-include .env
|
||||
|
||||
deploy-staging-files:
|
||||
./build.sh
|
||||
mv dist/opensupports_dev.zip .
|
||||
make upload-bundles
|
||||
|
||||
deploy-staging-population:
|
||||
curl --request POST \
|
||||
--url https://circleci.com/api/v2/project/github/opensupports/staging-population/pipeline \
|
||||
--header 'Circle-Token: ${CIRCLE_API_USER_TOKEN}' \
|
||||
--header 'content-type: application/json' \
|
||||
--data '{"branch":"master","parameters":{"server_to_deploy": "dev1"}}'
|
||||
curl --request POST \
|
||||
--url https://circleci.com/api/v2/project/github/opensupports/staging-population/pipeline \
|
||||
--header 'Circle-Token: ${CIRCLE_API_USER_TOKEN}' \
|
||||
--header 'content-type: application/json' \
|
||||
--data '{"branch":"master","parameters":{"server_to_deploy": "dev2"}}'
|
||||
curl --request POST \
|
||||
--url https://circleci.com/api/v2/project/github/opensupports/staging-population/pipeline \
|
||||
--header 'Circle-Token: ${CIRCLE_API_USER_TOKEN}' \
|
||||
--header 'content-type: application/json' \
|
||||
--data '{"branch":"master","parameters":{"server_to_deploy": "dev3"}}'
|
||||
curl --request POST \
|
||||
--url https://circleci.com/api/v2/project/github/opensupports/staging-population/pipeline \
|
||||
--header 'Circle-Token: ${CIRCLE_API_USER_TOKEN}' \
|
||||
--header 'content-type: application/json' \
|
||||
--data '{"branch":"master","parameters":{"server_to_deploy": "dev4"}}'
|
||||
build-release-bundles:
|
||||
$(eval UPGRADE_ZIP="opensupports_v$(VERSION)_update.zip")
|
||||
./build.sh
|
||||
mv dist/opensupports_dev.zip .
|
||||
cp opensupports_dev.zip ${UPGRADE_ZIP} && \
|
||||
mv opensupports_dev.zip opensupports_v${VERSION}.zip && \
|
||||
zip -d ${UPGRADE_ZIP} "api/config.php" && \
|
||||
(( \
|
||||
zip -r ${UPGRADE_ZIP} "version_upgrades/${VERSION}" && \
|
||||
zip -r ${UPGRADE_ZIP} "version_upgrades/mysql_connect.php" \
|
||||
) || true)
|
||||
|
||||
upload-bundles:
|
||||
for file in *.zip ; do \
|
||||
lftp -c "open -u $(FTP_USER),$(FTP_PASSWORD) $(FTP_HOST); set ssl:verify-certificate no; put -O /files/ $${file}"; \
|
||||
done
|
||||
|
||||
push-prerelease-tag:
|
||||
echo -e "Release v${VERSION}\n====\n" > log.txt && \
|
||||
git log $(git describe --tags --abbrev=0 @^)..@ --pretty=format:'%s' >> log.txt
|
||||
# ./version_upgrades/release_script/node_modules/.bin/github-release upload \
|
||||
# --owner opensupports \
|
||||
# --repo opensupports \
|
||||
# --draft true\
|
||||
# --tag "v$(VERSION)" \
|
||||
# --release-name "Release v$(VERSION)" \
|
||||
# --body "$(<log.txt)" \
|
||||
# opensupports_v${VERSION}.zip opensupports_v${VERSION}_update.zip
|
||||
|
||||
populate-staging-release:
|
||||
curl --request POST \
|
||||
--url https://circleci.com/api/v2/project/github/opensupports/staging-population/pipeline \
|
||||
--header 'Circle-Token: ${CIRCLE_API_USER_TOKEN}' \
|
||||
--header 'content-type: application/json' \
|
||||
--data '{"branch":"master","parameters":{"server_to_deploy": "westeros", "version_to_deploy": "${VERSION}"}}'
|
||||
curl --request POST \
|
||||
--url https://circleci.com/api/v2/project/github/opensupports/staging-population/pipeline \
|
||||
--header 'Circle-Token: ${CIRCLE_API_USER_TOKEN}' \
|
||||
--header 'content-type: application/json' \
|
||||
--data '{"branch":"master","parameters":{"server_to_deploy": "senate", "version_to_deploy": "${VERSION}_update"}}'
|
||||
|
||||
deploy-staging-release: build-release-bundles upload-bundles populate-staging-release
|
115
README.md
115
README.md
@ -1,106 +1,61 @@
|
||||

|
||||
<div align="center">
|
||||
|
||||
[](https://travis-ci.org/opensupports/opensupports)
|
||||

|
||||
|
||||
OpenSupports is an open source ticket system built primarly with PHP and ReactJS.
|
||||
Please, visit our website for more information: [http://www.opensupports.com/](http://www.opensupports.com/)
|
||||
OpenSupports is a simple and beautiful open source ticket system. <br />
|
||||
<a href="https://www.opensupports.com/"><strong>Learn more »</strong></a>
|
||||
<br />
|
||||
<p align="center">
|
||||
<a href="https://www.opensupports.com/">Website</a> •
|
||||
<a href="https://docs.opensupports.com/">Docs</a> •
|
||||
<a href="https://opensupports.com/demo/">Demo</a> •
|
||||
<a href="https://www.opensupports.com/pricing/">Official Subscription</a>
|
||||
</p>
|
||||
|
||||
## Requirements
|
||||
* PHP 5.6+
|
||||
* MySQL 4.1+
|
||||
</div>
|
||||
|
||||
## Development
|
||||
Here is a guide of how to set up the development environment in OpenSupports
|
||||
## 🌱 About the Project
|
||||
|
||||
### Getting up and running FRONT-END (client folder)
|
||||
0. update `sudo apt-get update`
|
||||
1. Clone this repo
|
||||
2. Install node 4.x version
|
||||
- `sudo apt-get install curl`
|
||||
- `curl -sL https://deb.nodesource.com/setup_4.x | sudo -E bash -`
|
||||
- `sudo apt-get install -y nodejs`
|
||||
3. Install npm `sudo apt-get install npm`
|
||||
4. Install gulp `sudo npm install -g gulp`
|
||||
5. Go to repo `cd os4-react/client`
|
||||
6. Install dependences `npm install`
|
||||
7. Rebuild node-sass `npm rebuild node-sass`
|
||||
8. Run `gulp dev`
|
||||
9. Go to the main app: `http://localhost:3000/app` or the component demo `http://localhost:3000/demo`
|
||||
10. Your browser will automatically be opened and directed to the browser-sync proxy address
|
||||
12. Use `gulp dev --api` to disable fixtures and use the real php server api (it must be running at :8080).
|
||||
### What Customers See
|
||||
|
||||
Now that `gulp dev` is running, the server is up as well and serving files from the `/build` directory. Any changes in the `/src` directory will be automatically processed by Gulp and the changes will be injected to any open browsers pointed at the proxy address.
|
||||

|
||||
|
||||
### What Staff Members See
|
||||
|
||||
##### Production Task
|
||||

|
||||
|
||||
Just as there is the `gulp dev` task for development, there is also a `gulp prod` task for putting the project into a production-ready state. This will run each of the tasks, while also adding the image minification task discussed above.
|
||||
## 🙌🏼 Ticket System for Absolutely Everyone
|
||||
|
||||
**Reminder:** Notice there is `index.html` and `index.php`. The firstone searches the backend server where `config.js` say it, the second one uses `/api` to find the server. If you want to run OpenSupports in a single server, then use `index.php`.
|
||||
OpenSupports is a simple and beautiful open source ticket system.
|
||||
|
||||
#### Frontend Unit Testing
|
||||
1. Do the steps described before
|
||||
2. Install mocha "sudo npm install -g mocha"
|
||||
3. Run `npm test` to run the tests
|
||||
It is a web application that provides you with a better management of your users’ queries. They send you tickets through OpenSupports and you can handle them appropriately.
|
||||
|
||||
### Getting up and running BACK-END (server folder)
|
||||
Self-hosted, or [hosted by us](https://www.opensupports.com/pricing/), API-driven, and ready to be deployed on your own domain.
|
||||
|
||||
1. Clone this repo
|
||||
2. [Install PHP 5.6](https://www.dev-metal.com/install-setup-php-5-6-ubuntu-14-04-lts/)
|
||||
3. [Create MySQL Database](#markdown-header-create-mysql-database)
|
||||
4. [Install composer](https://www.digitalocean.com/community/tutorials/how-to-install-and-use-composer-on-ubuntu-14-04)
|
||||
5. Go to `cd os4-react/api`
|
||||
6. Run `composer install`
|
||||
7. Run the server with `php -S localhost:8080`
|
||||
## 🧐 Stay Up-to-Date
|
||||
|
||||
##### Create MySQL Database
|
||||
OpenSupports is growing and steadily incorporating new features. You might want to **add a star to the project** (or watch updates) to be notified about new releases.
|
||||
|
||||
1. Install mysql-server
|
||||
## 💪🏼 Features
|
||||
|
||||
Ubuntu
|
||||
Check out our [most important features](https://opensupports.com/features) at our website.
|
||||
|
||||
`sudo apt-get install mysql-server`
|
||||
Are we missing something? [Suggest an improvement](https://github.com/opensupports/opensupports/issues/new)!
|
||||
|
||||
Cent OS
|
||||
## 🛠 Install
|
||||
|
||||
`sudo yum install mysql-server`
|
||||
`/etc/init.d/mysqld start`
|
||||
OpenSupports can be hosted on your own servers, or [hosted by us](https://www.opensupports.com/pricing/).
|
||||
|
||||
2. Access the mysql shell
|
||||
There are multiple benefits to having the system hosted by its creators, including official support into any problem you might encounter.
|
||||
|
||||
`mysql -u root`
|
||||
But in the case you prefer your development team to deal with the installation, maintenance (upgrades, backups, etc.), and integrations, we charge you nothing for it, OpenSupports is **free and open-source**!
|
||||
|
||||
3. Create a new database
|
||||
Check out our [installation guide](https://docs.opensupports.com/guides/installation/).
|
||||
|
||||
`CREATE DATABASE development;`
|
||||
## 👨🏼💻 Development
|
||||
|
||||
4. Run the MySQL server
|
||||
Are you a programmer? You can help us to fix bugs and build OpenSupports' features!
|
||||
|
||||
`sudo /etc/init.d/mysql start`
|
||||
|
||||
##### BACKEND API RUBY TESTING
|
||||
Check out our [development guide](./DEVELOPMENT.md) to get your development environment up and running.
|
||||
|
||||
1. Install ruby `sudo apt-get install ruby-full`
|
||||
2. Install mysql dev dependencies `sudo apt-get install libmysqlclient-dev libmysqlclient16 ruby-dev`
|
||||
3. Install bundle `sudo gem install bundler`
|
||||
4. Go to test folder `cd os4-react/tests`
|
||||
5. Install project dependencies `sudo gem install bundler`
|
||||
Test can run by using executing `run-tests.sh` file.
|
||||
|
||||
##### BACKEND FAKE SMTP SERVER
|
||||
If you're doing development, you can use a FakeSMTP server to see the mails that are being sent.
|
||||
|
||||
1. Install java if you don't have it jet
|
||||
|
||||
`sudo apt-get install default-jre`
|
||||
`sudo apt-get install default-jdk`
|
||||
|
||||
2. [Download FakeSMTP](https://nilhcem.github.io/FakeSMTP/download.html)
|
||||
|
||||
3. Extract the file from the zip and run it
|
||||
|
||||
`java -jar fakeSMTP-2.0.jar`
|
||||
|
||||
4. Set the port to 7070 and start the SMTP server
|
||||
|
||||
5. Every time the application sends an email, it will be reflected there.
|
||||
And even if you are not a programmer, you can help us by [reporting problems or suggesting improvements](https://github.com/opensupports/opensupports/issues/new), we love feedback and learn a lot from it!
|
||||
|
19
SECURITY.md
Normal file
19
SECURITY.md
Normal file
@ -0,0 +1,19 @@
|
||||
# Security Policy
|
||||
This document is intended to provide a guide to properly disclosure security issues found in our open source software.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you find a vulnerability or potential security issue in OpenSupports. Please contact us at contact@opensupports.com
|
||||
|
||||
We will acknowledge your email within 48 hours, and will send a more detailed response within 48 hours indicating the next steps in handling your report. After the initial reply to your report, we will endeavor to keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
When we receive a security bug report, we will assign it to a
|
||||
primary handler. This person will coordinate the fix and release process,
|
||||
involving the following steps:
|
||||
|
||||
* Confirm the problem and determine the affected versions.
|
||||
* Audit code to find any potential similar problems.
|
||||
* Prepare fixes for all releases still under maintenance. These fixes will be
|
||||
released as fast as possible in a new OpenSupports version.
|
37
build.sh
Executable file
37
build.sh
Executable file
@ -0,0 +1,37 @@
|
||||
echo "1/3 Building frontend..."
|
||||
cd client
|
||||
npm run build
|
||||
rm build/index.html
|
||||
echo "2/3 Creating api folder..."
|
||||
cd ../server
|
||||
echo -n > config.php
|
||||
mkdir files2
|
||||
mv files/.htaccess files2
|
||||
rm -rf files/
|
||||
mv files2 files
|
||||
cd ..
|
||||
mkdir api
|
||||
mv server/index.php api
|
||||
mv server/.htaccess api
|
||||
mv server/composer.json api
|
||||
mv server/composer.lock api
|
||||
mv server/controllers api
|
||||
mv server/data api
|
||||
mv server/libs api
|
||||
mv server/models api
|
||||
mv server/vendor api
|
||||
mv server/files api
|
||||
cp server/config.php api
|
||||
chmod -R 755 .
|
||||
cp client/src/index.php client/build
|
||||
echo "3/3 Generating zip..."
|
||||
cd client/build
|
||||
zip opensupports_dev.zip index.php
|
||||
zip -u opensupports_dev.zip .htaccess
|
||||
zip -u opensupports_dev.zip bundle.js
|
||||
zip -ur opensupports_dev.zip images
|
||||
mv opensupports_dev.zip ../..
|
||||
cd ../..
|
||||
zip -ur opensupports_dev.zip api
|
||||
mkdir dist
|
||||
mv opensupports_dev.zip dist
|
@ -1,3 +1,4 @@
|
||||
{
|
||||
"optional": ["es7.classProperties"]
|
||||
}
|
||||
"presets": ["@babel/preset-env", "@babel/preset-react"],
|
||||
"plugins": ["@babel/plugin-proposal-class-properties", "add-module-exports"]
|
||||
}
|
||||
|
11
client/Makefile
Normal file
11
client/Makefile
Normal file
@ -0,0 +1,11 @@
|
||||
build:
|
||||
@docker pull node:11.15.0
|
||||
|
||||
run: stop
|
||||
@docker run --platform=linux/amd64 --network os-net --name opensupports-client -v $(PWD):/client:delegated -p 3000:3000 node:11.15.0 sh -c "cd client && npm install && npm start"
|
||||
|
||||
sh: stop
|
||||
@docker run -it --platform=linux/amd64 --network os-net --name opensupports-client -v $(PWD):/client:delegated -p 3000:3000 node:11.15.0 sh -c "bash"
|
||||
|
||||
stop:
|
||||
@docker rm -f opensupports-client || true
|
@ -1,31 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
|
||||
'serverport': 3000,
|
||||
|
||||
'scripts': {
|
||||
'src': './src/*.js',
|
||||
'dest': './build/js/'
|
||||
},
|
||||
|
||||
'images': {
|
||||
'src': './src/assets/images/**/*.{jpeg,jpg,png}',
|
||||
'dest': './build/images/'
|
||||
},
|
||||
|
||||
'styles': {
|
||||
'src': './src/**/*.scss',
|
||||
'dest': './build/css/'
|
||||
},
|
||||
|
||||
'fonts': {
|
||||
'src': './src/scss/font_awesome/fonts/*',
|
||||
'dest': './build/fonts/'
|
||||
},
|
||||
|
||||
'sourceDir': './src/',
|
||||
|
||||
'buildDir': './build/'
|
||||
|
||||
};
|
@ -1,11 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
var fs = require('fs');
|
||||
var onlyScripts = require('./util/script-filter');
|
||||
var tasks = fs.readdirSync('./gulp/tasks/').filter(onlyScripts);
|
||||
|
||||
process.env.NODE_PATH = './src';
|
||||
|
||||
tasks.forEach(function(task) {
|
||||
require('./tasks/' + task);
|
||||
});
|
@ -1,14 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
var config = require('../config');
|
||||
var browserSync = require('browser-sync');
|
||||
var gulp = require('gulp');
|
||||
|
||||
gulp.task('browserSync', function() {
|
||||
|
||||
browserSync({
|
||||
proxy: 'localhost:' + config.serverport,
|
||||
startPath: '/'
|
||||
});
|
||||
|
||||
});
|
@ -1,78 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
var gulp = require('gulp');
|
||||
var gulpif = require('gulp-if');
|
||||
var gutil = require('gulp-util');
|
||||
var source = require('vinyl-source-stream');
|
||||
var streamify = require('gulp-streamify');
|
||||
var sourcemaps = require('gulp-sourcemaps');
|
||||
var rename = require('gulp-rename');
|
||||
var watchify = require('watchify');
|
||||
var browserify = require('browserify');
|
||||
var babelify = require('babelify');
|
||||
var uglify = require('gulp-uglify');
|
||||
var browserSync = require('browser-sync');
|
||||
var debowerify = require('debowerify');
|
||||
var handleErrors = require('../util/handle-errors');
|
||||
var config = require('../config');
|
||||
var util = require('gulp-util');
|
||||
|
||||
// Based on: http://blog.avisi.nl/2014/04/25/how-to-keep-a-fast-build-with-browserify-and-reactjs/
|
||||
function buildScript(file, watch) {
|
||||
|
||||
var bundler = browserify({
|
||||
entries: [config.sourceDir + file],
|
||||
debug: !global.isProd,
|
||||
insertGlobalVars: {
|
||||
isProd: function () {
|
||||
return (global.isProd) ? "'enabled'" : "'disabled'";
|
||||
},
|
||||
noFixtures: function() {
|
||||
return (util.env['api']) ? "'enabled'" : "'disabled'";
|
||||
}
|
||||
},
|
||||
cache: {},
|
||||
packageCache: {},
|
||||
fullPaths: false
|
||||
});
|
||||
|
||||
if ( watch ) {
|
||||
bundler = watchify(bundler);
|
||||
bundler.on('update', rebundle);
|
||||
}
|
||||
|
||||
bundler.transform(babelify, {'optional': ['es7.classProperties']});
|
||||
bundler.transform(debowerify);
|
||||
|
||||
function rebundle() {
|
||||
var stream = bundler.bundle();
|
||||
|
||||
gutil.log('Rebundle...');
|
||||
|
||||
return stream.on('error', handleErrors)
|
||||
.pipe(source(file))
|
||||
.pipe(gulpif(global.isProd, streamify(uglify())))
|
||||
.pipe(streamify(rename({
|
||||
basename: 'main'
|
||||
})))
|
||||
.pipe(gulpif(!global.isProd, sourcemaps.write('./')))
|
||||
.pipe(gulp.dest(config.scripts.dest))
|
||||
.pipe(gulpif(browserSync.active, browserSync.reload({ stream: true, once: true })));
|
||||
}
|
||||
|
||||
return rebundle();
|
||||
|
||||
}
|
||||
|
||||
gulp.task('browserify', function() {
|
||||
|
||||
// Only run watchify if NOT production
|
||||
return buildScript('index.js', !global.isProd);
|
||||
|
||||
});
|
||||
|
||||
gulp.task('config', function() {
|
||||
|
||||
return gulp.src(config.sourceDir + 'config.js')
|
||||
.pipe(gulp.dest(config.scripts.dest))
|
||||
});
|
@ -1,11 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
var config = require('../config');
|
||||
var gulp = require('gulp');
|
||||
var del = require('del');
|
||||
|
||||
gulp.task('clean', function(cb) {
|
||||
|
||||
del([config.buildDir], cb);
|
||||
|
||||
});
|
@ -1,10 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
var gulp = require('gulp');
|
||||
var config = require('../config');
|
||||
|
||||
gulp.task('copyFonts', function() {
|
||||
|
||||
return gulp.src(config.fonts.src)
|
||||
.pipe(gulp.dest(config.fonts.dest))
|
||||
});
|
@ -1,11 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
var gulp = require('gulp');
|
||||
|
||||
gulp.task('copyIcons', function() {
|
||||
|
||||
// Copy icons from root directory to build/
|
||||
return gulp.src(['./*.png', './favicon.ico'])
|
||||
.pipe(gulp.dest('build/'));
|
||||
|
||||
});
|
@ -1,12 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
var gulp = require('gulp');
|
||||
var config = require('../config');
|
||||
|
||||
gulp.task('copyIndex', function() {
|
||||
|
||||
gulp.src(config.sourceDir + 'index.html').pipe(gulp.dest(config.buildDir));
|
||||
gulp.src(config.sourceDir + 'index.php').pipe(gulp.dest(config.buildDir));
|
||||
gulp.src(config.sourceDir + '.htaccess').pipe(gulp.dest(config.buildDir));
|
||||
|
||||
});
|
@ -1,10 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
var gulp = require('gulp');
|
||||
//var config = require('../config');
|
||||
|
||||
gulp.task('deploy', ['prod'], function() {
|
||||
|
||||
// Deploy to hosting environment
|
||||
|
||||
});
|
@ -1,15 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
var gulp = require('gulp');
|
||||
var runSequence = require('run-sequence');
|
||||
|
||||
gulp.task('dev', ['clean'], function(callback) {
|
||||
|
||||
callback = callback || function() {};
|
||||
|
||||
global.isProd = false;
|
||||
|
||||
// Run all tasks once
|
||||
return runSequence(['sass', 'imagemin', 'browserify', 'copyFonts', 'copyIndex', 'copyIcons', 'config'], 'watch', callback);
|
||||
|
||||
});
|
@ -1,17 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
var gulp = require('gulp');
|
||||
var gulpif = require('gulp-if');
|
||||
var imagemin = require('gulp-imagemin');
|
||||
var browserSync = require('browser-sync');
|
||||
var config = require('../config');
|
||||
|
||||
gulp.task('imagemin', function() {
|
||||
|
||||
// Run imagemin task on all images
|
||||
return gulp.src(config.images.src)
|
||||
.pipe(gulpif(global.isProd, imagemin()))
|
||||
.pipe(gulp.dest(config.images.dest))
|
||||
.pipe(gulpif(browserSync.active, browserSync.reload({ stream: true, once: true })));
|
||||
|
||||
});
|
@ -1,15 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
var gulp = require('gulp');
|
||||
var runSequence = require('run-sequence');
|
||||
|
||||
gulp.task('prod', ['clean'], function(callback) {
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
callback = callback || function() {};
|
||||
|
||||
global.isProd = true;
|
||||
|
||||
runSequence(['sass', 'imagemin', 'browserify', 'copyFonts', 'copyIndex', 'copyIcons'], callback);
|
||||
|
||||
});
|
@ -1,26 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
var gulp = require('gulp');
|
||||
var sass = require('gulp-sass');
|
||||
var gulpif = require('gulp-if');
|
||||
var browserSync = require('browser-sync');
|
||||
var autoprefixer = require('gulp-autoprefixer');
|
||||
var bulkSass = require('gulp-sass-bulk-import');
|
||||
var handleErrors = require('../util/handle-errors');
|
||||
var config = require('../config');
|
||||
|
||||
gulp.task('sass', function () {
|
||||
|
||||
return gulp.src(config.styles.src)
|
||||
.pipe(bulkSass())
|
||||
.pipe(sass({
|
||||
sourceComments: global.isProd ? 'none' : 'map',
|
||||
sourceMap: 'sass',
|
||||
outputStyle: global.isProd ? 'compressed' : 'nested'
|
||||
}))
|
||||
.pipe(autoprefixer("last 2 versions", "> 1%", "ie 8"))
|
||||
.on('error', handleErrors)
|
||||
.pipe(gulp.dest(config.styles.dest))
|
||||
.pipe(gulpif(browserSync.active, browserSync.reload({ stream: true })));
|
||||
|
||||
});
|
@ -1,44 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
var config = require('../config');
|
||||
var http = require('http');
|
||||
var express = require('express');
|
||||
var gulp = require('gulp');
|
||||
var gutil = require('gulp-util');
|
||||
var morgan = require('morgan');
|
||||
var proxy = require('express-http-proxy');
|
||||
|
||||
gulp.task('server', function() {
|
||||
|
||||
var server = express();
|
||||
|
||||
// log all requests to the console
|
||||
server.use(morgan('dev'));
|
||||
server.use(express.static(config.buildDir));
|
||||
|
||||
// Proxy php server api
|
||||
server.use('/api', proxy('http://localhost:8080', {
|
||||
forwardPath: function(req, res) {
|
||||
return require('url').parse(req.url).path;
|
||||
}
|
||||
}));
|
||||
|
||||
// Serve index.html for all routes to leave routing up to react-router
|
||||
server.all('/*', function(req, res) {
|
||||
res.sendFile('index.html', { root: 'build' });
|
||||
});
|
||||
|
||||
// Start webserver if not already running
|
||||
var s = http.createServer(server);
|
||||
s.on('error', function(err){
|
||||
if(err.code === 'EADDRINUSE'){
|
||||
gutil.log('Development server is already started at port ' + config.serverport);
|
||||
}
|
||||
else {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
s.listen(config.serverport);
|
||||
|
||||
});
|
@ -1,10 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
var gulp = require('gulp');
|
||||
//var config = require('../config');
|
||||
|
||||
gulp.task('test', function() {
|
||||
|
||||
// Run all tests
|
||||
|
||||
});
|
@ -1,13 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
var gulp = require('gulp');
|
||||
var config = require('../config');
|
||||
|
||||
gulp.task('watch', ['browserSync', 'server'], function() {
|
||||
|
||||
// Scripts are automatically watched by Watchify inside Browserify task
|
||||
gulp.watch(config.styles.src, ['sass']);
|
||||
gulp.watch(config.images.src, ['imagemin']);
|
||||
gulp.watch(config.sourceDir + 'index.html', ['copyIndex']);
|
||||
|
||||
});
|
@ -1,27 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
var notify = require('gulp-notify');
|
||||
|
||||
module.exports = function(error) {
|
||||
|
||||
if( !global.isProd ) {
|
||||
|
||||
var args = Array.prototype.slice.call(arguments);
|
||||
|
||||
// Send error to notification center with gulp-notify
|
||||
notify.onError({
|
||||
title: 'Compile Error',
|
||||
message: '<%= error.message %>'
|
||||
}).apply(this, args);
|
||||
|
||||
// Keep gulp from hanging on this task
|
||||
this.emit('end');
|
||||
|
||||
} else {
|
||||
// Log the error and stop the process
|
||||
// to prevent broken code from building
|
||||
console.log(error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
};
|
@ -1,9 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
var path = require('path');
|
||||
|
||||
// Filters out non .coffee and .js files. Prevents
|
||||
// accidental inclusion of possible hidden files
|
||||
module.exports = function(name) {
|
||||
return /(\.(js|coffee)$)/i.test(path.extname(name));
|
||||
};
|
@ -1,5 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
global.isProd = false;
|
||||
|
||||
require('./gulp');
|
21698
client/package-lock.json
generated
Normal file
21698
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "OpenSupports",
|
||||
"version": "4.1.0",
|
||||
"version": "4.11.0",
|
||||
"author": "Ivan Diaz <contact@opensupports.com>",
|
||||
"description": "Open source ticket system made with PHP and ReactJS",
|
||||
"repository": {
|
||||
@ -9,68 +9,86 @@
|
||||
},
|
||||
"private": false,
|
||||
"engines": {
|
||||
"node": "^0.12.x",
|
||||
"npm": "^2.1.x"
|
||||
"node": "^11.15.x",
|
||||
"npm": "^6.7.x"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "export NODE_PATH=src && mocha src/lib-test/preprocessor.js --compilers js:babel-core/register --recursive src/**/**/__tests__/*-test.js"
|
||||
"start": "webpack-dev-server --display-reasons --display-error-details --history-api-fallback --progress --colors",
|
||||
"start-fixtures": "webpack-dev-server --env.FIXTURES=1",
|
||||
"build": "./node_modules/.bin/rimraf build && NODE_ENV=production ./node_modules/.bin/webpack -p --devtool none",
|
||||
"test": "export NODE_PATH=src && mocha src/lib-test/preprocessor.js --require @babel/register --recursive src/**/**/__tests__/*-test.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-core": "^5.8.22",
|
||||
"babel-plugin-transform-class-properties": "^6.11.5",
|
||||
"babel-register": "^6.7.2",
|
||||
"babelify": "^6.1.x",
|
||||
"browser-sync": "^2.7.13",
|
||||
"browserify": "^10.2.6",
|
||||
"@babel/cli": "^7.5.5",
|
||||
"@babel/core": "^7.5.5",
|
||||
"@babel/node": "^7.5.5",
|
||||
"@babel/plugin-proposal-class-properties": "^7.4.4",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.5.0",
|
||||
"@babel/preset-env": "^7.5.5",
|
||||
"@babel/preset-react": "^7.0.0",
|
||||
"@babel/register": "^7.5.5",
|
||||
"axios-mock-adapter": "^1.15.0",
|
||||
"babel-loader": "^8.0.6",
|
||||
"babel-plugin-add-module-exports": "^1.0.2",
|
||||
"browser-sync": "^2.27.5",
|
||||
"chai": "^3.5.0",
|
||||
"copy-webpack-plugin": "^5.0.3",
|
||||
"css-loader": "^3.0.0",
|
||||
"debowerify": "^1.3.1",
|
||||
"del": "^1.2.0",
|
||||
"eslint": "^5.16.0",
|
||||
"eslint-loader": "^2.1.2",
|
||||
"express": "^4.13.1",
|
||||
"express-http-proxy": "^0.6.0",
|
||||
"gulp": "^3.9.0",
|
||||
"gulp-autoprefixer": "^2.3.1",
|
||||
"gulp-connect-php": "0.0.5",
|
||||
"gulp-if": "^1.2.5",
|
||||
"gulp-imagemin": "^2.3.0",
|
||||
"gulp-notify": "^2.2.0",
|
||||
"gulp-rename": "^1.2.2",
|
||||
"gulp-sass": "^2.0.4",
|
||||
"gulp-sass-bulk-import": "^0.3.2",
|
||||
"gulp-sourcemaps": "^1.5.2",
|
||||
"gulp-streamify": "0.0.5",
|
||||
"gulp-uglify": "^2.1.2",
|
||||
"gulp-util": "^3.0.6",
|
||||
"file-loader": "^4.0.0",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
"humps": "^0.6.0",
|
||||
"jquery-mockjax": "^2.1.0",
|
||||
"jsdom": "^8.4.1",
|
||||
"mocha": "^6.2.0",
|
||||
"morgan": "^1.6.1",
|
||||
"node-sass": "^4.12.0",
|
||||
"nodemon": "^1.19.1",
|
||||
"path": "^0.12.7",
|
||||
"proxyquire": "^1.7.4",
|
||||
"react-addons-test-utils": "^15.0.1",
|
||||
"rimraf": "^2.6.3",
|
||||
"run-sequence": "^1.1.1",
|
||||
"sass-loader": "^7.1.0",
|
||||
"sinon": "^1.17.3",
|
||||
"sinon-chai": "^2.8.0",
|
||||
"style-loader": "^0.23.1",
|
||||
"stylelint-webpack-plugin": "^0.10.5",
|
||||
"vinyl-source-stream": "^1.1.0",
|
||||
"watchify": "^3.2.x"
|
||||
"watchify": "^3.2.x",
|
||||
"webpack": "^4.34.0",
|
||||
"webpack-bundle-analyzer": "^3.3.2",
|
||||
"webpack-cli": "^3.3.4",
|
||||
"webpack-dev-server": "^3.7.1",
|
||||
"webpack-import-glob": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"app-module-path": "^1.0.3",
|
||||
"chart.js": "^2.4.0",
|
||||
"axios": "^0.21.1",
|
||||
"chart.js": "^2.9.3",
|
||||
"classnames": "^2.2.5",
|
||||
"draft-js": "^0.10.0",
|
||||
"draft-js-export-html": "^0.5.2",
|
||||
"history": "^3.0.0",
|
||||
"jquery": "^2.1.4",
|
||||
"html-to-text": "^4.0.0",
|
||||
"keycode": "^2.1.4",
|
||||
"localStorage": "^1.0.3",
|
||||
"lodash": "^3.10.0",
|
||||
"lodash": "^4.17.21",
|
||||
"messageformat": "^0.2.2",
|
||||
"moment": "^2.27.0",
|
||||
"qs": "^6.5.2",
|
||||
"query-string": "^6.12.1",
|
||||
"quill-image-resize-module-react": "^3.0.0",
|
||||
"quill-magic-url": "^4.1.3",
|
||||
"random-string": "^0.2.0",
|
||||
"react": "^15.4.2",
|
||||
"react-chartjs-2": "^2.0.0",
|
||||
"react-chartjs-2": "^2.10.0",
|
||||
"react-document-title": "^1.0.2",
|
||||
"react-dom": "^15.4.2",
|
||||
"react-draft-wysiwyg": "^1.7.6",
|
||||
"react-google-recaptcha": "^0.5.2",
|
||||
"react-motion": "^0.4.7",
|
||||
"react-quill": "^1.3.1",
|
||||
"react-redux": "^4.4.5",
|
||||
"react-router": "^3.0.2",
|
||||
"react-router-redux": "^4.0.7",
|
||||
|
@ -1,17 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
var babel = require('babel-core');
|
||||
|
||||
module.exports = {
|
||||
process: function(src, filename) {
|
||||
// Ignore files other than .js, .es, .jsx or .es6
|
||||
if (!babel.canCompile(filename)) {
|
||||
return '';
|
||||
}
|
||||
// Ignore all files within node_modules
|
||||
if (filename.indexOf('node_modules') === -1) {
|
||||
return babel.transform(src, {filename: filename}).code;
|
||||
}
|
||||
return src;
|
||||
}
|
||||
};
|
@ -1,5 +1,5 @@
|
||||
export default {
|
||||
login: stub(),
|
||||
logout: stub(),
|
||||
initSession: stub()
|
||||
checkSession: stub()
|
||||
};
|
@ -1,161 +1,160 @@
|
||||
const sessionStoreMock = require('lib-app/__mocks__/session-store-mock');
|
||||
const APICallMock = require('lib-app/__mocks__/api-call-mock');
|
||||
const storeMock = {
|
||||
dispatch: stub()
|
||||
};
|
||||
// const sessionStoreMock = require('lib-app/__mocks__/session-store-mock');
|
||||
// const APICallMock = require('lib-app/__mocks__/api-call-mock');
|
||||
// const storeMock = require('app/__mocks__/store-mock');
|
||||
|
||||
const SessionActions = requireUnit('actions/session-actions', {
|
||||
'lib-app/api-call': APICallMock,
|
||||
'lib-app/session-store': sessionStoreMock,
|
||||
'app/store': storeMock
|
||||
});
|
||||
// const SessionActions = requireUnit('actions/session-actions', {
|
||||
// 'lib-app/api-call': APICallMock,
|
||||
// 'lib-app/session-store': sessionStoreMock,
|
||||
// 'app/store': storeMock
|
||||
// });
|
||||
|
||||
describe('Session Actions,', function () {
|
||||
// describe('Session Actions,', function () {
|
||||
|
||||
describe('login action', function () {
|
||||
it('should return LOGIN with with a result promise', function () {
|
||||
APICallMock.call.returns({
|
||||
then: function (resolve) {
|
||||
resolve({
|
||||
data: {
|
||||
userId: 14,
|
||||
token: 'SOME_TOKEN'
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
// describe('login action', function () {
|
||||
// it('should return LOGIN with with a result promise', function () {
|
||||
// APICallMock.call.returns({
|
||||
// then: function (resolve) {
|
||||
// resolve({
|
||||
// data: {
|
||||
// userId: 14,
|
||||
// token: 'SOME_TOKEN'
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
|
||||
let loginData = {
|
||||
email: 'SOME_EMAIL',
|
||||
password: 'SOME_PASSWORD',
|
||||
remember: false
|
||||
};
|
||||
// let loginData = {
|
||||
// email: 'SOME_EMAIL',
|
||||
// password: 'SOME_PASSWORD',
|
||||
// remember: false
|
||||
// };
|
||||
|
||||
expect(SessionActions.login(loginData).type).to.equal('LOGIN');
|
||||
expect(storeMock.dispatch).to.have.been.calledWithMatch({type: 'USER_DATA'});
|
||||
expect(APICallMock.call).to.have.been.calledWith({
|
||||
path: '/user/get',
|
||||
data: {
|
||||
csrf_userid: 14,
|
||||
csrf_token: 'SOME_TOKEN'
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
// expect(SessionActions.login(loginData).type).to.equal('LOGIN');
|
||||
// expect(storeMock.dispatch).to.have.been.calledWithMatch({type: 'USER_DATA'});
|
||||
// expect(APICallMock.call).to.have.been.calledWith({
|
||||
// path: '/user/get',
|
||||
// data: {
|
||||
// csrf_userid: 14,
|
||||
// csrf_token: 'SOME_TOKEN'
|
||||
// }
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
|
||||
describe('autoLogin action', function () {
|
||||
it('should return LOGIN_AUTO with remember data from sessionStore', function () {
|
||||
APICallMock.call.returns({
|
||||
then: function (resolve) {
|
||||
resolve({
|
||||
data: {
|
||||
userId: 14
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
sessionStoreMock.getRememberData.returns({
|
||||
token: 'SOME_TOKEN',
|
||||
userId: 'SOME_ID',
|
||||
expiration: 'SOME_EXPIRATION'
|
||||
});
|
||||
// describe('autoLogin action', function () {
|
||||
// it('should return LOGIN_AUTO with remember data from sessionStore', function () {
|
||||
// APICallMock.call.returns({
|
||||
// then: function (resolve) {
|
||||
// resolve({
|
||||
// data: {
|
||||
// userId: 14
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
// sessionStoreMock.getRememberData.returns({
|
||||
// token: 'SOME_TOKEN',
|
||||
// userId: 'SOME_ID',
|
||||
// expiration: 'SOME_EXPIRATION'
|
||||
// });
|
||||
|
||||
expect(SessionActions.autoLogin().type).to.equal('LOGIN_AUTO');
|
||||
expect(storeMock.dispatch).to.have.been.calledWithMatch({type: 'USER_DATA'});
|
||||
expect(APICallMock.call).to.have.been.calledWith({
|
||||
path: '/user/login',
|
||||
data: {
|
||||
rememberToken: 'SOME_TOKEN',
|
||||
userId: 'SOME_ID',
|
||||
isAutomatic: true
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
// expect(SessionActions.autoLogin().type).to.equal('LOGIN_AUTO');
|
||||
// expect(storeMock.dispatch).to.have.been.calledWithMatch({type: 'USER_DATA'});
|
||||
// expect(APICallMock.call).to.have.been.calledWith({
|
||||
// path: '/user/login',
|
||||
// data: {
|
||||
// rememberToken: 'SOME_TOKEN',
|
||||
// userId: 'SOME_ID',
|
||||
// remember: 1,
|
||||
// isAutomatic: 1
|
||||
// }
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
|
||||
describe('logout action', function () {
|
||||
it('should return LOGOUT and call /user/logout', function () {
|
||||
APICallMock.call.returns('API_RESULT');
|
||||
APICallMock.call.reset();
|
||||
// describe('logout action', function () {
|
||||
// it('should return LOGOUT and call /user/logout', function () {
|
||||
// APICallMock.call.returns('API_RESULT');
|
||||
// APICallMock.call.reset();
|
||||
|
||||
expect(SessionActions.logout()).to.deep.equal({
|
||||
type: 'LOGOUT',
|
||||
payload: 'API_RESULT'
|
||||
});
|
||||
expect(APICallMock.call).to.have.been.calledWith({
|
||||
path: '/user/logout',
|
||||
data: {}
|
||||
});
|
||||
});
|
||||
});
|
||||
// expect(SessionActions.logout()).to.deep.equal({
|
||||
// type: 'LOGOUT',
|
||||
// payload: 'API_RESULT'
|
||||
// });
|
||||
// expect(APICallMock.call).to.have.been.calledWith({
|
||||
// path: '/user/logout',
|
||||
// data: {}
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
|
||||
describe('initSession action', function () {
|
||||
beforeEach(function () {
|
||||
APICallMock.call.returns({
|
||||
then: function (resolve) {
|
||||
resolve({
|
||||
data: {
|
||||
sessionActive: false
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
APICallMock.call.reset();
|
||||
storeMock.dispatch.reset();
|
||||
sessionStoreMock.isLoggedIn.returns(true)
|
||||
});
|
||||
// describe('checkSession action', function () {
|
||||
// beforeEach(function () {
|
||||
// APICallMock.call.returns({
|
||||
// then: function (resolve) {
|
||||
// resolve({
|
||||
// data: {
|
||||
// sessionActive: false
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
// APICallMock.call.reset();
|
||||
// storeMock.dispatch.reset();
|
||||
// sessionStoreMock.isLoggedIn.returns(true)
|
||||
// });
|
||||
|
||||
after(function () {
|
||||
APICallMock.call.returns(new Promise(function (resolve) {
|
||||
resolve({
|
||||
data: {
|
||||
sessionActive: true
|
||||
}
|
||||
});
|
||||
}));
|
||||
});
|
||||
// after(function () {
|
||||
// APICallMock.call.returns(new Promise(function (resolve) {
|
||||
// resolve({
|
||||
// data: {
|
||||
// sessionActive: true
|
||||
// }
|
||||
// });
|
||||
// }));
|
||||
// });
|
||||
|
||||
it('should return CHECK_SESSION and dispatch SESSION_CHECKED if session is active', function () {
|
||||
APICallMock.call.returns({
|
||||
then: function (resolve) {
|
||||
resolve({
|
||||
data: {
|
||||
sessionActive: true
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
// it('should return CHECK_SESSION and dispatch SESSION_CHECKED if session is active', function () {
|
||||
// APICallMock.call.returns({
|
||||
// then: function (resolve) {
|
||||
// resolve({
|
||||
// data: {
|
||||
// sessionActive: true
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
|
||||
expect(SessionActions.initSession().type).to.equal('CHECK_SESSION');
|
||||
expect(storeMock.dispatch).to.have.been.calledWith({type: 'SESSION_CHECKED'});
|
||||
expect(APICallMock.call).to.have.been.calledWith({
|
||||
path: '/user/check-session',
|
||||
data: {}
|
||||
});
|
||||
});
|
||||
// expect(SessionActions.checkSession().type).to.equal('CHECK_SESSION');
|
||||
// expect(storeMock.dispatch).to.have.been.calledWith({type: 'SESSION_CHECKED'});
|
||||
// expect(APICallMock.call).to.have.been.calledWith({
|
||||
// path: '/user/check-session',
|
||||
// data: {}
|
||||
// });
|
||||
// });
|
||||
|
||||
it('should return CHECK_SESSION and dispatch LOGOUT_FULFILLED if session is not active and no remember data', function () {
|
||||
sessionStoreMock.isRememberDataExpired.returns(true);
|
||||
// it('should return CHECK_SESSION and dispatch LOGOUT_FULFILLED if session is not active and no remember data', function () {
|
||||
// sessionStoreMock.isRememberDataExpired.returns(true);
|
||||
|
||||
expect(SessionActions.initSession().type).to.equal('CHECK_SESSION');
|
||||
expect(storeMock.dispatch).to.have.been.calledWith({type: 'LOGOUT_FULFILLED'});
|
||||
expect(APICallMock.call).to.have.been.calledWith({
|
||||
path: '/user/check-session',
|
||||
data: {}
|
||||
});
|
||||
});
|
||||
// expect(SessionActions.checkSession().type).to.equal('CHECK_SESSION');
|
||||
// expect(storeMock.dispatch).to.have.been.calledWith({type: 'LOGOUT_FULFILLED'});
|
||||
// expect(APICallMock.call).to.have.been.calledWith({
|
||||
// path: '/user/check-session',
|
||||
// data: {}
|
||||
// });
|
||||
// });
|
||||
|
||||
it('should return CHECK_SESSION and dispatch LOGIN_AUTO if session is not active but remember data exists', function () {
|
||||
sessionStoreMock.isRememberDataExpired.returns(false);
|
||||
// it('should return CHECK_SESSION and dispatch LOGIN_AUTO if session is not active but remember data exists', function () {
|
||||
// sessionStoreMock.isRememberDataExpired.returns(false);
|
||||
|
||||
expect(SessionActions.initSession().type).to.equal('CHECK_SESSION');
|
||||
expect(storeMock.dispatch).to.not.have.been.calledWith({type: 'LOGOUT_FULFILLED'});
|
||||
expect(APICallMock.call).to.have.been.calledWith({
|
||||
path: '/user/check-session',
|
||||
data: {}
|
||||
});
|
||||
// expect(SessionActions.checkSession().type).to.equal('CHECK_SESSION');
|
||||
// expect(storeMock.dispatch).to.not.have.been.calledWith({type: 'LOGOUT_FULFILLED'});
|
||||
// expect(APICallMock.call).to.have.been.calledWith({
|
||||
// path: '/user/check-session',
|
||||
// data: {}
|
||||
// });
|
||||
|
||||
expect(storeMock.dispatch).to.have.been.calledWith(SessionActions.autoLogin());
|
||||
});
|
||||
});
|
||||
});
|
||||
// expect(storeMock.dispatch).to.have.been.calledWith(SessionActions.autoLogin());
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
|
@ -1,7 +1,7 @@
|
||||
import API from 'lib-app/api-call';
|
||||
|
||||
export default {
|
||||
|
||||
|
||||
retrieveCustomResponses() {
|
||||
return {
|
||||
type: 'CUSTOM_RESPONSES',
|
||||
@ -12,32 +12,42 @@ export default {
|
||||
};
|
||||
},
|
||||
|
||||
retrieveMyTickets() {
|
||||
retrieveMyTickets({page, closed = 0, departmentId = 0, pageSize = 10}) {
|
||||
return {
|
||||
type: 'MY_TICKETS',
|
||||
payload: API.call({
|
||||
path: '/staff/get-tickets',
|
||||
data: {}
|
||||
data: {page, closed, departmentId, pageSize}
|
||||
})
|
||||
};
|
||||
},
|
||||
|
||||
retrieveNewTickets() {
|
||||
retrieveNewTickets({page, departmentId = 0, pageSize = 10}) {
|
||||
return {
|
||||
type: 'NEW_TICKETS',
|
||||
payload: API.call({
|
||||
path: '/staff/get-new-tickets',
|
||||
data: {}
|
||||
data: {page, departmentId, pageSize}
|
||||
})
|
||||
};
|
||||
},
|
||||
|
||||
retrieveAllTickets(page = 1) {
|
||||
retrieveAllTickets(page = 1, query = '', closed = 0) {
|
||||
return {
|
||||
type: 'ALL_TICKETS',
|
||||
payload: API.call({
|
||||
path: '/staff/get-all-tickets',
|
||||
data: {page}
|
||||
data: {page, query, closed}
|
||||
})
|
||||
};
|
||||
},
|
||||
|
||||
retrieveStaffMembers() {
|
||||
return {
|
||||
type: 'STAFF_MEMBERS',
|
||||
payload: API.call({
|
||||
path: '/staff/get-all',
|
||||
data: {}
|
||||
})
|
||||
};
|
||||
},
|
||||
@ -51,4 +61,4 @@ export default {
|
||||
})
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
|
15
client/src/actions/login-form-actions.js
Normal file
15
client/src/actions/login-form-actions.js
Normal file
@ -0,0 +1,15 @@
|
||||
|
||||
export default {
|
||||
showLoginForm() {
|
||||
return {
|
||||
type: 'SHOW_LOGIN_FORM',
|
||||
payload: true
|
||||
};
|
||||
},
|
||||
hideLoginForm() {
|
||||
return {
|
||||
type: 'HIDE_LOGIN_FORM',
|
||||
payload: false
|
||||
};
|
||||
}
|
||||
};
|
81
client/src/actions/search-filters-actions.js
Normal file
81
client/src/actions/search-filters-actions.js
Normal file
@ -0,0 +1,81 @@
|
||||
import API from 'lib-app/api-call';
|
||||
import searchTicketsUtils from 'lib-app/search-tickets-utils';
|
||||
import history from 'lib-app/history';
|
||||
|
||||
export default {
|
||||
setLoadingInTrue() {
|
||||
return {
|
||||
type: 'SEARCH_FILTERS_SET_LOADING_IN_TRUE',
|
||||
payload: {}
|
||||
}
|
||||
},
|
||||
retrieveSearchTickets(ticketQueryListState, filters = {}, pageSize = 10) {
|
||||
return {
|
||||
type: 'SEARCH_TICKETS',
|
||||
payload: API.call({
|
||||
path: '/ticket/search',
|
||||
data: {
|
||||
...filters,
|
||||
page: ticketQueryListState.page,
|
||||
pageSize
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
changeForm(form) {
|
||||
return {
|
||||
type: 'SEARCH_FILTERS_CHANGE_FORM',
|
||||
payload: form
|
||||
}
|
||||
},
|
||||
changeFilters(listConfig) {
|
||||
const filtersForAPI = searchTicketsUtils.getFiltersForAPI(listConfig.filters);
|
||||
|
||||
return {
|
||||
type: 'SEARCH_FILTERS_CHANGE_FILTERS',
|
||||
payload: {...listConfig, filtersForAPI}
|
||||
}
|
||||
},
|
||||
setDefaultFormValues() {
|
||||
return {
|
||||
type: 'SEARCH_FILTERS_SET_DEFAULT_FORM_VALUES',
|
||||
payload: {}
|
||||
}
|
||||
},
|
||||
changeShowFilters(showFilters) {
|
||||
return {
|
||||
type: 'SEARCH_FILTERS_CHANGE_SHOW_FILTERS',
|
||||
payload: showFilters
|
||||
}
|
||||
},
|
||||
changePage(filtersWithPage) {
|
||||
const filtersForAPI = searchTicketsUtils.getFiltersForAPI(filtersWithPage);
|
||||
const currentPath = window.location.pathname;
|
||||
const urlQuery = searchTicketsUtils.getFiltersForURL({
|
||||
filters: filtersForAPI,
|
||||
shouldRemoveCustomParam: false,
|
||||
shouldRemoveUseInitialValuesParam: true
|
||||
});
|
||||
urlQuery && history.push(`${currentPath}${urlQuery}`);
|
||||
|
||||
return {
|
||||
type: 'SEARCH_FILTERS_CHANGE_PAGE',
|
||||
payload: {...filtersWithPage, ...filtersForAPI}
|
||||
}
|
||||
},
|
||||
changeOrderBy(filtersWithOrderBy) {
|
||||
const filtersForAPI = searchTicketsUtils.getFiltersForAPI(filtersWithOrderBy);
|
||||
const currentPath = window.location.pathname;
|
||||
const urlQuery = searchTicketsUtils.getFiltersForURL({
|
||||
filters: filtersForAPI,
|
||||
shouldRemoveCustomParam: false,
|
||||
shouldRemoveUseInitialValuesParam: true
|
||||
});
|
||||
urlQuery && history.push(`${currentPath}${urlQuery}`);
|
||||
|
||||
return {
|
||||
type: 'SEARCH_FILTERS_CHANGE_ORDER_BY',
|
||||
payload: {...filtersWithOrderBy, ...filtersForAPI}
|
||||
}
|
||||
},
|
||||
};
|
@ -1,5 +1,8 @@
|
||||
import _ from 'lodash';
|
||||
|
||||
import API from 'lib-app/api-call';
|
||||
import AdminDataActions from 'actions/admin-data-actions';
|
||||
import ConfigActions from 'actions/config-actions';
|
||||
import sessionStore from 'lib-app/session-store';
|
||||
import store from 'app/store';
|
||||
|
||||
@ -12,13 +15,16 @@ export default {
|
||||
let loginCall = () => {
|
||||
API.call({
|
||||
path: '/user/login',
|
||||
data: loginData
|
||||
data: _.extend(loginData, {remember: loginData.remember * 1})
|
||||
}).then((result) => {
|
||||
store.dispatch(this.getUserData(result.data.userId, result.data.token, result.data.staff)).then(() => {
|
||||
if(result.data.staff) {
|
||||
store.dispatch(AdminDataActions.retrieveCustomResponses());
|
||||
}
|
||||
});
|
||||
store.dispatch(this.getUserData(result.data.userId, result.data.token, result.data.staff))
|
||||
.then(() => store.dispatch(ConfigActions.updateData()))
|
||||
.then(() => {
|
||||
if(result.data.staff) {
|
||||
store.dispatch(AdminDataActions.retrieveCustomResponses());
|
||||
store.dispatch(AdminDataActions.retrieveStaffMembers());
|
||||
}
|
||||
});
|
||||
|
||||
resolve(result);
|
||||
}).catch((result) => {
|
||||
@ -48,11 +54,12 @@ export default {
|
||||
data: {
|
||||
userId: rememberData.userId,
|
||||
rememberToken: rememberData.token,
|
||||
isAutomatic: true
|
||||
staff: rememberData.isStaff,
|
||||
remember: 1,
|
||||
}
|
||||
}).then((result) => {
|
||||
store.dispatch(this.getUserData(result.data.userId, result.data.token));
|
||||
|
||||
store.dispatch(this.getUserData(result.data.userId, result.data.token, result.data.staff));
|
||||
|
||||
return result;
|
||||
})
|
||||
};
|
||||
@ -94,7 +101,7 @@ export default {
|
||||
};
|
||||
},
|
||||
|
||||
initSession() {
|
||||
checkSession() {
|
||||
return {
|
||||
type: 'CHECK_SESSION',
|
||||
payload: new Promise((resolve, reject) => {
|
||||
@ -125,4 +132,4 @@ export default {
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
@ -3,7 +3,7 @@ const TicketInfo = ReactMock();
|
||||
const Table = ReactMock();
|
||||
const Button = ReactMock();
|
||||
const Tooltip = ReactMock();
|
||||
const Dropdown = ReactMock();
|
||||
const DepartmentDropdown = ReactMock();
|
||||
const i18n = stub().returnsArg(0);
|
||||
|
||||
const TicketList = requireUnit('app-components/ticket-list', {
|
||||
@ -11,8 +11,15 @@ const TicketList = requireUnit('app-components/ticket-list', {
|
||||
'core-components/table': Table,
|
||||
'core-components/button': Button,
|
||||
'core-components/tooltip': Tooltip,
|
||||
'core-components/drop-down': Dropdown,
|
||||
'lib-app/i18n': i18n
|
||||
'app-components/department-dropdown': DepartmentDropdown,
|
||||
'lib-app/i18n': i18n,
|
||||
'react-redux': {
|
||||
connect: function() {
|
||||
return function(param) {
|
||||
return param;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
describe('TicketList component', function () {
|
||||
@ -28,11 +35,11 @@ describe('TicketList component', function () {
|
||||
id: 1,
|
||||
name: 'Sales Support'
|
||||
},
|
||||
priority: 'low',
|
||||
author: {
|
||||
id: 3,
|
||||
name: 'Francisco Villegas'
|
||||
}
|
||||
},
|
||||
tags: []
|
||||
};
|
||||
let list = _.range(5).map(() => ticket);
|
||||
|
||||
@ -50,71 +57,46 @@ describe('TicketList component', function () {
|
||||
|
||||
function renderTicketList(props = {}) {
|
||||
ticketList = TestUtils.renderIntoDocument(
|
||||
<TicketList tickets={tickets} {...props}/>
|
||||
<TicketList tickets={tickets} {...props}></TicketList>
|
||||
);
|
||||
|
||||
table = TestUtils.scryRenderedComponentsWithType(ticketList, Table);
|
||||
dropdown = TestUtils.scryRenderedComponentsWithType(ticketList, Dropdown);
|
||||
dropdown = TestUtils.scryRenderedComponentsWithType(ticketList, DepartmentDropdown);
|
||||
}
|
||||
|
||||
it('should pass correct props to Table', function () {
|
||||
renderTicketList();
|
||||
expect(table[0].props.loading).to.equal(false);
|
||||
expect(table[0].props.pageSize).to.equal(10);
|
||||
expect(table[0].props.headers).to.deep.equal([
|
||||
expect(table[0].props.headers[0]).to.deep.equal(
|
||||
{
|
||||
key: 'number',
|
||||
value: i18n('NUMBER'),
|
||||
className: 'ticket-list__number col-md-1'
|
||||
},
|
||||
});
|
||||
expect(table[0].props.headers[1]).to.deep.equal(
|
||||
{
|
||||
key: 'title',
|
||||
value: i18n('TITLE'),
|
||||
className: 'ticket-list__title col-md-6'
|
||||
},
|
||||
});
|
||||
expect(table[0].props.headers[2]).to.deep.equal(
|
||||
{
|
||||
key: 'department',
|
||||
value: i18n('DEPARTMENT'),
|
||||
className: 'ticket-list__department col-md-3'
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
value: i18n('DATE'),
|
||||
className: 'ticket-list__date col-md-2'
|
||||
}
|
||||
]);
|
||||
});
|
||||
expect(table[0].props.headers[3].key).to.equal('date');
|
||||
expect(table[0].props.headers[3].value.props.children[0]).to.equal(i18n('DATE'));
|
||||
expect(table[0].props.headers[3].value.props.children[1]).to.equal(null);
|
||||
expect(table[0].props.headers[3].className).to.equal('ticket-list__date col-md-2');
|
||||
});
|
||||
|
||||
|
||||
it('should pass loading to Table', function () {
|
||||
renderTicketList({loading: true});
|
||||
expect(table[0].props.loading).to.equal(true);
|
||||
});
|
||||
|
||||
it('should pass correct compare function to Table', function () {
|
||||
let minCompare = table[0].props.comp;
|
||||
|
||||
let row1 = {
|
||||
closed: false,
|
||||
unread: false,
|
||||
date: '20160405'
|
||||
};
|
||||
let row2 = {
|
||||
closed: false,
|
||||
unread: false,
|
||||
date: '20160406'
|
||||
};
|
||||
expect(minCompare(row1, row2)).to.equal(1);
|
||||
|
||||
row1.unread = true;
|
||||
expect(minCompare(row1, row2)).to.equal(-1);
|
||||
|
||||
row2.unread = true;
|
||||
expect(minCompare(row1, row2)).to.equal(1);
|
||||
|
||||
row2.date = '20160401';
|
||||
expect(minCompare(row1, row2)).to.equal(-1);
|
||||
});
|
||||
|
||||
describe('when using secondary type', function () {
|
||||
beforeEach(function () {
|
||||
renderTicketList({
|
||||
@ -127,45 +109,41 @@ describe('TicketList component', function () {
|
||||
});
|
||||
|
||||
it('should pass correct props to Table', function () {
|
||||
expect(table[0].props.headers).to.deep.equal([
|
||||
expect(table[0].props.headers[0]).to.deep.equal(
|
||||
{
|
||||
key: 'number',
|
||||
value: i18n('NUMBER'),
|
||||
className: 'ticket-list__number col-md-1'
|
||||
},
|
||||
});
|
||||
expect(table[0].props.headers[1]).to.deep.equal(
|
||||
{
|
||||
key: 'title',
|
||||
value: i18n('TITLE'),
|
||||
className: 'ticket-list__title col-md-4'
|
||||
},
|
||||
{
|
||||
key: 'priority',
|
||||
value: i18n('PRIORITY'),
|
||||
className: 'ticket-list__priority col-md-1'
|
||||
},
|
||||
});
|
||||
expect(table[0].props.headers[2]).to.deep.equal(
|
||||
{
|
||||
key: 'department',
|
||||
value: i18n('DEPARTMENT'),
|
||||
className: 'ticket-list__department col-md-2'
|
||||
},
|
||||
});
|
||||
expect(table[0].props.headers[3]).to.deep.equal(
|
||||
{
|
||||
key: 'author',
|
||||
value: i18n('AUTHOR'),
|
||||
className: 'ticket-list__author col-md-2'
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
value: i18n('DATE'),
|
||||
className: 'ticket-list__date col-md-2'
|
||||
}
|
||||
]);
|
||||
});
|
||||
expect(table[0].props.headers[4].key).to.equal('date');
|
||||
expect(table[0].props.headers[4].value.props.children[0]).to.equal(i18n('DATE'));
|
||||
expect(table[0].props.headers[4].value.props.children[1]).to.equal(null);
|
||||
expect(table[0].props.headers[4].className).to.equal('ticket-list__date col-md-2');
|
||||
});
|
||||
|
||||
it('should pass correct props to dropdown', function () {
|
||||
expect(dropdown[0].props.items).to.deep.equal([
|
||||
{content: i18n('ALL_DEPARTMENTS')},
|
||||
{content: 'Sales Support'},
|
||||
{content: 'Tech Help'}
|
||||
expect(dropdown[0].props.departments).to.deep.equal([
|
||||
{name: i18n('ALL_DEPARTMENTS')},
|
||||
{name: 'Sales Support', id: 1},
|
||||
{name: 'Tech Help', id: 2}
|
||||
]);
|
||||
expect(dropdown[0].props.size).to.equal('medium');
|
||||
});
|
||||
@ -185,4 +163,4 @@ describe('TicketList component', function () {
|
||||
expect(table[0].props.rows.length).to.equal(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -18,10 +18,12 @@ class ActivityRow extends React.Component {
|
||||
'CREATE_TICKET',
|
||||
'RE_OPEN',
|
||||
'DEPARTMENT_CHANGED',
|
||||
'PRIORITY_CHANGED',
|
||||
'EDIT_TITLE',
|
||||
'EDIT_COMMENT',
|
||||
|
||||
'EDIT_SETTINGS',
|
||||
'SIGNUP',
|
||||
'INVITE',
|
||||
'ADD_TOPIC',
|
||||
'ADD_ARTICLE',
|
||||
'DELETE_TOPIC',
|
||||
@ -56,16 +58,16 @@ class ActivityRow extends React.Component {
|
||||
'CREATE_TICKET',
|
||||
'RE_OPEN',
|
||||
'DEPARTMENT_CHANGED',
|
||||
'PRIORITY_CHANGED'
|
||||
'COMMENT_EDITED',
|
||||
'EDIT_TITLE',
|
||||
'EDIT_COMMENT',
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="activity-row">
|
||||
<Icon {...this.getIconProps()} className="activity-row__icon"/>
|
||||
<span>
|
||||
<Link className="activity-row__name-link" to={this.getNameLinkDestination()}>
|
||||
{this.props.author.name}
|
||||
</Link>
|
||||
{this.renderAuthorName()}
|
||||
</span>
|
||||
<span className="activity-row__message"> {i18n('ACTIVITY_' + this.props.type)} </span>
|
||||
{_.includes(ticketRelatedTypes, this.props.type) ? this.renderTicketNumber() : this.props.to}
|
||||
@ -74,6 +76,18 @@ class ActivityRow extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
renderAuthorName() {
|
||||
let name = this.props.author.name;
|
||||
|
||||
if (this.props.author.id) {
|
||||
name = <Link className="activity-row__name-link" to={this.getNameLinkDestination()}>
|
||||
{this.props.author.name}
|
||||
</Link>;
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
renderTicketNumber() {
|
||||
let ticketNumber = (this.props.mode === 'staff') ? this.props.ticketNumber : this.props.to;
|
||||
|
||||
@ -99,10 +113,12 @@ class ActivityRow extends React.Component {
|
||||
'CREATE_TICKET': 'ticket',
|
||||
'RE_OPEN': 'unlock-alt',
|
||||
'DEPARTMENT_CHANGED': 'exchange',
|
||||
'PRIORITY_CHANGED': 'exclamation',
|
||||
'EDIT_TITLE': 'edit',
|
||||
'EDIT_COMMENT': 'edit',
|
||||
|
||||
'EDIT_SETTINGS': 'wrench',
|
||||
'SIGNUP': 'user-plus',
|
||||
'INVITE': 'user-plus',
|
||||
'ADD_TOPIC': 'book',
|
||||
'ADD_ARTICLE': 'book',
|
||||
'DELETE_TOPIC': 'book',
|
||||
@ -126,4 +142,4 @@ class ActivityRow extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default ActivityRow;
|
||||
export default ActivityRow;
|
||||
|
@ -5,8 +5,7 @@ import ModalContainer from 'app-components/modal-container';
|
||||
|
||||
import Button from 'core-components/button';
|
||||
import Input from 'core-components/input';
|
||||
import Icon from 'core-components/icon';
|
||||
|
||||
import Loading from 'core-components/loading'
|
||||
|
||||
class AreYouSure extends React.Component {
|
||||
static propTypes = {
|
||||
@ -24,13 +23,14 @@ class AreYouSure extends React.Component {
|
||||
};
|
||||
|
||||
state = {
|
||||
loading: false,
|
||||
password: ''
|
||||
};
|
||||
|
||||
static openModal(description, onYes, type = 'default') {
|
||||
ModalContainer.openModal(
|
||||
<AreYouSure description={description} onYes={onYes} type={type}/>,
|
||||
true
|
||||
<AreYouSure description={description} onYes={onYes} type={type} />,
|
||||
{noPadding: true, closeButton: {showCloseButton: true, whiteColor: true}}
|
||||
);
|
||||
}
|
||||
|
||||
@ -39,28 +39,34 @@ class AreYouSure extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { loading } = this.state;
|
||||
const { description, type } = this.props;
|
||||
|
||||
return (
|
||||
<div className="are-you-sure" role="dialog" aria-labelledby="are-you-sure__header" aria-describedby="are-you-sure__description">
|
||||
<div className="are-you-sure__header" id="are-you-sure__header">
|
||||
{i18n('ARE_YOU_SURE')}
|
||||
</div>
|
||||
<span className="are-you-sure__close-icon" onClick={this.onNo.bind(this)}>
|
||||
<Icon name="times" size="2x"/>
|
||||
</span>
|
||||
<div className="are-you-sure__description" id="are-you-sure__description">
|
||||
{this.props.description || (this.props.type === 'secure' && i18n('PLEASE_CONFIRM_PASSWORD'))}
|
||||
{description || (type === 'secure' && i18n('PLEASE_CONFIRM_PASSWORD'))}
|
||||
</div>
|
||||
{(this.props.type === 'secure') ? this.renderPassword() : null}
|
||||
{(type === 'secure') ? this.renderPassword() : null}
|
||||
<span className="separator" />
|
||||
<div className="are-you-sure__buttons">
|
||||
<div className="are-you-sure__no-button">
|
||||
<Button type="link" size="auto" onClick={this.onNo.bind(this)} tabIndex="2">
|
||||
<Button disabled={loading} type="link" size="auto" onClick={this.onNo.bind(this)} tabIndex="2">
|
||||
{i18n('CANCEL')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="are-you-sure__yes-button">
|
||||
<Button type="secondary" size="small" onClick={this.onYes.bind(this)} ref="yesButton" tabIndex="2">
|
||||
{i18n('YES')}
|
||||
<Button
|
||||
type="secondary"
|
||||
size="small"
|
||||
onClick={this.onYes.bind(this)}
|
||||
ref="yesButton"
|
||||
tabIndex="2"
|
||||
disabled={loading}>
|
||||
{loading ? <Loading /> : i18n('YES')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -69,8 +75,20 @@ class AreYouSure extends React.Component {
|
||||
}
|
||||
|
||||
renderPassword() {
|
||||
const { password, loading } = this.state;
|
||||
|
||||
return (
|
||||
<Input className="are-you-sure__password" password placeholder="password" name="password" ref="password" size="medium" value={this.state.password} onChange={this.onPasswordChange.bind(this)} onKeyDown={this.onInputKeyDown.bind(this)}/>
|
||||
<Input
|
||||
className="are-you-sure__password"
|
||||
password
|
||||
placeholder="password"
|
||||
name="password"
|
||||
ref="password"
|
||||
size="medium"
|
||||
value={password}
|
||||
onChange={this.onPasswordChange.bind(this)}
|
||||
onKeyDown={this.onInputKeyDown.bind(this)}
|
||||
disabled={loading} />
|
||||
);
|
||||
}
|
||||
|
||||
@ -87,28 +105,59 @@ class AreYouSure extends React.Component {
|
||||
}
|
||||
|
||||
onYes() {
|
||||
if (this.props.type === 'secure' && !this.state.password) {
|
||||
const { password } = this.state;
|
||||
const { type, onYes } = this.props;
|
||||
|
||||
if(type === 'secure' && !password) {
|
||||
this.refs.password.focus()
|
||||
}
|
||||
|
||||
if (this.props.type === 'default' || this.state.password) {
|
||||
this.closeModal();
|
||||
|
||||
if (this.props.onYes) {
|
||||
this.props.onYes(this.state.password);
|
||||
if(type === 'default' || password) {
|
||||
if(onYes) {
|
||||
const result = onYes(password);
|
||||
if(this.isPromise(result)) {
|
||||
this.setState({
|
||||
loading: true
|
||||
});
|
||||
result
|
||||
.then(() => {
|
||||
this.setState({
|
||||
loading: false
|
||||
});
|
||||
this.closeModal();
|
||||
})
|
||||
.catch(() => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
});
|
||||
this.closeModal();
|
||||
})
|
||||
} else {
|
||||
this.closeModal();
|
||||
}
|
||||
} else {
|
||||
this.closeModal();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isPromise(object) {
|
||||
if(Promise && Promise.resolve) {
|
||||
return Promise.resolve(object) == object;
|
||||
} else {
|
||||
throw "Promise not supported in your environment"
|
||||
}
|
||||
}
|
||||
|
||||
onNo() {
|
||||
this.closeModal();
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
if (this.context.closeModal) {
|
||||
this.context.closeModal();
|
||||
}
|
||||
const { closeModal } = this.context;
|
||||
|
||||
closeModal && closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
export default AreYouSure;
|
||||
export default AreYouSure;
|
||||
|
@ -24,12 +24,16 @@
|
||||
&__buttons {
|
||||
margin-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
text-align: right;
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__yes-button,
|
||||
&__no-button {
|
||||
display: inline-block;
|
||||
display: block;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,6 @@
|
||||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import i18n from 'lib-app/i18n';
|
||||
import API from 'lib-app/api-call';
|
||||
@ -9,48 +11,65 @@ import Form from 'core-components/form';
|
||||
import FormField from 'core-components/form-field';
|
||||
import SubmitButton from 'core-components/submit-button';
|
||||
import Button from 'core-components/button';
|
||||
import TextEditor from 'core-components/text-editor';
|
||||
|
||||
class ArticleAddModal extends React.Component {
|
||||
static propTypes = {
|
||||
topicId: React.PropTypes.number.isRequired,
|
||||
topicName: React.PropTypes.string.isRequired,
|
||||
position: React.PropTypes.number.isRequired
|
||||
position: React.PropTypes.number.isRequired,
|
||||
allowAttachments: React.PropTypes.bool
|
||||
};
|
||||
|
||||
state = {
|
||||
loading: false
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="article-add-modal">
|
||||
<Header title={i18n('ADD_ARTICLE')} description={i18n('ADD_ARTICLE_DESCRIPTION', {category: this.props.topicName})} />
|
||||
<Form onSubmit={this.onAddNewArticleFormSubmit.bind(this)}>
|
||||
<Form onSubmit={this.onAddNewArticleFormSubmit.bind(this)} loading={this.state.loading}>
|
||||
<FormField name="title" label={i18n('TITLE')} field="input" fieldProps={{size: 'large'}} validation="TITLE" required/>
|
||||
<FormField name="content" label={i18n('CONTENT')} field="textarea" validation="TEXT_AREA" required/>
|
||||
<SubmitButton type="secondary">{i18n('ADD_ARTICLE')}</SubmitButton>
|
||||
<FormField name="content" label={i18n('CONTENT')} field="textarea" validation="TEXT_AREA" required fieldProps={{allowImages: this.props.allowAttachments}}/>
|
||||
<Button className="article-add-modal__cancel-button" type="link" onClick={(event) => {
|
||||
event.preventDefault();
|
||||
ModalContainer.closeModal();
|
||||
}}>{i18n('CANCEL')}</Button>
|
||||
<SubmitButton className="article-add-modal__submit-button" type="secondary">{i18n('ADD_ARTICLE')}</SubmitButton>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onAddNewArticleFormSubmit(form) {
|
||||
this.setState({
|
||||
loading: true
|
||||
});
|
||||
|
||||
API.call({
|
||||
path: '/article/add',
|
||||
data: {
|
||||
dataAsForm: true,
|
||||
data: _.extend(TextEditor.getContentFormData(form.content), {
|
||||
title: form.title,
|
||||
content: form.content,
|
||||
topicId: this.props.topicId,
|
||||
position: this.props.position
|
||||
}
|
||||
})
|
||||
}).then(() => {
|
||||
ModalContainer.closeModal();
|
||||
|
||||
if(this.props.onChange) {
|
||||
this.props.onChange();
|
||||
}
|
||||
}).catch(() => {
|
||||
this.setState({
|
||||
loading: false
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default ArticleAddModal;
|
||||
export default connect((store) => {
|
||||
return {
|
||||
allowAttachments: store.config['allow-attachments']
|
||||
};
|
||||
})(ArticleAddModal);
|
||||
|
@ -2,7 +2,11 @@
|
||||
width: 800px;
|
||||
|
||||
&__cancel-button {
|
||||
float: right;
|
||||
float: left;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
&__submit-button {
|
||||
float: right;
|
||||
}
|
||||
}
|
@ -36,11 +36,13 @@ class ArticlesList extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
if(this.props.errored) {
|
||||
return <Message type="error">{i18n('ERROR_RETRIEVING_ARTICLES')}</Message>;
|
||||
const { errored, loading } = this.props;
|
||||
|
||||
if(errored) {
|
||||
return <Message showCloseButton={false} type="error">{i18n('ERROR_RETRIEVING_ARTICLES')}</Message>;
|
||||
}
|
||||
|
||||
return (this.props.loading) ? <Loading /> : this.renderContent();
|
||||
return loading ? <Loading className="articles-list__loading" backgrounded size="large"/> : this.renderContent();
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
@ -53,17 +55,19 @@ class ArticlesList extends React.Component {
|
||||
}
|
||||
|
||||
renderTopics() {
|
||||
const { topics, editable, articlePath } = this.props;
|
||||
|
||||
return (
|
||||
<div className="articles-list__topics">
|
||||
{this.props.topics.map((topic, index) => {
|
||||
{topics.map((topic, index) => {
|
||||
return (
|
||||
<div key={index}>
|
||||
<TopicViewer
|
||||
{...topic}
|
||||
id={topic.id * 1}
|
||||
editable={this.props.editable}
|
||||
editable={editable}
|
||||
onChange={this.retrieveArticles.bind(this)}
|
||||
articlePath={this.props.articlePath} />
|
||||
articlePath={articlePath} />
|
||||
<span className="separator" />
|
||||
</div>
|
||||
);
|
||||
@ -76,7 +80,7 @@ class ArticlesList extends React.Component {
|
||||
return (
|
||||
<div className="articles-list__add-topic-button">
|
||||
<Button onClick={() => ModalContainer.openModal(<TopicEditModal addForm onChange={this.retrieveArticles.bind(this)} />)} type="secondary" className="articles-list__add">
|
||||
<Icon name="plus-circle" size="2x" className="articles-list__add-icon"/> {i18n('ADD_TOPIC')}
|
||||
<Icon name="plus" className="articles-list__add-icon" /> {i18n('ADD_TOPIC')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@ -88,9 +92,11 @@ class ArticlesList extends React.Component {
|
||||
}
|
||||
|
||||
export default connect((store) => {
|
||||
const { topics, errored, loading } = store.articles;
|
||||
|
||||
return {
|
||||
topics: store.articles.topics,
|
||||
errored: store.articles.errored,
|
||||
loading: store.articles.loading
|
||||
topics: topics.map((topic) => {return {...topic, private: topic.private === "1"}}),
|
||||
errored,
|
||||
loading
|
||||
};
|
||||
})(ArticlesList);
|
||||
|
@ -1,14 +1,19 @@
|
||||
@import "../scss/vars";
|
||||
|
||||
.articles-list {
|
||||
|
||||
&__add {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__add-icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
margin-top: -4px;
|
||||
&__loading {
|
||||
min-width: 300px;
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
&__add-topic-button {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
41
client/src/app-components/department-dropdown.js
Normal file
41
client/src/app-components/department-dropdown.js
Normal file
@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
|
||||
import DropDown from 'core-components/drop-down';
|
||||
import Icon from 'core-components/icon';
|
||||
|
||||
class DepartmentDropdown extends React.Component {
|
||||
static propTypes = {
|
||||
value: React.PropTypes.number,
|
||||
onChange: React.PropTypes.func,
|
||||
departments: React.PropTypes.array
|
||||
}
|
||||
|
||||
render() {
|
||||
return <DropDown {...this.props} onChange={this.onChange.bind(this)} items={this.getDepartments()} />
|
||||
}
|
||||
|
||||
getDepartments() {
|
||||
let departments = this.props.departments.map((department) => {
|
||||
if(department.private*1) {
|
||||
return {content: <span>{department.name} <Icon name='user-secret'/></span>};
|
||||
} else {
|
||||
return {content: department.name};
|
||||
}
|
||||
});
|
||||
|
||||
return departments;
|
||||
}
|
||||
|
||||
onChange(event) {
|
||||
if(this.props.onChange) {
|
||||
this.props.onChange({
|
||||
index: event.index,
|
||||
target: {
|
||||
value: event.index
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default DepartmentDropdown;
|
@ -3,7 +3,6 @@ import {connect} from 'react-redux';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import languageList from 'data/language-list';
|
||||
import i18n from 'lib-app/i18n';
|
||||
import DropDown from 'core-components/drop-down';
|
||||
|
||||
const languageCodes = Object.keys(languageList);
|
||||
|
@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import keyCode from 'keycode';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import store from 'app/store';
|
||||
import ModalActions from 'actions/modal-actions';
|
||||
@ -9,11 +8,14 @@ import Modal from 'core-components/modal';
|
||||
|
||||
class ModalContainer extends React.Component {
|
||||
|
||||
static openModal(content, noPadding) {
|
||||
static openModal(
|
||||
content,
|
||||
options={noPadding: false, outsideClick: false, closeButton: {showCloseButton: false, whiteColor: false}}
|
||||
) {
|
||||
store.dispatch(
|
||||
ModalActions.openModal({
|
||||
content,
|
||||
noPadding
|
||||
options
|
||||
})
|
||||
);
|
||||
}
|
||||
@ -49,8 +51,16 @@ class ModalContainer extends React.Component {
|
||||
}
|
||||
|
||||
renderModal() {
|
||||
const { content, options } = this.props.modal;
|
||||
const { noPadding, outsideClick, closeButton } = options;
|
||||
|
||||
return (
|
||||
<Modal content={this.props.modal.content} noPadding={this.props.modal.noPadding}/>
|
||||
<Modal
|
||||
content={content}
|
||||
noPadding={noPadding}
|
||||
outsideClick={outsideClick}
|
||||
onOutsideClick={this.closeModal.bind(this)}
|
||||
closeButton={closeButton} />
|
||||
);
|
||||
}
|
||||
|
||||
@ -69,4 +79,4 @@ export default connect((store) => {
|
||||
return {
|
||||
modal: store.modal
|
||||
};
|
||||
})(ModalContainer);
|
||||
})(ModalContainer);
|
||||
|
41
client/src/app-components/page-size-dropdown.js
Normal file
41
client/src/app-components/page-size-dropdown.js
Normal file
@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
|
||||
import DropDown from 'core-components/drop-down';
|
||||
import i18n from 'lib-app/i18n';
|
||||
|
||||
class PageSizeDropdown extends React.Component {
|
||||
static propTypes = {
|
||||
value: React.PropTypes.number,
|
||||
onChange: React.PropTypes.func,
|
||||
pages: React.PropTypes.array
|
||||
}
|
||||
|
||||
state = {
|
||||
selectedIndex: 1
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<DropDown {...this.props} onChange={this.onChange.bind(this)} items={this.getPages()} selectedIndex={this.state.selectedIndex} />
|
||||
)
|
||||
}
|
||||
|
||||
getPages() {
|
||||
return this.props.pages.map((page) => {
|
||||
return {content: `${page} / ${i18n('TICKETS')}`}
|
||||
});
|
||||
}
|
||||
|
||||
onChange(event) {
|
||||
this.setState({
|
||||
selectedIndex: event.index
|
||||
})
|
||||
if(this.props.onChange) {
|
||||
this.props.onChange({
|
||||
pageSize: this.props.pages[event.index]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PageSizeDropdown;
|
102
client/src/app-components/password-recovery.js
Normal file
102
client/src/app-components/password-recovery.js
Normal file
@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import i18n from 'lib-app/i18n';
|
||||
import API from 'lib-app/api-call';
|
||||
|
||||
import Form from 'core-components/form';
|
||||
import FormField from 'core-components/form-field';
|
||||
import Widget from 'core-components/widget';
|
||||
import Button from 'core-components/button';
|
||||
import SubmitButton from 'core-components/submit-button';
|
||||
import Message from 'core-components/message';
|
||||
|
||||
class PasswordRecovery extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
recoverSent: React.PropTypes.bool,
|
||||
formProps: React.PropTypes.object,
|
||||
onBackToLoginClick: React.PropTypes.func,
|
||||
renderLogo: React.PropTypes.bool
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
renderLogo: false
|
||||
};
|
||||
|
||||
state = {
|
||||
showRecoverSentMessage: true
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (!prevProps.recoverSent && this.props.recoverSent) {
|
||||
this.setState({showRecoverSentMessage : true});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { renderLogo, formProps, onBackToLoginClick, style } = this.props;
|
||||
|
||||
return (
|
||||
<Widget style={style} className={this.getClass()} title={!renderLogo ? i18n('RECOVER_PASSWORD') : ''}>
|
||||
{this.renderLogo()}
|
||||
<Form {...formProps}>
|
||||
<div className="password-recovery__inputs">
|
||||
<FormField ref="email" placeholder={i18n('EMAIL_LOWERCASE')} name="email" className="password-recovery__input" validation="EMAIL" required/>
|
||||
</div>
|
||||
<div className="password-recovery__submit-button">
|
||||
<SubmitButton type="primary">{i18n('RECOVER_PASSWORD')}</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
<Button className="password-recovery__forgot-password" type="link" onClick={onBackToLoginClick} onMouseDown={(event) => {event.preventDefault()}}>
|
||||
{i18n('BACK_LOGIN_FORM')}
|
||||
</Button>
|
||||
{this.renderRecoverStatus()}
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
getClass() {
|
||||
return classNames({
|
||||
'password-recovery__content': true,
|
||||
[this.props.className]: (this.props.className)
|
||||
});
|
||||
}
|
||||
|
||||
renderLogo() {
|
||||
let logo = null;
|
||||
|
||||
if (this.props.renderLogo) {
|
||||
logo = (<div className="password-recovery__image"><img width="100%" src={API.getURL() + '/images/logo.png'} alt="OpenSupports Login Panel"/></div>);
|
||||
}
|
||||
|
||||
return logo;
|
||||
}
|
||||
|
||||
renderRecoverStatus() {
|
||||
return (
|
||||
this.props.recoverSent ?
|
||||
<Message
|
||||
showMessage={this.state.showRecoverSentMessage}
|
||||
onCloseMessage={this.onCloseMessage.bind(this, "showRecoverSentMessage")}
|
||||
className="password-recovery__message"
|
||||
type="info"
|
||||
leftAligned>
|
||||
{i18n('RECOVER_SENT')}
|
||||
</Message> :
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
focusEmail() {
|
||||
this.refs.email.focus();
|
||||
}
|
||||
|
||||
onCloseMessage(showMessage) {
|
||||
this.setState({
|
||||
[showMessage]: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default PasswordRecovery;
|
38
client/src/app-components/password-recovery.scss
Normal file
38
client/src/app-components/password-recovery.scss
Normal file
@ -0,0 +1,38 @@
|
||||
.password-recovery {
|
||||
|
||||
&__inputs {
|
||||
display: inline-block;
|
||||
margin: 0 auto 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&__content {
|
||||
margin: 0 auto;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
&__forgot-password {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
&__message {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
&__image {
|
||||
width: 365px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 320px) {
|
||||
.widget {
|
||||
margin-left: -12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 992px) {
|
||||
.password-recovery__content {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
@ -93,7 +93,7 @@ class PeopleList extends React.Component {
|
||||
</div>
|
||||
<div className="people-list__item-block people-list__item-last-login">
|
||||
<div>{i18n('LAST_LOGIN')}</div>
|
||||
<div>{DateTransformer.transformToString(item.lastLogin)}</div>
|
||||
<div>{item.lastLogin ? DateTransformer.transformToString(item.lastLogin) : i18n('NEVER')}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
@ -61,4 +61,28 @@
|
||||
&__pagination {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 415px) {
|
||||
.people-list {
|
||||
&__item {
|
||||
height: unset;
|
||||
padding: unset;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
|
||||
&-profile-pic-wrapper {
|
||||
top: 15px;
|
||||
left: 15px;
|
||||
}
|
||||
|
||||
&-block {
|
||||
width: unset;
|
||||
display: unset;
|
||||
padding: 15px 10px 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
48
client/src/app-components/popup-message.js
Normal file
48
client/src/app-components/popup-message.js
Normal file
@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
|
||||
import ModalContainer from 'app-components/modal-container';
|
||||
|
||||
import Button from 'core-components/button';
|
||||
import Message from 'core-components/message';
|
||||
|
||||
|
||||
class PopupMessage extends React.Component {
|
||||
static propTypes = Message.propTypes;
|
||||
|
||||
static contextTypes = {
|
||||
closeModal: React.PropTypes.func
|
||||
};
|
||||
|
||||
static open(props) {
|
||||
ModalContainer.openModal(
|
||||
<PopupMessage {...props} />,
|
||||
{noPadding: true, outsideClick: true, closeButton: {showCloseButton: false, whiteColor: false}}
|
||||
);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.refs.closeButton && this.refs.closeButton.focus();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="popup-message">
|
||||
<Message {...this.props} showCloseButton={false} className="popup-message__message" />
|
||||
<Button
|
||||
className="popup-message__close-button"
|
||||
iconName="times"
|
||||
type="clean"
|
||||
ref="closeButton"
|
||||
onClick={this.closeModal.bind(this)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
const { closeModal } = this.context;
|
||||
|
||||
closeModal && closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
export default PopupMessage;
|
17
client/src/app-components/popup-message.scss
Normal file
17
client/src/app-components/popup-message.scss
Normal file
@ -0,0 +1,17 @@
|
||||
@import "../scss/vars";
|
||||
|
||||
.popup-message {
|
||||
min-width: 500px;
|
||||
position: relative;
|
||||
|
||||
&__close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
color: $dark-grey;
|
||||
&:focus {
|
||||
outline: none;
|
||||
color: $grey;
|
||||
}
|
||||
}
|
||||
}
|
19
client/src/app-components/session-expired-modal.js
Normal file
19
client/src/app-components/session-expired-modal.js
Normal file
@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
|
||||
import i18n from "lib-app/i18n";
|
||||
|
||||
import Header from "core-components/header";
|
||||
|
||||
class SessionExpiredModal extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<Header
|
||||
title={i18n("SESSION_EXPIRED")}
|
||||
description={i18n("SESSION_EXPIRED_DESCRIPTION")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default SessionExpiredModal;
|
42
client/src/app-components/stat-card.js
Normal file
42
client/src/app-components/stat-card.js
Normal file
@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
|
||||
import Tooltip from 'core-components/tooltip';
|
||||
|
||||
class StatCard extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
label: React.PropTypes.string,
|
||||
description: React.PropTypes.string,
|
||||
value: React.PropTypes.number,
|
||||
isPercentage: React.PropTypes.bool
|
||||
};
|
||||
|
||||
state = {
|
||||
showTooltip: false
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
isPercentage
|
||||
} = this.props;
|
||||
|
||||
const displayValue = isNaN(value) ? "-" : (isPercentage ? value.toFixed(2) : value);
|
||||
return (
|
||||
<Tooltip content={description} show={this.state.showTooltip} >
|
||||
<div className="stat-card" onMouseEnter={() => this.setState({showTooltip: true})} onMouseLeave={() => this.setState({showTooltip: false})}>
|
||||
<div className="stat-card__wrapper">
|
||||
{label}
|
||||
<div className="stat-card__container">
|
||||
{displayValue}{isPercentage && !isNaN(value) ? "%" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default StatCard;
|
22
client/src/app-components/stat-card.scss
Normal file
22
client/src/app-components/stat-card.scss
Normal file
@ -0,0 +1,22 @@
|
||||
@import "../scss/vars";
|
||||
|
||||
.stat-card {
|
||||
margin: 0 4px;
|
||||
|
||||
&__wrapper {
|
||||
padding: 10px;
|
||||
min-width: 160px;
|
||||
margin: 8px auto;
|
||||
transition: 0.3s;
|
||||
box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2);
|
||||
text-align: center;
|
||||
&:hover {
|
||||
box-shadow: 0 8px 16px 0 rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&__container {
|
||||
font-size: $font-size--lg;
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
@ -1,100 +0,0 @@
|
||||
import React from 'react';
|
||||
import {Line} from 'react-chartjs-2';
|
||||
|
||||
import i18n from 'lib-app/i18n';
|
||||
|
||||
class StatsChart extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
strokes: React.PropTypes.arrayOf(React.PropTypes.shape({
|
||||
name: React.PropTypes.string,
|
||||
values: React.PropTypes.arrayOf(React.PropTypes.shape({
|
||||
date: React.PropTypes.string,
|
||||
value: React.PropTypes.number
|
||||
}))
|
||||
})),
|
||||
period: React.PropTypes.number
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<Line data={this.getChartData()} options={this.getChartOptions()} width={800} height={400} redraw/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
getChartData() {
|
||||
let labels = this.getLabels();
|
||||
|
||||
let color = {
|
||||
'CLOSE': 'rgba(150, 20, 20, 0.6)',
|
||||
'CREATE_TICKET': 'rgba(20, 150, 20, 0.6)',
|
||||
'SIGNUP': 'rgba(20, 20, 150, 0.6)',
|
||||
'COMMENT': 'rgba(20, 200, 200, 0.6)',
|
||||
'ASSIGN': 'rgba(20, 150, 20, 0.6)'
|
||||
};
|
||||
|
||||
let strokes = this.props.strokes.slice();
|
||||
let datasets = strokes.map((stroke, index) => {
|
||||
return {
|
||||
label: i18n('CHART_' + stroke.name),
|
||||
data: stroke.values.map((val) => val.value),
|
||||
fill: false,
|
||||
borderWidth: this.getBorderWidth(),
|
||||
borderColor: color[stroke.name],
|
||||
pointBorderColor: color[stroke.name],
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3,
|
||||
lineTension: 0.2,
|
||||
pointHoverBackgroundColor: color[stroke.name],
|
||||
hitRadius: this.hitRadius(index)
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
labels: labels,
|
||||
datasets: datasets
|
||||
};
|
||||
}
|
||||
|
||||
getBorderWidth() {
|
||||
return (this.props.period <= 90) ? 3 : 2;
|
||||
}
|
||||
|
||||
getLabels() {
|
||||
let months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
let labels = [];
|
||||
|
||||
if (!this.props.strokes.length) {
|
||||
labels = Array.from('x'.repeat(this.props.period));
|
||||
}
|
||||
else {
|
||||
labels = this.props.strokes[0].values.map((item) => {
|
||||
let idx = item.date.slice(4, 6) - 1;
|
||||
|
||||
return item.date.slice(6, 8) + ' ' + months[idx];
|
||||
});
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
hitRadius(index) {
|
||||
if (this.props.period <= 7) return 20;
|
||||
if (this.props.period <= 30) return 15;
|
||||
if (this.props.period <= 90) return 10;
|
||||
return 3;
|
||||
}
|
||||
|
||||
getChartOptions() {
|
||||
return {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default StatsChart;
|
@ -1,199 +0,0 @@
|
||||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import i18n from 'lib-app/i18n';
|
||||
import API from 'lib-app/api-call';
|
||||
|
||||
import DropDown from 'core-components/drop-down';
|
||||
import ToggleList from 'core-components/toggle-list';
|
||||
|
||||
import StatsChart from 'app-components/stats-chart';
|
||||
|
||||
const generalStrokes = ['CREATE_TICKET', 'CLOSE', 'SIGNUP', 'COMMENT'];
|
||||
const staffStrokes = ['ASSIGN', 'CLOSE'];
|
||||
const ID = {
|
||||
'CREATE_TICKET': 0,
|
||||
'ASSIGN': 0,
|
||||
'CLOSE': 1,
|
||||
'SIGNUP': 2,
|
||||
'COMMENT': 3
|
||||
};
|
||||
|
||||
class Stats extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
type: React.PropTypes.string,
|
||||
staffId: React.PropTypes.number
|
||||
};
|
||||
|
||||
state = {
|
||||
stats: this.getDefaultStats(),
|
||||
strokes: this.getStrokes().map((name) => {
|
||||
return {
|
||||
name: name,
|
||||
values: []
|
||||
}
|
||||
}),
|
||||
showed: [0],
|
||||
period: 0
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.retrieve(7);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={this.getClass()}>
|
||||
<DropDown {...this.getDropDownProps()}/>
|
||||
<ToggleList {...this.getToggleListProps()} />
|
||||
<StatsChart {...this.getStatsChartProps()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
getClass() {
|
||||
let classes = {
|
||||
'stats': true,
|
||||
'stats_staff': this.props.type === 'staff'
|
||||
};
|
||||
|
||||
return classNames(classes);
|
||||
}
|
||||
|
||||
getToggleListProps() {
|
||||
return {
|
||||
values: this.state.showed,
|
||||
className: 'stats__toggle-list',
|
||||
onChange: this.onToggleListChange.bind(this),
|
||||
type: this.props.type === 'general' ? 'default' : 'small',
|
||||
items: this.getStrokes().map((name) => {
|
||||
return {
|
||||
className: 'stats__toggle-list_' + name,
|
||||
content: (
|
||||
<div className="stats__toggle-list-item">
|
||||
<div className="stats__toggle-list-item-value">{this.state.stats[name]}</div>
|
||||
<div className="stats__toggle-list-item-name">{i18n('CHART_' + name)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
onToggleListChange(event) {
|
||||
this.setState({
|
||||
showed: event.target.value
|
||||
});
|
||||
}
|
||||
|
||||
getDropDownProps() {
|
||||
return {
|
||||
items: ['LAST_7_DAYS', 'LAST_30_DAYS', 'LAST_90_DAYS', 'LAST_365_DAYS'].map((name) => {
|
||||
return {
|
||||
content: i18n(name),
|
||||
icon: ''
|
||||
};
|
||||
}),
|
||||
onChange: this.onDropDownChange.bind(this),
|
||||
className: 'stats__dropdown'
|
||||
}
|
||||
}
|
||||
|
||||
onDropDownChange(event) {
|
||||
let val = [7, 30, 90, 365];
|
||||
|
||||
this.retrieve(val[event.index]);
|
||||
}
|
||||
|
||||
getStatsChartProps() {
|
||||
let showed = this.getShowedArray();
|
||||
|
||||
return {
|
||||
period: this.state.period,
|
||||
strokes: _.filter(this.state.strokes, (s, i) => showed[i])
|
||||
};
|
||||
}
|
||||
|
||||
retrieve(period) {
|
||||
let periodName;
|
||||
|
||||
switch (period) {
|
||||
case 30:
|
||||
periodName = 'MONTH';
|
||||
break;
|
||||
case 90:
|
||||
periodName = 'QUARTER';
|
||||
break;
|
||||
case 365:
|
||||
periodName = 'YEAR';
|
||||
break;
|
||||
default:
|
||||
periodName = 'WEEK';
|
||||
}
|
||||
|
||||
API.call({
|
||||
path: '/system/get-stats',
|
||||
data: this.getApiCallData(periodName)
|
||||
}).then(this.onRetrieveSuccess.bind(this));
|
||||
}
|
||||
|
||||
onRetrieveSuccess(result) {
|
||||
let newStats = this.getDefaultStats();
|
||||
|
||||
let newStrokes = this.getStrokes().map((name) => {
|
||||
return {
|
||||
name: name,
|
||||
values: []
|
||||
};
|
||||
});
|
||||
|
||||
let realPeriod = result.data.length / this.getStrokes().length;
|
||||
|
||||
result.data.reverse().map((item) => {
|
||||
newStats[item.type] += item.value * 1;
|
||||
|
||||
newStrokes[ ID[item.type] ].values.push({
|
||||
date: item.date,
|
||||
value: item.value * 1
|
||||
});
|
||||
});
|
||||
|
||||
this.setState({stats: newStats, strokes: newStrokes, period: realPeriod});
|
||||
}
|
||||
|
||||
getShowedArray() {
|
||||
let showed = this.getStrokes().map(() => false);
|
||||
|
||||
for (let i = 0; i < showed.length; i++) {
|
||||
showed[this.state.showed[i]] = true;
|
||||
}
|
||||
|
||||
return showed;
|
||||
}
|
||||
|
||||
getStrokes() {
|
||||
return this.props.type === 'general' ? generalStrokes : staffStrokes;
|
||||
}
|
||||
|
||||
getDefaultStats() {
|
||||
return this.props.type === 'general' ?
|
||||
{
|
||||
'CREATE_TICKET': 0,
|
||||
'CLOSE': 0,
|
||||
'SIGNUP': 0,
|
||||
'COMMENT': 0
|
||||
} :
|
||||
{
|
||||
'ASSIGN': 0,
|
||||
'CLOSE': 0
|
||||
};
|
||||
}
|
||||
|
||||
getApiCallData(periodName) {
|
||||
return this.props.type === 'general' ? {period: periodName} : {period: periodName, staffId: this.props.staffId};
|
||||
}
|
||||
}
|
||||
|
||||
export default Stats;
|
@ -1,76 +0,0 @@
|
||||
@import '../scss/vars';
|
||||
|
||||
.stats {
|
||||
|
||||
&__dropdown {
|
||||
margin-left: auto;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
&__toggle-list {
|
||||
margin-bottom: 20px;
|
||||
user-select: none;
|
||||
|
||||
&-item {
|
||||
|
||||
&-value {
|
||||
font-size: $font-size--lg;
|
||||
line-height: 80px;
|
||||
}
|
||||
|
||||
&-name {
|
||||
font-size: $font-size--md;
|
||||
line-height: 20px;
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
&_CREATE_TICKET.toggle-list__selected {
|
||||
box-shadow: inset 0 -5px 0px 0px rgba(20, 150, 20, 0.6);
|
||||
}
|
||||
|
||||
&_CLOSE.toggle-list__selected {
|
||||
box-shadow: inset 0 -5px 0px 0px rgba(150, 20, 20, 0.6);
|
||||
}
|
||||
|
||||
&_SIGNUP.toggle-list__selected {
|
||||
box-shadow: inset 0 -5px 0px 0px rgba(20, 20, 150, 0.6);
|
||||
}
|
||||
|
||||
&_COMMENT.toggle-list__selected {
|
||||
box-shadow: inset 0 -5px 0px 0px rgba(20, 200, 200, 0.6);
|
||||
}
|
||||
|
||||
&_ASSIGN.toggle-list__selected {
|
||||
box-shadow: inset 0 -5px 0px 0px rgba(20, 150, 20, 0.6);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
&_staff {
|
||||
.stats__dropdown {
|
||||
margin-left: auto;
|
||||
margin-bottom: 20px;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.stats__toggle-list {
|
||||
margin-bottom: 20px;
|
||||
float: right;
|
||||
|
||||
&-item {
|
||||
|
||||
&-value {
|
||||
font-size: $font-size--md;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
&-name {
|
||||
font-size: $font-size--sm;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,12 +1,20 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import i18n from 'lib-app/i18n';
|
||||
import API from 'lib-app/api-call';
|
||||
|
||||
import DateTransformer from 'lib-core/date-transformer';
|
||||
import Icon from 'core-components/icon';
|
||||
import Tooltip from 'core-components/tooltip';
|
||||
import Button from 'core-components/button';
|
||||
import SubmitButton from 'core-components/submit-button';
|
||||
import Form from 'core-components/form';
|
||||
import FormField from 'core-components/form-field';
|
||||
|
||||
const VIEW_USER_PATH = "/admin/panel/users/view-user/";
|
||||
const VIEW_STAFF_PATH = "/admin/panel/staff/view-staff/";
|
||||
class TicketEvent extends React.Component {
|
||||
static propTypes = {
|
||||
type: React.PropTypes.oneOf([
|
||||
@ -16,11 +24,20 @@ class TicketEvent extends React.Component {
|
||||
'CLOSE',
|
||||
'RE_OPEN',
|
||||
'DEPARTMENT_CHANGED',
|
||||
'PRIORITY_CHANGED'
|
||||
]),
|
||||
author: React.PropTypes.object,
|
||||
content: React.PropTypes.string,
|
||||
date: React.PropTypes.string
|
||||
date: React.PropTypes.string,
|
||||
private: React.PropTypes.string,
|
||||
edited: React.PropTypes.bool,
|
||||
edit: React.PropTypes.bool,
|
||||
onToggleEdit: React.PropTypes.func,
|
||||
isLastComment: React.PropTypes.bool,
|
||||
isTicketClosed: React.PropTypes.bool
|
||||
};
|
||||
|
||||
state = {
|
||||
content: this.props.content
|
||||
};
|
||||
|
||||
render() {
|
||||
@ -71,42 +88,116 @@ class TicketEvent extends React.Component {
|
||||
'CLOSE': this.renderClosed.bind(this),
|
||||
'RE_OPEN': this.renderReOpened.bind(this),
|
||||
'DEPARTMENT_CHANGED': this.renderDepartmentChange.bind(this),
|
||||
'PRIORITY_CHANGED': this.renderPriorityChange.bind(this)
|
||||
};
|
||||
|
||||
return renders[this.props.type]();
|
||||
}
|
||||
|
||||
renderComment() {
|
||||
const { author, date, edit, file } = this.props;
|
||||
const customFields = (author && author.customfields) || [];
|
||||
|
||||
return (
|
||||
<div className="ticket-event__comment">
|
||||
<span className="ticket-event__comment-pointer" />
|
||||
<div className="ticket-event__comment-author">
|
||||
<span className="ticket-event__comment-author-name">{this.props.author.name}</span>
|
||||
<span className="ticket-event__comment-author-type">({i18n((this.props.author.staff) ? 'STAFF' : 'CUSTOMER')})</span>
|
||||
{this.renderCommentAuthor()}
|
||||
<span className="ticket-event__comment-badge-container">
|
||||
<span className="ticket-event__comment-badge">{i18n((author.staff) ? 'STAFF' : 'CUSTOMER')}</span>
|
||||
</span>
|
||||
{customFields.map(this.renderCustomFieldValue.bind(this))}
|
||||
{(this.props.private*1) ? this.renderPrivateBadge() : null}
|
||||
</div>
|
||||
<div className="ticket-event__comment-date">{DateTransformer.transformToString(this.props.date)}</div>
|
||||
<div className="ticket-event__comment-content" dangerouslySetInnerHTML={{__html: this.props.content}}></div>
|
||||
{this.renderFileRow(this.props.file)}
|
||||
<div className="ticket-event__comment-date">{DateTransformer.transformToString(date)}</div>
|
||||
{!edit ? this.renderContent() : this.renderEditField()}
|
||||
{this.renderFooter(file)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderCommentAuthor() {
|
||||
const {
|
||||
author,
|
||||
level
|
||||
} = this.props;
|
||||
const commentAutorClass = "ticket-event__comment-author-name";
|
||||
let commentAuthor;
|
||||
|
||||
if(level === "3") {
|
||||
commentAuthor = (
|
||||
<a className={commentAutorClass} href={((author.staff) ? VIEW_STAFF_PATH : VIEW_USER_PATH)+author.id}>
|
||||
{author.name}
|
||||
</a>
|
||||
);
|
||||
} else if(level && !author.staff) {
|
||||
commentAuthor = <a className={commentAutorClass} href={VIEW_USER_PATH+author.id}>{author.name}</a>;
|
||||
} else {
|
||||
commentAuthor = <span className={commentAutorClass}>{author.name}</span>;
|
||||
}
|
||||
|
||||
return commentAuthor;
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
const { content, author, userId, userStaff, isLastComment, isTicketClosed } = this.props;
|
||||
const { id, staff } = author;
|
||||
|
||||
return (
|
||||
<div className="ticket-event__comment-content ql-editor">
|
||||
<div dangerouslySetInnerHTML={{__html: content}}></div>
|
||||
{(id == userId && staff == userStaff && isLastComment && !isTicketClosed) ? this.renderEditIcon() : null }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
renderEditIcon() {
|
||||
return (
|
||||
<div className="ticket-event__comment-content__edit" >
|
||||
<Icon name="pencil" onClick={this.props.onToggleEdit} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
renderEditField() {
|
||||
return (
|
||||
<Form loading={this.props.loading} values={{content:this.state.content}} onChange={(form) => {this.setState({content:form.content})}} onSubmit={this.props.onEdit}>
|
||||
<FormField name="content" required field="textarea" validation="TEXT_AREA" fieldProps={{allowImages: this.props.allowAttachments}}/>
|
||||
<div className="ticket-event__submit-edited-comment" >
|
||||
<SubmitButton type="secondary" >{i18n('SUBMIT')}</SubmitButton>
|
||||
<Button size="medium" onClick={this.props.onToggleEdit}>{i18n('CLOSE')}</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
renderAssignment() {
|
||||
let assignedTo = this.props.content;
|
||||
let authorName = this.props.author.name;
|
||||
|
||||
if(!assignedTo || assignedTo == authorName) {
|
||||
assignedTo = i18n('HIMSELF');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ticket-event__circled">
|
||||
<span className="ticket-event__circled-author">{this.props.author.name}</span>
|
||||
<span className="ticket-event__circled-author">{authorName}</span>
|
||||
<span className="ticket-event__circled-text"> {i18n('ACTIVITY_ASSIGN_THIS')}</span>
|
||||
<span className="ticket-event__circled-text"> {assignedTo}</span>
|
||||
<span className="ticket-event__circled-date"> {i18n('DATE_PREFIX')} {DateTransformer.transformToString(this.props.date)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderUnAssignment() {
|
||||
let unAssignedTo = this.props.content;
|
||||
let authorName = this.props.author.name;
|
||||
|
||||
if(!unAssignedTo || unAssignedTo == authorName) {
|
||||
unAssignedTo = i18n('HIMSELF');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ticket-event__circled">
|
||||
<span className="ticket-event__circled-author">{this.props.author.name}</span>
|
||||
<span className="ticket-event__circled-author">{authorName}</span>
|
||||
<span className="ticket-event__circled-text"> {i18n('ACTIVITY_UN_ASSIGN_THIS')}</span>
|
||||
<span className="ticket-event__circled-text"> {unAssignedTo}</span>
|
||||
<span className="ticket-event__circled-date"> {i18n('DATE_PREFIX')} {DateTransformer.transformToString(this.props.date)}</span>
|
||||
</div>
|
||||
)
|
||||
@ -143,19 +234,19 @@ class TicketEvent extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
renderPriorityChange() {
|
||||
renderPrivateBadge() {
|
||||
return (
|
||||
<div className="ticket-event__circled">
|
||||
<span className="ticket-event__circled-author">{this.props.author.name}</span>
|
||||
<span className="ticket-event__circled-text"> {i18n('ACTIVITY_PRIORITY_CHANGED_THIS')}</span>
|
||||
<span className="ticket-event__circled-indication"> {this.props.content}</span>
|
||||
<span className="ticket-event__circled-date"> {i18n('DATE_PREFIX')} {DateTransformer.transformToString(this.props.date)}</span>
|
||||
</div>
|
||||
<span className="ticket-event__comment-badge-container">
|
||||
<Tooltip content={i18n('PRIVATE_RESPONSE_DESCRIPTION')} openOnHover>
|
||||
<span className="ticket-event__comment-badge">{i18n('PRIVATE')}</span>
|
||||
</Tooltip>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
renderFileRow(file) {
|
||||
renderFooter(file) {
|
||||
let node = null;
|
||||
let edited = null;
|
||||
|
||||
if (file) {
|
||||
node = <span> {this.getFileLink(file)} <Icon name="paperclip" /> </span>;
|
||||
@ -163,11 +254,30 @@ class TicketEvent extends React.Component {
|
||||
node = i18n('NO_ATTACHMENT');
|
||||
}
|
||||
|
||||
if (this.props.edited && this.props.type === 'COMMENT') {
|
||||
edited = i18n('COMMENT_EDITED');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ticket-event__file">
|
||||
{node}
|
||||
<div className="ticket-event__items">
|
||||
<div className="ticket-event__edited">
|
||||
{edited}
|
||||
</div>
|
||||
<div className="ticket-event__file">
|
||||
{node}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
renderCustomFieldValue(customField) {
|
||||
return (
|
||||
<span className="ticket-event__comment-badge-container">
|
||||
<span className="ticket-event__comment-badge">
|
||||
{customField.customfield}: <span className="ticket-event__comment-badge-value">{customField.value}</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
getClass() {
|
||||
@ -178,7 +288,6 @@ class TicketEvent extends React.Component {
|
||||
'CLOSE': true,
|
||||
'RE_OPEN': true,
|
||||
'DEPARTMENT_CHANGED': true,
|
||||
'PRIORITY_CHANGED': true
|
||||
};
|
||||
const classes = {
|
||||
'row': true,
|
||||
@ -189,7 +298,7 @@ class TicketEvent extends React.Component {
|
||||
'ticket-event_close': this.props.type === 'CLOSE',
|
||||
'ticket-event_reopen': this.props.type === 'RE_OPEN',
|
||||
'ticket-event_department': this.props.type === 'DEPARTMENT_CHANGED',
|
||||
'ticket-event_priority': this.props.type === 'PRIORITY_CHANGED'
|
||||
'ticket-event_private': this.props.private*1,
|
||||
};
|
||||
|
||||
return classNames(classes);
|
||||
@ -203,7 +312,6 @@ class TicketEvent extends React.Component {
|
||||
'CLOSE': 'lock',
|
||||
'RE_OPEN': 'unlock-alt',
|
||||
'DEPARTMENT_CHANGED': 'exchange',
|
||||
'PRIORITY_CHANGED': 'exclamation'
|
||||
};
|
||||
const iconSize = {
|
||||
'COMMENT': '2x',
|
||||
@ -212,7 +320,6 @@ class TicketEvent extends React.Component {
|
||||
'CLOSE': 'lg',
|
||||
'RE_OPEN': 'lg',
|
||||
'DEPARTMENT_CHANGED': 'lg',
|
||||
'PRIORITY_CHANGED': 'lg'
|
||||
};
|
||||
|
||||
return {
|
||||
@ -230,4 +337,7 @@ class TicketEvent extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default TicketEvent;
|
||||
export default connect((store) => {
|
||||
return { level: store.session.userLevel };
|
||||
})(TicketEvent);
|
||||
|
||||
|
@ -47,7 +47,7 @@
|
||||
|
||||
&__comment {
|
||||
position: relative;
|
||||
word-break: break-all;
|
||||
word-wrap: break-word;
|
||||
|
||||
&-pointer {
|
||||
right: 100%;
|
||||
@ -64,13 +64,19 @@
|
||||
position: relative;
|
||||
padding: 12px;
|
||||
color: $primary-black;
|
||||
}
|
||||
|
||||
&-type {
|
||||
font-size: 10px;
|
||||
padding-left: 10px;
|
||||
color: $secondary-blue;
|
||||
font-variant: small-caps;
|
||||
}
|
||||
&-badge-container {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
&-badge {
|
||||
font-size: 10.6px;
|
||||
font-weight: bold;
|
||||
color: $primary-blue;
|
||||
background-color: $very-light-grey;
|
||||
padding: 6px;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
&-date {
|
||||
@ -78,6 +84,8 @@
|
||||
border: 2px solid $light-grey;
|
||||
border-bottom: none;
|
||||
padding: 12px;
|
||||
font-size: 12.5px;
|
||||
font-family: helvetica;
|
||||
background-color: $light-grey;
|
||||
|
||||
}
|
||||
@ -86,27 +94,62 @@
|
||||
background-color: white;
|
||||
border: 2px solid $very-light-grey;
|
||||
border-top: none;
|
||||
padding: 20px 10px;
|
||||
padding: 28px 10px;
|
||||
text-align: left;
|
||||
position:relative;
|
||||
overflow-y: initial;
|
||||
|
||||
&:hover {
|
||||
.ticket-event__comment-content__edit {
|
||||
color: grey;
|
||||
cursor:pointer;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
max-width:100%;
|
||||
}
|
||||
|
||||
&__edit {
|
||||
position:absolute;
|
||||
top: 3px;
|
||||
right: 9px;
|
||||
align-self: right;
|
||||
color:white;
|
||||
}
|
||||
}
|
||||
}
|
||||
&__submit-edited-comment {
|
||||
display:flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
&__items {
|
||||
background-color: $very-light-grey;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&__file {
|
||||
background-color: $very-light-grey;
|
||||
cursor: pointer;
|
||||
text-align: right;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
&__edited{
|
||||
font-style: italic;
|
||||
}
|
||||
&__comment-badge-value {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
&_staff {
|
||||
.ticket-event__icon {
|
||||
background-color: $primary-blue;
|
||||
}
|
||||
|
||||
.ticket-event__comment-author-type {
|
||||
color: $primary-blue;
|
||||
}
|
||||
}
|
||||
|
||||
&_circled {
|
||||
@ -170,10 +213,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
&_priority {
|
||||
.ticket-event__icon {
|
||||
padding-left: 11px;
|
||||
padding-top: 5px;
|
||||
&_private {
|
||||
.ticket-event__comment-pointer {
|
||||
border-right-color: $light-yellow;
|
||||
}
|
||||
.ticket-event__comment-date {
|
||||
background-color: $light-yellow;
|
||||
border-color: $light-yellow;
|
||||
}
|
||||
.ticket-event__staff-pic {
|
||||
background-color: $light-yellow;
|
||||
border-color: $light-yellow;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
import htmlToText from 'html-to-text';
|
||||
|
||||
import i18n from 'lib-app/i18n';
|
||||
import Icon from 'core-components/icon';
|
||||
@ -19,7 +20,7 @@ class TicketInfo extends React.Component {
|
||||
</span>
|
||||
</div>
|
||||
<div className="ticket-info__description">
|
||||
{this.props.ticket.content}
|
||||
{htmlToText.fromString(this.props.ticket.content)}
|
||||
</div>
|
||||
|
||||
<div className="ticket-info__author">
|
||||
@ -34,14 +35,6 @@ class TicketInfo extends React.Component {
|
||||
{(this.props.ticket.closed) ? 'closed' : 'open'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ticket-info__properties__priority">
|
||||
<span className="ticket-info__properties__label">
|
||||
{i18n('PRIORITY')}:
|
||||
</span>
|
||||
<span className={this.getPriorityClass()}>
|
||||
{this.props.ticket.priority}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ticket-info__properties__owner">
|
||||
<span className="ticket-info__properties__label">
|
||||
{i18n('OWNED')}:
|
||||
@ -79,15 +72,6 @@ class TicketInfo extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
getPriorityClass() {
|
||||
let priorityClasses = {
|
||||
'low': 'ticket-info__properties__badge-green',
|
||||
'medium': 'ticket-info__properties__badge-blue',
|
||||
'high': 'ticket-info__properties__badge-red'
|
||||
};
|
||||
|
||||
return priorityClasses[this.props.ticket.priority];
|
||||
}
|
||||
}
|
||||
|
||||
export default TicketInfo;
|
||||
export default TicketInfo;
|
||||
|
@ -3,6 +3,7 @@
|
||||
.ticket-info {
|
||||
width: 300px;
|
||||
font-weight: normal;
|
||||
text-align: left;
|
||||
|
||||
&__title {
|
||||
color: $primary-black;
|
||||
@ -35,7 +36,6 @@
|
||||
|
||||
&__status,
|
||||
&__owner,
|
||||
&__priority,
|
||||
&__comments {
|
||||
display: inline-block;
|
||||
width: 50%;
|
||||
@ -86,4 +86,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,14 +1,22 @@
|
||||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
import {connect} from 'react-redux';
|
||||
import queryString from 'query-string';
|
||||
|
||||
import i18n from 'lib-app/i18n';
|
||||
import DateTransformer from 'lib-core/date-transformer';
|
||||
|
||||
import TicketInfo from 'app-components/ticket-info';
|
||||
import DepartmentDropdown from 'app-components/department-dropdown';
|
||||
import Table from 'core-components/table';
|
||||
import Button from 'core-components/button';
|
||||
import Tooltip from 'core-components/tooltip';
|
||||
import DropDown from 'core-components/drop-down';
|
||||
import Checkbox from 'core-components/checkbox';
|
||||
import Tag from 'core-components/tag';
|
||||
import Icon from 'core-components/icon';
|
||||
import Message from 'core-components/message';
|
||||
import history from 'lib-app/history';
|
||||
import PageSizeDropdown from './page-size-dropdown';
|
||||
|
||||
class TicketList extends React.Component {
|
||||
static propTypes = {
|
||||
@ -17,10 +25,15 @@ class TicketList extends React.Component {
|
||||
ticketPath: React.PropTypes.string,
|
||||
showDepartmentDropdown: React.PropTypes.bool,
|
||||
tickets: React.PropTypes.arrayOf(React.PropTypes.object),
|
||||
userId: React.PropTypes.number,
|
||||
type: React.PropTypes.oneOf([
|
||||
'primary',
|
||||
'secondary'
|
||||
])
|
||||
]),
|
||||
closedTicketsShown: React.PropTypes.bool,
|
||||
onClosedTicketsShownChange: React.PropTypes.func,
|
||||
onDepartmentChange: React.PropTypes.func,
|
||||
showPageSizeDropdown: React.PropTypes.bool
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@ -29,7 +42,9 @@ class TicketList extends React.Component {
|
||||
tickets: [],
|
||||
departments: [],
|
||||
ticketPath: '/dashboard/ticket/',
|
||||
type: 'primary'
|
||||
type: 'primary',
|
||||
closedTicketsShown: false,
|
||||
showPageSizeDropdown: true
|
||||
};
|
||||
|
||||
state = {
|
||||
@ -37,61 +52,129 @@ class TicketList extends React.Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { type, showDepartmentDropdown, onClosedTicketsShownChange, showPageSizeDropdown } = this.props;
|
||||
const pages = [5, 10, 20, 50];
|
||||
|
||||
return (
|
||||
<div className="ticket-list">
|
||||
{(this.props.type === 'secondary' && this.props.showDepartmentDropdown) ? this.renderDepartmentsDropDown() : null}
|
||||
<div className="ticket-list__filters">
|
||||
<div className="ticket-list__main-filters">
|
||||
{(type === 'primary') ? this.renderMessage() : null}
|
||||
{
|
||||
((type === 'secondary') && showDepartmentDropdown) ?
|
||||
this.renderDepartmentsDropDown() :
|
||||
null
|
||||
}
|
||||
{onClosedTicketsShownChange ? this.renderFilterCheckbox() : null}
|
||||
</div>
|
||||
{
|
||||
showPageSizeDropdown ?
|
||||
<PageSizeDropdown className="ticket-list__page-dropdown" pages={pages} onChange={(event) => this.pageSizeChange(event)} /> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
<Table {...this.getTableProps()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderFilterCheckbox() {
|
||||
return (
|
||||
<Checkbox
|
||||
className="ticket-list__checkbox"
|
||||
label={i18n("SHOW_CLOSED_TICKETS")}
|
||||
value={this.props.closedTicketsShown}
|
||||
onChange={this.props.onClosedTicketsShownChange}
|
||||
wrapInLabel
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderDepartmentsDropDown() {
|
||||
return (
|
||||
<div className="ticket-list__department-selector">
|
||||
<DropDown {...this.getDepartmentDropdownProps()} />
|
||||
<DepartmentDropdown {...this.getDepartmentDropdownProps()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderMessage() {
|
||||
switch (queryString.parse(window.location.search)["message"]) {
|
||||
case 'success':
|
||||
return (
|
||||
<Message
|
||||
onCloseMessage={this.onCloseMessage}
|
||||
className="create-ticket-form__message"
|
||||
type="success">
|
||||
{i18n('TICKET_SENT')}
|
||||
</Message>
|
||||
);
|
||||
case 'fail':
|
||||
return (
|
||||
<Message
|
||||
onCloseMessage={this.onCloseMessage}
|
||||
className="create-ticket-form__message"
|
||||
type="error">
|
||||
{i18n('TICKET_SENT_ERROR')}
|
||||
</Message>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
pageSizeChange(event) {
|
||||
const { onPageSizeChange } = this.props;
|
||||
|
||||
onPageSizeChange && onPageSizeChange(event.pageSize);
|
||||
}
|
||||
|
||||
getDepartmentDropdownProps() {
|
||||
const { departments, onDepartmentChange } = this.props;
|
||||
|
||||
return {
|
||||
items: this.getDepartments(),
|
||||
departments: this.getDepartments(),
|
||||
onChange: (event) => {
|
||||
const departmentId = event.index && departments[event.index - 1].id;
|
||||
|
||||
this.setState({
|
||||
selectedDepartment: event.index && this.props.departments[event.index - 1].id
|
||||
selectedDepartment: departmentId
|
||||
});
|
||||
|
||||
onDepartmentChange && onDepartmentChange(departmentId || null);
|
||||
},
|
||||
size: 'medium'
|
||||
};
|
||||
}
|
||||
|
||||
getTableProps() {
|
||||
const { loading, page, pages, onPageChange } = this.props;
|
||||
|
||||
return {
|
||||
loading: this.props.loading,
|
||||
loading,
|
||||
headers: this.getTableHeaders(),
|
||||
rows: this.getTableRows(),
|
||||
pageSize: 10,
|
||||
comp: this.compareFunction,
|
||||
page: this.props.page,
|
||||
pages: this.props.pages,
|
||||
onPageChange: this.props.onPageChange
|
||||
pageSize: this.state.tickets,
|
||||
page,
|
||||
pages,
|
||||
onPageChange
|
||||
};
|
||||
}
|
||||
|
||||
getDepartments() {
|
||||
let departments = this.props.departments.map((department) => {
|
||||
return {content: department.name};
|
||||
});
|
||||
let departments = _.clone(this.props.departments);
|
||||
|
||||
departments.unshift({
|
||||
content: i18n('ALL_DEPARTMENTS')
|
||||
name: i18n('ALL_DEPARTMENTS')
|
||||
});
|
||||
|
||||
return departments;
|
||||
}
|
||||
|
||||
getTableHeaders() {
|
||||
if (this.props.type == 'primary' ) {
|
||||
const { type } = this.props;
|
||||
|
||||
if(type == 'primary' ) {
|
||||
return [
|
||||
{
|
||||
key: 'number',
|
||||
@ -110,11 +193,14 @@ class TicketList extends React.Component {
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
value: i18n('DATE'),
|
||||
value: <div>
|
||||
{i18n('DATE')}
|
||||
{this.renderSortArrow('date')}
|
||||
</div>,
|
||||
className: 'ticket-list__date col-md-2'
|
||||
}
|
||||
];
|
||||
} else if (this.props.type == 'secondary') {
|
||||
} else if(type == 'secondary') {
|
||||
return [
|
||||
{
|
||||
key: 'number',
|
||||
@ -126,11 +212,6 @@ class TicketList extends React.Component {
|
||||
value: i18n('TITLE'),
|
||||
className: 'ticket-list__title col-md-4'
|
||||
},
|
||||
{
|
||||
key: 'priority',
|
||||
value: i18n('PRIORITY'),
|
||||
className: 'ticket-list__priority col-md-1'
|
||||
},
|
||||
{
|
||||
key: 'department',
|
||||
value: i18n('DEPARTMENT'),
|
||||
@ -143,99 +224,114 @@ class TicketList extends React.Component {
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
value: i18n('DATE'),
|
||||
value: <div>
|
||||
{i18n('DATE')}
|
||||
{this.renderSortArrow('date')}
|
||||
</div>,
|
||||
className: 'ticket-list__date col-md-2'
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
renderSortArrow(header) {
|
||||
const { orderBy, showOrderArrows, onChangeOrderBy } = this.props;
|
||||
|
||||
return (
|
||||
showOrderArrows ?
|
||||
<Icon
|
||||
name={`arrow-${this.getIconName(header, orderBy)}`}
|
||||
className="ticket-list__order-icon"
|
||||
color={this.getIconColor(header, orderBy)}
|
||||
onClick={() => onChangeOrderBy(header)} /> :
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
getIconName(header, orderBy) {
|
||||
return (orderBy && orderBy.value === header && orderBy.asc) ? "up" : "down";
|
||||
}
|
||||
|
||||
getIconColor(header, orderBy) {
|
||||
return (orderBy && orderBy.value === header) ? "gray" : "white";
|
||||
}
|
||||
|
||||
getTableRows() {
|
||||
return this.getTickets().map(this.gerTicketTableObject.bind(this));
|
||||
return this.getTickets().map(this.getTicketTableObject.bind(this));
|
||||
}
|
||||
|
||||
getTickets() {
|
||||
return (this.state.selectedDepartment) ? _.filter(this.props.tickets, (ticket) => {
|
||||
return ticket.department.id == this.state.selectedDepartment
|
||||
}) : this.props.tickets;
|
||||
const { tickets } = this.props;
|
||||
const { selectedDepartment } = this.state;
|
||||
|
||||
return (
|
||||
(selectedDepartment) ?
|
||||
_.filter(tickets, (ticket) => { return ticket.department.id == selectedDepartment}) :
|
||||
tickets
|
||||
);
|
||||
}
|
||||
|
||||
gerTicketTableObject(ticket) {
|
||||
let titleText = (this.isTicketUnread(ticket)) ? ticket.title + ' (1)' : ticket.title;
|
||||
getTicketTableObject(ticket) {
|
||||
const { date, title, ticketNumber, closed, tags, department, author } = ticket;
|
||||
const dateTodayWithOutHoursAndMinutes = DateTransformer.getDateToday();
|
||||
const ticketDateWithOutHoursAndMinutes = Math.floor(DateTransformer.UTCDateToLocalNumericDate(JSON.stringify(date*1)) / 10000);
|
||||
const stringTicketLocalDateFormat = DateTransformer.transformToString(date, false, true);
|
||||
const ticketDate = (
|
||||
((dateTodayWithOutHoursAndMinutes - ticketDateWithOutHoursAndMinutes) > 1) ?
|
||||
stringTicketLocalDateFormat :
|
||||
`${(dateTodayWithOutHoursAndMinutes - ticketDateWithOutHoursAndMinutes) ? i18n("YESTERDAY_AT") : i18n("TODAY_AT")} ${stringTicketLocalDateFormat.slice(-5)}`
|
||||
);
|
||||
let titleText = (this.isTicketUnread(ticket)) ? title + ' (1)' : title;
|
||||
|
||||
return {
|
||||
number: (
|
||||
<Tooltip content={<TicketInfo ticket={ticket}/>} openOnHover>
|
||||
{'#' + ticket.ticketNumber}
|
||||
<Tooltip content={<TicketInfo ticket={ticket} />} openOnHover>
|
||||
{'#' + ticketNumber}
|
||||
</Tooltip>
|
||||
),
|
||||
title: (
|
||||
<Button className="ticket-list__title-link" type="clean" route={{to: this.props.ticketPath + ticket.ticketNumber}}>
|
||||
{titleText}
|
||||
</Button>
|
||||
<div>
|
||||
{closed ? <Icon size="sm" name="lock" /> : null}
|
||||
<Button className="ticket-list__title-link" type="clean" route={{to: this.props.ticketPath + ticketNumber}}>
|
||||
{titleText}
|
||||
</Button>
|
||||
{(tags || []).map((tagName,index) => {
|
||||
let tag = _.find(this.props.tags, {name:tagName});
|
||||
return <Tag size='small' name={tag && tag.name} color={tag && tag.color} key={index} />
|
||||
})}
|
||||
</div>
|
||||
|
||||
),
|
||||
priority: this.getTicketPriority(ticket.priority),
|
||||
department: ticket.department.name,
|
||||
author: ticket.author.name,
|
||||
date: DateTransformer.transformToString(ticket.date, false),
|
||||
department: department.name,
|
||||
author: author.name,
|
||||
date: ticketDate,
|
||||
unread: this.isTicketUnread(ticket),
|
||||
highlighted: this.isTicketUnread(ticket)
|
||||
};
|
||||
}
|
||||
|
||||
getTicketPriority(priority) {
|
||||
if(priority == 'high'){
|
||||
return (
|
||||
<span className="ticket-list__priority-high">{i18n('HIGH')}</span>
|
||||
);
|
||||
}
|
||||
if(priority == 'medium'){
|
||||
return (
|
||||
<span className="ticket-list__priority-medium">{i18n('MEDIUM')}</span>
|
||||
);
|
||||
}
|
||||
if(priority == 'low'){
|
||||
return (
|
||||
<span className="ticket-list__priority-low">{i18n('LOW')}</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
compareFunction(row1, row2) {
|
||||
if (row1.closed == row2.closed) {
|
||||
if (row1.unread == row2.unread) {
|
||||
let s1 = row1.date;
|
||||
let s2 = row2.date;
|
||||
|
||||
let y1 = s1.substring(0, 4);
|
||||
let y2 = s2.substring(0, 4);
|
||||
|
||||
if (y1 == y2) {
|
||||
let m1 = s1.substring(4, 6);
|
||||
let m2 = s2.substring(4, 6);
|
||||
|
||||
if (m1 == m2) {
|
||||
let d1 = s1.substring(6, 8);
|
||||
let d2 = s2.substring(6, 8);
|
||||
|
||||
if (d1 == d2) {
|
||||
return 0;
|
||||
}
|
||||
return d1 > d2 ? -1 : 1;
|
||||
}
|
||||
return m1 > m2 ? -1 : 1;
|
||||
}
|
||||
return y1 > y2 ? -1 : 1;
|
||||
}
|
||||
return row1.unread ? -1 : 1;
|
||||
}
|
||||
return row1.closed ? -1 : 1;
|
||||
}
|
||||
|
||||
isTicketUnread(ticket) {
|
||||
return (this.props.type === 'primary' && ticket.unread) || (this.props.type === 'secondary' && ticket.unreadStaff);
|
||||
const { type, userId } = this.props;
|
||||
const { unread, author, unreadStaff } = ticket;
|
||||
|
||||
if(type === 'primary') {
|
||||
return unread;
|
||||
} else if(type === 'secondary') {
|
||||
if(author.id == userId && author.staff) {
|
||||
return unread;
|
||||
} else {
|
||||
return unreadStaff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onCloseMessage() {
|
||||
history.push(window.location.pathname);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default TicketList;
|
||||
export default connect((store) => {
|
||||
return {
|
||||
tags: store.config['tags']
|
||||
};
|
||||
})(TicketList);
|
||||
|
@ -1,9 +1,39 @@
|
||||
@import "../scss/vars";
|
||||
|
||||
.ticket-list {
|
||||
&__order-icon {
|
||||
padding-left: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__filters {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 25px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&__main-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__department-selector {
|
||||
margin-bottom: 25px;
|
||||
display: inline-block;
|
||||
margin-right: 25px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__page-dropdown {
|
||||
display: inline-block;
|
||||
margin-right: 25px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__checkbox {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&__number {
|
||||
@ -32,24 +62,8 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&__priority-low,
|
||||
&__priority-medium,
|
||||
&__priority-high {
|
||||
display: inline-block;
|
||||
border-radius: 10px;
|
||||
width: 70px;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
&__priority-low {
|
||||
background-color: $primary-green;
|
||||
}
|
||||
|
||||
&__priority-medium {
|
||||
background-color: $secondary-blue;
|
||||
}
|
||||
|
||||
&__priority-high {
|
||||
background-color: $primary-red;
|
||||
}
|
||||
.create-ticket-form__message {
|
||||
width: 100%;
|
||||
}
|
350
client/src/app-components/ticket-query-filters.js
Normal file
350
client/src/app-components/ticket-query-filters.js
Normal file
@ -0,0 +1,350 @@
|
||||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import SearchFiltersActions from 'actions/search-filters-actions';
|
||||
|
||||
import i18n from 'lib-app/i18n';
|
||||
import API from 'lib-app/api-call';
|
||||
import history from 'lib-app/history';
|
||||
import searchTicketsUtils from 'lib-app/search-tickets-utils';
|
||||
import ticketUtils from 'lib-app/ticket-utils';
|
||||
|
||||
import Form from 'core-components/form';
|
||||
import SubmitButton from 'core-components/submit-button';
|
||||
import FormField from 'core-components/form-field';
|
||||
import Icon from 'core-components/icon';
|
||||
import Button from 'core-components/button';
|
||||
import Loading from 'core-components/loading';
|
||||
|
||||
|
||||
class TicketQueryFilters extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
filters: React.PropTypes.shape({
|
||||
query: React.PropTypes.string,
|
||||
departments: React.PropTypes.string,
|
||||
owners: React.PropTypes.string,
|
||||
tags: React.PropTypes.string,
|
||||
dateRange: React.PropTypes.string,
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
formState,
|
||||
filters,
|
||||
showFilters,
|
||||
ticketQueryListState,
|
||||
staffList
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className={"ticket-query-filters" + (showFilters ? "__open" : "") }>
|
||||
<Form
|
||||
loading={ticketQueryListState.loading}
|
||||
values={this.getFormValue(formState)}
|
||||
onChange={this.onChangeForm.bind(this)}
|
||||
onSubmit={this.onSubmitListConfig.bind(this)}>
|
||||
<div className="ticket-query-filters__search-box">
|
||||
<FormField name="query" field="search-box" fieldProps={{onSearch: this.onSubmitListConfig.bind(this)}} />
|
||||
</div>
|
||||
<div className="ticket-query-filters__second-row">
|
||||
<FormField
|
||||
label={i18n('PERIOD')}
|
||||
name="period"
|
||||
field="select"
|
||||
fieldProps={{
|
||||
items: [{content: i18n('LAST_7_DAYS')}, {content: i18n('LAST_30_DAYS')}, {content: i18n('LAST_90_DAYS')}, {content: i18n('LAST_365_DAYS')}],
|
||||
className: 'ticket-query-filters__drop-down'
|
||||
}} />
|
||||
<FormField
|
||||
label={i18n('DEPARTMENTS')}
|
||||
name="departments"
|
||||
field="autocomplete"
|
||||
fieldProps={{items: this.getDepartmentsItems()}} />
|
||||
<FormField
|
||||
label={i18n('OWNER')}
|
||||
name="owners"
|
||||
field="autocomplete"
|
||||
fieldProps={{items: ticketUtils.getStaffList({staffList}, 'toAutocomplete')}} />
|
||||
</div>
|
||||
<div className="ticket-query-filters__third-row">
|
||||
<FormField
|
||||
label={i18n('STATUS')}
|
||||
name="closed"
|
||||
field="select"
|
||||
fieldProps={{
|
||||
items: this.getStatusItems(),
|
||||
className: 'ticket-query-filters__drop-down'
|
||||
}} />
|
||||
<FormField
|
||||
label={i18n('TAGS')}
|
||||
name="tags"
|
||||
field="tag-selector"
|
||||
fieldProps={{
|
||||
items: this.getTags(filters.tags),
|
||||
onRemoveClick: this.removeTag.bind(this),
|
||||
onTagSelected: this.addTag.bind(this)
|
||||
}} />
|
||||
<FormField
|
||||
label={i18n('AUTHORS')}
|
||||
name="authors"
|
||||
field="autocomplete"
|
||||
fieldProps={{
|
||||
getItemListFromQuery: this.searchAuthors.bind(this),
|
||||
comparerFunction: this.autorsComparer.bind(this)
|
||||
}} />
|
||||
</div>
|
||||
<div className="ticket-query-filters__container">
|
||||
<Button
|
||||
className="ticket-query-filters__container__button ticket-query-filters__container__clear-button"
|
||||
size= "medium"
|
||||
disabled={ticketQueryListState.loading}
|
||||
onClick={this.clearFormValues.bind(this)}>
|
||||
{ticketQueryListState.loading ? <Loading /> : i18n('CLEAR')}
|
||||
</Button>
|
||||
<SubmitButton
|
||||
className="ticket-query-filters__container__button ticket-query-filters__container__search-button"
|
||||
type="secondary"
|
||||
size= "medium">
|
||||
{i18n('SEARCH')}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
<span className="separator" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
searchAuthors(query, blacklist = []) {
|
||||
blacklist = blacklist.map(item => {return {isStaff: item.isStaff, id: item.id}});
|
||||
|
||||
return API.call({
|
||||
path: '/ticket/search-authors',
|
||||
data: {
|
||||
query: query,
|
||||
blackList: JSON.stringify(blacklist)
|
||||
}
|
||||
}).then(r => {
|
||||
return r.data.authors.map(author => {
|
||||
return {
|
||||
name: author.name,
|
||||
color: "gray",
|
||||
id: author.id*1,
|
||||
profilePic: author.profilePic,
|
||||
isStaff: author.isStaff * 1,
|
||||
content: author.profilePic !== undefined ? ticketUtils.renderStaffOption(author) : author.name,
|
||||
contentOnSelected: author.profilePic !== undefined ? ticketUtils.renderStaffSelected(author) : author.name
|
||||
}});
|
||||
});
|
||||
}
|
||||
|
||||
renderDepartmentOption(department) {
|
||||
return (
|
||||
<div className="ticket-query-filters__department-option" key={`department-option-${department.id}`}>
|
||||
{department.private*1 ?
|
||||
<Icon className="ticket-query-filters__department-option__icon" name='user-secret'/> :
|
||||
null}
|
||||
<span className="ticket-query-filters__department-option__name">{department.name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderDeparmentSelected(department) {
|
||||
return (
|
||||
<div className="ticket-query-filters__department-selected" key={`department-selected-${department.id}`}>
|
||||
{department.private*1 ?
|
||||
<Icon className="ticket-query-filters__department-selected__icon" name='user-secret'/> :
|
||||
null}
|
||||
<span className="ticket-query-filters__department-selected__name">{department.name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
addTag(tag) {
|
||||
const { formState } = this.props;
|
||||
this.onChangeFormState({...formState, tags: [...formState.tags, tag]});
|
||||
}
|
||||
|
||||
autorsComparer(autorList, autorSelectedList) {
|
||||
return autorList.filter(item => !_.some(autorSelectedList, {id: item.id, isStaff: item.isStaff}));
|
||||
}
|
||||
|
||||
clearFormValues(event) {
|
||||
event.preventDefault();
|
||||
this.props.dispatch(SearchFiltersActions.setDefaultFormValues());
|
||||
}
|
||||
|
||||
getDepartmentsItems() {
|
||||
const { departments, } = this.props;
|
||||
let departmentsList = departments.map(department => {
|
||||
return {
|
||||
id: JSON.parse(department.id),
|
||||
name: department.name.toLowerCase(),
|
||||
color: 'gray',
|
||||
contentOnSelected: this.renderDeparmentSelected(department),
|
||||
content: this.renderDepartmentOption(department),
|
||||
}
|
||||
});
|
||||
|
||||
return departmentsList;
|
||||
}
|
||||
|
||||
getSelectedDepartments(selectedDepartmentsId) {
|
||||
let selectedDepartments = [];
|
||||
|
||||
if(selectedDepartmentsId !== undefined) {
|
||||
selectedDepartments = selectedDepartmentsId.map(
|
||||
(departmentId) => this.getDepartmentsItems().find(_department => (_department.id === departmentId))
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
return selectedDepartments;
|
||||
}
|
||||
|
||||
getSelectedStaffs(selectedStaffsId) {
|
||||
const { staffList } = this.props;
|
||||
let selectedStaffs = [];
|
||||
|
||||
if(selectedStaffsId !== undefined) {
|
||||
selectedStaffs = selectedStaffsId.map(
|
||||
(staffId) => ticketUtils.getStaffList({staffList}, 'toAutocomplete').find(_staff => (_staff.id === staffId))
|
||||
);
|
||||
}
|
||||
|
||||
return selectedStaffs;
|
||||
}
|
||||
|
||||
getSelectedTagsName(selectedTagsId) {
|
||||
let selectedTagsName = [];
|
||||
|
||||
if(selectedTagsId !== undefined) {
|
||||
selectedTagsName = selectedTagsId.map(
|
||||
(tagId) => (this.getTags().find(_tag => (_tag.id === tagId)) || {}).name
|
||||
);
|
||||
}
|
||||
|
||||
return selectedTagsName;
|
||||
}
|
||||
|
||||
getStatusItems() {
|
||||
let items = [
|
||||
{id: 0, name: 'Any', content: i18n('ANY')},
|
||||
{id: 1, name: 'Opened', content: i18n('OPENED')},
|
||||
{id: 2, name: 'Closed', content: i18n('CLOSED')},
|
||||
];
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
getTags() {
|
||||
const { tags, } = this.props;
|
||||
let newTagList = tags.map(tag => {
|
||||
return {
|
||||
id: JSON.parse(tag.id),
|
||||
name: tag.name,
|
||||
color : tag.color
|
||||
}
|
||||
});
|
||||
|
||||
return newTagList;
|
||||
}
|
||||
|
||||
onChangeFormState(formValues) {
|
||||
this.props.dispatch(SearchFiltersActions.changeForm(formValues));
|
||||
}
|
||||
|
||||
onSubmitListConfig() {
|
||||
const {
|
||||
formState,
|
||||
filters,
|
||||
formEdited,
|
||||
} = this.props;
|
||||
const listConfigWithCompleteAuthorsList = searchTicketsUtils.formValueToListConfig(
|
||||
{...formState, orderBy: filters.orderBy, page: 1},
|
||||
true
|
||||
);
|
||||
|
||||
if(formEdited) {
|
||||
const filtersForAPI = searchTicketsUtils.getFiltersForAPI(listConfigWithCompleteAuthorsList.filters);
|
||||
const currentPath = window.location.pathname;
|
||||
const urlQuery = searchTicketsUtils.getFiltersForURL({
|
||||
filters: filtersForAPI,
|
||||
shouldRemoveCustomParam: true,
|
||||
shouldRemoveUseInitialValuesParam: true
|
||||
});
|
||||
urlQuery && history.push(`${currentPath}${urlQuery}`);
|
||||
}
|
||||
}
|
||||
|
||||
removeTag(tag) {
|
||||
const { formState } = this.props;
|
||||
|
||||
this.onChangeFormState({...formState, tags: formState.tags.filter(item => item !== tag)});
|
||||
}
|
||||
|
||||
tagsNametoTagsId(selectedTagsName) {
|
||||
let selectedTagsId = [];
|
||||
|
||||
if (selectedTagsName != undefined) {
|
||||
selectedTagsId = selectedTagsName.map(
|
||||
(tagName) => (this.getTags().find(_tag => (_tag.name === tagName)) || {}).id
|
||||
);
|
||||
}
|
||||
|
||||
return selectedTagsId;
|
||||
}
|
||||
|
||||
onChangeForm(data) {
|
||||
const departmentsId = data.departments.map(department => department.id);
|
||||
const staffsId = data.owners.map(staff => staff.id);
|
||||
const tagsName = this.tagsNametoTagsId(data.tags);
|
||||
const authors = data.authors.map(({name, id, isStaff, profilePic, color}) => ({name, id: id*1, isStaff, profilePic, color}));
|
||||
|
||||
this.onChangeFormState({
|
||||
...data,
|
||||
tags: tagsName,
|
||||
owners: staffsId,
|
||||
departments: departmentsId,
|
||||
authors: authors,
|
||||
});
|
||||
}
|
||||
|
||||
getFormValue(form) {
|
||||
return {
|
||||
...form,
|
||||
departments: this.getSelectedDepartments(form.departments),
|
||||
owners: this.getSelectedStaffs(form.owners),
|
||||
tags: this.getSelectedTagsName(form.tags),
|
||||
authors: this.getAuthors(form.authors),
|
||||
}
|
||||
}
|
||||
|
||||
getAuthors(authors = []) {
|
||||
return authors.map(author => ({
|
||||
name: author.name,
|
||||
color: "gray",
|
||||
id: author.id*1,
|
||||
isStaff: author.isStaff*1,
|
||||
profilePic: author.profilePic,
|
||||
content: author.profilePic !== undefined ? ticketUtils.renderStaffOption(author) : author.name,
|
||||
contentOnSelected: author.profilePic !== undefined ? ticketUtils.renderStaffSelected(author) : author.name
|
||||
}));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect((store) => {
|
||||
return {
|
||||
tags: store.config.tags,
|
||||
departments: store.config.departments,
|
||||
staffList: store.adminData.staffMembers,
|
||||
formState: store.searchFilters.form,
|
||||
filters: store.searchFilters.listConfig.filters,
|
||||
showFilters: store.searchFilters.showFilters,
|
||||
formEdited: store.searchFilters.formEdited,
|
||||
ticketQueryListState: store.searchFilters.ticketQueryListState,
|
||||
};
|
||||
})(TicketQueryFilters);
|
118
client/src/app-components/ticket-query-filters.scss
Normal file
118
client/src/app-components/ticket-query-filters.scss
Normal file
@ -0,0 +1,118 @@
|
||||
@import '../scss/vars';
|
||||
|
||||
.ticket-query-filters {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
overflow-y: hidden;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&__open {
|
||||
max-height: 1000px;
|
||||
opacity: 1;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
&__container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
|
||||
&__button {
|
||||
margin: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&__department-option {
|
||||
&__name {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&__department-selected {
|
||||
display: inline-block;
|
||||
border-radius: 3px;
|
||||
margin-left: 5px;
|
||||
padding: 1px;
|
||||
font-size: 13px;
|
||||
cursor: default;
|
||||
|
||||
&__icon {
|
||||
padding-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
&__first-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 10%;
|
||||
}
|
||||
|
||||
&__second-row,
|
||||
&__third-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&__drop-down > .drop-down__current-item {
|
||||
background-color: $very-light-grey;
|
||||
|
||||
&:focus {
|
||||
background-color: $medium-grey;
|
||||
}
|
||||
}
|
||||
|
||||
&__search-box {
|
||||
width: 100%;
|
||||
padding: 0 50px;
|
||||
}
|
||||
|
||||
&__staff-option {
|
||||
&__profile-pic {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&__name {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&__staff-selected {
|
||||
display: inline-block;
|
||||
border-radius: 3px;
|
||||
margin-left: 5px;
|
||||
padding: 1px;
|
||||
font-size: 13px;
|
||||
cursor: default;
|
||||
|
||||
&__profile-pic {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
border-radius: 50%;
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 992px) {
|
||||
.ticket-query-filters {
|
||||
&__first-row,
|
||||
&__second-row,
|
||||
&__third-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: unset;
|
||||
}
|
||||
}
|
||||
}
|
82
client/src/app-components/ticket-query-list.js
Normal file
82
client/src/app-components/ticket-query-list.js
Normal file
@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
|
||||
import i18n from 'lib-app/i18n';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import TicketList from 'app-components/ticket-list';
|
||||
import Message from 'core-components/message';
|
||||
import searchFiltersActions from '../actions/search-filters-actions';
|
||||
import queryString from 'query-string';
|
||||
import searchTicketsUtils from 'lib-app/search-tickets-utils';
|
||||
import history from 'lib-app/history';
|
||||
|
||||
class TicketQueryList extends React.Component {
|
||||
|
||||
state = {
|
||||
tickets: [],
|
||||
page: 1,
|
||||
pages: 0,
|
||||
error: null,
|
||||
loading: true
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
this.state.error ?
|
||||
<Message showCloseButton={false} type="error">{i18n('ERROR_RETRIEVING_TICKETS')}</Message> :
|
||||
<TicketList {...this.getTicketListProps()} />
|
||||
);
|
||||
}
|
||||
|
||||
onPageChange(event) {
|
||||
const {
|
||||
dispatch,
|
||||
filters
|
||||
} = this.props;
|
||||
|
||||
dispatch(searchFiltersActions.changePage({
|
||||
...filters,
|
||||
page: event.target.value
|
||||
}));
|
||||
}
|
||||
|
||||
getTicketListProps () {
|
||||
const {
|
||||
filters,
|
||||
onChangeOrderBy,
|
||||
userId,
|
||||
ticketQueryListState
|
||||
} = this.props;
|
||||
const page = {
|
||||
...ticketQueryListState,
|
||||
...queryString.parse(window.location.search)
|
||||
}.page*1;
|
||||
|
||||
return {
|
||||
userId: userId,
|
||||
ticketPath: '/admin/panel/tickets/view-ticket/',
|
||||
tickets: ticketQueryListState.tickets,
|
||||
page: page,
|
||||
pages: ticketQueryListState.pages,
|
||||
loading: ticketQueryListState.loading,
|
||||
type: 'secondary',
|
||||
showDepartmentDropdown: false,
|
||||
closedTicketsShown: false,
|
||||
onPageChange:this.onPageChange.bind(this),
|
||||
orderBy: filters.orderBy ? JSON.parse(filters.orderBy) : filters.orderBy,
|
||||
showOrderArrows: true,
|
||||
onChangeOrderBy: onChangeOrderBy,
|
||||
showPageSizeDropdown: false
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect((store) => {
|
||||
return {
|
||||
userId: store.session.userId*1,
|
||||
filters: store.searchFilters.listConfig.filters,
|
||||
ticketQueryListState: store.searchFilters.ticketQueryListState
|
||||
};
|
||||
})(TicketQueryList);
|
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,6 @@
|
||||
@import "../scss/vars";
|
||||
|
||||
.ticket-viewer {
|
||||
|
||||
&__header {
|
||||
background-color: $primary-blue;
|
||||
border-top-right-radius: 4px;
|
||||
@ -9,31 +8,162 @@
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
padding: 6px 0;
|
||||
display: flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
|
||||
&:hover {
|
||||
.ticket-viewer__edit-title-icon {
|
||||
color: $grey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__buttons-column {
|
||||
padding-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&__buttons-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
width: 250px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&__edited-title-text {
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
&__edit-icon {
|
||||
right: 12px;
|
||||
margin: 0 10px;
|
||||
color: $light-grey;
|
||||
|
||||
&:hover {
|
||||
cursor:pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&__edit-title-icon {
|
||||
color: $primary-blue;
|
||||
right: 12px;
|
||||
margin: 0 10px;
|
||||
|
||||
&:hover {
|
||||
cursor:pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&__edit-status__buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
&___input-edit-title {
|
||||
color: black;
|
||||
align-items:center;
|
||||
justify-content: center;
|
||||
margin-bottom: 6px;
|
||||
margin-right: 6px;
|
||||
|
||||
.input__text {
|
||||
height: 30px;
|
||||
text-align: center;
|
||||
padding-top: 12px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
&__edit-title__buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
&__edit-title__button {
|
||||
width: 50px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__number {
|
||||
color: white;
|
||||
margin-right: 10px;
|
||||
margin-right: 30px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: inline-block;
|
||||
max-width: 375px;
|
||||
}
|
||||
|
||||
&__flag {
|
||||
margin-left: 10px;
|
||||
margin-left: 30px;
|
||||
}
|
||||
|
||||
&__info-row-header {
|
||||
&__info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
background-color: $light-grey;
|
||||
font-weight: bold;
|
||||
}
|
||||
padding: 10px 15px 30px 15px;
|
||||
|
||||
&__info-row-values {
|
||||
background-color: $light-grey;
|
||||
color: $secondary-blue;
|
||||
padding-bottom: 10px;
|
||||
&-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
min-width: 150px;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
&-container-editable {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
min-width: 300px;
|
||||
|
||||
&:hover {
|
||||
.ticket-viewer__edit-icon {
|
||||
color: $primary-blue;
|
||||
}
|
||||
}
|
||||
|
||||
&__column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
min-width: 170px;
|
||||
}
|
||||
}
|
||||
|
||||
&-header {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&-value {
|
||||
color: $secondary-blue;
|
||||
padding-bottom: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__editable-dropdown {
|
||||
@ -52,7 +182,15 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__reopen-close-buttons {
|
||||
width: 230px;
|
||||
display: flex;
|
||||
align-content: left;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__response {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
position: relative;
|
||||
|
||||
@ -71,10 +209,74 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&-custom {
|
||||
&-actions {
|
||||
background-color: $very-light-grey;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&-custom {
|
||||
padding: 20px 0 0 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&-private {
|
||||
padding: 20px 20px 0 0;
|
||||
|
||||
&-info {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
&-container {
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
&-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
width: 50%;
|
||||
min-width: 50%;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1151px) {
|
||||
.ticket-viewer__info {
|
||||
&-container {
|
||||
width: 200px;
|
||||
min-width: unset;
|
||||
max-width: unset;
|
||||
}
|
||||
|
||||
&-container-editable {
|
||||
min-width: 250px;
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 992px) {
|
||||
.ticket-viewer__info {
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 571px) {
|
||||
.ticket-viewer {
|
||||
&__number, &__edit-title-icon {
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
&__flag {
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import FormField from 'core-components/form-field';
|
||||
import SubmitButton from 'core-components/submit-button';
|
||||
import IconSelector from 'core-components/icon-selector';
|
||||
import ColorSelector from 'core-components/color-selector';
|
||||
import InfoTooltip from 'core-components/info-tooltip';
|
||||
|
||||
class TopicEditModal extends React.Component {
|
||||
|
||||
@ -24,44 +25,57 @@ class TopicEditModal extends React.Component {
|
||||
};
|
||||
|
||||
state = {
|
||||
values: this.props.defaultValues || {title: '', icon: 'address-card', color: '#ff6900'}
|
||||
values: this.props.defaultValues || {title: '', icon: 'address-card', color: '#ff6900', private: false},
|
||||
loading: false
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="topic-edit-modal">
|
||||
<Header title={i18n((this.props.addForm) ? 'ADD_TOPIC' : 'EDIT_TOPIC')} description={i18n((this.props.addForm) ? 'ADD_TOPIC_DESCRIPTION' : 'EDIT_TOPIC_DESCRIPTION')} />
|
||||
<Form values={this.state.values} onChange={this.onFormChange.bind(this)} onSubmit={this.onSubmit.bind(this)}>
|
||||
<Form values={this.state.values} onChange={this.onFormChange.bind(this)} onSubmit={this.onSubmit.bind(this)} loading={this.state.loading}>
|
||||
<FormField name="title" label={i18n('TITLE')} fieldProps={{size: 'large'}} validation="TITLE" required />
|
||||
<FormField name="icon" className="topic-edit-modal__icon" label={i18n('ICON')} decorator={IconSelector} />
|
||||
<FormField name="color" className="topic-edit-modal__color" label={i18n('COLOR')} decorator={ColorSelector} />
|
||||
|
||||
<SubmitButton className="topic-edit-modal__save-button" type="secondary" size="small">
|
||||
{i18n('SAVE')}
|
||||
</SubmitButton>
|
||||
<Button className="topic-edit-modal__discard-button" onClick={this.onDiscardClick.bind(this)} size="small">
|
||||
{i18n('CANCEL')}
|
||||
</Button>
|
||||
<FormField className="topic-edit-modal__private" label={i18n('PRIVATE')} name="private" field="checkbox" />
|
||||
<InfoTooltip className="topic-edit-modal__private" text={i18n('PRIVATE_TOPIC_DESCRIPTION')} />
|
||||
<div className="topic-edit-modal__buttons-container">
|
||||
<Button className="topic-edit-modal__discard-button" onClick={this.onDiscardClick.bind(this)} size="small">
|
||||
{i18n('CANCEL')}
|
||||
</Button>
|
||||
<SubmitButton className="topic-edit-modal__save-button" type="secondary" size="small">
|
||||
{i18n('SAVE')}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
this.setState({
|
||||
loading: true
|
||||
});
|
||||
|
||||
API.call({
|
||||
path: (this.props.addForm) ? '/article/add-topic' : '/article/edit-topic',
|
||||
data: {
|
||||
topicId: this.props.topicId,
|
||||
name: this.state.values['title'],
|
||||
icon: this.state.values['icon'],
|
||||
iconColor: this.state.values['color']
|
||||
iconColor: this.state.values['color'],
|
||||
private: this.state.values['private']*1
|
||||
}
|
||||
}).then(() => {
|
||||
this.context.closeModal();
|
||||
|
||||
|
||||
if(this.props.onChange) {
|
||||
this.props.onChange();
|
||||
}
|
||||
}).catch(() => {
|
||||
this.setState({
|
||||
loading: false
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -77,4 +91,4 @@ class TopicEditModal extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default TopicEditModal;
|
||||
export default TopicEditModal;
|
||||
|
@ -13,7 +13,21 @@
|
||||
|
||||
}
|
||||
|
||||
&__discard-button {
|
||||
float: right;
|
||||
&__buttons-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 0 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__discard-button {
|
||||
float: left;
|
||||
}
|
||||
|
||||
&__private {
|
||||
display: inline-block;
|
||||
padding-right: 10px;
|
||||
}
|
||||
}
|
||||
|
@ -22,7 +22,8 @@ class TopicViewer extends React.Component {
|
||||
iconColor: React.PropTypes.string.isRequired,
|
||||
articles: React.PropTypes.array.isRequired,
|
||||
articlePath: React.PropTypes.string,
|
||||
editable: React.PropTypes.bool
|
||||
editable: React.PropTypes.bool,
|
||||
private: React.PropTypes.bool
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@ -53,6 +54,8 @@ class TopicViewer extends React.Component {
|
||||
<span className="topic-viewer__title">{this.props.name}</span>
|
||||
{(this.props.editable) ? this.renderEditButton() : null}
|
||||
{(this.props.editable) ? this.renderDeleteButton() : null}
|
||||
{this.props.private*1 ? <Icon className="topic-viewer__private" name='user-secret' color='grey'/> : null}
|
||||
|
||||
</div>
|
||||
<ul className="topic-viewer__list">
|
||||
{this.state.articles.map(this.renderArticleItem.bind(this))}
|
||||
@ -121,13 +124,16 @@ class TopicViewer extends React.Component {
|
||||
}
|
||||
|
||||
renderEditModal() {
|
||||
let props = {
|
||||
topicId: this.props.id,
|
||||
onChange: this.props.onChange,
|
||||
const {id, onChange, name, icon, iconColor} = this.props;
|
||||
|
||||
const props = {
|
||||
topicId: id,
|
||||
onChange,
|
||||
defaultValues: {
|
||||
title: this.props.name,
|
||||
icon: this.props.icon,
|
||||
iconColor: this.props.iconColor
|
||||
title: name,
|
||||
icon,
|
||||
color: iconColor,
|
||||
private: this.props.private * 1
|
||||
}
|
||||
};
|
||||
|
||||
@ -148,7 +154,7 @@ class TopicViewer extends React.Component {
|
||||
<ArticleAddModal {...props}/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
getArticleLinkProps(article) {
|
||||
let classes = {
|
||||
'topic-viewer__list-item-button': true,
|
||||
@ -162,7 +168,7 @@ class TopicViewer extends React.Component {
|
||||
}
|
||||
|
||||
onDeleteClick() {
|
||||
API.call({
|
||||
return API.call({
|
||||
path: '/article/delete-topic',
|
||||
data: {
|
||||
topicId: this.props.id
|
||||
@ -265,4 +271,4 @@ class TopicViewer extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default TopicViewer;
|
||||
export default TopicViewer;
|
||||
|
@ -25,6 +25,10 @@
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
&__private {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
&__edit-icon {
|
||||
color: $grey;
|
||||
cursor: pointer;
|
||||
@ -78,4 +82,4 @@
|
||||
&__add-item {
|
||||
color: $dark-grey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -50,7 +50,7 @@ class App extends React.Component {
|
||||
'application': true,
|
||||
'application_modal-opened': (this.props.modal.opened),
|
||||
'application_full-width': (this.props.config.layout === 'full-width' && !_.includes(this.props.location.pathname, '/admin')),
|
||||
'application_user-system': (this.props.config['user-system-enabled'])
|
||||
'application_mandatory-login': (this.props.config['mandatory-login'])
|
||||
};
|
||||
|
||||
return classNames(classes);
|
||||
@ -64,12 +64,12 @@ class App extends React.Component {
|
||||
loggedInStaff: !_.includes(props.location.pathname, '/admin/panel') && props.session.logged && props.session.staff,
|
||||
loggedOutStaff: _.includes(props.location.pathname, '/admin/panel') && !props.session.logged
|
||||
};
|
||||
|
||||
if(props.config['maintenance-mode'] === '1' && !_.includes(props.location.pathname, '/admin') && !_.includes(props.location.pathname, '/maintenance')) {
|
||||
|
||||
if(props.config['maintenance-mode'] && !_.includes(props.location.pathname, '/admin') && !_.includes(props.location.pathname, '/maintenance')) {
|
||||
history.push('/maintenance');
|
||||
}
|
||||
|
||||
if(props.config['maintenance-mode'] === '0' && _.includes(props.location.pathname, '/maintenance')) {
|
||||
if(!props.config['maintenance-mode'] && _.includes(props.location.pathname, '/maintenance')) {
|
||||
history.push('/');
|
||||
}
|
||||
|
||||
@ -99,7 +99,7 @@ class App extends React.Component {
|
||||
history.push('/');
|
||||
}
|
||||
|
||||
if(props.config['user-system-enabled'] && _.includes(props.location.pathname, '/check-ticket')) {
|
||||
if(props.config['mandatory-login'] && _.includes(props.location.pathname, '/check-ticket')) {
|
||||
history.push('/');
|
||||
}
|
||||
|
||||
@ -111,7 +111,7 @@ class App extends React.Component {
|
||||
history.push('/admin');
|
||||
}
|
||||
|
||||
if(isProd && _.includes(props.location.pathname, '/components-demo')) {
|
||||
if(process.env.NODE_ENV === 'production' && _.includes(props.location.pathname, '/components-demo')) {
|
||||
history.push('/');
|
||||
}
|
||||
}
|
||||
@ -139,4 +139,4 @@ export default connect((store) => {
|
||||
session: store.session,
|
||||
routing: store.routing
|
||||
};
|
||||
})(App);
|
||||
})(App);
|
||||
|
@ -32,13 +32,14 @@ import AdminPanelMyAccount from 'app/admin/panel/dashboard/admin-panel-my-accoun
|
||||
|
||||
import AdminPanelMyTickets from 'app/admin/panel/tickets/admin-panel-my-tickets';
|
||||
import AdminPanelNewTickets from 'app/admin/panel/tickets/admin-panel-new-tickets';
|
||||
import AdminPanelAllTickets from 'app/admin/panel/tickets/admin-panel-all-tickets';
|
||||
import AdminPanelSearchTickets from 'app/admin/panel/tickets/admin-panel-search-tickets';
|
||||
import AdminPanelViewTicket from 'app/admin/panel/tickets/admin-panel-view-ticket';
|
||||
import AdminPanelCustomResponses from 'app/admin/panel/tickets/admin-panel-custom-responses';
|
||||
|
||||
import AdminPanelListUsers from 'app/admin/panel/users/admin-panel-list-users';
|
||||
import AdminPanelViewUser from 'app/admin/panel/users/admin-panel-view-user';
|
||||
import AdminPanelBanUsers from 'app/admin/panel/users/admin-panel-ban-users';
|
||||
import AdminPanelCustomFields from 'app/admin/panel/users/admin-panel-custom-fields';
|
||||
|
||||
import AdminPanelListArticles from 'app/admin/panel/articles/admin-panel-list-articles';
|
||||
import AdminPanelViewArticle from 'app/admin/panel/articles/admin-panel-view-article';
|
||||
@ -49,7 +50,8 @@ import AdminPanelViewStaff from 'app/admin/panel/staff/admin-panel-view-staff';
|
||||
|
||||
import AdminPanelSystemPreferences from 'app/admin/panel/settings/admin-panel-system-preferences';
|
||||
import AdminPanelAdvancedSettings from 'app/admin/panel/settings/admin-panel-advanced-settings';
|
||||
import AdminPanelEmailTemplates from 'app/admin/panel/settings/admin-panel-email-templates';
|
||||
import AdminPanelEmailSettings from 'app/admin/panel/settings/admin-panel-email-settings';
|
||||
import AdminPanelCustomTags from 'app/admin/panel/settings/admin-panel-custom-tags';
|
||||
|
||||
// INSTALLATION
|
||||
import InstallLayout from 'app/install/install-layout';
|
||||
@ -59,7 +61,7 @@ import InstallStep3Database from 'app/install/install-step-3-database';
|
||||
import InstallStep4UserSystem from 'app/install/install-step-4-user-system';
|
||||
import InstallStep5Settings from 'app/install/install-step-5-settings';
|
||||
import InstallStep6Admin from 'app/install/install-step-6-admin';
|
||||
import InstallStep7Completed from 'app/install/install-step-7-completed';
|
||||
import InstallCompleted from 'app/install/install-completed';
|
||||
|
||||
export default (
|
||||
<Router history={history}>
|
||||
@ -96,12 +98,12 @@ export default (
|
||||
<Route path="step-4" component={InstallStep4UserSystem} />
|
||||
<Route path="step-5" component={InstallStep5Settings} />
|
||||
<Route path="step-6" component={InstallStep6Admin} />
|
||||
<Route path="step-7" component={InstallStep7Completed} />
|
||||
<Route path="completed" component={InstallCompleted} />
|
||||
</Route>
|
||||
<Route path="admin">
|
||||
<IndexRoute component={AdminLoginPage} />
|
||||
<Route path="panel" component={AdminPanelLayout}>
|
||||
<IndexRedirect to="stats" />
|
||||
<IndexRedirect to="activity" />
|
||||
<Route path="stats" component={AdminPanelStats} />
|
||||
<Route path="activity" component={AdminPanelActivity} />
|
||||
<Route path="my-account" component={AdminPanelMyAccount} />
|
||||
@ -110,7 +112,7 @@ export default (
|
||||
<IndexRedirect to="my-tickets" />
|
||||
<Route path="my-tickets" component={AdminPanelMyTickets} />
|
||||
<Route path="new-tickets" component={AdminPanelNewTickets} />
|
||||
<Route path="all-tickets" component={AdminPanelAllTickets} />
|
||||
<Route path="search-tickets" component={AdminPanelSearchTickets} />
|
||||
<Route path="custom-responses" component={AdminPanelCustomResponses} />
|
||||
<Route path="view-ticket/:ticketNumber" component={AdminPanelViewTicket} />
|
||||
</Route>
|
||||
@ -120,6 +122,7 @@ export default (
|
||||
<Route path="list-users" component={AdminPanelListUsers} />
|
||||
<Route path="view-user/:userId" component={AdminPanelViewUser} />
|
||||
<Route path="ban-users" component={AdminPanelBanUsers} />
|
||||
<Route path="custom-fields" component={AdminPanelCustomFields} />
|
||||
</Route>
|
||||
|
||||
<Route path="articles">
|
||||
@ -139,7 +142,8 @@ export default (
|
||||
<IndexRedirect to="system-preferences" />
|
||||
<Route path="system-preferences" component={AdminPanelSystemPreferences} />
|
||||
<Route path="advanced-settings" component={AdminPanelAdvancedSettings} />
|
||||
<Route path="email-templates" component={AdminPanelEmailTemplates} />
|
||||
<Route path="email-settings" component={AdminPanelEmailSettings} />
|
||||
<Route path="custom-tags" component={AdminPanelCustomTags} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
|
@ -1,5 +1,5 @@
|
||||
export default {
|
||||
dispatch: stub(),
|
||||
dispatch: stub().returns(new Promise(r => r())),
|
||||
getState: stub().returns({
|
||||
config: {},
|
||||
session: {},
|
||||
|
@ -1,59 +1,256 @@
|
||||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
import classNames from 'classnames';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import i18n from 'lib-app/i18n';
|
||||
import API from 'lib-app/api-call';
|
||||
import SessionActions from 'actions/session-actions';
|
||||
|
||||
import PasswordRecovery from 'app-components/password-recovery.js';
|
||||
import Button from 'core-components/button';
|
||||
import Form from 'core-components/form';
|
||||
import FormField from 'core-components/form-field';
|
||||
import SubmitButton from 'core-components/submit-button';
|
||||
import Message from 'core-components/message';
|
||||
import Widget from 'core-components/widget';
|
||||
import WidgetTransition from 'core-components/widget-transition';
|
||||
|
||||
import Captcha from 'app/main/captcha';
|
||||
|
||||
const MAX_FREE_LOGIN_ATTEMPTS = 3;
|
||||
class AdminLoginPage extends React.Component {
|
||||
|
||||
state = {
|
||||
sideToShow: 'front',
|
||||
loginFormErrors: {},
|
||||
recoverFormErrors: {},
|
||||
recoverSent: false,
|
||||
loadingLogin: false,
|
||||
loadingRecover: false,
|
||||
showRecoverSentMessage: true,
|
||||
showEmailOrPassordErrorMessage: true
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (!prevProps.session.failed && this.props.session.failed) {
|
||||
this.setState({showEmailOrPassordErrorMessage : true});
|
||||
this.refs.loginForm.refs.password.focus();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="admin-login-page">
|
||||
<WidgetTransition sideToShow={this.state.sideToShow} className={classNames('admin-login-page__container', this.props.className)}>
|
||||
{this.renderLogin()}
|
||||
{this.renderPasswordRecovery()}
|
||||
</WidgetTransition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderLogin() {
|
||||
return (
|
||||
<div>
|
||||
<Widget className="admin-login-page__content">
|
||||
<div className="admin-login-page__image"><img width="100%" src={API.getURL() + '/images/logo.png'} alt="OpenSupports Admin Panel"/></div>
|
||||
<div className="admin-login-page__login-form">
|
||||
<Form onSubmit={this.onSubmit.bind(this)} loading={this.props.session.pending}>
|
||||
<FormField name="email" label={i18n('EMAIL')} field="input" validation="EMAIL" fieldProps={{size:'large'}} required />
|
||||
<FormField name="password" label={i18n('PASSWORD')} field="input" fieldProps={{password:true, size:'large'}} />
|
||||
<SubmitButton>{i18n('LOG_IN')}</SubmitButton>
|
||||
<div className="admin-login-page__login-form-container">
|
||||
<Form {...this.getLoginFormProps()}>
|
||||
<div className="admin-login-page__login-form-container__login-form__fields">
|
||||
<FormField
|
||||
name="email"
|
||||
label={i18n('EMAIL')}
|
||||
className="admin-login-page__login-form-container__login-form__fields__email"
|
||||
field="input"
|
||||
validation="EMAIL"
|
||||
fieldProps={{size:'large'}}
|
||||
required />
|
||||
<FormField
|
||||
name="password"
|
||||
label={i18n('PASSWORD')}
|
||||
className="admin-login-page__login-form-container__login-form__fields__password"
|
||||
field="input"
|
||||
fieldProps={{password:true, size:'large'}} />
|
||||
<FormField
|
||||
name="remember"
|
||||
label={i18n('REMEMBER_ME')}
|
||||
className="admin-login-page__login-form-container__login-form__fields__remember"
|
||||
field="checkbox" />
|
||||
</div>
|
||||
{this.props.session.loginAttempts > MAX_FREE_LOGIN_ATTEMPTS ? this.renderLoginCaptcha() : null}
|
||||
<div className="admin-login-page__login-form-container__login-form__submit-button">
|
||||
<SubmitButton>{i18n('LOG_IN')}</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
{this.renderMessage()}
|
||||
{this.renderRecoverStatus()}
|
||||
{this.renderErrorStatus()}
|
||||
<Button className="login-widget__forgot-password" type="link" onClick={this.onForgotPasswordClick.bind(this)} onMouseDown={(event) => {event.preventDefault()}}>
|
||||
{i18n('FORGOT_PASSWORD')}
|
||||
</Button>
|
||||
</Widget>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderMessage() {
|
||||
let message = null;
|
||||
|
||||
if(this.props.session.failed) {
|
||||
message = (
|
||||
<Message className="admin-login-page__error" type="error">
|
||||
{i18n('EMAIL_OR_PASSWORD')}
|
||||
</Message>
|
||||
);
|
||||
}
|
||||
|
||||
return message;
|
||||
renderLoginCaptcha() {
|
||||
return(
|
||||
<div className={`main-home-page__${this.props.sitekey ? "captcha" : "no-captcha"}`}>
|
||||
<Captcha ref="captcha" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
onSubmit(formState) {
|
||||
renderPasswordRecovery() {
|
||||
return (
|
||||
<div className="admin-login-page__recovery-form-container">
|
||||
<PasswordRecovery recoverSent={this.state.recoverSent} formProps={this.getRecoverFormProps()} onBackToLoginClick={this.onBackToLoginClick.bind(this)} renderLogo={true}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderRecoverStatus() {
|
||||
const { showRecoverSentMessage, recoverSent } = this.state;
|
||||
|
||||
return (
|
||||
recoverSent ?
|
||||
<Message
|
||||
showMessage={showRecoverSentMessage}
|
||||
onCloseMessage={this.onCloseMessage.bind(this, "showRecoverSentMessage")}
|
||||
className="admin-login-page__message"
|
||||
type="info"
|
||||
leftAligned>
|
||||
{i18n('RECOVER_SENT')}
|
||||
</Message> :
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
renderErrorStatus() {
|
||||
return (
|
||||
this.props.session.failed ?
|
||||
<Message
|
||||
showMessage={this.state.showEmailOrPassordErrorMessage}
|
||||
onCloseMessage={this.onCloseMessage.bind(this, "showEmailOrPassordErrorMessage")}
|
||||
className="admin-login-page__error"
|
||||
type="error">
|
||||
{i18n('EMAIL_OR_PASSWORD')}
|
||||
</Message> :
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
getLoginFormProps() {
|
||||
return {
|
||||
loading: this.props.session.pending,
|
||||
className: 'admin-login-page__login-form-container__login-form',
|
||||
ref: 'loginForm',
|
||||
onSubmit: this.onLoginFormSubmit.bind(this),
|
||||
errors: this.getLoginFormErrors(),
|
||||
onValidateErrors: this.onLoginFormErrorsValidation.bind(this)
|
||||
};
|
||||
}
|
||||
|
||||
getRecoverFormProps() {
|
||||
const { loadingRecover, recoverFormErrors } = this.state;
|
||||
|
||||
return {
|
||||
loading: loadingRecover,
|
||||
className: 'admin-login-page__recovery-form-container__recovery-form',
|
||||
ref: 'recoverForm',
|
||||
onSubmit: this.onForgotPasswordSubmit.bind(this),
|
||||
errors: recoverFormErrors,
|
||||
onValidateErrors: this.onRecoverFormErrorsValidation.bind(this)
|
||||
};
|
||||
}
|
||||
|
||||
getLoginFormErrors() {
|
||||
let errors = _.extend({}, this.state.loginFormErrors);
|
||||
|
||||
if (this.props.session.failed) {
|
||||
if (this.props.session.failMessage === 'INVALID_CREDENTIALS') {
|
||||
errors.password = i18n('ERROR_PASSWORD');
|
||||
} else if (this.props.session.failMessage === 'UNVERIFIED_USER') {
|
||||
errors.email = i18n('UNVERIFIED_EMAIL');
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
onLoginFormSubmit(formState) {
|
||||
this.props.dispatch(SessionActions.login(_.extend({}, formState, {
|
||||
staff: true
|
||||
})));
|
||||
}
|
||||
|
||||
onForgotPasswordSubmit(formState) {
|
||||
this.setState({
|
||||
loadingRecover: true,
|
||||
recoverSent: false
|
||||
});
|
||||
|
||||
API.call({
|
||||
path: '/user/send-recover-password',
|
||||
data: _.extend({}, formState, {staff: true})
|
||||
}).then(this.onRecoverPasswordSent.bind(this)).catch(this.onRecoverPasswordFail.bind(this));
|
||||
}
|
||||
|
||||
onLoginFormErrorsValidation(errors) {
|
||||
this.setState({
|
||||
loginFormErrors: errors
|
||||
});
|
||||
}
|
||||
|
||||
onRecoverFormErrorsValidation(errors) {
|
||||
this.setState({
|
||||
recoverFormErrors: errors
|
||||
});
|
||||
}
|
||||
|
||||
onForgotPasswordClick() {
|
||||
this.setState({
|
||||
sideToShow: 'back'
|
||||
});
|
||||
}
|
||||
|
||||
onBackToLoginClick() {
|
||||
this.setState({
|
||||
sideToShow: 'front',
|
||||
recoverSent: false
|
||||
});
|
||||
}
|
||||
|
||||
onRecoverPasswordSent() {
|
||||
this.setState({
|
||||
loadingRecover: false,
|
||||
recoverSent: true,
|
||||
showRecoverSentMessage: true
|
||||
});
|
||||
}
|
||||
|
||||
onRecoverPasswordFail() {
|
||||
this.setState({
|
||||
loadingRecover: false,
|
||||
recoverFormErrors: {
|
||||
email: i18n('EMAIL_NOT_EXIST')
|
||||
}
|
||||
}, function () {
|
||||
this.refs.recoverForm.refs.email.focus();
|
||||
}.bind(this));
|
||||
}
|
||||
|
||||
onCloseMessage(showMessage) {
|
||||
this.setState({
|
||||
[showMessage]: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default connect((store) => {
|
||||
return {
|
||||
session: store.session
|
||||
session: store.session,
|
||||
sitekey: store.config.reCaptchaKey
|
||||
};
|
||||
})(AdminLoginPage);
|
||||
|
@ -2,6 +2,12 @@
|
||||
|
||||
.admin-login-page {
|
||||
display: flex;
|
||||
margin: 0 auto;
|
||||
width: 446px;
|
||||
|
||||
&__container {
|
||||
height: 361px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
margin: 0 auto;
|
||||
@ -13,12 +19,22 @@
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
&__login-form {
|
||||
&__login-form-container {
|
||||
margin: 0 auto;
|
||||
display: inline-block;
|
||||
|
||||
&__login-form__fields {
|
||||
padding: 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
margin-top: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
&__captcha {
|
||||
margin: 10px auto 20px;
|
||||
height: 78px;
|
||||
width: 304px;
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,7 @@
|
||||
import React from 'react';
|
||||
import store from 'app/store';
|
||||
|
||||
import ConfigActions from 'actions/config-actions';
|
||||
|
||||
import MainLayout from 'app/main/main-layout';
|
||||
import AdminPanelStaffWidget from 'app/admin/panel/admin-panel-staff-widget';
|
||||
@ -8,6 +11,10 @@ import Widget from 'core-components/widget';
|
||||
|
||||
class AdminPanel extends React.Component {
|
||||
|
||||
componentDidMount() {
|
||||
store.dispatch(ConfigActions.updateData());
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<MainLayout>
|
||||
@ -22,7 +29,7 @@ class AdminPanel extends React.Component {
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-md-12 admin-panel-layout__content">
|
||||
<Widget>
|
||||
<Widget className='admin-panel-layout__content__widget'>
|
||||
{this.props.children}
|
||||
</Widget>
|
||||
</div>
|
||||
@ -33,4 +40,4 @@ class AdminPanel extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default AdminPanel;
|
||||
export default AdminPanel;
|
||||
|
@ -4,4 +4,10 @@
|
||||
&__header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 415px) {
|
||||
.admin-panel-layout__content__widget {
|
||||
padding: 20px 5px;
|
||||
}
|
||||
}
|
||||
}
|
@ -2,10 +2,13 @@ import React from 'react';
|
||||
import _ from 'lodash';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {dispatch} from 'app/store';
|
||||
import i18n from 'lib-app/i18n';
|
||||
|
||||
import Menu from 'core-components/menu';
|
||||
import queryString from 'query-string';
|
||||
|
||||
const INITIAL_PAGE = 1;
|
||||
|
||||
|
||||
class AdminPanelMenu extends React.Component {
|
||||
static contextTypes = {
|
||||
@ -70,15 +73,32 @@ class AdminPanelMenu extends React.Component {
|
||||
|
||||
onGroupItemClick(index) {
|
||||
const group = this.getRoutes()[this.getGroupIndex()];
|
||||
const item = group.items[index];
|
||||
|
||||
this.context.router.push(group.items[index].path);
|
||||
this.context.router.push(item.path);
|
||||
item.onItemClick && item.onItemClick();
|
||||
}
|
||||
|
||||
getGroupItemIndex() {
|
||||
const { location } = this.props;
|
||||
const search = window.location.search;
|
||||
const filtersInURL = queryString.parse(search);
|
||||
const group = this.getRoutes()[this.getGroupIndex()];
|
||||
const pathname = this.props.location.pathname;
|
||||
const pathname = location.pathname + location.search;
|
||||
const SEARCH_TICKETS_PATH = '/admin/panel/tickets/search-tickets';
|
||||
|
||||
return _.findIndex(group.items, {path: pathname});
|
||||
return (
|
||||
_.findIndex(
|
||||
group.items,
|
||||
(item) => {
|
||||
if(location.pathname === SEARCH_TICKETS_PATH) {
|
||||
const customTicketsListNumber = queryString.parse(item.path.slice(SEARCH_TICKETS_PATH.length)).custom;
|
||||
return item.path.includes(SEARCH_TICKETS_PATH) && customTicketsListNumber === filtersInURL.custom;
|
||||
}
|
||||
return item.path === pathname;
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
getGroupIndex() {
|
||||
@ -90,8 +110,24 @@ class AdminPanelMenu extends React.Component {
|
||||
return (groupIndex === -1) ? 0 : groupIndex;
|
||||
}
|
||||
|
||||
getCustomlists() {
|
||||
if(window.customTicketList){
|
||||
return window.customTicketList.map((item, index) => {
|
||||
return {
|
||||
name: item.title,
|
||||
path: `/admin/panel/tickets/search-tickets?custom=${index}&page=${INITIAL_PAGE}&useInitialValues=true`,
|
||||
level: 1,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
getRoutes() {
|
||||
return this.getItemsByFilteredByLevel([
|
||||
const customLists = this.getCustomlists();
|
||||
|
||||
return this.getItemsByFilteredByLevel(_.without([
|
||||
{
|
||||
groupName: i18n('DASHBOARD'),
|
||||
path: '/admin/panel',
|
||||
@ -99,13 +135,13 @@ class AdminPanelMenu extends React.Component {
|
||||
level: 1,
|
||||
items: this.getItemsByFilteredByLevel([
|
||||
{
|
||||
name: i18n('STATISTICS'),
|
||||
path: '/admin/panel/stats',
|
||||
name: i18n('LAST_ACTIVITY'),
|
||||
path: '/admin/panel/activity',
|
||||
level: 1
|
||||
},
|
||||
{
|
||||
name: i18n('LAST_ACTIVITY'),
|
||||
path: '/admin/panel/activity',
|
||||
name: i18n('STATISTICS'),
|
||||
path: '/admin/panel/stats',
|
||||
level: 1
|
||||
}
|
||||
])
|
||||
@ -127,15 +163,16 @@ class AdminPanelMenu extends React.Component {
|
||||
level: 1
|
||||
},
|
||||
{
|
||||
name: i18n('ALL_TICKETS'),
|
||||
path: '/admin/panel/tickets/all-tickets',
|
||||
level: 1
|
||||
name: i18n('SEARCH_TICKETS'),
|
||||
path: `/admin/panel/tickets/search-tickets?page=${INITIAL_PAGE}&useInitialValues=true`,
|
||||
level: 1,
|
||||
},
|
||||
{
|
||||
name: i18n('CUSTOM_RESPONSES'),
|
||||
path: '/admin/panel/tickets/custom-responses',
|
||||
level: 2
|
||||
}
|
||||
},
|
||||
...customLists
|
||||
])
|
||||
},
|
||||
{
|
||||
@ -153,6 +190,11 @@ class AdminPanelMenu extends React.Component {
|
||||
name: i18n('BAN_USERS'),
|
||||
path: '/admin/panel/users/ban-users',
|
||||
level: 1
|
||||
},
|
||||
{
|
||||
name: i18n('CUSTOM_FIELDS'),
|
||||
path: '/admin/panel/users/custom-fields',
|
||||
level: 1
|
||||
}
|
||||
])
|
||||
},
|
||||
@ -170,7 +212,6 @@ class AdminPanelMenu extends React.Component {
|
||||
])
|
||||
},
|
||||
{
|
||||
|
||||
groupName: i18n('STAFF'),
|
||||
path: '/admin/panel/staff',
|
||||
icon: 'users',
|
||||
@ -206,13 +247,18 @@ class AdminPanelMenu extends React.Component {
|
||||
level: 3
|
||||
},
|
||||
{
|
||||
name: i18n('EMAIL_TEMPLATES'),
|
||||
path: '/admin/panel/settings/email-templates',
|
||||
name: i18n('EMAIL_SETTINGS'),
|
||||
path: '/admin/panel/settings/email-settings',
|
||||
level: 3
|
||||
},
|
||||
{
|
||||
name: i18n('CUSTOM_TAGS'),
|
||||
path: '/admin/panel/settings/custom-tags',
|
||||
level: 3
|
||||
}
|
||||
])
|
||||
}
|
||||
]);
|
||||
], null));
|
||||
}
|
||||
|
||||
getItemsByFilteredByLevel(items) {
|
||||
@ -222,6 +268,8 @@ class AdminPanelMenu extends React.Component {
|
||||
|
||||
export default connect((store) => {
|
||||
return {
|
||||
level: store.session.userLevel
|
||||
level: store.session.userLevel,
|
||||
config: store.config,
|
||||
searchFilters: store.searchFilters,
|
||||
};
|
||||
})(AdminPanelMenu);
|
||||
})(AdminPanelMenu);
|
||||
|
@ -18,4 +18,4 @@ class AdminPanelListArticles extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default AdminPanelListArticles;
|
||||
export default AdminPanelListArticles;
|
||||
|
@ -3,4 +3,11 @@
|
||||
&__list {
|
||||
padding: 0 50px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 415px) {
|
||||
.admin-panel-list-articles__list {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import ArticlesActions from 'actions/articles-actions';
|
||||
import SessionStore from 'lib-app/session-store';
|
||||
import i18n from 'lib-app/i18n';
|
||||
import API from 'lib-app/api-call';
|
||||
import MentionsParser from 'lib-app/mentions-parser';
|
||||
import DateTransformer from 'lib-core/date-transformer';
|
||||
|
||||
import AreYouSure from 'app-components/are-you-sure';
|
||||
@ -17,12 +18,14 @@ import Form from 'core-components/form';
|
||||
import FormField from 'core-components/form-field';
|
||||
import SubmitButton from 'core-components/submit-button';
|
||||
import TextEditor from 'core-components/text-editor';
|
||||
import Icon from 'core-components/icon';
|
||||
|
||||
class AdminPanelViewArticle extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
topics: React.PropTypes.array,
|
||||
loading: React.PropTypes.bool
|
||||
loading: React.PropTypes.bool,
|
||||
allowAttachments: React.PropTypes.bool
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@ -63,23 +66,22 @@ class AdminPanelViewArticle extends React.Component {
|
||||
renderArticlePreview(article) {
|
||||
return (
|
||||
<div className="admin-panel-view-article__content">
|
||||
<div className="admin-panel-view-article__edit-buttons">
|
||||
<Button className="admin-panel-view-article__edit-button" size="medium" onClick={this.onEditClick.bind(this, article)} type="tertiary">
|
||||
{i18n('EDIT')}
|
||||
</Button>
|
||||
<Button size="medium" onClick={this.onDeleteClick.bind(this, article)}>
|
||||
{i18n('DELETE')}
|
||||
</Button>
|
||||
<div className="admin-panel-view-article__header-wrapper">
|
||||
<Header title={article.title} />
|
||||
<div className="admin-panel-view-article__header-buttons">
|
||||
<span onClick={this.onEditClick.bind(this, article)}>
|
||||
<Icon className="admin-panel-view-article__edit-icon" name="pencil" />
|
||||
</span>
|
||||
<span onClick={this.onDeleteClick.bind(this, article)} >
|
||||
<Icon className="admin-panel-view-article__edit-icon" name="trash" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-panel-view-article__article">
|
||||
<Header title={article.title}/>
|
||||
|
||||
<div className="admin-panel-view-article__article-content">
|
||||
<div dangerouslySetInnerHTML={{__html: article.content}}/>
|
||||
</div>
|
||||
<div className="admin-panel-view-article__last-edited">
|
||||
{i18n('LAST_EDITED_IN', {date: DateTransformer.transformToString(article.lastEdited)})}
|
||||
</div>
|
||||
<div className="admin-panel-view-article__article-content ql-editor">
|
||||
<div dangerouslySetInnerHTML={{__html: MentionsParser.parse(article.content)}}/>
|
||||
</div>
|
||||
<div className="admin-panel-view-article__last-edited">
|
||||
{i18n('LAST_EDITED_IN', {date: DateTransformer.transformToString(article.lastEdited)})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -89,13 +91,13 @@ class AdminPanelViewArticle extends React.Component {
|
||||
return (
|
||||
<Form values={this.state.form} onChange={(form) => this.setState({form})} onSubmit={this.onFormSubmit.bind(this)}>
|
||||
<div className="admin-panel-view-article__buttons">
|
||||
<SubmitButton className="admin-panel-view-article__button" type="secondary" size="medium">{i18n('SAVE')}</SubmitButton>
|
||||
<Button className="admin-panel-view-article__button" size="medium" onClick={this.onFormCancel.bind(this)}>
|
||||
{i18n('CANCEL')}
|
||||
</Button>
|
||||
<SubmitButton className="admin-panel-view-article__button" type="secondary" size="medium">{i18n('SAVE')}</SubmitButton>
|
||||
</div>
|
||||
<FormField name="title" label={i18n('TITLE')} />
|
||||
<FormField name="content" label={i18n('CONTENT')} field="textarea" />
|
||||
<FormField name="content" label={i18n('CONTENT')} field="textarea" validation="TEXT_AREA" required fieldProps={{allowImages: this.props.allowAttachments}}/>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@ -129,11 +131,11 @@ class AdminPanelViewArticle extends React.Component {
|
||||
onFormSubmit(form) {
|
||||
API.call({
|
||||
path: '/article/edit',
|
||||
data: {
|
||||
dataAsForm: true,
|
||||
data: _.extend(TextEditor.getContentFormData(form.content), {
|
||||
articleId: this.findArticle().id,
|
||||
title: form.title,
|
||||
content: form.content
|
||||
}
|
||||
title: form.title
|
||||
})
|
||||
}).then(() => {
|
||||
this.props.dispatch(ArticlesActions.retrieveArticles());
|
||||
this.setState({
|
||||
@ -151,7 +153,7 @@ class AdminPanelViewArticle extends React.Component {
|
||||
}
|
||||
|
||||
onArticleDeleted(article) {
|
||||
API.call({
|
||||
return API.call({
|
||||
path: '/article/delete',
|
||||
data: {
|
||||
articleId: article.id
|
||||
@ -162,6 +164,7 @@ class AdminPanelViewArticle extends React.Component {
|
||||
|
||||
export default connect((store) => {
|
||||
return {
|
||||
allowAttachments: store.config['allow-attachments'],
|
||||
topics: store.articles.topics,
|
||||
loading: store.articles.loading
|
||||
};
|
||||
|
@ -1,12 +1,28 @@
|
||||
.admin-panel-view-article {
|
||||
|
||||
&__edit-buttons {
|
||||
text-align: left;
|
||||
margin-bottom: 20px;
|
||||
&__content {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&__edit-button {
|
||||
margin-right: 20px;
|
||||
&__header-wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
&__header-buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 50px;
|
||||
margin-bottom: 5px;
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
&__edit-icon {
|
||||
color: $grey;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__last-edited {
|
||||
@ -19,8 +35,8 @@
|
||||
text-align: left;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
|
||||
&__button {
|
||||
margin-right: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -43,7 +43,7 @@ class AdminPanelActivity extends React.Component {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
getMenuProps() {
|
||||
return {
|
||||
className: 'admin-panel-activity__menu',
|
||||
@ -148,4 +148,4 @@ class AdminPanelActivity extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default AdminPanelActivity;
|
||||
export default AdminPanelActivity;
|
||||
|
@ -2,6 +2,10 @@
|
||||
|
||||
&__menu {
|
||||
margin: 0 auto 20px auto;
|
||||
width: 300px;
|
||||
min-width: 300px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
@ -3,7 +3,6 @@ import {connect} from 'react-redux';
|
||||
import _ from 'lodash';
|
||||
|
||||
import i18n from 'lib-app/i18n';
|
||||
import API from 'lib-app/api-call';
|
||||
import SessionActions from 'actions/session-actions';
|
||||
|
||||
import StaffEditor from 'app/admin/panel/staff/staff-editor';
|
||||
|
@ -1,20 +1,195 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import API from 'lib-app/api-call';
|
||||
import i18n from 'lib-app/i18n';
|
||||
import Stats from 'app-components/stats';
|
||||
import statsUtils from 'lib-app/stats-utils';
|
||||
import date from 'lib-app/date';
|
||||
|
||||
import Header from 'core-components/header';
|
||||
import Form from 'core-components/form';
|
||||
import FormField from 'core-components/form-field';
|
||||
import Icon from 'core-components/icon';
|
||||
import Loading from 'core-components/loading';
|
||||
import SubmitButton from 'core-components/submit-button';
|
||||
import Button from 'core-components/button';
|
||||
|
||||
class AdminPanelStats extends React.Component {
|
||||
|
||||
state = {
|
||||
loading: true,
|
||||
rawForm: {
|
||||
period: 0,
|
||||
departments: [],
|
||||
owners: [],
|
||||
tags: []
|
||||
},
|
||||
ticketData: {}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
statsUtils.retrieveStats({
|
||||
rawForm: this.getFormWithDateRange(this.state.rawForm),
|
||||
tags: this.props.tags
|
||||
}).then(({data}) => {
|
||||
this.setState({ticketData: data, loading: false});
|
||||
}).catch((error) => {
|
||||
if (showLogs) console.error('ERROR: ', error);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { loading, rawForm, ticketData } = this.state;
|
||||
|
||||
return (
|
||||
<div className="admin-panel-stats">
|
||||
<Header title={i18n('STATISTICS')} description={i18n('STATISTICS_DESCRIPTION')}/>
|
||||
<Stats type="general"/>
|
||||
<Header title={i18n('STATISTICS')} description={i18n('STATISTICS_DESCRIPTION')} />
|
||||
<Form className="admin-panel-stats__form" loading={loading} values={rawForm} onChange={this.onFormChange.bind(this)} onSubmit={this.onFormSubmit.bind(this)}>
|
||||
<div className="admin-panel-stats__form__container">
|
||||
<div className="admin-panel-stats__form__container__row">
|
||||
<div className="admin-panel-stats__form__container__col">
|
||||
<FormField name="period" label={i18n('DATE')} field="select" fieldProps={{size: 'large', items: [{content: i18n('LAST_7_DAYS')}, {content: i18n('LAST_30_DAYS')}, {content: i18n('LAST_90_DAYS')}, {content: i18n('LAST_365_DAYS')}]}} />
|
||||
<FormField name="tags" label={i18n('TAGS')} field="tag-selector" fieldProps={{items: this.getTagItems()}} />
|
||||
</div>
|
||||
<div className="admin-panel-stats__form__container__col">
|
||||
<FormField name="departments" label={i18n('DEPARTMENTS')} field="autocomplete" fieldProps={{items: this.getDepartmentsItems()}} />
|
||||
<FormField name="owners" label={i18n('OWNER')} field="autocomplete" fieldProps={{items: this.getStaffItems()}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-panel-stats__container">
|
||||
<Button
|
||||
className="admin-panel-stats__container__button admin-panel-stats__container__clear-button"
|
||||
size= "medium"
|
||||
disabled={loading}
|
||||
onClick={this.clearFormValues.bind(this)}>
|
||||
{loading ? <Loading /> : i18n('CLEAR')}
|
||||
</Button>
|
||||
<SubmitButton
|
||||
className="admin-panel-stats__container__button admin-panel-stats__container__apply-button"
|
||||
type="secondary"
|
||||
size= "medium">
|
||||
{i18n('APPLY')}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
<div className="row">
|
||||
<div className="col-md-12">
|
||||
<span className="separator" />
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
loading ?
|
||||
<div className="admin-panel-stats__loading"><Loading backgrounded size="large" /></div> :
|
||||
statsUtils.renderStatistics({showStatCards: true, showStatsByHours: true, showStatsByDays: true, ticketData})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
clearFormValues(event) {
|
||||
event.preventDefault();
|
||||
this.setState({
|
||||
rawForm: {
|
||||
period: 0,
|
||||
departments: [],
|
||||
owners: [],
|
||||
tags: []
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getTagItems() {
|
||||
return this.props.tags.map((tag) => {
|
||||
return {
|
||||
id: JSON.parse(tag.id),
|
||||
name: tag.name,
|
||||
color : tag.color
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getStaffItems() {
|
||||
const getStaffProfilePic = (staff) => {
|
||||
return staff.profilePic ? API.getFileLink(staff.profilePic) : (API.getURL() + '/images/profile.png');
|
||||
}
|
||||
|
||||
const renderStaffItem = (staff, style) => {
|
||||
return (
|
||||
<div className={`admin-panel-stats__staff-${style}`} key={`staff-${style}-${staff.id}`}>
|
||||
<img className={`admin-panel-stats__staff-${style}__profile-pic`} src={getStaffProfilePic(staff)} />
|
||||
<span className={`admin-panel-stats__staff-${style}__name`}>{staff.name}</span>
|
||||
</div>
|
||||
)
|
||||
};
|
||||
|
||||
const { staffList } = this.props;
|
||||
let newStaffList = staffList.map(staff => {
|
||||
return {
|
||||
id: JSON.parse(staff.id),
|
||||
name: staff.name.toLowerCase(),
|
||||
color: 'gray',
|
||||
contentOnSelected: renderStaffItem(staff, 'selected'),
|
||||
content: renderStaffItem(staff, 'option'),
|
||||
}
|
||||
});
|
||||
|
||||
return newStaffList;
|
||||
}
|
||||
|
||||
getDepartmentsItems() {
|
||||
const renderDepartmentItem = (department, style) => {
|
||||
return (
|
||||
<div className={`admin-panel-stats__department-${style}`} key={`department-${style}-${department.id}`}>
|
||||
{department.private*1 ? <Icon className={`admin-panel-stats__department-${style}__icon`} name='user-secret' /> : null}
|
||||
<span className={`admin-panel-stats__department-${style}__name`}>{department.name}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return this.props.departments.map(department => {
|
||||
return {
|
||||
id: JSON.parse(department.id),
|
||||
name: department.name.toLowerCase(),
|
||||
color: 'gray',
|
||||
contentOnSelected: renderDepartmentItem(department, 'selected'),
|
||||
content: renderDepartmentItem(department, 'option'),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onFormChange(newFormState) {
|
||||
this.setState({rawForm: newFormState});
|
||||
}
|
||||
|
||||
onFormSubmit() {
|
||||
statsUtils.retrieveStats({
|
||||
rawForm: this.getFormWithDateRange(this.state.rawForm),
|
||||
tags: this.props.tags
|
||||
}).then(({data}) => {
|
||||
this.setState({ticketData: data, loading: false});
|
||||
}).catch((error) => {
|
||||
if (showLogs) console.error('ERROR: ', error);
|
||||
});
|
||||
}
|
||||
|
||||
getFormWithDateRange(form) {
|
||||
const {startDate, endDate} = statsUtils.getDateRangeFromPeriod(form.period);
|
||||
|
||||
return {
|
||||
...form,
|
||||
dateRange: {
|
||||
startDate,
|
||||
endDate
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default AdminPanelStats;
|
||||
export default connect((store) => {
|
||||
return {
|
||||
tags: store.config.tags,
|
||||
departments: store.config.departments,
|
||||
staffList: store.adminData.staffMembers
|
||||
};
|
||||
})(AdminPanelStats);
|
||||
|
@ -0,0 +1,110 @@
|
||||
@import "../../../../scss/vars";
|
||||
|
||||
.admin-panel-stats {
|
||||
text-align: left;
|
||||
|
||||
&__form {
|
||||
&__container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
&__col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
|
||||
&__button {
|
||||
margin: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&__loading {
|
||||
min-height: 361px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: $grey;
|
||||
}
|
||||
|
||||
&__card-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
&__department-option { // Duplicated from ticket-query-filters, please REMOVE
|
||||
&__name {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&__department-selected { // Duplicated from ticket-query-filters, please REMOVE
|
||||
display: inline-block;
|
||||
border-radius: 3px;
|
||||
margin-left: 5px;
|
||||
padding: 1px;
|
||||
font-size: 13px;
|
||||
cursor: default;
|
||||
|
||||
&__icon {
|
||||
padding-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
&__staff-option { // Duplicated from ticket-query-filters, please REMOVE
|
||||
&__profile-pic {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&__name {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&__staff-selected { // Duplicated from ticket-query-filters, please REMOVE
|
||||
display: inline-block;
|
||||
border-radius: 3px;
|
||||
margin-left: 5px;
|
||||
padding: 1px;
|
||||
font-size: 13px;
|
||||
cursor: default;
|
||||
|
||||
&__profile-pic {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
border-radius: 50%;
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 992px) {
|
||||
.admin-panel-stats__form__container__row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
@ -4,7 +4,6 @@ import {connect} from 'react-redux';
|
||||
import ConfigActions from 'actions/config-actions';
|
||||
import API from 'lib-app/api-call';
|
||||
import i18n from 'lib-app/i18n';
|
||||
import ToggleButton from 'app-components/toggle-button';
|
||||
import AreYouSure from 'app-components/are-you-sure';
|
||||
import ModalContainer from 'app-components/modal-container';
|
||||
|
||||
@ -17,6 +16,7 @@ import Listing from 'core-components/listing';
|
||||
import Form from 'core-components/form';
|
||||
import FormField from 'core-components/form-field';
|
||||
import SubmitButton from 'core-components/submit-button';
|
||||
import Checkbox from 'core-components/checkbox';
|
||||
|
||||
class AdminPanelAdvancedSettings extends React.Component {
|
||||
|
||||
@ -25,10 +25,11 @@ class AdminPanelAdvancedSettings extends React.Component {
|
||||
messageTitle: null,
|
||||
messageType: '',
|
||||
messageContent: '',
|
||||
keyName: '',
|
||||
keyCode: '',
|
||||
selectedAPIKey: -1,
|
||||
APIKeys: []
|
||||
APIKeys: [],
|
||||
error: '',
|
||||
showMessage: true,
|
||||
showAPIKeyMessage: true
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
@ -36,24 +37,33 @@ class AdminPanelAdvancedSettings extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { config } = this.props;
|
||||
const { messageType, error, selectedAPIKey, showAPIKeyMessage } = this.state;
|
||||
|
||||
return (
|
||||
<div className="admin-panel-advanced-settings">
|
||||
<Header title={i18n('ADVANCED_SETTINGS')} description={i18n('ADVANCED_SETTINGS_DESCRIPTION')}/>
|
||||
{(this.state.messageType) ? this.renderMessage() : null}
|
||||
<Header title={i18n('ADVANCED_SETTINGS')} description={i18n('ADVANCED_SETTINGS_DESCRIPTION')} />
|
||||
{messageType ? this.renderMessage() : null}
|
||||
<div className="row">
|
||||
<div className="col-md-12">
|
||||
<div className="col-md-6">
|
||||
<div className="admin-panel-advanced-settings__user-system-enabled">
|
||||
<span className="admin-panel-advanced-settings__text">
|
||||
{i18n('ENABLE_USER_SYSTEM')} <InfoTooltip text={i18n('ENABLE_USER_SYSTEM_DESCRIPTION')} />
|
||||
</span>
|
||||
<ToggleButton className="admin-panel-advanced-settings__toggle-button" value={this.props.config['user-system-enabled']} onChange={this.onToggleButtonUserSystemChange.bind(this)}/>
|
||||
</div>
|
||||
<div className="col-md-6 admin-panel-advanced-settings__mandatory-login">
|
||||
<Checkbox
|
||||
label={i18n('ENABLE_MANDATORY_LOGIN')}
|
||||
disabled={!config['registration']}
|
||||
className="admin-panel-advanced-settings__mandatory-login__checkbox"
|
||||
value={config['mandatory-login']}
|
||||
onChange={this.onCheckboxMandatoryLoginChange.bind(this)}
|
||||
wrapInLabel />
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<div className="admin-panel-advanced-settings__registration">
|
||||
<span className="admin-panel-advanced-settings__text">{i18n('ENABLE_USER_REGISTRATION')}</span>
|
||||
<ToggleButton className="admin-panel-advanced-settings__toggle-button" value={this.props.config['registration']} onChange={this.onToggleButtonRegistrationChange.bind(this)}/>
|
||||
<Checkbox
|
||||
label={i18n('ENABLE_USER_REGISTRATION')}
|
||||
disabled={!config['mandatory-login']}
|
||||
className="admin-panel-advanced-settings__registration__checkbox"
|
||||
value={config['registration']}
|
||||
onChange={this.onCheckboxRegistrationChange.bind(this)}
|
||||
wrapInLabel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -65,7 +75,7 @@ class AdminPanelAdvancedSettings extends React.Component {
|
||||
<div className="admin-panel-advanced-settings__text">
|
||||
{i18n('INCLUDE_USERS_VIA_CSV')} <InfoTooltip text={i18n('CSV_DESCRIPTION')} />
|
||||
</div>
|
||||
<FileUploader className="admin-panel-advanced-settings__button" text="Upload" onChange={this.onImportCSV.bind(this)}/>
|
||||
<FileUploader className="admin-panel-advanced-settings__button" text="Upload" onChange={this.onImportCSV.bind(this)} />
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<div className="admin-panel-advanced-settings__text">{i18n('BACKUP_DATABASE')}</div>
|
||||
@ -80,12 +90,21 @@ class AdminPanelAdvancedSettings extends React.Component {
|
||||
<span className="separator" />
|
||||
</div>
|
||||
<div className="col-md-12 admin-panel-advanced-settings__api-keys">
|
||||
<div className="col-md-12 admin-panel-advanced-settings__api-keys-title">{i18n('REGISTRATION_API_KEYS')}</div>
|
||||
<div className="col-md-12 admin-panel-advanced-settings__api-keys-title">{i18n('API_KEYS')}</div>
|
||||
<div className="col-md-4">
|
||||
<Listing {...this.getListingProps()} />
|
||||
</div>
|
||||
<div className="col-md-8">
|
||||
{(this.state.selectedAPIKey === -1) ? this.renderNoKey() : this.renderKey()}
|
||||
<div className="col-md-8 admin-panel-advanced-settings__api-keys__container">
|
||||
{
|
||||
error ?
|
||||
<Message
|
||||
showMessage={showAPIKeyMessage}
|
||||
onCloseMessage={this.onCloseMessage.bind(this, "showAPIKeyMessage")}
|
||||
type="error">
|
||||
{i18n(error)}
|
||||
</Message> :
|
||||
((selectedAPIKey === -1) ? this.renderNoKey() : this.renderKey())
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -94,8 +113,17 @@ class AdminPanelAdvancedSettings extends React.Component {
|
||||
}
|
||||
|
||||
renderMessage() {
|
||||
const { messageType, messageTitle, messageContent, showMessage } = this.state;
|
||||
|
||||
return (
|
||||
<Message type={this.state.messageType} title={this.state.messageTitle}>{this.state.messageContent}</Message>
|
||||
<Message
|
||||
showMessage={showMessage}
|
||||
onCloseMessage={this.onCloseMessage.bind(this, "showMessage")}
|
||||
className="admin-panel-advanced-settings__message"
|
||||
type={messageType}
|
||||
title={messageTitle}>
|
||||
{messageContent}
|
||||
</Message>
|
||||
);
|
||||
}
|
||||
|
||||
@ -108,14 +136,31 @@ class AdminPanelAdvancedSettings extends React.Component {
|
||||
}
|
||||
|
||||
renderKey() {
|
||||
let currentAPIKey = this.state.APIKeys[this.state.selectedAPIKey];
|
||||
const { APIKeys, selectedAPIKey } = this.state;
|
||||
const {
|
||||
name,
|
||||
token,
|
||||
canCreateTickets,
|
||||
shouldReturnTicketNumber,
|
||||
canCheckTickets,
|
||||
canCreateUser
|
||||
} = APIKeys[selectedAPIKey];
|
||||
|
||||
return (
|
||||
<div className="admin-panel-advanced-settings__api-keys-info">
|
||||
<div className="admin-panel-advanced-settings__api-keys__container-info">
|
||||
<div className="admin-panel-advanced-settings__api-keys-subtitle">{i18n('NAME_OF_KEY')}</div>
|
||||
<div className="admin-panel-advanced-settings__api-keys-data">{currentAPIKey.name}</div>
|
||||
<div className="admin-panel-advanced-settings__api-keys-data">{name}</div>
|
||||
<div className="admin-panel-advanced-settings__api-keys-subtitle">{i18n('KEY')}</div>
|
||||
<div className="admin-panel-advanced-settings__api-keys-data">{currentAPIKey.token}</div>
|
||||
<div className="admin-panel-advanced-settings__api-keys-data">{token}</div>
|
||||
<div className="admin-panel-advanced-settings__api-keys-subtitle">{i18n('PERMISSIONS')}</div>
|
||||
<div className="admin-panel-advanced-settings__api-keys__permissions">
|
||||
<FormField className="admin-panel-advanced-settings__api-keys__permissions__item" value={canCreateTickets*1} label={i18n('TICKET_CREATION_PERMISSION')} field='checkbox' />
|
||||
<FormField value={shouldReturnTicketNumber*1} label={i18n('TICKET_NUMBER_RETURN_PERMISSION')} field='checkbox' />
|
||||
</div>
|
||||
<div className="admin-panel-advanced-settings__api-keys__permissions">
|
||||
<FormField className="admin-panel-advanced-settings__api-keys__permissions__item" value={canCheckTickets*1} label={i18n('TICKET_CHECK_PERMISSION')} field='checkbox' />
|
||||
<FormField value={canCreateUser*1} label={i18n('USER_CREATION_PERMISSION')} field='checkbox' />
|
||||
</div>
|
||||
<Button className="admin-panel-advanced-settings__api-keys-button" size="medium" onClick={this.onDeleteKeyClick.bind(this)}>
|
||||
{i18n('DELETE')}
|
||||
</Button>
|
||||
@ -125,7 +170,7 @@ class AdminPanelAdvancedSettings extends React.Component {
|
||||
|
||||
getListingProps() {
|
||||
return {
|
||||
title: i18n('REGISTRATION_API_KEYS'),
|
||||
title: i18n('API_KEYS'),
|
||||
enableAddNew: true,
|
||||
items: this.state.APIKeys.map((item) => {
|
||||
return {
|
||||
@ -134,7 +179,7 @@ class AdminPanelAdvancedSettings extends React.Component {
|
||||
};
|
||||
}),
|
||||
selectedIndex: this.state.selectedAPIKey,
|
||||
onChange: index => this.setState({selectedAPIKey: index}),
|
||||
onChange: index => this.setState({selectedAPIKey: index, error:''}),
|
||||
onAddClick: this.openAPIKeyModal.bind(this)
|
||||
};
|
||||
}
|
||||
@ -142,18 +187,51 @@ class AdminPanelAdvancedSettings extends React.Component {
|
||||
openAPIKeyModal() {
|
||||
ModalContainer.openModal(
|
||||
<Form className="admin-panel-advanced-settings__api-keys-modal" onSubmit={this.addAPIKey.bind(this)}>
|
||||
<Header title={i18n('ADD_API_KEY')} description={i18n('ADD_API_KEY_DESCRIPTION')}/>
|
||||
<FormField name="name" label={i18n('NAME_OF_KEY')} validation="DEFAULT" required fieldProps={{size: 'large'}}/>
|
||||
<SubmitButton type="secondary">{i18n('SUBMIT')}</SubmitButton>
|
||||
</Form>
|
||||
<Header title={i18n('ADD_API_KEY')} description={i18n('ADD_API_KEY_DESCRIPTION')} />
|
||||
<FormField name="name" label={i18n('NAME_OF_KEY')} validation="DEFAULT" required fieldProps={{size: 'large'}} />
|
||||
<div className="admin-panel-advanced-settings__api-keys__permissions">
|
||||
<FormField className = "admin-panel-advanced-settings__api-keys__permissions__item" name="createTicketPermission" label={i18n('TICKET_CREATION_PERMISSION')} field='checkbox' />
|
||||
<FormField name="ticketNumberPermission" label={i18n('TICKET_NUMBER_RETURN_PERMISSION')} field='checkbox' />
|
||||
</div>
|
||||
<div className="admin-panel-advanced-settings__api-keys__permissions" >
|
||||
<FormField className = "admin-panel-advanced-settings__api-keys__permissions__item" name="checkTicketPermission" label={i18n('TICKET_CHECK_PERMISSION')} field='checkbox' />
|
||||
<FormField name="userPermission" label={i18n('USER_CREATION_PERMISSION')} field='checkbox' />
|
||||
</div>
|
||||
<div className="admin-panel-advanced-settings__api-keys__buttons-container">
|
||||
<Button
|
||||
className="admin-panel-advanced-settings__api-keys__cancel-button"
|
||||
onClick={(e) => {e.preventDefault(); ModalContainer.closeModal();}}
|
||||
type='link'
|
||||
size="medium">
|
||||
{i18n('CANCEL')}
|
||||
</Button>
|
||||
<SubmitButton className="admin-panel-advanced-settings__api-keys-modal__submit-button" type="secondary">{i18n('SUBMIT')}</SubmitButton>
|
||||
</div>
|
||||
</Form>,
|
||||
{
|
||||
closeButton: {
|
||||
showCloseButton: true
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
addAPIKey({name}) {
|
||||
addAPIKey({name,userPermission,createTicketPermission,checkTicketPermission,ticketNumberPermission}) {
|
||||
ModalContainer.closeModal();
|
||||
|
||||
this.setState({
|
||||
error: ''
|
||||
});
|
||||
|
||||
API.call({
|
||||
path: '/system/add-api-key',
|
||||
data: {name}
|
||||
data: {
|
||||
name,
|
||||
canCreateUsers: userPermission*1,
|
||||
canCreateTickets: createTicketPermission*1,
|
||||
canCheckTickets: checkTicketPermission*1,
|
||||
shouldReturnTicketNumber: ticketNumberPermission*1
|
||||
}
|
||||
}).then(this.getAllKeys.bind(this));
|
||||
}
|
||||
|
||||
@ -161,15 +239,20 @@ class AdminPanelAdvancedSettings extends React.Component {
|
||||
API.call({
|
||||
path: '/system/get-api-keys',
|
||||
data: {}
|
||||
}).then(this.onRetrieveSuccess.bind(this));
|
||||
}).then(this.onRetrieveSuccess.bind(this))
|
||||
}
|
||||
|
||||
onDeleteKeyClick() {
|
||||
const {
|
||||
APIKeys,
|
||||
selectedAPIKey
|
||||
} = this.state;
|
||||
|
||||
AreYouSure.openModal(null, () => {
|
||||
API.call({
|
||||
return API.call({
|
||||
path: '/system/delete-api-key',
|
||||
data: {
|
||||
name: this.state.APIKeys[this.state.selectedAPIKey].name
|
||||
name: APIKeys[selectedAPIKey].name
|
||||
}
|
||||
}).then(this.getAllKeys.bind(this));
|
||||
});
|
||||
@ -178,21 +261,24 @@ class AdminPanelAdvancedSettings extends React.Component {
|
||||
onRetrieveSuccess(result) {
|
||||
this.setState({
|
||||
APIKeys: result.data,
|
||||
selectedAPIKey: -1
|
||||
selectedAPIKey: -1,
|
||||
error: null
|
||||
});
|
||||
}
|
||||
|
||||
onToggleButtonUserSystemChange() {
|
||||
AreYouSure.openModal(null, this.onAreYouSureUserSystemOk.bind(this), 'secure');
|
||||
onCheckboxMandatoryLoginChange() {
|
||||
AreYouSure.openModal(null, this.onAreYouSureMandatoryLoginOk.bind(this), 'secure');
|
||||
}
|
||||
|
||||
onToggleButtonRegistrationChange() {
|
||||
onCheckboxRegistrationChange() {
|
||||
AreYouSure.openModal(null, this.onAreYouSureRegistrationOk.bind(this), 'secure');
|
||||
}
|
||||
|
||||
onAreYouSureUserSystemOk(password) {
|
||||
API.call({
|
||||
path: this.props.config['user-system-enabled'] ? '/system/disable-user-system' : '/system/enable-user-system',
|
||||
onAreYouSureMandatoryLoginOk(password) {
|
||||
const { config, dispatch } = this.props;
|
||||
|
||||
return API.call({
|
||||
path: config['mandatory-login'] ? '/system/disable-mandatory-login' : '/system/enable-mandatory-login',
|
||||
data: {
|
||||
password: password
|
||||
}
|
||||
@ -200,26 +286,30 @@ class AdminPanelAdvancedSettings extends React.Component {
|
||||
this.setState({
|
||||
messageType: 'success',
|
||||
messageTitle: null,
|
||||
messageContent: this.props.config['user-system-enabled'] ? i18n('USER_SYSTEM_DISABLED') : i18n('USER_SYSTEM_ENABLED')
|
||||
showMessage: true,
|
||||
messageContent: config['mandatory-login'] ? i18n('MANDATORY_LOGIN_DISABLED') : i18n('MANDATORY_LOGIN_ENABLED')
|
||||
});
|
||||
this.props.dispatch(ConfigActions.updateData());
|
||||
}).catch(() => this.setState({messageType: 'error', messageTitle: null, messageContent: i18n('ERROR_UPDATING_SETTINGS')}));
|
||||
dispatch(ConfigActions.updateData());
|
||||
}).catch(() => this.setState({messageType: 'error', showMessage: true, messageTitle: null, messageContent: i18n('ERROR_UPDATING_SETTINGS')}));
|
||||
}
|
||||
|
||||
onAreYouSureRegistrationOk(password) {
|
||||
API.call({
|
||||
path: this.props.config['registration'] ? '/system/disable-registration' : '/system/enable-registration',
|
||||
const { config, dispatch } = this.props;
|
||||
|
||||
return API.call({
|
||||
path: config['registration'] ? '/system/disable-registration' : '/system/enable-registration',
|
||||
data: {
|
||||
password: password
|
||||
}
|
||||
}).then(() => {
|
||||
this.setState({
|
||||
messageType: 'success',
|
||||
showMessage: true,
|
||||
messageTitle: null,
|
||||
messageContent: this.props.config['registration'] ? i18n('REGISTRATION_DISABLED') : i18n('REGISTRATION_ENABLED')
|
||||
messageContent: config['registration'] ? i18n('REGISTRATION_DISABLED') : i18n('REGISTRATION_ENABLED')
|
||||
});
|
||||
this.props.dispatch(ConfigActions.updateData());
|
||||
}).catch(() => this.setState({messageType: 'error', messageTitle: null, messageContent: i18n('ERROR_UPDATING_SETTINGS')}));
|
||||
dispatch(ConfigActions.updateData());
|
||||
}).catch(() => this.setState({messageType: 'error', showMessage: true, messageTitle: null, messageContent: i18n('ERROR_UPDATING_SETTINGS')}));
|
||||
}
|
||||
|
||||
onImportCSV(event) {
|
||||
@ -227,27 +317,35 @@ class AdminPanelAdvancedSettings extends React.Component {
|
||||
}
|
||||
|
||||
onAreYouSureCSVOk(file, password) {
|
||||
API.call({
|
||||
return API.call({
|
||||
path: '/system/csv-import',
|
||||
dataAsForm: true,
|
||||
data: {
|
||||
file: file,
|
||||
password: password
|
||||
file,
|
||||
password
|
||||
}
|
||||
})
|
||||
.then((result) => this.setState({
|
||||
messageType: 'success',
|
||||
messageType: 'success',
|
||||
showMessage: true,
|
||||
messageTitle: i18n('SUCCESS_IMPORTING_CSV_DESCRIPTION'),
|
||||
messageContent: (result.data.length) ? (
|
||||
<div>
|
||||
{i18n('ERRORS_FOUND')}
|
||||
<ul>
|
||||
{result.data.map((error) => <li>{error}</li>)}
|
||||
{result.data.map((error, index) => <li key={`csv-file__key-${index}`} >{error}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
) : null
|
||||
}))
|
||||
.catch(() => this.setState({messageType: 'error', messageTitle: null, messageContent: i18n('INVALID_FILE')}));
|
||||
.catch((error) => {
|
||||
this.setState({
|
||||
messageType: 'error',
|
||||
showMessage: true,
|
||||
messageTitle: null,
|
||||
messageContent: i18n(error.message)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
onBackupDatabase() {
|
||||
@ -270,17 +368,22 @@ class AdminPanelAdvancedSettings extends React.Component {
|
||||
}
|
||||
|
||||
onAreYouSureDeleteAllUsersOk(password) {
|
||||
API.call({
|
||||
return API.call({
|
||||
path: '/system/delete-all-users',
|
||||
data: {
|
||||
password: password
|
||||
}
|
||||
}).then(() => this.setState({messageType: 'success', messageTitle: null, messageContent: i18n('SUCCESS_DELETING_ALL_USERS')}
|
||||
)).catch(() => this.setState({messageType: 'error', messageTitle: null, messageContent: i18n('ERROR_DELETING_ALL_USERS')}));
|
||||
}).then(() => this.setState({messageType: 'success', showMessage: true, messageTitle: null, messageContent: i18n('SUCCESS_DELETING_ALL_USERS')}
|
||||
)).catch(() => this.setState({messageType: 'error', showMessage: true, messageTitle: null, messageContent: i18n('ERROR_DELETING_ALL_USERS')}));
|
||||
}
|
||||
|
||||
onCloseMessage(showMessage) {
|
||||
this.setState({
|
||||
[showMessage]: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default connect((store) => {
|
||||
return {
|
||||
config: store.config
|
||||
|
@ -1,7 +1,7 @@
|
||||
@import "../../../../scss/vars";
|
||||
|
||||
.admin-panel-advanced-settings {
|
||||
&__user-system-enabled {
|
||||
&__mandatory-login {
|
||||
|
||||
}
|
||||
|
||||
@ -9,13 +9,6 @@
|
||||
|
||||
}
|
||||
|
||||
&__toggle-button {
|
||||
display: inline-block;
|
||||
margin-left: 20px;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
&__text {
|
||||
margin-top: 30px;
|
||||
margin-bottom: 20px;
|
||||
@ -28,13 +21,20 @@
|
||||
|
||||
&__api-keys {
|
||||
|
||||
&__buttons-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: $font-size--bg;
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&-info {
|
||||
&__container-info {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@ -52,8 +52,21 @@
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
&__permissions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 20px;
|
||||
&__item {
|
||||
margin-right: 25px;
|
||||
}
|
||||
}
|
||||
|
||||
&-modal {
|
||||
min-width: 500px;
|
||||
min-width: 400px;
|
||||
|
||||
&__submit-button {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&-none {
|
||||
@ -61,4 +74,36 @@
|
||||
font-size: $font-size--md;
|
||||
}
|
||||
}
|
||||
|
||||
&__message {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 415px) {
|
||||
.admin-panel-advanced-settings {
|
||||
&__api-keys {
|
||||
|
||||
&-button {
|
||||
margin-bottom: 30px;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
&__container {
|
||||
padding: 30px 0;
|
||||
|
||||
&-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&-subtitle {
|
||||
margin: 10px 0 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -0,0 +1,156 @@
|
||||
import React from 'react';
|
||||
|
||||
import i18n from 'lib-app/i18n';
|
||||
import API from 'lib-app/api-call';
|
||||
|
||||
import Button from 'core-components/button';
|
||||
import Header from 'core-components/header';
|
||||
import Form from 'core-components/form';
|
||||
import FormField from 'core-components/form-field';
|
||||
import SubmitButton from 'core-components/submit-button';
|
||||
import ColorSelector from 'core-components/color-selector';
|
||||
|
||||
class AdminPanelCustomTagsModal extends React.Component {
|
||||
static contextTypes = {
|
||||
closeModal: React.PropTypes.func,
|
||||
createTag: React.PropTypes.bool
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
defaultValues: React.PropTypes.object,
|
||||
onTagCreated: React.PropTypes.func
|
||||
};
|
||||
|
||||
state = {
|
||||
form: this.props.defaultValues || {name: '', color: '#ff6900'},
|
||||
loading: false
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
this.renderTagContentPopUp(this.props.createTag)
|
||||
);
|
||||
}
|
||||
|
||||
renderTagContentPopUp(create) {
|
||||
const {
|
||||
form,
|
||||
errors,
|
||||
loading,
|
||||
} = this.state;
|
||||
let title, description, nameRequired, submitFunction;
|
||||
|
||||
if(create) {
|
||||
title = i18n('ADD_CUSTOM_TAG');
|
||||
description = i18n('DESCRIPTION_ADD_CUSTOM_TAG');
|
||||
submitFunction = this.onSubmitNewTag.bind(this);
|
||||
nameRequired = true;
|
||||
} else {
|
||||
title = i18n('EDIT_CUSTOM_TAG');
|
||||
description = i18n('DESCRIPTION_EDIT_CUSTOM_TAG');
|
||||
nameRequired = false;
|
||||
submitFunction = this.onSubmitEditTag.bind(this);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='admin-panel-custom-tags-modal'>
|
||||
<Header title={title} description={description} />
|
||||
<Form
|
||||
values={form}
|
||||
onChange={this.onFormChange.bind(this)}
|
||||
onSubmit={submitFunction}
|
||||
errors={errors}
|
||||
onValidateErrors={errors => this.setState({errors})}
|
||||
loading={loading}>
|
||||
<FormField name="name" label={i18n('NAME')} fieldProps={{size: 'large'}} required={nameRequired} />
|
||||
<FormField name="color" label={i18n('COLOR')} decorator={ColorSelector} />
|
||||
<div className='admin-panel-custom-tags-modal__actions'>
|
||||
<SubmitButton type="secondary" size="small">
|
||||
{i18n('SAVE')}
|
||||
</SubmitButton>
|
||||
<Button onClick={this.onDiscardClick.bind(this)} size="small">
|
||||
{i18n('CANCEL')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onFormChange(form) {
|
||||
this.setState({
|
||||
form
|
||||
});
|
||||
}
|
||||
|
||||
onSubmitEditTag(form) {
|
||||
this.setState({
|
||||
loading: true
|
||||
});
|
||||
|
||||
API.call({
|
||||
path: '/ticket/edit-tag',
|
||||
data: {
|
||||
tagId: this.props.id,
|
||||
name: form.name,
|
||||
color: form.color,
|
||||
}
|
||||
}).then(() => {
|
||||
this.context.closeModal();
|
||||
|
||||
if(this.props.onTagChange) {
|
||||
this.props.onTagChange();
|
||||
}
|
||||
}).catch((result) => {
|
||||
|
||||
this.setState({
|
||||
loading: false,
|
||||
errors: {
|
||||
'name': result.message
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
onSubmitNewTag(form) {
|
||||
this.setState({
|
||||
loading: true
|
||||
});
|
||||
|
||||
API.call({
|
||||
path: '/ticket/create-tag',
|
||||
data: {
|
||||
name: form.name,
|
||||
color: form.color,
|
||||
}
|
||||
}).then(() => {
|
||||
this.context.closeModal();
|
||||
|
||||
if(this.props.onTagCreated) {
|
||||
this.props.onTagCreated();
|
||||
}
|
||||
|
||||
}).catch((result) => {
|
||||
|
||||
this.setState({
|
||||
loading: false,
|
||||
errors: {
|
||||
'name': result.message
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
onDiscardClick(event) {
|
||||
event.preventDefault();
|
||||
this.context.closeModal();
|
||||
this.setState({
|
||||
loading: false,
|
||||
errors: {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default AdminPanelCustomTagsModal;
|
@ -0,0 +1,8 @@
|
||||
.admin-panel-custom-tags-modal {
|
||||
|
||||
&__actions{
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user