create-feedbax 0.2.0 → 0.2.2
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/README.md +17 -5
- package/dist/cli.js +2610 -291
- package/dist/index.js +2375 -200
- package/package.json +7 -7
- package/template/.feedbax-template.sha256 +1 -1
- package/template/.gitignore.template +1 -0
- package/template/.npmignore.template +1 -0
- package/template/.nvmrc +1 -0
- package/template/AGENTS.md +13 -0
- package/template/CONNECTED.md +418 -0
- package/template/README.md +97 -0
- package/template/components.json +1 -1
- package/template/eslint.config.mjs +23 -0
- package/template/feedbax-vite.ts +79 -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/migrations/0005_github.sql +4 -0
- package/template/migrations/0006_integration.sql +3 -0
- package/template/migrations/0007_ownership.sql +2 -0
- package/template/migrations/0008_integration_recovery.sql +5 -0
- package/template/migrations/0009_observed_history.sql +3 -0
- package/template/package.json +39 -34
- package/template/src/authorized-draft-post.tsx +1 -0
- package/template/src/changelog-page.tsx +63 -54
- package/template/src/changelog-runtime.ts +2 -1
- package/template/src/cloudflare-workers.d.ts +9 -0
- package/template/src/comment-creation.test.tsx +95 -7
- package/template/src/comment-thread-page.ts +11 -1
- package/template/src/components/command-palette.tsx +59 -30
- package/template/src/components/connected-identity-provider.tsx +135 -0
- package/template/src/components/device-profile-provider.tsx +41 -26
- package/template/src/components/follow-post-button.tsx +78 -0
- package/template/src/components/header.tsx +2 -0
- package/template/src/components/image-attachments.tsx +187 -0
- package/template/src/components/loader.tsx +48 -5
- package/template/src/components/notification-inbox.tsx +232 -0
- package/template/src/components/portal-footer.tsx +14 -0
- package/template/src/components/post-history.tsx +101 -0
- package/template/src/components/profile-menu.tsx +37 -0
- package/template/src/components/ui/components/command.tsx +30 -29
- package/template/src/components/ui/components/message-scroller.tsx +2 -2
- package/template/src/components/ui/components/scroll-area.tsx +0 -1
- package/template/src/components/ui/components/switch.tsx +29 -0
- package/template/src/components/ui/styles/globals.css +1 -1
- package/template/src/configured-feedback.test.ts +82 -0
- package/template/src/configured-feedback.ts +61 -0
- package/template/src/connected-identity.test.ts +148 -0
- package/template/src/connected-identity.ts +127 -0
- package/template/src/connected-participation.test.tsx +67 -0
- package/template/src/connected-runtime.test.ts +244 -0
- package/template/src/connected-runtime.ts +176 -0
- package/template/src/connected-social.test.tsx +158 -0
- package/template/src/feedback-cache-invalidation.ts +3 -0
- package/template/src/feedback-runtime.ts +123 -13
- package/template/src/feedbax/changelog/index.test.ts +35 -0
- package/template/src/feedbax/changelog/index.ts +5 -1
- package/template/src/feedbax/changelog/notion-changelog.test.ts +1 -1
- package/template/src/feedbax/changelog/notion-changelog.ts +1 -1
- package/template/src/feedbax/config/index.test.ts +73 -1
- package/template/src/feedbax/config/index.ts +205 -9
- package/template/src/feedbax/feedback/comment-storage.ts +38 -0
- package/template/src/feedbax/feedback/connected-feedback.ts +235 -0
- package/template/src/feedbax/feedback/connected.test.ts +272 -0
- package/template/src/feedbax/feedback/connected.ts +225 -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 +1 -0
- package/template/src/feedbax/feedback/github-comments.ts +390 -0
- package/template/src/feedbax/feedback/github-feedback.test.ts +1138 -0
- package/template/src/feedbax/feedback/github-feedback.ts +581 -0
- package/template/src/feedbax/feedback/github-request.ts +120 -0
- package/template/src/feedbax/feedback/github-service-client.ts +56 -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 +114 -0
- package/template/src/feedbax/feedback/images.test.ts +223 -0
- package/template/src/feedbax/feedback/images.ts +350 -0
- package/template/src/feedbax/feedback/index.ts +226 -16
- package/template/src/feedbax/feedback/integration-auth.ts +138 -0
- package/template/src/feedbax/feedback/notion-comments.ts +2 -35
- package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
- package/template/src/feedbax/feedback/observed-history.test.ts +150 -0
- package/template/src/feedbax/feedback/observed-history.ts +219 -0
- package/template/src/feedbax/feedback/ownership.ts +170 -0
- package/template/src/feedbax/feedback/participation.test.ts +653 -0
- package/template/src/feedbax/feedback/participation.ts +502 -0
- package/template/src/history-runtime.test.ts +80 -0
- package/template/src/history-runtime.ts +37 -0
- package/template/src/images-runtime.test.ts +133 -0
- package/template/src/images-runtime.ts +125 -0
- package/template/src/index.css +103 -440
- package/template/src/integration-runtime.test.ts +365 -0
- package/template/src/integration-runtime.ts +289 -0
- package/template/src/lazy-post-detail-overlay.tsx +13 -2
- package/template/src/my-feedback-page.test.tsx +152 -0
- package/template/src/my-feedback-page.tsx +398 -0
- package/template/src/my-feedback-runtime.test.ts +93 -0
- package/template/src/my-feedback-runtime.ts +154 -0
- package/template/src/overlay-post-detail.test.tsx +59 -8
- package/template/src/overlay-post-detail.tsx +20 -69
- 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.ts +35 -5
- package/template/src/portal-feedback-form.tsx +29 -6
- package/template/src/portal-feedback-runtime.ts +3 -0
- package/template/src/portal-feedback-server-function.ts +6 -3
- package/template/src/portal-feedback-submission.ts +19 -0
- package/template/src/portal-server-entry.ts +13 -0
- package/template/src/post-creation-flow.tsx +2 -2
- package/template/src/post-detail-content.test.tsx +233 -0
- package/template/src/post-detail-content.tsx +162 -0
- package/template/src/post-detail-query.ts +34 -0
- package/template/src/post-results.tsx +1 -1
- package/template/src/public-cache-policy.test.ts +11 -4
- package/template/src/public-cache-policy.ts +1 -1
- package/template/src/public-feedback-index.tsx +9 -39
- package/template/src/public-feedback-page.test.tsx +65 -0
- package/template/src/public-feedback-page.ts +12 -1
- package/template/src/public-portal-error.tsx +1 -1
- package/template/src/public-post-detail-skeleton.tsx +54 -0
- package/template/src/public-post-detail.tsx +437 -271
- package/template/src/public-post-route.test.tsx +39 -3
- package/template/src/public-post-route.tsx +15 -28
- package/template/src/public-release-links.test.ts +120 -0
- package/template/src/public-release-links.ts +130 -0
- package/template/src/public-roadmap-route.test.tsx +38 -2
- package/template/src/public-roadmap-view.tsx +56 -23
- package/template/src/public-shell.test.tsx +26 -0
- package/template/src/routeTree.gen.ts +70 -3
- package/template/src/router.tsx +2 -0
- package/template/src/routes/__root.tsx +3 -5
- package/template/src/routes/email-preferences.tsx +77 -0
- package/template/src/routes/my-feedback.tsx +16 -0
- package/template/src/routes/submit.tsx +1 -1
- package/template/src/safe-public-failure.ts +3 -1
- package/template/src/server.ts +87 -2
- package/template/src/submission-intent.ts +26 -0
- package/template/src/trusted-feedback-handler.test.ts +1 -1
- package/template/src/vote-toggle.tsx +42 -9
- package/template/vite.config.ts +2 -0
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "@testing-library/react";
|
|
14
14
|
import {
|
|
15
15
|
createMemoryHistory,
|
|
16
|
-
|
|
16
|
+
createRootRouteWithContext,
|
|
17
17
|
createRouter,
|
|
18
18
|
Outlet,
|
|
19
19
|
RouterProvider,
|
|
@@ -28,6 +28,8 @@ import {
|
|
|
28
28
|
vi,
|
|
29
29
|
} from "vitest";
|
|
30
30
|
|
|
31
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
32
|
+
|
|
31
33
|
import { loadPublicPost } from "./public-post-page";
|
|
32
34
|
import { createPublicPostRoute } from "./public-post-route";
|
|
33
35
|
import { PublicPostUnavailable } from "./public-post-unavailable";
|
|
@@ -76,7 +78,7 @@ describe("/p/$slug", () => {
|
|
|
76
78
|
expect(details.getByText("Submitted")).toBeTruthy();
|
|
77
79
|
expect(details.getByText("Updated")).toBeTruthy();
|
|
78
80
|
expect(history.location.pathname).toBe("/p/keyboard-first-search");
|
|
79
|
-
expect(screen.
|
|
81
|
+
expect(await screen.findByText("Please add Vim bindings.")).toBeTruthy();
|
|
80
82
|
expect(screen.getByText("Ari")).toBeTruthy();
|
|
81
83
|
expect(document.body.textContent).not.toContain("secret-id");
|
|
82
84
|
});
|
|
@@ -161,6 +163,30 @@ describe("/p/$slug", () => {
|
|
|
161
163
|
"native-cursor",
|
|
162
164
|
);
|
|
163
165
|
});
|
|
166
|
+
|
|
167
|
+
it("renders the full-page Post before a slow Comment request completes", async () => {
|
|
168
|
+
const feedback = createFeedbackModule({ initialItems: [publishedPost] });
|
|
169
|
+
let finish!: (page: CommentThreadPage) => void;
|
|
170
|
+
const loadComments = vi.fn(
|
|
171
|
+
() =>
|
|
172
|
+
new Promise<CommentThreadPage>((resolve) => {
|
|
173
|
+
finish = resolve;
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
const history = createMemoryHistory({
|
|
177
|
+
initialEntries: ["/p/keyboard-first-search"],
|
|
178
|
+
});
|
|
179
|
+
const router = createPostRouter({ feedback, history, loadComments });
|
|
180
|
+
render(<RouterProvider router={router} />);
|
|
181
|
+
expect(
|
|
182
|
+
await screen.findByRole("heading", { name: "Keyboard-first search" }),
|
|
183
|
+
).toBeTruthy();
|
|
184
|
+
expect(
|
|
185
|
+
screen.getByRole("status", { name: "Loading Comments" }),
|
|
186
|
+
).toBeTruthy();
|
|
187
|
+
finish({ items: [] });
|
|
188
|
+
expect(await screen.findByText("No comments yet")).toBeTruthy();
|
|
189
|
+
});
|
|
164
190
|
});
|
|
165
191
|
|
|
166
192
|
function createPostRouter({
|
|
@@ -172,7 +198,16 @@ function createPostRouter({
|
|
|
172
198
|
history: ReturnType<typeof createMemoryHistory>;
|
|
173
199
|
loadComments?: (slug: string, cursor?: string) => Promise<CommentThreadPage>;
|
|
174
200
|
}) {
|
|
175
|
-
const
|
|
201
|
+
const queryClient = new QueryClient({
|
|
202
|
+
defaultOptions: { queries: { retry: false } },
|
|
203
|
+
});
|
|
204
|
+
const rootRoute = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
|
205
|
+
component: () => (
|
|
206
|
+
<QueryClientProvider client={queryClient}>
|
|
207
|
+
<Outlet />
|
|
208
|
+
</QueryClientProvider>
|
|
209
|
+
),
|
|
210
|
+
});
|
|
176
211
|
const postRoute = createPublicPostRoute({
|
|
177
212
|
loadPost: (slug) => loadPublicPost({ feedback, slug }),
|
|
178
213
|
loadComments:
|
|
@@ -185,6 +220,7 @@ function createPostRouter({
|
|
|
185
220
|
getParentRoute: () => rootRoute,
|
|
186
221
|
} as never);
|
|
187
222
|
return createRouter({
|
|
223
|
+
context: { queryClient },
|
|
188
224
|
history,
|
|
189
225
|
routeTree: rootRoute.addChildren([postRoute]),
|
|
190
226
|
});
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import type { CommentThreadPage, PublicPost } from "@feedbax/feedback";
|
|
2
2
|
import { createFileRoute } from "@tanstack/react-router";
|
|
3
|
-
import {
|
|
3
|
+
import { useSuspenseQuery } from "@tanstack/react-query";
|
|
4
|
+
import { type ReactNode } from "react";
|
|
4
5
|
import type { PortalCommentRequest } from "./portal-comments";
|
|
5
|
-
import {
|
|
6
|
+
import { PostDetailContent } from "./post-detail-content";
|
|
6
7
|
import feedbax from "./feedbax";
|
|
7
8
|
import { createRouteHead } from "./portal-head";
|
|
8
9
|
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
PublicPostDetailSkeleton,
|
|
12
|
-
} from "./public-post-detail";
|
|
10
|
+
import { PublicPostDetailSkeleton } from "./public-post-detail-skeleton";
|
|
11
|
+
import { publicPostQuery } from "./post-detail-query";
|
|
13
12
|
|
|
14
13
|
export function createPublicPostRoute({
|
|
15
14
|
loadPost,
|
|
@@ -21,11 +20,11 @@ export function createPublicPostRoute({
|
|
|
21
20
|
renderUnavailable: (slug: string) => ReactNode;
|
|
22
21
|
}) {
|
|
23
22
|
const route = createFileRoute("/p/$slug")({
|
|
24
|
-
loader: async ({ params }) => {
|
|
25
|
-
const post = await
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return { post
|
|
23
|
+
loader: async ({ params, context }) => {
|
|
24
|
+
const post = await context.queryClient.ensureQueryData(
|
|
25
|
+
publicPostQuery(params.slug, loadPost),
|
|
26
|
+
);
|
|
27
|
+
return { post };
|
|
29
28
|
},
|
|
30
29
|
head: ({ loaderData, params }) =>
|
|
31
30
|
loaderData?.post
|
|
@@ -45,26 +44,14 @@ export function createPublicPostRoute({
|
|
|
45
44
|
});
|
|
46
45
|
|
|
47
46
|
function PostRoute() {
|
|
48
|
-
const data = route.useLoaderData();
|
|
49
47
|
const { slug } = route.useParams();
|
|
50
|
-
const
|
|
51
|
-
return
|
|
52
|
-
<
|
|
53
|
-
post={
|
|
54
|
-
|
|
48
|
+
const { data: post } = useSuspenseQuery(publicPostQuery(slug, loadPost));
|
|
49
|
+
return post ? (
|
|
50
|
+
<PostDetailContent
|
|
51
|
+
post={post}
|
|
52
|
+
loadComments={loadComments}
|
|
55
53
|
submitComment={submitPublicComment}
|
|
56
54
|
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
55
|
/>
|
|
69
56
|
) : (
|
|
70
57
|
renderUnavailable(slug)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import { createFeedbackModule } from "@feedbax/feedback";
|
|
3
|
+
import {
|
|
4
|
+
createChangelogModule,
|
|
5
|
+
createInMemoryChangelogStorage,
|
|
6
|
+
} from "@feedbax/changelog";
|
|
7
|
+
import { createPublicReleaseFeedbackModule } from "./public-release-links";
|
|
8
|
+
|
|
9
|
+
it("links only published local Changelog Entries or safe explicit external release notes", async () => {
|
|
10
|
+
const entry = {
|
|
11
|
+
id: "entry",
|
|
12
|
+
slug: "released-export",
|
|
13
|
+
title: "Export",
|
|
14
|
+
summary: "Public",
|
|
15
|
+
body: "Public",
|
|
16
|
+
date: "2026-10-15" as const,
|
|
17
|
+
labels: [],
|
|
18
|
+
images: [],
|
|
19
|
+
published: true,
|
|
20
|
+
createdAt: new Date(1000),
|
|
21
|
+
updatedAt: new Date(1000),
|
|
22
|
+
};
|
|
23
|
+
const changelog = createChangelogModule({
|
|
24
|
+
storage: createInMemoryChangelogStorage([
|
|
25
|
+
entry,
|
|
26
|
+
{ ...entry, id: "draft", slug: "private-release", published: false },
|
|
27
|
+
]),
|
|
28
|
+
});
|
|
29
|
+
const post = {
|
|
30
|
+
id: "p",
|
|
31
|
+
slug: "export",
|
|
32
|
+
title: "Export",
|
|
33
|
+
description: "Public",
|
|
34
|
+
type: "Feature Request" as const,
|
|
35
|
+
status: "Shipped" as const,
|
|
36
|
+
published: true,
|
|
37
|
+
createdAt: new Date(1000),
|
|
38
|
+
updatedAt: new Date(1000),
|
|
39
|
+
};
|
|
40
|
+
const module = (url: string) =>
|
|
41
|
+
createPublicReleaseFeedbackModule({
|
|
42
|
+
feedback: createFeedbackModule({
|
|
43
|
+
initialItems: [{ ...post, releaseNotesUrl: url }],
|
|
44
|
+
}),
|
|
45
|
+
siteUrl: "https://portal.example",
|
|
46
|
+
changelog,
|
|
47
|
+
});
|
|
48
|
+
expect(
|
|
49
|
+
(await module("/changelog#released-export").getPublicPost(post.slug))
|
|
50
|
+
?.releaseNotesUrl,
|
|
51
|
+
).toBe("/changelog#released-export");
|
|
52
|
+
for (const url of [
|
|
53
|
+
"/changelog#private-release",
|
|
54
|
+
"/internal/private",
|
|
55
|
+
"//evil.example",
|
|
56
|
+
"https://user:password@release.example",
|
|
57
|
+
"javascript:alert(1)",
|
|
58
|
+
"https://portal.example/changelog#private-release",
|
|
59
|
+
"https://github.com/private-team/private-repo/issues/1",
|
|
60
|
+
"https://github.com/private-team/private-repo/pull/2",
|
|
61
|
+
"https://github.com/private-team/private-repo/%69ssues/1",
|
|
62
|
+
"https://github.com./private-team/private-repo/issues/1",
|
|
63
|
+
])
|
|
64
|
+
expect(
|
|
65
|
+
(await module(url).getPublicPost(post.slug))?.releaseNotesUrl,
|
|
66
|
+
).toBeUndefined();
|
|
67
|
+
expect(
|
|
68
|
+
(
|
|
69
|
+
await module("https://example.com/releases/export").getPublicPost(
|
|
70
|
+
post.slug,
|
|
71
|
+
)
|
|
72
|
+
)?.releaseNotesUrl,
|
|
73
|
+
).toBe("https://example.com/releases/export");
|
|
74
|
+
expect(
|
|
75
|
+
(await module("/changelog#private-release").listPublicPosts()).items[0]
|
|
76
|
+
?.releaseNotesUrl,
|
|
77
|
+
).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("checks current public visibility before exposing a GitHub release URL", async () => {
|
|
81
|
+
const post = {
|
|
82
|
+
id: "p",
|
|
83
|
+
slug: "export",
|
|
84
|
+
title: "Export",
|
|
85
|
+
description: "Public",
|
|
86
|
+
type: "Feature Request" as const,
|
|
87
|
+
status: "Shipped" as const,
|
|
88
|
+
published: true,
|
|
89
|
+
createdAt: new Date(1000),
|
|
90
|
+
updatedAt: new Date(1000),
|
|
91
|
+
};
|
|
92
|
+
const module = (owner: string) =>
|
|
93
|
+
createPublicReleaseFeedbackModule({
|
|
94
|
+
feedback: createFeedbackModule({
|
|
95
|
+
initialItems: [
|
|
96
|
+
{
|
|
97
|
+
...post,
|
|
98
|
+
releaseNotesUrl: `https://github.com/${owner}/feedback/releases/tag/v1`,
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
}),
|
|
102
|
+
siteUrl: "https://portal.example",
|
|
103
|
+
request: async (input, init) => {
|
|
104
|
+
expect(String(input)).toBe(
|
|
105
|
+
`https://api.github.com/repos/${owner}/feedback`,
|
|
106
|
+
);
|
|
107
|
+
expect(init?.redirect).toBe("manual");
|
|
108
|
+
expect(new Headers(init?.headers).has("Authorization")).toBe(false);
|
|
109
|
+
return owner === "public-team"
|
|
110
|
+
? Response.json({ id: 123, private: false })
|
|
111
|
+
: new Response(null, { status: 404 });
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
expect(
|
|
115
|
+
(await module("public-team").getPublicPost(post.slug))?.releaseNotesUrl,
|
|
116
|
+
).toBe("https://github.com/public-team/feedback/releases/tag/v1");
|
|
117
|
+
expect(
|
|
118
|
+
(await module("private-team").getPublicPost(post.slug))?.releaseNotesUrl,
|
|
119
|
+
).toBeUndefined();
|
|
120
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { FeedbackModule, PublicPost } from "@feedbax/feedback";
|
|
2
|
+
import type { ChangelogModule } from "@feedbax/changelog";
|
|
3
|
+
|
|
4
|
+
export function createPublicReleaseFeedbackModule(options: {
|
|
5
|
+
feedback: FeedbackModule;
|
|
6
|
+
siteUrl?: string;
|
|
7
|
+
changelog?: ChangelogModule;
|
|
8
|
+
request?: typeof fetch;
|
|
9
|
+
}): FeedbackModule {
|
|
10
|
+
const { feedback } = options;
|
|
11
|
+
let published: Promise<Set<string>> | undefined;
|
|
12
|
+
const publishedSlugs = () =>
|
|
13
|
+
(published ??= (async () => {
|
|
14
|
+
const slugs = new Set<string>();
|
|
15
|
+
if (!options.changelog) return slugs;
|
|
16
|
+
let cursor: string | undefined;
|
|
17
|
+
for (let page = 0; page < 10; page++) {
|
|
18
|
+
const result = await options.changelog.listPublishedEntries({ cursor });
|
|
19
|
+
for (const entry of result.items) slugs.add(entry.slug);
|
|
20
|
+
cursor = result.nextCursor;
|
|
21
|
+
if (!cursor) break;
|
|
22
|
+
}
|
|
23
|
+
return slugs;
|
|
24
|
+
})());
|
|
25
|
+
const resolve = async (value: string) => {
|
|
26
|
+
if (
|
|
27
|
+
/[\\\s]/u.test(value) ||
|
|
28
|
+
Array.from(value).some(
|
|
29
|
+
(character) =>
|
|
30
|
+
character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127,
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
return;
|
|
34
|
+
try {
|
|
35
|
+
const url = new URL(value, options.siteUrl ?? "https://feedbax.invalid");
|
|
36
|
+
if (
|
|
37
|
+
url.protocol !== "https:" ||
|
|
38
|
+
url.username ||
|
|
39
|
+
url.password ||
|
|
40
|
+
value.startsWith("//")
|
|
41
|
+
)
|
|
42
|
+
return;
|
|
43
|
+
const local =
|
|
44
|
+
value.startsWith("/") ||
|
|
45
|
+
(options.siteUrl && url.origin === new URL(options.siteUrl).origin);
|
|
46
|
+
if (!local) {
|
|
47
|
+
const host = url.hostname.toLowerCase().replace(/\.+$/, "");
|
|
48
|
+
if (
|
|
49
|
+
[
|
|
50
|
+
"api.github.com",
|
|
51
|
+
"gist.github.com",
|
|
52
|
+
"raw.githubusercontent.com",
|
|
53
|
+
].includes(host)
|
|
54
|
+
)
|
|
55
|
+
return;
|
|
56
|
+
if (host === "github.com" || host === "www.github.com") {
|
|
57
|
+
const path = decodeURIComponent(url.pathname);
|
|
58
|
+
const release = path.match(
|
|
59
|
+
/^\/([A-Za-z0-9-]+)\/([A-Za-z0-9_.-]+)\/releases(?:\/|$)/,
|
|
60
|
+
);
|
|
61
|
+
if (!release || path.includes("%")) return;
|
|
62
|
+
const response = await (options.request ?? fetch)(
|
|
63
|
+
`https://api.github.com/repos/${release[1]}/${release[2]}`,
|
|
64
|
+
{
|
|
65
|
+
redirect: "manual",
|
|
66
|
+
signal: AbortSignal.timeout(5000),
|
|
67
|
+
headers: {
|
|
68
|
+
Accept: "application/vnd.github+json",
|
|
69
|
+
"User-Agent": "Feedbax",
|
|
70
|
+
"X-GitHub-Api-Version": "2026-03-10",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
if (!response.ok) return;
|
|
75
|
+
const repository: unknown = await response.json();
|
|
76
|
+
if (
|
|
77
|
+
!repository ||
|
|
78
|
+
typeof repository !== "object" ||
|
|
79
|
+
Reflect.get(repository, "private") !== false
|
|
80
|
+
)
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
if (
|
|
86
|
+
url.pathname !== "/changelog" ||
|
|
87
|
+
url.search ||
|
|
88
|
+
!/^#[a-z0-9]+(?:-[a-z0-9]+)*$/.test(url.hash)
|
|
89
|
+
)
|
|
90
|
+
return;
|
|
91
|
+
return (await publishedSlugs()).has(url.hash.slice(1))
|
|
92
|
+
? value
|
|
93
|
+
: undefined;
|
|
94
|
+
} catch {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const project = async <T extends PublicPost>(post: T): Promise<T> => {
|
|
99
|
+
if (!post.releaseNotesUrl) return post;
|
|
100
|
+
const url = await resolve(post.releaseNotesUrl);
|
|
101
|
+
const result = { ...post };
|
|
102
|
+
if (url) result.releaseNotesUrl = url;
|
|
103
|
+
else delete result.releaseNotesUrl;
|
|
104
|
+
return result;
|
|
105
|
+
};
|
|
106
|
+
const roadmap: FeedbackModule["listPublicRoadmapPosts"] = async (query) => {
|
|
107
|
+
const page = await feedback.listPublicRoadmapPosts(query);
|
|
108
|
+
return { ...page, items: await Promise.all(page.items.map(project)) };
|
|
109
|
+
};
|
|
110
|
+
return {
|
|
111
|
+
...feedback,
|
|
112
|
+
async getPublicPost(slug) {
|
|
113
|
+
const post = await feedback.getPublicPost(slug);
|
|
114
|
+
return post ? project(post) : undefined;
|
|
115
|
+
},
|
|
116
|
+
async listPublicPosts(query) {
|
|
117
|
+
const page = await feedback.listPublicPosts(query);
|
|
118
|
+
return { ...page, items: await Promise.all(page.items.map(project)) };
|
|
119
|
+
},
|
|
120
|
+
listPublicRoadmapPosts: roadmap,
|
|
121
|
+
async getPublicPostRoadmap() {
|
|
122
|
+
const [planned, progress, shipped] = await Promise.all([
|
|
123
|
+
roadmap({ status: "Planned" }),
|
|
124
|
+
roadmap({ status: "In Progress" }),
|
|
125
|
+
roadmap({ status: "Shipped" }),
|
|
126
|
+
]);
|
|
127
|
+
return { Planned: planned, "In Progress": progress, Shipped: shipped };
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -128,7 +128,7 @@ describe("/roadmap route", () => {
|
|
|
128
128
|
render(<RouterProvider router={router} />);
|
|
129
129
|
|
|
130
130
|
const loadMore = await screen.findByRole("button", {
|
|
131
|
-
name: "
|
|
131
|
+
name: "View more Planned Posts",
|
|
132
132
|
});
|
|
133
133
|
fireEvent.click(loadMore);
|
|
134
134
|
|
|
@@ -152,7 +152,7 @@ describe("/roadmap route", () => {
|
|
|
152
152
|
).toBeTruthy();
|
|
153
153
|
await waitFor(() =>
|
|
154
154
|
expect(
|
|
155
|
-
screen.queryByRole("button", { name: "
|
|
155
|
+
screen.queryByRole("button", { name: "View more Planned Posts" }),
|
|
156
156
|
).toBeNull(),
|
|
157
157
|
);
|
|
158
158
|
expect(
|
|
@@ -163,6 +163,42 @@ describe("/roadmap route", () => {
|
|
|
163
163
|
).toBe("1 more Planned Post loaded.");
|
|
164
164
|
});
|
|
165
165
|
|
|
166
|
+
it("reveals five cards at a time before requesting another server page", async () => {
|
|
167
|
+
const items = Array.from({ length: 12 }, (_, index) =>
|
|
168
|
+
post(`planned-${index}`, "Planned"),
|
|
169
|
+
);
|
|
170
|
+
const loadMore = vi.fn().mockResolvedValue({
|
|
171
|
+
items: [post("planned-12", "Planned")],
|
|
172
|
+
totalCount: 13,
|
|
173
|
+
});
|
|
174
|
+
render(
|
|
175
|
+
<PublicRoadmapView
|
|
176
|
+
roadmap={{
|
|
177
|
+
Planned: { items, totalCount: 13, nextCursor: "next-page" },
|
|
178
|
+
"In Progress": { items: [], totalCount: 0 },
|
|
179
|
+
Shipped: { items: [], totalCount: 0 },
|
|
180
|
+
}}
|
|
181
|
+
loadMore={loadMore}
|
|
182
|
+
/>,
|
|
183
|
+
);
|
|
184
|
+
expect(screen.getAllByRole("link")).toHaveLength(5);
|
|
185
|
+
const button = screen.getByRole("button", {
|
|
186
|
+
name: "View more Planned Posts",
|
|
187
|
+
});
|
|
188
|
+
fireEvent.click(button);
|
|
189
|
+
expect(screen.getAllByRole("link")).toHaveLength(10);
|
|
190
|
+
expect(loadMore).not.toHaveBeenCalled();
|
|
191
|
+
fireEvent.click(button);
|
|
192
|
+
expect(
|
|
193
|
+
await screen.findByRole("link", { name: /planned-12 / }),
|
|
194
|
+
).toBeTruthy();
|
|
195
|
+
expect(screen.getAllByRole("link")).toHaveLength(13);
|
|
196
|
+
expect(loadMore).toHaveBeenCalledOnce();
|
|
197
|
+
expect(
|
|
198
|
+
screen.queryByRole("button", { name: "View more Planned Posts" }),
|
|
199
|
+
).toBeNull();
|
|
200
|
+
});
|
|
201
|
+
|
|
166
202
|
it("shows one status column at a time behind mobile status tabs", async () => {
|
|
167
203
|
Object.defineProperty(window, "matchMedia", {
|
|
168
204
|
configurable: true,
|
|
@@ -50,6 +50,12 @@ const roadmapGroups = [
|
|
|
50
50
|
] as const;
|
|
51
51
|
|
|
52
52
|
const mobileMediaQuery = "(max-width: 1023px)";
|
|
53
|
+
const previewSize = 5;
|
|
54
|
+
const initialVisible = {
|
|
55
|
+
Planned: previewSize,
|
|
56
|
+
"In Progress": previewSize,
|
|
57
|
+
Shipped: previewSize,
|
|
58
|
+
};
|
|
53
59
|
|
|
54
60
|
export function PublicRoadmapView({
|
|
55
61
|
roadmap,
|
|
@@ -63,6 +69,7 @@ export function PublicRoadmapView({
|
|
|
63
69
|
const mobile = useMediaQuery(mobileMediaQuery);
|
|
64
70
|
const [activeStatus, setActiveStatus] = useState<RoadmapStatus>("Planned");
|
|
65
71
|
const [columns, setColumns] = useState(roadmap);
|
|
72
|
+
const [visible, setVisible] = useState(initialVisible);
|
|
66
73
|
const [loading, setLoading] = useState<Partial<Record<RoadmapStatus, true>>>(
|
|
67
74
|
{},
|
|
68
75
|
);
|
|
@@ -71,7 +78,10 @@ export function PublicRoadmapView({
|
|
|
71
78
|
);
|
|
72
79
|
const [loadStatusMessage, setLoadStatusMessage] = useState("");
|
|
73
80
|
|
|
74
|
-
useEffect(() =>
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
setColumns(roadmap);
|
|
83
|
+
setVisible(initialVisible);
|
|
84
|
+
}, [roadmap]);
|
|
75
85
|
|
|
76
86
|
const visibleGroups = mobile
|
|
77
87
|
? roadmapGroups.filter(({ status }) => status === activeStatus)
|
|
@@ -89,13 +99,26 @@ export function PublicRoadmapView({
|
|
|
89
99
|
return next;
|
|
90
100
|
});
|
|
91
101
|
try {
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
102
|
+
const before = Math.min(
|
|
103
|
+
visible[query.status],
|
|
104
|
+
columns[query.status].items.length,
|
|
105
|
+
);
|
|
106
|
+
const target = before + previewSize;
|
|
107
|
+
let combined = columns[query.status];
|
|
108
|
+
const cursors = new Set<string>();
|
|
109
|
+
do {
|
|
110
|
+
if (!combined.nextCursor || cursors.has(combined.nextCursor)) break;
|
|
111
|
+
cursors.add(combined.nextCursor);
|
|
112
|
+
combined = appendRoadmapPage(
|
|
113
|
+
combined,
|
|
114
|
+
await loadMore({ status: query.status, cursor: combined.nextCursor }),
|
|
115
|
+
);
|
|
116
|
+
} while (combined.items.length < target && combined.nextCursor);
|
|
117
|
+
setColumns((current) => ({ ...current, [query.status]: combined }));
|
|
118
|
+
const count = Math.min(target, combined.items.length) - before;
|
|
119
|
+
setVisible((current) => ({ ...current, [query.status]: before + count }));
|
|
97
120
|
setLoadStatusMessage(
|
|
98
|
-
`${
|
|
121
|
+
`${count} more ${group.heading} ${count === 1 ? "Post" : "Posts"} loaded.`,
|
|
99
122
|
);
|
|
100
123
|
} catch {
|
|
101
124
|
setErrors((current) => ({ ...current, [query.status]: true }));
|
|
@@ -168,7 +191,7 @@ export function PublicRoadmapView({
|
|
|
168
191
|
<RoadmapEmptyState heading={heading} />
|
|
169
192
|
) : (
|
|
170
193
|
<ol className="grid gap-3">
|
|
171
|
-
{column.items.map((item) => (
|
|
194
|
+
{column.items.slice(0, visible[status]).map((item) => (
|
|
172
195
|
<RoadmapItem
|
|
173
196
|
item={item}
|
|
174
197
|
key={item.slug}
|
|
@@ -200,26 +223,36 @@ export function PublicRoadmapView({
|
|
|
200
223
|
</Button>
|
|
201
224
|
</div>
|
|
202
225
|
) : null}
|
|
203
|
-
{column.
|
|
226
|
+
{(column.items.length > visible[status] ||
|
|
227
|
+
(column.nextCursor && loadMore)) &&
|
|
228
|
+
!errors[status] ? (
|
|
204
229
|
<Button
|
|
205
|
-
aria-label={`
|
|
230
|
+
aria-label={`View more ${heading} Posts`}
|
|
206
231
|
className="mt-4 h-10 w-full gap-2"
|
|
207
232
|
disabled={loading[status] === true}
|
|
208
233
|
type="button"
|
|
209
234
|
variant="ghost"
|
|
210
|
-
onClick={() =>
|
|
211
|
-
|
|
212
|
-
status
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
235
|
+
onClick={() => {
|
|
236
|
+
if (
|
|
237
|
+
column.items.length - visible[status] >= previewSize ||
|
|
238
|
+
!column.nextCursor ||
|
|
239
|
+
!loadMore
|
|
240
|
+
) {
|
|
241
|
+
setVisible((current) => ({
|
|
242
|
+
...current,
|
|
243
|
+
[status]: current[status] + previewSize,
|
|
244
|
+
}));
|
|
245
|
+
} else if (column.nextCursor) {
|
|
246
|
+
void loadNextPage({ status, cursor: column.nextCursor });
|
|
247
|
+
}
|
|
248
|
+
}}
|
|
216
249
|
>
|
|
217
250
|
{loading[status] ? (
|
|
218
251
|
<>
|
|
219
252
|
<Spinner aria-hidden="true" /> Loading…
|
|
220
253
|
</>
|
|
221
254
|
) : (
|
|
222
|
-
"
|
|
255
|
+
"View more"
|
|
223
256
|
)}
|
|
224
257
|
</Button>
|
|
225
258
|
) : null}
|
|
@@ -306,12 +339,12 @@ function RoadmapIntro() {
|
|
|
306
339
|
return (
|
|
307
340
|
<section aria-labelledby="roadmap-heading">
|
|
308
341
|
<h1
|
|
309
|
-
className="text-
|
|
342
|
+
className="text-2xl font-semibold tracking-tight"
|
|
310
343
|
id="roadmap-heading"
|
|
311
344
|
>
|
|
312
345
|
Roadmap
|
|
313
346
|
</h1>
|
|
314
|
-
<p className="mt-2 text-
|
|
347
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
315
348
|
What we’re planning, building, and shipping.
|
|
316
349
|
</p>
|
|
317
350
|
</section>
|
|
@@ -342,15 +375,15 @@ function RoadmapItem({ item, masked }: { item: PublicPost; masked: boolean }) {
|
|
|
342
375
|
contextual={masked}
|
|
343
376
|
className="group block border bg-background/30 transition-colors duration-150 hover:bg-muted/30"
|
|
344
377
|
>
|
|
345
|
-
<article className="p-
|
|
346
|
-
<h3 className="text-
|
|
378
|
+
<article className="flex h-40 flex-col p-4">
|
|
379
|
+
<h3 className="line-clamp-2 min-h-10 text-sm font-medium leading-5 group-hover:text-foreground">
|
|
347
380
|
{item.title}
|
|
348
381
|
</h3>
|
|
349
|
-
<p className="mt-2 text-sm leading-
|
|
382
|
+
<p className="mt-2 line-clamp-2 min-h-10 text-sm leading-5 text-muted-foreground">
|
|
350
383
|
{item.description}
|
|
351
384
|
</p>
|
|
352
385
|
<div
|
|
353
|
-
className="mt-
|
|
386
|
+
className="mt-auto flex items-center gap-3 pt-3 text-xs text-muted-foreground"
|
|
354
387
|
role="group"
|
|
355
388
|
aria-label="Post metadata"
|
|
356
389
|
>
|
|
@@ -365,6 +365,32 @@ describe("command palette", () => {
|
|
|
365
365
|
await waitFor(() => expect(history.location.pathname).toBe("/roadmap"));
|
|
366
366
|
});
|
|
367
367
|
|
|
368
|
+
it("keeps previous search results visible while a new term is loading", async () => {
|
|
369
|
+
let finish: ((page: PublicPostPage) => void) | undefined;
|
|
370
|
+
renderShell("/", {
|
|
371
|
+
searchPosts: (term) =>
|
|
372
|
+
term === "keyboard"
|
|
373
|
+
? Promise.resolve({ items: [palettePost()] })
|
|
374
|
+
: new Promise((resolve) => {
|
|
375
|
+
finish = resolve;
|
|
376
|
+
}),
|
|
377
|
+
});
|
|
378
|
+
await screen.findByRole("link", { name: "Feedback" });
|
|
379
|
+
fireEvent.keyDown(window, { key: "k", metaKey: true });
|
|
380
|
+
const input = await screen.findByPlaceholderText("Search feedback…");
|
|
381
|
+
fireEvent.change(input, { target: { value: "keyboard" } });
|
|
382
|
+
await screen.findByText("Keyboard-first search");
|
|
383
|
+
fireEvent.change(input, { target: { value: "next search" } });
|
|
384
|
+
await waitFor(() => expect(finish).toBeTypeOf("function"));
|
|
385
|
+
expect(screen.getByText("Keyboard-first search")).toBeTruthy();
|
|
386
|
+
finish?.({ items: [] });
|
|
387
|
+
await waitFor(() =>
|
|
388
|
+
expect(
|
|
389
|
+
screen.getByRole("status", { name: "Search status" }).textContent,
|
|
390
|
+
).toBe("No Posts match this search."),
|
|
391
|
+
);
|
|
392
|
+
});
|
|
393
|
+
|
|
368
394
|
it("shows an empty state when no Posts match", async () => {
|
|
369
395
|
renderShell("/");
|
|
370
396
|
|