Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d2ebd4f
feat: add PR triage filters
mariusvniekerk Jun 19, 2026
0067231
fix: align PR filter menu with sidebar controls
mariusvniekerk Jun 19, 2026
d1a1460
fix: keep PR filter controls usable when compact
mariusvniekerk Jun 19, 2026
cb0a62d
fix: make compact PR filters unambiguous
mariusvniekerk Jun 20, 2026
341a276
test: select compact PR grouping labels
mariusvniekerk Jun 20, 2026
9d8a470
fix: keep New kanban filter aligned with workflow grouping
mariusvniekerk Jun 20, 2026
b7e99ed
fix: normalize default kanban API responses
mariusvniekerk Jun 20, 2026
6b896b4
fix: align API kanban filters with default state
mariusvniekerk Jun 20, 2026
8957cb4
fix: keep compact filters open while selecting
mariusvniekerk Jun 20, 2026
251c4c6
test: dismiss compact filters after one-shot choices
mariusvniekerk Jun 20, 2026
3dd9378
fix: preserve transient GitLab source lookup errors
mariusvniekerk Jun 22, 2026
4f16b1d
fix: keep GitLab source lookup retries disabled
mariusvniekerk Jun 22, 2026
f44eb23
fix: surface transient GitLab fork lookup failures upstream
mariusvniekerk Jun 22, 2026
a69cfc0
test: remove runtime terminal attach race
mariusvniekerk Jun 22, 2026
9ffb90d
test: align browser grouping helper with compact labels
mariusvniekerk Jun 22, 2026
1027723
test: cover GitLab sync and stabilize hook flakes
mariusvniekerk Jun 22, 2026
a9ac713
test: assert GitLab sync failure leaves no partial row
mariusvniekerk Jun 22, 2026
f50109e
chore: keep runtime flake fixes out of filter PR
mariusvniekerk Jun 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions frontend/src/App.grouping-toggle.browser.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,15 +206,24 @@ async function selectPullGrouping(label: string): Promise<void> {
// inline group buttons are CSS-hidden and grouping lives in the "Filters"
// compact dropdown. The "Group" section is last, so pick the last item whose
// label matches (the State section may also expose an "All" entry).
const filtersBtn = Array.from(document.querySelectorAll(".compact-filter-menu .filter-btn")).find((b) =>
(b.textContent ?? "").includes("Filters"),
)!;
await vi.waitFor(() => expect(document.querySelector(".compact-filter-menu .filter-btn")).not.toBeNull(), WAIT);
const filtersBtn = document.querySelector(".compact-filter-menu .filter-btn")!;
await page.elementLocator(filtersBtn).click();
await vi.waitFor(() => expect(document.querySelector(".compact-filter-menu .filter-dropdown")).not.toBeNull(), WAIT);
const compactLabel = compactPullGroupingLabel(label);
const items = Array.from(document.querySelectorAll(".compact-filter-menu .filter-dropdown .filter-item")).filter(
(el) => (el.querySelector(".filter-label")?.textContent ?? el.textContent ?? "").trim() === label,
(el) => (el.querySelector(".filter-label")?.textContent ?? el.textContent ?? "").trim() === compactLabel,
);
await page.elementLocator(items[items.length - 1]!).click();
const item = items[items.length - 1];
expect(item).toBeDefined();
await page.elementLocator(item!).click();
}

function compactPullGroupingLabel(label: string): string {
if (label === "Repo") return "By repo";
if (label === "Status") return "By status";
if (label === "All") return "Flat list";
return label;
}

function activityViewBtn(): Element {
Expand Down
11 changes: 10 additions & 1 deletion frontend/tests/e2e-full/grouping-toggle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,17 @@ async function selectPullGrouping(page: Page, label: string | RegExp): Promise<v
return;
}

const compactLabel = compactPullGroupingLabel(label);
await page.getByRole("button", { name: "Filters" }).click();
await page.locator(".filter-dropdown .filter-item", { hasText: label }).last().click();
await page.locator(".filter-dropdown .filter-item", { hasText: compactLabel }).click();
}

function compactPullGroupingLabel(label: string | RegExp): string | RegExp {
if (typeof label !== "string") return label;
if (label === "Repo") return "By repo";
if (label === "Status") return "By status";
if (label === "All") return "Flat list";
return label;
}

