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:
@@ -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) => ({
|
||||
|
||||
Reference in New Issue
Block a user