Upgrade and fix slow autoresize

Fix global handler

Fix initial content, fix settings status, restore partially working rte

Fmt

Implement keyup for rte

Save editor type, switch in mixed mode

Fix initial state for switch between editors

New logger, add handle multiple editors

Nicer editor switcher, fix change user mode in project settings

Epics starts and ends at. Fix DateTime Input month name
This commit is contained in:
Adrian Woźniak 2021-04-25 11:31:52 +02:00 committed by eraden
parent 9994ab8165
commit 3ee08dea54
67 changed files with 2534 additions and 1490 deletions

237
Cargo.lock generated
View File

@ -15,7 +15,7 @@ dependencies = [
"bytes 0.5.6",
"crossbeam-channel",
"derive_more",
"futures 0.3.14",
"futures",
"lazy_static",
"log",
"parking_lot 0.10.2",
@ -91,11 +91,11 @@ checksum = "c95cc9569221e9802bf4c377f6c18b90ef10227d787611decf79fd47d2a8e76c"
dependencies = [
"actix-codec 0.2.0",
"actix-rt",
"actix-service",
"actix-service 1.0.6",
"actix-utils 1.0.6",
"derive_more",
"either",
"futures 0.3.14",
"futures",
"http",
"log",
"trust-dns-proto 0.18.0-alpha.2",
@ -110,7 +110,7 @@ checksum = "177837a10863f15ba8d3ae3ec12fac1099099529ed20083a27fdfe247381d0dc"
dependencies = [
"actix-codec 0.3.0",
"actix-rt",
"actix-service",
"actix-service 1.0.6",
"actix-utils 2.0.0",
"derive_more",
"either",
@ -141,7 +141,7 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51e8a9146c12fce92a6e4c24b8c4d9b05268130bfd8d61bc587e822c32ce689"
dependencies = [
"actix-service",
"actix-service 1.0.6",
"actix-web",
"bitflags",
"bytes 0.5.6",
@ -164,7 +164,7 @@ dependencies = [
"actix-codec 0.2.0",
"actix-connect 1.0.2",
"actix-rt",
"actix-service",
"actix-service 1.0.6",
"actix-threadpool",
"actix-utils 1.0.6",
"base64 0.11.0",
@ -208,7 +208,7 @@ dependencies = [
"actix-codec 0.3.0",
"actix-connect 2.0.0",
"actix-rt",
"actix-service",
"actix-service 1.0.6",
"actix-threadpool",
"actix-utils 2.0.0",
"base64 0.13.0",
@ -235,7 +235,7 @@ dependencies = [
"log",
"mime",
"percent-encoding",
"pin-project 1.0.6",
"pin-project 1.0.7",
"rand 0.7.3",
"regex",
"serde",
@ -262,7 +262,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "774bfeb11b54bf9c857a005b8ab893293da4eaff79261a66a9200dab7f5ab6e3"
dependencies = [
"actix-service",
"actix-service 1.0.6",
"actix-utils 2.0.0",
"actix-web",
"bytes 0.5.6",
@ -310,7 +310,7 @@ checksum = "45407e6e672ca24784baa667c5d32ef109ccdd8d5e0b5ebb9ef8a67f4dfb708e"
dependencies = [
"actix-codec 0.3.0",
"actix-rt",
"actix-service",
"actix-service 1.0.6",
"actix-utils 2.0.0",
"futures-channel",
"futures-util",
@ -332,6 +332,17 @@ dependencies = [
"pin-project 0.4.28",
]
[[package]]
name = "actix-service"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77f5f9d66a8730d0fae62c26f3424f5751e5518086628a40b7ab6fca4a705034"
dependencies = [
"futures-core",
"paste",
"pin-project-lite 0.2.6",
]
[[package]]
name = "actix-testing"
version = "1.0.1"
@ -341,7 +352,7 @@ dependencies = [
"actix-macros",
"actix-rt",
"actix-server",
"actix-service",
"actix-service 1.0.6",
"log",
"socket2",
]
@ -368,7 +379,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24789b7d7361cf5503a504ebe1c10806896f61e96eca9a7350e23001aca715fb"
dependencies = [
"actix-codec 0.3.0",
"actix-service",
"actix-service 1.0.6",
"actix-utils 2.0.0",
"futures-util",
]
@ -381,11 +392,11 @@ checksum = "fcf8f5631bf01adec2267808f00e228b761c60c0584cc9fa0b5364f41d147f4e"
dependencies = [
"actix-codec 0.2.0",
"actix-rt",
"actix-service",
"actix-service 1.0.6",
"bitflags",
"bytes 0.5.6",
"either",
"futures 0.3.14",
"futures",
"log",
"pin-project 0.4.28",
"slab",
@ -399,7 +410,7 @@ checksum = "2e9022dec56632d1d7979e59af14f0597a28a830a9c1c7fec8b2327eb9f16b5a"
dependencies = [
"actix-codec 0.3.0",
"actix-rt",
"actix-service",
"actix-service 1.0.6",
"bitflags",
"bytes 0.5.6",
"either",
@ -423,7 +434,7 @@ dependencies = [
"actix-router",
"actix-rt",
"actix-server",
"actix-service",
"actix-service 1.0.6",
"actix-testing",
"actix-threadpool",
"actix-tls",
@ -439,7 +450,7 @@ dependencies = [
"fxhash",
"log",
"mime",
"pin-project 1.0.6",
"pin-project 1.0.7",
"regex",
"serde",
"serde_json",
@ -518,11 +529,11 @@ version = "0.1.0"
dependencies = [
"actix 0.10.0",
"actix-rt",
"actix-service",
"actix-service 2.0.0",
"actix-web-actors",
"bytes 0.5.6",
"env_logger",
"futures 0.3.14",
"futures",
"jirs-config",
"libc",
"log",
@ -545,18 +556,6 @@ dependencies = [
"winapi 0.3.9",
]
[[package]]
name = "arrayref"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544"
[[package]]
name = "arrayvec"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
[[package]]
name = "ascii_utils"
version = "0.9.3"
@ -565,9 +564,9 @@ checksum = "71938f30533e4d95a6d17aa530939da3842c2ab6f4f84b9dae68447e4129f74a"
[[package]]
name = "async-trait"
version = "0.1.49"
version = "0.1.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589652ce7ccb335d1e7ecb3be145425702b290dbcb7029bbeaae263fc1d87b48"
checksum = "0b98e84bbb4cbcdd97da190ba0c58a1bb0de2c1fdf67d159e192ed766aeca722"
dependencies = [
"proc-macro2",
"quote",
@ -606,7 +605,7 @@ dependencies = [
"actix-codec 0.3.0",
"actix-http 2.2.0",
"actix-rt",
"actix-service",
"actix-service 1.0.6",
"base64 0.13.0",
"bytes 0.5.6",
"cfg-if 1.0.0",
@ -623,11 +622,12 @@ dependencies = [
[[package]]
name = "backtrace"
version = "0.3.56"
version = "0.3.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d117600f438b1707d4e4ae15d3595657288f8235a0eb593e80ecc98ab34e1bc"
checksum = "88fb5a785d6b44fd9d6700935608639af1b8356de1e55d5f7c2740f4faa15d82"
dependencies = [
"addr2line",
"cc",
"cfg-if 1.0.0",
"libc",
"miniz_oxide",
@ -704,17 +704,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693"
[[package]]
name = "blake2b_simd"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "afa748e348ad3be8263be728124b24a24f268266f6f5d58af9d75f6a40b5c587"
dependencies = [
"arrayref",
"arrayvec",
"constant_time_eq",
]
[[package]]
name = "block-buffer"
version = "0.7.3"
@ -914,15 +903,9 @@ dependencies = [
[[package]]
name = "const_fn"
version = "0.4.6"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "076a6803b0dacd6a88cfe64deba628b01533ff5ef265687e6938280c1afd0a28"
[[package]]
name = "constant_time_eq"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
checksum = "402da840495de3f976eaefc3485b7f5eb5b0bf9761f9a47be27fe975b3b8c2ec"
[[package]]
name = "convert_case"
@ -984,7 +967,7 @@ version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b153fe7cbef478c567df0f972e02e6d736db11affe43dfc9c56a9374d1adfb87"
dependencies = [
"crossbeam-utils 0.7.2",
"crossbeam-utils",
"maybe-uninit",
]
@ -999,17 +982,6 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9d99fa91428effe99c5c6d4634cdeba32b8cf784fc428a2a687f61a952c49"
dependencies = [
"autocfg 1.0.1",
"cfg-if 1.0.0",
"lazy_static",
]
[[package]]
name = "crypto-mac"
version = "0.8.0"
@ -1035,7 +1007,7 @@ dependencies = [
"diesel",
"dotenv",
"env_logger",
"futures 0.3.14",
"futures",
"ipnetwork 0.16.0",
"jirs-config",
"jirs-data",
@ -1158,9 +1130,9 @@ dependencies = [
[[package]]
name = "dirs-sys"
version = "0.3.5"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e93d7f5705de3e49895a2b5e0b8855a1c27f080192ae9c32a6432d50741a57a"
checksum = "03d86534ed367a67548dc68113a0f5db55432fdfbb6e6f9d77704397d95d5780"
dependencies = [
"libc",
"redox_users",
@ -1361,7 +1333,7 @@ dependencies = [
"actix-files",
"bytes 0.5.6",
"env_logger",
"futures 0.3.14",
"futures",
"jirs-config",
"log",
"pretty_env_logger",
@ -1433,12 +1405,6 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7"
[[package]]
name = "futures"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a471a38ef8ed83cd6e40aa59c1ffe17db6855c18e3604d9c4ed8c08ebc28678"
[[package]]
name = "futures"
version = "0.3.14"
@ -1757,9 +1723,9 @@ dependencies = [
[[package]]
name = "httparse"
version = "1.3.6"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc35c995b9d93ec174cf9a27d425c7892722101e14993cd227fdb51d70cf9589"
checksum = "4a1ce40d6fc9764887c2fdc7305c3dcc429ba11ff981c1509416afd5697e4437"
[[package]]
name = "httpdate"
@ -1792,7 +1758,7 @@ dependencies = [
"httparse",
"httpdate",
"itoa",
"pin-project 1.0.6",
"pin-project 1.0.7",
"socket2",
"tokio",
"tower-service",
@ -1941,7 +1907,7 @@ dependencies = [
"actix 0.10.0",
"actix-cors",
"actix-rt",
"actix-service",
"actix-service 2.0.0",
"actix-web",
"actix-web-actors",
"amazon-actor",
@ -1955,7 +1921,7 @@ dependencies = [
"dotenv",
"env_logger",
"filesystem-actor",
"futures 0.3.14",
"futures",
"highlight-actor",
"ipnetwork 0.16.0",
"jirs-config",
@ -1989,15 +1955,18 @@ dependencies = [
"derive_enum_iter",
"derive_enum_primitive",
"dotenv",
"futures 0.1.31",
"futures",
"jirs-data",
"js-sys",
"log",
"seed",
"serde",
"serde_json",
"uuid 0.8.2",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-bindgen-test",
"wasm-logger",
"web-sys",
"wee_alloc",
]
@ -2073,9 +2042,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.93"
version = "0.2.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9385f66bf6105b241aa65a61cb923ef20efc665cb9f9bb50ac2f0c4b7f378d41"
checksum = "18794a8ad5b29321f790b55d93dfba91e125cb1a9edbd4f8e3150acc771c1a5e"
[[package]]
name = "line-wrap"
@ -2135,7 +2104,7 @@ dependencies = [
"actix 0.10.0",
"dotenv",
"env_logger",
"futures 0.3.14",
"futures",
"jirs-config",
"lettre",
"lettre_email",
@ -2502,6 +2471,12 @@ dependencies = [
"winapi 0.3.9",
]
[[package]]
name = "paste"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acbf547ad0c65e31259204bd90935776d1c693cec2f4ff7abb7a1bbbd40dfe58"
[[package]]
name = "percent-encoding"
version = "2.1.0"
@ -2562,11 +2537,11 @@ dependencies = [
[[package]]
name = "pin-project"
version = "1.0.6"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc174859768806e91ae575187ada95c91a29e96a98dc5d2cd9a1fed039501ba6"
checksum = "c7509cc106041c40a4518d2af7a61530e1eed0e6285296a3d8c5472806ccc4a4"
dependencies = [
"pin-project-internal 1.0.6",
"pin-project-internal 1.0.7",
]
[[package]]
@ -2582,9 +2557,9 @@ dependencies = [
[[package]]
name = "pin-project-internal"
version = "1.0.6"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a490329918e856ed1b083f244e3bfe2d8c4f336407e4ea9e1a9f479ff09049e5"
checksum = "48c950132583b500556b1efd71d45b319029f2b71518d979fcc208e16b42426f"
dependencies = [
"proc-macro2",
"quote",
@ -2948,20 +2923,19 @@ dependencies = [
[[package]]
name = "redox_users"
version = "0.3.5"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de0737333e7a9502c789a36d7c7fa6092a49895d4faa31ca5df163857ded2e9d"
checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64"
dependencies = [
"getrandom 0.1.16",
"redox_syscall 0.1.57",
"rust-argon2",
"getrandom 0.2.2",
"redox_syscall 0.2.6",
]
[[package]]
name = "regex"
version = "1.4.5"
version = "1.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "957056ecddbeba1b26965114e191d2e8589ce74db242b6ea25fc4062427a5c19"
checksum = "2a26af418b574bd56588335b3a3659a65725d4e636eb1016c2f9e3b38c7cc759"
dependencies = [
"aho-corasick",
"memchr",
@ -3013,7 +2987,7 @@ dependencies = [
"base64 0.12.3",
"bytes 0.5.6",
"crc32fast",
"futures 0.3.14",
"futures",
"http",
"hyper",
"hyper-tls",
@ -3040,7 +3014,7 @@ dependencies = [
"async-trait",
"chrono",
"dirs",
"futures 0.3.14",
"futures",
"hyper",
"pin-project 0.4.28",
"regex",
@ -3059,7 +3033,7 @@ checksum = "1146e37a7c1df56471ea67825fe09bbbd37984b5f6e201d8b2e0be4ee15643d8"
dependencies = [
"async-trait",
"bytes 0.5.6",
"futures 0.3.14",
"futures",
"rusoto_core",
"xml-rs",
]
@ -3072,7 +3046,7 @@ checksum = "97a740a88dde8ded81b6f2cff9cd5e054a5a2e38a38397260f7acdd2c85d17dd"
dependencies = [
"base64 0.12.3",
"bytes 0.5.6",
"futures 0.3.14",
"futures",
"hex",
"hmac",
"http",
@ -3089,18 +3063,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "rust-argon2"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18820d944b33caa75a71378964ac46f58517c92b6ae5f762636247c09e78fb"
dependencies = [
"base64 0.13.0",
"blake2b_simd",
"constant_time_eq",
"crossbeam-utils 0.8.3",
]
[[package]]
name = "rustc-demangle"
version = "0.1.18"
@ -3194,13 +3156,13 @@ dependencies = [
[[package]]
name = "seed"
version = "0.8.0"
source = "git+https://github.com/seed-rs/seed.git#e1d82f5012fb9e71b36be3a5457d61d34a1ee53d"
source = "git+https://github.com/seed-rs/seed.git#938d71b6527efcd84c7d9ff37330949791b6d3a4"
dependencies = [
"console_error_panic_hook",
"cookie",
"dbg",
"enclose",
"futures 0.3.14",
"futures",
"getrandom 0.2.2",
"gloo-file",
"gloo-timers",
@ -3363,9 +3325,9 @@ dependencies = [
[[package]]
name = "slab"
version = "0.4.2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c111b5bd5695e56cffe5129854aa230b39c93a305372fdbb2668ca2394eea9f8"
checksum = "f173ac3d1a7e3b28003f40de0b5ce7fe2710f9b9dc3fc38664cebee46b3b6527"
[[package]]
name = "smallvec"
@ -3468,9 +3430,9 @@ checksum = "1e81da0851ada1f3e9d4312c704aa4f8806f0f9d69faaf8df2f3464b4a9437c2"
[[package]]
name = "syn"
version = "1.0.69"
version = "1.0.70"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48fe99c6bd8b1cc636890bcc071842de909d902c81ac7dab53ba33c421ab8ffb"
checksum = "b9505f307c872bab8eb46f77ae357c8eba1fdacead58ee5a850116b1d7f82883"
dependencies = [
"proc-macro2",
"quote",
@ -3763,7 +3725,7 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2"
dependencies = [
"pin-project 1.0.6",
"pin-project 1.0.7",
"tracing",
]
@ -3782,7 +3744,7 @@ dependencies = [
"async-trait",
"enum-as-inner",
"failure",
"futures 0.3.14",
"futures",
"idna",
"lazy_static",
"log",
@ -3802,7 +3764,7 @@ dependencies = [
"async-trait",
"cfg-if 1.0.0",
"enum-as-inner",
"futures 0.3.14",
"futures",
"idna",
"lazy_static",
"log",
@ -3821,7 +3783,7 @@ checksum = "6f90b1502b226f8b2514c6d5b37bafa8c200d7ca4102d57dc36ee0f3b7a04a2f"
dependencies = [
"cfg-if 0.1.10",
"failure",
"futures 0.3.14",
"futures",
"ipconfig",
"lazy_static",
"log",
@ -3839,7 +3801,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "710f593b371175db53a26d0b38ed2978fafb9e9e8d3868b1acd753ea18df0ceb"
dependencies = [
"cfg-if 0.1.10",
"futures 0.3.14",
"futures",
"ipconfig",
"lazy_static",
"log",
@ -4032,9 +3994,9 @@ dependencies = [
[[package]]
name = "vcpkg"
version = "0.2.11"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b00bca6106a5e23f3eee943593759b7fcddb00554332e856d990c893966879fb"
checksum = "cbdbff6266a24120518560b5dc983096efb98462e51d0d68169895b237be3e5d"
[[package]]
name = "vec_map"
@ -4179,6 +4141,17 @@ dependencies = [
"quote",
]
[[package]]
name = "wasm-logger"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "074649a66bb306c8f2068c9016395fa65d8e08d2affcbf95acf3c24c3ab19718"
dependencies = [
"log",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "web-actor"
version = "0.1.0"
@ -4187,7 +4160,7 @@ dependencies = [
"actix-cors",
"actix-multipart",
"actix-rt",
"actix-service",
"actix-service 2.0.0",
"actix-web",
"actix-web-actors",
"amazon-actor",
@ -4196,7 +4169,7 @@ dependencies = [
"database-actor",
"env_logger",
"filesystem-actor",
"futures 0.3.14",
"futures",
"jirs-config",
"jirs-data",
"libc",
@ -4233,7 +4206,7 @@ dependencies = [
"database-actor",
"env_logger",
"flate2",
"futures 0.3.14",
"futures",
"highlight-actor",
"jirs-config",
"jirs-data",
@ -4363,6 +4336,6 @@ dependencies = [
[[package]]
name = "zeroize"
version = "1.2.0"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81a974bcdd357f0dca4d41677db03436324d45a4c9ed2d0b873a5a360ce41c36"
checksum = "4756f7db3f7b5574938c3eb1c117038b8e07f95ee6718c0efad4ac21508f1efd"

View File

@ -58,12 +58,12 @@ https://git.sr.ht/~tsumanu/jirs
* [X] Add Epic
* [X] Edit Epic
* [X] Delete Epic
* [ ] Epic `starts` and `ends` date
* [X] Epic `starts` and `ends` date
* [X] Grouping by Epic
* [ ] Basic Rich Text Editor
* [X] Basic Rich Text Editor
* [ ] Insert Code in Rich Text Editor
* [X] Code syntax
* [ ] Personal settings to choose MDE (Markdown Editor) or RTE
* [X] Personal settings to choose MDE (Markdown Editor) or RTE
* [ ] Issues and filters view
* [ ] Issues and filters working filters

View File

@ -1,6 +1,6 @@
use derive_db_execute::Execute;
use diesel::prelude::*;
use jirs_data::{DescriptionString, Epic, EpicId, ProjectId};
use jirs_data::{DescriptionString, EndsAt, Epic, EpicId, ProjectId, StartsAt};
use crate::{db_create, db_delete, db_load, db_update};
@ -35,7 +35,7 @@ db_create! {
}
db_update! {
UpdateEpic,
UpdateEpicName,
msg => epics => diesel::update(
epics
.filter(project_id.eq(msg.project_id))
@ -47,6 +47,32 @@ db_update! {
name => String
}
db_update! {
UpdateEpicStartsAt,
msg => epics => diesel::update(
epics
.filter(project_id.eq(msg.project_id))
.find(msg.epic_id),
).set(starts_at.eq(msg.starts_at)),
Epic,
epic_id => i32,
project_id => i32,
starts_at => Option<StartsAt>
}
db_update! {
UpdateEpicEndsAt,
msg => epics => diesel::update(
epics
.filter(project_id.eq(msg.project_id))
.find(msg.epic_id),
).set(ends_at.eq(msg.ends_at)),
Epic,
epic_id => i32,
project_id => i32,
ends_at => Option<EndsAt>
}
db_delete! {
DeleteEpic,
msg => epics => diesel::delete(

View File

@ -20,6 +20,7 @@ pub enum ResourceKind {
Project,
Token,
UserProject,
UserSetting,
User,
Comment,
}

View File

@ -23,6 +23,7 @@ pub mod projects;
pub mod schema;
pub mod tokens;
pub mod user_projects;
pub mod user_settings;
pub mod users;
pub type DbPool = r2d2::Pool<ConnectionManager<PgConnection>>;

View File

@ -620,6 +620,35 @@ table! {
}
}
table! {
use diesel::sql_types::*;
use jirs_data::*;
/// Representation of the `user_settings` table.
///
/// (Automatically generated by Diesel.)
user_settings (id) {
/// The `id` column of the `user_settings` table.
///
/// Its SQL type is `Int4`.
///
/// (Automatically generated by Diesel.)
id -> Int4,
/// The `user_id` column of the `user_settings` table.
///
/// Its SQL type is `Int4`.
///
/// (Automatically generated by Diesel.)
user_id -> Int4,
/// The `text_editor_mode` column of the `user_settings` table.
///
/// Its SQL type is `TextEditorModeType`.
///
/// (Automatically generated by Diesel.)
text_editor_mode -> TextEditorModeType,
}
}
table! {
use diesel::sql_types::*;
use jirs_data::*;
@ -683,6 +712,7 @@ joinable!(issues -> users (reporter_id));
joinable!(tokens -> users (user_id));
joinable!(user_projects -> projects (project_id));
joinable!(user_projects -> users (user_id));
joinable!(user_settings -> users (user_id));
allow_tables_to_appear_in_same_query!(
comments,
@ -695,5 +725,6 @@ allow_tables_to_appear_in_same_query!(
projects,
tokens,
user_projects,
user_settings,
users,
);

View File

@ -0,0 +1,57 @@
use diesel::prelude::*;
use jirs_data::{TextEditorMode, UserId, UserSetting};
use crate::{db_find, db_update};
db_find! {
FindUserSetting,
msg => user_settings => user_settings
.distinct_on(id)
.filter(user_id.eq(msg.user_id))
.limit(1),
UserSetting,
user_id => UserId
}
db_update! {
UpdateUserSetting,
msg => conn => user_settings => {
inner::Update { user_id: msg.user_id, mode: msg.mode }
.execute(conn).or_else(|_|
inner::Create { user_id: msg.user_id, mode: msg.mode }
.execute(conn)
)?;
user_settings.filter(user_id.eq(msg.user_id))
},
UserSetting,
user_id => UserId,
mode => TextEditorMode
}
mod inner {
use diesel::prelude::*;
use jirs_data::{TextEditorMode, UserId, UserSetting};
use crate::{db_create, db_update};
db_update! {
Update,
msg => user_settings => {
diesel::update(user_settings.filter(user_id.eq(msg.user_id))).set(text_editor_mode.eq(msg.mode))
},
UserSetting,
user_id => UserId,
mode => TextEditorMode
}
db_create! {
Create,
msg => user_settings => diesel::insert_into(user_settings).values((
user_id.eq(msg.user_id),
text_editor_mode.eq(msg.mode)
)),
UserSetting,
user_id => UserId,
mode => TextEditorMode
}
}

View File

@ -1,84 +1,5 @@
use std::task::{Context, Poll};
use actix_service::{Service, Transform};
use actix_web::dev::{ServiceRequest, ServiceResponse};
use actix_web::http::header::{self};
use actix_web::http::HeaderMap;
use actix_web::Error;
use futures::future::{ok, FutureExt, LocalBoxFuture, Ready};
use jirs_data::User;
type Db = actix_web::web::Data<database_actor::DbPool>;
#[derive(Default)]
pub struct Authorize;
impl<S, B> Transform<S> for Authorize
where
S: Service<Request = ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Request = ServiceRequest;
type Response = ServiceResponse<B>;
type Error = Error;
type Transform = AuthorizeMiddleware<S>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ok(AuthorizeMiddleware { service })
}
}
pub struct AuthorizeMiddleware<S> {
service: S,
}
impl<S, B> Service for AuthorizeMiddleware<S>
where
S: Service<Request = ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Request = ServiceRequest;
type Response = ServiceResponse<B>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Error>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.service.poll_ready(cx)
}
fn call(&mut self, req: ServiceRequest) -> Self::Future {
let pool: &Db = match req.app_data::<Db>() {
Some(d) => d,
_ => {
return async move {
let res = crate::errors::ServiceError::DatabaseConnectionLost
.into_http_response()
.into_body();
Ok(req.into_response(res))
}
.boxed_local();
}
};
match check_token(req.headers(), pool.clone()) {
std::result::Result::Err(e) => {
return async move {
let res = e.into_http_response().into_body();
Ok(req.into_response(res))
}
.boxed_local();
}
Ok(_user) => {}
};
let fut = self.service.call(req);
async move { fut.await }.boxed_local()
}
}
pub fn token_from_headers(
headers: &HeaderMap,
@ -90,21 +11,6 @@ pub fn token_from_headers(
.and_then(|s| parse_bearer(s))
}
fn check_token(
headers: &HeaderMap,
pool: Db,
) -> std::result::Result<User, crate::errors::ServiceError> {
token_from_headers(headers).and_then(|access_token| {
use database_actor::authorize_user::AuthorizeUser;
let conn = pool
.get()
.map_err(|_| crate::errors::ServiceError::DatabaseConnectionLost)?;
AuthorizeUser { access_token }
.execute(&conn)
.map_err(|_| crate::errors::ServiceError::Unauthorized)
})
}
fn parse_bearer(header: &str) -> Result<uuid::Uuid, crate::errors::ServiceError> {
if !header.starts_with("Bearer ") {
return Err(crate::errors::ServiceError::Unauthorized);

View File

@ -3,11 +3,13 @@ use database_actor::authorize_user::AuthorizeUser;
use database_actor::tokens::{CreateBindToken, FindBindToken};
use database_actor::users::LookupUser;
use futures::executor::block_on;
use jirs_data::msg::WsError;
use jirs_data::{Token, WsMsg};
use mail_actor::welcome::Welcome;
use crate::{
db_or_debug_and_return, mail_or_debug_and_return, WebSocketActor, WsHandler, WsResult,
db_or_debug_and_return, db_or_debug_or_fallback, mail_or_debug_and_return, WebSocketActor,
WsHandler, WsResult,
};
pub struct Authenticate {
@ -19,7 +21,12 @@ impl WsHandler<Authenticate> for WebSocketActor {
fn handle_msg(&mut self, msg: Authenticate, _ctx: &mut Self::Context) -> WsResult {
let Authenticate { name, email } = msg;
// TODO check attempt number, allow only 5 times per day
let user = db_or_debug_and_return!(self, LookupUser { name, email });
let user = db_or_debug_and_return!(
self,
LookupUser { name, email },
Ok(Some(WsMsg::Error(WsError::InvalidLoginPair))),
Ok(Some(WsMsg::Error(WsError::InvalidLoginPair)))
);
let token = db_or_debug_and_return!(self, CreateBindToken { user_id: user.id });
if let Some(bind_token) = token.bind_token.as_ref().cloned() {
let _ = mail_or_debug_and_return!(
@ -50,12 +57,20 @@ impl WsHandler<CheckAuthToken> for WebSocketActor {
)))),
Ok(Some(WsMsg::AuthorizeExpired))
);
let setting: jirs_data::UserSetting = db_or_debug_or_fallback!(
self,
database_actor::user_settings::FindUserSetting { user_id: user.id },
crate::user_settings::default_user_setting(user.id),
crate::user_settings::default_user_setting(user.id)
);
self.current_user = Some(user.clone());
self.current_user_project = self.load_user_project().ok();
self.current_project = self.load_project().ok();
block_on(self.join_channel(ctx.address().recipient()));
Ok(Some(WsMsg::AuthorizeLoaded(Ok(user))))
Ok(Some(WsMsg::AuthorizeLoaded(Ok((user, setting)))))
}
}

View File

@ -1,5 +1,7 @@
use futures::executor::block_on;
use jirs_data::{DescriptionString, EpicId, IssueType, NameString, UserProject, WsMsg};
use jirs_data::{
DescriptionString, EndsAt, EpicId, IssueType, NameString, StartsAt, UserProject, WsMsg,
};
use crate::{db_or_debug_and_return, WebSocketActor, WsHandler, WsResult};
@ -45,21 +47,60 @@ impl WsHandler<CreateEpic> for WebSocketActor {
}
}
pub struct UpdateEpic {
pub struct UpdateEpicName {
pub epic_id: EpicId,
pub name: NameString,
}
impl WsHandler<UpdateEpic> for WebSocketActor {
fn handle_msg(&mut self, msg: UpdateEpic, _ctx: &mut Self::Context) -> WsResult {
let UpdateEpic { epic_id, name } = msg;
impl WsHandler<UpdateEpicName> for WebSocketActor {
fn handle_msg(&mut self, msg: UpdateEpicName, _ctx: &mut Self::Context) -> WsResult {
let UserProject { project_id, .. } = self.require_user_project()?;
let epic = db_or_debug_and_return!(
self,
database_actor::epics::UpdateEpic {
database_actor::epics::UpdateEpicName {
project_id: *project_id,
epic_id,
name: name.clone(),
epic_id: msg.epic_id,
name: msg.name.clone(),
}
);
Ok(Some(WsMsg::EpicUpdated(epic)))
}
}
pub struct UpdateEpicStartsAt {
pub epic_id: EpicId,
pub starts_at: Option<StartsAt>,
}
impl WsHandler<UpdateEpicStartsAt> for WebSocketActor {
fn handle_msg(&mut self, msg: UpdateEpicStartsAt, _ctx: &mut Self::Context) -> WsResult {
let UserProject { project_id, .. } = self.require_user_project()?;
let epic = db_or_debug_and_return!(
self,
database_actor::epics::UpdateEpicStartsAt {
project_id: *project_id,
epic_id: msg.epic_id,
starts_at: msg.starts_at,
}
);
Ok(Some(WsMsg::EpicUpdated(epic)))
}
}
pub struct UpdateEpicEndsAt {
pub epic_id: EpicId,
pub ends_at: Option<EndsAt>,
}
impl WsHandler<UpdateEpicEndsAt> for WebSocketActor {
fn handle_msg(&mut self, msg: UpdateEpicEndsAt, _ctx: &mut Self::Context) -> WsResult {
let UserProject { project_id, .. } = self.require_user_project()?;
let epic = db_or_debug_and_return!(
self,
database_actor::epics::UpdateEpicEndsAt {
project_id: *project_id,
epic_id: msg.epic_id,
ends_at: msg.ends_at,
}
);
Ok(Some(WsMsg::EpicUpdated(epic)))

View File

@ -20,4 +20,5 @@ pub mod issues;
pub mod messages;
pub mod projects;
pub mod user_projects;
pub mod user_settings;
pub mod users;

View File

@ -0,0 +1,30 @@
use futures::executor::block_on;
use jirs_data::{TextEditorMode, UserId, UserSetting, WsMsg};
use crate::{db_or_debug_and_return, WebSocketActor, WsHandler, WsResult};
pub fn default_user_setting(user_id: UserId) -> UserSetting {
UserSetting {
id: 0,
user_id,
text_editor_mode: Default::default(),
}
}
pub struct SetTextEditorMode {
pub mode: TextEditorMode,
}
impl WsHandler<SetTextEditorMode> for WebSocketActor {
fn handle_msg(&mut self, msg: SetTextEditorMode, _ctx: &mut Self::Context) -> WsResult {
let user_id = self.require_user()?.id;
let setting = db_or_debug_and_return!(
self,
database_actor::user_settings::UpdateUserSetting {
user_id,
mode: msg.mode
}
);
Ok(Some(WsMsg::UserSettingUpdated(setting)))
}
}

View File

@ -149,6 +149,11 @@ impl WebSocketActor {
self.handle_msg(RemoveInvitedUser { user_id }, ctx)?
}
// user settings
WsMsg::UserSettingSetEditorMode(mode) => {
self.handle_msg(user_settings::SetTextEditorMode { mode }, ctx)?
}
// comments
WsMsg::IssueCommentsLoad(issue_id) => {
self.handle_msg(LoadIssueComments { issue_id }, ctx)?
@ -189,8 +194,14 @@ impl WebSocketActor {
},
ctx,
)?,
WsMsg::EpicUpdate(epic_id, name) => {
self.handle_msg(epics::UpdateEpic { epic_id, name }, ctx)?
WsMsg::EpicUpdateName(epic_id, name) => {
self.handle_msg(epics::UpdateEpicName { epic_id, name }, ctx)?
}
WsMsg::EpicUpdateStartsAt(epic_id, starts_at) => {
self.handle_msg(epics::UpdateEpicStartsAt { epic_id, starts_at }, ctx)?
}
WsMsg::EpicUpdateEndsAt(epic_id, ends_at) => {
self.handle_msg(epics::UpdateEpicEndsAt { epic_id, ends_at }, ctx)?
}
WsMsg::EpicDelete(epic_id) => self.handle_msg(epics::DeleteEpic { epic_id }, ctx)?,
WsMsg::EpicTransform(epic_id, issue_type) => self.handle_msg(

View File

@ -8,6 +8,16 @@ macro_rules! db_or_debug_and_return {
};
}
#[macro_export]
macro_rules! db_or_debug_or_fallback {
($s: ident, $msg: expr, $actor_err: expr, $mailbox_err: expr) => {
$crate::actor_or_debug_and_fallback!($s, db, $msg, $actor_err, $mailbox_err)
};
($s: ident, $msg: expr) => {
$crate::actor_or_debug_and_fallback!($s, db, $msg)
};
}
#[macro_export]
macro_rules! mail_or_debug_and_return {
($s: ident, $msg: expr, $actor_err: expr, $mailbox_err: expr) => {

View File

@ -28,9 +28,11 @@ bincode = { version = "*" }
chrono = { version = "0.4", default-features = false, features = ["serde", "wasmbind"] }
uuid = { version = "0.8.1", features = ["serde"] }
futures = "^0.1.26"
futures = "0.3.6"
dotenv = { version = "*" }
wasm-logger = { version = "*" }
log = "*"
[dependencies.wee_alloc]
version = "*"
@ -40,6 +42,9 @@ features = ["static_array_backend"]
version = "*"
features = ["enable-interning"]
[dependencies.wasm-bindgen-futures]
version = "*"
[dependencies.js-sys]
version = "*"
default-features = false

View File

@ -0,0 +1,11 @@
#profile {
> .formContainer {
display: flex;
justify-content: center;
.styledForm {
max-width: 1024px;
width: 100%;
}
}
}

View File

@ -106,6 +106,26 @@
> .epicName {
}
> .timeRange {
display: flex;
justify-content: space-between;
> * {
margin-left: 2px;
margin-right: 2px;
}
> .endsAt {
&.error {
color: var(--danger);
}
&.warning {
color: var(--warning);
}
}
}
> .epicActions {
> .styledButton {
> .styledIcon {

View File

@ -83,3 +83,31 @@
}
}
}
.styledCheckbox.textEditorModeSwitcher {
justify-content: end;
margin-top: 5px;
margin-bottom: 5px;
& > .styledCheckboxChild {
&.mdonly, &.rteonly {
display: flex;
margin-left: 5px;
border: none;
color: var(--textDark);
&.selected {
color: var(--borderInputFocus);
.styledIcon {
color: var(--borderInputFocus);
}
}
.styledIcon {
font-size: 24px;
color: var(--textDark);
}
}
}
}

View File

@ -48,6 +48,7 @@
}
.styledInput.invalid {
input {
border: 1px solid var(--danger);
box-shadow: none;
@ -55,4 +56,9 @@
border: 1px solid var(--danger);
box-shadow: none;
}
}
.error {
color: var(--danger);
}
}

View File

@ -34,3 +34,4 @@
@import "css/users.scss";
@import "css/invite.scss";
@import "css/reports.scss";
@import "css/profile.scss";

View File

@ -1,7 +1,7 @@
use jirs_data::{EpicId, IssueStatusId, WsMsg};
use seed::prelude::WebSocketMessage;
use crate::components::styled_editor::Mode as TabMode;
use crate::components::styled_md_editor::MdEditorMode as TabMode;
use crate::FieldId;
#[derive(Clone, Debug, PartialEq)]

View File

@ -10,9 +10,11 @@ pub mod styled_icon;
pub mod styled_image_input;
pub mod styled_input;
pub mod styled_link;
pub mod styled_md_editor;
pub mod styled_modal;
// pub mod styled_rte;
pub mod styled_rte;
pub mod styled_select;
pub mod styled_select_child;
pub mod styled_textarea;
pub mod styled_tip;
pub mod styled_tooltip;

View File

@ -34,6 +34,7 @@ pub struct ChildBuilder<'l> {
pub value: u32,
pub selected: bool,
pub class_list: &'l str,
pub icon: Node<Msg>,
}
impl<'l> Default for ChildBuilder<'l> {
@ -46,6 +47,7 @@ impl<'l> Default for ChildBuilder<'l> {
value: 0,
selected: false,
class_list: "",
icon: Node::Empty,
}
}
}
@ -53,32 +55,32 @@ impl<'l> Default for ChildBuilder<'l> {
impl<'l> ChildBuilder<'l> {
#[inline(always)]
pub fn render(self) -> Node<Msg> {
let ChildBuilder {
field_id,
name,
label,
value,
selected,
class_list,
} = self;
let id = field_id.to_string();
let id = self.field_id.to_string();
let handler: EventHandler<Msg> = {
let id = field_id;
mouse_ev(Ev::Click, move |_| Msg::U32InputChanged(id, value))
let id = self.field_id;
let value = self.value;
mouse_ev(Ev::Click, move |ev| {
ev.stop_propagation();
ev.prevent_default();
Msg::U32InputChanged(id, value)
})
};
div![
C![
"styledCheckboxChild",
class_list,
IF![selected => "selected"]
self.class_list,
IF![self.selected => "selected"]
],
handler,
label![attrs![At::For => format!("{}-{}", id, name)], label],
self.icon,
label![
attrs![At::For => format!("{}-{}", id, self.name)],
self.label
],
input![
attrs![At::Type => "radio", At::Name => name, At::Id => format!("{}-{}", id, name)],
IF![selected => attrs!(At::Checked => selected)]
attrs![At::Type => "radio", At::Name => self.name, At::Id => format!("{}-{}", id, self.name)],
IF![self.selected => attrs!(At::Checked => self.selected)]
],
]
}

View File

@ -19,7 +19,7 @@ pub enum StyledDateTimeChanged {
#[derive(Clone, Debug, PartialOrd, PartialEq)]
pub struct StyledDateTimeInputState {
field_id: FieldId,
pub field_id: FieldId,
pub timestamp: Option<chrono::NaiveDateTime>,
pub popup_visible: bool,
}
@ -187,7 +187,7 @@ impl StyledDateTimeInput {
children: vec![
h2![
left_action,
span![current.format("%B %Y").to_string()],
span![start.format("%B %Y").to_string()],
right_action
],
div![
@ -285,6 +285,7 @@ impl<'l> DayCell<'l> {
ev(Ev::Click, move |ev| {
ev.stop_propagation();
ev.prevent_default();
log::info!("{:?}", date);
Msg::StyledDateTimeInputChanged(
field_id,
StyledDateTimeChanged::DayChanged(Some(date)),

View File

@ -1,124 +1,181 @@
use jirs_data::TextEditorMode;
use seed::prelude::*;
use seed::*;
use crate::components::styled_textarea::StyledTextarea;
use crate::{FieldChange, FieldId, Msg};
use crate::components::styled_checkbox::{ChildBuilder, StyledCheckbox, StyledCheckboxState};
use crate::components::styled_icon::{Icon, StyledIcon};
use crate::components::styled_md_editor::{MdEditorMode, StyledMdEditor, StyledMdEditorState};
use crate::components::styled_rte::{StyledRte, StyledRteState};
use crate::{FieldId, Msg};
#[derive(Debug, Clone, PartialOrd, PartialEq, Hash)]
#[repr(C)]
pub enum Mode {
Editor,
View,
#[derive(Debug)]
pub enum EditorMode {
Md(StyledMdEditorState),
Rte(StyledRteState),
}
#[derive(Debug, Clone, PartialOrd, PartialEq)]
#[derive(Debug)]
pub struct StyledEditorState {
pub mode: Mode,
pub initial_text: String,
pub field_id: FieldId,
pub state: EditorMode,
pub current_mode: StyledCheckboxState,
pub user_mode: TextEditorMode,
}
impl StyledEditorState {
#[inline(always)]
pub fn new<S: Into<String>>(mode: Mode, text: S) -> Self {
pub fn new(field_id: FieldId, user_mode: TextEditorMode, value: &str, html: &str) -> Self {
Self {
mode,
initial_text: text.into(),
current_mode: StyledCheckboxState::new(
field_id.clone(),
match user_mode {
TextEditorMode::RteOnly => TextEditorMode::RteOnly,
TextEditorMode::MdOnly | TextEditorMode::Mixed => TextEditorMode::MdOnly,
}
.into(),
),
state: build_state(user_mode, field_id.clone(), value, html),
field_id,
user_mode,
}
}
pub fn update(&mut self, msg: &Msg, orders: &mut impl Orders<Msg>) {
match msg {
Msg::U32InputChanged(field_id, mode)
if &self.field_id == field_id && self.current_mode.value != *mode =>
{
let mode: TextEditorMode = (*mode).into();
self.state = build_state(mode, self.field_id.clone(), "", "")
}
_ => {}
};
self.current_mode.update(msg);
self.state.update(msg, orders);
}
pub fn set_content(&mut self, text: &str, html: &str) {
match &mut self.state {
EditorMode::Md(md) => {
md.initial_text = text.to_string();
md.html = html.to_string();
}
EditorMode::Rte(rte) => {
rte.value = html.to_string();
}
}
}
}
#[derive(Debug, Clone)]
pub struct StyledEditor<'l> {
pub id: Option<FieldId>,
pub initial_text: &'l str,
pub text: &'l str,
pub html: &'l str,
pub mode: Mode,
pub update_event: Ev,
}
impl<'l> Default for StyledEditor<'l> {
#[inline(always)]
fn default() -> Self {
Self {
id: None,
initial_text: "",
text: "",
html: "",
mode: Mode::Editor,
update_event: Ev::Cached,
}
impl EditorMode {
pub fn update(&mut self, msg: &Msg, orders: &mut impl Orders<Msg>) {
match self {
EditorMode::Md(state) => state.update(msg),
EditorMode::Rte(state) => state.update(msg, orders),
};
}
}
impl<'l> StyledEditor<'l> {
#[inline(always)]
pub fn render(self) -> Node<Msg> {
let StyledEditor {
id,
initial_text,
text: _,
html,
mode,
update_event,
} = self;
/// Build project description text area with styled field wrapper
#[inline(always)]
pub fn render_styled_editor(state: &StyledEditorState) -> Node<Msg> {
let editor = match &state.state {
EditorMode::Md(state) => render_md(state),
EditorMode::Rte(state) => render_rte(state),
};
let switcher = render_mode_switcher(state);
div![switcher, editor]
}
let id = id.expect("Styled Editor requires ID");
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 {
id: Some(id),
height: 40,
value: initial_text,
update_event,
..Default::default()
#[inline(always)]
pub fn render_mode_switcher(state: &StyledEditorState) -> Node<Msg> {
match state.user_mode {
TextEditorMode::Mixed => StyledCheckbox {
options: Some(
vec![TextEditorMode::MdOnly, TextEditorMode::RteOnly]
.into_iter()
.map(|tem| editor_mode_checkbox_option(tem, &state.current_mode)),
),
class_list: "textEditorModeSwitcher",
}
.render();
div![
C!["styledEditor"],
label![
C!["navbar viewTab", IF![mode == Mode::View => "activeTab"]],
attrs![At::For => view_id.as_str()],
"View",
on_view_clicked
],
label![
C!["navbar editorTab", IF![mode == Mode::Editor => "activeTab"]],
attrs![At::For => editor_id.as_str()],
"Editor",
on_editor_clicked
],
seed::input![
id![editor_id.as_str()],
C!["editorRadio"],
attrs![At::Type => "radio"; At::Name => name.as_str(); At::Checked => true],
],
text_area,
seed::input![
id![view_id.as_str()],
C!["viewRadio"],
attrs![ At::Type => "radio"; At::Name => name.as_str();],
IF![mode == Mode::View => attrs![At::Checked => true]]
],
div![
C!["view"],
IF![mode == Mode::Editor => empty![]],
IF![mode == Mode::View => raw![html]],
],
]
.render(),
_ => Node::Empty,
}
}
#[inline(always)]
fn click_handler(field_id: FieldId, new_mode: Mode) -> EventHandler<Msg> {
mouse_ev(Ev::Click, move |ev| {
ev.stop_propagation();
Msg::ModalChanged(FieldChange::TabChanged(field_id, new_mode))
})
fn editor_mode_checkbox_option<'l>(
tem: TextEditorMode,
state: &StyledCheckboxState,
) -> ChildBuilder<'l> {
let value: u32 = tem.into();
ChildBuilder {
field_id: state.field_id.clone(),
selected: state.value == value,
label: match tem {
TextEditorMode::MdOnly => "Simple",
TextEditorMode::RteOnly => "Rich Text Editor",
TextEditorMode::Mixed => "Editor with possibility to switch between modes",
},
class_list: tem.to_str(),
value,
icon: match tem {
TextEditorMode::MdOnly => StyledIcon::from(Icon::MdEditor).render(),
TextEditorMode::RteOnly => StyledIcon::from(Icon::RteEditor).render(),
TextEditorMode::Mixed => Node::Empty,
},
..Default::default()
}
}
/// Build project description text area with styled field wrapper
#[inline(always)]
fn render_md(state: &StyledMdEditorState) -> Node<Msg> {
StyledMdEditor {
id: Some(state.id.clone()),
initial_text: state.initial_text.as_str(),
text: state.initial_text.as_str(),
html: state.html.as_str(),
mode: state.mode.clone(),
update_event: Ev::Change,
}
.render()
}
/// Build project description text area with styled field wrapper
#[inline(always)]
fn render_rte(state: &StyledRteState) -> Node<Msg> {
let id = state.field_id.clone();
StyledRte {
field_id: id,
table_tooltip: Some(&state.table_tooltip),
code_tooltip: Some(&state.code_tooltip),
identifier: Some(state.identifier),
}
.render()
}
fn build_state(
user_mode: TextEditorMode,
field_id: FieldId,
value: &str,
html: &str,
) -> EditorMode {
match user_mode {
TextEditorMode::RteOnly => build_state_rte(field_id),
TextEditorMode::MdOnly | TextEditorMode::Mixed => build_state_md(field_id, value, html),
}
}
fn build_state_rte(field_id: FieldId) -> EditorMode {
EditorMode::Rte(StyledRteState::new(field_id))
}
fn build_state_md(field_id: FieldId, text: &str, html: &str) -> EditorMode {
EditorMode::Md(StyledMdEditorState::new(
field_id,
MdEditorMode::View,
text,
html,
))
}

View File

@ -96,6 +96,9 @@ pub enum Icon {
TextWidth,
Underline,
Undo,
MdEditor,
RteEditor,
}
impl Icon {
@ -197,6 +200,10 @@ impl Icon {
Icon::DoubleLeft => "double-left",
Icon::DoubleRight => "double-right",
// editor
Icon::MdEditor => "icofont-brand-compaq",
Icon::RteEditor => "icofont-compass-alt-4",
}
}
}

View File

@ -117,6 +117,7 @@ pub struct StyledInput<'l, 'm: 'l> {
pub variant: InputVariant,
pub auto_focus: bool,
pub input_handlers: Vec<EventHandler<Msg>>,
pub err_msg: &'static str,
}
impl<'l, 'm: 'l> Default for StyledInput<'l, 'm> {
@ -132,24 +133,7 @@ impl<'l, 'm: 'l> Default for StyledInput<'l, 'm> {
variant: InputVariant::Normal,
auto_focus: false,
input_handlers: vec![],
}
}
}
impl<'l, 'm: 'l> StyledInput<'l, 'm> {
#[inline(always)]
pub fn new_with_id_and_value_and_valid(id: FieldId, value: &'m str, valid: bool) -> Self {
Self {
id: Some(id),
icon: None,
valid,
value,
input_type: None,
input_class_list: "",
wrapper_class_list: "",
variant: InputVariant::Normal,
auto_focus: false,
input_handlers: vec![],
err_msg: "",
}
}
}
@ -168,6 +152,7 @@ impl<'l, 'm: 'l> StyledInput<'l, 'm> {
variant,
auto_focus,
input_handlers,
err_msg,
} = self;
let id = id.expect("Input id is required");
@ -209,6 +194,7 @@ impl<'l, 'm: 'l> StyledInput<'l, 'm> {
on_change,
input_handlers,
],
IF![!valid => div![C!["error"], err_msg]]
]
}
}

View File

@ -0,0 +1,150 @@
use seed::prelude::*;
use seed::*;
use crate::components::styled_textarea::StyledTextarea;
use crate::{FieldChange, FieldId, Msg};
#[derive(Debug, Clone, PartialOrd, PartialEq, Hash)]
#[repr(C)]
pub enum MdEditorMode {
Editor,
View,
}
#[derive(Debug, Clone, PartialOrd, PartialEq)]
pub struct StyledMdEditorState {
pub id: FieldId,
pub mode: MdEditorMode,
pub initial_text: String,
pub html: String,
}
impl StyledMdEditorState {
#[inline(always)]
pub fn new<Text: Into<String>, Html: Into<String>>(
id: FieldId,
mode: MdEditorMode,
text: Text,
html: Html,
) -> Self {
Self {
id,
mode,
initial_text: text.into(),
html: html.into(),
}
}
pub fn update(&mut self, msg: &Msg) {
match msg {
Msg::ModalChanged(FieldChange::TabChanged(field_id, new_mode))
if &self.id == field_id =>
{
self.mode = new_mode.clone();
}
_ => {}
};
}
}
#[derive(Debug, Clone)]
pub struct StyledMdEditor<'l> {
pub id: Option<FieldId>,
pub initial_text: &'l str,
pub text: &'l str,
pub html: &'l str,
pub mode: MdEditorMode,
pub update_event: Ev,
}
impl<'l> Default for StyledMdEditor<'l> {
#[inline(always)]
fn default() -> Self {
Self {
id: None,
initial_text: "",
text: "",
html: "",
mode: MdEditorMode::Editor,
update_event: Ev::Cached,
}
}
}
impl<'l> StyledMdEditor<'l> {
#[inline(always)]
pub fn render(self) -> Node<Msg> {
let StyledMdEditor {
id,
initial_text,
text: _,
html,
mode,
update_event,
} = self;
let id = id.expect("Styled Editor requires ID");
let on_editor_clicked = click_handler(id.clone(), MdEditorMode::Editor);
let on_view_clicked = click_handler(id.clone(), MdEditorMode::View);
let editor_id = format!("editor-{}", id);
let view_id = format!("view-{}", id);
let name = format!("styled-editor-{}", id);
let text_area = StyledTextarea {
id: Some(id),
height: 40,
value: initial_text,
update_event,
..Default::default()
}
.render();
div![
C!["styledEditor"],
label![
C![
"navbar viewTab",
IF![mode == MdEditorMode::View => "activeTab"]
],
attrs![At::For => view_id.as_str()],
"View",
on_view_clicked
],
label![
C![
"navbar editorTab",
IF![mode == MdEditorMode::Editor => "activeTab"]
],
attrs![At::For => editor_id.as_str()],
"Editor",
on_editor_clicked
],
seed::input![
id![editor_id.as_str()],
C!["editorRadio"],
attrs![At::Type => "radio"; At::Name => name.as_str(); At::Checked => true],
],
text_area,
seed::input![
id![view_id.as_str()],
C!["viewRadio"],
attrs![ At::Type => "radio"; At::Name => name.as_str();],
IF![mode == MdEditorMode::View => attrs![At::Checked => true]]
],
div![
C!["view"],
IF![mode == MdEditorMode::Editor => empty![]],
IF![mode == MdEditorMode::View => raw![html]],
],
]
}
}
#[inline(always)]
fn click_handler(field_id: FieldId, new_mode: MdEditorMode) -> EventHandler<Msg> {
mouse_ev(Ev::Click, move |ev| {
ev.stop_propagation();
Msg::ModalChanged(FieldChange::TabChanged(field_id, new_mode))
})
}

View File

@ -1,11 +1,11 @@
use seed::prelude::*;
use seed::*;
use crate::components::styled_button::StyledButton;
use crate::components::styled_button::{ButtonVariant, StyledButton};
use crate::components::styled_icon::{Icon, StyledIcon};
use crate::components::styled_select::{StyledSelect, StyledSelectState};
use crate::components::styled_tooltip::StyledTooltip;
use crate::shared::ToChild;
use crate::components::styled_select::{SelectVariant, StyledSelect, StyledSelectState};
use crate::components::styled_select_child::StyledSelectOption;
use crate::components::styled_tooltip::{StyledTooltip, TooltipVariant};
use crate::{ButtonId, FieldId, Msg, RteField};
#[derive(Debug, Clone, Copy)]
@ -154,7 +154,7 @@ impl RteMsg {
if let Ok(Some(el)) = res {
if let Ok(el) = el.dyn_into::<web_sys::HtmlElement>() {
if let Err(e) = el.focus() {
log!(e)
error!(e)
}
}
}
@ -243,7 +243,7 @@ pub struct StyledRteState {
pub table_tooltip: StyledRteTableState,
pub code_tooltip: StyledRteCodeState,
range: Option<web_sys::Range>,
identifier: uuid::Uuid,
pub identifier: uuid::Uuid,
}
impl StyledRteState {
@ -277,9 +277,8 @@ impl StyledRteState {
Some(doc) => doc,
_ => return,
};
match doc.exec_command_with_show_ui_and_value(name, false, param) {
Ok(_) => {}
Err(e) => log!(e),
if let Err(e) = doc.exec_command_with_show_ui_and_value(name, false, param) {
error!(e)
}
if self.restore_range().is_err() {
return;
@ -325,7 +324,7 @@ impl StyledRteState {
}
view.set_inner_html(code.as_str());
if let Err(e) = r.insert_node(&view) {
log!(e);
error!(e);
}
self.code_tooltip.reset();
@ -365,11 +364,11 @@ impl StyledRteState {
RteTableBodyBuilder::new(*cols, *rows).to_string().as_str(),
);
if let Err(e) = r.insert_node(&table) {
log!(e);
error!(e);
}
self.schedule_focus(orders);
}
_ => log!(m),
_ => log::error!("unknown rte command {:?}", m),
},
};
// orders.skip().send_msg(Msg::StrInputChanged(
@ -382,7 +381,7 @@ impl StyledRteState {
self.range = seed::html_document()
.get_selection()
.ok()
.unwrap_or_else(|| None)
.unwrap_or(None)
.and_then(|s| s.get_range_at(0).ok());
}
@ -391,7 +390,7 @@ impl StyledRteState {
let sel = doc
.get_selection()
.ok()
.unwrap_or_else(|| None)
.unwrap_or(None)
.ok_or_else(|| "Restoring selection failed. Unable to obtain select".to_string())?;
let r = self
.range
@ -415,52 +414,25 @@ impl StyledRteState {
}
pub struct StyledRte<'component> {
field_id: FieldId,
table_tooltip: Option<&'component StyledRteTableState>,
identifier: Option<uuid::Uuid>,
code_tooltip: Option<&'component StyledRteCodeState>,
pub field_id: FieldId,
pub table_tooltip: Option<&'component StyledRteTableState>,
pub identifier: Option<uuid::Uuid>,
pub code_tooltip: Option<&'component StyledRteCodeState>,
}
impl<'component> StyledRte<'component> {
pub fn build(field_id: FieldId) -> StyledRteBuilder<'component> {
StyledRteBuilder {
field_id,
value: String::new(),
impl<'component> Default for StyledRte<'component> {
fn default() -> Self {
Self {
field_id: FieldId::NoField,
table_tooltip: None,
code_tooltip: None,
identifier: None,
code_tooltip: None,
}
}
}
pub struct StyledRteBuilder<'outer> {
field_id: FieldId,
value: String,
table_tooltip: Option<&'outer StyledRteTableState>,
code_tooltip: Option<&'outer StyledRteCodeState>,
identifier: Option<uuid::Uuid>,
}
impl<'outer> StyledRteBuilder<'outer> {
pub fn state(mut self, state: &'outer StyledRteState) -> Self {
self.value = state.value.clone();
self.table_tooltip = Some(&state.table_tooltip);
self.code_tooltip = Some(&state.code_tooltip);
self.identifier = Some(state.identifier);
self
}
pub fn build(self) -> StyledRte<'outer> {
StyledRte {
field_id: self.field_id,
table_tooltip: self.table_tooltip,
identifier: self.identifier,
code_tooltip: self.code_tooltip,
}
}
}
pub fn render(values: StyledRte) -> Node<Msg> {
impl<'outer> StyledRte<'outer> {
pub fn render(self) -> Node<Msg> {
/*{
let _brush_button = styled_rte_button(
"Brush",
@ -539,20 +511,16 @@ pub fn render(values: StyledRte) -> Node<Msg> {
}*/
// let field_id = values.field_id.clone();
let capture_event = ev(Ev::KeyDown, |ev| {
ev.stop_propagation();
None as Option<Msg>
});
// let capture_change = ev(Ev::Input, |ev| {
// ev.stop_propagation();
// Some(Msg::StrInputChanged(field_id, "".to_string()))
// });
let id = values.identifier.unwrap_or_default().to_string();
let id = self.identifier.unwrap_or_default().to_string();
let click_handler = {
let field_id = values.field_id.clone();
let (rows, cols) = values
let field_id = self.field_id.clone();
let (rows, cols) = self
.table_tooltip
.as_ref()
.map(|t| (t.rows, t.cols))
@ -600,7 +568,7 @@ pub fn render(values: StyledRte) -> Node<Msg> {
_ => {
let target = ev.target().unwrap();
let h = seed::to_html_el(&target);
log!(h);
error!("unknown rte command for element", h);
unreachable!();
}
};
@ -609,7 +577,7 @@ pub fn render(values: StyledRte) -> Node<Msg> {
};
let change_handler = {
let field_id = values.field_id.clone();
let field_id = self.field_id.clone();
ev(Ev::Change, move |event| {
event
.target()
@ -627,8 +595,8 @@ pub fn render(values: StyledRte) -> Node<Msg> {
attrs![At::Id => id],
div![
C!["bar"],
first_row(click_handler.clone()),
second_row(&values, click_handler, change_handler),
Self::first_row(click_handler.clone()),
self.second_row(click_handler, change_handler),
/* brush_button,
* color_bucket_button,
* color_picker_button,
@ -641,36 +609,35 @@ pub fn render(values: StyledRte) -> Node<Msg> {
div![
C!["editorWrapper"],
div![
C!["editor"],
C!["editor", self.field_id.to_str()],
attrs![At::ContentEditable => true],
capture_event,
// capture_change
],
]
]
}
}
fn first_row(click_handler: EventHandler<Msg>) -> Node<Msg> {
fn first_row(click_handler: EventHandler<Msg>) -> Node<Msg> {
let justify = {
let justify_all_button = styled_rte_button(
let justify_all_button = Self::styled_rte_button(
"Justify All",
ButtonId::JustifyAll,
Icon::JustifyAll,
click_handler.clone(),
);
let justify_center_button = styled_rte_button(
let justify_center_button = Self::styled_rte_button(
"Justify Center",
ButtonId::JustifyCenter,
Icon::JustifyCenter,
click_handler.clone(),
);
let justify_left_button = styled_rte_button(
let justify_left_button = Self::styled_rte_button(
"Justify Left",
ButtonId::JustifyLeft,
Icon::JustifyLeft,
click_handler.clone(),
);
let justify_right_button = styled_rte_button(
let justify_right_button = Self::styled_rte_button(
"Justify Right",
ButtonId::JustifyRight,
Icon::JustifyRight,
@ -687,9 +654,9 @@ fn first_row(click_handler: EventHandler<Msg>) -> Node<Msg> {
let system = {
let redo_button =
styled_rte_button("Redo", ButtonId::Redo, Icon::Redo, click_handler.clone());
Self::styled_rte_button("Redo", ButtonId::Redo, Icon::Redo, click_handler.clone());
let undo_button =
styled_rte_button("Undo", ButtonId::Undo, Icon::Undo, click_handler.clone());
Self::styled_rte_button("Undo", ButtonId::Undo, Icon::Undo, click_handler.clone());
/*let field_id = values.field_id.clone();
let clip_board_button = styled_rte_button(
"Paste",
@ -728,43 +695,43 @@ fn first_row(click_handler: EventHandler<Msg>) -> Node<Msg> {
};
let formatting = {
let remove_formatting = styled_rte_button(
let remove_formatting = Self::styled_rte_button(
"Remove format",
ButtonId::RemoveFormat,
Icon::EraserAlt,
click_handler.clone(),
);
let bold_button =
styled_rte_button("Bold", ButtonId::Bold, Icon::Bold, click_handler.clone());
let italic_button = styled_rte_button(
Self::styled_rte_button("Bold", ButtonId::Bold, Icon::Bold, click_handler.clone());
let italic_button = Self::styled_rte_button(
"Italic",
ButtonId::Italic,
Icon::Italic,
click_handler.clone(),
);
let underline_button = styled_rte_button(
let underline_button = Self::styled_rte_button(
"Underline",
ButtonId::Underscore,
Icon::Underline,
click_handler.clone(),
);
let strike_through_button = styled_rte_button(
let strike_through_button = Self::styled_rte_button(
"StrikeThrough",
ButtonId::Strikethrough,
Icon::StrikeThrough,
click_handler.clone(),
);
let subscript_button = styled_rte_button(
let subscript_button = Self::styled_rte_button(
"Subscript",
ButtonId::Subscript,
Icon::Subscript,
click_handler.clone(),
);
let superscript_button = styled_rte_button(
let superscript_button = Self::styled_rte_button(
"Superscript",
ButtonId::Superscript,
Icon::Superscript,
@ -784,13 +751,13 @@ fn first_row(click_handler: EventHandler<Msg>) -> Node<Msg> {
};
div![C!["row firstRow"], system, formatting, justify]
}
}
fn second_row(
values: &StyledRte,
fn second_row(
&self,
click_handler: EventHandler<Msg>,
change_handler: EventHandler<Msg>,
) -> Node<Msg> {
) -> Node<Msg> {
/*let align_group = {
let field_id = values.field_id.clone();
let align_center_button = styled_rte_button(
@ -829,19 +796,20 @@ fn second_row(
let font_group = {
let _font_button =
styled_rte_button("Font", ButtonId::Font, Icon::Font, click_handler.clone());
Self::styled_rte_button("Font", ButtonId::Font, Icon::Font, click_handler.clone());
let options: Vec<Node<Msg>> = HeadingSize::all()
.into_iter()
.iter()
.map(|h| {
let field_id = values.field_id.clone();
let button = StyledButton::build()
.text(h.as_str())
.on_click(mouse_ev(Ev::Click, move |ev| {
let field_id = self.field_id.clone();
let button = StyledButton {
text: Some(h.as_str()),
on_click: Some(mouse_ev(Ev::Click, move |ev| {
ev.prevent_default();
Some(Msg::Rte(field_id, RteMsg::InsertHeading(*h)))
}))
.empty()
.build()
})),
variant: ButtonVariant::Empty,
..Default::default()
}
.render();
span![C!["headingOption"], button]
})
@ -876,15 +844,15 @@ fn second_row(
};
let insert_group = {
let table_tooltip = table_tooltip(values, click_handler.clone(), change_handler);
let table_tooltip = self.table_tooltip(click_handler.clone(), change_handler);
let listing_dots = styled_rte_button(
let listing_dots = Self::styled_rte_button(
"Listing dots",
ButtonId::ListingDots,
Icon::ListingDots,
click_handler.clone(),
);
let listing_number = styled_rte_button(
let listing_number = Self::styled_rte_button(
"Listing number",
ButtonId::ListingNumber,
Icon::ListingNumber,
@ -900,23 +868,27 @@ fn second_row(
}),
);*/
let mut table_button =
styled_rte_button("Table", ButtonId::Table, Icon::Table, click_handler.clone());
let mut table_button = Self::styled_rte_button(
"Table",
ButtonId::Table,
Icon::Table,
click_handler.clone(),
);
table_button.add_child(table_tooltip);
let paragraph_button = styled_rte_button(
let paragraph_button = Self::styled_rte_button(
"Paragraph",
ButtonId::Paragraph,
Icon::Paragraph,
click_handler.clone(),
);
let mut code_alt_button = styled_rte_button(
let mut code_alt_button = Self::styled_rte_button(
"Insert code",
ButtonId::CodeAlt,
Icon::CodeAlt,
click_handler.clone(),
);
code_alt_button.add_child(code_tooltip(values, click_handler.clone()));
code_alt_button.add_child(self.code_tooltip(click_handler.clone()));
div![
C!["group insert"],
@ -930,14 +902,14 @@ fn second_row(
};
let indent_outdent = {
let indent_button = styled_rte_button(
let indent_button = Self::styled_rte_button(
"Indent",
ButtonId::Indent,
Icon::Indent,
click_handler.clone(),
);
let outdent_button =
styled_rte_button("Outdent", ButtonId::Outdent, Icon::Outdent, click_handler);
Self::styled_rte_button("Outdent", ButtonId::Outdent, Icon::Outdent, click_handler);
div![C!["group indentOutdent"], indent_button, outdent_button]
};
@ -948,14 +920,14 @@ fn second_row(
insert_group,
indent_outdent
]
}
}
fn table_tooltip(
values: &StyledRte,
fn table_tooltip(
&self,
click_handler: EventHandler<Msg>,
change_handler: EventHandler<Msg>,
) -> Node<Msg> {
let (visible, rows, cols) = values
_change_handler: EventHandler<Msg>,
) -> Node<Msg> {
let (visible, rows, cols) = self
.table_tooltip
.map(
|StyledRteTableState {
@ -967,7 +939,7 @@ fn table_tooltip(
.unwrap_or_default();
let on_rows_change = {
let field_id = values.field_id.clone();
let field_id = self.field_id.clone();
input_ev(Ev::Change, move |v| {
v.parse::<u16>()
.ok()
@ -976,7 +948,7 @@ fn table_tooltip(
};
let on_cols_change = {
let field_id = values.field_id.clone();
let field_id = self.field_id.clone();
input_ev(Ev::Change, move |v| {
v.parse::<u16>()
.ok()
@ -984,33 +956,35 @@ fn table_tooltip(
})
};
let close_table_tooltip = StyledButton::build()
.button_id(ButtonId::CloseRteTableTooltip)
.empty()
.icon(Icon::Close)
.on_click(click_handler.clone())
.build()
let close_table_tooltip = StyledButton {
button_id: Some(ButtonId::CloseRteTableTooltip),
variant: ButtonVariant::Empty,
icon: Some(StyledIcon::from(Icon::Close).render()),
on_click: Some(click_handler.clone()),
..Default::default()
}
.render();
let on_submit = click_handler;
StyledTooltip::build()
.table_tooltip()
.visible(visible)
.add_child(h2![span!["Add table"], close_table_tooltip])
.add_child(div![C!["inputs"], span!["Rows"], seed::input![
StyledTooltip {
variant: TooltipVariant::TableBuilder,
visible,
children: vec![
h2![span!["Add table"], close_table_tooltip],
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![
]],
div![
C!["inputs"],
span!["Columns"],
seed::input![
attrs![At::Type => "range"; At::Step => "1"; At::Min => "1"; At::Max => "10"; At::Value => cols],
on_cols_change
]
])
.add_child({
],
{
let body: Vec<Node<Msg>> = (0..rows)
.map(|_row| {
let tds: Vec<Node<Msg>> = (0..cols)
@ -1024,19 +998,20 @@ fn table_tooltip(
seed::table![tbody![body]],
input![attrs![At::Type => "button"; At::Id => "rteInsertTable"; At::Value => "Insert"], on_submit],
]
})
.build()
.render()
}
}
],
class_list: "",
}.render()
}
fn code_tooltip(values: &StyledRte, click_handler: EventHandler<Msg>) -> Node<Msg> {
let (visible, lang) = values
fn code_tooltip(&self, click_handler: EventHandler<Msg>) -> Node<Msg> {
let (visible, lang) = self
.code_tooltip
.as_ref()
.map(|StyledRteCodeState { visible, lang, .. }| (*visible, Some(lang)))
.unwrap_or_default();
let languages = values
let languages = self
.code_tooltip
.map(|ct| ct.languages().as_slice())
.unwrap_or_default();
@ -1047,31 +1022,46 @@ fn code_tooltip(values: &StyledRte, click_handler: EventHandler<Msg>) -> Node<Ms
.map(|(idx, label)| (label.to_string(), idx as u32))
.collect();
let select_lang_node = StyledSelect::build()
.try_state(lang)
.selected(
lang.and_then(|l| l.values.get(0))
let select_lang_node = StyledSelect {
id: FieldId::Rte(RteField::CodeLang(Box::new(self.field_id.clone()))),
variant: SelectVariant::Normal,
dropdown_width: None,
name: "",
valid: true,
options: Some(options.iter().map(|opt| StyledSelectOption {
text: Some(opt.0.as_str()),
value: opt.1,
..Default::default()
})),
selected: lang
.and_then(|l| l.values.get(0))
.and_then(|n| options.get(*n as usize))
.map(|v| vec![v.to_child()])
.map(|v| {
vec![StyledSelectOption {
text: Some(v.0.as_str()),
value: v.1,
..Default::default()
}]
})
.unwrap_or_default(),
)
.options(options.iter().map(|opt| opt.to_child()).collect())
.normal()
.build(FieldId::Rte(RteField::CodeLang(Box::new(
values.field_id.clone(),
))))
text_filter: lang.map(|l| l.text_filter.as_str()).unwrap_or_default(),
opened: lang.map(|l| l.opened).unwrap_or_default(),
clearable: true,
..Default::default()
}
.render();
let close_tooltip = StyledButton::build()
.empty()
.icon(Icon::Close)
.button_id(ButtonId::RteInsertCode)
.on_click(click_handler.clone())
.build()
let close_tooltip = StyledButton {
button_id: Some(ButtonId::RteInsertCode),
icon: Some(StyledIcon::from(Icon::Close).render()),
variant: ButtonVariant::Empty,
on_click: Some(click_handler.clone()),
..Default::default()
}
.render();
let input = {
let field_id = values.field_id.clone();
let field_id = self.field_id.clone();
let on_change = ev(Ev::Change, move |ev| {
ev.stop_propagation();
let target = ev.target().unwrap();
@ -1083,38 +1073,44 @@ fn code_tooltip(values: &StyledRte, click_handler: EventHandler<Msg>) -> Node<Ms
};
let actions = {
let insert = StyledButton::build()
.button_id(ButtonId::RteInjectCode)
.on_click(click_handler)
.text("Insert")
.build()
let insert = StyledButton {
button_id: Some(ButtonId::RteInjectCode),
on_click: Some(click_handler),
text: Some("Insert"),
..Default::default()
}
.render();
div![insert]
};
StyledTooltip::build()
.code_tooltip()
.visible(visible)
.add_child(h2!["Insert Code", close_tooltip])
.add_child(select_lang_node)
.add_child(input)
.add_child(actions)
.build()
StyledTooltip {
variant: TooltipVariant::CodeBuilder,
children: vec![
h2!["Insert Code", close_tooltip],
select_lang_node,
input,
actions,
],
visible,
class_list: "",
}
.render()
}
}
fn styled_rte_button(
fn styled_rte_button(
title: &str,
button_id: ButtonId,
icon: Icon,
handler: EventHandler<Msg>,
) -> Node<Msg> {
let button = StyledButton::build()
.button_id(button_id)
.icon(StyledIcon::build(icon).build())
.on_click(handler)
.empty()
.build()
) -> Node<Msg> {
let button = StyledButton {
button_id: Some(button_id),
icon: Some(StyledIcon::from(icon).render()),
on_click: Some(handler),
variant: ButtonVariant::Empty,
..Default::default()
}
.render();
span![C!["styledRteButton"], attrs![At::Title => title], button]
}
}

View File

@ -23,7 +23,7 @@ impl<'l> Default for StyledTextarea<'l> {
max_height: 0,
value: "",
class_list: "",
update_event: Ev::Cached,
update_event: Ev::Change,
placeholder: "",
disable_auto_resize: false,
}
@ -67,24 +67,25 @@ impl<'l> StyledTextarea<'l> {
));
}
let handler_disable_auto_resize = disable_auto_resize;
let resize_handler = ev(Ev::KeyUp, move |event| {
event.stop_propagation();
if handler_disable_auto_resize {
return None as Option<Msg>;
}
let target = event.target().unwrap();
let textarea = seed::to_textarea(&target);
let value = textarea.value();
let min_height =
get_min_height(value.as_str(), height as f64, handler_disable_auto_resize);
textarea
.style()
.set_css_text(format!("height: {min_height}px", min_height = min_height).as_str());
None as Option<Msg>
});
// let handler_disable_auto_resize = disable_auto_resize;
// let resize_handler = ev(Ev::KeyUp, move |event| {
// event.stop_propagation();
// if handler_disable_auto_resize {
// return None as Option<Msg>;
// }
//
// let target = event.target().unwrap();
// let textarea = seed::to_textarea(&target);
// let value = textarea.value();
// let min_height =
// get_min_height(value.as_str(), height as f64,
// handler_disable_auto_resize);
//
// textarea
// .style()
// .set_css_text(format!("height: {min_height}px", min_height =
// min_height).as_str()); None as Option<Msg>
// });
let handler_disable_auto_resize = disable_auto_resize;
let text_input_handler = {
@ -122,10 +123,11 @@ impl<'l> StyledTextarea<'l> {
At::AutoFocus => "true";
At::Style => style_list.join(";");
At::Placeholder => placeholder;
At::Rows => if disable_auto_resize { "5" } else { "auto" }
At::Rows => if disable_auto_resize { "5" } else { "auto" },
At::Data => height
],
value,
resize_handler,
// resize_handler,
text_input_handler,
]
]
@ -139,6 +141,18 @@ const PADDING_TOP_BOTTOM: f64 = 17f64;
const BORDER_TOP_BOTTOM: f64 = 2f64;
const ADDITIONAL_HEIGHT: f64 = PADDING_TOP_BOTTOM + BORDER_TOP_BOTTOM;
pub fn handle_resize(target: &web_sys::Element) -> Option<Msg> {
let height: usize = target.get_attribute("data")?.parse().ok()?;
let textarea = seed::to_textarea(target);
let value = textarea.value();
let min_height = get_min_height(value.as_str(), height as f64, false);
textarea
.style()
.set_css_text(format!("height: {min_height}px", min_height = min_height).as_str());
None
}
fn get_min_height(value: &str, min_height: f64, disable_auto_resize: bool) -> f64 {
if disable_auto_resize {
return min_height;

View File

@ -0,0 +1,22 @@
use seed::prelude::*;
use seed::*;
use crate::model::Model;
use crate::{BuildMsg, Msg};
pub fn styled_tip<B>(letter: char, model: &Model, builder: B) -> Node<Msg>
where
B: BuildMsg + 'static,
{
model
.key_triggers
.borrow_mut()
.insert(letter, Box::new(builder));
div![
C!["proTip"],
strong![C!["strong"], "Pro tip: "],
"press ",
span![C!["tipLetter", letter.to_string()], letter.to_string()],
" to comment"
]
}

View File

@ -86,6 +86,7 @@ impl ButtonId {
#[derive(Clone, Debug, PartialOrd, PartialEq, Hash)]
pub enum FieldId {
NoField,
SignIn(SignInFieldId),
SignUp(SignUpFieldId),
Invite(InviteFieldId),
@ -104,115 +105,103 @@ pub enum FieldId {
Rte(RteField),
}
impl std::fmt::Display for FieldId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
impl FieldId {
pub fn to_str(&self) -> &'static str {
match self {
FieldId::NoField => "",
FieldId::EditIssueModal(sub) => match sub {
EditIssueModalSection::Issue(IssueFieldId::Type) => {
f.write_str("issueTypeEditModalTop")
}
EditIssueModalSection::Issue(IssueFieldId::Title) => {
f.write_str("titleIssueEditModal")
}
EditIssueModalSection::Issue(IssueFieldId::Type) => "issueTypeEditModalTop",
EditIssueModalSection::Issue(IssueFieldId::Title) => "titleIssueEditModal",
EditIssueModalSection::Issue(IssueFieldId::Description) => {
f.write_str("descriptionIssueEditModal")
}
EditIssueModalSection::Issue(IssueFieldId::IssueStatusId) => {
f.write_str("statusIssueEditModal")
}
EditIssueModalSection::Issue(IssueFieldId::Assignees) => {
f.write_str("assigneesIssueEditModal")
}
EditIssueModalSection::Issue(IssueFieldId::Reporter) => {
f.write_str("reporterIssueEditModal")
}
EditIssueModalSection::Issue(IssueFieldId::Priority) => {
f.write_str("priorityIssueEditModal")
}
EditIssueModalSection::Issue(IssueFieldId::Estimate) => {
f.write_str("estimateIssueEditModal")
}
EditIssueModalSection::Issue(IssueFieldId::TimeSpent) => {
f.write_str("timeSpendIssueEditModal")
"descriptionIssueEditModal"
}
EditIssueModalSection::Issue(IssueFieldId::IssueStatusId) => "statusIssueEditModal",
EditIssueModalSection::Issue(IssueFieldId::Assignees) => "assigneesIssueEditModal",
EditIssueModalSection::Issue(IssueFieldId::Reporter) => "reporterIssueEditModal",
EditIssueModalSection::Issue(IssueFieldId::Priority) => "priorityIssueEditModal",
EditIssueModalSection::Issue(IssueFieldId::Estimate) => "estimateIssueEditModal",
EditIssueModalSection::Issue(IssueFieldId::TimeSpent) => "timeSpendIssueEditModal",
EditIssueModalSection::Issue(IssueFieldId::TimeRemaining) => {
f.write_str("timeRemainingIssueEditModal")
}
EditIssueModalSection::Comment(CommentFieldId::Body) => {
f.write_str("editIssue-commentBody")
"timeRemainingIssueEditModal"
}
EditIssueModalSection::Comment(CommentFieldId::Body) => "editIssue-commentBody",
EditIssueModalSection::Issue(IssueFieldId::ListPosition) => {
f.write_str("editIssue-listPosition")
}
EditIssueModalSection::Issue(IssueFieldId::EpicName) => {
f.write_str("editIssue-epicName")
"editIssue-listPosition"
}
EditIssueModalSection::Issue(IssueFieldId::EpicName) => "editIssue-epicName",
EditIssueModalSection::Issue(IssueFieldId::EpicStartsAt) => {
f.write_str("editIssue-epicStartsAt")
}
EditIssueModalSection::Issue(IssueFieldId::EpicEndsAt) => {
f.write_str("editIssue-epicEndsAt")
"editIssue-epicStartsAt"
}
EditIssueModalSection::Issue(IssueFieldId::EpicEndsAt) => "editIssue-epicEndsAt",
},
FieldId::AddIssueModal(sub) => match sub {
IssueFieldId::Type => f.write_str("issueTypeAddIssueModal"),
IssueFieldId::Title => f.write_str("summaryAddIssueModal"),
IssueFieldId::Description => f.write_str("descriptionAddIssueModal"),
IssueFieldId::Reporter => f.write_str("reporterAddIssueModal"),
IssueFieldId::Assignees => f.write_str("assigneesAddIssueModal"),
IssueFieldId::Priority => f.write_str("issuePriorityAddIssueModal"),
IssueFieldId::IssueStatusId => f.write_str("addIssueModal-status"),
IssueFieldId::Estimate => f.write_str("addIssueModal-estimate"),
IssueFieldId::TimeSpent => f.write_str("addIssueModal-timeSpend"),
IssueFieldId::TimeRemaining => f.write_str("addIssueModal-timeRemaining"),
IssueFieldId::ListPosition => f.write_str("addIssueModal-listPosition"),
IssueFieldId::EpicName => f.write_str("addIssueModal-epicName"),
IssueFieldId::EpicStartsAt => f.write_str("addIssueModal-epicStartsAt"),
IssueFieldId::EpicEndsAt => f.write_str("addIssueModal-epicEndsAt"),
IssueFieldId::Type => "issueTypeAddIssueModal",
IssueFieldId::Title => "summaryAddIssueModal",
IssueFieldId::Description => "descriptionAddIssueModal",
IssueFieldId::Reporter => "reporterAddIssueModal",
IssueFieldId::Assignees => "assigneesAddIssueModal",
IssueFieldId::Priority => "issuePriorityAddIssueModal",
IssueFieldId::IssueStatusId => "addIssueModal-status",
IssueFieldId::Estimate => "addIssueModal-estimate",
IssueFieldId::TimeSpent => "addIssueModal-timeSpend",
IssueFieldId::TimeRemaining => "addIssueModal-timeRemaining",
IssueFieldId::ListPosition => "addIssueModal-listPosition",
IssueFieldId::EpicName => "addIssueModal-epicName",
IssueFieldId::EpicStartsAt => "addIssueModal-epicStartsAt",
IssueFieldId::EpicEndsAt => "addIssueModal-epicEndsAt",
},
FieldId::TextFilterBoard => f.write_str("textFilterBoard"),
FieldId::CopyButtonLabel => f.write_str("copyButtonLabel"),
FieldId::TextFilterBoard => "textFilterBoard",
FieldId::CopyButtonLabel => "copyButtonLabel",
FieldId::ProjectSettings(sub) => match sub {
ProjectFieldId::Name => f.write_str("projectSettings-name"),
ProjectFieldId::Url => f.write_str("projectSettings-url"),
ProjectFieldId::Description => f.write_str("projectSettings-description"),
ProjectFieldId::Category => f.write_str("projectSettings-category"),
ProjectFieldId::TimeTracking => f.write_str("projectSettings-timeTracking"),
ProjectFieldId::IssueStatusName => f.write_str("projectSettings-issueStatusName"),
ProjectFieldId::Name => "projectSettings-name",
ProjectFieldId::Url => "projectSettings-url",
ProjectFieldId::Description => "projectSettings-description",
ProjectFieldId::Category => "projectSettings-category",
ProjectFieldId::TimeTracking => "projectSettings-timeTracking",
ProjectFieldId::IssueStatusName => "projectSettings-issueStatusName",
ProjectFieldId::DescriptionMode => "projectSettings-descriptionMode",
},
FieldId::SignIn(sub) => match sub {
SignInFieldId::Email => f.write_str("login-email"),
SignInFieldId::Username => f.write_str("login-username"),
SignInFieldId::Token => f.write_str("login-token"),
SignInFieldId::Email => "login-email",
SignInFieldId::Username => "login-username",
SignInFieldId::Token => "login-token",
},
FieldId::SignUp(sub) => match sub {
SignUpFieldId::Username => f.write_str("signUp-email"),
SignUpFieldId::Email => f.write_str("signUp-username"),
SignUpFieldId::Username => "signUp-email",
SignUpFieldId::Email => "signUp-username",
},
FieldId::Invite(sub) => match sub {
InviteFieldId::Token => f.write_str("invite-token"),
InviteFieldId::Token => "invite-token",
},
FieldId::Users(sub) => match sub {
UsersFieldId::Username => f.write_str("users-username"),
UsersFieldId::Email => f.write_str("users-email"),
UsersFieldId::UserRole => f.write_str("users-userRole"),
UsersFieldId::Avatar => f.write_str("users-avatar"),
UsersFieldId::CurrentProject => f.write_str("users-currentProject"),
UsersFieldId::Username => "users-username",
UsersFieldId::Email => "users-email",
UsersFieldId::UserRole => "users-userRole",
UsersFieldId::Avatar => "users-avatar",
UsersFieldId::CurrentProject => "users-currentProject",
UsersFieldId::TextEditorMode => "users-textEditorMode",
},
FieldId::Profile(sub) => match sub {
UsersFieldId::Username => f.write_str("profile-username"),
UsersFieldId::Email => f.write_str("profile-email"),
UsersFieldId::UserRole => f.write_str("profile-userRole"),
UsersFieldId::Avatar => f.write_str("profile-avatar"),
UsersFieldId::CurrentProject => f.write_str("profile-currentProject"),
UsersFieldId::Username => "profile-username",
UsersFieldId::Email => "profile-email",
UsersFieldId::UserRole => "profile-userRole",
UsersFieldId::Avatar => "profile-avatar",
UsersFieldId::CurrentProject => "profile-currentProject",
UsersFieldId::TextEditorMode => "profile-textEditorMode",
},
FieldId::EditEpic(sub) => match sub {
EpicFieldId::Name => f.write_str("epicEpic-name"),
EpicFieldId::StartsAt => f.write_str("epicEpic-startsAt"),
EpicFieldId::EndsAt => f.write_str("epicEpic-endsAt"),
EpicFieldId::TransformInto => f.write_str("epicEpic-transformInto"),
EpicFieldId::Name => "epicEpic-name",
EpicFieldId::StartsAt => "epicEpic-startsAt",
EpicFieldId::EndsAt => "epicEpic-endsAt",
EpicFieldId::TransformInto => "epicEpic-transformInto",
},
FieldId::Rte(..) => f.write_str("rte"),
FieldId::Rte(..) => "rte",
}
}
}
impl std::fmt::Display for FieldId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.to_str())
}
}

View File

@ -6,13 +6,14 @@ pub use fields::*;
pub use images::*;
use jirs_data::*;
use seed::prelude::*;
use seed::*;
use web_sys::File;
use crate::components::styled_date_time_input::StyledDateTimeChanged;
use crate::components::styled_rte::RteMsg;
use crate::components::styled_select::StyledSelectChanged;
use crate::components::styled_tooltip;
use crate::components::styled_tooltip::{TooltipVariant as StyledTooltip, TooltipVariant};
use crate::modals::DebugMsg;
use crate::model::{ModalType, Model, Page};
use crate::shared::{go_to_board, go_to_login};
use crate::ws::{flush_queue, open_socket, read_incoming, send_ws_msg};
@ -42,6 +43,7 @@ pub enum ResourceKind {
Epic,
Project,
User,
UserSetting,
UserProject,
Message,
Comment,
@ -58,17 +60,19 @@ pub enum OperationKind {
SingleModified,
}
pub trait BuildMsg: std::fmt::Debug {
fn build(&self) -> Msg;
}
#[derive(Debug)]
pub enum Msg {
GlobalKeyDown {
key: String,
shift: bool,
ctrl: bool,
alt: bool,
},
#[cfg(debug_assertions)]
Debug(DebugMsg),
PageChanged(PageChanged),
ChangePage(model::Page),
Rte(FieldId, RteMsg),
UserChanged(Option<User>),
ProjectChanged(Option<Project>),
@ -203,7 +207,7 @@ fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) {
};
if cfg!(debug_assertions) {
log!(msg);
log::info!("msg {:?}", msg);
}
match &msg {
@ -232,7 +236,7 @@ fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) {
_ => (),
}
{
if !matches!(model.page, Page::SignIn | Page::SignUp) {
use crate::shared::{aside, navbar_left};
aside::update(&msg, model, orders);
navbar_left::update(&msg, model, orders);
@ -254,11 +258,13 @@ fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) {
Page::Reports => pages::reports_page::update(msg, model, orders),
}
if cfg!(features = "print-model") {
log!(model);
log::debug!("{:?}", model);
}
}
fn view(model: &model::Model) -> Node<Msg> {
model.key_triggers.borrow_mut().clear();
match model.page {
Page::Project
| Page::AddIssue
@ -275,6 +281,7 @@ fn view(model: &model::Model) -> Node<Msg> {
}
}
#[inline(always)]
fn resolve_page(url: Url) -> Option<Page> {
if url.path().is_empty() {
return Some(Page::Project);
@ -310,49 +317,102 @@ fn resolve_page(url: Url) -> Option<Page> {
#[wasm_bindgen]
pub fn render() {
let app = seed::App::start("app", init, update, view);
wasm_logger::init(wasm_logger::Config::default());
{
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();
#[cfg(debug_assertions)]
crate::shared::on_event::keydown(move |ev| {
let app = app.clone();
let event = seed::to_keyboard_event(&ev);
let tag_name: String = seed::document()
if seed::document()
.active_element()
.map(|el| el.tag_name())
.unwrap_or_default();
let key = match tag_name.to_lowercase().as_str() {
"input" | "textarea" => return,
_ => event.key(),
};
let msg = Msg::GlobalKeyDown {
key,
shift: event.shift_key(),
ctrl: event.ctrl_key(),
alt: event.alt_key(),
};
app_clone.update(msg);
}) as Box<dyn FnMut(_)>);
seed::body()
.add_event_listener_with_callback("keyup", on_key_down.as_ref().unchecked_ref())
.expect("Failed to mount global key handler");
on_key_down.forget();
.map(|el| el.tag_name() != "BODY")
.unwrap_or(true)
|| !event.shift_key()
|| !event.ctrl_key()
{
return;
}
let key: String = event.key();
match key.as_str() {
">" => app.update(Msg::Debug(DebugMsg::Console)),
"?" => app.update(Msg::Debug(DebugMsg::Modal)),
_ => {}
};
});
crate::shared::on_event::keydown(move |_ev| {
let element = match seed::document().active_element() {
Some(el) => el,
_ => return,
};
let class_list = element.class_name();
if !class_list.contains("textAreaInput") {
return;
}
if element.get_attribute("rows").as_deref() != Some("auto") {
return;
}
crate::components::styled_textarea::handle_resize(&element);
});
}
fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model {
let sender = orders.msg_sender();
let page = resolve_page(url).unwrap_or(Page::Project);
let mut model = Model::new(
location::host_url().to_string(),
location::ws_url().to_string(),
page,
);
model.page = resolve_page(url).unwrap_or(Page::Project);
let key_triggers = model.key_triggers.clone();
let sender_clone = sender.clone();
crate::shared::on_event::keypress(move |ev| {
let sender = sender_clone.clone();
let key_triggers = key_triggers.clone();
let event = seed::to_keyboard_event(&ev);
if seed::document()
.active_element()
.map(|el| el.tag_name() != "BODY")
.unwrap_or(true)
{
return;
}
ev.prevent_default();
ev.stop_propagation();
let key: String = event.key();
let t = key_triggers.borrow();
if let Some(b) = key.chars().next().and_then(|c| t.get(&c)) {
let msg = b.build();
sender.clone()(Some(msg));
}
});
{
let sender_clone = sender.clone();
let id = FieldId::ProjectSettings(ProjectFieldId::Description);
model
.distinct_key_up
.keyup_wih_reset(id.to_str(), 20, move |ev| {
let sender = sender_clone.clone();
let key_ev = seed::to_keyboard_event(&ev);
let target = key_ev.target().unwrap();
let el = seed::to_html_el(&target);
let value = el.inner_html();
sender.clone()(Some(Msg::StrInputChanged(id.clone(), value)));
});
}
open_socket(&mut model, orders);
model
}
#[inline]
#[inline(always)]
fn authorize_or_redirect(model: &mut Model, orders: &mut impl Orders<Msg>) {
let pathname = seed::document().location().unwrap().pathname().unwrap();
match crate::shared::read_auth_token() {

View File

@ -3,6 +3,7 @@ use seed::prelude::Orders;
use crate::components::styled_checkbox::StyledCheckboxState;
use crate::components::styled_input::*;
use crate::styled_date_time_input::StyledDateTimeInputState;
use crate::{model, FieldId, Msg};
#[derive(Debug)]
@ -11,15 +12,19 @@ pub struct Model {
pub related_issues: Vec<IssueId>,
pub name: StyledInputState,
pub transform_into: StyledCheckboxState,
pub starts_at: StyledDateTimeInputState,
pub ends_at: StyledDateTimeInputState,
}
impl Model {
pub fn new(epic_id: i32, model: &mut model::Model) -> Self {
let name = model
.epics_by_id
.get(&epic_id)
.map(|epic| epic.name.as_str())
.unwrap_or_default();
let (name, starts_at, ends_at) = {
if let Some(epic) = model.epics_by_id.get(&epic_id) {
(epic.name.as_str(), epic.starts_at, epic.ends_at)
} else {
("", None, None)
}
};
let related_issues = model
.issues()
@ -40,11 +45,18 @@ impl Model {
FieldId::EditEpic(EpicFieldId::TransformInto),
0,
),
starts_at: StyledDateTimeInputState::new(
FieldId::EditEpic(EpicFieldId::StartsAt),
starts_at,
),
ends_at: StyledDateTimeInputState::new(FieldId::EditEpic(EpicFieldId::EndsAt), ends_at),
}
}
pub fn update(&mut self, msg: &Msg, _orders: &mut impl Orders<Msg>) {
pub fn update(&mut self, msg: &Msg, orders: &mut impl Orders<Msg>) {
self.name.update(msg);
self.starts_at.update(msg, orders);
self.ends_at.update(msg, orders);
self.transform_into.update(msg);
}
}

View File

@ -1,6 +1,7 @@
use jirs_data::{EpicFieldId, IssueType, WsMsg};
use seed::prelude::*;
use crate::components::styled_date_time_input::StyledDateTimeChanged;
use crate::{send_ws_msg, FieldId, Msg, OperationKind, ResourceKind};
pub fn update(msg: &Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) {
@ -34,7 +35,29 @@ pub fn update(msg: &Msg, model: &mut crate::model::Model, orders: &mut impl Orde
Msg::StrInputChanged(FieldId::EditEpic(EpicFieldId::Name), s) => {
let epic_id = modal.epic_id;
send_ws_msg(
WsMsg::EpicUpdate(epic_id, s.to_string()),
WsMsg::EpicUpdateName(epic_id, s.to_string()),
model.ws.as_ref(),
orders,
);
}
Msg::StyledDateTimeInputChanged(
FieldId::EditEpic(EpicFieldId::StartsAt),
StyledDateTimeChanged::DayChanged(Some(date)),
) => {
let epic_id = modal.epic_id;
send_ws_msg(
WsMsg::EpicUpdateStartsAt(epic_id, Some(*date)),
model.ws.as_ref(),
orders,
);
}
Msg::StyledDateTimeInputChanged(
FieldId::EditEpic(EpicFieldId::EndsAt),
StyledDateTimeChanged::DayChanged(Some(date)),
) => {
let epic_id = modal.epic_id;
send_ws_msg(
WsMsg::EpicUpdateEndsAt(epic_id, Some(*date)),
model.ws.as_ref(),
orders,
);

View File

@ -4,10 +4,12 @@ use seed::*;
use crate::components::styled_button::*;
use crate::components::styled_checkbox::*;
use crate::components::styled_date_time_input::StyledDateTimeInput;
use crate::components::styled_icon::{Icon, StyledIcon};
use crate::components::styled_input::*;
use crate::components::styled_modal::*;
use crate::modals::epics_edit::Model;
use crate::styled_field::StyledField;
use crate::{model, FieldId, Msg};
pub fn view(_model: &model::Model, modal: &Model) -> Node<Msg> {
@ -27,6 +29,33 @@ pub fn view(_model: &model::Model, modal: &Model) -> Node<Msg> {
..Default::default()
}
.render();
let starts_at = StyledDateTimeInput {
field_id: modal.starts_at.field_id.clone(),
popup_visible: modal.starts_at.popup_visible,
timestamp: modal.starts_at.timestamp,
}
.render();
let starts_at = StyledField {
input: starts_at,
label: "Starts At",
..Default::default()
}
.render();
let ends_at = StyledDateTimeInput {
field_id: modal.ends_at.field_id.clone(),
popup_visible: modal.ends_at.popup_visible,
timestamp: modal.ends_at.timestamp,
}
.render();
let ends_at = StyledField {
input: ends_at,
label: "Ends At",
..Default::default()
}
.render();
StyledModal {
width: Some(600),
class_list: "editEpic",
@ -39,6 +68,8 @@ pub fn view(_model: &model::Model, modal: &Model) -> Node<Msg> {
..Default::default()
}
.render(),
starts_at,
ends_at,
transform,
],
..Default::default()
@ -79,6 +110,7 @@ fn issue_type_select_option<'l>(ty: IssueType, state: &StyledCheckboxState) -> C
value: ty.into(),
class_list: ty.to_str(),
selected: value == state.value,
..Default::default()
}
}

View File

@ -42,7 +42,7 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node<Msg> {
input: StyledDateTimeInput {
field_id: FieldId::AddIssueModal(IssueFieldId::EpicStartsAt),
popup_visible: modal.epic_starts_at_state.popup_visible,
timestamp: modal.epic_starts_at_state.timestamp.clone(),
timestamp: modal.epic_starts_at_state.timestamp,
}
.render(),
label: "Starts at",
@ -54,7 +54,7 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node<Msg> {
input: StyledDateTimeInput {
field_id: FieldId::AddIssueModal(IssueFieldId::EpicEndsAt),
popup_visible: modal.epic_ends_at_state.popup_visible,
timestamp: modal.epic_ends_at_state.timestamp.clone(),
timestamp: modal.epic_ends_at_state.timestamp,
}
.render(),
label: "Ends at",

View File

@ -1,15 +1,15 @@
use jirs_data::{Issue, IssueFieldId, IssueId, TimeTracking, UpdateIssuePayload};
use jirs_data::{Issue, IssueFieldId, IssueId, TextEditorMode, TimeTracking, UpdateIssuePayload};
use seed::prelude::*;
use crate::components::styled_date_time_input::StyledDateTimeInputState;
use crate::components::styled_editor::{Mode, StyledEditorState};
use crate::components::styled_editor::StyledEditorState;
use crate::components::styled_input::StyledInputState;
use crate::components::styled_select::StyledSelectState;
use crate::modals::time_tracking::value_for_time_tracking;
use crate::model::{CommentForm, IssueModal};
use crate::{EditIssueModalSection, FieldId, Msg};
#[derive(Clone, Debug, PartialOrd, PartialEq)]
#[derive(Debug)]
pub struct Model {
pub id: IssueId,
pub link_copied: bool,
@ -38,7 +38,7 @@ pub struct Model {
}
impl Model {
pub fn new(issue: &Issue, time_tracking_type: TimeTracking) -> Self {
pub fn new(user_mode: TextEditorMode, issue: &Issue, time_tracking_type: TimeTracking) -> Self {
Self {
id: issue.id,
link_copied: false,
@ -110,8 +110,10 @@ impl Model {
)
.with_min(Some(3)),
description_state: StyledEditorState::new(
Mode::View,
issue.description_text.as_deref().unwrap_or(""),
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Description)),
user_mode,
issue.description_text.as_deref().unwrap_or_default(),
issue.description.as_deref().unwrap_or_default(),
),
comment_form: CommentForm {
id: None,
@ -163,5 +165,6 @@ impl IssueModal for Model {
self.epic_name_state.update(msg, orders);
self.title_state.update(msg);
self.description_state.update(msg, orders);
}
}

View File

@ -17,11 +17,10 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
Msg::ResourceChanged(ResourceKind::Issue, OperationKind::SingleModified, Some(id)) => {
let m = model.issues_by_id.get(id).cloned();
if let Some(issue) = m {
modal.description_state.initial_text = issue
.description_text
.as_deref()
.unwrap_or_default()
.to_string();
modal.description_state.set_content(
issue.description_text.as_deref().unwrap_or_default(),
issue.description.as_deref().unwrap_or_default(),
);
modal.payload = issue.into();
}
}
@ -282,12 +281,6 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
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,
@ -346,16 +339,6 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
orders.skip().send_msg(Msg::ModalDropped);
}
// global
Msg::GlobalKeyDown { key, .. } if key.as_str() == "m" && !modal.comment_form.creating => {
orders
.skip()
.send_msg(Msg::ModalChanged(FieldChange::ToggleCommentForm(
FieldId::EditIssueModal(EditIssueModalSection::Comment(CommentFieldId::Body)),
true,
)));
}
_ => (),
}
}

View File

@ -8,32 +8,42 @@ use seed::*;
use crate::components::styled_avatar::StyledAvatar;
use crate::components::styled_button::{ButtonVariant, StyledButton};
use crate::components::styled_editor::StyledEditor;
use crate::components::styled_editor::render_styled_editor;
use crate::components::styled_field::StyledField;
use crate::components::styled_icon::{Icon, StyledIcon};
use crate::components::styled_input::StyledInput;
use crate::components::styled_modal::*;
use crate::components::styled_select::{SelectVariant, StyledSelect, StyledSelectState};
use crate::components::styled_select_child::StyledSelectOption;
use crate::components::styled_tip::styled_tip;
use crate::modals::epic_field;
use crate::modals::issues_edit::Model as EditIssueModal;
use crate::modals::time_tracking::time_tracking_field;
use crate::model::{ModalType, Model};
use crate::shared::tracking_widget::tracking_link;
use crate::{EditIssueModalSection, FieldChange, FieldId, Msg};
use crate::{BuildMsg, EditIssueModalSection, FieldChange, FieldId, Msg};
mod comments;
#[inline(always)]
pub fn view(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
model
.issues_by_id
.get(&modal.id)
.map(|_issue| {
StyledModal::centered_with_width_and_body(1040, vec![details(model, modal)]).render()
StyledModal {
variant: ModalVariant::Center,
width: Some(1014),
with_icon: false,
children: vec![details(model, modal)],
class_list: "",
}
.render()
})
.unwrap_or(Node::Empty)
}
#[inline(always)]
pub fn details(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
div![
C!["issueDetails"],
@ -46,6 +56,7 @@ pub fn details(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
]
}
#[inline(always)]
fn modal_header(_model: &Model, modal: &EditIssueModal) -> Node<Msg> {
let EditIssueModal {
id,
@ -189,9 +200,9 @@ fn type_select_option<'l>(t: IssueType, text: &'l str) -> StyledSelectOption<'l>
}
}
#[inline(always)]
fn left_modal_column(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
let EditIssueModal {
payload,
description_state,
comment_form,
..
@ -209,19 +220,7 @@ fn left_modal_column(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
}
.render();
let description = {
StyledEditor {
id: Some(FieldId::EditIssueModal(EditIssueModalSection::Issue(
IssueFieldId::Description,
))),
initial_text: description_state.initial_text.as_str(),
text: description_state.initial_text.as_str(),
html: payload.description.as_deref().unwrap_or_default(),
mode: description_state.mode.clone(),
update_event: Ev::Change,
}
.render()
};
let description = render_styled_editor(&description_state);
let description_field = StyledField {
input: description,
..Default::default()
@ -265,13 +264,7 @@ fn left_modal_column(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
div![
C!["right"],
create_comment,
div![
C!["proTip"],
strong![C!["strong"], "Pro tip: "],
"press ",
span![C!["tipLetter"], "M"],
" to comment"
]
styled_tip('m', model, EnableCommentBuilder)
]
],
comments
@ -279,6 +272,19 @@ fn left_modal_column(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
]
}
#[derive(Debug)]
pub struct EnableCommentBuilder;
impl BuildMsg for EnableCommentBuilder {
fn build(&self) -> Msg {
Msg::ModalChanged(FieldChange::ToggleCommentForm(
FieldId::EditIssueModal(EditIssueModalSection::Comment(CommentFieldId::Body)),
true,
))
}
}
#[inline(always)]
fn right_modal_column(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
let EditIssueModal {
payload,
@ -377,7 +383,7 @@ fn priority_select_option<'l>(ip: IssuePriority) -> StyledSelectOption<'l> {
StyledSelectOption {
icon: Some(
StyledIcon {
icon: ip.clone().into(),
icon: ip.into(),
class_list: ip.to_str(),
..Default::default()
}

View File

@ -1,12 +1,18 @@
use jirs_data::{CommentId, EpicId, IssueId, IssueStatusId, TimeTracking, WsMsg};
use seed::prelude::*;
use seed::*;
use crate::model::{ModalType, Model, Page};
use crate::shared::go_to_board;
use crate::ws::send_ws_msg;
use crate::{FieldChange, FieldId, Msg, OperationKind, ResourceKind};
#[derive(Debug, Clone, Copy)]
#[repr(C)]
pub enum DebugMsg {
Console,
Modal,
}
pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
match msg {
Msg::ModalDropped if !model.modal_stack().is_empty() => {
@ -39,15 +45,12 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
Msg::ChangePage(Page::EditEpic(id)) => push_edit_epic_modal(*id, model, orders),
#[cfg(debug_assertions)]
Msg::GlobalKeyDown { key, .. } if key.eq("#") => push_debug_modal(model),
Msg::Debug(DebugMsg::Modal) => push_debug_modal(model),
#[cfg(debug_assertions)]
Msg::GlobalKeyDown { key, .. } if key.eq(">") => {
orders.skip();
log!(model);
}
Msg::GlobalKeyDown { .. } => {
Msg::Debug(DebugMsg::Console) => {
orders.skip();
log::debug!("{:?}", model);
}
_ => (),
@ -182,7 +185,12 @@ fn push_edit_issue_modal(issue_id: EpicId, model: &mut Model, orders: &mut impl
Some(issue) => issue,
_ => return,
};
crate::modals::issues_edit::Model::new(issue, time_tracking_type)
let user_mode = model
.user_settings
.as_ref()
.map(|u| u.text_editor_mode)
.unwrap_or_default();
crate::modals::issues_edit::Model::new(user_mode, issue, time_tracking_type)
};
send_ws_msg(
WsMsg::IssueCommentsLoad(issue_id),

View File

@ -15,7 +15,7 @@ use crate::pages::reports_page::model::ReportsPage;
use crate::pages::sign_in_page::model::SignInPage;
use crate::pages::sign_up_page::model::SignUpPage;
use crate::pages::users_page::model::UsersPage;
use crate::Msg;
use crate::{BuildMsg, Msg};
pub trait IssueModal {
fn epic_id_value(&self) -> Option<u32>;
@ -104,6 +104,24 @@ impl Page {
Page::Reports => "/reports".to_string(),
}
}
pub fn build_content(&self) -> PageContent {
match self {
Page::Project
| Page::DeleteEpic(..)
| Page::EditEpic(..)
| Page::EditIssue(_)
| Page::AddIssue => PageContent::Project(Box::new(ProjectPage::default())),
//
Page::SignIn => PageContent::SignIn(Box::new(SignInPage::default())),
Page::SignUp => PageContent::SignUp(Box::new(SignUpPage::default())),
Page::Invite => PageContent::Invite(Box::new(InvitePage::default())),
Page::Users => PageContent::Users(Box::new(UsersPage::default())),
Page::Reports => PageContent::Reports(Box::new(ReportsPage::default())),
// for those which requires additional data
_ => PageContent::Project(Box::new(ProjectPage::default())),
}
}
}
#[derive(Serialize, Deserialize, Debug)]
@ -141,13 +159,13 @@ impl Default for InvitationFormState {
macro_rules! match_page {
($model: ident, $ty: ident) => {
match &$model.page_content {
PageContent::$ty(page) => page,
crate::model::PageContent::$ty(page) => page,
_ => return,
}
};
($model: ident, $ty: ident; Empty) => {
match &$model.page_content {
PageContent::$ty(page) => page,
crate::model::PageContent::$ty(page) => page,
_ => return Node::Empty,
}
};
@ -212,6 +230,9 @@ pub struct Model {
pub users: Vec<User>,
pub users_by_id: HashMap<UserId, User>,
// user settings
pub user_settings: Option<UserSetting>,
// comments
pub comments: Vec<Comment>,
pub comments_by_id: HashMap<CommentId, Comment>,
@ -235,11 +256,14 @@ pub struct Model {
pub epics: Vec<Epic>,
pub epics_by_id: HashMap<EpicId, Epic>,
pub key_triggers: std::rc::Rc<std::cell::RefCell<HashMap<char, Box<dyn BuildMsg>>>>,
pub distinct_key_up: crate::shared::on_event::Distinct,
pub show_extras: bool,
}
impl Model {
pub fn new(host_url: String, ws_url: String) -> Self {
pub fn new(host_url: String, ws_url: String, page: Page) -> Self {
Self {
ws: None,
ws_queue: vec![],
@ -249,10 +273,10 @@ impl Model {
project_form: None,
comment_form: None,
comments_by_project_id: Default::default(),
page: Page::Project,
page_content: page.build_content(),
page,
host_url,
ws_url,
page_content: PageContent::Project(Box::new(ProjectPage::default())),
project: None,
current_user_project: None,
about_tooltip_visible: false,
@ -260,6 +284,7 @@ impl Model {
issues: vec![],
users: vec![],
users_by_id: Default::default(),
user_settings: None,
comments: vec![],
comments_by_id: Default::default(),
issue_statuses: vec![],
@ -274,6 +299,8 @@ impl Model {
epics_by_id: Default::default(),
modals_stack: vec![],
modals: Default::default(),
key_triggers: std::rc::Rc::new(std::cell::RefCell::new(HashMap::with_capacity(20))),
distinct_key_up: crate::shared::on_event::distinct(),
}
}

View File

@ -6,7 +6,7 @@ use crate::components::styled_button::{ButtonVariant, StyledButton};
use crate::components::styled_field::StyledField;
use crate::components::styled_form::StyledForm;
use crate::components::styled_input::StyledInput;
use crate::model::{Model, PageContent};
use crate::model::Model;
use crate::pages::invite_page::InvitePage;
use crate::shared::outer_layout;
use crate::validations::is_token;

View File

@ -1,5 +1,6 @@
use jirs_data::{ProjectId, User, UsersFieldId};
use jirs_data::{ProjectId, TextEditorMode, User, UsersFieldId};
use crate::components::styled_checkbox::StyledCheckboxState;
use crate::components::styled_image_input::StyledImageInputState;
use crate::components::styled_input::StyledInputState;
use crate::components::styled_select::StyledSelectState;
@ -11,10 +12,11 @@ pub struct ProfilePage {
pub email: StyledInputState,
pub avatar: StyledImageInputState,
pub current_project: StyledSelectState,
pub text_editor_mode: StyledCheckboxState,
}
impl ProfilePage {
pub fn new(user: &User, project_ids: Vec<ProjectId>) -> Self {
pub fn new(user: &User, mode: TextEditorMode, project_ids: Vec<ProjectId>) -> Self {
Self {
name: StyledInputState::new(
FieldId::Profile(UsersFieldId::Username),
@ -32,6 +34,10 @@ impl ProfilePage {
FieldId::Profile(UsersFieldId::CurrentProject),
project_ids.into_iter().map(|n| n as u32).collect(),
),
text_editor_mode: StyledCheckboxState::new(
FieldId::Profile(UsersFieldId::TextEditorMode),
mode.into(),
),
}
}
}

View File

@ -7,7 +7,8 @@ use crate::model::{Model, Page, PageContent};
use crate::pages::profile_page::model::ProfilePage;
use crate::ws::{board_load, send_ws_msg};
use crate::{
FieldId, Msg, OperationKind, PageChanged, ProfilePageChange, ResourceKind, WebSocketChanged,
match_page_mut, FieldId, Msg, OperationKind, PageChanged, ProfilePageChange, ResourceKind,
WebSocketChanged,
};
pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) {
@ -20,17 +21,23 @@ pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Order
_ => (),
}
let profile_page = match &mut model.page_content {
PageContent::Profile(profile_page) => profile_page,
_ => return,
};
let profile_page = match_page_mut!(model, Profile);
profile_page.name.update(&msg);
profile_page.email.update(&msg);
profile_page.avatar.update(&msg);
profile_page.text_editor_mode.update(&msg);
profile_page.current_project.update(&msg, orders);
match msg {
Msg::ResourceChanged(ResourceKind::UserSetting, OperationKind::SingleModified, _) => {
profile_page.text_editor_mode.value = model
.user_settings
.as_ref()
.map(|us| us.text_editor_mode)
.unwrap_or_default()
.into();
}
Msg::FileInputChanged(FieldId::Profile(UsersFieldId::Avatar), ..) => {
let file = match profile_page.avatar.file.as_ref() {
Some(f) => f,
@ -60,6 +67,13 @@ pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Order
Msg::ProjectChanged(Some(project)) => {
profile_page.current_project.values = vec![project.id as u32];
}
Msg::U32InputChanged(FieldId::Profile(UsersFieldId::TextEditorMode), v) => {
send_ws_msg(
WsMsg::UserSettingSetEditorMode(v.into()),
model.ws.as_ref(),
orders,
);
}
Msg::PageChanged(PageChanged::Profile(ProfilePageChange::SubmitForm)) => {
send_ws_msg(
WsMsg::ProfileUpdate(
@ -95,13 +109,20 @@ fn build_page_content(model: &mut Model) {
Some(ref user) => user,
_ => return,
};
model.page_content = PageContent::Profile(Box::new(ProfilePage::new(
user,
model
let text_editor_mode = model
.user_settings
.as_ref()
.map(|us| us.text_editor_mode)
.unwrap_or_default();
let project_ids = model
.project
.as_ref()
.map(|p| vec![p.id])
.unwrap_or_default(),
.unwrap_or_default();
model.page_content = PageContent::Profile(Box::new(ProfilePage::new(
user,
text_editor_mode,
project_ids,
)));
}

View File

@ -5,22 +5,20 @@ use seed::prelude::*;
use seed::*;
use crate::components::styled_button::{ButtonVariant, StyledButton};
use crate::components::styled_checkbox::{ChildBuilder, StyledCheckbox, StyledCheckboxState};
use crate::components::styled_field::StyledField;
use crate::components::styled_form::StyledForm;
use crate::components::styled_image_input::StyledImageInput;
use crate::components::styled_input::{InputVariant, StyledInput};
use crate::components::styled_select::{SelectVariant, StyledSelect};
use crate::components::styled_select_child::StyledSelectOption;
use crate::model::{Model, PageContent};
use crate::model::Model;
use crate::pages::profile_page::model::ProfilePage;
use crate::shared::inner_layout;
use crate::{FieldId, Msg, PageChanged, ProfilePageChange};
use crate::{match_page, FieldId, Msg, PageChanged, ProfilePageChange};
pub fn view(model: &Model) -> Node<Msg> {
let page = match &model.page_content {
PageContent::Profile(profile_page) => profile_page,
_ => return empty![],
};
let page = match_page!(model, Profile; Empty);
let avatar = StyledImageInput {
id: FieldId::Profile(UsersFieldId::Avatar),
@ -87,12 +85,13 @@ pub fn view(model: &Model) -> Node<Msg> {
avatar,
username_field,
email_field,
editor_mode_select(page),
current_project,
submit_field,
],
}
.render();
inner_layout(model, "profile", &[content])
inner_layout(model, "profile", &[div![C!["formContainer"], content]])
}
fn build_current_project(model: &Model, page: &ProfilePage) -> Node<Msg> {
@ -147,6 +146,7 @@ fn build_current_project(model: &Model, page: &ProfilePage) -> Node<Msg> {
.render()
}
#[inline(always)]
fn project_select_option<'l>(project: &'l Project) -> StyledSelectOption<'l> {
StyledSelectOption {
text: Some(project.name.as_str()),
@ -154,3 +154,47 @@ fn project_select_option<'l>(project: &'l Project) -> StyledSelectOption<'l> {
..Default::default()
}
}
#[inline(always)]
fn editor_mode_select(page: &ProfilePage) -> Node<Msg> {
let time_tracking = StyledCheckbox {
options: Some(
vec![
TextEditorMode::MdOnly,
TextEditorMode::RteOnly,
TextEditorMode::Mixed,
]
.into_iter()
.map(|tem| editor_mode_checkbox_option(tem, &page.text_editor_mode)),
),
class_list: "timeTracking",
}
.render();
StyledField {
input: time_tracking,
label: "Text editor type",
tip: Some("You can choose if what kind of text editor you will have"),
..Default::default()
}
.render()
}
#[inline(always)]
fn editor_mode_checkbox_option<'l>(
tem: TextEditorMode,
state: &StyledCheckboxState,
) -> ChildBuilder<'l> {
let value: u32 = tem.into();
ChildBuilder {
field_id: state.field_id.clone(),
selected: state.value == value,
label: match tem {
TextEditorMode::MdOnly => "Simple Markdown editor",
TextEditorMode::RteOnly => "Advanced Rich Text Editor",
TextEditorMode::Mixed => "Editor with possibility to switch between modes",
},
class_list: tem.to_str(),
value,
..Default::default()
}
}

View File

@ -14,7 +14,7 @@ pub struct StatusIssueIds {
#[derive(Default, Debug)]
pub struct EpicIssuePerStatus {
pub epic_ref: Option<(EpicId, EpicName)>,
pub epic_ref: Option<(EpicId, EpicName, Option<StartsAt>, Option<EndsAt>)>,
pub per_status_issues: Vec<StatusIssueIds>,
}
@ -36,9 +36,11 @@ impl ProjectPage {
issues: &[Issue],
user: &Option<User>,
) -> Vec<EpicIssuePerStatus> {
let epics = vec![None]
.into_iter()
.chain(epics.iter().map(|s| Some((s.id, s.name.as_str()))));
let epics = vec![None].into_iter().chain(
epics
.iter()
.map(|epic| Some((epic.id, epic.name.as_str(), epic.starts_at, epic.ends_at))),
);
let statuses = statuses.iter().map(|s| (s.id, s.name.as_str()));
let issues = issues.iter().filter(|issue| {
@ -74,7 +76,9 @@ impl ProjectPage {
epics
.map(|epic| {
let mut per_epic_map = EpicIssuePerStatus {
epic_ref: epic.map(|(id, name)| (id, name.to_string())),
epic_ref: epic.map(|(id, name, starts_at, ends_at)| {
(id, name.to_string(), starts_at, ends_at)
}),
..Default::default()
};
@ -83,7 +87,7 @@ impl ProjectPage {
status_id: current_status_id,
status_name: issue_status_name.to_string(),
issue_ids: issues_per_epic_id
.get(&epic.map(|(id, _)| id))
.get(&epic.map(|(id, ..)| id))
.map(|v| {
v.iter()
.filter(|issue| issue_filter_status(issue, current_status_id))

View File

@ -63,7 +63,7 @@ pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Order
Msg::ProjectAvatarFilterChanged(user_id, active) => {
if active {
project_page.active_avatar_filters =
std::mem::replace(&mut project_page.active_avatar_filters, vec![])
std::mem::take(&mut project_page.active_avatar_filters)
.into_iter()
.filter(|id| *id != user_id)
.collect();

View File

@ -12,6 +12,7 @@ use crate::{match_page, BoardPageChange, Model, Msg, Page, PageChanged};
pub fn project_board_lists(model: &Model) -> Node<Msg> {
let project_page = match_page!(model, Project; Empty);
let now = chrono::Utc::now().naive_utc();
let rows = project_page.visible_issues.iter().map(|per_epic| {
let columns: Vec<Node<Msg>> = per_epic
.per_status_issues
@ -31,7 +32,7 @@ pub fn project_board_lists(model: &Model) -> Node<Msg> {
})
.collect();
let epic_name = match per_epic.epic_ref.as_ref() {
Some((id, name)) => {
Some((id, name, starts_at, ends_at)) => {
let id = *id;
let edit_button = StyledButton {
variant: ButtonVariant::Empty,
@ -64,9 +65,25 @@ pub fn project_board_lists(model: &Model) -> Node<Msg> {
}
.render();
let range = match (starts_at, ends_at) {
(Some(s), Some(e)) => div![
C!["timeRange"],
div![C!["startsAt"], format!("{}", s.format("%e %B %Y"))],
div![C!["separator"], "-"],
div![
IF![now.date() > e.date() => C!["error"]],
IF![now.date() == e.date() => C!["warning"]],
C!["endsAt"],
format!("{}", e.format("%e %B %Y"))
]
],
_ => Node::Empty,
};
div![
C!["epicHeader"],
div![C!["epicName"], name],
range,
div![C!["epicActions"], edit_button, delete_button],
]
}

View File

@ -1,6 +1,7 @@
use jirs_data::{IssueStatusId, Project, ProjectFieldId, UpdateProjectPayload};
use jirs_data::{IssueStatusId, Project, ProjectFieldId, TextEditorMode, UpdateProjectPayload};
use crate::components::styled_checkbox::StyledCheckboxState;
use crate::components::styled_editor::StyledEditorState;
use crate::components::styled_input::StyledInputState;
use crate::components::styled_select::StyledSelectState;
use crate::shared::drag::DragState;
@ -10,17 +11,16 @@ use crate::FieldId;
pub struct ProjectSettingsPage {
pub payload: UpdateProjectPayload,
pub project_category_state: StyledSelectState,
pub description_mode: crate::components::styled_editor::Mode,
pub time_tracking: StyledCheckboxState,
pub column_drag: DragState,
pub edit_column_id: Option<IssueStatusId>,
pub creating_issue_status: bool,
pub name: StyledInputState,
pub description: StyledEditorState,
}
impl ProjectSettingsPage {
pub fn new(project: &Project) -> Self {
use crate::components::styled_editor::Mode as EditorMode;
pub fn new(mode: TextEditorMode, project: &Project) -> Self {
let jirs_data::Project {
id,
name,
@ -39,7 +39,6 @@ impl ProjectSettingsPage {
category: Some(*category),
time_tracking: Some(*time_tracking),
},
description_mode: EditorMode::View,
project_category_state: StyledSelectState::new(
FieldId::ProjectSettings(ProjectFieldId::Category),
vec![(*category).into()],
@ -55,6 +54,12 @@ impl ProjectSettingsPage {
FieldId::ProjectSettings(ProjectFieldId::IssueStatusName),
"",
),
description: StyledEditorState::new(
FieldId::ProjectSettings(ProjectFieldId::DescriptionMode),
mode,
"",
"",
),
}
}

View File

@ -8,8 +8,7 @@ use crate::components::styled_select::StyledSelectChanged;
use crate::model::{Model, Page, PageContent};
use crate::pages::project_settings_page::ProjectSettingsPage;
use crate::ws::{board_load, send_ws_msg};
use crate::FieldChange::TabChanged;
use crate::{FieldId, Msg, PageChanged, ProjectPageChange, WebSocketChanged};
use crate::{match_page_mut, FieldId, Msg, PageChanged, ProjectPageChange, WebSocketChanged};
pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
if model.page != Page::ProjectSettings {
@ -19,6 +18,7 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
match msg {
Msg::ProjectChanged(Some(_)) => {
build_page_content(model);
send_ws_msg(WsMsg::IssueStatusesLoad, model.ws.as_ref(), orders);
}
Msg::WebSocketChange(ref change) => match change {
WebSocketChanged::WsMsg(WsMsg::AuthorizeLoaded(..)) => {
@ -43,18 +43,28 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
_ => (),
}
if model.user.is_none() || model.project.is_none() {
if model.user.is_none() {
return;
}
let page = match &mut model.page_content {
PageContent::ProjectSettings(page) => page,
_ => return error!("bad content type"),
};
if model.project.is_none() {
board_load(model, orders);
return;
}
if matches!(model.page, Page::ProjectSettings)
&& !matches!(model.page_content, PageContent::ProjectSettings(..))
{
build_page_content(model);
send_ws_msg(WsMsg::IssueStatusesLoad, model.ws.as_ref(), orders);
}
let page = match_page_mut!(model, ProjectSettings);
page.project_category_state.update(&msg, orders);
page.time_tracking.update(&msg);
page.name.update(&msg);
// page.description_rte.update(&msg, orders);
page.description.update(&msg, orders);
match msg {
Msg::StrInputChanged(FieldId::ProjectSettings(ProjectFieldId::Name), text) => {
@ -73,12 +83,6 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
let category = value.into();
page.payload.category = Some(category);
}
Msg::ModalChanged(TabChanged(
FieldId::ProjectSettings(ProjectFieldId::Description),
mode,
)) => {
page.description_mode = mode;
}
Msg::PageChanged(PageChanged::ProjectSettings(
ProjectPageChange::SubmitProjectSettingsForm,
)) => {
@ -234,9 +238,13 @@ fn sync(model: &mut Model, orders: &mut impl Orders<Msg>) {
}
fn build_page_content(model: &mut Model) {
let project = match &model.project {
Some(project) => project,
_ => return,
};
model.page_content = PageContent::ProjectSettings(Box::new(ProjectSettingsPage::new(project)));
if let Some(project) = &model.project {
let mode = model
.user_settings
.as_ref()
.map(|us| us.text_editor_mode)
.unwrap_or_default();
model.page_content =
PageContent::ProjectSettings(Box::new(ProjectSettingsPage::new(mode, project)));
}
}

View File

@ -6,7 +6,7 @@ use seed::*;
use crate::components::styled_button::{ButtonVariant, StyledButton};
use crate::components::styled_checkbox::{ChildBuilder, StyledCheckbox, StyledCheckboxState};
use crate::components::styled_editor::StyledEditor;
use crate::components::styled_editor::render_styled_editor;
use crate::components::styled_field::StyledField;
use crate::components::styled_form::StyledForm;
use crate::components::styled_icon::{Icon, StyledIcon};
@ -19,8 +19,6 @@ use crate::pages::project_settings_page::ProjectSettingsPage;
use crate::shared::inner_layout;
use crate::{FieldId, Msg, PageChanged, ProjectFieldId, ProjectPageChange};
// use crate::shared::styled_rte::StyledRte;
static TIME_TRACKING_FIBONACCI: &str = include_str!("./time_tracking_fibonacci.txt");
static TIME_TRACKING_HOURLY: &str = include_str!("./time_tracking_hourly.txt");
@ -33,17 +31,7 @@ pub fn view(model: &model::Model) -> Node<Msg> {
let url_field = url_field(page);
let description_field = description_field(page);
// let desc_rte = StyledField::build()
// .input(
// StyledRte::build(FieldId::ProjectSettings(ProjectFieldId::
// Description)) .state(&page.description_rte)
// .build()
// .render(),
// )
// .build()
// .render();
let description_field = render_styled_editor(&page.description);
let category_field = category_field(page);
@ -114,6 +102,7 @@ fn time_tracking_select(page: &ProjectSettingsPage) -> Node<Msg> {
.render()
}
#[inline(always)]
fn time_tracking_checkbox_option<'l>(
t: TimeTracking,
state: &StyledCheckboxState,
@ -138,10 +127,12 @@ fn time_tracking_checkbox_option<'l>(
TimeTracking::Hourly => "hourly",
},
value,
..Default::default()
}
}
/// Build project name input with styled field wrapper
#[inline(always)]
fn name_field(page: &ProjectSettingsPage) -> Node<Msg> {
let name = StyledTextarea {
id: Some(FieldId::ProjectSettings(ProjectFieldId::Name)),
@ -162,6 +153,7 @@ fn name_field(page: &ProjectSettingsPage) -> Node<Msg> {
}
/// Build project url input with styled field wrapper
#[inline(always)]
fn url_field(page: &ProjectSettingsPage) -> Node<Msg> {
let url = StyledTextarea {
id: Some(FieldId::ProjectSettings(ProjectFieldId::Url)),
@ -181,27 +173,8 @@ fn url_field(page: &ProjectSettingsPage) -> Node<Msg> {
.render()
}
/// Build project description text area with styled field wrapper
fn description_field(page: &ProjectSettingsPage) -> Node<Msg> {
let description = StyledEditor {
id: Some(FieldId::ProjectSettings(ProjectFieldId::Description)),
initial_text: page.payload.description.as_deref().unwrap_or_default(),
text: page.payload.description.as_deref().unwrap_or_default(),
html: page.payload.description.as_deref().unwrap_or_default(),
mode: page.description_mode.clone(),
update_event: Ev::Change,
}
.render();
StyledField {
label: "Description",
tip: Some("Describe the project in as much detail as you'd like."),
input: description,
..Default::default()
}
.render()
}
/// Build project category dropdown with styled field wrapper
#[inline(always)]
fn category_field(page: &ProjectSettingsPage) -> Node<Msg> {
let category = StyledSelect {
id: FieldId::ProjectSettings(ProjectFieldId::Category),
@ -239,6 +212,7 @@ fn category_select_option<'l>(pc: ProjectCategory) -> StyledSelectOption<'l> {
}
/// Build draggable columns preview with option to remove and add new columns
#[inline(always)]
fn columns_section(model: &Model, page: &ProjectSettingsPage) -> Node<Msg> {
let width = 100f64 / (model.issue_statuses.len() + 1) as f64;
let column_style = format!("width: calc({width}% - 10px)", width = width);
@ -269,7 +243,7 @@ fn columns_section(model: &Model, page: &ProjectSettingsPage) -> Node<Msg> {
.render()
}
#[inline]
#[inline(always)]
fn add_column(page: &ProjectSettingsPage, column_style: &str) -> Node<Msg> {
let on_click = mouse_ev(Ev::Click, move |_| {
Msg::PageChanged(PageChanged::ProjectSettings(
@ -317,7 +291,7 @@ fn add_column(page: &ProjectSettingsPage, column_style: &str) -> Node<Msg> {
}
}
#[inline]
#[inline(always)]
fn column_preview(
is: &IssueStatus,
page: &ProjectSettingsPage,
@ -347,6 +321,7 @@ fn column_preview(
}
}
#[inline(always)]
fn show_column_preview(
is: &IssueStatus,
per_column_issue_count: &HashMap<i32, i32>,

View File

@ -1,3 +1,14 @@
use crate::shared::validate::*;
use crate::validations::*;
use crate::validator;
validator!(EmailFormat, is_email, "Not a valid e-mail address");
validator!(UuidFormat, is_token, "Malformed token");
pub type UsernameValidator = Touched<Between<4, 20>>;
pub type EmailValidator = Touched<Chain<Changed<AtLeast<6>>, Changed<EmailFormat>>>;
pub type TokenValidator = Touched<Chain<Between<10, 20>, Changed<UuidFormat>>>;
#[derive(Debug, Default)]
pub struct SignInPage {
pub username: String,
@ -5,8 +16,8 @@ pub struct SignInPage {
pub token: String,
pub login_success: bool,
pub bad_token: String,
// touched
pub username_touched: bool,
pub email_touched: bool,
pub token_touched: bool,
// validators
pub username_v: UsernameValidator,
pub email_v: EmailValidator,
pub token_v: TokenValidator,
}

View File

@ -7,37 +7,35 @@ use uuid::Uuid;
use crate::model::{self, Model, Page, PageContent};
use crate::pages::sign_in_page::model::SignInPage;
use crate::shared::validate::*;
use crate::shared::write_auth_token;
use crate::ws::send_ws_msg;
use crate::{FieldId, Msg, WebSocketChanged};
use crate::{match_page_mut, FieldId, Msg, WebSocketChanged};
pub fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) {
if model.page != Page::SignIn {
return;
}
if let Msg::ChangePage(Page::SignIn) = msg {
} else if !matches!(model.page_content, PageContent::SignIn(..)) {
build_page_content(model);
} else if matches!(msg, Msg::ChangePage(Page::SignIn)) {
build_page_content(model);
return;
};
let page = match &mut model.page_content {
PageContent::SignIn(page) => page,
_ => return,
};
let page = match_page_mut!(model, SignIn);
match msg {
Msg::StrInputChanged(FieldId::SignIn(SignInFieldId::Username), value) => {
page.username_v.validate(&value);
page.username = value;
page.username_touched = true;
}
Msg::StrInputChanged(FieldId::SignIn(SignInFieldId::Email), value) => {
page.email_v.validate(&value);
page.email = value;
page.email_touched = true;
}
Msg::StrInputChanged(FieldId::SignIn(SignInFieldId::Token), value) => {
page.token_v.validate(&value);
page.token = value;
page.token_touched = true;
}
Msg::SignInRequest => {
send_ws_msg(

View File

@ -7,21 +7,18 @@ use crate::components::styled_form::StyledForm;
use crate::components::styled_icon::{Icon, StyledIcon};
use crate::components::styled_input::StyledInput;
use crate::components::styled_link::StyledLink;
use crate::model::{self, PageContent};
use crate::shared::outer_layout;
use crate::validations::{is_email, is_token};
use crate::{FieldId, Msg, SignInFieldId};
use crate::shared::validate::Validator;
use crate::{match_page, model, FieldId, Msg, SignInFieldId};
pub fn view(model: &model::Model) -> Node<Msg> {
let page = match &model.page_content {
PageContent::SignIn(page) => page,
_ => return empty![],
};
let page = match_page!(model, SignIn; Empty);
let username = StyledInput {
value: page.username.as_str(),
valid: is_valid_username(page.username_touched, &page.username),
valid: page.username_v.is_valid(),
id: Some(FieldId::SignIn(SignInFieldId::Username)),
err_msg: page.username_v.message(),
..Default::default()
}
.render();
@ -34,8 +31,9 @@ pub fn view(model: &model::Model) -> Node<Msg> {
let email = StyledInput {
value: page.email.as_str(),
valid: is_valid_email(page.email_touched, page.email.as_str()),
valid: page.email_v.is_valid(),
id: Some(FieldId::SignIn(SignInFieldId::Email)),
err_msg: page.email_v.message(),
..Default::default()
}
.render();
@ -99,11 +97,13 @@ pub fn view(model: &model::Model) -> Node<Msg> {
}
.render();
let token = StyledInput::new_with_id_and_value_and_valid(
FieldId::SignIn(SignInFieldId::Token),
&page.token,
is_valid_token(page.token_touched, page.token.as_str()),
)
let token = StyledInput {
id: Some(FieldId::SignIn(SignInFieldId::Token)),
valid: page.token_v.is_valid(),
value: page.token.as_str(),
err_msg: page.token_v.message(),
..Default::default()
}
.render();
let token_field = StyledField {
label: "Single use token",
@ -138,15 +138,3 @@ pub fn view(model: &model::Model) -> Node<Msg> {
let children = vec![sign_in_form, bind_token_form];
outer_layout(model, "login", children)
}
fn is_valid_username(touched: bool, s: &str) -> bool {
!touched || (s.len() > 1 && s.len() < 20)
}
fn is_valid_email(touched: bool, s: &str) -> bool {
!touched || (is_email(s) && s.len() < 20)
}
fn is_valid_token(touched: bool, s: &str) -> bool {
!touched || is_token(s)
}

View File

@ -8,10 +8,9 @@ use crate::components::styled_form::StyledForm;
use crate::components::styled_icon::{Icon, StyledIcon};
use crate::components::styled_input::StyledInput;
use crate::components::styled_link::StyledLink;
use crate::model::{self, PageContent};
use crate::shared::outer_layout;
use crate::validations::is_email;
use crate::{match_page, FieldId, Msg};
use crate::{match_page, model, FieldId, Msg};
pub fn view(model: &model::Model) -> Node<Msg> {
let page = match_page!(model, SignUp; Empty);

View File

@ -1,15 +1,13 @@
use jirs_data::{InvitationState, UserRole, UsersFieldId, WsMsg};
use seed::log;
use seed::prelude::Orders;
use crate::components::styled_select::StyledSelectChanged;
use crate::model::{InvitationFormState, Model, Page, PageContent};
use crate::pages::users_page::model::UsersPage;
use crate::ws::{invitation_load, send_ws_msg};
use crate::{FieldId, Msg, PageChanged, UsersPageChange, WebSocketChanged};
use crate::{match_page_mut, FieldId, Msg, PageChanged, UsersPageChange, WebSocketChanged};
pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
log!(model);
if model.user.is_none() {
return;
}
@ -18,10 +16,7 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
build_page_content(model);
}
let page = match &mut model.page_content {
PageContent::Users(page) => page,
_ => return,
};
let page = match_page_mut!(model, Users);
page.user_role_state.update(&msg, orders);

View File

@ -1,11 +0,0 @@
fn main() {
// std::fs::create_dir_all("./jirs-client/src/hi/syntax").unwrap();
//
// use syntect::{dumps::*, parsing::*};
// let syntax_set: SyntaxSet = from_binary(include_bytes!("./hi/newlines.packdump"));
// std::fs::write(
// "./jirs-client/src/hi/syntax_set.cbor",
// serde_cbor::ser::to_vec(&syntax_set).unwrap(),
// )
// .unwrap();
}

View File

@ -7,7 +7,9 @@ use crate::{resolve_page, Msg};
pub mod aside;
pub mod drag;
pub mod navbar_left;
pub mod on_event;
pub mod tracking_widget;
pub mod validate;
pub trait ToChild<'l> {
type Builder: 'l;

View File

@ -0,0 +1,153 @@
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use std::sync::atomic::AtomicUsize;
use futures::StreamExt;
use seed::prelude::*;
use seed::*;
use wasm_bindgen_futures::spawn_local;
#[derive(Debug, Default)]
pub struct Distinct {
state: Rc<RefCell<HashSet<&'static str>>>,
locks: Rc<RefCell<HashMap<&'static str, bool>>>,
}
impl Distinct {
pub fn new() -> Self {
Self {
state: Rc::new(RefCell::new(Default::default())),
locks: Rc::new(RefCell::new(Default::default())),
}
}
pub fn keyup_wih_reset<Cb>(&self, selector: &'static str, submit_after_frames: usize, cb: Cb)
where
Cb: Fn(web_sys::KeyboardEvent) + 'static,
{
if self.state.borrow().contains(selector) {
return;
}
self.state.borrow_mut().insert(selector);
let callback = Rc::new(cb);
let locks = self.locks.clone();
on("keyup", move |ev| {
let active = match seed::document().active_element() {
None => {
return;
}
Some(el) => el,
};
if !active.class_name().contains(selector) {
return;
}
let locks = locks.clone();
if locks.borrow().get(selector).cloned().unwrap_or_default() {
return;
}
locks.borrow_mut().insert(selector, true);
let callback = callback.clone();
keyup_wih_reset(
selector,
move || {
let locks = locks.clone();
locks.borrow_mut().remove(selector);
let callback = callback.clone();
callback(ev);
},
submit_after_frames,
);
});
}
}
impl Clone for Distinct {
fn clone(&self) -> Self {
Self {
state: self.state.clone(),
locks: self.locks.clone(),
}
}
}
pub fn distinct() -> Distinct {
Distinct::new()
}
pub fn keydown<Cb>(cb: Cb)
where
Cb: FnMut(web_sys::KeyboardEvent) + 'static,
{
on("keydown", cb);
}
fn keyup_wih_reset<Cb>(selector: &'static str, cb: Cb, submit_after_frames: usize)
where
Cb: FnOnce() + 'static,
{
use std::sync::atomic::Ordering;
let (up_sender, mut up_receiver) = ::futures::channel::mpsc::unbounded();
let n: Rc<AtomicUsize> = Rc::new(AtomicUsize::new(submit_after_frames));
let reset = n.clone();
spawn_local(async move {
while up_receiver.next().await.is_some() {
reset.store(submit_after_frames, Ordering::Relaxed);
}
});
spawn_local(async move {
loop {
if n.load(Ordering::Relaxed) == 0 {
break;
}
wait_frame().await;
let _ = n.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| Some(v - 1));
}
cb();
});
on("keyup", move |_ev| {
let active = match seed::document().active_element() {
None => return,
Some(el) => el,
};
if !active.class_name().contains(selector) {
return;
}
let _ = up_sender.unbounded_send(());
});
}
pub fn keypress<Cb>(cb: Cb)
where
Cb: FnMut(web_sys::KeyboardEvent) + 'static,
{
on("keypress", cb);
}
pub fn on<Cb>(event: &str, cb: Cb)
where
Cb: FnMut(web_sys::KeyboardEvent) + 'static,
{
let handler = Closure::wrap(Box::new(cb) as Box<dyn FnMut(_)>);
seed::window()
.add_event_listener_with_callback(event, handler.as_ref().unchecked_ref())
.expect("Failed to mount global key handler");
handler.forget();
}
pub async fn wait_frame() -> f64 {
let (sender, mut receiver) = ::futures::channel::mpsc::unbounded();
let handler = Closure::wrap(Box::new(move |f| {
let _ = sender.unbounded_send(f);
}) as Box<dyn FnMut(f64)>);
let _ = seed::window().request_animation_frame(handler.as_ref().unchecked_ref());
handler.forget();
receiver.next().await.unwrap_or_default()
}

View File

@ -0,0 +1,207 @@
#[macro_export]
macro_rules! validator {
($id: ident, $is_valid: expr, $msg: expr) => {
#[derive(Debug, Default)]
pub struct $id;
impl Validator for $id {
fn validate(&mut self, value: &str) -> bool {
$is_valid(value)
}
fn message(&self) -> &'static str {
if self.is_valid() {
""
} else {
$msg
}
}
}
};
($id: ident, $const_name: ident, $const_ty: ty, $const_cond: ident, $msg: expr) => {
#[derive(Debug, Default)]
pub struct $id<const $const_name: $const_ty>;
impl<const $const_name: $const_ty> Validator for $id<$const_name> {
fn validate(&mut self, value: &str) -> bool {
$const_cond::<$const_name>(value)
}
fn message(&self) -> &'static str {
if self.is_valid() {
""
} else {
$msg
}
}
}
};
}
pub trait Validator: std::fmt::Debug {
fn validate(&mut self, value: &str) -> bool;
fn is_valid(&self) -> bool {
false
}
fn message(&self) -> &'static str {
""
}
}
#[derive(Debug, Default)]
pub struct Chain<A, B>(A, B)
where
A: Validator,
B: Validator;
impl<A, B> Validator for Chain<A, B>
where
A: Validator,
B: Validator,
{
fn validate(&mut self, value: &str) -> bool {
// trigger both
let left = self.0.validate(value);
let right = self.1.validate(value);
left && right
}
fn is_valid(&self) -> bool {
self.0.is_valid() && self.1.is_valid()
}
fn message(&self) -> &'static str {
match (!self.0.is_valid(), !self.1.is_valid()) {
(true, _) => self.0.message(),
(_, true) => self.1.message(),
_ => "",
}
}
}
#[derive(Debug, Default)]
pub struct AlwaysValid;
impl Validator for AlwaysValid {
fn validate(&mut self, _: &str) -> bool {
true
}
fn is_valid(&self) -> bool {
true
}
}
fn is_more_eq<const MIN: usize>(value: &str) -> bool {
value.len() >= MIN
}
fn is_less_eq<const MAX: usize>(value: &str) -> bool {
value.len() <= MAX
}
validator!(AtLeast, MIN, usize, is_more_eq, "Value is too short");
validator!(AtMost, MAX, usize, is_less_eq, "Value is too long");
pub type Between<const MIN: usize, const MAX: usize> =
Chain<Changed<AtLeast<MIN>>, Changed<AtMost<MAX>>>;
#[derive(Debug, Copy, Clone)]
#[repr(C)]
pub enum TouchState {
Untouched,
Touched,
}
impl Default for TouchState {
fn default() -> Self {
TouchState::Untouched
}
}
#[derive(Debug, Default)]
pub struct Touched<Valid>(pub Valid, TouchState)
where
Valid: Validator;
impl<V> Validator for Touched<V>
where
V: Validator,
{
fn validate(&mut self, value: &str) -> bool {
self.1 = TouchState::Touched;
self.0.validate(value)
}
fn is_valid(&self) -> bool {
match self.1 {
TouchState::Untouched => true,
_ => self.0.is_valid(),
}
}
fn message(&self) -> &'static str {
self.0.message()
}
}
#[derive(Debug)]
pub enum ChangedState {
Value(bool),
Unset,
}
impl Default for ChangedState {
fn default() -> Self {
ChangedState::Unset
}
}
#[derive(Debug, Default)]
pub struct Changed<V>(V, ChangedState)
where
V: Validator;
impl<V> Validator for Changed<V>
where
V: Validator,
{
fn validate(&mut self, value: &str) -> bool {
let res = self.0.validate(value);
self.1 = ChangedState::Value(res);
res
}
fn is_valid(&self) -> bool {
match self.1 {
ChangedState::Value(b) => b,
_ => self.0.is_valid(),
}
}
fn message(&self) -> &'static str {
self.0.message()
}
}
pub trait Validate<V: Validator> {
fn validator_mut(&mut self) -> Option<&mut V>;
fn validator(&self) -> Option<&V>;
fn is_valid(&self) -> bool {
self.validator().map(|v| v.is_valid()).unwrap_or(true)
}
fn validate(&mut self, value: &str) -> bool {
self.validator_mut()
.map(|v| v.validate(value))
.unwrap_or(true)
}
fn err_msg(&self) -> &'static str {
self.validator().map(|v| v.message()).unwrap_or("")
}
}

View File

@ -50,7 +50,7 @@ pub fn open_socket(model: &mut Model, orders: &mut impl Orders<Msg>) {
use seed::browser::web_socket::State;
use seed::prelude::*;
use seed::*;
log!(model.ws.as_ref().map(|ws| ws.state()));
log::warn!("{:?}", model.ws.as_ref().map(|ws| ws.state()));
match model.ws.as_ref() {
Some(ws) if ws.state() != State::Closed => {
@ -70,7 +70,7 @@ pub fn open_socket(model: &mut Model, orders: &mut impl Orders<Msg>) {
)))
})
.on_open(|| {
log!("open_socket opened");
log::info!("open_socket opened");
Some(Msg::WebSocketChange(WebSocketChanged::WebSocketOpened))
})
.on_close(|_| Some(Msg::WebSocketChange(WebSocketChanged::WebSocketClosed)))
@ -91,11 +91,14 @@ pub async fn read_incoming(msg: WebSocketMessage) -> Msg {
pub fn update(msg: WsMsg, model: &mut Model, orders: &mut impl Orders<Msg>) {
match msg {
// auth
WsMsg::AuthorizeLoaded(Ok(user)) => {
WsMsg::AuthorizeLoaded(Ok((user, setting))) => {
model.user = Some(user);
model.user_settings = Some(setting);
if is_non_logged_area() {
go_to_board(orders);
}
orders
.skip()
.send_msg(Msg::UserChanged(model.user.as_ref().cloned()))
@ -104,6 +107,11 @@ pub fn update(msg: WsMsg, model: &mut Model, orders: &mut impl Orders<Msg>) {
OperationKind::SingleLoaded,
model.user.as_ref().map(|u| u.id),
))
.send_msg(Msg::ResourceChanged(
ResourceKind::UserSetting,
OperationKind::SingleLoaded,
model.user.as_ref().map(|u| u.id),
))
.send_msg(Msg::ResourceChanged(
ResourceKind::Auth,
OperationKind::SingleLoaded,
@ -111,9 +119,7 @@ pub fn update(msg: WsMsg, model: &mut Model, orders: &mut impl Orders<Msg>) {
));
}
WsMsg::AuthorizeExpired => {
use seed::*;
log!("Received token expired");
log::warn!("Received token expired");
if let Ok(msg) = write_auth_token(None) {
orders.skip().send_msg(msg).send_msg(Msg::ResourceChanged(
ResourceKind::Auth,
@ -158,6 +164,15 @@ pub fn update(msg: WsMsg, model: &mut Model, orders: &mut impl Orders<Msg>) {
model.current_user_project.as_ref().map(|up| up.id),
));
}
// user settings
WsMsg::UserSettingUpdated(setting) => {
model.user_settings = Some(setting);
orders.send_msg(Msg::ResourceChanged(
ResourceKind::UserSetting,
OperationKind::SingleModified,
model.user_settings.as_ref().map(|up| up.id),
));
}
// issue statuses
WsMsg::IssueStatusesLoaded(v) => {

View File

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS user_settings;
DROP TYPE "TextEditorModeType";

View File

@ -0,0 +1,12 @@
CREATE TYPE "TextEditorModeType" AS ENUM (
'md_only',
'rte_only',
'mixed'
);
CREATE TABLE IF NOT EXISTS user_settings
(
id serial not null unique primary key,
user_id int references users (id) not null,
text_editor_mode "TextEditorModeType" DEFAULT 'md_only' NOT NULL
);

View File

@ -5,6 +5,7 @@ pub enum ProjectFieldId {
Name,
Url,
Description,
DescriptionMode,
Category,
TimeTracking,
IssueStatusName,
@ -30,6 +31,7 @@ pub enum UsersFieldId {
UserRole,
Avatar,
CurrentProject,
TextEditorMode,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, PartialEq, Hash)]

View File

@ -24,6 +24,7 @@ pub type ListPosition = i32;
pub type ProjectId = i32;
pub type ProjectName = String;
pub type UserId = i32;
pub type UserSettingId = i32;
pub type UserProjectId = i32;
pub type CommentId = i32;
pub type TokenId = i32;
@ -48,6 +49,9 @@ pub type Lang = String;
pub type BindToken = Uuid;
pub type InvitationToken = Uuid;
pub type StartsAt = NaiveDateTime;
pub type EndsAt = NaiveDateTime;
#[cfg_attr(feature = "backend", derive(FromSqlRow, AsExpression, EnumSql))]
#[cfg_attr(feature = "backend", sql_type = "IssueTypeType")]
#[derive(
@ -384,8 +388,8 @@ pub struct Epic {
pub project_id: ProjectId,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub starts_at: Option<NaiveDateTime>,
pub ends_at: Option<NaiveDateTime>,
pub starts_at: Option<StartsAt>,
pub ends_at: Option<EndsAt>,
pub description: Option<DescriptionString>,
pub description_html: Option<DescriptionString>,
}
@ -422,3 +426,29 @@ pub struct Style {
pub struct HighlightedCode {
pub parts: Vec<(Style, String)>,
}
#[cfg_attr(feature = "backend", derive(FromSqlRow, AsExpression, EnumSql))]
#[cfg_attr(feature = "backend", sql_type = "TextEditorModeType")]
#[derive(
Clone, Copy, Deserialize, Serialize, Debug, PartialOrd, PartialEq, Hash, EnumIter, EnumPrimitive,
)]
#[repr(C)]
pub enum TextEditorMode {
MdOnly,
RteOnly,
Mixed,
}
impl Default for TextEditorMode {
fn default() -> Self {
TextEditorMode::MdOnly
}
}
#[cfg_attr(feature = "backend", derive(Queryable))]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct UserSetting {
pub id: UserSettingId,
pub user_id: UserId,
pub text_editor_mode: TextEditorMode,
}

View File

@ -3,11 +3,12 @@ use uuid::Uuid;
use crate::{
AvatarUrl, BindToken, Code, Comment, CommentId, CreateCommentPayload, CreateIssuePayload,
DescriptionString, EmailString, Epic, EpicId, HighlightedCode, Invitation, InvitationId,
InvitationToken, Issue, IssueFieldId, IssueId, IssueStatus, IssueStatusId, IssueType, Lang,
ListPosition, Message, MessageId, NameString, NumberOfDeleted, PayloadVariant, Position,
Project, TitleString, UpdateCommentPayload, UpdateProjectPayload, User, UserId, UserProject,
UserProjectId, UserRole, UsernameString,
DescriptionString, EmailString, EndsAt, Epic, EpicId, HighlightedCode, Invitation,
InvitationId, InvitationToken, Issue, IssueFieldId, IssueId, IssueStatus, IssueStatusId,
IssueType, Lang, ListPosition, Message, MessageId, NameString, NumberOfDeleted, PayloadVariant,
Position, Project, StartsAt, TextEditorMode, TitleString, UpdateCommentPayload,
UpdateProjectPayload, User, UserId, UserProject, UserProjectId, UserRole, UserSetting,
UsernameString,
};
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
@ -126,7 +127,7 @@ pub enum WsMsg {
// auth
AuthorizeLoad(Uuid),
AuthorizeLoaded(Result<User, String>),
AuthorizeLoaded(Result<(User, UserSetting), String>),
AuthorizeExpired,
AuthenticateRequest(EmailString, UsernameString),
AuthenticateSuccess,
@ -212,6 +213,10 @@ pub enum WsMsg {
ProfileUpdate(EmailString, UsernameString),
ProfileUpdated,
// user settings
UserSettingUpdated(UserSetting),
UserSettingSetEditorMode(TextEditorMode),
// user projects
UserProjectsLoad,
UserProjectsLoaded(Vec<UserProject>),
@ -234,7 +239,9 @@ pub enum WsMsg {
Option<DescriptionString>,
),
EpicCreated(Epic),
EpicUpdate(EpicId, NameString),
EpicUpdateName(EpicId, NameString),
EpicUpdateStartsAt(EpicId, Option<StartsAt>),
EpicUpdateEndsAt(EpicId, Option<EndsAt>),
EpicUpdated(Epic),
EpicDelete(EpicId),
EpicDeleted(EpicId, NumberOfDeleted),