merge feature/ticket109-creator-display-and-filter dans develop (créateur affiché + filtre tickets, #109)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 10:46:04 +02:00
10 changed files with 218 additions and 10 deletions

View File

@ -168,6 +168,17 @@ function sortTickets(
}); });
} }
function sameTicketCreator(
actor: Ticket["createdBy"],
filter: NonNullable<TicketListQuery["createdBy"]>,
): boolean {
if (actor.kind !== filter.kind) return false;
if (actor.kind === "agent" && filter.kind === "agent") {
return actor.agentId === filter.agentId;
}
return true;
}
export class MockSystemGateway implements SystemGateway { export class MockSystemGateway implements SystemGateway {
private listeners = new Set<(e: DomainEvent) => void>(); private listeners = new Set<(e: DomainEvent) => void>();
@ -2840,6 +2851,7 @@ export class MockTicketGateway implements TicketGateway {
(t) => (t) =>
!q.assignedAgentId || t.assignedAgentIds.includes(q.assignedAgentId), !q.assignedAgentId || t.assignedAgentIds.includes(q.assignedAgentId),
) )
.filter((t) => !q.createdBy || sameTicketCreator(t.createdBy, q.createdBy))
.filter( .filter(
(t) => (t) =>
!text || !text ||
@ -2861,6 +2873,7 @@ export class MockTicketGateway implements TicketGateway {
priority: t.priority, priority: t.priority,
sprintId: t.sprintId ?? null, sprintId: t.sprintId ?? null,
assignedAgentIds: [...t.assignedAgentIds], assignedAgentIds: [...t.assignedAgentIds],
createdBy: structuredClone(t.createdBy),
updatedAt: t.updatedAt, updatedAt: t.updatedAt,
})), })),
...(end < rows.length ? { nextCursor: String(end) } : {}), ...(end < rows.length ? { nextCursor: String(end) } : {}),

View File

@ -39,4 +39,19 @@ describe("TauriTicketGateway invoke payloads", () => {
}, },
}); });
}); });
it("passes createdBy through the ticket list request DTO", async () => {
await new TauriTicketGateway().list("proj-1", {
createdBy: { kind: "agent", agentId: "agent-1" },
});
expect(invoke).toHaveBeenCalledWith("ticket_list", {
request: {
projectId: "proj-1",
statuses: [],
priorities: [],
createdBy: { kind: "agent", agentId: "agent-1" },
},
});
});
}); });

View File

@ -1528,6 +1528,7 @@ export interface TicketSummary {
/** Sprint membership (`null` ⇒ no sprint). Additive (ticket #10). */ /** Sprint membership (`null` ⇒ no sprint). Additive (ticket #10). */
sprintId?: string | null; sprintId?: string | null;
assignedAgentIds: string[]; assignedAgentIds: string[];
createdBy: TicketActor;
updatedAt: number; updatedAt: number;
} }

View File

