From 01ce1794cd9f688fc9cc16eaceb3034a2e134c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Wo=C5=BAniak?= Date: Wed, 6 Jan 2021 18:47:54 +0100 Subject: [PATCH] Huge optimizations, code organization and refactoring --- Cargo.lock | 189 ++-- Cargo.toml | 6 +- README.md | 8 + actors/amazon-actor/Cargo.toml | 51 ++ actors/amazon-actor/src/lib.rs | 87 ++ actors/highlight-actor/src/lib.rs | 66 +- actors/web-actor/Cargo.toml | 19 +- actors/web-actor/src/avatar.rs | 2 + .../src/handlers/upload_avatar_image.rs | 190 ++-- actors/websocket-actor/Cargo.toml | 9 + actors/websocket-actor/src/handlers/hi.rs | 28 + .../src/handlers/invitations.rs | 2 +- actors/websocket-actor/src/handlers/issues.rs | 59 +- actors/websocket-actor/src/handlers/mod.rs | 5 +- actors/websocket-actor/src/lib.rs | 6 + diesel.toml | 4 +- jirs-client/Cargo.toml | 13 +- jirs-client/js/css/project.css | 6 +- jirs-client/src/elements.rs | 4 +- jirs-client/src/images/mod.rs | 1 + jirs-client/src/images/project_avatar.rs | 100 +++ jirs-client/src/invite.rs | 121 --- jirs-client/src/lib.rs | 201 +++-- jirs-client/src/modal/issues.rs | 19 +- jirs-client/src/modal/issues/issue_details.rs | 822 ------------------ jirs-client/src/modal/mod.rs | 30 +- jirs-client/src/modal/time_tracking.rs | 10 +- .../issue_statuses_delete}/mod.rs | 2 + .../src/modals/issue_statuses_delete/model.rs | 16 + .../issue_statuses_delete/update.rs} | 30 +- .../src/modals/issue_statuses_delete/view.rs | 22 + .../{project => modals/issues_create}/mod.rs | 2 + jirs-client/src/modals/issues_create/model.rs | 195 +++++ .../src/modals/issues_create/update.rs | 122 +++ .../issues_create/view.rs} | 249 +----- jirs-client/src/modals/issues_edit/mod.rs | 7 + jirs-client/src/modals/issues_edit/model.rs | 164 ++++ jirs-client/src/modals/issues_edit/update.rs | 347 ++++++++ .../src/modals/issues_edit/view/comments.rs | 109 +++ .../src/modals/issues_edit/view/mod.rs | 397 +++++++++ jirs-client/src/modals/mod.rs | 3 + jirs-client/src/model.rs | 510 +---------- jirs-client/src/pages/invite_page/mod.rs | 7 + jirs-client/src/pages/invite_page/model.rs | 6 + jirs-client/src/pages/invite_page/update.rs | 67 ++ jirs-client/src/pages/invite_page/view.rs | 65 ++ jirs-client/src/pages/mod.rs | 8 + jirs-client/src/pages/profile_page/mod.rs | 6 + jirs-client/src/pages/profile_page/model.rs | 40 + .../{profile => pages/profile_page}/update.rs | 37 +- .../{profile => pages/profile_page}/view.rs | 32 +- jirs-client/src/pages/project_page/mod.rs | 6 + jirs-client/src/pages/project_page/model.rs | 118 +++ .../{project => pages/project_page}/update.rs | 62 +- .../{project => pages/project_page}/view.rs | 260 +++--- .../src/pages/project_settings_page/mod.rs | 7 + .../src/pages/project_settings_page/model.rs | 72 ++ .../time_tracking_fibonacci.txt | 0 .../time_tracking_hourly.txt | 0 .../project_settings_page}/update.rs | 24 +- .../project_settings_page}/view.rs | 56 +- jirs-client/src/pages/reports_page/mod.rs | 6 + jirs-client/src/pages/reports_page/model.rs | 25 + .../{reports => pages/reports_page}/update.rs | 11 +- .../{reports => pages/reports_page}/view.rs | 34 +- jirs-client/src/pages/sign_in_page/mod.rs | 6 + jirs-client/src/pages/sign_in_page/model.rs | 12 + jirs-client/src/pages/sign_in_page/update.rs | 83 ++ .../sign_in_page/view.rs} | 104 +-- jirs-client/src/pages/sign_up_page/mod.rs | 6 + jirs-client/src/pages/sign_up_page/model.rs | 10 + jirs-client/src/pages/sign_up_page/update.rs | 58 ++ .../sign_up_page/view.rs} | 82 +- jirs-client/src/pages/users_page/mod.rs | 6 + jirs-client/src/pages/users_page/model.rs | 40 + .../src/{users => pages/users_page}/update.rs | 11 +- .../src/{users => pages/users_page}/view.rs | 39 +- jirs-client/src/profile/mod.rs | 5 - jirs-client/src/project_settings/mod.rs | 5 - jirs-client/src/reports/mod.rs | 5 - jirs-client/src/shared/aside.rs | 133 +-- jirs-client/src/shared/mod.rs | 32 +- jirs-client/src/shared/navbar_left.rs | 83 +- jirs-client/src/shared/styled_avatar.rs | 34 +- jirs-client/src/shared/styled_button.rs | 47 +- jirs-client/src/shared/styled_checkbox.rs | 2 +- jirs-client/src/shared/styled_editor.rs | 369 ++++---- jirs-client/src/shared/styled_form.rs | 162 ++-- jirs-client/src/shared/styled_icon.rs | 31 +- jirs-client/src/shared/styled_image_input.rs | 12 +- jirs-client/src/shared/styled_input.rs | 22 +- jirs-client/src/shared/styled_link.rs | 2 +- jirs-client/src/shared/styled_modal.rs | 7 +- jirs-client/src/shared/styled_rte.rs | 40 +- jirs-client/src/shared/styled_select_child.rs | 6 +- jirs-client/src/shared/styled_textarea.rs | 19 +- jirs-client/src/shared/tracking_widget.rs | 43 +- jirs-client/src/ws/init_load_sets.rs | 1 + jirs-client/src/ws/issue.rs | 328 +++---- jirs-client/src/ws/mod.rs | 751 +++++++++------- jirs-client/static/index.js | 5 +- jirs-server/Cargo.toml | 21 +- jirs-server/src/main.rs | 6 + shared/jirs-config/src/amazon.rs | 50 ++ shared/jirs-config/src/database.rs | 33 +- shared/jirs-config/src/fs.rs | 36 +- shared/jirs-config/src/hi.rs | 33 +- shared/jirs-config/src/lib.rs | 10 + shared/jirs-config/src/mail.rs | 33 +- shared/jirs-config/src/utils.rs | 92 ++ shared/jirs-config/src/web.rs | 91 +- shared/jirs-config/src/websocket.rs | 33 +- shared/jirs-data/Cargo.toml | 6 +- shared/jirs-data/LICENSE | 1 - shared/jirs-data/src/lib.rs | 38 +- shared/jirs-data/src/msg.rs | 24 +- 116 files changed, 4588 insertions(+), 3702 deletions(-) create mode 100644 actors/amazon-actor/Cargo.toml create mode 100644 actors/amazon-actor/src/lib.rs create mode 100644 actors/websocket-actor/src/handlers/hi.rs create mode 100644 jirs-client/src/images/mod.rs create mode 100644 jirs-client/src/images/project_avatar.rs delete mode 100644 jirs-client/src/invite.rs delete mode 100644 jirs-client/src/modal/issues/issue_details.rs rename jirs-client/src/{users => modals/issue_statuses_delete}/mod.rs (67%) create mode 100644 jirs-client/src/modals/issue_statuses_delete/model.rs rename jirs-client/src/{modal/delete_issue_status.rs => modals/issue_statuses_delete/update.rs} (55%) create mode 100644 jirs-client/src/modals/issue_statuses_delete/view.rs rename jirs-client/src/{project => modals/issues_create}/mod.rs (67%) create mode 100644 jirs-client/src/modals/issues_create/model.rs create mode 100644 jirs-client/src/modals/issues_create/update.rs rename jirs-client/src/{modal/issues/add_issue.rs => modals/issues_create/view.rs} (53%) create mode 100644 jirs-client/src/modals/issues_edit/mod.rs create mode 100644 jirs-client/src/modals/issues_edit/model.rs create mode 100644 jirs-client/src/modals/issues_edit/update.rs create mode 100644 jirs-client/src/modals/issues_edit/view/comments.rs create mode 100644 jirs-client/src/modals/issues_edit/view/mod.rs create mode 100644 jirs-client/src/modals/mod.rs create mode 100644 jirs-client/src/pages/invite_page/mod.rs create mode 100644 jirs-client/src/pages/invite_page/model.rs create mode 100644 jirs-client/src/pages/invite_page/update.rs create mode 100644 jirs-client/src/pages/invite_page/view.rs create mode 100644 jirs-client/src/pages/mod.rs create mode 100644 jirs-client/src/pages/profile_page/mod.rs create mode 100644 jirs-client/src/pages/profile_page/model.rs rename jirs-client/src/{profile => pages/profile_page}/update.rs (78%) rename jirs-client/src/{profile => pages/profile_page}/view.rs (85%) create mode 100644 jirs-client/src/pages/project_page/mod.rs create mode 100644 jirs-client/src/pages/project_page/model.rs rename jirs-client/src/{project => pages/project_page}/update.rs (77%) rename jirs-client/src/{project => pages/project_page}/view.rs (54%) create mode 100644 jirs-client/src/pages/project_settings_page/mod.rs create mode 100644 jirs-client/src/pages/project_settings_page/model.rs rename jirs-client/src/{project_settings => pages/project_settings_page}/time_tracking_fibonacci.txt (100%) rename jirs-client/src/{project_settings => pages/project_settings_page}/time_tracking_hourly.txt (100%) rename jirs-client/src/{project_settings => pages/project_settings_page}/update.rs (94%) rename jirs-client/src/{project_settings => pages/project_settings_page}/view.rs (89%) create mode 100644 jirs-client/src/pages/reports_page/mod.rs create mode 100644 jirs-client/src/pages/reports_page/model.rs rename jirs-client/src/{reports => pages/reports_page}/update.rs (83%) rename jirs-client/src/{reports => pages/reports_page}/view.rs (89%) create mode 100644 jirs-client/src/pages/sign_in_page/mod.rs create mode 100644 jirs-client/src/pages/sign_in_page/model.rs create mode 100644 jirs-client/src/pages/sign_in_page/update.rs rename jirs-client/src/{sign_in.rs => pages/sign_in_page/view.rs} (53%) create mode 100644 jirs-client/src/pages/sign_up_page/mod.rs create mode 100644 jirs-client/src/pages/sign_up_page/model.rs create mode 100644 jirs-client/src/pages/sign_up_page/update.rs rename jirs-client/src/{sign_up.rs => pages/sign_up_page/view.rs} (54%) create mode 100644 jirs-client/src/pages/users_page/mod.rs create mode 100644 jirs-client/src/pages/users_page/model.rs rename jirs-client/src/{users => pages/users_page}/update.rs (94%) rename jirs-client/src/{users => pages/users_page}/view.rs (83%) delete mode 100644 jirs-client/src/profile/mod.rs delete mode 100644 jirs-client/src/project_settings/mod.rs delete mode 100644 jirs-client/src/reports/mod.rs create mode 100644 shared/jirs-config/src/amazon.rs create mode 100644 shared/jirs-config/src/utils.rs delete mode 120000 shared/jirs-data/LICENSE diff --git a/Cargo.lock b/Cargo.lock index 8cf1451f..8a657ba5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/Cargo.toml b/Cargo.toml index 4c14b9a2..fe73de2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" ] diff --git a/README.md b/README.md index 1be470de..c90c220e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/actors/amazon-actor/Cargo.toml b/actors/amazon-actor/Cargo.toml new file mode 100644 index 00000000..67605ac2 --- /dev/null +++ b/actors/amazon-actor/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "amazon-actor" +version = "0.1.0" +authors = ["Adrian Wozniak "] +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"] diff --git a/actors/amazon-actor/src/lib.rs b/actors/amazon-actor/src/lib.rs new file mode 100644 index 00000000..ec7af72b --- /dev/null +++ b/actors/amazon-actor/src/lib.rs @@ -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; +} + +#[derive(actix::Message)] +#[rtype(result = "Result")] +pub struct S3PutObject { + pub source: tokio::sync::broadcast::Receiver, + pub file_name: String, +} + +impl actix::Handler for AmazonExecutor { + type Result = Result; + + 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 = 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 + ) +} diff --git a/actors/highlight-actor/src/lib.rs b/actors/highlight-actor/src/lib.rs index b91a84d7..56bce415 100644 --- a/actors/highlight-actor/src/lib.rs +++ b/actors/highlight-actor/src/lib.rs @@ -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, HighlightError>")] +#[rtype(result = "Result")] pub struct HighlightCode { pub code: String, pub lang: String, } impl Handler for HighlightActor { - type Result = Result, HighlightError>; + type Result = Result; 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")] +pub struct TextHighlightCode { + pub code: String, + pub lang: String, +} + +impl Handler for HighlightActor { + type Result = Result; + + 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}{txt}", + 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 + ) + })) } } diff --git a/actors/web-actor/Cargo.toml b/actors/web-actor/Cargo.toml index dad732c4..40a897e1 100644 --- a/actors/web-actor/Cargo.toml +++ b/actors/web-actor/Cargo.toml @@ -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" diff --git a/actors/web-actor/src/avatar.rs b/actors/web-actor/src/avatar.rs index ffc2b8ae..1097573a 100644 --- a/actors/web-actor/src/avatar.rs +++ b/actors/web-actor/src/avatar.rs @@ -21,6 +21,7 @@ pub async fn upload( db: Data>, ws: Data>, fs: Data>, + amazon: Data>, ) -> Result { let mut user_id: Option = None; let mut avatar_url: Option = None; @@ -45,6 +46,7 @@ pub async fn upload( field, disposition, fs.clone(), + amazon.clone(), ) .await?, ); diff --git a/actors/web-actor/src/handlers/upload_avatar_image.rs b/actors/web-actor/src/handlers/upload_avatar_image.rs index 6208c3e8..f4a083b2 100644 --- a/actors/web-actor/src/handlers/upload_avatar_image.rs +++ b/actors/web-actor/src/handlers/upload_avatar_image.rs @@ -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>, + fs: Data>, + amazon: Data>, ) -> Result { 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>, + amazon: Data>, ) -> Result { 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>, + fs: Data>, ) -> Result { 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) { /// Stream bytes directly to AWS S3 Service #[cfg(feature = "aws-s3")] -async fn aws_s3(system_file_name: String, mut receiver: Receiver) -> Option { - let web_config = jirs_config::web::Configuration::read(); - let s3 = &web_config.s3; +async fn aws_s3( + system_file_name: String, + amazon: Data>, + receiver: Receiver, +) -> Option { + let s3 = jirs_config::amazon::config(); if !s3.active { return None; } - s3.set_variables(); - log::debug!("{:?}", s3); - let mut v: Vec = 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, ) -> Option { - 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, + } } diff --git a/actors/websocket-actor/Cargo.toml b/actors/websocket-actor/Cargo.toml index e24eb52b..f93066e9 100644 --- a/actors/websocket-actor/Cargo.toml +++ b/actors/websocket-actor/Cargo.toml @@ -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" diff --git a/actors/websocket-actor/src/handlers/hi.rs b/actors/websocket-actor/src/handlers/hi.rs new file mode 100644 index 00000000..14faf98d --- /dev/null +++ b/actors/websocket-actor/src/handlers/hi.rs @@ -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 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) + } + } + } +} diff --git a/actors/websocket-actor/src/handlers/invitations.rs b/actors/websocket-actor/src/handlers/invitations.rs index 42676cb4..9c024686 100644 --- a/actors/websocket-actor/src/handlers/invitations.rs +++ b/actors/websocket-actor/src/handlers/invitations.rs @@ -90,7 +90,7 @@ impl WsHandler for WebSocketActor { })) { self.addr.do_send(InnerMsg::SendToUser( message.receiver_id, - WsMsg::Message(message), + WsMsg::MessageUpdated(message), )); } diff --git a/actors/websocket-actor/src/handlers/issues.rs b/actors/websocket-actor/src/handlers/issues.rs index 961769f1..fdf1f8b4 100644 --- a/actors/websocket-actor/src/handlers/issues.rs +++ b/actors/websocket-actor/src/handlers/issues.rs @@ -50,7 +50,64 @@ impl WsHandler 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); diff --git a/actors/websocket-actor/src/handlers/mod.rs b/actors/websocket-actor/src/handlers/mod.rs index 1224f5e7..b5737e27 100644 --- a/actors/websocket-actor/src/handlers/mod.rs +++ b/actors/websocket-actor/src/handlers/mod.rs @@ -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; diff --git a/actors/websocket-actor/src/lib.rs b/actors/websocket-actor/src/lib.rs index 7192795f..a31bde46 100644 --- a/actors/websocket-actor/src/lib.rs +++ b/actors/websocket-actor/src/lib.rs @@ -34,6 +34,7 @@ struct WebSocketActor { db: Data>, mail: Data>, addr: Addr, + hi: Data>, current_user: Option, current_user_project: Option, current_project: Option, @@ -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>, mail: Data>, ws_server: Data>, + hi: Data>, ) -> Result { ws::start( WebSocketActor { db, mail, + hi, current_user: None, current_user_project: None, current_project: None, diff --git a/diesel.toml b/diesel.toml index ca5ae937..5b6fce59 100644 --- a/diesel.toml +++ b/diesel.toml @@ -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" diff --git a/jirs-client/Cargo.toml b/jirs-client/Cargo.toml index d22e1feb..355f6900 100644 --- a/jirs-client/Cargo.toml +++ b/jirs-client/Cargo.toml @@ -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 = "*" diff --git a/jirs-client/js/css/project.css b/jirs-client/js/css/project.css index 8fce9513..823d5e9c 100644 --- a/jirs-client/js/css/project.css +++ b/jirs-client/js/css/project.css @@ -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; } diff --git a/jirs-client/src/elements.rs b/jirs-client/src/elements.rs index 64ddf184..195b2718 100644 --- a/jirs-client/src/elements.rs +++ b/jirs-client/src/elements.rs @@ -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()) { diff --git a/jirs-client/src/images/mod.rs b/jirs-client/src/images/mod.rs new file mode 100644 index 00000000..d205e9ff --- /dev/null +++ b/jirs-client/src/images/mod.rs @@ -0,0 +1 @@ +pub mod project_avatar; diff --git a/jirs-client/src/images/project_avatar.rs b/jirs-client/src/images/project_avatar.rs new file mode 100644 index 00000000..7753608c --- /dev/null +++ b/jirs-client/src/images/project_avatar.rs @@ -0,0 +1,100 @@ +use { + crate::Msg, + seed::{prelude::*, *}, +}; + +#[inline(always)] +pub fn render() -> Node { + 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"]], + ] + ] + ] + ] +} diff --git a/jirs-client/src/invite.rs b/jirs-client/src/invite.rs deleted file mode 100644 index 0d081ab1..00000000 --- a/jirs-client/src/invite.rs +++ /dev/null @@ -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) { - 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 { - 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 { - let submit = StyledButton::build() - .text("Accept") - .primary() - .build() - .into_node(); - StyledField::build().input(submit).build().into_node() -} - -fn token_field(page: &InvitePage) -> Node { - 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() -} diff --git a/jirs-client/src/lib.rs b/jirs-client/src/lib.rs index 5357d775..3769e72e 100644 --- a/jirs-client/src/lib.rs +++ b/jirs-client/src/lib.rs @@ -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), } fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders) { @@ -139,8 +164,10 @@ fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders) { 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) { 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) { }, _ => (), } - 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 { 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 { - 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); + 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) -> AfterMount { +fn init(url: Url, orders: &mut impl Orders) -> 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) -> AfterMount { } 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> { - 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] diff --git a/jirs-client/src/modal/issues.rs b/jirs-client/src/modal/issues.rs index 2a98cc78..8db167b8 100644 --- a/jirs-client/src/modal/issues.rs +++ b/jirs-client/src/modal/issues.rs @@ -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(model: &Model, modal: &Modal, field_id: FieldId) -> Option> where Modal: IssueModal, diff --git a/jirs-client/src/modal/issues/issue_details.rs b/jirs-client/src/modal/issues/issue_details.rs deleted file mode 100644 index de75644f..00000000 --- a/jirs-client/src/modal/issues/issue_details.rs +++ /dev/null @@ -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) { - 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 { - 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 { - 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::() - .unwrap() - .clone(), - _ => return None as Option, - }; - 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 { - 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> = 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> { - 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> { - 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 { - 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, - ] -} diff --git a/jirs-client/src/modal/mod.rs b/jirs-client/src/modal/mod.rs index f627ca78..3da646c3 100644 --- a/jirs-client/src/modal/mod.rs +++ b/jirs-client/src/modal/mod.rs @@ -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::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 #[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 { + use crate::modals::{issue_statuses_delete, issues_create, issues_edit}; let modals: Vec> = 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 { } } 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 { } 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 Node { .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 { ); 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 { modal_title, tracking, inputs, - div![class!["actions"], close], + div![C!["actions"], close], ]) .width(400) .build() diff --git a/jirs-client/src/users/mod.rs b/jirs-client/src/modals/issue_statuses_delete/mod.rs similarity index 67% rename from jirs-client/src/users/mod.rs rename to jirs-client/src/modals/issue_statuses_delete/mod.rs index d42c7ecd..bc6b2959 100644 --- a/jirs-client/src/users/mod.rs +++ b/jirs-client/src/modals/issue_statuses_delete/mod.rs @@ -1,5 +1,7 @@ +pub use model::*; pub use update::*; pub use view::*; +mod model; mod update; mod view; diff --git a/jirs-client/src/modals/issue_statuses_delete/model.rs b/jirs-client/src/modals/issue_statuses_delete/model.rs new file mode 100644 index 00000000..ef3d1906 --- /dev/null +++ b/jirs-client/src/modals/issue_statuses_delete/model.rs @@ -0,0 +1,16 @@ +use jirs_data::IssueStatusId; + +#[derive(Clone, Debug, PartialOrd, PartialEq)] +pub struct Model { + pub delete_id: IssueStatusId, + pub receiver_id: Option, +} + +impl Model { + pub fn new(delete_id: IssueStatusId) -> Self { + Self { + delete_id, + receiver_id: None, + } + } +} diff --git a/jirs-client/src/modal/delete_issue_status.rs b/jirs-client/src/modals/issue_statuses_delete/update.rs similarity index 55% rename from jirs-client/src/modal/delete_issue_status.rs rename to jirs-client/src/modals/issue_statuses_delete/update.rs index 357e2711..8ab85fd6 100644 --- a/jirs-client/src/modal/delete_issue_status.rs +++ b/jirs-client/src/modals/issue_statuses_delete/update.rs @@ -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) { let _modal: &mut Box = @@ -34,16 +35,3 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders) { _ => (), }; } - -pub fn view(_model: &model::Model, issue_status_id: IssueStatusId) -> Node { - 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() -} diff --git a/jirs-client/src/modals/issue_statuses_delete/view.rs b/jirs-client/src/modals/issue_statuses_delete/view.rs new file mode 100644 index 00000000..a54121ce --- /dev/null +++ b/jirs-client/src/modals/issue_statuses_delete/view.rs @@ -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 { + 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() +} diff --git a/jirs-client/src/project/mod.rs b/jirs-client/src/modals/issues_create/mod.rs similarity index 67% rename from jirs-client/src/project/mod.rs rename to jirs-client/src/modals/issues_create/mod.rs index d42c7ecd..bc6b2959 100644 --- a/jirs-client/src/project/mod.rs +++ b/jirs-client/src/modals/issues_create/mod.rs @@ -1,5 +1,7 @@ +pub use model::*; pub use update::*; pub use view::*; +mod model; mod update; mod view; diff --git a/jirs-client/src/modals/issues_create/model.rs b/jirs-client/src/modals/issues_create/model.rs new file mode 100644 index 00000000..5190042c --- /dev/null +++ b/jirs-client/src/modals/issues_create/model.rs @@ -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 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, + pub description_text: Option, + pub estimate: Option, + pub time_spent: Option, + pub time_remaining: Option, + pub project_id: Option, + pub user_ids: Vec, + pub reporter_id: Option, + pub issue_status_id: jirs_data::IssueStatusId, + pub epic_id: Option, + + // 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 { + 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) { + 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); + } +} diff --git a/jirs-client/src/modals/issues_create/update.rs b/jirs-client/src/modals/issues_create/update.rs new file mode 100644 index 00000000..b083be91 --- /dev/null +++ b/jirs-client/src/modals/issues_create/update.rs @@ -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) { + 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 = 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(); + } + + _ => (), + } +} diff --git a/jirs-client/src/modal/issues/add_issue.rs b/jirs-client/src/modals/issues_create/view.rs similarity index 53% rename from jirs-client/src/modal/issues/add_issue.rs rename to jirs-client/src/modals/issues_create/view.rs index 68476af0..4caa033b 100644 --- a/jirs-client/src/modal/issues/add_issue.rs +++ b/jirs-client/src/modals/issues_create/view.rs @@ -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 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) { - 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 = 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 { +pub fn view(model: &Model, modal: &Box) -> Node { let issue_type = modal .type_state .values @@ -270,8 +64,11 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node { 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 { 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 { +fn issue_type_field(modal: &Box) -> Node { let select_type = StyledSelect::build() .name("type") .normal() @@ -351,7 +148,7 @@ fn issue_type_field(modal: &AddIssueModal) -> Node { .into_node() } -fn short_summary_field(modal: &AddIssueModal) -> Node { +fn short_summary_field(modal: &Box) -> Node { let short_summary = StyledInput::build() .state(&modal.title_state) .build(FieldId::AddIssueModal(IssueFieldId::Title)) diff --git a/jirs-client/src/modals/issues_edit/mod.rs b/jirs-client/src/modals/issues_edit/mod.rs new file mode 100644 index 00000000..bc6b2959 --- /dev/null +++ b/jirs-client/src/modals/issues_edit/mod.rs @@ -0,0 +1,7 @@ +pub use model::*; +pub use update::*; +pub use view::*; + +mod model; +mod update; +mod view; diff --git a/jirs-client/src/modals/issues_edit/model.rs b/jirs-client/src/modals/issues_edit/model.rs new file mode 100644 index 00000000..821f0fbd --- /dev/null +++ b/jirs-client/src/modals/issues_edit/model.rs @@ -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 { + 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) { + 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); + } +} diff --git a/jirs-client/src/modals/issues_edit/update.rs b/jirs-client/src/modals/issues_edit/update.rs new file mode 100644 index 00000000..a18bb11a --- /dev/null +++ b/jirs-client/src/modals/issues_edit/update.rs @@ -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) { + 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, + ))); + } + + _ => (), + } +} diff --git a/jirs-client/src/modals/issues_edit/view/comments.rs b/jirs-client/src/modals/issues_edit/view/comments.rs new file mode 100644 index 00000000..457ea47f --- /dev/null +++ b/jirs-client/src/modals/issues_edit/view/comments.rs @@ -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> { + 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> { + 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) +} diff --git a/jirs-client/src/modals/issues_edit/view/mod.rs b/jirs-client/src/modals/issues_edit/view/mod.rs new file mode 100644 index 00000000..a52330b1 --- /dev/null +++ b/jirs-client/src/modals/issues_edit/view/mod.rs @@ -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 { + 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 { + 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::() + .unwrap() + .clone(), + _ => return None as Option, + }; + 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 { + 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> = 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 { + 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, + ] +} diff --git a/jirs-client/src/modals/mod.rs b/jirs-client/src/modals/mod.rs new file mode 100644 index 00000000..3384bb02 --- /dev/null +++ b/jirs-client/src/modals/mod.rs @@ -0,0 +1,3 @@ +pub mod issue_statuses_delete; +pub mod issues_create; +pub mod issues_edit; diff --git a/jirs-client/src/model.rs b/jirs-client/src/model.rs index 54ea02af..03f2ff6b 100644 --- a/jirs-client/src/model.rs +++ b/jirs-client/src/model.rs @@ -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; + fn epic_state(&self) -> &StyledSelectState; fn update_states(&mut self, msg: &Msg, orders: &mut impl Orders); @@ -28,12 +26,12 @@ pub trait IssueModal { #[derive(Clone, Debug, PartialOrd, PartialEq)] pub enum ModalType { - AddIssue(Box), - EditIssue(IssueId, Box), + AddIssue(Box), + EditIssue(IssueId, Box), DeleteIssueConfirm(IssueId), DeleteCommentConfirm(CommentId), TimeTracking(IssueId), - DeleteIssueStatusModal(Box), + DeleteIssueStatusModal(Box), #[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, -} - -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 { - 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) { - 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, - pub description_text: Option, - pub estimate: Option, - pub time_spent: Option, - pub time_remaining: Option, - pub project_id: Option, - pub user_ids: Vec, - pub reporter_id: Option, - pub issue_status_id: jirs_data::IssueStatusId, - pub epic_id: Option, - - // 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 { - 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) { - 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, - 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, -} - -#[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, - 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, - pub error: String, - pub form_state: InvitationFormState, - - pub invited_users: Vec, - pub invitations: Vec, -} - -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) -> 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, - pub hovered_day: Option, - 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), @@ -591,12 +143,22 @@ pub struct Model { pub page_content: PageContent, pub project: Option, - pub user: Option, + pub current_user_project: Option, + pub issues: Vec, + pub issues_by_id: HashMap, + + pub user: Option, pub users: Vec, + pub users_by_id: HashMap, + pub comments: Vec, + pub issue_statuses: Vec, + pub issue_statuses_by_id: HashMap, + pub issue_statuses_by_name: HashMap, + pub messages: Vec, pub user_projects: Vec, pub projects: Vec, @@ -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() - // } } diff --git a/jirs-client/src/pages/invite_page/mod.rs b/jirs-client/src/pages/invite_page/mod.rs new file mode 100644 index 00000000..bc6b2959 --- /dev/null +++ b/jirs-client/src/pages/invite_page/mod.rs @@ -0,0 +1,7 @@ +pub use model::*; +pub use update::*; +pub use view::*; + +mod model; +mod update; +mod view; diff --git a/jirs-client/src/pages/invite_page/model.rs b/jirs-client/src/pages/invite_page/model.rs new file mode 100644 index 00000000..9e683a02 --- /dev/null +++ b/jirs-client/src/pages/invite_page/model.rs @@ -0,0 +1,6 @@ +#[derive(Debug, Default)] +pub struct InvitePage { + pub token: String, + pub token_touched: bool, + pub error: Option, +} diff --git a/jirs-client/src/pages/invite_page/update.rs b/jirs-client/src/pages/invite_page/update.rs new file mode 100644 index 00000000..a9b56758 --- /dev/null +++ b/jirs-client/src/pages/invite_page/update.rs @@ -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) { + 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)); +} diff --git a/jirs-client/src/pages/invite_page/view.rs b/jirs-client/src/pages/invite_page/view.rs new file mode 100644 index 00000000..436298a9 --- /dev/null +++ b/jirs-client/src/pages/invite_page/view.rs @@ -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 { + 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 { + let submit = StyledButton::build() + .text("Accept") + .primary() + .build() + .into_node(); + StyledField::build().input(submit).build().into_node() +} + +fn token_field(page: &InvitePage) -> Node { + 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() +} diff --git a/jirs-client/src/pages/mod.rs b/jirs-client/src/pages/mod.rs new file mode 100644 index 00000000..69545856 --- /dev/null +++ b/jirs-client/src/pages/mod.rs @@ -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; diff --git a/jirs-client/src/pages/profile_page/mod.rs b/jirs-client/src/pages/profile_page/mod.rs new file mode 100644 index 00000000..334568ff --- /dev/null +++ b/jirs-client/src/pages/profile_page/mod.rs @@ -0,0 +1,6 @@ +pub use update::*; +pub use view::*; + +pub mod model; +pub mod update; +pub mod view; diff --git a/jirs-client/src/pages/profile_page/model.rs b/jirs-client/src/pages/profile_page/model.rs new file mode 100644 index 00000000..dc89bfab --- /dev/null +++ b/jirs-client/src/pages/profile_page/model.rs @@ -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) -> 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(), + ), + } + } +} diff --git a/jirs-client/src/profile/update.rs b/jirs-client/src/pages/profile_page/update.rs similarity index 78% rename from jirs-client/src/profile/update.rs rename to jirs-client/src/pages/profile_page/update.rs index 09668d0d..6974b314 100644 --- a/jirs-client/src/profile/update.rs +++ b/jirs-client/src/pages/profile_page/update.rs @@ -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) { 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); } } } diff --git a/jirs-client/src/profile/view.rs b/jirs-client/src/pages/profile_page/view.rs similarity index 85% rename from jirs-client/src/profile/view.rs rename to jirs-client/src/pages/profile_page/view.rs index e5d5ee73..63e6b4f9 100644 --- a/jirs-client/src/profile/view.rs +++ b/jirs-client/src/pages/profile_page/view.rs @@ -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 { let page = match &model.page_content { @@ -122,7 +122,7 @@ fn build_current_project(model: &Model, page: &ProfilePage) -> Node { }; StyledField::build() .label("Current project") - .input(div![class!["project-name"], inner]) + .input(div![C!["project-name"], inner]) .build() .into_node() } diff --git a/jirs-client/src/pages/project_page/mod.rs b/jirs-client/src/pages/project_page/mod.rs new file mode 100644 index 00000000..334568ff --- /dev/null +++ b/jirs-client/src/pages/project_page/mod.rs @@ -0,0 +1,6 @@ +pub use update::*; +pub use view::*; + +pub mod model; +pub mod update; +pub mod view; diff --git a/jirs-client/src/pages/project_page/model.rs b/jirs-client/src/pages/project_page/model.rs new file mode 100644 index 00000000..c7a45db2 --- /dev/null +++ b/jirs-client/src/pages/project_page/model.rs @@ -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, +} + +#[derive(Default, Debug)] +pub struct EpicIssuePerStatus { + pub epic_name: EpicName, + pub per_status_issues: Vec, +} + +// pub type VisibleIssueMap = +// HashMap>>; + +#[derive(Debug, Default)] +pub struct ProjectPage { + pub text_filter: String, + pub active_avatar_filters: Vec, + pub only_my_filter: bool, + pub recently_updated_filter: bool, + pub issue_drag: DragState, + pub visible_issues: Vec, +} + +impl ProjectPage { + pub fn rebuild_visible( + &mut self, + epics: &Vec, + statuses: &Vec, + issues: &Vec, + user: &Option, + ) { + 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) -> 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) +// } diff --git a/jirs-client/src/project/update.rs b/jirs-client/src/pages/project_page/update.rs similarity index 77% rename from jirs-client/src/project/update.rs rename to jirs-client/src/pages/project_page/update.rs index 84042cff..0589f43a 100644 --- a/jirs-client/src/project/update.rs +++ b/jirs-client/src/pages/project_page/update.rs @@ -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) { 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 = 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 = 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)), diff --git a/jirs-client/src/project/view.rs b/jirs-client/src/pages/project_page/view.rs similarity index 54% rename from jirs-client/src/project/view.rs rename to jirs-client/src/pages/project_page/view.rs index 90deab27..f1125221 100644 --- a/jirs-client/src/project/view.rs +++ b/jirs-client/src/pages/project_page/view.rs @@ -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 { let project_section = vec![ @@ -29,11 +33,11 @@ fn breadcrumbs(model: &Model) -> Node { .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 { .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 { { 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 { }) .collect(); - div![id!["avatars"], class!["filterChild"], avatars] + div![id!["avatars"], C!["filterChild"], avatars] } fn project_board_lists(model: &Model) -> Node { - let mut rows: Vec> = vec![None]; - for epic in model.epics.iter() { - rows.push(Some(epic)); - } - let rows: Vec> = rows - .into_iter() - .map(|epic| { - let title = epic - .map(|epic| div![C!["rowName"], epic.name.as_str()]) - .unwrap_or_else(|| empty![]); - let columns: Vec> = 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> = 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 { - let project_page = match &model.page_content { - PageContent::Project(project_page) => project_page, - _ => return empty![], - }; - let ids: Vec = 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> = model - .issues + let issues: Vec> = 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) -> 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 { - let avatars: Vec> = model - .users + let avatars: Vec> = 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> = 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 { 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,], ] ] ] diff --git a/jirs-client/src/pages/project_settings_page/mod.rs b/jirs-client/src/pages/project_settings_page/mod.rs new file mode 100644 index 00000000..bc6b2959 --- /dev/null +++ b/jirs-client/src/pages/project_settings_page/mod.rs @@ -0,0 +1,7 @@ +pub use model::*; +pub use update::*; +pub use view::*; + +mod model; +mod update; +mod view; diff --git a/jirs-client/src/pages/project_settings_page/model.rs b/jirs-client/src/pages/project_settings_page/model.rs new file mode 100644 index 00000000..50b2f248 --- /dev/null +++ b/jirs-client/src/pages/project_settings_page/model.rs @@ -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, + 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; + } +} diff --git a/jirs-client/src/project_settings/time_tracking_fibonacci.txt b/jirs-client/src/pages/project_settings_page/time_tracking_fibonacci.txt similarity index 100% rename from jirs-client/src/project_settings/time_tracking_fibonacci.txt rename to jirs-client/src/pages/project_settings_page/time_tracking_fibonacci.txt diff --git a/jirs-client/src/project_settings/time_tracking_hourly.txt b/jirs-client/src/pages/project_settings_page/time_tracking_hourly.txt similarity index 100% rename from jirs-client/src/project_settings/time_tracking_hourly.txt rename to jirs-client/src/pages/project_settings_page/time_tracking_hourly.txt diff --git a/jirs-client/src/project_settings/update.rs b/jirs-client/src/pages/project_settings_page/update.rs similarity index 94% rename from jirs-client/src/project_settings/update.rs rename to jirs-client/src/pages/project_settings_page/update.rs index 0c059ca2..89aa7d3a 100644 --- a/jirs-client/src/project_settings/update.rs +++ b/jirs-client/src/pages/project_settings_page/update.rs @@ -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) { if model.page != Page::ProjectSettings { diff --git a/jirs-client/src/project_settings/view.rs b/jirs-client/src/pages/project_settings_page/view.rs similarity index 89% rename from jirs-client/src/project_settings/view.rs rename to jirs-client/src/pages/project_settings_page/view.rs index 8d951aeb..68b54cf5 100644 --- a/jirs-client/src/project_settings/view.rs +++ b/jirs-client/src/pages/project_settings_page/view.rs @@ -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 { .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 { .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 { .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 diff --git a/jirs-client/src/pages/reports_page/mod.rs b/jirs-client/src/pages/reports_page/mod.rs new file mode 100644 index 00000000..334568ff --- /dev/null +++ b/jirs-client/src/pages/reports_page/mod.rs @@ -0,0 +1,6 @@ +pub use update::*; +pub use view::*; + +pub mod model; +pub mod update; +pub mod view; diff --git a/jirs-client/src/pages/reports_page/model.rs b/jirs-client/src/pages/reports_page/model.rs new file mode 100644 index 00000000..82055f3d --- /dev/null +++ b/jirs-client/src/pages/reports_page/model.rs @@ -0,0 +1,25 @@ +use chrono::{prelude::*, NaiveDate}; + +#[derive(Debug)] +pub struct ReportsPage { + pub selected_day: Option, + pub hovered_day: Option, + 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, + } + } +} diff --git a/jirs-client/src/reports/update.rs b/jirs-client/src/pages/reports_page/update.rs similarity index 83% rename from jirs-client/src/reports/update.rs rename to jirs-client/src/pages/reports_page/update.rs index 64fcc18b..66dee9d9 100644 --- a/jirs-client/src/reports/update.rs +++ b/jirs-client/src/pages/reports_page/update.rs @@ -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) { if let Msg::ChangePage(Page::Reports) = msg { diff --git a/jirs-client/src/reports/view.rs b/jirs-client/src/pages/reports_page/view.rs similarity index 89% rename from jirs-client/src/reports/view.rs rename to jirs-client/src/pages/reports_page/view.rs index 9fc27459..bac2056f 100644 --- a/jirs-client/src/reports/view.rs +++ b/jirs-client/src/pages/reports_page/view.rs @@ -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 { 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 SVG_HEIGHT, At::Width => SVG_WIDTH], svg_parts, @@ -173,21 +175,21 @@ fn issue_list(page: &ReportsPage, this_month_updated: &[&Issue]) -> Node { .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 ] } diff --git a/jirs-client/src/pages/sign_in_page/mod.rs b/jirs-client/src/pages/sign_in_page/mod.rs new file mode 100644 index 00000000..334568ff --- /dev/null +++ b/jirs-client/src/pages/sign_in_page/mod.rs @@ -0,0 +1,6 @@ +pub use update::*; +pub use view::*; + +pub mod model; +pub mod update; +pub mod view; diff --git a/jirs-client/src/pages/sign_in_page/model.rs b/jirs-client/src/pages/sign_in_page/model.rs new file mode 100644 index 00000000..f43e8c63 --- /dev/null +++ b/jirs-client/src/pages/sign_in_page/model.rs @@ -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, +} diff --git a/jirs-client/src/pages/sign_in_page/update.rs b/jirs-client/src/pages/sign_in_page/update.rs new file mode 100644 index 00000000..ae91d063 --- /dev/null +++ b/jirs-client/src/pages/sign_in_page/update.rs @@ -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) { + 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())); +} diff --git a/jirs-client/src/sign_in.rs b/jirs-client/src/pages/sign_in_page/view.rs similarity index 53% rename from jirs-client/src/sign_in.rs rename to jirs-client/src/pages/sign_in_page/view.rs index 8ae7da67..76662921 100644 --- a/jirs-client/src/sign_in.rs +++ b/jirs-client/src/pages/sign_in_page/view.rs @@ -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) { - 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 { let page = match &model.page_content { @@ -133,7 +63,7 @@ pub fn view(model: &model::Model) -> Node { .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 { .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?"] diff --git a/jirs-client/src/pages/sign_up_page/mod.rs b/jirs-client/src/pages/sign_up_page/mod.rs new file mode 100644 index 00000000..334568ff --- /dev/null +++ b/jirs-client/src/pages/sign_up_page/mod.rs @@ -0,0 +1,6 @@ +pub use update::*; +pub use view::*; + +pub mod model; +pub mod update; +pub mod view; diff --git a/jirs-client/src/pages/sign_up_page/model.rs b/jirs-client/src/pages/sign_up_page/model.rs new file mode 100644 index 00000000..10b5d683 --- /dev/null +++ b/jirs-client/src/pages/sign_up_page/model.rs @@ -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, +} diff --git a/jirs-client/src/pages/sign_up_page/update.rs b/jirs-client/src/pages/sign_up_page/update.rs new file mode 100644 index 00000000..f747fbef --- /dev/null +++ b/jirs-client/src/pages/sign_up_page/update.rs @@ -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) { + 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())); +} diff --git a/jirs-client/src/sign_up.rs b/jirs-client/src/pages/sign_up_page/view.rs similarity index 54% rename from jirs-client/src/sign_up.rs rename to jirs-client/src/pages/sign_up_page/view.rs index c863a8e4..a13a5554 100644 --- a/jirs-client/src/sign_up.rs +++ b/jirs-client/src/pages/sign_up_page/view.rs @@ -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) { - 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 { let page = match &model.page_content { @@ -111,7 +67,7 @@ pub fn view(model: &model::Model) -> Node { .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 { .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 { 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() diff --git a/jirs-client/src/pages/users_page/mod.rs b/jirs-client/src/pages/users_page/mod.rs new file mode 100644 index 00000000..334568ff --- /dev/null +++ b/jirs-client/src/pages/users_page/mod.rs @@ -0,0 +1,6 @@ +pub use update::*; +pub use view::*; + +pub mod model; +pub mod update; +pub mod view; diff --git a/jirs-client/src/pages/users_page/model.rs b/jirs-client/src/pages/users_page/model.rs new file mode 100644 index 00000000..a08a8632 --- /dev/null +++ b/jirs-client/src/pages/users_page/model.rs @@ -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, + pub error: String, + pub form_state: InvitationFormState, + + pub invited_users: Vec, + pub invitations: Vec, +} + +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![], + } + } +} diff --git a/jirs-client/src/users/update.rs b/jirs-client/src/pages/users_page/update.rs similarity index 94% rename from jirs-client/src/users/update.rs rename to jirs-client/src/pages/users_page/update.rs index 3e312d75..1d2911f5 100644 --- a/jirs-client/src/users/update.rs +++ b/jirs-client/src/pages/users_page/update.rs @@ -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) { if let Msg::ChangePage(Page::Users) = msg { diff --git a/jirs-client/src/users/view.rs b/jirs-client/src/pages/users_page/view.rs similarity index 83% rename from jirs-client/src/users/view.rs rename to jirs-client/src/pages/users_page/view.rs index 71de1417..ecf23c42 100644 --- a/jirs-client/src/users/view.rs +++ b/jirs-client/src/pages/users_page/view.rs @@ -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 { if model.user.is_none() { @@ -79,11 +80,11 @@ pub fn view(model: &Model) -> Node { .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 { .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 { .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> = page @@ -147,7 +148,7 @@ pub fn view(model: &Model) -> Node { .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 { .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( diff --git a/jirs-client/src/profile/mod.rs b/jirs-client/src/profile/mod.rs deleted file mode 100644 index ba284010..00000000 --- a/jirs-client/src/profile/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub use update::update; -pub use view::view; - -mod update; -mod view; diff --git a/jirs-client/src/project_settings/mod.rs b/jirs-client/src/project_settings/mod.rs deleted file mode 100644 index ba284010..00000000 --- a/jirs-client/src/project_settings/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub use update::update; -pub use view::view; - -mod update; -mod view; diff --git a/jirs-client/src/reports/mod.rs b/jirs-client/src/reports/mod.rs deleted file mode 100644 index ba284010..00000000 --- a/jirs-client/src/reports/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub use update::update; -pub use view::view; - -mod update; -mod view; diff --git a/jirs-client/src/shared/aside.rs b/jirs-client/src/shared/aside.rs index b56039b3..efd8febb 100644 --- a/jirs-client/src/shared/aside.rs +++ b/jirs-client/src/shared/aside.rs @@ -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) { - 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) { model.ws.as_ref(), orders, ); + orders.skip(); } } pub fn render(model: &Model) -> Node { - 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 { + 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 { + 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) -> Node { 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], ] ] } diff --git a/jirs-client/src/shared/mod.rs b/jirs-client/src/shared/mod.rs index cdaf22ce..4a55ca29 100644 --- a/jirs-client/src/shared/mod.rs +++ b/jirs-client/src/shared/mod.rs @@ -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) { - 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) { - 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) { + 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 { - div![class!["divider"], ""] + div![C!["divider"], ""] } pub fn inner_layout(model: &Model, page_name: &str, children: Vec>) -> Node { 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>) -> pub fn outer_layout(model: &Model, page_name: &str, children: Vec>) -> Node { let modal = crate::modal::view(model); article![ - class!["outer-layout", "outerPage"], + C!["outer-layout", "outerPage"], id![page_name], modal, children diff --git a/jirs-client/src/shared/navbar_left.rs b/jirs-client/src/shared/navbar_left.rs index 420c23a6..72dcb85f 100644 --- a/jirs-client/src/shared/navbar_left.rs +++ b/jirs-client/src/shared/navbar_left.rs @@ -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; @@ -44,7 +49,7 @@ pub fn render(model: &Model) -> Vec> { 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> { 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> { 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> { ] } +#[inline] fn navbar_left_item( 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) -> Node { let on_click: EventHandler = 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 { @@ -140,7 +151,7 @@ fn messages_tooltip_popup(model: &Model) -> Node { } }; } - 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> { } 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> { .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> { .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 { }); 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 { .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(" "); diff --git a/jirs-client/src/shared/styled_avatar.rs b/jirs-client/src/shared/styled_avatar.rs index fbe0c506..78c22910 100644 --- a/jirs-client/src/shared/styled_avatar.rs +++ b/jirs-client/src/shared/styled_avatar.rs @@ -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 { 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) -> 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 { 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 = class_list.into_iter().map(|s| class![s]).collect(); let letter = name .chars() .rev() @@ -130,11 +138,10 @@ pub fn render(values: StyledAvatar) -> Node { 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 { 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, ] } } diff --git a/jirs-client/src/shared/styled_button.rs b/jirs-client/src/shared/styled_button.rs index 64a0af61..031867ca 100644 --- a/jirs-client/src/shared/styled_button.rs +++ b/jirs-client/src/shared/styled_button.rs @@ -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(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) -> Self { self.on_click = Some(value); self } + #[inline(always)] pub fn children(mut self, value: Vec>) -> 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 { render(self) } } +#[inline(always)] pub fn render(values: StyledButton) -> Node { let StyledButton { text, @@ -187,18 +205,21 @@ pub fn render(values: StyledButton) -> Node { _ => 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 = 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, diff --git a/jirs-client/src/shared/styled_checkbox.rs b/jirs-client/src/shared/styled_checkbox.rs index c8905850..92ec3935 100644 --- a/jirs-client/src/shared/styled_checkbox.rs +++ b/jirs-client/src/shared/styled_checkbox.rs @@ -189,7 +189,7 @@ fn render(values: StyledCheckbox) -> Node { .collect(); div![ - class!["styledCheckbox"], + C!["styledCheckbox"], attrs![At::Class => class_list.join(" ")], opt, ] diff --git a/jirs-client/src/shared/styled_editor.rs b/jirs-client/src/shared/styled_editor.rs index 90472889..e5d157d9 100644 --- a/jirs-client/src/shared/styled_editor.rs +++ b/jirs-client/src/shared/styled_editor.rs @@ -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, -} - -impl StyledEditorBuilder { - pub fn text(mut self, text: S) -> Self - where - S: Into, - { - 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 { - render(self) - } -} - -pub fn render(values: StyledEditor) -> Node { - 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>(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, +} + +impl StyledEditorBuilder { + pub fn text(mut self, text: S) -> Self + where + S: Into, + { + self.text = text.into(); + self + } + + pub fn initial_text(mut self, text: S) -> Self + where + S: Into, + { + self.initial_text = text.into(); + self + } + + pub fn html(mut self, text: S) -> Self + where + S: Into, + { + 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 { + render(self) + } +} + +pub fn render(values: StyledEditor) -> Node { + 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 { + mouse_ev(Ev::Click, move |ev| { + ev.stop_propagation(); + Msg::ModalChanged(FieldChange::TabChanged(field_id, new_mode)) + }) +} diff --git a/jirs-client/src/shared/styled_form.rs b/jirs-client/src/shared/styled_form.rs index 968412ec..894a78cd 100644 --- a/jirs-client/src/shared/styled_form.rs +++ b/jirs-client/src/shared/styled_form.rs @@ -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>, - on_submit: Option>, -} - -impl<'l> StyledForm<'l> { - pub fn build() -> StyledFormBuilder<'l> { - StyledFormBuilder::default() - } -} - -impl<'l> ToNode for StyledForm<'l> { - fn into_node(self) -> Node { - render(self) - } -} - -#[derive(Debug, Default)] -pub struct StyledFormBuilder<'l> { - fields: Vec>, - heading: &'l str, - on_submit: Option>, -} - -impl<'l> StyledFormBuilder<'l> { - pub fn add_field(mut self, node: Node) -> Self { - self.fields.push(node); - self - } - - pub fn try_field(mut self, node: Option>) -> 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) -> 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 { - 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>, + on_submit: Option>, +} + +impl<'l> StyledForm<'l> { + pub fn build() -> StyledFormBuilder<'l> { + StyledFormBuilder::default() + } +} + +impl<'l> ToNode for StyledForm<'l> { + fn into_node(self) -> Node { + render(self) + } +} + +#[derive(Debug, Default)] +pub struct StyledFormBuilder<'l> { + fields: Vec>, + heading: &'l str, + on_submit: Option>, +} + +impl<'l> StyledFormBuilder<'l> { + pub fn add_field(mut self, node: Node) -> Self { + self.fields.push(node); + self + } + + pub fn try_field(mut self, node: Option>) -> 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) -> 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 { + 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], + ] +} diff --git a/jirs-client/src/shared/styled_icon.rs b/jirs-client/src/shared/styled_icon.rs index bbf57109..5ed7f574 100644 --- a/jirs-client/src/shared/styled_icon.rs +++ b/jirs-client/src/shared/styled_icon.rs @@ -346,30 +346,33 @@ pub fn render(values: StyledIcon) -> Node { }), ] .into_iter() - .filter(Option::is_some) - .map(|o| o.unwrap()) + .filter_map(|o| o) .collect(); let class_list: Vec = 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::>() - .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, diff --git a/jirs-client/src/shared/styled_image_input.rs b/jirs-client/src/shared/styled_image_input.rs index 970cca0b..0e34d3f5 100644 --- a/jirs-client/src/shared/styled_image_input.rs +++ b/jirs-client/src/shared/styled_image_input.rs @@ -104,19 +104,15 @@ fn render(values: StyledImageInput) -> Node { 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 ] diff --git a/jirs-client/src/shared/styled_input.rs b/jirs-client/src/shared/styled_input.rs index 56f7208b..e7b6ecbe 100644 --- a/jirs-client/src/shared/styled_input.rs +++ b/jirs-client/src/shared/styled_input.rs @@ -223,18 +223,14 @@ pub fn render(values: StyledInput) -> Node { Msg::StrInputChanged(field_id, value) }) }; - let on_keyup = { - ev(Ev::KeyUp, move |event| { - event.stop_propagation(); - None as Option - }) - }; - let on_click = { - ev(Ev::Click, move |event| { - event.stop_propagation(); - None as Option - }) - }; + let on_keyup = ev(Ev::KeyUp, move |event| { + event.stop_propagation(); + None as Option + }); + let on_click = ev(Ev::Click, move |event| { + event.stop_propagation(); + None as Option + }); div![ C!["styledInput"], @@ -251,7 +247,7 @@ pub fn render(values: StyledInput) -> Node { 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 { diff --git a/jirs-client/src/shared/styled_link.rs b/jirs-client/src/shared/styled_link.rs index ad6bb1b4..a44d8dd1 100644 --- a/jirs-client/src/shared/styled_link.rs +++ b/jirs-client/src/shared/styled_link.rs @@ -65,7 +65,7 @@ pub fn render(values: StyledLink) -> Node { } = values; a![ - class!["styledLink"], + C!["styledLink"], attrs![ At::Class => class_list.join(" "), At::Href => href, diff --git a/jirs-client/src/shared/styled_modal.rs b/jirs-client/src/shared/styled_modal.rs index aa8b9327..765c5b0d 100644 --- a/jirs-client/src/shared/styled_modal.rs +++ b/jirs-client/src/shared/styled_modal.rs @@ -116,9 +116,14 @@ pub fn render(values: StyledModal) -> Node { 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 }); diff --git a/jirs-client/src/shared/styled_rte.rs b/jirs-client/src/shared/styled_rte.rs index 234910e2..7db3a092 100644 --- a/jirs-client/src/shared/styled_rte.rs +++ b/jirs-client/src/shared/styled_rte.rs @@ -682,7 +682,7 @@ fn first_row(click_handler: EventHandler) -> Node { 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) -> Node { }), );*/ div![ - class!["group system"], + C!["group system"], // clip_board_button, // copy_button, // cut_button, @@ -777,7 +777,7 @@ fn first_row(click_handler: EventHandler) -> Node { ); div![ - class!["group formatting"], + C!["group formatting"], bold_button, italic_button, underline_button, @@ -788,7 +788,7 @@ fn first_row(click_handler: EventHandler) -> Node { ] }; - 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] } diff --git a/jirs-client/src/shared/styled_select_child.rs b/jirs-client/src/shared/styled_select_child.rs index df07d20a..bc32b8e8 100644 --- a/jirs-client/src/shared/styled_select_child.rs +++ b/jirs-client/src/shared/styled_select_child.rs @@ -154,15 +154,15 @@ pub fn render(values: StyledSelectChild) -> Node { 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 diff --git a/jirs-client/src/shared/styled_textarea.rs b/jirs-client/src/shared/styled_textarea.rs index 0626703b..63801ea7 100644 --- a/jirs-client/src/shared/styled_textarea.rs +++ b/jirs-client/src/shared/styled_textarea.rs @@ -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 { 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, diff --git a/jirs-client/src/shared/tracking_widget.rs b/jirs-client/src/shared/tracking_widget.rs index 3e27ed29..c04f406a 100644 --- a/jirs-client/src/shared/tracking_widget.rs +++ b/jirs-client/src/shared/tracking_widget.rs @@ -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 { vec![0, 1, 2, 3, 5, 8, 13, 21, 34, 55] } -pub fn tracking_link(model: &Model, modal: &EditIssueModal) -> Node { - let EditIssueModal { id, .. } = modal; +pub fn tracking_link(model: &Model, modal: &crate::modals::issues_edit::Model) -> Node { + 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::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 { @@ -71,18 +72,18 @@ pub fn tracking_widget(model: &Model, modal: &EditIssueModal) -> Node { let remaining_node: Node = 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,] ] ] } diff --git a/jirs-client/src/ws/init_load_sets.rs b/jirs-client/src/ws/init_load_sets.rs index 033e0689..3b87ce65 100644 --- a/jirs-client/src/ws/init_load_sets.rs +++ b/jirs-client/src/ws/init_load_sets.rs @@ -11,6 +11,7 @@ pub fn board_load(model: &mut Model, orders: &mut impl Orders) { vec![ WsMsg::IssueStatusesLoad, WsMsg::ProjectIssuesLoad, + WsMsg::ProjectUsersLoad, WsMsg::EpicsLoad, ], model.ws.as_ref(), diff --git a/jirs-client/src/ws/issue.rs b/jirs-client/src/ws/issue.rs index a0bf1081..602200e5 100644 --- a/jirs-client/src/ws/issue.rs +++ b/jirs-client/src/ws/issue.rs @@ -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) { - // 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 = vec![]; - let mut pos = 0; - let mut found: Option = 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) { + // 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 = vec![]; + let mut pos = 0; + let mut found: Option = 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); + } +} diff --git a/jirs-client/src/ws/mod.rs b/jirs-client/src/ws/mod.rs index 682739e7..bd161e64 100644 --- a/jirs-client/src/ws/mod.rs +++ b/jirs-client/src/ws/mod.rs @@ -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) { - 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, ws: Option<&WebSocket>, orders: &mut impl Orders) { - for msg in v { - send_ws_msg(msg, ws, orders); - } -} - -pub fn send_ws_msg(msg: WsMsg, ws: Option<&WebSocket>, orders: &mut impl Orders) { - 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) { - 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 - }) - .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) { - 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) { - 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) { + 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, ws: Option<&WebSocket>, orders: &mut impl Orders) { + for msg in v { + send_ws_msg(msg, ws, orders); + } +} + +pub fn send_ws_msg(msg: WsMsg, ws: Option<&WebSocket>, orders: &mut impl Orders) { + 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) { + 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 + }) + .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) { + 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) { + 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") +} diff --git a/jirs-client/static/index.js b/jirs-client/static/index.js index bb4ffc0b..5a13bc02 100644 --- a/jirs-client/static/index.js +++ b/jirs-client/static/index.js @@ -5,8 +5,9 @@ const wsUrl = () => `${getProtocol()}//${getWsHostName()}:${process.env.JIRS_SER import("/jirs.js").then(async module => { // window.module = module; await module.default(); - const host_url = `${location.protocol}//${process.env.JIRS_SERVER_BIND}:${process.env.JIRS_SERVER_PORT}`; + const host_url = `${ location.protocol }//${ process.env.JIRS_SERVER_BIND }:${ process.env.JIRS_SERVER_PORT }`; module.render(host_url, wsUrl()); document.querySelector('main').className = ''; - document.querySelector('.spinner').remove(); + const spinner = document.querySelector('.spinner'); + spinner && spinner.remove(); }); diff --git a/jirs-server/Cargo.toml b/jirs-server/Cargo.toml index cb68ee45..237ccb2f 100644 --- a/jirs-server/Cargo.toml +++ b/jirs-server/Cargo.toml @@ -13,8 +13,8 @@ name = "jirs_server" path = "./src/main.rs" [features] -aws-s3 = [] -local-storage = [] +aws-s3 = ["amazon-actor"] +local-storage = ["filesystem-actor"] default = [ "aws-s3", "local-storage", @@ -83,20 +83,11 @@ path = "../actors/mail-actor" [dependencies.filesystem-actor] path = "../actors/filesystem-actor" +optional = true -# Amazon S3 -#[dependencies.rusoto_s3] -#optional = true -#version = "0.43.0" -# -#[dependencies.rusoto_core] -#optional = true -#version = "0.43.0" - -# Local storage -#[dependencies.actix-files] -#optional = true -#version = "*" +[dependencies.amazon-actor] +path = "../actors/amazon-actor" +optional = true [dependencies.tokio] version = "0.2.23" diff --git a/jirs-server/src/main.rs b/jirs-server/src/main.rs index 8ac43db2..a9106c14 100644 --- a/jirs-server/src/main.rs +++ b/jirs-server/src/main.rs @@ -40,6 +40,11 @@ async fn main() -> Result<(), String> { jirs_config::fs::Configuration::read().concurrency, filesystem_actor::FileSystemExecutor::default, ); + #[cfg(feature = "aws-s3")] + let amazon_addr = actix::SyncArbiter::start( + jirs_config::web::Configuration::read().concurrency, + amazon_actor::AmazonExecutor::default, + ); let ws_server = websocket_actor::server::WsServer::start_default(); @@ -54,6 +59,7 @@ async fn main() -> Result<(), String> { .data(hi_addr.clone()) .data(database_actor::build_pool()); featured! { app, "local-storage", app.data(fs_addr.clone()) }; + featured! { app, "aws-s3", app.data(amazon_addr.clone()) }; // services step let app = app diff --git a/shared/jirs-config/src/amazon.rs b/shared/jirs-config/src/amazon.rs new file mode 100644 index 00000000..43ef6030 --- /dev/null +++ b/shared/jirs-config/src/amazon.rs @@ -0,0 +1,50 @@ +use rusoto_signature::Region; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct Configuration { + pub access_key_id: String, + pub secret_access_key: String, + pub bucket: String, + pub region_name: String, + #[serde(default = "Configuration::default_active")] + pub active: bool, + pub concurrency: usize, +} + +impl Default for Configuration { + fn default() -> Self { + Self { + access_key_id: "".to_string(), + secret_access_key: "".to_string(), + bucket: "".to_string(), + region_name: Region::default().name().to_string(), + active: true, + concurrency: 2, + } + } +} + +impl Configuration { + pub fn default_active() -> bool { + true + } + + pub fn is_empty(&self) -> bool { + self.access_key_id.is_empty() || self.secret_access_key.is_empty() || self.bucket.is_empty() + } + + pub fn region(&self) -> Region { + self.region_name.parse::().unwrap_or_default() + } + + pub fn set_variables(&self) { + std::env::set_var("AWS_ACCESS_KEY_ID", self.access_key_id.as_str()); + std::env::set_var("AWS_SECRET_ACCESS_KEY", self.secret_access_key.as_str()); + std::env::set_var("AWS_S3_BUCKET_NAME", self.region_name.as_str()); + } + + crate::rw!("amazon.toml"); +} + +crate::read!(Configuration); diff --git a/shared/jirs-config/src/database.rs b/shared/jirs-config/src/database.rs index 381f3265..a4bbfa60 100644 --- a/shared/jirs-config/src/database.rs +++ b/shared/jirs-config/src/database.rs @@ -1,5 +1,3 @@ -use std::fs::{read_to_string, write}; - #[derive(serde::Serialize, serde::Deserialize)] pub struct Configuration { pub concurrency: usize, @@ -22,33 +20,6 @@ impl Default for Configuration { } impl Configuration { - pub fn read() -> Self { - let _ = std::fs::create_dir_all("./config"); - let contents: String = read_to_string(Self::config_file()).unwrap_or_default(); - match toml::from_str(contents.as_str()) { - Ok(config) => config, - _ => { - let config = Configuration::default(); - config.write().unwrap_or_else(|e| panic!(e)); - config - } - } - } - - pub fn write(&self) -> Result<(), String> { - let _ = std::fs::create_dir_all("./config"); - let s = toml::to_string(self).map_err(|e| e.to_string())?; - write(Self::config_file(), s.as_str()).map_err(|e| e.to_string())?; - Ok(()) - } - - #[cfg(not(test))] - pub fn config_file() -> &'static str { - "./config/db.toml" - } - - #[cfg(test)] - pub fn config_file() -> &'static str { - "./config/db.test.toml" - } + crate::rw!("db.toml"); } +crate::read!(Configuration); diff --git a/shared/jirs-config/src/fs.rs b/shared/jirs-config/src/fs.rs index 16ea6f53..00e2090d 100644 --- a/shared/jirs-config/src/fs.rs +++ b/shared/jirs-config/src/fs.rs @@ -1,5 +1,3 @@ -use std::fs::{read_to_string, write}; - use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] @@ -29,36 +27,6 @@ impl Configuration { self.store_path.is_empty() } - pub fn read() -> Self { - let _ = std::fs::create_dir_all("./config"); - let contents: String = read_to_string(Self::config_file()).unwrap_or_default(); - let config = match toml::from_str(contents.as_str()) { - Ok(config) => config, - _ => { - let config = Configuration::default(); - config.write().unwrap_or_else(|e| panic!(e)); - config - } - }; - let _ = std::fs::create_dir_all(config.tmp_path.as_str()).map_err(|e| e.to_string()); - let _ = std::fs::create_dir_all(config.store_path.as_str()).map_err(|e| e.to_string()); - config - } - - pub fn write(&self) -> Result<(), String> { - let _ = std::fs::create_dir_all("./config"); - let s = toml::to_string(self).map_err(|e| e.to_string())?; - write(Self::config_file(), s.as_str()).map_err(|e| e.to_string())?; - Ok(()) - } - - #[cfg(not(test))] - pub fn config_file() -> &'static str { - "./config/fs.toml" - } - - #[cfg(test)] - pub fn config_file() -> &'static str { - "./config/fs.test.toml" - } + crate::rw!("fs.toml"); } +crate::read!(Configuration); diff --git a/shared/jirs-config/src/hi.rs b/shared/jirs-config/src/hi.rs index 583b3b0d..0eb8a96f 100644 --- a/shared/jirs-config/src/hi.rs +++ b/shared/jirs-config/src/hi.rs @@ -1,5 +1,3 @@ -use std::fs::{read_to_string, write}; - use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] @@ -14,33 +12,6 @@ impl Default for Configuration { } impl Configuration { - pub fn read() -> Self { - let _ = std::fs::create_dir_all("./config"); - let contents: String = read_to_string(Self::config_file()).unwrap_or_default(); - match toml::from_str(contents.as_str()) { - Ok(config) => config, - _ => { - let config = Configuration::default(); - config.write().unwrap_or_else(|e| panic!(e)); - config - } - } - } - - pub fn write(&self) -> Result<(), String> { - let _ = std::fs::create_dir_all("./config"); - let s = toml::to_string(self).map_err(|e| e.to_string())?; - write(Self::config_file(), s.as_str()).map_err(|e| e.to_string())?; - Ok(()) - } - - #[cfg(not(test))] - pub fn config_file() -> &'static str { - "./config/highlight.toml" - } - - #[cfg(test)] - pub fn config_file() -> &'static str { - "./config/highlight.test.toml" - } + crate::rw!("highlight.toml"); } +crate::read!(Configuration); diff --git a/shared/jirs-config/src/lib.rs b/shared/jirs-config/src/lib.rs index df75ffc1..e2af2d56 100644 --- a/shared/jirs-config/src/lib.rs +++ b/shared/jirs-config/src/lib.rs @@ -1,12 +1,22 @@ +#[cfg(feature = "aws-s3")] +pub mod amazon; + #[cfg(feature = "database")] pub mod database; + #[cfg(feature = "local-storage")] pub mod fs; + #[cfg(feature = "hi")] pub mod hi; + #[cfg(feature = "mail")] pub mod mail; + #[cfg(feature = "web")] pub mod web; + #[cfg(feature = "websocket")] pub mod websocket; + +pub mod utils; diff --git a/shared/jirs-config/src/mail.rs b/shared/jirs-config/src/mail.rs index 87cbca1a..bca2c017 100644 --- a/shared/jirs-config/src/mail.rs +++ b/shared/jirs-config/src/mail.rs @@ -1,5 +1,3 @@ -use std::fs::{read_to_string, write}; - #[derive(serde::Serialize, serde::Deserialize)] pub struct Configuration { pub concurrency: usize, @@ -22,33 +20,6 @@ impl Default for Configuration { } impl Configuration { - pub fn read() -> Self { - let _ = std::fs::create_dir_all("./config"); - let contents: String = read_to_string(Self::config_file()).unwrap_or_default(); - match toml::from_str(contents.as_str()) { - Ok(config) => config, - _ => { - let config = Configuration::default(); - config.write().unwrap_or_else(|e| panic!(e)); - config - } - } - } - - pub fn write(&self) -> Result<(), String> { - let _ = std::fs::create_dir_all("./config"); - let s = toml::to_string(self).map_err(|e| e.to_string())?; - write(Self::config_file(), s.as_str()).map_err(|e| e.to_string())?; - Ok(()) - } - - #[cfg(not(test))] - fn config_file() -> &'static str { - "./config/mail.toml" - } - - #[cfg(test)] - fn config_file() -> &'static str { - "./config/mail.test.toml" - } + crate::rw!("mail.toml"); } +crate::read!(Configuration); diff --git a/shared/jirs-config/src/utils.rs b/shared/jirs-config/src/utils.rs new file mode 100644 index 00000000..b0aa1465 --- /dev/null +++ b/shared/jirs-config/src/utils.rs @@ -0,0 +1,92 @@ +use std::fs::{read_to_string, write}; + +use serde::export::PhantomData; +use serde::{de::DeserializeOwned, Serialize}; + +pub struct Reader { + __phantom: PhantomData, +} + +impl Reader { + pub fn read(file_name: &str) -> T { + let _ = std::fs::create_dir_all("./config"); + + let contents: String = + match read_to_string(std::path::PathBuf::new().join("./config").join(file_name)) { + Ok(s) => s, + Err(e) => { + if e.kind() == std::io::ErrorKind::NotFound { + let config = T::default(); + let _ = Writer::write(&config, file_name); + return config; + } + panic!("Failed to read ./config/{}. Reason: {}", file_name, e); + } + }; + + match toml::from_str(contents.as_str()) { + Ok(config) => config, + _ => { + let config = T::default(); + let _ = Writer::write(&config, file_name); + config + } + } + } +} + +pub struct Writer { + __phantom: PhantomData, +} + +impl Writer { + pub fn write(config: &T, file_name: &str) -> Result<(), String> { + let _ = std::fs::create_dir_all("./config"); + let s = toml::to_string(config).map_err(|e| e.to_string())?; + write( + std::path::PathBuf::new().join("./config").join(file_name), + s.as_str(), + ) + .map_err(|e| e.to_string())?; + Ok(()) + } +} + +#[macro_export] +macro_rules! rw { + ($path: expr) => { + pub fn read() -> Self { + $crate::utils::Reader::read(Self::config_file()) + } + + pub fn write(&self) -> Result<(), String> { + $crate::utils::Writer::write(self, Self::config_file()) + } + + #[cfg(not(test))] + pub fn config_file() -> &'static str { + $path + } + + #[cfg(test)] + pub fn config_file() -> &'static str { + std::concat!("test.", $path) + } + }; +} + +#[macro_export] +macro_rules! read { + ($c: ident) => { + static mut CONFIG: Option> = None; + + pub fn config() -> &'static $c { + if unsafe { CONFIG.is_none() } { + unsafe { + CONFIG = Some(Box::new($c::read())); + }; + } + unsafe { CONFIG.as_ref().unwrap() } + } + }; +} diff --git a/shared/jirs-config/src/web.rs b/shared/jirs-config/src/web.rs index cfbb9041..d4dd76ae 100644 --- a/shared/jirs-config/src/web.rs +++ b/shared/jirs-config/src/web.rs @@ -1,10 +1,4 @@ -#[cfg(feature = "aws-s3")] -use rusoto_signature::Region; - -use { - serde::{Deserialize, Serialize}, - std::fs::{read_to_string, write}, -}; +use serde::{Deserialize, Serialize}; #[derive(Debug)] pub enum Protocol { @@ -12,67 +6,15 @@ pub enum Protocol { Https, } -#[cfg(feature = "aws-s3")] -#[derive(Serialize, Deserialize, Debug)] -pub struct AmazonS3Storage { - pub access_key_id: String, - pub secret_access_key: String, - pub bucket: String, - pub region_name: String, - #[serde(default = "AmazonS3Storage::default_active")] - pub active: bool, -} - -#[cfg(feature = "aws-s3")] -impl AmazonS3Storage { - pub fn default_active() -> bool { - true - } - - pub fn is_empty(&self) -> bool { - self.access_key_id.is_empty() || self.secret_access_key.is_empty() || self.bucket.is_empty() - } - - pub fn region(&self) -> Region { - self.region_name.parse::().unwrap_or_default() - } - - pub fn set_variables(&self) { - std::env::set_var("AWS_ACCESS_KEY_ID", self.access_key_id.as_str()); - std::env::set_var("AWS_SECRET_ACCESS_KEY", self.secret_access_key.as_str()); - std::env::set_var("AWS_S3_BUCKET_NAME", self.region_name.as_str()); - } -} - #[derive(Serialize, Deserialize)] pub struct Configuration { pub concurrency: usize, pub port: String, pub bind: String, pub ssl: bool, - #[cfg(feature = "aws-s3")] - pub s3: AmazonS3Storage, } impl Default for Configuration { - #[cfg(feature = "aws-s3")] - fn default() -> Self { - Self { - concurrency: 2, - port: "5000".to_string(), - bind: "0.0.0.0".to_string(), - ssl: false, - - s3: AmazonS3Storage { - access_key_id: "".to_string(), - secret_access_key: "".to_string(), - bucket: "".to_string(), - region_name: Region::default().name().to_string(), - active: true, - }, - } - } - #[cfg(not(feature = "aws-s3"))] fn default() -> Self { Self { concurrency: 2, @@ -107,33 +49,6 @@ impl Configuration { } } - pub fn read() -> Self { - let _ = std::fs::create_dir_all("./config"); - let contents: String = read_to_string(Self::config_file()).unwrap_or_default(); - match toml::from_str(contents.as_str()) { - Ok(config) => config, - _ => { - let config = Configuration::default(); - config.write().unwrap_or_else(|e| panic!(e)); - config - } - } - } - - pub fn write(&self) -> Result<(), String> { - let _ = std::fs::create_dir_all("./config"); - let s = toml::to_string(self).map_err(|e| e.to_string())?; - write(Self::config_file(), s.as_str()).map_err(|e| e.to_string())?; - Ok(()) - } - - #[cfg(not(test))] - pub fn config_file() -> &'static str { - "./config/web.toml" - } - - #[cfg(test)] - pub fn config_file() -> &'static str { - "./config/web.test.toml" - } + crate::rw!("web.toml"); } +crate::read!(Configuration); diff --git a/shared/jirs-config/src/websocket.rs b/shared/jirs-config/src/websocket.rs index 75e563aa..7cc8ef88 100644 --- a/shared/jirs-config/src/websocket.rs +++ b/shared/jirs-config/src/websocket.rs @@ -1,5 +1,3 @@ -use std::fs::{read_to_string, write}; - #[derive(serde::Serialize, serde::Deserialize)] pub struct Configuration { pub concurrency: usize, @@ -12,33 +10,6 @@ impl Default for Configuration { } impl Configuration { - pub fn read() -> Self { - let _ = std::fs::create_dir_all("./config"); - let contents: String = read_to_string(Self::config_file()).unwrap_or_default(); - match toml::from_str(contents.as_str()) { - Ok(config) => config, - _ => { - let config = Configuration::default(); - config.write().unwrap_or_else(|e| panic!(e)); - config - } - } - } - - pub fn write(&self) -> Result<(), String> { - let _ = std::fs::create_dir_all("./config"); - let s = toml::to_string(self).map_err(|e| e.to_string())?; - write(Self::config_file(), s.as_str()).map_err(|e| e.to_string())?; - Ok(()) - } - - #[cfg(not(test))] - pub fn config_file() -> &'static str { - "./config/websocket.toml" - } - - #[cfg(test)] - pub fn config_file() -> &'static str { - "./config/websocket.test.toml" - } + crate::rw!("websocket.toml"); } +crate::read!(Configuration); diff --git a/shared/jirs-data/Cargo.toml b/shared/jirs-data/Cargo.toml index c6ad45f9..8e183076 100644 --- a/shared/jirs-data/Cargo.toml +++ b/shared/jirs-data/Cargo.toml @@ -13,7 +13,7 @@ name = "jirs_data" path = "./src/lib.rs" [features] -backend = ["diesel"] +backend = ["diesel", "actix"] frontend = [] [dependencies] @@ -22,7 +22,9 @@ serde_json = "*" chrono = { version = "*", features = ["serde"] } uuid = { version = ">=0.7.0, <0.9.0", features = ["serde"] } -actix = { version = "0.10.0" } +[dependencies.actix] +version = "0.10.0" +optional = true [dependencies.diesel] optional = true diff --git a/shared/jirs-data/LICENSE b/shared/jirs-data/LICENSE deleted file mode 120000 index ea5b6064..00000000 --- a/shared/jirs-data/LICENSE +++ /dev/null @@ -1 +0,0 @@ -../LICENSE \ No newline at end of file diff --git a/shared/jirs-data/src/lib.rs b/shared/jirs-data/src/lib.rs index da8917c1..288c3bfd 100644 --- a/shared/jirs-data/src/lib.rs +++ b/shared/jirs-data/src/lib.rs @@ -13,7 +13,7 @@ pub use payloads::*; #[cfg(feature = "backend")] pub use sql::*; -mod fields; +pub mod fields; pub mod msg; mod payloads; @@ -28,15 +28,18 @@ pub trait ToVec { pub type NumberOfDeleted = usize; pub type IssueId = i32; pub type ProjectId = i32; +pub type ProjectName = String; pub type UserId = i32; pub type UserProjectId = i32; pub type CommentId = i32; pub type TokenId = i32; pub type IssueStatusId = i32; +pub type IssueStatusName = String; pub type InvitationId = i32; pub type Position = i32; pub type MessageId = i32; pub type EpicId = i32; +pub type EpicName = String; pub type EmailString = String; pub type UsernameString = String; @@ -565,3 +568,36 @@ pub struct Epic { pub starts_at: Option, pub ends_at: Option, } + +pub type FontStyle = u8; + +pub static BOLD: FontStyle = 1; +pub static UNDERLINE: FontStyle = 2; +pub static ITALIC: FontStyle = 4; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Color { + /// Red component + pub r: u8, + /// Green component + pub g: u8, + /// Blue component + pub b: u8, + /// Alpha (transparency) component + pub a: u8, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Style { + /// Foreground color + pub foreground: Color, + /// Background color + pub background: Color, + /// Style of the font + pub font_style: FontStyle, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct HighlightedCode { + pub parts: Vec<(Style, String)>, +} diff --git a/shared/jirs-data/src/msg.rs b/shared/jirs-data/src/msg.rs index 2557c5d3..066c777f 100644 --- a/shared/jirs-data/src/msg.rs +++ b/shared/jirs-data/src/msg.rs @@ -1,12 +1,14 @@ -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::{ - AvatarUrl, BindToken, Code, Comment, CommentId, CreateCommentPayload, CreateIssuePayload, - EmailString, Epic, EpicId, Invitation, InvitationId, InvitationToken, Issue, IssueFieldId, - IssueId, IssueStatus, IssueStatusId, Lang, Message, MessageId, NameString, NumberOfDeleted, - PayloadVariant, Position, Project, TitleString, UpdateCommentPayload, UpdateProjectPayload, - User, UserId, UserProject, UserProjectId, UserRole, UsernameString, +use { + crate::{ + AvatarUrl, BindToken, Code, Comment, CommentId, CreateCommentPayload, CreateIssuePayload, + EmailString, Epic, EpicId, HighlightedCode, Invitation, InvitationId, InvitationToken, + Issue, IssueFieldId, IssueId, IssueStatus, IssueStatusId, Lang, Message, MessageId, + NameString, NumberOfDeleted, PayloadVariant, Position, Project, TitleString, + UpdateCommentPayload, UpdateProjectPayload, User, UserId, UserProject, UserProjectId, + UserRole, UsernameString, + }, + serde::{Deserialize, Serialize}, + uuid::Uuid, }; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] @@ -217,7 +219,7 @@ pub enum WsMsg { UserProjectCurrentChanged(UserProject), // messages - Message(Message), + MessageUpdated(Message), MessagesLoad, MessagesLoaded(Vec), MessageMarkSeen(MessageId), @@ -235,7 +237,7 @@ pub enum WsMsg { // highlight HighlightCode(Lang, Code), - HighlightedCode(Code), + HighlightedCode(HighlightedCode), // errors Error(WsError),