1
0
mirror of https://github.com/alexandrev/xslt-lab.git synced 2026-09-21 04:43:16 +00:00

Add user feedback improvements: server error reporting, Clarity analytics, usage survey

- Auto-report link on 5xx/network errors: pre-fills GitHub issue with version, error, and stylesheet snippet
- Microsoft Clarity: conditional on VITE_CLARITY_ID env var, loaded after page load
- Usage survey: appears after 3 successful transforms, fires GA4 event, one-shot (stored in localStorage)
- 27 tests passing (10 new tests covering buildBugReportUrl, server error detection, survey lifecycle)
This commit is contained in:
alexandrev-tibco
2026-04-14 10:00:56 +02:00
parent cb2e2617f8
commit 563d0789d0
6 changed files with 369 additions and 2 deletions
+11
View File
@@ -103,6 +103,17 @@
}
</style>
<script type="module" src="/env.js"></script>
<script>
window.addEventListener("load", function () {
var clarityId = window.env && window.env.VITE_CLARITY_ID;
if (!clarityId) return;
(function (c, l, a, r, i, t, y) {
c[a] = c[a] || function () { (c[a].q = c[a].q || []).push(arguments); };
t = l.createElement(r); t.async = 1; t.src = "https://www.clarity.ms/tag/" + i;
y = l.getElementsByTagName(r)[0]; y.parentNode.insertBefore(t, y);
})(window, document, "clarity", "script", clarityId);
}, { once: true });
</script>
<script>
const loadAdsense = () => {
const client = window.env && window.env.VITE_ADSENSE_CLIENT;
+49
View File
@@ -17,6 +17,7 @@ import {
const MonacoEditor = lazy(() => import("@monaco-editor/react"));
const FeedbackWidget = lazy(() => import("./components/FeedbackWidget"));
const BuyMeACoffee = lazy(() => import("./components/BuyMeACoffee"));
const UsageSurvey = lazy(() => import("./components/UsageSurvey"));
function runWhenIdle(callback, timeout = 2000) {
if (typeof window === "undefined") {
@@ -115,6 +116,7 @@ function defaultWorkspaceStatus() {
duration: null,
error: "",
errorLines: [],
isServerError: false,
traceEntries: [],
traceText: "",
showRawTrace: false,
@@ -122,6 +124,26 @@ function defaultWorkspaceStatus() {
};
}
export function buildBugReportUrl(version, error, xslt) {
const trimmedXslt = xslt ? xslt.slice(0, 500) : "";
const body = [
"## Bug report",
"",
`**XSLT version:** ${version || "default"}`,
`**Error:** ${error || ""}`,
"",
"**Stylesheet (first 500 chars):**",
"```xml",
trimmedXslt,
"```",
].join("\n");
const title = `Server error: ${(error || "").slice(0, 80)}`;
return (
"https://github.com/alexandrev/xslt-lab/issues/new" +
`?labels=bug&title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`
);
}
function looksLikeHtml(text) {
if (!text || typeof text !== "string") return false;
const trimmed = text.trim();
@@ -261,6 +283,10 @@ export default function App() {
const [paramsCollapsed, setParamsCollapsed] = useState(false);
const [errorCollapsed, setErrorCollapsed] = useState(false);
const [ethicalAdsReady, setEthicalAdsReady] = useState(false);
const [transformCount, setTransformCount] = useState(0);
const [surveyDone, setSurveyDone] = useState(() => {
try { return localStorage.getItem("xsp_survey_done") === "1"; } catch { return false; }
});
const workspaceImportRef = useRef(null);
const resultResizeState = useRef({ startY: 0, startHeight: MIN_RESULT_HEIGHT });
const paramResizeState = useRef({ startX: 0, startWidth: DEFAULT_PARAM_WIDTH });
@@ -466,6 +492,7 @@ export default function App() {
duration,
error,
errorLines,
isServerError,
traceEntries,
traceText,
showRawTrace,
@@ -979,6 +1006,7 @@ export default function App() {
updateWorkspaceStatus(tabId, {
error: txt || res.statusText,
errorLines: lines,
isServerError: res.status >= 500,
duration: null,
result: "",
traceEntries: [],
@@ -990,11 +1018,13 @@ export default function App() {
}
const data = await res.json();
const defaultView = looksLikeHtml(data.result) ? "render" : "source";
setTransformCount((prev) => prev + 1);
updateWorkspaceStatus(tabId, {
result: data.result,
duration: data.duration_ms,
error: "",
errorLines: [],
isServerError: false,
showRawTrace: false,
resultView: defaultView,
});
@@ -1012,6 +1042,7 @@ export default function App() {
updateWorkspaceStatus(tabId, {
error: txt,
errorLines: parseErrorLines(txt),
isServerError: true,
result: "",
duration: null,
traceEntries: [],
@@ -1784,6 +1815,16 @@ export default function App() {
+{(errorLines || []).length - MAX_ERROR_LINES} more
</div>
)}
{isServerError && (
<a
className="error-report-link"
href={buildBugReportUrl(activeTab?.version, error, activeTab?.xslt)}
target="_blank"
rel="noopener noreferrer"
>
Report this bug on GitHub
</a>
)}
</div>
)}
{error && errorCollapsed && (
@@ -1976,6 +2017,14 @@ export default function App() {
<Suspense fallback={null}>
<BuyMeACoffee />
<FeedbackWidget />
{!surveyDone && transformCount >= 3 && (
<UsageSurvey
onDismiss={() => {
setSurveyDone(true);
try { localStorage.setItem("xsp_survey_done", "1"); } catch {}
}}
/>
)}
</Suspense>
)}
</div>
+106 -2
View File
@@ -1,6 +1,6 @@
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
import { render, screen, waitFor, cleanup, fireEvent, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import App from "./App";
import App, { buildBugReportUrl } from "./App";
vi.mock("@monaco-editor/react", () => ({
default: () => <div data-testid="monaco-editor" />,
@@ -53,6 +53,110 @@ describe("App bootstrap", () => {
});
});
describe("buildBugReportUrl", () => {
it("includes version and error in the URL", () => {
const url = buildBugReportUrl("2.0", "Connection refused", "<xsl:stylesheet/>");
expect(url).toContain("labels=bug");
expect(url).toContain(encodeURIComponent("2.0"));
expect(url).toContain(encodeURIComponent("Connection refused"));
});
it("truncates xslt to 500 chars in the body", () => {
const longXslt = "x".repeat(600);
const url = buildBugReportUrl("1.0", "err", longXslt);
const body = decodeURIComponent(url.split("body=")[1]);
expect(body).toContain("x".repeat(500));
expect(body).not.toContain("x".repeat(501));
});
it("handles missing arguments gracefully", () => {
const url = buildBugReportUrl(undefined, undefined, undefined);
expect(url).toContain("github.com/alexandrev/xslt-lab/issues/new");
});
});
describe("server error reporting", () => {
it("shows report link on 5xx response", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve({
ok: false,
status: 500,
statusText: "Internal Server Error",
json: async () => ({ error: "Saxon died" }),
}),
),
);
render(<App />);
fireEvent.pointerDown(window);
await waitFor(() =>
expect(screen.getByText(/report this bug/i)).toBeInTheDocument(),
);
});
it("does not show report link on 400 XSLT syntax error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve({
ok: false,
status: 400,
statusText: "Bad Request",
json: async () => ({ error: "XSLT syntax error at line 3" }),
}),
),
);
render(<App />);
fireEvent.pointerDown(window);
await waitFor(() =>
expect(screen.getByText(/xslt syntax error/i)).toBeInTheDocument(),
);
expect(screen.queryByText(/report this bug/i)).not.toBeInTheDocument();
});
});
describe("usage survey", () => {
it("does not show survey before 3 successful transforms", async () => {
render(<App />);
fireEvent.pointerDown(window);
await waitFor(() => expect(fetch).toHaveBeenCalled());
expect(screen.queryByText(/what are you using this for/i)).not.toBeInTheDocument();
});
it("shows survey after 3 successful transforms", async () => {
render(<App />);
fireEvent.pointerDown(window);
// 1st transform on mount
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1), { timeout: 2000 });
// Toggle trace twice to trigger 2 more transforms
const traceCheckbox = screen.getByRole("checkbox", { name: /enable internal variables/i });
await act(async () => { fireEvent.click(traceCheckbox); });
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2), { timeout: 2000 });
await act(async () => { fireEvent.click(traceCheckbox); });
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(3), { timeout: 2000 });
await waitFor(() =>
expect(screen.getByText(/what are you using this for/i)).toBeInTheDocument(),
);
});
it("dismisses survey when ✕ is clicked", async () => {
render(<App />);
fireEvent.pointerDown(window);
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1), { timeout: 2000 });
const traceCheckbox = screen.getByRole("checkbox", { name: /enable internal variables/i });
await act(async () => { fireEvent.click(traceCheckbox); });
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2), { timeout: 2000 });
await act(async () => { fireEvent.click(traceCheckbox); });
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(3), { timeout: 2000 });
await waitFor(() =>
expect(screen.getByText(/what are you using this for/i)).toBeInTheDocument(),
);
fireEvent.click(screen.getByRole("button", { name: /dismiss survey/i }));
expect(screen.queryByText(/what are you using this for/i)).not.toBeInTheDocument();
});
});
describe("version selector", () => {
it("renders XSLT 1.0, 2.0 and 3.0 options", async () => {
render(<App />);
+41
View File
@@ -0,0 +1,41 @@
const SURVEY_OPTIONS = [
{ id: "learning", label: "Learning XSLT" },
{ id: "work", label: "Work / integration" },
{ id: "debugging", label: "Debugging" },
{ id: "other", label: "Other" },
];
export default function UsageSurvey({ onDismiss }) {
const handleSelect = (id) => {
if (typeof window.gtag === "function") {
window.gtag("event", "usage_survey", { use_case: id });
}
onDismiss();
};
return (
<div className="usage-survey" role="complementary" aria-label="Quick survey">
<span className="usage-survey-q">What are you using this for?</span>
<div className="usage-survey-options">
{SURVEY_OPTIONS.map((opt) => (
<button
key={opt.id}
type="button"
className="usage-survey-btn"
onClick={() => handleSelect(opt.id)}
>
{opt.label}
</button>
))}
</div>
<button
type="button"
className="usage-survey-dismiss"
onClick={onDismiss}
aria-label="Dismiss survey"
>
</button>
</div>
);
}
@@ -0,0 +1,46 @@
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import UsageSurvey from "./UsageSurvey";
afterEach(cleanup);
describe("UsageSurvey", () => {
it("renders all four options", () => {
render(<UsageSurvey onDismiss={() => {}} />);
expect(screen.getByText("Learning XSLT")).toBeInTheDocument();
expect(screen.getByText("Work / integration")).toBeInTheDocument();
expect(screen.getByText("Debugging")).toBeInTheDocument();
expect(screen.getByText("Other")).toBeInTheDocument();
});
it("calls onDismiss when an option is clicked", () => {
const onDismiss = vi.fn();
render(<UsageSurvey onDismiss={onDismiss} />);
fireEvent.click(screen.getByRole("button", { name: "Learning XSLT" }));
expect(onDismiss).toHaveBeenCalledOnce();
});
it("calls onDismiss when the dismiss button is clicked", () => {
const onDismiss = vi.fn();
render(<UsageSurvey onDismiss={onDismiss} />);
fireEvent.click(screen.getByRole("button", { name: /dismiss survey/i }));
expect(onDismiss).toHaveBeenCalledOnce();
});
it("fires a gtag event with the selected use_case", () => {
const gtag = vi.fn();
window.gtag = gtag;
render(<UsageSurvey onDismiss={() => {}} />);
fireEvent.click(screen.getByRole("button", { name: "Work / integration" }));
expect(gtag).toHaveBeenCalledWith("event", "usage_survey", { use_case: "work" });
delete window.gtag;
});
it("does not throw when gtag is not defined", () => {
delete window.gtag;
render(<UsageSurvey onDismiss={() => {}} />);
expect(() =>
fireEvent.click(screen.getByRole("button", { name: "Debugging" })),
).not.toThrow();
});
});
+116
View File
@@ -1479,3 +1479,119 @@ a:focus-visible {
:root[data-theme="dark"] .feedback-link:hover {
background: #4a3916;
}
/* ── Error report link ── */
.error-report-link {
display: inline-block;
margin: 6px 8px 4px;
font-size: 0.78rem;
color: #c0392b;
text-decoration: underline;
cursor: pointer;
}
.error-report-link:hover {
color: #922b21;
}
:root[data-theme="dark"] .error-report-link {
color: #ff7675;
}
:root[data-theme="dark"] .error-report-link:hover {
color: #fab1a0;
}
/* ── Usage survey banner ── */
.usage-survey {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 10px;
background: #fff;
border: 1px solid #cfdcf4;
border-radius: 12px;
padding: 10px 14px;
box-shadow: 0 4px 16px rgba(33, 66, 108, 0.14);
z-index: 100;
flex-wrap: wrap;
max-width: 90vw;
}
.usage-survey-q {
font-size: 0.85rem;
font-weight: 600;
color: #21426c;
white-space: nowrap;
}
.usage-survey-options {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.usage-survey-btn {
padding: 4px 10px;
border: 1px solid #cfdcf4;
border-radius: 20px;
background: #f0f5ff;
color: #21426c;
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
}
.usage-survey-btn:hover {
background: #007acc;
color: #fff;
border-color: #007acc;
}
.usage-survey-dismiss {
margin-left: 4px;
padding: 2px 6px;
border: none;
background: transparent;
color: #536084;
font-size: 0.85rem;
cursor: pointer;
border-radius: 4px;
}
.usage-survey-dismiss:hover {
background: #eef3ff;
}
:root[data-theme="dark"] .usage-survey {
background: #0f141b;
border-color: #2b3645;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
:root[data-theme="dark"] .usage-survey-q {
color: #e3e8ef;
}
:root[data-theme="dark"] .usage-survey-btn {
background: #1a2332;
color: #b0bfcf;
border-color: #2b3645;
}
:root[data-theme="dark"] .usage-survey-btn:hover {
background: #6bb6ff;
color: #0f141b;
border-color: #6bb6ff;
}
:root[data-theme="dark"] .usage-survey-dismiss {
color: #7a8fa8;
}
:root[data-theme="dark"] .usage-survey-dismiss:hover {
background: #1a2332;
}