Merge feature/ticket-filter-checkboxes into develop
Filtres multi-critères par cases à cocher (#12) : backend (IssueListFilter en Vec statuses/priorities, filter_matches OR/AND, DTO en tableaux, MCP idea_ticket_list aligné) et frontend (TicketListQuery multi-valeurs, UI checkboxes, toggle/clear). Tests verts. 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 {
|
pub struct TicketListRequestDto {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub project_id: String,
|
pub project_id: String,
|
||||||
pub status: Option<String>,
|
#[serde(default)]
|
||||||
pub priority: Option<String>,
|
pub statuses: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub priorities: Vec<String>,
|
||||||
pub assigned_agent_id: Option<String>,
|
pub assigned_agent_id: Option<String>,
|
||||||
pub sprint_id: Option<String>,
|
pub sprint_id: Option<String>,
|
||||||
pub text: Option<String>,
|
pub text: Option<String>,
|
||||||
@ -1109,6 +1111,7 @@ impl From<IssueCarnet> for TicketCarnetDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
struct TicketListPageInput {
|
struct TicketListPageInput {
|
||||||
filter: IssueListFilter,
|
filter: IssueListFilter,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
@ -1119,16 +1122,8 @@ impl TicketListPageInput {
|
|||||||
fn from_request(request: TicketListRequestDto) -> Result<Self, ErrorDto> {
|
fn from_request(request: TicketListRequestDto) -> Result<Self, ErrorDto> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
filter: IssueListFilter {
|
filter: IssueListFilter {
|
||||||
status: request
|
statuses: parse_statuses_dto(request.statuses)?,
|
||||||
.status
|
priorities: parse_priorities_dto(request.priorities)?,
|
||||||
.as_deref()
|
|
||||||
.map(parse_status_dto)
|
|
||||||
.transpose()?,
|
|
||||||
priority: request
|
|
||||||
.priority
|
|
||||||
.as_deref()
|
|
||||||
.map(parse_priority_dto)
|
|
||||||
.transpose()?,
|
|
||||||
assigned_agent_id: request
|
assigned_agent_id: request
|
||||||
.assigned_agent_id
|
.assigned_agent_id
|
||||||
.as_deref()
|
.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))
|
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> {
|
fn parse_link_kind_dto(raw: &str) -> Result<IssueLinkKind, ErrorDto> {
|
||||||
parse_link_kind(raw).map_err(|e| ErrorDto::invalid(e.message))
|
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.
|
/// Store-side list filter.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
pub struct IssueListFilter {
|
pub struct IssueListFilter {
|
||||||
/// Optional status filter.
|
/// Allowed statuses. Empty means every status.
|
||||||
pub status: Option<IssueStatus>,
|
pub statuses: Vec<IssueStatus>,
|
||||||
/// Optional priority filter.
|
/// Allowed priorities. Empty means every priority.
|
||||||
pub priority: Option<IssuePriority>,
|
pub priorities: Vec<IssuePriority>,
|
||||||
/// Optional assigned agent filter.
|
/// Optional assigned agent filter.
|
||||||
pub assigned_agent_id: Option<AgentId>,
|
pub assigned_agent_id: Option<AgentId>,
|
||||||
/// Optional sprint membership filter.
|
/// 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 {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
if filter
|
if !filter.priorities.is_empty() && !filter.priorities.contains(&row.priority) {
|
||||||
.priority
|
|
||||||
.is_some_and(|priority| row.priority != priority)
|
|
||||||
{
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if let Some(agent_id) = filter.assigned_agent_id {
|
if let Some(agent_id) = filter.assigned_agent_id {
|
||||||
|
|||||||
@ -125,8 +125,16 @@ pub fn catalogue() -> Vec<ToolDef> {
|
|||||||
input_schema: json!({
|
input_schema: json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"status": status.clone(),
|
"statuses": {
|
||||||
"priority": priority.clone(),
|
"type": "array",
|
||||||
|
"items": status.clone(),
|
||||||
|
"uniqueItems": true
|
||||||
|
},
|
||||||
|
"priorities": {
|
||||||
|
"type": "array",
|
||||||
|
"items": priority.clone(),
|
||||||
|
"uniqueItems": true
|
||||||
|
},
|
||||||
"assignedAgentId": { "type": "string", "format": "uuid" },
|
"assignedAgentId": { "type": "string", "format": "uuid" },
|
||||||
"text": { "type": "string" },
|
"text": { "type": "string" },
|
||||||
"limit": { "type": "integer", "minimum": 1 },
|
"limit": { "type": "integer", "minimum": 1 },
|
||||||
|
|||||||
@ -112,7 +112,7 @@ async fn issue_store_update_checks_expected_version() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 tmp = TempDir::new();
|
||||||
let root = tmp.root();
|
let root = tmp.root();
|
||||||
let store = FsIssueStore::new();
|
let store = FsIssueStore::new();
|
||||||
@ -127,21 +127,61 @@ async fn issue_store_lists_by_index_filters() {
|
|||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
store.create(&root, &closed).await.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(
|
.list(
|
||||||
&root,
|
&root,
|
||||||
IssueListFilter {
|
IssueListFilter {
|
||||||
status: Some(IssueStatus::Closed),
|
statuses: vec![IssueStatus::Closed, IssueStatus::Qa],
|
||||||
priority: Some(IssuePriority::Low),
|
|
||||||
..IssueListFilter::default()
|
..IssueListFilter::default()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(rows.len(), 1);
|
assert_eq!(by_status_or.len(), 2);
|
||||||
assert_eq!(rows[0].title, "Beta");
|
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]
|
#[tokio::test]
|
||||||
|
|||||||
@ -1951,9 +1951,13 @@ export class MockTicketGateway implements TicketGateway {
|
|||||||
async list(projectId: string, query?: TicketListQuery): Promise<TicketList> {
|
async list(projectId: string, query?: TicketListQuery): Promise<TicketList> {
|
||||||
const q = query ?? {};
|
const q = query ?? {};
|
||||||
const text = q.text?.trim().toLowerCase();
|
const text = q.text?.trim().toLowerCase();
|
||||||
|
// Multi-select facets (ticket #12): OR within a facet, AND across facets; an
|
||||||
|
// empty/absent set means "no constraint" (all values pass).
|
||||||
|
const statuses = q.statuses ?? [];
|
||||||
|
const priorities = q.priorities ?? [];
|
||||||
const rows = [...this.projectTickets(projectId).values()]
|
const rows = [...this.projectTickets(projectId).values()]
|
||||||
.filter((t) => !q.status || t.status === q.status)
|
.filter((t) => statuses.length === 0 || statuses.includes(t.status))
|
||||||
.filter((t) => !q.priority || t.priority === q.priority)
|
.filter((t) => priorities.length === 0 || priorities.includes(t.priority))
|
||||||
.filter(
|
.filter(
|
||||||
(t) =>
|
(t) =>
|
||||||
!q.assignedAgentId || t.assignedAgentIds.includes(q.assignedAgentId),
|
!q.assignedAgentId || t.assignedAgentIds.includes(q.assignedAgentId),
|
||||||
|
|||||||
@ -58,8 +58,16 @@ export class TauriTicketGateway implements TicketGateway {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async list(projectId: string, query?: TicketListQuery): Promise<TicketList> {
|
async list(projectId: string, query?: TicketListQuery): Promise<TicketList> {
|
||||||
|
const { statuses, priorities, ...rest } = query ?? {};
|
||||||
|
// Multi-select facets (ticket #12): the backend expects `statuses`/
|
||||||
|
// `priorities` arrays (empty ⇒ no constraint on that facet).
|
||||||
return invoke<TicketList>("ticket_list", {
|
return invoke<TicketList>("ticket_list", {
|
||||||
request: { projectId, ...(query ?? {}) },
|
request: {
|
||||||
|
projectId,
|
||||||
|
statuses: statuses ?? [],
|
||||||
|
priorities: priorities ?? [],
|
||||||
|
...rest,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,12 +9,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import type {
|
import type { Sprint, TicketPriority, TicketSummary } from "@/domain";
|
||||||
Sprint,
|
|
||||||
TicketPriority,
|
|
||||||
TicketStatus,
|
|
||||||
TicketSummary,
|
|
||||||
} from "@/domain";
|
|
||||||
import { Button, Input, Panel, Spinner, cn } from "@/shared";
|
import { Button, Input, Panel, Spinner, cn } from "@/shared";
|
||||||
import { useTickets } from "./useTickets";
|
import { useTickets } from "./useTickets";
|
||||||
import { useProjectAgents } from "./useProjectAgents";
|
import { useProjectAgents } from "./useProjectAgents";
|
||||||
@ -165,45 +160,53 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
|||||||
vm.setQuery({ ...vm.query, text: e.target.value || undefined })
|
vm.setQuery({ ...vm.query, text: e.target.value || undefined })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* Multi-select facets (ticket #12): OR within a facet, AND across; no
|
||||||
|
box ticked ⇒ that facet is unconstrained. */}
|
||||||
|
<fieldset
|
||||||
|
aria-label="filter by status"
|
||||||
|
className="flex flex-wrap items-center gap-x-3 gap-y-1"
|
||||||
|
>
|
||||||
|
<legend className="mr-1 text-xs font-medium text-muted">Statut</legend>
|
||||||
|
{TICKET_STATUSES.map((s) => (
|
||||||
|
<label
|
||||||
|
key={s}
|
||||||
|
className="flex cursor-pointer items-center gap-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`filter status ${statusLabel(s)}`}
|
||||||
|
className="accent-primary"
|
||||||
|
checked={(vm.query.statuses ?? []).includes(s)}
|
||||||
|
onChange={() => vm.toggleStatus(s)}
|
||||||
|
/>
|
||||||
|
<StatusBadge status={s} />
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
<fieldset
|
||||||
|
aria-label="filter by priority"
|
||||||
|
className="flex flex-wrap items-center gap-x-3 gap-y-1"
|
||||||
|
>
|
||||||
|
<legend className="mr-1 text-xs font-medium text-muted">
|
||||||
|
Priorité
|
||||||
|
</legend>
|
||||||
|
{TICKET_PRIORITIES.map((p) => (
|
||||||
|
<label
|
||||||
|
key={p}
|
||||||
|
className="flex cursor-pointer items-center gap-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`filter priority ${priorityLabel(p)}`}
|
||||||
|
className="accent-primary"
|
||||||
|
checked={(vm.query.priorities ?? []).includes(p)}
|
||||||
|
onChange={() => vm.togglePriority(p)}
|
||||||
|
/>
|
||||||
|
<PriorityBadge priority={p} />
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<select
|
|
||||||
aria-label="filter by status"
|
|
||||||
className={selectClass}
|
|
||||||
value={vm.query.status ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
vm.setQuery({
|
|
||||||
...vm.query,
|
|
||||||
status: (e.target.value || undefined) as TicketStatus | undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">All statuses</option>
|
|
||||||
{TICKET_STATUSES.map((s) => (
|
|
||||||
<option key={s} value={s}>
|
|
||||||
{statusLabel(s)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
aria-label="filter by priority"
|
|
||||||
className={selectClass}
|
|
||||||
value={vm.query.priority ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
vm.setQuery({
|
|
||||||
...vm.query,
|
|
||||||
priority: (e.target.value || undefined) as
|
|
||||||
| TicketPriority
|
|
||||||
| undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">All priorities</option>
|
|
||||||
{TICKET_PRIORITIES.map((p) => (
|
|
||||||
<option key={p} value={p}>
|
|
||||||
{priorityLabel(p)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
<select
|
||||||
aria-label="filter by assignee"
|
aria-label="filter by assignee"
|
||||||
className={selectClass}
|
className={selectClass}
|
||||||
@ -222,6 +225,17 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{((vm.query.statuses?.length ?? 0) > 0 ||
|
||||||
|
(vm.query.priorities?.length ?? 0) > 0) && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label="clear filters"
|
||||||
|
onClick={() => vm.clearFacets()}
|
||||||
|
>
|
||||||
|
Effacer
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -78,7 +78,7 @@ describe("MockTicketGateway", () => {
|
|||||||
expect(events).toContain("issueCreated");
|
expect(events).toContain("issueCreated");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters by status and searches text", async () => {
|
it("filters by statuses (OR within facet) and searches text (#12)", async () => {
|
||||||
await ticket.create(PROJECT_ID, { title: "alpha bug", status: "open" });
|
await ticket.create(PROJECT_ID, { title: "alpha bug", status: "open" });
|
||||||
const t2 = await ticket.create(PROJECT_ID, { title: "beta task" });
|
const t2 = await ticket.create(PROJECT_ID, { title: "beta task" });
|
||||||
await ticket.update(PROJECT_ID, t2.ref, {
|
await ticket.update(PROJECT_ID, t2.ref, {
|
||||||
@ -86,13 +86,55 @@ describe("MockTicketGateway", () => {
|
|||||||
expectedVersion: t2.version,
|
expectedVersion: t2.version,
|
||||||
});
|
});
|
||||||
|
|
||||||
const open = await ticket.list(PROJECT_ID, { status: "open" });
|
const open = await ticket.list(PROJECT_ID, { statuses: ["open"] });
|
||||||
expect(open.items.map((i) => i.ref)).toEqual(["#1"]);
|
expect(open.items.map((i) => i.ref)).toEqual(["#1"]);
|
||||||
|
|
||||||
|
// OR within the status facet: both values pass.
|
||||||
|
const either = await ticket.list(PROJECT_ID, {
|
||||||
|
statuses: ["open", "closed"],
|
||||||
|
});
|
||||||
|
expect(either.items.map((i) => i.ref).sort()).toEqual(["#1", "#2"]);
|
||||||
|
|
||||||
|
// An empty set ⇒ no status constraint (all pass).
|
||||||
|
const all = await ticket.list(PROJECT_ID, { statuses: [] });
|
||||||
|
expect(all.items).toHaveLength(2);
|
||||||
|
|
||||||
const search = await ticket.list(PROJECT_ID, { text: "beta" });
|
const search = await ticket.list(PROJECT_ID, { text: "beta" });
|
||||||
expect(search.items.map((i) => i.ref)).toEqual(["#2"]);
|
expect(search.items.map((i) => i.ref)).toEqual(["#2"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("combines status and priority facets with AND (#12)", async () => {
|
||||||
|
await ticket.create(PROJECT_ID, {
|
||||||
|
title: "a",
|
||||||
|
status: "open",
|
||||||
|
priority: "high",
|
||||||
|
});
|
||||||
|
await ticket.create(PROJECT_ID, {
|
||||||
|
title: "b",
|
||||||
|
status: "open",
|
||||||
|
priority: "low",
|
||||||
|
});
|
||||||
|
await ticket.create(PROJECT_ID, {
|
||||||
|
title: "c",
|
||||||
|
status: "closed",
|
||||||
|
priority: "high",
|
||||||
|
});
|
||||||
|
|
||||||
|
// statuses:[open] AND priorities:[high] ⇒ only #1.
|
||||||
|
const both = await ticket.list(PROJECT_ID, {
|
||||||
|
statuses: ["open"],
|
||||||
|
priorities: ["high"],
|
||||||
|
});
|
||||||
|
expect(both.items.map((i) => i.ref)).toEqual(["#1"]);
|
||||||
|
|
||||||
|
// OR within status (open|closed) AND priorities:[high] ⇒ #1 and #3.
|
||||||
|
const mix = await ticket.list(PROJECT_ID, {
|
||||||
|
statuses: ["open", "closed"],
|
||||||
|
priorities: ["high"],
|
||||||
|
});
|
||||||
|
expect(mix.items.map((i) => i.ref).sort()).toEqual(["#1", "#3"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects a stale write with a version conflict", async () => {
|
it("rejects a stale write with a version conflict", async () => {
|
||||||
const t = await ticket.create(PROJECT_ID, { title: "x" });
|
const t = await ticket.create(PROJECT_ID, { title: "x" });
|
||||||
await ticket.update(PROJECT_ID, t.ref, {
|
await ticket.update(PROJECT_ID, t.ref, {
|
||||||
@ -172,6 +214,59 @@ describe("TicketsView", () => {
|
|||||||
expect(await screen.findByText("Live created")).toBeTruthy();
|
expect(await screen.findByText("Live created")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("multi-selects status/priority facets (OR intra, AND inter) and clears (#12)", async () => {
|
||||||
|
const listSpy = vi.spyOn(ticket, "list");
|
||||||
|
await ticket.create(PROJECT_ID, {
|
||||||
|
title: "Alpha",
|
||||||
|
status: "open",
|
||||||
|
priority: "high",
|
||||||
|
});
|
||||||
|
await ticket.create(PROJECT_ID, {
|
||||||
|
title: "Bravo",
|
||||||
|
status: "closed",
|
||||||
|
priority: "low",
|
||||||
|
});
|
||||||
|
await ticket.create(PROJECT_ID, {
|
||||||
|
title: "Charlie",
|
||||||
|
status: "QA",
|
||||||
|
priority: "high",
|
||||||
|
});
|
||||||
|
|
||||||
|
renderView(ticket, system, agent);
|
||||||
|
|
||||||
|
// All three visible initially (no facet ⇒ all pass).
|
||||||
|
await screen.findByText("Alpha");
|
||||||
|
expect(screen.getByText("Bravo")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Charlie")).toBeTruthy();
|
||||||
|
|
||||||
|
// Tick two statuses → OR within the facet ⇒ open|closed ⇒ Alpha + Bravo,
|
||||||
|
// Charlie (QA) drops out.
|
||||||
|
fireEvent.click(screen.getByLabelText("filter status Open"));
|
||||||
|
fireEvent.click(screen.getByLabelText("filter status Closed"));
|
||||||
|
await waitFor(() => expect(screen.queryByText("Charlie")).toBeNull());
|
||||||
|
expect(screen.getByText("Alpha")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Bravo")).toBeTruthy();
|
||||||
|
|
||||||
|
// Add a priority → AND across facets ⇒ (open|closed) AND high ⇒ only Alpha.
|
||||||
|
fireEvent.click(screen.getByLabelText("filter priority High"));
|
||||||
|
await waitFor(() => expect(screen.queryByText("Bravo")).toBeNull());
|
||||||
|
expect(screen.getByText("Alpha")).toBeTruthy();
|
||||||
|
|
||||||
|
// The gateway received the expected multi-select query.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(listSpy.mock.calls.at(-1)?.[1]).toMatchObject({
|
||||||
|
statuses: ["open", "closed"],
|
||||||
|
priorities: ["high"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// « Effacer » empties both facets ⇒ all three back.
|
||||||
|
fireEvent.click(screen.getByLabelText("clear filters"));
|
||||||
|
await waitFor(() => expect(screen.getByText("Charlie")).toBeTruthy());
|
||||||
|
expect(screen.getByText("Bravo")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Alpha")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("opens the detail and edits status with a version bump", async () => {
|
it("opens the detail and edits status with a version bump", async () => {
|
||||||
const t = await ticket.create(PROJECT_ID, { title: "Editable" });
|
const t = await ticket.create(PROJECT_ID, { title: "Editable" });
|
||||||
renderView(ticket, system, agent);
|
renderView(ticket, system, agent);
|
||||||
|
|||||||
@ -20,6 +20,8 @@ import {
|
|||||||
type Sprint,
|
type Sprint,
|
||||||
type Ticket,
|
type Ticket,
|
||||||
type TicketList,
|
type TicketList,
|
||||||
|
type TicketPriority,
|
||||||
|
type TicketStatus,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import type { CreateTicketInput, TicketListQuery } from "@/ports";
|
import type { CreateTicketInput, TicketListQuery } from "@/ports";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
@ -32,6 +34,16 @@ export interface TicketsViewModel {
|
|||||||
busy: boolean;
|
busy: boolean;
|
||||||
query: TicketListQuery;
|
query: TicketListQuery;
|
||||||
setQuery: (next: TicketListQuery) => void;
|
setQuery: (next: TicketListQuery) => void;
|
||||||
|
/**
|
||||||
|
* Toggles a status in the multi-select filter (ticket #12): adds it when
|
||||||
|
* absent, removes it when present. An empty set ⇒ no status constraint (all).
|
||||||
|
* The list re-fetches automatically as the query changes.
|
||||||
|
*/
|
||||||
|
toggleStatus: (status: TicketStatus) => void;
|
||||||
|
/** Toggles a priority in the multi-select filter (ticket #12). */
|
||||||
|
togglePriority: (priority: TicketPriority) => void;
|
||||||
|
/** Clears both facet filters (status + priority), leaving other criteria. */
|
||||||
|
clearFacets: () => void;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
create: (input: CreateTicketInput) => Promise<Ticket | null>;
|
create: (input: CreateTicketInput) => Promise<Ticket | null>;
|
||||||
/**
|
/**
|
||||||
@ -89,6 +101,35 @@ export function useTickets(projectId: string): TicketsViewModel {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [projectId, ticket, queryKey]);
|
}, [projectId, ticket, queryKey]);
|
||||||
|
|
||||||
|
// Toggle a value in one facet set, dropping the key entirely when it empties
|
||||||
|
// so the query stays clean ("no constraint"). Setting a fresh object identity
|
||||||
|
// re-fetches via the `queryKey` effect (ticket #12).
|
||||||
|
const toggleStatus = useCallback((status: TicketStatus) => {
|
||||||
|
setQuery((prev) => {
|
||||||
|
const current = prev.statuses ?? [];
|
||||||
|
const next = current.includes(status)
|
||||||
|
? current.filter((s) => s !== status)
|
||||||
|
: [...current, status];
|
||||||
|
const { statuses: _drop, ...rest } = prev;
|
||||||
|
return next.length > 0 ? { ...rest, statuses: next } : rest;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const togglePriority = useCallback((priority: TicketPriority) => {
|
||||||
|
setQuery((prev) => {
|
||||||
|
const current = prev.priorities ?? [];
|
||||||
|
const next = current.includes(priority)
|
||||||
|
? current.filter((p) => p !== priority)
|
||||||
|
: [...current, priority];
|
||||||
|
const { priorities: _drop, ...rest } = prev;
|
||||||
|
return next.length > 0 ? { ...rest, priorities: next } : rest;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearFacets = useCallback(() => {
|
||||||
|
setQuery(({ statuses: _s, priorities: _p, ...rest }) => rest);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const refreshSprints = useCallback(async () => {
|
const refreshSprints = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setSprints(await ticket.listSprints(projectId));
|
setSprints(await ticket.listSprints(projectId));
|
||||||
@ -242,6 +283,9 @@ export function useTickets(projectId: string): TicketsViewModel {
|
|||||||
busy,
|
busy,
|
||||||
query,
|
query,
|
||||||
setQuery,
|
setQuery,
|
||||||
|
toggleStatus,
|
||||||
|
togglePriority,
|
||||||
|
clearFacets,
|
||||||
refresh,
|
refresh,
|
||||||
create,
|
create,
|
||||||
assignSprint,
|
assignSprint,
|
||||||
|
|||||||
@ -707,10 +707,16 @@ export interface ConversationGateway {
|
|||||||
): Promise<TurnPage>;
|
): Promise<TurnPage>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Filter/search criteria for {@link TicketGateway.list}. */
|
/**
|
||||||
|
* Filter/search criteria for {@link TicketGateway.list} (ticket #12).
|
||||||
|
*
|
||||||
|
* Status and priority are **multi-select**: `statuses`/`priorities` are sets of
|
||||||
|
* accepted values with OR semantics *within* a facet and AND *across* facets. An
|
||||||
|
* empty or absent array means "no constraint on this facet" (all values pass).
|
||||||
|
*/
|
||||||
export interface TicketListQuery {
|
export interface TicketListQuery {
|
||||||
status?: TicketStatus;
|
statuses?: TicketStatus[];
|
||||||
priority?: TicketPriority;
|
priorities?: TicketPriority[];
|
||||||
assignedAgentId?: string;
|
assignedAgentId?: string;
|
||||||
/** Free-text match over title/description. */
|
/** Free-text match over title/description. */
|
||||||
text?: string;
|
text?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user