feat(tickets): popup réutilisable de sélection de ticket (#18)
Ajoute TicketPicker, popup modale réutilisable de sélection de ticket, socle du chemin critique du sprint UI rework (#17 et #19 en dépendent). - TicketPicker.tsx : popup de recherche/sélection réutilisable - useTicketSearch.ts : hook de recherche/filtrage extrait et partagé - TicketFacetsBar.tsx : barre de facettes extraite de TicketsPanel pour réutilisation (listing principal + picker) - shared/ui/zIndex.ts : échelle z-index centralisée - TicketsPanel : consomme la barre de facettes extraite Tests : TicketPicker.test.tsx 8/8, suite tickets 37/37, tsc --noEmit clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
126
frontend/src/features/tickets/TicketFacetsBar.tsx
Normal file
126
frontend/src/features/tickets/TicketFacetsBar.tsx
Normal file
@ -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 (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Input
|
||||||
|
aria-label="search tickets"
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => onTextChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
{/* 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={statuses.includes(s)}
|
||||||
|
onChange={() => onToggleStatus(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={priorities.includes(p)}
|
||||||
|
onChange={() => onTogglePriority(p)}
|
||||||
|
/>
|
||||||
|
<PriorityBadge priority={p} />
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
{onClearFacets && hasFacets && (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label="clear filters"
|
||||||
|
onClick={onClearFacets}
|
||||||
|
>
|
||||||
|
Effacer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
201
frontend/src/features/tickets/TicketPicker.test.tsx
Normal file
201
frontend/src/features/tickets/TicketPicker.test.tsx
Normal file
@ -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<React.ComponentProps<typeof TicketPicker>> = {},
|
||||||
|
) {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const gateways = { ticket } as unknown as Gateways;
|
||||||
|
const utils = render(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<TicketPicker
|
||||||
|
open
|
||||||
|
projectId={PROJECT_ID}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onClose={onClose}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
return { ...utils, onSelect, onClose };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seeds three tickets with distinct status/priority for filter assertions. */
|
||||||
|
async function seedTrio(ticket: MockTicketGateway): Promise<Ticket[]> {
|
||||||
|
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(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<TicketPicker
|
||||||
|
open={false}
|
||||||
|
projectId={PROJECT_ID}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
299
frontend/src/features/tickets/TicketPicker.tsx
Normal file
299
frontend/src/features/tickets/TicketPicker.tsx
Normal file
@ -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 (
|
||||||
|
<TicketPickerBody
|
||||||
|
projectId={projectId}
|
||||||
|
title={title}
|
||||||
|
confirmLabel={confirmLabel}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
excludeRefs={excludeRefs}
|
||||||
|
initialQuery={initialQuery}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<TicketPickerProps, "excludeRefs" | "initialQuery">) {
|
||||||
|
const vm = useTicketSearch(projectId, { initialQuery, excludeRefs });
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [selecting, setSelecting] = useState(false);
|
||||||
|
const [resolveError, setResolveError] = useState<string | null>(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<HTMLElement>(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<HTMLElement>(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 (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 flex items-center justify-center bg-black/55 p-4"
|
||||||
|
style={{ zIndex: zIndex.floatingWindowNested }}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
// Backdrop click closes; clicks inside the panel don't bubble here.
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
className="flex max-h-[80vh] w-full max-w-lg flex-col overflow-hidden rounded-lg border border-border bg-surface shadow-xl"
|
||||||
|
>
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
|
||||||
|
<span className="text-sm font-medium text-content">{title}</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label="close ticket picker"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── Filters ── */}
|
||||||
|
<div className="shrink-0 border-b border-border px-4 py-3">
|
||||||
|
<TicketFacetsBar
|
||||||
|
text={vm.text}
|
||||||
|
onTextChange={vm.setText}
|
||||||
|
statuses={vm.statuses}
|
||||||
|
priorities={vm.priorities}
|
||||||
|
onToggleStatus={vm.toggleStatus}
|
||||||
|
onTogglePriority={vm.togglePriority}
|
||||||
|
onClearFacets={vm.clearFacets}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(vm.error || resolveError) && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="shrink-0 border-b border-danger/40 bg-danger/10 px-4 py-2 text-sm text-danger"
|
||||||
|
>
|
||||||
|
{resolveError ?? vm.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Results ── */}
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto px-4 py-3">
|
||||||
|
{vm.busy && vm.rows.length === 0 ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted">
|
||||||
|
<Spinner size={14} />
|
||||||
|
<span>Loading tickets…</span>
|
||||||
|
</div>
|
||||||
|
) : vm.rows.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">No matching tickets.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col divide-y divide-border">
|
||||||
|
{vm.rows.map((t) => (
|
||||||
|
<li key={t.ref} className="py-1.5 first:pt-0 last:pb-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`select ticket ${t.ref}`}
|
||||||
|
disabled={selecting}
|
||||||
|
onClick={() => void handlePick(t.ref)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors",
|
||||||
|
"hover:bg-raised focus:bg-raised focus:outline-none",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TicketRefBadge ticketRef={t.ref} className="mt-0.5" />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content">
|
||||||
|
{t.title}
|
||||||
|
</span>
|
||||||
|
<span className="flex shrink-0 flex-col items-end gap-1">
|
||||||
|
<StatusBadge status={t.status} />
|
||||||
|
<PriorityBadge priority={t.priority} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vm.hasMore && (
|
||||||
|
<div className="mt-3 flex justify-center">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
loading={vm.busy}
|
||||||
|
onClick={() => vm.loadMore()}
|
||||||
|
>
|
||||||
|
Load more
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer reserved for multi-select confirm (not wired in single V1). */}
|
||||||
|
{selectionMode === "multi" && (
|
||||||
|
<footer className="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
|
||||||
|
<Button size="sm" variant="ghost" onClick={onClose}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" disabled>
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -14,14 +14,13 @@ import { Button, Input, Panel, Spinner, cn } from "@/shared";
|
|||||||
import { useTickets } from "./useTickets";
|
import { useTickets } from "./useTickets";
|
||||||
import { useProjectAgents } from "./useProjectAgents";
|
import { useProjectAgents } from "./useProjectAgents";
|
||||||
import { SprintManager } from "./SprintManager";
|
import { SprintManager } from "./SprintManager";
|
||||||
|
import { TicketFacetsBar } from "./TicketFacetsBar";
|
||||||
import {
|
import {
|
||||||
PriorityBadge,
|
PriorityBadge,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
TICKET_PRIORITIES,
|
TICKET_PRIORITIES,
|
||||||
TICKET_STATUSES,
|
|
||||||
TicketRef,
|
TicketRef,
|
||||||
priorityLabel,
|
priorityLabel,
|
||||||
statusLabel,
|
|
||||||
} from "./ticketMeta";
|
} from "./ticketMeta";
|
||||||
|
|
||||||
const selectClass = cn(
|
const selectClass = cn(
|
||||||
@ -152,60 +151,19 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
|||||||
|
|
||||||
{/* ── Filters ── */}
|
{/* ── Filters ── */}
|
||||||
<div className="mb-3 flex flex-col gap-2">
|
<div className="mb-3 flex flex-col gap-2">
|
||||||
<Input
|
{/* Shared search + status/priority facets (#18). Assignee stays here —
|
||||||
aria-label="search tickets"
|
it needs the project agent roster and is out of the picker's scope. */}
|
||||||
placeholder="Search title/description…"
|
<TicketFacetsBar
|
||||||
value={vm.query.text ?? ""}
|
text={vm.query.text ?? ""}
|
||||||
onChange={(e) =>
|
onTextChange={(text) =>
|
||||||
vm.setQuery({ ...vm.query, text: e.target.value || undefined })
|
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. */}
|
|
||||||
<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
|
<select
|
||||||
aria-label="filter by assignee"
|
aria-label="filter by assignee"
|
||||||
@ -225,17 +183,6 @@ 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>
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,19 @@ export { TicketDetail } from "./TicketDetail";
|
|||||||
export type { TicketDetailProps } from "./TicketDetail";
|
export type { TicketDetailProps } from "./TicketDetail";
|
||||||
export { SprintManager } from "./SprintManager";
|
export { SprintManager } from "./SprintManager";
|
||||||
export type { SprintManagerProps } 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 { useTickets } from "./useTickets";
|
||||||
export type { TicketsViewModel } from "./useTickets";
|
export type { TicketsViewModel } from "./useTickets";
|
||||||
export { useTicketDetail } from "./useTicketDetail";
|
export { useTicketDetail } from "./useTicketDetail";
|
||||||
|
|||||||
272
frontend/src/features/tickets/useTicketSearch.ts
Normal file
272
frontend/src/features/tickets/useTicketSearch.ts
Normal file
@ -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<TicketPickerResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Facets>({
|
||||||
|
statuses: initialQuery?.statuses,
|
||||||
|
priorities: initialQuery?.priorities,
|
||||||
|
assignedAgentId: initialQuery?.assignedAgentId,
|
||||||
|
});
|
||||||
|
const [text, setText] = useState(initialQuery?.text ?? "");
|
||||||
|
const [debouncedText, setDebouncedText] = useState(text);
|
||||||
|
|
||||||
|
const [items, setItems] = useState<TicketSummary[]>([]);
|
||||||
|
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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<TicketPickerResult> => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -31,3 +31,6 @@ export type { ToolbarProps } from "./ui/Toolbar";
|
|||||||
|
|
||||||
export { Spinner } from "./ui/Spinner";
|
export { Spinner } from "./ui/Spinner";
|
||||||
export type { SpinnerProps } from "./ui/Spinner";
|
export type { SpinnerProps } from "./ui/Spinner";
|
||||||
|
|
||||||
|
export { zIndex } from "./ui/zIndex";
|
||||||
|
export type { ZIndexLevel } from "./ui/zIndex";
|
||||||
|
|||||||
28
frontend/src/shared/ui/zIndex.ts
Normal file
28
frontend/src/shared/ui/zIndex.ts
Normal file
@ -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;
|
||||||
Reference in New Issue
Block a user