Implement share and export components

This commit is contained in:
adilallo
2026-04-29 22:27:46 -06:00
parent a31a36c926
commit a37a72c71d
58 changed files with 3153 additions and 117 deletions
+45
View File
@@ -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,
});
});
});
+159
View File
@@ -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("&lt;script&gt;");
expect(html).not.toContain("<script>bad()");
expect(html).toContain("Values &lt;x&gt;");
});
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",
);
});
});
+137
View File
@@ -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", () => {