Add transform epic into issue

This commit is contained in:
Adrian Woźniak 2021-01-19 21:57:48 +01:00
parent 8412a113e7
commit 39021a8643
20 changed files with 439 additions and 105 deletions

View File

@ -51,16 +51,17 @@ https://git.sr.ht/~tsumanu/jirs
* Fix S3 upload with upgraded version of `rusoto` * Fix S3 upload with upgraded version of `rusoto`
* Remove Custom Elements * Remove Custom Elements
* Replace CSS with SCSS * Replace CSS with SCSS
* Disable RTE until properly optimized
##### Work Progress ##### Work Progress
* [X] Add Epic * [X] Add Epic
* [ ] Edit Epic * [X] Edit Epic
* [ ] Delete Epic * [X] Delete Epic
* [ ] Epic `starts` and `ends` date * [ ] Epic `starts` and `ends` date
* [X] Grouping by Epic * [X] Grouping by Epic
* [X] Basic Rich Text Editor * [ ] Basic Rich Text Editor
* [X] Insert Code in Rich Text Editor * [ ] Insert Code in Rich Text Editor
* [X] Code syntax * [X] Code syntax
* [ ] Personal settings to choose MDE (Markdown Editor) or RTE * [ ] Personal settings to choose MDE (Markdown Editor) or RTE
* [ ] Issues and filters view * [ ] Issues and filters view

View File

@ -1,14 +1,21 @@
use { use {
crate::{db_create, db_delete, db_load, db_update}, crate::{db_create, db_delete, db_load, db_update},
derive_db_execute::Execute,
diesel::prelude::*, diesel::prelude::*,
jirs_data::Epic, jirs_data::{DescriptionString, Epic, EpicId, ProjectId},
}; };
#[derive(Execute)]
#[db_exec(schema = "epics", result = "Epic", find = "epics.find(msg.epic_id)")]
pub struct FindEpic {
pub epic_id: EpicId,
}
db_load! { db_load! {
LoadEpics, LoadEpics,
msg => epics => epics.distinct_on(id).filter(project_id.eq(msg.project_id)), msg => epics => epics.distinct_on(id).filter(project_id.eq(msg.project_id)),
Epic, Epic,
project_id => i32 project_id => ProjectId
} }
db_create! { db_create! {
@ -17,11 +24,15 @@ db_create! {
name.eq(msg.name.as_str()), name.eq(msg.name.as_str()),
user_id.eq(msg.user_id), user_id.eq(msg.user_id),
project_id.eq(msg.project_id), project_id.eq(msg.project_id),
msg.description.map(|d| description.eq(d)),
msg.description_html.map(|d| description_html.eq(d)),
)), )),
Epic, Epic,
user_id => i32, user_id => i32,
project_id => i32, project_id => i32,
name => String name => String,
description => Option<DescriptionString>,
description_html => Option<DescriptionString>
} }
db_update! { db_update! {

View File

@ -103,6 +103,18 @@ table! {
/// ///
/// (Automatically generated by Diesel.) /// (Automatically generated by Diesel.)
ends_at -> Nullable<Timestamp>, ends_at -> Nullable<Timestamp>,
/// The `description` column of the `epics` table.
///
/// Its SQL type is `Nullable<Text>`.
///
/// (Automatically generated by Diesel.)
description -> Nullable<Text>,
/// The `description_html` column of the `epics` table.
///
/// Its SQL type is `Nullable<Text>`.
///
/// (Automatically generated by Diesel.)
description_html -> Nullable<Text>,
} }
} }

View File

