Implement share and export components
This commit is contained in:
@@ -1,8 +1,13 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders as render, screen } from "../utils/test-utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { CompletedScreen } from "../../app/(app)/create/screens/completed/CompletedScreen";
|
||||
import { CREATE_FLOW_LAST_PUBLISHED_KEY } from "../../lib/create/lastPublishedRule";
|
||||
import {
|
||||
CREATE_FLOW_COMPLETED_CELEBRATE_QUERY,
|
||||
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
|
||||
} from "../../app/(app)/create/utils/flowSteps";
|
||||
|
||||
const storedRuleFixture = {
|
||||
id: "rule-fixture-1",
|
||||
@@ -32,9 +37,18 @@ const storedRuleFixture = {
|
||||
},
|
||||
};
|
||||
|
||||
function mockSearchParams(record?: Record<string, string>) {
|
||||
vi.mocked(useSearchParams).mockReturnValue(
|
||||
new URLSearchParams(record ?? undefined) as NonNullable<
|
||||
ReturnType<typeof useSearchParams>
|
||||
>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("CompletedScreen", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.removeItem(CREATE_FLOW_LAST_PUBLISHED_KEY);
|
||||
mockSearchParams();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -70,7 +84,25 @@ describe("CompletedScreen", () => {
|
||||
expect(screen.getByText("Fixture value title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders toast alert when page loads", () => {
|
||||
it("does not show post-finalize toast without celebrate query", () => {
|
||||
render(<CompletedScreen />);
|
||||
expect(
|
||||
screen.queryByText(
|
||||
"This is what folks see when you share your CommunityRule",
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(
|
||||
"Your group can use this document as an operating manual.",
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows post-finalize toast in status region when celebrate query is set", () => {
|
||||
mockSearchParams({
|
||||
[CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]:
|
||||
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
|
||||
});
|
||||
render(<CompletedScreen />);
|
||||
expect(
|
||||
screen.getByText(
|
||||
@@ -82,10 +114,6 @@ describe("CompletedScreen", () => {
|
||||
"Your group can use this document as an operating manual.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders toast with role status", () => {
|
||||
render(<CompletedScreen />);
|
||||
const statusRegions = screen.getAllByRole("status");
|
||||
expect(statusRegions.length).toBeGreaterThanOrEqual(1);
|
||||
expect(
|
||||
|
||||
@@ -36,7 +36,7 @@ const config: ComponentTestSuiteConfig<CreateFlowTopNavProps> = {
|
||||
hasEdit: true,
|
||||
saveDraftOnExit: true,
|
||||
onShare: vi.fn(),
|
||||
onExport: vi.fn(),
|
||||
onSelectExportFormat: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
className: "test-class",
|
||||
@@ -66,11 +66,30 @@ describe("CreateFlowTopNav (behavioral tests)", () => {
|
||||
expect(exitButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Exit when saveDraftOnExit is false", () => {
|
||||
render(<CreateFlowTopNav saveDraftOnExit={false} />);
|
||||
const exitButton = screen.getByRole("button", { name: "Exit" });
|
||||
expect(exitButton).toBeInTheDocument();
|
||||
});
|
||||
it.each([
|
||||
["Download Markdown", "markdown"],
|
||||
["Download PDF", "pdf"],
|
||||
["Download CSV", "csv"],
|
||||
] as const)(
|
||||
"opens export menu and calls onSelectExportFormat for %s",
|
||||
async (menuLabel, expectedFormat) => {
|
||||
const user = userEvent.setup();
|
||||
const handleExport = vi.fn();
|
||||
render(
|
||||
<CreateFlowTopNav
|
||||
hasExport={true}
|
||||
onSelectExportFormat={handleExport}
|
||||
/>,
|
||||
);
|
||||
|
||||
const exportButton = screen.getByRole("button", { name: "Export" });
|
||||
await user.click(exportButton);
|
||||
const item = screen.getByRole("menuitem", { name: menuLabel });
|
||||
await user.click(item);
|
||||
|
||||
expect(handleExport).toHaveBeenCalledWith(expectedFormat);
|
||||
},
|
||||
);
|
||||
|
||||
it("renders Share button when hasShare is true", () => {
|
||||
render(<CreateFlowTopNav hasShare={true} />);
|
||||
@@ -86,7 +105,12 @@ describe("CreateFlowTopNav (behavioral tests)", () => {
|
||||
});
|
||||
|
||||
it("renders Export button when hasExport is true", () => {
|
||||
render(<CreateFlowTopNav hasExport={true} />);
|
||||
render(
|
||||
<CreateFlowTopNav
|
||||
hasExport={true}
|
||||
onSelectExportFormat={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const exportButton = screen.getByRole("button", { name: "Export" });
|
||||
expect(exportButton).toBeInTheDocument();
|
||||
});
|
||||
@@ -107,15 +131,4 @@ describe("CreateFlowTopNav (behavioral tests)", () => {
|
||||
|
||||
expect(handleExit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onShare when Share button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleShare = vi.fn();
|
||||
render(<CreateFlowTopNav hasShare={true} onShare={handleShare} />);
|
||||
|
||||
const shareButton = screen.getByRole("button", { name: "Share" });
|
||||
await user.click(shareButton);
|
||||
|
||||
expect(handleShare).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders as render, screen } from "../../utils/test-utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import ListItem from "../../../app/components/layout/ListItem";
|
||||
|
||||
describe("ListItem", () => {
|
||||
it("renders as a menu item with label and icon", () => {
|
||||
render(
|
||||
<div role="menu" aria-label="Test menu">
|
||||
<ListItem
|
||||
showDivider
|
||||
leadingIcon="markdown_copy"
|
||||
label="Download Markdown"
|
||||
onClick={vi.fn()}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
expect(screen.getByRole("menuitem", { name: "Download Markdown" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("invokes onClick when activated", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<div role="menu" aria-label="Test menu">
|
||||
<ListItem
|
||||
showDivider={false}
|
||||
leadingIcon="csv"
|
||||
label="Download CSV"
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
await user.click(screen.getByRole("menuitem", { name: "Download CSV" }));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders as render, screen } from "../../utils/test-utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import ListItem from "../../../app/components/layout/ListItem";
|
||||
import Popover from "../../../app/components/modals/Popover";
|
||||
|
||||
describe("Popover (export menu)", () => {
|
||||
it("exposes a menu landmark with localized label", () => {
|
||||
render(
|
||||
<Popover id="export-menu" menuAriaLabel="Export format">
|
||||
<ListItem
|
||||
showDivider
|
||||
leadingIcon="markdown_copy"
|
||||
label="Download Markdown"
|
||||
onClick={vi.fn()}
|
||||
/>
|
||||
</Popover>,
|
||||
);
|
||||
expect(screen.getByRole("menu", { name: "Export format" })).toBeTruthy();
|
||||
expect(screen.getByRole("menuitem", { name: "Download Markdown" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("invokes handler when list item clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCsv = vi.fn();
|
||||
render(
|
||||
<Popover id="popover-csv" menuAriaLabel="Pick format">
|
||||
<ListItem
|
||||
showDivider={false}
|
||||
leadingIcon="csv"
|
||||
label="Download CSV"
|
||||
onClick={onCsv}
|
||||
/>
|
||||
</Popover>,
|
||||
);
|
||||
await user.click(screen.getByRole("menuitem", { name: "Download CSV" }));
|
||||
expect(onCsv).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders as render, screen } from "../../utils/test-utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import Share from "../../../app/components/modals/Share";
|
||||
|
||||
const noopHandlers = {
|
||||
onCopyLink: vi.fn(),
|
||||
onEmailShare: vi.fn(),
|
||||
onSignalShare: vi.fn(),
|
||||
onSlackShare: vi.fn(),
|
||||
onDiscordShare: vi.fn(),
|
||||
};
|
||||
|
||||
describe("Share modal", () => {
|
||||
it("does not render dialog when closed", () => {
|
||||
render(
|
||||
<Share isOpen={false} onClose={vi.fn()} {...noopHandlers} />,
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders localized heading and copy link action when open", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCopyLink = vi.fn();
|
||||
render(
|
||||
<Share
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
{...noopHandlers}
|
||||
onCopyLink={onCopyLink}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: /Share this CommunityRule/ }),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Copy link" }));
|
||||
expect(onCopyLink).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("invokes channel handlers for Signal, Slack, and Discord", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSignalShare = vi.fn();
|
||||
const onSlackShare = vi.fn();
|
||||
const onDiscordShare = vi.fn();
|
||||
render(
|
||||
<Share
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
{...noopHandlers}
|
||||
onSignalShare={onSignalShare}
|
||||
onSlackShare={onSlackShare}
|
||||
onDiscordShare={onDiscordShare}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Signal" }));
|
||||
await user.click(screen.getByRole("button", { name: "Slack" }));
|
||||
await user.click(screen.getByRole("button", { name: "Discord" }));
|
||||
expect(onSignalShare).toHaveBeenCalledTimes(1);
|
||||
expect(onSlackShare).toHaveBeenCalledTimes(1);
|
||||
expect(onDiscordShare).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onClose when Done is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(<Share isOpen={true} onClose={onClose} {...noopHandlers} />);
|
||||
await user.click(screen.getByRole("button", { name: "Done" }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onClose when header overflow (more) is activated, matching modal chrome parity", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(<Share isOpen={true} onClose={onClose} {...noopHandlers} />);
|
||||
await user.click(screen.getByRole("button", { name: "More share options" }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
buildCoreValuesPrefillFromTemplateBody,
|
||||
buildTemplateCustomizePrefill,
|
||||
} from "../../lib/create/applyTemplatePrefill";
|
||||
import { methodSectionsPinsForHydratedSelections } from "../../lib/create/publishedDocumentToCreateFlowState";
|
||||
import coreValuesMessages from "../../messages/en/create/customRule/coreValues.json";
|
||||
|
||||
function coreValuePresetId(label: string): string {
|
||||
@@ -153,3 +154,47 @@ describe("buildCoreValuesPrefillFromTemplateBody", () => {
|
||||
expect(prefill.selectedCommunicationMethodIds).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTemplateCustomizePrefill + method card pins", () => {
|
||||
it("derives compact-deck pins for each non-empty method facet prefill", () => {
|
||||
const body = {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [{ title: "In-Person Meetings", body: "x" }],
|
||||
},
|
||||
{
|
||||
categoryName: "Membership",
|
||||
entries: [{ title: "Peer Sponsorship", body: "m" }],
|
||||
},
|
||||
{
|
||||
categoryName: "Decision-making",
|
||||
entries: [{ title: "Consensus Decision-Making", body: "d" }],
|
||||
},
|
||||
{
|
||||
categoryName: "Conflict management",
|
||||
entries: [{ title: "Restorative Justice", body: "c" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const prefill = buildTemplateCustomizePrefill(body);
|
||||
expect(methodSectionsPinsForHydratedSelections(prefill)).toEqual({
|
||||
communication: true,
|
||||
membership: true,
|
||||
decisionApproaches: true,
|
||||
conflictManagement: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not set pins when template supplies values only", () => {
|
||||
const prefill = buildTemplateCustomizePrefill({
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [{ title: "Consensus", body: "" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(methodSectionsPinsForHydratedSelections(prefill)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import React from "react";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { MessagesProvider } from "../../../app/contexts/MessagesContext";
|
||||
import messages from "../../../messages/en/index";
|
||||
import { useCompletedRuleShareExport } from "../../../app/(app)/create/hooks/useCompletedRuleShareExport";
|
||||
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
|
||||
import {
|
||||
DISCORD_WEB_DM_HUB_URL,
|
||||
DISCORD_NATIVE_DM_HUB_URL,
|
||||
NATIVE_SHARE_FALLBACK_DELAY_MS,
|
||||
SLACK_NATIVE_OPEN_URL,
|
||||
} from "../../../lib/create/shareChannels";
|
||||
|
||||
vi.mock("../../../lib/create/lastPublishedRule", () => ({
|
||||
readLastPublishedRule: vi.fn(),
|
||||
}));
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <MessagesProvider messages={messages}>{children}</MessagesProvider>;
|
||||
}
|
||||
|
||||
describe("useCompletedRuleShareExport", () => {
|
||||
const mockRule = {
|
||||
id: "rule-1",
|
||||
title: "Garden norms",
|
||||
summary: "Be kind.",
|
||||
document: {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(readLastPublishedRule).mockReturnValue(mockRule);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("shareViaSlack opens Slack web share URL when window.open succeeds", async () => {
|
||||
vi.useFakeTimers();
|
||||
const clickSpy = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, "click")
|
||||
.mockImplementation(() => {});
|
||||
const openSpy = vi.spyOn(window, "open").mockReturnValue({} as Window);
|
||||
const setBanner = vi.fn();
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCompletedRuleShareExport({
|
||||
setActionBanner: setBanner,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sharePublishedRuleViaSlack();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_SHARE_FALLBACK_DELAY_MS + 25);
|
||||
});
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
const anchorUnknown = clickSpy.mock.instances.at(-1) as unknown;
|
||||
expect(anchorUnknown).toBeInstanceOf(HTMLAnchorElement);
|
||||
const anchorEl = anchorUnknown as HTMLAnchorElement;
|
||||
expect(anchorEl.getAttribute("href")).toBe(SLACK_NATIVE_OPEN_URL);
|
||||
|
||||
const expectedUrl = `https://slack.com/share?url=${encodeURIComponent(`${window.location.origin}/rules/rule-1`)}`;
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
expectedUrl,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
expect(setBanner).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: "completedShareCopyFailed",
|
||||
status: "danger",
|
||||
}),
|
||||
);
|
||||
clickSpy.mockRestore();
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("shareViaSlack does not show copy-failed banner when fallback is skipped after handoff (no focus)", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
vi.spyOn(window, "open").mockReturnValue(null);
|
||||
const hasFocusSpy = vi.spyOn(document, "hasFocus").mockReturnValue(false);
|
||||
const writeText = vi.fn().mockRejectedValue(new Error("NotAllowedError"));
|
||||
vi.stubGlobal("navigator", {
|
||||
...navigator,
|
||||
share: undefined,
|
||||
canShare: undefined,
|
||||
clipboard: { writeText },
|
||||
});
|
||||
|
||||
const setBanner = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCompletedRuleShareExport({
|
||||
setActionBanner: setBanner,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sharePublishedRuleViaSlack();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_SHARE_FALLBACK_DELAY_MS + 25);
|
||||
});
|
||||
|
||||
expect(writeText).not.toHaveBeenCalled();
|
||||
expect(setBanner).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: "completedShareCopyFailed" }),
|
||||
);
|
||||
hasFocusSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("shareViaSlack suppresses copy-failed banner when clipboard denies after focus loss", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
vi.spyOn(window, "open").mockReturnValue(null);
|
||||
let hasFocusCalls = 0;
|
||||
const hasFocusSpy = vi.spyOn(document, "hasFocus").mockImplementation(() => {
|
||||
hasFocusCalls += 1;
|
||||
return hasFocusCalls <= 2;
|
||||
});
|
||||
const writeText = vi.fn().mockImplementation(async () => {
|
||||
throw new Error("NotAllowedError");
|
||||
});
|
||||
vi.stubGlobal("navigator", {
|
||||
...navigator,
|
||||
share: undefined,
|
||||
canShare: undefined,
|
||||
clipboard: { writeText },
|
||||
});
|
||||
|
||||
const setBanner = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCompletedRuleShareExport({
|
||||
setActionBanner: setBanner,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sharePublishedRuleViaSlack();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_SHARE_FALLBACK_DELAY_MS + 25);
|
||||
});
|
||||
|
||||
expect(writeText).toHaveBeenCalled();
|
||||
expect(setBanner).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: "completedShareCopyFailed" }),
|
||||
);
|
||||
hasFocusSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("shareViaSlack falls back to clipboard when popup blocked and Web Share cannot run", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
vi.spyOn(window, "open").mockReturnValue(null);
|
||||
vi.spyOn(document, "hasFocus").mockReturnValue(true);
|
||||
const share = vi.fn();
|
||||
vi.stubGlobal("navigator", {
|
||||
...navigator,
|
||||
share: share,
|
||||
canShare: vi.fn().mockReturnValue(false),
|
||||
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
});
|
||||
|
||||
const setBanner = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCompletedRuleShareExport({
|
||||
setActionBanner: setBanner,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sharePublishedRuleViaSlack();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_SHARE_FALLBACK_DELAY_MS + 25);
|
||||
});
|
||||
|
||||
expect(share).not.toHaveBeenCalled();
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
`${window.location.origin}/rules/rule-1`,
|
||||
);
|
||||
expect(setBanner).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: "completedShareSlackFallback",
|
||||
status: "positive",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shareViaSignal uses navigator.share when canShare allows URL-only data", async () => {
|
||||
const share = vi.fn().mockResolvedValue(undefined);
|
||||
vi.stubGlobal("navigator", {
|
||||
...navigator,
|
||||
share: share,
|
||||
canShare: vi.fn().mockImplementation((data: ShareData) => data.url != null),
|
||||
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
});
|
||||
|
||||
const setBanner = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCompletedRuleShareExport({
|
||||
setActionBanner: setBanner,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sharePublishedRuleViaSignal();
|
||||
});
|
||||
|
||||
expect(share).toHaveBeenCalledWith({
|
||||
url: `${window.location.origin}/rules/rule-1`,
|
||||
});
|
||||
expect(navigator.clipboard.writeText).not.toHaveBeenCalled();
|
||||
expect(setBanner).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shareViaDiscord opens Discord hub and copies link when share unavailable", async () => {
|
||||
vi.useFakeTimers();
|
||||
const clickSpy = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, "click")
|
||||
.mockImplementation(() => {});
|
||||
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
vi.stubGlobal("navigator", {
|
||||
...navigator,
|
||||
share: undefined,
|
||||
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
});
|
||||
|
||||
const setBanner = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCompletedRuleShareExport({
|
||||
setActionBanner: setBanner,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sharePublishedRuleViaDiscord();
|
||||
});
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
const anchorUnknown = clickSpy.mock.instances.at(-1) as unknown;
|
||||
expect(anchorUnknown).toBeInstanceOf(HTMLAnchorElement);
|
||||
expect((anchorUnknown as HTMLAnchorElement).getAttribute("href")).toBe(
|
||||
DISCORD_NATIVE_DM_HUB_URL,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_SHARE_FALLBACK_DELAY_MS + 25);
|
||||
});
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
DISCORD_WEB_DM_HUB_URL,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
`${window.location.origin}/rules/rule-1`,
|
||||
);
|
||||
expect(setBanner).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: "completedShareDiscordPaste",
|
||||
status: "positive",
|
||||
}),
|
||||
);
|
||||
clickSpy.mockRestore();
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("onSelectExportFormat pdf triggers download with community-rule pdf filename", () => {
|
||||
vi.mocked(readLastPublishedRule).mockReturnValue({
|
||||
...mockRule,
|
||||
document: {
|
||||
sections: [
|
||||
{ categoryName: "Values", entries: [{ title: "Norm", body: "Text." }] },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const createObjectURL = vi.fn().mockReturnValue("blob:unit-test");
|
||||
const revokeObjectURL = vi.fn();
|
||||
Object.defineProperty(URL, "createObjectURL", {
|
||||
value: createObjectURL,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(URL, "revokeObjectURL", {
|
||||
value: revokeObjectURL,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const clickSpy = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, "click")
|
||||
.mockImplementation(() => {});
|
||||
const setBanner = vi.fn();
|
||||
|
||||
try {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCompletedRuleShareExport({
|
||||
setActionBanner: setBanner,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.onSelectExportFormat("pdf");
|
||||
});
|
||||
|
||||
expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob));
|
||||
const blob = createObjectURL.mock.calls[0][0] as Blob;
|
||||
expect(blob.type).toBe("application/pdf");
|
||||
|
||||
const anchorUnknown = clickSpy.mock.instances.at(-1) as unknown;
|
||||
expect(anchorUnknown).toBeInstanceOf(HTMLAnchorElement);
|
||||
const anchorEl = anchorUnknown as HTMLAnchorElement;
|
||||
expect(anchorEl.getAttribute("download")).toBe(
|
||||
"garden-norms-community-rule.pdf",
|
||||
);
|
||||
expect(anchorEl.getAttribute("href")).toBe("blob:unit-test");
|
||||
expect(setBanner).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
Reflect.deleteProperty(URL, "createObjectURL");
|
||||
Reflect.deleteProperty(URL, "revokeObjectURL");
|
||||
clickSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { CreateFlowState } from "../../../app/(app)/create/types";
|
||||
import { useCreateFlowFinalize } from "../../../app/(app)/create/hooks/useCreateFlowFinalize";
|
||||
import { publishRule, updatePublishedRule } from "../../../lib/create/api";
|
||||
import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule";
|
||||
import {
|
||||
CREATE_FLOW_COMPLETED_CELEBRATE_QUERY,
|
||||
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
|
||||
} from "../../../app/(app)/create/utils/flowSteps";
|
||||
|
||||
vi.mock("../../../lib/create/buildPublishPayload", () => ({
|
||||
buildPublishPayload: vi.fn(() => ({
|
||||
ok: true as const,
|
||||
title: "Published title",
|
||||
summary: "Published summary",
|
||||
document: {},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../../lib/create/api", () => ({
|
||||
publishRule: vi.fn(),
|
||||
updatePublishedRule: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../lib/create/lastPublishedRule", () => ({
|
||||
writeLastPublishedRule: vi.fn(),
|
||||
}));
|
||||
|
||||
const emptyState = {} as CreateFlowState;
|
||||
|
||||
describe("useCreateFlowFinalize", () => {
|
||||
const router = { push: vi.fn() };
|
||||
const updateState = vi.fn();
|
||||
const openLogin = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(publishRule).mockReset();
|
||||
vi.mocked(updatePublishedRule).mockReset();
|
||||
vi.mocked(writeLastPublishedRule).mockReset();
|
||||
router.push.mockReset();
|
||||
updateState.mockReset();
|
||||
openLogin.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("routes with celebrate query after initial POST publish", async () => {
|
||||
vi.mocked(publishRule).mockResolvedValue({
|
||||
ok: true,
|
||||
id: "new-rule-id",
|
||||
title: "Published title",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCreateFlowFinalize({
|
||||
state: emptyState,
|
||||
router,
|
||||
openLogin,
|
||||
updateState,
|
||||
loginReturnPath: "/create/final-review",
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.finalize();
|
||||
});
|
||||
|
||||
expect(router.push).toHaveBeenCalledWith(
|
||||
`/create/completed?${CREATE_FLOW_COMPLETED_CELEBRATE_QUERY}=${CREATE_FLOW_COMPLETED_CELEBRATE_VALUE}`,
|
||||
);
|
||||
expect(updatePublishedRule).not.toHaveBeenCalled();
|
||||
expect(writeLastPublishedRule).toHaveBeenCalledWith({
|
||||
id: "new-rule-id",
|
||||
title: "Published title",
|
||||
summary: "Published summary",
|
||||
document: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("routes to /create/completed without celebrate after PATCH update", async () => {
|
||||
vi.mocked(updatePublishedRule).mockResolvedValue({ ok: true });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCreateFlowFinalize({
|
||||
state: {
|
||||
...emptyState,
|
||||
editingPublishedRuleId: " existing-id ",
|
||||
},
|
||||
router,
|
||||
openLogin,
|
||||
updateState,
|
||||
loginReturnPath: "/create/edit-rule",
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.finalize();
|
||||
});
|
||||
|
||||
expect(router.push).toHaveBeenCalledWith("/create/completed");
|
||||
expect(publishRule).not.toHaveBeenCalled();
|
||||
expect(updatePublishedRule).toHaveBeenCalledWith(
|
||||
"existing-id",
|
||||
expect.objectContaining({
|
||||
title: "Published title",
|
||||
summary: "Published summary",
|
||||
document: {},
|
||||
}),
|
||||
);
|
||||
expect(writeLastPublishedRule).toHaveBeenCalledWith({
|
||||
id: "existing-id",
|
||||
title: "Published title",
|
||||
summary: "Published summary",
|
||||
document: {},
|
||||
});
|
||||
expect(updateState).toHaveBeenCalledWith({
|
||||
editingPublishedRuleId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildPrintableRuleHtmlDocument,
|
||||
buildPublicRuleUrl,
|
||||
buildStoredRulePdfBlob,
|
||||
exportFilenameBase,
|
||||
sectionsToCsv,
|
||||
sectionsToMarkdown,
|
||||
} from "../../../lib/create/ruleExport";
|
||||
import type { CommunityRuleSection } from "../../../app/components/type/CommunityRule/CommunityRule.types";
|
||||
|
||||
async function readBlobAsArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
|
||||
if (typeof blob.arrayBuffer === "function") {
|
||||
return blob.arrayBuffer();
|
||||
}
|
||||
return new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onload = (): void => resolve(r.result as ArrayBuffer);
|
||||
r.onerror = (): void => reject(new Error("FileReader failed"));
|
||||
r.readAsArrayBuffer(blob);
|
||||
});
|
||||
}
|
||||
|
||||
describe("ruleExport", () => {
|
||||
it("buildPublicRuleUrl encodes id and trims origin slash", () => {
|
||||
expect(buildPublicRuleUrl("https://example.com/", "abc/xyz")).toBe(
|
||||
"https://example.com/rules/abc%2Fxyz",
|
||||
);
|
||||
expect(buildPublicRuleUrl("https://example.com", "r1")).toBe(
|
||||
"https://example.com/rules/r1",
|
||||
);
|
||||
});
|
||||
|
||||
it("exportFilenameBase slugifies title", () => {
|
||||
expect(
|
||||
exportFilenameBase({
|
||||
id: "id-1",
|
||||
title: "Mutual Aid Mondays!",
|
||||
document: {},
|
||||
}),
|
||||
).toBe("mutual-aid-mondays");
|
||||
});
|
||||
|
||||
it("exportFilenameBase falls back to id fragment", () => {
|
||||
expect(
|
||||
exportFilenameBase({
|
||||
id: "full-uuid-here",
|
||||
title: " ",
|
||||
document: {},
|
||||
}),
|
||||
).toBe("rule-full-uui");
|
||||
});
|
||||
|
||||
it("sectionsToMarkdown renders title, summary, and sections", () => {
|
||||
const sections: CommunityRuleSection[] = [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [
|
||||
{
|
||||
title: "Solidarity",
|
||||
body: "First paragraph.\n\nSecond paragraph.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const md = sectionsToMarkdown(
|
||||
"My Rule",
|
||||
"Short summary.",
|
||||
sections,
|
||||
);
|
||||
expect(md).toContain("# My Rule");
|
||||
expect(md).toContain("Short summary.");
|
||||
expect(md).toContain("## Values");
|
||||
expect(md).toContain("### Solidarity");
|
||||
expect(md).toContain("First paragraph.");
|
||||
expect(md).toContain("Second paragraph.");
|
||||
});
|
||||
|
||||
it("sectionsToCsv includes header row, title metadata, sections, and quotes commas", () => {
|
||||
const sections: CommunityRuleSection[] = [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [
|
||||
{
|
||||
title: "Solidarity",
|
||||
body: "One, two",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const csv = sectionsToCsv("My Rule", "Sum, mary", sections);
|
||||
expect(csv).toContain("Section,Entry,Block label,Content");
|
||||
expect(csv).toContain('"Sum, mary"');
|
||||
expect(csv).toContain('"One, two"');
|
||||
expect(csv).toContain(",Title,,My Rule");
|
||||
});
|
||||
|
||||
it("buildPrintableRuleHtmlDocument escapes HTML in user content", () => {
|
||||
const sections: CommunityRuleSection[] = [
|
||||
{
|
||||
categoryName: 'Values <x>',
|
||||
entries: [{ title: "Entry", body: "<script>bad()</script>" }],
|
||||
},
|
||||
];
|
||||
const html = buildPrintableRuleHtmlDocument(
|
||||
'Title <t>',
|
||||
null,
|
||||
sections,
|
||||
);
|
||||
expect(html).toContain("<script>");
|
||||
expect(html).not.toContain("<script>bad()");
|
||||
expect(html).toContain("Values <x>");
|
||||
});
|
||||
|
||||
it("buildStoredRulePdfBlob produces application/pdf with PDF magic bytes", async () => {
|
||||
const blob = buildStoredRulePdfBlob({
|
||||
id: "id-1",
|
||||
title: "Garden Norms",
|
||||
summary: "Summary here.",
|
||||
document: {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [{ title: "Solidarity", body: "Be kind.\n\nShare tools." }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(blob.type).toBe("application/pdf");
|
||||
expect(blob.size).toBeGreaterThan(500);
|
||||
const buf = new Uint8Array(await readBlobAsArrayBuffer(blob));
|
||||
expect(String.fromCharCode(...buf.subarray(0, 5))).toBe("%PDF-");
|
||||
});
|
||||
|
||||
it("buildStoredRulePdfBlob throws exportEmptyDocument when sections empty", () => {
|
||||
expect(() =>
|
||||
buildStoredRulePdfBlob({
|
||||
id: "id-1",
|
||||
title: "T",
|
||||
document: {},
|
||||
}),
|
||||
).toThrowError("exportEmptyDocument");
|
||||
});
|
||||
|
||||
it("export pdf attachment filename matches csv/md convention", () => {
|
||||
const rule = {
|
||||
id: "id-1",
|
||||
title: "Garden norms",
|
||||
document: {
|
||||
sections: [
|
||||
{ categoryName: "X", entries: [{ title: "t", body: "b" }] },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(`${exportFilenameBase(rule)}-community-rule.pdf`).toBe(
|
||||
"garden-norms-community-rule.pdf",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import {
|
||||
buildMailtoShareHref,
|
||||
buildSlackWebShareUrl,
|
||||
DISCORD_NATIVE_DM_HUB_URL,
|
||||
DISCORD_WEB_DM_HUB_URL,
|
||||
NATIVE_SHARE_FALLBACK_DELAY_MS,
|
||||
type NativeFallbackTimers,
|
||||
scheduleNativeSchemeThenFallback,
|
||||
SLACK_NATIVE_OPEN_URL,
|
||||
} from "../../../lib/create/shareChannels";
|
||||
|
||||
describe("shareChannels", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("buildSlackWebShareUrl encodes the outgoing URL query value", () => {
|
||||
expect(buildSlackWebShareUrl("https://example.com/rules/r1")).toBe(
|
||||
"https://slack.com/share?url=https%3A%2F%2Fexample.com%2Frules%2Fr1",
|
||||
);
|
||||
expect(
|
||||
buildSlackWebShareUrl("https://example.com/rules/a?b=c&d=e"),
|
||||
).toBe(
|
||||
"https://slack.com/share?url=https%3A%2F%2Fexample.com%2Frules%2Fa%3Fb%3Dc%26d%3De",
|
||||
);
|
||||
});
|
||||
|
||||
it("buildMailtoShareHref percent-encodes subject and body including newlines", () => {
|
||||
expect(
|
||||
buildMailtoShareHref({
|
||||
subject: "Hello & welcome",
|
||||
body: "Line one\n\nhttps://x.com/y z",
|
||||
}),
|
||||
).toBe(
|
||||
"mailto:?subject=Hello%20%26%20welcome&body=Line%20one%0A%0Ahttps%3A%2F%2Fx.com%2Fy%20z",
|
||||
);
|
||||
});
|
||||
|
||||
it("buildMailtoShareHref handles unicode", () => {
|
||||
const href = buildMailtoShareHref({
|
||||
subject: "日本語",
|
||||
body: "café ☕",
|
||||
});
|
||||
expect(href.startsWith("mailto:?subject=")).toBe(true);
|
||||
expect(href).toContain(encodeURIComponent("日本語"));
|
||||
expect(href).toContain(encodeURIComponent("café ☕"));
|
||||
});
|
||||
|
||||
it("exposes Discord native + web DM hub URL constants", () => {
|
||||
expect(DISCORD_WEB_DM_HUB_URL).toBe("https://discord.com/channels/@me");
|
||||
expect(DISCORD_NATIVE_DM_HUB_URL).toBe("discord://-/channels/@me");
|
||||
});
|
||||
|
||||
it("scheduleNativeSchemeThenFallback skips native assign and invokes fallback synchronously when URL is not allowlisted", () => {
|
||||
const assign = vi.fn();
|
||||
const fb = vi.fn();
|
||||
const timers: NativeFallbackTimers = {
|
||||
setTimeout: (): unknown => 0,
|
||||
clearTimeout: vi.fn(),
|
||||
};
|
||||
|
||||
scheduleNativeSchemeThenFallback(
|
||||
"javascript:alert(1)",
|
||||
fb,
|
||||
{
|
||||
assignLocationHref: assign,
|
||||
getVisibilityState: (): Document["visibilityState"] => "visible",
|
||||
onVisibilityChange: () => {},
|
||||
offVisibilityChange: () => {},
|
||||
},
|
||||
timers,
|
||||
);
|
||||
|
||||
expect(assign).not.toHaveBeenCalled();
|
||||
expect(fb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("scheduleNativeSchemeThenFallback triggers fallback once after timeout when tab stays visible", () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const assign = vi.fn();
|
||||
const fb = vi.fn();
|
||||
|
||||
scheduleNativeSchemeThenFallback(
|
||||
SLACK_NATIVE_OPEN_URL,
|
||||
fb,
|
||||
{
|
||||
assignLocationHref: assign,
|
||||
getVisibilityState: (): Document["visibilityState"] => "visible",
|
||||
onVisibilityChange: () => {},
|
||||
offVisibilityChange: () => {},
|
||||
},
|
||||
window as unknown as NativeFallbackTimers,
|
||||
NATIVE_SHARE_FALLBACK_DELAY_MS,
|
||||
);
|
||||
|
||||
expect(assign).toHaveBeenCalledWith(SLACK_NATIVE_OPEN_URL);
|
||||
|
||||
vi.advanceTimersByTime(NATIVE_SHARE_FALLBACK_DELAY_MS - 1);
|
||||
expect(fb).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(10);
|
||||
expect(fb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("scheduleNativeSchemeThenFallback cancels fallback when visibility becomes hidden before timeout", () => {
|
||||
vi.useFakeTimers();
|
||||
let vis: Document["visibilityState"] = "visible";
|
||||
const listeners: (() => void)[] = [];
|
||||
const fb = vi.fn();
|
||||
|
||||
scheduleNativeSchemeThenFallback(
|
||||
DISCORD_NATIVE_DM_HUB_URL,
|
||||
fb,
|
||||
{
|
||||
assignLocationHref: vi.fn(),
|
||||
getVisibilityState: (): Document["visibilityState"] => vis,
|
||||
onVisibilityChange: (l: () => void): void => {
|
||||
listeners.push(l);
|
||||
},
|
||||
offVisibilityChange: (l: () => void): void => {
|
||||
const idx = listeners.indexOf(l);
|
||||
if (idx >= 0) listeners.splice(idx, 1);
|
||||
},
|
||||
},
|
||||
window as unknown as NativeFallbackTimers,
|
||||
NATIVE_SHARE_FALLBACK_DELAY_MS,
|
||||
);
|
||||
|
||||
vis = "hidden";
|
||||
listeners.forEach((l) => l());
|
||||
|
||||
vi.advanceTimersByTime(NATIVE_SHARE_FALLBACK_DELAY_MS + 200);
|
||||
expect(fb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,185 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createFlowStateFromPublishedRule } from "../../lib/create/publishedDocumentToCreateFlowState";
|
||||
import {
|
||||
createFlowStateFromPublishedRule,
|
||||
isPublishedRuleSelectionMissing,
|
||||
methodSectionsPinsForHydratedSelections,
|
||||
methodSectionsPinsFromPublishedHydratePatch,
|
||||
} from "../../lib/create/publishedDocumentToCreateFlowState";
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
|
||||
describe("isPublishedRuleSelectionMissing", () => {
|
||||
it("is true when published patch has communication ids but state has none", () => {
|
||||
const patch = createFlowStateFromPublishedRule({
|
||||
id: "r",
|
||||
title: "T",
|
||||
summary: "",
|
||||
document: {
|
||||
methodSelections: {
|
||||
communication: [
|
||||
{
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
sections: {
|
||||
corePrinciple: "",
|
||||
logisticsAdmin: "",
|
||||
codeOfConduct: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const state = {
|
||||
sections: [],
|
||||
title: "T",
|
||||
editingPublishedRuleId: "r",
|
||||
} as CreateFlowState;
|
||||
expect(isPublishedRuleSelectionMissing(state, patch)).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when sections are clear and state already has matching facet ids", () => {
|
||||
const patch = createFlowStateFromPublishedRule({
|
||||
id: "r",
|
||||
title: "T",
|
||||
summary: "",
|
||||
document: {
|
||||
methodSelections: {
|
||||
communication: [
|
||||
{
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
sections: {
|
||||
corePrinciple: "",
|
||||
logisticsAdmin: "",
|
||||
codeOfConduct: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const state = {
|
||||
sections: [],
|
||||
title: "T",
|
||||
editingPublishedRuleId: "r",
|
||||
selectedCommunicationMethodIds: ["slack"],
|
||||
} as CreateFlowState;
|
||||
expect(isPublishedRuleSelectionMissing(state, patch)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("methodSectionsPinsForHydratedSelections / methodSectionsPinsFromPublishedHydratePatch", () => {
|
||||
it("alias matches hydrated-selection helper output", () => {
|
||||
const partial: Partial<CreateFlowState> = {
|
||||
selectedCommunicationMethodIds: ["a"],
|
||||
selectedConflictManagementIds: ["b"],
|
||||
};
|
||||
expect(methodSectionsPinsFromPublishedHydratePatch(partial)).toEqual(
|
||||
methodSectionsPinsForHydratedSelections(partial),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("methodSectionsPinsFromPublishedHydratePatch", () => {
|
||||
it("sets communication when published patch includes communication ids", () => {
|
||||
const patch = createFlowStateFromPublishedRule({
|
||||
id: "r",
|
||||
title: "T",
|
||||
summary: "",
|
||||
document: {
|
||||
methodSelections: {
|
||||
communication: [
|
||||
{
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
sections: {
|
||||
corePrinciple: "",
|
||||
logisticsAdmin: "",
|
||||
codeOfConduct: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(methodSectionsPinsFromPublishedHydratePatch(patch)).toEqual({
|
||||
communication: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("sets all four method facets when each has selections on the patch", () => {
|
||||
const patch = createFlowStateFromPublishedRule({
|
||||
id: "r",
|
||||
title: "T",
|
||||
summary: "",
|
||||
document: {
|
||||
methodSelections: {
|
||||
communication: [
|
||||
{
|
||||
id: "slack",
|
||||
label: "S",
|
||||
sections: {
|
||||
corePrinciple: "",
|
||||
logisticsAdmin: "",
|
||||
codeOfConduct: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
membership: [
|
||||
{
|
||||
id: "x",
|
||||
label: "X",
|
||||
sections: {
|
||||
eligibility: "",
|
||||
joiningProcess: "",
|
||||
expectations: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
decisionApproaches: [
|
||||
{
|
||||
id: "d",
|
||||
label: "D",
|
||||
sections: {
|
||||
corePrinciple: "",
|
||||
applicableScope: [],
|
||||
selectedApplicableScope: [],
|
||||
stepByStepInstructions: "",
|
||||
consensusLevel: 0,
|
||||
objectionsDeadlocks: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
conflictManagement: [
|
||||
{
|
||||
id: "c",
|
||||
label: "C",
|
||||
sections: {
|
||||
corePrinciple: "",
|
||||
applicableScope: [],
|
||||
selectedApplicableScope: [],
|
||||
processProtocol: "",
|
||||
restorationFallbacks: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(methodSectionsPinsFromPublishedHydratePatch(patch)).toEqual({
|
||||
communication: true,
|
||||
membership: true,
|
||||
decisionApproaches: true,
|
||||
conflictManagement: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns {} when patch has no method-card selections", () => {
|
||||
expect(methodSectionsPinsFromPublishedHydratePatch({ sections: [] })).toEqual(
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFlowStateFromPublishedRule", () => {
|
||||
it("maps coreValues and methodSelections into draft fields", () => {
|
||||
|
||||
Reference in New Issue
Block a user