@tribe-nest/forge 3.60.0 → 3.61.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 (207) hide show
  1. package/package.json +2 -1
  2. package/src/contexts/CartContext.tsx +34 -1
  3. package/src/contexts/PublicAuthContext.tsx +15 -3
  4. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  5. package/src/data/queries/useAccount.ts +33 -2
  6. package/src/data/queries/useAnalytics.ts +6 -6
  7. package/src/data/queries/useCallChat.ts +2 -2
  8. package/src/data/queries/useChat.ts +10 -1
  9. package/src/data/queries/useCheckouts.ts +2 -1
  10. package/src/data/queries/useCoachingAvailability.ts +10 -1
  11. package/src/data/queries/useCommunities.ts +2 -0
  12. package/src/data/queries/useCommunity.ts +362 -18
  13. package/src/data/queries/useCommunityAgent.ts +247 -0
  14. package/src/data/queries/useCommunityCalendar.ts +150 -0
  15. package/src/data/queries/useCommunityClassroom.ts +75 -0
  16. package/src/data/queries/useCommunityMembers.ts +108 -0
  17. package/src/data/queries/useCommunityModeration.ts +327 -0
  18. package/src/data/queries/useCommunityWiki.ts +137 -0
  19. package/src/data/queries/useCourses.ts +2 -0
  20. package/src/data/queries/useEmailSignIn.ts +79 -0
  21. package/src/data/queries/useEvents.ts +6 -1
  22. package/src/data/queries/useForms.ts +5 -1
  23. package/src/data/queries/useLeadMagnet.ts +6 -2
  24. package/src/data/queries/useMyBookings.ts +24 -1
  25. package/src/data/queries/useMyTickets.ts +24 -1
  26. package/src/data/queries/useNotifications.ts +6 -2
  27. package/src/data/queries/useOrders.ts +2 -1
  28. package/src/data/queries/usePWA.ts +111 -35
  29. package/src/data/queries/useSiteApp.ts +130 -0
  30. package/src/data/queries/useSubscriptions.ts +6 -1
  31. package/src/i18n/de.json +746 -1
  32. package/src/i18n/en.json +746 -1
  33. package/src/index.ts +17 -0
  34. package/src/runtime/pages.spec.ts +5 -0
  35. package/src/runtime/pages.ts +32 -6
  36. package/src/types/models.ts +11 -0
  37. package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
  38. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  39. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  40. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  41. package/src/ui/analytics/useMetaEvents.ts +78 -0
  42. package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
  43. package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
  44. package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
  45. package/src/ui/headless/_tests/communityHarness.tsx +10 -3
  46. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  47. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  48. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  49. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  50. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  51. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  52. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  53. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +228 -14
  54. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
  55. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  56. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
  57. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  58. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  59. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  60. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  61. package/src/ui/headless/auth/useSignupForm.ts +20 -1
  62. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  63. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  64. package/src/ui/headless/communities/index.ts +41 -0
  65. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  66. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  67. package/src/ui/headless/communities/useCommunityHome.ts +3 -0
  68. package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
  69. package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
  70. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  71. package/src/ui/headless/community/communityPoll.ts +297 -0
  72. package/src/ui/headless/community/countries.ts +123 -0
  73. package/src/ui/headless/community/index.ts +81 -1
  74. package/src/ui/headless/community/linkPreview.ts +71 -0
  75. package/src/ui/headless/community/richLexical.ts +27 -2
  76. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  77. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  78. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  79. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  80. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  81. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  82. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  83. package/src/ui/headless/downloads/index.ts +8 -0
  84. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  85. package/src/ui/headless/index.ts +13 -0
  86. package/src/ui/headless/memberHome/index.ts +10 -0
  87. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  88. package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
  89. package/src/ui/headless/portal/index.ts +57 -0
  90. package/src/ui/headless/portal/usePortal.ts +274 -0
  91. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  92. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  93. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  94. package/src/ui/headless/siteApp/appBar.ts +102 -0
  95. package/src/ui/headless/siteApp/appHome.ts +344 -0
  96. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  97. package/src/ui/headless/siteApp/index.ts +58 -0
  98. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  99. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  100. package/src/ui/headless/work/index.ts +2 -0
  101. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  102. package/src/ui/index.ts +11 -0
  103. package/src/ui/styled/AccountDashboard.tsx +712 -352
  104. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  105. package/src/ui/styled/ChatRoom.tsx +126 -16
  106. package/src/ui/styled/Checkout.tsx +8 -0
  107. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  108. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  109. package/src/ui/styled/CoachingDetail.tsx +2 -0
  110. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  111. package/src/ui/styled/CourseDetail.tsx +2 -0
  112. package/src/ui/styled/DownloadPage.tsx +263 -0
  113. package/src/ui/styled/EventConfirmation.tsx +10 -0
  114. package/src/ui/styled/EventDetail.tsx +10 -0
  115. package/src/ui/styled/FilmLibrary.tsx +22 -9
  116. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  117. package/src/ui/styled/ProductDetail.tsx +12 -0
  118. package/src/ui/styled/PwaRegistration.tsx +5 -1
  119. package/src/ui/styled/SignupForm.tsx +18 -3
  120. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +5 -5
  121. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  122. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  123. package/src/ui/styled/app/AppBar.tsx +298 -0
  124. package/src/ui/styled/app/AppHome.tsx +1139 -0
  125. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  126. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  127. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  128. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  129. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  130. package/src/ui/styled/app/index.ts +7 -0
  131. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  132. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  133. package/src/ui/styled/communities/CommunityAbout.tsx +6 -1
  134. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  135. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  136. package/src/ui/styled/communities/CommunityHome.tsx +134 -11
  137. package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
  138. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
  139. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  140. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  141. package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
  142. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  143. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  144. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  145. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  146. package/src/ui/styled/communities/index.ts +9 -0
  147. package/src/ui/styled/communities/shared.tsx +39 -11
  148. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  149. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  150. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  151. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  152. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  153. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  154. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  155. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  156. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  157. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  158. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  159. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  160. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  161. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  162. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  163. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  164. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  165. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  166. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  167. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  168. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  169. package/src/ui/styled/community/index.ts +37 -2
  170. package/src/ui/styled/forge-utilities.css +234 -0
  171. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  172. package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
  173. package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
  174. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  175. package/src/ui/styled/members/index.ts +8 -0
  176. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  177. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  178. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  179. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  180. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  181. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  182. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  183. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  184. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  185. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  186. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  187. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  188. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  189. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  190. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  191. package/src/ui/styled/portal/index.ts +13 -0
  192. package/src/ui/styled/portal/portalNav.ts +260 -0
  193. package/src/ui/styled/portal/shared.tsx +206 -0
  194. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  195. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  196. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  197. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  198. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  199. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  200. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  201. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  202. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  203. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  204. package/src/ui/styled/work/index.ts +1 -1
  205. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  206. package/src/utils/landing.ts +19 -6
  207. package/src/utils/metaEvents.ts +302 -0