@ -1,7 +1,8 @@
use jirs_data::IssueType;
use { use {
crate::{db_or_debug_and_return, WebSocketActor, WsHandler, WsResult}, crate::{db_or_debug_and_return, WebSocketActor, WsHandler, WsResult},
futures::executor::block_on, futures::executor::block_on,
jirs_data::{EpicId, NameString, UserProject, WsMsg}, jirs_data::{DescriptionString, EpicId, NameString, UserProject, WsMsg},
}; };
pub struct LoadEpics; pub struct LoadEpics;
@ -16,11 +17,17 @@ impl WsHandler<LoadEpics> for WebSocketActor {
pub struct CreateEpic { pub struct CreateEpic {
pub name: NameString, pub name: NameString,
pub description: Option<DescriptionString>,
pub description_html: Option<DescriptionString>,
} }
impl WsHandler<CreateEpic> for WebSocketActor { impl WsHandler<CreateEpic> for WebSocketActor {
fn handle_msg(&mut self, msg: CreateEpic, _ctx: &mut Self::Context) -> WsResult { fn handle_msg(&mut self, msg: CreateEpic, _ctx: &mut Self::Context) -> WsResult {
let CreateEpic { name } = msg; let CreateEpic {
name,
description,
description_html,
} = msg;
let UserProject { let UserProject {
user_id, user_id,
project_id, project_id,
@ -31,6 +38,8 @@ impl WsHandler<CreateEpic> for WebSocketActor {
database_actor::epics::CreateEpic { database_actor::epics::CreateEpic {
user_id: *user_id, user_id: *user_id,
project_id: *project_id, project_id: *project_id,
description,
description_html,
name, name,
} }
); );
@ -77,3 +86,47 @@ impl WsHandler<DeleteEpic> for WebSocketActor {
Ok(Some(WsMsg::EpicDeleted(epic_id, n))) Ok(Some(WsMsg::EpicDeleted(epic_id, n)))
} }
} }
pub struct TransformEpic {
pub epic_id: EpicId,
pub issue_type: IssueType,
}
impl WsHandler<TransformEpic> for WebSocketActor {
fn handle_msg(&mut self, msg: TransformEpic, _ctx: &mut Self::Context) -> WsResult {
let epic: jirs_data::Epic = db_or_debug_and_return!(
self,
database_actor::epics::FindEpic {
epic_id: msg.epic_id
}
);
let issue: database_actor::models::Issue = db_or_debug_and_return!(
self,
database_actor::issues::CreateIssue {
title: epic.name,
issue_type: msg.issue_type,
issue_status_id: 0,
priority: Default::default(),
description: epic.description_html,
description_text: epic.description,
estimate: None,
time_spent: None,
time_remaining: None,
project_id: epic.project_id,
reporter_id: epic.user_id,
user_ids: vec![epic.user_id],
epic_id: None
}
);
let n = db_or_debug_and_return!(
self,
database_actor::epics::DeleteEpic {
user_id: epic.user_id,
epic_id: epic.id
}
);
self.broadcast(&WsMsg::EpicDeleted(msg.epic_id, n));
self.broadcast(&WsMsg::IssueCreated(issue.into()));
Ok(None)
}
}

View File

@ -185,11 +185,27 @@ impl WebSocketActor {
// epics // epics
WsMsg::EpicsLoad => self.handle_msg(epics::LoadEpics, ctx)?, WsMsg::EpicsLoad => self.handle_msg(epics::LoadEpics, ctx)?,
WsMsg::EpicCreate(name) => self.handle_msg(epics::CreateEpic { name }, ctx)?, WsMsg::EpicCreate(name, description, description_html) => self.handle_msg(
epics::CreateEpic {
name,
description_html,
description,
},
ctx,
)?,
WsMsg::EpicUpdate(epic_id, name) => { WsMsg::EpicUpdate(epic_id, name) => {
self.handle_msg(epics::UpdateEpic { epic_id, name }, ctx)? self.handle_msg(epics::UpdateEpic { epic_id, name }, ctx)?
} }
WsMsg::EpicDelete(epic_id) => self.handle_msg(epics::DeleteEpic { epic_id }, ctx)?, WsMsg::EpicDelete(epic_id) => self.handle_msg(epics::DeleteEpic { epic_id }, ctx)?,
WsMsg::EpicTransform(epic_id, issue_type) => self.handle_msg(
epics::TransformEpic {
epic_id,
issue_type,
},
ctx,
)?,
// hi
WsMsg::HighlightCode(lang, code) => { WsMsg::HighlightCode(lang, code) => {
self.handle_msg(hi::HighlightCode(lang, code), ctx)? self.handle_msg(hi::HighlightCode(lang, code), ctx)?
} }

View File

@ -31,6 +31,46 @@ fn parse_meta(mut it: Peekable<IntoIter>) -> (Peekable<IntoIter>, Option<Attribu
(it, attrs) (it, attrs)
} }
///
///
///
/// Example:
/// ```
/// pub struct Issue {
/// pub id: i32,
/// pub name: String,
/// }
///
/// #[derive(Execute)]
/// #[db_exec(schema = "issues", result = "Issue", find = "issues.find(msg.id)")]
/// pub struct FindOne {
/// pub id: i32,
/// }
///
/// #[derive(Execute)]
/// #[db_exec(schema = "issues", result = "Issue", load = "issues")]
/// pub struct LoadAll;
///
/// #[derive(Execute)]
/// #[db_exec(schema = "issues", result = "usize", destroy = "diesel::delete(issues.find(msg.id)")]
/// pub struct DeleteOne {
/// pub id: i32
/// }
///
/// #[derive(Execute)]
/// #[db_exec(schema = "issues", result = "Issue", destroy = "diesel::insert_into(issues).values(name.eq(msg.name))")]
/// pub struct CreateOne {
/// pub name: String
/// }
///
/// #[derive(Execute)]
/// #[db_exec(schema = "issues", result = "Issue", destroy = "diesel::update(issues.find(msg.id)).set(name.eq(msg.name))")]
/// pub struct UpdateOne {
/// pub id: i32,
/// pub name: String
/// }
/// ```
///
#[proc_macro_derive(Execute, attributes(db_exec))] #[proc_macro_derive(Execute, attributes(db_exec))]
pub fn derive_enum_iter(item: TokenStream) -> TokenStream { pub fn derive_enum_iter(item: TokenStream) -> TokenStream {
let mut it = item.into_iter().peekable(); let mut it = item.into_iter().peekable();

View File

@ -1,10 +1,11 @@
extern crate proc_macro; extern crate proc_macro;
use proc_macro::{TokenStream, TokenTree}; use {
proc_macro::{token_stream::IntoIter, TokenStream, TokenTree},
std::iter::Peekable,
};
#[proc_macro_derive(EnumIter)] fn skip_meta(mut it: Peekable<IntoIter>) -> Peekable<IntoIter> {
pub fn derive_enum_iter(item: TokenStream) -> TokenStream {
let mut it = item.into_iter().peekable();
while let Some(token) = it.peek() { while let Some(token) = it.peek() {
if let TokenTree::Ident(_) = token { if let TokenTree::Ident(_) = token {
break; break;
@ -12,20 +13,21 @@ pub fn derive_enum_iter(item: TokenStream) -> TokenStream {
it.next(); it.next();
} }
} }
it
}
fn consume_ident(mut it: Peekable<IntoIter>, name: &str) -> Peekable<IntoIter> {
if let Some(TokenTree::Ident(ident)) = it.next() { if let Some(TokenTree::Ident(ident)) = it.next() {
if ident.to_string().as_str() != "pub" { if ident.to_string().as_str() != name {
panic!("Expect to find keyword pub but was found {:?}", ident) panic!("Expect to find keyword {} but was found {:?}", name, ident)
} }
} else { } else {
panic!("Expect to find keyword pub but nothing was found") panic!("Expect to find keyword {} but nothing was found", name)
}
if let Some(TokenTree::Ident(ident)) = it.next() {
if ident.to_string().as_str() != "enum" {
panic!("Expect to find keyword struct but was found {:?}", ident)
}
} else {
panic!("Expect to find keyword struct but nothing was found")
} }
it
}
pub(in crate) fn codegen(mut it: Peekable<IntoIter>) -> Result<String, String> {
let name = it let name = it
.next() .next()
.expect("Expect to struct name but nothing was found"); .expect("Expect to struct name but nothing was found");
@ -38,10 +40,10 @@ pub fn derive_enum_iter(item: TokenStream) -> TokenStream {
} }
} }
} else { } else {
panic!("Enum variants group expected"); return Err("Enum variants group expected".to_string());
} }
if variants.is_empty() { if variants.is_empty() {
panic!("Enum cannot be empty") return Err("Enum cannot be empty".to_string());
} }
let mut code = format!( let mut code = format!(
@ -72,16 +74,15 @@ impl std::iter::Iterator for {name}Iter {{
match idx { match idx {
0 => code.push_str( 0 => code.push_str(
format!( format!(
"None => Some({name}::{variant}),\n", " None => Some({name}::{variant}),\n",
variant = variant, variant = variant,
name = name name = name
) )
.as_str(), .as_str(),
), ),
_ if idx == variants.len() - 1 => code.push_str("_ => None,\n"),
_ => code.push_str( _ => code.push_str(
format!( format!(
"Some({name}::{last_variant}) => Some({name}::{variant}),\n", " Some({name}::{last_variant}) => Some({name}::{variant}),\n",
last_variant = last_variant, last_variant = last_variant,
variant = variant, variant = variant,
name = name, name = name,
@ -89,6 +90,9 @@ impl std::iter::Iterator for {name}Iter {{
.as_str(), .as_str(),
), ),
} }
if idx == variants.len() - 1 {
code.push_str(" _ => None,\n");
}
last_variant = variant.as_str(); last_variant = variant.as_str();
} }
@ -113,5 +117,33 @@ impl std::iter::IntoIterator for {name} {{
) )
.as_str(), .as_str(),
); );
Ok(code)
}
#[proc_macro_derive(EnumIter)]
pub fn derive_enum_iter(item: TokenStream) -> TokenStream {
let mut it = item.into_iter().peekable();
it = skip_meta(it);
it = consume_ident(it, "pub");
it = consume_ident(it, "enum");
let code = codegen(it).unwrap();
code.parse().unwrap() code.parse().unwrap()
} }
// #[cfg(test)]
// mod tests {
// use super::codegen;
// use proc_macro::TokenStream;
// use std::str::FromStr;
//
// #[test]
// fn empty_enum() {
// let it = TokenStream::from_str("enum A {}")
// .unwrap()
// .into_iter()
// .peekable();
// let code = codegen(it);
// assert_eq!(code, Err("Enum cannot be empty".to_string()));
// }
// }

View File

@ -17,8 +17,57 @@
} }
> .issue { > .issue {
padding: {
top: 10px;
bottom: 10px;
}
display: grid; display: grid;
grid-template-columns: 32px 32px 240px auto 120px; grid-template-columns: 124px 130px calc(100% - 250px);
grid-template-areas: "type number name" "priority desc desc" "updatedAt desc desc";
&:hover {
background-color: var(--issue-background-selected);
}
.number {
grid-area: number;
> .styledLink {
line-height: 1;
> .styledIcon, > span {
line-height: 1;
}
}
}
.type {
grid-area: type;
}
.priority {
grid-area: priority;
}
.name {
grid-area: name;
}
.desc {
grid-area: desc;
margin-top: 10px;
font-size: 12px;
color: var(--textLight);
pre * {
font-size: 12px;
color: var(--textLight);
}
}
.updatedAt {
grid-area: updatedAt;
align-self: end;
color: var(--textLight);
font-size: 12px;
}
} }
> .issue.selected { > .issue.selected {

View File

@ -8,4 +8,19 @@
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
font-size: 14.5px; font-size: 14.5px;
&.withIcon {
display: flex;
> .styledIcon {
margin-right: 10px;
font-size: 14.5px;
line-height: 2;
}
> span {
font-size: 14.5px;
line-height: 2;
}
}
} }

View File

@ -1,6 +1,7 @@
use { use {
crate::{shared::ToNode, Msg}, crate::{shared::ToNode, Msg},
seed::{prelude::*, *}, seed::{prelude::*, *},
std::str::FromStr,
}; };
pub struct StyledLink<'l> { pub struct StyledLink<'l> {
@ -28,6 +29,11 @@ impl<'l> StyledLinkBuilder<'l> {
self self
} }
pub fn with_icon(self) -> Self {
self.add_child(crate::components::styled_icon::Icon::Link.into_node())
.add_class("withIcon")
}
pub fn add_class(mut self, name: &'l str) -> Self { pub fn add_class(mut self, name: &'l str) -> Self {
self.class_list.push(name); self.class_list.push(name);
self self
@ -64,12 +70,28 @@ pub fn render(values: StyledLink) -> Node<Msg> {
href, href,
} = values; } = values;
let on_click = {
let href = href.to_string();
mouse_ev("click", move |ev| {
if href.starts_with('/') {
ev.prevent_default();
ev.stop_propagation();
if let Ok(url) = seed::Url::from_str(href.as_str()) {
url.go_and_push();
}
}
None as Option<Msg>
})
};
a![ a![
C!["styledLink"], C!["styledLink"],
attrs![ attrs![
At::Class => class_list.join(" "), At::Class => class_list.join(" "),
At::Href => href, At::Href => href,
], ],
on_click,
children, children,
] ]
} }

