diff --git a/app/client/cypress/integration/Smoke_TestSuite/Pages/Pages_spec.js b/app/client/cypress/integration/Smoke_TestSuite/Pages/Pages_spec.js new file mode 100644 index 0000000000..420a709de5 --- /dev/null +++ b/app/client/cypress/integration/Smoke_TestSuite/Pages/Pages_spec.js @@ -0,0 +1,18 @@ +const pages = require("../../../locators/Pages.json"); + +describe("Pages", function() { + it("Clone page", function() { + cy.xpath(pages.popover) + .last() + .click({ force: true }); + cy.get(pages.clonePage).click({ force: true }); + + cy.wait("@clonePage").should( + "have.nested.property", + "response.body.responseMeta.status", + 201, + ); + + cy.get(".t--entity-name:contains(Page1 Copy)"); + }); +}); diff --git a/app/client/cypress/locators/Pages.json b/app/client/cypress/locators/Pages.json index a718431a0b..8ef9748aa0 100644 --- a/app/client/cypress/locators/Pages.json +++ b/app/client/cypress/locators/Pages.json @@ -16,6 +16,7 @@ "entityExplorer": ".t--nav-link-entity-explorer", "popover": "//div[contains(@class,'t--entity page')]//*[local-name()='g' and @id='Icon/Outline/more-vertical']", "editName": ".single-select >div:contains('Edit Name')", + "clonePage": ".single-select >div:contains('Clone')", "deletePage": ".single-select >div:contains('Delete')", "entityQuery": ".t--entity-name:contains('Queries')" } \ No newline at end of file diff --git a/app/client/cypress/support/commands.js b/app/client/cypress/support/commands.js index 87da3f2c72..9b4a491d2c 100644 --- a/app/client/cypress/support/commands.js +++ b/app/client/cypress/support/commands.js @@ -1478,6 +1478,7 @@ Cypress.Commands.add("startServerAndRoutes", () => { ); cy.route("GET", "/api/v1/users/me").as("getUser"); cy.route("POST", "/api/v1/pages").as("createPage"); + cy.route("POST", "/api/v1/pages/clone/*").as("clonePage"); }); Cypress.Commands.add("alertValidate", text => { diff --git a/app/client/src/actions/pageActions.tsx b/app/client/src/actions/pageActions.tsx index 8d0cbc7324..ff0122138e 100644 --- a/app/client/src/actions/pageActions.tsx +++ b/app/client/src/actions/pageActions.tsx @@ -105,6 +105,30 @@ export const createPage = (applicationId: string, pageName: string) => { }; }; +export const clonePageInit = (pageId: string) => { + return { + type: ReduxActionTypes.CLONE_PAGE_INIT, + payload: { + id: pageId, + }, + }; +}; + +export const clonePageSuccess = ( + pageId: string, + pageName: string, + layoutId: string, +) => { + return { + type: ReduxActionTypes.CLONE_PAGE_SUCCESS, + payload: { + pageId, + pageName, + layoutId, + }, + }; +}; + export const updatePage = (id: string, name: string) => { return { type: ReduxActionTypes.UPDATE_PAGE_INIT, diff --git a/app/client/src/api/PageApi.tsx b/app/client/src/api/PageApi.tsx index dfd0d150fc..4344fb22d6 100644 --- a/app/client/src/api/PageApi.tsx +++ b/app/client/src/api/PageApi.tsx @@ -78,6 +78,10 @@ export interface DeletePageRequest { id: string; } +export interface ClonePageRequest { + id: string; +} + export interface UpdateWidgetNameRequest { pageId: string; layoutId: string; @@ -150,6 +154,10 @@ class PageApi extends Api { return Api.delete(PageApi.url + "/" + request.id); } + static clonePage(request: ClonePageRequest): AxiosPromise { + return Api.post(PageApi.url + "/clone/" + request.id); + } + static updateWidgetName( request: UpdateWidgetNameRequest, ): AxiosPromise { diff --git a/app/client/src/constants/ReduxActionConstants.tsx b/app/client/src/constants/ReduxActionConstants.tsx index 7a50182ef2..51911c992a 100644 --- a/app/client/src/constants/ReduxActionConstants.tsx +++ b/app/client/src/constants/ReduxActionConstants.tsx @@ -161,6 +161,8 @@ export const ReduxActionTypes: { [key: string]: string } = { DELETE_APPLICATION_SUCCESS: "DELETE_APPLICATION_SUCCESS", DELETE_PAGE_INIT: "DELETE_PAGE_INIT", DELETE_PAGE_SUCCESS: "DELETE_PAGE_SUCCESS", + CLONE_PAGE_INIT: "CLONE_PAGE_INIT", + CLONE_PAGE_SUCCESS: "CLONE_PAGE_SUCCESS", SET_DEFAULT_APPLICATION_PAGE_INIT: "SET_DEFAULT_APPLICATION_PAGE_INIT", SET_DEFAULT_APPLICATION_PAGE_SUCCESS: "SET_DEFAULT_APPLICATION_PAGE_SUCCESS", CREATE_ORGANIZATION_INIT: "CREATE_ORGANIZATION_INIT", @@ -312,6 +314,7 @@ export const ReduxActionErrorTypes: { [key: string]: string } = { MOVE_ACTION_ERROR: "MOVE_ACTION_ERROR", COPY_ACTION_ERROR: "COPY_ACTION_ERROR", DELETE_PAGE_ERROR: "DELETE_PAGE_ERROR", + CLONE_PAGE_ERROR: "CLONE_PAGE_ERROR", DELETE_APPLICATION_ERROR: "DELETE_APPLICATION_ERROR", SET_DEFAULT_APPLICATION_PAGE_ERROR: "SET_DEFAULT_APPLICATION_PAGE_ERROR", CREATE_ORGANIZATION_ERROR: "CREATE_ORGANIZATION_ERROR", @@ -394,6 +397,13 @@ export interface Page { latest?: boolean; } +export interface ClonePageSuccessPayload { + pageName: string; + pageId: string; + layoutId: string; + isDefault: boolean; +} + export type PageListPayload = Array; export type ApplicationPayload = { diff --git a/app/client/src/pages/Editor/Explorer/Pages/PageContextMenu.tsx b/app/client/src/pages/Editor/Explorer/Pages/PageContextMenu.tsx index 5ac771bfab..6deffd7340 100644 --- a/app/client/src/pages/Editor/Explorer/Pages/PageContextMenu.tsx +++ b/app/client/src/pages/Editor/Explorer/Pages/PageContextMenu.tsx @@ -9,6 +9,7 @@ import { ReduxActionTypes } from "constants/ReduxActionConstants"; import AnalyticsUtil from "utils/AnalyticsUtil"; import { ContextMenuPopoverModifiers } from "../helpers"; import { initExplorerEntityNameEdit } from "actions/explorerActions"; +import { clonePageInit } from "actions/pageActions"; export const PageContextMenu = (props: { pageId: string; @@ -52,12 +53,22 @@ export const PageContextMenu = (props: { [dispatch, props.pageId], ); + const clonePage = useCallback(() => dispatch(clonePageInit(props.pageId)), [ + dispatch, + props.pageId, + ]); + const optionTree: TreeDropdownOption[] = [ { value: "rename", onSelect: editPageName, label: "Edit Name", }, + { + value: "clone", + onSelect: clonePage, + label: "Clone", + }, ]; if (!props.isDefaultPage) { optionTree.push({ diff --git a/app/client/src/reducers/entityReducers/pageListReducer.tsx b/app/client/src/reducers/entityReducers/pageListReducer.tsx index c5b8696160..e7fbb3bd78 100644 --- a/app/client/src/reducers/entityReducers/pageListReducer.tsx +++ b/app/client/src/reducers/entityReducers/pageListReducer.tsx @@ -3,6 +3,7 @@ import { ReduxAction, ReduxActionTypes, PageListPayload, + ClonePageSuccessPayload, } from "constants/ReduxActionConstants"; const initialState: PageListReduxState = { @@ -51,6 +52,17 @@ const pageListReducer = createReducer(initialState, { _state.pages.push({ ...action.payload, latest: true }); return { ..._state }; }, + [ReduxActionTypes.CLONE_PAGE_SUCCESS]: ( + state: PageListReduxState, + action: ReduxAction, + ): PageListReduxState => { + return { + ...state, + pages: state.pages + .map(page => ({ ...page, latest: false })) + .concat([{ ...action.payload, latest: true }]), + }; + }, [ReduxActionTypes.SET_DEFAULT_APPLICATION_PAGE_SUCCESS]: ( state: PageListReduxState, action: ReduxAction<{ pageId: string; applicationId: string }>, diff --git a/app/client/src/reducers/uiReducers/editorReducer.tsx b/app/client/src/reducers/uiReducers/editorReducer.tsx index d3b6bb25ca..2276b126f7 100644 --- a/app/client/src/reducers/uiReducers/editorReducer.tsx +++ b/app/client/src/reducers/uiReducers/editorReducer.tsx @@ -22,6 +22,8 @@ const initialState: EditorReduxState = { isPageSwitching: false, creatingPage: false, creatingPageError: false, + cloningPage: false, + cloningPageError: false, updatingWidgetName: false, updateWidgetNameError: false, }, @@ -111,6 +113,20 @@ const editorReducer = createReducer(initialState, { currentApplicationId, }; }, + [ReduxActionTypes.CLONE_PAGE_INIT]: (state: EditorReduxState) => { + state.loadingStates.cloningPage = true; + state.loadingStates.cloningPageError = false; + return { ...state }; + }, + [ReduxActionTypes.CLONE_PAGE_ERROR]: (state: EditorReduxState) => { + state.loadingStates.cloningPageError = true; + state.loadingStates.cloningPage = false; + return { ...state }; + }, + [ReduxActionTypes.CLONE_PAGE_SUCCESS]: (state: EditorReduxState) => { + state.loadingStates.cloningPage = false; + return { ...state }; + }, [ReduxActionTypes.CREATE_PAGE_INIT]: (state: EditorReduxState) => { state.loadingStates.creatingPage = true; state.loadingStates.creatingPageError = false; @@ -162,6 +178,8 @@ export interface EditorReduxState { pageSwitchingError: boolean; creatingPage: boolean; creatingPageError: boolean; + cloningPage: boolean; + cloningPageError: boolean; updatingWidgetName: boolean; updateWidgetNameError: boolean; }; diff --git a/app/client/src/reducers/uiReducers/explorerReducer.ts b/app/client/src/reducers/uiReducers/explorerReducer.ts index 4382d76e90..eea97da4fc 100644 --- a/app/client/src/reducers/uiReducers/explorerReducer.ts +++ b/app/client/src/reducers/uiReducers/explorerReducer.ts @@ -32,6 +32,10 @@ const explorerReducer = createReducer(initialState, { [ReduxActionTypes.FETCH_PAGE_ERROR]: setEntityUpdateError, [ReduxActionTypes.FETCH_PAGE_SUCCESS]: setEntityUpdateSuccess, + [ReduxActionTypes.CLONE_PAGE_INIT]: setUpdatingEntity, + [ReduxActionTypes.CLONE_PAGE_ERROR]: setEntityUpdateError, + [ReduxActionTypes.CLONE_PAGE_SUCCESS]: setEntityUpdateSuccess, + [ReduxActionTypes.MOVE_ACTION_INIT]: setUpdatingEntity, [ReduxActionErrorTypes.MOVE_ACTION_ERROR]: setEntityUpdateError, [ReduxActionTypes.MOVE_ACTION_SUCCESS]: setEntityUpdateSuccess, diff --git a/app/client/src/sagas/PageSagas.tsx b/app/client/src/sagas/PageSagas.tsx index 13a2ef63db..4c19491595 100644 --- a/app/client/src/sagas/PageSagas.tsx +++ b/app/client/src/sagas/PageSagas.tsx @@ -10,6 +10,7 @@ import { } from "constants/ReduxActionConstants"; import { deletePageSuccess, + clonePageSuccess, fetchPageSuccess, fetchPublishedPageSuccess, savePageSuccess, @@ -31,6 +32,7 @@ import PageApi, { UpdatePageRequest, UpdateWidgetNameRequest, UpdateWidgetNameResponse, + ClonePageRequest, } from "api/PageApi"; import { FlattenedWidgetProps } from "reducers/entityReducers/canvasWidgetsReducer"; import { @@ -382,6 +384,40 @@ export function* deletePageSaga(action: ReduxAction) { } } +export function* clonePageSaga(clonePageAction: ReduxAction) { + try { + const request: ClonePageRequest = clonePageAction.payload; + const response: FetchPageResponse = yield call(PageApi.clonePage, request); + const applicationId = yield select( + (state: AppState) => state.entities.pageList.applicationId, + ); + const isValidResponse = yield validateResponse(response); + if (isValidResponse) { + yield put( + clonePageSuccess( + response.data.id, + response.data.name, + response.data.layouts[0].id, + ), + ); + yield put({ + type: ReduxActionTypes.FETCH_PAGE_DSL_INIT, + payload: { + pageId: response.data.id, + }, + }); + history.push(BUILDER_PAGE_URL(applicationId, response.data.id)); + } + } catch (error) { + yield put({ + type: ReduxActionErrorTypes.CLONE_PAGE_ERROR, + payload: { + error, + }, + }); + } +} + export function* updateWidgetNameSaga( action: ReduxAction<{ id: string; newName: string }>, ) { @@ -477,6 +513,7 @@ export default function* pageSagas() { ), takeLatest(ReduxActionTypes.UPDATE_LAYOUT, saveLayoutSaga), takeLeading(ReduxActionTypes.CREATE_PAGE_INIT, createPageSaga), + takeLeading(ReduxActionTypes.CLONE_PAGE_INIT, clonePageSaga), takeLatest(ReduxActionTypes.FETCH_PAGE_LIST_INIT, fetchPageListSaga), takeLatest(ReduxActionTypes.UPDATE_PAGE_INIT, updatePageSaga), takeLatest(ReduxActionTypes.DELETE_PAGE_INIT, deletePageSaga),