Huge optimizations, code organization and refactoring

This commit is contained in:
Adrian Woźniak 2021-01-06 18:47:54 +01:00
parent 5a7ddb03e1
commit 01ce1794cd
116 changed files with 4588 additions and 3702 deletions

189
Cargo.lock generated
View File

@ -10,7 +10,7 @@ dependencies = [
"actix-rt", "actix-rt",
"actix_derive", "actix_derive",
"bitflags", "bitflags",
"bytes 0.5.6", "bytes",
"crossbeam-channel", "crossbeam-channel",
"derive_more", "derive_more",
"futures 0.3.8", "futures 0.3.8",
@ -34,7 +34,7 @@ dependencies = [
"actix-rt", "actix-rt",
"actix_derive", "actix_derive",
"bitflags", "bitflags",
"bytes 0.5.6", "bytes",
"crossbeam-channel", "crossbeam-channel",
"derive_more", "derive_more",
"futures-channel", "futures-channel",
@ -57,7 +57,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e55f0a5c2ca15795035d90c46bd0e73a5123b72f68f12596d6ba5282051380" checksum = "09e55f0a5c2ca15795035d90c46bd0e73a5123b72f68f12596d6ba5282051380"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"bytes 0.5.6", "bytes",
"futures-core", "futures-core",
"futures-sink", "futures-sink",
"log", "log",
@ -72,7 +72,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78d1833b3838dbe990df0f1f87baf640cf6146e898166afe401839d1b001e570" checksum = "78d1833b3838dbe990df0f1f87baf640cf6146e898166afe401839d1b001e570"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"bytes 0.5.6", "bytes",
"futures-core", "futures-core",
"futures-sink", "futures-sink",
"log", "log",
@ -135,14 +135,14 @@ dependencies = [
[[package]] [[package]]
name = "actix-files" name = "actix-files"
version = "0.4.1" version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d031468a7859f71674e5531bd05137e0ea5de05ec9a917314330b88c582e2e0a" checksum = "c51e8a9146c12fce92a6e4c24b8c4d9b05268130bfd8d61bc587e822c32ce689"
dependencies = [ dependencies = [
"actix-service", "actix-service",
"actix-web", "actix-web",
"bitflags", "bitflags",
"bytes 0.5.6", "bytes",
"derive_more", "derive_more",
"futures-core", "futures-core",
"futures-util", "futures-util",
@ -167,7 +167,7 @@ dependencies = [
"actix-utils 1.0.6", "actix-utils 1.0.6",
"base64 0.11.0", "base64 0.11.0",
"bitflags", "bitflags",
"bytes 0.5.6", "bytes",
"chrono", "chrono",
"copyless", "copyless",
"derive_more", "derive_more",
@ -212,8 +212,8 @@ dependencies = [
"base64 0.13.0", "base64 0.13.0",
"bitflags", "bitflags",
"brotli2", "brotli2",
"bytes 0.5.6", "bytes",
"cookie 0.14.3", "cookie",
"copyless", "copyless",
"derive_more", "derive_more",
"either", "either",
@ -263,7 +263,7 @@ dependencies = [
"actix-service", "actix-service",
"actix-utils 2.0.0", "actix-utils 2.0.0",
"actix-web", "actix-web",
"bytes 0.5.6", "bytes",
"derive_more", "derive_more",
"futures-util", "futures-util",
"httparse", "httparse",
@ -381,7 +381,7 @@ dependencies = [
"actix-rt", "actix-rt",
"actix-service", "actix-service",
"bitflags", "bitflags",
"bytes 0.5.6", "bytes",
"either", "either",
"futures 0.3.8", "futures 0.3.8",
"log", "log",
@ -399,7 +399,7 @@ dependencies = [
"actix-rt", "actix-rt",
"actix-service", "actix-service",
"bitflags", "bitflags",
"bytes 0.5.6", "bytes",
"either", "either",
"futures-channel", "futures-channel",
"futures-sink", "futures-sink",
@ -428,7 +428,7 @@ dependencies = [
"actix-utils 2.0.0", "actix-utils 2.0.0",
"actix-web-codegen", "actix-web-codegen",
"awc", "awc",
"bytes 0.5.6", "bytes",
"derive_more", "derive_more",
"encoding_rs", "encoding_rs",
"futures-channel", "futures-channel",
@ -458,7 +458,7 @@ dependencies = [
"actix-codec 0.3.0", "actix-codec 0.3.0",
"actix-http 2.2.0", "actix-http 2.2.0",
"actix-web", "actix-web",
"bytes 0.5.6", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"pin-project 0.4.27", "pin-project 0.4.27",
@ -488,9 +488,9 @@ dependencies = [
[[package]] [[package]]
name = "addr2line" name = "addr2line"
version = "0.14.0" version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c0929d69e78dd9bf5408269919fcbcaeb2e35e5d43e5815517cdc6a8e11a423" checksum = "a55f82cfe485775d02112886f4169bde0c5894d75e79ead7eafe7e40a25e45f7"
dependencies = [ dependencies = [
"gimli", "gimli",
] ]
@ -510,6 +510,30 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "amazon-actor"
version = "0.1.0"
dependencies = [
"actix 0.10.0",
"actix-rt",
"actix-service",
"actix-web-actors",
"bytes",
"env_logger",
"futures 0.3.8",
"jirs-config",
"libc",
"log",
"openssl-sys",
"pretty_env_logger",
"rusoto_core",
"rusoto_s3",
"rusoto_signature",
"serde",
"tokio",
"uuid 0.8.1",
]
[[package]] [[package]]
name = "ansi_term" name = "ansi_term"
version = "0.11.0" version = "0.11.0"
@ -582,7 +606,7 @@ dependencies = [
"actix-rt", "actix-rt",
"actix-service", "actix-service",
"base64 0.13.0", "base64 0.13.0",
"bytes 0.5.6", "bytes",
"cfg-if 1.0.0", "cfg-if 1.0.0",
"derive_more", "derive_more",
"futures-core", "futures-core",
@ -742,11 +766,11 @@ dependencies = [
[[package]] [[package]]
name = "buf-min" name = "buf-min"
version = "0.2.0" version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "881e704e61d0fb41d7c6c9ae2bd790eb8c13dc974ae102fb98c788b4fdea4349" checksum = "fa17aa1cf56bdd6bb30518767d00e58019d326f3f05d8c3e0730b549d332ea83"
dependencies = [ dependencies = [
"bytes 0.6.0", "bytes",
] ]
[[package]] [[package]]
@ -779,19 +803,13 @@ version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e4cec68f03f32e44924783795810fa50a7035d8c8ebe78580ad7e6c703fba38" checksum = "0e4cec68f03f32e44924783795810fa50a7035d8c8ebe78580ad7e6c703fba38"
[[package]]
name = "bytes"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0dcbc35f504eb6fc275a6d20e4ebcda18cf50d40ba6fabff8c711fa16cb3b16"
[[package]] [[package]]
name = "bytestring" name = "bytestring"
version = "0.1.5" version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7c05fa5172da78a62d9949d662d2ac89d4cc7355d7b49adee5163f1fb3f363" checksum = "fc7c05fa5172da78a62d9949d662d2ac89d4cc7355d7b49adee5163f1fb3f363"
dependencies = [ dependencies = [
"bytes 0.5.6", "bytes",
] ]
[[package]] [[package]]
@ -899,16 +917,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
[[package]]
name = "cookie"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c60ef6d0bbf56ad2674249b6bb74f2c6aeb98b98dd57b5d3e37cace33011d69"
dependencies = [
"percent-encoding",
"time 0.2.23",
]
[[package]] [[package]]
name = "cookie" name = "cookie"
version = "0.14.3" version = "0.14.3"
@ -1146,9 +1154,9 @@ checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
[[package]] [[package]]
name = "dtoa" name = "dtoa"
version = "0.4.6" version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "134951f4028bdadb9b84baf4232681efbf277da25144b9b0ad65df75946c422b" checksum = "88d7ed2934d741c6b37e33e3832298e8850b53fd2d2bea03873375596c7cea4e"
[[package]] [[package]]
name = "either" name = "either"
@ -1324,7 +1332,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"actix 0.10.0", "actix 0.10.0",
"actix-files", "actix-files",
"bytes 0.5.6", "bytes",
"env_logger", "env_logger",
"futures 0.3.8", "futures 0.3.8",
"jirs-config", "jirs-config",
@ -1653,7 +1661,7 @@ version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e4728fd124914ad25e99e3d15a9361a879f6620f63cb56bbb08f95abb97a535" checksum = "5e4728fd124914ad25e99e3d15a9361a879f6620f63cb56bbb08f95abb97a535"
dependencies = [ dependencies = [
"bytes 0.5.6", "bytes",
"fnv", "fnv",
"futures-core", "futures-core",
"futures-sink", "futures-sink",
@ -1752,7 +1760,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84129d298a6d57d246960ff8eb831ca4af3f96d29e2e28848dae275408658e26" checksum = "84129d298a6d57d246960ff8eb831ca4af3f96d29e2e28848dae275408658e26"
dependencies = [ dependencies = [
"bytes 0.5.6", "bytes",
"fnv", "fnv",
"itoa", "itoa",
] ]
@ -1763,7 +1771,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13d5ff830006f7646652e057693569bfe0d51760c0085a071769d142a205111b" checksum = "13d5ff830006f7646652e057693569bfe0d51760c0085a071769d142a205111b"
dependencies = [ dependencies = [
"bytes 0.5.6", "bytes",
"http", "http",
] ]
@ -1794,7 +1802,7 @@ version = "0.13.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ad767baac13b44d4529fcf58ba2cd0995e36e7b435bc5b039de6f47e880dbf" checksum = "f6ad767baac13b44d4529fcf58ba2cd0995e36e7b435bc5b039de6f47e880dbf"
dependencies = [ dependencies = [
"bytes 0.5.6", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"futures-util", "futures-util",
@ -1818,7 +1826,7 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d979acc56dcb5b8dddba3917601745e877576475aa046df3226eabdecef78eed" checksum = "d979acc56dcb5b8dddba3917601745e877576475aa046df3226eabdecef78eed"
dependencies = [ dependencies = [
"bytes 0.5.6", "bytes",
"hyper", "hyper",
"native-tls", "native-tls",
"tokio", "tokio",
@ -1916,9 +1924,9 @@ dependencies = [
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "0.4.6" version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6f3ad7b9d11a0c00842ff8de1b60ee58661048eb8049ed33c73594f359d7e6" checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736"
[[package]] [[package]]
name = "jirs-cli" name = "jirs-cli"
@ -1973,6 +1981,7 @@ dependencies = [
"actix-service", "actix-service",
"actix-web", "actix-web",
"actix-web-actors", "actix-web-actors",
"amazon-actor",
"async-trait", "async-trait",
"bigdecimal", "bigdecimal",
"bincode", "bincode",
@ -2014,7 +2023,6 @@ version = "0.1.0"
dependencies = [ dependencies = [
"bincode", "bincode",
"chrono", "chrono",
"comrak",
"futures 0.1.30", "futures 0.1.30",
"jirs-data", "jirs-data",
"js-sys", "js-sys",
@ -2320,9 +2328,9 @@ dependencies = [
[[package]] [[package]]
name = "native-tls" name = "native-tls"
version = "0.2.6" version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fcc7939b5edc4e4f86b1b4a04bb1498afaaf871b1a6691838ed06fcb48d3a3f" checksum = "b8d96b2e1c8da3957d58100b09f102c6d9cfdfced01b7ec5a8974044bb09dbd4"
dependencies = [ dependencies = [
"lazy_static", "lazy_static",
"libc", "libc",
@ -2469,9 +2477,9 @@ checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
[[package]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.31" version = "0.10.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d008f51b1acffa0d3450a68606e6a51c123012edaacb0f4e1426bd978869187" checksum = "038d43985d1ddca7a9900630d8cd031b56e4794eecc2e9ea39dd17aa04399a70"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"cfg-if 1.0.0", "cfg-if 1.0.0",
@ -2498,9 +2506,9 @@ dependencies = [
[[package]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.59" version = "0.9.60"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de52d8eabd217311538a39bba130d7dea1f1e118010fee7a033d966845e7d5fe" checksum = "921fc71883267538946025deffb622905ecad223c28efbfdef9bb59a0175f3e6"
dependencies = [ dependencies = [
"autocfg 1.0.1", "autocfg 1.0.1",
"cc", "cc",
@ -2734,9 +2742,9 @@ dependencies = [
[[package]] [[package]]
name = "pulldown-cmark" name = "pulldown-cmark"
version = "0.7.2" version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca36dea94d187597e104a5c8e4b07576a8a45aa5db48a65e12940d3eb7461f55" checksum = "ffade02495f22453cd593159ea2f59827aae7f53fa8323f756799b670881dcf8"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"getopts", "getopts",
@ -3030,7 +3038,7 @@ checksum = "e977941ee0658df96fca7291ecc6fc9a754600b21ad84b959eb1dbbc9d5abcc7"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.12.3", "base64 0.12.3",
"bytes 0.5.6", "bytes",
"crc32fast", "crc32fast",
"futures 0.3.8", "futures 0.3.8",
"http", "http",
@ -3077,7 +3085,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1146e37a7c1df56471ea67825fe09bbbd37984b5f6e201d8b2e0be4ee15643d8" checksum = "1146e37a7c1df56471ea67825fe09bbbd37984b5f6e201d8b2e0be4ee15643d8"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"bytes 0.5.6", "bytes",
"futures 0.3.8", "futures 0.3.8",
"rusoto_core", "rusoto_core",
"xml-rs", "xml-rs",
@ -3090,7 +3098,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a740a88dde8ded81b6f2cff9cd5e054a5a2e38a38397260f7acdd2c85d17dd" checksum = "97a740a88dde8ded81b6f2cff9cd5e054a5a2e38a38397260f7acdd2c85d17dd"
dependencies = [ dependencies = [
"base64 0.12.3", "base64 0.12.3",
"bytes 0.5.6", "bytes",
"futures 0.3.8", "futures 0.3.8",
"hex", "hex",
"hmac", "hmac",
@ -3212,12 +3220,12 @@ dependencies = [
[[package]] [[package]]
name = "seed" name = "seed"
version = "0.7.0" version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "882f4569a394bbb2f15f2fc410e0fbcef178fe24fc2d91599607a598443c6df8" checksum = "3b599be9cc57456f4b7fc99b8abfb154d4819f7b6c147e80be5580663dad4536"
dependencies = [ dependencies = [
"console_error_panic_hook", "console_error_panic_hook",
"cookie 0.13.3", "cookie",
"dbg", "dbg",
"enclose", "enclose",
"futures 0.3.8", "futures 0.3.8",
@ -3273,9 +3281,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.60" version = "1.0.61"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1500e84d27fe482ed1dc791a56eddc2f230046a040fa908c08bda1d9fb615779" checksum = "4fceb2595057b6891a4ee808f70054bd2d12f0e97f1cbb78689b59f676df325a"
dependencies = [ dependencies = [
"itoa", "itoa",
"ryu", "ryu",
@ -3395,10 +3403,16 @@ dependencies = [
] ]
[[package]] [[package]]
name = "standback" name = "spin"
version = "0.2.13" version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf906c8b8fc3f6ecd1046e01da1d8ddec83e48c8b08b84dcc02b585a6bedf5a8" checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
[[package]]
name = "standback"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c66a8cff4fa24853fdf6b51f75c6d7f8206d7c75cab4e467bcd7f25c2b1febe0"
dependencies = [ dependencies = [
"version_check 0.9.2", "version_check 0.9.2",
] ]
@ -3466,9 +3480,9 @@ checksum = "1e81da0851ada1f3e9d4312c704aa4f8806f0f9d69faaf8df2f3464b4a9437c2"
[[package]] [[package]]
name = "syn" name = "syn"
version = "1.0.55" version = "1.0.56"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a571a711dddd09019ccc628e1b17fe87c59b09d513c06c026877aa708334f37a" checksum = "a9802ddde94170d186eeee5005b798d9c159fa970403f1be19976d0cfb939b72"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -3555,18 +3569,18 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.22" version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e9ae34b84616eedaaf1e9dd6026dbe00dcafa92aa0c8077cb69df1fcfe5e53e" checksum = "76cc616c6abf8c8928e2fdcc0dbfab37175edd8fb49a4641066ad1364fdab146"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl",
] ]
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "1.0.22" version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ba20f23e85b10754cd195504aebf6a27e2e6cbe28c17778a0c930724628dd56" checksum = "9be73a2caec27583d0046ef3796c3794f868a5bc813db689eed00c7631275cd1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -3661,7 +3675,7 @@ version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "099837d3464c16a808060bb3f02263b412f6fafcb5d01c533d309985fbeebe48" checksum = "099837d3464c16a808060bb3f02263b412f6fafcb5d01c533d309985fbeebe48"
dependencies = [ dependencies = [
"bytes 0.5.6", "bytes",
"fnv", "fnv",
"futures-core", "futures-core",
"iovec", "iovec",
@ -3705,7 +3719,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "571da51182ec208780505a32528fc5512a8fe1443ab960b3f2f3ef093cd16930" checksum = "571da51182ec208780505a32528fc5512a8fe1443ab960b3f2f3ef093cd16930"
dependencies = [ dependencies = [
"bytes 0.5.6", "bytes",
"futures-core", "futures-core",
"futures-sink", "futures-sink",
"log", "log",
@ -3719,7 +3733,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be8242891f2b6cbef26a2d7e8605133c2c554cd35b3e4948ea892d6d68436499" checksum = "be8242891f2b6cbef26a2d7e8605133c2c554cd35b3e4948ea892d6d68436499"
dependencies = [ dependencies = [
"bytes 0.5.6", "bytes",
"futures-core", "futures-core",
"futures-io", "futures-io",
"futures-sink", "futures-sink",
@ -4002,9 +4016,9 @@ dependencies = [
[[package]] [[package]]
name = "v_escape" name = "v_escape"
version = "0.14.1" version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccca9e73c678b882900cbaec16dae4d3662ace5a17774ac45af04e0f3988fafa" checksum = "f3e0ab5fab1db278a9413d2ea794cb66f471f898c5b020c3c394f6447625d9d4"
dependencies = [ dependencies = [
"buf-min", "buf-min",
"v_escape_derive", "v_escape_derive",
@ -4024,9 +4038,9 @@ dependencies = [
[[package]] [[package]]
name = "v_htmlescape" name = "v_htmlescape"
version = "0.11.0" version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db00c903248abee8499af60bf20d242e7882335bbbffd2614915184cbb207402" checksum = "1f9a8af610ad6f7fc9989c9d2590d9764bc61f294884e9ee93baa58795174572"
dependencies = [ dependencies = [
"cfg-if 1.0.0", "cfg-if 1.0.0",
"v_escape", "v_escape",
@ -4192,26 +4206,21 @@ dependencies = [
"actix-service", "actix-service",
"actix-web", "actix-web",
"actix-web-actors", "actix-web-actors",
"amazon-actor",
"bincode", "bincode",
"bytes 0.5.6", "bytes",
"database-actor", "database-actor",
"env_logger", "env_logger",
"filesystem-actor", "filesystem-actor",
"flate2",
"futures 0.3.8", "futures 0.3.8",
"jirs-config", "jirs-config",
"jirs-data", "jirs-data",
"lazy_static",
"libc", "libc",
"log", "log",
"mail-actor", "mail-actor",
"openssl-sys", "openssl-sys",
"pretty_env_logger", "pretty_env_logger",
"rusoto_core",
"rusoto_s3",
"rusoto_signature",
"serde", "serde",
"syntect",
"tokio", "tokio",
"toml", "toml",
"uuid 0.8.1", "uuid 0.8.1",
@ -4236,10 +4245,12 @@ dependencies = [
"actix-web", "actix-web",
"actix-web-actors", "actix-web-actors",
"bincode", "bincode",
"comrak",
"database-actor", "database-actor",
"env_logger", "env_logger",
"flate2", "flate2",
"futures 0.3.8", "futures 0.3.8",
"highlight-actor",
"jirs-config", "jirs-config",
"jirs-data", "jirs-data",
"lazy_static", "lazy_static",
@ -4248,6 +4259,7 @@ dependencies = [
"mail-actor", "mail-actor",
"openssl-sys", "openssl-sys",
"pretty_env_logger", "pretty_env_logger",
"pulldown-cmark",
"serde", "serde",
"syntect", "syntect",
"toml", "toml",
@ -4263,6 +4275,7 @@ dependencies = [
"cfg-if 0.1.10", "cfg-if 0.1.10",
"libc", "libc",
"memory_units", "memory_units",
"spin",
"winapi 0.3.9", "winapi 0.3.9",
] ]

View File

@ -12,7 +12,6 @@
members = [ members = [
"./jirs-cli", "./jirs-cli",
"./jirs-server", "./jirs-server",
"./jirs-client",
"./jirs-css", "./jirs-css",
"./shared/jirs-config", "./shared/jirs-config",
"./shared/jirs-data", "./shared/jirs-data",
@ -22,5 +21,8 @@ members = [
"./actors/web-actor", "./actors/web-actor",
"./actors/websocket-actor", "./actors/websocket-actor",
"./actors/mail-actor", "./actors/mail-actor",
"./actors/filesystem-actor" "./actors/amazon-actor",
"./actors/filesystem-actor",
# Client
"./jirs-client"
] ]

View File

@ -42,6 +42,14 @@ https://git.sr.ht/~tsumanu/jirs
* Add personal settings to choose MDE (Markdown Editor) or RTE * Add personal settings to choose MDE (Markdown Editor) or RTE
* Add issues and filters * Add issues and filters
##### Version 1.1.1
* Refactor actors
* Extract code highlight to server actor
* Handle upload avatar with stream
* Move config to `./config` directory
* Fix S3 upload with upgraded version of `rusoto`
##### Work Progress ##### Work Progress
* [X] Add Epic * [X] Add Epic

View File

@ -0,0 +1,51 @@
[package]
name = "amazon-actor"
version = "0.1.0"
authors = ["Adrian Wozniak <adrian.wozniak@ita-prog.pl>"]
edition = "2018"
description = "JIRS (Simplified JIRA in Rust) shared data types"
repository = "https://gitlab.com/adrian.wozniak/jirs"
license = "MPL-2.0"
#license-file = "../LICENSE"
[lib]
name = "amazon_actor"
path = "./src/lib.rs"
[dependencies]
serde = "*"
actix = { version = "0.10.0" }
actix-service = { version = "*" }
actix-rt = "1"
actix-web-actors = "*"
bytes = { version = "0.5.6" }
futures = { version = "0.3.8" }
openssl-sys = { version = "*", features = ["vendored"] }
libc = { version = "0.2.0", default-features = false }
log = "0.4"
pretty_env_logger = "0.4"
env_logger = "0.7"
uuid = { version = "0.8.1", features = ["serde", "v4", "v5"] }
[dependencies.jirs-config]
path = "../../shared/jirs-config"
features = ["mail", "web", "local-storage"]
# Amazon S3
[dependencies.rusoto_s3]
version = "0.45.0"
[dependencies.rusoto_core]
version = "0.45.0"
[dependencies.rusoto_signature]
version = "0.45.0"
[dependencies.tokio]
version = "0.2.23"
features = ["tcp", "time", "rt-core", "fs"]

View File

@ -0,0 +1,87 @@
use {
actix,
rusoto_s3::{PutObjectRequest, S3Client, S3},
};
#[derive(Debug)]
pub enum AmazonError {
UploadFailed,
}
pub struct AmazonExecutor;
impl Default for AmazonExecutor {
fn default() -> Self {
Self {}
}
}
impl actix::Actor for AmazonExecutor {
type Context = actix::SyncContext<Self>;
}
#[derive(actix::Message)]
#[rtype(result = "Result<String, AmazonError>")]
pub struct S3PutObject {
pub source: tokio::sync::broadcast::Receiver<bytes::Bytes>,
pub file_name: String,
}
impl actix::Handler<S3PutObject> for AmazonExecutor {
type Result = Result<String, AmazonError>;
fn handle(&mut self, msg: S3PutObject, _ctx: &mut Self::Context) -> Self::Result {
let S3PutObject {
mut source,
file_name,
} = msg;
jirs_config::amazon::config().set_variables();
tokio::runtime::Runtime::new()
.expect("Failed to start amazon agent")
.block_on(async {
let s3 = jirs_config::amazon::config();
log::debug!("{:?}", s3);
// TODO: Unable to upload as stream because there is no size_hint
// use futures::stream::*;
// let stream = source
// .into_stream()
// .map_err(|_e| std::io::Error::from_raw_os_error(1));
let mut v: Vec<u8> = vec![];
use bytes::Buf;
while let Ok(b) = source.recv().await {
v.extend_from_slice(b.bytes())
}
let client = S3Client::new(s3.region());
let put_object = PutObjectRequest {
bucket: s3.bucket.clone(),
key: file_name.clone(),
// body: Some(rusoto_signature::ByteStream::new(stream)),
body: Some(v.into()),
..Default::default()
};
let id = match client.put_object(put_object).await {
Ok(obj) => obj,
Err(e) => {
log::error!("{}", e);
return Err(AmazonError::UploadFailed);
}
};
log::debug!("{:?}", id);
Ok(aws_s3_url(file_name.as_str()))
})
}
}
fn aws_s3_url(key: &str) -> String {
let config = jirs_config::amazon::config();
format!(
"https://{bucket}.s3.{region}.amazonaws.com/{key}",
bucket = config.bucket,
region = config.region_name,
key = key
)
}

View File

@ -1,3 +1,4 @@
use jirs_data::HighlightedCode;
use { use {
actix::{Actor, Handler, SyncContext}, actix::{Actor, Handler, SyncContext},
std::sync::Arc, std::sync::Arc,
@ -45,17 +46,74 @@ impl Actor for HighlightActor {
} }
#[derive(actix::Message)] #[derive(actix::Message)]
#[rtype(result = "Result<Vec<u8>, HighlightError>")] #[rtype(result = "Result<HighlightedCode, HighlightError>")]
pub struct HighlightCode { pub struct HighlightCode {
pub code: String, pub code: String,
pub lang: String, pub lang: String,
} }
impl Handler<HighlightCode> for HighlightActor { impl Handler<HighlightCode> for HighlightActor {
type Result = Result<Vec<u8>, HighlightError>; type Result = Result<HighlightedCode, HighlightError>;
fn handle(&mut self, msg: HighlightCode, _ctx: &mut Self::Context) -> Self::Result { fn handle(&mut self, msg: HighlightCode, _ctx: &mut Self::Context) -> Self::Result {
let res = hi(&msg.code, &msg.lang)?; let res: Vec<(Style, &str)> = hi(&msg.code, &msg.lang)?;
bincode::serialize(&res).map_err(|_| HighlightError::ResultUnserializable)
Ok(HighlightedCode {
parts: res
.into_iter()
.map(|(style, part)| {
(
jirs_data::Style {
foreground: jirs_data::Color {
r: style.foreground.r,
g: style.foreground.g,
b: style.foreground.b,
a: style.foreground.a,
},
background: jirs_data::Color {
r: style.background.r,
g: style.background.g,
b: style.background.b,
a: style.background.a,
},
font_style: style.font_style.bits(),
},
part.to_string(),
)
})
.collect(),
})
}
}
#[derive(actix::Message)]
#[rtype(result = "Result<String, HighlightError>")]
pub struct TextHighlightCode {
pub code: String,
pub lang: String,
}
impl Handler<TextHighlightCode> for HighlightActor {
type Result = Result<String, HighlightError>;
fn handle(&mut self, msg: TextHighlightCode, ctx: &mut Self::Context) -> Self::Result {
let v = self.handle(
HighlightCode {
lang: msg.lang,
code: msg.code,
},
ctx,
)?;
Ok(v.parts
.into_iter()
.fold(String::new(), |mem, (style, text)| {
format!(
"{mem}<span style=\"color:rgba({fr},{fg},{fb},{fa});background:rgba({br},{bg},{bb},{ba})\">{txt}</span>",
mem = mem,
fr = style.foreground.r, fg = style.foreground.g, fb = style.foreground.b, fa = style.foreground.a,
br = style.background.r, bg = style.background.g, bb = style.background.b, ba = style.background.a,
txt = text
)
}))
} }
} }

View File

@ -14,7 +14,7 @@ path = "./src/lib.rs"
[features] [features]
local-storage = ["filesystem-actor"] local-storage = ["filesystem-actor"]
aws-s3 = ["rusoto_s3", "rusoto_core"] aws-s3 = ["amazon-actor"]
default = ["local-storage", "aws-s3"] default = ["local-storage", "aws-s3"]
[dependencies] [dependencies]
@ -36,10 +36,6 @@ futures = { version = "0.3.8" }
openssl-sys = { version = "*", features = ["vendored"] } openssl-sys = { version = "*", features = ["vendored"] }
libc = { version = "0.2.0", default-features = false } libc = { version = "0.2.0", default-features = false }
flate2 = { version = "*" }
syntect = { version = "*" }
lazy_static = { version = "*" }
log = "0.4" log = "0.4"
pretty_env_logger = "0.4" pretty_env_logger = "0.4"
env_logger = "0.7" env_logger = "0.7"
@ -67,18 +63,9 @@ path = "../websocket-actor"
path = "../filesystem-actor" path = "../filesystem-actor"
optional = true optional = true
# Amazon S3 [dependencies.amazon-actor]
[dependencies.rusoto_s3] path = "../amazon-actor"
optional = true optional = true
version = "0.45.0"
[dependencies.rusoto_core]
optional = true
version = "0.45.0"
[dependencies.rusoto_signature]
optional = true
version = "0.45.0"
[dependencies.tokio] [dependencies.tokio]
version = "0.2.23" version = "0.2.23"

View File

@ -21,6 +21,7 @@ pub async fn upload(
db: Data<Addr<DbExecutor>>, db: Data<Addr<DbExecutor>>,
ws: Data<Addr<WsServer>>, ws: Data<Addr<WsServer>>,
fs: Data<Addr<filesystem_actor::FileSystemExecutor>>, fs: Data<Addr<filesystem_actor::FileSystemExecutor>>,
amazon: Data<Addr<amazon_actor::AmazonExecutor>>,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
let mut user_id: Option<UserId> = None; let mut user_id: Option<UserId> = None;
let mut avatar_url: Option<String> = None; let mut avatar_url: Option<String> = None;
@ -45,6 +46,7 @@ pub async fn upload(
field, field,
disposition, disposition,
fs.clone(), fs.clone(),
amazon.clone(),
) )
.await?, .await?,
); );

View File

@ -1,51 +1,40 @@
#[cfg(feature = "local-storage")]
use filesystem_actor::FileSystemExecutor;
use { use {
actix::Addr, actix::Addr,
actix_multipart::Field, actix_multipart::Field,
actix_web::{http::header::ContentDisposition, web::Data, Error}, actix_web::{http::header::ContentDisposition, web::Data, Error},
futures::{StreamExt, TryStreamExt}, futures::StreamExt,
jirs_data::UserId, jirs_data::UserId,
rusoto_core::ByteStream,
tokio::sync::broadcast::{Receiver, Sender}, tokio::sync::broadcast::{Receiver, Sender},
}; };
#[cfg(feature = "aws-s3")]
use {
jirs_config::web::AmazonS3Storage,
rusoto_s3::{PutObjectRequest, S3Client, S3},
};
#[cfg(all(feature = "local-storage", feature = "aws-s3"))] #[cfg(all(feature = "local-storage", feature = "aws-s3"))]
pub(crate) async fn handle_image( pub(crate) async fn handle_image(
user_id: UserId, user_id: UserId,
mut field: Field, mut field: Field,
disposition: ContentDisposition, disposition: ContentDisposition,
fs: Data<Addr<FileSystemExecutor>>, fs: Data<Addr<filesystem_actor::FileSystemExecutor>>,
amazon: Data<Addr<amazon_actor::AmazonExecutor>>,
) -> Result<String, Error> { ) -> Result<String, Error> {
let filename = disposition.get_filename().unwrap(); let filename = disposition.get_filename().unwrap();
let system_file_name = format!("{}-{}", user_id, filename); let system_file_name = format!("{}-{}", user_id, filename);
let (sender, receiver) = tokio::sync::broadcast::channel(4); let (sender, receiver) = tokio::sync::broadcast::channel(64);
let fs_fut = tokio::task::spawn(local_storage_write( let fs_fut = local_storage_write(system_file_name.clone(), fs, user_id, sender.subscribe());
system_file_name.clone(), let aws_fut = aws_s3(system_file_name, amazon, receiver);
fs.clone(), let read_fut = read_form_data(&mut field, sender);
user_id,
sender.subscribe(),
));
// Upload to AWS S3 let fs_join = tokio::task::spawn(fs_fut);
let aws_fut = tokio::task::spawn(aws_s3(system_file_name, receiver)); let aws_join = tokio::task::spawn(aws_fut);
read_fut.await;
read_form_data(&mut field, sender).await;
let mut new_link = None; let mut new_link = None;
if let Ok(url) = fs_fut.await { if let Ok(url) = fs_join.await {
new_link = url; new_link = url;
} }
if let Ok(url) = aws_fut.await { if let Ok(url) = aws_join.await {
new_link = url; new_link = url;
} }
@ -57,31 +46,25 @@ pub(crate) async fn handle_image(
user_id: UserId, user_id: UserId,
mut field: Field, mut field: Field,
disposition: ContentDisposition, disposition: ContentDisposition,
fs: Data<Addr<FileSystemExecutor>>, amazon: Data<Addr<amazon_actor::AmazonExecutor>>,
) -> Result<String, Error> { ) -> Result<String, Error> {
let filename = disposition.get_filename().unwrap(); let filename = disposition.get_filename().unwrap();
let system_file_name = format!("{}-{}", user_id, filename); let system_file_name = format!("{}-{}", user_id, filename);
let (sender, receiver) = tokio::sync::broadcast::channel(4); let (sender, receiver) = tokio::sync::broadcast::channel(64);
// Upload to AWS S3 let aws_fut = aws_s3(system_file_name, amazon, receiver);
let aws_fut = aws_s3(system_file_name, receiver); let read_fut = read_form_data(&mut field, sender);
read_form_data(&mut field, sender).await; let aws_join = tokio::task::spawn(aws_fut);
read_fut.await;
let new_link = tokio::select! { let mut new_link = None;
b = aws_fut => b,
}; if let Ok(url) = aws_join.await {
new_link = url;
}
{
use filesystem_actor::RemoveTmpFile;
let _ = fs
.send(RemoveTmpFile {
file_name: format!("{}-{}", user_id, filename),
})
.await
.ok();
};
Ok(new_link.unwrap_or_default()) Ok(new_link.unwrap_or_default())
} }
@ -90,35 +73,25 @@ pub(crate) async fn handle_image(
user_id: UserId, user_id: UserId,
mut field: Field, mut field: Field,
disposition: ContentDisposition, disposition: ContentDisposition,
fs: Data<Addr<FileSystemExecutor>>, fs: Data<Addr<filesystem_actor::FileSystemExecutor>>,
) -> Result<String, Error> { ) -> Result<String, Error> {
let filename = disposition.get_filename().unwrap(); let filename = disposition.get_filename().unwrap();
let system_file_name = format!("{}-{}", user_id, filename); let system_file_name = format!("{}-{}", user_id, filename);
let (sender, receiver) = tokio::sync::broadcast::channel(4); let (sender, receiver) = tokio::sync::broadcast::channel(64);
let fs_fut = local_storage_write( let fs_fut = local_storage_write(system_file_name, fs, user_id, sender.subscribe());
system_file_name.clone(), let read_fut = read_form_data(&mut field, sender);
fs.clone(),
user_id,
sender.subscribe(),
);
read_form_data(&mut field, sender).await; let fs_join = tokio::task::spawn(fs_fut);
read_fut.await;
let new_link = tokio::select! { let mut new_link = None;
a = fs_fut => a,
}; if let Ok(url) = fs_join.await {
new_link = url;
}
{
use filesystem_actor::RemoveTmpFile;
let _ = fs
.send(RemoveTmpFile {
file_name: format!("{}-{}", user_id, filename),
})
.await
.ok();
};
Ok(new_link.unwrap_or_default()) Ok(new_link.unwrap_or_default())
} }
@ -134,44 +107,28 @@ async fn read_form_data(field: &mut Field, sender: Sender<bytes::Bytes>) {
/// Stream bytes directly to AWS S3 Service /// Stream bytes directly to AWS S3 Service
#[cfg(feature = "aws-s3")] #[cfg(feature = "aws-s3")]
async fn aws_s3(system_file_name: String, mut receiver: Receiver<bytes::Bytes>) -> Option<String> { async fn aws_s3(
let web_config = jirs_config::web::Configuration::read(); system_file_name: String,
let s3 = &web_config.s3; amazon: Data<Addr<amazon_actor::AmazonExecutor>>,
receiver: Receiver<bytes::Bytes>,
) -> Option<String> {
let s3 = jirs_config::amazon::config();
if !s3.active { if !s3.active {
return None; return None;
} }
s3.set_variables();
log::debug!("{:?}", s3);
let mut v: Vec<u8> = vec![]; match amazon
use bytes::Buf; .send(amazon_actor::S3PutObject {
source: receiver,
while let Ok(b) = receiver.recv().await { file_name: system_file_name.to_string(),
v.extend_from_slice(b.bytes()) })
.await
{
Ok(Ok(s)) => Some(s),
_ => None,
} }
// let stream = receiver.into_stream();
// let stream = stream.map_err(|_e| std::io::Error::from_raw_os_error(1));
let client = S3Client::new(s3.region());
let put_object = PutObjectRequest {
bucket: s3.bucket.clone(),
key: system_file_name.clone(),
// body: Some(ByteStream::new(stream)),
body: Some(v.into()),
..Default::default()
};
let id = match client.put_object(put_object).await {
Ok(obj) => obj,
Err(e) => {
log::error!("{}", e);
return None;
}
};
log::debug!("{:?}", id);
Some(aws_s3_url(system_file_name.as_str(), s3))
} }
///
#[cfg(feature = "local-storage")] #[cfg(feature = "local-storage")]
async fn local_storage_write( async fn local_storage_write(
system_file_name: String, system_file_name: String,
@ -179,36 +136,29 @@ async fn local_storage_write(
user_id: jirs_data::UserId, user_id: jirs_data::UserId,
receiver: Receiver<bytes::Bytes>, receiver: Receiver<bytes::Bytes>,
) -> Option<String> { ) -> Option<String> {
let web_config = jirs_config::web::Configuration::read(); let web_config = jirs_config::web::config();
let fs_config = jirs_config::fs::Configuration::read(); let fs_config = jirs_config::fs::config();
let _ = fs match fs
.send(filesystem_actor::CreateFile { .send(filesystem_actor::CreateFile {
source: receiver, source: receiver,
file_name: system_file_name.clone(), file_name: system_file_name.to_string(),
}) })
.await; .await
{
Some(format!( Ok(Ok(_)) => Some(format!(
"{proto}://{bind}{port}{client_path}/{user_id}-{filename}", "{proto}://{bind}{port}{client_path}/{user_id}-{filename}",
proto = if web_config.ssl { "https" } else { "http" }, proto = if web_config.ssl { "https" } else { "http" },
bind = web_config.bind, bind = web_config.bind,
port = match web_config.port.as_str() { port = match web_config.port.as_str() {
"80" | "443" => "".to_string(), "80" | "443" => "".to_string(),
p => format!(":{}", p), p => format!(":{}", p),
}, },
client_path = fs_config.client_path, client_path = fs_config.client_path,
user_id = user_id, user_id = user_id,
filename = system_file_name filename = system_file_name
)) )),
} Ok(_) => None,
_ => None,
#[cfg(feature = "aws-s3")] }
fn aws_s3_url(key: &str, config: &AmazonS3Storage) -> String {
format!(
"https://{bucket}.s3.{region}.amazonaws.com/{key}",
bucket = config.bucket,
region = config.region_name,
key = key
)
} }

View File

@ -35,6 +35,12 @@ env_logger = "0.7"
uuid = { version = "0.8.1", features = ["serde", "v4", "v5"] } uuid = { version = "0.8.1", features = ["serde", "v4", "v5"] }
[dependencies.comrak]
version = "*"
[dependencies.pulldown-cmark]
version = "*"
[dependencies.jirs-config] [dependencies.jirs-config]
path = "../../shared/jirs-config" path = "../../shared/jirs-config"
features = ["websocket"] features = ["websocket"]
@ -48,3 +54,6 @@ path = "../database-actor"
[dependencies.mail-actor] [dependencies.mail-actor]
path = "../mail-actor" path = "../mail-actor"
[dependencies.highlight-actor]
path = "../highlight-actor"

View File

@ -0,0 +1,28 @@
use futures::executor::block_on;
use jirs_data::WsMsg;
use jirs_data::{Code, Lang};
use crate::{WebSocketActor, WsHandler, WsResult};
pub struct HighlightCode(pub Lang, pub Code);
impl WsHandler<HighlightCode> for WebSocketActor {
fn handle_msg(&mut self, msg: HighlightCode, _ctx: &mut Self::Context) -> WsResult {
self.require_user()?.id;
match block_on(self.hi.send(highlight_actor::HighlightCode {
code: msg.1,
lang: msg.0,
})) {
Ok(Ok(res)) => Ok(Some(WsMsg::HighlightedCode(res))),
Ok(Err(e)) => {
error!("{:?}", e);
Ok(None)
}
Err(e) => {
error!("{}", e);
Ok(None)
}
}
}
}

View File

@ -90,7 +90,7 @@ impl WsHandler<CreateInvitation> for WebSocketActor {
})) { })) {
self.addr.do_send(InnerMsg::SendToUser( self.addr.do_send(InnerMsg::SendToUser(
message.receiver_id, message.receiver_id,
WsMsg::Message(message), WsMsg::MessageUpdated(message),
)); ));
} }

View File

@ -50,7 +50,64 @@ impl WsHandler<UpdateIssueHandler> for WebSocketActor {
msg.title = Some(s); msg.title = Some(s);
} }
(IssueFieldId::Description, PayloadVariant::String(s)) => { (IssueFieldId::Description, PayloadVariant::String(s)) => {
msg.description = Some(s); // let mut opts = comrak::ComrakOptions::default();
// opts.render.github_pre_lang = true;
// let html = comrak::markdown_to_html(s.as_str(), &opts);
let html: String = {
use pulldown_cmark::*;
let parser = pulldown_cmark::Parser::new(s.as_str());
enum ParseState {
Code(highlight_actor::TextHighlightCode),
Other,
};
let mut state = ParseState::Other;
let parser = parser.flat_map(|event| match event {
Event::Text(s) => {
if let ParseState::Code(h) = &mut state {
h.code.push_str(s.as_ref());
return vec![];
}
vec![Event::Text(s)]
}
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(name))) => {
state = ParseState::Code(highlight_actor::TextHighlightCode {
lang: name.to_string(),
code: String::new(),
});
vec![Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(name)))]
}
Event::End(Tag::CodeBlock(CodeBlockKind::Fenced(lang))) => {
let ev = if let ParseState::Code(h) = &mut state {
let mut msg = highlight_actor::TextHighlightCode {
code: String::new(),
lang: String::new(),
};
std::mem::swap(h, &mut msg);
let highlighted =
match futures::executor::block_on(self.hi.send(msg)) {
Ok(Ok(res)) => res,
_ => s.to_string(),
};
vec![
Event::Html(highlighted.into()),
Event::End(Tag::CodeBlock(CodeBlockKind::Fenced(lang))),
]
} else {
vec![]
};
state = ParseState::Other;
ev
}
_ => vec![event],
});
let mut buff = String::new();
let _ = html::push_html(&mut buff, parser);
buff
};
msg.description = Some(html);
msg.description_text = Some(s);
} }
(IssueFieldId::IssueStatusId, PayloadVariant::I32(s)) => { (IssueFieldId::IssueStatusId, PayloadVariant::I32(s)) => {
msg.issue_status_id = Some(s); msg.issue_status_id = Some(s);

View File

@ -1,11 +1,12 @@
pub use { pub use {
auth::*, comments::*, epics::*, invitations::*, issue_statuses::*, issues::*, messages::*, auth::*, comments::*, epics::*, hi::*, invitations::*, issue_statuses::*, issues::*,
projects::*, user_projects::*, users::*, messages::*, projects::*, user_projects::*, users::*,
}; };
pub mod auth; pub mod auth;
pub mod comments; pub mod comments;
pub mod epics; pub mod epics;
pub mod hi;
pub mod invitations; pub mod invitations;
pub mod issue_statuses; pub mod issue_statuses;
pub mod issues; pub mod issues;

View File

@ -34,6 +34,7 @@ struct WebSocketActor {
db: Data<Addr<DbExecutor>>, db: Data<Addr<DbExecutor>>,
mail: Data<Addr<MailExecutor>>, mail: Data<Addr<MailExecutor>>,
addr: Addr<WsServer>, addr: Addr<WsServer>,
hi: Data<Addr<highlight_actor::HighlightActor>>,
current_user: Option<jirs_data::User>, current_user: Option<jirs_data::User>,
current_user_project: Option<jirs_data::UserProject>, current_user_project: Option<jirs_data::UserProject>,
current_project: Option<jirs_data::Project>, current_project: Option<jirs_data::Project>,
@ -186,6 +187,9 @@ impl WebSocketActor {
self.handle_msg(epics::UpdateEpic { epic_id, name }, ctx)? self.handle_msg(epics::UpdateEpic { epic_id, name }, ctx)?
} }
WsMsg::EpicDelete(epic_id) => self.handle_msg(epics::DeleteEpic { epic_id }, ctx)?, WsMsg::EpicDelete(epic_id) => self.handle_msg(epics::DeleteEpic { epic_id }, ctx)?,
WsMsg::HighlightCode(lang, code) => {
self.handle_msg(hi::HighlightCode(lang, code), ctx)?
}
// else fail // else fail
_ => { _ => {
@ -323,11 +327,13 @@ pub async fn index(
db: Data<Addr<DbExecutor>>, db: Data<Addr<DbExecutor>>,
mail: Data<Addr<MailExecutor>>, mail: Data<Addr<MailExecutor>>,
ws_server: Data<Addr<WsServer>>, ws_server: Data<Addr<WsServer>>,
hi: Data<Addr<highlight_actor::HighlightActor>>,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
ws::start( ws::start(
WebSocketActor { WebSocketActor {
db, db,
mail, mail,
hi,
current_user: None, current_user: None,
current_user_project: None, current_user_project: None,
current_project: None, current_project: None,

View File

@ -2,7 +2,7 @@
# see diesel.rs/guides/configuring-diesel-cli # see diesel.rs/guides/configuring-diesel-cli
[print_schema] [print_schema]
file = "database-actor/src/schema.rs" file = "actors/database-actor/src/schema.rs"
import_types = ["diesel::sql_types::*", "jirs_data::sql::*"] import_types = ["diesel::sql_types::*", "jirs_data::sql::*"]
with_docs = true with_docs = true
patch_file = "./database-actor/src/schema.patch" patch_file = "./actors/database-actor/src/schema.patch"

View File

@ -13,12 +13,14 @@ crate-type = ["cdylib", "rlib"]
name = "jirs_client" name = "jirs_client"
path = "src/lib.rs" path = "src/lib.rs"
[features]
print-model = []
default = []
[dependencies] [dependencies]
jirs-data = { path = "../shared/jirs-data", features = ["frontend"] } jirs-data = { path = "../shared/jirs-data", features = ["frontend"] }
wee_alloc = "*" seed = { version = "0.8.0" }
seed = { version = "0.7.0" }
serde = { version = "*" } serde = { version = "*" }
serde_json = { version = "*" } serde_json = { version = "*" }
@ -27,7 +29,10 @@ bincode = { version = "*" }
chrono = { version = "0.4", default-features = false, features = ["serde", "wasmbind"] } chrono = { version = "0.4", default-features = false, features = ["serde", "wasmbind"] }
uuid = { version = "0.8.1", features = ["serde"] } uuid = { version = "0.8.1", features = ["serde"] }
futures = "^0.1.26" futures = "^0.1.26"
comrak = "*"
[dependencies.wee_alloc]
version = "*"
features = ["static_array_backend"]
[dependencies.wasm-bindgen] [dependencies.wasm-bindgen]
version = "*" version = "*"

View File

@ -84,9 +84,13 @@
color: var(--textMedium); color: var(--textMedium);
} }
#projectPage > .rows > .row > .epicName {
margin: 18px 0 10px 0;
}
#projectPage > .rows > .row > .projectBoardLists { #projectPage > .rows > .row > .projectBoardLists {
display: flex; display: flex;
margin: 26px -5px 0; margin: 10px -5px 0;
position: relative; position: relative;
flex-direction: column; flex-direction: column;
} }

View File

@ -276,8 +276,8 @@ impl ElementBuilder {
pub fn mount(&self) { pub fn mount(&self) {
let source = self.to_js(); let source = self.to_js();
{ {
use seed::*; // use seed::*;
log!(source); // log!(source);
} }
use seed::*; use seed::*;
match js_sys::eval(source.as_str()) { match js_sys::eval(source.as_str()) {

View File

@ -0,0 +1 @@
pub mod project_avatar;

View File

@ -0,0 +1,100 @@
use {
crate::Msg,
seed::{prelude::*, *},
};
#[inline(always)]
pub fn render() -> Node<Msg> {
seed::svg![
attrs![
At::ViewBox => "0 0 128 128",
At::Version => "1.1",
At::Xmlns => "http://www.w3.org/2000/svg",
At::Width => "40",
At::Height => "40"
],
defs![rect![attrs![
At::Id=>"path-1",
At::X=>"0",
At::Y=>"0",
At::Width=>"128",
At::Height=>"128",
At::Fill=>"#FF5630"
]]],
g![
attrs![At::Id=>"Page-1", At::Stroke=>"none", At::StrokeWidth=>"1" ,At::Fill=>"none", At::FillRule=>"evenodd"],
g![
rect![
attrs![At::Id=>"path-1", At::X=>"0", At::Y=>"0", At::Width=>"128", At::Height=>"128", At::Fill=>"#FF5630"]
],
g![
attrs![
At::Id=>"Settings",
At::FillRule=>"nonzero",
At::Transform=>"translate(20.000000, 17.000000)"
],
path![attrs![
At::D=>"M74.578,84.289 L72.42,84.289 C70.625,84.289 69.157,82.821 69.157,81.026 L69.157,16.537 C69.157,14.742 70.625,13.274 72.42,13.274 L74.578,13.274 C76.373,13.274 77.841,14.742 77.841,16.537 L77.841,81.026 C77.842,82.82 76.373,84.289 74.578,84.289 Z",
At::Id=>"Shape",
At::Fill=>"#2A5083"]],
path![attrs![
At::D=>"M14.252,84.289 L12.094,84.289 C10.299,84.289 8.831,82.821 8.831,81.026 L8.831,16.537 C8.831,14.742 10.299,13.274 12.094,13.274 L14.252,13.274 C16.047,13.274 17.515,14.742 17.515,16.537 L17.515,81.026 C17.515,82.82 16.047,84.289 14.252,84.289 Z",
At::Id=>"Shape",
At::Fill=>"#2A5083"]],
rect![attrs![
At::Id=>"Rectangle-path",
At::Fill=>"#153A56",
At::X=>"8.83",
At::Y=>"51.311",
At::Width=>"8.685",
At::Height=>"7.763"]],
path![attrs![
At::D=>"M13.173,53.776 L13.173,53.776 C6.342,53.776 0.753,48.187 0.753,41.356 L0.753,41.356 C0.753,34.525 6.342,28.936 13.173,28.936 L13.173,28.936 C20.004,28.936 25.593,34.525 25.593,41.356 L25.593,41.356 C25.593,48.187 20.004,53.776 13.173,53.776 Z",
At::Id=>"Shape",
At::Fill=>"#FFFFFF"]],
path![attrs![
At::D=>"M18.021,43.881 L8.324,43.881 C7.453,43.881 6.741,43.169 6.741,42.298 L6.741,41.25 C6.741,40.379 7.453,39.667 8.324,39.667 L18.021,39.667 C18.892,39.667 19.604,40.379 19.604,41.25 L19.604,42.297 C19.605,43.168 18.892,43.881 18.021,43.881 Z",
At::Id=>"Shape",
At::Fill=>"#2A5083",
At::Opacity=>"0.2"]],
rect![attrs![
At::Id=>"Rectangle-path",
At::Fill=>"#153A56",
At::X=>"69.157",
At::Y=>"68.307",
At::Width=>"8.685",
At::Height=>"7.763"]],
path![attrs![
At::D=>"M73.499,70.773 L73.499,70.773 C66.668,70.773 61.079,65.184 61.079,58.353 L61.079,58.353 C61.079,51.522 66.668,45.933 73.499,45.933 L73.499,45.933 C80.33,45.933 85.919,51.522 85.919,58.353 L85.919,58.353 C85.919,65.183 80.33,70.773 73.499,70.773 Z",
At::Id=>"Shape",
At::Fill=>"#FFFFFF"]],
path![attrs![
At::D=>"M78.348,60.877 L68.651,60.877 C67.78,60.877 67.068,60.165 67.068,59.294 L67.068,58.247 C67.068,57.376 67.781,56.664 68.651,56.664 L78.348,56.664 C79.219,56.664 79.931,57.377 79.931,58.247 L79.931,59.294 C79.931,60.165 79.219,60.877 78.348,60.877 Z",
At::Id=>"Shape",
At::Fill=>"#2A5083",
At::Opacity=>"0.2"]],
path![attrs![
At::D=>"M44.415,84.289 L42.257,84.289 C40.462,84.289 38.994,82.821 38.994,81.026 L38.994,16.537 C38.994,14.742 40.462,13.274 42.257,13.274 L44.415,13.274 C46.21,13.274 47.678,14.742 47.678,16.537 L47.678,81.026 C47.678,82.82 46.21,84.289 44.415,84.289 Z",
At::Id=>"Shape",
At::Fill=>"#2A5083"]],
rect![attrs![
At::Id=>"Rectangle-path",
At::Fill=>"#153A56",
At::X=>"38.974",
At::Y=>"23.055",
At::Width=>"8.685",
At::Height=>"7.763"]],
path![attrs![
At::D=>"M43.316,25.521 L43.316,25.521 C36.485,25.521 30.896,19.932 30.896,13.101 L30.896,13.101 C30.896,6.27 36.485,0.681 43.316,0.681 L43.316,0.681 C50.147,0.681 55.736,6.27 55.736,13.101 L55.736,13.101 C55.736,19.932 50.147,25.521 43.316,25.521 Z",
At::Id=>"Shape",
At::Fill=>"#FFFFFF"]],
path![attrs![
At::D=>"M48.165,15.626 L38.468,15.626 C37.597,15.626 36.885,14.914 36.885,14.043 L36.885,12.996 C36.885,12.125 37.597,11.413 38.468,11.413 L48.165,11.413 C49.036,11.413 49.748,12.125 49.748,12.996 L49.748,14.043 C49.748,14.913 49.036,15.626 48.165,15.626 Z",
At::Id=>"Shape",
At::Fill=>"#2A5083",
At::Opacity=>"0.2"]],
]
]
]
]
}

View File

@ -1,121 +0,0 @@
use std::str::FromStr;
use seed::{prelude::*, *};
use jirs_data::{InviteFieldId, WsMsg};
use crate::model::{InvitePage, Model, Page, PageContent};
use crate::shared::styled_button::StyledButton;
use crate::shared::styled_field::StyledField;
use crate::shared::styled_form::StyledForm;
use crate::shared::styled_input::StyledInput;
use crate::shared::{outer_layout, write_auth_token, ToNode};
use crate::validations::is_token;
use crate::ws::send_ws_msg;
use crate::{
authorize_or_redirect, FieldId, InvitationPageChange, Msg, PageChanged, WebSocketChanged,
};
pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
match model.page_content {
PageContent::Invite(..) => (),
_ if model.page == Page::Invite => build_page_content(model),
_ => (),
};
let page = match &mut model.page_content {
PageContent::Invite(page) => page,
_ => return,
};
match msg {
Msg::WebSocketChange(WebSocketChanged::WsMsg(ws_msg)) => match ws_msg {
WsMsg::InvitationAcceptFailure(_) => {
page.error = Some("Invalid token".to_string());
}
WsMsg::InvitationAcceptSuccess(token) => {
if let Ok(Msg::AuthTokenStored) = write_auth_token(Some(token)) {
authorize_or_redirect(model, orders);
}
}
_ => (),
},
Msg::StrInputChanged(FieldId::Invite(InviteFieldId::Token), text) => {
page.token_touched = true;
page.token = text;
page.error = None;
}
Msg::PageChanged(PageChanged::Invitation(InvitationPageChange::SubmitForm)) => {
if let Ok(token) = uuid::Uuid::from_str(page.token.as_str()) {
send_ws_msg(
WsMsg::InvitationAcceptRequest(token),
model.ws.as_ref(),
orders,
);
page.error = None;
}
}
_ => {}
}
}
fn build_page_content(model: &mut Model) {
let s: String = seed::document().location().unwrap().to_string().into();
let url = seed::Url::from_str(s.as_str()).unwrap();
let search = url.search();
let values = search.get("token").cloned().unwrap_or_default();
let mut content = InvitePage::default();
content.token = values.get(0).cloned().unwrap_or_default();
model.page_content = PageContent::Invite(Box::new(content));
}
pub fn view(model: &Model) -> Node<Msg> {
let page = match &model.page_content {
PageContent::Invite(page) => page,
_ => return empty![],
};
let token_field = token_field(page);
let submit_field = submit(page);
let error = match page.error.as_ref() {
Some(s) => div![class!["error"], s.as_str()],
_ => empty![],
};
let form = StyledForm::build()
.heading("Welcome in JIRS")
.on_submit(ev(Ev::Submit, move |ev| {
ev.prevent_default();
Msg::PageChanged(PageChanged::Invitation(InvitationPageChange::SubmitForm))
}))
.add_field(token_field)
.add_field(submit_field)
.add_field(error)
.build()
.into_node();
outer_layout(model, "invite", vec![form])
}
fn submit(_page: &InvitePage) -> Node<Msg> {
let submit = StyledButton::build()
.text("Accept")
.primary()
.build()
.into_node();
StyledField::build().input(submit).build().into_node()
}
fn token_field(page: &InvitePage) -> Node<Msg> {
let token = StyledInput::build()
.valid(!page.token_touched || is_token(page.token.as_str()) && page.error.is_none())
.value(page.token.as_str())
.build(FieldId::Invite(InviteFieldId::Token))
.into_node();
StyledField::build()
.input(token)
.label("Your invite token")
.build()
.into_node()
}

View File

@ -1,39 +1,61 @@
#![feature(or_patterns, type_ascription)] #![feature(or_patterns, type_ascription)]
use seed::{prelude::*, *}; use {
use web_sys::File; crate::{
model::{ModalType, Model, Page},
shared::{
go_to_board, go_to_login,
styled_date_time_input::StyledDateTimeChanged,
styled_select::StyledSelectChanged,
styled_tooltip,
styled_tooltip::{Variant as StyledTooltip, Variant},
},
ws::{flush_queue, open_socket, read_incoming, send_ws_msg},
},
jirs_data::*,
seed::{prelude::*, *},
web_sys::File,
};
pub use {changes::*, fields::*, images::*};
pub use changes::*;
pub use fields::*;
use jirs_data::*;
use crate::model::{ModalType, Model, Page};
use crate::shared::styled_date_time_input::StyledDateTimeChanged;
use crate::shared::{go_to_board, go_to_login, styled_tooltip};
// use crate::shared::styled_rte::RteMsg; // use crate::shared::styled_rte::RteMsg;
use crate::shared::styled_select::StyledSelectChanged;
use crate::shared::styled_tooltip::{Variant as StyledTooltip, Variant};
use crate::ws::{flush_queue, open_socket, read_incoming, send_ws_msg};
mod changes; mod changes;
pub mod elements; pub mod elements;
mod fields; mod fields;
mod invite; mod images;
mod modal; mod modal;
mod modals;
mod model; mod model;
mod profile; mod pages;
mod project;
mod project_settings;
mod reports;
mod shared; mod shared;
mod sign_in;
mod sign_up;
mod users;
pub mod validations; pub mod validations;
mod ws; mod ws;
#[global_allocator] // #[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; // static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
#[derive(Debug)]
pub enum ResourceKind {
Issue,
IssueStatus,
Epic,
Project,
User,
UserProject,
Message,
Comment,
Auth,
}
#[derive(Debug)]
pub enum OperationKind {
ListLoaded,
SingleLoaded,
SingleCreated,
SingleRemoved,
SingleModified,
}
#[derive(Debug)] #[derive(Debug)]
pub enum Msg { pub enum Msg {
@ -112,6 +134,9 @@ pub enum Msg {
// WebSocket // WebSocket
WebSocketChange(WebSocketChanged), WebSocketChange(WebSocketChanged),
// resource changes
ResourceChanged(ResourceKind, OperationKind, Option<i32>),
} }
fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) { fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) {
@ -139,8 +164,10 @@ fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) {
orders.skip(); orders.skip();
return; return;
} }
WebSocketChanged::WsMsg(ref ws_msg) => { WebSocketChanged::WsMsg(ws_msg) => {
ws::update(ws_msg, model, orders); ws::update(ws_msg, model, orders);
orders.skip();
return;
} }
WebSocketChanged::WebSocketMessageLoaded(v) => { WebSocketChanged::WebSocketMessageLoaded(v) => {
match bincode::deserialize(v.as_slice()) { match bincode::deserialize(v.as_slice()) {
@ -187,7 +214,6 @@ fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) {
return; return;
} }
Msg::ChangePage(page) => { Msg::ChangePage(page) => {
orders.skip();
model.page = *page; model.page = *page;
} }
Msg::ToggleTooltip(variant) => match variant { Msg::ToggleTooltip(variant) => match variant {
@ -203,42 +229,42 @@ fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) {
}, },
_ => (), _ => (),
} }
crate::shared::aside::update(&msg, model, orders);
crate::shared::navbar_left::update(&msg, model, orders); {
crate::modal::update(&msg, model, orders); use crate::shared::{aside, navbar_left};
match model.page { aside::update(&msg, model, orders);
Page::Project | Page::AddIssue | Page::EditIssue(..) => project::update(msg, model, orders), navbar_left::update(&msg, model, orders);
Page::ProjectSettings => project_settings::update(msg, model, orders),
Page::SignIn => sign_in::update(msg, model, orders),
Page::SignUp => sign_up::update(msg, model, orders),
Page::Invite => invite::update(msg, model, orders),
Page::Users => users::update(msg, model, orders),
Page::Profile => profile::update(msg, model, orders),
Page::Reports => reports::update(msg, model, orders),
} }
if cfg!(debug_assertions) { crate::modal::update(&msg, model, orders);
// debug!(model);
match model.page {
Page::Project | Page::AddIssue | Page::EditIssue(..) => {
pages::project_page::update(msg, model, orders)
}
Page::ProjectSettings => pages::project_settings_page::update(msg, model, orders),
Page::SignIn => pages::sign_in_page::update(msg, model, orders),
Page::SignUp => pages::sign_up_page::update(msg, model, orders),
Page::Invite => pages::invite_page::update(msg, model, orders),
Page::Users => pages::users_page::update(msg, model, orders),
Page::Profile => pages::profile_page::update(msg, model, orders),
Page::Reports => pages::reports_page::update(msg, model, orders),
}
if cfg!(features = "print-model") {
log!(model);
} }
} }
fn view(model: &model::Model) -> Node<Msg> { fn view(model: &model::Model) -> Node<Msg> {
match model.page { match model.page {
Page::Project | Page::AddIssue => project::view(model), Page::Project | Page::AddIssue => pages::project_page::view(model),
Page::EditIssue(_id) => project::view(model), Page::EditIssue(_id) => pages::project_page::view(model),
Page::ProjectSettings => project_settings::view(model), Page::ProjectSettings => pages::project_settings_page::view(model),
Page::SignIn => sign_in::view(model), Page::SignIn => pages::sign_in_page::view(model),
Page::SignUp => sign_up::view(model), Page::SignUp => pages::sign_up_page::view(model),
Page::Invite => invite::view(model), Page::Invite => pages::invite_page::view(model),
Page::Users => users::view(model), Page::Users => pages::users_page::view(model),
Page::Profile => profile::view(model), Page::Profile => pages::profile_page::view(model),
Page::Reports => reports::view(model), Page::Reports => pages::reports_page::view(model),
}
}
fn routes(url: Url) -> Option<Msg> {
match resolve_page(url) {
Some(page) => Some(Msg::ChangePage(page)),
_ => None,
} }
} }
@ -277,14 +303,39 @@ pub fn render(host_url: String, ws_url: String) {
} }
elements::define(); elements::define();
let _app = seed::App::builder(update, view) let app = seed::App::start("app", init, update, view);
.routes(routes)
.after_mount(after_mount) {
.window_events(window_events) let app_clone = app.clone();
.build_and_start(); let on_key_down = Closure::wrap(Box::new(move |event: web_sys::KeyboardEvent| {
let event: web_sys::KeyboardEvent = event.unchecked_into();
let tag_name: String = seed::document()
.active_element()
.map(|el| el.tag_name())
.unwrap_or_default();
let key = match tag_name.to_lowercase().as_str() {
"input" | "textarea" => return,
_ => event.key(),
};
let msg = Msg::GlobalKeyDown {
key,
shift: event.shift_key(),
ctrl: event.ctrl_key(),
alt: event.alt_key(),
};
app_clone.update(msg);
}) as Box<dyn FnMut(_)>);
seed::body()
.add_event_listener_with_callback("keyup", on_key_down.as_ref().unchecked_ref())
.expect("Failed to mount global key handler");
on_key_down.forget();
}
} }
fn after_mount(url: Url, orders: &mut impl Orders<Msg>) -> AfterMount<Model> { fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model {
let host_url = unsafe { HOST_URL.clone() }; let host_url = unsafe { HOST_URL.clone() };
let ws_url = unsafe { WS_URL.clone() }; let ws_url = unsafe { WS_URL.clone() };
let mut model = Model::new(host_url, ws_url); let mut model = Model::new(host_url, ws_url);
@ -294,31 +345,13 @@ fn after_mount(url: Url, orders: &mut impl Orders<Msg>) -> AfterMount<Model> {
} }
model.page = resolve_page(url).unwrap_or(Page::Project); model.page = resolve_page(url).unwrap_or(Page::Project);
open_socket(&mut model, orders); open_socket(&mut model, orders);
AfterMount::new(model).url_handling(UrlHandling::PassToRoutes)
}
fn window_events(_model: &Model) -> Vec<EventHandler<Msg>> { // orders.subscribe(|subs::UrlChanged(url)| {
vec![keyboard_ev( // if let Some(page) = resolve_page(url) {
Ev::KeyDown, // orders.send_msg(Msg::ChangePage(page));
move |event: web_sys::KeyboardEvent| { // }
let tag_name: String = seed::document() // });
.active_element() model
.map(|el| el.tag_name())
.unwrap_or_default();
let key = match tag_name.to_lowercase().as_str() {
"" | "input" | "textarea" => return None,
_ => event.key(),
};
Some(Msg::GlobalKeyDown {
key,
shift: event.shift_key(),
ctrl: event.ctrl_key(),
alt: event.alt_key(),
})
},
)]
} }
#[inline] #[inline]

View File

@ -1,16 +1,13 @@
use seed::prelude::Node; use {
crate::{
use jirs_data::EpicId; model::{IssueModal, Model},
shared::{styled_field::StyledField, styled_select::StyledSelect, ToChild, ToNode},
use crate::{ FieldId, Msg,
model::{IssueModal, Model}, },
shared::{styled_field::StyledField, styled_select::StyledSelect, ToChild, ToNode}, jirs_data::EpicId,
FieldId, Msg, seed::prelude::Node,
}; };
pub mod add_issue;
pub mod issue_details;
pub fn epic_field<Modal>(model: &Model, modal: &Modal, field_id: FieldId) -> Option<Node<Msg>> pub fn epic_field<Modal>(model: &Model, modal: &Modal, field_id: FieldId) -> Option<Node<Msg>>
where where
Modal: IssueModal, Modal: IssueModal,

View File

@ -1,822 +0,0 @@
use seed::{prelude::*, *};
use jirs_data::*;
use crate::{
modal::{issues::epic_field, time_tracking::time_tracking_field},
model::{CommentForm, EditIssueModal, IssueModal, ModalType, Model},
shared::{
styled_avatar::StyledAvatar,
styled_button::StyledButton,
styled_editor::StyledEditor,
styled_field::StyledField,
styled_icon::Icon,
styled_input::StyledInput,
styled_select::{StyledSelect, StyledSelectChanged},
styled_textarea::StyledTextarea,
tracking_widget::tracking_link,
ToChild, ToNode,
},
ws::send_ws_msg,
EditIssueModalSection, FieldChange, FieldId, Msg, WebSocketChanged,
};
pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
let modal: &mut EditIssueModal = match model.modals.get_mut(0) {
Some(ModalType::EditIssue(_issue_id, modal)) => modal,
_ => return,
};
modal.update_states(msg, orders);
match msg {
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::IssueUpdated(issue))) => {
modal.payload = issue.clone().into();
}
Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Type)),
StyledSelectChanged::Changed(Some(value)),
) => {
modal.payload.issue_type = (*value).into();
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::Type,
PayloadVariant::IssueType(modal.payload.issue_type),
),
model.ws.as_ref(),
orders,
);
}
Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::IssueStatusId)),
StyledSelectChanged::Changed(Some(value)),
) => {
modal.payload.issue_status_id = *value as IssueStatusId;
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::IssueStatusId,
PayloadVariant::I32(modal.payload.issue_status_id),
),
model.ws.as_ref(),
orders,
);
}
Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Reporter)),
StyledSelectChanged::Changed(Some(value)),
) => {
modal.payload.reporter_id = *value as i32;
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::Reporter,
PayloadVariant::I32(modal.payload.reporter_id),
),
model.ws.as_ref(),
orders,
);
}
Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Assignees)),
StyledSelectChanged::Changed(Some(value)),
) => {
modal.payload.user_ids.push(*value as i32);
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::Assignees,
PayloadVariant::VecI32(modal.payload.user_ids.clone()),
),
model.ws.as_ref(),
orders,
);
}
Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Assignees)),
StyledSelectChanged::RemoveMulti(value),
) => {
let mut old = vec![];
std::mem::swap(&mut old, &mut modal.payload.user_ids);
let dropped = *value as i32;
for id in old.into_iter() {
if id != dropped {
modal.payload.user_ids.push(id);
}
}
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::Assignees,
PayloadVariant::VecI32(modal.payload.user_ids.clone()),
),
model.ws.as_ref(),
orders,
);
}
Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Priority)),
StyledSelectChanged::Changed(Some(value)),
) => {
modal.payload.priority = (*value).into();
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::Priority,
PayloadVariant::IssuePriority(modal.payload.priority),
),
model.ws.as_ref(),
orders,
);
}
Msg::StrInputChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Title)),
value,
) => {
modal.payload.title = value.clone();
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::Title,
PayloadVariant::String(modal.payload.title.clone()),
),
model.ws.as_ref(),
orders,
);
}
Msg::StrInputChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Description)),
value,
) => {
modal.payload.description = Some(value.clone());
modal.payload.description_text = Some(value.clone());
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::Description,
PayloadVariant::String(
modal
.payload
.description
.as_ref()
.cloned()
.unwrap_or_default(),
),
),
model.ws.as_ref(),
orders,
);
}
// TimeSpent
Msg::StrInputChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::TimeSpent)),
..,
) => {
modal.payload.time_spent = modal.time_spent.represent_f64_as_i32();
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::TimeSpent,
PayloadVariant::OptionI32(modal.payload.time_spent),
),
model.ws.as_ref(),
orders,
);
}
Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::TimeSpent)),
StyledSelectChanged::Changed(..),
) => {
modal.payload.time_spent = modal.time_spent_select.values.get(0).map(|n| *n as i32);
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::TimeSpent,
PayloadVariant::OptionI32(modal.payload.time_spent),
),
model.ws.as_ref(),
orders,
);
}
// Time Remaining
Msg::StrInputChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::TimeRemaining)),
..,
) => {
modal.payload.time_remaining = modal.time_remaining.represent_f64_as_i32();
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::TimeRemaining,
PayloadVariant::OptionI32(modal.payload.time_remaining),
),
model.ws.as_ref(),
orders,
);
}
Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::TimeRemaining)),
StyledSelectChanged::Changed(..),
) => {
modal.payload.time_remaining =
modal.time_remaining_select.values.get(0).map(|n| *n as i32);
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::TimeRemaining,
PayloadVariant::OptionI32(modal.payload.time_remaining),
),
model.ws.as_ref(),
orders,
);
}
// Estimate
Msg::StrInputChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Estimate)),
..,
) => {
modal.payload.estimate = modal.estimate.represent_f64_as_i32();
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::Estimate,
PayloadVariant::OptionI32(modal.payload.estimate),
),
model.ws.as_ref(),
orders,
);
}
Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Estimate)),
StyledSelectChanged::Changed(..),
) => {
modal.payload.estimate = modal.estimate_select.values.get(0).map(|n| *n as i32);
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::Estimate,
PayloadVariant::OptionI32(modal.payload.estimate),
),
model.ws.as_ref(),
orders,
);
}
Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::EpicName)),
StyledSelectChanged::Changed(v),
) => {
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::EpicName,
PayloadVariant::OptionI32(v.map(|n| n as EpicId).clone()),
),
model.ws.as_ref(),
orders,
);
}
Msg::ModalChanged(FieldChange::TabChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Description)),
mode,
)) => {
modal.description_editor_mode = mode.clone();
}
Msg::ModalChanged(FieldChange::ToggleCommentForm(
FieldId::EditIssueModal(EditIssueModalSection::Comment(CommentFieldId::Body)),
flag,
)) => {
modal.comment_form.creating = *flag;
if !*flag {
modal.comment_form.body.clear();
modal.comment_form.id = None;
}
}
//
// comments
//
Msg::StrInputChanged(
FieldId::EditIssueModal(EditIssueModalSection::Comment(CommentFieldId::Body)),
text,
) => {
modal.comment_form.body = text.clone();
}
Msg::SaveComment => {
let msg = match modal.comment_form.id {
Some(id) => WsMsg::CommentUpdate(UpdateCommentPayload {
id,
body: modal.comment_form.body.clone(),
}),
_ => WsMsg::CommentCreate(CreateCommentPayload {
user_id: None,
body: modal.comment_form.body.clone(),
issue_id: modal.id,
}),
};
send_ws_msg(msg, model.ws.as_ref(), orders);
orders
.skip()
.send_msg(Msg::ModalChanged(FieldChange::ToggleCommentForm(
FieldId::EditIssueModal(EditIssueModalSection::Comment(CommentFieldId::Body)),
false,
)));
}
Msg::ModalChanged(FieldChange::EditComment(
FieldId::EditIssueModal(EditIssueModalSection::Comment(CommentFieldId::Body)),
comment_id,
)) => {
let id = *comment_id;
let body = model
.comments
.iter()
.find(|c| c.id == id)
.map(|c| c.body.clone())
.unwrap_or_default();
modal.comment_form.body = body;
modal.comment_form.id = Some(id);
modal.comment_form.creating = true;
}
Msg::DeleteComment(comment_id) => {
send_ws_msg(WsMsg::CommentDelete(*comment_id), model.ws.as_ref(), orders);
orders.skip().send_msg(Msg::ModalDropped);
}
// global
Msg::GlobalKeyDown { key, .. } if key.as_str() == "m" && !modal.comment_form.creating => {
orders
.skip()
.send_msg(Msg::ModalChanged(FieldChange::ToggleCommentForm(
FieldId::EditIssueModal(EditIssueModalSection::Comment(CommentFieldId::Body)),
true,
)));
}
_ => (),
}
}
pub fn view(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
div![
class!["issueDetails"],
top_modal_row(model, modal),
div![
class!["content"],
left_modal_column(model, modal),
right_modal_column(model, modal),
],
]
}
fn top_modal_row(_model: &Model, modal: &EditIssueModal) -> Node<Msg> {
let EditIssueModal {
id,
payload,
top_type_state,
link_copied,
..
} = modal;
let issue_id = *id;
let click_handler = mouse_ev(Ev::Click, move |_| {
let link = format!("http://localhost:7000/issues/{id}", id = issue_id);
let el = match seed::html_document().create_element("textarea") {
Ok(el) => el
.dyn_ref::<web_sys::HtmlTextAreaElement>()
.unwrap()
.clone(),
_ => return None as Option<Msg>,
};
seed::body().append_child(&el).unwrap();
el.set_text_content(Some(link.as_str()));
el.select();
el.set_selection_range(0, 9999).unwrap();
seed::html_document().exec_command("copy").unwrap();
seed::body().remove_child(&el).unwrap();
Some(Msg::ModalChanged(FieldChange::LinkCopied(
FieldId::CopyButtonLabel,
true,
)))
});
let close_handler = mouse_ev(Ev::Click, |_| Msg::ModalDropped);
let delete_confirmation_handler = mouse_ev(Ev::Click, move |_| {
Msg::ModalOpened(Box::new(ModalType::DeleteIssueConfirm(issue_id)))
});
let copy_button = StyledButton::build()
.empty()
.icon(Icon::Link)
.on_click(click_handler)
.children(vec![span![if *link_copied {
"Link Copied"
} else {
"Copy link"
}]])
.build()
.into_node();
let delete_button = StyledButton::build()
.empty()
.icon(Icon::Trash.into_styled_builder().size(19).build())
.on_click(delete_confirmation_handler)
.build()
.into_node();
let close_button = StyledButton::build()
.empty()
.icon(Icon::Close.into_styled_builder().size(24).build())
.on_click(close_handler)
.build()
.into_node();
let issue_types = IssueType::ordered();
let issue_type_select = StyledSelect::build()
.dropdown_width(150)
.name("type")
.text_filter(top_type_state.text_filter.as_str())
.opened(top_type_state.opened)
.valid(true)
.options(
issue_types
.iter()
.map(|t| t.to_child().name("type"))
.collect(),
)
.selected(vec![{
let id = modal.id;
let issue_type = &payload.issue_type;
issue_type
.to_child()
.name("type")
.text_owned(format!("{} - {}", issue_type, id))
}])
.build(FieldId::EditIssueModal(EditIssueModalSection::Issue(
IssueFieldId::Type,
)))
.into_node();
div![
attrs![At::Class => "topActions"],
issue_type_select,
div![
attrs![At::Class => "topActionsRight"],
copy_button,
delete_button,
close_button
],
]
}
fn left_modal_column(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
let EditIssueModal {
payload,
description_editor_mode,
comment_form,
..
} = modal;
let title = StyledInput::build()
.add_input_class("issueSummary")
.add_wrapper_class("issueSummary")
.add_wrapper_class("textarea")
.value(payload.title.as_str())
.valid(payload.title.len() >= 3)
.build(FieldId::EditIssueModal(EditIssueModalSection::Issue(
IssueFieldId::Title,
)))
.into_node();
let description_text = payload.description.as_ref().cloned().unwrap_or_default();
let description = StyledEditor::build(FieldId::EditIssueModal(EditIssueModalSection::Issue(
IssueFieldId::Description,
)))
.text(description_text)
.mode(description_editor_mode.clone())
.update_on(Ev::Change)
.build()
.into_node();
let description_field = StyledField::build().input(description).build().into_node();
let user_avatar = StyledAvatar::build()
.add_class("userAvatar")
.size(32)
.avatar_url(
model
.user
.as_ref()
.and_then(|u| u.avatar_url.as_deref())
.unwrap_or_default(),
)
.build()
.into_node();
let create_comment = if comment_form.creating && comment_form.id.is_none() {
build_comment_form(comment_form)
} else {
let creating_comment = comment_form.creating;
let handler = mouse_ev(Ev::Click, move |ev| {
ev.stop_propagation();
Msg::ModalChanged(FieldChange::ToggleCommentForm(
FieldId::EditIssueModal(EditIssueModalSection::Comment(CommentFieldId::Body)),
!creating_comment,
))
});
vec![div![class!["fakeTextArea"], "Add a comment...", handler]]
};
let comments: Vec<Node<Msg>> = model
.comments
.iter()
.flat_map(|c| comment(model, modal, c))
.collect();
div![
class!["left"],
title,
description_field,
div![
class!["comments"],
div![class!["title"], "Comments"],
div![
class!["create"],
user_avatar,
div![
class!["right"],
create_comment,
div![
class!["proTip"],
strong![class!["strong"], "Pro tip: "],
"press ",
span![class!["tipLetter"], "M"],
" to comment"
]
]
],
comments
],
]
}
fn build_comment_form(form: &CommentForm) -> Vec<Node<Msg>> {
let submit_comment_form = mouse_ev(Ev::Click, move |ev| {
ev.stop_propagation();
Msg::SaveComment
});
let close_comment_form = mouse_ev(Ev::Click, move |ev| {
ev.stop_propagation();
Msg::ModalChanged(FieldChange::ToggleCommentForm(
FieldId::EditIssueModal(EditIssueModalSection::Comment(CommentFieldId::Body)),
false,
))
});
let text_area = StyledTextarea::build(FieldId::EditIssueModal(EditIssueModalSection::Comment(
CommentFieldId::Body,
)))
.value(form.body.as_str())
.placeholder("Add a comment...")
.build()
.into_node();
let submit = StyledButton::build()
.primary()
.on_click(submit_comment_form)
.text("Save")
.build()
.into_node();
let cancel = StyledButton::build()
.empty()
.on_click(close_comment_form)
.text("Cancel")
.build()
.into_node();
vec![text_area, div![class!["actions"], submit, cancel]]
}
fn comment(model: &Model, modal: &EditIssueModal, comment: &Comment) -> Option<Node<Msg>> {
let show_form = modal.comment_form.creating && modal.comment_form.id == Some(comment.id);
let user = model.users.iter().find(|u| u.id == comment.user_id)?;
let avatar = StyledAvatar::build()
.size(32)
.avatar_url(user.avatar_url.as_deref()?)
.add_class("userAvatar")
.build()
.into_node();
let buttons = if model.user.as_ref().map(|u| u.id) == Some(comment.user_id) {
let comment_id = comment.id;
let delete_comment_handler = mouse_ev(Ev::Click, move |ev| {
ev.stop_propagation();
Msg::ModalOpened(Box::new(ModalType::DeleteCommentConfirm(comment_id)))
});
let edit_button = StyledButton::build()
.add_class("editButton")
.on_click(mouse_ev(Ev::Click, move |_| {
Msg::ModalChanged(FieldChange::EditComment(
FieldId::EditIssueModal(EditIssueModalSection::Comment(CommentFieldId::Body)),
comment_id,
))
}))
.text("Edit")
.empty()
.build()
.into_node();
let cancel_button = StyledButton::build()
.add_class("deleteButton")
.on_click(delete_comment_handler)
.text("Delete")
.empty()
.build()
.into_node();
vec![edit_button, cancel_button]
} else {
vec![]
};
let content = if show_form {
div![class!["content"], build_comment_form(&modal.comment_form)]
} else {
div![
class!["content"],
div![class!["userName"], user.name.as_str()],
p![class!["body"], comment.body.as_str()],
buttons,
]
};
let node = div![class!["styledComment"], avatar, content];
Some(node)
}
fn right_modal_column(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
let EditIssueModal {
payload,
status_state,
reporter_state,
assignees_state,
priority_state,
..
} = modal;
let status = StyledSelect::build()
.name("status")
.opened(status_state.opened)
.normal()
.text_filter(status_state.text_filter.as_str())
.options(
model
.issue_statuses
.iter()
.map(|opt| opt.to_child().name("status"))
.collect(),
)
.selected(
model
.issue_statuses
.iter()
.filter(|is| is.id == payload.issue_status_id)
.map(|is| is.to_child().name("status"))
.collect(),
)
.valid(true)
.build(FieldId::EditIssueModal(EditIssueModalSection::Issue(
IssueFieldId::IssueStatusId,
)))
.into_node();
let status_field = StyledField::build()
.input(status)
.label("Status")
.build()
.into_node();
let assignees = StyledSelect::build()
.name("assignees")
.opened(assignees_state.opened)
.empty()
.multi()
.text_filter(assignees_state.text_filter.as_str())
.options(
model
.users
.iter()
.map(|user| user.to_child().name("assignees"))
.collect(),
)
.selected(
model
.users
.iter()
.filter(|user| payload.user_ids.contains(&user.id))
.map(|user| user.to_child().name("assignees"))
.collect(),
)
.build(FieldId::EditIssueModal(EditIssueModalSection::Issue(
IssueFieldId::Assignees,
)))
.into_node();
let assignees_field = StyledField::build()
.input(assignees)
.label("Assignees")
.build()
.into_node();
let reporter = StyledSelect::build()
.name("reporter")
.opened(reporter_state.opened)
.empty()
.text_filter(reporter_state.text_filter.as_str())
.options(
model
.users
.iter()
.map(|user| user.to_child().name("reporter"))
.collect(),
)
.selected(
model
.users
.iter()
.filter(|user| payload.reporter_id == user.id)
.map(|user| user.to_child().name("reporter"))
.collect(),
)
.build(FieldId::EditIssueModal(EditIssueModalSection::Issue(
IssueFieldId::Reporter,
)))
.into_node();
let reporter_field = StyledField::build()
.input(reporter)
.label("Reporter")
.build()
.into_node();
let issue_priorities = IssuePriority::ordered();
let priority = StyledSelect::build()
.name("priority")
.opened(priority_state.opened)
.empty()
.text_filter(priority_state.text_filter.as_str())
.options(
issue_priorities
.iter()
.map(|p| p.to_child().name("priority"))
.collect(),
)
.selected(vec![payload.priority.to_child().name("priority")])
.build(FieldId::EditIssueModal(EditIssueModalSection::Issue(
IssueFieldId::Priority,
)))
.into_node();
let priority_field = StyledField::build()
.input(priority)
.label("Priority")
.build()
.into_node();
let time_tracking_type = model
.project
.as_ref()
.map(|p| p.time_tracking)
.unwrap_or_else(|| TimeTracking::Untracked);
let (estimate_field, tracking_field) = if time_tracking_type != TimeTracking::Untracked {
let estimate_field = time_tracking_field(
time_tracking_type,
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Estimate)),
"Original Estimate (hours)",
&modal.estimate,
&modal.estimate_select,
);
let tracking = tracking_link(model, modal);
let tracking_field = StyledField::build()
.label("TIME TRACKING")
.tip("")
.input(tracking)
.build()
.into_node();
(estimate_field, tracking_field)
} else {
(empty![], empty![])
};
let epic_field = epic_field(
model,
modal,
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::EpicName)),
)
.unwrap_or_else(|| empty![]);
div![
attrs![At::Class => "right"],
status_field,
assignees_field,
reporter_field,
priority_field,
estimate_field,
tracking_field,
epic_field,
]
}

