create-feedbax 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -21
- package/README.md +11 -1
- package/dist/cli.js +1261 -26
- package/dist/index.js +1402 -160
- package/package.json +40 -14
- package/template/.feedbax-template.sha256 +1 -0
- package/template/.gitignore.template +6 -0
- package/template/.npmignore.template +5 -0
- package/template/.prettierignore.template +4 -0
- package/template/LICENSE +201 -0
- package/template/components.json +25 -0
- package/template/eslint.config.mjs +25 -0
- package/template/package.json +70 -14
- package/template/public/feedbax-mark.svg +4 -0
- package/template/public/robots.txt +3 -0
- package/template/src/authorized-draft-post.test.tsx +212 -0
- package/template/src/authorized-draft-post.tsx +204 -0
- package/template/src/authorized-draft-query.ts +74 -0
- package/template/src/browser-post-state.test.ts +120 -0
- package/template/src/browser-post-state.ts +134 -0
- package/template/src/browser-vote-state.test.ts +14 -0
- package/template/src/browser-vote-state.ts +27 -0
- package/template/src/capability-authorization-error.ts +3 -0
- package/template/src/changelog-page.test.tsx +139 -0
- package/template/src/changelog-page.tsx +219 -0
- package/template/src/changelog-query.ts +13 -0
- package/template/src/changelog-runtime.ts +27 -0
- package/template/src/cloudflare-turnstile.ts +31 -0
- package/template/src/cloudflare-workers-test.ts +3 -0
- package/template/src/cloudflare-workers.d.ts +18 -0
- package/template/src/comment-creation.test.tsx +270 -0
- package/template/src/comment-profile.ts +5 -0
- package/template/src/comment-thread-page.ts +18 -0
- package/template/src/components/command-palette-context.tsx +71 -0
- package/template/src/components/command-palette.tsx +246 -0
- package/template/src/components/create-post-trigger.tsx +44 -0
- package/template/src/components/device-profile-provider.tsx +309 -0
- package/template/src/components/header.tsx +95 -0
- package/template/src/components/loader.tsx +10 -0
- package/template/src/components/profile-menu.tsx +89 -0
- package/template/src/components/ui/components/alert-dialog.tsx +185 -0
- package/template/src/components/ui/components/attachment.tsx +207 -0
- package/template/src/components/ui/components/avatar.tsx +107 -0
- package/template/src/components/ui/components/badge.tsx +52 -0
- package/template/src/components/ui/components/bubble.tsx +128 -0
- package/template/src/components/ui/components/button-group.tsx +87 -0
- package/template/src/components/ui/components/button.tsx +56 -0
- package/template/src/components/ui/components/card.tsx +103 -0
- package/template/src/components/ui/components/checkbox.tsx +26 -0
- package/template/src/components/ui/components/command.tsx +194 -0
- package/template/src/components/ui/components/dialog.tsx +154 -0
- package/template/src/components/ui/components/drawer.tsx +229 -0
- package/template/src/components/ui/components/dropdown-menu.tsx +270 -0
- package/template/src/components/ui/components/empty.tsx +101 -0
- package/template/src/components/ui/components/field.tsx +236 -0
- package/template/src/components/ui/components/input-group.tsx +155 -0
- package/template/src/components/ui/components/input.tsx +20 -0
- package/template/src/components/ui/components/item.tsx +201 -0
- package/template/src/components/ui/components/kbd.tsx +26 -0
- package/template/src/components/ui/components/label.tsx +18 -0
- package/template/src/components/ui/components/marker.tsx +71 -0
- package/template/src/components/ui/components/message-scroller.tsx +128 -0
- package/template/src/components/ui/components/message.tsx +92 -0
- package/template/src/components/ui/components/native-select.tsx +65 -0
- package/template/src/components/ui/components/popover.tsx +88 -0
- package/template/src/components/ui/components/portal-primitives.test.tsx +85 -0
- package/template/src/components/ui/components/radio-group.tsx +36 -0
- package/template/src/components/ui/components/scroll-area.tsx +52 -0
- package/template/src/components/ui/components/secondary-form-actions.test.tsx +23 -0
- package/template/src/components/ui/components/select.tsx +199 -0
- package/template/src/components/ui/components/separator.tsx +23 -0
- package/template/src/components/ui/components/sheet.tsx +135 -0
- package/template/src/components/ui/components/skeleton.tsx +13 -0
- package/template/src/components/ui/components/sonner.tsx +43 -0
- package/template/src/components/ui/components/spinner.tsx +16 -0
- package/template/src/components/ui/components/tabs.tsx +80 -0
- package/template/src/components/ui/components/textarea.tsx +18 -0
- package/template/src/components/ui/components/tooltip.tsx +64 -0
- package/template/src/components/ui/hooks/.gitkeep +0 -0
- package/template/src/components/ui/lib/utils.ts +6 -0
- package/template/src/components/ui/styles/globals.css +151 -0
- package/template/src/create-post-overlay.tsx +82 -0
- package/template/src/draft-post-list.tsx +39 -0
- package/template/src/feedback-cache-invalidation.test.ts +185 -0
- package/template/src/feedback-cache-invalidation.ts +97 -0
- package/template/src/feedback-runtime.ts +38 -0
- package/template/src/feedbax/changelog/index.test.ts +156 -0
- package/template/src/feedbax/changelog/index.ts +206 -0
- package/template/src/feedbax/changelog/notion-changelog.test.ts +395 -0
- package/template/src/feedbax/changelog/notion-changelog.ts +400 -0
- package/template/src/feedbax/config/index.test.ts +111 -0
- package/template/src/feedbax/config/index.ts +271 -0
- package/template/src/feedbax/feedback/feedback-storage.ts +43 -0
- package/template/src/feedbax/feedback/index.test.ts +900 -0
- package/template/src/feedbax/feedback/index.ts +942 -0
- package/template/src/feedbax/feedback/notion-comments.test.ts +171 -0
- package/template/src/feedbax/feedback/notion-comments.ts +231 -0
- package/template/src/feedbax/feedback/notion-data-source.test.ts +364 -0
- package/template/src/feedbax/feedback/notion-data-source.ts +458 -0
- package/template/src/feedbax/feedback/notion-feedback.test.ts +998 -0
- package/template/src/feedbax/feedback/notion-feedback.ts +651 -0
- package/template/src/feedbax/feedback/notion-request.ts +74 -0
- package/template/src/feedbax.test-fixture.ts +64 -0
- package/template/src/feedbax.ts +69 -0
- package/template/src/index.css +2138 -0
- package/template/src/lazy-create-post-overlay.tsx +6 -0
- package/template/src/lazy-post-detail-overlay.tsx +10 -0
- package/template/src/masked-post-link.tsx +27 -0
- package/template/src/overlay-post-detail.test.tsx +130 -0
- package/template/src/overlay-post-detail.tsx +92 -0
- package/template/src/portal-comments.test.ts +375 -0
- package/template/src/portal-comments.ts +384 -0
- package/template/src/portal-features.test.tsx +150 -0
- package/template/src/portal-feedback-form.tsx +515 -0
- package/template/src/portal-feedback-runtime.test.ts +32 -0
- package/template/src/portal-feedback-runtime.ts +55 -0
- package/template/src/portal-feedback-server-function.ts +93 -0
- package/template/src/portal-feedback-submission.test.ts +307 -0
- package/template/src/portal-feedback-submission.ts +214 -0
- package/template/src/portal-head.ts +58 -0
- package/template/src/portal-server-entry.ts +64 -0
- package/template/src/portal-voting.test.ts +106 -0
- package/template/src/portal-voting.ts +149 -0
- package/template/src/post-badges.tsx +27 -0
- package/template/src/post-creation-flow.test.tsx +230 -0
- package/template/src/post-creation-flow.tsx +88 -0
- package/template/src/post-detail-overlay.test.tsx +114 -0
- package/template/src/post-detail-overlay.tsx +67 -0
- package/template/src/post-feed-controls.test.tsx +239 -0
- package/template/src/post-feed-controls.tsx +298 -0
- package/template/src/post-presentation.ts +37 -0
- package/template/src/post-queries.test.ts +158 -0
- package/template/src/post-queries.ts +26 -0
- package/template/src/post-results.tsx +165 -0
- package/template/src/public-cache-policy.test.ts +141 -0
- package/template/src/public-cache-policy.ts +119 -0
- package/template/src/public-cache-tags.ts +7 -0
- package/template/src/public-changelog-page.ts +15 -0
- package/template/src/public-changelog-server-function.ts +27 -0
- package/template/src/public-date.ts +11 -0
- package/template/src/public-failure-state.test.tsx +69 -0
- package/template/src/public-feedback-index.tsx +124 -0
- package/template/src/public-feedback-page.test.tsx +290 -0
- package/template/src/public-feedback-page.ts +75 -0
- package/template/src/public-feedback-server-function.ts +22 -0
- package/template/src/public-portal-error.tsx +27 -0
- package/template/src/public-post-detail.tsx +682 -0
- package/template/src/public-post-page.test.tsx +173 -0
- package/template/src/public-post-page.ts +15 -0
- package/template/src/public-post-route.test.tsx +214 -0
- package/template/src/public-post-route.tsx +171 -0
- package/template/src/public-post-server-function.ts +48 -0
- package/template/src/public-post-unavailable.tsx +10 -0
- package/template/src/public-roadmap-page.test.tsx +139 -0
- package/template/src/public-roadmap-page.ts +24 -0
- package/template/src/public-roadmap-route.test.tsx +218 -0
- package/template/src/public-roadmap-route.tsx +50 -0
- package/template/src/public-roadmap-server-function.ts +44 -0
- package/template/src/public-roadmap-view.tsx +378 -0
- package/template/src/public-shell.test.tsx +518 -0
- package/template/src/roadmap-query.ts +21 -0
- package/template/src/root-branding.test.ts +53 -0
- package/template/src/routeTree.gen.ts +131 -0
- package/template/src/routed-post-creation-form.tsx +28 -0
- package/template/src/router.tsx +25 -0
- package/template/src/routes/__root.tsx +116 -0
- package/template/src/routes/changelog.tsx +39 -0
- package/template/src/routes/index.tsx +61 -0
- package/template/src/routes/p.$slug.tsx +16 -0
- package/template/src/routes/roadmap.tsx +11 -0
- package/template/src/routes/submit.tsx +28 -0
- package/template/src/safe-public-failure.ts +45 -0
- package/template/src/server.ts +111 -0
- package/template/src/similar-posts.tsx +45 -0
- package/template/src/submit-route.test.tsx +134 -0
- package/template/src/trusted-feedback-handler.test.ts +425 -0
- package/template/src/trusted-feedback-handler.ts +181 -0
- package/template/src/trusted-feedback-runtime.ts +17 -0
- package/template/src/turnstile-configuration.test.ts +37 -0
- package/template/src/turnstile-configuration.ts +19 -0
- package/template/src/use-media-query.ts +17 -0
- package/template/src/vote-toggle.test.tsx +171 -0
- package/template/src/vote-toggle.tsx +211 -0
- package/template/tsconfig.json +45 -0
- package/template/vite.config.ts +20 -0
- package/template/vitest.config.ts +4 -0
- package/template/vitest.setup.ts +3 -0
- package/template/wrangler.jsonc +35 -0
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/index.d.ts +0 -21
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/template/.env.example +0 -4
- package/template/README.md +0 -7
- package/template/apps/portal/components.json +0 -20
- package/template/apps/portal/package.json +0 -31
- package/template/apps/portal/src/lib/utils.ts +0 -6
- package/template/apps/portal/src/routeTree.gen.ts +0 -59
- package/template/apps/portal/src/router.tsx +0 -10
- package/template/apps/portal/src/routes/__root.tsx +0 -31
- package/template/apps/portal/src/routes/index.tsx +0 -35
- package/template/apps/portal/src/styles.css +0 -91
- package/template/apps/portal/tsconfig.json +0 -15
- package/template/apps/portal/vite.config.ts +0 -13
- package/template/feedbax.config.ts +0 -13
- package/template/feedbax.jsonc +0 -11
- package/template/pnpm-workspace.yaml +0 -3
- package/template/turbo.json +0 -8
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { createFeedbackModule } from "@feedbax/feedback";
|
|
4
|
+
import {
|
|
5
|
+
cleanup,
|
|
6
|
+
fireEvent,
|
|
7
|
+
render,
|
|
8
|
+
screen,
|
|
9
|
+
waitFor,
|
|
10
|
+
within,
|
|
11
|
+
} from "@testing-library/react";
|
|
12
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
13
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
14
|
+
import { loadPublicPost, postPath } from "./public-post-page";
|
|
15
|
+
import {
|
|
16
|
+
PublicPostDetail,
|
|
17
|
+
PublicPostDetailSkeleton,
|
|
18
|
+
} from "./public-post-detail";
|
|
19
|
+
|
|
20
|
+
afterEach(cleanup);
|
|
21
|
+
|
|
22
|
+
describe("canonical Post page", () => {
|
|
23
|
+
it("uses only the immutable slug as public identity", async () => {
|
|
24
|
+
const feedback = createFeedbackModule({
|
|
25
|
+
initialItems: [
|
|
26
|
+
{
|
|
27
|
+
id: "secret-id",
|
|
28
|
+
slug: "clean-post",
|
|
29
|
+
title: "Clean Post",
|
|
30
|
+
description: "Body",
|
|
31
|
+
type: "Feature Request",
|
|
32
|
+
status: "New",
|
|
33
|
+
published: true,
|
|
34
|
+
createdAt: new Date("2026-01-01"),
|
|
35
|
+
updatedAt: new Date("2026-01-01"),
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
const post = await loadPublicPost({ feedback, slug: "clean-post" });
|
|
40
|
+
expect(postPath(post!)).toBe("/p/clean-post");
|
|
41
|
+
expect(post).not.toHaveProperty("id");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("gives missing and unpublished Posts the same safe unavailable state", async () => {
|
|
45
|
+
const feedback = createFeedbackModule({
|
|
46
|
+
initialItems: [
|
|
47
|
+
{
|
|
48
|
+
id: "private-id",
|
|
49
|
+
slug: "private-post",
|
|
50
|
+
title: "Private title",
|
|
51
|
+
description: "Private description",
|
|
52
|
+
type: "General Feedback",
|
|
53
|
+
status: "Reviewing",
|
|
54
|
+
published: false,
|
|
55
|
+
createdAt: new Date("2026-08-20T10:00:00.000Z"),
|
|
56
|
+
updatedAt: new Date("2026-08-20T12:00:00.000Z"),
|
|
57
|
+
submitter: { email: "private@example.com" },
|
|
58
|
+
pageBody: "Private Product Team notes",
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await expect(
|
|
64
|
+
loadPublicPost({ feedback, slug: "private-post" }),
|
|
65
|
+
).resolves.toBeUndefined();
|
|
66
|
+
await expect(
|
|
67
|
+
loadPublicPost({ feedback, slug: "missing-post" }),
|
|
68
|
+
).resolves.toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("renders a contextual Post detail skeleton", () => {
|
|
72
|
+
const html = renderToStaticMarkup(<PublicPostDetailSkeleton />);
|
|
73
|
+
|
|
74
|
+
expect(html).toContain('aria-label="Loading Post details"');
|
|
75
|
+
expect(html).toContain('aria-busy="true"');
|
|
76
|
+
expect(html).toContain("Loading Post details…");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("shows the Post summary with a Vote toggle", () => {
|
|
80
|
+
render(<PublicPostDetail post={publicPost} />);
|
|
81
|
+
|
|
82
|
+
expect(
|
|
83
|
+
screen.getByRole("heading", { name: "Keyboard-first search" }),
|
|
84
|
+
).toBeTruthy();
|
|
85
|
+
expect(screen.getAllByText("Feature Request").length).toBeGreaterThan(0);
|
|
86
|
+
expect(screen.getAllByText("Planned").length).toBeGreaterThan(0);
|
|
87
|
+
expect(
|
|
88
|
+
screen.getByRole("button", { name: "Add Vote, 0 Votes" }),
|
|
89
|
+
).toBeTruthy();
|
|
90
|
+
expect(screen.queryByLabelText("Comments unavailable")).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("persists voted state only after confirmation and rolls back a failed optimistic Vote", async () => {
|
|
94
|
+
localStorage.clear();
|
|
95
|
+
const fetchMock = vi
|
|
96
|
+
.spyOn(globalThis, "fetch")
|
|
97
|
+
.mockResolvedValueOnce(Response.json({ voteCount: 1 }))
|
|
98
|
+
.mockResolvedValueOnce(
|
|
99
|
+
Response.json(
|
|
100
|
+
{
|
|
101
|
+
error: "Vote verification is required.",
|
|
102
|
+
code: "verification_required",
|
|
103
|
+
},
|
|
104
|
+
{ status: 400 },
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
render(<PublicPostDetail post={publicPost} />);
|
|
108
|
+
const button = screen.getByRole("button", { name: "Add Vote, 0 Votes" });
|
|
109
|
+
|
|
110
|
+
fireEvent.click(button);
|
|
111
|
+
expect(button.getAttribute("aria-pressed")).toBe("true");
|
|
112
|
+
await waitFor(() => expect(button.textContent).toContain("1"));
|
|
113
|
+
expect(localStorage.getItem("feedbax:voted-posts")).toContain(
|
|
114
|
+
publicPost.slug,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
sessionStorage.setItem("feedbax:participation-pass", "expired-pass");
|
|
118
|
+
fireEvent.click(button);
|
|
119
|
+
expect(button.getAttribute("aria-pressed")).toBe("false");
|
|
120
|
+
await waitFor(() =>
|
|
121
|
+
expect(screen.getByRole("alert").textContent).toContain(
|
|
122
|
+
"verification is required",
|
|
123
|
+
),
|
|
124
|
+
);
|
|
125
|
+
expect(button.getAttribute("aria-pressed")).toBe("true");
|
|
126
|
+
expect(button.textContent).toContain("1");
|
|
127
|
+
expect(localStorage.getItem("feedbax:voted-posts")).toContain(
|
|
128
|
+
publicPost.slug,
|
|
129
|
+
);
|
|
130
|
+
expect(sessionStorage.getItem("feedbax:participation-pass")).toBeNull();
|
|
131
|
+
fetchMock.mockRestore();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("shows only public metadata in the Details sidebar", () => {
|
|
135
|
+
render(<PublicPostDetail post={publicPost} />);
|
|
136
|
+
|
|
137
|
+
const details = within(
|
|
138
|
+
screen.getByRole("complementary", { name: "Details" }),
|
|
139
|
+
);
|
|
140
|
+
expect(details.getByText("Status")).toBeTruthy();
|
|
141
|
+
expect(details.getByText("Planned")).toBeTruthy();
|
|
142
|
+
expect(details.getByText("Post type")).toBeTruthy();
|
|
143
|
+
expect(details.getByText("Feature Request")).toBeTruthy();
|
|
144
|
+
expect(details.getByText("Submitted")).toBeTruthy();
|
|
145
|
+
expect(details.getByText("August 20, 2026")).toBeTruthy();
|
|
146
|
+
expect(details.getByText("Updated")).toBeTruthy();
|
|
147
|
+
expect(details.getByText("August 22, 2026")).toBeTruthy();
|
|
148
|
+
expect(details.queryByText("Author")).toBeNull();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("renders an honest empty Comments state without unavailable controls", () => {
|
|
152
|
+
render(<PublicPostDetail post={publicPost} />);
|
|
153
|
+
|
|
154
|
+
const comments = within(screen.getByRole("region", { name: "Comments" }));
|
|
155
|
+
expect(comments.getByText("No comments yet")).toBeTruthy();
|
|
156
|
+
expect(comments.queryByText(/isn’t available/)).toBeNull();
|
|
157
|
+
expect(screen.queryByRole("textbox")).toBeNull();
|
|
158
|
+
expect(
|
|
159
|
+
screen.queryByRole("button", { name: /comment|follow/i }),
|
|
160
|
+
).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const publicPost = {
|
|
165
|
+
slug: "keyboard-first-search",
|
|
166
|
+
title: "Keyboard-first search",
|
|
167
|
+
description: "Open search without reaching for the mouse.",
|
|
168
|
+
type: "Feature Request" as const,
|
|
169
|
+
status: "Planned" as const,
|
|
170
|
+
voteCount: 0,
|
|
171
|
+
createdAt: new Date("2026-08-20T10:00:00.000Z"),
|
|
172
|
+
updatedAt: new Date("2026-08-22T10:00:00.000Z"),
|
|
173
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FeedbackModule, PublicPost } from "@feedbax/feedback";
|
|
2
|
+
|
|
3
|
+
export async function loadPublicPost({
|
|
4
|
+
feedback,
|
|
5
|
+
slug,
|
|
6
|
+
}: {
|
|
7
|
+
feedback: Pick<FeedbackModule, "getPublicPost">;
|
|
8
|
+
slug: string;
|
|
9
|
+
}): Promise<PublicPost | undefined> {
|
|
10
|
+
return feedback.getPublicPost(slug);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function postPath(post: Pick<PublicPost, "slug">): string {
|
|
14
|
+
return `/p/${encodeURIComponent(post.slug)}`;
|
|
15
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createFeedbackModule,
|
|
5
|
+
type CommentThreadPage,
|
|
6
|
+
} from "@feedbax/feedback";
|
|
7
|
+
import {
|
|
8
|
+
cleanup,
|
|
9
|
+
fireEvent,
|
|
10
|
+
render,
|
|
11
|
+
screen,
|
|
12
|
+
within,
|
|
13
|
+
} from "@testing-library/react";
|
|
14
|
+
import {
|
|
15
|
+
createMemoryHistory,
|
|
16
|
+
createRootRoute,
|
|
17
|
+
createRouter,
|
|
18
|
+
Outlet,
|
|
19
|
+
RouterProvider,
|
|
20
|
+
} from "@tanstack/react-router";
|
|
21
|
+
import {
|
|
22
|
+
afterAll,
|
|
23
|
+
afterEach,
|
|
24
|
+
beforeAll,
|
|
25
|
+
describe,
|
|
26
|
+
expect,
|
|
27
|
+
it,
|
|
28
|
+
vi,
|
|
29
|
+
} from "vitest";
|
|
30
|
+
|
|
31
|
+
import { loadPublicPost } from "./public-post-page";
|
|
32
|
+
import { createPublicPostRoute } from "./public-post-route";
|
|
33
|
+
import { PublicPostUnavailable } from "./public-post-unavailable";
|
|
34
|
+
|
|
35
|
+
beforeAll(() => {
|
|
36
|
+
vi.spyOn(window, "scrollTo").mockImplementation(() => undefined);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(() => {
|
|
40
|
+
vi.restoreAllMocks();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(cleanup);
|
|
44
|
+
|
|
45
|
+
describe("/p/$slug", () => {
|
|
46
|
+
it("retrieves and renders a complete Post from its clean canonical URL", async () => {
|
|
47
|
+
const feedback = createFeedbackModule({
|
|
48
|
+
initialItems: [publishedPost],
|
|
49
|
+
initialComments: [
|
|
50
|
+
{
|
|
51
|
+
id: "comment-1",
|
|
52
|
+
postId: "secret-id",
|
|
53
|
+
discussionId: "discussion-1",
|
|
54
|
+
body: "Please add Vim bindings.",
|
|
55
|
+
author: { kind: "participant", displayName: "Ari" },
|
|
56
|
+
createdAt: new Date("2026-08-20T13:00:00.000Z"),
|
|
57
|
+
resolved: false,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
const history = createMemoryHistory({
|
|
62
|
+
initialEntries: ["/p/keyboard-first-search"],
|
|
63
|
+
});
|
|
64
|
+
const router = createPostRouter({ feedback, history });
|
|
65
|
+
|
|
66
|
+
render(<RouterProvider router={router} />);
|
|
67
|
+
|
|
68
|
+
expect(
|
|
69
|
+
await screen.findByRole("heading", { name: "Keyboard-first search" }),
|
|
70
|
+
).toBeTruthy();
|
|
71
|
+
const details = within(
|
|
72
|
+
screen.getByRole("complementary", { name: "Details" }),
|
|
73
|
+
);
|
|
74
|
+
expect(details.getByText("Feature Request")).toBeTruthy();
|
|
75
|
+
expect(details.getByText("Planned")).toBeTruthy();
|
|
76
|
+
expect(details.getByText("Submitted")).toBeTruthy();
|
|
77
|
+
expect(details.getByText("Updated")).toBeTruthy();
|
|
78
|
+
expect(history.location.pathname).toBe("/p/keyboard-first-search");
|
|
79
|
+
expect(screen.getByText("Please add Vim bindings.")).toBeTruthy();
|
|
80
|
+
expect(screen.getByText("Ari")).toBeTruthy();
|
|
81
|
+
expect(document.body.textContent).not.toContain("secret-id");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it.each(["missing-post", "unpublished-post"])(
|
|
85
|
+
"renders the same safe unavailable state for %s",
|
|
86
|
+
async (slug) => {
|
|
87
|
+
const feedback = createFeedbackModule({
|
|
88
|
+
initialItems: [unpublishedPost],
|
|
89
|
+
});
|
|
90
|
+
const history = createMemoryHistory({
|
|
91
|
+
initialEntries: [`/p/${slug}`],
|
|
92
|
+
});
|
|
93
|
+
const router = createPostRouter({ feedback, history });
|
|
94
|
+
|
|
95
|
+
render(<RouterProvider router={router} />);
|
|
96
|
+
|
|
97
|
+
expect(
|
|
98
|
+
await screen.findByRole("heading", {
|
|
99
|
+
name: "This Post isn’t available.",
|
|
100
|
+
}),
|
|
101
|
+
).toBeTruthy();
|
|
102
|
+
expect(document.body.textContent).toContain(
|
|
103
|
+
"It may not exist, or it may not be published yet.",
|
|
104
|
+
);
|
|
105
|
+
expect(document.body.textContent).not.toContain("Private title");
|
|
106
|
+
expect(document.body.textContent).not.toContain("private@example.com");
|
|
107
|
+
expect(document.body.textContent).not.toContain(
|
|
108
|
+
"Private Product Team notes",
|
|
109
|
+
);
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
it("loads and merges the next stable Comment page only after Load more", async () => {
|
|
114
|
+
const feedback = createFeedbackModule({ initialItems: [publishedPost] });
|
|
115
|
+
const loadComments = vi
|
|
116
|
+
.fn()
|
|
117
|
+
.mockResolvedValueOnce({
|
|
118
|
+
items: [
|
|
119
|
+
{
|
|
120
|
+
id: "discussion-1",
|
|
121
|
+
comments: [
|
|
122
|
+
{
|
|
123
|
+
id: "comment-1",
|
|
124
|
+
body: "First page",
|
|
125
|
+
author: { kind: "participant", displayName: "Ari" },
|
|
126
|
+
createdAt: new Date("2026-08-20T13:00:00.000Z"),
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
nextCursor: "native-cursor",
|
|
132
|
+
})
|
|
133
|
+
.mockResolvedValueOnce({
|
|
134
|
+
items: [
|
|
135
|
+
{
|
|
136
|
+
id: "discussion-1",
|
|
137
|
+
comments: [
|
|
138
|
+
{
|
|
139
|
+
id: "comment-2",
|
|
140
|
+
body: "Second page",
|
|
141
|
+
author: { kind: "product-team", displayName: "Product Team" },
|
|
142
|
+
createdAt: new Date("2026-08-20T14:00:00.000Z"),
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
});
|
|
148
|
+
const history = createMemoryHistory({
|
|
149
|
+
initialEntries: ["/p/keyboard-first-search"],
|
|
150
|
+
});
|
|
151
|
+
const router = createPostRouter({ feedback, history, loadComments });
|
|
152
|
+
render(<RouterProvider router={router} />);
|
|
153
|
+
|
|
154
|
+
expect(await screen.findByText("First page")).toBeTruthy();
|
|
155
|
+
expect(loadComments).toHaveBeenCalledTimes(1);
|
|
156
|
+
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
|
157
|
+
expect(await screen.findByText("Second page")).toBeTruthy();
|
|
158
|
+
expect(screen.getByText("First page")).toBeTruthy();
|
|
159
|
+
expect(loadComments).toHaveBeenLastCalledWith(
|
|
160
|
+
"keyboard-first-search",
|
|
161
|
+
"native-cursor",
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
function createPostRouter({
|
|
167
|
+
feedback,
|
|
168
|
+
history,
|
|
169
|
+
loadComments,
|
|
170
|
+
}: {
|
|
171
|
+
feedback: ReturnType<typeof createFeedbackModule>;
|
|
172
|
+
history: ReturnType<typeof createMemoryHistory>;
|
|
173
|
+
loadComments?: (slug: string, cursor?: string) => Promise<CommentThreadPage>;
|
|
174
|
+
}) {
|
|
175
|
+
const rootRoute = createRootRoute({ component: () => <Outlet /> });
|
|
176
|
+
const postRoute = createPublicPostRoute({
|
|
177
|
+
loadPost: (slug) => loadPublicPost({ feedback, slug }),
|
|
178
|
+
loadComments:
|
|
179
|
+
loadComments ??
|
|
180
|
+
((slug, cursor) => feedback.listCommentThreads({ slug, cursor })),
|
|
181
|
+
renderUnavailable: () => <PublicPostUnavailable />,
|
|
182
|
+
}).update({
|
|
183
|
+
id: "/p/$slug",
|
|
184
|
+
path: "/p/$slug",
|
|
185
|
+
getParentRoute: () => rootRoute,
|
|
186
|
+
} as never);
|
|
187
|
+
return createRouter({
|
|
188
|
+
history,
|
|
189
|
+
routeTree: rootRoute.addChildren([postRoute]),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const publishedPost = {
|
|
194
|
+
id: "secret-id",
|
|
195
|
+
slug: "keyboard-first-search",
|
|
196
|
+
title: "Keyboard-first search",
|
|
197
|
+
description: "Open search without reaching for the mouse.",
|
|
198
|
+
type: "Feature Request" as const,
|
|
199
|
+
status: "Planned" as const,
|
|
200
|
+
published: true,
|
|
201
|
+
createdAt: new Date("2026-08-20T10:00:00.000Z"),
|
|
202
|
+
updatedAt: new Date("2026-08-20T12:00:00.000Z"),
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const unpublishedPost = {
|
|
206
|
+
...publishedPost,
|
|
207
|
+
id: "private-id",
|
|
208
|
+
slug: "unpublished-post",
|
|
209
|
+
title: "Private title",
|
|
210
|
+
description: "Private description",
|
|
211
|
+
published: false,
|
|
212
|
+
submitter: { email: "private@example.com" },
|
|
213
|
+
pageBody: "Private Product Team notes",
|
|
214
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { CommentThreadPage, PublicPost } from "@feedbax/feedback";
|
|
2
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
3
|
+
import { type ReactNode, useState } from "react";
|
|
4
|
+
import type { PortalCommentRequest } from "./portal-comments";
|
|
5
|
+
import { mergeCommentThreads } from "./comment-thread-page";
|
|
6
|
+
import feedbax from "./feedbax";
|
|
7
|
+
import { createRouteHead } from "./portal-head";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
PublicPostDetail,
|
|
11
|
+
PublicPostDetailSkeleton,
|
|
12
|
+
} from "./public-post-detail";
|
|
13
|
+
|
|
14
|
+
export function createPublicPostRoute({
|
|
15
|
+
loadPost,
|
|
16
|
+
loadComments,
|
|
17
|
+
renderUnavailable,
|
|
18
|
+
}: {
|
|
19
|
+
loadPost: (slug: string) => Promise<PublicPost | undefined>;
|
|
20
|
+
loadComments?: (slug: string, cursor?: string) => Promise<CommentThreadPage>;
|
|
21
|
+
renderUnavailable: (slug: string) => ReactNode;
|
|
22
|
+
}) {
|
|
23
|
+
const route = createFileRoute("/p/$slug")({
|
|
24
|
+
loader: async ({ params }) => {
|
|
25
|
+
const post = await loadPost(params.slug);
|
|
26
|
+
const comments =
|
|
27
|
+
post && loadComments ? await loadComments(params.slug) : { items: [] };
|
|
28
|
+
return { post, comments };
|
|
29
|
+
},
|
|
30
|
+
head: ({ loaderData, params }) =>
|
|
31
|
+
loaderData?.post
|
|
32
|
+
? createRouteHead(feedbax.product, {
|
|
33
|
+
title: loaderData.post.title,
|
|
34
|
+
description: loaderData.post.description,
|
|
35
|
+
path: `/p/${encodeURIComponent(loaderData.post.slug)}`,
|
|
36
|
+
})
|
|
37
|
+
: createRouteHead(feedbax.product, {
|
|
38
|
+
title: "Post unavailable",
|
|
39
|
+
description: feedbax.product.description,
|
|
40
|
+
path: `/p/${encodeURIComponent(params.slug)}`,
|
|
41
|
+
noIndex: true,
|
|
42
|
+
}),
|
|
43
|
+
component: PostRoute,
|
|
44
|
+
pendingComponent: PublicPostDetailSkeleton,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function PostRoute() {
|
|
48
|
+
const data = route.useLoaderData();
|
|
49
|
+
const { slug } = route.useParams();
|
|
50
|
+
const [comments, setComments] = useState(data.comments);
|
|
51
|
+
return data.post ? (
|
|
52
|
+
<PublicPostDetail
|
|
53
|
+
post={data.post}
|
|
54
|
+
comments={comments}
|
|
55
|
+
submitComment={submitPublicComment}
|
|
56
|
+
mutateComment={mutatePublicComment}
|
|
57
|
+
loadMore={
|
|
58
|
+
loadComments && comments.nextCursor
|
|
59
|
+
? async () => {
|
|
60
|
+
const next = await loadComments(slug, comments.nextCursor);
|
|
61
|
+
setComments({
|
|
62
|
+
items: mergeCommentThreads(comments.items, next.items),
|
|
63
|
+
...(next.nextCursor ? { nextCursor: next.nextCursor } : {}),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
: undefined
|
|
67
|
+
}
|
|
68
|
+
/>
|
|
69
|
+
) : (
|
|
70
|
+
renderUnavailable(slug)
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return route;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function submitPublicComment(input: PortalCommentRequest) {
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
const timeout = setTimeout(() => controller.abort(), 10_000);
|
|
80
|
+
let response: Response;
|
|
81
|
+
try {
|
|
82
|
+
response = await fetch("/internal/comments", {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { "Content-Type": "application/json" },
|
|
85
|
+
signal: controller.signal,
|
|
86
|
+
body: JSON.stringify(input),
|
|
87
|
+
});
|
|
88
|
+
} catch (error) {
|
|
89
|
+
throw error instanceof DOMException && error.name === "AbortError"
|
|
90
|
+
? new Error("Comment timed out. Check your connection and try again.")
|
|
91
|
+
: error;
|
|
92
|
+
} finally {
|
|
93
|
+
clearTimeout(timeout);
|
|
94
|
+
}
|
|
95
|
+
const result: unknown = await response.json();
|
|
96
|
+
if (!response.ok) {
|
|
97
|
+
const code =
|
|
98
|
+
result && typeof result === "object"
|
|
99
|
+
? Reflect.get(result, "code")
|
|
100
|
+
: undefined;
|
|
101
|
+
if (code === "verification_required" || code === "verification_failed")
|
|
102
|
+
sessionStorage.removeItem("feedbax:participation-pass");
|
|
103
|
+
const message =
|
|
104
|
+
result &&
|
|
105
|
+
typeof result === "object" &&
|
|
106
|
+
typeof Reflect.get(result, "error") === "string"
|
|
107
|
+
? Reflect.get(result, "error")
|
|
108
|
+
: "Comment could not be saved. Try again.";
|
|
109
|
+
throw new Error(message as string);
|
|
110
|
+
}
|
|
111
|
+
if (!result || typeof result !== "object" || !Reflect.get(result, "comment"))
|
|
112
|
+
throw new Error("Comment could not be confirmed. Try again.");
|
|
113
|
+
const participationPass = Reflect.get(result, "participationPass");
|
|
114
|
+
if (typeof participationPass === "string")
|
|
115
|
+
sessionStorage.setItem("feedbax:participation-pass", participationPass);
|
|
116
|
+
const comment = Reflect.get(result, "comment");
|
|
117
|
+
if (!comment || typeof comment !== "object")
|
|
118
|
+
throw new Error("Comment could not be confirmed. Try again.");
|
|
119
|
+
const createdAt = new Date(String(Reflect.get(comment, "createdAt")));
|
|
120
|
+
if (Number.isNaN(createdAt.getTime()))
|
|
121
|
+
throw new Error("Comment could not be confirmed. Try again.");
|
|
122
|
+
const commentCapability = Reflect.get(result, "commentCapability");
|
|
123
|
+
const commentCapabilityExpiresAt = Reflect.get(
|
|
124
|
+
result,
|
|
125
|
+
"commentCapabilityExpiresAt",
|
|
126
|
+
);
|
|
127
|
+
return {
|
|
128
|
+
...comment,
|
|
129
|
+
createdAt,
|
|
130
|
+
...(typeof commentCapability === "string" &&
|
|
131
|
+
typeof commentCapabilityExpiresAt === "number"
|
|
132
|
+
? { commentCapability, commentCapabilityExpiresAt }
|
|
133
|
+
: {}),
|
|
134
|
+
} as import("./public-post-detail").ConfirmedPortalComment;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function mutatePublicComment(input: {
|
|
138
|
+
action: "edit" | "delete";
|
|
139
|
+
commentId: string;
|
|
140
|
+
commentCapability: string;
|
|
141
|
+
body?: string;
|
|
142
|
+
}) {
|
|
143
|
+
const participationPass = sessionStorage.getItem(
|
|
144
|
+
"feedbax:participation-pass",
|
|
145
|
+
);
|
|
146
|
+
const response = await fetch("/internal/comments", {
|
|
147
|
+
method: input.action === "edit" ? "PATCH" : "DELETE",
|
|
148
|
+
headers: { "Content-Type": "application/json" },
|
|
149
|
+
body: JSON.stringify({
|
|
150
|
+
...input,
|
|
151
|
+
...(participationPass ? { participationPass } : {}),
|
|
152
|
+
}),
|
|
153
|
+
});
|
|
154
|
+
const result: unknown = await response.json();
|
|
155
|
+
if (!response.ok) {
|
|
156
|
+
const code =
|
|
157
|
+
result && typeof result === "object"
|
|
158
|
+
? Reflect.get(result, "code")
|
|
159
|
+
: undefined;
|
|
160
|
+
if (code === "verification_required")
|
|
161
|
+
sessionStorage.removeItem("feedbax:participation-pass");
|
|
162
|
+
const message =
|
|
163
|
+
result &&
|
|
164
|
+
typeof result === "object" &&
|
|
165
|
+
typeof Reflect.get(result, "error") === "string"
|
|
166
|
+
? Reflect.get(result, "error")
|
|
167
|
+
: "Comment could not be changed. Try again.";
|
|
168
|
+
throw new Error(message as string);
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
2
|
+
|
|
3
|
+
import { createConfiguredFeedbackModule } from "./feedback-runtime";
|
|
4
|
+
import { loadPublicPost } from "./public-post-page";
|
|
5
|
+
import { runSafePublicRead } from "./safe-public-failure";
|
|
6
|
+
|
|
7
|
+
export const getPublicPost = createServerFn({ method: "GET" })
|
|
8
|
+
.validator((input: unknown) => {
|
|
9
|
+
const slug =
|
|
10
|
+
input && typeof input === "object"
|
|
11
|
+
? Reflect.get(input, "slug")
|
|
12
|
+
: undefined;
|
|
13
|
+
if (typeof slug !== "string" || !slug)
|
|
14
|
+
throw new Error("Post route is invalid.");
|
|
15
|
+
return { slug };
|
|
16
|
+
})
|
|
17
|
+
.handler(({ data }) =>
|
|
18
|
+
runSafePublicRead(() =>
|
|
19
|
+
loadPublicPost({
|
|
20
|
+
feedback: createConfiguredFeedbackModule(),
|
|
21
|
+
slug: data.slug,
|
|
22
|
+
}),
|
|
23
|
+
),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
export const getPublicPostComments = createServerFn({ method: "GET" })
|
|
27
|
+
.validator((input: unknown) => {
|
|
28
|
+
const slug =
|
|
29
|
+
input && typeof input === "object"
|
|
30
|
+
? Reflect.get(input, "slug")
|
|
31
|
+
: undefined;
|
|
32
|
+
const cursor =
|
|
33
|
+
input && typeof input === "object"
|
|
34
|
+
? Reflect.get(input, "cursor")
|
|
35
|
+
: undefined;
|
|
36
|
+
if (
|
|
37
|
+
typeof slug !== "string" ||
|
|
38
|
+
!slug ||
|
|
39
|
+
(cursor !== undefined && typeof cursor !== "string")
|
|
40
|
+
)
|
|
41
|
+
throw new Error("Comment request is invalid.");
|
|
42
|
+
return { slug, ...(cursor ? { cursor } : {}) };
|
|
43
|
+
})
|
|
44
|
+
.handler(({ data }) =>
|
|
45
|
+
runSafePublicRead(() =>
|
|
46
|
+
createConfiguredFeedbackModule().listCommentThreads(data),
|
|
47
|
+
),
|
|
48
|
+
);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function PublicPostUnavailable() {
|
|
2
|
+
return (
|
|
3
|
+
<main className="feedback-detail feedback-detail-missing">
|
|
4
|
+
<p className="feedback-eyebrow">Post unavailable</p>
|
|
5
|
+
<h1>This Post isn’t available.</h1>
|
|
6
|
+
<p>It may not exist, or it may not be published yet.</p>
|
|
7
|
+
<a href="/">Browse Posts</a>
|
|
8
|
+
</main>
|
|
9
|
+
);
|
|
10
|
+
}
|