Multiple changes

This commit is contained in:
Adrian Woźniak 2021-01-13 22:16:22 +01:00
parent 57adfac3a4
commit 7803e0fc3d
32 changed files with 725 additions and 472 deletions

25
Cargo.lock generated
View File

@ -861,7 +861,7 @@ dependencies = [
"ansi_term", "ansi_term",
"atty", "atty",
"bitflags", "bitflags",
"strsim", "strsim 0.8.0",
"textwrap", "textwrap",
"unicode-width", "unicode-width",
"vec_map", "vec_map",
@ -1731,6 +1731,7 @@ dependencies = [
"log", "log",
"pretty_env_logger", "pretty_env_logger",
"serde", "serde",
"simsearch",
"syntect", "syntect",
"toml", "toml",
] ]
@ -3394,6 +3395,16 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "simsearch"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b5ceaabc64e2d93a73aa29f8a7dd83dde9c02fdbee660c4551c7e659ce4185"
dependencies = [
"strsim 0.10.0",
"triple_accel",
]
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.2" version = "0.4.2"
@ -3487,6 +3498,12 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
[[package]]
name = "strsim"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.4.0" version = "2.4.0"
@ -3803,6 +3820,12 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "triple_accel"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "622b09ce2fe2df4618636fb92176d205662f59803f39e70d1c333393082de96c"
[[package]] [[package]]
name = "trust-dns-proto" name = "trust-dns-proto"
version = "0.18.0-alpha.2" version = "0.18.0-alpha.2"

View File

@ -17,6 +17,7 @@ serde = "*"
bincode = "*" bincode = "*"
toml = { version = "*" } toml = { version = "*" }
simsearch = { version = "0.2" }
actix = { version = "0.10.0" } actix = { version = "0.10.0" }
flate2 = { version = "*" } flate2 = { version = "*" }

View File

@ -1,6 +1,7 @@
use { use {
actix::{Actor, Handler, SyncContext}, actix::{Actor, Handler, SyncContext},
jirs_data::HighlightedCode, jirs_data::HighlightedCode,
simsearch::SimSearch,
std::sync::Arc, std::sync::Arc,
syntect::{ syntect::{
easy::HighlightLines, easy::HighlightLines,
@ -14,6 +15,20 @@ mod load;
lazy_static::lazy_static! { lazy_static::lazy_static! {
pub static ref THEME_SET: Arc<ThemeSet> = Arc::new(load::integrated_themeset()); pub static ref THEME_SET: Arc<ThemeSet> = Arc::new(load::integrated_themeset());
pub static ref SYNTAX_SET: Arc<SyntaxSet> = Arc::new(load::integrated_syntaxset()); pub static ref SYNTAX_SET: Arc<SyntaxSet> = Arc::new(load::integrated_syntaxset());
pub static ref SIM_SEARCH: Arc<SimSearch<usize>> = Arc::new(create_search_engine());
}
fn create_search_engine() -> SimSearch<usize> {
let mut engine = SimSearch::new();
for (idx, name) in SYNTAX_SET
.syntaxes()
.iter()
.map(|s| s.name.as_str())
.enumerate()
{
engine.insert(idx, name);
}
engine
} }
#[derive(Debug)] #[derive(Debug)]
@ -23,23 +38,53 @@ pub enum HighlightError {
ResultUnserializable, ResultUnserializable,
} }
fn hi<'l>(code: &'l str, lang: &'l str) -> Result<Vec<(Style, &'l str)>, HighlightError> { #[derive(Debug)]
let set = SYNTAX_SET pub struct HighlightActor {
.as_ref() theme_set: Arc<ThemeSet>,
.find_syntax_by_name(lang) syntax_set: Arc<SyntaxSet>,
.ok_or_else(|| HighlightError::UnknownLanguage)?;
let theme: &syntect::highlighting::Theme = THEME_SET
.as_ref()
.themes
.get("GitHub")
.ok_or_else(|| HighlightError::UnknownTheme)?;
let mut hi = HighlightLines::new(set, theme);
Ok(hi.highlight(code, SYNTAX_SET.as_ref()))
} }
#[derive(Debug, Default)] impl Default for HighlightActor {
pub struct HighlightActor {} fn default() -> Self {
let theme_set = THEME_SET.clone();
let syntax_set = SYNTAX_SET.clone();
Self {
theme_set,
syntax_set,
}
}
}
impl HighlightActor {
fn hi<'l>(
&self,
code: &'l str,
lang: &'l str,
) -> Result<Vec<(Style, &'l str)>, HighlightError> {
let lang = SIM_SEARCH
.search(lang)
.first()
.and_then(|idx| self.syntax_set.syntaxes().get(*idx))
.map(|st| st.name.as_str())
.ok_or_else(|| HighlightError::UnknownLanguage)?;
let set = self
.syntax_set
.as_ref()
.find_syntax_by_name(lang)
.ok_or_else(|| HighlightError::UnknownLanguage)?;
let theme: &syntect::highlighting::Theme = self
.theme_set
.as_ref()
.themes
.get("GitHub")
.ok_or_else(|| HighlightError::UnknownTheme)?;
let mut hi = HighlightLines::new(set, theme);
Ok(hi.highlight(code, self.syntax_set.as_ref()))
}
}
impl Actor for HighlightActor { impl Actor for HighlightActor {
type Context = SyncContext<Self>; type Context = SyncContext<Self>;
@ -56,7 +101,7 @@ impl Handler<HighlightCode> for HighlightActor {
type Result = Result<HighlightedCode, HighlightError>; type Result = Result<HighlightedCode, HighlightError>;
fn handle(&mut self, msg: HighlightCode, _ctx: &mut Self::Context) -> Self::Result { fn handle(&mut self, msg: HighlightCode, _ctx: &mut Self::Context) -> Self::Result {
let res: Vec<(Style, &str)> = hi(&msg.code, &msg.lang)?; let res: Vec<(Style, &str)> = self.hi(&msg.code, &msg.lang)?;
Ok(HighlightedCode { Ok(HighlightedCode {
parts: res parts: res
@ -117,3 +162,20 @@ impl Handler<TextHighlightCode> for HighlightActor {
})) }))
} }
} }
#[derive(actix::Message)]
#[rtype(result = "Result<Vec<String>, HighlightError>")]
pub struct LoadSyntaxSet;
impl Handler<LoadSyntaxSet> for HighlightActor {
type Result = Result<Vec<String>, HighlightError>;
fn handle(&mut self, _msg: LoadSyntaxSet, _ctx: &mut Self::Context) -> Self::Result {
Ok(self
.syntax_set
.syntaxes()
.iter()
.map(|s| s.name.clone())
.collect())
}
}

View File

