From 4ae1b1f9f4d3755d7c641f16b483fc2f60de9463 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Fri, 2 Oct 2026 21:58:00 +1000 Subject: [PATCH 1/2] Share private feedback across mdbase applications --- apps/editor/.env.e2e | 1 + apps/editor/src/App.tsx | 19 +- apps/editor/src/AppErrorBoundary.tsx | 25 +- apps/editor/src/CollectionRail.tsx | 3 + apps/editor/src/ConnectApp.test.tsx | 12 +- apps/editor/src/ConnectApp.tsx | 58 ++-- apps/editor/src/ConnectionScreens.tsx | 2 + apps/editor/src/FeedbackPage.test.tsx | 264 +++++++++++------- apps/editor/src/FeedbackPage.tsx | 172 +----------- apps/editor/src/Turnstile.tsx | 77 ----- apps/editor/src/app-state-types.ts | 8 + apps/editor/src/connect.css | 156 ----------- apps/editor/src/feedback.ts | 238 +--------------- apps/editor/src/main.tsx | 17 +- apps/editor/src/test-setup.ts | 6 + apps/editor/tests/feedback-capture.mjs | 52 ++++ apps/editor/tests/feedback.spec.ts | 140 ++++++++++ config/architecture-budgets.json | 10 +- docs/code-quality.md | 13 + docs/shared-feedback.md | 158 +++++++++++ packages/app-ui/README.md | 56 ++++ packages/app-ui/css/feedback.css | 62 ++++ packages/app-ui/package.json | 5 + packages/app-ui/src/Dialog.tsx | 1 + packages/app-ui/src/Feedback.tsx | 222 +++++++++++++++ packages/app-ui/src/FeedbackMarkup.tsx | 110 ++++++++ packages/app-ui/src/FeedbackVerification.tsx | 83 ++++++ packages/app-ui/src/feedback.test.ts | 41 +++ packages/app-ui/src/feedback.ts | 183 ++++++++++++ services/feedback/README.md | 16 +- services/feedback/src/index.ts | 69 ++++- services/feedback/src/shared-feedback.test.ts | 69 +++++ 32 files changed, 1554 insertions(+), 794 deletions(-) delete mode 100644 apps/editor/src/Turnstile.tsx create mode 100644 apps/editor/tests/feedback-capture.mjs create mode 100644 apps/editor/tests/feedback.spec.ts create mode 100644 docs/shared-feedback.md create mode 100644 packages/app-ui/css/feedback.css create mode 100644 packages/app-ui/src/Feedback.tsx create mode 100644 packages/app-ui/src/FeedbackMarkup.tsx create mode 100644 packages/app-ui/src/FeedbackVerification.tsx create mode 100644 packages/app-ui/src/feedback.test.ts create mode 100644 packages/app-ui/src/feedback.ts create mode 100644 services/feedback/src/shared-feedback.test.ts diff --git a/apps/editor/.env.e2e b/apps/editor/.env.e2e index 14c7babbf..98f0b766b 100644 --- a/apps/editor/.env.e2e +++ b/apps/editor/.env.e2e @@ -1 +1,2 @@ VITE_MDBASE_EDITOR_DEMO=1 +VITE_MDBASE_FEEDBACK_URL=http://127.0.0.1:8790/v1/feedback diff --git a/apps/editor/src/App.tsx b/apps/editor/src/App.tsx index bccc3912b..41808d483 100644 --- a/apps/editor/src/App.tsx +++ b/apps/editor/src/App.tsx @@ -25,6 +25,7 @@ import { ActionMenu } from "./ActionMenu"; import { AttachmentTransfer, attachmentMenuItem, useAttachmentUpload } from "./AttachmentUpload"; import { useCollectionBrowserEntries } from "./collection-browser"; import { CollectionRail } from "./CollectionRail"; +import { useFeedback } from "@mdbase-dev/ui/feedback"; import { CollectionSwitcher, ConnectScreen } from "./ConnectionScreens"; import { ConflictResolver } from "./ConflictResolver"; import { DraftRecovery, frontmatterPatch } from "./draft-recovery"; @@ -35,7 +36,7 @@ import { type ContractCatalogPack } from "./contract-catalog"; import { reviewCatalogPackInstallation } from "./catalog-pack-installation"; -import type { AppPhase, ConnectionState, ContractCatalogLoadState, CreationContext, MobileHistoryState, MobilePane, Surface } from "./app-state-types"; +import { isMobileHistoryState, type AppPhase, type ConnectionState, type ContractCatalogLoadState, type CreationContext, type MobileHistoryState, type MobilePane, type Surface } from "./app-state-types"; import { editorPermissions, gatewayError, missingCoreCapabilities, missingTypeCapabilities } from "./gateway"; import { CollectionMutationScope, StaleCollectionOperationError, type CollectionScopeToken } from "./collection-mutation-scope"; import { useTypeDefinitionLifecycle } from "./use-type-definition-lifecycle"; @@ -155,7 +156,8 @@ interface NoteNavigationOptions { historyIndex?: number; } -export function App({ gateway }: { gateway: CollectionGateway }) { +export function App({ gateway, onFeedbackContext }: { gateway: CollectionGateway; onFeedbackContext?(surface: Surface, collectionName?: string): void }) { + const { reportError } = useFeedback(); const { controller: indexController, state: collectionIndex } = useCollectionIndex(gateway); const { controller: fileController, state: fileInventory } = useFileInventory(gateway); const fileAssetStore = useFileAssetStore(gateway); @@ -191,6 +193,8 @@ export function App({ gateway }: { gateway: CollectionGateway }) { const deferredSearch = useDeferredValue(search); const [noteFilter, setNoteFilter] = useState(); const [surface, setSurface] = useState(initialEditorSurface); + useEffect(() => { onFeedbackContext?.(surface, description?.displayName); }, [surface, description?.displayName, onFeedbackContext]); + useEffect(() => { if (noteOpenFailure) reportError({ code: "source_open_failed" }); }, [noteOpenFailure, reportError]); const [selectedTypeName, setSelectedTypeName] = useState(); const [typeWorkspace, setTypeWorkspace] = useState<"definition" | "packs">("definition"); const [contractCatalog, setContractCatalog] = useState({ status: "idle" }); @@ -442,6 +446,7 @@ export function App({ gateway }: { gateway: CollectionGateway }) { session.error = `“${session.draft.title || session.document.path}” changed elsewhere. Your edits are still here.`; if (active) setNotice(session.error); } else if (snapshot.problem && snapshot.problem !== previous.problem) { + reportError({ code: "save_failed" }); const message = gatewayError(new MdbaseConnectError(snapshot.problem)); session.error = message; setNotice(active ? message : `Couldn’t save “${session.draft.title || session.document.path}”. ${message}`); @@ -449,7 +454,7 @@ export function App({ gateway }: { gateway: CollectionGateway }) { session.error = undefined; } touchSession(session); - }, [touchSession, updateNoteSummary]); + }, [touchSession, updateNoteSummary, reportError]); const createSession = useCallback((next: NoteDocument) => { const session = new NoteSession(next, () => typeDescriptorsRef.current, sessionRecords(mutationScope.current.token())); @@ -2261,11 +2266,3 @@ function collectionExplicitTypeKeys(configuration: unknown): string[] { if (!Array.isArray(configured)) return ["type", "types"]; return configured.filter((key): key is string => typeof key === "string"); } - -function isMobileHistoryState(value: unknown): value is MobileHistoryState { - if (!value || typeof value !== "object") return false; - const state = value as Partial; - return state.mdbaseEditor === true - && (state.pane === "collections" || state.pane === "notes" || state.pane === "editor") - && (state.surface === "notes" || state.surface === "types" || state.surface === "settings"); -} diff --git a/apps/editor/src/AppErrorBoundary.tsx b/apps/editor/src/AppErrorBoundary.tsx index 89a347df3..8f22620be 100644 --- a/apps/editor/src/AppErrorBoundary.tsx +++ b/apps/editor/src/AppErrorBoundary.tsx @@ -1,7 +1,9 @@ -import { Component, type ErrorInfo, type ReactNode } from "react"; +import { Component, useEffect, type ErrorInfo, type ReactNode } from "react"; +import { FeedbackButton, useFeedback } from "@mdbase-dev/ui/feedback"; interface AppErrorBoundaryProps { children: ReactNode; + product?: "mdbase editor" | "mdbase connect"; } interface AppErrorBoundaryState { @@ -16,17 +18,22 @@ export class AppErrorBoundary extends Component -
- mdbase editor needs to restart -

