mirror of
https://github.com/jakejarvis/hoot.git
synced 2025-10-18 20:14:25 -04:00
Add CreateIssueButton component and integrate into error handling
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import posthog from "posthog-js";
|
||||
import { useEffect } from "react";
|
||||
import { CreateIssueButton } from "@/components/create-issue-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function RootError(props: {
|
||||
@@ -33,16 +34,17 @@ export default function RootError(props: {
|
||||
{error.stack}
|
||||
</pre>
|
||||
) : null}
|
||||
{!isDev && error?.digest ? (
|
||||
{error?.digest ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Error id: {error.digest}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
<Button onClick={() => reset()}>Try again</Button>
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href="/">Go home</Link>
|
||||
<div className="mt-6 flex flex-col items-center justify-center gap-3">
|
||||
<Button onClick={() => reset()}>
|
||||
<RefreshCcw className="size-4" /> Try again
|
||||
</Button>
|
||||
|
||||
<CreateIssueButton variant="outline" error={error} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
33
components/create-issue-button.test.tsx
Normal file
33
components/create-issue-button.test.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CreateIssueButton } from "./create-issue-button";
|
||||
|
||||
describe("CreateIssueButton", () => {
|
||||
it("renders with icon and label", () => {
|
||||
render(<CreateIssueButton />);
|
||||
// lucide icons render an svg with aria-hidden=true; presence of svg is enough
|
||||
expect(
|
||||
screen.getByRole("link", { name: /create github issue/i }),
|
||||
).toBeInTheDocument();
|
||||
// The svg isn't directly role-accessible; check it exists under the link
|
||||
const linkEl = screen.getByRole("link", { name: /create github issue/i });
|
||||
expect(linkEl.querySelector("svg")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("prefills URL parameters", () => {
|
||||
const error = new Error("Something exploded");
|
||||
render(<CreateIssueButton error={error} />);
|
||||
const link = screen.getByRole("link", {
|
||||
name: /create github issue/i,
|
||||
}) as HTMLAnchorElement;
|
||||
const url = new URL(link.href);
|
||||
expect(url.hostname).toBe("github.com");
|
||||
expect(url.pathname).toMatch(/\/issues\/new$/);
|
||||
expect(url.searchParams.get("labels")).toBe("bug");
|
||||
expect(url.searchParams.get("title")).toMatch(/Something exploded/);
|
||||
const body = url.searchParams.get("body") ?? "";
|
||||
expect(body).toMatch(/### Description/);
|
||||
expect(body).toMatch(/### Error/);
|
||||
expect(body).toMatch(/Message: Something exploded/);
|
||||
});
|
||||
});
|
88
components/create-issue-button.tsx
Normal file
88
components/create-issue-button.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { Bug } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type ErrorWithOptionalDigest = Error & { digest?: string };
|
||||
|
||||
type CreateIssueButtonProps = {
|
||||
error?: ErrorWithOptionalDigest;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
variant?: React.ComponentProps<typeof Button>["variant"];
|
||||
size?: React.ComponentProps<typeof Button>["size"];
|
||||
};
|
||||
|
||||
const REPOSITORY_SLUG = "jakejarvis/hoot" as const;
|
||||
|
||||
function buildIssueUrl(error?: ErrorWithOptionalDigest) {
|
||||
const message = error?.message?.trim() || "Unexpected error";
|
||||
const digest = error && "digest" in error ? error.digest : undefined;
|
||||
|
||||
const shortMessage =
|
||||
message.length > 80 ? `${message.slice(0, 77)}…` : message;
|
||||
const titleParts = ["Bug:", shortMessage];
|
||||
if (digest) titleParts.push(`(id: ${digest})`);
|
||||
const title = titleParts.join(" ");
|
||||
|
||||
const url = new URL(`https://github.com/${REPOSITORY_SLUG}/issues/new`);
|
||||
url.searchParams.set("labels", "bug");
|
||||
url.searchParams.set("title", title);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push("### Description");
|
||||
lines.push("");
|
||||
lines.push("Describe what you were doing when the error occurred.");
|
||||
lines.push("");
|
||||
lines.push("### Environment");
|
||||
lines.push("");
|
||||
if (typeof window !== "undefined") {
|
||||
lines.push(`- URL: ${window.location.href}`);
|
||||
}
|
||||
if (typeof navigator !== "undefined") {
|
||||
lines.push(`- Browser: ${navigator.userAgent}`);
|
||||
}
|
||||
lines.push(`- Time: ${new Date().toISOString()}`);
|
||||
if (digest) {
|
||||
lines.push(`- Error id: ${digest}`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("### Error");
|
||||
lines.push("");
|
||||
lines.push(`- Message: ${message}`);
|
||||
if (error?.stack) {
|
||||
lines.push("");
|
||||
lines.push("<details><summary>Stack trace</summary>\n\n");
|
||||
lines.push("```text");
|
||||
lines.push(error.stack);
|
||||
lines.push("```");
|
||||
lines.push("\n</details>");
|
||||
}
|
||||
|
||||
url.searchParams.set("body", lines.join("\n"));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function CreateIssueButton(props: CreateIssueButtonProps) {
|
||||
const {
|
||||
error,
|
||||
children,
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
} = props;
|
||||
|
||||
const issueUrl = useMemo(() => buildIssueUrl(error), [error]);
|
||||
|
||||
return (
|
||||
<Button asChild variant={variant} size={size} className={className}>
|
||||
<a href={issueUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Bug />
|
||||
{children ?? "Create GitHub issue"}
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateIssueButton;
|
Reference in New Issue
Block a user