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

View File

@ -12,7 +12,6 @@
members = [
"./jirs-cli",
"./jirs-server",
"./jirs-client",
"./jirs-css",
"./shared/jirs-config",
"./shared/jirs-data",
@ -22,5 +21,8 @@ members = [
"./actors/web-actor",
"./actors/websocket-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 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
* [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 {
actix::{Actor, Handler, SyncContext},
std::sync::Arc,
@ -45,17 +46,74 @@ impl Actor for HighlightActor {
}
#[derive(actix::Message)]
#[rtype(result = "Result<Vec<u8>, HighlightError>")]
#[rtype(result = "Result<HighlightedCode, HighlightError>")]
pub struct HighlightCode {
pub code: String,
pub lang: String,
}
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 {
let res = hi(&msg.code, &msg.lang)?;
bincode::serialize(&res).map_err(|_| HighlightError::ResultUnserializable)
let res: Vec<(Style, &str)> = hi(&msg.code, &msg.lang)?;
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]
local-storage = ["filesystem-actor"]
aws-s3 = ["rusoto_s3", "rusoto_core"]
aws-s3 = ["amazon-actor"]
default = ["local-storage", "aws-s3"]
[dependencies]
@ -36,10 +36,6 @@ futures = { version = "0.3.8" }
openssl-sys = { version = "*", features = ["vendored"] }
libc = { version = "0.2.0", default-features = false }
flate2 = { version = "*" }
syntect = { version = "*" }
lazy_static = { version = "*" }
log = "0.4"
pretty_env_logger = "0.4"
env_logger = "0.7"
@ -67,18 +63,9 @@ path = "../websocket-actor"
path = "../filesystem-actor"
optional = true
# Amazon S3
[dependencies.rusoto_s3]
[dependencies.amazon-actor]
path = "../amazon-actor"
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]
version = "0.2.23"

View File

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

View File

@ -1,51 +1,40 @@
#[cfg(feature = "local-storage")]
use filesystem_actor::FileSystemExecutor;
use {
actix::Addr,
actix_multipart::Field,
actix_web::{http::header::ContentDisposition, web::Data, Error},
futures::{StreamExt, TryStreamExt},
futures::StreamExt,
jirs_data::UserId,
rusoto_core::ByteStream,
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"))]
pub(crate) async fn handle_image(
user_id: UserId,
mut field: Field,
disposition: ContentDisposition,
fs: Data<Addr<FileSystemExecutor>>,
fs: Data<Addr<filesystem_actor::FileSystemExecutor>>,
amazon: Data<Addr<amazon_actor::AmazonExecutor>>,
) -> Result<String, Error> {
let filename = disposition.get_filename().unwrap();
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(
system_file_name.clone(),
fs.clone(),
user_id,
sender.subscribe(),
));
let fs_fut = local_storage_write(system_file_name.clone(), fs, user_id, sender.subscribe());
let aws_fut = aws_s3(system_file_name, amazon, receiver);
let read_fut = read_form_data(&mut field, sender);
// Upload to AWS S3
let aws_fut = tokio::task::spawn(aws_s3(system_file_name, receiver));
read_form_data(&mut field, sender).await;
let fs_join = tokio::task::spawn(fs_fut);
let aws_join = tokio::task::spawn(aws_fut);
read_fut.await;
let mut new_link = None;
if let Ok(url) = fs_fut.await {
if let Ok(url) = fs_join.await {
new_link = url;
}
if let Ok(url) = aws_fut.await {
if let Ok(url) = aws_join.await {
new_link = url;
}
@ -57,31 +46,25 @@ pub(crate) async fn handle_image(
user_id: UserId,
mut field: Field,
disposition: ContentDisposition,
fs: Data<Addr<FileSystemExecutor>>,
amazon: Data<Addr<amazon_actor::AmazonExecutor>>,
) -> Result<String, Error> {
let filename = disposition.get_filename().unwrap();
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, receiver);
let aws_fut = aws_s3(system_file_name, amazon, 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! {
b = aws_fut => b,
};
let mut new_link = None;
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())
}
@ -90,35 +73,25 @@ pub(crate) async fn handle_image(
user_id: UserId,
mut field: Field,
disposition: ContentDisposition,
fs: Data<Addr<FileSystemExecutor>>,
fs: Data<Addr<filesystem_actor::FileSystemExecutor>>,
) -> Result<String, Error> {
let filename = disposition.get_filename().unwrap();
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(
system_file_name.clone(),
fs.clone(),
user_id,
sender.subscribe(),
);
let fs_fut = local_storage_write(system_file_name, fs, user_id, sender.subscribe());
let read_fut = read_form_data(&mut field, sender);
read_form_data(&mut field, sender).await;
let fs_join = tokio::task::spawn(fs_fut);
read_fut.await;
let new_link = tokio::select! {
a = fs_fut => a,
};
let mut new_link = None;
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())
}
@ -134,44 +107,28 @@ async fn read_form_data(field: &mut Field, sender: Sender<bytes::Bytes>) {
/// Stream bytes directly to AWS S3 Service
#[cfg(feature = "aws-s3")]
async fn aws_s3(system_file_name: String, mut receiver: Receiver<bytes::Bytes>) -> Option<String> {
let web_config = jirs_config::web::Configuration::read();
let s3 = &web_config.s3;
async fn aws_s3(
system_file_name: String,
amazon: Data<Addr<amazon_actor::AmazonExecutor>>,
receiver: Receiver<bytes::Bytes>,
) -> Option<String> {
let s3 = jirs_config::amazon::config();
if !s3.active {
return None;
}
s3.set_variables();
log::debug!("{:?}", s3);
let mut v: Vec<u8> = vec![];
use bytes::Buf;
while let Ok(b) = receiver.recv().await {
v.extend_from_slice(b.bytes())
match amazon
.send(amazon_actor::S3PutObject {
source: receiver,
file_name: system_file_name.to_string(),
})
.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")]
async fn local_storage_write(
system_file_name: String,
@ -179,36 +136,29 @@ async fn local_storage_write(
user_id: jirs_data::UserId,
receiver: Receiver<bytes::Bytes>,
) -> Option<String> {
let web_config = jirs_config::web::Configuration::read();
let fs_config = jirs_config::fs::Configuration::read();
let web_config = jirs_config::web::config();
let fs_config = jirs_config::fs::config();
let _ = fs
match fs
.send(filesystem_actor::CreateFile {
source: receiver,
file_name: system_file_name.clone(),
file_name: system_file_name.to_string(),
})
.await;
Some(format!(
"{proto}://{bind}{port}{client_path}/{user_id}-{filename}",
proto = if web_config.ssl { "https" } else { "http" },
bind = web_config.bind,
port = match web_config.port.as_str() {
"80" | "443" => "".to_string(),
p => format!(":{}", p),
},
client_path = fs_config.client_path,
user_id = user_id,
filename = system_file_name
))
}
#[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
)
.await
{
Ok(Ok(_)) => Some(format!(
"{proto}://{bind}{port}{client_path}/{user_id}-{filename}",
proto = if web_config.ssl { "https" } else { "http" },
bind = web_config.bind,
port = match web_config.port.as_str() {
"80" | "443" => "".to_string(),
p => format!(":{}", p),
},
client_path = fs_config.client_path,
user_id = user_id,
filename = system_file_name
)),
Ok(_) => None,
_ => None,
}
}

View File

@ -35,6 +35,12 @@ env_logger = "0.7"
uuid = { version = "0.8.1", features = ["serde", "v4", "v5"] }
[dependencies.comrak]
version = "*"
[dependencies.pulldown-cmark]
version = "*"
[dependencies.jirs-config]
path = "../../shared/jirs-config"
features = ["websocket"]
@ -48,3 +54,6 @@ path = "../database-actor"
[dependencies.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(
message.receiver_id,
WsMsg::Message(message),
WsMsg::MessageUpdated(message),
));
}

View File

@ -50,7 +50,64 @@ impl WsHandler<UpdateIssueHandler> for WebSocketActor {
msg.title = Some(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)) => {
msg.issue_status_id = Some(s);

View File

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

View File

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

View File

@ -2,7 +2,7 @@
# see diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "database-actor/src/schema.rs"
file = "actors/database-actor/src/schema.rs"
import_types = ["diesel::sql_types::*", "jirs_data::sql::*"]
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"
path = "src/lib.rs"
[features]
print-model = []
default = []
[dependencies]
jirs-data = { path = "../shared/jirs-data", features = ["frontend"] }
wee_alloc = "*"
seed = { version = "0.7.0" }
seed = { version = "0.8.0" }
serde = { version = "*" }
serde_json = { version = "*" }
@ -27,7 +29,10 @@ bincode = { version = "*" }
chrono = { version = "0.4", default-features = false, features = ["serde", "wasmbind"] }
uuid = { version = "0.8.1", features = ["serde"] }
futures = "^0.1.26"
comrak = "*"
[dependencies.wee_alloc]
version = "*"
features = ["static_array_backend"]
[dependencies.wasm-bindgen]
version = "*"

View File

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

View File

@ -276,8 +276,8 @@ impl ElementBuilder {
pub fn mount(&self) {
let source = self.to_js();
{
use seed::*;
log!(source);
// use seed::*;
// log!(source);
}
use seed::*;
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)]
use seed::{prelude::*, *};
use web_sys::File;
use {
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_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;
pub mod elements;
mod fields;
mod invite;
mod images;
mod modal;
mod modals;
mod model;
mod profile;
mod project;
mod project_settings;
mod reports;
mod pages;
mod shared;
mod sign_in;
mod sign_up;
mod users;
pub mod validations;
mod ws;
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
// #[global_allocator]
// 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)]
pub enum Msg {
@ -112,6 +134,9 @@ pub enum Msg {
// WebSocket
WebSocketChange(WebSocketChanged),
// resource changes
ResourceChanged(ResourceKind, OperationKind, Option<i32>),
}
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();
return;
}
WebSocketChanged::WsMsg(ref ws_msg) => {
WebSocketChanged::WsMsg(ws_msg) => {
ws::update(ws_msg, model, orders);
orders.skip();
return;
}
WebSocketChanged::WebSocketMessageLoaded(v) => {
match bincode::deserialize(v.as_slice()) {
@ -187,7 +214,6 @@ fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) {
return;
}
Msg::ChangePage(page) => {
orders.skip();
model.page = *page;
}
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);
match model.page {
Page::Project | Page::AddIssue | Page::EditIssue(..) => project::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),
{
use crate::shared::{aside, navbar_left};
aside::update(&msg, model, orders);
navbar_left::update(&msg, model, orders);
}
if cfg!(debug_assertions) {
// debug!(model);
crate::modal::update(&msg, model, orders);
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> {
match model.page {
Page::Project | Page::AddIssue => project::view(model),
Page::EditIssue(_id) => project::view(model),
Page::ProjectSettings => project_settings::view(model),
Page::SignIn => sign_in::view(model),
Page::SignUp => sign_up::view(model),
Page::Invite => invite::view(model),
Page::Users => users::view(model),
Page::Profile => profile::view(model),
Page::Reports => reports::view(model),
}
}
fn routes(url: Url) -> Option<Msg> {
match resolve_page(url) {
Some(page) => Some(Msg::ChangePage(page)),
_ => None,
Page::Project | Page::AddIssue => pages::project_page::view(model),
Page::EditIssue(_id) => pages::project_page::view(model),
Page::ProjectSettings => pages::project_settings_page::view(model),
Page::SignIn => pages::sign_in_page::view(model),
Page::SignUp => pages::sign_up_page::view(model),
Page::Invite => pages::invite_page::view(model),
Page::Users => pages::users_page::view(model),
Page::Profile => pages::profile_page::view(model),
Page::Reports => pages::reports_page::view(model),
}
}
@ -277,14 +303,39 @@ pub fn render(host_url: String, ws_url: String) {
}
elements::define();
let _app = seed::App::builder(update, view)
.routes(routes)
.after_mount(after_mount)
.window_events(window_events)
.build_and_start();
let app = seed::App::start("app", init, update, view);
{
let app_clone = app.clone();
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 ws_url = unsafe { WS_URL.clone() };
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);
open_socket(&mut model, orders);
AfterMount::new(model).url_handling(UrlHandling::PassToRoutes)
}
fn window_events(_model: &Model) -> Vec<EventHandler<Msg>> {
vec![keyboard_ev(
Ev::KeyDown,
move |event: web_sys::KeyboardEvent| {
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 None,
_ => event.key(),
};
Some(Msg::GlobalKeyDown {
key,
shift: event.shift_key(),
ctrl: event.ctrl_key(),
alt: event.alt_key(),
})
},
)]
// orders.subscribe(|subs::UrlChanged(url)| {
// if let Some(page) = resolve_page(url) {
// orders.send_msg(Msg::ChangePage(page));
// }
// });
model
}
#[inline]

View File

@ -1,16 +1,13 @@
use seed::prelude::Node;
use jirs_data::EpicId;
use crate::{
model::{IssueModal, Model},
shared::{styled_field::StyledField, styled_select::StyledSelect, ToChild, ToNode},
FieldId, Msg,
use {
crate::{
model::{IssueModal, Model},
shared::{styled_field::StyledField, styled_select::StyledSelect, ToChild, ToNode},
FieldId, Msg,
},
jirs_data::EpicId,
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>>
where
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 crate::{
modal::issues::*,
model::{self, AddIssueModal, EditIssueModal, ModalType, Model, Page},
model::{self, ModalType, Model, Page},
shared::{
find_issue, go_to_board,
styled_confirm_modal::StyledConfirmModal,
@ -18,7 +17,6 @@ use crate::{
mod confirm_delete_issue;
#[cfg(debug_assertions)]
mod debug_modal;
mod delete_issue_status;
pub mod issues;
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) => {
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);
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)]
Msg::GlobalKeyDown { key, .. } if key.eq(">") => {
orders.skip();
log!(model);
}
Msg::GlobalKeyDown { .. } => {
orders.skip();
}
_ => (),
}
add_issue::update(msg, model, orders);
issue_details::update(msg, model, orders);
delete_issue_status::update(msg, model, orders);
use crate::modals::{issue_statuses_delete, issues_create, issues_edit};
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> {
use crate::modals::{issue_statuses_delete, issues_create, issues_edit};
let modals: Vec<Node<Msg>> = model
.modals
.iter()
.map(|modal| match modal {
ModalType::EditIssue(issue_id, modal) => {
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()
.variant(ModalVariant::Center)
.width(1040)
@ -98,7 +103,7 @@ pub fn view(model: &model::Model) -> Node<Msg> {
}
}
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) => {
let comment_id = *comment_id;
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::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)]
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(
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(

View File

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

View File

@ -1,5 +1,7 @@
pub use model::*;
pub use update::*;
pub use view::*;
mod model;
mod update;
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 jirs_data::{IssueStatusId, WsMsg};
use crate::model::{DeleteIssueStatusModal, ModalType, Model};
use crate::shared::styled_confirm_modal::StyledConfirmModal;
use crate::shared::ToNode;
use crate::{model, Msg, WebSocketChanged};
use {
crate::{
modals::issue_statuses_delete::Model as DeleteIssueStatusModal,
model::{ModalType, Model},
Msg, WebSocketChanged,
},
jirs_data::WsMsg,
seed::prelude::*,
};
pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
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 view::*;
mod model;
mod update;
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 jirs_data::{IssueFieldId, IssuePriority, ToVec, UserId, WsMsg};
use crate::shared::styled_date_time_input::StyledDateTimeInput;
use crate::{
modal::issues::epic_field,
model::{AddIssueModal, IssueModal, ModalType, Model},
shared::{
styled_button::StyledButton,
styled_field::StyledField,
styled_form::StyledForm,
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,
use {
crate::{
modal::issues::epic_field,
modals::issues_create::{Model as AddIssueModal, Type},
model::Model,
shared::{
styled_button::StyledButton, styled_date_time_input::StyledDateTimeInput,
styled_field::StyledField, styled_form::StyledForm, styled_input::StyledInput,
styled_modal::StyledModal, styled_select::StyledSelect,
styled_textarea::StyledTextarea, ToChild, ToNode,
},
FieldId, Msg,
},
ws::send_ws_msg,
FieldId, Msg, WebSocketChanged,
jirs_data::{IssueFieldId, IssuePriority, ToVec},
seed::{prelude::*, *},
};
#[derive(Copy, Clone)]
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> {
pub fn view(model: &Model, modal: &Box<AddIssueModal>) -> Node<Msg> {
let issue_type = modal
.type_state
.values
@ -270,8 +64,11 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node<Msg> {
let reporter_field = reporter_field(model, modal);
let assignees_field = assignees_field(model, modal);
let issue_priority_field = issue_priority_field(modal);
let epic_field =
epic_field(model, modal, FieldId::AddIssueModal(IssueFieldId::EpicName));
let epic_field = epic_field(
model,
modal.as_ref(),
FieldId::AddIssueModal(IssueFieldId::EpicName),
);
form.add_field(short_summary_field)
.add_field(description_field)
@ -317,13 +114,13 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node<Msg> {
StyledModal::build()
.add_class("addIssue")
.width(0)
.variant(ModalVariant::Center)
.variant(crate::shared::styled_modal::Variant::Center)
.children(vec![form])
.build()
.into_node()
}
fn issue_type_field(modal: &AddIssueModal) -> Node<Msg> {
fn issue_type_field(modal: &Box<AddIssueModal>) -> Node<Msg> {
let select_type = StyledSelect::build()
.name("type")
.normal()
@ -351,7 +148,7 @@ fn issue_type_field(modal: &AddIssueModal) -> Node<Msg> {
.into_node()
}
fn short_summary_field(modal: &AddIssueModal) -> Node<Msg> {
fn short_summary_field(modal: &Box<AddIssueModal>) -> Node<Msg> {
let short_summary = StyledInput::build()
.state(&modal.title_state)
.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 chrono::{prelude::*, NaiveDate};
use seed::app::Orders;
use seed::browser::web_socket::WebSocket;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use jirs_data::*;
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,
use {
crate::{
pages::{
invite_page::InvitePage, profile_page::model::ProfilePage,
project_page::model::ProjectPage, project_settings_page::ProjectSettingsPage,
reports_page::model::ReportsPage, sign_in_page::model::SignInPage,
sign_up_page::model::SignUpPage, users_page::model::UsersPage,
},
shared::styled_select::StyledSelectState,
Msg,
},
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 {
fn epic_id_value(&self) -> Option<u32>;
fn epic_state(&self) -> &StyledSelectState;
fn update_states(&mut self, msg: &Msg, orders: &mut impl Orders<Msg>);
@ -28,12 +26,12 @@ pub trait IssueModal {
#[derive(Clone, Debug, PartialOrd, PartialEq)]
pub enum ModalType {
AddIssue(Box<AddIssueModal>),
EditIssue(IssueId, Box<EditIssueModal>),
AddIssue(Box<crate::modals::issues_create::Model>),
EditIssue(IssueId, Box<crate::modals::issues_edit::Model>),
DeleteIssueConfirm(IssueId),
DeleteCommentConfirm(CommentId),
TimeTracking(IssueId),
DeleteIssueStatusModal(Box<DeleteIssueStatusModal>),
DeleteIssueStatusModal(Box<crate::modals::issue_statuses_delete::Model>),
#[cfg(debug_assertions)]
DebugModal,
}
@ -45,259 +43,6 @@ pub struct CommentForm {
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)]
pub enum Page {
Project,
@ -345,109 +90,6 @@ pub struct UpdateProjectForm {
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)]
pub enum InvitationFormState {
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)]
pub enum PageContent {
SignIn(Box<SignInPage>),
@ -591,12 +143,22 @@ pub struct Model {
pub page_content: PageContent,
pub project: Option<Project>,
pub user: Option<User>,
pub current_user_project: Option<UserProject>,
pub issues: Vec<Issue>,
pub issues_by_id: HashMap<IssueId, Issue>,
pub user: Option<User>,
pub users: Vec<User>,
pub users_by_id: HashMap<UserId, User>,
pub comments: Vec<Comment>,
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 user_projects: Vec<UserProject>,
pub projects: Vec<Project>,
@ -605,8 +167,6 @@ pub struct Model {
impl Model {
pub fn new(host_url: String, ws_url: String) -> Self {
// let hi_worker = Worker::new("/hi.js");
Self {
ws: None,
ws_queue: vec![],
@ -627,12 +187,16 @@ impl Model {
messages_tooltip_visible: false,
issues: vec![],
users: vec![],
users_by_id: Default::default(),
comments: vec![],
issue_statuses: vec![],
issue_statuses_by_id: Default::default(),
issue_statuses_by_name: Default::default(),
messages: vec![],
user_projects: vec![],
projects: vec![],
epics: vec![],
issues_by_id: Default::default(),
}
}
@ -642,10 +206,4 @@ impl Model {
.map(|up| up.role)
.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 web_sys::FormData;
use jirs_data::{ProjectId, UsersFieldId, WsMsg};
use crate::model::{Model, Page, PageContent, ProfilePage};
use crate::shared::styled_select::StyledSelectChanged;
use crate::ws::{board_load, send_ws_msg};
use crate::{FieldId, Msg, PageChanged, ProfilePageChange, WebSocketChanged};
use {
crate::{
model::{Model, Page, PageContent},
pages::profile_page::model::ProfilePage,
shared::styled_select::StyledSelectChanged,
ws::{board_load, send_ws_msg},
FieldId, Msg, OperationKind, PageChanged, ProfilePageChange, ResourceKind,
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>) {
match msg {
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::AuthorizeLoaded(..)))
Msg::ResourceChanged(ResourceKind::Auth, OperationKind::SingleLoaded, Some(_))
| Msg::ChangePage(Page::Profile) => {
board_load(model, orders);
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.skip();
}
Msg::WebSocketChange(WebSocketChanged::WsMsg(ws_msg)) => {
if let WsMsg::AvatarUrlChanged(user_id, avatar_url) = ws_msg {
if let Some(me) = model.user.as_mut() {
if me.id == user_id {
profile_page.avatar.url = Some(avatar_url);
}
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::AvatarUrlChanged(
user_id,
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 seed::{prelude::*, *};
use jirs_data::*;
use crate::model::{Model, PageContent, ProfilePage};
use crate::shared::styled_button::StyledButton;
use crate::shared::styled_field::StyledField;
use crate::shared::styled_form::StyledForm;
use crate::shared::styled_image_input::StyledImageInput;
use crate::shared::styled_input::StyledInput;
use crate::shared::styled_select::StyledSelect;
use crate::shared::{inner_layout, ToChild, ToNode};
use crate::{FieldId, Msg, PageChanged, ProfilePageChange};
use {
crate::{
model::{Model, PageContent},
pages::profile_page::model::ProfilePage,
shared::{
inner_layout, styled_button::StyledButton, styled_field::StyledField,
styled_form::StyledForm, styled_image_input::StyledImageInput,
styled_input::StyledInput, styled_select::StyledSelect, ToChild, ToNode,
},
FieldId, Msg, PageChanged, ProfilePageChange,
},
jirs_data::*,
seed::{prelude::*, *},
std::collections::HashMap,
};
pub fn view(model: &Model) -> Node<Msg> {
let page = match &model.page_content {
@ -122,7 +122,7 @@ fn build_current_project(model: &Model, page: &ProfilePage) -> Node<Msg> {
};
StyledField::build()
.label("Current project")
.input(div![class!["project-name"], inner])
.input(div![C!["project-name"], inner])
.build()
.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::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};
use crate::{OperationKind, ResourceKind};
pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) {
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 {
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::AuthorizeLoaded(..)))
| Msg::UserChanged(..)
| Msg::ProjectChanged(Some(..))
| Msg::ChangePage(Page::Project)
| Msg::ChangePage(Page::AddIssue)
| Msg::ChangePage(Page::EditIssue(..)) => {
board_load(model, orders);
}
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::IssueUpdated(issue))) => {
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);
}
}
Msg::ResourceChanged(ResourceKind::Issue, OperationKind::SingleRemoved, ..) => {
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(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Type)),

View File

@ -1,15 +1,19 @@
use chrono::NaiveDateTime;
use seed::{prelude::*, *};
use jirs_data::*;
use crate::model::{Model, PageContent};
use crate::shared::styled_avatar::StyledAvatar;
use crate::shared::styled_button::StyledButton;
use crate::shared::styled_icon::{Icon, StyledIcon};
use crate::shared::styled_input::StyledInput;
use crate::shared::{inner_layout, ToNode};
use crate::{BoardPageChange, FieldId, Msg, PageChanged};
use {
crate::{
model::{Model, Page, PageContent},
shared::{
inner_layout,
styled_avatar::StyledAvatar,
styled_button::StyledButton,
styled_icon::{Icon, StyledIcon},
styled_input::StyledInput,
ToNode,
},
BoardPageChange, FieldId, Msg, PageChanged,
},
jirs_data::*,
seed::{prelude::*, *},
};
pub fn view(model: &Model) -> Node<Msg> {
let project_section = vec![
@ -29,11 +33,11 @@ fn breadcrumbs(model: &Model) -> Node<Msg> {
.map(|p| p.name.clone())
.unwrap_or_default();
div![
class!["breadcrumbsContainer"],
C!["breadcrumbsContainer"],
span!["Projects"],
span![class!["breadcrumbsDivider"], "/"],
span![C!["breadcrumbsDivider"], "/"],
span![project_name],
span![class!["breadcrumbsDivider"], "/"],
span![C!["breadcrumbsDivider"], "/"],
span!["Kanban Board"]
]
}
@ -47,7 +51,7 @@ fn header() -> Node<Msg> {
.into_node();
div![
id!["projectBoardHeader"],
div![id!["boardName"], class!["headerChild"], "Kanban board"],
div![id!["boardName"], C!["headerChild"], "Kanban board"],
a![
attrs![At::Href => "https://gitlab.com/adrian.wozniak/jirs", At::Target => "__blank", At::Rel => "noreferrer noopener"],
button
@ -91,7 +95,7 @@ fn project_board_filters(model: &Model) -> Node<Msg> {
{
seed::button![
id!["clearAllFilters"],
class!["filterChild"],
C!["filterChild"],
"Clear all",
mouse_ev(Ev::Click, |_| Msg::ProjectClearFilters),
]
@ -139,94 +143,77 @@ fn avatars_filters(model: &Model) -> Node<Msg> {
})
.collect();
div![id!["avatars"], class!["filterChild"], avatars]
div![id!["avatars"], C!["filterChild"], avatars]
}
fn project_board_lists(model: &Model) -> Node<Msg> {
let mut rows: Vec<Option<&Epic>> = vec![None];
for epic in model.epics.iter() {
rows.push(Some(epic));
}
let rows: Vec<Node<Msg>> = rows
.into_iter()
.map(|epic| {
let title = epic
.map(|epic| div![C!["rowName"], epic.name.as_str()])
.unwrap_or_else(|| empty![]);
let columns: Vec<Node<Msg>> = model
.issue_statuses
.iter()
.map(|issue_status| project_issue_list(model, issue_status, epic))
.collect();
div![C!["row"], title, div![C!["projectBoardLists"], columns]]
})
.collect();
let project_page = match &model.page_content {
PageContent::Project(project_page) => project_page,
_ => return empty![],
};
let rows = project_page.visible_issues.iter().map(|per_epic| {
let columns: Vec<Node<Msg>> = per_epic
.per_status_issues
.iter()
.map(|per_status| {
let issues: Vec<&Issue> = per_status
.issue_ids
.iter()
.filter_map(|id| model.issues_by_id.get(id))
.collect();
project_issue_list(
model,
per_status.status_id,
&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]
}
fn project_issue_list(
model: &Model,
status: &jirs_data::IssueStatus,
epic: Option<&jirs_data::Epic>,
status_id: IssueStatusId,
status_name: &str,
issues: &[&Issue],
) -> Node<Msg> {
let project_page = match &model.page_content {
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
let issues: Vec<Node<Msg>> = issues
.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))
.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 drop_handler = 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 = drag_ev(Ev::DragOver, move |ev| {
ev.prevent_default();
Some(Msg::PageChanged(PageChanged::Board(
BoardPageChange::IssueDragOverStatus(send_status),
)))
});
let drag_over_handler = {
let send_status = status_id;
drag_ev(Ev::DragOver, move |ev| {
ev.prevent_default();
Some(Msg::PageChanged(PageChanged::Board(
BoardPageChange::IssueDragOverStatus(send_status),
)))
})
};
div![
attrs![At::Class => "list";],
C!["list"],
div![C!["title"], status_name, div![C!["issuesCount"]]],
div![
attrs![At::Class => "title"],
label,
div![attrs![At::Class => "issuesCount"]]
],
div![
attrs![At::Class => "issues"; At::DropZone => "link"],
C!["issues"],
attrs![At::DropZone => "link"],
drop_handler,
drag_over_handler,
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> {
let avatars: Vec<Node<Msg>> = model
.users
let avatars: Vec<Node<Msg>> = issue
.user_ids
.iter()
.enumerate()
.filter(|(_, user)| issue.user_ids.contains(&user.id))
.map(|(idx, user)| {
.filter_map(|id| model.users_by_id.get(id))
.map(|user| {
StyledAvatar::build()
.size(24)
.name(user.name.as_str())
.avatar_url(user.avatar_url.as_deref().unwrap_or_default())
.user_index(idx)
.user_index(0)
.build()
.into_node()
})
.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 = {
StyledIcon::build(issue.issue_type.clone().into())
.with_color(issue.issue_type.to_str())
.build()
.into_node()
};
let issue_type_icon = StyledIcon::build(issue.issue_type.clone().into())
.with_color(issue.issue_type.to_str())
.build()
.into_node();
let priority_icon = {
let icon = match issue.priority {
IssuePriority::Low | IssuePriority::Lowest => Icon::ArrowDown,
@ -315,33 +285,43 @@ fn project_issue(model: &Model, issue: &Issue) -> Node<Msg> {
BoardPageChange::ExchangePosition(issue_id),
)))
});
let issue_id = issue.id;
let drag_out = drag_ev(Ev::DragLeave, move |_| {
Some(Msg::PageChanged(PageChanged::Board(
BoardPageChange::DragLeave(issue_id),
)))
});
let class_list = vec!["issue"];
let on_click = mouse_ev("click", move |ev| {
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);
a![
drag_started,
attrs![At::Class => "issueLink"; At::Href => href],
on_click,
C!["issueLink"],
attrs![At::Href => href],
div![
attrs![At::Class => class_list.join(" "), At::Draggable => true],
C!["issue"],
attrs![At::Draggable => true],
drag_stopped,
drag_over_handler,
drag_out,
p![attrs![At::Class => "title"], issue.title.as_str()],
p![C!["title"], issue.title.as_str()],
div![
attrs![At::Class => "bottom"],
C!["bottom"],
div![
div![attrs![At::Class => "issueTypeIcon"], issue_type_icon],
div![attrs![At::Class => "issuePriorityIcon"], priority_icon]
div![C!["issueTypeIcon"], issue_type_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 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};
use crate::pages::project_settings_page::ProjectSettingsPage;
pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
if model.page != Page::ProjectSettings {

View File

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

View File

@ -5,10 +5,12 @@ use seed::{prelude::*, *};
use jirs_data::Issue;
use crate::model::{Model, PageContent, ReportsPage};
use crate::shared::styled_icon::StyledIcon;
use crate::shared::{inner_layout, ToNode};
use crate::{Msg, PageChanged, ReportsPageChange};
use crate::pages::reports_page::model::ReportsPage;
use crate::{
model::{Model, PageContent},
shared::{inner_layout, styled_icon::StyledIcon, ToNode},
Msg, PageChanged, ReportsPageChange,
};
const SVG_MARGIN_X: u32 = 10;
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 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])
}
@ -138,8 +140,8 @@ fn this_month_graph(page: &ReportsPage, this_month_updated: &[&Issue]) -> Node<M
}
div![
class!["graph"],
h5![class!["graphHeader"], "Last updated"],
C!["graph"],
h5![C!["graphHeader"], "Last updated"],
svg![
attrs![At::Height => SVG_HEIGHT, At::Width => SVG_WIDTH],
svg_parts,
@ -173,21 +175,21 @@ fn issue_list(page: &ReportsPage, this_month_updated: &[&Issue]) -> Node<Msg> {
.build()
.into_node();
children.push(li![
class!["issue"],
class![active_class],
span![class!["priority"], priority_icon],
span![class!["type"], type_icon],
span![class!["name"], title.as_str()],
C!["issue"],
C![active_class],
span![C!["priority"], priority_icon],
span![C!["type"], type_icon],
span![C!["name"], title.as_str()],
span![
class!["desc"],
C!["desc"],
description.as_ref().cloned().unwrap_or_default()
],
span![class!["updatedAt"], day.as_str()],
span![C!["updatedAt"], day.as_str()],
]);
}
div![
class!["issueList"],
h5![class!["issueListHeader"], "Issues this month"],
C!["issueList"],
h5![C!["issueListHeader"], "Issues this month"],
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 uuid::Uuid;
use jirs_data::WsMsg;
use crate::model::{Model, Page, PageContent, SignInPage};
use crate::shared::styled_button::StyledButton;
use crate::shared::styled_field::StyledField;
use crate::shared::styled_form::StyledForm;
use crate::shared::styled_icon::{Icon, StyledIcon};
use crate::shared::styled_input::StyledInput;
use crate::shared::styled_link::StyledLink;
use crate::shared::{outer_layout, write_auth_token, ToNode};
use crate::validations::{is_email, is_token};
use crate::ws::send_ws_msg;
use crate::{model, FieldId, Msg, SignInFieldId, 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()));
}
use crate::{
model::{self, PageContent},
shared::{
outer_layout,
styled_button::StyledButton,
styled_field::StyledField,
styled_form::StyledForm,
styled_icon::{Icon, StyledIcon},
styled_input::StyledInput,
styled_link::StyledLink,
ToNode,
},
validations::{is_email, is_token},
FieldId, Msg, SignInFieldId,
};
pub fn view(model: &model::Model) -> Node<Msg> {
let page = match &model.page_content {
@ -133,7 +63,7 @@ pub fn view(model: &model::Model) -> Node<Msg> {
.build()
.into_node();
let submit_field = StyledField::build()
.input(div![class!["twoRow"], submit, register_link,])
.input(div![C!["twoRow"], submit, register_link,])
.build()
.into_node();
@ -144,7 +74,7 @@ pub fn view(model: &model::Model) -> Node<Msg> {
.into_node();
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."],
help_icon,
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 jirs_data::{SignUpFieldId, WsMsg};
use jirs_data::SignUpFieldId;
use crate::model::{Model, Page, PageContent, SignUpPage};
use crate::shared::styled_button::StyledButton;
use crate::shared::styled_field::StyledField;
use crate::shared::styled_form::StyledForm;
use crate::shared::styled_icon::{Icon, StyledIcon};
use crate::shared::styled_input::StyledInput;
use crate::shared::styled_link::StyledLink;
use crate::shared::{outer_layout, ToNode};
use crate::validations::is_email;
use crate::ws::send_ws_msg;
use crate::{model, 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()));
}
use crate::{
model::{self, PageContent},
shared::{
outer_layout,
styled_button::StyledButton,
styled_field::StyledField,
styled_form::StyledForm,
styled_icon::{Icon, StyledIcon},
styled_input::StyledInput,
styled_link::StyledLink,
ToNode,
},
validations::is_email,
FieldId, Msg,
};
pub fn view(model: &model::Model) -> Node<Msg> {
let page = match &model.page_content {
@ -111,7 +67,7 @@ pub fn view(model: &model::Model) -> Node<Msg> {
.into_node();
let submit_field = StyledField::build()
.input(div![class!["twoRow"], submit, sign_in_link,])
.input(div![C!["twoRow"], submit, sign_in_link,])
.build()
.into_node();
@ -122,7 +78,7 @@ pub fn view(model: &model::Model) -> Node<Msg> {
.into_node();
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."],
help_icon,
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() {
empty![]
} else {
div![class!["error"], p![page.error.as_str()]]
div![C!["error"], p![page.error.as_str()]]
};
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 crate::model::{InvitationFormState, Model, Page, PageContent, UsersPage};
use crate::shared::styled_select::StyledSelectChanged;
use crate::ws::{invitation_load, send_ws_msg};
use crate::{FieldId, Msg, PageChanged, UsersPageChange, WebSocketChanged};
use crate::pages::users_page::model::UsersPage;
use crate::{
model::{InvitationFormState, Model, Page, PageContent},
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>) {
if let Msg::ChangePage(Page::Users) = msg {

View File

@ -2,15 +2,16 @@ use seed::{prelude::*, *};
use jirs_data::{InvitationState, ToVec, UserRole, UsersFieldId};
use crate::model::{InvitationFormState, Model, 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::styled_select::StyledSelect;
use crate::shared::{inner_layout, ToChild, ToNode};
use crate::validations::is_email;
use crate::{FieldId, Msg, PageChanged, UsersPageChange};
use crate::{
model::{InvitationFormState, Model, PageContent},
shared::{
inner_layout, styled_button::StyledButton, styled_field::StyledField,
styled_form::StyledForm, styled_input::StyledInput, styled_select::StyledSelect, ToChild,
ToNode,
},
validations::is_email,
FieldId, Msg, PageChanged, UsersPageChange,
};
pub fn view(model: &Model) -> Node<Msg> {
if model.user.is_none() {
@ -79,11 +80,11 @@ pub fn view(model: &Model) -> Node<Msg> {
.text("Reset")
.build()
.into_node(),
InvitationFormState::Failed => div![class!["error"], "There was an error"],
InvitationFormState::Failed => div![C!["error"], "There was an error"],
_ => empty![],
};
let submit_field = StyledField::build()
.input(div![class!["invitationActions"], submit, submit_supplement])
.input(div![C!["invitationActions"], submit, submit_supplement])
.build()
.into_node();
@ -120,7 +121,7 @@ pub fn view(model: &Model) -> Node<Msg> {
.unwrap_or_default();
li![
class!["user"],
C!["user"],
span![user.name.as_str()],
span![user.email.as_str()],
span![format!("{}", role)],
@ -130,9 +131,9 @@ pub fn view(model: &Model) -> Node<Msg> {
.collect();
let users_section = section![
class!["usersSection"],
h1![class!["heading"], "Users"],
ul![class!["usersList"], users],
C!["usersSection"],
h1![C!["heading"], "Users"],
ul![C!["usersList"], users],
];
let invitations: Vec<Node<Msg>> = page
@ -147,7 +148,7 @@ pub fn view(model: &Model) -> Node<Msg> {
.build()
.into_node();
li![
class!["invitation"],
C!["invitation"],
attrs![At::Class => format!("{}", invitation.state)],
span![invitation.name.as_str()],
span![invitation.email.as_str()],
@ -158,9 +159,9 @@ pub fn view(model: &Model) -> Node<Msg> {
.collect();
let invitations_section = section![
class!["invitationsSection"],
h1![class!["heading"], "Invitations"],
ul![class!["invitationsList"], invitations],
C!["invitationsSection"],
h1![C!["heading"], "Invitations"],
ul![C!["invitationsList"], invitations],
];
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 jirs_data::{UserRole, WsMsg};
use crate::model::{Model, Page};
use crate::shared::styled_icon::{Icon, StyledIcon};
use crate::shared::{divider, ToNode};
use crate::ws::enqueue_ws_msg;
use crate::{Msg, WebSocketChanged};
use {
crate::{
model::{Model, Page},
shared::{
divider,
styled_icon::{Icon, StyledIcon},
ToNode,
},
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>) {
if let Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::AuthorizeLoaded(Ok(_)))) = msg {
if let Msg::ResourceChanged(ResourceKind::Auth, OperationKind::SingleLoaded, _) = msg {
enqueue_ws_msg(
vec![
WsMsg::UserProjectsLoad,
@ -20,86 +25,104 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
model.ws.as_ref(),
orders,
);
orders.skip();
}
}
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() {
Some(project) => li![
id!["projectInfo"],
project_icon,
div![
class!["projectTexts"],
div![class!["projectName"], project.name.as_str()],
div![class!["projectCategory"], project.category.to_string()]
C!["projectTexts"],
div![C!["projectName"], project.name.as_str()],
div![C!["projectCategory"], project.category.to_string()]
],
],
_ => li![
id!["projectInfo"],
div![
class!["projectTexts"],
div![class!["projectName"], ""],
div![class!["projectCategory"], ""]
C!["projectTexts"],
div![C!["projectName"], ""],
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![
id!["sidebar"],
ul![
project_info,
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> {
let path = page.map(|ref p| p.to_path()).unwrap_or_default();
let mut class_list = vec![];
if page.is_none() {
class_list.push("notAllowed");
let allow_flag = if page.is_none() {
Some(C!["notAllowed"])
} else {
None
};
if Some(model.page) == page {
class_list.push("active");
}
let active_flag = page.filter(|p| *p == model.page).map(|_| C!["active"]);
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![
class!["linkItem"],
class![icon.to_str()],
C!["linkItem"],
active_flag,
allow_flag,
C![icon.to_str()],
a![
attrs![At::Href => path],
on_click,
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 jirs_data::*;
use crate::model::Model;
use crate::model::Page;
use crate::Msg;
use crate::{
model::{Model, Page},
resolve_page, Msg,
};
pub mod aside;
pub mod drag;
@ -37,21 +36,28 @@ pub trait ToChild<'l> {
fn to_child<'m: 'l>(&'m self) -> Self::Builder;
}
#[inline]
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));
}
#[inline]
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));
}
pub fn go_to(url: &str) {
seed::push_route(Url::from_str(url).unwrap());
#[inline]
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)
}
@ -60,14 +66,14 @@ pub trait ToNode {
}
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> {
let modal_node = crate::modal::view(model);
article![
modal_node,
class!["inner-layout", "innerPage"],
C!["inner-layout", "innerPage"],
id![page_name],
navbar_left::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> {
let modal = crate::modal::view(model);
article![
class!["outer-layout", "outerPage"],
C!["outer-layout", "outerPage"],
id![page_name],
modal,
children

View File

@ -2,13 +2,18 @@ use seed::{prelude::*, *};
use jirs_data::{InvitationToken, Message, MessageType, WsMsg};
use crate::model::Model;
use crate::shared::styled_avatar::StyledAvatar;
use crate::shared::styled_button::StyledButton;
use crate::shared::styled_icon::{Icon, StyledIcon};
use crate::shared::{divider, styled_tooltip, ToNode};
use crate::ws::send_ws_msg;
use crate::Msg;
use crate::{
model::Model,
shared::{
divider,
styled_avatar::StyledAvatar,
styled_button::StyledButton,
styled_icon::{Icon, StyledIcon},
styled_tooltip, ToNode,
},
ws::send_ws_msg,
Msg, Page,
};
trait IntoNavItemIcon {
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() {
Some(user) => i![
class!["styledIcon"],
C!["styledIcon"],
StyledAvatar::build()
.size(27)
.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),
]
};
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![
about_tooltip_popup(model),
@ -84,14 +95,14 @@ pub fn render(model: &Model) -> Vec<Node<Msg>> {
aside![
id!["navbar-left"],
a![
class!["logoLink"],
C!["logoLink"],
attrs![At::Href => "/"],
div![class!["styledLogo"], logo_svg]
div![C!["styledLogo"], logo_svg]
],
issue_nav,
div![
class!["bottom"],
navbar_left_item("Profile", user_icon, Some("/profile"), None),
C!["bottom"],
navbar_left_item("Profile", user_icon, Some("/profile"), Some(go_to_profile)),
messages,
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>(
text: &str,
icon: I,
@ -109,13 +121,12 @@ where
I: IntoNavItemIcon,
{
let styled_icon = icon.into_nav_item_icon();
let href = href.unwrap_or_else(|| "#");
a![
class!["item"],
attrs![At::Href => href],
C!["item"],
attrs![At::Href => href.unwrap_or("#")],
styled_icon,
span![class!["itemText"], text],
span![C!["itemText"], text],
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 |_| {
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> {
@ -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()
.add_class("messagesPopup")
.visible(model.messages_tooltip_visible)
@ -166,9 +177,9 @@ fn message_ui(model: &Model, message: &Message) -> Option<Node<Msg>> {
} else {
let link_icon = StyledIcon::build(Icon::Link).build().into_node();
div![
class!["hyperlink"],
C!["hyperlink"],
a![
class!["styledLink"],
C!["styledLink"],
attrs![At::Href => hyper_link],
link_icon,
hyper_link
@ -188,9 +199,9 @@ fn message_ui(model: &Model, message: &Message) -> Option<Node<Msg>> {
.build()
.into_node();
let top = div![
class!["top"],
div![class!["summary"], summary],
div![class!["action"], close_button],
C!["top"],
div![C!["summary"], summary],
div![C!["action"], close_button],
];
let node = match message_type {
@ -221,23 +232,23 @@ fn message_ui(model: &Model, message: &Message) -> Option<Node<Msg>> {
.build()
.into_node();
div![
class!["message"],
C!["message"],
attrs![At::Class => format!("{}", message_type)],
top,
div![class!["description"], message_description],
div![class!["actions"], accept, reject],
div![C!["description"], message_description],
div![C!["actions"], accept, reject],
]
}
MessageType::AssignedToIssue => div![
class!["message assignedToIssue"],
C!["message assignedToIssue"],
top,
div![class!["description"], message_description],
div![C!["description"], message_description],
hyperlink,
],
MessageType::Mention => div![
class!["message mention"],
C!["message mention"],
top,
div![class!["description"], message_description],
div![C!["description"], message_description],
hyperlink,
],
};
@ -262,18 +273,18 @@ fn about_tooltip_popup(model: &Model) -> Node<Msg> {
});
let body = div![
on_click,
class!["feedbackDropdown"],
C!["feedbackDropdown"],
div![
class!["feedbackImageCont"],
C!["feedbackImageCont"],
img![attrs![At::Src => "/feedback.png"]],
class!["feedbackImage"],
C!["feedbackImage"],
],
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."
],
div![
class!["feedbackParagraph"],
C!["feedbackParagraph"],
"Read more on my website or reach out via ",
a![
attrs![At::Href => "mailto:adrian.wozniak@ita-prog.pl"],
@ -326,7 +337,7 @@ fn parse_description(model: &Model, desc: &str) -> Node<Msg> {
.size(16)
.build()
.into_node();
span![class!["mention"], avatar, user.name.as_str()]
span![C!["mention"], avatar, user.name.as_str()]
})
.unwrap_or_else(|| span![word]);
container.add_child(child).add_text(" ");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -116,9 +116,14 @@ pub fn render(values: StyledModal) -> Node<Msg> {
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| {
ev.stop_propagation();
ev.prevent_default();
None as Option<Msg>
});

View File

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

View File

@ -98,7 +98,7 @@ impl<'l> StyledTextareaBuilder<'l> {
height: self.height.unwrap_or(110),
class_list: self.class_list,
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,
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| {
event.stop_propagation();
let target = event.target().unwrap();
let textarea = seed::to_textarea(&target);
let value = textarea.value();
let value = event
.target()
.map(|target| seed::to_textarea(&target).value())
.unwrap_or_default();
if handler_disable_auto_resize && value.contains('\n') {
event.prevent_default();
}
Msg::StrInputChanged(
Some(Msg::StrInputChanged(
id,
if handler_disable_auto_resize {
value.trim().to_string()
} else {
value
},
)
))
});
class_list.push("textAreaInput");
div![
attrs![At::Class => "styledTextArea"],
div![attrs![At::Class => "textAreaHeading"]],
C!["styledTextArea"],
div![C!["textAreaHeading"]],
textarea![
attrs![
At::Class => class_list.join(" ");
At::AutoFocus => "true";
At::Style => style_list.join(";");
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,
resize_handler,

View File

@ -1,20 +1,25 @@
use seed::{prelude::*, *};
use jirs_data::{TimeTracking, UpdateIssuePayload};
use crate::modal::time_tracking::value_for_time_tracking;
use crate::model::{EditIssueModal, ModalType, Model};
use crate::shared::styled_icon::{Icon, StyledIcon};
use crate::shared::ToNode;
use crate::Msg;
use {
crate::{
modal::time_tracking::value_for_time_tracking,
modals::issues_edit::Model as EditIssueModal,
model::{ModalType, Model},
shared::{
styled_icon::{Icon, StyledIcon},
ToNode,
},
Msg,
},
jirs_data::{TimeTracking, UpdateIssuePayload},
seed::{prelude::*, *},
};
#[inline]
pub fn fibonacci_values() -> Vec<u32> {
vec![0, 1, 2, 3, 5, 8, 13, 21, 34, 55]
}
pub fn tracking_link(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
let EditIssueModal { id, .. } = modal;
pub fn tracking_link(model: &Model, modal: &crate::modals::issues_edit::Model) -> Node<Msg> {
let crate::modals::issues_edit::Model { id, .. } = modal;
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)))
});
div![
class!["trackingLink"],
handler,
tracking_widget(model, modal),
]
div![C!["trackingLink"], handler, tracking_widget(model, modal),]
}
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);
div![
class!["trackingWidget"],
C!["trackingWidget"],
icon,
div![
class!["right"],
C!["right"],
div![
class!["barCounter"],
C!["barCounter"],
div![
class!["bar"],
C!["bar"],
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![
WsMsg::IssueStatusesLoad,
WsMsg::ProjectIssuesLoad,
WsMsg::ProjectUsersLoad,
WsMsg::EpicsLoad,
],
model.ws.as_ref(),

View File

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

View File

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