feat: Workflows import export support

This commit is contained in:
Akshay Sasidharan 2025-07-02 16:15:24 +05:30
parent dfd18fc59c
commit 8fca46f7cb
6 changed files with 247 additions and 50 deletions

View file

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

View file

@ -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"
>
&nbsp;{t('blankPage.importApplication', 'Import an app')}
<input
disabled={appCreationDisabled}
type="file"
ref={fileInput}
style={{ display: 'none' }}
data-cy="import-option-input"
/>
</label>
</ButtonSolid>
</div>
)}
&nbsp;
{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">

View file

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

View file

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

View file

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