diff --git a/jirs-client/js/css/issuesAndFilters.scss b/jirs-client/js/css/issuesAndFilters.scss index 784a7954..fc901e2b 100644 --- a/jirs-client/js/css/issuesAndFilters.scss +++ b/jirs-client/js/css/issuesAndFilters.scss @@ -166,6 +166,10 @@ } } } + + > .description { + padding: 5px; + } } > .issuesList { diff --git a/jirs-client/src/components/styled_link.rs b/jirs-client/src/components/styled_link.rs index cd5e5fa4..b2bf8272 100644 --- a/jirs-client/src/components/styled_link.rs +++ b/jirs-client/src/components/styled_link.rs @@ -3,7 +3,7 @@ use std::str::FromStr; use seed::prelude::*; use seed::*; -use crate::Msg; +use crate::{resolve_page, Msg}; #[derive(Debug, Default)] pub struct StyledLink<'l> { @@ -24,6 +24,9 @@ impl<'l> StyledLink<'l> { ev.stop_propagation(); if let Ok(url) = seed::Url::from_str(href.as_str()) { url.go_and_push(); + if let Some(page) = resolve_page(url) { + return Some(Msg::ChangePage(page)); + } } } diff --git a/jirs-client/src/lib.rs b/jirs-client/src/lib.rs index d9bffbf1..085b1b8d 100644 --- a/jirs-client/src/lib.rs +++ b/jirs-client/src/lib.rs @@ -15,6 +15,7 @@ use crate::components::styled_tooltip; use crate::components::styled_tooltip::{TooltipVariant as StyledTooltip, TooltipVariant}; use crate::modals::DebugMsg; use crate::model::{ModalType, Model, Page}; +use crate::pages::issues_and_filters::IssuesAndFiltersMsg; use crate::shared::{go_to_board, go_to_login}; use crate::ws::{flush_queue, open_socket, read_incoming, send_ws_msg}; @@ -102,6 +103,9 @@ pub enum Msg { ProjectToggleRecentlyUpdated, ProjectClearFilters, + // issues and filters + IssuesAndFilters(IssuesAndFiltersMsg), + // inputs StrInputChanged(FieldId, String), diff --git a/jirs-client/src/pages/issues_and_filters/model.rs b/jirs-client/src/pages/issues_and_filters/model.rs index 023cd579..dd8127a1 100644 --- a/jirs-client/src/pages/issues_and_filters/model.rs +++ b/jirs-client/src/pages/issues_and_filters/model.rs @@ -4,17 +4,23 @@ use seed::app::Orders; use crate::components::styled_select::StyledSelectState; use crate::{model, FieldId, IssuesAndFiltersId, Msg}; +#[derive(Debug)] +pub enum IssuesAndFiltersMsg { + RemoveFilter(usize), +} + #[derive(Debug)] pub struct IssuesAndFiltersPage { pub visible_issues: Vec, pub active_id: Option, pub current_jql_part: StyledSelectState, - pub jql_parts: Vec, + pub jql: Jql, } impl IssuesAndFiltersPage { pub fn new(model: &model::Model) -> Self { - let visible_issues = Self::visible_issues(model.issues()); + let jql = Jql::default(); + let visible_issues = Self::visible_issues(model.issues(), &jql); let active_id = model.issues().first().as_ref().map(|issue| issue.id); Self { @@ -24,12 +30,16 @@ impl IssuesAndFiltersPage { FieldId::IssuesAndFilters(IssuesAndFiltersId::Jql), vec![], ), - jql_parts: vec![], + jql, } } - pub fn visible_issues(issues: &[Issue]) -> Vec { - issues.iter().map(|issue| issue.id).collect() + pub fn visible_issues(issues: &[Issue], jql: &Jql) -> Vec { + issues + .iter() + .filter(|issue| jql.is_visible(issue)) + .map(|issue| issue.id) + .collect() } pub fn update(&mut self, msg: &Msg, orders: &mut impl Orders) { @@ -37,11 +47,181 @@ impl IssuesAndFiltersPage { } } +#[derive(Debug, Default)] +pub struct Jql { + pub parts: Vec, +} + +impl Jql { + pub fn current_token(&self) -> Option { + if self.parts.is_empty() { + return None; + } + // [field, op, field, keyword] + match self.len() % 4 { + 0 => Some(JqlPartType::Keyword), + 3 => Some(JqlPartType::Value), + 2 => Some(JqlPartType::Op), + 1 => Some(JqlPartType::Field), + _ => None, + } + } + + pub fn op(&self) -> Option<&JqlPart> { + if self.parts.is_empty() { + return None; + } + match self.len() % 4 { + // [field, op, value, keyword] + 0 => self.parts.get(self.len() - 3), + // [field, op, value] + 3 => self.parts.get(self.len() - 2), + // [field, op] + 2 => self.parts.last(), + // [field] + _ => None, + } + } + + pub fn value(&self) -> Option<&JqlPart> { + if self.parts.is_empty() { + return None; + } + + match self.len() % 4 { + // [field, op, value, keyword] + 0 => self.parts.get(self.len() - 2), + // [field, op, value] + 3 => self.parts.last(), + _ => None, + } + } + + pub fn field(&self) -> Option<&JqlPart> { + if self.parts.is_empty() { + return None; + } + match self.len() % 4 { + // [field, op, value, keyword] + 0 => self.parts.get(self.len() - 3), + // [field] + 1 => self.parts.last(), + // [field, op] + 2 => self.parts.get(self.len() - 2), + _ => None, + } + } + + pub fn keyword(&self) -> Option<&JqlPart> { + if self.parts.is_empty() { + return None; + } + match self.len() % 4 { + // [field] + 0 => self.parts.last(), + _ => None, + } + } + + pub fn len(&self) -> usize { + self.parts.len() + } + + pub fn push(&mut self, part: JqlPart) { + self.parts.push(part); + } + + pub fn remove_from(&mut self, idx: usize) { + if self.parts.is_empty() { + return; + } + if self.len() <= 4 { + self.parts.clear(); + } else { + self.parts.drain((idx - 1 - (idx % 4))..); + } + } + + pub fn is_visible(&self, issue: &jirs_data::Issue) -> bool { + if self.len() < 3 { + return true; + } + + let mut q = (&self.parts).iter(); + while let Some(field) = q.next() { + let op = match q.next() { + None => break, + Some(op) => op, + }; + let value = match q.next() { + None => break, + Some(value) => value, + }; + let _keyword = q.next(); // skip keyword + match (field, op, value) { + // + ( + JqlPart::Field(FieldOption::Assignee), + JqlPart::Op(OpOption::Is | OpOption::Eq), + JqlPart::Value(JqlValueOption::User(id, _)), + ) if !issue.user_ids.contains(id) => return false, + ( + JqlPart::Field(FieldOption::Assignee), + JqlPart::Op(OpOption::IsNot | OpOption::NotEq), + JqlPart::Value(JqlValueOption::User(id, _)), + ) if issue.user_ids.contains(id) => return false, + // + ( + JqlPart::Field(FieldOption::Type), + JqlPart::Op(OpOption::Is | OpOption::Eq), + JqlPart::Value(JqlValueOption::Type(t)), + ) if issue.issue_type != *t => { + return false; + } + ( + JqlPart::Field(FieldOption::Type), + JqlPart::Op(OpOption::IsNot | OpOption::NotEq), + JqlPart::Value(JqlValueOption::Type(t)), + ) if issue.issue_type == *t => { + return false; + } + // + ( + JqlPart::Field(FieldOption::Priority), + JqlPart::Op(OpOption::Is | OpOption::Eq), + JqlPart::Value(JqlValueOption::Priority(p)), + ) if issue.priority != *p => { + return false; + } + ( + JqlPart::Field(FieldOption::Priority), + JqlPart::Op(OpOption::IsNot | OpOption::NotEq), + JqlPart::Value(JqlValueOption::Priority(p)), + ) if issue.priority == *p => { + return false; + } + + _ => {} + }; + } + true + } +} + +#[derive(Debug)] +pub enum JqlPartType { + Field, + Op, + Value, + Keyword, +} + #[derive(Debug)] pub enum JqlPart { Field(FieldOption), Op(OpOption), Value(JqlValueOption), + Keyword(KeywordOption), } impl JqlPart { @@ -50,6 +230,7 @@ impl JqlPart { JqlPart::Field(f) => f.to_label(), JqlPart::Op(op) => op.to_label(), JqlPart::Value(v) => v.to_label(), + JqlPart::Keyword(k) => k.to_label(), } } } @@ -58,6 +239,8 @@ impl JqlPart { pub enum FieldOption { None, Assignee, + Type, + Priority, } impl FieldOption { @@ -65,6 +248,8 @@ impl FieldOption { match self { FieldOption::None => " ", FieldOption::Assignee => "Assignee", + FieldOption::Type => "Type", + FieldOption::Priority => "Priority", } } @@ -72,6 +257,8 @@ impl FieldOption { match self { FieldOption::None => "none", FieldOption::Assignee => "assignee", + FieldOption::Type => "ticketType", + FieldOption::Priority => "ticketPriority", } } @@ -79,6 +266,8 @@ impl FieldOption { match self { FieldOption::None => 0, FieldOption::Assignee => 1, + FieldOption::Type => 2, + FieldOption::Priority => 3, } } } @@ -87,6 +276,8 @@ impl From for FieldOption { fn from(n: u32) -> Self { match n { 1 => FieldOption::Assignee, + 2 => FieldOption::Type, + 3 => FieldOption::Priority, _ => FieldOption::None, } } @@ -97,6 +288,8 @@ pub enum OpOption { None, Eq, NotEq, + Is, + IsNot, } impl OpOption { @@ -105,6 +298,8 @@ impl OpOption { OpOption::None => " ", OpOption::Eq => "=", OpOption::NotEq => "!=", + OpOption::Is => "IS", + OpOption::IsNot => "IS NOT", } } @@ -113,6 +308,8 @@ impl OpOption { OpOption::None => "none", OpOption::Eq => "equal", OpOption::NotEq => "notEqual", + OpOption::Is => "is", + OpOption::IsNot => "isNot", } } @@ -121,6 +318,8 @@ impl OpOption { OpOption::None => 0, OpOption::Eq => 1, OpOption::NotEq => 2, + OpOption::Is => 3, + OpOption::IsNot => 4, } } } @@ -130,6 +329,8 @@ impl From for OpOption { match n { 1 => OpOption::Eq, 2 => OpOption::NotEq, + 3 => OpOption::Is, + 4 => OpOption::IsNot, _ => OpOption::None, } } @@ -138,24 +339,70 @@ impl From for OpOption { #[derive(Debug)] pub enum JqlValueOption { User(UserId, UsernameString), + Priority(jirs_data::IssuePriority), + Type(jirs_data::IssueType), } impl JqlValueOption { pub fn to_label(&self) -> &str { match self { JqlValueOption::User(_id, name) => name.as_str(), + JqlValueOption::Priority(p) => p.to_label(), + JqlValueOption::Type(t) => t.to_label(), } } pub fn to_name(&self) -> &'static str { match self { JqlValueOption::User(_, _) => "user", + JqlValueOption::Priority(_) => "priority", + JqlValueOption::Type(_) => "type", } } pub fn to_value(&self) -> u32 { match self { JqlValueOption::User(id, _) => (*id) as u32, + JqlValueOption::Priority(p) => (*p).into(), + JqlValueOption::Type(t) => (*t).into(), + } + } +} + +#[derive(Debug)] +pub enum KeywordOption { + None, + And, +} + +impl From for KeywordOption { + fn from(n: u32) -> Self { + match n { + 1 => KeywordOption::And, + _ => KeywordOption::None, + } + } +} + +impl KeywordOption { + pub fn to_label(&self) -> &'static str { + match self { + KeywordOption::None => " ", + KeywordOption::And => "AND", + } + } + + pub fn to_name(&self) -> &'static str { + match self { + KeywordOption::None => "none", + KeywordOption::And => "and", + } + } + + pub fn to_value(&self) -> u32 { + match self { + KeywordOption::None => 0, + KeywordOption::And => 1, } } } diff --git a/jirs-client/src/pages/issues_and_filters/update.rs b/jirs-client/src/pages/issues_and_filters/update.rs index 35f528ab..8ea03886 100644 --- a/jirs-client/src/pages/issues_and_filters/update.rs +++ b/jirs-client/src/pages/issues_and_filters/update.rs @@ -1,12 +1,13 @@ use seed::prelude::*; +use super::IssuesAndFiltersMsg; use crate::components::styled_select::StyledSelectChanged; use crate::model::{Model, PageContent}; -use crate::pages::issues_and_filters::{FieldOption, JqlPart, JqlValueOption, OpOption}; +use crate::pages::issues_and_filters::{ + FieldOption, JqlPart, JqlPartType, JqlValueOption, KeywordOption, OpOption, +}; use crate::ws::board_load; -use crate::{FieldId, IssuesAndFiltersId, Msg, OperationKind, Page, ResourceKind}; - -mod jql; +use crate::{match_page, FieldId, IssuesAndFiltersId, Msg, OperationKind, Page, ResourceKind}; pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders) { if model.user.is_none() { @@ -31,8 +32,14 @@ pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Order crate::match_page_mut!(model, IssuesAndFilters).update(&msg, orders); match msg { + Msg::IssuesAndFilters(IssuesAndFiltersMsg::RemoveFilter(idx)) => { + crate::match_page_mut!(model, IssuesAndFilters) + .jql + .remove_from(idx); + } Msg::ResourceChanged(ResourceKind::Issue, OperationKind::ListLoaded, _) => { - let issues = super::IssuesAndFiltersPage::visible_issues(model.issues()); + let jql = &match_page!(model, IssuesAndFilters).jql; + let issues = super::IssuesAndFiltersPage::visible_issues(model.issues(), jql); 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() { @@ -52,26 +59,56 @@ pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Order StyledSelectChanged::Changed(Some(n)), ) if n != 0 => { let page = crate::match_page_mut!(model, IssuesAndFilters); - match page.jql_parts.len() % 3 { - 0 => { + match page.jql.current_token() { + None | Some(JqlPartType::Keyword) => { let field = FieldOption::from(n); - page.jql_parts.push(JqlPart::Field(field)); + page.jql.push(JqlPart::Field(field)); } - 1 => { + Some(JqlPartType::Field) => { let field = OpOption::from(n); - page.jql_parts.push(JqlPart::Op(field)); + page.jql.push(JqlPart::Op(field)); } - 2 => { + Some(JqlPartType::Op) + if matches!( + page.jql.field(), + Some(JqlPart::Field(FieldOption::Assignee)) + ) => + { let u = match model.users.get(n as usize) { Some(u) => u, _ => return, }; let field = JqlValueOption::User(u.id, u.name.clone()); - page.jql_parts.push(JqlPart::Value(field)); + page.jql.push(JqlPart::Value(field)); + } + Some(JqlPartType::Op) + if matches!(page.jql.field(), Some(JqlPart::Field(FieldOption::Type))) => + { + page.jql + .push(JqlPart::Value(JqlValueOption::Type(n.into()))); + } + Some(JqlPartType::Op) + if matches!( + page.jql.field(), + Some(JqlPart::Field(FieldOption::Priority)) + ) => + { + page.jql + .push(JqlPart::Value(JqlValueOption::Priority(n.into()))); + } + Some(JqlPartType::Value) => { + let field = KeywordOption::from(n); + page.jql.push(JqlPart::Keyword(field)); } _ => {} } page.current_jql_part.reset(); + page.current_jql_part.opened = true; + let issues = super::IssuesAndFiltersPage::visible_issues( + model.issues(), + &crate::match_page!(model, IssuesAndFilters).jql, + ); + crate::match_page_mut!(model, IssuesAndFilters).visible_issues = issues; } _ => {} } diff --git a/jirs-client/src/pages/issues_and_filters/update/jql.rs b/jirs-client/src/pages/issues_and_filters/update/jql.rs deleted file mode 100644 index bacdffbe..00000000 --- a/jirs-client/src/pages/issues_and_filters/update/jql.rs +++ /dev/null @@ -1,31 +0,0 @@ -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 } -} diff --git a/jirs-client/src/pages/issues_and_filters/view/filters.rs b/jirs-client/src/pages/issues_and_filters/view/filters.rs index 0ed187a8..8a2c8b3f 100644 --- a/jirs-client/src/pages/issues_and_filters/view/filters.rs +++ b/jirs-client/src/pages/issues_and_filters/view/filters.rs @@ -1,12 +1,15 @@ use seed::prelude::*; use seed::*; +use super::super::IssuesAndFiltersMsg; use crate::components::styled_button::ButtonVariant; 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, JqlPart}; +use crate::pages::issues_and_filters::{ + FieldOption, IssuesAndFiltersPage, Jql, JqlPart, JqlPartType, KeywordOption, OpOption, +}; use crate::styled_button::StyledButton; use crate::Msg; @@ -28,10 +31,9 @@ fn pseudo_input(model: &Model, page: &IssuesAndFiltersPage) -> Node { .current_jql_part .values .iter() - .enumerate() - .map(|(idx, n)| field_select_option(model, idx, *n, &page.jql_parts)) + .map(|n| field_select_option(model, *n, &page.jql)) .collect(), - options: Some(options(model, &page.jql_parts).into_iter()), + options: Some(options(model, &page.jql).into_iter()), is_multi: false, opened: page.current_jql_part.opened, } @@ -39,60 +41,108 @@ fn pseudo_input(model: &Model, page: &IssuesAndFiltersPage) -> Node { div![ C!["pseudoInput"], StyledIcon::from(Icon::Search).render(), - page.jql_parts + page.jql + .parts .iter() .map(|s| s.to_label()) - .map(removable_part) + .enumerate() + .map(|(idx, m)| removable_part(idx, m)) .collect::>>(), input ] } -fn options<'l, 'm: 'l>(model: &'m Model, parts: &[JqlPart]) -> Vec> { - log::info!("{} {:?}", parts.len(), parts); - if parts.is_empty() { - vec![field_select_option(model, 0, 1, &parts)] - } else if parts.len() % 3 == 2 - && matches!( - parts.get(parts.len() - 2), - Some(JqlPart::Field(FieldOption::Assignee)) - ) - { - model - .users - .iter() +fn options<'l, 'm: 'l>(model: &'m Model, jql: &Jql) -> Vec> { + let ty = jql.current_token(); + match ty { + Some(JqlPartType::Value) => { + vec![build_keyword_select_option(1)] + } + Some(JqlPartType::Field) => { + vec![ + field_select_option(model, 1, jql), + field_select_option(model, 2, jql), + field_select_option(model, 3, jql), + field_select_option(model, 4, jql), + ] + } + Some(JqlPartType::Op) + if matches!(jql.field(), Some(JqlPart::Field(FieldOption::Assignee))) => + { + model + .users + .iter() + .map(|u| StyledSelectOption { + name: Some("user"), + icon: None, + text: Some(u.name.as_str()), + value: u.id as u32, + class_list: "", + variant: SelectVariant::Empty, + }) + .collect() + } + Some(JqlPartType::Op) + if matches!(jql.field(), Some(JqlPart::Field(FieldOption::Priority))) => + { + vec![ + jirs_data::IssuePriority::Lowest, + jirs_data::IssuePriority::Low, + jirs_data::IssuePriority::Medium, + jirs_data::IssuePriority::High, + jirs_data::IssuePriority::Highest, + ] + .into_iter() .map(|u| StyledSelectOption { - name: Some("user"), + name: Some("priority"), icon: None, - text: Some(u.name.as_str()), - value: u.id as u32, + text: Some(u.to_label()), + value: u.into(), class_list: "", variant: SelectVariant::Empty, }) .collect() - } else { - vec![field_select_option(model, 0, 1, &parts)] + } + Some(JqlPartType::Op) if matches!(jql.field(), Some(JqlPart::Field(FieldOption::Type))) => { + vec![ + jirs_data::IssueType::Task, + jirs_data::IssueType::Bug, + jirs_data::IssueType::Story, + ] + .into_iter() + .map(|u| StyledSelectOption { + name: Some("type"), + icon: None, + text: Some(u.to_label()), + value: u.into(), + class_list: "", + variant: SelectVariant::Empty, + }) + .collect() + } + Some(JqlPartType::Op) => { + vec![field_select_option(model, 1, jql)] + } + None | Some(JqlPartType::Keyword) => vec![ + field_select_option(model, 1, jql), + field_select_option(model, 2, jql), + field_select_option(model, 3, jql), + ], } } fn field_select_option<'l, 'm: 'l>( model: &'m Model, - idx: usize, option_value: u32, - parts: &[JqlPart], + jql: &Jql, ) -> StyledSelectOption<'l> { - if parts.is_empty() { + let ty = jql.current_token(); + if ty.is_none() { return build_field_select_option(option_value); } - match (parts.len(), parts.len() % 3) { - (_, 1) => { - return build_field_select_option(option_value); - } - (_, 2) - if matches!( - parts.get(idx - 1), - Some(JqlPart::Field(FieldOption::Assignee)) - ) => + match jql.current_token() { + Some(JqlPartType::Op) + if matches!(jql.field(), Some(JqlPart::Field(FieldOption::Assignee))) => { let user = model .users_by_id @@ -108,10 +158,59 @@ fn field_select_option<'l, 'm: 'l>( variant: SelectVariant::Empty, } } + Some(JqlPartType::Op) + if matches!(jql.field(), Some(JqlPart::Field(FieldOption::Priority))) => + { + let p: jirs_data::IssuePriority = option_value.into(); + StyledSelectOption { + name: Some("priority"), + icon: None, + text: Some(p.to_label()), + value: p.into(), + class_list: "", + variant: SelectVariant::Empty, + } + } + Some(JqlPartType::Op) if matches!(jql.field(), Some(JqlPart::Field(FieldOption::Type))) => { + let p: jirs_data::IssueType = option_value.into(); + StyledSelectOption { + name: Some("type"), + icon: None, + text: Some(p.to_label()), + value: p.into(), + class_list: "", + variant: SelectVariant::Empty, + } + } + Some(JqlPartType::Field) => build_op_select_option(option_value), _ => build_field_select_option(option_value), } } +fn build_op_select_option<'l>(option_value: u32) -> StyledSelectOption<'l> { + let v = OpOption::from(option_value); + StyledSelectOption { + name: Some(v.to_name()), + icon: None, + text: Some(v.to_label()), + value: v.to_value(), + class_list: "", + variant: SelectVariant::Empty, + } +} + +fn build_keyword_select_option<'l>(option_value: u32) -> StyledSelectOption<'l> { + let v = KeywordOption::from(option_value); + StyledSelectOption { + name: Some(v.to_name()), + icon: None, + text: Some(v.to_label()), + value: v.to_value(), + class_list: "", + variant: SelectVariant::Empty, + } +} + fn build_field_select_option<'l>(option_value: u32) -> StyledSelectOption<'l> { let v = FieldOption::from(option_value); StyledSelectOption { @@ -124,11 +223,13 @@ fn build_field_select_option<'l>(option_value: u32) -> StyledSelectOption<'l> { } } -fn removable_part(part: &str) -> Node { +fn removable_part(idx: usize, part: &str) -> Node { let remove = StyledButton { variant: ButtonVariant::Empty, icon: Some(StyledIcon::from(Icon::Trash).render()), - on_click: None, + on_click: Some(mouse_ev("click", move |_| { + Msg::IssuesAndFilters(IssuesAndFiltersMsg::RemoveFilter(idx)) + })), ..Default::default() } .render();