diff --git a/frontend/src/features/tickets/TicketFacetsBar.tsx b/frontend/src/features/tickets/TicketFacetsBar.tsx new file mode 100644 index 0000000..f09811e --- /dev/null +++ b/frontend/src/features/tickets/TicketFacetsBar.tsx @@ -0,0 +1,126 @@ +/** + * Reusable ticket filter/search bar (UI rework #18): a free-text search input + * plus the multi-select status/priority checkbox facets. Extracted from + * {@link TicketsPanel} so both the main listing and the {@link TicketPicker} + * popup share one implementation and stay in sync. + * + * Purely controlled/presentational — it holds no state and knows nothing about + * the gateway; the owner (a view-model or {@link useTicketSearch}) drives every + * value and toggle. Facet semantics (ticket #12): OR within a facet, AND across; + * an empty set ⇒ that facet is unconstrained. + * + * The assignee filter stays with the main listing (it needs the project's agent + * roster and is out of the picker's scope), so it is intentionally not here. + */ + +import type { TicketPriority, TicketStatus } from "@/domain"; +import { Button, Input } from "@/shared"; +import { + PriorityBadge, + StatusBadge, + TICKET_PRIORITIES, + TICKET_STATUSES, + priorityLabel, + statusLabel, +} from "./ticketMeta"; + +export interface TicketFacetsBarProps { + /** Current free-text search value (controlled). */ + text: string; + /** Emitted on every keystroke with the raw input value. */ + onTextChange: (text: string) => void; + /** Currently-selected status facet values. */ + statuses: TicketStatus[]; + /** Currently-selected priority facet values. */ + priorities: TicketPriority[]; + onToggleStatus: (status: TicketStatus) => void; + onTogglePriority: (priority: TicketPriority) => void; + /** + * Clears both facets. When provided, a « Effacer » button appears while any + * facet is active. Omit to hide the affordance (the caller clears elsewhere). + */ + onClearFacets?: () => void; + /** Placeholder for the search input. */ + searchPlaceholder?: string; +} + +export function TicketFacetsBar({ + text, + onTextChange, + statuses, + priorities, + onToggleStatus, + onTogglePriority, + onClearFacets, + searchPlaceholder = "Search title/description…", +}: TicketFacetsBarProps) { + const hasFacets = statuses.length > 0 || priorities.length > 0; + return ( +
+ onTextChange(e.target.value)} + /> + {/* Multi-select facets (ticket #12): OR within a facet, AND across; no + box ticked ⇒ that facet is unconstrained. */} +
+ Statut + {TICKET_STATUSES.map((s) => ( + + ))} +
+
+ + Priorité + + {TICKET_PRIORITIES.map((p) => ( + + ))} +
+ {onClearFacets && hasFacets && ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/features/tickets/TicketPicker.test.tsx b/frontend/src/features/tickets/TicketPicker.test.tsx new file mode 100644 index 0000000..1b7317f --- /dev/null +++ b/frontend/src/features/tickets/TicketPicker.test.tsx @@ -0,0 +1,201 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import { DIProvider } from "@/app/di"; +import { MockSystemGateway, MockTicketGateway } from "@/adapters/mock"; +import type { Ticket } from "@/domain"; +import type { Gateways } from "@/ports"; +import { TicketPicker, type TicketPickerResult } from "./TicketPicker"; + +const PROJECT_ID = "project-picker-test"; + +function renderPicker( + ticket: MockTicketGateway, + props: Partial> = {}, +) { + const onSelect = vi.fn(); + const onClose = vi.fn(); + const gateways = { ticket } as unknown as Gateways; + const utils = render( + + + , + ); + return { ...utils, onSelect, onClose }; +} + +/** Seeds three tickets with distinct status/priority for filter assertions. */ +async function seedTrio(ticket: MockTicketGateway): Promise { + const a = await ticket.create(PROJECT_ID, { + title: "Alpha bug", + status: "open", + priority: "high", + }); + const b = await ticket.create(PROJECT_ID, { + title: "Bravo task", + priority: "low", + }); + await ticket.update(PROJECT_ID, b.ref, { + status: "closed", + expectedVersion: b.version, + }); + const c = await ticket.create(PROJECT_ID, { + title: "Charlie chore", + status: "QA", + priority: "medium", + }); + return [a, b, c]; +} + +describe("TicketPicker (#18)", () => { + let system: MockSystemGateway; + let ticket: MockTicketGateway; + + beforeEach(() => { + system = new MockSystemGateway(); + ticket = new MockTicketGateway(system); + }); + + it("renders nothing when closed", () => { + const gateways = { ticket } as unknown as Gateways; + const { container } = render( + + + , + ); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + }); + + it("lists tickets and single-select hands back the full result + closes", async () => { + const [a] = await seedTrio(ticket); + const { onSelect, onClose } = renderPicker(ticket); + + // Rows appear. + await screen.findByText("Alpha bug"); + expect(screen.getByText("Bravo task")).toBeTruthy(); + expect(screen.getByText("Charlie chore")).toBeTruthy(); + + fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`)); + + await waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1)); + const result = onSelect.mock.calls[0][0] as TicketPickerResult; + expect(result).toMatchObject({ + ref: a.ref, + id: a.id, + number: a.number, + title: "Alpha bug", + status: "open", + priority: "high", + }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("hides excludeRefs client-side", async () => { + const [a, b] = await seedTrio(ticket); + renderPicker(ticket, { excludeRefs: [a.ref] }); + + await screen.findByText("Bravo task"); + expect(screen.queryByText("Alpha bug")).toBeNull(); + // Excluded ref cannot be selected either. + expect(screen.queryByLabelText(`select ticket ${a.ref}`)).toBeNull(); + expect(screen.getByLabelText(`select ticket ${b.ref}`)).toBeTruthy(); + }); + + it("filters by status facet (OR within, AND across)", async () => { + await seedTrio(ticket); + renderPicker(ticket); + + await screen.findByText("Alpha bug"); + + // Tick "open" ⇒ only Alpha (open) remains; Bravo (closed) & Charlie (QA) go. + fireEvent.click(screen.getByLabelText("filter status Open")); + await waitFor(() => expect(screen.queryByText("Bravo task")).toBeNull()); + expect(screen.getByText("Alpha bug")).toBeTruthy(); + expect(screen.queryByText("Charlie chore")).toBeNull(); + + // Add priority "low" ⇒ (open) AND (low) ⇒ nothing (Alpha is high). + fireEvent.click(screen.getByLabelText("filter priority Low")); + await waitFor(() => + expect(screen.getByText("No matching tickets.")).toBeTruthy(), + ); + + // Clear facets ⇒ all three back. + fireEvent.click(screen.getByLabelText("clear filters")); + await waitFor(() => expect(screen.getByText("Charlie chore")).toBeTruthy()); + expect(screen.getByText("Alpha bug")).toBeTruthy(); + expect(screen.getByText("Bravo task")).toBeTruthy(); + }); + + it("debounced text search narrows the list", async () => { + await seedTrio(ticket); + renderPicker(ticket); + + await screen.findByText("Alpha bug"); + + fireEvent.change(screen.getByLabelText("search tickets"), { + target: { value: "charlie" }, + }); + + // Wait on the removal (all three are visible until the debounced refetch). + await waitFor(() => expect(screen.queryByText("Alpha bug")).toBeNull()); + expect(screen.getByText("Charlie chore")).toBeTruthy(); + expect(screen.queryByText("Bravo task")).toBeNull(); + }); + + it("honors initialQuery as a seed filter", async () => { + await seedTrio(ticket); + renderPicker(ticket, { initialQuery: { statuses: ["QA"] } }); + + await screen.findByText("Charlie chore"); + expect(screen.queryByText("Alpha bug")).toBeNull(); + expect(screen.queryByText("Bravo task")).toBeNull(); + }); + + it("closes on Escape and on backdrop click", async () => { + await seedTrio(ticket); + const { onClose } = renderPicker(ticket); + await screen.findByText("Alpha bug"); + + fireEvent.keyDown(document, { key: "Escape" }); + expect(onClose).toHaveBeenCalledTimes(1); + + // Backdrop (the outermost fixed overlay) closes on mousedown. + const dialog = screen.getByRole("dialog"); + const backdrop = dialog.parentElement as HTMLElement; + fireEvent.mouseDown(backdrop); + expect(onClose).toHaveBeenCalledTimes(2); + }); + + it("uses a custom title and surfaces a resolve failure without closing", async () => { + const [a] = await seedTrio(ticket); + const readSpy = vi + .spyOn(ticket, "read") + .mockRejectedValueOnce({ code: "NOT_FOUND", message: "gone" }); + const { onSelect, onClose } = renderPicker(ticket, { + title: "Choisir un ticket à lier", + }); + + await screen.findByText("Alpha bug"); + expect( + screen.getByRole("dialog", { name: "Choisir un ticket à lier" }), + ).toBeTruthy(); + + fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`)); + + await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("gone")); + expect(onSelect).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + readSpy.mockRestore(); + }); +}); diff --git a/frontend/src/features/tickets/TicketPicker.tsx b/frontend/src/features/tickets/TicketPicker.tsx new file mode 100644 index 0000000..97d5909 --- /dev/null +++ b/frontend/src/features/tickets/TicketPicker.tsx @@ -0,0 +1,299 @@ +/** + * Reusable ticket-selection popup (ticket #18, G3). + * + * A modal picker consumed by any feature that needs the user to choose a ticket + * (sprint composition #19, ticket linking #17, …). It reuses the shared + * {@link TicketFacetsBar} (search + status/priority facets) and the slim + * {@link useTicketSearch} hook, so filtering/search behave exactly like the main + * listing without dragging in its sprint grouping or event subscriptions. + * + * Integration rules (sprint scoping note `ui-rework-sprint-scoping-contracts`): + * - G5: mount at the **chrome level** (ProjectsView/App), never inside + * LayoutGrid/LeafView. A focus-trap is mandatory because xterm captures + * keyboard focus in the workspace. + * - z-index `floatingWindowNested` (60): the picker is opened from within other + * floating windows, so it must sit above them. + * + * ⚠️ Location note: this lives under `features/tickets` (not `shared/ui`) because + * it depends on the ticket domain + DI gateways, whereas `shared/ui` is the pure + * design system (Button/Input/…). This matches every other feature overlay + * (SprintManager, MemoryEditor, …). Only the design-system-level `zIndex.ts` + * went into `shared/ui`. + * + * V1 implements single-select (a row click selects and closes). `"multi"` is in + * the frozen prop contract but not required this sprint; the surrounding + * structure keeps it extensible. + */ + +import { useEffect, useRef, useState } from "react"; + +import type { TicketRef } from "@/domain"; +import type { TicketListQuery } from "@/ports"; +import { Button, Spinner, cn, zIndex } from "@/shared"; +import { TicketFacetsBar } from "./TicketFacetsBar"; +import { PriorityBadge, StatusBadge, TicketRef as TicketRefBadge } from "./ticketMeta"; +import { + useTicketSearch, + type TicketPickerResult, +} from "./useTicketSearch"; + +export type { TicketPickerResult } from "./useTicketSearch"; + +export type TicketPickerSelectionMode = "single" | "multi"; + +export interface TicketPickerProps { + /** Whether the popup is shown. Rendered as `null` when closed. */ + open: boolean; + projectId: string; + /** Dialog heading (default "Sélectionner un ticket"). */ + title?: string; + /** Confirm-button label (multi-select only; unused in single-select V1). */ + confirmLabel?: string; + /** Selection mode (default "single"). */ + selectionMode?: TicketPickerSelectionMode; + /** Refs to hide from the results (e.g. the current ticket, already-linked). */ + excludeRefs?: TicketRef[]; + /** Seed filter/search query. */ + initialQuery?: TicketListQuery; + /** + * Selection callback. In `"single"` mode it receives one + * {@link TicketPickerResult}; in `"multi"` it receives the array. + */ + onSelect: (result: TicketPickerResult | TicketPickerResult[]) => void; + /** Called on Escape, backdrop click, or the Close button. */ + onClose: () => void; +} + +/** Selector for tabbable elements, used by the focus-trap. */ +const FOCUSABLE = + 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'; + +export function TicketPicker({ + open, + projectId, + title = "Sélectionner un ticket", + confirmLabel = "Sélectionner", + selectionMode = "single", + excludeRefs, + initialQuery, + onSelect, + onClose, +}: TicketPickerProps) { + if (!open) return null; + return ( + + ); +} + +/** + * The mounted picker. Split from {@link TicketPicker} so the hook (and its + * fetch) only run while the popup is actually open — mounting/unmounting on + * `open` resets state cleanly between openings. + */ +function TicketPickerBody({ + projectId, + title, + confirmLabel, + selectionMode, + excludeRefs, + initialQuery, + onSelect, + onClose, +}: Required< + Pick< + TicketPickerProps, + "projectId" | "title" | "confirmLabel" | "selectionMode" | "onSelect" | "onClose" + > +> & + Pick) { + const vm = useTicketSearch(projectId, { initialQuery, excludeRefs }); + const dialogRef = useRef(null); + const [selecting, setSelecting] = useState(false); + const [resolveError, setResolveError] = useState(null); + + // Focus-trap (G5): remember the previously-focused element, move focus into + // the dialog on mount, keep Tab cycling inside it, and restore focus on close. + useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement | null; + const node = dialogRef.current; + // Focus the first focusable control (the search input) once mounted. + const first = node?.querySelector(FOCUSABLE); + first?.focus(); + + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + return; + } + if (e.key !== "Tab" || !node) return; + const focusables = Array.from( + node.querySelectorAll(FOCUSABLE), + ).filter((el) => el.offsetParent !== null || el === document.activeElement); + if (focusables.length === 0) return; + const firstEl = focusables[0]; + const lastEl = focusables[focusables.length - 1]; + const active = document.activeElement; + if (e.shiftKey && active === firstEl) { + e.preventDefault(); + lastEl.focus(); + } else if (!e.shiftKey && active === lastEl) { + e.preventDefault(); + firstEl.focus(); + } + } + + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + previouslyFocused?.focus?.(); + }; + }, [onClose]); + + async function handlePick(ref: TicketRef) { + if (selecting) return; + setSelecting(true); + setResolveError(null); + try { + const result = await vm.resolve(ref); + // V1: single-select selects-and-closes. Multi (not required this sprint) + // would accumulate a set and confirm via the footer button instead. + onSelect(selectionMode === "multi" ? [result] : result); + onClose(); + } catch (e) { + setResolveError( + e && typeof e === "object" && "message" in e + ? String((e as { message: unknown }).message) + : String(e), + ); + setSelecting(false); + } + } + + return ( +
{ + // Backdrop click closes; clicks inside the panel don't bubble here. + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {/* ── Header ── */} +
+ {title} + +
+ + {/* ── Filters ── */} +
+ +
+ + {(vm.error || resolveError) && ( +

+ {resolveError ?? vm.error} +

+ )} + + {/* ── Results ── */} +
+ {vm.busy && vm.rows.length === 0 ? ( +
+ + Loading tickets… +
+ ) : vm.rows.length === 0 ? ( +

No matching tickets.

+ ) : ( +
    + {vm.rows.map((t) => ( +
  • + +
  • + ))} +
+ )} + + {vm.hasMore && ( +
+ +
+ )} +
+ + {/* Footer reserved for multi-select confirm (not wired in single V1). */} + {selectionMode === "multi" && ( +
+ + +
+ )} +
+
+ ); +} diff --git a/frontend/src/features/tickets/TicketsPanel.tsx b/frontend/src/features/tickets/TicketsPanel.tsx index c6dfed8..cd95fec 100644 --- a/frontend/src/features/tickets/TicketsPanel.tsx +++ b/frontend/src/features/tickets/TicketsPanel.tsx @@ -14,14 +14,13 @@ import { Button, Input, Panel, Spinner, cn } from "@/shared"; import { useTickets } from "./useTickets"; import { useProjectAgents } from "./useProjectAgents"; import { SprintManager } from "./SprintManager"; +import { TicketFacetsBar } from "./TicketFacetsBar"; import { PriorityBadge, StatusBadge, TICKET_PRIORITIES, - TICKET_STATUSES, TicketRef, priorityLabel, - statusLabel, } from "./ticketMeta"; const selectClass = cn( @@ -152,60 +151,19 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) { {/* ── Filters ── */}
- - vm.setQuery({ ...vm.query, text: e.target.value || undefined }) + {/* Shared search + status/priority facets (#18). Assignee stays here — + it needs the project agent roster and is out of the picker's scope. */} + + vm.setQuery({ ...vm.query, text: text || undefined }) } + statuses={vm.query.statuses ?? []} + priorities={vm.query.priorities ?? []} + onToggleStatus={vm.toggleStatus} + onTogglePriority={vm.togglePriority} + onClearFacets={vm.clearFacets} /> - {/* Multi-select facets (ticket #12): OR within a facet, AND across; no - box ticked ⇒ that facet is unconstrained. */} -
- Statut - {TICKET_STATUSES.map((s) => ( - - ))} -
-
- - Priorité - - {TICKET_PRIORITIES.map((p) => ( - - ))} -
- {((vm.query.statuses?.length ?? 0) > 0 || - (vm.query.priorities?.length ?? 0) > 0) && ( - - )}
diff --git a/frontend/src/features/tickets/index.ts b/frontend/src/features/tickets/index.ts index c7e82cf..c9364af 100644 --- a/frontend/src/features/tickets/index.ts +++ b/frontend/src/features/tickets/index.ts @@ -9,6 +9,19 @@ export { TicketDetail } from "./TicketDetail"; export type { TicketDetailProps } from "./TicketDetail"; export { SprintManager } from "./SprintManager"; export type { SprintManagerProps } from "./SprintManager"; +export { TicketPicker } from "./TicketPicker"; +export type { + TicketPickerProps, + TicketPickerResult, + TicketPickerSelectionMode, +} from "./TicketPicker"; +export { TicketFacetsBar } from "./TicketFacetsBar"; +export type { TicketFacetsBarProps } from "./TicketFacetsBar"; +export { useTicketSearch } from "./useTicketSearch"; +export type { + TicketSearchViewModel, + UseTicketSearchOptions, +} from "./useTicketSearch"; export { useTickets } from "./useTickets"; export type { TicketsViewModel } from "./useTickets"; export { useTicketDetail } from "./useTicketDetail"; diff --git a/frontend/src/features/tickets/useTicketSearch.ts b/frontend/src/features/tickets/useTicketSearch.ts new file mode 100644 index 0000000..aff040d --- /dev/null +++ b/frontend/src/features/tickets/useTicketSearch.ts @@ -0,0 +1,272 @@ +/** + * Lightweight ticket search view-model for the {@link TicketPicker} popup (#18). + * + * A deliberately slim alternative to {@link useTickets}: it wraps only + * `TicketGateway.list` with the status/priority facets and a debounced free-text + * search, plus client-side `excludeRefs` filtering and opaque-cursor pagination. + * It does NOT group by sprint, subscribe to domain events, or expose + * create/assign — the picker only ever reads and selects. + * + * ── Contract guards ── + * - G3-bis: `cursor` is an **opaque token**. We never build or increment it; we + * only relay the `nextCursor` the previous page returned. A backend migration + * to opaque cursors is therefore zero-change here. + * - `excludeRefs` is filtered **client-side** after each page arrives (the + * backend has no "exclude" criterion). + * - `TicketSummary` from `ticket_list` carries no `id`/`number`, so {@link + * TicketSearchViewModel.resolve} reads the ticket on selection to build the + * full {@link TicketPickerResult}. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import type { + GatewayError, + TicketPriority, + TicketRef, + TicketStatus, + TicketSummary, +} from "@/domain"; +import type { TicketListQuery } from "@/ports"; +import { useGateways } from "@/app/di"; + +/** + * The selection payload the picker hands back (frozen contract, #18). `number` + * is the numeric form of `ref` and `id` is the stable ticket id — both resolved + * via a single `ticket_read` on selection since `ticket_list` omits them. + */ +export interface TicketPickerResult { + ref: TicketRef; + id: string; + number: number; + title: string; + status: TicketStatus; + priority: TicketPriority; +} + +export interface UseTicketSearchOptions { + /** + * Seed query. `statuses`/`priorities`/`assignedAgentId`/`text`/`limit` are + * honored; `cursor` is ignored (pagination always starts at the first page). + */ + initialQuery?: TicketListQuery; + /** Refs to hide from the results, filtered client-side after each page. */ + excludeRefs?: TicketRef[]; + /** Debounce applied to the free-text search, in ms (default 200). */ + debounceMs?: number; +} + +export interface TicketSearchViewModel { + /** Result rows for the current query, accumulated across loaded pages, with + * `excludeRefs` removed. */ + rows: TicketSummary[]; + busy: boolean; + error: string | null; + /** Raw (un-debounced) search text — bind straight to the input. */ + text: string; + setText: (text: string) => void; + statuses: TicketStatus[]; + priorities: TicketPriority[]; + toggleStatus: (status: TicketStatus) => void; + togglePriority: (priority: TicketPriority) => void; + clearFacets: () => void; + /** True when the backend reported a further page (opaque cursor present). */ + hasMore: boolean; + /** Loads and appends the next page (no-op when exhausted or busy). */ + loadMore: () => void; + /** Resolves a row's `#ref` to the full picker result (reads id + number). */ + resolve: (ref: TicketRef) => Promise; +} + +function describe(e: unknown): string { + if (e && typeof e === "object" && "message" in e) { + return String((e as GatewayError).message); + } + return String(e); +} + +/** Facet subset of the query we own locally (text is handled via debounce). */ +interface Facets { + statuses?: TicketStatus[]; + priorities?: TicketPriority[]; + assignedAgentId?: string; +} + +export function useTicketSearch( + projectId: string, + options: UseTicketSearchOptions = {}, +): TicketSearchViewModel { + const { initialQuery, excludeRefs, debounceMs = 200 } = options; + const { ticket } = useGateways(); + + const [facets, setFacets] = useState({ + statuses: initialQuery?.statuses, + priorities: initialQuery?.priorities, + assignedAgentId: initialQuery?.assignedAgentId, + }); + const [text, setText] = useState(initialQuery?.text ?? ""); + const [debouncedText, setDebouncedText] = useState(text); + + const [items, setItems] = useState([]); + const [cursor, setCursor] = useState(undefined); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const limit = initialQuery?.limit; + // Stable set so identity only changes when the caller passes new refs. + const excludeKey = (excludeRefs ?? []).join(","); + const excluded = useMemo( + () => new Set(excludeRefs ?? []), + // eslint-disable-next-line react-hooks/exhaustive-deps + [excludeKey], + ); + + // Debounce the free-text search so typing doesn't refetch on every keystroke. + useEffect(() => { + const id = setTimeout(() => setDebouncedText(text), debounceMs); + return () => clearTimeout(id); + }, [text, debounceMs]); + + // A key that changes whenever the effective query (minus cursor) changes, so + // the first-page effect refetches exactly when a criterion moves. + const queryKey = useMemo( + () => JSON.stringify({ facets, text: debouncedText.trim(), limit }), + [facets, debouncedText, limit], + ); + + // Guards against races: only the latest first-page/loadMore fetch may commit. + const fetchToken = useRef(0); + + const buildQuery = useCallback( + (nextCursor?: string): TicketListQuery => { + const trimmed = debouncedText.trim(); + return { + ...(facets.statuses && facets.statuses.length > 0 + ? { statuses: facets.statuses } + : {}), + ...(facets.priorities && facets.priorities.length > 0 + ? { priorities: facets.priorities } + : {}), + ...(facets.assignedAgentId + ? { assignedAgentId: facets.assignedAgentId } + : {}), + ...(trimmed ? { text: trimmed } : {}), + ...(limit ? { limit } : {}), + // G3-bis: opaque token, relayed verbatim from the previous page only. + ...(nextCursor ? { cursor: nextCursor } : {}), + }; + }, + [facets, debouncedText, limit], + ); + + // First page: (re)fetch whenever the query key changes; replaces the list. + useEffect(() => { + const token = ++fetchToken.current; + setBusy(true); + setError(null); + void ticket + .list(projectId, buildQuery()) + .then((page) => { + if (token !== fetchToken.current) return; + setItems(page.items); + setCursor(page.nextCursor); + }) + .catch((e) => { + if (token !== fetchToken.current) return; + setError(describe(e)); + setItems([]); + setCursor(undefined); + }) + .finally(() => { + if (token === fetchToken.current) setBusy(false); + }); + // `buildQuery`/`projectId`/`ticket` are captured via `queryKey`; listing + // them would refetch on every render (fresh object identities). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [queryKey, projectId, ticket]); + + const loadMore = useCallback(() => { + if (busy || !cursor) return; + const token = ++fetchToken.current; + setBusy(true); + setError(null); + void ticket + .list(projectId, buildQuery(cursor)) + .then((page) => { + if (token !== fetchToken.current) return; + setItems((prev) => [...prev, ...page.items]); + setCursor(page.nextCursor); + }) + .catch((e) => { + if (token !== fetchToken.current) return; + setError(describe(e)); + }) + .finally(() => { + if (token === fetchToken.current) setBusy(false); + }); + }, [busy, cursor, ticket, projectId, buildQuery]); + + const toggleStatus = useCallback((status: TicketStatus) => { + setFacets((prev) => { + const current = prev.statuses ?? []; + const next = current.includes(status) + ? current.filter((s) => s !== status) + : [...current, status]; + return { ...prev, statuses: next.length > 0 ? next : undefined }; + }); + }, []); + + const togglePriority = useCallback((priority: TicketPriority) => { + setFacets((prev) => { + const current = prev.priorities ?? []; + const next = current.includes(priority) + ? current.filter((p) => p !== priority) + : [...current, priority]; + return { ...prev, priorities: next.length > 0 ? next : undefined }; + }); + }, []); + + const clearFacets = useCallback(() => { + setFacets((prev) => ({ + ...prev, + statuses: undefined, + priorities: undefined, + })); + }, []); + + const resolve = useCallback( + async (ref: TicketRef): Promise => { + const t = await ticket.read(projectId, ref); + return { + ref: t.ref, + id: t.id, + number: t.number, + title: t.title, + status: t.status, + priority: t.priority, + }; + }, + [ticket, projectId], + ); + + const rows = useMemo( + () => (excluded.size === 0 ? items : items.filter((t) => !excluded.has(t.ref))), + [items, excluded], + ); + + return { + rows, + busy, + error, + text, + setText, + statuses: facets.statuses ?? [], + priorities: facets.priorities ?? [], + toggleStatus, + togglePriority, + clearFacets, + hasMore: cursor !== undefined, + loadMore, + resolve, + }; +} diff --git a/frontend/src/shared/index.ts b/frontend/src/shared/index.ts index 7467b85..df8e4db 100644 --- a/frontend/src/shared/index.ts +++ b/frontend/src/shared/index.ts @@ -31,3 +31,6 @@ export type { ToolbarProps } from "./ui/Toolbar"; export { Spinner } from "./ui/Spinner"; export type { SpinnerProps } from "./ui/Spinner"; + +export { zIndex } from "./ui/zIndex"; +export type { ZIndexLevel } from "./ui/zIndex"; diff --git a/frontend/src/shared/ui/zIndex.ts b/frontend/src/shared/ui/zIndex.ts new file mode 100644 index 0000000..43fafa6 --- /dev/null +++ b/frontend/src/shared/ui/zIndex.ts @@ -0,0 +1,28 @@ +/** + * Centralized z-index scale for full-window chrome overlays (G2 — UI rework). + * + * In-cell veils/portals keep their historical low values (chrome 2–5, F3 z-20, + * write-portal z-4, resume z-5); this scale is for floating windows and menus + * mounted at the **chrome level** (ProjectsView/App), never inside + * LayoutGrid/LeafView (G5). + * + * ⚠️ PROVISIONAL (lot B / ticket #18): created here because lot A (ticket #16) + * introduces the canonical `shared/ui/zIndex.ts` (+ `FloatingWindow`) in + * parallel. The values below MUST match the frozen scale; at merge, keep a + * single module (drop this one if lot A's is richer). See the sprint scoping + * note `ui-rework-sprint-scoping-contracts`. + */ +export const zIndex = { + /** Dropdown menus / popovers anchored to a control. */ + menuDropdown: 40, + /** A top-level floating window (movable panel, editor overlay). */ + floatingWindow: 50, + /** A floating window opened from within another floating window (e.g. a + * ticket picker launched from a sprint/link dialog). Sits above it. */ + floatingWindowNested: 60, + /** Transient toasts, always on top. */ + toast: 70, +} as const; + +/** A level name in the {@link zIndex} scale. */ +export type ZIndexLevel = keyof typeof zIndex;