create-feedbax 0.2.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/dist/cli.js +2042 -16
- package/dist/index.js +1997 -5
- package/package.json +1 -1
- package/template/.feedbax-template.sha256 +1 -1
- package/template/AGENTS.md +13 -0
- package/template/CONNECTED.md +404 -0
- package/template/README.md +58 -0
- package/template/components.json +1 -1
- package/template/eslint.config.mjs +23 -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 +9 -5
- package/template/src/authorized-draft-post.tsx +1 -0
- package/template/src/changelog-page.tsx +63 -54
- package/template/src/cloudflare-workers.d.ts +6 -0
- package/template/src/comment-creation.test.tsx +42 -1
- 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 +108 -0
- package/template/src/components/device-profile-provider.tsx +41 -26
- package/template/src/components/follow-post-button.tsx +89 -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/profile-menu.tsx +30 -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/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/feedback-cache-invalidation.ts +1 -0
- package/template/src/feedback-runtime.ts +79 -12
- package/template/src/feedbax/config/index.test.ts +28 -0
- package/template/src/feedbax/config/index.ts +93 -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/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.ts +55 -1
- package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
- package/template/src/feedbax/feedback/participation.test.ts +653 -0
- package/template/src/feedbax/feedback/participation.ts +502 -0
- package/template/src/images-runtime.test.ts +133 -0
- package/template/src/images-runtime.ts +125 -0
- package/template/src/index.css +97 -440
- package/template/src/lazy-post-detail-overlay.tsx +13 -2
- 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 +24 -5
- package/template/src/portal-feedback-form.tsx +18 -3
- 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 +10 -0
- package/template/src/portal-server-entry.ts +4 -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-post-detail-skeleton.tsx +54 -0
- package/template/src/public-post-detail.tsx +298 -208
- package/template/src/public-post-route.test.tsx +39 -3
- package/template/src/public-post-route.tsx +15 -28
- 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 +49 -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/submit.tsx +1 -1
- package/template/src/safe-public-failure.ts +3 -1
- package/template/src/server.ts +41 -1
- package/template/src/trusted-feedback-handler.test.ts +1 -1
- package/template/src/vote-toggle.tsx +42 -9
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ImageAttachments } from "./components/image-attachments";
|
|
1
2
|
import { configuredTurnstileSiteKey } from "./turnstile-configuration";
|
|
2
3
|
import type { Post, PostType, SubmittedPost } from "@feedbax/feedback";
|
|
3
4
|
import { useForm } from "@tanstack/react-form";
|
|
@@ -120,6 +121,10 @@ export function PortalFeedbackForm({
|
|
|
120
121
|
}) {
|
|
121
122
|
const [draft, setDraft] = useState<StoredDraft | undefined>(initialDraft);
|
|
122
123
|
const [pending, setPending] = useState(false);
|
|
124
|
+
const [images, setImages] = useState<
|
|
125
|
+
import("@feedbax/feedback").ImageAttachment[]
|
|
126
|
+
>([]);
|
|
127
|
+
const [uploading, setUploading] = useState(false);
|
|
123
128
|
const [message, setMessage] = useState<string>();
|
|
124
129
|
const [confirmingWithdrawal, setConfirmingWithdrawal] = useState(false);
|
|
125
130
|
const turnstileToken = useRef<string | undefined>(undefined);
|
|
@@ -161,6 +166,9 @@ export function PortalFeedbackForm({
|
|
|
161
166
|
const result = await mutations.submitPost({
|
|
162
167
|
data: {
|
|
163
168
|
...values,
|
|
169
|
+
...(images.length
|
|
170
|
+
? { imageIds: images.map((image) => image.id) }
|
|
171
|
+
: {}),
|
|
164
172
|
...(turnstileToken.current
|
|
165
173
|
? { turnstileToken: turnstileToken.current }
|
|
166
174
|
: {}),
|
|
@@ -408,9 +416,16 @@ export function PortalFeedbackForm({
|
|
|
408
416
|
);
|
|
409
417
|
}}
|
|
410
418
|
/>
|
|
419
|
+
{!draft ? (
|
|
420
|
+
<ImageAttachments
|
|
421
|
+
value={images}
|
|
422
|
+
onChange={setImages}
|
|
423
|
+
onUploadingChange={setUploading}
|
|
424
|
+
/>
|
|
425
|
+
) : null}
|
|
411
426
|
</FieldGroup>
|
|
412
427
|
<div className="feedback-submit-actions">
|
|
413
|
-
<Button type="submit" disabled={pending}>
|
|
428
|
+
<Button type="submit" disabled={pending || uploading}>
|
|
414
429
|
{pending ? "Working…" : draft ? "Save changes" : "Create Post"}
|
|
415
430
|
</Button>
|
|
416
431
|
{display === "overlay" ? (
|
|
@@ -419,7 +434,7 @@ export function PortalFeedbackForm({
|
|
|
419
434
|
variant="ghost"
|
|
420
435
|
className="feedback-cancel"
|
|
421
436
|
onClick={onCancel}
|
|
422
|
-
disabled={pending}
|
|
437
|
+
disabled={pending || uploading}
|
|
423
438
|
>
|
|
424
439
|
Cancel
|
|
425
440
|
</Button>
|
|
@@ -430,7 +445,7 @@ export function PortalFeedbackForm({
|
|
|
430
445
|
variant="destructive"
|
|
431
446
|
className="feedback-withdraw"
|
|
432
447
|
onClick={() => setConfirmingWithdrawal(true)}
|
|
433
|
-
disabled={pending}
|
|
448
|
+
disabled={pending || uploading}
|
|
434
449
|
>
|
|
435
450
|
Withdraw draft
|
|
436
451
|
</Button>
|
|
@@ -10,6 +10,7 @@ interface PortalSubmissionEnvironment {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
interface PortalSubmissionSecurityOptions {
|
|
13
|
+
turnstile?: FeedbaxConfig["turnstile"];
|
|
13
14
|
environment: PortalSubmissionEnvironment;
|
|
14
15
|
rateLimitKey: string;
|
|
15
16
|
}
|
|
@@ -22,11 +23,13 @@ interface TurnstileWarningOptions {
|
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export function createPortalSubmissionSecurity({
|
|
26
|
+
turnstile = feedbax.turnstile,
|
|
25
27
|
environment,
|
|
26
28
|
rateLimitKey,
|
|
27
29
|
}: PortalSubmissionSecurityOptions) {
|
|
28
30
|
const turnstileSecretKey = configuredTurnstileSecret(
|
|
29
31
|
environment.TURNSTILE_SECRET_KEY,
|
|
32
|
+
turnstile,
|
|
30
33
|
);
|
|
31
34
|
|
|
32
35
|
return {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createServerFn } from "@tanstack/react-start";
|
|
2
|
-
import { getRequestIP } from "@tanstack/react-start/server";
|
|
2
|
+
import { getRequest, getRequestIP } from "@tanstack/react-start/server";
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
requestParticipant,
|
|
5
6
|
createConfiguredFeedbackModule,
|
|
6
7
|
feedbackSubmissionLimits,
|
|
7
8
|
} from "./feedback-runtime";
|
|
@@ -24,9 +25,10 @@ export const submitPortalFeedback = createServerFn({ method: "POST" })
|
|
|
24
25
|
environment: env,
|
|
25
26
|
nodeEnv: import.meta.env.PROD ? "production" : "development",
|
|
26
27
|
});
|
|
28
|
+
const participant = await requestParticipant(getRequest());
|
|
27
29
|
return runSafePortalMutation(() =>
|
|
28
30
|
createPortalFeedbackSubmission({
|
|
29
|
-
feedback: createConfiguredFeedbackModule(),
|
|
31
|
+
feedback: createConfiguredFeedbackModule(participant),
|
|
30
32
|
limits: feedbackSubmissionLimits,
|
|
31
33
|
...createPortalSubmissionSecurity({
|
|
32
34
|
environment: env,
|
|
@@ -40,9 +42,10 @@ export const submitPortalPost = createServerFn({ method: "POST" })
|
|
|
40
42
|
.validator((input: unknown) => input)
|
|
41
43
|
.handler(async ({ data }) => {
|
|
42
44
|
const { env } = await import("cloudflare:workers");
|
|
45
|
+
const participant = await requestParticipant(getRequest());
|
|
43
46
|
return runSafePortalMutation(() =>
|
|
44
47
|
createPortalPostSubmission({
|
|
45
|
-
feedback: createConfiguredFeedbackModule(),
|
|
48
|
+
feedback: createConfiguredFeedbackModule(participant),
|
|
46
49
|
limits: feedbackSubmissionLimits,
|
|
47
50
|
...createPortalSubmissionSecurity({
|
|
48
51
|
environment: env,
|
|
@@ -147,8 +147,18 @@ function validateInput(
|
|
|
147
147
|
throw invalidSubmission();
|
|
148
148
|
}
|
|
149
149
|
if (!isValidSubmitter(value.submitter)) throw invalidSubmission();
|
|
150
|
+
if (
|
|
151
|
+
value.imageIds !== undefined &&
|
|
152
|
+
(!Array.isArray(value.imageIds) ||
|
|
153
|
+
value.imageIds.length > 4 ||
|
|
154
|
+
value.imageIds.some(
|
|
155
|
+
(id) => typeof id !== "string" || !/^[-a-f0-9]{36}$/.test(id),
|
|
156
|
+
))
|
|
157
|
+
)
|
|
158
|
+
throw invalidSubmission();
|
|
150
159
|
|
|
151
160
|
return {
|
|
161
|
+
...(value.imageIds ? { imageIds: value.imageIds as string[] } : {}),
|
|
152
162
|
title: value.title,
|
|
153
163
|
description: value.description,
|
|
154
164
|
type: value.type,
|
|
@@ -8,12 +8,14 @@ import {
|
|
|
8
8
|
} from "./public-cache-policy";
|
|
9
9
|
|
|
10
10
|
export function createPortalServerEntry({
|
|
11
|
+
connectedHandler,
|
|
11
12
|
trustedFeedbackHandler,
|
|
12
13
|
voteHandler,
|
|
13
14
|
commentHandler,
|
|
14
15
|
commentMutationHandler,
|
|
15
16
|
applicationHandler = startHandler.fetch,
|
|
16
17
|
}: {
|
|
18
|
+
connectedHandler?: (request: Request) => Promise<Response | undefined>;
|
|
17
19
|
trustedFeedbackHandler: (request: Request) => Promise<Response>;
|
|
18
20
|
voteHandler?: (request: Request) => Promise<Response>;
|
|
19
21
|
commentHandler?: (request: Request) => Promise<Response>;
|
|
@@ -23,6 +25,8 @@ export function createPortalServerEntry({
|
|
|
23
25
|
return createServerEntry({
|
|
24
26
|
async fetch(request, options) {
|
|
25
27
|
const url = new URL(request.url);
|
|
28
|
+
const connectedResponse = await connectedHandler?.(request);
|
|
29
|
+
if (connectedResponse) return connectedResponse;
|
|
26
30
|
if (request.method === "GET" && url.pathname === "/health") {
|
|
27
31
|
return Response.json(
|
|
28
32
|
{ status: "ok" },
|
|
@@ -65,11 +65,11 @@ export function PostCreationFlow({
|
|
|
65
65
|
export function PostCreationSkeleton() {
|
|
66
66
|
return (
|
|
67
67
|
<main
|
|
68
|
-
className="feedback-index
|
|
68
|
+
className="feedback-index"
|
|
69
69
|
aria-label="Loading Post creation"
|
|
70
70
|
aria-busy="true"
|
|
71
71
|
>
|
|
72
|
-
<section className="feedback-submit
|
|
72
|
+
<section className="feedback-submit" aria-hidden="true">
|
|
73
73
|
<div className="feedback-submit-copy">
|
|
74
74
|
<Skeleton className="creation-skeleton-eyebrow" />
|
|
75
75
|
<Skeleton className="creation-skeleton-heading" />
|
|
@@ -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
|
|