Refactor Variable Selection in Process Flow Components

- Replaced select dropdowns with VariableBrowser component in ApiNodeConfiguration, BusinessRuleNodeConfiguration, FormNodeConfiguration, GatewayConditionManager, NotificationNodeConfiguration, and other relevant components for improved variable management.
- Enhanced user experience by allowing variable creation directly within the VariableBrowser, streamlining the process of selecting and managing variables.
- Updated related methods to ensure proper handling of variable selection and insertion across components, maintaining consistency in functionality.
- Improved code clarity and maintainability by consolidating variable selection logic into the new VariableBrowser component.
This commit is contained in:
Md Afiq Iskandar 2025-07-07 17:53:20 +08:00
parent d03eda54c8
commit 75e5e6f97e
6 changed files with 1114 additions and 180 deletions

View File

@ -62,19 +62,13 @@
<label class="block text-sm font-medium text-gray-700 mb-1">Headers</label>
<div class="bg-white p-3 border rounded-md shadow-sm">
<div class="flex gap-2 mb-2">
<select
class="form-select text-sm flex-grow"
@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>
<VariableBrowser
:availableVariables="availableVariables"
placeholder="Insert Variable into Headers..."
:allowCreate="false"
@update:modelValue="insertVariable($event, 'headers')"
class="flex-grow"
/>
<RsButton
variant="secondary"
size="sm"
@ -113,19 +107,13 @@
<label class="block text-sm font-medium text-gray-700 mb-1">Request Body</label>
<div class="bg-white p-3 border rounded-md shadow-sm">
<div class="flex gap-2 mb-2">
<select
class="form-select text-sm flex-grow"
@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>
<VariableBrowser
:availableVariables="availableVariables"
placeholder="Insert Variable into Request Body..."
:allowCreate="false"
@update:modelValue="insertVariable($event, 'requestBody')"
class="flex-grow"
/>
<RsButton
variant="secondary"
size="sm"
@ -174,31 +162,12 @@
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Output Variable</label>
<div class="flex gap-2">
<select
<VariableBrowser
v-model="localNodeData.outputVariable"
class="w-full p-2 border rounded-md shadow-sm focus:border-indigo-500 focus:ring focus:ring-indigo-200 text-sm"
:availableVariables="availableVariables"
:allowCreate="true"
@change="saveChanges"
>
<option value="" disabled>Select a global variable</option>
<option value="apiResponse">Create new: apiResponse</option>
<option
v-for="variable in availableVariables"
:key="variable.name"
:value="variable.name"
>
{{ variable.label }}
</option>
</select>
<RsButton
@click="createGlobalVariable(localNodeData.outputVariable)"
variant="primary"
size="sm"
title="Create a new global variable"
class="flex-shrink-0"
>
<Icon name="material-symbols:add" class="mr-1" />
Create
</RsButton>
/>
</div>
<p class="mt-1 text-xs text-gray-500">
API response will be stored in this global variable for use in later steps
@ -209,31 +178,12 @@
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Error Variable</label>
<div class="flex gap-2">
<select
<VariableBrowser
v-model="localNodeData.errorVariable"
class="w-full p-2 border rounded-md shadow-sm focus:border-indigo-500 focus:ring focus:ring-indigo-200 text-sm"
:availableVariables="availableVariables"
:allowCreate="true"
@change="saveChanges"
>
<option value="" disabled>Select a global variable</option>
<option value="apiError">Create new: apiError</option>
<option
v-for="variable in availableVariables"
:key="variable.name"
:value="variable.name"
>
{{ variable.label }}
</option>
</select>
<RsButton
@click="createGlobalVariable(localNodeData.errorVariable, `API error from ${localNodeData.label}`)"
variant="primary"
size="sm"
title="Create a new global variable"
class="flex-shrink-0"
>
<Icon name="material-symbols:add" class="mr-1" />
Create
</RsButton>
/>
</div>
<p class="mt-1 text-xs text-gray-500">
Any API errors will be stored in this variable for error handling
@ -325,6 +275,7 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { useProcessBuilderStore } from '@/stores/processBuilder';
import VariableBrowser from './VariableBrowser.vue';
const props = defineProps({
nodeData: {

View File

@ -53,11 +53,41 @@
<h4 class="font-medium">Business Rules</h4>
</div>
<div class="flex justify-between items-center mb-3">
<p class="text-sm text-gray-600">
Define when conditions occur and what actions should be taken
</p>
<!-- Mode Toggle -->
<div class="flex justify-between items-center mb-4">
<div class="flex items-center space-x-4">
<p class="text-sm text-gray-600">
Define when conditions occur and what actions should be taken
</p>
<div class="flex items-center bg-white rounded-lg border border-gray-200 p-1">
<button
@click="editorMode = 'visual'"
:class="[
'px-3 py-1 text-xs font-medium rounded-md transition-colors',
editorMode === 'visual'
? 'bg-purple-100 text-purple-700'
: 'text-gray-500 hover:text-gray-700'
]"
>
<Icon name="material-symbols:dashboard" class="w-4 h-4 mr-1" />
Visual Builder
</button>
<button
@click="editorMode = 'pseudocode'"
:class="[
'px-3 py-1 text-xs font-medium rounded-md transition-colors',
editorMode === 'pseudocode'
? 'bg-purple-100 text-purple-700'
: 'text-gray-500 hover:text-gray-700'
]"
>
<Icon name="material-symbols:code" class="w-4 h-4 mr-1" />
Pseudocode
</button>
</div>
</div>
<RsButton
v-if="editorMode === 'visual'"
@click="addRuleGroup"
variant="primary"
size="sm"
@ -66,6 +96,112 @@
<Icon name="material-symbols:add" class="mr-1" /> Add Rule
</RsButton>
</div>
<!-- Pseudocode Editor -->
<div v-if="editorMode === 'pseudocode'" class="space-y-4">
<!-- Pseudocode Help -->
<div class="bg-blue-50 border border-blue-200 rounded-md p-3">
<div class="flex items-start">
<Icon name="material-symbols:info" class="w-4 h-4 text-blue-600 mt-0.5 mr-2 flex-shrink-0" />
<div class="text-sm">
<p class="font-medium text-blue-800 mb-1">Pseudocode Syntax Guide:</p>
<div class="text-blue-700 space-y-1">
<p><code class="bg-blue-100 px-1 rounded">IF variable operator value THEN action</code></p>
<p><code class="bg-blue-100 px-1 rounded">IF age &gt; 18 AND status = "active" THEN set eligibility = "approved"</code></p>
<p><code class="bg-blue-100 px-1 rounded">IF amount &lt; 1000 THEN set discount = 0.1</code></p>
</div>
</div>
</div>
</div>
<!-- Pseudocode Editor -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Write your business rules in pseudocode:
</label>
<div class="border border-gray-300 rounded-md overflow-hidden">
<RsCodeMirror
v-model="pseudocodeText"
language="javascript"
height="300px"
:theme="'oneDark'"
class="pseudocode-editor"
@update:modelValue="onPseudocodeChange"
/>
</div>
<div class="mt-2 text-xs text-gray-500">
<p><strong>Quick Start:</strong> Type your rules in natural language format</p>
<p class="mt-1">Example: <code class="bg-gray-100 px-1 rounded">IF age &gt; 18 THEN SET canVote = true</code></p>
</div>
</div>
<!-- Pseudocode Actions -->
<div class="flex justify-between items-center">
<div class="flex space-x-2">
<RsButton @click="generatePseudocode" variant="secondary" size="sm">
<Icon name="material-symbols:refresh" class="mr-1" />
Generate from Rules
</RsButton>
<RsButton @click="parsePseudocode" variant="primary" size="sm">
<Icon name="material-symbols:rule" class="mr-1" />
Convert to Rules
</RsButton>
</div>
<RsButton @click="showPseudocodeExamples = !showPseudocodeExamples" variant="tertiary" size="sm">
<Icon name="material-symbols:help" class="mr-1" />
Examples
</RsButton>
</div>
<!-- Pseudocode Examples Modal -->
<div v-if="showPseudocodeExamples" class="bg-gray-50 border border-gray-200 rounded-md p-4">
<h4 class="font-medium text-gray-800 mb-3">Pseudocode Examples</h4>
<div class="space-y-3 text-sm">
<div>
<p class="font-medium text-gray-700">Basic Condition:</p>
<pre class="bg-white p-2 rounded border text-xs font-mono">IF age &gt;= 18 THEN SET canVote = true</pre>
</div>
<div>
<p class="font-medium text-gray-700">Multiple Conditions (AND):</p>
<pre class="bg-white p-2 rounded border text-xs font-mono">IF income &gt; 50000 AND creditScore &gt;= 700 THEN SET loanApproved = true</pre>
</div>
<div>
<p class="font-medium text-gray-700">Multiple Conditions (OR):</p>
<pre class="bg-white p-2 rounded border text-xs font-mono">IF isPremium = true OR yearsActive &gt; 5 THEN SET discount = 0.15</pre>
</div>
<div>
<p class="font-medium text-gray-700">Multiple Actions:</p>
<pre class="bg-white p-2 rounded border text-xs font-mono">IF orderAmount &gt; 100 THEN
SET freeShipping = true
SET priority = "high"
SET notification = "Thank you for your large order!"
END</pre>
</div>
</div>
<div class="mt-3 pt-3 border-t border-gray-300">
<RsButton @click="showPseudocodeExamples = false" variant="tertiary" size="sm">
<Icon name="material-symbols:close" class="mr-1" />
Close Examples
</RsButton>
</div>
</div>
<!-- Parse Errors -->
<div v-if="pseudocodeErrors.length > 0" class="bg-red-50 border border-red-200 rounded-md p-3">
<div class="flex items-start">
<Icon name="material-symbols:error" class="w-4 h-4 text-red-600 mt-0.5 mr-2 flex-shrink-0" />
<div>
<p class="font-medium text-red-800 mb-1">Pseudocode Errors:</p>
<ul class="text-sm text-red-700 space-y-1">
<li v-for="(error, index) in pseudocodeErrors" :key="index">{{ error }}</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Visual Builder -->
<div v-if="editorMode === 'visual'">
<!-- No rules placeholder -->
<div v-if="!localNodeData.ruleGroups || localNodeData.ruleGroups.length === 0"
@ -169,35 +305,12 @@
<tbody>
<tr v-for="(condition, condIndex) in ruleGroup.conditions" :key="condIndex" class="border-t hover:bg-gray-50">
<td class="px-3 py-2">
<select
<VariableBrowser
v-model="condition.variable"
class="form-select"
:availableVariables="props.availableVariables"
:allowCreate="true"
@change="updateConditionVariable(groupIndex, condIndex)"
>
<option value="" disabled>Select variable</option>
<option
v-for="(variable, index) in props.availableVariables"
:key="variable.name || index"
:value="variable.name"
>
{{ variable.label || variable.name || `Variable ${index}` }}
</option>
</select>
<div v-if="condition.variable" class="mt-1">
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
:class="{
'bg-purple-100 text-purple-800': ['int', 'decimal'].includes(props.availableVariables.find(v => v.name === condition.variable)?.type),
'bg-blue-100 text-blue-800': props.availableVariables.find(v => v.name === condition.variable)?.type === 'string',
'bg-indigo-100 text-indigo-800': props.availableVariables.find(v => v.name === condition.variable)?.type === 'boolean',
'bg-amber-100 text-amber-800': ['date', 'datetime'].includes(props.availableVariables.find(v => v.name === condition.variable)?.type),
'bg-emerald-100 text-emerald-800': props.availableVariables.find(v => v.name === condition.variable)?.type === 'object',
'bg-gray-100 text-gray-800': !['int', 'decimal', 'string', 'boolean', 'date', 'datetime', 'object'].includes(props.availableVariables.find(v => v.name === condition.variable)?.type)
}"
>
{{ props.availableVariables.find(v => v.name === condition.variable)?.type }} type
</span>
</div>
/>
</td>
<td class="px-3 py-2">
<select
@ -323,7 +436,7 @@
</div>
<!-- Condition type selection -->
<div class="mt-3 pt-3 border-t border-gray-200 flex flex-wrap items-center">
<div class="mt-3 pt-3 border-t border-gray-300 flex flex-wrap items-center">
<span class="text-sm font-medium text-gray-700 mr-3">Match Type:</span>
<div class="space-x-4">
<label class="inline-flex items-center">
@ -413,20 +526,13 @@
</select>
</td>
<td class="px-3 py-2">
<select
<VariableBrowser
v-model="action.variable"
class="form-select"
@change="saveChanges"
>
<option value="" disabled>Target variable</option>
<option
v-for="variable in props.availableVariables"
:key="variable.name"
:value="variable.name"
>
{{ variable.label }}
</option>
</select>
:availableVariables="props.availableVariables"
placeholder="Select target variable"
:allowCreate="true"
@update:modelValue="saveChanges"
/>
</td>
<td class="px-3 py-2">
<!-- Set Variable -->
@ -486,6 +592,8 @@
</div>
</div>
</div>
</div> <!-- End Visual Builder -->
</div>
</div>
</template>
@ -495,6 +603,8 @@ import { ref, computed, onMounted, watch } from 'vue';
import { useProcessBuilderStore } from '~/stores/processBuilder';
import { DateTime } from 'luxon';
import { Icon } from '#components';
import RsCodeMirror from '~/components/RsCodeMirror.vue';
import VariableBrowser from './VariableBrowser.vue';
const props = defineProps({
nodeId: {
@ -528,6 +638,12 @@ const localNodeData = ref({
// Use the availableVariables prop instead of creating a computed property
// The prop is already properly formatted from the parent component
// Pseudocode functionality
const editorMode = ref('visual');
const pseudocodeText = ref('');
const pseudocodeErrors = ref([]);
const showPseudocodeExamples = ref(false);
// Initialize with default values if needed
onMounted(() => {
// If we have old-style conditions/actions, migrate them to the new format
@ -541,6 +657,11 @@ onMounted(() => {
localNodeData.value.priority = 'medium';
}
// Initialize pseudocode text with examples
if (!pseudocodeText.value) {
generatePseudocode();
}
saveChanges();
});
@ -954,9 +1075,360 @@ const updateConditionVariable = (groupIndex, condIndex) => {
saveChanges();
};
// Pseudocode functionality methods
const generatePseudocode = () => {
try {
let pseudocode = '';
if (!localNodeData.value.ruleGroups || localNodeData.value.ruleGroups.length === 0) {
pseudocodeText.value = `// No rules defined yet
// You can either:
// 1. Add rules in the visual builder first, then generate pseudocode
// 2. Write pseudocode directly here and convert to visual rules
// Example pseudocode:
IF age >= 18 AND status = "active" THEN
SET eligibility = "approved"
SET discountRate = 0.1
END
IF orderAmount > 1000 THEN
SET priority = "high"
SET freeShipping = true
END`;
return;
}
localNodeData.value.ruleGroups.forEach((ruleGroup, index) => {
if (index > 0) pseudocode += '\n\n';
// Rule comment
pseudocode += `// ${ruleGroup.name || `Rule ${index + 1}`}\n`;
// Build conditions
if (ruleGroup.conditions && ruleGroup.conditions.length > 0) {
pseudocode += 'IF ';
ruleGroup.conditions.forEach((condition, condIndex) => {
if (condIndex > 0) {
pseudocode += ruleGroup.conditionType === 'any' ? ' OR ' : ' AND ';
}
const variable = props.availableVariables.find(v => v.name === condition.variable);
const varName = variable ? variable.name : condition.variable;
const operator = condition.operator;
let value = condition.value;
// Convert operators to readable format
let readableOperator = operator;
switch (operator) {
case 'eq': readableOperator = '='; break;
case 'neq': readableOperator = '≠'; break;
case 'gt': readableOperator = '>'; break;
case 'gte': readableOperator = '≥'; break;
case 'lt': readableOperator = '<'; break;
case 'lte': readableOperator = '≤'; break;
case 'contains': readableOperator = 'contains'; break;
case 'starts_with': readableOperator = 'starts with'; break;
case 'ends_with': readableOperator = 'ends with'; break;
case 'is_empty': readableOperator = 'is empty'; value = ''; break;
case 'is_not_empty': readableOperator = 'is not empty'; value = ''; break;
case 'is_true': readableOperator = 'is'; value = 'true'; break;
case 'is_false': readableOperator = 'is'; value = 'false'; break;
}
if (value && variable?.type === 'string' && !['true', 'false'].includes(value.toString().toLowerCase())) {
value = `"${value}"`;
}
pseudocode += `${varName} ${readableOperator}`;
if (value !== '') {
pseudocode += ` ${value}`;
}
});
pseudocode += ' THEN\n';
// Build actions
if (ruleGroup.actions && ruleGroup.actions.length > 0) {
ruleGroup.actions.forEach(action => {
const targetVar = props.availableVariables.find(v => v.name === action.variable);
const varName = targetVar ? targetVar.name : action.variable;
switch (action.type) {
case 'set_variable':
let setValue = action.value;
if (setValue && targetVar?.type === 'string' && !['true', 'false'].includes(setValue.toString().toLowerCase())) {
setValue = `"${setValue}"`;
}
pseudocode += ` SET ${varName} = ${setValue}\n`;
break;
case 'calculate':
const op = action.operator === 'add' ? '+' : action.operator === 'subtract' ? '-' : action.operator === 'multiply' ? '*' : '/';
pseudocode += ` SET ${varName} = ${varName} ${op} ${action.value}\n`;
break;
case 'increment':
pseudocode += ` SET ${varName} = ${varName} + 1\n`;
break;
case 'decrement':
pseudocode += ` SET ${varName} = ${varName} - 1\n`;
break;
}
});
} else {
pseudocode += ' // No actions defined\n';
}
pseudocode += 'END';
} else {
pseudocode += '// No conditions defined';
}
});
pseudocodeText.value = pseudocode;
pseudocodeErrors.value = [];
} catch (error) {
pseudocodeErrors.value = [`Failed to generate pseudocode: ${error.message}`];
}
};
const parsePseudocode = () => {
try {
pseudocodeErrors.value = [];
if (!pseudocodeText.value.trim()) {
pseudocodeErrors.value = ['Pseudocode is empty'];
return;
}
const lines = pseudocodeText.value.split('\n').map(line => line.trim()).filter(line => line && !line.startsWith('//'));
const newRuleGroups = [];
let currentRule = null;
let inRule = false;
let ruleIndex = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.toUpperCase().startsWith('IF ')) {
// Start new rule
currentRule = {
name: `Rule ${++ruleIndex}`,
conditions: [],
actions: [],
conditionType: 'all'
};
inRule = true;
// Parse conditions
const conditionPart = line.substring(3).replace(/\s+THEN\s*$/i, '');
const conditions = parseConditions(conditionPart);
currentRule.conditions = conditions.conditions;
currentRule.conditionType = conditions.type;
if (line.toUpperCase().includes(' THEN')) {
// Single line IF-THEN, look for actions on next lines
continue;
}
} else if (line.toUpperCase().startsWith('SET ') && inRule && currentRule) {
// Parse action
const action = parseAction(line);
if (action) {
currentRule.actions.push(action);
}
} else if (line.toUpperCase() === 'END' && inRule && currentRule) {
// End of rule
newRuleGroups.push(currentRule);
currentRule = null;
inRule = false;
} else if (line.toUpperCase().includes(' THEN ')) {
// Single line IF-THEN-SET
const parts = line.split(/\s+THEN\s+/i);
if (parts.length === 2) {
const conditionPart = parts[0].replace(/^IF\s+/i, '');
const actionPart = parts[1];
currentRule = {
name: `Rule ${++ruleIndex}`,
conditions: [],
actions: [],
conditionType: 'all'
};
const conditions = parseConditions(conditionPart);
currentRule.conditions = conditions.conditions;
currentRule.conditionType = conditions.type;
const action = parseAction(actionPart);
if (action) {
currentRule.actions.push(action);
}
newRuleGroups.push(currentRule);
currentRule = null;
}
}
}
// Add any remaining rule
if (currentRule && inRule) {
newRuleGroups.push(currentRule);
}
if (newRuleGroups.length === 0) {
pseudocodeErrors.value = ['No valid rules found in pseudocode'];
return;
}
// Update the rules
localNodeData.value.ruleGroups = newRuleGroups;
saveChanges();
// Switch back to visual mode to see the results
editorMode.value = 'visual';
} catch (error) {
pseudocodeErrors.value = [`Failed to parse pseudocode: ${error.message}`];
}
};
const parseConditions = (conditionText) => {
const conditions = [];
let conditionType = 'all'; // Default to AND
// Check if it's OR logic
if (conditionText.toUpperCase().includes(' OR ')) {
conditionType = 'any';
const parts = conditionText.split(/\s+OR\s+/i);
parts.forEach(part => {
const condition = parseSingleCondition(part.trim());
if (condition) conditions.push(condition);
});
} else if (conditionText.toUpperCase().includes(' AND ')) {
const parts = conditionText.split(/\s+AND\s+/i);
parts.forEach(part => {
const condition = parseSingleCondition(part.trim());
if (condition) conditions.push(condition);
});
} else {
// Single condition
const condition = parseSingleCondition(conditionText.trim());
if (condition) conditions.push(condition);
}
return { conditions, type: conditionType };
};
const parseSingleCondition = (conditionText) => {
// Handle different operators
const operators = [
{ pattern: /\s+(>=|≥)\s+/, value: 'gte' },
{ pattern: /\s+(<=|≤)\s+/, value: 'lte' },
{ pattern: /\s+(!=|≠)\s+/, value: 'neq' },
{ pattern: /\s+(>)\s+/, value: 'gt' },
{ pattern: /\s+(<)\s+/, value: 'lt' },
{ pattern: /\s+(=)\s+/, value: 'eq' },
{ pattern: /\s+contains\s+/i, value: 'contains' },
{ pattern: /\s+starts\s+with\s+/i, value: 'starts_with' },
{ pattern: /\s+ends\s+with\s+/i, value: 'ends_with' },
{ pattern: /\s+is\s+empty/i, value: 'is_empty' },
{ pattern: /\s+is\s+not\s+empty/i, value: 'is_not_empty' },
{ pattern: /\s+is\s+true/i, value: 'is_true' },
{ pattern: /\s+is\s+false/i, value: 'is_false' },
{ pattern: /\s+is\s+/i, value: 'eq' }
];
for (const op of operators) {
const match = conditionText.match(op.pattern);
if (match) {
const variable = conditionText.substring(0, match.index).trim();
const value = conditionText.substring(match.index + match[0].length).trim();
// Clean up quotes
let cleanValue = value.replace(/^["']|["']$/g, '');
// Handle special cases
if (['is_empty', 'is_not_empty', 'is_true', 'is_false'].includes(op.value)) {
cleanValue = '';
}
return {
variable: variable,
operator: op.value,
value: cleanValue
};
}
}
return null;
};
const parseAction = (actionText) => {
if (actionText.toUpperCase().startsWith('SET ')) {
const actionPart = actionText.substring(4).trim();
// Handle different action types
if (actionPart.includes(' = ')) {
const [variable, expression] = actionPart.split(' = ').map(s => s.trim());
// Check if it's a calculation
if (expression.includes(variable)) {
// It's a calculation like "amount = amount + 10"
const calcMatch = expression.match(new RegExp(`${variable}\\s*([+\\-*/])\\s*(.+)`));
if (calcMatch) {
const operator = calcMatch[1] === '+' ? 'add' : calcMatch[1] === '-' ? 'subtract' : calcMatch[1] === '*' ? 'multiply' : 'divide';
return {
type: 'calculate',
variable: variable,
operator: operator,
value: calcMatch[2].trim()
};
}
// Check for increment/decrement
if (expression === `${variable} + 1`) {
return { type: 'increment', variable: variable };
}
if (expression === `${variable} - 1`) {
return { type: 'decrement', variable: variable };
}
}
// Regular assignment
let value = expression.replace(/^["']|["']$/g, ''); // Remove quotes
return {
type: 'set_variable',
variable: variable,
value: value
};
}
}
return null;
};
const onPseudocodeChange = () => {
// Clear errors when user starts typing
if (pseudocodeErrors.value.length > 0) {
pseudocodeErrors.value = [];
}
};
</script>
<style scoped>
.pseudocode-editor {
/* Custom styling for the pseudocode editor */
}
.pseudocode-editor :deep(.cm-editor) {
border: none;
}
.pseudocode-editor :deep(.cm-focused) {
outline: none;
}
.form-control {
@apply block w-full p-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-purple-500 focus:border-purple-500 sm:text-sm;
}
@ -1004,4 +1476,4 @@ td {
.rule-group:hover {
@apply shadow-md border-purple-300;
}
</style>
</style>

View File

@ -123,12 +123,12 @@
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Process Variable</label>
<FormKit
type="select"
<VariableBrowser
v-model="mapping.processVariable"
:options="processVariableOptions"
placeholder="Select a process variable"
:classes="{ outer: 'mb-0' }"
:availableVariables="availableVariables"
placeholder="Select process variable"
:allowCreate="true"
@update:modelValue="saveChanges"
/>
<p class="mt-1 text-xs text-gray-500">
The source variable containing the data
@ -227,28 +227,13 @@
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Process Variable</label>
<div class="flex items-center gap-2">
<FormKit
type="select"
v-model="mapping.processVariable"
:options="[
{ label: 'Create new variable', value: 'create_new_' + getVariableNameFromFormField(mapping.formField) },
...processVariableOptions
]"
placeholder="Select a variable"
:classes="{ outer: 'mb-0', input: 'flex-1' }"
/>
<RsButton
v-if="getStringValue(mapping.processVariable) && getStringValue(mapping.processVariable).startsWith('create_new_')"
@click="createVariableFromMapping(mapping)"
variant="primary"
size="sm"
title="Create this variable"
class="flex-shrink-0 btn-add-var"
>
<Icon name="material-symbols:add" />
</RsButton>
</div>
<VariableBrowser
v-model="mapping.processVariable"
:availableVariables="availableVariables"
placeholder="Select process variable"
:allowCreate="true"
@update:modelValue="saveChanges"
/>
<p class="mt-1 text-xs text-gray-500">
The target variable to store form data
</p>
@ -330,12 +315,12 @@
<!-- Process Variable -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Process Variable</label>
<FormKit
type="select"
<VariableBrowser
v-model="condition.processVariable"
:options="processVariableOptions"
placeholder="Select variable"
:classes="{ outer: 'mb-0' }"
:availableVariables="availableVariables"
placeholder="Select process variable"
:allowCreate="true"
@update:modelValue="saveChanges"
/>
<p class="mt-1 text-xs text-gray-500">Variable to check</p>
</div>
@ -609,6 +594,7 @@
import { ref, computed, watch, nextTick } from 'vue';
import { useProcessBuilderStore } from '@/stores/processBuilder';
import FormSelector from './FormSelector.vue';
import VariableBrowser from './VariableBrowser.vue';
import { Icon } from '#components';
const props = defineProps({

View File

@ -1,5 +1,7 @@
<script setup>
import { ref, computed, watch } from 'vue';
import VariableBrowser from './VariableBrowser.vue';
import { Icon } from '#components';
const props = defineProps({
conditions: {
@ -602,19 +604,13 @@ const getPathSummary = (group) => {
<!-- Variable -->
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Variable</label>
<select
<VariableBrowser
v-model="condition.variable"
@change="updateCondition(groupIndex, conditionIndex, 'variable', condition.variable)"
class="w-full p-2 border rounded-md shadow-sm focus:border-orange-500 focus:ring focus:ring-orange-200 text-sm"
>
<option
v-for="variable in availableVariables"
:key="variable.name"
:value="variable.name"
>
{{ variable.name }} ({{ variable.type }})
</option>
</select>
:availableVariables="availableVariables"
placeholder="Select variable"
:allowCreate="true"
@update:modelValue="updateCondition(groupIndex, conditionIndex, 'variable', condition.variable)"
/>
<div v-if="condition.variable" class="mt-1 flex items-center">
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"

View File

@ -187,16 +187,13 @@
<div v-if="localNodeData.recipientType === 'variable'">
<label class="block text-sm font-medium text-gray-700 mb-1">Select Variable</label>
<select
v-model="localNodeData.recipientVariable"
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
@change="saveChanges"
>
<option value="">Select a variable</option>
<option v-for="variable in availableVariables" :key="variable.name" :value="variable.name">
{{ variable.label || variable.name }}
</option>
</select>
<VariableBrowser
v-model="localNodeData.recipientVariable"
:availableVariables="availableVariables"
placeholder="Select recipient variable"
:allowCreate="true"
@update:modelValue="saveChanges"
/>
<p class="mt-1 text-xs text-gray-500">
Variable should contain a user ID, role ID, or email address
</p>
@ -229,7 +226,17 @@
<!-- Subject -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Subject</label>
<div class="flex gap-2 mb-1">
<VariableBrowser
:availableVariables="availableVariables"
placeholder="Insert Variable into Subject..."
:allowCreate="false"
@update:modelValue="insertVariableIntoSubject($event)"
class="flex-grow"
/>
</div>
<input
id="subjectField"
v-model="localNodeData.subject"
type="text"
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
@ -279,14 +286,25 @@
</div>
<!-- Plain Text Editor -->
<textarea
v-if="localNodeData.messageFormat === 'text'"
v-model="localNodeData.message"
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
placeholder="Enter notification message"
rows="4"
@blur="saveChanges"
></textarea>
<div v-if="localNodeData.messageFormat === 'text'">
<div class="flex gap-2 mb-1">
<VariableBrowser
:availableVariables="availableVariables"
placeholder="Insert Variable into Message..."
:allowCreate="false"
@update:modelValue="insertVariableIntoMessage($event)"
class="flex-grow"
/>
</div>
<textarea
id="messageField"
v-model="localNodeData.message"
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
placeholder="Enter notification message"
rows="4"
@blur="saveChanges"
></textarea>
</div>
<!-- Rich Text Editor -->
<div v-if="localNodeData.messageFormat === 'richtext'" class="border rounded-md">
@ -458,6 +476,8 @@
<script setup>
import { ref, computed, watch } from 'vue';
import VariableBrowser from './VariableBrowser.vue';
import { Icon } from '#components';
const props = defineProps({
nodeData: {
@ -756,6 +776,47 @@ const insertHtmlSnippet = (code) => {
saveChanges();
};
// Variable insertion helper methods
const insertVariableIntoSubject = (variableName) => {
if (!variableName) return;
const field = document.getElementById('subjectField');
if (field) {
const cursorPos = field.selectionStart;
const textBefore = localNodeData.value.subject.substring(0, cursorPos);
const textAfter = localNodeData.value.subject.substring(field.selectionEnd);
localNodeData.value.subject = textBefore + `{${variableName}}` + textAfter;
saveChanges();
// Set cursor position after the inserted variable
setTimeout(() => {
field.focus();
field.setSelectionRange(cursorPos + variableName.length + 2, cursorPos + variableName.length + 2);
}, 0);
}
};
const insertVariableIntoMessage = (variableName) => {
if (!variableName) return;
const field = document.getElementById('messageField');
if (field) {
const cursorPos = field.selectionStart;
const textBefore = localNodeData.value.message.substring(0, cursorPos);
const textAfter = localNodeData.value.message.substring(field.selectionEnd);
localNodeData.value.message = textBefore + `{${variableName}}` + textAfter;
saveChanges();
// Set cursor position after the inserted variable
setTimeout(() => {
field.focus();
field.setSelectionRange(cursorPos + variableName.length + 2, cursorPos + variableName.length + 2);
}, 0);
}
};
// Watch for message format changes to enable email delivery for HTML and Rich Text
watch(() => localNodeData.value.messageFormat, (newFormat) => {
if ((newFormat === 'html' || newFormat === 'richtext') && !localNodeData.value.deliveryOptions.email) {

View File

@ -0,0 +1,468 @@
<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"
>
<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>
<option value="date">Date</option>
<option value="datetime">DateTime</option>
<option value="object">Object (JSON)</option>
</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(() => {
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: [] }
};
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 (types[type]) {
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',
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'
};
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':
try {
const str = typeof value === 'string' ? value : JSON.stringify(value);
return str.length > 30 ? `${str.substring(0, 30)}...` : str;
} catch {
return 'Invalid JSON';
}
default:
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>