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:
@ -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