@@ -0,0 +1,420 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import {
5
+ communityMuteUntil,
6
+ describeHoldReason,
7
+ describeModerationNotification,
8
+ moderationErrorMessage,
9
+ restorableLogEntries,
10
+ useCommunityModeration,
11
+ useCommunityModeratorActions,
12
+ } from "../community/useCommunityModeration";
13
+ import { useMemberHomeNav } from "../memberHome/useMemberHomeNav";
14
+ import type { CommunityModerationLogEntry } from "../../../data/queries/useCommunityModeration";
15
+ import {
16
+ COMMUNITY,
17
+ PROFILE_ID,
18
+ account,
19
+ calls,
20
+ get,
21
+ installAdapter,
22
+ post,
23
+ restoreAdapter,
24
+ wrapper,
25
+ } from "./communityHarness";
26
+
27
+ /**
28
+ * Moderation from the site. The server decides who may moderate; these pin
29
+ * what the site SENDS (every body names the profile, a mute names the
30
+ * community), that nothing is asked for until the viewer is known to moderate
31
+ * this community, and the small readings a renderer depends on: hold reasons
32
+ * in words, which log entries can still be undone, and which notifications
33
+ * carry a moderation outcome.
34
+ */
35
+
36
+ afterEach(restoreAdapter);
37
+
38
+ const me = (canModerate: boolean) =>
39
+ get("/public/community/me", () => ({
40
+ accountId: "account-1",
41
+ role: canModerate ? "moderator" : "member",
42
+ canModerate,
43
+ }));
44
+
45
+ const QUEUE = {
46
+ reports: {
47
+ data: [
48
+ {
49
+ id: "rep-1",
50
+ entityType: "community_post",
51
+ entityId: "post-9",
52
+ reason: "spam",
53
+ reporterFirstName: "Ada",
54
+ reporterLastName: "L",
55
+ contentPreview: "buy now",
56
+ contentHidden: false,
57
+ createdAt: "2026-09-01T00:00:00.000Z",
58
+ },
59
+ ],
60
+ total: 1,
61
+ },
62
+ pending: {
63
+ posts: [
64
+ {
65
+ id: "post-1",
66
+ spaceId: "space-1",
67
+ spaceName: "General",
68
+ title: "Hello",
69
+ bodyText: "first post",
70
+ authorAccountId: "account-2",
71
+ authorName: "New Fan",
72
+ moderationReason: "approval_new_member",
73
+ createdAt: "2026-09-01T00:00:00.000Z",
74
+ },
75
+ ],
76
+ replies: [
77
+ {
78
+ id: "reply-1",
79
+ postId: "post-5",
80
+ postTitle: "Tour",
81
+ bodyText: "a reply",
82
+ authorAccountId: "account-3",
83
+ authorName: "Other Fan",
84
+ moderationReason: "keyword:spoiler",
85
+ createdAt: "2026-09-01T00:00:00.000Z",
86
+ },
87
+ ],
88
+ },
89
+ };
90
+
91
+ const logEntry = (overrides: Partial<CommunityModerationLogEntry>): CommunityModerationLogEntry => ({
92
+ id: "log-x",
93
+ action: "remove",
94
+ entityType: "community_post",
95
+ entityId: "post-1",
96
+ actorAccountId: "account-1",
97
+ targetAccountId: "account-2",
98
+ reason: null,
99
+ createdAt: "2026-09-01T00:00:00.000Z",
100
+ actorName: "Mod",
101
+ targetName: "Fan",
102
+ ...overrides,
103
+ });
104
+
105
+ describe("describeHoldReason", () => {
106
+ it("reads the three reasons the server writes", () => {
107
+ expect(describeHoldReason("approval_new_member")).toEqual({ kind: "new_member" });
108
+ expect(describeHoldReason("approval_all")).toEqual({ kind: "all_posts" });
109
+ expect(describeHoldReason("keyword:spoiler alert")).toEqual({ kind: "keyword", word: "spoiler alert" });
110
+ });
111
+
112
+ it("passes an unknown reason through, and nothing for none", () => {
113
+ expect(describeHoldReason("something_new")).toEqual({ kind: "other", raw: "something_new" });
114
+ expect(describeHoldReason(null)).toBeNull();
115
+ expect(describeHoldReason("")).toBeNull();
116
+ });
117
+ });
118
+
119
+ describe("communityMuteUntil", () => {
120
+ it("adds whole days to now", () => {
121
+ const now = new Date("2026-09-29T12:00:00.000Z");
122
+ expect(communityMuteUntil(1, now)).toBe("2026-09-30T12:00:00.000Z");
123
+ expect(communityMuteUntil(7, now)).toBe("2026-10-06T12:00:00.000Z");
124
+ expect(communityMuteUntil(30, now)).toBe("2026-10-29T12:00:00.000Z");
125
+ });
126
+ });
127
+
128
+ describe("moderationErrorMessage", () => {
129
+ it("returns the server's message, or null", () => {
130
+ const banned = { response: { status: 403, data: { message: "You can no longer take part in this community." } } };
131
+ expect(moderationErrorMessage(banned)).toBe("You can no longer take part in this community.");
132
+ expect(moderationErrorMessage(new Error("network"))).toBeNull();
133
+ expect(moderationErrorMessage({ response: { data: { message: " " } } })).toBeNull();
134
+ });
135
+ });
136
+
137
+ describe("describeModerationNotification", () => {
138
+ it("reads an outcome on a post or reply", () => {
139
+ expect(
140
+ describeModerationNotification({
141
+ type: "mod",
142
+ entityType: "community_post",
143
+ data: { outcome: "approved", reason: null },
144
+ }),
145
+ ).toEqual({ outcome: "approved", subject: "post", reason: null });
146
+ expect(
147
+ describeModerationNotification({
148
+ type: "mod",
149
+ entityType: "community_reply",
150
+ data: { outcome: "rejected", reason: " off topic " },
151
+ }),
152
+ ).toEqual({ outcome: "rejected", subject: "reply", reason: "off topic" });
153
+ });
154
+
155
+ it("leaves the mute notice (about the community, no data) and other types alone", () => {
156
+ expect(describeModerationNotification({ type: "mod", entityType: "community", data: null })).toBeNull();
157
+ expect(describeModerationNotification({ type: "mod", entityType: "community", entityId: "c" } as never)).toBeNull();
158
+ expect(
159
+ describeModerationNotification({ type: "reply", entityType: "community_post", data: { outcome: "approved" } }),
160
+ ).toBeNull();
161
+ expect(
162
+ describeModerationNotification({ type: "mod", entityType: "community_post", data: { outcome: "maybe" } }),
163
+ ).toBeNull();
164
+ });
165
+ });
166
+
167
+ describe("restorableLogEntries", () => {
168
+ it("offers restore on a removal nothing newer has restored", () => {
169
+ const entries = [
170
+ logEntry({ id: "log-3", action: "remove", entityId: "post-2" }),
171
+ logEntry({ id: "log-2", action: "restore", entityId: "post-1" }),
172
+ logEntry({ id: "log-1", action: "remove", entityId: "post-1" }),
173
+ ];
174
+ const restorable = restorableLogEntries(entries);
175
+ expect([...restorable.keys()]).toEqual(["log-3"]);
176
+ expect(restorable.get("log-3")).toEqual({ type: "post", id: "post-2" });
177
+ });
178
+
179
+ it("covers a report resolved by hiding, and replies, and only the newest removal of one thing", () => {
180
+ const entries = [
181
+ logEntry({ id: "log-3", action: "report_resolved_hidden", entityType: "community_reply", entityId: "reply-1" }),
182
+ logEntry({ id: "log-2", action: "remove", entityType: "community_reply", entityId: "reply-1" }),
183
+ logEntry({ id: "log-1", action: "pin", entityId: "post-1" }),
184
+ logEntry({ id: "log-0", action: "mute", entityType: null, entityId: null }),
185
+ ];
186
+ const restorable = restorableLogEntries(entries);
187
+ expect([...restorable.keys()]).toEqual(["log-3"]);
188
+ expect(restorable.get("log-3")).toEqual({ type: "reply", id: "reply-1" });
189
+ });
190
+ });
191
+
192
+ describe("useCommunityModeration", () => {
193
+ it("asks for nothing and says so when the viewer does not moderate", async () => {
194
+ installAdapter([me(false)]);
195
+ const { result } = renderHook(() => useCommunityModeration(), { wrapper: wrapper({ user: account() }) });
196
+ await waitFor(() => expect(result.current.isChecking).toBe(false));
197
+ expect(result.current.canModerate).toBe(false);
198
+ expect(calls("get", "/public/community-moderation/queue")).toHaveLength(0);
199
+ expect(calls("get", "/public/community-moderation/log")).toHaveLength(0);
200
+ });
201
+
202
+ it("asks for nothing when signed out", async () => {
203
+ installAdapter([me(true)]);
204
+ const { result } = renderHook(() => useCommunityModeration(), { wrapper: wrapper() });
205
+ expect(result.current.canModerate).toBe(false);
206
+ expect(calls("get", "/public/community/me")).toHaveLength(0);
207
+ });
208
+
209
+ it("loads the queue and the log for THIS community", async () => {
210
+ installAdapter([
211
+ me(true),
212
+ get("/public/community-moderation/queue", () => QUEUE),
213
+ get("/public/community-moderation/log", () => ({
214
+ data: [logEntry({ id: "log-1" })],
215
+ total: 21,
216
+ page: 1,
217
+ pageSize: 20,
218
+ hasNextPage: true,
219
+ })),
220
+ ]);
221
+ const { result } = renderHook(() => useCommunityModeration(), { wrapper: wrapper({ user: account() }) });
222
+ await waitFor(() => expect(result.current.reports).toHaveLength(1));
223
+ expect(result.current.pendingPosts[0].id).toBe("post-1");
224
+ expect(result.current.pendingReplies[0].id).toBe("reply-1");
225
+ expect(calls("get", "/public/community-moderation/queue")[0].params).toEqual({
226
+ profileId: PROFILE_ID,
227
+ communityId: COMMUNITY.id,
228
+ });
229
+
230
+ await waitFor(() => expect(result.current.log.entries).toHaveLength(1));
231
+ expect(result.current.log.hasNextPage).toBe(true);
232
+ expect(result.current.log.restorable.has("log-1")).toBe(true);
233
+ expect(calls("get", "/public/community-moderation/log")[0].params).toEqual({
234
+ profileId: PROFILE_ID,
235
+ communityId: COMMUNITY.id,
236
+ page: 1,
237
+ limit: 20,
238
+ });
239
+
240
+ act(() => result.current.log.nextPage());
241
+ await waitFor(() =>
242
+ expect(calls("get", "/public/community-moderation/log").some((r) => r.params?.page === 2)).toBe(true),
243
+ );
244
+ });
245
+
246
+ it("sends each queue action with the profile, and the reason where one is given", async () => {
247
+ installAdapter([
248
+ me(true),
249
+ get("/public/community-moderation/queue", () => QUEUE),
250
+ get("/public/community-moderation/log", () => ({
251
+ data: [],
252
+ total: 0,
253
+ page: 1,
254
+ pageSize: 20,
255
+ hasNextPage: false,
256
+ })),
257
+ post("/public/community-moderation/reports/rep-1/resolve", () => ({ success: true })),
258
+ post("/public/community-moderation/posts/post-1/approve", () => ({ id: "post-1" })),
259
+ post("/public/community-moderation/posts/post-1/reject", () => ({ success: true })),
260
+ post("/public/community-moderation/replies/reply-1/reject", () => ({ success: true })),
261
+ ]);
262
+ const { result } = renderHook(() => useCommunityModeration(), { wrapper: wrapper({ user: account() }) });
263
+ await waitFor(() => expect(result.current.reports).toHaveLength(1));
264
+
265
+ await act(async () => {
266
+ expect(await result.current.resolveReport("rep-1", "resolve_and_hide")).toBe(true);
267
+ });
268
+ expect(calls("post", "/public/community-moderation/reports/rep-1/resolve")[0].body).toEqual({
269
+ profileId: PROFILE_ID,
270
+ action: "resolved",
271
+ hideContent: true,
272
+ });
273
+
274
+ await act(async () => {
275
+ await result.current.resolveReport("rep-1", "dismiss");
276
+ });
277
+ expect(calls("post", "/public/community-moderation/reports/rep-1/resolve")[1].body).toMatchObject({
278
+ action: "dismissed",
279
+ hideContent: false,
280
+ });
281
+
282
+ await act(async () => {
283
+ await result.current.approvePost({ id: "post-1" });
284
+ await result.current.rejectPost({ id: "post-1" }, " off topic ");
285
+ await result.current.rejectReply({ id: "reply-1", postId: "post-5" }, "");
286
+ });
287
+ expect(calls("post", "/public/community-moderation/posts/post-1/approve")[0].body).toEqual({
288
+ profileId: PROFILE_ID,
289
+ });
290
+ expect(calls("post", "/public/community-moderation/posts/post-1/reject")[0].body).toEqual({
291
+ profileId: PROFILE_ID,
292
+ reason: "off topic",
293
+ });
294
+ // A blank reason is not sent at all.
295
+ expect(calls("post", "/public/community-moderation/replies/reply-1/reject")[0].body).toEqual({
296
+ profileId: PROFILE_ID,
297
+ });
298
+ });
299
+
300
+ it("keeps the server's refusal on the row it happened to (approving twice is a 409)", async () => {
301
+ installAdapter([
302
+ me(true),
303
+ get("/public/community-moderation/queue", () => QUEUE),
304
+ get("/public/community-moderation/log", () => ({
305
+ data: [],
306
+ total: 0,
307
+ page: 1,
308
+ pageSize: 20,
309
+ hasNextPage: false,
310
+ })),
311
+ post("/public/community-moderation/posts/post-1/approve", () => {
312
+ throw { status: 409, message: "This is not waiting for approval." };
313
+ }),
314
+ ]);
315
+ const { result } = renderHook(() => useCommunityModeration(), { wrapper: wrapper({ user: account() }) });
316
+ await waitFor(() => expect(result.current.pendingPosts).toHaveLength(1));
317
+ await act(async () => {
318
+ expect(await result.current.approvePost({ id: "post-1" })).toBe(false);
319
+ });
320
+ expect(result.current.error).toEqual({ id: "post-1", message: "This is not waiting for approval." });
321
+ expect(result.current.busyId).toBeNull();
322
+ });
323
+ });
324
+
325
+ describe("useCommunityModeratorActions", () => {
326
+ it("is not a moderator until /me says so", async () => {
327
+ installAdapter([me(false)]);
328
+ const { result } = renderHook(() => useCommunityModeratorActions(), { wrapper: wrapper({ user: account() }) });
329
+ await waitFor(() => expect(calls("get", "/public/community/me")).toHaveLength(1));
330
+ expect(result.current.canModerate).toBe(false);
331
+ });
332
+
333
+ it("pins, removes with a reason, and mutes the author in THIS community", async () => {
334
+ installAdapter([
335
+ me(true),
336
+ post("/public/community-moderation/posts/post-1/pin", () => ({ id: "post-1", isPinned: true })),
337
+ post("/public/community-moderation/posts/post-1/remove", () => ({ success: true })),
338
+ post("/public/community-moderation/replies/reply-1/remove", () => ({ success: true })),
339
+ post("/public/community-moderation/members/account-2/mute", () => ({ success: true })),
340
+ ]);
341
+ const { result } = renderHook(() => useCommunityModeratorActions(), { wrapper: wrapper({ user: account() }) });
342
+ await waitFor(() => expect(result.current.canModerate).toBe(true));
343
+ expect(result.current.currentAccountId).toBe("account-1");
344
+
345
+ const before = Date.now();
346
+ await act(async () => {
347
+ await result.current.pinPost({ id: "post-1" });
348
+ await result.current.removePost({ id: "post-1" }, "spam");
349
+ await result.current.removeReply({ id: "reply-1", postId: "post-1" });
350
+ await result.current.muteMember("account-2", 7);
351
+ });
352
+ expect(calls("post", "/public/community-moderation/posts/post-1/pin")[0].body).toEqual({ profileId: PROFILE_ID });
353
+ expect(calls("post", "/public/community-moderation/posts/post-1/remove")[0].body).toEqual({
354
+ profileId: PROFILE_ID,
355
+ reason: "spam",
356
+ });
357
+ expect(calls("post", "/public/community-moderation/replies/reply-1/remove")[0].body).toEqual({
358
+ profileId: PROFILE_ID,
359
+ });
360
+
361
+ const mute = calls("post", "/public/community-moderation/members/account-2/mute")[0].body!;
362
+ expect(mute.profileId).toBe(PROFILE_ID);
363
+ expect(mute.communityId).toBe(COMMUNITY.id);
364
+ const until = new Date(mute.mutedUntil as string).getTime();
365
+ expect(until - before).toBeGreaterThanOrEqual(7 * 24 * 3600 * 1000 - 1000);
366
+ expect(until - before).toBeLessThan(7 * 24 * 3600 * 1000 + 60_000);
367
+ });
368
+
369
+ it("unmutes with a null time", async () => {
370
+ installAdapter([me(true), post("/public/community-moderation/members/account-2/mute", () => ({ success: true }))]);
371
+ const { result } = renderHook(() => useCommunityModeratorActions(), { wrapper: wrapper({ user: account() }) });
372
+ await waitFor(() => expect(result.current.canModerate).toBe(true));
373
+ await act(async () => {
374
+ await result.current.unmuteMember("account-2");
375
+ });
376
+ expect(calls("post", "/public/community-moderation/members/account-2/mute")[0].body).toEqual({
377
+ profileId: PROFILE_ID,
378
+ communityId: COMMUNITY.id,
379
+ mutedUntil: null,
380
+ });
381
+ });
382
+
383
+ it("surfaces a refusal as the server worded it", async () => {
384
+ installAdapter([
385
+ me(true),
386
+ post("/public/community-moderation/posts/post-1/lock", () => {
387
+ throw { status: 404, message: "Post not found" };
388
+ }),
389
+ ]);
390
+ const { result } = renderHook(() => useCommunityModeratorActions(), { wrapper: wrapper({ user: account() }) });
391
+ await waitFor(() => expect(result.current.canModerate).toBe(true));
392
+ await act(async () => {
393
+ expect(await result.current.lockPost({ id: "post-1" })).toBe(false);
394
+ });
395
+ expect(result.current.failed).toBe(true);
396
+ expect(result.current.error).toBe("Post not found");
397
+ });
398
+ });
399
+
400
+ describe("the Moderation tab in the member home nav", () => {
401
+ it("shows only to a moderator of this community", async () => {
402
+ installAdapter([me(true)]);
403
+ const { result } = renderHook(() => useMemberHomeNav({ currentPath: "/i/communities/fan-club/home/moderation" }), {
404
+ wrapper: wrapper({ user: account() }),
405
+ });
406
+ await waitFor(() => expect(result.current.visibleItems.map((i) => i.key)).toContain("moderation"));
407
+ expect(result.current.activeKey).toBe("moderation");
408
+ expect(result.current.items.find((i) => i.key === "moderation")?.href).toBe(
409
+ "/i/communities/fan-club/home/moderation",
410
+ );
411
+ });
412
+
413
+ it("stays hidden for a member who does not moderate", async () => {
414
+ installAdapter([me(false)]);
415
+ const { result } = renderHook(() => useMemberHomeNav({}), { wrapper: wrapper({ user: account() }) });
416
+ await waitFor(() => expect(calls("get", "/public/community/me")).toHaveLength(1));
417
+ await waitFor(() => expect(result.current.isReady).toBe(true));
418
+ expect(result.current.visibleItems.map((i) => i.key)).not.toContain("moderation");
419
+ });
420
+ });
@@ -207,21 +207,30 @@ describe("useMemberHomeNav", () => {
207
207
  get("/public/community/spaces", () => [{ id: "s-1", name: "Space", tiers: [] }]),
208
208
  get("/public/chat/channels", () => [{ id: "c-1", name: "General", type: "public", description: null }]),
209
209
  get("/public/post-collections", () => ({ data: [{ id: "col-1" }], total: 1, page: 1, pageSize: 10 })),
210
- get("/public/scheduled-calls/mine", () => [{ id: "call-1" }]),
211
210
  ]);