@ -18,6 +18,7 @@ import { useProjectAgents } from "./useProjectAgents";
import { TicketAssistantPanel } from "./TicketAssistantPanel"; import { TicketAssistantPanel } from "./TicketAssistantPanel";
import { TicketPicker } from "./TicketPicker"; import { TicketPicker } from "./TicketPicker";
import { TicketViewportSelect } from "./TicketViewportSelect"; import { TicketViewportSelect } from "./TicketViewportSelect";
import { ticketActorLabel } from "./ticketActor";
import { import {
PriorityBadge, PriorityBadge,
StatusBadge, StatusBadge,
@ -256,6 +257,9 @@ export function TicketDetail({
<div className="-mx-4"> <div className="-mx-4">
{/* ── Fields (F3) ── */} {/* ── Fields (F3) ── */}
<Section title="Details"> <Section title="Details">
<p className="text-xs text-muted">
Créé par : {ticketActorLabel(t.createdBy, nameOf)}
</p>
<label className="text-xs font-medium text-muted" htmlFor="td-title"> <label className="text-xs font-medium text-muted" htmlFor="td-title">
Title Title
</label> </label>

View File

@ -17,6 +17,11 @@ import { SprintManager } from "./SprintManager";
import { SprintPicker } from "./SprintPicker"; import { SprintPicker } from "./SprintPicker";
import { TicketFacetsBar } from "./TicketFacetsBar"; import { TicketFacetsBar } from "./TicketFacetsBar";
import { TicketViewportSelect } from "./TicketViewportSelect"; import { TicketViewportSelect } from "./TicketViewportSelect";
import {
creatorFilterFromValue,
creatorFilterValue,
ticketActorLabel,
} from "./ticketActor";
import { import {
PriorityBadge, PriorityBadge,
StatusBadge, StatusBadge,
@ -37,17 +42,26 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
const vm = useTickets(projectId); const vm = useTickets(projectId);
const { agents, nameOf, loaded: agentsLoaded } = useProjectAgents(projectId); const { agents, nameOf, loaded: agentsLoaded } = useProjectAgents(projectId);
// Reconcile a *restored* assignee filter (ticket #29) against the live roster: // Reconcile *restored* agent-scoped filters (ticket #29/#109) against the live
// once the agents are known, an assignee that no longer exists is dropped // roster: once agents are known, stale ids are dropped and persisted storage
// (which also clears it from persisted storage via `useTickets`). Guarded on // follows via `useTickets`. Guarded on `agentsLoaded` so a still-valid agent is
// `agentsLoaded` so a still-valid assignee is never cleared during the load // never cleared during the load window when the roster is momentarily empty.
// window when the roster is momentarily empty.
useEffect(() => { useEffect(() => {
if (!agentsLoaded) return; if (!agentsLoaded) return;
const id = vm.query.assignedAgentId; const validAgentIds = new Set(agents.map((a) => a.id));
if (id && !agents.some((a) => a.id === id)) { const staleAssignee =
const { assignedAgentId: _drop, ...rest } = vm.query; vm.query.assignedAgentId && !validAgentIds.has(vm.query.assignedAgentId);
vm.setQuery(rest); const staleCreator =
vm.query.createdBy?.kind === "agent" &&
!validAgentIds.has(vm.query.createdBy.agentId);
if (staleAssignee || staleCreator) {
const { assignedAgentId: _dropAssignee, createdBy: _dropCreator, ...rest } =
vm.query;
vm.setQuery({
...rest,
...(staleAssignee ? {} : { assignedAgentId: vm.query.assignedAgentId }),
...(staleCreator ? {} : { createdBy: vm.query.createdBy }),
});
} }
}, [agentsLoaded, agents, vm.query, vm.setQuery]); }, [agentsLoaded, agents, vm.query, vm.setQuery]);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
@ -305,6 +319,26 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
}) })
} }
/> />
<TicketViewportSelect
aria-label="filter by creator"
value={creatorFilterValue(vm.query.createdBy)}
options={[
{ value: "", label: "Tous créateurs" },
{ value: "user", label: "Utilisateur" },
...agents.map((agent) => ({
value: `agent:${agent.id}`,
label: agent.name,
})),
]}
onChange={(next) => {
const { createdBy: _drop, ...rest } = vm.query;
const createdBy = creatorFilterFromValue(next);
vm.setQuery({
...rest,
...(createdBy ? { createdBy } : {}),
});
}}
/>
</div> </div>
</div> </div>
@ -485,6 +519,9 @@ function SprintSection({
{t.assignedAgentIds.map(nameOf).join(", ")} {t.assignedAgentIds.map(nameOf).join(", ")}
</span> </span>
)} )}
<span className="mt-0.5 block truncate text-xs text-muted">
Créé par : {ticketActorLabel(t.createdBy, nameOf)}
</span>
</button> </button>
<span className="flex shrink-0 flex-col items-end gap-1"> <span className="flex shrink-0 flex-col items-end gap-1">
<StatusBadge status={t.status} /> <StatusBadge status={t.status} />

View File

@ -0,0 +1,37 @@
import type { TicketActor } from "@/domain";
export type TicketCreatorFilterValue = "" | "user" | `agent:${string}`;
export function ticketActorLabel(
actor: TicketActor,
nameOf: (agentId: string) => string,
): string {
switch (actor.kind) {
case "agent":
return `Agent : ${nameOf(actor.agentId)}`;
case "system":
return "Système";
case "user":
default:
return "Utilisateur";
}
}
export function creatorFilterValue(
actor: { kind: "user" } | { kind: "agent"; agentId: string } | undefined,
): TicketCreatorFilterValue {
if (!actor) return "";
if (actor.kind === "user") return "user";
return `agent:${actor.agentId}`;
}
export function creatorFilterFromValue(
value: string,
): { kind: "user" } | { kind: "agent"; agentId: string } | undefined {
if (value === "user") return { kind: "user" };
if (value.startsWith("agent:")) {
const agentId = value.slice("agent:".length);
return agentId ? { kind: "agent", agentId } : undefined;
}
return undefined;
}

