feat(github): show an Updating… indicator in the PR status popover (#2402)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -122,7 +122,7 @@ export function MultiPRCIPopover({
|
||||
prs: TaskPR[];
|
||||
enabled: boolean;
|
||||
onOpenDetailPanel?: (pr: TaskPR) => void;
|
||||
refreshTaskPR?: () => void;
|
||||
refreshTaskPR?: () => void | Promise<void>;
|
||||
onRemovePR?: (pr: TaskPR) => Promise<void>;
|
||||
onCollapseFocus?: (remainingPR: TaskPR) => void;
|
||||
}) {
|
||||
|
||||
@@ -31,6 +31,7 @@ vi.mock("@/hooks/domains/github/use-pr-ci-popover", () => ({
|
||||
usePRCIPopover: () => ({
|
||||
feedback: null,
|
||||
isFetching: false,
|
||||
isRefreshing: false,
|
||||
lastUpdatedAt: null,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { ToastProvider } from "@/components/toast-provider";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
|
||||
const popoverState = vi.hoisted(() => ({
|
||||
isFetching: false,
|
||||
isRefreshing: false,
|
||||
lastUpdatedAt: null as number | null,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/domains/github/use-pr-ci-popover", () => ({
|
||||
usePRFeedbackBackgroundSync: vi.fn(),
|
||||
usePRCIPopover: () => ({
|
||||
feedback: null,
|
||||
isFetching: popoverState.isFetching,
|
||||
isRefreshing: popoverState.isRefreshing,
|
||||
lastUpdatedAt: popoverState.lastUpdatedAt,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/domains/github/use-task-ci-options", () => ({
|
||||
useTaskCIAutomationOptions: () => ({
|
||||
options: null,
|
||||
loading: false,
|
||||
saving: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
update: vi.fn(),
|
||||
resetPrompt: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { PRCIPopover } from "./pr-ci-popover";
|
||||
|
||||
const UPDATING_LABEL = "Updating…";
|
||||
|
||||
function makePR(): TaskPR {
|
||||
return {
|
||||
id: "id",
|
||||
task_id: "task-1",
|
||||
owner: "acme",
|
||||
repo: "app",
|
||||
pr_number: 7,
|
||||
pr_url: "https://github.com/acme/app/pull/7",
|
||||
pr_title: "Test PR",
|
||||
head_branch: "feat",
|
||||
base_branch: "main",
|
||||
author_login: "alice",
|
||||
state: "open",
|
||||
review_state: "",
|
||||
checks_state: "success",
|
||||
mergeable_state: "clean",
|
||||
review_count: 0,
|
||||
pending_review_count: 0,
|
||||
comment_count: 0,
|
||||
unresolved_review_threads: 0,
|
||||
checks_total: 1,
|
||||
checks_passing: 1,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
created_at: "",
|
||||
merged_at: null,
|
||||
closed_at: null,
|
||||
last_synced_at: null,
|
||||
updated_at: "",
|
||||
};
|
||||
}
|
||||
|
||||
function renderPopover() {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<StateProvider>
|
||||
<ToastProvider>
|
||||
<PRCIPopover pr={makePR()} enabled={true} />
|
||||
</ToastProvider>
|
||||
</StateProvider>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("PR popover footer refresh indicator", () => {
|
||||
beforeEach(() => {
|
||||
popoverState.isFetching = false;
|
||||
popoverState.isRefreshing = false;
|
||||
popoverState.lastUpdatedAt = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("shows a spinner and 'Updating…' instead of a freshness claim while refreshing", () => {
|
||||
popoverState.isRefreshing = true;
|
||||
popoverState.lastUpdatedAt = Date.now();
|
||||
renderPopover();
|
||||
|
||||
const updating = screen.getByTestId("pr-popover-updating");
|
||||
expect(updating.textContent).toContain(UPDATING_LABEL);
|
||||
expect(updating.querySelector(".animate-spin")).not.toBeNull();
|
||||
expect(screen.queryByTestId("pr-popover-updated-at")).toBeNull();
|
||||
});
|
||||
|
||||
it("announces the refresh to assistive tech", () => {
|
||||
popoverState.isRefreshing = true;
|
||||
popoverState.lastUpdatedAt = Date.now();
|
||||
renderPopover();
|
||||
|
||||
expect(screen.getByRole("status").textContent).toContain(UPDATING_LABEL);
|
||||
});
|
||||
|
||||
it("shows the elapsed label once the refresh settles", () => {
|
||||
popoverState.isRefreshing = false;
|
||||
popoverState.lastUpdatedAt = Date.now();
|
||||
renderPopover();
|
||||
|
||||
expect(screen.getByTestId("pr-popover-updated-at").textContent).toBe("updated just now");
|
||||
expect(screen.queryByTestId("pr-popover-updating")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the indicator on a first open, when there is no cached timestamp yet", () => {
|
||||
popoverState.isRefreshing = true;
|
||||
popoverState.lastUpdatedAt = null;
|
||||
renderPopover();
|
||||
|
||||
expect(screen.getByTestId("pr-popover-updating").textContent).toContain(UPDATING_LABEL);
|
||||
});
|
||||
|
||||
it("renders no footer at all when there is nothing to report", () => {
|
||||
popoverState.isRefreshing = false;
|
||||
popoverState.lastUpdatedAt = null;
|
||||
renderPopover();
|
||||
|
||||
expect(screen.queryByTestId("pr-popover-footer")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IconCircleX,
|
||||
IconExternalLink,
|
||||
IconGitPullRequest,
|
||||
IconLoader2,
|
||||
IconMessageCircle,
|
||||
IconPlus,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -493,7 +494,13 @@ function elapsedLabel(
|
||||
return t("github:updatedAgo", { elapsed: formatElapsedShort(elapsed) });
|
||||
}
|
||||
|
||||
function PRPopoverFooter({ lastUpdatedAt }: { lastUpdatedAt: number | null }) {
|
||||
function PRPopoverFooter({
|
||||
lastUpdatedAt,
|
||||
isRefreshing,
|
||||
}: {
|
||||
lastUpdatedAt: number | null;
|
||||
isRefreshing: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Capture a "now" snapshot only inside the setInterval callback (an event
|
||||
// handler — no impurity in render, no setState during commit). When no
|
||||
@@ -509,18 +516,31 @@ function PRPopoverFooter({ lastUpdatedAt }: { lastUpdatedAt: number | null }) {
|
||||
lastUpdatedAt == null
|
||||
? null
|
||||
: Math.max(0, Math.floor(((now ?? lastUpdatedAt) - lastUpdatedAt) / 1000));
|
||||
if (lastUpdatedAt == null) return null;
|
||||
// The first open of a PR has no cache entry yet — that's the slowest fetch of
|
||||
// all, so the footer has to exist for the spinner even with nothing to date.
|
||||
if (lastUpdatedAt == null && !isRefreshing) return null;
|
||||
return (
|
||||
<div
|
||||
data-testid="pr-popover-footer"
|
||||
className="flex items-center justify-end border-t border-border/50 pt-1.5"
|
||||
>
|
||||
<span
|
||||
data-testid="pr-popover-updated-at"
|
||||
className="text-[10px] text-muted-foreground tabular-nums"
|
||||
>
|
||||
{elapsedLabel(elapsed, t)}
|
||||
</span>
|
||||
{isRefreshing ? (
|
||||
<span
|
||||
data-testid="pr-popover-updating"
|
||||
role="status"
|
||||
className="flex items-center gap-1.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
<IconLoader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
|
||||
{t("github:updatingStatus")}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
data-testid="pr-popover-updated-at"
|
||||
className="text-[10px] text-muted-foreground tabular-nums"
|
||||
>
|
||||
{elapsedLabel(elapsed, t)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -561,12 +581,12 @@ export function PRCIPopover({
|
||||
pr: TaskPR;
|
||||
enabled: boolean;
|
||||
onOpenDetailPanel?: () => void;
|
||||
refreshTaskPR?: () => void;
|
||||
refreshTaskPR?: () => void | Promise<void>;
|
||||
}) {
|
||||
const workspaceId = useAppStore((s) => s.workspaces.activeId);
|
||||
const { status: ghStatus } = useGitHubStatus(workspaceId);
|
||||
const authLost = ghStatus !== null && !ghStatus.authenticated;
|
||||
const { feedback, isFetching, lastUpdatedAt, refetch } = usePRCIPopover(
|
||||
const { feedback, isFetching, isRefreshing, lastUpdatedAt, refetch } = usePRCIPopover(
|
||||
workspaceId,
|
||||
pr,
|
||||
enabled && !authLost,
|
||||
@@ -600,7 +620,7 @@ export function PRCIPopover({
|
||||
<PRMergeButton taskPR={pr} onMerged={refetch} compact />
|
||||
</>
|
||||
)}
|
||||
<PRPopoverFooter lastUpdatedAt={lastUpdatedAt} />
|
||||
<PRPopoverFooter lastUpdatedAt={lastUpdatedAt} isRefreshing={isRefreshing} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,14 +68,14 @@ type TriggerRef = { current: HTMLButtonElement | null };
|
||||
type SingleChipProps = {
|
||||
pr: TaskPR;
|
||||
automation: AutomationFlags;
|
||||
refreshTaskPR: () => void;
|
||||
refreshTaskPR: () => void | Promise<void>;
|
||||
triggerRef?: TriggerRef;
|
||||
};
|
||||
type MultiChipProps = {
|
||||
prs: TaskPR[];
|
||||
statusPrs?: TaskPR[];
|
||||
automation: AutomationFlags;
|
||||
refreshTaskPR: () => void;
|
||||
refreshTaskPR: () => void | Promise<void>;
|
||||
onRemovePR?: (pr: TaskPR) => Promise<void>;
|
||||
triggerRef?: TriggerRef;
|
||||
};
|
||||
|
||||
@@ -145,7 +145,7 @@ function PRSingleButton({
|
||||
triggerRef,
|
||||
}: {
|
||||
pr: TaskPR;
|
||||
refreshTaskPR: () => void;
|
||||
refreshTaskPR: () => void | Promise<void>;
|
||||
triggerRef?: TriggerRef;
|
||||
}) {
|
||||
const addPRPanel = useDockviewStore((s) => s.addPRPanel);
|
||||
@@ -229,7 +229,7 @@ function PRMultiButton({
|
||||
triggerRef,
|
||||
}: {
|
||||
prs: TaskPR[];
|
||||
refreshTaskPR: () => void;
|
||||
refreshTaskPR: () => void | Promise<void>;
|
||||
onRemovePR: (associationId: string) => Promise<void>;
|
||||
triggerRef?: TriggerRef;
|
||||
}) {
|
||||
|
||||
Reference in New Issue
Block a user