Add issue status management

This commit is contained in:
Adrian Woźniak 2020-05-07 17:08:40 +02:00
parent 8178483019
commit b47009635c
18 changed files with 713 additions and 120 deletions

95
jirs-client/dev/logo.svg Normal file
View File

@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid meet" viewBox="0 0 640 640" width="640" height="640">
<defs>
<path d="M500 300C500 410.38 410.38 500 300 500C189.62 500 100 410.38 100 300C100 189.61 189.62 100 300 100C410.38 100 500 189.61 500 300Z"
id="b6KNxjEO2"></path>
<mask id="maskb3oIvRAxi1" x="78" y="78" width="444" height="444" maskUnits="userSpaceOnUse">
<rect x="78" y="78" width="444" height="444" fill="white"></rect>
<use xlink:href="#b6KNxjEO2" opacity="0.46" fill="black"></use>
</mask>
<path d="M520 338.18C520 448.56 430.38 538.18 320 538.18C209.62 538.18 120 448.56 120 338.18C120 227.8 209.62 138.18 320 138.18C430.38 138.18 520 227.8 520 338.18Z"
id="bDKGuSkBj"></path>
<mask id="maskb4LRewzUS" x="98" y="116.18" width="444" height="444" maskUnits="userSpaceOnUse">
<rect x="98" y="116.18" width="444" height="444" fill="white"></rect>
<use xlink:href="#bDKGuSkBj" opacity="0.46" fill="black"></use>
</mask>
<path d="M543.03 374.84C543.03 485.23 453.41 574.84 343.03 574.84C232.65 574.84 143.03 485.23 143.03 374.84C143.03 264.46 232.65 174.84 343.03 174.84C453.41 174.84 543.03 264.46 543.03 374.84Z"
id="a1k8tJrWR3"></path>
<mask id="maskbctn2Bw0" x="121.03" y="152.84" width="444" height="444" maskUnits="userSpaceOnUse">
<rect x="121.03" y="152.84" width="444" height="444" fill="white"></rect>
<use xlink:href="#a1k8tJrWR3" opacity="0.46" fill="black"></use>
</mask>
</defs>
<g>
<g>
<g>
<g>
<filter id="shadow16022051" x="78" y="78" width="458" height="454" filterUnits="userSpaceOnUse"
primitiveUnits="userSpaceOnUse">
<feFlood></feFlood>
<feComposite in2="SourceAlpha" operator="in"></feComposite>
<feGaussianBlur stdDeviation="1"></feGaussianBlur>
<feOffset dx="14" dy="10" result="afterOffset"></feOffset>
<feFlood flood-color="#0d0e44" flood-opacity="0.5"></feFlood>
<feComposite in2="afterOffset" operator="in"></feComposite>
<feMorphology operator="dilate" radius="1"></feMorphology>
<feComposite in2="SourceAlpha" operator="out"></feComposite>
</filter>
<path d="M500 300C500 410.38 410.38 500 300 500C189.62 500 100 410.38 100 300C100 189.61 189.62 100 300 100C410.38 100 500 189.61 500 300Z"
id="h3zi0EZA0A" fill="white" fill-opacity="1" filter="url(#shadow16022051)"></path>
</g>
<use xlink:href="#b6KNxjEO2" opacity="0.46" fill="#fefefe" fill-opacity="1"></use>
<g mask="url(#maskb3oIvRAxi1)">
<use xlink:href="#b6KNxjEO2" opacity="0.46" fill-opacity="0" stroke="#06697d" stroke-width="22"
stroke-opacity="1"></use>
</g>
</g>
<g>
<g>
<filter id="shadow11664684" x="98" y="116.18" width="458" height="454" filterUnits="userSpaceOnUse"
primitiveUnits="userSpaceOnUse">
<feFlood></feFlood>
<feComposite in2="SourceAlpha" operator="in"></feComposite>
<feGaussianBlur stdDeviation="1"></feGaussianBlur>
<feOffset dx="14" dy="10" result="afterOffset"></feOffset>
<feFlood flood-color="#0c0e43" flood-opacity="0.5"></feFlood>
<feComposite in2="afterOffset" operator="in"></feComposite>
<feMorphology operator="dilate" radius="1"></feMorphology>
<feComposite in2="SourceAlpha" operator="out"></feComposite>
</filter>
<path d="M520 338.18C520 448.56 430.38 538.18 320 538.18C209.62 538.18 120 448.56 120 338.18C120 227.8 209.62 138.18 320 138.18C430.38 138.18 520 227.8 520 338.18Z"
id="mzwSl44s1" fill="white" fill-opacity="1" filter="url(#shadow11664684)"></path>
</g>
<use xlink:href="#bDKGuSkBj" opacity="0.46" fill="#fefefe" fill-opacity="1"></use>
<g mask="url(#maskb4LRewzUS)">
<use xlink:href="#bDKGuSkBj" opacity="0.46" fill-opacity="0" stroke="#06697d" stroke-width="22"
stroke-opacity="1"></use>
</g>
</g>
<g>
<g>
<filter id="shadow13224340" x="121.03" y="152.84" width="458" height="454"
filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse">
<feFlood></feFlood>
<feComposite in2="SourceAlpha" operator="in"></feComposite>
<feGaussianBlur stdDeviation="1"></feGaussianBlur>
<feOffset dx="14" dy="10" result="afterOffset"></feOffset>
<feFlood flood-color="#0c0e43" flood-opacity="0.5"></feFlood>
<feComposite in2="afterOffset" operator="in"></feComposite>
<feMorphology operator="dilate" radius="1"></feMorphology>
<feComposite in2="SourceAlpha" operator="out"></feComposite>
</filter>
<path d="M543.03 374.84C543.03 485.23 453.41 574.84 343.03 574.84C232.65 574.84 143.03 485.23 143.03 374.84C143.03 264.46 232.65 174.84 343.03 174.84C453.41 174.84 543.03 264.46 543.03 374.84Z"
id="c16Px1v9IX" fill="white" fill-opacity="1" filter="url(#shadow13224340)"></path>
</g>
<use xlink:href="#a1k8tJrWR3" opacity="0.46" fill="#fefefe" fill-opacity="1"></use>
<g mask="url(#maskbctn2Bw0)">
<use xlink:href="#a1k8tJrWR3" opacity="0.46" fill-opacity="0" stroke="#06697d" stroke-width="22"
stroke-opacity="1"></use>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@ -4,10 +4,48 @@
}
#projectSettings > .formContainer .styledForm {
max-width: 720px;
max-width: 1024px;
width: 100%;
}
#projectSettings > .formContainer .styledForm > .formElement > .actionButton {
margin-top: 30px;
}
#projectSettings > .formContainer .styledForm > .formElement > .styledField.columnsField > .styledLabel {
font-size: 14px;
}
#projectSettings > .formContainer .styledForm > .formElement > .styledField > .columnsSection > .columns {
display: flex;
flex-direction: row;
justify-content: space-around;
}
#projectSettings > .formContainer .styledForm > .formElement > .styledField > .columnsSection > .columns > .columnPreview {
width: auto;
min-height: 60px;
background: var(--backgroundLightest);
margin: 0 5px;
}
#projectSettings > .formContainer .styledForm > .formElement > .styledField > .columnsSection > .columns > .columnPreview > .columnName {
display: block;
margin: 0 5px;
padding: 13px 10px 17px;
text-transform: uppercase;
color: var(--textMedium);
font-size: 12.5px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
cursor: pointer;
user-select: none;
}
#projectSettings > .formContainer .styledForm > .formElement > .styledField > .columnsSection > .columns > .columnPreview > .columnName.addColumn,
#projectSettings > .formContainer .styledForm > .formElement > .styledField > .columnsSection > .columns > .columnPreview > .columnName.addColumn > i {
color: var(--textMedium);
text-align: center;
cursor: pointer;
}

