feat: integrates on page unload behavior with backend for deployed mode of the app (#41036)

## Description
**TLDR:** Adds support for executing page unload actions during
navigation in deployed mode, refactors related components, and improves
action handling.

<ins>Problem</ins>

Page unload actions were not triggered during navigation in deployed
mode, leading to incomplete workflows especially for cleanup.

<ins>Root cause</ins>

The application lacked integration for executing unload actions on page
transitions, and related components did not properly handle navigation
or action execution.

<ins>Solution</ins>

This PR handles the integration of page unload action execution during
navigation in deployed mode. It introduces selectors for unload actions,
refactors the MenuItem component for better navigation handling, and
improves the PluginActionSaga for executing plugin actions. Unused
parameters and functions are removed for clarity and maintainability.

Fixes #40997
_or_  
Fixes `Issue URL`
> [!WARNING]  
> _If no issue exists, please create an issue first, and check with the
maintainers if the issue is valid._

## Automation

/ok-to-test tags="@tag.All"

### 🔍 Cypress test results
<!-- This is an auto-generated comment: Cypress test results  -->
> [!TIP]
> 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉
> Workflow run:
<https://github.com/appsmithorg/appsmith/actions/runs/16021075820>
> Commit: f09e3c44d379488e43aec6ab27228d7675f79415
> <a
href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=16021075820&attempt=1"
target="_blank">Cypress dashboard</a>.
> Tags: `@tag.All`
> Spec:
> <hr>Wed, 02 Jul 2025 10:21:00 UTC
<!-- end of auto-generated comment: Cypress test results  -->


## Communication
Should the DevRel and Marketing teams inform users about this change?
- [ ] Yes
- [ ] No


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added support for actions that execute automatically when navigating
away from a page.
* Introduced new navigation logic and hooks for consistent page
transitions within the app.
  * Added new menu and dropdown components for improved navigation UI.

* **Bug Fixes**
* Updated navigation item styling and active state detection for
improved accuracy.

* **Tests**
* Added comprehensive tests for navigation sagas and page unload
actions.
  * Added unit tests for navigation menu components.

* **Chores**
  * Refactored and centralized navigation logic for maintainability.
* Improved type safety and selector usage in navigation and action
execution.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Rahul Barwal 2025-07-02 18:40:44 +05:30 committed by GitHub
parent 481988daf1
commit b4d5685d21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1551 additions and 95 deletions

View File

@ -59,7 +59,7 @@ export class AppSettings {
".t--app-viewer-navigation-top-inline-more-dropdown-item", ".t--app-viewer-navigation-top-inline-more-dropdown-item",
_scrollArrows: ".scroll-arrows", _scrollArrows: ".scroll-arrows",
_getActivePage: (pageName: string) => _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']", _importBtn: "[data-testid='t--app-setting-import-btn']",
}; };

View File

@ -30,6 +30,7 @@ import type { PACKAGE_PULL_STATUS } from "ee/constants/ModuleConstants";
import type { ApiResponse } from "api/ApiResponses"; import type { ApiResponse } from "api/ApiResponses";
import type { EvaluationReduxAction } from "./EvaluationReduxActionTypes"; import type { EvaluationReduxAction } from "./EvaluationReduxActionTypes";
import { appsmithTelemetry } from "instrumentation"; import { appsmithTelemetry } from "instrumentation";
import type { NavigateToAnotherPagePayload } from "sagas/ActionExecution/NavigateActionSaga/types";
export interface FetchPageListPayload { export interface FetchPageListPayload {
applicationId: string; applicationId: string;
@ -696,3 +697,10 @@ export const setupPublishedPage = (
pageWithMigratedDsl, pageWithMigratedDsl,
}, },
}); });
export const navigateToAnotherPage = (
payload: NavigateToAnotherPagePayload,
) => ({
type: ReduxActionTypes.NAVIGATE_TO_ANOTHER_PAGE,
payload,
});

View File

