- Introduced new components: ArrowEdge, CustomEdge, InteractiveArrowEdge, and EdgeConfiguration for enhanced edge management in the process flow. - Implemented dynamic edge paths with customizable styles, labels, and animations, improving visual representation and user interaction. - Enhanced ProcessFlowCanvas to support new edge types and configurations, allowing for more flexible process designs. - Updated ProcessFlowNodes to include new edge components, ensuring seamless integration with existing node functionalities. - Improved user experience by providing configuration options for edges, including animation and style settings, directly within the process builder.
548 lines
17 KiB
Vue
548 lines
17 KiB
Vue
<template>
|
|
<div class="variable-browser">
|
|
<!-- Variable Selection Input with Add Button -->
|
|
<div class="flex items-center space-x-2">
|
|
<div class="flex-1">
|
|
<select
|
|
:value="modelValue"
|
|
@change="handleVariableSelect"
|
|
:class="[
|
|
'form-select w-full',
|
|
hasError ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''
|
|
]"
|
|
:disabled="disabled"
|
|
>
|
|
<option value="" disabled>{{ placeholder || 'Select variable' }}</option>
|
|
<optgroup
|
|
v-for="group in groupedVariables"
|
|
:key="group.type"
|
|
:label="group.label"
|
|
>
|
|
<option
|
|
v-for="variable in group.variables"
|
|
:key="variable.name"
|
|
:value="variable.name"
|
|
:title="variable.description || `${variable.type} variable`"
|
|
>
|
|
{{ variable.name }} ({{ variable.type }})
|
|
<span v-if="variable.currentValue !== undefined && variable.currentValue !== null">
|
|
= {{ formatPreviewValue(variable.currentValue, variable.type) }}
|
|
</span>
|
|
</option>
|
|
</optgroup>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Add Variable Button (if allowed) -->
|
|
<RsButton
|
|
v-if="allowCreate"
|
|
@click.stop="openCreateVariable"
|
|
variant="secondary"
|
|
size="sm"
|
|
:disabled="disabled"
|
|
title="Create new variable"
|
|
class="flex-shrink-0"
|
|
>
|
|
<Icon name="material-symbols:add" class="w-4 h-4" />
|
|
</RsButton>
|
|
</div>
|
|
|
|
<!-- Variable Info Display -->
|
|
<div v-if="selectedVariable" class="mt-2 p-2 bg-gray-50 rounded-md border text-sm">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center space-x-2">
|
|
<span
|
|
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
|
:class="getTypeColorClass(selectedVariable.type)"
|
|
>
|
|
{{ selectedVariable.type }}
|
|
</span>
|
|
<span class="font-medium">{{ selectedVariable.name }}</span>
|
|
</div>
|
|
<div v-if="selectedVariable.currentValue !== undefined" class="text-gray-600">
|
|
Current: {{ formatPreviewValue(selectedVariable.currentValue, selectedVariable.type) }}
|
|
</div>
|
|
</div>
|
|
<div v-if="selectedVariable.description" class="mt-1 text-gray-600">
|
|
{{ selectedVariable.description }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error Display -->
|
|
<div v-if="hasError" class="mt-1 text-red-600 text-sm flex items-center">
|
|
<Icon name="material-symbols:error" class="w-4 h-4 mr-1" />
|
|
{{ errorMessage }}
|
|
</div>
|
|
|
|
<!-- Create Variable Modal (if enabled) -->
|
|
<Teleport to="body">
|
|
<div
|
|
v-if="showCreateVariable"
|
|
class="variable-modal-overlay"
|
|
@click.self="closeCreateVariable"
|
|
>
|
|
<div class="variable-modal-dialog">
|
|
<div class="variable-modal-content">
|
|
<!-- Modal Header -->
|
|
<div class="variable-modal-header">
|
|
<h4 class="text-lg font-semibold text-gray-900">Create New Variable</h4>
|
|
<button
|
|
@click="closeCreateVariable"
|
|
class="text-gray-400 hover:text-gray-600 transition-colors"
|
|
title="Close"
|
|
>
|
|
<Icon name="material-symbols:close" class="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Modal Body -->
|
|
<div class="variable-modal-body">
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">Variable Name</label>
|
|
<input
|
|
v-model="newVariableName"
|
|
type="text"
|
|
placeholder="Enter variable name"
|
|
class="w-full p-2 border border-gray-300 rounded-md focus:border-purple-500 focus:ring focus:ring-purple-200"
|
|
@input="validateVariableName"
|
|
/>
|
|
<div v-if="nameValidationError" class="mt-1 text-red-600 text-sm">
|
|
{{ nameValidationError }}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">Data Type</label>
|
|
<select
|
|
v-model="newVariableType"
|
|
class="w-full p-2 border border-gray-300 rounded-md shadow-sm focus:border-purple-500 focus:ring focus:ring-purple-200"
|
|
>
|
|
<optgroup label="Common Types">
|
|
<option value="string">String (Text)</option>
|
|
<option value="int">Integer (Whole Number)</option>
|
|
<option value="decimal">Decimal (Number with decimals)</option>
|
|
<option value="boolean">Boolean (True/False)</option>
|
|
</optgroup>
|
|
<optgroup label="Date & Time Types">
|
|
<option value="date">Date</option>
|
|
<option value="datetime">DateTime</option>
|
|
<option value="time">Time</option>
|
|
<option value="timestamp">Timestamp</option>
|
|
</optgroup>
|
|
<optgroup label="Complex Types">
|
|
<option value="object">Object (JSON)</option>
|
|
<option value="array">Array (List)</option>
|
|
<option value="map">Map (Key-Value Pairs)</option>
|
|
</optgroup>
|
|
<optgroup label="Special Types">
|
|
<option value="currency">Currency</option>
|
|
<option value="percentage">Percentage</option>
|
|
<option value="email">Email</option>
|
|
<option value="url">URL</option>
|
|
<option value="phone">Phone Number</option>
|
|
<option value="file">File</option>
|
|
<option value="image">Image</option>
|
|
<option value="binary">Binary Data</option>
|
|
<option value="uuid">UUID</option>
|
|
</optgroup>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">Default Value (Optional)</label>
|
|
<input
|
|
v-model="newVariableDefaultValue"
|
|
type="text"
|
|
placeholder="Enter default value"
|
|
class="w-full p-2 border border-gray-300 rounded-md focus:border-purple-500 focus:ring focus:ring-purple-200"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">Description (Optional)</label>
|
|
<textarea
|
|
v-model="newVariableDescription"
|
|
placeholder="Describe what this variable is used for"
|
|
rows="2"
|
|
class="w-full p-2 border border-gray-300 rounded-md focus:border-purple-500 focus:ring focus:ring-purple-200"
|
|
></textarea>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal Footer -->
|
|
<div class="variable-modal-footer">
|
|
<RsButton @click="closeCreateVariable" variant="secondary">
|
|
Cancel
|
|
</RsButton>
|
|
<RsButton
|
|
@click="createVariable"
|
|
variant="primary"
|
|
:disabled="!canCreateVariable"
|
|
>
|
|
Create Variable
|
|
</RsButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch } from 'vue';
|
|
import { useProcessBuilderStore } from '~/stores/processBuilder';
|
|
import { Icon } from '#components';
|
|
|
|
const props = defineProps({
|
|
modelValue: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
availableVariables: {
|
|
type: Array,
|
|
default: () => []
|
|
},
|
|
placeholder: {
|
|
type: String,
|
|
default: 'Select variable'
|
|
},
|
|
disabled: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
allowCreate: {
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
filterTypes: {
|
|
type: Array,
|
|
default: () => [] // If specified, only show variables of these types
|
|
},
|
|
required: {
|
|
type: Boolean,
|
|
default: false
|
|
}
|
|
});
|
|
|
|
const emit = defineEmits(['update:modelValue', 'variable-created']);
|
|
|
|
const processStore = useProcessBuilderStore();
|
|
|
|
// Create variable state
|
|
const showCreateVariable = ref(false);
|
|
const newVariableName = ref('');
|
|
const newVariableType = ref('string');
|
|
const newVariableDefaultValue = ref('');
|
|
const newVariableDescription = ref('');
|
|
const nameValidationError = ref('');
|
|
|
|
// Computed properties
|
|
const selectedVariable = computed(() => {
|
|
return props.availableVariables.find(v => v.name === props.modelValue);
|
|
});
|
|
|
|
const hasError = computed(() => {
|
|
if (!props.required && !props.modelValue) return false;
|
|
if (props.modelValue && !selectedVariable.value) return true;
|
|
return false;
|
|
});
|
|
|
|
const errorMessage = computed(() => {
|
|
if (props.required && !props.modelValue) {
|
|
return 'Variable selection is required';
|
|
}
|
|
if (props.modelValue && !selectedVariable.value) {
|
|
return `Variable "${props.modelValue}" not found`;
|
|
}
|
|
return '';
|
|
});
|
|
|
|
const groupedVariables = computed(() => {
|
|
// Initialize with common types but allow for dynamic additions
|
|
const types = {
|
|
string: { label: 'Text Variables', variables: [] },
|
|
int: { label: 'Integer Variables', variables: [] },
|
|
decimal: { label: 'Decimal Variables', variables: [] },
|
|
boolean: { label: 'Boolean Variables', variables: [] },
|
|
date: { label: 'Date Variables', variables: [] },
|
|
datetime: { label: 'DateTime Variables', variables: [] },
|
|
object: { label: 'Object Variables', variables: [] }
|
|
// Other types will be added dynamically
|
|
};
|
|
|
|
let variables = props.availableVariables;
|
|
|
|
// Filter by allowed types if specified
|
|
if (props.filterTypes.length > 0) {
|
|
variables = variables.filter(v => props.filterTypes.includes(v.type));
|
|
}
|
|
|
|
variables.forEach(variable => {
|
|
const type = variable.type || 'string';
|
|
|
|
// If type doesn't exist in our types object, add it dynamically
|
|
if (!types[type]) {
|
|
// Create a friendly label from the type name
|
|
const label = type.charAt(0).toUpperCase() + type.slice(1) + ' Variables';
|
|
types[type] = { label, variables: [] };
|
|
}
|
|
|
|
// Add the variable to its type group
|
|
types[type].variables.push(variable);
|
|
});
|
|
|
|
return Object.values(types).filter(group => group.variables.length > 0);
|
|
});
|
|
|
|
const canCreateVariable = computed(() => {
|
|
return newVariableName.value.trim() &&
|
|
newVariableType.value &&
|
|
!nameValidationError.value;
|
|
});
|
|
|
|
// Methods
|
|
const handleVariableSelect = (event) => {
|
|
emit('update:modelValue', event.target.value);
|
|
};
|
|
|
|
const openCreateVariable = () => {
|
|
showCreateVariable.value = true;
|
|
resetCreateForm();
|
|
};
|
|
|
|
const closeCreateVariable = () => {
|
|
showCreateVariable.value = false;
|
|
resetCreateForm();
|
|
};
|
|
|
|
const resetCreateForm = () => {
|
|
newVariableName.value = '';
|
|
newVariableType.value = 'string';
|
|
newVariableDefaultValue.value = '';
|
|
newVariableDescription.value = '';
|
|
nameValidationError.value = '';
|
|
};
|
|
|
|
const validateVariableName = () => {
|
|
const name = newVariableName.value.trim();
|
|
|
|
if (!name) {
|
|
nameValidationError.value = '';
|
|
return;
|
|
}
|
|
|
|
// Check if name already exists
|
|
if (props.availableVariables.some(v => v.name === name)) {
|
|
nameValidationError.value = 'Variable name already exists';
|
|
return;
|
|
}
|
|
|
|
// Check if name is valid (alphanumeric and underscore only)
|
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
|
nameValidationError.value = 'Variable name must start with letter or underscore and contain only letters, numbers, and underscores';
|
|
return;
|
|
}
|
|
|
|
nameValidationError.value = '';
|
|
};
|
|
|
|
const createVariable = () => {
|
|
if (!canCreateVariable.value) return;
|
|
|
|
const newVariable = {
|
|
name: newVariableName.value.trim(),
|
|
type: newVariableType.value,
|
|
defaultValue: newVariableDefaultValue.value || null,
|
|
description: newVariableDescription.value.trim() || null
|
|
};
|
|
|
|
// Add to process store
|
|
processStore.addProcessVariable(newVariable);
|
|
|
|
// Select the new variable
|
|
emit('update:modelValue', newVariable.name);
|
|
emit('variable-created', newVariable);
|
|
|
|
closeCreateVariable();
|
|
};
|
|
|
|
const getTypeColorClass = (type) => {
|
|
const classes = {
|
|
string: 'bg-blue-100 text-blue-800',
|
|
int: 'bg-purple-100 text-purple-800',
|
|
decimal: 'bg-purple-100 text-purple-800',
|
|
number: 'bg-purple-100 text-purple-800',
|
|
boolean: 'bg-indigo-100 text-indigo-800',
|
|
date: 'bg-amber-100 text-amber-800',
|
|
datetime: 'bg-amber-100 text-amber-800',
|
|
object: 'bg-emerald-100 text-emerald-800',
|
|
// Add colors for any additional types you know about
|
|
array: 'bg-emerald-100 text-emerald-800',
|
|
map: 'bg-teal-100 text-teal-800',
|
|
set: 'bg-teal-100 text-teal-800',
|
|
binary: 'bg-slate-100 text-slate-800',
|
|
time: 'bg-amber-100 text-amber-800',
|
|
timestamp: 'bg-amber-100 text-amber-800',
|
|
uuid: 'bg-violet-100 text-violet-800',
|
|
email: 'bg-blue-100 text-blue-800',
|
|
url: 'bg-blue-100 text-blue-800',
|
|
phone: 'bg-blue-100 text-blue-800',
|
|
currency: 'bg-green-100 text-green-800',
|
|
percentage: 'bg-purple-100 text-purple-800',
|
|
file: 'bg-orange-100 text-orange-800',
|
|
image: 'bg-orange-100 text-orange-800'
|
|
};
|
|
return classes[type] || 'bg-gray-100 text-gray-800';
|
|
};
|
|
|
|
const formatPreviewValue = (value, type) => {
|
|
if (value === null || value === undefined) return 'null';
|
|
|
|
switch (type) {
|
|
case 'string':
|
|
return value.length > 20 ? `"${value.substring(0, 20)}..."` : `"${value}"`;
|
|
case 'boolean':
|
|
return value ? 'true' : 'false';
|
|
case 'object':
|
|
case 'array':
|
|
case 'map':
|
|
case 'set':
|
|
try {
|
|
const str = typeof value === 'string' ? value : JSON.stringify(value);
|
|
return str.length > 30 ? `${str.substring(0, 30)}...` : str;
|
|
} catch {
|
|
return 'Invalid JSON';
|
|
}
|
|
case 'date':
|
|
case 'datetime':
|
|
case 'time':
|
|
case 'timestamp':
|
|
// Try to format dates nicely if possible
|
|
try {
|
|
if (value instanceof Date) {
|
|
return value.toLocaleString();
|
|
} else if (typeof value === 'string' && (value.includes('T') || value.includes('-'))) {
|
|
return new Date(value).toLocaleString();
|
|
}
|
|
} catch {}
|
|
return String(value);
|
|
case 'int':
|
|
case 'decimal':
|
|
case 'percentage':
|
|
case 'currency':
|
|
// For numeric types
|
|
if (typeof value === 'number') {
|
|
return value.toString();
|
|
}
|
|
return String(value);
|
|
case 'file':
|
|
case 'image':
|
|
case 'binary':
|
|
// For file-like types
|
|
if (typeof value === 'string') {
|
|
return value.length > 20 ? `${value.substring(0, 20)}...` : value;
|
|
}
|
|
return '[Binary Data]';
|
|
default:
|
|
// For any other type, just convert to string
|
|
return String(value);
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.variable-browser .form-select {
|
|
@apply block w-full p-2 border border-gray-300 rounded-md shadow-sm focus:border-purple-500 focus:ring focus:ring-purple-200 text-sm;
|
|
}
|
|
|
|
.variable-browser .form-select:disabled {
|
|
@apply bg-gray-100 text-gray-500 cursor-not-allowed;
|
|
}
|
|
|
|
/* Custom Variable Modal Styles */
|
|
.variable-modal-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background-color: rgba(0, 0, 0, 0.5);
|
|
z-index: 1100;
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: center;
|
|
padding: 1.5rem;
|
|
box-sizing: border-box;
|
|
overflow-y: auto;
|
|
padding-top: 2rem;
|
|
}
|
|
|
|
.variable-modal-dialog {
|
|
position: relative;
|
|
background-color: white;
|
|
border-radius: 0.5rem;
|
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
|
margin: 1.5rem auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
max-height: calc(100vh - 3rem);
|
|
width: 100%;
|
|
max-width: 500px;
|
|
animation: modalSlideIn 0.2s ease-out;
|
|
}
|
|
|
|
@keyframes modalSlideIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(-20px) scale(0.95);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0) scale(1);
|
|
}
|
|
}
|
|
|
|
.variable-modal-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.variable-modal-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 1.25rem 1.5rem;
|
|
border-bottom: 1px solid #e5e7eb;
|
|
background-color: #f9fafb;
|
|
border-radius: 0.5rem 0.5rem 0 0;
|
|
}
|
|
|
|
.variable-modal-body {
|
|
padding: 1.5rem;
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.variable-modal-footer {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: 0.75rem;
|
|
padding: 1rem 1.5rem;
|
|
border-top: 1px solid #e5e7eb;
|
|
background-color: #f9fafb;
|
|
border-radius: 0 0 0.5rem 0.5rem;
|
|
}
|
|
</style>
|
|
|
|
<style>
|
|
/* Prevent body scroll when modal is open */
|
|
body:has(.variable-modal-overlay) {
|
|
overflow: hidden;
|
|
}
|
|
</style> |