@ -1,8 +1,8 @@
use futures::executor::block_on; use {
crate::{WebSocketActor, WsHandler, WsResult},
use jirs_data::{CommentId, CreateCommentPayload, IssueId, UpdateCommentPayload, WsMsg}; futures::executor::block_on,
jirs_data::{CommentId, CreateCommentPayload, IssueId, UpdateCommentPayload, WsMsg},
use crate::{WebSocketActor, WsHandler, WsResult}; };
pub struct LoadIssueComments { pub struct LoadIssueComments {
pub issue_id: IssueId, pub issue_id: IssueId,

View File

@ -1,8 +1,8 @@
use futures::executor::block_on; use {
crate::{WebSocketActor, WsHandler, WsResult},
use jirs_data::{EpicId, NameString, UserProject, WsMsg}; futures::executor::block_on,
jirs_data::{EpicId, NameString, UserProject, WsMsg},
use crate::{WebSocketActor, WsHandler, WsResult}; };
pub struct LoadEpics; pub struct LoadEpics;

View File

@ -1,9 +1,8 @@
use futures::executor::block_on; use {
crate::{WebSocketActor, WsHandler, WsResult},
use jirs_data::WsMsg; futures::executor::block_on,
use jirs_data::{Code, Lang}; jirs_data::{Code, Lang, WsMsg},
};
use crate::{WebSocketActor, WsHandler, WsResult};
pub struct HighlightCode(pub Lang, pub Code); pub struct HighlightCode(pub Lang, pub Code);

View File

@ -50,17 +50,13 @@ impl WsHandler<UpdateIssueHandler> for WebSocketActor {
msg.title = Some(s); msg.title = Some(s);
} }
(IssueFieldId::Description, PayloadVariant::String(s)) => { (IssueFieldId::Description, PayloadVariant::String(s)) => {
// let mut opts = comrak::ComrakOptions::default();
// opts.render.github_pre_lang = true;
// let html = comrak::markdown_to_html(s.as_str(), &opts);
let html: String = { let html: String = {
use pulldown_cmark::*; use pulldown_cmark::*;
let parser = pulldown_cmark::Parser::new(s.as_str()); let parser = pulldown_cmark::Parser::new(s.as_str());
enum ParseState { enum ParseState {
Code(highlight_actor::TextHighlightCode), Code(highlight_actor::TextHighlightCode),
Other, Other,
}; }
let mut state = ParseState::Other; let mut state = ParseState::Other;
let parser = parser.flat_map(|event| match event { let parser = parser.flat_map(|event| match event {

View File

@ -50,12 +50,21 @@
transition: transform 0.1s; transition: transform 0.1s;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
background: var(--backgroundMedium);
border-color: var(--backgroundLight)
} }
#projectPage > #projectBoardFilters > #avatars > .avatarIsActiveBorder.isActive { #projectPage > #projectBoardFilters > #avatars > .avatarIsActiveBorder.isActive {
box-shadow: 0 0 0 4px var(--primary); box-shadow: 0 0 0 4px var(--primary);
} }
#projectPage > #projectBoardFilters > #avatars > .avatarIsActiveBorder > .letter {
width: 32px;
height: 32px;
font-size: 16px;
font-weight: bolder;
}
#projectPage > #projectBoardFilters > #avatars > .avatarIsActiveBorder:hover { #projectPage > #projectBoardFilters > #avatars > .avatarIsActiveBorder:hover {
transform: translateY(-5px); transform: translateY(-5px);
} }
@ -134,6 +143,19 @@
#projectPage > .rows > .row > .projectBoardLists > .list > .issues > .issueLink { #projectPage > .rows > .row > .projectBoardLists > .list > .issues > .issueLink {
display: block; display: block;
margin-bottom: 5px; margin-bottom: 5px;
position: relative;
}
#projectPage > .rows > .row > .projectBoardLists > .list > .issues > .issueLink > .dragCover {
display: block;
position: absolute;
z-index: 1;
width: 100%;
height: 100%;
}
#projectPage > .rows > .row > .projectBoardLists > .list > .issues > .issueLink > .dragCover:-moz-drag-over {
border: var(--borderInputFocus);
} }
#projectPage > .rows > .row > .projectBoardLists > .list > .issues > .issueLink > .issue { #projectPage > .rows > .row > .projectBoardLists > .list > .issues > .issueLink > .issue {

View File

@ -384,3 +384,40 @@ i.styledIcon.double-left:before {
i.styledIcon.double-right:before { i.styledIcon.double-right:before {
content: "\ea7c"; content: "\ea7c";
} }
i.styledIcon.task {
color: var(--task);
}
i.styledIcon.bug {
color: var(--bug);
}
i.styledIcon.story {
color: var(--story);
}
i.styledIcon.epic {
color: var(--epic);
}
i.styledIcon.highest {
color: var(--highest);
}
i.styledIcon.high {
color: var(--high);
}
i.styledIcon.medium {
color: var(--medium);
}
i.styledIcon.low {
color: var(--low);
}
i.styledIcon.lowest {
color: var(--lowest);
}

View File

@ -99,6 +99,7 @@ pub enum Msg {
// inputs // inputs
StrInputChanged(FieldId, String), StrInputChanged(FieldId, String),
U32InputChanged(FieldId, u32), U32InputChanged(FieldId, u32),
FileInputChanged(FieldId, Vec<File>), FileInputChanged(FieldId, Vec<File>),
// Rte(FieldId, RteMsg), // Rte(FieldId, RteMsg),

View File

@ -8,7 +8,7 @@ use {
ToNode, ToNode,
}, },
ws::send_ws_msg, ws::send_ws_msg,
FieldChange, FieldId, Msg, WebSocketChanged, FieldChange, FieldId, Msg, OperationKind, ResourceKind,
}, },
jirs_data::{TimeTracking, WsMsg}, jirs_data::{TimeTracking, WsMsg},
seed::{prelude::*, *}, seed::{prelude::*, *},
@ -37,24 +37,19 @@ pub fn update(msg: &Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>
model.modals.push(modal_type.as_ref().clone()); model.modals.push(modal_type.as_ref().clone());
} }
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::ProjectIssuesLoaded(_issues))) => { Msg::ResourceChanged(ResourceKind::Issue, OperationKind::ListLoaded, _) => {
match model.page { match model.page {
Page::EditIssue(issue_id) if model.modals.is_empty() => { Page::EditIssue(issue_id) if model.modals.is_empty() => {
push_edit_modal(issue_id, model, orders) push_edit_modal(issue_id, model, orders)
} }
Page::AddIssue if model.modals.is_empty() => push_add_modal(model, orders),
_ => (), _ => (),
} }
} }
Msg::ChangePage(Page::EditIssue(issue_id)) => { Msg::ChangePage(Page::EditIssue(issue_id)) => push_edit_modal(*issue_id, model, orders),
push_edit_modal(*issue_id, model, orders);
}
Msg::ChangePage(Page::AddIssue) => { Msg::ChangePage(Page::AddIssue) => push_add_modal(model, orders),
let mut modal = crate::modals::issues_create::Model::default();
modal.project_id = model.project.as_ref().map(|p| p.id);
model.modals.push(ModalType::AddIssue(Box::new(modal)));
}
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
Msg::GlobalKeyDown { key, .. } if key.eq("#") => { Msg::GlobalKeyDown { key, .. } if key.eq("#") => {
@ -123,6 +118,14 @@ pub fn view(model: &model::Model) -> Node<Msg> {
section![id!["modals"], modals] section![id!["modals"], modals]
} }
fn push_add_modal(model: &mut Model, _orders: &mut impl Orders<Msg>) {
use crate::modals::issues_create::Model;
model.modals.push(ModalType::AddIssue(Box::new(Model {
project_id: model.project.as_ref().map(|p| p.id),
..Model::default()
})));
}
fn push_edit_modal(issue_id: i32, model: &mut Model, orders: &mut impl Orders<Msg>) { fn push_edit_modal(issue_id: i32, model: &mut Model, orders: &mut impl Orders<Msg>) {
let time_tracking_type = model let time_tracking_type = model
.project .project

View File

@ -3,7 +3,7 @@ use {
model::{IssueModal, ModalType}, model::{IssueModal, ModalType},
shared::styled_select::StyledSelectChanged, shared::styled_select::StyledSelectChanged,
ws::send_ws_msg, ws::send_ws_msg,
FieldId, Msg, WebSocketChanged, FieldId, Msg, OperationKind, ResourceKind,
}, },
jirs_data::{IssueFieldId, UserId, WsMsg}, jirs_data::{IssueFieldId, UserId, WsMsg},
seed::prelude::*, seed::prelude::*,
@ -48,7 +48,7 @@ pub fn update(msg: &Msg, model: &mut crate::model::Model, orders: &mut impl Orde
time_remaining: modal.time_remaining, time_remaining: modal.time_remaining,
project_id: modal.project_id.unwrap_or(project_id), project_id: modal.project_id.unwrap_or(project_id),
user_ids: modal.user_ids.clone(), user_ids: modal.user_ids.clone(),
reporter_id: modal.reporter_id.unwrap_or_else(|| user_id), reporter_id: modal.reporter_id.unwrap_or(user_id),
epic_id: modal.epic_id, epic_id: modal.epic_id,
}; };
@ -64,12 +64,7 @@ pub fn update(msg: &Msg, model: &mut crate::model::Model, orders: &mut impl Orde
}; };
} }
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::IssueCreated(issue))) => { Msg::ResourceChanged(ResourceKind::Issue, OperationKind::SingleCreated, _) => {
model.issues.push(issue.clone());
orders.skip().send_msg(Msg::ModalDropped);
}
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::EpicCreated(_))) => {
orders.skip().send_msg(Msg::ModalDropped); orders.skip().send_msg(Msg::ModalDropped);
} }

