feat(web): add repository defaults to saved GitHub queries (#1758)

* feat(web): add repository defaults to saved GitHub queries

* fix(web): commit saved query draft after saving
This commit is contained in:
José Almeida
2026-07-17 14:56:29 +01:00
committed by GitHub
parent bb290d4281
commit db26f2107e
13 changed files with 621 additions and 82 deletions
@@ -1,14 +1,11 @@
"use client";
import { useMemo } from "react";
import { IconRefresh } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Combobox, type ComboboxOption } from "@/components/combobox";
import { cn } from "@/lib/utils";
import { formatRelativeTime } from "@/lib/utils";
const ALL_REPOS = "__all__";
import { RepoFilterCombobox } from "./repo-filter-combobox";
type ListToolbarProps = {
title: string;
@@ -58,13 +55,6 @@ function RefreshControls({
);
}
function buildRepoFilterOptions(repoOptions: string[]): ComboboxOption[] {
return [
{ value: ALL_REPOS, label: "All repos", keywords: ["all", "repositories", "repos"] },
...repoOptions.map((key) => ({ value: key, label: key, keywords: [key] })),
];
}
export function ListToolbar({
title,
count,
@@ -79,9 +69,7 @@ export function ListToolbar({
repoOptions,
onRefresh,
}: ListToolbarProps) {
const selectValue = repoFilter || ALL_REPOS;
const dirty = customQuery !== committedQuery;
const repoFilterOptions = useMemo(() => buildRepoFilterOptions(repoOptions), [repoOptions]);
return (
<div className="px-4 sm:px-6 py-2.5 border-b shrink-0 flex flex-col md:flex-row md:items-center md:flex-wrap gap-2 md:gap-3">
<div className="flex items-center gap-2 min-w-0">
@@ -102,17 +90,11 @@ export function ListToolbar({
/>
</div>
</div>
<Combobox
value={selectValue}
onValueChange={(v) => {
// Combobox signals "toggle off" with ""; All repos is the explicit clear path.
if (!v) return;
onRepoFilterChange(v === ALL_REPOS ? "" : v);
}}
options={repoFilterOptions}
placeholder="All repos"
searchPlaceholder="Filter repositories..."
emptyMessage="No repositories found."
<RepoFilterCombobox
repoFilter={repoFilter}
onRepoFilterChange={onRepoFilterChange}
repoOptions={repoOptions}
ariaLabel="Filter GitHub results by repository"
triggerClassName="w-full md:w-[220px] h-8 border border-input bg-background hover:bg-secondary/50 px-2 py-1.5 text-xs/relaxed"
className="md:min-w-[360px]"
testId="github-repo-filter-trigger"
@@ -0,0 +1,57 @@
"use client";
import { useMemo } from "react";
import { Combobox, type ComboboxOption } from "@/components/combobox";
const ALL_REPOS = "__all__";
type RepoFilterComboboxProps = {
repoFilter: string;
onRepoFilterChange: (value: string) => void;
repoOptions: string[];
ariaLabel: string;
testId: string;
dropdownTestId: string;
triggerClassName?: string;
className?: string;
};
function buildRepoFilterOptions(repoOptions: string[]): ComboboxOption[] {
return [
{ value: ALL_REPOS, label: "All repos", keywords: ["all", "repositories", "repos"] },
...repoOptions.map((repo) => ({ value: repo, label: repo, keywords: [repo] })),
];
}
export function RepoFilterCombobox({
repoFilter,
onRepoFilterChange,
repoOptions,
ariaLabel,
testId,
dropdownTestId,
triggerClassName,
className,
}: RepoFilterComboboxProps) {
const options = useMemo(() => buildRepoFilterOptions(repoOptions), [repoOptions]);
return (
<Combobox
value={repoFilter || ALL_REPOS}
onValueChange={(value) => {
// Combobox signals reselecting the active option with an empty value.
if (!value) return;
onRepoFilterChange(value === ALL_REPOS ? "" : value);
}}
options={options}
ariaLabel={ariaLabel}
placeholder="All repos"
searchPlaceholder="Filter repositories..."
emptyMessage="No repositories found."
triggerClassName={triggerClassName}
className={className}
testId={testId}
dropdownTestId={dropdownTestId}
/>
);
}
@@ -12,6 +12,7 @@ import {
import { Input } from "@kandev/ui/input";
import { Button } from "@kandev/ui/button";
import { Label } from "@kandev/ui/label";
import { RepoFilterCombobox } from "./repo-filter-combobox";
type SavePresetDialogProps = {
open: boolean;
@@ -19,14 +20,16 @@ type SavePresetDialogProps = {
kind: "pr" | "issue";
customQuery: string;
repoFilter: string;
repoOptions: string[];
suggestedLabel: string;
onSave: (label: string) => void;
onSave: (label: string, repoFilter: string) => void;
};
function SavePresetForm({
kind,
customQuery,
repoFilter,
repoOptions,
suggestedLabel,
onSave,
onClose,
@@ -34,19 +37,21 @@ function SavePresetForm({
kind: "pr" | "issue";
customQuery: string;
repoFilter: string;
repoOptions: string[];
suggestedLabel: string;
onSave: (label: string) => void;
onSave: (label: string, repoFilter: string) => void;
onClose: () => void;
}) {
const [value, setValue] = useState(suggestedLabel);
const [defaultRepoFilter, setDefaultRepoFilter] = useState(repoFilter);
const trimmed = value.trim();
const canSubmit = trimmed.length > 0;
const handleSubmit = useCallback(() => {
if (!canSubmit) return;
onSave(trimmed);
onSave(trimmed, defaultRepoFilter);
onClose();
}, [canSubmit, trimmed, onSave, onClose]);
}, [canSubmit, trimmed, defaultRepoFilter, onSave, onClose]);
return (
<>
@@ -80,14 +85,21 @@ function SavePresetForm({
</code>
</div>
)}
{repoFilter && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0 w-16">Repo</span>
<code className="font-mono text-[11px] bg-muted rounded px-1.5 py-0.5 break-all">
{repoFilter}
</code>
</div>
)}
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs">Default repository</Label>
<RepoFilterCombobox
repoFilter={defaultRepoFilter}
onRepoFilterChange={setDefaultRepoFilter}
repoOptions={repoOptions}
ariaLabel="Default repository"
triggerClassName="h-11 border border-input bg-background px-3 py-2 text-sm hover:bg-secondary/50 md:h-9 md:py-1.5"
testId="github-save-query-repo-trigger"
dropdownTestId="github-save-query-repo-dropdown"
/>
<p className="text-xs text-muted-foreground">
This repository opens by default. You can change the filter after opening the query.
</p>
</div>
</div>
<DialogFooter>
@@ -108,6 +120,7 @@ export function SavePresetDialog({
kind,
customQuery,
repoFilter,
repoOptions,
suggestedLabel,
onSave,
}: SavePresetDialogProps) {
@@ -120,6 +133,7 @@ export function SavePresetDialog({
kind={kind}
customQuery={customQuery}
repoFilter={repoFilter}
repoOptions={repoOptions}
suggestedLabel={suggestedLabel}
onSave={onSave}
onClose={handleClose}
@@ -0,0 +1,150 @@
import { act, renderHook } from "@testing-library/react";
import { IconInbox } from "@tabler/icons-react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PresetOption } from "./search-bar";
import { useSavedPresetActions } from "./use-saved-preset-actions";
import { useSavedPresets, type SavedPreset } from "./use-saved-presets";
vi.mock("./use-saved-presets", () => ({
useSavedPresets: vi.fn(),
}));
const QUERY = "assignee:@me is:open";
const REPO = "kdlbs/kandev";
const savedPreset: SavedPreset = {
id: "saved-1",
kind: "issue",
label: "Assigned in Kandev",
customQuery: QUERY,
repoFilter: REPO,
createdAt: "2026-07-17T00:00:00Z",
};
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: QUERY,
group: "inbox",
icon: IconInbox,
};
type Options = Parameters<typeof useSavedPresetActions>[0];
function renderActions(overrides: Partial<Options> = {}) {
const setProgrammaticSelection = vi.fn();
const setQueryImmediate = vi.fn();
const setRepoFilter = vi.fn();
const options: Options = {
workspaceId: "workspace-1",
selection: { kind: "issue", source: "saved", id: savedPreset.id },
customQuery: QUERY,
resolvedPrPresets: [prPreset],
resolvedIssuePresets: [issuePreset],
setProgrammaticSelection,
setQueryImmediate,
setRepoFilter,
...overrides,
};
return {
...renderHook(() => useSavedPresetActions(options)),
setProgrammaticSelection,
setQueryImmediate,
setRepoFilter,
};
}
describe("useSavedPresetActions", () => {
beforeEach(() => {
vi.mocked(useSavedPresets).mockReset();
});
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" },
});
act(() => result.current.onConfirmSave("Assigned in Kandev", REPO));
expect(useSavedPresets).toHaveBeenCalledWith("workspace-1");
expect(save).toHaveBeenCalledWith({
kind: "issue",
label: "Assigned in Kandev",
customQuery: QUERY,
repoFilter: REPO,
});
expect(setProgrammaticSelection).toHaveBeenCalledWith({
kind: "issue",
source: "saved",
id: savedPreset.id,
});
expect(setRepoFilter).toHaveBeenCalledWith(REPO);
expect(setQueryImmediate).toHaveBeenCalledWith(QUERY);
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();
act(() => result.current.onConfirmSave("Unavailable", REPO));
expect(save).toHaveBeenCalledWith({
kind: "issue",
label: "Unavailable",
customQuery: QUERY,
repoFilter: REPO,
});
expect(setProgrammaticSelection).not.toHaveBeenCalled();
expect(setQueryImmediate).not.toHaveBeenCalled();
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();
act(() => result.current.onDeleteSaved(savedPreset.id));
expect(remove).toHaveBeenCalledWith(savedPreset.id);
expect(setProgrammaticSelection).toHaveBeenCalledWith({
kind: "issue",
source: "preset",
id: issuePreset.value,
});
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" },
});
act(() => result.current.onDeleteSaved(savedPreset.id));
expect(remove).toHaveBeenCalledWith(savedPreset.id);
expect(setProgrammaticSelection).not.toHaveBeenCalled();
expect(setQueryImmediate).not.toHaveBeenCalled();
expect(setRepoFilter).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,69 @@
"use client";
import type { PresetOption } from "./search-bar";
import type { SidebarSelection } from "./presets-sidebar";
import { useSavedPresets } from "./use-saved-presets";
type SavedPresetActionsOptions = {
workspaceId: string | null;
selection: SidebarSelection;
customQuery: string;
resolvedPrPresets: PresetOption[];
resolvedIssuePresets: PresetOption[];
setProgrammaticSelection: (selection: SidebarSelection) => void;
setQueryImmediate: (query: string) => void;
setRepoFilter: (repo: string) => void;
};
function firstPresetSelection(
kind: SidebarSelection["kind"],
pr: PresetOption[],
issue: PresetOption[],
) {
const preset = (kind === "pr" ? pr : issue)[0];
return {
selection: { kind, source: "preset", id: preset?.value ?? "" } as SidebarSelection,
filter: preset?.filter ?? "",
};
}
export function useSavedPresetActions({
workspaceId,
selection,
customQuery,
resolvedPrPresets,
resolvedIssuePresets,
setProgrammaticSelection,
setQueryImmediate,
setRepoFilter,
}: SavedPresetActionsOptions) {
const {
presets: savedPresets,
save: saveSavedPreset,
remove: removeSavedPreset,
} = useSavedPresets(workspaceId);
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 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 };
}
@@ -45,7 +45,7 @@ function resetTestState() {
}
function workspaceSettings(
savedPresets: SavedPreset[] = [],
savedPresets: unknown = [],
): Awaited<ReturnType<typeof fetchGitHubWorkspaceSettings>> {
return {
workspace_id: WORKSPACE_ID,
@@ -169,3 +169,74 @@ describe("useSavedPresets workspace sync", () => {
expect(updateGitHubWorkspaceSettings).not.toHaveBeenCalled();
});
});
describe("useSavedPresets repository defaults", () => {
beforeEach(() => {
resetTestState();
});
it("preserves the chosen repository in the workspace settings update", async () => {
const server = { ...valid, id: "p_server", label: "Server" };
vi.mocked(fetchGitHubWorkspaceSettings).mockResolvedValue(workspaceSettings([server]));
vi.mocked(updateGitHubWorkspaceSettings).mockResolvedValue(workspaceSettings());
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
await waitFor(() => expect(result.current.presets).toEqual([server]));
act(() => {
result.current.save({
kind: "pr",
label: "Kandev PRs",
customQuery: "is:open",
repoFilter: "kdlbs/kandev",
});
});
await waitFor(() =>
expect(updateGitHubWorkspaceSettings).toHaveBeenCalledWith({
workspace_id: WORKSPACE_ID,
saved_presets: [
server,
expect.objectContaining({
kind: "pr",
label: "Kandev PRs",
customQuery: "is:open",
repoFilter: "kdlbs/kandev",
}),
],
}),
);
});
it.each([
{
caseName: "missing",
serverPreset: {
id: "p_legacy_missing",
kind: "pr",
label: "Legacy missing repo",
customQuery: "author:@me",
createdAt: SETTINGS_TIMESTAMP,
},
},
{
caseName: "non-string",
serverPreset: {
id: "p_legacy_invalid",
kind: "pr",
label: "Legacy invalid repo",
customQuery: "author:@me",
repoFilter: 42,
createdAt: SETTINGS_TIMESTAMP,
},
},
])("normalizes a $caseName server repoFilter to All repos", async ({ serverPreset }) => {
vi.mocked(fetchGitHubWorkspaceSettings).mockResolvedValue(workspaceSettings([serverPreset]));
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
await waitFor(() =>
expect(result.current.presets).toEqual([{ ...serverPreset, repoFilter: "" }]),
);
});
});
@@ -45,14 +45,24 @@ function publish(next: SavedPreset[]) {
function readServerPresets(value: unknown): SavedPreset[] | null {
if (!Array.isArray(value)) return null;
return value.filter(
(p): p is SavedPreset =>
typeof p === "object" &&
p !== null &&
typeof (p as SavedPreset).id === "string" &&
((p as SavedPreset).kind === "pr" || (p as SavedPreset).kind === "issue") &&
typeof (p as SavedPreset).label === "string",
);
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) => ({