From 3a6da2d797b576b6a7424d88ba8eff9ef2ced296 Mon Sep 17 00:00:00 2001 From: Kaushik Varanasi Date: Thu, 3 Jun 2021 11:48:08 +0530 Subject: [PATCH] Feature/import applications (#4483) * Added export option to app menu. TODO: call api to download app file * Added checkbox component and removed unused code * Added import app without filepicker. Opens modal * added ability to fetch the exported app * can download exported application as a json file * Updated the file picker component to accept other file formats * WIP import app * Added functionality to import application json file * minor fixes * Made the file type prop mandatory for file picker * added a test suite for export app * Test added to check if on import application click, it open a modal * added a dummy application file for cypress testing * Added end to end integration test suite to verify import app feature * added test to verify the export api status and download file. * added a linked btn to carry exporting. - according to latest BE changes * Removed old redux and saga mechanism for app export * updated cypress test to validate new flow * fixed minor linting errors * updated test case title * updated the test cases for import/export app feat * review changes * added prop to facilitate delayed upload * added new application file to fixtures. Minor fix to take care of loading state. * Removed export app modal. Added one click action, to download the file. * Updated File picker to work with all other files acc to the design. * Updated the import modal * updated the import application test * Added remove upload tooltip * updated the icons for import/export actions * removed unused logs * added hard coded feature flag to hide/show import export feature Co-authored-by: Pranav Kanade --- .../cypress/fixtures/application-file.json | 174 ++++++++++++++ .../Applications/ExportApplication_spec.js | 26 +++ .../OrgImportApplication_spec.js | 39 ++++ app/client/cypress/locators/HomePage.json | 4 + app/client/cypress/support/commands.js | 2 + app/client/src/actions/applicationActions.ts | 12 +- app/client/src/api/ApplicationApi.tsx | 22 ++ app/client/src/assets/icons/ads/download.svg | 3 + .../src/assets/icons/ads/upload_success.svg | 3 + app/client/src/components/ads/FilePicker.tsx | 219 ++++++++++++++---- app/client/src/components/ads/Icon.tsx | 12 + .../components/stories/FilePicker.stories.tsx | 11 +- .../src/constants/ReduxActionConstants.tsx | 3 + app/client/src/constants/messages.ts | 5 +- .../pages/Applications/ApplicationCard.tsx | 30 +++ .../src/pages/Applications/ForkModalStyles.ts | 3 +- .../Applications/ImportApplicationModal.tsx | 117 ++++++++++ app/client/src/pages/Applications/index.tsx | 29 ++- app/client/src/pages/organization/General.tsx | 2 + .../uiReducers/applicationsReducer.tsx | 26 +++ app/client/src/sagas/ApplicationSagas.tsx | 52 +++++ app/client/src/selectors/{ui.ts => ui.tsx} | 0 22 files changed, 744 insertions(+), 50 deletions(-) create mode 100644 app/client/cypress/fixtures/application-file.json create mode 100644 app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Applications/ExportApplication_spec.js create mode 100644 app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/OrganisationTests/OrgImportApplication_spec.js create mode 100644 app/client/src/assets/icons/ads/download.svg create mode 100644 app/client/src/assets/icons/ads/upload_success.svg create mode 100644 app/client/src/pages/Applications/ImportApplicationModal.tsx rename app/client/src/selectors/{ui.ts => ui.tsx} (100%) diff --git a/app/client/cypress/fixtures/application-file.json b/app/client/cypress/fixtures/application-file.json new file mode 100644 index 0000000000..5a1193ac1d --- /dev/null +++ b/app/client/cypress/fixtures/application-file.json @@ -0,0 +1,174 @@ +{ + "exportedApplication": { + "userPermissions": [ + "canComment:applications", + "manage:applications", + "read:applications", + "publish:applications", + "makePublic:applications" + ], + "name": "testing app - pk", + "isPublic": false, + "appIsExample": false, + "color": "#FE9F44", + "icon": "heart", + "new": true + }, + "datasourceList": [], + "pageList": [ + { + "userPermissions": [ + "read:pages", + "manage:pages" + ], + "unpublishedPage": { + "name": "Page1", + "layouts": [ + { + "id": "60a77186cdbfc9440388285c", + "userPermissions": [], + "dsl": { + "widgetName": "MainContainer", + "backgroundColor": "none", + "rightColumn": 1118, + "snapColumns": 16, + "detachFromLayout": true, + "widgetId": "0", + "topRow": 0, + "bottomRow": 1280, + "containerStyle": "none", + "snapRows": 33, + "parentRowSpace": 1, + "type": "CANVAS_WIDGET", + "canExtend": true, + "version": 18, + "minHeight": 1292, + "parentColumnSpace": 1, + "dynamicTriggerPathList": [], + "dynamicBindingPathList": [], + "leftColumn": 0, + "children": [ + { + "widgetName": "Button1", + "rightColumn": 4, + "isDefaultClickDisabled": true, + "widgetId": "g7jf8v3wkq", + "buttonStyle": "PRIMARY_BUTTON", + "topRow": 0, + "bottomRow": 1, + "parentRowSpace": 40, + "isVisible": true, + "type": "BUTTON_WIDGET", + "version": 1, + "parentId": "0", + "isLoading": false, + "parentColumnSpace": 67.375, + "leftColumn": 2, + "text": "Submit", + "isDisabled": false + }, + { + "widgetName": "Chart1", + "rightColumn": 8, + "allowHorizontalScroll": false, + "widgetId": "ow55pc4z0z", + "topRow": 5, + "bottomRow": 13, + "parentRowSpace": 40, + "isVisible": true, + "type": "CHART_WIDGET", + "version": 1, + "parentId": "0", + "isLoading": false, + "chartData": { + "pftw37090s": { + "seriesName": "Sales", + "data": [ + { + "x": "Mon", + "y": 10000 + }, + { + "x": "Tue", + "y": 12000 + }, + { + "x": "Wed", + "y": 32000 + }, + { + "x": "Thu", + "y": 28000 + }, + { + "x": "Fri", + "y": 14000 + }, + { + "x": "Sat", + "y": 19000 + }, + { + "x": "Sun", + "y": 36000 + } + ] + } + }, + "yAxisName": "Total Order Revenue $", + "parentColumnSpace": 67.375, + "chartName": "Last week's revenue", + "leftColumn": 2, + "xAxisName": "Last Week", + "chartType": "LINE_CHART" + } + ] + }, + "layoutOnLoadActions": [], + "new": false + } + ], + "userPermissions": [] + }, + "publishedPage": { + "name": "Page1", + "layouts": [ + { + "id": "60a77186cdbfc9440388285c", + "userPermissions": [], + "dsl": { + "widgetName": "MainContainer", + "backgroundColor": "none", + "rightColumn": 1224, + "snapColumns": 16, + "detachFromLayout": true, + "widgetId": "0", + "topRow": 0, + "bottomRow": 1254, + "containerStyle": "none", + "snapRows": 33, + "parentRowSpace": 1, + "type": "CANVAS_WIDGET", + "canExtend": true, + "version": 4, + "minHeight": 1292, + "parentColumnSpace": 1, + "dynamicBindingPathList": [], + "leftColumn": 0, + "children": [] + }, + "new": false + } + ], + "userPermissions": [] + }, + "new": true + } + ], + "publishedDefaultPageName": "Page1", + "unpublishedDefaultPageName": "Page1", + "actionList": [], + "decryptedFields": {}, + "publishedLayoutmongoEscapedWidgets": {}, + "unpublishedLayoutmongoEscapedWidgets": {} +} \ No newline at end of file diff --git a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Applications/ExportApplication_spec.js b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Applications/ExportApplication_spec.js new file mode 100644 index 0000000000..1c8bbc29a0 --- /dev/null +++ b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Applications/ExportApplication_spec.js @@ -0,0 +1,26 @@ +const dsl = require("../../../../fixtures/displayWidgetDsl.json"); +const homePage = require("../../../../locators/HomePage.json"); +const commonlocators = require("../../../../locators/commonlocators.json"); + +describe("Export application as a JSON file", function() { + before(() => { + cy.addDsl(dsl); + }); + + it("Check if exporting app flow works as expected", function() { + cy.get(commonlocators.homeIcon).click({ force: true }); + const appname = localStorage.getItem("AppName"); + cy.get(homePage.searchInput).type(appname); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(2000); + + cy.get(homePage.applicationCard) + .first() + .trigger("mouseover"); + cy.get(homePage.appMoreIcon) + .first() + .click({ force: true }); + cy.get(homePage.exportAppFromMenu).click({ force: true }); + cy.get(homePage.toastMessage).should("contain", "Successfully exported"); + }); +}); diff --git a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/OrganisationTests/OrgImportApplication_spec.js b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/OrganisationTests/OrgImportApplication_spec.js new file mode 100644 index 0000000000..620b05e0aa --- /dev/null +++ b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/OrganisationTests/OrgImportApplication_spec.js @@ -0,0 +1,39 @@ +const homePage = require("../../../../locators/HomePage.json"); + +describe("Organization Import Application", function() { + let orgid; + let newOrganizationName; + const fixtureDummyAppPath = "application-file.json"; + it("Can Import Application", function() { + cy.NavigateToHome(); + cy.generateUUID().then((uid) => { + orgid = uid; + localStorage.setItem("OrgName", orgid); + cy.createOrg(); + cy.wait("@createOrg").then((createOrgInterception) => { + newOrganizationName = createOrgInterception.response.body.data.name; + cy.renameOrg(newOrganizationName, orgid); + cy.get(homePage.orgImportAppOption).click({ force: true }); + + cy.get(homePage.orgImportAppModal).should("be.visible"); + cy.xpath(homePage.uploadLogo).attachFile(fixtureDummyAppPath); + + cy.get(homePage.orgImportAppButton).click({ force: true }); + cy.wait("@importNewApplication").then((interception) => { + let appId = interception.response.body.data.id; + let defaultPage = interception.response.body.data.pages.find( + (eachPage) => !!eachPage.isDefault, + ); + cy.get(homePage.toastMessage).should( + "contain", + "Application imported successfully", + ); + cy.url().should( + "include", + `/applications/${appId}/pages/${defaultPage.id}/edit`, + ); + }); + }); + }); + }); +}); diff --git a/app/client/cypress/locators/HomePage.json b/app/client/cypress/locators/HomePage.json index fbc854261f..39bfc865de 100644 --- a/app/client/cypress/locators/HomePage.json +++ b/app/client/cypress/locators/HomePage.json @@ -9,6 +9,7 @@ "appMoreIcon": ".bp3-popover-wrapper.more .bp3-popover-target", "duplicateApp": "[data-cy=t--duplicate]", "forkAppFromMenu": "[data-cy=t--fork-app]", + "exportAppFromMenu": "[data-cy=t--export-app]", "forkAppOrgList": ".radio-group", "forkAppOrgButton": "[data-cy=t--fork-app-to-org-button]", "selectAction": "#Base", @@ -59,6 +60,9 @@ "applicationColorSelector": ".t--color-not-selected", "applicationBackgroundColor": ".t--application-card-background", "orgSettingOption": "[data-cy=t--org-setting]", + "orgImportAppOption": "[data-cy=t--org-import-app]", + "orgImportAppModal": ".t--import-application-modal", + "orgImportAppButton": "[data-cy=t--org-import-app-button]", "orgNameInput": "[data-cy=t--org-name-input]", "renameOrgInput": "[data-cy=t--org-rename-input]", "orgEmailInput": "[data-cy=t--org-email-input]", diff --git a/app/client/cypress/support/commands.js b/app/client/cypress/support/commands.js index eda98ece15..1e49791eaa 100644 --- a/app/client/cypress/support/commands.js +++ b/app/client/cypress/support/commands.js @@ -2212,6 +2212,8 @@ Cypress.Commands.add("startServerAndRoutes", () => { cy.route("PUT", "/api/v1/actions/move").as("moveAction"); cy.route("POST", "/api/v1/organizations").as("createOrg"); + cy.route("POST", "api/v1/applications/import/*").as("importNewApplication"); + cy.route("GET", "api/v1/applications/export/*").as("exportApplication"); cy.route("GET", "/api/v1/organizations/roles?organizationId=*").as( "getRoles", ); diff --git a/app/client/src/actions/applicationActions.ts b/app/client/src/actions/applicationActions.ts index 1dda8b55d8..e4cf360f65 100644 --- a/app/client/src/actions/applicationActions.ts +++ b/app/client/src/actions/applicationActions.ts @@ -1,6 +1,9 @@ import { ReduxAction, ReduxActionTypes } from "constants/ReduxActionConstants"; import { APP_MODE } from "../reducers/entityReducers/appReducer"; -import { UpdateApplicationPayload } from "api/ApplicationApi"; +import { + UpdateApplicationPayload, + ImportApplicationRequest, +} from "api/ApplicationApi"; export const setDefaultApplicationPageSuccess = ( pageId: string, @@ -77,6 +80,13 @@ export const duplicateApplication = (applicationId: string) => { }; }; +export const importApplication = (appDetails: ImportApplicationRequest) => { + return { + type: ReduxActionTypes.IMPORT_APPLICATION_INIT, + payload: appDetails, + }; +}; + export const getAllApplications = () => { return { type: ReduxActionTypes.GET_ALL_APPLICATION_INIT, diff --git a/app/client/src/api/ApplicationApi.tsx b/app/client/src/api/ApplicationApi.tsx index 0347802ff0..5ad7f57fd7 100644 --- a/app/client/src/api/ApplicationApi.tsx +++ b/app/client/src/api/ApplicationApi.tsx @@ -119,6 +119,13 @@ export interface FetchUsersApplicationsOrgsResponse extends ApiResponse { }; } +export interface ImportApplicationRequest { + orgId: string; + applicationFile?: File; + progress?: (progressEvent: ProgressEvent) => void; + onSuccessCallback?: () => void; +} + class ApplicationApi extends Api { static baseURL = "v1/applications/"; static publishURLPath = (applicationId: string) => `publish/${applicationId}`; @@ -212,6 +219,21 @@ class ApplicationApi extends Api { request.organizationId, ); } + + static importApplicationToOrg( + request: ImportApplicationRequest, + ): AxiosPromise { + const formData = new FormData(); + if (request.applicationFile) { + formData.append("file", request.applicationFile); + } + return Api.post("v1/applications/import/" + request.orgId, formData, null, { + headers: { + "Content-Type": "multipart/form-data", + }, + onUploadProgress: request.progress, + }); + } } export default ApplicationApi; diff --git a/app/client/src/assets/icons/ads/download.svg b/app/client/src/assets/icons/ads/download.svg new file mode 100644 index 0000000000..0cbe360e6c --- /dev/null +++ b/app/client/src/assets/icons/ads/download.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/client/src/assets/icons/ads/upload_success.svg b/app/client/src/assets/icons/ads/upload_success.svg new file mode 100644 index 0000000000..578e9a3171 --- /dev/null +++ b/app/client/src/assets/icons/ads/upload_success.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/client/src/components/ads/FilePicker.tsx b/app/client/src/components/ads/FilePicker.tsx index 1d5d6936b4..4ccffaa125 100644 --- a/app/client/src/components/ads/FilePicker.tsx +++ b/app/client/src/components/ads/FilePicker.tsx @@ -3,28 +3,52 @@ import styled from "styled-components"; import Button, { Category, Size } from "./Button"; import axios from "axios"; import { ReactComponent as UploadIcon } from "../../assets/icons/ads/upload.svg"; +import { ReactComponent as UploadSuccessIcon } from "../../assets/icons/ads/upload_success.svg"; import { DndProvider, useDrop, DropTargetMonitor } from "react-dnd"; import HTML5Backend, { NativeTypes } from "react-dnd-html5-backend"; import Text, { TextType } from "./Text"; import { Classes, Variant } from "./common"; import { Toaster } from "./Toast"; -import { createMessage, ERROR_FILE_TOO_LARGE } from "constants/messages"; - +import { + createMessage, + ERROR_FILE_TOO_LARGE, + REMOVE_FILE_TOOL_TIP, +} from "constants/messages"; +import TooltipComponent from "components/ads/Tooltip"; +import { Position } from "@blueprintjs/core/lib/esm/common/position"; +import Icon, { IconSize } from "./Icon"; const CLOUDINARY_PRESETS_NAME = ""; const CLOUDINARY_CLOUD_NAME = ""; +const FileEndings = { + IMAGE: ".jpeg,.png,.svg", + JSON: ".json", + TEXT: ".txt", + ANY: "*", +}; + +export enum FileType { + IMAGE = "IMAGE", + JSON = "JSON", + TEXT = "TEXT", + ANY = "ANY", +} + type FilePickerProps = { onFileUploaded?: (fileUrl: string) => void; onFileRemoved?: () => void; fileUploader?: FileUploader; url?: string; logoUploadError?: string; + fileType: FileType; + delayedUpload?: boolean; }; const ContainerDiv = styled.div<{ isUploaded: boolean; isActive: boolean; canDrop: boolean; + fileType: FileType; }>` width: 320px; height: 190px; @@ -41,7 +65,7 @@ const ContainerDiv = styled.div<{ color: ${(props) => props.theme.colors.filePicker.color}; } - .bg-image { + .upload-form-container { width: 100%; height: 100%; display: grid; @@ -51,15 +75,36 @@ const ContainerDiv = styled.div<{ background-size: contain; } + .centered { + justify-content: center; + flex-direction: column; + align-items: center; + + .success-container { + display: flex; + align-items: center; + .success-icon { + margin-right: ${(props) => props.theme.spaces[4]}px; + } + + .success-text { + color: #03b365; + margin-right: ${(props) => props.theme.spaces[4]}px; + } + } + } + .file-description { width: 95%; - margin-top: auto; + margin: 0 auto; + margin-top: ${(props) => + props.fileType === FileType.IMAGE ? "auto" : "0px"}; margin-bottom: ${(props) => props.theme.spaces[6] + 1}px; display: none; } .file-spec { - margin-bottom: ${(props) => props.theme.spaces[2]}px; + margin-bottom: ${(props) => props.theme.spaces[3]}px; span { margin-right: ${(props) => props.theme.spaces[4]}px; } @@ -116,6 +161,11 @@ const ContainerDiv = styled.div<{ } `; +const IconWrapper = styled.div` + width: ${(props) => props.theme.spaces[9]}px; + padding-left: ${(props) => props.theme.spaces[2]}px; +`; + export type SetProgress = (percentage: number) => void; export type UploadCallback = (url: string) => void; export type FileUploader = ( @@ -159,7 +209,7 @@ export function CloudinaryUploader( } function FilePickerComponent(props: FilePickerProps) { - const { logoUploadError } = props; + const { fileType, logoUploadError } = props; const [fileInfo, setFileInfo] = useState<{ name: string; size: number }>({ name: "", size: 0, @@ -207,7 +257,7 @@ function FilePickerComponent(props: FilePickerProps) { } if (uploadPercentage === 100) { setIsUploaded(true); - if (fileDescRef.current && bgRef.current) { + if (fileDescRef.current && bgRef.current && fileType === FileType.IMAGE) { fileDescRef.current.style.display = "none"; bgRef.current.style.opacity = "1"; } @@ -219,6 +269,35 @@ function FilePickerComponent(props: FilePickerProps) { } function handleFileUpload(files: FileList | null) { + if (fileType === FileType.IMAGE) { + handleImageFileUpload(files); + } else { + handleOtherFileUpload(files); + } + } + + function handleOtherFileUpload(files: FileList | null) { + const file = files && files[0]; + let fileSize = 0; + if (!file) { + return; + } + fileSize = Math.floor(file.size / 1024); + setFileInfo({ name: file.name, size: fileSize }); + if (props.delayedUpload) { + setIsUploaded(true); + setProgress(100); + } + if (fileDescRef.current) { + fileDescRef.current.style.display = "flex"; + } + if (fileContainerRef.current) { + fileContainerRef.current.style.display = "none"; + } + props.fileUploader && props.fileUploader(file, setProgress, onUpload); + } + + function handleImageFileUpload(files: FileList | null) { const file = files && files[0]; let fileSize = 0; @@ -253,10 +332,15 @@ function FilePickerComponent(props: FilePickerProps) { } function removeFile() { - if (fileContainerRef.current && bgRef.current) { + if (fileContainerRef.current) { setFileUrl(""); + if (fileDescRef.current) { + fileDescRef.current.style.display = "none"; + } fileContainerRef.current.style.display = "flex"; - bgRef.current.style.backgroundImage = "url('')"; + if (bgRef.current) { + bgRef.current.style.backgroundImage = "url('')"; + } setIsUploaded(false); props.onFileRemoved && props.onFileRemoved(); } @@ -275,8 +359,9 @@ function FilePickerComponent(props: FilePickerProps) { } }, [props.url]); + // Following hook should be used only if file type is image. useEffect(() => { - if (fileUrl && !isUploaded) { + if (fileUrl && !isUploaded && fileType === FileType.IMAGE) { setIsUploaded(true); if (bgRef.current) { bgRef.current.style.backgroundImage = `url(${fileUrl})`; @@ -291,42 +376,47 @@ function FilePickerComponent(props: FilePickerProps) { } }, [fileUrl, logoUploadError]); - return ( - -
-
- - - Drag & Drop files to upload or - -
- handleFileUpload(el.target.files)} - ref={inputRef} - type="file" - value={""} - /> -
+ // + + const uploadFileForm = ( +
+ + + Drag & Drop files to upload or + +
+ handleFileUpload(el.target.files)} + ref={inputRef} + type="file" + value={""} + /> +
+ ); + + const uploadStatus = ( +
+ {fileInfo.name} + {fileInfo.size}KB +
+ ); + + const imageUploadComponent = ( + <> +
+ {uploadFileForm}
-
- {fileInfo.name} - {fileInfo.size}KB -
+ {uploadStatus}
@@ -341,6 +431,45 @@ function FilePickerComponent(props: FilePickerProps) { text="remove" />
+ + ); + + const uploadComponent = ( +
+ {uploadFileForm} +
+ {uploadStatus} +
+ + + Successfully Uploaded! + + + removeFile()}> + + + +
+
+
+ ); + + return ( + + {fileType === FileType.IMAGE ? imageUploadComponent : uploadComponent} ); } diff --git a/app/client/src/components/ads/Icon.tsx b/app/client/src/components/ads/Icon.tsx index e02af7fe68..ecca134ab8 100644 --- a/app/client/src/components/ads/Icon.tsx +++ b/app/client/src/components/ads/Icon.tsx @@ -64,6 +64,8 @@ import { ReactComponent as Pin3 } from "assets/icons/comments/pin_3.svg"; import { ReactComponent as Unpin } from "assets/icons/comments/unpin.svg"; import { ReactComponent as Reaction } from "assets/icons/comments/reaction.svg"; import { ReactComponent as Reaction2 } from "assets/icons/comments/reaction-2.svg"; +import { ReactComponent as Upload } from "assets/icons/ads/upload.svg"; +import { ReactComponent as Download } from "assets/icons/ads/download.svg"; import styled from "styled-components"; import { CommonComponentProps, Classes } from "./common"; import { noop } from "lodash"; @@ -117,6 +119,8 @@ export const sizeHandler = (size?: IconSize) => { }; export const IconCollection = [ + "upload", + "download", "book", "bug", "cancel", @@ -477,6 +481,14 @@ const Icon = forwardRef( returnIcon = ; break; + case "upload": + returnIcon = ; + break; + + case "download": + returnIcon = ; + break; + default: returnIcon = null; break; diff --git a/app/client/src/components/stories/FilePicker.stories.tsx b/app/client/src/components/stories/FilePicker.stories.tsx index 8fd569b06a..f9edcec2bc 100644 --- a/app/client/src/components/stories/FilePicker.stories.tsx +++ b/app/client/src/components/stories/FilePicker.stories.tsx @@ -1,5 +1,5 @@ import React from "react"; -import FilePicker, { CloudinaryUploader } from "../ads/FilePicker"; +import FilePicker, { CloudinaryUploader, FileType } from "../ads/FilePicker"; export default { title: "FilePicker", @@ -12,6 +12,15 @@ function ShowUploadedFile(data: any) { export const withDynamicProps = () => ( ShowUploadedFile(data)} + /> +); + +export const withJsonInputType = () => ( + ShowUploadedFile(data)} /> diff --git a/app/client/src/constants/ReduxActionConstants.tsx b/app/client/src/constants/ReduxActionConstants.tsx index b8b4711c9a..3f24e7b315 100644 --- a/app/client/src/constants/ReduxActionConstants.tsx +++ b/app/client/src/constants/ReduxActionConstants.tsx @@ -418,6 +418,8 @@ export const ReduxActionTypes: { [key: string]: string } = { CURRENT_APPLICATION_LAYOUT_UPDATE: "CURRENT_APPLICATION_LAYOUT_UPDATE", FORK_APPLICATION_INIT: "FORK_APPLICATION_INIT", FORK_APPLICATION_SUCCESS: "FORK_APPLICATION_SUCCESS", + IMPORT_APPLICATION_INIT: "IMPORT_APPLICATION_INIT", + IMPORT_APPLICATION_SUCCESS: "IMPORT_APPLICATION_SUCCESS", SET_WIDGET_LOADING: "SET_WIDGET_LOADING", SET_GLOBAL_SEARCH_QUERY: "SET_GLOBAL_SEARCH_QUERY", TOGGLE_SHOW_GLOBAL_SEARCH_MODAL: "TOGGLE_SHOW_GLOBAL_SEARCH_MODAL", @@ -525,6 +527,7 @@ export const ReduxActionErrorTypes: { [key: string]: string } = { SAVE_ACTION_NAME_ERROR: "SAVE_ACTION_NAME_ERROR", FETCH_USER_APPLICATIONS_ORGS_ERROR: "FETCH_USER_APPLICATIONS_ORGS_ERROR", FORK_APPLICATION_ERROR: "FORK_APPLICATION_ERROR", + IMPORT_APPLICATION_ERROR: "IMPORT_APPLICATION_ERROR", FETCH_ALL_USERS_ERROR: "FETCH_ALL_USERS_ERROR", FETCH_ALL_ROLES_ERROR: "FETCH_ALL_ROLES_ERROR", UPDATE_USER_DETAILS_ERROR: "UPDATE_USER_DETAILS_ERROR", diff --git a/app/client/src/constants/messages.ts b/app/client/src/constants/messages.ts index 9084eb5eaf..b40f6f66db 100644 --- a/app/client/src/constants/messages.ts +++ b/app/client/src/constants/messages.ts @@ -202,7 +202,7 @@ export const GOOGLE_RECAPTCHA_DOMAIN_ERROR = () => export const SERVER_API_TIMEOUT_ERROR = () => `Appsmith server is taking too long to respond. Please try again after some time`; export const DEFAULT_ERROR_MESSAGE = () => `There was an unexpected error`; - +export const REMOVE_FILE_TOOL_TIP = () => "Remove Upload"; export const ERROR_FILE_TOO_LARGE = (fileSize: string) => `File size should be less than ${fileSize}!`; export const ERROR_DATEPICKER_MIN_DATE = () => @@ -329,3 +329,6 @@ export const OPEN_THE_DEBUGGER = () => " to open the debugger"; export const NO_LOGS = () => "No logs to show"; export const TROUBLESHOOT_ISSUE = () => "Troubleshoot issue"; + +// Import/Export Application features +export const IMPORT_APPLICATION_MODAL_TITLE = () => "Import Application"; diff --git a/app/client/src/pages/Applications/ApplicationCard.tsx b/app/client/src/pages/Applications/ApplicationCard.tsx index 6f6fae0bc4..e65dc5f501 100644 --- a/app/client/src/pages/Applications/ApplicationCard.tsx +++ b/app/client/src/pages/Applications/ApplicationCard.tsx @@ -46,6 +46,8 @@ import { Classes as CsClasses } from "components/ads/common"; import TooltipComponent from "components/ads/Tooltip"; import { isEllipsisActive } from "utils/helpers"; import ForkApplicationModal from "./ForkApplicationModal"; +import { Toaster } from "components/ads/Toast"; +import { Variant } from "components/ads/common"; type NameWrapperProps = { hasReadPermission: boolean; @@ -220,6 +222,7 @@ type ApplicationCardProps = { share?: (applicationId: string) => void; delete?: (applicationId: string) => void; update?: (id: string, data: UpdateApplicationPayload) => void; + enableImportExport?: boolean; }; const EditButton = styled(Button)` @@ -297,6 +300,14 @@ export function ApplicationCard(props: ApplicationCardProps) { cypressSelector: "t--fork-app", }); } + if (!!props.enableImportExport) { + moreActionItems.push({ + onSelect: exportApplicationAsJSONFile, + text: "Export", + icon: "download", + cypressSelector: "t--export-app", + }); + } setMoreActionItems(moreActionItems); addDeleteOption(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -331,6 +342,25 @@ export function ApplicationCard(props: ApplicationCardProps) { const shareApp = () => { props.share && props.share(props.application.id); }; + const exportApplicationAsJSONFile = () => { + // export api response comes with content-disposition header. + // there is no straightforward way to handle it with axios/fetch + const id = `t--export-app-link`; + const existingLink = document.getElementById(id); + existingLink && existingLink.remove(); + const link = document.createElement("a"); + link.href = `/api/v1/applications/export/${props.application.id}`; + link.target = "_blank"; + link.id = id; + document.body.appendChild(link); + link.click(); + setIsMenuOpen(false); + Toaster.show({ + text: `Successfully exported ${props.application.name}`, + variant: Variant.success, + }); + link.remove(); + }; const forkApplicationInitiate = () => { // open fork application modal // on click on an organisation, create app and take to app diff --git a/app/client/src/pages/Applications/ForkModalStyles.ts b/app/client/src/pages/Applications/ForkModalStyles.ts index a79e04f042..2e731b7b57 100644 --- a/app/client/src/pages/Applications/ForkModalStyles.ts +++ b/app/client/src/pages/Applications/ForkModalStyles.ts @@ -26,9 +26,10 @@ const StyledRadioComponent = styled(RadioComponent)` } `; -const ForkButton = styled(Button)` +const ForkButton = styled(Button)<{ disabled?: boolean }>` height: 38px; width: 203px; + pointer-events: ${(props) => (!!props.disabled ? "none" : "auto")}; `; const OrganizationList = styled.div` diff --git a/app/client/src/pages/Applications/ImportApplicationModal.tsx b/app/client/src/pages/Applications/ImportApplicationModal.tsx new file mode 100644 index 0000000000..4f37426224 --- /dev/null +++ b/app/client/src/pages/Applications/ImportApplicationModal.tsx @@ -0,0 +1,117 @@ +import React, { useCallback, useState } from "react"; +import styled from "styled-components"; +import Button, { Size } from "components/ads/Button"; +import { StyledDialog } from "./ForkModalStyles"; +import { useSelector } from "store"; +import { AppState } from "reducers"; +import FilePicker, { SetProgress, FileType } from "components/ads/FilePicker"; +import { useDispatch } from "react-redux"; +import { importApplication } from "actions/applicationActions"; +import { Toaster } from "components/ads/Toast"; +import { Variant } from "components/ads/common"; +import { IMPORT_APPLICATION_MODAL_TITLE } from "constants/messages"; + +const ImportButton = styled(Button)<{ disabled?: boolean }>` + height: 30px; + width: 81px; + pointer-events: ${(props) => (!!props.disabled ? "none" : "auto")}; +`; + +const ButtonWrapper = styled.div` + display: flex; + justify-content: center; +`; + +const FilePickerWrapper = styled.div` + width: 100%; + display: flex; + align-items: center; + justify-content: center; +`; + +type ImportApplicationModalProps = { + // import?: (file: any) => void; + organizationId?: string; + isModalOpen?: boolean; + onClose?: () => void; +}; + +function ImportApplicationModal(props: ImportApplicationModalProps) { + const { isModalOpen, onClose, organizationId } = props; + const [appFileToBeUploaded, setAppFileToBeUploaded] = useState<{ + file: File; + setProgress: SetProgress; + } | null>(null); + const dispatch = useDispatch(); + + const importingApplication = useSelector( + (state: AppState) => state.ui.applications.importingApplication, + ); + + const FileUploader = useCallback( + async (file: File, setProgress: SetProgress) => { + if (!!file) { + setAppFileToBeUploaded({ + file, + setProgress, + }); + } else { + setAppFileToBeUploaded(null); + } + }, + [], + ); + + const onImportApplication = useCallback(() => { + if (!appFileToBeUploaded) { + Toaster.show({ + text: "Please choose a valid application file!", + variant: Variant.danger, + }); + return; + } + const { file } = appFileToBeUploaded || {}; + + dispatch( + importApplication({ + orgId: organizationId as string, + applicationFile: file, + }), + ); + }, [appFileToBeUploaded, organizationId]); + + const onRemoveFile = useCallback(() => setAppFileToBeUploaded(null), []); + + return ( + + + + + + + + + ); +} + +export default ImportApplicationModal; diff --git a/app/client/src/pages/Applications/index.tsx b/app/client/src/pages/Applications/index.tsx index 3b3282c734..d75a4525f3 100644 --- a/app/client/src/pages/Applications/index.tsx +++ b/app/client/src/pages/Applications/index.tsx @@ -78,6 +78,7 @@ import WelcomeHelper from "components/editorComponents/Onboarding/WelcomeHelper" import { useIntiateOnboarding } from "components/editorComponents/Onboarding/utils"; import AnalyticsUtil from "utils/AnalyticsUtil"; import { createOrganizationSubmitHandler } from "../organization/helpers"; +import ImportApplicationModal from "./ImportApplicationModal"; const OrgDropDown = styled.div` display: flex; @@ -505,6 +506,7 @@ const NoSearchResultImg = styled.img` `; function ApplicationsSection(props: any) { + const enableImportExport = true; const dispatch = useDispatch(); const theme = useContext(ThemeContext); const isSavingOrgInfo = useSelector(getIsSavingOrgInfo); @@ -534,6 +536,10 @@ function ApplicationsSection(props: any) { }; const [selectedOrgId, setSelectedOrgId] = useState(); + const [ + selectedOrgIdForImportApplication, + setSelectedOrgIdForImportApplication, + ] = useState(); const Form: any = OrgInviteUsersForm; const leaveOrg = (orgId: string) => { @@ -668,6 +674,18 @@ function ApplicationsSection(props: any) { } text="Organization Settings" /> + {enableImportExport && ( + + setSelectedOrgIdForImportApplication( + organization.id, + ) + } + text="Import Application" + /> + )} setSelectedOrgId(organization.id)} @@ -691,7 +709,15 @@ function ApplicationsSection(props: any) { /> )} - + {selectedOrgIdForImportApplication && ( + setSelectedOrgIdForImportApplication("")} + organizationId={selectedOrgIdForImportApplication} + /> + )} {hasManageOrgPermissions && ( diff --git a/app/client/src/pages/organization/General.tsx b/app/client/src/pages/organization/General.tsx index f9bd6e2010..b426b6230d 100644 --- a/app/client/src/pages/organization/General.tsx +++ b/app/client/src/pages/organization/General.tsx @@ -20,6 +20,7 @@ import { getOrgLoadingStates } from "selectors/organizationSelectors"; import FilePicker, { SetProgress, UploadCallback, + FileType, } from "components/ads/FilePicker"; import { getIsFetchingApplications } from "selectors/applicationSelectors"; @@ -171,6 +172,7 @@ export function GeneralSettings() { {isFetchingOrg && } {!isFetchingOrg && ( ({ ...state, importingApplication: true }), + [ReduxActionTypes.IMPORT_APPLICATION_SUCCESS]: ( + state: ApplicationsReduxState, + action: ReduxAction<{ importedApplication: any }>, + ) => { + const { importedApplication } = action.payload; + return { + ...state, + importingApplication: false, + importedApplication, + }; + }, + [ReduxActionErrorTypes.IMPORT_APPLICATION_ERROR]: ( + state: ApplicationsReduxState, + ) => { + return { + ...state, + importingApplication: false, + }; + }, [ReduxActionTypes.SAVING_ORG_INFO]: (state: ApplicationsReduxState) => { return { ...state, @@ -349,6 +373,8 @@ export interface ApplicationsReduxState { currentApplication?: ApplicationPayload; userOrgs: Organization[]; isSavingOrgInfo: boolean; + importingApplication: boolean; + importedApplication: any; showAppInviteUsersDialog: boolean; } diff --git a/app/client/src/sagas/ApplicationSagas.tsx b/app/client/src/sagas/ApplicationSagas.tsx index 5346152181..e5a08dd70c 100644 --- a/app/client/src/sagas/ApplicationSagas.tsx +++ b/app/client/src/sagas/ApplicationSagas.tsx @@ -20,6 +20,7 @@ import ApplicationApi, { PublishApplicationResponse, SetDefaultPageRequest, UpdateApplicationRequest, + ImportApplicationRequest, } from "api/ApplicationApi"; import { all, call, put, select, takeLatest } from "redux-saga/effects"; @@ -55,8 +56,11 @@ import { getCurrentPageId, } from "selectors/editorSelectors"; import { showCompletionDialog } from "./OnboardingSagas"; + import { deleteRecentAppEntities } from "utils/storage"; import { reconnectWebsocket as reconnectWebsocketAction } from "actions/websocketActions"; +import { getCurrentOrg } from "selectors/organizationSelectors"; +import { Org } from "constants/orgConstants"; const getDefaultPageId = ( pages?: ApplicationPagePayload[], @@ -502,6 +506,53 @@ export function* forkApplicationSaga( } } +export function* importApplicationSaga( + action: ReduxAction, +) { + try { + const response: ApiResponse = yield call( + ApplicationApi.importApplicationToOrg, + action.payload, + ); + const isValidResponse = yield validateResponse(response); + if (isValidResponse) { + const allOrgs = yield select(getCurrentOrg); + const currentOrg = allOrgs.filter( + (el: Org) => el.id === action.payload.orgId, + ); + if (currentOrg.length > 0) { + const { + id: appId, + pages, + }: { + id: string; + pages: { default?: boolean; id: string; isDefault?: boolean }[]; + } = response.data; + yield put({ + type: ReduxActionTypes.IMPORT_APPLICATION_SUCCESS, + payload: { + importedApplication: appId, + }, + }); + const defaultPage = pages.filter((eachPage) => !!eachPage.isDefault); + const pageURL = BUILDER_PAGE_URL(appId, defaultPage[0].id); + history.push(pageURL); + Toaster.show({ + text: "Application imported successfully", + variant: Variant.success, + }); + } + } + } catch (error) { + yield put({ + type: ReduxActionErrorTypes.IMPORT_APPLICATION_ERROR, + payload: { + error, + }, + }); + } +} + export default function* applicationSagas() { yield all([ takeLatest( @@ -530,5 +581,6 @@ export default function* applicationSagas() { ReduxActionTypes.DUPLICATE_APPLICATION_INIT, duplicateApplicationSaga, ), + takeLatest(ReduxActionTypes.IMPORT_APPLICATION_INIT, importApplicationSaga), ]); } diff --git a/app/client/src/selectors/ui.ts b/app/client/src/selectors/ui.tsx similarity index 100% rename from app/client/src/selectors/ui.ts rename to app/client/src/selectors/ui.tsx