@ -620,6 +620,7 @@ const PageActionTypes = {
RESET_PAGE_LIST: "RESET_PAGE_LIST", RESET_PAGE_LIST: "RESET_PAGE_LIST",
SET_ONLOAD_ACTION_EXECUTED: "SET_ONLOAD_ACTION_EXECUTED", SET_ONLOAD_ACTION_EXECUTED: "SET_ONLOAD_ACTION_EXECUTED",
EXECUTE_REACTIVE_QUERIES: "EXECUTE_REACTIVE_QUERIES", EXECUTE_REACTIVE_QUERIES: "EXECUTE_REACTIVE_QUERIES",
NAVIGATE_TO_ANOTHER_PAGE: "NAVIGATE_TO_ANOTHER_PAGE",
}; };
const PageActionErrorTypes = { const PageActionErrorTypes = {
@ -731,6 +732,9 @@ const ActionExecutionTypes = {
CANCEL_ACTION_MODAL: "CANCEL_ACTION_MODAL", CANCEL_ACTION_MODAL: "CANCEL_ACTION_MODAL",
CONFIRM_ACTION_MODAL: "CONFIRM_ACTION_MODAL", CONFIRM_ACTION_MODAL: "CONFIRM_ACTION_MODAL",
EXECUTE_PAGE_LOAD_ACTIONS: "EXECUTE_PAGE_LOAD_ACTIONS", 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_REQUEST: "EXECUTE_PLUGIN_ACTION_REQUEST",
EXECUTE_PLUGIN_ACTION_SUCCESS: "EXECUTE_PLUGIN_ACTION_SUCCESS", EXECUTE_PLUGIN_ACTION_SUCCESS: "EXECUTE_PLUGIN_ACTION_SUCCESS",
SET_ACTION_RESPONSE_DISPLAY_FORMAT: "SET_ACTION_RESPONSE_DISPLAY_FORMAT", SET_ACTION_RESPONSE_DISPLAY_FORMAT: "SET_ACTION_RESPONSE_DISPLAY_FORMAT",

View File

@ -39,6 +39,7 @@ export enum ActionExecutionContext {
PAGE_LOAD = "PAGE_LOAD", PAGE_LOAD = "PAGE_LOAD",
EVALUATION_ACTION_TRIGGER = "EVALUATION_ACTION_TRIGGER", EVALUATION_ACTION_TRIGGER = "EVALUATION_ACTION_TRIGGER",
REFRESH_ACTIONS_ON_ENV_CHANGE = "REFRESH_ACTIONS_ON_ENV_CHANGE", REFRESH_ACTIONS_ON_ENV_CHANGE = "REFRESH_ACTIONS_ON_ENV_CHANGE",
PAGE_UNLOAD = "PAGE_UNLOAD",
} }
export interface KeyValuePair { export interface KeyValuePair {

View File

@ -5,10 +5,9 @@ import {
getMenuItemBackgroundColorWhenActive, getMenuItemBackgroundColorWhenActive,
getMenuItemTextColor, getMenuItemTextColor,
} from "pages/AppViewer/utils"; } from "pages/AppViewer/utils";
import { NavLink } from "react-router-dom";
import styled from "styled-components"; import styled from "styled-components";
export const StyledMenuItem = styled(NavLink)<{ export const StyledMenuItem = styled.div<{
borderRadius: string; borderRadius: string;
primaryColor: string; primaryColor: string;
navColorStyle: NavigationSetting["colorStyle"]; navColorStyle: NavigationSetting["colorStyle"];

View File

@ -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 }) => (
<div data-testid="styled-menu-item" {...props}>
{children}
</div>
)),
}));
jest.mock("../MenuText", () =>
jest.fn((props) => (
<div data-testid="menu-text" {...props}>
{props.name}
</div>
)),
);
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<MenuItemProps> = {},
initialState: Partial<DefaultRootState> = {},
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(
<Provider store={store}>
<MenuItem
navigationSetting={{
...defaultNavigationSetting,
colorStyle: NAVIGATION_SETTINGS.COLOR_STYLE.LIGHT,
}}
page={mockPage}
query={mockQuery}
{...props}
/>
</Provider>,
);
};
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<DefaultRootState> = {},
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;
};

