This commit is contained in:
Adrian Wozniak 2020-04-10 08:09:40 +02:00
parent a97a96e9ac
commit f2081c9974
30 changed files with 673 additions and 652 deletions

227
Cargo.lock generated
View File

@ -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"

View File

@ -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 = "*"

View File

@ -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;

View 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;
}

View File

@ -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";

View File

@ -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)]

View File

@ -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,],
],
]
}

View File

@ -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)

View File

@ -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)]

View File

@ -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;

View 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]
]
}

View File

@ -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));

View File

@ -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"),

View File

@ -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)]

View File

@ -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(_)) => (),

View File

@ -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
{

View File

@ -1,6 +0,0 @@
{
"extends": ["plugin:cypress/recommended"],
"rules": {
"no-unused-expressions": 0 // chai assertions trigger this rule
}
}

View File

@ -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);
});
});

View File

@ -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`);
});
});

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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/"]');
});

View File

@ -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 });
});
};
});

View File

@ -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');
});
});

View File

@ -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 }));
};

View File

@ -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);
});
});

View File

@ -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';

View File

@ -1,4 +0,0 @@
export const testid = (strings, ...values) => {
const id = strings.map((str, index) => str + (values[index] || '')).join('');
return `[data-testid="${id}"]`;
};

View File

@ -1 +0,0 @@
module.exports = 'test-file-stub';

View File

@ -1 +0,0 @@
module.exports = {};