View File

@ -3,8 +3,7 @@ use seed::{prelude::*, *};
use jirs_data::{TimeTracking, WsMsg}; use jirs_data::{TimeTracking, WsMsg};
use crate::{ use crate::{
modal::issues::*, model::{self, ModalType, Model, Page},
model::{self, AddIssueModal, EditIssueModal, ModalType, Model, Page},
shared::{ shared::{
find_issue, go_to_board, find_issue, go_to_board,
styled_confirm_modal::StyledConfirmModal, styled_confirm_modal::StyledConfirmModal,
@ -18,7 +17,6 @@ use crate::{
mod confirm_delete_issue; mod confirm_delete_issue;
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
mod debug_modal; mod debug_modal;
mod delete_issue_status;
pub mod issues; pub mod issues;
pub mod time_tracking; pub mod time_tracking;
@ -57,7 +55,7 @@ pub fn update(msg: &Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>
} }
Msg::ChangePage(Page::AddIssue) => { Msg::ChangePage(Page::AddIssue) => {
let mut modal = AddIssueModal::default(); let mut modal = crate::modals::issues_create::Model::default();
modal.project_id = model.project.as_ref().map(|p| p.id); modal.project_id = model.project.as_ref().map(|p| p.id);
model.modals.push(ModalType::AddIssue(Box::new(modal))); model.modals.push(ModalType::AddIssue(Box::new(modal)));
} }
@ -69,24 +67,31 @@ pub fn update(msg: &Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
Msg::GlobalKeyDown { key, .. } if key.eq(">") => { Msg::GlobalKeyDown { key, .. } if key.eq(">") => {
orders.skip();
log!(model); log!(model);
} }
Msg::GlobalKeyDown { .. } => {
orders.skip();
}
_ => (), _ => (),
} }
add_issue::update(msg, model, orders);
issue_details::update(msg, model, orders); use crate::modals::{issue_statuses_delete, issues_create, issues_edit};
delete_issue_status::update(msg, model, orders); issues_create::update(msg, model, orders);
issues_edit::update(msg, model, orders);
issue_statuses_delete::update(msg, model, orders);
} }
pub fn view(model: &model::Model) -> Node<Msg> { pub fn view(model: &model::Model) -> Node<Msg> {
use crate::modals::{issue_statuses_delete, issues_create, issues_edit};
let modals: Vec<Node<Msg>> = model let modals: Vec<Node<Msg>> = model
.modals .modals
.iter() .iter()
.map(|modal| match modal { .map(|modal| match modal {
ModalType::EditIssue(issue_id, modal) => { ModalType::EditIssue(issue_id, modal) => {
if let Some(_issue) = find_issue(model, *issue_id) { if let Some(_issue) = find_issue(model, *issue_id) {
let details = issue_details::view(model, &modal); let details = issues_edit::view(model, modal.as_ref());
StyledModal::build() StyledModal::build()
.variant(ModalVariant::Center) .variant(ModalVariant::Center)
.width(1040) .width(1040)
@ -98,7 +103,7 @@ pub fn view(model: &model::Model) -> Node<Msg> {
} }
} }
ModalType::DeleteIssueConfirm(_id) => confirm_delete_issue::view(model), ModalType::DeleteIssueConfirm(_id) => confirm_delete_issue::view(model),
ModalType::AddIssue(modal) => add_issue::view(model, modal), ModalType::AddIssue(modal) => issues_create::view(model, modal),
ModalType::DeleteCommentConfirm(comment_id) => { ModalType::DeleteCommentConfirm(comment_id) => {
let comment_id = *comment_id; let comment_id = *comment_id;
StyledConfirmModal::build() StyledConfirmModal::build()
@ -111,7 +116,7 @@ pub fn view(model: &model::Model) -> Node<Msg> {
} }
ModalType::TimeTracking(issue_id) => time_tracking::view(model, *issue_id), ModalType::TimeTracking(issue_id) => time_tracking::view(model, *issue_id),
ModalType::DeleteIssueStatusModal(delete_issue_modal) => { ModalType::DeleteIssueStatusModal(delete_issue_modal) => {
delete_issue_status::view(model, delete_issue_modal.delete_id) issue_statuses_delete::view(model, delete_issue_modal.delete_id)
} }
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
ModalType::DebugModal => debug_modal::view(model), ModalType::DebugModal => debug_modal::view(model),
@ -133,7 +138,10 @@ fn push_edit_modal(issue_id: i32, model: &mut Model, orders: &mut impl Orders<Ms
}; };
ModalType::EditIssue( ModalType::EditIssue(
issue_id, issue_id,
Box::new(EditIssueModal::new(issue, time_tracking_type)), Box::new(crate::modals::issues_edit::Model::new(
issue,
time_tracking_type,
)),
) )
}; };
send_ws_msg( send_ws_msg(

View File

@ -38,7 +38,7 @@ pub fn view(model: &Model, issue_id: IssueId) -> Node<Msg> {
.map(|p| p.time_tracking) .map(|p| p.time_tracking)
.unwrap_or_else(|| TimeTracking::Untracked); .unwrap_or_else(|| TimeTracking::Untracked);
let modal_title = div![class!["modalTitle"], "Time tracking"]; let modal_title = div![C!["modalTitle"], "Time tracking"];
let tracking = tracking_widget(model, edit_issue_modal); let tracking = tracking_widget(model, edit_issue_modal);
@ -58,9 +58,9 @@ pub fn view(model: &Model, issue_id: IssueId) -> Node<Msg> {
); );
let inputs = div![ let inputs = div![
class!["inputs"], C!["inputs"],
div![class!["inputContainer"], time_spent_field], div![C!["inputContainer"], time_spent_field],
div![class!["inputContainer"], time_remaining_field] div![C!["inputContainer"], time_remaining_field]
]; ];
let close = StyledButton::build() let close = StyledButton::build()
@ -75,7 +75,7 @@ pub fn view(model: &Model, issue_id: IssueId) -> Node<Msg> {
modal_title, modal_title,
tracking, tracking,
inputs, inputs,
div![class!["actions"], close], div![C!["actions"], close],
]) ])
.width(400) .width(400)
.build() .build()

View File

@ -1,5 +1,7 @@
pub use model::*;
pub use update::*; pub use update::*;
pub use view::*; pub use view::*;
mod model;
mod update; mod update;
mod view; mod view;

View File

@ -0,0 +1,16 @@
use jirs_data::IssueStatusId;
#[derive(Clone, Debug, PartialOrd, PartialEq)]
pub struct Model {
pub delete_id: IssueStatusId,
pub receiver_id: Option<IssueStatusId>,
}
impl Model {
pub fn new(delete_id: IssueStatusId) -> Self {
Self {
delete_id,
receiver_id: None,
}
}
}

View File

@ -1,11 +1,12 @@
use seed::prelude::*; use {
crate::{
use jirs_data::{IssueStatusId, WsMsg}; modals::issue_statuses_delete::Model as DeleteIssueStatusModal,
model::{ModalType, Model},
use crate::model::{DeleteIssueStatusModal, ModalType, Model}; Msg, WebSocketChanged,
use crate::shared::styled_confirm_modal::StyledConfirmModal; },
use crate::shared::ToNode; jirs_data::WsMsg,
use crate::{model, Msg, WebSocketChanged}; seed::prelude::*,
};
pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) { pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
let _modal: &mut Box<DeleteIssueStatusModal> = let _modal: &mut Box<DeleteIssueStatusModal> =
@ -34,16 +35,3 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
_ => (), _ => (),
}; };
} }
pub fn view(_model: &model::Model, issue_status_id: IssueStatusId) -> Node<Msg> {
StyledConfirmModal::build()
.title("Delete column")
.cancel_text("No")
.confirm_text("Yes")
.on_confirm(mouse_ev(Ev::Click, move |_| {
Msg::DeleteIssueStatus(issue_status_id)
}))
.message("Are you sure you want to delete column?")
.build()
.into_node()
}

View File

@ -0,0 +1,22 @@
use seed::prelude::*;
use jirs_data::IssueStatusId;
use crate::{
model,
shared::{styled_confirm_modal::StyledConfirmModal, ToNode},
Msg,
};
pub fn view(_model: &model::Model, issue_status_id: IssueStatusId) -> Node<Msg> {
StyledConfirmModal::build()
.title("Delete column")
.cancel_text("No")
.confirm_text("Yes")
.on_confirm(mouse_ev(Ev::Click, move |_| {
Msg::DeleteIssueStatus(issue_status_id)
}))
.message("Are you sure you want to delete column?")
.build()
.into_node()
}

View File

@ -1,5 +1,7 @@
pub use model::*;
pub use update::*; pub use update::*;
pub use view::*; pub use view::*;
mod model;
mod update; mod update;
mod view; mod view;

View File

@ -0,0 +1,195 @@
use {
crate::{
model::IssueModal,
shared::{
styled_date_time_input::StyledDateTimeInputState,
styled_input::StyledInputState,
styled_select::StyledSelectState,
styled_select_child::{StyledSelectChild, StyledSelectChildBuilder},
ToChild, ToNode,
},
FieldId, Msg,
},
jirs_data::{IssueFieldId, IssuePriority},
seed::prelude::*,
};
#[derive(Copy, Clone)]
pub enum Type {
Task,
Bug,
Story,
Epic,
}
impl From<u32> for Type {
fn from(n: u32) -> Self {
match n {
0 => Type::Task,
1 => Type::Bug,
2 => Type::Story,
3 => Type::Epic,
_ => Type::Task,
}
}
}
impl Type {
pub(crate) fn ordered<'l>() -> &'l [Type] {
use Type::*;
&[Task, Bug, Story, Epic]
}
pub(crate) fn submit_label(&self) -> &str {
use Type::*;
match self {
Epic => "Create epic",
Bug | Task | Story => "Create issue",
}
}
pub(crate) fn form_label(&self) -> &str {
use Type::*;
match self {
Epic => "Create epic",
Bug | Task | Story => "Create issue",
}
}
pub(crate) fn submit_action(&self) -> Msg {
use Type::*;
match self {
Epic => Msg::AddEpic,
Bug | Task | Story => Msg::AddIssue,
}
}
}
impl<'l> ToChild<'l> for Type {
type Builder = StyledSelectChildBuilder<'l>;
fn to_child<'m: 'l>(&'m self) -> Self::Builder {
let name = match self {
Type::Task => "Task",
Type::Bug => "Bug",
Type::Story => "Story",
Type::Epic => "Epic",
};
let value = match self {
Type::Task => 0,
Type::Bug => 1,
Type::Story => 2,
Type::Epic => 3,
};
let icon = match self {
Type::Task => crate::shared::styled_icon::Icon::Task,
Type::Bug => crate::shared::styled_icon::Icon::Bug,
Type::Story => crate::shared::styled_icon::Icon::Story,
Type::Epic => crate::shared::styled_icon::Icon::Epic,
};
let type_icon = crate::shared::styled_icon::StyledIcon::build(icon)
.add_class(name)
.build()
.into_node();
StyledSelectChild::build()
.add_class(name)
.text(name)
.icon(type_icon)
.value(value)
}
}
#[derive(Clone, Debug, PartialOrd, PartialEq)]
pub struct Model {
pub priority: IssuePriority,
pub description: Option<String>,
pub description_text: Option<String>,
pub estimate: Option<i32>,
pub time_spent: Option<i32>,
pub time_remaining: Option<i32>,
pub project_id: Option<jirs_data::ProjectId>,
pub user_ids: Vec<jirs_data::UserId>,
pub reporter_id: Option<jirs_data::UserId>,
pub issue_status_id: jirs_data::IssueStatusId,
pub epic_id: Option<jirs_data::UserId>,
// modal fields
pub title_state: StyledInputState,
pub type_state: StyledSelectState,
pub reporter_state: StyledSelectState,
pub assignees_state: StyledSelectState,
pub priority_state: StyledSelectState,
// epic
pub epic_name_state: StyledSelectState,
pub epic_starts_at_state: StyledDateTimeInputState,
pub epic_ends_at_state: StyledDateTimeInputState,
}
impl Default for Model {
fn default() -> Self {
Self {
priority: Default::default(),
description: Default::default(),
description_text: Default::default(),
estimate: Default::default(),
time_spent: Default::default(),
time_remaining: Default::default(),
project_id: Default::default(),
user_ids: Default::default(),
reporter_id: Default::default(),
issue_status_id: Default::default(),
epic_id: Default::default(),
title_state: StyledInputState::new(FieldId::AddIssueModal(IssueFieldId::Title), ""),
type_state: StyledSelectState::new(FieldId::AddIssueModal(IssueFieldId::Type), vec![]),
reporter_state: StyledSelectState::new(
FieldId::AddIssueModal(IssueFieldId::Reporter),
vec![],
),
assignees_state: StyledSelectState::new(
FieldId::AddIssueModal(IssueFieldId::Assignees),
vec![],
),
priority_state: StyledSelectState::new(
FieldId::AddIssueModal(IssueFieldId::Priority),
vec![],
),
// epic
epic_name_state: StyledSelectState::new(
FieldId::AddIssueModal(IssueFieldId::EpicName),
vec![],
),
epic_starts_at_state: StyledDateTimeInputState::new(
FieldId::AddIssueModal(IssueFieldId::EpicStartsAt),
None,
),
epic_ends_at_state: StyledDateTimeInputState::new(
FieldId::AddIssueModal(IssueFieldId::EpicEndsAt),
None,
),
}
}
}
impl IssueModal for Model {
fn epic_id_value(&self) -> Option<u32> {
self.epic_name_state.values.get(0).cloned()
}
fn epic_state(&self) -> &StyledSelectState {
&self.epic_name_state
}
fn update_states(&mut self, msg: &Msg, orders: &mut impl Orders<Msg>) {
self.title_state.update(msg);
self.assignees_state.update(msg, orders);
self.reporter_state.update(msg, orders);
self.type_state.update(msg, orders);
self.priority_state.update(msg, orders);
self.epic_name_state.update(msg, orders);
self.epic_starts_at_state.update(msg, orders);
self.epic_ends_at_state.update(msg, orders);
}
}

View File

@ -0,0 +1,122 @@
use {
crate::{
model::{IssueModal, ModalType},
shared::styled_select::StyledSelectChanged,
ws::send_ws_msg,
FieldId, Msg, WebSocketChanged,
},
jirs_data::{IssueFieldId, UserId, WsMsg},
seed::prelude::*,
};
pub fn update(msg: &Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) {
let modal = model.modals.iter_mut().find(|modal| match modal {
ModalType::AddIssue(..) => true,
_ => false,
});
let modal = match modal {
Some(ModalType::AddIssue(modal)) => modal,
_ => return,
};
modal.update_states(msg, orders);
match msg {
Msg::AddEpic => {
send_ws_msg(
WsMsg::EpicCreate(modal.title_state.value.clone()),
model.ws.as_ref(),
orders,
);
}
Msg::AddIssue => {
let user_id = model.user.as_ref().map(|u| u.id).unwrap_or_default();
let project_id = model.project.as_ref().map(|p| p.id).unwrap_or_default();
let type_value = modal.type_state.values.get(0).cloned().unwrap_or_default();
match type_value {
0 | 1 | 2 => {
let issue_type = type_value.into();
let payload = jirs_data::CreateIssuePayload {
title: modal.title_state.value.clone(),
issue_type,
issue_status_id: modal.issue_status_id,
priority: modal.priority,
description: modal.description.clone(),
description_text: modal.description_text.clone(),
estimate: modal.estimate,
time_spent: modal.time_spent,
time_remaining: modal.time_remaining,
project_id: modal.project_id.unwrap_or(project_id),
user_ids: modal.user_ids.clone(),
reporter_id: modal.reporter_id.unwrap_or_else(|| user_id),
epic_id: modal.epic_id,
};
send_ws_msg(
jirs_data::WsMsg::IssueCreate(payload),
model.ws.as_ref(),
orders,
);
}
_ => {
//
}
};
}
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::IssueCreated(issue))) => {
model.issues.push(issue.clone());
orders.skip().send_msg(Msg::ModalDropped);
}
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::EpicCreated(_))) => {
orders.skip().send_msg(Msg::ModalDropped);
}
Msg::StrInputChanged(FieldId::AddIssueModal(IssueFieldId::Description), value) => {
modal.description = Some(value.clone());
}
// ReporterAddIssueModal
Msg::StyledSelectChanged(
FieldId::AddIssueModal(IssueFieldId::Reporter),
StyledSelectChanged::Changed(id),
) => {
modal.reporter_id = id.map(|n| n as UserId);
}
// AssigneesAddIssueModal
Msg::StyledSelectChanged(
FieldId::AddIssueModal(IssueFieldId::Assignees),
StyledSelectChanged::Changed(Some(id)),
) => {
let id = *id as UserId;
if !modal.user_ids.contains(&id) {
modal.user_ids.push(id);
}
}
Msg::StyledSelectChanged(
FieldId::AddIssueModal(IssueFieldId::Assignees),
StyledSelectChanged::RemoveMulti(id),
) => {
let id = *id as i32;
let mut old: Vec<i32> = vec![];
std::mem::swap(&mut old, &mut modal.user_ids);
for user_id in old {
if id != user_id {
modal.user_ids.push(user_id);
}
}
}
// IssuePriorityAddIssueModal
Msg::StyledSelectChanged(
FieldId::AddIssueModal(IssueFieldId::Priority),
StyledSelectChanged::Changed(Some(id)),
) => {
modal.priority = (*id).into();
}
_ => (),
}
}