View File

@ -69,6 +69,22 @@ describe("parseTicketFilters (validation)", () => {
.assignedAgentId, .assignedAgentId,
).toBe("gone"); ).toBe("gone");
}); });
it("keeps valid creator filters and drops obsolete creator agents", () => {
expect(parseTicketFilters({ createdBy: { kind: "user" } }).createdBy).toEqual({
kind: "user",
});
expect(
parseTicketFilters({ createdBy: { kind: "agent", agentId: "a1" } }, {
validAgentIds: new Set(["a1"]),
}).createdBy,
).toEqual({ kind: "agent", agentId: "a1" });
expect(
parseTicketFilters({ createdBy: { kind: "agent", agentId: "gone" } }, {
validAgentIds: new Set(["a1"]),
}).createdBy,
).toBeUndefined();
});
}); });
describe("sanitize (persistable subsets)", () => { describe("sanitize (persistable subsets)", () => {
@ -77,6 +93,7 @@ describe("sanitize (persistable subsets)", () => {
statuses: ["open"], statuses: ["open"],
priorities: ["high"], priorities: ["high"],
assignedAgentId: "a1", assignedAgentId: "a1",
createdBy: { kind: "agent", agentId: "a1" },
sort: { field: "title", direction: "asc" }, sort: { field: "title", direction: "asc" },
limit: 100, limit: 100,
cursor: "opaque", cursor: "opaque",
@ -89,6 +106,7 @@ describe("sanitize (persistable subsets)", () => {
statuses: ["open"], statuses: ["open"],
priorities: ["high"], priorities: ["high"],
assignedAgentId: "a1", assignedAgentId: "a1",
createdBy: { kind: "agent", agentId: "a1" },
sort: { field: "title", direction: "asc" }, sort: { field: "title", direction: "asc" },
limit: 100, limit: 100,
}); });
@ -104,6 +122,7 @@ describe("sanitize (persistable subsets)", () => {
sort: { field: "title", direction: "asc" }, sort: { field: "title", direction: "asc" },
}); });
expect("assignedAgentId" in out).toBe(false); expect("assignedAgentId" in out).toBe(false);
expect("createdBy" in out).toBe(false);
expect("limit" in out).toBe(false); expect("limit" in out).toBe(false);
expect("cursor" in out).toBe(false); expect("cursor" in out).toBe(false);
}); });

View File

@ -11,7 +11,7 @@
* - `tickets:pickerFilters:<projectId>` — the reusable ticket picker. * - `tickets:pickerFilters:<projectId>` — the reusable ticket picker.
* *
* We only ever store the *stable* criteria (`text`, `statuses`, `priorities`, * We only ever store the *stable* criteria (`text`, `statuses`, `priorities`,
* `sort`, `limit`, and — main list only — `assignedAgentId`). The opaque * `sort`, `limit`, and — main list only — `assignedAgentId`/`createdBy`). The opaque
* `cursor` is **never** persisted: it is a transient pagination token whose reuse * `cursor` is **never** persisted: it is a transient pagination token whose reuse
* across sessions is meaningless (G3-bis). Reads are defensive — unknown values * across sessions is meaningless (G3-bis). Reads are defensive — unknown values
* are dropped and a corrupt blob yields the default empty query. * are dropped and a corrupt blob yields the default empty query.
@ -133,6 +133,21 @@ export function parseTicketFilters(
} }
} }
if (isObject(raw.createdBy)) {
if (raw.createdBy.kind === "user") {
query.createdBy = { kind: "user" };
} else if (
raw.createdBy.kind === "agent" &&
typeof raw.createdBy.agentId === "string" &&
raw.createdBy.agentId
) {
const { validAgentIds } = options;
if (!validAgentIds || validAgentIds.has(raw.createdBy.agentId)) {
query.createdBy = { kind: "agent", agentId: raw.createdBy.agentId };
}
}
}
const sort = parseSort(raw.sort); const sort = parseSort(raw.sort);
if (sort) query.sort = sort; if (sort) query.sort = sort;
@ -161,6 +176,7 @@ export function sanitizeListFilters(query: TicketListQuery): TicketListQuery | n
out.priorities = query.priorities; out.priorities = query.priorities;
} }
if (query.assignedAgentId) out.assignedAgentId = query.assignedAgentId; if (query.assignedAgentId) out.assignedAgentId = query.assignedAgentId;
if (query.createdBy) out.createdBy = query.createdBy;
if (query.sort) out.sort = query.sort; if (query.sort) out.sort = query.sort;
if (typeof query.limit === "number") out.limit = query.limit; if (typeof query.limit === "number") out.limit = query.limit;
return Object.keys(out).length > 0 ? out : null; return Object.keys(out).length > 0 ? out : null;

