mirror of
https://github.com/ToolJet/ToolJet
synced 2026-05-05 22:38:48 +00:00
Merge pull request #13179 from ToolJet/release/workflows-lts-migration
Release: Workflows LTS migration
This commit is contained in:
commit
542e370093
48 changed files with 1324 additions and 269 deletions
|
|
@ -1 +1 @@
|
|||
Subproject commit 1a14db61e117145def63bf66e9225e049a2f5818
|
||||
Subproject commit eb63fab1fdbd022ad0c7b8028c63a25293cfa4cf
|
||||
2
frontend/package-lock.json
generated
2
frontend/package-lock.json
generated
|
|
@ -35241,4 +35241,4 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
141
frontend/src/AppBuilder/_utils/async-query-handler.js
Normal file
141
frontend/src/AppBuilder/_utils/async-query-handler.js
Normal 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)
|
||||
// );
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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, 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}
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
8
frontend/src/_stores/workflowStore.js
Normal file
8
frontend/src/_stores/workflowStore.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import create from 'zustand';
|
||||
|
||||
const useWorkflowStore = create((set) => ({
|
||||
workflowId: null,
|
||||
setWorkflowId: (id) => set({ workflowId: id }),
|
||||
}));
|
||||
|
||||
export default useWorkflowStore;
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit cc864000dd03cc345e53ae9fc43821d3174f4c64
|
||||
Subproject commit 49945c6f57df4739b2898298ed703c9f1adf8173
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -68,7 +70,7 @@ export class AppModule implements OnModuleInit {
|
|||
* █ █
|
||||
* ████████████████████████████████████████████████████████████████████
|
||||
*/
|
||||
const imports = [
|
||||
const baseImports = [
|
||||
await AbilityModule.forRoot(configs),
|
||||
await LicenseModule.forRoot(configs),
|
||||
await FilesModule.register(configs),
|
||||
|
|
@ -103,7 +105,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),
|
||||
|
|
@ -117,6 +118,13 @@ export class AppModule implements OnModuleInit {
|
|||
await EmailListenerModule.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],
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
12
server/src/modules/auth/workflow-sse-auth.guard.ts
Normal file
12
server/src/modules/auth/workflow-sse-auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 : {}) },
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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]: {},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 }>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue