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