Most of time tracking modal
This commit is contained in:
parent
02d2c958b7
commit
91a6445bf6
@ -13,6 +13,10 @@ crate-type = ["cdylib", "rlib"]
|
|||||||
name = "jirs_client"
|
name = "jirs_client"
|
||||||
path = "./src/lib.rs"
|
path = "./src/lib.rs"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = true
|
||||||
|
opt-level = 's'
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
jirs-data = { path = "../jirs-data" }
|
jirs-data = { path = "../jirs-data" }
|
||||||
seed = { version = "*" }
|
seed = { version = "*" }
|
||||||
|
@ -119,45 +119,6 @@
|
|||||||
padding-top: 5px;
|
padding-top: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.issueDetails > .content > .right > .styledField > .trackingLink {
|
|
||||||
padding: 4px 4px 2px 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: background 0.1s;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.issueDetails > .content > .right > .styledField > .trackingLink:hover {
|
|
||||||
background: var(--backgroundLight);
|
|
||||||
}
|
|
||||||
|
|
||||||
.issueDetails > .content > .right > .styledField > .trackingLink > .trackingWidget {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.issueDetails > .content > .right > .styledField > .trackingLink > .trackingWidget > .watchIcon {
|
|
||||||
color: var(--textMedium);
|
|
||||||
}
|
|
||||||
|
|
||||||
.issueDetails > .content > .right > .styledField > .trackingLink > .trackingWidget > .right {
|
|
||||||
width: 90%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.issueDetails > .content > .right > .styledField > .trackingLink > .trackingWidget > .right > .barCounter {
|
|
||||||
height: 5px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--backgroundMedium);
|
|
||||||
}
|
|
||||||
|
|
||||||
.issueDetails > .content > .right > .styledField > .trackingLink > .trackingWidget > .right > .barCounter > .bar {
|
|
||||||
height: 5px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--primary);
|
|
||||||
transition: all 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*===================================================*/
|
/*===================================================*/
|
||||||
/* TOP ACTIONS */
|
/* TOP ACTIONS */
|
||||||
/*===================================================*/
|
/*===================================================*/
|
||||||
|
13
jirs-client/js/css/projectSettings.css
Normal file
13
jirs-client/js/css/projectSettings.css
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
#projectSettings > .formContainer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#projectSettings > .formContainer .styledForm {
|
||||||
|
max-width: 720px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#projectSettings > .formContainer .styledForm > .formElement > .actionButton {
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
@ -1,3 +1,50 @@
|
|||||||
|
.trackingLink {
|
||||||
|
padding: 4px 4px 2px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.1s;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackingLink:hover {
|
||||||
|
background: var(--backgroundLight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackingWidget {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackingWidget > .watchIcon {
|
||||||
|
color: var(--textMedium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackingWidget > .right {
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackingWidget > .right > .barCounter {
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--backgroundMedium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackingWidget > .right > .barCounter > .bar {
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--primary);
|
||||||
|
transition: all 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackingWidget > .right > .values {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 3px;
|
||||||
|
font-size: 14.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*MODAL*/
|
||||||
.timeTrackingModal {
|
.timeTrackingModal {
|
||||||
padding: 20px 25px 25px;
|
padding: 20px 25px 25px;
|
||||||
}
|
}
|
||||||
@ -6,7 +53,11 @@
|
|||||||
padding-bottom: 14px;
|
padding-bottom: 14px;
|
||||||
font-family: var(--font-medium);
|
font-family: var(--font-medium);
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-size: 20px
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeTrackingModal > .trackingWidget {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeTrackingModal > .inputs {
|
.timeTrackingModal > .inputs {
|
||||||
@ -18,3 +69,8 @@
|
|||||||
margin: 0 5px;
|
margin: 0 5px;
|
||||||
width: 50%;
|
width: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timeTrackingModal > .actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
@ -30,6 +30,7 @@ import("../pkg/index.js").then(module => {
|
|||||||
buildWebSocket();
|
buildWebSocket();
|
||||||
|
|
||||||
window.send_bin_code = code => queue.push(code);
|
window.send_bin_code = code => queue.push(code);
|
||||||
|
window.inspectQueue = () => queue;
|
||||||
|
|
||||||
let wsCheckDelay = 100;
|
let wsCheckDelay = 100;
|
||||||
const flush = () => {
|
const flush = () => {
|
||||||
|
@ -21,4 +21,5 @@
|
|||||||
@import "./css/app.css";
|
@import "./css/app.css";
|
||||||
@import "./css/issue.css";
|
@import "./css/issue.css";
|
||||||
@import "./css/project.css";
|
@import "./css/project.css";
|
||||||
|
@import "./css/projectSettings.css";
|
||||||
@import "./css/timeTracking.css";
|
@import "./css/timeTracking.css";
|
||||||
|
@ -138,6 +138,7 @@ pub enum Msg {
|
|||||||
ProjectToggleOnlyMy,
|
ProjectToggleOnlyMy,
|
||||||
ProjectToggleRecentlyUpdated,
|
ProjectToggleRecentlyUpdated,
|
||||||
ProjectClearFilters,
|
ProjectClearFilters,
|
||||||
|
ProjectSaveChanges,
|
||||||
|
|
||||||
// dragging
|
// dragging
|
||||||
IssueDragStarted(IssueId),
|
IssueDragStarted(IssueId),
|
||||||
@ -159,7 +160,7 @@ pub enum Msg {
|
|||||||
DeleteComment(CommentId),
|
DeleteComment(CommentId),
|
||||||
|
|
||||||
// modals
|
// modals
|
||||||
ModalOpened(ModalType),
|
ModalOpened(Box<ModalType>),
|
||||||
ModalDropped,
|
ModalDropped,
|
||||||
ModalChanged(FieldChange),
|
ModalChanged(FieldChange),
|
||||||
|
|
||||||
@ -175,7 +176,7 @@ fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) {
|
|||||||
}
|
}
|
||||||
match &msg {
|
match &msg {
|
||||||
Msg::ChangePage(page) => {
|
Msg::ChangePage(page) => {
|
||||||
model.page = page.clone();
|
model.page = *page;
|
||||||
}
|
}
|
||||||
Msg::ToggleAboutTooltip => {
|
Msg::ToggleAboutTooltip => {
|
||||||
model.about_tooltip_visible = !model.about_tooltip_visible;
|
model.about_tooltip_visible = !model.about_tooltip_visible;
|
||||||
@ -185,8 +186,7 @@ fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) {
|
|||||||
crate::ws::update(&msg, model, orders);
|
crate::ws::update(&msg, model, orders);
|
||||||
crate::modal::update(&msg, model, orders);
|
crate::modal::update(&msg, model, orders);
|
||||||
match model.page {
|
match model.page {
|
||||||
Page::Project | Page::AddIssue => project::update(msg, model, orders),
|
Page::Project | Page::AddIssue | Page::EditIssue(..) => project::update(msg, model, orders),
|
||||||
Page::EditIssue(_id) => project::update(msg, model, orders),
|
|
||||||
Page::ProjectSettings => project_settings::update(msg, model, orders),
|
Page::ProjectSettings => project_settings::update(msg, model, orders),
|
||||||
Page::Login => login::update(msg, model, orders),
|
Page::Login => login::update(msg, model, orders),
|
||||||
Page::Register => register::update(msg, model, orders),
|
Page::Register => register::update(msg, model, orders),
|
||||||
@ -242,11 +242,8 @@ pub fn handle_ws_message(value: &wasm_bindgen::JsValue) {
|
|||||||
for idx in 0..a.length() {
|
for idx in 0..a.length() {
|
||||||
v.push(a.get_index(idx));
|
v.push(a.get_index(idx));
|
||||||
}
|
}
|
||||||
match bincode::deserialize(v.as_slice()) {
|
if let Ok(msg) = bincode::deserialize(v.as_slice()) {
|
||||||
Ok(msg) => {
|
|
||||||
ws::handle(msg);
|
ws::handle(msg);
|
||||||
}
|
|
||||||
_ => (),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -299,9 +296,8 @@ pub fn render() {
|
|||||||
.routes(routes)
|
.routes(routes)
|
||||||
.build_and_start();
|
.build_and_start();
|
||||||
|
|
||||||
match crate::shared::read_auth_token() {
|
if let Ok(uuid) = crate::shared::read_auth_token() {
|
||||||
Ok(uuid) => send_ws_msg(WsMsg::AuthorizeRequest(uuid)),
|
send_ws_msg(WsMsg::AuthorizeRequest(uuid));
|
||||||
_ => (),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let cell_app = std::sync::RwLock::new(app);
|
let cell_app = std::sync::RwLock::new(app);
|
||||||
|
@ -43,9 +43,9 @@ pub fn update(msg: &Msg, model: &mut crate::model::Model, orders: &mut impl Orde
|
|||||||
priority: modal.priority.clone(),
|
priority: modal.priority.clone(),
|
||||||
description: modal.description.clone(),
|
description: modal.description.clone(),
|
||||||
description_text: modal.description_text.clone(),
|
description_text: modal.description_text.clone(),
|
||||||
estimate: modal.estimate.clone(),
|
estimate: modal.estimate,
|
||||||
time_spent: modal.time_spent.clone(),
|
time_spent: modal.time_spent,
|
||||||
time_remaining: modal.time_remaining.clone(),
|
time_remaining: modal.time_remaining,
|
||||||
project_id: modal.project_id.unwrap_or(project_id),
|
project_id: modal.project_id.unwrap_or(project_id),
|
||||||
user_ids: modal.user_ids.clone(),
|
user_ids: modal.user_ids.clone(),
|
||||||
reporter_id: modal.reporter_id.unwrap_or_else(|| user_id),
|
reporter_id: modal.reporter_id.unwrap_or_else(|| user_id),
|
||||||
|
@ -8,11 +8,12 @@ use crate::shared::styled_avatar::StyledAvatar;
|
|||||||
use crate::shared::styled_button::StyledButton;
|
use crate::shared::styled_button::StyledButton;
|
||||||
use crate::shared::styled_editor::StyledEditor;
|
use crate::shared::styled_editor::StyledEditor;
|
||||||
use crate::shared::styled_field::StyledField;
|
use crate::shared::styled_field::StyledField;
|
||||||
use crate::shared::styled_icon::{Icon, StyledIcon};
|
use crate::shared::styled_icon::Icon;
|
||||||
use crate::shared::styled_input::StyledInput;
|
use crate::shared::styled_input::StyledInput;
|
||||||
use crate::shared::styled_select::{StyledSelect, StyledSelectChange};
|
use crate::shared::styled_select::{StyledSelect, StyledSelectChange};
|
||||||
use crate::shared::styled_select_child::ToStyledSelectChild;
|
use crate::shared::styled_select_child::ToStyledSelectChild;
|
||||||
use crate::shared::styled_textarea::StyledTextarea;
|
use crate::shared::styled_textarea::StyledTextarea;
|
||||||
|
use crate::shared::tracking_widget::tracking_link;
|
||||||
use crate::shared::ToNode;
|
use crate::shared::ToNode;
|
||||||
use crate::{EditIssueModalFieldId, FieldChange, FieldId, Msg};
|
use crate::{EditIssueModalFieldId, FieldChange, FieldId, Msg};
|
||||||
|
|
||||||
@ -89,6 +90,14 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
|||||||
modal.payload.description_text = Some(value.clone());
|
modal.payload.description_text = Some(value.clone());
|
||||||
send_ws_msg(WsMsg::IssueUpdateRequest(modal.id, modal.payload.clone()));
|
send_ws_msg(WsMsg::IssueUpdateRequest(modal.id, modal.payload.clone()));
|
||||||
}
|
}
|
||||||
|
Msg::InputChanged(FieldId::EditIssueModal(EditIssueModalFieldId::TimeSpend), value) => {
|
||||||
|
modal.payload.time_spent = value.parse::<i32>().ok();
|
||||||
|
send_ws_msg(WsMsg::IssueUpdateRequest(modal.id, modal.payload.clone()));
|
||||||
|
}
|
||||||
|
Msg::InputChanged(FieldId::EditIssueModal(EditIssueModalFieldId::TimeRemaining), value) => {
|
||||||
|
modal.payload.time_remaining = value.parse::<i32>().ok();
|
||||||
|
send_ws_msg(WsMsg::IssueUpdateRequest(modal.id, modal.payload.clone()));
|
||||||
|
}
|
||||||
Msg::ModalChanged(FieldChange::TabChanged(
|
Msg::ModalChanged(FieldChange::TabChanged(
|
||||||
FieldId::EditIssueModal(EditIssueModalFieldId::Description),
|
FieldId::EditIssueModal(EditIssueModalFieldId::Description),
|
||||||
mode,
|
mode,
|
||||||
@ -197,7 +206,7 @@ fn top_modal_row(_model: &Model, modal: &EditIssueModal) -> Node<Msg> {
|
|||||||
..
|
..
|
||||||
} = modal;
|
} = modal;
|
||||||
|
|
||||||
let issue_id = id.clone();
|
let issue_id = *id;
|
||||||
|
|
||||||
let click_handler = mouse_ev(Ev::Click, move |_| {
|
let click_handler = mouse_ev(Ev::Click, move |_| {
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
@ -220,7 +229,7 @@ fn top_modal_row(_model: &Model, modal: &EditIssueModal) -> Node<Msg> {
|
|||||||
});
|
});
|
||||||
let close_handler = mouse_ev(Ev::Click, |_| Msg::ModalDropped);
|
let close_handler = mouse_ev(Ev::Click, |_| Msg::ModalDropped);
|
||||||
let delete_confirmation_handler = mouse_ev(Ev::Click, move |_| {
|
let delete_confirmation_handler = mouse_ev(Ev::Click, move |_| {
|
||||||
Msg::ModalOpened(ModalType::DeleteIssueConfirm(issue_id))
|
Msg::ModalOpened(Box::new(ModalType::DeleteIssueConfirm(issue_id)))
|
||||||
});
|
});
|
||||||
|
|
||||||
let copy_button = StyledButton::build()
|
let copy_button = StyledButton::build()
|
||||||
@ -261,7 +270,7 @@ fn top_modal_row(_model: &Model, modal: &EditIssueModal) -> Node<Msg> {
|
|||||||
.collect(),
|
.collect(),
|
||||||
)
|
)
|
||||||
.selected(vec![{
|
.selected(vec![{
|
||||||
let id = modal.id.clone();
|
let id = modal.id;
|
||||||
let issue_type = &payload.issue_type;
|
let issue_type = &payload.issue_type;
|
||||||
issue_type
|
issue_type
|
||||||
.to_select_child()
|
.to_select_child()
|
||||||
@ -424,7 +433,7 @@ fn comment(model: &Model, modal: &EditIssueModal, comment: &Comment) -> Option<N
|
|||||||
let comment_id = comment.id;
|
let comment_id = comment.id;
|
||||||
let delete_comment_handler = mouse_ev(Ev::Click, move |ev| {
|
let delete_comment_handler = mouse_ev(Ev::Click, move |ev| {
|
||||||
ev.stop_propagation();
|
ev.stop_propagation();
|
||||||
Msg::ModalOpened(ModalType::DeleteCommentConfirm(comment_id))
|
Msg::ModalOpened(Box::new(ModalType::DeleteCommentConfirm(comment_id)))
|
||||||
});
|
});
|
||||||
let edit_button = StyledButton::build()
|
let edit_button = StyledButton::build()
|
||||||
.add_class("editButton")
|
.add_class("editButton")
|
||||||
@ -582,7 +591,6 @@ fn right_modal_column(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
|
|||||||
.estimate
|
.estimate
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|n| n.to_string())
|
.map(|n| n.to_string())
|
||||||
.clone()
|
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
@ -593,7 +601,7 @@ fn right_modal_column(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
|
|||||||
.build()
|
.build()
|
||||||
.into_node();
|
.into_node();
|
||||||
|
|
||||||
let tracking = tracking_widget(model, modal);
|
let tracking = tracking_link(model, modal);
|
||||||
let tracking_field = StyledField::build()
|
let tracking_field = StyledField::build()
|
||||||
.label("TIME TRACKING")
|
.label("TIME TRACKING")
|
||||||
.tip("")
|
.tip("")
|
||||||
@ -611,68 +619,3 @@ fn right_modal_column(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
|
|||||||
tracking_field,
|
tracking_field,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tracking_widget(_model: &Model, modal: &EditIssueModal) -> Node<Msg> {
|
|
||||||
let EditIssueModal {
|
|
||||||
id,
|
|
||||||
payload:
|
|
||||||
UpdateIssuePayload {
|
|
||||||
estimate,
|
|
||||||
time_spent,
|
|
||||||
time_remaining,
|
|
||||||
..
|
|
||||||
},
|
|
||||||
..
|
|
||||||
} = modal;
|
|
||||||
|
|
||||||
let issue_id = *id;
|
|
||||||
|
|
||||||
let icon = StyledIcon::build(Icon::Stopwatch)
|
|
||||||
.add_class("watchIcon")
|
|
||||||
.size(32)
|
|
||||||
.build()
|
|
||||||
.into_node();
|
|
||||||
|
|
||||||
let bar_width = calc_bar_width(estimate, time_spent, time_remaining);
|
|
||||||
let handler = mouse_ev(Ev::Click, move |_| {
|
|
||||||
Msg::ModalOpened(ModalType::TimeTracking(issue_id))
|
|
||||||
});
|
|
||||||
|
|
||||||
div![
|
|
||||||
class!["trackingLink"],
|
|
||||||
handler,
|
|
||||||
div![
|
|
||||||
class!["trackingWidget"],
|
|
||||||
icon,
|
|
||||||
div![
|
|
||||||
class!["right"],
|
|
||||||
div![
|
|
||||||
class!["barCounter"],
|
|
||||||
div![
|
|
||||||
class!["bar"],
|
|
||||||
attrs![At::Style => format!("width: {}%", bar_width)]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
],
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn calc_bar_width(
|
|
||||||
estimate: &Option<i32>,
|
|
||||||
time_spent: &Option<i32>,
|
|
||||||
time_remaining: &Option<i32>,
|
|
||||||
) -> f64 {
|
|
||||||
match (estimate, time_spent, time_remaining) {
|
|
||||||
(Some(estimate), Some(spent), _) => {
|
|
||||||
((*spent as f64 / *estimate as f64) * 100f64).max(100f64)
|
|
||||||
}
|
|
||||||
(_, Some(spent), Some(remaining)) => {
|
|
||||||
(*spent as f64 / (*spent as f64 + *remaining as f64)) * 100f64
|
|
||||||
}
|
|
||||||
(None, None, _) => 100f64,
|
|
||||||
(None, _, _) => 0f64,
|
|
||||||
_ => 0f64,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -33,18 +33,18 @@ pub fn update(msg: &Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Msg::ModalOpened(modal_type) => {
|
Msg::ModalOpened(modal_type) => {
|
||||||
model.modals.push(modal_type.clone());
|
model.modals.push(modal_type.as_ref().clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
Msg::WsMsg(jirs_data::WsMsg::ProjectIssuesLoaded(_issues)) => match model.page.clone() {
|
Msg::WsMsg(jirs_data::WsMsg::ProjectIssuesLoaded(_issues)) => match model.page {
|
||||||
Page::EditIssue(issue_id) if model.modals.is_empty() => {
|
Page::EditIssue(issue_id) if model.modals.is_empty() => {
|
||||||
push_edit_modal(&issue_id, model)
|
push_edit_modal(issue_id, model)
|
||||||
}
|
}
|
||||||
_ => (),
|
_ => (),
|
||||||
},
|
},
|
||||||
|
|
||||||
Msg::ChangePage(Page::EditIssue(issue_id)) => {
|
Msg::ChangePage(Page::EditIssue(issue_id)) => {
|
||||||
push_edit_modal(issue_id, model);
|
push_edit_modal(*issue_id, model);
|
||||||
}
|
}
|
||||||
|
|
||||||
Msg::ChangePage(Page::AddIssue) => {
|
Msg::ChangePage(Page::AddIssue) => {
|
||||||
@ -95,14 +95,14 @@ pub fn view(model: &model::Model) -> Node<Msg> {
|
|||||||
section![id!["modals"], modals]
|
section![id!["modals"], modals]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_edit_modal(issue_id: &i32, model: &mut Model) {
|
fn push_edit_modal(issue_id: i32, model: &mut Model) {
|
||||||
let modal = {
|
let modal = {
|
||||||
let issue = match find_issue(model, *issue_id) {
|
let issue = match find_issue(model, issue_id) {
|
||||||
Some(issue) => issue,
|
Some(issue) => issue,
|
||||||
_ => return,
|
_ => return,
|
||||||
};
|
};
|
||||||
ModalType::EditIssue(*issue_id, EditIssueModal::new(issue))
|
ModalType::EditIssue(issue_id, EditIssueModal::new(issue))
|
||||||
};
|
};
|
||||||
send_ws_msg(WsMsg::IssueCommentsRequest(issue_id.clone()));
|
send_ws_msg(WsMsg::IssueCommentsRequest(issue_id));
|
||||||
model.modals.push(modal);
|
model.modals.push(modal);
|
||||||
}
|
}
|
||||||
|
@ -2,27 +2,88 @@ use seed::{prelude::*, *};
|
|||||||
|
|
||||||
use jirs_data::IssueId;
|
use jirs_data::IssueId;
|
||||||
|
|
||||||
use crate::model::Model;
|
use crate::model::{ModalType, Model};
|
||||||
|
use crate::shared::styled_button::StyledButton;
|
||||||
|
use crate::shared::styled_field::StyledField;
|
||||||
use crate::shared::styled_input::StyledInput;
|
use crate::shared::styled_input::StyledInput;
|
||||||
use crate::shared::styled_modal::StyledModal;
|
use crate::shared::styled_modal::StyledModal;
|
||||||
|
use crate::shared::tracking_widget::tracking_widget;
|
||||||
use crate::shared::{find_issue, ToNode};
|
use crate::shared::{find_issue, ToNode};
|
||||||
use crate::Msg;
|
use crate::{EditIssueModalFieldId, FieldId, Msg, ProjectSettingsFieldId};
|
||||||
|
|
||||||
pub fn view(model: &Model, issue_id: IssueId) -> Node<Msg> {
|
pub fn view(model: &Model, issue_id: IssueId) -> Node<Msg> {
|
||||||
let issue = match find_issue(model, issue_id) {
|
let _issue = match find_issue(model, issue_id) {
|
||||||
Some(issue) => issue,
|
Some(issue) => issue,
|
||||||
_ => return empty![],
|
_ => return empty![],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let edit_issue_modal = match model.modals.get(0) {
|
||||||
|
Some(ModalType::EditIssue(_, modal)) => modal,
|
||||||
|
_ => return empty![],
|
||||||
|
};
|
||||||
|
|
||||||
let modal_title = div![class!["modalTitle"], "Time tracking"];
|
let modal_title = div![class!["modalTitle"], "Time tracking"];
|
||||||
|
|
||||||
// let time_spent = StyledInput::build()
|
let tracking = tracking_widget(model, edit_issue_modal);
|
||||||
|
|
||||||
let inputs = div![class!["inputs"], ""];
|
let time_spent = StyledInput::build(FieldId::EditIssueModal(EditIssueModalFieldId::TimeSpend))
|
||||||
|
.value(
|
||||||
|
edit_issue_modal
|
||||||
|
.payload
|
||||||
|
.time_spent
|
||||||
|
.as_ref()
|
||||||
|
.map(|n| n.to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
.valid(true)
|
||||||
|
.build()
|
||||||
|
.into_node();
|
||||||
|
let time_spent_field = StyledField::build()
|
||||||
|
.input(time_spent)
|
||||||
|
.label("Time spent")
|
||||||
|
.build()
|
||||||
|
.into_node();
|
||||||
|
let time_remaining = StyledInput::build(FieldId::EditIssueModal(
|
||||||
|
EditIssueModalFieldId::TimeRemaining,
|
||||||
|
))
|
||||||
|
.value(
|
||||||
|
edit_issue_modal
|
||||||
|
.payload
|
||||||
|
.time_remaining
|
||||||
|
.as_ref()
|
||||||
|
.map(|n| n.to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
.valid(true)
|
||||||
|
.build()
|
||||||
|
.into_node();
|
||||||
|
let time_remaining_field = StyledField::build()
|
||||||
|
.input(time_remaining)
|
||||||
|
.label("Time remaining")
|
||||||
|
.build()
|
||||||
|
.into_node();
|
||||||
|
|
||||||
|
let inputs = div![
|
||||||
|
class!["inputs"],
|
||||||
|
div![class!["inputContainer"], time_spent_field],
|
||||||
|
div![class!["inputContainer"], time_remaining_field]
|
||||||
|
];
|
||||||
|
|
||||||
|
let close = StyledButton::build()
|
||||||
|
.text("Done")
|
||||||
|
.on_click(mouse_ev(Ev::Click, |_| Msg::ModalDropped))
|
||||||
|
.build()
|
||||||
|
.into_node();
|
||||||
|
|
||||||
StyledModal::build()
|
StyledModal::build()
|
||||||
.add_class("timeTrackingModal")
|
.add_class("timeTrackingModal")
|
||||||
.children(vec![modal_title, inputs])
|
.children(vec![
|
||||||
|
modal_title,
|
||||||
|
tracking,
|
||||||
|
inputs,
|
||||||
|
div![class!["actions"], close],
|
||||||
|
])
|
||||||
|
.width(400)
|
||||||
.build()
|
.build()
|
||||||
.into_node()
|
.into_node()
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,9 @@ use jirs_data::*;
|
|||||||
|
|
||||||
use crate::shared::styled_editor::Mode;
|
use crate::shared::styled_editor::Mode;
|
||||||
use crate::shared::styled_select::StyledSelectState;
|
use crate::shared::styled_select::StyledSelectState;
|
||||||
use crate::{AddIssueModalFieldId, EditIssueModalFieldId, FieldId, HOST_URL};
|
use crate::{
|
||||||
|
AddIssueModalFieldId, EditIssueModalFieldId, FieldId, ProjectSettingsFieldId, HOST_URL,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialOrd, PartialEq, Hash)]
|
#[derive(Clone, Debug, PartialOrd, PartialEq, Hash)]
|
||||||
pub enum ModalType {
|
pub enum ModalType {
|
||||||
@ -52,14 +54,14 @@ impl EditIssueModal {
|
|||||||
issue_type: issue.issue_type.clone(),
|
issue_type: issue.issue_type.clone(),
|
||||||
status: issue.status.clone(),
|
status: issue.status.clone(),
|
||||||
priority: issue.priority.clone(),
|
priority: issue.priority.clone(),
|
||||||
list_position: issue.list_position.clone(),
|
list_position: issue.list_position,
|
||||||
description: issue.description.clone(),
|
description: issue.description.clone(),
|
||||||
description_text: issue.description_text.clone(),
|
description_text: issue.description_text.clone(),
|
||||||
estimate: issue.estimate.clone(),
|
estimate: issue.estimate,
|
||||||
time_spent: issue.time_spent.clone(),
|
time_spent: issue.time_spent,
|
||||||
time_remaining: issue.time_remaining.clone(),
|
time_remaining: issue.time_remaining,
|
||||||
project_id: issue.project_id.clone(),
|
project_id: issue.project_id,
|
||||||
reporter_id: issue.reporter_id.clone(),
|
reporter_id: issue.reporter_id,
|
||||||
user_ids: issue.user_ids.clone(),
|
user_ids: issue.user_ids.clone(),
|
||||||
},
|
},
|
||||||
top_type_state: StyledSelectState::new(FieldId::EditIssueModal(
|
top_type_state: StyledSelectState::new(FieldId::EditIssueModal(
|
||||||
@ -151,11 +153,11 @@ pub enum Page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Page {
|
impl Page {
|
||||||
pub fn to_path(&self) -> String {
|
pub fn to_path(self) -> String {
|
||||||
match self {
|
match self {
|
||||||
Page::Project => "/board".to_string(),
|
Page::Project => "/board".to_string(),
|
||||||
Page::EditIssue(id) => format!("/issues/{id}", id = id),
|
Page::EditIssue(id) => format!("/issues/{id}", id = id),
|
||||||
Page::AddIssue => format!("/add-issues"),
|
Page::AddIssue => "/add-issues".to_string(),
|
||||||
Page::ProjectSettings => "/project-settings".to_string(),
|
Page::ProjectSettings => "/project-settings".to_string(),
|
||||||
Page::Login => "/login".to_string(),
|
Page::Login => "/login".to_string(),
|
||||||
Page::Register => "/register".to_string(),
|
Page::Register => "/register".to_string(),
|
||||||
@ -193,7 +195,35 @@ pub struct ProjectPage {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ProjectSettingsPage {
|
pub struct ProjectSettingsPage {
|
||||||
pub payload: UpdateProjectPayload,
|
pub payload: UpdateProjectPayload,
|
||||||
pub project_type_state: StyledSelectState,
|
pub project_category_state: StyledSelectState,
|
||||||
|
pub description_mode: crate::shared::styled_editor::Mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProjectSettingsPage {
|
||||||
|
pub fn new(project: &Project) -> Self {
|
||||||
|
use crate::shared::styled_editor::Mode as EditorMode;
|
||||||
|
let jirs_data::Project {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
..
|
||||||
|
} = project;
|
||||||
|
Self {
|
||||||
|
payload: UpdateProjectPayload {
|
||||||
|
id: *id,
|
||||||
|
name: Some(name.clone()),
|
||||||
|
url: Some(url.clone()),
|
||||||
|
description: Some(description.clone()),
|
||||||
|
category: Some(category.clone()),
|
||||||
|
},
|
||||||
|
description_mode: EditorMode::View,
|
||||||
|
project_category_state: StyledSelectState::new(FieldId::ProjectSettings(
|
||||||
|
ProjectSettingsFieldId::Category,
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
@ -4,7 +4,7 @@ use seed::{prelude::*, *};
|
|||||||
use jirs_data::*;
|
use jirs_data::*;
|
||||||
|
|
||||||
use crate::api::send_ws_msg;
|
use crate::api::send_ws_msg;
|
||||||
use crate::model::{ModalType, Model, Page, PageContent};
|
use crate::model::{ModalType, Model, Page, PageContent, ProjectPage};
|
||||||
use crate::shared::styled_avatar::StyledAvatar;
|
use crate::shared::styled_avatar::StyledAvatar;
|
||||||
use crate::shared::styled_button::StyledButton;
|
use crate::shared::styled_button::StyledButton;
|
||||||
use crate::shared::styled_icon::{Icon, StyledIcon};
|
use crate::shared::styled_icon::{Icon, StyledIcon};
|
||||||
@ -14,16 +14,23 @@ use crate::shared::{drag_ev, inner_layout, ToNode};
|
|||||||
use crate::{EditIssueModalFieldId, FieldId, Msg};
|
use crate::{EditIssueModalFieldId, FieldId, Msg};
|
||||||
|
|
||||||
pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) {
|
pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) {
|
||||||
|
match &model.page {
|
||||||
|
Page::Project | Page::AddIssue | Page::EditIssue(..) => {
|
||||||
|
model.page_content = PageContent::Project(ProjectPage::default());
|
||||||
|
}
|
||||||
|
_ => return,
|
||||||
|
}
|
||||||
|
|
||||||
let project_page = match &mut model.page_content {
|
let project_page = match &mut model.page_content {
|
||||||
PageContent::Project(project_page) => project_page,
|
PageContent::Project(project_page) => project_page,
|
||||||
_ => return,
|
_ => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
match msg {
|
match msg {
|
||||||
Msg::ChangePage(Page::Project)
|
Msg::WsMsg(WsMsg::AuthorizeLoaded(..))
|
||||||
|
| Msg::ChangePage(Page::Project)
|
||||||
| Msg::ChangePage(Page::AddIssue)
|
| Msg::ChangePage(Page::AddIssue)
|
||||||
| Msg::ChangePage(Page::EditIssue(..))
|
| Msg::ChangePage(Page::EditIssue(..)) => {
|
||||||
| Msg::WsMsg(WsMsg::AuthorizeLoaded(Ok(_))) => {
|
|
||||||
send_ws_msg(jirs_data::WsMsg::ProjectRequest);
|
send_ws_msg(jirs_data::WsMsg::ProjectRequest);
|
||||||
send_ws_msg(jirs_data::WsMsg::ProjectIssuesRequest);
|
send_ws_msg(jirs_data::WsMsg::ProjectIssuesRequest);
|
||||||
send_ws_msg(jirs_data::WsMsg::ProjectUsersRequest);
|
send_ws_msg(jirs_data::WsMsg::ProjectUsersRequest);
|
||||||
@ -68,19 +75,17 @@ pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Order
|
|||||||
Msg::InputChanged(FieldId::TextFilterBoard, text) => {
|
Msg::InputChanged(FieldId::TextFilterBoard, text) => {
|
||||||
project_page.text_filter = text;
|
project_page.text_filter = text;
|
||||||
}
|
}
|
||||||
Msg::ProjectAvatarFilterChanged(user_id, active) => match active {
|
Msg::ProjectAvatarFilterChanged(user_id, active) => {
|
||||||
true => {
|
if active {
|
||||||
project_page.active_avatar_filters = project_page
|
project_page.active_avatar_filters = project_page
|
||||||
.active_avatar_filters
|
.active_avatar_filters
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|id| **id != user_id)
|
.filter_map(|id| if *id != user_id { Some(*id) } else { None })
|
||||||
.map(|id| *id)
|
|
||||||
.collect();
|
.collect();
|
||||||
}
|
} else {
|
||||||
false => {
|
|
||||||
project_page.active_avatar_filters.push(user_id);
|
project_page.active_avatar_filters.push(user_id);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
Msg::ProjectToggleOnlyMy => {
|
Msg::ProjectToggleOnlyMy => {
|
||||||
project_page.only_my_filter = !project_page.only_my_filter;
|
project_page.only_my_filter = !project_page.only_my_filter;
|
||||||
}
|
}
|
||||||
@ -184,16 +189,17 @@ fn project_board_filters(model: &Model) -> Node<Msg> {
|
|||||||
.build()
|
.build()
|
||||||
.into_node();
|
.into_node();
|
||||||
|
|
||||||
let clear_all = match project_page.only_my_filter
|
let clear_all = if project_page.only_my_filter
|
||||||
|| project_page.recently_updated_filter
|
|| project_page.recently_updated_filter
|
||||||
|| !project_page.active_avatar_filters.is_empty()
|
|| !project_page.active_avatar_filters.is_empty()
|
||||||
{
|
{
|
||||||
true => seed::button![
|
seed::button![
|
||||||
id!["clearAllFilters"],
|
id!["clearAllFilters"],
|
||||||
"Clear all",
|
"Clear all",
|
||||||
mouse_ev(Ev::Click, |_| Msg::ProjectClearFilters),
|
mouse_ev(Ev::Click, |_| Msg::ProjectClearFilters),
|
||||||
],
|
]
|
||||||
false => empty![],
|
} else {
|
||||||
|
empty![]
|
||||||
};
|
};
|
||||||
|
|
||||||
div![
|
div![
|
||||||
@ -252,11 +258,11 @@ fn project_issue_list(model: &Model, status: jirs_data::IssueStatus) -> Node<Msg
|
|||||||
PageContent::Project(project_page) => project_page,
|
PageContent::Project(project_page) => project_page,
|
||||||
_ => return empty![],
|
_ => return empty![],
|
||||||
};
|
};
|
||||||
let ids = if project_page.recently_updated_filter {
|
let ids: Vec<IssueId> = if project_page.recently_updated_filter {
|
||||||
let mut v: Vec<(IssueId, NaiveDateTime)> = model
|
let mut v: Vec<(IssueId, NaiveDateTime)> = model
|
||||||
.issues
|
.issues
|
||||||
.iter()
|
.iter()
|
||||||
.map(|issue| (issue.id, issue.updated_at.clone()))
|
.map(|issue| (issue.id, issue.updated_at))
|
||||||
.collect();
|
.collect();
|
||||||
v.sort_by(|(_, a_time), (_, b_time)| a_time.cmp(b_time));
|
v.sort_by(|(_, a_time), (_, b_time)| a_time.cmp(b_time));
|
||||||
if v.len() > 10 { v[0..10].to_vec() } else { v }
|
if v.len() > 10 { v[0..10].to_vec() } else { v }
|
||||||
@ -273,7 +279,7 @@ fn project_issue_list(model: &Model, status: jirs_data::IssueStatus) -> Node<Msg
|
|||||||
issue_filter_status(issue, &status)
|
issue_filter_status(issue, &status)
|
||||||
&& issue_filter_with_text(issue, project_page.text_filter.as_str())
|
&& issue_filter_with_text(issue, project_page.text_filter.as_str())
|
||||||
&& issue_filter_with_only_my(issue, project_page.only_my_filter, &model.user)
|
&& issue_filter_with_only_my(issue, project_page.only_my_filter, &model.user)
|
||||||
&& issue_filter_with_only_recent(issue, &ids)
|
&& issue_filter_with_only_recent(issue, ids.as_slice())
|
||||||
})
|
})
|
||||||
.map(|issue| project_issue(model, issue))
|
.map(|issue| project_issue(model, issue))
|
||||||
.collect();
|
.collect();
|
||||||
@ -324,7 +330,7 @@ fn issue_filter_with_only_my(issue: &Issue, only_my: bool, user: &Option<User>)
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn issue_filter_with_only_recent(issue: &Issue, ids: &Vec<IssueId>) -> bool {
|
fn issue_filter_with_only_recent(issue: &Issue, ids: &[IssueId]) -> bool {
|
||||||
ids.is_empty() || ids.contains(&issue.id)
|
ids.is_empty() || ids.contains(&issue.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,18 +1,92 @@
|
|||||||
use seed::{prelude::*, *};
|
use seed::{prelude::*, *};
|
||||||
|
|
||||||
|
use jirs_data::{ProjectCategory, ToVec, WsMsg};
|
||||||
|
|
||||||
|
use crate::api::send_ws_msg;
|
||||||
|
use crate::model::{Model, Page, PageContent, ProjectSettingsPage};
|
||||||
|
use crate::shared::styled_button::StyledButton;
|
||||||
use crate::shared::styled_editor::StyledEditor;
|
use crate::shared::styled_editor::StyledEditor;
|
||||||
use crate::shared::styled_field::StyledField;
|
use crate::shared::styled_field::StyledField;
|
||||||
use crate::shared::styled_form::StyledForm;
|
use crate::shared::styled_form::StyledForm;
|
||||||
use crate::shared::styled_input::StyledInput;
|
use crate::shared::styled_select::{StyledSelect, StyledSelectChange};
|
||||||
|
use crate::shared::styled_select_child::ToStyledSelectChild;
|
||||||
|
use crate::shared::styled_textarea::StyledTextarea;
|
||||||
use crate::shared::{inner_layout, ToNode};
|
use crate::shared::{inner_layout, ToNode};
|
||||||
|
use crate::FieldChange::TabChanged;
|
||||||
use crate::{model, FieldId, Msg, ProjectSettingsFieldId};
|
use crate::{model, FieldId, Msg, ProjectSettingsFieldId};
|
||||||
|
|
||||||
pub fn update(_msg: Msg, _model: &mut model::Model, _orders: &mut impl Orders<Msg>) {}
|
pub fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) {
|
||||||
|
if model.page != Page::ProjectSettings {
|
||||||
|
log!("not settings page");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match msg {
|
||||||
|
Msg::WsMsg(WsMsg::AuthorizeLoaded(..)) => {
|
||||||
|
send_ws_msg(WsMsg::ProjectRequest);
|
||||||
|
}
|
||||||
|
Msg::ChangePage(Page::ProjectSettings) => {
|
||||||
|
send_ws_msg(WsMsg::ProjectRequest);
|
||||||
|
build_page_content(model);
|
||||||
|
}
|
||||||
|
Msg::WsMsg(WsMsg::ProjectLoaded(..)) => {
|
||||||
|
build_page_content(model);
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
|
||||||
|
let page = match &mut model.page_content {
|
||||||
|
PageContent::ProjectSettings(page) => page,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
page.project_category_state.update(&msg, orders);
|
||||||
|
match msg {
|
||||||
|
Msg::ProjectSaveChanges => send_ws_msg(WsMsg::ProjectUpdateRequest(page.payload.clone())),
|
||||||
|
Msg::InputChanged(FieldId::ProjectSettings(ProjectSettingsFieldId::Name), text) => {
|
||||||
|
page.payload.name = Some(text);
|
||||||
|
}
|
||||||
|
Msg::InputChanged(FieldId::ProjectSettings(ProjectSettingsFieldId::Url), text) => {
|
||||||
|
page.payload.url = Some(text);
|
||||||
|
}
|
||||||
|
Msg::InputChanged(FieldId::ProjectSettings(ProjectSettingsFieldId::Description), text) => {
|
||||||
|
page.payload.description = Some(text);
|
||||||
|
}
|
||||||
|
Msg::StyledSelectChanged(
|
||||||
|
FieldId::ProjectSettings(ProjectSettingsFieldId::Category),
|
||||||
|
StyledSelectChange::Changed(value),
|
||||||
|
) => {
|
||||||
|
let category = value.into();
|
||||||
|
page.payload.category = Some(category);
|
||||||
|
}
|
||||||
|
Msg::ModalChanged(TabChanged(
|
||||||
|
FieldId::ProjectSettings(ProjectSettingsFieldId::Description),
|
||||||
|
mode,
|
||||||
|
)) => {
|
||||||
|
page.description_mode = mode;
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_page_content(model: &mut Model) {
|
||||||
|
let project = match &model.project {
|
||||||
|
Some(project) => project,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
model.page_content = PageContent::ProjectSettings(ProjectSettingsPage::new(project));
|
||||||
|
}
|
||||||
|
|
||||||
pub fn view(model: &model::Model) -> Node<Msg> {
|
pub fn view(model: &model::Model) -> Node<Msg> {
|
||||||
let name = StyledInput::build(FieldId::ProjectSettings(ProjectSettingsFieldId::Name))
|
let page = match &model.page_content {
|
||||||
.valid(true)
|
PageContent::ProjectSettings(page) => page,
|
||||||
.build()
|
_ => return empty![],
|
||||||
|
};
|
||||||
|
let name = StyledTextarea::build()
|
||||||
|
.value(page.payload.name.as_ref().cloned().unwrap_or_default())
|
||||||
|
.height(39)
|
||||||
|
.max_height(39)
|
||||||
|
.disable_auto_resize()
|
||||||
|
.build(FieldId::ProjectSettings(ProjectSettingsFieldId::Name))
|
||||||
.into_node();
|
.into_node();
|
||||||
let name_field = StyledField::build()
|
let name_field = StyledField::build()
|
||||||
.label("Name")
|
.label("Name")
|
||||||
@ -21,9 +95,12 @@ pub fn view(model: &model::Model) -> Node<Msg> {
|
|||||||
.build()
|
.build()
|
||||||
.into_node();
|
.into_node();
|
||||||
|
|
||||||
let url = StyledInput::build(FieldId::ProjectSettings(ProjectSettingsFieldId::Url))
|
let url = StyledTextarea::build()
|
||||||
.valid(true)
|
.height(39)
|
||||||
.build()
|
.max_height(39)
|
||||||
|
.disable_auto_resize()
|
||||||
|
.value(page.payload.url.as_ref().cloned().unwrap_or_default())
|
||||||
|
.build(FieldId::ProjectSettings(ProjectSettingsFieldId::Url))
|
||||||
.into_node();
|
.into_node();
|
||||||
let url_field = StyledField::build()
|
let url_field = StyledField::build()
|
||||||
.label("Url")
|
.label("Url")
|
||||||
@ -35,8 +112,15 @@ pub fn view(model: &model::Model) -> Node<Msg> {
|
|||||||
let description = StyledEditor::build(FieldId::ProjectSettings(
|
let description = StyledEditor::build(FieldId::ProjectSettings(
|
||||||
ProjectSettingsFieldId::Description,
|
ProjectSettingsFieldId::Description,
|
||||||
))
|
))
|
||||||
.text("")
|
.text(
|
||||||
|
page.payload
|
||||||
|
.description
|
||||||
|
.as_ref()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
.update_on(Ev::Change)
|
.update_on(Ev::Change)
|
||||||
|
.mode(page.description_mode.clone())
|
||||||
.build()
|
.build()
|
||||||
.into_node();
|
.into_node();
|
||||||
let description_field = StyledField::build()
|
let description_field = StyledField::build()
|
||||||
@ -46,15 +130,50 @@ pub fn view(model: &model::Model) -> Node<Msg> {
|
|||||||
.build()
|
.build()
|
||||||
.into_node();
|
.into_node();
|
||||||
|
|
||||||
|
let category = StyledSelect::build(FieldId::ProjectSettings(ProjectSettingsFieldId::Category))
|
||||||
|
.opened(page.project_category_state.opened)
|
||||||
|
.text_filter(page.project_category_state.text_filter.as_str())
|
||||||
|
.valid(true)
|
||||||
|
.normal()
|
||||||
|
.options(
|
||||||
|
ProjectCategory::ordered()
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| c.to_select_child())
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
.selected(vec![page
|
||||||
|
.payload
|
||||||
|
.category
|
||||||
|
.as_ref()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_select_child()])
|
||||||
|
.build()
|
||||||
|
.into_node();
|
||||||
|
let category_field = StyledField::build()
|
||||||
|
.label("Project Category")
|
||||||
|
.input(category)
|
||||||
|
.build()
|
||||||
|
.into_node();
|
||||||
|
|
||||||
|
let save_button = StyledButton::build()
|
||||||
|
.add_class("actionButton")
|
||||||
|
.on_click(mouse_ev(Ev::Click, |_| Msg::ProjectSaveChanges))
|
||||||
|
.text("Save changes")
|
||||||
|
.build()
|
||||||
|
.into_node();
|
||||||
|
|
||||||
let form = StyledForm::build()
|
let form = StyledForm::build()
|
||||||
.heading("Project Details")
|
.heading("Project Details")
|
||||||
.add_field(name_field)
|
.add_field(name_field)
|
||||||
.add_field(url_field)
|
.add_field(url_field)
|
||||||
.add_field(description_field)
|
.add_field(description_field)
|
||||||
|
.add_field(category_field)
|
||||||
|
.add_field(save_button)
|
||||||
.build()
|
.build()
|
||||||
.into_node();
|
.into_node();
|
||||||
|
|
||||||
let project_section = vec![form];
|
let project_section = vec![div![class!["formContainer"], form]];
|
||||||
|
|
||||||
inner_layout(model, "projectSettings", project_section, empty![])
|
inner_layout(model, "projectSettings", project_section, empty![])
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,10 @@ pub fn render(model: &Model) -> Node<Msg> {
|
|||||||
div![
|
div![
|
||||||
attrs![At::Class => "projectTexts";],
|
attrs![At::Class => "projectTexts";],
|
||||||
div![attrs![At::Class => "projectName";], project.name],
|
div![attrs![At::Class => "projectName";], project.name],
|
||||||
div![attrs![At::Class => "projectCategory";], project.category]
|
div![
|
||||||
|
attrs![At::Class => "projectCategory";],
|
||||||
|
project.category.to_string()
|
||||||
|
]
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
_ => li![
|
_ => li![
|
||||||
@ -50,7 +53,7 @@ pub fn render(model: &Model) -> Node<Msg> {
|
|||||||
fn sidebar_link_item(model: &Model, name: &str, icon: Icon, page: Option<Page>) -> Node<Msg> {
|
fn sidebar_link_item(model: &Model, name: &str, icon: Icon, page: Option<Page>) -> Node<Msg> {
|
||||||
let path = page.map(|ref p| p.to_path()).unwrap_or_default();
|
let path = page.map(|ref p| p.to_path()).unwrap_or_default();
|
||||||
let mut class_list = vec!["linkItem".to_string(), icon.to_string()];
|
let mut class_list = vec!["linkItem".to_string(), icon.to_string()];
|
||||||
if let None = page {
|
if page.is_none() {
|
||||||
class_list.push("notAllowed".to_string());
|
class_list.push("notAllowed".to_string());
|
||||||
};
|
};
|
||||||
if Some(model.page) == page {
|
if Some(model.page) == page {
|
||||||
|
@ -21,6 +21,7 @@ pub mod styled_select;
|
|||||||
pub mod styled_select_child;
|
pub mod styled_select_child;
|
||||||
pub mod styled_textarea;
|
pub mod styled_textarea;
|
||||||
pub mod styled_tooltip;
|
pub mod styled_tooltip;
|
||||||
|
pub mod tracking_widget;
|
||||||
|
|
||||||
pub fn find_issue(model: &Model, issue_id: IssueId) -> Option<&Issue> {
|
pub fn find_issue(model: &Model, issue_id: IssueId) -> Option<&Issue> {
|
||||||
model.issues.iter().find(|issue| issue.id == issue_id)
|
model.issues.iter().find(|issue| issue.id == issue_id)
|
||||||
|
@ -30,9 +30,9 @@ pub struct StyledButtonBuilder {
|
|||||||
variant: Option<Variant>,
|
variant: Option<Variant>,
|
||||||
disabled: Option<bool>,
|
disabled: Option<bool>,
|
||||||
active: Option<bool>,
|
active: Option<bool>,
|
||||||
text: Option<Option<String>>,
|
text: Option<String>,
|
||||||
icon: Option<Option<Node<Msg>>>,
|
icon: Option<Node<Msg>>,
|
||||||
on_click: Option<Option<EventHandler<Msg>>>,
|
on_click: Option<EventHandler<Msg>>,
|
||||||
children: Option<Vec<Node<Msg>>>,
|
children: Option<Vec<Node<Msg>>>,
|
||||||
class_list: Vec<String>,
|
class_list: Vec<String>,
|
||||||
}
|
}
|
||||||
@ -77,7 +77,7 @@ impl StyledButtonBuilder {
|
|||||||
where
|
where
|
||||||
S: Into<String>,
|
S: Into<String>,
|
||||||
{
|
{
|
||||||
self.text = Some(Some(value.into()));
|
self.text = Some(value.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,12 +85,12 @@ impl StyledButtonBuilder {
|
|||||||
where
|
where
|
||||||
I: ToNode,
|
I: ToNode,
|
||||||
{
|
{
|
||||||
self.icon = Some(Some(value.into_node()));
|
self.icon = Some(value.into_node());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_click(mut self, value: EventHandler<Msg>) -> Self {
|
pub fn on_click(mut self, value: EventHandler<Msg>) -> Self {
|
||||||
self.on_click = Some(Some(value));
|
self.on_click = Some(value);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,9 +112,9 @@ impl StyledButtonBuilder {
|
|||||||
variant: self.variant.unwrap_or_else(|| Variant::Primary),
|
variant: self.variant.unwrap_or_else(|| Variant::Primary),
|
||||||
disabled: self.disabled.unwrap_or_else(|| false),
|
disabled: self.disabled.unwrap_or_else(|| false),
|
||||||
active: self.active.unwrap_or_else(|| false),
|
active: self.active.unwrap_or_else(|| false),
|
||||||
text: self.text.unwrap_or_default(),
|
text: self.text,
|
||||||
icon: self.icon.unwrap_or_else(|| None),
|
icon: self.icon,
|
||||||
on_click: self.on_click.unwrap_or_else(|| None),
|
on_click: self.on_click,
|
||||||
children: self.children.unwrap_or_default(),
|
children: self.children.unwrap_or_default(),
|
||||||
class_list: self.class_list,
|
class_list: self.class_list,
|
||||||
}
|
}
|
||||||
@ -187,9 +187,10 @@ pub fn render(values: StyledButton) -> Node<Msg> {
|
|||||||
At::Class => class_list.join(" "),
|
At::Class => class_list.join(" "),
|
||||||
],
|
],
|
||||||
handler,
|
handler,
|
||||||
match disabled {
|
if disabled {
|
||||||
true => vec![attrs![At::Disabled => true]],
|
vec![attrs![At::Disabled => true]]
|
||||||
false => vec![],
|
} else {
|
||||||
|
vec![]
|
||||||
},
|
},
|
||||||
icon_node,
|
icon_node,
|
||||||
content,
|
content,
|
||||||
|
@ -38,7 +38,7 @@ pub struct StyledConfirmModalBuilder {
|
|||||||
message: Option<String>,
|
message: Option<String>,
|
||||||
confirm_text: Option<String>,
|
confirm_text: Option<String>,
|
||||||
cancel_text: Option<String>,
|
cancel_text: Option<String>,
|
||||||
on_confirm: Option<Option<EventHandler<Msg>>>,
|
on_confirm: Option<EventHandler<Msg>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StyledConfirmModalBuilder {
|
impl StyledConfirmModalBuilder {
|
||||||
@ -75,7 +75,7 @@ impl StyledConfirmModalBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_confirm(mut self, on_confirm: EventHandler<Msg>) -> Self {
|
pub fn on_confirm(mut self, on_confirm: EventHandler<Msg>) -> Self {
|
||||||
self.on_confirm = Some(Some(on_confirm));
|
self.on_confirm = Some(on_confirm);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,7 +87,7 @@ impl StyledConfirmModalBuilder {
|
|||||||
.confirm_text
|
.confirm_text
|
||||||
.unwrap_or_else(|| CONFIRM_TEXT.to_string()),
|
.unwrap_or_else(|| CONFIRM_TEXT.to_string()),
|
||||||
cancel_text: self.cancel_text.unwrap_or_else(|| CANCEL_TEXT.to_string()),
|
cancel_text: self.cancel_text.unwrap_or_else(|| CANCEL_TEXT.to_string()),
|
||||||
on_confirm: self.on_confirm.unwrap_or_default(),
|
on_confirm: self.on_confirm,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -88,24 +88,24 @@ pub fn render(values: StyledEditor) -> Node<Msg> {
|
|||||||
let field_id = id.clone();
|
let field_id = id.clone();
|
||||||
let on_editor_clicked = mouse_ev(Ev::Click, move |ev| {
|
let on_editor_clicked = mouse_ev(Ev::Click, move |ev| {
|
||||||
ev.stop_propagation();
|
ev.stop_propagation();
|
||||||
Msg::ModalChanged(FieldChange::TabChanged(field_id.clone(), Mode::Editor))
|
Msg::ModalChanged(FieldChange::TabChanged(field_id, Mode::Editor))
|
||||||
});
|
});
|
||||||
|
|
||||||
let field_id = id.clone();
|
let field_id = id.clone();
|
||||||
let on_view_clicked = mouse_ev(Ev::Click, move |ev| {
|
let on_view_clicked = mouse_ev(Ev::Click, move |ev| {
|
||||||
ev.stop_propagation();
|
ev.stop_propagation();
|
||||||
Msg::ModalChanged(FieldChange::TabChanged(field_id.clone(), Mode::View))
|
Msg::ModalChanged(FieldChange::TabChanged(field_id, Mode::View))
|
||||||
});
|
});
|
||||||
|
|
||||||
let editor_id = format!("editor-{}", id.clone());
|
let editor_id = format!("editor-{}", id);
|
||||||
let view_id = format!("view-{}", id.clone());
|
let view_id = format!("view-{}", id);
|
||||||
let name = format!("styled-editor-{}", id.clone());
|
let name = format!("styled-editor-{}", id);
|
||||||
|
|
||||||
let text_area = StyledTextarea::build()
|
let text_area = StyledTextarea::build()
|
||||||
.height(40)
|
.height(40)
|
||||||
.update_on(update_event)
|
.update_on(update_event)
|
||||||
.value(text.as_str())
|
.value(text.as_str())
|
||||||
.build(id.clone())
|
.build(id)
|
||||||
.into_node();
|
.into_node();
|
||||||
|
|
||||||
let parsed = comrak::markdown_to_html(
|
let parsed = comrak::markdown_to_html(
|
||||||
|
@ -54,8 +54,8 @@ pub fn render(values: StyledForm) -> Node<Msg> {
|
|||||||
div![
|
div![
|
||||||
attrs![At::Class => "styledForm"],
|
attrs![At::Class => "styledForm"],
|
||||||
div![
|
div![
|
||||||
attrs![At::Class => "formElement"],
|
class!["formElement"],
|
||||||
div![attrs![At::Class => "formHeading"], heading],
|
div![class!["formHeading"], heading],
|
||||||
fields
|
fields
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
|
@ -132,14 +132,14 @@ impl ToNode for Icon {
|
|||||||
|
|
||||||
pub struct StyledIconBuilder {
|
pub struct StyledIconBuilder {
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
size: Option<Option<i32>>,
|
size: Option<i32>,
|
||||||
class_list: Vec<String>,
|
class_list: Vec<String>,
|
||||||
style_list: Vec<String>,
|
style_list: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StyledIconBuilder {
|
impl StyledIconBuilder {
|
||||||
pub fn size(mut self, size: i32) -> Self {
|
pub fn size(mut self, size: i32) -> Self {
|
||||||
self.size = Some(Some(size));
|
self.size = Some(size);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,7 +162,7 @@ impl StyledIconBuilder {
|
|||||||
pub fn build(self) -> StyledIcon {
|
pub fn build(self) -> StyledIcon {
|
||||||
StyledIcon {
|
StyledIcon {
|
||||||
icon: self.icon,
|
icon: self.icon,
|
||||||
size: self.size.unwrap_or_default(),
|
size: self.size,
|
||||||
class_list: self.class_list,
|
class_list: self.class_list,
|
||||||
style_list: self.style_list,
|
style_list: self.style_list,
|
||||||
}
|
}
|
||||||
|
@ -88,26 +88,29 @@ pub fn render(values: StyledInput) -> Node<Msg> {
|
|||||||
Some(icon) => StyledIcon::build(icon).build().into_node(),
|
Some(icon) => StyledIcon::build(icon).build().into_node(),
|
||||||
_ => empty![],
|
_ => empty![],
|
||||||
};
|
};
|
||||||
|
let field_id = id.clone();
|
||||||
let mut handlers = vec![];
|
let change_handler = keyboard_ev(Ev::KeyUp, move |event| {
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
handlers.push(input_ev(Ev::Change, move |value| {
|
event.stop_propagation();
|
||||||
Msg::InputChanged(id, value)
|
let value = event
|
||||||
}));
|
.target()
|
||||||
|
.unwrap()
|
||||||
|
.dyn_ref::<web_sys::HtmlInputElement>()
|
||||||
|
.unwrap()
|
||||||
|
.value();
|
||||||
|
log!("asd");
|
||||||
|
Msg::InputChanged(field_id, value)
|
||||||
|
});
|
||||||
|
|
||||||
div![
|
div![
|
||||||
attrs!(At::Class => wrapper_class_list.join(" ")),
|
attrs!(At::Class => wrapper_class_list.join(" ")),
|
||||||
icon,
|
icon,
|
||||||
keyboard_ev(Ev::KeyUp, |ev| {
|
|
||||||
ev.stop_propagation();
|
|
||||||
Msg::NoOp
|
|
||||||
}),
|
|
||||||
seed::input![
|
seed::input![
|
||||||
attrs![
|
attrs![
|
||||||
At::Class => input_class_list.join(" "),
|
At::Class => input_class_list.join(" "),
|
||||||
At::Value => value.unwrap_or_default(),
|
At::Value => value.unwrap_or_default(),
|
||||||
],
|
],
|
||||||
handlers
|
change_handler,
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -112,8 +112,8 @@ impl StyledSelect {
|
|||||||
pub struct StyledSelectBuilder {
|
pub struct StyledSelectBuilder {
|
||||||
id: FieldId,
|
id: FieldId,
|
||||||
variant: Option<Variant>,
|
variant: Option<Variant>,
|
||||||
dropdown_width: Option<Option<usize>>,
|
dropdown_width: Option<usize>,
|
||||||
name: Option<Option<String>>,
|
name: Option<String>,
|
||||||
valid: Option<bool>,
|
valid: Option<bool>,
|
||||||
is_multi: Option<bool>,
|
is_multi: Option<bool>,
|
||||||
allow_clear: Option<bool>,
|
allow_clear: Option<bool>,
|
||||||
@ -128,8 +128,8 @@ impl StyledSelectBuilder {
|
|||||||
StyledSelect {
|
StyledSelect {
|
||||||
id: self.id,
|
id: self.id,
|
||||||
variant: self.variant.unwrap_or_default(),
|
variant: self.variant.unwrap_or_default(),
|
||||||
dropdown_width: self.dropdown_width.unwrap_or_default(),
|
dropdown_width: self.dropdown_width,
|
||||||
name: self.name.unwrap_or_default(),
|
name: self.name,
|
||||||
valid: self.valid.unwrap_or(true),
|
valid: self.valid.unwrap_or(true),
|
||||||
is_multi: self.is_multi.unwrap_or_default(),
|
is_multi: self.is_multi.unwrap_or_default(),
|
||||||
allow_clear: self.allow_clear.unwrap_or_default(),
|
allow_clear: self.allow_clear.unwrap_or_default(),
|
||||||
@ -141,7 +141,7 @@ impl StyledSelectBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn dropdown_width(mut self, dropdown_width: usize) -> Self {
|
pub fn dropdown_width(mut self, dropdown_width: usize) -> Self {
|
||||||
self.dropdown_width = Some(Some(dropdown_width));
|
self.dropdown_width = Some(dropdown_width);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -149,7 +149,7 @@ impl StyledSelectBuilder {
|
|||||||
where
|
where
|
||||||
S: Into<String>,
|
S: Into<String>,
|
||||||
{
|
{
|
||||||
self.name = Some(Some(name.into()));
|
self.name = Some(name.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -224,19 +224,20 @@ pub fn render(values: StyledSelect) -> Node<Msg> {
|
|||||||
|
|
||||||
let dropdown_style = dropdown_width
|
let dropdown_style = dropdown_width
|
||||||
.map(|n| format!("width: {}px;", n))
|
.map(|n| format!("width: {}px;", n))
|
||||||
.unwrap_or_else(|| format!("width: 100%;"));
|
.unwrap_or_else(|| "width: 100%;".to_string());
|
||||||
|
|
||||||
let mut select_class = vec!["styledSelect".to_string(), format!("{}", variant)];
|
let mut select_class = vec!["styledSelect".to_string(), format!("{}", variant)];
|
||||||
if !valid {
|
if !valid {
|
||||||
select_class.push("invalid".to_string());
|
select_class.push("invalid".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let chevron_down = match (selected.is_empty() || !is_multi) && variant != Variant::Empty {
|
let chevron_down = if (selected.is_empty() || !is_multi) && variant != Variant::Empty {
|
||||||
true => StyledIcon::build(Icon::ChevronDown)
|
StyledIcon::build(Icon::ChevronDown)
|
||||||
.add_class("chevronIcon")
|
.add_class("chevronIcon")
|
||||||
.build()
|
.build()
|
||||||
.into_node(),
|
.into_node()
|
||||||
_ => empty![],
|
} else {
|
||||||
|
empty![]
|
||||||
};
|
};
|
||||||
|
|
||||||
let children: Vec<Node<Msg>> = options
|
let children: Vec<Node<Msg>> = options
|
||||||
@ -259,8 +260,8 @@ pub fn render(values: StyledSelect) -> Node<Msg> {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let text_input = match opened {
|
let text_input = if opened {
|
||||||
true => seed::input![
|
seed::input![
|
||||||
attrs![
|
attrs![
|
||||||
At::Name => name.unwrap_or_default(),
|
At::Name => name.unwrap_or_default(),
|
||||||
At::Class => "dropDownInput",
|
At::Class => "dropDownInput",
|
||||||
@ -268,9 +269,10 @@ pub fn render(values: StyledSelect) -> Node<Msg> {
|
|||||||
At::Placeholder => "Search"
|
At::Placeholder => "Search"
|
||||||
At::AutoFocus => "true",
|
At::AutoFocus => "true",
|
||||||
],
|
],
|
||||||
on_text.clone(),
|
on_text,
|
||||||
],
|
]
|
||||||
_ => empty![],
|
} else {
|
||||||
|
empty![]
|
||||||
};
|
};
|
||||||
|
|
||||||
let clear_icon = match (opened, allow_clear) {
|
let clear_icon = match (opened, allow_clear) {
|
||||||
|
@ -217,7 +217,7 @@ impl ToStyledSelectChild for jirs_data::IssueStatus {
|
|||||||
|
|
||||||
StyledSelectChild::build()
|
StyledSelectChild::build()
|
||||||
.value(self.clone().into())
|
.value(self.clone().into())
|
||||||
.add_class(text.clone())
|
.add_class(text)
|
||||||
.text(text)
|
.text(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -238,3 +238,14 @@ impl ToStyledSelectChild for jirs_data::IssueType {
|
|||||||
.value(self.clone().into())
|
.value(self.clone().into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ToStyledSelectChild for jirs_data::ProjectCategory {
|
||||||
|
fn to_select_child(&self) -> StyledSelectChildBuilder {
|
||||||
|
let name = self.to_string();
|
||||||
|
|
||||||
|
StyledSelectChild::build()
|
||||||
|
.add_class(name.as_str())
|
||||||
|
.text(name)
|
||||||
|
.value(self.clone().into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -12,6 +12,7 @@ pub struct StyledTextarea {
|
|||||||
class_list: Vec<String>,
|
class_list: Vec<String>,
|
||||||
update_event: Ev,
|
update_event: Ev,
|
||||||
placeholder: Option<String>,
|
placeholder: Option<String>,
|
||||||
|
disable_auto_resize: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToNode for StyledTextarea {
|
impl ToNode for StyledTextarea {
|
||||||
@ -35,6 +36,7 @@ pub struct StyledTextareaBuilder {
|
|||||||
class_list: Vec<String>,
|
class_list: Vec<String>,
|
||||||
update_event: Option<Ev>,
|
update_event: Option<Ev>,
|
||||||
placeholder: Option<String>,
|
placeholder: Option<String>,
|
||||||
|
disable_auto_resize: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StyledTextareaBuilder {
|
impl StyledTextareaBuilder {
|
||||||
@ -81,6 +83,11 @@ impl StyledTextareaBuilder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn disable_auto_resize(mut self) -> Self {
|
||||||
|
self.disable_auto_resize = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn build(self, id: FieldId) -> StyledTextarea {
|
pub fn build(self, id: FieldId) -> StyledTextarea {
|
||||||
StyledTextarea {
|
StyledTextarea {
|
||||||
@ -91,6 +98,7 @@ impl StyledTextareaBuilder {
|
|||||||
max_height: self.max_height.unwrap_or_default(),
|
max_height: self.max_height.unwrap_or_default(),
|
||||||
update_event: self.update_event.unwrap_or_else(|| Ev::KeyUp),
|
update_event: self.update_event.unwrap_or_else(|| Ev::KeyUp),
|
||||||
placeholder: self.placeholder,
|
placeholder: self.placeholder,
|
||||||
|
disable_auto_resize: self.disable_auto_resize,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -117,10 +125,11 @@ pub fn render(values: StyledTextarea) -> Node<Msg> {
|
|||||||
mut class_list,
|
mut class_list,
|
||||||
update_event,
|
update_event,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
disable_auto_resize,
|
||||||
} = values;
|
} = values;
|
||||||
let mut style_list = vec![];
|
let mut style_list = vec![];
|
||||||
|
|
||||||
let min_height = get_min_height(value.as_str(), height as f64);
|
let min_height = get_min_height(value.as_str(), height as f64, disable_auto_resize);
|
||||||
if min_height > 0f64 {
|
if min_height > 0f64 {
|
||||||
style_list.push(format!("min-height: {}px", min_height));
|
style_list.push(format!("min-height: {}px", min_height));
|
||||||
}
|
}
|
||||||
@ -129,8 +138,13 @@ pub fn render(values: StyledTextarea) -> Node<Msg> {
|
|||||||
style_list.push(format!("max-height: {}px", max_height));
|
style_list.push(format!("max-height: {}px", max_height));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if disable_auto_resize {
|
||||||
|
style_list.push("resize: none".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
let mut handlers = vec![];
|
let mut handlers = vec![];
|
||||||
|
|
||||||
|
let handler_disable_auto_resize = disable_auto_resize;
|
||||||
let resize_handler = ev(Ev::KeyUp, move |event| {
|
let resize_handler = ev(Ev::KeyUp, move |event| {
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
@ -140,7 +154,7 @@ pub fn render(values: StyledTextarea) -> Node<Msg> {
|
|||||||
};
|
};
|
||||||
let text_area = target.dyn_ref::<web_sys::HtmlTextAreaElement>().unwrap();
|
let text_area = target.dyn_ref::<web_sys::HtmlTextAreaElement>().unwrap();
|
||||||
let value: String = text_area.value();
|
let value: String = text_area.value();
|
||||||
let min_height = get_min_height(value.as_str(), height as f64);
|
let min_height = get_min_height(value.as_str(), height as f64, handler_disable_auto_resize);
|
||||||
|
|
||||||
text_area
|
text_area
|
||||||
.style()
|
.style()
|
||||||
@ -148,11 +162,9 @@ pub fn render(values: StyledTextarea) -> Node<Msg> {
|
|||||||
Msg::NoOp
|
Msg::NoOp
|
||||||
});
|
});
|
||||||
handlers.push(resize_handler);
|
handlers.push(resize_handler);
|
||||||
let text_input_handler = input_ev(update_event.clone(), move |value| {
|
let text_input_handler = input_ev(update_event, move |value| Msg::InputChanged(id, value));
|
||||||
Msg::InputChanged(id, value)
|
|
||||||
});
|
|
||||||
handlers.push(text_input_handler);
|
handlers.push(text_input_handler);
|
||||||
handlers.push(keyboard_ev(Ev::KeyUp, |ev| {
|
handlers.push(keyboard_ev(Ev::Input, |ev| {
|
||||||
ev.stop_propagation();
|
ev.stop_propagation();
|
||||||
Msg::NoOp
|
Msg::NoOp
|
||||||
}));
|
}));
|
||||||
@ -175,9 +187,12 @@ pub fn render(values: StyledTextarea) -> Node<Msg> {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_min_height(value: &str, min_height: f64) -> f64 {
|
fn get_min_height(value: &str, min_height: f64, disable_auto_resize: bool) -> f64 {
|
||||||
|
if disable_auto_resize {
|
||||||
|
return min_height;
|
||||||
|
}
|
||||||
let len = value.lines().count() as f64;
|
let len = value.lines().count() as f64;
|
||||||
if value.chars().last() == Some('\n') {}
|
// if value.chars().last() == Some('\n') {}
|
||||||
|
|
||||||
let calc_height = (len * LETTER_HEIGHT) + ADDITIONAL_HEIGHT;
|
let calc_height = (len * LETTER_HEIGHT) + ADDITIONAL_HEIGHT;
|
||||||
if calc_height + ADDITIONAL_HEIGHT < min_height {
|
if calc_height + ADDITIONAL_HEIGHT < min_height {
|
||||||
|
89
jirs-client/src/shared/tracking_widget.rs
Normal file
89
jirs-client/src/shared/tracking_widget.rs
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
use seed::{prelude::*, *};
|
||||||
|
|
||||||
|
use jirs_data::UpdateIssuePayload;
|
||||||
|
|
||||||
|
use crate::model::{EditIssueModal, ModalType, Model};
|
||||||
|
use crate::shared::styled_icon::{Icon, StyledIcon};
|
||||||
|
use crate::shared::ToNode;
|
||||||
|
use crate::Msg;
|
||||||
|
|
||||||
|
pub fn tracking_link(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
|
||||||
|
let EditIssueModal { id, .. } = modal;
|
||||||
|
|
||||||
|
let issue_id = *id;
|
||||||
|
|
||||||
|
let handler = mouse_ev(Ev::Click, move |_| {
|
||||||
|
Msg::ModalOpened(Box::new(ModalType::TimeTracking(issue_id)))
|
||||||
|
});
|
||||||
|
|
||||||
|
div![
|
||||||
|
class!["trackingLink"],
|
||||||
|
handler,
|
||||||
|
tracking_widget(model, modal),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tracking_widget(_model: &Model, modal: &EditIssueModal) -> Node<Msg> {
|
||||||
|
let EditIssueModal {
|
||||||
|
payload:
|
||||||
|
UpdateIssuePayload {
|
||||||
|
estimate,
|
||||||
|
time_spent,
|
||||||
|
time_remaining,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
..
|
||||||
|
} = modal;
|
||||||
|
|
||||||
|
let icon = StyledIcon::build(Icon::Stopwatch)
|
||||||
|
.add_class("watchIcon")
|
||||||
|
.size(32)
|
||||||
|
.build()
|
||||||
|
.into_node();
|
||||||
|
let bar_width = calc_bar_width(*estimate, *time_spent, *time_remaining);
|
||||||
|
|
||||||
|
let spent_text = if let Some(time) = time_spent {
|
||||||
|
format!("{}h logged", time)
|
||||||
|
} else {
|
||||||
|
"No time logged".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let remaining_node: Node<Msg> = match (time_remaining, estimate) {
|
||||||
|
(Some(n), _) => div![format!("{}h remaining", n)],
|
||||||
|
(_, Some(n)) => div![format!("{}h estimated", n)],
|
||||||
|
_ => empty![],
|
||||||
|
};
|
||||||
|
|
||||||
|
div![
|
||||||
|
class!["trackingWidget"],
|
||||||
|
icon,
|
||||||
|
div![
|
||||||
|
class!["right"],
|
||||||
|
div![
|
||||||
|
class!["barCounter"],
|
||||||
|
div![
|
||||||
|
class!["bar"],
|
||||||
|
attrs![At::Style => format!("width: {}%", bar_width)]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
div![class!["values"], div![spent_text], remaining_node,]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn calc_bar_width(
|
||||||
|
estimate: Option<i32>,
|
||||||
|
time_spent: Option<i32>,
|
||||||
|
time_remaining: Option<i32>,
|
||||||
|
) -> f64 {
|
||||||
|
match (estimate, time_spent, time_remaining) {
|
||||||
|
(Some(estimate), Some(spent), _) => ((spent as f64 / estimate as f64) * 100f64).max(100f64),
|
||||||
|
(_, Some(spent), Some(remaining)) => {
|
||||||
|
(spent as f64 / (spent as f64 + remaining as f64)) * 100f64
|
||||||
|
}
|
||||||
|
(None, None, _) => 100f64,
|
||||||
|
(None, _, _) => 0f64,
|
||||||
|
_ => 0f64,
|
||||||
|
}
|
||||||
|
}
|
@ -150,7 +150,7 @@ pub fn change_status(status: IssueStatus, model: &mut Model) {
|
|||||||
model.issues.push(issue);
|
model.issues.push(issue);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
issue.status = status.clone();
|
issue.status = status;
|
||||||
issue.list_position = pos + 1;
|
issue.list_position = pos + 1;
|
||||||
model.issues.push(issue);
|
model.issues.push(issue);
|
||||||
|
|
||||||
|
@ -26,7 +26,6 @@ pub type TokenId = i32;
|
|||||||
#[cfg_attr(feature = "backend", derive(FromSqlRow, AsExpression))]
|
#[cfg_attr(feature = "backend", derive(FromSqlRow, AsExpression))]
|
||||||
#[cfg_attr(feature = "backend", sql_type = "IssueTypeType")]
|
#[cfg_attr(feature = "backend", sql_type = "IssueTypeType")]
|
||||||
#[derive(Clone, Deserialize, Serialize, Debug, PartialOrd, PartialEq, Hash)]
|
#[derive(Clone, Deserialize, Serialize, Debug, PartialOrd, PartialEq, Hash)]
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum IssueType {
|
pub enum IssueType {
|
||||||
Task,
|
Task,
|
||||||
Bug,
|
Bug,
|
||||||
@ -248,6 +247,77 @@ impl Into<IssuePriority> for u32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "backend", derive(FromSqlRow, AsExpression))]
|
||||||
|
#[cfg_attr(feature = "backend", sql_type = "ProjectCategoryType")]
|
||||||
|
#[derive(Clone, Deserialize, Serialize, Debug, PartialOrd, PartialEq, Hash)]
|
||||||
|
pub enum ProjectCategory {
|
||||||
|
Software,
|
||||||
|
Marketing,
|
||||||
|
Business,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToVec for ProjectCategory {
|
||||||
|
type Item = ProjectCategory;
|
||||||
|
|
||||||
|
fn ordered() -> Vec<Self> {
|
||||||
|
vec![
|
||||||
|
ProjectCategory::Software,
|
||||||
|
ProjectCategory::Marketing,
|
||||||
|
ProjectCategory::Business,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for ProjectCategory {
|
||||||
|
type Err = String;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s.to_lowercase().trim() {
|
||||||
|
"software" => Ok(ProjectCategory::Software),
|
||||||
|
"marketing" => Ok(ProjectCategory::Marketing),
|
||||||
|
"business" => Ok(ProjectCategory::Business),
|
||||||
|
_ => Err(format!("Unknown project category {}", s)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProjectCategory {
|
||||||
|
fn default() -> Self {
|
||||||
|
ProjectCategory::Software
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ProjectCategory {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
ProjectCategory::Software => f.write_str("software"),
|
||||||
|
ProjectCategory::Marketing => f.write_str("marketing"),
|
||||||
|
ProjectCategory::Business => f.write_str("business"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Into<u32> for ProjectCategory {
|
||||||
|
fn into(self) -> u32 {
|
||||||
|
match self {
|
||||||
|
ProjectCategory::Software => 0,
|
||||||
|
ProjectCategory::Marketing => 1,
|
||||||
|
ProjectCategory::Business => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Into<ProjectCategory> for u32 {
|
||||||
|
fn into(self) -> ProjectCategory {
|
||||||
|
match self {
|
||||||
|
0 => ProjectCategory::Software,
|
||||||
|
1 => ProjectCategory::Marketing,
|
||||||
|
2 => ProjectCategory::Business,
|
||||||
|
_ => ProjectCategory::Software,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Debug, PartialEq)]
|
#[derive(Clone, Serialize, Debug, PartialEq)]
|
||||||
pub struct ErrorResponse {
|
pub struct ErrorResponse {
|
||||||
pub errors: Vec<String>,
|
pub errors: Vec<String>,
|
||||||
@ -259,7 +329,7 @@ pub struct Project {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub category: String,
|
pub category: ProjectCategory,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
}
|
}
|
||||||
@ -382,12 +452,13 @@ pub struct CreateIssuePayload {
|
|||||||
pub reporter_id: UserId,
|
pub reporter_id: UserId,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||||
pub struct UpdateProjectPayload {
|
pub struct UpdateProjectPayload {
|
||||||
|
pub id: ProjectId,
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub url: Option<String>,
|
pub url: Option<String>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub category: Option<String>,
|
pub category: Option<ProjectCategory>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||||
@ -407,6 +478,7 @@ pub enum WsMsg {
|
|||||||
ProjectIssuesLoaded(Vec<Issue>),
|
ProjectIssuesLoaded(Vec<Issue>),
|
||||||
ProjectUsersRequest,
|
ProjectUsersRequest,
|
||||||
ProjectUsersLoaded(Vec<User>),
|
ProjectUsersLoaded(Vec<User>),
|
||||||
|
ProjectUpdateRequest(UpdateProjectPayload),
|
||||||
|
|
||||||
// issue
|
// issue
|
||||||
IssueUpdateRequest(IssueId, UpdateIssuePayload),
|
IssueUpdateRequest(IssueId, UpdateIssuePayload),
|
||||||
|
@ -2,7 +2,7 @@ use std::io::Write;
|
|||||||
|
|
||||||
use diesel::{deserialize::*, pg::*, serialize::*, *};
|
use diesel::{deserialize::*, pg::*, serialize::*, *};
|
||||||
|
|
||||||
use crate::{IssuePriority, IssueStatus, IssueType};
|
use crate::{IssuePriority, IssueStatus, IssueType, ProjectCategory};
|
||||||
|
|
||||||
#[derive(SqlType)]
|
#[derive(SqlType)]
|
||||||
#[postgres(type_name = "IssuePriorityType")]
|
#[postgres(type_name = "IssuePriorityType")]
|
||||||
@ -121,3 +121,45 @@ impl ToSql<IssueStatusType, Pg> for IssueStatus {
|
|||||||
Ok(IsNull::No)
|
Ok(IsNull::No)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
///////////
|
||||||
|
|
||||||
|
#[derive(SqlType)]
|
||||||
|
#[postgres(type_name = "ProjectCategoryType")]
|
||||||
|
pub struct ProjectCategoryType;
|
||||||
|
|
||||||
|
impl diesel::query_builder::QueryId for ProjectCategoryType {
|
||||||
|
type QueryId = IssueStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_category_from_sql(bytes: Option<&[u8]>) -> deserialize::Result<ProjectCategory> {
|
||||||
|
match not_none!(bytes) {
|
||||||
|
b"software" => Ok(ProjectCategory::Software),
|
||||||
|
b"marketing" => Ok(ProjectCategory::Marketing),
|
||||||
|
b"business" => Ok(ProjectCategory::Business),
|
||||||
|
_ => Ok(ProjectCategory::Software),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromSql<ProjectCategoryType, Pg> for ProjectCategory {
|
||||||
|
fn from_sql(bytes: Option<&[u8]>) -> deserialize::Result<Self> {
|
||||||
|
project_category_from_sql(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromSql<sql_types::Text, Pg> for ProjectCategory {
|
||||||
|
fn from_sql(bytes: Option<&[u8]>) -> deserialize::Result<Self> {
|
||||||
|
project_category_from_sql(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToSql<ProjectCategoryType, Pg> for ProjectCategory {
|
||||||
|
fn to_sql<W: Write>(&self, out: &mut Output<W, Pg>) -> serialize::Result {
|
||||||
|
match *self {
|
||||||
|
ProjectCategory::Software => out.write_all(b"software")?,
|
||||||
|
ProjectCategory::Marketing => out.write_all(b"marketing")?,
|
||||||
|
ProjectCategory::Business => out.write_all(b"business")?,
|
||||||
|
}
|
||||||
|
Ok(IsNull::No)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE projects ALTER COLUMN category SET DATA TYPE VARCHAR;
|
@ -0,0 +1,12 @@
|
|||||||
|
ALTER TABLE projects
|
||||||
|
ALTER COLUMN category
|
||||||
|
DROP DEFAULT;
|
||||||
|
|
||||||
|
ALTER TABLE projects
|
||||||
|
ALTER COLUMN category
|
||||||
|
SET DATA TYPE "ProjectCategoryType"
|
||||||
|
USING category::text::"ProjectCategoryType";
|
||||||
|
|
||||||
|
ALTER TABLE projects
|
||||||
|
ALTER COLUMN category
|
||||||
|
SET DEFAULT 'software';
|
@ -77,11 +77,11 @@ pub struct UpdateIssue {
|
|||||||
pub status: Option<IssueStatus>,
|
pub status: Option<IssueStatus>,
|
||||||
pub priority: Option<IssuePriority>,
|
pub priority: Option<IssuePriority>,
|
||||||
pub list_position: Option<i32>,
|
pub list_position: Option<i32>,
|
||||||
pub description: Option<Option<String>>,
|
pub description: Option<String>,
|
||||||
pub description_text: Option<Option<String>>,
|
pub description_text: Option<String>,
|
||||||
pub estimate: Option<Option<i32>>,
|
pub estimate: Option<i32>,
|
||||||
pub time_spent: Option<Option<i32>>,
|
pub time_spent: Option<i32>,
|
||||||
pub time_remaining: Option<Option<i32>>,
|
pub time_remaining: Option<i32>,
|
||||||
pub project_id: Option<i32>,
|
pub project_id: Option<i32>,
|
||||||
pub user_ids: Option<Vec<i32>>,
|
pub user_ids: Option<Vec<i32>>,
|
||||||
pub reporter_id: Option<i32>,
|
pub reporter_id: Option<i32>,
|
||||||
|
@ -23,8 +23,8 @@ impl Actor for DbExecutor {
|
|||||||
type Context = SyncContext<Self>;
|
type Context = SyncContext<Self>;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DbExecutor {
|
impl Default for DbExecutor {
|
||||||
pub fn new() -> Self {
|
fn default() -> Self {
|
||||||
Self(build_pool())
|
Self(build_pool())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -37,7 +37,7 @@ pub fn build_pool() -> DbPool {
|
|||||||
let manager = ConnectionManager::<PgConnection>::new(database_url.clone());
|
let manager = ConnectionManager::<PgConnection>::new(database_url.clone());
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
let manager: ConnectionManager<VerboseConnection> =
|
let manager: ConnectionManager<VerboseConnection> =
|
||||||
ConnectionManager::<dev::VerboseConnection>::new(database_url.clone());
|
ConnectionManager::<dev::VerboseConnection>::new(database_url.as_str());
|
||||||
r2d2::Pool::builder()
|
r2d2::Pool::builder()
|
||||||
.build(manager)
|
.build(manager)
|
||||||
.unwrap_or_else(|e| panic!("Failed to create pool. {}", e))
|
.unwrap_or_else(|e| panic!("Failed to create pool. {}", e))
|
||||||
|
@ -2,6 +2,8 @@ use actix::{Handler, Message};
|
|||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use jirs_data::ProjectCategory;
|
||||||
|
|
||||||
use crate::db::DbExecutor;
|
use crate::db::DbExecutor;
|
||||||
use crate::errors::ServiceErrors;
|
use crate::errors::ServiceErrors;
|
||||||
use crate::models::Project;
|
use crate::models::Project;
|
||||||
@ -25,8 +27,14 @@ impl Handler<LoadCurrentProject> for DbExecutor {
|
|||||||
.get()
|
.get()
|
||||||
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
|
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
|
||||||
|
|
||||||
projects
|
let query = projects.filter(id.eq(msg.project_id));
|
||||||
.filter(id.eq(msg.project_id))
|
|
||||||
|
debug!(
|
||||||
|
"{}",
|
||||||
|
diesel::debug_query::<diesel::pg::Pg, _>(&query).to_string()
|
||||||
|
);
|
||||||
|
|
||||||
|
query
|
||||||
.first::<Project>(conn)
|
.first::<Project>(conn)
|
||||||
.map_err(|_| ServiceErrors::RecordNotFound("Project".to_string()))
|
.map_err(|_| ServiceErrors::RecordNotFound("Project".to_string()))
|
||||||
}
|
}
|
||||||
@ -38,7 +46,7 @@ pub struct UpdateProject {
|
|||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub url: Option<String>,
|
pub url: Option<String>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub category: Option<String>,
|
pub category: Option<ProjectCategory>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Message for UpdateProject {
|
impl Message for UpdateProject {
|
||||||
|
@ -32,7 +32,7 @@ impl Into<HttpResponse> for ServiceErrors {
|
|||||||
}
|
}
|
||||||
ServiceErrors::DatabaseQueryFailed(error) => {
|
ServiceErrors::DatabaseQueryFailed(error) => {
|
||||||
HttpResponse::BadRequest().json(ErrorResponse {
|
HttpResponse::BadRequest().json(ErrorResponse {
|
||||||
errors: vec![error.to_owned()],
|
errors: vec![error],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
ServiceErrors::RecordNotFound(resource_name) => {
|
ServiceErrors::RecordNotFound(resource_name) => {
|
||||||
|
@ -25,7 +25,7 @@ async fn main() -> Result<(), String> {
|
|||||||
let bind = std::env::var("JIRS_SERVER_BIND").unwrap_or_else(|_| "0.0.0.0".to_string());
|
let bind = std::env::var("JIRS_SERVER_BIND").unwrap_or_else(|_| "0.0.0.0".to_string());
|
||||||
let addr = format!("{}:{}", bind, port);
|
let addr = format!("{}:{}", bind, port);
|
||||||
|
|
||||||
let db_addr = actix::SyncArbiter::start(4, || crate::db::DbExecutor::new());
|
let db_addr = actix::SyncArbiter::start(4, crate::db::DbExecutor::default);
|
||||||
|
|
||||||
HttpServer::new(move || {
|
HttpServer::new(move || {
|
||||||
App::new()
|
App::new()
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
use crate::db::SyncQuery;
|
use std::task::{Context, Poll};
|
||||||
|
|
||||||
use actix_service::{Service, Transform};
|
use actix_service::{Service, Transform};
|
||||||
use actix_web::http::header::{self};
|
use actix_web::http::header::{self};
|
||||||
use actix_web::http::HeaderMap;
|
use actix_web::http::HeaderMap;
|
||||||
use actix_web::{dev::ServiceRequest, dev::ServiceResponse, Error};
|
use actix_web::{dev::ServiceRequest, dev::ServiceResponse, Error};
|
||||||
use futures::future::{ok, FutureExt, LocalBoxFuture, Ready};
|
use futures::future::{ok, FutureExt, LocalBoxFuture, Ready};
|
||||||
use std::task::{Context, Poll};
|
|
||||||
|
use crate::db::SyncQuery;
|
||||||
|
|
||||||
type Db = actix_web::web::Data<crate::db::DbPool>;
|
type Db = actix_web::web::Data<crate::db::DbPool>;
|
||||||
|
|
||||||
@ -82,7 +84,7 @@ pub fn token_from_headers(
|
|||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
) -> std::result::Result<uuid::Uuid, crate::errors::ServiceErrors> {
|
) -> std::result::Result<uuid::Uuid, crate::errors::ServiceErrors> {
|
||||||
headers
|
headers
|
||||||
.get(&header::AUTHORIZATION)
|
.get(header::AUTHORIZATION)
|
||||||
.ok_or_else(|| crate::errors::ServiceErrors::Unauthorized)
|
.ok_or_else(|| crate::errors::ServiceErrors::Unauthorized)
|
||||||
.map(|h| h.to_str().unwrap_or_default())
|
.map(|h| h.to_str().unwrap_or_default())
|
||||||
.and_then(|s| parse_bearer(s))
|
.and_then(|s| parse_bearer(s))
|
||||||
|
@ -2,7 +2,7 @@ use chrono::NaiveDateTime;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use jirs_data::{IssuePriority, IssueStatus, IssueType};
|
use jirs_data::{IssuePriority, IssueStatus, IssueType, ProjectCategory};
|
||||||
|
|
||||||
use crate::schema::*;
|
use crate::schema::*;
|
||||||
|
|
||||||
@ -127,7 +127,7 @@ pub struct Project {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub category: String,
|
pub category: ProjectCategory,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
}
|
}
|
||||||
@ -147,17 +147,15 @@ impl Into<jirs_data::Project> for Project {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Insertable)]
|
#[derive(Debug, Serialize, Deserialize, Insertable)]
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
#[table_name = "projects"]
|
#[table_name = "projects"]
|
||||||
pub struct UpdateProjectForm {
|
pub struct UpdateProjectForm {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub url: Option<String>,
|
pub url: Option<String>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub category: Option<String>,
|
pub category: Option<ProjectCategory>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Queryable)]
|
#[derive(Debug, Serialize, Deserialize, Queryable)]
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@ -190,8 +188,8 @@ impl Into<jirs_data::User> for &User {
|
|||||||
email: self.email.clone(),
|
email: self.email.clone(),
|
||||||
avatar_url: self.avatar_url.clone(),
|
avatar_url: self.avatar_url.clone(),
|
||||||
project_id: self.project_id,
|
project_id: self.project_id,
|
||||||
created_at: self.created_at.clone(),
|
created_at: self.created_at,
|
||||||
updated_at: self.updated_at.clone(),
|
updated_at: self.updated_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -223,10 +223,10 @@ table! {
|
|||||||
description -> Text,
|
description -> Text,
|
||||||
/// The `category` column of the `projects` table.
|
/// The `category` column of the `projects` table.
|
||||||
///
|
///
|
||||||
/// Its SQL type is `Text`.
|
/// Its SQL type is `ProjectCategoryType`.
|
||||||
///
|
///
|
||||||
/// (Automatically generated by Diesel.)
|
/// (Automatically generated by Diesel.)
|
||||||
category -> Text,
|
category -> ProjectCategoryType,
|
||||||
/// The `created_at` column of the `projects` table.
|
/// The `created_at` column of the `projects` table.
|
||||||
///
|
///
|
||||||
/// Its SQL type is `Timestamp`.
|
/// Its SQL type is `Timestamp`.
|
||||||
|
@ -25,11 +25,11 @@ pub async fn update_issue(
|
|||||||
status: Some(payload.status),
|
status: Some(payload.status),
|
||||||
priority: Some(payload.priority),
|
priority: Some(payload.priority),
|
||||||
list_position: Some(payload.list_position),
|
list_position: Some(payload.list_position),
|
||||||
description: Some(payload.description),
|
description: payload.description,
|
||||||
description_text: Some(payload.description_text),
|
description_text: payload.description_text,
|
||||||
estimate: Some(payload.estimate),
|
estimate: payload.estimate,
|
||||||
time_spent: Some(payload.time_spent),
|
time_spent: payload.time_spent,
|
||||||
time_remaining: Some(payload.time_remaining),
|
time_remaining: payload.time_remaining,
|
||||||
project_id: Some(payload.project_id),
|
project_id: Some(payload.project_id),
|
||||||
user_ids: Some(payload.user_ids),
|
user_ids: Some(payload.user_ids),
|
||||||
reporter_id: Some(payload.reporter_id),
|
reporter_id: Some(payload.reporter_id),
|
||||||
@ -40,12 +40,7 @@ pub async fn update_issue(
|
|||||||
_ => return Ok(None),
|
_ => return Ok(None),
|
||||||
};
|
};
|
||||||
|
|
||||||
let assignees = match db
|
let assignees = match db.send(LoadAssignees { issue_id: issue.id }).await {
|
||||||
.send(LoadAssignees {
|
|
||||||
issue_id: issue.id.clone(),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Ok(v)) => v,
|
Ok(Ok(v)) => v,
|
||||||
_ => vec![],
|
_ => vec![],
|
||||||
};
|
};
|
||||||
@ -53,7 +48,7 @@ pub async fn update_issue(
|
|||||||
issue.user_ids.push(assignee.user_id);
|
issue.user_ids.push(assignee.user_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Some(WsMsg::IssueUpdated(issue.into())))
|
Ok(Some(WsMsg::IssueUpdated(issue)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn add_issue(
|
pub async fn add_issue(
|
||||||
@ -109,22 +104,17 @@ pub async fn load_issues(db: &Data<Addr<DbExecutor>>, user: &Option<jirs_data::U
|
|||||||
let mut issue_map = HashMap::new();
|
let mut issue_map = HashMap::new();
|
||||||
let mut queue = vec![];
|
let mut queue = vec![];
|
||||||
for issue in issues.into_iter() {
|
for issue in issues.into_iter() {
|
||||||
let f = db.send(LoadAssignees {
|
let f = db.send(LoadAssignees { issue_id: issue.id });
|
||||||
issue_id: issue.id.clone(),
|
|
||||||
});
|
|
||||||
queue.push(f);
|
queue.push(f);
|
||||||
issue_map.insert(issue.id.clone(), issue);
|
issue_map.insert(issue.id, issue);
|
||||||
}
|
}
|
||||||
for f in queue {
|
for f in queue {
|
||||||
match f.await {
|
if let Ok(Ok(assignees)) = f.await {
|
||||||
Ok(Ok(assignees)) => {
|
|
||||||
for assignee in assignees {
|
for assignee in assignees {
|
||||||
if let Some(issue) = issue_map.get_mut(&assignee.issue_id) {
|
if let Some(issue) = issue_map.get_mut(&assignee.issue_id) {
|
||||||
issue.user_ids.push(assignee.user_id);
|
issue.user_ids.push(assignee.user_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let mut issues = vec![];
|
let mut issues = vec![];
|
||||||
|
@ -75,6 +75,12 @@ impl WebSocketActor {
|
|||||||
block_on(projects::current_project(&self.db, &self.current_user))?
|
block_on(projects::current_project(&self.db, &self.current_user))?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
WsMsg::ProjectUpdateRequest(payload) => block_on(projects::update_project(
|
||||||
|
&self.db,
|
||||||
|
&self.current_user,
|
||||||
|
payload,
|
||||||
|
))?,
|
||||||
|
|
||||||
// auth
|
// auth
|
||||||
WsMsg::AuthorizeRequest(uuid) => block_on(self.authorize(uuid))?,
|
WsMsg::AuthorizeRequest(uuid) => block_on(self.authorize(uuid))?,
|
||||||
|
|
||||||
@ -114,6 +120,9 @@ impl WebSocketActor {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if msg.is_some() && msg != Some(WsMsg::Pong) {
|
||||||
|
info!("sending message {:?}", msg);
|
||||||
|
}
|
||||||
Ok(msg)
|
Ok(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
use actix::Addr;
|
use actix::Addr;
|
||||||
use actix_web::web::Data;
|
use actix_web::web::Data;
|
||||||
|
|
||||||
use jirs_data::WsMsg;
|
use jirs_data::{UpdateProjectPayload, WsMsg};
|
||||||
|
|
||||||
use crate::db::users::LoadProjectUsers;
|
use crate::db::projects::LoadCurrentProject;
|
||||||
use crate::db::DbExecutor;
|
use crate::db::DbExecutor;
|
||||||
use crate::ws::{current_user, WsResult};
|
use crate::ws::{current_user, WsResult};
|
||||||
|
|
||||||
@ -13,11 +13,38 @@ pub async fn current_project(
|
|||||||
) -> WsResult {
|
) -> WsResult {
|
||||||
let project_id = current_user(user).map(|u| u.project_id)?;
|
let project_id = current_user(user).map(|u| u.project_id)?;
|
||||||
|
|
||||||
let m = match db.send(LoadProjectUsers { project_id }).await {
|
let m = match db.send(LoadCurrentProject { project_id }).await {
|
||||||
Ok(Ok(v)) => Some(WsMsg::ProjectUsersLoaded(
|
Ok(Ok(project)) => Some(WsMsg::ProjectLoaded(project.into())),
|
||||||
v.into_iter().map(|i| i.into()).collect(),
|
Ok(Err(e)) => {
|
||||||
)),
|
error!("{:?}", e);
|
||||||
_ => None,
|
None
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("{:?}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
};
|
};
|
||||||
Ok(m)
|
Ok(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_project(
|
||||||
|
db: &Data<Addr<DbExecutor>>,
|
||||||
|
user: &Option<jirs_data::User>,
|
||||||
|
payload: UpdateProjectPayload,
|
||||||
|
) -> WsResult {
|
||||||
|
let project_id = current_user(user).map(|u| u.project_id)?;
|
||||||
|
let project = match db
|
||||||
|
.send(crate::db::projects::UpdateProject {
|
||||||
|
project_id,
|
||||||
|
name: payload.name,
|
||||||
|
url: payload.url,
|
||||||
|
description: payload.description,
|
||||||
|
category: payload.category,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(project)) => project,
|
||||||
|
_ => return Ok(None),
|
||||||
|
};
|
||||||
|
Ok(Some(WsMsg::ProjectLoaded(project.into())))
|
||||||
|
}
|
||||||
|
2
react-client/src/Project/Styles.js
vendored
2
react-client/src/Project/Styles.js
vendored
@ -1,6 +1,6 @@
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
import { sizes } from 'shared/utils/styles';
|
import { sizes } from '../shared/utils/styles';
|
||||||
|
|
||||||
const paddingLeft = sizes.appNavBarLeftWidth + sizes.secondarySideBarWidth + 40;
|
const paddingLeft = sizes.appNavBarLeftWidth + sizes.secondarySideBarWidth + 40;
|
||||||
|
|
||||||
|
@ -23,8 +23,8 @@ const Project = () => {
|
|||||||
|
|
||||||
const [{ data, error, setLocalData }, fetchProject] = useApi.get('/project');
|
const [{ data, error, setLocalData }, fetchProject] = useApi.get('/project');
|
||||||
|
|
||||||
if (!data) return <PageLoader />;
|
if (!data) return <PageLoader/>;
|
||||||
if (error) return <PageError />;
|
if (error) return <PageError/>;
|
||||||
|
|
||||||
const { project } = data;
|
const { project } = data;
|
||||||
|
|
||||||
@ -38,22 +38,22 @@ const Project = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProjectPage id={ 'ProjectPage' }>
|
<ProjectPage id={'ProjectPage'}>
|
||||||
<NavbarLeft
|
<NavbarLeft
|
||||||
issueSearchModalOpen={ issueSearchModalHelpers.open }
|
issueSearchModalOpen={issueSearchModalHelpers.open}
|
||||||
issueCreateModalOpen={ issueCreateModalHelpers.open }
|
issueCreateModalOpen={issueCreateModalHelpers.open}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Sidebar project={ project }/>
|
<Sidebar project={project}/>
|
||||||
|
|
||||||
{ issueSearchModalHelpers.isOpen() && (
|
{issueSearchModalHelpers.isOpen() && (
|
||||||
<Modal
|
<Modal
|
||||||
isOpen
|
isOpen
|
||||||
testid="modal:issue-search"
|
testid="modal:issue-search"
|
||||||
variant="aside"
|
variant="aside"
|
||||||
width={600}
|
width={600}
|
||||||
onClose={issueSearchModalHelpers.close}
|
onClose={issueSearchModalHelpers.close}
|
||||||
renderContent={() => <IssueSearch project={project} />}
|
renderContent={() => <IssueSearch project={project}/>}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -88,10 +88,10 @@ const Project = () => {
|
|||||||
|
|
||||||
<Route
|
<Route
|
||||||
path={`${match.path}/settings`}
|
path={`${match.path}/settings`}
|
||||||
render={() => <ProjectSettings project={project} fetchProject={fetchProject} />}
|
render={() => <ProjectSettings project={project} fetchProject={fetchProject}/>}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{match.isExact && <Redirect to={`${match.url}/board`} />}
|
{match.isExact && <Redirect to={`${match.url}/board`}/>}
|
||||||
</ProjectPage>
|
</ProjectPage>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user