corrad-bp/components/process-flow/VariableBrowser.vue
Afiq 8f84b00a9e Enhance Form Builder Submit Button Configuration and Variable Handling
- Introduced a new submit button configuration in the form builder, allowing users to enable/disable the default submit button and customize its label, category, and color.
- Updated VariableBrowser.vue to support object property path input for variables, including validation and error handling for property paths.
- Enhanced ApiNodeConfiguration.vue to prevent object path creation for output and error variables.
- Improved workflow page to respect form builder submit button settings, ensuring consistent behavior across the application.
- Added helper functions for managing submit button styles and variants, enhancing the overall user experience.
2025-08-04 16:25:56 +08:00

695 lines
22 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="baseVariableName"
@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>
<!-- Object Property Path Input (for object/array variables) -->
<div v-if="selectedVariable && isObjectType(selectedVariable.type) && allowObjectPath" class="mt-2">
<label class="block text-xs font-medium text-gray-600 mb-1">
Object Property Path (optional)
</label>
<div class="flex items-center space-x-2">
<div class="flex-1 relative">
<div class="flex items-center border rounded-md focus-within:ring-2 focus-within:ring-purple-500 focus-within:border-purple-500" :class="propertyPathError ? 'border-red-500' : 'border-gray-300'">
<span class="px-3 py-2 text-sm text-gray-600 bg-gray-50 border-r border-gray-300 rounded-l-md">
{{ baseVariableName }}.
</span>
<input
v-model="propertyPath"
type="text"
placeholder="data.user.name or [0].title"
class="flex-1 px-3 py-2 text-sm border-0 rounded-r-md focus:outline-none focus:ring-0"
@input="handlePropertyPathChange"
@blur="validatePropertyPath"
/>
</div>
</div>
<button
v-if="propertyPath"
@click="clearPropertyPath"
class="p-2 text-gray-400 hover:text-gray-600 transition-colors"
title="Clear property path"
>
<Icon name="material-symbols:close" class="w-4 h-4" />
</button>
</div>
<!-- Property Path Examples -->
<div class="mt-1 text-xs text-gray-500">
<details class="cursor-pointer">
<summary class="hover:text-gray-700">Examples</summary>
<div class="mt-1 space-y-1 pl-2 border-l-2 border-gray-200">
<div><code class="bg-gray-100 px-1">data.user.name</code> - Access nested object property</div>
<div><code class="bg-gray-100 px-1">items[0].title</code> - Access first array item property</div>
<div><code class="bg-gray-100 px-1">results.users[2].email</code> - Complex nested access</div>
<div><code class="bg-gray-100 px-1">response.data.attributes.value</code> - Deep nesting</div>
</div>
</details>
</div>
<!-- Property Path Error -->
<div v-if="propertyPathError" class="mt-1 text-red-600 text-xs flex items-center">
<Icon name="material-symbols:error" class="w-3 h-3 mr-1" />
{{ propertyPathError }}
</div>
<!-- Final Variable Path Preview -->
<div v-if="finalVariablePath && finalVariablePath !== baseVariableName" class="mt-2 p-2 bg-blue-50 border border-blue-200 rounded-md">
<div class="text-xs font-medium text-blue-800 mb-1">Full Variable Path:</div>
<code class="text-sm text-blue-700 bg-blue-100 px-2 py-1 rounded">{{ finalVariablePath }}</code>
</div>
</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
},
allowObjectPath: {
type: Boolean,
default: true // Enable object property path input
}
});
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('');
// Object property path state
const propertyPath = ref('');
const propertyPathError = ref('');
// Computed properties
const baseVariableName = computed(() => {
// Extract base variable name from modelValue (strip property path)
const value = props.modelValue || '';
const dotIndex = value.indexOf('.');
return dotIndex > -1 ? value.substring(0, dotIndex) : value;
});
const selectedVariable = computed(() => {
return props.availableVariables.find(v => v.name === baseVariableName.value);
});
const finalVariablePath = computed(() => {
if (!baseVariableName.value) return '';
if (!propertyPath.value) return baseVariableName.value;
return `${baseVariableName.value}.${propertyPath.value}`;
});
const hasError = computed(() => {
if (!props.required && !props.modelValue) return false;
if (props.modelValue && !selectedVariable.value) return true;
if (propertyPathError.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 "${baseVariableName.value}" not found`;
}
if (propertyPathError.value) {
return propertyPathError.value;
}
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) => {
const selectedVar = event.target.value;
propertyPath.value = ''; // Reset property path when changing variable
propertyPathError.value = '';
emit('update:modelValue', selectedVar);
};
const handlePropertyPathChange = () => {
propertyPathError.value = ''; // Clear error on input
emit('update:modelValue', finalVariablePath.value);
};
const validatePropertyPath = () => {
if (!propertyPath.value) {
propertyPathError.value = '';
return;
}
// Basic validation for property path syntax
const path = propertyPath.value.trim();
// Check for invalid characters or patterns
if (path.includes('..') || path.startsWith('.') || path.endsWith('.')) {
propertyPathError.value = 'Invalid property path format';
return;
}
// Check for balanced brackets
const openBrackets = (path.match(/\[/g) || []).length;
const closeBrackets = (path.match(/\]/g) || []).length;
if (openBrackets !== closeBrackets) {
propertyPathError.value = 'Unmatched brackets in property path';
return;
}
propertyPathError.value = '';
};
const clearPropertyPath = () => {
propertyPath.value = '';
propertyPathError.value = '';
emit('update:modelValue', baseVariableName.value);
};
const isObjectType = (type) => {
return ['object', 'array', 'map', 'set'].includes(type);
};
// Watch for external changes to modelValue to sync property path
watch(() => props.modelValue, (newValue) => {
if (!newValue) {
propertyPath.value = '';
propertyPathError.value = '';
return;
}
const dotIndex = newValue.indexOf('.');
if (dotIndex > -1) {
// modelValue contains a property path
const baseName = newValue.substring(0, dotIndex);
const path = newValue.substring(dotIndex + 1);
propertyPath.value = path;
} else {
// No property path in modelValue
propertyPath.value = '';
}
propertyPathError.value = '';
}, { immediate: true });
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>