View File

@ -34,6 +34,7 @@ pub struct Model {
pub time_remaining: StyledInputState, pub time_remaining: StyledInputState,
pub time_remaining_select: StyledSelectState, pub time_remaining_select: StyledSelectState,
pub title_state: StyledInputState,
pub description_state: StyledEditorState, pub description_state: StyledEditorState,
// comments // comments
@ -107,6 +108,11 @@ impl Model {
.map(|n| vec![n as u32]) .map(|n| vec![n as u32])
.unwrap_or_default(), .unwrap_or_default(),
), ),
title_state: StyledInputState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Title)),
issue.title.clone(),
)
.with_min(Some(3)),
description_state: StyledEditorState::new( description_state: StyledEditorState::new(
Mode::View, Mode::View,
issue.description_text.as_deref().unwrap_or(""), issue.description_text.as_deref().unwrap_or(""),
@ -159,5 +165,7 @@ impl IssueModal for Model {
self.time_remaining.update(msg); self.time_remaining.update(msg);
self.time_remaining_select.update(msg, orders); self.time_remaining_select.update(msg, orders);
self.epic_name_state.update(msg, orders); self.epic_name_state.update(msg, orders);
self.title_state.update(msg);
} }
} }

View File

@ -25,6 +25,8 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
issue.description_text.clone().unwrap_or_default(); issue.description_text.clone().unwrap_or_default();
} }
} }
// type
Msg::StyledSelectChanged( Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Type)), FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Type)),
StyledSelectChanged::Changed(Some(value)), StyledSelectChanged::Changed(Some(value)),
@ -40,6 +42,8 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
orders, orders,
); );
} }
// issue status id
Msg::StyledSelectChanged( Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::IssueStatusId)), FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::IssueStatusId)),
StyledSelectChanged::Changed(Some(value)), StyledSelectChanged::Changed(Some(value)),
@ -55,6 +59,8 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
orders, orders,
); );
} }
// reporter id
Msg::StyledSelectChanged( Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Reporter)), FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Reporter)),
StyledSelectChanged::Changed(Some(value)), StyledSelectChanged::Changed(Some(value)),
@ -70,6 +76,8 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
orders, orders,
); );
} }
// assignees
Msg::StyledSelectChanged( Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Assignees)), FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Assignees)),
StyledSelectChanged::Changed(Some(value)), StyledSelectChanged::Changed(Some(value)),
@ -89,8 +97,7 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Assignees)), FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Assignees)),
StyledSelectChanged::RemoveMulti(value), StyledSelectChanged::RemoveMulti(value),
) => { ) => {
let mut old = vec![]; let old = std::mem::replace(&mut modal.payload.user_ids, vec![]);
std::mem::swap(&mut old, &mut modal.payload.user_ids);
let dropped = *value as i32; let dropped = *value as i32;
for id in old.into_iter() { for id in old.into_iter() {
if id != dropped { if id != dropped {
@ -107,6 +114,8 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
orders, orders,
); );
} }
// priority
Msg::StyledSelectChanged( Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Priority)), FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Priority)),
StyledSelectChanged::Changed(Some(value)), StyledSelectChanged::Changed(Some(value)),
@ -122,6 +131,8 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
orders, orders,
); );
} }
// Title
Msg::StrInputChanged( Msg::StrInputChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Title)), FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Title)),
value, value,
@ -136,7 +147,10 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
model.ws.as_ref(), model.ws.as_ref(),
orders, orders,
); );
orders.skip();
} }
// Description
Msg::StrInputChanged( Msg::StrInputChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Description)), FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Description)),
value, value,

View File

