From 53ea8e208b57965f37ad600e251933d03eba32fb Mon Sep 17 00:00:00 2001 From: Rahul Barwal Date: Tue, 30 May 2023 14:54:38 +0530 Subject: [PATCH] feat: Adds option to fork an app within the app editor (#23158) ## Description * Fork within application, this needs 2 things: * Load the workspaceList even when we have setModalClose variable set * When fork is successful, on the next page it should close the forking modal * Adds forking model to EditorAppName menu * Adds FETCH_APPLICATION_INIT to forkApplicationSaga * This makes sure that when we fork an app from within another app, it will reinitialize the new app properly. * Corrects workspaceId variable for forkApplicationSaga #### PR fixes following issue(s) Fixes # (issue number) #21470 #### Media ![Screenshot 2023-05-10 at 11 17 44 AM 1](https://github.com/appsmithorg/appsmith/assets/6761673/35a21154-c379-4638-b4ad-60859cc05344) #### Type of change - New feature (non-breaking change which adds functionality) ## Testing #### How Has This Been Tested? - [x] Cypress #### Test Plan > Add Testsmith test cases links that relate to this PR > > #### Issues raised during DP testing > Link issues raised during DP testing for better visiblity and tracking (copy link from comments dropped on this PR) > > > ## Checklist: #### Dev activity - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my own code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] PR is being merged under a feature flag #### QA activity: - [ ] [Speedbreak features](https://github.com/appsmithorg/TestSmith/wiki/Test-plan-implementation#speedbreaker-features-to-consider-for-every-change) have been covered - [ ] Test plan covers all impacted features and [areas of interest](https://github.com/appsmithorg/TestSmith/wiki/Guidelines-for-test-plans/_edit#areas-of-interest) - [ ] Test plan has been peer reviewed by project stakeholders and other QA members - [ ] Manually tested functionality on DP - [ ] We had an implementation alignment call with stakeholders post QA Round 2 - [ ] Cypress test cases have been added and approved by SDET/manual QA - [ ] Added `Test Plan Approved` label after Cypress tests were reviewed - [ ] Added `Test Plan Approved` label after JUnit tests were reviewed --- .../ForkApplicationWithinAppEditor_spec.ts | 47 ++++++++++ .../cypress/support/Objects/CommonLocators.ts | 1 + app/client/cypress/support/Pages/HomePage.ts | 7 +- app/client/src/ce/api/ApplicationApi.tsx | 1 + app/client/src/ce/sagas/ApplicationSagas.tsx | 19 +++- .../Applications/ForkApplicationModal.tsx | 34 ++++++-- .../EditorAppName/NavigationMenuData.ts | 12 +++ .../src/pages/Editor/EditorAppName/index.tsx | 86 +++++++++++-------- 8 files changed, 158 insertions(+), 49 deletions(-) create mode 100644 app/client/cypress/e2e/Regression/ClientSide/OtherUIFeatures/ForkApplicationWithinAppEditor_spec.ts diff --git a/app/client/cypress/e2e/Regression/ClientSide/OtherUIFeatures/ForkApplicationWithinAppEditor_spec.ts b/app/client/cypress/e2e/Regression/ClientSide/OtherUIFeatures/ForkApplicationWithinAppEditor_spec.ts new file mode 100644 index 0000000000..7af12b2355 --- /dev/null +++ b/app/client/cypress/e2e/Regression/ClientSide/OtherUIFeatures/ForkApplicationWithinAppEditor_spec.ts @@ -0,0 +1,47 @@ +import * as _ from "../../../../support/Objects/ObjectsCore"; + +const dsl = require("../../../../fixtures/basicDsl.json"); + +let forkedApplicationDsl; +let parentApplicationDsl: any; + +describe("Fork application across workspaces", function () { + before(() => { + _.agHelper.AddDsl(dsl); + }); + + it("1. Signed user should be able to fork a public forkable app & Check if the forked application has the same dsl as the original", function () { + const appname: string = localStorage.getItem("AppName") || "randomApp"; + _.entityExplorer.SelectEntityByName("Input1"); + + cy.intercept("PUT", "/api/v1/layouts/*/pages/*").as("inputUpdate"); + _.propPane.TypeTextIntoField("defaultvalue", "A"); + cy.wait("@inputUpdate").then((response) => { + response.response && + (parentApplicationDsl = response.response.body.data.dsl); + }); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(2000); + _.homePage.NavigateToHome(); + _.homePage.FilterApplication(appname); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.get(_.homePage._applicationCard).first().trigger("mouseover"); + cy.get(_.homePage._appEditIcon).first().click({ force: true }); + + cy.get(_.homePage._applicationName).click({ force: true }); + cy.contains("Fork Application").click({ force: true }); + + cy.get(_.locators._forkAppToWorkspaceBtn).click({ force: true }); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait("@postForkAppWorkspace").its("status").should("equal", 200); + // check that forked application has same dsl + cy.get("@getPage") + .its("response.body.data") + .then((data) => { + forkedApplicationDsl = data.layouts[0].dsl; + expect(JSON.stringify(forkedApplicationDsl)).to.contain( + JSON.stringify(parentApplicationDsl), + ); + }); + }); +}); diff --git a/app/client/cypress/support/Objects/CommonLocators.ts b/app/client/cypress/support/Objects/CommonLocators.ts index 4e9a1eb649..217677bb95 100644 --- a/app/client/cypress/support/Objects/CommonLocators.ts +++ b/app/client/cypress/support/Objects/CommonLocators.ts @@ -213,4 +213,5 @@ export class CommonLocators { `//*[text()='${popupname}']/following-sibling::button`; _selectByValue = (value: string) => `//button[contains(@class, 't--open-dropdown-${value}')]`; + _forkAppToWorkspaceBtn = ".t--fork-app-to-workspace-button"; } diff --git a/app/client/cypress/support/Pages/HomePage.ts b/app/client/cypress/support/Pages/HomePage.ts index b7dd1d2b72..0427a0d23a 100644 --- a/app/client/cypress/support/Pages/HomePage.ts +++ b/app/client/cypress/support/Pages/HomePage.ts @@ -45,13 +45,14 @@ export class HomePage { public _closeBtn = ".ads-v2-modal__content-header-close-button"; private _appHome = "//a[@href='/applications']"; _applicationCard = ".t--application-card"; + _appEditIcon = ".t--application-edit-link"; _homeIcon = ".t--appsmith-logo"; private _homeAppsmithImage = "a.t--appsmith-logo"; private _appContainer = ".t--applications-container"; _homePageAppCreateBtn = this._appContainer + " .createnew"; private _existingWorkspaceCreateNewApp = (existingWorkspaceName: string) => `//span[text()='${existingWorkspaceName}']/ancestor::div[contains(@class, 't--workspace-section')]//button[contains(@class, 't--new-button')]`; - private _applicationName = ".t--application-name"; + _applicationName = ".t--application-name"; private _editAppName = "bp3-editable-text-editing"; private _appMenu = ".ads-v2-menu__menu-item-children"; _buildFromDataTableActionCard = "[data-testid='generate-app']"; @@ -323,10 +324,10 @@ export class HomePage { .should("be.enabled"); } - public FilterApplication(appName: string, workspaceId: string) { + public FilterApplication(appName: string, workspaceId?: string) { cy.get(this._searchInput).type(appName, { force: true }); this.agHelper.Sleep(2000); - cy.get(this._appContainer).contains(workspaceId); + workspaceId && cy.get(this._appContainer).contains(workspaceId); cy.xpath(this.locator._spanButton("Share")).first().should("be.visible"); } diff --git a/app/client/src/ce/api/ApplicationApi.tsx b/app/client/src/ce/api/ApplicationApi.tsx index 15f7382c8b..f581686254 100644 --- a/app/client/src/ce/api/ApplicationApi.tsx +++ b/app/client/src/ce/api/ApplicationApi.tsx @@ -102,6 +102,7 @@ export interface DeleteApplicationRequest { export interface ForkApplicationRequest { applicationId: string; workspaceId: string; + editMode?: boolean; } export type GetAllApplicationResponse = ApiResponse; diff --git a/app/client/src/ce/sagas/ApplicationSagas.tsx b/app/client/src/ce/sagas/ApplicationSagas.tsx index e366858ff5..5354591383 100644 --- a/app/client/src/ce/sagas/ApplicationSagas.tsx +++ b/app/client/src/ce/sagas/ApplicationSagas.tsx @@ -625,7 +625,10 @@ export function* forkApplicationSaga( application: ApplicationResponsePayload; isPartialImport: boolean; unConfiguredDatasourceList: Datasource[]; - }> = yield call(ApplicationApi.forkApplication, action.payload); + }> = yield call(ApplicationApi.forkApplication, { + applicationId: action.payload.applicationId, + workspaceId: action.payload.workspaceId, + }); const isValidResponse: boolean = yield validateResponse(response); if (isValidResponse) { yield put(resetCurrentApplication()); @@ -644,12 +647,24 @@ export function* forkApplicationSaga( yield put({ type: ReduxActionTypes.SET_CURRENT_WORKSPACE_ID, payload: { - id: action.payload.workspaceId, + workspaceId: action.payload.workspaceId, }, }); const pageURL = builderURL({ pageId: application.defaultPageId as string, }); + + if (action.payload.editMode) { + const appId = application.id; + const pageId = application.defaultPageId; + yield put({ + type: ReduxActionTypes.FETCH_APPLICATION_INIT, + payload: { + applicationId: appId, + pageId, + }, + }); + } history.push(pageURL); const isEditorInitialized: boolean = yield select(getIsEditorInitialized); diff --git a/app/client/src/pages/Applications/ForkApplicationModal.tsx b/app/client/src/pages/Applications/ForkApplicationModal.tsx index 47e8d1a42b..4f70091bd7 100644 --- a/app/client/src/pages/Applications/ForkApplicationModal.tsx +++ b/app/client/src/pages/Applications/ForkApplicationModal.tsx @@ -36,6 +36,7 @@ type ForkApplicationModalProps = { trigger?: React.ReactNode; isModalOpen?: boolean; setModalClose?: (isOpen: boolean) => void; + isInEditMode?: boolean; }; function ForkApplicationModal(props: ForkApplicationModalProps) { @@ -55,17 +56,35 @@ function ForkApplicationModal(props: ForkApplicationModalProps) { const queryParams = new URLSearchParams(location.search); useEffect(() => { - if (queryParams.get("fork") === "true" || isModalOpen) { + if (queryParams.get("fork") === "true") { handleOpen(); } }, []); + useEffect(() => { + // This effect makes sure that no if + // is getting controlled from outside, then we always load workspaces + if (isModalOpen) { + handleOpen(); + return; + } + }, [isModalOpen]); + + useEffect(() => { + // when we fork from within the appeditor, fork modal remains open + // even on the landing page of "Forked" app, this closes it + const shouldCloseForcibly = + !forkingApplication && isModalOpen && setModalClose; + shouldCloseForcibly && setModalClose(false); + }, [forkingApplication]); + const forkApplication = () => { dispatch({ type: ReduxActionTypes.FORK_APPLICATION_INIT, payload: { applicationId: props.applicationId, workspaceId: workspace?.value, + editMode: props.isInEditMode, }, }); }; @@ -110,13 +129,14 @@ function ForkApplicationModal(props: ForkApplicationModalProps) { }; const handleOpen = () => { - // TODO: removed if condition here. Ensure it will affect something or not. - const url = new URL(window.location.href); - if (!url.searchParams.has("fork")) { - url.searchParams.append("fork", "true"); - history.push(url.toString().slice(url.origin.length)); + if (!props.setModalClose) { + const url = new URL(window.location.href); + if (!url.searchParams.has("fork")) { + url.searchParams.append("fork", "true"); + history.push(url.toString().slice(url.origin.length)); + } } - dispatch(getAllApplications()); + !workspaceList.length && dispatch(getAllApplications()); }; const handleOnOpenChange = (isOpen: boolean) => { diff --git a/app/client/src/pages/Editor/EditorAppName/NavigationMenuData.ts b/app/client/src/pages/Editor/EditorAppName/NavigationMenuData.ts index 105404940f..0f4e2261e7 100644 --- a/app/client/src/pages/Editor/EditorAppName/NavigationMenuData.ts +++ b/app/client/src/pages/Editor/EditorAppName/NavigationMenuData.ts @@ -26,10 +26,12 @@ import type { ThemeProp } from "widgets/constants"; type NavigationMenuDataProps = ThemeProp & { editMode: typeof noop; + setForkApplicationModalOpen: React.Dispatch>; }; export const GetNavigationMenuData = ({ editMode, + setForkApplicationModalOpen, }: NavigationMenuDataProps): MenuItemData[] => { const dispatch = useDispatch(); const history = useHistory(); @@ -43,6 +45,10 @@ export const GetNavigationMenuData = ({ currentApplication?.userPermissions ?? [], PERMISSION_TYPE.EXPORT_APPLICATION, ); + const hasEditPermission = isPermitted( + currentApplication?.userPermissions ?? [], + PERMISSION_TYPE.MANAGE_APPLICATION, + ); const openExternalLink = useCallback((link: string) => { if (link) { window.open(link, "_blank"); @@ -148,6 +154,12 @@ export const GetNavigationMenuData = ({ }, ], }, + { + text: "Fork Application", + onClick: () => setForkApplicationModalOpen(true), + type: MenuTypes.MENU, + isVisible: isApplicationIdPresent && hasEditPermission, + }, { text: "Export application", onClick: () => diff --git a/app/client/src/pages/Editor/EditorAppName/index.tsx b/app/client/src/pages/Editor/EditorAppName/index.tsx index 1a1ebf0003..0ac421a19c 100644 --- a/app/client/src/pages/Editor/EditorAppName/index.tsx +++ b/app/client/src/pages/Editor/EditorAppName/index.tsx @@ -13,6 +13,7 @@ import { GetNavigationMenuData } from "./NavigationMenuData"; import { NavigationMenu } from "./NavigationMenu"; import type { Theme } from "constants/DefaultTheme"; import { Icon, Menu, toast, MenuTrigger } from "design-system"; +import ForkApplicationModal from "pages/Applications/ForkApplicationModal"; type EditorAppNameProps = CommonComponentProps & { applicationId: string | undefined; @@ -80,6 +81,8 @@ export function EditorAppName(props: EditorAppNameProps) { const [savingState, setSavingState] = useState( SavingState.NOT_STARTED, ); + const [isForkApplicationModalopen, setForkApplicationModalOpen] = + useState(false); const onBlur = (value: string) => { if (props.onBlur) props.onBlur(value); @@ -123,48 +126,57 @@ export function EditorAppName(props: EditorAppNameProps) { const NavigationMenuData = GetNavigationMenuData({ editMode, theme, + setForkApplicationModalOpen, }); return defaultValue !== "" ? ( - - - - - {!isEditing && ( - + + + + - )} - - - + )} + + + + + - + ) : null; }