View File

@ -1,227 +1,21 @@
use seed::{prelude::*, *}; use {
crate::{
use jirs_data::{IssueFieldId, IssuePriority, ToVec, UserId, WsMsg}; modal::issues::epic_field,
modals::issues_create::{Model as AddIssueModal, Type},
use crate::shared::styled_date_time_input::StyledDateTimeInput; model::Model,
use crate::{ shared::{
modal::issues::epic_field, styled_button::StyledButton, styled_date_time_input::StyledDateTimeInput,
model::{AddIssueModal, IssueModal, ModalType, Model}, styled_field::StyledField, styled_form::StyledForm, styled_input::StyledInput,
shared::{ styled_modal::StyledModal, styled_select::StyledSelect,
styled_button::StyledButton, styled_textarea::StyledTextarea, ToChild, ToNode,
styled_field::StyledField, },
styled_form::StyledForm, FieldId, Msg,
styled_input::StyledInput,
styled_modal::{StyledModal, Variant as ModalVariant},
styled_select::StyledSelect,
styled_select::StyledSelectChanged,
styled_select_child::{StyledSelectChild, StyledSelectChildBuilder},
styled_textarea::StyledTextarea,
ToChild, ToNode,
}, },
ws::send_ws_msg, jirs_data::{IssueFieldId, IssuePriority, ToVec},
FieldId, Msg, WebSocketChanged, seed::{prelude::*, *},
}; };
#[derive(Copy, Clone)] pub fn view(model: &Model, modal: &Box<AddIssueModal>) -> Node<Msg> {
enum Type {
Task,
Bug,
Story,
Epic,
}
impl From<u32> for Type {
fn from(n: u32) -> Self {
match n {
0 => Type::Task,
1 => Type::Bug,
2 => Type::Story,
3 => Type::Epic,
_ => Type::Task,
}
}
}
impl Type {
fn ordered<'l>() -> &'l [Type] {
use Type::*;
&[Task, Bug, Story, Epic]
}
fn submit_label(&self) -> &str {
use Type::*;
match self {
Epic => "Create epic",
Bug | Task | Story => "Create issue",
}
}
fn form_label(&self) -> &str {
use Type::*;
match self {
Epic => "Create epic",
Bug | Task | Story => "Create issue",
}
}
fn submit_action(&self) -> Msg {
use Type::*;
match self {
Epic => Msg::AddEpic,
Bug | Task | Story => Msg::AddIssue,
}
}
}
impl<'l> ToChild<'l> for Type {
type Builder = StyledSelectChildBuilder<'l>;
fn to_child<'m: 'l>(&'m self) -> Self::Builder {
let name = match self {
Type::Task => "Task",
Type::Bug => "Bug",
Type::Story => "Story",
Type::Epic => "Epic",
};
let value = match self {
Type::Task => 0,
Type::Bug => 1,
Type::Story => 2,
Type::Epic => 3,
};
let icon = match self {
Type::Task => crate::shared::styled_icon::Icon::Task,
Type::Bug => crate::shared::styled_icon::Icon::Bug,
Type::Story => crate::shared::styled_icon::Icon::Story,
Type::Epic => crate::shared::styled_icon::Icon::Epic,
};
let type_icon = crate::shared::styled_icon::StyledIcon::build(icon)
.add_class(name)
.build()
.into_node();
StyledSelectChild::build()
.add_class(name)
.text(name)
.icon(type_icon)
.value(value)
}
}
pub fn update(msg: &Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) {
let modal = model.modals.iter_mut().find(|modal| match modal {
ModalType::AddIssue(..) => true,
_ => false,
});
let modal = match modal {
Some(ModalType::AddIssue(modal)) => modal,
_ => return,
};
modal.update_states(msg, orders);
match msg {
Msg::AddEpic => {
send_ws_msg(
WsMsg::EpicCreate(modal.title_state.value.clone()),
model.ws.as_ref(),
orders,
);
}
Msg::AddIssue => {
let user_id = model.user.as_ref().map(|u| u.id).unwrap_or_default();
let project_id = model.project.as_ref().map(|p| p.id).unwrap_or_default();
let type_value = modal.type_state.values.get(0).cloned().unwrap_or_default();
match type_value {
0 | 1 | 2 => {
let issue_type = type_value.into();
let payload = jirs_data::CreateIssuePayload {
title: modal.title_state.value.clone(),
issue_type,
issue_status_id: modal.issue_status_id,
priority: modal.priority,
description: modal.description.clone(),
description_text: modal.description_text.clone(),
estimate: modal.estimate,
time_spent: modal.time_spent,
time_remaining: modal.time_remaining,
project_id: modal.project_id.unwrap_or(project_id),
user_ids: modal.user_ids.clone(),
reporter_id: modal.reporter_id.unwrap_or_else(|| user_id),
epic_id: modal.epic_id,
};
send_ws_msg(
jirs_data::WsMsg::IssueCreate(payload),
model.ws.as_ref(),
orders,
);
}
_ => {
//
}
};
}
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::IssueCreated(issue))) => {
model.issues.push(issue.clone());
orders.skip().send_msg(Msg::ModalDropped);
}
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::EpicCreated(_))) => {
orders.skip().send_msg(Msg::ModalDropped);
}
Msg::StrInputChanged(FieldId::AddIssueModal(IssueFieldId::Description), value) => {
modal.description = Some(value.clone());
}
// ReporterAddIssueModal
Msg::StyledSelectChanged(
FieldId::AddIssueModal(IssueFieldId::Reporter),
StyledSelectChanged::Changed(id),
) => {
modal.reporter_id = id.map(|n| n as UserId);
}
// AssigneesAddIssueModal
Msg::StyledSelectChanged(
FieldId::AddIssueModal(IssueFieldId::Assignees),
StyledSelectChanged::Changed(Some(id)),
) => {
let id = *id as UserId;
if !modal.user_ids.contains(&id) {
modal.user_ids.push(id);
}
}
Msg::StyledSelectChanged(
FieldId::AddIssueModal(IssueFieldId::Assignees),
StyledSelectChanged::RemoveMulti(id),
) => {
let id = *id as i32;
let mut old: Vec<i32> = vec![];
std::mem::swap(&mut old, &mut modal.user_ids);
for user_id in old {
if id != user_id {
modal.user_ids.push(user_id);
}
}
}
// IssuePriorityAddIssueModal
Msg::StyledSelectChanged(
FieldId::AddIssueModal(IssueFieldId::Priority),
StyledSelectChanged::Changed(Some(id)),
) => {
modal.priority = (*id).into();
}
_ => (),
}
}
pub fn view(model: &Model, modal: &AddIssueModal) -> Node<Msg> {
let issue_type = modal let issue_type = modal
.type_state .type_state
.values .values
@ -270,8 +64,11 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node<Msg> {
let reporter_field = reporter_field(model, modal); let reporter_field = reporter_field(model, modal);
let assignees_field = assignees_field(model, modal); let assignees_field = assignees_field(model, modal);
let issue_priority_field = issue_priority_field(modal); let issue_priority_field = issue_priority_field(modal);
let epic_field = let epic_field = epic_field(
epic_field(model, modal, FieldId::AddIssueModal(IssueFieldId::EpicName)); model,
modal.as_ref(),
FieldId::AddIssueModal(IssueFieldId::EpicName),
);
form.add_field(short_summary_field) form.add_field(short_summary_field)
.add_field(description_field) .add_field(description_field)
@ -317,13 +114,13 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node<Msg> {
StyledModal::build() StyledModal::build()
.add_class("addIssue") .add_class("addIssue")
.width(0) .width(0)
.variant(ModalVariant::Center) .variant(crate::shared::styled_modal::Variant::Center)
.children(vec![form]) .children(vec![form])
.build() .build()
.into_node() .into_node()
} }
fn issue_type_field(modal: &AddIssueModal) -> Node<Msg> { fn issue_type_field(modal: &Box<AddIssueModal>) -> Node<Msg> {
let select_type = StyledSelect::build() let select_type = StyledSelect::build()
.name("type") .name("type")
.normal() .normal()
@ -351,7 +148,7 @@ fn issue_type_field(modal: &AddIssueModal) -> Node<Msg> {
.into_node() .into_node()
} }
fn short_summary_field(modal: &AddIssueModal) -> Node<Msg> { fn short_summary_field(modal: &Box<AddIssueModal>) -> Node<Msg> {
let short_summary = StyledInput::build() let short_summary = StyledInput::build()
.state(&modal.title_state) .state(&modal.title_state)
.build(FieldId::AddIssueModal(IssueFieldId::Title)) .build(FieldId::AddIssueModal(IssueFieldId::Title))

View File

@ -0,0 +1,7 @@
pub use model::*;
pub use update::*;
pub use view::*;
mod model;
mod update;
mod view;

View File

@ -0,0 +1,164 @@
use {
crate::{
modal::time_tracking::value_for_time_tracking,
model::{CommentForm, IssueModal},
shared::{
styled_date_time_input::StyledDateTimeInputState, styled_editor::Mode,
styled_input::StyledInputState, styled_select::StyledSelectState,
},
EditIssueModalSection, FieldId, Msg,
},
jirs_data::{Issue, IssueFieldId, IssueId, TimeTracking, UpdateIssuePayload},
seed::prelude::*,
};
use crate::shared::styled_editor::StyledEditorState;
#[derive(Clone, Debug, PartialOrd, PartialEq)]
pub struct Model {
pub id: IssueId,
pub link_copied: bool,
pub payload: UpdateIssuePayload,
pub top_type_state: StyledSelectState,
pub status_state: StyledSelectState,
pub reporter_state: StyledSelectState,
pub assignees_state: StyledSelectState,
pub priority_state: StyledSelectState,
pub epic_name_state: StyledSelectState,
pub epic_starts_at_state: StyledDateTimeInputState,
pub epic_ends_at_state: StyledDateTimeInputState,
pub estimate: StyledInputState,
pub estimate_select: StyledSelectState,
pub time_spent: StyledInputState,
pub time_spent_select: StyledSelectState,
pub time_remaining: StyledInputState,
pub time_remaining_select: StyledSelectState,
pub description_state: StyledEditorState,
// comments
pub comment_form: CommentForm,
}
impl Model {
pub fn new(issue: &Issue, time_tracking_type: TimeTracking) -> Self {
Self {
id: issue.id,
link_copied: false,
payload: UpdateIssuePayload {
title: issue.title.clone(),
issue_type: issue.issue_type,
issue_status_id: issue.issue_status_id,
priority: issue.priority,
list_position: issue.list_position,
description: issue.description.clone(),
description_text: issue.description_text.clone(),
estimate: issue.estimate,
time_spent: issue.time_spent,
time_remaining: issue.time_remaining,
project_id: issue.project_id,
reporter_id: issue.reporter_id,
user_ids: issue.user_ids.clone(),
},
top_type_state: StyledSelectState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Type)),
issue.estimate.map(|v| vec![v as u32]).unwrap_or_default(),
),
status_state: StyledSelectState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::IssueStatusId)),
vec![issue.issue_status_id as u32],
),
reporter_state: StyledSelectState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Reporter)),
vec![issue.reporter_id as u32],
),
assignees_state: StyledSelectState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Assignees)),
issue.user_ids.iter().map(|n| *n as u32).collect(),
),
priority_state: StyledSelectState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Priority)),
vec![issue.priority.into()],
),
estimate: StyledInputState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Estimate)),
value_for_time_tracking(&issue.estimate, &time_tracking_type),
),
estimate_select: StyledSelectState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Estimate)),
issue.estimate.map(|n| vec![n as u32]).unwrap_or_default(),
),
time_spent: StyledInputState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::TimeSpent)),
value_for_time_tracking(&issue.time_spent, &time_tracking_type),
),
time_spent_select: StyledSelectState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::TimeSpent)),
issue.time_spent.map(|n| vec![n as u32]).unwrap_or_default(),
),
time_remaining: StyledInputState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::TimeRemaining)),
value_for_time_tracking(&issue.time_remaining, &time_tracking_type),
),
time_remaining_select: StyledSelectState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::TimeRemaining)),
issue
.time_remaining
.map(|n| vec![n as u32])
.unwrap_or_default(),
),
description_state: StyledEditorState::new(
Mode::View,
issue.description_text.as_deref().unwrap_or(""),
),
comment_form: CommentForm {
id: None,
body: String::new(),
creating: false,
},
// epic
epic_name_state: StyledSelectState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::EpicName)),
issue
.epic_id
.as_ref()
.map(|id| vec![*id as u32])
.unwrap_or_default(),
),
epic_starts_at_state: StyledDateTimeInputState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::EpicStartsAt)),
None,
),
epic_ends_at_state: StyledDateTimeInputState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::EpicStartsAt)),
None,
),
}
}
}
impl IssueModal for Model {
fn epic_id_value(&self) -> Option<u32> {
self.epic_name_state.values.get(0).cloned()
}
fn epic_state(&self) -> &StyledSelectState {
&self.epic_name_state
}
fn update_states(&mut self, msg: &Msg, orders: &mut impl Orders<Msg>) {
self.top_type_state.update(msg, orders);
self.status_state.update(msg, orders);
self.reporter_state.update(msg, orders);
self.assignees_state.update(msg, orders);
self.priority_state.update(msg, orders);
self.estimate.update(msg);
self.estimate_select.update(msg, orders);
self.time_spent.update(msg);
self.time_spent_select.update(msg, orders);
self.time_remaining.update(msg);
self.time_remaining_select.update(msg, orders);
self.epic_name_state.update(msg, orders);
}
}

