feat(tickets): filtres multi-critères par cases à cocher (backend + frontend)
Passe les filtres de la liste des tickets en sélection multiple (#12). Backend Rust : - IssueListFilter : statuses/priorities en Vec, filter_matches en OR intra-champ et AND inter-champs - TicketListRequestDto en tableaux + from_request (parse/déduplication) - MCP idea_ticket_list aligné sur le nouveau contrat Frontend : - TicketListQuery.statuses/priorities - UI de cases à cocher, toggle/clear des filtres Tests verts : issue_store (7), app-tauri --lib (59), mcp_server (24), issue_usecases (6), sprint_usecases (6), frontend vitest (505), npm run build (exit 0). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -195,8 +195,10 @@ pub struct TicketDeleteRequestDto {
|
||||
pub struct TicketListRequestDto {
|
||||
#[serde(default)]
|
||||
pub project_id: String,
|
||||
pub status: Option<String>,
|
||||
pub priority: Option<String>,
|
||||
#[serde(default)]
|
||||
pub statuses: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub priorities: Vec<String>,
|
||||
pub assigned_agent_id: Option<String>,
|
||||
pub sprint_id: Option<String>,
|
||||
pub text: Option<String>,
|
||||
@ -1109,6 +1111,7 @@ impl From<IssueCarnet> for TicketCarnetDto {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TicketListPageInput {
|
||||
filter: IssueListFilter,
|
||||
limit: usize,
|
||||
@ -1119,16 +1122,8 @@ impl TicketListPageInput {
|
||||
fn from_request(request: TicketListRequestDto) -> Result<Self, ErrorDto> {
|
||||
Ok(Self {
|
||||
filter: IssueListFilter {
|
||||
status: request
|
||||
.status
|
||||
.as_deref()
|
||||
.map(parse_status_dto)
|
||||
.transpose()?,
|
||||
priority: request
|
||||
.priority
|
||||
.as_deref()
|
||||
.map(parse_priority_dto)
|
||||
.transpose()?,
|
||||
statuses: parse_statuses_dto(request.statuses)?,
|
||||
priorities: parse_priorities_dto(request.priorities)?,
|
||||
assigned_agent_id: request
|
||||
.assigned_agent_id
|
||||
.as_deref()
|
||||
@ -1349,6 +1344,28 @@ fn parse_priority_dto(raw: &str) -> Result<IssuePriority, ErrorDto> {
|
||||
parse_priority(raw).map_err(|e| ErrorDto::invalid(e.message))
|
||||
}
|
||||
|
||||
fn parse_statuses_dto(raw: Vec<String>) -> Result<Vec<IssueStatus>, ErrorDto> {
|
||||
let mut out = Vec::new();
|
||||
for item in raw {
|
||||
let status = parse_status_dto(&item)?;
|
||||
if !out.contains(&status) {
|
||||
out.push(status);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn parse_priorities_dto(raw: Vec<String>) -> Result<Vec<IssuePriority>, ErrorDto> {
|
||||
let mut out = Vec::new();
|
||||
for item in raw {
|
||||
let priority = parse_priority_dto(&item)?;
|
||||
if !out.contains(&priority) {
|
||||
out.push(priority);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn parse_link_kind_dto(raw: &str) -> Result<IssueLinkKind, ErrorDto> {
|
||||
parse_link_kind(raw).map_err(|e| ErrorDto::invalid(e.message))
|
||||
}
|
||||
@ -1524,3 +1541,99 @@ impl ErrorDto {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::IssueNumber;
|
||||
|
||||
#[test]
|
||||
fn ticket_list_request_deduplicates_multi_select_filters() {
|
||||
let page = TicketListPageInput::from_request(TicketListRequestDto {
|
||||
project_id: String::new(),
|
||||
statuses: vec!["open".into(), "closed".into(), "open".into()],
|
||||
priorities: vec!["high".into(), "low".into(), "high".into()],
|
||||
assigned_agent_id: None,
|
||||
sprint_id: None,
|
||||
text: None,
|
||||
limit: None,
|
||||
cursor: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
page.filter.statuses,
|
||||
vec![IssueStatus::Open, IssueStatus::Closed]
|
||||
);
|
||||
assert_eq!(
|
||||
page.filter.priorities,
|
||||
vec![IssuePriority::High, IssuePriority::Low]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_list_request_rejects_invalid_multi_select_token() {
|
||||
let err = TicketListPageInput::from_request(TicketListRequestDto {
|
||||
project_id: String::new(),
|
||||
statuses: vec!["open".into(), "bad".into()],
|
||||
priorities: Vec::new(),
|
||||
assigned_agent_id: None,
|
||||
sprint_id: None,
|
||||
text: None,
|
||||
limit: None,
|
||||
cursor: None,
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code, "INVALID");
|
||||
assert!(err.message.contains("invalid ticket status: bad"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_list_pagination_preserves_multi_filter_request_shape() {
|
||||
let page = TicketListPageInput::from_request(TicketListRequestDto {
|
||||
project_id: String::new(),
|
||||
statuses: vec!["open".into(), "QA".into()],
|
||||
priorities: vec!["high".into()],
|
||||
assigned_agent_id: None,
|
||||
sprint_id: None,
|
||||
text: None,
|
||||
limit: Some(1),
|
||||
cursor: Some("1".into()),
|
||||
})
|
||||
.unwrap();
|
||||
let rows = vec![
|
||||
issue_row(1, IssueStatus::Open, IssuePriority::High, "Alpha"),
|
||||
issue_row(2, IssueStatus::Qa, IssuePriority::High, "Beta"),
|
||||
issue_row(3, IssueStatus::Qa, IssuePriority::High, "Gamma"),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
page.filter.statuses,
|
||||
vec![IssueStatus::Open, IssueStatus::Qa]
|
||||
);
|
||||
assert_eq!(page.filter.priorities, vec![IssuePriority::High]);
|
||||
let out = paginate(rows, page.limit, page.cursor);
|
||||
assert_eq!(out.items.len(), 1);
|
||||
assert_eq!(out.items[0].r#ref, "#2");
|
||||
assert_eq!(out.next_cursor, Some("2".to_owned()));
|
||||
}
|
||||
|
||||
fn issue_row(
|
||||
number: u64,
|
||||
status: IssueStatus,
|
||||
priority: IssuePriority,
|
||||
title: &str,
|
||||
) -> IssueIndexEntry {
|
||||
IssueIndexEntry {
|
||||
issue_ref: IssueRef::from(IssueNumber::new(number).unwrap()),
|
||||
path: number.to_string(),
|
||||
title: title.to_owned(),
|
||||
status,
|
||||
priority,
|
||||
sprint: None,
|
||||
assigned_agent_ids: Vec::new(),
|
||||
updated_at: number,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -426,10 +426,10 @@ impl From<&Issue> for IssueIndexEntry {
|
||||
/// Store-side list filter.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct IssueListFilter {
|
||||
/// Optional status filter.
|
||||
pub status: Option<IssueStatus>,
|
||||
/// Optional priority filter.
|
||||
pub priority: Option<IssuePriority>,
|
||||
/// Allowed statuses. Empty means every status.
|
||||
pub statuses: Vec<IssueStatus>,
|
||||
/// Allowed priorities. Empty means every priority.
|
||||
pub priorities: Vec<IssuePriority>,
|
||||
/// Optional assigned agent filter.
|
||||
pub assigned_agent_id: Option<AgentId>,
|
||||
/// Optional sprint membership filter.
|
||||
|
||||
@ -231,13 +231,10 @@ async fn write_index(root: &ProjectPath, rows: &[IssueIndexEntry]) -> Result<(),
|
||||
}
|
||||
|
||||
fn filter_matches(row: &IssueIndexEntry, filter: &IssueListFilter) -> bool {
|
||||
if filter.status.is_some_and(|status| row.status != status) {
|
||||
if !filter.statuses.is_empty() && !filter.statuses.contains(&row.status) {
|
||||
return false;
|
||||
}
|
||||
if filter
|
||||
.priority
|
||||
.is_some_and(|priority| row.priority != priority)
|
||||
{
|
||||
if !filter.priorities.is_empty() && !filter.priorities.contains(&row.priority) {
|
||||
return false;
|
||||
}
|
||||
if let Some(agent_id) = filter.assigned_agent_id {
|
||||
|
||||
@ -125,8 +125,16 @@ pub fn catalogue() -> Vec<ToolDef> {
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": status.clone(),
|
||||
"priority": priority.clone(),
|
||||
"statuses": {
|
||||
"type": "array",
|
||||
"items": status.clone(),
|
||||
"uniqueItems": true
|
||||
},
|
||||
"priorities": {
|
||||
"type": "array",
|
||||
"items": priority.clone(),
|
||||
"uniqueItems": true
|
||||
},
|
||||
"assignedAgentId": { "type": "string", "format": "uuid" },
|
||||
"text": { "type": "string" },
|
||||
"limit": { "type": "integer", "minimum": 1 },
|
||||
|
||||
@ -112,7 +112,7 @@ async fn issue_store_update_checks_expected_version() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn issue_store_lists_by_index_filters() {
|
||||
async fn issue_store_lists_by_index_filters_with_empty_or_and_and_semantics() {
|
||||
let tmp = TempDir::new();
|
||||
let root = tmp.root();
|
||||
let store = FsIssueStore::new();
|
||||
@ -127,21 +127,61 @@ async fn issue_store_lists_by_index_filters() {
|
||||
})
|
||||
.unwrap();
|
||||
store.create(&root, &closed).await.unwrap();
|
||||
let qa_high = issue(&root, 3, "Gamma")
|
||||
.mutate(IssueActor::System, 3_000, |i| {
|
||||
i.status = IssueStatus::Qa;
|
||||
i.priority = IssuePriority::Critical;
|
||||
})
|
||||
.unwrap();
|
||||
store.create(&root, &qa_high).await.unwrap();
|
||||
|
||||
let rows = store
|
||||
let all = store.list(&root, IssueListFilter::default()).await.unwrap();
|
||||
assert_eq!(all.len(), 3);
|
||||
|
||||
let by_status_or = store
|
||||
.list(
|
||||
&root,
|
||||
IssueListFilter {
|
||||
status: Some(IssueStatus::Closed),
|
||||
priority: Some(IssuePriority::Low),
|
||||
statuses: vec![IssueStatus::Closed, IssueStatus::Qa],
|
||||
..IssueListFilter::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].title, "Beta");
|
||||
assert_eq!(by_status_or.len(), 2);
|
||||
assert_eq!(by_status_or[0].title, "Beta");
|
||||
assert_eq!(by_status_or[1].title, "Gamma");
|
||||
|
||||
let by_priority_or = store
|
||||
.list(
|
||||
&root,
|
||||
IssueListFilter {
|
||||
priorities: vec![IssuePriority::Low, IssuePriority::Critical],
|
||||
..IssueListFilter::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(by_priority_or.len(), 2);
|
||||
assert_eq!(by_priority_or[0].title, "Beta");
|
||||
assert_eq!(by_priority_or[1].title, "Gamma");
|
||||
|
||||
let by_status_and_priority = store
|
||||
.list(
|
||||
&root,
|
||||
IssueListFilter {
|
||||
statuses: vec![IssueStatus::Closed, IssueStatus::Qa],
|
||||
priorities: vec![IssuePriority::Low],
|
||||
..IssueListFilter::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(by_status_and_priority.len(), 1);
|
||||
assert_eq!(by_status_and_priority[0].title, "Beta");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user