Most of time tracking modal

This commit is contained in:
Adrian Woźniak 2020-04-14 16:20:05 +02:00
parent 02d2c958b7
commit 91a6445bf6
44 changed files with 848 additions and 371 deletions

View File

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

View File

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

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

View File

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

View File

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

View File

@ -21,4 +21,5 @@
@import "./css/app.css";
@import "./css/issue.css";
@import "./css/project.css";
@import "./css/projectSettings.css";
@import "./css/timeTracking.css";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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![])
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
ALTER TABLE projects ALTER COLUMN category SET DATA TYPE VARCHAR;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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`.

View File

@ -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![];

View File

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

View File

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

View File

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

View File

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