fix: Plain chat not opening from help button for free users and on bookings list page (#23491)

This commit is contained in:
Amit Sharma
2025-09-01 12:28:01 +00:00
committed by GitHub
parent b101157d67
commit 0a9e732ea4
5 changed files with 66 additions and 59 deletions
@@ -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");
});
+14 -4
View File
@@ -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;
+2 -1
View File
@@ -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.