1
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:
2025-09-26 11:46:58 -04:00
parent be27d9ced3
commit d9c110ff7c
3 changed files with 129 additions and 6 deletions

View File

@@ -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>

View 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/);
});
});

View 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;