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:
parent
481988daf1
commit
b4d5685d21
|
|
@ -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']",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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 (
|
||||
<StyledMenuItem
|
||||
activeClassName="is-active"
|
||||
borderRadius={borderRadius}
|
||||
className="t--page-switch-tab"
|
||||
className={`t--page-switch-tab ${isActive ? "is-active" : ""}`}
|
||||
navColorStyle={navColorStyle}
|
||||
onClick={handleClick}
|
||||
primaryColor={primaryColor}
|
||||
to={{
|
||||
pathname: trimQueryString(pageURL),
|
||||
search: query,
|
||||
state: { invokedBy: NavigationMethod.AppNavigation },
|
||||
}}
|
||||
>
|
||||
<MenuText
|
||||
name={page.pageName}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}>`
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<StyledMenuItemInDropdown
|
||||
activeClassName="is-active"
|
||||
<MoreDropDownButtonItem
|
||||
borderRadius={borderRadius}
|
||||
className="t--app-viewer-navigation-top-inline-more-dropdown-item"
|
||||
key={page.pageId}
|
||||
primaryColor={primaryColor}
|
||||
to={{
|
||||
pathname: trimQueryString(pageURL),
|
||||
search: query,
|
||||
state: { invokedBy: NavigationMethod.AppNavigation },
|
||||
}}
|
||||
>
|
||||
<MenuText
|
||||
name={page.pageName}
|
||||
navColorStyle={navColorStyle}
|
||||
page={page}
|
||||
primaryColor={primaryColor}
|
||||
query={query}
|
||||
/>
|
||||
</StyledMenuItemInDropdown>
|
||||
);
|
||||
})}
|
||||
</StyledMenuDropdownContainer>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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({
|
||||
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<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);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import type { AppsmithLocationState } from "utils/history";
|
||||
|
||||
export interface NavigateToAnotherPagePayload {
|
||||
pageURL: string;
|
||||
query: string;
|
||||
state: AppsmithLocationState;
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<EntityInfo>) {
|
||||
try {
|
||||
|
|
@ -23,5 +24,9 @@ function* navigateEntitySaga(action: ReduxAction<EntityInfo>) {
|
|||
export default function* navigationSagas() {
|
||||
yield all([
|
||||
takeEvery(ReduxActionTypes.NAVIGATE_TO_ENTITY, navigateEntitySaga),
|
||||
takeEvery(
|
||||
ReduxActionTypes.NAVIGATE_TO_ANOTHER_PAGE,
|
||||
navigateToAnyPageInApplication,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
433
app/client/src/sagas/__tests__/NavigateActionSaga.test.ts
Normal file
433
app/client/src/sagas/__tests__/NavigateActionSaga.test.ts
Normal 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¶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<NavigateToAnotherPagePayload> = {
|
||||
type: "NAVIGATE_TO_PAGE", // Mock action type
|
||||
payload,
|
||||
};
|
||||
|
||||
return expectSaga(navigateToAnyPageInApplication, action)
|
||||
.provide([[call(pushToHistory, payload), undefined]])
|
||||
.call(pushToHistory, payload)
|
||||
.run();
|
||||
});
|
||||
});
|
||||
});
|
||||
394
app/client/src/sagas/__tests__/onPageUnloadSaga.test.ts
Normal file
394
app/client/src/sagas/__tests__/onPageUnloadSaga.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 || [];
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user