Implement share and export components
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user