Editor
This commit is contained in:
parent
a97a96e9ac
commit
f2081c9974
227
Cargo.lock
generated
227
Cargo.lock
generated
@ -344,6 +344,15 @@ dependencies = [
|
||||
"memchr 2.3.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ansi_term"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b"
|
||||
dependencies = [
|
||||
"winapi 0.3.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "0.4.5"
|
||||
@ -456,6 +465,27 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0940dc441f31689269e10ac70eb1002a3a1d3ad1390e030043662eb7fe4688b"
|
||||
dependencies = [
|
||||
"block-padding",
|
||||
"byte-tools",
|
||||
"byteorder",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa79dedbb091f449f1f39e53edf88d5dbe95f895dae6135a8d7b881fb5af73f5"
|
||||
dependencies = [
|
||||
"byte-tools",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-sys"
|
||||
version = "0.3.2"
|
||||
@ -482,6 +512,12 @@ version = "3.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12ae9db68ad7fac5fe51304d20f016c911539251075a214f8e663babefa35187"
|
||||
|
||||
[[package]]
|
||||
name = "byte-tools"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.3.4"
|
||||
@ -527,6 +563,21 @@ dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "2.33.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5067f5bb2d80ef5d68b4c87db81601f0b75bca627bc2ef76b141d7b846a3c6d9"
|
||||
dependencies = [
|
||||
"ansi_term",
|
||||
"atty",
|
||||
"bitflags",
|
||||
"strsim",
|
||||
"textwrap",
|
||||
"unicode-width",
|
||||
"vec_map",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cloudabi"
|
||||
version = "0.0.3"
|
||||
@ -536,6 +587,23 @@ dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "comrak"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e17058cc536cf290563e88787d7b9e6030ce4742943017cc2ffb71f88034021c"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"entities",
|
||||
"lazy_static",
|
||||
"pest",
|
||||
"pest_derive",
|
||||
"regex 1.3.6",
|
||||
"twoway",
|
||||
"typed-arena",
|
||||
"unicode_categories",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "console_error_panic_hook"
|
||||
version = "0.1.6"
|
||||
@ -647,6 +715,15 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dotenv"
|
||||
version = "0.15.0"
|
||||
@ -680,6 +757,12 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "entities"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca"
|
||||
|
||||
[[package]]
|
||||
name = "enum-as-inner"
|
||||
version = "0.3.2"
|
||||
@ -737,6 +820,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fake-simd"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.0.14"
|
||||
@ -883,6 +972,15 @@ dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c68f0274ae0e023facc3c97b2e00f076be70e254bc851d972503b328db79b2ec"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.21"
|
||||
@ -1065,6 +1163,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"bincode",
|
||||
"chrono",
|
||||
"comrak",
|
||||
"futures 0.1.29",
|
||||
"jirs-data",
|
||||
"js-sys",
|
||||
@ -1204,6 +1303,12 @@ dependencies = [
|
||||
"linked-hash-map",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maplit"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
||||
|
||||
[[package]]
|
||||
name = "match_cfg"
|
||||
version = "0.1.0"
|
||||
@ -1345,6 +1450,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2839e79665f131bdb5782e51f2c6c9599c133c6098982a54c794358bf432529c"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.10.0"
|
||||
@ -1381,6 +1492,49 @@ version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e"
|
||||
|
||||
[[package]]
|
||||
name = "pest"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10f4872ae94d7b90ae48754df22fd42ad52ce740b8f370b03da4835417403e53"
|
||||
dependencies = [
|
||||
"ucd-trie",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_derive"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "833d1ae558dc601e9a60366421196a8d94bc0ac980476d0b67e1d0988d72b2d0"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_generator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_generator"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "99b8db626e31e5b81787b9783425769681b347011cc59471e33ea46d2ea0cf55"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_meta",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_meta"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54be6e404f5317079812fc8f9f5279de376d8856929e21c184ecf6bbd692a11d"
|
||||
dependencies = [
|
||||
"maplit",
|
||||
"pest",
|
||||
"sha-1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "0.4.8"
|
||||
@ -1742,6 +1896,18 @@ dependencies = [
|
||||
"url 2.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha-1"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7d94d0bede923b3cea61f3f1ff57ff8cdfd77b400fb8f9998949e0cf04163df"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"digest",
|
||||
"fake-simd",
|
||||
"opaque-debug",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.6.0"
|
||||
@ -1782,6 +1948,12 @@ dependencies = [
|
||||
"winapi 0.3.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.17"
|
||||
@ -1814,6 +1986,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "textwrap"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread-id"
|
||||
version = "2.0.0"
|
||||
@ -1936,6 +2117,40 @@ dependencies = [
|
||||
"trust-dns-proto",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "twoway"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b40075910de3a912adbd80b5d8bad6ad10a23eeb1f5bf9d4006839e899ba5bc"
|
||||
dependencies = [
|
||||
"memchr 2.3.3",
|
||||
"unchecked-index",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typed-arena"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9b2228007eba4120145f785df0f6c92ea538f5a3635a612ecf4e334c8c1446d"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d2783fe2d6b8c1101136184eb41be8b1ad379e4657050b8aaff0c79ee7575f9"
|
||||
|
||||
[[package]]
|
||||
name = "ucd-trie"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c"
|
||||
|
||||
[[package]]
|
||||
name = "unchecked-index"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eeba86d422ce181a719445e51872fa30f1f7413b62becb52e95ec91aa262d85c"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.6.0"
|
||||
@ -1981,6 +2196,12 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "826e7639553986605ec5979c7dd957c7895e93eabed50ab2ffa7f6128a75097c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode_categories"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "1.7.2"
|
||||
@ -2033,6 +2254,12 @@ version = "0.2.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fc439f2794e98976c88a2a2dafce96b930fe8010b0a256b3c2199a773933168"
|
||||
|
||||
[[package]]
|
||||
name = "vec_map"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05c78687fb1a80548ae3250346c3db86a80a7cdd77bda190189f2d0a0987c81a"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.1.5"
|
||||
|
@ -23,6 +23,7 @@ chrono = { version = "*", features = [ "serde" ] }
|
||||
uuid = { version = "*", features = [ "serde" ] }
|
||||
wasm-bindgen = "0.2.60"
|
||||
futures = "^0.1.26"
|
||||
comrak = "*"
|
||||
|
||||
[dependencies.js-sys]
|
||||
version = "*"
|
||||
|
@ -4,7 +4,7 @@
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
vertical-align: middle;
|
||||
line-height: 1;
|
||||
line-height: 2;
|
||||
white-space: nowrap;
|
||||
border-radius: 3px;
|
||||
transition: all 0.1s;
|
||||
|
63
jirs-client/js/css/styledEditor.css
Normal file
63
jirs-client/js/css/styledEditor.css
Normal file
@ -0,0 +1,63 @@
|
||||
.styledEditor {
|
||||
display: grid;
|
||||
grid-template-areas: "tab1 tab2" "view view";
|
||||
}
|
||||
|
||||
.styledEditor > input[type="radio"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.styledEditor > .navbar {
|
||||
border: 1px solid var(--borderLight);
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
font-family: var(--font-medium);
|
||||
font-weight: normal;
|
||||
text-align: center;
|
||||
height: 32px;
|
||||
vertical-align: middle;
|
||||
line-height: 2;
|
||||
white-space: nowrap;
|
||||
transition: all 0.1s;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 14.5px;
|
||||
}
|
||||
|
||||
.styledEditor > .navbar:hover {
|
||||
background: #fff;
|
||||
border: 1px solid var(--borderInputFocus);
|
||||
box-shadow: 0 0 0 1px var(--borderInputFocus);
|
||||
}
|
||||
|
||||
.styledEditor > .navbar.active {
|
||||
border-color: var(--borderInputFocus);
|
||||
}
|
||||
|
||||
.styledEditor > .navbar.editor {
|
||||
grid-area: tab1;
|
||||
}
|
||||
|
||||
.styledEditor > .navbar.view {
|
||||
grid-area: tab2;
|
||||
}
|
||||
|
||||
.styledEditor > .styledTextArea {
|
||||
grid-area: view;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.styledEditor > input.editorRadio:checked ~ .styledTextArea {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.styledEditor > .view {
|
||||
grid-area: view;
|
||||
display: none;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.styledEditor > input.viewRadio:checked ~ .view {
|
||||
display: block;
|
||||
}
|
@ -14,6 +14,7 @@
|
||||
@import "./css/styledModal.css";
|
||||
@import "./css/styledTextArea.css";
|
||||
@import "./css/styledForm.css";
|
||||
@import "./css/styledEditor.css";
|
||||
@import "./css/app.css";
|
||||
@import "./css/issue.css";
|
||||
@import "./css/project.css";
|
||||
|
@ -8,6 +8,7 @@ use jirs_data::{IssueStatus, WsMsg};
|
||||
use crate::api::send_ws_msg;
|
||||
use crate::model::{ModalType, Model, Page};
|
||||
use crate::shared::read_auth_token;
|
||||
use crate::shared::styled_editor::Mode as TabMode;
|
||||
use crate::shared::styled_select::StyledSelectChange;
|
||||
|
||||
mod api;
|
||||
@ -78,6 +79,7 @@ impl std::fmt::Display for FieldId {
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FieldChange {
|
||||
LinkCopied(FieldId, bool),
|
||||
TabChanged(FieldId, TabMode),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
@ -1,13 +1,14 @@
|
||||
use seed::{prelude::*, *};
|
||||
|
||||
use jirs_data::{Issue, IssuePriority, IssueStatus, IssueType, ToVec, User};
|
||||
use jirs_data::{IssuePriority, IssueStatus, IssueType, ToVec, User};
|
||||
|
||||
use crate::model::{EditIssueModal, ModalType, Model};
|
||||
use crate::shared::styled_avatar::StyledAvatar;
|
||||
use crate::shared::styled_button::StyledButton;
|
||||
use crate::shared::styled_editor::StyledEditor;
|
||||
use crate::shared::styled_field::StyledField;
|
||||
use crate::shared::styled_icon::{Icon, StyledIcon};
|
||||
use crate::shared::styled_select::{SelectOption, StyledSelect};
|
||||
use crate::shared::styled_select::{SelectOption, StyledSelect, StyledSelectChange};
|
||||
use crate::shared::styled_textarea::StyledTextarea;
|
||||
use crate::shared::ToNode;
|
||||
use crate::{FieldChange, FieldId, IssueId, Msg};
|
||||
@ -22,16 +23,72 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
modal.reporter_state.update(msg, orders);
|
||||
modal.assignees_state.update(msg, orders);
|
||||
modal.priority_state.update(msg, orders);
|
||||
|
||||
match msg {
|
||||
Msg::StyledSelectChanged(
|
||||
FieldId::IssueTypeEditModalTop,
|
||||
StyledSelectChange::Changed(value),
|
||||
) => {
|
||||
modal.payload.issue_type = (*value).into();
|
||||
}
|
||||
Msg::StyledSelectChanged(
|
||||
FieldId::StatusIssueEditModal,
|
||||
StyledSelectChange::Changed(value),
|
||||
) => {
|
||||
modal.payload.status = (*value).into();
|
||||
}
|
||||
Msg::StyledSelectChanged(
|
||||
FieldId::ReporterIssueEditModal,
|
||||
StyledSelectChange::Changed(value),
|
||||
) => {
|
||||
modal.payload.reporter_id = *value as i32;
|
||||
}
|
||||
Msg::StyledSelectChanged(
|
||||
FieldId::AssigneesIssueEditModal,
|
||||
StyledSelectChange::Changed(value),
|
||||
) => {
|
||||
modal.payload.user_ids.push(*value as i32);
|
||||
}
|
||||
Msg::StyledSelectChanged(
|
||||
FieldId::PriorityIssueEditModal,
|
||||
StyledSelectChange::Changed(value),
|
||||
) => {
|
||||
modal.payload.priority = (*value).into();
|
||||
}
|
||||
Msg::InputChanged(FieldId::TitleIssueEditModal, value) => {
|
||||
modal.payload.title = value.clone();
|
||||
}
|
||||
Msg::InputChanged(FieldId::DescriptionIssueEditModal, value) => {
|
||||
modal.payload.description = Some(value.clone());
|
||||
modal.payload.description_text = Some(value.clone());
|
||||
}
|
||||
Msg::ModalChanged(FieldChange::TabChanged(FieldId::DescriptionIssueEditModal, mode)) => {
|
||||
modal.description_editor_mode = mode.clone();
|
||||
}
|
||||
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view(_model: &Model, issue: &Issue, modal: &EditIssueModal) -> Node<Msg> {
|
||||
let issue_id = issue.id;
|
||||
pub fn view(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
|
||||
let EditIssueModal {
|
||||
id,
|
||||
link_copied,
|
||||
payload,
|
||||
top_type_state,
|
||||
status_state,
|
||||
reporter_state: _,
|
||||
assignees_state: _,
|
||||
priority_state: _,
|
||||
description_editor_mode,
|
||||
} = modal;
|
||||
let issue_id = id.clone();
|
||||
|
||||
let issue_type_select = StyledSelect::build(FieldId::IssueTypeEditModalTop)
|
||||
.dropdown_width(150)
|
||||
.name("type")
|
||||
.text_filter(modal.top_type_state.text_filter.as_str())
|
||||
.opened(modal.top_type_state.opened)
|
||||
.text_filter(top_type_state.text_filter.as_str())
|
||||
.opened(top_type_state.opened)
|
||||
.valid(true)
|
||||
.options(
|
||||
IssueType::ordered()
|
||||
@ -39,7 +96,10 @@ pub fn view(_model: &Model, issue: &Issue, modal: &EditIssueModal) -> Node<Msg>
|
||||
.map(|t| IssueTypeTopOption(issue_id, t))
|
||||
.collect(),
|
||||
)
|
||||
.selected(vec![IssueTypeTopOption(issue_id, modal.value.clone())])
|
||||
.selected(vec![IssueTypeTopOption(
|
||||
issue_id,
|
||||
payload.issue_type.clone(),
|
||||
)])
|
||||
.build()
|
||||
.into_node();
|
||||
|
||||
@ -71,7 +131,7 @@ pub fn view(_model: &Model, issue: &Issue, modal: &EditIssueModal) -> Node<Msg>
|
||||
.empty()
|
||||
.icon(Icon::Link)
|
||||
.on_click(click_handler)
|
||||
.children(vec![span![if modal.link_copied {
|
||||
.children(vec![span![if *link_copied {
|
||||
"Link Copied"
|
||||
} else {
|
||||
"Copy link"
|
||||
@ -93,33 +153,65 @@ pub fn view(_model: &Model, issue: &Issue, modal: &EditIssueModal) -> Node<Msg>
|
||||
|
||||
// left
|
||||
let title = StyledTextarea::build()
|
||||
.value(issue.title.as_str())
|
||||
.value(payload.title.as_str())
|
||||
.add_class("textarea")
|
||||
.max_height(48)
|
||||
.height(0)
|
||||
.build(FieldId::TitleIssueEditModal)
|
||||
.into_node();
|
||||
|
||||
let description_text = payload.description.as_ref().cloned().unwrap_or_default();
|
||||
let description = StyledEditor::build(FieldId::DescriptionIssueEditModal)
|
||||
.text(description_text)
|
||||
.mode(description_editor_mode.clone())
|
||||
.build()
|
||||
.into_node();
|
||||
let description_field = StyledField::build().input(description).build().into_node();
|
||||
|
||||
// right
|
||||
let status = StyledSelect::build(FieldId::StatusIssueEditModal)
|
||||
.name("status")
|
||||
.opened(modal.status_state.opened)
|
||||
.text_filter(modal.status_state.text_filter.as_str())
|
||||
.opened(status_state.opened)
|
||||
.normal()
|
||||
.text_filter(status_state.text_filter.as_str())
|
||||
.options(
|
||||
IssueStatus::ordered()
|
||||
.into_iter()
|
||||
.map(|opt| IssueStatusOption(issue_id, opt))
|
||||
.collect(),
|
||||
)
|
||||
.selected(vec![IssueStatusOption(issue_id, issue.status.clone())])
|
||||
.selected(vec![IssueStatusOption(issue_id, payload.status.clone())])
|
||||
.valid(true)
|
||||
.build()
|
||||
.into_node();
|
||||
// let status_field = StyledField::build()
|
||||
// .input(status)
|
||||
// .label("Status")
|
||||
// .build()
|
||||
// .into_node();
|
||||
let status_field = StyledField::build()
|
||||
.input(status)
|
||||
.label("Status")
|
||||
.build()
|
||||
.into_node();
|
||||
|
||||
let assignees = StyledSelect::build(FieldId::AssigneesIssueEditModal)
|
||||
.name("assignees")
|
||||
.opened(modal.assignees_state.opened)
|
||||
.normal()
|
||||
.multi()
|
||||
.text_filter(modal.assignees_state.text_filter.as_str())
|
||||
.options(model.users.iter().map(|user| UserOption(user)).collect())
|
||||
.selected(
|
||||
model
|
||||
.users
|
||||
.iter()
|
||||
.filter(|user| payload.user_ids.contains(&user.id))
|
||||
.map(|user| UserOption(user))
|
||||
.collect(),
|
||||
)
|
||||
.build()
|
||||
.into_node();
|
||||
let assignees_field = StyledField::build()
|
||||
.input(assignees)
|
||||
.label("Assignees")
|
||||
.build()
|
||||
.into_node();
|
||||
|
||||
div![
|
||||
attrs![At::Class => "issueDetails"],
|
||||
@ -138,10 +230,10 @@ pub fn view(_model: &Model, issue: &Issue, modal: &EditIssueModal) -> Node<Msg>
|
||||
div![
|
||||
attrs![At::Class => "left"],
|
||||
title,
|
||||
div![attrs![At::Class => "description"]],
|
||||
description_field,
|
||||
div![attrs![At::Class => "comments"]],
|
||||
],
|
||||
div![attrs![At::Class => "right"], status],
|
||||
div![attrs![At::Class => "right"], status_field, assignees_field,],
|
||||
],
|
||||
]
|
||||
}
|
||||
|
@ -1,11 +1,12 @@
|
||||
use seed::{prelude::*, *};
|
||||
|
||||
use jirs_data::{Issue, IssueType, UpdateIssuePayload};
|
||||
use jirs_data::UpdateIssuePayload;
|
||||
|
||||
use crate::api::send_ws_msg;
|
||||
use crate::model::{AddIssueModal, EditIssueModal, ModalType, Page};
|
||||
use crate::shared::styled_editor::Mode;
|
||||
use crate::shared::styled_modal::{StyledModal, Variant as ModalVariant};
|
||||
use crate::shared::styled_select::{StyledSelectChange, StyledSelectState};
|
||||
use crate::shared::styled_select::StyledSelectState;
|
||||
use crate::shared::{find_issue, ToNode};
|
||||
use crate::{model, FieldChange, FieldId, Msg};
|
||||
|
||||
@ -36,22 +37,41 @@ pub fn update(msg: &Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>
|
||||
}
|
||||
|
||||
Msg::ChangePage(Page::EditIssue(issue_id)) => {
|
||||
let value = find_issue(model, *issue_id)
|
||||
.map(|issue| issue.issue_type.clone())
|
||||
.unwrap_or_else(|| IssueType::Task);
|
||||
model.modals.push(ModalType::EditIssue(
|
||||
*issue_id,
|
||||
EditIssueModal {
|
||||
id: *issue_id,
|
||||
value,
|
||||
link_copied: false,
|
||||
top_type_state: StyledSelectState::new(FieldId::IssueTypeEditModalTop),
|
||||
status_state: StyledSelectState::new(FieldId::StatusIssueEditModal),
|
||||
reporter_state: StyledSelectState::new(FieldId::ReporterIssueEditModal),
|
||||
assignees_state: StyledSelectState::new(FieldId::AssigneesIssueEditModal),
|
||||
priority_state: StyledSelectState::new(FieldId::PriorityIssueEditModal),
|
||||
},
|
||||
));
|
||||
let modal = {
|
||||
let issue = match find_issue(model, *issue_id) {
|
||||
Some(issue) => issue,
|
||||
_ => return,
|
||||
};
|
||||
ModalType::EditIssue(
|
||||
*issue_id,
|
||||
EditIssueModal {
|
||||
id: *issue_id,
|
||||
link_copied: false,
|
||||
payload: UpdateIssuePayload {
|
||||
title: issue.title.clone(),
|
||||
issue_type: issue.issue_type.clone(),
|
||||
status: issue.status.clone(),
|
||||
priority: issue.priority.clone(),
|
||||
list_position: issue.list_position.clone(),
|
||||
description: issue.description.clone(),
|
||||
description_text: issue.description_text.clone(),
|
||||
estimate: issue.estimate.clone(),
|
||||
time_spent: issue.time_spent.clone(),
|
||||
time_remaining: issue.time_remaining.clone(),
|
||||
project_id: issue.project_id.clone(),
|
||||
reporter_id: issue.reporter_id.clone(),
|
||||
user_ids: issue.user_ids.clone(),
|
||||
},
|
||||
top_type_state: StyledSelectState::new(FieldId::IssueTypeEditModalTop),
|
||||
status_state: StyledSelectState::new(FieldId::StatusIssueEditModal),
|
||||
reporter_state: StyledSelectState::new(FieldId::ReporterIssueEditModal),
|
||||
assignees_state: StyledSelectState::new(FieldId::AssigneesIssueEditModal),
|
||||
priority_state: StyledSelectState::new(FieldId::PriorityIssueEditModal),
|
||||
description_editor_mode: Mode::Editor,
|
||||
},
|
||||
)
|
||||
};
|
||||
model.modals.push(modal);
|
||||
}
|
||||
Msg::ChangePage(Page::AddIssue) => {
|
||||
let mut modal = AddIssueModal::default();
|
||||
@ -59,54 +79,6 @@ pub fn update(msg: &Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>
|
||||
model.modals.push(ModalType::AddIssue(modal));
|
||||
}
|
||||
|
||||
Msg::StyledSelectChanged(FieldId::IssueTypeEditModalTop, change) => {
|
||||
match (change, model.modals.last_mut()) {
|
||||
(StyledSelectChange::Text(ref text), Some(ModalType::EditIssue(_, modal))) => {
|
||||
modal.top_type_state.text_filter = text.clone();
|
||||
}
|
||||
(
|
||||
StyledSelectChange::DropDownVisibility(flag),
|
||||
Some(ModalType::EditIssue(_, modal)),
|
||||
) => {
|
||||
modal.top_type_state.opened = *flag;
|
||||
}
|
||||
(
|
||||
StyledSelectChange::Changed(value),
|
||||
Some(ModalType::EditIssue(issue_id, modal)),
|
||||
) => {
|
||||
modal.value = (*value).into();
|
||||
let mut found: Option<&mut Issue> = None;
|
||||
for issue in model.issues.iter_mut() {
|
||||
if issue.id == *issue_id {
|
||||
found = Some(issue);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let issue = match found {
|
||||
Some(i) => i,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
let form = UpdateIssuePayload {
|
||||
title: Some(issue.title.clone()),
|
||||
issue_type: Some(modal.value.clone()),
|
||||
status: Some(issue.status.clone()),
|
||||
priority: Some(issue.priority.clone()),
|
||||
list_position: Some(issue.list_position),
|
||||
description: Some(issue.description.clone()),
|
||||
description_text: Some(issue.description_text.clone()),
|
||||
estimate: Some(issue.estimate.clone()),
|
||||
time_spent: Some(issue.time_spent.clone()),
|
||||
time_remaining: Some(issue.time_remaining.clone()),
|
||||
project_id: Some(issue.project_id.clone()),
|
||||
user_ids: Some(issue.user_ids.clone()),
|
||||
};
|
||||
send_ws_msg(jirs_data::WsMsg::IssueUpdateRequest(issue_id.clone(), form));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
_ => (),
|
||||
}
|
||||
add_issue::update(msg, model, orders);
|
||||
@ -119,8 +91,8 @@ pub fn view(model: &model::Model) -> Node<Msg> {
|
||||
.iter()
|
||||
.map(|modal| match modal {
|
||||
ModalType::EditIssue(issue_id, modal) => {
|
||||
if let Some(issue) = find_issue(model, *issue_id) {
|
||||
let details = issue_details::view(model, issue, &modal);
|
||||
if let Some(_issue) = find_issue(model, *issue_id) {
|
||||
let details = issue_details::view(model, &modal);
|
||||
StyledModal::build()
|
||||
.variant(ModalVariant::Center)
|
||||
.width(1040)
|
||||
|
@ -5,6 +5,7 @@ use uuid::Uuid;
|
||||
|
||||
use jirs_data::*;
|
||||
|
||||
use crate::shared::styled_editor::Mode;
|
||||
use crate::shared::styled_select::StyledSelectState;
|
||||
use crate::{FieldId, IssueId, UserId, HOST_URL};
|
||||
|
||||
@ -20,13 +21,15 @@ pub enum ModalType {
|
||||
#[derive(Clone, Debug, PartialOrd, PartialEq, Hash)]
|
||||
pub struct EditIssueModal {
|
||||
pub id: i32,
|
||||
pub value: IssueType,
|
||||
pub link_copied: bool,
|
||||
pub payload: UpdateIssuePayload,
|
||||
pub top_type_state: StyledSelectState,
|
||||
pub status_state: StyledSelectState,
|
||||
pub reporter_state: StyledSelectState,
|
||||
pub assignees_state: StyledSelectState,
|
||||
pub priority_state: StyledSelectState,
|
||||
|
||||
pub description_editor_mode: Mode,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialOrd, PartialEq, Hash)]
|
||||
|
@ -11,6 +11,7 @@ pub mod navbar_left;
|
||||
pub mod styled_avatar;
|
||||
pub mod styled_button;
|
||||
pub mod styled_confirm_modal;
|
||||
pub mod styled_editor;
|
||||
pub mod styled_field;
|
||||
pub mod styled_form;
|
||||
pub mod styled_icon;
|
||||
|
158
jirs-client/src/shared/styled_editor.rs
Normal file
158
jirs-client/src/shared/styled_editor.rs
Normal file
@ -0,0 +1,158 @@
|
||||
use seed::{prelude::*, *};
|
||||
|
||||
use crate::shared::styled_textarea::StyledTextarea;
|
||||
use crate::shared::ToNode;
|
||||
use crate::{FieldChange, FieldId, Msg};
|
||||
|
||||
#[derive(Debug, Clone, PartialOrd, PartialEq, Hash)]
|
||||
pub enum Mode {
|
||||
Editor,
|
||||
View,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StyledEditorState {
|
||||
pub mode: Mode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StyledEditor {
|
||||
id: FieldId,
|
||||
text: String,
|
||||
mode: Mode,
|
||||
}
|
||||
|
||||
impl StyledEditor {
|
||||
pub fn build(id: FieldId) -> StyledEditorBuilder {
|
||||
StyledEditorBuilder {
|
||||
id,
|
||||
text: String::new(),
|
||||
mode: Mode::Editor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct StyledEditorBuilder {
|
||||
id: FieldId,
|
||||
text: String,
|
||||
mode: Mode,
|
||||
}
|
||||
|
||||
impl StyledEditorBuilder {
|
||||
pub fn text<S>(mut self, text: S) -> Self
|
||||
where
|
||||
S: Into<String>,
|
||||
{
|
||||
self.text = text.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn mode(mut self, mode: Mode) -> Self {
|
||||
self.mode = mode;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> StyledEditor {
|
||||
StyledEditor {
|
||||
id: self.id,
|
||||
text: self.text,
|
||||
mode: self.mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToNode for StyledEditor {
|
||||
fn into_node(self) -> Node<Msg> {
|
||||
render(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(values: StyledEditor) -> Node<Msg> {
|
||||
let StyledEditor { id, text, mode } = values;
|
||||
|
||||
let field_id = id.clone();
|
||||
let on_editor_clicked = mouse_ev(Ev::Click, move |ev| {
|
||||
ev.stop_propagation();
|
||||
Msg::ModalChanged(FieldChange::TabChanged(field_id.clone(), Mode::Editor))
|
||||
});
|
||||
|
||||
let field_id = id.clone();
|
||||
let on_view_clicked = mouse_ev(Ev::Click, move |ev| {
|
||||
ev.stop_propagation();
|
||||
Msg::ModalChanged(FieldChange::TabChanged(field_id.clone(), Mode::View))
|
||||
});
|
||||
|
||||
let editor_id = format!("editor-{}", id.clone());
|
||||
let view_id = format!("view-{}", id.clone());
|
||||
let name = format!("styled-editor-{}", id.clone());
|
||||
|
||||
let text_area = StyledTextarea::build()
|
||||
.height(40)
|
||||
.value(text.as_str())
|
||||
.build(id.clone())
|
||||
.into_node();
|
||||
|
||||
let parsed = comrak::markdown_to_html(
|
||||
text.as_str(),
|
||||
&comrak::ComrakOptions {
|
||||
hardbreaks: false,
|
||||
smart: true,
|
||||
github_pre_lang: true,
|
||||
width: 0,
|
||||
default_info_string: None,
|
||||
unsafe_: false,
|
||||
ext_strikethrough: true,
|
||||
ext_tagfilter: true,
|
||||
ext_table: true,
|
||||
ext_autolink: true,
|
||||
ext_tasklist: true,
|
||||
ext_superscript: true,
|
||||
ext_header_ids: None,
|
||||
ext_footnotes: true,
|
||||
ext_description_lists: true,
|
||||
},
|
||||
);
|
||||
let parsed_node = Node::from_html(parsed.as_str());
|
||||
|
||||
let (editor_radio_node, view_radio_node) = match mode {
|
||||
Mode::Editor => (
|
||||
seed::input![
|
||||
id![editor_id.as_str()],
|
||||
attrs![At::Type => "radio"; At::Name => name.as_str(); At::Class => "editorRadio"; At::Checked => true],
|
||||
],
|
||||
seed::input![
|
||||
id![view_id.as_str()],
|
||||
attrs![ At::Type => "radio"; At::Name => name.as_str(); At::Class => "viewRadio";],
|
||||
],
|
||||
),
|
||||
Mode::View => (
|
||||
seed::input![
|
||||
id![editor_id.as_str()],
|
||||
attrs![At::Type => "radio"; At::Name => name.as_str(); At::Class => "editorRadio";],
|
||||
],
|
||||
seed::input![
|
||||
id![view_id.as_str()],
|
||||
attrs![ At::Type => "radio"; At::Name => name.as_str(); At::Class => "viewRadio"; At::Checked => true],
|
||||
],
|
||||
),
|
||||
};
|
||||
|
||||
div![
|
||||
attrs![At::Class => "styledEditor"],
|
||||
label![
|
||||
attrs![At::Class => "navbar editorTab", At::For => editor_id.as_str()],
|
||||
"Editor",
|
||||
on_editor_clicked
|
||||
],
|
||||
label![
|
||||
attrs![At::Class => "navbar viewTab"; At::For => view_id.as_str()],
|
||||
"View",
|
||||
on_view_clicked
|
||||
],
|
||||
editor_radio_node,
|
||||
text_area,
|
||||
view_radio_node,
|
||||
div![attrs![At::Class => "view"], parsed_node]
|
||||
]
|
||||
}
|
@ -78,18 +78,19 @@ pub fn dropped(_status: IssueStatus, model: &mut Model) {
|
||||
}
|
||||
|
||||
let payload = UpdateIssuePayload {
|
||||
title: Some(issue.title.clone()),
|
||||
issue_type: Some(issue.issue_type.clone()),
|
||||
status: Some(issue.status.clone()),
|
||||
priority: Some(issue.priority.clone()),
|
||||
list_position: Some(issue.list_position),
|
||||
description: Some(issue.description.clone()),
|
||||
description_text: Some(issue.description_text.clone()),
|
||||
estimate: Some(issue.estimate),
|
||||
time_spent: Some(issue.time_spent),
|
||||
time_remaining: Some(issue.time_remaining),
|
||||
project_id: Some(issue.project_id),
|
||||
user_ids: Some(issue.user_ids.clone()),
|
||||
title: issue.title.clone(),
|
||||
issue_type: issue.issue_type.clone(),
|
||||
status: issue.status.clone(),
|
||||
priority: issue.priority.clone(),
|
||||
list_position: issue.list_position,
|
||||
description: issue.description.clone(),
|
||||
description_text: issue.description_text.clone(),
|
||||
estimate: issue.estimate,
|
||||
time_spent: issue.time_spent,
|
||||
time_remaining: issue.time_remaining,
|
||||
project_id: issue.project_id,
|
||||
reporter_id: issue.reporter_id,
|
||||
user_ids: issue.user_ids.clone(),
|
||||
};
|
||||
model.project_page.dragged_issue_id = None;
|
||||
send_ws_msg(WsMsg::IssueUpdateRequest(issue.id, payload));
|
||||
|
@ -62,6 +62,7 @@ module.exports = {
|
||||
plugins: [
|
||||
new WasmPackPlugin({
|
||||
crateDirectory: path.resolve(__dirname),
|
||||
extraArgs: '--no-typescript'
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
template: path.resolve(__dirname, "js", "template.ejs"),
|
||||
|
@ -109,6 +109,18 @@ impl Into<u32> for IssueStatus {
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<IssueStatus> for u32 {
|
||||
fn into(self) -> IssueStatus {
|
||||
match self {
|
||||
0 => IssueStatus::Backlog,
|
||||
1 => IssueStatus::Selected,
|
||||
2 => IssueStatus::InProgress,
|
||||
3 => IssueStatus::Done,
|
||||
_ => IssueStatus::Backlog,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for IssueStatus {
|
||||
type Err = String;
|
||||
|
||||
@ -372,20 +384,21 @@ pub struct Token {
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, PartialOrd, Hash)]
|
||||
pub struct UpdateIssuePayload {
|
||||
pub title: Option<String>,
|
||||
pub issue_type: Option<IssueType>,
|
||||
pub status: Option<IssueStatus>,
|
||||
pub priority: Option<IssuePriority>,
|
||||
pub list_position: Option<i32>,
|
||||
pub description: Option<Option<String>>,
|
||||
pub description_text: Option<Option<String>>,
|
||||
pub estimate: Option<Option<i32>>,
|
||||
pub time_spent: Option<Option<i32>>,
|
||||
pub time_remaining: Option<Option<i32>>,
|
||||
pub project_id: Option<i32>,
|
||||
pub user_ids: Option<Vec<i32>>,
|
||||
pub title: String,
|
||||
pub issue_type: IssueType,
|
||||
pub status: IssueStatus,
|
||||
pub priority: IssuePriority,
|
||||
pub list_position: i32,
|
||||
pub description: Option<String>,
|
||||
pub description_text: Option<String>,
|
||||
pub estimate: Option<i32>,
|
||||
pub time_spent: Option<i32>,
|
||||
pub time_remaining: Option<i32>,
|
||||
pub project_id: i32,
|
||||
pub reporter_id: i32,
|
||||
pub user_ids: Vec<i32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
|
@ -101,18 +101,18 @@ pub async fn update(
|
||||
};
|
||||
let signal = UpdateIssue {
|
||||
issue_id,
|
||||
title: payload.title.clone(),
|
||||
issue_type: payload.issue_type.clone(),
|
||||
status: payload.status.clone(),
|
||||
priority: payload.priority.clone(),
|
||||
list_position: payload.list_position.clone(),
|
||||
description: payload.description.clone(),
|
||||
description_text: payload.description_text.clone(),
|
||||
estimate: payload.estimate.clone(),
|
||||
time_spent: payload.time_spent.clone(),
|
||||
time_remaining: payload.time_remaining.clone(),
|
||||
project_id: payload.project_id.clone(),
|
||||
user_ids: payload.user_ids.clone(),
|
||||
title: Some(payload.title.clone()),
|
||||
issue_type: Some(payload.issue_type.clone()),
|
||||
status: Some(payload.status.clone()),
|
||||
priority: Some(payload.priority.clone()),
|
||||
list_position: Some(payload.list_position.clone()),
|
||||
description: Some(payload.description.clone()),
|
||||
description_text: Some(payload.description_text.clone()),
|
||||
estimate: Some(payload.estimate.clone()),
|
||||
time_spent: Some(payload.time_spent.clone()),
|
||||
time_remaining: Some(payload.time_remaining.clone()),
|
||||
project_id: Some(payload.project_id.clone()),
|
||||
user_ids: Some(payload.user_ids.clone()),
|
||||
};
|
||||
match db.send(signal).await {
|
||||
Ok(Ok(_)) => (),
|
||||
|
@ -125,18 +125,18 @@ impl WebSocketActor {
|
||||
.db
|
||||
.send(UpdateIssue {
|
||||
issue_id,
|
||||
title: payload.title,
|
||||
issue_type: payload.issue_type,
|
||||
status: payload.status,
|
||||
priority: payload.priority,
|
||||
list_position: payload.list_position,
|
||||
description: payload.description,
|
||||
description_text: payload.description_text,
|
||||
estimate: payload.estimate,
|
||||
time_spent: payload.time_spent,
|
||||
time_remaining: payload.time_remaining,
|
||||
project_id: payload.project_id,
|
||||
user_ids: payload.user_ids,
|
||||
title: Some(payload.title),
|
||||
issue_type: Some(payload.issue_type),
|
||||
status: Some(payload.status),
|
||||
priority: Some(payload.priority),
|
||||
list_position: Some(payload.list_position),
|
||||
description: Some(payload.description),
|
||||
description_text: Some(payload.description_text),
|
||||
estimate: Some(payload.estimate),
|
||||
time_spent: Some(payload.time_spent),
|
||||
time_remaining: Some(payload.time_remaining),
|
||||
project_id: Some(payload.project_id),
|
||||
user_ids: Some(payload.user_ids),
|
||||
})
|
||||
.await
|
||||
{
|
||||
|
@ -1,6 +0,0 @@
|
||||
{
|
||||
"extends": ["plugin:cypress/recommended"],
|
||||
"rules": {
|
||||
"no-unused-expressions": 0 // chai assertions trigger this rule
|
||||
}
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
import { testid } from '../support/utils';
|
||||
|
||||
describe('Authentication', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
cy.visit('/');
|
||||
});
|
||||
|
||||
it('creates guest account if user has no auth token', () => {
|
||||
cy.window()
|
||||
.its('localStorage.authToken')
|
||||
.should('be.undefined');
|
||||
|
||||
cy.window()
|
||||
.its('localStorage.authToken')
|
||||
.should('be.a', 'string')
|
||||
.and('not.be.empty');
|
||||
|
||||
cy.get(testid`list-issue`).should('have.length', 8);
|
||||
});
|
||||
});
|
@ -1,35 +0,0 @@
|
||||
import { testid } from '../support/utils';
|
||||
|
||||
describe('Issue create', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
cy.createTestAccount();
|
||||
cy.visit('/project/settings?modal-issue-create=true');
|
||||
});
|
||||
|
||||
it('validates form and creates issue successfully', () => {
|
||||
cy.get(testid`modal:issue-create`).within(() => {
|
||||
cy.get('button[type="submit"]').click();
|
||||
cy.get(testid`form-field:title`).should('contain', 'This field is required');
|
||||
|
||||
cy.selectOption('type', 'Story');
|
||||
cy.get('input[name="title"]').type('TEST_TITLE');
|
||||
cy.get('.ql-editor').type('TEST_DESCRIPTION');
|
||||
cy.selectOption('reporterId', 'Yoda');
|
||||
cy.selectOption('userIds', 'Gaben', 'Yoda');
|
||||
cy.selectOption('priority', 'High');
|
||||
|
||||
cy.get('button[type="submit"]').click();
|
||||
});
|
||||
|
||||
cy.get(testid`modal:issue-create`).should('not.exist');
|
||||
cy.contains('Issue has been successfully created.').should('exist');
|
||||
cy.location('pathname').should('equal', '/project/board');
|
||||
cy.location('search').should('be.empty');
|
||||
|
||||
cy.contains(testid`list-issue`, 'TEST_TITLE')
|
||||
.should('have.descendants', testid`avatar:Gaben`)
|
||||
.and('have.descendants', testid`avatar:Yoda`)
|
||||
.and('have.descendants', testid`icon:story`);
|
||||
});
|
||||
});
|
@ -1,166 +0,0 @@
|
||||
import { testid } from '../support/utils';
|
||||
|
||||
describe('Issue details', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
cy.createTestAccount();
|
||||
cy.visit('/project/board');
|
||||
getListIssue().click(); // open issue details modal
|
||||
});
|
||||
|
||||
it('updates type, status, assignees, reporter, priority successfully', () => {
|
||||
getIssueDetailsModal().within(() => {
|
||||
cy.selectOption('type', 'Story');
|
||||
cy.selectOption('status', 'Done');
|
||||
cy.selectOption('assignees', 'Gaben', 'Yoda');
|
||||
cy.selectOption('reporter', 'Yoda');
|
||||
cy.selectOption('priority', 'Medium');
|
||||
});
|
||||
|
||||
cy.assertReloadAssert(() => {
|
||||
getIssueDetailsModal().within(() => {
|
||||
cy.selectShouldContain('type', 'Story');
|
||||
cy.selectShouldContain('status', 'Done');
|
||||
cy.selectShouldContain('assignees', 'Gaben', 'Yoda');
|
||||
cy.selectShouldContain('reporter', 'Yoda');
|
||||
cy.selectShouldContain('priority', 'Medium');
|
||||
});
|
||||
|
||||
getListIssue()
|
||||
.should('have.descendants', testid`avatar:Gaben`)
|
||||
.and('have.descendants', testid`avatar:Yoda`)
|
||||
.and('have.descendants', testid`icon:story`);
|
||||
});
|
||||
});
|
||||
|
||||
it('updates title, description successfully', () => {
|
||||
getIssueDetailsModal().within(() => {
|
||||
cy.get('textarea[placeholder="Short summary"]')
|
||||
.clear()
|
||||
.type('TEST_TITLE')
|
||||
.blur();
|
||||
|
||||
cy.contains('Add a description...')
|
||||
.click()
|
||||
.should('not.exist');
|
||||
|
||||
cy.get('.ql-editor').type('TEST_DESCRIPTION');
|
||||
|
||||
cy.contains('button', 'Save')
|
||||
.click()
|
||||
.should('not.exist');
|
||||
});
|
||||
|
||||
cy.assertReloadAssert(() => {
|
||||
getIssueDetailsModal().within(() => {
|
||||
cy.get('textarea[placeholder="Short summary"]').should('have.value', 'TEST_TITLE');
|
||||
cy.get('.ql-editor').should('contain', 'TEST_DESCRIPTION');
|
||||
});
|
||||
|
||||
cy.get(testid`list-issue`).should('contain', 'TEST_TITLE');
|
||||
});
|
||||
});
|
||||
|
||||
it('updates estimate, time tracking successfully', () => {
|
||||
getIssueDetailsModal().within(() => {
|
||||
getNumberInputAtIndex(0).debounced('type', '10');
|
||||
cy.contains('10h estimated').click(); // open tracking modal
|
||||
});
|
||||
|
||||
cy.get(testid`modal:tracking`).within(() => {
|
||||
cy.contains('No time logged').should('exist');
|
||||
|
||||
getNumberInputAtIndex(0).debounced('type', 1);
|
||||
cy.get('div[width="10"]').should('exist'); // tracking bar
|
||||
|
||||
getNumberInputAtIndex(1).debounced('type', 2);
|
||||
|
||||
cy.contains('button', 'Done')
|
||||
.click()
|
||||
.should('not.exist');
|
||||
});
|
||||
|
||||
cy.assertReloadAssert(() => {
|
||||
getIssueDetailsModal().within(() => {
|
||||
getNumberInputAtIndex(0).should('have.value', '10');
|
||||
cy.contains('1h logged').should('exist');
|
||||
cy.contains('2h remaining').should('exist');
|
||||
cy.get('div[width*="33.3333"]').should('exist');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes an issue successfully', () => {
|
||||
getIssueDetailsModal()
|
||||
.find(`button ${testid`icon:trash`}`)
|
||||
.click();
|
||||
|
||||
cy.get(testid`modal:confirm`)
|
||||
.contains('button', 'Delete issue')
|
||||
.click();
|
||||
|
||||
cy.assertReloadAssert(() => {
|
||||
getIssueDetailsModal().should('not.exist');
|
||||
getListIssue().should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a comment successfully', () => {
|
||||
getIssueDetailsModal().within(() => {
|
||||
cy.contains('Add a comment...')
|
||||
.click()
|
||||
.should('not.exist');
|
||||
|
||||
cy.get('textarea[placeholder="Add a comment..."]').type('TEST_COMMENT');
|
||||
|
||||
cy.contains('button', 'Save')
|
||||
.click()
|
||||
.should('not.exist');
|
||||
|
||||
cy.contains('Add a comment...').should('exist');
|
||||
cy.get(testid`issue-comment`).should('contain', 'TEST_COMMENT');
|
||||
});
|
||||
});
|
||||
|
||||
it('edits a comment successfully', () => {
|
||||
getIssueDetailsModal().within(() => {
|
||||
cy.get(testid`issue-comment`)
|
||||
.contains('Edit')
|
||||
.click()
|
||||
.should('not.exist');
|
||||
|
||||
cy.get('textarea[placeholder="Add a comment..."]')
|
||||
.should('have.value', 'Comment body')
|
||||
.clear()
|
||||
.type('TEST_COMMENT_EDITED');
|
||||
|
||||
cy.contains('button', 'Save')
|
||||
.click()
|
||||
.should('not.exist');
|
||||
|
||||
cy.get(testid`issue-comment`)
|
||||
.should('contain', 'Edit')
|
||||
.and('contain', 'TEST_COMMENT_EDITED');
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes a comment successfully', () => {
|
||||
getIssueDetailsModal()
|
||||
.find(testid`issue-comment`)
|
||||
.contains('Delete')
|
||||
.click();
|
||||
|
||||
cy.get(testid`modal:confirm`)
|
||||
.contains('button', 'Delete comment')
|
||||
.click()
|
||||
.should('not.exist');
|
||||
|
||||
getIssueDetailsModal()
|
||||
.find(testid`issue-comment`)
|
||||
.should('not.exist');
|
||||
});
|
||||
|
||||
const getIssueDetailsModal = () => cy.get(testid`modal:issue-details`);
|
||||
const getListIssue = () => cy.contains(testid`list-issue`, 'Issue title 1');
|
||||
const getNumberInputAtIndex = index => cy.get('input[placeholder="Number"]').eq(index);
|
||||
});
|
@ -1,35 +0,0 @@
|
||||
import { testid } from '../support/utils';
|
||||
|
||||
describe('Issue filters', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
cy.createTestAccount();
|
||||
cy.visit('/project/board');
|
||||
});
|
||||
|
||||
it('filters issues', () => {
|
||||
getSearchInput().debounced('type', 'Issue title 1');
|
||||
assertIssuesCount(1);
|
||||
getSearchInput().debounced('clear');
|
||||
assertIssuesCount(3);
|
||||
|
||||
getUserAvatar().click();
|
||||
assertIssuesCount(2);
|
||||
getUserAvatar().click();
|
||||
assertIssuesCount(3);
|
||||
|
||||
getMyOnlyButton().click();
|
||||
assertIssuesCount(2);
|
||||
getMyOnlyButton().click();
|
||||
assertIssuesCount(3);
|
||||
|
||||
getRecentButton().click();
|
||||
assertIssuesCount(3);
|
||||
});
|
||||
|
||||
const getSearchInput = () => cy.get(testid`board-filters`).find('input');
|
||||
const getUserAvatar = () => cy.get(testid`board-filters`).find(testid`avatar:Gaben`);
|
||||
const getMyOnlyButton = () => cy.get(testid`board-filters`).contains('Only My Issues');
|
||||
const getRecentButton = () => cy.get(testid`board-filters`).contains('Recently Updated');
|
||||
const assertIssuesCount = count => cy.get(testid`list-issue`).should('have.length', count);
|
||||
});
|
@ -1,50 +0,0 @@
|
||||
import { testid } from '../support/utils';
|
||||
|
||||
describe('Issue search', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
cy.createTestAccount();
|
||||
cy.visit('/project/board?modal-issue-search=true');
|
||||
});
|
||||
|
||||
it('displays recent issues if search input is empty', () => {
|
||||
getIssueSearchModal().within(() => {
|
||||
cy.contains('Recent Issues').should('exist');
|
||||
getIssues().should('have.length', 3);
|
||||
|
||||
cy.get('input').debounced('type', 'anything');
|
||||
cy.contains('Recent Issues').should('not.exist');
|
||||
|
||||
cy.get('input').debounced('clear');
|
||||
cy.contains('Recent Issues').should('exist');
|
||||
getIssues().should('have.length', 3);
|
||||
});
|
||||
});
|
||||
|
||||
it('displays matching issues successfully', () => {
|
||||
getIssueSearchModal().within(() => {
|
||||
cy.get('input').debounced('type', 'Issue');
|
||||
getIssues().should('have.length', 3);
|
||||
|
||||
cy.get('input').debounced('type', ' description');
|
||||
getIssues().should('have.length', 2);
|
||||
|
||||
cy.get('input').debounced('type', ' 3');
|
||||
getIssues().should('have.length', 1);
|
||||
|
||||
cy.contains('Matching Issues').should('exist');
|
||||
});
|
||||
});
|
||||
|
||||
it('displays message if no results were found', () => {
|
||||
getIssueSearchModal().within(() => {
|
||||
cy.get('input').debounced('type', 'gibberish');
|
||||
|
||||
getIssues().should('not.exist');
|
||||
cy.contains("We couldn't find anything matching your search").should('exist');
|
||||
});
|
||||
});
|
||||
|
||||
const getIssueSearchModal = () => cy.get(testid`modal:issue-search`);
|
||||
const getIssues = () => cy.get('a[href*="/project/board/issues/"]');
|
||||
});
|
@ -1,48 +0,0 @@
|
||||
import { KeyCodes } from 'shared/constants/keyCodes';
|
||||
|
||||
import { testid } from '../support/utils';
|
||||
|
||||
describe('Issues drag & drop', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
cy.createTestAccount();
|
||||
cy.visit('/project/board');
|
||||
});
|
||||
|
||||
it('moves issue between different lists', () => {
|
||||
cy.get(testid`board-list:backlog`).should('contain', firstIssueTitle);
|
||||
cy.get(testid`board-list:selected`).should('not.contain', firstIssueTitle);
|
||||
moveFirstIssue(KeyCodes.ARROW_RIGHT);
|
||||
|
||||
cy.assertReloadAssert(() => {
|
||||
cy.get(testid`board-list:backlog`).should('not.contain', firstIssueTitle);
|
||||
cy.get(testid`board-list:selected`).should('contain', firstIssueTitle);
|
||||
});
|
||||
});
|
||||
|
||||
it('moves issue within a single list', () => {
|
||||
getIssueAtIndex(0).should('contain', firstIssueTitle);
|
||||
getIssueAtIndex(1).should('contain', secondIssueTitle);
|
||||
moveFirstIssue(KeyCodes.ARROW_DOWN);
|
||||
|
||||
cy.assertReloadAssert(() => {
|
||||
getIssueAtIndex(0).should('contain', secondIssueTitle);
|
||||
getIssueAtIndex(1).should('contain', firstIssueTitle);
|
||||
});
|
||||
});
|
||||
|
||||
const firstIssueTitle = 'Issue title 1';
|
||||
const secondIssueTitle = 'Issue title 2';
|
||||
|
||||
const getIssueAtIndex = index => cy.get(testid`list-issue`).eq(index);
|
||||
|
||||
const moveFirstIssue = directionKeyCode => {
|
||||
cy.waitForXHR('PUT', '/issues/**', () => {
|
||||
getIssueAtIndex(0)
|
||||
.focus()
|
||||
.trigger('keydown', { keyCode: KeyCodes.SPACE })
|
||||
.trigger('keydown', { keyCode: directionKeyCode, force: true })
|
||||
.trigger('keydown', { keyCode: KeyCodes.SPACE, force: true });
|
||||
});
|
||||
};
|
||||
});
|
@ -1,34 +0,0 @@
|
||||
import { testid } from '../support/utils';
|
||||
|
||||
describe('Project settings', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
cy.createTestAccount();
|
||||
cy.visit('/project/settings');
|
||||
});
|
||||
|
||||
it('should display current values in form', () => {
|
||||
cy.get('input[name="name"]').should('have.value', 'Project name');
|
||||
cy.get('input[name="url"]').should('have.value', 'https://www.testurl.com');
|
||||
cy.get('.ql-editor').should('contain', 'Project description');
|
||||
cy.selectShouldContain('category', 'Software');
|
||||
});
|
||||
|
||||
it('validates form and updates project successfully', () => {
|
||||
cy.get('input[name="name"]').clear();
|
||||
cy.get('button[type="submit"]').click();
|
||||
cy.get(testid`form-field:name`).should('contain', 'This field is required');
|
||||
|
||||
cy.get('input[name="name"]').type('TEST_NAME');
|
||||
cy.get(testid`form-field:name`).should('not.contain', 'This field is required');
|
||||
|
||||
cy.selectOption('category', 'Business');
|
||||
cy.get('button[type="submit"]').click();
|
||||
cy.contains('Changes have been saved successfully.').should('exist');
|
||||
|
||||
cy.reload();
|
||||
|
||||
cy.get('input[name="name"]').should('have.value', 'TEST_NAME');
|
||||
cy.selectShouldContain('category', 'Business');
|
||||
});
|
||||
});
|
22
react-client/cypress/plugins/index.js
vendored
22
react-client/cypress/plugins/index.js
vendored
@ -1,22 +0,0 @@
|
||||
/* eslint-disable global-require */
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
|
||||
// ***********************************************************
|
||||
// This example plugins/index.js can be used to load plugins
|
||||
//
|
||||
// You can change the location of this file or turn off loading
|
||||
// the plugins file with the 'pluginsFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/plugins-guide
|
||||
// ***********************************************************
|
||||
|
||||
// This function is called when a project is opened or re-opened (e.g. due to
|
||||
// the project's config changing)
|
||||
|
||||
const webpack = require('@cypress/webpack-preprocessor');
|
||||
const webpackOptions = require('../../webpack.config.js');
|
||||
|
||||
module.exports = on => {
|
||||
on('file:preprocessor', webpack({ webpackOptions }));
|
||||
};
|
75
react-client/cypress/support/commands.js
vendored
75
react-client/cypress/support/commands.js
vendored
@ -1,75 +0,0 @@
|
||||
import 'core-js/stable';
|
||||
import 'regenerator-runtime/runtime';
|
||||
|
||||
import '@4tw/cypress-drag-drop';
|
||||
|
||||
import { objectToQueryString } from 'shared/utils/url';
|
||||
import { getStoredAuthToken, storeAuthToken } from 'shared/utils/authToken';
|
||||
|
||||
import { testid } from './utils';
|
||||
|
||||
Cypress.Commands.add('selectOption', (selectName, ...optionLabels) => {
|
||||
optionLabels.forEach(optionLabel => {
|
||||
cy.get(testid`select:${selectName}`).click('bottomRight');
|
||||
cy.get(testid`select-option:${optionLabel}`).click();
|
||||
});
|
||||
});
|
||||
|
||||
Cypress.Commands.add('selectShouldContain', (selectName, ...optionLabels) => {
|
||||
optionLabels.forEach(optionLabel => {
|
||||
cy.get(testid`select:${selectName}`).should('contain', optionLabel);
|
||||
});
|
||||
});
|
||||
|
||||
// We don't want to waste time when running tests on cypress waiting for debounced
|
||||
// inputs. We can use tick() to speed up time and trigger onChange immediately.
|
||||
Cypress.Commands.add('debounced', { prevSubject: true }, (input, action, value) => {
|
||||
cy.clock();
|
||||
cy.wrap(input)[action](value);
|
||||
cy.tick(1000);
|
||||
});
|
||||
|
||||
// Sometimes cypress fails to properly wait for api requests to finish which results
|
||||
// in flaky tests, and in those cases we need to explicitly tell it to wait
|
||||
// https://docs.cypress.io/guides/guides/network-requests.html#Flake
|
||||
Cypress.Commands.add('waitForXHR', (method, url, funcThatTriggersXHR) => {
|
||||
const alias = method + url;
|
||||
cy.server();
|
||||
cy.route(method, url).as(alias);
|
||||
funcThatTriggersXHR();
|
||||
cy.wait(`@${alias}`);
|
||||
});
|
||||
|
||||
// We're using optimistic updates (not waiting for API response before updating
|
||||
// the local data and UI) in a lot of places in the app. That's why we want to assert
|
||||
// both the immediate local UI change in the first assert, and if the change was
|
||||
// successfully persisted by the API in the second assert after page reload
|
||||
Cypress.Commands.add('assertReloadAssert', assertFunc => {
|
||||
assertFunc();
|
||||
cy.reload();
|
||||
assertFunc();
|
||||
});
|
||||
|
||||
Cypress.Commands.add('apiRequest', (method, url, variables = {}, options = {}) => {
|
||||
cy.request({
|
||||
method,
|
||||
url: `${Cypress.env('apiBaseUrl')}${url}`,
|
||||
qs: method === 'GET' ? objectToQueryString(variables) : undefined,
|
||||
body: method !== 'GET' ? variables : undefined,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: getStoredAuthToken() ? `Bearer ${getStoredAuthToken()}` : undefined,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
});
|
||||
|
||||
Cypress.Commands.add('resetDatabase', () => {
|
||||
cy.apiRequest('DELETE', '/test/reset-database');
|
||||
});
|
||||
|
||||
Cypress.Commands.add('createTestAccount', () => {
|
||||
cy.apiRequest('POST', '/test/create-account').then(response => {
|
||||
storeAuthToken(response.body.authToken);
|
||||
});
|
||||
});
|
16
react-client/cypress/support/index.js
vendored
16
react-client/cypress/support/index.js
vendored
@ -1,16 +0,0 @@
|
||||
// ***********************************************************
|
||||
// This example support/index.js is processed and
|
||||
// loaded automatically before your test files.
|
||||
//
|
||||
// This is a great place to put global configuration and
|
||||
// behavior that modifies Cypress.
|
||||
//
|
||||
// You can change the location of this file or turn off
|
||||
// automatically serving support files with the
|
||||
// 'supportFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/configuration
|
||||
// ***********************************************************
|
||||
|
||||
import './commands';
|
4
react-client/cypress/support/utils.js
vendored
4
react-client/cypress/support/utils.js
vendored
@ -1,4 +0,0 @@
|
||||
export const testid = (strings, ...values) => {
|
||||
const id = strings.map((str, index) => str + (values[index] || '')).join('');
|
||||
return `[data-testid="${id}"]`;
|
||||
};
|
1
react-client/jest/fileMock.js
vendored
1
react-client/jest/fileMock.js
vendored
@ -1 +0,0 @@
|
||||
module.exports = 'test-file-stub';
|
1
react-client/jest/styleMock.js
vendored
1
react-client/jest/styleMock.js
vendored
@ -1 +0,0 @@
|
||||
module.exports = {};
|
Loading…
Reference in New Issue
Block a user