Add Form and Task Node Configuration Components
- Introduced new components, FormNodeConfiguration and TaskNodeConfiguration, for configuring form and task nodes within the process builder. - Enhanced the ProcessBuilder to support form and task node types, allowing users to define input/output mappings and assignment settings. - Implemented backend API endpoints for fetching active users and roles, improving user assignment functionality in task nodes. - Updated the ProcessFlowCanvas to handle updates for form and task nodes, ensuring consistent data management across node types. - Improved overall user experience by refining the UI for node configuration and enhancing variable handling in the process builder.
This commit is contained in:
parent
dce3e7f0f2
commit
c35073f7fe
@ -2,7 +2,7 @@
|
||||
<div class="api-node-configuration">
|
||||
<h3 class="text-lg font-semibold mb-4">API Call Configuration</h3>
|
||||
|
||||
<div class="form-group mb-4">
|
||||
<!-- <div class="form-group mb-4">
|
||||
<label for="nodeLabel" class="form-label">Node Label</label>
|
||||
<input
|
||||
id="nodeLabel"
|
||||
@ -10,10 +10,11 @@
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="API Call"
|
||||
@blur="saveChanges"
|
||||
/>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="form-group mb-4">
|
||||
<!-- <div class="form-group mb-4">
|
||||
<label for="nodeDescription" class="form-label">Description</label>
|
||||
<textarea
|
||||
id="nodeDescription"
|
||||
@ -21,8 +22,9 @@
|
||||
class="form-control"
|
||||
placeholder="API call description"
|
||||
rows="2"
|
||||
@blur="saveChanges"
|
||||
></textarea>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="form-group mb-4">
|
||||
<label for="apiMethod" class="form-label">HTTP Method</label>
|
||||
@ -30,6 +32,7 @@
|
||||
id="apiMethod"
|
||||
v-model="localNodeData.apiMethod"
|
||||
class="form-control"
|
||||
@change="saveChanges"
|
||||
>
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
@ -47,37 +50,112 @@
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="https://example.com/api/endpoint"
|
||||
@blur="saveChanges"
|
||||
/>
|
||||
<small class="form-text text-muted">
|
||||
You can use process variables with curly braces: https://example.com/api/users/{userId}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Variable Insertion for Request Body -->
|
||||
<div class="form-group mb-4" v-if="showRequestBody">
|
||||
<label for="requestBody" class="form-label">Request Body</label>
|
||||
<textarea
|
||||
id="requestBody"
|
||||
v-model="localNodeData.requestBody"
|
||||
class="form-control font-mono"
|
||||
placeholder='{ "key": "value" }'
|
||||
rows="4"
|
||||
></textarea>
|
||||
<small class="form-text text-muted">
|
||||
You can use process variables with curly braces: { "userId": "{userId}" }
|
||||
<div class="space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
class="form-control text-sm"
|
||||
@change="insertVariable($event.target.value, 'requestBody')"
|
||||
>
|
||||
<option value="">Insert Variable...</option>
|
||||
<option
|
||||
v-for="variable in availableVariables"
|
||||
:key="variable.name"
|
||||
:value="variable.name"
|
||||
>
|
||||
{{ variable.label }}
|
||||
</option>
|
||||
</select>
|
||||
<RsButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@click="formatJson('requestBody')"
|
||||
title="Format JSON"
|
||||
>
|
||||
<Icon name="material-symbols:format-align-left" />
|
||||
</RsButton>
|
||||
</div>
|
||||
<textarea
|
||||
id="requestBody"
|
||||
v-model="localNodeData.requestBody"
|
||||
class="form-control font-mono"
|
||||
placeholder='{ "key": "{variableName}" }'
|
||||
rows="6"
|
||||
@blur="saveChanges"
|
||||
></textarea>
|
||||
</div>
|
||||
<small class="form-text text-muted mt-1">
|
||||
Use variables in curly braces, e.g.: { "userId": "{userId}" }
|
||||
</small>
|
||||
|
||||
<!-- Request Body Preview -->
|
||||
<div v-if="localNodeData.requestBody" class="mt-3 border-t pt-3">
|
||||
<div class="text-sm font-medium text-gray-700 mb-2">Preview with Current Values:</div>
|
||||
<div class="bg-white border rounded p-3">
|
||||
<pre class="text-xs font-mono whitespace-pre-wrap">{{ getPreviewWithValues('requestBody') }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Variable Insertion for Headers -->
|
||||
<div class="form-group mb-4">
|
||||
<label for="headers" class="form-label">Headers</label>
|
||||
<textarea
|
||||
id="headers"
|
||||
v-model="localNodeData.headers"
|
||||
class="form-control font-mono"
|
||||
placeholder='{ "Content-Type": "application/json" }'
|
||||
rows="3"
|
||||
></textarea>
|
||||
<div class="space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
class="form-control text-sm"
|
||||
@change="insertVariable($event.target.value, 'headers')"
|
||||
>
|
||||
<option value="">Insert Variable...</option>
|
||||
<option
|
||||
v-for="variable in availableVariables"
|
||||
:key="variable.name"
|
||||
:value="variable.name"
|
||||
>
|
||||
{{ variable.label }}
|
||||
</option>
|
||||
</select>
|
||||
<RsButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@click="formatJson('headers')"
|
||||
title="Format JSON"
|
||||
>
|
||||
<Icon name="material-symbols:format-align-left" />
|
||||
</RsButton>
|
||||
</div>
|
||||
<textarea
|
||||
id="headers"
|
||||
v-model="localNodeData.headers"
|
||||
class="form-control font-mono"
|
||||
placeholder='{ "Authorization": "Bearer {accessToken}" }'
|
||||
rows="4"
|
||||
@blur="saveChanges"
|
||||
></textarea>
|
||||
</div>
|
||||
<small class="form-text text-muted mt-1">
|
||||
Use variables in curly braces, e.g.: { "Authorization": "Bearer {accessToken}" }
|
||||
</small>
|
||||
|
||||
<!-- Headers Preview -->
|
||||
<div v-if="localNodeData.headers" class="mt-3 border-t pt-3">
|
||||
<div class="text-sm font-medium text-gray-700 mb-2">Preview with Current Values:</div>
|
||||
<div class="bg-white border rounded p-3">
|
||||
<pre class="text-xs font-mono whitespace-pre-wrap">{{ getPreviewWithValues('headers') }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Output Variable Selection -->
|
||||
<div class="form-group mb-4">
|
||||
<label for="outputVariable" class="form-label">Output Variable</label>
|
||||
<div class="flex gap-2">
|
||||
@ -85,18 +163,17 @@
|
||||
id="outputVariable"
|
||||
v-model="localNodeData.outputVariable"
|
||||
class="form-control flex-grow"
|
||||
@change="saveChanges"
|
||||
>
|
||||
<option value="" disabled>Select a global variable</option>
|
||||
<option value="apiResponse">Create new: apiResponse</option>
|
||||
<optgroup label="Global Variables">
|
||||
<option
|
||||
v-for="variable in availableVariables.global"
|
||||
:key="variable.name"
|
||||
:value="variable.name"
|
||||
>
|
||||
{{ variable.description ? `${variable.name} (${variable.description})` : variable.name }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<option
|
||||
v-for="variable in availableVariables"
|
||||
:key="variable.name"
|
||||
:value="variable.name"
|
||||
>
|
||||
{{ variable.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
@click="createGlobalVariable(localNodeData.outputVariable)"
|
||||
@ -111,21 +188,7 @@
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-4">
|
||||
<label class="form-label d-block">Error Handling</label>
|
||||
<div class="form-check">
|
||||
<input
|
||||
id="continueOnError"
|
||||
v-model="localNodeData.continueOnError"
|
||||
type="checkbox"
|
||||
class="form-check-input"
|
||||
/>
|
||||
<label for="continueOnError" class="form-check-label">
|
||||
Continue process execution on error
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Variable Selection -->
|
||||
<div class="form-group mb-4">
|
||||
<label for="errorVariable" class="form-label">Error Variable</label>
|
||||
<div class="flex gap-2">
|
||||
@ -133,18 +196,17 @@
|
||||
id="errorVariable"
|
||||
v-model="localNodeData.errorVariable"
|
||||
class="form-control flex-grow"
|
||||
@change="saveChanges"
|
||||
>
|
||||
<option value="" disabled>Select a global variable</option>
|
||||
<option value="apiError">Create new: apiError</option>
|
||||
<optgroup label="Global Variables">
|
||||
<option
|
||||
v-for="variable in availableVariables.global"
|
||||
:key="variable.name"
|
||||
:value="variable.name"
|
||||
>
|
||||
{{ variable.description ? `${variable.name} (${variable.description})` : variable.name }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<option
|
||||
v-for="variable in availableVariables"
|
||||
:key="variable.name"
|
||||
:value="variable.name"
|
||||
>
|
||||
{{ variable.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
@click="createGlobalVariable(localNodeData.errorVariable, `API error from ${localNodeData.label}`)"
|
||||
@ -159,33 +221,82 @@
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Test API Call Button -->
|
||||
<div class="form-group mt-6">
|
||||
<RsButton @click="testApiCall" variant="primary" :disabled="!localNodeData.apiUrl">
|
||||
<Icon name="material-symbols:send" class="mr-1" />
|
||||
Test API Call
|
||||
</RsButton>
|
||||
<!-- Test API Call Button and Results -->
|
||||
<div class="form-group mt-6 space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<RsButton @click="testApiCall" variant="primary" :disabled="!localNodeData.apiUrl || isLoading">
|
||||
<Icon name="material-symbols:send" class="mr-1" />
|
||||
{{ isLoading ? 'Testing...' : 'Test API Call' }}
|
||||
</RsButton>
|
||||
<div v-if="isLoading" class="text-gray-600 text-sm flex items-center">
|
||||
<Icon name="material-symbols:sync" class="animate-spin mr-2" />
|
||||
Testing API endpoint...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Test Results -->
|
||||
<div v-if="testResult" :class="[
|
||||
'p-4 rounded-md border',
|
||||
testResult.success ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'
|
||||
]">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon
|
||||
:name="testResult.success ? 'material-symbols:check-circle' : 'material-symbols:error'"
|
||||
:class="testResult.success ? 'text-green-500' : 'text-red-500'"
|
||||
class="w-5 h-5 mr-2"
|
||||
/>
|
||||
<span :class="testResult.success ? 'text-green-700' : 'text-red-700'" class="font-medium">
|
||||
{{ testResult.success ? 'API Call Successful' : 'API Call Failed' }}
|
||||
</span>
|
||||
</div>
|
||||
<button @click="testResult = null" class="text-gray-400 hover:text-gray-600">
|
||||
<Icon name="material-symbols:close" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Success Response -->
|
||||
<div v-if="testResult.success" class="mt-3">
|
||||
<div class="text-sm text-gray-600 mb-2">Response stored in variable: {{ localNodeData.outputVariable }}</div>
|
||||
<div class="bg-white border border-green-100 rounded p-3">
|
||||
<pre class="text-xs font-mono whitespace-pre-wrap">{{ JSON.stringify(testResult.data, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Response -->
|
||||
<div v-else class="mt-3">
|
||||
<div class="text-sm text-red-600 mb-2">Error stored in variable: {{ localNodeData.errorVariable }}</div>
|
||||
<div class="bg-white border border-red-100 rounded p-3">
|
||||
<pre class="text-xs font-mono whitespace-pre-wrap text-red-600">{{ JSON.stringify(testResult.error, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { useProcessBuilderStore } from '@/stores/processBuilder';
|
||||
import { useVariableStore } from '@/stores/variableStore';
|
||||
|
||||
const props = defineProps({
|
||||
nodeId: {
|
||||
type: String,
|
||||
nodeData: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
availableVariables: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
// Get the stores
|
||||
const processStore = useProcessBuilderStore();
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
// Get the variable store for creating variables
|
||||
const variableStore = useVariableStore();
|
||||
|
||||
// Local state for node data
|
||||
// Local state for node data - create a deep copy to avoid mutation issues
|
||||
const localNodeData = ref({
|
||||
label: 'API Call',
|
||||
description: '',
|
||||
@ -205,100 +316,97 @@ const showRequestBody = computed(() => {
|
||||
|
||||
// Get available variables for dropdowns
|
||||
const availableVariables = computed(() => {
|
||||
const globalVars = variableStore.getAllVariables.global.map(v => ({
|
||||
// Only use global variables, matching VariableManager implementation
|
||||
const globalVars = props.availableVariables?.map(v => ({
|
||||
name: v.name || 'unnamed',
|
||||
label: v?.description
|
||||
? `${v.description} (${v.name || 'unnamed'}, global)`
|
||||
: `${v.name || 'unnamed'} (global)`,
|
||||
? `${v.name} (${v.description})`
|
||||
: v.name,
|
||||
type: v.type || 'string',
|
||||
scope: 'global'
|
||||
}));
|
||||
|
||||
return {
|
||||
global: globalVars
|
||||
};
|
||||
value: v.value
|
||||
})) || [];
|
||||
|
||||
return globalVars;
|
||||
});
|
||||
|
||||
// Load node data when component mounts or nodeId changes
|
||||
watch(() => props.nodeId, () => {
|
||||
loadNodeData();
|
||||
}, { immediate: true });
|
||||
|
||||
// Watch for changes in local data and emit updates
|
||||
watch(localNodeData, (newValue) => {
|
||||
saveChanges();
|
||||
}, { deep: true });
|
||||
|
||||
// Load node data from the store
|
||||
function loadNodeData() {
|
||||
const node = processStore.currentProcess.nodes.find(n => n.id === props.nodeId);
|
||||
if (node && node.data) {
|
||||
// Watch for changes from parent props
|
||||
watch(() => props.nodeData, (newNodeData) => {
|
||||
if (newNodeData) {
|
||||
// Create a deep copy to break reactivity chains with parent
|
||||
localNodeData.value = {
|
||||
label: node.label || 'API Call',
|
||||
description: node.data.description || '',
|
||||
apiMethod: node.data.apiMethod || 'GET',
|
||||
apiUrl: node.data.apiUrl || '',
|
||||
requestBody: node.data.requestBody || '',
|
||||
headers: node.data.headers || '{ "Content-Type": "application/json" }',
|
||||
outputVariable: node.data.outputVariable || 'apiResponse',
|
||||
continueOnError: node.data.continueOnError || false,
|
||||
errorVariable: node.data.errorVariable || 'apiError'
|
||||
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 });
|
||||
|
||||
// Function to create a new global variable
|
||||
function createGlobalVariable(name, description = '') {
|
||||
variableStore.addVariable({
|
||||
if (!name) return;
|
||||
|
||||
const newVariable = {
|
||||
name,
|
||||
type: 'object',
|
||||
scope: 'global',
|
||||
value: null,
|
||||
description: description || `API response from ${localNodeData.value.label}`
|
||||
};
|
||||
|
||||
// Add the variable using the store's addVariable method
|
||||
variableStore.addVariable(newVariable);
|
||||
|
||||
// Force a refresh of the component
|
||||
nextTick(() => {
|
||||
saveChanges();
|
||||
});
|
||||
}
|
||||
|
||||
// Save changes to the store
|
||||
// Save changes by emitting them to parent
|
||||
function saveChanges() {
|
||||
const updates = {
|
||||
label: localNodeData.value.label,
|
||||
data: {
|
||||
...localNodeData.value,
|
||||
label: localNodeData.value.label // Ensure label is in both places
|
||||
}
|
||||
};
|
||||
|
||||
// Update the node in the store
|
||||
processStore.updateNode(props.nodeId, updates);
|
||||
// Create a clean copy of the data to avoid reactivity issues
|
||||
const nodeDataCopy = JSON.parse(JSON.stringify(localNodeData.value));
|
||||
|
||||
// Ensure variables exist
|
||||
if (localNodeData.value.outputVariable) {
|
||||
// Ensure variables exist before saving
|
||||
if (nodeDataCopy.outputVariable) {
|
||||
variableStore.addVariableIfNotExists({
|
||||
name: localNodeData.value.outputVariable,
|
||||
name: nodeDataCopy.outputVariable,
|
||||
type: 'object',
|
||||
scope: 'global',
|
||||
value: null,
|
||||
description: `API response from ${localNodeData.value.label}`
|
||||
description: `API response from ${nodeDataCopy.label}`
|
||||
});
|
||||
}
|
||||
|
||||
if (localNodeData.value.errorVariable) {
|
||||
if (nodeDataCopy.errorVariable) {
|
||||
variableStore.addVariableIfNotExists({
|
||||
name: localNodeData.value.errorVariable,
|
||||
name: nodeDataCopy.errorVariable,
|
||||
type: 'object',
|
||||
scope: 'global',
|
||||
value: null,
|
||||
description: `API error from ${localNodeData.value.label}`
|
||||
description: `API error from ${nodeDataCopy.label}`
|
||||
});
|
||||
}
|
||||
|
||||
// Emit the updated data to parent
|
||||
emit('update', nodeDataCopy);
|
||||
}
|
||||
|
||||
// Test API call function
|
||||
// Add these refs for handling the API test state
|
||||
const isLoading = ref(false);
|
||||
const testResult = ref(null);
|
||||
|
||||
// Update the testApiCall function
|
||||
async function testApiCall() {
|
||||
if (!localNodeData.value.apiUrl) return;
|
||||
|
||||
const isLoading = ref(true);
|
||||
const testResult = ref(null);
|
||||
isLoading.value = true;
|
||||
testResult.value = null;
|
||||
|
||||
try {
|
||||
// Get process variables for substitution
|
||||
@ -307,14 +415,12 @@ async function testApiCall() {
|
||||
process: {}
|
||||
};
|
||||
|
||||
// Extract variables from the store
|
||||
const allVars = variableStore.getAllVariables;
|
||||
allVars.process.forEach(v => {
|
||||
processVariables.process[v.name] = v.value;
|
||||
});
|
||||
allVars.global.forEach(v => {
|
||||
processVariables.global[v.name] = v.value;
|
||||
});
|
||||
// Extract variables from available variables
|
||||
if (props.availableVariables) {
|
||||
props.availableVariables.forEach(v => {
|
||||
processVariables.global[v.name] = v.value;
|
||||
});
|
||||
}
|
||||
|
||||
// Call the test API endpoint
|
||||
const response = await fetch('/api/process/test-api-node', {
|
||||
@ -327,7 +433,7 @@ async function testApiCall() {
|
||||
apiMethod: localNodeData.value.apiMethod,
|
||||
apiUrl: localNodeData.value.apiUrl,
|
||||
requestBody: localNodeData.value.requestBody,
|
||||
headers: localNodeData.value.headers,
|
||||
headers: JSON.parse(localNodeData.value.headers || '{}'),
|
||||
outputVariable: localNodeData.value.outputVariable,
|
||||
errorVariable: localNodeData.value.errorVariable,
|
||||
continueOnError: localNodeData.value.continueOnError
|
||||
@ -337,37 +443,124 @@ async function testApiCall() {
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
testResult.value = result;
|
||||
|
||||
if (result.success && localNodeData.value.outputVariable) {
|
||||
// Store the test result
|
||||
testResult.value = {
|
||||
success: response.ok && !result.error,
|
||||
data: result.data,
|
||||
error: result.error || (response.ok ? null : { message: 'API request failed' })
|
||||
};
|
||||
|
||||
// Update variables in the store
|
||||
if (testResult.value.success && localNodeData.value.outputVariable) {
|
||||
variableStore.updateVariable(
|
||||
localNodeData.value.outputVariable,
|
||||
{ value: result.data },
|
||||
'global'
|
||||
);
|
||||
} else if (!result.success && localNodeData.value.errorVariable) {
|
||||
} else if (!testResult.value.success && localNodeData.value.errorVariable) {
|
||||
variableStore.updateVariable(
|
||||
localNodeData.value.errorVariable,
|
||||
{ value: result.error },
|
||||
{ value: testResult.value.error },
|
||||
'global'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API test error:', error);
|
||||
testResult.value = {
|
||||
success: false,
|
||||
error: {
|
||||
message: error.message || 'An error occurred while testing the API call'
|
||||
message: error.message || 'An error occurred while testing the API call',
|
||||
details: error.toString()
|
||||
}
|
||||
};
|
||||
|
||||
// Store error in variable
|
||||
if (localNodeData.value.errorVariable) {
|
||||
variableStore.updateVariable(
|
||||
localNodeData.value.errorVariable,
|
||||
{ value: testResult.value.error },
|
||||
'global'
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to insert a variable at cursor position or append to end
|
||||
function insertVariable(variableName, field) {
|
||||
if (!variableName) return;
|
||||
|
||||
const varName = `{${variableName}}`;
|
||||
const textarea = document.getElementById(field);
|
||||
|
||||
if (textarea.selectionStart || textarea.selectionStart === 0) {
|
||||
const startPos = textarea.selectionStart;
|
||||
const endPos = textarea.selectionEnd;
|
||||
|
||||
localNodeData.value[field] =
|
||||
textarea.value.substring(0, startPos) +
|
||||
varName +
|
||||
textarea.value.substring(endPos);
|
||||
|
||||
// Reset cursor position after variable
|
||||
textarea.selectionStart = startPos + varName.length;
|
||||
textarea.selectionEnd = startPos + varName.length;
|
||||
} else {
|
||||
localNodeData.value[field] += varName;
|
||||
}
|
||||
|
||||
saveChanges();
|
||||
}
|
||||
|
||||
// Function to format JSON in a field
|
||||
function formatJson(field) {
|
||||
try {
|
||||
const value = localNodeData.value[field];
|
||||
if (!value) return;
|
||||
|
||||
const parsed = JSON.parse(value);
|
||||
localNodeData.value[field] = JSON.stringify(parsed, null, 2);
|
||||
saveChanges();
|
||||
} catch (error) {
|
||||
console.warn(`Failed to format JSON for ${field}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Function to preview values with actual variable values
|
||||
function getPreviewWithValues(field) {
|
||||
try {
|
||||
let text = localNodeData.value[field];
|
||||
if (!text) return '';
|
||||
|
||||
// Replace all variables with their current values
|
||||
const variablePattern = /{([^}]+)}/g;
|
||||
text = text.replace(variablePattern, (match, varName) => {
|
||||
const variable = availableVariables.value.find(v => v.name === varName);
|
||||
if (variable) {
|
||||
return JSON.stringify(variable.value) || '(undefined)';
|
||||
}
|
||||
return '(undefined)';
|
||||
});
|
||||
|
||||
// Try to format as JSON if possible
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
} catch (error) {
|
||||
return '(Invalid format)';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.api-node-configuration {
|
||||
padding: 1rem;
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
|
382
components/process-flow/FormNodeConfiguration.vue
Normal file
382
components/process-flow/FormNodeConfiguration.vue
Normal file
@ -0,0 +1,382 @@
|
||||
<template>
|
||||
<div class="form-node-configuration">
|
||||
<h3 class="text-lg font-semibold mb-4">Form Task Configuration</h3>
|
||||
|
||||
<!-- <div class="form-group mb-4">
|
||||
<label for="nodeLabel" class="form-label">Node Label</label>
|
||||
<input
|
||||
id="nodeLabel"
|
||||
v-model="localNodeData.label"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Form Task"
|
||||
@blur="saveChanges"
|
||||
/>
|
||||
</div> -->
|
||||
|
||||
<!-- <div class="form-group mb-4">
|
||||
<label for="nodeDescription" class="form-label">Description</label>
|
||||
<textarea
|
||||
id="nodeDescription"
|
||||
v-model="localNodeData.description"
|
||||
class="form-control"
|
||||
placeholder="Form task description"
|
||||
rows="2"
|
||||
@blur="saveChanges"
|
||||
></textarea>
|
||||
</div> -->
|
||||
|
||||
<!-- Form Selection -->
|
||||
<div class="form-group mb-4">
|
||||
<FormSelector
|
||||
:formId="localNodeData.formId"
|
||||
@select="handleFormSelection"
|
||||
@clear="clearFormSelection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Form Data Mapping Section -->
|
||||
<div v-if="localNodeData.formId" class="form-group mb-6">
|
||||
<div class="border-t border-gray-200 my-4 pt-4">
|
||||
<h4 class="text-base font-medium mb-4">Form Data Mapping</h4>
|
||||
|
||||
<!-- Input Variables -->
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<label class="form-label">Input Variables (Prefill Form)</label>
|
||||
<button
|
||||
@click="addInputMapping()"
|
||||
class="px-2 py-1 bg-blue-500 text-white text-xs rounded hover:bg-blue-600 flex items-center"
|
||||
>
|
||||
<Icon name="material-symbols:add" class="w-3.5 h-3.5 mr-1" />
|
||||
Add Mapping
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!localNodeData.inputMappings || localNodeData.inputMappings.length === 0" class="text-sm text-gray-500 italic mb-2">
|
||||
No input mappings defined. Form will not be prefilled with process data.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="(mapping, index) in localNodeData.inputMappings" :key="'input-' + index" class="p-3 border rounded-md bg-blue-50">
|
||||
<div class="flex justify-between mb-2">
|
||||
<h5 class="text-sm font-medium">Mapping #{{ index + 1 }}</h5>
|
||||
<button @click="removeInputMapping(index)" class="text-red-500 hover:text-red-700">
|
||||
<Icon name="material-symbols:delete-outline" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs mb-1">Process Variable</label>
|
||||
<select
|
||||
v-model="mapping.processVariable"
|
||||
class="w-full p-2 border rounded text-sm"
|
||||
@change="saveChanges"
|
||||
>
|
||||
<option value="" disabled>Select a variable</option>
|
||||
<option
|
||||
v-for="variable in availableVariables"
|
||||
:key="variable.name"
|
||||
:value="variable.name"
|
||||
>
|
||||
{{ variable.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs mb-1">Form Field</label>
|
||||
<input
|
||||
v-model="mapping.formField"
|
||||
type="text"
|
||||
class="w-full p-2 border rounded text-sm"
|
||||
placeholder="form_field_name"
|
||||
@blur="saveChanges"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Output Variables -->
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<label class="form-label">Output Variables (Form Submissions)</label>
|
||||
<button
|
||||
@click="addOutputMapping()"
|
||||
class="px-2 py-1 bg-blue-500 text-white text-xs rounded hover:bg-blue-600 flex items-center"
|
||||
>
|
||||
<Icon name="material-symbols:add" class="w-3.5 h-3.5 mr-1" />
|
||||
Add Mapping
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!localNodeData.outputMappings || localNodeData.outputMappings.length === 0" class="text-sm text-gray-500 italic mb-2">
|
||||
No output mappings defined. Form data will not be stored in process variables.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="(mapping, index) in localNodeData.outputMappings" :key="'output-' + index" class="p-3 border rounded-md bg-green-50">
|
||||
<div class="flex justify-between mb-2">
|
||||
<h5 class="text-sm font-medium">Mapping #{{ index + 1 }}</h5>
|
||||
<button @click="removeOutputMapping(index)" class="text-red-500 hover:text-red-700">
|
||||
<Icon name="material-symbols:delete-outline" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs mb-1">Form Field</label>
|
||||
<input
|
||||
v-model="mapping.formField"
|
||||
type="text"
|
||||
class="w-full p-2 border rounded text-sm"
|
||||
placeholder="form_field_name"
|
||||
@blur="saveChanges"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs mb-1">Process Variable</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
v-model="mapping.processVariable"
|
||||
class="w-full p-2 border rounded text-sm"
|
||||
@change="saveChanges"
|
||||
>
|
||||
<option value="" disabled>Select a variable</option>
|
||||
<option :value="'create_new_' + getVariableNameFromFormField(mapping.formField)">
|
||||
Create new variable
|
||||
</option>
|
||||
<option
|
||||
v-for="variable in availableVariables"
|
||||
:key="variable.name"
|
||||
:value="variable.name"
|
||||
>
|
||||
{{ variable.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
v-if="mapping.processVariable && mapping.processVariable.startsWith('create_new_')"
|
||||
@click="createVariableFromMapping(mapping)"
|
||||
class="shrink-0 px-2 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
title="Create this variable"
|
||||
>
|
||||
<Icon name="material-symbols:add" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { useVariableStore } from '@/stores/variableStore';
|
||||
import FormSelector from './FormSelector.vue';
|
||||
|
||||
const props = defineProps({
|
||||
nodeData: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
availableVariables: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
// Get the variable store for creating variables
|
||||
const variableStore = useVariableStore();
|
||||
|
||||
// Local state for node data - create a deep copy to avoid mutation issues
|
||||
const localNodeData = ref({
|
||||
label: 'Form Task',
|
||||
description: '',
|
||||
formId: null,
|
||||
formName: '',
|
||||
formUuid: null,
|
||||
inputMappings: [],
|
||||
outputMappings: []
|
||||
});
|
||||
|
||||
// Watch for changes from parent props
|
||||
watch(() => props.nodeData, (newNodeData) => {
|
||||
if (newNodeData) {
|
||||
// Create a deep copy to break reactivity chains with parent
|
||||
localNodeData.value = {
|
||||
label: newNodeData.label || 'Form Task',
|
||||
description: newNodeData.description || '',
|
||||
formId: newNodeData.formId || null,
|
||||
formName: newNodeData.formName || '',
|
||||
formUuid: newNodeData.formUuid || null,
|
||||
inputMappings: Array.isArray(newNodeData.inputMappings)
|
||||
? [...newNodeData.inputMappings]
|
||||
: [],
|
||||
outputMappings: Array.isArray(newNodeData.outputMappings)
|
||||
? [...newNodeData.outputMappings]
|
||||
: []
|
||||
};
|
||||
}
|
||||
}, { immediate: true, deep: true });
|
||||
|
||||
// Function to handle form selection
|
||||
function handleFormSelection(form) {
|
||||
if (!form) return;
|
||||
|
||||
localNodeData.value = {
|
||||
...localNodeData.value,
|
||||
formId: form.formID,
|
||||
formName: form.formName,
|
||||
formUuid: form.formUUID,
|
||||
label: form.formName || 'Form Task',
|
||||
description: `Form: ${form.formName}`
|
||||
};
|
||||
|
||||
saveChanges();
|
||||
}
|
||||
|
||||
// Function to clear form selection
|
||||
function clearFormSelection() {
|
||||
localNodeData.value = {
|
||||
...localNodeData.value,
|
||||
formId: null,
|
||||
formName: '',
|
||||
formUuid: null,
|
||||
label: 'Form Task',
|
||||
description: 'Form submission task',
|
||||
inputMappings: [],
|
||||
outputMappings: []
|
||||
};
|
||||
|
||||
saveChanges();
|
||||
}
|
||||
|
||||
// Add an input mapping
|
||||
function addInputMapping() {
|
||||
if (!localNodeData.value.inputMappings) {
|
||||
localNodeData.value.inputMappings = [];
|
||||
}
|
||||
|
||||
localNodeData.value.inputMappings.push({
|
||||
processVariable: '',
|
||||
formField: ''
|
||||
});
|
||||
|
||||
saveChanges();
|
||||
}
|
||||
|
||||
// Remove an input mapping
|
||||
function removeInputMapping(index) {
|
||||
localNodeData.value.inputMappings.splice(index, 1);
|
||||
saveChanges();
|
||||
}
|
||||
|
||||
// Add an output mapping
|
||||
function addOutputMapping() {
|
||||
if (!localNodeData.value.outputMappings) {
|
||||
localNodeData.value.outputMappings = [];
|
||||
}
|
||||
|
||||
localNodeData.value.outputMappings.push({
|
||||
formField: '',
|
||||
processVariable: ''
|
||||
});
|
||||
|
||||
saveChanges();
|
||||
}
|
||||
|
||||
// Remove an output mapping
|
||||
function removeOutputMapping(index) {
|
||||
localNodeData.value.outputMappings.splice(index, 1);
|
||||
saveChanges();
|
||||
}
|
||||
|
||||
// Generate a variable name from a form field
|
||||
function getVariableNameFromFormField(formField) {
|
||||
if (!formField) return 'form_data';
|
||||
|
||||
// Convert the form field to a valid variable name
|
||||
// Remove spaces, special characters, and convert to camelCase
|
||||
return formField.replace(/[^a-zA-Z0-9_]/g, '_')
|
||||
.replace(/^([A-Z])/, (match) => match.toLowerCase())
|
||||
.replace(/[\s_]+(\w)/g, (_, c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
// Create a variable from a mapping
|
||||
function createVariableFromMapping(mapping) {
|
||||
if (!mapping.processVariable || !mapping.processVariable.startsWith('create_new_')) return;
|
||||
|
||||
const variableName = mapping.processVariable.replace('create_new_', '');
|
||||
if (!variableName) return;
|
||||
|
||||
// Create the variable
|
||||
const newVariable = {
|
||||
name: variableName,
|
||||
type: 'string',
|
||||
scope: 'global',
|
||||
description: `Form data from ${mapping.formField} in ${localNodeData.value.formName || 'form'}`
|
||||
};
|
||||
|
||||
// Add the variable
|
||||
variableStore.addVariable(newVariable);
|
||||
|
||||
// Update the mapping to use the new variable
|
||||
mapping.processVariable = variableName;
|
||||
|
||||
// Save changes
|
||||
nextTick(() => {
|
||||
saveChanges();
|
||||
});
|
||||
}
|
||||
|
||||
// Save changes by emitting them to parent
|
||||
function saveChanges() {
|
||||
// Create a clean copy of the data to avoid reactivity issues
|
||||
const nodeDataCopy = JSON.parse(JSON.stringify(localNodeData.value));
|
||||
|
||||
// Emit the updated data to parent
|
||||
emit('update', nodeDataCopy);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-node-configuration {
|
||||
padding: 1rem;
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.form-text {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
@ -311,48 +311,52 @@ const onDragOver = (event) => {
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
};
|
||||
|
||||
// Add a method to update a node in the flow
|
||||
const updateNode = (nodeId, updates) => {
|
||||
if (!nodeId) return;
|
||||
// Define methods to expose to parent components
|
||||
defineExpose({
|
||||
updateNode,
|
||||
addNode,
|
||||
removeNode,
|
||||
fitView
|
||||
});
|
||||
|
||||
// Update an existing node
|
||||
function updateNode(nodeId, newData) {
|
||||
const nodeToUpdate = nodes.value.find(node => node.id === nodeId);
|
||||
if (!nodeToUpdate) return;
|
||||
|
||||
// console.log('ProcessFlowCanvas: Updating node:', nodeId, updates);
|
||||
|
||||
// Find the node in Vue Flow nodes
|
||||
const nodeIndex = nodes.value.findIndex(n => n.id === nodeId);
|
||||
if (nodeIndex === -1) {
|
||||
console.warn(`Node with ID ${nodeId} not found in flow`);
|
||||
return;
|
||||
// Update the node properties
|
||||
if (newData.label) {
|
||||
nodeToUpdate.label = newData.label;
|
||||
}
|
||||
|
||||
// Update the node with new values
|
||||
const node = nodes.value[nodeIndex];
|
||||
// Update the node data
|
||||
if (newData.data) {
|
||||
nodeToUpdate.data = {
|
||||
...nodeToUpdate.data,
|
||||
...newData.data
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure label is consistently set in both places
|
||||
const updatedLabel = updates.label || node.label;
|
||||
const updatedData = {
|
||||
...node.data,
|
||||
...(updates.data || {}),
|
||||
label: updatedLabel // Ensure label is also in data
|
||||
};
|
||||
// Update node internals to trigger re-render
|
||||
updateNodeInternals([nodeId]);
|
||||
|
||||
// Update the node directly to avoid triggering watchers unnecessarily
|
||||
Object.assign(nodes.value[nodeIndex], {
|
||||
label: updatedLabel,
|
||||
data: updatedData
|
||||
});
|
||||
|
||||
// Notify Vue Flow to update the node's internals
|
||||
updateNodeInternals(nodeId);
|
||||
|
||||
// console.log('Node updated:', updatedData);
|
||||
|
||||
return updatedData;
|
||||
};
|
||||
return nodeToUpdate;
|
||||
}
|
||||
|
||||
// Expose methods to parent components
|
||||
defineExpose({
|
||||
updateNode
|
||||
});
|
||||
// Add a new node to the canvas
|
||||
function addNode(node) {
|
||||
addNodes([node]);
|
||||
return node;
|
||||
}
|
||||
|
||||
// Remove a node from the canvas
|
||||
function removeNode(nodeId) {
|
||||
const nodeToRemove = nodes.value.find(node => node.id === nodeId);
|
||||
if (!nodeToRemove) return;
|
||||
|
||||
removeNodes([nodeToRemove]);
|
||||
return nodeToRemove;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -507,6 +511,7 @@ defineExpose({
|
||||
|
||||
:deep(.node-gateway .custom-node-icon .material-icons) {
|
||||
color: #FF9800;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
:deep(.node-script .custom-node-icon .material-icons) {
|
||||
|
@ -73,9 +73,50 @@ export const TaskNode = markRaw({
|
||||
nodeLabel() {
|
||||
// Get label from either prop or data, with fallback
|
||||
return this.label || (this.data && this.data.label) || 'Task';
|
||||
},
|
||||
|
||||
// Helper method to get assignment display text
|
||||
assignmentText() {
|
||||
if (!this.data) return 'Unassigned';
|
||||
|
||||
const { assignmentType, assignedUsers, assignedRoles, assigneeVariable } = this.data;
|
||||
|
||||
if (assignmentType === 'user' && Array.isArray(assignedUsers) && assignedUsers.length > 0) {
|
||||
return `${assignedUsers.length} User${assignedUsers.length > 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
if (assignmentType === 'role' && Array.isArray(assignedRoles) && assignedRoles.length > 0) {
|
||||
return `${assignedRoles.length} Role${assignedRoles.length > 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
if (assignmentType === 'variable' && assigneeVariable) {
|
||||
return `Variable: ${assigneeVariable}`;
|
||||
}
|
||||
|
||||
return 'Unassigned';
|
||||
},
|
||||
|
||||
// Helper to determine priority class
|
||||
priorityClass() {
|
||||
if (!this.data || !this.data.priority) return '';
|
||||
|
||||
const priorityColors = {
|
||||
low: 'text-green-500',
|
||||
medium: 'text-blue-500',
|
||||
high: 'text-orange-500',
|
||||
urgent: 'text-red-500'
|
||||
};
|
||||
|
||||
return priorityColors[this.data.priority] || '';
|
||||
}
|
||||
},
|
||||
render() {
|
||||
const badgeContent = this.data?.priority ?
|
||||
h('span', {
|
||||
class: `node-badge px-1 text-xs rounded ${this.priorityClass} bg-gray-100`
|
||||
}, this.data.priority.charAt(0).toUpperCase() + this.data.priority.slice(1)) :
|
||||
null;
|
||||
|
||||
return h(CustomNode, {
|
||||
id: this.id,
|
||||
type: 'task',
|
||||
@ -85,12 +126,22 @@ export const TaskNode = markRaw({
|
||||
onClick: () => this.$emit('node-click', this.id)
|
||||
}, {
|
||||
icon: () => h('i', { class: 'material-icons text-blue-500' }, 'assignment'),
|
||||
badge: () => badgeContent,
|
||||
default: () => h('div', { class: 'node-details' }, [
|
||||
h('p', { class: 'node-description' }, this.data?.description || 'A general task'),
|
||||
h('div', { class: 'node-assignee' }, [
|
||||
h('span', { class: 'node-assignee-label' }, 'Assigned to:'),
|
||||
h('span', { class: 'node-assignee-value' }, this.data?.assignee || 'Unassigned')
|
||||
])
|
||||
h('div', { class: 'node-assignee flex items-center justify-between text-xs' }, [
|
||||
h('span', { class: 'node-assignee-label' }, 'Assigned:'),
|
||||
h('span', { class: 'node-assignee-value ml-1 font-medium text-blue-600' }, this.assignmentText)
|
||||
]),
|
||||
this.data?.dueDateType !== 'none' && this.data?.dueDateType ?
|
||||
h('div', { class: 'node-due-date text-xs mt-1' }, [
|
||||
h('span', { class: 'node-due-date-label' }, 'Due:'),
|
||||
h('span', { class: 'node-due-date-value ml-1' },
|
||||
this.data.dueDateType === 'fixed' ?
|
||||
`${this.data.dueDateDays || 0} days` :
|
||||
`Variable: ${this.data.dueDateVariable || 'none'}`
|
||||
)
|
||||
]) : null
|
||||
])
|
||||
});
|
||||
}
|
||||
@ -178,7 +229,7 @@ export const GatewayNode = markRaw({
|
||||
render() {
|
||||
// Create the badge content
|
||||
const badgeContent = h('span', {
|
||||
class: 'node-badge bg-orange-100 text-orange-600 px-1 text-xs rounded absolute -top-5 left-1/2 transform -translate-x-1/2 whitespace-nowrap'
|
||||
class: 'node-badge bg-orange-100 text-orange-600 px-1 text-xs rounded'
|
||||
}, `${this.totalPaths} path${this.totalPaths !== 1 ? 's' : ''}`);
|
||||
|
||||
return h(CustomNode, {
|
||||
@ -192,7 +243,11 @@ export const GatewayNode = markRaw({
|
||||
icon: () => h('i', { class: 'material-icons text-orange-500' }, 'call_split'),
|
||||
badge: () => badgeContent,
|
||||
default: () => h('div', { class: 'gateway-details' }, [
|
||||
h('div', { class: 'node-conditions-value' }, this.conditionSummary)
|
||||
h('p', { class: 'node-description' }, this.data?.description || 'Decision based on conditions'),
|
||||
h('div', { class: 'node-conditions flex items-center justify-between text-xs' }, [
|
||||
h('span', { class: 'node-conditions-label' }, 'Paths:'),
|
||||
h('span', { class: 'node-conditions-value ml-1 font-medium text-orange-600' }, this.conditionSummary)
|
||||
])
|
||||
])
|
||||
});
|
||||
}
|
||||
@ -402,8 +457,8 @@ export const nodeStyles = `
|
||||
.node-gateway .custom-node-content {
|
||||
position: absolute;
|
||||
transform: rotate(-45deg);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 120%;
|
||||
height: 120%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@ -627,22 +682,40 @@ export const nodeStyles = `
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.node-gateway .node-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) rotate(-45deg);
|
||||
background-color: #fff7ed;
|
||||
border: 1px solid #fdba74;
|
||||
z-index: 10;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
.node-gateway .node-description {
|
||||
text-align: center;
|
||||
margin-bottom: 4px;
|
||||
font-size: 10px;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.node-gateway .node-conditions {
|
||||
display: flex;
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.node-gateway .node-conditions-label {
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.node-gateway .node-conditions-value {
|
||||
white-space: nowrap;
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 10px;
|
||||
max-width: 80px;
|
||||
}
|
||||
|
||||
.node-gateway .material-icons {
|
||||
font-size: 24px;
|
||||
font-size: 18px;
|
||||
color: #f97316;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
391
components/process-flow/TaskNodeConfiguration.vue
Normal file
391
components/process-flow/TaskNodeConfiguration.vue
Normal file
@ -0,0 +1,391 @@
|
||||
<template>
|
||||
<div class="task-node-configuration">
|
||||
<h3 class="text-lg font-semibold mb-4">Task Configuration</h3>
|
||||
|
||||
<!-- <div class="form-group mb-4">
|
||||
<label for="nodeLabel" class="form-label">Task Name</label>
|
||||
<input
|
||||
id="nodeLabel"
|
||||
v-model="localNodeData.label"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Task Name"
|
||||
@blur="saveChanges"
|
||||
/>
|
||||
</div> -->
|
||||
|
||||
<!-- <div class="form-group mb-4">
|
||||
<label for="nodeDescription" class="form-label">Description</label>
|
||||
<textarea
|
||||
id="nodeDescription"
|
||||
v-model="localNodeData.description"
|
||||
class="form-control"
|
||||
placeholder="Task description"
|
||||
rows="2"
|
||||
@blur="saveChanges"
|
||||
></textarea>
|
||||
</div> -->
|
||||
|
||||
<!-- Assignment Type Selector -->
|
||||
<div class="form-group mb-4">
|
||||
<label class="form-label">Assignment Type</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="localNodeData.assignmentType"
|
||||
value="user"
|
||||
class="form-radio"
|
||||
@change="saveChanges"
|
||||
/>
|
||||
<span class="ml-2">User</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="localNodeData.assignmentType"
|
||||
value="role"
|
||||
class="form-radio"
|
||||
@change="saveChanges"
|
||||
/>
|
||||
<span class="ml-2">Role</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="localNodeData.assignmentType"
|
||||
value="variable"
|
||||
class="form-radio"
|
||||
@change="saveChanges"
|
||||
/>
|
||||
<span class="ml-2">Process Variable</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Assignment -->
|
||||
<div v-if="localNodeData.assignmentType === 'user'" class="form-group mb-4">
|
||||
<label class="form-label">Assign to Users</label>
|
||||
<div v-if="loading.users" class="py-2 text-gray-500 text-sm">
|
||||
Loading users...
|
||||
</div>
|
||||
<div v-else-if="users.length === 0" class="py-2 text-gray-500 text-sm">
|
||||
No users available
|
||||
</div>
|
||||
<div v-else class="max-h-60 overflow-y-auto border rounded-md divide-y">
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="user.userID"
|
||||
class="p-2 hover:bg-gray-50 flex items-center justify-between"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
:id="`user-${user.userID}`"
|
||||
:value="user.userID"
|
||||
v-model="localNodeData.assignedUsers"
|
||||
class="form-checkbox"
|
||||
@change="saveChanges"
|
||||
/>
|
||||
<label :for="`user-${user.userID}`" class="ml-2 cursor-pointer flex-1">
|
||||
<div class="font-medium text-sm">{{ user.userFullName || user.userUsername }}</div>
|
||||
<div class="text-xs text-gray-500">{{ user.userEmail }}</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Role Assignment -->
|
||||
<div v-if="localNodeData.assignmentType === 'role'" class="form-group mb-4">
|
||||
<label class="form-label">Assign to Roles</label>
|
||||
<div v-if="loading.roles" class="py-2 text-gray-500 text-sm">
|
||||
Loading roles...
|
||||
</div>
|
||||
<div v-else-if="roles.length === 0" class="py-2 text-gray-500 text-sm">
|
||||
No roles available
|
||||
</div>
|
||||
<div v-else class="max-h-60 overflow-y-auto border rounded-md divide-y">
|
||||
<div
|
||||
v-for="role in roles"
|
||||
:key="role.roleID"
|
||||
class="p-2 hover:bg-gray-50 flex items-center justify-between"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
:id="`role-${role.roleID}`"
|
||||
:value="role.roleID"
|
||||
v-model="localNodeData.assignedRoles"
|
||||
class="form-checkbox"
|
||||
@change="saveChanges"
|
||||
/>
|
||||
<label :for="`role-${role.roleID}`" class="ml-2 cursor-pointer flex-1">
|
||||
<div class="font-medium text-sm">{{ role.roleName }}</div>
|
||||
<div class="text-xs text-gray-500">{{ role.roleDescription }}</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Variable Assignment -->
|
||||
<div v-if="localNodeData.assignmentType === 'variable'" class="form-group mb-4">
|
||||
<label class="form-label">Assignee Variable</label>
|
||||
<select
|
||||
v-model="localNodeData.assigneeVariable"
|
||||
class="form-control"
|
||||
@change="saveChanges"
|
||||
>
|
||||
<option value="" disabled>Select a variable</option>
|
||||
<option
|
||||
v-for="variable in availableVariables"
|
||||
:key="variable.name"
|
||||
:value="variable.name"
|
||||
>
|
||||
{{ variable.label }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="text-gray-500 text-xs mt-1 block">
|
||||
The selected variable should contain a user ID or a role ID.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Additional Task Settings -->
|
||||
<div class="border-t border-gray-200 my-4 pt-4">
|
||||
<h4 class="text-base font-medium mb-2">Additional Settings</h4>
|
||||
|
||||
<!-- Due Date -->
|
||||
<div class="form-group mb-4">
|
||||
<label class="form-label">Due Date</label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<select
|
||||
v-model="localNodeData.dueDateType"
|
||||
class="form-control"
|
||||
@change="saveChanges"
|
||||
>
|
||||
<option value="none">No due date</option>
|
||||
<option value="fixed">Fixed duration</option>
|
||||
<option value="variable">From variable</option>
|
||||
</select>
|
||||
|
||||
<div v-if="localNodeData.dueDateType === 'fixed'">
|
||||
<input
|
||||
type="number"
|
||||
v-model="localNodeData.dueDateDays"
|
||||
class="form-control"
|
||||
placeholder="Days"
|
||||
min="0"
|
||||
@blur="saveChanges"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="localNodeData.dueDateType === 'variable'">
|
||||
<select
|
||||
v-model="localNodeData.dueDateVariable"
|
||||
class="form-control"
|
||||
@change="saveChanges"
|
||||
>
|
||||
<option value="" disabled>Select a variable</option>
|
||||
<option
|
||||
v-for="variable in availableVariables"
|
||||
:key="variable.name"
|
||||
:value="variable.name"
|
||||
>
|
||||
{{ variable.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Priority -->
|
||||
<div class="form-group mb-4">
|
||||
<label class="form-label">Priority</label>
|
||||
<select
|
||||
v-model="localNodeData.priority"
|
||||
class="form-control"
|
||||
@change="saveChanges"
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Instructions -->
|
||||
<div class="form-group mb-4">
|
||||
<label class="form-label">Task Instructions</label>
|
||||
<textarea
|
||||
v-model="localNodeData.instructions"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
placeholder="Instructions for the assignee"
|
||||
@blur="saveChanges"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { useVariableStore } from '@/stores/variableStore';
|
||||
|
||||
const props = defineProps({
|
||||
nodeData: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
availableVariables: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
// Local state
|
||||
const users = ref([]);
|
||||
const roles = ref([]);
|
||||
const loading = ref({
|
||||
users: false,
|
||||
roles: false
|
||||
});
|
||||
|
||||
// Local state for node data
|
||||
const localNodeData = ref({
|
||||
label: 'Task',
|
||||
description: '',
|
||||
assignmentType: 'user', // 'user', 'role', or 'variable'
|
||||
assignedUsers: [],
|
||||
assignedRoles: [],
|
||||
assigneeVariable: '',
|
||||
dueDateType: 'none', // 'none', 'fixed', or 'variable'
|
||||
dueDateDays: 3,
|
||||
dueDateVariable: '',
|
||||
priority: 'medium',
|
||||
instructions: ''
|
||||
});
|
||||
|
||||
// Get the variable store for variables
|
||||
const variableStore = useVariableStore();
|
||||
|
||||
// Watch for changes from parent props
|
||||
watch(() => props.nodeData, (newNodeData) => {
|
||||
if (newNodeData) {
|
||||
// Create a deep copy to break reactivity chains with parent
|
||||
localNodeData.value = {
|
||||
label: newNodeData.label || 'Task',
|
||||
description: newNodeData.description || '',
|
||||
assignmentType: newNodeData.assignmentType || 'user',
|
||||
assignedUsers: Array.isArray(newNodeData.assignedUsers) ? [...newNodeData.assignedUsers] : [],
|
||||
assignedRoles: Array.isArray(newNodeData.assignedRoles) ? [...newNodeData.assignedRoles] : [],
|
||||
assigneeVariable: newNodeData.assigneeVariable || '',
|
||||
dueDateType: newNodeData.dueDateType || 'none',
|
||||
dueDateDays: newNodeData.dueDateDays || 3,
|
||||
dueDateVariable: newNodeData.dueDateVariable || '',
|
||||
priority: newNodeData.priority || 'medium',
|
||||
instructions: newNodeData.instructions || ''
|
||||
};
|
||||
}
|
||||
}, { immediate: true, deep: true });
|
||||
|
||||
// Fetch users and roles when component is mounted
|
||||
onMounted(async () => {
|
||||
await fetchUsers();
|
||||
await fetchRoles();
|
||||
});
|
||||
|
||||
// Fetch users from API
|
||||
async function fetchUsers() {
|
||||
loading.value.users = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/users');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && Array.isArray(result.users)) {
|
||||
users.value = result.users;
|
||||
} else {
|
||||
console.error('Error fetching users:', result.error || 'Unknown error');
|
||||
users.value = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error);
|
||||
users.value = [];
|
||||
} finally {
|
||||
loading.value.users = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch roles from API
|
||||
async function fetchRoles() {
|
||||
loading.value.roles = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/roles');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && Array.isArray(result.roles)) {
|
||||
roles.value = result.roles;
|
||||
} else {
|
||||
console.error('Error fetching roles:', result.error || 'Unknown error');
|
||||
roles.value = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching roles:', error);
|
||||
roles.value = [];
|
||||
} finally {
|
||||
loading.value.roles = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Save changes by emitting them to parent
|
||||
function saveChanges() {
|
||||
// Create a clean copy of the data to avoid reactivity issues
|
||||
const nodeDataCopy = JSON.parse(JSON.stringify(localNodeData.value));
|
||||
|
||||
// Emit the updated data to parent
|
||||
emit('update', nodeDataCopy);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-node-configuration {
|
||||
padding: 1rem;
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-radio {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
@ -10,6 +10,8 @@ import GatewayConditionManager from '~/components/process-flow/GatewayConditionM
|
||||
import ApiNodeConfiguration from '~/components/process-flow/ApiNodeConfiguration.vue';
|
||||
import VariableManager from '~/components/process-flow/VariableManager.vue';
|
||||
import { onBeforeRouteLeave } from 'vue-router';
|
||||
import FormNodeConfiguration from '~/components/process-flow/FormNodeConfiguration.vue';
|
||||
import TaskNodeConfiguration from '~/components/process-flow/TaskNodeConfiguration.vue';
|
||||
|
||||
// Define page meta
|
||||
definePageMeta({
|
||||
@ -624,6 +626,66 @@ const onConditionsUpdated = (conditions) => {
|
||||
updateNodeInStore();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle API node update
|
||||
const handleApiNodeUpdate = (updatedData) => {
|
||||
if (selectedNodeData.value && selectedNodeData.value.type === 'api') {
|
||||
// Make sure to update the label both in data and at the root level
|
||||
const newLabel = updatedData.label || 'API Call';
|
||||
|
||||
// Update the data
|
||||
selectedNodeData.value.data = {
|
||||
...updatedData,
|
||||
label: newLabel // Ensure label is in data
|
||||
};
|
||||
|
||||
// Also update the root label
|
||||
selectedNodeData.value.label = newLabel;
|
||||
|
||||
// Update the node in store
|
||||
updateNodeInStore();
|
||||
}
|
||||
};
|
||||
|
||||
// Add this function to handle form node updates
|
||||
const handleFormNodeUpdate = (updatedData) => {
|
||||
if (selectedNodeData.value && selectedNodeData.value.type === 'form') {
|
||||
// Make sure to update the label both in data and at the root level
|
||||
const newLabel = updatedData.label || 'Form Task';
|
||||
|
||||
// Update the data
|
||||
selectedNodeData.value.data = {
|
||||
...updatedData,
|
||||
label: newLabel // Ensure label is in data
|
||||
};
|
||||
|
||||
// Also update the root label
|
||||
selectedNodeData.value.label = newLabel;
|
||||
|
||||
// Update the node in store
|
||||
updateNodeInStore();
|
||||
}
|
||||
};
|
||||
|
||||
// Add this function to handle task node updates
|
||||
const handleTaskNodeUpdate = (updatedData) => {
|
||||
if (selectedNodeData.value && selectedNodeData.value.type === 'task') {
|
||||
// Make sure to update the label both in data and at the root level
|
||||
const newLabel = updatedData.label || 'Task';
|
||||
|
||||
// Update the data
|
||||
selectedNodeData.value.data = {
|
||||
...updatedData,
|
||||
label: newLabel // Ensure label is in data
|
||||
};
|
||||
|
||||
// Also update the root label
|
||||
selectedNodeData.value.label = newLabel;
|
||||
|
||||
// Update the node in store
|
||||
updateNodeInStore();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -724,27 +786,28 @@ const onConditionsUpdated = (conditions) => {
|
||||
|
||||
<!-- Node Type Specific Properties -->
|
||||
<div v-if="selectedNodeData.type === 'task'">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Assignee</label>
|
||||
<input
|
||||
v-model="nodeAssignee"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
<TaskNodeConfiguration
|
||||
:nodeData="selectedNodeData.data"
|
||||
:availableVariables="variableStore.getAllVariables.global"
|
||||
@update="handleTaskNodeUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Form Selection for Form Nodes -->
|
||||
<div v-if="selectedNodeData.type === 'form'">
|
||||
<FormSelector
|
||||
@select="handleFormSelection"
|
||||
@clear="clearFormSelection"
|
||||
:formId="selectedNodeData.data?.formId"
|
||||
<FormNodeConfiguration
|
||||
:nodeData="selectedNodeData.data"
|
||||
:availableVariables="variableStore.getAllVariables.global"
|
||||
@update="handleFormNodeUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- API Configuration for API Nodes -->
|
||||
<div v-if="selectedNodeData.type === 'api'">
|
||||
<ApiNodeConfiguration
|
||||
:nodeId="selectedNodeData.id"
|
||||
:nodeData="selectedNodeData.data"
|
||||
:availableVariables="gatewayAvailableVariables"
|
||||
@update="handleApiNodeUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
36
server/api/roles/index.get.js
Normal file
36
server/api/roles/index.get.js
Normal file
@ -0,0 +1,36 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Get all active roles
|
||||
const roles = await prisma.role.findMany({
|
||||
where: {
|
||||
roleStatus: 'active' // Assuming there's a status field to filter active roles
|
||||
},
|
||||
select: {
|
||||
roleID: true,
|
||||
roleName: true,
|
||||
roleDescription: true
|
||||
},
|
||||
orderBy: {
|
||||
roleName: 'asc'
|
||||
}
|
||||
});
|
||||
|
||||
await prisma.$disconnect();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
roles: roles
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching roles:', error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to fetch roles'
|
||||
};
|
||||
}
|
||||
});
|
37
server/api/users/index.get.js
Normal file
37
server/api/users/index.get.js
Normal file
@ -0,0 +1,37 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Get all active users
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
userStatus: 'active' // Assuming there's a status field to filter active users
|
||||
},
|
||||
select: {
|
||||
userID: true,
|
||||
userUsername: true,
|
||||
userFullName: true,
|
||||
userEmail: true
|
||||
},
|
||||
orderBy: {
|
||||
userFullName: 'asc'
|
||||
}
|
||||
});
|
||||
|
||||
await prisma.$disconnect();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
users: users
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to fetch users'
|
||||
};
|
||||
}
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user