@tribe-nest/forge 3.48.0 → 3.52.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 +2 -2
- package/src/data/queries/useCohortEnrolment.ts +87 -0
- package/src/data/queries/useMembershipCalls.ts +58 -8
- package/src/i18n/de.json +46 -0
- package/src/i18n/en.json +46 -0
- package/src/index.ts +1 -0
- package/src/types/models.ts +78 -5
- package/src/ui/headless/cohort/useCohortCheckout.ts +180 -0
- package/src/ui/headless/memberHome/useMemberCalls.ts +13 -0
- package/src/ui/index.ts +10 -0
- package/src/ui/styled/BlogPost.tsx +1 -1
- package/src/ui/styled/ChatRoom.tsx +2 -2
- package/src/ui/styled/Checkout.tsx +125 -125
- package/src/ui/styled/CoachingBooking.tsx +1 -1
- package/src/ui/styled/CoachingDetail.tsx +4 -4
- package/src/ui/styled/CohortCheckout.tsx +450 -0
- package/src/ui/styled/CohortConfirmation.tsx +259 -0
- package/src/ui/styled/CohortPage.tsx +31 -1
- package/src/ui/styled/CourseAccess.tsx +19 -6
- package/src/ui/styled/CourseCallsSection.tsx +99 -0
- package/src/ui/styled/CourseDetail.tsx +4 -4
- package/src/ui/styled/EventDetail.tsx +4 -4
- package/src/ui/styled/FormRenderer.tsx +14 -4
- package/src/ui/styled/PaystackPayButton.tsx +1 -1
- package/src/ui/styled/PodcastEpisode.tsx +1 -1
- package/src/ui/styled/ProductDetail.tsx +4 -4
- package/src/ui/styled/_tests/CohortCheckout.spec.tsx +232 -0
- package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +163 -0
- package/src/ui/styled/_tests/packagedCssIsCurrent.spec.ts +43 -0
- package/src/ui/styled/_tests/utilityPrefix.spec.ts +156 -0
- package/src/ui/styled/broadcast/BroadcastMoqStage.tsx +1 -1
- package/src/ui/styled/chat/ChatThreadPanel.tsx +1 -1
- package/src/ui/styled/forge-utilities.css +463 -731
- package/src/ui/styled/members/MemberCallsCalendar.tsx +21 -3
- package/src/ui/styled/members/MemberHomeLayout.tsx +4 -4
- package/src/ui/styled/members/MemberPostsFeed.tsx +1 -1
- package/src/ui/styled/members/PostCollectionsGrid.tsx +1 -1
- package/src/ui/styled/musicLinkStyles.tsx +4 -4
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What is under test is the WIRING, and specifically the two things a cohort
|
|
10
|
+
* checkout gets wrong when nobody pins them.
|
|
11
|
+
*
|
|
12
|
+
* 1. THE PLAN IS THE PRICE, so the buyer chooses one before anything else and
|
|
13
|
+
* the creator's default is preselected. Guessing the plan would charge a
|
|
14
|
+
* buyer for a cadence they did not pick.
|
|
15
|
+
* 2. THE PAYMENT STEP SHOWS WHAT IS TAKEN TODAY, not the plan total. On a
|
|
16
|
+
* three-payment plan those are different numbers, and showing the total
|
|
17
|
+
* tells the buyer they are about to be charged three times what they will
|
|
18
|
+
* be.
|
|
19
|
+
*
|
|
20
|
+
* The modal, the plan cards and the payment step are all driven from
|
|
21
|
+
* `useCohortCheckout`, so the hook is mocked and the component is exercised.
|
|
22
|
+
*/
|
|
23
|
+
type Plan = {
|
|
24
|
+
id: string;
|
|
25
|
+
title: string;
|
|
26
|
+
currency: string;
|
|
27
|
+
totalAmount: string | number;
|
|
28
|
+
installmentCount: number;
|
|
29
|
+
intervalDays: number;
|
|
30
|
+
isDefault: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const PLANS: Plan[] = [
|
|
34
|
+
{
|
|
35
|
+
id: "p1",
|
|
36
|
+
title: "Pay in full",
|
|
37
|
+
currency: "USD",
|
|
38
|
+
totalAmount: 600,
|
|
39
|
+
installmentCount: 1,
|
|
40
|
+
intervalDays: 0,
|
|
41
|
+
isDefault: true,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: "p2",
|
|
45
|
+
title: "Three payments",
|
|
46
|
+
currency: "USD",
|
|
47
|
+
totalAmount: 660,
|
|
48
|
+
installmentCount: 3,
|
|
49
|
+
intervalDays: 30,
|
|
50
|
+
isDefault: false,
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const state: Record<string, unknown> = {};
|
|
55
|
+
const choosePlan = vi.fn();
|
|
56
|
+
const setStep = vi.fn();
|
|
57
|
+
const enrol = vi.fn();
|
|
58
|
+
|
|
59
|
+
vi.mock("../../headless/cohort/useCohortCheckout", () => ({
|
|
60
|
+
useCohortCheckout: () => state,
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
/*
|
|
64
|
+
* Both reached through `useAmountFormatter`, which resolves the viewer's
|
|
65
|
+
* currency. Neither is what this file is about, and standing real providers up
|
|
66
|
+
* here would be scaffolding around the two assertions that matter.
|
|
67
|
+
*/
|
|
68
|
+
vi.mock("../../../contexts/PublicAuthContext", () => ({
|
|
69
|
+
usePublicAuth: () => ({ isAuthenticated: false, isInitialized: true, user: null }),
|
|
70
|
+
}));
|
|
71
|
+
/*
|
|
72
|
+
* The NARROW seam, deliberately. Mocking `ForgeProvider` itself also replaced
|
|
73
|
+
* `useForge` for every other spec sharing this worker, and took
|
|
74
|
+
* `paymentFlowReturnUrl.spec` down with it. `useSiteConfig` is the only thing
|
|
75
|
+
* the formatter actually needs from that direction.
|
|
76
|
+
*/
|
|
77
|
+
vi.mock("../../../data/queries/useWebsite", () => ({
|
|
78
|
+
useSiteConfig: () => ({ data: { currency: "USD" }, isLoading: false }),
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
const { CohortCheckout } = await import("../CohortCheckout");
|
|
82
|
+
|
|
83
|
+
const base = () => ({
|
|
84
|
+
cohort: { id: "c1", title: "Spring cohort" },
|
|
85
|
+
course: { id: "co1", title: "The programme" },
|
|
86
|
+
isLoading: false,
|
|
87
|
+
plans: PLANS,
|
|
88
|
+
selectedPlan: PLANS[0],
|
|
89
|
+
planId: "p1",
|
|
90
|
+
choosePlan,
|
|
91
|
+
step: "plan",
|
|
92
|
+
setStep,
|
|
93
|
+
firstName: "",
|
|
94
|
+
setFirstName: vi.fn(),
|
|
95
|
+
lastName: "",
|
|
96
|
+
setLastName: vi.fn(),
|
|
97
|
+
email: "",
|
|
98
|
+
setEmail: vi.fn(),
|
|
99
|
+
questionnaire: undefined,
|
|
100
|
+
setQuestionnaire: vi.fn(),
|
|
101
|
+
enrol,
|
|
102
|
+
clientSecret: undefined,
|
|
103
|
+
isPaystack: false,
|
|
104
|
+
canOpenPaystack: false,
|
|
105
|
+
openPaystackCheckout: vi.fn(),
|
|
106
|
+
totalAmount: 600,
|
|
107
|
+
installmentAmount: undefined,
|
|
108
|
+
installmentCount: 1,
|
|
109
|
+
setupRequiresAction: false,
|
|
110
|
+
setupClientSecret: null,
|
|
111
|
+
taxQuote: null,
|
|
112
|
+
returnUrl: "",
|
|
113
|
+
isProcessing: false,
|
|
114
|
+
error: null,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/** The provider takes no default, so every spec here supplies one. */
|
|
118
|
+
const THEME = {
|
|
119
|
+
colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" },
|
|
120
|
+
cornerRadius: 8,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A REAL query client rather than a fourth mock. `useAmountFormatter` reaches a
|
|
125
|
+
* currency query, and stubbing every provider between here and it would leave a
|
|
126
|
+
* spec that passes because of its scaffolding. No retries, so a query that does
|
|
127
|
+
* fire fails fast instead of holding the test open.
|
|
128
|
+
*/
|
|
129
|
+
const wrap = (node: React.ReactNode) => (
|
|
130
|
+
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
|
131
|
+
<ForgeThemeProvider theme={THEME}>{node}</ForgeThemeProvider>
|
|
132
|
+
</QueryClientProvider>
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `formatAmount` is supplied rather than left to `useAmountFormatter`, which
|
|
137
|
+
* resolves the viewer's currency through a query this spec has no server for.
|
|
138
|
+
* What is under test is WHICH figure is shown, not how it is punctuated.
|
|
139
|
+
*/
|
|
140
|
+
const fmt = (n: number) => `$${n}`;
|
|
141
|
+
|
|
142
|
+
const draw = () => render(wrap(<CohortCheckout cohortId="c1" formatAmount={fmt} />));
|
|
143
|
+
|
|
144
|
+
const open = () => fireEvent.click(screen.getByText("Enrol"));
|
|
145
|
+
|
|
146
|
+
beforeEach(() => {
|
|
147
|
+
Object.keys(state).forEach((k) => delete state[k]);
|
|
148
|
+
Object.assign(state, base());
|
|
149
|
+
choosePlan.mockClear();
|
|
150
|
+
setStep.mockClear();
|
|
151
|
+
enrol.mockClear();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
afterEach(cleanup);
|
|
155
|
+
|
|
156
|
+
describe("CohortCheckout", () => {
|
|
157
|
+
it("renders nothing at all when the cohort has no pricing plans", () => {
|
|
158
|
+
// Not an error state: a cohort that is not priced yet. A dead "Enrol"
|
|
159
|
+
// button would be worse than no button.
|
|
160
|
+
Object.assign(state, { plans: [], selectedPlan: undefined });
|
|
161
|
+
const { queryByText } = draw();
|
|
162
|
+
// The theme provider injects its own :root style block, so the absence to
|
|
163
|
+
// assert is the TRIGGER, not an empty container.
|
|
164
|
+
expect(queryByText("Enrol")).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("opens on plan choice, and offers every plan the creator priced", () => {
|
|
168
|
+
draw();
|
|
169
|
+
open();
|
|
170
|
+
expect(screen.getByText("Choose a plan")).toBeTruthy();
|
|
171
|
+
// "Pay in full" is both the plan's TITLE and, for a one-payment plan, its
|
|
172
|
+
// terms line, so it legitimately appears twice on the same card.
|
|
173
|
+
expect(screen.getAllByText("Pay in full").length).toBeGreaterThan(0);
|
|
174
|
+
expect(screen.getByText("Three payments")).toBeTruthy();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("spells out an instalment plan's terms rather than only its total", () => {
|
|
178
|
+
draw();
|
|
179
|
+
open();
|
|
180
|
+
// The per-payment figure and the cadence, because "660" alone does not tell
|
|
181
|
+
// a buyer what leaves their account or when.
|
|
182
|
+
expect(screen.getByText(/3 payments of/)).toBeTruthy();
|
|
183
|
+
expect(screen.getByText(/every 30 days/)).toBeTruthy();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("passes the chosen plan up rather than assuming one", () => {
|
|
187
|
+
draw();
|
|
188
|
+
open();
|
|
189
|
+
fireEvent.click(screen.getByText("Three payments"));
|
|
190
|
+
expect(choosePlan).toHaveBeenCalledWith("p2");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("shows what is taken TODAY on an instalment plan, not the plan total", () => {
|
|
194
|
+
Object.assign(state, {
|
|
195
|
+
step: "payment",
|
|
196
|
+
selectedPlan: PLANS[1],
|
|
197
|
+
planId: "p2",
|
|
198
|
+
totalAmount: 660,
|
|
199
|
+
installmentAmount: 220,
|
|
200
|
+
installmentCount: 3,
|
|
201
|
+
});
|
|
202
|
+
const { baseElement } = draw();
|
|
203
|
+
open();
|
|
204
|
+
|
|
205
|
+
expect(screen.getByText("Due now")).toBeTruthy();
|
|
206
|
+
// 220 is the charge; 660 is the commitment. Both appear, and the figure
|
|
207
|
+
// beside "Due now" is the one about to leave the buyer's card. Read off the
|
|
208
|
+
// whole dialog because the formatter splits the amount across nodes.
|
|
209
|
+
expect(baseElement.textContent).toContain("220");
|
|
210
|
+
expect(baseElement.textContent).toContain("660");
|
|
211
|
+
expect(screen.getByText(/3 payments in total/)).toBeTruthy();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("asks for the off-session consent when Stripe needs it confirmed", () => {
|
|
215
|
+
// Instalments 2..n are charged months later by a cron with nobody present.
|
|
216
|
+
// A checkout that skips this takes payment one and silently fails the rest.
|
|
217
|
+
const renderPayment = vi.fn(() => <div>card form</div>);
|
|
218
|
+
Object.assign(state, {
|
|
219
|
+
step: "payment",
|
|
220
|
+
selectedPlan: PLANS[1],
|
|
221
|
+
installmentCount: 3,
|
|
222
|
+
installmentAmount: 220,
|
|
223
|
+
setupRequiresAction: true,
|
|
224
|
+
setupClientSecret: "seti_123_secret",
|
|
225
|
+
});
|
|
226
|
+
render(wrap(<CohortCheckout cohortId="c1" formatAmount={fmt} renderPayment={renderPayment} />));
|
|
227
|
+
open();
|
|
228
|
+
|
|
229
|
+
expect(screen.getByText(/Authorise the later payments/)).toBeTruthy();
|
|
230
|
+
expect(renderPayment).toHaveBeenCalledWith(expect.objectContaining({ clientSecret: "seti_123_secret" }));
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, fireEvent, render } from "@testing-library/react";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
6
|
+
import type { MyMembershipCall } from "../../../data/queries/useMembershipCalls";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The section is a WRAPPER, so the mock goes under the whole chain
|
|
10
|
+
* (`MemberCallsCalendar` -> `useMemberCalls` -> `useMyMembershipCalls`) rather
|
|
11
|
+
* than over the calendar itself. That is the point of the file: what is under
|
|
12
|
+
* test is the wiring - which scope reaches the query, which empty state the
|
|
13
|
+
* student is shown, and which call id Join opens - not the month grid, which
|
|
14
|
+
* has its own spec.
|
|
15
|
+
*/
|
|
16
|
+
const rows: MyMembershipCall[] = [];
|
|
17
|
+
const useMyMembershipCalls = vi.fn();
|
|
18
|
+
|
|
19
|
+
vi.mock("../../../data/queries/useMembershipCalls", () => ({
|
|
20
|
+
useMyMembershipCalls: (options: unknown) => useMyMembershipCalls(options),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
let session = { isAuthenticated: true, isInitialized: true };
|
|
24
|
+
vi.mock("../../../contexts/PublicAuthContext", () => ({
|
|
25
|
+
usePublicAuth: () => session,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const { CourseCallsSection } = await import("../CourseCallsSection");
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A day inside the month the grid opens on.
|
|
32
|
+
*
|
|
33
|
+
* `useMemberCalls` lays out the six weeks around the REAL clock and buckets
|
|
34
|
+
* calls into them by the viewer's local day, so a call pinned to a fixed
|
|
35
|
+
* calendar date falls outside the grid the moment that month passes and every
|
|
36
|
+
* assertion here goes quietly green-then-red. Built from `now` instead.
|
|
37
|
+
*/
|
|
38
|
+
const at = (hour: number): Date => {
|
|
39
|
+
const now = new Date();
|
|
40
|
+
return new Date(now.getFullYear(), now.getMonth(), 15, hour, 0, 0);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const call = (over: Partial<MyMembershipCall> = {}): MyMembershipCall => ({
|
|
44
|
+
id: "call-1",
|
|
45
|
+
title: "Week 1 kickoff",
|
|
46
|
+
description: "Bring your questions.",
|
|
47
|
+
startsAt: at(17).toISOString(),
|
|
48
|
+
endsAt: at(18).toISOString(),
|
|
49
|
+
timezone: "America/New_York",
|
|
50
|
+
participation: "open",
|
|
51
|
+
role: "attendee",
|
|
52
|
+
audiences: [{ id: "a1", audienceType: "course_cohort", audienceId: "cohort-1", label: "March cohort" }],
|
|
53
|
+
opensAt: at(16).toISOString(),
|
|
54
|
+
closesAt: at(20).toISOString(),
|
|
55
|
+
isOpen: true,
|
|
56
|
+
callPublicId: "pub-week-1",
|
|
57
|
+
recording: null,
|
|
58
|
+
...over,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const mount = (props: Record<string, unknown> = {}, navigate = vi.fn()) => {
|
|
62
|
+
const view = render(
|
|
63
|
+
<ForgeThemeProvider theme={{ colors: { text: "#111", background: "#fff", primary: "#6d28d9" }, cornerRadius: 8 }}>
|
|
64
|
+
<CourseCallsSection courseId="course-1" cohortId="cohort-1" navigate={navigate} {...props} />
|
|
65
|
+
</ForgeThemeProvider>,
|
|
66
|
+
);
|
|
67
|
+
return { ...view, navigate };
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
rows.length = 0;
|
|
72
|
+
session = { isAuthenticated: true, isInitialized: true };
|
|
73
|
+
useMyMembershipCalls.mockReset();
|
|
74
|
+
useMyMembershipCalls.mockImplementation(() => ({
|
|
75
|
+
data: rows,
|
|
76
|
+
isLoading: false,
|
|
77
|
+
error: null,
|
|
78
|
+
refetch: vi.fn(),
|
|
79
|
+
}));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterEach(cleanup);
|
|
83
|
+
|
|
84
|
+
describe("CourseCallsSection", () => {
|
|
85
|
+
it("renders the student's calls under a heading", () => {
|
|
86
|
+
rows.push(call());
|
|
87
|
+
const { getAllByText } = mount();
|
|
88
|
+
expect(getAllByText("Live calls").length).toBe(1);
|
|
89
|
+
// Desktop grid cell and mobile agenda row are both in the DOM; the
|
|
90
|
+
// calendar renders both faces and hides one with a media class.
|
|
91
|
+
expect(getAllByText("Week 1 kickoff").length).toBeGreaterThan(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("scopes the read to the course AND the cohort being viewed", () => {
|
|
95
|
+
rows.push(call());
|
|
96
|
+
mount();
|
|
97
|
+
const options = useMyMembershipCalls.mock.calls[0]![0] as { courseId?: string; cohortId?: string };
|
|
98
|
+
expect(options.courseId).toBe("course-1");
|
|
99
|
+
expect(options.cohortId).toBe("cohort-1");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("passes only the cohort when that is all the surface knows", () => {
|
|
103
|
+
mount({ courseId: null });
|
|
104
|
+
const options = useMyMembershipCalls.mock.calls[0]![0] as { courseId?: string | null; cohortId?: string };
|
|
105
|
+
expect(options.courseId).toBeNull();
|
|
106
|
+
expect(options.cohortId).toBe("cohort-1");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("shows the empty state with NO membership upsell on a course surface", () => {
|
|
110
|
+
const { getByText, queryByText } = mount();
|
|
111
|
+
expect(getByText("Nothing this month")).toBeTruthy();
|
|
112
|
+
// The whole reason this wrapper exists. `MemberCallsCalendar` renders the
|
|
113
|
+
// button only when it is handed a `membershipHref`, and a course-only buyer
|
|
114
|
+
// standing in their own classroom must not be sold a membership.
|
|
115
|
+
expect(queryByText("My membership")).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("links Join to the call's own publicId, on the generic call page", () => {
|
|
119
|
+
rows.push(call({ callPublicId: "pub-week-1" }));
|
|
120
|
+
const { getAllByText, getByText, navigate } = mount();
|
|
121
|
+
fireEvent.click(getAllByText("Week 1 kickoff")[0]!);
|
|
122
|
+
fireEvent.click(getByText("Join call"));
|
|
123
|
+
expect(navigate).toHaveBeenCalledWith("/i/video-calls/pub-week-1");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("offers Watch for a past call whose recording is ready", () => {
|
|
127
|
+
rows.push(
|
|
128
|
+
call({
|
|
129
|
+
id: "call-2",
|
|
130
|
+
title: "Week 0 orientation",
|
|
131
|
+
isOpen: false,
|
|
132
|
+
recording: { id: "rec-1", status: "ready", durationSeconds: 3600, watchUrl: "https://cdn.test/rec.mp4" },
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
const { getAllByText, container } = mount();
|
|
136
|
+
fireEvent.click(getAllByText("Week 0 orientation")[0]!);
|
|
137
|
+
// Queried by href rather than by text: the label carries the duration in a
|
|
138
|
+
// second text node, so a text matcher would be matching the minutes too.
|
|
139
|
+
const watch = container.querySelector<HTMLAnchorElement>('a[href="https://cdn.test/rec.mp4"]');
|
|
140
|
+
expect(watch?.textContent).toContain("Watch recording");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("renders nothing for a reader with no session, rather than a failed read", () => {
|
|
144
|
+
// A legacy course grant opens for anyone holding the link, and the cohort
|
|
145
|
+
// page is anonymous to the public. The attendee endpoint would 401 them, and
|
|
146
|
+
// "we could not load your calls" on a page that is working perfectly is
|
|
147
|
+
// worse than no section at all.
|
|
148
|
+
session = { isAuthenticated: false, isInitialized: true };
|
|
149
|
+
const { container } = mount();
|
|
150
|
+
// Not `textContent`: `ForgeThemeProvider` writes a `:root` custom-property
|
|
151
|
+
// block into the tree, which is there whatever the section does.
|
|
152
|
+
expect(container.querySelector("section")).toBeNull();
|
|
153
|
+
expect(useMyMembershipCalls).not.toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("renders nothing when there is no course or cohort to scope by", () => {
|
|
157
|
+
// Unscoped here would put the viewer's WHOLE schedule - other courses, the
|
|
158
|
+
// membership AMA - on one course's page.
|
|
159
|
+
const { container } = mount({ courseId: null, cohortId: null });
|
|
160
|
+
expect(container.querySelector("section")).toBeNull();
|
|
161
|
+
expect(useMyMembershipCalls).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The shipped stylesheet must match the components that need it.
|
|
8
|
+
*
|
|
9
|
+
* Forge packages its own Tailwind output (`forge-utilities.css`) because a
|
|
10
|
+
* published site cannot scan Forge's source: the build sandbox hoists forge to
|
|
11
|
+
* a workspace-root node_modules, the starter's `@source` paths miss, and
|
|
12
|
+
* Tailwind skips a missing `@source` SILENTLY. So the packaged file is the only
|
|
13
|
+
* thing standing between a Forge component and an unstyled published page.
|
|
14
|
+
*
|
|
15
|
+
* Which makes staleness invisible in exactly the same way. Add a class to a
|
|
16
|
+
* component, forget `npm run build:css`, and it works locally for ever (the
|
|
17
|
+
* monorepo `@source` reaches Forge's source) while every published site renders
|
|
18
|
+
* without it. This runs the build and fails if the committed file would change.
|
|
19
|
+
*/
|
|
20
|
+
describe("Forge's packaged CSS", () => {
|
|
21
|
+
it("is up to date with the components that use it", () => {
|
|
22
|
+
const root = join(__dirname, "..", "..", "..", "..");
|
|
23
|
+
const cssPath = join(root, "src", "ui", "styled", "forge-utilities.css");
|
|
24
|
+
const before = readFileSync(cssPath, "utf-8");
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
execFileSync("npm", ["run", "build:css"], { cwd: root, stdio: "pipe" });
|
|
28
|
+
} catch (err) {
|
|
29
|
+
throw new Error(`build:css failed, so the packaged CSS cannot be verified: ${String(err)}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const after = readFileSync(cssPath, "utf-8");
|
|
33
|
+
// Put the committed file back before asserting, so a failing run does not
|
|
34
|
+
// leave a rebuilt artifact in the tree and turn one red test into a dirty
|
|
35
|
+
// working copy.
|
|
36
|
+
if (before !== after) writeFileSync(cssPath, before);
|
|
37
|
+
|
|
38
|
+
expect(
|
|
39
|
+
after === before,
|
|
40
|
+
"forge-utilities.css is stale. A class was added to a component without rebuilding, so it is missing from every PUBLISHED site while working locally. Run `npm run build:css` in packages/forge and commit the result.",
|
|
41
|
+
).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Every Tailwind utility in a Forge component carries the `fg:` prefix.
|
|
7
|
+
*
|
|
8
|
+
* THE ONE RULE, and the only one. Forge builds its stylesheet with
|
|
9
|
+
* `prefix(fg)` (see tailwind.input.css), so `fg:flex` compiles to `.fg\:flex`
|
|
10
|
+
* and a bare `flex` compiles to NOTHING. An unprefixed class is therefore not a
|
|
11
|
+
* style that loses an argument, it is a style that does not exist: the element
|
|
12
|
+
* renders unstyled and no error is raised anywhere.
|
|
13
|
+
*
|
|
14
|
+
* WHY THE PREFIX EXISTS. Tailwind utilities are global and unprefixed by
|
|
15
|
+
* construction, so Forge's `.hidden` and a host's `.hidden` are the same rule.
|
|
16
|
+
* Shipping Forge's copies bare meant one of the two had to lose everywhere, and
|
|
17
|
+
* both orderings shipped a bug: Forge above the host collapsed the admin
|
|
18
|
+
* sidebar (027b8aa9d), Forge below the host pinned a coaching booking page's
|
|
19
|
+
* "Select availability" card over its cover image on every published site, and
|
|
20
|
+
* made `hidden sm:block` blocks permanently invisible. A cascade layer picks
|
|
21
|
+
* one global winner for a name that means different things on different
|
|
22
|
+
* elements, so it could never settle it. The prefix removes the collision.
|
|
23
|
+
*
|
|
24
|
+
* WHAT IS NOT A UTILITY, and must stay bare:
|
|
25
|
+
* - the hand-written classes in Forge's own stylesheets (`cf-rise`,
|
|
26
|
+
* `forge-rich`, `forge-spin`, …), which are real CSS rules;
|
|
27
|
+
* - `group` and `peer`, which are Tailwind MARKERS targeted by `group-*` /
|
|
28
|
+
* `peer-*` and never emitted as rules of their own;
|
|
29
|
+
* - anything arriving through the `className` PROP, which belongs to the host.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** Hand-written CSS in Forge's own stylesheets, plus Tailwind's markers. */
|
|
33
|
+
const NOT_UTILITIES = new Set([
|
|
34
|
+
"cf-card",
|
|
35
|
+
"cf-halo",
|
|
36
|
+
"cf-pop",
|
|
37
|
+
"cf-ring",
|
|
38
|
+
"cf-rise",
|
|
39
|
+
"cf-tick",
|
|
40
|
+
"forge-community-composer-editable",
|
|
41
|
+
"forge-pa-compact",
|
|
42
|
+
"forge-rich",
|
|
43
|
+
"forge-spin",
|
|
44
|
+
"group",
|
|
45
|
+
"peer",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
const PREFIX = "fg:";
|
|
49
|
+
|
|
50
|
+
function walk(dir: string, out: string[] = []): string[] {
|
|
51
|
+
for (const entry of readdirSync(dir)) {
|
|
52
|
+
const full = join(dir, entry);
|
|
53
|
+
if (statSync(full).isDirectory()) {
|
|
54
|
+
if (entry !== "_tests") walk(full, out);
|
|
55
|
+
} else if (full.endsWith(".tsx") || full.endsWith(".ts")) out.push(full);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The whole `className=` value, braces and backticks balanced.
|
|
62
|
+
*
|
|
63
|
+
* A regex cannot do this, and the naive one nearly shipped the bug it is meant
|
|
64
|
+
* to prevent: `` className={`pb-32 md:pb-0${className ? ` ${className}` : ""}`} ``
|
|
65
|
+
* contains a NESTED backtick, so a `` /\{`([^`]*)`\}/ `` pattern never matched it
|
|
66
|
+
* and four components sat unprefixed while the guard reported clean.
|
|
67
|
+
*/
|
|
68
|
+
function classNameExpressions(src: string): Array<{ expr: string; index: number }> {
|
|
69
|
+
const found: Array<{ expr: string; index: number }> = [];
|
|
70
|
+
const marker = "className=";
|
|
71
|
+
for (let i = src.indexOf(marker); i !== -1; i = src.indexOf(marker, i + 1)) {
|
|
72
|
+
let j = i + marker.length;
|
|
73
|
+
if (src[j] === '"') {
|
|
74
|
+
const end = src.indexOf('"', j + 1);
|
|
75
|
+
if (end === -1) continue;
|
|
76
|
+
found.push({ expr: src.slice(j, end + 1), index: i });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (src[j] !== "{") continue;
|
|
80
|
+
let depth = 0;
|
|
81
|
+
let inTemplate = false;
|
|
82
|
+
for (; j < src.length; j += 1) {
|
|
83
|
+
const ch = src[j];
|
|
84
|
+
if (ch === "\\") { j += 1; continue; }
|
|
85
|
+
if (ch === "`") inTemplate = !inTemplate;
|
|
86
|
+
else if (ch === "{") depth += 1;
|
|
87
|
+
else if (ch === "}") {
|
|
88
|
+
depth -= 1;
|
|
89
|
+
if (depth === 0) break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
found.push({ expr: src.slice(i + marker.length, j + 1), index: i });
|
|
93
|
+
}
|
|
94
|
+
return found;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Every literal class token in a className expression, interpolations aside. */
|
|
98
|
+
function classTokens(expr: string): string[] {
|
|
99
|
+
const tokens: string[] = [];
|
|
100
|
+
|
|
101
|
+
// 1. Quoted strings, which is where a ternary keeps its classes:
|
|
102
|
+
// `${open ? "fg:flex" : "fg:hidden"}`.
|
|
103
|
+
for (const m of expr.matchAll(/"([^"]*)"|'([^']*)'/g)) {
|
|
104
|
+
tokens.push(...(m[1] ?? m[2] ?? "").split(/\s+/));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 2. Template TEXT, with every `${...}` removed first so an interpolation's
|
|
108
|
+
// own code can never be mistaken for a class name.
|
|
109
|
+
let stripped = "";
|
|
110
|
+
let depth = 0;
|
|
111
|
+
for (let i = 0; i < expr.length; i += 1) {
|
|
112
|
+
if (expr[i] === "$" && expr[i + 1] === "{") { depth += 1; i += 1; continue; }
|
|
113
|
+
if (depth > 0) {
|
|
114
|
+
if (expr[i] === "{") depth += 1;
|
|
115
|
+
else if (expr[i] === "}") depth -= 1;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
stripped += expr[i];
|
|
119
|
+
}
|
|
120
|
+
for (const m of stripped.matchAll(/`([^`]*)`/g)) tokens.push(...(m[1] ?? "").split(/\s+/));
|
|
121
|
+
|
|
122
|
+
return tokens.filter(Boolean);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe("Forge utilities are prefixed", () => {
|
|
126
|
+
it("has no unprefixed utility in any component", () => {
|
|
127
|
+
const root = join(__dirname, "..", "..", "..");
|
|
128
|
+
const offences: string[] = [];
|
|
129
|
+
|
|
130
|
+
for (const file of walk(root)) {
|
|
131
|
+
const src = readFileSync(file, "utf-8");
|
|
132
|
+
for (const { expr, index } of classNameExpressions(src)) {
|
|
133
|
+
for (const cls of classTokens(expr)) {
|
|
134
|
+
if (cls.startsWith(PREFIX) || NOT_UTILITIES.has(cls)) continue;
|
|
135
|
+
const line = src.slice(0, index).split("\n").length;
|
|
136
|
+
offences.push(
|
|
137
|
+
`${file.slice(file.indexOf("packages/"))}:${line} "${cls}" has no fg: prefix, so it compiles to nothing and silently does not apply. Write "fg:${cls}", or add it to NOT_UTILITIES if it is hand-written CSS.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
expect(offences, offences.join("\n")).toEqual([]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("ships no bare utility in the packaged stylesheet", () => {
|
|
147
|
+
// The other half, measured on the OUTPUT rather than the source: even if a
|
|
148
|
+
// component slipped one through, nothing unprefixed may reach a host's page,
|
|
149
|
+
// because that is the rule a bare class would break.
|
|
150
|
+
const css = readFileSync(join(__dirname, "..", "forge-utilities.css"), "utf-8");
|
|
151
|
+
const bare = [...css.matchAll(/^\s*\.([a-zA-Z][a-zA-Z0-9_-]*)\s*(?:,|\{)/gm)]
|
|
152
|
+
.map((m) => m[1]!)
|
|
153
|
+
.filter((c) => !c.startsWith("fg") && !NOT_UTILITIES.has(c));
|
|
154
|
+
expect([...new Set(bare)], "unprefixed rules in the shipped stylesheet").toEqual([]);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -120,5 +120,5 @@ export function BroadcastMoqStage({ url, name, muted, onFailed }: BroadcastMoqSt
|
|
|
120
120
|
};
|
|
121
121
|
}, [url, name, muted]);
|
|
122
122
|
|
|
123
|
-
return <div ref={hostRef} className="h-full w-full bg-black" />;
|
|
123
|
+
return <div ref={hostRef} className="fg:h-full fg:w-full fg:bg-black" />;
|
|
124
124
|
}
|
|
@@ -48,7 +48,7 @@ export function ChatThreadPanel({
|
|
|
48
48
|
|
|
49
49
|
return (
|
|
50
50
|
<section
|
|
51
|
-
className="fixed inset-0 z-50 md:static md:z-auto md:w-[360px] flex flex-col"
|
|
51
|
+
className="fg:fixed fg:inset-0 fg:z-50 fg:md:static fg:md:z-auto fg:md:w-[360px] fg:flex fg:flex-col"
|
|
52
52
|
style={{ background: theme.colors.background, borderLeft: border }}
|
|
53
53
|
>
|
|
54
54
|
<header style={{ padding: "12px 16px", borderBottom: border, display: "flex", alignItems: "center", gap: 12 }}>
|