@tribe-nest/forge 3.26.0 → 3.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/package.json +6 -3
  2. package/src/_tests/publishedResolvability.spec.ts +184 -0
  3. package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
  4. package/src/_tests/workspaceAliases.ts +40 -0
  5. package/src/client/_tests/localeHeader.spec.tsx +98 -0
  6. package/src/client/activeLocale.ts +34 -0
  7. package/src/client/createForgeClient.ts +13 -0
  8. package/src/contexts/CartContext.tsx +76 -0
  9. package/src/contexts/PublicAuthContext.tsx +34 -5
  10. package/src/contexts/_tests/CartContext.spec.tsx +210 -0
  11. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  12. package/src/data/queries/useBroadcasts.ts +151 -0
  13. package/src/data/queries/useCheckouts.ts +83 -0
  14. package/src/data/queries/useMyBookings.ts +9 -1
  15. package/src/data/queries/useWebsite.ts +7 -0
  16. package/src/i18n/CONVENTION.md +184 -0
  17. package/src/i18n/_tests/translationKeys.spec.ts +153 -0
  18. package/src/i18n/de.json +1019 -0
  19. package/src/i18n/en.json +1019 -0
  20. package/src/i18n/index.ts +125 -0
  21. package/src/index.ts +21 -0
  22. package/src/provider/ForgeAppProvider.tsx +2 -1
  23. package/src/provider/SiteConfigProvider.tsx +11 -0
  24. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  25. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  26. package/src/ui/format/membershipPwyw.ts +164 -0
  27. package/src/ui/format/pwyw.ts +37 -0
  28. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  29. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  30. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  31. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
  32. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
  33. package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
  34. package/src/ui/headless/checkout/useCartResume.ts +340 -0
  35. package/src/ui/headless/checkout/useCheckout.ts +9 -2
  36. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  37. package/src/ui/headless/index.ts +32 -0
  38. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  39. package/src/ui/index.ts +38 -0
  40. package/src/ui/media/CallHelpHint.tsx +87 -0
  41. package/src/ui/media/CallStage.tsx +542 -0
  42. package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
  43. package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
  44. package/src/ui/media/_tests/callState.spec.ts +452 -0
  45. package/src/ui/media/_tests/fakeNode.ts +178 -0
  46. package/src/ui/media/bookingSession.tsx +194 -0
  47. package/src/ui/media/callState.ts +341 -0
  48. package/src/ui/media/index.ts +135 -0
  49. package/src/ui/shell/TribeNestApp.tsx +36 -27
  50. package/src/ui/styled/AccountDashboard.tsx +289 -96
  51. package/src/ui/styled/AiAgentWidget.tsx +10 -6
  52. package/src/ui/styled/AudioPlayer.tsx +14 -6
  53. package/src/ui/styled/BlogCategory.tsx +4 -2
  54. package/src/ui/styled/BlogComments.tsx +27 -20
  55. package/src/ui/styled/BlogList.tsx +5 -3
  56. package/src/ui/styled/BlogPost.tsx +14 -11
  57. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  58. package/src/ui/styled/BundleConfirmation.tsx +32 -23
  59. package/src/ui/styled/CancellationTerms.tsx +3 -1
  60. package/src/ui/styled/Cart.tsx +32 -14
  61. package/src/ui/styled/ChatRoom.tsx +65 -20
  62. package/src/ui/styled/Checkout.tsx +151 -89
  63. package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
  64. package/src/ui/styled/CoachingBooking.tsx +71 -44
  65. package/src/ui/styled/CoachingConfirmation.tsx +56 -19
  66. package/src/ui/styled/CoachingDetail.tsx +15 -11
  67. package/src/ui/styled/CohortPage.tsx +10 -8
  68. package/src/ui/styled/ConfirmSubscription.tsx +14 -8
  69. package/src/ui/styled/ContactForm.tsx +31 -12
  70. package/src/ui/styled/CookieConsent.tsx +19 -15
  71. package/src/ui/styled/CourseAccess.tsx +54 -47
  72. package/src/ui/styled/CourseCheckout.tsx +56 -38
  73. package/src/ui/styled/CourseConfirmation.tsx +39 -17
  74. package/src/ui/styled/CourseDetail.tsx +21 -15
  75. package/src/ui/styled/CoursesGrid.tsx +4 -2
  76. package/src/ui/styled/CurrencySwitcher.tsx +4 -1
  77. package/src/ui/styled/DiscountCode.tsx +22 -18
  78. package/src/ui/styled/DonationButton.tsx +52 -17
  79. package/src/ui/styled/DonationPage.tsx +4 -4
  80. package/src/ui/styled/EmailListForm.tsx +22 -9
  81. package/src/ui/styled/EventConfirmation.tsx +37 -14
  82. package/src/ui/styled/EventCountdown.tsx +10 -6
  83. package/src/ui/styled/EventDetail.tsx +8 -6
  84. package/src/ui/styled/EventSeriesDetail.tsx +18 -13
  85. package/src/ui/styled/EventTickets.tsx +232 -108
  86. package/src/ui/styled/EventWaitlist.tsx +42 -33
  87. package/src/ui/styled/EventsList.tsx +15 -13
  88. package/src/ui/styled/ForgotPasswordForm.tsx +17 -12
  89. package/src/ui/styled/FormRenderer.tsx +12 -7
  90. package/src/ui/styled/InstallBanner.tsx +21 -18
  91. package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
  92. package/src/ui/styled/InvoicePayment.tsx +37 -16
  93. package/src/ui/styled/LeadMagnet.tsx +9 -7
  94. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  95. package/src/ui/styled/Loading.tsx +7 -1
  96. package/src/ui/styled/LoginForm.tsx +32 -14
  97. package/src/ui/styled/MembershipCheckout.tsx +341 -49
  98. package/src/ui/styled/MembershipTiers.tsx +34 -18
  99. package/src/ui/styled/OfferButton.tsx +33 -13
  100. package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
  101. package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
  102. package/src/ui/styled/PodcastEpisode.tsx +20 -9
  103. package/src/ui/styled/PodcastList.tsx +9 -3
  104. package/src/ui/styled/PodcastShow.tsx +29 -7
  105. package/src/ui/styled/PostsFeed.tsx +10 -8
  106. package/src/ui/styled/PresaleCode.tsx +8 -11
  107. package/src/ui/styled/ProductBrowseNav.tsx +21 -11
  108. package/src/ui/styled/ProductDetail.tsx +58 -28
  109. package/src/ui/styled/ReactionBar.tsx +4 -2
  110. package/src/ui/styled/ReplayList.tsx +24 -13
  111. package/src/ui/styled/ResetPasswordForm.tsx +32 -15
  112. package/src/ui/styled/ResumeCart.tsx +289 -0
  113. package/src/ui/styled/ReviewForm.tsx +52 -58
  114. package/src/ui/styled/SignupForm.tsx +34 -16
  115. package/src/ui/styled/TicketTransfer.tsx +55 -54
  116. package/src/ui/styled/UserMenu.tsx +16 -14
  117. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  118. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  119. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  120. package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
  121. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  122. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  123. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  124. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  125. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  126. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  127. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  128. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  129. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