View File

@ -0,0 +1,347 @@
use {
crate::{
modals::issues_edit::Model as EditIssueModal,
model::{IssueModal, ModalType, Model},
shared::styled_select::StyledSelectChanged,
ws::send_ws_msg,
EditIssueModalSection, FieldChange, FieldId, Msg, OperationKind, ResourceKind,
},
jirs_data::*,
seed::prelude::*,
};
pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
let modal: &mut EditIssueModal = match model.modals.get_mut(0) {
Some(ModalType::EditIssue(_issue_id, modal)) => modal,
_ => return,
};
modal.update_states(msg, orders);
match msg {
Msg::ResourceChanged(ResourceKind::Issue, OperationKind::SingleModified, Some(id)) => {
if let Some(issue) = model.issues_by_id.get(id) {
modal.payload = issue.clone().into();
}
}
Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Type)),
StyledSelectChanged::Changed(Some(value)),
) => {
modal.payload.issue_type = (*value).into();
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::Type,
PayloadVariant::IssueType(modal.payload.issue_type),
),
model.ws.as_ref(),
orders,
);
}
Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::IssueStatusId)),
StyledSelectChanged::Changed(Some(value)),
) => {
modal.payload.issue_status_id = *value as IssueStatusId;
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::IssueStatusId,
PayloadVariant::I32(modal.payload.issue_status_id),
),
model.ws.as_ref(),
orders,
);
}
Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Reporter)),
StyledSelectChanged::Changed(Some(value)),
) => {
modal.payload.reporter_id = *value as i32;
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::Reporter,
PayloadVariant::I32(modal.payload.reporter_id),
),
model.ws.as_ref(),
orders,
);
}
Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Assignees)),
StyledSelectChanged::Changed(Some(value)),
) => {
modal.payload.user_ids.push(*value as i32);
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::Assignees,
PayloadVariant::VecI32(modal.payload.user_ids.clone()),
),
model.ws.as_ref(),
orders,
);
}
Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Assignees)),
StyledSelectChanged::RemoveMulti(value),
) => {
let mut old = vec![];
std::mem::swap(&mut old, &mut modal.payload.user_ids);
let dropped = *value as i32;
for id in old.into_iter() {
if id != dropped {
modal.payload.user_ids.push(id);
}
}
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::Assignees,
PayloadVariant::VecI32(modal.payload.user_ids.clone()),
),
model.ws.as_ref(),
orders,
);
}
Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Priority)),
StyledSelectChanged::Changed(Some(value)),
) => {
modal.payload.priority = (*value).into();
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::Priority,
PayloadVariant::IssuePriority(modal.payload.priority),
),
model.ws.as_ref(),
orders,
);
}
Msg::StrInputChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Title)),
value,
) => {
modal.payload.title = value.clone();
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::Title,
PayloadVariant::String(modal.payload.title.clone()),
),
model.ws.as_ref(),
orders,
);
}
Msg::StrInputChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Description)),
value,
) => {
// modal.payload.description = Some(value.clone());
modal.payload.description_text = Some(value.clone());
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::Description,
PayloadVariant::String(
modal
.payload
.description
.as_ref()
.cloned()
.unwrap_or_default(),
),
),
model.ws.as_ref(),
orders,
);
orders.skip();
}
// TimeSpent
Msg::StrInputChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::TimeSpent)),
..,
) => {
modal.payload.time_spent = modal.time_spent.represent_f64_as_i32();
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::TimeSpent,
PayloadVariant::OptionI32(modal.payload.time_spent),
),
model.ws.as_ref(),
orders,
);
}
Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::TimeSpent)),
StyledSelectChanged::Changed(..),
) => {
modal.payload.time_spent = modal.time_spent_select.values.get(0).map(|n| *n as i32);
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::TimeSpent,
PayloadVariant::OptionI32(modal.payload.time_spent),
),
model.ws.as_ref(),
orders,
);
}
// Time Remaining
Msg::StrInputChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::TimeRemaining)),
..,
) => {
modal.payload.time_remaining = modal.time_remaining.represent_f64_as_i32();
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::TimeRemaining,
PayloadVariant::OptionI32(modal.payload.time_remaining),
),
model.ws.as_ref(),
orders,
);
}
Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::TimeRemaining)),
StyledSelectChanged::Changed(..),
) => {
modal.payload.time_remaining =
modal.time_remaining_select.values.get(0).map(|n| *n as i32);
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::TimeRemaining,
PayloadVariant::OptionI32(modal.payload.time_remaining),
),
model.ws.as_ref(),
orders,
);
}
// Estimate
Msg::StrInputChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Estimate)),
..,
) => {
modal.payload.estimate = modal.estimate.represent_f64_as_i32();
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::Estimate,
PayloadVariant::OptionI32(modal.payload.estimate),
),
model.ws.as_ref(),
orders,
);
}
Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Estimate)),
StyledSelectChanged::Changed(..),
) => {
modal.payload.estimate = modal.estimate_select.values.get(0).map(|n| *n as i32);
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::Estimate,
PayloadVariant::OptionI32(modal.payload.estimate),
),
model.ws.as_ref(),
orders,
);
}
Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::EpicName)),
StyledSelectChanged::Changed(v),
) => {
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::EpicName,
PayloadVariant::OptionI32(v.map(|n| n as EpicId)),
),
model.ws.as_ref(),
orders,
);
}
Msg::ModalChanged(FieldChange::TabChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Description)),
mode,
)) => {
modal.description_state.mode = mode.clone();
}
Msg::ModalChanged(FieldChange::ToggleCommentForm(
FieldId::EditIssueModal(EditIssueModalSection::Comment(CommentFieldId::Body)),
flag,
)) => {
modal.comment_form.creating = *flag;
if !*flag {
modal.comment_form.body.clear();
modal.comment_form.id = None;
}
}
//
// comments
//
Msg::StrInputChanged(
FieldId::EditIssueModal(EditIssueModalSection::Comment(CommentFieldId::Body)),
text,
) => {
modal.comment_form.body = text.clone();
}
Msg::SaveComment => {
let msg = match modal.comment_form.id {
Some(id) => WsMsg::CommentUpdate(UpdateCommentPayload {
id,
body: modal.comment_form.body.clone(),
}),
_ => WsMsg::CommentCreate(CreateCommentPayload {
user_id: None,
body: modal.comment_form.body.clone(),
issue_id: modal.id,
}),
};
send_ws_msg(msg, model.ws.as_ref(), orders);
orders
.skip()
.send_msg(Msg::ModalChanged(FieldChange::ToggleCommentForm(
FieldId::EditIssueModal(EditIssueModalSection::Comment(CommentFieldId::Body)),
false,
)));
}
Msg::ModalChanged(FieldChange::EditComment(
FieldId::EditIssueModal(EditIssueModalSection::Comment(CommentFieldId::Body)),
comment_id,
)) => {
let id = *comment_id;
let body = model
.comments
.iter()
.find(|c| c.id == id)
.map(|c| c.body.clone())
.unwrap_or_default();
modal.comment_form.body = body;
modal.comment_form.id = Some(id);
modal.comment_form.creating = true;
}
Msg::DeleteComment(comment_id) => {
send_ws_msg(WsMsg::CommentDelete(*comment_id), model.ws.as_ref(), orders);
orders.skip().send_msg(Msg::ModalDropped);
}
// global
Msg::GlobalKeyDown { key, .. } if key.as_str() == "m" && !modal.comment_form.creating => {
orders
.skip()
.send_msg(Msg::ModalChanged(FieldChange::ToggleCommentForm(
FieldId::EditIssueModal(EditIssueModalSection::Comment(CommentFieldId::Body)),
true,
)));
}
_ => (),
}
}

View File

@ -0,0 +1,109 @@
use {
crate::{
modals::issues_edit::Model as EditIssueModal,
model::{CommentForm, ModalType, Model},
shared::{
styled_avatar::StyledAvatar, styled_button::StyledButton,
styled_textarea::StyledTextarea, ToNode,
},
EditIssueModalSection, FieldChange, FieldId, Msg,
},
jirs_data::{Comment, CommentFieldId},
seed::{prelude::*, *},
};
pub fn build_comment_form(form: &CommentForm) -> Vec<Node<Msg>> {
let submit_comment_form = mouse_ev(Ev::Click, move |ev| {
ev.stop_propagation();
Msg::SaveComment
});
let close_comment_form = mouse_ev(Ev::Click, move |ev| {
ev.stop_propagation();
Msg::ModalChanged(FieldChange::ToggleCommentForm(
FieldId::EditIssueModal(EditIssueModalSection::Comment(CommentFieldId::Body)),
false,
))
});
let text_area = StyledTextarea::build(FieldId::EditIssueModal(EditIssueModalSection::Comment(
CommentFieldId::Body,
)))
.value(form.body.as_str())
.placeholder("Add a comment...")
.build()
.into_node();
let submit = StyledButton::build()
.primary()
.on_click(submit_comment_form)
.text("Save")
.build()
.into_node();
let cancel = StyledButton::build()
.empty()
.on_click(close_comment_form)
.text("Cancel")
.build()
.into_node();
vec![text_area, div![C!["actions"], submit, cancel]]
}
pub fn comment(model: &Model, modal: &EditIssueModal, comment: &Comment) -> Option<Node<Msg>> {
let show_form = modal.comment_form.creating && modal.comment_form.id == Some(comment.id);
let user = model.users_by_id.get(&comment.user_id)?;
let avatar = StyledAvatar::build()
.size(32)
.avatar_url(user.avatar_url.as_deref()?)
.add_class("userAvatar")
.build()
.into_node();
let buttons = if model.user.as_ref().map(|u| u.id) == Some(comment.user_id) {
let comment_id = comment.id;
let delete_comment_handler = mouse_ev(Ev::Click, move |ev| {
ev.stop_propagation();
Msg::ModalOpened(Box::new(ModalType::DeleteCommentConfirm(comment_id)))
});
let edit_button = StyledButton::build()
.add_class("editButton")
.on_click(mouse_ev(Ev::Click, move |_| {
Msg::ModalChanged(FieldChange::EditComment(
FieldId::EditIssueModal(EditIssueModalSection::Comment(CommentFieldId::Body)),
comment_id,
))
}))
.text("Edit")
.empty()
.build()
.into_node();
let cancel_button = StyledButton::build()
.add_class("deleteButton")
.on_click(delete_comment_handler)
.text("Delete")
.empty()
.build()
.into_node();
vec![edit_button, cancel_button]
} else {
vec![]
};
let content = if show_form {
div![C!["content"], build_comment_form(&modal.comment_form)]
} else {
div![
C!["content"],
div![C!["userName"], user.name.as_str()],
p![C!["body"], comment.body.as_str()],
buttons,
]
};
let node = div![C!["styledComment"], avatar, content];
Some(node)
}

View File

@ -0,0 +1,397 @@
use {
crate::{
modal::{issues::epic_field, time_tracking::time_tracking_field},
modals::issues_edit::Model as EditIssueModal,
model::{ModalType, Model},
shared::{
styled_avatar::StyledAvatar, styled_button::StyledButton, styled_editor::StyledEditor,
styled_field::StyledField, styled_icon::Icon, styled_input::StyledInput,
styled_select::StyledSelect, tracking_widget::tracking_link, ToChild, ToNode,
},
EditIssueModalSection, FieldChange, FieldId, Msg,
},
comments::*,
jirs_data::{CommentFieldId, IssueFieldId, IssuePriority, IssueType, TimeTracking, ToVec},
seed::{prelude::*, *},
};
mod comments;
pub fn view(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
div![
C!["issueDetails"],
modal_header(model, modal),
div![
C!["content"],
left_modal_column(model, modal),
right_modal_column(model, modal),
],
]
}
fn modal_header(_model: &Model, modal: &EditIssueModal) -> Node<Msg> {
let EditIssueModal {
id,
payload,
top_type_state,
link_copied,
..
} = modal;
let issue_id = *id;
let click_handler = mouse_ev(Ev::Click, move |_| {
let link = format!("http://localhost:7000/issues/{id}", id = issue_id);
let el = match seed::html_document().create_element("textarea") {
Ok(el) => el
.dyn_ref::<web_sys::HtmlTextAreaElement>()
.unwrap()
.clone(),
_ => return None as Option<Msg>,
};
seed::body().append_child(&el).unwrap();
el.set_text_content(Some(link.as_str()));
el.select();
el.set_selection_range(0, 9999).unwrap();
seed::html_document().exec_command("copy").unwrap();
seed::body().remove_child(&el).unwrap();
Some(Msg::ModalChanged(FieldChange::LinkCopied(
FieldId::CopyButtonLabel,
true,
)))
});
let close_handler = mouse_ev(Ev::Click, |ev| {
ev.prevent_default();
ev.stop_propagation();
seed::Url::new().add_path_part("board").go_and_push();
Msg::ModalDropped
});
let delete_confirmation_handler = mouse_ev(Ev::Click, move |_| {
Msg::ModalOpened(Box::new(ModalType::DeleteIssueConfirm(issue_id)))
});
let copy_button = StyledButton::build()
.empty()
.icon(Icon::Link)
.on_click(click_handler)
.children(vec![span![if *link_copied {
"Link Copied"
} else {
"Copy link"
}]])
.build()
.into_node();
let delete_button = StyledButton::build()
.empty()
.icon(Icon::Trash.into_styled_builder().size(19).build())
.on_click(delete_confirmation_handler)
.build()
.into_node();
let close_button = StyledButton::build()
.empty()
.icon(Icon::Close.into_styled_builder().size(24).build())
.on_click(close_handler)
.build()
.into_node();
let issue_types = IssueType::ordered();
let issue_type_select = StyledSelect::build()
.dropdown_width(150)
.name("type")
.text_filter(top_type_state.text_filter.as_str())
.opened(top_type_state.opened)
.valid(true)
.options(
issue_types
.iter()
.map(|t| t.to_child().name("type"))
.collect(),
)
.selected(vec![{
let id = modal.id;
let issue_type = &payload.issue_type;
issue_type
.to_child()
.name("type")
.text_owned(format!("{} - {}", issue_type, id))
}])
.build(FieldId::EditIssueModal(EditIssueModalSection::Issue(
IssueFieldId::Type,
)))
.into_node();
div![
C!["topActions"],
issue_type_select,
div![
C!["topActionsRight"],
copy_button,
delete_button,
close_button
],
]
}
fn left_modal_column(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
let EditIssueModal {
payload,
description_state,
comment_form,
..
} = modal;
let title = StyledInput::build()
.add_input_class("issueSummary")
.add_wrapper_class("issueSummary")
.add_wrapper_class("textarea")
.value(payload.title.as_str())
.valid(payload.title.len() >= 3)
.build(FieldId::EditIssueModal(EditIssueModalSection::Issue(
IssueFieldId::Title,
)))
.into_node();
let description = {
StyledEditor::build(FieldId::EditIssueModal(EditIssueModalSection::Issue(
IssueFieldId::Description,
)))
.initial_text(description_state.initial_text.as_str())
.html(payload.description.as_ref().cloned().unwrap_or_default())
.mode(description_state.mode.clone())
.update_on(Ev::Change)
.build()
.into_node()
};
let description_field = StyledField::build().input(description).build().into_node();
let user_avatar = StyledAvatar::build()
.add_class("userAvatar")
.size(32)
.avatar_url(
model
.user
.as_ref()
.and_then(|u| u.avatar_url.as_deref())
.unwrap_or_default(),
)
.build()
.into_node();
let create_comment = if comment_form.creating && comment_form.id.is_none() {
build_comment_form(comment_form)
} else {
let creating_comment = comment_form.creating;
let handler = mouse_ev(Ev::Click, move |ev| {
ev.stop_propagation();
Msg::ModalChanged(FieldChange::ToggleCommentForm(
FieldId::EditIssueModal(EditIssueModalSection::Comment(CommentFieldId::Body)),
!creating_comment,
))
});
vec![div![C!["fakeTextArea"], "Add a comment...", handler]]
};
let comments: Vec<Node<Msg>> = model
.comments
.iter()
.flat_map(|c| comment(model, modal, c))
.collect();
div![
C!["left"],
title,
description_field,
div![
C!["comments"],
div![C!["title"], "Comments"],
div![
C!["create"],
user_avatar,
div![
C!["right"],
create_comment,
div![
C!["proTip"],
strong![C!["strong"], "Pro tip: "],
"press ",
span![C!["tipLetter"], "M"],
" to comment"
]
]
],
comments
],
]
}
fn right_modal_column(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
let EditIssueModal {
payload,
status_state,
reporter_state,
assignees_state,
priority_state,
..
} = modal;
let status = StyledSelect::build()
.name("status")
.opened(status_state.opened)
.normal()
.text_filter(status_state.text_filter.as_str())
.options(
model
.issue_statuses
.iter()
.map(|opt| opt.to_child().name("status"))
.collect(),
)
.selected(
model
.issue_statuses
.iter()
.filter(|is| is.id == payload.issue_status_id)
.map(|is| is.to_child().name("status"))
.collect(),
)
.valid(true)
.build(FieldId::EditIssueModal(EditIssueModalSection::Issue(
IssueFieldId::IssueStatusId,
)))
.into_node();
let status_field = StyledField::build()
.input(status)
.label("Status")
.build()
.into_node();
let assignees = StyledSelect::build()
.name("assignees")
.opened(assignees_state.opened)
.empty()
.multi()
.text_filter(assignees_state.text_filter.as_str())
.options(
model
.users
.iter()
.map(|user| user.to_child().name("assignees"))
.collect(),
)
.selected(
model
.users
.iter()
.filter(|user| payload.user_ids.contains(&user.id))
.map(|user| user.to_child().name("assignees"))
.collect(),
)
.build(FieldId::EditIssueModal(EditIssueModalSection::Issue(
IssueFieldId::Assignees,
)))
.into_node();
let assignees_field = StyledField::build()
.input(assignees)
.label("Assignees")
.build()
.into_node();
let reporter = StyledSelect::build()
.name("reporter")
.opened(reporter_state.opened)
.empty()
.text_filter(reporter_state.text_filter.as_str())
.options(
model
.users
.iter()
.map(|user| user.to_child().name("reporter"))
.collect(),
)
.selected(
model
.users
.iter()
.filter(|user| payload.reporter_id == user.id)
.map(|user| user.to_child().name("reporter"))
.collect(),
)
.build(FieldId::EditIssueModal(EditIssueModalSection::Issue(
IssueFieldId::Reporter,
)))
.into_node();
let reporter_field = StyledField::build()
.input(reporter)
.label("Reporter")
.build()
.into_node();
let issue_priorities = IssuePriority::ordered();
let priority = StyledSelect::build()
.name("priority")
.opened(priority_state.opened)
.empty()
.text_filter(priority_state.text_filter.as_str())
.options(
issue_priorities
.iter()
.map(|p| p.to_child().name("priority"))
.collect(),
)
.selected(vec![payload.priority.to_child().name("priority")])
.build(FieldId::EditIssueModal(EditIssueModalSection::Issue(
IssueFieldId::Priority,
)))
.into_node();
let priority_field = StyledField::build()
.input(priority)
.label("Priority")
.build()
.into_node();
let time_tracking_type = model
.project
.as_ref()
.map(|p| p.time_tracking)
.unwrap_or_else(|| TimeTracking::Untracked);
let (estimate_field, tracking_field) = if time_tracking_type != TimeTracking::Untracked {
let estimate_field = time_tracking_field(
time_tracking_type,
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Estimate)),
"Original Estimate (hours)",
&modal.estimate,
&modal.estimate_select,
);
let tracking = tracking_link(model, modal);
let tracking_field = StyledField::build()
.label("TIME TRACKING")
.tip("")
.input(tracking)
.build()
.into_node();
(estimate_field, tracking_field)
} else {
(Node::Empty, Node::Empty)
};
let epic_field = epic_field(
model,
modal,
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::EpicName)),
)
.unwrap_or(Node::Empty);
div![
C!["right"],
status_field,
assignees_field,
reporter_field,
priority_field,
estimate_field,
tracking_field,
epic_field,
]
}

View File

@ -0,0 +1,3 @@
pub mod issue_statuses_delete;
pub mod issues_create;
pub mod issues_edit;

View File

