e3aada1df2
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The web UI has a bottom-left account flyout menu where users reach profile, docs, and the light/dark toggle > - There was no in-product way for users to send feedback or report issues — they had to find an external channel > - We want a low-friction, always-visible entry point for feedback, and a clean URL we can re-point later without shipping app changes > - This pull request adds a **Feedback** item (Megaphone icon) to the account flyout, between Documentation and the theme toggle, that opens `https://paperclip.ing/feedback` in a new tab > - `paperclip.ing/feedback` is a stable indirection (added to the marketing site) that currently 302-redirects to a Google Form, so the destination can be swapped for a richer solution later with no app release > - The benefit is a one-click feedback path for users and a future-proof link the team controls ## Linked Issues or Issue Description No public GitHub issue exists (tracked internally as Paperclip PAP-107). Describing the underlying request inline as a feature, per CONTRIBUTING.md path (B): ### Problem or motivation Users have no in-app affordance to give feedback or report issues; that friction loses signal we'd otherwise act on. ### Proposed solution Add a Feedback item to the account flyout (Megaphone icon, between Documentation and the theme toggle) that opens a stable `paperclip.ing/feedback` URL in a new tab. That URL redirects to a Google Form for now, keeping the client decoupled from the destination. ### Alternatives considered Linking the Google Form directly from the app — rejected because it bakes a throwaway URL into the client; the `/feedback` indirection keeps the link clean and swappable. ### Roadmap alignment Small, self-contained UX addition; no overlap with planned core work (checked ROADMAP.md). The `/feedback` redirect lives in the separate `paperclip-website` repo (Astro site on Cloudflare Pages), commit `f65b566`. No duplicate/related PRs found in this repo (searched feedback/flyout/menu). ## What Changed - `ui/src/components/SidebarAccountMenu.tsx`: import `Megaphone` from `lucide-react`; add `FEEDBACK_URL = "https://paperclip.ing/feedback"` const next to `DOCS_URL`; insert a `Feedback` `MenuAction` between Documentation and the theme toggle using the `external` prop so it opens in a new tab (`target="_blank"`, `rel="noreferrer"`) and closes the popover on click. - `ui/src/components/SidebarAccountMenu.test.tsx`: assert the Feedback item renders with the correct `href`, opens in a new tab, and is ordered after Documentation and before the theme toggle. - (Separate repo, for context) `paperclip-website` `public/_redirects`: `/feedback` → 302 → the feedback Google Form. ## Verification - **Unit tests:** `SidebarAccountMenu` tests pass (item renders, correct `href`, `target="_blank"`, ordering). Run: `cd ui && npm test -- SidebarAccountMenu`. - **Manual / canary:** The board previewed the canary build of the menu item and accepted it. Clicking **Feedback** opens a new tab to `paperclip.ing/feedback`. - **Redirect:** After the Cloudflare Pages deploy propagates, `curl -sI https://paperclip.ing/feedback` returns the Google Form in the `Location` header. _Screenshots:_ UI change was validated via the accepted canary preview; the item reuses the existing `MenuAction` styling, so it visually matches the Documentation/theme rows. ## Risks - **Low risk.** Additive, self-contained UI change with no new state or API calls. The only external dependency is the `paperclip.ing/feedback` redirect (separate repo, already deployed); if it were missing the link would 404, but it is in place. No migrations, no breaking changes. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. ## Model Used - **Claude (Anthropic).** PR authoring/orchestration: **claude-opus-4-8** (extended thinking + tool use). The implementation commit `b454a12d` was produced with assistance from **claude-sonnet-4-6**. All changes reviewed before pushing. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Paperclip <noreply@paperclip.ing>
141 lines
4.4 KiB
TypeScript
141 lines
4.4 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { createRoot } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { SidebarAccountMenu } from "./SidebarAccountMenu";
|
|
|
|
const mockAuthApi = vi.hoisted(() => ({
|
|
getSession: vi.fn(),
|
|
signInEmail: vi.fn(),
|
|
signUpEmail: vi.fn(),
|
|
getProfile: vi.fn(),
|
|
updateProfile: vi.fn(),
|
|
signOut: vi.fn(),
|
|
}));
|
|
const mockToggleTheme = vi.hoisted(() => vi.fn());
|
|
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("@/api/auth", () => ({
|
|
authApi: mockAuthApi,
|
|
}));
|
|
|
|
vi.mock("@/lib/router", () => ({
|
|
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string }) => (
|
|
<a href={to} {...props}>{children}</a>
|
|
),
|
|
}));
|
|
|
|
vi.mock("../context/SidebarContext", () => ({
|
|
useSidebar: () => ({
|
|
isMobile: false,
|
|
setSidebarOpen: mockSetSidebarOpen,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../context/ThemeContext", () => ({
|
|
useTheme: () => ({
|
|
theme: "dark",
|
|
toggleTheme: mockToggleTheme,
|
|
}),
|
|
}));
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
async function act(callback: () => void | Promise<void>) {
|
|
await callback();
|
|
await Promise.resolve();
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
}
|
|
|
|
async function flushReact() {
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
});
|
|
}
|
|
|
|
describe("SidebarAccountMenu", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
mockAuthApi.getSession.mockResolvedValue({
|
|
session: { id: "session-1", userId: "user-1" },
|
|
user: {
|
|
id: "user-1",
|
|
name: "Jane Example",
|
|
email: "jane@example.com",
|
|
image: "https://example.com/jane.png",
|
|
},
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
container.remove();
|
|
document.body.innerHTML = "";
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("renders the signed-in user and opens the account card menu", async () => {
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<SidebarAccountMenu
|
|
deploymentMode="authenticated"
|
|
version="1.2.3"
|
|
/>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(container.textContent).toContain("Jane Example");
|
|
expect(container.textContent).not.toContain("jane@example.com");
|
|
|
|
const trigger = container.querySelector('button[aria-label="Open account menu"]');
|
|
expect(trigger).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
|
|
expect(document.body.textContent).toContain("Edit profile");
|
|
expect(document.body.textContent).not.toContain("Instance settings");
|
|
expect(document.body.textContent).toContain("Documentation");
|
|
expect(document.body.textContent).toContain("Feedback");
|
|
|
|
// Feedback link opens in a new tab pointing at the feedback URL
|
|
const feedbackAnchor = document.body.querySelector('a[href="https://paperclip.ing/feedback"]') as HTMLAnchorElement | null;
|
|
expect(feedbackAnchor).not.toBeNull();
|
|
expect(feedbackAnchor?.getAttribute("target")).toBe("_blank");
|
|
|
|
// Feedback appears after Documentation and before the theme toggle
|
|
const menuText = document.body.querySelector('[data-slot="popover-content"]')?.textContent ?? "";
|
|
const docsPos = menuText.indexOf("Documentation");
|
|
const feedbackPos = menuText.indexOf("Feedback");
|
|
const themePos = menuText.indexOf("Switch to");
|
|
expect(docsPos).toBeLessThan(feedbackPos);
|
|
expect(feedbackPos).toBeLessThan(themePos);
|
|
|
|
expect(document.body.textContent).toContain("Paperclip v1.2.3");
|
|
expect(document.body.textContent).toContain("jane@example.com");
|
|
expect(document.body.querySelector('[data-slot="popover-content"]')?.className)
|
|
.toContain("w-[277px]");
|
|
expect(document.body.querySelector('a[href="/company/settings/instance/profile"]')).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
});
|