feat(gitlab): filter issues by milestone (#3278)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { Issue } from "@/lib/types/gitlab";
|
||||
import { IssueList } from "./issue-list";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
function fakeIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
return {
|
||||
id: 1,
|
||||
iid: 1,
|
||||
project_id: 1,
|
||||
title: "Fix the thing",
|
||||
body: "",
|
||||
url: "",
|
||||
web_url: "",
|
||||
state: "opened",
|
||||
author_username: "alice",
|
||||
project_namespace: "acme",
|
||||
project_path: "acme/api",
|
||||
labels: [],
|
||||
assignees: [],
|
||||
milestone: "",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("IssueList milestone chip", () => {
|
||||
it("renders no chip when the issue has no milestone, and leaves the label chips unmoved (Scenario 14)", () => {
|
||||
render(
|
||||
<IssueList
|
||||
items={[fakeIssue({ labels: ["bug", "frontend"] })]}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId("gitlab-issue-milestone")).toBeNull();
|
||||
expect(screen.getByText("bug")).not.toBeNull();
|
||||
expect(screen.getByText("frontend")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("positions the milestone chip after the project#iid/author/time metadata and before the first label chip (Scenario 14)", () => {
|
||||
render(
|
||||
<IssueList
|
||||
items={[fakeIssue({ milestone: "Next", labels: ["bug", "frontend"] })]}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>,
|
||||
);
|
||||
const row = screen.getByTestId("issue-row");
|
||||
const metadata = within(row).getByText(/acme\/api#1/);
|
||||
const chip = within(row).getByTestId("gitlab-issue-milestone");
|
||||
const firstLabel = within(row).getByText("bug");
|
||||
|
||||
expect(chip.textContent).toBe("Next");
|
||||
// DOM order: metadata, then the milestone chip, then the first label chip.
|
||||
expect(
|
||||
// eslint-disable-next-line no-bitwise -- Node.compareDocumentPosition bitmask, not a numeric flag
|
||||
metadata.compareDocumentPosition(chip) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).not.toBe(0);
|
||||
expect(
|
||||
// eslint-disable-next-line no-bitwise -- Node.compareDocumentPosition bitmask, not a numeric flag
|
||||
chip.compareDocumentPosition(firstLabel) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).not.toBe(0);
|
||||
});
|
||||
|
||||
it("does not consume a label slot: a milestone plus six labels renders the chip, four labels, and a +2 overflow indicator (Scenario 26)", () => {
|
||||
render(
|
||||
<IssueList
|
||||
items={[
|
||||
fakeIssue({
|
||||
milestone: "Next",
|
||||
labels: ["l1", "l2", "l3", "l4", "l5", "l6"],
|
||||
}),
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>,
|
||||
);
|
||||
const row = screen.getByTestId("issue-row");
|
||||
expect(within(row).getByTestId("gitlab-issue-milestone").textContent).toBe("Next");
|
||||
for (const label of ["l1", "l2", "l3", "l4"]) {
|
||||
expect(within(row).getByText(label)).not.toBeNull();
|
||||
}
|
||||
expect(within(row).queryByText("l5")).toBeNull();
|
||||
expect(within(row).queryByText("l6")).toBeNull();
|
||||
expect(within(row).getByText("+2")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,19 @@ type IssueListProps = {
|
||||
host?: string;
|
||||
};
|
||||
|
||||
function IssueMilestoneChip({ milestone }: { milestone: string }) {
|
||||
if (!milestone) return null;
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0 h-4"
|
||||
data-testid="gitlab-issue-milestone"
|
||||
>
|
||||
{milestone}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueLabels({ labels }: { labels: string[] }) {
|
||||
if (!labels?.length) return null;
|
||||
return (
|
||||
@@ -76,6 +89,7 @@ function IssueRow({
|
||||
time: formatRelativeTime(issue.created_at),
|
||||
})}
|
||||
</span>
|
||||
<IssueMilestoneChip milestone={issue.milestone ?? ""} />
|
||||
<IssueLabels labels={issue.labels} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import { ListToolbar } from "./list-toolbar";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const MILESTONE_TEST_ID = "gitlab-milestone-filter";
|
||||
|
||||
function renderToolbar({
|
||||
milestone = "",
|
||||
committedMilestone = "",
|
||||
}: { milestone?: string; committedMilestone?: string } = {}) {
|
||||
const onMilestoneChange = vi.fn();
|
||||
const onCommitMilestone = vi.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ListToolbar
|
||||
title="Assigned to me"
|
||||
count={2}
|
||||
loading={false}
|
||||
lastFetchedAt={null}
|
||||
customQuery=""
|
||||
committedQuery=""
|
||||
onCustomQueryChange={vi.fn()}
|
||||
onCommitCustomQuery={vi.fn()}
|
||||
projectFilter=""
|
||||
onProjectFilterChange={vi.fn()}
|
||||
projectOptions={[]}
|
||||
onRefresh={vi.fn()}
|
||||
showMilestoneFilter
|
||||
milestone={milestone}
|
||||
committedMilestone={committedMilestone}
|
||||
onMilestoneChange={onMilestoneChange}
|
||||
onCommitMilestone={onCommitMilestone}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
return { onMilestoneChange, onCommitMilestone };
|
||||
}
|
||||
|
||||
describe("MilestoneFilterInput", () => {
|
||||
it("does not render when showMilestoneFilter is false", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ListToolbar
|
||||
title="Assigned to me"
|
||||
count={2}
|
||||
loading={false}
|
||||
lastFetchedAt={null}
|
||||
customQuery=""
|
||||
committedQuery=""
|
||||
onCustomQueryChange={vi.fn()}
|
||||
onCommitCustomQuery={vi.fn()}
|
||||
projectFilter=""
|
||||
onProjectFilterChange={vi.fn()}
|
||||
projectOptions={[]}
|
||||
onRefresh={vi.fn()}
|
||||
showMilestoneFilter={false}
|
||||
milestone=""
|
||||
committedMilestone=""
|
||||
onMilestoneChange={vi.fn()}
|
||||
onCommitMilestone={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId(MILESTONE_TEST_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("commits on blur when the draft differs from the committed milestone", () => {
|
||||
const { onCommitMilestone } = renderToolbar({ milestone: "Next", committedMilestone: "" });
|
||||
|
||||
fireEvent.blur(screen.getByTestId(MILESTONE_TEST_ID));
|
||||
|
||||
expect(onCommitMilestone).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not commit on blur when the draft matches the committed milestone", () => {
|
||||
const { onCommitMilestone } = renderToolbar({ milestone: "Next", committedMilestone: "Next" });
|
||||
|
||||
fireEvent.blur(screen.getByTestId(MILESTONE_TEST_ID));
|
||||
|
||||
expect(onCommitMilestone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("commits on Enter and prevents the default form submission", () => {
|
||||
const { onCommitMilestone } = renderToolbar({ milestone: "Next", committedMilestone: "" });
|
||||
|
||||
const input = screen.getByTestId(MILESTONE_TEST_ID);
|
||||
const event = fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(onCommitMilestone).toHaveBeenCalledTimes(1);
|
||||
expect(event).toBe(false);
|
||||
});
|
||||
|
||||
it("does not commit while an IME is composing a milestone candidate", () => {
|
||||
const { onCommitMilestone } = renderToolbar({ milestone: "次", committedMilestone: "" });
|
||||
|
||||
fireEvent.keyDown(screen.getByTestId(MILESTONE_TEST_ID), {
|
||||
key: "Enter",
|
||||
isComposing: true,
|
||||
});
|
||||
|
||||
expect(onCommitMilestone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not commit on keys other than Enter", () => {
|
||||
const { onCommitMilestone } = renderToolbar({ milestone: "Next", committedMilestone: "" });
|
||||
|
||||
fireEvent.keyDown(screen.getByTestId(MILESTONE_TEST_ID), { key: "Escape" });
|
||||
|
||||
expect(onCommitMilestone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports every keystroke through onMilestoneChange without committing", () => {
|
||||
const { onMilestoneChange, onCommitMilestone } = renderToolbar();
|
||||
|
||||
fireEvent.change(screen.getByTestId(MILESTONE_TEST_ID), { target: { value: "Sprint 42" } });
|
||||
|
||||
expect(onMilestoneChange).toHaveBeenCalledWith("Sprint 42");
|
||||
expect(onCommitMilestone).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IntegrationListToolbar } from "@/components/integrations/integration-list-toolbar";
|
||||
|
||||
@@ -19,8 +20,76 @@ type ListToolbarProps = {
|
||||
onProjectFilterChange: (value: string) => void;
|
||||
projectOptions: string[];
|
||||
onRefresh: () => void;
|
||||
showMilestoneFilter: boolean;
|
||||
milestone: string;
|
||||
committedMilestone: string;
|
||||
onMilestoneChange: (value: string) => void;
|
||||
onCommitMilestone: () => void;
|
||||
};
|
||||
|
||||
function ProjectFilterSelect({
|
||||
projectFilter,
|
||||
onProjectFilterChange,
|
||||
projectOptions,
|
||||
}: Pick<ListToolbarProps, "projectFilter" | "onProjectFilterChange" | "projectOptions">) {
|
||||
const { t } = useTranslation();
|
||||
const selectValue = projectFilter || ALL_PROJECTS;
|
||||
return (
|
||||
<Select
|
||||
value={selectValue}
|
||||
onValueChange={(value) => onProjectFilterChange(value === ALL_PROJECTS ? "" : value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-8 w-full cursor-pointer md:w-[220px]"
|
||||
data-testid="gitlab-project-filter-trigger"
|
||||
>
|
||||
<SelectValue placeholder={t("gitlab:allProjects")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_PROJECTS} className="cursor-pointer">
|
||||
{t("gitlab:allProjects")}
|
||||
</SelectItem>
|
||||
{projectOptions.map((key) => (
|
||||
<SelectItem key={key} value={key} className="cursor-pointer">
|
||||
{key}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function MilestoneFilterInput({
|
||||
milestone,
|
||||
committedMilestone,
|
||||
onMilestoneChange,
|
||||
onCommitMilestone,
|
||||
}: Pick<
|
||||
ListToolbarProps,
|
||||
"milestone" | "committedMilestone" | "onMilestoneChange" | "onCommitMilestone"
|
||||
>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Input
|
||||
value={milestone}
|
||||
onChange={(event) => onMilestoneChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.nativeEvent.isComposing && event.keyCode !== 229) {
|
||||
event.preventDefault();
|
||||
onCommitMilestone();
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (milestone !== committedMilestone) onCommitMilestone();
|
||||
}}
|
||||
placeholder={t("gitlab:eGSprint42")}
|
||||
aria-label={t("gitlab:milestoneFilterLabel")}
|
||||
className="h-11 w-full md:h-8 md:w-[180px]"
|
||||
data-testid="gitlab-milestone-filter"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListToolbar({
|
||||
title,
|
||||
count,
|
||||
@@ -34,9 +103,13 @@ export function ListToolbar({
|
||||
onProjectFilterChange,
|
||||
projectOptions,
|
||||
onRefresh,
|
||||
showMilestoneFilter,
|
||||
milestone,
|
||||
committedMilestone,
|
||||
onMilestoneChange,
|
||||
onCommitMilestone,
|
||||
}: ListToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
const selectValue = projectFilter || ALL_PROJECTS;
|
||||
return (
|
||||
<IntegrationListToolbar
|
||||
title={title}
|
||||
@@ -49,27 +122,21 @@ export function ListToolbar({
|
||||
onCommitCustomQuery={onCommitCustomQuery}
|
||||
onRefresh={onRefresh}
|
||||
filter={
|
||||
<Select
|
||||
value={selectValue}
|
||||
onValueChange={(value) => onProjectFilterChange(value === ALL_PROJECTS ? "" : value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-8 w-full cursor-pointer md:w-[220px]"
|
||||
data-testid="gitlab-project-filter-trigger"
|
||||
>
|
||||
<SelectValue placeholder={t("gitlab:allProjects")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_PROJECTS} className="cursor-pointer">
|
||||
{t("gitlab:allProjects")}
|
||||
</SelectItem>
|
||||
{projectOptions.map((key) => (
|
||||
<SelectItem key={key} value={key} className="cursor-pointer">
|
||||
{key}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<>
|
||||
<ProjectFilterSelect
|
||||
projectFilter={projectFilter}
|
||||
onProjectFilterChange={onProjectFilterChange}
|
||||
projectOptions={projectOptions}
|
||||
/>
|
||||
{showMilestoneFilter ? (
|
||||
<MilestoneFilterInput
|
||||
milestone={milestone}
|
||||
committedMilestone={committedMilestone}
|
||||
onMilestoneChange={onMilestoneChange}
|
||||
onCommitMilestone={onCommitMilestone}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
queryPlaceholder={t("gitlab:customQueryPressEnterEG")}
|
||||
titleTestId="gitlab-list-toolbar-title"
|
||||
|
||||
@@ -167,6 +167,7 @@ describe("GitLab QuickTaskLauncher recovery", () => {
|
||||
project_path: "group/project",
|
||||
labels: [],
|
||||
assignees: [],
|
||||
milestone: "",
|
||||
created_at: FIXED_DATE,
|
||||
updated_at: FIXED_DATE,
|
||||
},
|
||||
|
||||
@@ -110,6 +110,7 @@ function fakeIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
project_path: "acme/api",
|
||||
labels: [],
|
||||
assignees: [],
|
||||
milestone: "",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
...overrides,
|
||||
@@ -225,6 +226,73 @@ describe("useGitLabSearch — fetch wiring", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useGitLabSearch — milestone", () => {
|
||||
beforeEach(resetMocks);
|
||||
|
||||
it("forwards the milestone to the issues API", async () => {
|
||||
searchUserIssuesMock.mockResolvedValue({ issues: [], total_count: 0, page: 1, per_page: 25 });
|
||||
renderHook(() =>
|
||||
useGitLabSearch({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
kind: "issue",
|
||||
presets: PRESETS,
|
||||
preset: PRESET_ASSIGNED,
|
||||
customQuery: "",
|
||||
milestone: "Next",
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(searchUserIssuesMock).toHaveBeenCalled());
|
||||
const args = searchUserIssuesMock.mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(args.milestone).toBe("Next");
|
||||
});
|
||||
|
||||
it("never forwards a milestone to the MR API", async () => {
|
||||
searchUserMRsMock.mockResolvedValue(EMPTY_PAGE);
|
||||
renderHook(() =>
|
||||
useGitLabSearch({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
kind: "mr",
|
||||
presets: PRESETS,
|
||||
preset: PRESET_ASSIGNED,
|
||||
customQuery: "",
|
||||
milestone: "Next",
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(searchUserMRsMock).toHaveBeenCalled());
|
||||
const args = searchUserMRsMock.mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(args).not.toHaveProperty("milestone");
|
||||
});
|
||||
|
||||
it("refetches when the milestone changes, without moving the current page", async () => {
|
||||
searchUserIssuesMock.mockResolvedValue({ issues: [], total_count: 0, page: 1, per_page: 25 });
|
||||
const { result, rerender } = renderHook(
|
||||
({ milestone }: { milestone: string }) =>
|
||||
useGitLabSearch({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
kind: "issue",
|
||||
presets: PRESETS,
|
||||
preset: PRESET_ASSIGNED,
|
||||
customQuery: "",
|
||||
milestone,
|
||||
}),
|
||||
{ initialProps: { milestone: "" } },
|
||||
);
|
||||
await waitFor(() => expect(searchUserIssuesMock).toHaveBeenCalledTimes(1));
|
||||
act(() => result.current.setPage(3));
|
||||
await waitFor(() => expect(result.current.page).toBe(3));
|
||||
await waitFor(() => expect(searchUserIssuesMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
rerender({ milestone: "Next" });
|
||||
await waitFor(() => expect(searchUserIssuesMock).toHaveBeenCalledTimes(3));
|
||||
const args = searchUserIssuesMock.mock.calls[2][0] as Record<string, unknown>;
|
||||
expect(args.milestone).toBe("Next");
|
||||
// The hook itself does not reset the page on a milestone change — callers
|
||||
// that commit a milestone are responsible for calling setPage(1) in the
|
||||
// same event handler (see use-gitlab-page-state.ts).
|
||||
expect(args.page).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useGitLabSearch — state", () => {
|
||||
beforeEach(resetMocks);
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ type SearchState = {
|
||||
type FetchArgs = {
|
||||
filter: string;
|
||||
customQuery: string;
|
||||
milestone: string;
|
||||
page: number;
|
||||
};
|
||||
|
||||
@@ -32,6 +33,7 @@ type UseGitLabSearchOptions = {
|
||||
presets: PresetOption[];
|
||||
preset: string;
|
||||
customQuery: string;
|
||||
milestone?: string;
|
||||
projectFilter?: string;
|
||||
// Gate the network fetch on the integration being connected. When GitLab is
|
||||
// not configured the page renders the connect notice instead of the list, so
|
||||
@@ -73,12 +75,42 @@ function filterByProject(items: Item[], project: string): Item[] {
|
||||
return items.filter((it) => it.project_path === project);
|
||||
}
|
||||
|
||||
async function fetchGitLabSearchPage(
|
||||
kind: SearchKind,
|
||||
workspaceId: string,
|
||||
args: FetchArgs,
|
||||
): Promise<Omit<SearchState, "workspaceId">> {
|
||||
const params = {
|
||||
workspaceId,
|
||||
filter: args.filter,
|
||||
customQuery: args.customQuery,
|
||||
page: args.page,
|
||||
perPage: SEARCH_PAGE_SIZE,
|
||||
};
|
||||
const response =
|
||||
kind === "mr"
|
||||
? await searchUserMRs(params)
|
||||
: await searchUserIssues({ ...params, milestone: args.milestone });
|
||||
const items: Item[] =
|
||||
kind === "mr"
|
||||
? ((response as { mrs: MR[] | null } | null)?.mrs ?? [])
|
||||
: ((response as { issues: Issue[] | null } | null)?.issues ?? []);
|
||||
return {
|
||||
items,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastFetchedAt: new Date(),
|
||||
total: response?.total_count ?? items.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function useGitLabSearch({
|
||||
workspaceId,
|
||||
kind,
|
||||
presets,
|
||||
preset,
|
||||
customQuery,
|
||||
milestone = "",
|
||||
projectFilter = "",
|
||||
enabled = true,
|
||||
}: UseGitLabSearchOptions) {
|
||||
@@ -91,7 +123,7 @@ export function useGitLabSearch({
|
||||
}, [preset, customQuery, kind]);
|
||||
|
||||
const fetchData = useCallback(
|
||||
async ({ filter: ef, customQuery: ec, page: epage }: FetchArgs) => {
|
||||
async (args: FetchArgs) => {
|
||||
if (!enabled || !workspaceId) return;
|
||||
const seq = ++requestSeq.current;
|
||||
const requestedWorkspaceId = workspaceId;
|
||||
@@ -108,28 +140,9 @@ export function useGitLabSearch({
|
||||
},
|
||||
);
|
||||
try {
|
||||
const params = {
|
||||
workspaceId,
|
||||
filter: ef,
|
||||
customQuery: ec,
|
||||
page: epage,
|
||||
perPage: SEARCH_PAGE_SIZE,
|
||||
};
|
||||
const response =
|
||||
kind === "mr" ? await searchUserMRs(params) : await searchUserIssues(params);
|
||||
const next = await fetchGitLabSearchPage(kind, workspaceId, args);
|
||||
if (seq !== requestSeq.current) return;
|
||||
const items: Item[] =
|
||||
kind === "mr"
|
||||
? ((response as { mrs: MR[] | null } | null)?.mrs ?? [])
|
||||
: ((response as { issues: Issue[] | null } | null)?.issues ?? []);
|
||||
setState({
|
||||
items,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastFetchedAt: new Date(),
|
||||
total: response?.total_count ?? items.length,
|
||||
workspaceId: requestedWorkspaceId,
|
||||
});
|
||||
setState({ ...next, workspaceId: requestedWorkspaceId });
|
||||
} catch (err) {
|
||||
if (seq !== requestSeq.current) return;
|
||||
setState((s) => ({
|
||||
@@ -155,12 +168,18 @@ export function useGitLabSearch({
|
||||
// its run callback and its debounce effect) so a disabled integration never
|
||||
// schedules a fetch. The callback keeps its own guard to also cover refresh().
|
||||
if (!enabled || !workspaceId) return;
|
||||
void fetchData({ filter: resolved.filter, customQuery: resolved.customQuery, page });
|
||||
}, [fetchData, enabled, workspaceId, resolved.filter, resolved.customQuery, page]);
|
||||
void fetchData({
|
||||
filter: resolved.filter,
|
||||
customQuery: resolved.customQuery,
|
||||
milestone,
|
||||
page,
|
||||
});
|
||||
}, [fetchData, enabled, workspaceId, resolved.filter, resolved.customQuery, milestone, page]);
|
||||
|
||||
const refresh = useCallback(
|
||||
() => fetchData({ filter: resolved.filter, customQuery: resolved.customQuery, page }),
|
||||
[fetchData, resolved.filter, resolved.customQuery, page],
|
||||
() =>
|
||||
fetchData({ filter: resolved.filter, customQuery: resolved.customQuery, milestone, page }),
|
||||
[fetchData, resolved.filter, resolved.customQuery, milestone, page],
|
||||
);
|
||||
|
||||
const isCurrentWorkspace = state.workspaceId === workspaceId;
|
||||
|
||||
@@ -21,6 +21,8 @@ const valid: SavedPreset = {
|
||||
label: "My MRs",
|
||||
customQuery: "scope=created_by_me",
|
||||
projectFilter: "",
|
||||
milestone: "",
|
||||
preset: "",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
@@ -45,6 +47,8 @@ describe("useSavedPresets (gitlab)", () => {
|
||||
label: "My MRs",
|
||||
customQuery: "scope=created_by_me",
|
||||
projectFilter: "",
|
||||
milestone: "",
|
||||
preset: "",
|
||||
});
|
||||
});
|
||||
expect(result.current.presets).toHaveLength(1);
|
||||
@@ -64,6 +68,8 @@ describe("useSavedPresets (gitlab)", () => {
|
||||
label: "Triage",
|
||||
customQuery: "labels=bug",
|
||||
projectFilter: "",
|
||||
milestone: "",
|
||||
preset: "",
|
||||
});
|
||||
});
|
||||
expect(result.current.presets).toHaveLength(1);
|
||||
@@ -80,6 +86,8 @@ describe("useSavedPresets (gitlab)", () => {
|
||||
label: "Shared",
|
||||
customQuery: "q",
|
||||
projectFilter: "",
|
||||
milestone: "",
|
||||
preset: "",
|
||||
});
|
||||
});
|
||||
expect(a.result.current.presets).toHaveLength(1);
|
||||
@@ -97,3 +105,93 @@ describe("useSavedPresets (gitlab)", () => {
|
||||
expect(updateUserSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
const LEGACY_CREATED_AT = "2025-01-01T00:00:00Z";
|
||||
|
||||
describe("useSavedPresets (gitlab) — legacy preset field normalization", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
__resetSnapshotForTests();
|
||||
vi.mocked(updateUserSettings).mockResolvedValue({
|
||||
settings: {},
|
||||
} as Awaited<ReturnType<typeof updateUserSettings>>);
|
||||
});
|
||||
|
||||
it("keeps a legacy preset with neither a milestone nor a preset key, defaulting both to empty", async () => {
|
||||
const legacy = {
|
||||
id: "g_legacy",
|
||||
kind: "issue",
|
||||
label: "Legacy triage",
|
||||
customQuery: "labels=bug",
|
||||
projectFilter: "",
|
||||
createdAt: LEGACY_CREATED_AT,
|
||||
};
|
||||
vi.mocked(fetchUserSettings).mockResolvedValue({
|
||||
settings: { gitlab_saved_presets: [legacy] },
|
||||
} as Awaited<ReturnType<typeof fetchUserSettings>>);
|
||||
|
||||
const { result } = renderHook(() => useSavedPresets());
|
||||
|
||||
await waitFor(() => expect(result.current.presets).toHaveLength(1));
|
||||
expect(result.current.presets[0]).toMatchObject({
|
||||
id: "g_legacy",
|
||||
milestone: "",
|
||||
preset: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes a non-string milestone/preset (number, null, object) to empty rather than dropping the entry", async () => {
|
||||
const withBadTypes = [
|
||||
{
|
||||
id: "g_number",
|
||||
kind: "issue",
|
||||
label: "Number",
|
||||
customQuery: "",
|
||||
projectFilter: "",
|
||||
createdAt: LEGACY_CREATED_AT,
|
||||
milestone: 42,
|
||||
preset: null,
|
||||
},
|
||||
{
|
||||
id: "g_object",
|
||||
kind: "issue",
|
||||
label: "Object",
|
||||
customQuery: "",
|
||||
projectFilter: "",
|
||||
createdAt: LEGACY_CREATED_AT,
|
||||
milestone: { title: "Next" },
|
||||
preset: 7,
|
||||
},
|
||||
];
|
||||
vi.mocked(fetchUserSettings).mockResolvedValue({
|
||||
settings: { gitlab_saved_presets: withBadTypes },
|
||||
} as Awaited<ReturnType<typeof fetchUserSettings>>);
|
||||
|
||||
const { result } = renderHook(() => useSavedPresets());
|
||||
|
||||
await waitFor(() => expect(result.current.presets).toHaveLength(2));
|
||||
expect(result.current.presets[0]).toMatchObject({ id: "g_number", milestone: "", preset: "" });
|
||||
expect(result.current.presets[1]).toMatchObject({ id: "g_object", milestone: "", preset: "" });
|
||||
});
|
||||
|
||||
it("still rejects an entry missing a required legacy field", async () => {
|
||||
const missingLabel = [
|
||||
{
|
||||
id: "g_bad",
|
||||
kind: "issue",
|
||||
customQuery: "",
|
||||
projectFilter: "",
|
||||
createdAt: LEGACY_CREATED_AT,
|
||||
},
|
||||
];
|
||||
vi.mocked(fetchUserSettings).mockResolvedValue({
|
||||
settings: { gitlab_saved_presets: missingLabel },
|
||||
} as Awaited<ReturnType<typeof fetchUserSettings>>);
|
||||
|
||||
const { result } = renderHook(() => useSavedPresets());
|
||||
|
||||
await waitFor(() => expect(fetchUserSettings).toHaveBeenCalled());
|
||||
expect(result.current.presets).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,12 @@ export type SavedPreset = {
|
||||
label: string;
|
||||
customQuery: string;
|
||||
projectFilter: string;
|
||||
// Milestone title in effect when the query was saved, and the sidebar
|
||||
// preset value it was saved under. Both "" when none. Presets written
|
||||
// before this feature carry neither key; isSavedPreset tolerates a missing
|
||||
// or non-string value for either and normalizeSavedPreset reads it as "".
|
||||
milestone: string;
|
||||
preset: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
@@ -39,7 +45,10 @@ function publish(next: SavedPreset[]) {
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
function isSavedPreset(p: unknown): p is SavedPreset {
|
||||
// Checks the fields every SavedPreset has always had. milestone/preset are
|
||||
// deliberately not checked here: a preset saved before this feature has
|
||||
// neither key, and that SHALL NOT disqualify it (Scenario 13).
|
||||
function isSavedPreset(p: unknown): p is Omit<SavedPreset, "milestone" | "preset"> {
|
||||
return (
|
||||
typeof p === "object" &&
|
||||
p !== null &&
|
||||
@@ -52,9 +61,29 @@ function isSavedPreset(p: unknown): p is SavedPreset {
|
||||
);
|
||||
}
|
||||
|
||||
// A missing value, or one present but not a string, reads as "" (Scenario 23).
|
||||
function normalizeStoredString(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function normalizeSavedPreset(p: unknown): SavedPreset | null {
|
||||
if (!isSavedPreset(p)) return null;
|
||||
const raw = p as Record<string, unknown>;
|
||||
return {
|
||||
...p,
|
||||
milestone: normalizeStoredString(raw.milestone),
|
||||
preset: normalizeStoredString(raw.preset),
|
||||
};
|
||||
}
|
||||
|
||||
function readServerPresets(value: unknown): SavedPreset[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
return value.filter(isSavedPreset);
|
||||
const normalized: SavedPreset[] = [];
|
||||
for (const item of value) {
|
||||
const preset = normalizeSavedPreset(item);
|
||||
if (preset) normalized.push(preset);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const syncServer = createQueuedUserSettingsSync<SavedPreset[]>((next) => ({
|
||||
|
||||
Reference in New Issue
Block a user