mirror of
https://github.com/ToolJet/ToolJet
synced 2026-05-23 17:08:34 +00:00
commit
8e78560c0a
38 changed files with 986 additions and 426 deletions
2
.version
2
.version
|
|
@ -1 +1 @@
|
|||
2.20.2
|
||||
2.21.0
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ Cypress.Commands.add("createApp", (appName) => {
|
|||
|
||||
cy.get("body").then(($title) => {
|
||||
cy.get(getAppButtonSelector($title)).click();
|
||||
cy.clearAndType('[data-cy="app-name-input"]', appName);
|
||||
cy.get('[data-cy="+ Create app"]').click();
|
||||
});
|
||||
cy.waitForAppLoad();
|
||||
cy.skipEditorPopover();
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export const dashboardText = {
|
|||
},
|
||||
seeAllAppsTemplateButton: "See all templates",
|
||||
addToFolderTitle: "Add to folder",
|
||||
appClonedToast: "App cloned successfully.",
|
||||
appClonedToast: "App cloned successfully!",
|
||||
darkModeText: "Dark Mode",
|
||||
lightModeText: "Light Mode",
|
||||
dashboardAppsHeaderLabel: " All apps",
|
||||
|
|
|
|||
|
|
@ -42,9 +42,8 @@ describe("App Version Functionality", () => {
|
|||
});
|
||||
|
||||
it("Verify the elements of the version module", () => {
|
||||
cy.createApp();
|
||||
cy.createApp(data.appName);
|
||||
cy.get(appVersionSelectors.appVersionLabel).should("be.visible");
|
||||
cy.renameApp(data.appName);
|
||||
cy.get(commonSelectors.appNameInput).verifyVisibleElement(
|
||||
"have.value",
|
||||
data.appName
|
||||
|
|
|
|||
|
|
@ -168,12 +168,10 @@ describe("dashboard", () => {
|
|||
|
||||
it("Should verify app card elements and app card operations", () => {
|
||||
cy.apiLogin();
|
||||
cy.apiCreateApp();
|
||||
cy.apiCreateApp(data.appName);
|
||||
cy.openApp();
|
||||
cy.renameApp(data.appName);
|
||||
cy.dragAndDropWidget("Table", 250, 250);
|
||||
|
||||
|
||||
cy.get(commonSelectors.editorPageLogo).click();
|
||||
|
||||
cy.wait(500);
|
||||
|
|
@ -192,7 +190,6 @@ describe("dashboard", () => {
|
|||
expect($el.contents().last().text().trim()).to.eq("The Developer");
|
||||
});
|
||||
});
|
||||
cy.reloadAppForTheElement(data.appName);
|
||||
|
||||
viewAppCardOptions(data.appName);
|
||||
cy.get(
|
||||
|
|
@ -213,7 +210,6 @@ describe("dashboard", () => {
|
|||
|
||||
modifyAndVerifyAppCardIcon(data.appName);
|
||||
createFolder(data.folderName);
|
||||
cy.reloadAppForTheElement(data.appName);
|
||||
|
||||
viewAppCardOptions(data.appName);
|
||||
cy.get(
|
||||
|
|
@ -246,7 +242,7 @@ describe("dashboard", () => {
|
|||
cy.get(commonSelectors.appCard(data.appName))
|
||||
.contains(data.appName)
|
||||
.should("be.visible");
|
||||
cy.reloadAppForTheElement(data.appName);
|
||||
|
||||
viewAppCardOptions(data.appName);
|
||||
|
||||
cy.get(commonSelectors.appCardOptions(commonText.removeFromFolderOption))
|
||||
|
|
@ -256,7 +252,6 @@ describe("dashboard", () => {
|
|||
|
||||
cancelModal(commonText.cancelButton);
|
||||
|
||||
cy.reloadAppForTheElement(data.appName);
|
||||
viewAppCardOptions(data.appName);
|
||||
cy.get(
|
||||
commonSelectors.appCardOptions(commonText.removeFromFolderOption)
|
||||
|
|
@ -276,17 +271,13 @@ describe("dashboard", () => {
|
|||
deleteFolder(data.folderName);
|
||||
|
||||
cy.get(commonSelectors.allApplicationsLink).click();
|
||||
cy.reloadAppForTheElement(data.appName);
|
||||
|
||||
viewAppCardOptions(data.appName);
|
||||
cy.get(commonSelectors.appCardOptions(commonText.cloneAppOption)).click();
|
||||
cy.verifyToastMessage(
|
||||
commonSelectors.toastMessage,
|
||||
dashboardText.appClonedToast
|
||||
);
|
||||
// cy.waitForAppLoad();
|
||||
cy.wait(2000);
|
||||
cy.clearAndType(commonSelectors.appNameInput, data.cloneAppName);
|
||||
cy.get('[data-cy="Clone app"]').click();
|
||||
cy.get('.go3958317564').should('be.visible').and('have.text', dashboardText.appClonedToast)
|
||||
cy.wait(3000);
|
||||
cy.renameApp(data.cloneAppName);
|
||||
cy.dragAndDropWidget("button", 25, 25);
|
||||
cy.get(commonSelectors.editorPageLogo).click();
|
||||
cy.wait("@appLibrary");
|
||||
|
|
@ -341,12 +332,11 @@ describe("dashboard", () => {
|
|||
it("Should verify the app CRUD operation", () => {
|
||||
data.appName = `${fake.companyName}-App`;
|
||||
cy.appUILogin();
|
||||
cy.createApp();
|
||||
cy.renameApp(data.appName);
|
||||
cy.createApp(data.appName);
|
||||
cy.dragAndDropWidget("Button", 450, 450);
|
||||
|
||||
cy.get(commonSelectors.editorPageLogo).click();
|
||||
cy.reloadAppForTheElement(data.appName);
|
||||
|
||||
cy.get(commonSelectors.appCard(data.appName)).should(
|
||||
"contain.text",
|
||||
data.appName
|
||||
|
|
@ -368,8 +358,7 @@ describe("dashboard", () => {
|
|||
it("Should verify the folder CRUD operation", () => {
|
||||
data.appName = `${fake.companyName}-App`;
|
||||
cy.appUILogin();
|
||||
cy.createApp();
|
||||
cy.renameApp(data.appName);
|
||||
cy.createApp(data.appName);
|
||||
cy.dragAndDropWidget("Button", 100, 100);
|
||||
|
||||
cy.get(commonSelectors.editorPageLogo).click();
|
||||
|
|
@ -431,7 +420,7 @@ describe("dashboard", () => {
|
|||
.should("be.visible")
|
||||
.and("have.text", "Edit folder");
|
||||
|
||||
cy.get(commonSelectors.folderNameInput).should("be.visible")
|
||||
cy.get(commonSelectors.folderNameInput).should("be.visible");
|
||||
|
||||
// verifyModal(
|
||||
// commonText.updateFolderTitle,
|
||||
|
|
|
|||
|
|
@ -13,119 +13,121 @@ describe("App share functionality", () => {
|
|||
data.email = fake.email.toLowerCase();
|
||||
const slug = data.appName.toLowerCase().replace(/\s+/g, "-");
|
||||
const firstUserEmail = data.email
|
||||
const envVar = Cypress.env("environment");
|
||||
beforeEach(() => {
|
||||
cy.appUILogin();
|
||||
});
|
||||
|
||||
it("Verify private and public app share funtionality", () => {
|
||||
cy.apiLogin();
|
||||
cy.apiCreateApp();
|
||||
cy.openApp();
|
||||
cy.renameApp(data.appName);
|
||||
cy.dragAndDropWidget("Table", 250, 250);
|
||||
if (envVar === "Community") {
|
||||
it("Verify private and public app share funtionality", () => {
|
||||
cy.apiLogin();
|
||||
cy.apiCreateApp(data.appName);
|
||||
cy.openApp();
|
||||
cy.dragAndDropWidget("Table", 250, 250);
|
||||
|
||||
cy.get(commonWidgetSelector.shareAppButton).click();
|
||||
cy.get(commonWidgetSelector.shareAppButton).click();
|
||||
|
||||
for (const elements in commonWidgetSelector.shareModalElements) {
|
||||
cy.get(
|
||||
commonWidgetSelector.shareModalElements[elements]
|
||||
).verifyVisibleElement(
|
||||
for (const elements in commonWidgetSelector.shareModalElements) {
|
||||
cy.get(
|
||||
commonWidgetSelector.shareModalElements[elements]
|
||||
).verifyVisibleElement(
|
||||
"have.text",
|
||||
commonText.shareModalElements[elements]
|
||||
);
|
||||
}
|
||||
|
||||
cy.get(commonWidgetSelector.makePublicAppToggle).should("be.visible");
|
||||
cy.get(commonWidgetSelector.appLink).should("be.visible");
|
||||
cy.get(commonWidgetSelector.appNameSlugInput).should("be.visible");
|
||||
// cy.get(commonWidgetSelector.iframeLink).should("be.visible");
|
||||
cy.get(commonWidgetSelector.modalCloseButton).should("be.visible");
|
||||
|
||||
cy.clearAndType(commonWidgetSelector.appNameSlugInput, `${slug}`);
|
||||
cy.get(commonWidgetSelector.modalCloseButton).click();
|
||||
cy.forceClickOnCanvas()
|
||||
cy.dragAndDropWidget("Button", 50, 50);
|
||||
cy.get(commonSelectors.editorPageLogo).click();
|
||||
|
||||
logout();
|
||||
cy.visit(`/applications/${slug}`);
|
||||
|
||||
cy.get(commonSelectors.loginButton).should("be.visible");
|
||||
|
||||
cy.clearAndType(commonSelectors.workEmailInputField, "dev@tooljet.io");
|
||||
cy.clearAndType(commonSelectors.passwordInputField, "password");
|
||||
cy.get(commonSelectors.loginButton).click();
|
||||
|
||||
cy.wait(500);
|
||||
cy.get('[data-cy="draggable-widget-table1"]').should("be.visible");
|
||||
cy.get(commonSelectors.viewerPageLogo).click();
|
||||
|
||||
navigateToAppEditor(data.appName);
|
||||
cy.get(commonWidgetSelector.shareAppButton).click();
|
||||
cy.get(commonWidgetSelector.makePublicAppToggle).check();
|
||||
cy.get(commonWidgetSelector.modalCloseButton).click();
|
||||
cy.get(commonSelectors.editorPageLogo).click();
|
||||
|
||||
logout();
|
||||
cy.visit(`/applications/${slug}`);
|
||||
cy.wait(500);
|
||||
cy.get('[data-cy="draggable-widget-table1"]').should("be.visible");
|
||||
});
|
||||
|
||||
it("Verify app private and public app visibility for the same workspace user", () => {
|
||||
addNewUserMW(data.firstName, data.email);
|
||||
|
||||
logout();
|
||||
cy.visit(`/applications/${slug}`);
|
||||
cy.get('[data-cy="draggable-widget-table1"]').should("be.visible");
|
||||
|
||||
cy.appUILogin();
|
||||
navigateToAppEditor(data.appName);
|
||||
cy.skipEditorPopover()
|
||||
cy.get(commonWidgetSelector.shareAppButton).click();
|
||||
cy.get(commonWidgetSelector.makePublicAppToggle).uncheck();
|
||||
cy.get(commonWidgetSelector.modalCloseButton).click();
|
||||
cy.get(commonSelectors.editorPageLogo).click();
|
||||
|
||||
logout();
|
||||
cy.visit(`/applications/${slug}`);
|
||||
|
||||
cy.login(data.email, "password");
|
||||
cy.get(commonSelectors.allApplicationLink).verifyVisibleElement(
|
||||
"have.text",
|
||||
commonText.shareModalElements[elements]
|
||||
commonText.allApplicationLink
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
cy.get(commonWidgetSelector.makePublicAppToggle).should("be.visible");
|
||||
cy.get(commonWidgetSelector.appLink).should("be.visible");
|
||||
cy.get(commonWidgetSelector.appNameSlugInput).should("be.visible");
|
||||
// cy.get(commonWidgetSelector.iframeLink).should("be.visible");
|
||||
cy.get(commonWidgetSelector.modalCloseButton).should("be.visible");
|
||||
it("Verify app private and public app visibility for the same instance user", () => {
|
||||
data.firstName = fake.firstName;
|
||||
data.email = fake.email.toLowerCase();
|
||||
|
||||
cy.clearAndType(commonWidgetSelector.appNameSlugInput, `${slug}`);
|
||||
cy.get(commonWidgetSelector.modalCloseButton).click();
|
||||
cy.forceClickOnCanvas()
|
||||
cy.dragAndDropWidget("Button", 50, 50);
|
||||
cy.get(commonSelectors.editorPageLogo).click();
|
||||
logout();
|
||||
userSignUp(data.firstName, data.email, "Test");
|
||||
cy.visit(`/applications/${slug}`);
|
||||
cy.wait(1000);
|
||||
|
||||
logout();
|
||||
cy.visit(`/applications/${slug}`);
|
||||
cy.clearAndType(commonSelectors.workEmailInputField, data.email);
|
||||
cy.clearAndType(commonSelectors.passwordInputField, "password");
|
||||
cy.get(commonSelectors.signInButton).click();
|
||||
cy.wait(1000);
|
||||
|
||||
cy.get(commonSelectors.loginButton).should("be.visible");
|
||||
cy.visit("/");
|
||||
cy.wait(2000);
|
||||
logout();
|
||||
cy.appUILogin();
|
||||
|
||||
cy.clearAndType(commonSelectors.workEmailInputField, "dev@tooljet.io");
|
||||
cy.clearAndType(commonSelectors.passwordInputField, "password");
|
||||
cy.get(commonSelectors.loginButton).click();
|
||||
navigateToAppEditor(data.appName);
|
||||
cy.skipEditorPopover();
|
||||
cy.get(commonWidgetSelector.shareAppButton).click();
|
||||
cy.get(commonWidgetSelector.makePublicAppToggle).check();
|
||||
cy.get(commonWidgetSelector.modalCloseButton).click();
|
||||
cy.get(commonSelectors.editorPageLogo).click();
|
||||
|
||||
cy.wait(500);
|
||||
cy.get('[data-cy="draggable-widget-table1"]').should("be.visible");
|
||||
cy.get(commonSelectors.viewerPageLogo).click();
|
||||
|
||||
navigateToAppEditor(data.appName);
|
||||
cy.get(commonWidgetSelector.shareAppButton).click();
|
||||
cy.get(commonWidgetSelector.makePublicAppToggle).check();
|
||||
cy.get(commonWidgetSelector.modalCloseButton).click();
|
||||
cy.get(commonSelectors.editorPageLogo).click();
|
||||
|
||||
logout();
|
||||
cy.visit(`/applications/${slug}`);
|
||||
cy.wait(500);
|
||||
cy.get('[data-cy="draggable-widget-table1"]').should("be.visible");
|
||||
});
|
||||
|
||||
it("Verify app private and public app visibility for the same workspace user", () => {
|
||||
addNewUserMW(data.firstName, data.email);
|
||||
|
||||
logout();
|
||||
cy.visit(`/applications/${slug}`);
|
||||
cy.get('[data-cy="draggable-widget-table1"]').should("be.visible");
|
||||
|
||||
cy.appUILogin();
|
||||
navigateToAppEditor(data.appName);
|
||||
cy.skipEditorPopover()
|
||||
cy.get(commonWidgetSelector.shareAppButton).click();
|
||||
cy.get(commonWidgetSelector.makePublicAppToggle).uncheck();
|
||||
cy.get(commonWidgetSelector.modalCloseButton).click();
|
||||
cy.get(commonSelectors.editorPageLogo).click();
|
||||
|
||||
logout();
|
||||
cy.visit(`/applications/${slug}`);
|
||||
|
||||
cy.login(data.email, "password");
|
||||
cy.get(commonSelectors.allApplicationLink).verifyVisibleElement(
|
||||
"have.text",
|
||||
commonText.allApplicationLink
|
||||
);
|
||||
});
|
||||
|
||||
it("Verify app private and public app visibility for the same instance user", () => {
|
||||
data.firstName = fake.firstName;
|
||||
data.email = fake.email.toLowerCase();
|
||||
|
||||
logout();
|
||||
userSignUp(data.firstName, data.email, "Test");
|
||||
cy.visit(`/applications/${slug}`);
|
||||
cy.wait(1000);
|
||||
|
||||
cy.clearAndType(commonSelectors.workEmailInputField, data.email);
|
||||
cy.clearAndType(commonSelectors.passwordInputField, "password");
|
||||
cy.get(commonSelectors.signInButton).click();
|
||||
cy.wait(1000);
|
||||
|
||||
cy.visit("/");
|
||||
cy.wait(2000);
|
||||
logout();
|
||||
cy.appUILogin();
|
||||
|
||||
navigateToAppEditor(data.appName);
|
||||
cy.skipEditorPopover();
|
||||
cy.get(commonWidgetSelector.shareAppButton).click();
|
||||
cy.get(commonWidgetSelector.makePublicAppToggle).check();
|
||||
cy.get(commonWidgetSelector.modalCloseButton).click();
|
||||
cy.get(commonSelectors.editorPageLogo).click();
|
||||
|
||||
logout();
|
||||
cy.visit(`/applications/${slug}`);
|
||||
cy.get('[data-cy="draggable-widget-table1"]').should("be.visible");
|
||||
cy.get(commonSelectors.viewerPageLogo).click();
|
||||
});
|
||||
logout();
|
||||
cy.visit(`/applications/${slug}`);
|
||||
cy.get('[data-cy="draggable-widget-table1"]').should("be.visible");
|
||||
cy.get(commonSelectors.viewerPageLogo).click();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -23,11 +23,9 @@ describe("User permissions", () => {
|
|||
permissions.reset();
|
||||
cy.get(commonSelectors.homePageLogo).click();
|
||||
cy.wait("@homePage");
|
||||
cy.createApp();
|
||||
cy.renameApp(data.appName);
|
||||
cy.createApp(data.appName);
|
||||
cy.dragAndDropWidget("Table", 250, 250);
|
||||
cy.get(commonSelectors.editorPageLogo).click();
|
||||
cy.reloadAppForTheElement(data.appName);
|
||||
permissions.addNewUserMW(data.firstName, data.email);
|
||||
common.logout();
|
||||
});
|
||||
|
|
@ -41,11 +39,7 @@ describe("User permissions", () => {
|
|||
cy.login(data.email, usersText.password);
|
||||
cy.get("body").then(($title) => {
|
||||
if ($title.text().includes(dashboardText.emptyPageDescription)) {
|
||||
cy.get(commonSelectors.dashboardAppCreateButton).click();
|
||||
cy.verifyToastMessage(
|
||||
commonSelectors.toastMessage,
|
||||
usersText.createAppPermissionToast
|
||||
);
|
||||
cy.get(commonSelectors.dashboardAppCreateButton).should('be.disabled');
|
||||
} else {
|
||||
cy.contains(dashboardText.createAppButton).should("not.exist");
|
||||
}
|
||||
|
|
@ -120,7 +114,21 @@ describe("User permissions", () => {
|
|||
});
|
||||
|
||||
it("Should verify the Create and Delete app permission", () => {
|
||||
data.appName = `${fake.companyName}-App`;
|
||||
cy.createApp(data.appName);
|
||||
cy.get(commonSelectors.editorPageLogo).click();
|
||||
cy.wait(1000);
|
||||
common.navigateToManageGroups();
|
||||
cy.get(groupsSelector.appSearchBox).click();
|
||||
cy.get(groupsSelector.searchBoxOptions).contains(data.appName).click();
|
||||
cy.get(groupsSelector.selectAddButton).click();
|
||||
cy.get("table").contains("td", data.appName);
|
||||
cy.contains("td", data.appName)
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get("td input").first().should("be.checked");
|
||||
});
|
||||
cy.wait(500)
|
||||
cy.get(groupsSelector.permissionsLink).click();
|
||||
cy.get(groupsSelector.appsCreateCheck).check();
|
||||
cy.get(groupsSelector.permissionsLink).click();
|
||||
|
|
@ -141,11 +149,10 @@ describe("User permissions", () => {
|
|||
common.viewAppCardOptions(data.appName);
|
||||
cy.contains("Delete app").should("not.exist");
|
||||
|
||||
cy.createApp();
|
||||
cy.renameApp(data.email);
|
||||
cy.createApp(data.email);
|
||||
|
||||
cy.dragAndDropWidget("Table", 50, 50);
|
||||
cy.get(commonSelectors.editorPageLogo).click();
|
||||
cy.reloadAppForTheElement(data.email);
|
||||
common.viewAppCardOptions(data.email);
|
||||
cy.contains("Delete app").should("exist");
|
||||
cy.get(commonSelectors.appCardOptions(commonText.deleteAppOption)).click();
|
||||
|
|
|
|||
|
|
@ -279,8 +279,7 @@ describe("Workspace constants", () => {
|
|||
|
||||
cy.get(commonSelectors.homePageLogo).click();
|
||||
cy.wait("@homePage");
|
||||
cy.createApp();
|
||||
cy.renameApp(data.appName);
|
||||
cy.createApp(data.appName);
|
||||
|
||||
selectQueryFromLandingPage("runjs", "JavaScript");
|
||||
addInputOnQueryField("runjs", `return constants.${data.constantsName}`);
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
2.20.2
|
||||
2.21.0
|
||||
|
|
|
|||
|
|
@ -1,57 +1,136 @@
|
|||
import React from 'react';
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { ToolTip } from '@/_components';
|
||||
import { appService } from '@/_services';
|
||||
import { handleHttpErrorMessages, validateName } from '../../_helpers/utils';
|
||||
import { handleHttpErrorMessages, validateAppName, validateName } from '@/_helpers/utils';
|
||||
import InfoOrErrorBox from './InfoOrErrorBox';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
function EditAppName({ appId, appName = '', onNameChanged }) {
|
||||
const darkMode = localStorage.getItem('darkMode') === 'true';
|
||||
const [name, setName] = React.useState(appName);
|
||||
const [name, setName] = useState(appName);
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [warningText, setWarningText] = useState('');
|
||||
|
||||
React.useEffect(() => {
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setName(appName);
|
||||
}, [appName]);
|
||||
|
||||
const saveAppName = async (name) => {
|
||||
const newName = name.trim();
|
||||
if (!validateName(name, 'App name').status) {
|
||||
return;
|
||||
}
|
||||
if (newName === appName) {
|
||||
//will set back name without starting and ending spaces
|
||||
setName(newName);
|
||||
return;
|
||||
}
|
||||
await appService
|
||||
.saveApp(appId, { name: newName })
|
||||
.then(() => {
|
||||
onNameChanged(newName);
|
||||
})
|
||||
.catch((error) => {
|
||||
handleHttpErrorMessages(error, 'app');
|
||||
});
|
||||
const clearError = () => {
|
||||
setIsError(false);
|
||||
setErrorMessage('');
|
||||
};
|
||||
|
||||
const setError = (message) => {
|
||||
setIsError(true);
|
||||
setErrorMessage(message);
|
||||
};
|
||||
|
||||
const saveAppName = async (newName) => {
|
||||
const trimmedName = newName.trim();
|
||||
if (validateName(trimmedName, 'App name', true)?.errorMsg) {
|
||||
setName(appName);
|
||||
clearError();
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedName === appName) {
|
||||
setIsValid(true);
|
||||
setIsEditing(false);
|
||||
setName(appName);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await appService.saveApp(appId, { name: trimmedName });
|
||||
onNameChanged(trimmedName);
|
||||
setIsValid(true);
|
||||
setIsEditing(false);
|
||||
toast.success('App name successfully updated!');
|
||||
} catch (error) {
|
||||
if (error.statusCode === 409) {
|
||||
setError('App name already exists');
|
||||
} else {
|
||||
clearError();
|
||||
setName(appName);
|
||||
setIsEditing(false);
|
||||
handleHttpErrorMessages(error, 'app');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
saveAppName(name);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsValid(true);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleInput = (e) => {
|
||||
const newValue = e.target.value;
|
||||
setName(newValue);
|
||||
if (newValue.length >= 50) {
|
||||
setWarningText('Maximum length has been reached');
|
||||
} else {
|
||||
setWarningText('');
|
||||
clearError();
|
||||
}
|
||||
};
|
||||
|
||||
const borderColor = isError
|
||||
? 'var(--light-tomato-10, #DB4324)' // Apply error border color
|
||||
: darkMode
|
||||
? 'var(--dark-border-color, #2D3748)' // Change this to the appropriate dark border color
|
||||
: 'var(--light-border-color, #FFF0EE)';
|
||||
|
||||
return (
|
||||
<ToolTip message={name} placement="bottom">
|
||||
<div className={`app-name input-icon ${darkMode ? 'dark' : ''}`}>
|
||||
<div className={`app-name input-icon ${darkMode ? 'dark' : ''}`}>
|
||||
<ToolTip message={name} placement="bottom" isVisible={!isEditing}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
onChange={(e) => {
|
||||
onChange={() => {
|
||||
//this was quick fix. replace this with actual tooltip props and state later
|
||||
if (document.getElementsByClassName('tooltip').length) {
|
||||
document.getElementsByClassName('tooltip')[0].style.display = 'none';
|
||||
}
|
||||
validateName(e.target.value, 'App name', true);
|
||||
setName(e.target.value);
|
||||
}}
|
||||
onBlur={(e) => saveAppName(e.target.value)}
|
||||
className="form-control-plaintext form-control-plaintext-sm"
|
||||
onInput={handleInput}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onClick={() => {
|
||||
inputRef.current.select();
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className={`form-control-plaintext form-control-plaintext-sm ${
|
||||
(!isError && !isEditing) || isValid ? '' : 'is-invalid'
|
||||
} ${isError ? 'error' : ''}`} // Add the 'error' class when there's an error
|
||||
style={{ border: `1px solid ${borderColor}` }}
|
||||
value={name}
|
||||
maxLength={50}
|
||||
data-cy="app-name-input"
|
||||
/>
|
||||
</div>
|
||||
</ToolTip>
|
||||
</ToolTip>
|
||||
<InfoOrErrorBox
|
||||
active={isError || isEditing}
|
||||
message={
|
||||
errorMessage ||
|
||||
warningText ||
|
||||
(name.length >= 50 ? 'Maximum length has been reached' : 'App name should be unique and max 50 characters')
|
||||
}
|
||||
isWarning={warningText || name.length >= 50}
|
||||
isError={isError}
|
||||
darkMode={darkMode}
|
||||
additionalClassName={isError ? 'error' : ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
38
frontend/src/Editor/Header/InfoOrErrorBox.jsx
Normal file
38
frontend/src/Editor/Header/InfoOrErrorBox.jsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import React from 'react';
|
||||
|
||||
function InfoOrErrorBox({ active, message, isError, isWarning, darkMode, additionalClassName }) {
|
||||
const color = isError ? 'var(--light-tomato-10, #DB4324)' : isWarning ? '#ED5F00' : 'var(--slate-light-10, #7E868C)';
|
||||
const boxStyle = {
|
||||
display: active ? 'flex' : 'none',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-start',
|
||||
gap: '2px',
|
||||
width: '200px',
|
||||
height: '32px',
|
||||
borderRadius: '6px',
|
||||
border: `1px solid ${darkMode ? 'var(--dark-border-color, #2D3748)' : 'var(--light-border-color, #FFF0EE)'}`,
|
||||
background: darkMode ? 'var(--dark-bg-01, #1E293B)' : 'var(--base-white-00, #FFF)',
|
||||
boxShadow: '0px 1px 2px 0px rgba(16, 24, 40, 0.05)',
|
||||
color: color,
|
||||
zIndex: 10000,
|
||||
position: 'absolute',
|
||||
fontFamily: 'IBM Plex Sans',
|
||||
fontSize: '10px',
|
||||
fontStyle: 'normal',
|
||||
fontWeight: 500,
|
||||
lineHeight: '16px',
|
||||
padding: '2px 8px',
|
||||
...(additionalClassName && {
|
||||
...additionalClassName.split(' ').reduce((acc, cls) => ({ ...acc, [cls]: true }), {}),
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={additionalClassName} style={boxStyle}>
|
||||
{message && <div>{message}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InfoOrErrorBox;
|
||||
|
|
@ -2,7 +2,7 @@ import React, { useState, useCallback, useEffect } from 'react';
|
|||
import cx from 'classnames';
|
||||
import { AppMenu } from './AppMenu';
|
||||
import moment from 'moment';
|
||||
import { ToolTip } from '@/_components';
|
||||
import { ToolTip } from '@/_components/index';
|
||||
import useHover from '@/_hooks/useHover';
|
||||
import configs from './Configs/AppIcon.json';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
|
@ -66,18 +66,18 @@ export default function AppCard({
|
|||
|
||||
return (
|
||||
<div className="card homepage-app-card animation-fade">
|
||||
<div key={app.id} ref={hoverRef} data-cy={`${app.name.toLowerCase().replace(/\s+/g, '-')}-card`}>
|
||||
<div key={app?.id} ref={hoverRef} data-cy={`${app?.name.toLowerCase().replace(/\s+/g, '-')}-card`}>
|
||||
<div className="row home-app-card-header">
|
||||
<div className="col-12 d-flex justify-content-between">
|
||||
<div>
|
||||
<div className="app-icon-main">
|
||||
<div className="app-icon d-flex" data-cy={`app-card-${app.icon}-icon`}>
|
||||
<div className="app-icon d-flex" data-cy={`app-card-${app?.icon}-icon`}>
|
||||
{AppIcon && AppIcon}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div visible={focused}>
|
||||
{(canCreateApp(app) || canDeleteApp(app)) && (
|
||||
{(canCreateApp(app) || canDeleteApp(app) || canUpdateApp(app)) && (
|
||||
<AppMenu
|
||||
onMenuOpen={onMenuToggle}
|
||||
openAppActionModal={appActionModalCallBack}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next';
|
|||
|
||||
export const AppMenu = function AppMenu({
|
||||
deleteApp,
|
||||
cloneApp,
|
||||
exportApp,
|
||||
canCreateApp,
|
||||
canDeleteApp,
|
||||
|
|
@ -47,6 +46,12 @@ export const AppMenu = function AppMenu({
|
|||
<Popover id="popover-app-menu" className={darkMode && 'dark-theme'} placement="bottom">
|
||||
<Popover.Body bsPrefix="popover-body">
|
||||
<div data-cy="card-options">
|
||||
{canUpdateApp && (
|
||||
<Field
|
||||
text={t('homePage.appCard.renameApp', 'Rename app')}
|
||||
onClick={() => openAppActionModal('rename-app')}
|
||||
/>
|
||||
)}
|
||||
{canUpdateApp && (
|
||||
<Field
|
||||
text={t('homePage.appCard.changeIcon', 'Change Icon')}
|
||||
|
|
@ -66,7 +71,10 @@ export const AppMenu = function AppMenu({
|
|||
onClick={() => openAppActionModal('remove-app-from-folder')}
|
||||
/>
|
||||
)}
|
||||
<Field text={t('homePage.appCard.cloneApp', 'Clone app')} onClick={cloneApp} />
|
||||
<Field
|
||||
text={t('homePage.appCard.cloneApp', 'Clone app')}
|
||||
onClick={() => openAppActionModal('clone-app')}
|
||||
/>
|
||||
<Field text={t('homePage.appCard.exportApp', 'Export app')} onClick={exportApp} />
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,22 @@
|
|||
import React, { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import TemplateLibraryModal from './TemplateLibraryModal/';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { libraryAppService } from '@/_services';
|
||||
import EmptyIllustration from '@assets/images/no-apps.svg';
|
||||
import { ButtonSolid } from '@/_ui/AppButton/AppButton';
|
||||
import { getWorkspaceId } from '../_helpers/utils';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export const BlankPage = function BlankPage({
|
||||
createApp,
|
||||
darkMode,
|
||||
creatingApp,
|
||||
handleImportApp,
|
||||
readAndImport,
|
||||
isImportingApp,
|
||||
fileInput,
|
||||
openCreateAppModal,
|
||||
openCreateAppFromTemplateModal,
|
||||
creatingApp,
|
||||
darkMode,
|
||||
showTemplateLibraryModal,
|
||||
hideTemplateLibraryModal,
|
||||
viewTemplateLibraryModal,
|
||||
canCreateApp,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [deploying, setDeploying] = useState(false);
|
||||
|
|
@ -29,25 +28,7 @@ export const BlankPage = function BlankPage({
|
|||
{ id: 'whatsapp-and-sms-crm', name: 'Whatsapp and sms crm' },
|
||||
];
|
||||
|
||||
function deployApp(id) {
|
||||
if (!deploying) {
|
||||
const loadingToastId = toast.loading('Deploying app...');
|
||||
setDeploying(true);
|
||||
libraryAppService
|
||||
.deploy(id)
|
||||
.then((data) => {
|
||||
setDeploying(false);
|
||||
toast.dismiss(loadingToastId);
|
||||
toast.success('App created.');
|
||||
navigate(`/${getWorkspaceId()}/apps/${data.id}`);
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.dismiss(loadingToastId);
|
||||
toast.error(e.error);
|
||||
setDeploying(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
const appCreationDisabled = !canCreateApp();
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -70,27 +51,30 @@ export const BlankPage = function BlankPage({
|
|||
<div className="row mt-4">
|
||||
<ButtonSolid
|
||||
leftIcon="plus"
|
||||
onClick={createApp}
|
||||
onClick={openCreateAppModal}
|
||||
isLoading={creatingApp}
|
||||
data-cy="button-new-app-from-scratch"
|
||||
className="col"
|
||||
fill={'#FDFDFE'}
|
||||
disabled={appCreationDisabled}
|
||||
>
|
||||
Create new application
|
||||
</ButtonSolid>
|
||||
<div className="col">
|
||||
<ButtonSolid
|
||||
leftIcon="folderdownload"
|
||||
onChange={handleImportApp}
|
||||
onChange={readAndImport}
|
||||
isLoading={isImportingApp}
|
||||
data-cy="button-import-an-app"
|
||||
className="col"
|
||||
variant="tertiary"
|
||||
disabled={appCreationDisabled}
|
||||
variant={!appCreationDisabled ? 'tertiary' : 'primary'}
|
||||
>
|
||||
<label
|
||||
className="cursor-pointer"
|
||||
style={{ visibility: isImportingApp ? 'hidden' : 'visible' }}
|
||||
data-cy="import-an-application"
|
||||
disabled={appCreationDisabled}
|
||||
>
|
||||
{t('blankPage.importApplication', 'Import an app')}
|
||||
<input
|
||||
|
|
@ -98,6 +82,7 @@ export const BlankPage = function BlankPage({
|
|||
ref={fileInput}
|
||||
style={{ display: 'none' }}
|
||||
data-cy="import-option-input"
|
||||
disabled={appCreationDisabled}
|
||||
/>
|
||||
</label>
|
||||
</ButtonSolid>
|
||||
|
|
@ -108,44 +93,54 @@ export const BlankPage = function BlankPage({
|
|||
<EmptyIllustration />
|
||||
</div>
|
||||
</div>
|
||||
<div className="hr-text" data-cy="action-option">
|
||||
Or choose from templates
|
||||
</div>
|
||||
<div className="row" data-cy="app-template-row">
|
||||
{staticTemplates.map(({ id, name }) => {
|
||||
return (
|
||||
<div key={id} className="col-4 app-template-card-wrapper" onClick={() => deployApp(id)}>
|
||||
<div
|
||||
className="template-card cursor-pointer"
|
||||
data-cy={`${name.toLowerCase().replace(/\s+/g, '-')}-app-template-card`}
|
||||
>
|
||||
{!appCreationDisabled && (
|
||||
<div>
|
||||
<div className="hr-text" data-cy="action-option">
|
||||
Or choose from templates
|
||||
</div>
|
||||
<div className="row" data-cy="app-template-row">
|
||||
{staticTemplates.map(({ id, name }) => {
|
||||
return (
|
||||
<div
|
||||
className="img-responsive img-responsive-21x9 card-img-top template-card-img"
|
||||
style={{ backgroundImage: `url(assets/images/templates/${id}.png)` }}
|
||||
data-cy={`${name.toLowerCase().replace(/\s+/g, '-')}-app-template-image`}
|
||||
/>
|
||||
<div className="card-body">
|
||||
<h3
|
||||
className="tj-text-md font-weight-500"
|
||||
data-cy={`${name.toLowerCase().replace(/\s+/g, '-')}-app-template-title`}
|
||||
key={id}
|
||||
className="col-4 app-template-card-wrapper"
|
||||
onClick={() => {
|
||||
openCreateAppFromTemplateModal({ id, name });
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="template-card cursor-pointer"
|
||||
data-cy={`${name.toLowerCase().replace(/\s+/g, '-')}-app-template-card`}
|
||||
>
|
||||
{name}
|
||||
</h3>
|
||||
<div
|
||||
className="img-responsive img-responsive-21x9 card-img-top template-card-img"
|
||||
style={{ backgroundImage: `url(assets/images/templates/${id}.png)` }}
|
||||
data-cy={`${name.toLowerCase().replace(/\s+/g, '-')}-app-template-image`}
|
||||
/>
|
||||
<div className="card-body">
|
||||
<h3
|
||||
className="tj-text-md font-weight-500"
|
||||
data-cy={`${name.toLowerCase().replace(/\s+/g, '-')}-app-template-title`}
|
||||
>
|
||||
{name}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="m-auto text-center mt-4">
|
||||
<button
|
||||
className="see-all-temlplates-link tj-text-sm font-weight-600 bg-transparent border-0"
|
||||
onClick={viewTemplateLibraryModal}
|
||||
data-cy="see-all-apps-template-buton"
|
||||
>
|
||||
See all templates
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="m-auto text-center mt-4">
|
||||
<button
|
||||
className="see-all-temlplates-link tj-text-sm font-weight-600 bg-transparent border-0"
|
||||
onClick={viewTemplateLibraryModal}
|
||||
data-cy="see-all-apps-template-buton"
|
||||
>
|
||||
See all templates
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -155,6 +150,7 @@ export const BlankPage = function BlankPage({
|
|||
onHide={hideTemplateLibraryModal}
|
||||
onCloseButtonClick={hideTemplateLibraryModal}
|
||||
darkMode={darkMode}
|
||||
appCreationDisabled={appCreationDisabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import cx from 'classnames';
|
||||
import { appService, folderService, authenticationService } from '@/_services';
|
||||
import { ConfirmDialog } from '@/_components';
|
||||
import { appService, folderService, authenticationService, libraryAppService } from '@/_services';
|
||||
import { ConfirmDialog, AppModal } from '@/_components';
|
||||
import Select from '@/_ui/Select';
|
||||
import { Folders } from './Folders';
|
||||
import { BlankPage } from './BlankPage';
|
||||
|
|
@ -61,6 +61,15 @@ class HomePageComponent extends React.Component {
|
|||
appOperations: {},
|
||||
showTemplateLibraryModal: false,
|
||||
app: {},
|
||||
showCreateAppModal: false,
|
||||
showCreateAppFromTemplateModal: false,
|
||||
showImportAppModal: false,
|
||||
showCloneAppModal: false,
|
||||
showRenameAppModal: false,
|
||||
fileContent: '',
|
||||
fileName: '',
|
||||
selectedTemplate: null,
|
||||
deploying: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -119,88 +128,140 @@ class HomePageComponent extends React.Component {
|
|||
this.fetchFolders();
|
||||
};
|
||||
|
||||
createApp = () => {
|
||||
createApp = async (appName) => {
|
||||
let _self = this;
|
||||
_self.setState({ creatingApp: true });
|
||||
appService
|
||||
.createApp({ icon: sample(iconList) })
|
||||
.then((data) => {
|
||||
const workspaceId = getWorkspaceId();
|
||||
_self.props.navigate(`/${workspaceId}/apps/${data.id}`);
|
||||
})
|
||||
.catch(({ error }) => {
|
||||
toast.error(error);
|
||||
try {
|
||||
const data = await appService.createApp({ icon: sample(iconList), name: appName });
|
||||
|
||||
const workspaceId = getWorkspaceId();
|
||||
_self.props.navigate(`/${workspaceId}/apps/${data.id}`);
|
||||
toast.success('App created successfully!');
|
||||
return true;
|
||||
} catch (errorResponse) {
|
||||
if (errorResponse.statusCode === 409) {
|
||||
_self.setState({ creatingApp: false });
|
||||
});
|
||||
return false;
|
||||
} else {
|
||||
throw errorResponse;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
renameApp = async (newAppName, appId) => {
|
||||
let _self = this;
|
||||
_self.setState({ renamingApp: true });
|
||||
try {
|
||||
await appService.saveApp(appId, { name: newAppName });
|
||||
await this.fetchApps();
|
||||
toast.success('App name has been updated!');
|
||||
return true;
|
||||
} catch (errorResponse) {
|
||||
if (errorResponse.statusCode === 409) {
|
||||
console.log(errorResponse);
|
||||
_self.setState({ renamingApp: false });
|
||||
return false;
|
||||
} else {
|
||||
throw errorResponse;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
deleteApp = (app) => {
|
||||
this.setState({ showAppDeletionConfirmation: true, appToBeDeleted: app });
|
||||
};
|
||||
|
||||
cloneApp = (app) => {
|
||||
cloneApp = async (appId, appName) => {
|
||||
this.setState({ isCloningApp: true });
|
||||
appService
|
||||
.cloneResource({ app: [{ id: app.id }], organization_id: getWorkspaceId() })
|
||||
.then((data) => {
|
||||
toast.success('App cloned successfully.');
|
||||
this.setState({ isCloningApp: false });
|
||||
this.props.navigate(`/${getWorkspaceId()}/apps/${data.imports.app[0].id}`);
|
||||
})
|
||||
.catch(({ _error }) => {
|
||||
toast.error('Could not clone the app.');
|
||||
this.setState({ isCloningApp: false });
|
||||
console.log(_error);
|
||||
});
|
||||
try {
|
||||
const data = await appService.cloneApp(appName, appId);
|
||||
toast.success('App cloned successfully!');
|
||||
this.setState({ isCloningApp: false });
|
||||
this.props.navigate(`/${getWorkspaceId()}/apps/${data.id}`);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
this.setState({ isCloningApp: false });
|
||||
if (_error.statusCode === 409) {
|
||||
return false;
|
||||
} else {
|
||||
throw _error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exportApp = async (app) => {
|
||||
this.setState({ isExportingApp: true, app: app });
|
||||
};
|
||||
|
||||
handleImportApp = (event) => {
|
||||
const fileReader = new FileReader();
|
||||
fileReader.readAsText(event.target.files[0], 'UTF-8');
|
||||
fileReader.onload = (event) => {
|
||||
const fileContent = event.target.result;
|
||||
this.setState({ isImportingApp: true });
|
||||
try {
|
||||
const organization_id = getWorkspaceId();
|
||||
let importJSON = JSON.parse(fileContent);
|
||||
// For backward compatibility with legacy app import
|
||||
const isLegacyImport = isEmpty(importJSON.tooljet_version);
|
||||
if (isLegacyImport) {
|
||||
importJSON = { app: [{ definition: importJSON }], tooljet_version: importJSON.tooljetVersion };
|
||||
}
|
||||
const requestBody = { organization_id, ...importJSON };
|
||||
appService
|
||||
.importResource(requestBody)
|
||||
.then((data) => {
|
||||
toast.success('Imported successfully.');
|
||||
this.setState({
|
||||
isImportingApp: false,
|
||||
});
|
||||
if (!isEmpty(data.imports.app)) {
|
||||
this.props.navigate(`/${getWorkspaceId()}/apps/${data.imports.app[0].id}`);
|
||||
} else if (!isEmpty(data.imports.tooljet_database)) {
|
||||
this.props.navigate(`/${getWorkspaceId()}/database`);
|
||||
}
|
||||
})
|
||||
.catch(({ error }) => {
|
||||
toast.error(`Could not import: ${error}`);
|
||||
this.setState({
|
||||
isImportingApp: false,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(`Could not import: ${error}`);
|
||||
this.setState({
|
||||
isImportingApp: false,
|
||||
});
|
||||
}
|
||||
// set file input as null to handle same file upload
|
||||
readAndImport = (event) => {
|
||||
try {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const fileReader = new FileReader();
|
||||
const fileName = file.name.replace('.json', '').substring(0, 50);
|
||||
fileReader.readAsText(file, 'UTF-8');
|
||||
fileReader.onload = (event) => {
|
||||
const result = event.target.result;
|
||||
const fileContent = JSON.parse(result);
|
||||
this.setState({ fileContent, fileName, showImportAppModal: true });
|
||||
};
|
||||
fileReader.onerror = (error) => {
|
||||
throw new Error(`Could not import the app: ${error}`);
|
||||
};
|
||||
event.target.value = null;
|
||||
};
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
importFile = async (importJSON, appName) => {
|
||||
this.setState({ isImportingApp: true });
|
||||
// For backward compatibility with legacy app import
|
||||
const organization_id = getWorkspaceId();
|
||||
const isLegacyImport = isEmpty(importJSON.tooljet_version);
|
||||
if (isLegacyImport) {
|
||||
importJSON = { app: [{ definition: importJSON }], tooljet_version: importJSON.tooljetVersion };
|
||||
}
|
||||
const requestBody = { organization_id, appName, ...importJSON };
|
||||
try {
|
||||
const data = await appService.importResource(requestBody);
|
||||
toast.success('App imported successfully.');
|
||||
this.setState({
|
||||
isImportingApp: false,
|
||||
});
|
||||
if (!isEmpty(data.imports.app)) {
|
||||
this.props.navigate(`/${getWorkspaceId()}/apps/${data.imports.app[0].id}`);
|
||||
} else if (!isEmpty(data.imports.tooljet_database)) {
|
||||
this.props.navigate(`/${getWorkspaceId()}/database`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.setState({
|
||||
isImportingApp: false,
|
||||
});
|
||||
if (error.statusCode === 409) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
deployApp = async (event, appName, selectedApp) => {
|
||||
event.preventDefault();
|
||||
const id = selectedApp.id;
|
||||
this.setState({ deploying: true });
|
||||
try {
|
||||
const data = await libraryAppService.deploy(id, appName);
|
||||
this.setState({ deploying: false });
|
||||
toast.success('App created successfully!', { position: 'top-center' });
|
||||
this.props.navigate(`/${getWorkspaceId()}/apps/${data.app[0].id}`);
|
||||
} catch (e) {
|
||||
this.setState({ deploying: false });
|
||||
if (e.statusCode === 409) {
|
||||
return false;
|
||||
} else {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
canUserPerform(user, action, app) {
|
||||
|
|
@ -387,6 +448,18 @@ class HomePageComponent extends React.Component {
|
|||
showRemoveAppFromFolderConfirmation: true,
|
||||
});
|
||||
break;
|
||||
case 'clone-app':
|
||||
this.setState({
|
||||
appOperations: { ...appOperations, selectedApp: app, selectedIcon: app?.icon },
|
||||
showCloneAppModal: true,
|
||||
});
|
||||
break;
|
||||
case 'rename-app':
|
||||
this.setState({
|
||||
appOperations: { ...appOperations, selectedApp: app },
|
||||
showRenameAppModal: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -442,6 +515,22 @@ class HomePageComponent extends React.Component {
|
|||
this.setState({ showTemplateLibraryModal: false });
|
||||
};
|
||||
|
||||
openCreateAppFromTemplateModal = (template) => {
|
||||
this.setState({ showCreateAppFromTemplateModal: true, selectedTemplate: template });
|
||||
};
|
||||
|
||||
closeCreateAppFromTemplateModal = () => {
|
||||
this.setState({ showCreateAppFromTemplateModal: false, selectedTemplate: null });
|
||||
};
|
||||
|
||||
openCreateAppModal = () => {
|
||||
this.setState({ showCreateAppModal: true });
|
||||
};
|
||||
|
||||
closeCreateAppModal = () => {
|
||||
this.setState({ showCreateAppModal: false });
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
apps,
|
||||
|
|
@ -457,14 +546,78 @@ class HomePageComponent extends React.Component {
|
|||
appSearchKey,
|
||||
showAddToFolderModal,
|
||||
showChangeIconModal,
|
||||
showCloneAppModal,
|
||||
appOperations,
|
||||
isExportingApp,
|
||||
appToBeDeleted,
|
||||
app,
|
||||
showCreateAppModal,
|
||||
showImportAppModal,
|
||||
fileContent,
|
||||
fileName,
|
||||
showRenameAppModal,
|
||||
showCreateAppFromTemplateModal,
|
||||
} = this.state;
|
||||
return (
|
||||
<Layout switchDarkMode={this.props.switchDarkMode} darkMode={this.props.darkMode}>
|
||||
<div className="wrapper home-page">
|
||||
{showCreateAppModal && (
|
||||
<AppModal
|
||||
closeModal={this.closeCreateAppModal}
|
||||
processApp={this.createApp}
|
||||
show={this.openCreateAppModal}
|
||||
title={'Create app'}
|
||||
actionButton={'+ Create app'}
|
||||
actionLoadingButton={'Creating'}
|
||||
/>
|
||||
)}
|
||||
{showCloneAppModal && (
|
||||
<AppModal
|
||||
closeModal={() => this.setState({ showCloneAppModal: false })}
|
||||
processApp={this.cloneApp}
|
||||
show={() => this.setState({ showCloneAppModal: true })}
|
||||
selectedAppId={appOperations?.selectedApp?.id}
|
||||
selectedAppName={appOperations?.selectedApp?.name}
|
||||
title={'Clone app'}
|
||||
actionButton={'Clone app'}
|
||||
actionLoadingButton={'Cloning'}
|
||||
/>
|
||||
)}
|
||||
{showImportAppModal && (
|
||||
<AppModal
|
||||
closeModal={() => this.setState({ showImportAppModal: false })}
|
||||
processApp={this.importFile}
|
||||
fileContent={fileContent}
|
||||
show={() => this.setState({ showImportAppModal: true })}
|
||||
selectedAppName={fileName}
|
||||
title={'Import app'}
|
||||
actionButton={'Import app'}
|
||||
actionLoadingButton={'Importing'}
|
||||
/>
|
||||
)}
|
||||
{showCreateAppFromTemplateModal && (
|
||||
<AppModal
|
||||
show={this.openCreateAppFromTemplateModal}
|
||||
templateDetails={this.state.selectedTemplate}
|
||||
processApp={this.deployApp}
|
||||
closeModal={this.closeCreateAppFromTemplateModal}
|
||||
title={'Create new app from template'}
|
||||
actionButton={'+ Create app'}
|
||||
actionLoadingButton={'Creating'}
|
||||
/>
|
||||
)}
|
||||
{showRenameAppModal && (
|
||||
<AppModal
|
||||
show={() => this.setState({ showRenameAppModal: true })}
|
||||
closeModal={() => this.setState({ showRenameAppModal: false })}
|
||||
processApp={this.renameApp}
|
||||
selectedAppId={appOperations.selectedApp.id}
|
||||
selectedAppName={appOperations.selectedApp.name}
|
||||
title={'Rename app'}
|
||||
actionButton={'Rename app'}
|
||||
actionLoadingButton={'Renaming'}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
show={showAppDeletionConfirmation}
|
||||
message={this.props.t(
|
||||
|
|
@ -479,7 +632,6 @@ class HomePageComponent extends React.Component {
|
|||
onCancel={() => this.cancelDeleteAppDialog()}
|
||||
darkMode={this.props.darkMode}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
show={showRemoveAppFromFolderConfirmation}
|
||||
message={this.props.t(
|
||||
|
|
@ -598,7 +750,7 @@ class HomePageComponent extends React.Component {
|
|||
<Dropdown as={ButtonGroup} className="d-inline-flex create-new-app-dropdown">
|
||||
<Button
|
||||
className={`create-new-app-button col-11 ${creatingApp ? 'btn-loading' : ''}`}
|
||||
onClick={this.createApp}
|
||||
onClick={() => this.setState({ showCreateAppModal: true })}
|
||||
data-cy="create-new-app-button"
|
||||
>
|
||||
{isImportingApp && <span className="spinner-border spinner-border-sm mx-2" role="status"></span>}
|
||||
|
|
@ -616,7 +768,7 @@ class HomePageComponent extends React.Component {
|
|||
<label
|
||||
className="homepage-dropdown-style tj-text tj-text-xsm"
|
||||
data-cy="import-option-label"
|
||||
onChange={this.handleImportApp}
|
||||
onChange={this.readAndImport}
|
||||
>
|
||||
{this.props.t('homePage.header.import', 'Import')}
|
||||
<input
|
||||
|
|
@ -661,15 +813,17 @@ class HomePageComponent extends React.Component {
|
|||
)}
|
||||
{!isLoading && meta?.total_count === 0 && !currentFolder.id && !appSearchKey && (
|
||||
<BlankPage
|
||||
createApp={this.createApp}
|
||||
readAndImport={this.readAndImport}
|
||||
isImportingApp={isImportingApp}
|
||||
fileInput={this.fileInput}
|
||||
handleImportApp={this.handleImportApp}
|
||||
openCreateAppModal={this.openCreateAppModal}
|
||||
openCreateAppFromTemplateModal={this.openCreateAppFromTemplateModal}
|
||||
creatingApp={creatingApp}
|
||||
darkMode={this.props.darkMode}
|
||||
showTemplateLibraryModal={this.state.showTemplateLibraryModal}
|
||||
viewTemplateLibraryModal={this.showTemplateLibraryModal}
|
||||
hideTemplateLibraryModal={this.hideTemplateLibraryModal}
|
||||
canCreateApp={this.canCreateApp}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && meta.total_count === 0 && appSearchKey && (
|
||||
|
|
@ -714,6 +868,8 @@ class HomePageComponent extends React.Component {
|
|||
onHide={() => this.setState({ showTemplateLibraryModal: false })}
|
||||
onCloseButtonClick={() => this.setState({ showTemplateLibraryModal: false })}
|
||||
darkMode={this.props.darkMode}
|
||||
openCreateAppFromTemplateModal={this.openCreateAppFromTemplateModal}
|
||||
appCreationDisabled={!this.canCreateApp()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
import React from 'react';
|
||||
import { default as BootstrapModal } from 'react-bootstrap/Modal';
|
||||
|
||||
export default function Modal({ title, show, closeModal, customClassName, children }) {
|
||||
export default function Modal({ title, show, closeModal, customClassName, children, footerContent = null }) {
|
||||
const darkMode = localStorage.getItem('darkMode') === 'true';
|
||||
const modalFooter = footerContent ? (
|
||||
<BootstrapModal.Footer className={`modal-divider ${darkMode ? 'dark-theme-modal-divider' : ''}`}>
|
||||
{footerContent}
|
||||
</BootstrapModal.Footer>
|
||||
) : null;
|
||||
return (
|
||||
<BootstrapModal
|
||||
onHide={() => closeModal(false)}
|
||||
|
|
@ -31,6 +36,7 @@ export default function Modal({ title, show, closeModal, customClassName, childr
|
|||
></button>
|
||||
</BootstrapModal.Header>
|
||||
<BootstrapModal.Body>{children}</BootstrapModal.Body>
|
||||
{modalFooter ? modalFooter : <></>}
|
||||
</BootstrapModal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Container, Row, Col } from 'react-bootstrap';
|
||||
import Categories from './Categories';
|
||||
import AppList from './AppList';
|
||||
|
|
@ -25,6 +25,7 @@ export default function TemplateLibraryModal(props) {
|
|||
(app) => selectedCategory.id === 'all' || app.category === selectedCategory.id
|
||||
);
|
||||
const [selectedApp, selectApp] = useState(undefined);
|
||||
const [showCreateAppFromTemplateModal, setShowCreateAppFromTemplateModal] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -51,31 +52,10 @@ export default function TemplateLibraryModal(props) {
|
|||
|
||||
const [deploying, setDeploying] = useState(false);
|
||||
|
||||
function deployApp(event) {
|
||||
event.preventDefault();
|
||||
const id = selectedApp.id;
|
||||
setDeploying(true);
|
||||
libraryAppService
|
||||
.deploy(id)
|
||||
.then((data) => {
|
||||
setDeploying(false);
|
||||
props.onCloseButtonClick();
|
||||
toast.success('App created.', {
|
||||
position: 'top-center',
|
||||
});
|
||||
navigate(`/${getWorkspaceId()}/apps/${data.app[0].id}`);
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error(e.error, {
|
||||
position: 'top-center',
|
||||
});
|
||||
setDeploying(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
{...props}
|
||||
show={props.show}
|
||||
onHide={props.onCloseButtonClick}
|
||||
className={`template-library-modal ${props.darkMode ? 'dark-mode dark-theme' : ''}`}
|
||||
aria-labelledby="contained-modal-title-vcenter"
|
||||
centered
|
||||
|
|
@ -114,11 +94,14 @@ export default function TemplateLibraryModal(props) {
|
|||
{t('globals.cancel', 'Cancel')}
|
||||
</ButtonSolid>
|
||||
<ButtonSolid
|
||||
onClick={(e) => {
|
||||
deployApp(e);
|
||||
onClick={() => {
|
||||
props.openCreateAppFromTemplateModal(selectedApp);
|
||||
setShowCreateAppFromTemplateModal(false);
|
||||
props.onCloseButtonClick();
|
||||
}}
|
||||
isLoading={deploying}
|
||||
className=" ms-2 "
|
||||
className="ms-2"
|
||||
disabled={props.appCreationDisabled}
|
||||
>
|
||||
{t('homePage.templateLibraryModal.createAppfromTemplate', 'Create application from template')}
|
||||
</ButtonSolid>
|
||||
|
|
|
|||
187
frontend/src/_components/AppModal.jsx
Normal file
187
frontend/src/_components/AppModal.jsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import React, { useState, useEffect, useContext, useRef } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import Modal from '../HomePage/Modal';
|
||||
import { ButtonSolid } from '@/_ui/AppButton/AppButton';
|
||||
import _ from 'lodash';
|
||||
import { validateAppName } from '@/_helpers/utils';
|
||||
|
||||
export function AppModal({
|
||||
closeModal,
|
||||
processApp,
|
||||
show,
|
||||
fileContent = null,
|
||||
templateDetails = null,
|
||||
selectedAppId = null,
|
||||
selectedAppName = null,
|
||||
title,
|
||||
actionButton,
|
||||
actionLoadingButton,
|
||||
}) {
|
||||
if (!selectedAppName && templateDetails) {
|
||||
selectedAppName = templateDetails?.name || '';
|
||||
} else if (!selectedAppName) {
|
||||
selectedAppName = '';
|
||||
}
|
||||
|
||||
if (actionButton === 'Clone app') {
|
||||
if (selectedAppName.length >= 45) {
|
||||
selectedAppName = selectedAppName.slice(0, 45) + '_Copy';
|
||||
} else {
|
||||
selectedAppName = selectedAppName + '_Copy';
|
||||
}
|
||||
}
|
||||
|
||||
const [deploying, setDeploying] = useState(false);
|
||||
const [newAppName, setNewAppName] = useState(selectedAppName);
|
||||
const [errorText, setErrorText] = useState('');
|
||||
const [infoText, setInfoText] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isNameChanged, setIsNameChanged] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const [clearInput, setClearInput] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIsNameChanged(newAppName?.trim() !== selectedAppName);
|
||||
}, [newAppName, selectedAppName]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsSuccess(false);
|
||||
}, [show]);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.select();
|
||||
}, [show]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsSuccess(false);
|
||||
setClearInput(false);
|
||||
setNewAppName(selectedAppName);
|
||||
}, [selectedAppName]);
|
||||
|
||||
const handleAction = async (e) => {
|
||||
setDeploying(true);
|
||||
const trimmedAppName = newAppName.trim();
|
||||
setNewAppName(trimmedAppName);
|
||||
if (!errorText) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
let success = true;
|
||||
//create app from template
|
||||
if (templateDetails) {
|
||||
success = await processApp(e, trimmedAppName, templateDetails);
|
||||
//import app
|
||||
} else if (fileContent) {
|
||||
success = await processApp(fileContent, trimmedAppName);
|
||||
//rename app/clone existing app
|
||||
} else if (selectedAppId) {
|
||||
success = await processApp(trimmedAppName, selectedAppId);
|
||||
//create app from scratch
|
||||
} else {
|
||||
success = await processApp(trimmedAppName);
|
||||
}
|
||||
if (success === false) {
|
||||
setErrorText('App name already exists');
|
||||
setInfoText('');
|
||||
} else {
|
||||
setErrorText('');
|
||||
setInfoText('');
|
||||
closeModal();
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(e.error, {
|
||||
position: 'top-center',
|
||||
});
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const newAppName = e.target.value;
|
||||
const trimmedName = newAppName.trim();
|
||||
setNewAppName(newAppName);
|
||||
if (newAppName.length >= 50) {
|
||||
setInfoText('Maximum length has been reached');
|
||||
} else {
|
||||
setInfoText('');
|
||||
const error = validateAppName(trimmedName);
|
||||
setErrorText(error?.errorMsg || '');
|
||||
}
|
||||
};
|
||||
|
||||
const createBtnDisableState =
|
||||
isLoading ||
|
||||
errorText ||
|
||||
(actionButton === 'Rename app' && (!isNameChanged || newAppName.trim().length === 0 || newAppName.length > 50)) || // For rename case
|
||||
(actionButton !== 'Rename app' && (newAppName.length > 50 || newAppName.trim().length === 0));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
show={show}
|
||||
closeModal={closeModal}
|
||||
title={title}
|
||||
footerContent={
|
||||
<>
|
||||
<ButtonSolid variant="tertiary" onClick={closeModal} data-cy="cancel-button" className="modal-footer-divider">
|
||||
Cancel
|
||||
</ButtonSolid>
|
||||
<ButtonSolid onClick={(e) => handleAction(e)} data-cy={actionButton} disabled={createBtnDisableState}>
|
||||
{isLoading ? actionLoadingButton : actionButton}
|
||||
</ButtonSolid>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="row workspace-folder-modal mb-3">
|
||||
<div className="col modal-main tj-app-input">
|
||||
<label className="tj-input-label">{'App Name'}</label>
|
||||
<input
|
||||
type="text"
|
||||
onChange={handleInputChange}
|
||||
className={`form-control ${errorText ? 'input-error-border' : ''}`}
|
||||
placeholder={'Enter app name'}
|
||||
value={newAppName}
|
||||
data-cy="app-name-input"
|
||||
maxLength={50}
|
||||
autoFocus
|
||||
ref={inputRef}
|
||||
style={{
|
||||
borderColor: errorText ? '#DB4324 !important' : 'initial',
|
||||
}}
|
||||
/>
|
||||
{errorText ? (
|
||||
<small
|
||||
className="tj-input-error"
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
color: '#DB4324',
|
||||
}}
|
||||
>
|
||||
{errorText}
|
||||
</small>
|
||||
) : infoText || newAppName.length >= 50 ? (
|
||||
<small
|
||||
className="tj-input-error"
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
color: '#ED5F00',
|
||||
}}
|
||||
>
|
||||
{infoText || 'Maximum length has been reached'}
|
||||
</small>
|
||||
) : (
|
||||
<small
|
||||
className="tj-input-error"
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
color: '#7E868C',
|
||||
}}
|
||||
>
|
||||
App name must be unique and max 50 characters
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ export * from './ConfirmDialog';
|
|||
export * from './DarkModeToggle';
|
||||
export * from './SearchBox';
|
||||
export * from './ToolTip';
|
||||
export * from './AppModal';
|
||||
export * from './ImageWithSpinner';
|
||||
export * from './Menu';
|
||||
export * from './LoginLoader';
|
||||
|
|
|
|||
|
|
@ -920,6 +920,22 @@ export function isExpectedDataType(data, expectedDataType) {
|
|||
return data;
|
||||
}
|
||||
|
||||
export const validateAppName = (name, showError = false) => {
|
||||
const newName = name.trim();
|
||||
let errorMsg = '';
|
||||
if (newName.length > 50) {
|
||||
errorMsg = `Maximum length has been reached`;
|
||||
showError &&
|
||||
toast.error(errorMsg, {
|
||||
id: '1',
|
||||
});
|
||||
}
|
||||
return {
|
||||
status: !(errorMsg.length > 0),
|
||||
errorMsg,
|
||||
};
|
||||
};
|
||||
|
||||
export const validateName = (name, nameType, showError = false, allowSpecialChars = true) => {
|
||||
const newName = name.trim();
|
||||
let errorMsg = '';
|
||||
|
|
|
|||
|
|
@ -48,8 +48,13 @@ function createApp(body = {}) {
|
|||
return fetch(`${config.apiUrl}/apps`, requestOptions).then(handleResponse);
|
||||
}
|
||||
|
||||
function cloneApp(id) {
|
||||
const requestOptions = { method: 'POST', headers: authHeader(), credentials: 'include' };
|
||||
function cloneApp(id, name) {
|
||||
const requestOptions = {
|
||||
method: 'POST',
|
||||
headers: authHeader(),
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ name }),
|
||||
};
|
||||
return fetch(`${config.apiUrl}/apps/${id}/clone`, requestOptions).then(handleResponse);
|
||||
}
|
||||
|
||||
|
|
@ -97,8 +102,13 @@ function getVersions(id) {
|
|||
return fetch(`${config.apiUrl}/apps/${id}/versions`, requestOptions).then(handleResponse);
|
||||
}
|
||||
|
||||
function importApp(body) {
|
||||
const requestOptions = { method: 'POST', headers: authHeader(), credentials: 'include', body: JSON.stringify(body) };
|
||||
function importApp(app, name) {
|
||||
const requestOptions = {
|
||||
method: 'POST',
|
||||
headers: authHeader(),
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ app, name }),
|
||||
};
|
||||
return fetch(`${config.apiUrl}/apps/import`, requestOptions).then(handleResponse);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ export const libraryAppService = {
|
|||
templateManifests,
|
||||
};
|
||||
|
||||
function deploy(identifier) {
|
||||
function deploy(identifier, appName) {
|
||||
const body = {
|
||||
identifier,
|
||||
appName,
|
||||
};
|
||||
|
||||
const requestOptions = { method: 'POST', headers: authHeader(), credentials: 'include', body: JSON.stringify(body) };
|
||||
|
|
|
|||
|
|
@ -10226,6 +10226,9 @@ tbody {
|
|||
border: 1px solid var(--indigo9) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
&.input-error-border {
|
||||
border-color: #DB4324 !important;
|
||||
}
|
||||
|
||||
&:-webkit-autofill {
|
||||
box-shadow: 0 0 0 1000px var(--base) inset !important;
|
||||
|
|
@ -11298,6 +11301,16 @@ tbody {
|
|||
background-color: #F1F3F5;
|
||||
color: #C1C8CD;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-divider {
|
||||
border-top: 1px solid #dee2e6;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.dark-theme-modal-divider {
|
||||
border-top: 1px solid var(--slate5) !important;
|
||||
padding: 10px;
|
||||
|
||||
.nav-item {
|
||||
background-color: transparent !important;
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
2.20.2
|
||||
2.21.0
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { AppsAbilityFactory } from 'src/modules/casl/abilities/apps-ability.fact
|
|||
import { App } from 'src/entities/app.entity';
|
||||
import { AppImportExportService } from '@services/app_import_export.service';
|
||||
import { User } from 'src/decorators/user.decorator';
|
||||
import { AppImportDto } from '@dto/app-import.dto';
|
||||
|
||||
@Controller('apps')
|
||||
export class AppsImportExportController {
|
||||
|
|
@ -17,13 +18,14 @@ export class AppsImportExportController {
|
|||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('/import')
|
||||
async import(@User() user, @Body() body) {
|
||||
async import(@User() user, @Body() appImportDto: AppImportDto) {
|
||||
const ability = await this.appsAbilityFactory.appsActions(user);
|
||||
|
||||
if (!ability.can('createApp', App)) {
|
||||
throw new ForbiddenException('You do not have permissions to perform this action');
|
||||
}
|
||||
const app = await this.appImportExportService.import(user, body);
|
||||
const { name: appName, app: appContent } = appImportDto;
|
||||
const app = await this.appImportExportService.import(user, appContent, appName);
|
||||
return decamelizeKeys(app);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,12 +21,14 @@ import { FoldersService } from '@services/folders.service';
|
|||
import { App } from 'src/entities/app.entity';
|
||||
import { User } from 'src/decorators/user.decorator';
|
||||
import { AppUpdateDto } from '@dto/app-update.dto';
|
||||
import { AppCreateDto } from '@dto/app-create.dto';
|
||||
import { VersionCreateDto } from '@dto/version-create.dto';
|
||||
import { VersionEditDto } from '@dto/version-edit.dto';
|
||||
import { dbTransactionWrap } from 'src/helpers/utils.helper';
|
||||
import { EntityManager } from 'typeorm';
|
||||
import { ValidAppInterceptor } from 'src/interceptors/valid.app.interceptor';
|
||||
import { AppDecorator } from 'src/decorators/app.decorator';
|
||||
import { AppCloneDto } from '@dto/app-clone.dto';
|
||||
|
||||
@Controller('apps')
|
||||
export class AppsController {
|
||||
|
|
@ -38,17 +40,20 @@ export class AppsController {
|
|||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post()
|
||||
async create(@User() user, @Body('icon') icon: string) {
|
||||
async create(@User() user, @Body() appCreateDto: AppCreateDto) {
|
||||
const ability = await this.appsAbilityFactory.appsActions(user);
|
||||
const name = appCreateDto.name;
|
||||
const icon = appCreateDto.icon;
|
||||
|
||||
if (!ability.can('createApp', App)) {
|
||||
throw new ForbiddenException('You do not have permissions to perform this action');
|
||||
}
|
||||
|
||||
return await dbTransactionWrap(async (manager: EntityManager) => {
|
||||
const app = await this.appsService.create(user, manager);
|
||||
const app = await this.appsService.create(name, user, manager);
|
||||
|
||||
const appUpdateDto = new AppUpdateDto();
|
||||
appUpdateDto.name = name;
|
||||
appUpdateDto.slug = app.id;
|
||||
appUpdateDto.icon = icon;
|
||||
await this.appsService.update(app.id, appUpdateDto, manager);
|
||||
|
|
@ -154,14 +159,14 @@ export class AppsController {
|
|||
@UseGuards(JwtAuthGuard)
|
||||
@UseInterceptors(ValidAppInterceptor)
|
||||
@Post(':id/clone')
|
||||
async clone(@User() user, @AppDecorator() app: App) {
|
||||
async clone(@User() user, @AppDecorator() app: App, @Body() appCloneDto: AppCloneDto) {
|
||||
const ability = await this.appsAbilityFactory.appsActions(user, app.id);
|
||||
|
||||
if (!ability.can('cloneApp', app)) {
|
||||
throw new ForbiddenException('You do not have permissions to perform this action');
|
||||
}
|
||||
|
||||
const result = await this.appsService.clone(app, user);
|
||||
const appName = appCloneDto.name;
|
||||
const result = await this.appsService.clone(app, user, appName);
|
||||
const response = decamelizeKeys(result);
|
||||
|
||||
return response;
|
||||
|
|
|
|||
|
|
@ -15,14 +15,15 @@ export class LibraryAppsController {
|
|||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async create(@User() user, @Body('identifier') identifier) {
|
||||
async create(@User() user, @Body('identifier') identifier, @Body('appName') appName) {
|
||||
const ability = await this.appsAbilityFactory.appsActions(user);
|
||||
|
||||
if (!ability.can('createApp', App)) {
|
||||
throw new ForbiddenException('You do not have permissions to perform this action');
|
||||
}
|
||||
const result = await this.libraryAppCreationService.perform(user, identifier);
|
||||
return result;
|
||||
const newApp = await this.libraryAppCreationService.perform(user, identifier, appName);
|
||||
|
||||
return newApp;
|
||||
}
|
||||
|
||||
@Get()
|
||||
|
|
|
|||
8
server/src/dto/app-clone.dto.ts
Normal file
8
server/src/dto/app-clone.dto.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
|
||||
|
||||
export class AppCloneDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@MaxLength(50, { message: 'Maximum length has been reached.' })
|
||||
name: string;
|
||||
}
|
||||
12
server/src/dto/app-create.dto.ts
Normal file
12
server/src/dto/app-create.dto.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { IsString, IsOptional, IsNotEmpty, MaxLength } from 'class-validator';
|
||||
|
||||
export class AppCreateDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@MaxLength(50, { message: 'Maximum length has been reached.' })
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
icon?: string;
|
||||
}
|
||||
11
server/src/dto/app-import.dto.ts
Normal file
11
server/src/dto/app-import.dto.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
|
||||
|
||||
export class AppImportDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@MaxLength(50, { message: 'Maximum length has been reached.' })
|
||||
name: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
app: object;
|
||||
}
|
||||
|
|
@ -10,6 +10,9 @@ export class ImportResourcesDto {
|
|||
@IsOptional()
|
||||
app: ImportAppDto[];
|
||||
|
||||
@IsOptional()
|
||||
appName: string;
|
||||
|
||||
@IsOptional()
|
||||
tooljet_database: ImportTooljetDatabaseDto[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,11 +11,12 @@ import { GroupPermission } from 'src/entities/group_permission.entity';
|
|||
import { User } from 'src/entities/user.entity';
|
||||
import { EntityManager } from 'typeorm';
|
||||
import { DataSourcesService } from './data_sources.service';
|
||||
import { dbTransactionWrap, defaultAppEnvironments, truncateAndReplace } from 'src/helpers/utils.helper';
|
||||
import { dbTransactionWrap, defaultAppEnvironments, catchDbException } from 'src/helpers/utils.helper';
|
||||
import { AppEnvironmentService } from './app_environments.service';
|
||||
import { convertAppDefinitionFromSinglePageToMultiPage } from '../../lib/single-page-to-and-from-multipage-definition-conversion';
|
||||
import { DataSourceScopes, DataSourceTypes } from 'src/helpers/data_source.constants';
|
||||
import { Organization } from 'src/entities/organization.entity';
|
||||
import { DataBaseConstraints } from 'src/helpers/db_constraints.constants';
|
||||
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
import { Plugin } from 'src/entities/plugin.entity';
|
||||
|
||||
|
|
@ -151,7 +152,7 @@ export class AppImportExportService {
|
|||
});
|
||||
}
|
||||
|
||||
async import(user: User, appParamsObj: any, externalResourceMappings = {}): Promise<App> {
|
||||
async import(user: User, appParamsObj: any, appName: string, externalResourceMappings = {}): Promise<App> {
|
||||
if (typeof appParamsObj !== 'object') {
|
||||
throw new BadRequestException('Invalid params for app import');
|
||||
}
|
||||
|
|
@ -171,6 +172,7 @@ export class AppImportExportService {
|
|||
const schemaUnifiedAppParams = appParams?.schemaDetails?.multiPages
|
||||
? appParams
|
||||
: convertSinglePageSchemaToMultiPageSchema(appParams);
|
||||
schemaUnifiedAppParams.name = appName;
|
||||
|
||||
await dbTransactionWrap(async (manager) => {
|
||||
importedApp = await this.createImportedAppForUser(manager, schemaUnifiedAppParams, user);
|
||||
|
|
@ -194,18 +196,24 @@ export class AppImportExportService {
|
|||
}
|
||||
|
||||
async createImportedAppForUser(manager: EntityManager, appParams: any, user: User): Promise<App> {
|
||||
const importedApp = manager.create(App, {
|
||||
name: truncateAndReplace(appParams.name),
|
||||
organizationId: user.organizationId,
|
||||
userId: user.id,
|
||||
slug: null, // Prevent db unique constraint error.
|
||||
icon: appParams.icon,
|
||||
isPublic: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
await manager.save(importedApp);
|
||||
return importedApp;
|
||||
return await catchDbException(
|
||||
async () => {
|
||||
const importedApp = manager.create(App, {
|
||||
name: appParams.name,
|
||||
organizationId: user.organizationId,
|
||||
userId: user.id,
|
||||
slug: null,
|
||||
icon: appParams.icon,
|
||||
isPublic: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
await manager.save(importedApp);
|
||||
return importedApp;
|
||||
},
|
||||
DataBaseConstraints.APP_NAME_UNIQUE,
|
||||
'This app name is already taken.'
|
||||
);
|
||||
}
|
||||
|
||||
extractImportDataFromAppParams(appParams: Record<string, any>): {
|
||||
|
|
|
|||
|
|
@ -12,13 +12,7 @@ import { AppGroupPermission } from 'src/entities/app_group_permission.entity';
|
|||
import { AppImportExportService } from './app_import_export.service';
|
||||
import { DataSourcesService } from './data_sources.service';
|
||||
import { Credential } from 'src/entities/credential.entity';
|
||||
import {
|
||||
catchDbException,
|
||||
cleanObject,
|
||||
dbTransactionWrap,
|
||||
defaultAppEnvironments,
|
||||
generateNextName,
|
||||
} from 'src/helpers/utils.helper';
|
||||
import { catchDbException, cleanObject, dbTransactionWrap, defaultAppEnvironments } from 'src/helpers/utils.helper';
|
||||
import { AppUpdateDto } from '@dto/app-update.dto';
|
||||
import { viewableAppsQuery } from 'src/helpers/queries';
|
||||
import { VersionEditDto } from '@dto/version-edit.dto';
|
||||
|
|
@ -103,35 +97,40 @@ export class AppsService {
|
|||
});
|
||||
}
|
||||
|
||||
async create(user: User, manager: EntityManager): Promise<App> {
|
||||
async create(name: string, user: User, manager: EntityManager): Promise<App> {
|
||||
return await dbTransactionWrap(async (manager: EntityManager) => {
|
||||
const name = await generateNextName('My app');
|
||||
const app = await manager.save(
|
||||
manager.create(App, {
|
||||
name,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
organizationId: user.organizationId,
|
||||
userId: user.id,
|
||||
})
|
||||
return await catchDbException(
|
||||
async () => {
|
||||
const app = await manager.save(
|
||||
manager.create(App, {
|
||||
name,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
organizationId: user.organizationId,
|
||||
userId: user.id,
|
||||
})
|
||||
);
|
||||
|
||||
//create default app version
|
||||
await this.createVersion(user, app, 'v1', null, null, manager);
|
||||
|
||||
await manager.save(
|
||||
manager.create(AppUser, {
|
||||
userId: user.id,
|
||||
appId: app.id,
|
||||
role: 'admin',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
);
|
||||
|
||||
await this.createAppGroupPermissionsForAdmin(app, manager);
|
||||
return app;
|
||||
},
|
||||
DataBaseConstraints.APP_NAME_UNIQUE,
|
||||
'This app name is already taken.'
|
||||
);
|
||||
|
||||
//create default app version
|
||||
await this.createVersion(user, app, 'v1', null, null, manager);
|
||||
|
||||
await manager.save(
|
||||
manager.create(AppUser, {
|
||||
userId: user.id,
|
||||
appId: app.id,
|
||||
role: 'admin',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
);
|
||||
|
||||
await this.createAppGroupPermissionsForAdmin(app, manager);
|
||||
return app;
|
||||
}, manager);
|
||||
});
|
||||
}
|
||||
|
||||
async createAppGroupPermissionsForAdmin(app: App, manager: EntityManager): Promise<void> {
|
||||
|
|
@ -170,9 +169,9 @@ export class AppsService {
|
|||
}
|
||||
}
|
||||
|
||||
async clone(existingApp: App, user: User): Promise<App> {
|
||||
async clone(existingApp: App, user: User, appName: string): Promise<App> {
|
||||
const appWithRelations = await this.appImportExportService.export(user, existingApp.id);
|
||||
const clonedApp = await this.appImportExportService.import(user, appWithRelations);
|
||||
const clonedApp = await this.appImportExportService.import(user, appWithRelations, appName);
|
||||
|
||||
return clonedApp;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,9 +59,10 @@ export class ImportExportResourcesService {
|
|||
}
|
||||
|
||||
if (importResourcesDto.app) {
|
||||
const appName = importResourcesDto.appName;
|
||||
for (const appImportDto of importResourcesDto.app) {
|
||||
user.organizationId = importResourcesDto.organization_id;
|
||||
const createdApp = await this.appImportExportService.import(user, appImportDto.definition, {
|
||||
const createdApp = await this.appImportExportService.import(user, appImportDto.definition, appName, {
|
||||
tooljet_database: tableNameMapping,
|
||||
});
|
||||
imports.app.push({ id: createdApp.id, name: createdApp.name });
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export class LibraryAppCreationService {
|
|||
private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async perform(currentUser: User, identifier: string) {
|
||||
async perform(currentUser: User, identifier: string, appName: string) {
|
||||
const templateDefinition = this.findTemplateDefinition(identifier);
|
||||
const importDto = new ImportResourcesDto();
|
||||
importDto.organization_id = currentUser.organizationId;
|
||||
|
|
@ -24,7 +24,7 @@ export class LibraryAppCreationService {
|
|||
if (this.isVersionGreaterThanOrEqual(templateDefinition.tooljet_version, '2.16.0')) {
|
||||
return await this.importExportResourcesService.import(currentUser, importDto);
|
||||
} else {
|
||||
const importedApp = await this.appImportExportService.import(currentUser, templateDefinition);
|
||||
const importedApp = await this.appImportExportService.import(currentUser, templateDefinition, appName);
|
||||
return {
|
||||
app: [importedApp],
|
||||
tooljet_database: [],
|
||||
|
|
|
|||
|
|
@ -82,11 +82,15 @@ describe('apps controller', () => {
|
|||
});
|
||||
await createApplicationVersion(app, application);
|
||||
|
||||
const appName = 'My app';
|
||||
for (const userData of [viewerUserData, developerUserData]) {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/apps`)
|
||||
.set('tj-workspace-id', userData.user.defaultOrganizationId)
|
||||
.set('Cookie', userData['tokenCookie']);
|
||||
.set('Cookie', userData['tokenCookie'])
|
||||
.send({
|
||||
name: appName,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
}
|
||||
|
|
@ -94,7 +98,10 @@ describe('apps controller', () => {
|
|||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/apps`)
|
||||
.set('tj-workspace-id', adminUserData.user.defaultOrganizationId)
|
||||
.set('Cookie', adminUserData['tokenCookie']);
|
||||
.set('Cookie', adminUserData['tokenCookie'])
|
||||
.send({
|
||||
name: appName,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
expect(response.body.name).toContain('My app');
|
||||
|
|
@ -115,10 +122,14 @@ describe('apps controller', () => {
|
|||
|
||||
await createAppEnvironments(app, adminUserData.organization.id);
|
||||
|
||||
const appName = 'My app';
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/apps`)
|
||||
.set('tj-workspace-id', adminUserData.user.defaultOrganizationId)
|
||||
.set('Cookie', loggedUser.tokenCookie);
|
||||
.set('Cookie', loggedUser.tokenCookie)
|
||||
.send({
|
||||
name: appName,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
expect(response.body.name).toContain('My app');
|
||||
|
|
@ -535,7 +546,8 @@ describe('apps controller', () => {
|
|||
let response = await request(app.getHttpServer())
|
||||
.post(`/api/apps/${application.id}/clone`)
|
||||
.set('tj-workspace-id', adminUserData.user.defaultOrganizationId)
|
||||
.set('Cookie', adminUserData['tokenCookie']);
|
||||
.set('Cookie', adminUserData['tokenCookie'])
|
||||
.send({ name: 'App to clone_Copy' });
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
|
||||
|
|
@ -546,14 +558,16 @@ describe('apps controller', () => {
|
|||
response = await request(app.getHttpServer())
|
||||
.post(`/api/apps/${application.id}/clone`)
|
||||
.set('tj-workspace-id', developerUserData.user.defaultOrganizationId)
|
||||
.set('Cookie', developerUserData['tokenCookie']);
|
||||
.set('Cookie', developerUserData['tokenCookie'])
|
||||
.send({ name: 'App to clone_Copy' });
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
|
||||
response = await request(app.getHttpServer())
|
||||
.post(`/api/apps/${application.id}/clone`)
|
||||
.set('tj-workspace-id', viewerUserData.user.defaultOrganizationId)
|
||||
.set('Cookie', viewerUserData['tokenCookie']);
|
||||
.set('Cookie', viewerUserData['tokenCookie'])
|
||||
.send({ name: 'App to clone_Copy' });
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
|
||||
|
|
@ -583,7 +597,8 @@ describe('apps controller', () => {
|
|||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/apps/${application.id}/clone`)
|
||||
.set('tj-workspace-id', anotherOrgAdminUserData.user.defaultOrganizationId)
|
||||
.set('Cookie', loggedUser.tokenCookie);
|
||||
.set('Cookie', loggedUser.tokenCookie)
|
||||
.send({ name: 'name_Copy' });
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
|
||||
|
|
@ -2121,7 +2136,8 @@ describe('apps controller', () => {
|
|||
const response = await request(app.getHttpServer())
|
||||
.post('/api/apps/import')
|
||||
.set('tj-workspace-id', userData.user.defaultOrganizationId)
|
||||
.set('Cookie', userData['tokenCookie']);
|
||||
.set('Cookie', userData['tokenCookie'])
|
||||
.send({ app: application, name: 'name' });
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
}
|
||||
|
|
@ -2130,7 +2146,7 @@ describe('apps controller', () => {
|
|||
.post('/api/apps/import')
|
||||
.set('tj-workspace-id', adminUserData.user.defaultOrganizationId)
|
||||
.set('Cookie', adminUserData['tokenCookie'])
|
||||
.send({ name: 'Imported App' });
|
||||
.send({ app: application, name: 'Imported App' });
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ describe('library apps controller', () => {
|
|||
|
||||
let response = await request(app.getHttpServer())
|
||||
.post('/api/library_apps')
|
||||
.send({ identifier: 'github-contributors' })
|
||||
.send({ identifier: 'github-contributors', appName: 'Github Contributors' })
|
||||
.set('tj-workspace-id', nonAdminUserData.user.defaultOrganizationId)
|
||||
.set('Cookie', nonAdminUserData['tokenCookie']);
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ describe('library apps controller', () => {
|
|||
|
||||
response = await request(app.getHttpServer())
|
||||
.post('/api/library_apps')
|
||||
.send({ identifier: 'github-contributors' })
|
||||
.send({ identifier: 'github-contributors', appName: 'GitHub Contributor Leaderboard' })
|
||||
.set('tj-workspace-id', adminUserData.user.defaultOrganizationId)
|
||||
.set('Cookie', adminUserData['tokenCookie']);
|
||||
|
||||
|
|
@ -61,7 +61,7 @@ describe('library apps controller', () => {
|
|||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/library_apps')
|
||||
.send({ identifier: 'non-existent-template' })
|
||||
.send({ identifier: 'non-existent-template', appName: 'Non existent template' })
|
||||
.set('tj-workspace-id', adminUserData.user.defaultOrganizationId)
|
||||
.set('Cookie', adminUserData['tokenCookie']);
|
||||
|
||||
|
|
|
|||
|
|
@ -164,7 +164,8 @@ describe('AppImportExportService', () => {
|
|||
groups: ['all_users', 'admin'],
|
||||
});
|
||||
const adminUser = adminUserData.user;
|
||||
await expect(service.import(adminUser, 'hello world')).rejects.toThrow('Invalid params for app import');
|
||||
const appName = 'my app';
|
||||
await expect(service.import(adminUser, 'hello world', appName)).rejects.toThrow('Invalid params for app import');
|
||||
});
|
||||
|
||||
it('should import app with empty related associations', async () => {
|
||||
|
|
@ -180,8 +181,8 @@ describe('AppImportExportService', () => {
|
|||
});
|
||||
|
||||
const { appV2: exportedApp } = await service.export(adminUser, app.id);
|
||||
|
||||
const result = await service.import(adminUser, exportedApp);
|
||||
const appName = 'my app';
|
||||
const result = await service.import(adminUser, exportedApp, appName);
|
||||
const importedApp = await getAppWithAllDetails(result.id);
|
||||
|
||||
expect(importedApp.id == exportedApp.id).toBeFalsy();
|
||||
|
|
@ -261,7 +262,8 @@ describe('AppImportExportService', () => {
|
|||
});
|
||||
|
||||
const { appV2: exportedApp } = await service.export(adminUser, application.id);
|
||||
const result = await service.import(adminUser, exportedApp);
|
||||
const appName = 'my app';
|
||||
const result = await service.import(adminUser, exportedApp, appName);
|
||||
const importedApp = await getAppWithAllDetails(result.id);
|
||||
|
||||
expect(importedApp.id == exportedApp.id).toBeFalsy();
|
||||
|
|
|
|||
Loading…
Reference in a new issue