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"
|
||||
path = "./src/lib.rs"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
opt-level = 's'
|
||||
|
||||
[dependencies]
|
||||
jirs-data = { path = "../jirs-data" }
|
||||
seed = { version = "*" }
|
||||
|
@ -119,45 +119,6 @@
|
||||
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 */
|
||||
/*===================================================*/
|
||||
|
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 {
|
||||
padding: 20px 25px 25px;
|
||||
}
|
||||
@ -6,7 +53,11 @@
|
||||
padding-bottom: 14px;
|
||||
font-family: var(--font-medium);
|
||||
font-weight: normal;
|
||||
font-size: 20px
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.timeTrackingModal > .trackingWidget {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.timeTrackingModal > .inputs {
|
||||
@ -18,3 +69,8 @@
|
||||
margin: 0 5px;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.timeTrackingModal > .actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
@ -30,6 +30,7 @@ import("../pkg/index.js").then(module => {
|
||||
buildWebSocket();
|
||||
|
||||
window.send_bin_code = code => queue.push(code);
|
||||
window.inspectQueue = () => queue;
|
||||
|
||||
let wsCheckDelay = 100;
|
||||
const flush = () => {
|
||||
|
@ -21,4 +21,5 @@
|
||||
@import "./css/app.css";
|
||||
@import "./css/issue.css";
|
||||
@import "./css/project.css";
|
||||
@import "./css/projectSettings.css";
|
||||
@import "./css/timeTracking.css";
|
||||
|
@ -138,6 +138,7 @@ pub enum Msg {
|
||||
ProjectToggleOnlyMy,
|
||||
ProjectToggleRecentlyUpdated,
|
||||
ProjectClearFilters,
|
||||
ProjectSaveChanges,
|
||||
|
||||
// dragging
|
||||
IssueDragStarted(IssueId),
|
||||
@ -159,7 +160,7 @@ pub enum Msg {
|
||||
DeleteComment(CommentId),
|
||||
|
||||
// modals
|
||||
ModalOpened(ModalType),
|
||||
ModalOpened(Box<ModalType>),
|
||||
ModalDropped,
|
||||
ModalChanged(FieldChange),
|
||||
|
||||
@ -175,7 +176,7 @@ fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) {
|
||||
}
|
||||
match &msg {
|
||||
Msg::ChangePage(page) => {
|
||||
model.page = page.clone();
|
||||
model.page = *page;
|
||||
}
|
||||
Msg::ToggleAboutTooltip => {
|
||||
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::modal::update(&msg, model, orders);
|
||||
match model.page {
|
||||
Page::Project | Page::AddIssue => project::update(msg, model, orders),
|
||||
Page::EditIssue(_id) => project::update(msg, model, orders),
|
||||
Page::Project | Page::AddIssue | Page::EditIssue(..) => project::update(msg, model, orders),
|
||||
Page::ProjectSettings => project_settings::update(msg, model, orders),
|
||||
Page::Login => login::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() {
|
||||
v.push(a.get_index(idx));
|
||||
}
|
||||
match bincode::deserialize(v.as_slice()) {
|
||||
Ok(msg) => {
|
||||
ws::handle(msg);
|
||||
}
|
||||
_ => (),
|
||||
if let Ok(msg) = bincode::deserialize(v.as_slice()) {
|
||||
ws::handle(msg);
|
||||
};
|
||||
}
|
||||
|
||||
@ -299,9 +296,8 @@ pub fn render() {
|
||||
.routes(routes)
|
||||
.build_and_start();
|
||||
|
||||
match crate::shared::read_auth_token() {
|
||||
Ok(uuid) => send_ws_msg(WsMsg::AuthorizeRequest(uuid)),
|
||||
_ => (),
|
||||
if let Ok(uuid) = crate::shared::read_auth_token() {
|
||||
send_ws_msg(WsMsg::AuthorizeRequest(uuid));
|
||||
};
|
||||
|
||||
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(),
|
||||
description: modal.description.clone(),
|
||||
description_text: modal.description_text.clone(),
|
||||
estimate: modal.estimate.clone(),
|
||||
time_spent: modal.time_spent.clone(),
|
||||
time_remaining: modal.time_remaining.clone(),
|
||||
estimate: modal.estimate,
|
||||
time_spent: modal.time_spent,
|
||||
time_remaining: modal.time_remaining,
|
||||
project_id: modal.project_id.unwrap_or(project_id),
|
||||
user_ids: modal.user_ids.clone(),
|
||||
reporter_id: modal.reporter_id.unwrap_or_else(|| user_id),
|
||||
|
@ -8,11 +8,12 @@ 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_icon::Icon;
|
||||
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::tracking_widget::tracking_link;
|
||||
use crate::shared::ToNode;
|
||||
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());
|
||||
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(
|
||||
FieldId::EditIssueModal(EditIssueModalFieldId::Description),
|
||||
mode,
|
||||
@ -197,7 +206,7 @@ fn top_modal_row(_model: &Model, modal: &EditIssueModal) -> Node<Msg> {
|
||||
..
|
||||
} = modal;
|
||||
|
||||
let issue_id = id.clone();
|
||||
let issue_id = *id;
|
||||
|
||||
let click_handler = mouse_ev(Ev::Click, move |_| {
|
||||
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 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()
|
||||
@ -261,7 +270,7 @@ fn top_modal_row(_model: &Model, modal: &EditIssueModal) -> Node<Msg> {
|
||||
.collect(),
|
||||
)
|
||||
.selected(vec![{
|
||||
let id = modal.id.clone();
|
||||
let id = modal.id;
|
||||
let issue_type = &payload.issue_type;
|
||||
issue_type
|
||||
.to_select_child()
|
||||
@ -424,7 +433,7 @@ fn comment(model: &Model, modal: &EditIssueModal, comment: &Comment) -> Option<N
|
||||
let comment_id = comment.id;
|
||||
let delete_comment_handler = mouse_ev(Ev::Click, move |ev| {
|
||||
ev.stop_propagation();
|
||||
Msg::ModalOpened(ModalType::DeleteCommentConfirm(comment_id))
|
||||
Msg::ModalOpened(Box::new(ModalType::DeleteCommentConfirm(comment_id)))
|
||||
});
|
||||
let edit_button = StyledButton::build()
|
||||
.add_class("editButton")
|
||||
@ -582,7 +591,6 @@ fn right_modal_column(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
|
||||
.estimate
|
||||
.as_ref()
|
||||
.map(|n| n.to_string())
|
||||
.clone()
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.build()
|
||||
@ -593,7 +601,7 @@ fn right_modal_column(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
|
||||
.build()
|
||||
.into_node();
|
||||
|
||||
let tracking = tracking_widget(model, modal);
|
||||
let tracking = tracking_link(model, modal);
|
||||
let tracking_field = StyledField::build()
|
||||
.label("TIME TRACKING")
|
||||
.tip("")
|
||||
@ -611,68 +619,3 @@ fn right_modal_column(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
|
||||
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) => {
|
||||
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() => {
|
||||
push_edit_modal(&issue_id, model)
|
||||
push_edit_modal(issue_id, model)
|
||||
}
|
||||
_ => (),
|
||||
},
|
||||
|
||||
Msg::ChangePage(Page::EditIssue(issue_id)) => {
|
||||
push_edit_modal(issue_id, model);
|
||||
push_edit_modal(*issue_id, model);
|
||||
}
|
||||
|
||||
Msg::ChangePage(Page::AddIssue) => {
|
||||
@ -95,14 +95,14 @@ pub fn view(model: &model::Model) -> Node<Msg> {
|
||||
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 issue = match find_issue(model, *issue_id) {
|
||||
let issue = match find_issue(model, issue_id) {
|
||||
Some(issue) => issue,
|
||||
_ => 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);
|
||||
}
|
||||
|
@ -2,27 +2,88 @@ use seed::{prelude::*, *};
|
||||
|
||||
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_modal::StyledModal;
|
||||
use crate::shared::tracking_widget::tracking_widget;
|
||||
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> {
|
||||
let issue = match find_issue(model, issue_id) {
|
||||
let _issue = match find_issue(model, issue_id) {
|
||||
Some(issue) => issue,
|
||||
_ => 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 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()
|
||||
.add_class("timeTrackingModal")
|
||||
.children(vec![modal_title, inputs])
|
||||
.children(vec![
|
||||
modal_title,
|
||||
tracking,
|
||||
inputs,
|
||||
div![class!["actions"], close],
|
||||
])
|
||||
.width(400)
|
||||
.build()
|
||||
.into_node()
|
||||
}
|
||||
|
@ -7,7 +7,9 @@ use jirs_data::*;
|
||||
|
||||
use crate::shared::styled_editor::Mode;
|
||||
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)]
|
||||
pub enum ModalType {
|
||||
@ -52,14 +54,14 @@ impl EditIssueModal {
|
||||
issue_type: issue.issue_type.clone(),
|
||||
status: issue.status.clone(),
|
||||
priority: issue.priority.clone(),
|
||||
list_position: issue.list_position.clone(),
|
||||
list_position: issue.list_position,
|
||||
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(),
|
||||
estimate: issue.estimate,
|
||||
time_spent: issue.time_spent,
|
||||
time_remaining: issue.time_remaining,
|
||||
project_id: issue.project_id,
|
||||
reporter_id: issue.reporter_id,
|
||||
user_ids: issue.user_ids.clone(),
|
||||
},
|
||||
top_type_state: StyledSelectState::new(FieldId::EditIssueModal(
|
||||
@ -151,11 +153,11 @@ pub enum Page {
|
||||
}
|
||||
|
||||
impl Page {
|
||||
pub fn to_path(&self) -> String {
|
||||
pub fn to_path(self) -> String {
|
||||
match self {
|
||||
Page::Project => "/board".to_string(),
|
||||
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::Login => "/login".to_string(),
|
||||
Page::Register => "/register".to_string(),
|
||||
@ -193,7 +195,35 @@ pub struct ProjectPage {
|
||||
#[derive(Debug)]
|
||||
pub struct ProjectSettingsPage {
|
||||
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)]
|
||||
|
@ -4,7 +4,7 @@ use seed::{prelude::*, *};
|
||||
use jirs_data::*;
|
||||
|
||||
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_button::StyledButton;
|
||||
use crate::shared::styled_icon::{Icon, StyledIcon};
|
||||
@ -14,16 +14,23 @@ use crate::shared::{drag_ev, inner_layout, ToNode};
|
||||
use crate::{EditIssueModalFieldId, FieldId, 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 {
|
||||
PageContent::Project(project_page) => project_page,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
match msg {
|
||||
Msg::ChangePage(Page::Project)
|
||||
Msg::WsMsg(WsMsg::AuthorizeLoaded(..))
|
||||
| Msg::ChangePage(Page::Project)
|
||||
| Msg::ChangePage(Page::AddIssue)
|
||||
| Msg::ChangePage(Page::EditIssue(..))
|
||||
| Msg::WsMsg(WsMsg::AuthorizeLoaded(Ok(_))) => {
|
||||
| Msg::ChangePage(Page::EditIssue(..)) => {
|
||||
send_ws_msg(jirs_data::WsMsg::ProjectRequest);
|
||||
send_ws_msg(jirs_data::WsMsg::ProjectIssuesRequest);
|
||||
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) => {
|
||||
project_page.text_filter = text;
|
||||
}
|
||||
Msg::ProjectAvatarFilterChanged(user_id, active) => match active {
|
||||
true => {
|
||||
Msg::ProjectAvatarFilterChanged(user_id, active) => {
|
||||
if active {
|
||||
project_page.active_avatar_filters = project_page
|
||||
.active_avatar_filters
|
||||
.iter()
|
||||
.filter(|id| **id != user_id)
|
||||
.map(|id| *id)
|
||||
.filter_map(|id| if *id != user_id { Some(*id) } else { None })
|
||||
.collect();
|
||||
}
|
||||
false => {
|
||||
} else {
|
||||
project_page.active_avatar_filters.push(user_id);
|
||||
}
|
||||
},
|
||||
}
|
||||
Msg::ProjectToggleOnlyMy => {
|
||||
project_page.only_my_filter = !project_page.only_my_filter;
|
||||
}
|
||||
@ -184,16 +189,17 @@ fn project_board_filters(model: &Model) -> Node<Msg> {
|
||||
.build()
|
||||
.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.active_avatar_filters.is_empty()
|
||||
{
|
||||
true => seed::button![
|
||||
seed::button![
|
||||
id!["clearAllFilters"],
|
||||
"Clear all",
|
||||
mouse_ev(Ev::Click, |_| Msg::ProjectClearFilters),
|
||||
],
|
||||
false => empty![],
|
||||
]
|
||||
} else {
|
||||
empty![]
|
||||
};
|
||||
|
||||
div![
|
||||
@ -252,11 +258,11 @@ fn project_issue_list(model: &Model, status: jirs_data::IssueStatus) -> Node<Msg
|
||||
PageContent::Project(project_page) => project_page,
|
||||
_ => 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
|
||||
.issues
|
||||
.iter()
|
||||
.map(|issue| (issue.id, issue.updated_at.clone()))
|
||||
.map(|issue| (issue.id, issue.updated_at))
|
||||
.collect();
|
||||
v.sort_by(|(_, a_time), (_, b_time)| a_time.cmp(b_time));
|
||||
if v.len() > 10 { v[0..10].to_vec() } else { v }
|
||||
@ -273,7 +279,7 @@ fn project_issue_list(model: &Model, status: jirs_data::IssueStatus) -> Node<Msg
|
||||
issue_filter_status(issue, &status)
|
||||
&& issue_filter_with_text(issue, project_page.text_filter.as_str())
|
||||
&& issue_filter_with_only_my(issue, project_page.only_my_filter, &model.user)
|
||||
&& issue_filter_with_only_recent(issue, &ids)
|
||||
&& issue_filter_with_only_recent(issue, ids.as_slice())
|
||||
})
|
||||
.map(|issue| project_issue(model, issue))
|
||||
.collect();
|
||||
@ -324,7 +330,7 @@ fn issue_filter_with_only_my(issue: &Issue, only_my: bool, user: &Option<User>)
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
|
@ -1,18 +1,92 @@
|
||||
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_field::StyledField;
|
||||
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::FieldChange::TabChanged;
|
||||
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> {
|
||||
let name = StyledInput::build(FieldId::ProjectSettings(ProjectSettingsFieldId::Name))
|
||||
.valid(true)
|
||||
.build()
|
||||
let page = match &model.page_content {
|
||||
PageContent::ProjectSettings(page) => page,
|
||||
_ => 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();
|
||||
let name_field = StyledField::build()
|
||||
.label("Name")
|
||||
@ -21,9 +95,12 @@ pub fn view(model: &model::Model) -> Node<Msg> {
|
||||
.build()
|
||||
.into_node();
|
||||
|
||||
let url = StyledInput::build(FieldId::ProjectSettings(ProjectSettingsFieldId::Url))
|
||||
.valid(true)
|
||||
.build()
|
||||
let url = StyledTextarea::build()
|
||||
.height(39)
|
||||
.max_height(39)
|
||||
.disable_auto_resize()
|
||||
.value(page.payload.url.as_ref().cloned().unwrap_or_default())
|
||||
.build(FieldId::ProjectSettings(ProjectSettingsFieldId::Url))
|
||||
.into_node();
|
||||
let url_field = StyledField::build()
|
||||
.label("Url")
|
||||
@ -35,8 +112,15 @@ pub fn view(model: &model::Model) -> Node<Msg> {
|
||||
let description = StyledEditor::build(FieldId::ProjectSettings(
|
||||
ProjectSettingsFieldId::Description,
|
||||
))
|
||||
.text("")
|
||||
.text(
|
||||
page.payload
|
||||
.description
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.update_on(Ev::Change)
|
||||
.mode(page.description_mode.clone())
|
||||
.build()
|
||||
.into_node();
|
||||
let description_field = StyledField::build()
|
||||
@ -46,15 +130,50 @@ pub fn view(model: &model::Model) -> Node<Msg> {
|
||||
.build()
|
||||
.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()
|
||||
.heading("Project Details")
|
||||
.add_field(name_field)
|
||||
.add_field(url_field)
|
||||
.add_field(description_field)
|
||||
.add_field(category_field)
|
||||
.add_field(save_button)
|
||||
.build()
|
||||
.into_node();
|
||||
|
||||
let project_section = vec![form];
|
||||
let project_section = vec![div![class!["formContainer"], form]];
|
||||
|
||||
inner_layout(model, "projectSettings", project_section, empty![])
|
||||
}
|
||||
|
@ -14,7 +14,10 @@ pub fn render(model: &Model) -> Node<Msg> {
|
||||
div![
|
||||
attrs![At::Class => "projectTexts";],
|
||||
div![attrs![At::Class => "projectName";], project.name],
|
||||
div![attrs![At::Class => "projectCategory";], project.category]
|
||||
div![
|
||||
attrs![At::Class => "projectCategory";],
|
||||
project.category.to_string()
|
||||
]
|
||||
],
|
||||
],
|
||||
_ => 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> {
|
||||
let path = page.map(|ref p| p.to_path()).unwrap_or_default();
|
||||
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());
|
||||
};
|
||||
if Some(model.page) == page {
|
||||
|
@ -21,6 +21,7 @@ pub mod styled_select;
|
||||
pub mod styled_select_child;
|
||||
pub mod styled_textarea;
|
||||
pub mod styled_tooltip;
|
||||
pub mod tracking_widget;
|
||||
|
||||
pub fn find_issue(model: &Model, issue_id: IssueId) -> Option<&Issue> {
|
||||
model.issues.iter().find(|issue| issue.id == issue_id)
|
||||
|
@ -30,9 +30,9 @@ pub struct StyledButtonBuilder {
|
||||
variant: Option<Variant>,
|
||||
disabled: Option<bool>,
|
||||
active: Option<bool>,
|
||||
text: Option<Option<String>>,
|
||||
icon: Option<Option<Node<Msg>>>,
|
||||
on_click: Option<Option<EventHandler<Msg>>>,
|
||||
text: Option<String>,
|
||||
icon: Option<Node<Msg>>,
|
||||
on_click: Option<EventHandler<Msg>>,
|
||||
children: Option<Vec<Node<Msg>>>,
|
||||
class_list: Vec<String>,
|
||||
}
|
||||
@ -77,7 +77,7 @@ impl StyledButtonBuilder {
|
||||
where
|
||||
S: Into<String>,
|
||||
{
|
||||
self.text = Some(Some(value.into()));
|
||||
self.text = Some(value.into());
|
||||
self
|
||||
}
|
||||
|
||||
@ -85,12 +85,12 @@ impl StyledButtonBuilder {
|
||||
where
|
||||
I: ToNode,
|
||||
{
|
||||
self.icon = Some(Some(value.into_node()));
|
||||
self.icon = Some(value.into_node());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(mut self, value: EventHandler<Msg>) -> Self {
|
||||
self.on_click = Some(Some(value));
|
||||
self.on_click = Some(value);
|
||||
self
|
||||
}
|
||||
|
||||
@ -112,9 +112,9 @@ impl StyledButtonBuilder {
|
||||
variant: self.variant.unwrap_or_else(|| Variant::Primary),
|
||||
disabled: self.disabled.unwrap_or_else(|| false),
|
||||
active: self.active.unwrap_or_else(|| false),
|
||||
text: self.text.unwrap_or_default(),
|
||||
icon: self.icon.unwrap_or_else(|| None),
|
||||
on_click: self.on_click.unwrap_or_else(|| None),
|
||||
text: self.text,
|
||||
icon: self.icon,
|
||||
on_click: self.on_click,
|
||||
children: self.children.unwrap_or_default(),
|
||||
class_list: self.class_list,
|
||||
}
|
||||
@ -187,9 +187,10 @@ pub fn render(values: StyledButton) -> Node<Msg> {
|
||||
At::Class => class_list.join(" "),
|
||||
],
|
||||
handler,
|
||||
match disabled {
|
||||
true => vec![attrs![At::Disabled => true]],
|
||||
false => vec![],
|
||||
if disabled {
|
||||
vec![attrs![At::Disabled => true]]
|
||||
} else {
|
||||
vec![]
|
||||
},
|
||||
icon_node,
|
||||
content,
|
||||
|
@ -38,7 +38,7 @@ pub struct StyledConfirmModalBuilder {
|
||||
message: Option<String>,
|
||||
confirm_text: Option<String>,
|
||||
cancel_text: Option<String>,
|
||||
on_confirm: Option<Option<EventHandler<Msg>>>,
|
||||
on_confirm: Option<EventHandler<Msg>>,
|
||||
}
|
||||
|
||||
impl StyledConfirmModalBuilder {
|
||||
@ -75,7 +75,7 @@ impl StyledConfirmModalBuilder {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -87,7 +87,7 @@ impl StyledConfirmModalBuilder {
|
||||
.confirm_text
|
||||
.unwrap_or_else(|| CONFIRM_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 on_editor_clicked = mouse_ev(Ev::Click, move |ev| {
|
||||
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 on_view_clicked = mouse_ev(Ev::Click, move |ev| {
|
||||
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 view_id = format!("view-{}", id.clone());
|
||||
let name = format!("styled-editor-{}", id.clone());
|
||||
let editor_id = format!("editor-{}", id);
|
||||
let view_id = format!("view-{}", id);
|
||||
let name = format!("styled-editor-{}", id);
|
||||
|
||||
let text_area = StyledTextarea::build()
|
||||
.height(40)
|
||||
.update_on(update_event)
|
||||
.value(text.as_str())
|
||||
.build(id.clone())
|
||||
.build(id)
|
||||
.into_node();
|
||||
|
||||
let parsed = comrak::markdown_to_html(
|
||||
|
@ -54,8 +54,8 @@ pub fn render(values: StyledForm) -> Node<Msg> {
|
||||
div![
|
||||
attrs![At::Class => "styledForm"],
|
||||
div![
|
||||
attrs![At::Class => "formElement"],
|
||||
div![attrs![At::Class => "formHeading"], heading],
|
||||
class!["formElement"],
|
||||
div![class!["formHeading"], heading],
|
||||
fields
|
||||
],
|
||||
]
|
||||
|
@ -132,14 +132,14 @@ impl ToNode for Icon {
|
||||
|
||||
pub struct StyledIconBuilder {
|
||||
icon: Icon,
|
||||
size: Option<Option<i32>>,
|
||||
size: Option<i32>,
|
||||
class_list: Vec<String>,
|
||||
style_list: Vec<String>,
|
||||
}
|
||||
|
||||
impl StyledIconBuilder {
|
||||
pub fn size(mut self, size: i32) -> Self {
|
||||
self.size = Some(Some(size));
|
||||
self.size = Some(size);
|
||||
self
|
||||
}
|
||||
|
||||
@ -162,7 +162,7 @@ impl StyledIconBuilder {
|
||||
pub fn build(self) -> StyledIcon {
|
||||
StyledIcon {
|
||||
icon: self.icon,
|
||||
size: self.size.unwrap_or_default(),
|
||||
size: self.size,
|
||||
class_list: self.class_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(),
|
||||
_ => empty![],
|
||||
};
|
||||
|
||||
let mut handlers = vec![];
|
||||
|
||||
handlers.push(input_ev(Ev::Change, move |value| {
|
||||
Msg::InputChanged(id, value)
|
||||
}));
|
||||
let field_id = id.clone();
|
||||
let change_handler = keyboard_ev(Ev::KeyUp, move |event| {
|
||||
use wasm_bindgen::JsCast;
|
||||
event.stop_propagation();
|
||||
let value = event
|
||||
.target()
|
||||
.unwrap()
|
||||
.dyn_ref::<web_sys::HtmlInputElement>()
|
||||
.unwrap()
|
||||
.value();
|
||||
log!("asd");
|
||||
Msg::InputChanged(field_id, value)
|
||||
});
|
||||
|
||||
div![
|
||||
attrs!(At::Class => wrapper_class_list.join(" ")),
|
||||
icon,
|
||||
keyboard_ev(Ev::KeyUp, |ev| {
|
||||
ev.stop_propagation();
|
||||
Msg::NoOp
|
||||
}),
|
||||
seed::input![
|
||||
attrs![
|
||||
At::Class => input_class_list.join(" "),
|
||||
At::Value => value.unwrap_or_default(),
|
||||
],
|
||||
handlers
|
||||
change_handler,
|
||||
],
|
||||
]
|
||||
}
|
||||
|
@ -112,8 +112,8 @@ impl StyledSelect {
|
||||
pub struct StyledSelectBuilder {
|
||||
id: FieldId,
|
||||
variant: Option<Variant>,
|
||||
dropdown_width: Option<Option<usize>>,
|
||||
name: Option<Option<String>>,
|
||||
dropdown_width: Option<usize>,
|
||||
name: Option<String>,
|
||||
valid: Option<bool>,
|
||||
is_multi: Option<bool>,
|
||||
allow_clear: Option<bool>,
|
||||
@ -128,8 +128,8 @@ impl StyledSelectBuilder {
|
||||
StyledSelect {
|
||||
id: self.id,
|
||||
variant: self.variant.unwrap_or_default(),
|
||||
dropdown_width: self.dropdown_width.unwrap_or_default(),
|
||||
name: self.name.unwrap_or_default(),
|
||||
dropdown_width: self.dropdown_width,
|
||||
name: self.name,
|
||||
valid: self.valid.unwrap_or(true),
|
||||
is_multi: self.is_multi.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 {
|
||||
self.dropdown_width = Some(Some(dropdown_width));
|
||||
self.dropdown_width = Some(dropdown_width);
|
||||
self
|
||||
}
|
||||
|
||||
@ -149,7 +149,7 @@ impl StyledSelectBuilder {
|
||||
where
|
||||
S: Into<String>,
|
||||
{
|
||||
self.name = Some(Some(name.into()));
|
||||
self.name = Some(name.into());
|
||||
self
|
||||
}
|
||||
|
||||
@ -224,19 +224,20 @@ pub fn render(values: StyledSelect) -> Node<Msg> {
|
||||
|
||||
let dropdown_style = dropdown_width
|
||||
.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)];
|
||||
if !valid {
|
||||
select_class.push("invalid".to_string());
|
||||
}
|
||||
|
||||
let chevron_down = match (selected.is_empty() || !is_multi) && variant != Variant::Empty {
|
||||
true => StyledIcon::build(Icon::ChevronDown)
|
||||
let chevron_down = if (selected.is_empty() || !is_multi) && variant != Variant::Empty {
|
||||
StyledIcon::build(Icon::ChevronDown)
|
||||
.add_class("chevronIcon")
|
||||
.build()
|
||||
.into_node(),
|
||||
_ => empty![],
|
||||
.into_node()
|
||||
} else {
|
||||
empty![]
|
||||
};
|
||||
|
||||
let children: Vec<Node<Msg>> = options
|
||||
@ -259,8 +260,8 @@ pub fn render(values: StyledSelect) -> Node<Msg> {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let text_input = match opened {
|
||||
true => seed::input![
|
||||
let text_input = if opened {
|
||||
seed::input![
|
||||
attrs![
|
||||
At::Name => name.unwrap_or_default(),
|
||||
At::Class => "dropDownInput",
|
||||
@ -268,9 +269,10 @@ pub fn render(values: StyledSelect) -> Node<Msg> {
|
||||
At::Placeholder => "Search"
|
||||
At::AutoFocus => "true",
|
||||
],
|
||||
on_text.clone(),
|
||||
],
|
||||
_ => empty![],
|
||||
on_text,
|
||||
]
|
||||
} else {
|
||||
empty![]
|
||||
};
|
||||
|
||||
let clear_icon = match (opened, allow_clear) {
|
||||
|
@ -217,7 +217,7 @@ impl ToStyledSelectChild for jirs_data::IssueStatus {
|
||||
|
||||
StyledSelectChild::build()
|
||||
.value(self.clone().into())
|
||||
.add_class(text.clone())
|
||||
.add_class(text)
|
||||
.text(text)
|
||||
}
|
||||
}
|
||||
@ -238,3 +238,14 @@ impl ToStyledSelectChild for jirs_data::IssueType {
|
||||
.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>,
|
||||
update_event: Ev,
|
||||
placeholder: Option<String>,
|
||||
disable_auto_resize: bool,
|
||||
}
|
||||
|
||||
impl ToNode for StyledTextarea {
|
||||
@ -35,6 +36,7 @@ pub struct StyledTextareaBuilder {
|
||||
class_list: Vec<String>,
|
||||
update_event: Option<Ev>,
|
||||
placeholder: Option<String>,
|
||||
disable_auto_resize: bool,
|
||||
}
|
||||
|
||||
impl StyledTextareaBuilder {
|
||||
@ -81,6 +83,11 @@ impl StyledTextareaBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn disable_auto_resize(mut self) -> Self {
|
||||
self.disable_auto_resize = true;
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn build(self, id: FieldId) -> StyledTextarea {
|
||||
StyledTextarea {
|
||||
@ -91,6 +98,7 @@ impl StyledTextareaBuilder {
|
||||
max_height: self.max_height.unwrap_or_default(),
|
||||
update_event: self.update_event.unwrap_or_else(|| Ev::KeyUp),
|
||||
placeholder: self.placeholder,
|
||||
disable_auto_resize: self.disable_auto_resize,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -117,10 +125,11 @@ pub fn render(values: StyledTextarea) -> Node<Msg> {
|
||||
mut class_list,
|
||||
update_event,
|
||||
placeholder,
|
||||
disable_auto_resize,
|
||||
} = values;
|
||||
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 {
|
||||
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));
|
||||
}
|
||||
|
||||
if disable_auto_resize {
|
||||
style_list.push("resize: none".to_string());
|
||||
}
|
||||
|
||||
let mut handlers = vec![];
|
||||
|
||||
let handler_disable_auto_resize = disable_auto_resize;
|
||||
let resize_handler = ev(Ev::KeyUp, move |event| {
|
||||
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 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
|
||||
.style()
|
||||
@ -148,11 +162,9 @@ pub fn render(values: StyledTextarea) -> Node<Msg> {
|
||||
Msg::NoOp
|
||||
});
|
||||
handlers.push(resize_handler);
|
||||
let text_input_handler = input_ev(update_event.clone(), move |value| {
|
||||
Msg::InputChanged(id, value)
|
||||
});
|
||||
let text_input_handler = input_ev(update_event, move |value| Msg::InputChanged(id, value));
|
||||
handlers.push(text_input_handler);
|
||||
handlers.push(keyboard_ev(Ev::KeyUp, |ev| {
|
||||
handlers.push(keyboard_ev(Ev::Input, |ev| {
|
||||
ev.stop_propagation();
|
||||
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;
|
||||
if value.chars().last() == Some('\n') {}
|
||||
// if value.chars().last() == Some('\n') {}
|
||||
|
||||
let calc_height = (len * LETTER_HEIGHT) + ADDITIONAL_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);
|
||||
return;
|
||||
}
|
||||
issue.status = status.clone();
|
||||
issue.status = status;
|
||||
issue.list_position = pos + 1;
|
||||
model.issues.push(issue);
|
||||
|
||||
|
@ -26,7 +26,6 @@ pub type TokenId = i32;
|
||||
#[cfg_attr(feature = "backend", derive(FromSqlRow, AsExpression))]
|
||||
#[cfg_attr(feature = "backend", sql_type = "IssueTypeType")]
|
||||
#[derive(Clone, Deserialize, Serialize, Debug, PartialOrd, PartialEq, Hash)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum IssueType {
|
||||
Task,
|
||||
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)]
|
||||
pub struct ErrorResponse {
|
||||
pub errors: Vec<String>,
|
||||
@ -259,7 +329,7 @@ pub struct Project {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub description: String,
|
||||
pub category: String,
|
||||
pub category: ProjectCategory,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
@ -382,12 +452,13 @@ pub struct CreateIssuePayload {
|
||||
pub reporter_id: UserId,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct UpdateProjectPayload {
|
||||
pub id: ProjectId,
|
||||
pub name: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub category: Option<ProjectCategory>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
@ -407,6 +478,7 @@ pub enum WsMsg {
|
||||
ProjectIssuesLoaded(Vec<Issue>),
|
||||
ProjectUsersRequest,
|
||||
ProjectUsersLoaded(Vec<User>),
|
||||
ProjectUpdateRequest(UpdateProjectPayload),
|
||||
|
||||
// issue
|
||||
IssueUpdateRequest(IssueId, UpdateIssuePayload),
|
||||
|
@ -2,7 +2,7 @@ use std::io::Write;
|
||||
|
||||
use diesel::{deserialize::*, pg::*, serialize::*, *};
|
||||
|
||||
use crate::{IssuePriority, IssueStatus, IssueType};
|
||||
use crate::{IssuePriority, IssueStatus, IssueType, ProjectCategory};
|
||||
|
||||
#[derive(SqlType)]
|
||||
#[postgres(type_name = "IssuePriorityType")]
|
||||
@ -121,3 +121,45 @@ impl ToSql<IssueStatusType, Pg> for IssueStatus {
|
||||
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 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 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: Option<i32>,
|
||||
pub user_ids: Option<Vec<i32>>,
|
||||
pub reporter_id: Option<i32>,
|
||||
|
@ -23,8 +23,8 @@ impl Actor for DbExecutor {
|
||||
type Context = SyncContext<Self>;
|
||||
}
|
||||
|
||||
impl DbExecutor {
|
||||
pub fn new() -> Self {
|
||||
impl Default for DbExecutor {
|
||||
fn default() -> Self {
|
||||
Self(build_pool())
|
||||
}
|
||||
}
|
||||
@ -37,7 +37,7 @@ pub fn build_pool() -> DbPool {
|
||||
let manager = ConnectionManager::<PgConnection>::new(database_url.clone());
|
||||
#[cfg(debug_assertions)]
|
||||
let manager: ConnectionManager<VerboseConnection> =
|
||||
ConnectionManager::<dev::VerboseConnection>::new(database_url.clone());
|
||||
ConnectionManager::<dev::VerboseConnection>::new(database_url.as_str());
|
||||
r2d2::Pool::builder()
|
||||
.build(manager)
|
||||
.unwrap_or_else(|e| panic!("Failed to create pool. {}", e))
|
||||
|
@ -2,6 +2,8 @@ use actix::{Handler, Message};
|
||||
use diesel::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use jirs_data::ProjectCategory;
|
||||
|
||||
use crate::db::DbExecutor;
|
||||
use crate::errors::ServiceErrors;
|
||||
use crate::models::Project;
|
||||
@ -25,8 +27,14 @@ impl Handler<LoadCurrentProject> for DbExecutor {
|
||||
.get()
|
||||
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
|
||||
|
||||
projects
|
||||
.filter(id.eq(msg.project_id))
|
||||
let query = projects.filter(id.eq(msg.project_id));
|
||||
|
||||
debug!(
|
||||
"{}",
|
||||
diesel::debug_query::<diesel::pg::Pg, _>(&query).to_string()
|
||||
);
|
||||
|
||||
query
|
||||
.first::<Project>(conn)
|
||||
.map_err(|_| ServiceErrors::RecordNotFound("Project".to_string()))
|
||||
}
|
||||
@ -38,7 +46,7 @@ pub struct UpdateProject {
|
||||
pub name: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub category: Option<ProjectCategory>,
|
||||
}
|
||||
|
||||
impl Message for UpdateProject {
|
||||
|
@ -32,7 +32,7 @@ impl Into<HttpResponse> for ServiceErrors {
|
||||
}
|
||||
ServiceErrors::DatabaseQueryFailed(error) => {
|
||||
HttpResponse::BadRequest().json(ErrorResponse {
|
||||
errors: vec![error.to_owned()],
|
||||
errors: vec![error],
|
||||
})
|
||||
}
|
||||
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 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 || {
|
||||
App::new()
|
||||
|
@ -1,10 +1,12 @@
|
||||
use crate::db::SyncQuery;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use actix_service::{Service, Transform};
|
||||
use actix_web::http::header::{self};
|
||||
use actix_web::http::HeaderMap;
|
||||
use actix_web::{dev::ServiceRequest, dev::ServiceResponse, Error};
|
||||
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>;
|
||||
|
||||
@ -82,7 +84,7 @@ pub fn token_from_headers(
|
||||
headers: &HeaderMap,
|
||||
) -> std::result::Result<uuid::Uuid, crate::errors::ServiceErrors> {
|
||||
headers
|
||||
.get(&header::AUTHORIZATION)
|
||||
.get(header::AUTHORIZATION)
|
||||
.ok_or_else(|| crate::errors::ServiceErrors::Unauthorized)
|
||||
.map(|h| h.to_str().unwrap_or_default())
|
||||
.and_then(|s| parse_bearer(s))
|
||||
|
@ -2,7 +2,7 @@ use chrono::NaiveDateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use jirs_data::{IssuePriority, IssueStatus, IssueType};
|
||||
use jirs_data::{IssuePriority, IssueStatus, IssueType, ProjectCategory};
|
||||
|
||||
use crate::schema::*;
|
||||
|
||||
@ -127,7 +127,7 @@ pub struct Project {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub description: String,
|
||||
pub category: String,
|
||||
pub category: ProjectCategory,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
@ -147,17 +147,15 @@ impl Into<jirs_data::Project> for Project {
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Insertable)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[table_name = "projects"]
|
||||
pub struct UpdateProjectForm {
|
||||
pub name: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub category: Option<ProjectCategory>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Queryable)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct User {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
@ -190,8 +188,8 @@ impl Into<jirs_data::User> for &User {
|
||||
email: self.email.clone(),
|
||||
avatar_url: self.avatar_url.clone(),
|
||||
project_id: self.project_id,
|
||||
created_at: self.created_at.clone(),
|
||||
updated_at: self.updated_at.clone(),
|
||||
created_at: self.created_at,
|
||||
updated_at: self.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -223,10 +223,10 @@ table! {
|
||||
description -> Text,
|
||||
/// The `category` column of the `projects` table.
|
||||
///
|
||||
/// Its SQL type is `Text`.
|
||||
/// Its SQL type is `ProjectCategoryType`.
|
||||
///
|
||||
/// (Automatically generated by Diesel.)
|
||||
category -> Text,
|
||||
category -> ProjectCategoryType,
|
||||
/// The `created_at` column of the `projects` table.
|
||||
///
|
||||
/// Its SQL type is `Timestamp`.
|
||||
|
@ -25,11 +25,11 @@ pub async fn update_issue(
|
||||
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),
|
||||
description: payload.description,
|
||||
description_text: payload.description_text,
|
||||
estimate: payload.estimate,
|
||||
time_spent: payload.time_spent,
|
||||
time_remaining: payload.time_remaining,
|
||||
project_id: Some(payload.project_id),
|
||||
user_ids: Some(payload.user_ids),
|
||||
reporter_id: Some(payload.reporter_id),
|
||||
@ -40,12 +40,7 @@ pub async fn update_issue(
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
let assignees = match db
|
||||
.send(LoadAssignees {
|
||||
issue_id: issue.id.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
let assignees = match db.send(LoadAssignees { issue_id: issue.id }).await {
|
||||
Ok(Ok(v)) => v,
|
||||
_ => vec![],
|
||||
};
|
||||
@ -53,7 +48,7 @@ pub async fn update_issue(
|
||||
issue.user_ids.push(assignee.user_id);
|
||||
}
|
||||
|
||||
Ok(Some(WsMsg::IssueUpdated(issue.into())))
|
||||
Ok(Some(WsMsg::IssueUpdated(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 queue = vec![];
|
||||
for issue in issues.into_iter() {
|
||||
let f = db.send(LoadAssignees {
|
||||
issue_id: issue.id.clone(),
|
||||
});
|
||||
let f = db.send(LoadAssignees { issue_id: issue.id });
|
||||
queue.push(f);
|
||||
issue_map.insert(issue.id.clone(), issue);
|
||||
issue_map.insert(issue.id, issue);
|
||||
}
|
||||
for f in queue {
|
||||
match f.await {
|
||||
Ok(Ok(assignees)) => {
|
||||
for assignee in assignees {
|
||||
if let Some(issue) = issue_map.get_mut(&assignee.issue_id) {
|
||||
issue.user_ids.push(assignee.user_id);
|
||||
}
|
||||
if let Ok(Ok(assignees)) = f.await {
|
||||
for assignee in assignees {
|
||||
if let Some(issue) = issue_map.get_mut(&assignee.issue_id) {
|
||||
issue.user_ids.push(assignee.user_id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
let mut issues = vec![];
|
||||
|
@ -75,6 +75,12 @@ impl WebSocketActor {
|
||||
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
|
||||
WsMsg::AuthorizeRequest(uuid) => block_on(self.authorize(uuid))?,
|
||||
|
||||
@ -114,6 +120,9 @@ impl WebSocketActor {
|
||||
None
|
||||
}
|
||||
};
|
||||
if msg.is_some() && msg != Some(WsMsg::Pong) {
|
||||
info!("sending message {:?}", msg);
|
||||
}
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
|
@ -1,9 +1,9 @@
|
||||
use actix::Addr;
|
||||
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::ws::{current_user, WsResult};
|
||||
|
||||
@ -13,11 +13,38 @@ pub async fn current_project(
|
||||
) -> WsResult {
|
||||
let project_id = current_user(user).map(|u| u.project_id)?;
|
||||
|
||||
let m = match db.send(LoadProjectUsers { project_id }).await {
|
||||
Ok(Ok(v)) => Some(WsMsg::ProjectUsersLoaded(
|
||||
v.into_iter().map(|i| i.into()).collect(),
|
||||
)),
|
||||
_ => None,
|
||||
let m = match db.send(LoadCurrentProject { project_id }).await {
|
||||
Ok(Ok(project)) => Some(WsMsg::ProjectLoaded(project.into())),
|
||||
Ok(Err(e)) => {
|
||||
error!("{:?}", e);
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
error!("{:?}", e);
|
||||
None
|
||||
}
|
||||
};
|
||||
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 { sizes } from 'shared/utils/styles';
|
||||
import { sizes } from '../shared/utils/styles';
|
||||
|
||||
const paddingLeft = sizes.appNavBarLeftWidth + sizes.secondarySideBarWidth + 40;
|
||||
|
||||
|
@ -21,79 +21,79 @@ const Project = () => {
|
||||
const issueSearchModalHelpers = createQueryParamModalHelpers('issue-search');
|
||||
const issueCreateModalHelpers = createQueryParamModalHelpers('issue-create');
|
||||
|
||||
const [{ data, error, setLocalData }, fetchProject] = useApi.get('/project');
|
||||
const [{ data, error, setLocalData }, fetchProject] = useApi.get('/project');
|
||||
|
||||
if (!data) return <PageLoader />;
|
||||
if (error) return <PageError />;
|
||||
if (!data) return <PageLoader/>;
|
||||
if (error) return <PageError/>;
|
||||
|
||||
const { project } = data;
|
||||
const { project } = data;
|
||||
|
||||
const updateLocalProjectIssues = (issueId, updatedFields) => {
|
||||
setLocalData(currentData => ({
|
||||
project: {
|
||||
...currentData.project,
|
||||
issues: updateArrayItemById(currentData.project.issues, issueId, updatedFields),
|
||||
},
|
||||
}));
|
||||
};
|
||||
const updateLocalProjectIssues = (issueId, updatedFields) => {
|
||||
setLocalData(currentData => ({
|
||||
project: {
|
||||
...currentData.project,
|
||||
issues: updateArrayItemById(currentData.project.issues, issueId, updatedFields),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<ProjectPage id={ 'ProjectPage' }>
|
||||
<NavbarLeft
|
||||
issueSearchModalOpen={ issueSearchModalHelpers.open }
|
||||
issueCreateModalOpen={ issueCreateModalHelpers.open }
|
||||
/>
|
||||
|
||||
<Sidebar project={ project }/>
|
||||
|
||||
{ issueSearchModalHelpers.isOpen() && (
|
||||
<Modal
|
||||
isOpen
|
||||
testid="modal:issue-search"
|
||||
variant="aside"
|
||||
width={600}
|
||||
onClose={issueSearchModalHelpers.close}
|
||||
renderContent={() => <IssueSearch project={project} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{issueCreateModalHelpers.isOpen() && (
|
||||
<Modal
|
||||
isOpen
|
||||
testid="modal:issue-create"
|
||||
width={800}
|
||||
withCloseIcon={false}
|
||||
onClose={issueCreateModalHelpers.close}
|
||||
renderContent={modal => (
|
||||
<IssueCreate
|
||||
project={project}
|
||||
fetchProject={fetchProject}
|
||||
onCreate={() => history.push(`${match.url}/board`)}
|
||||
modalClose={modal.close}
|
||||
return (
|
||||
<ProjectPage id={'ProjectPage'}>
|
||||
<NavbarLeft
|
||||
issueSearchModalOpen={issueSearchModalHelpers.open}
|
||||
issueCreateModalOpen={issueCreateModalHelpers.open}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Route
|
||||
path={`${match.path}/board`}
|
||||
render={() => (
|
||||
<Board
|
||||
project={project}
|
||||
fetchProject={fetchProject}
|
||||
updateLocalProjectIssues={updateLocalProjectIssues}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Sidebar project={project}/>
|
||||
|
||||
<Route
|
||||
path={`${match.path}/settings`}
|
||||
render={() => <ProjectSettings project={project} fetchProject={fetchProject} />}
|
||||
/>
|
||||
{issueSearchModalHelpers.isOpen() && (
|
||||
<Modal
|
||||
isOpen
|
||||
testid="modal:issue-search"
|
||||
variant="aside"
|
||||
width={600}
|
||||
onClose={issueSearchModalHelpers.close}
|
||||
renderContent={() => <IssueSearch project={project}/>}
|
||||
/>
|
||||
)}
|
||||
|
||||
{match.isExact && <Redirect to={`${match.url}/board`} />}
|
||||
</ProjectPage>
|
||||
);
|
||||
{issueCreateModalHelpers.isOpen() && (
|
||||
<Modal
|
||||
isOpen
|
||||
testid="modal:issue-create"
|
||||
width={800}
|
||||
withCloseIcon={false}
|
||||
onClose={issueCreateModalHelpers.close}
|
||||
renderContent={modal => (
|
||||
<IssueCreate
|
||||
project={project}
|
||||
fetchProject={fetchProject}
|
||||
onCreate={() => history.push(`${match.url}/board`)}
|
||||
modalClose={modal.close}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Route
|
||||
path={`${match.path}/board`}
|
||||
render={() => (
|
||||
<Board
|
||||
project={project}
|
||||
fetchProject={fetchProject}
|
||||
updateLocalProjectIssues={updateLocalProjectIssues}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path={`${match.path}/settings`}
|
||||
render={() => <ProjectSettings project={project} fetchProject={fetchProject}/>}
|
||||
/>
|
||||
|
||||
{match.isExact && <Redirect to={`${match.url}/board`}/>}
|
||||
</ProjectPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default Project;
|
||||
|
Loading…
Reference in New Issue
Block a user