feat(github): improve mobile dashboard parity (#3614)

This commit is contained in:
José Almeida
2026-09-12 10:06:05 +02:00
committed by GitHub
parent 20efe4855a
commit e598742110
73 changed files with 2751 additions and 427 deletions
@@ -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),
};
}