Compare commits
2 Commits
ab5a257283
...
03000b710b
Author | SHA1 | Date | |
---|---|---|---|
03000b710b | |||
406bcaea08 |
@ -24,6 +24,8 @@
|
|||||||
<option value="PUT">PUT</option>
|
<option value="PUT">PUT</option>
|
||||||
<option value="PATCH">PATCH</option>
|
<option value="PATCH">PATCH</option>
|
||||||
<option value="DELETE">DELETE</option>
|
<option value="DELETE">DELETE</option>
|
||||||
|
<option value="HEAD">HEAD</option>
|
||||||
|
<option value="OPTIONS">OPTIONS</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="mt-1 text-xs text-gray-500">
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
The HTTP method determines how the API call interacts with the endpoint.
|
The HTTP method determines how the API call interacts with the endpoint.
|
||||||
@ -48,112 +50,116 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 2: Headers and Body -->
|
<!-- Step 2: Authorization -->
|
||||||
<div class="mb-6 bg-gray-50 p-4 rounded-md border border-gray-200">
|
<div class="mb-6 bg-gray-50 p-4 rounded-md border border-gray-200">
|
||||||
<div class="flex items-center mb-3">
|
<div class="flex items-center mb-3">
|
||||||
<div class="w-6 h-6 rounded-full bg-indigo-100 flex items-center justify-center mr-2">
|
<div class="w-6 h-6 rounded-full bg-indigo-100 flex items-center justify-center mr-2">
|
||||||
<span class="text-xs font-semibold text-indigo-600">2</span>
|
<span class="text-xs font-semibold text-indigo-600">2</span>
|
||||||
</div>
|
</div>
|
||||||
<h4 class="font-medium">Request Configuration</h4>
|
<h4 class="font-medium">Authorization</h4>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
<!-- Headers -->
|
<label class="block text-sm font-medium text-gray-700 mb-1">Type</label>
|
||||||
<div class="mb-4">
|
<select v-if="localNodeData.authorization" v-model="localNodeData.authorization.type" class="w-48 p-2 border rounded text-sm" @change="saveChanges">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Headers</label>
|
<option v-for="type in AUTH_TYPES" :key="type.value" :value="type.value">{{ type.label }}</option>
|
||||||
<div class="bg-white p-3 border rounded-md shadow-sm">
|
</select>
|
||||||
<div class="flex gap-2 mb-2">
|
|
||||||
<VariableBrowser
|
|
||||||
:availableVariables="availableVariables"
|
|
||||||
placeholder="Insert Variable into Headers..."
|
|
||||||
:allowCreate="false"
|
|
||||||
@update:modelValue="insertVariable($event, 'headers')"
|
|
||||||
class="flex-grow"
|
|
||||||
/>
|
|
||||||
<RsButton
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
@click="formatJson('headers')"
|
|
||||||
title="Format JSON"
|
|
||||||
class="flex-shrink-0"
|
|
||||||
>
|
|
||||||
<Icon name="material-symbols:format-align-left" />
|
|
||||||
Format
|
|
||||||
</RsButton>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
id="headers"
|
|
||||||
v-model="localNodeData.headers"
|
|
||||||
class="w-full p-2 border rounded-md shadow-sm focus:border-indigo-500 focus:ring focus:ring-indigo-200 text-sm font-mono"
|
|
||||||
placeholder='{ "Authorization": "Bearer {accessToken}" }'
|
|
||||||
rows="4"
|
|
||||||
@blur="saveChanges"
|
|
||||||
></textarea>
|
|
||||||
<p class="mt-1 text-xs text-gray-500">
|
|
||||||
Set HTTP headers as a JSON object. Use variables with <code class="bg-gray-100 px-1">{variableName}</code> syntax.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Headers Preview -->
|
|
||||||
<div v-if="localNodeData.headers" class="mt-3 pt-3 border-t border-gray-200">
|
|
||||||
<div class="text-xs font-medium text-gray-700 mb-1">Preview with Current Values:</div>
|
|
||||||
<div class="bg-gray-50 border rounded p-2">
|
|
||||||
<pre class="text-xs font-mono whitespace-pre-wrap">{{ getPreviewWithValues('headers') }}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="localNodeData.authorization && localNodeData.authorization.type" v-for="field in getAuthFields()" :key="field.key" class="mb-2">
|
||||||
<!-- Request Body - show only for POST, PUT, PATCH -->
|
<label class="block text-xs font-medium text-gray-600 mb-1">{{ field.label }}</label>
|
||||||
<div v-if="showRequestBody" class="mt-4">
|
<input v-if="field.type !== 'select'" :type="field.type" v-model="localNodeData.authorization[field.key]" :placeholder="field.placeholder" class="w-full px-2 py-1 border rounded text-sm" @input="saveChanges" />
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Request Body</label>
|
<select v-else v-model="localNodeData.authorization[field.key]" class="w-full px-2 py-1 border rounded text-sm" @change="saveChanges">
|
||||||
<div class="bg-white p-3 border rounded-md shadow-sm">
|
<option v-for="opt in field.options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||||
<div class="flex gap-2 mb-2">
|
</select>
|
||||||
<VariableBrowser
|
|
||||||
:availableVariables="availableVariables"
|
|
||||||
placeholder="Insert Variable into Request Body..."
|
|
||||||
:allowCreate="false"
|
|
||||||
@update:modelValue="insertVariable($event, 'requestBody')"
|
|
||||||
class="flex-grow"
|
|
||||||
/>
|
|
||||||
<RsButton
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
@click="formatJson('requestBody')"
|
|
||||||
title="Format JSON"
|
|
||||||
class="flex-shrink-0"
|
|
||||||
>
|
|
||||||
<Icon name="material-symbols:format-align-left" />
|
|
||||||
Format
|
|
||||||
</RsButton>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
id="requestBody"
|
|
||||||
v-model="localNodeData.requestBody"
|
|
||||||
class="w-full p-2 border rounded-md shadow-sm focus:border-indigo-500 focus:ring focus:ring-indigo-200 text-sm font-mono"
|
|
||||||
placeholder='{ "key": "value", "userId": "{userId}" }'
|
|
||||||
rows="6"
|
|
||||||
@blur="saveChanges"
|
|
||||||
></textarea>
|
|
||||||
<p class="mt-1 text-xs text-gray-500">
|
|
||||||
Request body to send with the API call. Use variables with <code class="bg-gray-100 px-1">{variableName}</code> syntax.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Request Body Preview -->
|
|
||||||
<div v-if="localNodeData.requestBody" class="mt-3 pt-3 border-t border-gray-200">
|
|
||||||
<div class="text-xs font-medium text-gray-700 mb-1">Preview with Current Values:</div>
|
|
||||||
<div class="bg-gray-50 border rounded p-2">
|
|
||||||
<pre class="text-xs font-mono whitespace-pre-wrap">{{ getPreviewWithValues('requestBody') }}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 3: Response Handling -->
|
<!-- Step 3: Params (GET, DELETE, etc.) -->
|
||||||
<div class="mb-6 bg-gray-50 p-4 rounded-md border border-gray-200">
|
<div v-if="showParams" class="mb-6 bg-gray-50 p-4 rounded-md border border-gray-200">
|
||||||
<div class="flex items-center mb-3">
|
<div class="flex items-center mb-3">
|
||||||
<div class="w-6 h-6 rounded-full bg-indigo-100 flex items-center justify-center mr-2">
|
<div class="w-6 h-6 rounded-full bg-indigo-100 flex items-center justify-center mr-2">
|
||||||
<span class="text-xs font-semibold text-indigo-600">3</span>
|
<span class="text-xs font-semibold text-indigo-600">3</span>
|
||||||
</div>
|
</div>
|
||||||
|
<h4 class="font-medium">Query Parameters</h4>
|
||||||
|
</div>
|
||||||
|
<KeyValueTable
|
||||||
|
v-model="localNodeData.params"
|
||||||
|
:variables="processVariables"
|
||||||
|
:allow-variable-insert="true"
|
||||||
|
placeholder-key="Param Key"
|
||||||
|
placeholder-value="Param Value"
|
||||||
|
@update:modelValue="saveChanges"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 4: Headers -->
|
||||||
|
<div class="mb-6 bg-gray-50 p-4 rounded-md border border-gray-200">
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
|
<div class="w-6 h-6 rounded-full bg-indigo-100 flex items-center justify-center mr-2">
|
||||||
|
<span class="text-xs font-semibold text-indigo-600">4</span>
|
||||||
|
</div>
|
||||||
|
<h4 class="font-medium">Headers</h4>
|
||||||
|
</div>
|
||||||
|
<KeyValueTable
|
||||||
|
v-model="localNodeData.headers"
|
||||||
|
:variables="processVariables"
|
||||||
|
:allow-variable-insert="true"
|
||||||
|
placeholder-key="Header Name"
|
||||||
|
placeholder-value="Header Value"
|
||||||
|
@update:modelValue="saveChanges"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 5: Body (POST/PUT/PATCH) -->
|
||||||
|
<div v-if="showRequestBody && localNodeData.body && bodyType" class="mb-6 bg-gray-50 p-4 rounded-md border border-gray-200">
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
|
<div class="w-6 h-6 rounded-full bg-indigo-100 flex items-center justify-center mr-2">
|
||||||
|
<span class="text-xs font-semibold text-indigo-600">5</span>
|
||||||
|
</div>
|
||||||
|
<h4 class="font-medium">Request Body</h4>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Body Type</label>
|
||||||
|
<select v-model="localNodeData.body.type" class="w-64 p-2 border rounded text-sm" @change="saveChanges">
|
||||||
|
<option v-for="type in BODY_TYPES" :key="type.value" :value="type.value">{{ type.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div v-if="['form-data', 'x-www-form-urlencoded'].includes(bodyType)">
|
||||||
|
<KeyValueTable
|
||||||
|
:model-value="getBodyData()"
|
||||||
|
:variables="processVariables"
|
||||||
|
:allow-variable-insert="true"
|
||||||
|
placeholder-key="Field Name"
|
||||||
|
placeholder-value="Field Value"
|
||||||
|
@update:modelValue="setBodyData"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="bodyType === 'raw'">
|
||||||
|
<textarea v-model="localNodeData.body.data" rows="6" class="w-full p-2 border rounded font-mono text-sm" placeholder="Raw body (JSON, XML, etc.)" @input="saveChanges"></textarea>
|
||||||
|
<VariableBrowser
|
||||||
|
:available-variables="processVariables"
|
||||||
|
:allow-create="false"
|
||||||
|
:placeholder="'Insert variable...'"
|
||||||
|
@update:modelValue="insertVariableToRaw"
|
||||||
|
class="w-48 mt-2"
|
||||||
|
/>
|
||||||
|
<div v-if="rawBodyJsonWarning && rawBodyHasVariable" class="mt-2 text-xs text-red-600 flex items-center gap-1">
|
||||||
|
<Icon name="material-symbols:warning" class="w-4 h-4" />
|
||||||
|
{{ rawBodyJsonWarning }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="bodyType === 'binary'">
|
||||||
|
<input type="file" @change="handleFileUpload" />
|
||||||
|
<div v-if="localNodeData.body.data" class="mt-2 text-xs text-gray-600">Selected: {{ localNodeData.body.data }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-xs text-gray-500">No body will be sent.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 6: Response Handling -->
|
||||||
|
<div class="mb-6 bg-gray-50 p-4 rounded-md border border-gray-200">
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
|
<div class="w-6 h-6 rounded-full bg-indigo-100 flex items-center justify-center mr-2">
|
||||||
|
<span class="text-xs font-semibold text-indigo-600">6</span>
|
||||||
|
</div>
|
||||||
<h4 class="font-medium">Response Handling</h4>
|
<h4 class="font-medium">Response Handling</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -208,11 +214,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 4: Test API Call -->
|
<!-- Step 7: Test API Call -->
|
||||||
<div class="mb-6 bg-gray-50 p-4 rounded-md border border-gray-200">
|
<div class="mb-6 bg-gray-50 p-4 rounded-md border border-gray-200">
|
||||||
<div class="flex items-center mb-3">
|
<div class="flex items-center mb-3">
|
||||||
<div class="w-6 h-6 rounded-full bg-indigo-100 flex items-center justify-center mr-2">
|
<div class="w-6 h-6 rounded-full bg-indigo-100 flex items-center justify-center mr-2">
|
||||||
<span class="text-xs font-semibold text-indigo-600">4</span>
|
<span class="text-xs font-semibold text-indigo-600">7</span>
|
||||||
</div>
|
</div>
|
||||||
<h4 class="font-medium">Test API Call</h4>
|
<h4 class="font-medium">Test API Call</h4>
|
||||||
</div>
|
</div>
|
||||||
@ -276,6 +282,22 @@
|
|||||||
import { ref, computed, watch, nextTick } from 'vue';
|
import { ref, computed, watch, nextTick } from 'vue';
|
||||||
import { useProcessBuilderStore } from '@/stores/processBuilder';
|
import { useProcessBuilderStore } from '@/stores/processBuilder';
|
||||||
import VariableBrowser from './VariableBrowser.vue';
|
import VariableBrowser from './VariableBrowser.vue';
|
||||||
|
import KeyValueTable from './KeyValueTable.vue';
|
||||||
|
|
||||||
|
const AUTH_TYPES = [
|
||||||
|
{ value: 'none', label: 'None' },
|
||||||
|
{ value: 'bearer', label: 'Bearer Token' },
|
||||||
|
{ value: 'basic', label: 'Basic Auth' },
|
||||||
|
{ value: 'apiKey', label: 'API Key' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const BODY_TYPES = [
|
||||||
|
{ value: 'none', label: 'None' },
|
||||||
|
{ value: 'form-data', label: 'form-data' },
|
||||||
|
{ value: 'x-www-form-urlencoded', label: 'x-www-form-urlencoded' },
|
||||||
|
{ value: 'raw', label: 'raw' },
|
||||||
|
{ value: 'binary', label: 'binary' }
|
||||||
|
];
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
nodeData: {
|
nodeData: {
|
||||||
@ -294,17 +316,7 @@ const emit = defineEmits(['update']);
|
|||||||
const processStore = useProcessBuilderStore();
|
const processStore = useProcessBuilderStore();
|
||||||
|
|
||||||
// Local state for node data - create a deep copy to avoid mutation issues
|
// Local state for node data - create a deep copy to avoid mutation issues
|
||||||
const localNodeData = ref({
|
const localNodeData = ref(migrateNodeData(props.nodeData));
|
||||||
label: 'API Call',
|
|
||||||
description: '',
|
|
||||||
apiMethod: 'GET',
|
|
||||||
apiUrl: '',
|
|
||||||
requestBody: '',
|
|
||||||
headers: '{ "Content-Type": "application/json" }',
|
|
||||||
outputVariable: 'apiResponse',
|
|
||||||
continueOnError: false,
|
|
||||||
errorVariable: 'apiError'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Computed for showing request body based on method
|
// Computed for showing request body based on method
|
||||||
const showRequestBody = computed(() => {
|
const showRequestBody = computed(() => {
|
||||||
@ -324,22 +336,16 @@ const availableVariables = computed(() => {
|
|||||||
return globalVars;
|
return globalVars;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get process variables for substitution
|
||||||
|
const processVariables = computed(() => {
|
||||||
|
// Use processStore.currentProcess.variables (object) and convert to array
|
||||||
|
const varsObj = processStore.currentProcess?.variables || {};
|
||||||
|
return Object.values(varsObj);
|
||||||
|
});
|
||||||
|
|
||||||
// Watch for changes from parent props
|
// Watch for changes from parent props
|
||||||
watch(() => props.nodeData, (newNodeData) => {
|
watch(() => props.nodeData, (newNodeData) => {
|
||||||
if (newNodeData) {
|
if (newNodeData) localNodeData.value = migrateNodeData(newNodeData);
|
||||||
// Create a deep copy to break reactivity chains with parent
|
|
||||||
localNodeData.value = {
|
|
||||||
label: newNodeData.label || 'API Call',
|
|
||||||
description: newNodeData.description || '',
|
|
||||||
apiMethod: newNodeData.apiMethod || 'GET',
|
|
||||||
apiUrl: newNodeData.apiUrl || '',
|
|
||||||
requestBody: newNodeData.requestBody || '',
|
|
||||||
headers: newNodeData.headers || '{ "Content-Type": "application/json" }',
|
|
||||||
outputVariable: newNodeData.outputVariable || 'apiResponse',
|
|
||||||
continueOnError: newNodeData.continueOnError || false,
|
|
||||||
errorVariable: newNodeData.errorVariable || 'apiError'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, { immediate: true, deep: true });
|
}, { immediate: true, deep: true });
|
||||||
|
|
||||||
// Function to create a new global variable
|
// Function to create a new global variable
|
||||||
@ -559,6 +565,178 @@ function getPreviewWithValues(field) {
|
|||||||
return localNodeData.value[field] || '';
|
return localNodeData.value[field] || '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Authorization Fields ---
|
||||||
|
function getAuthFields() {
|
||||||
|
switch (localNodeData.value.authorization.type) {
|
||||||
|
case 'bearer':
|
||||||
|
return [
|
||||||
|
{ key: 'token', label: 'Token', type: 'text', placeholder: 'Bearer token', value: localNodeData.value.authorization.token || '' }
|
||||||
|
];
|
||||||
|
case 'basic':
|
||||||
|
return [
|
||||||
|
{ key: 'username', label: 'Username', type: 'text', placeholder: 'Username', value: localNodeData.value.authorization.username || '' },
|
||||||
|
{ key: 'password', label: 'Password', type: 'password', placeholder: 'Password', value: localNodeData.value.authorization.password || '' }
|
||||||
|
];
|
||||||
|
case 'apiKey':
|
||||||
|
return [
|
||||||
|
{ key: 'key', label: 'Key Name', type: 'text', placeholder: 'e.g. X-API-Key', value: localNodeData.value.authorization.key || '' },
|
||||||
|
{ key: 'value', label: 'Key Value', type: 'text', placeholder: 'API Key Value', value: localNodeData.value.authorization.value || '' },
|
||||||
|
{ key: 'in', label: 'Add To', type: 'select', options: [
|
||||||
|
{ value: 'header', label: 'Header' },
|
||||||
|
{ value: 'query', label: 'Query Param' }
|
||||||
|
], value: localNodeData.value.authorization.in || 'header' }
|
||||||
|
];
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function updateAuthField(field, value) {
|
||||||
|
localNodeData.value.authorization[field] = value;
|
||||||
|
saveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Body Data Handling ---
|
||||||
|
function getBodyData() {
|
||||||
|
if (['form-data', 'x-www-form-urlencoded'].includes(localNodeData.value.body.type)) {
|
||||||
|
return Array.isArray(localNodeData.value.body.data) ? localNodeData.value.body.data : [];
|
||||||
|
}
|
||||||
|
if (localNodeData.value.body.type === 'raw') {
|
||||||
|
return typeof localNodeData.value.body.data === 'string' ? localNodeData.value.body.data : '';
|
||||||
|
}
|
||||||
|
if (localNodeData.value.body.type === 'binary') {
|
||||||
|
return localNodeData.value.body.data || null;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
function setBodyData(val) {
|
||||||
|
if (['form-data', 'x-www-form-urlencoded'].includes(localNodeData.value.body.type)) {
|
||||||
|
localNodeData.value.body.data = val;
|
||||||
|
} else if (localNodeData.value.body.type === 'raw') {
|
||||||
|
localNodeData.value.body.data = val;
|
||||||
|
} else if (localNodeData.value.body.type === 'binary') {
|
||||||
|
localNodeData.value.body.data = val;
|
||||||
|
}
|
||||||
|
saveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- File Upload for Binary ---
|
||||||
|
function handleFileUpload(e) {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
localNodeData.value.body.data = file.name;
|
||||||
|
saveChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- JSON Validation for Raw Body ---
|
||||||
|
const rawBodyIsJson = computed(() => {
|
||||||
|
if (bodyType.value !== 'raw') return false;
|
||||||
|
const val = localNodeData.value.body?.data || '';
|
||||||
|
// Only check if it looks like JSON
|
||||||
|
if (!val.trim().startsWith('{') && !val.trim().startsWith('[')) return false;
|
||||||
|
try {
|
||||||
|
// Replace {variable} with a string to allow parsing
|
||||||
|
const replaced = val.replace(/\{[a-zA-Z0-9_]+\}/g, '"VAR"');
|
||||||
|
JSON.parse(replaced);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const rawBodyHasVariable = computed(() => {
|
||||||
|
if (bodyType.value !== 'raw') return false;
|
||||||
|
const val = localNodeData.value.body?.data || '';
|
||||||
|
return /\{[a-zA-Z0-9_]+\}/.test(val);
|
||||||
|
});
|
||||||
|
const rawBodyJsonWarning = computed(() => {
|
||||||
|
if (bodyType.value !== 'raw') return '';
|
||||||
|
const val = localNodeData.value.body?.data || '';
|
||||||
|
if (!val.trim().startsWith('{') && !val.trim().startsWith('[')) return '';
|
||||||
|
try {
|
||||||
|
// Replace both quoted and unquoted {variable} with "VAR"
|
||||||
|
let replaced = val
|
||||||
|
.replace(/"\{[a-zA-Z0-9_]+\}"/g, '"VAR"') // quoted
|
||||||
|
.replace(/\{[a-zA-Z0-9_]+\}/g, '"VAR"'); // unquoted
|
||||||
|
JSON.parse(replaced);
|
||||||
|
return '';
|
||||||
|
} catch {
|
||||||
|
return 'Warning: Your request body is not valid JSON. Make sure variables are inside quotes.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Variable Insertion Helper ---
|
||||||
|
function insertVariableToRaw(varName) {
|
||||||
|
if (!varName) return;
|
||||||
|
const textarea = document.querySelector('textarea[placeholder="Raw body (JSON, XML, etc.)"]');
|
||||||
|
let val = localNodeData.value.body.data || '';
|
||||||
|
let insertText = `{${varName}}`;
|
||||||
|
// Detect if inside quotes
|
||||||
|
let cursor = textarea ? textarea.selectionStart : val.length;
|
||||||
|
let before = val.substring(0, cursor);
|
||||||
|
let after = val.substring(cursor);
|
||||||
|
// Check if body looks like JSON
|
||||||
|
const isJson = val.trim().startsWith('{') || val.trim().startsWith('[');
|
||||||
|
// Check if cursor is inside quotes
|
||||||
|
let insideQuotes = false;
|
||||||
|
if (isJson && textarea) {
|
||||||
|
// Count quotes before cursor
|
||||||
|
const quotesBefore = before.split('"').length - 1;
|
||||||
|
insideQuotes = quotesBefore % 2 === 1;
|
||||||
|
}
|
||||||
|
if (isJson && !insideQuotes) {
|
||||||
|
insertText = `"{${varName}}"`;
|
||||||
|
}
|
||||||
|
// Insert at cursor
|
||||||
|
localNodeData.value.body.data = before + insertText + after;
|
||||||
|
saveChanges();
|
||||||
|
// Move cursor after inserted variable
|
||||||
|
nextTick(() => {
|
||||||
|
if (textarea) {
|
||||||
|
const newPos = before.length + insertText.length;
|
||||||
|
textarea.focus();
|
||||||
|
textarea.selectionStart = textarea.selectionEnd = newPos;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- UI State ---
|
||||||
|
const showParams = computed(() => ['GET', 'DELETE', 'HEAD', 'OPTIONS'].includes(localNodeData.value.apiMethod));
|
||||||
|
|
||||||
|
// Defensive computed for bodyType and authType
|
||||||
|
const bodyType = computed(() => localNodeData.value.body && localNodeData.value.body.type ? localNodeData.value.body.type : 'none');
|
||||||
|
const authType = computed(() => localNodeData.value.authorization && localNodeData.value.authorization.type ? localNodeData.value.authorization.type : 'none');
|
||||||
|
|
||||||
|
function migrateNodeData(data) {
|
||||||
|
const migrated = { ...data };
|
||||||
|
// Headers
|
||||||
|
if (typeof migrated.headers === 'string') {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(migrated.headers);
|
||||||
|
migrated.headers = Object.entries(obj).map(([key, value]) => ({ key, value }));
|
||||||
|
} catch {
|
||||||
|
migrated.headers = [];
|
||||||
|
}
|
||||||
|
} else if (!Array.isArray(migrated.headers)) {
|
||||||
|
migrated.headers = [];
|
||||||
|
}
|
||||||
|
// Params
|
||||||
|
if (!Array.isArray(migrated.params)) migrated.params = [];
|
||||||
|
// Body
|
||||||
|
if (!migrated.body || typeof migrated.body !== 'object') {
|
||||||
|
migrated.body = { type: 'none', data: [] };
|
||||||
|
} else {
|
||||||
|
if (!migrated.body.type) migrated.body.type = 'none';
|
||||||
|
if (migrated.body.data === undefined) migrated.body.data = migrated.body.type === 'raw' ? '' : [];
|
||||||
|
}
|
||||||
|
if (typeof data.requestBody === 'string' && (!migrated.body || migrated.body.type === 'none')) {
|
||||||
|
migrated.body = { type: 'raw', data: data.requestBody };
|
||||||
|
}
|
||||||
|
// Auth
|
||||||
|
if (!migrated.authorization || typeof migrated.authorization !== 'object') migrated.authorization = { type: 'none' };
|
||||||
|
if (!migrated.authorization.type) migrated.authorization.type = 'none';
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
68
components/process-flow/KeyValueTable.vue
Normal file
68
components/process-flow/KeyValueTable.vue
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-for="(row, idx) in rows" :key="idx" class="flex items-center gap-2 mb-2">
|
||||||
|
<input
|
||||||
|
v-model="row.key"
|
||||||
|
:placeholder="placeholderKey"
|
||||||
|
class="w-32 px-2 py-1 border rounded text-sm"
|
||||||
|
@input="emitChange"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="row.value"
|
||||||
|
:placeholder="placeholderValue"
|
||||||
|
class="flex-1 px-2 py-1 border rounded text-sm"
|
||||||
|
@input="emitChange"
|
||||||
|
:ref="el => valueInputs[idx] = el"
|
||||||
|
/>
|
||||||
|
<VariableBrowser
|
||||||
|
v-if="allowVariableInsert && variables && variables.length"
|
||||||
|
:available-variables="variables"
|
||||||
|
:placeholder="'Insert variable...'"
|
||||||
|
:allow-create="false"
|
||||||
|
@update:modelValue="varName => insertVariable(idx, varName)"
|
||||||
|
class="w-32"
|
||||||
|
/>
|
||||||
|
<button @click="removeRow(idx)" class="px-2 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200">✕</button>
|
||||||
|
</div>
|
||||||
|
<button @click="addRow" class="mt-1 px-3 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200">+ Add</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, nextTick } from 'vue';
|
||||||
|
import VariableBrowser from './VariableBrowser.vue';
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Array, default: () => [] },
|
||||||
|
placeholderKey: { type: String, default: 'Key' },
|
||||||
|
placeholderValue: { type: String, default: 'Value' },
|
||||||
|
allowVariableInsert: { type: Boolean, default: false },
|
||||||
|
variables: { type: Array, default: () => [] }
|
||||||
|
});
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
const rows = ref(props.modelValue.map(r => ({ ...r })));
|
||||||
|
const valueInputs = ref([]);
|
||||||
|
watch(() => props.modelValue, v => { rows.value = v.map(r => ({ ...r })); });
|
||||||
|
function emitChange() { emit('update:modelValue', rows.value.map(r => ({ ...r }))); }
|
||||||
|
function addRow() { rows.value.push({ key: '', value: '' }); emitChange(); }
|
||||||
|
function removeRow(idx) { rows.value.splice(idx, 1); emitChange(); }
|
||||||
|
function insertVariable(idx, varName) {
|
||||||
|
if (!varName) return;
|
||||||
|
const input = valueInputs.value[idx];
|
||||||
|
if (input && input.selectionStart !== undefined) {
|
||||||
|
const start = input.selectionStart;
|
||||||
|
const end = input.selectionEnd;
|
||||||
|
const before = rows.value[idx].value.substring(0, start);
|
||||||
|
const after = rows.value[idx].value.substring(end);
|
||||||
|
rows.value[idx].value = before + `{${varName}}` + after;
|
||||||
|
emitChange();
|
||||||
|
// Move cursor after inserted variable
|
||||||
|
nextTick(() => {
|
||||||
|
input.focus();
|
||||||
|
input.selectionStart = input.selectionEnd = start + varName.length + 2;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
rows.value[idx].value += `{${varName}}`;
|
||||||
|
emitChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
@ -54,6 +54,28 @@
|
|||||||
"animated": true,
|
"animated": true,
|
||||||
"sourceHandle": "html-1752550500000-right",
|
"sourceHandle": "html-1752550500000-right",
|
||||||
"targetHandle": "end-1752546716111-left"
|
"targetHandle": "end-1752546716111-left"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "html-1752550500000-notification-1752621850786-1752621852927",
|
||||||
|
"data": {},
|
||||||
|
"type": "custom",
|
||||||
|
"label": "",
|
||||||
|
"source": "html-1752550500000",
|
||||||
|
"target": "notification-1752621850786",
|
||||||
|
"animated": true,
|
||||||
|
"sourceHandle": "html-1752550500000-bottom",
|
||||||
|
"targetHandle": "notification-1752621850786-left"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "notification-1752621850786-end-1752546716111-1752621856004",
|
||||||
|
"data": {},
|
||||||
|
"type": "custom",
|
||||||
|
"label": "",
|
||||||
|
"source": "notification-1752621850786",
|
||||||
|
"target": "end-1752546716111",
|
||||||
|
"animated": true,
|
||||||
|
"sourceHandle": "notification-1752621850786-right",
|
||||||
|
"targetHandle": "end-1752546716111-left"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"nodes": [
|
"nodes": [
|
||||||
@ -99,14 +121,21 @@
|
|||||||
{
|
{
|
||||||
"id": "api-1752550319410",
|
"id": "api-1752550319410",
|
||||||
"data": {
|
"data": {
|
||||||
|
"body": { "data": "{ \"title\" : \"{todoTitle}\"}", "type": "raw" },
|
||||||
"label": "API Call",
|
"label": "API Call",
|
||||||
"apiUrl": "https://jsonplaceholder.typicode.com/todos/1",
|
"shape": "rectangle",
|
||||||
"headers": "{ \"Content-Type\": \"application/json\" }",
|
"apiUrl": "https://jsonplaceholder.typicode.com/posts",
|
||||||
"apiMethod": "GET",
|
"params": [],
|
||||||
|
"headers": [{ "key": "Content-Type", "value": "application/json" }],
|
||||||
|
"apiMethod": "POST",
|
||||||
|
"textColor": "#1e40af",
|
||||||
|
"borderColor": "#3b82f6",
|
||||||
"description": "External API call",
|
"description": "External API call",
|
||||||
"requestBody": "",
|
"requestBody": "",
|
||||||
|
"authorization": { "type": "none" },
|
||||||
"errorVariable": "apiError",
|
"errorVariable": "apiError",
|
||||||
"outputVariable": "apiResponse",
|
"outputVariable": "apiResponse",
|
||||||
|
"backgroundColor": "#eff6ff",
|
||||||
"continueOnError": false
|
"continueOnError": false
|
||||||
},
|
},
|
||||||
"type": "api",
|
"type": "api",
|
||||||
@ -131,7 +160,7 @@
|
|||||||
},
|
},
|
||||||
"type": "script",
|
"type": "script",
|
||||||
"label": "Script Task",
|
"label": "Script Task",
|
||||||
"position": { "x": 1200, "y": 330 }
|
"position": { "x": 1185, "y": 330 }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "html-1752550500000",
|
"id": "html-1752550500000",
|
||||||
@ -148,12 +177,37 @@
|
|||||||
},
|
},
|
||||||
"type": "html",
|
"type": "html",
|
||||||
"label": "Show Result",
|
"label": "Show Result",
|
||||||
"position": { "x": 1560, "y": 105 }
|
"position": { "x": 1425, "y": 75 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "notification-1752621850786",
|
||||||
|
"data": {
|
||||||
|
"label": "Notification",
|
||||||
|
"message": "This is the notification",
|
||||||
|
"subject": "This is the notification",
|
||||||
|
"priority": "medium",
|
||||||
|
"expiration": { "unit": "hours", "value": 24, "enabled": false },
|
||||||
|
"description": "Send notification to users",
|
||||||
|
"htmlMessage": "",
|
||||||
|
"messageFormat": "text",
|
||||||
|
"recipientRole": "",
|
||||||
|
"recipientType": "email",
|
||||||
|
"recipientUser": "",
|
||||||
|
"recipientEmail": "mdafiqiskandar@gmail.com",
|
||||||
|
"recipientGroup": "",
|
||||||
|
"deliveryOptions": { "sms": false, "email": false, "inApp": true },
|
||||||
|
"richTextMessage": "",
|
||||||
|
"notificationType": "info",
|
||||||
|
"recipientVariable": ""
|
||||||
|
},
|
||||||
|
"type": "notification",
|
||||||
|
"label": "Notification",
|
||||||
|
"position": { "x": 1590, "y": 360 }
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"viewport": {
|
"viewport": {
|
||||||
"x": -660.4278622401489,
|
"x": -118.4524312896406,
|
||||||
"y": 202.3364877443377,
|
"y": 314.4180761099366,
|
||||||
"zoom": 0.8153893887682282
|
"zoom": 0.6437632135306554
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
288
docs/process-variables-system.md
Normal file
288
docs/process-variables-system.md
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
# Process Variables System Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Process variables serve as the **central nervous system** for data flow and state management in the BPM system. They enable dynamic behavior, external system integration, and stateful execution across multiple user interactions and system boundaries.
|
||||||
|
|
||||||
|
## Architecture Components
|
||||||
|
|
||||||
|
### 1. VariableManager.vue - Central Variable Management
|
||||||
|
|
||||||
|
**Location**: `components/process-flow/VariableManager.vue`
|
||||||
|
|
||||||
|
**Primary Functions:**
|
||||||
|
- **Variable Creation & Management**: Add, edit, delete process variables with comprehensive type support
|
||||||
|
- **Usage Tracking**: Analyzes where variables are used across all node types (form, API, script, gateway, etc.)
|
||||||
|
- **Variable Navigation**: Click-to-navigate from variable usage to specific nodes in the process
|
||||||
|
- **Type Organization**: Groups variables by data type (string, int, decimal, boolean, date, object, etc.)
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
- **14+ Variable Types**: string, int, decimal, boolean, date, datetime, object, array, currency, email, UUID, etc.
|
||||||
|
- **Deep Usage Analysis**: Scans all node data for variable references in multiple formats:
|
||||||
|
- Template placeholders: `{variableName}`, `{{variableName}}`
|
||||||
|
- JavaScript references: `${variableName}`, `processVariables.variableName`
|
||||||
|
- Direct value assignments and API outputs
|
||||||
|
- **Cross-Node Detection**: Finds variables in API URLs, form mappings, script code, business rules, notifications
|
||||||
|
|
||||||
|
### 2. VariableBrowser.vue - Variable Selection Component
|
||||||
|
|
||||||
|
**Location**: `components/process-flow/VariableBrowser.vue`
|
||||||
|
|
||||||
|
**Purpose**: Used throughout the process builder for variable selection in node configurations
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
- **Grouped Selection**: Variables organized by type with optgroups
|
||||||
|
- **Live Preview**: Shows current variable values in selection dropdown
|
||||||
|
- **Inline Creation**: "+" button to create new variables on-the-fly
|
||||||
|
- **Type Filtering**: Can restrict to specific variable types
|
||||||
|
- **Validation**: Checks for variable existence and naming conflicts
|
||||||
|
- **Rich Type Support**: 20+ variable types including special types (currency, email, UUID, etc.)
|
||||||
|
|
||||||
|
**Integration Points:**
|
||||||
|
- Form node configuration (input/output mappings)
|
||||||
|
- API node configuration (URL parameters, request body)
|
||||||
|
- Script node configuration (input/output variables)
|
||||||
|
- Business rule conditions and actions
|
||||||
|
- Gateway decision logic
|
||||||
|
- Notification recipient and content variables
|
||||||
|
|
||||||
|
### 3. variableStore.js - Legacy Store
|
||||||
|
|
||||||
|
**Location**: `stores/variableStore.js`
|
||||||
|
|
||||||
|
**Note**: This appears to be a legacy store with basic functionality. The actual variable management is handled primarily by the `processBuilder` store.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- **Scope Management**: Global vs process-level variables
|
||||||
|
- **Basic CRUD**: Add, update, delete variables
|
||||||
|
- **Simple Structure**: Arrays for global and process variables
|
||||||
|
- **Node Variables**: Input/output tracking for individual nodes
|
||||||
|
|
||||||
|
## Variable Lifecycle & Data Flow
|
||||||
|
|
||||||
|
### 1. Variable Creation
|
||||||
|
|
||||||
|
- **Primary Source**: VariableManager component for explicit creation
|
||||||
|
- **Secondary Source**: VariableBrowser inline creation during node configuration
|
||||||
|
- **Auto-Creation**: Form nodes can auto-create variables from mappings (`createVariableFromMapping`)
|
||||||
|
|
||||||
|
### 2. Variable Usage Across Node Types
|
||||||
|
|
||||||
|
#### Form Nodes (`FormNodeConfiguration.vue`)
|
||||||
|
- **Input Mappings**: Process variables → Form field pre-filling
|
||||||
|
- **Output Mappings**: Form submission data → Process variables
|
||||||
|
- **Field Conditions**: Process variables control field visibility/behavior
|
||||||
|
- **Assignment Variables**: Dynamic task assignment based on variable values
|
||||||
|
|
||||||
|
**Example Configuration:**
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
inputMappings: [
|
||||||
|
{
|
||||||
|
processVariable: "customerName",
|
||||||
|
formField: "name"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
outputMappings: [
|
||||||
|
{
|
||||||
|
formField: "email",
|
||||||
|
processVariable: "customerEmail"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
fieldConditions: [
|
||||||
|
{
|
||||||
|
processVariable: "userType",
|
||||||
|
operator: "equals",
|
||||||
|
value: "premium",
|
||||||
|
targetField: "specialOffers",
|
||||||
|
action: "show"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### API Nodes
|
||||||
|
- **URL Interpolation**: Variables embedded in API endpoints
|
||||||
|
- **Request Body**: Variables in JSON payloads
|
||||||
|
- **Headers**: Dynamic authentication tokens/values
|
||||||
|
- **Output Variables**: API responses stored as process variables
|
||||||
|
- **Error Handling**: Error data captured in designated variables
|
||||||
|
|
||||||
|
**Example Configuration:**
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
apiUrl: "https://api.example.com/users/{userId}",
|
||||||
|
requestBody: "{\"name\": \"{customerName}\", \"email\": \"{customerEmail}\"}",
|
||||||
|
headers: "{\"Authorization\": \"Bearer {authToken}\"}",
|
||||||
|
outputVariable: "apiResponse",
|
||||||
|
errorVariable: "apiError"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Script Nodes
|
||||||
|
- **Input Variables**: Pass process variables to script execution
|
||||||
|
- **Output Variables**: Script results stored back to process variables
|
||||||
|
- **Script Code**: Direct variable references in JavaScript code
|
||||||
|
|
||||||
|
**Example Configuration:**
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
inputVariables: ["customerAge", "orderAmount"],
|
||||||
|
outputVariables: [
|
||||||
|
{
|
||||||
|
name: "discount",
|
||||||
|
type: "decimal",
|
||||||
|
description: "Calculated discount amount"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
scriptCode: `
|
||||||
|
const discount = customerAge > 65 ? orderAmount * 0.1 : 0;
|
||||||
|
return { discount };
|
||||||
|
`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Business Rule Nodes
|
||||||
|
- **Conditions**: Variables used in rule evaluation logic
|
||||||
|
- **Actions**: Variables updated based on rule outcomes
|
||||||
|
- **Complex Logic**: AND/OR conditions with multiple variable checks
|
||||||
|
|
||||||
|
#### Gateway/Decision Nodes
|
||||||
|
- **Decision Logic**: Variables determine process flow paths
|
||||||
|
- **Condition Evaluation**: Dynamic routing based on variable values
|
||||||
|
|
||||||
|
### 3. Variable Reference Patterns
|
||||||
|
|
||||||
|
The system supports multiple variable reference formats:
|
||||||
|
|
||||||
|
- **Template**: `{variableName}`, `{{variableName}}`
|
||||||
|
- **JavaScript**: `${variableName}`, `processVariables.variableName`
|
||||||
|
- **JSON**: Direct string/object references
|
||||||
|
- **API Integration**: URL parameters, headers, body content
|
||||||
|
|
||||||
|
### 4. Variable Tracking & Dependencies
|
||||||
|
|
||||||
|
- **Usage Analysis**: Real-time tracking of where variables are used
|
||||||
|
- **Dependency Management**: Prevents deletion of variables in use
|
||||||
|
- **Navigation Support**: Click-to-navigate from variable to usage location
|
||||||
|
- **Reference Counting**: Shows total references per variable
|
||||||
|
|
||||||
|
### 5. Process Execution Context
|
||||||
|
|
||||||
|
Variables serve as the **data backbone** of process execution:
|
||||||
|
|
||||||
|
- **State Management**: Track process state between nodes
|
||||||
|
- **Data Transformation**: Convert and format data as it flows
|
||||||
|
- **Decision Making**: Enable dynamic process paths
|
||||||
|
- **Integration**: Bridge between external systems and process logic
|
||||||
|
- **User Interaction**: Store and recall user input across process steps
|
||||||
|
|
||||||
|
## Variable Types
|
||||||
|
|
||||||
|
### Basic Types
|
||||||
|
- **string**: Text values
|
||||||
|
- **int**: Whole numbers
|
||||||
|
- **decimal**: Decimal numbers
|
||||||
|
- **boolean**: True/False values
|
||||||
|
- **date**: Date values only
|
||||||
|
- **datetime**: Date and time values
|
||||||
|
|
||||||
|
### Complex Types
|
||||||
|
- **object**: JSON objects and complex data structures
|
||||||
|
- **array**: Lists of values
|
||||||
|
- **map**: Key-value pairs
|
||||||
|
|
||||||
|
### Special Types
|
||||||
|
- **currency**: Monetary values
|
||||||
|
- **percentage**: Percentage values
|
||||||
|
- **email**: Email addresses
|
||||||
|
- **url**: URLs
|
||||||
|
- **phone**: Phone numbers
|
||||||
|
- **uuid**: Unique identifiers
|
||||||
|
- **file**: File references
|
||||||
|
- **image**: Image references
|
||||||
|
- **binary**: Binary data
|
||||||
|
|
||||||
|
## Form Integration
|
||||||
|
|
||||||
|
### Form Node Configuration Steps
|
||||||
|
|
||||||
|
The form node configuration provides a comprehensive 4-step process:
|
||||||
|
|
||||||
|
#### Step 1: Form Selection
|
||||||
|
- Select existing forms or create new ones
|
||||||
|
- Integration with FormSelector component
|
||||||
|
|
||||||
|
#### Step 2: Form Data Mapping
|
||||||
|
- **Input Mappings**: Process variables → Form field pre-filling
|
||||||
|
- **Output Mappings**: Form submission data → Process variables
|
||||||
|
- Bidirectional data flow configuration
|
||||||
|
|
||||||
|
#### Step 3: Field Conditions
|
||||||
|
- Configure conditional field behavior based on process variables
|
||||||
|
- Supported operators: equals, not_equals, is_true, is_false, is_empty, etc.
|
||||||
|
- Actions: readonly, hide, required, optional, show, enable
|
||||||
|
|
||||||
|
#### Step 4: Task Assignment
|
||||||
|
- **Public**: Anyone can complete
|
||||||
|
- **Specific Users**: Select individual users
|
||||||
|
- **Role-based**: Assign to user roles
|
||||||
|
- **Dynamic**: Use process variables to determine assignee
|
||||||
|
|
||||||
|
## API Integration
|
||||||
|
|
||||||
|
Variables enable seamless integration with external APIs:
|
||||||
|
|
||||||
|
- **Dynamic URLs**: Embed variables in API endpoints
|
||||||
|
- **Request Customization**: Variables in headers, body, and parameters
|
||||||
|
- **Response Handling**: Store API responses in process variables
|
||||||
|
- **Error Management**: Capture and handle API errors
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Variable Naming
|
||||||
|
- Use descriptive names (e.g., `customerEmail` instead of `email`)
|
||||||
|
- Follow camelCase convention
|
||||||
|
- Avoid spaces and special characters
|
||||||
|
- Use consistent prefixes for related variables
|
||||||
|
|
||||||
|
### Type Selection
|
||||||
|
- Choose the most specific type for better validation
|
||||||
|
- Use `object` for complex data structures
|
||||||
|
- Use `array` for lists of items
|
||||||
|
- Use special types (email, currency) for semantic clarity
|
||||||
|
|
||||||
|
### Scope Management
|
||||||
|
- Prefer process-level variables for data that persists across nodes
|
||||||
|
- Use global variables sparingly for truly global data
|
||||||
|
- Document variable purposes and expected values
|
||||||
|
|
||||||
|
### Dependency Management
|
||||||
|
- Review variable usage before deletion
|
||||||
|
- Use the usage tracking feature to understand dependencies
|
||||||
|
- Plan variable lifecycle aligned with process flow
|
||||||
|
|
||||||
|
## Development Notes
|
||||||
|
|
||||||
|
### Store Integration
|
||||||
|
Variables are primarily managed through the `processBuilder` store:
|
||||||
|
- `processStore.addProcessVariable()`
|
||||||
|
- `processStore.getProcessVariables()`
|
||||||
|
- `processStore.updateProcessVariable()`
|
||||||
|
- `processStore.deleteProcessVariable()`
|
||||||
|
|
||||||
|
### Component Integration
|
||||||
|
- VariableBrowser is used throughout node configuration modals
|
||||||
|
- VariableManager provides comprehensive variable management UI
|
||||||
|
- Real-time validation and error handling
|
||||||
|
|
||||||
|
### Performance Considerations
|
||||||
|
- Variable usage analysis runs on-demand to avoid performance issues
|
||||||
|
- Deep object cloning used to prevent reactivity issues
|
||||||
|
- Efficient grouping and filtering for large variable sets
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Form Builder System](./form-builder-system.md)
|
||||||
|
- [Process Definition Schema](./json/process-builder/processDefinition.json)
|
||||||
|
- [Process Variables Example](./json/process-builder/processVariables.json)
|
@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watch } from 'vue';
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import FormScriptEngine from '~/components/FormScriptEngine.vue';
|
import FormScriptEngine from '~/components/FormScriptEngine.vue';
|
||||||
import ConditionalLogicEngine from '~/components/ConditionalLogicEngine.vue';
|
import ConditionalLogicEngine from '~/components/ConditionalLogicEngine.vue';
|
||||||
@ -36,6 +36,7 @@ const currentForm = ref(null);
|
|||||||
const tasks = ref([]);
|
const tasks = ref([]);
|
||||||
const conditionalLogicScript = ref('');
|
const conditionalLogicScript = ref('');
|
||||||
const combinedScript = ref('');
|
const combinedScript = ref('');
|
||||||
|
const fieldStates = ref({});
|
||||||
|
|
||||||
// Get process ID from route
|
// Get process ID from route
|
||||||
const processId = computed(() => route.params.id);
|
const processId = computed(() => route.params.id);
|
||||||
@ -130,24 +131,115 @@ const startProcessExecution = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper: Get next node ID by following edges
|
// Helper: Get all outgoing edges for a node
|
||||||
function getNextNodeId(currentNodeId) {
|
function getOutgoingEdges(currentNodeId) {
|
||||||
const edge = workflowData.value.edges.find(e => e.source === currentNodeId);
|
return workflowData.value.edges.filter(e => e.source === currentNodeId);
|
||||||
return edge ? edge.target : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move to next step in workflow (edge-based)
|
// Helper: Get next node ID by following edges (for single-path nodes)
|
||||||
|
function getNextNodeId(currentNodeId) {
|
||||||
|
const edges = getOutgoingEdges(currentNodeId);
|
||||||
|
return edges.length > 0 ? edges[0].target : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Check if a node can auto-progress (has single outgoing edge)
|
||||||
|
function canAutoProgress(node) {
|
||||||
|
const outgoingEdges = getOutgoingEdges(node.id);
|
||||||
|
return outgoingEdges.length === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Get next node ID for decision/gateway nodes based on conditions
|
||||||
|
function getNextNodeIdForDecision(currentNodeId) {
|
||||||
|
const currentNodeObj = workflowData.value.nodes.find(n => n.id === currentNodeId);
|
||||||
|
const outgoingEdges = getOutgoingEdges(currentNodeId);
|
||||||
|
|
||||||
|
if (!currentNodeObj || !outgoingEdges.length) return null;
|
||||||
|
|
||||||
|
const { conditions = [], defaultPath } = currentNodeObj.data || {};
|
||||||
|
|
||||||
|
// Evaluate conditions
|
||||||
|
for (const condition of conditions) {
|
||||||
|
if (evaluateCondition(condition, processVariables.value)) {
|
||||||
|
// Find the edge that matches this condition's target
|
||||||
|
const edge = outgoingEdges.find(e => e.data?.conditionId === condition.id || e.label === condition.label);
|
||||||
|
if (edge) return edge.target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no conditions match, use default path
|
||||||
|
if (defaultPath) {
|
||||||
|
const defaultEdge = outgoingEdges.find(e => e.data?.isDefault || e.label?.toLowerCase().includes('default'));
|
||||||
|
if (defaultEdge) return defaultEdge.target;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to first edge
|
||||||
|
return outgoingEdges[0]?.target || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Evaluate a single condition
|
||||||
|
function evaluateCondition(condition, variables) {
|
||||||
|
const { variable, operator, value } = condition;
|
||||||
|
const variableValue = variables[variable];
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case 'equals':
|
||||||
|
case '==':
|
||||||
|
return variableValue == value;
|
||||||
|
case 'not_equals':
|
||||||
|
case '!=':
|
||||||
|
return variableValue != value;
|
||||||
|
case 'greater_than':
|
||||||
|
case '>':
|
||||||
|
return Number(variableValue) > Number(value);
|
||||||
|
case 'less_than':
|
||||||
|
case '<':
|
||||||
|
return Number(variableValue) < Number(value);
|
||||||
|
case 'greater_than_or_equal':
|
||||||
|
case '>=':
|
||||||
|
return Number(variableValue) >= Number(value);
|
||||||
|
case 'less_than_or_equal':
|
||||||
|
case '<=':
|
||||||
|
return Number(variableValue) <= Number(value);
|
||||||
|
case 'contains':
|
||||||
|
return String(variableValue).includes(String(value));
|
||||||
|
case 'not_contains':
|
||||||
|
return !String(variableValue).includes(String(value));
|
||||||
|
case 'is_empty':
|
||||||
|
return !variableValue || variableValue === '' || variableValue === null || variableValue === undefined;
|
||||||
|
case 'is_not_empty':
|
||||||
|
return variableValue && variableValue !== '' && variableValue !== null && variableValue !== undefined;
|
||||||
|
default:
|
||||||
|
console.warn('[Workflow] Unknown condition operator:', operator);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to a specific node by its ID
|
||||||
|
const moveToSpecificNode = (targetNodeId) => {
|
||||||
|
const nextIndex = workflowData.value.nodes.findIndex(n => n.id === targetNodeId);
|
||||||
|
if (nextIndex !== -1) {
|
||||||
|
currentStep.value = nextIndex;
|
||||||
|
const node = workflowData.value.nodes[currentStep.value];
|
||||||
|
console.log(`[Workflow] Moved to node: ${node.type} - ${node.data?.label || node.label}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Move to next step in workflow (only for single-path auto-progressable nodes)
|
||||||
const moveToNextStep = () => {
|
const moveToNextStep = () => {
|
||||||
const currentNode = workflowData.value.nodes[currentStep.value];
|
const currentNode = workflowData.value.nodes[currentStep.value];
|
||||||
if (!currentNode) return;
|
if (!currentNode) return;
|
||||||
|
|
||||||
|
// Only auto-progress if there's exactly one outgoing edge
|
||||||
|
if (!canAutoProgress(currentNode)) {
|
||||||
|
console.log(`[Workflow] Node ${currentNode.id} has multiple paths, waiting for user choice`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const nextNodeId = getNextNodeId(currentNode.id);
|
const nextNodeId = getNextNodeId(currentNode.id);
|
||||||
if (nextNodeId) {
|
if (nextNodeId) {
|
||||||
const nextIndex = workflowData.value.nodes.findIndex(n => n.id === nextNodeId);
|
moveToSpecificNode(nextNodeId);
|
||||||
if (nextIndex !== -1) {
|
|
||||||
currentStep.value = nextIndex;
|
|
||||||
const node = workflowData.value.nodes[currentStep.value];
|
|
||||||
console.log(`[Workflow] Entered node: ${node.type} - ${node.data?.label || node.label}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -155,17 +247,31 @@ const moveToNextStep = () => {
|
|||||||
const handleFormSubmit = async () => {
|
const handleFormSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
stepLoading.value = true;
|
stepLoading.value = true;
|
||||||
|
|
||||||
console.log('[Workflow] Form submitted. Data:', formData.value);
|
console.log('[Workflow] Form submitted. Data:', formData.value);
|
||||||
// Save form data to process variables
|
|
||||||
Object.assign(processVariables.value, formData.value);
|
|
||||||
|
|
||||||
// Move to next step
|
// Apply output mappings if configured
|
||||||
|
if (currentNode.value.data?.outputMappings?.length > 0) {
|
||||||
|
applyOutputMappings(
|
||||||
|
currentNode.value.data,
|
||||||
|
formData.value,
|
||||||
|
processVariables.value
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Fallback to current flat assignment behavior
|
||||||
|
Object.assign(processVariables.value, formData.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle assignment variables
|
||||||
|
if (currentNode.value.data?.assignmentVariable) {
|
||||||
|
processVariables.value[currentNode.value.data.assignmentVariable] = {
|
||||||
|
completedBy: 'currentUser', // TODO: Get from auth context
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
formData: { ...formData.value }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
moveToNextStep();
|
moveToNextStep();
|
||||||
|
|
||||||
console.log('[Workflow] After form submit, current node:', currentNode.value);
|
console.log('[Workflow] After form submit, current node:', currentNode.value);
|
||||||
|
|
||||||
// If next step is API or script, execute it automatically
|
|
||||||
if (currentNode.value && ['api', 'script'].includes(currentNode.value.type)) {
|
if (currentNode.value && ['api', 'script'].includes(currentNode.value.type)) {
|
||||||
await executeCurrentStep();
|
await executeCurrentStep();
|
||||||
}
|
}
|
||||||
@ -177,49 +283,284 @@ const handleFormSubmit = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Execute current step (for API/script nodes)
|
// --- Utility: Substitute variables in a string ---
|
||||||
|
function substituteVariables(str, variables) {
|
||||||
|
if (typeof str !== 'string') return str;
|
||||||
|
return str.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, varName) => {
|
||||||
|
const value = variables[varName];
|
||||||
|
if (value === undefined || value === null) return '';
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Variable Mapping Functions ---
|
||||||
|
|
||||||
|
// Apply input mappings to pre-fill form with process variables
|
||||||
|
function applyInputMappings(nodeData, processVars, formData) {
|
||||||
|
const { inputMappings = [] } = nodeData;
|
||||||
|
console.log('[Workflow] Applying input mappings:', inputMappings);
|
||||||
|
|
||||||
|
inputMappings.forEach(mapping => {
|
||||||
|
const { processVariable, formField } = mapping;
|
||||||
|
if (processVars[processVariable] !== undefined) {
|
||||||
|
formData[formField] = processVars[processVariable];
|
||||||
|
console.log(`[Workflow] Mapped ${processVariable} -> ${formField}:`, processVars[processVariable]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply output mappings to save form data to process variables
|
||||||
|
function applyOutputMappings(nodeData, formData, processVars) {
|
||||||
|
const { outputMappings = [] } = nodeData;
|
||||||
|
console.log('[Workflow] Applying output mappings:', outputMappings);
|
||||||
|
|
||||||
|
outputMappings.forEach(mapping => {
|
||||||
|
const { formField, processVariable } = mapping;
|
||||||
|
if (formData[formField] !== undefined) {
|
||||||
|
processVars[processVariable] = formData[formField];
|
||||||
|
console.log(`[Workflow] Mapped ${formField} -> ${processVariable}:`, formData[formField]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply field conditions for dynamic form behavior
|
||||||
|
function applyFieldConditions(nodeData, processVars) {
|
||||||
|
const { fieldConditions = [] } = nodeData;
|
||||||
|
const fieldStates = {};
|
||||||
|
|
||||||
|
fieldConditions.forEach(condition => {
|
||||||
|
const { processVariable, operator, value, targetField, action } = condition;
|
||||||
|
|
||||||
|
if (evaluateCondition({ variable: processVariable, operator, value }, processVars)) {
|
||||||
|
fieldStates[targetField] = {
|
||||||
|
...fieldStates[targetField],
|
||||||
|
[action]: true
|
||||||
|
};
|
||||||
|
console.log(`[Workflow] Field condition applied: ${targetField} -> ${action}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return fieldStates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Utility: Build API request from node data ---
|
||||||
|
function buildApiRequest(nodeData, variables) {
|
||||||
|
// 1. URL (with param substitution)
|
||||||
|
let url = substituteVariables(nodeData.apiUrl, variables);
|
||||||
|
|
||||||
|
// 2. Params (for GET, DELETE, etc.)
|
||||||
|
let params = Array.isArray(nodeData.params) ? nodeData.params : [];
|
||||||
|
if (params.length) {
|
||||||
|
const query = params
|
||||||
|
.filter(p => p.key)
|
||||||
|
.map(p => `${encodeURIComponent(substituteVariables(p.key, variables))}=${encodeURIComponent(substituteVariables(p.value, variables))}`)
|
||||||
|
.join('&');
|
||||||
|
if (query) {
|
||||||
|
url += (url.includes('?') ? '&' : '?') + query;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Headers
|
||||||
|
let headers = {};
|
||||||
|
if (Array.isArray(nodeData.headers)) {
|
||||||
|
nodeData.headers.forEach(h => {
|
||||||
|
if (h.key) headers[substituteVariables(h.key, variables)] = substituteVariables(h.value, variables);
|
||||||
|
});
|
||||||
|
} else if (typeof nodeData.headers === 'object') {
|
||||||
|
headers = { ...nodeData.headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Authorization
|
||||||
|
if (nodeData.authorization && nodeData.authorization.type && nodeData.authorization.type !== 'none') {
|
||||||
|
const auth = nodeData.authorization;
|
||||||
|
if (auth.type === 'bearer' && auth.token) {
|
||||||
|
headers['Authorization'] = `Bearer ${substituteVariables(auth.token, variables)}`;
|
||||||
|
} else if (auth.type === 'basic' && auth.username && auth.password) {
|
||||||
|
const token = btoa(`${substituteVariables(auth.username, variables)}:${substituteVariables(auth.password, variables)}`);
|
||||||
|
headers['Authorization'] = `Basic ${token}`;
|
||||||
|
} else if (auth.type === 'apiKey' && auth.key && auth.value) {
|
||||||
|
if (auth.in === 'header') {
|
||||||
|
headers[substituteVariables(auth.key, variables)] = substituteVariables(auth.value, variables);
|
||||||
|
} else if (auth.in === 'query') {
|
||||||
|
url += (url.includes('?') ? '&' : '?') + `${encodeURIComponent(substituteVariables(auth.key, variables))}=${encodeURIComponent(substituteVariables(auth.value, variables))}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Body
|
||||||
|
let body;
|
||||||
|
if (nodeData.body && nodeData.body.type && nodeData.body.type !== 'none') {
|
||||||
|
if (['form-data', 'x-www-form-urlencoded'].includes(nodeData.body.type)) {
|
||||||
|
const dataArr = Array.isArray(nodeData.body.data) ? nodeData.body.data : [];
|
||||||
|
if (nodeData.body.type === 'form-data') {
|
||||||
|
body = new FormData();
|
||||||
|
dataArr.forEach(item => {
|
||||||
|
if (item.key) body.append(substituteVariables(item.key, variables), substituteVariables(item.value, variables));
|
||||||
|
});
|
||||||
|
// Remove content-type header for FormData (browser will set it)
|
||||||
|
if (headers['Content-Type']) delete headers['Content-Type'];
|
||||||
|
} else {
|
||||||
|
// x-www-form-urlencoded
|
||||||
|
body = dataArr
|
||||||
|
.filter(item => item.key)
|
||||||
|
.map(item => `${encodeURIComponent(substituteVariables(item.key, variables))}=${encodeURIComponent(substituteVariables(item.value, variables))}`)
|
||||||
|
.join('&');
|
||||||
|
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||||
|
}
|
||||||
|
} else if (nodeData.body.type === 'raw') {
|
||||||
|
body = substituteVariables(nodeData.body.data, variables);
|
||||||
|
// Try to detect JSON
|
||||||
|
if (body && body.trim().startsWith('{')) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
} else if (nodeData.body.type === 'binary') {
|
||||||
|
// Not supported in workflow execution context
|
||||||
|
body = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { url, headers, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update executeCurrentStep for API node
|
||||||
const executeCurrentStep = async () => {
|
const executeCurrentStep = async () => {
|
||||||
try {
|
try {
|
||||||
stepLoading.value = true;
|
stepLoading.value = true;
|
||||||
|
|
||||||
console.log('[Workflow] Executing current step:', currentNode.value);
|
console.log('[Workflow] Executing current step:', currentNode.value);
|
||||||
|
|
||||||
if (currentNode.value?.type === 'api') {
|
if (currentNode.value?.type === 'api') {
|
||||||
console.log(`[Workflow] Executing API node: ${currentNode.value.data?.label || currentNode.value.label}`);
|
console.log(`[Workflow] Executing API node: ${currentNode.value.data?.label || currentNode.value.label}`);
|
||||||
// Enhanced API node execution
|
const nodeData = currentNode.value.data || {};
|
||||||
const {
|
// Use new structure if present
|
||||||
apiUrl,
|
if (nodeData.body || nodeData.headers || nodeData.params || nodeData.authorization) {
|
||||||
apiMethod = 'GET',
|
const { url, headers, body } = buildApiRequest(nodeData, processVariables.value);
|
||||||
headers = '{}',
|
const apiMethod = nodeData.apiMethod || 'GET';
|
||||||
requestBody = '',
|
const outputVariable = nodeData.outputVariable || 'apiResponse';
|
||||||
outputVariable = 'apiResponse',
|
const errorVariable = nodeData.errorVariable || 'apiError';
|
||||||
errorVariable = 'apiError',
|
const continueOnError = nodeData.continueOnError || false;
|
||||||
continueOnError = false
|
try {
|
||||||
} = currentNode.value.data || {};
|
const response = await $fetch(url, {
|
||||||
try {
|
method: apiMethod,
|
||||||
const response = await $fetch(apiUrl, {
|
headers,
|
||||||
method: apiMethod,
|
body: ['GET', 'HEAD'].includes(apiMethod) ? undefined : body,
|
||||||
headers: headers ? JSON.parse(headers) : {},
|
});
|
||||||
body: requestBody ? JSON.parse(requestBody) : undefined,
|
processVariables.value[outputVariable] = response;
|
||||||
});
|
processVariables.value[errorVariable] = null;
|
||||||
processVariables.value[outputVariable] = response;
|
console.log('[Workflow] API call success. Output variable set:', outputVariable, response);
|
||||||
processVariables.value[errorVariable] = null;
|
if (canAutoProgress(currentNode.value)) {
|
||||||
console.log('[Workflow] API call success. Output variable set:', outputVariable, response);
|
moveToNextStep();
|
||||||
moveToNextStep();
|
} else {
|
||||||
} catch (err) {
|
console.log('[Workflow] API completed, multiple paths available - waiting for user choice');
|
||||||
processVariables.value[errorVariable] = err;
|
}
|
||||||
console.error('[Workflow] API call failed:', err);
|
} catch (err) {
|
||||||
if (continueOnError) {
|
processVariables.value[errorVariable] = err;
|
||||||
moveToNextStep();
|
console.error('[Workflow] API call failed:', err);
|
||||||
} else {
|
if (continueOnError) {
|
||||||
error.value = 'API call failed: ' + (err.message || err);
|
if (canAutoProgress(currentNode.value)) {
|
||||||
|
moveToNextStep();
|
||||||
|
} else {
|
||||||
|
console.log('[Workflow] API failed but continuing, multiple paths available - waiting for user choice');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error.value = 'API call failed: ' + (err.message || err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: old structure
|
||||||
|
const {
|
||||||
|
apiUrl,
|
||||||
|
apiMethod = 'GET',
|
||||||
|
headers = '{}',
|
||||||
|
requestBody = '',
|
||||||
|
outputVariable = 'apiResponse',
|
||||||
|
errorVariable = 'apiError',
|
||||||
|
continueOnError = false
|
||||||
|
} = nodeData;
|
||||||
|
try {
|
||||||
|
const response = await $fetch(apiUrl, {
|
||||||
|
method: apiMethod,
|
||||||
|
headers: headers ? JSON.parse(headers) : {},
|
||||||
|
body: requestBody ? JSON.parse(requestBody) : undefined,
|
||||||
|
});
|
||||||
|
processVariables.value[outputVariable] = response;
|
||||||
|
processVariables.value[errorVariable] = null;
|
||||||
|
console.log('[Workflow] API call success. Output variable set:', outputVariable, response);
|
||||||
|
if (canAutoProgress(currentNode.value)) {
|
||||||
|
moveToNextStep();
|
||||||
|
} else {
|
||||||
|
console.log('[Workflow] API completed, multiple paths available - waiting for user choice');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
processVariables.value[errorVariable] = err;
|
||||||
|
console.error('[Workflow] API call failed:', err);
|
||||||
|
if (continueOnError) {
|
||||||
|
if (canAutoProgress(currentNode.value)) {
|
||||||
|
moveToNextStep();
|
||||||
|
} else {
|
||||||
|
console.log('[Workflow] API failed but continuing, multiple paths available - waiting for user choice');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error.value = 'API call failed: ' + (err.message || err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (currentNode.value?.type === 'script') {
|
} else if (currentNode.value?.type === 'script') {
|
||||||
console.log(`[Workflow] Executing script node: ${currentNode.value.data?.label || currentNode.value.label}`);
|
console.log(`[Workflow] Executing script node: ${currentNode.value.data?.label || currentNode.value.label}`);
|
||||||
// Simulate script execution
|
// Simulate script execution
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
moveToNextStep();
|
|
||||||
|
// Only auto-progress if there's a single outgoing edge
|
||||||
|
if (canAutoProgress(currentNode.value)) {
|
||||||
|
moveToNextStep();
|
||||||
|
} else {
|
||||||
|
console.log('[Workflow] Script completed, multiple paths available - waiting for user choice');
|
||||||
|
}
|
||||||
|
} else if (currentNode.value?.type === 'html') {
|
||||||
|
console.log(`[Workflow] Processing HTML node: ${currentNode.value.data?.label || currentNode.value.label}`);
|
||||||
|
// HTML nodes are user-interactive, so we don't auto-advance
|
||||||
|
// The user will click a button to continue
|
||||||
|
return;
|
||||||
|
} else if (currentNode.value?.type === 'notification') {
|
||||||
|
console.log(`[Workflow] Sending notification: ${currentNode.value.data?.label || currentNode.value.label}`);
|
||||||
|
// Enhanced notification node execution
|
||||||
|
const {
|
||||||
|
notificationType = 'info',
|
||||||
|
recipients = [],
|
||||||
|
message = '',
|
||||||
|
subject = '',
|
||||||
|
continueOnError = true
|
||||||
|
} = currentNode.value.data || {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Simulate notification sending
|
||||||
|
console.log('[Workflow] Notification sent:', { type: notificationType, recipients, message, subject });
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call delay
|
||||||
|
|
||||||
|
// Only auto-progress if there's a single outgoing edge
|
||||||
|
if (canAutoProgress(currentNode.value)) {
|
||||||
|
moveToNextStep();
|
||||||
|
} else {
|
||||||
|
console.log('[Workflow] Notification sent, multiple paths available - waiting for user choice');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Workflow] Notification failed:', err);
|
||||||
|
if (continueOnError) {
|
||||||
|
if (canAutoProgress(currentNode.value)) {
|
||||||
|
moveToNextStep();
|
||||||
|
} else {
|
||||||
|
console.log('[Workflow] Notification failed but continuing, multiple paths available - waiting for user choice');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error.value = 'Notification failed: ' + (err.message || err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (['decision', 'gateway'].includes(currentNode.value?.type)) {
|
||||||
|
console.log(`[Workflow] Processing decision node: ${currentNode.value.data?.label || currentNode.value.label}`);
|
||||||
|
// Decision nodes require user interaction or automatic condition evaluation
|
||||||
|
// We'll handle this in the template section
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// Add more node types as needed
|
// Add more node types as needed
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -238,6 +579,9 @@ const getStepInfo = (node) => {
|
|||||||
'api': { label: 'API Call', icon: 'material-symbols:api', color: 'purple' },
|
'api': { label: 'API Call', icon: 'material-symbols:api', color: 'purple' },
|
||||||
'script': { label: 'Script Execution', icon: 'material-symbols:code', color: 'orange' },
|
'script': { label: 'Script Execution', icon: 'material-symbols:code', color: 'orange' },
|
||||||
'decision': { label: 'Decision Point', icon: 'material-symbols:alt-route', color: 'yellow' },
|
'decision': { label: 'Decision Point', icon: 'material-symbols:alt-route', color: 'yellow' },
|
||||||
|
'gateway': { label: 'Decision Gateway', icon: 'material-symbols:fork-right', color: 'yellow' },
|
||||||
|
'html': { label: 'HTML Content', icon: 'material-symbols:code-blocks', color: 'indigo' },
|
||||||
|
'notification': { label: 'Notification', icon: 'material-symbols:notifications', color: 'pink' },
|
||||||
'end': { label: 'Process Complete', icon: 'material-symbols:check-circle', color: 'green' }
|
'end': { label: 'Process Complete', icon: 'material-symbols:check-circle', color: 'green' }
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -300,20 +644,42 @@ onMounted(() => {
|
|||||||
// Watch for step changes to auto-execute non-form steps or load form data
|
// Watch for step changes to auto-execute non-form steps or load form data
|
||||||
watch(currentStep, async (newStep) => {
|
watch(currentStep, async (newStep) => {
|
||||||
if (currentNode.value) {
|
if (currentNode.value) {
|
||||||
if (['api', 'script'].includes(currentNode.value.type)) {
|
if (['api', 'script', 'notification'].includes(currentNode.value.type)) {
|
||||||
await executeCurrentStep();
|
await executeCurrentStep();
|
||||||
} else if (currentNode.value.type === 'form') {
|
} else if (currentNode.value.type === 'form') {
|
||||||
// Load form data for form nodes
|
// Load form data for form nodes
|
||||||
const formId = currentNode.value.data?.formId;
|
const formId = currentNode.value.data?.formId;
|
||||||
if (formId) {
|
if (formId) {
|
||||||
currentForm.value = await loadFormData(formId);
|
currentForm.value = await loadFormData(formId);
|
||||||
|
|
||||||
|
// Apply input mappings to pre-fill form
|
||||||
|
if (currentNode.value.data?.inputMappings) {
|
||||||
|
applyInputMappings(
|
||||||
|
currentNode.value.data,
|
||||||
|
processVariables.value,
|
||||||
|
formData.value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply field conditions
|
||||||
|
if (currentNode.value.data?.fieldConditions) {
|
||||||
|
fieldStates.value = applyFieldConditions(
|
||||||
|
currentNode.value.data,
|
||||||
|
processVariables.value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Update form store with form components and data for ComponentPreview
|
// Update form store with form components and data for ComponentPreview
|
||||||
if (currentForm.value?.formComponents) {
|
if (currentForm.value?.formComponents) {
|
||||||
formStore.formComponents = currentForm.value.formComponents;
|
formStore.formComponents = currentForm.value.formComponents;
|
||||||
formStore.updatePreviewFormData(formData.value);
|
formStore.updatePreviewFormData(formData.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (['decision', 'gateway'].includes(currentNode.value.type)) {
|
||||||
|
// Handle decision nodes
|
||||||
|
await executeDecisionNode();
|
||||||
}
|
}
|
||||||
|
// html nodes are handled in template - no auto-execution needed
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -322,6 +688,13 @@ watch(formData, (newData) => {
|
|||||||
formStore.updatePreviewFormData(newData);
|
formStore.updatePreviewFormData(newData);
|
||||||
}, { deep: true });
|
}, { deep: true });
|
||||||
|
|
||||||
|
// Watch for process variable changes and re-evaluate field conditions
|
||||||
|
watch(processVariables, (newVars) => {
|
||||||
|
if (currentNode.value?.type === 'form' && currentNode.value.data?.fieldConditions) {
|
||||||
|
fieldStates.value = applyFieldConditions(currentNode.value.data, newVars);
|
||||||
|
}
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
// Add FormKit form ref
|
// Add FormKit form ref
|
||||||
const formRef = ref(null);
|
const formRef = ref(null);
|
||||||
|
|
||||||
@ -337,6 +710,55 @@ const validateAndSubmit = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle decision node execution (automatic or manual)
|
||||||
|
const executeDecisionNode = async () => {
|
||||||
|
try {
|
||||||
|
stepLoading.value = true;
|
||||||
|
const currentNodeObj = workflowData.value.nodes[currentStep.value];
|
||||||
|
const { executionType = 'automatic' } = currentNodeObj.data || {};
|
||||||
|
|
||||||
|
if (executionType === 'automatic') {
|
||||||
|
// Automatic decision based on conditions
|
||||||
|
const nextNodeId = getNextNodeIdForDecision(currentNodeObj.id);
|
||||||
|
if (nextNodeId) {
|
||||||
|
if (moveToSpecificNode(nextNodeId)) {
|
||||||
|
const node = workflowData.value.nodes[currentStep.value];
|
||||||
|
console.log(`[Workflow] Decision made automatically, moving to: ${node.data?.label || node.label}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Manual decisions are handled in the template with buttons
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Workflow] Error executing decision:', err);
|
||||||
|
error.value = 'Failed to execute decision';
|
||||||
|
} finally {
|
||||||
|
stepLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle manual decision choice
|
||||||
|
const makeDecision = (targetNodeId) => {
|
||||||
|
if (moveToSpecificNode(targetNodeId)) {
|
||||||
|
const node = workflowData.value.nodes[currentStep.value];
|
||||||
|
console.log(`[Workflow] Manual decision made, moving to: ${node.data?.label || node.label}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper: Interpolate process variables in HTML content
|
||||||
|
const interpolateHtmlContent = (htmlContent) => {
|
||||||
|
if (!htmlContent || typeof htmlContent !== 'string') return htmlContent;
|
||||||
|
// Replace {{ processVariables.variableName }} with actual values
|
||||||
|
return htmlContent.replace(/\{\{\s*processVariables\.(\w+)\s*\}\}/g, (match, variableName) => {
|
||||||
|
const value = processVariables.value[variableName];
|
||||||
|
if (value === undefined || value === null) return '';
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
// Pretty print object as JSON, wrapped in <pre> if not already inside <pre>
|
||||||
|
return `<pre style='display:inline;white-space:pre-wrap;'>${JSON.stringify(value, null, 2)}</pre>`;
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Compute the workflow path by following edges from start to end
|
// Compute the workflow path by following edges from start to end
|
||||||
function computeWorkflowPath() {
|
function computeWorkflowPath() {
|
||||||
const path = [];
|
const path = [];
|
||||||
@ -371,6 +793,68 @@ const currentStepIndex = computed(() => {
|
|||||||
return workflowPath.value.indexOf(currentNode.value?.id) + 1;
|
return workflowPath.value.indexOf(currentNode.value?.id) + 1;
|
||||||
});
|
});
|
||||||
const totalSteps = computed(() => workflowPath.value.length);
|
const totalSteps = computed(() => workflowPath.value.length);
|
||||||
|
|
||||||
|
// Computed: Interpolated HTML content for HTML nodes
|
||||||
|
const interpolatedHtmlContent = computed(() => {
|
||||||
|
if (currentNode.value?.type !== 'html') return '';
|
||||||
|
|
||||||
|
const htmlContent = currentNode.value?.data?.htmlCode || currentNode.value?.data?.htmlContent || '';
|
||||||
|
return interpolateHtmlContent(htmlContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Computed: CSS styles for HTML nodes
|
||||||
|
const htmlNodeStyles = computed(() => {
|
||||||
|
if (currentNode.value?.type !== 'html') return '';
|
||||||
|
return currentNode.value?.data?.cssCode || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// CSS injection for HTML nodes
|
||||||
|
let currentStyleElement = null;
|
||||||
|
|
||||||
|
// Function to inject CSS
|
||||||
|
const injectHtmlNodeCSS = (cssCode) => {
|
||||||
|
// Remove previous style element
|
||||||
|
if (currentStyleElement) {
|
||||||
|
currentStyleElement.remove();
|
||||||
|
currentStyleElement = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new styles if available
|
||||||
|
if (cssCode && cssCode.trim()) {
|
||||||
|
currentStyleElement = document.createElement('style');
|
||||||
|
currentStyleElement.textContent = cssCode;
|
||||||
|
currentStyleElement.setAttribute('data-workflow-html-node', 'true');
|
||||||
|
document.head.appendChild(currentStyleElement);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch for HTML node CSS changes and inject styles
|
||||||
|
watch(htmlNodeStyles, (newStyles) => {
|
||||||
|
injectHtmlNodeCSS(newStyles);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
// Cleanup styles on unmount
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (currentStyleElement) {
|
||||||
|
currentStyleElement.remove();
|
||||||
|
currentStyleElement = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper: Get next node object for single-path nodes
|
||||||
|
function getNextNodeObject(currentNode) {
|
||||||
|
if (!currentNode || !workflowData.value?.nodes) return null;
|
||||||
|
const edges = getOutgoingEdges(currentNode.id);
|
||||||
|
if (edges.length !== 1) return null;
|
||||||
|
const nextNodeId = edges[0].target;
|
||||||
|
return workflowData.value.nodes.find(n => n.id === nextNodeId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Get next node label by node ID
|
||||||
|
function getNodeLabel(nodeId) {
|
||||||
|
const node = workflowData.value.nodes.find(n => n.id === nodeId);
|
||||||
|
return node?.data?.label || node?.label || 'Next Step';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -463,6 +947,9 @@ const totalSteps = computed(() => workflowPath.value.length);
|
|||||||
getStepInfo(currentNode).color === 'green' ? 'bg-green-100 text-green-600' :
|
getStepInfo(currentNode).color === 'green' ? 'bg-green-100 text-green-600' :
|
||||||
getStepInfo(currentNode).color === 'purple' ? 'bg-purple-100 text-purple-600' :
|
getStepInfo(currentNode).color === 'purple' ? 'bg-purple-100 text-purple-600' :
|
||||||
getStepInfo(currentNode).color === 'orange' ? 'bg-orange-100 text-orange-600' :
|
getStepInfo(currentNode).color === 'orange' ? 'bg-orange-100 text-orange-600' :
|
||||||
|
getStepInfo(currentNode).color === 'yellow' ? 'bg-yellow-100 text-yellow-600' :
|
||||||
|
getStepInfo(currentNode).color === 'indigo' ? 'bg-indigo-100 text-indigo-600' :
|
||||||
|
getStepInfo(currentNode).color === 'pink' ? 'bg-pink-100 text-pink-600' :
|
||||||
'bg-gray-100 text-gray-600'
|
'bg-gray-100 text-gray-600'
|
||||||
]">
|
]">
|
||||||
<Icon :name="getStepInfo(currentNode).icon" class="w-6 h-6" />
|
<Icon :name="getStepInfo(currentNode).icon" class="w-6 h-6" />
|
||||||
@ -527,7 +1014,8 @@ const totalSteps = computed(() => workflowPath.value.length);
|
|||||||
<template v-for="(component, index) in currentForm.formComponents" :key="index">
|
<template v-for="(component, index) in currentForm.formComponents" :key="index">
|
||||||
<ComponentPreview
|
<ComponentPreview
|
||||||
:component="component"
|
:component="component"
|
||||||
:is-preview="false"
|
:is-preview="false"
|
||||||
|
:field-states="fieldStates"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<div class="flex justify-end pt-6 border-t border-gray-200">
|
<div class="flex justify-end pt-6 border-t border-gray-200">
|
||||||
@ -573,13 +1061,167 @@ const totalSteps = computed(() => workflowPath.value.length);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- API/Script Step -->
|
<!-- API/Script/Notification Step -->
|
||||||
<div v-else-if="['api', 'script'].includes(currentNode.type)" class="bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center">
|
<div v-else-if="['api', 'script', 'notification'].includes(currentNode.type)" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
<Icon name="material-symbols:progress-activity" class="w-8 h-8 animate-spin text-blue-500 mx-auto mb-4" />
|
<!-- Processing State -->
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">
|
<div v-if="stepLoading" class="text-center py-8">
|
||||||
{{ currentNode.type === 'api' ? 'Calling API...' : 'Executing Script...' }}
|
<Icon name="material-symbols:progress-activity" class="w-8 h-8 animate-spin text-blue-500 mx-auto mb-4" />
|
||||||
</h3>
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">
|
||||||
<p class="text-gray-600">Please wait while we process this step</p>
|
{{
|
||||||
|
currentNode.type === 'api' ? 'Calling API...' :
|
||||||
|
currentNode.type === 'script' ? 'Executing Script...' :
|
||||||
|
'Sending Notification...'
|
||||||
|
}}
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-600">Please wait while we process this step</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Completed State with Multiple Paths -->
|
||||||
|
<div v-else-if="!canAutoProgress(currentNode)" class="space-y-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<Icon name="material-symbols:check-circle" class="w-8 h-8 text-green-500 mx-auto mb-4" />
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
{{
|
||||||
|
currentNode.type === 'api' ? 'API Call Complete' :
|
||||||
|
currentNode.type === 'script' ? 'Script Executed' :
|
||||||
|
'Notification Sent'
|
||||||
|
}}
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-600 mb-6">{{ currentNode.data?.label || currentNode.label }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Multiple Path Options -->
|
||||||
|
<div class="border-t border-gray-200 pt-4">
|
||||||
|
<h4 class="font-medium text-gray-700 mb-3">Choose your next step:</h4>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<template v-for="edge in getOutgoingEdges(currentNode.id)" :key="edge.id">
|
||||||
|
<RsButton
|
||||||
|
@click="makeDecision(edge.target)"
|
||||||
|
variant="outline"
|
||||||
|
class="justify-between p-3"
|
||||||
|
>
|
||||||
|
<span>Continue to: {{ getNodeLabel(edge.target) }}</span>
|
||||||
|
<Icon name="material-symbols:arrow-right-alt" />
|
||||||
|
</RsButton>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- HTML Content Step -->
|
||||||
|
<div v-else-if="currentNode.type === 'html'" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
{{ currentNode.data?.title || currentNode.data?.label || 'Content Display' }}
|
||||||
|
</h3>
|
||||||
|
<p v-if="currentNode.data?.description" class="text-gray-600 text-sm">
|
||||||
|
{{ currentNode.data.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- HTML Content -->
|
||||||
|
<div class="html-content-container border border-gray-200 rounded-lg p-4 mb-6">
|
||||||
|
<div
|
||||||
|
v-if="interpolatedHtmlContent"
|
||||||
|
v-html="interpolatedHtmlContent"
|
||||||
|
class="prose max-w-none"
|
||||||
|
></div>
|
||||||
|
<div v-else class="text-center text-gray-500 py-8">
|
||||||
|
<Icon name="material-symbols:code-blocks" class="w-12 h-12 mx-auto mb-2" />
|
||||||
|
<p>No HTML content provided</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Continue Options -->
|
||||||
|
<div class="pt-4 border-t border-gray-200">
|
||||||
|
<!-- Single Path - Simple Continue Button -->
|
||||||
|
<div v-if="canAutoProgress(currentNode)" class="flex justify-end">
|
||||||
|
<RsButton
|
||||||
|
@click="moveToNextStep"
|
||||||
|
:disabled="stepLoading"
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
<Icon v-if="stepLoading" name="material-symbols:progress-activity" class="w-4 h-4 animate-spin mr-2" />
|
||||||
|
<template v-if="!stepLoading">
|
||||||
|
Continue to: {{ getNextNodeObject(currentNode)?.data?.label || getNextNodeObject(currentNode)?.label || 'Next Step' }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Processing...
|
||||||
|
</template>
|
||||||
|
</RsButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Multiple Paths - Show Options -->
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<h4 class="font-medium text-gray-700">Choose your next step:</h4>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<template v-for="edge in getOutgoingEdges(currentNode.id)" :key="edge.id">
|
||||||
|
<RsButton
|
||||||
|
@click="makeDecision(edge.target)"
|
||||||
|
variant="outline"
|
||||||
|
class="justify-between p-3"
|
||||||
|
>
|
||||||
|
<span>Continue to: {{ getNodeLabel(edge.target) }}</span>
|
||||||
|
<Icon name="material-symbols:arrow-right-alt" />
|
||||||
|
</RsButton>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Decision/Gateway Step -->
|
||||||
|
<div v-else-if="['decision', 'gateway'].includes(currentNode.type)" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
{{ currentNode.data?.title || currentNode.data?.label || 'Make a Decision' }}
|
||||||
|
</h3>
|
||||||
|
<p v-if="currentNode.data?.description" class="text-gray-600 text-sm">
|
||||||
|
{{ currentNode.data.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual Decision Options -->
|
||||||
|
<div v-if="currentNode.data?.executionType === 'manual'" class="space-y-4">
|
||||||
|
<p class="text-gray-700 mb-4">Please choose one of the following options:</p>
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<template v-for="edge in workflowData.edges.filter(e => e.source === currentNode.id)" :key="edge.id">
|
||||||
|
<RsButton
|
||||||
|
@click="makeDecision(edge.target)"
|
||||||
|
variant="outline"
|
||||||
|
class="justify-start p-4 h-auto"
|
||||||
|
>
|
||||||
|
<div class="text-left">
|
||||||
|
<div class="font-medium">{{ edge.label || 'Option' }}</div>
|
||||||
|
<div v-if="edge.data?.description" class="text-sm text-gray-500 mt-1">
|
||||||
|
{{ edge.data.description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Icon name="material-symbols:arrow-right-alt" class="ml-auto" />
|
||||||
|
</RsButton>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Automatic Decision Processing -->
|
||||||
|
<div v-else class="text-center py-8">
|
||||||
|
<Icon name="material-symbols:progress-activity" class="w-8 h-8 animate-spin text-yellow-500 mx-auto mb-4" />
|
||||||
|
<h4 class="text-lg font-medium text-gray-900 mb-2">Evaluating Conditions</h4>
|
||||||
|
<p class="text-gray-600">Please wait while we determine the next step...</p>
|
||||||
|
|
||||||
|
<!-- Show conditions being evaluated -->
|
||||||
|
<div v-if="currentNode.data?.conditions?.length" class="mt-6 text-left">
|
||||||
|
<h5 class="font-medium text-gray-700 mb-3">Conditions:</h5>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div v-for="(condition, index) in currentNode.data.conditions" :key="index"
|
||||||
|
class="flex items-center gap-2 text-sm text-gray-600 bg-gray-50 p-2 rounded">
|
||||||
|
<Icon name="material-symbols:rule" class="w-4 h-4" />
|
||||||
|
<span>{{ condition.variable }} {{ condition.operator }} {{ condition.value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Other Step Types -->
|
<!-- Other Step Types -->
|
||||||
@ -593,8 +1235,46 @@ const totalSteps = computed(() => workflowPath.value.length);
|
|||||||
</RsButton>
|
</RsButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Variable Mapping Debug (only in development) -->
|
||||||
|
<div v-if="currentNode.type === 'form'" class="bg-gray-100 rounded-lg p-4">
|
||||||
|
<details>
|
||||||
|
<summary class="font-medium text-gray-700 cursor-pointer mb-2">Variable Mapping Debug</summary>
|
||||||
|
<div class="space-y-3 text-xs">
|
||||||
|
<!-- Input Mappings -->
|
||||||
|
<div v-if="currentNode.data?.inputMappings?.length">
|
||||||
|
<p class="font-medium text-gray-700">Input Mappings (Process → Form):</p>
|
||||||
|
<pre class="text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(currentNode.data.inputMappings, null, 2) }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Output Mappings -->
|
||||||
|
<div v-if="currentNode.data?.outputMappings?.length">
|
||||||
|
<p class="font-medium text-gray-700">Output Mappings (Form → Process):</p>
|
||||||
|
<pre class="text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(currentNode.data.outputMappings, null, 2) }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Field Conditions -->
|
||||||
|
<div v-if="currentNode.data?.fieldConditions?.length">
|
||||||
|
<p class="font-medium text-gray-700">Field Conditions:</p>
|
||||||
|
<pre class="text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(currentNode.data.fieldConditions, null, 2) }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Field States -->
|
||||||
|
<div v-if="Object.keys(fieldStates).length">
|
||||||
|
<p class="font-medium text-gray-700">Active Field States:</p>
|
||||||
|
<pre class="text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(fieldStates, null, 2) }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Data -->
|
||||||
|
<div v-if="Object.keys(formData).length">
|
||||||
|
<p class="font-medium text-gray-700">Current Form Data:</p>
|
||||||
|
<pre class="text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(formData, null, 2) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Process Variables Debug (only in development) -->
|
<!-- Process Variables Debug (only in development) -->
|
||||||
<div v-if="Object.keys(processVariables).length > 0" class="bg-gray-100 rounded-lg p-4">
|
<div v-if="Object.keys(processVariables).length > 0" class="bg-gray-100 rounded-lg p-4 mt-4">
|
||||||
<details>
|
<details>
|
||||||
<summary class="font-medium text-gray-700 cursor-pointer mb-2">Process Variables (Debug)</summary>
|
<summary class="font-medium text-gray-700 cursor-pointer mb-2">Process Variables (Debug)</summary>
|
||||||
<pre class="text-xs text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(processVariables, null, 2) }}</pre>
|
<pre class="text-xs text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(processVariables, null, 2) }}</pre>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user