Invite people after review, then finish the Rule, with login before inviting and Invite your members on Profile.
This commit is contained in:
1 parent
88bfccd1e7
commit
70ceb73104
31 files changed
+1388
-401
No files matched your search
@@ -67,7 +67,7 @@ describe("CompletedScreen", () => {
|
||||
expect(screen.queryByText("Values")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not pin the title on small screens", () => {
|
||||
it("keeps the heading in place while the Rule column scrolls", () => {
|
||||
sessionStorage.setItem(
|
||||
CREATE_FLOW_LAST_PUBLISHED_KEY,
|
||||
JSON.stringify(storedRuleFixture),
|
||||
@@ -78,11 +78,25 @@ describe("CompletedScreen", () => {
|
||||
});
|
||||
const column = heading.closest("div[class*='md:justify-center']");
|
||||
expect(column).toBeTruthy();
|
||||
expect(column.className).not.toMatch(/\bsticky\b/);
|
||||
expect(column.className).toMatch(/\bshrink-0\b/);
|
||||
const ruleColumn = screen
|
||||
.getByText("Fixture value title")
|
||||
.closest("div[class*='md:overflow-y-auto']");
|
||||
expect(ruleColumn?.className).toMatch(/max-md:shrink-0/);
|
||||
.closest("div[class*='overflow-y-auto']");
|
||||
expect(ruleColumn?.className).toMatch(/overflow-y-auto/);
|
||||
});
|
||||
|
||||
it("offers Ask an organizer under the finished Rule heading", () => {
|
||||
sessionStorage.setItem(
|
||||
CREATE_FLOW_LAST_PUBLISHED_KEY,
|
||||
JSON.stringify(storedRuleFixture),
|
||||
);
|
||||
render(<CompletedScreen />);
|
||||
expect(
|
||||
screen.getByText("Has your Rule changed how your community works?"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Tell an organizer what changed." }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders header and document from sessionStorage", () => {
|
||||
@@ -106,12 +120,12 @@ describe("CompletedScreen", () => {
|
||||
render(<CompletedScreen />);
|
||||
expect(
|
||||
screen.queryByText(
|
||||
"This is what folks see when you share your CommunityRule",
|
||||
"Your Rule is finished",
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(
|
||||
"Your group can use this document as an operating manual.",
|
||||
"This is what the people you invite will see.",
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -123,20 +137,16 @@ describe("CompletedScreen", () => {
|
||||
});
|
||||
render(<CompletedScreen />);
|
||||
expect(
|
||||
screen.getByText(
|
||||
"This is what folks see when you share your CommunityRule",
|
||||
),
|
||||
screen.getByText("Your Rule is finished"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Your group can use this document as an operating manual.",
|
||||
),
|
||||
screen.getByText("This is what the people you invite will see."),
|
||||
).toBeInTheDocument();
|
||||
const statusRegions = screen.getAllByRole("status");
|
||||
expect(statusRegions.length).toBeGreaterThanOrEqual(1);
|
||||
expect(
|
||||
statusRegions.some((el) =>
|
||||
el.textContent?.includes("This is what folks see when you share"),
|
||||
el.textContent?.includes("Your Rule is finished"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1,42 +1,128 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { useEffect } from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders as render, screen } from "../utils/test-utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { ConfirmStakeholdersScreen } from "../../app/(app)/create/screens/select/ConfirmStakeholdersScreen";
|
||||
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
|
||||
vi.mock("../../lib/create/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/create/api")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchAuthSession: vi.fn(async () => ({
|
||||
user: { id: "u1", email: "maya@example.org" },
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
function SeedCreateFlow({ patch }: { patch: Partial<CreateFlowState> }) {
|
||||
const { updateState } = useCreateFlow();
|
||||
useEffect(() => {
|
||||
updateState(patch);
|
||||
}, [patch, updateState]);
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("ConfirmStakeholdersScreen", () => {
|
||||
it("renders title and description", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the invite heading and email field", () => {
|
||||
render(<ConfirmStakeholdersScreen />);
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: /Do other stakeholders need to be involved/i,
|
||||
name: "Invite people to read your Rule",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Everyone you add gets a private link to read the Rule."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Add their email addresses\. When you publish, we'll send each person a one-time link/i,
|
||||
/Every Rule starts private\. Only the people you invite can read it/,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Add stakeholder control", () => {
|
||||
it("does not show the old draft toast", () => {
|
||||
render(<ConfirmStakeholdersScreen />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add stakeholder" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows draft toast and can dismiss it", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ConfirmStakeholdersScreen />);
|
||||
expect(
|
||||
screen.getByText(/Congratulations! You've drafted your CommunityRule!/i),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(
|
||||
screen.queryByText(
|
||||
/Congratulations! You've drafted your CommunityRule!/i,
|
||||
),
|
||||
screen.queryByText(/Congratulations! You've drafted your CommunityRule!/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds an email and shows the owner as You", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ConfirmStakeholdersScreen />);
|
||||
expect(await screen.findByText("maya@example.org")).toBeInTheDocument();
|
||||
expect(screen.getByText("You")).toBeInTheDocument();
|
||||
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /Add someone by email/i }),
|
||||
"rosa@example.org",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Add" }));
|
||||
expect(screen.getByText("rosa@example.org")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("2 people get a private link to read the Rule."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /Remove maya@example.org/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Remove rosa@example.org/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps a pasted invalid address in the field and reports what was added", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ConfirmStakeholdersScreen />);
|
||||
const field = await screen.findByRole("textbox", {
|
||||
name: /Add someone by email/i,
|
||||
});
|
||||
field.focus();
|
||||
await user.paste("rosa@example.org, jo@example, sam@example.org");
|
||||
expect(await screen.findByText("rosa@example.org")).toBeInTheDocument();
|
||||
expect(screen.getByText("sam@example.org")).toBeInTheDocument();
|
||||
expect(field).toHaveValue("jo@example");
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Added two people from your list\. One is not an email address\./,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Not an email address. Fix it and select Add, or clear it."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the membership card from the selected method", async () => {
|
||||
render(
|
||||
<>
|
||||
<SeedCreateFlow
|
||||
patch={{
|
||||
selectedMembershipMethodIds: ["open-admission"],
|
||||
membershipMethodDetailsById: {
|
||||
"open-admission": {
|
||||
eligibility: "Anyone who shares the work belongs.",
|
||||
joiningProcess: "Work two Sunday shares, then ask to join.",
|
||||
expectations: "",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<ConfirmStakeholdersScreen />
|
||||
</>,
|
||||
);
|
||||
expect(await screen.findByText("Open admission")).toBeInTheDocument();
|
||||
expect(screen.getByText("Who belongs, in your Rule’s words")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Anyone who shares the work belongs/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -212,6 +212,44 @@ describe("LoginForm", () => {
|
||||
window.localStorage.removeItem("create-flow-anonymous");
|
||||
});
|
||||
|
||||
it("invite variant attaches a create-flow draft", async () => {
|
||||
const user = userEvent.setup();
|
||||
window.localStorage.setItem(
|
||||
"create-flow-anonymous",
|
||||
JSON.stringify({ title: "Guest draft" }),
|
||||
);
|
||||
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
|
||||
renderWithProviders(
|
||||
<Suspense fallback={null}>
|
||||
<LoginForm
|
||||
variant="invite"
|
||||
magicLinkNextPath="/create/confirm-stakeholders?syncDraft=1"
|
||||
/>
|
||||
</Suspense>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: /log in to invite your members/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /email address/i }),
|
||||
"guest@example.com",
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /send me a magic link/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(requestMagicLink).toHaveBeenCalledWith(
|
||||
"guest@example.com",
|
||||
"/create/confirm-stakeholders?syncDraft=1",
|
||||
expect.objectContaining({ title: "Guest draft" }),
|
||||
);
|
||||
});
|
||||
expect(setTransferPendingFlag).toHaveBeenCalled();
|
||||
window.localStorage.removeItem("create-flow-anonymous");
|
||||
});
|
||||
|
||||
it("keepRule continue without saving calls onDismiss", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDismiss = vi.fn();
|
||||
|
||||
@@ -87,6 +87,17 @@ function LoginTrigger() {
|
||||
>
|
||||
Open keep rule then leave
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openLogin({
|
||||
variant: "invite",
|
||||
nextPath: "/create/confirm-stakeholders?syncDraft=1",
|
||||
})
|
||||
}
|
||||
>
|
||||
Open invite login
|
||||
</button>
|
||||
<button type="button" onClick={() => closeLogin()}>
|
||||
Close from outside
|
||||
</button>
|
||||
@@ -253,4 +264,44 @@ describe("AuthModalProvider (header overlay)", () => {
|
||||
});
|
||||
expect(screen.getByText("Left completed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invite openLogin shows back-to-rule and attaches a draft", async () => {
|
||||
const user = userEvent.setup();
|
||||
window.localStorage.setItem(
|
||||
"create-flow-anonymous",
|
||||
JSON.stringify({ title: "Guest draft" }),
|
||||
);
|
||||
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
|
||||
renderWithProviders(
|
||||
<Suspense fallback={null}>
|
||||
<LoginTrigger />
|
||||
</Suspense>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /open invite login/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /log in to invite your members/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /back to your rule/i }),
|
||||
).toBeInTheDocument();
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /email address/i }),
|
||||
"guest@example.com",
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /send me a magic link/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(requestMagicLink).toHaveBeenCalledWith(
|
||||
"guest@example.com",
|
||||
"/create/confirm-stakeholders?syncDraft=1",
|
||||
expect.objectContaining({ title: "Guest draft" }),
|
||||
);
|
||||
});
|
||||
expect(setTransferPendingFlag).toHaveBeenCalled();
|
||||
window.localStorage.removeItem("create-flow-anonymous");
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,7 @@ function viewProps(
|
||||
onCloseDeleteRule: vi.fn(),
|
||||
onConfirmDeleteRule: vi.fn(),
|
||||
onDuplicateRule: vi.fn(),
|
||||
onInviteMembers: vi.fn(),
|
||||
onContinueDraft: vi.fn(),
|
||||
onDeleteDraft: vi.fn(),
|
||||
onCloseDeleteDraft: vi.fn(),
|
||||
@@ -142,4 +143,45 @@ describe("ProfilePageView", () => {
|
||||
await user.click(confirm);
|
||||
expect(onConfirmDeleteAccount).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("finished owner cards offer Invite your members beside Duplicate and Delete", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onInviteMembers = vi.fn();
|
||||
render(
|
||||
<ProfilePageView
|
||||
{...viewProps({
|
||||
onInviteMembers,
|
||||
rules: [
|
||||
{
|
||||
id: "rule-1",
|
||||
title: "Garden coop",
|
||||
summary: "How we share the plots.",
|
||||
createdAt: "2026-11-01T00:00:00.000Z",
|
||||
updatedAt: "2026-11-01T00:00:00.000Z",
|
||||
role: "owner",
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(copy.welcomeBodyFirstRule),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(copy.finishedBadge)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: copy.yourRulesHeading }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: copy.inviteMembers }),
|
||||
);
|
||||
expect(onInviteMembers).toHaveBeenCalledWith("rule-1");
|
||||
expect(
|
||||
screen.getByRole("button", { name: copy.duplicate }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: copy.deleteRule }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -183,7 +183,7 @@ describe("flowSteps", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("parseReviewReturnSearchParam accepts final-review, edit-rule, and review", () => {
|
||||
it("parseReviewReturnSearchParam accepts final-review, edit-rule, review, and completed", () => {
|
||||
expect(
|
||||
parseReviewReturnSearchParam(
|
||||
new URLSearchParams("reviewReturn=final-review"),
|
||||
@@ -197,6 +197,11 @@ describe("flowSteps", () => {
|
||||
expect(
|
||||
parseReviewReturnSearchParam(new URLSearchParams("reviewReturn=review")),
|
||||
).toBe("review");
|
||||
expect(
|
||||
parseReviewReturnSearchParam(
|
||||
new URLSearchParams("reviewReturn=completed"),
|
||||
),
|
||||
).toBe("completed");
|
||||
expect(
|
||||
parseReviewReturnSearchParam(new URLSearchParams("reviewReturn=nope")),
|
||||
).toBeNull();
|
||||
|
||||
@@ -306,6 +306,71 @@ describe("useCreateFlowFinalize", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("omits stakeholder emails when skipInvites is set", async () => {
|
||||
vi.mocked(publishRule).mockResolvedValue({
|
||||
ok: true,
|
||||
id: "new-rule-id",
|
||||
title: "Published title",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCreateFlowFinalize({
|
||||
state: {
|
||||
...emptyState,
|
||||
stakeholderEmails: ["invitee@example.com"],
|
||||
},
|
||||
router,
|
||||
openLogin,
|
||||
updateState,
|
||||
loginReturnPath: "/create/final-review",
|
||||
sessionUser: signedIn,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.finalize({ skipInvites: true });
|
||||
});
|
||||
|
||||
expect(publishRule).toHaveBeenCalledWith({
|
||||
title: "Published title",
|
||||
summary: "Published summary",
|
||||
document: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the send-failed banner when invitations do not send", async () => {
|
||||
vi.mocked(publishRule).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
error: "mail failed",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCreateFlowFinalize({
|
||||
state: {
|
||||
...emptyState,
|
||||
stakeholderEmails: ["invitee@example.com"],
|
||||
},
|
||||
router,
|
||||
openLogin,
|
||||
updateState,
|
||||
loginReturnPath: "/create/final-review",
|
||||
sessionUser: signedIn,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.finalize({ skipInvites: false });
|
||||
});
|
||||
|
||||
expect(result.current.publishBannerTitle).toBe(
|
||||
messages.create.reviewAndComplete.confirmStakeholders.sendFailedTitle,
|
||||
);
|
||||
expect(result.current.publishBannerMessage).toBe(
|
||||
messages.create.reviewAndComplete.confirmStakeholders.sendFailedDescription,
|
||||
);
|
||||
});
|
||||
|
||||
it("routes to /create/completed without celebrate after PATCH update", async () => {
|
||||
vi.mocked(updatePublishedRule).mockResolvedValue({ ok: true });
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { membershipInvitePreview } from "../../lib/create/membershipInvitePreview";
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
|
||||
describe("membershipInvitePreview", () => {
|
||||
it("returns null when no membership method is selected", () => {
|
||||
expect(membershipInvitePreview({} as CreateFlowState)).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the first selected membership method and saved words", () => {
|
||||
const preview = membershipInvitePreview({
|
||||
selectedMembershipMethodIds: ["open-admission"],
|
||||
membershipMethodDetailsById: {
|
||||
"open-admission": {
|
||||
eligibility: "Anyone who shares the work belongs.",
|
||||
joiningProcess: "Work two Sunday shares, then ask to join.",
|
||||
expectations: "",
|
||||
},
|
||||
},
|
||||
} as CreateFlowState);
|
||||
expect(preview?.title).toBe("Open admission");
|
||||
expect(preview?.categoryName).toBe("Membership");
|
||||
expect(preview?.eligibility).toBe("Anyone who shares the work belongs.");
|
||||
expect(preview?.joiningProcess).toBe(
|
||||
"Work two Sunday shares, then ask to join.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatInviteCountWord,
|
||||
parseInvitePaste,
|
||||
splitInvitePasteTokens,
|
||||
} from "../../lib/create/parseInviteEmails";
|
||||
|
||||
describe("parseInviteEmails", () => {
|
||||
it("splits comma, semicolon, and whitespace lists", () => {
|
||||
expect(
|
||||
splitInvitePasteTokens("rosa@example.org, dev@example.org; sam@example.org\nlee@example.org"),
|
||||
).toEqual([
|
||||
"rosa@example.org",
|
||||
"dev@example.org",
|
||||
"sam@example.org",
|
||||
"lee@example.org",
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds valid addresses and leaves invalid tokens in the remainder", () => {
|
||||
const result = parseInvitePaste(
|
||||
"rosa@example.org, jo@example, sam@example.org",
|
||||
["maya@example.org"],
|
||||
30,
|
||||
);
|
||||
expect(result.added).toEqual(["rosa@example.org", "sam@example.org"]);
|
||||
expect(result.invalidRemainder).toBe("jo@example");
|
||||
});
|
||||
|
||||
it("skips duplicates already on the list", () => {
|
||||
const result = parseInvitePaste(
|
||||
"maya@example.org, rosa@example.org",
|
||||
["maya@example.org"],
|
||||
30,
|
||||
);
|
||||
expect(result.added).toEqual(["rosa@example.org"]);
|
||||
expect(result.skippedDuplicate).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps overflow addresses in the remainder once the max is hit", () => {
|
||||
const result = parseInvitePaste(
|
||||
"a@example.org, b@example.org, c@example.org",
|
||||
[],
|
||||
2,
|
||||
);
|
||||
expect(result.added).toEqual(["a@example.org", "b@example.org"]);
|
||||
expect(result.invalidRemainder).toBe("c@example.org");
|
||||
});
|
||||
|
||||
it("uses words for counts through ten", () => {
|
||||
expect(formatInviteCountWord(5)).toBe("five");
|
||||
expect(formatInviteCountWord(11)).toBe("11");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user