View File

@ -111,6 +111,7 @@ pub enum Msg {
AddEpic, AddEpic,
DeleteEpic, DeleteEpic,
UpdateEpic, UpdateEpic,
TransformEpic,
// issue statuses // issue statuses
DeleteIssueStatus(IssueStatusId), DeleteIssueStatus(IssueStatusId),
@ -172,8 +173,7 @@ fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) {
WebSocketChanged::WebSocketMessageLoaded(v) => { WebSocketChanged::WebSocketMessageLoaded(v) => {
match bincode::deserialize(v.as_slice()) { match bincode::deserialize(v.as_slice()) {
Ok(WsMsg::Ping | WsMsg::Pong) => { Ok(WsMsg::Ping | WsMsg::Pong) => {
orders.skip(); orders.skip().perform_cmd(cmds::timeout(300, || {
orders.perform_cmd(cmds::timeout(300, || {
Msg::WebSocketChange(WebSocketChanged::SendPing) Msg::WebSocketChange(WebSocketChanged::SendPing)
})); }));
} }

View File

@ -1,6 +1,6 @@
use { use {
crate::{send_ws_msg, FieldId, Msg, OperationKind, ResourceKind}, crate::{send_ws_msg, FieldId, Msg, OperationKind, ResourceKind},
jirs_data::{EpicFieldId, WsMsg}, jirs_data::{EpicFieldId, IssueType, WsMsg},
seed::prelude::*, seed::prelude::*,
}; };
@ -40,6 +40,16 @@ pub fn update(msg: &Msg, model: &mut crate::model::Model, orders: &mut impl Orde
orders, orders,
); );
} }
Msg::TransformEpic => {
let epic_id = modal.epic_id;
let issue_type: IssueType = modal.transform_into.value.into();
send_ws_msg(
WsMsg::EpicTransform(epic_id, issue_type),
model.ws.as_ref(),
orders,
);
orders.skip().send_msg(Msg::ModalDropped);
}
_ => (), _ => (),
}; };
} }

