FN-7179: show active PRs in pull request views
Add list mode so Pull Requests hosts without a selected PR display active project pull requests. - Fetch and render the active pull request list for no-id PullRequestView mounts. - Allow selecting a PR for detail view and returning to the active PR list. - Add list loading, empty, error, styling, i18n, regression tests, and a patch changeset. Files changed: .changeset/fn-7179-pr-sidebar-list.md | 7 + .../app/__tests__/pull-request-view.test.tsx | 122 ++++++++++++++- .../dashboard/app/components/PullRequestView.css | 96 ++++++++++++ .../dashboard/app/components/PullRequestView.tsx | 163 ++++++++++++++++++--- packages/i18n/locales/en/app.json | 6 + 5 files changed, 370 insertions(+), 24 deletions(-) Fusion-Task-Id: FN-7179 Fusion-Task-Lineage: 712dccae-eb03-4aea-b89e-0a333db7c3fc Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7179-pr-sidebar-list.md
Normal file
7
.changeset/fn-7179-pr-sidebar-list.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Show active project pull requests in the Pull Requests sidebar and main view.
|
||||||
|
category: fix
|
||||||
|
dev: Adds PullRequestView list mode for no-id hosts with selectable detail and back navigation.
|
||||||
@@ -37,8 +37,9 @@ function makeSummary(over: Partial<PrDetail["summary"]> = {}): PrDetail["summary
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeDetail(over: Partial<PrDetail> = {}): PrDetail {
|
function makeDetail(over: Partial<PrDetail> = {}): PrDetail {
|
||||||
|
const id = over.id ?? "PR-1";
|
||||||
return {
|
return {
|
||||||
id: "PR-1",
|
id,
|
||||||
sourceType: "task",
|
sourceType: "task",
|
||||||
sourceId: "FN-1",
|
sourceId: "FN-1",
|
||||||
repo: "owner/repo",
|
repo: "owner/repo",
|
||||||
@@ -58,6 +59,125 @@ function makeDetail(over: Partial<PrDetail> = {}): PrDetail {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeList(): PrDetail[] {
|
||||||
|
return [
|
||||||
|
makeDetail({ id: "PR-1", repo: "owner/repo", headBranch: "feature/x", prNumber: 42 }),
|
||||||
|
makeDetail({ id: "PR-2", repo: "owner/other", headBranch: "feature/y", prNumber: 7 }),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PullRequestView list mode", () => {
|
||||||
|
it("renders project PRs when mounted without an id or detail", async () => {
|
||||||
|
render(<PullRequestView loadPullRequests={vi.fn(async () => makeList())} />);
|
||||||
|
|
||||||
|
const list = await screen.findByTestId("pr-list");
|
||||||
|
expect(list).toBeTruthy();
|
||||||
|
expect(screen.getAllByTestId("pr-list-item")).toHaveLength(2);
|
||||||
|
expect(screen.getByText("owner/repo")).toBeTruthy();
|
||||||
|
expect(screen.getByText("#42")).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId("pr-view-empty")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a bounded loading state while the list fetch is in flight", async () => {
|
||||||
|
const pending = deferred<PrDetail[]>();
|
||||||
|
render(<PullRequestView loadPullRequests={() => pending.promise} />);
|
||||||
|
|
||||||
|
expect(await screen.findByTestId("pr-list-loading")).toBeTruthy();
|
||||||
|
pending.resolve(makeList());
|
||||||
|
expect(await screen.findByTestId("pr-list")).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId("pr-list-loading")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the list empty state for zero active PRs without spinning", async () => {
|
||||||
|
render(<PullRequestView loadPullRequests={vi.fn(async () => [])} />);
|
||||||
|
|
||||||
|
expect(await screen.findByTestId("pr-list-empty")).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId("pr-list-loading")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("pr-view-empty")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a list error state when the list fetch fails", async () => {
|
||||||
|
render(<PullRequestView loadPullRequests={vi.fn(async () => { throw new Error("network down"); })} />);
|
||||||
|
|
||||||
|
expect(await screen.findByTestId("pr-list-error")).toHaveTextContent("network down");
|
||||||
|
expect(screen.queryByTestId("pr-list-loading")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads selected PR detail and returns back to the list", async () => {
|
||||||
|
const list = makeList();
|
||||||
|
const loadPullRequest = vi.fn(async (id: string) => makeDetail({ id, repo: "owner/selected", prNumber: 99 }));
|
||||||
|
render(<PullRequestView loadPullRequests={vi.fn(async () => list)} loadPullRequest={loadPullRequest} />);
|
||||||
|
|
||||||
|
fireEvent.click((await screen.findAllByTestId("pr-list-item"))[1]);
|
||||||
|
|
||||||
|
await waitFor(() => expect(loadPullRequest).toHaveBeenCalledWith("PR-2"));
|
||||||
|
expect(await screen.findByTestId("pr-view")).toBeTruthy();
|
||||||
|
expect(screen.getByText("owner/selected")).toBeTruthy();
|
||||||
|
fireEvent.click(screen.getByTestId("pr-back-to-list"));
|
||||||
|
expect(await screen.findByTestId("pr-list")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not enter list mode for parent-supplied detail, explicit null detail, or explicit ids", async () => {
|
||||||
|
const loadPullRequests = vi.fn(async () => makeList());
|
||||||
|
const { rerender } = render(<PullRequestView detail={makeDetail()} loadPullRequests={loadPullRequests} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("pr-view")).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId("pr-list")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("pr-back-to-list")).toBeNull();
|
||||||
|
expect(loadPullRequests).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
rerender(<PullRequestView detail={null} loadPullRequests={loadPullRequests} />);
|
||||||
|
expect(screen.getByTestId("pr-view-empty")).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId("pr-list")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("pr-back-to-list")).toBeNull();
|
||||||
|
expect(loadPullRequests).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const loadPullRequest = vi.fn(async () => makeDetail({ id: "PR-9", repo: "owner/explicit" }));
|
||||||
|
rerender(<PullRequestView pullRequestId="PR-9" loadPullRequest={loadPullRequest} loadPullRequests={loadPullRequests} />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(loadPullRequest).toHaveBeenCalledWith("PR-9"));
|
||||||
|
expect(await screen.findByText("owner/explicit")).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId("pr-list")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("pr-back-to-list")).toBeNull();
|
||||||
|
expect(loadPullRequests).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enters list mode when a host forwards an undefined detail prop", async () => {
|
||||||
|
const loadPullRequests = vi.fn(async () => makeList());
|
||||||
|
render(<PullRequestView detail={undefined} loadPullRequests={loadPullRequests} />);
|
||||||
|
|
||||||
|
expect(await screen.findByTestId("pr-list")).toBeTruthy();
|
||||||
|
expect(screen.getAllByTestId("pr-list-item")).toHaveLength(2);
|
||||||
|
expect(screen.queryByTestId("pr-view-empty")).toBeNull();
|
||||||
|
expect(loadPullRequests).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes the list on fusion store-changed events", async () => {
|
||||||
|
const loadPullRequests = vi
|
||||||
|
.fn<() => Promise<PrDetail[]>>()
|
||||||
|
.mockResolvedValueOnce([makeDetail({ id: "PR-1", repo: "owner/first", prNumber: 1 })])
|
||||||
|
.mockResolvedValueOnce([makeDetail({ id: "PR-2", repo: "owner/second", prNumber: 2 })]);
|
||||||
|
render(<PullRequestView loadPullRequests={loadPullRequests} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("owner/first")).toBeTruthy();
|
||||||
|
window.dispatchEvent(new Event("fusion:store-changed"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(loadPullRequests).toHaveBeenCalledTimes(2));
|
||||||
|
expect(await screen.findByText("owner/second")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("owner/first")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("PullRequestView per-node-state rendering", () => {
|
describe("PullRequestView per-node-state rendering", () => {
|
||||||
it("creating → 'Creating PR…' placeholder", () => {
|
it("creating → 'Creating PR…' placeholder", () => {
|
||||||
render(<PullRequestView detail={makeDetail({ state: "creating" })} />);
|
render(<PullRequestView detail={makeDetail({ state: "creating" })} />);
|
||||||
|
|||||||
@@ -26,6 +26,102 @@ The view now renders the shared ViewHeader at the top, which supplies the --spac
|
|||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* list mode */
|
||||||
|
.pr-list-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-list-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-list-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md, var(--space-xs));
|
||||||
|
background: var(--surface, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-list-item:hover,
|
||||||
|
.pr-list-item:focus-visible {
|
||||||
|
border-color: var(--accent, var(--border));
|
||||||
|
background: var(--surface-2, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-list-item-main,
|
||||||
|
.pr-list-item-meta,
|
||||||
|
.pr-list-item-checks,
|
||||||
|
.pr-list-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-list-item-main {
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-list-item-meta {
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-list-item-repo {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-list-item-number {
|
||||||
|
color: var(--accent, var(--text));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-list-item-branch {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.85em;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-list-item-checks {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-list-state {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-list-state--error {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-back-to-list {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pr-list-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-list-item-meta {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* identity header */
|
/* identity header */
|
||||||
.pr-identity {
|
.pr-identity {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
@@ -71,8 +71,10 @@ export interface PullRequestViewProps {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
/** Override the action dispatcher (tests). Defaults to the POST routes. */
|
/** Override the action dispatcher (tests). Defaults to the POST routes. */
|
||||||
onAction?: (kind: ActionKind, id: string, body?: Record<string, unknown>) => Promise<PrDetail>;
|
onAction?: (kind: ActionKind, id: string, body?: Record<string, unknown>) => Promise<PrDetail>;
|
||||||
/** Override the fetcher (tests). */
|
/** Override the detail fetcher (tests). */
|
||||||
loadPullRequest?: (id: string) => Promise<PrDetail>;
|
loadPullRequest?: (id: string) => Promise<PrDetail>;
|
||||||
|
/** Override the list fetcher (tests). */
|
||||||
|
loadPullRequests?: () => Promise<PrDetail[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultLoad(projectId?: string) {
|
function defaultLoad(projectId?: string) {
|
||||||
@@ -83,6 +85,14 @@ function defaultLoad(projectId?: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultLoadList(projectId?: string) {
|
||||||
|
return async (): Promise<PrDetail[]> => {
|
||||||
|
const q = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||||
|
const res = await api<{ pullRequests: PrDetail[] }>(`/pull-requests${q}`);
|
||||||
|
return res.pullRequests;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function defaultAction(projectId?: string) {
|
function defaultAction(projectId?: string) {
|
||||||
return async (kind: ActionKind, id: string, body?: Record<string, unknown>): Promise<PrDetail> => {
|
return async (kind: ActionKind, id: string, body?: Record<string, unknown>): Promise<PrDetail> => {
|
||||||
const path = kind === "automerge" ? "automerge" : kind;
|
const path = kind === "automerge" ? "automerge" : kind;
|
||||||
@@ -102,46 +112,75 @@ function ChecksIcon({ rollup }: { rollup: string }) {
|
|||||||
return <span className="pr-icon-none">—</span>;
|
return <span className="pr-icon-none">—</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PR_LOAD_TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
|
async function withPrTimeout<T>(promise: Promise<T>, message: string): Promise<T> {
|
||||||
|
const timeout = new Promise<T>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error(message)), PR_LOAD_TIMEOUT_MS),
|
||||||
|
);
|
||||||
|
return Promise.race([promise, timeout]);
|
||||||
|
}
|
||||||
|
|
||||||
export function PullRequestView(props: PullRequestViewProps) {
|
export function PullRequestView(props: PullRequestViewProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const { detail: detailProp, pullRequestId, projectId, onAction, loadPullRequest } = props;
|
const { detail: detailProp, pullRequestId, projectId, onAction, loadPullRequest, loadPullRequests } = props;
|
||||||
|
// FNXC:PullRequests 2026-06-27-22:59: Optional host props may forward `detail={undefined}` while no PR is selected. Treat only concrete detail values (including explicit null) as controlled detail mode so undefined still follows the no-id list invariant.
|
||||||
|
const hasDetailProp = detailProp !== undefined;
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [detail, setDetail] = useState<PrDetail | null>(detailProp ?? null);
|
const [detail, setDetail] = useState<PrDetail | null>(detailProp ?? null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pullRequests, setPullRequests] = useState<PrDetail[]>([]);
|
||||||
|
const [listError, setListError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState<ActionKind | null>(null);
|
const [busy, setBusy] = useState<ActionKind | null>(null);
|
||||||
const [confirmingMerge, setConfirmingMerge] = useState(false);
|
const [confirmingMerge, setConfirmingMerge] = useState(false);
|
||||||
// FNXC:PullRequests 2026-06-23-00:45: `loading` is true ONLY while a fetch is in flight. Previously detail===null always rendered the spinner, so with no pullRequestId (nothing to load) the view hung on "Loading PR…" forever. Now no-id → empty state, and the fetch is time-bounded so a hung request surfaces an error instead of spinning indefinitely.
|
// FNXC:PullRequests 2026-06-27-00:00: `loading` and `listLoading` are true ONLY while a fetch is in flight. No-id now enters bounded list mode so the sidebar shows active project PRs; explicit empty/detail states still never hang on an endless spinner.
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [listLoading, setListLoading] = useState(false);
|
||||||
|
|
||||||
const load = loadPullRequest ?? defaultLoad(projectId);
|
const load = useMemo(() => loadPullRequest ?? defaultLoad(projectId), [loadPullRequest, projectId]);
|
||||||
const dispatch = onAction ?? defaultAction(projectId);
|
const loadList = useMemo(() => loadPullRequests ?? defaultLoadList(projectId), [loadPullRequests, projectId]);
|
||||||
|
const dispatch = useMemo(() => onAction ?? defaultAction(projectId), [onAction, projectId]);
|
||||||
|
const activePullRequestId = pullRequestId ?? selectedId ?? undefined;
|
||||||
|
const isListMode = !hasDetailProp && !pullRequestId && !selectedId;
|
||||||
|
const canReturnToList = !hasDetailProp && !pullRequestId && Boolean(selectedId);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
if (detailProp) {
|
if (hasDetailProp) {
|
||||||
setDetail(detailProp);
|
setDetail(detailProp ?? null);
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!pullRequestId) {
|
|
||||||
// Nothing to load — show the empty state, never an indefinite spinner.
|
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setDetail(null);
|
return;
|
||||||
|
}
|
||||||
|
if (!activePullRequestId) {
|
||||||
|
/*
|
||||||
|
FNXC:PullRequests 2026-06-27-00:00:
|
||||||
|
The right-dock Pull Requests tab and main-content pull-requests view mount this component with no PR id. No-id must mean project PR list mode, not the old empty detail state, so active PRs remain visible and selectable from every host.
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
setListError(null);
|
||||||
|
setListLoading(true);
|
||||||
|
setDetail(null);
|
||||||
|
setPullRequests(await withPrTimeout(loadList(), "Timed out loading pull requests"));
|
||||||
|
} catch (err) {
|
||||||
|
setPullRequests([]);
|
||||||
|
setListError(err instanceof Error ? err.message : t("pr.view.listError", "Failed to load pull requests"));
|
||||||
|
} finally {
|
||||||
|
setListLoading(false);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
// Time-bound the fetch (15s) so a hung request resolves into an error state.
|
// Time-bound the fetch (15s) so a hung request resolves into an error state.
|
||||||
const PR_LOAD_TIMEOUT_MS = 15000;
|
setDetail(await withPrTimeout(load(activePullRequestId), "Timed out loading pull request"));
|
||||||
const timeout = new Promise<PrDetail>((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error("Timed out loading pull request")), PR_LOAD_TIMEOUT_MS),
|
|
||||||
);
|
|
||||||
setDetail(await Promise.race([load(pullRequestId), timeout]));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to load PR");
|
setError(err instanceof Error ? err.message : "Failed to load PR");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [detailProp, pullRequestId, load]);
|
}, [activePullRequestId, detailProp, hasDetailProp, load, loadList, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
@@ -151,11 +190,11 @@ export function PullRequestView(props: PullRequestViewProps) {
|
|||||||
// uses. We listen for the lightweight "store-changed" window event the SSE
|
// uses. We listen for the lightweight "store-changed" window event the SSE
|
||||||
// bridge dispatches; each tick re-reads authoritative state from the route.
|
// bridge dispatches; each tick re-reads authoritative state from the route.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (detailProp || !pullRequestId) return;
|
if (hasDetailProp) return;
|
||||||
const handler = () => void refresh();
|
const handler = () => void refresh();
|
||||||
window.addEventListener("fusion:store-changed", handler);
|
window.addEventListener("fusion:store-changed", handler);
|
||||||
return () => window.removeEventListener("fusion:store-changed", handler);
|
return () => window.removeEventListener("fusion:store-changed", handler);
|
||||||
}, [detailProp, pullRequestId, refresh]);
|
}, [hasDetailProp, refresh]);
|
||||||
|
|
||||||
const runAction = useCallback(
|
const runAction = useCallback(
|
||||||
async (kind: ActionKind, body?: Record<string, unknown>) => {
|
async (kind: ActionKind, body?: Record<string, unknown>) => {
|
||||||
@@ -175,6 +214,76 @@ export function PullRequestView(props: PullRequestViewProps) {
|
|||||||
[detail, dispatch],
|
[detail, dispatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const viewHeader = <ViewHeader icon={GitPullRequest} title={t("pr.view.title", "Pull Requests")} />;
|
||||||
|
|
||||||
|
if (isListMode) {
|
||||||
|
if (listError) {
|
||||||
|
return (
|
||||||
|
<div className="pr-view pr-view--error" data-testid="pr-list-error">
|
||||||
|
{viewHeader}
|
||||||
|
<div className="pr-list-state pr-list-state--error">
|
||||||
|
<AlertTriangle size={16} /> {listError}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (listLoading) {
|
||||||
|
return (
|
||||||
|
<div className="pr-view pr-view--loading" data-testid="pr-list-loading">
|
||||||
|
{viewHeader}
|
||||||
|
<div className="pr-list-state">{t("pr.view.listLoading", "Loading pull requests…")}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pullRequests.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="pr-view pr-view--empty" data-testid="pr-list-empty">
|
||||||
|
{viewHeader}
|
||||||
|
<div className="pr-list-state">
|
||||||
|
<GitPullRequest size={16} /> {t("pr.view.listEmpty", "No active pull requests to show.")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="pr-view" data-testid="pr-list">
|
||||||
|
{viewHeader}
|
||||||
|
<div className="pr-list-title">{t("pr.view.listTitle", "Active pull requests")}</div>
|
||||||
|
<div className="pr-list-items">
|
||||||
|
{pullRequests.map((pullRequest) => (
|
||||||
|
<button
|
||||||
|
key={pullRequest.id}
|
||||||
|
type="button"
|
||||||
|
className="pr-list-item"
|
||||||
|
data-testid="pr-list-item"
|
||||||
|
aria-label={t("pr.view.listItemLabel", "Open pull request {{repo}} {{number}}", {
|
||||||
|
repo: pullRequest.repo,
|
||||||
|
number: pullRequest.prNumber != null ? `#${pullRequest.prNumber}` : pullRequest.headBranch,
|
||||||
|
})}
|
||||||
|
onClick={() => {
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
setSelectedId(pullRequest.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="pr-list-item-main">
|
||||||
|
<span className="pr-list-item-repo">{pullRequest.repo}</span>
|
||||||
|
{pullRequest.prNumber != null && <span className="pr-list-item-number">#{pullRequest.prNumber}</span>}
|
||||||
|
<span className="pr-list-item-branch">{pullRequest.headBranch}</span>
|
||||||
|
</span>
|
||||||
|
<span className="pr-list-item-meta">
|
||||||
|
<span className={`pr-identity-state pr-identity-state--${pullRequest.state}`}>{pullRequest.state}</span>
|
||||||
|
<span className="pr-list-item-checks">
|
||||||
|
<ChecksIcon rollup={pullRequest.summary.checksRollup} /> {pullRequest.summary.checksRollup}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (error && !detail) {
|
if (error && !detail) {
|
||||||
return (
|
return (
|
||||||
<div className="pr-view pr-view--error" data-testid="pr-view-error">
|
<div className="pr-view pr-view--error" data-testid="pr-view-error">
|
||||||
@@ -183,7 +292,7 @@ export function PullRequestView(props: PullRequestViewProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!detail) {
|
if (!detail) {
|
||||||
// FNXC:PullRequests 2026-06-23-00:45: Only show the spinner while actually fetching; otherwise (no PR id / nothing to load / timed out) show the empty state so the view never hangs on an endless "Loading PR…".
|
// FNXC:PullRequests 2026-06-27-00:00: Only show the detail spinner while actually fetching an explicit PR; list mode owns no-id loading/empty/error so the view never hangs on an endless "Loading PR…".
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="pr-view pr-view--loading" data-testid="pr-view-loading">
|
<div className="pr-view pr-view--loading" data-testid="pr-view-loading">
|
||||||
@@ -204,13 +313,18 @@ export function PullRequestView(props: PullRequestViewProps) {
|
|||||||
FNXC:PullRequests 2026-06-22-01:00:
|
FNXC:PullRequests 2026-06-22-01:00:
|
||||||
Added the shared ViewHeader (GitPullRequest icon, matching the left-sidebar nav) at the top of every populated PR state so the view reads consistently with other main-content views. The PR-specific identity row (repo/number/branch/state) stays below it. ViewHeader supplies the standard --space-lg top/side padding; the view body must not repeat the top padding.
|
Added the shared ViewHeader (GitPullRequest icon, matching the left-sidebar nav) at the top of every populated PR state so the view reads consistently with other main-content views. The PR-specific identity row (repo/number/branch/state) stays below it. ViewHeader supplies the standard --space-lg top/side padding; the view body must not repeat the top padding.
|
||||||
*/
|
*/
|
||||||
const viewHeader = <ViewHeader icon={GitPullRequest} title={t("pr.view.title", "Pull Requests")} />;
|
const backToListControl = canReturnToList ? (
|
||||||
|
<button type="button" className="pr-back-to-list btn" data-testid="pr-back-to-list" onClick={() => setSelectedId(null)}>
|
||||||
|
{t("pr.view.backToList", "Back to list")}
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
// ── creating ───────────────────────────────────────────────────────────────
|
// ── creating ───────────────────────────────────────────────────────────────
|
||||||
if (state === "creating") {
|
if (state === "creating") {
|
||||||
return (
|
return (
|
||||||
<div className="pr-view" data-testid="pr-view" data-state="creating">
|
<div className="pr-view" data-testid="pr-view" data-state="creating">
|
||||||
{viewHeader}
|
{viewHeader}
|
||||||
|
{backToListControl}
|
||||||
<PrIdentityHeader detail={detail} />
|
<PrIdentityHeader detail={detail} />
|
||||||
<div className="pr-placeholder" data-testid="pr-creating">
|
<div className="pr-placeholder" data-testid="pr-creating">
|
||||||
<Clock size={16} /> {t("pr.view.creating", "Creating PR…")}
|
<Clock size={16} /> {t("pr.view.creating", "Creating PR…")}
|
||||||
@@ -224,6 +338,7 @@ export function PullRequestView(props: PullRequestViewProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="pr-view" data-testid="pr-view" data-state="failed">
|
<div className="pr-view" data-testid="pr-view" data-state="failed">
|
||||||
{viewHeader}
|
{viewHeader}
|
||||||
|
{backToListControl}
|
||||||
<PrIdentityHeader detail={detail} />
|
<PrIdentityHeader detail={detail} />
|
||||||
<div className="pr-error-reason" data-testid="pr-failed">
|
<div className="pr-error-reason" data-testid="pr-failed">
|
||||||
<AlertTriangle size={16} className="pr-icon-failure" />
|
<AlertTriangle size={16} className="pr-icon-failure" />
|
||||||
@@ -250,6 +365,7 @@ export function PullRequestView(props: PullRequestViewProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="pr-view" data-testid="pr-view" data-state="unverified">
|
<div className="pr-view" data-testid="pr-view" data-state="unverified">
|
||||||
{viewHeader}
|
{viewHeader}
|
||||||
|
{backToListControl}
|
||||||
<PrIdentityHeader detail={detail} />
|
<PrIdentityHeader detail={detail} />
|
||||||
<div className="pr-notice pr-notice--unverified" data-testid="pr-unverified">
|
<div className="pr-notice pr-notice--unverified" data-testid="pr-unverified">
|
||||||
<Clock size={16} /> {t("pr.view.verifyingGithub", "Verifying with GitHub…")}
|
<Clock size={16} /> {t("pr.view.verifyingGithub", "Verifying with GitHub…")}
|
||||||
@@ -275,6 +391,7 @@ export function PullRequestView(props: PullRequestViewProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="pr-view" data-testid="pr-view" data-state={state}>
|
<div className="pr-view" data-testid="pr-view" data-state={state}>
|
||||||
{viewHeader}
|
{viewHeader}
|
||||||
|
{backToListControl}
|
||||||
<PrIdentityHeader detail={detail} />
|
<PrIdentityHeader detail={detail} />
|
||||||
|
|
||||||
{/* responding banner */}
|
{/* responding banner */}
|
||||||
|
|||||||
@@ -4808,6 +4808,12 @@
|
|||||||
"confirmMerge": "Confirm merge",
|
"confirmMerge": "Confirm merge",
|
||||||
"creating": "Creating PR…",
|
"creating": "Creating PR…",
|
||||||
"creationFailed": "PR creation failed",
|
"creationFailed": "PR creation failed",
|
||||||
|
"backToList": "Back to list",
|
||||||
|
"listEmpty": "No active pull requests to show.",
|
||||||
|
"listError": "Failed to load pull requests",
|
||||||
|
"listItemLabel": "Open pull request {{repo}} {{number}}",
|
||||||
|
"listLoading": "Loading pull requests…",
|
||||||
|
"listTitle": "Active pull requests",
|
||||||
"loading": "Loading PR…",
|
"loading": "Loading PR…",
|
||||||
"merge": "Merge",
|
"merge": "Merge",
|
||||||
"mergeableLabel": "Mergeable:",
|
"mergeableLabel": "Mergeable:",
|
||||||
|
|||||||
Reference in New Issue
Block a user