From 437f610d60421356bdb6faf2a3b69ba4842efc50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:52:34 +0100 Subject: [PATCH] feat(github): add saved query default views (#2399) --- apps/packages/ui/src/dropdown-menu.tsx | 23 +- apps/web/app/github/github-page-client.tsx | 141 ++--- .../my-github/presets-scope-bar.test.tsx | 208 ++++++ .../github/my-github/presets-scope-bar.tsx | 41 +- .../github/my-github/presets-sidebar.test.tsx | 115 ++++ .../github/my-github/presets-sidebar.tsx | 122 ++-- .../my-github/saved-preset-model.test.ts | 147 +++++ .../github/my-github/saved-preset-model.ts | 100 +++ .../use-saved-preset-actions.test.ts | 515 +++++++++++++-- .../my-github/use-saved-preset-actions.ts | 250 +++++++- .../use-saved-presets-concurrency.test.ts | 590 ++++++++++++++++++ .../my-github/use-saved-presets.test.ts | 289 ++++++++- .../github/my-github/use-saved-presets.ts | 291 +++++++-- .../my-github/use-sidebar-selection.test.ts | 409 ++++++++++++ .../github/my-github/use-sidebar-selection.ts | 154 +++++ .../presets-scope-bar-base.test.tsx | 234 +++++++ .../integrations/presets-scope-bar-base.tsx | 109 +++- .../saved-query-default-button.tsx | 105 ++++ apps/web/e2e/pages/mobile-github-page.ts | 6 + .../e2e/tests/github/github-scope-bar.spec.ts | 155 +++++ .../github/mobile-github-sidebar.spec.ts | 88 +++ apps/web/src/locales/en/github.json | 1 - apps/web/src/locales/en/integrations.json | 8 +- apps/web/src/locales/pseudo/github.json | 1 - apps/web/src/locales/pseudo/integrations.json | 8 +- apps/web/src/locales/pt-pt/github.json | 1 - apps/web/src/locales/pt-pt/integrations.json | 8 +- apps/web/src/locales/zh-cn/github.json | 1 - apps/web/src/locales/zh-cn/integrations.json | 8 +- .../plans/github-saved-query-defaults/plan.md | 54 ++ .../task-01-persist-default-saved-queries.md | 45 ++ .../task-02-apply-default-selection.md | 39 ++ .../task-03-default-view-controls.md | 48 ++ .../task-04-docs-and-verification.md | 43 ++ docs/public/integrations.md | 2 + docs/specs/INDEX.md | 1 + docs/specs/ui/github-saved-query-defaults.md | 91 +++ 37 files changed, 4102 insertions(+), 349 deletions(-) create mode 100644 apps/web/components/github/my-github/presets-scope-bar.test.tsx create mode 100644 apps/web/components/github/my-github/presets-sidebar.test.tsx create mode 100644 apps/web/components/github/my-github/saved-preset-model.test.ts create mode 100644 apps/web/components/github/my-github/saved-preset-model.ts create mode 100644 apps/web/components/github/my-github/use-saved-presets-concurrency.test.ts create mode 100644 apps/web/components/github/my-github/use-sidebar-selection.test.ts create mode 100644 apps/web/components/github/my-github/use-sidebar-selection.ts create mode 100644 apps/web/components/integrations/presets-scope-bar-base.test.tsx create mode 100644 apps/web/components/integrations/saved-query-default-button.tsx create mode 100644 docs/plans/github-saved-query-defaults/plan.md create mode 100644 docs/plans/github-saved-query-defaults/task-01-persist-default-saved-queries.md create mode 100644 docs/plans/github-saved-query-defaults/task-02-apply-default-selection.md create mode 100644 docs/plans/github-saved-query-defaults/task-03-default-view-controls.md create mode 100644 docs/plans/github-saved-query-defaults/task-04-docs-and-verification.md create mode 100644 docs/specs/ui/github-saved-query-defaults.md diff --git a/apps/packages/ui/src/dropdown-menu.tsx b/apps/packages/ui/src/dropdown-menu.tsx index 8bf908662f..561afde215 100644 --- a/apps/packages/ui/src/dropdown-menu.tsx +++ b/apps/packages/ui/src/dropdown-menu.tsx @@ -75,8 +75,11 @@ function DropdownMenuCheckboxItem({ className, children, checked, + showIndicator = true, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + showIndicator?: boolean; +}) { return ( - - - - - + {showIndicator && ( + + + + + + )} {children} ); diff --git a/apps/web/app/github/github-page-client.tsx b/apps/web/app/github/github-page-client.tsx index daafb33406..32383431b8 100644 --- a/apps/web/app/github/github-page-client.tsx +++ b/apps/web/app/github/github-page-client.tsx @@ -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, - setQueryImmediate: (query: string) => void, - setRepoFilter: (repo: string) => void, -) { - const userSelectedRef = useRef(false); - const [selection, setSelection] = useState(() => ({ - 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({ 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} diff --git a/apps/web/components/github/my-github/presets-scope-bar.test.tsx b/apps/web/components/github/my-github/presets-scope-bar.test.tsx new file mode 100644 index 0000000000..216b5cd988 --- /dev/null +++ b/apps/web/components/github/my-github/presets-scope-bar.test.tsx @@ -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 ( +
+
+ ); + }, +})); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +function expectExplicitKindSwitchRequest() { + const onSelect = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByTestId("scope-bar-kind-switch")); + expect(onSelect).toHaveBeenCalledWith({ kind: "issue", source: "kind-switch" }); +} + +function expectActiveKindRequestIgnored() { + const onSelect = vi.fn(); + + render( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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(); + const refreshedPreset = { ...savedPreset, label: "Updated", isDefault: true }; + + rerender(); + + 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( + , + ); + + fireEvent.click(screen.getByTestId("scope-bar-default-toggle")); + expect(warn).toHaveBeenCalledWith("[github:presets] default toggle target missing", { + id: "missing-preset", + }); + expect(onToggleSavedDefault).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/github/my-github/presets-scope-bar.tsx b/apps/web/components/github/my-github/presets-scope-bar.tsx index 39aa7757f1..7e8c48f5c8 100644 --- a/apps/web/components/github/my-github/presets-scope-bar.tsx +++ b/apps/web/components/github/my-github/presets-scope-bar.tsx @@ -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 ( ({ value, label: t(labelKey) }))} diff --git a/apps/web/components/github/my-github/presets-sidebar.test.tsx b/apps/web/components/github/my-github/presets-sidebar.test.tsx new file mode 100644 index 0000000000..51f7e222f7 --- /dev/null +++ b/apps/web/components/github/my-github/presets-sidebar.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Pull requests" })); + + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("deletes a saved query without selecting it", expectDeleteDoesNotSelect); +}); diff --git a/apps/web/components/github/my-github/presets-sidebar.tsx b/apps/web/components/github/my-github/presets-sidebar.tsx index 5ed4f0827f..7136a4e546 100644 --- a/apps/web/components/github/my-github/presets-sidebar.tsx +++ b/apps/web/components/github/my-github/presets-sidebar.tsx @@ -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) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onClick(); - } - }; return (
- - {label} + {trailing}
); @@ -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 ( <> @@ -158,34 +166,49 @@ function SavedSection({ {t("github:noSavedQueriesYet")} )} - {saved.map((s) => ( - onSelect({ kind, source: "saved", id: s.id })} - trailing={ - - } - /> - ))} + {saved.map((s) => { + const deleteLabel = t("integrations:deleteSavedQueryNamed", { label: s.label }); + const accessibleDeleteLabel = defaultMutationPending + ? t("integrations:savedQueryDefaultUpdateInProgress", { action: deleteLabel }) + : deleteLabel; + return ( + onSelect({ kind, source: "saved", id: s.id })} + trailing={ +
+ void onToggleSavedDefault(s)} + /> + +
+ } + /> + ); + })} - - )) + saved.map((s) => { + const deleteLabel = t("integrations:deleteSavedQueryNamed", { label: s.label }); + const accessibleDeleteLabel = defaultMutationPending + ? t("integrations:savedQueryDefaultUpdateInProgress", { action: deleteLabel }) + : deleteLabel; + return ( +
+ onSelect({ kind: s.kind, source: "saved", id: s.id })} + className="min-w-0 flex-1 cursor-pointer gap-2" + > + + {s.label} + + {onToggleSavedDefault && ( + onToggleSavedDefault(s.id)} + /> + )} + {/* A peer Radix item is intentional: arrow navigation reaches delete, + and focus opacity reveals it without pointer hover. */} + { + 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} + > + + +
+ ); + }) )} ({ ); } +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 = { className?: string; testId: string; @@ -187,12 +223,14 @@ export type IntegrationScopeBarProps = { kinds: ReadonlyArray<{ value: K; label: string }>; selected: ScopeSelection; onSelect: (s: ScopeSelection) => void; + /** Overrides the default first-preset selection when the active kind changes. */ + onKindChange?: (kind: K) => void; presetsByKind: (kind: K) => ScopePreset[]; savedPresets: ScopeSavedPreset[]; onDeleteSaved: (id: string) => void; canSaveCurrent: boolean; onSaveCurrent: () => void; -}; +} & SavedDefaultActionProps; export function IntegrationScopeBar({ className, @@ -201,20 +239,31 @@ export function IntegrationScopeBar({ kinds, selected, onSelect, + onKindChange, presetsByKind, savedPresets, onDeleteSaved, canSaveCurrent, onSaveCurrent, + onToggleSavedDefault, + defaultMutationPendingId = null, }: IntegrationScopeBarProps) { 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) => ( ({ className={cn("flex items-center gap-1.5 overflow-x-auto px-4 py-2 sm:px-6", className)} data-testid={testId} > - + {inbox.map(renderPill)} {inbox.length > 0 && created.length > 0 && } @@ -245,6 +294,8 @@ export function IntegrationScopeBar({ onDeleteSaved={onDeleteSaved} canSaveCurrent={canSaveCurrent} onSaveCurrent={onSaveCurrent} + onToggleSavedDefault={onToggleSavedDefault} + defaultMutationPendingId={defaultMutationPendingId} /> diff --git a/apps/web/components/integrations/saved-query-default-button.tsx b/apps/web/components/integrations/saved-query-default-button.tsx new file mode 100644 index 0000000000..e6d0066dd0 --- /dev/null +++ b/apps/web/components/integrations/saved-query-default-button.tsx @@ -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 ( + 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" + > + + + ); +} + +export function SavedQueryDefaultButton({ + label, + isDefault, + disabled = false, + pending = false, + testId, + onToggle, +}: SavedQueryDefaultControlProps) { + const { accessibleLabel, iconClassName } = useSavedQueryDefaultPresentation( + label, + isDefault, + disabled, + pending, + ); + return ( + + ); +} diff --git a/apps/web/e2e/pages/mobile-github-page.ts b/apps/web/e2e/pages/mobile-github-page.ts index 89803e6f17..576364475f 100644 --- a/apps/web/e2e/pages/mobile-github-page.ts +++ b/apps/web/e2e/pages/mobile-github-page.ts @@ -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`, + }); + } } diff --git a/apps/web/e2e/tests/github/github-scope-bar.spec.ts b/apps/web/e2e/tests/github/github-scope-bar.spec.ts index efb74bc88f..f9c3553224 100644 --- a/apps/web/e2e/tests/github/github-scope-bar.spec.ts +++ b/apps/web/e2e/tests/github/github-scope-bar.spec.ts @@ -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, diff --git a/apps/web/e2e/tests/github/mobile-github-sidebar.spec.ts b/apps/web/e2e/tests/github/mobile-github-sidebar.spec.ts index 0a4be72467..67e87e602f 100644 --- a/apps/web/e2e/tests/github/mobile-github-sidebar.spec.ts +++ b/apps/web/e2e/tests/github/mobile-github-sidebar.spec.ts @@ -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, diff --git a/apps/web/src/locales/en/github.json b/apps/web/src/locales/en/github.json index a59519e698..fcae8b7cf0 100644 --- a/apps/web/src/locales/en/github.json +++ b/apps/web/src/locales/en/github.json @@ -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", diff --git a/apps/web/src/locales/en/integrations.json b/apps/web/src/locales/en/integrations.json index 375b9fe68f..e147c2fa2c 100644 --- a/apps/web/src/locales/en/integrations.json +++ b/apps/web/src/locales/en/integrations.json @@ -37,13 +37,16 @@ "creatingEllipsis": "Creating...", "creatingFrom": "Creating {{shortName}} from <4>{{displayBranch}}", "creatingFromInto": "Creating {{shortName}} from <4>{{displayBranch}} → <6>{{baseBranch}}", - "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}} file changed", "filesChanged_other": "<0>{{count}} 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", diff --git a/apps/web/src/locales/pseudo/github.json b/apps/web/src/locales/pseudo/github.json index 850859a025..0c6160cc06 100644 --- a/apps/web/src/locales/pseudo/github.json +++ b/apps/web/src/locales/pseudo/github.json @@ -138,7 +138,6 @@ "defaultQueriesSaved": "Ďēƒàũĺţ qũēŕĩēś śàvēď", "defaultRepository": "Ďēƒàũĺţ ŕēƥōśĩţōŕŷ", "delete": "Ďēĺēţē", - "deleteSavedQuery": "Ďēĺēţē śàvēď qũēŕŷ", "deletedTasks_one": "Ďēĺēţēď {{count}} ţàśķ", "deletedTasks_other": "Ďēĺēţēď {{count}} ţàśķś", "description": "Ďēśćŕĩƥţĩōń", diff --git a/apps/web/src/locales/pseudo/integrations.json b/apps/web/src/locales/pseudo/integrations.json index 03e9a6a7dc..9259211e5f 100644 --- a/apps/web/src/locales/pseudo/integrations.json +++ b/apps/web/src/locales/pseudo/integrations.json @@ -37,13 +37,16 @@ "creatingEllipsis": "Ćŕēàţĩńĝ...", "creatingFrom": "Ćŕēàţĩńĝ {{shortName}} ƒŕōḿ <4>{{displayBranch}}", "creatingFromInto": "Ćŕēàţĩńĝ {{shortName}} ƒŕōḿ <4>{{displayBranch}} → <6>{{baseBranch}}", - "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}} ƒĩĺē ćĥàńĝēď", "filesChanged_other": "<0>{{count}} ƒĩĺēś ćĥàńĝēď", "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": "Śţàĝē àĺĺ ćĥàńĝēś ƀēƒōŕē ćōḿḿĩţţĩńĝ", diff --git a/apps/web/src/locales/pt-pt/github.json b/apps/web/src/locales/pt-pt/github.json index ff16e9ef68..c8c8f83bda 100644 --- a/apps/web/src/locales/pt-pt/github.json +++ b/apps/web/src/locales/pt-pt/github.json @@ -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", diff --git a/apps/web/src/locales/pt-pt/integrations.json b/apps/web/src/locales/pt-pt/integrations.json index ac8583f819..51e74aba0c 100644 --- a/apps/web/src/locales/pt-pt/integrations.json +++ b/apps/web/src/locales/pt-pt/integrations.json @@ -37,13 +37,16 @@ "creatingEllipsis": "A criar...", "creatingFrom": "A criar o {{shortName}} a partir de <4>{{displayBranch}}", "creatingFromInto": "A criar o {{shortName}} a partir de <4>{{displayBranch}} → <6>{{baseBranch}}", - "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}} ficheiro alterado", "filesChanged_other": "<0>{{count}} 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", diff --git a/apps/web/src/locales/zh-cn/github.json b/apps/web/src/locales/zh-cn/github.json index 48d09ccd3e..88acb3ec7a 100644 --- a/apps/web/src/locales/zh-cn/github.json +++ b/apps/web/src/locales/zh-cn/github.json @@ -137,7 +137,6 @@ "defaultQueriesSaved": "默认查询已保存", "defaultRepository": "默认仓库", "delete": "删除", - "deleteSavedQuery": "删除已保存查询", "deletedTasks_one": "已删除 {{count}} 个任务", "deletedTasks_other": "已删除 {{count}} 个任务", "description": "描述", diff --git a/apps/web/src/locales/zh-cn/integrations.json b/apps/web/src/locales/zh-cn/integrations.json index 2a3fb12322..dc6a573ef1 100644 --- a/apps/web/src/locales/zh-cn/integrations.json +++ b/apps/web/src/locales/zh-cn/integrations.json @@ -37,13 +37,16 @@ "creatingEllipsis": "正在创建…", "creatingFrom": "正在从 <4>{{displayBranch}} 创建 {{shortName}}", "creatingFromInto": "正在从 <4>{{displayBranch}} → <6>{{baseBranch}} 创建 {{shortName}}", - "deleteSavedQuery": "删除已保存的查询", + "deleteSavedQueryNamed": "删除已保存的查询“{{label}}”", "describeYourChanges": "描述你的更改…", "description": "描述", "draftCreated": "草稿 {{shortName}} 已创建", "enableIntegrationNamed": "启用 {{name}}", "enterCommitMessage": "输入提交信息…", "failedToCopyConfig": "复制配置失败:{{err}}", + "failedToDeleteSavedQuery": "删除已保存的查询失败", + "failedToSaveSavedQuery": "保存查询失败", + "failedToUpdateDefaultView": "更新默认视图失败", "filesChanged_one": "已更改 <0>{{count}} 个文件", "filesChanged_other": "已更改 <0>{{count}} 个文件", "forcePush": "强制推送", @@ -76,6 +79,9 @@ "retryShortName": "重试 {{shortName}}", "saveCurrentQuery": "保存当前查询", "savedQueries": "已保存", + "savedQueryDefaultUpdateInProgress": "正在更新默认视图…{{action}}", + "setSavedQueryAsDefaultView": "将 {{label}} 设为默认视图", + "clearSavedQueryAsDefaultView": "取消将 {{label}} 设为默认视图", "selectAWorkspace": "选择工作区…", "shortNameCreated": "{{shortName}} 已创建", "stageAllChangesBeforeCommitting": "提交前暂存所有更改", diff --git a/docs/plans/github-saved-query-defaults/plan.md b/docs/plans/github-saved-query-defaults/plan.md new file mode 100644 index 0000000000..045ec0770a --- /dev/null +++ b/docs/plans/github-saved-query-defaults/plan.md @@ -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. diff --git a/docs/plans/github-saved-query-defaults/task-01-persist-default-saved-queries.md b/docs/plans/github-saved-query-defaults/task-01-persist-default-saved-queries.md new file mode 100644 index 0000000000..f5a004b059 --- /dev/null +++ b/docs/plans/github-saved-query-defaults/task-01-persist-default-saved-queries.md @@ -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. diff --git a/docs/plans/github-saved-query-defaults/task-02-apply-default-selection.md b/docs/plans/github-saved-query-defaults/task-02-apply-default-selection.md new file mode 100644 index 0000000000..d679f97948 --- /dev/null +++ b/docs/plans/github-saved-query-defaults/task-02-apply-default-selection.md @@ -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. diff --git a/docs/plans/github-saved-query-defaults/task-03-default-view-controls.md b/docs/plans/github-saved-query-defaults/task-03-default-view-controls.md new file mode 100644 index 0000000000..5fc3ffee84 --- /dev/null +++ b/docs/plans/github-saved-query-defaults/task-03-default-view-controls.md @@ -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. diff --git a/docs/plans/github-saved-query-defaults/task-04-docs-and-verification.md b/docs/plans/github-saved-query-defaults/task-04-docs-and-verification.md new file mode 100644 index 0000000000..9f7e0a971e --- /dev/null +++ b/docs/plans/github-saved-query-defaults/task-04-docs-and-verification.md @@ -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. diff --git a/docs/public/integrations.md b/docs/public/integrations.md index c6244a2229..f8e300f8e5 100644 --- a/docs/public/integrations.md +++ b/docs/public/integrations.md @@ -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. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index d935833580..d904a6de5a 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -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 | diff --git a/docs/specs/ui/github-saved-query-defaults.md b/docs/specs/ui/github-saved-query-defaults.md new file mode 100644 index 0000000000..ee0f0ff33c --- /dev/null +++ b/docs/specs/ui/github-saved-query-defaults.md @@ -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)