@ -1,26 +1,24 @@
use std::collections::hash_map::HashMap; use {
crate::{
use chrono::{prelude::*, NaiveDate}; pages::{
use seed::app::Orders; invite_page::InvitePage, profile_page::model::ProfilePage,
use seed::browser::web_socket::WebSocket; project_page::model::ProjectPage, project_settings_page::ProjectSettingsPage,
use serde::{Deserialize, Serialize}; reports_page::model::ReportsPage, sign_in_page::model::SignInPage,
use uuid::Uuid; sign_up_page::model::SignUpPage, users_page::model::UsersPage,
},
use jirs_data::*; shared::styled_select::StyledSelectState,
Msg,
use crate::{
modal::time_tracking::value_for_time_tracking,
shared::{
drag::DragState, styled_checkbox::StyledCheckboxState,
styled_date_time_input::StyledDateTimeInputState, styled_editor::Mode,
styled_image_input::StyledImageInputState, styled_input::StyledInputState,
/*styled_rte::StyledRteState,*/ styled_select::StyledSelectState,
}, },
EditIssueModalSection, FieldId, Msg, ProjectFieldId, jirs_data::*,
seed::{app::Orders, browser::web_socket::WebSocket},
serde::{Deserialize, Serialize},
std::collections::hash_map::HashMap,
uuid::Uuid,
}; };
pub trait IssueModal { pub trait IssueModal {
fn epic_id_value(&self) -> Option<u32>; fn epic_id_value(&self) -> Option<u32>;
fn epic_state(&self) -> &StyledSelectState; fn epic_state(&self) -> &StyledSelectState;
fn update_states(&mut self, msg: &Msg, orders: &mut impl Orders<Msg>); fn update_states(&mut self, msg: &Msg, orders: &mut impl Orders<Msg>);
@ -28,12 +26,12 @@ pub trait IssueModal {
#[derive(Clone, Debug, PartialOrd, PartialEq)] #[derive(Clone, Debug, PartialOrd, PartialEq)]
pub enum ModalType { pub enum ModalType {
AddIssue(Box<AddIssueModal>), AddIssue(Box<crate::modals::issues_create::Model>),
EditIssue(IssueId, Box<EditIssueModal>), EditIssue(IssueId, Box<crate::modals::issues_edit::Model>),
DeleteIssueConfirm(IssueId), DeleteIssueConfirm(IssueId),
DeleteCommentConfirm(CommentId), DeleteCommentConfirm(CommentId),
TimeTracking(IssueId), TimeTracking(IssueId),
DeleteIssueStatusModal(Box<DeleteIssueStatusModal>), DeleteIssueStatusModal(Box<crate::modals::issue_statuses_delete::Model>),
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
DebugModal, DebugModal,
} }
@ -45,259 +43,6 @@ pub struct CommentForm {
pub creating: bool, pub creating: bool,
} }
#[derive(Clone, Debug, PartialOrd, PartialEq)]
pub struct DeleteIssueStatusModal {
pub delete_id: IssueStatusId,
pub receiver_id: Option<IssueStatusId>,
}
impl DeleteIssueStatusModal {
pub fn new(delete_id: IssueStatusId) -> Self {
Self {
delete_id,
receiver_id: None,
}
}
}
#[derive(Clone, Debug, PartialOrd, PartialEq)]
pub struct EditIssueModal {
pub id: IssueId,
pub link_copied: bool,
pub payload: UpdateIssuePayload,
pub top_type_state: StyledSelectState,
pub status_state: StyledSelectState,
pub reporter_state: StyledSelectState,
pub assignees_state: StyledSelectState,
pub priority_state: StyledSelectState,
pub epic_name_state: StyledSelectState,
pub epic_starts_at_state: StyledDateTimeInputState,
pub epic_ends_at_state: StyledDateTimeInputState,
pub estimate: StyledInputState,
pub estimate_select: StyledSelectState,
pub time_spent: StyledInputState,
pub time_spent_select: StyledSelectState,
pub time_remaining: StyledInputState,
pub time_remaining_select: StyledSelectState,
pub description_editor_mode: Mode,
// comments
pub comment_form: CommentForm,
}
impl IssueModal for EditIssueModal {
fn epic_id_value(&self) -> Option<u32> {
self.epic_name_state.values.get(0).cloned()
}
fn epic_state(&self) -> &StyledSelectState {
&self.epic_name_state
}
fn update_states(&mut self, msg: &Msg, orders: &mut impl Orders<Msg>) {
self.top_type_state.update(msg, orders);
self.status_state.update(msg, orders);
self.reporter_state.update(msg, orders);
self.assignees_state.update(msg, orders);
self.priority_state.update(msg, orders);
self.estimate.update(msg);
self.estimate_select.update(msg, orders);
self.time_spent.update(msg);
self.time_spent_select.update(msg, orders);
self.time_remaining.update(msg);
self.time_remaining_select.update(msg, orders);
self.epic_name_state.update(msg, orders);
}
}
impl EditIssueModal {
pub fn new(issue: &Issue, time_tracking_type: TimeTracking) -> Self {
Self {
id: issue.id,
link_copied: false,
payload: UpdateIssuePayload {
title: issue.title.clone(),
issue_type: issue.issue_type,
issue_status_id: issue.issue_status_id,
priority: issue.priority,
list_position: issue.list_position,
description: issue.description.clone(),
description_text: issue.description_text.clone(),
estimate: issue.estimate,
time_spent: issue.time_spent,
time_remaining: issue.time_remaining,
project_id: issue.project_id,
reporter_id: issue.reporter_id,
user_ids: issue.user_ids.clone(),
},
top_type_state: StyledSelectState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Type)),
issue.estimate.map(|v| vec![v as u32]).unwrap_or_default(),
),
status_state: StyledSelectState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::IssueStatusId)),
vec![issue.issue_status_id as u32],
),
reporter_state: StyledSelectState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Reporter)),
vec![issue.reporter_id as u32],
),
assignees_state: StyledSelectState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Assignees)),
issue.user_ids.iter().map(|n| *n as u32).collect(),
),
priority_state: StyledSelectState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Priority)),
vec![issue.priority.into()],
),
estimate: StyledInputState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Estimate)),
value_for_time_tracking(&issue.estimate, &time_tracking_type),
),
estimate_select: StyledSelectState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Estimate)),
issue.estimate.map(|n| vec![n as u32]).unwrap_or_default(),
),
time_spent: StyledInputState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::TimeSpent)),
value_for_time_tracking(&issue.time_spent, &time_tracking_type),
),
time_spent_select: StyledSelectState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::TimeSpent)),
issue.time_spent.map(|n| vec![n as u32]).unwrap_or_default(),
),
time_remaining: StyledInputState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::TimeRemaining)),
value_for_time_tracking(&issue.time_remaining, &time_tracking_type),
),
time_remaining_select: StyledSelectState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::TimeRemaining)),
issue
.time_remaining
.map(|n| vec![n as u32])
.unwrap_or_default(),
),
description_editor_mode: Mode::View,
comment_form: CommentForm {
id: None,
body: String::new(),
creating: false,
},
// epic
epic_name_state: StyledSelectState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::EpicName)),
issue
.epic_id
.as_ref()
.map(|id| vec![*id as u32])
.unwrap_or_default(),
),
epic_starts_at_state: StyledDateTimeInputState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::EpicStartsAt)),
None,
),
epic_ends_at_state: StyledDateTimeInputState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::EpicStartsAt)),
None,
),
}
}
}
#[derive(Clone, Debug, PartialOrd, PartialEq)]
pub struct AddIssueModal {
pub priority: IssuePriority,
pub description: Option<String>,
pub description_text: Option<String>,
pub estimate: Option<i32>,
pub time_spent: Option<i32>,
pub time_remaining: Option<i32>,
pub project_id: Option<jirs_data::ProjectId>,
pub user_ids: Vec<jirs_data::UserId>,
pub reporter_id: Option<jirs_data::UserId>,
pub issue_status_id: jirs_data::IssueStatusId,
pub epic_id: Option<jirs_data::UserId>,
// modal fields
pub title_state: StyledInputState,
pub type_state: StyledSelectState,
pub reporter_state: StyledSelectState,
pub assignees_state: StyledSelectState,
pub priority_state: StyledSelectState,
// epic
pub epic_name_state: StyledSelectState,
pub epic_starts_at_state: StyledDateTimeInputState,
pub epic_ends_at_state: StyledDateTimeInputState,
}
impl IssueModal for AddIssueModal {
fn epic_id_value(&self) -> Option<u32> {
self.epic_name_state.values.get(0).cloned()
}
fn epic_state(&self) -> &StyledSelectState {
&self.epic_name_state
}
fn update_states(&mut self, msg: &Msg, orders: &mut impl Orders<Msg>) {
self.title_state.update(msg);
self.assignees_state.update(msg, orders);
self.reporter_state.update(msg, orders);
self.type_state.update(msg, orders);
self.priority_state.update(msg, orders);
self.epic_name_state.update(msg, orders);
self.epic_starts_at_state.update(msg, orders);
self.epic_ends_at_state.update(msg, orders);
}
}
impl Default for AddIssueModal {
fn default() -> Self {
Self {
priority: Default::default(),
description: Default::default(),
description_text: Default::default(),
estimate: Default::default(),
time_spent: Default::default(),
time_remaining: Default::default(),
project_id: Default::default(),
user_ids: Default::default(),
reporter_id: Default::default(),
issue_status_id: Default::default(),
epic_id: Default::default(),
title_state: StyledInputState::new(FieldId::AddIssueModal(IssueFieldId::Title), ""),
type_state: StyledSelectState::new(FieldId::AddIssueModal(IssueFieldId::Type), vec![]),
reporter_state: StyledSelectState::new(
FieldId::AddIssueModal(IssueFieldId::Reporter),
vec![],
),
assignees_state: StyledSelectState::new(
FieldId::AddIssueModal(IssueFieldId::Assignees),
vec![],
),
priority_state: StyledSelectState::new(
FieldId::AddIssueModal(IssueFieldId::Priority),
vec![],
),
// epic
epic_name_state: StyledSelectState::new(
FieldId::AddIssueModal(IssueFieldId::EpicName),
vec![],
),
epic_starts_at_state: StyledDateTimeInputState::new(
FieldId::AddIssueModal(IssueFieldId::EpicStartsAt),
None,
),
epic_ends_at_state: StyledDateTimeInputState::new(
FieldId::AddIssueModal(IssueFieldId::EpicEndsAt),
None,
),
}
}
}
#[derive(Copy, Clone, Debug, PartialOrd, PartialEq)] #[derive(Copy, Clone, Debug, PartialOrd, PartialEq)]
pub enum Page { pub enum Page {
Project, Project,
@ -345,109 +90,6 @@ pub struct UpdateProjectForm {
pub fields: UpdateProjectPayload, pub fields: UpdateProjectPayload,
} }
#[derive(Debug, Default)]
pub struct ProjectPage {
pub text_filter: String,
pub active_avatar_filters: Vec<UserId>,
pub only_my_filter: bool,
pub recently_updated_filter: bool,
pub issue_drag: DragState,
}
#[derive(Debug, Default)]
pub struct InvitePage {
pub token: String,
pub token_touched: bool,
pub error: Option<String>,
}
#[derive(Debug)]
pub struct ProjectSettingsPage {
pub payload: UpdateProjectPayload,
pub project_category_state: StyledSelectState,
pub description_mode: crate::shared::styled_editor::Mode,
pub time_tracking: StyledCheckboxState,
pub column_drag: DragState,
pub edit_column_id: Option<IssueStatusId>,
pub creating_issue_status: bool,
pub name: StyledInputState,
// pub description_rte: StyledRteState,
}
impl ProjectSettingsPage {
pub fn new(project: &Project) -> Self {
use crate::shared::styled_editor::Mode as EditorMode;
let jirs_data::Project {
id,
name,
url,
description,
category,
time_tracking,
..
} = project;
Self {
payload: UpdateProjectPayload {
id: *id,
name: Some(name.clone()),
url: Some(url.clone()),
description: Some(description.clone()),
category: Some(*category),
time_tracking: Some(*time_tracking),
},
description_mode: EditorMode::View,
project_category_state: StyledSelectState::new(
FieldId::ProjectSettings(ProjectFieldId::Category),
vec![(*category).into()],
),
time_tracking: StyledCheckboxState::new(
FieldId::ProjectSettings(ProjectFieldId::TimeTracking),
(*time_tracking).into(),
),
column_drag: Default::default(),
edit_column_id: None,
creating_issue_status: false,
name: StyledInputState::new(
FieldId::ProjectSettings(ProjectFieldId::IssueStatusName),
"",
),
// description_rte: StyledRteState::new(FieldId::ProjectSettings(
// ProjectFieldId::Description,
// )),
}
}
pub fn reset(&mut self) {
self.edit_column_id = None;
self.name.reset();
self.creating_issue_status = false;
}
}
#[derive(Debug, Default)]
pub struct SignInPage {
pub username: String,
pub email: String,
pub token: String,
pub login_success: bool,
pub bad_token: String,
// touched
pub username_touched: bool,
pub email_touched: bool,
pub token_touched: bool,
}
#[derive(Debug, Default)]
pub struct SignUpPage {
pub username: String,
pub email: String,
pub sign_up_success: bool,
pub error: String,
// touched
pub username_touched: bool,
pub email_touched: bool,
}
#[derive(Debug, Clone, Copy, PartialOrd, PartialEq)] #[derive(Debug, Clone, Copy, PartialOrd, PartialEq)]
pub enum InvitationFormState { pub enum InvitationFormState {
Initial = 1, Initial = 1,
@ -462,96 +104,6 @@ impl Default for InvitationFormState {
} }
} }
#[derive(Debug)]
pub struct UsersPage {
pub name: String,
pub name_touched: bool,
pub email: String,
pub email_touched: bool,
pub user_role: UserRole,
pub user_role_state: StyledSelectState,
pub pending_invitations: Vec<String>,
pub error: String,
pub form_state: InvitationFormState,
pub invited_users: Vec<User>,
pub invitations: Vec<Invitation>,
}
impl Default for UsersPage {
fn default() -> Self {
Self {
name: "".to_string(),
name_touched: false,
email: "".to_string(),
email_touched: false,
user_role: Default::default(),
user_role_state: StyledSelectState::new(FieldId::Users(UsersFieldId::UserRole), vec![]),
pending_invitations: vec![],
error: "".to_string(),
form_state: Default::default(),
invited_users: vec![],
invitations: vec![],
}
}
}
#[derive(Debug)]
pub struct ProfilePage {
pub name: StyledInputState,
pub email: StyledInputState,
pub avatar: StyledImageInputState,
pub current_project: StyledSelectState,
}
impl ProfilePage {
pub fn new(user: &User, project_ids: Vec<ProjectId>) -> Self {
Self {
name: StyledInputState::new(
FieldId::Profile(UsersFieldId::Username),
user.name.as_str(),
),
email: StyledInputState::new(
FieldId::Profile(UsersFieldId::Email),
user.email.as_str(),
),
avatar: StyledImageInputState::new(
FieldId::Profile(UsersFieldId::Avatar),
user.avatar_url.as_ref().cloned(),
),
current_project: StyledSelectState::new(
FieldId::Profile(UsersFieldId::CurrentProject),
project_ids.into_iter().map(|n| n as u32).collect(),
),
}
}
}
#[derive(Debug)]
pub struct ReportsPage {
pub selected_day: Option<chrono::NaiveDate>,
pub hovered_day: Option<chrono::NaiveDate>,
pub first_day: NaiveDate,
pub last_day: NaiveDate,
}
impl Default for ReportsPage {
fn default() -> Self {
let first_day = chrono::Utc::today().with_day(1).unwrap().naive_local();
let last_day = (first_day + chrono::Duration::days(32))
.with_day(1)
.unwrap()
- chrono::Duration::days(1);
Self {
first_day,
last_day,
selected_day: None,
hovered_day: None,
}
}
}
#[derive(Debug)] #[derive(Debug)]
pub enum PageContent { pub enum PageContent {
SignIn(Box<SignInPage>), SignIn(Box<SignInPage>),
@ -591,12 +143,22 @@ pub struct Model {
pub page_content: PageContent, pub page_content: PageContent,
pub project: Option<Project>, pub project: Option<Project>,
pub user: Option<User>,
pub current_user_project: Option<UserProject>, pub current_user_project: Option<UserProject>,
pub issues: Vec<Issue>, pub issues: Vec<Issue>,
pub issues_by_id: HashMap<IssueId, Issue>,
pub user: Option<User>,
pub users: Vec<User>, pub users: Vec<User>,
pub users_by_id: HashMap<UserId, User>,
pub comments: Vec<Comment>, pub comments: Vec<Comment>,
pub issue_statuses: Vec<IssueStatus>, pub issue_statuses: Vec<IssueStatus>,
pub issue_statuses_by_id: HashMap<IssueStatusId, IssueStatus>,
pub issue_statuses_by_name: HashMap<String, IssueStatus>,
pub messages: Vec<Message>, pub messages: Vec<Message>,
pub user_projects: Vec<UserProject>, pub user_projects: Vec<UserProject>,
pub projects: Vec<Project>, pub projects: Vec<Project>,
@ -605,8 +167,6 @@ pub struct Model {
impl Model { impl Model {
pub fn new(host_url: String, ws_url: String) -> Self { pub fn new(host_url: String, ws_url: String) -> Self {
// let hi_worker = Worker::new("/hi.js");
Self { Self {
ws: None, ws: None,
ws_queue: vec![], ws_queue: vec![],
@ -627,12 +187,16 @@ impl Model {
messages_tooltip_visible: false, messages_tooltip_visible: false,
issues: vec![], issues: vec![],
users: vec![], users: vec![],
users_by_id: Default::default(),
comments: vec![], comments: vec![],
issue_statuses: vec![], issue_statuses: vec![],
issue_statuses_by_id: Default::default(),
issue_statuses_by_name: Default::default(),
messages: vec![], messages: vec![],
user_projects: vec![], user_projects: vec![],
projects: vec![], projects: vec![],
epics: vec![], epics: vec![],
issues_by_id: Default::default(),
} }
} }
@ -642,10 +206,4 @@ impl Model {
.map(|up| up.role) .map(|up| up.role)
.unwrap_or_default() .unwrap_or_default()
} }
// pub fn current_project_id(&self) -> ProjectId {
// self.current_user_project
// .as_ref()
// .map(|up| up.project_id)
// .unwrap_or_default()
// }
} }

View File

@ -0,0 +1,7 @@
pub use model::*;
pub use update::*;
pub use view::*;
mod model;
mod update;
mod view;

View File

@ -0,0 +1,6 @@
#[derive(Debug, Default)]
pub struct InvitePage {
pub token: String,
pub token_touched: bool,
pub error: Option<String>,
}

View File

@ -0,0 +1,67 @@
use std::str::FromStr;
use seed::prelude::*;
use jirs_data::{fields::*, WsMsg};
use crate::{
authorize_or_redirect,
model::{Model, Page, PageContent},
pages::invite_page::InvitePage,
shared::write_auth_token,
ws::send_ws_msg,
FieldId, InvitationPageChange, Msg, PageChanged, WebSocketChanged,
};
pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
match model.page_content {
PageContent::Invite(..) => (),
_ if model.page == Page::Invite => build_page_content(model),
_ => (),
};
let page = match &mut model.page_content {
PageContent::Invite(page) => page,
_ => return,
};
match msg {
Msg::WebSocketChange(WebSocketChanged::WsMsg(ws_msg)) => match ws_msg {
WsMsg::InvitationAcceptFailure(_) => {
page.error = Some("Invalid token".to_string());
}
WsMsg::InvitationAcceptSuccess(token) => {
if let Ok(Msg::AuthTokenStored) = write_auth_token(Some(token)) {
authorize_or_redirect(model, orders);
}
}
_ => (),
},
Msg::StrInputChanged(FieldId::Invite(InviteFieldId::Token), text) => {
page.token_touched = true;
page.token = text;
page.error = None;
}
Msg::PageChanged(PageChanged::Invitation(InvitationPageChange::SubmitForm)) => {
if let Ok(token) = uuid::Uuid::from_str(page.token.as_str()) {
send_ws_msg(
WsMsg::InvitationAcceptRequest(token),
model.ws.as_ref(),
orders,
);
page.error = None;
}
}
_ => {}
}
}
fn build_page_content(model: &mut Model) {
let s: String = seed::document().location().unwrap().to_string().into();
let url = seed::Url::from_str(s.as_str()).unwrap();
let search = url.search();
let values = search.get("token").cloned().unwrap_or_default();
let mut content = InvitePage::default();
content.token = values.get(0).cloned().unwrap_or_default();
model.page_content = PageContent::Invite(Box::new(content));
}

View File

@ -0,0 +1,65 @@
use {
crate::{
model::{Model, PageContent},
pages::invite_page::InvitePage,
shared::{
outer_layout, styled_button::StyledButton, styled_field::StyledField,
styled_form::StyledForm, styled_input::StyledInput, ToNode,
},
validations::is_token,
FieldId, InvitationPageChange, Msg, PageChanged,
},
jirs_data::fields::*,
seed::{prelude::*, *},
};
pub fn view(model: &Model) -> Node<Msg> {
let page = match &model.page_content {
PageContent::Invite(page) => page,
_ => return empty![],
};
let token_field = token_field(page);
let submit_field = submit(page);
let error = match page.error.as_ref() {
Some(s) => div![C!["error"], s.as_str()],
_ => empty![],
};
let form = StyledForm::build()
.heading("Welcome in JIRS")
.on_submit(ev(Ev::Submit, move |ev| {
ev.prevent_default();
Msg::PageChanged(PageChanged::Invitation(InvitationPageChange::SubmitForm))
}))
.add_field(token_field)
.add_field(submit_field)
.add_field(error)
.build()
.into_node();
outer_layout(model, "invite", vec![form])
}
fn submit(_page: &InvitePage) -> Node<Msg> {
let submit = StyledButton::build()
.text("Accept")
.primary()
.build()
.into_node();
StyledField::build().input(submit).build().into_node()
}
fn token_field(page: &InvitePage) -> Node<Msg> {
let token = StyledInput::build()
.valid(!page.token_touched || is_token(page.token.as_str()) && page.error.is_none())
.value(page.token.as_str())
.build(FieldId::Invite(InviteFieldId::Token))
.into_node();
StyledField::build()
.input(token)
.label("Your invite token")
.build()
.into_node()
}

View File

@ -0,0 +1,8 @@
pub mod invite_page;
pub mod profile_page;
pub mod project_page;
pub mod project_settings_page;
pub mod reports_page;
pub mod sign_in_page;
pub mod sign_up_page;
pub mod users_page;

View File

@ -0,0 +1,6 @@
pub use update::*;
pub use view::*;
pub mod model;
pub mod update;
pub mod view;

View File

@ -0,0 +1,40 @@
use jirs_data::{ProjectId, User, UsersFieldId};
use crate::{
shared::{
styled_image_input::StyledImageInputState, styled_input::StyledInputState,
styled_select::StyledSelectState,
},
FieldId,
};
#[derive(Debug)]
pub struct ProfilePage {
pub name: StyledInputState,
pub email: StyledInputState,
pub avatar: StyledImageInputState,
pub current_project: StyledSelectState,
}
impl ProfilePage {
pub fn new(user: &User, project_ids: Vec<ProjectId>) -> Self {
Self {
name: StyledInputState::new(
FieldId::Profile(UsersFieldId::Username),
user.name.as_str(),
),
email: StyledInputState::new(
FieldId::Profile(UsersFieldId::Email),
user.email.as_str(),
),
avatar: StyledImageInputState::new(
FieldId::Profile(UsersFieldId::Avatar),
user.avatar_url.as_ref().cloned(),
),
current_project: StyledSelectState::new(
FieldId::Profile(UsersFieldId::CurrentProject),
project_ids.into_iter().map(|n| n as u32).collect(),
),
}
}
}

View File

@ -1,16 +1,20 @@
use seed::prelude::{Method, Orders, Request}; use {
use web_sys::FormData; crate::{
model::{Model, Page, PageContent},
use jirs_data::{ProjectId, UsersFieldId, WsMsg}; pages::profile_page::model::ProfilePage,
shared::styled_select::StyledSelectChanged,
use crate::model::{Model, Page, PageContent, ProfilePage}; ws::{board_load, send_ws_msg},
use crate::shared::styled_select::StyledSelectChanged; FieldId, Msg, OperationKind, PageChanged, ProfilePageChange, ResourceKind,
use crate::ws::{board_load, send_ws_msg}; WebSocketChanged,
use crate::{FieldId, Msg, PageChanged, ProfilePageChange, WebSocketChanged}; },
jirs_data::{ProjectId, User, UsersFieldId, WsMsg},
seed::prelude::{Method, Orders, Request},
web_sys::FormData,
};
pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) { pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) {
match msg { match msg {
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::AuthorizeLoaded(..))) Msg::ResourceChanged(ResourceKind::Auth, OperationKind::SingleLoaded, Some(_))
| Msg::ChangePage(Page::Profile) => { | Msg::ChangePage(Page::Profile) => {
board_load(model, orders); board_load(model, orders);
build_page_content(model); build_page_content(model);
@ -45,12 +49,13 @@ pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Order
orders.perform_cmd(update_avatar(fd, model.host_url.clone())); orders.perform_cmd(update_avatar(fd, model.host_url.clone()));
orders.skip(); orders.skip();
} }
Msg::WebSocketChange(WebSocketChanged::WsMsg(ws_msg)) => { Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::AvatarUrlChanged(
if let WsMsg::AvatarUrlChanged(user_id, avatar_url) = ws_msg { user_id,
if let Some(me) = model.user.as_mut() { avatar_url,
if me.id == user_id { ))) => {
profile_page.avatar.url = Some(avatar_url); if let Some(User { id, .. }) = model.user.as_mut() {
} if *id == user_id {
profile_page.avatar.url = Some(avatar_url);
} }
} }
} }

View File

@ -1,18 +1,18 @@
use std::collections::HashMap; use {
crate::{
use seed::{prelude::*, *}; model::{Model, PageContent},
pages::profile_page::model::ProfilePage,
use jirs_data::*; shared::{
inner_layout, styled_button::StyledButton, styled_field::StyledField,
use crate::model::{Model, PageContent, ProfilePage}; styled_form::StyledForm, styled_image_input::StyledImageInput,
use crate::shared::styled_button::StyledButton; styled_input::StyledInput, styled_select::StyledSelect, ToChild, ToNode,
use crate::shared::styled_field::StyledField; },
use crate::shared::styled_form::StyledForm; FieldId, Msg, PageChanged, ProfilePageChange,
use crate::shared::styled_image_input::StyledImageInput; },
use crate::shared::styled_input::StyledInput; jirs_data::*,
use crate::shared::styled_select::StyledSelect; seed::{prelude::*, *},
use crate::shared::{inner_layout, ToChild, ToNode}; std::collections::HashMap,
use crate::{FieldId, Msg, PageChanged, ProfilePageChange}; };
pub fn view(model: &Model) -> Node<Msg> { pub fn view(model: &Model) -> Node<Msg> {
let page = match &model.page_content { let page = match &model.page_content {
@ -122,7 +122,7 @@ fn build_current_project(model: &Model, page: &ProfilePage) -> Node<Msg> {
}; };
StyledField::build() StyledField::build()
.label("Current project") .label("Current project")
.input(div![class!["project-name"], inner]) .input(div![C!["project-name"], inner])
.build() .build()
.into_node() .into_node()
} }

View File

@ -0,0 +1,6 @@
pub use update::*;
pub use view::*;
pub mod model;
pub mod update;
pub mod view;

View File

@ -0,0 +1,118 @@
use std::collections::HashMap;
use jirs_data::*;
use crate::shared::drag::DragState;
#[derive(Default, Debug)]
pub struct StatusIssueIds {
pub status_id: IssueStatusId,
pub status_name: IssueStatusName,
pub issue_ids: Vec<IssueId>,
}
#[derive(Default, Debug)]
pub struct EpicIssuePerStatus {
pub epic_name: EpicName,
pub per_status_issues: Vec<StatusIssueIds>,
}
// pub type VisibleIssueMap =
// HashMap<EpicName, HashMap<(IssueStatusId, IssueStatusName), Vec<IssueId>>>;
#[derive(Debug, Default)]
pub struct ProjectPage {
pub text_filter: String,
pub active_avatar_filters: Vec<UserId>,
pub only_my_filter: bool,
pub recently_updated_filter: bool,
pub issue_drag: DragState,
pub visible_issues: Vec<EpicIssuePerStatus>,
}
impl ProjectPage {
pub fn rebuild_visible(
&mut self,
epics: &Vec<Epic>,
statuses: &Vec<IssueStatus>,
issues: &Vec<Issue>,
user: &Option<User>,
) {
let mut map = vec![];
let epics = vec![None]
.into_iter()
.chain(epics.iter().map(|s| Some((s.id, s.name.as_str()))));
let statuses = statuses.iter().map(|s| (s.id, s.name.as_str()));
let mut issues: Vec<&Issue> = {
let mut m = HashMap::new();
let mut sorted = vec![];
for issue in issues.iter() {
sorted.push((issue.id, issue.updated_at));
m.insert(issue.id, issue);
}
sorted.sort_by(|(_, a_time), (_, b_time)| a_time.cmp(b_time));
sorted
.into_iter()
.flat_map(|(id, _)| m.remove(&id))
.collect()
};
if self.recently_updated_filter {
issues = issues[0..10].to_vec()
}
for epic in epics {
let mut per_epic_map = EpicIssuePerStatus::default();
per_epic_map.epic_name = epic.map(|(_, name)| name).unwrap_or_default().to_string();
for (current_status_id, issue_status_name) in statuses.clone() {
let mut per_status_map = StatusIssueIds::default();
per_status_map.status_id = current_status_id;
per_status_map.status_name = issue_status_name.to_string();
for issue in issues.iter() {
if issue.epic_id == epic.map(|(id, _)| id)
&& issue_filter_status(issue, current_status_id)
&& issue_filter_with_avatars(issue, &self.active_avatar_filters)
&& issue_filter_with_text(issue, self.text_filter.as_str())
&& issue_filter_with_only_my(issue, self.only_my_filter, user)
{
per_status_map.issue_ids.push(issue.id);
}
}
per_epic_map.per_status_issues.push(per_status_map);
}
map.push(per_epic_map);
}
self.visible_issues = map;
}
}
#[inline]
fn issue_filter_with_avatars(issue: &Issue, user_ids: &[UserId]) -> bool {
if user_ids.is_empty() {
return true;
}
user_ids.contains(&issue.reporter_id) || issue.user_ids.iter().any(|id| user_ids.contains(id))
}
#[inline]
fn issue_filter_status(issue: &Issue, current_status_id: IssueStatusId) -> bool {
issue.issue_status_id == current_status_id
}
#[inline]
fn issue_filter_with_text(issue: &Issue, text: &str) -> bool {
text.is_empty() || issue.title.contains(text)
}
#[inline]
fn issue_filter_with_only_my(issue: &Issue, only_my: bool, user: &Option<User>) -> bool {
let my_id = user.as_ref().map(|u| u.id).unwrap_or_default();
!only_my || issue.user_ids.contains(&my_id)
}
// #[inline]
// fn issue_filter_with_only_recent(issue: &Issue, ids: &[IssueId]) -> bool {
// ids.is_empty() || ids.contains(&issue.id)
// }

View File

@ -1,11 +1,16 @@
use seed::prelude::Orders; use {
crate::{
model::{ModalType, Model, Page, PageContent},
pages::project_page::model::ProjectPage,
shared::styled_select::StyledSelectChanged,
ws::{board_load, send_ws_msg},
BoardPageChange, EditIssueModalSection, FieldId, Msg, PageChanged, WebSocketChanged,
},
jirs_data::*,
seed::prelude::Orders,
};
use jirs_data::{Issue, IssueFieldId, WsMsg}; use crate::{OperationKind, ResourceKind};
use crate::model::{ModalType, Model, Page, PageContent, ProjectPage};
use crate::shared::styled_select::StyledSelectChanged;
use crate::ws::{board_load, send_ws_msg};
use crate::{BoardPageChange, EditIssueModalSection, FieldId, Msg, PageChanged, WebSocketChanged};
pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) { pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) {
if model.user.is_none() { if model.user.is_none() {
@ -28,34 +33,35 @@ pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Order
match msg { match msg {
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::AuthorizeLoaded(..))) Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::AuthorizeLoaded(..)))
| Msg::UserChanged(..)
| Msg::ProjectChanged(Some(..)) | Msg::ProjectChanged(Some(..))
| Msg::ChangePage(Page::Project) | Msg::ChangePage(Page::Project)
| Msg::ChangePage(Page::AddIssue) | Msg::ChangePage(Page::AddIssue)
| Msg::ChangePage(Page::EditIssue(..)) => { | Msg::ChangePage(Page::EditIssue(..)) => {
board_load(model, orders); board_load(model, orders);
} }
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::IssueUpdated(issue))) => { Msg::ResourceChanged(ResourceKind::Issue, OperationKind::SingleRemoved, ..) => {
let mut old: Vec<Issue> = vec![];
std::mem::swap(&mut old, &mut model.issues);
for is in old {
if is.id == issue.id {
model.issues.push(issue.clone())
} else {
model.issues.push(is);
}
}
}
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::IssueDeleted(id, count)))
if count > 0 =>
{
let mut old: Vec<Issue> = vec![];
std::mem::swap(&mut old, &mut model.issues);
for is in old {
if is.id != id {
model.issues.push(is);
}
}
orders.skip().send_msg(Msg::ModalDropped); orders.skip().send_msg(Msg::ModalDropped);
project_page.rebuild_visible(
&model.epics,
&model.issue_statuses,
&model.issues,
&model.user,
);
}
Msg::ResourceChanged(
ResourceKind::Issue
| ResourceKind::Project
| ResourceKind::IssueStatus
| ResourceKind::Epic,
..,
) => {
project_page.rebuild_visible(
&model.epics,
&model.issue_statuses,
&model.issues,
&model.user,
);
} }
Msg::StyledSelectChanged( Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Type)), FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Type)),

View File

