corrad-bp/components/process-flow/VariableBrowser.vue
Md Afiq Iskandar 8919ac819c Add Interactive Edges and Edge Configuration to Process Flow
- 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.
2025-07-11 14:13:42 +08:00

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>