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:
@@ -29,7 +29,8 @@ import {
|
||||
useDefaultQueryPresets,
|
||||
resolvePresetOptions,
|
||||
} from "@/components/github/my-github/use-default-query-presets";
|
||||
import { useSavedPresets, type SavedPreset } from "@/components/github/my-github/use-saved-presets";
|
||||
import type { SavedPreset } from "@/components/github/my-github/use-saved-presets";
|
||||
import { useSavedPresetActions } from "@/components/github/my-github/use-saved-preset-actions";
|
||||
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";
|
||||
@@ -236,18 +237,6 @@ function useInitialSidebarSelection(
|
||||
return { selection, setProgrammaticSelection: setSelection, setUserSelection };
|
||||
}
|
||||
|
||||
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 ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function useSidebarSelectionHandler({
|
||||
savedPresets,
|
||||
resolvedPrPresets,
|
||||
@@ -331,11 +320,6 @@ function useGitHubPageState(workspaceId: string | null) {
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||
const { autoResetSearchRef, markSearchInteracted, setCustomQuery, setRepoFilter } =
|
||||
useSearchInteractionControls(setCustomQueryRaw, setRepoFilterRaw);
|
||||
const {
|
||||
presets: savedPresets,
|
||||
save: saveSavedPreset,
|
||||
remove: removeSavedPreset,
|
||||
} = useSavedPresets(workspaceId);
|
||||
const { selection, setProgrammaticSelection, setUserSelection } = useInitialSidebarSelection(
|
||||
workspaceId,
|
||||
resolvedPrPresets,
|
||||
@@ -343,6 +327,16 @@ function useGitHubPageState(workspaceId: string | null) {
|
||||
setQueryImmediate,
|
||||
setRepoFilterRaw,
|
||||
);
|
||||
const { savedPresets, onConfirmSave, onDeleteSaved } = useSavedPresetActions({
|
||||
workspaceId,
|
||||
selection,
|
||||
customQuery,
|
||||
resolvedPrPresets,
|
||||
resolvedIssuePresets,
|
||||
setProgrammaticSelection,
|
||||
setQueryImmediate,
|
||||
setRepoFilter: setRepoFilterRaw,
|
||||
});
|
||||
|
||||
const presets = selection.kind === "pr" ? resolvedPrPresets : resolvedIssuePresets;
|
||||
const search = useGitHubSearch<GitHubPR | GitHubIssue>({
|
||||
@@ -378,24 +372,6 @@ function useGitHubPageState(workspaceId: string | null) {
|
||||
const canSaveCurrent = customQuery.trim().length > 0 || repoFilter.length > 0;
|
||||
const suggestedLabel = customQuery.trim() || (repoFilter ? `In ${repoFilter}` : "Saved query");
|
||||
const onOpenSaveDialog = () => canSaveCurrent && setSaveDialogOpen(true);
|
||||
const onConfirmSave = (label: string) => {
|
||||
const created = saveSavedPreset({ kind: selection.kind, label, customQuery, repoFilter });
|
||||
if (!created) return;
|
||||
setProgrammaticSelection({ kind: selection.kind, source: "saved", id: created.id });
|
||||
};
|
||||
const onDeleteSaved = (id: string) => {
|
||||
removeSavedPreset(id);
|
||||
if (selection.source === "saved" && selection.id === id) {
|
||||
const fallback = firstPresetSelection(
|
||||
selection.kind,
|
||||
resolvedPrPresets,
|
||||
resolvedIssuePresets,
|
||||
);
|
||||
setProgrammaticSelection(fallback.selection);
|
||||
setQueryImmediate(fallback.filter);
|
||||
setRepoFilterRaw("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
selection,
|
||||
@@ -579,6 +555,7 @@ export function GitHubPageClient({
|
||||
kind={state.selection.kind}
|
||||
customQuery={state.customQuery}
|
||||
repoFilter={state.repoFilter}
|
||||
repoOptions={state.repoOptions}
|
||||
suggestedLabel={state.suggestedLabel}
|
||||
onSave={state.onConfirmSave}
|
||||
/>
|
||||
|
||||
@@ -38,6 +38,7 @@ interface ComboboxProps {
|
||||
options: ComboboxOption[];
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
ariaLabel?: string;
|
||||
dropdownLabel?: string;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
@@ -141,6 +142,7 @@ export const Combobox = memo(function Combobox({
|
||||
options,
|
||||
value,
|
||||
onValueChange,
|
||||
ariaLabel,
|
||||
dropdownLabel,
|
||||
placeholder = "Select option...",
|
||||
searchPlaceholder = "Search...",
|
||||
@@ -179,6 +181,7 @@ export const Combobox = memo(function Combobox({
|
||||
<Button
|
||||
variant="ghost"
|
||||
role="combobox"
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={open}
|
||||
className={cn("w-full justify-between", !disabled && "cursor-pointer", triggerClassName)}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -7,6 +7,8 @@ export class MobileGitHubPage {
|
||||
readonly toolbarTitle: Locator;
|
||||
readonly repoFilterTrigger: Locator;
|
||||
readonly repoSearchInput: Locator;
|
||||
readonly saveQueryRepoTrigger: Locator;
|
||||
readonly saveQueryRepoDropdown: Locator;
|
||||
|
||||
constructor(private page: Page) {
|
||||
this.mobileMenuButton = page.getByTestId("github-mobile-menu-button");
|
||||
@@ -17,6 +19,8 @@ export class MobileGitHubPage {
|
||||
this.toolbarTitle = page.getByTestId("github-list-toolbar-title");
|
||||
this.repoFilterTrigger = page.getByTestId("github-repo-filter-trigger");
|
||||
this.repoSearchInput = page.getByTestId("github-repo-filter-dropdown").getByRole("combobox");
|
||||
this.saveQueryRepoTrigger = page.getByTestId("github-save-query-repo-trigger");
|
||||
this.saveQueryRepoDropdown = page.getByTestId("github-save-query-repo-dropdown");
|
||||
}
|
||||
|
||||
async goto() {
|
||||
@@ -30,4 +34,12 @@ export class MobileGitHubPage {
|
||||
presetByLabel(label: string): Locator {
|
||||
return this.mobileSidebar.getByRole("button", { name: label });
|
||||
}
|
||||
|
||||
issueRowByTitle(title: string): Locator {
|
||||
return this.page.getByTestId("issue-row").filter({ hasText: title });
|
||||
}
|
||||
|
||||
savedQueryByLabel(label: string): Locator {
|
||||
return this.mobileSidebar.getByText(label, { exact: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
|
||||
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";
|
||||
|
||||
function issueRow(testPage: Page, title: string) {
|
||||
return testPage.getByTestId("issue-row").filter({ hasText: title });
|
||||
}
|
||||
|
||||
// The /github dashboard used to render its own w-60 presets rail next to the
|
||||
// global AppSidebar (a redundant "double sidebar"). On desktop that rail is now
|
||||
// a horizontal scope bar; this guards that it drives presets/kind and that the
|
||||
@@ -83,4 +93,93 @@ test.describe("Desktop /github scope bar", () => {
|
||||
"testorg/testrepo",
|
||||
);
|
||||
});
|
||||
|
||||
test("saved query defaults to its chosen repository and persists", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
const savedQuery = "Default repo issues";
|
||||
await apiClient.mockGitHubReset();
|
||||
await apiClient.mockGitHubSetUser("test-user");
|
||||
await apiClient.mockGitHubAddIssues([
|
||||
{
|
||||
number: 31,
|
||||
title: DEFAULT_REPO_ISSUE,
|
||||
state: "open",
|
||||
author_login: "test-user",
|
||||
repo_owner: "defaultorg",
|
||||
repo_name: "defaultrepo",
|
||||
assignees: ["test-user"],
|
||||
},
|
||||
{
|
||||
number: 32,
|
||||
title: OTHER_REPO_ISSUE,
|
||||
state: "open",
|
||||
author_login: "test-user",
|
||||
repo_owner: "otherorg",
|
||||
repo_name: "otherrepo",
|
||||
assignees: ["test-user"],
|
||||
},
|
||||
]);
|
||||
|
||||
await testPage.goto("/github");
|
||||
|
||||
const scopeBar = testPage.getByTestId("github-presets-scope-bar");
|
||||
const savedMenu = testPage.getByTestId("github-saved-queries-menu");
|
||||
const repoFilter = testPage.getByTestId("github-repo-filter-trigger");
|
||||
await expect(scopeBar).toBeVisible();
|
||||
await scopeBar.getByRole("button", { name: "Issues", exact: true }).click();
|
||||
const queryInput = testPage.getByPlaceholder(/Custom query/);
|
||||
await queryInput.fill(SAVED_QUERY_FILTER);
|
||||
await queryInput.press("Enter");
|
||||
await expect(testPage.getByTestId("issue-row")).toHaveCount(2, { timeout: 15_000 });
|
||||
|
||||
await savedMenu.click();
|
||||
await testPage.getByRole("menuitem", { name: "Save current query" }).click();
|
||||
const dialog = testPage.getByRole("dialog", { name: "Save query" });
|
||||
await dialog.getByLabel("Name").fill(savedQuery);
|
||||
const saveRepoTrigger = dialog.getByTestId("github-save-query-repo-trigger");
|
||||
await expect(saveRepoTrigger).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => (await saveRepoTrigger.boundingBox())?.height ?? 0)
|
||||
.toBeCloseTo(36, 0);
|
||||
await saveRepoTrigger.click();
|
||||
const saveRepoDropdown = testPage.getByTestId("github-save-query-repo-dropdown");
|
||||
await expect(saveRepoDropdown).toBeVisible();
|
||||
await saveRepoDropdown.getByRole("option", { name: DEFAULT_REPO, exact: true }).click();
|
||||
const saveResponse = testPage.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes("/api/v1/github/workspace-settings") &&
|
||||
response.request().method() === "PUT" &&
|
||||
response.status() === 200,
|
||||
);
|
||||
await dialog.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await saveResponse;
|
||||
|
||||
await expect(repoFilter).toContainText(DEFAULT_REPO);
|
||||
await expect(issueRow(testPage, DEFAULT_REPO_ISSUE)).toBeVisible();
|
||||
await expect(issueRow(testPage, OTHER_REPO_ISSUE)).toHaveCount(0);
|
||||
|
||||
await repoFilter.click();
|
||||
await testPage
|
||||
.getByTestId("github-repo-filter-dropdown")
|
||||
.getByRole("option", { name: "All repos", exact: true })
|
||||
.click();
|
||||
await expect(testPage.getByTestId("issue-row")).toHaveCount(2);
|
||||
|
||||
await savedMenu.click();
|
||||
await testPage.getByRole("menuitem").filter({ hasText: savedQuery }).click();
|
||||
await expect(repoFilter).toContainText(DEFAULT_REPO);
|
||||
await expect(issueRow(testPage, DEFAULT_REPO_ISSUE)).toBeVisible();
|
||||
await expect(issueRow(testPage, OTHER_REPO_ISSUE)).toHaveCount(0);
|
||||
|
||||
await testPage.reload();
|
||||
await expect(scopeBar).toBeVisible();
|
||||
await scopeBar.getByRole("button", { name: "Issues", exact: true }).click();
|
||||
await savedMenu.click();
|
||||
await testPage.getByRole("menuitem").filter({ hasText: savedQuery }).click();
|
||||
await expect(repoFilter).toContainText(DEFAULT_REPO);
|
||||
await expect(issueRow(testPage, DEFAULT_REPO_ISSUE)).toBeVisible();
|
||||
await expect(issueRow(testPage, OTHER_REPO_ISSUE)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { MobileGitHubPage } from "../../pages/mobile-github-page";
|
||||
|
||||
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";
|
||||
|
||||
test.describe("Mobile /github sidebar", () => {
|
||||
test("hamburger opens sheet and selecting a preset updates the toolbar", async ({
|
||||
testPage,
|
||||
@@ -82,4 +87,94 @@ test.describe("Mobile /github sidebar", () => {
|
||||
await testPage.getByRole("option", { name: "testorg/testrepo" }).tap();
|
||||
await expect(page.repoFilterTrigger).toContainText("testorg/testrepo");
|
||||
});
|
||||
|
||||
test("saved query defaults to its chosen repository and persists by touch", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
const savedQuery = "Mobile default repo issues";
|
||||
await apiClient.mockGitHubReset();
|
||||
await apiClient.mockGitHubSetUser("test-user");
|
||||
await apiClient.mockGitHubAddIssues([
|
||||
{
|
||||
number: 220,
|
||||
title: DEFAULT_REPO_ISSUE,
|
||||
state: "open",
|
||||
author_login: "test-user",
|
||||
repo_owner: "mobileorg",
|
||||
repo_name: "defaultrepo",
|
||||
assignees: ["test-user"],
|
||||
},
|
||||
{
|
||||
number: 221,
|
||||
title: OTHER_REPO_ISSUE,
|
||||
state: "open",
|
||||
author_login: "test-user",
|
||||
repo_owner: "otherorg",
|
||||
repo_name: "otherrepo",
|
||||
assignees: ["test-user"],
|
||||
},
|
||||
]);
|
||||
|
||||
const page = new MobileGitHubPage(testPage);
|
||||
await page.goto();
|
||||
await page.mobileMenuButton.tap();
|
||||
await page.mobileSidebar.getByRole("button", { name: "Issues", exact: true }).tap();
|
||||
const queryInput = testPage.getByPlaceholder(/Custom query/);
|
||||
await queryInput.fill(SAVED_QUERY_FILTER);
|
||||
await queryInput.press("Enter");
|
||||
await expect(testPage.getByTestId("issue-row")).toHaveCount(2, { timeout: 15_000 });
|
||||
|
||||
await page.mobileMenuButton.tap();
|
||||
await page.mobileSidebar.getByRole("button", { name: "Save current query" }).tap();
|
||||
const dialog = testPage.getByRole("dialog", { name: "Save query" });
|
||||
await dialog.getByLabel("Name").fill(savedQuery);
|
||||
await expect(page.saveQueryRepoTrigger).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => (await page.saveQueryRepoTrigger.boundingBox())?.height ?? 0)
|
||||
.toBeCloseTo(44, 0);
|
||||
await page.saveQueryRepoTrigger.tap();
|
||||
await expect(page.saveQueryRepoDropdown).toBeVisible();
|
||||
await page.saveQueryRepoDropdown.getByRole("option", { name: DEFAULT_REPO, exact: true }).tap();
|
||||
const saveResponse = testPage.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes("/api/v1/github/workspace-settings") &&
|
||||
response.request().method() === "PUT" &&
|
||||
response.status() === 200,
|
||||
);
|
||||
await dialog.getByRole("button", { name: "Save", exact: true }).tap();
|
||||
await saveResponse;
|
||||
|
||||
await expect(page.repoFilterTrigger).toContainText(DEFAULT_REPO);
|
||||
await expect(page.issueRowByTitle(DEFAULT_REPO_ISSUE)).toBeVisible();
|
||||
await expect(page.issueRowByTitle(OTHER_REPO_ISSUE)).toHaveCount(0);
|
||||
|
||||
await page.repoFilterTrigger.tap();
|
||||
await testPage
|
||||
.getByTestId("github-repo-filter-dropdown")
|
||||
.getByRole("option", { name: "All repos", exact: true })
|
||||
.tap();
|
||||
await expect(testPage.getByTestId("issue-row")).toHaveCount(2);
|
||||
|
||||
await page.mobileMenuButton.tap();
|
||||
await page.savedQueryByLabel(savedQuery).tap();
|
||||
await expect(page.repoFilterTrigger).toContainText(DEFAULT_REPO);
|
||||
await expect(page.issueRowByTitle(DEFAULT_REPO_ISSUE)).toBeVisible();
|
||||
await expect(page.issueRowByTitle(OTHER_REPO_ISSUE)).toHaveCount(0);
|
||||
|
||||
await testPage.reload();
|
||||
await page.mobileMenuButton.waitFor({ state: "visible" });
|
||||
await page.mobileMenuButton.tap();
|
||||
await page.mobileSidebar.getByRole("button", { name: "Issues", exact: true }).tap();
|
||||
await page.mobileMenuButton.tap();
|
||||
await page.savedQueryByLabel(savedQuery).tap();
|
||||
await expect(page.repoFilterTrigger).toContainText(DEFAULT_REPO);
|
||||
await expect(page.issueRowByTitle(DEFAULT_REPO_ISSUE)).toBeVisible();
|
||||
await expect(page.issueRowByTitle(OTHER_REPO_ISSUE)).toHaveCount(0);
|
||||
expect(
|
||||
await testPage.evaluate(
|
||||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ The status panel reports the authenticated user, selected method, rate-limit inf
|
||||
|
||||
### Configure and use the workspace
|
||||
|
||||
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. An associated pull request also appears in task review surfaces for feedback, checks, reviews, and merge actions.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user