create-feedbax 0.1.0 → 0.2.1
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 +3283 -22
- package/dist/index.js +3394 -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/AGENTS.md +13 -0
- package/template/CONNECTED.md +404 -0
- package/template/LICENSE +201 -0
- package/template/README.md +56 -5
- package/template/components.json +25 -0
- package/template/eslint.config.mjs +48 -0
- package/template/migrations/0001_connected.sql +6 -0
- package/template/migrations/0002_participation.sql +8 -0
- package/template/migrations/0003_images.sql +3 -0
- package/template/migrations/0004_followed_status.sql +9 -0
- package/template/package.json +74 -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 +205 -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 +228 -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 +24 -0
- package/template/src/comment-creation.test.tsx +311 -0
- package/template/src/comment-profile.ts +5 -0
- package/template/src/comment-thread-page.ts +28 -0
- package/template/src/components/command-palette-context.tsx +71 -0
- package/template/src/components/command-palette.tsx +275 -0
- package/template/src/components/connected-identity-provider.tsx +108 -0
- package/template/src/components/create-post-trigger.tsx +44 -0
- package/template/src/components/device-profile-provider.tsx +324 -0
- package/template/src/components/follow-post-button.tsx +89 -0
- package/template/src/components/header.tsx +97 -0
- package/template/src/components/image-attachments.tsx +187 -0
- package/template/src/components/loader.tsx +53 -0
- package/template/src/components/notification-inbox.tsx +232 -0
- package/template/src/components/portal-footer.tsx +14 -0
- package/template/src/components/profile-menu.tsx +119 -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 +195 -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 +51 -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/switch.tsx +29 -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/connected-identity.test.ts +144 -0
- package/template/src/connected-identity.ts +122 -0
- package/template/src/connected-participation.test.tsx +67 -0
- package/template/src/connected-runtime.test.ts +241 -0
- package/template/src/connected-runtime.ts +176 -0
- package/template/src/connected-social.test.tsx +158 -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 +98 -0
- package/template/src/feedback-runtime.ts +105 -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 +139 -0
- package/template/src/feedbax/config/index.ts +364 -0
- package/template/src/feedbax/feedback/connected-feedback.ts +206 -0
- package/template/src/feedbax/feedback/connected.test.ts +272 -0
- package/template/src/feedbax/feedback/connected.ts +215 -0
- package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
- package/template/src/feedbax/feedback/email-delivery.ts +66 -0
- package/template/src/feedbax/feedback/feedback-storage.ts +43 -0
- package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
- package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
- package/template/src/feedbax/feedback/image-feedback.ts +101 -0
- package/template/src/feedbax/feedback/images.test.ts +223 -0
- package/template/src/feedbax/feedback/images.ts +324 -0
- package/template/src/feedbax/feedback/index.test.ts +900 -0
- package/template/src/feedbax/feedback/index.ts +996 -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-image-review.ts +55 -0
- package/template/src/feedbax/feedback/notion-request.ts +74 -0
- package/template/src/feedbax/feedback/participation.test.ts +653 -0
- package/template/src/feedbax/feedback/participation.ts +502 -0
- package/template/src/feedbax.test-fixture.ts +64 -0
- package/template/src/feedbax.ts +69 -0
- package/template/src/images-runtime.test.ts +133 -0
- package/template/src/images-runtime.ts +125 -0
- package/template/src/index.css +1795 -0
- package/template/src/lazy-create-post-overlay.tsx +6 -0
- package/template/src/lazy-post-detail-overlay.tsx +21 -0
- package/template/src/masked-post-link.tsx +27 -0
- package/template/src/overlay-post-detail.test.tsx +181 -0
- package/template/src/overlay-post-detail.tsx +43 -0
- package/template/src/participation-client.ts +63 -0
- package/template/src/participation-runtime.test.ts +35 -0
- package/template/src/participation-runtime.ts +255 -0
- package/template/src/portal-comments.test.ts +375 -0
- package/template/src/portal-comments.ts +403 -0
- package/template/src/portal-features.test.tsx +150 -0
- package/template/src/portal-feedback-form.tsx +530 -0
- package/template/src/portal-feedback-runtime.test.ts +32 -0
- package/template/src/portal-feedback-runtime.ts +58 -0
- package/template/src/portal-feedback-server-function.ts +96 -0
- package/template/src/portal-feedback-submission.test.ts +307 -0
- package/template/src/portal-feedback-submission.ts +224 -0
- package/template/src/portal-head.ts +58 -0
- package/template/src/portal-server-entry.ts +68 -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-content.test.tsx +233 -0
- package/template/src/post-detail-content.tsx +162 -0
- package/template/src/post-detail-overlay.test.tsx +114 -0
- package/template/src/post-detail-overlay.tsx +67 -0
- package/template/src/post-detail-query.ts +34 -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 +148 -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 +94 -0
- package/template/src/public-feedback-page.test.tsx +355 -0
- package/template/src/public-feedback-page.ts +86 -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-skeleton.tsx +54 -0
- package/template/src/public-post-detail.tsx +772 -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 +250 -0
- package/template/src/public-post-route.tsx +158 -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 +254 -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 +411 -0
- package/template/src/public-shell.test.tsx +544 -0
- package/template/src/roadmap-query.ts +21 -0
- package/template/src/root-branding.test.ts +53 -0
- package/template/src/routeTree.gen.ts +177 -0
- package/template/src/routed-post-creation-form.tsx +28 -0
- package/template/src/router.tsx +27 -0
- package/template/src/routes/__root.tsx +114 -0
- package/template/src/routes/changelog.tsx +39 -0
- package/template/src/routes/email-preferences.tsx +77 -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 +47 -0
- package/template/src/server.ts +151 -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 +244 -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/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,69 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { PublicPortalError } from "./public-portal-error";
|
|
5
|
+
import {
|
|
6
|
+
ActionablePortalFailure,
|
|
7
|
+
runSafePortalMutation,
|
|
8
|
+
runSafePublicRead,
|
|
9
|
+
} from "./safe-public-failure";
|
|
10
|
+
|
|
11
|
+
describe("public portal failure state", () => {
|
|
12
|
+
it("replaces private upstream diagnostics at the public read boundary", async () => {
|
|
13
|
+
const privateFailure = new Error(
|
|
14
|
+
"Notion token secret-token and property private-field",
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const failure = await runSafePublicRead(async () =>
|
|
18
|
+
Promise.reject(privateFailure),
|
|
19
|
+
).then(
|
|
20
|
+
() => undefined,
|
|
21
|
+
(error: unknown) => error,
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
expect(failure).toBeInstanceOf(Error);
|
|
25
|
+
expect((failure as Error).message).toBe(
|
|
26
|
+
"Public feedback is temporarily unavailable.",
|
|
27
|
+
);
|
|
28
|
+
expect((failure as Error).message).not.toContain("secret-token");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("renders an explicit retry state without serializing private failure details", () => {
|
|
32
|
+
const privateFailure = new Error(
|
|
33
|
+
"Notion token secret-token, property private-field, Browser Capability private-capability",
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const html = renderToStaticMarkup(
|
|
37
|
+
<PublicPortalError error={privateFailure} reset={() => undefined} />,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(html).toContain("Posts are temporarily unavailable");
|
|
41
|
+
expect(html).toContain("Try again");
|
|
42
|
+
expect(html).not.toContain("secret-token");
|
|
43
|
+
expect(html).not.toContain("private-field");
|
|
44
|
+
expect(html).not.toContain("private-capability");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("preserves actionable portal failures while replacing private mutation diagnostics", async () => {
|
|
48
|
+
await expect(
|
|
49
|
+
runSafePortalMutation(async () => {
|
|
50
|
+
throw new ActionablePortalFailure(
|
|
51
|
+
"Feedback submission rate limit exceeded.",
|
|
52
|
+
);
|
|
53
|
+
}),
|
|
54
|
+
).rejects.toThrow("Feedback submission rate limit exceeded.");
|
|
55
|
+
|
|
56
|
+
const failure = await runSafePortalMutation(async () => {
|
|
57
|
+
throw new Error("Notion private diagnostic secret-token");
|
|
58
|
+
}).then(
|
|
59
|
+
() => undefined,
|
|
60
|
+
(error: unknown) => error,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
expect(failure).toBeInstanceOf(Error);
|
|
64
|
+
expect((failure as Error).message).toBe(
|
|
65
|
+
"Public feedback mutation is temporarily unavailable.",
|
|
66
|
+
);
|
|
67
|
+
expect((failure as Error).message).not.toContain("secret-token");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { PublicPostQuery, PublicPostPage } from "@feedbax/feedback";
|
|
2
|
+
import type { PortalFeatures } from "@feedbax/config";
|
|
3
|
+
import Loader from "./components/loader";
|
|
4
|
+
import { useAuthorizedDraftPosts } from "./draft-post-list";
|
|
5
|
+
import {
|
|
6
|
+
BoardNavigation,
|
|
7
|
+
PostFeedControls,
|
|
8
|
+
StatusNavigation,
|
|
9
|
+
} from "./post-feed-controls";
|
|
10
|
+
import { PostResults } from "./post-results";
|
|
11
|
+
import feedbax from "./feedbax";
|
|
12
|
+
|
|
13
|
+
export function PublicPostIndex({
|
|
14
|
+
page,
|
|
15
|
+
search,
|
|
16
|
+
loadMore,
|
|
17
|
+
loadingMore,
|
|
18
|
+
loadMoreError,
|
|
19
|
+
onSearchChange,
|
|
20
|
+
pending = false,
|
|
21
|
+
maskPostLinks = false,
|
|
22
|
+
features = feedbax.features,
|
|
23
|
+
}: {
|
|
24
|
+
page: PublicPostPage;
|
|
25
|
+
search: PublicPostQuery;
|
|
26
|
+
loadMore?: () => void;
|
|
27
|
+
loadingMore?: boolean;
|
|
28
|
+
loadMoreError?: boolean;
|
|
29
|
+
onSearchChange?: (search: PublicPostQuery) => void;
|
|
30
|
+
pending?: boolean;
|
|
31
|
+
maskPostLinks?: boolean;
|
|
32
|
+
features?: PortalFeatures;
|
|
33
|
+
}) {
|
|
34
|
+
const drafts = useAuthorizedDraftPosts(search);
|
|
35
|
+
return (
|
|
36
|
+
<main className="mx-auto w-full max-w-[96rem] px-6 py-10 lg:px-10">
|
|
37
|
+
<div className="grid gap-12 lg:grid-cols-[minmax(0,1fr)_22rem]">
|
|
38
|
+
<div className="min-w-0">
|
|
39
|
+
<section aria-labelledby="feedback-heading">
|
|
40
|
+
<h1
|
|
41
|
+
id="feedback-heading"
|
|
42
|
+
className="text-2xl font-semibold tracking-tight"
|
|
43
|
+
>
|
|
44
|
+
Feedback
|
|
45
|
+
</h1>
|
|
46
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
47
|
+
{features.voting
|
|
48
|
+
? "Share ideas and vote on what matters."
|
|
49
|
+
: "Share ideas and follow public progress."}
|
|
50
|
+
</p>
|
|
51
|
+
</section>
|
|
52
|
+
<div className="mt-8 flex flex-col gap-3 lg:flex-row lg:items-center">
|
|
53
|
+
<PostFeedControls
|
|
54
|
+
contextualCreatePost={maskPostLinks}
|
|
55
|
+
search={search}
|
|
56
|
+
onSearchChange={onSearchChange}
|
|
57
|
+
pending={pending}
|
|
58
|
+
features={features}
|
|
59
|
+
/>
|
|
60
|
+
</div>
|
|
61
|
+
<section
|
|
62
|
+
className="mt-8"
|
|
63
|
+
aria-live="polite"
|
|
64
|
+
aria-label="Feedback posts"
|
|
65
|
+
>
|
|
66
|
+
<PostResults
|
|
67
|
+
drafts={drafts}
|
|
68
|
+
initialItems={page.items}
|
|
69
|
+
nextCursor={page.nextCursor}
|
|
70
|
+
loadMore={loadMore}
|
|
71
|
+
loadingMore={loadingMore}
|
|
72
|
+
loadMoreError={loadMoreError}
|
|
73
|
+
maskPostLinks={maskPostLinks}
|
|
74
|
+
features={features}
|
|
75
|
+
/>
|
|
76
|
+
</section>
|
|
77
|
+
</div>
|
|
78
|
+
<aside
|
|
79
|
+
className="hidden border-l pl-8 pt-14 lg:block"
|
|
80
|
+
aria-label="Feedback navigation"
|
|
81
|
+
>
|
|
82
|
+
<BoardNavigation search={search} onSearchChange={onSearchChange} />
|
|
83
|
+
<div className="mt-8">
|
|
84
|
+
<StatusNavigation search={search} onSearchChange={onSearchChange} />
|
|
85
|
+
</div>
|
|
86
|
+
</aside>
|
|
87
|
+
</div>
|
|
88
|
+
</main>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function PublicPostFeedSkeleton() {
|
|
93
|
+
return <Loader label="Loading Posts" />;
|
|
94
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { createFeedbackModule } from "@feedbax/feedback";
|
|
4
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
5
|
+
import { cleanup, render, screen, within } from "@testing-library/react";
|
|
6
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
7
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
PublicPostFeedSkeleton,
|
|
11
|
+
PublicPostIndex,
|
|
12
|
+
} from "./public-feedback-index";
|
|
13
|
+
import { loadPublicPostPage } from "./public-feedback-page";
|
|
14
|
+
import { authorizedDraftPostsQueryKey } from "./authorized-draft-query";
|
|
15
|
+
|
|
16
|
+
afterEach(cleanup);
|
|
17
|
+
|
|
18
|
+
describe("public feedback home page", () => {
|
|
19
|
+
it("keeps Shipped Posts out of default Top before pagination while preserving explicit discovery", async () => {
|
|
20
|
+
const feedback = createFeedbackModule({
|
|
21
|
+
initialItems: [
|
|
22
|
+
...Array.from({ length: 26 }, (_, index) => ({
|
|
23
|
+
id: `active-${index}`,
|
|
24
|
+
slug: `active-${index}`,
|
|
25
|
+
title: `Active ${index}`,
|
|
26
|
+
description: "Feedback",
|
|
27
|
+
type: "Feature Request" as const,
|
|
28
|
+
status: "Planned" as const,
|
|
29
|
+
published: true,
|
|
30
|
+
voteCount: index,
|
|
31
|
+
createdAt: new Date(index * 1000),
|
|
32
|
+
updatedAt: new Date(index * 1000),
|
|
33
|
+
})),
|
|
34
|
+
...Array.from({ length: 27 }, (_, index) => ({
|
|
35
|
+
id: `shipped-${index}`,
|
|
36
|
+
slug: `shipped-${index}`,
|
|
37
|
+
title: `Shipped ${index}`,
|
|
38
|
+
description: "Feedback",
|
|
39
|
+
type: "Feature Request" as const,
|
|
40
|
+
status: "Shipped" as const,
|
|
41
|
+
published: true,
|
|
42
|
+
voteCount: 1000,
|
|
43
|
+
createdAt: new Date(100000 + index * 1000),
|
|
44
|
+
updatedAt: new Date(100000 + index * 1000),
|
|
45
|
+
})),
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
const first = await loadPublicPostPage({
|
|
49
|
+
feedback,
|
|
50
|
+
search: { sort: "top" },
|
|
51
|
+
});
|
|
52
|
+
expect(first.items).toHaveLength(25);
|
|
53
|
+
expect(first.items.every((post) => post.status !== "Shipped")).toBe(true);
|
|
54
|
+
const second = await loadPublicPostPage({
|
|
55
|
+
feedback,
|
|
56
|
+
search: { sort: "top", cursor: first.nextCursor },
|
|
57
|
+
});
|
|
58
|
+
expect(second.items).toHaveLength(1);
|
|
59
|
+
const legacy = await loadPublicPostPage({
|
|
60
|
+
feedback,
|
|
61
|
+
search: { sort: "trending" },
|
|
62
|
+
});
|
|
63
|
+
expect(legacy.items.map((post) => post.slug)).toEqual(
|
|
64
|
+
first.items.map((post) => post.slug),
|
|
65
|
+
);
|
|
66
|
+
const shipped = await loadPublicPostPage({
|
|
67
|
+
feedback,
|
|
68
|
+
search: { sort: "top", statuses: ["Shipped"] },
|
|
69
|
+
});
|
|
70
|
+
expect(shipped.items).toHaveLength(25);
|
|
71
|
+
expect(shipped.items.every((post) => post.status === "Shipped")).toBe(true);
|
|
72
|
+
const search = await loadPublicPostPage({
|
|
73
|
+
feedback,
|
|
74
|
+
search: { sort: "top", search: "Shipped" },
|
|
75
|
+
});
|
|
76
|
+
expect(search.items[0]?.status).toBe("Shipped");
|
|
77
|
+
const newest = await loadPublicPostPage({
|
|
78
|
+
feedback,
|
|
79
|
+
search: { sort: "new" },
|
|
80
|
+
});
|
|
81
|
+
expect(newest.items[0]?.status).toBe("Shipped");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("loads 25 Published Posts and an opaque next-page cursor", async () => {
|
|
85
|
+
const feedback = createFeedbackModule({
|
|
86
|
+
initialItems: Array.from({ length: 26 }, (_, index) => ({
|
|
87
|
+
id: `feedback-${index + 1}`,
|
|
88
|
+
slug: `feedback-${index + 1}`,
|
|
89
|
+
title: `Feedback ${index + 1}`,
|
|
90
|
+
description: `Description ${index + 1}`,
|
|
91
|
+
type: "Feature Request" as const,
|
|
92
|
+
status: "New" as const,
|
|
93
|
+
published: true,
|
|
94
|
+
createdAt: new Date(Date.UTC(2026, 6, index + 1)),
|
|
95
|
+
updatedAt: new Date(Date.UTC(2026, 6, index + 1)),
|
|
96
|
+
})),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const page = await loadPublicPostPage({
|
|
100
|
+
feedback,
|
|
101
|
+
search: {},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(page.items).toHaveLength(25);
|
|
105
|
+
expect(page.items.map((item) => item.title)).toEqual([
|
|
106
|
+
"Feedback 26",
|
|
107
|
+
...Array.from({ length: 24 }, (_, index) => `Feedback ${25 - index}`),
|
|
108
|
+
]);
|
|
109
|
+
expect(page.nextCursor).toEqual(expect.any(String));
|
|
110
|
+
expect(page.nextCursor).not.toContain("feedback-");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("keeps each public feed ordering deterministic across route pages", async () => {
|
|
114
|
+
const feedback = createFeedbackModule({
|
|
115
|
+
initialItems: Array.from({ length: 27 }, (_, index) => ({
|
|
116
|
+
id: `post-${index}`,
|
|
117
|
+
slug: `post-${index}`,
|
|
118
|
+
title: `Post ${index}`,
|
|
119
|
+
description: "Visible.",
|
|
120
|
+
type: "Feature Request" as const,
|
|
121
|
+
status: "Planned" as const,
|
|
122
|
+
published: true,
|
|
123
|
+
voteCount: index < 2 ? 100 : index,
|
|
124
|
+
createdAt: new Date(Date.UTC(2026, 7, index + 1)),
|
|
125
|
+
updatedAt: new Date(Date.UTC(2026, 7, index + 1)),
|
|
126
|
+
})),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const top = await loadPublicPostPage({
|
|
130
|
+
feedback,
|
|
131
|
+
search: { sort: "top" },
|
|
132
|
+
});
|
|
133
|
+
const trending = await loadPublicPostPage({
|
|
134
|
+
feedback,
|
|
135
|
+
search: { sort: "trending" },
|
|
136
|
+
});
|
|
137
|
+
const newest = await loadPublicPostPage({
|
|
138
|
+
feedback,
|
|
139
|
+
search: { sort: "new" },
|
|
140
|
+
});
|
|
141
|
+
const topNext = await loadPublicPostPage({
|
|
142
|
+
feedback,
|
|
143
|
+
search: { sort: "top", cursor: top.nextCursor },
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(top.items.map(({ slug }) => slug)).toEqual(
|
|
147
|
+
trending.items.map(({ slug }) => slug),
|
|
148
|
+
);
|
|
149
|
+
expect(top.items.slice(0, 2).map(({ slug }) => slug)).toEqual([
|
|
150
|
+
"post-1",
|
|
151
|
+
"post-0",
|
|
152
|
+
]);
|
|
153
|
+
expect(newest.items[0]?.slug).toBe("post-26");
|
|
154
|
+
expect([...top.items, ...topNext.items]).toHaveLength(27);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("applies Feedback Type and Feedback Status filters from the page URL", async () => {
|
|
158
|
+
const feedback = createFeedbackModule({
|
|
159
|
+
initialItems: [
|
|
160
|
+
{
|
|
161
|
+
id: "matching-feedback",
|
|
162
|
+
slug: "matching-feedback",
|
|
163
|
+
title: "Matching feedback",
|
|
164
|
+
description: "A planned feature request.",
|
|
165
|
+
type: "Feature Request",
|
|
166
|
+
status: "Planned",
|
|
167
|
+
published: true,
|
|
168
|
+
createdAt: new Date("2026-08-20T10:00:00.000Z"),
|
|
169
|
+
updatedAt: new Date("2026-08-20T10:00:00.000Z"),
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
id: "wrong-status",
|
|
173
|
+
slug: "wrong-status",
|
|
174
|
+
title: "New feature request",
|
|
175
|
+
description: "This should be filtered out.",
|
|
176
|
+
type: "Feature Request",
|
|
177
|
+
status: "New",
|
|
178
|
+
published: true,
|
|
179
|
+
createdAt: new Date("2026-08-19T10:00:00.000Z"),
|
|
180
|
+
updatedAt: new Date("2026-08-19T10:00:00.000Z"),
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: "wrong-type",
|
|
184
|
+
slug: "wrong-type",
|
|
185
|
+
title: "Planned bug report",
|
|
186
|
+
description: "This should also be filtered out.",
|
|
187
|
+
type: "Bug Report",
|
|
188
|
+
status: "Planned",
|
|
189
|
+
published: true,
|
|
190
|
+
createdAt: new Date("2026-08-18T10:00:00.000Z"),
|
|
191
|
+
updatedAt: new Date("2026-08-18T10:00:00.000Z"),
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const page = await loadPublicPostPage({
|
|
197
|
+
feedback,
|
|
198
|
+
search: { types: ["Feature Request"], statuses: ["Planned"] },
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(page.items.map((item) => item.title)).toEqual(["Matching feedback"]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("renders the registry empty state with a clear reset action", () => {
|
|
205
|
+
const html = renderIndex(
|
|
206
|
+
<PublicPostIndex
|
|
207
|
+
page={{ items: [], nextCursor: "opaque-next-page" }}
|
|
208
|
+
search={{ types: ["Bug Report"], statuses: ["Reviewing"] }}
|
|
209
|
+
/>,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
expect(html).toContain("No Posts match this view");
|
|
213
|
+
expect(html).toContain("Clear filters");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("renders the feedback workspace described by the visual brief", () => {
|
|
217
|
+
const html = renderIndex(
|
|
218
|
+
<PublicPostIndex page={{ items: [publishedPost] }} search={{}} />,
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
expect(html).toContain("Share ideas and vote on what matters.");
|
|
222
|
+
expect(html).toContain("Boards");
|
|
223
|
+
expect(html).toContain("All posts");
|
|
224
|
+
expect(html).toContain("Status");
|
|
225
|
+
expect(html).not.toContain("Trending");
|
|
226
|
+
expect(html).toContain("Top");
|
|
227
|
+
expect(html).toContain("Top");
|
|
228
|
+
expect(html).toContain("New post");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("renders the working Vote toggle on eligible public feed cards", () => {
|
|
232
|
+
renderIndexIntoDocument(
|
|
233
|
+
<PublicPostIndex page={{ items: [publishedPost] }} search={{}} />,
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const post = screen.getByRole("link", { name: /Keyboard-first search!/ });
|
|
237
|
+
const metadata = within(post).getByRole("group", {
|
|
238
|
+
name: "Post metadata",
|
|
239
|
+
});
|
|
240
|
+
expect(within(metadata).getByText("Feature Request")).toBeTruthy();
|
|
241
|
+
expect(within(metadata).getByText("Planned")).toBeTruthy();
|
|
242
|
+
expect(
|
|
243
|
+
screen.getByRole("button", { name: "Add Vote, 7 Votes" }),
|
|
244
|
+
).toBeTruthy();
|
|
245
|
+
expect(within(metadata).queryByText(/comment/i)).toBeNull();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("links each Published Post by immutable slug", () => {
|
|
249
|
+
const html = renderIndex(
|
|
250
|
+
<PublicPostIndex
|
|
251
|
+
page={{
|
|
252
|
+
items: [
|
|
253
|
+
{
|
|
254
|
+
slug: "keyboard-first-search",
|
|
255
|
+
title: "Keyboard-first search!",
|
|
256
|
+
description: "Open search without reaching for the mouse.",
|
|
257
|
+
type: "Feature Request",
|
|
258
|
+
status: "Planned",
|
|
259
|
+
createdAt: new Date("2026-08-20T10:00:00.000Z"),
|
|
260
|
+
updatedAt: new Date("2026-08-20T12:00:00.000Z"),
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
}}
|
|
264
|
+
search={{}}
|
|
265
|
+
/>,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
expect(html).toContain('href="/p/keyboard-first-search"');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("keeps visible Posts and offers retry when Load More fails", () => {
|
|
272
|
+
const html = renderIndex(
|
|
273
|
+
<PublicPostIndex
|
|
274
|
+
page={{ items: [publishedPost], nextCursor: "opaque-next-page" }}
|
|
275
|
+
search={{}}
|
|
276
|
+
loadMore={() => undefined}
|
|
277
|
+
loadMoreError
|
|
278
|
+
/>,
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
expect(html).toContain("Keyboard-first search!");
|
|
282
|
+
expect(html).toContain("Couldn’t load more Posts");
|
|
283
|
+
expect(html).toContain("Try again");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("renders a contextual feed skeleton while the route is loading", () => {
|
|
287
|
+
const html = renderToStaticMarkup(<PublicPostFeedSkeleton />);
|
|
288
|
+
|
|
289
|
+
expect(html).toContain('aria-label="Loading Posts"');
|
|
290
|
+
expect(html).toContain('aria-busy="true"');
|
|
291
|
+
expect(html.match(/data-post-skeleton-row/g)).toHaveLength(3);
|
|
292
|
+
expect(html).toContain("Loading Posts…");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("pins recognizable matching Draft Posts above Published Posts", () => {
|
|
296
|
+
const draft = {
|
|
297
|
+
id: "draft-id",
|
|
298
|
+
slug: "keyboard-draft",
|
|
299
|
+
title: "Keyboard draft",
|
|
300
|
+
description: "Keyboard access still being drafted.",
|
|
301
|
+
type: "Feature Request" as const,
|
|
302
|
+
status: "New" as const,
|
|
303
|
+
submitter: {},
|
|
304
|
+
createdAt: new Date("2026-08-22T10:00:00.000Z"),
|
|
305
|
+
updatedAt: new Date("2026-08-22T10:00:00.000Z"),
|
|
306
|
+
};
|
|
307
|
+
const html = renderIndex(
|
|
308
|
+
<PublicPostIndex
|
|
309
|
+
page={{ items: [publishedPost] }}
|
|
310
|
+
search={{ search: "keyboard", types: ["Feature Request"] }}
|
|
311
|
+
/>,
|
|
312
|
+
[draft],
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
expect(html.indexOf("Keyboard draft")).toBeLessThan(
|
|
316
|
+
html.indexOf("Keyboard-first search!"),
|
|
317
|
+
);
|
|
318
|
+
expect(html.match(/<ol class="divide-y/g)).toHaveLength(1);
|
|
319
|
+
expect(html).toContain("draft-post-link");
|
|
320
|
+
expect(html).toContain(">Draft</span>");
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
function renderIndex(
|
|
325
|
+
element: React.ReactElement,
|
|
326
|
+
drafts: unknown[] = [],
|
|
327
|
+
): string {
|
|
328
|
+
return renderToStaticMarkup(withIndexQueryClient(element, drafts));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function renderIndexIntoDocument(element: React.ReactElement) {
|
|
332
|
+
render(withIndexQueryClient(element));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function withIndexQueryClient(
|
|
336
|
+
element: React.ReactElement,
|
|
337
|
+
drafts: unknown[] = [],
|
|
338
|
+
) {
|
|
339
|
+
const queryClient = new QueryClient();
|
|
340
|
+
queryClient.setQueryData(authorizedDraftPostsQueryKey({}), drafts);
|
|
341
|
+
return (
|
|
342
|
+
<QueryClientProvider client={queryClient}>{element}</QueryClientProvider>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const publishedPost = {
|
|
347
|
+
slug: "keyboard-first-search",
|
|
348
|
+
title: "Keyboard-first search!",
|
|
349
|
+
description: "Open search without reaching for the mouse.",
|
|
350
|
+
type: "Feature Request" as const,
|
|
351
|
+
status: "Planned" as const,
|
|
352
|
+
voteCount: 7,
|
|
353
|
+
createdAt: new Date("2026-08-20T10:00:00.000Z"),
|
|
354
|
+
updatedAt: new Date("2026-08-20T12:00:00.000Z"),
|
|
355
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FeedbackModule,
|
|
3
|
+
PostStatus,
|
|
4
|
+
PostType,
|
|
5
|
+
PublicPostQuery,
|
|
6
|
+
PublicPostPage,
|
|
7
|
+
} from "@feedbax/feedback";
|
|
8
|
+
import type { PortalFeatures } from "@feedbax/config";
|
|
9
|
+
import feedbax from "./feedbax";
|
|
10
|
+
|
|
11
|
+
export const postTypes = [
|
|
12
|
+
"Feature Request",
|
|
13
|
+
"Bug Report",
|
|
14
|
+
"General Feedback",
|
|
15
|
+
] as const satisfies readonly PostType[];
|
|
16
|
+
export const postStatuses = [
|
|
17
|
+
"New",
|
|
18
|
+
"Reviewing",
|
|
19
|
+
"Planned",
|
|
20
|
+
"In Progress",
|
|
21
|
+
"Shipped",
|
|
22
|
+
"Closed",
|
|
23
|
+
] as const satisfies readonly PostStatus[];
|
|
24
|
+
export const postSorts = ["top", "new"] as const;
|
|
25
|
+
|
|
26
|
+
export async function loadPublicPostPage({
|
|
27
|
+
feedback,
|
|
28
|
+
search,
|
|
29
|
+
}: {
|
|
30
|
+
feedback: Pick<FeedbackModule, "listPublicPosts">;
|
|
31
|
+
search: Record<string, unknown>;
|
|
32
|
+
}): Promise<PublicPostPage> {
|
|
33
|
+
const query = publicPostSearch(search);
|
|
34
|
+
const defaultTop =
|
|
35
|
+
query.sort !== "new" &&
|
|
36
|
+
!query.search &&
|
|
37
|
+
!query.status &&
|
|
38
|
+
!query.statuses?.length;
|
|
39
|
+
return feedback.listPublicPosts({
|
|
40
|
+
...query,
|
|
41
|
+
...(defaultTop
|
|
42
|
+
? { statuses: postStatuses.filter((status) => status !== "Shipped") }
|
|
43
|
+
: {}),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function publicPostSearch(
|
|
48
|
+
search: Record<string, unknown>,
|
|
49
|
+
features: PortalFeatures = feedbax.features,
|
|
50
|
+
): PublicPostQuery & { pageSize?: string; schema?: string } {
|
|
51
|
+
const cursor = text(search.cursor);
|
|
52
|
+
const query = text(search.search);
|
|
53
|
+
const sortValue = text(search.sort);
|
|
54
|
+
const types = values(search.types).filter((value): value is PostType =>
|
|
55
|
+
postTypes.includes(value as PostType),
|
|
56
|
+
);
|
|
57
|
+
const statuses = values(search.statuses).filter(
|
|
58
|
+
(value): value is PostStatus => postStatuses.includes(value as PostStatus),
|
|
59
|
+
);
|
|
60
|
+
return {
|
|
61
|
+
...(text(search.pageSize) ? { pageSize: text(search.pageSize) } : {}),
|
|
62
|
+
...(text(search.schema) ? { schema: text(search.schema) } : {}),
|
|
63
|
+
...(cursor ? { cursor } : {}),
|
|
64
|
+
...(query ? { search: query } : {}),
|
|
65
|
+
sort: !features.voting
|
|
66
|
+
? "new"
|
|
67
|
+
: sortValue === "trending"
|
|
68
|
+
? "top"
|
|
69
|
+
: postSorts.includes(sortValue as (typeof postSorts)[number])
|
|
70
|
+
? (sortValue as PublicPostQuery["sort"])
|
|
71
|
+
: "top",
|
|
72
|
+
...(types.length ? { types } : {}),
|
|
73
|
+
...(statuses.length ? { statuses } : {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function text(value: unknown) {
|
|
78
|
+
return typeof value === "string" && value ? value : undefined;
|
|
79
|
+
}
|
|
80
|
+
function values(value: unknown): string[] {
|
|
81
|
+
return Array.isArray(value)
|
|
82
|
+
? value.filter((entry): entry is string => typeof entry === "string")
|
|
83
|
+
: typeof value === "string"
|
|
84
|
+
? value.split(",").filter(Boolean)
|
|
85
|
+
: [];
|
|
86
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
2
|
+
|
|
3
|
+
import { createConfiguredFeedbackModule } from "./feedback-runtime";
|
|
4
|
+
import { loadPublicPostPage, publicPostSearch } from "./public-feedback-page";
|
|
5
|
+
import { runSafePublicRead } from "./safe-public-failure";
|
|
6
|
+
|
|
7
|
+
export const getPublicPostPage = createServerFn({ method: "GET" })
|
|
8
|
+
.validator((input: unknown) =>
|
|
9
|
+
publicPostSearch(
|
|
10
|
+
typeof input === "object" && input !== null
|
|
11
|
+
? (input as Record<string, unknown>)
|
|
12
|
+
: {},
|
|
13
|
+
),
|
|
14
|
+
)
|
|
15
|
+
.handler(({ data }) =>
|
|
16
|
+
runSafePublicRead(() =>
|
|
17
|
+
loadPublicPostPage({
|
|
18
|
+
feedback: createConfiguredFeedbackModule(),
|
|
19
|
+
search: data,
|
|
20
|
+
}),
|
|
21
|
+
),
|
|
22
|
+
);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Button } from "@feedbax/ui/components/button";
|
|
2
|
+
|
|
3
|
+
export function PublicPortalError({
|
|
4
|
+
reset,
|
|
5
|
+
}: {
|
|
6
|
+
error: Error;
|
|
7
|
+
reset: () => void;
|
|
8
|
+
}) {
|
|
9
|
+
return (
|
|
10
|
+
<main className="public-error">
|
|
11
|
+
<p className="feedback-eyebrow">Temporary interruption</p>
|
|
12
|
+
<h1>Posts are temporarily unavailable.</h1>
|
|
13
|
+
<p>
|
|
14
|
+
We couldn’t reach the Post service after several attempts. Your private
|
|
15
|
+
information has not been displayed.
|
|
16
|
+
</p>
|
|
17
|
+
<div className="public-error-actions">
|
|
18
|
+
<Button type="button" onClick={reset}>
|
|
19
|
+
Try again
|
|
20
|
+
</Button>
|
|
21
|
+
<Button render={<a href="/" />} variant="outline">
|
|
22
|
+
Return to the portal
|
|
23
|
+
</Button>
|
|
24
|
+
</div>
|
|
25
|
+
</main>
|
|
26
|
+
);
|
|
27
|
+
}
|