feat(github): improve mobile dashboard parity (#3614)
This commit is contained in:
@@ -1,22 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import Link from "@/components/routing/app-link";
|
||||
import { IconCircle, IconCircleCheck, IconPlus, IconChevronDown } from "@tabler/icons-react";
|
||||
import { IconCircle, IconCircleCheck } from "@tabler/icons-react";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@kandev/ui/dropdown-menu";
|
||||
import { Spinner } from "@kandev/ui/spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatRelativeTime } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChangeRequestList, ChangeRequestRow } from "@/components/integrations/change-request-list";
|
||||
import { IntegrationStartTaskMenu } from "@/components/integrations/integration-start-task-menu";
|
||||
import { TaskRowIndicator } from "@/components/integrations/task-row-indicator";
|
||||
import { cn, formatRelativeTime } from "@/lib/utils";
|
||||
import type { GitHubIssue, TaskIssueLink } from "@/lib/types/github";
|
||||
import type { LaunchPayload, TaskPreset } from "./quick-task-launcher";
|
||||
import { TaskRowIndicator } from "@/components/integrations/task-row-indicator";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type IssueListProps = {
|
||||
items: GitHubIssue[];
|
||||
@@ -27,55 +19,17 @@ type IssueListProps = {
|
||||
issueKeyToTasks?: Map<string, TaskIssueLink[]>;
|
||||
};
|
||||
|
||||
function StartTaskMenu({
|
||||
issue,
|
||||
presets,
|
||||
onStartTask,
|
||||
}: {
|
||||
issue: GitHubIssue;
|
||||
presets: TaskPreset[];
|
||||
onStartTask: IssueListProps["onStartTask"];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const launch = (preset: TaskPreset) => onStartTask({ kind: "issue", issue, preset });
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="gap-1 cursor-pointer">
|
||||
<IconPlus className="h-3.5 w-3.5" />
|
||||
{t("github:task")}
|
||||
<IconChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{presets.map((p) => {
|
||||
const ItemIcon = p.icon;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={p.id}
|
||||
className="cursor-pointer gap-2 py-1.5"
|
||||
onSelect={() => launch(p)}
|
||||
>
|
||||
<ItemIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-medium leading-tight">{p.label}</span>
|
||||
<span className="text-[11px] text-muted-foreground leading-tight">{p.hint}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueLabels({ labels }: { labels: string[] }) {
|
||||
if (!labels?.length) return null;
|
||||
return (
|
||||
<>
|
||||
{labels.slice(0, 4).map((l) => (
|
||||
<Badge key={l} variant="secondary" className="text-[10px] px-1.5 py-0 h-4">
|
||||
{l}
|
||||
{labels.slice(0, 4).map((label) => (
|
||||
<Badge
|
||||
key={label}
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0 h-auto min-h-4 whitespace-normal wrap-anywhere"
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
{labels.length > 4 && (
|
||||
@@ -103,78 +57,65 @@ function IssueRow({
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-purple-600 dark:text-purple-400";
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-3 px-4 py-3 hover:bg-muted/40 transition-colors"
|
||||
data-testid="issue-row"
|
||||
data-issue-number={issue.number}
|
||||
>
|
||||
<StateIcon className={cn("h-4 w-4 mt-1 shrink-0", stateClass)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={issue.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-semibold hover:underline block truncate cursor-pointer"
|
||||
>
|
||||
{issue.title}
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 mt-0.5 text-xs text-muted-foreground">
|
||||
<span className="whitespace-nowrap">
|
||||
<ChangeRequestRow
|
||||
stateIcon={<StateIcon className={cn("h-4 w-4", stateClass)} />}
|
||||
title={issue.title}
|
||||
href={issue.html_url}
|
||||
metadata={
|
||||
<>
|
||||
<span>
|
||||
{issue.repo_owner}/{issue.repo_name}#{issue.number}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span className="whitespace-nowrap">
|
||||
<span>
|
||||
{t("github:byAuthorOpenedAgo", {
|
||||
author: issue.author_login,
|
||||
time: formatRelativeTime(issue.created_at),
|
||||
})}
|
||||
</span>
|
||||
<IssueLabels labels={issue.labels} />
|
||||
<TaskRowIndicator
|
||||
tasks={tasks?.map((task) => ({
|
||||
id: task.task_id,
|
||||
taskId: task.task_id,
|
||||
fallbackTitle: task.task_title,
|
||||
}))}
|
||||
testIdPrefix="issue-row-task-indicator"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<StartTaskMenu issue={issue} presets={presets} onStartTask={onStartTask} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
taskIndicator={
|
||||
<TaskRowIndicator
|
||||
tasks={tasks?.map((task) => ({
|
||||
id: task.task_id,
|
||||
taskId: task.task_id,
|
||||
fallbackTitle: task.task_title,
|
||||
}))}
|
||||
testIdPrefix="issue-row-task-indicator"
|
||||
/>
|
||||
}
|
||||
action={
|
||||
<IntegrationStartTaskMenu
|
||||
presets={presets}
|
||||
onSelect={(preset) => onStartTask({ kind: "issue", issue, preset })}
|
||||
triggerTestId="issue-start-task-trigger"
|
||||
itemTestId="issue-start-task-preset"
|
||||
/>
|
||||
}
|
||||
testId="issue-row"
|
||||
dataAttributes={{ "data-issue-number": issue.number }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueListBody({
|
||||
export function IssueList({
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
items,
|
||||
presets,
|
||||
onStartTask,
|
||||
issueKeyToTasks,
|
||||
}: IssueListProps) {
|
||||
const { t } = useTranslation();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-10">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return <div className="text-center py-10 text-destructive text-sm">{error}</div>;
|
||||
}
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-10 text-muted-foreground text-sm">
|
||||
{t("github:noIssuesMatchThisFilter")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="divide-y">
|
||||
<ChangeRequestList
|
||||
loading={loading}
|
||||
error={error}
|
||||
isEmpty={items.length === 0}
|
||||
emptyMessage={t("github:noIssuesMatchThisFilter")}
|
||||
>
|
||||
{items.map((issue) => {
|
||||
const key = `${issue.repo_owner}/${issue.repo_name}#${issue.number}`;
|
||||
return (
|
||||
@@ -187,14 +128,6 @@ function IssueListBody({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueList(props: IssueListProps) {
|
||||
return (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<IssueListBody {...props} />
|
||||
</div>
|
||||
</ChangeRequestList>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ReactNode } from "react";
|
||||
import { IntegrationListToolbar } from "@/components/integrations/integration-list-toolbar";
|
||||
import { RepoFilterCombobox } from "./repo-filter-combobox";
|
||||
|
||||
type ListToolbarProps = {
|
||||
title: string;
|
||||
titleControl?: ReactNode;
|
||||
count: number;
|
||||
loading: boolean;
|
||||
lastFetchedAt: Date | null;
|
||||
@@ -21,6 +23,7 @@ type ListToolbarProps = {
|
||||
|
||||
export function ListToolbar({
|
||||
title,
|
||||
titleControl,
|
||||
count,
|
||||
loading,
|
||||
lastFetchedAt,
|
||||
@@ -37,6 +40,7 @@ export function ListToolbar({
|
||||
return (
|
||||
<IntegrationListToolbar
|
||||
title={title}
|
||||
titleControl={titleControl}
|
||||
count={count}
|
||||
loading={loading}
|
||||
lastFetchedAt={lastFetchedAt}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconCheck, IconChevronDown, IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@kandev/ui/drawer";
|
||||
|
||||
type PagePickerProps = {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
};
|
||||
|
||||
function PagePicker({ page, totalPages, onPageChange }: PagePickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const selectedPage = useRef<HTMLButtonElement>(null);
|
||||
const currentPageId = useId();
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen} autoFocus>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-w-0 flex-1 justify-between gap-2 text-sm tabular-nums"
|
||||
aria-label={t("github:choosePage")}
|
||||
aria-describedby={currentPageId}
|
||||
>
|
||||
<span id={currentPageId}>{t("github:pageOf", { page, total: totalPages })}</span>
|
||||
<IconChevronDown aria-hidden="true" />
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent
|
||||
className="data-[vaul-drawer-direction=bottom]:max-h-[80dvh] overflow-hidden"
|
||||
aria-describedby={undefined}
|
||||
onOpenAutoFocus={(event) => {
|
||||
if (!selectedPage.current) return;
|
||||
event.preventDefault();
|
||||
selectedPage.current?.focus({ preventScroll: true });
|
||||
selectedPage.current?.scrollIntoView({ block: "center" });
|
||||
}}
|
||||
>
|
||||
<DrawerHeader className="flex-row shrink-0 items-center justify-between px-3 py-2">
|
||||
<DrawerTitle>{t("github:choosePage")}</DrawerTitle>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="ghost">{t("task:done")}</Button>
|
||||
</DrawerClose>
|
||||
</DrawerHeader>
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 pb-[max(1rem,env(safe-area-inset-bottom))]"
|
||||
data-testid="github-page-picker-scroll"
|
||||
>
|
||||
{Array.from({ length: totalPages }, (_, index) => index + 1).map((value) => (
|
||||
<Button
|
||||
key={value}
|
||||
ref={value === page ? selectedPage : undefined}
|
||||
variant={value === page ? "secondary" : "ghost"}
|
||||
className="mb-1 h-11 w-full justify-between text-sm tabular-nums"
|
||||
aria-current={value === page ? "page" : undefined}
|
||||
onClick={() => {
|
||||
if (value !== page) onPageChange(value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("github:pageOf", { page: value, total: totalPages })}
|
||||
{value === page && <IconCheck aria-hidden="true" />}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileResultsPagination({ range, ...props }: PagePickerProps & { range: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { page, totalPages, onPageChange } = props;
|
||||
return (
|
||||
<nav
|
||||
aria-label={t("github:choosePage")}
|
||||
className="shrink-0 border-t px-3 pt-2 pb-[max(0.75rem,env(safe-area-inset-bottom))]"
|
||||
data-testid="github-results-pagination"
|
||||
>
|
||||
<p className="mb-2 text-center text-xs text-muted-foreground tabular-nums" aria-live="polite">
|
||||
{range}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={t("github:previousPage")}
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
>
|
||||
<IconChevronLeft />
|
||||
</Button>
|
||||
<PagePicker {...props} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={t("github:nextPage")}
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
>
|
||||
<IconChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@kandev/ui/drawer";
|
||||
import { PresetsSidebar, type PresetsSidebarProps } from "./presets-sidebar";
|
||||
|
||||
export function MobileViewsPicker({
|
||||
title,
|
||||
onSelect,
|
||||
onSaveCurrent,
|
||||
...props
|
||||
}: PresetsSidebarProps & { title: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const saveAfterClose = useRef(false);
|
||||
const saveFrame = useRef(0);
|
||||
useEffect(
|
||||
() => () => {
|
||||
saveAfterClose.current = false;
|
||||
cancelAnimationFrame(saveFrame.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-11 min-w-0 w-full justify-between gap-2 text-sm font-normal"
|
||||
data-testid="github-mobile-menu-button"
|
||||
aria-label={t("github:viewPicker", { view: title })}
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="text-muted-foreground">{t("github:views")}:</span>{" "}
|
||||
<span className="font-medium" data-testid="github-list-toolbar-title">
|
||||
{title}
|
||||
</span>
|
||||
</span>
|
||||
<IconChevronDown className="h-4 w-4 shrink-0" />
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent
|
||||
className="h-[88dvh] max-h-[88dvh] overflow-hidden pb-[max(0.5rem,env(safe-area-inset-bottom))]"
|
||||
data-testid="github-mobile-sidebar"
|
||||
aria-describedby={undefined}
|
||||
onCloseAutoFocus={() => {
|
||||
if (!saveAfterClose.current) return;
|
||||
saveAfterClose.current = false;
|
||||
saveFrame.current = requestAnimationFrame(onSaveCurrent);
|
||||
}}
|
||||
>
|
||||
<DrawerHeader className="flex-row shrink-0 items-center justify-between px-3 py-2">
|
||||
<DrawerTitle>{t("github:views")}</DrawerTitle>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="ghost">{t("task:done")}</Button>
|
||||
</DrawerClose>
|
||||
</DrawerHeader>
|
||||
<PresetsSidebar
|
||||
{...props}
|
||||
onSelect={(selection) => {
|
||||
onSelect(selection);
|
||||
if (selection.source !== "kind-switch") setOpen(false);
|
||||
}}
|
||||
onSaveCurrent={() => {
|
||||
saveAfterClose.current = true;
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -82,11 +82,12 @@ describe("PRRowTaskIndicator", () => {
|
||||
expect(container.textContent).toContain("2");
|
||||
});
|
||||
|
||||
it("keeps long task titles in the DOM while visually truncating them", () => {
|
||||
it("keeps long task titles readable with desktop-only truncation", () => {
|
||||
const longTitle = "This is an extremely long pull request title that should be truncated";
|
||||
renderWithStore(<PRRowTaskIndicator tasks={[makeTaskPR({ pr_title: longTitle })]} />);
|
||||
const btn = screen.getByRole("button");
|
||||
expect(btn.textContent).toContain(longTitle);
|
||||
expect(screen.getByText(longTitle).classList.contains("truncate")).toBe(true);
|
||||
expect(screen.getByText(longTitle).classList.contains("md:truncate")).toBe(true);
|
||||
expect(screen.getByText(longTitle).classList.contains("wrap-anywhere")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useCallback, useRef, type ReactNode } 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";
|
||||
@@ -12,6 +12,7 @@ type PresetsScopeBarProps = {
|
||||
selected: SidebarSelection;
|
||||
onSelect: (request: SidebarSelectionRequest) => void;
|
||||
savedPresets: SavedPreset[];
|
||||
savedStatus?: ReactNode;
|
||||
onDeleteSaved: (id: string) => void;
|
||||
canSaveCurrent: boolean;
|
||||
onSaveCurrent: () => void;
|
||||
@@ -30,7 +31,7 @@ const KINDS = [
|
||||
/**
|
||||
* Horizontal scope bar for the /github dashboard (desktop). Thin wrapper over
|
||||
* the shared {@link IntegrationScopeBar}; mobile keeps the vertical
|
||||
* PresetsSidebar in a sheet.
|
||||
* PresetsSidebar in the Views drawer.
|
||||
*/
|
||||
export function PresetsScopeBar({
|
||||
prPresets = PR_PRESETS,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { IconX, IconDeviceFloppy, IconBookmark } from "@tabler/icons-react";
|
||||
import type { Icon } from "@tabler/icons-react";
|
||||
import { useCallback } from "react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { SavedQueryLoadStatus, type SavedQueryLoadState } from "./saved-query-load-status";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PR_PRESETS, ISSUE_PRESETS, type PresetOption, type PresetGroup } from "./search-bar";
|
||||
import type { SavedPreset } from "./saved-preset-model";
|
||||
@@ -24,7 +26,7 @@ export type SidebarSelectionRequest =
|
||||
| SidebarSelection
|
||||
| { kind: SidebarSelection["kind"]; source: "kind-switch" };
|
||||
|
||||
type PresetsSidebarProps = {
|
||||
export type PresetsSidebarProps = {
|
||||
selected: SidebarSelection;
|
||||
onSelect: (request: SidebarSelectionRequest) => void;
|
||||
savedPresets: SavedPreset[];
|
||||
@@ -35,6 +37,7 @@ type PresetsSidebarProps = {
|
||||
defaultMutationPendingId: string | null;
|
||||
prPresets?: PresetOption[];
|
||||
issuePresets?: PresetOption[];
|
||||
savedQueryLoad?: SavedQueryLoadState;
|
||||
};
|
||||
|
||||
function KindToggle({
|
||||
@@ -46,11 +49,12 @@ function KindToggle({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mx-2 mb-3 grid grid-cols-2 rounded-md border p-0.5 text-xs">
|
||||
<div className="mx-2 mb-3 grid shrink-0 grid-cols-2 rounded-md border p-0.5 text-xs">
|
||||
{(["pr", "issue"] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={kind === value}
|
||||
onClick={() => onChange(value)}
|
||||
className={cn(
|
||||
"min-h-11 px-2 py-1 rounded cursor-pointer transition-colors",
|
||||
@@ -101,7 +105,7 @@ function PresetItem({
|
||||
className="flex min-h-11 min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md px-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
<span className="min-w-0 flex-1 break-words py-2">{label}</span>
|
||||
</button>
|
||||
{trailing}
|
||||
</div>
|
||||
@@ -146,20 +150,18 @@ function SavedSection({
|
||||
onSelect,
|
||||
onDelete,
|
||||
kind,
|
||||
canSaveCurrent,
|
||||
onSaveCurrent,
|
||||
onToggleSavedDefault,
|
||||
defaultMutationPendingId,
|
||||
savedQueryLoad,
|
||||
}: {
|
||||
saved: SavedPreset[];
|
||||
selected: SidebarSelection;
|
||||
onSelect: (s: SidebarSelection) => void;
|
||||
onDelete: (id: string) => void;
|
||||
kind: "pr" | "issue";
|
||||
canSaveCurrent: boolean;
|
||||
onSaveCurrent: () => void;
|
||||
onToggleSavedDefault: (preset: SavedPreset) => void;
|
||||
defaultMutationPendingId: string | null;
|
||||
savedQueryLoad?: SavedQueryLoadState;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const deletion = useSavedTaskViewDeleteConfirmation<HTMLButtonElement>(saved);
|
||||
@@ -167,7 +169,8 @@ function SavedSection({
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title={t("github:saved")} />
|
||||
{saved.length === 0 && (
|
||||
{savedQueryLoad && <SavedQueryLoadStatus {...savedQueryLoad} />}
|
||||
{saved.length === 0 && !savedQueryLoad?.loading && !savedQueryLoad?.error && (
|
||||
<div className="mx-2 px-2 py-1 text-xs text-muted-foreground/80 italic">
|
||||
{t("github:noSavedQueriesYet")}
|
||||
</div>
|
||||
@@ -185,21 +188,6 @@ function SavedSection({
|
||||
onToggleDefault={() => void onToggleSavedDefault(preset)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSaveCurrent}
|
||||
disabled={!canSaveCurrent}
|
||||
className={cn(
|
||||
"mx-1 mt-1 flex min-h-11 items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors",
|
||||
canSaveCurrent
|
||||
? "text-muted-foreground hover:bg-muted/50 hover:text-foreground cursor-pointer"
|
||||
: "text-muted-foreground/50 cursor-not-allowed",
|
||||
)}
|
||||
title={canSaveCurrent ? t("github:saveCurrentQuery") : t("github:typeACustomQueryFirst")}
|
||||
>
|
||||
<IconDeviceFloppy className="h-4 w-4 shrink-0" />
|
||||
<span>{t("github:saveCurrentQuery")}</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -297,7 +285,9 @@ export function PresetsSidebar({
|
||||
defaultMutationPendingId,
|
||||
prPresets = PR_PRESETS,
|
||||
issuePresets = ISSUE_PRESETS,
|
||||
savedQueryLoad,
|
||||
}: PresetsSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const presets = selected.kind === "pr" ? prPresets : issuePresets;
|
||||
const saved = savedPresets.filter((p) => p.kind === selected.kind);
|
||||
const onKindChange = useCallback(
|
||||
@@ -308,33 +298,48 @@ export function PresetsSidebar({
|
||||
[onSelect, selected.kind],
|
||||
);
|
||||
return (
|
||||
<nav className="flex w-full min-w-0 flex-col overflow-x-hidden py-3">
|
||||
<nav className="flex w-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden pt-3">
|
||||
<KindToggle kind={selected.kind} onChange={onKindChange} />
|
||||
<PresetGroupList
|
||||
presets={presets}
|
||||
group="inbox"
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
kind={selected.kind}
|
||||
/>
|
||||
<PresetGroupList
|
||||
presets={presets}
|
||||
group="created"
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
kind={selected.kind}
|
||||
/>
|
||||
<SavedSection
|
||||
saved={saved}
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
onDelete={onDeleteSaved}
|
||||
kind={selected.kind}
|
||||
canSaveCurrent={canSaveCurrent}
|
||||
onSaveCurrent={onSaveCurrent}
|
||||
onToggleSavedDefault={onToggleSavedDefault}
|
||||
defaultMutationPendingId={defaultMutationPendingId}
|
||||
/>
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-3"
|
||||
data-testid="github-views-scroll"
|
||||
>
|
||||
<PresetGroupList
|
||||
presets={presets}
|
||||
group="inbox"
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
kind={selected.kind}
|
||||
/>
|
||||
<PresetGroupList
|
||||
presets={presets}
|
||||
group="created"
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
kind={selected.kind}
|
||||
/>
|
||||
<SavedSection
|
||||
saved={saved}
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
onDelete={onDeleteSaved}
|
||||
kind={selected.kind}
|
||||
onToggleSavedDefault={onToggleSavedDefault}
|
||||
defaultMutationPendingId={defaultMutationPendingId}
|
||||
savedQueryLoad={savedQueryLoad}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 border-t p-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full cursor-pointer"
|
||||
onClick={onSaveCurrent}
|
||||
disabled={!canSaveCurrent}
|
||||
>
|
||||
<IconDeviceFloppy className="h-4 w-4 shrink-0" />
|
||||
{t("github:saveCurrentQuery")}
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ResultsPagination } from "./results-pagination";
|
||||
|
||||
const CHOOSE_PAGE = "Choose results page";
|
||||
let mobile = true;
|
||||
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
||||
useResponsiveBreakpoint: () => ({ isMobile: mobile }),
|
||||
}));
|
||||
beforeEach(() => {
|
||||
mobile = true;
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
// @covers AC-INTEGRATIONS-GITHUB-MOBILE-001.4
|
||||
describe("phone pagination", () => {
|
||||
it("keeps focus in the page drawer when the current page disappears after a refresh", async () => {
|
||||
const onPageChange = vi.fn();
|
||||
const { rerender } = render(
|
||||
<ResultsPagination page={40} pageSize={25} total={1000} onPageChange={onPageChange} />,
|
||||
);
|
||||
rerender(<ResultsPagination page={40} pageSize={25} total={50} onPageChange={onPageChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: CHOOSE_PAGE }));
|
||||
const dialog = screen.getByRole("dialog", { name: CHOOSE_PAGE });
|
||||
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
|
||||
expect(onPageChange).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Page 2 of 2" }));
|
||||
expect(onPageChange).toHaveBeenCalledExactlyOnceWith(2);
|
||||
});
|
||||
|
||||
it("offers direct selection up to GitHub's 1000-result cap", () => {
|
||||
const onPageChange = vi.fn();
|
||||
render(<ResultsPagination page={1} pageSize={25} total={1700} onPageChange={onPageChange} />);
|
||||
const selector = screen.getByLabelText(CHOOSE_PAGE, { selector: "button,select" });
|
||||
expect(selector.tagName).toBe("BUTTON");
|
||||
expect(screen.getByText("1–25 of 1000+")).not.toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Previous page" }).hasAttribute("disabled")).toBe(
|
||||
true,
|
||||
);
|
||||
fireEvent.click(selector);
|
||||
expect(screen.getByRole("dialog", { name: CHOOSE_PAGE })).toBeTruthy();
|
||||
expect(screen.getAllByRole("button", { name: /^Page \d+ of 40$/ })).toHaveLength(40);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Page 5 of 40" }));
|
||||
expect(onPageChange).toHaveBeenCalledExactlyOnceWith(5);
|
||||
expect(selector.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("bounds next and previous at the last available page", () => {
|
||||
const onPageChange = vi.fn();
|
||||
render(<ResultsPagination page={40} pageSize={25} total={1700} onPageChange={onPageChange} />);
|
||||
expect(screen.getByLabelText(CHOOSE_PAGE, { selector: "button,select" }).textContent).toBe(
|
||||
"Page 40 of 40",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Next page" }).hasAttribute("disabled")).toBe(true);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Previous page" }));
|
||||
expect(onPageChange).toHaveBeenCalledExactlyOnceWith(39);
|
||||
});
|
||||
|
||||
it("marks the current page and dismisses without fetching it again", () => {
|
||||
const onPageChange = vi.fn();
|
||||
render(<ResultsPagination page={5} pageSize={25} total={1700} onPageChange={onPageChange} />);
|
||||
const selector = screen.getByLabelText(CHOOSE_PAGE, { selector: "button,select" });
|
||||
fireEvent.click(selector);
|
||||
const current = screen.getByRole("button", { name: "Page 5 of 40" });
|
||||
expect(current.getAttribute("aria-current")).toBe("page");
|
||||
fireEvent.click(current);
|
||||
expect(selector.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(onPageChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains numbered pagination on desktop", () => {
|
||||
mobile = false;
|
||||
render(<ResultsPagination page={5} pageSize={25} total={1700} onPageChange={vi.fn()} />);
|
||||
expect(screen.queryByRole("combobox")).toBeNull();
|
||||
expect(screen.getByRole("link", { name: "40" })).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { MobileResultsPagination } from "./mobile-results-pagination";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
@@ -29,6 +32,8 @@ function pageWindow(page: number, totalPages: number): number[] {
|
||||
}
|
||||
|
||||
export function ResultsPagination({ page, pageSize, total, onPageChange }: ResultsPaginationProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isMobile } = useResponsiveBreakpoint();
|
||||
const effectiveTotal = Math.min(total, GITHUB_MAX_RESULTS);
|
||||
const totalPages = Math.max(1, Math.ceil(effectiveTotal / pageSize));
|
||||
if (totalPages <= 1) return null;
|
||||
@@ -36,12 +41,24 @@ export function ResultsPagination({ page, pageSize, total, onPageChange }: Resul
|
||||
const windowPages = pageWindow(page, totalPages);
|
||||
const start = (page - 1) * pageSize + 1;
|
||||
const end = Math.min(page * pageSize, effectiveTotal);
|
||||
const range = t("github:resultRange", {
|
||||
start,
|
||||
end,
|
||||
total: total > GITHUB_MAX_RESULTS ? `${GITHUB_MAX_RESULTS}+` : total,
|
||||
});
|
||||
if (isMobile)
|
||||
return (
|
||||
<MobileResultsPagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
range={range}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t shrink-0">
|
||||
<div className="text-xs text-muted-foreground tabular-nums">
|
||||
{start}–{end} of {total > GITHUB_MAX_RESULTS ? `${GITHUB_MAX_RESULTS}+` : total}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground tabular-nums">{range}</div>
|
||||
<Pagination className="mx-0 w-auto justify-end">
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SavePresetDialog } from "./save-preset-dialog";
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderDialog(onSave: (label: string, repo: string) => Promise<boolean>) {
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<SavePresetDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
kind="pr"
|
||||
customQuery="is:open"
|
||||
repoFilter="org/repo"
|
||||
repoOptions={["org/repo"]}
|
||||
suggestedLabel="My review queue"
|
||||
onSave={onSave}
|
||||
/>,
|
||||
);
|
||||
return onOpenChange;
|
||||
}
|
||||
|
||||
// @covers AC-INTEGRATIONS-GITHUB-MOBILE-001.1
|
||||
describe("SavePresetDialog persistence", () => {
|
||||
it("keeps the entered name after failure and closes only after a successful retry", async () => {
|
||||
const save = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true);
|
||||
const onOpenChange = renderDialog(save);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New name" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(save).toHaveBeenCalledTimes(1));
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("New name");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Save" }).hasAttribute("disabled")).toBe(false),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false));
|
||||
expect(save).toHaveBeenLastCalledWith("New name", "org/repo");
|
||||
});
|
||||
|
||||
it("prevents duplicate submission and dismissal while persistence is pending", async () => {
|
||||
let finish!: (success: boolean) => void;
|
||||
const save = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const onOpenChange = renderDialog(save);
|
||||
const submit = screen.getByRole("button", { name: "Save" });
|
||||
fireEvent.click(submit);
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBe(submit);
|
||||
expect(submit.hasAttribute("disabled")).toBe(true);
|
||||
expect(screen.getAllByRole("status")).toHaveLength(1);
|
||||
expect(screen.getByRole("status").textContent).toBe("Saving...");
|
||||
fireEvent.click(submit);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
await act(async () => finish(true));
|
||||
expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(false);
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -14,6 +14,7 @@ import { Button } from "@kandev/ui/button";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { RepoFilterCombobox } from "./repo-filter-combobox";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createFocusReturnHandler } from "@/lib/dialog-focus-return";
|
||||
|
||||
type SavePresetDialogProps = {
|
||||
open: boolean;
|
||||
@@ -23,7 +24,8 @@ type SavePresetDialogProps = {
|
||||
repoFilter: string;
|
||||
repoOptions: string[];
|
||||
suggestedLabel: string;
|
||||
onSave: (label: string, repoFilter: string) => void;
|
||||
onSave: (label: string, repoFilter: string) => Promise<boolean>;
|
||||
focusReturnRef?: RefObject<HTMLElement | null>;
|
||||
};
|
||||
|
||||
function SavePresetForm({
|
||||
@@ -34,6 +36,7 @@ function SavePresetForm({
|
||||
suggestedLabel,
|
||||
onSave,
|
||||
onClose,
|
||||
pending,
|
||||
}: {
|
||||
kind: "pr" | "issue";
|
||||
customQuery: string;
|
||||
@@ -42,18 +45,18 @@ function SavePresetForm({
|
||||
suggestedLabel: string;
|
||||
onSave: (label: string, repoFilter: string) => void;
|
||||
onClose: () => void;
|
||||
pending: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState(suggestedLabel);
|
||||
const [defaultRepoFilter, setDefaultRepoFilter] = useState(repoFilter);
|
||||
const trimmed = value.trim();
|
||||
const canSubmit = trimmed.length > 0;
|
||||
const canSubmit = trimmed.length > 0 && !pending;
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!canSubmit) return;
|
||||
onSave(trimmed, defaultRepoFilter);
|
||||
onClose();
|
||||
}, [canSubmit, trimmed, defaultRepoFilter, onSave, onClose]);
|
||||
}, [canSubmit, trimmed, defaultRepoFilter, onSave]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -65,7 +68,7 @@ function SavePresetForm({
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<fieldset disabled={pending} className="flex min-w-0 flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="preset-label" className="text-xs">
|
||||
{t("github:name")}
|
||||
@@ -104,9 +107,14 @@ function SavePresetForm({
|
||||
{t("github:thisRepositoryOpensByDefaultYou")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
{pending && (
|
||||
<p role="status" className="text-xs text-muted-foreground">
|
||||
{t("github:saving")}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="cursor-pointer" onClick={onClose}>
|
||||
<Button variant="outline" className="cursor-pointer" onClick={onClose} disabled={pending}>
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
<Button className="cursor-pointer" disabled={!canSubmit} onClick={handleSubmit}>
|
||||
@@ -126,11 +134,35 @@ export function SavePresetDialog({
|
||||
repoOptions,
|
||||
suggestedLabel,
|
||||
onSave,
|
||||
focusReturnRef,
|
||||
}: SavePresetDialogProps) {
|
||||
const handleClose = useCallback(() => onOpenChange(false), [onOpenChange]);
|
||||
const [pending, setPending] = useState(false);
|
||||
const pendingRef = useRef(false);
|
||||
const handleOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (!pendingRef.current) onOpenChange(nextOpen);
|
||||
},
|
||||
[onOpenChange],
|
||||
);
|
||||
const handleClose = useCallback(() => handleOpenChange(false), [handleOpenChange]);
|
||||
const handleSave = async (label: string, repo: string) => {
|
||||
if (pendingRef.current) return;
|
||||
pendingRef.current = true;
|
||||
setPending(true);
|
||||
try {
|
||||
if (await onSave(label, repo)) onOpenChange(false);
|
||||
} finally {
|
||||
pendingRef.current = false;
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
className="sm:max-w-md"
|
||||
aria-busy={pending}
|
||||
onCloseAutoFocus={createFocusReturnHandler(focusReturnRef)}
|
||||
>
|
||||
{open && (
|
||||
<SavePresetForm
|
||||
kind={kind}
|
||||
@@ -138,8 +170,9 @@ export function SavePresetDialog({
|
||||
repoFilter={repoFilter}
|
||||
repoOptions={repoOptions}
|
||||
suggestedLabel={suggestedLabel}
|
||||
onSave={onSave}
|
||||
onSave={handleSave}
|
||||
onClose={handleClose}
|
||||
pending={pending}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu";
|
||||
import { SavedQueryLoadStatus } from "./saved-query-load-status";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("exposes Retry as a keyboard-accessible menu item inside desktop saved queries", async () => {
|
||||
const retry = vi.fn();
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger>Saved queries</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<SavedQueryLoadStatus loading={false} error retry={retry} presentation="menu" />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Retry" }));
|
||||
expect(retry).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { DropdownMenuItem } from "@kandev/ui/dropdown-menu";
|
||||
|
||||
export type SavedQueryLoadState = {
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
retry: () => void;
|
||||
};
|
||||
|
||||
export function SavedQueryLoadStatus({
|
||||
loading,
|
||||
error,
|
||||
retry,
|
||||
presentation = "inline",
|
||||
}: SavedQueryLoadState & { presentation?: "inline" | "menu" }) {
|
||||
const { t } = useTranslation();
|
||||
if (loading) {
|
||||
return (
|
||||
<p role="status" className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{t("github:loadingSavedQueries")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (!error) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 px-3 py-2">
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{t("github:savedQueriesLoadFailed")}
|
||||
</p>
|
||||
{presentation === "menu" ? (
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
retry();
|
||||
}}
|
||||
>
|
||||
{t("github:retry")}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<Button variant="outline" onClick={retry}>
|
||||
{t("github:retry")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -132,7 +132,7 @@ async function expectLatestPresetCatalogAfterDelete() {
|
||||
expect(setQueryImmediate).toHaveBeenCalledWith(refreshedIssuePreset.filter);
|
||||
}
|
||||
|
||||
async function expectStaleSaveOutcomeIgnored() {
|
||||
async function expectStaleSaveOutcomeIgnored(returnToFirstWorkspace = false) {
|
||||
let finishSave!: () => void;
|
||||
const save = vi.fn(
|
||||
() =>
|
||||
@@ -149,14 +149,21 @@ async function expectStaleSaveOutcomeIgnored() {
|
||||
setRepoFilter,
|
||||
} = renderActions({}, makeStore({ save }));
|
||||
|
||||
let mutation!: Promise<void>;
|
||||
let mutation!: Promise<boolean>;
|
||||
act(() => {
|
||||
mutation = result.current.onConfirmSave(savedPreset.label, savedPreset.repoFilter);
|
||||
});
|
||||
rerender({ workspaceId: SECOND_WORKSPACE_ID });
|
||||
if (returnToFirstWorkspace) {
|
||||
rerender({
|
||||
workspaceId: FIRST_WORKSPACE_ID,
|
||||
customQuery: "newer query",
|
||||
selection: { kind: "pr", source: "preset", id: "mentions" },
|
||||
});
|
||||
}
|
||||
await act(async () => {
|
||||
finishSave();
|
||||
await mutation;
|
||||
expect(await mutation).toBe(false);
|
||||
});
|
||||
|
||||
expect(markSearchInteracted).not.toHaveBeenCalled();
|
||||
@@ -275,7 +282,10 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("useSavedPresetActions save actions", () => {
|
||||
it("ignores a completed save after changing workspace", expectStaleSaveOutcomeIgnored);
|
||||
it("ignores a completed save after changing workspace", () => expectStaleSaveOutcomeIgnored());
|
||||
|
||||
it("ignores a completed save after navigating A to B to A", () =>
|
||||
expectStaleSaveOutcomeIgnored(true));
|
||||
|
||||
it("saves the current query, commits it, selects it, and applies its repository", async () => {
|
||||
const save = vi.fn(async () => savedPreset);
|
||||
@@ -321,7 +331,9 @@ describe("useSavedPresetActions save actions", () => {
|
||||
markSearchInteracted,
|
||||
} = renderActions({}, makeStore({ save }));
|
||||
|
||||
await act(async () => result.current.onConfirmSave("Unavailable", REPO));
|
||||
await act(async () => {
|
||||
expect(await result.current.onConfirmSave("Unavailable", REPO)).toBe(false);
|
||||
});
|
||||
|
||||
expect(markSearchInteracted).not.toHaveBeenCalled();
|
||||
expect(save).toHaveBeenCalledWith({
|
||||
@@ -344,7 +356,9 @@ describe("useSavedPresetActions save actions", () => {
|
||||
makeStore({ save }),
|
||||
);
|
||||
|
||||
await act(async () => result.current.onConfirmSave("Unavailable", REPO));
|
||||
await act(async () => {
|
||||
expect(await result.current.onConfirmSave("Unavailable", REPO)).toBe(false);
|
||||
});
|
||||
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
description: "Failed to save saved query",
|
||||
|
||||
@@ -69,19 +69,24 @@ function useConfirmSave({
|
||||
reportError: () => void;
|
||||
isCurrentWorkspace: IsCurrentWorkspace;
|
||||
}) {
|
||||
const scope = useRef({ workspaceId });
|
||||
if (scope.current.workspaceId !== workspaceId) scope.current = { workspaceId };
|
||||
return useCallback(
|
||||
async (label: string, defaultRepoFilter: string) => {
|
||||
const startedIn = scope.current;
|
||||
try {
|
||||
const created = await save({ kind, label, customQuery, repoFilter: defaultRepoFilter });
|
||||
// No persistence started when workspace presets are not available yet.
|
||||
if (!created) return;
|
||||
if (!isCurrentWorkspace(workspaceId)) return;
|
||||
if (!created || scope.current !== startedIn || !isCurrentWorkspace(workspaceId))
|
||||
return false;
|
||||
markSearchInteracted();
|
||||
setProgrammaticSelection({ kind, source: "saved", id: created.id });
|
||||
setQueryImmediate(customQuery);
|
||||
setRepoFilter(defaultRepoFilter);
|
||||
return true;
|
||||
} catch {
|
||||
reportError();
|
||||
if (scope.current === startedIn && isCurrentWorkspace(workspaceId)) reportError();
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { useLayoutEffect } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchUserSettings } from "@/lib/api/domains/settings-api";
|
||||
import {
|
||||
fetchGitHubWorkspaceSettings,
|
||||
updateGitHubWorkspaceSettings,
|
||||
} from "@/lib/api/domains/github-api";
|
||||
import { __resetSnapshotForTests, useSavedPresets } from "./use-saved-presets";
|
||||
|
||||
vi.mock("@/lib/api/domains/settings-api", () => ({
|
||||
fetchUserSettings: vi.fn(),
|
||||
updateUserSettings: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/api/domains/github-api", () => ({
|
||||
fetchGitHubWorkspaceSettings: vi.fn(),
|
||||
updateGitHubWorkspaceSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
const saved = {
|
||||
id: "saved-review",
|
||||
kind: "pr",
|
||||
label: "Reviews",
|
||||
customQuery: "review-requested:@me",
|
||||
repoFilter: "",
|
||||
isDefault: false,
|
||||
createdAt: "2026-09-11T00:00:00Z",
|
||||
};
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
__resetSnapshotForTests();
|
||||
});
|
||||
|
||||
// @covers AC-INTEGRATIONS-GITHUB-MOBILE-001.1
|
||||
describe.each([null, "workspace-1"])("saved-query recovery in %s", (workspaceId) => {
|
||||
const response = (presets: unknown[]) =>
|
||||
workspaceId
|
||||
? { workspace_id: workspaceId, saved_presets: presets }
|
||||
: { settings: { github_saved_presets: presets } };
|
||||
const fetchSettings = () =>
|
||||
vi.mocked(workspaceId ? fetchGitHubWorkspaceSettings : fetchUserSettings);
|
||||
|
||||
it("distinguishes loading, failure and a successful retry", async () => {
|
||||
const request = deferred<never>();
|
||||
fetchSettings()
|
||||
.mockReturnValueOnce(request.promise)
|
||||
.mockResolvedValueOnce(response([saved]) as never);
|
||||
const { result } = renderHook(() => useSavedPresets(workspaceId));
|
||||
expect(result.current.loading).toBe(true);
|
||||
await act(async () => request.reject(new Error("unavailable")));
|
||||
expect(result.current.error).toBe(true);
|
||||
expect(result.current.loading).toBe(false);
|
||||
await act(async () => result.current.retry());
|
||||
await waitFor(() => expect(result.current.presets).toEqual([saved]));
|
||||
expect(result.current.error).toBe(false);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(fetchSettings()).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("distinguishes a successful empty collection from unavailable settings", async () => {
|
||||
fetchSettings().mockResolvedValue(response([]) as never);
|
||||
const { result } = renderHook(() => useSavedPresets(workspaceId));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.error).toBe(false);
|
||||
expect(result.current.presets).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a failed load from the previous workspace", async () => {
|
||||
const first = deferred<Awaited<ReturnType<typeof fetchGitHubWorkspaceSettings>>>();
|
||||
vi.mocked(fetchGitHubWorkspaceSettings)
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockResolvedValueOnce({ saved_presets: [saved] } as never);
|
||||
const { result, rerender } = renderHook((id) => useSavedPresets(id), { initialProps: "first" });
|
||||
rerender("second");
|
||||
await waitFor(() => expect(result.current.presets).toEqual([saved]));
|
||||
await act(async () => first.reject(new Error("old workspace failed")));
|
||||
expect(result.current.error).toBe(false);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.presets).toEqual([saved]);
|
||||
});
|
||||
|
||||
it("exposes no previous-workspace data or writable snapshot on the first new-scope render", async () => {
|
||||
const second = deferred<Awaited<ReturnType<typeof fetchGitHubWorkspaceSettings>>>();
|
||||
vi.mocked(updateGitHubWorkspaceSettings).mockResolvedValue({} as never);
|
||||
vi.mocked(fetchGitHubWorkspaceSettings)
|
||||
.mockResolvedValueOnce({ saved_presets: [saved] } as never)
|
||||
.mockReturnValueOnce(second.promise);
|
||||
const renders: Array<{ id: string; labels: string[]; loading: boolean }> = [];
|
||||
let attemptedSave: Promise<unknown> | undefined;
|
||||
const { result, rerender } = renderHook(
|
||||
(id: string) => {
|
||||
const store = useSavedPresets(id);
|
||||
const { save } = store;
|
||||
renders.push({
|
||||
id,
|
||||
labels: store.presets.map((preset) => preset.label),
|
||||
loading: store.loading,
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
if (id === "second") {
|
||||
attemptedSave = save({
|
||||
kind: "pr",
|
||||
label: "New view",
|
||||
customQuery: "is:open",
|
||||
repoFilter: "",
|
||||
});
|
||||
}
|
||||
}, [id, save]);
|
||||
return store;
|
||||
},
|
||||
{ initialProps: "first" },
|
||||
);
|
||||
await waitFor(() => expect(result.current.presets).toEqual([saved]));
|
||||
rerender("second");
|
||||
expect(renders.find((render) => render.id === "second")).toEqual({
|
||||
id: "second",
|
||||
labels: [],
|
||||
loading: true,
|
||||
});
|
||||
await expect(attemptedSave).resolves.toBeNull();
|
||||
expect(updateGitHubWorkspaceSettings).not.toHaveBeenCalled();
|
||||
await act(async () => second.resolve({ saved_presets: [] } as never));
|
||||
expect(result.current.presets).toEqual([]);
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
@@ -98,52 +98,79 @@ export function __resetSnapshotForTests() {
|
||||
}
|
||||
|
||||
function useUserSavedPresetsSync(enabled: boolean) {
|
||||
const [loadState, setLoadState] = useState({ loading: true, error: false });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const retry = useCallback(() => setAttempt((value) => value + 1), []);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
const initialVersion = snapshotVersion;
|
||||
setLoadState({ loading: true, error: false });
|
||||
fetchUserSettings({ cache: "no-store" })
|
||||
.then((response) => {
|
||||
const serverPresets = readSavedPresets(response.settings.github_saved_presets);
|
||||
if (cancelled || snapshotVersion !== initialVersion) return;
|
||||
publish(serverPresets);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled]);
|
||||
}
|
||||
|
||||
function useWorkspaceSavedPresets(workspaceId: string | null) {
|
||||
const [workspacePresets, setWorkspacePresets] = useState<SavedPreset[] | undefined>(undefined);
|
||||
const writeSeq = useRef(0);
|
||||
useEffect(() => {
|
||||
if (!workspaceId) {
|
||||
setWorkspacePresets(undefined);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const seq = writeSeq.current;
|
||||
setWorkspacePresets(undefined);
|
||||
fetchGitHubWorkspaceSettings(workspaceId)
|
||||
.then((settings) => {
|
||||
if (cancelled || seq !== writeSeq.current) return;
|
||||
const serverPresets = readSavedPresets(settings.saved_presets);
|
||||
setWorkspacePresets(serverPresets);
|
||||
if (cancelled) return;
|
||||
if (snapshotVersion === initialVersion) publish(serverPresets);
|
||||
setLoadState({ loading: false, error: false });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled && seq === writeSeq.current) setWorkspacePresets(undefined);
|
||||
if (!cancelled) setLoadState({ loading: false, error: snapshotVersion === initialVersion });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workspaceId]);
|
||||
const setWorkspacePresetsFromLocal = useCallback((next: SavedPreset[]) => {
|
||||
writeSeq.current += 1;
|
||||
setWorkspacePresets(next);
|
||||
}, []);
|
||||
return { workspacePresets, setWorkspacePresets: setWorkspacePresetsFromLocal };
|
||||
}, [enabled, attempt]);
|
||||
return { ...loadState, retry };
|
||||
}
|
||||
|
||||
function useWorkspaceSavedPresets(workspaceId: string | null) {
|
||||
const [state, setState] = useState<{
|
||||
workspaceId: string | null;
|
||||
presets?: SavedPreset[];
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
}>({ workspaceId, loading: true, error: false });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const retry = useCallback(() => setAttempt((value) => value + 1), []);
|
||||
const writeSeq = useRef(0);
|
||||
useEffect(() => {
|
||||
if (!workspaceId) {
|
||||
setState({ workspaceId, loading: true, error: false });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const seq = writeSeq.current;
|
||||
setState({ workspaceId, loading: true, error: false });
|
||||
fetchGitHubWorkspaceSettings(workspaceId)
|
||||
.then((settings) => {
|
||||
if (cancelled || seq !== writeSeq.current) return;
|
||||
const serverPresets = readSavedPresets(settings.saved_presets);
|
||||
setState({ workspaceId, presets: serverPresets, loading: false, error: false });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled && seq === writeSeq.current) {
|
||||
setState({ workspaceId, loading: false, error: true });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workspaceId, attempt]);
|
||||
const setWorkspacePresetsFromLocal = useCallback(
|
||||
(next: SavedPreset[]) => {
|
||||
writeSeq.current += 1;
|
||||
setState({ workspaceId, presets: next, loading: false, error: false });
|
||||
},
|
||||
[workspaceId],
|
||||
);
|
||||
const current = state.workspaceId === workspaceId;
|
||||
return {
|
||||
workspacePresets: current ? state.presets : undefined,
|
||||
setWorkspacePresets: setWorkspacePresetsFromLocal,
|
||||
loading: current ? state.loading : true,
|
||||
error: current ? state.error : false,
|
||||
retry,
|
||||
};
|
||||
}
|
||||
|
||||
function discardSavedPreset(presets: SavedPreset[], id: string): SavedPreset[] {
|
||||
@@ -226,18 +253,7 @@ function useSavedPresetMutationContext(
|
||||
return { mutationContextRef, applyLocal };
|
||||
}
|
||||
|
||||
export function useSavedPresets(workspaceId: string | null = null) {
|
||||
const presets = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
const { workspacePresets, setWorkspacePresets } = useWorkspaceSavedPresets(workspaceId);
|
||||
const workspacePresetsRef = useRef(workspacePresets);
|
||||
workspacePresetsRef.current = workspacePresets;
|
||||
useUserSavedPresetsSync(!workspaceId);
|
||||
const activePresets = workspaceId ? (workspacePresets ?? emptySnapshot) : presets;
|
||||
const { mutationContextRef, applyLocal } = useSavedPresetMutationContext(
|
||||
workspaceId,
|
||||
activePresets,
|
||||
setWorkspacePresets,
|
||||
);
|
||||
function useSavedPresetMutationQueue(workspaceId: string | null) {
|
||||
const mutationQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const previousWorkspaceIdRef = useRef(workspaceId);
|
||||
if (previousWorkspaceIdRef.current !== workspaceId) {
|
||||
@@ -245,7 +261,7 @@ export function useSavedPresets(workspaceId: string | null = null) {
|
||||
mutationQueueRef.current = Promise.resolve();
|
||||
}
|
||||
|
||||
const queueMutation = useCallback(
|
||||
return useCallback(
|
||||
(mutation: () => Promise<void>) => {
|
||||
if (workspaceId === null) return queuePortableMutation(mutation);
|
||||
const queued = mutationQueueRef.current.catch(() => undefined).then(mutation);
|
||||
@@ -254,6 +270,22 @@ export function useSavedPresets(workspaceId: string | null = null) {
|
||||
},
|
||||
[workspaceId],
|
||||
);
|
||||
}
|
||||
|
||||
export function useSavedPresets(workspaceId: string | null = null) {
|
||||
const presets = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
const { workspacePresets, setWorkspacePresets, ...workspaceLoad } =
|
||||
useWorkspaceSavedPresets(workspaceId);
|
||||
const workspacePresetsRef = useRef(workspacePresets);
|
||||
workspacePresetsRef.current = workspacePresets;
|
||||
const userLoad = useUserSavedPresetsSync(!workspaceId);
|
||||
const activePresets = workspaceId ? (workspacePresets ?? emptySnapshot) : presets;
|
||||
const { mutationContextRef, applyLocal } = useSavedPresetMutationContext(
|
||||
workspaceId,
|
||||
activePresets,
|
||||
setWorkspacePresets,
|
||||
);
|
||||
const queueMutation = useSavedPresetMutationQueue(workspaceId);
|
||||
|
||||
/**
|
||||
* @returns The created preset, or null while workspace settings are unavailable.
|
||||
@@ -351,5 +383,11 @@ export function useSavedPresets(workspaceId: string | null = null) {
|
||||
[applyLocal, queueMutation, workspaceId],
|
||||
);
|
||||
|
||||
return { presets: activePresets, save, remove, setDefault };
|
||||
return {
|
||||
presets: activePresets,
|
||||
save,
|
||||
remove,
|
||||
setDefault,
|
||||
...(workspaceId ? workspaceLoad : userLoad),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user