View File

@ -69,7 +69,15 @@ fn transform_into_available(modal: &super::Model) -> Node<Msg> {
.state(&modal.transform_into) .state(&modal.transform_into)
.build(FieldId::EditEpic(EpicFieldId::TransformInto)) .build(FieldId::EditEpic(EpicFieldId::TransformInto))
.into_node(); .into_node();
let execute = StyledButton::build().text("Transform").build().into_node(); let execute = StyledButton::build()
.on_click(mouse_ev("click", |ev| {
ev.stop_propagation();
ev.prevent_default();
Msg::TransformEpic
}))
.text("Transform")
.build()
.into_node();
div![C!["transform available"], div![types], div![execute]] div![C!["transform available"], div![types], div![execute]]
} }

View File

@ -21,7 +21,7 @@ pub fn update(msg: &Msg, model: &mut crate::model::Model, orders: &mut impl Orde
match msg { match msg {
Msg::AddEpic => { Msg::AddEpic => {
send_ws_msg( send_ws_msg(
WsMsg::EpicCreate(modal.title_state.value.clone()), WsMsg::EpicCreate(modal.title_state.value.clone(), None, None),
model.ws.as_ref(), model.ws.as_ref(),
orders, orders,
); );

View File

@ -4,16 +4,21 @@ use {
model::{Model, Page, PageContent}, model::{Model, Page, PageContent},
pages::reports_page::model::ReportsPage, pages::reports_page::model::ReportsPage,
ws::board_load, ws::board_load,
Msg, WebSocketChanged, Msg, OperationKind, ResourceKind,
}, },
jirs_data::WsMsg,
seed::prelude::*, seed::prelude::*,
}; };
pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) { pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) {
if let Msg::ChangePage(Page::Reports) = msg { match msg {
build_page_content(model); Msg::ChangePage(Page::Reports) => build_page_content(model),
} Msg::ResourceChanged(ResourceKind::Auth, OperationKind::SingleLoaded, _)
if model.page == Page::Reports =>
{
build_page_content(model);
}
_ => {}
};
let page = match &mut model.page_content { let page = match &mut model.page_content {
PageContent::Reports(page) => page, PageContent::Reports(page) => page,
@ -25,7 +30,7 @@ pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Order
} }
match msg { match msg {
Msg::UserChanged(Some(..)) Msg::UserChanged(Some(..))
| Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::AuthorizeLoaded(..))) | Msg::ResourceChanged(ResourceKind::Auth, OperationKind::SingleLoaded, _)
| Msg::ChangePage(Page::Reports) => { | Msg::ChangePage(Page::Reports) => {
board_load(model, orders); board_load(model, orders);
} }
@ -39,6 +44,6 @@ pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Order
} }
} }
fn build_page_content(model: &mut Model) { pub fn build_page_content(model: &mut Model) {
model.page_content = PageContent::Reports(Box::new(ReportsPage::default())) model.page_content = PageContent::Reports(Box::new(ReportsPage::default()))
} }

