feat(tickets): affiche le créateur d'un ticket et filtre la liste par créateur
createdBy était déjà présent dans le frontmatter des tickets mais ni exposé ni exploitable côté UI. Ajout du DTO (domain/ports/mock), affichage dans TicketDetail, colonne/filtre créateur dans TicketsPanel, et persistance du filtre. ticketActor.ts introduit pour porter la logique de filtrage côté frontend, sans changement backend nécessaire (lot frontend pur). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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 {
|
||||
private listeners = new Set<(e: DomainEvent) => void>();
|
||||
|
||||
@ -2840,6 +2851,7 @@ export class MockTicketGateway implements TicketGateway {
|
||||
(t) =>
|
||||
!q.assignedAgentId || t.assignedAgentIds.includes(q.assignedAgentId),
|
||||
)
|
||||
.filter((t) => !q.createdBy || sameTicketCreator(t.createdBy, q.createdBy))
|
||||
.filter(
|
||||
(t) =>
|
||||
!text ||
|
||||
@ -2861,6 +2873,7 @@ export class MockTicketGateway implements TicketGateway {
|
||||
priority: t.priority,
|
||||
sprintId: t.sprintId ?? null,
|
||||
assignedAgentIds: [...t.assignedAgentIds],
|
||||
createdBy: structuredClone(t.createdBy),
|
||||
updatedAt: t.updatedAt,
|
||||
})),
|
||||
...(end < rows.length ? { nextCursor: String(end) } : {}),
|
||||
|
||||
@ -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" },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1528,6 +1528,7 @@ export interface TicketSummary {
|
||||
/** Sprint membership (`null` ⇒ no sprint). Additive (ticket #10). */
|
||||
sprintId?: string | null;
|
||||
assignedAgentIds: string[];
|
||||
createdBy: TicketActor;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ import { useProjectAgents } from "./useProjectAgents";
|
||||
import { TicketAssistantPanel } from "./TicketAssistantPanel";
|
||||
import { TicketPicker } from "./TicketPicker";
|
||||
import { TicketViewportSelect } from "./TicketViewportSelect";
|
||||
import { ticketActorLabel } from "./ticketActor";
|
||||
import {
|
||||
PriorityBadge,
|
||||
StatusBadge,
|
||||
@ -256,6 +257,9 @@ export function TicketDetail({
|
||||
<div className="-mx-4">
|
||||
{/* ── Fields (F3) ── */}
|
||||
<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">
|
||||
Title
|
||||
</label>
|
||||
|
||||
@ -17,6 +17,11 @@ import { SprintManager } from "./SprintManager";
|
||||
import { SprintPicker } from "./SprintPicker";
|
||||
import { TicketFacetsBar } from "./TicketFacetsBar";
|
||||
import { TicketViewportSelect } from "./TicketViewportSelect";
|
||||
import {
|
||||
creatorFilterFromValue,
|
||||
creatorFilterValue,
|
||||
ticketActorLabel,
|
||||
} from "./ticketActor";
|
||||
import {
|
||||
PriorityBadge,
|
||||
StatusBadge,
|
||||
@ -37,17 +42,26 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
const vm = useTickets(projectId);
|
||||
const { agents, nameOf, loaded: agentsLoaded } = useProjectAgents(projectId);
|
||||
|
||||
// Reconcile a *restored* assignee filter (ticket #29) against the live roster:
|
||||
// once the agents are known, an assignee that no longer exists is dropped
|
||||
// (which also clears it from persisted storage via `useTickets`). Guarded on
|
||||
// `agentsLoaded` so a still-valid assignee is never cleared during the load
|
||||
// window when the roster is momentarily empty.
|
||||
// Reconcile *restored* agent-scoped filters (ticket #29/#109) against the live
|
||||
// roster: once agents are known, stale ids are dropped and persisted storage
|
||||
// follows via `useTickets`. Guarded on `agentsLoaded` so a still-valid agent is
|
||||
// never cleared during the load window when the roster is momentarily empty.
|
||||
useEffect(() => {
|
||||
if (!agentsLoaded) return;
|
||||
const id = vm.query.assignedAgentId;
|
||||
if (id && !agents.some((a) => a.id === id)) {
|
||||
const { assignedAgentId: _drop, ...rest } = vm.query;
|
||||
vm.setQuery(rest);
|
||||
const validAgentIds = new Set(agents.map((a) => a.id));
|
||||
const staleAssignee =
|
||||
vm.query.assignedAgentId && !validAgentIds.has(vm.query.assignedAgentId);
|
||||
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]);
|
||||
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>
|
||||
|
||||
@ -485,6 +519,9 @@ function SprintSection({
|
||||
{t.assignedAgentIds.map(nameOf).join(", ")}
|
||||
</span>
|
||||
)}
|
||||
<span className="mt-0.5 block truncate text-xs text-muted">
|
||||
Créé par : {ticketActorLabel(t.createdBy, nameOf)}
|
||||
</span>
|
||||
</button>
|
||||
<span className="flex shrink-0 flex-col items-end gap-1">
|
||||
<StatusBadge status={t.status} />
|
||||
|
||||
37
frontend/src/features/tickets/ticketActor.ts
Normal file
37
frontend/src/features/tickets/ticketActor.ts
Normal 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;
|
||||
}
|
||||
@ -69,6 +69,22 @@ describe("parseTicketFilters (validation)", () => {
|
||||
.assignedAgentId,
|
||||
).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)", () => {
|
||||
@ -77,6 +93,7 @@ describe("sanitize (persistable subsets)", () => {
|
||||
statuses: ["open"],
|
||||
priorities: ["high"],
|
||||
assignedAgentId: "a1",
|
||||
createdBy: { kind: "agent", agentId: "a1" },
|
||||
sort: { field: "title", direction: "asc" },
|
||||
limit: 100,
|
||||
cursor: "opaque",
|
||||
@ -89,6 +106,7 @@ describe("sanitize (persistable subsets)", () => {
|
||||
statuses: ["open"],
|
||||
priorities: ["high"],
|
||||
assignedAgentId: "a1",
|
||||
createdBy: { kind: "agent", agentId: "a1" },
|
||||
sort: { field: "title", direction: "asc" },
|
||||
limit: 100,
|
||||
});
|
||||
@ -104,6 +122,7 @@ describe("sanitize (persistable subsets)", () => {
|
||||
sort: { field: "title", direction: "asc" },
|
||||
});
|
||||
expect("assignedAgentId" in out).toBe(false);
|
||||
expect("createdBy" in out).toBe(false);
|
||||
expect("limit" in out).toBe(false);
|
||||
expect("cursor" in out).toBe(false);
|
||||
});
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
* - `tickets:pickerFilters:<projectId>` — the reusable ticket picker.
|
||||
*
|
||||
* 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
|
||||
* across sessions is meaningless (G3-bis). Reads are defensive — unknown values
|
||||
* 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);
|
||||
if (sort) query.sort = sort;
|
||||
|
||||
@ -161,6 +176,7 @@ export function sanitizeListFilters(query: TicketListQuery): TicketListQuery | n
|
||||
out.priorities = query.priorities;
|
||||
}
|
||||
if (query.assignedAgentId) out.assignedAgentId = query.assignedAgentId;
|
||||
if (query.createdBy) out.createdBy = query.createdBy;
|
||||
if (query.sort) out.sort = query.sort;
|
||||
if (typeof query.limit === "number") out.limit = query.limit;
|
||||
return Object.keys(out).length > 0 ? out : null;
|
||||
|
||||
@ -103,6 +103,28 @@ describe("MockTicketGateway", () => {
|
||||
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 () => {
|
||||
await ticket.create(PROJECT_ID, {
|
||||
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 () => {
|
||||
const t = await ticket.create(PROJECT_ID, { title: "Editable" });
|
||||
renderView(ticket, system, agent);
|
||||
|
||||
@ -998,6 +998,7 @@ export interface TicketListQuery {
|
||||
statuses?: TicketStatus[];
|
||||
priorities?: TicketPriority[];
|
||||
assignedAgentId?: string;
|
||||
createdBy?: TicketCreatorFilter;
|
||||
/** Free-text match over title/description. */
|
||||
text?: string;
|
||||
sort?: TicketListSort;
|
||||
@ -1005,6 +1006,10 @@ export interface TicketListQuery {
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
export type TicketCreatorFilter =
|
||||
| { kind: "user" }
|
||||
| { kind: "agent"; agentId: string };
|
||||
|
||||
export type TicketListSortField = "number" | "priority" | "status" | "title";
|
||||
|
||||
export type TicketListSortDirection = "asc" | "desc";
|
||||
|
||||
Reference in New Issue
Block a user