test.describe("grouping toggle", () => {
Expand Down
29 changes: 25 additions & 4 deletions frontend/tests/e2e-full/pull-list.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ async function selectPullState(page: Page, label: string): Promise<void> {
return;
}

await page.getByRole("button", { name: "Filters" }).click();
await page.locator(".filter-dropdown .filter-item", { hasText: label }).first().click();
const dropdown = await openCompactFilterMenu(page);
await dropdown.locator(".filter-item", { hasText: label }).first().click();
await page.keyboard.press("Escape");
await expect(dropdown).toBeHidden();
}

async function selectPullGrouping(page: Page, label: string): Promise<void> {
Expand All @@ -28,8 +30,27 @@ async function selectPullGrouping(page: Page, label: string): Promise<void> {
return;
}

await page.getByRole("button", { name: "Filters" }).click();
await page.locator(".filter-dropdown .filter-item", { hasText: label }).last().click();
const compactLabel = compactPullGroupingLabel(label);
const dropdown = await openCompactFilterMenu(page);
await dropdown.locator(".filter-item", { hasText: compactLabel }).click();
await page.keyboard.press("Escape");
await expect(dropdown).toBeHidden();
}

function compactPullGroupingLabel(label: string): string {
if (label === "Repo") return "By repo";
if (label === "Status") return "By status";
if (label === "All") return "Flat list";
return label;
}

async function openCompactFilterMenu(page: Page) {
const dropdown = page.locator(".filter-dropdown");
if (!(await dropdown.isVisible())) {
await page.locator(".compact-filter-menu .filter-btn").click();
await expect(dropdown).toBeVisible();
}
return dropdown;
}

const longRepoName = "widgets-with-an-extremely-long-repository-name";
Expand Down
138 changes: 123 additions & 15 deletions frontend/tests/e2e-full/sidebar-collapse.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { startIsolatedE2EServer } from "./support/e2eServer";

type PullListRow = {
Title: string;
ReviewDecision: string;
KanbanStatus: string;
};

type PullDetailRow = {
merge_request: PullListRow;
};

async function waitForPRList(page: Page): Promise<void> {
await page.locator(".pull-item").first().waitFor({ state: "visible", timeout: 10_000 });
Expand Down Expand Up @@ -77,9 +88,7 @@ async function expectCompactFiltersAtMinimumWidth(
await expect.poll(async () => sidebarWidth(sidebar)).toBe(200);

const filterBar = sidebar.locator(".filter-bar").first();
const compactFilters = filterBar.getByRole("button", {
name: "Filters",
});
const compactFilters = filterBar.locator(".compact-filter-menu .filter-btn");
await expect(compactFilters).toBeVisible();
await expect(filterBar.locator(".state-toggle")).toBeHidden();
await expect(filterBar.locator(".group-toggle")).toBeHidden();
Expand All @@ -103,7 +112,7 @@ async function expectCompactFiltersInNarrowViewport(

const sidebar = page.locator(".sidebar").first();
const filterBar = sidebar.locator(".filter-bar").first();
await expect(filterBar.getByRole("button", { name: "Filters" })).toBeVisible();
await expect(filterBar.locator(".compact-filter-menu .filter-btn")).toBeVisible();
await expect(filterBar.locator(".state-toggle")).toBeHidden();
await expect(filterBar.locator(".group-toggle")).toBeHidden();
}
Expand All @@ -125,21 +134,22 @@ async function setPersistedSidebarWidth(
}

async function expectCompactFilterBar(filterBar: Locator): Promise<void> {
await expect(filterBar.getByRole("button", { name: "Filters" })).toBeVisible();
await expect(filterBar.locator(".compact-filter-menu .filter-btn")).toBeVisible();
await expect(filterBar.locator(".local-filter-menu .filter-btn")).toBeHidden();
await expect(filterBar.locator(".state-toggle")).toBeHidden();
await expect(filterBar.locator(".group-toggle")).toBeHidden();
await expectFastAnimation(filterBar.locator(".compact-filter-menu"));
}

async function openCompactFilters(filterBar: Locator): Promise<Locator> {
await filterBar.getByRole("button", { name: "Filters" }).click();
await filterBar.locator(".compact-filter-menu .filter-btn").click();
const dropdown = filterBar.page().locator(".filter-dropdown");
await expect(dropdown).toBeVisible();
return dropdown;
}

async function expectExpandedFilterBar(filterBar: Locator): Promise<void> {
await expect(filterBar.getByRole("button", { name: "Filters" })).toBeHidden();
await expect(filterBar.locator(".compact-filter-menu .filter-btn")).toBeHidden();
await expect(filterBar.locator(".state-toggle")).toBeVisible();
await expect(filterBar.locator(".group-toggle")).toBeVisible();
await expectFastAnimation(filterBar.locator(".state-toggle"));
Expand All @@ -150,6 +160,32 @@ async function expectExpandedFilterBar(filterBar: Locator): Promise<void> {
expect(filterMetrics.scrollWidth).toBeLessThanOrEqual(filterMetrics.clientWidth);
}

async function expectPullLocalFilterIconOnly(filterBar: Locator): Promise<void> {
const localFilter = filterBar.locator(".local-filter-menu");
await expect(filterBar.locator(".state-toggle")).toBeVisible();
await expect(filterBar.locator(".group-toggle")).toBeVisible();
await expect(filterBar.locator(".compact-filter-menu")).toBeHidden();
await expect(localFilter.locator(".filter-trigger-label")).toHaveCSS("display", "none");

const triggerWidth = await localFilter
.locator(".filter-btn")
.evaluate((node) => Math.round(node.getBoundingClientRect().width));
expect(triggerWidth).toBe(34);

const filterMetrics = await filterBar.evaluate((node) => ({
clientWidth: node.clientWidth,
scrollWidth: node.scrollWidth,
}));
expect(filterMetrics.scrollWidth).toBeLessThanOrEqual(filterMetrics.clientWidth);
}

async function expectPullLocalFilterLabeled(filterBar: Locator): Promise<void> {
await expectExpandedFilterBar(filterBar);
const localFilter = filterBar.locator(".local-filter-menu");
await expect(localFilter.locator(".filter-trigger-label")).toHaveText("PR filters");
await expect(localFilter.locator(".filter-trigger-label")).not.toHaveCSS("display", "none");
}

async function expectFastAnimation(locator: Locator): Promise<void> {
const durationMs = await locator.evaluate((node) => {
const durations = getComputedStyle(node)
Expand Down Expand Up @@ -269,9 +305,14 @@ test.describe("collapsible sidebar", () => {
await expectCompactFiltersInNarrowViewport(page, "/issues", waitForIssueList);
});

test("pull filters switch at the buffered 396px fit point", async ({ page }) => {
await expectCompactFilterBar(await setPersistedSidebarWidth(page, "/pulls", 395, waitForPRList));
await expectExpandedFilterBar(await setPersistedSidebarWidth(page, "/pulls", 396, waitForPRList));
test("pull filters switch at the buffered 431px fit point", async ({ page }) => {
await expectCompactFilterBar(await setPersistedSidebarWidth(page, "/pulls", 430, waitForPRList));
await expectExpandedFilterBar(await setPersistedSidebarWidth(page, "/pulls", 431, waitForPRList));
});

test("pull PR filter becomes icon-only before the filter row compacts", async ({ page }) => {
await expectPullLocalFilterIconOnly(await setPersistedSidebarWidth(page, "/pulls", 520, waitForPRList));
await expectPullLocalFilterLabeled(await setPersistedSidebarWidth(page, "/pulls", 521, waitForPRList));
});

test("issue filters switch at the buffered 373px fit point", async ({ page }) => {
Expand All @@ -283,28 +324,95 @@ test.describe("collapsible sidebar", () => {
const filterBar = await setPersistedSidebarWidth(page, "/pulls", 395, waitForPRList);
await expectCompactFilterBar(filterBar);

let dropdown = await openCompactFilters(filterBar);
const dropdown = await openCompactFilters(filterBar);
await expect(dropdown.locator(".filter-section-title", { hasText: "PR" })).toBeVisible();
await expect(dropdown.locator(".filter-section-title", { hasText: "Kanban" })).toBeVisible();
await dropdown.locator(".filter-item", { hasText: "Closed" }).click();
await expect(filterBar.page().locator(".state-note")).toBeVisible();
await expect(dropdown).toBeVisible();

dropdown = await openCompactFilters(filterBar);
await dropdown.locator(".filter-item", { hasText: "All" }).last().click();
await dropdown.locator(".filter-item", { hasText: "Flat list" }).click();
await expect(dropdown).toBeVisible();
await expect(page.locator(".repo-header")).toHaveCount(0, {
timeout: 5_000,
});
await expect(page.locator(".repo-chip").first()).toBeVisible();
});

test("pull compact filters apply PR attributes and kanban status against the real API", async ({ page }) => {
const server = await startIsolatedE2EServer();
try {
const stateResponse = await page.request.put(`${server.info.base_url}/api/v1/pulls/github/acme/widgets/1/state`, {
data: { status: "reviewing" },
});
expect(stateResponse.ok()).toBe(true);

const pullsResponse = await page.request.get(`${server.info.base_url}/api/v1/pulls?state=open`);
expect(pullsResponse.ok()).toBe(true);
const pulls = (await pullsResponse.json()) as PullListRow[];
const expectedTitles = pulls
.filter((pull) => pull.ReviewDecision.trim().toUpperCase() === "APPROVED" && pull.KanbanStatus === "reviewing")
.map((pull) => pull.Title);
expect(expectedTitles).toEqual(["Add widget caching layer"]);

const filterBar = await setPersistedSidebarWidth(page, `${server.info.base_url}/pulls`, 395, waitForPRList);
await expectCompactFilterBar(filterBar);

const dropdown = await openCompactFilters(filterBar);
await dropdown.getByRole("button", { name: "Approved", exact: true }).click();
await dropdown.getByRole("button", { name: "Reviewing", exact: true }).click();

const rows = page.locator(".pull-item");
await expect(rows).toHaveCount(expectedTitles.length);
for (const title of expectedTitles) {
await expect(page.locator(".pull-item", { hasText: title })).toBeVisible();
}
} finally {
await server.stop();
}
});

test("pull compact filters keep default-new PRs visible through the real API", async ({ page }) => {
const server = await startIsolatedE2EServer();
try {
const detailResponse = await page.request.get(`${server.info.base_url}/api/v1/pulls/github/acme/widgets/1`);
expect(detailResponse.ok()).toBe(true);
const detail = (await detailResponse.json()) as PullDetailRow;
expect(detail.merge_request.KanbanStatus).toBe("new");

const pullsResponse = await page.request.get(`${server.info.base_url}/api/v1/pulls?state=open`);
expect(pullsResponse.ok()).toBe(true);
const pulls = (await pullsResponse.json()) as PullListRow[];
const expectedTitles = pulls.filter((pull) => pull.KanbanStatus === "new").map((pull) => pull.Title);
expect(expectedTitles).toContain("Add widget caching layer");

const filterBar = await setPersistedSidebarWidth(page, `${server.info.base_url}/pulls`, 395, waitForPRList);
await expectCompactFilterBar(filterBar);

const dropdown = await openCompactFilters(filterBar);
await dropdown.getByRole("button", { name: "New", exact: true }).click();

const rows = page.locator(".pull-item");
await expect(rows).toHaveCount(expectedTitles.length);
for (const title of expectedTitles) {
await expect(page.locator(".pull-item", { hasText: title })).toBeVisible();
}
} finally {
await server.stop();
}
});

test("issue compact filters update state and grouping", async ({ page }) => {
const filterBar = await setPersistedSidebarWidth(page, "/issues", 372, waitForIssueList);
await expectCompactFilterBar(filterBar);

let dropdown = await openCompactFilters(filterBar);
const dropdown = await openCompactFilters(filterBar);
await dropdown.locator(".filter-item", { hasText: "Closed" }).click();
await expect(filterBar.page().locator(".state-note")).toBeVisible();
await expect(dropdown).toBeVisible();

dropdown = await openCompactFilters(filterBar);
await dropdown.locator(".filter-item", { hasText: "All" }).last().click();
await expect(dropdown).toBeVisible();
await expect(page.locator(".repo-header")).toHaveCount(0, {
timeout: 5_000,
});
Expand Down
Loading
Loading