212
211
  const { result } = renderHook(() => useMemberHomeNav({ currentPath: "/i/communities/fan-club/home/chat" }), { wrapper: wrapper() });
213
212
  await waitFor(() => expect(result.current.isReady).toBe(true));
214
- await waitFor(() => expect(result.current.visibleItems).toHaveLength(7));
213
+ await waitFor(() => expect(result.current.visibleItems).toHaveLength(6));
215
214
  const hrefs = Object.fromEntries(result.current.items.map((item) => [item.key, item.href]));
216
215
  expect(hrefs).toEqual({
217
216
  posts: "/i/communities/fan-club/home",
218
217
  collections: "/i/communities/fan-club/home/collections",
219
218
  spaces: "/i/communities/fan-club/home/spaces",
219
+ // Built, but hidden: signed out, so not a member.
220
220
  calendar: "/i/communities/fan-club/home/calendar",
221
+ // Built, but hidden: signed out, so not a member.
222
+ classroom: "/i/communities/fan-club/home/classroom",
221
223
  chat: "/i/communities/fan-club/home/chat",
222
224
  leaderboard: "/i/communities/fan-club/home/leaderboard",
223
225
  notifications: "/i/communities/fan-club/home/notifications",
226
+ // Built, but hidden: signed out, so not a member.
227
+ members: "/i/communities/fan-club/home/members",
228
+ // Built, but hidden: this community has no wiki pages listed.
229
+ wiki: "/i/communities/fan-club/home/wiki",
230
+ // Built, but hidden: this viewer does not moderate the community.
231
+ moderation: "/i/communities/fan-club/home/moderation",
224
232
  });
