Add transform epic into issue
This commit is contained in:
parent
8412a113e7
commit
39021a8643
@ -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
|
||||||
|
@ -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! {
|
||||||
|
@ -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>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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)?
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
@ -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()));
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
@ -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 {
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
_ => (),
|
_ => (),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -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]]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
);
|
);
|
||||||
|
@ -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()))
|
||||||
}
|
}
|
||||||
|
@ -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"],
|
||||||
|
4
migrations/2021-01-18-220341_add_epics_fields/down.sql
Normal file
4
migrations/2021-01-18-220341_add_epics_fields/down.sql
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE epics
|
||||||
|
DROP COLUMN description;
|
||||||
|
ALTER TABLE epics
|
||||||
|
DROP COLUMN description_html;
|
4
migrations/2021-01-18-220341_add_epics_fields/up.sql
Normal file
4
migrations/2021-01-18-220341_add_epics_fields/up.sql
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE epics
|
||||||
|
ADD COLUMN description TEXT;
|
||||||
|
ALTER TABLE epics
|
||||||
|
ADD COLUMN description_html TEXT;
|
@ -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;
|
||||||
|
@ -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),
|
||||||
|
Loading…
Reference in New Issue
Block a user