View File

@ -103,6 +103,28 @@ describe("MockTicketGateway", () => {
expect(search.items.map((i) => i.ref)).toEqual(["#2"]); expect(search.items.map((i) => i.ref)).toEqual(["#2"]);
}); });
it("filters by creator and exposes createdBy on summaries (#109)", async () => {
await ticket.create(PROJECT_ID, { title: "User ticket" });
const agentTicket = await ticket.create(PROJECT_ID, { title: "Agent ticket" });
ticket._seedTicket(PROJECT_ID, {
...agentTicket,
createdBy: { kind: "agent", agentId: "agent-1" },
});
const byUser = await ticket.list(PROJECT_ID, { createdBy: { kind: "user" } });
expect(byUser.items.map((i) => i.title)).toEqual(["User ticket"]);
expect(byUser.items[0]?.createdBy).toEqual({ kind: "user" });
const byAgent = await ticket.list(PROJECT_ID, {
createdBy: { kind: "agent", agentId: "agent-1" },
});
expect(byAgent.items.map((i) => i.title)).toEqual(["Agent ticket"]);
expect(byAgent.items[0]?.createdBy).toEqual({
kind: "agent",
agentId: "agent-1",
});
});
it("combines status and priority facets with AND (#12)", async () => { it("combines status and priority facets with AND (#12)", async () => {
await ticket.create(PROJECT_ID, { await ticket.create(PROJECT_ID, {
title: "a", title: "a",
@ -419,6 +441,45 @@ describe("TicketsView", () => {
); );
}); });
it("shows ticket creators and filters the list by creator (#109)", async () => {
const creator = await seedAgent(agent, "CreatorAgent");
await ticket.create(PROJECT_ID, { title: "Created by user" });
const agentTicket = await ticket.create(PROJECT_ID, {
title: "Created by agent",
});
ticket._seedTicket(PROJECT_ID, {
...agentTicket,
createdBy: { kind: "agent", agentId: creator.id },
});
const listSpy = vi.spyOn(ticket, "list");
renderView(ticket, system, agent);
await screen.findByText("Created by user");
expect(screen.getByText("Créé par : Utilisateur")).toBeTruthy();
expect(screen.getByText("Créé par : Agent : CreatorAgent")).toBeTruthy();
fireEvent.click(screen.getByLabelText("filter by creator"));
fireEvent.click(await screen.findByRole("option", { name: "CreatorAgent" }));
await waitFor(() =>
expect(listSpy.mock.calls.at(-1)?.[1]?.createdBy).toEqual({
kind: "agent",
agentId: creator.id,
}),
);
await waitFor(() => expect(screen.queryByText("Created by user")).toBeNull());
expect(screen.getByText("Created by agent")).toBeTruthy();
fireEvent.click(screen.getByText("Created by agent"));
const dialog = await screen.findByRole("dialog", {
name: `ticket ${agentTicket.ref}`,
});
expect(
within(dialog).getByText("Créé par : Agent : CreatorAgent"),
).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);

View File

@ -998,6 +998,7 @@ export interface TicketListQuery {
statuses?: TicketStatus[]; statuses?: TicketStatus[];
priorities?: TicketPriority[]; priorities?: TicketPriority[];
assignedAgentId?: string; assignedAgentId?: string;
createdBy?: TicketCreatorFilter;
/** Free-text match over title/description. */ /** Free-text match over title/description. */
text?: string; text?: string;
sort?: TicketListSort; sort?: TicketListSort;
@ -1005,6 +1006,10 @@ export interface TicketListQuery {
cursor?: string; cursor?: string;
} }
export type TicketCreatorFilter =
| { kind: "user" }
| { kind: "agent"; agentId: string };
export type TicketListSortField = "number" | "priority" | "status" | "title"; export type TicketListSortField = "number" | "priority" | "status" | "title";
export type TicketListSortDirection = "asc" | "desc"; export type TicketListSortDirection = "asc" | "desc";