233
+ expect(result.current.visibleItems.map((item) => item.key)).not.toContain("moderation");
225
234
  expect(result.current.activeKey).toBe("chat");
226
235
  expect(JSON.stringify(result.current.items)).not.toContain("/i/account");
227
236
  });
@@ -0,0 +1,224 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { renderHook, waitFor } from "@testing-library/react";
4
+ import {
5
+ defaultWikiPageSlug,
6
+ extractWikiToc,
7
+ parseWikiContent,
8
+ shapeWikiList,
9
+ useCommunityWiki,
10
+ useCommunityWikiPage,
11
+ wikiHeadingSlug,
12
+ } from "../communities/useCommunityWiki";
13
+ import { useMemberHomeNav } from "../memberHome/useMemberHomeNav";
14
+ import type { CommunityWikiPageSummary } from "../../../data/queries/useCommunityWiki";
15
+ import { PROFILE_ID, account, calls, get, installAdapter, restoreAdapter, wrapper } from "./communityHarness";
16
+
17
+ /**
18
+ * The community wiki as the site reads it. These pin the list shaping (what is
19
+ * readable, what is locked, what the About page shows), which page the Wiki tab
20
+ * opens on, the table of contents a page grows from its headings, and what the
21
+ * hooks SEND: the community named by slug, the profile, and nothing more.
22
+ */
23
+
24
+ afterEach(restoreAdapter);
25
+
26
+ const summary = (overrides: Partial<CommunityWikiPageSummary>): CommunityWikiPageSummary => ({
27
+ id: "page-x",
28
+ title: "Page",
29
+ slug: "page",
30
+ isPublic: false,
31
+ locked: false,
32
+ unlockTiers: [],
33
+ updatedAt: "2026-09-01T00:00:00.000Z",
34
+ ...overrides,
35
+ });
36
+
37
+ const PAGES = [
38
+ summary({
39
+ id: "p1",
40
+ slug: "vip-lounge",
41
+ title: "VIP lounge",
42
+ locked: true,
43
+ unlockTiers: [{ id: "tier-gold", name: "Gold" }],
44
+ }),
45
+ summary({ id: "p2", slug: "start-here", title: "Start here" }),
46
+ summary({ id: "p3", slug: "community-guidelines", title: "Community guidelines", isPublic: true }),
47
+ ];
48
+
49
+ const text = (value: string) => ({ type: "text", version: 1, text: value });
50
+ const heading = (tag: string, value: string) => ({ type: "heading", version: 1, tag, children: [text(value)] });
51
+ const paragraph = (value: string) => ({ type: "paragraph", version: 1, children: [text(value)] });
52
+ const doc = (...children: unknown[]) => ({ root: { type: "root", version: 1, children } });
53
+
54
+ describe("shapeWikiList", () => {
55
+ it("splits readable from locked, keeping the wiki's order", () => {
56
+ const shaped = shapeWikiList(PAGES);
57
+ expect(shaped.all.map((p) => p.slug)).toEqual(["vip-lounge", "start-here", "community-guidelines"]);
58
+ expect(shaped.readable.map((p) => p.slug)).toEqual(["start-here", "community-guidelines"]);
59
+ expect(shaped.locked.map((p) => p.slug)).toEqual(["vip-lounge"]);
60
+ });
61
+
62
+ it("keeps only public pages for the About page", () => {
63
+ expect(shapeWikiList(PAGES, { publicOnly: true }).all.map((p) => p.slug)).toEqual(["community-guidelines"]);
64
+ });
65
+
66
+ it("treats a missing or malformed list as empty", () => {
67
+ expect(shapeWikiList(undefined).all).toEqual([]);
68
+ expect(shapeWikiList({} as never).all).toEqual([]);
69
+ });
70
+ });
71
+
72
+ describe("defaultWikiPageSlug", () => {
73
+ it("opens the first page the viewer can read, skipping locked ones", () => {
74
+ expect(defaultWikiPageSlug(PAGES)).toBe("start-here");
75
+ });
76
+
77
+ it("is null when every page is locked, or there are none", () => {
78
+ expect(defaultWikiPageSlug([PAGES[0]])).toBeNull();
79
+ expect(defaultWikiPageSlug([])).toBeNull();
80
+ expect(defaultWikiPageSlug(undefined)).toBeNull();
81
+ });
82
+ });
83
+
84
+ describe("parseWikiContent", () => {
85
+ it("accepts the editor state as an object or as a JSON string", () => {
86
+ const state = doc(paragraph("hi"));
87
+ expect(parseWikiContent(state)?.root.type).toBe("root");
88
+ expect(parseWikiContent(JSON.stringify(state))?.root.type).toBe("root");
89
+ });
90
+
91
+ it("returns null for anything that is not an editor state", () => {
92
+ expect(parseWikiContent(null)).toBeNull();
93
+ expect(parseWikiContent("not json")).toBeNull();
94
+ expect(parseWikiContent(42)).toBeNull();
95
+ });
96
+ });
97
+
98
+ describe("extractWikiToc", () => {
99
+ it("lists the top-level headings in order, with their level", () => {
100
+ const toc = extractWikiToc(doc(heading("h2", "Getting started"), paragraph("..."), heading("h3", "Your profile")));
101
+ expect(toc).toEqual([
102
+ { id: "getting-started", text: "Getting started", level: 2 },
103
+ { id: "your-profile", text: "Your profile", level: 3 },
104
+ ]);
105
+ });
106
+
107
+ it("gives repeated titles unique anchors, and skips empty headings", () => {
108
+ const toc = extractWikiToc(doc(heading("h2", "FAQ"), heading("h2", " "), heading("h2", "FAQ")));
109
+ expect(toc.map((e) => e.id)).toEqual(["faq", "faq-2"]);
110
+ });
111
+
112
+ it("reads headings made of several text runs, and a string state", () => {
113
+ const multi = {
114
+ type: "heading",
115
+ version: 1,
116
+ tag: "h1",
117
+ children: [text("Hello "), { type: "link", version: 1, children: [text("world")] }],
118
+ };
119
+ expect(extractWikiToc(JSON.stringify(doc(multi)))).toEqual([{ id: "hello-world", text: "Hello world", level: 1 }]);
120
+ });
121
+
122
+ it("is empty for a page with no headings (the seeded pages are plain paragraphs)", () => {
123
+ expect(extractWikiToc(doc(paragraph("Welcome")))).toEqual([]);
124
+ });
125
+ });
126
+
127
+ describe("wikiHeadingSlug", () => {
128
+ it("makes a URL-safe anchor, keeping letters beyond ASCII", () => {
129
+ expect(wikiHeadingSlug("Membership & billing!")).toBe("membership-billing");
130
+ expect(wikiHeadingSlug("Über uns")).toBe("uber-uns");
131
+ expect(wikiHeadingSlug("???")).toBe("section");
132
+ });
133
+ });
134
+
135
+ describe("useCommunityWiki", () => {
136
+ it("asks for this community's wiki by slug, and shapes it", async () => {
137
+ installAdapter([get("/public/communities/fan-club/wiki", () => PAGES)]);
138
+ const { result } = renderHook(() => useCommunityWiki(), { wrapper: wrapper({ user: account() }) });
139
+ await waitFor(() => expect(result.current.pages).toHaveLength(3));
140
+ expect(result.current.defaultSlug).toBe("start-here");
141
+ expect(result.current.lockedPages[0].slug).toBe("vip-lounge");
142
+ expect(calls("get", "/public/communities/fan-club/wiki")[0].params).toEqual({ profileId: PROFILE_ID });
143
+ // No community home around it, so the upgrade leads to the About page with the tier chosen.
144
+ expect(result.current.unlock).toBeNull();
145
+ expect(result.current.unlockHref(PAGES[0])).toBe("/i/communities/fan-club?tier=tier-gold");
146
+ });
147
+
148
+ it("shows only public pages with publicOnly, signed out too", async () => {
149
+ installAdapter([get("/public/communities/fan-club/wiki", () => PAGES)]);
150
+ const { result } = renderHook(() => useCommunityWiki({ publicOnly: true }), { wrapper: wrapper() });
151
+ await waitFor(() => expect(result.current.pages).toHaveLength(1));
152
+ expect(result.current.readablePages[0].slug).toBe("community-guidelines");
153
+ });
154
+ });
155
+
156
+ describe("useCommunityWikiPage", () => {
157
+ it("loads one page with its table of contents", async () => {
158
+ installAdapter([
159
+ get("/public/communities/fan-club/wiki/start-here", () => ({
160
+ id: "p2",
161
+ title: "Start here",
162
+ slug: "start-here",
163
+ isPublic: false,
164
+ content: doc(heading("h2", "Welcome"), paragraph("..."), heading("h2", "Where to go next")),
165
+ updatedAt: "2026-09-01T00:00:00.000Z",
166
+ })),
167
+ ]);
168
+ const { result } = renderHook(() => useCommunityWikiPage("start-here"), { wrapper: wrapper({ user: account() }) });
169
+ await waitFor(() => expect(result.current.page?.title).toBe("Start here"));
170
+ expect(result.current.showToc).toBe(true);
171
+ expect(result.current.headingIds).toEqual(["welcome", "where-to-go-next"]);
172
+ expect(calls("get", "/public/communities/fan-club/wiki/start-here")[0].params).toEqual({ profileId: PROFILE_ID });
173
+ });
174
+
175
+ it("hides the table of contents under two headings", async () => {
176
+ installAdapter([
177
+ get("/public/communities/fan-club/wiki/faq", () => ({
178
+ id: "p4",
179
+ title: "FAQ",
180
+ slug: "faq",
181
+ isPublic: false,
182
+ content: doc(heading("h2", "Only one"), paragraph("...")),
183
+ updatedAt: "2026-09-01T00:00:00.000Z",
184
+ })),
185
+ ]);
186
+ const { result } = renderHook(() => useCommunityWikiPage("faq"), { wrapper: wrapper({ user: account() }) });
187
+ await waitFor(() => expect(result.current.page).not.toBeNull());
188
+ expect(result.current.toc).toHaveLength(1);
189
+ expect(result.current.showToc).toBe(false);
190
+ });
191
+
192
+ it("reports a page the viewer may not read as not found, without retrying", async () => {
193
+ installAdapter([
194
+ get("/public/communities/fan-club/wiki/vip-lounge", () => {
195
+ throw { status: 404, message: "Not found" };
196
+ }),
197
+ ]);
198
+ const { result } = renderHook(() => useCommunityWikiPage("vip-lounge"), { wrapper: wrapper({ user: account() }) });
199
+ await waitFor(() => expect(result.current.notFound).toBe(true));
200
+ expect(result.current.isError).toBe(false);
201
+ expect(calls("get", "/public/communities/fan-club/wiki/vip-lounge")).toHaveLength(1);
202
+ });
203
+ });
204
+
205
+ describe("the Wiki tab in the member home nav", () => {
206
+ it("shows once the community has a page this viewer may see", async () => {
207
+ installAdapter([get("/public/communities/fan-club/wiki", () => PAGES)]);
208
+ const { result } = renderHook(
209
+ () => useMemberHomeNav({ currentPath: "/i/communities/fan-club/home/wiki/start-here" }),
210
+ {
211
+ wrapper: wrapper({ user: account() }),
212
+ },
213
+ );
214
+ await waitFor(() => expect(result.current.visibleItems.map((i) => i.key)).toContain("wiki"));
215
+ expect(result.current.activeKey).toBe("wiki");
216
+ });
217
+
218
+ it("stays hidden with no pages", async () => {
219
+ installAdapter([get("/public/communities/fan-club/wiki", () => [])]);
220
+ const { result } = renderHook(() => useMemberHomeNav({}), { wrapper: wrapper({ user: account() }) });
221
+ await waitFor(() => expect(result.current.isReady).toBe(true));
222
+ expect(result.current.visibleItems.map((i) => i.key)).not.toContain("wiki");
223
+ });
224
+ });