@ -1,15 +1,19 @@
use chrono::NaiveDateTime; use {
use seed::{prelude::*, *}; crate::{
model::{Model, Page, PageContent},
use jirs_data::*; shared::{
inner_layout,
use crate::model::{Model, PageContent}; styled_avatar::StyledAvatar,
use crate::shared::styled_avatar::StyledAvatar; styled_button::StyledButton,
use crate::shared::styled_button::StyledButton; styled_icon::{Icon, StyledIcon},
use crate::shared::styled_icon::{Icon, StyledIcon}; styled_input::StyledInput,
use crate::shared::styled_input::StyledInput; ToNode,
use crate::shared::{inner_layout, ToNode}; },
use crate::{BoardPageChange, FieldId, Msg, PageChanged}; BoardPageChange, FieldId, Msg, PageChanged,
},
jirs_data::*,
seed::{prelude::*, *},
};
pub fn view(model: &Model) -> Node<Msg> { pub fn view(model: &Model) -> Node<Msg> {
let project_section = vec![ let project_section = vec![
@ -29,11 +33,11 @@ fn breadcrumbs(model: &Model) -> Node<Msg> {
.map(|p| p.name.clone()) .map(|p| p.name.clone())
.unwrap_or_default(); .unwrap_or_default();
div![ div![
class!["breadcrumbsContainer"], C!["breadcrumbsContainer"],
span!["Projects"], span!["Projects"],
span![class!["breadcrumbsDivider"], "/"], span![C!["breadcrumbsDivider"], "/"],
span![project_name], span![project_name],
span![class!["breadcrumbsDivider"], "/"], span![C!["breadcrumbsDivider"], "/"],
span!["Kanban Board"] span!["Kanban Board"]
] ]
} }
@ -47,7 +51,7 @@ fn header() -> Node<Msg> {
.into_node(); .into_node();
div![ div![
id!["projectBoardHeader"], id!["projectBoardHeader"],
div![id!["boardName"], class!["headerChild"], "Kanban board"], div![id!["boardName"], C!["headerChild"], "Kanban board"],
a![ a![
attrs![At::Href => "https://gitlab.com/adrian.wozniak/jirs", At::Target => "__blank", At::Rel => "noreferrer noopener"], attrs![At::Href => "https://gitlab.com/adrian.wozniak/jirs", At::Target => "__blank", At::Rel => "noreferrer noopener"],
button button
@ -91,7 +95,7 @@ fn project_board_filters(model: &Model) -> Node<Msg> {
{ {
seed::button![ seed::button![
id!["clearAllFilters"], id!["clearAllFilters"],
class!["filterChild"], C!["filterChild"],
"Clear all", "Clear all",
mouse_ev(Ev::Click, |_| Msg::ProjectClearFilters), mouse_ev(Ev::Click, |_| Msg::ProjectClearFilters),
] ]
@ -139,94 +143,77 @@ fn avatars_filters(model: &Model) -> Node<Msg> {
}) })
.collect(); .collect();
div![id!["avatars"], class!["filterChild"], avatars] div![id!["avatars"], C!["filterChild"], avatars]
} }
fn project_board_lists(model: &Model) -> Node<Msg> { fn project_board_lists(model: &Model) -> Node<Msg> {
let mut rows: Vec<Option<&Epic>> = vec![None]; let project_page = match &model.page_content {
for epic in model.epics.iter() { PageContent::Project(project_page) => project_page,
rows.push(Some(epic)); _ => return empty![],
} };
let rows: Vec<Node<Msg>> = rows let rows = project_page.visible_issues.iter().map(|per_epic| {
.into_iter() let columns: Vec<Node<Msg>> = per_epic
.map(|epic| { .per_status_issues
let title = epic .iter()
.map(|epic| div![C!["rowName"], epic.name.as_str()]) .map(|per_status| {
.unwrap_or_else(|| empty![]); let issues: Vec<&Issue> = per_status
let columns: Vec<Node<Msg>> = model .issue_ids
.issue_statuses .iter()
.iter() .filter_map(|id| model.issues_by_id.get(id))
.map(|issue_status| project_issue_list(model, issue_status, epic)) .collect();
.collect(); project_issue_list(
div![C!["row"], title, div![C!["projectBoardLists"], columns]] model,
}) per_status.status_id,
.collect(); &per_status.status_name,
issues.as_slice(),
)
})
.collect();
div![
C!["row"],
div![C!["epicName"], per_epic.epic_name.as_str()],
div![C!["projectBoardLists"], columns]
]
});
div![C!["rows"], rows] div![C!["rows"], rows]
} }
fn project_issue_list( fn project_issue_list(
model: &Model, model: &Model,
status: &jirs_data::IssueStatus, status_id: IssueStatusId,
epic: Option<&jirs_data::Epic>, status_name: &str,
issues: &[&Issue],
) -> Node<Msg> { ) -> Node<Msg> {
let project_page = match &model.page_content { let issues: Vec<Node<Msg>> = issues
PageContent::Project(project_page) => project_page,
_ => return empty![],
};
let ids: Vec<IssueId> = if project_page.recently_updated_filter {
let mut v: Vec<(IssueId, NaiveDateTime)> = model
.issues
.iter()
.map(|issue| (issue.id, issue.updated_at))
.collect();
v.sort_by(|(_, a_time), (_, b_time)| a_time.cmp(b_time));
if v.len() > 10 { v[0..10].to_vec() } else { v }
.into_iter()
.map(|(id, _)| id)
.collect()
} else {
model.issues.iter().map(|issue| issue.id).collect()
};
let issues: Vec<Node<Msg>> = model
.issues
.iter() .iter()
.filter(|issue| {
issue.epic_id == epic.map(|epic| epic.id)
&& issue_filter_status(issue, status)
&& issue_filter_with_avatars(issue, &project_page.active_avatar_filters)
&& issue_filter_with_text(issue, project_page.text_filter.as_str())
&& issue_filter_with_only_my(issue, project_page.only_my_filter, &model.user)
&& issue_filter_with_only_recent(issue, ids.as_slice())
})
.map(|issue| project_issue(model, issue)) .map(|issue| project_issue(model, issue))
.collect(); .collect();
let label = status.name.clone(); let drop_handler = {
let send_status = status_id;
drag_ev(Ev::Drop, move |ev| {
ev.prevent_default();
Some(Msg::PageChanged(PageChanged::Board(
BoardPageChange::IssueDropZone(send_status),
)))
})
};
let send_status = status.id; let drag_over_handler = {
let drop_handler = drag_ev(Ev::Drop, move |ev| { let send_status = status_id;
ev.prevent_default(); drag_ev(Ev::DragOver, move |ev| {
Some(Msg::PageChanged(PageChanged::Board( ev.prevent_default();
BoardPageChange::IssueDropZone(send_status), Some(Msg::PageChanged(PageChanged::Board(
))) BoardPageChange::IssueDragOverStatus(send_status),
}); )))
})
let send_status = status.id; };
let drag_over_handler = drag_ev(Ev::DragOver, move |ev| {
ev.prevent_default();
Some(Msg::PageChanged(PageChanged::Board(
BoardPageChange::IssueDragOverStatus(send_status),
)))
});
div![ div![
attrs![At::Class => "list";], C!["list"],
div![C!["title"], status_name, div![C!["issuesCount"]]],
div![ div![
attrs![At::Class => "title"], C!["issues"],
label, attrs![At::DropZone => "link"],
div![attrs![At::Class => "issuesCount"]]
],
div![
attrs![At::Class => "issues"; At::DropZone => "link"],
drop_handler, drop_handler,
drag_over_handler, drag_over_handler,
issues issues
@ -234,58 +221,41 @@ fn project_issue_list(
] ]
} }
#[inline]
fn issue_filter_with_avatars(issue: &Issue, user_ids: &[UserId]) -> bool {
if user_ids.is_empty() {
return true;
}
user_ids.contains(&issue.reporter_id) || issue.user_ids.iter().any(|id| user_ids.contains(id))
}
#[inline]
fn issue_filter_status(issue: &Issue, status: &IssueStatus) -> bool {
issue.issue_status_id == status.id
}
#[inline]
fn issue_filter_with_text(issue: &Issue, text: &str) -> bool {
text.is_empty() || issue.title.contains(text)
}
#[inline]
fn issue_filter_with_only_my(issue: &Issue, only_my: bool, user: &Option<User>) -> bool {
let my_id = user.as_ref().map(|u| u.id).unwrap_or_default();
!only_my || issue.user_ids.contains(&my_id)
}
#[inline]
fn issue_filter_with_only_recent(issue: &Issue, ids: &[IssueId]) -> bool {
ids.is_empty() || ids.contains(&issue.id)
}
fn project_issue(model: &Model, issue: &Issue) -> Node<Msg> { fn project_issue(model: &Model, issue: &Issue) -> Node<Msg> {
let avatars: Vec<Node<Msg>> = model let avatars: Vec<Node<Msg>> = issue
.users .user_ids
.iter() .iter()
.enumerate() .filter_map(|id| model.users_by_id.get(id))
.filter(|(_, user)| issue.user_ids.contains(&user.id)) .map(|user| {
.map(|(idx, user)| {
StyledAvatar::build() StyledAvatar::build()
.size(24) .size(24)
.name(user.name.as_str()) .name(user.name.as_str())
.avatar_url(user.avatar_url.as_deref().unwrap_or_default()) .avatar_url(user.avatar_url.as_deref().unwrap_or_default())
.user_index(idx) .user_index(0)
.build() .build()
.into_node() .into_node()
}) })
.collect(); .collect();
// let avatars: Vec<Node<Msg>> = model
// .users
// .iter()
// .enumerate()
// .filter(|(_, user)| issue.user_ids.contains(&user.id))
// .map(|(idx, user)| {
// StyledAvatar::build()
// .size(24)
// .name(user.name.as_str())
// .avatar_url(user.avatar_url.as_deref().unwrap_or_default())
// .user_index(idx)
// .build()
// .into_node()
// })
// .collect();
let issue_type_icon = { let issue_type_icon = StyledIcon::build(issue.issue_type.clone().into())
StyledIcon::build(issue.issue_type.clone().into()) .with_color(issue.issue_type.to_str())
.with_color(issue.issue_type.to_str()) .build()
.build() .into_node();
.into_node()
};
let priority_icon = { let priority_icon = {
let icon = match issue.priority { let icon = match issue.priority {
IssuePriority::Low | IssuePriority::Lowest => Icon::ArrowDown, IssuePriority::Low | IssuePriority::Lowest => Icon::ArrowDown,
@ -315,33 +285,43 @@ fn project_issue(model: &Model, issue: &Issue) -> Node<Msg> {
BoardPageChange::ExchangePosition(issue_id), BoardPageChange::ExchangePosition(issue_id),
))) )))
}); });
let issue_id = issue.id;
let drag_out = drag_ev(Ev::DragLeave, move |_| { let drag_out = drag_ev(Ev::DragLeave, move |_| {
Some(Msg::PageChanged(PageChanged::Board( Some(Msg::PageChanged(PageChanged::Board(
BoardPageChange::DragLeave(issue_id), BoardPageChange::DragLeave(issue_id),
))) )))
}); });
let on_click = mouse_ev("click", move |ev| {
let class_list = vec!["issue"]; ev.prevent_default();
ev.stop_propagation();
seed::Url::new()
.add_path_part("issues")
.add_path_part(format!("{}", issue_id))
.go_and_push();
Msg::ChangePage(Page::EditIssue(issue_id))
});
let href = format!("/issues/{id}", id = issue_id); let href = format!("/issues/{id}", id = issue_id);
a![ a![
drag_started, drag_started,
attrs![At::Class => "issueLink"; At::Href => href], on_click,
C!["issueLink"],
attrs![At::Href => href],
div![ div![
attrs![At::Class => class_list.join(" "), At::Draggable => true], C!["issue"],
attrs![At::Draggable => true],
drag_stopped, drag_stopped,
drag_over_handler, drag_over_handler,
drag_out, drag_out,
p![attrs![At::Class => "title"], issue.title.as_str()], p![C!["title"], issue.title.as_str()],
div![ div![
attrs![At::Class => "bottom"], C!["bottom"],
div![ div![
div![attrs![At::Class => "issueTypeIcon"], issue_type_icon], div![C!["issueTypeIcon"], issue_type_icon],
div![attrs![At::Class => "issuePriorityIcon"], priority_icon] div![C!["issuePriorityIcon"], priority_icon]
], ],
div![attrs![At::Class => "assignees"], avatars,], div![C!["assignees"], avatars,],
] ]
] ]
] ]

View File

@ -0,0 +1,7 @@
pub use model::*;
pub use update::*;
pub use view::*;
mod model;
mod update;
mod view;

View File

@ -0,0 +1,72 @@
use jirs_data::{IssueStatusId, Project, ProjectFieldId, UpdateProjectPayload};
use crate::{
shared::{
drag::DragState, styled_checkbox::StyledCheckboxState, styled_input::StyledInputState,
styled_select::StyledSelectState,
},
FieldId,
};
#[derive(Debug)]
pub struct ProjectSettingsPage {
pub payload: UpdateProjectPayload,
pub project_category_state: StyledSelectState,
pub description_mode: crate::shared::styled_editor::Mode,
pub time_tracking: StyledCheckboxState,
pub column_drag: DragState,
pub edit_column_id: Option<IssueStatusId>,
pub creating_issue_status: bool,
pub name: StyledInputState,
// pub description_rte: StyledRteState,
}
impl ProjectSettingsPage {
pub fn new(project: &Project) -> Self {
use crate::shared::styled_editor::Mode as EditorMode;
let jirs_data::Project {
id,
name,
url,
description,
category,
time_tracking,
..
} = project;
Self {
payload: UpdateProjectPayload {
id: *id,
name: Some(name.clone()),
url: Some(url.clone()),
description: Some(description.clone()),
category: Some(*category),
time_tracking: Some(*time_tracking),
},
description_mode: EditorMode::View,
project_category_state: StyledSelectState::new(
FieldId::ProjectSettings(ProjectFieldId::Category),
vec![(*category).into()],
),
time_tracking: StyledCheckboxState::new(
FieldId::ProjectSettings(ProjectFieldId::TimeTracking),
(*time_tracking).into(),
),
column_drag: Default::default(),
edit_column_id: None,
creating_issue_status: false,
name: StyledInputState::new(
FieldId::ProjectSettings(ProjectFieldId::IssueStatusName),
"",
),
// description_rte: StyledRteState::new(FieldId::ProjectSettings(
// ProjectFieldId::Description,
// )),
}
}
pub fn reset(&mut self) {
self.edit_column_id = None;
self.name.reset();
self.creating_issue_status = false;
}
}

View File

@ -1,15 +1,17 @@
use std::collections::HashSet; use {
crate::{
model::{Model, Page, PageContent},
shared::styled_select::StyledSelectChanged,
ws::{board_load, send_ws_msg},
FieldChange::TabChanged,
FieldId, Msg, PageChanged, ProjectPageChange, WebSocketChanged,
},
jirs_data::{IssueStatus, IssueStatusId, ProjectFieldId, UpdateProjectPayload, WsMsg},
seed::{error, prelude::Orders},
std::collections::HashSet,
};
use seed::error; use crate::pages::project_settings_page::ProjectSettingsPage;
use seed::prelude::Orders;
use jirs_data::{IssueStatus, IssueStatusId, ProjectFieldId, UpdateProjectPayload, WsMsg};
use crate::model::{Model, Page, PageContent, ProjectSettingsPage};
use crate::shared::styled_select::StyledSelectChanged;
use crate::ws::{board_load, send_ws_msg};
use crate::FieldChange::TabChanged;
use crate::{FieldId, Msg, PageChanged, ProjectPageChange, WebSocketChanged};
pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) { pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
if model.page != Page::ProjectSettings { if model.page != Page::ProjectSettings {

View File

@ -4,19 +4,27 @@ use seed::{prelude::*, *};
use jirs_data::{IssueStatus, ProjectCategory, TimeTracking, ToVec}; use jirs_data::{IssueStatus, ProjectCategory, TimeTracking, ToVec};
use crate::model::{DeleteIssueStatusModal, ModalType, Model, PageContent, ProjectSettingsPage}; use crate::{
use crate::shared::styled_button::StyledButton; modals::issue_statuses_delete::Model as DeleteIssueStatusModal,
use crate::shared::styled_checkbox::StyledCheckbox; model::{self, ModalType, Model, PageContent},
use crate::shared::styled_editor::StyledEditor; pages::project_settings_page::ProjectSettingsPage,
use crate::shared::styled_field::StyledField; shared::{
use crate::shared::styled_form::StyledForm; inner_layout,
use crate::shared::styled_icon::{Icon, StyledIcon}; styled_button::StyledButton,
use crate::shared::styled_input::StyledInput; styled_checkbox::StyledCheckbox,
use crate::shared::{inner_layout, ToChild, ToNode}; styled_editor::StyledEditor,
use crate::{model, FieldId, Msg, PageChanged, ProjectFieldId, ProjectPageChange}; styled_field::StyledField,
styled_form::StyledForm,
styled_icon::{Icon, StyledIcon},
styled_input::StyledInput,
styled_select::StyledSelect,
styled_textarea::StyledTextarea,
ToChild, ToNode,
},
FieldId, Msg, PageChanged, ProjectFieldId, ProjectPageChange,
};
// use crate::shared::styled_rte::StyledRte; // use crate::shared::styled_rte::StyledRte;
use crate::shared::styled_select::StyledSelect;
use crate::shared::styled_textarea::StyledTextarea;
static TIME_TRACKING_FIBONACCI: &str = include_str!("./time_tracking_fibonacci.txt"); static TIME_TRACKING_FIBONACCI: &str = include_str!("./time_tracking_fibonacci.txt");
static TIME_TRACKING_HOURLY: &str = include_str!("./time_tracking_hourly.txt"); static TIME_TRACKING_HOURLY: &str = include_str!("./time_tracking_hourly.txt");
@ -98,7 +106,7 @@ pub fn view(model: &model::Model) -> Node<Msg> {
.build() .build()
.into_node(); .into_node();
let project_section = vec![div![class!["formContainer"], form]]; let project_section = vec![div![C!["formContainer"], form]];
inner_layout(model, "projectSettings", project_section) inner_layout(model, "projectSettings", project_section)
} }
@ -201,9 +209,9 @@ fn columns_section(model: &Model, page: &ProjectSettingsPage) -> Node<Msg> {
.collect(); .collect();
let columns_section = section![ let columns_section = section![
class!["columnsSection"], C!["columnsSection"],
div![ div![
class!["columns"], C!["columns"],
columns, columns,
add_column(page, column_style.as_str()) add_column(page, column_style.as_str())
] ]
@ -246,15 +254,15 @@ fn add_column(page: &ProjectSettingsPage, column_style: &str) -> Node<Msg> {
.into_node(); .into_node();
div![ div![
class!["columnPreview"], C!["columnPreview"],
div![class!["columnName"], form![on_submit, input]] div![C!["columnName"], form![on_submit, input]]
] ]
} else { } else {
let add_column = StyledIcon::build(Icon::Plus).build().into_node(); let add_column = StyledIcon::build(Icon::Plus).build().into_node();
div![ div![
class!["columnPreview"], C!["columnPreview"],
attrs![At::Style => column_style], attrs![At::Style => column_style],
div![class!["columnName addColumn"], add_column], div![C!["columnName addColumn"], add_column],
on_click, on_click,
] ]
} }
@ -280,7 +288,7 @@ fn column_preview(
.build(FieldId::ProjectSettings(ProjectFieldId::IssueStatusName)) .build(FieldId::ProjectSettings(ProjectFieldId::IssueStatusName))
.into_node(); .into_node();
div![class!["columnPreview"], div![class!["columnName"], input]] div![C!["columnPreview"], div![C!["columnName"], input]]
} else { } else {
show_column_preview(is, per_column_issue_count, column_style) show_column_preview(is, per_column_issue_count, column_style)
} }
@ -336,19 +344,19 @@ fn show_column_preview(
.on_click(on_delete) .on_click(on_delete)
.build() .build()
.into_node(); .into_node();
div![class!["removeColumn"], delete] div![C!["removeColumn"], delete]
} else { } else {
div![ div![
class!["issueCount"], C!["issueCount"],
format!("Issues in column: {}", issue_count_in_column) format!("Issues in column: {}", issue_count_in_column)
] ]
}; };
div![ div![
class!["columnPreview"], C!["columnPreview"],
attrs![At::Style => column_style, At::Draggable => "true", At::DropZone => "true"], attrs![At::Style => column_style, At::Draggable => "true", At::DropZone => "true"],
div![ div![
class!["columnName"], C!["columnName"],
span![is.name.as_str()], span![is.name.as_str()],
on_edit, on_edit,
delete_row delete_row

View File

@ -0,0 +1,6 @@
pub use update::*;
pub use view::*;
pub mod model;
pub mod update;
pub mod view;

View File

@ -0,0 +1,25 @@
use chrono::{prelude::*, NaiveDate};
#[derive(Debug)]
pub struct ReportsPage {
pub selected_day: Option<chrono::NaiveDate>,
pub hovered_day: Option<chrono::NaiveDate>,
pub first_day: NaiveDate,
pub last_day: NaiveDate,
}
impl Default for ReportsPage {
fn default() -> Self {
let first_day = chrono::Utc::today().with_day(1).unwrap().naive_local();
let last_day = (first_day + chrono::Duration::days(32))
.with_day(1)
.unwrap()
- chrono::Duration::days(1);
Self {
first_day,
last_day,
selected_day: None,
hovered_day: None,
}
}
}

View File

@ -2,10 +2,13 @@ use seed::prelude::*;
use jirs_data::WsMsg; use jirs_data::WsMsg;
use crate::changes::{PageChanged, ReportsPageChange}; use crate::pages::reports_page::model::ReportsPage;
use crate::model::{Model, Page, PageContent, ReportsPage}; use crate::{
use crate::ws::board_load; changes::{PageChanged, ReportsPageChange},
use crate::{Msg, WebSocketChanged}; model::{Model, Page, PageContent},
ws::board_load,
Msg, WebSocketChanged,
};
pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) { pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) {
if let Msg::ChangePage(Page::Reports) = msg { if let Msg::ChangePage(Page::Reports) = msg {

View File

@ -5,10 +5,12 @@ use seed::{prelude::*, *};
use jirs_data::Issue; use jirs_data::Issue;
use crate::model::{Model, PageContent, ReportsPage}; use crate::pages::reports_page::model::ReportsPage;
use crate::shared::styled_icon::StyledIcon; use crate::{
use crate::shared::{inner_layout, ToNode}; model::{Model, PageContent},
use crate::{Msg, PageChanged, ReportsPageChange}; shared::{inner_layout, styled_icon::StyledIcon, ToNode},
Msg, PageChanged, ReportsPageChange,
};
const SVG_MARGIN_X: u32 = 10; const SVG_MARGIN_X: u32 = 10;
const SVG_DRAWABLE_HEIGHT: u32 = 300; const SVG_DRAWABLE_HEIGHT: u32 = 300;
@ -26,7 +28,7 @@ pub fn view(model: &Model) -> Node<Msg> {
let graph = this_month_graph(page, &this_month_updated); let graph = this_month_graph(page, &this_month_updated);
let list = issue_list(page, this_month_updated.as_slice()); let list = issue_list(page, this_month_updated.as_slice());
let body = section![class!["top"], h1![class!["header"], "Reports"], graph, list]; let body = section![C!["top"], h1![C!["header"], "Reports"], graph, list];
inner_layout(model, "reports", vec![body]) inner_layout(model, "reports", vec![body])
} }
@ -138,8 +140,8 @@ fn this_month_graph(page: &ReportsPage, this_month_updated: &[&Issue]) -> Node<M
} }
div![ div![
class!["graph"], C!["graph"],
h5![class!["graphHeader"], "Last updated"], h5![C!["graphHeader"], "Last updated"],
svg![ svg![
attrs![At::Height => SVG_HEIGHT, At::Width => SVG_WIDTH], attrs![At::Height => SVG_HEIGHT, At::Width => SVG_WIDTH],
svg_parts, svg_parts,
@ -173,21 +175,21 @@ fn issue_list(page: &ReportsPage, this_month_updated: &[&Issue]) -> Node<Msg> {
.build() .build()
.into_node(); .into_node();
children.push(li![ children.push(li![
class!["issue"], C!["issue"],
class![active_class], C![active_class],
span![class!["priority"], priority_icon], span![C!["priority"], priority_icon],
span![class!["type"], type_icon], span![C!["type"], type_icon],
span![class!["name"], title.as_str()], span![C!["name"], title.as_str()],
span![ span![
class!["desc"], C!["desc"],
description.as_ref().cloned().unwrap_or_default() description.as_ref().cloned().unwrap_or_default()
], ],
span![class!["updatedAt"], day.as_str()], span![C!["updatedAt"], day.as_str()],
]); ]);
} }
div![ div![
class!["issueList"], C!["issueList"],
h5![class!["issueListHeader"], "Issues this month"], h5![C!["issueListHeader"], "Issues this month"],
children children
] ]
} }

View File

@ -0,0 +1,6 @@
pub use update::*;
pub use view::*;
pub mod model;
pub mod update;
pub mod view;

View File

@ -0,0 +1,12 @@
#[derive(Debug, Default)]
pub struct SignInPage {
pub username: String,
pub email: String,
pub token: String,
pub login_success: bool,
pub bad_token: String,
// touched
pub username_touched: bool,
pub email_touched: bool,
pub token_touched: bool,
}

View File

@ -0,0 +1,83 @@
use std::str::FromStr;
use seed::{prelude::*, *};
use uuid::Uuid;
use jirs_data::{SignInFieldId, WsMsg};
use crate::pages::sign_in_page::model::SignInPage;
use crate::{
model::{self, Model, Page, PageContent},
shared::write_auth_token,
ws::send_ws_msg,
FieldId, Msg, WebSocketChanged,
};
pub fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) {
if model.page != Page::SignIn {
return;
}
if let Msg::ChangePage(Page::SignIn) = msg {
build_page_content(model);
return;
};
let page = match &mut model.page_content {
PageContent::SignIn(page) => page,
_ => return,
};
match msg {
Msg::StrInputChanged(FieldId::SignIn(SignInFieldId::Username), value) => {
page.username = value;
page.username_touched = true;
}
Msg::StrInputChanged(FieldId::SignIn(SignInFieldId::Email), value) => {
page.email = value;
page.email_touched = true;
}
Msg::StrInputChanged(FieldId::SignIn(SignInFieldId::Token), value) => {
page.token = value;
page.token_touched = true;
}
Msg::SignInRequest => {
send_ws_msg(
WsMsg::AuthenticateRequest(page.email.clone(), page.username.clone()),
model.ws.as_ref(),
orders,
);
}
Msg::BindClientRequest => {
let bind_token: uuid::Uuid = match Uuid::from_str(page.token.as_str()) {
Ok(token) => token,
Err(error) => {
error!(error);
return;
}
};
send_ws_msg(WsMsg::BindTokenCheck(bind_token), model.ws.as_ref(), orders);
}
Msg::WebSocketChange(change) => match change {
WebSocketChanged::WsMsg(WsMsg::AuthenticateSuccess) => {
page.login_success = true;
}
WebSocketChanged::WsMsg(WsMsg::BindTokenOk(access_token)) => {
match write_auth_token(Some(access_token)) {
Ok(msg) => {
orders.skip().send_msg(msg);
}
Err(e) => {
error!(e);
}
}
}
_ => (),
},
_ => (),
};
}
fn build_page_content(model: &mut Model) {
model.page_content = PageContent::SignIn(Box::new(SignInPage::default()));
}

View File

@ -1,90 +1,20 @@
use std::str::FromStr;
use seed::{prelude::*, *}; use seed::{prelude::*, *};
use uuid::Uuid;
use jirs_data::WsMsg; use crate::{
model::{self, PageContent},
use crate::model::{Model, Page, PageContent, SignInPage}; shared::{
use crate::shared::styled_button::StyledButton; outer_layout,
use crate::shared::styled_field::StyledField; styled_button::StyledButton,
use crate::shared::styled_form::StyledForm; styled_field::StyledField,
use crate::shared::styled_icon::{Icon, StyledIcon}; styled_form::StyledForm,
use crate::shared::styled_input::StyledInput; styled_icon::{Icon, StyledIcon},
use crate::shared::styled_link::StyledLink; styled_input::StyledInput,
use crate::shared::{outer_layout, write_auth_token, ToNode}; styled_link::StyledLink,
use crate::validations::{is_email, is_token}; ToNode,
use crate::ws::send_ws_msg; },
use crate::{model, FieldId, Msg, SignInFieldId, WebSocketChanged}; validations::{is_email, is_token},
FieldId, Msg, SignInFieldId,
pub fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) { };
if model.page != Page::SignIn {
return;
}
if let Msg::ChangePage(Page::SignIn) = msg {
build_page_content(model);
return;
};
let page = match &mut model.page_content {
PageContent::SignIn(page) => page,
_ => return,
};
match msg {
Msg::StrInputChanged(FieldId::SignIn(SignInFieldId::Username), value) => {
page.username = value;
page.username_touched = true;
}
Msg::StrInputChanged(FieldId::SignIn(SignInFieldId::Email), value) => {
page.email = value;
page.email_touched = true;
}
Msg::StrInputChanged(FieldId::SignIn(SignInFieldId::Token), value) => {
page.token = value;
page.token_touched = true;
}
Msg::SignInRequest => {
send_ws_msg(
WsMsg::AuthenticateRequest(page.email.clone(), page.username.clone()),
model.ws.as_ref(),
orders,
);
}
Msg::BindClientRequest => {
let bind_token: uuid::Uuid = match Uuid::from_str(page.token.as_str()) {
Ok(token) => token,
Err(error) => {
error!(error);
return;
}
};
send_ws_msg(WsMsg::BindTokenCheck(bind_token), model.ws.as_ref(), orders);
}
Msg::WebSocketChange(change) => match change {
WebSocketChanged::WsMsg(WsMsg::AuthenticateSuccess) => {
page.login_success = true;
}
WebSocketChanged::WsMsg(WsMsg::BindTokenOk(access_token)) => {
match write_auth_token(Some(access_token)) {
Ok(msg) => {
orders.skip().send_msg(msg);
}
Err(e) => {
error!(e);
}
}
}
_ => (),
},
_ => (),
};
}
fn build_page_content(model: &mut Model) {
model.page_content = PageContent::SignIn(Box::new(SignInPage::default()));
}
pub fn view(model: &model::Model) -> Node<Msg> { pub fn view(model: &model::Model) -> Node<Msg> {
let page = match &model.page_content { let page = match &model.page_content {
@ -133,7 +63,7 @@ pub fn view(model: &model::Model) -> Node<Msg> {
.build() .build()
.into_node(); .into_node();
let submit_field = StyledField::build() let submit_field = StyledField::build()
.input(div![class!["twoRow"], submit, register_link,]) .input(div![C!["twoRow"], submit, register_link,])
.build() .build()
.into_node(); .into_node();
@ -144,7 +74,7 @@ pub fn view(model: &model::Model) -> Node<Msg> {
.into_node(); .into_node();
let no_pass_section = div![ let no_pass_section = div![
class!["noPasswordSection"], C!["noPasswordSection"],
attrs![At::Title => "We don't believe password is helping anyone. Instead after user provide correct login and e-mail he'll receive mail with 1-use token."], attrs![At::Title => "We don't believe password is helping anyone. Instead after user provide correct login and e-mail he'll receive mail with 1-use token."],
help_icon, help_icon,
span!["Why I don't see password?"] span!["Why I don't see password?"]

View File

@ -0,0 +1,6 @@
pub use update::*;
pub use view::*;
pub mod model;
pub mod update;
pub mod view;

View File

@ -0,0 +1,10 @@
#[derive(Debug, Default)]
pub struct SignUpPage {
pub username: String,
pub email: String,
pub sign_up_success: bool,
pub error: String,
// touched
pub username_touched: bool,
pub email_touched: bool,
}

View File

@ -0,0 +1,58 @@
use seed::prelude::*;
use jirs_data::{SignUpFieldId, WsMsg};
use crate::pages::sign_up_page::model::SignUpPage;
use crate::{
model::{self, Model, Page, PageContent},
ws::send_ws_msg,
FieldId, Msg, WebSocketChanged,
};
pub fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) {
if model.page != Page::SignUp {
return;
}
if let Msg::ChangePage(Page::SignUp) = msg {
build_page_content(model);
return;
};
let page = match &mut model.page_content {
PageContent::SignUp(page) => page,
_ => return,
};
match msg {
Msg::StrInputChanged(FieldId::SignUp(SignUpFieldId::Username), value) => {
page.username = value;
page.username_touched = true;
}
Msg::StrInputChanged(FieldId::SignUp(SignUpFieldId::Email), value) => {
page.email = value;
page.email_touched = true;
}
Msg::SignUpRequest => {
send_ws_msg(
WsMsg::SignUpRequest(page.email.clone(), page.username.clone()),
model.ws.as_ref(),
orders,
);
}
Msg::WebSocketChange(change) => match change {
WebSocketChanged::WsMsg(WsMsg::SignUpSuccess) => {
page.sign_up_success = true;
}
WebSocketChanged::WsMsg(WsMsg::SignUpPairTaken) => {
page.error = "Pair you give is either taken or is not matching".to_string();
}
_ => (),
},
_ => (),
}
}
fn build_page_content(model: &mut Model) {
model.page_content = PageContent::SignUp(Box::new(SignUpPage::default()));
}

View File

@ -1,66 +1,22 @@
use seed::{prelude::*, *}; use seed::{prelude::*, *};
use jirs_data::{SignUpFieldId, WsMsg}; use jirs_data::SignUpFieldId;
use crate::model::{Model, Page, PageContent, SignUpPage}; use crate::{
use crate::shared::styled_button::StyledButton; model::{self, PageContent},
use crate::shared::styled_field::StyledField; shared::{
use crate::shared::styled_form::StyledForm; outer_layout,
use crate::shared::styled_icon::{Icon, StyledIcon}; styled_button::StyledButton,
use crate::shared::styled_input::StyledInput; styled_field::StyledField,
use crate::shared::styled_link::StyledLink; styled_form::StyledForm,
use crate::shared::{outer_layout, ToNode}; styled_icon::{Icon, StyledIcon},
use crate::validations::is_email; styled_input::StyledInput,
use crate::ws::send_ws_msg; styled_link::StyledLink,
use crate::{model, FieldId, Msg, WebSocketChanged}; ToNode,
},
pub fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) { validations::is_email,
if model.page != Page::SignUp { FieldId, Msg,
return; };
}
if let Msg::ChangePage(Page::SignUp) = msg {
build_page_content(model);
return;
};
let page = match &mut model.page_content {
PageContent::SignUp(page) => page,
_ => return,
};
match msg {
Msg::StrInputChanged(FieldId::SignUp(SignUpFieldId::Username), value) => {
page.username = value;
page.username_touched = true;
}
Msg::StrInputChanged(FieldId::SignUp(SignUpFieldId::Email), value) => {
page.email = value;
page.email_touched = true;
}
Msg::SignUpRequest => {
send_ws_msg(
WsMsg::SignUpRequest(page.email.clone(), page.username.clone()),
model.ws.as_ref(),
orders,
);
}
Msg::WebSocketChange(change) => match change {
WebSocketChanged::WsMsg(WsMsg::SignUpSuccess) => {
page.sign_up_success = true;
}
WebSocketChanged::WsMsg(WsMsg::SignUpPairTaken) => {
page.error = "Pair you give is either taken or is not matching".to_string();
}
_ => (),
},
_ => (),
}
}
fn build_page_content(model: &mut Model) {
model.page_content = PageContent::SignUp(Box::new(SignUpPage::default()));
}
pub fn view(model: &model::Model) -> Node<Msg> { pub fn view(model: &model::Model) -> Node<Msg> {
let page = match &model.page_content { let page = match &model.page_content {
@ -111,7 +67,7 @@ pub fn view(model: &model::Model) -> Node<Msg> {
.into_node(); .into_node();
let submit_field = StyledField::build() let submit_field = StyledField::build()
.input(div![class!["twoRow"], submit, sign_in_link,]) .input(div![C!["twoRow"], submit, sign_in_link,])
.build() .build()
.into_node(); .into_node();
@ -122,7 +78,7 @@ pub fn view(model: &model::Model) -> Node<Msg> {
.into_node(); .into_node();
let no_pass_section = div![ let no_pass_section = div![
class!["noPasswordSection"], C!["noPasswordSection"],
attrs![At::Title => "We don't believe password is helping anyone. Instead after user provide correct login and e-mail he'll receive mail with 1-use token."], attrs![At::Title => "We don't believe password is helping anyone. Instead after user provide correct login and e-mail he'll receive mail with 1-use token."],
help_icon, help_icon,
span!["Why I don't see password?"] span!["Why I don't see password?"]
@ -131,7 +87,7 @@ pub fn view(model: &model::Model) -> Node<Msg> {
let error_row = if page.error.is_empty() { let error_row = if page.error.is_empty() {
empty![] empty![]
} else { } else {
div![class!["error"], p![page.error.as_str()]] div![C!["error"], p![page.error.as_str()]]
}; };
let sign_up_form = StyledForm::build() let sign_up_form = StyledForm::build()

View File

@ -0,0 +1,6 @@
pub use update::*;
pub use view::*;
pub mod model;
pub mod update;
pub mod view;

View File

@ -0,0 +1,40 @@
use jirs_data::{Invitation, User, UserRole, UsersFieldId};
use crate::model::InvitationFormState;
use crate::shared::styled_select::StyledSelectState;
use crate::FieldId;
#[derive(Debug)]
pub struct UsersPage {
pub name: String,
pub name_touched: bool,
pub email: String,
pub email_touched: bool,
pub user_role: UserRole,
pub user_role_state: StyledSelectState,
pub pending_invitations: Vec<String>,
pub error: String,
pub form_state: InvitationFormState,
pub invited_users: Vec<User>,
pub invitations: Vec<Invitation>,
}
impl Default for UsersPage {
fn default() -> Self {
Self {
name: "".to_string(),
name_touched: false,
email: "".to_string(),
email_touched: false,
user_role: Default::default(),
user_role_state: StyledSelectState::new(FieldId::Users(UsersFieldId::UserRole), vec![]),
pending_invitations: vec![],
error: "".to_string(),
form_state: Default::default(),
invited_users: vec![],
invitations: vec![],
}
}
}

View File

@ -2,10 +2,13 @@ use seed::prelude::Orders;
use jirs_data::{InvitationState, UserRole, UsersFieldId, WsMsg}; use jirs_data::{InvitationState, UserRole, UsersFieldId, WsMsg};
use crate::model::{InvitationFormState, Model, Page, PageContent, UsersPage}; use crate::pages::users_page::model::UsersPage;
use crate::shared::styled_select::StyledSelectChanged; use crate::{
use crate::ws::{invitation_load, send_ws_msg}; model::{InvitationFormState, Model, Page, PageContent},
use crate::{FieldId, Msg, PageChanged, UsersPageChange, WebSocketChanged}; shared::styled_select::StyledSelectChanged,
ws::{invitation_load, send_ws_msg},
FieldId, Msg, PageChanged, UsersPageChange, WebSocketChanged,
};
pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) { pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
if let Msg::ChangePage(Page::Users) = msg { if let Msg::ChangePage(Page::Users) = msg {

View File

@ -2,15 +2,16 @@ use seed::{prelude::*, *};
use jirs_data::{InvitationState, ToVec, UserRole, UsersFieldId}; use jirs_data::{InvitationState, ToVec, UserRole, UsersFieldId};
use crate::model::{InvitationFormState, Model, PageContent}; use crate::{
use crate::shared::styled_button::StyledButton; model::{InvitationFormState, Model, PageContent},
use crate::shared::styled_field::StyledField; shared::{
use crate::shared::styled_form::StyledForm; inner_layout, styled_button::StyledButton, styled_field::StyledField,
use crate::shared::styled_input::StyledInput; styled_form::StyledForm, styled_input::StyledInput, styled_select::StyledSelect, ToChild,
use crate::shared::styled_select::StyledSelect; ToNode,
use crate::shared::{inner_layout, ToChild, ToNode}; },
use crate::validations::is_email; validations::is_email,
use crate::{FieldId, Msg, PageChanged, UsersPageChange}; FieldId, Msg, PageChanged, UsersPageChange,
};
pub fn view(model: &Model) -> Node<Msg> { pub fn view(model: &Model) -> Node<Msg> {
if model.user.is_none() { if model.user.is_none() {
@ -79,11 +80,11 @@ pub fn view(model: &Model) -> Node<Msg> {
.text("Reset") .text("Reset")
.build() .build()
.into_node(), .into_node(),
InvitationFormState::Failed => div![class!["error"], "There was an error"], InvitationFormState::Failed => div![C!["error"], "There was an error"],
_ => empty![], _ => empty![],
}; };
let submit_field = StyledField::build() let submit_field = StyledField::build()
.input(div![class!["invitationActions"], submit, submit_supplement]) .input(div![C!["invitationActions"], submit, submit_supplement])
.build() .build()
.into_node(); .into_node();
@ -120,7 +121,7 @@ pub fn view(model: &Model) -> Node<Msg> {
.unwrap_or_default(); .unwrap_or_default();
li![ li![
class!["user"], C!["user"],
span![user.name.as_str()], span![user.name.as_str()],
span![user.email.as_str()], span![user.email.as_str()],
span![format!("{}", role)], span![format!("{}", role)],
@ -130,9 +131,9 @@ pub fn view(model: &Model) -> Node<Msg> {
.collect(); .collect();
let users_section = section![ let users_section = section![
class!["usersSection"], C!["usersSection"],
h1![class!["heading"], "Users"], h1![C!["heading"], "Users"],
ul![class!["usersList"], users], ul![C!["usersList"], users],
]; ];
let invitations: Vec<Node<Msg>> = page let invitations: Vec<Node<Msg>> = page
@ -147,7 +148,7 @@ pub fn view(model: &Model) -> Node<Msg> {
.build() .build()
.into_node(); .into_node();
li![ li![
class!["invitation"], C!["invitation"],
attrs![At::Class => format!("{}", invitation.state)], attrs![At::Class => format!("{}", invitation.state)],
span![invitation.name.as_str()], span![invitation.name.as_str()],
span![invitation.email.as_str()], span![invitation.email.as_str()],
@ -158,9 +159,9 @@ pub fn view(model: &Model) -> Node<Msg> {
.collect(); .collect();
let invitations_section = section![ let invitations_section = section![
class!["invitationsSection"], C!["invitationsSection"],
h1![class!["heading"], "Invitations"], h1![C!["heading"], "Invitations"],
ul![class!["invitationsList"], invitations], ul![C!["invitationsList"], invitations],
]; ];
inner_layout( inner_layout(

View File

@ -1,5 +0,0 @@
pub use update::update;
pub use view::view;
mod update;
mod view;

View File

@ -1,5 +0,0 @@
pub use update::update;
pub use view::view;
mod update;
mod view;

View File

@ -1,5 +0,0 @@
pub use update::update;
pub use view::view;
mod update;
mod view;

View File

@ -1,15 +1,20 @@
use seed::{prelude::*, *}; use {
crate::{
use jirs_data::{UserRole, WsMsg}; model::{Model, Page},
shared::{
use crate::model::{Model, Page}; divider,
use crate::shared::styled_icon::{Icon, StyledIcon}; styled_icon::{Icon, StyledIcon},
use crate::shared::{divider, ToNode}; ToNode,
use crate::ws::enqueue_ws_msg; },
use crate::{Msg, WebSocketChanged}; ws::enqueue_ws_msg,
Msg, OperationKind, ResourceKind,
},
jirs_data::{UserRole, WsMsg},
seed::{prelude::*, *},
};
pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) { pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
if let Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::AuthorizeLoaded(Ok(_)))) = msg { if let Msg::ResourceChanged(ResourceKind::Auth, OperationKind::SingleLoaded, _) = msg {
enqueue_ws_msg( enqueue_ws_msg(
vec![ vec![
WsMsg::UserProjectsLoad, WsMsg::UserProjectsLoad,
@ -20,86 +25,104 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
model.ws.as_ref(), model.ws.as_ref(),
orders, orders,
); );
orders.skip();
} }
} }
pub fn render(model: &Model) -> Node<Msg> { pub fn render(model: &Model) -> Node<Msg> {
let project_icon = Node::from_html(include_str!("../../static/project-avatar.svg")); let project_icon = crate::images::project_avatar::render();
let project_info = match model.project.as_ref() { let project_info = match model.project.as_ref() {
Some(project) => li![ Some(project) => li![
id!["projectInfo"], id!["projectInfo"],
project_icon, project_icon,
div![ div![
class!["projectTexts"], C!["projectTexts"],
div![class!["projectName"], project.name.as_str()], div![C!["projectName"], project.name.as_str()],
div![class!["projectCategory"], project.category.to_string()] div![C!["projectCategory"], project.category.to_string()]
], ],
], ],
_ => li![ _ => li![
id!["projectInfo"], id!["projectInfo"],
div![ div![
class!["projectTexts"], C!["projectTexts"],
div![class!["projectName"], ""], div![C!["projectName"], ""],
div![class!["projectCategory"], ""] div![C!["projectCategory"], ""]
], ],
], ],
}; };
let mut links = vec![];
if model.current_user_role() > UserRole::User {
links.push(sidebar_link_item(
model,
"Project settings",
Icon::Settings,
Some(Page::ProjectSettings),
));
}
links.extend(vec![
li![divider()],
sidebar_link_item(model, "Releases", Icon::Shipping, None),
sidebar_link_item(model, "Issue and Filters", Icon::Issues, None),
sidebar_link_item(model, "Pages", Icon::Page, None),
sidebar_link_item(model, "Reports", Icon::Reports, Some(Page::Reports)),
sidebar_link_item(model, "Components", Icon::Component, None),
]);
if model.current_user_role() > UserRole::User {
links.push(sidebar_link_item(
model,
"Users",
Icon::Cop,
Some(Page::Users),
));
}
nav![ nav![
id!["sidebar"], id!["sidebar"],
ul![ ul![
project_info, project_info,
sidebar_link_item(model, "Kanban Board", Icon::Board, Some(Page::Project)), sidebar_link_item(model, "Kanban Board", Icon::Board, Some(Page::Project)),
links, project_settings(model),
li![divider()],
sidebar_link_item(model, "Releases", Icon::Shipping, None),
sidebar_link_item(model, "Issue and Filters", Icon::Issues, None),
sidebar_link_item(model, "Pages", Icon::Page, None),
sidebar_link_item(model, "Reports", Icon::Reports, Some(Page::Reports)),
sidebar_link_item(model, "Components", Icon::Component, None),
users_link(model)
] ]
] ]
} }
#[inline]
fn project_settings(model: &Model) -> Node<Msg> {
if model.current_user_role() <= UserRole::User {
return Node::Empty;
}
sidebar_link_item(
model,
"Project settings",
Icon::Settings,
Some(Page::ProjectSettings),
)
}
#[inline]
fn users_link(model: &Model) -> Node<Msg> {
if model.current_user_role() <= UserRole::User {
return Node::Empty;
}
sidebar_link_item(model, "Users", Icon::Cop, Some(Page::Users))
}
#[inline]
fn sidebar_link_item(model: &Model, name: &str, icon: Icon, page: Option<Page>) -> Node<Msg> { fn sidebar_link_item(model: &Model, name: &str, icon: Icon, page: Option<Page>) -> Node<Msg> {
let path = page.map(|ref p| p.to_path()).unwrap_or_default(); let path = page.map(|ref p| p.to_path()).unwrap_or_default();
let mut class_list = vec![]; let allow_flag = if page.is_none() {
if page.is_none() { Some(C!["notAllowed"])
class_list.push("notAllowed"); } else {
None
}; };
if Some(model.page) == page { let active_flag = page.filter(|p| *p == model.page).map(|_| C!["active"]);
class_list.push("active");
}
let icon_node = StyledIcon::build(icon).build().into_node(); let icon_node = StyledIcon::build(icon).build().into_node();
let on_click = page.map(|p| {
mouse_ev("click", move |ev| {
ev.stop_propagation();
ev.prevent_default();
seed::Url::new()
.set_path(p.to_path().split('/').filter(|s| !s.is_empty()))
.go_and_push();
Msg::ChangePage(p)
})
});
li![ li![
class!["linkItem"], C!["linkItem"],
class![icon.to_str()], active_flag,
allow_flag,
C![icon.to_str()],
a![ a![
attrs![At::Href => path], attrs![At::Href => path],
on_click,
icon_node, icon_node,
div![attrs![At::Class => "linkText"], name], div![C!["linkText"], name],
] ]
] ]
} }

View File

@ -1,12 +1,11 @@
use std::str::FromStr;
use seed::{prelude::*, *}; use seed::{prelude::*, *};
use jirs_data::*; use jirs_data::*;
use crate::model::Model; use crate::{
use crate::model::Page; model::{Model, Page},
use crate::Msg; resolve_page, Msg,
};
pub mod aside; pub mod aside;
pub mod drag; pub mod drag;
@ -37,21 +36,28 @@ pub trait ToChild<'l> {
fn to_child<'m: 'l>(&'m self) -> Self::Builder; fn to_child<'m: 'l>(&'m self) -> Self::Builder;
} }
#[inline]
pub fn go_to_board(orders: &mut impl Orders<Msg>) { pub fn go_to_board(orders: &mut impl Orders<Msg>) {
go_to("/board"); go_to("board", orders);
orders.skip().send_msg(Msg::ChangePage(Page::Project)); orders.skip().send_msg(Msg::ChangePage(Page::Project));
} }
#[inline]
pub fn go_to_login(orders: &mut impl Orders<Msg>) { pub fn go_to_login(orders: &mut impl Orders<Msg>) {
go_to("/login"); go_to("login", orders);
orders.skip().send_msg(Msg::ChangePage(Page::SignIn)); orders.skip().send_msg(Msg::ChangePage(Page::SignIn));
} }
pub fn go_to(url: &str) { #[inline]
seed::push_route(Url::from_str(url).unwrap()); pub fn go_to(url: &str, orders: &mut impl Orders<Msg>) {
let url = seed::Url::new().add_path_part(url);
url.go_and_push();
if let Some(page) = resolve_page(url) {
orders.skip().send_msg(Msg::ChangePage(page));
}
} }
pub fn find_issue<'l>(model: &'l Model, issue_id: IssueId) -> Option<&'l Issue> { pub fn find_issue(model: &'_ Model, issue_id: IssueId) -> Option<&'_ Issue> {
model.issues.iter().find(|issue| issue.id == issue_id) model.issues.iter().find(|issue| issue.id == issue_id)
} }
@ -60,14 +66,14 @@ pub trait ToNode {
} }
pub fn divider() -> Node<Msg> { pub fn divider() -> Node<Msg> {
div![class!["divider"], ""] div![C!["divider"], ""]
} }
pub fn inner_layout(model: &Model, page_name: &str, children: Vec<Node<Msg>>) -> Node<Msg> { pub fn inner_layout(model: &Model, page_name: &str, children: Vec<Node<Msg>>) -> Node<Msg> {
let modal_node = crate::modal::view(model); let modal_node = crate::modal::view(model);
article![ article![
modal_node, modal_node,
class!["inner-layout", "innerPage"], C!["inner-layout", "innerPage"],
id![page_name], id![page_name],
navbar_left::render(model), navbar_left::render(model),
aside::render(model), aside::render(model),
@ -78,7 +84,7 @@ pub fn inner_layout(model: &Model, page_name: &str, children: Vec<Node<Msg>>) ->
pub fn outer_layout(model: &Model, page_name: &str, children: Vec<Node<Msg>>) -> Node<Msg> { pub fn outer_layout(model: &Model, page_name: &str, children: Vec<Node<Msg>>) -> Node<Msg> {
let modal = crate::modal::view(model); let modal = crate::modal::view(model);
article![ article![
class!["outer-layout", "outerPage"], C!["outer-layout", "outerPage"],
id![page_name], id![page_name],
modal, modal,
children children

View File

@ -2,13 +2,18 @@ use seed::{prelude::*, *};
use jirs_data::{InvitationToken, Message, MessageType, WsMsg}; use jirs_data::{InvitationToken, Message, MessageType, WsMsg};
use crate::model::Model; use crate::{
use crate::shared::styled_avatar::StyledAvatar; model::Model,
use crate::shared::styled_button::StyledButton; shared::{
use crate::shared::styled_icon::{Icon, StyledIcon}; divider,
use crate::shared::{divider, styled_tooltip, ToNode}; styled_avatar::StyledAvatar,
use crate::ws::send_ws_msg; styled_button::StyledButton,
use crate::Msg; styled_icon::{Icon, StyledIcon},
styled_tooltip, ToNode,
},
ws::send_ws_msg,
Msg, Page,
};
trait IntoNavItemIcon { trait IntoNavItemIcon {
fn into_nav_item_icon(self) -> Node<Msg>; fn into_nav_item_icon(self) -> Node<Msg>;
@ -44,7 +49,7 @@ pub fn render(model: &Model) -> Vec<Node<Msg>> {
let user_icon = match model.user.as_ref() { let user_icon = match model.user.as_ref() {
Some(user) => i![ Some(user) => i![
class!["styledIcon"], C!["styledIcon"],
StyledAvatar::build() StyledAvatar::build()
.size(27) .size(27)
.name(user.name.as_str()) .name(user.name.as_str())
@ -77,6 +82,12 @@ pub fn render(model: &Model) -> Vec<Node<Msg>> {
navbar_left_item("Create Issue", Icon::Plus, Some("/add-issue"), None), navbar_left_item("Create Issue", Icon::Plus, Some("/add-issue"), None),
] ]
}; };
let go_to_profile = mouse_ev("click", move |ev| {
ev.stop_propagation();
ev.prevent_default();
seed::Url::new().add_path_part("profile").go_and_push();
Msg::ChangePage(Page::Profile)
});
vec![ vec![
about_tooltip_popup(model), about_tooltip_popup(model),
@ -84,14 +95,14 @@ pub fn render(model: &Model) -> Vec<Node<Msg>> {
aside![ aside![
id!["navbar-left"], id!["navbar-left"],
a![ a![
class!["logoLink"], C!["logoLink"],
attrs![At::Href => "/"], attrs![At::Href => "/"],
div![class!["styledLogo"], logo_svg] div![C!["styledLogo"], logo_svg]
], ],
issue_nav, issue_nav,
div![ div![
class!["bottom"], C!["bottom"],
navbar_left_item("Profile", user_icon, Some("/profile"), None), navbar_left_item("Profile", user_icon, Some("/profile"), Some(go_to_profile)),
messages, messages,
about_tooltip(model, navbar_left_item("About", Icon::Help, None, None)), about_tooltip(model, navbar_left_item("About", Icon::Help, None, None)),
], ],
@ -99,6 +110,7 @@ pub fn render(model: &Model) -> Vec<Node<Msg>> {
] ]
} }
#[inline]
fn navbar_left_item<I>( fn navbar_left_item<I>(
text: &str, text: &str,
icon: I, icon: I,
@ -109,13 +121,12 @@ where
I: IntoNavItemIcon, I: IntoNavItemIcon,
{ {
let styled_icon = icon.into_nav_item_icon(); let styled_icon = icon.into_nav_item_icon();
let href = href.unwrap_or_else(|| "#");
a![ a![
class!["item"], C!["item"],
attrs![At::Href => href], attrs![At::Href => href.unwrap_or("#")],
styled_icon, styled_icon,
span![class!["itemText"], text], span![C!["itemText"], text],
on_click, on_click,
] ]
} }
@ -124,7 +135,7 @@ pub fn about_tooltip(_model: &Model, children: Node<Msg>) -> Node<Msg> {
let on_click: EventHandler<Msg> = ev(Ev::Click, move |_| { let on_click: EventHandler<Msg> = ev(Ev::Click, move |_| {
Some(Msg::ToggleTooltip(styled_tooltip::Variant::About)) Some(Msg::ToggleTooltip(styled_tooltip::Variant::About))
}); });
div![class!["aboutTooltip"], on_click, children] div![C!["aboutTooltip"], on_click, children]
} }
fn messages_tooltip_popup(model: &Model) -> Node<Msg> { fn messages_tooltip_popup(model: &Model) -> Node<Msg> {
@ -140,7 +151,7 @@ fn messages_tooltip_popup(model: &Model) -> Node<Msg> {
} }
}; };
} }
let body = div![on_click, class!["messagesList"], messages]; let body = div![on_click, C!["messagesList"], messages];
styled_tooltip::StyledTooltip::build() styled_tooltip::StyledTooltip::build()
.add_class("messagesPopup") .add_class("messagesPopup")
.visible(model.messages_tooltip_visible) .visible(model.messages_tooltip_visible)
@ -166,9 +177,9 @@ fn message_ui(model: &Model, message: &Message) -> Option<Node<Msg>> {
} else { } else {
let link_icon = StyledIcon::build(Icon::Link).build().into_node(); let link_icon = StyledIcon::build(Icon::Link).build().into_node();
div![ div![
class!["hyperlink"], C!["hyperlink"],
a![ a![
class!["styledLink"], C!["styledLink"],
attrs![At::Href => hyper_link], attrs![At::Href => hyper_link],
link_icon, link_icon,
hyper_link hyper_link
@ -188,9 +199,9 @@ fn message_ui(model: &Model, message: &Message) -> Option<Node<Msg>> {
.build() .build()
.into_node(); .into_node();
let top = div![ let top = div![
class!["top"], C!["top"],
div![class!["summary"], summary], div![C!["summary"], summary],
div![class!["action"], close_button], div![C!["action"], close_button],
]; ];
let node = match message_type { let node = match message_type {
@ -221,23 +232,23 @@ fn message_ui(model: &Model, message: &Message) -> Option<Node<Msg>> {
.build() .build()
.into_node(); .into_node();
div![ div![
class!["message"], C!["message"],
attrs![At::Class => format!("{}", message_type)], attrs![At::Class => format!("{}", message_type)],
top, top,
div![class!["description"], message_description], div![C!["description"], message_description],
div![class!["actions"], accept, reject], div![C!["actions"], accept, reject],
] ]
} }
MessageType::AssignedToIssue => div![ MessageType::AssignedToIssue => div![
class!["message assignedToIssue"], C!["message assignedToIssue"],
top, top,
div![class!["description"], message_description], div![C!["description"], message_description],
hyperlink, hyperlink,
], ],
MessageType::Mention => div![ MessageType::Mention => div![
class!["message mention"], C!["message mention"],
top, top,
div![class!["description"], message_description], div![C!["description"], message_description],
hyperlink, hyperlink,
], ],
}; };
@ -262,18 +273,18 @@ fn about_tooltip_popup(model: &Model) -> Node<Msg> {
}); });
let body = div![ let body = div![
on_click, on_click,
class!["feedbackDropdown"], C!["feedbackDropdown"],
div![ div![
class!["feedbackImageCont"], C!["feedbackImageCont"],
img![attrs![At::Src => "/feedback.png"]], img![attrs![At::Src => "/feedback.png"]],
class!["feedbackImage"], C!["feedbackImage"],
], ],
div![ div![
class!["feedbackParagraph"], C!["feedbackParagraph"],
"This simplified Jira clone is built with Seed.rs on the front-end and Actix-Web on the back-end." "This simplified Jira clone is built with Seed.rs on the front-end and Actix-Web on the back-end."
], ],
div![ div![
class!["feedbackParagraph"], C!["feedbackParagraph"],
"Read more on my website or reach out via ", "Read more on my website or reach out via ",
a![ a![
attrs![At::Href => "mailto:adrian.wozniak@ita-prog.pl"], attrs![At::Href => "mailto:adrian.wozniak@ita-prog.pl"],
@ -326,7 +337,7 @@ fn parse_description(model: &Model, desc: &str) -> Node<Msg> {
.size(16) .size(16)
.build() .build()
.into_node(); .into_node();
span![class!["mention"], avatar, user.name.as_str()] span![C!["mention"], avatar, user.name.as_str()]
}) })
.unwrap_or_else(|| span![word]); .unwrap_or_else(|| span![word]);
container.add_child(child).add_text(" "); container.add_child(child).add_text(" ");

View File

@ -1,7 +1,7 @@
use seed::{prelude::*, *}; use {
crate::{shared::ToNode, Msg},
use crate::shared::ToNode; seed::{prelude::*, *},
use crate::Msg; };
pub struct StyledAvatar<'l> { pub struct StyledAvatar<'l> {
avatar_url: Option<&'l str>, avatar_url: Option<&'l str>,
@ -26,6 +26,7 @@ impl<'l> Default for StyledAvatar<'l> {
} }
impl<'l> StyledAvatar<'l> { impl<'l> StyledAvatar<'l> {
#[inline(always)]
pub fn build() -> StyledAvatarBuilder<'l> { pub fn build() -> StyledAvatarBuilder<'l> {
StyledAvatarBuilder { StyledAvatarBuilder {
avatar_url: None, avatar_url: None,
@ -39,6 +40,7 @@ impl<'l> StyledAvatar<'l> {
} }
impl<'l> ToNode for StyledAvatar<'l> { impl<'l> ToNode for StyledAvatar<'l> {
#[inline(always)]
fn into_node(self) -> Node<Msg> { fn into_node(self) -> Node<Msg> {
render(self) render(self)
} }
@ -54,6 +56,7 @@ pub struct StyledAvatarBuilder<'l> {
} }
impl<'l> StyledAvatarBuilder<'l> { impl<'l> StyledAvatarBuilder<'l> {
#[inline(always)]
pub fn avatar_url<'m: 'l>(mut self, avatar_url: &'m str) -> Self { pub fn avatar_url<'m: 'l>(mut self, avatar_url: &'m str) -> Self {
if !avatar_url.is_empty() { if !avatar_url.is_empty() {
self.avatar_url = Some(avatar_url); self.avatar_url = Some(avatar_url);
@ -61,31 +64,37 @@ impl<'l> StyledAvatarBuilder<'l> {
self self
} }
#[inline(always)]
pub fn size(mut self, size: u32) -> Self { pub fn size(mut self, size: u32) -> Self {
self.size = Some(size); self.size = Some(size);
self self
} }
#[inline(always)]
pub fn name<'m: 'l>(mut self, name: &'m str) -> Self { pub fn name<'m: 'l>(mut self, name: &'m str) -> Self {
self.name = name; self.name = name;
self self
} }
#[inline(always)]
pub fn on_click(mut self, on_click: EventHandler<Msg>) -> Self { pub fn on_click(mut self, on_click: EventHandler<Msg>) -> Self {
self.on_click = Some(on_click); self.on_click = Some(on_click);
self self
} }
#[inline(always)]
pub fn add_class<'m: 'l>(mut self, name: &'m str) -> Self { pub fn add_class<'m: 'l>(mut self, name: &'m str) -> Self {
self.class_list.push(name); self.class_list.push(name);
self self
} }
#[inline(always)]
pub fn user_index(mut self, user_index: usize) -> Self { pub fn user_index(mut self, user_index: usize) -> Self {
self.user_index = user_index; self.user_index = user_index;
self self
} }
#[inline(always)]
pub fn build(self) -> StyledAvatar<'l> { pub fn build(self) -> StyledAvatar<'l> {
StyledAvatar { StyledAvatar {
avatar_url: self.avatar_url, avatar_url: self.avatar_url,
@ -111,11 +120,10 @@ pub fn render(values: StyledAvatar) -> Node<Msg> {
let index = user_index % 8; let index = user_index % 8;
let shared_style = format!("width: {size}px; height: {size}px", size = size); let shared_style = format!("width: {size}px; height: {size}px", size = size);
let handler = match on_click { let class_list: Attrs = {
None => vec![], let s: String = class_list.join(" ");
Some(h) => vec![h], C![s.as_str()]
}; };
let class_list: Vec<Attrs> = class_list.into_iter().map(|s| class![s]).collect();
let letter = name let letter = name
.chars() .chars()
.rev() .rev()
@ -130,11 +138,10 @@ pub fn render(values: StyledAvatar) -> Node<Msg> {
url = url url = url
); );
div![ div![
class!["styledAvatar"], C!["styledAvatar image"],
class!["image"],
class_list, class_list,
attrs![At::Style => style, At::Title => name], attrs![At::Style => style, At::Title => name],
handler on_click
] ]
} }
_ => { _ => {
@ -144,15 +151,14 @@ pub fn render(values: StyledAvatar) -> Node<Msg> {
size = size size = size
); );
div![ div![
class!["styledAvatar"], C!["styledAvatar letter"],
class!["letter"],
class_list, class_list,
attrs![ attrs![
At::Class => format!("avatarColor{}", index + 1), At::Class => format!("avatarColor{}", index + 1),
At::Style => style At::Style => style
], ],
span![letter], span![letter],
handler, on_click,
] ]
} }
} }

View File

@ -1,7 +1,7 @@
use seed::{prelude::*, *}; use {
crate::{shared::ToNode, ButtonId, Msg},
use crate::shared::ToNode; seed::{prelude::*, *},
use crate::{ButtonId, Msg}; };
#[allow(dead_code)] #[allow(dead_code)]
enum Variant { enum Variant {
@ -45,27 +45,33 @@ pub struct StyledButtonBuilder<'l> {
} }
impl<'l> StyledButtonBuilder<'l> { impl<'l> StyledButtonBuilder<'l> {
#[inline(always)]
fn variant(mut self, value: Variant) -> Self { fn variant(mut self, value: Variant) -> Self {
self.variant = Some(value); self.variant = Some(value);
self self
} }
#[inline(always)]
pub fn primary(self) -> Self { pub fn primary(self) -> Self {
self.variant(Variant::Primary) self.variant(Variant::Primary)
} }
#[inline(always)]
pub fn success(self) -> Self { pub fn success(self) -> Self {
self.variant(Variant::Success) self.variant(Variant::Success)
} }
#[inline(always)]
pub fn danger(self) -> Self { pub fn danger(self) -> Self {
self.variant(Variant::Danger) self.variant(Variant::Danger)
} }
#[inline(always)]
pub fn secondary(self) -> Self { pub fn secondary(self) -> Self {
self.variant(Variant::Secondary) self.variant(Variant::Secondary)
} }
#[inline(always)]
pub fn empty(self) -> Self { pub fn empty(self) -> Self {
self.variant(Variant::Empty) self.variant(Variant::Empty)
} }
@ -75,21 +81,25 @@ impl<'l> StyledButtonBuilder<'l> {
// self // self
// } // }
#[inline(always)]
pub fn disabled(mut self, value: bool) -> Self { pub fn disabled(mut self, value: bool) -> Self {
self.disabled = Some(value); self.disabled = Some(value);
self self
} }
#[inline(always)]
pub fn active(mut self, value: bool) -> Self { pub fn active(mut self, value: bool) -> Self {
self.active = Some(value); self.active = Some(value);
self self
} }
#[inline(always)]
pub fn text(mut self, value: &'l str) -> Self { pub fn text(mut self, value: &'l str) -> Self {
self.text = Some(value); self.text = Some(value);
self self
} }
#[inline(always)]
pub fn icon<I>(mut self, value: I) -> Self pub fn icon<I>(mut self, value: I) -> Self
where where
I: ToNode, I: ToNode,
@ -98,37 +108,42 @@ impl<'l> StyledButtonBuilder<'l> {
self self
} }
#[inline(always)]
pub fn on_click(mut self, value: EventHandler<Msg>) -> Self { pub fn on_click(mut self, value: EventHandler<Msg>) -> Self {
self.on_click = Some(value); self.on_click = Some(value);
self self
} }
#[inline(always)]
pub fn children(mut self, value: Vec<Node<Msg>>) -> Self { pub fn children(mut self, value: Vec<Node<Msg>>) -> Self {
self.children = Some(value); self.children = Some(value);
self self
} }
#[inline(always)]
pub fn add_class(mut self, name: &'l str) -> Self { pub fn add_class(mut self, name: &'l str) -> Self {
self.class_list.push(name); self.class_list.push(name);
self self
} }
#[inline(always)]
pub fn set_type_reset(mut self) -> Self { pub fn set_type_reset(mut self) -> Self {
self.button_type = Some("reset"); self.button_type = Some("reset");
self self
} }
#[inline(always)]
pub fn build(self) -> StyledButton<'l> { pub fn build(self) -> StyledButton<'l> {
StyledButton { StyledButton {
variant: self.variant.unwrap_or_else(|| Variant::Primary), variant: self.variant.unwrap_or(Variant::Primary),
disabled: self.disabled.unwrap_or_else(|| false), disabled: self.disabled.unwrap_or(false),
active: self.active.unwrap_or_else(|| false), active: self.active.unwrap_or(false),
text: self.text, text: self.text,
icon: self.icon, icon: self.icon,
on_click: self.on_click, on_click: self.on_click,
children: self.children.unwrap_or_default(), children: self.children.unwrap_or_default(),
class_list: self.class_list, class_list: self.class_list,
button_type: self.button_type.unwrap_or_else(|| "submit"), button_type: self.button_type.unwrap_or("submit"),
button_id: self.button_id, button_id: self.button_id,
} }
} }
@ -148,17 +163,20 @@ pub struct StyledButton<'l> {
} }
impl<'l> StyledButton<'l> { impl<'l> StyledButton<'l> {
#[inline(always)]
pub fn build() -> StyledButtonBuilder<'l> { pub fn build() -> StyledButtonBuilder<'l> {
StyledButtonBuilder::default() StyledButtonBuilder::default()
} }
} }
impl<'l> ToNode for StyledButton<'l> { impl<'l> ToNode for StyledButton<'l> {
#[inline(always)]
fn into_node(self) -> Node<Msg> { fn into_node(self) -> Node<Msg> {
render(self) render(self)
} }
} }
#[inline(always)]
pub fn render(values: StyledButton) -> Node<Msg> { pub fn render(values: StyledButton) -> Node<Msg> {
let StyledButton { let StyledButton {
text, text,
@ -187,18 +205,21 @@ pub fn render(values: StyledButton) -> Node<Msg> {
_ => vec![], _ => vec![],
}; };
let icon_node = icon.unwrap_or_else(|| empty![]); let icon_node = icon.unwrap_or(Node::Empty);
let content = if children.is_empty() && text.is_none() { let content = if children.is_empty() && text.is_none() {
empty![] Node::Empty
} else { } else {
span![class!["text"], text.unwrap_or_default(), children] span![C!["text"], text.unwrap_or_default(), children]
}; };
let class_list: Vec<Attrs> = class_list.into_iter().map(|s| class![s]).collect(); let class_list: Attrs = {
let class_list: String = class_list.join(" ");
C![class_list.as_str()]
};
let button_id = button_id.map(|id| id.to_str()).unwrap_or_default(); let button_id = button_id.map(|id| id.to_str()).unwrap_or_default();
seed::button![ seed::button![
class!["styledButton"], C!["styledButton"],
class_list, class_list,
attrs![ attrs![
At::Id => button_id, At::Id => button_id,

View File

@ -189,7 +189,7 @@ fn render(values: StyledCheckbox) -> Node<Msg> {
.collect(); .collect();
div![ div![
class!["styledCheckbox"], C!["styledCheckbox"],
attrs![At::Class => class_list.join(" ")], attrs![At::Class => class_list.join(" ")],
opt, opt,
] ]

View File

@ -1,166 +1,203 @@
use seed::{prelude::*, *}; use {
crate::{
use crate::shared::styled_textarea::StyledTextarea; shared::{styled_textarea::StyledTextarea, ToNode},
use crate::shared::ToNode; FieldChange, FieldId, Msg,
use crate::{FieldChange, FieldId, Msg}; },
seed::{prelude::*, *},
#[derive(Debug, Clone, PartialOrd, PartialEq, Hash)] };
pub enum Mode {
Editor, #[derive(Debug, Clone, PartialOrd, PartialEq, Hash)]
View, pub enum Mode {
} Editor,
View,
#[derive(Debug, Clone)] }
pub struct StyledEditorState {
pub mode: Mode, #[derive(Debug, Clone, PartialOrd, PartialEq)]
} pub struct StyledEditorState {
pub mode: Mode,
#[derive(Debug, Clone)] pub initial_text: String,
pub struct StyledEditor { }
id: FieldId,
text: String, impl StyledEditorState {
mode: Mode, pub fn new<S: Into<String>>(mode: Mode, text: S) -> Self {
update_event: Ev, Self {
} mode,
initial_text: text.into(),
impl StyledEditor { }
pub fn build(id: FieldId) -> StyledEditorBuilder { }
StyledEditorBuilder { }
id,
text: String::new(), #[derive(Debug, Clone)]
mode: Mode::View, pub struct StyledEditor {
update_event: None, id: FieldId,
} initial_text: String,
} text: String,
} html: String,
mode: Mode,
#[derive(Debug)] update_event: Ev,
pub struct StyledEditorBuilder { }
id: FieldId,
text: String, impl StyledEditor {
mode: Mode, pub fn build(id: FieldId) -> StyledEditorBuilder {
update_event: Option<Ev>, StyledEditorBuilder {
} id,
initial_text: "".to_string(),
impl StyledEditorBuilder { text: "".to_string(),
pub fn text<S>(mut self, text: S) -> Self html: "".to_string(),
where mode: Mode::View,
S: Into<String>, update_event: None,
{ }
self.text = text.into(); }
self }
}
#[derive(Debug)]
pub fn mode(mut self, mode: Mode) -> Self { pub struct StyledEditorBuilder {
self.mode = mode; id: FieldId,
self initial_text: String,
} text: String,
html: String,
pub fn build(self) -> StyledEditor { mode: Mode,
StyledEditor { update_event: Option<Ev>,
id: self.id, }
text: self.text,
mode: self.mode, impl StyledEditorBuilder {
update_event: self.update_event.unwrap_or_else(|| Ev::KeyUp), pub fn text<S>(mut self, text: S) -> Self
} where
} S: Into<String>,
{
pub fn update_on(mut self, ev: Ev) -> Self { self.text = text.into();
self.update_event = Some(ev); self
self }
}
} pub fn initial_text<S>(mut self, text: S) -> Self
where
impl ToNode for StyledEditor { S: Into<String>,
fn into_node(self) -> Node<Msg> { {
render(self) self.initial_text = text.into();
} self
} }
pub fn render(values: StyledEditor) -> Node<Msg> { pub fn html<S>(mut self, text: S) -> Self
let StyledEditor { where
id, S: Into<String>,
text, {
mode, self.html = text.into();
update_event, self
} = values; }
let field_id = id.clone(); pub fn mode(mut self, mode: Mode) -> Self {
let on_editor_clicked = mouse_ev(Ev::Click, move |ev| { self.mode = mode;
ev.stop_propagation(); self
Msg::ModalChanged(FieldChange::TabChanged(field_id, Mode::Editor)) }
});
pub fn build(self) -> StyledEditor {
let field_id = id.clone(); StyledEditor {
let on_view_clicked = mouse_ev(Ev::Click, move |ev| { id: self.id,
ev.stop_propagation(); initial_text: self.initial_text,
Msg::ModalChanged(FieldChange::TabChanged(field_id, Mode::View)) text: self.text,
}); html: self.html,
mode: self.mode,
let editor_id = format!("editor-{}", id); update_event: self.update_event.unwrap_or(Ev::KeyUp),
let view_id = format!("view-{}", id); }
let name = format!("styled-editor-{}", id); }
let text_area = StyledTextarea::build(id) pub fn update_on(mut self, ev: Ev) -> Self {
.height(40) self.update_event = Some(ev);
.update_on(update_event) self
.value(text.as_str()) }
.build() }
.into_node();
impl ToNode for StyledEditor {
let parsed = comrak::markdown_to_html(text.as_str(), &comrak::ComrakOptions::default()); fn into_node(self) -> Node<Msg> {
let parsed_node = Node::from_html(parsed.as_str()); render(self)
}
let (editor_radio_node, view_radio_node) = match mode { }
Mode::Editor => (
seed::input![ pub fn render(values: StyledEditor) -> Node<Msg> {
id![editor_id.as_str()], let StyledEditor {
attrs![At::Type => "radio"; At::Name => name.as_str(); At::Class => "editorRadio"; At::Checked => true], id,
], initial_text,
seed::input![ text: _,
id![view_id.as_str()], html,
attrs![ At::Type => "radio"; At::Name => name.as_str(); At::Class => "viewRadio";], mode,
], update_event,
), } = values;
Mode::View => (
seed::input![ let on_editor_clicked = click_handler(id.clone(), Mode::Editor);
id![editor_id.as_str()], let on_view_clicked = click_handler(id.clone(), Mode::View);
class!["editorRadio"],
attrs![At::Type => "radio"; At::Name => name.as_str();], let editor_id = format!("editor-{}", id);
], let view_id = format!("view-{}", id);
seed::input![ let name = format!("styled-editor-{}", id);
id![view_id.as_str()],
class!["viewRadio"], let text_area = StyledTextarea::build(id)
attrs![ At::Type => "radio"; At::Name => name.as_str(); At::Checked => true], .height(40)
], .update_on(update_event)
), // .disable_auto_resize()
}; .value(initial_text.as_str())
.build()
div![ .into_node();
attrs![At::Class => "styledEditor"],
label![ let (editor_radio_node, view_radio_node, parsed_node) = match mode {
if mode == Mode::View { Mode::Editor => (
class!["navbar viewTab activeTab"] seed::input![
} else { id![editor_id.as_str()],
class!["navbar viewTab"] attrs![At::Type => "radio"; At::Name => name.as_str(); At::Class => "editorRadio"; At::Checked => true],
}, ],
attrs![At::For => view_id.as_str()], seed::input![
"View", id![view_id.as_str()],
on_view_clicked attrs![ At::Type => "radio"; At::Name => name.as_str(); At::Class => "viewRadio";],
], ],
label![ vec![],
if mode == Mode::Editor { ),
C!["navbar editorTab activeTab"] Mode::View => (
} else { seed::input![
C!["navbar editorTab"] id![editor_id.as_str()],
}, C!["editorRadio"],
attrs![At::For => editor_id.as_str()], attrs![At::Type => "radio"; At::Name => name.as_str();],
"Editor", ],
on_editor_clicked seed::input![
], id![view_id.as_str()],
editor_radio_node, C!["viewRadio"],
text_area, attrs![ At::Type => "radio"; At::Name => name.as_str(); At::Checked => true],
view_radio_node, ],
div![attrs![At::Class => "view"], parsed_node], Node::from_html(html.as_str()),
] ),
} };
div![
C!["styledEditor"],
label![
if mode == Mode::View {
C!["navbar viewTab activeTab"]
} else {
C!["navbar viewTab"]
},
attrs![At::For => view_id.as_str()],
"View",
on_view_clicked
],
label![
if mode == Mode::Editor {
C!["navbar editorTab activeTab"]
} else {
C!["navbar editorTab"]
},
attrs![At::For => editor_id.as_str()],
"Editor",
on_editor_clicked
],
editor_radio_node,
text_area,
view_radio_node,
div![C!["view"], parsed_node],
]
}
#[inline]
fn click_handler(field_id: FieldId, new_mode: Mode) -> EventHandler<Msg> {
mouse_ev(Ev::Click, move |ev| {
ev.stop_propagation();
Msg::ModalChanged(FieldChange::TabChanged(field_id, new_mode))
})
}

View File

@ -1,83 +1,79 @@
use seed::{prelude::*, *}; use seed::{prelude::*, *};
use crate::shared::ToNode; use crate::shared::ToNode;
use crate::Msg; use crate::Msg;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct StyledForm<'l> { pub struct StyledForm<'l> {
heading: &'l str, heading: &'l str,
fields: Vec<Node<Msg>>, fields: Vec<Node<Msg>>,
on_submit: Option<EventHandler<Msg>>, on_submit: Option<EventHandler<Msg>>,
} }
impl<'l> StyledForm<'l> { impl<'l> StyledForm<'l> {
pub fn build() -> StyledFormBuilder<'l> { pub fn build() -> StyledFormBuilder<'l> {
StyledFormBuilder::default() StyledFormBuilder::default()
} }
} }
impl<'l> ToNode for StyledForm<'l> { impl<'l> ToNode for StyledForm<'l> {
fn into_node(self) -> Node<Msg> { fn into_node(self) -> Node<Msg> {
render(self) render(self)
} }
} }
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct StyledFormBuilder<'l> { pub struct StyledFormBuilder<'l> {
fields: Vec<Node<Msg>>, fields: Vec<Node<Msg>>,
heading: &'l str, heading: &'l str,
on_submit: Option<EventHandler<Msg>>, on_submit: Option<EventHandler<Msg>>,
} }
impl<'l> StyledFormBuilder<'l> { impl<'l> StyledFormBuilder<'l> {
pub fn add_field(mut self, node: Node<Msg>) -> Self { pub fn add_field(mut self, node: Node<Msg>) -> Self {
self.fields.push(node); self.fields.push(node);
self self
} }
pub fn try_field(mut self, node: Option<Node<Msg>>) -> Self { pub fn try_field(mut self, node: Option<Node<Msg>>) -> Self {
if let Some(n) = node { if let Some(n) = node {
self.fields.push(n); self.fields.push(n);
} }
self self
} }
pub fn heading(mut self, heading: &'l str) -> Self { pub fn heading(mut self, heading: &'l str) -> Self {
self.heading = heading; self.heading = heading;
self self
} }
pub fn on_submit(mut self, on_submit: EventHandler<Msg>) -> Self { pub fn on_submit(mut self, on_submit: EventHandler<Msg>) -> Self {
self.on_submit = Some(on_submit); self.on_submit = Some(on_submit);
self self
} }
pub fn build(self) -> StyledForm<'l> { pub fn build(self) -> StyledForm<'l> {
StyledForm { StyledForm {
heading: self.heading, heading: self.heading,
fields: self.fields, fields: self.fields,
on_submit: self.on_submit, on_submit: self.on_submit,
} }
} }
} }
pub fn render(values: StyledForm) -> Node<Msg> { pub fn render(values: StyledForm) -> Node<Msg> {
let StyledForm { let StyledForm {
heading, heading,
fields, fields,
on_submit, on_submit,
} = values; } = values;
let handlers = match on_submit { let handlers = match on_submit {
Some(handler) => vec![handler], Some(handler) => vec![handler],
_ => vec![], _ => vec![],
}; };
seed::form![ seed::form![
handlers, handlers,
attrs![At::Class => "styledForm"], attrs![At::Class => "styledForm"],
div![ div![C!["formElement"], div![C!["formHeading"], heading], fields],
class!["formElement"], ]
div![class!["formHeading"], heading], }
fields
],
]
}

View File

@ -346,30 +346,33 @@ pub fn render(values: StyledIcon) -> Node<Msg> {
}), }),
] ]
.into_iter() .into_iter()
.filter(Option::is_some) .filter_map(|o| o)
.map(|o| o.unwrap())
.collect(); .collect();
let class_list: Vec<seed::Attrs> = class_list let class_list: Vec<seed::Attrs> = class_list
.into_iter() .into_iter()
.map(|s| match s { .map(|s| match s {
Cow::Borrowed(s) => class![s], Cow::Borrowed(s) => C![s],
Cow::Owned(s) => class![s.as_str()], Cow::Owned(s) => C![s.as_str()],
}) })
.collect(); .collect();
let style_list = style_list let style_list = style_list.into_iter().fold("".to_string(), |mut mem, s| {
.iter() match s {
.map(|s| match s { Cow::Borrowed(s) => {
Cow::Borrowed(s) => s, mem.push_str(s);
Cow::Owned(s) => s.as_str(), }
}) Cow::Owned(owned) => {
.collect::<Vec<&str>>() mem.push_str(owned.as_str());
.join(";"); }
}
mem.push(';');
mem
});
i![ i![
class!["styledIcon"], C!["styledIcon"],
class_list, class_list,
class![icon.to_str()], C![icon.to_str()],
styles, styles,
attrs![ At::Style => style_list ], attrs![ At::Style => style_list ],
on_click, on_click,

View File

@ -104,19 +104,15 @@ fn render(values: StyledImageInput) -> Node<Msg> {
let input_id = id.to_string(); let input_id = id.to_string();
div![ div![
class!["styledImageInput"], C!["styledImageInput"],
attrs![At::Class => class_list.join(" ")], attrs![At::Class => class_list.join(" ")],
label![ label![
class!["label"], C!["label"],
attrs![At::For => input_id], attrs![At::For => input_id],
img![ img![C!["mask"], attrs![At::Src => url.unwrap_or_default()], " "]
class!["mask"],
attrs![At::Src => url.unwrap_or_default()],
" "
]
], ],
input![ input![
class!["input"], C!["input"],
attrs![At::Type => "file", At::Id => input_id], attrs![At::Type => "file", At::Id => input_id],
on_change on_change
] ]

View File

@ -223,18 +223,14 @@ pub fn render(values: StyledInput) -> Node<Msg> {
Msg::StrInputChanged(field_id, value) Msg::StrInputChanged(field_id, value)
}) })
}; };
let on_keyup = { let on_keyup = ev(Ev::KeyUp, move |event| {
ev(Ev::KeyUp, move |event| { event.stop_propagation();
event.stop_propagation(); None as Option<Msg>
None as Option<Msg> });
}) let on_click = ev(Ev::Click, move |event| {
}; event.stop_propagation();
let on_click = { None as Option<Msg>
ev(Ev::Click, move |event| { });
event.stop_propagation();
None as Option<Msg>
})
};
div![ div![
C!["styledInput"], C!["styledInput"],
@ -251,7 +247,7 @@ pub fn render(values: StyledInput) -> Node<Msg> {
At::Id => format!("{}", id), At::Id => format!("{}", id),
At::Class => input_class_list.join(" "), At::Class => input_class_list.join(" "),
At::Value => value.unwrap_or_default(), At::Value => value.unwrap_or_default(),
At::Type => input_type.unwrap_or_else(|| "text"), At::Type => input_type.unwrap_or("text"),
], ],
if auto_focus { if auto_focus {

View File

@ -65,7 +65,7 @@ pub fn render(values: StyledLink) -> Node<Msg> {
} = values; } = values;
a![ a![
class!["styledLink"], C!["styledLink"],
attrs![ attrs![
At::Class => class_list.join(" "), At::Class => class_list.join(" "),
At::Href => href, At::Href => href,

View File

@ -116,9 +116,14 @@ pub fn render(values: StyledModal) -> Node<Msg> {
empty![] empty![]
}; };
let close_handler = mouse_ev(Ev::Click, |_| Msg::ModalDropped); let close_handler = mouse_ev(Ev::Click, |ev| {
ev.stop_propagation();
ev.prevent_default();
Msg::ModalDropped
});
let body_handler = mouse_ev(Ev::Click, |ev| { let body_handler = mouse_ev(Ev::Click, |ev| {
ev.stop_propagation(); ev.stop_propagation();
ev.prevent_default();
None as Option<Msg> None as Option<Msg>
}); });

View File

@ -682,7 +682,7 @@ fn first_row(click_handler: EventHandler<Msg>) -> Node<Msg> {
click_handler.clone(), click_handler.clone(),
); );
div![ div![
class!["group justify"], C!["group justify"],
justify_all_button, justify_all_button,
justify_center_button, justify_center_button,
justify_left_button, justify_left_button,
@ -723,7 +723,7 @@ fn first_row(click_handler: EventHandler<Msg>) -> Node<Msg> {
}), }),
);*/ );*/
div![ div![
class!["group system"], C!["group system"],
// clip_board_button, // clip_board_button,
// copy_button, // copy_button,
// cut_button, // cut_button,
@ -777,7 +777,7 @@ fn first_row(click_handler: EventHandler<Msg>) -> Node<Msg> {
); );
div![ div![
class!["group formatting"], C!["group formatting"],
bold_button, bold_button,
italic_button, italic_button,
underline_button, underline_button,
@ -788,7 +788,7 @@ fn first_row(click_handler: EventHandler<Msg>) -> Node<Msg> {
] ]
}; };
div![class!["row firstRow"], system, formatting, justify] div![C!["row firstRow"], system, formatting, justify]
} }
fn second_row( fn second_row(
@ -825,7 +825,7 @@ fn second_row(
}), }),
); );
div![ div![
class!["group align"], C!["group align"],
align_center_button, align_center_button,
align_left_button, align_left_button,
align_right_button, align_right_button,
@ -848,10 +848,10 @@ fn second_row(
.empty() .empty()
.build() .build()
.into_node(); .into_node();
span![class!["headingOption"], button] span![C!["headingOption"], button]
}) })
.collect(); .collect();
let heading_button = span![class!["headingList"], options]; let heading_button = span![C!["headingList"], options];
/*let _field_id = values.field_id.clone(); /*let _field_id = values.field_id.clone();
let _small_cap_button = styled_rte_button( let _small_cap_button = styled_rte_button(
@ -872,7 +872,7 @@ fn second_row(
}), }),
);*/ );*/
div![ div![
class!["group font"], C!["group font"],
// font_button, // font_button,
heading_button, heading_button,
// small_cap_button, // small_cap_button,
@ -924,7 +924,7 @@ fn second_row(
code_alt_button.add_child(code_tooltip(values, click_handler.clone())); code_alt_button.add_child(code_tooltip(values, click_handler.clone()));
div![ div![
class!["group insert"], C!["group insert"],
paragraph_button, paragraph_button,
table_button, table_button,
code_alt_button, code_alt_button,
@ -943,11 +943,11 @@ fn second_row(
); );
let outdent_button = let outdent_button =
styled_rte_button("Outdent", ButtonId::Outdent, Icon::Outdent, click_handler); styled_rte_button("Outdent", ButtonId::Outdent, Icon::Outdent, click_handler);
div![class!["group indentOutdent"], indent_button, outdent_button] div![C!["group indentOutdent"], indent_button, outdent_button]
}; };
div![ div![
class!["row secondRow"], C!["row secondRow"],
font_group, font_group,
// align_group, // align_group,
insert_group, insert_group,
@ -1000,15 +1000,15 @@ fn table_tooltip(
let on_submit = click_handler; let on_submit = click_handler;
StyledTooltip::build() StyledTooltip::build()
.table_tooltip() .table_tooltip()
.visible(visible) .visible(visible)
.add_child(h2![span!["Add table"], close_table_tooltip]) .add_child(h2![span!["Add table"], close_table_tooltip])
.add_child(div![class!["inputs"], span!["Rows"], seed::input![ .add_child(div![C!["inputs"], span!["Rows"], seed::input![
attrs![At::Type => "range"; At::Step => "1"; At::Min => "1"; At::Max => "10"; At::Value => rows], attrs![At::Type => "range"; At::Step => "1"; At::Min => "1"; At::Max => "10"; At::Value => rows],
on_rows_change on_rows_change
]]) ]])
.add_child(div![ .add_child(div![
class!["inputs"], C!["inputs"],
span!["Columns"], span!["Columns"],
seed::input![ seed::input![
attrs![At::Type => "range"; At::Step => "1"; At::Min => "1"; At::Max => "10"; At::Value => cols], attrs![At::Type => "range"; At::Step => "1"; At::Min => "1"; At::Max => "10"; At::Value => cols],
@ -1025,7 +1025,7 @@ fn table_tooltip(
}) })
.collect(); .collect();
seed::div![ seed::div![
class!["tablePreview"], C!["tablePreview"],
seed::table![tbody![body]], seed::table![tbody![body]],
input![attrs![At::Type => "button"; At::Id => "rteInsertTable"; At::Value => "Insert"], on_submit], input![attrs![At::Type => "button"; At::Id => "rteInsertTable"; At::Value => "Insert"], on_submit],
] ]
@ -1121,9 +1121,5 @@ fn styled_rte_button(
.empty() .empty()
.build() .build()
.into_node(); .into_node();
span![ span![C!["styledRteButton"], attrs![At::Title => title], button]
class!["styledRteButton"],
attrs![At::Title => title],
button
]
} }

View File

@ -154,15 +154,15 @@ pub fn render(values: StyledSelectChild) -> Node<Msg> {
At::Class => name.as_deref().map(|s| format!("{}Label", s)).unwrap_or_default(), At::Class => name.as_deref().map(|s| format!("{}Label", s)).unwrap_or_default(),
At::Class => class_list.join(" "), At::Class => class_list.join(" "),
], ],
class![label_class.as_str()], C![label_class.as_str()],
text text
], ],
_ => empty![], _ => empty![],
}; };
div![ div![
class![variant.to_str()], C![variant.to_str()],
class![wrapper_class.as_str()], C![wrapper_class.as_str()],
attrs![At::Class => class_list.join(" ")], attrs![At::Class => class_list.join(" ")],
icon_node, icon_node,
label_node label_node

View File

@ -98,7 +98,7 @@ impl<'l> StyledTextareaBuilder<'l> {
height: self.height.unwrap_or(110), height: self.height.unwrap_or(110),
class_list: self.class_list, class_list: self.class_list,
max_height: self.max_height.unwrap_or_default(), max_height: self.max_height.unwrap_or_default(),
update_event: self.update_event.unwrap_or_else(|| Ev::KeyUp), update_event: self.update_event.unwrap_or(Ev::KeyUp),
placeholder: self.placeholder, placeholder: self.placeholder,
disable_auto_resize: self.disable_auto_resize, disable_auto_resize: self.disable_auto_resize,
} }
@ -170,36 +170,37 @@ pub fn render(values: StyledTextarea) -> Node<Msg> {
let text_input_handler = ev(update_event, move |event| { let text_input_handler = ev(update_event, move |event| {
event.stop_propagation(); event.stop_propagation();
let target = event.target().unwrap(); let value = event
let textarea = seed::to_textarea(&target); .target()
let value = textarea.value(); .map(|target| seed::to_textarea(&target).value())
.unwrap_or_default();
if handler_disable_auto_resize && value.contains('\n') { if handler_disable_auto_resize && value.contains('\n') {
event.prevent_default(); event.prevent_default();
} }
Msg::StrInputChanged( Some(Msg::StrInputChanged(
id, id,
if handler_disable_auto_resize { if handler_disable_auto_resize {
value.trim().to_string() value.trim().to_string()
} else { } else {
value value
}, },
) ))
}); });
class_list.push("textAreaInput"); class_list.push("textAreaInput");
div![ div![
attrs![At::Class => "styledTextArea"], C!["styledTextArea"],
div![attrs![At::Class => "textAreaHeading"]], div![C!["textAreaHeading"]],
textarea![ textarea![
attrs![ attrs![
At::Class => class_list.join(" "); At::Class => class_list.join(" ");
At::AutoFocus => "true"; At::AutoFocus => "true";
At::Style => style_list.join(";"); At::Style => style_list.join(";");
At::Placeholder => placeholder.unwrap_or_default(); At::Placeholder => placeholder.unwrap_or_default();
At::Rows => if disable_auto_resize { "1" } else { "auto" } At::Rows => if disable_auto_resize { "5" } else { "auto" }
], ],
value, value,
resize_handler, resize_handler,

View File

@ -1,20 +1,25 @@
use seed::{prelude::*, *}; use {
crate::{
use jirs_data::{TimeTracking, UpdateIssuePayload}; modal::time_tracking::value_for_time_tracking,
modals::issues_edit::Model as EditIssueModal,
use crate::modal::time_tracking::value_for_time_tracking; model::{ModalType, Model},
use crate::model::{EditIssueModal, ModalType, Model}; shared::{
use crate::shared::styled_icon::{Icon, StyledIcon}; styled_icon::{Icon, StyledIcon},
use crate::shared::ToNode; ToNode,
use crate::Msg; },
Msg,
},
jirs_data::{TimeTracking, UpdateIssuePayload},
seed::{prelude::*, *},
};
#[inline] #[inline]
pub fn fibonacci_values() -> Vec<u32> { pub fn fibonacci_values() -> Vec<u32> {
vec![0, 1, 2, 3, 5, 8, 13, 21, 34, 55] vec![0, 1, 2, 3, 5, 8, 13, 21, 34, 55]
} }
pub fn tracking_link(model: &Model, modal: &EditIssueModal) -> Node<Msg> { pub fn tracking_link(model: &Model, modal: &crate::modals::issues_edit::Model) -> Node<Msg> {
let EditIssueModal { id, .. } = modal; let crate::modals::issues_edit::Model { id, .. } = modal;
let issue_id = *id; let issue_id = *id;
@ -22,11 +27,7 @@ pub fn tracking_link(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
Msg::ModalOpened(Box::new(ModalType::TimeTracking(issue_id))) Msg::ModalOpened(Box::new(ModalType::TimeTracking(issue_id)))
}); });
div![ div![C!["trackingLink"], handler, tracking_widget(model, modal),]
class!["trackingLink"],
handler,
tracking_widget(model, modal),
]
} }
pub fn tracking_widget(model: &Model, modal: &EditIssueModal) -> Node<Msg> { pub fn tracking_widget(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
@ -71,18 +72,18 @@ pub fn tracking_widget(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
let remaining_node: Node<Msg> = remaining_node(time_remaining, estimate, time_tracking_type); let remaining_node: Node<Msg> = remaining_node(time_remaining, estimate, time_tracking_type);
div![ div![
class!["trackingWidget"], C!["trackingWidget"],
icon, icon,
div![ div![
class!["right"], C!["right"],
div![ div![
class!["barCounter"], C!["barCounter"],
div![ div![
class!["bar"], C!["bar"],
attrs![At::Style => format!("width: {}%", bar_width)] attrs![At::Style => format!("width: {}%", bar_width)]
] ]
], ],
div![class!["values"], div![spent_text], remaining_node,] div![C!["values"], div![spent_text], remaining_node,]
] ]
] ]
} }

View File

@ -11,6 +11,7 @@ pub fn board_load(model: &mut Model, orders: &mut impl Orders<Msg>) {
vec![ vec![
WsMsg::IssueStatusesLoad, WsMsg::IssueStatusesLoad,
WsMsg::ProjectIssuesLoad, WsMsg::ProjectIssuesLoad,
WsMsg::ProjectUsersLoad,
WsMsg::EpicsLoad, WsMsg::EpicsLoad,
], ],
model.ws.as_ref(), model.ws.as_ref(),

View File

@ -1,164 +1,164 @@
use seed::prelude::Orders; use seed::prelude::Orders;
use seed::*; use seed::*;
use jirs_data::*; use jirs_data::*;
use crate::model::{Model, PageContent}; use crate::model::{Model, PageContent};
use crate::ws::send_ws_msg; use crate::ws::send_ws_msg;
use crate::Msg; use crate::Msg;
pub fn drag_started(issue_id: IssueId, model: &mut Model) { pub fn drag_started(issue_id: IssueId, model: &mut Model) {
let project_page = match &mut model.page_content { let project_page = match &mut model.page_content {
PageContent::Project(project_page) => project_page, PageContent::Project(project_page) => project_page,
_ => return, _ => return,
}; };
project_page.issue_drag.drag(issue_id); project_page.issue_drag.drag(issue_id);
} }
pub fn exchange_position(issue_bellow_id: IssueId, model: &mut Model) { pub fn exchange_position(issue_bellow_id: IssueId, model: &mut Model) {
let project_page = match &mut model.page_content { let project_page = match &mut model.page_content {
PageContent::Project(project_page) => project_page, PageContent::Project(project_page) => project_page,
_ => return, _ => return,
}; };
if project_page.issue_drag.dragged_or_last(issue_bellow_id) { if project_page.issue_drag.dragged_or_last(issue_bellow_id) {
return; return;
} }
let dragged_id = match project_page.issue_drag.dragged_id.as_ref().cloned() { let dragged_id = match project_page.issue_drag.dragged_id.as_ref().cloned() {
Some(id) => id, Some(id) => id,
_ => return error!("Nothing is dragged"), _ => return error!("Nothing is dragged"),
}; };
let mut below = None; let mut below = None;
let mut dragged = None; let mut dragged = None;
let mut issues = vec![]; let mut issues = vec![];
std::mem::swap(&mut issues, &mut model.issues); std::mem::swap(&mut issues, &mut model.issues);
for issue in issues.into_iter() { for issue in issues.into_iter() {
match issue.id { match issue.id {
id if id == issue_bellow_id => below = Some(issue), id if id == issue_bellow_id => below = Some(issue),
id if id == dragged_id => dragged = Some(issue), id if id == dragged_id => dragged = Some(issue),
_ => model.issues.push(issue), _ => model.issues.push(issue),
}; };
} }
let mut below = match below { let mut below = match below {
Some(below) => below, Some(below) => below,
_ => return, _ => return,
}; };
let mut dragged = match dragged { let mut dragged = match dragged {
Some(issue) => issue, Some(issue) => issue,
_ => { _ => {
model.issues.push(below); model.issues.push(below);
return; return;
} }
}; };
if dragged.issue_status_id != below.issue_status_id { if dragged.issue_status_id != below.issue_status_id {
let mut issues = vec![]; let mut issues = vec![];
std::mem::swap(&mut issues, &mut model.issues); std::mem::swap(&mut issues, &mut model.issues);
for mut c in issues.into_iter() { for mut c in issues.into_iter() {
if c.issue_status_id == below.issue_status_id && c.list_position > below.list_position { if c.issue_status_id == below.issue_status_id && c.list_position > below.list_position {
c.list_position += 1; c.list_position += 1;
project_page.issue_drag.mark_dirty(c.id); project_page.issue_drag.mark_dirty(c.id);
} }
model.issues.push(c); model.issues.push(c);
} }
dragged.list_position = below.list_position + 1; dragged.list_position = below.list_position + 1;
dragged.issue_status_id = below.issue_status_id; dragged.issue_status_id = below.issue_status_id;
} }
std::mem::swap(&mut dragged.list_position, &mut below.list_position); std::mem::swap(&mut dragged.list_position, &mut below.list_position);
project_page.issue_drag.mark_dirty(dragged.id); project_page.issue_drag.mark_dirty(dragged.id);
project_page.issue_drag.mark_dirty(below.id); project_page.issue_drag.mark_dirty(below.id);
model.issues.push(below); model.issues.push(below);
model.issues.push(dragged); model.issues.push(dragged);
model model
.issues .issues
.sort_by(|a, b| a.list_position.cmp(&b.list_position)); .sort_by(|a, b| a.list_position.cmp(&b.list_position));
project_page.issue_drag.last_id = Some(issue_bellow_id); project_page.issue_drag.last_id = Some(issue_bellow_id);
} }
pub fn sync(model: &mut Model, orders: &mut impl Orders<Msg>) { pub fn sync(model: &mut Model, orders: &mut impl Orders<Msg>) {
// log!("------------------------------------------------------------------"); // log!("------------------------------------------------------------------");
// log!("| SYNC |"); // log!("| SYNC |");
// log!("------------------------------------------------------------------"); // log!("------------------------------------------------------------------");
let project_page = match &mut model.page_content { let project_page = match &mut model.page_content {
PageContent::Project(project_page) => project_page, PageContent::Project(project_page) => project_page,
_ => return, _ => return,
}; };
for issue in model.issues.iter() { for issue in model.issues.iter() {
if !project_page.issue_drag.dirty.contains(&issue.id) { if !project_page.issue_drag.dirty.contains(&issue.id) {
continue; continue;
} }
send_ws_msg( send_ws_msg(
WsMsg::IssueUpdate( WsMsg::IssueUpdate(
issue.id, issue.id,
IssueFieldId::IssueStatusId, IssueFieldId::IssueStatusId,
PayloadVariant::I32(issue.issue_status_id), PayloadVariant::I32(issue.issue_status_id),
), ),
model.ws.as_ref(), model.ws.as_ref(),
orders, orders,
); );
send_ws_msg( send_ws_msg(
WsMsg::IssueUpdate( WsMsg::IssueUpdate(
issue.id, issue.id,
IssueFieldId::ListPosition, IssueFieldId::ListPosition,
PayloadVariant::I32(issue.list_position), PayloadVariant::I32(issue.list_position),
), ),
model.ws.as_ref(), model.ws.as_ref(),
orders, orders,
); );
} }
project_page.issue_drag.clear(); project_page.issue_drag.clear();
} }
pub fn change_status(status_id: IssueStatusId, model: &mut Model) { pub fn change_status(status_id: IssueStatusId, model: &mut Model) {
let project_page = match &mut model.page_content { let project_page = match &mut model.page_content {
PageContent::Project(project_page) => project_page, PageContent::Project(project_page) => project_page,
_ => return, _ => return,
}; };
let issue_id = match project_page.issue_drag.dragged_id.as_ref().cloned() { let issue_id = match project_page.issue_drag.dragged_id.as_ref().cloned() {
Some(issue_id) => issue_id, Some(issue_id) => issue_id,
_ => return error!("Nothing is dragged"), _ => return error!("Nothing is dragged"),
}; };
let mut old: Vec<Issue> = vec![]; let mut old: Vec<Issue> = vec![];
let mut pos = 0; let mut pos = 0;
let mut found: Option<Issue> = None; let mut found: Option<Issue> = None;
std::mem::swap(&mut old, &mut model.issues); std::mem::swap(&mut old, &mut model.issues);
old.sort_by(|a, b| a.list_position.cmp(&b.list_position)); old.sort_by(|a, b| a.list_position.cmp(&b.list_position));
for mut issue in old.into_iter() { for mut issue in old.into_iter() {
if issue.issue_status_id == status_id { if issue.issue_status_id == status_id {
if issue.list_position != pos { if issue.list_position != pos {
issue.list_position = pos; issue.list_position = pos;
project_page.issue_drag.mark_dirty(issue.id); project_page.issue_drag.mark_dirty(issue.id);
} }
pos += 1; pos += 1;
} }
if issue.id != issue_id { if issue.id != issue_id {
model.issues.push(issue); model.issues.push(issue);
} else { } else {
found = Some(issue); found = Some(issue);
} }
} }
let mut issue = match found { let mut issue = match found {
Some(i) => i, Some(i) => i,
_ => { _ => {
return; return;
} }
}; };
if issue.issue_status_id == status_id { if issue.issue_status_id == status_id {
model.issues.push(issue); model.issues.push(issue);
} else { } else {
issue.issue_status_id = status_id; issue.issue_status_id = status_id;
issue.list_position = pos + 1; issue.list_position = pos + 1;
model.issues.push(issue); model.issues.push(issue);
project_page.issue_drag.mark_dirty(issue_id); project_page.issue_drag.mark_dirty(issue_id);
} }
} }

View File

@ -1,324 +1,427 @@
use seed::prelude::*; use seed::prelude::*;
pub use init_load_sets::*; pub use init_load_sets::*;
use jirs_data::WsMsg; use jirs_data::*;
use crate::model::*; use crate::{
use crate::shared::{go_to_board, write_auth_token}; model::*,
use crate::{Msg, WebSocketChanged}; shared::{go_to_board, write_auth_token},
Msg, OperationKind, ResourceKind, WebSocketChanged,
mod init_load_sets; };
pub mod issue; mod init_load_sets;
pub fn flush_queue(model: &mut Model, orders: &mut impl Orders<Msg>) { pub mod issue;
use seed::browser::web_socket::State;
match model.ws.as_ref() { pub fn flush_queue(model: &mut Model, orders: &mut impl Orders<Msg>) {
Some(ws) if ws.state() != State::Open => return, use seed::browser::web_socket::State;
None => return, match model.ws.as_ref() {
_ => (), Some(ws) if ws.state() != State::Open => return,
}; None => return,
let mut old = vec![]; _ => (),
std::mem::swap(&mut model.ws_queue, &mut old); };
for msg in old { let mut old = vec![];
send_ws_msg(msg, model.ws.as_ref(), orders); std::mem::swap(&mut model.ws_queue, &mut old);
} for msg in old {
} send_ws_msg(msg, model.ws.as_ref(), orders);
}
pub fn enqueue_ws_msg(v: Vec<WsMsg>, ws: Option<&WebSocket>, orders: &mut impl Orders<Msg>) { }
for msg in v {
send_ws_msg(msg, ws, orders); pub fn enqueue_ws_msg(v: Vec<WsMsg>, ws: Option<&WebSocket>, orders: &mut impl Orders<Msg>) {
} for msg in v {
} send_ws_msg(msg, ws, orders);
}
pub fn send_ws_msg(msg: WsMsg, ws: Option<&WebSocket>, orders: &mut impl Orders<Msg>) { }
use seed::browser::web_socket::State;
let ws = match ws { pub fn send_ws_msg(msg: WsMsg, ws: Option<&WebSocket>, orders: &mut impl Orders<Msg>) {
Some(ws) if ws.state() == State::Open => ws, use seed::browser::web_socket::State;
_ => { let ws = match ws {
orders Some(ws) if ws.state() == State::Open => ws,
.skip() _ => {
.send_msg(Msg::WebSocketChange(WebSocketChanged::Bounced(msg))); orders
return; .skip()
} .send_msg(Msg::WebSocketChange(WebSocketChanged::Bounced(msg)));
}; return;
let binary = bincode::serialize(&msg).unwrap(); }
ws.send_bytes(binary.as_slice()) };
.expect("Failed to send ws msg"); let binary = bincode::serialize(&msg).unwrap();
} ws.send_bytes(binary.as_slice())
.expect("Failed to send ws msg");
pub fn open_socket(model: &mut Model, orders: &mut impl Orders<Msg>) { }
use seed::browser::web_socket::State;
use seed::{prelude::*, *}; pub fn open_socket(model: &mut Model, orders: &mut impl Orders<Msg>) {
log!(model.ws.as_ref().map(|ws| ws.state())); use seed::browser::web_socket::State;
use seed::{prelude::*, *};
match model.ws.as_ref() { log!(model.ws.as_ref().map(|ws| ws.state()));
Some(ws) if ws.state() != State::Closed => {
return; match model.ws.as_ref() {
} Some(ws) if ws.state() != State::Closed => {
_ => (), return;
}; }
if model.host_url.is_empty() { _ => (),
return; };
} if model.host_url.is_empty() {
let url = model.ws_url.as_str(); return;
}
model.ws = WebSocket::builder(url, orders) let url = model.ws_url.as_str();
.on_message(|msg| {
Some(Msg::WebSocketChange(WebSocketChanged::WebSocketMessage( model.ws = WebSocket::builder(url, orders)
msg, .on_message(|msg| {
))) Some(Msg::WebSocketChange(WebSocketChanged::WebSocketMessage(
}) msg,
.on_open(|| { )))
log!("open_socket opened"); })
Some(Msg::WebSocketChange(WebSocketChanged::WebSocketOpened)) .on_open(|| {
}) log!("open_socket opened");
.on_close(|_| Some(Msg::WebSocketChange(WebSocketChanged::WebSocketClosed))) Some(Msg::WebSocketChange(WebSocketChanged::WebSocketOpened))
.on_error(|| { })
error!("Failed to open WebSocket"); .on_close(|_| Some(Msg::WebSocketChange(WebSocketChanged::WebSocketClosed)))
None as Option<Msg> .on_error(|| {
}) error!("Failed to open WebSocket");
.protocols(&["jirs"]) None as Option<Msg>
.build_and_open() })
.ok(); .protocols(&["jirs"])
} .build_and_open()
.ok();
pub async fn read_incoming(msg: WebSocketMessage) -> Msg { }
let bytes = msg.bytes().await.unwrap_or_default();
Msg::WebSocketChange(WebSocketChanged::WebSocketMessageLoaded(bytes)) pub async fn read_incoming(msg: WebSocketMessage) -> Msg {
} let bytes = msg.bytes().await.unwrap_or_default();
Msg::WebSocketChange(WebSocketChanged::WebSocketMessageLoaded(bytes))
pub fn update(msg: &WsMsg, model: &mut Model, orders: &mut impl Orders<Msg>) { }
match msg {
// auth pub fn update(msg: WsMsg, model: &mut Model, orders: &mut impl Orders<Msg>) {
WsMsg::AuthorizeLoaded(Ok(user)) => { match msg {
model.user = Some(user.clone()); // auth
if is_non_logged_area() { WsMsg::AuthorizeLoaded(Ok(user)) => {
go_to_board(orders); model.user = Some(user);
} if is_non_logged_area() {
orders go_to_board(orders);
.skip() }
.send_msg(Msg::UserChanged(model.user.as_ref().cloned())); orders
} .skip()
WsMsg::AuthorizeExpired => { .send_msg(Msg::UserChanged(model.user.as_ref().cloned()))
use seed::*; .send_msg(Msg::ResourceChanged(
ResourceKind::User,
log!("Received token expired"); OperationKind::SingleLoaded,
if let Ok(msg) = write_auth_token(None) { model.user.as_ref().map(|u| u.id),
orders.skip().send_msg(msg); ))
} .send_msg(Msg::ResourceChanged(
} ResourceKind::Auth,
// project OperationKind::SingleLoaded,
WsMsg::ProjectsLoaded(v) => { model.user.as_ref().map(|u| u.id),
model.projects = v.clone(); ));
init_current_project(model, orders); }
} WsMsg::AuthorizeExpired => {
// user projects use seed::*;
WsMsg::UserProjectsLoaded(v) => {
model.user_projects = v.clone(); log!("Received token expired");
model.current_user_project = v.iter().find(|up| up.is_current).cloned(); if let Ok(msg) = write_auth_token(None) {
init_current_project(model, orders); orders.skip().send_msg(msg).send_msg(Msg::ResourceChanged(
} ResourceKind::Auth,
WsMsg::UserProjectCurrentChanged(user_project) => { OperationKind::SingleRemoved,
let mut old = vec![]; model.user.as_ref().map(|u| u.id),
std::mem::swap(&mut old, &mut model.user_projects); ));
for mut up in old { }
up.is_current = up.id == user_project.id; }
model.user_projects.push(up); // project
} WsMsg::ProjectsLoaded(v) => {
model.current_user_project = Some(user_project.clone()); model.projects = v;
init_current_project(model, orders); init_current_project(model, orders);
} orders.send_msg(Msg::ResourceChanged(
ResourceKind::Project,
// issues OperationKind::ListLoaded,
WsMsg::ProjectIssuesLoaded(v) => { None,
let mut v = v.clone(); ));
v.sort_by(|a, b| (a.list_position as i64).cmp(&(b.list_position as i64))); }
model.issues = v; // user projects
} WsMsg::UserProjectsLoaded(v) => {
// issue statuses model.current_user_project = v.iter().find(|up| up.is_current).cloned();
WsMsg::IssueStatusesLoaded(v) => { model.user_projects = v;
model.issue_statuses = v.clone(); init_current_project(model, orders);
model orders.send_msg(Msg::ResourceChanged(
.issue_statuses ResourceKind::UserProject,
.sort_by(|a, b| a.position.cmp(&b.position)); OperationKind::ListLoaded,
} None,
WsMsg::IssueStatusCreated(is) => { ));
model.issue_statuses.push(is.clone()); }
model WsMsg::UserProjectCurrentChanged(user_project) => {
.issue_statuses let mut old = vec![];
.sort_by(|a, b| a.position.cmp(&b.position)); std::mem::swap(&mut old, &mut model.user_projects);
} for mut up in old {
WsMsg::IssueStatusUpdated(changed) => { up.is_current = up.id == user_project.id;
let mut old = vec![]; model.user_projects.push(up);
std::mem::swap(&mut model.issue_statuses, &mut old); }
for is in old { model.current_user_project = Some(user_project);
if is.id == changed.id { init_current_project(model, orders);
model.issue_statuses.push(changed.clone()); orders.send_msg(Msg::ResourceChanged(
} else { ResourceKind::UserProject,
model.issue_statuses.push(is); OperationKind::SingleModified,
} model.current_user_project.as_ref().map(|up| up.id),
} ));
model }
.issue_statuses
.sort_by(|a, b| a.position.cmp(&b.position)); // issues
} WsMsg::ProjectIssuesLoaded(mut v) => {
WsMsg::IssueStatusDeleted(dropped_id, _count) => { v.sort_by(|a, b| (a.list_position as i64).cmp(&(b.list_position as i64)));
let mut old = vec![]; model.issues = v;
std::mem::swap(&mut model.issue_statuses, &mut old); model.issues_by_id.clear();
for is in old { for issue in model.issues.iter() {
if is.id != *dropped_id { model.issues_by_id.insert(issue.id, issue.clone());
model.issue_statuses.push(is); }
}
} orders.send_msg(Msg::ResourceChanged(
model ResourceKind::Issue,
.issue_statuses OperationKind::ListLoaded,
.sort_by(|a, b| a.position.cmp(&b.position)); None,
} ));
WsMsg::IssueDeleted(id, _count) => { }
let mut old = vec![]; // issue statuses
std::mem::swap(&mut model.issue_statuses, &mut old); WsMsg::IssueStatusesLoaded(v) => {
for is in old { model.issue_statuses = v;
if is.id == *id { model
continue; .issue_statuses
} .sort_by(|a, b| a.position.cmp(&b.position));
model.issue_statuses.push(is); orders.send_msg(Msg::ResourceChanged(
} ResourceKind::IssueStatus,
model OperationKind::ListLoaded,
.issue_statuses None,
.sort_by(|a, b| a.position.cmp(&b.position)); ));
} }
// users WsMsg::IssueStatusCreated(is) => {
WsMsg::ProjectUsersLoaded(v) => { let id = is.id;
model.users = v.clone(); model.issue_statuses.push(is);
} model
// comments .issue_statuses
WsMsg::IssueCommentsLoaded(comments) => { .sort_by(|a, b| a.position.cmp(&b.position));
let issue_id = match model.modals.get(0) { orders.send_msg(Msg::ResourceChanged(
Some(ModalType::EditIssue(issue_id, _)) => *issue_id, ResourceKind::IssueStatus,
_ => return, OperationKind::SingleCreated,
}; Some(id),
if comments.iter().any(|c| c.issue_id != issue_id) { ));
return; }
} WsMsg::IssueStatusUpdated(mut changed) => {
let mut v = comments.clone(); let id = changed.id;
v.sort_by(|a, b| a.updated_at.cmp(&b.updated_at)); if let Some(idx) = model.issue_statuses.iter().position(|c| c.id == changed.id) {
model.comments = v; std::mem::swap(&mut model.issue_statuses[idx], &mut changed);
} }
WsMsg::CommentUpdated(comment) => { model
let mut old = vec![]; .issue_statuses
std::mem::swap(&mut model.comments, &mut old); .sort_by(|a, b| a.position.cmp(&b.position));
for current in old.into_iter() { orders.send_msg(Msg::ResourceChanged(
if current.id != comment.id { ResourceKind::IssueStatus,
model.comments.push(current); OperationKind::SingleModified,
} else { Some(id),
model.comments.push(comment.clone()); ));
} }
} WsMsg::IssueStatusDeleted(dropped_id, _count) => {
} let mut old = vec![];
WsMsg::CommentDeleted(comment_id, _count) => { std::mem::swap(&mut model.issue_statuses, &mut old);
let mut old = vec![]; for is in old {
std::mem::swap(&mut model.comments, &mut old); if is.id != dropped_id {
for comment in old.into_iter() { model.issue_statuses.push(is);
if *comment_id != comment.id { }
model.comments.push(comment); }
} model
} .issue_statuses
} .sort_by(|a, b| a.position.cmp(&b.position));
WsMsg::AvatarUrlChanged(user_id, avatar_url) => { orders.send_msg(Msg::ResourceChanged(
for user in model.users.iter_mut() { ResourceKind::IssueStatus,
if user.id == *user_id { OperationKind::SingleRemoved,
user.avatar_url = Some(avatar_url.clone()); Some(dropped_id),
} ));
} }
if let Some(me) = model.user.as_mut() { // issues
if me.id == *user_id { WsMsg::IssueUpdated(mut issue) => {
me.avatar_url = Some(avatar_url.clone()); let id = issue.id;
} if let Some(idx) = model.issues.iter().position(|i| i.id == issue.id) {
} std::mem::swap(&mut model.issues[idx], &mut issue);
} }
// messages orders.send_msg(Msg::ResourceChanged(
WsMsg::Message(received) => { ResourceKind::Issue,
let mut old = vec![]; OperationKind::SingleModified,
std::mem::swap(&mut old, &mut model.messages); Some(id),
for m in old { ));
if m.id != received.id { }
model.messages.push(m); WsMsg::IssueDeleted(id, _count) => {
} else { let mut old = vec![];
model.messages.push(received.clone()); std::mem::swap(&mut model.issue_statuses, &mut old);
} for is in old {
} if is.id == id {
model.messages.sort_by(|a, b| a.id.cmp(&b.id)); continue;
} }
WsMsg::MessagesLoaded(v) => { model.issue_statuses.push(is);
model.messages = v.clone(); }
model.messages.sort_by(|a, b| a.id.cmp(&b.id)); model
} .issue_statuses
WsMsg::MessageMarkedSeen(id, _count) => { .sort_by(|a, b| a.position.cmp(&b.position));
let mut old = vec![]; orders.send_msg(Msg::ResourceChanged(
std::mem::swap(&mut old, &mut model.messages); ResourceKind::Issue,
for m in old { OperationKind::SingleRemoved,
if m.id != *id { Some(id),
model.messages.push(m); ));
} }
} // users
model.messages.sort_by(|a, b| a.id.cmp(&b.id)); WsMsg::ProjectUsersLoaded(v) => {
} model.users = v.clone();
for user in v {
// epics model.users_by_id.insert(user.id, user.clone());
WsMsg::EpicsLoaded(epics) => { }
model.epics = epics.clone(); orders.send_msg(Msg::ResourceChanged(
} ResourceKind::User,
WsMsg::EpicCreated(epic) => { OperationKind::ListLoaded,
model.epics.push(epic.clone()); None,
model.epics.sort_by(|a, b| a.id.cmp(&b.id)); ));
} }
WsMsg::EpicUpdated(epic) => { // comments
let mut old = vec![]; WsMsg::IssueCommentsLoaded(mut comments) => {
std::mem::swap(&mut old, &mut model.epics); let issue_id = match model.modals.get(0) {
for current in old { Some(ModalType::EditIssue(issue_id, _)) => *issue_id,
if current.id != epic.id { _ => return,
model.epics.push(current); };
} else { if comments.iter().any(|c| c.issue_id != issue_id) {
model.epics.push(epic.clone()); return;
} }
} comments.sort_by(|a, b| a.updated_at.cmp(&b.updated_at));
model.epics.sort_by(|a, b| a.id.cmp(&b.id)); model.comments = comments;
} orders.send_msg(Msg::ResourceChanged(
WsMsg::EpicDeleted(id, _count) => { ResourceKind::Comment,
let mut old = vec![]; OperationKind::ListLoaded,
std::mem::swap(&mut old, &mut model.epics); None,
for current in old { ));
if current.id != *id { }
model.epics.push(current); WsMsg::CommentUpdated(mut comment) => {
} if let Some(idx) = model.comments.iter().position(|c| c.id == comment.id) {
} std::mem::swap(&mut model.comments[idx], &mut comment);
model.epics.sort_by(|a, b| a.id.cmp(&b.id)); }
} orders.send_msg(Msg::ResourceChanged(
_ => (), ResourceKind::Comment,
}; OperationKind::SingleModified,
} Some(comment.id),
));
fn init_current_project(model: &mut Model, orders: &mut impl Orders<Msg>) { }
if model.projects.is_empty() { WsMsg::CommentDeleted(comment_id, _count) => {
return; if let Some(idx) = model.comments.iter().position(|c| c.id == comment_id) {
} model.comments.remove(idx);
model.project = model.current_user_project.as_ref().and_then(|up| { }
model orders.send_msg(Msg::ResourceChanged(
.projects ResourceKind::Comment,
.iter() OperationKind::SingleRemoved,
.find(|p| p.id == up.project_id) Some(comment_id),
.cloned() ));
}); }
orders WsMsg::AvatarUrlChanged(user_id, avatar_url) => {
.skip() for user in model.users.iter_mut() {
.send_msg(Msg::ProjectChanged(model.project.as_ref().cloned())); if user.id == user_id {
} user.avatar_url = Some(avatar_url.clone());
}
fn is_non_logged_area() -> bool { }
let pathname = seed::document().location().unwrap().pathname().unwrap(); if let Some(me) = model.user.as_mut() {
match pathname.as_str() { if me.id == user_id {
"/login" | "/register" | "/invite" => true, me.avatar_url = Some(avatar_url.clone());
_ => false, }
} }
} orders.send_msg(Msg::ResourceChanged(
ResourceKind::User,
OperationKind::SingleModified,
Some(user_id),
));
}
// messages
WsMsg::MessageUpdated(mut received) => {
if let Some(idx) = model.messages.iter().position(|m| m.id == received.id) {
std::mem::swap(&mut model.messages[idx], &mut received);
}
model.messages.sort_by(|a, b| a.id.cmp(&b.id));
orders.send_msg(Msg::ResourceChanged(
ResourceKind::Message,
OperationKind::SingleModified,
Some(received.id),
));
}
WsMsg::MessagesLoaded(v) => {
model.messages = v;
model.messages.sort_by(|a, b| a.id.cmp(&b.id));
orders.send_msg(Msg::ResourceChanged(
ResourceKind::Message,
OperationKind::ListLoaded,
None,
));
}
WsMsg::MessageMarkedSeen(id, _count) => {
if let Some(idx) = model.messages.iter().position(|m| m.id == id) {
model.messages.remove(idx);
}
model.messages.sort_by(|a, b| a.id.cmp(&b.id));
orders.send_msg(Msg::ResourceChanged(
ResourceKind::Message,
OperationKind::SingleRemoved,
Some(id),
));
}
// epics
WsMsg::EpicsLoaded(epics) => {
model.epics = epics;
orders.send_msg(Msg::ResourceChanged(
ResourceKind::Epic,
OperationKind::ListLoaded,
None,
));
}
WsMsg::EpicCreated(epic) => {
let id = epic.id;
model.epics.push(epic);
model.epics.sort_by(|a, b| a.id.cmp(&b.id));
orders.send_msg(Msg::ResourceChanged(
ResourceKind::Epic,
OperationKind::SingleCreated,
Some(id),
));
}
WsMsg::EpicUpdated(mut epic) => {
if let Some(idx) = model.epics.iter().position(|e| e.id == epic.id) {
std::mem::swap(&mut model.epics[idx], &mut epic);
}
model.epics.sort_by(|a, b| a.id.cmp(&b.id));
orders.send_msg(Msg::ResourceChanged(
ResourceKind::Epic,
OperationKind::SingleModified,
Some(epic.id),
));
}
WsMsg::EpicDeleted(id, _count) => {
if let Some(idx) = model.epics.iter().position(|e| e.id == id) {
model.epics.remove(idx);
}
model.epics.sort_by(|a, b| a.id.cmp(&b.id));
orders.send_msg(Msg::ResourceChanged(
ResourceKind::Epic,
OperationKind::SingleRemoved,
Some(id),
));
}
_ => (),
};
}
fn init_current_project(model: &mut Model, orders: &mut impl Orders<Msg>) {
if model.projects.is_empty() {
return;
}
model.project = model.current_user_project.as_ref().and_then(|up| {
model
.projects
.iter()
.find(|p| p.id == up.project_id)
.cloned()
});
orders
.skip()
.send_msg(Msg::ProjectChanged(model.project.as_ref().cloned()));
}
fn is_non_logged_area() -> bool {
let pathname = seed::document().location().unwrap().pathname().unwrap();
matches!(pathname.as_str(), "/login" | "/register" | "/invite")
}

Some files were not shown because too many files have changed in this diff Show More