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
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
3
|
+
import {
|
|
4
|
+
cleanup,
|
|
5
|
+
fireEvent,
|
|
6
|
+
render,
|
|
7
|
+
screen,
|
|
8
|
+
waitFor,
|
|
9
|
+
} from "@testing-library/react";
|
|
10
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
11
|
+
import { PostDetailContent } from "./post-detail-content";
|
|
12
|
+
import { saveDeviceProfile } from "./browser-post-state";
|
|
13
|
+
|
|
14
|
+
const post = {
|
|
15
|
+
slug: "comment-cache",
|
|
16
|
+
title: "Comment cache",
|
|
17
|
+
description: "Feedback",
|
|
18
|
+
type: "Feature Request" as const,
|
|
19
|
+
status: "Planned" as const,
|
|
20
|
+
createdAt: new Date(),
|
|
21
|
+
updatedAt: new Date(),
|
|
22
|
+
};
|
|
23
|
+
const author = { kind: "participant" as const, displayName: "Ama" };
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
cleanup();
|
|
26
|
+
localStorage.clear();
|
|
27
|
+
vi.restoreAllMocks();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it.each(["edit", "delete"] as const)(
|
|
31
|
+
"preserves a confirmed Comment %s when another page arrives",
|
|
32
|
+
async (action) => {
|
|
33
|
+
saveDeviceProfile(localStorage, { name: "Ama", email: "ama@example.com" });
|
|
34
|
+
localStorage.setItem(
|
|
35
|
+
"feedbax:comment-capabilities",
|
|
36
|
+
JSON.stringify({
|
|
37
|
+
"comment-1": {
|
|
38
|
+
capability: "capability",
|
|
39
|
+
expiresAt: Date.now() + 60000,
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
let state: "original" | "edit" | "delete" = "original";
|
|
44
|
+
const loadComments = vi.fn(async (_slug: string, cursor?: string) =>
|
|
45
|
+
cursor
|
|
46
|
+
? {
|
|
47
|
+
items: [
|
|
48
|
+
{
|
|
49
|
+
id: "thread-2",
|
|
50
|
+
comments: [
|
|
51
|
+
{
|
|
52
|
+
id: "comment-2",
|
|
53
|
+
body: "Second page",
|
|
54
|
+
author,
|
|
55
|
+
createdAt: new Date(),
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
}
|
|
61
|
+
: {
|
|
62
|
+
items:
|
|
63
|
+
state === "delete"
|
|
64
|
+
? []
|
|
65
|
+
: [
|
|
66
|
+
{
|
|
67
|
+
id: "thread-1",
|
|
68
|
+
comments: [
|
|
69
|
+
{
|
|
70
|
+
id: "comment-1",
|
|
71
|
+
body:
|
|
72
|
+
state === "edit"
|
|
73
|
+
? "Corrected message"
|
|
74
|
+
: "Original message",
|
|
75
|
+
author,
|
|
76
|
+
createdAt: new Date(),
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
nextCursor: "next",
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
const mutateComment = vi.fn(async () => {
|
|
85
|
+
state = action;
|
|
86
|
+
});
|
|
87
|
+
vi.spyOn(window, "confirm").mockReturnValue(true);
|
|
88
|
+
const client = new QueryClient({
|
|
89
|
+
defaultOptions: { queries: { retry: false } },
|
|
90
|
+
});
|
|
91
|
+
render(
|
|
92
|
+
<QueryClientProvider client={client}>
|
|
93
|
+
<PostDetailContent
|
|
94
|
+
post={post}
|
|
95
|
+
loadComments={loadComments}
|
|
96
|
+
mutateComment={mutateComment}
|
|
97
|
+
/>
|
|
98
|
+
</QueryClientProvider>,
|
|
99
|
+
);
|
|
100
|
+
expect(await screen.findByText("Original message")).toBeTruthy();
|
|
101
|
+
fireEvent.click(
|
|
102
|
+
await screen.findByRole("button", {
|
|
103
|
+
name: action === "edit" ? "Edit" : "Delete",
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
if (action === "edit") {
|
|
107
|
+
fireEvent.change(screen.getByRole("textbox", { name: "Comment text" }), {
|
|
108
|
+
target: { value: "Corrected message" },
|
|
109
|
+
});
|
|
110
|
+
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
111
|
+
}
|
|
112
|
+
await waitFor(() => expect(mutateComment).toHaveBeenCalledOnce());
|
|
113
|
+
await waitFor(() =>
|
|
114
|
+
expect(screen.queryByText("Original message")).toBeNull(),
|
|
115
|
+
);
|
|
116
|
+
const more = await screen.findByRole("button", {
|
|
117
|
+
name: "Load more",
|
|
118
|
+
});
|
|
119
|
+
await waitFor(() => expect(more.hasAttribute("disabled")).toBe(false));
|
|
120
|
+
fireEvent.click(more);
|
|
121
|
+
expect(await screen.findByText("Second page")).toBeTruthy();
|
|
122
|
+
expect(screen.queryByText("Original message")).toBeNull();
|
|
123
|
+
expect(screen.queryAllByText("Corrected message")).toHaveLength(
|
|
124
|
+
action === "edit" ? 1 : 0,
|
|
125
|
+
);
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
it("removes a Comment resolved in Notion when the shared query refreshes", async () => {
|
|
130
|
+
let visible = true;
|
|
131
|
+
const loadComments = vi.fn(async () => ({
|
|
132
|
+
items: visible
|
|
133
|
+
? [
|
|
134
|
+
{
|
|
135
|
+
id: "thread-1",
|
|
136
|
+
comments: [
|
|
137
|
+
{
|
|
138
|
+
id: "comment-1",
|
|
139
|
+
body: "Resolved in Notion",
|
|
140
|
+
author,
|
|
141
|
+
createdAt: new Date(),
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
]
|
|
146
|
+
: [],
|
|
147
|
+
}));
|
|
148
|
+
const client = new QueryClient({
|
|
149
|
+
defaultOptions: { queries: { retry: false } },
|
|
150
|
+
});
|
|
151
|
+
render(
|
|
152
|
+
<QueryClientProvider client={client}>
|
|
153
|
+
<PostDetailContent post={post} loadComments={loadComments} />
|
|
154
|
+
</QueryClientProvider>,
|
|
155
|
+
);
|
|
156
|
+
expect(await screen.findByText("Resolved in Notion")).toBeTruthy();
|
|
157
|
+
visible = false;
|
|
158
|
+
void client.invalidateQueries({
|
|
159
|
+
queryKey: ["public-post-comments", post.slug],
|
|
160
|
+
});
|
|
161
|
+
await waitFor(() =>
|
|
162
|
+
expect(screen.queryByText("Resolved in Notion")).toBeNull(),
|
|
163
|
+
);
|
|
164
|
+
expect(screen.getByText("No comments yet")).toBeTruthy();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("keeps private edit capabilities out of the shared public Comment cache", async () => {
|
|
168
|
+
saveDeviceProfile(localStorage, { name: "Ama", email: "ama@example.com" });
|
|
169
|
+
const confirmed = {
|
|
170
|
+
id: "comment-1",
|
|
171
|
+
discussionId: "thread-1",
|
|
172
|
+
body: "Public message",
|
|
173
|
+
author,
|
|
174
|
+
createdAt: new Date(),
|
|
175
|
+
commentCapability: "private-edit-capability",
|
|
176
|
+
commentCapabilityExpiresAt: Date.now() + 60000,
|
|
177
|
+
};
|
|
178
|
+
let submitted = false;
|
|
179
|
+
const loadComments = vi.fn(async () => ({
|
|
180
|
+
items: submitted
|
|
181
|
+
? [
|
|
182
|
+
{
|
|
183
|
+
id: "thread-1",
|
|
184
|
+
comments: [
|
|
185
|
+
{
|
|
186
|
+
id: confirmed.id,
|
|
187
|
+
body: confirmed.body,
|
|
188
|
+
author,
|
|
189
|
+
createdAt: confirmed.createdAt,
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
]
|
|
194
|
+
: [],
|
|
195
|
+
}));
|
|
196
|
+
const client = new QueryClient({
|
|
197
|
+
defaultOptions: { queries: { retry: false } },
|
|
198
|
+
});
|
|
199
|
+
const snapshots: string[] = [];
|
|
200
|
+
const unsubscribe = client.getQueryCache().subscribe((event) => {
|
|
201
|
+
snapshots.push(JSON.stringify(event.query.state.data));
|
|
202
|
+
});
|
|
203
|
+
render(
|
|
204
|
+
<QueryClientProvider client={client}>
|
|
205
|
+
<PostDetailContent
|
|
206
|
+
post={post}
|
|
207
|
+
loadComments={loadComments}
|
|
208
|
+
submitComment={async () => {
|
|
209
|
+
submitted = true;
|
|
210
|
+
return confirmed;
|
|
211
|
+
}}
|
|
212
|
+
/>
|
|
213
|
+
</QueryClientProvider>,
|
|
214
|
+
);
|
|
215
|
+
fireEvent.change(await screen.findByLabelText("Add a comment"), {
|
|
216
|
+
target: { value: confirmed.body },
|
|
217
|
+
});
|
|
218
|
+
fireEvent.click(screen.getByRole("button", { name: "Post comment" }));
|
|
219
|
+
expect(await screen.findByText(confirmed.body)).toBeTruthy();
|
|
220
|
+
await waitFor(() =>
|
|
221
|
+
expect(localStorage.getItem("feedbax:comment-capabilities")).toContain(
|
|
222
|
+
confirmed.commentCapability,
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
const cached = JSON.stringify(
|
|
226
|
+
client.getQueryData(["public-post-comments", post.slug]),
|
|
227
|
+
);
|
|
228
|
+
expect(cached).toContain(confirmed.body);
|
|
229
|
+
expect(cached).not.toContain("commentCapability");
|
|
230
|
+
expect(cached).not.toContain(confirmed.commentCapability);
|
|
231
|
+
expect(snapshots.join("\n")).not.toContain(confirmed.commentCapability);
|
|
232
|
+
unsubscribe();
|
|
233
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { CommentThreadPage, CreatedComment } from "@feedbax/feedback";
|
|
2
|
+
import { type ComponentProps, useMemo } from "react";
|
|
3
|
+
import {
|
|
4
|
+
type InfiniteData,
|
|
5
|
+
useInfiniteQuery,
|
|
6
|
+
useQueryClient,
|
|
7
|
+
} from "@tanstack/react-query";
|
|
8
|
+
import { mergeCommentThreads } from "./comment-thread-page";
|
|
9
|
+
import { PublicPostDetail } from "./public-post-detail";
|
|
10
|
+
import {
|
|
11
|
+
publicPostCommentsQuery,
|
|
12
|
+
type FetchPostComments,
|
|
13
|
+
} from "./post-detail-query";
|
|
14
|
+
import feedbax from "./feedbax";
|
|
15
|
+
|
|
16
|
+
type Props = Omit<
|
|
17
|
+
ComponentProps<typeof PublicPostDetail>,
|
|
18
|
+
"comments" | "loadMore"
|
|
19
|
+
> & { loadComments?: FetchPostComments };
|
|
20
|
+
type Pages = InfiniteData<CommentThreadPage, string | undefined>;
|
|
21
|
+
const emptyComments: FetchPostComments = async () => ({ items: [] });
|
|
22
|
+
|
|
23
|
+
export function PostDetailContent(props: Props) {
|
|
24
|
+
return <PostDiscussion key={props.post.slug} {...props} />;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function PostDiscussion({ loadComments, ...props }: Props) {
|
|
28
|
+
const client = useQueryClient();
|
|
29
|
+
const options = publicPostCommentsQuery(
|
|
30
|
+
props.post.slug,
|
|
31
|
+
loadComments ?? emptyComments,
|
|
32
|
+
);
|
|
33
|
+
const query = useInfiniteQuery({
|
|
34
|
+
...options,
|
|
35
|
+
enabled:
|
|
36
|
+
(props.features ?? feedbax.features).comments && Boolean(loadComments),
|
|
37
|
+
});
|
|
38
|
+
const comments = useMemo(
|
|
39
|
+
() => ({
|
|
40
|
+
items:
|
|
41
|
+
query.data?.pages.reduce(
|
|
42
|
+
(current, page) => mergeCommentThreads(current, page.items),
|
|
43
|
+
[] as CommentThreadPage["items"],
|
|
44
|
+
) ?? [],
|
|
45
|
+
}),
|
|
46
|
+
[query.data],
|
|
47
|
+
);
|
|
48
|
+
function updatePages(update: (pages: Pages) => Pages) {
|
|
49
|
+
client.setQueryData<Pages>(options.queryKey, (current) =>
|
|
50
|
+
update(current ?? { pages: [{ items: [] }], pageParams: [undefined] }),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return (
|
|
54
|
+
<PublicPostDetail
|
|
55
|
+
{...props}
|
|
56
|
+
comments={comments}
|
|
57
|
+
commentsLoading={query.isFetching}
|
|
58
|
+
commentsLoadError={query.isError}
|
|
59
|
+
retryComments={() => {
|
|
60
|
+
if (query.isFetchNextPageError) void query.fetchNextPage();
|
|
61
|
+
else void query.refetch();
|
|
62
|
+
}}
|
|
63
|
+
loadMore={
|
|
64
|
+
query.hasNextPage && !query.isError
|
|
65
|
+
? async () => {
|
|
66
|
+
await query.fetchNextPage();
|
|
67
|
+
}
|
|
68
|
+
: undefined
|
|
69
|
+
}
|
|
70
|
+
submitComment={
|
|
71
|
+
props.submitComment
|
|
72
|
+
? async (input) => {
|
|
73
|
+
await client.cancelQueries({ queryKey: options.queryKey });
|
|
74
|
+
try {
|
|
75
|
+
const confirmed = await props.submitComment!(input);
|
|
76
|
+
updatePages((current) => upsertComment(current, confirmed));
|
|
77
|
+
return confirmed;
|
|
78
|
+
} finally {
|
|
79
|
+
void client.invalidateQueries({ queryKey: options.queryKey });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
: undefined
|
|
83
|
+
}
|
|
84
|
+
mutateComment={
|
|
85
|
+
props.mutateComment
|
|
86
|
+
? async (input) => {
|
|
87
|
+
await client.cancelQueries({ queryKey: options.queryKey });
|
|
88
|
+
try {
|
|
89
|
+
const result = await props.mutateComment!(input);
|
|
90
|
+
updatePages((current) => ({
|
|
91
|
+
...current,
|
|
92
|
+
pages: current.pages.map((page) => ({
|
|
93
|
+
...page,
|
|
94
|
+
items: page.items
|
|
95
|
+
.map((thread) => ({
|
|
96
|
+
...thread,
|
|
97
|
+
comments: thread.comments.flatMap((comment) =>
|
|
98
|
+
comment.id !== input.commentId
|
|
99
|
+
? [comment]
|
|
100
|
+
: input.action === "delete"
|
|
101
|
+
? []
|
|
102
|
+
: [{ ...comment, body: input.body! }],
|
|
103
|
+
),
|
|
104
|
+
}))
|
|
105
|
+
.filter((thread) => thread.comments.length > 0),
|
|
106
|
+
})),
|
|
107
|
+
}));
|
|
108
|
+
return result;
|
|
109
|
+
} finally {
|
|
110
|
+
void client.invalidateQueries({ queryKey: options.queryKey });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
: undefined
|
|
114
|
+
}
|
|
115
|
+
/>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function upsertComment(current: Pages, comment: CreatedComment): Pages {
|
|
120
|
+
// Cache only the public projection; browser edit capabilities stay outside it.
|
|
121
|
+
const publicComment = {
|
|
122
|
+
id: comment.id,
|
|
123
|
+
body: comment.body,
|
|
124
|
+
...(comment.images?.length
|
|
125
|
+
? {
|
|
126
|
+
images: comment.images.map((image) => ({
|
|
127
|
+
id: image.id,
|
|
128
|
+
src: image.src,
|
|
129
|
+
contentType: image.contentType,
|
|
130
|
+
bytes: image.bytes,
|
|
131
|
+
})),
|
|
132
|
+
}
|
|
133
|
+
: {}),
|
|
134
|
+
createdAt: comment.createdAt,
|
|
135
|
+
author:
|
|
136
|
+
comment.author.kind === "participant"
|
|
137
|
+
? {
|
|
138
|
+
kind: "participant" as const,
|
|
139
|
+
displayName: comment.author.displayName,
|
|
140
|
+
...(comment.author.verified ? { verified: true } : {}),
|
|
141
|
+
}
|
|
142
|
+
: {
|
|
143
|
+
kind: "product-team" as const,
|
|
144
|
+
displayName: comment.author.displayName,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
const thread = { id: comment.discussionId, comments: [publicComment] };
|
|
148
|
+
const present = current.pages.some((page) =>
|
|
149
|
+
page.items.some((item) => item.id === thread.id),
|
|
150
|
+
);
|
|
151
|
+
return {
|
|
152
|
+
...current,
|
|
153
|
+
pages: current.pages.map((page, index) => ({
|
|
154
|
+
...page,
|
|
155
|
+
items:
|
|
156
|
+
(index === 0 && !present) ||
|
|
157
|
+
page.items.some((item) => item.id === thread.id)
|
|
158
|
+
? mergeCommentThreads(page.items, [thread])
|
|
159
|
+
: page.items,
|
|
160
|
+
})),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { CommentThreadPage, PublicPost } from "@feedbax/feedback";
|
|
2
|
+
import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query";
|
|
3
|
+
|
|
4
|
+
export const publicPostQueryKey = ["public-post"] as const;
|
|
5
|
+
export type FetchPostComments = (
|
|
6
|
+
slug: string,
|
|
7
|
+
cursor?: string,
|
|
8
|
+
) => Promise<CommentThreadPage>;
|
|
9
|
+
|
|
10
|
+
export function publicPostQuery(
|
|
11
|
+
slug: string,
|
|
12
|
+
loadPost: (slug: string) => Promise<PublicPost | undefined>,
|
|
13
|
+
) {
|
|
14
|
+
return queryOptions({
|
|
15
|
+
queryKey: [...publicPostQueryKey, slug],
|
|
16
|
+
queryFn: async () => (await loadPost(slug)) ?? null,
|
|
17
|
+
staleTime: 30_000,
|
|
18
|
+
retry: false,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function publicPostCommentsQuery(
|
|
23
|
+
slug: string,
|
|
24
|
+
loadComments: FetchPostComments,
|
|
25
|
+
) {
|
|
26
|
+
return infiniteQueryOptions({
|
|
27
|
+
queryKey: ["public-post-comments", slug],
|
|
28
|
+
initialPageParam: undefined as string | undefined,
|
|
29
|
+
queryFn: ({ pageParam }) => loadComments(slug, pageParam),
|
|
30
|
+
getNextPageParam: (page) => page.nextCursor,
|
|
31
|
+
staleTime: 0,
|
|
32
|
+
retry: false,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -142,7 +142,7 @@ function PostResultRow({
|
|
|
142
142
|
<span className="block text-base font-medium text-foreground sm:text-lg">
|
|
143
143
|
{post.title}
|
|
144
144
|
</span>
|
|
145
|
-
<span className="mt-1
|
|
145
|
+
<span className="mt-1 line-clamp-2 break-words text-sm leading-relaxed text-muted-foreground">
|
|
146
146
|
{post.description}
|
|
147
147
|
</span>
|
|
148
148
|
<span
|
|
@@ -75,7 +75,7 @@ describe("public portal cache policy", () => {
|
|
|
75
75
|
);
|
|
76
76
|
|
|
77
77
|
expect(first?.url).toBe(
|
|
78
|
-
"https://feedback.example.com/?types=%5B%22Bug+Report%22%5D&statuses=%5B%22New%22%5D&pageSize=25&sort=top&schema=
|
|
78
|
+
"https://feedback.example.com/?types=%5B%22Bug+Report%22%5D&statuses=%5B%22New%22%5D&pageSize=25&sort=top&schema=2",
|
|
79
79
|
);
|
|
80
80
|
expect(second?.url).toBe(first?.url);
|
|
81
81
|
});
|
|
@@ -94,12 +94,12 @@ describe("public portal cache policy", () => {
|
|
|
94
94
|
new Request("https://feedback.example.com/roadmap"),
|
|
95
95
|
)?.url,
|
|
96
96
|
).toBe(
|
|
97
|
-
"https://feedback.example.com/roadmap?sort=updated-at-desc&schema=
|
|
97
|
+
"https://feedback.example.com/roadmap?sort=updated-at-desc&schema=2",
|
|
98
98
|
);
|
|
99
99
|
expect(
|
|
100
100
|
canonicalPublicRequest(new Request("https://feedback.example.com/p/slug"))
|
|
101
101
|
?.url,
|
|
102
|
-
).toBe("https://feedback.example.com/p/slug?schema=
|
|
102
|
+
).toBe("https://feedback.example.com/p/slug?schema=2");
|
|
103
103
|
});
|
|
104
104
|
});
|
|
105
105
|
|
|
@@ -108,7 +108,7 @@ describe("feed URL normalization", () => {
|
|
|
108
108
|
expect(
|
|
109
109
|
canonicalPublicRequest(
|
|
110
110
|
new Request(
|
|
111
|
-
"https://feedback.example.com/?pageSize=25&sort=new&schema=
|
|
111
|
+
"https://feedback.example.com/?pageSize=25&sort=new&schema=2",
|
|
112
112
|
),
|
|
113
113
|
),
|
|
114
114
|
).toBeUndefined();
|
|
@@ -139,3 +139,10 @@ describe("feed URL normalization", () => {
|
|
|
139
139
|
expect(canonicalPublicRequest(first!)).toBeUndefined();
|
|
140
140
|
});
|
|
141
141
|
});
|
|
142
|
+
|
|
143
|
+
it("moves pre-0.2.1 cached projections to a new schema identity", () => {
|
|
144
|
+
const request = canonicalPublicRequest(
|
|
145
|
+
new Request("https://feedback.example.com/?pageSize=25&sort=top&schema=1"),
|
|
146
|
+
);
|
|
147
|
+
expect(new URL(request!.url).searchParams.get("schema")).toBe("2");
|
|
148
|
+
});
|
|
@@ -15,7 +15,7 @@ const BROWSER_CACHE_CONTROL = "public, max-age=30";
|
|
|
15
15
|
const EDGE_CACHE_CONTROL =
|
|
16
16
|
"public, max-age=120, stale-while-revalidate=600, stale-if-error=86400";
|
|
17
17
|
const CHANGELOG_EDGE_CACHE_CONTROL = "public, max-age=30";
|
|
18
|
-
const PUBLIC_PROJECTION_SCHEMA_VERSION = "
|
|
18
|
+
const PUBLIC_PROJECTION_SCHEMA_VERSION = "2";
|
|
19
19
|
const PUBLIC_FEEDBACK_PAGE_SIZE = "25";
|
|
20
20
|
const PUBLIC_ROADMAP_SORT = "updated-at-desc";
|
|
21
21
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PublicPostQuery, PublicPostPage } from "@feedbax/feedback";
|
|
2
2
|
import type { PortalFeatures } from "@feedbax/config";
|
|
3
|
-
import
|
|
3
|
+
import Loader from "./components/loader";
|
|
4
4
|
import { useAuthorizedDraftPosts } from "./draft-post-list";
|
|
5
5
|
import {
|
|
6
6
|
BoardNavigation,
|
|
@@ -39,11 +39,11 @@ export function PublicPostIndex({
|
|
|
39
39
|
<section aria-labelledby="feedback-heading">
|
|
40
40
|
<h1
|
|
41
41
|
id="feedback-heading"
|
|
42
|
-
className="text-
|
|
42
|
+
className="text-2xl font-semibold tracking-tight"
|
|
43
43
|
>
|
|
44
44
|
Feedback
|
|
45
45
|
</h1>
|
|
46
|
-
<p className="mt-2 text-
|
|
46
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
47
47
|
{features.voting
|
|
48
48
|
? "Share ideas and vote on what matters."
|
|
49
49
|
: "Share ideas and follow public progress."}
|
|
@@ -58,10 +58,11 @@ export function PublicPostIndex({
|
|
|
58
58
|
features={features}
|
|
59
59
|
/>
|
|
60
60
|
</div>
|
|
61
|
-
<section
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
<section
|
|
62
|
+
className="mt-8"
|
|
63
|
+
aria-live="polite"
|
|
64
|
+
aria-label="Feedback posts"
|
|
65
|
+
>
|
|
65
66
|
<PostResults
|
|
66
67
|
drafts={drafts}
|
|
67
68
|
initialItems={page.items}
|
|
@@ -89,36 +90,5 @@ export function PublicPostIndex({
|
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
export function PublicPostFeedSkeleton() {
|
|
92
|
-
return
|
|
93
|
-
<main
|
|
94
|
-
className="feedback-index feedback-feed-skeleton"
|
|
95
|
-
aria-label="Loading Posts"
|
|
96
|
-
aria-busy="true"
|
|
97
|
-
>
|
|
98
|
-
<section className="feedback-intro" aria-hidden="true">
|
|
99
|
-
<Skeleton className="feedback-skeleton-heading" />
|
|
100
|
-
<Skeleton className="feedback-skeleton-deck" />
|
|
101
|
-
</section>
|
|
102
|
-
<div className="post-controls" aria-hidden="true">
|
|
103
|
-
<Skeleton className="feedback-skeleton-control" />
|
|
104
|
-
<Skeleton className="feedback-skeleton-control" />
|
|
105
|
-
<Skeleton className="feedback-skeleton-control" />
|
|
106
|
-
</div>
|
|
107
|
-
<section className="feedback-results" aria-hidden="true">
|
|
108
|
-
<Skeleton className="feedback-skeleton-section-heading" />
|
|
109
|
-
<ol className="feedback-list">
|
|
110
|
-
{Array.from({ length: 3 }, (_, index) => (
|
|
111
|
-
<li key={index} data-post-skeleton-row>
|
|
112
|
-
<div className="feedback-skeleton-row">
|
|
113
|
-
<Skeleton className="feedback-skeleton-meta" />
|
|
114
|
-
<Skeleton className="feedback-skeleton-title" />
|
|
115
|
-
<Skeleton className="feedback-skeleton-copy" />
|
|
116
|
-
</div>
|
|
117
|
-
</li>
|
|
118
|
-
))}
|
|
119
|
-
</ol>
|
|
120
|
-
</section>
|
|
121
|
-
<span className="sr-only">Loading Posts…</span>
|
|
122
|
-
</main>
|
|
123
|
-
);
|
|
93
|
+
return <Loader label="Loading Posts" />;
|
|
124
94
|
}
|
|
@@ -16,6 +16,71 @@ import { authorizedDraftPostsQueryKey } from "./authorized-draft-query";
|
|
|
16
16
|
afterEach(cleanup);
|
|
17
17
|
|
|
18
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
|
+
|
|
19
84
|
it("loads 25 Published Posts and an opaque next-page cursor", async () => {
|
|
20
85
|
const feedback = createFeedbackModule({
|
|
21
86
|
initialItems: Array.from({ length: 26 }, (_, index) => ({
|
|
@@ -30,7 +30,18 @@ export async function loadPublicPostPage({
|
|
|
30
30
|
feedback: Pick<FeedbackModule, "listPublicPosts">;
|
|
31
31
|
search: Record<string, unknown>;
|
|
32
32
|
}): Promise<PublicPostPage> {
|
|
33
|
-
|
|
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
|
+
});
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
export function publicPostSearch(
|