Add issues and filters

This commit is contained in:
Adrian Woźniak 2021-01-20 22:05:10 +01:00
parent 1c666dc26a
commit adea6d4b4a
16 changed files with 485 additions and 30 deletions

View File

@ -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;
}
}
}
}
}
}

View File

@ -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";

View File

@ -8,6 +8,7 @@ pub struct StyledLink<'l> {
children: Vec<Node<Msg>>,
class_list: Vec<&'l str>,
href: &'l str,
disabled: bool,
}
impl<'l> StyledLink<'l> {
@ -21,6 +22,7 @@ pub struct StyledLinkBuilder<'l> {
children: Vec<Node<Msg>>,
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<Msg> {
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<Msg> {
}
None as Option<Msg>
})
}))
};
a![
@ -91,6 +102,7 @@ pub fn render(values: StyledLink) -> Node<Msg> {
At::Class => class_list.join(" "),
At::Href => href,
],
IF![disabled => attrs![At::OnClick => "return false"]],
on_click,
children,
]

View File

@ -107,6 +107,9 @@ pub enum Msg {
AddIssue,
DeleteIssue(EpicId),
// issues and filters
SetActiveIssue(Option<IssueId>),
// epics
AddEpic,
DeleteEpic,
@ -250,6 +253,7 @@ fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) {
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<Msg> {
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<Page> {
Some(Ok(id)) => Page::EditIssue(id),
_ => return None,
},
"issues-and-filters" => Page::IssuesAndFilters,
"add-issue" => Page::AddIssue,
"project-settings" => Page::ProjectSettings,
"login" => Page::SignIn,

View File

@ -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<UsersPage>),
Profile(Box<ProfilePage>),
Reports(Box<ReportsPage>),
IssuesAndFilters(Box<crate::pages::issues_and_filters::Model>),
}
#[derive(Debug)]

View File

@ -0,0 +1,5 @@
pub use {model::*, update::*, view::*};
mod model;
mod update;
mod view;

View File

@ -0,0 +1,26 @@
use {
crate::model,
jirs_data::{Issue, IssueId},
};
#[derive(Debug)]
pub struct Model {
pub visible_issues: Vec<IssueId>,
pub active_id: Option<IssueId>,
}
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<IssueId> {
issues.iter().map(|issue| issue.id).collect()
}
}

View File

@ -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<Msg>) {
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)));
}

View File

@ -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<Msg> {
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],
)
}

View File

@ -0,0 +1,8 @@
use {
crate::{model::Model, Msg},
seed::{prelude::*, *},
};
pub fn filters(_model: &Model, _page: &super::super::Model) -> Node<Msg> {
div![C!["filters"]]
}

View File

@ -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<Msg> {
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<Msg> {
let issues: Vec<Node<Msg>> = 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<Msg> {
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<Msg> {
StyledLink::build()
.with_icon()
.href(format!("/issues/{}", id).as_str())
.text(format!("{}-{}", project_name, id).as_str())
.disabled()
.build()
.into_node()
}

View File

@ -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;

View File

@ -57,7 +57,12 @@ pub fn render(model: &Model) -> Node<Msg> {
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),

View File

@ -77,7 +77,16 @@ pub fn render(model: &Model) -> Vec<Node<Msg>> {
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,

View File

@ -163,7 +163,10 @@ pub fn update(msg: WsMsg, model: &mut Model, orders: &mut impl Orders<Msg>) {
// 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<Msg>) {
}
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<Msg>) {
}
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<Msg>) {
));
}
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);

View File

@ -1,5 +1,4 @@
#![feature(async_closure)]
#![feature(vec_remove_item)]
#![recursion_limit = "256"]
use {