View File

@ -112,6 +112,7 @@ impl std::fmt::Display for FieldId {
ProjectFieldId::Description => f.write_str("projectSettings-description"),
ProjectFieldId::Category => f.write_str("projectSettings-category"),
ProjectFieldId::TimeTracking => f.write_str("projectSettings-timeTracking"),
ProjectFieldId::IssueStatusName => f.write_str("projectSettings-issueStatusName"),
},
FieldId::SignIn(sub) => match sub {
SignInFieldId::Email => f.write_str("login-email"),
@ -149,6 +150,17 @@ pub enum FieldChange {
EditComment(FieldId, i32),
}
#[derive(Clone, Debug, PartialEq)]
pub enum BoardPageChange {
// dragging
IssueDragStarted(IssueId),
IssueDragStopped(IssueId),
DragLeave(IssueId),
ExchangePosition(IssueId),
IssueDragOverStatus(IssueStatusId),
IssueDropZone(IssueStatusId),
}
#[derive(Clone, Debug, PartialEq)]
pub enum UsersPageChange {
ResetForm,
@ -158,6 +170,15 @@ pub enum UsersPageChange {
pub enum ProjectPageChange {
ResetForm,
SubmitForm,
// dragging
ColumnDragStarted(IssueStatusId),
ColumnDragStopped(IssueStatusId),
ColumnDragLeave(IssueStatusId),
ColumnExchangePosition(IssueStatusId),
ColumnDragOverStatus(IssueStatusId),
ColumnDropZone(IssueStatusId),
// edit issue status name
EditIssueStatusName(Option<IssueStatusId>),
}
#[derive(Clone, Debug, PartialEq)]
@ -170,6 +191,7 @@ pub enum PageChanged {
Users(UsersPageChange),
ProjectSettings(ProjectPageChange),
Profile(ProfilePageChange),
Board(BoardPageChange),
}
#[derive(Clone, Debug)]
@ -209,15 +231,6 @@ pub enum Msg {
ProjectToggleRecentlyUpdated,
ProjectClearFilters,
// dragging
IssueDragStarted(IssueId),
IssueDragStopped(IssueId),
DragLeave(IssueId),
ExchangePosition(IssueId),
IssueDragOverStatus(IssueStatusId),
IssueDropZone(IssueStatusId),
UnlockDragOver,
// inputs
StrInputChanged(FieldId, String),
U32InputChanged(FieldId, u32),

View File

@ -6,6 +6,7 @@ use uuid::Uuid;
use jirs_data::*;
use crate::modal::time_tracking::value_for_time_tracking;
use crate::shared::drag::DragState;
use crate::shared::styled_checkbox::StyledCheckboxState;
use crate::shared::styled_editor::Mode;
use crate::shared::styled_image_input::StyledImageInputState;
@ -235,9 +236,7 @@ pub struct ProjectPage {
pub active_avatar_filters: Vec<UserId>,
pub only_my_filter: bool,
pub recently_updated_filter: bool,
pub dragged_issue_id: Option<IssueId>,
pub last_drag_exchange_id: Option<IssueId>,
pub dirty_issues: Vec<IssueId>,
pub issue_drag: DragState,
}
#[derive(Debug, Default)]
@ -252,6 +251,9 @@ pub struct ProjectSettingsPage {
pub project_category_state: StyledSelectState,
pub description_mode: crate::shared::styled_editor::Mode,
pub time_tracking: StyledCheckboxState,
pub column_drag: DragState,
pub edit_column_id: Option<IssueStatusId>,
pub name: StyledInputState,
}
impl ProjectSettingsPage {
@ -284,6 +286,12 @@ impl ProjectSettingsPage {
FieldId::ProjectSettings(ProjectFieldId::TimeTracking),
(*time_tracking).into(),
),
column_drag: Default::default(),
edit_column_id: None,
name: StyledInputState::new(
FieldId::ProjectSettings(ProjectFieldId::IssueStatusName),
"",
),
}
}
}

View File

@ -11,7 +11,7 @@ use crate::shared::styled_icon::{Icon, StyledIcon};
use crate::shared::styled_input::StyledInput;
use crate::shared::styled_select::StyledSelectChange;
use crate::shared::{drag_ev, inner_layout, ToNode};
use crate::{EditIssueModalSection, FieldId, Msg};
use crate::{BoardPageChange, EditIssueModalSection, FieldId, Msg, PageChanged};
pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) {
if model.user.is_none() {
@ -104,16 +104,24 @@ pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Order
project_page.recently_updated_filter = false;
project_page.only_my_filter = false;
}
Msg::IssueDragStarted(issue_id) => crate::ws::issue::drag_started(issue_id, model),
Msg::IssueDragStopped(_) => {
Msg::PageChanged(PageChanged::Board(BoardPageChange::IssueDragStarted(issue_id))) => {
crate::ws::issue::drag_started(issue_id, model)
}
Msg::PageChanged(PageChanged::Board(BoardPageChange::IssueDragStopped(_))) => {
crate::ws::issue::sync(model);
}
Msg::ExchangePosition(issue_bellow_id) => {
crate::ws::issue::exchange_position(issue_bellow_id, model)
Msg::PageChanged(PageChanged::Board(BoardPageChange::ExchangePosition(
issue_bellow_id,
))) => crate::ws::issue::exchange_position(issue_bellow_id, model),
Msg::PageChanged(PageChanged::Board(BoardPageChange::IssueDragOverStatus(status))) => {
crate::ws::issue::change_status(status, model)
}
Msg::PageChanged(PageChanged::Board(BoardPageChange::IssueDropZone(_status))) => {
crate::ws::issue::sync(model)
}
Msg::PageChanged(PageChanged::Board(BoardPageChange::DragLeave(_id))) => {
project_page.issue_drag.clear_last();
}
Msg::IssueDragOverStatus(status) => crate::ws::issue::change_status(status, model),
Msg::IssueDropZone(_status) => crate::ws::issue::sync(model),
Msg::DragLeave(_id) => project_page.last_drag_exchange_id = None,
Msg::DeleteIssue(issue_id) => {
send_ws_msg(jirs_data::WsMsg::IssueDeleteRequest(issue_id));
}
@ -298,13 +306,17 @@ fn project_issue_list(model: &Model, status: &jirs_data::IssueStatus) -> Node<Ms
let send_status = status.id;
let drop_handler = drag_ev(Ev::Drop, move |ev| {
ev.prevent_default();
Msg::IssueDropZone(send_status)
Msg::PageChanged(PageChanged::Board(BoardPageChange::IssueDropZone(
send_status,
)))
});
let send_status = status.id;
let drag_over_handler = drag_ev(Ev::DragOver, move |ev| {
ev.prevent_default();
Msg::IssueDragOverStatus(send_status)
Msg::PageChanged(PageChanged::Board(BoardPageChange::IssueDragOverStatus(
send_status,
)))
});
div![
@ -382,15 +394,27 @@ fn project_issue(model: &Model, issue: &Issue) -> Node<Msg> {
};
let issue_id = issue.id;
let drag_started = drag_ev(Ev::DragStart, move |_| Msg::IssueDragStarted(issue_id));
let drag_stopped = drag_ev(Ev::DragEnd, move |_| Msg::IssueDragStopped(issue_id));
let drag_started = drag_ev(Ev::DragStart, move |_| {
Msg::PageChanged(PageChanged::Board(BoardPageChange::IssueDragStarted(
issue_id,
)))
});
let drag_stopped = drag_ev(Ev::DragEnd, move |_| {
Msg::PageChanged(PageChanged::Board(BoardPageChange::IssueDragStopped(
issue_id,
)))
});
let drag_over_handler = drag_ev(Ev::DragOver, move |ev| {
ev.prevent_default();
ev.stop_propagation();
Msg::ExchangePosition(issue_id)
Msg::PageChanged(PageChanged::Board(BoardPageChange::ExchangePosition(
issue_id,
)))
});
let issue_id = issue.id;
let drag_out = drag_ev(Ev::DragLeave, move |_| Msg::DragLeave(issue_id));
let drag_out = drag_ev(Ev::DragLeave, move |_| {
Msg::PageChanged(PageChanged::Board(BoardPageChange::DragLeave(issue_id)))
});
let class_list = vec!["issue"];

View File

@ -1,6 +1,8 @@
use seed::{prelude::*, *};
use jirs_data::{ProjectCategory, TimeTracking, ToVec, UpdateProjectPayload, WsMsg};
use jirs_data::{
IssueStatus, IssueStatusId, ProjectCategory, TimeTracking, ToVec, UpdateProjectPayload, WsMsg,
};
use crate::api::send_ws_msg;
use crate::model::{Model, Page, PageContent, ProjectSettingsPage};
@ -9,9 +11,11 @@ use crate::shared::styled_checkbox::StyledCheckbox;
use crate::shared::styled_editor::StyledEditor;
use crate::shared::styled_field::StyledField;
use crate::shared::styled_form::StyledForm;
use crate::shared::styled_icon::{Icon, StyledIcon};
use crate::shared::styled_input::StyledInput;
use crate::shared::styled_select::{StyledSelect, StyledSelectChange};
use crate::shared::styled_textarea::StyledTextarea;
use crate::shared::{inner_layout, ToChild, ToNode};
use crate::shared::{drag_ev, inner_layout, ToChild, ToNode};
use crate::FieldChange::TabChanged;
use crate::{model, FieldId, Msg, PageChanged, ProjectFieldId, ProjectPageChange};
@ -26,11 +30,13 @@ pub fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>)
match msg {
Msg::WsMsg(WsMsg::AuthorizeLoaded(..)) => {
send_ws_msg(WsMsg::ProjectRequest);
send_ws_msg(WsMsg::IssueStatusesRequest);
}
Msg::ChangePage(Page::ProjectSettings) => {
build_page_content(model);
if model.user.is_some() {
send_ws_msg(WsMsg::ProjectRequest);
send_ws_msg(WsMsg::IssueStatusesRequest);
}
}
Msg::WsMsg(WsMsg::ProjectLoaded(..)) => {
@ -83,18 +89,47 @@ pub fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>)
time_tracking: Some(page.time_tracking.value.into()),
}));
}
Msg::PageChanged(PageChanged::ProjectSettings(ProjectPageChange::ColumnDragStarted(
issue_status_id,
))) => {
page.column_drag.drag(issue_status_id);
}
Msg::PageChanged(PageChanged::ProjectSettings(ProjectPageChange::ColumnDragStopped(
_issue_status_id,
))) => {
sync(model);
}
Msg::PageChanged(PageChanged::ProjectSettings(ProjectPageChange::ColumnDragLeave(
_issue_status_id,
))) => page.column_drag.clear_last(),
Msg::PageChanged(PageChanged::ProjectSettings(
ProjectPageChange::ColumnExchangePosition(issue_bellow_id),
)) => exchange_position(issue_bellow_id, model),
Msg::PageChanged(PageChanged::ProjectSettings(ProjectPageChange::ColumnDropZone(
_issue_status_id,
))) => {
sync(model);
}
Msg::PageChanged(PageChanged::ProjectSettings(ProjectPageChange::EditIssueStatusName(
id,
))) => {
page.name.value = model
.issue_statuses
.iter()
.find_map(|is| {
if Some(is.id) == id {
Some(is.name.clone())
} else {
None
}
})
.unwrap_or_default();
page.edit_column_id = id;
}
_ => (),
}
}
fn build_page_content(model: &mut Model) {
let project = match &model.project {
Some(project) => project,
_ => return,
};
model.page_content = PageContent::ProjectSettings(Box::new(ProjectSettingsPage::new(project)));
}
pub fn view(model: &model::Model) -> Node<Msg> {
let page = match &model.page_content {
PageContent::ProjectSettings(page) => page,
@ -195,6 +230,83 @@ pub fn view(model: &model::Model) -> Node<Msg> {
.build()
.into_node();
let width = 100f64 / (model.issue_statuses.len() + 1) as f64;
let column_style = format!("width: calc({width}% - 10px)", width = width);
let columns: Vec<Node<Msg>> = model
.issue_statuses
.iter()
.map(|is| {
let id = is.id;
let drag_started = drag_ev(Ev::DragStart, move |_| {
Msg::PageChanged(PageChanged::ProjectSettings(ProjectPageChange::ColumnDragStarted(
id,
)))
});
let drag_stopped = drag_ev(Ev::DragEnd, move |_| {
Msg::PageChanged(PageChanged::ProjectSettings(ProjectPageChange::ColumnDragStopped(
id,
)))
});
let drag_over_handler = drag_ev(Ev::DragOver, move |ev| {
ev.prevent_default();
ev.stop_propagation();
Msg::PageChanged(PageChanged::ProjectSettings(ProjectPageChange::ColumnExchangePosition(
id,
)))
});
let drag_out = drag_ev(Ev::DragLeave, move |_| {
Msg::PageChanged(PageChanged::ProjectSettings(ProjectPageChange::ColumnDragLeave(id)))
});
let name = if page.edit_column_id == Some(id) {
let blur = ev("focusout", |_| {
Msg::PageChanged(PageChanged::ProjectSettings(ProjectPageChange::EditIssueStatusName(None)))
});
let input = StyledInput::build(FieldId::ProjectSettings(ProjectFieldId::IssueStatusName))
.state(&page.name)
.primary()
.auto_focus()
.build()
.into_node();
div![class!["columnName"], input, blur]
} else {
let edit = mouse_ev(Ev::Click, move |_| {
Msg::PageChanged(PageChanged::ProjectSettings(ProjectPageChange::EditIssueStatusName(Some(id))))
});
div![class!["columnName"], is.name, edit]
};
div![
class!["columnPreview"],
attrs![At::Style => column_style.as_str(), At::Draggable => "true", At::DropZone => "true"],
name,
drag_started,
drag_stopped,
drag_over_handler,
drag_out,
]
})
.collect();
let add_column = StyledIcon::build(Icon::Plus).build().into_node();
let columns_section = section![
class!["columnsSection"],
div![
class!["columns"],
columns,
div![
class!["columnPreview"],
attrs![At::Style => column_style.as_str()],
div![class!["columnName addColumn"], add_column]
]
]
];
let columns_field = StyledField::build()
.add_class("columnsField")
.input(columns_section)
.label("Columns")
.build()
.into_node();
let save_button = StyledButton::build()
.add_class("actionButton")
.on_click(mouse_ev(Ev::Click, |ev| {
@ -217,6 +329,7 @@ pub fn view(model: &model::Model) -> Node<Msg> {
.add_field(category_field)
.add_field(time_tracking_field)
.add_field(save_button)
.add_field(columns_field)
.build()
.into_node();
@ -224,3 +337,76 @@ pub fn view(model: &model::Model) -> Node<Msg> {
inner_layout(model, "projectSettings", project_section, empty![])
}
fn exchange_position(bellow_id: IssueStatusId, model: &mut Model) {
let page = match &mut model.page_content {
PageContent::ProjectSettings(page) => page,
_ => return,
};
if page.column_drag.dragged_or_last(bellow_id) {
return;
}
let dragged_id = match page.column_drag.dragged_id.as_ref().cloned() {
Some(id) => id,
_ => return error!("Nothing is dragged"),
};
let mut below = None;
let mut dragged = None;
let mut issues_statuses = vec![];
std::mem::swap(&mut issues_statuses, &mut model.issue_statuses);
for issue_status in issues_statuses.into_iter() {
match issue_status.id {
id if id == bellow_id => below = Some(issue_status),
id if id == dragged_id => dragged = Some(issue_status),
_ => model.issue_statuses.push(issue_status),
};
}
let mut below = match below {
Some(below) => below,
_ => return,
};
let mut dragged = match dragged {
Some(issue_status) => issue_status,
_ => {
model.issue_statuses.push(below);
return;
}
};
std::mem::swap(&mut dragged.position, &mut below.position);
page.column_drag.mark_dirty(dragged.id);
page.column_drag.mark_dirty(below.id);
model.issue_statuses.push(below);
model.issue_statuses.push(dragged);
model
.issue_statuses
.sort_by(|a, b| a.position.cmp(&b.position));
page.column_drag.last_id = Some(bellow_id);
}
fn sync(model: &mut Model) {
let page = match &mut model.page_content {
PageContent::ProjectSettings(page) => page,
_ => return error!("bad content type"),
};
for id in page.column_drag.dirty.iter() {
let IssueStatus { name, position, .. } =
match model.issue_statuses.iter().find(|is| is.id == *id) {
Some(is) => is,
_ => continue,
};
send_ws_msg(WsMsg::IssueStatusUpdate(*id, name.clone(), *position))
}
}
fn build_page_content(model: &mut Model) {
let project = match &model.project {
Some(project) => project,
_ => return,
};
model.page_content = PageContent::ProjectSettings(Box::new(ProjectSettingsPage::new(project)));
}

View File

@ -0,0 +1,38 @@
use std::collections::HashSet;
#[derive(Default, Debug)]
pub struct DragState {
pub last_id: Option<i32>,
pub dragged_id: Option<i32>,
pub dirty: HashSet<i32>,
}
impl DragState {
#[inline]
pub fn mark_dirty(&mut self, id: i32) {
self.dirty.insert(id);
}
#[inline]
pub fn drag(&mut self, id: i32) {
self.dragged_id = Some(id);
self.mark_dirty(id);
}
#[inline]
pub fn dragged_or_last(&self, id: i32) -> bool {
self.dragged_id == Some(id) || self.last_id == Some(id)
}
#[inline]
pub fn clear_last(&mut self) {
self.last_id = None;
}
#[inline]
pub fn clear(&mut self) {
self.last_id = None;
self.dragged_id = None;
self.dirty.clear();
}
}

View File

@ -7,6 +7,7 @@ use crate::model::Model;
use crate::Msg;
pub mod aside;
pub mod drag;
pub mod navbar_left;
pub mod styled_avatar;
pub mod styled_button;

View File

@ -8,6 +8,7 @@ pub struct StyledField {
label: String,
tip: Option<String>,
input: Node<Msg>,
class_list: Vec<String>,
}
impl StyledField {
@ -27,6 +28,7 @@ pub struct StyledFieldBuilder {
label: Option<String>,
tip: Option<String>,
input: Option<Node<Msg>>,
class_list: Vec<String>,
}
impl StyledFieldBuilder {
@ -51,24 +53,39 @@ impl StyledFieldBuilder {
self
}
pub fn add_class<S>(mut self, name: S) -> Self
where
S: Into<String>,
{
self.class_list.push(name.into());
self
}
pub fn build(self) -> StyledField {
StyledField {
label: self.label.unwrap_or_default(),
tip: self.tip,
input: self.input.unwrap_or_else(|| empty![]),
class_list: self.class_list,
}
}
}
pub fn render(values: StyledField) -> Node<Msg> {
let StyledField { label, tip, input } = values;
let StyledField {
label,
tip,
input,
mut class_list,
} = values;
let tip_node = match tip {
Some(s) => div![attrs![At::Class => "styledTip"], s],
_ => empty![],
};
class_list.push("styledField".to_string());
div![
attrs![At::Class => "styledField"],
attrs![At::Class => class_list.join(" ")],
seed::label![attrs![At::Class => "styledLabel"], label],
input,
tip_node,

View File

@ -69,6 +69,7 @@ pub struct StyledInput {
input_class_list: Vec<String>,
wrapper_class_list: Vec<String>,
variant: Variant,
auto_focus: bool,
}
impl StyledInput {
@ -82,6 +83,7 @@ impl StyledInput {
input_class_list: vec![],
wrapper_class_list: vec![],
variant: Variant::Normal,
auto_focus: false,
}
}
}
@ -96,6 +98,7 @@ pub struct StyledInputBuilder {
input_class_list: Vec<String>,
wrapper_class_list: Vec<String>,
variant: Variant,
auto_focus: bool,
}
impl StyledInputBuilder {
@ -119,6 +122,7 @@ impl StyledInputBuilder {
pub fn state(self, state: &StyledInputState) -> Self {
self.value(state.value.as_str())
.valid(!state.value.is_empty())
}
pub fn add_input_class<S>(mut self, name: S) -> Self
@ -142,6 +146,11 @@ impl StyledInputBuilder {
self
}
pub fn auto_focus(mut self) -> Self {
self.auto_focus = true;
self
}
pub fn build(self) -> StyledInput {
StyledInput {
id: self.id,
@ -152,6 +161,7 @@ impl StyledInputBuilder {
input_class_list: self.input_class_list,
wrapper_class_list: self.wrapper_class_list,
variant: self.variant,
auto_focus: self.auto_focus,
}
}
}
@ -172,6 +182,7 @@ pub fn render(values: StyledInput) -> Node<Msg> {
mut input_class_list,
mut wrapper_class_list,
variant,
auto_focus,
} = values;
wrapper_class_list.push("styledInput".to_string());
@ -213,6 +224,7 @@ pub fn render(values: StyledInput) -> Node<Msg> {
At::Class => input_class_list.join(" "),
At::Value => value.unwrap_or_default(),
At::Type => input_type.unwrap_or_else(|| "text".to_string()),
At::AutoFocus => auto_focus,
],
change_handler,
key_handler,

View File

@ -3,15 +3,14 @@ use seed::*;
use jirs_data::*;
use crate::api::send_ws_msg;
use crate::model::{Model, PageContent, ProjectPage};
use crate::model::{Model, PageContent};
pub fn drag_started(issue_id: IssueId, model: &mut Model) {
let project_page = match &mut model.page_content {
PageContent::Project(project_page) => project_page,
_ => return,
};
project_page.dragged_issue_id = Some(issue_id);
mark_dirty(issue_id, project_page);
project_page.issue_drag.drag(issue_id);
}
pub fn exchange_position(issue_bellow_id: IssueId, model: &mut Model) {
@ -19,12 +18,10 @@ pub fn exchange_position(issue_bellow_id: IssueId, model: &mut Model) {
PageContent::Project(project_page) => project_page,
_ => return,
};
if project_page.dragged_issue_id == Some(issue_bellow_id)
|| project_page.last_drag_exchange_id == Some(issue_bellow_id)
{
if project_page.issue_drag.dragged_or_last(issue_bellow_id) {
return;
}
let dragged_id = match project_page.dragged_issue_id.as_ref().cloned() {
let dragged_id = match project_page.issue_drag.dragged_id.as_ref().cloned() {
Some(id) => id,
_ => return error!("Nothing is dragged"),
};
@ -59,7 +56,7 @@ pub fn exchange_position(issue_bellow_id: IssueId, model: &mut Model) {
for mut c in issues.into_iter() {
if c.issue_status_id == below.issue_status_id && c.list_position > below.list_position {
c.list_position += 1;
mark_dirty(c.id, project_page);
project_page.issue_drag.mark_dirty(c.id);
}
model.issues.push(c);
}
@ -68,15 +65,15 @@ pub fn exchange_position(issue_bellow_id: IssueId, model: &mut Model) {
}
std::mem::swap(&mut dragged.list_position, &mut below.list_position);
mark_dirty(dragged.id, project_page);
mark_dirty(below.id, project_page);
project_page.issue_drag.mark_dirty(dragged.id);
project_page.issue_drag.mark_dirty(below.id);
model.issues.push(below);
model.issues.push(dragged);
model
.issues
.sort_by(|a, b| a.list_position.cmp(&b.list_position));
project_page.last_drag_exchange_id = Some(issue_bellow_id);
project_page.issue_drag.last_id = Some(issue_bellow_id);
}
pub fn sync(model: &mut Model) {
@ -89,7 +86,7 @@ pub fn sync(model: &mut Model) {
};
for issue in model.issues.iter() {
if !project_page.dirty_issues.contains(&issue.id) {
if !project_page.issue_drag.dirty.contains(&issue.id) {
continue;
}
@ -104,9 +101,7 @@ pub fn sync(model: &mut Model) {
PayloadVariant::I32(issue.list_position),
));
}
project_page.dragged_issue_id = None;
project_page.last_drag_exchange_id = None;
project_page.dirty_issues.clear();
project_page.issue_drag.clear();
}
pub fn change_status(status_id: IssueStatusId, model: &mut Model) {
@ -115,7 +110,7 @@ pub fn change_status(status_id: IssueStatusId, model: &mut Model) {
_ => return,
};
let issue_id = match project_page.dragged_issue_id.as_ref().cloned() {
let issue_id = match project_page.issue_drag.dragged_id.as_ref().cloned() {
Some(issue_id) => issue_id,
_ => return error!("Nothing is dragged"),
};
@ -130,7 +125,7 @@ pub fn change_status(status_id: IssueStatusId, model: &mut Model) {
if issue.issue_status_id == status_id {
if issue.list_position != pos {
issue.list_position = pos;
mark_dirty(issue.id, project_page);
project_page.issue_drag.mark_dirty(issue.id);
}
pos += 1;
}
@ -154,13 +149,6 @@ pub fn change_status(status_id: IssueStatusId, model: &mut Model) {
issue.issue_status_id = status_id;
issue.list_position = pos + 1;
model.issues.push(issue);
mark_dirty(issue_id, project_page);
}
}
#[inline]
fn mark_dirty(id: IssueId, project_page: &mut ProjectPage) {
if !project_page.dirty_issues.contains(&id) {
project_page.dirty_issues.push(id);
project_page.issue_drag.mark_dirty(issue_id);
}
}

View File

@ -48,6 +48,39 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
.issue_statuses
.sort_by(|a, b| a.position.cmp(&b.position));
}
Msg::WsMsg(WsMsg::IssueStatusCreated(is)) => {
model.issue_statuses.push(is.clone());
model
.issue_statuses
.sort_by(|a, b| a.position.cmp(&b.position));
}
Msg::WsMsg(WsMsg::IssueStatusUpdated(changed)) => {
let mut old = vec![];
std::mem::swap(&mut model.issue_statuses, &mut old);
for is in old {
if is.id == changed.id {
model.issue_statuses.push(changed.clone());
} else {
model.issue_statuses.push(is);
}
}
model
.issue_statuses
.sort_by(|a, b| a.position.cmp(&b.position));
}
Msg::WsMsg(WsMsg::IssueDeleted(id)) => {
let mut old = vec![];
std::mem::swap(&mut model.issue_statuses, &mut old);
for is in old {
if is.id == *id {
continue;
}
model.issue_statuses.push(is);
}
model
.issue_statuses
.sort_by(|a, b| a.position.cmp(&b.position));
}
// users
Msg::WsMsg(WsMsg::ProjectUsersLoaded(v)) => {
model.users = v.clone();

View File

@ -25,8 +25,10 @@ pub type CommentId = i32;
pub type TokenId = i32;
pub type IssueStatusId = i32;
pub type InvitationId = i32;
pub type Position = i32;
pub type EmailString = String;
pub type UsernameString = String;
pub type TitleString = String;
#[cfg_attr(feature = "backend", derive(FromSqlRow, AsExpression))]
#[cfg_attr(feature = "backend", sql_type = "IssueTypeType")]
@ -587,6 +589,7 @@ pub enum ProjectFieldId {
Description,
Category,
TimeTracking,
IssueStatusName,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, PartialEq, Hash)]
@ -697,6 +700,12 @@ pub enum WsMsg {
// issue status
IssueStatusesRequest,
IssueStatusesResponse(Vec<IssueStatus>),
IssueStatusUpdate(IssueStatusId, TitleString, Position),
IssueStatusUpdated(IssueStatus),
IssueStatusCreate(TitleString, Position),
IssueStatusCreated(IssueStatus),
IssueStatusDelete(IssueStatusId),
IssueStatusDeleted(IssueStatusId),
// comments
IssueCommentsRequest(IssueId),

View File

@ -13,6 +13,11 @@ rustflags = [
"-C", "link-arg=-fuse-ld=lld",
]
[target.nightly-x86_64-unknown-linux-gnu]
rustflags = [
"-C", "link-arg=-fuse-ld=lld",
]
[[bin]]
name = "jirs_server"
path = "./src/main.rs"

View File

@ -2,7 +2,7 @@ use actix::{Handler, Message};
use diesel::pg::Pg;
use diesel::prelude::*;
use jirs_data::{IssueStatus, IssueStatusId, ProjectId};
use jirs_data::{IssueStatus, IssueStatusId, Position, ProjectId, TitleString};
use crate::db::DbExecutor;
use crate::errors::ServiceErrors;
@ -39,7 +39,7 @@ impl Handler<LoadIssueStatuses> for DbExecutor {
pub struct CreateIssueStatus {
pub project_id: ProjectId,
pub position: i32,
pub name: String,
pub name: TitleString,
}
impl Message for CreateIssueStatus {
@ -70,29 +70,71 @@ impl Handler<CreateIssueStatus> for DbExecutor {
}
pub struct DeleteIssueStatus {
pub project_id: ProjectId,
pub issue_status_id: IssueStatusId,
}
impl Message for DeleteIssueStatus {
type Result = Result<usize, ServiceErrors>;
type Result = Result<IssueStatusId, ServiceErrors>;
}
impl Handler<DeleteIssueStatus> for DbExecutor {
type Result = Result<usize, ServiceErrors>;
type Result = Result<IssueStatusId, ServiceErrors>;
fn handle(&mut self, msg: DeleteIssueStatus, _ctx: &mut Self::Context) -> Self::Result {
use crate::schema::issue_statuses::dsl::{id, issue_statuses};
use crate::schema::issue_statuses::dsl::{id, issue_statuses, project_id};
let conn = &self
.pool
.get()
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
let issue_assignees_query =
diesel::delete(issue_statuses).filter(id.eq(msg.issue_status_id));
let issue_assignees_query = diesel::delete(issue_statuses)
.filter(id.eq(msg.issue_status_id))
.filter(project_id.eq(msg.project_id));
debug!("{}", diesel::debug_query::<Pg, _>(&issue_assignees_query));
issue_assignees_query
.execute(conn)
.map_err(|_| ServiceErrors::RecordNotFound("issue users".to_string()))?;
Ok(msg.issue_status_id)
}
}
pub struct UpdateIssueStatus {
pub issue_status_id: IssueStatusId,
pub project_id: ProjectId,
pub position: Position,
pub name: TitleString,
}
impl Message for UpdateIssueStatus {
type Result = Result<IssueStatus, ServiceErrors>;
}
impl Handler<UpdateIssueStatus> for DbExecutor {
type Result = Result<IssueStatus, ServiceErrors>;
fn handle(&mut self, msg: UpdateIssueStatus, _ctx: &mut Self::Context) -> Self::Result {
use crate::schema::issue_statuses::dsl::{
id, issue_statuses, name, position, project_id, updated_at,
};
let conn = &self
.pool
.get()
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
let issue_assignees_query = diesel::update(issue_statuses)
.set((
name.eq(msg.name),
position.eq(msg.position),
updated_at.eq(chrono::Utc::now().naive_utc()),
))
.filter(id.eq(msg.issue_status_id))
.filter(project_id.eq(msg.project_id));
debug!("{}", diesel::debug_query::<Pg, _>(&issue_assignees_query));
issue_assignees_query
.get_result::<IssueStatus>(conn)
.map_err(|_| ServiceErrors::RecordNotFound("issue users".to_string()))
}
}

View File

@ -153,6 +153,53 @@ table! {
}
}
table! {
use diesel::sql_types::*;
use jirs_data::sql::*;
/// Representation of the `issue_statuses` table.
///
/// (Automatically generated by Diesel.)
issue_statuses (id) {
/// The `id` column of the `issue_statuses` table.
///
/// Its SQL type is `Int4`.
///
/// (Automatically generated by Diesel.)
id -> Int4,
/// The `name` column of the `issue_statuses` table.
///
/// Its SQL type is `Varchar`.
///
/// (Automatically generated by Diesel.)
name -> Varchar,
/// The `position` column of the `issue_statuses` table.
///
/// Its SQL type is `Int4`.
///
/// (Automatically generated by Diesel.)
position -> Int4,
/// The `project_id` column of the `issue_statuses` table.
///
/// Its SQL type is `Int4`.
///
/// (Automatically generated by Diesel.)
project_id -> Int4,
/// The `created_at` column of the `issue_statuses` table.
///
/// Its SQL type is `Timestamp`.
///
/// (Automatically generated by Diesel.)
created_at -> Timestamp,
/// The `updated_at` column of the `issue_statuses` table.
///
/// Its SQL type is `Timestamp`.
///
/// (Automatically generated by Diesel.)
updated_at -> Timestamp,
}
}
table! {
use diesel::sql_types::*;
use jirs_data::sql::*;
@ -254,53 +301,6 @@ table! {
}
}
table! {
use diesel::sql_types::*;
use jirs_data::sql::*;
/// Representation of the `issue_statuses` table.
///
/// (Automatically generated by Diesel.)
issue_statuses (id) {
/// The `id` column of the `issue_statuses` table.
///
/// Its SQL type is `Int4`.
///
/// (Automatically generated by Diesel.)
id -> Int4,
/// The `name` column of the `issue_statuses` table.
///
/// Its SQL type is `Varchar`.
///
/// (Automatically generated by Diesel.)
name -> Varchar,
/// The `position` column of the `issue_statuses` table.
///
/// Its SQL type is `Int4`.
///
/// (Automatically generated by Diesel.)
position -> Int4,
/// The `project_id` column of the `issue_statuses` table.
///
/// Its SQL type is `Int4`.
///
/// (Automatically generated by Diesel.)
project_id -> Int4,
/// The `created_at` column of the `issue_statuses` table.
///
/// Its SQL type is `Timestamp`.
///
/// (Automatically generated by Diesel.)
created_at -> Timestamp,
/// The `updated_at` column of the `issue_statuses` table.
///
/// Its SQL type is `Timestamp`.
///
/// (Automatically generated by Diesel.)
updated_at -> Timestamp,
}
}
table! {
use diesel::sql_types::*;
use jirs_data::sql::*;
@ -489,8 +489,8 @@ allow_tables_to_appear_in_same_query!(
comments,
invitations,
issue_assignees,
issues,
issue_statuses,
issues,
projects,
tokens,
users,

View File

@ -1,6 +1,6 @@
use futures::executor::block_on;
use jirs_data::WsMsg;
use jirs_data::{IssueStatusId, Position, TitleString, WsMsg};
use crate::db::issue_statuses;
use crate::ws::{WebSocketActor, WsHandler, WsResult};
@ -21,3 +21,73 @@ impl WsHandler<LoadIssueStatuses> for WebSocketActor {
Ok(msg)
}
}
pub struct CreateIssueStatus {
pub position: i32,
pub name: TitleString,
}
impl WsHandler<CreateIssueStatus> for WebSocketActor {
fn handle_msg(&mut self, msg: CreateIssueStatus, _ctx: &mut Self::Context) -> WsResult {
let project_id = self.require_user()?.project_id;
let CreateIssueStatus { position, name } = msg;
let msg = match block_on(self.db.send(issue_statuses::CreateIssueStatus {
project_id,
position,
name,
})) {
Ok(Ok(is)) => Some(WsMsg::IssueStatusCreated(is)),
_ => None,
};
Ok(msg)
}
}
pub struct DeleteIssueStatus {
pub issue_status_id: IssueStatusId,
}
impl WsHandler<DeleteIssueStatus> for WebSocketActor {
fn handle_msg(&mut self, msg: DeleteIssueStatus, _ctx: &mut Self::Context) -> WsResult {
let project_id = self.require_user()?.project_id;
let DeleteIssueStatus { issue_status_id } = msg;
let msg = match block_on(self.db.send(issue_statuses::DeleteIssueStatus {
issue_status_id,
project_id,
})) {
Ok(Ok(is)) => Some(WsMsg::IssueStatusDeleted(is)),
_ => None,
};
Ok(msg)
}
}
pub struct UpdateIssueStatus {
pub issue_status_id: IssueStatusId,
pub position: Position,
pub name: TitleString,
}
impl WsHandler<UpdateIssueStatus> for WebSocketActor {
fn handle_msg(&mut self, msg: UpdateIssueStatus, _ctx: &mut Self::Context) -> WsResult {
let project_id = self.require_user()?.project_id;
let UpdateIssueStatus {
issue_status_id,
position,
name,
} = msg;
let msg = match block_on(self.db.send(issue_statuses::UpdateIssueStatus {
issue_status_id,
position,
name,
project_id,
})) {
Ok(Ok(is)) => Some(WsMsg::IssueStatusUpdated(is)),
_ => None,
};
Ok(msg)
}
}

View File

@ -98,6 +98,20 @@ impl WebSocketActor {
// issue statuses
WsMsg::IssueStatusesRequest => self.handle_msg(LoadIssueStatuses, ctx)?,
WsMsg::IssueStatusDelete(issue_status_id) => {
self.handle_msg(DeleteIssueStatus { issue_status_id }, ctx)?
}
WsMsg::IssueStatusUpdate(issue_status_id, name, position) => self.handle_msg(
UpdateIssueStatus {
issue_status_id,
name,
position,
},
ctx,
)?,
WsMsg::IssueStatusCreate(name, position) => {
self.handle_msg(CreateIssueStatus { name, position }, ctx)?
}
// projects
WsMsg::ProjectRequest => self.handle_msg(CurrentProject, ctx)?,