From adea6d4b4a8b88bad480cd315cec58af2767a2b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Wo=C5=BAniak?= Date: Wed, 20 Jan 2021 22:05:10 +0100 Subject: [PATCH] Add issues and filters --- jirs-client/js/css/issuesAndFilters.scss | 135 +++++++++++++++ jirs-client/js/styles.css | 9 +- jirs-client/src/components/styled_link.rs | 18 +- jirs-client/src/lib.rs | 6 + jirs-client/src/model.rs | 38 +++-- .../src/pages/issues_and_filters/mod.rs | 5 + .../src/pages/issues_and_filters/model.rs | 26 +++ .../src/pages/issues_and_filters/update.rs | 45 +++++ .../src/pages/issues_and_filters/view.rs | 30 ++++ .../pages/issues_and_filters/view/filters.rs | 8 + .../issues_and_filters/view/issue_info.rs | 160 ++++++++++++++++++ jirs-client/src/pages/mod.rs | 1 + jirs-client/src/shared/aside.rs | 7 +- jirs-client/src/shared/navbar_left.rs | 11 +- jirs-client/src/ws/mod.rs | 15 +- jirs-server/src/main.rs | 1 - 16 files changed, 485 insertions(+), 30 deletions(-) create mode 100644 jirs-client/js/css/issuesAndFilters.scss create mode 100644 jirs-client/src/pages/issues_and_filters/mod.rs create mode 100644 jirs-client/src/pages/issues_and_filters/model.rs create mode 100644 jirs-client/src/pages/issues_and_filters/update.rs create mode 100644 jirs-client/src/pages/issues_and_filters/view.rs create mode 100644 jirs-client/src/pages/issues_and_filters/view/filters.rs create mode 100644 jirs-client/src/pages/issues_and_filters/view/issue_info.rs diff --git a/jirs-client/js/css/issuesAndFilters.scss b/jirs-client/js/css/issuesAndFilters.scss new file mode 100644 index 00000000..11ce9b9d --- /dev/null +++ b/jirs-client/js/css/issuesAndFilters.scss @@ -0,0 +1,135 @@ +#issuesAndFilters { + display: block; + + > .container { + display: flex; + justify-content: space-between; + + --listWidth: 338px; + + > .issueInfo { + width: calc(100% - var(--listWidth)); + padding: { + left: 10px; + right: 10px; + top: 5px; + bottom: 5px; + }; + + > .header { + display: grid; + grid-template-areas: "icon link" "icon name"; + grid-template-columns: 48px; + + > .logo { + width: 48px; + } + + > .path { + display: flex; + justify-content: space-between; + + > .styledLink { + color: var(--textLink); + + > span { + color: var(--textLink); + } + } + } + + > .title { + } + } + + > .issueBody { + display: flex; + + > .details { + list-style: none; + + > .line { + --lineWidth: 460px; + --nameWidth: 150px; + + list-style: none; + width: var(--lineWidth); + display: flex; + justify-content: space-between; + padding: { + top: 2px; + bottom: 2px; + left: 5px; + right: 5px; + }; + font-size: 14px; + + > .detailsTitle { + color: var(--textLight); + width: var(--nameWidth); + font-size: 14px; + } + + > .detailsValue { + width: calc(var(--lineWidth) - var(--nameWidth)); + font-size: 14px; + } + } + } + } + } + + > .issuesList { + width: var(--listWidth); + list-style: none; + + > .listItem { + list-style: none; + + > .issue { + display: grid; + grid-template-areas: "type number" "priority name"; + grid-template-columns: 32px auto; + border: { + bottom: 1px solid var(--borderLight); + } + padding: { + left: 10px; + right: 10px; + top: 5px; + bottom: 5px; + }; + cursor: pointer; + + &.active { + background-color: var(--backgroundLightPrimary); + } + + &:hover { + background-color: var(--backgroundLightest); + + &.active { + background-color: var(--backgroundLightPrimary); + } + } + + > .type { + grid-area: type; + } + + > .number { + grid-area: number; + } + + > .priority { + grid-area: priority; + } + + > .name { + grid-area: name; + } + } + } + } + } +} diff --git a/jirs-client/js/styles.css b/jirs-client/js/styles.css index c38e4fa7..be5e250c 100644 --- a/jirs-client/js/styles.css +++ b/jirs-client/js/styles.css @@ -9,23 +9,24 @@ @import "css/shared.scss"; @import "css/styledTooltip.scss"; @import "css/styledAvatar.scss"; -@import "./css/styledSelect.scss"; -@import "./css/styledSelectChild.scss"; +@import "css/styledSelect.scss"; +@import "css/styledSelectChild.scss"; @import "css/styledButton.scss"; @import "css/styledInput.scss"; @import "css/styledImageInput.scss"; -@import "./css/styledModal.scss"; +@import "css/styledModal.scss"; @import "css/styledTextArea.scss"; @import "css/styledForm.scss"; @import "css/styledEditor.scss"; @import "css/styledComment.scss"; @import "css/styledPage.scss"; -@import "./css/styledLink.scss"; +@import "css/styledLink.scss"; @import "css/styledRte.scss"; @import "css/styledDateTimeInput.scss"; @import "css/app.scss"; @import "css/issue.scss"; @import "css/project.scss"; +@import "css/issuesAndFilters.scss"; @import "css/projectSettings.scss"; @import "css/timeTracking.scss"; @import "css/styledCheckbox.scss"; diff --git a/jirs-client/src/components/styled_link.rs b/jirs-client/src/components/styled_link.rs index 4e2ad67b..3f9c3173 100644 --- a/jirs-client/src/components/styled_link.rs +++ b/jirs-client/src/components/styled_link.rs @@ -8,6 +8,7 @@ pub struct StyledLink<'l> { children: Vec>, class_list: Vec<&'l str>, href: &'l str, + disabled: bool, } impl<'l> StyledLink<'l> { @@ -21,6 +22,7 @@ pub struct StyledLinkBuilder<'l> { children: Vec>, class_list: Vec<&'l str>, href: &'l str, + disabled: bool, } impl<'l> StyledLinkBuilder<'l> { @@ -44,6 +46,11 @@ impl<'l> StyledLinkBuilder<'l> { self } + pub fn disabled(mut self) -> Self { + self.disabled = true; + self + } + pub fn text(self, s: &'l str) -> Self { self.add_child(span![s]) } @@ -53,6 +60,7 @@ impl<'l> StyledLinkBuilder<'l> { children: self.children, class_list: self.class_list, href: self.href, + disabled: self.disabled, } } } @@ -68,11 +76,14 @@ pub fn render(values: StyledLink) -> Node { children, class_list, href, + disabled, } = values; - let on_click = { + let on_click = if disabled { + None + } else { let href = href.to_string(); - mouse_ev("click", move |ev| { + Some(mouse_ev("click", move |ev| { if href.starts_with('/') { ev.prevent_default(); ev.stop_propagation(); @@ -82,7 +93,7 @@ pub fn render(values: StyledLink) -> Node { } None as Option - }) + })) }; a![ @@ -91,6 +102,7 @@ pub fn render(values: StyledLink) -> Node { At::Class => class_list.join(" "), At::Href => href, ], + IF![disabled => attrs![At::OnClick => "return false"]], on_click, children, ] diff --git a/jirs-client/src/lib.rs b/jirs-client/src/lib.rs index 2023523b..27162087 100644 --- a/jirs-client/src/lib.rs +++ b/jirs-client/src/lib.rs @@ -107,6 +107,9 @@ pub enum Msg { AddIssue, DeleteIssue(EpicId), + // issues and filters + SetActiveIssue(Option), + // epics AddEpic, DeleteEpic, @@ -250,6 +253,7 @@ fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders) { Page::Users => pages::users_page::update(msg, model, orders), Page::Profile => pages::profile_page::update(msg, model, orders), Page::Reports => pages::reports_page::update(msg, model, orders), + Page::IssuesAndFilters => pages::issues_and_filters::update(msg, model, orders), } if cfg!(features = "print-model") { log!(model); @@ -270,6 +274,7 @@ fn view(model: &model::Model) -> Node { Page::Users => pages::users_page::view(model), Page::Profile => pages::profile_page::view(model), Page::Reports => pages::reports_page::view(model), + Page::IssuesAndFilters => pages::issues_and_filters::view(model), } } @@ -285,6 +290,7 @@ fn resolve_page(url: Url) -> Option { Some(Ok(id)) => Page::EditIssue(id), _ => return None, }, + "issues-and-filters" => Page::IssuesAndFilters, "add-issue" => Page::AddIssue, "project-settings" => Page::ProjectSettings, "login" => Page::SignIn, diff --git a/jirs-client/src/model.rs b/jirs-client/src/model.rs index da4a18b5..ecb54d5d 100644 --- a/jirs-client/src/model.rs +++ b/jirs-client/src/model.rs @@ -12,7 +12,7 @@ use { jirs_data::*, seed::{app::Orders, browser::web_socket::WebSocket}, serde::{Deserialize, Serialize}, - std::collections::hash_map::HashMap, + std::{borrow::Cow, collections::hash_map::HashMap}, uuid::Uuid, }; @@ -83,23 +83,25 @@ pub enum Page { Users, Profile, Reports, + IssuesAndFilters, } impl Page { - pub fn to_path(self) -> String { + pub fn to_path(self) -> std::borrow::Cow<'static, str> { match self { - Page::Project => "/board".to_string(), - Page::DeleteEpic(id) => format!("/delete-epic/{id}", id = id), - Page::EditEpic(id) => format!("/edit-epic/{id}", id = id), - Page::EditIssue(id) => format!("/issues/{id}", id = id), - Page::AddIssue => "/add-issue".to_string(), - Page::ProjectSettings => "/project-settings".to_string(), - Page::SignIn => "/login".to_string(), - Page::SignUp => "/register".to_string(), - Page::Invite => "/invite".to_string(), - Page::Users => "/users".to_string(), - Page::Profile => "/profile".to_string(), - Page::Reports => "/reports".to_string(), + Page::Project => Cow::Borrowed("/board"), + Page::DeleteEpic(id) => Cow::Owned(format!("/delete-epic/{id}", id = id)), + Page::EditEpic(id) => Cow::Owned(format!("/edit-epic/{id}", id = id)), + Page::EditIssue(id) => Cow::Owned(format!("/issues/{id}", id = id)), + Page::AddIssue => Cow::Borrowed("/add-issue"), + Page::ProjectSettings => Cow::Borrowed("/project-settings"), + Page::SignIn => Cow::Borrowed("/login"), + Page::SignUp => Cow::Borrowed("/register"), + Page::Invite => Cow::Borrowed("/invite"), + Page::Users => Cow::Borrowed("/users"), + Page::Profile => Cow::Borrowed("/profile"), + Page::Reports => Cow::Borrowed("/reports"), + Page::IssuesAndFilters => Cow::Borrowed("/issues-and-filters"), } } } @@ -137,9 +139,12 @@ impl Default for InvitationFormState { #[macro_export] macro_rules! match_page { ($model: ident, $ty: ident) => { + $crate::match_page!($model, $ty, ()) + }; + ($model: ident, $ty: ident, $ret: expr) => { match &$model.page_content { - PageContent::$ty(page) => page, - _ => return, + $crate::model::PageContent::$ty(page) => page, + _ => return $ret, } }; } @@ -163,6 +168,7 @@ pub enum PageContent { Users(Box), Profile(Box), Reports(Box), + IssuesAndFilters(Box), } #[derive(Debug)] diff --git a/jirs-client/src/pages/issues_and_filters/mod.rs b/jirs-client/src/pages/issues_and_filters/mod.rs new file mode 100644 index 00000000..aecce944 --- /dev/null +++ b/jirs-client/src/pages/issues_and_filters/mod.rs @@ -0,0 +1,5 @@ +pub use {model::*, update::*, view::*}; + +mod model; +mod update; +mod view; diff --git a/jirs-client/src/pages/issues_and_filters/model.rs b/jirs-client/src/pages/issues_and_filters/model.rs new file mode 100644 index 00000000..3c171982 --- /dev/null +++ b/jirs-client/src/pages/issues_and_filters/model.rs @@ -0,0 +1,26 @@ +use { + crate::model, + jirs_data::{Issue, IssueId}, +}; + +#[derive(Debug)] +pub struct Model { + pub visible_issues: Vec, + pub active_id: Option, +} + +impl Model { + pub fn new(model: &model::Model) -> Self { + let visible_issues = Self::visible_issues(model.issues()); + let active_id = model.issues().first().as_ref().map(|issue| issue.id); + + Self { + visible_issues, + active_id, + } + } + + pub fn visible_issues(issues: &[Issue]) -> Vec { + issues.iter().map(|issue| issue.id).collect() + } +} diff --git a/jirs-client/src/pages/issues_and_filters/update.rs b/jirs-client/src/pages/issues_and_filters/update.rs new file mode 100644 index 00000000..2ecd8c24 --- /dev/null +++ b/jirs-client/src/pages/issues_and_filters/update.rs @@ -0,0 +1,45 @@ +use { + crate::{ + model::{Model, PageContent}, + ws::board_load, + Msg, OperationKind, Page, ResourceKind, + }, + seed::prelude::*, +}; + +pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders) { + match msg { + Msg::ResourceChanged(ResourceKind::Auth, OperationKind::SingleLoaded, Some(_)) + | Msg::ChangePage(Page::IssuesAndFilters) => { + board_load(model, orders); + build_page_content(model); + } + _ => (), + } + + match msg { + Msg::ResourceChanged(ResourceKind::Issue, OperationKind::ListLoaded, _) => { + let issues = super::Model::visible_issues(model.issues()); + let first_id = model.issues().first().as_ref().map(|issue| issue.id); + let page = crate::match_page_mut!(model, IssuesAndFilters); + if page.active_id.is_none() { + page.active_id = first_id; + } + page.visible_issues = issues; + } + Msg::SetActiveIssue(Some(id)) => { + let page = crate::match_page_mut!(model, IssuesAndFilters); + page.active_id = Some(id); + } + Msg::SetActiveIssue(None) => { + let first_id = model.issues().first().as_ref().map(|issue| issue.id); + let page = crate::match_page_mut!(model, IssuesAndFilters); + page.active_id = first_id; + } + _ => {} + } +} + +fn build_page_content(model: &mut Model) { + model.page_content = PageContent::IssuesAndFilters(Box::new(super::Model::new(model))); +} diff --git a/jirs-client/src/pages/issues_and_filters/view.rs b/jirs-client/src/pages/issues_and_filters/view.rs new file mode 100644 index 00000000..8652ebc8 --- /dev/null +++ b/jirs-client/src/pages/issues_and_filters/view.rs @@ -0,0 +1,30 @@ +mod filters; +mod issue_info; + +use { + crate::{model::Model, shared::inner_layout, Msg}, + seed::{prelude::*, *}, +}; + +pub fn view(model: &Model) -> Node { + let page = crate::match_page!(model, IssuesAndFilters, Node::Empty); + let project_name = model + .project + .as_ref() + .map(|p| p.name.as_str()) + .unwrap_or_default(); + let details = match page.active_id.and_then(|id| model.issues_by_id.get(&id)) { + Some(issue) => issue_info::issue_details(model, issue, project_name), + _ => Node::Empty, + }; + let content = div![ + C!["container"], + issue_info::issues_list(model, page, project_name), + details + ]; + inner_layout( + model, + "issuesAndFilters", + &[filters::filters(model, page), content], + ) +} diff --git a/jirs-client/src/pages/issues_and_filters/view/filters.rs b/jirs-client/src/pages/issues_and_filters/view/filters.rs new file mode 100644 index 00000000..7e942d6e --- /dev/null +++ b/jirs-client/src/pages/issues_and_filters/view/filters.rs @@ -0,0 +1,8 @@ +use { + crate::{model::Model, Msg}, + seed::{prelude::*, *}, +}; + +pub fn filters(_model: &Model, _page: &super::super::Model) -> Node { + div![C!["filters"]] +} diff --git a/jirs-client/src/pages/issues_and_filters/view/issue_info.rs b/jirs-client/src/pages/issues_and_filters/view/issue_info.rs new file mode 100644 index 00000000..a6c8bb02 --- /dev/null +++ b/jirs-client/src/pages/issues_and_filters/view/issue_info.rs @@ -0,0 +1,160 @@ +use { + crate::{ + components::{styled_icon::*, styled_link::*}, + model::Model, + shared::ToNode, + Msg, + }, + jirs_data::{Issue, IssueId}, + seed::{prelude::*, *}, +}; + +pub fn issue_details(model: &Model, issue: &Issue, project_name: &str) -> Node { + let Issue { + id, + title, + issue_type, + priority, + description, + issue_status_id, + epic_id, + created_at: _, + updated_at: _, + user_ids: _, + .. + } = issue; + + let issue_link = StyledLink::build() + .href(format!("/issues/{}", id).as_str()) + .text(format!("{}-{}", project_name, id).as_str()) + .disabled() + .build() + .into_node(); + + let project_link = StyledLink::build() + .disabled() + .href("/board") + .text( + model + .project + .as_ref() + .map(|p| p.name.as_str()) + .unwrap_or_default(), + ) + .build() + .into_node(); + + let details = { + let issue_type = { + let type_name = issue_type.to_string(); + let type_icon = Icon::from(*issue_type).into_node(); + li![ + C!["line"], + div![C!["detailsTitle"], "Type:"], + div![C!["type detailsValue"], type_icon, type_name], + ] + }; + let priority = { + let name = priority.to_string(); + let icon = Icon::from(*priority).into_node(); + li![ + C!["line"], + div![C!["detailsTitle"], "Priority:"], + div![C!["priority detailsValue"], icon, name], + ] + }; + + let epic = li![ + C!["line"], + div![C!["detailsTitle"], "Epic link:"], + match epic_id.and_then(|id| model.epics_by_id.get(&id)) { + Some(epic) => div![C!["detailsValue epic"], a![epic.name.as_str()]], + _ => div![C!["detailsValue epic"], "None"], + }, + ]; + + let status = li![ + C!["line"], + div![C!["detailsTitle"], "Status:"], + div![C!["detailsValue status"], { + match model.issue_statuses_by_id.get(issue_status_id) { + Some(status) => status.name.as_str(), + _ => "", + } + }] + ]; + + ul![C!["details"], issue_type, priority, epic, status] + }; + + let right_column = div![]; + + div![ + C!["issueInfo"], + div![ + C!["header"], + div![C!["logo"], "X"], + div![C!["title"], title.as_str()], + div![C!["path"], project_link, "/", issue_link] + ], + div![C!["issueBody"], details, right_column], + div![ + C!["description"], + raw! { description.as_deref().unwrap_or_default() } + ] + ] +} + +pub fn issues_list(model: &Model, page: &super::super::Model, project_name: &str) -> Node { + let issues: Vec> = page + .visible_issues + .iter() + .filter_map(|id| model.issues_by_id.get(id)) + .map(|issue| issue_entry(page, issue, project_name)) + .collect(); + ul![C!["issuesList"], issues] +} + +fn issue_entry(page: &super::super::Model, issue: &Issue, project_name: &str) -> Node { + let link = issue_link(issue.id, project_name); + let ty = { + let icon: Icon = issue.issue_type.into(); + icon.into_node() + }; + let priority = { + let icon: Icon = issue.priority.into(); + icon.into_node() + }; + + let on_click = { + let id = issue.id; + mouse_ev("click", move |ev| { + ev.stop_propagation(); + ev.prevent_default(); + Msg::SetActiveIssue(Some(id)) + }) + }; + + li![ + C!["listItem"], + on_click, + a![ + C!["issue"], + IF![page.active_id == Some(issue.id) => C!["active"]], + div![C!["number"], link], + div![C!["name"], issue.title.as_str()], + div![C!["type"], ty], + div![C!["priority"], priority] + ] + ] +} + +fn issue_link(id: IssueId, project_name: &str) -> Node { + StyledLink::build() + .with_icon() + .href(format!("/issues/{}", id).as_str()) + .text(format!("{}-{}", project_name, id).as_str()) + .disabled() + .build() + .into_node() +} diff --git a/jirs-client/src/pages/mod.rs b/jirs-client/src/pages/mod.rs index 69545856..54f85659 100644 --- a/jirs-client/src/pages/mod.rs +++ b/jirs-client/src/pages/mod.rs @@ -1,4 +1,5 @@ pub mod invite_page; +pub mod issues_and_filters; pub mod profile_page; pub mod project_page; pub mod project_settings_page; diff --git a/jirs-client/src/shared/aside.rs b/jirs-client/src/shared/aside.rs index 286711dd..7a1bc340 100644 --- a/jirs-client/src/shared/aside.rs +++ b/jirs-client/src/shared/aside.rs @@ -57,7 +57,12 @@ pub fn render(model: &Model) -> Node { project_settings(model), li![divider()], sidebar_link_item(model, "Releases", Icon::Shipping, None), - sidebar_link_item(model, "Issue and Filters", Icon::Issues, None), + sidebar_link_item( + model, + "Issue and Filters", + Icon::Issues, + Some(Page::IssuesAndFilters) + ), sidebar_link_item(model, "Pages", Icon::Page, None), sidebar_link_item(model, "Reports", Icon::Reports, Some(Page::Reports)), sidebar_link_item(model, "Components", Icon::Component, None), diff --git a/jirs-client/src/shared/navbar_left.rs b/jirs-client/src/shared/navbar_left.rs index 06c91b8b..9d026d17 100644 --- a/jirs-client/src/shared/navbar_left.rs +++ b/jirs-client/src/shared/navbar_left.rs @@ -77,7 +77,16 @@ pub fn render(model: &Model) -> Vec> { vec![] } else { vec![ - navbar_left_item("Search issues", Icon::Search, None, None), + navbar_left_item( + "Search issues", + Icon::Search, + Some("/issues-and-filters"), + Some(mouse_ev("click", |ev| { + ev.stop_propagation(); + ev.prevent_default(); + Msg::ChangePage(Page::IssuesAndFilters) + })), + ), navbar_left_item( "Create Issue", Icon::Plus, diff --git a/jirs-client/src/ws/mod.rs b/jirs-client/src/ws/mod.rs index a0b1ac13..4239ef7c 100644 --- a/jirs-client/src/ws/mod.rs +++ b/jirs-client/src/ws/mod.rs @@ -163,7 +163,10 @@ pub fn update(msg: WsMsg, model: &mut Model, orders: &mut impl Orders) { // issue statuses WsMsg::IssueStatusesLoaded(v) => { - model.issue_statuses = v; + model.issue_statuses = v.clone(); + for is in v { + model.issue_statuses_by_id.insert(is.id, is); + } model .issue_statuses .sort_by(|a, b| a.position.cmp(&b.position)); @@ -175,7 +178,8 @@ pub fn update(msg: WsMsg, model: &mut Model, orders: &mut impl Orders) { } WsMsg::IssueStatusCreated(is) => { let id = is.id; - model.issue_statuses.push(is); + model.issue_statuses.push(is.clone()); + model.issue_statuses_by_id.insert(is.id, is); model .issue_statuses .sort_by(|a, b| a.position.cmp(&b.position)); @@ -187,6 +191,9 @@ pub fn update(msg: WsMsg, model: &mut Model, orders: &mut impl Orders) { } WsMsg::IssueStatusUpdated(mut changed) => { let id = changed.id; + model + .issue_statuses_by_id + .insert(changed.id, changed.clone()); if let Some(idx) = model.issue_statuses.iter().position(|c| c.id == changed.id) { std::mem::swap(&mut model.issue_statuses[idx], &mut changed); } @@ -200,8 +207,8 @@ pub fn update(msg: WsMsg, model: &mut Model, orders: &mut impl Orders) { )); } WsMsg::IssueStatusDeleted(dropped_id, _count) => { - let mut old = vec![]; - std::mem::swap(&mut model.issue_statuses, &mut old); + model.issue_statuses_by_id.remove(&dropped_id); + let old = std::mem::replace(&mut model.issue_statuses, vec![]); for is in old { if is.id != dropped_id { model.issue_statuses.push(is); diff --git a/jirs-server/src/main.rs b/jirs-server/src/main.rs index a9106c14..e68b79e5 100644 --- a/jirs-server/src/main.rs +++ b/jirs-server/src/main.rs @@ -1,5 +1,4 @@ #![feature(async_closure)] -#![feature(vec_remove_item)] #![recursion_limit = "256"] use {