Add issues and filters

This commit is contained in:
Adrian Woźniak 2021-04-28 16:57:56 +02:00
parent 3ee08dea54
commit e6778db137
20 changed files with 664 additions and 18 deletions

View File

@ -0,0 +1,178 @@
#issuesAndFilters {
display: block;
> .filters {
margin: {
top: 5px;
bottom: 5px;
}
> .pseudoInput {
display: flex;
> .styledIcon {
line-height: 39px;
width: 24px;
}
> .styledSelect {
display: inline;
min-width: 20px;
> .valueContainer {
min-height: 100%;
}
}
}
}
> .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;
> span {
margin: 0 5px;
font-family: var(--font-medium);
height: 32px;
vertical-align: middle;
line-height: 2;
appearance: none;
cursor: none;
user-select: none;
font-size: 14.5px;
}
> .styledLink, > span {
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;
> .issueLink {
display: inline;
cursor: alias;
}
}
> .priority {
grid-area: priority;
}
> .name {
grid-area: name;
}
}
}
}
}
}

View File

@ -9,18 +9,18 @@
@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";
@ -35,3 +35,4 @@
@import "css/invite.scss";
@import "css/reports.scss";
@import "css/profile.scss";
@import "css/issuesAndFilters.scss";

View File

@ -23,7 +23,7 @@ impl InputVariant {
#[derive(Clone, Debug, PartialOrd, PartialEq)]
pub struct StyledInputState {
id: FieldId,
pub field_id: FieldId,
touched: bool,
pub value: String,
pub min: Option<usize>,
@ -37,7 +37,7 @@ impl StyledInputState {
S: Into<String>,
{
Self {
id,
field_id: id,
touched: false,
value: value.into(),
min: None,
@ -75,7 +75,7 @@ impl StyledInputState {
#[inline(always)]
pub fn update(&mut self, msg: &Msg) {
match msg {
Msg::StrInputChanged(field_id, s) if field_id == &self.id => {
Msg::StrInputChanged(field_id, s) if field_id == &self.field_id => {
self.value = s.clone();
self.touched = true;
}

View File

@ -5,23 +5,19 @@ use seed::*;
use crate::Msg;
#[derive(Debug, Default)]
pub struct StyledLink<'l> {
pub children: Vec<Node<Msg>>,
pub class_list: &'l str,
pub href: &'l str,
pub disabled: bool,
}
impl<'l> StyledLink<'l> {
#[inline(always)]
pub fn render(self) -> Node<Msg> {
let StyledLink {
children,
class_list,
href,
} = self;
let on_click = {
let href = href.to_string();
let href = self.href.to_string();
mouse_ev("click", move |ev| {
if href.starts_with('/') {
ev.prevent_default();
@ -36,10 +32,10 @@ impl<'l> StyledLink<'l> {
};
a![
C!["styledLink", class_list],
attrs![ At::Href => href ],
C!["styledLink", self.class_list],
attrs![ At::Href => self.href ],
on_click,
children,
self.children,
]
}
}

View File

@ -46,6 +46,10 @@ pub struct StyledSelectState {
}
impl StyledSelectState {
pub fn field_id(&self) -> FieldId {
self.field_id.clone()
}
pub fn reset(&mut self) {
self.text_filter.clear();
self.opened = false;

View File

@ -84,6 +84,11 @@ impl ButtonId {
}
}
#[derive(Clone, Debug, PartialOrd, PartialEq, Hash)]
pub enum IssuesAndFiltersId {
Jql,
}
#[derive(Clone, Debug, PartialOrd, PartialEq, Hash)]
pub enum FieldId {
NoField,
@ -95,6 +100,7 @@ pub enum FieldId {
// issue
AddIssueModal(IssueFieldId),
EditIssueModal(EditIssueModalSection),
IssuesAndFilters(IssuesAndFiltersId),
// epic
EditEpic(EpicFieldId),
// project boards
@ -196,6 +202,9 @@ impl FieldId {
EpicFieldId::TransformInto => "epicEpic-transformInto",
},
FieldId::Rte(..) => "rte",
FieldId::IssuesAndFilters(sub) => match sub {
IssuesAndFiltersId::Jql => "issuesAndFilters-jql",
},
}
}
}

View File

@ -112,6 +112,7 @@ pub enum Msg {
// issues
AddIssue,
DeleteIssue(EpicId),
SetActiveIssue(Option<IssueId>),
// epics
AddEpic,
@ -256,6 +257,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::debug!("{:?}", model);
@ -278,6 +280,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),
}
}
@ -309,6 +312,7 @@ fn resolve_page(url: Url) -> Option<Page> {
Some(Ok(id)) => Page::EditEpic(id),
_ => return None,
},
"issues-and-filters" => Page::IssuesAndFilters,
_ => Page::Project,
};
Some(page)

View File

@ -8,6 +8,7 @@ use uuid::Uuid;
use crate::components::styled_select::StyledSelectState;
use crate::pages::invite_page::InvitePage;
use crate::pages::issues_and_filters::IssuesAndFiltersPage;
use crate::pages::profile_page::model::ProfilePage;
use crate::pages::project_page::model::ProjectPage;
use crate::pages::project_settings_page::ProjectSettingsPage;
@ -75,6 +76,7 @@ pub enum Page {
// issue
EditIssue(EpicId),
AddIssue,
IssuesAndFilters,
// settings
ProjectSettings,
// auth
@ -95,6 +97,7 @@ impl Page {
Page::EditEpic(id) => format!("/edit-epic/{id}", id = id),
Page::EditIssue(id) => format!("/issues/{id}", id = id),
Page::AddIssue => "/add-issue".to_string(),
Page::IssuesAndFilters => "/issues-and-filters".to_string(),
Page::ProjectSettings => "/project-settings".to_string(),
Page::SignIn => "/login".to_string(),
Page::SignUp => "/register".to_string(),
@ -190,6 +193,7 @@ pub enum PageContent {
Users(Box<UsersPage>),
Profile(Box<ProfilePage>),
Reports(Box<ReportsPage>),
IssuesAndFilters(Box<IssuesAndFiltersPage>),
}
#[derive(Debug)]

View File

@ -0,0 +1,7 @@
pub use model::*;
pub use update::*;
pub use view::*;
mod model;
mod update;
mod view;

View File

@ -0,0 +1,75 @@
use jirs_data::{Issue, IssueId};
use seed::app::Orders;
use crate::components::styled_select::StyledSelectState;
use crate::{model, FieldId, IssuesAndFiltersId, Msg};
#[derive(Debug)]
pub struct IssuesAndFiltersPage {
pub visible_issues: Vec<IssueId>,
pub active_id: Option<IssueId>,
pub current_jql_part: StyledSelectState,
pub jql_parts: Vec<String>,
}
impl IssuesAndFiltersPage {
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,
current_jql_part: StyledSelectState::new(
FieldId::IssuesAndFilters(IssuesAndFiltersId::Jql),
vec![],
),
jql_parts: vec![],
}
}
pub fn visible_issues(issues: &[Issue]) -> Vec<IssueId> {
issues.iter().map(|issue| issue.id).collect()
}
pub fn update(&mut self, msg: &Msg, orders: &mut impl Orders<Msg>) {
self.current_jql_part.update(msg, orders);
}
}
pub enum FieldOption {
None,
Assignee,
}
impl FieldOption {
pub fn to_label(&self) -> &'static str {
match self {
FieldOption::None => "none",
FieldOption::Assignee => "Assignee",
}
}
pub fn to_name(&self) -> &'static str {
match self {
FieldOption::None => " ",
FieldOption::Assignee => "assignee",
}
}
pub fn to_value(&self) -> u32 {
match self {
FieldOption::None => 0,
FieldOption::Assignee => 1,
}
}
}
impl From<u32> for FieldOption {
fn from(n: u32) -> Self {
match n {
1 => FieldOption::Assignee,
_ => FieldOption::None,
}
}
}

View File

@ -0,0 +1,73 @@
use seed::prelude::*;
use crate::components::styled_select::StyledSelectChanged;
use crate::model::{Model, PageContent};
use crate::pages::issues_and_filters::FieldOption;
use crate::ws::board_load;
use crate::{FieldId, IssuesAndFiltersId, Msg, OperationKind, Page, ResourceKind};
mod jql;
pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) {
if model.user.is_none() {
return;
}
if matches!(model.page, Page::IssuesAndFilters)
&& !matches!(model.page_content, PageContent::IssuesAndFilters(..))
{
build_page_content(model);
}
match msg {
Msg::ResourceChanged(ResourceKind::Auth, OperationKind::SingleLoaded, Some(_))
| Msg::ChangePage(Page::IssuesAndFilters) => {
board_load(model, orders);
build_page_content(model);
}
_ => (),
}
crate::match_page_mut!(model, IssuesAndFilters).update(&msg, orders);
match msg {
Msg::ResourceChanged(ResourceKind::Issue, OperationKind::ListLoaded, _) => {
let issues = super::IssuesAndFiltersPage::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)) => {
crate::match_page_mut!(model, IssuesAndFilters).active_id = Some(id);
}
Msg::SetActiveIssue(None) => {
let first_id = model.issues().first().as_ref().map(|issue| issue.id);
crate::match_page_mut!(model, IssuesAndFilters).active_id = first_id;
}
Msg::StyledSelectChanged(
FieldId::IssuesAndFilters(IssuesAndFiltersId::Jql),
StyledSelectChanged::Changed(Some(n)),
) if n != 0 => {
let page = crate::match_page_mut!(model, IssuesAndFilters);
match page.jql_parts.len() % 3 {
0 => {
let field = FieldOption::from(n);
page.jql_parts.push(field.to_label().to_string());
}
1 => {}
2 => {}
_ => {}
}
page.current_jql_part.reset();
}
_ => {}
}
}
fn build_page_content(model: &mut Model) {
model.page_content =
PageContent::IssuesAndFilters(Box::new(super::IssuesAndFiltersPage::new(model)));
}

View File

@ -0,0 +1,31 @@
pub enum OpType {
Eq,
}
impl OpType {
pub fn to_str(&self) -> &str {
match self {
OpType::Eq => "=",
}
}
}
pub enum FieldType {
Assignee,
}
impl FieldType {
pub fn to_str(&self) -> &'static str {
match self {
FieldType::Assignee => "Assignee",
}
}
}
pub struct JqlNode {
op: OpType,
}
pub fn parse(_query: &[&str]) -> JqlNode {
JqlNode { op: OpType::Eq }
}

View File

@ -0,0 +1,33 @@
use seed::prelude::*;
use seed::*;
use crate::model::Model;
use crate::shared::inner_layout;
use crate::Msg;
mod filters;
mod issue_info;
pub fn view(model: &Model) -> Node<Msg> {
let page = crate::match_page!(model, IssuesAndFilters; 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,53 @@
use seed::prelude::*;
use seed::*;
use crate::components::styled_icon::{Icon, StyledIcon};
use crate::components::styled_select::{SelectVariant, StyledSelect};
use crate::components::styled_select_child::StyledSelectOption;
use crate::model::Model;
use crate::pages::issues_and_filters::{FieldOption, IssuesAndFiltersPage};
use crate::Msg;
pub fn filters(_model: &Model, page: &IssuesAndFiltersPage) -> Node<Msg> {
let search_input = pseudo_input(page);
div![C!["filters"], search_input]
}
fn pseudo_input(page: &IssuesAndFiltersPage) -> Node<Msg> {
let input = StyledSelect {
id: page.current_jql_part.field_id(),
text_filter: page.current_jql_part.text_filter.as_str(),
variant: SelectVariant::Empty,
dropdown_width: None,
name: "jqlPart",
valid: true,
clearable: true,
selected: page
.current_jql_part
.values
.iter()
.map(|n| field_select_option(*n))
.collect(),
options: Some(vec![field_select_option(1)].into_iter()),
is_multi: false,
opened: page.current_jql_part.opened,
}
.render();
div![
C!["pseudoInput"],
StyledIcon::from(Icon::Search).render(),
input
]
}
fn field_select_option<'l>(n: u32) -> StyledSelectOption<'l> {
let v = FieldOption::from(n);
StyledSelectOption {
name: Some(v.to_name()),
icon: None,
text: Some(v.to_label()),
value: v.to_value(),
class_list: "",
variant: SelectVariant::Empty,
}
}

View File

@ -0,0 +1,169 @@
use jirs_data::{Issue, IssueId};
use seed::prelude::*;
use seed::*;
use crate::components::styled_icon::*;
use crate::components::styled_link::*;
use crate::model::Model;
use crate::Msg;
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 {
href: format!("/issues/{}", id).as_str(),
children: vec![div![
C!["text"],
format!("{}-{}", project_name, id).as_str()
]],
disabled: true,
..Default::default()
}
.render();
let project_link = StyledLink {
disabled: true,
href: "/board",
children: vec![span![model
.project
.as_ref()
.map(|p| p.name.as_str())
.unwrap_or_default()]],
..Default::default()
}
.render();
let details = {
let issue_type = {
let type_name = issue_type.to_string();
let type_icon = StyledIcon::from(Icon::from(*issue_type)).render();
li![
C!["line"],
div![C!["detailsTitle"], "Type:"],
div![C!["type detailsValue"], type_icon, type_name],
]
};
let priority = {
let name = priority.to_string();
let icon = StyledIcon::from(Icon::from(*priority)).render();
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![];
let project_icon = crate::images::project_avatar::render();
div![
C!["issueInfo"],
div![
C!["header"],
div![C!["logo"], project_icon],
div![C!["title"], title.as_str()],
div![C!["path"], project_link, span!["/"], 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::IssuesAndFiltersPage,
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::IssuesAndFiltersPage,
issue: &Issue,
project_name: &str,
) -> Node<Msg> {
let link = issue_link(issue.id, project_name);
let ty = { StyledIcon::from(Icon::from(issue.issue_type)).render() };
let priority = { StyledIcon::from(Icon::from(issue.priority)).render() };
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"],
a![
on_click,
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 {
href: format!("/issues/{}", id).as_str(),
children: vec![
StyledIcon::from(Icon::Link).render(),
span![format!("{}-{}", project_name, id)],
],
disabled: true,
class_list: "withIcon issueLink",
..Default::default()
}
.render()
}

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

@ -217,6 +217,7 @@ fn issue_list(page: &ReportsPage, project_name: &str, this_month_updated: &[&Iss
],
class_list: "withIcon",
href: format!("/issues/{}", id).as_str(),
..Default::default()
}.render();
li![

View File

@ -63,6 +63,7 @@ pub fn view(model: &model::Model) -> Node<Msg> {
children: vec![span!["Register"]],
class_list: "signUpLink",
href: "/register",
..Default::default()
}
.render();
let submit_field = StyledField {

View File

@ -63,6 +63,7 @@ pub fn view(model: &model::Model) -> Node<Msg> {
children: vec![span!["Sign In"]],
class_list: "signInLink",
href: "/login",
..Default::default()
}
.render();

View File

@ -55,7 +55,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),