View File

@ -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 { NAVIGATION_SETTINGS } from "constants/AppConstants";
import { APP_MODE } from "entities/App";
import { get } from "lodash"; import { get } from "lodash";
import { useHref } from "pages/Editor/utils"; import React, { useMemo } from "react";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { builderURL, viewerURL } from "ee/RouteBuilder"; import { useLocation } from "react-router-dom";
import { getAppMode } from "ee/selectors/applicationSelectors";
import { getSelectedAppTheme } from "selectors/appThemingSelectors"; import { getSelectedAppTheme } from "selectors/appThemingSelectors";
import { trimQueryString } from "utils/helpers";
import MenuText from "./MenuText";
import { StyledMenuItem } from "./MenuItem.styled";
import { NavigationMethod } from "utils/history"; import { NavigationMethod } from "utils/history";
import useNavigateToAnotherPage from "../../hooks/useNavigateToAnotherPage";
interface MenuItemProps { import { StyledMenuItem } from "../MenuItem.styled";
page: Page; import MenuText from "../MenuText";
query: string; import type { MenuItemProps } from "./types";
navigationSetting?: NavigationSetting;
}
const MenuItem = ({ navigationSetting, page, query }: MenuItemProps) => { const MenuItem = ({ navigationSetting, page, query }: MenuItemProps) => {
const appMode = useSelector(getAppMode); const location = useLocation();
const pageURL = useHref(
appMode === APP_MODE.PUBLISHED ? viewerURL : builderURL, const navigateToAnotherPage = useNavigateToAnotherPage({
{ basePageId: page.basePageId }, basePageId: page.basePageId,
); query,
state: { invokedBy: NavigationMethod.AppNavigation },
});
const selectedTheme = useSelector(getSelectedAppTheme); const selectedTheme = useSelector(getSelectedAppTheme);
const navColorStyle = const navColorStyle =
navigationSetting?.colorStyle || NAVIGATION_SETTINGS.COLOR_STYLE.LIGHT; navigationSetting?.colorStyle || NAVIGATION_SETTINGS.COLOR_STYLE.LIGHT;
@ -40,18 +32,22 @@ const MenuItem = ({ navigationSetting, page, query }: MenuItemProps) => {
"inherit", "inherit",
); );
const isActive = useMemo(
() => location.pathname.indexOf(page.pageId) > -1,
[location, page.pageId],
);
const handleClick = () => {
navigateToAnotherPage();
};
return ( return (
<StyledMenuItem <StyledMenuItem
activeClassName="is-active"
borderRadius={borderRadius} borderRadius={borderRadius}
className="t--page-switch-tab" className={`t--page-switch-tab ${isActive ? "is-active" : ""}`}
navColorStyle={navColorStyle} navColorStyle={navColorStyle}
onClick={handleClick}
primaryColor={primaryColor} primaryColor={primaryColor}
to={{
pathname: trimQueryString(pageURL),
search: query,
state: { invokedBy: NavigationMethod.AppNavigation },
}}
> >
<MenuText <MenuText
name={page.pageName} name={page.pageName}

View File

@ -0,0 +1,8 @@
import type { NavigationSetting } from "constants/AppConstants";
import type { Page } from "entities/Page";
export interface MenuItemProps {
page: Page;
query: string;
navigationSetting?: NavigationSetting;
}

View File

@ -0,0 +1,57 @@
import type { Page } from "entities/Page";
import React, { useMemo } from "react";
import { useLocation } from "react-router-dom";
import { NavigationMethod } from "utils/history";
import useNavigateToAnotherPage from "../hooks/useNavigateToAnotherPage";
import MenuText from "./MenuText";
import { StyledMenuItemInDropdown } from "./MoreDropdownButton.styled";
interface MoreDropDownButtonItemProps {
page: Page;
query: string;
borderRadius: string;
primaryColor: string;
navColorStyle: string;
}
const MoreDropDownButtonItem = ({
borderRadius,
navColorStyle,
page,
primaryColor,
query,
}: MoreDropDownButtonItemProps) => {
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 (
<StyledMenuItemInDropdown
borderRadius={borderRadius}
className={`t--app-viewer-navigation-top-inline-more-dropdown-item ${
isActive ? "is-active" : ""
}`}
key={page.pageId}
onClick={handleClick}
primaryColor={primaryColor}
>
<MenuText
name={page.pageName}
navColorStyle={navColorStyle}
primaryColor={primaryColor}
/>
</StyledMenuItemInDropdown>
);
};
export default MoreDropDownButtonItem;

View File

@ -7,7 +7,6 @@ import {
} from "pages/AppViewer/utils"; } from "pages/AppViewer/utils";
import styled from "styled-components"; import styled from "styled-components";
import Button from "pages/AppViewer/AppViewerButton"; import Button from "pages/AppViewer/AppViewerButton";
import { NavLink } from "react-router-dom";
import { Menu } from "@appsmith/ads-old"; import { Menu } from "@appsmith/ads-old";
export const StyleMoreDropdownButton = styled(Button)<{ 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; borderRadius: string;
primaryColor: string; primaryColor: string;
}>` }>`

View File

@ -1,22 +1,17 @@
import React, { useState } from "react"; import { Icon } from "@appsmith/ads";
import type { NavigationSetting } from "constants/AppConstants"; import type { NavigationSetting } from "constants/AppConstants";
import { NAVIGATION_SETTINGS } from "constants/AppConstants"; import { NAVIGATION_SETTINGS } from "constants/AppConstants";
import type { Page } from "entities/Page";
import { get } from "lodash"; import { get } from "lodash";
import React, { useState } from "react";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { getSelectedAppTheme } from "selectors/appThemingSelectors"; import { getSelectedAppTheme } from "selectors/appThemingSelectors";
import { Icon } from "@appsmith/ads";
import MenuText from "./MenuText"; import MenuText from "./MenuText";
import { import {
StyledMenuDropdownContainer, StyledMenuDropdownContainer,
StyledMenuItemInDropdown,
StyleMoreDropdownButton, StyleMoreDropdownButton,
} from "./MoreDropdownButton.styled"; } from "./MoreDropdownButton.styled";
import type { Page } from "entities/Page"; import MoreDropDownButtonItem from "./MoreDropDownButtonItem";
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";
interface MoreDropdownButtonProps { interface MoreDropdownButtonProps {
navigationSetting?: NavigationSetting; navigationSetting?: NavigationSetting;
@ -42,7 +37,6 @@ const MoreDropdownButton = ({
"properties.borderRadius.appBorderRadius", "properties.borderRadius.appBorderRadius",
"inherit", "inherit",
); );
const appMode = useSelector(getAppMode);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const TargetButton = ( const TargetButton = (
@ -83,34 +77,15 @@ const MoreDropdownButton = ({
target={TargetButton} target={TargetButton}
> >
{pages.map((page) => { {pages.map((page) => {
const pageURL =
appMode === APP_MODE.PUBLISHED
? viewerURL({
basePageId: page.basePageId,
})
: builderURL({
basePageId: page.basePageId,
});
return ( return (
<StyledMenuItemInDropdown <MoreDropDownButtonItem
activeClassName="is-active"
borderRadius={borderRadius} borderRadius={borderRadius}
className="t--app-viewer-navigation-top-inline-more-dropdown-item"
key={page.pageId} key={page.pageId}
navColorStyle={navColorStyle}
page={page}
primaryColor={primaryColor} primaryColor={primaryColor}
to={{ query={query}
pathname: trimQueryString(pageURL), />
search: query,
state: { invokedBy: NavigationMethod.AppNavigation },
}}
>
<MenuText
name={page.pageName}
navColorStyle={navColorStyle}
primaryColor={primaryColor}
/>
</StyledMenuItemInDropdown>
); );
})} })}
</StyledMenuDropdownContainer> </StyledMenuDropdownContainer>

View File

@ -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;

View File

@ -1,26 +1,24 @@
import { call, put, select } from "redux-saga/effects"; import type { ReduxAction } from "actions/ReduxActionTypes";
import { getCurrentPageId, getPageList } from "selectors/editorSelectors";
import _ from "lodash";
import { ReduxActionTypes } from "ee/constants/ReduxActionConstants"; 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 { 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 { 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 { isValidURL, matchesURLPattern } from "utils/URLUtils";
import type { TNavigateToDescription } from "workers/Evaluation/fns/navigateTo"; import type { TNavigateToDescription } from "workers/Evaluation/fns/navigateTo";
import { NavigationTargetType } from "workers/Evaluation/fns/navigateTo"; import { NavigationTargetType } from "workers/Evaluation/fns/navigateTo";
import type { SourceEntity } from "entities/AppsmithConsole"; import { TriggerFailureError } from "../errorUtils";
import type { NavigateToAnotherPagePayload } from "./types";
export enum NavigationTargetType_Dep { import type { LocationDescriptor, Path } from "history";
SAME_WINDOW = "SAME_WINDOW",
NEW_WINDOW = "NEW_WINDOW",
}
const isValidPageName = ( const isValidPageName = (
pageNameOrUrl: string, pageNameOrUrl: string,
@ -48,19 +46,14 @@ export default function* navigateActionSaga(
}); });
const appMode: APP_MODE = yield select(getAppMode); const appMode: APP_MODE = yield select(getAppMode);
const path = const urlBuilder = appMode === APP_MODE.EDIT ? builderURL : viewerURL;
appMode === APP_MODE.EDIT const path = urlBuilder({
? builderURL({ basePageId: page.basePageId,
basePageId: page.basePageId, params,
params, });
})
: viewerURL({
basePageId: page.basePageId,
params,
});
if (target === NavigationTargetType.SAME_WINDOW) { if (target === NavigationTargetType.SAME_WINDOW) {
history.push(path); yield call(pushToHistory, path);
if (currentPageId === page.pageId) { if (currentPageId === page.pageId) {
yield call(setDataUrl); yield call(setDataUrl);
@ -114,3 +107,34 @@ export default function* navigateActionSaga(
}); });
} }
} }
export function* navigateToAnyPageInApplication(
action: ReduxAction<NavigateToAnotherPagePayload>,
) {
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<AppsmithLocationState> = {
pathname: payload.pageURL,
search: payload.query,
state: payload.state,
};
history.push(historyState);
}

View File

@ -0,0 +1,7 @@
import type { AppsmithLocationState } from "utils/history";
export interface NavigateToAnotherPagePayload {
pageURL: string;
query: string;
state: AppsmithLocationState;
}

View File

@ -88,6 +88,7 @@ import {
getIsSavingEntity, getIsSavingEntity,
getLayoutOnLoadActions, getLayoutOnLoadActions,
getLayoutOnLoadIssues, getLayoutOnLoadIssues,
getLayoutOnUnloadActions,
} from "selectors/editorSelectors"; } from "selectors/editorSelectors";
import log from "loglevel"; import log from "loglevel";
import { EMPTY_RESPONSE } from "components/editorComponents/emptyResponse"; import { EMPTY_RESPONSE } from "components/editorComponents/emptyResponse";
@ -1653,6 +1654,82 @@ function* softRefreshActionsSaga() {
yield put({ type: ReduxActionTypes.SWITCH_ENVIRONMENT_SUCCESS }); 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() { export function* watchPluginActionExecutionSagas() {
yield all([ yield all([
takeLatest(ReduxActionTypes.RUN_ACTION_REQUEST, runActionSaga), takeLatest(ReduxActionTypes.RUN_ACTION_REQUEST, runActionSaga),
@ -1665,5 +1742,9 @@ export function* watchPluginActionExecutionSagas() {
executePageLoadActionsSaga, executePageLoadActionsSaga,
), ),
takeLatest(ReduxActionTypes.PLUGIN_SOFT_REFRESH, softRefreshActionsSaga), takeLatest(ReduxActionTypes.PLUGIN_SOFT_REFRESH, softRefreshActionsSaga),
takeLatest(
ReduxActionTypes.EXECUTE_PAGE_UNLOAD_ACTIONS,
executePageUnloadActionsSaga,
),
]); ]);
} }

View File

@ -5,6 +5,7 @@ import EntityNavigationFactory from "pages/Editor/EntityNavigation/factory";
import type { EntityInfo } from "pages/Editor/EntityNavigation/types"; import type { EntityInfo } from "pages/Editor/EntityNavigation/types";
import log from "loglevel"; import log from "loglevel";
import type PaneNavigation from "pages/Editor/EntityNavigation/PaneNavigation"; import type PaneNavigation from "pages/Editor/EntityNavigation/PaneNavigation";
import { navigateToAnyPageInApplication } from "./ActionExecution/NavigateActionSaga";
function* navigateEntitySaga(action: ReduxAction<EntityInfo>) { function* navigateEntitySaga(action: ReduxAction<EntityInfo>) {
try { try {
@ -23,5 +24,9 @@ function* navigateEntitySaga(action: ReduxAction<EntityInfo>) {
export default function* navigationSagas() { export default function* navigationSagas() {
yield all([ yield all([
takeEvery(ReduxActionTypes.NAVIGATE_TO_ENTITY, navigateEntitySaga), takeEvery(ReduxActionTypes.NAVIGATE_TO_ENTITY, navigateEntitySaga),
takeEvery(
ReduxActionTypes.NAVIGATE_TO_ANOTHER_PAGE,
navigateToAnyPageInApplication,
),
]); ]);
} }

View File

@ -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&param2=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<NavigateToAnotherPagePayload> = {
type: "NAVIGATE_TO_PAGE", // Mock action type
payload,
};
return expectSaga(navigateToAnyPageInApplication, action)
.provide([[call(pushToHistory, payload), undefined]])
.call(pushToHistory, payload)
.run();
});
});
});

View File

@ -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();
});
});
});
});