@ -151,8 +151,7 @@ fn left_modal_column(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
.add_input_class("issueSummary") .add_input_class("issueSummary")
.add_wrapper_class("issueSummary") .add_wrapper_class("issueSummary")
.add_wrapper_class("textarea") .add_wrapper_class("textarea")
.value(payload.title.as_str()) .state(&modal.title_state)
.valid(payload.title.len() >= 3)
.build(FieldId::EditIssueModal(EditIssueModalSection::Issue( .build(FieldId::EditIssueModal(EditIssueModalSection::Issue(
IssueFieldId::Title, IssueFieldId::Title,
))) )))

View File

@ -62,7 +62,7 @@ impl Page {
match self { match self {
Page::Project => "/board".to_string(), Page::Project => "/board".to_string(),
Page::EditIssue(id) => format!("/issues/{id}", id = id), Page::EditIssue(id) => format!("/issues/{id}", id = id),
Page::AddIssue => "/add-issues".to_string(), Page::AddIssue => "/add-issue".to_string(),
Page::ProjectSettings => "/project-settings".to_string(), Page::ProjectSettings => "/project-settings".to_string(),
Page::SignIn => "/login".to_string(), Page::SignIn => "/login".to_string(),
Page::SignUp => "/register".to_string(), Page::SignUp => "/register".to_string(),
@ -163,6 +163,8 @@ pub struct Model {
pub user_projects: Vec<UserProject>, pub user_projects: Vec<UserProject>,
pub projects: Vec<Project>, pub projects: Vec<Project>,
pub epics: Vec<Epic>, pub epics: Vec<Epic>,
pub show_extras: bool,
} }
impl Model { impl Model {
@ -197,6 +199,7 @@ impl Model {
projects: vec![], projects: vec![],
epics: vec![], epics: vec![],
issues_by_id: Default::default(), issues_by_id: Default::default(),
show_extras: false,
} }
} }

View File

@ -76,7 +76,7 @@ pub fn view(model: &Model) -> Node<Msg> {
.add_field(submit_field) .add_field(submit_field)
.build() .build()
.into_node(); .into_node();
inner_layout(model, "profile", vec![content]) inner_layout(model, "profile", &[content])
} }
fn build_current_project(model: &Model, page: &ProfilePage) -> Node<Msg> { fn build_current_project(model: &Model, page: &ProfilePage) -> Node<Msg> {

View File

@ -38,31 +38,35 @@ impl ProjectPage {
let statuses = statuses.iter().map(|s| (s.id, s.name.as_str())); let statuses = statuses.iter().map(|s| (s.id, s.name.as_str()));
let mut issues: Vec<&Issue> = { let mut issues: Vec<&Issue> = issues.iter().collect();
if self.recently_updated_filter {
let mut m = HashMap::new(); let mut m = HashMap::new();
let mut sorted = vec![]; let mut sorted = vec![];
for issue in issues.iter() { for issue in issues.into_iter() {
sorted.push((issue.id, issue.updated_at)); sorted.push((issue.id, issue.updated_at));
m.insert(issue.id, issue); m.insert(issue.id, issue);
} }
sorted.sort_by(|(_, a_time), (_, b_time)| a_time.cmp(b_time)); sorted.sort_by(|(_, a_time), (_, b_time)| a_time.cmp(b_time));
sorted issues = sorted
.into_iter() .into_iter()
.take(10)
.flat_map(|(id, _)| m.remove(&id)) .flat_map(|(id, _)| m.remove(&id))
.collect() .collect();
}; issues.sort_by(|a, b| a.list_position.cmp(&b.list_position));
if self.recently_updated_filter {
issues = issues[0..10].to_vec()
} }
for epic in epics { for epic in epics {
let mut per_epic_map = EpicIssuePerStatus::default(); let mut per_epic_map = EpicIssuePerStatus {
per_epic_map.epic_name = epic.map(|(_, name)| name).unwrap_or_default().to_string(); epic_name: epic.map(|(_, name)| name).unwrap_or_default().to_string(),
..Default::default()
};
for (current_status_id, issue_status_name) in statuses.to_owned() { for (current_status_id, issue_status_name) in statuses.to_owned() {
let mut per_status_map = StatusIssueIds::default(); let mut per_status_map = StatusIssueIds {
per_status_map.status_id = current_status_id; status_id: current_status_id,
per_status_map.status_name = issue_status_name.to_string(); status_name: issue_status_name.to_string(),
..Default::default()
};
for issue in issues.iter() { for issue in issues.iter() {
if issue.epic_id == epic.map(|(id, _)| id) if issue.epic_id == epic.map(|(id, _)| id)
&& issue_filter_status(issue, current_status_id) && issue_filter_status(issue, current_status_id)

View File

@ -79,28 +79,58 @@ pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Order
} }
Msg::StrInputChanged(FieldId::TextFilterBoard, text) => { Msg::StrInputChanged(FieldId::TextFilterBoard, text) => {
project_page.text_filter = text; project_page.text_filter = text;
project_page.rebuild_visible(
&model.epics,
&model.issue_statuses,
&model.issues,
&model.user,
);
} }
Msg::ProjectAvatarFilterChanged(user_id, active) => { Msg::ProjectAvatarFilterChanged(user_id, active) => {
if active { if active {
project_page.active_avatar_filters = project_page project_page.active_avatar_filters =
.active_avatar_filters std::mem::replace(&mut project_page.active_avatar_filters, vec![])
.iter() .into_iter()
.filter_map(|id| if *id != user_id { Some(*id) } else { None }) .filter(|id| *id != user_id)
.collect(); .collect();
} else { } else {
project_page.active_avatar_filters.push(user_id); project_page.active_avatar_filters.push(user_id);
} }
project_page.rebuild_visible(
&model.epics,
&model.issue_statuses,
&model.issues,
&model.user,
);
} }
Msg::ProjectToggleOnlyMy => { Msg::ProjectToggleOnlyMy => {
project_page.only_my_filter = !project_page.only_my_filter; project_page.only_my_filter = !project_page.only_my_filter;
project_page.rebuild_visible(
&model.epics,
&model.issue_statuses,
&model.issues,
&model.user,
);
} }
Msg::ProjectToggleRecentlyUpdated => { Msg::ProjectToggleRecentlyUpdated => {
project_page.recently_updated_filter = !project_page.recently_updated_filter; project_page.recently_updated_filter = !project_page.recently_updated_filter;
project_page.rebuild_visible(
&model.epics,
&model.issue_statuses,
&model.issues,
&model.user,
);
} }
Msg::ProjectClearFilters => { Msg::ProjectClearFilters => {
project_page.active_avatar_filters = vec![]; project_page.active_avatar_filters = vec![];
project_page.recently_updated_filter = false; project_page.recently_updated_filter = false;
project_page.only_my_filter = false; project_page.only_my_filter = false;
project_page.rebuild_visible(
&model.epics,
&model.issue_statuses,
&model.issues,
&model.user,
);
} }
Msg::PageChanged(PageChanged::Board(BoardPageChange::IssueDragStarted(issue_id))) => { Msg::PageChanged(PageChanged::Board(BoardPageChange::IssueDragStarted(issue_id))) => {
crate::ws::issue::drag_started(issue_id, model) crate::ws::issue::drag_started(issue_id, model)

View File

@ -1,36 +1,31 @@
use { use {
crate::{ crate::{
model::{Model, Page, PageContent}, model::Model,
shared::{ shared::{inner_layout, styled_button::StyledButton, styled_icon::Icon, ToNode},
inner_layout, Msg,
styled_avatar::StyledAvatar,
styled_button::StyledButton,
styled_icon::{Icon, StyledIcon},
styled_input::StyledInput,
ToNode,
},
BoardPageChange, FieldId, Msg, PageChanged,
}, },
jirs_data::*,
seed::{prelude::*, *}, seed::{prelude::*, *},
}; };
mod board;
mod filters;
pub fn view(model: &Model) -> Node<Msg> { pub fn view(model: &Model) -> Node<Msg> {
let project_section = vec![ let project_section = [
breadcrumbs(model), breadcrumbs(model),
header(), header(model),
project_board_filters(model), filters::project_board_filters(model),
project_board_lists(model), board::project_board_lists(model),
]; ];
inner_layout(model, "projectPage", project_section) inner_layout(model, "projectPage", &project_section)
} }
fn breadcrumbs(model: &Model) -> Node<Msg> { fn breadcrumbs(model: &Model) -> Node<Msg> {
let project_name = model let project_name = model
.project .project
.as_ref() .as_ref()
.map(|p| p.name.clone()) .map(|p| p.name.as_str())
.unwrap_or_default(); .unwrap_or_default();
div![ div![
C!["breadcrumbsContainer"], C!["breadcrumbsContainer"],
@ -42,10 +37,13 @@ fn breadcrumbs(model: &Model) -> Node<Msg> {
] ]
} }
fn header() -> Node<Msg> { fn header(model: &Model) -> Node<Msg> {
if !model.show_extras {
return Node::Empty;
}
let button = StyledButton::build() let button = StyledButton::build()
.secondary() .secondary()
.text("Github Repo") .text("Repository")
.icon(Icon::Github) .icon(Icon::Github)
.build() .build()
.into_node(); .into_node();
@ -58,256 +56,3 @@ fn header() -> Node<Msg> {
] ]
] ]
} }
fn project_board_filters(model: &Model) -> Node<Msg> {
let project_page = match &model.page_content {
PageContent::Project(page_content) => page_content,
_ => return empty![],
};
let search_input = StyledInput::build()
.icon(Icon::Search)
.valid(true)
.value(project_page.text_filter.as_str())
.build(FieldId::TextFilterBoard)
.into_node();
let only_my = StyledButton::build()
.empty()
.active(project_page.only_my_filter)
.text("Only My Issues")
.add_class("filterChild")
.on_click(mouse_ev(Ev::Click, |_| Msg::ProjectToggleOnlyMy))
.build()
.into_node();
let recently_updated = StyledButton::build()
.empty()
.text("Recently Updated")
.add_class("filterChild")
.on_click(mouse_ev(Ev::Click, |_| Msg::ProjectToggleRecentlyUpdated))
.build()
.into_node();
let clear_all = if project_page.only_my_filter
|| project_page.recently_updated_filter
|| !project_page.active_avatar_filters.is_empty()
{
seed::button![
id!["clearAllFilters"],
C!["filterChild"],
"Clear all",
mouse_ev(Ev::Click, |_| Msg::ProjectClearFilters),
]
} else {
empty![]
};
div![
id!["projectBoardFilters"],
search_input,
avatars_filters(model),
only_my,
recently_updated,
clear_all
]
}
fn avatars_filters(model: &Model) -> Node<Msg> {
let project_page = match &model.page_content {
PageContent::Project(project_page) => project_page,
_ => return empty![],
};
let active_avatar_filters = &project_page.active_avatar_filters;
let avatars: Vec<Node<Msg>> = model
.users
.iter()
.enumerate()
.map(|(idx, user)| {
let mut class_list = vec!["avatarIsActiveBorder"];
let user_id = user.id;
let active = active_avatar_filters.contains(&user_id);
if active {
class_list.push("isActive");
}
let styled_avatar = StyledAvatar::build()
.avatar_url(user.avatar_url.as_deref().unwrap_or_default())
.on_click(mouse_ev(Ev::Click, move |_| {
Msg::ProjectAvatarFilterChanged(user_id, active)
}))
.name(user.name.as_str())
.user_index(idx)
.build()
.into_node();
div![attrs![At::Class => class_list.join(" ")], styled_avatar]
})
.collect();
div![id!["avatars"], C!["filterChild"], avatars]
}
fn project_board_lists(model: &Model) -> Node<Msg> {
let project_page = match &model.page_content {
PageContent::Project(project_page) => project_page,
_ => return empty![],
};
let rows = project_page.visible_issues.iter().map(|per_epic| {
let columns: Vec<Node<Msg>> = per_epic
.per_status_issues
.iter()
.map(|per_status| {
let issues: Vec<&Issue> = per_status
.issue_ids
.iter()
.filter_map(|id| model.issues_by_id.get(id))
.collect();
project_issue_list(
model,
per_status.status_id,
&per_status.status_name,
issues.as_slice(),
)
})
.collect();
div![
C!["row"],
div![C!["epicName"], per_epic.epic_name.as_str()],
div![C!["projectBoardLists"], columns]
]
});
div![C!["rows"], rows]
}
fn project_issue_list(
model: &Model,
status_id: IssueStatusId,
status_name: &str,
issues: &[&Issue],
) -> Node<Msg> {
let issues: Vec<Node<Msg>> = issues
.iter()
.map(|issue| project_issue(model, issue))
.collect();
let drop_handler = {
let send_status = status_id;
drag_ev(Ev::Drop, move |ev| {
ev.prevent_default();
Some(Msg::PageChanged(PageChanged::Board(
BoardPageChange::IssueDropZone(send_status),
)))
})
};
let drag_over_handler = {
let send_status = status_id;
drag_ev(Ev::DragOver, move |ev| {
ev.prevent_default();
Some(Msg::PageChanged(PageChanged::Board(
BoardPageChange::IssueDragOverStatus(send_status),
)))
})
};
div![
C!["list"],
div![C!["title"], status_name, div![C!["issuesCount"]]],
div![
C!["issues"],
attrs![At::DropZone => "link"],
drop_handler,
drag_over_handler,
issues
]
]
}
fn project_issue(model: &Model, issue: &Issue) -> Node<Msg> {
let avatars: Vec<Node<Msg>> = issue
.user_ids
.iter()
.filter_map(|id| model.users_by_id.get(id))
.map(|user| {
StyledAvatar::build()
.size(24)
.name(user.name.as_str())
.avatar_url(user.avatar_url.as_deref().unwrap_or_default())
.user_index(0)
.build()
.into_node()
})
.collect();
let issue_type_icon = StyledIcon::build(issue.issue_type.clone().into())
.with_color(issue.issue_type.to_str())
.build()
.into_node();
let priority_icon = {
let icon = match issue.priority {
IssuePriority::Low | IssuePriority::Lowest => Icon::ArrowDown,
_ => Icon::ArrowUp,
};
StyledIcon::build(icon)
.with_color(issue.priority.to_str())
.build()
.into_node()
};
let issue_id = issue.id;
let drag_started = drag_ev(Ev::DragStart, move |_| {
Some(Msg::PageChanged(PageChanged::Board(
BoardPageChange::IssueDragStarted(issue_id),
)))
});
let drag_stopped = drag_ev(Ev::DragEnd, move |_| {
Some(Msg::PageChanged(PageChanged::Board(
BoardPageChange::IssueDragStopped(issue_id),
)))
});
let drag_over_handler = drag_ev(Ev::DragOver, move |ev| {
ev.prevent_default();
ev.stop_propagation();
Some(Msg::PageChanged(PageChanged::Board(
BoardPageChange::ExchangePosition(issue_id),
)))
});
let drag_out = drag_ev(Ev::DragLeave, move |_| {
Some(Msg::PageChanged(PageChanged::Board(
BoardPageChange::DragLeave(issue_id),
)))
});
let on_click = mouse_ev("click", move |ev| {
ev.prevent_default();
ev.stop_propagation();
seed::Url::new()
.add_path_part("issues")
.add_path_part(format!("{}", issue_id))
.go_and_push();
Msg::ChangePage(Page::EditIssue(issue_id))
});
let href = format!("/issues/{id}", id = issue_id);
a![
drag_started,
on_click,
C!["issueLink"],
attrs![At::Href => href],
div![
C!["issue"],
attrs![At::Draggable => true],
drag_stopped,
drag_over_handler,
drag_out,
p![C!["title"], issue.title.as_str()],
div![
C!["bottom"],
div![
div![C!["issueTypeIcon"], issue_type_icon],
div![C!["issuePriorityIcon"], priority_icon]
],
div![C!["assignees"], avatars,],
]
]
]
}

View File

@ -0,0 +1,181 @@
use {
crate::{
model::PageContent,
shared::{styled_avatar::*, styled_icon::*, ToNode},
BoardPageChange, Model, Msg, Page, PageChanged,
},
jirs_data::*,
seed::{prelude::*, *},
};
pub fn project_board_lists(model: &Model) -> Node<Msg> {
let project_page = match &model.page_content {
PageContent::Project(project_page) => project_page,
_ => return empty![],
};
let rows = project_page.visible_issues.iter().map(|per_epic| {
let columns: Vec<Node<Msg>> = per_epic
.per_status_issues
.iter()
.map(|per_status| {
let issues: Vec<&Issue> = per_status
.issue_ids
.iter()
.filter_map(|id| model.issues_by_id.get(id))
.collect();
project_issue_list(
model,
per_status.status_id,
&per_status.status_name,
issues.as_slice(),
)
})
.collect();
div![
C!["row"],
div![C!["epicName"], per_epic.epic_name.as_str()],
div![C!["projectBoardLists"], columns]
]
});
div![C!["rows"], rows]
}
fn project_issue_list(
model: &Model,
status_id: IssueStatusId,
status_name: &str,
issues: &[&Issue],
) -> Node<Msg> {
let issues: Vec<Node<Msg>> = issues
.iter()
.map(|issue| project_issue(model, issue))
.collect();
let drop_handler = {
let send_status = status_id;
drag_ev(Ev::Drop, move |ev| {
ev.prevent_default();
Some(Msg::PageChanged(PageChanged::Board(
BoardPageChange::IssueDropZone(send_status),
)))
})
};
let drag_over_handler = {
let send_status = status_id;
drag_ev(Ev::DragOver, move |ev| {
ev.prevent_default();
Some(Msg::PageChanged(PageChanged::Board(
BoardPageChange::IssueDragOverStatus(send_status),
)))
})
};
div![
C!["list"],
div![C!["title"], status_name, div![C!["issuesCount"]]],
div![
C!["issues"],
attrs![At::DropZone => "link"],
drop_handler,
drag_over_handler,
issues
]
]
}
fn project_issue(model: &Model, issue: &Issue) -> Node<Msg> {
let is_dragging = match &model.page_content {
PageContent::Project(project_page) => project_page.issue_drag.is_dragging(),
_ => false,
};
let avatars: Vec<Node<Msg>> = issue
.user_ids
.iter()
.filter_map(|id| model.users_by_id.get(id))
.map(|user| {
StyledAvatar::build()
.size(24)
.name(user.name.as_str())
.avatar_url(user.avatar_url.as_deref().unwrap_or_default())
.user_index(0)
.build()
.into_node()
})
.collect();
let issue_type_icon = StyledIcon::build(issue.issue_type.clone().into())
.with_color(issue.issue_type.to_str())
.build()
.into_node();
let priority_icon = {
let icon = match issue.priority {
IssuePriority::Low | IssuePriority::Lowest => Icon::ArrowDown,
_ => Icon::ArrowUp,
};
StyledIcon::build(icon)
.add_class(issue.priority.to_str())
.with_color(issue.priority.to_str())
.build()
.into_node()
};
let issue_id = issue.id;
let drag_started = drag_ev(Ev::DragStart, move |_| {
Some(Msg::PageChanged(PageChanged::Board(
BoardPageChange::IssueDragStarted(issue_id),
)))
});
let drag_stopped = drag_ev(Ev::DragEnd, move |_| {
Some(Msg::PageChanged(PageChanged::Board(
BoardPageChange::IssueDragStopped(issue_id),
)))
});
let drag_over_handler = drag_ev(Ev::DragEnter, move |ev| {
ev.prevent_default();
ev.stop_propagation();
Some(Msg::PageChanged(PageChanged::Board(
BoardPageChange::ExchangePosition(issue_id),
)))
});
let drag_out = drag_ev(Ev::DragLeave, move |_| {
Some(Msg::PageChanged(PageChanged::Board(
BoardPageChange::DragLeave(issue_id),
)))
});
let on_click = mouse_ev("click", move |ev| {
ev.prevent_default();
ev.stop_propagation();
seed::Url::new()
.add_path_part("issues")
.add_path_part(format!("{}", issue_id))
.go_and_push();
Msg::ChangePage(Page::EditIssue(issue_id))
});
let href = format!("/issues/{id}", id = issue_id);
a![
drag_started,
on_click,
C!["issueLink"],
attrs![At::Href => href],
IF![is_dragging => div![C!["dragCover"], drag_over_handler]],
div![
C!["issue"],
attrs![At::Draggable => true],
drag_stopped,
drag_out,
p![C!["title"], issue.title.as_str()],
div![
C!["bottom"],
div![
div![C!["issueTypeIcon"], issue_type_icon],
div![C!["issuePriorityIcon"], priority_icon]
],
div![C!["assignees"], avatars,],
]
]
]
}

View File

@ -0,0 +1,95 @@
use {
crate::{
model::PageContent,
shared::{styled_avatar::*, styled_button::*, styled_icon::*, styled_input::*, ToNode},
FieldId, Model, Msg,
},
seed::{prelude::*, *},
};
pub fn project_board_filters(model: &Model) -> Node<Msg> {
let project_page = match &model.page_content {
PageContent::Project(page_content) => page_content,
_ => return empty![],
};
let search_input = StyledInput::build()
.icon(Icon::Search)
.valid(true)
.value(project_page.text_filter.as_str())
.build(FieldId::TextFilterBoard)
.into_node();
let only_my = StyledButton::build()
.empty()
.active(project_page.only_my_filter)
.text("Only My Issues")
.add_class("filterChild")
.on_click(mouse_ev(Ev::Click, |_| Msg::ProjectToggleOnlyMy))
.build()
.into_node();
let recently_updated = StyledButton::build()
.empty()
.text("Recently Updated")
.add_class("filterChild")
.on_click(mouse_ev(Ev::Click, |_| Msg::ProjectToggleRecentlyUpdated))
.build()
.into_node();
let clear_all = if project_page.only_my_filter
|| project_page.recently_updated_filter
|| !project_page.active_avatar_filters.is_empty()
{
seed::button![
id!["clearAllFilters"],
C!["filterChild"],
"Clear all",
mouse_ev(Ev::Click, |_| Msg::ProjectClearFilters),
]
} else {
empty![]
};
div![
id!["projectBoardFilters"],
search_input,
avatars_filters(model),
only_my,
recently_updated,
clear_all
]
}
pub fn avatars_filters(model: &Model) -> Node<Msg> {
let project_page = match &model.page_content {
PageContent::Project(project_page) => project_page,
_ => return empty![],
};
let active_avatar_filters = &project_page.active_avatar_filters;
let avatars: Vec<Node<Msg>> = model
.users
.iter()
.enumerate()
.map(|(idx, user)| {
let user_id = user.id;
let active = active_avatar_filters.contains(&user_id);
let styled_avatar = StyledAvatar::build()
.avatar_url(user.avatar_url.as_deref().unwrap_or_default())
.on_click(mouse_ev(Ev::Click, move |_| {
Msg::ProjectAvatarFilterChanged(user_id, active)
}))
.name(user.name.as_str())
.user_index(idx)
.build()
.into_node();
div![
if active { Some(C!["isActive"]) } else { None },
C!["avatarIsActiveBorder"],
styled_avatar
]
})
.collect();
div![id!["avatars"], C!["filterChild"], avatars]
}

View File

@ -105,9 +105,9 @@ pub fn view(model: &model::Model) -> Node<Msg> {
.build() .build()
.into_node(); .into_node();
let project_section = vec![div![C!["formContainer"], form]]; let project_section = [div![C!["formContainer"], form]];
inner_layout(model, "projectSettings", project_section) inner_layout(model, "projectSettings", &project_section)
} }
/// Build project name input with styled field wrapper /// Build project name input with styled field wrapper

View File

@ -29,7 +29,7 @@ pub fn view(model: &Model) -> Node<Msg> {
let body = section![C!["top"], h1![C!["header"], "Reports"], graph, list]; let body = section![C!["top"], h1![C!["header"], "Reports"], graph, list];
inner_layout(model, "reports", vec![body]) inner_layout(model, "reports", &[body])
} }
fn this_month_graph(page: &ReportsPage, this_month_updated: &[&Issue]) -> Node<Msg> { fn this_month_graph(page: &ReportsPage, this_month_updated: &[&Issue]) -> Node<Msg> {

View File

@ -167,9 +167,5 @@ pub fn view(model: &Model) -> Node<Msg> {
ul![C!["invitationsList"], invitations], ul![C!["invitationsList"], invitations],
]; ];
inner_layout( inner_layout(model, "users", &[form, users_section, invitations_section])
model,
"users",
vec![form, users_section, invitations_section],
)
} }

View File

@ -8,6 +8,11 @@ pub struct DragState {
} }
impl DragState { impl DragState {
#[inline]
pub fn is_dragging(&self) -> bool {
self.dragged_id.is_some()
}
#[inline] #[inline]
pub fn mark_dirty(&mut self, id: i32) { pub fn mark_dirty(&mut self, id: i32) {
self.dirty.insert(id); self.dirty.insert(id);

View File

@ -77,7 +77,7 @@ pub fn divider() -> Node<Msg> {
} }
#[inline] #[inline]
pub fn inner_layout(model: &Model, page_name: &str, children: Vec<Node<Msg>>) -> Node<Msg> { pub fn inner_layout(model: &Model, page_name: &str, children: &[Node<Msg>]) -> Node<Msg> {
let modal_node = crate::modal::view(model); let modal_node = crate::modal::view(model);
article![ article![
modal_node, modal_node,

View File

@ -103,7 +103,10 @@ pub fn render(model: &Model) -> Vec<Node<Msg>> {
C!["bottom"], C!["bottom"],
navbar_left_item("Profile", user_icon, Some("/profile"), Some(go_to_profile)), navbar_left_item("Profile", user_icon, Some("/profile"), Some(go_to_profile)),
messages, messages,
about_tooltip(model, navbar_left_item("About", Icon::Help, None, None)), IF![model.show_extras => about_tooltip(
model,
navbar_left_item("About", Icon::Help, None, None)
)],
], ],
], ],
] ]

View File

@ -145,17 +145,13 @@ pub fn render(values: StyledAvatar) -> Node<Msg> {
] ]
} }
_ => { _ => {
let style = format!(
"{shared}; width: {size}px; height: {size}px; font-size: calc({size}px / 1.7);",
shared = shared_style,
size = size
);
div![ div![
C!["styledAvatar letter"], C!["styledAvatar letter"],
class_list, class_list,
attrs![ attrs![
At::Class => format!("avatarColor{}", index + 1), At::Class => format!("avatarColor{}", index + 1),
At::Style => style At::Style => shared_style,
At::Title => name
], ],
span![letter], span![letter],
on_click, on_click,

View File

@ -32,6 +32,8 @@ pub struct StyledInputState {
id: FieldId, id: FieldId,
touched: bool, touched: bool,
pub value: String, pub value: String,
pub min: Option<usize>,
pub max: Option<usize>,
} }
impl StyledInputState { impl StyledInputState {
@ -44,9 +46,23 @@ impl StyledInputState {
id, id,
touched: false, touched: false,
value: value.into(), value: value.into(),
min: None,
max: None,
} }
} }
#[inline]
pub fn with_min(mut self, min: Option<usize>) -> Self {
self.min = min;
self
}
#[inline]
pub fn with_max(mut self, max: Option<usize>) -> Self {
self.max = max;
self
}
#[inline] #[inline]
pub fn to_i32(&self) -> Option<i32> { pub fn to_i32(&self) -> Option<i32> {
self.value.parse::<i32>().ok() self.value.parse::<i32>().ok()
@ -80,11 +96,11 @@ impl StyledInputState {
} }
#[derive(Debug)] #[derive(Debug)]
pub struct StyledInput<'l> { pub struct StyledInput<'l, 'm: 'l> {
id: FieldId, id: FieldId,
icon: Option<Icon>, icon: Option<Icon>,
valid: bool, valid: bool,
value: Option<String>, value: Option<&'m str>,
input_type: Option<&'l str>, input_type: Option<&'l str>,
input_class_list: Vec<&'l str>, input_class_list: Vec<&'l str>,
wrapper_class_list: Vec<&'l str>, wrapper_class_list: Vec<&'l str>,
@ -93,9 +109,9 @@ pub struct StyledInput<'l> {
input_handlers: Vec<EventHandler<Msg>>, input_handlers: Vec<EventHandler<Msg>>,
} }
impl<'l> StyledInput<'l> { impl<'l, 'm: 'l> StyledInput<'l, 'm> {
#[inline] #[inline]
pub fn build() -> StyledInputBuilder<'l> { pub fn build() -> StyledInputBuilder<'l, 'm> {
StyledInputBuilder { StyledInputBuilder {
icon: None, icon: None,
valid: None, valid: None,
@ -111,10 +127,10 @@ impl<'l> StyledInput<'l> {
} }
#[derive(Debug)] #[derive(Debug)]
pub struct StyledInputBuilder<'l> { pub struct StyledInputBuilder<'l, 'm: 'l> {
icon: Option<Icon>, icon: Option<Icon>,
valid: Option<bool>, valid: Option<bool>,
value: Option<String>, value: Option<&'m str>,
input_type: Option<&'l str>, input_type: Option<&'l str>,
input_class_list: Vec<&'l str>, input_class_list: Vec<&'l str>,
wrapper_class_list: Vec<&'l str>, wrapper_class_list: Vec<&'l str>,
@ -123,7 +139,7 @@ pub struct StyledInputBuilder<'l> {
input_handlers: Vec<EventHandler<Msg>>, input_handlers: Vec<EventHandler<Msg>>,
} }
impl<'l> StyledInputBuilder<'l> { impl<'l, 'm: 'l> StyledInputBuilder<'l, 'm> {
#[inline] #[inline]
pub fn icon(mut self, icon: Icon) -> Self { pub fn icon(mut self, icon: Icon) -> Self {
self.icon = Some(icon); self.icon = Some(icon);
@ -137,18 +153,27 @@ impl<'l> StyledInputBuilder<'l> {
} }
#[inline] #[inline]
pub fn value<S>(mut self, v: S) -> Self pub fn value(mut self, v: &'m str) -> Self {
where self.value = Some(v);
S: Into<String>,
{
self.value = Some(v.into());
self self
} }
#[inline] #[inline]
pub fn state(self, state: &StyledInputState) -> Self { pub fn state(self, state: &'m StyledInputState) -> Self {
self.value(state.value.as_str()) self.value(&state.value.as_str()).valid(
.valid(!state.touched || !state.value.is_empty()) match (
state.touched,
state.value.as_str(),
state.min.as_ref(),
state.max.as_ref(),
) {
(false, ..) => true,
(_, s, None, None) => !s.is_empty(),
(_, s, Some(min), None) => s.len() >= *min,
(_, s, None, Some(max)) => s.len() <= *max,
(_, s, Some(min), Some(max)) => s.len() >= *min && s.len() <= *max,
},
)
} }
#[inline] #[inline]
@ -182,7 +207,7 @@ impl<'l> StyledInputBuilder<'l> {
} }
#[inline] #[inline]
pub fn build(self, id: FieldId) -> StyledInput<'l> { pub fn build(self, id: FieldId) -> StyledInput<'l, 'm> {
StyledInput { StyledInput {
id, id,
icon: self.icon, icon: self.icon,
@ -198,7 +223,7 @@ impl<'l> StyledInputBuilder<'l> {
} }
} }
impl<'l> ToNode for StyledInput<'l> { impl<'l, 'm: 'l> ToNode for StyledInput<'l, 'm> {
#[inline] #[inline]
fn into_node(self) -> Node<Msg> { fn into_node(self) -> Node<Msg> {
render(self) render(self)
@ -212,70 +237,50 @@ pub fn render(values: StyledInput) -> Node<Msg> {
valid, valid,
value, value,
input_type, input_type,
mut input_class_list, input_class_list,
mut wrapper_class_list, wrapper_class_list,
variant, variant,
auto_focus, auto_focus,
input_handlers, input_handlers,
} = values; } = values;
wrapper_class_list.push(variant.to_str()); let icon_node = icon
if !valid {
wrapper_class_list.push("invalid");
}
input_class_list.push(variant.to_str());
if icon.is_some() {
input_class_list.push("withIcon");
}
let icon = icon
.map(|icon| StyledIcon::build(icon).build().into_node()) .map(|icon| StyledIcon::build(icon).build().into_node())
.unwrap_or(Node::Empty); .unwrap_or(Node::Empty);
let on_input = { let on_change = {
let field_id = id.clone(); let field_id = id.clone();
ev(Ev::Input, move |event| { ev(Ev::Change, move |event| {
event.stop_propagation(); event.stop_propagation();
let target = event.target().unwrap(); let target = event.target().unwrap();
let input = seed::to_input(&target); Msg::StrInputChanged(field_id, seed::to_input(&target).value())
let value = input.value();
Msg::StrInputChanged(field_id, value)
}) })
}; };
let on_keyup = ev(Ev::KeyUp, move |event| {
event.stop_propagation();
None as Option<Msg>
});
let on_click = ev(Ev::Click, move |event| {
event.stop_propagation();
None as Option<Msg>
});
div![ div![
C!["styledInput"], C!["styledInput"],
C![variant.to_str()],
if !valid { Some(C!["invalid"]) } else { None },
attrs!( attrs!(
At::Class => wrapper_class_list.join(" "), "class" => format!("{} {}", id, wrapper_class_list.join(" ")),
At::Class => format!("{}", id),
), ),
icon, icon_node,
on_click,
on_keyup,
seed::input![ seed::input![
C!["inputElement"], C!["inputElement"],
icon.as_ref().map(|_| C!["withIcon"]),
C![variant.to_str()],
attrs![ attrs![
At::Id => format!("{}", id), "id" => format!("{}", id),
At::Class => input_class_list.join(" "), At::Class => input_class_list.join(" "),
At::Value => value.unwrap_or_default(), "value" => value.unwrap_or_default(),
At::Type => input_type.unwrap_or("text"), "type" => input_type.unwrap_or("text"),
], ],
if auto_focus { if auto_focus {
vec![attrs![At::AutoFocus => true]] vec![attrs![At::AutoFocus => true]]
} else { } else {
vec![] vec![]
}, },
on_input, on_change,
input_handlers, input_handlers,
], ],
] ]

View File

@ -15,67 +15,88 @@ pub fn drag_started(issue_id: IssueId, model: &mut Model) {
project_page.issue_drag.drag(issue_id); project_page.issue_drag.drag(issue_id);
} }
pub fn exchange_position(issue_bellow_id: IssueId, model: &mut Model) { pub fn exchange_position(below_id: IssueId, model: &mut Model) {
let project_page = match &mut model.page_content { let project_page = match &mut model.page_content {
PageContent::Project(project_page) => project_page, PageContent::Project(project_page) => project_page,
_ => return, _ => return,
}; };
if project_page.issue_drag.dragged_or_last(issue_bellow_id) {
return;
}
let dragged_id = match project_page.issue_drag.dragged_id.as_ref().cloned() { let dragged_id = match project_page.issue_drag.dragged_id.as_ref().cloned() {
Some(id) => id, Some(id) => id,
_ => return error!("Nothing is dragged"), _ => return error!("Nothing is dragged"),
}; };
if below_id == dragged_id {
let mut below = None; return;
let mut dragged = None;
let mut issues = vec![];
std::mem::swap(&mut issues, &mut model.issues);
for issue in issues.into_iter() {
match issue.id {
id if id == issue_bellow_id => below = Some(issue),
id if id == dragged_id => dragged = Some(issue),
_ => model.issues.push(issue),
};
} }
let below_idx = model
let mut below = match below {
Some(below) => below,
_ => return,
};
let mut dragged = match dragged {
Some(issue) => issue,
_ => {
model.issues.push(below);
return;
}
};
if dragged.issue_status_id != below.issue_status_id {
let mut issues = vec![];
std::mem::swap(&mut issues, &mut model.issues);
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;
project_page.issue_drag.mark_dirty(c.id);
}
model.issues.push(c);
}
dragged.list_position = below.list_position + 1;
dragged.issue_status_id = below.issue_status_id;
}
std::mem::swap(&mut dragged.list_position, &mut below.list_position);
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 .issues
.sort_by(|a, b| a.list_position.cmp(&b.list_position)); .iter()
project_page.issue_drag.last_id = Some(issue_bellow_id); .position(|issue| issue.id == below_id)
.unwrap_or(model.issues.len());
let dragged = model
.issues
.iter()
.position(|issue| issue.id == dragged_id)
.map(|idx| model.issues.remove(idx))
.unwrap();
let epic_id = dragged.epic_id;
model.issues.insert(below_idx, dragged);
let changed: Vec<(IssueId, i32)> = model
.issues
.iter_mut()
.filter(|issue| issue.epic_id == epic_id)
.enumerate()
.map(|(idx, issue)| {
issue.list_position = idx as i32;
(issue.id, issue.list_position)
})
.collect();
for (id, pos) in changed {
if let Some(iss) = model.issues_by_id.get_mut(&id) {
iss.list_position = pos;
}
}
// let dragged_pos = match model.issues_by_id.get(&dragged_id) {
// Some(i) => i.list_position,
// _ => return,
// };
// let below_pos = match model.issues_by_id.get(&below_id) {
// Some(i) => i.list_position,
// _ => return,
// };
// use seed::*;
// log!(format!(
// "exchange dragged {} {} below {} {}",
// dragged_id, dragged_pos, below_id, below_pos
// ));
// for issue in model.issues_by_id.values_mut() {
// if issue.id == below_id {
// issue.list_position = dragged_pos;
// } else if issue.id == dragged_id {
// issue.list_position = below_pos;
// }
// }
//
// for issue in model.issues.iter_mut() {
// if issue.id == below_id {
// issue.list_position = dragged_pos;
// } else if issue.id == dragged_id {
// issue.list_position = below_pos;
// }
// }
// model
// .issues
// .sort_by(|a, b| a.list_position.cmp(&b.list_position));
if let PageContent::Project(project_page) = &mut model.page_content {
project_page.rebuild_visible(
&model.epics,
&model.issue_statuses,
&model.issues,
&model.user,
);
project_page.issue_drag.mark_dirty(dragged_id);
project_page.issue_drag.mark_dirty(below_id);
}
} }
pub fn sync(model: &mut Model, orders: &mut impl Orders<Msg>) { pub fn sync(model: &mut Model, orders: &mut impl Orders<Msg>) {

View File

@ -3,15 +3,24 @@ use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct Configuration { pub struct Configuration {
pub concurrency: usize, pub concurrency: usize,
#[serde(default = "Configuration::default_theme")]
pub theme: String,
} }
impl Default for Configuration { impl Default for Configuration {
fn default() -> Self { fn default() -> Self {
Self { concurrency: 2 } Self {
concurrency: 2,
theme: Self::default_theme(),
}
} }
} }
impl Configuration { impl Configuration {
crate::rw!("highlight.toml"); crate::rw!("highlight.toml");
fn default_theme() -> String {
"Github".to_string()
}
} }
crate::read!(Configuration); crate::read!(Configuration);