diff --git a/app/client/cypress/e2e/Regression/ClientSide/OtherUIFeatures/ForkApplication_spec.js b/app/client/cypress/e2e/Regression/ClientSide/OtherUIFeatures/ForkApplication_spec.js index 62c441d5fdd..3a9eeffe9ff 100644 --- a/app/client/cypress/e2e/Regression/ClientSide/OtherUIFeatures/ForkApplication_spec.js +++ b/app/client/cypress/e2e/Regression/ClientSide/OtherUIFeatures/ForkApplication_spec.js @@ -65,7 +65,7 @@ describe("Fork application across workspaces", function () { }); cy.wait(2000); } - + cy.get("#sidebar").should("be.visible"); cy.PublishtheApp(); _.agHelper.Sleep(2000); cy.get("button:contains('Share')").first().click({ force: true }); @@ -99,8 +99,36 @@ describe("Fork application across workspaces", function () { cy.wait(10000); cy.get(applicationLocators.forkButton).first().click({ force: true }); cy.get(homePage.forkAppWorkspaceButton).should("be.visible"); + _.agHelper.GetNClick(_.locators._dialogCloseButton); + cy.LogOut(); + cy.LogintoApp(Cypress.env("USERNAME"), Cypress.env("PASSWORD")); + _.homePage.CreateNewApplication(); }); }); }); }); + + it("Mark application as forkable", () => { + _.appSettings.OpenAppSettings(); + _.appSettings.GoToEmbedSettings(); + _.embedSettings.ToggleMarkForkable(); + + _.inviteModal.OpenShareModal(); + _.homePage.InviteUserToWorkspaceFromApp( + Cypress.env("TESTUSERNAME1"), + "App Viewer", + false, + ); + _.inviteModal.CloseModal(); + + _.deployMode.DeployApp(); + cy.url().then((url) => { + forkableAppUrl = url; + cy.LogOut(); + cy.LogintoApp(Cypress.env("TESTUSERNAME1"), Cypress.env("TESTPASSWORD1")); + cy.visit(forkableAppUrl); + + _.agHelper.AssertElementVisible(applicationLocators.forkButton); + }); + }); }); diff --git a/app/client/cypress/support/Objects/CommonLocators.ts b/app/client/cypress/support/Objects/CommonLocators.ts index 3b680faa181..ac066ea6752 100644 --- a/app/client/cypress/support/Objects/CommonLocators.ts +++ b/app/client/cypress/support/Objects/CommonLocators.ts @@ -183,6 +183,7 @@ export class CommonLocators { _commentString = ".cm-comment"; _modalWrapper = "[data-testid='modal-wrapper']"; _editorBackButton = ".t--close-editor"; + _dialogCloseButton = ".ads-v2-modal__content-header-close-button"; _evaluateMsg = ".t--evaluatedPopup-error"; _canvas = "[data-testid=widgets-editor]"; _enterPreviewMode = "[data-testid='edit-mode']"; diff --git a/app/client/cypress/support/Pages/AppSettings/EmbedSettings.ts b/app/client/cypress/support/Pages/AppSettings/EmbedSettings.ts index ada2a9054ed..3792f320997 100644 --- a/app/client/cypress/support/Pages/AppSettings/EmbedSettings.ts +++ b/app/client/cypress/support/Pages/AppSettings/EmbedSettings.ts @@ -11,6 +11,8 @@ export class EmbedSettings { _restrictedText: "Embedding restricted", _disabledText: "Embedding disabled", _showNavigationBar: "[data-testid='show-navigation-bar-toggle']", + _enableForking: "[data-testid='forking-enabled-toggle']", + _confirmForking: "[data-testid='allow-forking']", }; public OpenEmbedSettings() { @@ -45,4 +47,19 @@ export class EmbedSettings { } }); } + + public ToggleMarkForkable(check: "true" | "false" = "true") { + const input = this.agHelper.GetElement(this.locators._enableForking); + input.invoke("attr", "checked").then((value) => { + if (value !== check) { + this.agHelper.GetNClick(this.locators._enableForking); + + if (check) { + this.agHelper.GetNClick(this.locators._confirmForking); + } + + this.agHelper.ValidateNetworkStatus("@updateApplication"); + } + }); + } } diff --git a/app/client/cypress/support/Pages/HomePage.ts b/app/client/cypress/support/Pages/HomePage.ts index a69f52bc17d..82c6c83e9cf 100644 --- a/app/client/cypress/support/Pages/HomePage.ts +++ b/app/client/cypress/support/Pages/HomePage.ts @@ -428,7 +428,11 @@ export class HomePage { cy.xpath(this._uploadFile).attachFile(fixtureJson); this.agHelper.Sleep(3500); } - public InviteUserToWorkspaceFromApp(email: string, role: string) { + public InviteUserToWorkspaceFromApp( + email: string, + role: string, + validate = true, + ) { const successMessage = CURRENT_REPO === REPO.CE ? "The user has been invited successfully" @@ -446,7 +450,9 @@ export class HomePage { .its("request.headers") .should("have.property", "origin", "Cypress"); // cy.contains(email, { matchCase: false }); - cy.contains(successMessage); + if (validate) { + cy.contains(successMessage); + } } public InviteUserToApplicationFromApp(email: string, role: string) { diff --git a/app/client/src/ce/actions/applicationActions.ts b/app/client/src/ce/actions/applicationActions.ts index 0361291b785..66d2210c4ff 100644 --- a/app/client/src/ce/actions/applicationActions.ts +++ b/app/client/src/ce/actions/applicationActions.ts @@ -92,6 +92,15 @@ export const updateCurrentApplicationEmbedSetting = ( }; }; +export const updateCurrentApplicationForkingEnabled = ( + forkingEnabled: boolean, +) => { + return { + type: ReduxActionTypes.CURRENT_APPLICATION_FORKING_ENABLED_UPDATE, + payload: forkingEnabled, + }; +}; + export const updateApplicationNavigationSettingAction = ( navigationSetting: NavigationSetting, ) => { diff --git a/app/client/src/ce/api/ApplicationApi.tsx b/app/client/src/ce/api/ApplicationApi.tsx index df6d59fd0f6..b648273ebee 100644 --- a/app/client/src/ce/api/ApplicationApi.tsx +++ b/app/client/src/ce/api/ApplicationApi.tsx @@ -121,6 +121,7 @@ export type UpdateApplicationPayload = { navigationSetting?: NavigationSetting; appPositioning?: AppPositioningTypeConfig; }; + forkingEnabled?: boolean; }; export type UpdateApplicationRequest = UpdateApplicationPayload & { @@ -206,6 +207,7 @@ export interface UpdateApplicationResponse { evaluationVersion: number; applicationVersion: number; isManualUpdate: boolean; + forkingEnabled: boolean; appLayout: AppLayoutConfig; new: boolean; modifiedAt: Date; diff --git a/app/client/src/ce/constants/ReduxActionConstants.tsx b/app/client/src/ce/constants/ReduxActionConstants.tsx index 9f2b2c58b97..f8265d81f15 100644 --- a/app/client/src/ce/constants/ReduxActionConstants.tsx +++ b/app/client/src/ce/constants/ReduxActionConstants.tsx @@ -516,6 +516,8 @@ const ActionTypes = { CURRENT_APPLICATION_EMBED_SETTING_UPDATE: "CURRENT_APPLICATION_EMBED_SETTING_UPDATE", UPDATE_NAVIGATION_SETTING: "UPDATE_NAVIGATION_SETTING", + CURRENT_APPLICATION_FORKING_ENABLED_UPDATE: + "CURRENT_APPLICATION_FORKING_ENABLED_UPDATE", FORK_APPLICATION_INIT: "FORK_APPLICATION_INIT", FORK_APPLICATION_SUCCESS: "FORK_APPLICATION_SUCCESS", IMPORT_APPLICATION_INIT: "IMPORT_APPLICATION_INIT", diff --git a/app/client/src/ce/constants/messages.ts b/app/client/src/ce/constants/messages.ts index 0c19b09af24..299257361c4 100644 --- a/app/client/src/ce/constants/messages.ts +++ b/app/client/src/ce/constants/messages.ts @@ -1557,6 +1557,12 @@ export const IN_APP_EMBED_SETTING = { allowEmbeddingLabel: () => "Embedding enabled", allowEmbeddingTooltip: () => "This app can be embedded in all domains, including malicious ones", + forkApplicationConfirmation: { + title: () => "Allow developers to fork this app to their workspace?", + body: () => "Forking allows developers to copy your app to their workspace", + cancel: () => "CANCEL", + confirm: () => "ALLOW FORKING", + }, copy: () => "Copy", copied: () => "Copied", limitEmbeddingLabel: () => "Embedding restricted", @@ -1574,6 +1580,10 @@ export const IN_APP_EMBED_SETTING = { sectionContentHeader: () => "Share", sectionHeaderDesc: () => "Make public, embed properties", showNavigationBar: () => "Show navigation bar", + forkContentHeader: () => "Fork", + forkLabel: () => "Make application forkable", + forkLabelTooltip: () => + "Forking allows developers to copy your app to their workspace", upgradeHeading: () => "Please contact your workspace admin to make the app public before embedding", upgradeHeadingForInviteModal: () => diff --git a/app/client/src/ce/reducers/uiReducers/applicationsReducer.tsx b/app/client/src/ce/reducers/uiReducers/applicationsReducer.tsx index 385bcde234e..420f46fbef1 100644 --- a/app/client/src/ce/reducers/uiReducers/applicationsReducer.tsx +++ b/app/client/src/ce/reducers/uiReducers/applicationsReducer.tsx @@ -570,6 +570,18 @@ export const handlers = { }, }; }, + [ReduxActionTypes.CURRENT_APPLICATION_FORKING_ENABLED_UPDATE]: ( + state: ApplicationsReduxState, + action: ReduxAction, + ) => { + return { + ...state, + currentApplication: { + ...state.currentApplication, + forkingEnabled: action.payload, + }, + }; + }, [ReduxActionTypes.UPDATE_NAVIGATION_SETTING]: ( state: ApplicationsReduxState, action: ReduxAction, diff --git a/app/client/src/ce/sagas/ApplicationSagas.tsx b/app/client/src/ce/sagas/ApplicationSagas.tsx index 8f5bcb68bfe..449fb3a0397 100644 --- a/app/client/src/ce/sagas/ApplicationSagas.tsx +++ b/app/client/src/ce/sagas/ApplicationSagas.tsx @@ -56,6 +56,7 @@ import { updateApplicationNavigationSettingAction, updateCurrentApplicationEmbedSetting, updateCurrentApplicationIcon, + updateCurrentApplicationForkingEnabled, } from "@appsmith/actions/applicationActions"; import AnalyticsUtil from "utils/AnalyticsUtil"; import { @@ -414,6 +415,13 @@ export function* updateApplicationSaga( updateCurrentApplicationEmbedSetting(response.data.embedSetting), ); } + if ("forkingEnabled" in request) { + yield put( + updateCurrentApplicationForkingEnabled( + response.data.forkingEnabled, + ), + ); + } if ( request.applicationDetail?.navigationSetting && response.data.applicationDetail?.navigationSetting diff --git a/app/client/src/pages/AppViewer/PrimaryCTA.test.tsx b/app/client/src/pages/AppViewer/PrimaryCTA.test.tsx index 447cbf4467b..a02e52df030 100644 --- a/app/client/src/pages/AppViewer/PrimaryCTA.test.tsx +++ b/app/client/src/pages/AppViewer/PrimaryCTA.test.tsx @@ -149,7 +149,11 @@ describe("App viewer fork button", () => { render( - + , ); @@ -161,7 +165,11 @@ describe("App viewer fork button", () => { render( - + , ); diff --git a/app/client/src/pages/AppViewer/PrimaryCTA.tsx b/app/client/src/pages/AppViewer/PrimaryCTA.tsx index d5410da5b26..b56d53c1e66 100644 --- a/app/client/src/pages/AppViewer/PrimaryCTA.tsx +++ b/app/client/src/pages/AppViewer/PrimaryCTA.tsx @@ -131,11 +131,12 @@ function PrimaryCTA(props: Props) { ); } - - if (!currentUser) return; + // We wait for the url to be available here to avoid showing the fork + // button for a moment and then showing the edit button i.e show one of the buttons once + // the data is available + if (!currentUser || !url) return; if ( currentApplication?.forkingEnabled && - currentApplication?.isPublic && currentUser?.username === ANONYMOUS_USERNAME ) { return ( @@ -157,7 +158,7 @@ function PrimaryCTA(props: Props) { ); } - if (currentApplication?.forkingEnabled && currentApplication?.isPublic) { + if (currentApplication?.forkingEnabled) { return (
+ + + + + ); +} + +function MakeApplicationForkable({ + application, +}: { + application: ApplicationPayload | undefined; +}) { + const dispatch = useDispatch(); + const isFetchingApplication = useSelector(getIsFetchingApplications); + const [showConfirmationModal, setShowConfirmationModal] = useState(false); + + const onChangeInit = () => { + if (!application?.forkingEnabled) { + setShowConfirmationModal(true); + } else { + onChangeConfirm(); + } + }; + + const onChangeConfirm = () => { + setShowConfirmationModal(false); + application && + dispatch( + updateApplication(application?.id, { + forkingEnabled: !application?.forkingEnabled, + currentApp: true, + }), + ); + }; + + const closeModal = () => { + setShowConfirmationModal(false); + }; + + return ( + <> +
+
+ {createMessage(IN_APP_EMBED_SETTING.forkContentHeader)} +
+
+
+
+ + + +
+
+
+ + + ); +} + +export default MakeApplicationForkable; diff --git a/app/client/src/pages/Editor/AppSettingsPane/AppSettings/EmbedSettings.tsx b/app/client/src/pages/Editor/AppSettingsPane/AppSettings/EmbedSettings/index.tsx similarity index 91% rename from app/client/src/pages/Editor/AppSettingsPane/AppSettings/EmbedSettings.tsx rename to app/client/src/pages/Editor/AppSettingsPane/AppSettings/EmbedSettings/index.tsx index 451d1541f05..1477051e3b8 100644 --- a/app/client/src/pages/Editor/AppSettingsPane/AppSettings/EmbedSettings.tsx +++ b/app/client/src/pages/Editor/AppSettingsPane/AppSettings/EmbedSettings/index.tsx @@ -19,6 +19,7 @@ import { isPermitted, PERMISSION_TYPE, } from "@appsmith/utils/permissionHelpers"; +import MakeApplicationForkable from "./MakeApplicationForkable"; import EmbedSnippetTab from "@appsmith/pages/Applications/EmbedSnippetTab"; const StyledPropertyHelpLabel = styled(PropertyHelpLabel)` @@ -47,6 +48,10 @@ function EmbedSettings() { userAppPermissions, PERMISSION_TYPE.MAKE_PUBLIC_APPLICATION, ); + const canMarkAppForkable = isPermitted( + userAppPermissions, + PERMISSION_TYPE.EXPORT_APPLICATION, + ); return (
@@ -86,6 +91,9 @@ function EmbedSettings() { )} + {canMarkAppForkable && ( + + )}
); diff --git a/app/client/src/pages/Editor/AppSettingsPane/index.tsx b/app/client/src/pages/Editor/AppSettingsPane/index.tsx index 44c7ff05a03..bb771761be2 100644 --- a/app/client/src/pages/Editor/AppSettingsPane/index.tsx +++ b/app/client/src/pages/Editor/AppSettingsPane/index.tsx @@ -15,6 +15,7 @@ function AppSettingsPane() { if (document.getElementById("save-theme-modal")) return; if (document.getElementById("delete-theme-modal")) return; if (document.getElementById("manual-upgrades-modal")) return; + if (document.getElementById("confirm-fork-modal")) return; // If logo configuration navigation setting dropdown is open if (