fix(ui): search parent-issue picker on the server (#11334)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The board UI lets an operator choose a parent issue for an issue > - The parent picker loads a priority-first page and filters that page in the browser > - A medium-priority or low-priority issue past the page limit never enters the picker > - This pull request sends typed parent-picker text to the server and keeps the picker exclusions > - The benefit is that the operator can select valid parent issues beyond the default page ## Linked Issues or Issue Description This pull request supersedes [#6193](https://github.com/paperclipai/paperclip/pull/6193), whose old file path no longer matches the current component tree. **What happened?** The parent picker fetched one default issue page and filtered it in the browser. The default page sorts by priority and caps the result at 500 issues. Valid medium-priority and low-priority parent issues beyond that page stayed hidden. **Expected behavior** The parent picker must search the server when the operator types text. It must show matching issues beyond the default page while it keeps the current issue and descendant exclusions. **Steps to reproduce** 1. Open an issue in a company with more than 500 issues. 2. Open the parent picker and type the name of a medium-priority or low-priority issue beyond the default page. 3. Observe that the picker does not show the matching issue. **Paperclip version or commit** Commit `c6965bd0237fd9536b41f1495e2a4bb252afcde7`. **Deployment mode** Local dev (`pnpm dev`). ## What Changed - Send parent-picker searches to the issue list endpoint with `q` and a bounded `limit` of 50. - Keep the empty-search list, cycle exclusions, and current sort behavior. - Add a component test for a low-priority match hidden by the default page. ## Verification - Run `pnpm vitest run ui/src/components/IssueProperties.test.tsx`. - Confirm that all 53 tests pass. - Confirm that the new test checks `{ q, limit: 50 }` and the matching issue. ## Risks - Low risk. The change affects only parent-picker search requests. - The server search uses the existing issue list query and does not change stored data. ## Model Used Codex, GPT-5, with tool use and code execution. The model assisted with the change and test. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
403fcefb97
commit
031003c5e1
|
|
@ -2039,6 +2039,82 @@ describe("IssueProperties", () => {
|
|||
|
||||
act(() => rerenderedRoot.unmount());
|
||||
});
|
||||
|
||||
it("searches the server for parent candidates so a lower-priority match past the default page stays selectable", async () => {
|
||||
const onUpdate = vi.fn();
|
||||
// The default list is priority-first and caps at 500 rows, so a low-priority
|
||||
// match past that cap never enters the client list. Model that gap: the default
|
||||
// page returns only a high-priority candidate, and the server search with `q`
|
||||
// returns the low-priority match that the default page hides.
|
||||
const defaultPageCandidate = createIssue({
|
||||
id: "issue-2",
|
||||
identifier: "PAP-2",
|
||||
title: "High priority candidate",
|
||||
status: "in_progress",
|
||||
priority: "high",
|
||||
});
|
||||
const lowPriorityMatch = createIssue({
|
||||
id: "issue-900",
|
||||
identifier: "PAP-900",
|
||||
title: "Low priority needle",
|
||||
status: "todo",
|
||||
priority: "low",
|
||||
});
|
||||
mockIssuesApi.list.mockImplementation((_companyId: string, filters?: { q?: string; limit?: number }) => {
|
||||
if (filters?.q === "needle") return Promise.resolve([lowPriorityMatch]);
|
||||
return Promise.resolve([defaultPageCandidate]);
|
||||
});
|
||||
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue(),
|
||||
childIssues: [],
|
||||
onUpdate,
|
||||
inline: true,
|
||||
});
|
||||
await flush();
|
||||
|
||||
const parentTrigger = findRowTrigger(container, "Parent");
|
||||
expect(parentTrigger).not.toBeUndefined();
|
||||
await act(async () => {
|
||||
parentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
// With an empty search the picker shows the default page. The low-priority match is absent.
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("PAP-2 High priority candidate");
|
||||
});
|
||||
expect(container.textContent).not.toContain("PAP-900 Low priority needle");
|
||||
|
||||
const searchInput = container.querySelector('input[placeholder="Search tasks..."]') as HTMLInputElement | null;
|
||||
expect(searchInput).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
nativeSetter?.call(searchInput, "needle");
|
||||
searchInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
// The typed text re-queries the server with `q`, and the low-priority match now appears.
|
||||
await waitForAssertion(() => {
|
||||
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", { q: "needle", limit: 50 });
|
||||
expect(container.textContent).toContain("PAP-900 Low priority needle");
|
||||
expect(container.textContent).not.toContain("PAP-2 High priority candidate");
|
||||
});
|
||||
|
||||
const candidateButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("PAP-900 Low priority needle"));
|
||||
expect(candidateButton).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
candidateButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith({ parentId: "issue-900" });
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("shows a run review action after reviewers are configured and starts execution explicitly when clicked", async () => {
|
||||
const onUpdate = vi.fn();
|
||||
const root = renderProperties(container, {
|
||||
|
|
|
|||
|
|
@ -287,6 +287,7 @@ export function IssueProperties({
|
|||
const [watchdogAgentInput, setWatchdogAgentInput] = useState(issue.watchdog?.watchdogAgentId ?? "");
|
||||
const [watchdogInstructionsInput, setWatchdogInstructionsInput] = useState(issue.watchdog?.instructions ?? "");
|
||||
const normalizedBlockedBySearch = blockedBySearch.trim();
|
||||
const normalizedParentSearch = parentSearch.trim();
|
||||
|
||||
useEffect(() => {
|
||||
setBlockedByExpanded(false);
|
||||
|
|
@ -349,6 +350,17 @@ export function IssueProperties({
|
|||
enabled: !!companyId && blockedByOpen && normalizedBlockedBySearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: searchedParentIssues, isFetching: isFetchingSearchedParentIssues } = useQuery({
|
||||
queryKey: companyId
|
||||
? queryKeys.issues.search(companyId, normalizedParentSearch, undefined, ISSUE_BLOCKER_SEARCH_LIMIT)
|
||||
: ["issues", "blocker-search", normalizedParentSearch, ISSUE_BLOCKER_SEARCH_LIMIT],
|
||||
queryFn: () => issuesApi.list(companyId!, {
|
||||
q: normalizedParentSearch,
|
||||
limit: ISSUE_BLOCKER_SEARCH_LIMIT,
|
||||
}),
|
||||
enabled: !!companyId && parentOpen && normalizedParentSearch.length > 0,
|
||||
});
|
||||
|
||||
const createLabel = useMutation({
|
||||
mutationFn: (data: { name: string; color: string }) => issuesApi.createLabel(companyId!, data),
|
||||
onSuccess: async (created) => {
|
||||
|
|
@ -1898,22 +1910,22 @@ export function IssueProperties({
|
|||
<ArrowUpRight className="h-3 w-3" />
|
||||
</Link>
|
||||
) : undefined;
|
||||
const parentOptions = (allIssues ?? [])
|
||||
const parentSearchActive = normalizedParentSearch.length > 0;
|
||||
// When the user types, search on the server. The default list caps at 500 rows
|
||||
// and sorts priority-first, so a medium-priority or low-priority match past that
|
||||
// cap never enters the client list. A server query with `q` still finds it.
|
||||
const parentSourceIssues = parentSearchActive ? searchedParentIssues : allIssues;
|
||||
const parentOptions = (parentSourceIssues ?? [])
|
||||
.filter((candidate) => candidate.id !== issue.id)
|
||||
.filter((candidate) => !descendantIssueIds.has(candidate.id))
|
||||
.filter((candidate) => {
|
||||
if (!parentSearch.trim()) return true;
|
||||
const query = parentSearch.toLowerCase();
|
||||
return (
|
||||
(candidate.identifier ?? "").toLowerCase().includes(query) ||
|
||||
candidate.title.toLowerCase().includes(query)
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aLabel = `${a.identifier ?? ""} ${a.title}`.trim();
|
||||
const bLabel = `${b.identifier ?? ""} ${b.title}`.trim();
|
||||
return aLabel.localeCompare(bLabel);
|
||||
});
|
||||
const parentOptionsLoading = parentOpen && (
|
||||
parentSearchActive ? isFetchingSearchedParentIssues : isFetchingIssuePickerIssues
|
||||
);
|
||||
const parentContent = (
|
||||
<>
|
||||
<input
|
||||
|
|
@ -1955,6 +1967,11 @@ export function IssueProperties({
|
|||
</span>
|
||||
</button>
|
||||
))}
|
||||
{parentOptionsLoading ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">Searching tasks...</div>
|
||||
) : parentOptions.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">No matching tasks.</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue