diff --git a/app/client/cypress/support/Pages/AppSettings/AppSettings.ts b/app/client/cypress/support/Pages/AppSettings/AppSettings.ts
index f761bc63f5..7a1250699f 100644
--- a/app/client/cypress/support/Pages/AppSettings/AppSettings.ts
+++ b/app/client/cypress/support/Pages/AppSettings/AppSettings.ts
@@ -59,7 +59,7 @@ export class AppSettings {
".t--app-viewer-navigation-top-inline-more-dropdown-item",
_scrollArrows: ".scroll-arrows",
_getActivePage: (pageName: string) =>
- `//span[contains(text(),"${pageName}")]//ancestor::a[contains(@class,'is-active')]`,
+ `//span[contains(text(),"${pageName}")]//ancestor::div[contains(@class,'is-active')]`,
_importBtn: "[data-testid='t--app-setting-import-btn']",
};
diff --git a/app/client/src/actions/pageActions.tsx b/app/client/src/actions/pageActions.tsx
index 1d1abbcb9d..47aa7f95dd 100644
--- a/app/client/src/actions/pageActions.tsx
+++ b/app/client/src/actions/pageActions.tsx
@@ -30,6 +30,7 @@ import type { PACKAGE_PULL_STATUS } from "ee/constants/ModuleConstants";
import type { ApiResponse } from "api/ApiResponses";
import type { EvaluationReduxAction } from "./EvaluationReduxActionTypes";
import { appsmithTelemetry } from "instrumentation";
+import type { NavigateToAnotherPagePayload } from "sagas/ActionExecution/NavigateActionSaga/types";
export interface FetchPageListPayload {
applicationId: string;
@@ -696,3 +697,10 @@ export const setupPublishedPage = (
pageWithMigratedDsl,
},
});
+
+export const navigateToAnotherPage = (
+ payload: NavigateToAnotherPagePayload,
+) => ({
+ type: ReduxActionTypes.NAVIGATE_TO_ANOTHER_PAGE,
+ payload,
+});
diff --git a/app/client/src/ce/constants/ReduxActionConstants.tsx b/app/client/src/ce/constants/ReduxActionConstants.tsx
index a91ae96238..028a3dc491 100644
--- a/app/client/src/ce/constants/ReduxActionConstants.tsx
+++ b/app/client/src/ce/constants/ReduxActionConstants.tsx
@@ -620,6 +620,7 @@ const PageActionTypes = {
RESET_PAGE_LIST: "RESET_PAGE_LIST",
SET_ONLOAD_ACTION_EXECUTED: "SET_ONLOAD_ACTION_EXECUTED",
EXECUTE_REACTIVE_QUERIES: "EXECUTE_REACTIVE_QUERIES",
+ NAVIGATE_TO_ANOTHER_PAGE: "NAVIGATE_TO_ANOTHER_PAGE",
};
const PageActionErrorTypes = {
@@ -731,6 +732,9 @@ const ActionExecutionTypes = {
CANCEL_ACTION_MODAL: "CANCEL_ACTION_MODAL",
CONFIRM_ACTION_MODAL: "CONFIRM_ACTION_MODAL",
EXECUTE_PAGE_LOAD_ACTIONS: "EXECUTE_PAGE_LOAD_ACTIONS",
+ EXECUTE_PAGE_UNLOAD_ACTIONS: "EXECUTE_PAGE_UNLOAD_ACTIONS",
+ EXECUTE_PAGE_UNLOAD_ACTIONS_SUCCESS: "EXECUTE_PAGE_UNLOAD_ACTIONS_SUCCESS",
+ EXECUTE_PAGE_UNLOAD_ACTIONS_ERROR: "EXECUTE_PAGE_UNLOAD_ACTIONS_ERROR",
EXECUTE_PLUGIN_ACTION_REQUEST: "EXECUTE_PLUGIN_ACTION_REQUEST",
EXECUTE_PLUGIN_ACTION_SUCCESS: "EXECUTE_PLUGIN_ACTION_SUCCESS",
SET_ACTION_RESPONSE_DISPLAY_FORMAT: "SET_ACTION_RESPONSE_DISPLAY_FORMAT",
diff --git a/app/client/src/entities/Action/index.ts b/app/client/src/entities/Action/index.ts
index 80ce9655f2..54934b3044 100644
--- a/app/client/src/entities/Action/index.ts
+++ b/app/client/src/entities/Action/index.ts
@@ -39,6 +39,7 @@ export enum ActionExecutionContext {
PAGE_LOAD = "PAGE_LOAD",
EVALUATION_ACTION_TRIGGER = "EVALUATION_ACTION_TRIGGER",
REFRESH_ACTIONS_ON_ENV_CHANGE = "REFRESH_ACTIONS_ON_ENV_CHANGE",
+ PAGE_UNLOAD = "PAGE_UNLOAD",
}
export interface KeyValuePair {
diff --git a/app/client/src/pages/AppViewer/Navigation/components/MenuItem.styled.tsx b/app/client/src/pages/AppViewer/Navigation/components/MenuItem.styled.tsx
index 09a3347b53..ba92c53693 100644
--- a/app/client/src/pages/AppViewer/Navigation/components/MenuItem.styled.tsx
+++ b/app/client/src/pages/AppViewer/Navigation/components/MenuItem.styled.tsx
@@ -5,10 +5,9 @@ import {
getMenuItemBackgroundColorWhenActive,
getMenuItemTextColor,
} from "pages/AppViewer/utils";
-import { NavLink } from "react-router-dom";
import styled from "styled-components";
-export const StyledMenuItem = styled(NavLink)<{
+export const StyledMenuItem = styled.div<{
borderRadius: string;
primaryColor: string;
navColorStyle: NavigationSetting["colorStyle"];
diff --git a/app/client/src/pages/AppViewer/Navigation/components/MenuItem/MenuItem.test.tsx b/app/client/src/pages/AppViewer/Navigation/components/MenuItem/MenuItem.test.tsx
new file mode 100644
index 0000000000..0e60a94549
--- /dev/null
+++ b/app/client/src/pages/AppViewer/Navigation/components/MenuItem/MenuItem.test.tsx
@@ -0,0 +1,416 @@
+import "@testing-library/jest-dom";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { navigateToAnotherPage } from "actions/pageActions";
+import {
+ NAVIGATION_SETTINGS,
+ defaultNavigationSetting,
+} from "constants/AppConstants";
+import * as RouteBuilder from "ee/RouteBuilder";
+import { APP_MODE } from "entities/App";
+import type { AppTheme } from "entities/AppTheming";
+import type { ApplicationPayload } from "entities/Application";
+import type { Page } from "entities/Page";
+import React from "react";
+import { Provider, type DefaultRootState } from "react-redux";
+import configureStore from "redux-mock-store";
+import { NavigationMethod } from "utils/history";
+import type { MenuItemProps } from "./types";
+import MenuItem from ".";
+
+const mockStore = configureStore([]);
+
+jest.mock("react-router-dom", () => ({
+ ...jest.requireActual("react-router-dom"),
+ useLocation: jest.fn(),
+}));
+
+jest.mock("../MenuItem.styled", () => ({
+ StyledMenuItem: jest.fn(({ children, ...props }) => (
+
+ {children}
+
+ )),
+}));
+
+jest.mock("../MenuText", () =>
+ jest.fn((props) => (
+
+ {props.name}
+
+ )),
+);
+
+jest.mock("actions/pageActions", () => ({
+ navigateToAnotherPage: jest.fn((payload) => ({
+ type: "NAVIGATE_TO_PAGE", // Mock action type
+ payload,
+ })),
+}));
+
+// Mock the selectors and utilities
+jest.mock("ee/selectors/applicationSelectors", () => ({
+ getAppMode: jest.fn(),
+ getCurrentApplication: jest.fn(),
+}));
+
+jest.mock("selectors/appThemingSelectors", () => ({
+ getSelectedAppTheme: jest.fn(),
+}));
+
+jest.mock("pages/Editor/utils", () => ({
+ useHref: jest.fn(),
+}));
+
+jest.mock("ee/RouteBuilder", () => ({
+ viewerURL: jest.fn(),
+ builderURL: jest.fn(),
+}));
+
+// Mock the useNavigateToAnotherPage hook
+jest.mock("../../hooks/useNavigateToAnotherPage", () => ({
+ __esModule: true,
+ default: jest.fn(() => jest.fn()),
+}));
+
+const mockPage: Page = {
+ pageId: "page1_id",
+ pageName: "Test Page 1",
+ basePageId: "base_page1_id",
+ isDefault: true,
+ isHidden: false,
+ slug: "test-page-1",
+};
+const mockQuery = "param=value";
+
+describe("MenuItem Component", () => {
+ const mockStoreInstance = mockStore();
+
+ let store: typeof mockStoreInstance;
+
+ const renderComponent = (
+ props: Partial = {},
+ initialState: Partial = {},
+ currentPathname = "/app/page1_id/section",
+ appMode?: APP_MODE,
+ ) => {
+ const testState = getTestState(initialState, appMode);
+
+ store = mockStore(testState);
+
+ // Setup mocks
+ /* eslint-disable @typescript-eslint/no-var-requires */
+ const { useHref } = require("pages/Editor/utils");
+ const { getAppMode } = require("ee/selectors/applicationSelectors");
+ const { getSelectedAppTheme } = require("selectors/appThemingSelectors");
+ const { builderURL, viewerURL } = require("ee/RouteBuilder");
+ /* eslint-enable @typescript-eslint/no-var-requires */
+
+ useHref.mockImplementation(
+ (
+ urlBuilder:
+ | typeof RouteBuilder.viewerURL
+ | typeof RouteBuilder.builderURL,
+ params: { basePageId: string },
+ ) => {
+ if (urlBuilder === RouteBuilder.viewerURL) {
+ return `/viewer/${params.basePageId}`;
+ }
+
+ return `/builder/${params.basePageId}`;
+ },
+ );
+
+ getAppMode.mockImplementation(() => testState.entities.app.mode);
+
+ getSelectedAppTheme.mockImplementation(
+ () => testState.ui.appTheming.selectedTheme,
+ );
+
+ viewerURL.mockImplementation(
+ (params: { basePageId: string }) => `/viewer/${params.basePageId}`,
+ );
+ builderURL.mockImplementation(
+ (params: { basePageId: string }) => `/builder/${params.basePageId}`,
+ );
+
+ /* eslint-disable @typescript-eslint/no-var-requires */
+ (require("react-router-dom").useLocation as jest.Mock).mockReturnValue({
+ pathname: currentPathname,
+ });
+
+ // Mock the useNavigateToAnotherPage hook
+ const useNavigateToAnotherPageMock =
+ require("../../hooks/useNavigateToAnotherPage").default;
+
+ useNavigateToAnotherPageMock.mockImplementation(() => {
+ return () => {
+ const pageURL =
+ testState.entities.app.mode === APP_MODE.PUBLISHED
+ ? `/viewer/${mockPage.basePageId}`
+ : `/builder/${mockPage.basePageId}`;
+
+ store.dispatch(
+ navigateToAnotherPage({
+ pageURL,
+ query: mockQuery,
+ state: { invokedBy: NavigationMethod.AppNavigation },
+ }),
+ );
+ };
+ });
+
+ return render(
+
+
+ ,
+ );
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("renders the page name", () => {
+ renderComponent();
+ expect(screen.getByText("Test Page 1")).toBeInTheDocument();
+ expect(screen.getByTestId("menu-text")).toHaveAttribute(
+ "name",
+ "Test Page 1",
+ );
+ });
+
+ it("is marked active if current path matches pageId", () => {
+ renderComponent(undefined, undefined, "/app/some-app/page1_id/details");
+ expect(screen.getByTestId("styled-menu-item")).toHaveClass("is-active");
+ });
+
+ it("is not marked active if current path does not match pageId", () => {
+ renderComponent(undefined, undefined, "/app/some-app/page2_id/details");
+ expect(screen.getByTestId("styled-menu-item")).not.toHaveClass("is-active");
+ });
+
+ it("dispatches navigateToAnotherPage on click in PUBLISHED mode", () => {
+ renderComponent(undefined, {}, "/app/page1_id/section", APP_MODE.PUBLISHED);
+
+ fireEvent.click(screen.getByTestId("styled-menu-item"));
+
+ expect(navigateToAnotherPage).toHaveBeenCalledWith({
+ pageURL: `/viewer/${mockPage.basePageId}`,
+ query: mockQuery,
+ state: { invokedBy: NavigationMethod.AppNavigation },
+ });
+ expect(store.getActions()).toContainEqual(
+ expect.objectContaining({
+ type: "NAVIGATE_TO_PAGE",
+ payload: {
+ pageURL: `/viewer/${mockPage.basePageId}`,
+ query: mockQuery,
+ state: { invokedBy: NavigationMethod.AppNavigation },
+ },
+ }),
+ );
+ });
+
+ it("dispatches navigateToAnotherPage on click in EDIT mode", () => {
+ renderComponent(undefined, {}, "/app/page1_id/section", APP_MODE.EDIT);
+
+ fireEvent.click(screen.getByTestId("styled-menu-item"));
+
+ expect(navigateToAnotherPage).toHaveBeenCalledWith({
+ pageURL: `/builder/${mockPage.basePageId}`,
+ query: mockQuery,
+ state: { invokedBy: NavigationMethod.AppNavigation },
+ });
+ expect(store.getActions()).toContainEqual(
+ expect.objectContaining({
+ type: "NAVIGATE_TO_PAGE",
+ payload: {
+ pageURL: `/builder/${mockPage.basePageId}`,
+ query: mockQuery,
+ state: { invokedBy: NavigationMethod.AppNavigation },
+ },
+ }),
+ );
+ });
+
+ it("passes correct props to StyledMenuItem and MenuText", () => {
+ renderComponent({
+ navigationSetting: {
+ ...defaultNavigationSetting,
+ colorStyle: NAVIGATION_SETTINGS.COLOR_STYLE.LIGHT,
+ },
+ });
+
+ const styledMenuItem = screen.getByTestId("styled-menu-item");
+
+ expect(styledMenuItem).toHaveAttribute("primarycolor", "blue");
+ expect(styledMenuItem).toHaveAttribute("borderradius", "4px");
+
+ const menuText = screen.getByTestId("menu-text");
+
+ expect(menuText).toHaveAttribute("primarycolor", "blue");
+ });
+
+ it("uses default navigation color style if not provided", () => {
+ renderComponent({ navigationSetting: defaultNavigationSetting });
+ expect(screen.getByTestId("styled-menu-item")).toHaveAttribute(
+ "navcolorstyle",
+ NAVIGATION_SETTINGS.COLOR_STYLE.LIGHT, // Default
+ );
+ });
+});
+
+const mockSelectedTheme: AppTheme = {
+ id: "theme1",
+ name: "Test Theme",
+ displayName: "Test Theme",
+ created_by: "user1",
+ created_at: "2023-01-01",
+ isSystemTheme: false,
+ config: {
+ order: 1,
+ colors: {
+ primaryColor: "blue",
+ backgroundColor: "white",
+ },
+ borderRadius: {},
+ boxShadow: {},
+ fontFamily: {},
+ },
+ stylesheet: {},
+ properties: {
+ colors: { primaryColor: "blue", backgroundColor: "white" },
+ borderRadius: { appBorderRadius: "4px" },
+ boxShadow: {},
+ fontFamily: {},
+ },
+};
+
+const mockApplication: ApplicationPayload = {
+ id: "app1",
+ baseId: "base_app1",
+ name: "Test App",
+ workspaceId: "workspace1",
+ defaultPageId: "page1_id",
+ defaultBasePageId: "base_page1_id",
+ appIsExample: false,
+ slug: "test-app",
+ pages: [
+ {
+ id: "page1_id",
+ baseId: "base_page1_id",
+ name: "Test Page 1",
+ isDefault: true,
+ slug: "test-page-1",
+ },
+ ],
+ applicationVersion: 1,
+};
+
+const getTestState = (
+ overrides: Partial = {},
+ appMode?: APP_MODE,
+): DefaultRootState => {
+ return {
+ ui: {
+ appTheming: {
+ selectedTheme: mockSelectedTheme,
+ isSaving: false,
+ isChanging: false,
+ stack: [],
+ themes: [],
+ themesLoading: false,
+ selectedThemeLoading: false,
+ isBetaCardShown: false,
+ },
+ applications: {
+ currentApplication: mockApplication,
+ applicationList: [],
+ searchKeyword: undefined,
+ isSavingAppName: false,
+ isErrorSavingAppName: false,
+ isFetchingApplication: false,
+ isChangingViewAccess: false,
+ creatingApplication: {},
+ createApplicationError: undefined,
+ deletingApplication: false,
+ forkingApplication: false,
+ importingApplication: false,
+ importedApplication: undefined,
+ isImportAppModalOpen: false,
+ workspaceIdForImport: undefined,
+ pageIdForImport: "",
+ isDatasourceConfigForImportFetched: undefined,
+ isAppSidebarPinned: false,
+ isSavingNavigationSetting: false,
+ isErrorSavingNavigationSetting: false,
+ isUploadingNavigationLogo: false,
+ isDeletingNavigationLogo: false,
+ loadingStates: {
+ isFetchingAllRoles: false,
+ isFetchingAllUsers: false,
+ },
+ currentApplicationIdForCreateNewApp: undefined,
+ partialImportExport: {
+ isExportModalOpen: false,
+ isExporting: false,
+ isExportDone: false,
+ isImportModalOpen: false,
+ isImporting: false,
+ isImportDone: false,
+ },
+ currentPluginIdForCreateNewApp: undefined,
+ },
+ ...overrides.ui,
+ } as DefaultRootState["ui"],
+ entities: {
+ app: {
+ mode: appMode || overrides.entities?.app?.mode || APP_MODE.PUBLISHED,
+ user: { username: "", email: "", id: "" },
+ URL: {
+ queryParams: {},
+ protocol: "",
+ host: "",
+ hostname: "",
+ port: "",
+ pathname: "",
+ hash: "",
+ fullPath: "",
+ },
+ store: {},
+ geolocation: { canBeRequested: false },
+ workflows: {},
+ },
+ pageList: {
+ pages: [mockPage],
+ baseApplicationId: "base_app1",
+ applicationId: "app1",
+ currentBasePageId: "base_page1_id",
+ currentPageId: "page1_id",
+ defaultBasePageId: "base_page1_id",
+ defaultPageId: "page1_id",
+ loading: {},
+ },
+ canvasWidgets: {},
+ metaWidgets: {},
+ actions: [],
+ jsActions: [],
+ /* eslint-disable @typescript-eslint/no-explicit-any */
+ plugins: {} as any,
+ /* eslint-disable @typescript-eslint/no-explicit-any */
+ datasources: {} as any,
+ meta: {},
+ moduleInstanceEntities: {},
+ ...overrides.entities,
+ } as DefaultRootState["entities"],
+ ...overrides,
+ } as DefaultRootState;
+};
diff --git a/app/client/src/pages/AppViewer/Navigation/components/MenuItem.tsx b/app/client/src/pages/AppViewer/Navigation/components/MenuItem/index.tsx
similarity index 51%
rename from app/client/src/pages/AppViewer/Navigation/components/MenuItem.tsx
rename to app/client/src/pages/AppViewer/Navigation/components/MenuItem/index.tsx
index a481f950c6..25b2bfc827 100644
--- a/app/client/src/pages/AppViewer/Navigation/components/MenuItem.tsx
+++ b/app/client/src/pages/AppViewer/Navigation/components/MenuItem/index.tsx
@@ -1,31 +1,23 @@
-import React from "react";
-import type { Page } from "entities/Page";
-import type { NavigationSetting } from "constants/AppConstants";
import { NAVIGATION_SETTINGS } from "constants/AppConstants";
-import { APP_MODE } from "entities/App";
import { get } from "lodash";
-import { useHref } from "pages/Editor/utils";
+import React, { useMemo } from "react";
import { useSelector } from "react-redux";
-import { builderURL, viewerURL } from "ee/RouteBuilder";
-import { getAppMode } from "ee/selectors/applicationSelectors";
+import { useLocation } from "react-router-dom";
import { getSelectedAppTheme } from "selectors/appThemingSelectors";
-import { trimQueryString } from "utils/helpers";
-import MenuText from "./MenuText";
-import { StyledMenuItem } from "./MenuItem.styled";
import { NavigationMethod } from "utils/history";
-
-interface MenuItemProps {
- page: Page;
- query: string;
- navigationSetting?: NavigationSetting;
-}
+import useNavigateToAnotherPage from "../../hooks/useNavigateToAnotherPage";
+import { StyledMenuItem } from "../MenuItem.styled";
+import MenuText from "../MenuText";
+import type { MenuItemProps } from "./types";
const MenuItem = ({ navigationSetting, page, query }: MenuItemProps) => {
- const appMode = useSelector(getAppMode);
- const pageURL = useHref(
- appMode === APP_MODE.PUBLISHED ? viewerURL : builderURL,
- { basePageId: page.basePageId },
- );
+ const location = useLocation();
+
+ const navigateToAnotherPage = useNavigateToAnotherPage({
+ basePageId: page.basePageId,
+ query,
+ state: { invokedBy: NavigationMethod.AppNavigation },
+ });
const selectedTheme = useSelector(getSelectedAppTheme);
const navColorStyle =
navigationSetting?.colorStyle || NAVIGATION_SETTINGS.COLOR_STYLE.LIGHT;
@@ -40,18 +32,22 @@ const MenuItem = ({ navigationSetting, page, query }: MenuItemProps) => {
"inherit",
);
+ const isActive = useMemo(
+ () => location.pathname.indexOf(page.pageId) > -1,
+ [location, page.pageId],
+ );
+
+ const handleClick = () => {
+ navigateToAnotherPage();
+ };
+
return (
{
+ const location = useLocation();
+ const navigateToAnotherPage = useNavigateToAnotherPage({
+ basePageId: page.basePageId,
+ query,
+ state: { invokedBy: NavigationMethod.AppNavigation },
+ });
+ const handleClick = () => {
+ navigateToAnotherPage();
+ };
+ const isActive = useMemo(
+ () => location.pathname.indexOf(page.pageId) > -1,
+ [location, page.pageId],
+ );
+
+ return (
+
+
+
+ );
+};
+
+export default MoreDropDownButtonItem;
diff --git a/app/client/src/pages/AppViewer/Navigation/components/MoreDropdownButton.styled.tsx b/app/client/src/pages/AppViewer/Navigation/components/MoreDropdownButton.styled.tsx
index 6a1c49e1b4..e6a022df43 100644
--- a/app/client/src/pages/AppViewer/Navigation/components/MoreDropdownButton.styled.tsx
+++ b/app/client/src/pages/AppViewer/Navigation/components/MoreDropdownButton.styled.tsx
@@ -7,7 +7,6 @@ import {
} from "pages/AppViewer/utils";
import styled from "styled-components";
import Button from "pages/AppViewer/AppViewerButton";
-import { NavLink } from "react-router-dom";
import { Menu } from "@appsmith/ads-old";
export const StyleMoreDropdownButton = styled(Button)<{
@@ -56,7 +55,7 @@ export const StyledMenuDropdownContainer = styled(Menu)<{
}
`;
-export const StyledMenuItemInDropdown = styled(NavLink)<{
+export const StyledMenuItemInDropdown = styled.div<{
borderRadius: string;
primaryColor: string;
}>`
diff --git a/app/client/src/pages/AppViewer/Navigation/components/MoreDropdownButton.tsx b/app/client/src/pages/AppViewer/Navigation/components/MoreDropdownButton.tsx
index a68575bc55..dfd14ab2b2 100644
--- a/app/client/src/pages/AppViewer/Navigation/components/MoreDropdownButton.tsx
+++ b/app/client/src/pages/AppViewer/Navigation/components/MoreDropdownButton.tsx
@@ -1,22 +1,17 @@
-import React, { useState } from "react";
+import { Icon } from "@appsmith/ads";
import type { NavigationSetting } from "constants/AppConstants";
import { NAVIGATION_SETTINGS } from "constants/AppConstants";
+import type { Page } from "entities/Page";
import { get } from "lodash";
+import React, { useState } from "react";
import { useSelector } from "react-redux";
import { getSelectedAppTheme } from "selectors/appThemingSelectors";
-import { Icon } from "@appsmith/ads";
import MenuText from "./MenuText";
import {
StyledMenuDropdownContainer,
- StyledMenuItemInDropdown,
StyleMoreDropdownButton,
} from "./MoreDropdownButton.styled";
-import type { Page } from "entities/Page";
-import { getAppMode } from "ee/selectors/applicationSelectors";
-import { APP_MODE } from "entities/App";
-import { builderURL, viewerURL } from "ee/RouteBuilder";
-import { trimQueryString } from "utils/helpers";
-import { NavigationMethod } from "utils/history";
+import MoreDropDownButtonItem from "./MoreDropDownButtonItem";
interface MoreDropdownButtonProps {
navigationSetting?: NavigationSetting;
@@ -42,7 +37,6 @@ const MoreDropdownButton = ({
"properties.borderRadius.appBorderRadius",
"inherit",
);
- const appMode = useSelector(getAppMode);
const [isOpen, setIsOpen] = useState(false);
const TargetButton = (
@@ -83,34 +77,15 @@ const MoreDropdownButton = ({
target={TargetButton}
>
{pages.map((page) => {
- const pageURL =
- appMode === APP_MODE.PUBLISHED
- ? viewerURL({
- basePageId: page.basePageId,
- })
- : builderURL({
- basePageId: page.basePageId,
- });
-
return (
-
-
-
+ query={query}
+ />
);
})}
diff --git a/app/client/src/pages/AppViewer/Navigation/hooks/useNavigateToAnotherPage.tsx b/app/client/src/pages/AppViewer/Navigation/hooks/useNavigateToAnotherPage.tsx
new file mode 100644
index 0000000000..c6370c290d
--- /dev/null
+++ b/app/client/src/pages/AppViewer/Navigation/hooks/useNavigateToAnotherPage.tsx
@@ -0,0 +1,38 @@
+import { useDispatch } from "react-redux";
+import { navigateToAnotherPage } from "actions/pageActions";
+import type { AppsmithLocationState } from "utils/history";
+import { APP_MODE } from "entities/App";
+import { useHref } from "pages/Editor/utils";
+import { builderURL, viewerURL } from "ee/RouteBuilder";
+import { getAppMode } from "ee/selectors/applicationSelectors";
+import { useSelector } from "react-redux";
+import { trimQueryString } from "utils/helpers";
+
+const useNavigateToAnotherPage = ({
+ basePageId,
+ query,
+ state,
+}: {
+ basePageId: string;
+ query: string;
+ state: AppsmithLocationState;
+}) => {
+ const appMode = useSelector(getAppMode);
+ const dispatch = useDispatch();
+ const pageURL = useHref(
+ appMode === APP_MODE.PUBLISHED ? viewerURL : builderURL,
+ { basePageId: basePageId },
+ );
+
+ return () => {
+ dispatch(
+ navigateToAnotherPage({
+ pageURL: trimQueryString(pageURL),
+ query,
+ state,
+ }),
+ );
+ };
+};
+
+export default useNavigateToAnotherPage;
diff --git a/app/client/src/sagas/ActionExecution/NavigateActionSaga.ts b/app/client/src/sagas/ActionExecution/NavigateActionSaga/index.ts
similarity index 70%
rename from app/client/src/sagas/ActionExecution/NavigateActionSaga.ts
rename to app/client/src/sagas/ActionExecution/NavigateActionSaga/index.ts
index 7c4df1d906..63b79f5704 100644
--- a/app/client/src/sagas/ActionExecution/NavigateActionSaga.ts
+++ b/app/client/src/sagas/ActionExecution/NavigateActionSaga/index.ts
@@ -1,26 +1,24 @@
-import { call, put, select } from "redux-saga/effects";
-import { getCurrentPageId, getPageList } from "selectors/editorSelectors";
-import _ from "lodash";
+import type { ReduxAction } from "actions/ReduxActionTypes";
import { ReduxActionTypes } from "ee/constants/ReduxActionConstants";
-import type { Page } from "entities/Page";
-import AnalyticsUtil from "ee/utils/AnalyticsUtil";
-import { getAppMode } from "ee/selectors/applicationSelectors";
-import { APP_MODE } from "entities/App";
import { getQueryStringfromObject } from "ee/entities/URLRedirect/URLAssembly";
-import history from "utils/history";
-import { setDataUrl } from "ee/sagas/PageSagas";
-import AppsmithConsole from "utils/AppsmithConsole";
import { builderURL, viewerURL } from "ee/RouteBuilder";
-import { TriggerFailureError } from "./errorUtils";
+import { setDataUrl } from "ee/sagas/PageSagas";
+import { getAppMode } from "ee/selectors/applicationSelectors";
+import AnalyticsUtil from "ee/utils/AnalyticsUtil";
+import { APP_MODE } from "entities/App";
+import type { SourceEntity } from "entities/AppsmithConsole";
+import type { Page } from "entities/Page";
+import _ from "lodash";
+import { call, put, select, take } from "redux-saga/effects";
+import { getCurrentPageId, getPageList } from "selectors/editorSelectors";
+import AppsmithConsole from "utils/AppsmithConsole";
+import history, { type AppsmithLocationState } from "utils/history";
import { isValidURL, matchesURLPattern } from "utils/URLUtils";
import type { TNavigateToDescription } from "workers/Evaluation/fns/navigateTo";
import { NavigationTargetType } from "workers/Evaluation/fns/navigateTo";
-import type { SourceEntity } from "entities/AppsmithConsole";
-
-export enum NavigationTargetType_Dep {
- SAME_WINDOW = "SAME_WINDOW",
- NEW_WINDOW = "NEW_WINDOW",
-}
+import { TriggerFailureError } from "../errorUtils";
+import type { NavigateToAnotherPagePayload } from "./types";
+import type { LocationDescriptor, Path } from "history";
const isValidPageName = (
pageNameOrUrl: string,
@@ -48,19 +46,14 @@ export default function* navigateActionSaga(
});
const appMode: APP_MODE = yield select(getAppMode);
- const path =
- appMode === APP_MODE.EDIT
- ? builderURL({
- basePageId: page.basePageId,
- params,
- })
- : viewerURL({
- basePageId: page.basePageId,
- params,
- });
+ const urlBuilder = appMode === APP_MODE.EDIT ? builderURL : viewerURL;
+ const path = urlBuilder({
+ basePageId: page.basePageId,
+ params,
+ });
if (target === NavigationTargetType.SAME_WINDOW) {
- history.push(path);
+ yield call(pushToHistory, path);
if (currentPageId === page.pageId) {
yield call(setDataUrl);
@@ -114,3 +107,34 @@ export default function* navigateActionSaga(
});
}
}
+
+export function* navigateToAnyPageInApplication(
+ action: ReduxAction,
+) {
+ yield call(pushToHistory, action.payload);
+}
+
+export function* pushToHistory(payload: NavigateToAnotherPagePayload | Path) {
+ yield put({
+ type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS,
+ });
+
+ yield take([
+ ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS_SUCCESS,
+ ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS_ERROR,
+ ]);
+
+ if (typeof payload === "string") {
+ history.push(payload);
+
+ return;
+ }
+
+ const historyState: LocationDescriptor = {
+ pathname: payload.pageURL,
+ search: payload.query,
+ state: payload.state,
+ };
+
+ history.push(historyState);
+}
diff --git a/app/client/src/sagas/ActionExecution/NavigateActionSaga/types.ts b/app/client/src/sagas/ActionExecution/NavigateActionSaga/types.ts
new file mode 100644
index 0000000000..79cb5e01a8
--- /dev/null
+++ b/app/client/src/sagas/ActionExecution/NavigateActionSaga/types.ts
@@ -0,0 +1,7 @@
+import type { AppsmithLocationState } from "utils/history";
+
+export interface NavigateToAnotherPagePayload {
+ pageURL: string;
+ query: string;
+ state: AppsmithLocationState;
+}
diff --git a/app/client/src/sagas/ActionExecution/PluginActionSaga.ts b/app/client/src/sagas/ActionExecution/PluginActionSaga.ts
index 7b0e881871..b9856cf37b 100644
--- a/app/client/src/sagas/ActionExecution/PluginActionSaga.ts
+++ b/app/client/src/sagas/ActionExecution/PluginActionSaga.ts
@@ -88,6 +88,7 @@ import {
getIsSavingEntity,
getLayoutOnLoadActions,
getLayoutOnLoadIssues,
+ getLayoutOnUnloadActions,
} from "selectors/editorSelectors";
import log from "loglevel";
import { EMPTY_RESPONSE } from "components/editorComponents/emptyResponse";
@@ -1653,6 +1654,82 @@ function* softRefreshActionsSaga() {
yield put({ type: ReduxActionTypes.SWITCH_ENVIRONMENT_SUCCESS });
}
+// This gets called for "onPageUnload" JS actions
+function* executeOnPageUnloadJSAction(pageAction: Action) {
+ const collectionId: string = pageAction.collectionId || "";
+ const pageId: string | undefined = yield select(getCurrentPageId);
+
+ if (!collectionId) return;
+
+ const collection: JSCollection = yield select(
+ getJSCollectionFromAllEntities,
+ collectionId,
+ );
+
+ if (!collection) {
+ appsmithTelemetry.captureException(
+ new Error(
+ "Collection present in layoutOnUnloadActions but no collection exists ",
+ ),
+ {
+ errorName: "MissingJSCollection",
+ extra: {
+ collectionId,
+ actionId: pageAction.id,
+ pageId,
+ },
+ },
+ );
+
+ return;
+ }
+
+ const jsAction = collection.actions.find(
+ (action: JSAction) => action.id === pageAction.id,
+ );
+
+ if (!!jsAction) {
+ yield call(handleExecuteJSFunctionSaga, {
+ action: jsAction,
+ collection,
+ isExecuteJSFunc: true,
+ onPageLoad: false,
+ });
+ }
+}
+
+export function* executePageUnloadActionsSaga() {
+ const span = startRootSpan("executePageUnloadActionsSaga");
+
+ try {
+ const pageActions: Action[] = yield select(getLayoutOnUnloadActions);
+ const actionCount = pageActions.length;
+
+ setAttributesToSpan(span, { numActions: actionCount });
+
+ // Execute unload actions in parallel batches
+ yield all(
+ pageActions.map((action) => call(executeOnPageUnloadJSAction, action)),
+ );
+
+ // Publish success event after all actions are executed
+ yield put({
+ type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS_SUCCESS,
+ });
+ } catch (e) {
+ log.error(e);
+ AppsmithConsole.error({
+ text: "Failed to execute actions during page unload",
+ });
+ // Publish error event if something goes wrong
+ yield put({
+ type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS_ERROR,
+ });
+ }
+ endSpan(span);
+}
+// End of Selection
+
export function* watchPluginActionExecutionSagas() {
yield all([
takeLatest(ReduxActionTypes.RUN_ACTION_REQUEST, runActionSaga),
@@ -1665,5 +1742,9 @@ export function* watchPluginActionExecutionSagas() {
executePageLoadActionsSaga,
),
takeLatest(ReduxActionTypes.PLUGIN_SOFT_REFRESH, softRefreshActionsSaga),
+ takeLatest(
+ ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS,
+ executePageUnloadActionsSaga,
+ ),
]);
}
diff --git a/app/client/src/sagas/NavigationSagas.ts b/app/client/src/sagas/NavigationSagas.ts
index 6234182cab..d08a70a225 100644
--- a/app/client/src/sagas/NavigationSagas.ts
+++ b/app/client/src/sagas/NavigationSagas.ts
@@ -5,6 +5,7 @@ import EntityNavigationFactory from "pages/Editor/EntityNavigation/factory";
import type { EntityInfo } from "pages/Editor/EntityNavigation/types";
import log from "loglevel";
import type PaneNavigation from "pages/Editor/EntityNavigation/PaneNavigation";
+import { navigateToAnyPageInApplication } from "./ActionExecution/NavigateActionSaga";
function* navigateEntitySaga(action: ReduxAction) {
try {
@@ -23,5 +24,9 @@ function* navigateEntitySaga(action: ReduxAction) {
export default function* navigationSagas() {
yield all([
takeEvery(ReduxActionTypes.NAVIGATE_TO_ENTITY, navigateEntitySaga),
+ takeEvery(
+ ReduxActionTypes.NAVIGATE_TO_ANOTHER_PAGE,
+ navigateToAnyPageInApplication,
+ ),
]);
}
diff --git a/app/client/src/sagas/__tests__/NavigateActionSaga.test.ts b/app/client/src/sagas/__tests__/NavigateActionSaga.test.ts
new file mode 100644
index 0000000000..636d095b6e
--- /dev/null
+++ b/app/client/src/sagas/__tests__/NavigateActionSaga.test.ts
@@ -0,0 +1,433 @@
+import type { ReduxAction } from "actions/ReduxActionTypes";
+import { ReduxActionTypes } from "ee/constants/ReduxActionConstants";
+import { ENTITY_TYPE } from "ee/entities/AppsmithConsole/utils";
+import { setDataUrl } from "ee/sagas/PageSagas";
+import { getAppMode } from "ee/selectors/applicationSelectors";
+import AnalyticsUtil from "ee/utils/AnalyticsUtil";
+import { APP_MODE } from "entities/App";
+import type { Page } from "entities/Page";
+import { expectSaga } from "redux-saga-test-plan";
+import { call, put, select, take } from "redux-saga/effects";
+import navigateActionSaga, {
+ navigateToAnyPageInApplication,
+ pushToHistory,
+} from "sagas/ActionExecution/NavigateActionSaga";
+import type { NavigateToAnotherPagePayload } from "sagas/ActionExecution/NavigateActionSaga/types";
+import { TriggerFailureError } from "sagas/ActionExecution/errorUtils";
+import { getCurrentPageId, getPageList } from "selectors/editorSelectors";
+import AppsmithConsole from "utils/AppsmithConsole";
+import history, { NavigationMethod } from "utils/history";
+import type { TNavigateToDescription } from "workers/Evaluation/fns/navigateTo";
+import { NavigationTargetType } from "workers/Evaluation/fns/navigateTo";
+
+// Mock worker global functions
+const mockWindowOpen = jest.fn();
+const mockWindowLocationAssign = jest.fn();
+
+Object.defineProperty(window, "open", {
+ value: mockWindowOpen,
+ writable: true,
+});
+Object.defineProperty(window, "location", {
+ value: { assign: mockWindowLocationAssign },
+ writable: true,
+});
+
+jest.mock("ee/utils/AnalyticsUtil");
+jest.mock("utils/AppsmithConsole");
+jest.mock("ee/sagas/PageSagas");
+jest.mock("utils/history");
+jest.mock("ee/RouteBuilder", () => ({
+ builderURL: jest.fn(({ basePageId, params }) => {
+ let url = `/builder/${basePageId}`;
+
+ if (params) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const queryString = new URLSearchParams(params as any).toString();
+
+ if (queryString) url += `?${queryString}`;
+ }
+
+ return url;
+ }),
+ viewerURL: jest.fn(({ basePageId, params }) => {
+ let url = `/viewer/${basePageId}`;
+
+ if (params) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const queryString = new URLSearchParams(params as any).toString();
+
+ if (queryString) url += `?${queryString}`;
+ }
+
+ return url;
+ }),
+}));
+
+const MOCK_PAGE_LIST: Page[] = [
+ { pageId: "page1", pageName: "Page1", basePageId: "basePage1" } as Page,
+ { pageId: "page2", pageName: "Page2", basePageId: "basePage2" } as Page,
+];
+
+const MOCK_SOURCE_ENTITY = {
+ id: "widgetId",
+ name: "Button1",
+ type: ENTITY_TYPE.WIDGET,
+};
+
+describe("NavigateActionSaga", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("navigateActionSaga", () => {
+ const basePayload: TNavigateToDescription["payload"] = {
+ pageNameOrUrl: "Page1",
+ params: {},
+ target: NavigationTargetType.SAME_WINDOW,
+ };
+
+ it("should navigate to a page in the same window (EDIT mode)", async () => {
+ const action: TNavigateToDescription = {
+ type: "NAVIGATE_TO",
+ payload: basePayload,
+ };
+
+ return expectSaga(navigateActionSaga, action, MOCK_SOURCE_ENTITY)
+ .provide([
+ [select(getPageList), MOCK_PAGE_LIST],
+ [select(getCurrentPageId), "page2"],
+ [select(getAppMode), APP_MODE.EDIT],
+ [call(pushToHistory, "/builder/basePage1"), undefined], // Mock pushToHistory
+ ])
+ .call(pushToHistory, "/builder/basePage1")
+ .run()
+ .then(() => {
+ expect(AnalyticsUtil.logEvent).toHaveBeenCalledWith("NAVIGATE", {
+ pageName: "Page1",
+ pageParams: {},
+ });
+ expect(AppsmithConsole.info).toHaveBeenCalledWith(
+ expect.objectContaining({
+ text: "navigateTo triggered",
+ source: MOCK_SOURCE_ENTITY,
+ }),
+ );
+ });
+ });
+
+ it("should navigate to a page in a new window (VIEW mode)", async () => {
+ const action: TNavigateToDescription = {
+ type: "NAVIGATE_TO",
+ payload: {
+ ...basePayload,
+ target: NavigationTargetType.NEW_WINDOW,
+ },
+ };
+
+ return expectSaga(navigateActionSaga, action)
+ .provide([
+ [select(getPageList), MOCK_PAGE_LIST],
+ [select(getCurrentPageId), "page2"],
+ [select(getAppMode), APP_MODE.PUBLISHED],
+ ])
+ .run()
+ .then(() => {
+ expect(mockWindowOpen).toHaveBeenCalledWith(
+ "/viewer/basePage1",
+ "_blank",
+ );
+ expect(AnalyticsUtil.logEvent).toHaveBeenCalled();
+ });
+ });
+
+ it("should navigate to the current page and trigger re-evaluation", async () => {
+ const action: TNavigateToDescription = {
+ type: "NAVIGATE_TO",
+ payload: { ...basePayload, pageNameOrUrl: "Page1" }, // current page
+ };
+
+ return expectSaga(navigateActionSaga, action)
+ .provide([
+ [select(getPageList), MOCK_PAGE_LIST],
+ [select(getCurrentPageId), "page1"], // Current page is page1
+ [select(getAppMode), APP_MODE.EDIT],
+ [call(pushToHistory, "/builder/basePage1"), undefined],
+ [call(setDataUrl), undefined],
+ ])
+ .put({ type: ReduxActionTypes.TRIGGER_EVAL })
+ .call(setDataUrl)
+ .run();
+ });
+
+ it("should navigate to an external URL in the same window", async () => {
+ const action: TNavigateToDescription = {
+ type: "NAVIGATE_TO",
+ payload: {
+ pageNameOrUrl: "www.google.com",
+ params: { q: "test" },
+ target: NavigationTargetType.SAME_WINDOW,
+ },
+ };
+
+ return expectSaga(navigateActionSaga, action)
+ .provide([[select(getPageList), MOCK_PAGE_LIST]])
+ .run()
+ .then(() => {
+ expect(mockWindowLocationAssign).toHaveBeenCalledWith(
+ "https://www.google.com?q=test",
+ );
+ expect(AnalyticsUtil.logEvent).toHaveBeenCalledWith("NAVIGATE", {
+ navUrl: "www.google.com",
+ });
+ });
+ });
+
+ it("should navigate to an external URL (with https) in a new window", async () => {
+ const action: TNavigateToDescription = {
+ type: "NAVIGATE_TO",
+ payload: {
+ pageNameOrUrl: "https://appsmith.com",
+ params: {},
+ target: NavigationTargetType.NEW_WINDOW,
+ },
+ };
+
+ return expectSaga(navigateActionSaga, action)
+ .provide([[select(getPageList), MOCK_PAGE_LIST]])
+ .run()
+ .then(() => {
+ expect(mockWindowOpen).toHaveBeenCalledWith(
+ "https://appsmith.com",
+ "_blank",
+ );
+ });
+ });
+
+ it("should throw TriggerFailureError for invalid URL", async () => {
+ const action: TNavigateToDescription = {
+ type: "NAVIGATE_TO",
+ payload: {
+ pageNameOrUrl: "invalid-url-that-does-not-exist",
+ params: {},
+ target: NavigationTargetType.SAME_WINDOW,
+ },
+ };
+
+ return expectSaga(navigateActionSaga, action)
+ .provide([[select(getPageList), MOCK_PAGE_LIST]])
+ .run()
+ .catch((e) => {
+ expect(e).toBeInstanceOf(TriggerFailureError);
+ expect(e.message).toBe("Enter a valid URL or page name");
+ });
+ });
+
+ it("should navigate to page with query params", async () => {
+ const params = { key1: "value1", key2: "value2" };
+ const action: TNavigateToDescription = {
+ type: "NAVIGATE_TO",
+ payload: { ...basePayload, params },
+ };
+
+ return expectSaga(navigateActionSaga, action)
+ .provide([
+ [select(getPageList), MOCK_PAGE_LIST],
+ [select(getCurrentPageId), "page2"],
+ [select(getAppMode), APP_MODE.EDIT],
+ [
+ call(pushToHistory, {
+ pageURL: "/builder/basePage1?key1=value1&key2=value2",
+ query: "key1=value1&key2=value2",
+ state: {},
+ }),
+ undefined,
+ ],
+ ])
+ .run();
+ });
+ });
+
+ describe("pushToHistory", () => {
+ const payload: NavigateToAnotherPagePayload = {
+ pageURL: "/app/page-1",
+ query: "param=value",
+ state: {},
+ };
+
+ const onPageUnloadActionsCompletionPattern = [
+ ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS_SUCCESS,
+ ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS_ERROR,
+ ];
+
+ describe("with payload", () => {
+ it("should dispatch EXECUTE_PAGE_UNLOAD_ACTIONS and wait for success", async () => {
+ return expectSaga(pushToHistory, payload)
+ .provide([
+ [put({ type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS }), true],
+ [
+ take(onPageUnloadActionsCompletionPattern),
+ { type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS_SUCCESS },
+ ],
+ ])
+ .run()
+ .then(() => {
+ expect(history.push).toHaveBeenCalledWith({
+ pathname: "/app/page-1",
+ search: "param=value",
+ state: {},
+ });
+ });
+ });
+
+ it("should dispatch EXECUTE_PAGE_UNLOAD_ACTIONS and wait for error", async () => {
+ return expectSaga(pushToHistory, payload)
+ .provide([
+ [put({ type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS }), true],
+ [
+ take(onPageUnloadActionsCompletionPattern),
+ { type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS_ERROR },
+ ],
+ ])
+ .run()
+ .then(() => {
+ expect(history.push).toHaveBeenCalledWith({
+ pathname: "/app/page-1",
+ search: "param=value",
+ state: {},
+ });
+ });
+ });
+
+ it("should call history.push with state if provided", async () => {
+ const payloadWithState: NavigateToAnotherPagePayload = {
+ ...payload,
+ state: { invokedBy: NavigationMethod.AppNavigation },
+ };
+
+ return expectSaga(pushToHistory, payloadWithState)
+ .provide([
+ [put({ type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS }), true],
+ [
+ take(onPageUnloadActionsCompletionPattern),
+ { type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS_SUCCESS },
+ ],
+ ])
+ .run()
+ .then(() => {
+ expect(history.push).toHaveBeenCalledWith({
+ pathname: "/app/page-1",
+ search: "param=value",
+ state: { invokedBy: NavigationMethod.AppNavigation },
+ });
+ });
+ });
+ });
+
+ describe("with string parameter", () => {
+ it("should dispatch EXECUTE_PAGE_UNLOAD_ACTIONS and wait for success with string path", async () => {
+ const stringPath = "/app/simple-page";
+
+ return expectSaga(pushToHistory, stringPath)
+ .provide([
+ [put({ type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS }), true],
+ [
+ take(onPageUnloadActionsCompletionPattern),
+ { type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS_SUCCESS },
+ ],
+ ])
+ .run()
+ .then(() => {
+ expect(history.push).toHaveBeenCalledWith(stringPath);
+ });
+ });
+
+ it("should dispatch EXECUTE_PAGE_UNLOAD_ACTIONS and wait for error with string path", async () => {
+ const stringPath = "/app/another-page";
+
+ return expectSaga(pushToHistory, stringPath)
+ .provide([
+ [put({ type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS }), true],
+ [
+ take(onPageUnloadActionsCompletionPattern),
+ { type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS_ERROR },
+ ],
+ ])
+ .run()
+ .then(() => {
+ expect(history.push).toHaveBeenCalledWith(stringPath);
+ });
+ });
+
+ it("should handle string path with query parameters", async () => {
+ const stringPathWithQuery = "/app/page?param1=value1¶m2=value2";
+
+ return expectSaga(pushToHistory, stringPathWithQuery)
+ .provide([
+ [put({ type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS }), true],
+ [
+ take(onPageUnloadActionsCompletionPattern),
+ { type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS_SUCCESS },
+ ],
+ ])
+ .run()
+ .then(() => {
+ expect(history.push).toHaveBeenCalledWith(stringPathWithQuery);
+ });
+ });
+
+ it("should handle root path string", async () => {
+ const rootPath = "/";
+
+ return expectSaga(pushToHistory, rootPath)
+ .provide([
+ [put({ type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS }), true],
+ [
+ take(onPageUnloadActionsCompletionPattern),
+ { type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS_SUCCESS },
+ ],
+ ])
+ .run()
+ .then(() => {
+ expect(history.push).toHaveBeenCalledWith(rootPath);
+ });
+ });
+
+ it("should handle empty string path", async () => {
+ const emptyPath = "";
+
+ return expectSaga(pushToHistory, emptyPath)
+ .provide([
+ [put({ type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS }), true],
+ [
+ take(onPageUnloadActionsCompletionPattern),
+ { type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS_SUCCESS },
+ ],
+ ])
+ .run()
+ .then(() => {
+ expect(history.push).toHaveBeenCalledWith(emptyPath);
+ });
+ });
+ });
+ });
+
+ describe("navigateToAnyPageInApplication", () => {
+ it("should call pushToHistory with the given payload", async () => {
+ const payload: NavigateToAnotherPagePayload = {
+ pageURL: "/app/my-page",
+ query: "test=1",
+ state: {},
+ };
+ const action: ReduxAction = {
+ type: "NAVIGATE_TO_PAGE", // Mock action type
+ payload,
+ };
+
+ return expectSaga(navigateToAnyPageInApplication, action)
+ .provide([[call(pushToHistory, payload), undefined]])
+ .call(pushToHistory, payload)
+ .run();
+ });
+ });
+});
diff --git a/app/client/src/sagas/__tests__/onPageUnloadSaga.test.ts b/app/client/src/sagas/__tests__/onPageUnloadSaga.test.ts
new file mode 100644
index 0000000000..d529503145
--- /dev/null
+++ b/app/client/src/sagas/__tests__/onPageUnloadSaga.test.ts
@@ -0,0 +1,394 @@
+import { ReduxActionTypes } from "ee/constants/ReduxActionConstants";
+import { ENTITY_TYPE, PLATFORM_ERROR } from "ee/entities/AppsmithConsole/utils";
+import { getJSCollectionFromAllEntities } from "ee/selectors/entitiesSelector";
+import type { Action } from "entities/Action";
+import LOG_TYPE from "entities/AppsmithConsole/logtype";
+import type { JSAction, JSCollection } from "entities/JSCollection";
+import { appsmithTelemetry } from "instrumentation";
+import {
+ endSpan,
+ setAttributesToSpan,
+ startRootSpan,
+} from "instrumentation/generateTraces";
+import log from "loglevel";
+import { expectSaga } from "redux-saga-test-plan";
+import { call, select } from "redux-saga/effects";
+import { executePageUnloadActionsSaga } from "sagas/ActionExecution/PluginActionSaga";
+import { handleExecuteJSFunctionSaga } from "sagas/JSPaneSagas";
+import {
+ getCurrentPageId,
+ getLayoutOnUnloadActions,
+} from "selectors/editorSelectors";
+import AppsmithConsole from "utils/AppsmithConsole";
+
+// Mock dependencies
+jest.mock("sagas/JSPaneSagas");
+jest.mock("instrumentation", () => ({
+ appsmithTelemetry: {
+ captureException: jest.fn(),
+ getTraceAndContext: jest.fn(() => ({ context: {} })),
+ },
+}));
+jest.mock("instrumentation/generateTraces");
+jest.mock("loglevel");
+jest.mock("utils/AppsmithConsole");
+
+// For testing executeOnPageUnloadJSAction directly if it were exported
+// We will test it indirectly via executePageUnloadActionsSaga
+const MOCK_JS_ACTION: JSAction = {
+ id: "jsAction1",
+ baseId: "jsAction1",
+ name: "myFun",
+ collectionId: "jsCollection1",
+ fullyQualifiedName: "JSObject1.myFun",
+ pluginType: "JS",
+ pluginId: "pluginId1",
+ workspaceId: "ws1",
+ applicationId: "app1",
+ pageId: "page1",
+ runBehaviour: "ON_PAGE_LOAD",
+ dynamicBindingPathList: [],
+ isValid: true,
+ invalids: [],
+ jsonPathKeys: [],
+ cacheResponse: "",
+ messages: [],
+ actionConfiguration: {
+ body: "return 1;",
+ timeoutInMillisecond: 5000,
+ jsArguments: [],
+ },
+ clientSideExecution: true,
+} as JSAction;
+
+const MOCK_JS_COLLECTION: JSCollection = {
+ id: "jsCollection1",
+ name: "JSObject1",
+ pageId: "page1",
+ actions: [MOCK_JS_ACTION],
+} as JSCollection;
+
+const MOCK_ACTION_TRIGGER: Action = {
+ id: "jsAction1",
+ name: "JSObject1.myFun",
+ collectionId: "jsCollection1",
+ pluginId: "pluginId1",
+ pluginType: "JS",
+ jsonPathKeys: [],
+ eventData: {},
+ pageId: "page1",
+ applicationId: "app1",
+ workspaceId: "ws1",
+ datasourceUrl: "",
+} as unknown as Action;
+
+describe("OnPageUnloadSaga", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("executePageUnloadActionsSaga", () => {
+ it("should do nothing and dispatch SUCCESS if no actions are present", async () => {
+ const span = startRootSpan("executePageUnloadActionsSaga");
+
+ return expectSaga(executePageUnloadActionsSaga)
+ .provide([[select(getLayoutOnUnloadActions), []]])
+ .put({ type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS_SUCCESS })
+ .run()
+ .then(() => {
+ expect(startRootSpan).toHaveBeenCalledWith(
+ "executePageUnloadActionsSaga",
+ );
+ expect(setAttributesToSpan).toHaveBeenCalledWith(span, {
+ numActions: 0,
+ });
+ expect(handleExecuteJSFunctionSaga).not.toHaveBeenCalled();
+ expect(endSpan).toHaveBeenCalled();
+ });
+ });
+
+ it("should execute a single JS action", async () => {
+ const actionsToRun = [MOCK_ACTION_TRIGGER];
+ const span = startRootSpan("executePageUnloadActionsSaga");
+
+ return expectSaga(executePageUnloadActionsSaga)
+ .provide([
+ [select(getLayoutOnUnloadActions), actionsToRun],
+ [select(getCurrentPageId), "page1"],
+ [
+ select(getJSCollectionFromAllEntities, "jsCollection1"),
+ MOCK_JS_COLLECTION,
+ ],
+ [
+ call(
+ handleExecuteJSFunctionSaga,
+ expect.objectContaining({ action: MOCK_JS_ACTION }),
+ ),
+ undefined, // Mock successful execution
+ ],
+ ])
+ .put({ type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS_SUCCESS })
+ .run()
+ .then(() => {
+ expect(setAttributesToSpan).toHaveBeenCalledWith(span, {
+ numActions: 1,
+ });
+ expect(handleExecuteJSFunctionSaga).toHaveBeenCalledTimes(1);
+ expect(handleExecuteJSFunctionSaga).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: MOCK_JS_ACTION,
+ collection: MOCK_JS_COLLECTION,
+ isExecuteJSFunc: true,
+ onPageLoad: false,
+ }),
+ );
+ });
+ });
+
+ it("should execute multiple JS actions in parallel", async () => {
+ const span = startRootSpan("executePageUnloadActionsSaga");
+ const anotherJsAction: JSAction = {
+ ...MOCK_JS_ACTION,
+ id: "jsAction2",
+ };
+ const anotherCollection: JSCollection = {
+ ...MOCK_JS_COLLECTION,
+ id: "jsCollection2",
+ actions: [anotherJsAction],
+ };
+ const anotherActionTrigger: Action = {
+ ...MOCK_ACTION_TRIGGER,
+ id: "jsAction2",
+ collectionId: "jsCollection2",
+ };
+ const actionsToRun = [MOCK_ACTION_TRIGGER, anotherActionTrigger];
+
+ return expectSaga(executePageUnloadActionsSaga)
+ .provide([
+ [select(getLayoutOnUnloadActions), actionsToRun],
+ [select(getCurrentPageId), "page1"],
+ [
+ select(getJSCollectionFromAllEntities, "jsCollection1"),
+ MOCK_JS_COLLECTION,
+ ],
+ [
+ select(getJSCollectionFromAllEntities, "jsCollection2"),
+ anotherCollection,
+ ],
+ [
+ call(
+ handleExecuteJSFunctionSaga,
+ expect.objectContaining({ action: MOCK_JS_ACTION }),
+ ),
+ undefined,
+ ],
+ [
+ call(
+ handleExecuteJSFunctionSaga,
+ expect.objectContaining({ action: anotherJsAction }),
+ ),
+ undefined,
+ ],
+ ])
+ .put({ type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS_SUCCESS })
+ .run()
+ .then(() => {
+ expect(setAttributesToSpan).toHaveBeenCalledWith(span, {
+ numActions: 2,
+ });
+ expect(handleExecuteJSFunctionSaga).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it("should handle JS execution errors gracefully and still dispatch SUCCESS", async () => {
+ const actionsToRun = [MOCK_ACTION_TRIGGER];
+ const span = startRootSpan("executePageUnloadActionsSaga");
+
+ return expectSaga(executePageUnloadActionsSaga)
+ .provide([
+ [select(getLayoutOnUnloadActions), actionsToRun],
+ [select(getCurrentPageId), "page1"],
+ [
+ select(getJSCollectionFromAllEntities, "jsCollection1"),
+ MOCK_JS_COLLECTION,
+ ],
+ [
+ call(
+ handleExecuteJSFunctionSaga,
+ expect.objectContaining({ action: MOCK_JS_ACTION }),
+ ),
+ // handleExecuteJSFunctionSaga doesn't throw - it catches errors internally
+ undefined, // Mock successful execution (even though there's an internal error)
+ ],
+ ])
+ .put({ type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS_SUCCESS })
+ .run()
+ .then(() => {
+ expect(setAttributesToSpan).toHaveBeenCalledWith(span, {
+ numActions: 1,
+ });
+ expect(handleExecuteJSFunctionSaga).toHaveBeenCalledTimes(1);
+ expect(handleExecuteJSFunctionSaga).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: MOCK_JS_ACTION,
+ collection: MOCK_JS_COLLECTION,
+ isExecuteJSFunc: true,
+ onPageLoad: false,
+ }),
+ );
+ // The error handling happens inside handleExecuteJSFunctionSaga via AppsmithConsole.addErrors
+ // We don't expect log.error or AppsmithConsole.error to be called here
+ expect(log.error).not.toHaveBeenCalled();
+ expect(AppsmithConsole.error).not.toHaveBeenCalled();
+ expect(endSpan).toHaveBeenCalled();
+ });
+ });
+
+ it("should handle actual JS execution errors via AppsmithConsole.addErrors", async () => {
+ const actionsToRun = [MOCK_ACTION_TRIGGER];
+ const span = startRootSpan("executePageUnloadActionsSaga");
+
+ // Mock handleExecuteJSFunctionSaga to simulate internal error handling
+ const mockHandleExecuteJSFunctionSaga = jest
+ .fn()
+ .mockImplementation(function* () {
+ // Simulate the internal error handling that happens in handleExecuteJSFunctionSaga
+ AppsmithConsole.addErrors([
+ {
+ payload: {
+ id: MOCK_JS_ACTION.id,
+ logType: LOG_TYPE.JS_EXECUTION_ERROR,
+ text: "JS execution failed",
+ source: {
+ type: ENTITY_TYPE.JSACTION,
+ name: "JSObject1.myFun",
+ id: "jsCollection1",
+ },
+ messages: [
+ {
+ message: {
+ name: "Error",
+ message: "JS execution failed",
+ },
+ type: PLATFORM_ERROR.PLUGIN_EXECUTION,
+ },
+ ],
+ },
+ },
+ ]);
+ });
+
+ // Replace the mocked function temporarily
+ const originalHandleExecuteJSFunctionSaga = handleExecuteJSFunctionSaga;
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (handleExecuteJSFunctionSaga as any) = mockHandleExecuteJSFunctionSaga;
+
+ return expectSaga(executePageUnloadActionsSaga)
+ .provide([
+ [select(getLayoutOnUnloadActions), actionsToRun],
+ [select(getCurrentPageId), "page1"],
+ [
+ select(getJSCollectionFromAllEntities, "jsCollection1"),
+ MOCK_JS_COLLECTION,
+ ],
+ ])
+ .put({ type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS_SUCCESS })
+ .run()
+ .then(() => {
+ expect(setAttributesToSpan).toHaveBeenCalledWith(span, {
+ numActions: 1,
+ });
+ expect(mockHandleExecuteJSFunctionSaga).toHaveBeenCalledTimes(1);
+ expect(AppsmithConsole.addErrors).toHaveBeenCalledWith([
+ expect.objectContaining({
+ payload: expect.objectContaining({
+ id: MOCK_JS_ACTION.id,
+ logType: LOG_TYPE.JS_EXECUTION_ERROR,
+ }),
+ }),
+ ]);
+ expect(endSpan).toHaveBeenCalled();
+
+ // Restore the original function
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (handleExecuteJSFunctionSaga as any) =
+ originalHandleExecuteJSFunctionSaga;
+ });
+ });
+
+ it("should handle missing collectionId in action trigger gracefully", async () => {
+ const actionWithNoCollectionId = {
+ ...MOCK_ACTION_TRIGGER,
+ collectionId: undefined,
+ };
+ const actionsToRun = [actionWithNoCollectionId];
+
+ return expectSaga(executePageUnloadActionsSaga)
+ .provide([
+ [select(getLayoutOnUnloadActions), actionsToRun],
+ [select(getCurrentPageId), "page1"],
+ // No collection fetch or JS execution should happen
+ ])
+ .put({ type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS_SUCCESS })
+ .run()
+ .then(() => {
+ expect(handleExecuteJSFunctionSaga).not.toHaveBeenCalled();
+ expect(appsmithTelemetry.captureException).not.toHaveBeenCalled();
+ });
+ });
+
+ it("should capture exception if JS collection is not found", async () => {
+ const actionsToRun = [MOCK_ACTION_TRIGGER];
+
+ return expectSaga(executePageUnloadActionsSaga)
+ .provide([
+ [select(getLayoutOnUnloadActions), actionsToRun],
+ [select(getCurrentPageId), "page1"],
+ [
+ select(getJSCollectionFromAllEntities, "jsCollection1"),
+ undefined, // Collection not found
+ ],
+ ])
+ .put({ type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS_SUCCESS }) // Still success, as the saga itself doesn't fail here
+ .run()
+ .then(() => {
+ expect(appsmithTelemetry.captureException).toHaveBeenCalledWith(
+ expect.any(Error),
+ expect.objectContaining({
+ errorName: "MissingJSCollection",
+ extra: {
+ collectionId: "jsCollection1",
+ actionId: "jsAction1",
+ pageId: "page1",
+ },
+ }),
+ );
+ expect(handleExecuteJSFunctionSaga).not.toHaveBeenCalled();
+ });
+ });
+
+ it("should not call handleExecuteJSFunctionSaga if JSAction is not found in collection", async () => {
+ const collectionWithMissingAction: JSCollection = {
+ ...MOCK_JS_COLLECTION,
+ actions: [], // No actions in collection
+ };
+ const actionsToRun = [MOCK_ACTION_TRIGGER];
+
+ return expectSaga(executePageUnloadActionsSaga)
+ .provide([
+ [select(getLayoutOnUnloadActions), actionsToRun],
+ [select(getCurrentPageId), "page1"],
+ [
+ select(getJSCollectionFromAllEntities, "jsCollection1"),
+ collectionWithMissingAction,
+ ],
+ ])
+ .put({ type: ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS_SUCCESS })
+ .run()
+ .then(() => {
+ expect(handleExecuteJSFunctionSaga).not.toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/app/client/src/selectors/editorSelectors.tsx b/app/client/src/selectors/editorSelectors.tsx
index 767b3700f5..b220b6ab77 100644
--- a/app/client/src/selectors/editorSelectors.tsx
+++ b/app/client/src/selectors/editorSelectors.tsx
@@ -30,6 +30,7 @@ import type { MainCanvasReduxState } from "ee/reducers/uiReducers/mainCanvasRedu
import { getActionEditorSavingMap } from "PluginActionEditor/store";
import {
getCanvasWidgets,
+ getAllJSCollectionActions,
getJSCollections,
} from "ee/selectors/entitiesSelector";
import { checkIsDropTarget } from "WidgetProvider/factory/helpers";
@@ -50,6 +51,7 @@ import { getCurrentApplication } from "ee/selectors/applicationSelectors";
import type { Page } from "entities/Page";
import { objectKeys } from "@appsmith/utils";
import type { MetaWidgetsReduxState } from "reducers/entityReducers/metaWidgetsReducer";
+import { ActionRunBehaviour } from "PluginActionEditor/types/PluginActionTypes";
const getIsDraggingOrResizing = (state: DefaultRootState) =>
state.ui.widgetDragResize.isResizing || state.ui.widgetDragResize.isDragging;
@@ -126,6 +128,15 @@ export const getPageSavingError = (state: DefaultRootState) => {
export const getLayoutOnLoadActions = (state: DefaultRootState) =>
state.ui.editor.pageActions || [];
+export const getLayoutOnUnloadActions = createSelector(
+ getAllJSCollectionActions,
+ (jsActions) => {
+ return jsActions.filter((action) => {
+ return action.runBehaviour === ActionRunBehaviour.ON_PAGE_UNLOAD;
+ });
+ },
+);
+
export const getLayoutOnLoadIssues = (state: DefaultRootState) => {
return state.ui.editor.layoutOnLoadActionErrors || [];
};