Merge branch 'main' into fix/workspace-constants-mapping

This commit is contained in:
Ganesh Kumar 2025-07-08 21:02:59 +05:30
commit 8eff0bd6c8
46 changed files with 1322 additions and 267 deletions

View file

@ -35241,4 +35241,4 @@
}
}
}
}
}

View file

@ -38,6 +38,7 @@ import {
getDataSourcesRoutes,
getAuditLogsRoutes,
} from '@/modules';
import { isWorkflowsFeatureEnabled } from '@/modules/common/helpers/utils';
import { shallow } from 'zustand/shallow';
import useStore from '@/AppBuilder/_stores/store';
import { checkIfToolJetCloud } from '@/_helpers/utils';
@ -278,7 +279,7 @@ class AppComponent extends React.Component {
</PrivateRoute>
}
/>
{window.public_config?.ENABLE_WORKFLOWS_FEATURE === 'true' && (
{isWorkflowsFeatureEnabled() && (
<Route
exact
path="/:workspaceId/workflows/*"
@ -299,7 +300,12 @@ class AppComponent extends React.Component {
<InstanceSettings switchDarkMode={this.switchDarkMode} darkMode={darkMode} {...this.props} />
}
></Route>
<Route path="/:workspaceId/settings/*" element={<InstanceSettings {...this.props} darkMode={darkMode} switchDarkMode={this.switchDarkMode} />}></Route>
<Route
path="/:workspaceId/settings/*"
element={
<InstanceSettings {...this.props} darkMode={darkMode} switchDarkMode={this.switchDarkMode} />
}
></Route>
<Route
exact
path="/:workspaceId/modules"

View file

@ -14,6 +14,7 @@ import { useQueryPanelActions } from '@/_stores/queryPanelStore';
import { Tooltip } from 'react-tooltip';
import { canCreateDataSource } from '@/_helpers';
import SolidIcon from '@/_ui/Icon/SolidIcons';
import { isWorkflowsFeatureEnabled } from '@/modules/common/helpers/utils';
import '../queryManager.theme.scss';
import useStore from '@/AppBuilder/_stores/store';
import { staticDataSources } from '../constants';
@ -80,7 +81,7 @@ function DataSourcePicker({ darkMode }) {
navigate(`/${workspaceId}/data-sources`);
};
const workflowsEnabled = window.public_config?.ENABLE_WORKFLOWS_FEATURE == 'true';
const workflowsEnabled = isWorkflowsFeatureEnabled();
return (
<>

View file

@ -15,6 +15,7 @@ import { DataBaseSources, ApiSources, CloudStorageSources } from '@/modules/comm
import { canCreateDataSource } from '@/_helpers';
import './../queryManager.theme.scss';
import { DATA_SOURCE_TYPE } from '@/_helpers/constants';
import { isWorkflowsFeatureEnabled } from '@/modules/common/helpers/utils';
import useStore from '@/AppBuilder/_stores/store';
function DataSourceSelect({ isDisabled, selectRef, closePopup, workflowDataSources, onNewNode, defaultDataSources }) {
@ -40,7 +41,7 @@ function DataSourceSelect({ isDisabled, selectRef, closePopup, workflowDataSourc
closePopup();
};
const workflowsEnabled = window.public_config?.ENABLE_WORKFLOWS_FEATURE == 'true';
const workflowsEnabled = isWorkflowsFeatureEnabled();
const staticDataSources = workflowsEnabled
? staticDatasources
: staticDatasources.filter((ds) => ds?.kind !== 'workflows');

View file

@ -9,6 +9,7 @@ import { BaseUrl } from './BaseUrl';
import { queryManagerSelectComponentStyle } from '@/_ui/Select/styles';
import CodeHinter from '@/AppBuilder/CodeEditor';
import { deepClone } from '@/_helpers/utilities/utils.helpers';
import './styles.css';
class Restapi extends React.Component {
constructor(props) {
@ -287,14 +288,15 @@ class Restapi extends React.Component {
const { options } = this.state;
const dataSourceURL = this.props.selectedDataSource?.options?.url?.value;
const queryName = this.props.queryName;
const isWorkflowNode = queryName === 'workflowNode';
const currentValue = { label: options.method?.toUpperCase(), value: options.method };
return (
<div className={`${this.props?.queryName !== 'workflowNode' && 'd-flex'} flex-column`}>
<div className={`${!isWorkflowNode && 'd-flex'} flex-column`}>
{this.props.selectedDataSource?.scope == 'global' && <div className="form-label flex-shrink-0"></div>}{' '}
<div className="flex-grow-1 overflow-hidden">
<div className="rest-api-methods-select-element-container">
<div className="d-flex">
<div className={`rest-api-methods-select-element-container ${isWorkflowNode ? 'workflow-rest-api' : ''}`}>
<div className={`d-flex ${isWorkflowNode ? 'mb-2' : ''}`}>
<p
className="text-placeholder font-weight-medium"
style={{ width: '100px', marginRight: '16px', marginBottom: '0px' }}
@ -303,8 +305,11 @@ class Restapi extends React.Component {
</p>
</div>
<div className="d-flex flex-column w-100">
<div className="d-flex flex-row">
<div className={`me-2`} style={{ width: '90px', height: '32px' }}>
<div className={`${isWorkflowNode ? '' : 'd-flex'} flex-row`}>
<div
className={`me-2 ${isWorkflowNode ? 'mb-2' : ''}`}
style={{ width: isWorkflowNode ? '150px' : '90px', height: '32px' }}
>
<label className="font-weight-medium color-slate12">Method</label>
<Select
options={[
@ -320,9 +325,9 @@ class Restapi extends React.Component {
value={currentValue}
defaultValue={{ label: 'GET', value: 'get' }}
placeholder="Method"
width={100}
width={isWorkflowNode ? 150 : 100}
height={32}
styles={this.customSelectStyles(this.props.darkMode, 91)}
styles={this.customSelectStyles(this.props.darkMode, isWorkflowNode ? 150 : 91)}
useCustomStyles={true}
customClassPrefix="restapi-method-select"
onMenuOpen={() => {
@ -335,7 +340,7 @@ class Restapi extends React.Component {
</div>
<div
className={`field rest-methods-url ${dataSourceURL && 'data-source-exists'}`}
style={{ width: 'calc(100% - 214px)' }}
style={{ width: isWorkflowNode ? '100%' : 'calc(100% - 214px)' }}
>
<div className="font-weight-medium color-slate12">URL</div>
<div className="d-flex h-100 w-100">
@ -371,7 +376,7 @@ class Restapi extends React.Component {
</div>
</div>
</div>
<div className={`query-pane-restapi-tabs`}>
<div className={`query-pane-restapi-tabs`} data-workflow={isWorkflowNode ? 'true' : 'false'}>
<Tabs
theme={this.props.darkMode ? 'monokai' : 'default'}
options={this.state.options}
@ -384,6 +389,7 @@ class Restapi extends React.Component {
bodyToggle={this.state.options.body_toggle}
setBodyToggle={this.onBodyToggleChanged}
onInputChange={this.handleInputChange}
isWorkflow={isWorkflowNode}
/>
</div>
</div>

View file

@ -0,0 +1,45 @@
/* Specific styling for workflow modal */
.workflow-rest-api {
display: flex;
flex-direction: column;
}
/* Ensure method and URL fields have full width in workflow node */
.workflow-rest-api .me-2 {
width: 100% !important;
margin-bottom: 16px; /* Increased spacing to avoid label overlap */
}
/* Ensure URL label doesn't overlap with Method dropdown */
.workflow-rest-api .field .font-weight-medium {
margin-bottom: 4px;
display: block;
padding-top: 4px; /* Add space above URL label */
}
/* Fix the method dropdown width and height for workflow */
.workflow-rest-api .me-2 {
width: 150px !important; /* Wider to accommodate "DELETE" and other long options */
height: auto !important;
min-height: 32px;
}
/* Fix Add more button to fit text properly */
.add-params-btn {
width: 100px !important;
padding: 4px 8px;
}
.add-params-btn p {
display: flex;
align-items: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Button fix for workflow */
.workflow-rest-api ~ .query-pane-restapi-tabs .add-params-btn {
width: auto !important;
min-width: 100px;
}

View file

@ -7,6 +7,7 @@ import { v4 as uuidv4 } from 'uuid';
import useStore from '@/AppBuilder/_stores/store';
import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext';
import usePopoverObserver from '@/AppBuilder/_hooks/usePopoverObserver';
import useWorkflowStore from '@/_stores/workflowStore';
export function Workflows({ options, optionsChanged, currentState }) {
const { moduleId } = useModuleContext();
@ -15,7 +16,9 @@ export function Workflows({ options, optionsChanged, currentState }) {
const [_selectedWorkflowId, setSelectedWorkflowId] = useState(undefined);
const [params, setParams] = useState([...(options.params ?? [{ key: '', value: '' }])]);
const appId = useStore((state) => state.appStore.modules[moduleId].app.appId);
const workflowIdFromStore = useWorkflowStore((state) => state.workflowId);
const appIdFromStore = useStore((state) => state.appStore.modules[moduleId].app.appId);
const appId = workflowIdFromStore || appIdFromStore;
usePopoverObserver(
document.getElementsByClassName('query-details')[0],

View file

@ -1,3 +1,5 @@
import { toast } from 'react-hot-toast';
import { AsyncQueryHandler } from '@/AppBuilder/_utils/async-query-handler';
import _, { isEmpty } from 'lodash';
import { resolveReferences, loadPyodide, hasCircularDependency } from '@/_helpers/utils';
import { fetchOAuthToken, fetchOauthTokenForSlackAndGSheet } from '@/AppBuilder/_utils/auth';
@ -7,7 +9,7 @@ import axios from 'axios';
import { validateMultilineCode } from '@/_helpers/utility';
import { convertMapSet, getQueryVariables } from '@/AppBuilder/_utils/queryPanel';
import { deepClone } from '@/_helpers/utilities/utils.helpers';
import toast from 'react-hot-toast';
const queryManagerPreferences = JSON.parse(localStorage.getItem('queryManagerPreferences')) ?? {};
const initialState = {
@ -168,6 +170,19 @@ export const createQueryPanelSlice = (set, get) => ({
'setLoadingDataQueries'
),
setAsyncQueryRuns: (updater) =>
set(
(state) => {
if (typeof updater === 'function') {
state.queryPanel.asyncQueryRuns = updater(state.queryPanel.asyncQueryRuns);
} else {
state.queryPanel.asyncQueryRuns = updater;
}
},
false,
'setAsyncQueryRuns'
),
onQueryConfirmOrCancel: (queryConfirmationData, isConfirm = false, mode = 'edit', moduleId = 'canvas') => {
const { queryPanel, dataQuery, setResolvedQuery } = get();
const { runQuery } = queryPanel;
@ -208,6 +223,69 @@ export const createQueryPanelSlice = (set, get) => ({
);
},
createWorkflowAsyncHandler: ({
executionId,
queryId,
processQueryResults,
handleFailure,
shouldSetPreviewData,
setPreviewData,
setResolvedQuery,
}) => {
const asyncHandler = new AsyncQueryHandler({
streamSSE: (jobId) => {
return workflowExecutionsService.streamSSE(jobId);
},
extractJobId: () => executionId,
classifyEventStatus: (eventData) => {
// hardcoded for workflows
if (eventData.type === 'workflow_connection_close') {
return { status: 'CLOSE', data: eventData };
} else if (eventData.type === 'workflow_execution_completed') {
return { status: 'COMPLETE', result: eventData.result, data: eventData };
} else if (eventData.type === 'workflow_execution_error') {
return { status: 'ERROR', data: eventData };
} else {
return { status: 'PROGRESS', data: eventData };
}
},
callbacks: {
onProgress: (progressData) => {
// Update UI with progress information
if (shouldSetPreviewData) {
setPreviewData({ ...progressData });
}
setResolvedQuery(queryId, {
isLoading: true,
progress: progressData.progress,
currentData: progressData.partialData || [],
});
},
onComplete: async (result) => {
await processQueryResults(result);
// Remove the AsyncQueryHandler instance from asyncQueryRuns on completion
get().queryPanel.setAsyncQueryRuns((currentRuns) =>
currentRuns.filter((handler) => handler.jobId !== asyncHandler.jobId)
);
},
onError: (e) => {
handleFailure({
status: 'failed',
message: e?.error?.message || 'Error running workflow',
description: e?.error?.description || null,
data: typeof e?.error === 'object' ? { ...e.error } : e?.error,
});
// Remove the AsyncQueryHandler instance from asyncQueryRuns on error
get().queryPanel.setAsyncQueryRuns((currentRuns) =>
currentRuns.filter((handler) => handler.jobId !== asyncHandler.jobId)
);
},
},
});
return asyncHandler;
},
runQuery: (
queryId,
queryName,
@ -238,7 +316,7 @@ export const createQueryPanelSlice = (set, get) => ({
setPreviewPanelExpanded,
executeRunPycode,
runTransformation,
executeWorkflow,
triggerWorkflow,
executeMultilineJS,
} = queryPanel;
const queryUpdatePromise = dataQuerySlice.queryUpdates[queryId];
@ -339,6 +417,120 @@ export const createQueryPanelSlice = (set, get) => ({
}
}
// Handler for transformation and completion of query results
const processQueryResults = async (data, rawData = null) => {
let finalData = data;
rawData = rawData || data;
if (dataQuery.options.enableTransformation) {
finalData = await runTransformation(
finalData,
query.options.transformation,
query.options.transformationLanguage,
query,
mode,
moduleId
);
if (finalData.status === 'failed') {
handleFailure(finalData);
return finalData;
}
}
if (shouldSetPreviewData) {
setPreviewLoading(false);
setPreviewData(finalData);
}
if (dataQuery.options.showSuccessNotification) {
const notificationDuration = dataQuery.options.notificationDuration * 1000 || 5000;
toast.success(dataQuery.options.successMessage, {
duration: notificationDuration,
});
}
get().debugger.log({
logLevel: 'success',
type: 'query',
kind: query.kind,
key: query.name,
message: 'Query executed successfully',
isQuerySuccessLog: true,
errorTarget: 'Queries',
});
setResolvedQuery(
queryId,
{
isLoading: false,
data: finalData,
rawData,
metadata: data?.metadata,
request: data?.metadata?.request,
response: data?.metadata?.response,
},
moduleId
);
onEvent('onDataQuerySuccess', queryEvents, mode);
return { status: 'ok', data: finalData };
};
// Handler for query failures
const handleFailure = (errorData) => {
if (shouldSetPreviewData) {
setPreviewLoading(false);
setPreviewData(errorData);
}
get().debugger.log({
logLevel: 'error',
type: 'query',
kind: query.kind,
key: query.name,
message: errorData?.description,
errorTarget: 'Queries',
error:
query.kind === 'restapi'
? {
substitutedVariables: options,
request: errorData?.requestObject,
response: errorData?.responseObject,
}
: errorData,
isQuerySuccessLog: false,
});
setResolvedQuery(
queryId,
{
isLoading: false,
...(query.kind === 'restapi' || errorData?.type === 'tj-401'
? {
metadata: errorData?.metadata,
request: errorData?.requestObject,
response: errorData?.responseObject,
responseHeaders: errorData?.responseHeaders,
}
: {}),
},
moduleId
);
setResolvedQuery(
queryId,
{
isLoading: false,
error: errorData,
},
moduleId
);
onEvent('onDataQueryFailure', queryEvents);
return errorData;
};
// eslint-disable-next-line no-unused-vars
return new Promise(function (resolve, reject) {
if (shouldSetPreviewData) {
@ -363,9 +555,8 @@ export const createQueryPanelSlice = (set, get) => ({
} else if (query.kind === 'runpy') {
queryExecutionPromise = executeRunPycode(query.options?.code, query, false, mode, queryState, moduleId);
} else if (query.kind === 'workflows') {
queryExecutionPromise = executeWorkflow(
queryExecutionPromise = triggerWorkflow(
moduleId,
query,
query.options?.workflowId,
query.options?.blocking,
query.options?.params,
@ -395,6 +586,38 @@ export const createQueryPanelSlice = (set, get) => ({
fetchOAuthToken(url, dataQuery['data_source_id'] || dataQuery['dataSourceId']);
}
// Asynchronous query execution
// Currently async query resolution is applicable only to workflows
// Change this conditional to async query type check for other
// async queries in the future
if (query.kind === 'workflows') {
const { error, completionPromise } = get().queryPanel.setupAsyncWorkflowHandler({
data,
queryId,
processQueryResults,
handleFailure,
shouldSetPreviewData,
setPreviewData,
setResolvedQuery,
});
if (error) {
resolve({ status: 'failed', message: error });
return;
}
if (!error && completionPromise) {
// This early resolution pattern is temporary - once the UI fully supports
// tracking individual async queries through their lifecycle, we can refactor
// this to rely on the completion promise concurrently
const result = await completionPromise;
resolve(result);
}
return;
}
// Handle synchronous queries (original code)
let queryStatusCode = data?.status ?? null;
const promiseStatus = query.kind === 'runpy' ? data?.data?.status ?? 'ok' : data.status;
// Note: Need to move away from statusText -> statusCode
@ -429,120 +652,22 @@ export const createQueryPanelSlice = (set, get) => ({
errorData = data;
break;
}
if (shouldSetPreviewData) {
setPreviewLoading(false);
setPreviewData(errorData);
}
errorData = query.kind === 'runpy' || query.kind === 'runjs' ? data?.data : data;
get().debugger.log({
logLevel: 'error',
type: 'query',
kind: query.kind,
key: query.name,
message: errorData?.description,
errorTarget: 'Queries',
error:
query.kind === 'restapi'
? {
substitutedVariables: options,
request: data?.data?.requestObject,
response: data?.data?.responseObject,
}
: errorData,
isQuerySuccessLog: false,
});
setResolvedQuery(
queryId,
{
isLoading: false,
...(query.kind === 'restapi' || data.data.type === 'tj-401'
? {
metadata: data.metadata,
request: data.data.requestObject,
response: data.data.responseObject,
responseHeaders: data.data.responseHeaders,
}
: {}),
},
moduleId
);
resolve(data);
onEvent('onDataQueryFailure', queryEvents);
const result = handleFailure(errorData);
resolve(result);
return;
} else {
let rawData = data.data;
let finalData = data.data;
if (dataQuery.options.enableTransformation) {
finalData = await runTransformation(
finalData,
query.options.transformation,
query.options.transformationLanguage,
query,
'edit',
moduleId
);
if (finalData.status === 'failed') {
setResolvedQuery(
queryId,
{
isLoading: false,
},
moduleId
);
resolve(finalData);
onEvent('onDataQueryFailure', queryEvents);
setPreviewLoading(false);
if (shouldSetPreviewData) setPreviewData(finalData);
return;
}
}
if (shouldSetPreviewData) {
setPreviewLoading(false);
setPreviewData(finalData);
}
if (dataQuery.options.showSuccessNotification) {
const notificationDuration = dataQuery.options.notificationDuration * 1000 || 5000;
toast.success(dataQuery.options.successMessage, {
duration: notificationDuration,
});
}
get().debugger.log({
logLevel: 'success',
type: 'query',
kind: query.kind,
key: query.name,
message: 'Query executed successfully',
isQuerySuccessLog: true,
errorTarget: 'Queries',
});
setResolvedQuery(
queryId,
{
isLoading: false,
data: finalData,
rawData,
metadata: data?.metadata,
request: data?.metadata?.request,
response: data?.metadata?.response,
},
moduleId
);
resolve({ status: 'ok', data: finalData });
onEvent('onDataQuerySuccess', queryEvents, mode);
const rawData = data.data;
const result = await processQueryResults(data.data, rawData);
resolve(result);
}
})
.catch((e) => {
const { error } = e;
if (mode !== 'view') toast.error(error ?? 'Unknown error');
resolve({ status: 'failed', message: error });
const errorMessage = typeof error === 'string' ? error : error?.message || 'Unknown error';
if (mode !== 'view') toast.error(errorMessage);
resolve({ status: 'failed', message: errorMessage });
});
});
},
@ -556,7 +681,7 @@ export const createQueryPanelSlice = (set, get) => ({
setPreviewPanelExpanded,
executeRunPycode,
runTransformation,
executeWorkflow,
triggerWorkflow,
executeMultilineJS,
setIsPreviewQueryLoading,
} = queryPanel;
@ -616,7 +741,7 @@ export const createQueryPanelSlice = (set, get) => ({
} else if (query.kind === 'runpy') {
queryExecutionPromise = executeRunPycode(query.options.code, query, true, 'edit', queryState);
} else if (query.kind === 'workflows') {
queryExecutionPromise = executeWorkflow(
queryExecutionPromise = triggerWorkflow(
moduleId,
query.options.workflowId,
query.options.blocking,
@ -629,11 +754,73 @@ export const createQueryPanelSlice = (set, get) => ({
queryExecutionPromise
.then(async (data) => {
// Asynchronous query execution
// Currently async query resolution is applicable only to workflows
// Change this conditional to async query type check for other
// async queries in the future
if (query.kind === 'workflows') {
const processQueryResultsPreview = async (result) => {
let finalData = result;
if (query.options.enableTransformation) {
finalData = await runTransformation(
finalData,
query.options.transformation,
query.options.transformationLanguage,
query,
'edit',
moduleId
);
if (finalData.status === 'failed') {
setPreviewLoading(false);
setIsPreviewQueryLoading(false);
if (!calledFromQuery) setPreviewData(finalData);
return { status: 'failed', data: finalData };
}
}
setPreviewLoading(false);
setIsPreviewQueryLoading(false);
if (!calledFromQuery) setPreviewData(finalData);
return { status: 'ok', data: finalData };
};
const handleFailurePreview = (errorData) => {
setPreviewLoading(false);
setIsPreviewQueryLoading(false);
if (!calledFromQuery) setPreviewData(errorData);
return { status: 'failed', data: errorData };
};
const { error, completionPromise } = get().queryPanel.setupAsyncWorkflowHandler({
data,
queryId: query.id,
processQueryResults: processQueryResultsPreview,
handleFailure: handleFailurePreview,
shouldSetPreviewData: true,
setPreviewData,
setResolvedQuery: () => {}, // No resolvedQuery for preview
resolve,
});
if (!error && completionPromise) {
try {
// This early resolution pattern is temporary - once the UI fully supports
// tracking individual async queries through their lifecycle, we can refactor
// this to rely on the completion promise concurrently
const result = await completionPromise;
resolve(result);
} catch (error) {
toast.error('Async operation failed:', error);
setPreviewLoading(false);
setIsPreviewQueryLoading(false);
resolve({ status: 'failed', message: error?.message || 'Unknown error' });
}
}
return;
}
let finalData = data.data;
let queryStatusCode = data?.status ?? null;
const queryStatus = query.kind === 'runpy' ? data?.data?.status ?? 'ok' : data.status;
switch (true) {
// Note: Need to move away from statusText -> statusCode
case queryStatus === 'Bad Request' ||
queryStatus === 'Not Found' ||
queryStatus === 'Unprocessable Entity' ||
@ -665,9 +852,7 @@ export const createQueryPanelSlice = (set, get) => ({
}
onEvent('onDataQueryFailure', queryEvents);
if (!calledFromQuery) setPreviewData(errorData);
break;
}
case queryStatus === 'needs_oauth': {
@ -730,7 +915,7 @@ export const createQueryPanelSlice = (set, get) => ({
});
},
executeRunPycode: async (code, query, isPreview, mode, currentState) => {
executeRunPycode: async (code, query, isPreview, mode, currentState, _moduleId = 'canvas') => {
const {
queryPanel: { evaluatePythonCode },
} = get();
@ -950,7 +1135,13 @@ export const createQueryPanelSlice = (set, get) => ({
const {
queryPanel: { evaluatePythonCode },
} = get();
return await evaluatePythonCode({ queryResult, code, query, mode, currentState });
return await evaluatePythonCode({
queryResult,
code,
query,
mode,
currentState,
});
},
updateQuerySuggestions: (oldName, newName) => {
@ -971,7 +1162,7 @@ export const createQueryPanelSlice = (set, get) => ({
delete updatedQueries[oldName];
const oldSuggestions = Object.keys(queries[oldName]).map((key) => `queries.${oldName}.${key}`);
const _oldSuggestions = Object.keys(queries[oldName]).map((key) => `queries.${oldName}.${key}`);
// useResolveStore.getState().actions.removeAppSuggestions(oldSuggestions);
// useCurrentStateStore.getState().actions.setCurrentState({
@ -1013,6 +1204,18 @@ export const createQueryPanelSlice = (set, get) => ({
return { data: undefined, status: 'failed' };
}
},
triggerWorkflow: async (moduleId, workflowAppId, _blocking = false, params = {}, appEnvId) => {
const { getAllExposedValues } = get();
const currentState = getAllExposedValues();
const resolvedParams = get().resolveReferences(moduleId, params, currentState, {}, {});
try {
const executionResponse = await workflowExecutionsService.trigger(workflowAppId, resolvedParams, appEnvId);
return { data: executionResponse.result, status: 'ok' };
} catch (e) {
return { data: e?.message, status: 'failed' };
}
},
createProxy: (obj, path = '') => {
const { queryPanel } = get();
@ -1209,6 +1412,48 @@ export const createQueryPanelSlice = (set, get) => ({
isQuerySelected: (queryId) => {
return get().queryPanel.selectedQuery?.id === queryId;
},
setupAsyncWorkflowHandler: ({
data,
queryId,
processQueryResults,
handleFailure,
shouldSetPreviewData,
setPreviewData,
setResolvedQuery,
}) => {
try {
const asyncHandler = get().queryPanel.createWorkflowAsyncHandler({
executionId: data.data.executionId,
queryId,
processQueryResults,
handleFailure,
shouldSetPreviewData,
setPreviewData,
setResolvedQuery,
});
// Process initial response and start SSE monitoring
const { __asyncCompletionPromise } = asyncHandler.processInitialResponse(data.data);
// Add the AsyncQueryHandler instance to asyncQueryRuns
get().queryPanel.setAsyncQueryRuns((currentRuns) => [...currentRuns, asyncHandler]);
if (setResolvedQuery) {
setResolvedQuery(queryId, {
isLoading: true,
jobId: asyncHandler.jobId,
});
}
return {
handler: asyncHandler,
completionPromise: __asyncCompletionPromise,
};
} catch (error) {
return { error };
}
},
runQueryOnShortcut: () => {
const { queryPanel } = get();
const { runQuery, selectedQuery } = queryPanel;

View file

@ -0,0 +1,141 @@
// AsyncQueryHandler manages long-running operations via server-sent events (SSE).
export class AsyncQueryHandler {
/**
* Creates a new AsyncQueryHandler
* @param {Object} options - Configuration options
* @param {Function} options.streamSSE - Function that returns an EventSource for SSE status updates
* @param {Function} options.extractJobId - Function to extract job ID from response
* @param {Function} options.classifyEventStatus - Function to classify SSE events into status categories
* @param {Object} options.callbacks - Event callbacks
* @param {Function} options.callbacks.onProgress - Progress update handler
* @param {Function} options.callbacks.onComplete - Completion handler
* @param {Function} options.callbacks.onError - Error handler
* @param {Function} options.callbacks.onClose - Close handler
*/
constructor(options = {}) {
this.config = {
streamSSE: () => {},
extractJobId: (response) => response.data?.id,
// Default implementation that doesn't make assumptions about specific status/type fields
classifyEventStatus: (data) => {
return {
// Default to treating all messages as progress updates
status: 'PROGRESS',
result: data.result || data,
// Return data for callback handlers
data,
};
},
callbacks: {
onProgress: () => {},
onComplete: () => {},
onError: () => {},
onClose: () => {},
},
...options,
};
this.eventSource = null;
this.jobId = null;
}
/**
* Processes the initial query response and starts SSE monitoring
* @param {Object} response - The initial query response
* @returns {{ __jobId: string, __cancel: Function, __asyncCompletionPromise: Promise<any> }} Status object with jobId, control methods, and completion promise
*/
processInitialResponse(response) {
const jobId = this.config.extractJobId(response);
if (!jobId) throw new Error('Could not extract job ID for async query');
this.jobId = jobId;
this.eventSource = this.startSSE(jobId);
// Return the reserved async completion promise for consumers
this.__asyncCompletionPromise =
this.__asyncCompletionPromise ||
new Promise((resolve, reject) => {
this.resolveCompletion = resolve;
this.rejectCompletion = reject;
});
return { __jobId: jobId, __cancel: () => this.cancel(), __asyncCompletionPromise: this.__asyncCompletionPromise };
}
/**
* Opens an SSE connection to receive real-time updates for the given job.
* @private
* @param {string} jobId - Identifier for the async job
* @returns {EventSource} SSE event source for updates
*/
startSSE(jobId) {
const eventSource = this.config.streamSSE(jobId);
eventSource.onmessage = (event) => this.handleMessage(event, eventSource);
eventSource.onerror = (error) => this.handleError(error, eventSource);
return eventSource;
}
/**
* Processes incoming SSE messages and delegates to the appropriate callback.
* @private
* @param {MessageEvent} event - Incoming SSE message
* @param {EventSource} eventSource - EventSource instance for the SSE connection
*/
handleMessage(event, eventSource) {
try {
const payload = JSON.parse(event.data);
const { status, result, data } = this.config.classifyEventStatus(payload);
switch (status) {
case 'PROGRESS':
this.config.callbacks.onProgress(data);
break;
case 'COMPLETE':
eventSource.close();
this.config.callbacks.onComplete(result);
this.resolveCompletion(result);
break;
case 'ERROR':
eventSource.close();
this.config.callbacks.onError(data);
this.rejectCompletion(data);
break;
case 'CLOSE':
eventSource.close();
this.config.callbacks.onClose(data);
break;
default:
this.config.callbacks.onProgress(data);
}
} catch (err) {
console.error('Error parsing SSE message:', err);
eventSource.close();
this.config.callbacks.onError({ message: 'Invalid server message', error: err });
}
}
/**
* Handles SSE connection errors and notifies onError if closed.
* @private
* @param {any} error - Error event or object
* @param {EventSource} eventSource - EventSource instance for the SSE connection
*/
handleError(error, eventSource) {
if (eventSource.readyState === EventSource.CLOSED) {
this.config.callbacks.onError({ message: 'SSE connection closed', error });
}
}
/**
* Cancels the ongoing async operation and cleans up resources.
*/
cancel() {
if (this.eventSource) {
this.eventSource.close();
}
// Notify backend to cancel the job if jobId exists
// if (this.jobId) {
// fetch(`${this.config.endpoint}/${this.jobId}/cancel`, { method: 'POST' }).catch((e) =>
// console.error('Failed to cancel async job', e)
// );
// }
}
}

View file

@ -14,6 +14,7 @@ import { useQueryPanelActions } from '@/_stores/queryPanelStore';
import { Tooltip } from 'react-tooltip';
import { canCreateDataSource } from '@/_helpers';
import SolidIcon from '@/_ui/Icon/SolidIcons';
import { isWorkflowsFeatureEnabled } from '@/modules/common/helpers/utils';
import '../queryManager.theme.scss';
function DataSourcePicker({ dataSources, sampleDataSource, staticDataSources, darkMode, globalDataSources }) {
@ -50,7 +51,7 @@ function DataSourcePicker({ dataSources, sampleDataSource, staticDataSources, da
navigate(`/${workspaceId}/data-sources`);
};
const workflowsEnabled = window.public_config?.ENABLE_WORKFLOWS_FEATURE == 'true';
const workflowsEnabled = isWorkflowsFeatureEnabled();
return (
<>

View file

@ -14,8 +14,17 @@ import { DataBaseSources, ApiSources, CloudStorageSources } from '@/modules/comm
import { canCreateDataSource } from '@/_helpers';
import './../queryManager.theme.scss';
import { DATA_SOURCE_TYPE } from '@/_helpers/constants';
import { workflowDefaultSources } from '../constants';
function DataSourceSelect({ isDisabled, selectRef, closePopup, workflowDataSources, onNewNode, staticDataSources }) {
function DataSourceSelect({
isDisabled,
selectRef,
closePopup,
workflowDataSources,
onNewNode,
staticDataSources,
sampleDataSources = [],
}) {
const dataSources = useDataSources();
const globalDataSources = useGlobalDataSources();
const sampleDataSource = useSampleDataSource();
@ -32,6 +41,10 @@ function DataSourceSelect({ isDisabled, selectRef, closePopup, workflowDataSourc
closePopup();
};
function cleanWord(word) {
return word.replace(/default/g, '');
}
useEffect(() => {
const shouldAddSampleDataSource = !!sampleDataSource;
const allDataSources = [...dataSources, ...globalDataSources, shouldAddSampleDataSource && sampleDataSource].filter(
@ -132,6 +145,37 @@ function DataSourceSelect({ isDisabled, selectRef, closePopup, workflowDataSourc
...userDefinedSourcesOpts,
];
// Group sample data sources by kind
const groupedSampleDataSources =
sampleDataSources && sampleDataSources.length > 0
? Object.entries(groupBy(sampleDataSources, 'kind')).map(([kind, sources]) => ({
label: (
<div>
<DataSourceIcon source={sources[0]} height={16} />
<span className="ms-1 small">{dataSourcesKinds.find((dsk) => dsk.kind === kind)?.name || kind}</span>
</div>
),
options: sources.map((source) => ({
label: (
<div
key={source.id}
className="py-2 px-2 rounded option-nested-datasource-selector small text-truncate"
style={{ fontSize: '13px' }}
data-tooltip-id="tooltip-for-add-query-dd-option"
data-tooltip-content={decodeEntities(source.name)}
data-cy={`ds-${source.name.toLowerCase()}`}
>
{decodeEntities(source.name)}
<Tooltip id="tooltip-for-add-query-dd-option" className="tooltip query-manager-ds-select-tooltip" />
</div>
),
value: source.id,
isNested: true,
source,
})),
}))
: [];
const dataSourcesAvailable = [
{
label: (
@ -146,7 +190,7 @@ function DataSourceSelect({ isDisabled, selectRef, closePopup, workflowDataSourc
label: (
<div>
<DataSourceIcon source={source} height={16} />{' '}
<span className="ms-1 small">{source?.name ?? source.kind}</span>
<span className="ms-1 small"> {workflowDefaultSources[cleanWord(source.name)]?.name}</span>
</div>
),
value: source.name,
@ -154,6 +198,22 @@ function DataSourceSelect({ isDisabled, selectRef, closePopup, workflowDataSourc
})),
},
...userDefinedSourcesOpts,
// Sample data sources group header
...(groupedSampleDataSources.length > 0
? [
{
label: (
<div>
<span className="color-slate9" style={{ fontWeight: 500 }}>
Sample data sources
</span>
</div>
),
isDisabled: true,
},
...groupedSampleDataSources,
]
: []),
];
const dataSourceList = workflowDataSources && workflowDataSources ? dataSourcesAvailable : DataSourceOptions;

View file

@ -106,3 +106,10 @@ export const defaultSources = {
runpy: { kind: 'runpy', id: 'runpy', name: 'Run Python code' },
workflows: { kind: 'workflows', id: 'null', name: 'Run Workflow' },
};
export const workflowDefaultSources = {
...defaultSources,
'If condition': { kind: 'if', id: 'if', name: 'If condition' },
Response: { kind: 'response', id: 'response', name: 'Response' },
Loop: { kind: 'loop', id: 'loop', name: 'Loop' },
};

View file

@ -85,6 +85,12 @@ export const AppMenu = function AppMenu({
)}
{canUpdateApp && canCreateApp && appType !== 'workflow' && (
<>
{appType !== 'workflow' && (
<Field
text={t('homePage.appCard.cloneApp', 'Clone app')}
onClick={() => openAppActionModal('clone-app')}
/>
)}
<Field
text={
appType === 'workflow' ? 'Clone workflow' : appType === 'module' ? 'Clone module' : 'Clone app'

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, ToolTip } from '@/_components';
import Select from '@/_ui/Select';
import _, { sample, isEmpty } from 'lodash';
import _, { sample, isEmpty, capitalize, has } from 'lodash';
import { Folders } from './Folders';
import { BlankPage } from './BlankPage';
import { toast } from 'react-hot-toast';
@ -48,6 +48,7 @@ import {
} from '@/modules/dashboard/components';
import CreateAppWithPrompt from '@/modules/AiBuilder/components/CreateAppWithPrompt';
import SolidIcon from '@/_ui/Icon/SolidIcons';
import { isWorkflowsFeatureEnabled } from '@/modules/common/helpers/utils';
import EmptyModuleSvg from '../../assets/images/icons/empty-modules.svg';
const { iconList, defaultIcon } = configs;
@ -256,7 +257,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) => {
@ -337,6 +342,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];
@ -451,7 +516,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`);
}
};
@ -483,7 +548,7 @@ class HomePageComponent extends React.Component {
};
canViewWorkflow = () => {
return this.canUserPerform(this.state.currentUser, 'view');
return this.canUserPerform(this.state.currentUser, 'view') && isWorkflowsFeatureEnabled();
};
canUserPerform(user, action, app) {
@ -951,6 +1016,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,
@ -1010,7 +1122,7 @@ class HomePageComponent extends React.Component {
} else if (this.props.appType === 'front-end') {
return appsLimit?.percentage >= 100;
} else {
return workflowInstanceLevelLimit.percentage >= 100 || workflowWorkspaceLevelLimit.percentage >= 100;
return this.hasWorkflowLimitReached();
}
};
const modalConfigs = {
@ -1462,15 +1574,12 @@ class HomePageComponent extends React.Component {
)}
</>
</Button>
{this.props.appType !== 'workflow' && (
<Dropdown.Toggle
disabled={getDisabledState()}
split
className="d-inline"
data-cy="import-dropdown-menu"
/>
)}
<Dropdown.Toggle
disabled={getDisabledState()}
split
className="d-inline"
data-cy="import-dropdown-menu"
/>
<ImportAppMenu
darkMode={this.props.darkMode}
showTemplateLibraryModal={
@ -1677,7 +1786,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

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.3884 6L7.61156 6C6.45387 6 5.73256 7.25582 6.31589 8.25581L10.7043 15.7789C11.2832 16.7711 12.7169 16.7711 13.2957 15.7789L17.6841 8.25581C18.2674 7.25582 17.5461 6 16.3884 6Z" fill="#6A727C"/>
</svg>

After

Width:  |  Height:  |  Size: 310 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 16.3885V7.61157C7 6.45389 8.25582 5.73258 9.25581 6.3159L16.7789 10.7043C17.7711 11.2832 17.7711 12.7169 16.7789 13.2957L9.25581 17.6841C8.25582 18.2675 7 17.5461 7 16.3885Z" fill="#6A727C"/>
</svg>

After

Width:  |  Height:  |  Size: 307 B

View file

@ -11,6 +11,7 @@ export const dataqueryService = {
changeQueryDataSource,
updateStatus,
bulkUpdateQueryOptions,
createWorkflowQuery,
};
function getAll(appVersionId, mode) {
@ -36,6 +37,21 @@ function create(app_id, app_version_id, name, kind, options, data_source_id, plu
).then(handleResponse);
}
function createWorkflowQuery(app_id, app_version_id, name, kind, options, data_source_id, plugin_id) {
const body = {
app_id,
app_version_id,
name,
kind,
options,
data_source_id,
plugin_id,
};
const requestOptions = { method: 'POST', headers: authHeader(), credentials: 'include', body: JSON.stringify(body) };
return fetch(`${config.apiUrl}/data-queries/workflow-node`, requestOptions).then(handleResponse);
}
function update(id, versionId, name, options, dataSourceId) {
const body = {
options,

View file

@ -10,11 +10,15 @@ export const workflowExecutionsService = {
all,
enableWebhook,
previewQueryNode,
getPaginatedExecutions,
getPaginatedNodes,
trigger,
streamSSE,
};
function previewQueryNode(queryId, appVersionId, nodeId) {
function previewQueryNode(queryId, appVersionId, nodeId, state = {}) {
const currentSession = authenticationService.currentSessionValue;
const body = { appVersionId, userId: currentSession.current_user?.id, queryId, nodeId };
const body = { appVersionId, userId: currentSession.current_user?.id, queryId, nodeId, state };
const requestOptions = { method: 'POST', headers: authHeader(), body: JSON.stringify(body), credentials: 'include' };
return fetch(`${config.apiUrl}/workflow_executions/previewQueryNode`, requestOptions).then(handleResponse);
}
@ -70,3 +74,40 @@ function enableWebhook(appId, value) {
const requestOptions = { method: 'PATCH', headers: authHeader(), body: JSON.stringify(body), credentials: 'include' };
return fetch(`${config.apiUrl}/v2/webhooks/workflows/${appId}`, requestOptions).then(handleResponse);
}
function getPaginatedExecutions(appVersionId, page = 1, perPage = 10) {
const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' };
return fetch(
`${config.apiUrl}/workflow_executions?appVersionId=${appVersionId}&page=${page}&per_page=${perPage}`,
requestOptions
).then(handleResponse);
}
function getPaginatedNodes(executionId, page = 1, perPage = 20) {
const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' };
return fetch(
`${config.apiUrl}/workflow_executions/${executionId}/nodes?page=${page}&per_page=${perPage}`,
requestOptions
).then(handleResponse);
}
function trigger(workflowAppId, params, environmentId) {
const currentSession = authenticationService.currentSessionValue;
const body = {
appId: workflowAppId,
userId: currentSession.current_user?.id,
executeUsing: 'app',
params: Array.isArray(params)
? Object.fromEntries(params.filter((param) => param.key !== '').map((param) => [param.key, param.value]))
: params || {},
environmentId,
};
const requestOptions = { method: 'POST', headers: authHeader(), body: JSON.stringify(body), credentials: 'include' };
return fetch(`${config.apiUrl}/workflow_executions/${workflowAppId}/trigger`, requestOptions).then(handleResponse);
}
function streamSSE(workflowExecutionId) {
return new EventSource(`${config.apiUrl}/workflow_executions/${workflowExecutionId}/stream`, {
withCredentials: true,
});
}

View file

@ -0,0 +1,8 @@
import create from 'zustand';
const useWorkflowStore = create((set) => ({
workflowId: null,
setWorkflowId: (id) => set({ workflowId: id }),
}));
export default useWorkflowStore;

View file

@ -10,13 +10,14 @@ 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">
{props.appType !== 'module' && (
{appType !== 'wzorkflow' && appType !== 'module' && (
<Dropdown.Item
className="homepage-dropdown-style tj-text tj-text-xsm"
onClick={showTemplateLibraryModal}

View file

@ -6,11 +6,12 @@ import { getPrivateRoute, redirectToDashboard, redirectToWorkflows } from '@/_he
import SolidIcon from '@/_ui/Icon/SolidIcons';
import AppLogo from '@/_components/AppLogo';
import { hasBuilderRole } from '@/_helpers/utils';
import { isWorkflowsFeatureEnabled } from '@/modules/common/helpers/utils';
const BaseLogoNavDropdown = ({ darkMode, showWorkflows = false, type = 'apps' }) => {
const { admin } = authenticationService?.currentSessionValue ?? {};
const isWorkflows = type === 'workflows';
const workflowsEnabled = admin && window.public_config?.ENABLE_WORKFLOWS_FEATURE == 'true';
const workflowsEnabled = admin && isWorkflowsFeatureEnabled();
const handleBackClick = (e) => {
e.preventDefault();

View file

@ -18,4 +18,9 @@ const fetchEdition = () => {
return config.TOOLJET_EDITION?.toLowerCase() || 'ce';
};
export { processErrorMessage, clearPageHistory, fetchEdition };
const isWorkflowsFeatureEnabled = () => {
if (fetchEdition() === 'ee') return true;
return false;
};
export { processErrorMessage, clearPageHistory, fetchEdition, isWorkflowsFeatureEnabled };

View file

@ -32,11 +32,11 @@ export function resolveCode(codeContext) {
...Object.fromEntries(reservedKeyword.map((keyWord) => [keyWord, null])),
};
const codeToExecute = getFunctionWrappedCode(
'const console = { log: __reserved_keyword_log };\n' + code,
'const console = { log: (...args) => __reserved_keyword_log(args.join(\', \'), \'normal\') };\n' + code,
globalState,
isIfCondition
);
const isolate = new ivm.Isolate({ memoryLimit: parseInt(process.env?.WORKFLOWS_JS_MEMORY_LIMIT ?? '20') });
const isolate = new ivm.Isolate({ memoryLimit: parseInt(process.env?.WORKFLOW_JS_MEMORY_LIMIT_MB) || 20 });
const context = isolate.createContextSync();
Object.entries(globalState).forEach(([key, value]) => {
context.global.setSync(key, new ivm.ExternalCopy(value).copyInto({ release: true }));
@ -57,7 +57,13 @@ export function resolveCode(codeContext) {
// }, 1); // Monitor every 100ms
// try {
result = script.runSync(context, { release: true, timeout: 100, copy: true });
result = script.runSync(
context,
{
release: true,
timeout: parseInt(process.env?.WORKFLOW_JS_TIMEOUT_MS) || 100,
copy: true
});
// const stats = isolate.getHeapStatisticsSync();
// addLog("Used heap size: " + stats.used_heap_size);
// addLog("heap size limit: " + stats.heap_size_limit);

View file

@ -1,4 +1,4 @@
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
import { IsString, IsNotEmpty, IsOptional, IsObject } from 'class-validator';
export class PreviewWorkflowNodeDto {
@IsString()
@ -20,4 +20,8 @@ export class PreviewWorkflowNodeDto {
@IsString()
@IsOptional()
appEnvId?: string;
@IsObject()
@IsOptional()
state?: Record<string, any>;
}

View file

@ -3,6 +3,8 @@ import { GetConnection } from './database/getConnection';
import { ShutdownHook } from './schedulers/shut-down.hook';
import { AppModuleLoader } from './loader';
import * as Sentry from '@sentry/node';
import { getTooljetEdition } from '@helpers/utils.helper';
import { TOOLJET_EDITIONS } from '@modules/app/constants';
import { InstanceSettingsModule } from '@modules/instance-settings/module';
import { AbilityModule } from '@modules/ability/module';
import { LicenseModule } from '@modules/licensing/module';
@ -69,7 +71,7 @@ export class AppModule implements OnModuleInit {
*
*
*/
const imports = [
const baseImports = [
await AbilityModule.forRoot(configs),
await LicenseModule.forRoot(configs),
await FilesModule.register(configs),
@ -104,7 +106,6 @@ export class AppModule implements OnModuleInit {
await ImportExportResourcesModule.register(configs),
await TemplatesModule.register(configs),
await TooljetDbModule.register(configs),
await WorkflowsModule.register(configs),
await ModulesModule.register(configs),
await AiModule.register(configs),
await CustomStylesModule.register(configs),
@ -119,6 +120,13 @@ export class AppModule implements OnModuleInit {
await InMemoryCacheModule.register(configs),
];
const conditionalImports = [];
if (getTooljetEdition() !== TOOLJET_EDITIONS.Cloud) {
conditionalImports.push(await WorkflowsModule.register(configs));
}
const imports = [...baseImports, ...conditionalImports];
return {
module: AppModule,
imports: [...modules, ...imports],

View file

@ -9,12 +9,15 @@ import { ValidAppGuard } from '../guards/valid-app.guard';
import { AppDecorator as App } from '@modules/app/decorators/app.decorator';
import { WorkflowService } from '../services/workflow.service';
import { IWorkflowController } from '../interfaces/IControllerWorkflow';
import { InitFeature } from '@modules/app/decorators/init-feature.decorator';
import { FEATURE_KEY } from '../constants';
@InitModule(MODULES.APP)
@Controller('apps')
export class WorkflowController implements IWorkflowController {
constructor(protected readonly workflowService: WorkflowService) {}
@InitFeature(FEATURE_KEY.GET)
@UseGuards(JwtAuthGuard, ValidAppGuard, FeatureAbilityGuard)
@Get(':id/workflows')
async fetchWorkflows(@App() app: AppEntity) {

View file

@ -30,20 +30,24 @@ export class AppsModule extends SubModule {
static async register(configs: { IS_GET_CONTEXT: boolean }): Promise<DynamicModule> {
const {
AppsController,
WorkflowController,
AppsService,
AppsUtilService,
PageService,
EventsService,
ComponentsService,
WorkflowService,
AppImportExportService,
PageHelperService,
} = await this.getProviders(configs, 'apps', [
'controller',
'controllers/workflow.controller',
'service',
'util.service',
'services/page.service',
'services/event.service',
'services/component.service',
'services/workflow.service',
'services/app-import-export.service',
'services/page.util.service',
]);
@ -63,9 +67,10 @@ export class AppsModule extends SubModule {
await UsersModule.register(configs),
await AppEnvironmentsModule.register(configs),
],
controllers: [AppsController],
controllers: [AppsController, WorkflowController],
providers: [
AppsService,
WorkflowService,
VersionRepository,
AppsRepository,
AppGitRepository,

View file

@ -433,13 +433,7 @@ export class AppImportExportService {
const currentTooljetVersion = !cloning ? tooljetVersion : null;
const importedApp = await this.createImportedAppForUser(
manager,
schemaUnifiedAppParams,
user,
isGitApp,
appParams?.type
);
const importedApp = await this.createImportedAppForUser(manager, schemaUnifiedAppParams, user, isGitApp);
const resourceMapping = await this.setupImportedAppAssociations(
manager,
@ -527,23 +521,22 @@ 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,
type?: APP_TYPES
): Promise<App> {
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 || APP_TYPES.FRONT_END,
isMaintenanceOn: appParams.isMaintenanceOn || false,
organizationId: user?.organizationId,
userId: user.id, //fetch super admin user id for EE
slug: null,
icon: appParams.icon,
type: type || APP_TYPES.FRONT_END,
creationMode: `${isGitApp ? 'GIT' : 'DEFAULT'}`,
isPublic: false,
createdAt: new Date(),
@ -605,7 +598,7 @@ export class AppImportExportService {
isNormalizedAppDefinitionSchema: boolean,
tooljetVersion: string | null,
moduleResourceMappings?: Record<string, unknown>
) {
): Promise<AppResourceMappings> {
// Old version without app version
// Handle exports prior to 0.12.0
// TODO: have version based conditional based on app versions
@ -1270,6 +1263,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[]

View file

@ -0,0 +1,12 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class WorkflowSseAuthGuard extends AuthGuard('jwt') {
canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
request.isUserNotMandatory = true;
return request;
}
}

View file

@ -114,7 +114,7 @@ export class DataQueryRepository extends Repository<DataQuery> {
findOptions: FindOptionsWhere<DataQuery>,
relations?: string[],
manager?: EntityManager
): Promise<DataQuery> {
): Promise<DataQuery[]> {
return dbTransactionWrap(async (manager: EntityManager) => {
return manager.find(DataQuery, {
where: { ...(findOptions ? findOptions : {}) },

View file

@ -5,7 +5,7 @@ import { UserPermissions } from '@modules/ability/types';
import { MODULES } from '@modules/app/constants/modules';
import { dbTransactionWrap } from '@helpers/database.helper';
import { DataSourceScopes, DataSourceTypes } from './constants';
import { GetQueryVariables } from './types';
import { DefaultDataSourceKind, GetQueryVariables } from './types';
import { decode } from 'js-base64';
@Injectable()
@ -162,6 +162,14 @@ export class DataSourcesRepository extends Repository<DataSource> {
});
}
async getStaticDataSourceByKind(organizationId: string, kind: DefaultDataSourceKind, manager?: EntityManager): Promise<DataSource> {
return dbTransactionWrap((manager: EntityManager) => {
return manager.findOneOrFail(DataSource, {
where: { organizationId, type: DataSourceTypes.STATIC, kind },
});
}, manager || this.manager);
}
findByQuery(dataQueryId: string, organizationId: string, dataSourceId?: string, manager?: EntityManager) {
return dbTransactionWrap((manager: EntityManager) => {
return manager.findOne(DataSource, {

View file

@ -5,6 +5,7 @@ import { LicenseTermsService } from '../interfaces/IService';
import { LICENSE_FIELD, LICENSE_LIMIT } from '../constants';
import { AppsRepository } from '@modules/apps/repository';
import { APP_TYPES } from '@modules/apps/constants';
import { isUUID } from 'class-validator';
@Injectable()
export class WebhookGuard implements CanActivate {
@ -23,14 +24,16 @@ export class WebhookGuard implements CanActivate {
: request.headers['tj-workspace-id'];
const workflowsLimit = await this.licenseTermsService.getLicenseTerms(LICENSE_FIELD.WORKFLOWS, organizationId);
const isUuid = isUUID(request?.params?.idOrName);
const workflowApp = await this.appsRepository.findOne({
where: {
id: request?.params?.id,
[isUuid ? 'id' : 'name']: request?.params?.idOrName,
type: APP_TYPES.WORKFLOW,
},
});
if (!workflowApp) throw new HttpException(`Workflow doesn't exists`, 404);
request.tj_app = workflowApp;
// Webhook API token validation
if (request.headers.authorization.split(' ')[1] !== workflowApp.workflowApiToken) throw new UnauthorizedException();
@ -39,71 +42,80 @@ export class WebhookGuard implements CanActivate {
if (!workflowApp.workflowEnabled) throw new HttpException(`Webhook endpoint disabled or doesn't exists`, 404);
// Workspace Level -
// Daily Limit
if (
workflowsLimit.workspace.daily_executions !== LICENSE_LIMIT.UNLIMITED &&
(
await this.manager.query(
`SELECT COUNT(*)
if (workflowsLimit.workspace) {
// Daily Limit
const workspaceDailyExecutionsQuery = `
SELECT COUNT(*)
FROM apps a
INNER JOIN app_versions av on av.app_id = a.id
INNER JOIN workflow_executions we on we.app_version_id = av.id
WHERE a.organization_id = $1
AND DATE(we.created_at) = current_date`,
[workflowApp.organizationId]
)
)[0].count >= workflowsLimit.workspace.daily_executions
) {
throw new HttpException('Maximum daily limit for workflow execution has reached for this workspace', 451);
}
AND DATE(we.created_at) = current_date
`;
// Monthly Limit
if (
workflowsLimit.workspace.monthly_executions !== LICENSE_LIMIT.UNLIMITED &&
(
await this.manager.query(
`SELECT COUNT(*)
if (
workflowsLimit.workspace.daily_executions !== LICENSE_LIMIT.UNLIMITED &&
(await this.manager.query(workspaceDailyExecutionsQuery, [workflowApp.organizationId]))[0].count >=
workflowsLimit.workspace.daily_executions
) {
throw new HttpException('Maximum daily limit for workflow execution has reached for this workspace', 451);
}
// Monthly Limit
const workspaceMonthlyExecutionsQuery = `
SELECT COUNT(*)
FROM apps a
INNER JOIN app_versions av on av.app_id = a.id
INNER JOIN workflow_executions we on we.app_version_id = av.id
WHERE a.organization_id = $1
AND extract (year from we.created_at) = extract (year from current_date)
AND extract (month from we.created_at) = extract (month from current_date)`,
[workflowApp.organizationId]
)
)[0].count >= workflowsLimit.workspace.monthly_executions
) {
throw new HttpException('Maximum monthly limit for workflow execution has reached for this workspace', 451);
AND extract (month from we.created_at) = extract (month from current_date)
`;
if (
workflowsLimit.workspace.monthly_executions !== LICENSE_LIMIT.UNLIMITED &&
(await this.manager.query(workspaceMonthlyExecutionsQuery, [workflowApp.organizationId]))[0].count >=
workflowsLimit.workspace.monthly_executions
) {
throw new HttpException('Maximum monthly limit for workflow execution has reached for this workspace', 451);
}
}
// Instance Level -
// Daily Limit
if (
workflowsLimit.instance.daily_executions !== LICENSE_LIMIT.UNLIMITED &&
(
await this.manager.query(`SELECT COUNT(*)
if (workflowsLimit.instance) {
// Daily Limit
const instanceDailyExecutionsQuery = `
SELECT COUNT(*)
FROM apps a
INNER JOIN app_versions av on av.app_id = a.id
INNER JOIN workflow_executions we on we.app_version_id = av.id
WHERE DATE(we.created_at) = current_date`)
)[0].count >= workflowsLimit.instance.daily_executions
) {
throw new HttpException('Maximum daily limit for workflow execution has been reached', 451);
}
WHERE DATE(we.created_at) = current_date
`;
// Monthly Limit
if (
workflowsLimit.instance.monthly_executions !== LICENSE_LIMIT.UNLIMITED &&
(
await this.manager.query(`SELECT COUNT(*)
if (
workflowsLimit.instance.daily_executions !== LICENSE_LIMIT.UNLIMITED &&
(await this.manager.query(instanceDailyExecutionsQuery))[0].count >= workflowsLimit.instance.daily_executions
) {
throw new HttpException('Maximum daily limit for workflow execution has been reached', 451);
}
// Monthly Limit
const instanceMonthlyExecutionsQuery = `
SELECT COUNT(*)
FROM apps a
INNER JOIN app_versions av on av.app_id = a.id
INNER JOIN workflow_executions we on we.app_version_id = av.id
WHERE extract (year from we.created_at) = extract (year from current_date)
AND extract (month from we.created_at) = extract (month from current_date)`)
)[0].count >= workflowsLimit.instance.monthly_executions
) {
throw new HttpException('Maximum monthly limit for workflow execution has been reached', 451);
AND extract (month from we.created_at) = extract (month from current_date)
`;
if (
workflowsLimit.instance.monthly_executions !== LICENSE_LIMIT.UNLIMITED &&
(await this.manager.query(instanceMonthlyExecutionsQuery))[0].count >=
workflowsLimit.instance.monthly_executions
) {
throw new HttpException('Maximum monthly limit for workflow execution has been reached', 451);
}
}
return true;

View file

@ -22,6 +22,7 @@ export function defineWorkflowVersionAbility(
FEATURE_KEY.UPDATE,
FEATURE_KEY.UPDATE_SETTINGS,
FEATURE_KEY.PROMOTE,
FEATURE_KEY.APP_VERSION_UPDATE,
],
App
);
@ -41,6 +42,7 @@ export function defineWorkflowVersionAbility(
FEATURE_KEY.UPDATE,
FEATURE_KEY.UPDATE_SETTINGS,
FEATURE_KEY.PROMOTE,
FEATURE_KEY.APP_VERSION_UPDATE,
],
App
);
@ -58,6 +60,7 @@ export function defineWorkflowVersionAbility(
FEATURE_KEY.UPDATE,
FEATURE_KEY.UPDATE_SETTINGS,
FEATURE_KEY.PROMOTE,
FEATURE_KEY.APP_VERSION_UPDATE,
],
App
);

View file

@ -8,6 +8,8 @@ export const FEATURES: FeaturesConfig = {
[FEATURE_KEY.WORKFLOW_EXECUTION_STATUS]: {},
[FEATURE_KEY.WORKFLOW_EXECUTION_DETAILS]: {}, //Basic plan users can access worfklows
[FEATURE_KEY.LIST_WORKFLOW_EXECUTIONS]: {},
[FEATURE_KEY.FETCH_EXECUTION_LOGS]: {},
[FEATURE_KEY.FETCH_EXECUTION_NODES]: {},
[FEATURE_KEY.PREVIEW_QUERY_NODE]: {},
[FEATURE_KEY.CREATE_WORKFLOW_SCHEDULE]: {},

View file

@ -3,6 +3,8 @@ export enum FEATURE_KEY {
WORKFLOW_EXECUTION_STATUS = 'workflow_execution_status',
WORKFLOW_EXECUTION_DETAILS = 'workflow_execution_details',
LIST_WORKFLOW_EXECUTIONS = 'list_workflow_executions',
FETCH_EXECUTION_LOGS = 'fetch_execution_logs',
FETCH_EXECUTION_NODES = 'fetch_execution_nodes',
PREVIEW_QUERY_NODE = 'preview_query_node',
CREATE_WORKFLOW_SCHEDULE = 'create_workflow_schedule',

View file

@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, Res } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Query, Res, Sse } from '@nestjs/common';
import { Response } from 'express';
import { IWorkflowExecutionController } from '../interfaces/IWorkflowExecutionController';
import { CreateWorkflowExecutionDto } from '@dto/create-workflow-execution.dto';
@ -9,6 +9,7 @@ import { InitModule } from '@modules/app/decorators/init-module';
import { MODULES } from '@modules/app/constants/modules';
import { InitFeature } from '@modules/app/decorators/init-feature.decorator';
import { FEATURE_KEY } from '@modules/workflows/constants';
import { Observable } from 'rxjs';
@InitModule(MODULES.WORKFLOWS)
@Controller('workflow_executions')
@ -43,6 +44,28 @@ export class WorkflowExecutionsController implements IWorkflowExecutionControlle
throw new Error('Method not implemented.');
}
@InitFeature(FEATURE_KEY.FETCH_EXECUTION_LOGS)
@Get()
async getExecutions(
@Query('appVersionId') appVersionId: string,
@Query('page') page = '1',
@Query('per_page') perPage = '10',
@User() user
): Promise<any> {
throw new Error('Method not implemented.');
}
@InitFeature(FEATURE_KEY.FETCH_EXECUTION_NODES)
@Get(':id/nodes')
async getExecutionNodes(
@Param('id') id: string,
@Query('page') page = '1',
@Query('per_page') perPage = '10',
@User() user
): Promise<any> {
throw new Error('Method not implemented.');
}
@InitFeature(FEATURE_KEY.PREVIEW_QUERY_NODE)
@Post('previewQueryNode')
async previewQueryNode(
@ -52,4 +75,21 @@ export class WorkflowExecutionsController implements IWorkflowExecutionControlle
): Promise<{ result: any }> {
throw new Error('Method not implemented.');
}
@InitFeature(FEATURE_KEY.EXECUTE_WORKFLOW)
@Post(':id/trigger')
async trigger(
@Param('id') id: string,
@Body() createWorkflowExecutionDto: CreateWorkflowExecutionDto,
@User() user,
@Res({ passthrough: true }) response: Response
): Promise<{ result: any }> {
throw new Error('Method not implemented.');
}
@InitFeature(FEATURE_KEY.WORKFLOW_EXECUTION_STATUS)
@Sse(':id/stream')
async streamWorkflowExecution(@Param('id') id: string): Promise<Observable<MessageEvent>> {
throw new Error('Method not implemented.');
}
}

View file

@ -1,4 +1,4 @@
import { Controller, Post, Param, Body, Patch, Query, Res } from '@nestjs/common';
import { Controller, Post, Param, Body, Patch, Query, Res, Get, Sse, Req } from '@nestjs/common';
import { Response } from 'express';
import { IWorkflowWebhooksController } from '../interfaces/IWorkflowWebhooksController';
import { InitModule } from '@modules/app/decorators/init-module';
@ -20,11 +20,36 @@ export class WorkflowWebhooksController implements IWorkflowWebhooksController {
@Param('id') id: any,
@Body() workflowParams,
@Query('environment') environment: string,
@Res({ passthrough: true }) response: Response
@Res({ passthrough: true }) response: Response,
@Req() req: Request
): Promise<any> {
throw new Error('Method not implemented.');
}
@InitFeature(FEATURE_KEY.WEBHOOK_TRIGGER_WORKFLOW)
@Post('workflows/:idOrName/trigger-async')
async triggerWorkflowAsync(
@Param('app') app: any,
@Param('idOrName') idOrName: string,
@Body() workflowParams: Record<string, unknown>,
@Query('environment') environment: string,
@Req() req: Request
): Promise<any> {
throw new Error('Method not implemented.');
}
@InitFeature(FEATURE_KEY.WEBHOOK_TRIGGER_WORKFLOW)
@Get('workflows/:idOrName/status/:executionId')
async getExecutionStatus(@Param('executionId') executionId: string): Promise<any> {
throw new Error('Method not implemented.');
}
@InitFeature(FEATURE_KEY.WEBHOOK_TRIGGER_WORKFLOW)
@Sse('workflows/:idOrName/execution/:executionId/stream')
async triggerWorkflowStream(@Param('executionId') executionId: string): Promise<any> {
throw new Error('Method not implemented.');
}
@InitFeature(FEATURE_KEY.UPDATE_WORKFLOW_WEBHOOK_DETAILS)
@Patch('workflows/:id')
async updateWorkflow(@Param('id') id, @Body() workflowValuesToUpdate): Promise<any> {

View file

@ -16,5 +16,9 @@ export interface IWorkflowExecutionController {
index(appVersionId: any, user: any): Promise<WorkflowExecution[]>;
getExecutions(appVersionId: string, page: any, perPage: any, user: any): Promise<any>;
getExecutionNodes(id: string, user: any, page: any, perPage: any): Promise<any>;
previewQueryNode(user: any, previewNodeDto: PreviewWorkflowNodeDto, response: Response): Promise<{ result: any }>;
}

View file

@ -1,23 +1,62 @@
import { CreateWorkflowExecutionDto } from '@dto/create-workflow-execution.dto';
import { WorkflowExecution } from 'src/entities/workflow_execution.entity';
import { AppVersion } from 'src/entities/app_version.entity';
import { User } from 'src/entities/user.entity';
import { Response } from 'express';
import { QueryResult } from '@tooljet/plugins/dist/packages/common/lib';
import { WorkflowExecutionNode } from 'src/entities/workflow_execution_node.entity';
export interface IWorkflowExecutionsService {
create(createWorkflowExecutionDto: CreateWorkflowExecutionDto): Promise<WorkflowExecution>;
execute(workflowExecution: WorkflowExecution, params: any, envId: string, response: Response): Promise<any>;
execute(
workflowExecution: WorkflowExecution,
params: Record<string, any>,
envId: string,
response: Response,
throwOnError?: boolean,
executionStartTime?: Date
): Promise<QueryResult>;
getStatus(id: string): Promise<{ logs: string[]; status: boolean; nodes: any[] }>;
getStatus(id: string): Promise<{
logs: unknown;
status: boolean;
nodes: Array<{
id: string;
idOnDefinition: string;
executed: boolean;
result: unknown;
}>;
}>;
getWorkflowExecution(id: string): Promise<WorkflowExecution>;
listWorkflowExecutions(appVersionId: string): Promise<WorkflowExecution[]>;
findOne(id: string, relations?: string[]): Promise<WorkflowExecution>;
previewQueryNode(
queryId: string,
nodeId: string,
params: any,
appVersion: any,
user: any,
response: any
state: Record<string, any>,
appVersion: AppVersion,
user: User,
response: Response
): Promise<any>;
getWorkflowExecutionsLogs(appVersionId: string, page?: number, limit?: number): Promise<{
data: WorkflowExecution[];
page: number;
per_page: number;
total: number;
total_pages: number;
}>;
getWorkflowExecutionNodes(workflowExecutionId: string, page?: number, limit?: number): Promise<{
data: WorkflowExecutionNode[];
page: number;
per_page: number;
total: number;
total_pages: number;
}>;
}

View file

@ -1,7 +1,7 @@
import { Response } from 'express';
export interface IWorkflowWebhooksController {
triggerWorkflow(id: any, workflowParams: any, environment: string, response: Response): Promise<any>;
triggerWorkflow(id: any, workflowParams: any, environment: string, response: Response, req: Request): Promise<any>;
updateWorkflow(id: any, workflowValuesToUpdate: any): Promise<any>;
}

View file

@ -0,0 +1,40 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { WorkflowExecutionsService } from '../services/workflow-executions.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Logger } from 'nestjs-pino';
import { CreateWorkflowExecutionDto } from '@dto/create-workflow-execution.dto';
import { WorkflowExecution } from '@entities/workflow_execution.entity';
import { Response } from 'express';
import { AppVersion } from '@entities/app_version.entity';
import { App } from '@entities/app.entity';
import { EntityManager } from 'typeorm';
export const WORKFLOW_EXECUTION_STATUS = {
TRIGGERED: 'workflow_execution_triggered',
RUNNING: 'workflow_execution_running',
COMPLETED: 'workflow_execution_completed',
ERROR: 'workflow_execution_error',
};
@Injectable()
export class WorkflowTriggersListener {
constructor(
protected workflowExecutionsService: WorkflowExecutionsService,
protected readonly logger: Logger,
protected readonly eventEmitter: EventEmitter2
) {}
@OnEvent('triggerWorkflow')
async handleTriggerWorkflow({
createWorkflowExecutionDto,
workflowExecution,
response,
}: {
createWorkflowExecutionDto: CreateWorkflowExecutionDto;
workflowExecution: WorkflowExecution;
response: Response;
}): Promise<void> {
throw new Error('Not implemented.');
}
}

View file

@ -44,7 +44,9 @@ export class WorkflowsModule extends SubModule {
WorkflowSchedulesService,
TemporalService,
WorkflowWebhooksListener,
WorkflowTriggersListener,
FeatureAbilityFactory,
WorkflowStreamService,
} = await this.getProviders(configs, 'workflows', [
'services/workflow-executions.service',
'controllers/workflow-executions.controller',
@ -55,7 +57,9 @@ export class WorkflowsModule extends SubModule {
'services/workflow-schedules.service',
'services/temporal.service',
'listeners/workflow-webhooks.listener',
'listeners/workflow-triggers.listener',
'ability/app',
'services/workflow-stream.service',
]);
// Get apps related providers
@ -126,6 +130,8 @@ export class WorkflowsModule extends SubModule {
PageService,
EventsService,
WorkflowExecutionsService,
WorkflowStreamService,
WorkflowTriggersListener,
WorkflowWebhooksListener,
WorkflowWebhooksService,
OrganizationConstantsService,

View file

@ -5,6 +5,8 @@ import { User } from 'src/entities/user.entity';
import { Response } from 'express';
import { IWorkflowExecutionsService } from '../interfaces/IWorkflowExecutionsService';
import { CreateWorkflowExecutionDto } from '@dto/create-workflow-execution.dto';
import { QueryResult } from '@tooljet/plugins/dist/packages/common/lib';
import { WorkflowExecutionNode } from 'src/entities/workflow_execution_node.entity';
@Injectable()
export class WorkflowExecutionsService implements IWorkflowExecutionsService {
@ -14,11 +16,27 @@ export class WorkflowExecutionsService implements IWorkflowExecutionsService {
throw new Error('Method not implemented.');
}
async execute(workflowExecution: WorkflowExecution, params: any, envId: string, response: any): Promise<any> {
async execute(
workflowExecution: WorkflowExecution,
params: Record<string, any>,
envId: string,
response: Response,
throwOnError?: boolean,
executionStartTime?: Date
): Promise<QueryResult> {
throw new Error('Method not implemented.');
}
async getStatus(workflowExecutionId: string): Promise<{ logs: string[]; status: boolean; nodes: any[] }> {
async getStatus(workflowExecutionId: string): Promise<{
logs: unknown;
status: boolean;
nodes: Array<{
id: string;
idOnDefinition: string;
executed: boolean;
result: unknown;
}>;
}> {
throw new Error('Method not implemented.');
}
@ -33,11 +51,35 @@ export class WorkflowExecutionsService implements IWorkflowExecutionsService {
async previewQueryNode(
queryId: string,
nodeId: string,
state: object,
state: Record<string, any>,
appVersion: AppVersion,
user: User,
response: Response
): Promise<any> {
throw new Error('Method not implemented.');
}
async findOne(id: string, relations?: string[]): Promise<WorkflowExecution> {
throw new Error('Method not implemented.');
}
async getWorkflowExecutionsLogs(appVersionId: string, page: number = 1, limit: number = 10): Promise<{
data: WorkflowExecution[];
page: number;
per_page: number;
total: number;
total_pages: number;
}> {
throw new Error('Method not implemented.');
}
async getWorkflowExecutionNodes(workflowExecutionId: string, page: number = 1, limit: number = 10): Promise<{
data: WorkflowExecutionNode[];
page: number;
per_page: number;
total: number;
total_pages: number;
}> {
throw new Error('Method not implemented.');
}
}

View file

@ -0,0 +1,31 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Observable } from 'rxjs';
import { OnEvent } from '@nestjs/event-emitter';
export const WORKFLOW_CONNECTION_TYPES = {
INITIALIZED: 'workflow_connection_initialized',
STREAMING: 'workflow_connection_streaming',
ERROR: 'workflow_connection_error',
CLOSE: 'workflow_connection_close',
};
// Base WorkflowStreamService class for CE
// This provides the interface but throws "Not implemented" errors for all methods
// EE version will extend this class and provide actual implementations
@Injectable()
export class WorkflowStreamService implements OnModuleInit {
constructor() {}
onModuleInit() {
// CE version - no implementation needed
}
@OnEvent('workflow.status')
handleWorkflowStatus({ executionId, status }: { executionId: string; status: any }) {
throw new Error('Method not implemented.');
}
getStream(executionId: string): Observable<MessageEvent> {
throw new Error('Method not implemented.');
}
}

View file

@ -7,6 +7,8 @@ interface Features {
[FEATURE_KEY.WORKFLOW_EXECUTION_STATUS]: FeatureConfig;
[FEATURE_KEY.WORKFLOW_EXECUTION_DETAILS]: FeatureConfig;
[FEATURE_KEY.LIST_WORKFLOW_EXECUTIONS]: FeatureConfig;
[FEATURE_KEY.FETCH_EXECUTION_LOGS]: FeatureConfig;
[FEATURE_KEY.FETCH_EXECUTION_NODES]: FeatureConfig;
[FEATURE_KEY.PREVIEW_QUERY_NODE]: FeatureConfig;
[FEATURE_KEY.CREATE_WORKFLOW_SCHEDULE]: FeatureConfig;
[FEATURE_KEY.LIST_WORKFLOW_SCHEDULES]: FeatureConfig;