mirror of
https://github.com/ToolJet/ToolJet
synced 2026-05-05 22:38:48 +00:00
feat: Workflows import export support
This commit is contained in:
parent
dfd18fc59c
commit
8fca46f7cb
6 changed files with 247 additions and 50 deletions
|
|
@ -82,13 +82,22 @@ export const AppMenu = function AppMenu({
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
{canUpdateApp && canCreateApp && appType !== 'workflow' && !isModuleApp && (
|
||||
{canUpdateApp && canCreateApp && !isModuleApp && (
|
||||
<>
|
||||
{appType !== 'workflow' && (
|
||||
<Field
|
||||
text={t('homePage.appCard.cloneApp', 'Clone app')}
|
||||
onClick={() => openAppActionModal('clone-app')}
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
text={t('homePage.appCard.cloneApp', 'Clone app')}
|
||||
onClick={() => openAppActionModal('clone-app')}
|
||||
text={
|
||||
appType === 'workflow'
|
||||
? t('homePage.appCard.exportWorkflow', 'Export workflow')
|
||||
: t('homePage.appCard.exportApp', 'Export app')
|
||||
}
|
||||
onClick={exportApp}
|
||||
/>
|
||||
<Field text={t('homePage.appCard.exportApp', 'Export app')} onClick={exportApp} />
|
||||
</>
|
||||
)}
|
||||
{canDeleteApp && (
|
||||
|
|
|
|||
|
|
@ -147,34 +147,38 @@ export const BlankPage = function BlankPage({
|
|||
Create new {appType !== 'workflow' ? 'application' : 'workflow'}
|
||||
</ButtonSolid>
|
||||
</div>
|
||||
{appType !== 'workflow' && (
|
||||
<div className="col-6">
|
||||
<ButtonSolid
|
||||
disabled={appCreationDisabled}
|
||||
leftIcon="folderdownload"
|
||||
onChange={readAndImport}
|
||||
isLoading={isImportingApp}
|
||||
data-cy="button-import-an-app"
|
||||
className="col"
|
||||
variant="tertiary"
|
||||
<div className="col-6">
|
||||
<ButtonSolid
|
||||
disabled={appType !== 'workflow' ? appCreationDisabled : workflowsCreationDisabled}
|
||||
leftIcon="folderdownload"
|
||||
onChange={readAndImport}
|
||||
isLoading={isImportingApp}
|
||||
data-cy={appType !== 'workflow' ? 'button-import-an-app' : 'button-import-a-workflow'}
|
||||
className="col"
|
||||
variant="tertiary"
|
||||
>
|
||||
<label
|
||||
className={cx('', {
|
||||
'cursor-pointer':
|
||||
appType !== 'workflow' ? !appCreationDisabled : !workflowsCreationDisabled,
|
||||
})}
|
||||
style={{ visibility: isImportingApp ? 'hidden' : 'visible' }}
|
||||
data-cy={appType !== 'workflow' ? 'import-an-application' : 'import-a-workflow'}
|
||||
>
|
||||
<label
|
||||
className={cx('', { 'cursor-pointer': !appCreationDisabled })}
|
||||
style={{ visibility: isImportingApp ? 'hidden' : 'visible' }}
|
||||
data-cy="import-an-application"
|
||||
>
|
||||
{t('blankPage.importApplication', 'Import an app')}
|
||||
<input
|
||||
disabled={appCreationDisabled}
|
||||
type="file"
|
||||
ref={fileInput}
|
||||
style={{ display: 'none' }}
|
||||
data-cy="import-option-input"
|
||||
/>
|
||||
</label>
|
||||
</ButtonSolid>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{appType !== 'workflow'
|
||||
? t('blankPage.importApplication', 'Import an app')
|
||||
: t('blankPage.importWorkflow', 'Import a workflow')}
|
||||
<input
|
||||
disabled={appType !== 'workflow' ? appCreationDisabled : workflowsCreationDisabled}
|
||||
type="file"
|
||||
ref={fileInput}
|
||||
style={{ display: 'none' }}
|
||||
data-cy="import-option-input"
|
||||
/>
|
||||
</label>
|
||||
</ButtonSolid>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-5 empty-home-page-image" data-cy="empty-home-page-image">
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
} from '@/_services';
|
||||
import { ConfirmDialog, AppModal } from '@/_components';
|
||||
import Select from '@/_ui/Select';
|
||||
import _, { sample, isEmpty } from 'lodash';
|
||||
import _, { sample, isEmpty, capitalize } from 'lodash';
|
||||
import { Folders } from './Folders';
|
||||
import { BlankPage } from './BlankPage';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
|
@ -252,7 +252,11 @@ class HomePageComponent extends React.Component {
|
|||
};
|
||||
|
||||
getAppType = () => {
|
||||
return this.props.appType === 'module' ? 'Module' : this.props.appType === 'workflow' ? 'Workflow' : 'App';
|
||||
const { appType } = this.props;
|
||||
if (appType === 'front-end') return 'App';
|
||||
if (appType === 'workflow') return 'Workflow';
|
||||
if (appType === 'module') return 'Module';
|
||||
return 'app';
|
||||
};
|
||||
|
||||
createApp = async (appName) => {
|
||||
|
|
@ -330,6 +334,66 @@ class HomePageComponent extends React.Component {
|
|||
this.setState({ isExportingApp: true, app: app });
|
||||
};
|
||||
|
||||
exportAppDirectly = async (app) => {
|
||||
try {
|
||||
const fetchVersions = await appsService.getVersions(app.id);
|
||||
const { versions } = fetchVersions;
|
||||
|
||||
const currentEditingVersion = versions?.filter((version) => version?.isCurrentEditingVersion)[0];
|
||||
if (!currentEditingVersion) {
|
||||
toast.error('Could not find current editing version.', {
|
||||
position: 'top-center',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Export all TJDB tables used by default
|
||||
const fetchTables = await appsService.getTables(app.id);
|
||||
const { tables: allTables } = fetchTables;
|
||||
|
||||
const versionId = currentEditingVersion.id;
|
||||
const exportTjDb = true;
|
||||
const exportTables = allTables;
|
||||
|
||||
const appOpts = {
|
||||
app: [
|
||||
{
|
||||
id: app.id,
|
||||
search_params: { version_id: versionId },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const requestBody = {
|
||||
...appOpts,
|
||||
...(exportTjDb && { tooljet_database: exportTables }),
|
||||
organization_id: app.organization_id,
|
||||
};
|
||||
|
||||
const data = await appsService.exportResource(requestBody);
|
||||
|
||||
const appName = app.name.replace(/\s+/g, '-').toLowerCase();
|
||||
const fileName = `${appName}-export-${new Date().getTime()}`;
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const href = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = href;
|
||||
link.download = fileName + '.json';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
toast.success('Workflow exported successfully!', {
|
||||
position: 'top-center',
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(`Could not export workflow: ${error?.data?.message || error.message}`, {
|
||||
position: 'top-center',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
readAndImport = (event) => {
|
||||
try {
|
||||
const file = event.target.files[0];
|
||||
|
|
@ -411,7 +475,7 @@ class HomePageComponent extends React.Component {
|
|||
}
|
||||
|
||||
const data = await appsService.importResource(requestBody);
|
||||
toast.success('App imported successfully.');
|
||||
toast.success(`${capitalize(this.getAppType())} imported successfully.`);
|
||||
this.setState({ isImportingApp: false });
|
||||
|
||||
if (!isEmpty(data.imports.app)) {
|
||||
|
|
@ -433,7 +497,7 @@ class HomePageComponent extends React.Component {
|
|||
|
||||
this.setState({ isImportingApp: false });
|
||||
if (error.statusCode === 409) return false;
|
||||
toast.error(error?.error || error?.message || 'App import failed');
|
||||
toast.error(error?.error || error?.message || `${capitalize(this.getAppType())} import failed`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -935,6 +999,53 @@ class HomePageComponent extends React.Component {
|
|||
importingGitAppOperations: validationMessage,
|
||||
});
|
||||
};
|
||||
|
||||
// Helper functions for workflow limit checks
|
||||
hasWorkflowLimitReached = () => {
|
||||
const { workflowInstanceLevelLimit, workflowWorkspaceLevelLimit } = this.state;
|
||||
|
||||
const instanceLimitReached =
|
||||
workflowInstanceLevelLimit.total === 0 || workflowInstanceLevelLimit.current >= workflowInstanceLevelLimit.total;
|
||||
const workspaceLimitReached =
|
||||
workflowWorkspaceLevelLimit.total === 0 ||
|
||||
workflowWorkspaceLevelLimit.current >= workflowWorkspaceLevelLimit.total;
|
||||
|
||||
return instanceLimitReached || workspaceLimitReached;
|
||||
};
|
||||
|
||||
hasWorkflowLimitWarning = () => {
|
||||
const { workflowInstanceLevelLimit, workflowWorkspaceLevelLimit } = this.state;
|
||||
return this.hasInstanceLimitWarning() || this.hasWorkspaceLimitWarning();
|
||||
};
|
||||
|
||||
hasInstanceLimitWarning = () => {
|
||||
const { workflowInstanceLevelLimit } = this.state;
|
||||
const percentage = workflowInstanceLevelLimit.percentage;
|
||||
|
||||
return (
|
||||
workflowInstanceLevelLimit.current >= workflowInstanceLevelLimit.total ||
|
||||
(percentage >= 90 && percentage < 100) ||
|
||||
workflowInstanceLevelLimit.current === workflowInstanceLevelLimit.total - 1
|
||||
);
|
||||
};
|
||||
|
||||
hasWorkspaceLimitWarning = () => {
|
||||
const { workflowWorkspaceLevelLimit } = this.state;
|
||||
const percentage = workflowWorkspaceLevelLimit.percentage;
|
||||
|
||||
return (
|
||||
workflowWorkspaceLevelLimit.current >= workflowWorkspaceLevelLimit.total ||
|
||||
(percentage >= 90 && percentage < 100) ||
|
||||
workflowWorkspaceLevelLimit.current === workflowWorkspaceLevelLimit.total - 1
|
||||
);
|
||||
};
|
||||
|
||||
getWorkflowLimit = () => {
|
||||
return this.hasInstanceLimitWarning()
|
||||
? this.state.workflowInstanceLevelLimit
|
||||
: this.state.workflowWorkspaceLevelLimit;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
apps,
|
||||
|
|
@ -1436,16 +1547,24 @@ class HomePageComponent extends React.Component {
|
|||
'Create new app'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{this.props.appType !== 'workflow' && this.props.appType !== 'module' && (
|
||||
{this.props.appType === 'workflow' ? (
|
||||
<Dropdown.Toggle
|
||||
disabled={
|
||||
appsLimit?.percentage >= 100 || (this.props.appType === 'module' && invalidLicense)
|
||||
}
|
||||
disabled={this.hasWorkflowLimitReached()}
|
||||
split
|
||||
className="d-inline"
|
||||
data-cy="import-dropdown-menu"
|
||||
/>
|
||||
) : (
|
||||
this.props.appType !== 'module' && (
|
||||
<Dropdown.Toggle
|
||||
disabled={
|
||||
appsLimit?.percentage >= 100 || (this.props.appType === 'module' && invalidLicense)
|
||||
}
|
||||
split
|
||||
className="d-inline"
|
||||
data-cy="import-dropdown-menu"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<ImportAppMenu
|
||||
darkMode={this.props.darkMode}
|
||||
|
|
@ -1454,6 +1573,7 @@ class HomePageComponent extends React.Component {
|
|||
orgGit={orgGit}
|
||||
toggleGitRepositoryImportModal={this.toggleGitRepositoryImportModal}
|
||||
readAndImport={this.readAndImport}
|
||||
appType={this.props.appType}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
|
@ -1621,7 +1741,7 @@ class HomePageComponent extends React.Component {
|
|||
canUpdateApp={this.canUpdateApp}
|
||||
deleteApp={this.deleteApp}
|
||||
cloneApp={this.cloneApp}
|
||||
exportApp={this.exportApp}
|
||||
exportApp={this.props.appType === 'workflow' ? this.exportAppDirectly : this.exportApp}
|
||||
meta={meta}
|
||||
currentFolder={currentFolder}
|
||||
isLoading={isLoading || !featuresLoaded}
|
||||
|
|
|
|||
|
|
@ -9,19 +9,22 @@ const BaseImportAppMenu = ({
|
|||
showCloudMenuItems = false,
|
||||
CloudMenuComponent = () => null,
|
||||
darkMode = false,
|
||||
appType = 'front-end',
|
||||
...props
|
||||
}) => {
|
||||
const fileInput = React.createRef();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dropdown.Menu className="import-lg-position new-app-dropdown">
|
||||
<Dropdown.Item
|
||||
className="homepage-dropdown-style tj-text tj-text-xsm"
|
||||
onClick={showTemplateLibraryModal}
|
||||
data-cy="choose-from-template-button"
|
||||
>
|
||||
{t('homePage.header.chooseFromTemplate', 'Choose from template')}
|
||||
</Dropdown.Item>
|
||||
{appType !== 'workflow' && (
|
||||
<Dropdown.Item
|
||||
className="homepage-dropdown-style tj-text tj-text-xsm"
|
||||
onClick={showTemplateLibraryModal}
|
||||
data-cy="choose-from-template-button"
|
||||
>
|
||||
{t('homePage.header.chooseFromTemplate', 'Choose from template')}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
<label
|
||||
className="homepage-dropdown-style tj-text tj-text-xsm"
|
||||
data-cy="import-option-label"
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit cde2ebcce6c552ba5d9ba49099cd4aae82ca5e1c
|
||||
Subproject commit f9d5b9e05105ab8c6d8f5d79c5bc578a02626351
|
||||
|
|
@ -377,12 +377,18 @@ export class AppImportExportService {
|
|||
await manager.update(AppVersion, { id: appVersion.id }, { globalSettings: updatedGlobalSettings });
|
||||
}
|
||||
}
|
||||
|
||||
if (appVersionIds.length > 0) {
|
||||
await this.updateWorkflowDefinitionQueryReferences(manager, appVersionIds, resourceMapping);
|
||||
}
|
||||
}
|
||||
|
||||
async createImportedAppForUser(manager: EntityManager, appParams: any, user: User, isGitApp = false): Promise<App> {
|
||||
return await catchDbException(async () => {
|
||||
const importedApp = manager.create(App, {
|
||||
name: appParams.name,
|
||||
type: appParams.type,
|
||||
isMaintenanceOn: appParams.isMaintenanceOn || false,
|
||||
organizationId: user?.organizationId,
|
||||
userId: user.id, //fetch super admin user id for EE
|
||||
slug: null,
|
||||
|
|
@ -447,7 +453,7 @@ export class AppImportExportService {
|
|||
externalResourceMappings: Record<string, unknown>,
|
||||
isNormalizedAppDefinitionSchema: boolean,
|
||||
tooljetVersion: string | null
|
||||
) {
|
||||
): Promise<AppResourceMappings> {
|
||||
// Old version without app version
|
||||
// Handle exports prior to 0.12.0
|
||||
// TODO: have version based conditional based on app versions
|
||||
|
|
@ -1057,6 +1063,61 @@ export class AppImportExportService {
|
|||
return appResourceMappings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates workflow definition query references with newly created query IDs during app import.
|
||||
*
|
||||
* Note: For workflow apps, the entire workflow definition (including nodes, edges, and query mappings)
|
||||
* is stored as JSON in the app_versions.definition column. Unlike regular apps where queries are
|
||||
* stored as separate entities, workflow queries are referenced within this JSON structure through
|
||||
* a queries array that maps workflow node IDs (idOnDefinition) to actual data query IDs.
|
||||
*
|
||||
* During import, new data queries are created with different IDs, so we need to update the
|
||||
* workflow definition's queries array to reference these new IDs while preserving the
|
||||
* idOnDefinition values that link to workflow nodes.
|
||||
*/
|
||||
private async updateWorkflowDefinitionQueryReferences(
|
||||
manager: EntityManager,
|
||||
appVersionIds: string[],
|
||||
resourceMapping: AppResourceMappings
|
||||
): Promise<void> {
|
||||
// Get the app versions with their definitions and associated apps
|
||||
const appVersionsWithDefinitions = await manager
|
||||
.createQueryBuilder(AppVersion, 'appVersion')
|
||||
.leftJoinAndSelect('appVersion.app', 'app')
|
||||
.where('appVersion.id IN(:...appVersionIds)', { appVersionIds })
|
||||
.select(['appVersion.id', 'appVersion.definition', 'app.type'])
|
||||
.getMany();
|
||||
|
||||
const workflowAppVersions = appVersionsWithDefinitions.filter(
|
||||
(appVersion) => appVersion.app?.type === 'workflow' && appVersion.definition?.queries
|
||||
);
|
||||
|
||||
if (workflowAppVersions.length > 0) {
|
||||
for (const appVersion of workflowAppVersions) {
|
||||
const definition = appVersion.definition;
|
||||
let definitionUpdated = false;
|
||||
|
||||
// Update query IDs in the workflow definition
|
||||
if (definition.queries && Array.isArray(definition.queries)) {
|
||||
definition.queries = definition.queries.map((query) => {
|
||||
if (query.id && resourceMapping.dataQueryMapping[query.id]) {
|
||||
definitionUpdated = true;
|
||||
return {
|
||||
...query,
|
||||
id: resourceMapping.dataQueryMapping[query.id],
|
||||
};
|
||||
}
|
||||
return query;
|
||||
});
|
||||
}
|
||||
|
||||
if (definitionUpdated) {
|
||||
await manager.update(AppVersion, { id: appVersion.id }, { definition });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async rejectMarketplacePluginsNotInstalled(
|
||||
manager: EntityManager,
|
||||
importingDataSources: DataSource[]
|
||||
|
|
|
|||
Loading…
Reference in a new issue