View File

@ -1,6 +1,6 @@
use { use {
crate::{ crate::{
components::styled_icon::StyledIcon, components::{styled_icon::StyledIcon, styled_link::*},
model::{Model, PageContent}, model::{Model, PageContent},
pages::reports_page::model::ReportsPage, pages::reports_page::model::ReportsPage,
shared::{inner_layout, ToNode}, shared::{inner_layout, ToNode},
@ -24,9 +24,14 @@ pub fn view(model: &Model) -> Node<Msg> {
_ => return empty![], _ => return empty![],
}; };
let project_name = model
.project
.as_ref()
.map(|p| p.name.as_str())
.unwrap_or_default();
let this_month_updated = this_month_updated(model, page); let this_month_updated = this_month_updated(model, page);
let graph = this_month_graph(page, &this_month_updated); let graph = this_month_graph(page, &this_month_updated);
let list = issue_list(page, this_month_updated.as_slice()); let list = issue_list(page, project_name, this_month_updated.as_slice());
let body = section![C!["top"], h1![C!["header"], "Reports"], graph, list]; let body = section![C!["top"], h1![C!["header"], "Reports"], graph, list];
@ -91,26 +96,32 @@ fn this_month_graph(page: &ReportsPage, this_month_updated: &[&Issue]) -> Node<M
+ (legend_margin_width + SVG_MARGIN_X as f64); + (legend_margin_width + SVG_MARGIN_X as f64);
let height = num_issues as f64 * piece_height; let height = num_issues as f64 * piece_height;
let day = page.first_day.with_day0(day).unwrap(); let day = page.first_day.clone().with_day0(day).unwrap();
let on_hover: EventHandler<Msg> = mouse_ev(Ev::MouseEnter, move |_| { let on_hover = mouse_ev(Ev::MouseEnter, move |ev| {
Some(Msg::PageChanged(PageChanged::Reports( ev.stop_propagation();
ReportsPageChange::DayHovered(Some(day)), ev.prevent_default();
))) Msg::PageChanged(PageChanged::Reports(ReportsPageChange::DayHovered(Some(
day,
))))
}); });
let on_blur: EventHandler<Msg> = mouse_ev(Ev::MouseLeave, move |_| {
Some(Msg::PageChanged(PageChanged::Reports( let on_blur = mouse_ev(Ev::MouseLeave, move |ev| {
ReportsPageChange::DayHovered(None), ev.stop_propagation();
))) ev.prevent_default();
Msg::PageChanged(PageChanged::Reports(ReportsPageChange::DayHovered(None)))
}); });
let selected = page.selected_day; let selected = page.selected_day;
let current_date = day; let current_date = day;
let on_click: EventHandler<Msg> = mouse_ev(Ev::MouseLeave, move |_| { let on_click = mouse_ev("click", move |ev| {
Some(Msg::PageChanged(PageChanged::Reports( ev.stop_propagation();
ReportsPageChange::DaySelected(match selected { ev.prevent_default();
Msg::PageChanged(PageChanged::Reports(ReportsPageChange::DaySelected(
match selected {
Some(_) => None, Some(_) => None,
None => Some(current_date), None => Some(current_date),
}), },
))) )))
}); });
@ -149,44 +160,76 @@ fn this_month_graph(page: &ReportsPage, this_month_updated: &[&Issue]) -> Node<M
] ]
} }
fn issue_list(page: &ReportsPage, this_month_updated: &[&Issue]) -> Node<Msg> { #[derive(PartialEq)]
let mut children: Vec<Node<Msg>> = vec![]; enum SelectionState {
for issue in this_month_updated { Inactive,
let date = issue.updated_at.date(); Selected,
let day = date.format("%Y-%m-%d").to_string(); NotSelected,
let active_class = match (page.hovered_day.as_ref(), page.selected_day.as_ref()) { }
(Some(d), _) if *d == date => "selected",
(_, Some(d)) if *d == date => "selected", impl SelectionState {
(Some(_), _) | (_, Some(_)) => "nonSelected", fn to_str(&self) -> &str {
_ => "", match self {
}; SelectionState::Inactive => "",
let Issue { SelectionState::Selected => "selected",
title, SelectionState::NotSelected => "nonSelected",
issue_type, }
priority,
description,
issue_status_id: _,
..
} = issue;
let type_icon = StyledIcon::build(issue_type.clone().into())
.build()
.into_node();
let priority_icon = StyledIcon::build(priority.clone().into())
.build()
.into_node();
children.push(li![
C!["issue"],
C![active_class],
span![C!["priority"], priority_icon],
span![C!["type"], type_icon],
span![C!["name"], title.as_str()],
span![
C!["desc"],
description.as_ref().cloned().unwrap_or_default()
],
span![C!["updatedAt"], day.as_str()],
]);
} }
}
fn issue_list(page: &ReportsPage, project_name: &str, this_month_updated: &[&Issue]) -> Node<Msg> {
let children: Vec<Node<Msg>> = this_month_updated
.iter()
.map(|issue| {
let date = issue.updated_at.date();
let selection_state = match (page.hovered_day.as_ref(), page.selected_day.as_ref()) {
(Some(d), _) if *d == date => SelectionState::Selected,
(_, Some(d)) if *d == date => SelectionState::Selected,
(Some(_), _) | (_, Some(_)) => SelectionState::NotSelected,
_ => SelectionState::Inactive,
};
let Issue {
id,
title,
issue_type,
priority,
description,
..
} = issue;
let day = date.format("%Y-%m-%d").to_string();
let type_icon = StyledIcon::build(issue_type.clone().into())
.build()
.into_node();
let priority_icon = StyledIcon::build(priority.clone().into())
.build()
.into_node();
let desc = Node::from_html(
description
.as_deref()
.unwrap_or_default()
);
let link = StyledLink::build()
.with_icon()
.text(format!("{}-{}", project_name, id).as_str())
.href(format!("/issues/{}", id).as_str())
.build()
.into_node();
li![
C!["issue"],
C![selection_state.to_str()],
div![C!["number"], link],
div![C!["type"], type_icon],
IF!( selection_state != SelectionState::NotSelected => div![C!["priority"], priority_icon]),
IF!( selection_state != SelectionState::NotSelected => div![C!["name"], title.as_str()]),
IF!( selection_state != SelectionState::NotSelected => div![C!["desc"], desc]),
IF!( selection_state != SelectionState::NotSelected => div![C!["updatedAt"], day.as_str()]),
]
})
.collect();
div![ div![
C!["issueList"], C!["issueList"],
h5![C!["issueListHeader"], "Issues this month"], h5![C!["issueListHeader"], "Issues this month"],

View File

@ -0,0 +1,4 @@
ALTER TABLE epics
DROP COLUMN description;
ALTER TABLE epics
DROP COLUMN description_html;

View File

@ -0,0 +1,4 @@
ALTER TABLE epics
ADD COLUMN description TEXT;
ALTER TABLE epics
ADD COLUMN description_html TEXT;

View File

@ -40,6 +40,7 @@ pub type UsernameString = String;
pub type TitleString = String; pub type TitleString = String;
pub type NameString = String; pub type NameString = String;
pub type AvatarUrl = String; pub type AvatarUrl = String;
pub type DescriptionString = String;
pub type Code = String; pub type Code = String;
pub type Lang = String; pub type Lang = String;
@ -226,9 +227,9 @@ pub struct Issue {
pub title: String, pub title: String,
pub issue_type: IssueType, pub issue_type: IssueType,
pub priority: IssuePriority, pub priority: IssuePriority,
pub list_position: i32, pub list_position: ListPosition,
pub description: Option<String>, pub description: Option<DescriptionString>,
pub description_text: Option<String>, pub description_text: Option<DescriptionString>,
pub estimate: Option<i32>, pub estimate: Option<i32>,
pub time_spent: Option<i32>, pub time_spent: Option<i32>,
pub time_remaining: Option<i32>, pub time_remaining: Option<i32>,
@ -256,12 +257,12 @@ pub struct IssueStatus {
#[cfg_attr(feature = "backend", derive(Queryable))] #[cfg_attr(feature = "backend", derive(Queryable))]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Invitation { pub struct Invitation {
pub id: i32, pub id: InvitationId,
pub name: String, pub name: String,
pub email: String, pub email: String,
pub state: InvitationState, pub state: InvitationState,
pub project_id: i32, pub project_id: ProjectId,
pub invited_by_id: i32, pub invited_by_id: UserId,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub bind_token: Uuid, pub bind_token: Uuid,
@ -373,6 +374,8 @@ pub struct Epic {
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub starts_at: Option<NaiveDateTime>, pub starts_at: Option<NaiveDateTime>,
pub ends_at: Option<NaiveDateTime>, pub ends_at: Option<NaiveDateTime>,
pub description: Option<DescriptionString>,
pub description_html: Option<DescriptionString>,
} }
pub type FontStyle = u8; pub type FontStyle = u8;

View File

@ -1,11 +1,12 @@
use crate::DescriptionString;
use { use {
crate::{ crate::{
AvatarUrl, BindToken, Code, Comment, CommentId, CreateCommentPayload, CreateIssuePayload, AvatarUrl, BindToken, Code, Comment, CommentId, CreateCommentPayload, CreateIssuePayload,
EmailString, Epic, EpicId, HighlightedCode, Invitation, InvitationId, InvitationToken, EmailString, Epic, EpicId, HighlightedCode, Invitation, InvitationId, InvitationToken,
Issue, IssueFieldId, IssueId, IssueStatus, IssueStatusId, Lang, ListPosition, Message, Issue, IssueFieldId, IssueId, IssueStatus, IssueStatusId, IssueType, Lang, ListPosition,
MessageId, NameString, NumberOfDeleted, PayloadVariant, Position, Project, TitleString, Message, MessageId, NameString, NumberOfDeleted, PayloadVariant, Position, Project,
UpdateCommentPayload, UpdateProjectPayload, User, UserId, UserProject, UserProjectId, TitleString, UpdateCommentPayload, UpdateProjectPayload, User, UserId, UserProject,
UserRole, UsernameString, UserProjectId, UserRole, UsernameString,
}, },
serde::{Deserialize, Serialize}, serde::{Deserialize, Serialize},
uuid::Uuid, uuid::Uuid,
@ -229,12 +230,17 @@ pub enum WsMsg {
// epics // epics
EpicsLoad, EpicsLoad,
EpicsLoaded(Vec<Epic>), EpicsLoaded(Vec<Epic>),
EpicCreate(NameString), EpicCreate(
NameString,
Option<DescriptionString>,
Option<DescriptionString>,
),
EpicCreated(Epic), EpicCreated(Epic),
EpicUpdate(EpicId, NameString), EpicUpdate(EpicId, NameString),
EpicUpdated(Epic), EpicUpdated(Epic),
EpicDelete(EpicId), EpicDelete(EpicId),
EpicDeleted(EpicId, NumberOfDeleted), EpicDeleted(EpicId, NumberOfDeleted),
EpicTransform(EpicId, IssueType),
// highlight // highlight
HighlightCode(Lang, Code), HighlightCode(Lang, Code),