feat(github): add saved query default views (#2399)
This commit is contained in:
@@ -75,8 +75,11 @@ function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
showIndicator = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||
showIndicator?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
@@ -87,14 +90,16 @@ function DropdownMenuCheckboxItem({
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center pointer-events-none"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<IconCheck />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{showIndicator && (
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center pointer-events-none"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<IconCheck />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "@/components/routing/app-link";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { IconBrandGithub, IconMenu2 } from "@tabler/icons-react";
|
||||
import { Alert, AlertDescription } from "@kandev/ui/alert";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
@@ -29,8 +29,12 @@ import {
|
||||
useDefaultQueryPresets,
|
||||
resolvePresetOptions,
|
||||
} from "@/components/github/my-github/use-default-query-presets";
|
||||
import type { SavedPreset } from "@/components/github/my-github/use-saved-presets";
|
||||
import { useSavedPresets, type SavedPreset } from "@/components/github/my-github/use-saved-presets";
|
||||
import { useSavedPresetActions } from "@/components/github/my-github/use-saved-preset-actions";
|
||||
import {
|
||||
useInitialSidebarSelection,
|
||||
useSidebarSelectionHandler,
|
||||
} from "@/components/github/my-github/use-sidebar-selection";
|
||||
import { useKnownRepos, resetKnownReposStore } from "@/components/github/my-github/use-known-repos";
|
||||
import { useCommittedQuery } from "@/components/github/my-github/use-committed-query";
|
||||
import { ListToolbar } from "@/components/github/my-github/list-toolbar";
|
||||
@@ -223,90 +227,15 @@ function useResolvedQueryPresets(workspaceId: string | null = null) {
|
||||
return { pr, issue };
|
||||
}
|
||||
|
||||
function useInitialSidebarSelection(
|
||||
workspaceId: string | null,
|
||||
resolvedPrPresets: PresetOption[],
|
||||
autoResetSearchRef: MutableRefObject<boolean>,
|
||||
setQueryImmediate: (query: string) => void,
|
||||
setRepoFilter: (repo: string) => void,
|
||||
) {
|
||||
const userSelectedRef = useRef(false);
|
||||
const [selection, setSelection] = useState<SidebarSelection>(() => ({
|
||||
kind: "pr",
|
||||
source: "preset",
|
||||
id: resolvedPrPresets[0]?.value ?? "",
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
userSelectedRef.current = false;
|
||||
autoResetSearchRef.current = true;
|
||||
}, [workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userSelectedRef.current || !autoResetSearchRef.current) return;
|
||||
const first = resolvedPrPresets[0];
|
||||
setSelection({ kind: "pr", source: "preset", id: first?.value ?? "" });
|
||||
setQueryImmediate(first?.filter ?? "");
|
||||
setRepoFilter("");
|
||||
}, [workspaceId, resolvedPrPresets, autoResetSearchRef, setQueryImmediate, setRepoFilter]);
|
||||
|
||||
const setUserSelection = useCallback((next: SidebarSelection) => {
|
||||
userSelectedRef.current = true;
|
||||
setSelection(next);
|
||||
}, []);
|
||||
|
||||
return { selection, setProgrammaticSelection: setSelection, setUserSelection };
|
||||
}
|
||||
|
||||
function useSidebarSelectionHandler({
|
||||
savedPresets,
|
||||
resolvedPrPresets,
|
||||
resolvedIssuePresets,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
setUserSelection,
|
||||
markSearchInteracted,
|
||||
}: {
|
||||
savedPresets: SavedPreset[];
|
||||
resolvedPrPresets: PresetOption[];
|
||||
resolvedIssuePresets: PresetOption[];
|
||||
setQueryImmediate: (query: string) => void;
|
||||
setRepoFilter: (repo: string) => void;
|
||||
setUserSelection: (next: SidebarSelection) => void;
|
||||
markSearchInteracted: () => void;
|
||||
}) {
|
||||
return useCallback(
|
||||
(s: SidebarSelection) => {
|
||||
markSearchInteracted();
|
||||
setUserSelection(s);
|
||||
if (s.source === "saved") {
|
||||
const found = savedPresets.find((p) => p.id === s.id);
|
||||
setQueryImmediate(found?.customQuery ?? "");
|
||||
setRepoFilter(found?.repoFilter ?? "");
|
||||
return;
|
||||
}
|
||||
const preset = (s.kind === "pr" ? resolvedPrPresets : resolvedIssuePresets).find(
|
||||
(p) => p.value === s.id,
|
||||
);
|
||||
setQueryImmediate(preset?.filter ?? "");
|
||||
setRepoFilter("");
|
||||
},
|
||||
[
|
||||
savedPresets,
|
||||
setQueryImmediate,
|
||||
resolvedPrPresets,
|
||||
resolvedIssuePresets,
|
||||
setUserSelection,
|
||||
markSearchInteracted,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function useSearchInteractionControls(
|
||||
setCustomQueryRaw: (query: string) => void,
|
||||
setRepoFilterRaw: (repo: string) => void,
|
||||
) {
|
||||
const autoResetSearchRef = useRef(true);
|
||||
const shouldAutoResetSearch = useCallback(() => autoResetSearchRef.current, []);
|
||||
const resetSearchOnWorkspaceChange = useCallback(() => {
|
||||
autoResetSearchRef.current = true;
|
||||
}, []);
|
||||
const markSearchInteracted = useCallback(() => {
|
||||
autoResetSearchRef.current = false;
|
||||
}, []);
|
||||
@@ -324,7 +253,12 @@ function useSearchInteractionControls(
|
||||
},
|
||||
[markSearchInteracted, setRepoFilterRaw],
|
||||
);
|
||||
return { autoResetSearchRef, markSearchInteracted, setCustomQuery, setRepoFilter };
|
||||
return {
|
||||
autoResetSearch: { shouldAutoResetSearch, resetSearchOnWorkspaceChange },
|
||||
markSearchInteracted,
|
||||
setCustomQuery,
|
||||
setRepoFilter,
|
||||
};
|
||||
}
|
||||
|
||||
function useGitHubPageState(workspaceId: string | null, enabled: boolean) {
|
||||
@@ -340,16 +274,24 @@ function useGitHubPageState(workspaceId: string | null, enabled: boolean) {
|
||||
} = useCommittedQuery(resolvedPrPresets[0]?.filter ?? "");
|
||||
const [repoFilter, setRepoFilterRaw] = useState("");
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||
const { autoResetSearchRef, markSearchInteracted, setCustomQuery, setRepoFilter } =
|
||||
const savedPresetStore = useSavedPresets(workspaceId);
|
||||
const { autoResetSearch, markSearchInteracted, setCustomQuery, setRepoFilter } =
|
||||
useSearchInteractionControls(setCustomQueryRaw, setRepoFilterRaw);
|
||||
const { selection, setProgrammaticSelection, setUserSelection } = useInitialSidebarSelection(
|
||||
const { selection, setProgrammaticSelection, setUserSelection } = useInitialSidebarSelection({
|
||||
workspaceId,
|
||||
...autoResetSearch,
|
||||
resolvedPrPresets,
|
||||
autoResetSearchRef,
|
||||
setQueryImmediate,
|
||||
setRepoFilterRaw,
|
||||
);
|
||||
const { savedPresets, onConfirmSave, onDeleteSaved } = useSavedPresetActions({
|
||||
setRepoFilter: setRepoFilterRaw,
|
||||
savedPresets: savedPresetStore.presets,
|
||||
});
|
||||
const {
|
||||
savedPresets,
|
||||
onConfirmSave,
|
||||
onDeleteSaved,
|
||||
onToggleSavedDefault,
|
||||
defaultMutationPendingId,
|
||||
} = useSavedPresetActions({
|
||||
workspaceId,
|
||||
selection,
|
||||
customQuery,
|
||||
@@ -358,31 +300,26 @@ function useGitHubPageState(workspaceId: string | null, enabled: boolean) {
|
||||
setProgrammaticSelection,
|
||||
setQueryImmediate,
|
||||
setRepoFilter: setRepoFilterRaw,
|
||||
savedPresetStore,
|
||||
markSearchInteracted,
|
||||
});
|
||||
|
||||
const presets = selection.kind === "pr" ? resolvedPrPresets : resolvedIssuePresets;
|
||||
const search = useGitHubSearch<GitHubPR | GitHubIssue>({
|
||||
enabled,
|
||||
kind: selection.kind,
|
||||
presets,
|
||||
presets: selection.kind === "pr" ? resolvedPrPresets : resolvedIssuePresets,
|
||||
preset: selection.source === "preset" ? selection.id : "",
|
||||
customQuery: committedQuery,
|
||||
repoFilter,
|
||||
workspaceId,
|
||||
});
|
||||
const repoOptions = useRepoOptions(
|
||||
workspaceId,
|
||||
selection,
|
||||
committedQuery,
|
||||
search.items,
|
||||
repoFilter,
|
||||
);
|
||||
const repos = useRepoOptions(workspaceId, selection, committedQuery, search.items, repoFilter);
|
||||
const title = useMemo(
|
||||
() => resolveTitle(selection, savedPresets, resolvedPrPresets, resolvedIssuePresets, t),
|
||||
[selection, savedPresets, resolvedPrPresets, resolvedIssuePresets, t],
|
||||
);
|
||||
|
||||
const onSelect = useSidebarSelectionHandler({
|
||||
currentKind: selection.kind,
|
||||
savedPresets,
|
||||
resolvedPrPresets,
|
||||
resolvedIssuePresets,
|
||||
@@ -406,7 +343,7 @@ function useGitHubPageState(workspaceId: string | null, enabled: boolean) {
|
||||
setRepoFilter,
|
||||
savedPresets,
|
||||
search,
|
||||
repoOptions,
|
||||
repoOptions: repos,
|
||||
title,
|
||||
onSelect,
|
||||
canSaveCurrent,
|
||||
@@ -416,6 +353,8 @@ function useGitHubPageState(workspaceId: string | null, enabled: boolean) {
|
||||
onOpenSaveDialog,
|
||||
onConfirmSave,
|
||||
onDeleteSaved,
|
||||
onToggleSavedDefault,
|
||||
defaultMutationPendingId,
|
||||
resolvedPrPresets,
|
||||
resolvedIssuePresets,
|
||||
};
|
||||
@@ -446,6 +385,8 @@ function AuthenticatedLayout({
|
||||
onSelect={state.onSelect}
|
||||
savedPresets={state.savedPresets}
|
||||
onDeleteSaved={state.onDeleteSaved}
|
||||
onToggleSavedDefault={state.onToggleSavedDefault}
|
||||
defaultMutationPendingId={state.defaultMutationPendingId}
|
||||
canSaveCurrent={state.canSaveCurrent}
|
||||
onSaveCurrent={state.onOpenSaveDialog}
|
||||
prPresets={state.resolvedPrPresets}
|
||||
@@ -564,6 +505,8 @@ export function GitHubPageClient({
|
||||
onSelect={onMobileSidebarSelect}
|
||||
savedPresets={state.savedPresets}
|
||||
onDeleteSaved={state.onDeleteSaved}
|
||||
onToggleSavedDefault={state.onToggleSavedDefault}
|
||||
defaultMutationPendingId={state.defaultMutationPendingId}
|
||||
canSaveCurrent={state.canSaveCurrent}
|
||||
onSaveCurrent={onMobileSaveCurrent}
|
||||
prPresets={state.resolvedPrPresets}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useRef } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SavedPreset } from "./saved-preset-model";
|
||||
import { PresetsScopeBar } from "./presets-scope-bar";
|
||||
|
||||
const savedPreset: SavedPreset = {
|
||||
id: "saved-pr",
|
||||
kind: "pr",
|
||||
label: "Needs review",
|
||||
customQuery: "review-requested:@me is:open",
|
||||
repoFilter: "kdlbs/kandev",
|
||||
createdAt: "2026-08-10T00:00:00Z",
|
||||
isDefault: false,
|
||||
};
|
||||
|
||||
vi.mock("@/components/integrations/presets-scope-bar-base", () => ({
|
||||
IntegrationScopeBar: ({
|
||||
defaultMutationPendingId,
|
||||
onKindChange,
|
||||
onToggleSavedDefault,
|
||||
savedPresets,
|
||||
}: {
|
||||
defaultMutationPendingId?: string | null;
|
||||
onKindChange?: (kind: "pr" | "issue") => void;
|
||||
onToggleSavedDefault?: (id: string) => void;
|
||||
savedPresets: Array<{ id: string }>;
|
||||
}) => {
|
||||
const initialDefaultAction = useRef(onToggleSavedDefault);
|
||||
return (
|
||||
<div
|
||||
data-testid="scope-bar-default-action"
|
||||
data-pending={defaultMutationPendingId !== null}
|
||||
data-pending-id={defaultMutationPendingId}
|
||||
data-callback-stable={initialDefaultAction.current === onToggleSavedDefault}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="scope-bar-default-toggle"
|
||||
onClick={() => onToggleSavedDefault?.(savedPresets[0]?.id ?? "missing-preset")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="scope-bar-kind-switch"
|
||||
onClick={() => onKindChange?.("issue")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="scope-bar-active-kind"
|
||||
onClick={() => onKindChange?.("pr")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function expectExplicitKindSwitchRequest() {
|
||||
const onSelect = vi.fn();
|
||||
|
||||
render(
|
||||
<PresetsScopeBar
|
||||
selected={{ kind: "pr", source: "preset", id: "review" }}
|
||||
onSelect={onSelect}
|
||||
savedPresets={[]}
|
||||
onDeleteSaved={vi.fn()}
|
||||
canSaveCurrent={false}
|
||||
onSaveCurrent={vi.fn()}
|
||||
onToggleSavedDefault={vi.fn()}
|
||||
defaultMutationPendingId={null}
|
||||
prPresets={[]}
|
||||
issuePresets={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("scope-bar-kind-switch"));
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: "issue", source: "kind-switch" });
|
||||
}
|
||||
|
||||
function expectActiveKindRequestIgnored() {
|
||||
const onSelect = vi.fn();
|
||||
|
||||
render(
|
||||
<PresetsScopeBar
|
||||
selected={{ kind: "pr", source: "preset", id: "review" }}
|
||||
onSelect={onSelect}
|
||||
savedPresets={[]}
|
||||
onDeleteSaved={vi.fn()}
|
||||
canSaveCurrent={false}
|
||||
onSaveCurrent={vi.fn()}
|
||||
onToggleSavedDefault={vi.fn()}
|
||||
defaultMutationPendingId={null}
|
||||
prPresets={[]}
|
||||
issuePresets={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("scope-bar-active-kind"));
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
describe("PresetsScopeBar default adapter", () => {
|
||||
it("emits an explicit kind-switch request", expectExplicitKindSwitchRequest);
|
||||
|
||||
it("ignores an active-kind request at the wrapper boundary", expectActiveKindRequestIgnored);
|
||||
|
||||
it("forwards pending state to the desktop default action", () => {
|
||||
render(
|
||||
<PresetsScopeBar
|
||||
selected={{ kind: "pr", source: "saved", id: savedPreset.id }}
|
||||
onSelect={vi.fn()}
|
||||
savedPresets={[savedPreset]}
|
||||
onDeleteSaved={vi.fn()}
|
||||
canSaveCurrent={false}
|
||||
onSaveCurrent={vi.fn()}
|
||||
onToggleSavedDefault={vi.fn()}
|
||||
defaultMutationPendingId={savedPreset.id}
|
||||
prPresets={[]}
|
||||
issuePresets={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("scope-bar-default-action").getAttribute("data-pending")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByTestId("scope-bar-default-action").getAttribute("data-pending-id")).toBe(
|
||||
savedPreset.id,
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards a matching saved preset to the domain callback", () => {
|
||||
const onToggleSavedDefault = vi.fn();
|
||||
|
||||
render(
|
||||
<PresetsScopeBar
|
||||
selected={{ kind: "pr", source: "saved", id: savedPreset.id }}
|
||||
onSelect={vi.fn()}
|
||||
savedPresets={[savedPreset]}
|
||||
onDeleteSaved={vi.fn()}
|
||||
canSaveCurrent={false}
|
||||
onSaveCurrent={vi.fn()}
|
||||
onToggleSavedDefault={onToggleSavedDefault}
|
||||
defaultMutationPendingId={null}
|
||||
prPresets={[]}
|
||||
issuePresets={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("scope-bar-default-toggle"));
|
||||
expect(onToggleSavedDefault).toHaveBeenCalledWith(savedPreset);
|
||||
});
|
||||
|
||||
it("keeps the default adapter stable while using refreshed presets", () => {
|
||||
const savedPresets = [savedPreset];
|
||||
const props = {
|
||||
selected: { kind: "pr", source: "saved", id: savedPreset.id } as const,
|
||||
onSelect: vi.fn(),
|
||||
savedPresets,
|
||||
onDeleteSaved: vi.fn(),
|
||||
canSaveCurrent: false,
|
||||
onSaveCurrent: vi.fn(),
|
||||
onToggleSavedDefault: vi.fn(),
|
||||
defaultMutationPendingId: null,
|
||||
prPresets: [],
|
||||
issuePresets: [],
|
||||
};
|
||||
const { rerender } = render(<PresetsScopeBar {...props} />);
|
||||
const refreshedPreset = { ...savedPreset, label: "Updated", isDefault: true };
|
||||
|
||||
rerender(<PresetsScopeBar {...props} savedPresets={[refreshedPreset]} />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("scope-bar-default-action").getAttribute("data-callback-stable"),
|
||||
).toBe("true");
|
||||
fireEvent.click(screen.getByTestId("scope-bar-default-toggle"));
|
||||
expect(props.onToggleSavedDefault).toHaveBeenCalledWith(refreshedPreset);
|
||||
});
|
||||
|
||||
it("warns in development when a stale default target is requested", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
const onToggleSavedDefault = vi.fn();
|
||||
|
||||
render(
|
||||
<PresetsScopeBar
|
||||
selected={{ kind: "pr", source: "preset", id: "review" }}
|
||||
onSelect={vi.fn()}
|
||||
savedPresets={[]}
|
||||
onDeleteSaved={vi.fn()}
|
||||
canSaveCurrent={false}
|
||||
onSaveCurrent={vi.fn()}
|
||||
onToggleSavedDefault={onToggleSavedDefault}
|
||||
defaultMutationPendingId={null}
|
||||
prPresets={[]}
|
||||
issuePresets={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("scope-bar-default-toggle"));
|
||||
expect(warn).toHaveBeenCalledWith("[github:presets] default toggle target missing", {
|
||||
id: "missing-preset",
|
||||
});
|
||||
expect(onToggleSavedDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IntegrationScopeBar } from "@/components/integrations/presets-scope-bar-base";
|
||||
import { PR_PRESETS, ISSUE_PRESETS, type PresetOption } from "./search-bar";
|
||||
import type { SavedPreset } from "./use-saved-presets";
|
||||
import type { SidebarSelection } from "./presets-sidebar";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SavedPreset } from "./saved-preset-model";
|
||||
import type { SidebarSelection, SidebarSelectionRequest } from "./presets-sidebar";
|
||||
|
||||
type PresetsScopeBarProps = {
|
||||
className?: string;
|
||||
selected: SidebarSelection;
|
||||
onSelect: (s: SidebarSelection) => void;
|
||||
onSelect: (request: SidebarSelectionRequest) => void;
|
||||
savedPresets: SavedPreset[];
|
||||
onDeleteSaved: (id: string) => void;
|
||||
canSaveCurrent: boolean;
|
||||
onSaveCurrent: () => void;
|
||||
onToggleSavedDefault: (preset: SavedPreset) => void;
|
||||
defaultMutationPendingId: string | null;
|
||||
prPresets?: PresetOption[];
|
||||
issuePresets?: PresetOption[];
|
||||
};
|
||||
@@ -32,12 +35,42 @@ const KINDS = [
|
||||
export function PresetsScopeBar({
|
||||
prPresets = PR_PRESETS,
|
||||
issuePresets = ISSUE_PRESETS,
|
||||
savedPresets,
|
||||
selected,
|
||||
onSelect,
|
||||
onToggleSavedDefault,
|
||||
...props
|
||||
}: PresetsScopeBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const savedPresetsRef = useRef(savedPresets);
|
||||
savedPresetsRef.current = savedPresets;
|
||||
const handleToggleSavedDefault = useCallback(
|
||||
(id: string) => {
|
||||
const preset = savedPresetsRef.current.find((candidate) => candidate.id === id);
|
||||
if (!preset) {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.warn("[github:presets] default toggle target missing", { id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
void onToggleSavedDefault(preset);
|
||||
},
|
||||
[onToggleSavedDefault],
|
||||
);
|
||||
const handleKindChange = useCallback(
|
||||
(kind: SidebarSelection["kind"]) => {
|
||||
if (kind !== selected.kind) onSelect({ kind, source: "kind-switch" });
|
||||
},
|
||||
[onSelect, selected.kind],
|
||||
);
|
||||
return (
|
||||
<IntegrationScopeBar
|
||||
{...props}
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
onKindChange={handleKindChange}
|
||||
savedPresets={savedPresets}
|
||||
onToggleSavedDefault={handleToggleSavedDefault}
|
||||
testId="github-presets-scope-bar"
|
||||
savedMenuTestId="github-saved-queries-menu"
|
||||
kinds={KINDS.map(({ value, labelKey }) => ({ value, label: t(labelKey) }))}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SavedPreset } from "./saved-preset-model";
|
||||
import { PresetsSidebar } from "./presets-sidebar";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const savedPreset: SavedPreset = {
|
||||
id: "saved-pr",
|
||||
kind: "pr",
|
||||
label: "Kandev PRs",
|
||||
customQuery: "author:@me is:open",
|
||||
repoFilter: "kdlbs/kandev",
|
||||
createdAt: "2026-08-10T00:00:00Z",
|
||||
isDefault: false,
|
||||
};
|
||||
|
||||
function expectDeleteDoesNotSelect() {
|
||||
const onSelect = vi.fn();
|
||||
const onDeleteSaved = vi.fn();
|
||||
render(
|
||||
<PresetsSidebar
|
||||
selected={{ kind: "pr", source: "saved", id: savedPreset.id }}
|
||||
onSelect={onSelect}
|
||||
savedPresets={[savedPreset]}
|
||||
onDeleteSaved={onDeleteSaved}
|
||||
canSaveCurrent={false}
|
||||
onSaveCurrent={vi.fn()}
|
||||
onToggleSavedDefault={vi.fn()}
|
||||
defaultMutationPendingId={null}
|
||||
prPresets={[]}
|
||||
issuePresets={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete Kandev PRs saved query" }));
|
||||
|
||||
expect(onDeleteSaved).toHaveBeenCalledWith(savedPreset.id);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
describe("PresetsSidebar saved defaults", () => {
|
||||
it("disables saved-query actions while a default mutation is pending", () => {
|
||||
render(
|
||||
<PresetsSidebar
|
||||
selected={{ kind: "pr", source: "saved", id: savedPreset.id }}
|
||||
onSelect={vi.fn()}
|
||||
savedPresets={[savedPreset]}
|
||||
onDeleteSaved={vi.fn()}
|
||||
canSaveCurrent={false}
|
||||
onSaveCurrent={vi.fn()}
|
||||
onToggleSavedDefault={vi.fn()}
|
||||
defaultMutationPendingId={savedPreset.id}
|
||||
prPresets={[]}
|
||||
issuePresets={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
(
|
||||
screen.getByRole("button", {
|
||||
name: "Updating default view… Delete Kandev PRs saved query",
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
const defaultAction = screen.getByRole("button", {
|
||||
name: "Updating default view… Set Kandev PRs as default view",
|
||||
}) as HTMLButtonElement;
|
||||
expect(defaultAction.disabled).toBe(true);
|
||||
expect(defaultAction.getAttribute("aria-busy")).toBeNull();
|
||||
expect(defaultAction.querySelector("svg")?.getAttribute("class")).toContain("animate-pulse");
|
||||
});
|
||||
|
||||
it("sends only the destination kind when switching views", () => {
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<PresetsSidebar
|
||||
selected={{ kind: "pr", source: "saved", id: savedPreset.id }}
|
||||
onSelect={onSelect}
|
||||
savedPresets={[savedPreset]}
|
||||
onDeleteSaved={vi.fn()}
|
||||
canSaveCurrent={false}
|
||||
onSaveCurrent={vi.fn()}
|
||||
onToggleSavedDefault={vi.fn()}
|
||||
defaultMutationPendingId={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Issues" }));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: "issue", source: "kind-switch" });
|
||||
});
|
||||
|
||||
it("ignores reselecting the active kind", () => {
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<PresetsSidebar
|
||||
selected={{ kind: "pr", source: "saved", id: savedPreset.id }}
|
||||
onSelect={onSelect}
|
||||
savedPresets={[savedPreset]}
|
||||
onDeleteSaved={vi.fn()}
|
||||
canSaveCurrent={false}
|
||||
onSaveCurrent={vi.fn()}
|
||||
onToggleSavedDefault={vi.fn()}
|
||||
defaultMutationPendingId={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Pull requests" }));
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes a saved query without selecting it", expectDeleteDoesNotSelect);
|
||||
});
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import { IconX, IconDeviceFloppy, IconBookmark } from "@tabler/icons-react";
|
||||
import type { Icon } from "@tabler/icons-react";
|
||||
import { useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PR_PRESETS, ISSUE_PRESETS, type PresetOption, type PresetGroup } from "./search-bar";
|
||||
import type { SavedPreset } from "./use-saved-presets";
|
||||
import type { SavedPreset } from "./saved-preset-model";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SavedQueryDefaultButton } from "@/components/integrations/saved-query-default-button";
|
||||
|
||||
export type SidebarSelection = {
|
||||
kind: "pr" | "issue";
|
||||
@@ -13,13 +15,19 @@ export type SidebarSelection = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type SidebarSelectionRequest =
|
||||
| SidebarSelection
|
||||
| { kind: SidebarSelection["kind"]; source: "kind-switch" };
|
||||
|
||||
type PresetsSidebarProps = {
|
||||
selected: SidebarSelection;
|
||||
onSelect: (s: SidebarSelection) => void;
|
||||
onSelect: (request: SidebarSelectionRequest) => void;
|
||||
savedPresets: SavedPreset[];
|
||||
onDeleteSaved: (id: string) => void;
|
||||
canSaveCurrent: boolean;
|
||||
onSaveCurrent: () => void;
|
||||
onToggleSavedDefault: (preset: SavedPreset) => void;
|
||||
defaultMutationPendingId: string | null;
|
||||
prPresets?: PresetOption[];
|
||||
issuePresets?: PresetOption[];
|
||||
};
|
||||
@@ -40,7 +48,7 @@ function KindToggle({
|
||||
type="button"
|
||||
onClick={() => onChange(value)}
|
||||
className={cn(
|
||||
"px-2 py-1 rounded cursor-pointer transition-colors",
|
||||
"min-h-11 px-2 py-1 rounded cursor-pointer transition-colors",
|
||||
kind === value
|
||||
? "bg-muted font-medium text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
@@ -74,27 +82,22 @@ function PresetItem({
|
||||
onClick: () => void;
|
||||
trailing?: React.ReactNode;
|
||||
}) {
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"group/item mx-1 flex items-center gap-2 rounded-md px-2 py-1.5 text-sm cursor-pointer transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
"group/item mx-1 flex min-w-0 items-center rounded-md text-sm transition-colors",
|
||||
active ? "bg-muted font-medium text-foreground" : "text-muted-foreground hover:bg-muted/50",
|
||||
)}
|
||||
onClick={onClick}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate flex-1">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className="flex min-h-11 min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md px-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
</button>
|
||||
{trailing}
|
||||
</div>
|
||||
);
|
||||
@@ -140,6 +143,8 @@ function SavedSection({
|
||||
kind,
|
||||
canSaveCurrent,
|
||||
onSaveCurrent,
|
||||
onToggleSavedDefault,
|
||||
defaultMutationPendingId,
|
||||
}: {
|
||||
saved: SavedPreset[];
|
||||
selected: SidebarSelection;
|
||||
@@ -148,8 +153,11 @@ function SavedSection({
|
||||
kind: "pr" | "issue";
|
||||
canSaveCurrent: boolean;
|
||||
onSaveCurrent: () => void;
|
||||
onToggleSavedDefault: (preset: SavedPreset) => void;
|
||||
defaultMutationPendingId: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const defaultMutationPending = defaultMutationPendingId !== null;
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title={t("github:saved")} />
|
||||
@@ -158,34 +166,49 @@ function SavedSection({
|
||||
{t("github:noSavedQueriesYet")}
|
||||
</div>
|
||||
)}
|
||||
{saved.map((s) => (
|
||||
<PresetItem
|
||||
key={s.id}
|
||||
label={s.label}
|
||||
Icon={IconBookmark}
|
||||
active={selected.source === "saved" && selected.id === s.id}
|
||||
onClick={() => onSelect({ kind, source: "saved", id: s.id })}
|
||||
trailing={
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(s.id);
|
||||
}}
|
||||
className="opacity-0 group-hover/item:opacity-100 transition-opacity text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
title={t("github:deleteSavedQuery")}
|
||||
>
|
||||
<IconX className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{saved.map((s) => {
|
||||
const deleteLabel = t("integrations:deleteSavedQueryNamed", { label: s.label });
|
||||
const accessibleDeleteLabel = defaultMutationPending
|
||||
? t("integrations:savedQueryDefaultUpdateInProgress", { action: deleteLabel })
|
||||
: deleteLabel;
|
||||
return (
|
||||
<PresetItem
|
||||
key={s.id}
|
||||
label={s.label}
|
||||
Icon={IconBookmark}
|
||||
active={selected.source === "saved" && selected.id === s.id}
|
||||
onClick={() => onSelect({ kind, source: "saved", id: s.id })}
|
||||
trailing={
|
||||
<div className="flex shrink-0 items-center">
|
||||
<SavedQueryDefaultButton
|
||||
label={s.label}
|
||||
isDefault={s.isDefault}
|
||||
disabled={defaultMutationPending}
|
||||
pending={defaultMutationPendingId === s.id}
|
||||
testId={`github-saved-query-default-${s.id}`}
|
||||
onToggle={() => void onToggleSavedDefault(s)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={defaultMutationPending}
|
||||
onClick={() => onDelete(s.id)}
|
||||
className="flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-wait disabled:opacity-50"
|
||||
title={accessibleDeleteLabel}
|
||||
aria-label={accessibleDeleteLabel}
|
||||
>
|
||||
<IconX className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSaveCurrent}
|
||||
disabled={!canSaveCurrent}
|
||||
className={cn(
|
||||
"mx-1 mt-1 flex items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors",
|
||||
"mx-1 mt-1 flex min-h-11 items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors",
|
||||
canSaveCurrent
|
||||
? "text-muted-foreground hover:bg-muted/50 hover:text-foreground cursor-pointer"
|
||||
: "text-muted-foreground/50 cursor-not-allowed",
|
||||
@@ -206,17 +229,22 @@ export function PresetsSidebar({
|
||||
onDeleteSaved,
|
||||
canSaveCurrent,
|
||||
onSaveCurrent,
|
||||
onToggleSavedDefault,
|
||||
defaultMutationPendingId,
|
||||
prPresets = PR_PRESETS,
|
||||
issuePresets = ISSUE_PRESETS,
|
||||
}: PresetsSidebarProps) {
|
||||
const presets = selected.kind === "pr" ? prPresets : issuePresets;
|
||||
const saved = savedPresets.filter((p) => p.kind === selected.kind);
|
||||
const onKindChange = (kind: "pr" | "issue") => {
|
||||
const fallback = (kind === "pr" ? prPresets : issuePresets)[0]?.value ?? "";
|
||||
onSelect({ kind, source: "preset", id: fallback });
|
||||
};
|
||||
const onKindChange = useCallback(
|
||||
(kind: "pr" | "issue") => {
|
||||
if (kind === selected.kind) return;
|
||||
onSelect({ kind, source: "kind-switch" });
|
||||
},
|
||||
[onSelect, selected.kind],
|
||||
);
|
||||
return (
|
||||
<nav className="flex flex-col py-3">
|
||||
<nav className="flex w-full min-w-0 flex-col overflow-x-hidden py-3">
|
||||
<KindToggle kind={selected.kind} onChange={onKindChange} />
|
||||
<PresetGroupList
|
||||
presets={presets}
|
||||
@@ -240,6 +268,8 @@ export function PresetsSidebar({
|
||||
kind={selected.kind}
|
||||
canSaveCurrent={canSaveCurrent}
|
||||
onSaveCurrent={onSaveCurrent}
|
||||
onToggleSavedDefault={onToggleSavedDefault}
|
||||
defaultMutationPendingId={defaultMutationPendingId}
|
||||
/>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findDefaultSavedPreset,
|
||||
readSavedPresets,
|
||||
resolveDefaultSidebarTarget,
|
||||
setSavedPresetDefault,
|
||||
type SavedPreset,
|
||||
type SavedPresetKind,
|
||||
} from "./saved-preset-model";
|
||||
|
||||
function preset(id: string, kind: SavedPresetKind, isDefault = false): SavedPreset {
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
label: id,
|
||||
customQuery: `query:${id}`,
|
||||
repoFilter: `repo/${id}`,
|
||||
createdAt: "2026-08-07T00:00:00Z",
|
||||
isDefault,
|
||||
};
|
||||
}
|
||||
|
||||
describe("saved preset parsing", () => {
|
||||
it("returns an empty list for non-array input", () => {
|
||||
expect(readSavedPresets(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ field: "customQuery", value: undefined },
|
||||
{ field: "customQuery", value: 42 },
|
||||
{ field: "createdAt", value: undefined },
|
||||
{ field: "createdAt", value: 42 },
|
||||
])("rejects an entry with an invalid $field", ({ field, value }) => {
|
||||
const valid = preset("valid", "pr");
|
||||
const malformed = { ...preset("malformed", "pr"), [field]: value };
|
||||
|
||||
expect(readSavedPresets([malformed, valid])).toEqual([valid]);
|
||||
});
|
||||
|
||||
it("drops entries that omit required query or timestamp fields", () => {
|
||||
const valid = preset("valid", "pr");
|
||||
const missingCustomQuery = {
|
||||
id: "missing-query",
|
||||
kind: "pr",
|
||||
label: "Missing query",
|
||||
repoFilter: "repo/missing-query",
|
||||
createdAt: "2026-08-07T00:00:00Z",
|
||||
isDefault: false,
|
||||
};
|
||||
const missingCreatedAt = {
|
||||
id: "missing-created-at",
|
||||
kind: "pr",
|
||||
label: "Missing created at",
|
||||
customQuery: "query:missing-created-at",
|
||||
repoFilter: "repo/missing-created-at",
|
||||
isDefault: false,
|
||||
};
|
||||
|
||||
expect(readSavedPresets([missingCustomQuery, missingCreatedAt, valid])).toEqual([valid]);
|
||||
});
|
||||
|
||||
it("returns only validated saved-preset fields", () => {
|
||||
const valid = preset("valid", "pr");
|
||||
|
||||
expect(readSavedPresets([{ ...valid, serverOnly: "discard me" }])).toEqual([valid]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saved preset defaults", () => {
|
||||
it("replaces one kind's default without changing the other kind", () => {
|
||||
const initial = [
|
||||
preset("pr-a", "pr", true),
|
||||
preset("pr-b", "pr"),
|
||||
preset("issue-a", "issue", true),
|
||||
];
|
||||
|
||||
const next = setSavedPresetDefault(initial, "pr", "pr-b");
|
||||
|
||||
expect(next.map(({ id, isDefault }) => ({ id, isDefault }))).toEqual([
|
||||
{ id: "pr-a", isDefault: false },
|
||||
{ id: "pr-b", isDefault: true },
|
||||
{ id: "issue-a", isDefault: true },
|
||||
]);
|
||||
expect(initial[0].isDefault).toBe(true);
|
||||
});
|
||||
|
||||
it("clears only the requested kind's default", () => {
|
||||
const initial = [preset("pr-a", "pr", true), preset("issue-a", "issue", true)];
|
||||
|
||||
const next = setSavedPresetDefault(initial, "pr", null);
|
||||
|
||||
expect(next.map(({ id, isDefault }) => ({ id, isDefault }))).toEqual([
|
||||
{ id: "pr-a", isDefault: false },
|
||||
{ id: "issue-a", isDefault: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the current default when the requested saved query does not exist", () => {
|
||||
const initial = [preset("pr-a", "pr", true)];
|
||||
|
||||
expect(setSavedPresetDefault(initial, "pr", "missing")).toBe(initial);
|
||||
});
|
||||
|
||||
it("returns the original list when the target is already the default", () => {
|
||||
const initial = [preset("pr-a", "pr", true), preset("pr-b", "pr")];
|
||||
|
||||
expect(setSavedPresetDefault(initial, "pr", "pr-a")).toBe(initial);
|
||||
});
|
||||
|
||||
it("finds the effective default for a result kind", () => {
|
||||
const issueDefault = preset("issue-a", "issue", true);
|
||||
|
||||
expect(findDefaultSavedPreset([preset("pr-a", "pr", true), issueDefault], "issue")).toBe(
|
||||
issueDefault,
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves a saved default with its query and repository", () => {
|
||||
const saved = preset("pr-default", "pr", true);
|
||||
|
||||
expect(
|
||||
resolveDefaultSidebarTarget("pr", [saved], [{ value: "review", filter: "review:@me" }]),
|
||||
).toEqual({
|
||||
selection: { kind: "pr", source: "saved", id: "pr-default" },
|
||||
query: "query:pr-default",
|
||||
repoFilter: "repo/pr-default",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the first configured query with all repositories", () => {
|
||||
expect(
|
||||
resolveDefaultSidebarTarget("issue", [], [{ value: "assigned", filter: "assignee:@me" }]),
|
||||
).toEqual({
|
||||
selection: { kind: "issue", source: "preset", id: "assigned" },
|
||||
query: "assignee:@me",
|
||||
repoFilter: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to an empty query when no query is configured", () => {
|
||||
expect(resolveDefaultSidebarTarget("issue", [], [])).toEqual({
|
||||
selection: { kind: "issue", source: "preset", id: "" },
|
||||
query: "",
|
||||
repoFilter: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
export type SavedPresetKind = "pr" | "issue";
|
||||
|
||||
export type SavedPreset = {
|
||||
id: string;
|
||||
kind: SavedPresetKind;
|
||||
label: string;
|
||||
customQuery: string;
|
||||
repoFilter: string;
|
||||
createdAt: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
export type DefaultSidebarTarget = {
|
||||
selection: {
|
||||
kind: SavedPresetKind;
|
||||
source: "preset" | "saved";
|
||||
id: string;
|
||||
};
|
||||
query: string;
|
||||
repoFilter: string;
|
||||
};
|
||||
|
||||
export function readSavedPresets(value: unknown): SavedPreset[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const defaultKinds = new Set<SavedPresetKind>();
|
||||
return value.flatMap((candidate): SavedPreset[] => {
|
||||
if (typeof candidate !== "object" || candidate === null) return [];
|
||||
const preset = candidate as Record<string, unknown>;
|
||||
const kind = preset.kind;
|
||||
if (
|
||||
typeof preset.id !== "string" ||
|
||||
(kind !== "pr" && kind !== "issue") ||
|
||||
typeof preset.label !== "string" ||
|
||||
typeof preset.customQuery !== "string" ||
|
||||
typeof preset.createdAt !== "string"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const isDefault = preset.isDefault === true && !defaultKinds.has(kind);
|
||||
if (isDefault) defaultKinds.add(kind);
|
||||
return [
|
||||
{
|
||||
id: preset.id,
|
||||
kind,
|
||||
label: preset.label,
|
||||
customQuery: preset.customQuery,
|
||||
repoFilter: typeof preset.repoFilter === "string" ? preset.repoFilter : "",
|
||||
createdAt: preset.createdAt,
|
||||
isDefault,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function findDefaultSavedPreset(
|
||||
presets: SavedPreset[],
|
||||
kind: SavedPresetKind,
|
||||
): SavedPreset | undefined {
|
||||
return presets.find((preset) => preset.kind === kind && preset.isDefault);
|
||||
}
|
||||
|
||||
export function setSavedPresetDefault(
|
||||
presets: SavedPreset[],
|
||||
kind: SavedPresetKind,
|
||||
id: string | null,
|
||||
): SavedPreset[] {
|
||||
if (id !== null && !presets.some((preset) => preset.kind === kind && preset.id === id)) {
|
||||
return presets;
|
||||
}
|
||||
let changed = false;
|
||||
const next = presets.map((preset) => {
|
||||
if (preset.kind !== kind) return preset;
|
||||
const isDefault = id !== null && preset.id === id;
|
||||
if (preset.isDefault === isDefault) return preset;
|
||||
changed = true;
|
||||
return { ...preset, isDefault };
|
||||
});
|
||||
return changed ? next : presets;
|
||||
}
|
||||
|
||||
export function resolveDefaultSidebarTarget(
|
||||
kind: SavedPresetKind,
|
||||
savedPresets: SavedPreset[],
|
||||
configuredPresets: ReadonlyArray<{ value: string; filter: string }>,
|
||||
): DefaultSidebarTarget {
|
||||
const saved = findDefaultSavedPreset(savedPresets, kind);
|
||||
if (saved) {
|
||||
return {
|
||||
selection: { kind, source: "saved", id: saved.id },
|
||||
query: saved.customQuery,
|
||||
repoFilter: saved.repoFilter,
|
||||
};
|
||||
}
|
||||
const configured = configuredPresets[0];
|
||||
return {
|
||||
selection: { kind, source: "preset", id: configured?.value ?? "" },
|
||||
query: configured?.filter ?? "",
|
||||
repoFilter: "",
|
||||
};
|
||||
}
|
||||
@@ -5,12 +5,20 @@ import type { PresetOption } from "./search-bar";
|
||||
import { useSavedPresetActions } from "./use-saved-preset-actions";
|
||||
import { useSavedPresets, type SavedPreset } from "./use-saved-presets";
|
||||
|
||||
const mockToast = vi.fn();
|
||||
|
||||
vi.mock("./use-saved-presets", () => ({
|
||||
useSavedPresets: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/toast-provider", () => ({
|
||||
useToast: () => ({ toast: mockToast }),
|
||||
}));
|
||||
|
||||
const QUERY = "assignee:@me is:open";
|
||||
const REPO = "kdlbs/kandev";
|
||||
const FIRST_WORKSPACE_ID = "workspace-1";
|
||||
const SECOND_WORKSPACE_ID = "workspace-2";
|
||||
|
||||
const savedPreset: SavedPreset = {
|
||||
id: "saved-1",
|
||||
@@ -19,6 +27,7 @@ const savedPreset: SavedPreset = {
|
||||
customQuery: QUERY,
|
||||
repoFilter: REPO,
|
||||
createdAt: "2026-07-17T00:00:00Z",
|
||||
isDefault: false,
|
||||
};
|
||||
|
||||
const prPreset: PresetOption = {
|
||||
@@ -37,14 +46,29 @@ const issuePreset: PresetOption = {
|
||||
icon: IconInbox,
|
||||
};
|
||||
|
||||
type SavedPresetStore = ReturnType<typeof useSavedPresets>;
|
||||
type Options = Parameters<typeof useSavedPresetActions>[0];
|
||||
|
||||
function renderActions(overrides: Partial<Options> = {}) {
|
||||
function makeStore(overrides: Partial<SavedPresetStore> = {}): SavedPresetStore {
|
||||
return {
|
||||
presets: [],
|
||||
save: vi.fn(async () => null),
|
||||
remove: vi.fn(async () => false),
|
||||
setDefault: vi.fn(async () => false),
|
||||
...overrides,
|
||||
} as SavedPresetStore;
|
||||
}
|
||||
|
||||
function renderActions(
|
||||
overrides: Partial<Options> = {},
|
||||
savedPresetStore = makeStore({ presets: [savedPreset] }),
|
||||
) {
|
||||
const setProgrammaticSelection = vi.fn();
|
||||
const setQueryImmediate = vi.fn();
|
||||
const setRepoFilter = vi.fn();
|
||||
const markSearchInteracted = vi.fn();
|
||||
const options: Options = {
|
||||
workspaceId: "workspace-1",
|
||||
workspaceId: FIRST_WORKSPACE_ID,
|
||||
selection: { kind: "issue", source: "saved", id: savedPreset.id },
|
||||
customQuery: QUERY,
|
||||
resolvedPrPresets: [prPreset],
|
||||
@@ -52,39 +76,231 @@ function renderActions(overrides: Partial<Options> = {}) {
|
||||
setProgrammaticSelection,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
savedPresetStore,
|
||||
markSearchInteracted,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const rendered = renderHook((currentOptions: Options) => useSavedPresetActions(currentOptions), {
|
||||
initialProps: options,
|
||||
});
|
||||
return {
|
||||
...renderHook(() => useSavedPresetActions(options)),
|
||||
...rendered,
|
||||
rerender: (nextOverrides: Partial<Options> = {}) =>
|
||||
rendered.rerender({ ...options, ...nextOverrides }),
|
||||
setProgrammaticSelection,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
markSearchInteracted,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useSavedPresetActions", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useSavedPresets).mockReset();
|
||||
async function expectLatestPresetCatalogAfterDelete() {
|
||||
let finishRemove!: (removed: boolean) => void;
|
||||
const remove = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
finishRemove = resolve;
|
||||
}),
|
||||
);
|
||||
const refreshedIssuePreset: PresetOption = {
|
||||
...issuePreset,
|
||||
value: "mentioned",
|
||||
label: "Mentioned",
|
||||
filter: "mentions:@me is:open",
|
||||
};
|
||||
const { result, rerender, setProgrammaticSelection, setQueryImmediate } = renderActions(
|
||||
{},
|
||||
makeStore({ presets: [savedPreset], remove }),
|
||||
);
|
||||
|
||||
let deletion!: Promise<void>;
|
||||
act(() => {
|
||||
deletion = result.current.onDeleteSaved(savedPreset.id);
|
||||
});
|
||||
rerender({ resolvedIssuePresets: [refreshedIssuePreset] });
|
||||
|
||||
await act(async () => {
|
||||
finishRemove(true);
|
||||
await deletion;
|
||||
});
|
||||
|
||||
it("saves the current query, commits it, selects it, and applies its repository", () => {
|
||||
const save = vi.fn(() => savedPreset);
|
||||
const remove = vi.fn();
|
||||
vi.mocked(useSavedPresets).mockReturnValue({ presets: [savedPreset], save, remove });
|
||||
const { result, setProgrammaticSelection, setQueryImmediate, setRepoFilter } = renderActions({
|
||||
selection: { kind: "issue", source: "preset", id: "assigned" },
|
||||
});
|
||||
expect(setProgrammaticSelection).toHaveBeenCalledWith({
|
||||
kind: "issue",
|
||||
source: "preset",
|
||||
id: refreshedIssuePreset.value,
|
||||
});
|
||||
expect(setQueryImmediate).toHaveBeenCalledWith(refreshedIssuePreset.filter);
|
||||
}
|
||||
|
||||
act(() => result.current.onConfirmSave("Assigned in Kandev", REPO));
|
||||
async function expectStaleSaveOutcomeIgnored() {
|
||||
let finishSave!: () => void;
|
||||
const save = vi.fn(
|
||||
() =>
|
||||
new Promise<SavedPreset>((resolve) => {
|
||||
finishSave = () => resolve(savedPreset);
|
||||
}),
|
||||
);
|
||||
const {
|
||||
result,
|
||||
rerender,
|
||||
markSearchInteracted,
|
||||
setProgrammaticSelection,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
} = renderActions({}, makeStore({ save }));
|
||||
|
||||
expect(useSavedPresets).toHaveBeenCalledWith("workspace-1");
|
||||
let mutation!: Promise<void>;
|
||||
act(() => {
|
||||
mutation = result.current.onConfirmSave(savedPreset.label, savedPreset.repoFilter);
|
||||
});
|
||||
rerender({ workspaceId: SECOND_WORKSPACE_ID });
|
||||
await act(async () => {
|
||||
finishSave();
|
||||
await mutation;
|
||||
});
|
||||
|
||||
expect(markSearchInteracted).not.toHaveBeenCalled();
|
||||
expect(setProgrammaticSelection).not.toHaveBeenCalled();
|
||||
expect(setQueryImmediate).not.toHaveBeenCalled();
|
||||
expect(setRepoFilter).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
async function expectStaleDeleteOutcomeIgnored() {
|
||||
let finishRemove!: () => void;
|
||||
const remove = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
finishRemove = () => resolve(true);
|
||||
}),
|
||||
);
|
||||
const {
|
||||
result,
|
||||
rerender,
|
||||
markSearchInteracted,
|
||||
setProgrammaticSelection,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
} = renderActions({}, makeStore({ remove }));
|
||||
|
||||
let mutation!: Promise<void>;
|
||||
act(() => {
|
||||
mutation = result.current.onDeleteSaved(savedPreset.id);
|
||||
});
|
||||
rerender({ workspaceId: SECOND_WORKSPACE_ID });
|
||||
await act(async () => {
|
||||
finishRemove();
|
||||
await mutation;
|
||||
});
|
||||
|
||||
expect(markSearchInteracted).not.toHaveBeenCalled();
|
||||
expect(setProgrammaticSelection).not.toHaveBeenCalled();
|
||||
expect(setQueryImmediate).not.toHaveBeenCalled();
|
||||
expect(setRepoFilter).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
async function expectStaleToggleDefaultOutcomeIgnored() {
|
||||
let finishSetDefault!: () => void;
|
||||
const setDefault = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
finishSetDefault = () => resolve(true);
|
||||
}),
|
||||
);
|
||||
const { result, rerender, markSearchInteracted } = renderActions(
|
||||
{},
|
||||
makeStore({ presets: [savedPreset], setDefault }),
|
||||
);
|
||||
|
||||
let mutation!: Promise<void>;
|
||||
act(() => {
|
||||
mutation = result.current.onToggleSavedDefault(savedPreset);
|
||||
});
|
||||
rerender({ workspaceId: SECOND_WORKSPACE_ID });
|
||||
await act(async () => {
|
||||
finishSetDefault();
|
||||
await mutation;
|
||||
});
|
||||
|
||||
expect(markSearchInteracted).not.toHaveBeenCalled();
|
||||
expect(result.current.defaultMutationPendingId).toBeNull();
|
||||
}
|
||||
|
||||
async function expectDefaultPendingScopedToWorkspace() {
|
||||
let finishFirst!: () => void;
|
||||
let finishSecond!: () => void;
|
||||
const setDefault = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
const finish = () => resolve(true);
|
||||
if (setDefault.mock.calls.length === 1) finishFirst = finish;
|
||||
else finishSecond = finish;
|
||||
}),
|
||||
);
|
||||
const { result, rerender, markSearchInteracted } = renderActions({}, makeStore({ setDefault }));
|
||||
|
||||
let firstMutation!: Promise<void>;
|
||||
act(() => {
|
||||
firstMutation = result.current.onToggleSavedDefault(savedPreset);
|
||||
});
|
||||
expect(result.current.defaultMutationPendingId).toBe(savedPreset.id);
|
||||
|
||||
rerender({ workspaceId: SECOND_WORKSPACE_ID });
|
||||
expect(result.current.defaultMutationPendingId).toBeNull();
|
||||
|
||||
let secondMutation!: Promise<void>;
|
||||
act(() => {
|
||||
secondMutation = result.current.onToggleSavedDefault(savedPreset);
|
||||
});
|
||||
expect(setDefault).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.defaultMutationPendingId).toBe(savedPreset.id);
|
||||
|
||||
await act(async () => {
|
||||
finishFirst();
|
||||
await firstMutation;
|
||||
});
|
||||
expect(markSearchInteracted).not.toHaveBeenCalled();
|
||||
expect(result.current.defaultMutationPendingId).toBe(savedPreset.id);
|
||||
|
||||
await act(async () => {
|
||||
finishSecond();
|
||||
await secondMutation;
|
||||
});
|
||||
expect(markSearchInteracted).toHaveBeenCalledOnce();
|
||||
expect(result.current.defaultMutationPendingId).toBeNull();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useSavedPresets).mockReset();
|
||||
mockToast.mockReset();
|
||||
});
|
||||
|
||||
describe("useSavedPresetActions save actions", () => {
|
||||
it("ignores a completed save after changing workspace", expectStaleSaveOutcomeIgnored);
|
||||
|
||||
it("saves the current query, commits it, selects it, and applies its repository", async () => {
|
||||
const save = vi.fn(async () => savedPreset);
|
||||
const store = makeStore({ presets: [savedPreset], save });
|
||||
const {
|
||||
result,
|
||||
setProgrammaticSelection,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
markSearchInteracted,
|
||||
} = renderActions({ selection: { kind: "issue", source: "preset", id: "assigned" } }, store);
|
||||
|
||||
await act(async () => result.current.onConfirmSave("Assigned in Kandev", REPO));
|
||||
|
||||
expect(useSavedPresets).not.toHaveBeenCalled();
|
||||
expect(markSearchInteracted).toHaveBeenCalledOnce();
|
||||
expect(save).toHaveBeenCalledWith({
|
||||
kind: "issue",
|
||||
label: "Assigned in Kandev",
|
||||
customQuery: QUERY,
|
||||
repoFilter: REPO,
|
||||
});
|
||||
expect(save.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
markSearchInteracted.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(setProgrammaticSelection).toHaveBeenCalledWith({
|
||||
kind: "issue",
|
||||
source: "saved",
|
||||
@@ -95,13 +311,19 @@ describe("useSavedPresetActions", () => {
|
||||
expect(result.current.savedPresets).toEqual([savedPreset]);
|
||||
});
|
||||
|
||||
it("leaves selection and repository unchanged when saving returns null", () => {
|
||||
const save = vi.fn(() => null);
|
||||
vi.mocked(useSavedPresets).mockReturnValue({ presets: [], save, remove: vi.fn() });
|
||||
const { result, setProgrammaticSelection, setQueryImmediate, setRepoFilter } = renderActions();
|
||||
it("leaves selection and repository unchanged when saving returns null", async () => {
|
||||
const save = vi.fn(async () => null);
|
||||
const {
|
||||
result,
|
||||
setProgrammaticSelection,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
markSearchInteracted,
|
||||
} = renderActions({}, makeStore({ save }));
|
||||
|
||||
act(() => result.current.onConfirmSave("Unavailable", REPO));
|
||||
await act(async () => result.current.onConfirmSave("Unavailable", REPO));
|
||||
|
||||
expect(markSearchInteracted).not.toHaveBeenCalled();
|
||||
expect(save).toHaveBeenCalledWith({
|
||||
kind: "issue",
|
||||
label: "Unavailable",
|
||||
@@ -113,14 +335,49 @@ describe("useSavedPresetActions", () => {
|
||||
expect(setRepoFilter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes the active saved query and selects the first same-kind preset", () => {
|
||||
const remove = vi.fn();
|
||||
vi.mocked(useSavedPresets).mockReturnValue({ presets: [savedPreset], save: vi.fn(), remove });
|
||||
const { result, setProgrammaticSelection, setQueryImmediate, setRepoFilter } = renderActions();
|
||||
it("reports a save persistence failure without selecting the optimistic preset", async () => {
|
||||
const failure = Promise.reject(new Error("settings down"));
|
||||
void failure.catch(() => undefined);
|
||||
const save = vi.fn(() => failure);
|
||||
const { result, setProgrammaticSelection, markSearchInteracted } = renderActions(
|
||||
{},
|
||||
makeStore({ save }),
|
||||
);
|
||||
|
||||
act(() => result.current.onDeleteSaved(savedPreset.id));
|
||||
await act(async () => result.current.onConfirmSave("Unavailable", REPO));
|
||||
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
description: "Failed to save saved query",
|
||||
variant: "error",
|
||||
});
|
||||
expect(setProgrammaticSelection).not.toHaveBeenCalled();
|
||||
expect(markSearchInteracted).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSavedPresetActions delete actions", () => {
|
||||
it("ignores a completed delete after changing workspace", expectStaleDeleteOutcomeIgnored);
|
||||
|
||||
it("deletes the active saved query and selects the first same-kind preset", async () => {
|
||||
const remove = vi.fn(async () => true);
|
||||
const {
|
||||
result,
|
||||
setProgrammaticSelection,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
markSearchInteracted,
|
||||
} = renderActions({}, makeStore({ presets: [savedPreset], remove }));
|
||||
|
||||
await act(async () => result.current.onDeleteSaved(savedPreset.id));
|
||||
|
||||
expect(markSearchInteracted).toHaveBeenCalledOnce();
|
||||
expect(remove).toHaveBeenCalledWith(savedPreset.id);
|
||||
expect(remove.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
markSearchInteracted.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(remove.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
setProgrammaticSelection.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(setProgrammaticSelection).toHaveBeenCalledWith({
|
||||
kind: "issue",
|
||||
source: "preset",
|
||||
@@ -128,23 +385,209 @@ describe("useSavedPresetActions", () => {
|
||||
});
|
||||
expect(setQueryImmediate).toHaveBeenCalledWith(issuePreset.filter);
|
||||
expect(setRepoFilter).toHaveBeenCalledWith("");
|
||||
expect(remove.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
setProgrammaticSelection.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes an inactive saved query without changing the active search", () => {
|
||||
const remove = vi.fn();
|
||||
vi.mocked(useSavedPresets).mockReturnValue({ presets: [savedPreset], save: vi.fn(), remove });
|
||||
const { result, setProgrammaticSelection, setQueryImmediate, setRepoFilter } = renderActions({
|
||||
selection: { kind: "issue", source: "saved", id: "saved-active" },
|
||||
});
|
||||
it(
|
||||
"uses the latest preset catalog when an active deletion finishes",
|
||||
expectLatestPresetCatalogAfterDelete,
|
||||
);
|
||||
|
||||
act(() => result.current.onDeleteSaved(savedPreset.id));
|
||||
it("deletes an inactive saved query without changing the active search", async () => {
|
||||
const remove = vi.fn(async () => true);
|
||||
const {
|
||||
result,
|
||||
setProgrammaticSelection,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
markSearchInteracted,
|
||||
} = renderActions(
|
||||
{ selection: { kind: "issue", source: "saved", id: "saved-active" } },
|
||||
makeStore({ presets: [savedPreset], remove }),
|
||||
);
|
||||
|
||||
await act(async () => result.current.onDeleteSaved(savedPreset.id));
|
||||
|
||||
expect(markSearchInteracted).toHaveBeenCalledOnce();
|
||||
expect(remove).toHaveBeenCalledWith(savedPreset.id);
|
||||
expect(setProgrammaticSelection).not.toHaveBeenCalled();
|
||||
expect(setQueryImmediate).not.toHaveBeenCalled();
|
||||
expect(setRepoFilter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not mark search interaction when the saved query no longer exists", async () => {
|
||||
const remove = vi.fn(async () => false);
|
||||
const { result, markSearchInteracted } = renderActions({}, makeStore({ remove }));
|
||||
|
||||
await act(async () => result.current.onDeleteSaved(savedPreset.id));
|
||||
|
||||
expect(markSearchInteracted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a delete persistence failure", async () => {
|
||||
const failure = Promise.reject(new Error("settings down"));
|
||||
void failure.catch(() => undefined);
|
||||
const remove = vi.fn(() => failure);
|
||||
const {
|
||||
result,
|
||||
setProgrammaticSelection,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
markSearchInteracted,
|
||||
} = renderActions({}, makeStore({ remove }));
|
||||
|
||||
await act(async () => result.current.onDeleteSaved(savedPreset.id));
|
||||
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
description: "Failed to delete saved query",
|
||||
variant: "error",
|
||||
});
|
||||
expect(setProgrammaticSelection).not.toHaveBeenCalled();
|
||||
expect(setQueryImmediate).not.toHaveBeenCalled();
|
||||
expect(setRepoFilter).not.toHaveBeenCalled();
|
||||
expect(markSearchInteracted).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSavedPresetActions default deletion", () => {
|
||||
it("deletes an inactive default without changing the active search", async () => {
|
||||
const inactiveDefault = { ...savedPreset, isDefault: true };
|
||||
const remove = vi.fn(async () => true);
|
||||
const {
|
||||
result,
|
||||
setProgrammaticSelection,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
markSearchInteracted,
|
||||
} = renderActions(
|
||||
{ selection: { kind: "issue", source: "saved", id: "saved-active" } },
|
||||
makeStore({ presets: [inactiveDefault], remove }),
|
||||
);
|
||||
|
||||
await act(async () => result.current.onDeleteSaved(inactiveDefault.id));
|
||||
|
||||
expect(markSearchInteracted).toHaveBeenCalledOnce();
|
||||
expect(remove).toHaveBeenCalledWith(inactiveDefault.id);
|
||||
expect(setProgrammaticSelection).not.toHaveBeenCalled();
|
||||
expect(setQueryImmediate).not.toHaveBeenCalled();
|
||||
expect(setRepoFilter).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSavedPresetActions default actions", () => {
|
||||
it("scopes pending default toggles to their workspace", expectDefaultPendingScopedToWorkspace);
|
||||
|
||||
it(
|
||||
"ignores a completed default toggle after changing workspace",
|
||||
expectStaleToggleDefaultOutcomeIgnored,
|
||||
);
|
||||
|
||||
it("keeps action handlers stable across unchanged renders", () => {
|
||||
const { result, rerender } = renderActions();
|
||||
const initialConfirm = result.current.onConfirmSave;
|
||||
const initialDelete = result.current.onDeleteSaved;
|
||||
const initialToggle = result.current.onToggleSavedDefault;
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.onConfirmSave).toBe(initialConfirm);
|
||||
expect(result.current.onDeleteSaved).toBe(initialDelete);
|
||||
expect(result.current.onToggleSavedDefault).toBe(initialToggle);
|
||||
});
|
||||
|
||||
it("does not mark search interaction when the default mutation is unavailable", async () => {
|
||||
const { result, markSearchInteracted } = renderActions();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onToggleSavedDefault(savedPreset);
|
||||
});
|
||||
|
||||
expect(markSearchInteracted).not.toHaveBeenCalled();
|
||||
expect(result.current.defaultMutationPendingId).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores a second default toggle while the first is pending", async () => {
|
||||
let finish!: () => void;
|
||||
const setDefault = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
finish = () => resolve(true);
|
||||
}),
|
||||
);
|
||||
const { result } = renderActions({}, makeStore({ presets: [savedPreset], setDefault }));
|
||||
const toggle = result.current.onToggleSavedDefault;
|
||||
|
||||
let firstMutation!: Promise<void>;
|
||||
let secondMutation!: Promise<void>;
|
||||
act(() => {
|
||||
firstMutation = toggle(savedPreset);
|
||||
secondMutation = toggle(savedPreset);
|
||||
});
|
||||
|
||||
expect(setDefault).toHaveBeenCalledOnce();
|
||||
|
||||
await act(async () => {
|
||||
finish();
|
||||
await Promise.all([firstMutation, secondMutation]);
|
||||
});
|
||||
});
|
||||
|
||||
it("sets a future default without changing the current search and exposes pending state", async () => {
|
||||
let finish!: () => void;
|
||||
const setDefault = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
finish = () => resolve(true);
|
||||
}),
|
||||
);
|
||||
const store = makeStore({ presets: [savedPreset], setDefault });
|
||||
const {
|
||||
result,
|
||||
setProgrammaticSelection,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
markSearchInteracted,
|
||||
} = renderActions({}, store);
|
||||
|
||||
let mutation!: Promise<void>;
|
||||
act(() => {
|
||||
mutation = result.current.onToggleSavedDefault(savedPreset);
|
||||
});
|
||||
|
||||
expect(markSearchInteracted).not.toHaveBeenCalled();
|
||||
expect(setDefault).toHaveBeenCalledWith("issue", savedPreset.id);
|
||||
expect(result.current.defaultMutationPendingId).toBe(savedPreset.id);
|
||||
expect(setProgrammaticSelection).not.toHaveBeenCalled();
|
||||
expect(setQueryImmediate).not.toHaveBeenCalled();
|
||||
expect(setRepoFilter).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
finish();
|
||||
await mutation;
|
||||
});
|
||||
expect(markSearchInteracted).toHaveBeenCalledOnce();
|
||||
expect(result.current.defaultMutationPendingId).toBeNull();
|
||||
});
|
||||
|
||||
it("clears an existing default and reports persistence failure", async () => {
|
||||
const currentDefault = { ...savedPreset, isDefault: true };
|
||||
const setDefault = vi.fn(async () => {
|
||||
throw new Error("settings down");
|
||||
});
|
||||
const { result, markSearchInteracted } = renderActions(
|
||||
{},
|
||||
makeStore({ presets: [currentDefault], setDefault }),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onToggleSavedDefault(currentDefault);
|
||||
});
|
||||
|
||||
expect(setDefault).toHaveBeenCalledWith("issue", null);
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
description: "Failed to update default view",
|
||||
variant: "error",
|
||||
});
|
||||
expect(markSearchInteracted).not.toHaveBeenCalled();
|
||||
expect(result.current.defaultMutationPendingId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import type { PresetOption } from "./search-bar";
|
||||
import type { SidebarSelection } from "./presets-sidebar";
|
||||
import { useSavedPresets } from "./use-saved-presets";
|
||||
import type { SavedPreset } from "./saved-preset-model";
|
||||
import type { useSavedPresets } from "./use-saved-presets";
|
||||
|
||||
type SavedPresetStore = ReturnType<typeof useSavedPresets>;
|
||||
type WorkspaceId = string | null;
|
||||
type IsCurrentWorkspace = (workspaceId: WorkspaceId) => boolean;
|
||||
type PendingDefaultMutation = {
|
||||
presetId: string;
|
||||
};
|
||||
|
||||
type SavedPresetActionsOptions = {
|
||||
workspaceId: string | null;
|
||||
@@ -13,6 +24,8 @@ type SavedPresetActionsOptions = {
|
||||
setProgrammaticSelection: (selection: SidebarSelection) => void;
|
||||
setQueryImmediate: (query: string) => void;
|
||||
setRepoFilter: (repo: string) => void;
|
||||
savedPresetStore: SavedPresetStore;
|
||||
markSearchInteracted: () => void;
|
||||
};
|
||||
|
||||
function firstPresetSelection(
|
||||
@@ -27,43 +40,228 @@ function firstPresetSelection(
|
||||
};
|
||||
}
|
||||
|
||||
function useIsCurrentWorkspace(workspaceId: WorkspaceId): IsCurrentWorkspace {
|
||||
const workspaceIdRef = useRef(workspaceId);
|
||||
workspaceIdRef.current = workspaceId;
|
||||
return useCallback((candidate) => workspaceIdRef.current === candidate, []);
|
||||
}
|
||||
|
||||
function useConfirmSave({
|
||||
workspaceId,
|
||||
kind,
|
||||
customQuery,
|
||||
save,
|
||||
markSearchInteracted,
|
||||
setProgrammaticSelection,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
reportError,
|
||||
isCurrentWorkspace,
|
||||
}: {
|
||||
workspaceId: WorkspaceId;
|
||||
kind: SidebarSelection["kind"];
|
||||
customQuery: string;
|
||||
save: SavedPresetStore["save"];
|
||||
markSearchInteracted: () => void;
|
||||
setProgrammaticSelection: (selection: SidebarSelection) => void;
|
||||
setQueryImmediate: (query: string) => void;
|
||||
setRepoFilter: (repo: string) => void;
|
||||
reportError: () => void;
|
||||
isCurrentWorkspace: IsCurrentWorkspace;
|
||||
}) {
|
||||
return useCallback(
|
||||
async (label: string, defaultRepoFilter: string) => {
|
||||
try {
|
||||
const created = await save({ kind, label, customQuery, repoFilter: defaultRepoFilter });
|
||||
// No persistence started when workspace presets are not available yet.
|
||||
if (!created) return;
|
||||
if (!isCurrentWorkspace(workspaceId)) return;
|
||||
markSearchInteracted();
|
||||
setProgrammaticSelection({ kind, source: "saved", id: created.id });
|
||||
setQueryImmediate(customQuery);
|
||||
setRepoFilter(defaultRepoFilter);
|
||||
} catch {
|
||||
reportError();
|
||||
}
|
||||
},
|
||||
[
|
||||
customQuery,
|
||||
isCurrentWorkspace,
|
||||
kind,
|
||||
markSearchInteracted,
|
||||
reportError,
|
||||
save,
|
||||
setProgrammaticSelection,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
workspaceId,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function useDeleteSaved({
|
||||
workspaceId,
|
||||
selection,
|
||||
prPresets,
|
||||
issuePresets,
|
||||
remove,
|
||||
markSearchInteracted,
|
||||
setProgrammaticSelection,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
reportError,
|
||||
isCurrentWorkspace,
|
||||
}: {
|
||||
workspaceId: WorkspaceId;
|
||||
selection: SidebarSelection;
|
||||
prPresets: PresetOption[];
|
||||
issuePresets: PresetOption[];
|
||||
remove: SavedPresetStore["remove"];
|
||||
markSearchInteracted: () => void;
|
||||
setProgrammaticSelection: (selection: SidebarSelection) => void;
|
||||
setQueryImmediate: (query: string) => void;
|
||||
setRepoFilter: (repo: string) => void;
|
||||
reportError: () => void;
|
||||
isCurrentWorkspace: IsCurrentWorkspace;
|
||||
}) {
|
||||
const selectionRef = useRef(selection);
|
||||
selectionRef.current = selection;
|
||||
const presetsRef = useRef({ prPresets, issuePresets });
|
||||
presetsRef.current = { prPresets, issuePresets };
|
||||
return useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
const removed = await remove(id);
|
||||
// Loading or a stale target is a no-op, not a persistence failure.
|
||||
if (!removed) return;
|
||||
if (!isCurrentWorkspace(workspaceId)) return;
|
||||
markSearchInteracted();
|
||||
const currentSelection = selectionRef.current;
|
||||
if (currentSelection.source === "saved" && currentSelection.id === id) {
|
||||
const fallback = firstPresetSelection(
|
||||
currentSelection.kind,
|
||||
presetsRef.current.prPresets,
|
||||
presetsRef.current.issuePresets,
|
||||
);
|
||||
setProgrammaticSelection(fallback.selection);
|
||||
setQueryImmediate(fallback.filter);
|
||||
setRepoFilter("");
|
||||
}
|
||||
} catch {
|
||||
reportError();
|
||||
}
|
||||
},
|
||||
[
|
||||
isCurrentWorkspace,
|
||||
markSearchInteracted,
|
||||
remove,
|
||||
reportError,
|
||||
setProgrammaticSelection,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
workspaceId,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export function useSavedPresetActions({
|
||||
workspaceId,
|
||||
selection,
|
||||
customQuery,
|
||||
resolvedPrPresets,
|
||||
resolvedIssuePresets,
|
||||
resolvedPrPresets: prPresets,
|
||||
resolvedIssuePresets: issuePresets,
|
||||
setProgrammaticSelection,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
savedPresetStore,
|
||||
markSearchInteracted,
|
||||
}: SavedPresetActionsOptions) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [, setDefaultMutationVersion] = useState(0);
|
||||
const pendingDefaultMutationsRef = useRef(new Map<WorkspaceId, PendingDefaultMutation>());
|
||||
const isCurrentWorkspace = useIsCurrentWorkspace(workspaceId);
|
||||
const pendingDefaultMutation = pendingDefaultMutationsRef.current.get(workspaceId);
|
||||
const defaultMutationPendingId = pendingDefaultMutation?.presetId ?? null;
|
||||
const {
|
||||
presets: savedPresets,
|
||||
save: saveSavedPreset,
|
||||
remove: removeSavedPreset,
|
||||
} = useSavedPresets(workspaceId);
|
||||
setDefault: setSavedPresetDefault,
|
||||
} = savedPresetStore;
|
||||
|
||||
const onConfirmSave = (label: string, defaultRepoFilter: string) => {
|
||||
const created = saveSavedPreset({
|
||||
kind: selection.kind,
|
||||
label,
|
||||
customQuery,
|
||||
repoFilter: defaultRepoFilter,
|
||||
});
|
||||
if (!created) return;
|
||||
setProgrammaticSelection({ kind: selection.kind, source: "saved", id: created.id });
|
||||
setQueryImmediate(customQuery);
|
||||
setRepoFilter(defaultRepoFilter);
|
||||
const reportSaveError = useCallback(
|
||||
() => toast({ description: t("integrations:failedToSaveSavedQuery"), variant: "error" }),
|
||||
[t, toast],
|
||||
);
|
||||
const reportDeleteError = useCallback(
|
||||
() => toast({ description: t("integrations:failedToDeleteSavedQuery"), variant: "error" }),
|
||||
[t, toast],
|
||||
);
|
||||
const onConfirmSave = useConfirmSave({
|
||||
workspaceId,
|
||||
kind: selection.kind,
|
||||
customQuery,
|
||||
save: saveSavedPreset,
|
||||
markSearchInteracted,
|
||||
setProgrammaticSelection,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
reportError: reportSaveError,
|
||||
isCurrentWorkspace,
|
||||
});
|
||||
const onDeleteSaved = useDeleteSaved({
|
||||
workspaceId,
|
||||
selection,
|
||||
prPresets,
|
||||
issuePresets,
|
||||
remove: removeSavedPreset,
|
||||
markSearchInteracted,
|
||||
setProgrammaticSelection,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
reportError: reportDeleteError,
|
||||
isCurrentWorkspace,
|
||||
});
|
||||
|
||||
const onToggleSavedDefault = useCallback(
|
||||
async (preset: SavedPreset) => {
|
||||
const pendingMutations = pendingDefaultMutationsRef.current;
|
||||
if (pendingMutations.has(workspaceId)) return;
|
||||
const pendingMutation: PendingDefaultMutation = { presetId: preset.id };
|
||||
pendingMutations.set(workspaceId, pendingMutation);
|
||||
setDefaultMutationVersion((version) => version + 1);
|
||||
try {
|
||||
const defaultId = preset.isDefault ? null : preset.id;
|
||||
const persisted = await setSavedPresetDefault(preset.kind, defaultId);
|
||||
// Loading or an already-matching default is a no-op, not a persistence failure.
|
||||
if (!persisted) return;
|
||||
if (!isCurrentWorkspace(workspaceId)) return;
|
||||
// Mark interaction so the initial-selection effect does not switch the
|
||||
// active view to the newly set default on the same render cycle.
|
||||
markSearchInteracted();
|
||||
} catch {
|
||||
toast({
|
||||
description: t("integrations:failedToUpdateDefaultView"),
|
||||
variant: "error",
|
||||
});
|
||||
} finally {
|
||||
if (pendingMutations.get(workspaceId) === pendingMutation) {
|
||||
pendingMutations.delete(workspaceId);
|
||||
if (isCurrentWorkspace(workspaceId)) {
|
||||
setDefaultMutationVersion((version) => version + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[isCurrentWorkspace, markSearchInteracted, setSavedPresetDefault, t, toast, workspaceId],
|
||||
);
|
||||
|
||||
return {
|
||||
savedPresets,
|
||||
onConfirmSave,
|
||||
onDeleteSaved,
|
||||
onToggleSavedDefault,
|
||||
defaultMutationPendingId,
|
||||
};
|
||||
|
||||
const onDeleteSaved = (id: string) => {
|
||||
removeSavedPreset(id);
|
||||
if (selection.source !== "saved" || selection.id !== id) return;
|
||||
const fallback = firstPresetSelection(selection.kind, resolvedPrPresets, resolvedIssuePresets);
|
||||
setProgrammaticSelection(fallback.selection);
|
||||
setQueryImmediate(fallback.filter);
|
||||
setRepoFilter("");
|
||||
};
|
||||
|
||||
return { savedPresets, onConfirmSave, onDeleteSaved };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchUserSettings, updateUserSettings } from "@/lib/api/domains/settings-api";
|
||||
import {
|
||||
fetchGitHubWorkspaceSettings,
|
||||
updateGitHubWorkspaceSettings,
|
||||
} from "@/lib/api/domains/github-api";
|
||||
import { __resetSnapshotForTests, useSavedPresets, type SavedPreset } from "./use-saved-presets";
|
||||
|
||||
const WORKSPACE_ID = "ws-1";
|
||||
const SETTINGS_TIMESTAMP = "2026-01-01T00:00:00Z";
|
||||
const WORKSPACE_MODE = "workspace";
|
||||
const PORTABLE_USER_MODE = "portable user";
|
||||
const MODES = [WORKSPACE_MODE, PORTABLE_USER_MODE] as const;
|
||||
|
||||
type PersistenceMode = (typeof MODES)[number];
|
||||
|
||||
const valid: SavedPreset = {
|
||||
id: "pr-a",
|
||||
kind: "pr",
|
||||
label: "PR A",
|
||||
customQuery: "author:@me",
|
||||
repoFilter: "",
|
||||
createdAt: SETTINGS_TIMESTAMP,
|
||||
isDefault: false,
|
||||
};
|
||||
|
||||
vi.mock("@/lib/api/domains/settings-api", () => ({
|
||||
fetchUserSettings: vi.fn(),
|
||||
updateUserSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/domains/github-api", () => ({
|
||||
fetchGitHubWorkspaceSettings: vi.fn(),
|
||||
updateGitHubWorkspaceSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function workspaceSettings(
|
||||
savedPresets: unknown = [],
|
||||
): Awaited<ReturnType<typeof fetchGitHubWorkspaceSettings>> {
|
||||
return {
|
||||
workspace_id: WORKSPACE_ID,
|
||||
repo_scope_mode: "all",
|
||||
repo_scope_orgs: [],
|
||||
repo_scope_repos: [],
|
||||
saved_presets: savedPresets,
|
||||
default_query_presets: null,
|
||||
created_at: SETTINGS_TIMESTAMP,
|
||||
updated_at: SETTINGS_TIMESTAMP,
|
||||
} as Awaited<ReturnType<typeof fetchGitHubWorkspaceSettings>>;
|
||||
}
|
||||
|
||||
function requirePreset(value: SavedPreset | null): SavedPreset {
|
||||
expect(value).not.toBeNull();
|
||||
if (value === null) throw new Error("Expected a saved preset");
|
||||
return value;
|
||||
}
|
||||
|
||||
function mockHydration(mode: PersistenceMode, presets: SavedPreset[]) {
|
||||
if (mode === WORKSPACE_MODE) {
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockResolvedValue(workspaceSettings(presets));
|
||||
return;
|
||||
}
|
||||
vi.mocked(fetchUserSettings).mockResolvedValue({
|
||||
settings: { github_saved_presets: presets },
|
||||
} as Awaited<ReturnType<typeof fetchUserSettings>>);
|
||||
}
|
||||
|
||||
function deferFirstPersistence(mode: PersistenceMode): () => void {
|
||||
if (mode === WORKSPACE_MODE) {
|
||||
const first = deferred<Awaited<ReturnType<typeof updateGitHubWorkspaceSettings>>>();
|
||||
vi.mocked(updateGitHubWorkspaceSettings)
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockResolvedValueOnce(workspaceSettings());
|
||||
return () => first.resolve(workspaceSettings());
|
||||
}
|
||||
const first = deferred<Awaited<ReturnType<typeof updateUserSettings>>>();
|
||||
const success = undefined as unknown as Awaited<ReturnType<typeof updateUserSettings>>;
|
||||
vi.mocked(updateUserSettings).mockReturnValueOnce(first.promise).mockResolvedValueOnce(success);
|
||||
return () => first.resolve(success);
|
||||
}
|
||||
|
||||
function expectPersistenceCalls(mode: PersistenceMode, count: number) {
|
||||
if (mode === WORKSPACE_MODE) {
|
||||
expect(updateGitHubWorkspaceSettings).toHaveBeenCalledTimes(count);
|
||||
} else {
|
||||
expect(updateUserSettings).toHaveBeenCalledTimes(count);
|
||||
}
|
||||
}
|
||||
|
||||
function expectLastPersisted(mode: PersistenceMode, presets: SavedPreset[]) {
|
||||
if (mode === WORKSPACE_MODE) {
|
||||
expect(updateGitHubWorkspaceSettings).toHaveBeenLastCalledWith({
|
||||
workspace_id: WORKSPACE_ID,
|
||||
saved_presets: presets,
|
||||
});
|
||||
} else {
|
||||
expect(updateUserSettings).toHaveBeenLastCalledWith({ github_saved_presets: presets });
|
||||
}
|
||||
}
|
||||
|
||||
async function renderLoaded(mode: PersistenceMode, presets: SavedPreset[]) {
|
||||
mockHydration(mode, presets);
|
||||
const hook = renderHook(() => useSavedPresets(mode === WORKSPACE_MODE ? WORKSPACE_ID : null));
|
||||
await waitFor(() => expect(hook.result.current.presets).toEqual(presets));
|
||||
return hook;
|
||||
}
|
||||
|
||||
async function expectPendingDefaultTargetRemoval(mode: PersistenceMode) {
|
||||
const prA = { ...valid, isDefault: true };
|
||||
const prB = { ...valid, id: "pr-b", label: "PR B" };
|
||||
const resolveDefault = deferFirstPersistence(mode);
|
||||
const { result } = await renderLoaded(mode, [prA, prB]);
|
||||
|
||||
let defaultMutation!: Promise<boolean>;
|
||||
act(() => {
|
||||
defaultMutation = result.current.setDefault("pr", "pr-b");
|
||||
});
|
||||
await waitFor(() => expectPersistenceCalls(mode, 1));
|
||||
|
||||
let removeMutation!: Promise<boolean>;
|
||||
act(() => {
|
||||
removeMutation = result.current.remove("pr-b");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveDefault();
|
||||
await Promise.all([defaultMutation, removeMutation]);
|
||||
});
|
||||
await waitFor(() => expectPersistenceCalls(mode, 2));
|
||||
|
||||
const expected = [prA];
|
||||
expect(result.current.presets).toEqual(expected);
|
||||
expectLastPersisted(mode, expected);
|
||||
}
|
||||
|
||||
async function expectIndependentWorkspacePersistence() {
|
||||
const firstWorkspaceId = "ws-first";
|
||||
const secondWorkspaceId = "ws-second";
|
||||
const firstWrite = deferred<Awaited<ReturnType<typeof updateGitHubWorkspaceSettings>>>();
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockImplementation(async (workspaceId) => ({
|
||||
...workspaceSettings([valid]),
|
||||
workspace_id: workspaceId,
|
||||
}));
|
||||
vi.mocked(updateGitHubWorkspaceSettings).mockImplementation((settings) => {
|
||||
const response = { ...workspaceSettings(), workspace_id: settings.workspace_id };
|
||||
return settings.workspace_id === firstWorkspaceId
|
||||
? firstWrite.promise
|
||||
: Promise.resolve(response);
|
||||
});
|
||||
const firstHook = renderHook(() => useSavedPresets(firstWorkspaceId));
|
||||
const secondHook = renderHook(() => useSavedPresets(secondWorkspaceId));
|
||||
await waitFor(() => expect(firstHook.result.current.presets).toEqual([valid]));
|
||||
await waitFor(() => expect(secondHook.result.current.presets).toEqual([valid]));
|
||||
|
||||
let firstSave!: Promise<SavedPreset | null>;
|
||||
act(() => {
|
||||
firstSave = firstHook.result.current.save({
|
||||
kind: "pr",
|
||||
label: "First workspace",
|
||||
customQuery: "is:open",
|
||||
repoFilter: "",
|
||||
});
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(updateGitHubWorkspaceSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workspace_id: firstWorkspaceId }),
|
||||
),
|
||||
);
|
||||
|
||||
let secondSave!: Promise<SavedPreset | null>;
|
||||
act(() => {
|
||||
secondSave = secondHook.result.current.save({
|
||||
kind: "pr",
|
||||
label: "Second workspace",
|
||||
customQuery: "is:closed",
|
||||
repoFilter: "",
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(updateGitHubWorkspaceSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workspace_id: secondWorkspaceId }),
|
||||
),
|
||||
{ timeout: 250 },
|
||||
);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
firstWrite.resolve({ ...workspaceSettings(), workspace_id: firstWorkspaceId });
|
||||
await Promise.all([firstSave, secondSave]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function expectQueuedWorkspaceSaveIsolation() {
|
||||
const firstWorkspaceId = "ws-first";
|
||||
const secondWorkspaceId = "ws-second";
|
||||
const firstWorkspacePreset = { ...valid, label: "Workspace A" };
|
||||
const secondWorkspacePreset = { ...valid, id: "pr-b", label: "Workspace B" };
|
||||
const firstWrite = deferred<Awaited<ReturnType<typeof updateGitHubWorkspaceSettings>>>();
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockImplementation(async (workspaceId) => ({
|
||||
...workspaceSettings(
|
||||
workspaceId === firstWorkspaceId ? [firstWorkspacePreset] : [secondWorkspacePreset],
|
||||
),
|
||||
workspace_id: workspaceId,
|
||||
}));
|
||||
vi.mocked(updateGitHubWorkspaceSettings)
|
||||
.mockReturnValueOnce(firstWrite.promise)
|
||||
.mockResolvedValueOnce({ ...workspaceSettings(), workspace_id: firstWorkspaceId });
|
||||
const { result, rerender } = renderHook(({ workspaceId }) => useSavedPresets(workspaceId), {
|
||||
initialProps: { workspaceId: firstWorkspaceId },
|
||||
});
|
||||
await waitFor(() => expect(result.current.presets).toEqual([firstWorkspacePreset]));
|
||||
|
||||
let firstSave!: Promise<SavedPreset | null>;
|
||||
act(() => {
|
||||
firstSave = result.current.save({
|
||||
kind: "pr",
|
||||
label: "First queued",
|
||||
customQuery: "is:open",
|
||||
repoFilter: "",
|
||||
});
|
||||
});
|
||||
await waitFor(() => expect(updateGitHubWorkspaceSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
let secondSave!: Promise<SavedPreset | null>;
|
||||
act(() => {
|
||||
secondSave = result.current.save({
|
||||
kind: "pr",
|
||||
label: "Second queued",
|
||||
customQuery: "is:closed",
|
||||
repoFilter: "",
|
||||
});
|
||||
});
|
||||
rerender({ workspaceId: secondWorkspaceId });
|
||||
await waitFor(() => expect(result.current.presets).toEqual([secondWorkspacePreset]));
|
||||
|
||||
await act(async () => {
|
||||
firstWrite.resolve({ ...workspaceSettings(), workspace_id: firstWorkspaceId });
|
||||
await Promise.all([firstSave, secondSave]);
|
||||
});
|
||||
await waitFor(() => expect(updateGitHubWorkspaceSettings).toHaveBeenCalledTimes(2));
|
||||
|
||||
const secondPayload = vi.mocked(updateGitHubWorkspaceSettings).mock.calls[1]?.[0];
|
||||
expect(secondPayload?.workspace_id).toBe(firstWorkspaceId);
|
||||
expect((secondPayload?.saved_presets as SavedPreset[]).map((preset) => preset.label)).toEqual([
|
||||
"Workspace A",
|
||||
"First queued",
|
||||
"Second queued",
|
||||
]);
|
||||
expect(result.current.presets).toEqual([secondWorkspacePreset]);
|
||||
}
|
||||
|
||||
async function expectWorkspaceNavigationUnblocksNewMutations() {
|
||||
const firstWorkspaceId = "ws-first";
|
||||
const secondWorkspaceId = "ws-second";
|
||||
const firstWorkspacePreset = { ...valid, label: "Workspace A" };
|
||||
const secondWorkspacePreset = { ...valid, id: "pr-b", label: "Workspace B" };
|
||||
const firstWrite = deferred<Awaited<ReturnType<typeof updateGitHubWorkspaceSettings>>>();
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockImplementation(async (workspaceId) => ({
|
||||
...workspaceSettings(
|
||||
workspaceId === firstWorkspaceId ? [firstWorkspacePreset] : [secondWorkspacePreset],
|
||||
),
|
||||
workspace_id: workspaceId,
|
||||
}));
|
||||
vi.mocked(updateGitHubWorkspaceSettings)
|
||||
.mockReturnValueOnce(firstWrite.promise)
|
||||
.mockResolvedValue(workspaceSettings());
|
||||
const { result, rerender } = renderHook(({ workspaceId }) => useSavedPresets(workspaceId), {
|
||||
initialProps: { workspaceId: firstWorkspaceId },
|
||||
});
|
||||
await waitFor(() => expect(result.current.presets).toEqual([firstWorkspacePreset]));
|
||||
|
||||
let firstSave!: Promise<SavedPreset | null>;
|
||||
act(() => {
|
||||
firstSave = result.current.save({
|
||||
kind: "pr",
|
||||
label: "First pending",
|
||||
customQuery: "is:open",
|
||||
repoFilter: "",
|
||||
});
|
||||
});
|
||||
await waitFor(() => expect(updateGitHubWorkspaceSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender({ workspaceId: secondWorkspaceId });
|
||||
await waitFor(() => expect(result.current.presets).toEqual([secondWorkspacePreset]));
|
||||
let secondSave!: Promise<SavedPreset | null>;
|
||||
act(() => {
|
||||
secondSave = result.current.save({
|
||||
kind: "pr",
|
||||
label: "Second immediate",
|
||||
customQuery: "is:closed",
|
||||
repoFilter: "",
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(updateGitHubWorkspaceSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workspace_id: secondWorkspaceId }),
|
||||
),
|
||||
{ timeout: 250 },
|
||||
);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
firstWrite.resolve(workspaceSettings());
|
||||
await Promise.all([firstSave, secondSave]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function expectSaveThenRemove(mode: PersistenceMode) {
|
||||
const resolveSave = deferFirstPersistence(mode);
|
||||
const { result } = await renderLoaded(mode, [valid]);
|
||||
|
||||
let saveMutation!: Promise<SavedPreset | null>;
|
||||
act(() => {
|
||||
saveMutation = result.current.save({
|
||||
kind: "pr",
|
||||
label: "Saved while deleting",
|
||||
customQuery: "is:open",
|
||||
repoFilter: "",
|
||||
});
|
||||
});
|
||||
await waitFor(() => expectPersistenceCalls(mode, 1));
|
||||
|
||||
let removeMutation!: Promise<boolean>;
|
||||
act(() => {
|
||||
removeMutation = result.current.remove(valid.id);
|
||||
});
|
||||
|
||||
let created: SavedPreset | null = null;
|
||||
let removed = false;
|
||||
await act(async () => {
|
||||
resolveSave();
|
||||
[created, removed] = await Promise.all([saveMutation, removeMutation]);
|
||||
});
|
||||
await waitFor(() => expectPersistenceCalls(mode, 2));
|
||||
|
||||
const expected = [requirePreset(created)];
|
||||
expect(removed).toBe(true);
|
||||
expect(result.current.presets).toEqual(expected);
|
||||
expectLastPersisted(mode, expected);
|
||||
}
|
||||
|
||||
async function expectPortableDefaultsAcrossHookInstances() {
|
||||
const pr = { ...valid, isDefault: false };
|
||||
const issue = { ...valid, id: "issue-a", kind: "issue" as const, isDefault: false };
|
||||
const resolveFirst = deferFirstPersistence(PORTABLE_USER_MODE);
|
||||
mockHydration(PORTABLE_USER_MODE, [pr, issue]);
|
||||
const first = renderHook(() => useSavedPresets());
|
||||
const second = renderHook(() => useSavedPresets());
|
||||
await waitFor(() => expect(first.result.current.presets).toEqual([pr, issue]));
|
||||
await waitFor(() => expect(second.result.current.presets).toEqual([pr, issue]));
|
||||
|
||||
let firstMutation!: Promise<boolean>;
|
||||
act(() => {
|
||||
firstMutation = first.result.current.setDefault("pr", pr.id);
|
||||
});
|
||||
await waitFor(() => expectPersistenceCalls(PORTABLE_USER_MODE, 1));
|
||||
|
||||
let secondMutation!: Promise<boolean>;
|
||||
act(() => {
|
||||
secondMutation = second.result.current.setDefault("issue", issue.id);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveFirst();
|
||||
await Promise.all([firstMutation, secondMutation]);
|
||||
});
|
||||
await waitFor(() => expectPersistenceCalls(PORTABLE_USER_MODE, 2));
|
||||
|
||||
const expected = [
|
||||
{ ...pr, isDefault: true },
|
||||
{ ...issue, isDefault: true },
|
||||
];
|
||||
expectLastPersisted(PORTABLE_USER_MODE, expected);
|
||||
expect(first.result.current.presets).toEqual(expected);
|
||||
expect(second.result.current.presets).toEqual(expected);
|
||||
}
|
||||
|
||||
async function expectPortableDefaultLastWriteWins() {
|
||||
const prA = { ...valid, isDefault: false };
|
||||
const prB = { ...valid, id: "pr-b", label: "PR B", isDefault: false };
|
||||
const resolveFirst = deferFirstPersistence(PORTABLE_USER_MODE);
|
||||
mockHydration(PORTABLE_USER_MODE, [prA, prB]);
|
||||
const first = renderHook(() => useSavedPresets());
|
||||
const second = renderHook(() => useSavedPresets());
|
||||
await waitFor(() => expect(first.result.current.presets).toEqual([prA, prB]));
|
||||
await waitFor(() => expect(second.result.current.presets).toEqual([prA, prB]));
|
||||
|
||||
let firstMutation!: Promise<boolean>;
|
||||
act(() => {
|
||||
firstMutation = first.result.current.setDefault("pr", prA.id);
|
||||
});
|
||||
await waitFor(() => expectPersistenceCalls(PORTABLE_USER_MODE, 1));
|
||||
|
||||
let secondMutation!: Promise<boolean>;
|
||||
act(() => {
|
||||
secondMutation = second.result.current.setDefault("pr", prB.id);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveFirst();
|
||||
await Promise.all([firstMutation, secondMutation]);
|
||||
});
|
||||
await waitFor(() => expectPersistenceCalls(PORTABLE_USER_MODE, 2));
|
||||
|
||||
const expected = [
|
||||
{ ...prA, isDefault: false },
|
||||
{ ...prB, isDefault: true },
|
||||
];
|
||||
expectLastPersisted(PORTABLE_USER_MODE, expected);
|
||||
expect(first.result.current.presets).toEqual(expected);
|
||||
expect(second.result.current.presets).toEqual(expected);
|
||||
}
|
||||
|
||||
async function expectWorkspaceResetDetachesStaleMutation() {
|
||||
const firstWrite = deferred<Awaited<ReturnType<typeof updateGitHubWorkspaceSettings>>>();
|
||||
vi.mocked(updateGitHubWorkspaceSettings)
|
||||
.mockReturnValueOnce(firstWrite.promise)
|
||||
.mockResolvedValue(workspaceSettings());
|
||||
const firstHook = await renderLoaded(WORKSPACE_MODE, [valid]);
|
||||
|
||||
let firstSave!: Promise<SavedPreset | null>;
|
||||
act(() => {
|
||||
firstSave = firstHook.result.current.save({
|
||||
kind: "pr",
|
||||
label: "First write",
|
||||
customQuery: "is:open",
|
||||
repoFilter: "",
|
||||
});
|
||||
});
|
||||
await waitFor(() => expect(updateGitHubWorkspaceSettings).toHaveBeenCalledTimes(1));
|
||||
firstHook.unmount();
|
||||
|
||||
__resetSnapshotForTests();
|
||||
const secondHook = await renderLoaded(WORKSPACE_MODE, [valid]);
|
||||
let secondSave!: Promise<SavedPreset | null>;
|
||||
act(() => {
|
||||
secondSave = secondHook.result.current.save({
|
||||
kind: "pr",
|
||||
label: "Second write",
|
||||
customQuery: "is:closed",
|
||||
repoFilter: "",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(updateGitHubWorkspaceSettings).toHaveBeenCalledTimes(2));
|
||||
await act(async () => {
|
||||
firstWrite.resolve(workspaceSettings());
|
||||
await Promise.all([firstSave, secondSave]);
|
||||
});
|
||||
}
|
||||
|
||||
async function expectPortableResetDetachesStaleMutation() {
|
||||
const resolveFirst = deferFirstPersistence(PORTABLE_USER_MODE);
|
||||
const firstHook = await renderLoaded(PORTABLE_USER_MODE, [valid]);
|
||||
|
||||
let firstDefault!: Promise<boolean>;
|
||||
act(() => {
|
||||
firstDefault = firstHook.result.current.setDefault("pr", valid.id);
|
||||
});
|
||||
await waitFor(() => expectPersistenceCalls(PORTABLE_USER_MODE, 1));
|
||||
firstHook.unmount();
|
||||
|
||||
__resetSnapshotForTests();
|
||||
const freshPreset = { ...valid, label: "Fresh test state" };
|
||||
const secondHook = await renderLoaded(PORTABLE_USER_MODE, [freshPreset]);
|
||||
|
||||
await act(async () => {
|
||||
resolveFirst();
|
||||
await firstDefault;
|
||||
});
|
||||
|
||||
expect(secondHook.result.current.presets).toEqual([freshPreset]);
|
||||
}
|
||||
|
||||
function resetMutationTestState() {
|
||||
__resetSnapshotForTests();
|
||||
vi.mocked(fetchUserSettings).mockReset();
|
||||
vi.mocked(updateUserSettings).mockReset();
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockReset();
|
||||
vi.mocked(updateGitHubWorkspaceSettings).mockReset();
|
||||
}
|
||||
|
||||
describe("useSavedPresets mutation ordering", () => {
|
||||
beforeEach(resetMutationTestState);
|
||||
|
||||
it.each(MODES)("preserves a %s save while a default update is pending", async (mode) => {
|
||||
const prA = { ...valid, isDefault: true };
|
||||
const prB = { ...valid, id: "pr-b", label: "PR B" };
|
||||
const resolveDefault = deferFirstPersistence(mode);
|
||||
const { result } = await renderLoaded(mode, [prA, prB]);
|
||||
|
||||
let defaultMutation!: Promise<boolean>;
|
||||
act(() => {
|
||||
defaultMutation = result.current.setDefault("pr", "pr-b");
|
||||
});
|
||||
await waitFor(() => expectPersistenceCalls(mode, 1));
|
||||
|
||||
let saveMutation!: Promise<SavedPreset | null>;
|
||||
act(() => {
|
||||
saveMutation = result.current.save({
|
||||
kind: "pr",
|
||||
label: "Saved while pending",
|
||||
customQuery: "is:open",
|
||||
repoFilter: "kdlbs/kandev",
|
||||
});
|
||||
});
|
||||
|
||||
let created: SavedPreset | null = null;
|
||||
await act(async () => {
|
||||
resolveDefault();
|
||||
[, created] = await Promise.all([defaultMutation, saveMutation]);
|
||||
});
|
||||
await waitFor(() => expectPersistenceCalls(mode, 2));
|
||||
|
||||
const saved = requirePreset(created);
|
||||
const expected = [{ ...prA, isDefault: false }, { ...prB, isDefault: true }, saved];
|
||||
expect(result.current.presets).toEqual(expected);
|
||||
expectLastPersisted(mode, expected);
|
||||
});
|
||||
|
||||
it.each(MODES)("preserves a %s delete while a default update is pending", async (mode) => {
|
||||
const prA = { ...valid, isDefault: true };
|
||||
const prB = { ...valid, id: "pr-b", label: "PR B" };
|
||||
const resolveDefault = deferFirstPersistence(mode);
|
||||
const { result } = await renderLoaded(mode, [prA, prB]);
|
||||
|
||||
let defaultMutation!: Promise<boolean>;
|
||||
act(() => {
|
||||
defaultMutation = result.current.setDefault("pr", "pr-b");
|
||||
});
|
||||
await waitFor(() => expectPersistenceCalls(mode, 1));
|
||||
|
||||
let removeMutation!: Promise<boolean>;
|
||||
act(() => {
|
||||
removeMutation = result.current.remove("pr-a");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveDefault();
|
||||
await Promise.all([defaultMutation, removeMutation]);
|
||||
});
|
||||
await waitFor(() => expectPersistenceCalls(mode, 2));
|
||||
|
||||
const expected = [{ ...prB, isDefault: true }];
|
||||
expect(result.current.presets).toEqual(expected);
|
||||
expectLastPersisted(mode, expected);
|
||||
});
|
||||
|
||||
it.each(MODES)("applies a %s delete while a save is pending", expectSaveThenRemove);
|
||||
|
||||
it(
|
||||
"serializes portable defaults across hook instances",
|
||||
expectPortableDefaultsAcrossHookInstances,
|
||||
);
|
||||
|
||||
it("last portable default wins across hook instances", expectPortableDefaultLastWriteWins);
|
||||
|
||||
it.each(MODES)("removes a pending %s default target", expectPendingDefaultTargetRemoval);
|
||||
|
||||
it("persists different workspaces independently", expectIndependentWorkspacePersistence);
|
||||
|
||||
it("keeps queued saves scoped across workspace navigation", expectQueuedWorkspaceSaveIsolation);
|
||||
|
||||
it("new workspace skips the old mutation queue", expectWorkspaceNavigationUnblocksNewMutations);
|
||||
|
||||
it(
|
||||
"detaches future workspace writes from a stale test queue",
|
||||
expectWorkspaceResetDetachesStaleMutation,
|
||||
);
|
||||
|
||||
it(
|
||||
"ignores stale portable outcomes after a test reset",
|
||||
expectPortableResetDetachesStaleMutation,
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { fetchUserSettings, updateUserSettings } from "@/lib/api/domains/settings-api";
|
||||
import {
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
} from "@/lib/api/domains/github-api";
|
||||
import { __resetSnapshotForTests, useSavedPresets, type SavedPreset } from "./use-saved-presets";
|
||||
|
||||
const STORAGE_KEY = "kandev:github-presets:v1";
|
||||
const WORKSPACE_ID = "ws-1";
|
||||
const SETTINGS_TIMESTAMP = "2026-01-01T00:00:00Z";
|
||||
const SETTINGS_DOWN = "settings down";
|
||||
|
||||
vi.mock("@/lib/api/domains/settings-api", () => ({
|
||||
fetchUserSettings: vi.fn(),
|
||||
@@ -21,22 +21,18 @@ vi.mock("@/lib/api/domains/github-api", () => ({
|
||||
updateGitHubWorkspaceSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
function set(raw: string | null) {
|
||||
if (raw === null) window.localStorage.removeItem(STORAGE_KEY);
|
||||
else window.localStorage.setItem(STORAGE_KEY, raw);
|
||||
}
|
||||
|
||||
const valid: SavedPreset = {
|
||||
const legacyValid = {
|
||||
id: "p_1",
|
||||
kind: "pr",
|
||||
kind: "pr" as const,
|
||||
label: "My PRs",
|
||||
customQuery: "author:@me",
|
||||
repoFilter: "",
|
||||
createdAt: SETTINGS_TIMESTAMP,
|
||||
};
|
||||
|
||||
const valid: SavedPreset = { ...legacyValid, isDefault: false };
|
||||
|
||||
function resetTestState() {
|
||||
window.localStorage.clear();
|
||||
__resetSnapshotForTests();
|
||||
vi.mocked(fetchUserSettings).mockReset();
|
||||
vi.mocked(updateUserSettings).mockReset();
|
||||
@@ -59,13 +55,38 @@ function workspaceSettings(
|
||||
} as Awaited<ReturnType<typeof fetchGitHubWorkspaceSettings>>;
|
||||
}
|
||||
|
||||
async function expectRandomUuidPresetId() {
|
||||
const uuid = "123e4567-e89b-42d3-a456-426614174000";
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(uuid);
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockResolvedValue(workspaceSettings());
|
||||
vi.mocked(updateGitHubWorkspaceSettings).mockResolvedValue(workspaceSettings());
|
||||
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
|
||||
await waitFor(() => expect(result.current.presets).toEqual([]));
|
||||
|
||||
let created: SavedPreset | null = null;
|
||||
await act(async () => {
|
||||
created = await result.current.save({
|
||||
kind: "pr",
|
||||
label: "UUID preset",
|
||||
customQuery: "is:open",
|
||||
repoFilter: "",
|
||||
});
|
||||
});
|
||||
|
||||
expect((created as SavedPreset | null)?.id).toBe(`p_${uuid}`);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useSavedPresets", () => {
|
||||
beforeEach(() => {
|
||||
resetTestState();
|
||||
});
|
||||
|
||||
it("ignores stale local presets when backend settings are empty", async () => {
|
||||
set(JSON.stringify([valid]));
|
||||
it("uses empty backend settings without writing", async () => {
|
||||
vi.mocked(fetchUserSettings).mockResolvedValue({
|
||||
settings: { github_saved_presets: [] },
|
||||
} as Awaited<ReturnType<typeof fetchUserSettings>>);
|
||||
@@ -75,6 +96,14 @@ describe("useSavedPresets", () => {
|
||||
await waitFor(() => expect(result.current.presets).toEqual([]));
|
||||
expect(updateUserSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects the test reset in production", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
|
||||
expect(() => __resetSnapshotForTests()).toThrow(
|
||||
"__resetSnapshotForTests must not be called in production",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSavedPresets workspace sync", () => {
|
||||
@@ -82,8 +111,7 @@ describe("useSavedPresets workspace sync", () => {
|
||||
resetTestState();
|
||||
});
|
||||
|
||||
it("ignores stale local presets when workspace settings are empty", async () => {
|
||||
set(JSON.stringify([valid]));
|
||||
it("uses empty workspace settings without writing", async () => {
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockResolvedValue(workspaceSettings());
|
||||
|
||||
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
|
||||
@@ -92,9 +120,8 @@ describe("useSavedPresets workspace sync", () => {
|
||||
expect(updateGitHubWorkspaceSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not migrate local presets over existing workspace presets", async () => {
|
||||
it("uses existing workspace presets without writing", async () => {
|
||||
const server = { ...valid, id: "p_server", label: "Server" };
|
||||
set(JSON.stringify([valid]));
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockResolvedValue(workspaceSettings([server]));
|
||||
|
||||
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
|
||||
@@ -103,14 +130,16 @@ describe("useSavedPresets workspace sync", () => {
|
||||
expect(updateGitHubWorkspaceSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not save while workspace presets are still loading", () => {
|
||||
it("uses a random UUID for new preset ids", expectRandomUuidPresetId);
|
||||
|
||||
it("does not save while workspace presets are still loading", async () => {
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockReturnValue(new Promise(() => {}));
|
||||
|
||||
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
|
||||
|
||||
let created: SavedPreset | null = null;
|
||||
act(() => {
|
||||
created = result.current.save({
|
||||
let created: SavedPreset | null = valid;
|
||||
await act(async () => {
|
||||
created = await result.current.save({
|
||||
kind: "pr",
|
||||
label: "Loading",
|
||||
customQuery: "is:open",
|
||||
@@ -123,15 +152,15 @@ describe("useSavedPresets workspace sync", () => {
|
||||
});
|
||||
|
||||
it("does not save after workspace presets fail to load", async () => {
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockRejectedValue(new Error("settings down"));
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockRejectedValue(new Error(SETTINGS_DOWN));
|
||||
|
||||
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
|
||||
|
||||
await waitFor(() => expect(fetchGitHubWorkspaceSettings).toHaveBeenCalled());
|
||||
|
||||
let created: SavedPreset | null = valid;
|
||||
act(() => {
|
||||
created = result.current.save({
|
||||
await act(async () => {
|
||||
created = await result.current.save({
|
||||
kind: "pr",
|
||||
label: "Failed load",
|
||||
customQuery: "is:open",
|
||||
@@ -143,33 +172,105 @@ describe("useSavedPresets workspace sync", () => {
|
||||
expect(updateGitHubWorkspaceSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not remove while workspace presets are still loading", () => {
|
||||
it("does not remove while workspace presets are still loading", async () => {
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockReturnValue(new Promise(() => {}));
|
||||
|
||||
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
|
||||
|
||||
act(() => {
|
||||
result.current.remove("p_1");
|
||||
await act(async () => {
|
||||
await result.current.remove("p_1");
|
||||
});
|
||||
|
||||
expect(updateGitHubWorkspaceSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not set a default while workspace presets are still loading", async () => {
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockReturnValue(new Promise(() => {}));
|
||||
|
||||
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
|
||||
|
||||
await expect(result.current.setDefault("pr", "p_1")).resolves.toBe(false);
|
||||
expect(updateGitHubWorkspaceSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps mutation callbacks stable across optimistic workspace updates", async () => {
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockResolvedValue(workspaceSettings([valid]));
|
||||
vi.mocked(updateGitHubWorkspaceSettings).mockResolvedValue(workspaceSettings());
|
||||
|
||||
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
|
||||
await waitFor(() => expect(result.current.presets).toEqual([valid]));
|
||||
const callbacks = {
|
||||
save: result.current.save,
|
||||
remove: result.current.remove,
|
||||
setDefault: result.current.setDefault,
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
await result.current.save({
|
||||
kind: "pr",
|
||||
label: "Stable callbacks",
|
||||
customQuery: "is:open",
|
||||
repoFilter: "",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.save).toBe(callbacks.save);
|
||||
expect(result.current.remove).toBe(callbacks.remove);
|
||||
expect(result.current.setDefault).toBe(callbacks.setDefault);
|
||||
});
|
||||
|
||||
it("does not remove after workspace presets fail to load", async () => {
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockRejectedValue(new Error("settings down"));
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockRejectedValue(new Error(SETTINGS_DOWN));
|
||||
|
||||
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
|
||||
|
||||
await waitFor(() => expect(fetchGitHubWorkspaceSettings).toHaveBeenCalled());
|
||||
|
||||
act(() => {
|
||||
result.current.remove("p_1");
|
||||
await act(async () => {
|
||||
await result.current.remove("p_1");
|
||||
});
|
||||
|
||||
expect(updateGitHubWorkspaceSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSavedPresets workspace mutation failures", () => {
|
||||
beforeEach(() => {
|
||||
resetTestState();
|
||||
});
|
||||
|
||||
it("rolls back an optimistic save when persistence fails", async () => {
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockResolvedValue(workspaceSettings([valid]));
|
||||
vi.mocked(updateGitHubWorkspaceSettings).mockRejectedValue(new Error(SETTINGS_DOWN));
|
||||
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
|
||||
await waitFor(() => expect(result.current.presets).toEqual([valid]));
|
||||
|
||||
await act(async () => {
|
||||
await expect(
|
||||
result.current.save({
|
||||
kind: "pr",
|
||||
label: "Unsaved",
|
||||
customQuery: "is:open",
|
||||
repoFilter: "",
|
||||
}),
|
||||
).rejects.toThrow(SETTINGS_DOWN);
|
||||
});
|
||||
expect(result.current.presets).toEqual([valid]);
|
||||
});
|
||||
|
||||
it("rolls back an optimistic removal when persistence fails", async () => {
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockResolvedValue(workspaceSettings([valid]));
|
||||
vi.mocked(updateGitHubWorkspaceSettings).mockRejectedValue(new Error(SETTINGS_DOWN));
|
||||
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
|
||||
await waitFor(() => expect(result.current.presets).toEqual([valid]));
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.remove(valid.id)).rejects.toThrow(SETTINGS_DOWN);
|
||||
});
|
||||
expect(result.current.presets).toEqual([valid]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSavedPresets repository defaults", () => {
|
||||
beforeEach(() => {
|
||||
resetTestState();
|
||||
@@ -183,8 +284,9 @@ describe("useSavedPresets repository defaults", () => {
|
||||
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
|
||||
|
||||
await waitFor(() => expect(result.current.presets).toEqual([server]));
|
||||
act(() => {
|
||||
result.current.save({
|
||||
let created: SavedPreset | null = null;
|
||||
await act(async () => {
|
||||
created = await result.current.save({
|
||||
kind: "pr",
|
||||
label: "Kandev PRs",
|
||||
customQuery: "is:open",
|
||||
@@ -192,6 +294,8 @@ describe("useSavedPresets repository defaults", () => {
|
||||
});
|
||||
});
|
||||
|
||||
expect((created as SavedPreset | null)?.isDefault).toBe(false);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateGitHubWorkspaceSettings).toHaveBeenCalledWith({
|
||||
workspace_id: WORKSPACE_ID,
|
||||
@@ -236,7 +340,128 @@ describe("useSavedPresets repository defaults", () => {
|
||||
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.presets).toEqual([{ ...serverPreset, repoFilter: "" }]),
|
||||
expect(result.current.presets).toEqual([
|
||||
{ ...serverPreset, repoFilter: "", isDefault: false },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes a legacy saved query to a non-default view", async () => {
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockResolvedValue(workspaceSettings([legacyValid]));
|
||||
|
||||
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.presets).toEqual([{ ...legacyValid, isDefault: false }]),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps only the first persisted default for each result kind", async () => {
|
||||
const issue = { ...valid, id: "issue-1", kind: "issue", isDefault: true };
|
||||
const duplicates = [
|
||||
{ ...valid, id: "pr-1", isDefault: true },
|
||||
{ ...valid, id: "pr-2", isDefault: true },
|
||||
issue,
|
||||
];
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockResolvedValue(workspaceSettings(duplicates));
|
||||
|
||||
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.presets.map(({ id, isDefault }) => ({ id, isDefault }))).toEqual([
|
||||
{ id: "pr-1", isDefault: true },
|
||||
{ id: "pr-2", isDefault: false },
|
||||
{ id: "issue-1", isDefault: true },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSavedPresets default persistence", () => {
|
||||
beforeEach(() => {
|
||||
resetTestState();
|
||||
});
|
||||
|
||||
it("publishes a workspace default only after persistence succeeds", async () => {
|
||||
const prA = { ...valid, id: "pr-a", isDefault: true };
|
||||
const prB = { ...valid, id: "pr-b" };
|
||||
const issue = { ...valid, id: "issue-a", kind: "issue" as const, isDefault: true };
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockResolvedValue(workspaceSettings([prA, prB, issue]));
|
||||
let resolveUpdate!: (value: Awaited<ReturnType<typeof updateGitHubWorkspaceSettings>>) => void;
|
||||
vi.mocked(updateGitHubWorkspaceSettings).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveUpdate = resolve;
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
|
||||
await waitFor(() => expect(result.current.presets).toHaveLength(3));
|
||||
|
||||
let mutation!: Promise<boolean>;
|
||||
act(() => {
|
||||
mutation = result.current.setDefault("pr", "pr-b");
|
||||
});
|
||||
expect(result.current.presets.map((preset) => preset.isDefault)).toEqual([true, false, true]);
|
||||
|
||||
let persisted!: boolean;
|
||||
await act(async () => {
|
||||
resolveUpdate(workspaceSettings());
|
||||
persisted = await mutation;
|
||||
});
|
||||
|
||||
expect(persisted).toBe(true);
|
||||
expect(updateGitHubWorkspaceSettings).toHaveBeenCalledWith({
|
||||
workspace_id: WORKSPACE_ID,
|
||||
saved_presets: [{ ...prA, isDefault: false }, { ...prB, isDefault: true }, issue],
|
||||
});
|
||||
expect(result.current.presets.map((preset) => preset.isDefault)).toEqual([false, true, true]);
|
||||
});
|
||||
|
||||
it("retains the prior workspace default when persistence fails", async () => {
|
||||
const prA = { ...valid, id: "pr-a", isDefault: true };
|
||||
const prB = { ...valid, id: "pr-b" };
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockResolvedValue(workspaceSettings([prA, prB]));
|
||||
vi.mocked(updateGitHubWorkspaceSettings).mockRejectedValue(new Error(SETTINGS_DOWN));
|
||||
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
|
||||
await waitFor(() => expect(result.current.presets).toHaveLength(2));
|
||||
|
||||
await expect(result.current.setDefault("pr", "pr-b")).rejects.toThrow(SETTINGS_DOWN);
|
||||
|
||||
expect(result.current.presets.map((preset) => preset.isDefault)).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it("persists and publishes a portable user default", async () => {
|
||||
const prA = { ...valid, id: "pr-a" };
|
||||
vi.mocked(fetchUserSettings).mockResolvedValue({
|
||||
settings: { github_saved_presets: [prA] },
|
||||
} as Awaited<ReturnType<typeof fetchUserSettings>>);
|
||||
vi.mocked(updateUserSettings).mockResolvedValue(
|
||||
undefined as unknown as Awaited<ReturnType<typeof updateUserSettings>>,
|
||||
);
|
||||
const { result } = renderHook(() => useSavedPresets());
|
||||
await waitFor(() => expect(result.current.presets).toEqual([prA]));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.setDefault("pr", "pr-a");
|
||||
});
|
||||
|
||||
expect(updateUserSettings).toHaveBeenCalledWith({
|
||||
github_saved_presets: [{ ...prA, isDefault: true }],
|
||||
});
|
||||
expect(result.current.presets).toEqual([{ ...prA, isDefault: true }]);
|
||||
});
|
||||
|
||||
it("retains the prior portable user default when persistence fails", async () => {
|
||||
const prA = { ...valid, id: "pr-a", isDefault: true };
|
||||
const prB = { ...valid, id: "pr-b" };
|
||||
vi.mocked(fetchUserSettings).mockResolvedValue({
|
||||
settings: { github_saved_presets: [prA, prB] },
|
||||
} as Awaited<ReturnType<typeof fetchUserSettings>>);
|
||||
vi.mocked(updateUserSettings).mockRejectedValue(new Error(SETTINGS_DOWN));
|
||||
const { result } = renderHook(() => useSavedPresets());
|
||||
await waitFor(() => expect(result.current.presets).toHaveLength(2));
|
||||
|
||||
await expect(result.current.setDefault("pr", "pr-b")).rejects.toThrow(SETTINGS_DOWN);
|
||||
|
||||
expect(result.current.presets.map((preset) => preset.isDefault)).toEqual([true, false]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,15 +7,14 @@ import {
|
||||
updateGitHubWorkspaceSettings,
|
||||
} from "@/lib/api/domains/github-api";
|
||||
import { createQueuedUserSettingsSync } from "@/lib/user-settings-sync";
|
||||
import {
|
||||
readSavedPresets,
|
||||
setSavedPresetDefault,
|
||||
type SavedPreset,
|
||||
type SavedPresetKind,
|
||||
} from "./saved-preset-model";
|
||||
|
||||
export type SavedPreset = {
|
||||
id: string;
|
||||
kind: "pr" | "issue";
|
||||
label: string;
|
||||
customQuery: string;
|
||||
repoFilter: string;
|
||||
createdAt: string;
|
||||
};
|
||||
export type { SavedPreset } from "./saved-preset-model";
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let snapshot: SavedPreset[] = [];
|
||||
@@ -43,36 +42,33 @@ function publish(next: SavedPreset[]) {
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
function readServerPresets(value: unknown): SavedPreset[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
return value.flatMap((candidate): SavedPreset[] => {
|
||||
if (typeof candidate !== "object" || candidate === null) return [];
|
||||
const preset = candidate as Record<string, unknown>;
|
||||
const kind = preset.kind;
|
||||
if (
|
||||
typeof preset.id !== "string" ||
|
||||
(kind !== "pr" && kind !== "issue") ||
|
||||
typeof preset.label !== "string"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
...(preset as SavedPreset),
|
||||
repoFilter: typeof preset.repoFilter === "string" ? preset.repoFilter : "",
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
const syncServer = createQueuedUserSettingsSync<SavedPreset[]>((next) => ({
|
||||
github_saved_presets: next,
|
||||
}));
|
||||
// Portable presets use a module snapshot shared by every hook instance, so queue the
|
||||
// complete read/persist/publish mutation rather than only its already-built payload.
|
||||
let portableMutationQueue = Promise.resolve();
|
||||
let portableMutationGeneration = 0;
|
||||
|
||||
let workspaceSyncQueue = Promise.resolve();
|
||||
function queuePortableMutation(mutation: () => Promise<void>): Promise<void> {
|
||||
const generation = portableMutationGeneration;
|
||||
const queued = portableMutationQueue
|
||||
.catch(() => undefined)
|
||||
.then(() => {
|
||||
if (generation !== portableMutationGeneration) return;
|
||||
return mutation();
|
||||
});
|
||||
portableMutationQueue = queued;
|
||||
return queued;
|
||||
}
|
||||
|
||||
// Shared across hook instances so writes for the same workspace stay ordered.
|
||||
// Separate workspaces remain independent during navigation between them.
|
||||
const workspaceSyncQueues = new Map<string, Promise<void>>();
|
||||
|
||||
function syncWorkspaceSavedPresets(workspaceId: string, next: SavedPreset[]): Promise<void> {
|
||||
workspaceSyncQueue = workspaceSyncQueue
|
||||
const previous = workspaceSyncQueues.get(workspaceId) ?? Promise.resolve();
|
||||
const queued = previous
|
||||
.catch(() => undefined)
|
||||
.then(() =>
|
||||
updateGitHubWorkspaceSettings({
|
||||
@@ -80,12 +76,24 @@ function syncWorkspaceSavedPresets(workspaceId: string, next: SavedPreset[]): Pr
|
||||
saved_presets: next,
|
||||
}).then(() => undefined),
|
||||
);
|
||||
return workspaceSyncQueue;
|
||||
workspaceSyncQueues.set(workspaceId, queued);
|
||||
void queued
|
||||
.finally(() => {
|
||||
if (workspaceSyncQueues.get(workspaceId) === queued) workspaceSyncQueues.delete(workspaceId);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return queued;
|
||||
}
|
||||
|
||||
export function __resetSnapshotForTests() {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
throw new Error("__resetSnapshotForTests must not be called in production");
|
||||
}
|
||||
snapshot = [];
|
||||
snapshotVersion = 0;
|
||||
portableMutationGeneration += 1;
|
||||
portableMutationQueue = Promise.resolve();
|
||||
workspaceSyncQueues.clear();
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
@@ -96,8 +104,8 @@ function useUserSavedPresetsSync(enabled: boolean) {
|
||||
const initialVersion = snapshotVersion;
|
||||
fetchUserSettings({ cache: "no-store" })
|
||||
.then((response) => {
|
||||
const serverPresets = readServerPresets(response.settings.github_saved_presets);
|
||||
if (cancelled || !serverPresets || snapshotVersion !== initialVersion) return;
|
||||
const serverPresets = readSavedPresets(response.settings.github_saved_presets);
|
||||
if (cancelled || snapshotVersion !== initialVersion) return;
|
||||
publish(serverPresets);
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -121,11 +129,11 @@ function useWorkspaceSavedPresets(workspaceId: string | null) {
|
||||
fetchGitHubWorkspaceSettings(workspaceId)
|
||||
.then((settings) => {
|
||||
if (cancelled || seq !== writeSeq.current) return;
|
||||
const serverPresets = readServerPresets(settings.saved_presets) ?? [];
|
||||
const serverPresets = readSavedPresets(settings.saved_presets);
|
||||
setWorkspacePresets(serverPresets);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setWorkspacePresets(undefined);
|
||||
if (!cancelled && seq === writeSeq.current) setWorkspacePresets(undefined);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -138,47 +146,210 @@ function useWorkspaceSavedPresets(workspaceId: string | null) {
|
||||
return { workspacePresets, setWorkspacePresets: setWorkspacePresetsFromLocal };
|
||||
}
|
||||
|
||||
function discardSavedPreset(presets: SavedPreset[], id: string): SavedPreset[] {
|
||||
return presets.filter((preset) => preset.id !== id);
|
||||
}
|
||||
|
||||
function restoreSavedPreset(
|
||||
presets: SavedPreset[],
|
||||
preset: SavedPreset,
|
||||
originalIndex: number,
|
||||
): SavedPreset[] {
|
||||
if (presets.some((candidate) => candidate.id === preset.id)) return presets;
|
||||
const restored =
|
||||
preset.isDefault &&
|
||||
presets.some((candidate) => candidate.kind === preset.kind && candidate.isDefault)
|
||||
? { ...preset, isDefault: false }
|
||||
: preset;
|
||||
// Best-effort: the insertion point may shift if concurrent saves ran before rollback.
|
||||
const insertionIndex = Math.min(originalIndex, presets.length);
|
||||
return [...presets.slice(0, insertionIndex), restored, ...presets.slice(insertionIndex)];
|
||||
}
|
||||
|
||||
type SavedPresetMutationContext = {
|
||||
workspaceId: string | null;
|
||||
presets: SavedPreset[];
|
||||
portableGeneration: number;
|
||||
};
|
||||
|
||||
function readMutationPresets(context: SavedPresetMutationContext): SavedPreset[] {
|
||||
return context.workspaceId === null ? snapshot : context.presets;
|
||||
}
|
||||
|
||||
function persistSavedPresets(
|
||||
context: SavedPresetMutationContext,
|
||||
next: SavedPreset[],
|
||||
): Promise<void> {
|
||||
if (context.workspaceId !== null) {
|
||||
return syncWorkspaceSavedPresets(context.workspaceId, next);
|
||||
}
|
||||
return syncServer(next);
|
||||
}
|
||||
|
||||
function useSavedPresetMutationContext(
|
||||
workspaceId: string | null,
|
||||
activePresets: SavedPreset[],
|
||||
setWorkspacePresets: (next: SavedPreset[]) => void,
|
||||
) {
|
||||
const mutationContextRef = useRef<SavedPresetMutationContext>({
|
||||
workspaceId,
|
||||
presets: activePresets,
|
||||
portableGeneration: portableMutationGeneration,
|
||||
});
|
||||
if (
|
||||
mutationContextRef.current.workspaceId !== workspaceId ||
|
||||
mutationContextRef.current.portableGeneration !== portableMutationGeneration
|
||||
) {
|
||||
mutationContextRef.current = {
|
||||
workspaceId,
|
||||
presets: activePresets,
|
||||
portableGeneration: portableMutationGeneration,
|
||||
};
|
||||
} else {
|
||||
mutationContextRef.current.presets = activePresets;
|
||||
}
|
||||
const applyLocal = useCallback(
|
||||
(context: SavedPresetMutationContext, next: SavedPreset[]) => {
|
||||
if (
|
||||
context.workspaceId === null &&
|
||||
context.portableGeneration !== portableMutationGeneration
|
||||
) {
|
||||
return;
|
||||
}
|
||||
context.presets = next;
|
||||
if (mutationContextRef.current !== context) return;
|
||||
if (context.workspaceId !== null) setWorkspacePresets(next);
|
||||
else publish(next);
|
||||
},
|
||||
[setWorkspacePresets],
|
||||
);
|
||||
return { mutationContextRef, applyLocal };
|
||||
}
|
||||
|
||||
export function useSavedPresets(workspaceId: string | null = null) {
|
||||
const presets = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
const { workspacePresets, setWorkspacePresets } = useWorkspaceSavedPresets(workspaceId);
|
||||
const workspacePresetsRef = useRef(workspacePresets);
|
||||
workspacePresetsRef.current = workspacePresets;
|
||||
useUserSavedPresetsSync(!workspaceId);
|
||||
const activePresets = workspaceId ? (workspacePresets ?? []) : presets;
|
||||
const activePresets = workspaceId ? (workspacePresets ?? emptySnapshot) : presets;
|
||||
const { mutationContextRef, applyLocal } = useSavedPresetMutationContext(
|
||||
workspaceId,
|
||||
activePresets,
|
||||
setWorkspacePresets,
|
||||
);
|
||||
const mutationQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const previousWorkspaceIdRef = useRef(workspaceId);
|
||||
if (previousWorkspaceIdRef.current !== workspaceId) {
|
||||
previousWorkspaceIdRef.current = workspaceId;
|
||||
mutationQueueRef.current = Promise.resolve();
|
||||
}
|
||||
|
||||
const queueMutation = useCallback(
|
||||
(mutation: () => Promise<void>) => {
|
||||
if (workspaceId === null) return queuePortableMutation(mutation);
|
||||
const queued = mutationQueueRef.current.catch(() => undefined).then(mutation);
|
||||
mutationQueueRef.current = queued;
|
||||
return queued;
|
||||
},
|
||||
[workspaceId],
|
||||
);
|
||||
|
||||
/**
|
||||
* @returns The created preset, or null while workspace settings are unavailable.
|
||||
* @throws The persistence error after rolling back the optimistic update.
|
||||
*/
|
||||
const save = useCallback(
|
||||
(input: Omit<SavedPreset, "id" | "createdAt">) => {
|
||||
if (workspaceId && workspacePresets === undefined) return null;
|
||||
async (input: Omit<SavedPreset, "id" | "createdAt" | "isDefault">) => {
|
||||
if (workspaceId && workspacePresetsRef.current === undefined) return null;
|
||||
const context = mutationContextRef.current;
|
||||
const preset: SavedPreset = {
|
||||
...input,
|
||||
id: `p_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
id: `p_${crypto.randomUUID()}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
isDefault: false,
|
||||
};
|
||||
const next = [...activePresets, preset];
|
||||
if (workspaceId) {
|
||||
setWorkspacePresets(next);
|
||||
void syncWorkspaceSavedPresets(workspaceId, next).catch(() => {});
|
||||
const append = (current: SavedPreset[]) =>
|
||||
current.some((saved) => saved.id === preset.id) ? current : [...current, preset];
|
||||
applyLocal(context, append(readMutationPresets(context)));
|
||||
try {
|
||||
await queueMutation(async () => {
|
||||
await persistSavedPresets(context, append(readMutationPresets(context)));
|
||||
});
|
||||
return preset;
|
||||
} catch (error) {
|
||||
const current = readMutationPresets(context);
|
||||
const rolledBack = discardSavedPreset(current, preset.id);
|
||||
if (rolledBack.length !== current.length) applyLocal(context, rolledBack);
|
||||
throw error;
|
||||
}
|
||||
publish(next);
|
||||
void syncServer(next);
|
||||
return preset;
|
||||
},
|
||||
[activePresets, workspaceId, workspacePresets, setWorkspacePresets],
|
||||
[applyLocal, queueMutation, workspaceId],
|
||||
);
|
||||
|
||||
/**
|
||||
* @returns Whether an existing preset was removed.
|
||||
* @throws The persistence error; the removed preset is restored before rejection.
|
||||
*/
|
||||
const remove = useCallback(
|
||||
(id: string) => {
|
||||
if (workspaceId && workspacePresets === undefined) return;
|
||||
const next = activePresets.filter((p) => p.id !== id);
|
||||
if (workspaceId) {
|
||||
setWorkspacePresets(next);
|
||||
void syncWorkspaceSavedPresets(workspaceId, next).catch(() => {});
|
||||
return;
|
||||
async (id: string) => {
|
||||
if (workspaceId && workspacePresetsRef.current === undefined) return false;
|
||||
const context = mutationContextRef.current;
|
||||
const current = readMutationPresets(context);
|
||||
const originalIndex = current.findIndex((preset) => preset.id === id);
|
||||
if (originalIndex === -1) return false;
|
||||
const removedPreset = current[originalIndex];
|
||||
applyLocal(context, discardSavedPreset(current, id));
|
||||
try {
|
||||
await queueMutation(async () => {
|
||||
// Re-read the scoped context so concurrent saves appended after the optimistic remove
|
||||
// are included in the persisted payload; the removed id is already absent.
|
||||
await persistSavedPresets(context, discardSavedPreset(readMutationPresets(context), id));
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
const latest = readMutationPresets(context);
|
||||
const rolledBack = restoreSavedPreset(latest, removedPreset, originalIndex);
|
||||
if (rolledBack !== latest) applyLocal(context, rolledBack);
|
||||
throw error;
|
||||
}
|
||||
publish(next);
|
||||
void syncServer(next);
|
||||
},
|
||||
[activePresets, workspaceId, workspacePresets, setWorkspacePresets],
|
||||
[applyLocal, queueMutation, workspaceId],
|
||||
);
|
||||
|
||||
return { presets: activePresets, save, remove };
|
||||
/**
|
||||
* @returns Whether a changed default was persisted; loading and unchanged targets return false.
|
||||
* @remarks Queued same-kind calls run in invocation order, so the last changed target wins.
|
||||
* @throws The persistence error when the settings update fails.
|
||||
*/
|
||||
const setDefault = useCallback(
|
||||
async (kind: SavedPresetKind, id: string | null) => {
|
||||
if (workspaceId && workspacePresetsRef.current === undefined) return false;
|
||||
const context = mutationContextRef.current;
|
||||
let persisted = false;
|
||||
await queueMutation(async () => {
|
||||
const current = readMutationPresets(context);
|
||||
const next = setSavedPresetDefault(current, kind, id);
|
||||
if (next === current) {
|
||||
// No-op when already matched or the target was removed while queued.
|
||||
return;
|
||||
}
|
||||
await persistSavedPresets(context, next);
|
||||
persisted = true;
|
||||
// `setDefault` publishes only after persistence succeeds (no optimistic update).
|
||||
// Re-read the scoped context so that concurrent mutations applied
|
||||
// during the await (e.g. sibling-hook writes for portable user settings)
|
||||
// are merged in before publishing the new default state.
|
||||
// This is also a no-op when a concurrent remove deleted the target:
|
||||
// setSavedPresetDefault returns the same reference for a missing id.
|
||||
const latest = readMutationPresets(context);
|
||||
const remerged = setSavedPresetDefault(latest, kind, id);
|
||||
if (remerged !== latest) applyLocal(context, remerged);
|
||||
});
|
||||
return persisted;
|
||||
},
|
||||
[applyLocal, queueMutation, workspaceId],
|
||||
);
|
||||
|
||||
return { presets: activePresets, save, remove, setDefault };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { IconInbox } from "@tabler/icons-react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PresetOption } from "./search-bar";
|
||||
import type { SidebarSelection } from "./presets-sidebar";
|
||||
import type { SavedPreset } from "./saved-preset-model";
|
||||
import { useInitialSidebarSelection, useSidebarSelectionHandler } from "./use-sidebar-selection";
|
||||
|
||||
const prPreset: PresetOption = {
|
||||
value: "review-requested",
|
||||
label: "Review requested",
|
||||
filter: "review-requested:@me is:open",
|
||||
group: "inbox",
|
||||
icon: IconInbox,
|
||||
};
|
||||
|
||||
const issuePreset: PresetOption = {
|
||||
value: "assigned",
|
||||
label: "Assigned",
|
||||
filter: "assignee:@me is:open",
|
||||
group: "inbox",
|
||||
icon: IconInbox,
|
||||
};
|
||||
|
||||
const prPresets = [prPreset];
|
||||
const issuePresets = [issuePreset];
|
||||
const WORKSPACE_ID = "workspace-1";
|
||||
const KIND_SWITCH = "kind-switch" as const;
|
||||
|
||||
type HandlerRenderProps = {
|
||||
savedPresets: SavedPreset[];
|
||||
currentPrPresets: PresetOption[];
|
||||
currentIssuePresets: PresetOption[];
|
||||
currentKind: SidebarSelection["kind"];
|
||||
};
|
||||
|
||||
const prDefault: SavedPreset = {
|
||||
id: "saved-pr",
|
||||
kind: "pr",
|
||||
label: "Kandev PRs",
|
||||
customQuery: "author:@me is:open",
|
||||
repoFilter: "kdlbs/kandev",
|
||||
createdAt: "2026-08-07T00:00:00Z",
|
||||
isDefault: true,
|
||||
};
|
||||
|
||||
const issueDefault: SavedPreset = {
|
||||
...prDefault,
|
||||
id: "saved-issue",
|
||||
kind: "issue",
|
||||
label: "Kandev issues",
|
||||
customQuery: "assignee:@me label:bug",
|
||||
};
|
||||
|
||||
function createAutoResetControls() {
|
||||
let enabled = true;
|
||||
return {
|
||||
shouldAutoResetSearch: () => enabled,
|
||||
resetSearchOnWorkspaceChange: vi.fn(() => {
|
||||
enabled = true;
|
||||
}),
|
||||
disable: () => {
|
||||
enabled = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function expectNewWorkspaceDefaultAfterManualSelection() {
|
||||
const setQueryImmediate = vi.fn();
|
||||
const setRepoFilter = vi.fn();
|
||||
const autoReset = createAutoResetControls();
|
||||
const { result, rerender } = renderHook(
|
||||
({ workspaceId, savedPresets }) =>
|
||||
useInitialSidebarSelection({
|
||||
workspaceId,
|
||||
resolvedPrPresets: prPresets,
|
||||
shouldAutoResetSearch: autoReset.shouldAutoResetSearch,
|
||||
resetSearchOnWorkspaceChange: autoReset.resetSearchOnWorkspaceChange,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
savedPresets,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
workspaceId: WORKSPACE_ID,
|
||||
savedPresets: [] as SavedPreset[],
|
||||
},
|
||||
},
|
||||
);
|
||||
const manual: SidebarSelection = { kind: "pr", source: "preset", id: "manual" };
|
||||
act(() => {
|
||||
result.current.setUserSelection(manual);
|
||||
autoReset.disable();
|
||||
});
|
||||
autoReset.resetSearchOnWorkspaceChange.mockClear();
|
||||
|
||||
rerender({ workspaceId: "workspace-2", savedPresets: [prDefault] });
|
||||
|
||||
await waitFor(() => expect(result.current.selection.id).toBe(prDefault.id));
|
||||
expect(setQueryImmediate).toHaveBeenLastCalledWith(prDefault.customQuery);
|
||||
expect(setRepoFilter).toHaveBeenLastCalledWith(prDefault.repoFilter);
|
||||
expect(autoReset.resetSearchOnWorkspaceChange).toHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
function expectConfiguredPresetFallbackOnKindSwitch() {
|
||||
const setUserSelection = vi.fn();
|
||||
const setQueryImmediate = vi.fn();
|
||||
const setRepoFilter = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useSidebarSelectionHandler({
|
||||
currentKind: "pr",
|
||||
savedPresets: [],
|
||||
resolvedPrPresets: prPresets,
|
||||
resolvedIssuePresets: issuePresets,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
setUserSelection,
|
||||
markSearchInteracted: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => result.current({ kind: "issue", source: KIND_SWITCH }));
|
||||
|
||||
expect(setUserSelection).toHaveBeenCalledWith({
|
||||
kind: "issue",
|
||||
source: "preset",
|
||||
id: issuePreset.value,
|
||||
});
|
||||
expect(setQueryImmediate).toHaveBeenCalledWith(issuePreset.filter);
|
||||
expect(setRepoFilter).toHaveBeenCalledWith("");
|
||||
}
|
||||
|
||||
function expectSameKindSentinelNoop() {
|
||||
const setUserSelection = vi.fn();
|
||||
const setQueryImmediate = vi.fn();
|
||||
const setRepoFilter = vi.fn();
|
||||
const markSearchInteracted = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useSidebarSelectionHandler({
|
||||
currentKind: "pr",
|
||||
savedPresets: [],
|
||||
resolvedPrPresets: prPresets,
|
||||
resolvedIssuePresets: issuePresets,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
setUserSelection,
|
||||
markSearchInteracted,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => result.current({ kind: "pr", source: KIND_SWITCH }));
|
||||
|
||||
expect(markSearchInteracted).not.toHaveBeenCalled();
|
||||
expect(setUserSelection).not.toHaveBeenCalled();
|
||||
expect(setQueryImmediate).not.toHaveBeenCalled();
|
||||
expect(setRepoFilter).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
function expectStableHandlerWithRefreshedPresetCatalogs() {
|
||||
const setUserSelection = vi.fn();
|
||||
const setQueryImmediate = vi.fn();
|
||||
const setRepoFilter = vi.fn();
|
||||
const markSearchInteracted = vi.fn();
|
||||
const { result, rerender } = renderHook(
|
||||
({ savedPresets, currentPrPresets, currentIssuePresets, currentKind }: HandlerRenderProps) =>
|
||||
useSidebarSelectionHandler({
|
||||
currentKind,
|
||||
savedPresets,
|
||||
resolvedPrPresets: currentPrPresets,
|
||||
resolvedIssuePresets: currentIssuePresets,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
setUserSelection,
|
||||
markSearchInteracted,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
savedPresets: [prDefault],
|
||||
currentPrPresets: prPresets,
|
||||
currentIssuePresets: issuePresets,
|
||||
currentKind: "pr",
|
||||
},
|
||||
},
|
||||
);
|
||||
const initialHandler = result.current;
|
||||
|
||||
rerender({
|
||||
savedPresets: [prDefault, issueDefault],
|
||||
currentPrPresets: [...prPresets],
|
||||
currentIssuePresets: [...issuePresets],
|
||||
currentKind: "pr",
|
||||
});
|
||||
|
||||
expect(result.current).toBe(initialHandler);
|
||||
act(() => initialHandler({ kind: "issue", source: KIND_SWITCH }));
|
||||
expect(setUserSelection).toHaveBeenCalledWith({
|
||||
kind: "issue",
|
||||
source: "saved",
|
||||
id: issueDefault.id,
|
||||
});
|
||||
expect(setQueryImmediate).toHaveBeenCalledWith(issueDefault.customQuery);
|
||||
expect(setRepoFilter).toHaveBeenCalledWith(issueDefault.repoFilter);
|
||||
|
||||
const refreshedIssuePreset = {
|
||||
...issuePreset,
|
||||
value: "mentioned",
|
||||
filter: "mentions:@me is:open",
|
||||
};
|
||||
setUserSelection.mockClear();
|
||||
setQueryImmediate.mockClear();
|
||||
setRepoFilter.mockClear();
|
||||
rerender({
|
||||
savedPresets: [prDefault],
|
||||
currentPrPresets: [...prPresets],
|
||||
currentIssuePresets: [refreshedIssuePreset],
|
||||
currentKind: "pr",
|
||||
});
|
||||
expect(result.current).toBe(initialHandler);
|
||||
act(() => initialHandler({ kind: "issue", source: KIND_SWITCH }));
|
||||
expect(setUserSelection).toHaveBeenCalledWith({
|
||||
kind: "issue",
|
||||
source: "preset",
|
||||
id: refreshedIssuePreset.value,
|
||||
});
|
||||
expect(setQueryImmediate).toHaveBeenCalledWith(refreshedIssuePreset.filter);
|
||||
expect(setRepoFilter).toHaveBeenCalledWith("");
|
||||
|
||||
markSearchInteracted.mockClear();
|
||||
setUserSelection.mockClear();
|
||||
setQueryImmediate.mockClear();
|
||||
setRepoFilter.mockClear();
|
||||
rerender({
|
||||
savedPresets: [prDefault],
|
||||
currentPrPresets: [...prPresets],
|
||||
currentIssuePresets: [refreshedIssuePreset],
|
||||
currentKind: "issue",
|
||||
});
|
||||
expect(result.current).toBe(initialHandler);
|
||||
act(() => initialHandler({ kind: "issue", source: KIND_SWITCH }));
|
||||
expect(markSearchInteracted).not.toHaveBeenCalled();
|
||||
expect(setUserSelection).not.toHaveBeenCalled();
|
||||
expect(setQueryImmediate).not.toHaveBeenCalled();
|
||||
expect(setRepoFilter).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
describe("useInitialSidebarSelection", () => {
|
||||
it("does not reapply an unchanged default when preset references change", async () => {
|
||||
const setQueryImmediate = vi.fn();
|
||||
const setRepoFilter = vi.fn();
|
||||
const autoReset = createAutoResetControls();
|
||||
const { rerender } = renderHook(
|
||||
({ savedPresets }) =>
|
||||
useInitialSidebarSelection({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
resolvedPrPresets: prPresets,
|
||||
shouldAutoResetSearch: autoReset.shouldAutoResetSearch,
|
||||
resetSearchOnWorkspaceChange: autoReset.resetSearchOnWorkspaceChange,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
savedPresets,
|
||||
}),
|
||||
{ initialProps: { savedPresets: [prDefault] } },
|
||||
);
|
||||
await waitFor(() => expect(setQueryImmediate).toHaveBeenCalledOnce());
|
||||
|
||||
rerender({ savedPresets: [{ ...prDefault }] });
|
||||
|
||||
expect(setQueryImmediate).toHaveBeenCalledOnce();
|
||||
expect(setRepoFilter).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("applies a saved PR default when saved queries hydrate", async () => {
|
||||
const setQueryImmediate = vi.fn();
|
||||
const setRepoFilter = vi.fn();
|
||||
const autoReset = createAutoResetControls();
|
||||
const { result, rerender } = renderHook(
|
||||
({ savedPresets }) =>
|
||||
useInitialSidebarSelection({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
resolvedPrPresets: prPresets,
|
||||
shouldAutoResetSearch: autoReset.shouldAutoResetSearch,
|
||||
resetSearchOnWorkspaceChange: autoReset.resetSearchOnWorkspaceChange,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
savedPresets,
|
||||
}),
|
||||
{ initialProps: { savedPresets: [] as SavedPreset[] } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.selection.id).toBe(prPreset.value));
|
||||
|
||||
rerender({ savedPresets: [prDefault] });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.selection).toEqual({
|
||||
kind: "pr",
|
||||
source: "saved",
|
||||
id: prDefault.id,
|
||||
}),
|
||||
);
|
||||
expect(setQueryImmediate).toHaveBeenLastCalledWith(prDefault.customQuery);
|
||||
expect(setRepoFilter).toHaveBeenLastCalledWith(prDefault.repoFilter);
|
||||
});
|
||||
|
||||
it("does not apply a late default after manual selection", async () => {
|
||||
const setQueryImmediate = vi.fn();
|
||||
const setRepoFilter = vi.fn();
|
||||
const autoReset = createAutoResetControls();
|
||||
const { result, rerender } = renderHook(
|
||||
({ savedPresets }) =>
|
||||
useInitialSidebarSelection({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
resolvedPrPresets: prPresets,
|
||||
shouldAutoResetSearch: autoReset.shouldAutoResetSearch,
|
||||
resetSearchOnWorkspaceChange: autoReset.resetSearchOnWorkspaceChange,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
savedPresets,
|
||||
}),
|
||||
{ initialProps: { savedPresets: [] as SavedPreset[] } },
|
||||
);
|
||||
const manual: SidebarSelection = { kind: "pr", source: "preset", id: "manual" };
|
||||
act(() => result.current.setUserSelection(manual));
|
||||
setQueryImmediate.mockClear();
|
||||
setRepoFilter.mockClear();
|
||||
|
||||
rerender({ savedPresets: [prDefault] });
|
||||
|
||||
expect(result.current.selection).toEqual(manual);
|
||||
expect(setQueryImmediate).not.toHaveBeenCalled();
|
||||
expect(setRepoFilter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resets manual selection before applying a new workspace default", async () => {
|
||||
await expectNewWorkspaceDefaultAfterManualSelection();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSidebarSelectionHandler", () => {
|
||||
it("applies the destination kind's saved default on a kind switch", () => {
|
||||
const setUserSelection = vi.fn();
|
||||
const setQueryImmediate = vi.fn();
|
||||
const setRepoFilter = vi.fn();
|
||||
const markSearchInteracted = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useSidebarSelectionHandler({
|
||||
currentKind: "pr",
|
||||
savedPresets: [prDefault, issueDefault],
|
||||
resolvedPrPresets: prPresets,
|
||||
resolvedIssuePresets: issuePresets,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
setUserSelection,
|
||||
markSearchInteracted,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => result.current({ kind: "issue", source: KIND_SWITCH }));
|
||||
|
||||
expect(markSearchInteracted).toHaveBeenCalledOnce();
|
||||
expect(setUserSelection).toHaveBeenCalledWith({
|
||||
kind: "issue",
|
||||
source: "saved",
|
||||
id: issueDefault.id,
|
||||
});
|
||||
expect(setQueryImmediate).toHaveBeenCalledWith(issueDefault.customQuery);
|
||||
expect(setRepoFilter).toHaveBeenCalledWith(issueDefault.repoFilter);
|
||||
});
|
||||
|
||||
it(
|
||||
"falls back to the first configured preset when the destination has no saved default",
|
||||
expectConfiguredPresetFallbackOnKindSwitch,
|
||||
);
|
||||
|
||||
it("ignores a same-kind toggle sentinel", expectSameKindSentinelNoop);
|
||||
|
||||
it(
|
||||
"keeps the handler stable while using refreshed catalogs and current kind",
|
||||
expectStableHandlerWithRefreshedPresetCatalogs,
|
||||
);
|
||||
|
||||
it("keeps an explicit selection within the current kind", () => {
|
||||
const setUserSelection = vi.fn();
|
||||
const setQueryImmediate = vi.fn();
|
||||
const setRepoFilter = vi.fn();
|
||||
const requested: SidebarSelection = {
|
||||
kind: "pr",
|
||||
source: "preset",
|
||||
id: prPreset.value,
|
||||
};
|
||||
const { result } = renderHook(() =>
|
||||
useSidebarSelectionHandler({
|
||||
currentKind: "pr",
|
||||
savedPresets: [prDefault],
|
||||
resolvedPrPresets: prPresets,
|
||||
resolvedIssuePresets: issuePresets,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
setUserSelection,
|
||||
markSearchInteracted: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => result.current(requested));
|
||||
|
||||
expect(setUserSelection).toHaveBeenCalledWith(requested);
|
||||
expect(setQueryImmediate).toHaveBeenCalledWith(prPreset.filter);
|
||||
expect(setRepoFilter).toHaveBeenCalledWith("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PresetOption } from "./search-bar";
|
||||
import type { SidebarSelection, SidebarSelectionRequest } from "./presets-sidebar";
|
||||
import {
|
||||
findDefaultSavedPreset,
|
||||
resolveDefaultSidebarTarget,
|
||||
type SavedPreset,
|
||||
} from "./saved-preset-model";
|
||||
|
||||
type InitialSidebarSelectionOptions = {
|
||||
workspaceId: string | null;
|
||||
resolvedPrPresets: PresetOption[];
|
||||
shouldAutoResetSearch: () => boolean;
|
||||
resetSearchOnWorkspaceChange: () => void;
|
||||
setQueryImmediate: (query: string) => void;
|
||||
setRepoFilter: (repo: string) => void;
|
||||
savedPresets?: SavedPreset[];
|
||||
};
|
||||
|
||||
type AppliedSidebarTarget = ReturnType<typeof resolveDefaultSidebarTarget> & {
|
||||
workspaceId: string | null;
|
||||
};
|
||||
|
||||
function sameAppliedSidebarTarget(
|
||||
current: AppliedSidebarTarget | null,
|
||||
next: AppliedSidebarTarget,
|
||||
) {
|
||||
return (
|
||||
current !== null &&
|
||||
current.workspaceId === next.workspaceId &&
|
||||
current.selection.kind === next.selection.kind &&
|
||||
current.selection.source === next.selection.source &&
|
||||
current.selection.id === next.selection.id &&
|
||||
current.query === next.query &&
|
||||
current.repoFilter === next.repoFilter
|
||||
);
|
||||
}
|
||||
|
||||
export function useInitialSidebarSelection({
|
||||
workspaceId,
|
||||
resolvedPrPresets,
|
||||
shouldAutoResetSearch,
|
||||
resetSearchOnWorkspaceChange,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
savedPresets = [],
|
||||
}: InitialSidebarSelectionOptions) {
|
||||
const userSelectedRef = useRef(false);
|
||||
const resetWorkspaceIdRef = useRef<string | null | undefined>(undefined);
|
||||
const lastAppliedTargetRef = useRef<AppliedSidebarTarget | null>(null);
|
||||
const savedPrDefault = useMemo(() => findDefaultSavedPreset(savedPresets, "pr"), [savedPresets]);
|
||||
const resolvedPrTarget = useMemo(
|
||||
() =>
|
||||
resolveDefaultSidebarTarget("pr", savedPrDefault ? [savedPrDefault] : [], resolvedPrPresets),
|
||||
[savedPrDefault, resolvedPrPresets],
|
||||
);
|
||||
const [selection, setSelection] = useState<SidebarSelection>(() => resolvedPrTarget.selection);
|
||||
|
||||
useEffect(() => {
|
||||
if (resetWorkspaceIdRef.current !== workspaceId) {
|
||||
resetWorkspaceIdRef.current = workspaceId;
|
||||
userSelectedRef.current = false;
|
||||
resetSearchOnWorkspaceChange();
|
||||
}
|
||||
if (userSelectedRef.current || !shouldAutoResetSearch()) return;
|
||||
const target = resolvedPrTarget;
|
||||
const appliedTarget = { workspaceId, ...target };
|
||||
if (sameAppliedSidebarTarget(lastAppliedTargetRef.current, appliedTarget)) return;
|
||||
lastAppliedTargetRef.current = appliedTarget;
|
||||
setSelection((current) =>
|
||||
current.kind === target.selection.kind &&
|
||||
current.source === target.selection.source &&
|
||||
current.id === target.selection.id
|
||||
? current
|
||||
: target.selection,
|
||||
);
|
||||
setQueryImmediate(target.query);
|
||||
setRepoFilter(target.repoFilter);
|
||||
}, [
|
||||
workspaceId,
|
||||
resolvedPrTarget,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
resetSearchOnWorkspaceChange,
|
||||
shouldAutoResetSearch,
|
||||
]);
|
||||
|
||||
const setUserSelection = useCallback((next: SidebarSelection) => {
|
||||
userSelectedRef.current = true;
|
||||
setSelection(next);
|
||||
}, []);
|
||||
|
||||
return { selection, setProgrammaticSelection: setSelection, setUserSelection };
|
||||
}
|
||||
|
||||
export function useSidebarSelectionHandler({
|
||||
savedPresets,
|
||||
resolvedPrPresets,
|
||||
resolvedIssuePresets,
|
||||
setQueryImmediate,
|
||||
setRepoFilter,
|
||||
setUserSelection,
|
||||
markSearchInteracted,
|
||||
currentKind,
|
||||
}: {
|
||||
savedPresets: SavedPreset[];
|
||||
resolvedPrPresets: PresetOption[];
|
||||
resolvedIssuePresets: PresetOption[];
|
||||
setQueryImmediate: (query: string) => void;
|
||||
setRepoFilter: (repo: string) => void;
|
||||
setUserSelection: (next: SidebarSelection) => void;
|
||||
markSearchInteracted: () => void;
|
||||
currentKind: SidebarSelection["kind"];
|
||||
}) {
|
||||
const savedPresetsRef = useRef(savedPresets);
|
||||
savedPresetsRef.current = savedPresets;
|
||||
const resolvedPresetsRef = useRef({ pr: resolvedPrPresets, issue: resolvedIssuePresets });
|
||||
resolvedPresetsRef.current = { pr: resolvedPrPresets, issue: resolvedIssuePresets };
|
||||
const currentKindRef = useRef(currentKind);
|
||||
currentKindRef.current = currentKind;
|
||||
return useCallback(
|
||||
(selection: SidebarSelectionRequest) => {
|
||||
if (selection.source === "kind-switch") {
|
||||
if (selection.kind === currentKindRef.current) return;
|
||||
markSearchInteracted();
|
||||
const target = resolveDefaultSidebarTarget(
|
||||
selection.kind,
|
||||
savedPresetsRef.current,
|
||||
resolvedPresetsRef.current[selection.kind],
|
||||
);
|
||||
setUserSelection(target.selection);
|
||||
setQueryImmediate(target.query);
|
||||
setRepoFilter(target.repoFilter);
|
||||
return;
|
||||
}
|
||||
markSearchInteracted();
|
||||
setUserSelection(selection);
|
||||
if (selection.source === "saved") {
|
||||
const found = savedPresetsRef.current.find((preset) => preset.id === selection.id);
|
||||
setQueryImmediate(found?.customQuery ?? "");
|
||||
setRepoFilter(found?.repoFilter ?? "");
|
||||
return;
|
||||
}
|
||||
const preset = resolvedPresetsRef.current[selection.kind].find(
|
||||
(candidate) => candidate.value === selection.id,
|
||||
);
|
||||
setQueryImmediate(preset?.filter ?? "");
|
||||
setRepoFilter("");
|
||||
},
|
||||
[setQueryImmediate, setUserSelection, markSearchInteracted, setRepoFilter],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { IconInbox } from "@tabler/icons-react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { IntegrationScopeBar, type IntegrationScopeBarProps } from "./presets-scope-bar-base";
|
||||
|
||||
let lastMenuItemSelectEvent: Event | null = null;
|
||||
|
||||
vi.mock("@kandev/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div role="menu">{children}</div>
|
||||
),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
disabled,
|
||||
onSelect,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||
disabled?: boolean;
|
||||
onSelect?: (event: Event) => void;
|
||||
}) => {
|
||||
const select = () => {
|
||||
if (disabled) return;
|
||||
const event = new Event("select", { cancelable: true });
|
||||
lastMenuItemSelectEvent = event;
|
||||
onSelect?.(event);
|
||||
};
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
role="menuitem"
|
||||
aria-disabled={disabled}
|
||||
onClick={select}
|
||||
onPointerUp={select}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
DropdownMenuCheckboxItem: ({
|
||||
children,
|
||||
checked,
|
||||
disabled,
|
||||
onCheckedChange,
|
||||
onSelect,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
onSelect?: (event: Event) => void;
|
||||
}) => (
|
||||
<div
|
||||
{...props}
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={checked}
|
||||
aria-disabled={disabled}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
onSelect?.(new Event("select", { cancelable: true }));
|
||||
onCheckedChange?.(!checked);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
lastMenuItemSelectEvent = null;
|
||||
});
|
||||
|
||||
type Kind = "pr" | "issue";
|
||||
type Props = IntegrationScopeBarProps<Kind>;
|
||||
type DefaultActions<T> = T extends {
|
||||
onToggleSavedDefault?: ((id: string) => void) | undefined;
|
||||
defaultMutationPendingId?: string | null | undefined;
|
||||
}
|
||||
? Pick<T, "onToggleSavedDefault" | "defaultMutationPendingId">
|
||||
: never;
|
||||
const ARIA_DISABLED_ATTRIBUTE = "aria-disabled";
|
||||
const FUTURE_DELETE_LABEL = "Delete Future default saved query";
|
||||
|
||||
function acceptDefaultActions(actions: DefaultActions<Props>) {
|
||||
return actions;
|
||||
}
|
||||
|
||||
function renderBar(overrides: Partial<Props> = {}) {
|
||||
const onSelect = vi.fn();
|
||||
const onToggleSavedDefault = vi.fn();
|
||||
const onDeleteSaved = vi.fn();
|
||||
const props: Props = {
|
||||
testId: "scope-bar",
|
||||
savedMenuTestId: "saved-menu",
|
||||
kinds: [
|
||||
{ value: "pr", label: "Pull requests" },
|
||||
{ value: "issue", label: "Issues" },
|
||||
],
|
||||
selected: { kind: "pr", source: "preset", id: "review" },
|
||||
onSelect,
|
||||
presetsByKind: () => [
|
||||
{
|
||||
value: "review",
|
||||
label: "Review requested",
|
||||
icon: IconInbox,
|
||||
group: "inbox",
|
||||
},
|
||||
],
|
||||
savedPresets: [
|
||||
{ id: "saved-a", kind: "pr", label: "Current default", isDefault: true },
|
||||
{ id: "saved-b", kind: "pr", label: "Future default", isDefault: false },
|
||||
],
|
||||
onDeleteSaved,
|
||||
canSaveCurrent: false,
|
||||
onSaveCurrent: vi.fn(),
|
||||
onToggleSavedDefault,
|
||||
defaultMutationPendingId: null,
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
...render(<IntegrationScopeBar {...props} />),
|
||||
onSelect,
|
||||
onToggleSavedDefault,
|
||||
onDeleteSaved,
|
||||
};
|
||||
}
|
||||
|
||||
function expectActiveKindReselectionNoop() {
|
||||
const { onSelect } = renderBar();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Pull requests" }));
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
describe("IntegrationScopeBar saved defaults", () => {
|
||||
it("requires pending default state to include its callback", () => {
|
||||
// @ts-expect-error A pending id without its mutation callback is an invalid contract.
|
||||
expect(acceptDefaultActions({ defaultMutationPendingId: "saved-b" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("ignores reselecting the active kind", expectActiveKindReselectionNoop);
|
||||
|
||||
it("delegates kind changes to the explicit callback when provided", () => {
|
||||
const onKindChange = vi.fn();
|
||||
const { onSelect } = renderBar({ onKindChange });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Issues" }));
|
||||
|
||||
expect(onKindChange).toHaveBeenCalledWith("issue");
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders set and clear markers without selecting the saved query", () => {
|
||||
const { onSelect, onToggleSavedDefault } = renderBar();
|
||||
const clear = screen.getByRole("menuitemcheckbox", {
|
||||
name: "Clear Current default as default view",
|
||||
});
|
||||
const set = screen.getByRole("menuitemcheckbox", {
|
||||
name: "Set Future default as default view",
|
||||
});
|
||||
|
||||
expect(clear.getAttribute("aria-checked")).toBe("true");
|
||||
expect(set.getAttribute("aria-checked")).toBe("false");
|
||||
expect(set.parentElement?.getAttribute("role")).toBe("none");
|
||||
fireEvent.click(set);
|
||||
|
||||
expect(onToggleSavedDefault).toHaveBeenCalledWith("saved-b");
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables every default action while a mutation is pending", () => {
|
||||
renderBar({ defaultMutationPendingId: "saved-b" });
|
||||
const currentDefault = screen.getByRole("menuitemcheckbox", {
|
||||
name: "Updating default view… Clear Current default as default view",
|
||||
});
|
||||
const currentDelete = screen.getByRole("menuitem", {
|
||||
name: "Updating default view… Delete Current default saved query",
|
||||
});
|
||||
|
||||
expect(currentDefault.getAttribute(ARIA_DISABLED_ATTRIBUTE)).toBe("true");
|
||||
expect(currentDefault.getAttribute("aria-busy")).toBeNull();
|
||||
expect(currentDefault.querySelector("svg")?.getAttribute("class")).not.toContain(
|
||||
"animate-pulse",
|
||||
);
|
||||
const futureDefault = screen.getByRole("menuitemcheckbox", {
|
||||
name: "Updating default view… Set Future default as default view",
|
||||
});
|
||||
expect(futureDefault.getAttribute(ARIA_DISABLED_ATTRIBUTE)).toBe("true");
|
||||
expect(futureDefault.querySelector("svg")?.getAttribute("class")).toContain("fill-amber-500");
|
||||
expect(futureDefault.querySelector("svg")?.getAttribute("class")).toContain("opacity-60");
|
||||
expect(futureDefault.querySelector("svg")?.getAttribute("class")).toContain("animate-pulse");
|
||||
expect(currentDelete.getAttribute(ARIA_DISABLED_ATTRIBUTE)).toBe("true");
|
||||
expect(currentDelete.className).toContain("group-hover/saved:data-[disabled]:opacity-50");
|
||||
expect(
|
||||
screen
|
||||
.getByRole("menuitem", {
|
||||
name: `Updating default view… ${FUTURE_DELETE_LABEL}`,
|
||||
})
|
||||
.getAttribute(ARIA_DISABLED_ATTRIBUTE),
|
||||
).toBe("true");
|
||||
});
|
||||
|
||||
it("renders no default actions when an integration omits the optional contract", () => {
|
||||
renderBar({ onToggleSavedDefault: undefined });
|
||||
|
||||
expect(screen.queryByRole("menuitemcheckbox", { name: /as default view/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("names each delete action for its saved query", () => {
|
||||
renderBar();
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole("menuitem", { name: "Delete Current default saved query" })
|
||||
.getAttribute("title"),
|
||||
).toBe("Delete Current default saved query");
|
||||
expect(screen.getByRole("menuitem", { name: FUTURE_DELETE_LABEL }).getAttribute("title")).toBe(
|
||||
FUTURE_DELETE_LABEL,
|
||||
);
|
||||
});
|
||||
|
||||
it("prevents the menu from closing when a saved query is deleted", () => {
|
||||
const { onDeleteSaved } = renderBar();
|
||||
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: FUTURE_DELETE_LABEL }));
|
||||
|
||||
expect(onDeleteSaved).toHaveBeenCalledWith("saved-b");
|
||||
expect(lastMenuItemSelectEvent?.defaultPrevented).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { IconBookmark, IconChevronDown, IconDeviceFloppy, IconX } from "@tabler/icons-react";
|
||||
import type { Icon } from "@tabler/icons-react";
|
||||
import {
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
} from "@kandev/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SavedQueryDefaultDropdownItem } from "./saved-query-default-button";
|
||||
|
||||
/**
|
||||
* Shared, domain-agnostic scope bar for the integration dashboards (/github,
|
||||
@@ -39,6 +41,7 @@ export type ScopeSavedPreset<K extends string> = {
|
||||
id: string;
|
||||
kind: K;
|
||||
label: string;
|
||||
isDefault?: boolean;
|
||||
};
|
||||
|
||||
const PILL_BASE =
|
||||
@@ -110,6 +113,8 @@ function SavedMenu<K extends string>({
|
||||
onDeleteSaved,
|
||||
canSaveCurrent,
|
||||
onSaveCurrent,
|
||||
onToggleSavedDefault,
|
||||
defaultMutationPendingId,
|
||||
}: {
|
||||
testId: string;
|
||||
selected: ScopeSelection<K>;
|
||||
@@ -118,8 +123,11 @@ function SavedMenu<K extends string>({
|
||||
onDeleteSaved: (id: string) => void;
|
||||
canSaveCurrent: boolean;
|
||||
onSaveCurrent: () => void;
|
||||
onToggleSavedDefault?: (id: string) => void;
|
||||
defaultMutationPendingId: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const defaultMutationPending = defaultMutationPendingId !== null;
|
||||
const activeSaved = selected.source === "saved";
|
||||
const activeLabel = activeSaved ? saved.find((s) => s.id === selected.id)?.label : null;
|
||||
return (
|
||||
@@ -141,30 +149,47 @@ function SavedMenu<K extends string>({
|
||||
{saved.length === 0 ? (
|
||||
<DropdownMenuItem disabled>{t("integrations:noSavedQueriesYet")}</DropdownMenuItem>
|
||||
) : (
|
||||
saved.map((s) => (
|
||||
<DropdownMenuItem
|
||||
key={s.id}
|
||||
onSelect={() => onSelect({ kind: s.kind, source: "saved", id: s.id })}
|
||||
className="group/saved cursor-pointer gap-2"
|
||||
>
|
||||
<IconBookmark className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate">{s.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
// Stop the pointerdown so Radix doesn't treat the delete click
|
||||
// as a select of the (about-to-be-deleted) saved query.
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteSaved(s.id);
|
||||
}}
|
||||
className="pointer-events-none cursor-pointer text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/saved:pointer-events-auto group-hover/saved:opacity-100"
|
||||
title={t("integrations:deleteSavedQuery")}
|
||||
>
|
||||
<IconX className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
saved.map((s) => {
|
||||
const deleteLabel = t("integrations:deleteSavedQueryNamed", { label: s.label });
|
||||
const accessibleDeleteLabel = defaultMutationPending
|
||||
? t("integrations:savedQueryDefaultUpdateInProgress", { action: deleteLabel })
|
||||
: deleteLabel;
|
||||
return (
|
||||
<div key={s.id} role="none" className="group/saved flex items-center gap-0.5">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onSelect({ kind: s.kind, source: "saved", id: s.id })}
|
||||
className="min-w-0 flex-1 cursor-pointer gap-2"
|
||||
>
|
||||
<IconBookmark className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate">{s.label}</span>
|
||||
</DropdownMenuItem>
|
||||
{onToggleSavedDefault && (
|
||||
<SavedQueryDefaultDropdownItem
|
||||
label={s.label}
|
||||
isDefault={s.isDefault === true}
|
||||
disabled={defaultMutationPending}
|
||||
pending={defaultMutationPendingId === s.id}
|
||||
testId={`saved-query-default-${s.id}`}
|
||||
onToggle={() => onToggleSavedDefault(s.id)}
|
||||
/>
|
||||
)}
|
||||
{/* A peer Radix item is intentional: arrow navigation reaches delete,
|
||||
and focus opacity reveals it without pointer hover. */}
|
||||
<DropdownMenuItem
|
||||
disabled={defaultMutationPending}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
onDeleteSaved(s.id);
|
||||
}}
|
||||
className="h-7 min-h-7 w-7 shrink-0 cursor-pointer justify-center p-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus:opacity-100 group-hover/saved:opacity-100 data-[disabled]:cursor-wait data-[disabled]:opacity-50 group-hover/saved:data-[disabled]:opacity-50"
|
||||
title={accessibleDeleteLabel}
|
||||
aria-label={accessibleDeleteLabel}
|
||||
>
|
||||
<IconX className="h-3.5 w-3.5" />
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
@@ -180,6 +205,17 @@ function SavedMenu<K extends string>({
|
||||
);
|
||||
}
|
||||
|
||||
type SavedDefaultActionProps =
|
||||
| {
|
||||
/** Emits only the stable id; domain wrappers own richer saved-query data. */
|
||||
onToggleSavedDefault: (id: string) => void;
|
||||
defaultMutationPendingId: string | null;
|
||||
}
|
||||
| {
|
||||
onToggleSavedDefault?: never;
|
||||
defaultMutationPendingId?: never;
|
||||
};
|
||||
|
||||
export type IntegrationScopeBarProps<K extends string> = {
|
||||
className?: string;
|
||||
testId: string;
|
||||
@@ -187,12 +223,14 @@ export type IntegrationScopeBarProps<K extends string> = {
|
||||
kinds: ReadonlyArray<{ value: K; label: string }>;
|
||||
selected: ScopeSelection<K>;
|
||||
onSelect: (s: ScopeSelection<K>) => void;
|
||||
/** Overrides the default first-preset selection when the active kind changes. */
|
||||
onKindChange?: (kind: K) => void;
|
||||
presetsByKind: (kind: K) => ScopePreset[];
|
||||
savedPresets: ScopeSavedPreset<K>[];
|
||||
onDeleteSaved: (id: string) => void;
|
||||
canSaveCurrent: boolean;
|
||||
onSaveCurrent: () => void;
|
||||
};
|
||||
} & SavedDefaultActionProps;
|
||||
|
||||
export function IntegrationScopeBar<K extends string>({
|
||||
className,
|
||||
@@ -201,20 +239,31 @@ export function IntegrationScopeBar<K extends string>({
|
||||
kinds,
|
||||
selected,
|
||||
onSelect,
|
||||
onKindChange,
|
||||
presetsByKind,
|
||||
savedPresets,
|
||||
onDeleteSaved,
|
||||
canSaveCurrent,
|
||||
onSaveCurrent,
|
||||
onToggleSavedDefault,
|
||||
defaultMutationPendingId = null,
|
||||
}: IntegrationScopeBarProps<K>) {
|
||||
const presets = presetsByKind(selected.kind);
|
||||
const saved = savedPresets.filter((p) => p.kind === selected.kind);
|
||||
const inbox = presets.filter((p) => p.group === "inbox");
|
||||
const created = presets.filter((p) => p.group === "created");
|
||||
|
||||
const onKindChange = (kind: K) => {
|
||||
onSelect({ kind, source: "preset", id: presetsByKind(kind)[0]?.value ?? "" });
|
||||
};
|
||||
const handleKindChange = useCallback(
|
||||
(kind: K) => {
|
||||
if (kind === selected.kind) return;
|
||||
if (onKindChange) {
|
||||
onKindChange(kind);
|
||||
return;
|
||||
}
|
||||
onSelect({ kind, source: "preset", id: presetsByKind(kind)[0]?.value ?? "" });
|
||||
},
|
||||
[onKindChange, onSelect, presetsByKind, selected.kind],
|
||||
);
|
||||
|
||||
const renderPill = (p: ScopePreset) => (
|
||||
<PresetPill
|
||||
@@ -231,7 +280,7 @@ export function IntegrationScopeBar<K extends string>({
|
||||
className={cn("flex items-center gap-1.5 overflow-x-auto px-4 py-2 sm:px-6", className)}
|
||||
data-testid={testId}
|
||||
>
|
||||
<KindSegment kinds={kinds} active={selected.kind} onChange={onKindChange} />
|
||||
<KindSegment kinds={kinds} active={selected.kind} onChange={handleKindChange} />
|
||||
<Divider />
|
||||
{inbox.map(renderPill)}
|
||||
{inbox.length > 0 && created.length > 0 && <Divider />}
|
||||
@@ -245,6 +294,8 @@ export function IntegrationScopeBar<K extends string>({
|
||||
onDeleteSaved={onDeleteSaved}
|
||||
canSaveCurrent={canSaveCurrent}
|
||||
onSaveCurrent={onSaveCurrent}
|
||||
onToggleSavedDefault={onToggleSavedDefault}
|
||||
defaultMutationPendingId={defaultMutationPendingId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { IconStar } from "@tabler/icons-react";
|
||||
import { DropdownMenuCheckboxItem } from "@kandev/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type SavedQueryDefaultControlProps = {
|
||||
label: string;
|
||||
isDefault: boolean;
|
||||
disabled?: boolean;
|
||||
pending?: boolean;
|
||||
testId?: string;
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
function useSavedQueryDefaultPresentation(
|
||||
label: string,
|
||||
isDefault: boolean,
|
||||
disabled: boolean,
|
||||
pending: boolean,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const actionLabel = t(
|
||||
isDefault
|
||||
? "integrations:clearSavedQueryAsDefaultView"
|
||||
: "integrations:setSavedQueryAsDefaultView",
|
||||
{ label },
|
||||
);
|
||||
return {
|
||||
accessibleLabel: disabled
|
||||
? t("integrations:savedQueryDefaultUpdateInProgress", { action: actionLabel })
|
||||
: actionLabel,
|
||||
iconClassName: cn(
|
||||
"h-4 w-4",
|
||||
isDefault && "fill-amber-500 text-amber-500",
|
||||
pending && !isDefault && "fill-amber-500 text-amber-500 opacity-60",
|
||||
pending && "animate-pulse motion-reduce:animate-none",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function SavedQueryDefaultDropdownItem({
|
||||
label,
|
||||
isDefault,
|
||||
disabled = false,
|
||||
pending = false,
|
||||
testId,
|
||||
onToggle,
|
||||
}: SavedQueryDefaultControlProps) {
|
||||
const { accessibleLabel, iconClassName } = useSavedQueryDefaultPresentation(
|
||||
label,
|
||||
isDefault,
|
||||
disabled,
|
||||
pending,
|
||||
);
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={isDefault}
|
||||
disabled={disabled}
|
||||
aria-label={accessibleLabel}
|
||||
title={accessibleLabel}
|
||||
data-testid={testId}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={() => onToggle()}
|
||||
showIndicator={false}
|
||||
className="h-7 min-h-7 w-7 shrink-0 cursor-pointer justify-center p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<IconStar className={iconClassName} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function SavedQueryDefaultButton({
|
||||
label,
|
||||
isDefault,
|
||||
disabled = false,
|
||||
pending = false,
|
||||
testId,
|
||||
onToggle,
|
||||
}: SavedQueryDefaultControlProps) {
|
||||
const { accessibleLabel, iconClassName } = useSavedQueryDefaultPresentation(
|
||||
label,
|
||||
isDefault,
|
||||
disabled,
|
||||
pending,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={accessibleLabel}
|
||||
aria-pressed={isDefault}
|
||||
title={accessibleLabel}
|
||||
disabled={disabled}
|
||||
data-testid={testId}
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"flex shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-wait disabled:opacity-50",
|
||||
"h-11 w-11",
|
||||
)}
|
||||
>
|
||||
<IconStar className={iconClassName} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -42,4 +42,10 @@ export class MobileGitHubPage {
|
||||
savedQueryByLabel(label: string): Locator {
|
||||
return this.mobileSidebar.getByText(label, { exact: true });
|
||||
}
|
||||
|
||||
savedQueryDefaultAction(label: string, action: "Set" | "Clear" = "Set"): Locator {
|
||||
return this.mobileSidebar.getByRole("button", {
|
||||
name: `${action} ${label} as default view`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ const DEFAULT_REPO = "defaultorg/defaultrepo";
|
||||
const DEFAULT_REPO_ISSUE = "Issue in saved default repo";
|
||||
const OTHER_REPO_ISSUE = "Issue outside saved default repo";
|
||||
const SAVED_QUERY_FILTER = "assignee:@me is:open saved-default-repo-e2e";
|
||||
const DEFAULTS_TIMESTAMP = "2026-08-07T00:00:00Z";
|
||||
|
||||
function issueRow(testPage: Page, title: string) {
|
||||
return testPage.getByTestId("issue-row").filter({ hasText: title });
|
||||
@@ -15,6 +16,160 @@ function issueRow(testPage: Page, title: string) {
|
||||
// a horizontal scope bar; this guards that it drives presets/kind and that the
|
||||
// old inline rail is gone. Mobile keeps the sheet (see mobile-github-sidebar).
|
||||
test.describe("Desktop /github scope bar", () => {
|
||||
test.describe("saved default views", () => {
|
||||
test.afterEach(async ({ testPage, seedData }) => {
|
||||
const cleanupResponse = await testPage.request.put("/api/v1/github/workspace-settings", {
|
||||
data: { workspace_id: seedData.workspaceId, saved_presets: [] },
|
||||
});
|
||||
expect(cleanupResponse.ok(), "afterEach saved-presets cleanup").toBe(true);
|
||||
});
|
||||
|
||||
test("saved defaults apply independently on entry and kind switches", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const prDefaultLabel = "Default Kandev PRs";
|
||||
const issueDefaultLabel = "Future Kandev issues";
|
||||
await apiClient.mockGitHubReset();
|
||||
await apiClient.mockGitHubSetUser("test-user");
|
||||
await apiClient.mockGitHubAddPRs([
|
||||
{
|
||||
number: 7,
|
||||
title: "PR in default view",
|
||||
state: "open",
|
||||
head_branch: "feat/default-view",
|
||||
base_branch: "main",
|
||||
author_login: "test-user",
|
||||
repo_owner: "defaultorg",
|
||||
repo_name: "defaultrepo",
|
||||
},
|
||||
]);
|
||||
await apiClient.mockGitHubAddIssues([
|
||||
{
|
||||
number: 8,
|
||||
title: DEFAULT_REPO_ISSUE,
|
||||
state: "open",
|
||||
author_login: "test-user",
|
||||
repo_owner: "defaultorg",
|
||||
repo_name: "defaultrepo",
|
||||
assignees: ["test-user"],
|
||||
},
|
||||
{
|
||||
number: 9,
|
||||
title: OTHER_REPO_ISSUE,
|
||||
state: "open",
|
||||
author_login: "test-user",
|
||||
repo_owner: "otherorg",
|
||||
repo_name: "otherrepo",
|
||||
assignees: ["test-user"],
|
||||
},
|
||||
]);
|
||||
const seedResponse = await testPage.request.put("/api/v1/github/workspace-settings", {
|
||||
data: {
|
||||
workspace_id: seedData.workspaceId,
|
||||
saved_presets: [
|
||||
{
|
||||
id: "saved-pr-default",
|
||||
kind: "pr",
|
||||
label: prDefaultLabel,
|
||||
customQuery: "author:@me is:open",
|
||||
repoFilter: DEFAULT_REPO,
|
||||
createdAt: DEFAULTS_TIMESTAMP,
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
id: "saved-issue-future",
|
||||
kind: "issue",
|
||||
label: issueDefaultLabel,
|
||||
customQuery: "assignee:@me is:open",
|
||||
repoFilter: DEFAULT_REPO,
|
||||
createdAt: DEFAULTS_TIMESTAMP,
|
||||
isDefault: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(seedResponse.ok()).toBe(true);
|
||||
|
||||
await testPage.goto("/github");
|
||||
|
||||
const scopeBar = testPage.getByTestId("github-presets-scope-bar");
|
||||
const title = testPage.getByTestId("github-list-toolbar-title");
|
||||
const savedMenu = testPage.getByTestId("github-saved-queries-menu");
|
||||
const repoFilter = testPage.getByTestId("github-repo-filter-trigger");
|
||||
await expect(title).toContainText(prDefaultLabel);
|
||||
await expect(repoFilter).toContainText(DEFAULT_REPO);
|
||||
|
||||
await scopeBar.getByRole("button", { name: "Issues", exact: true }).click();
|
||||
await expect(title).toContainText("Assigned");
|
||||
await expect(repoFilter).toContainText("All repos");
|
||||
|
||||
await savedMenu.focus();
|
||||
await savedMenu.press("Enter");
|
||||
const issueSavedQuery = testPage.getByRole("menuitem", {
|
||||
name: issueDefaultLabel,
|
||||
exact: true,
|
||||
});
|
||||
const setIssueDefault = testPage.getByRole("menuitemcheckbox", {
|
||||
name: `Set ${issueDefaultLabel} as default view`,
|
||||
});
|
||||
await expect(issueSavedQuery).toBeFocused();
|
||||
await testPage.keyboard.press("ArrowDown");
|
||||
await expect(setIssueDefault).toBeFocused();
|
||||
const deleteIssueSavedQuery = testPage.getByRole("menuitem", {
|
||||
name: `Delete ${issueDefaultLabel} saved query`,
|
||||
});
|
||||
await testPage.keyboard.press("ArrowDown");
|
||||
await expect(deleteIssueSavedQuery).toBeFocused();
|
||||
await testPage.keyboard.press("ArrowUp");
|
||||
await expect(setIssueDefault).toBeFocused();
|
||||
const setResponse = testPage.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes("/api/v1/github/workspace-settings") &&
|
||||
response.request().method() === "PUT" &&
|
||||
response.status() === 200,
|
||||
);
|
||||
await testPage.keyboard.press("Enter");
|
||||
await setResponse;
|
||||
const setMarker = testPage.getByRole("menuitemcheckbox", {
|
||||
name: `Clear ${issueDefaultLabel} as default view`,
|
||||
});
|
||||
await expect(setMarker).toBeVisible();
|
||||
await expect(setMarker).toHaveAttribute("aria-checked", "true");
|
||||
await expect(title).toContainText("Assigned");
|
||||
await expect(repoFilter).toContainText("All repos");
|
||||
|
||||
await testPage.reload();
|
||||
await expect(title).toContainText(prDefaultLabel);
|
||||
await scopeBar.getByRole("button", { name: "Issues", exact: true }).click();
|
||||
await expect(title).toContainText(issueDefaultLabel);
|
||||
await expect(repoFilter).toContainText(DEFAULT_REPO);
|
||||
await expect(issueRow(testPage, DEFAULT_REPO_ISSUE)).toBeVisible();
|
||||
await expect(issueRow(testPage, OTHER_REPO_ISSUE)).toHaveCount(0);
|
||||
|
||||
await savedMenu.click();
|
||||
const clearIssueDefault = testPage.getByRole("menuitemcheckbox", {
|
||||
name: `Clear ${issueDefaultLabel} as default view`,
|
||||
});
|
||||
const clearResponse = testPage.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes("/api/v1/github/workspace-settings") &&
|
||||
response.request().method() === "PUT" &&
|
||||
response.status() === 200,
|
||||
);
|
||||
await clearIssueDefault.click();
|
||||
await clearResponse;
|
||||
await expect(title).toContainText(issueDefaultLabel);
|
||||
|
||||
await testPage.reload();
|
||||
await expect(title).toContainText(prDefaultLabel);
|
||||
await scopeBar.getByRole("button", { name: "Issues", exact: true }).click();
|
||||
await expect(title).toContainText("Assigned");
|
||||
await expect(repoFilter).toContainText("All repos");
|
||||
});
|
||||
});
|
||||
|
||||
test("scope bar drives presets and kind, with no second sidebar", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
|
||||
@@ -5,8 +5,96 @@ const DEFAULT_REPO = "mobileorg/defaultrepo";
|
||||
const DEFAULT_REPO_ISSUE = "Mobile issue in saved default repo";
|
||||
const OTHER_REPO_ISSUE = "Mobile issue outside saved default repo";
|
||||
const SAVED_QUERY_FILTER = "assignee:@me is:open mobile-saved-default-repo-e2e";
|
||||
const DEFAULTS_TIMESTAMP = "2026-08-07T00:00:00Z";
|
||||
|
||||
test.describe("Mobile /github sidebar", () => {
|
||||
test.describe("saved default views", () => {
|
||||
test.afterEach(async ({ testPage, seedData }) => {
|
||||
const cleanupResponse = await testPage.request.put("/api/v1/github/workspace-settings", {
|
||||
data: { workspace_id: seedData.workspaceId, saved_presets: [] },
|
||||
});
|
||||
expect(cleanupResponse.ok(), "afterEach saved-presets cleanup").toBe(true);
|
||||
});
|
||||
|
||||
test("saved default action is touch-sized, isolated, and persistent", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const savedLabel = "Mobile future issues";
|
||||
await apiClient.mockGitHubReset();
|
||||
await apiClient.mockGitHubSetUser("test-user");
|
||||
await apiClient.mockGitHubAddIssues([
|
||||
{
|
||||
number: 190,
|
||||
title: DEFAULT_REPO_ISSUE,
|
||||
state: "open",
|
||||
author_login: "test-user",
|
||||
repo_owner: "mobileorg",
|
||||
repo_name: "defaultrepo",
|
||||
assignees: ["test-user"],
|
||||
},
|
||||
]);
|
||||
const seedResponse = await testPage.request.put("/api/v1/github/workspace-settings", {
|
||||
data: {
|
||||
workspace_id: seedData.workspaceId,
|
||||
saved_presets: [
|
||||
{
|
||||
id: "mobile-saved-issue",
|
||||
kind: "issue",
|
||||
label: savedLabel,
|
||||
customQuery: "assignee:@me is:open",
|
||||
repoFilter: DEFAULT_REPO,
|
||||
createdAt: DEFAULTS_TIMESTAMP,
|
||||
isDefault: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(seedResponse.ok()).toBe(true);
|
||||
|
||||
const page = new MobileGitHubPage(testPage);
|
||||
await page.goto();
|
||||
await page.mobileMenuButton.tap();
|
||||
await page.mobileSidebar.getByRole("button", { name: "Issues", exact: true }).tap();
|
||||
await expect(page.toolbarTitle).toContainText("Assigned");
|
||||
await page.mobileMenuButton.tap();
|
||||
|
||||
const defaultAction = page.savedQueryDefaultAction(savedLabel);
|
||||
const actionBox = await defaultAction.boundingBox();
|
||||
expect(actionBox).not.toBeNull();
|
||||
expect(actionBox!.height).toBeGreaterThanOrEqual(44);
|
||||
expect(actionBox!.width).toBeGreaterThanOrEqual(44);
|
||||
const setResponse = testPage.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes("/api/v1/github/workspace-settings") &&
|
||||
response.request().method() === "PUT" &&
|
||||
response.status() === 200,
|
||||
);
|
||||
await defaultAction.tap();
|
||||
await setResponse;
|
||||
|
||||
await expect(page.mobileSidebar).toBeVisible();
|
||||
await expect(page.toolbarTitle).toContainText("Assigned");
|
||||
await expect(page.savedQueryDefaultAction(savedLabel, "Clear")).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
expect(
|
||||
await testPage.evaluate(
|
||||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await testPage.reload();
|
||||
await page.mobileMenuButton.waitFor({ state: "visible" });
|
||||
await page.mobileMenuButton.tap();
|
||||
await page.mobileSidebar.getByRole("button", { name: "Issues", exact: true }).tap();
|
||||
await expect(page.toolbarTitle).toContainText(savedLabel);
|
||||
await expect(page.repoFilterTrigger).toContainText(DEFAULT_REPO);
|
||||
});
|
||||
});
|
||||
|
||||
test("hamburger opens sheet and selecting a preset updates the toolbar", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
|
||||
@@ -138,7 +138,6 @@
|
||||
"defaultQueriesSaved": "Default queries saved",
|
||||
"defaultRepository": "Default repository",
|
||||
"delete": "Delete",
|
||||
"deleteSavedQuery": "Delete saved query",
|
||||
"deletedTasks_one": "Deleted {{count}} task",
|
||||
"deletedTasks_other": "Deleted {{count}} tasks",
|
||||
"description": "Description",
|
||||
|
||||
@@ -37,13 +37,16 @@
|
||||
"creatingEllipsis": "Creating...",
|
||||
"creatingFrom": "Creating {{shortName}} from <4>{{displayBranch}}</4>",
|
||||
"creatingFromInto": "Creating {{shortName}} from <4>{{displayBranch}}</4> → <6>{{baseBranch}}</6>",
|
||||
"deleteSavedQuery": "Delete saved query",
|
||||
"deleteSavedQueryNamed": "Delete {{label}} saved query",
|
||||
"describeYourChanges": "Describe your changes...",
|
||||
"description": "Description",
|
||||
"draftCreated": "Draft {{shortName}} created",
|
||||
"enableIntegrationNamed": "Enable {{name}}",
|
||||
"enterCommitMessage": "Enter commit message...",
|
||||
"failedToCopyConfig": "Failed to copy config: {{err}}",
|
||||
"failedToDeleteSavedQuery": "Failed to delete saved query",
|
||||
"failedToSaveSavedQuery": "Failed to save saved query",
|
||||
"failedToUpdateDefaultView": "Failed to update default view",
|
||||
"filesChanged_one": "<0>{{count}}</0> file changed",
|
||||
"filesChanged_other": "<0>{{count}}</0> files changed",
|
||||
"forcePush": "Force Push",
|
||||
@@ -76,6 +79,9 @@
|
||||
"retryShortName": "Retry {{shortName}}",
|
||||
"saveCurrentQuery": "Save current query",
|
||||
"savedQueries": "Saved",
|
||||
"savedQueryDefaultUpdateInProgress": "Updating default view… {{action}}",
|
||||
"setSavedQueryAsDefaultView": "Set {{label}} as default view",
|
||||
"clearSavedQueryAsDefaultView": "Clear {{label}} as default view",
|
||||
"selectAWorkspace": "Select a workspace…",
|
||||
"shortNameCreated": "{{shortName}} created",
|
||||
"stageAllChangesBeforeCommitting": "Stage all changes before committing",
|
||||
|
||||
@@ -138,7 +138,6 @@
|
||||
"defaultQueriesSaved": "Ďēƒàũĺţ qũēŕĩēś śàvēď",
|
||||
"defaultRepository": "Ďēƒàũĺţ ŕēƥōśĩţōŕŷ",
|
||||
"delete": "Ďēĺēţē",
|
||||
"deleteSavedQuery": "Ďēĺēţē śàvēď qũēŕŷ",
|
||||
"deletedTasks_one": "Ďēĺēţēď {{count}} ţàśķ",
|
||||
"deletedTasks_other": "Ďēĺēţēď {{count}} ţàśķś",
|
||||
"description": "Ďēśćŕĩƥţĩōń",
|
||||
|
||||
@@ -37,13 +37,16 @@
|
||||
"creatingEllipsis": "Ćŕēàţĩńĝ...",
|
||||
"creatingFrom": "Ćŕēàţĩńĝ {{shortName}} ƒŕōḿ <4>{{displayBranch}}</4>",
|
||||
"creatingFromInto": "Ćŕēàţĩńĝ {{shortName}} ƒŕōḿ <4>{{displayBranch}}</4> → <6>{{baseBranch}}</6>",
|
||||
"deleteSavedQuery": "Ďēĺēţē śàvēď qũēŕŷ",
|
||||
"deleteSavedQueryNamed": "Ďēĺēţē {{label}} śàvēď qũēŕŷ",
|
||||
"describeYourChanges": "Ďēśćŕĩƀē ŷōũŕ ćĥàńĝēś...",
|
||||
"description": "Ďēśćŕĩƥţĩōń",
|
||||
"draftCreated": "Ďŕàƒţ {{shortName}} ćŕēàţēď",
|
||||
"enableIntegrationNamed": "Ēńàƀĺē {{name}}",
|
||||
"enterCommitMessage": "Ēńţēŕ ćōḿḿĩţ ḿēśśàĝē...",
|
||||
"failedToCopyConfig": "Ƒàĩĺēď ţō ćōƥŷ ćōńƒĩĝ: {{err}}",
|
||||
"failedToDeleteSavedQuery": "Ƒàĩĺēď ţō ďēĺēţē śàvēď qũēŕŷ",
|
||||
"failedToSaveSavedQuery": "Ƒàĩĺēď ţō śàvē śàvēď qũēŕŷ",
|
||||
"failedToUpdateDefaultView": "Ƒàĩĺēď ţō ũƥďàţē ďēƒàũĺţ vĩēŵ",
|
||||
"filesChanged_one": "<0>{{count}}</0> ƒĩĺē ćĥàńĝēď",
|
||||
"filesChanged_other": "<0>{{count}}</0> ƒĩĺēś ćĥàńĝēď",
|
||||
"forcePush": "Ƒōŕćē Ƥũśĥ",
|
||||
@@ -76,6 +79,9 @@
|
||||
"retryShortName": "Ŕēţŕŷ {{shortName}}",
|
||||
"saveCurrentQuery": "Śàvē ćũŕŕēńţ qũēŕŷ",
|
||||
"savedQueries": "Śàvēď",
|
||||
"savedQueryDefaultUpdateInProgress": "Ũƥďàţĩńĝ ďēƒàũĺţ vĩēŵ… {{action}}",
|
||||
"setSavedQueryAsDefaultView": "Śēţ {{label}} àś ďēƒàũĺţ vĩēŵ",
|
||||
"clearSavedQueryAsDefaultView": "Ćĺēàŕ {{label}} àś ďēƒàũĺţ vĩēŵ",
|
||||
"selectAWorkspace": "Śēĺēćţ à ŵōŕķśƥàćē…",
|
||||
"shortNameCreated": "{{shortName}} ćŕēàţēď",
|
||||
"stageAllChangesBeforeCommitting": "Śţàĝē àĺĺ ćĥàńĝēś ƀēƒōŕē ćōḿḿĩţţĩńĝ",
|
||||
|
||||
@@ -137,7 +137,6 @@
|
||||
"defaultQueriesSaved": "Consultas pré-definidas guardadas",
|
||||
"defaultRepository": "Repositório pré-definido",
|
||||
"delete": "Eliminar",
|
||||
"deleteSavedQuery": "Eliminar a consulta guardada",
|
||||
"deletedTasks_one": "{{count}} tarefa eliminada",
|
||||
"deletedTasks_other": "{{count}} tarefas eliminadas",
|
||||
"description": "Descrição",
|
||||
|
||||
@@ -37,13 +37,16 @@
|
||||
"creatingEllipsis": "A criar...",
|
||||
"creatingFrom": "A criar o {{shortName}} a partir de <4>{{displayBranch}}</4>",
|
||||
"creatingFromInto": "A criar o {{shortName}} a partir de <4>{{displayBranch}}</4> → <6>{{baseBranch}}</6>",
|
||||
"deleteSavedQuery": "Eliminar a consulta guardada",
|
||||
"deleteSavedQueryNamed": "Eliminar a consulta guardada {{label}}",
|
||||
"describeYourChanges": "Descreva as suas alterações...",
|
||||
"description": "Descrição",
|
||||
"draftCreated": "Rascunho de {{shortName}} criado",
|
||||
"enableIntegrationNamed": "Ativar {{name}}",
|
||||
"enterCommitMessage": "Introduza a mensagem de commit...",
|
||||
"failedToCopyConfig": "Não foi possível copiar a configuração: {{err}}",
|
||||
"failedToDeleteSavedQuery": "Não foi possível eliminar a consulta guardada",
|
||||
"failedToSaveSavedQuery": "Não foi possível guardar a consulta",
|
||||
"failedToUpdateDefaultView": "Não foi possível atualizar a vista pré-definida",
|
||||
"filesChanged_one": "<0>{{count}}</0> ficheiro alterado",
|
||||
"filesChanged_other": "<0>{{count}}</0> ficheiros alterados",
|
||||
"forcePush": "Forçar push",
|
||||
@@ -76,6 +79,9 @@
|
||||
"retryShortName": "Repetir o {{shortName}}",
|
||||
"saveCurrentQuery": "Guardar a consulta atual",
|
||||
"savedQueries": "Guardadas",
|
||||
"savedQueryDefaultUpdateInProgress": "A atualizar a vista pré-definida… {{action}}",
|
||||
"setSavedQueryAsDefaultView": "Definir {{label}} como vista pré-definida",
|
||||
"clearSavedQueryAsDefaultView": "Remover {{label}} como vista pré-definida",
|
||||
"selectAWorkspace": "Selecione uma área de trabalho…",
|
||||
"shortNameCreated": "{{shortName}} criado",
|
||||
"stageAllChangesBeforeCommitting": "Preparar todas as alterações antes de fazer commit",
|
||||
|
||||
@@ -137,7 +137,6 @@
|
||||
"defaultQueriesSaved": "默认查询已保存",
|
||||
"defaultRepository": "默认仓库",
|
||||
"delete": "删除",
|
||||
"deleteSavedQuery": "删除已保存查询",
|
||||
"deletedTasks_one": "已删除 {{count}} 个任务",
|
||||
"deletedTasks_other": "已删除 {{count}} 个任务",
|
||||
"description": "描述",
|
||||
|
||||
@@ -37,13 +37,16 @@
|
||||
"creatingEllipsis": "正在创建…",
|
||||
"creatingFrom": "正在从 <4>{{displayBranch}}</4> 创建 {{shortName}}",
|
||||
"creatingFromInto": "正在从 <4>{{displayBranch}}</4> → <6>{{baseBranch}}</6> 创建 {{shortName}}",
|
||||
"deleteSavedQuery": "删除已保存的查询",
|
||||
"deleteSavedQueryNamed": "删除已保存的查询“{{label}}”",
|
||||
"describeYourChanges": "描述你的更改…",
|
||||
"description": "描述",
|
||||
"draftCreated": "草稿 {{shortName}} 已创建",
|
||||
"enableIntegrationNamed": "启用 {{name}}",
|
||||
"enterCommitMessage": "输入提交信息…",
|
||||
"failedToCopyConfig": "复制配置失败:{{err}}",
|
||||
"failedToDeleteSavedQuery": "删除已保存的查询失败",
|
||||
"failedToSaveSavedQuery": "保存查询失败",
|
||||
"failedToUpdateDefaultView": "更新默认视图失败",
|
||||
"filesChanged_one": "已更改 <0>{{count}}</0> 个文件",
|
||||
"filesChanged_other": "已更改 <0>{{count}}</0> 个文件",
|
||||
"forcePush": "强制推送",
|
||||
@@ -76,6 +79,9 @@
|
||||
"retryShortName": "重试 {{shortName}}",
|
||||
"saveCurrentQuery": "保存当前查询",
|
||||
"savedQueries": "已保存",
|
||||
"savedQueryDefaultUpdateInProgress": "正在更新默认视图…{{action}}",
|
||||
"setSavedQueryAsDefaultView": "将 {{label}} 设为默认视图",
|
||||
"clearSavedQueryAsDefaultView": "取消将 {{label}} 设为默认视图",
|
||||
"selectAWorkspace": "选择工作区…",
|
||||
"shortNameCreated": "{{shortName}} 已创建",
|
||||
"stageAllChangesBeforeCommitting": "提交前暂存所有更改",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
spec: docs/specs/ui/github-saved-query-defaults.md
|
||||
created: 2026-08-07
|
||||
status: completed
|
||||
---
|
||||
|
||||
# Implementation Plan: GitHub Saved-Query Default Views
|
||||
|
||||
## Overview
|
||||
|
||||
Extend the existing saved-query JSON objects with an `isDefault` marker, keep
|
||||
one default per GitHub result kind, and resolve that view on dashboard entry or
|
||||
kind switches. Reuse the existing workspace/user settings queues and shared
|
||||
desktop scope bar while keeping GitLab behavior unchanged.
|
||||
|
||||
## Architecture
|
||||
|
||||
1. A pure saved-preset model validates legacy data, enforces the per-kind
|
||||
invariant, mutates default markers, and resolves default search targets.
|
||||
2. `useSavedPresets` continues to own workspace/user persistence and adds an
|
||||
acknowledged default mutation that publishes only after a successful write.
|
||||
3. A dedicated selection hook applies initial and kind-specific defaults while
|
||||
preserving manual interaction against late hydration.
|
||||
4. GitHub wrappers opt into default controls on the shared desktop scope bar;
|
||||
GitLab omits the optional contract. Mobile renders sibling 44px row actions.
|
||||
|
||||
## Mobile design contract
|
||||
|
||||
Desktop outcome is a set/clear action in the saved-query menu. Mobile entry
|
||||
remains the existing GitHub filter sheet; nearest geometry reference is
|
||||
`apps/web/components/kanban/mobile-menu-sheet.tsx`. The saved list owns vertical
|
||||
scrolling, row selection remains primary, and star/delete controls are visible
|
||||
siblings rather than nested row buttons. Shared state and mutations serve both
|
||||
presentations. Mobile Playwright proves touch reachability, 44px geometry,
|
||||
state isolation, reload persistence, and no document horizontal overflow.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] [Task 01: Persist default saved queries](task-01-persist-default-saved-queries.md)
|
||||
- [x] [Task 02: Apply defaults during selection](task-02-apply-default-selection.md)
|
||||
- [x] [Task 03: Expose desktop and mobile controls](task-03-default-view-controls.md)
|
||||
- [x] [Task 04: Document and verify behavior](task-04-docs-and-verification.md)
|
||||
|
||||
Execution is sequential in the primary conversation. No subagents authorized.
|
||||
|
||||
## Risks
|
||||
|
||||
- Saved settings hydrate asynchronously; a late response must not override a
|
||||
search or default action the user already performed.
|
||||
- Full-array saved-query writes use existing last-write-wins queues; the new
|
||||
acknowledged mutation must not publish a marker before persistence succeeds.
|
||||
- Shared scope-bar changes must remain optional so GitLab gains no new behavior.
|
||||
- Legacy arrays may omit repository/default fields or contain duplicate defaults.
|
||||
- Mobile sibling controls must remain touch-sized without horizontal clipping.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
id: "01-persist-default-saved-queries"
|
||||
title: "Persist default saved queries"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/ui/github-saved-query-defaults.md"
|
||||
---
|
||||
|
||||
# Task 01: Persist Default Saved Queries
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Legacy arrays normalize missing markers to `false` and duplicate defaults to
|
||||
the first valid entry per kind.
|
||||
- Setting, replacing, and clearing one kind's default preserves the other kind.
|
||||
- Workspace and user-setting writes publish new markers only after success;
|
||||
failed workspace writes retain prior state.
|
||||
|
||||
## TDD sequence
|
||||
|
||||
1. Add pure-model and hook regressions and confirm expected RED failures.
|
||||
2. Add the normalized model and acknowledged persistence mutation.
|
||||
3. Rerun focused suites and refactor while green.
|
||||
|
||||
## Files
|
||||
|
||||
- `apps/web/components/github/my-github/saved-preset-model.ts`
|
||||
- `apps/web/components/github/my-github/saved-preset-model.test.ts`
|
||||
- `apps/web/components/github/my-github/use-saved-presets.ts`
|
||||
- `apps/web/components/github/my-github/use-saved-presets.test.ts`
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web exec vitest run components/github/my-github/saved-preset-model.test.ts components/github/my-github/use-saved-presets.test.ts
|
||||
```
|
||||
|
||||
## Result
|
||||
|
||||
- RED: 2 normalization assertions failed because legacy and duplicate markers
|
||||
were not normalized; 6 model-export assertions and 4 persistence assertions
|
||||
then failed before their implementations existed.
|
||||
- GREEN: focused command passed 21 tests across 2 files.
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
id: "02-apply-default-selection"
|
||||
title: "Apply default saved-query selection"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-persist-default-saved-queries"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/ui/github-saved-query-defaults.md"
|
||||
---
|
||||
|
||||
# Task 02: Apply Default Saved-Query Selection
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Dashboard entry resolves the pull-request default after hydration, including
|
||||
query and repository, unless the user already interacted.
|
||||
- Kind switches resolve that kind's saved default or first configured query.
|
||||
- Save/delete/default mutations preserve the current-view rules in the spec.
|
||||
|
||||
## Files
|
||||
|
||||
- `apps/web/components/github/my-github/use-sidebar-selection.ts`
|
||||
- `apps/web/components/github/my-github/use-sidebar-selection.test.ts`
|
||||
- `apps/web/components/github/my-github/use-saved-preset-actions.ts`
|
||||
- `apps/web/components/github/my-github/use-saved-preset-actions.test.ts`
|
||||
- `apps/web/app/github/github-page-client.tsx`
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web exec vitest run components/github/my-github/use-sidebar-selection.test.ts components/github/my-github/use-saved-preset-actions.test.ts
|
||||
```
|
||||
|
||||
## Result
|
||||
|
||||
- RED: initial hydration remained on the first built-in PR query; kind switching
|
||||
selected the first issue query; action tests showed duplicate store ownership,
|
||||
no interaction marker, and no default mutation.
|
||||
- GREEN: focused command passed 10 tests across 2 files.
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
id: "03-default-view-controls"
|
||||
title: "Expose default-view controls"
|
||||
status: done
|
||||
wave: 3
|
||||
depends_on: ["02-apply-default-selection"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/ui/github-saved-query-defaults.md"
|
||||
---
|
||||
|
||||
# Task 03: Expose Default-View Controls
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Desktop and mobile saved-query lists show accessible set/clear markers that
|
||||
do not select or delete the row; failed/pending mutations are safe.
|
||||
- GitLab's shared scope bar remains unchanged when optional default props are absent.
|
||||
- Desktop and mobile Playwright scenarios prove persistence and application of
|
||||
independent kind defaults; mobile also proves 44px reachability and no overflow.
|
||||
|
||||
## Files
|
||||
|
||||
- `apps/web/components/integrations/presets-scope-bar-base.tsx`
|
||||
- `apps/web/components/integrations/presets-scope-bar-base.test.tsx`
|
||||
- `apps/web/components/github/my-github/presets-scope-bar.tsx`
|
||||
- `apps/web/components/github/my-github/presets-sidebar.tsx`
|
||||
- `apps/web/src/locales/en/integrations.json`
|
||||
- `apps/web/src/locales/pseudo/integrations.json`
|
||||
- `apps/web/e2e/tests/github/github-scope-bar.spec.ts`
|
||||
- `apps/web/e2e/tests/github/mobile-github-sidebar.spec.ts`
|
||||
- `apps/web/e2e/pages/mobile-github-page.ts`
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web exec vitest run components/integrations/presets-scope-bar-base.test.tsx
|
||||
cd apps/web && pnpm e2e:run tests/github/github-scope-bar.spec.ts
|
||||
cd apps/web && pnpm e2e:run --project mobile-chrome tests/github/mobile-github-sidebar.spec.ts
|
||||
```
|
||||
|
||||
## Result
|
||||
|
||||
- RED: component regression showed pointer-up selecting the saved row; desktop
|
||||
E2E showed the default action replacing the current query; mobile E2E could
|
||||
not find a touch-sized default action.
|
||||
- GREEN: component suite passed 3 tests. Focused desktop and mobile Playwright
|
||||
scenarios passed, including future-only selection, per-kind persistence,
|
||||
44px mobile geometry, and no horizontal overflow.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: "04-docs-and-verification"
|
||||
title: "Document and verify default views"
|
||||
status: done
|
||||
wave: 4
|
||||
depends_on: ["03-default-view-controls"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/ui/github-saved-query-defaults.md"
|
||||
---
|
||||
|
||||
# Task 04: Document and Verify Default Views
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Public GitHub integration docs explain how per-kind saved defaults behave.
|
||||
- Focused unit, type, lint, i18n, desktop/mobile E2E, and docs checks pass.
|
||||
- Spec, plan, and task statuses record completed behavior and exact results.
|
||||
|
||||
## Files
|
||||
|
||||
- `docs/public/integrations.md`
|
||||
- `docs/specs/ui/github-saved-query-defaults.md`
|
||||
- `docs/plans/github-saved-query-defaults/*`
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/web && pnpm run typecheck
|
||||
cd apps && pnpm --filter @kandev/web lint -- app/github/github-page-client.tsx components/github/my-github components/integrations/presets-scope-bar-base.tsx
|
||||
cd apps && pnpm --filter @kandev/web i18n:check && pnpm --filter @kandev/web i18n:ratchet
|
||||
node --test scripts/validate-public-docs.test.mjs
|
||||
node scripts/validate-public-docs.mjs
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Result
|
||||
|
||||
- Focused Vitest: 34 tests across 5 files passed.
|
||||
- TypeScript typecheck and targeted ESLint passed with zero warnings.
|
||||
- i18n checks and new-code ratchet passed; pseudo locale regenerated.
|
||||
- Desktop and mobile Playwright suites each passed 4 tests.
|
||||
- Public-doc tests passed 58 tests; 41 published pages validated.
|
||||
- `git diff --check` passed.
|
||||
@@ -278,6 +278,8 @@ For recovery:
|
||||
|
||||
Workspace GitHub settings control repository scope, default/saved searches, quick-action prompts, pull-request analytics, review watches, and issue watches. At `/github`, search or browse pull requests and issues, save queries, apply prompt presets, and launch a Kandev task. A saved query can default to one repository; choose **All repos** for no repository default, and change the repository filter without rewriting the saved query. An associated pull request also appears in task review surfaces for feedback, checks, reviews, and merge actions.
|
||||
|
||||
In the **Saved** list, use the star beside a query to set or clear it as the default view. Pull requests and issues keep separate saved defaults. Kandev applies the relevant saved default, including its repository filter, the next time you enter `/github` or switch to that result type; setting or clearing the star does not replace the view currently on screen. Without a saved default, Kandev uses the first configured default query for that result type.
|
||||
|
||||
A **Review Watch** polls a GitHub search and creates review work. It requires a workflow, starting step, prompt, and workspace. The default query is `type:pr state:open review-requested:@me -is:draft`; add repository filters or replace the query as needed. An optional agent or executor profile overrides the selected step's defaults. The poll interval defaults to 300 seconds and accepts 60–3,600 seconds. The prompt field accepts `@name` references to saved prompts, resolved the same way as in a workflow step; see [Saved prompt references in step prompts](workflow-tips.md#saved-prompt-references-in-step-prompts).
|
||||
|
||||
When a review watch is created, Kandev saves its verified target GitHub login. App-backed polling replaces `review-requested:@me` with that explicit login because an installation is not a user. Creating a user-targeted review watch therefore requires a connected personal identity or human PAT/CLI automation identity. A migrated watch with no verified target is disabled until an identity is reconnected.
|
||||
|
||||
@@ -155,6 +155,7 @@ Subscription quota tracking and per-agent cheap-model profile routing.
|
||||
|---|---|
|
||||
| [ci-pr-automation](ui/ci-pr-automation.md) | building |
|
||||
| [github-pr-review-actions](ui/github-pr-review-actions.md) | shipped |
|
||||
| [github-saved-query-defaults](ui/github-saved-query-defaults.md) | shipped |
|
||||
| [pr-task-status-summary](ui/pr-task-status-summary.md) | shipped |
|
||||
| [comment-markdown](ui/comment-markdown.md) | shipped |
|
||||
| [resizable-markdown-tables](ui/resizable-markdown-tables.md) | building |
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
status: shipped
|
||||
created: 2026-08-07
|
||||
owner: Kandev
|
||||
---
|
||||
|
||||
# GitHub Saved-Query Default Views
|
||||
|
||||
## Why
|
||||
|
||||
Saved GitHub queries can capture a team's useful pull-request or issue view,
|
||||
including its repository filter, but users must select that view again every
|
||||
time they enter the dashboard or switch result kind. A saved query should be
|
||||
able to become the default view for its result kind.
|
||||
|
||||
## What
|
||||
|
||||
- GitHub saved queries may be marked as the default view.
|
||||
- Pull requests and issues each own an independent default saved query.
|
||||
- `/github` continues to open on pull requests. It applies the pull-request
|
||||
default when one exists, otherwise the first configured pull-request query.
|
||||
- Switching between pull requests and issues applies the destination kind's
|
||||
default saved query, otherwise its first configured query.
|
||||
- Applying a saved default restores both its custom GitHub query and repository
|
||||
filter.
|
||||
- Setting or clearing a default affects future dashboard entry and kind
|
||||
switches. It does not replace the view currently on screen.
|
||||
- A default marker and set/clear action appear beside each saved query in the
|
||||
desktop menu and mobile filter surface.
|
||||
- New saved queries are not defaults until explicitly marked.
|
||||
|
||||
## Persistence
|
||||
|
||||
The default marker is stored with the existing saved-query object. Workspace
|
||||
GitHub settings own it when a workspace is active; portable user settings own
|
||||
it when no workspace is active. Copying GitHub workspace settings therefore
|
||||
copies default saved views together with saved queries.
|
||||
|
||||
Legacy saved-query objects without a marker remain valid and are treated as
|
||||
non-default. At most one saved query per kind is effective as default. If
|
||||
malformed persisted data marks several defaults for one kind, the first valid
|
||||
entry remains default and later entries are normalized to non-default.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- A failed default update leaves the prior persisted and rendered markers in
|
||||
place, surfaces an error, and permits retry.
|
||||
- Deleting the active saved query keeps the existing first-configured-query
|
||||
fallback.
|
||||
- Deleting an inactive default saved query removes that future default without
|
||||
changing the current view.
|
||||
- If a kind has no configured queries and no saved default, it opens with an
|
||||
empty query and no repository filter.
|
||||
|
||||
## Mobile contract
|
||||
|
||||
The existing GitHub mobile filter sheet remains the entry point. Each saved
|
||||
query row exposes a visible, accessible 44px default action alongside its
|
||||
primary select action. Default and delete actions do not select the row. The
|
||||
sheet keeps one vertical scroll owner and must not create document-level
|
||||
horizontal overflow.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** a saved pull-request query is the pull-request default, **WHEN** the
|
||||
user enters `/github`, **THEN** its label, custom query, and repository filter
|
||||
are active.
|
||||
- **GIVEN** independent pull-request and issue defaults, **WHEN** the user
|
||||
switches kinds, **THEN** the destination kind's saved default becomes active.
|
||||
- **GIVEN** no issue default, **WHEN** the user switches to issues, **THEN** the
|
||||
first configured issue query is active with all repositories selected.
|
||||
- **GIVEN** a non-active saved query, **WHEN** the user marks it default,
|
||||
**THEN** its marker updates after persistence succeeds while the current view
|
||||
stays unchanged.
|
||||
- **GIVEN** a default update fails, **WHEN** the request settles, **THEN** the
|
||||
old default marker remains and an error is shown.
|
||||
- **GIVEN** a phone viewport, **WHEN** the user taps the saved query's default
|
||||
action, **THEN** the marker updates without selecting the row, clipping the
|
||||
action, or causing horizontal overflow.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Choosing built-in configured queries as defaults.
|
||||
- Remembering the last open kind or changing the initial kind from pull requests.
|
||||
- GitLab saved-query defaults.
|
||||
- A separate settings page or save-dialog-only default control.
|
||||
- Renaming the existing GitHub **Default queries** configuration.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
[Implementation plan](../../plans/github-saved-query-defaults/plan.md)
|
||||
Reference in New Issue
Block a user