View File

@ -30,6 +30,7 @@ import type { MainCanvasReduxState } from "ee/reducers/uiReducers/mainCanvasRedu
import { getActionEditorSavingMap } from "PluginActionEditor/store"; import { getActionEditorSavingMap } from "PluginActionEditor/store";
import { import {
getCanvasWidgets, getCanvasWidgets,
getAllJSCollectionActions,
getJSCollections, getJSCollections,
} from "ee/selectors/entitiesSelector"; } from "ee/selectors/entitiesSelector";
import { checkIsDropTarget } from "WidgetProvider/factory/helpers"; import { checkIsDropTarget } from "WidgetProvider/factory/helpers";
@ -50,6 +51,7 @@ import { getCurrentApplication } from "ee/selectors/applicationSelectors";
import type { Page } from "entities/Page"; import type { Page } from "entities/Page";
import { objectKeys } from "@appsmith/utils"; import { objectKeys } from "@appsmith/utils";
import type { MetaWidgetsReduxState } from "reducers/entityReducers/metaWidgetsReducer"; import type { MetaWidgetsReduxState } from "reducers/entityReducers/metaWidgetsReducer";
import { ActionRunBehaviour } from "PluginActionEditor/types/PluginActionTypes";
const getIsDraggingOrResizing = (state: DefaultRootState) => const getIsDraggingOrResizing = (state: DefaultRootState) =>
state.ui.widgetDragResize.isResizing || state.ui.widgetDragResize.isDragging; state.ui.widgetDragResize.isResizing || state.ui.widgetDragResize.isDragging;
@ -126,6 +128,15 @@ export const getPageSavingError = (state: DefaultRootState) => {
export const getLayoutOnLoadActions = (state: DefaultRootState) => export const getLayoutOnLoadActions = (state: DefaultRootState) =>
state.ui.editor.pageActions || []; 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) => { export const getLayoutOnLoadIssues = (state: DefaultRootState) => {
return state.ui.editor.layoutOnLoadActionErrors || []; return state.ui.editor.layoutOnLoadActionErrors || [];
}; };