Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
bin/ui: Move issue types to dedicated module
Erik Kundt committed 4 months ago
commit 241ffc95931c554f576fd61a6951f357cc8f4727
parent 838bf38
5 files changed +342 -312
modified bin/commands/issue/list.rs
@@ -39,7 +39,8 @@ use tui::{BoxedAny, Channel, Exit, PageStack};

use crate::cob::issue;
use crate::settings::{self, ThemeBundle, ThemeMode};
-
use crate::ui::items::{CommentItem, IssueItem, IssueItemFilter};
+
use crate::ui::items::issue::{Issue, IssueFilter};
+
use crate::ui::items::CommentItem;
use crate::ui::rm::{BrowserState, IssueDetails, IssueDetailsProps};
use crate::ui::TerminalInfo;

@@ -105,7 +106,7 @@ pub struct PreviewState {
    /// If preview is visible.
    show: bool,
    /// Currently selected issue item.
-
    issue: Option<IssueItem>,
+
    issue: Option<Issue>,
    /// Tree selection per issue.
    selected_comments: HashMap<IssueId, Vec<CommentId>>,
    /// State of currently selected comment
@@ -156,7 +157,7 @@ pub struct HelpState {
#[derive(Clone, Debug)]
pub struct State {
    pages: PageStack<AppPage>,
-
    browser: BrowserState<IssueItem, IssueItemFilter>,
+
    browser: BrowserState<Issue, IssueFilter>,
    preview: PreviewState,
    section: Option<Section>,
    help: HelpState,
@@ -173,7 +174,7 @@ impl TryFrom<(&Context, &TerminalInfo)> for State {
        let issues = issue::all(&context.profile, &context.repository)?;
        let search =
            BufferedValue::new(context.search.clone().unwrap_or(context.filter.to_string()));
-
        let filter = IssueItemFilter::from_str(&search.read()).unwrap_or_default();
+
        let filter = IssueFilter::from_str(&search.read()).unwrap_or_default();

        let default_bundle = ThemeBundle::default();
        let theme_bundle = settings.theme.active_bundle().unwrap_or(&default_bundle);
@@ -192,7 +193,7 @@ impl TryFrom<(&Context, &TerminalInfo)> for State {
        // Convert into UI items
        let mut issues: Vec<_> = issues
            .into_iter()
-
            .flat_map(|issue| IssueItem::new(&context.profile, issue).ok())
+
            .flat_map(|issue| Issue::new(&context.profile, issue).ok())
            .collect();

        issues.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
modified bin/commands/issue/list/ui.rs
@@ -27,7 +27,7 @@ use tui::ui::Column;

use tui::BoxedAny;

-
use crate::ui::items::{IssueItem, IssueItemFilter};
+
use crate::ui::items::issue::{Issue, IssueFilter};

use super::{Message, State};

@@ -36,7 +36,7 @@ type Widget = widget::Widget<State, Message>;
#[derive(Clone, Default)]
pub struct BrowserProps<'a> {
    /// Filtered issues.
-
    issues: Vec<IssueItem>,
+
    issues: Vec<Issue>,
    /// Issue statistics.
    stats: HashMap<String, usize>,
    /// Header columns
@@ -140,7 +140,7 @@ impl Browser {
                        .into()
                }))
                .content(
-
                    Table::<State, Message, IssueItem, 8>::default()
+
                    Table::<State, Message, Issue, 8>::default()
                        .to_widget(tx.clone())
                        .on_event(|_, s, _| {
                            let (selected, _) =
@@ -290,7 +290,7 @@ fn browse_footer<'a>(props: &BrowserProps<'a>) -> Vec<Column<'a>> {
        span::default(&props.issues.len().to_string()).dim(),
    ]);

-
    match IssueItemFilter::from_str(&props.search)
+
    match IssueFilter::from_str(&props.search)
        .unwrap_or_default()
        .state()
    {
modified bin/ui/items.rs
@@ -1,34 +1,27 @@
+
pub mod issue;
pub mod notification;
pub mod patch;

use std::collections::HashMap;
use std::fmt::Debug;
-
use std::str::FromStr;
-

-
use nom::bytes::complete::{tag, take};
-
use nom::multi::separated_list0;
-
use nom::sequence::{delimited, preceded};
-
use nom::{IResult, Parser};

use radicle::cob::thread::{Comment, CommentId};
-
use radicle::cob::{Label, ObjectId, Timestamp};
+
use radicle::cob::{ObjectId, Timestamp};

use radicle::identity::Did;
-
use radicle::issue;
-
use radicle::issue::{CloseReason, Issue, IssueId};
+
use radicle::issue::{Issue, IssueId};
use radicle::node::{Alias, AliasStore, NodeId};
use radicle::Profile;

use ratatui::prelude::*;
-
use ratatui::style::{Style, Stylize};
-
use ratatui::widgets::Cell;
+
use ratatui::style::Stylize;

use tui_tree_widget::TreeItem;

use radicle_tui as tui;

use tui::ui::span;
-
use tui::ui::{ToRow, ToTree};
+
use tui::ui::ToTree;

use super::format;

@@ -143,291 +136,6 @@ impl AuthorItem {
    }
}

-
#[derive(Clone, Debug)]
-
pub struct IssueItem {
-
    /// Issue OID.
-
    pub id: IssueId,
-
    /// Issue state.
-
    pub state: issue::State,
-
    /// Issue title.
-
    pub title: String,
-
    /// Issue author.
-
    pub author: AuthorItem,
-
    /// Issue labels.
-
    pub labels: Vec<Label>,
-
    /// Issue assignees.
-
    pub assignees: Vec<AuthorItem>,
-
    /// Time when issue was opened.
-
    pub timestamp: Timestamp,
-
    /// Comment timeline
-
    pub comments: Vec<CommentItem>,
-
}
-

-
impl IssueItem {
-
    pub fn new(profile: &Profile, issue: (IssueId, Issue)) -> Result<Self, anyhow::Error> {
-
        let (id, issue) = issue;
-

-
        Ok(Self {
-
            id,
-
            state: *issue.state(),
-
            title: issue.title().into(),
-
            author: AuthorItem::new(Some(*issue.author().id), profile),
-
            labels: issue.labels().cloned().collect(),
-
            assignees: issue
-
                .assignees()
-
                .map(|did| AuthorItem::new(Some(**did), profile))
-
                .collect::<Vec<_>>(),
-
            timestamp: issue.timestamp(),
-
            comments: issue
-
                .comments()
-
                .map(|(comment_id, comment)| {
-
                    CommentItem::new(profile, (id, issue.clone()), (*comment_id, comment.clone()))
-
                })
-
                .collect(),
-
        })
-
    }
-

-
    pub fn root_comments(&self) -> Vec<CommentItem> {
-
        self.comments
-
            .iter()
-
            .filter(|comment| comment.reply_to.is_none())
-
            .cloned()
-
            .collect::<Vec<_>>()
-
    }
-

-
    pub fn has_comment(&self, comment_id: &CommentId) -> bool {
-
        self.comments
-
            .iter()
-
            .any(|comment| comment.id == *comment_id)
-
    }
-

-
    pub fn path_to_comment(&self, comment_id: &CommentId) -> Option<Vec<CommentId>> {
-
        for comment in &self.comments {
-
            let mut path = Vec::new();
-
            if comment.path_to(comment_id, &mut path) {
-
                return Some(path);
-
            }
-
        }
-
        None
-
    }
-
}
-

-
impl ToRow<8> for IssueItem {
-
    fn to_row(&self) -> [Cell<'_>; 8] {
-
        let (state, state_color) = format::issue_state(&self.state);
-

-
        let state = span::default(&state).style(Style::default().fg(state_color));
-
        let id = span::primary(&format::cob(&self.id));
-
        let title = span::default(&self.title.clone());
-

-
        let author = match &self.author.alias {
-
            Some(alias) => {
-
                if self.author.you {
-
                    span::alias(&format!("{alias} (you)"))
-
                } else {
-
                    span::alias(alias)
-
                }
-
            }
-
            None => match &self.author.human_nid {
-
                Some(nid) => span::alias(nid).dim(),
-
                None => span::blank(),
-
            },
-
        };
-
        let did = match &self.author.human_nid {
-
            Some(nid) => span::alias(nid).dim(),
-
            None => span::blank(),
-
        };
-
        let labels = span::labels(&format::labels(&self.labels));
-
        let assignees = self
-
            .assignees
-
            .iter()
-
            .map(|author| (author.nid, author.alias.clone(), author.you))
-
            .collect::<Vec<_>>();
-
        let assignees = span::alias(&format::assignees(&assignees));
-
        let opened = span::timestamp(&format::timestamp(&self.timestamp));
-

-
        [
-
            state.into(),
-
            id.into(),
-
            title.into(),
-
            author.into(),
-
            did.into(),
-
            labels.into(),
-
            assignees.into(),
-
            opened.into(),
-
        ]
-
    }
-
}
-

-
impl HasId for IssueItem {
-
    fn id(&self) -> ObjectId {
-
        self.id
-
    }
-
}
-

-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub struct IssueItemFilter {
-
    state: Option<issue::State>,
-
    authored: bool,
-
    authors: Vec<Did>,
-
    assigned: bool,
-
    assignees: Vec<Did>,
-
    search: Option<String>,
-
}
-

-
impl IssueItemFilter {
-
    pub fn state(&self) -> Option<issue::State> {
-
        self.state
-
    }
-
}
-

-
impl filter::Filter<IssueItem> for IssueItemFilter {
-
    fn matches(&self, issue: &IssueItem) -> bool {
-
        use fuzzy_matcher::skim::SkimMatcherV2;
-
        use fuzzy_matcher::FuzzyMatcher;
-

-
        let matcher = SkimMatcherV2::default();
-

-
        let matches_state = match self.state {
-
            Some(issue::State::Closed {
-
                reason: CloseReason::Other,
-
            }) => matches!(issue.state, issue::State::Closed { .. }),
-
            Some(state) => issue.state == state,
-
            None => true,
-
        };
-

-
        let matches_authored = if self.authored {
-
            issue.author.you
-
        } else {
-
            true
-
        };
-

-
        let matches_authors = if !self.authors.is_empty() {
-
            {
-
                self.authors
-
                    .iter()
-
                    .any(|other| issue.author.nid == Some(**other))
-
            }
-
        } else {
-
            true
-
        };
-

-
        let matches_assigned = if self.assigned {
-
            issue.assignees.iter().any(|assignee| assignee.you)
-
        } else {
-
            true
-
        };
-

-
        let matches_assignees = if !self.assignees.is_empty() {
-
            {
-
                self.assignees.iter().any(|other| {
-
                    issue
-
                        .assignees
-
                        .iter()
-
                        .filter_map(|author| author.nid)
-
                        .collect::<Vec<_>>()
-
                        .contains(other)
-
                })
-
            }
-
        } else {
-
            true
-
        };
-

-
        let matches_search = match &self.search {
-
            Some(search) => match matcher.fuzzy_match(&issue.title, search) {
-
                Some(score) => score == 0 || score > 60,
-
                _ => false,
-
            },
-
            None => true,
-
        };
-

-
        matches_state
-
            && matches_authored
-
            && matches_authors
-
            && matches_assigned
-
            && matches_assignees
-
            && matches_search
-
    }
-
}
-

-
impl FromStr for IssueItemFilter {
-
    type Err = anyhow::Error;
-

-
    fn from_str(value: &str) -> Result<Self, Self::Err> {
-
        let mut state = None;
-
        let mut search = String::new();
-
        let mut authored = false;
-
        let mut authors = vec![];
-
        let mut assigned = false;
-
        let mut assignees = vec![];
-

-
        let mut authors_parser = |input| -> IResult<&str, Vec<&str>> {
-
            preceded(
-
                tag("authors:"),
-
                delimited(
-
                    tag("["),
-
                    separated_list0(tag(","), take(56_usize)),
-
                    tag("]"),
-
                ),
-
            )
-
            .parse(input)
-
        };
-

-
        let mut assignees_parser = |input| -> IResult<&str, Vec<&str>> {
-
            preceded(
-
                tag("assignees:"),
-
                delimited(
-
                    tag("["),
-
                    separated_list0(tag(","), take(56_usize)),
-
                    tag("]"),
-
                ),
-
            )
-
            .parse(input)
-
        };
-

-
        let parts = value.split(' ');
-
        for part in parts {
-
            match part {
-
                "is:open" => state = Some(issue::State::Open),
-
                "is:closed" => {
-
                    state = Some(issue::State::Closed {
-
                        reason: issue::CloseReason::Other,
-
                    })
-
                }
-
                "is:solved" => {
-
                    state = Some(issue::State::Closed {
-
                        reason: issue::CloseReason::Solved,
-
                    })
-
                }
-
                "is:authored" => authored = true,
-
                "is:assigned" => assigned = true,
-
                other => {
-
                    if let Ok((_, dids)) = assignees_parser.parse(other) {
-
                        for did in dids {
-
                            assignees.push(Did::from_str(did)?);
-
                        }
-
                    } else if let Ok((_, dids)) = authors_parser.parse(other) {
-
                        for did in dids {
-
                            authors.push(Did::from_str(did)?);
-
                        }
-
                    } else {
-
                        search.push_str(other);
-
                    }
-
                }
-
            }
-
        }
-

-
        Ok(Self {
-
            state,
-
            authored,
-
            authors,
-
            assigned,
-
            assignees,
-
            search: Some(search),
-
        })
-
    }
-
}
-

/// A `CommentItem` represents a comment COB and is constructed from an `Issue` and
/// a `Comment`.
#[derive(Clone, Debug)]
@@ -546,16 +254,21 @@ impl ToTree<String> for CommentItem {
#[cfg(test)]
mod tests {
    use anyhow::Result;
+
    use std::str::FromStr;
+

+
    use radicle::issue::State;
+

+
    use crate::ui::items::issue::IssueFilter;

    use super::*;

    #[test]
    fn issue_item_filter_from_str_should_succeed() -> Result<()> {
        let search = r#"is:open is:assigned assignees:[did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB,did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] is:authored authors:[did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] cli"#;
-
        let actual = IssueItemFilter::from_str(search)?;
+
        let actual = IssueFilter::from_str(search)?;

-
        let expected = IssueItemFilter {
-
            state: Some(issue::State::Open),
+
        let expected = IssueFilter {
+
            state: Some(State::Open),
            authors: vec![Did::from_str(
                "did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx",
            )?],
added bin/ui/items/issue.rs
@@ -0,0 +1,316 @@
+
use std::fmt::Debug;
+
use std::str::FromStr;
+

+
use nom::bytes::complete::{tag, take};
+
use nom::multi::separated_list0;
+
use nom::sequence::{delimited, preceded};
+
use nom::{IResult, Parser};
+

+
use radicle::cob::thread::CommentId;
+
use radicle::cob::{Label, ObjectId, Timestamp};
+
use radicle::issue::{CloseReason, IssueId};
+
use radicle::prelude::Did;
+
use radicle::Profile;
+

+
use ratatui::style::{Style, Stylize};
+
use ratatui::widgets::Cell;
+

+
use radicle_tui as tui;
+

+
use tui::ui::span;
+
use tui::ui::ToRow;
+

+
use crate::ui::format;
+
use crate::ui::items::filter::Filter;
+
use crate::ui::items::{AuthorItem, CommentItem, HasId};
+

+
#[derive(Clone, Debug)]
+
pub struct Issue {
+
    /// Issue OID.
+
    pub id: IssueId,
+
    /// Issue state.
+
    pub state: radicle::issue::State,
+
    /// Issue title.
+
    pub title: String,
+
    /// Issue author.
+
    pub author: AuthorItem,
+
    /// Issue labels.
+
    pub labels: Vec<Label>,
+
    /// Issue assignees.
+
    pub assignees: Vec<AuthorItem>,
+
    /// Time when issue was opened.
+
    pub timestamp: Timestamp,
+
    /// Comment timeline
+
    pub comments: Vec<CommentItem>,
+
}
+

+
impl Issue {
+
    pub fn new(
+
        profile: &Profile,
+
        issue: (IssueId, radicle::issue::Issue),
+
    ) -> Result<Self, anyhow::Error> {
+
        let (id, issue) = issue;
+

+
        Ok(Self {
+
            id,
+
            state: *issue.state(),
+
            title: issue.title().into(),
+
            author: AuthorItem::new(Some(*issue.author().id), profile),
+
            labels: issue.labels().cloned().collect(),
+
            assignees: issue
+
                .assignees()
+
                .map(|did| AuthorItem::new(Some(**did), profile))
+
                .collect::<Vec<_>>(),
+
            timestamp: issue.timestamp(),
+
            comments: issue
+
                .comments()
+
                .map(|(comment_id, comment)| {
+
                    CommentItem::new(profile, (id, issue.clone()), (*comment_id, comment.clone()))
+
                })
+
                .collect(),
+
        })
+
    }
+

+
    pub fn root_comments(&self) -> Vec<CommentItem> {
+
        self.comments
+
            .iter()
+
            .filter(|comment| comment.reply_to.is_none())
+
            .cloned()
+
            .collect::<Vec<_>>()
+
    }
+

+
    pub fn has_comment(&self, comment_id: &CommentId) -> bool {
+
        self.comments
+
            .iter()
+
            .any(|comment| comment.id == *comment_id)
+
    }
+

+
    pub fn path_to_comment(&self, comment_id: &CommentId) -> Option<Vec<CommentId>> {
+
        for comment in &self.comments {
+
            let mut path = Vec::new();
+
            if comment.path_to(comment_id, &mut path) {
+
                return Some(path);
+
            }
+
        }
+
        None
+
    }
+
}
+

+
impl ToRow<8> for Issue {
+
    fn to_row(&self) -> [Cell<'_>; 8] {
+
        let (state, state_color) = format::issue_state(&self.state);
+

+
        let state = span::default(&state).style(Style::default().fg(state_color));
+
        let id = span::primary(&format::cob(&self.id));
+
        let title = span::default(&self.title.clone());
+

+
        let author = match &self.author.alias {
+
            Some(alias) => {
+
                if self.author.you {
+
                    span::alias(&format!("{alias} (you)"))
+
                } else {
+
                    span::alias(alias)
+
                }
+
            }
+
            None => match &self.author.human_nid {
+
                Some(nid) => span::alias(nid).dim(),
+
                None => span::blank(),
+
            },
+
        };
+
        let did = match &self.author.human_nid {
+
            Some(nid) => span::alias(nid).dim(),
+
            None => span::blank(),
+
        };
+
        let labels = span::labels(&format::labels(&self.labels));
+
        let assignees = self
+
            .assignees
+
            .iter()
+
            .map(|author| (author.nid, author.alias.clone(), author.you))
+
            .collect::<Vec<_>>();
+
        let assignees = span::alias(&format::assignees(&assignees));
+
        let opened = span::timestamp(&format::timestamp(&self.timestamp));
+

+
        [
+
            state.into(),
+
            id.into(),
+
            title.into(),
+
            author.into(),
+
            did.into(),
+
            labels.into(),
+
            assignees.into(),
+
            opened.into(),
+
        ]
+
    }
+
}
+

+
impl HasId for Issue {
+
    fn id(&self) -> ObjectId {
+
        self.id
+
    }
+
}
+

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub(crate) struct IssueFilter {
+
    pub(crate) state: Option<radicle::issue::State>,
+
    pub(crate) authored: bool,
+
    pub(crate) authors: Vec<Did>,
+
    pub(crate) assigned: bool,
+
    pub(crate) assignees: Vec<Did>,
+
    pub(crate) search: Option<String>,
+
}
+

+
impl IssueFilter {
+
    pub fn state(&self) -> Option<radicle::issue::State> {
+
        self.state
+
    }
+
}
+

+
impl Filter<Issue> for IssueFilter {
+
    fn matches(&self, issue: &Issue) -> bool {
+
        use fuzzy_matcher::skim::SkimMatcherV2;
+
        use fuzzy_matcher::FuzzyMatcher;
+
        use radicle::issue::State;
+

+
        let matcher = SkimMatcherV2::default();
+

+
        let matches_state = match self.state {
+
            Some(State::Closed {
+
                reason: CloseReason::Other,
+
            }) => matches!(issue.state, State::Closed { .. }),
+
            Some(state) => issue.state == state,
+
            None => true,
+
        };
+

+
        let matches_authored = if self.authored {
+
            issue.author.you
+
        } else {
+
            true
+
        };
+

+
        let matches_authors = if !self.authors.is_empty() {
+
            {
+
                self.authors
+
                    .iter()
+
                    .any(|other| issue.author.nid == Some(**other))
+
            }
+
        } else {
+
            true
+
        };
+

+
        let matches_assigned = if self.assigned {
+
            issue.assignees.iter().any(|assignee| assignee.you)
+
        } else {
+
            true
+
        };
+

+
        let matches_assignees = if !self.assignees.is_empty() {
+
            {
+
                self.assignees.iter().any(|other| {
+
                    issue
+
                        .assignees
+
                        .iter()
+
                        .filter_map(|author| author.nid)
+
                        .collect::<Vec<_>>()
+
                        .contains(other)
+
                })
+
            }
+
        } else {
+
            true
+
        };
+

+
        let matches_search = match &self.search {
+
            Some(search) => match matcher.fuzzy_match(&issue.title, search) {
+
                Some(score) => score == 0 || score > 60,
+
                _ => false,
+
            },
+
            None => true,
+
        };
+

+
        matches_state
+
            && matches_authored
+
            && matches_authors
+
            && matches_assigned
+
            && matches_assignees
+
            && matches_search
+
    }
+
}
+

+
impl FromStr for IssueFilter {
+
    type Err = anyhow::Error;
+

+
    fn from_str(value: &str) -> Result<Self, Self::Err> {
+
        use radicle::issue::State;
+

+
        let mut state = None;
+
        let mut search = String::new();
+
        let mut authored = false;
+
        let mut authors = vec![];
+
        let mut assigned = false;
+
        let mut assignees = vec![];
+

+
        let mut authors_parser = |input| -> IResult<&str, Vec<&str>> {
+
            preceded(
+
                tag("authors:"),
+
                delimited(
+
                    tag("["),
+
                    separated_list0(tag(","), take(56_usize)),
+
                    tag("]"),
+
                ),
+
            )
+
            .parse(input)
+
        };
+

+
        let mut assignees_parser = |input| -> IResult<&str, Vec<&str>> {
+
            preceded(
+
                tag("assignees:"),
+
                delimited(
+
                    tag("["),
+
                    separated_list0(tag(","), take(56_usize)),
+
                    tag("]"),
+
                ),
+
            )
+
            .parse(input)
+
        };
+

+
        let parts = value.split(' ');
+
        for part in parts {
+
            match part {
+
                "is:open" => state = Some(State::Open),
+
                "is:closed" => {
+
                    state = Some(State::Closed {
+
                        reason: CloseReason::Other,
+
                    })
+
                }
+
                "is:solved" => {
+
                    state = Some(State::Closed {
+
                        reason: CloseReason::Solved,
+
                    })
+
                }
+
                "is:authored" => authored = true,
+
                "is:assigned" => assigned = true,
+
                other => {
+
                    if let Ok((_, dids)) = assignees_parser.parse(other) {
+
                        for did in dids {
+
                            assignees.push(Did::from_str(did)?);
+
                        }
+
                    } else if let Ok((_, dids)) = authors_parser.parse(other) {
+
                        for did in dids {
+
                            authors.push(Did::from_str(did)?);
+
                        }
+
                    } else {
+
                        search.push_str(other);
+
                    }
+
                }
+
            }
+
        }
+

+
        Ok(Self {
+
            state,
+
            authored,
+
            authors,
+
            assigned,
+
            assignees,
+
            search: Some(search),
+
        })
+
    }
+
}
modified bin/ui/rm.rs
@@ -16,9 +16,9 @@ use tui::ui::theme::style;
use tui::ui::{layout, span, BufferedValue};

use super::format;
-
use super::items::IssueItem;

use crate::ui::items::filter::Filter;
+
use crate::ui::items::issue::Issue;
use crate::ui::items::HasId;

/// A `BrowserState` represents the internal state of a browser widget.
@@ -151,12 +151,12 @@ where

#[derive(Clone, Default)]
pub struct IssueDetailsProps {
-
    issue: Option<IssueItem>,
+
    issue: Option<Issue>,
    dim: bool,
}

impl IssueDetailsProps {
-
    pub fn issue(mut self, issue: Option<IssueItem>) -> Self {
+
    pub fn issue(mut self, issue: Option<Issue>) -> Self {
        self.issue = issue;
        self
    }