Compare commits

...

1 Commits

Author SHA1 Message Date
Dhruwang
e92e51b030 fix: browser back behaviour 2025-07-30 12:22:40 +05:30
2 changed files with 246 additions and 9 deletions

View File

@@ -3,6 +3,7 @@ import { updateSurveyAction } from "@/modules/survey/editor/actions";
import { SurveyMenuBar } from "@/modules/survey/editor/components/survey-menu-bar";
import { isSurveyValid } from "@/modules/survey/editor/lib/validation";
import { Project } from "@prisma/client";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
@@ -71,6 +72,23 @@ vi.mock("@formbricks/i18n-utils/src/utils", () => ({
getLanguageLabel: vi.fn((code) => `Lang(${code})`),
}));
// Mock Zod schemas to always validate successfully
vi.mock("@formbricks/types/surveys/types", async () => {
const actual = await vi.importActual("@formbricks/types/surveys/types");
return {
...actual,
ZSurvey: {
safeParse: vi.fn(() => ({ success: true })),
},
ZSurveyEndScreenCard: {
parse: vi.fn((ending) => ending),
},
ZSurveyRedirectUrlCard: {
parse: vi.fn((ending) => ending),
},
};
});
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
@@ -88,15 +106,43 @@ vi.mock("lucide-react", async () => {
};
});
// Mock next/navigation
const mockRouter = {
back: vi.fn(),
push: vi.fn(),
refresh: vi.fn(),
};
vi.mock("next/navigation", () => ({
useRouter: () => mockRouter,
}));
const mockSetLocalSurvey = vi.fn();
const mockSetActiveId = vi.fn();
const mockSetInvalidQuestions = vi.fn();
const mockSetIsCautionDialogOpen = vi.fn();
// Mock window.history
const mockHistoryPushState = vi.fn();
Object.defineProperty(window, "history", {
value: {
pushState: mockHistoryPushState,
},
writable: true,
});
// Mock window event listeners
const mockAddEventListener = vi.fn();
const mockRemoveEventListener = vi.fn();
Object.defineProperty(window, "addEventListener", {
value: mockAddEventListener,
writable: true,
});
Object.defineProperty(window, "removeEventListener", {
value: mockRemoveEventListener,
writable: true,
});
const baseSurvey = {
id: "survey-1",
createdAt: new Date(),
@@ -163,6 +209,10 @@ const defaultProps = {
};
describe("SurveyMenuBar", () => {
afterEach(() => {
cleanup();
});
beforeEach(() => {
vi.mocked(updateSurveyAction).mockResolvedValue({ data: { ...baseSurvey, updatedAt: new Date() } }); // Mock successful update
vi.mocked(isSurveyValid).mockReturnValue(true);
@@ -171,10 +221,9 @@ describe("SurveyMenuBar", () => {
} as any);
localStorage.clear();
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
mockHistoryPushState.mockClear();
mockAddEventListener.mockClear();
mockRemoveEventListener.mockClear();
});
test("renders correctly with default props", () => {
@@ -186,6 +235,133 @@ describe("SurveyMenuBar", () => {
expect(screen.getByText("environments.surveys.edit.publish")).toBeInTheDocument();
});
test("sets up browser history state and event listeners on mount", () => {
render(<SurveyMenuBar {...defaultProps} />);
// Check that history state is pushed with inSurveyEditor flag
expect(mockHistoryPushState).toHaveBeenCalledWith({ inSurveyEditor: true }, "");
// Check that event listeners are added
expect(mockAddEventListener).toHaveBeenCalledWith("popstate", expect.any(Function));
expect(mockAddEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
});
test("removes event listeners on unmount", () => {
const { unmount } = render(<SurveyMenuBar {...defaultProps} />);
// Clear the mock to focus on cleanup calls
mockRemoveEventListener.mockClear();
unmount();
// Check that event listeners are removed
expect(mockRemoveEventListener).toHaveBeenCalledWith("popstate", expect.any(Function));
expect(mockRemoveEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
});
test("handles popstate event by calling handleBack", () => {
render(<SurveyMenuBar {...defaultProps} />);
// Get the popstate handler from the addEventListener call
const popstateHandler = mockAddEventListener.mock.calls.find((call) => call[0] === "popstate")?.[1];
expect(popstateHandler).toBeDefined();
// Simulate popstate event
popstateHandler?.(new PopStateEvent("popstate"));
// Should navigate to home since surveys are equal (no changes)
expect(mockRouter.push).toHaveBeenCalledWith("/");
});
test("handles keyboard shortcut (Alt + ArrowLeft) by calling handleBack", () => {
render(<SurveyMenuBar {...defaultProps} />);
// Get the keydown handler from the addEventListener call
const keydownHandler = mockAddEventListener.mock.calls.find((call) => call[0] === "keydown")?.[1];
expect(keydownHandler).toBeDefined();
// Simulate Alt + ArrowLeft keydown event
const keyEvent = new KeyboardEvent("keydown", {
altKey: true,
key: "ArrowLeft",
});
keydownHandler?.(keyEvent);
// Should navigate to home since surveys are equal (no changes)
expect(mockRouter.push).toHaveBeenCalledWith("/");
});
test("handles keyboard shortcut (Cmd + ArrowLeft) by calling handleBack", () => {
render(<SurveyMenuBar {...defaultProps} />);
// Get the keydown handler from the addEventListener call
const keydownHandler = mockAddEventListener.mock.calls.find((call) => call[0] === "keydown")?.[1];
expect(keydownHandler).toBeDefined();
// Simulate Cmd + ArrowLeft keydown event
const keyEvent = new KeyboardEvent("keydown", {
metaKey: true,
key: "ArrowLeft",
});
keydownHandler?.(keyEvent);
// Should navigate to home since surveys are equal (no changes)
expect(mockRouter.push).toHaveBeenCalledWith("/");
});
test("ignores keyboard events without proper modifier keys", () => {
render(<SurveyMenuBar {...defaultProps} />);
// Get the keydown handler from the addEventListener call
const keydownHandler = mockAddEventListener.mock.calls.find((call) => call[0] === "keydown")?.[1];
expect(keydownHandler).toBeDefined();
// Simulate ArrowLeft without modifier keys
const keyEvent = new KeyboardEvent("keydown", {
key: "ArrowLeft",
});
keydownHandler?.(keyEvent);
// Should not navigate
expect(mockRouter.push).not.toHaveBeenCalled();
});
test("ignores keyboard events with wrong key", () => {
render(<SurveyMenuBar {...defaultProps} />);
// Get the keydown handler from the addEventListener call
const keydownHandler = mockAddEventListener.mock.calls.find((call) => call[0] === "keydown")?.[1];
expect(keydownHandler).toBeDefined();
// Simulate Alt + ArrowRight (wrong key)
const keyEvent = new KeyboardEvent("keydown", {
altKey: true,
key: "ArrowRight",
});
keydownHandler?.(keyEvent);
// Should not navigate
expect(mockRouter.push).not.toHaveBeenCalled();
});
test("navigates to home page instead of using router.back when handleBack is called with no changes", async () => {
render(<SurveyMenuBar {...defaultProps} />);
const backButton = screen.getByText("common.back").closest("button");
await userEvent.click(backButton!);
expect(mockRouter.push).toHaveBeenCalledWith("/");
expect(mockRouter.back).not.toHaveBeenCalled();
});
test("updates survey name on input change", async () => {
render(<SurveyMenuBar {...defaultProps} />);
const input = screen.getByTestId("survey-name-input");
@@ -198,11 +374,49 @@ describe("SurveyMenuBar", () => {
render(<SurveyMenuBar {...defaultProps} localSurvey={changedSurvey} />);
const backButton = screen.getByText("common.back").closest("button");
await userEvent.click(backButton!);
expect(mockRouter.back).not.toHaveBeenCalled();
expect(mockRouter.push).not.toHaveBeenCalled();
expect(screen.getByTestId("alert-dialog")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.edit.confirm_survey_changes")).toBeInTheDocument();
});
test("navigates to home page when declining unsaved changes in dialog", async () => {
const changedSurvey = { ...baseSurvey, name: "Changed Name" };
render(<SurveyMenuBar {...defaultProps} localSurvey={changedSurvey} />);
const backButton = screen.getByText("common.back").closest("button");
await userEvent.click(backButton!);
const declineButton = screen.getByText("common.discard");
await userEvent.click(declineButton);
expect(mockRouter.push).toHaveBeenCalledWith("/");
});
test("saves and navigates to home page when confirming unsaved changes in dialog", async () => {
const changedSurvey = { ...baseSurvey, name: "Changed Name" };
// Mock successful save response
vi.mocked(updateSurveyAction).mockResolvedValueOnce({
data: { ...changedSurvey, updatedAt: new Date() },
});
render(<SurveyMenuBar {...defaultProps} localSurvey={changedSurvey} />);
const backButton = screen.getByText("common.back").closest("button");
await userEvent.click(backButton!);
// Get the save button specifically from within the alert dialog
const dialog = screen.getByTestId("alert-dialog");
const confirmButton = dialog.querySelector("button:first-of-type")!;
await userEvent.click(confirmButton);
// Wait for the async operation to complete
await vi.waitFor(
() => {
expect(mockRouter.push).toHaveBeenCalledWith("/");
},
{ timeout: 3000 }
);
});
test("shows caution alert when responseCount > 0", () => {
render(<SurveyMenuBar {...defaultProps} responseCount={5} />);
expect(screen.getByText("environments.surveys.edit.caution_text")).toBeInTheDocument();

View File

@@ -91,6 +91,29 @@ export const SurveyMenuBar = ({
};
}, [localSurvey, survey, t]);
useEffect(() => {
window.history.pushState({ inSurveyEditor: true }, "");
const handlePopstate = (_: PopStateEvent) => {
handleBack();
};
const handleKeyDown = (e: KeyboardEvent) => {
const isBackShortcut = (e.altKey || e.metaKey) && e.key === "ArrowLeft";
if (isBackShortcut) {
handleBack();
}
};
window.addEventListener("popstate", handlePopstate);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("popstate", handlePopstate);
window.removeEventListener("keydown", handleKeyDown);
};
}, []);
const clearSurveyLocalStorage = () => {
if (typeof localStorage !== "undefined") {
localStorage.removeItem(`${localSurvey.id}-columnOrder`);
@@ -121,7 +144,7 @@ export const SurveyMenuBar = ({
if (!isEqual(localSurveyRest, surveyRest)) {
setConfirmDialogOpen(true);
} else {
router.back();
router.push("/");
}
};
@@ -247,7 +270,6 @@ export const SurveyMenuBar = ({
if (updatedSurveyResponse?.data) {
setLocalSurvey(updatedSurveyResponse.data);
toast.success(t("environments.surveys.edit.changes_saved"));
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updatedSurveyResponse);
toast.error(errorMessage);
@@ -266,7 +288,8 @@ export const SurveyMenuBar = ({
const handleSaveAndGoBack = async () => {
const isSurveySaved = await handleSurveySave();
if (isSurveySaved) {
router.back();
setConfirmDialogOpen(false);
router.push("/");
}
};
@@ -395,7 +418,7 @@ export const SurveyMenuBar = ({
declineBtnVariant="destructive"
onDecline={() => {
setConfirmDialogOpen(false);
router.back();
router.push("/");
}}
onConfirm={handleSaveAndGoBack}
/>