@tribe-nest/forge 3.54.0 → 3.58.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.
- package/package.json +4 -2
- package/src/_tests/workspaceAliases.ts +1 -0
- package/src/contexts/CartContext.tsx +88 -5
- package/src/data/queries/useCallBreakouts.ts +112 -0
- package/src/data/queries/useCheckouts.ts +15 -2
- package/src/data/queries/useCoachingProducts.ts +15 -1
- package/src/data/queries/useCourses.ts +15 -1
- package/src/data/queries/useEvents.ts +80 -1
- package/src/data/queries/useMembership.ts +176 -4
- package/src/data/queries/useMyBookings.ts +16 -0
- package/src/data/queries/useNotifications.ts +1 -1
- package/src/data/queries/useProducts.ts +15 -1
- package/src/data/queries/useSubscriptions.ts +21 -1
- package/src/i18n/de.json +153 -0
- package/src/i18n/en.json +153 -0
- package/src/index.ts +1 -0
- package/src/server/_tests/buildPwaHead.spec.ts +1 -1
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +1 -1
- package/src/server/index.ts +75 -1
- package/src/server/platformEvents.generated.ts +60 -0
- package/src/server/pwa.ts +1 -1
- package/src/types/models.ts +137 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +55 -1
- package/src/ui/analytics/_tests/ForgeAnalytics.engagement.spec.tsx +78 -0
- package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +3 -1
- package/src/ui/headless/checkout/_tests/cartResume.spec.ts +3 -10
- package/src/ui/headless/checkout/_tests/seatConflict.spec.ts +187 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +3 -11
- package/src/ui/headless/checkout/_tests/useCheckoutSeats.spec.tsx +313 -0
- package/src/ui/headless/checkout/seatConflict.ts +117 -0
- package/src/ui/headless/checkout/useCheckout.ts +178 -55
- package/src/ui/headless/event/_tests/seatingFixture.ts +111 -0
- package/src/ui/headless/event/_tests/useEventCheckoutSeats.spec.tsx +443 -0
- package/src/ui/headless/event/_tests/useEventSeating.spec.tsx +154 -0
- package/src/ui/headless/event/useEventCheckout.ts +362 -22
- package/src/ui/headless/event/useEventSeating.ts +233 -0
- package/src/ui/headless/forms/_tests/usePhoneInput.spec.ts +187 -0
- package/src/ui/headless/forms/useEmailListForm.ts +37 -6
- package/src/ui/headless/forms/usePhoneInput.ts +294 -0
- package/src/ui/headless/forms/useSectionedForm.ts +2 -2
- package/src/ui/headless/index.ts +48 -1
- package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
- package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
- package/src/ui/headless/membership/membershipApplication.ts +109 -0
- package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
- package/src/ui/headless/membership/membershipTrial.ts +53 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
- package/src/ui/index.ts +16 -0
- package/src/ui/media/CallStage.tsx +107 -10
- package/src/ui/media/_tests/CallBreakoutsPanel.spec.tsx +271 -0
- package/src/ui/media/_tests/CallStageBreakouts.spec.tsx +212 -0
- package/src/ui/media/_tests/callState.spec.ts +103 -1
- package/src/ui/media/callBreakouts.ts +57 -0
- package/src/ui/media/callState.ts +83 -23
- package/src/ui/media/index.ts +11 -0
- package/src/ui/media/panels/CallBreakoutsPanel.tsx +395 -0
- package/src/ui/media/panels/CallSidePanel.tsx +37 -4
- package/src/ui/media/stage/CallCenterNotice.tsx +5 -3
- package/src/ui/media/stage/CallControlsBar.tsx +46 -0
- package/src/ui/media/stage/CallTopBar.tsx +1 -1
- package/src/ui/payment/ForgePaymentProvider.tsx +14 -0
- package/src/ui/payment/ForgeStripePayment.tsx +78 -7
- package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
- package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
- package/src/ui/payment/stripeIntentKind.ts +32 -0
- package/src/ui/shell/TribeNestApp.tsx +1 -1
- package/src/ui/styled/AccountDashboard.tsx +165 -2
- package/src/ui/styled/BundleConfirmation.tsx +47 -2
- package/src/ui/styled/Cart.tsx +211 -135
- package/src/ui/styled/Checkout.tsx +163 -32
- package/src/ui/styled/EmailListForm.tsx +7 -9
- package/src/ui/styled/EventTickets.tsx +143 -4
- package/src/ui/styled/LoginForm.tsx +5 -3
- package/src/ui/styled/MembershipCheckout.tsx +596 -256
- package/src/ui/styled/MembershipTierCallout.tsx +17 -3
- package/src/ui/styled/MembershipTiers.tsx +148 -6
- package/src/ui/styled/PhoneInput.tsx +127 -0
- package/src/ui/styled/ProductGrid.tsx +14 -2
- package/src/ui/styled/SignupForm.tsx +4 -2
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -0
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
- package/src/ui/styled/_tests/CartSeats.spec.tsx +109 -0
- package/src/ui/styled/_tests/CheckoutSeatsConflict.spec.tsx +194 -0
- package/src/ui/styled/_tests/EventSeatPicker.spec.tsx +199 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
- package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
- package/src/ui/styled/forge-utilities.css +67 -0
- package/src/ui/styled/seating/EventSeatPicker.tsx +190 -0
- package/src/ui/styled/seating/SeatLegend.tsx +73 -0
- package/src/ui/styled/seating/SeatMapCanvas.tsx +647 -0
- package/src/ui/styled/seating/paint.ts +79 -0
- package/src/ui/styled/seating/useSeatMapViewport.ts +248 -0
- package/src/ui/styled/seating/viewport.ts +86 -0
- package/src/utils/_tests/engagementTracker.spec.ts +111 -0
- package/src/utils/engagementTracker.ts +73 -0
- package/src/utils/landing.ts +1 -1
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
|
|
18
18
|
import { useForgeT, type ForgeT } from "../../i18n";
|
|
19
19
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
20
|
+
import type { CallStageBreakouts } from "./callBreakouts";
|
|
20
21
|
import type { CallStageChat } from "./callChat";
|
|
21
22
|
import {
|
|
22
23
|
callHeadCount,
|
|
@@ -24,10 +25,11 @@ import {
|
|
|
24
25
|
callTiles,
|
|
25
26
|
remoteAudioProducerIds,
|
|
26
27
|
stageNotice,
|
|
28
|
+
type BreakoutMove,
|
|
27
29
|
type CallTile,
|
|
28
30
|
} from "./callState";
|
|
29
31
|
import { useFlyingReactions, useRaisedHands } from "./hooks/useCallSignals";
|
|
30
|
-
import { CallSidePanel, type SidePanelTab } from "./panels/CallSidePanel";
|
|
32
|
+
import { CallSidePanel, showsRoomsTab, type SidePanelTab } from "./panels/CallSidePanel";
|
|
31
33
|
import { resolveLayout, GALLERY_PAGE_SIZE, pickDominant, type StageLayout, type ViewChoice } from "./stage/callLayout";
|
|
32
34
|
import { diffPeers, unreadCount } from "./stage/callSignals";
|
|
33
35
|
import { createCallSounds } from "./stage/callSounds";
|
|
@@ -96,6 +98,12 @@ export type CallStageProps = {
|
|
|
96
98
|
* how. Absent means no chat tab or button is drawn.
|
|
97
99
|
*/
|
|
98
100
|
chat?: CallStageChat;
|
|
101
|
+
/**
|
|
102
|
+
* The breakout-rooms adapter, injected like `chat`. Absent means no rooms
|
|
103
|
+
* tab, no Rooms button and no Return to main: the stage draws exactly what
|
|
104
|
+
* it drew before rooms existed.
|
|
105
|
+
*/
|
|
106
|
+
breakouts?: CallStageBreakouts;
|
|
99
107
|
/** Rendering hint for host courtesies (lower-hand). The server enforces
|
|
100
108
|
* every real host action itself. */
|
|
101
109
|
isHost?: boolean;
|
|
@@ -161,6 +169,7 @@ export function CallStage({
|
|
|
161
169
|
aspectRatio = 16 / 9,
|
|
162
170
|
recordingControl,
|
|
163
171
|
chat,
|
|
172
|
+
breakouts,
|
|
164
173
|
isHost = false,
|
|
165
174
|
mediaDefaults,
|
|
166
175
|
cameraSimulcast = false,
|
|
@@ -168,7 +177,7 @@ export function CallStage({
|
|
|
168
177
|
const themeTokens = useThemeTokens();
|
|
169
178
|
const tokens = useMemo(() => stageTokens(themeTokens), [themeTokens]);
|
|
170
179
|
const t = useForgeT();
|
|
171
|
-
const { connectionState, error, recovering, retry } = useMediaRoom();
|
|
180
|
+
const { connectionState, error, recovering, retry, room } = useMediaRoom();
|
|
172
181
|
const state = useRoomState();
|
|
173
182
|
const peers = useParticipants();
|
|
174
183
|
const activeSpeakers = useActiveSpeakers();
|
|
@@ -185,9 +194,23 @@ export function CallStage({
|
|
|
185
194
|
|
|
186
195
|
const [ui, dispatch] = useReducer(uiReducer, initialUi);
|
|
187
196
|
|
|
197
|
+
/**
|
|
198
|
+
* A breakout move in flight, from either side.
|
|
199
|
+
*
|
|
200
|
+
* The node's `moveRequested` lands in `state.moving` (the host sent us
|
|
201
|
+
* somewhere); a Join or Return the person pressed themselves goes through
|
|
202
|
+
* `room.moveTo()`, which the SDK reports exactly like a server drain, so the
|
|
203
|
+
* stage remembers the destination itself in `localMoving` until the room is
|
|
204
|
+
* back. Both read the same way everywhere below.
|
|
205
|
+
*/
|
|
206
|
+
const [localMoving, setLocalMoving] = useState<BreakoutMove | null>(null);
|
|
207
|
+
const [movePending, setMovePending] = useState(false);
|
|
208
|
+
const [moveError, setMoveError] = useState<string | null>(null);
|
|
209
|
+
const moving: BreakoutMove | null = state.moving ?? localMoving;
|
|
210
|
+
|
|
188
211
|
const status = useMemo(
|
|
189
|
-
() => callStatus({ connectionState, error, recovering, phase: state.phase, closedReason: state.closedReason }),
|
|
190
|
-
[connectionState, error, recovering, state.phase, state.closedReason],
|
|
212
|
+
() => callStatus({ connectionState, error, recovering, phase: state.phase, closedReason: state.closedReason, moving }),
|
|
213
|
+
[connectionState, error, recovering, state.phase, state.closedReason, moving],
|
|
191
214
|
);
|
|
192
215
|
|
|
193
216
|
// Everyone by name, INCLUDING self as "You" - the peer list is other people
|
|
@@ -211,11 +234,54 @@ export function CallStage({
|
|
|
211
234
|
const remoteCount = useMemo(() => new Set(tiles.map((tile) => tile.identity)).size, [tiles]);
|
|
212
235
|
const everSawPeersRef = useRef(false);
|
|
213
236
|
if (remoteCount > 0) everSawPeersRef.current = true;
|
|
237
|
+
const breakoutsActive = breakouts?.active ?? false;
|
|
214
238
|
const notice = useMemo(
|
|
215
|
-
() => stageNotice({ status, remoteCount, everSawPeers: everSawPeersRef.current }),
|
|
216
|
-
[status, remoteCount],
|
|
239
|
+
() => stageNotice({ status, remoteCount, everSawPeers: everSawPeersRef.current, moving, breakoutsActive }),
|
|
240
|
+
[status, remoteCount, moving, breakoutsActive],
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The self-move: write the assignment, then leave and reconnect. The backend
|
|
245
|
+
* mints the next ticket for the room the assignment names, so the reconnect
|
|
246
|
+
* IS the move; captures survive it the way they survive a drain.
|
|
247
|
+
*/
|
|
248
|
+
const selfMove = useCallback(
|
|
249
|
+
async (roomPublicId: string | null) => {
|
|
250
|
+
if (!breakouts || !room) return;
|
|
251
|
+
setMoveError(null);
|
|
252
|
+
setMovePending(true);
|
|
253
|
+
try {
|
|
254
|
+
await breakouts.moveSelf(roomPublicId);
|
|
255
|
+
const name = roomPublicId ? (breakouts.rooms.find((r) => r.publicId === roomPublicId)?.name ?? roomPublicId) : null;
|
|
256
|
+
setLocalMoving({ room: roomPublicId ?? "main", name });
|
|
257
|
+
await room.moveTo();
|
|
258
|
+
} catch (cause) {
|
|
259
|
+
setLocalMoving(null);
|
|
260
|
+
setMoveError(cause instanceof Error && cause.message ? cause.message : t("forge.call_stage.breakouts.error"));
|
|
261
|
+
} finally {
|
|
262
|
+
setMovePending(false);
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
[breakouts, room, t],
|
|
217
266
|
);
|
|
218
267
|
|
|
268
|
+
// The room is back (or gone): the local destination has been reached or is
|
|
269
|
+
// moot, and the assignment view is re-read so the panel shows where everyone
|
|
270
|
+
// landed. Every RETURN to connected refreshes, not only after our own move:
|
|
271
|
+
// the host may have reassigned us while we were away.
|
|
272
|
+
const previousConnectionRef = useRef(connectionState);
|
|
273
|
+
const refreshRef = useRef(breakouts?.refresh);
|
|
274
|
+
refreshRef.current = breakouts?.refresh;
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
const previous = previousConnectionRef.current;
|
|
277
|
+
previousConnectionRef.current = connectionState;
|
|
278
|
+
if (connectionState === "connected" && previous !== "connected") {
|
|
279
|
+
setLocalMoving(null);
|
|
280
|
+
if (previous !== "idle" && previous !== "connecting") refreshRef.current?.();
|
|
281
|
+
}
|
|
282
|
+
if (connectionState === "closed") setLocalMoving(null);
|
|
283
|
+
}, [connectionState]);
|
|
284
|
+
|
|
219
285
|
// Dominant speaker with a hold, so the rail and speaker view do not flap.
|
|
220
286
|
const [dominant, setDominant] = useState<{ identity: string | null; since: number }>({ identity: null, since: 0 });
|
|
221
287
|
useEffect(() => {
|
|
@@ -308,6 +374,23 @@ export function CallStage({
|
|
|
308
374
|
|
|
309
375
|
const togglePanel = (tab: SidePanelTab) => dispatch({ type: "panel", panel: ui.panel === tab ? null : tab });
|
|
310
376
|
|
|
377
|
+
// A tab whose content went away (chat never injected, rooms closed under an
|
|
378
|
+
// attendee) falls back to People rather than drawing an empty rail.
|
|
379
|
+
const roomsTab = showsRoomsTab(breakouts);
|
|
380
|
+
const panelTab: SidePanelTab | null =
|
|
381
|
+
ui.panel === "chat" && !chat ? "people" : ui.panel === "rooms" && !roomsTab ? "people" : ui.panel;
|
|
382
|
+
|
|
383
|
+
// An attendee's rooms list is read on demand: once when the tab opens and
|
|
384
|
+
// then every few seconds while it stays open. The host's adapter polls on
|
|
385
|
+
// its own, so this only fires for a seat whose adapter does not.
|
|
386
|
+
const attendeeRoomsOpen = panelTab === "rooms" && !!breakouts && !breakouts.canManage;
|
|
387
|
+
useEffect(() => {
|
|
388
|
+
if (!attendeeRoomsOpen) return undefined;
|
|
389
|
+
refreshRef.current?.();
|
|
390
|
+
const timer = setInterval(() => refreshRef.current?.(), 5000);
|
|
391
|
+
return () => clearInterval(timer);
|
|
392
|
+
}, [attendeeRoomsOpen]);
|
|
393
|
+
|
|
311
394
|
const showSelfView =
|
|
312
395
|
!ui.selfHidden && layout.mode !== "duo" && connectionState === "connected" && (!notice || notice.kind === "waiting" || notice.kind === "everyone_left");
|
|
313
396
|
|
|
@@ -410,7 +493,7 @@ export function CallStage({
|
|
|
410
493
|
{ui.panel && narrow && (
|
|
411
494
|
<div data-testid="call-panel-overlay" style={{ position: "absolute", inset: 0, zIndex: 30, display: "flex" }}>
|
|
412
495
|
<CallSidePanel
|
|
413
|
-
tab={
|
|
496
|
+
tab={panelTab ?? "people"}
|
|
414
497
|
onTab={(tab) => dispatch({ type: "panel", panel: tab })}
|
|
415
498
|
onClose={() => dispatch({ type: "panel", panel: null })}
|
|
416
499
|
tokens={tokens}
|
|
@@ -423,6 +506,10 @@ export function CallStage({
|
|
|
423
506
|
onLowerHand={hands.lowerHandOf}
|
|
424
507
|
onLowerAll={hands.lowerAll}
|
|
425
508
|
unread={chatUnread}
|
|
509
|
+
breakouts={breakouts}
|
|
510
|
+
onSelfMove={(id) => void selfMove(id)}
|
|
511
|
+
movePending={movePending}
|
|
512
|
+
moveError={moveError}
|
|
426
513
|
fill
|
|
427
514
|
/>
|
|
428
515
|
</div>
|
|
@@ -431,7 +518,7 @@ export function CallStage({
|
|
|
431
518
|
|
|
432
519
|
{ui.panel && !narrow && (
|
|
433
520
|
<CallSidePanel
|
|
434
|
-
tab={
|
|
521
|
+
tab={panelTab ?? "people"}
|
|
435
522
|
onTab={(tab) => dispatch({ type: "panel", panel: tab })}
|
|
436
523
|
onClose={() => dispatch({ type: "panel", panel: null })}
|
|
437
524
|
tokens={tokens}
|
|
@@ -444,6 +531,10 @@ export function CallStage({
|
|
|
444
531
|
onLowerHand={hands.lowerHandOf}
|
|
445
532
|
onLowerAll={hands.lowerAll}
|
|
446
533
|
unread={chatUnread}
|
|
534
|
+
breakouts={breakouts}
|
|
535
|
+
onSelfMove={(id) => void selfMove(id)}
|
|
536
|
+
movePending={movePending}
|
|
537
|
+
moveError={moveError}
|
|
447
538
|
/>
|
|
448
539
|
)}
|
|
449
540
|
</div>
|
|
@@ -466,8 +557,14 @@ export function CallStage({
|
|
|
466
557
|
chatUnread={chatUnread}
|
|
467
558
|
onToggleChat={() => togglePanel("chat")}
|
|
468
559
|
onTogglePeople={() => togglePanel("people")}
|
|
469
|
-
chatOpen={
|
|
470
|
-
peopleOpen={
|
|
560
|
+
chatOpen={panelTab === "chat"}
|
|
561
|
+
peopleOpen={panelTab === "people"}
|
|
562
|
+
roomsAvailable={roomsTab}
|
|
563
|
+
roomsOpen={panelTab === "rooms"}
|
|
564
|
+
onToggleRooms={() => togglePanel("rooms")}
|
|
565
|
+
inBreakout={!!breakouts?.current}
|
|
566
|
+
onReturnToMain={() => void selfMove(null)}
|
|
567
|
+
movePending={movePending}
|
|
471
568
|
onGesture={unlockSounds}
|
|
472
569
|
/>
|
|
473
570
|
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { translateForge, type ForgeT } from "../../../i18n";
|
|
6
|
+
import type { CallStageBreakouts } from "../callBreakouts";
|
|
7
|
+
import { CallSidePanel, showsRoomsTab } from "../panels/CallSidePanel";
|
|
8
|
+
import type { StageTokens } from "../stage/stageTheme";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The rooms rail, rendered against a fake adapter.
|
|
12
|
+
*
|
|
13
|
+
* Three things are worth being sure about here, and none of them is "did it
|
|
14
|
+
* render": that a call WITHOUT rooms draws exactly the two tabs it always drew,
|
|
15
|
+
* that the host's controls reach the adapter with the right arguments (an
|
|
16
|
+
* assignment sent to the wrong room is silent), and that closing every room
|
|
17
|
+
* takes a second press.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
afterEach(cleanup);
|
|
21
|
+
|
|
22
|
+
const tokens: StageTokens = {
|
|
23
|
+
stageBg: "#0c0d10",
|
|
24
|
+
stageSurface: "#17181d",
|
|
25
|
+
stageChrome: "#1e2026",
|
|
26
|
+
stageText: "#f4f5f7",
|
|
27
|
+
stageMuted: "#9aa0ab",
|
|
28
|
+
stageBorder: "#2c2f37",
|
|
29
|
+
accent: "#0b6",
|
|
30
|
+
accentText: "#fff",
|
|
31
|
+
danger: "#c00",
|
|
32
|
+
cornerRadius: 8,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const t: ForgeT = (key, vars) => translateForge("en", key, vars);
|
|
36
|
+
|
|
37
|
+
const adapter = (over: Partial<CallStageBreakouts> = {}): CallStageBreakouts => ({
|
|
38
|
+
ready: true,
|
|
39
|
+
canManage: false,
|
|
40
|
+
active: false,
|
|
41
|
+
current: null,
|
|
42
|
+
main: { participantCount: 0, participants: [] },
|
|
43
|
+
rooms: [],
|
|
44
|
+
pending: false,
|
|
45
|
+
create: vi.fn(async () => undefined),
|
|
46
|
+
assign: vi.fn(async () => undefined),
|
|
47
|
+
moveSelf: vi.fn(async () => undefined),
|
|
48
|
+
closeAll: vi.fn(async () => undefined),
|
|
49
|
+
refresh: vi.fn(),
|
|
50
|
+
...over,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const ROOMS = [
|
|
54
|
+
{
|
|
55
|
+
publicId: "room-1",
|
|
56
|
+
name: "Room 1",
|
|
57
|
+
position: 1,
|
|
58
|
+
participantCount: 1,
|
|
59
|
+
participants: [{ identity: "anna", name: "Anna" }],
|
|
60
|
+
assigned: [
|
|
61
|
+
{ identity: "anna", name: "Anna" },
|
|
62
|
+
{ identity: "ben", name: "Ben" },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
{ publicId: "room-2", name: "Room 2", position: 2, participantCount: 0, participants: [], assigned: [] },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
const mount = (
|
|
69
|
+
props: { breakouts?: CallStageBreakouts; tab?: "chat" | "people" | "rooms"; onSelfMove?: (id: string | null) => void } = {},
|
|
70
|
+
) => {
|
|
71
|
+
const onTab = vi.fn();
|
|
72
|
+
const utils = render(
|
|
73
|
+
<CallSidePanel
|
|
74
|
+
tab={props.tab ?? "rooms"}
|
|
75
|
+
onTab={onTab}
|
|
76
|
+
onClose={() => undefined}
|
|
77
|
+
tokens={tokens}
|
|
78
|
+
t={t}
|
|
79
|
+
tiles={[]}
|
|
80
|
+
hands={[]}
|
|
81
|
+
isHost={!!props.breakouts?.canManage}
|
|
82
|
+
myIdentity="host-1"
|
|
83
|
+
onLowerHand={() => undefined}
|
|
84
|
+
onLowerAll={() => undefined}
|
|
85
|
+
unread={0}
|
|
86
|
+
breakouts={props.breakouts}
|
|
87
|
+
onSelfMove={props.onSelfMove ?? (() => undefined)}
|
|
88
|
+
movePending={false}
|
|
89
|
+
moveError={null}
|
|
90
|
+
/>,
|
|
91
|
+
);
|
|
92
|
+
return { ...utils, onTab };
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
describe("the rooms tab exists only when there is something behind it", () => {
|
|
96
|
+
it("draws no rooms tab and no rooms panel when the page injected no adapter", () => {
|
|
97
|
+
const { queryByText, queryByTestId, getByTestId } = mount({ breakouts: undefined, tab: "rooms" });
|
|
98
|
+
expect(showsRoomsTab(undefined)).toBe(false);
|
|
99
|
+
expect(queryByText("Rooms")).toBeNull();
|
|
100
|
+
expect(queryByTestId("call-breakouts-panel")).toBeNull();
|
|
101
|
+
// A rooms tab asked for with nothing behind it falls back to the roster.
|
|
102
|
+
expect(getByTestId("call-participants-panel")).toBeTruthy();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("hides the tab from an attendee while no room is open, and shows it once one is", () => {
|
|
106
|
+
expect(showsRoomsTab(adapter({ canManage: false, active: false }))).toBe(false);
|
|
107
|
+
expect(showsRoomsTab(adapter({ canManage: false, active: true }))).toBe(true);
|
|
108
|
+
// The host always has it: that is where rooms get created.
|
|
109
|
+
expect(showsRoomsTab(adapter({ canManage: true, active: false }))).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("the host's view", () => {
|
|
114
|
+
it("offers the create form while no room is open and sends count and assignment", async () => {
|
|
115
|
+
const breakouts = adapter({ canManage: true });
|
|
116
|
+
const { getByLabelText, getByText, queryByTestId } = mount({ breakouts });
|
|
117
|
+
|
|
118
|
+
expect(queryByTestId("breakouts-close-all")).toBeNull();
|
|
119
|
+
fireEvent.change(getByLabelText("Number of rooms"), { target: { value: "3" } });
|
|
120
|
+
fireEvent.change(getByLabelText("Assign people"), { target: { value: "manual" } });
|
|
121
|
+
fireEvent.click(getByText("Create rooms"));
|
|
122
|
+
|
|
123
|
+
await waitFor(() => expect(breakouts.create).toHaveBeenCalledWith({ count: 3, assign: "manual" }));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("refuses a count outside 1..20 without asking the server", () => {
|
|
127
|
+
const breakouts = adapter({ canManage: true });
|
|
128
|
+
const { getByLabelText, getByText } = mount({ breakouts });
|
|
129
|
+
fireEvent.change(getByLabelText("Number of rooms"), { target: { value: "21" } });
|
|
130
|
+
expect((getByText("Create rooms") as HTMLButtonElement).disabled).toBe(true);
|
|
131
|
+
fireEvent.change(getByLabelText("Number of rooms"), { target: { value: "0" } });
|
|
132
|
+
expect((getByText("Create rooms") as HTMLButtonElement).disabled).toBe(true);
|
|
133
|
+
expect(breakouts.create).not.toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("lists the main room and every room with live counts, arrivals and the people on their way", () => {
|
|
137
|
+
const breakouts = adapter({
|
|
138
|
+
canManage: true,
|
|
139
|
+
active: true,
|
|
140
|
+
main: { participantCount: 2, participants: [{ identity: "host-1", name: "Host" }, { identity: "carla", name: "Carla" }] },
|
|
141
|
+
rooms: ROOMS,
|
|
142
|
+
});
|
|
143
|
+
const { getByTestId } = mount({ breakouts });
|
|
144
|
+
|
|
145
|
+
const main = getByTestId("breakouts-room-main");
|
|
146
|
+
expect(main.textContent).toContain("Main room");
|
|
147
|
+
expect(main.textContent).toContain("2 in this room");
|
|
148
|
+
expect(main.textContent).toContain("Host (you)");
|
|
149
|
+
expect(main.textContent).toContain("Carla");
|
|
150
|
+
|
|
151
|
+
const room1 = getByTestId("breakouts-room-room-1");
|
|
152
|
+
expect(room1.textContent).toContain("Room 1");
|
|
153
|
+
expect(room1.textContent).toContain("1 in this room");
|
|
154
|
+
expect(room1.textContent).toContain("Anna");
|
|
155
|
+
// Assigned, not yet arrived: listed where they are GOING, and said so.
|
|
156
|
+
expect(room1.textContent).toContain("Ben (on the way)");
|
|
157
|
+
|
|
158
|
+
expect(getByTestId("breakouts-room-room-2").textContent).toContain("Nobody here yet");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("assigns a person through the select beside their name, and never offers one for the host themselves", async () => {
|
|
162
|
+
const breakouts = adapter({
|
|
163
|
+
canManage: true,
|
|
164
|
+
active: true,
|
|
165
|
+
main: { participantCount: 2, participants: [{ identity: "host-1", name: "Host" }, { identity: "carla", name: "Carla" }] },
|
|
166
|
+
rooms: ROOMS,
|
|
167
|
+
});
|
|
168
|
+
const { getByLabelText, queryByLabelText } = mount({ breakouts });
|
|
169
|
+
|
|
170
|
+
fireEvent.change(getByLabelText("Move Carla to a room"), { target: { value: "room-2" } });
|
|
171
|
+
await waitFor(() => expect(breakouts.assign).toHaveBeenCalledWith("carla", "room-2"));
|
|
172
|
+
|
|
173
|
+
// Back to main is `null`, not the empty string the option carries.
|
|
174
|
+
fireEvent.change(getByLabelText("Move Anna to a room"), { target: { value: "" } });
|
|
175
|
+
await waitFor(() => expect(breakouts.assign).toHaveBeenCalledWith("anna", null));
|
|
176
|
+
|
|
177
|
+
// The host moves themselves with Join, never by reassigning their own row.
|
|
178
|
+
expect(queryByLabelText("Move Host to a room")).toBeNull();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("joins a room through the stage's self-move, and marks the room the host is in", () => {
|
|
182
|
+
const onSelfMove = vi.fn();
|
|
183
|
+
const breakouts = adapter({ canManage: true, active: true, current: { publicId: "room-1", name: "Room 1" }, rooms: ROOMS });
|
|
184
|
+
const { getByTestId, queryByTestId } = mount({ breakouts, onSelfMove });
|
|
185
|
+
|
|
186
|
+
// In Room 1: no Join for it, a marker instead; Join for the other room and
|
|
187
|
+
// Return to main for the main room.
|
|
188
|
+
expect(queryByTestId("breakouts-join-room-1")).toBeNull();
|
|
189
|
+
expect(getByTestId("breakouts-room-room-1").textContent).toContain("You are here");
|
|
190
|
+
|
|
191
|
+
fireEvent.click(getByTestId("breakouts-join-room-2"));
|
|
192
|
+
expect(onSelfMove).toHaveBeenCalledWith("room-2");
|
|
193
|
+
fireEvent.click(getByTestId("breakouts-join-main"));
|
|
194
|
+
expect(onSelfMove).toHaveBeenCalledWith(null);
|
|
195
|
+
// The panel does not touch the adapter for a self-move; the stage does.
|
|
196
|
+
expect(breakouts.moveSelf).not.toHaveBeenCalled();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("closes every room only after the warning has been confirmed", async () => {
|
|
200
|
+
const breakouts = adapter({ canManage: true, active: true, rooms: ROOMS });
|
|
201
|
+
const { getByTestId, getByText, queryByTestId, queryByText } = mount({ breakouts });
|
|
202
|
+
|
|
203
|
+
// The warning is not on screen until the control is pressed.
|
|
204
|
+
expect(queryByText(/Everyone comes back to the main room/)).toBeNull();
|
|
205
|
+
fireEvent.click(getByTestId("breakouts-close-all"));
|
|
206
|
+
expect(breakouts.closeAll).not.toHaveBeenCalled();
|
|
207
|
+
// Visible as content: a warning about what a control is about to do is
|
|
208
|
+
// never a `?` hint.
|
|
209
|
+
expect(getByText(/Everyone comes back to the main room/)).toBeTruthy();
|
|
210
|
+
|
|
211
|
+
fireEvent.click(getByText("Keep them open"));
|
|
212
|
+
expect(breakouts.closeAll).not.toHaveBeenCalled();
|
|
213
|
+
expect(queryByTestId("breakouts-close-confirm")).toBeNull();
|
|
214
|
+
|
|
215
|
+
fireEvent.click(getByTestId("breakouts-close-all"));
|
|
216
|
+
fireEvent.click(getByTestId("breakouts-close-confirm"));
|
|
217
|
+
await waitFor(() => expect(breakouts.closeAll).toHaveBeenCalledTimes(1));
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("shows the server's own sentence when an action is refused", async () => {
|
|
221
|
+
const breakouts = adapter({
|
|
222
|
+
canManage: true,
|
|
223
|
+
create: vi.fn(async () => {
|
|
224
|
+
throw new Error("Rooms are already open.");
|
|
225
|
+
}),
|
|
226
|
+
});
|
|
227
|
+
const { getByText, getByTestId } = mount({ breakouts });
|
|
228
|
+
fireEvent.click(getByText("Create rooms"));
|
|
229
|
+
await waitFor(() => expect(getByTestId("breakouts-error").textContent).toBe("Rooms are already open."));
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("keeps the explanation behind a `?` with a name that says what it explains", async () => {
|
|
233
|
+
const { getByLabelText, queryByText } = mount({ breakouts: adapter({ canManage: true }) });
|
|
234
|
+
expect(queryByText(/Each room is a separate call/)).toBeNull();
|
|
235
|
+
fireEvent.click(getByLabelText("What happens when I create rooms?"));
|
|
236
|
+
await waitFor(() => expect(queryByText(/Each room is a separate call/)).toBeTruthy());
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("the attendee's view", () => {
|
|
241
|
+
it("says where you are, lists the rooms, and offers no host control", () => {
|
|
242
|
+
const breakouts = adapter({ canManage: false, active: true, current: { publicId: "room-2", name: "Room 2" }, rooms: ROOMS });
|
|
243
|
+
const { getByTestId, queryByTestId, queryByLabelText, queryByText } = mount({ breakouts });
|
|
244
|
+
|
|
245
|
+
expect(getByTestId("breakouts-where").textContent).toBe("You are in Room 2");
|
|
246
|
+
expect(queryByText("Room 1")).toBeTruthy();
|
|
247
|
+
expect(queryByText("Main room")).toBeTruthy();
|
|
248
|
+
expect(queryByTestId("breakouts-close-all")).toBeNull();
|
|
249
|
+
expect(queryByTestId("breakouts-create")).toBeNull();
|
|
250
|
+
expect(queryByLabelText(/Move .* to a room/)).toBeNull();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("offers Return to main room only while in a breakout, and routes it through the stage", () => {
|
|
254
|
+
const onSelfMove = vi.fn();
|
|
255
|
+
const inRoom = adapter({ canManage: false, active: true, current: { publicId: "room-2", name: "Room 2" }, rooms: ROOMS });
|
|
256
|
+
const { getByTestId, unmount } = mount({ breakouts: inRoom, onSelfMove });
|
|
257
|
+
fireEvent.click(getByTestId("breakouts-return-main"));
|
|
258
|
+
expect(onSelfMove).toHaveBeenCalledWith(null);
|
|
259
|
+
unmount();
|
|
260
|
+
|
|
261
|
+
const inMain = adapter({ canManage: false, active: true, current: null, rooms: ROOMS });
|
|
262
|
+
const { queryByTestId, getByTestId: get } = mount({ breakouts: inMain });
|
|
263
|
+
expect(queryByTestId("breakouts-return-main")).toBeNull();
|
|
264
|
+
expect(get("breakouts-where").textContent).toBe("You are in the main room");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("says it is loading before the first describe has landed", () => {
|
|
268
|
+
const { getByText } = mount({ breakouts: adapter({ ready: false, active: true }) });
|
|
269
|
+
expect(getByText("Loading rooms…")).toBeTruthy();
|
|
270
|
+
});
|
|
271
|
+
});
|