fix: Plain chat not opening from help button for free users and on bookings list page (#23491)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { FileUploader, type FileData } from "@calcom/ui/components/file-uploader";
|
||||
@@ -9,8 +9,7 @@ import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@calcom/ui/components/popover";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
const PlainContactForm = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const PlainContactForm = ({ open, setIsOpen }: { open: boolean, setIsOpen: Dispatch<SetStateAction<boolean>> }) => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
||||
@@ -55,6 +54,7 @@ const PlainContactForm = () => {
|
||||
setIsUploadingImage(false);
|
||||
|
||||
const formData = new FormData();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
uploadFormData.forEach(({ key, value }: any) => {
|
||||
formData.append(key, value);
|
||||
});
|
||||
@@ -138,7 +138,7 @@ const PlainContactForm = () => {
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-[1rem] right-[1rem] z-50">
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Popover open={open} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild className="enabled:hover:bg-subtle bg-subtle shadow-none">
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
|
||||
@@ -17,11 +17,15 @@ vi.mock("@calcom/ui/components/toast", () => ({
|
||||
const mockUseSession = vi.mocked(useSession);
|
||||
const mockShowToast = vi.mocked(showToast);
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
global.fetch = mockFetch as unknown as typeof fetch;
|
||||
|
||||
describe("PlainContactForm", () => {
|
||||
let mockSetIsOpen: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSetIsOpen = vi.fn();
|
||||
|
||||
mockUseSession.mockReturnValue({
|
||||
data: {
|
||||
hasValidLicense: true,
|
||||
@@ -35,42 +39,42 @@ describe("PlainContactForm", () => {
|
||||
},
|
||||
status: "authenticated",
|
||||
update: vi.fn(),
|
||||
});
|
||||
} as any);
|
||||
});
|
||||
|
||||
it("should render contact button when closed", () => {
|
||||
render(<PlainContactForm />);
|
||||
it("renders the contact button when closed", () => {
|
||||
render(<PlainContactForm open={false} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open contact form when button is clicked", () => {
|
||||
render(<PlainContactForm />);
|
||||
it("opens when the launcher button is clicked (calls setIsOpen(true))", () => {
|
||||
render(<PlainContactForm open={false} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mockSetIsOpen).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("shows the form when open=true", () => {
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
expect(screen.getByText("Contact support")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Describe the issue")).toBeInTheDocument();
|
||||
expect(screen.getByText("Attachments (optional)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show empty form initially", () => {
|
||||
render(<PlainContactForm />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
it("shows empty form initially", () => {
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
const messageInput = screen.getByLabelText("Describe the issue") as HTMLTextAreaElement;
|
||||
expect(messageInput.value).toBe("");
|
||||
});
|
||||
|
||||
it("should close form when X button is clicked", () => {
|
||||
render(<PlainContactForm />);
|
||||
|
||||
const openButton = screen.getByRole("button");
|
||||
fireEvent.click(openButton);
|
||||
it("closes when X button is clicked (calls setIsOpen(false))", () => {
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const closeButton = buttons.find((button) => button.querySelector('svg use[href="#x"]'));
|
||||
@@ -78,19 +82,16 @@ describe("PlainContactForm", () => {
|
||||
|
||||
fireEvent.click(closeButton!);
|
||||
|
||||
expect(screen.queryByText("Contact support")).not.toBeInTheDocument();
|
||||
expect(mockSetIsOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("should handle form submission successfully", async () => {
|
||||
it("handles form submission successfully", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
});
|
||||
|
||||
render(<PlainContactForm />);
|
||||
|
||||
const openButton = screen.getByRole("button");
|
||||
fireEvent.click(openButton);
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Describe the issue"), {
|
||||
target: { value: "Test message" },
|
||||
@@ -115,14 +116,11 @@ describe("PlainContactForm", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should show loading state during submission", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
it("shows loading state during submission", async () => {
|
||||
// never resolves → stays loading
|
||||
mockFetch.mockImplementation(() => new Promise((_resolve) => {}));
|
||||
|
||||
render(<PlainContactForm />);
|
||||
|
||||
const openButton = screen.getByRole("button");
|
||||
fireEvent.click(openButton);
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Describe the issue"), {
|
||||
target: { value: "Test message" },
|
||||
@@ -135,23 +133,19 @@ describe("PlainContactForm", () => {
|
||||
await expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should reset form after successful submission", async () => {
|
||||
it("resets form after successful submission", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
});
|
||||
|
||||
render(<PlainContactForm />);
|
||||
|
||||
const openButton = screen.getByRole("button");
|
||||
fireEvent.click(openButton);
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Describe the issue"), {
|
||||
target: { value: "Test message" },
|
||||
});
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: /send message/i });
|
||||
fireEvent.click(submitButton);
|
||||
fireEvent.click(screen.getByRole("button", { name: /send message/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Message Sent")).toBeInTheDocument();
|
||||
@@ -166,27 +160,21 @@ describe("PlainContactForm", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle missing user session", () => {
|
||||
it("handles missing user session", () => {
|
||||
mockUseSession.mockReturnValue({
|
||||
data: null,
|
||||
status: "unauthenticated",
|
||||
update: vi.fn(),
|
||||
});
|
||||
} as any);
|
||||
|
||||
render(<PlainContactForm />);
|
||||
|
||||
const openButton = screen.getByRole("button");
|
||||
fireEvent.click(openButton);
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
const messageInput = screen.getByLabelText("Describe the issue") as HTMLTextAreaElement;
|
||||
expect(messageInput.value).toBe("");
|
||||
});
|
||||
|
||||
it("should require message field", async () => {
|
||||
render(<PlainContactForm />);
|
||||
|
||||
const openButton = screen.getByRole("button");
|
||||
fireEvent.click(openButton);
|
||||
it("requires message field", async () => {
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
await expect(screen.getByLabelText("Describe the issue")).toHaveAttribute("required");
|
||||
});
|
||||
|
||||
@@ -83,6 +83,7 @@ const PlainChat = IS_PLAIN_CHAT_ENABLED
|
||||
? ({ nonce }: { nonce: string | undefined }) => {
|
||||
const [config, setConfig] = useState<PlainChatConfig | null>(null);
|
||||
const [isSmallScreen, setIsSmallScreen] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data: session } = useSession();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -269,10 +270,19 @@ const PlainChat = IS_PLAIN_CHAT_ENABLED
|
||||
|
||||
checkScreenSize();
|
||||
window.addEventListener("resize", checkScreenSize);
|
||||
initConfig();
|
||||
|
||||
if (isPaidUser) {
|
||||
initConfig();
|
||||
} else if (typeof window !== "undefined" && !window.Plain) {
|
||||
window.Plain = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
init: () => {},
|
||||
open: () => {
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
}
|
||||
return () => window.removeEventListener("resize", checkScreenSize);
|
||||
}, [isAppDomain, checkScreenSize, initConfig, userEmail]);
|
||||
}, [isAppDomain, checkScreenSize, initConfig, userEmail, isPaidUser]);
|
||||
|
||||
const plainChatScript = `
|
||||
window.plainScriptLoaded = function() {
|
||||
@@ -289,7 +299,7 @@ const PlainChat = IS_PLAIN_CHAT_ENABLED
|
||||
if (!isAppDomain || isSmallScreen || typeof window === "undefined") return null;
|
||||
|
||||
if (!isPaidUser) {
|
||||
return <PlainContactForm />;
|
||||
return <PlainContactForm open={isOpen} setIsOpen={setIsOpen} />;
|
||||
}
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
@@ -14,10 +14,11 @@ export default function useIsBookingPage(): boolean {
|
||||
"/apps/routing-forms/routing-link", // Routing Form page
|
||||
"/forms/", // Rewrites to /apps/routing-forms/routing-link
|
||||
].some((route) => pathname?.startsWith(route));
|
||||
const isBookingsListPage = ["/upcoming", "/unconfirmed", "/recurring", "/cancelled", "/past"].some((route) => pathname?.endsWith(route));
|
||||
|
||||
const searchParams = useCompatSearchParams();
|
||||
const isUserBookingPage = Boolean(searchParams?.get("user"));
|
||||
const isUserBookingTypePage = Boolean(searchParams?.get("user") && searchParams?.get("type"));
|
||||
|
||||
return isBookingPage || isUserBookingPage || isUserBookingTypePage;
|
||||
return (isBookingPage && !isBookingsListPage) || isUserBookingPage || isUserBookingTypePage;
|
||||
}
|
||||
|
||||
@@ -56,11 +56,19 @@ export function UserDropdown({ small }: UserDropdownProps) {
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const handleHelpClick = () => {
|
||||
if (window.Plain) {
|
||||
window.Plain.open();
|
||||
}
|
||||
const handleHelpClick = (e?: React.MouseEvent) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
|
||||
setMenuOpen(false);
|
||||
|
||||
// Defer to next frame to avoid the originating click closing the dialog
|
||||
requestAnimationFrame(() => {
|
||||
// double RAF is extra-safe if your dropdown unmounts with a transition
|
||||
requestAnimationFrame(() => {
|
||||
if (window.Plain) window.Plain.open();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Prevent rendering dropdown if user isn't available.
|
||||
|
||||
Reference in New Issue
Block a user