Merge pull request #7936 from ToolJet/main

merge main to develop
This commit is contained in:
Midhun G S 2023-10-17 19:25:12 +05:30 committed by GitHub
commit 8e78560c0a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 986 additions and 426 deletions

View file

@ -1 +1 @@
2.20.2
2.21.0

View file

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

View file

@ -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",

View file

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

View file

@ -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,

View file

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

View file

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

View file

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

View file

@ -1 +1 @@
2.20.2
2.21.0

View file

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

View 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;

View file

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

View file

@ -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} />
</>
)}

View file

@ -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}
>
&nbsp;{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>
);

View file

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

View file

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

View file

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

View 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>
);
}

View file

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

View file

@ -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 = '';

View file

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

View file

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

View file

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

View file

@ -1 +1 @@
2.20.2
2.21.0

View file

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

View file

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

View file

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

View 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;
}

View 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;
}

View 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;
}

View file

@ -10,6 +10,9 @@ export class ImportResourcesDto {
@IsOptional()
app: ImportAppDto[];
@IsOptional()
appName: string;
@IsOptional()
tooljet_database: ImportTooljetDatabaseDto[];
}

View file

@ -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>): {

View file

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

View file

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

View file

@ -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: [],

View file

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

View file

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

View file

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