Your collection was not deleted. Changes that had already finished saving are safe.

- -
- ; + return ; } } + +function FatalError({ product }: { product: "mdbase editor" | "mdbase connect" }) { + const { reportError } = useFeedback(); + useEffect(() => { reportError({ code: "unknown_error" }); }, [reportError]); + return
+ {product} needs to restart +

Your collection was not deleted. Changes that had already finished saving are safe.

+ + +
; +} diff --git a/apps/editor/src/CollectionRail.tsx b/apps/editor/src/CollectionRail.tsx index 50401732d..49ad410f9 100644 --- a/apps/editor/src/CollectionRail.tsx +++ b/apps/editor/src/CollectionRail.tsx @@ -13,6 +13,8 @@ import { TagIcon as Tag } from "./icons"; import { ContextMenu } from "./ContextMenu"; +import { FeedbackButton } from "@mdbase-dev/ui/feedback"; +import "@mdbase-dev/ui/feedback.css"; import { EditorRail } from "./EditorRail"; import type { CollectionFile, ConnectionSummary, NoteSummary } from "./model"; import { folderTree, tags as collectionTags, types as collectionTypes, type FolderTreeNode } from "./note"; @@ -74,6 +76,7 @@ export function CollectionRail({ collectionId, name, count, types, activeFilter, ? :

} {connectionState === "reconnecting" && } + } > diff --git a/apps/editor/src/ConnectApp.test.tsx b/apps/editor/src/ConnectApp.test.tsx index 7ecc2174c..618bcaab8 100644 --- a/apps/editor/src/ConnectApp.test.tsx +++ b/apps/editor/src/ConnectApp.test.tsx @@ -77,13 +77,15 @@ describe("ConnectApp", () => { render(); await screen.findByRole("heading", { name: "Garden notes" }); - await user.click(screen.getAllByRole("link", { name: "Send feedback" })[0]); + await user.click(screen.getAllByRole("button", { name: "Send feedback" })[0]); - await waitFor(() => expect(location.pathname).toBe("/connect/feedback")); - expect(screen.getByRole("heading", { name: "Send feedback" })).toHaveFocus(); + expect(location.pathname).toBe("/connect"); + expect(screen.getByRole("dialog", { name: "Send feedback" })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /What happened/ })).toHaveFocus(); + await user.click(screen.getByText("What gets sent")); expect(screen.getByRole("checkbox", { name: /Include collection name: Garden notes/ })).not.toBeChecked(); - expect(screen.getByRole("checkbox", { name: /Include application origin/ })).not.toBeChecked(); - expect(screen.getByText(/Nothing here is included unless you choose it/)).toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: /Include technical diagnostics/ })).not.toBeChecked(); + expect(screen.getByText("Private feedback to the mdbase team.")).toBeInTheDocument(); }); it("keeps Connect inside the editor collection shell", async () => { diff --git a/apps/editor/src/ConnectApp.tsx b/apps/editor/src/ConnectApp.tsx index 9c3308393..042d9ae11 100644 --- a/apps/editor/src/ConnectApp.tsx +++ b/apps/editor/src/ConnectApp.tsx @@ -14,6 +14,7 @@ import { } from "@mdbase/connect-ui/access"; import { useCallback, useEffect, useRef, useState, type FormEvent, type MouseEvent, type ReactNode } from "react"; import { AccountManagement, DeletedAccount } from "./AccountManagement"; +import { AppErrorBoundary } from "./AppErrorBoundary"; import { MdbaseMark } from "@mdbase-dev/ui/brand"; import { usePermissionSelection } from "@mdbase-dev/ui/permission-selection"; import { @@ -29,7 +30,8 @@ import { } from "./ConnectPrimitives"; import { EditorRail } from "./EditorRail"; import { FeedbackPage } from "./FeedbackPage"; -import { feedbackEndpoint as configuredFeedbackEndpoint, recordFeedbackFailure, turnstileSiteKey, type FeedbackSourceView } from "./feedback"; +import { FeedbackButton, FeedbackProvider, feedbackApplication, useFeedback, type FeedbackFailure } from "@mdbase-dev/ui/feedback"; +import { feedbackEndpoint as configuredFeedbackEndpoint, turnstileSiteKey } from "./feedback"; import { BracketsCurlyIcon as Braces, GearSixIcon as Settings, @@ -40,6 +42,7 @@ import { WarningCircleIcon as Warning } from "./icons"; import "./connect.css"; +import "@mdbase-dev/ui/feedback.css"; import { Select } from "@mdbase-dev/ui/select"; type MemberRole = "viewer" | "editor"; @@ -47,7 +50,7 @@ type MemberRole = "viewer" | "editor"; // The owner row shows "Owner" instead of a role choice, so only assignable roles are listed. const memberRoles: ReadonlyArray<{ value: MemberRole; label: string }> = [{ value: "viewer", label: "Viewer" }, { value: "editor", label: "Editor" }]; -type ConnectView = FeedbackSourceView; +type ConnectView = "overview" | "storage" | "access" | "collections" | "applications" | "computers" | "account" | "feedback"; type Grant = ManagementOverview["grants"][number]; type BusyOperations = ReadonlySet; type PerformOperation = ( @@ -66,9 +69,19 @@ const allOperations = [ ]; export function ConnectApp() { + const [context, setContext] = useState<{ view: ConnectView; collectionName?: string }>({ view: viewFromPath() }); + const updateContext = useCallback((view: ConnectView, collectionName?: string) => { + setContext((previous) => previous.view === view && previous.collectionName === collectionName ? previous : { view, collectionName }); + }, []); + return + + ; +} + +function ConnectWorkspace({ onFeedbackContext }: { onFeedbackContext(view: ConnectView, collectionName?: string): void }) { + const { reportError, enabled: feedbackEnabled } = useFeedback(); const [accountDeleted, setAccountDeleted] = useState(location.pathname === "/connect/account-deleted"); const [view, setView] = useState(viewFromPath); - const [feedbackSourceView, setFeedbackSourceView] = useState("overview"); const [data, setData] = useState(); const [sessions, setSessions] = useState>["sessions"]>(); const [refreshError, setRefreshError] = useState(""); @@ -102,7 +115,6 @@ export function ConnectApp() { } } catch (reason) { if (signal?.aborted) return; - recordFeedbackFailure("management_refresh_failed", reason); if (reason instanceof ManagementApiError && reason.status === 401) { location.href = new URL("/login", management.baseUrl).href; return; @@ -156,7 +168,7 @@ export function ConnectApp() { succeeded = true; } catch (reason) { if (!lifecycle.aborted) { - recordFeedbackFailure("management_request_failed", reason); + reportError(feedbackFailure(reason)); setMutationError(errorMessage(reason)); } } finally { @@ -187,7 +199,6 @@ export function ConnectApp() { } function navigate(next: ConnectView, collectionId?: string) { - if (next === "feedback" && view !== "feedback") setFeedbackSourceView(view); const path = next === "overview" ? "/connect" : `/connect/${next}`; const url = new URL(location.href); url.pathname = path; @@ -209,6 +220,8 @@ export function ConnectApp() { ?? (collections.length === 1 ? collections[0] : undefined); const selectedCollectionId = selectedCollection?.id; + useEffect(() => { onFeedbackContext(view, selectedCollection?.name); }, [view, selectedCollection?.name, onFeedbackContext]); + useEffect(() => { if (!data || accountDeleted) return; if (selectedCollectionId) { @@ -242,10 +255,6 @@ export function ConnectApp() { || (typeof grant.reauthorization_required_at === "string" && !freshlyAuthorized.has(`${grant.application_id}\0${grant.collection_id}`))); const applications = groupApplicationAccess(activeGrants); - const feedbackEndpoint = configuredFeedbackEndpoint(); - const feedbackApplicationOrigins = [...new Set((selectedCollection - ? activeGrants.filter((grant) => grant.collection_id === selectedCollection.id) - : activeGrants).map((grant) => normalizedOrigin(grant.application_origin)).filter(Boolean))].sort(); const selectedGrants = selectedCollection ? activeGrants.filter((grant) => grant.collection_id === selectedCollection.id) : []; @@ -268,7 +277,7 @@ export function ConnectApp() { onSwitch={() => navigate("collections", selectedCollection?.id)} footer={<> {selectedCollection &&

} - {feedbackEndpoint && Send feedback} + {data.user.name}{identityLabel(data.user)} } /> @@ -287,7 +296,7 @@ export function ConnectApp() { } selected={activeView === "applications"} view="applications" collectionId={selectedCollection?.id} navigate={navigate} /> } selected={activeView === "computers"} view="computers" collectionId={selectedCollection?.id} navigate={navigate} /> } selected={activeView === "account"} view="account" collectionId={selectedCollection?.id} navigate={navigate} /> - {feedbackEndpoint && Send feedback} + @@ -308,15 +317,8 @@ export function ConnectApp() { {activeView === "applications" && } {activeView === "computers" && } {activeView === "account" && setAccountDeleted(true)} />} - {activeView === "feedback" && feedbackEndpoint && navigate(feedbackSourceView === "feedback" ? "overview" : feedbackSourceView, selectedCollection?.id)} - />} - {activeView === "feedback" && !feedbackEndpoint &&
} + {activeView === "feedback" && feedbackEnabled && navigate("overview", selectedCollection?.id)} />} + {activeView === "feedback" && !feedbackEnabled &&
} ; } @@ -780,7 +782,12 @@ function RouteLink({ view, collectionId, navigate, children, className = "", ari } function ConnectLoading({ error }: { error: string }) { - return
{error ? "mdbase connect is unavailable" : "Opening mdbase connect"}

{error || "Loading your account and collections…"}

; + const { reportError } = useFeedback(); + const reportedInitialFailure = useRef(false); + useEffect(() => { + if (error && !reportedInitialFailure.current) { reportedInitialFailure.current = true; reportError({ code: "unknown_error" }); } + }, [error, reportError]); + return
{error ? "mdbase connect is unavailable" : "Opening mdbase connect"}

{error || "Loading your account and collections…"}

; } function DesktopRecoveryHelp({ action }: { action: string }) { @@ -923,8 +930,11 @@ function host(value: string): string { try { return new URL(value).host; } catch { return value; } } -function normalizedOrigin(value: string): string { - try { return new URL(value).origin; } catch { return ""; } +function feedbackFailure(reason: unknown): FeedbackFailure { + if (!(reason instanceof ManagementApiError)) return { code: "unknown_error" }; + const codes = ["cancelled", "http_error", "invalid_response", "outcome_unknown", "partial_failure", "timeout", "network_error"] as const; + const code = codes.find((value) => value === reason.code) ?? "http_error"; + return { code, ...(Number.isInteger(reason.status) && reason.status >= 100 && reason.status <= 599 ? { status: reason.status } : {}) }; } function relativeTime(value: string): string { diff --git a/apps/editor/src/ConnectionScreens.tsx b/apps/editor/src/ConnectionScreens.tsx index f9061519a..12a9dce71 100644 --- a/apps/editor/src/ConnectionScreens.tsx +++ b/apps/editor/src/ConnectionScreens.tsx @@ -6,6 +6,7 @@ import { } from "./icons"; import { ActionMenu } from "./ActionMenu"; import { ConnectLayout } from "@mdbase-dev/ui/screens"; +import { FeedbackButton } from "@mdbase-dev/ui/feedback"; import { Dialog } from "./Dialog"; import type { ConnectionSummary } from "./model"; @@ -55,6 +56,7 @@ export function ConnectScreen({ notice, missingCapabilities = [], connections, o {!fatal &&

{updatingAccess ? "mdbase connect keeps the access you already approved and shows only what needs to be added." : "You’ll continue to mdbase connect. Sign in if asked, choose a collection, and approve mdbase editor. You’ll return here automatically; your files stay where they are."}

} +
Collection not listed?

The editor opens mdbase 0.3 collections. For an older collection, use mdbase to upgrade a copy, verify that copy, then choose it here. Your original files can stay untouched while you check the result.

; } diff --git a/apps/editor/src/FeedbackPage.test.tsx b/apps/editor/src/FeedbackPage.test.tsx index 819415144..3c8a75930 100644 --- a/apps/editor/src/FeedbackPage.test.tsx +++ b/apps/editor/src/FeedbackPage.test.tsx @@ -1,116 +1,190 @@ -import { render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FeedbackButton, FeedbackProvider, feedbackApplication, useFeedback } from "@mdbase-dev/ui/feedback"; import { FeedbackPage } from "./FeedbackPage"; -import { FEEDBACK_MAX_SCREENSHOT_BYTES, readFeedbackScreenshot } from "./feedback"; -const ONE_PIXEL_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; +const application = feedbackApplication("mdbase reader", "library", "abc123", "development"); +function FailureControls() { + const { reportError } = useFeedback(); + return <>; +} +function fixture({ endpoint = "https://feedback-api.mdbase.dev/v1/feedback", siteKey = null }: { endpoint?: string | null; siteKey?: string | null } = {}) { + return render(); +} +async function open() { + await userEvent.click(screen.getByRole("button", { name: "Send feedback" })); + return screen.getByRole("dialog", { name: "Send feedback" }); +} +afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); Reflect.deleteProperty(window, "turnstile"); }); -beforeEach(() => { - vi.stubGlobal("fetch", vi.fn(async () => Response.json({ ok: true }, { status: 202 }))); -}); +describe("shared feedback", () => { + it("opens form-first, focuses the description, and sends nothing until explicit submission", async () => { + fixture(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + const dialog = await open(); + const description = within(dialog).getByRole("textbox", { name: /What happened/ }); + expect(description).toHaveFocus(); + expect(dialog.querySelectorAll("details")).toHaveLength(1); + expect(dialog.querySelector("details")).not.toHaveAttribute("open"); + expect(within(dialog).queryByText("HELP US MAKE IT BETTER")).not.toBeInTheDocument(); + expect(dialog.querySelector(".mdbase-feedback-context")).not.toBeInTheDocument(); + expect(within(dialog).getAllByRole("heading").map((heading) => heading.textContent)).toEqual(["Send feedback"]); + await userEvent.click(within(dialog).getByText("What gets sent")); + expect(within(dialog).getByRole("checkbox", { name: /Include technical diagnostics/ })).not.toBeChecked(); + expect(within(dialog).getByRole("checkbox", { name: /Include collection name/ })).not.toBeChecked(); + await userEvent.click(within(dialog).getByText("What gets sent")); + expect(fetch).not.toHaveBeenCalled(); + await userEvent.type(description, "Something went wrong."); + await userEvent.click(within(dialog).getByRole("button", { name: "Send feedback" })); + expect(await screen.findByText("Thanks for the report.")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Thanks for the report." })).toHaveFocus(); + const [url, init] = vi.mocked(fetch).mock.calls[0]; + expect(url).toBe("https://feedback-api.mdbase.dev/v1/feedback"); + expect(init).toMatchObject({ method: "POST", credentials: "omit", referrerPolicy: "no-referrer" }); + expect(JSON.parse(String(init?.body))).toEqual({ schema_version: 2, request_id: expect.any(String), application, topic: "problem", message: "Something went wrong." }); + }); -afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); + it("keeps drafts and request IDs on failure, while a successful close clears the draft", async () => { + vi.mocked(fetch).mockRejectedValueOnce(new Error("a private infrastructure error")); + fixture(); const dialog = await open(); + await userEvent.type(within(dialog).getByRole("textbox", { name: /What happened/ }), "Please help."); + await userEvent.click(within(dialog).getByRole("button", { name: "Send feedback" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Check your connection"); + expect(screen.queryByText("a private infrastructure error")).not.toBeInTheDocument(); + const requestId = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body)).request_id; + await userEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + await open(); + expect(screen.getByRole("textbox", { name: /What happened/ })).toHaveValue("Please help."); + await userEvent.click(within(dialog).getByRole("button", { name: "Send feedback" })); + expect(await screen.findByText("Thanks for the report.")).toBeInTheDocument(); + expect(JSON.parse(String(vi.mocked(fetch).mock.calls[1][1]?.body)).request_id).toBe(requestId); + await userEvent.click(screen.getByRole("button", { name: "Back to your work" })); + await open(); expect(screen.getByRole("textbox", { name: /What happened/ })).toHaveValue(""); + }); -describe("FeedbackPage", () => { - it("shows optional context and sends only consented fields", async () => { - const user = userEvent.setup(); - render( undefined} - />); + it.each(["Suggest an improvement", "Share something you like"])("supports %s without including problem diagnostics", async (label) => { + fixture(); await userEvent.click(screen.getByRole("button", { name: "Fail visibly" })); + const dialog = await open(); + await userEvent.click(within(dialog).getByText("What gets sent")); + await userEvent.click(within(dialog).getByRole("checkbox", { name: /Include technical diagnostics/ })); + await userEvent.click(within(dialog).getByRole("radio", { name: label })); + await userEvent.type(within(dialog).getAllByRole("textbox")[0], "Thank you."); + await userEvent.click(within(dialog).getByRole("button", { name: "Send feedback" })); + await waitFor(() => expect(fetch).toHaveBeenCalledOnce()); + const sent = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body)); + expect(sent.topic).toBe(label === "Suggest an improvement" ? "idea" : "appreciation"); + expect(sent.diagnostics).toBeUndefined(); + }); - await user.click(screen.getByRole("radio", { name: /Share an idea/ })); - await user.type(screen.getByLabelText("What would you like to be able to do?"), "Make application access easier to compare."); - await user.type(screen.getByLabelText(/Reply email/), "person@example.com"); - await user.click(screen.getByRole("checkbox", { name: /Include collection name/ })); - await user.click(screen.getByRole("button", { name: "Send feedback" })); + it("wiggles only for meaningful, rate-limited failures and includes only opted-in bounded diagnostics", async () => { + fixture(); + await userEvent.click(screen.getByRole("button", { name: "Cancel operation" })); + expect(document.querySelector(".is-wiggling")).toBeNull(); + await userEvent.click(screen.getByRole("button", { name: "Fail visibly" })); + const bug = document.querySelector(".is-wiggling"); expect(bug).not.toBeNull(); + await userEvent.click(screen.getByRole("button", { name: "Fail visibly" })); + expect(document.querySelector(".is-wiggling")).toBe(bug); + expect(fetch).not.toHaveBeenCalled(); + const dialog = await open(); + await userEvent.click(within(dialog).getByText("What gets sent")); + await userEvent.click(within(dialog).getByRole("checkbox", { name: /Include technical diagnostics/ })); + await userEvent.click(within(dialog).getByRole("checkbox", { name: /Include collection name/ })); + await userEvent.type(within(dialog).getByRole("textbox", { name: /What happened/ }), "Save failed."); + await userEvent.click(within(dialog).getByRole("button", { name: "Send feedback" })); + await waitFor(() => expect(fetch).toHaveBeenCalledOnce()); + const sent = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body)); + expect(sent.context).toEqual({ collection_name: "Private collection" }); + expect(sent.diagnostics.events).toEqual([{ at: expect.any(String), code: "save_failed" }, { at: expect.any(String), code: "save_failed" }]); + expect(Object.keys(sent.diagnostics)).toEqual(["schema_version", "browser", "operating_system", "viewport", "events"]); + }); - expect(await screen.findByRole("heading", { name: "Feedback sent" })).toBeInTheDocument(); - expect(fetch).toHaveBeenCalledOnce(); - const [url, init] = vi.mocked(fetch).mock.calls[0]; - expect(url).toBe("https://feedback-api.mdbase.dev/v1/feedback"); - expect(init).toMatchObject({ method: "POST", credentials: "omit", referrerPolicy: "no-referrer" }); - const submission = JSON.parse(String(init?.body)); - expect(submission).toMatchObject({ - schema_version: 1, - topic: "idea", - message: "Make application access easier to compare.", - reply_email: "person@example.com", - context: { collection_name: "Garden notes" }, - diagnostics: { schema_version: 1, surface: "connect", source_view: "applications" } + it("closes the native dialog before the picker and restores the draft after cancellation", async () => { + const picker = vi.fn(async () => { + expect(document.querySelector(".mdbase-feedback-dialog")?.open).toBe(false); + expect(document.querySelector(".mdbase-feedback-app")?.inert).toBe(true); + throw new DOMException("The person cancelled", "NotAllowedError"); }); - expect(submission.context.application_origin).toBeUndefined(); - expect(JSON.stringify(submission.diagnostics)).not.toContain("Garden notes"); - expect(JSON.stringify(submission.diagnostics)).not.toContain("example.app"); + vi.stubGlobal("navigator", { userAgent: "", maxTouchPoints: 0, mediaDevices: { getDisplayMedia: picker } }); + fixture(); const dialog = await open(); + await userEvent.type(within(dialog).getByRole("textbox", { name: /What happened/ }), "Preserve this draft."); + await userEvent.click(within(dialog).getByRole("button", { name: "Attach screenshot" })); + expect(await screen.findByText(/No screenshot taken/)).toBeInTheDocument(); + expect(dialog).toHaveAttribute("open"); + expect(screen.getByRole("textbox", { name: /What happened/ })).toHaveValue("Preserve this draft."); + expect(document.querySelector(".mdbase-feedback-app")?.inert).toBe(false); + expect(picker).toHaveBeenCalledOnce(); expect(fetch).not.toHaveBeenCalled(); }); - it("preserves the draft when delivery fails", async () => { - vi.mocked(fetch).mockResolvedValueOnce(Response.json({ error: { message: "Feedback could not be delivered. Please try again." } }, { status: 503 })); - const user = userEvent.setup(); - render( undefined} - />); - - const message = screen.getByLabelText("What were you trying to do, and what happened?"); - await user.type(message, "The collection stayed offline."); - await user.click(screen.getByRole("button", { name: "Send feedback" })); + it("stops media returned after the provider unmounts", async () => { + let resolve!: (stream: MediaStream) => void; + const picker = vi.fn(() => new Promise((done) => { resolve = done; })); + const stop = vi.fn(); + vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); + vi.stubGlobal("navigator", { userAgent: "", mediaDevices: { getDisplayMedia: picker } }); + const view = fixture(); const dialog = await open(); + await userEvent.click(within(dialog).getByRole("button", { name: "Attach screenshot" })); + await waitFor(() => expect(picker).toHaveBeenCalledOnce()); + view.unmount(); + await act(async () => { resolve({ getTracks: () => [{ stop }] } as unknown as MediaStream); }); + expect(stop).toHaveBeenCalled(); expect(fetch).not.toHaveBeenCalled(); + }); - expect(await screen.findByRole("alert")).toHaveTextContent("Feedback could not be delivered"); - expect(message).toHaveValue("The collection stayed offline."); - expect(screen.getByRole("button", { name: "Send feedback" })).toBeEnabled(); + it("leaves screenshots optional in browsers without capture", async () => { + vi.stubGlobal("navigator", { userAgent: "", mediaDevices: undefined }); + fixture(); const dialog = await open(); + await userEvent.click(within(dialog).getByRole("button", { name: "Attach screenshot" })); + expect(await screen.findByRole("alert")).toHaveTextContent("You can attach an image instead"); + expect(within(dialog).getByLabelText("Choose an image")).toBeInTheDocument(); }); - it("validates screenshot size, type, and magic bytes before submission", async () => { - const oversized = fakeFile("image/png", new Uint8Array(FEEDBACK_MAX_SCREENSHOT_BYTES + 1)); - await expect(readFeedbackScreenshot(oversized)).rejects.toThrow("smaller than 3 MB"); + it("requires verification and resets consumed tokens on retry", async () => { + let verify!: (token: string) => void; + const remove = vi.fn(); + Object.defineProperty(window, "turnstile", { configurable: true, value: { render: vi.fn((_element, options) => { verify = options.callback; return "widget"; }), remove } }); + vi.mocked(fetch).mockRejectedValueOnce(new Error("offline")); + fixture({ siteKey: "configured-key" }); const dialog = await open(); + await userEvent.type(within(dialog).getByRole("textbox", { name: /What happened/ }), "A problem."); + const send = within(dialog).getByRole("button", { name: "Send feedback" }); expect(send).toBeDisabled(); + act(() => verify("one-use-token")); expect(send).toBeEnabled(); + await userEvent.click(send); + expect(await screen.findByRole("alert")).toHaveTextContent("Check your connection"); + expect(send).toBeDisabled(); expect(remove).toHaveBeenCalled(); + expect(JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body)).turnstile_token).toBe("one-use-token"); + }); - const disguised = fakeFile("image/png", new TextEncoder().encode("not a png")); - await expect(readFeedbackScreenshot(disguised)).rejects.toThrow("does not contain a valid PNG"); + it("preserves /connect/feedback bookmarks through the same component", async () => { + render( {}} />); + expect(await screen.findByRole("dialog", { name: "Send feedback" })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /What happened/ })).toHaveFocus(); + }); - const canonicalPng = bytesFromBase64(ONE_PIXEL_PNG_BASE64); - vi.stubGlobal("createImageBitmap", vi.fn(async () => ({ width: 1, height: 1, close: vi.fn() }))); - vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ drawImage: vi.fn() } as unknown as CanvasRenderingContext2D); - const canonicalBuffer = new ArrayBuffer(canonicalPng.byteLength); - new Uint8Array(canonicalBuffer).set(canonicalPng); - vi.spyOn(HTMLCanvasElement.prototype, "toBlob").mockImplementation((callback) => callback(new Blob([canonicalBuffer], { type: "image/png" }))); - const png = fakeFile("image/png", canonicalPng); - await expect(readFeedbackScreenshot(png)).resolves.toMatchObject({ media_type: "image/png", filename: "screenshot.png", content_base64: ONE_PIXEL_PNG_BASE64 }); + it("hides entry points when a deployment does not configure feedback", () => { + fixture({ endpoint: null }); + expect(screen.queryByRole("button", { name: "Send feedback" })).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); - it("requires a configured Turnstile token before enabling submission", async () => { - render( undefined} - />); - const user = userEvent.setup(); - await user.type(screen.getByLabelText("What were you trying to do, and what happened?"), "A clear problem description."); - expect(screen.getByRole("button", { name: "Send feedback" })).toBeDisabled(); + it.each(["http://feedback.example", "ftp://localhost", "https://secret@feedback.example"])("hides invalid feedback destinations: %s", (endpoint) => { + fixture({ endpoint }); + expect(screen.queryByRole("button", { name: "Send feedback" })).not.toBeInTheDocument(); + expect(fetch).not.toHaveBeenCalled(); }); -}); -function bytesFromBase64(value: string): Uint8Array { - return Uint8Array.from(atob(value), (character) => character.charCodeAt(0)); -} + it("resets collection-name consent when the collection changes", async () => { + const shell = (name: string) => ; + const view = render(shell("First collection")); await open(); + await userEvent.click(screen.getByText("What gets sent")); + await userEvent.click(screen.getByRole("checkbox", { name: "Include collection name: First collection" })); + view.rerender(shell("Another collection")); + expect(screen.getByRole("checkbox", { name: "Include collection name: Another collection" })).not.toBeChecked(); + }); -function fakeFile(type: string, bytes: Uint8Array): File { - const data = Uint8Array.from(bytes).buffer; - const file = new File([data], "capture", { type }); - Object.defineProperty(file, "size", { value: data.byteLength }); - Object.defineProperty(file, "arrayBuffer", { value: async () => data }); - return file; -} + it("retains drafts when Escape dismisses the form", async () => { + fixture(); const dialog = await open(); + fireEvent.change(within(dialog).getByRole("textbox", { name: /What happened/ }), { target: { value: "Still here." } }); + fireEvent(dialog, new Event("cancel", { bubbles: false, cancelable: true })); + expect(dialog).not.toHaveAttribute("open"); await open(); + expect(screen.getByRole("textbox", { name: /What happened/ })).toHaveValue("Still here."); + }); +}); diff --git a/apps/editor/src/FeedbackPage.tsx b/apps/editor/src/FeedbackPage.tsx index 9bf078564..0ec40a98e 100644 --- a/apps/editor/src/FeedbackPage.tsx +++ b/apps/editor/src/FeedbackPage.tsx @@ -1,162 +1,12 @@ -import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent } from "react"; -import { ConnectPage as Page, ConnectSectionTitle as SectionTitle } from "./ConnectPrimitives"; -import { Turnstile } from "./Turnstile"; -import { - FEEDBACK_MAX_MESSAGE_LENGTH, - buildFeedbackDiagnostics, - readFeedbackScreenshot, - sendFeedback, - type FeedbackScreenshot, - type FeedbackSourceView, - type FeedbackTopic -} from "./feedback"; -import { Select } from "@mdbase-dev/ui/select"; - -export function FeedbackPage({ endpoint, turnstileSiteKey, sourceView, collectionName, applicationOrigins, onDone }: { - endpoint: string; - turnstileSiteKey: string | null; - sourceView: FeedbackSourceView; - collectionName?: string; - applicationOrigins: string[]; - onDone(): void; -}) { - const [topic, setTopic] = useState("problem"); - const [message, setMessage] = useState(""); - const [replyEmail, setReplyEmail] = useState(""); - const [includeCollection, setIncludeCollection] = useState(false); - const [includeApplication, setIncludeApplication] = useState(false); - const [applicationOrigin, setApplicationOrigin] = useState(applicationOrigins[0] ?? ""); - const [includeDiagnostics, setIncludeDiagnostics] = useState(true); - const [screenshot, setScreenshot] = useState(); - const [screenshotPreview, setScreenshotPreview] = useState(""); - const [screenshotError, setScreenshotError] = useState(""); - const [readingScreenshot, setReadingScreenshot] = useState(false); - const [turnstileToken, setTurnstileToken] = useState(""); - const [turnstileAttempt, setTurnstileAttempt] = useState(0); - const [submitting, setSubmitting] = useState(false); - const [submitError, setSubmitError] = useState(""); - const [submitted, setSubmitted] = useState(false); - const requestId = useRef(createRequestId()); - const diagnostics = useMemo(() => buildFeedbackDiagnostics(sourceView), [sourceView]); - - useEffect(() => () => { - if (screenshotPreview) URL.revokeObjectURL(screenshotPreview); - }, [screenshotPreview]); - - async function chooseScreenshot(event: ChangeEvent) { - const file = event.target.files?.[0]; - setScreenshot(undefined); - setScreenshotError(""); - if (!file) return; - setReadingScreenshot(true); - try { - const next = await readFeedbackScreenshot(file); - setScreenshot(next); - setScreenshotPreview(URL.createObjectURL(file)); - } catch (reason) { - event.target.value = ""; - setScreenshotPreview(""); - setScreenshotError(reason instanceof Error ? reason.message : "The screenshot could not be read."); - } finally { - setReadingScreenshot(false); - } - } - - function removeScreenshot() { - setScreenshot(undefined); - setScreenshotPreview(""); - setScreenshotError(""); - } - - async function submit(event: FormEvent) { - event.preventDefault(); - const cleanMessage = message.trim(); - if (!cleanMessage || readingScreenshot || (turnstileSiteKey && !turnstileToken)) return; - setSubmitting(true); - setSubmitError(""); - try { - await sendFeedback(endpoint, { - schema_version: 1, - request_id: requestId.current, - topic, - message: cleanMessage, - ...(replyEmail.trim() ? { reply_email: replyEmail.trim() } : {}), - ...(includeCollection || includeApplication ? { context: { - ...(includeCollection && collectionName ? { collection_name: collectionName } : {}), - ...(includeApplication && applicationOrigin ? { application_origin: applicationOrigin } : {}) - } } : {}), - ...(includeDiagnostics ? { diagnostics } : {}), - ...(screenshot ? { screenshot } : {}), - ...(turnstileToken ? { turnstile_token: turnstileToken } : {}) - }); - setSubmitted(true); - } catch (reason) { - if (turnstileSiteKey) { - setTurnstileToken(""); - setTurnstileAttempt((attempt) => attempt + 1); - } - setSubmitError(reason instanceof Error ? reason.message : "Feedback could not be sent. Please try again."); - } finally { - setSubmitting(false); - } - } - - if (submitted) { - return -
- -

Feedback is reviewed, but it may not receive an individual response. Security reports should use the private reporting instructions instead.

- -
-
; - } - - return -
void submit(event)}> -
- -
- What would you like to share? - - -
-