@@ -0,0 +1,281 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+ import type { ReactNode } from "react";
6
+
7
+ import { CommunityComposer, type CommunityComposerProps } from "../CommunityComposer";
8
+ import { ForgeClientProvider } from "../../../../provider/ForgeProvider";
9
+ import { ForgeThemeProvider, defaultForgeTheme } from "../../../theme/ForgeThemeProvider";
10
+
11
+ /**
12
+ * The composer's open/close behaviour, rendered for real.
13
+ *
14
+ * ## What is actually at stake here
15
+ *
16
+ * The space feed's post box is closed by default, so the ONLY way a member
17
+ * reaches the editor is the affordance these tests click. Two things can go
18
+ * wrong and neither is visible to the type system:
19
+ *
20
+ * 1. Opening without focusing. The member clicks, the box unfolds, and the
21
+ * caret is nowhere. Writing costs two clicks instead of one and it reads as
22
+ * a bug every single time.
23
+ * 2. Closing by unmounting. The DOM inside the editor IS the draft, so a
24
+ * conditional render deletes a half-written post silently, with no undo. The
25
+ * fix is invisible in a screenshot and a refactor can undo it by accident,
26
+ * which is exactly why it is pinned here.
27
+ *
28
+ * There is a third, opposite failure: collapsing a composer the member DID ask
29
+ * for. A reply box summoned by clicking "Reply" must be open the instant it
30
+ * mounts, so the last test asserts the non-collapsible path is untouched.
31
+ *
32
+ * No `profileId` is provided, so the mention-directory query is disabled and
33
+ * nothing here reaches the network.
34
+ */
35
+
36
+ afterEach(cleanup);
37
+
38
+ function wrap(children: ReactNode) {
39
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
40
+ return (
41
+ <QueryClientProvider client={client}>
42
+ <ForgeClientProvider apiUrl="http://localhost/api">
43
+ <ForgeThemeProvider theme={defaultForgeTheme}>{children}</ForgeThemeProvider>
44
+ </ForgeClientProvider>
45
+ </QueryClientProvider>
46
+ );
47
+ }
48
+
49
+ function renderComposer(props: Partial<CommunityComposerProps> = {}) {
50
+ const onSubmit = props.onSubmit ?? vi.fn().mockResolvedValue(undefined);
51
+ const utils = render(wrap(<CommunityComposer onSubmit={onSubmit} {...props} />));
52
+ return { ...utils, onSubmit };
53
+ }
54
+
55
+ /** The editor, whether or not it is currently exposed. */
56
+ const editorEl = (container: HTMLElement) =>
57
+ container.querySelector<HTMLElement>(".forge-community-composer-editable")!;
58
+
59
+ /** Type into the contentEditable the way the member does: content, then input. */
60
+ function type(container: HTMLElement, html: string) {
61
+ const editor = editorEl(container);
62
+ editor.innerHTML = html;
63
+ fireEvent.input(editor);
64
+ }
65
+
66
+ describe("CommunityComposer, collapsed by default", () => {
67
+ it("renders no editor until the affordance is used, and the affordance says what it opens", () => {
68
+ const { container } = renderComposer({
69
+ collapsible: true,
70
+ collapsedLabel: "Share something with the community",
71
+ });
72
+
73
+ // `getByRole` reads the accessibility tree, so a `display: none` editor is
74
+ // absent from it exactly as it is absent for a member.
75
+ expect(screen.queryByRole("textbox")).toBeNull();
76
+ expect(screen.getByRole("button", { name: /share something with the community/i })).toBeTruthy();
77
+
78
+ // Hidden, not gone: the draft has somewhere to live from the first render.
79
+ expect(editorEl(container)).toBeTruthy();
80
+ });
81
+
82
+ it("opens on click and puts the caret in the editor, so writing is one click", () => {
83
+ const { container } = renderComposer({ collapsible: true, collapsedLabel: "Write a reply" });
84
+
85
+ fireEvent.click(screen.getByRole("button", { name: /write a reply/i }));
86
+
87
+ const editor = editorEl(container);
88
+ expect(screen.getByRole("textbox")).toBe(editor);
89
+ expect(document.activeElement).toBe(editor);
90
+ });
91
+
92
+ it("focuses the title first on a forum post, because the title is what is typed first", () => {
93
+ renderComposer({ collapsible: true, withTitle: true, collapsedLabel: "Start a discussion" });
94
+
95
+ fireEvent.click(screen.getByRole("button", { name: /start a discussion/i }));
96
+
97
+ expect(document.activeElement).toBe(screen.getByPlaceholderText("Title"));
98
+ });
99
+
100
+ it("hands open/closed to a controlled parent instead of keeping a second copy", () => {
101
+ const onOpenChange = vi.fn();
102
+ const { rerender } = render(
103
+ wrap(
104
+ <CommunityComposer
105
+ collapsible
106
+ open={false}
107
+ onOpenChange={onOpenChange}
108
+ collapsedLabel="Write a post"
109
+ onSubmit={vi.fn().mockResolvedValue(undefined)}
110
+ />,
111
+ ),
112
+ );
113
+
114
+ // Closed by the parent: clicking reports upward and changes nothing locally.
115
+ fireEvent.click(screen.getByRole("button", { name: /write a post/i }));
116
+ expect(onOpenChange).toHaveBeenCalledWith(true);
117
+ expect(screen.queryByRole("textbox")).toBeNull();
118
+
119
+ rerender(
120
+ wrap(
121
+ <CommunityComposer
122
+ collapsible
123
+ open
124
+ onOpenChange={onOpenChange}
125
+ collapsedLabel="Write a post"
126
+ onSubmit={vi.fn().mockResolvedValue(undefined)}
127
+ />,
128
+ ),
129
+ );
130
+ expect(screen.getByRole("textbox")).toBeTruthy();
131
+
132
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
133
+ expect(onOpenChange).toHaveBeenLastCalledWith(false);
134
+ });
135
+ });
136
+
137
+ describe("CommunityComposer, closing keeps the draft", () => {
138
+ it("keeps the text, and says so, rather than throwing it away on Close", () => {
139
+ const { container } = renderComposer({ collapsible: true, collapsedLabel: "Write a reply" });
140
+
141
+ fireEvent.click(screen.getByRole("button", { name: /write a reply/i }));
142
+ type(container, "half a thought about the new record");
143
+
144
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
145
+
146
+ // Closed to the member.
147
+ expect(screen.queryByRole("textbox")).toBeNull();
148
+ // The words are still there, and the affordance admits it.
149
+ expect(editorEl(container).textContent).toBe("half a thought about the new record");
150
+ expect(screen.getByRole("button", { name: /continue your draft/i })).toBeTruthy();
151
+ expect(screen.queryByRole("button", { name: /^write a reply$/i })).toBeNull();
152
+ });
153
+
154
+ it("reopens on the draft with the caret in it", () => {
155
+ const { container } = renderComposer({ collapsible: true, collapsedLabel: "Write a reply" });
156
+
157
+ fireEvent.click(screen.getByRole("button", { name: /write a reply/i }));
158
+ type(container, "unfinished");
159
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
160
+ fireEvent.click(screen.getByRole("button", { name: /continue your draft/i }));
161
+
162
+ const editor = editorEl(container);
163
+ expect(screen.getByRole("textbox")).toBe(editor);
164
+ expect(editor.textContent).toBe("unfinished");
165
+ expect(document.activeElement).toBe(editor);
166
+ });
167
+
168
+ it("keeps a forum title across a close too", () => {
169
+ renderComposer({ collapsible: true, withTitle: true, collapsedLabel: "Start a discussion" });
170
+
171
+ fireEvent.click(screen.getByRole("button", { name: /start a discussion/i }));
172
+ fireEvent.change(screen.getByPlaceholderText("Title"), { target: { value: "Tour dates" } });
173
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
174
+
175
+ expect(screen.getByRole("button", { name: /continue your draft/i })).toBeTruthy();
176
+
177
+ fireEvent.click(screen.getByRole("button", { name: /continue your draft/i }));
178
+ expect(screen.getByPlaceholderText("Title")).toHaveProperty("value", "Tour dates");
179
+ });
180
+
181
+ it("offers no Discard when there is nothing to discard", () => {
182
+ renderComposer({ collapsible: true, collapsedLabel: "Write a reply" });
183
+
184
+ expect(screen.queryByRole("button", { name: "Discard" })).toBeNull();
185
+
186
+ fireEvent.click(screen.getByRole("button", { name: /write a reply/i }));
187
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
188
+
189
+ // Opened and closed again without typing: still nothing to warn about.
190
+ expect(screen.queryByRole("button", { name: "Discard" })).toBeNull();
191
+ expect(screen.getByRole("button", { name: /^write a reply$/i })).toBeTruthy();
192
+ });
193
+
194
+ it("only discards when it is asked for twice, and Keep it changes nothing", () => {
195
+ const { container } = renderComposer({ collapsible: true, collapsedLabel: "Write a reply" });
196
+
197
+ fireEvent.click(screen.getByRole("button", { name: /write a reply/i }));
198
+ type(container, "words worth keeping");
199
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
200
+
201
+ // First ask: a warning that has to be read, and the draft untouched.
202
+ fireEvent.click(screen.getByRole("button", { name: "Discard" }));
203
+ expect(screen.getByText(/cannot be brought back/i)).toBeTruthy();
204
+ expect(editorEl(container).textContent).toBe("words worth keeping");
205
+
206
+ // Backing out leaves it exactly where it was.
207
+ fireEvent.click(screen.getByRole("button", { name: "Keep it" }));
208
+ expect(editorEl(container).textContent).toBe("words worth keeping");
209
+ expect(screen.getByRole("button", { name: /continue your draft/i })).toBeTruthy();
210
+
211
+ // Asked again and confirmed: now it goes.
212
+ fireEvent.click(screen.getByRole("button", { name: "Discard" }));
213
+ fireEvent.click(screen.getByRole("button", { name: "Discard" }));
214
+ expect(editorEl(container).textContent).toBe("");
215
+ expect(screen.getByRole("button", { name: /^write a reply$/i })).toBeTruthy();
216
+ expect(screen.queryByRole("button", { name: /continue your draft/i })).toBeNull();
217
+ });
218
+ });
219
+
220
+ describe("CommunityComposer, posting", () => {
221
+ it("submits the draft, clears it and folds away", async () => {
222
+ const onSubmit = vi.fn().mockResolvedValue(undefined);
223
+ const { container } = renderComposer({
224
+ collapsible: true,
225
+ collapsedLabel: "Write a reply",
226
+ submitLabel: "Reply",
227
+ onSubmit,
228
+ });
229
+
230
+ fireEvent.click(screen.getByRole("button", { name: /write a reply/i }));
231
+ type(container, "posted for real");
232
+ fireEvent.click(screen.getByRole("button", { name: "Reply" }));
233
+
234
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
235
+ expect(onSubmit.mock.calls[0][0]).toMatchObject({ attachments: [], mentionedAccountIds: [] });
236
+
237
+ await waitFor(() => expect(screen.queryByRole("textbox")).toBeNull());
238
+ // Nothing was lost, because there is nothing left: the post went out.
239
+ expect(editorEl(container).textContent).toBe("");
240
+ expect(screen.getByRole("button", { name: /^write a reply$/i })).toBeTruthy();
241
+ });
242
+
243
+ it("stays open with the draft intact when publishing fails", async () => {
244
+ const onSubmit = vi.fn().mockRejectedValue({ response: { data: { message: "Space is locked." } } });
245
+ const { container } = renderComposer({
246
+ collapsible: true,
247
+ collapsedLabel: "Write a reply",
248
+ submitLabel: "Reply",
249
+ onSubmit,
250
+ });
251
+
252
+ fireEvent.click(screen.getByRole("button", { name: /write a reply/i }));
253
+ type(container, "this will not land");
254
+ fireEvent.click(screen.getByRole("button", { name: "Reply" }));
255
+
256
+ expect(await screen.findByText("Space is locked.")).toBeTruthy();
257
+ expect(screen.getByRole("textbox")).toBeTruthy();
258
+ expect(editorEl(container).textContent).toBe("this will not land");
259
+ });
260
+ });
261
+
262
+ describe("CommunityComposer, a box the member asked for", () => {
263
+ it("is open on mount and has no way to collapse itself", () => {
264
+ const onCancel = vi.fn();
265
+ renderComposer({ placeholder: "Reply to Ada…", submitLabel: "Reply", onCancel });
266
+
267
+ // No affordance, no Close: it is already the thing the member wanted.
268
+ expect(screen.getByRole("textbox")).toBeTruthy();
269
+ expect(screen.queryByRole("button", { name: "Close" })).toBeNull();
270
+ expect(screen.queryByRole("button", { name: /reply to ada/i })).toBeNull();
271
+ expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy();
272
+ });
273
+
274
+ it("stays open while it is typed into", () => {
275
+ const { container } = renderComposer({ placeholder: "Reply to Ada…", submitLabel: "Reply" });
276
+
277
+ type(container, "still here");
278
+ expect(screen.getByRole("textbox")).toBeTruthy();
279
+ expect(editorEl(container).textContent).toBe("still here");
280
+ });
281
+ });
@@ -0,0 +1,175 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+
6
+ import { CommunityPostDetail } from "../CommunityPostDetail";
7
+ import { ForgeClientProvider } from "../../../../provider/ForgeProvider";
8
+ import { ForgeThemeProvider, defaultForgeTheme } from "../../../theme/ForgeThemeProvider";
9
+
10
+ /**
11
+ * The thread view has TWO composers with opposite requirements, and this file
12
+ * exists because the obvious change breaks one of them.
13
+ *
14
+ * - The top-level reply box is unsolicited, so it starts closed.
15
+ * - The box under a reply was summoned by a click on "Reply", so collapsing it
16
+ * would eat that click and the member would have to press it again.
17
+ *
18
+ * And the seam between them: opening a nested reply box hides the top-level one.
19
+ * It used to be UNMOUNTED there, which meant a member who was half way through
20
+ * a top-level reply, then answered someone else, came back to an empty box.
21
+ */
22
+
23
+ vi.mock("../../../headless/community/useCommunityThread", () => ({
24
+ useCommunityThread: () => threadStub,
25
+ }));
26
+
27
+ const author = (name: string, accountId: string) => ({
28
+ accountId,
29
+ firstName: name,
30
+ lastName: "",
31
+ displayName: name,
32
+ avatarUrl: null,
33
+ level: 1,
34
+ });
35
+
36
+ const text = (value: string) => ({
37
+ root: { type: "root", children: [{ type: "paragraph", children: [{ type: "text", text: value }] }] },
38
+ });
39
+
40
+ const reply = (id: string, name: string, body: string) => ({
41
+ id,
42
+ postId: "post-1",
43
+ profileId: "profile-1",
44
+ authorAccountId: `acct-${id}`,
45
+ parentReplyId: null,
46
+ body: text(body),
47
+ bodyText: body,
48
+ createdAt: new Date("2026-08-01T10:00:00Z").toISOString(),
49
+ author: author(name, `acct-${id}`),
50
+ media: [],
51
+ reactions: [],
52
+ });
53
+
54
+ let threadStub: any;
55
+
56
+ function mountThread({ isLocked = false } = {}) {
57
+ const replies = [reply("r1", "Ada", "first reply")];
58
+ threadStub = {
59
+ post: {
60
+ id: "post-1",
61
+ spaceId: "space-1",
62
+ profileId: "profile-1",
63
+ authorAccountId: "acct-owner",
64
+ title: "Tour dates",
65
+ body: text("Where should we play next?"),
66
+ bodyText: "Where should we play next?",
67
+ isPinned: false,
68
+ isLocked,
69
+ replyCount: replies.length,
70
+ reactionCount: 0,
71
+ lastActivityAt: null,
72
+ createdAt: new Date("2026-08-01T09:00:00Z").toISOString(),
73
+ author: author("Owner", "acct-owner"),
74
+ media: [],
75
+ reactions: [],
76
+ },
77
+ postLoading: false,
78
+ postError: null,
79
+ replies,
80
+ replyTree: replies.map((r) => ({ reply: r, children: [], depth: 0 })),
81
+ repliesLoading: false,
82
+ fetchNextPage: vi.fn(),
83
+ hasMore: false,
84
+ currentAccountId: "acct-me",
85
+ createReply: { mutateAsync: vi.fn().mockResolvedValue(undefined) },
86
+ react: vi.fn(),
87
+ };
88
+
89
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
90
+ return render(
91
+ <QueryClientProvider client={client}>
92
+ <ForgeClientProvider apiUrl="http://localhost/api">
93
+ <ForgeThemeProvider theme={defaultForgeTheme}>
94
+ <CommunityPostDetail postId="post-1" />
95
+ </ForgeThemeProvider>
96
+ </ForgeClientProvider>
97
+ </QueryClientProvider>,
98
+ );
99
+ }
100
+
101
+ /** Every editor on the page, exposed or not. */
102
+ const editors = (container: HTMLElement) =>
103
+ Array.from(container.querySelectorAll<HTMLElement>(".forge-community-composer-editable"));
104
+
105
+ /**
106
+ * The "Reply" ACTION under Ada's reply. The top-level composer's submit button
107
+ * carries the same word and sits earlier in the document once it is open, so
108
+ * this takes the last one rather than the first.
109
+ */
110
+ const replyToAda = () => {
111
+ const all = screen.getAllByRole("button", { name: "Reply" });
112
+ return all[all.length - 1];
113
+ };
114
+
115
+ afterEach(cleanup);
116
+
117
+ describe("CommunityPostDetail composers", () => {
118
+ it("starts the top-level reply box closed, behind an affordance", () => {
119
+ mountThread();
120
+
121
+ expect(screen.queryByRole("textbox")).toBeNull();
122
+ expect(screen.getByRole("button", { name: /write a reply/i })).toBeTruthy();
123
+ });
124
+
125
+ it("opens a reply box that STAYS open when Reply is clicked on a reply", () => {
126
+ mountThread();
127
+
128
+ fireEvent.click(replyToAda());
129
+
130
+ // One editor exposed, and it is the one that was just asked for: it carries
131
+ // the addressee's name in its placeholder.
132
+ const exposed = screen.getAllByRole("textbox");
133
+ expect(exposed).toHaveLength(1);
134
+ expect(exposed[0].getAttribute("data-placeholder")).toBe("Reply to Ada…");
135
+
136
+ // It offers Cancel (abandon this reply), never Close (fold away): a box the
137
+ // member summoned does not get to collapse itself out from under them.
138
+ expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy();
139
+ expect(screen.queryByRole("button", { name: "Close" })).toBeNull();
140
+ });
141
+
142
+ it("keeps a half-written top-level reply while the member answers someone else", () => {
143
+ const { container } = mountThread();
144
+
145
+ // Start a top-level reply and get some words down.
146
+ fireEvent.click(screen.getByRole("button", { name: /write a reply/i }));
147
+ const topLevel = editors(container)[0];
148
+ topLevel.innerHTML = "my own thought";
149
+ fireEvent.input(topLevel);
150
+
151
+ // Then answer Ada instead.
152
+ fireEvent.click(replyToAda());
153
+ expect(screen.getAllByRole("textbox")).toHaveLength(1);
154
+
155
+ // The top-level box is hidden, NOT unmounted, so the words survive.
156
+ expect(editors(container)[0]).toBe(topLevel);
157
+ expect(topLevel.textContent).toBe("my own thought");
158
+
159
+ // Back out of the nested reply and it is there, still open, still written in.
160
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
161
+ const exposed = screen.getAllByRole("textbox");
162
+ expect(exposed).toHaveLength(1);
163
+ expect(exposed[0]).toBe(topLevel);
164
+ expect(topLevel.textContent).toBe("my own thought");
165
+ });
166
+
167
+ it("offers no composer at all on a locked post", () => {
168
+ mountThread({ isLocked: true });
169
+
170
+ expect(screen.getByText(/replies are closed/i)).toBeTruthy();
171
+ expect(screen.queryByRole("textbox")).toBeNull();
172
+ expect(screen.queryByRole("button", { name: /write a reply/i })).toBeNull();
173
+ expect(screen.queryByRole("button", { name: "Reply" })).toBeNull();
174
+ });
175
+ });