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`
* Remove Custom Elements
* Replace CSS with SCSS
* Disable RTE until properly optimized
##### Work Progress
* [X] Add Epic
* [ ] Edit Epic
* [ ] Delete Epic
* [X] Edit Epic
* [X] Delete Epic
* [ ] Epic `starts` and `ends` date
* [X] Grouping by Epic
* [X] Basic Rich Text Editor
* [X] Insert Code in Rich Text Editor
* [ ] Basic Rich Text Editor
* [ ] Insert Code in Rich Text Editor
* [X] Code syntax
* [ ] Personal settings to choose MDE (Markdown Editor) or RTE
* [ ] Issues and filters view

View File

@ -1,14 +1,21 @@
use {
crate::{db_create, db_delete, db_load, db_update},
derive_db_execute::Execute,
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! {
LoadEpics,
msg => epics => epics.distinct_on(id).filter(project_id.eq(msg.project_id)),
Epic,
project_id => i32
project_id => ProjectId
}
db_create! {
@ -17,11 +24,15 @@ db_create! {
name.eq(msg.name.as_str()),
user_id.eq(msg.user_id),
project_id.eq(msg.project_id),
msg.description.map(|d| description.eq(d)),
msg.description_html.map(|d| description_html.eq(d)),
)),
Epic,
user_id => i32,
project_id => i32,
name => String
name => String,
description => Option<DescriptionString>,
description_html => Option<DescriptionString>
}
db_update! {

View File

@ -103,6 +103,18 @@ table! {
///
/// (Automatically generated by Diesel.)
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 {
crate::{db_or_debug_and_return, WebSocketActor, WsHandler, WsResult},
futures::executor::block_on,
jirs_data::{EpicId, NameString, UserProject, WsMsg},
jirs_data::{DescriptionString, EpicId, NameString, UserProject, WsMsg},
};
pub struct LoadEpics;
@ -16,11 +17,17 @@ impl WsHandler<LoadEpics> for WebSocketActor {
pub struct CreateEpic {
pub name: NameString,
pub description: Option<DescriptionString>,
pub description_html: Option<DescriptionString>,
}
impl WsHandler<CreateEpic> for WebSocketActor {
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 {
user_id,
project_id,
@ -31,6 +38,8 @@ impl WsHandler<CreateEpic> for WebSocketActor {
database_actor::epics::CreateEpic {
user_id: *user_id,
project_id: *project_id,
description,
description_html,
name,
}
);
@ -77,3 +86,47 @@ impl WsHandler<DeleteEpic> for WebSocketActor {
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
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) => {
self.handle_msg(epics::UpdateEpic { epic_id, name }, 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) => {
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)
}
///
///
///
/// 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))]
pub fn derive_enum_iter(item: TokenStream) -> TokenStream {
let mut it = item.into_iter().peekable();

View File

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

View File

@ -8,4 +8,19 @@
cursor: pointer;
user-select: none;
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 {
crate::{shared::ToNode, Msg},
seed::{prelude::*, *},
std::str::FromStr,
};
pub struct StyledLink<'l> {
@ -28,6 +29,11 @@ impl<'l> StyledLinkBuilder<'l> {
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 {
self.class_list.push(name);
self
@ -64,12 +70,28 @@ pub fn render(values: StyledLink) -> Node<Msg> {
href,
} = 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![
C!["styledLink"],
attrs![
At::Class => class_list.join(" "),
At::Href => href,
],
on_click,
children,
]
}

View File

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

View File

@ -1,6 +1,6 @@
use {
crate::{send_ws_msg, FieldId, Msg, OperationKind, ResourceKind},
jirs_data::{EpicFieldId, WsMsg},
jirs_data::{EpicFieldId, IssueType, WsMsg},
seed::prelude::*,
};
@ -40,6 +40,16 @@ pub fn update(msg: &Msg, model: &mut crate::model::Model, orders: &mut impl Orde
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)
.build(FieldId::EditEpic(EpicFieldId::TransformInto))
.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]]
}

View File

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

View File

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

View File

@ -1,6 +1,6 @@
use {
crate::{
components::styled_icon::StyledIcon,
components::{styled_icon::StyledIcon, styled_link::*},
model::{Model, PageContent},
pages::reports_page::model::ReportsPage,
shared::{inner_layout, ToNode},
@ -24,9 +24,14 @@ pub fn view(model: &Model) -> Node<Msg> {
_ => 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 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];
@ -91,26 +96,32 @@ fn this_month_graph(page: &ReportsPage, this_month_updated: &[&Issue]) -> Node<M
+ (legend_margin_width + SVG_MARGIN_X as f64);
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 |_| {
Some(Msg::PageChanged(PageChanged::Reports(
ReportsPageChange::DayHovered(Some(day)),
)))
let on_hover = mouse_ev(Ev::MouseEnter, move |ev| {
ev.stop_propagation();
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(
ReportsPageChange::DayHovered(None),
)))
let on_blur = mouse_ev(Ev::MouseLeave, move |ev| {
ev.stop_propagation();
ev.prevent_default();
Msg::PageChanged(PageChanged::Reports(ReportsPageChange::DayHovered(None)))
});
let selected = page.selected_day;
let current_date = day;
let on_click: EventHandler<Msg> = mouse_ev(Ev::MouseLeave, move |_| {
Some(Msg::PageChanged(PageChanged::Reports(
ReportsPageChange::DaySelected(match selected {
let on_click = mouse_ev("click", move |ev| {
ev.stop_propagation();
ev.prevent_default();
Msg::PageChanged(PageChanged::Reports(ReportsPageChange::DaySelected(
match selected {
Some(_) => None,
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> {
let mut children: Vec<Node<Msg>> = vec![];
for issue in this_month_updated {
#[derive(PartialEq)]
enum SelectionState {
Inactive,
Selected,
NotSelected,
}
impl SelectionState {
fn to_str(&self) -> &str {
match self {
SelectionState::Inactive => "",
SelectionState::Selected => "selected",
SelectionState::NotSelected => "nonSelected",
}
}
}
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 day = date.format("%Y-%m-%d").to_string();
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",
(Some(_), _) | (_, Some(_)) => "nonSelected",
_ => "",
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_status_id: _,
..
} = 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();
children.push(li![
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![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()],
]);
}
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![
C!["issueList"],
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 NameString = String;
pub type AvatarUrl = String;
pub type DescriptionString = String;
pub type Code = String;
pub type Lang = String;
@ -226,9 +227,9 @@ pub struct Issue {
pub title: String,
pub issue_type: IssueType,
pub priority: IssuePriority,
pub list_position: i32,
pub description: Option<String>,
pub description_text: Option<String>,
pub list_position: ListPosition,
pub description: Option<DescriptionString>,
pub description_text: Option<DescriptionString>,
pub estimate: Option<i32>,
pub time_spent: Option<i32>,
pub time_remaining: Option<i32>,
@ -256,12 +257,12 @@ pub struct IssueStatus {
#[cfg_attr(feature = "backend", derive(Queryable))]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Invitation {
pub id: i32,
pub id: InvitationId,
pub name: String,
pub email: String,
pub state: InvitationState,
pub project_id: i32,
pub invited_by_id: i32,
pub project_id: ProjectId,
pub invited_by_id: UserId,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub bind_token: Uuid,
@ -373,6 +374,8 @@ pub struct Epic {
pub updated_at: NaiveDateTime,
pub starts_at: Option<NaiveDateTime>,
pub ends_at: Option<NaiveDateTime>,
pub description: Option<DescriptionString>,
pub description_html: Option<DescriptionString>,
}
pub type FontStyle = u8;

View File

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