Skip to content

Commit 37eda9b

Browse files
authored
fix: folder-scoped case selection, bulk-edit issue unlink + version snapshots, and sync-button clarity (#453)
* fix(repository): scope test-case selection to the current folder view Repository case selection lived in a single global list that was never cleared on folder navigation, so a bulk action could silently span cases in folders the user had navigated away from -- and inconsistently between Select All and manual picks. Clear the bulk-edit selection when the folder/view/filter changes. Selection still persists across pagination and sorting within a view, and run-mode (test-run building) selection is parent-owned and left untouched. Adds unit coverage (clear-on-folder-switch, keep-on-paginate, clear-on-filter-change, preserve-in-selection-mode) plus an E2E spec that reproduces the Select-All cross-folder scenario through the TreeView. * fix(test-cases): remove issue links cleared during bulk edit Bulk Edit only ever computed an issues `connect` set and skipped the update entirely when the new selection was empty, so clearing or removing linked issues left them attached. Mirror the tags branch: compute both connect and disconnect and apply whichever changed. * fix(test-cases): snapshot post-update relations in bulk-edit versions The bulk-edit version snapshot passed tags/issues/steps/stateName overrides built from the pre-update case, so a new version recorded the old relations and state name after a bulk change. Drop those overrides and let the version service read the freshly-updated row inside the same transaction. * fix(integrations): clarify that sync re-syncs only linked issues The integration sync button read "Sync all issues from issue integration", but it only refreshes issues already linked into TestPlanIt. Relabel it "Re-sync linked issues", add a tooltip hint explaining new issues aren't imported, and document the behavior so it isn't mistaken for importing every external issue. * style: apply prettier formatting to bulk-edit and folder-selection tests Run the three test/spec files through Prettier so `pnpm format:check` (part of the precommit gate) passes.
1 parent 1e67b18 commit 37eda9b

25 files changed

Lines changed: 711 additions & 69 deletions

File tree

docs/docs/user-guide/integrations.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,14 @@ To delete:
433433
2. Click **Delete** and confirm
434434
3. Linked issues remain in the database but become unlinked
435435

436+
### Re-syncing Linked Issues
437+
438+
Each integration row exposes a **Re-sync linked issues** action (the refresh icon). It queues a background job that refreshes every issue **already linked into TestPlanIt** for that integration — pulling the latest status, priority, title, and description from the external tracker. A toast confirms the job was queued; the Issues table updates automatically when it finishes.
439+
440+
:::info
441+
This action **re-syncs issues that already exist in TestPlanIt — it does not import new issues from the external tracker.** Issues enter TestPlanIt when you link them (see [Manual Linking](#manual-linking)) or when an [inbound webhook](#real-time-updates-via-inbound-webhooks) references them for the first time; a re-sync only refreshes that existing set. The action is unavailable for **Simple URL** integrations, which have no upstream API to pull from.
442+
:::
443+
436444
## Project Configuration
437445

438446
After creating an integration, assign it to projects:

testplanit/app/[locale]/admin/integrations/SyncIntegrationButton.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ export function SyncIntegrationButton({
8181
</TooltipTrigger>
8282
<TooltipContent>
8383
<p>{t("syncIntegration")}</p>
84+
<p className="mt-1 max-w-xs text-xs text-muted-foreground">
85+
{t("syncIntegrationHint")}
86+
</p>
8487
</TooltipContent>
8588
</Tooltip>
8689
);

testplanit/app/[locale]/projects/repository/[projectId]/BulkEditModal.test.tsx

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,25 @@ vi.mock("~/lib/hooks/project-llm-integration", () => ({
173173
}),
174174
}));
175175

176+
// Stub the issue picker so tests can deterministically drive issue selection
177+
// without rendering the real UnifiedIssueManager (network + provider heavy).
178+
vi.mock("@/components/issues/UnifiedIssueManager", () => ({
179+
UnifiedIssueManager: ({ linkedIssueIds, setLinkedIssueIds }: any) => (
180+
<div data-testid="mock-issue-manager">
181+
<span data-testid="linked-issue-ids">
182+
{(linkedIssueIds ?? []).join(",")}
183+
</span>
184+
<button
185+
type="button"
186+
data-testid="clear-issues"
187+
onClick={() => setLinkedIssueIds([])}
188+
>
189+
clear issues
190+
</button>
191+
</div>
192+
),
193+
}));
194+
176195
// Now import everything else after the mocks
177196
import {
178197
DateFormat,
@@ -1732,6 +1751,128 @@ describe("BulkEditModal", () => {
17321751
);
17331752
});
17341753

1754+
it("disconnects issues that are removed during bulk edit", async () => {
1755+
// Reproduce the reported bug: two cases linked to the same issue, then
1756+
// the issue is cleared in bulk edit. The payload must request a
1757+
// disconnect — previously it only ever sent connect, so links survived.
1758+
const casesSharingIssue = mockCasesWithTextFields.map((c) => ({
1759+
...c,
1760+
issues: [mockIssuesData[0]],
1761+
}));
1762+
1763+
global.fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
1764+
const url =
1765+
typeof input === "string"
1766+
? input
1767+
: input instanceof URL
1768+
? input.toString()
1769+
: input.url;
1770+
1771+
if (
1772+
url.includes("/api/projects/") &&
1773+
(url.includes("/cases/fetch-many") ||
1774+
url.includes("/cases/bulk-edit-fetch"))
1775+
) {
1776+
return Promise.resolve({
1777+
ok: true,
1778+
json: async () => ({ cases: casesSharingIssue }),
1779+
status: 200,
1780+
statusText: "OK",
1781+
headers: new Headers(),
1782+
} as Response);
1783+
}
1784+
1785+
if (
1786+
url.includes("/api/projects/") &&
1787+
url.includes("/cases/bulk-edit") &&
1788+
!url.includes("bulk-edit-fetch") &&
1789+
init?.method === "POST"
1790+
) {
1791+
const payload = init.body ? JSON.parse(init.body as string) : {};
1792+
bulkEditCalls.push({ url, payload });
1793+
return Promise.resolve({
1794+
ok: true,
1795+
json: async () => ({
1796+
success: true,
1797+
updatedCount: payload.caseIds?.length || 0,
1798+
}),
1799+
status: 200,
1800+
statusText: "OK",
1801+
headers: new Headers(),
1802+
} as Response);
1803+
}
1804+
1805+
return Promise.resolve({
1806+
ok: true,
1807+
json: async () => ({}),
1808+
status: 200,
1809+
statusText: "OK",
1810+
headers: new Headers(),
1811+
} as Response);
1812+
}) as any;
1813+
1814+
render(
1815+
<BulkEditModal
1816+
isOpen={true}
1817+
onClose={vi.fn()}
1818+
onSaveSuccess={vi.fn()}
1819+
selectedCaseIds={[1, 2]}
1820+
projectId={1}
1821+
/>
1822+
);
1823+
1824+
// Wait for loading spinner to disappear
1825+
await waitFor(() => {
1826+
const spinner = document.querySelector(".animate-spin");
1827+
expect(spinner).not.toBeInTheDocument();
1828+
});
1829+
1830+
// Enable editing for the issues field
1831+
const issuesCheckbox = document.getElementById(
1832+
"edit-issues"
1833+
) as HTMLInputElement;
1834+
await act(async () => {
1835+
fireEvent.click(issuesCheckbox);
1836+
});
1837+
1838+
await waitFor(() => {
1839+
expect(issuesCheckbox).toBeChecked();
1840+
});
1841+
1842+
// Both cases share issue id 1, so editing starts with it selected
1843+
await waitFor(() => {
1844+
expect(screen.getByTestId("linked-issue-ids")).toHaveTextContent("1");
1845+
});
1846+
1847+
// Remove all linked issues
1848+
await act(async () => {
1849+
fireEvent.click(screen.getByTestId("clear-issues"));
1850+
});
1851+
1852+
const saveButton = screen.getByRole("button", {
1853+
name: "[t]common.actions.save",
1854+
});
1855+
await waitFor(() => {
1856+
expect(saveButton).not.toBeDisabled();
1857+
});
1858+
await act(async () => {
1859+
fireEvent.click(saveButton);
1860+
});
1861+
1862+
await waitFor(
1863+
() => {
1864+
expect(bulkEditCalls.length).toBe(1);
1865+
const { payload } = bulkEditCalls[0];
1866+
expect(payload.caseIds).toEqual([1, 2]);
1867+
// The fix: removed issues are disconnected, not silently dropped
1868+
expect(payload.updates.issues).toEqual({
1869+
disconnect: [{ id: 1 }],
1870+
});
1871+
},
1872+
{ timeout: 10000 }
1873+
);
1874+
});
1875+
17351876
it("should handle bulk delete", async () => {
17361877
const user = userEvent.setup();
17371878
const onSaveSuccess = vi.fn();

testplanit/app/[locale]/projects/repository/[projectId]/BulkEditModal.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1462,7 +1462,7 @@ export function BulkEditModal({
14621462
}
14631463
}
14641464
} else if (fieldKey === "issues") {
1465-
// Similar logic to tags
1465+
// Get all unique issue IDs from all cases
14661466
const allCurrentIssueIds = new Set<number>();
14671467
casesData.forEach((c) => {
14681468
(c.issues || []).forEach((i) => allCurrentIssueIds.add(i.id));
@@ -1472,14 +1472,25 @@ export function BulkEditModal({
14721472
? newValue.map(Number)
14731473
: [];
14741474

1475+
// Connect new issues that aren't currently on any case
14751476
const issuesToConnect = newIssueIds
14761477
.filter((id) => !allCurrentIssueIds.has(id))
14771478
.map((id) => ({ id }));
14781479

1479-
if (newIssueIds.length > 0) {
1480-
payload.updates.issues = {
1481-
connect: issuesToConnect.length > 0 ? issuesToConnect : [],
1482-
};
1480+
// Disconnect issues that were removed (were on cases but not in new selection)
1481+
const issuesToDisconnect = Array.from(allCurrentIssueIds)
1482+
.filter((id) => !newIssueIds.includes(id))
1483+
.map((id) => ({ id }));
1484+
1485+
// Only update issues if there are actual changes
1486+
if (issuesToConnect.length > 0 || issuesToDisconnect.length > 0) {
1487+
payload.updates.issues = {};
1488+
if (issuesToConnect.length > 0) {
1489+
payload.updates.issues.connect = issuesToConnect;
1490+
}
1491+
if (issuesToDisconnect.length > 0) {
1492+
payload.updates.issues.disconnect = issuesToDisconnect;
1493+
}
14831494
}
14841495
}
14851496
}

testplanit/app/[locale]/projects/repository/[projectId]/Cases.test.tsx

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,8 @@ vi.mock("./columns", () => ({
291291
}));
292292

293293
// ---- Imports ----
294-
import { render, screen, waitFor } from "@testing-library/react";
294+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
295+
import { DataTable } from "@/components/tables/DataTable";
295296
import React from "react";
296297
import * as NextAuth from "next-auth/react";
297298
import { useFindManyRepositoryCasesByDescendants } from "~/hooks/useRepositoryCasesByDescendants";
@@ -701,4 +702,156 @@ describe("Cases component", () => {
701702
expect(dataTable).toBeInTheDocument();
702703
expect(dataTable.getAttribute("data-count")).toBe("1");
703704
});
705+
706+
// The default DataTable stub does not render interactive rows. Swap in a stub
707+
// that exposes a button to drive the table's row-selection callback, so a test
708+
// can simulate selecting the first visible case. Returns a restore function;
709+
// afterEach's clearAllMocks does not reset implementations, so callers must
710+
// restore to avoid leaking the stub into later tests.
711+
function installSelectableDataTable() {
712+
const dataTableMock = vi.mocked(DataTable);
713+
const originalImpl = dataTableMock.getMockImplementation();
714+
dataTableMock.mockImplementation(({ onRowSelectionChange }: any) => (
715+
<button
716+
data-testid="simulate-select-first-row"
717+
onClick={() => onRowSelectionChange?.(() => ({ "0": true }))}
718+
>
719+
select first row
720+
</button>
721+
));
722+
return () => dataTableMock.mockImplementation(originalImpl!);
723+
}
724+
725+
it("clears the bulk-edit selection when switching folders", async () => {
726+
const restoreDataTable = installSelectableDataTable();
727+
try {
728+
setupMocks({ data: [{ ...mockCase, id: 101, folderId: 1 }] });
729+
730+
const { rerender } = render(
731+
<Cases {...defaultProps} viewType="folders" folderId={1} />
732+
);
733+
734+
// Selecting a case in folder 1 surfaces the bulk-edit action.
735+
fireEvent.click(await screen.findByTestId("simulate-select-first-row"));
736+
expect(await screen.findByTestId("bulk-edit-button")).toBeInTheDocument();
737+
738+
// Navigating to a different folder must clear the selection so a bulk
739+
// action can't silently span cases the user can no longer see.
740+
setupMocks({ data: [{ ...mockCase, id: 202, folderId: 2 }] });
741+
rerender(<Cases {...defaultProps} viewType="folders" folderId={2} />);
742+
743+
await waitFor(() => {
744+
expect(
745+
screen.queryByTestId("bulk-edit-button")
746+
).not.toBeInTheDocument();
747+
});
748+
} finally {
749+
restoreDataTable();
750+
}
751+
});
752+
753+
it("keeps the bulk-edit selection when paginating within the same folder", async () => {
754+
const restoreDataTable = installSelectableDataTable();
755+
try {
756+
setupMocks({ data: [{ ...mockCase, id: 101, folderId: 1 }] });
757+
758+
const { rerender } = render(
759+
<Cases {...defaultProps} viewType="folders" folderId={1} />
760+
);
761+
762+
fireEvent.click(await screen.findByTestId("simulate-select-first-row"));
763+
expect(await screen.findByTestId("bulk-edit-button")).toBeInTheDocument();
764+
765+
// Changing pages within the same folder must NOT clear the selection —
766+
// the cross-page merge logic depends on it persisting across pages.
767+
setupMocks({
768+
data: [{ ...mockCase, id: 101, folderId: 1 }],
769+
paginationOverrides: { currentPage: 2 },
770+
});
771+
rerender(<Cases {...defaultProps} viewType="folders" folderId={1} />);
772+
773+
// Give effects a chance to (incorrectly) clear before asserting it stayed.
774+
await waitFor(() => {
775+
expect(screen.getByTestId("bulk-edit-button")).toBeInTheDocument();
776+
});
777+
} finally {
778+
restoreDataTable();
779+
}
780+
});
781+
782+
it("clears the bulk-edit selection when the filter changes", async () => {
783+
const restoreDataTable = installSelectableDataTable();
784+
try {
785+
setupMocks({ data: [{ ...mockCase, id: 101, folderId: 1 }] });
786+
787+
const { rerender } = render(
788+
<Cases
789+
{...defaultProps}
790+
viewType="folders"
791+
folderId={1}
792+
filterId={null}
793+
/>
794+
);
795+
796+
fireEvent.click(await screen.findByTestId("simulate-select-first-row"));
797+
expect(await screen.findByTestId("bulk-edit-button")).toBeInTheDocument();
798+
799+
// Changing the filter swaps which cases are visible, so the selection is
800+
// scoped out the same way a folder switch is.
801+
setupMocks({ data: [{ ...mockCase, id: 202, folderId: 1 }] });
802+
rerender(
803+
<Cases
804+
{...defaultProps}
805+
viewType="folders"
806+
folderId={1}
807+
filterId={[7]}
808+
/>
809+
);
810+
811+
await waitFor(() => {
812+
expect(
813+
screen.queryByTestId("bulk-edit-button")
814+
).not.toBeInTheDocument();
815+
});
816+
} finally {
817+
restoreDataTable();
818+
}
819+
});
820+
821+
it("does not clear the parent-owned selection on folder switch in selection mode", async () => {
822+
const onSelectionChange = vi.fn();
823+
setupMocks({ data: [{ ...mockCase, id: 101, folderId: 1 }] });
824+
825+
const { rerender } = render(
826+
<Cases
827+
{...defaultProps}
828+
isSelectionMode={true}
829+
selectedTestCases={[101]}
830+
onSelectionChange={onSelectionChange}
831+
viewType="folders"
832+
folderId={1}
833+
/>
834+
);
835+
836+
await screen.findByTestId("data-table");
837+
838+
// Run-mode selection is owned by the parent and may legitimately span
839+
// folders, so switching folders must not reset it.
840+
setupMocks({ data: [{ ...mockCase, id: 202, folderId: 2 }] });
841+
rerender(
842+
<Cases
843+
{...defaultProps}
844+
isSelectionMode={true}
845+
selectedTestCases={[101]}
846+
onSelectionChange={onSelectionChange}
847+
viewType="folders"
848+
folderId={2}
849+
/>
850+
);
851+
852+
await waitFor(() => {
853+
expect(screen.getByTestId("data-table")).toBeInTheDocument();
854+
});
855+
expect(onSelectionChange).not.toHaveBeenCalledWith([]);
856+
});
704857
});

0 commit comments

Comments
 (0)