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
This commit is contained in:
Rahul Barwal 2023-05-30 14:54:38 +05:30 committed by GitHub
parent 198084f93a
commit 53ea8e208b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 158 additions and 49 deletions

View File

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

View File

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

View File

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

View File

@ -102,6 +102,7 @@ export interface DeleteApplicationRequest {
export interface ForkApplicationRequest {
applicationId: string;
workspaceId: string;
editMode?: boolean;
}
export type GetAllApplicationResponse = ApiResponse<ApplicationPagePayload[]>;

View File

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

View File

@ -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 <ForkApplicationModel />
// 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) => {

View File

@ -26,10 +26,12 @@ import type { ThemeProp } from "widgets/constants";
type NavigationMenuDataProps = ThemeProp & {
editMode: typeof noop;
setForkApplicationModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
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: () =>

View File

@ -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>(
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 !== "" ? (
<Menu
className="t--application-edit-menu"
onOpenChange={handleOnInteraction}
open={isPopoverOpen}
>
<MenuTrigger disabled={isEditing}>
<Container onClick={handleAppNameClick}>
<EditableAppName
className={props.className}
defaultSavingState={defaultSavingState}
defaultValue={defaultValue}
editInteractionKind={props.editInteractionKind}
fill={props.fill}
hideEditIcon
inputValidation={inputValidation}
isEditing={isEditing}
isEditingDefault={isEditingDefault}
isError={props.isError}
isInvalid={isInvalid}
onBlur={onBlur}
placeholder={props.placeholder}
savingState={savingState}
setIsEditing={setIsEditing}
setIsInvalid={setIsInvalid}
setSavingState={setSavingState}
/>
{!isEditing && (
<StyledIcon
name={isPopoverOpen ? "expand-less" : "down-arrow"}
size="md"
<>
<Menu
className="t--application-edit-menu"
onOpenChange={handleOnInteraction}
open={isPopoverOpen}
>
<MenuTrigger disabled={isEditing}>
<Container onClick={handleAppNameClick}>
<EditableAppName
className={props.className}
defaultSavingState={defaultSavingState}
defaultValue={defaultValue}
editInteractionKind={props.editInteractionKind}
fill={props.fill}
hideEditIcon
inputValidation={inputValidation}
isEditing={isEditing}
isEditingDefault={isEditingDefault}
isError={props.isError}
isInvalid={isInvalid}
onBlur={onBlur}
placeholder={props.placeholder}
savingState={savingState}
setIsEditing={setIsEditing}
setIsInvalid={setIsInvalid}
setSavingState={setSavingState}
/>
)}
</Container>
</MenuTrigger>
<NavigationMenu
menuItems={NavigationMenuData}
setIsPopoverOpen={setIsPopoverOpen}
{!isEditing && (
<StyledIcon
name={isPopoverOpen ? "expand-less" : "down-arrow"}
size="md"
/>
)}
</Container>
</MenuTrigger>
<NavigationMenu
menuItems={NavigationMenuData}
setIsPopoverOpen={setIsPopoverOpen}
/>
</Menu>
<ForkApplicationModal
applicationId={props.applicationId || ""}
isInEditMode
isModalOpen={isForkApplicationModalopen}
setModalClose={setForkApplicationModalOpen}
/>
</Menu>
</>
) : null;
}