Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions apps/web/components/plain/PlainContactForm.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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)}
Expand Down
80 changes: 34 additions & 46 deletions apps/web/components/plain/__tests__/PlainContactForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Comment on lines 19 to +28
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Reset the fetch mock between tests to avoid leaked implementations

The “loading” test installs a never-resolving impl; since only vi.clearAllMocks() runs, that impl can bleed into later tests. Reset the mock each test.

Apply in beforeEach:

 beforeEach(() => {
   vi.clearAllMocks();
+  mockFetch.mockReset();
   mockSetIsOpen = vi.fn();

🏁 Script executed:

#!/bin/bash
sed -n '1,200p' apps/web/components/plain/__tests__/PlainContactForm.test.tsx

Length of output: 5663


Reset fetch mock implementation between tests

vi.clearAllMocks() only clears call history, not mock implementations—add mockFetch.mockReset() in each test’s setup to clear any custom implementations.

Apply in beforeEach:

 beforeEach(() => {
   vi.clearAllMocks();
+  mockFetch.mockReset();
   mockSetIsOpen = vi.fn();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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();
const mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
describe("PlainContactForm", () => {
let mockSetIsOpen: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
mockFetch.mockReset();
mockSetIsOpen = vi.fn();
});
// …
});
🤖 Prompt for AI Agents
In apps/web/components/plain/__tests__/PlainContactForm.test.tsx around lines 19
to 28, the test setup only calls vi.clearAllMocks() which clears call history
but does not reset mock implementations on global.fetch; add
mockFetch.mockReset() (or mockClear() as appropriate) inside the beforeEach
after vi.clearAllMocks() to reset any custom fetch implementations and ensure
each test starts with a clean mock implementation.

mockUseSession.mockReturnValue({
data: {
hasValidLicense: true,
Expand All @@ -35,62 +39,59 @@ 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"]'));
expect(closeButton).toBeDefined();

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" },
Expand All @@ -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" },
Expand All @@ -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();
Expand All @@ -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");
});
Expand Down
18 changes: 14 additions & 4 deletions apps/web/components/plain/plainChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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() {
Expand All @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion apps/web/lib/hooks/useIsBookingPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
16 changes: 12 additions & 4 deletions packages/features/shell/user-dropdown/UserDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Comment on lines +60 to +70
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoids closing the PlainContactForm component

});
};

// Prevent rendering dropdown if user isn't available.
Expand Down
Loading