Merge branch 'demo' into demo-pagewrapper

This commit is contained in:
Afiq 2025-08-06 16:41:32 +08:00
commit f217b90a98
26 changed files with 4901 additions and 798 deletions

View File

@ -27,14 +27,21 @@
:label="component.props.label" :help="component.props.help" :placeholder="component.props.placeholder" :label="component.props.label" :help="component.props.help" :placeholder="component.props.placeholder"
:validation="component.props.validation" :validation-visibility="isPreview ? 'live' : 'blur'" :validation="component.props.validation" :validation-visibility="isPreview ? 'live' : 'blur'"
:readonly="component.props.readonly || isPreview" :readonly="component.props.readonly || isPreview"
:disabled="isPreview" :options="component.props.options || undefined" :value="component.props.value || undefined" :disabled="isPreview || (component.props.readonly && ['select', 'checkbox', 'radio'].includes(component.type))"
:options="component.props.options || undefined" :value="component.props.value || undefined"
:accept="component.props.accept || undefined" :max="component.props.max || undefined" :accept="component.props.accept || undefined" :max="component.props.max || undefined"
:mask="component.props.mask || undefined" :digits="component.props.digits || undefined" :mask="component.props.mask || undefined" :digits="component.props.digits || undefined"
:multiple="component.props.multiple || undefined" :maxSize="component.props.maxSize || undefined" :multiple="component.props.multiple || undefined" :maxSize="component.props.maxSize || undefined"
:maxFiles="component.props.maxFiles || undefined" :classes="component.type === 'checkbox' ? { :maxFiles="component.props.maxFiles || undefined" :rows="component.type === 'textarea' ? (component.props.rows || 3) : undefined"
:classes="component.type === 'checkbox' ? {
wrapper: 'mb-1', wrapper: 'mb-1',
options: 'space-y-0.5' options: 'space-y-0.5'
} : {}" :class="{ 'canvas-component': isPreview }" /> } : {}" :class="{
'canvas-component': isPreview,
'readonly-select': component.props.readonly && component.type === 'select',
'readonly-checkbox': component.props.readonly && component.type === 'checkbox',
'readonly-radio': component.props.readonly && component.type === 'radio'
}" />
<!-- Heading --> <!-- Heading -->
<div v-else-if="component.type === 'heading'" class="py-2"> <div v-else-if="component.type === 'heading'" class="py-2">
@ -325,15 +332,70 @@
<!-- Button Component --> <!-- Button Component -->
<div v-else-if="component.type === 'button'" class="py-2"> <div v-else-if="component.type === 'button'" class="py-2">
<label v-if="component.props.label" class="block text-sm font-medium text-gray-700 mb-2"> <label v-if="component.props.label && component.props.showLabel !== false" class="block text-sm font-medium text-gray-700 mb-2">
{{ component.props.label }} {{ component.props.label }}
</label> </label>
<RsButton :type="component.props.buttonType || 'button'" :variant="component.props.variant || 'primary'" <!-- Custom Button with Custom Colors -->
:size="component.props.size || 'md'" :disabled="component.props.disabled || false" @click="handleButtonClick" <div v-if="component.props.variant === 'custom'" class="inline-block">
class="button-component"> <!-- Link Button with Custom Colors -->
{{ component.props.label || 'Button' }} <a v-if="component.props.linkType && component.props.linkType !== 'none' && getButtonLink()"
</RsButton> :href="getButtonLink()"
:target="component.props.linkTarget || '_self'"
class="inline-block">
<button
:type="component.props.buttonType || 'button'"
:disabled="component.props.disabled || false"
:class="getButtonSizeClass(component.props.size)"
:style="getCustomButtonStyles(component.props)"
:data-hover-effect="component.props.customHoverEffect || 'none'"
class="button-component custom-button"
@click="handleButtonClick"
>
<span v-if="component.props.showButtonText !== false">{{ component.props.buttonText || component.props.label || 'Button' }}</span>
<Icon v-if="component.props.icon" :name="component.props.icon" class="w-4 h-4" :class="{'ml-2': component.props.showButtonText !== false}" />
</button>
</a>
<!-- Regular Button with Custom Colors (no link) -->
<button
v-else
:type="component.props.buttonType || 'button'"
:disabled="component.props.disabled || false"
:class="getButtonSizeClass(component.props.size)"
:style="getCustomButtonStyles(component.props)"
:data-hover-effect="component.props.customHoverEffect || 'none'"
class="button-component custom-button"
@click="handleButtonClick"
>
<span v-if="component.props.showButtonText !== false">{{ component.props.buttonText || component.props.label || 'Button' }}</span>
<Icon v-if="component.props.icon" :name="component.props.icon" class="w-4 h-4" :class="{'ml-2': component.props.showButtonText !== false}" />
</button>
</div>
<!-- Standard Button (non-custom colors) -->
<div v-else class="inline-block">
<!-- Link Button -->
<a v-if="component.props.linkType && component.props.linkType !== 'none' && getButtonLink()"
:href="getButtonLink()"
:target="component.props.linkTarget || '_self'"
class="inline-block">
<RsButton :type="component.props.buttonType || 'button'" :variant="component.props.variant || 'primary'"
:size="component.props.size || 'md'" :disabled="component.props.disabled || false"
class="button-component">
<span v-if="component.props.showButtonText !== false">{{ component.props.buttonText || component.props.label || 'Button' }}</span>
<Icon v-if="component.props.icon" :name="component.props.icon" class="w-4 h-4" :class="{'ml-2': component.props.showButtonText !== false}" />
</RsButton>
</a>
<!-- Regular Button (no link) -->
<RsButton v-else :type="component.props.buttonType || 'button'" :variant="component.props.variant || 'primary'"
:size="component.props.size || 'md'" :disabled="component.props.disabled || false" @click="handleButtonClick"
class="button-component">
<span v-if="component.props.showButtonText !== false">{{ component.props.buttonText || component.props.label || 'Button' }}</span>
<Icon v-if="component.props.icon" :name="component.props.icon" class="w-4 h-4" :class="{'ml-2': component.props.showButtonText !== false}" />
</RsButton>
</div>
<div v-if="component.props.help" class="mt-1 text-xs text-gray-500"> <div v-if="component.props.help" class="mt-1 text-xs text-gray-500">
{{ component.props.help }} {{ component.props.help }}
@ -394,7 +456,7 @@
'no-header': !component.props.showHeader 'no-header': !component.props.showHeader
}" }"
> >
<!-- In preview mode, show the nested components or placeholder --> <!-- In preview mode, show only the nested components (no placeholder) -->
<div v-if="isPreview" class="section-fields"> <div v-if="isPreview" class="section-fields">
<!-- Render nested components if they exist --> <!-- Render nested components if they exist -->
<div v-if="component.props.children && component.props.children.length > 0" class="space-y-3"> <div v-if="component.props.children && component.props.children.length > 0" class="space-y-3">
@ -402,17 +464,7 @@
<component-preview :component="childComponent" :is-preview="true" /> <component-preview :component="childComponent" :is-preview="true" />
</template> </template>
</div> </div>
<!-- No placeholder in preview mode when empty -->
<!-- Show placeholder if no nested components -->
<div v-else class="section-placeholder">
<div class="placeholder-content">
<Icon name="material-symbols:dashboard-customize-outline" class="w-8 h-8 text-gray-300 mx-auto mb-2" />
<p class="text-sm text-gray-500 text-center mb-2">Form Section Container</p>
<p class="text-xs text-gray-400 text-center">
This section will contain the form fields grouped here
</p>
</div>
</div>
</div> </div>
<!-- In form builder mode, show drop zone --> <!-- In form builder mode, show drop zone -->
@ -477,14 +529,15 @@
class="section-drop-placeholder" class="section-drop-placeholder"
:class="{ :class="{
'drop-active': sectionDropStates[component.id]?.isDraggingOver, 'drop-active': sectionDropStates[component.id]?.isDraggingOver,
'empty': !component.props.children || component.props.children.length === 0 'empty': !component.props.children || component.props.children.length === 0,
'hidden-placeholder': component.props.showPlaceholder === false
}" }"
@dragover.prevent="handleSectionDragOver($event, component.id)" @dragover.prevent="handleSectionDragOver($event, component.id)"
@dragleave="handleSectionDragLeave($event, component.id)" @dragleave="handleSectionDragLeave($event, component.id)"
@drop="handleSectionDrop($event, component.id)" @drop="handleSectionDrop($event, component.id)"
@dragenter.prevent="handleSectionDragEnter($event, component.id)" @dragenter.prevent="handleSectionDragEnter($event, component.id)"
> >
<div class="placeholder-content"> <div v-if="component.props.showPlaceholder !== false" class="placeholder-content">
<Icon name="material-symbols:add-box-outline" class="w-8 h-8 text-gray-300 mx-auto mb-2" /> <Icon name="material-symbols:add-box-outline" class="w-8 h-8 text-gray-300 mx-auto mb-2" />
<p class="text-sm text-gray-500 text-center mb-1">Drop Components Here</p> <p class="text-sm text-gray-500 text-center mb-1">Drop Components Here</p>
<p class="text-xs text-gray-400 text-center"> <p class="text-xs text-gray-400 text-center">
@ -534,7 +587,7 @@ const props = defineProps({
} }
}); });
const emit = defineEmits(['select-nested-component']); const emit = defineEmits(['select-nested-component', 'form-data-updated']);
// Get access to the form builder store // Get access to the form builder store
const formStore = useFormBuilderStore(); const formStore = useFormBuilderStore();
@ -593,6 +646,37 @@ onMounted(() => {
} }
} }
} }
// Initialize repeating groups
if (props.component.type === 'repeating-group') {
const groupName = props.component.props.name;
if (groupName) {
// Get current groups or initialize empty array
const currentGroups = safeGetField(groupName, formStore.previewFormData);
// If no groups exist and minItems is specified, create initial groups
if ((!currentGroups || currentGroups.length === 0) && props.component.props.minItems > 0) {
const initialGroups = [];
const minItems = props.component.props.minItems || 1;
for (let i = 0; i < minItems; i++) {
const newGroup = {};
// Add fields from configuration
if (props.component.props.fields) {
props.component.props.fields.forEach(field => {
newGroup[field.name] = '';
});
}
initialGroups.push(newGroup);
}
const updatedData = { ...formStore.previewFormData, [groupName]: initialGroups };
formStore.updatePreviewFormData(updatedData);
}
}
}
}); });
// Watch for changes to component props, especially defaultItems // Watch for changes to component props, especially defaultItems
@ -609,6 +693,39 @@ watch(() => props.component.props.defaultItems, (newDefaultItems, oldDefaultItem
} }
}, { deep: true, immediate: true }); }, { deep: true, immediate: true });
// Watch for changes to repeating group props
watch(() => props.component.props.minItems, (newMinItems, oldMinItems) => {
if (props.component.type === 'repeating-group') {
const groupName = props.component.props.name;
if (!groupName) return;
const currentGroups = safeGetField(groupName, formStore.previewFormData);
const minItems = newMinItems || 1;
// If current groups are fewer than minItems, add missing groups
if ((!currentGroups || currentGroups.length < minItems) && minItems > 0) {
const groupsToAdd = minItems - (currentGroups?.length || 0);
const updatedGroups = [...(currentGroups || [])];
for (let i = 0; i < groupsToAdd; i++) {
const newGroup = {};
// Add fields from configuration
if (props.component.props.fields) {
props.component.props.fields.forEach(field => {
newGroup[field.name] = '';
});
}
updatedGroups.push(newGroup);
}
const updatedData = { ...formStore.previewFormData, [groupName]: updatedGroups };
formStore.updatePreviewFormData(updatedData);
}
}
}, { deep: true, immediate: true });
// Repeating group and dynamic list functionality // Repeating group and dynamic list functionality
const addGroupItem = () => { const addGroupItem = () => {
if (props.isPreview) return; if (props.isPreview) return;
@ -635,6 +752,9 @@ const addGroupItem = () => {
// Update the form data // Update the form data
const updatedData = { ...formStore.previewFormData, [groupName]: currentGroups }; const updatedData = { ...formStore.previewFormData, [groupName]: currentGroups };
formStore.updatePreviewFormData(updatedData); formStore.updatePreviewFormData(updatedData);
// Also emit an event to notify parent components about the data change
emit('form-data-updated', updatedData);
}; };
const removeGroupItem = (index) => { const removeGroupItem = (index) => {
@ -646,12 +766,22 @@ const removeGroupItem = (index) => {
// Get current groups // Get current groups
const currentGroups = [...(safeGetField(groupName, formStore.previewFormData) || [])]; const currentGroups = [...(safeGetField(groupName, formStore.previewFormData) || [])];
// Check if we can remove this item (respect minimum items)
const minItems = props.component.props.minItems || 1;
if (currentGroups.length <= minItems) {
return;
}
// Remove the group at the specified index // Remove the group at the specified index
currentGroups.splice(index, 1); currentGroups.splice(index, 1);
// Update the form data // Update the form data
const updatedData = { ...formStore.previewFormData, [groupName]: currentGroups }; const updatedData = { ...formStore.previewFormData, [groupName]: currentGroups };
formStore.updatePreviewFormData(updatedData); formStore.updatePreviewFormData(updatedData);
// Also emit an event to notify parent components about the data change
// This is important for FormKit integration
emit('form-data-updated', updatedData);
}; };
const addListItem = () => { const addListItem = () => {
@ -1247,6 +1377,106 @@ const saveNestedComponentSettings = (updatedComponent) => {
// Close the modal // Close the modal
closeNestedSettingsModal(); closeNestedSettingsModal();
}; };
// Button link functionality
const getButtonLink = () => {
if (!props.component || props.component.type !== 'button') return null;
const { linkType, linkUrl, linkProcessId, linkTarget, iframeDebug, iframeHideComplete, iframeTheme, iframeCustomParams } = props.component.props;
if (linkType === 'url' && linkUrl) {
return linkUrl;
}
if (linkType === 'process' && linkProcessId) {
// Generate the process workflow URL with iframe parameters
const baseUrl = `${window.location.origin}/workflow/${linkProcessId}`;
const params = new URLSearchParams();
// Add debug parameter (false = iframe mode, true = debug mode)
if (iframeDebug !== undefined) {
params.append('debug', iframeDebug ? 'true' : 'false');
}
// Add hideComplete parameter
if (iframeHideComplete) {
params.append('hideComplete', 'true');
}
// Add theme parameter
if (iframeTheme) {
params.append('theme', iframeTheme);
}
// Add custom parameters
if (iframeCustomParams) {
const customParams = new URLSearchParams(iframeCustomParams);
customParams.forEach((value, key) => {
params.append(key, value);
});
}
const queryString = params.toString();
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
}
return null;
};
// Custom button styling functions
const getCustomButtonStyles = (props) => {
if (!props || props.variant !== 'custom') return {};
const styles = {
backgroundColor: props.customBackgroundColor || '#3b82f6',
color: props.customTextColor || '#ffffff',
border: 'none',
cursor: 'pointer',
transition: 'all 0.2s ease-in-out'
};
// Add border if specified
if (props.customBorderColor && props.customBorderWidth) {
styles.border = `${props.customBorderWidth}px solid ${props.customBorderColor}`;
}
// Add border radius
if (props.customBorderRadius) {
styles.borderRadius = `${props.customBorderRadius}px`;
}
// Add hover effects
const hoverEffect = props.customHoverEffect;
if (hoverEffect && hoverEffect !== 'none') {
switch (hoverEffect) {
case 'darken':
styles[':hover'] = { filter: 'brightness(0.9)' };
break;
case 'lighten':
styles[':hover'] = { filter: 'brightness(1.1)' };
break;
case 'scale':
styles[':hover'] = { transform: 'scale(1.05)' };
break;
case 'glow':
styles[':hover'] = {
boxShadow: `0 0 10px ${props.customBackgroundColor || '#3b82f6'}`
};
break;
}
}
return styles;
};
const getButtonSizeClass = (size) => {
const sizeClasses = {
'sm': 'px-3 py-1.5 text-sm',
'md': 'px-4 py-2 text-sm',
'lg': 'px-6 py-3 text-base'
};
return sizeClasses[size] || sizeClasses['md'];
};
</script> </script>
<style scoped> <style scoped>
@ -1271,6 +1501,32 @@ const saveNestedComponentSettings = (updatedComponent) => {
pointer-events: none; pointer-events: none;
} }
/* Readonly styles for select, checkbox, and radio components */
:deep(.readonly-select),
:deep(.readonly-checkbox),
:deep(.readonly-radio) {
cursor: default !important;
}
:deep(.readonly-select select),
:deep(.readonly-select .formkit-inner),
:deep(.readonly-checkbox input[type="checkbox"]),
:deep(.readonly-radio input[type="radio"]) {
pointer-events: none !important;
opacity: 0.8 !important;
background-color: #f3f4f6 !important;
}
:deep(.readonly-checkbox) .formkit-options,
:deep(.readonly-radio) .formkit-options {
pointer-events: none !important;
}
:deep(.readonly-checkbox) .formkit-wrapper,
:deep(.readonly-radio) .formkit-wrapper {
cursor: not-allowed !important;
}
/* Image Preview Component */ /* Image Preview Component */
.image-preview-container { .image-preview-container {
display: flex; display: flex;
@ -1485,10 +1741,55 @@ const saveNestedComponentSettings = (updatedComponent) => {
background-color: #f1f5f9; background-color: #f1f5f9;
} }
.section-drop-placeholder.hidden-placeholder {
border: none;
background-color: transparent;
padding: 0;
min-height: 40px;
}
/* Ghost styles for dragging */ /* Ghost styles for dragging */
.ghost { .ghost {
opacity: 0.5; opacity: 0.5;
background-color: #dbeafe !important; background-color: #dbeafe !important;
border-color: #3b82f6 !important; border-color: #3b82f6 !important;
} }
/* Custom button styles */
.custom-button {
font-weight: 500;
border-radius: 6px;
transition: all 0.2s ease-in-out;
}
.custom-button:hover {
transform: translateY(-1px);
}
.custom-button:active {
transform: translateY(0);
}
.custom-button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
/* Custom button hover effects */
.custom-button[data-hover-effect="darken"]:hover {
filter: brightness(0.9);
}
.custom-button[data-hover-effect="lighten"]:hover {
filter: brightness(1.1);
}
.custom-button[data-hover-effect="scale"]:hover {
transform: scale(1.05) translateY(-1px);
}
.custom-button[data-hover-effect="glow"]:hover {
box-shadow: 0 0 15px rgba(59, 130, 246, 0.5);
}
</style> </style>

View File

@ -598,6 +598,10 @@ const availableComponents = [
variant: 'primary', // primary, secondary, success, warning, danger variant: 'primary', // primary, secondary, success, warning, danger
size: 'md', // sm, md, lg size: 'md', // sm, md, lg
disabled: false, disabled: false,
showLabel: true, // Whether to show the label above the button
showButtonText: true, // Whether to show text on the button
buttonText: '', // Text to display on button (falls back to label if empty)
icon: '', // Optional icon to display on button
onClick: '' // Custom JavaScript code to execute onClick: '' // Custom JavaScript code to execute
} }
}, },
@ -623,6 +627,7 @@ const availableComponents = [
spacing: 'normal', // compact, normal, relaxed spacing: 'normal', // compact, normal, relaxed
width: '100%', width: '100%',
gridColumn: 'span 12', gridColumn: 'span 12',
showPlaceholder: true, // Whether to show the placeholder in builder mode
children: [], // Array to hold nested components children: [], // Array to hold nested components
// Conditional Logic Properties // Conditional Logic Properties
conditionalLogic: { conditionalLogic: {

View File

@ -218,6 +218,19 @@
rows="2" rows="2"
/> />
<FormKit
v-if="showField('rows')"
type="number"
label="Number of Rows"
name="rows"
v-model="configModel.rows"
help="Number of visible text lines in the textarea"
:classes="{ outer: 'field-wrapper' }"
placeholder="3"
min="1"
max="20"
/>
<FormKit <FormKit
v-if="showField('readonly')" v-if="showField('readonly')"
type="switch" type="switch"
@ -490,26 +503,347 @@
{ label: 'Secondary (Gray)', value: 'secondary' }, { label: 'Secondary (Gray)', value: 'secondary' },
{ label: 'Success (Green)', value: 'success' }, { label: 'Success (Green)', value: 'success' },
{ label: 'Danger (Red)', value: 'danger' }, { label: 'Danger (Red)', value: 'danger' },
{ label: 'Warning (Orange)', value: 'warning' } { label: 'Warning (Orange)', value: 'warning' },
{ label: 'Custom Color', value: 'custom' }
]" ]"
help="Visual appearance of the button" help="Visual appearance of the button"
:classes="{ outer: 'field-wrapper' }" :classes="{ outer: 'field-wrapper' }"
/> />
</div> </div>
<!-- Custom Color Picker -->
<div v-if="configModel.variant === 'custom'" class="space-y-3">
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Custom Color Settings</h5>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormKit
type="color"
label="Background Color"
name="customBackgroundColor"
v-model="configModel.customBackgroundColor"
help="Choose the button background color"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="color"
label="Text Color"
name="customTextColor"
v-model="configModel.customTextColor"
help="Choose the button text color"
:classes="{ outer: 'field-wrapper' }"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormKit
type="color"
label="Border Color"
name="customBorderColor"
v-model="configModel.customBorderColor"
help="Choose the button border color"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="number"
label="Border Width (px)"
name="customBorderWidth"
v-model="configModel.customBorderWidth"
help="Set the border width in pixels"
:classes="{ outer: 'field-wrapper' }"
:min="0"
:max="10"
placeholder="2"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormKit
type="number"
label="Border Radius (px)"
name="customBorderRadius"
v-model="configModel.customBorderRadius"
help="Set the corner roundness in pixels"
:classes="{ outer: 'field-wrapper' }"
:min="0"
:max="50"
placeholder="6"
/>
<FormKit
type="select"
label="Hover Effect"
name="customHoverEffect"
v-model="configModel.customHoverEffect"
:options="[
{ label: 'None', value: 'none' },
{ label: 'Darken', value: 'darken' },
{ label: 'Lighten', value: 'lighten' },
{ label: 'Scale', value: 'scale' },
{ label: 'Glow', value: 'glow' }
]"
help="Choose hover animation effect"
:classes="{ outer: 'field-wrapper' }"
/>
</div>
<!-- Color Preview -->
<div class="mt-4 p-3 bg-gray-50 border border-gray-200 rounded-lg">
<h6 class="text-sm font-medium text-gray-700 mb-2">Color Preview</h6>
<div class="flex items-center space-x-3">
<button
class="px-4 py-2 rounded font-medium transition-all duration-200"
:style="getCustomButtonStyles()"
disabled
>
{{ configModel.buttonText || configModel.label || 'Button Preview' }}
</button>
<div class="text-xs text-gray-600">
<div>Background: {{ configModel.customBackgroundColor || '#3b82f6' }}</div>
<div>Text: {{ configModel.customTextColor || '#ffffff' }}</div>
<div>Border: {{ configModel.customBorderColor || 'transparent' }}</div>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<FormKit
type="select"
label="Button Size"
name="size"
v-model="configModel.size"
:options="[
{ label: 'Small', value: 'sm' },
{ label: 'Medium', value: 'md' },
{ label: 'Large', value: 'lg' }
]"
help="Size of the button"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="text"
label="Icon (Optional)"
name="icon"
v-model="configModel.icon"
help="Icon name (e.g., 'material-symbols:add')"
:classes="{ outer: 'field-wrapper' }"
placeholder="material-symbols:add"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<FormKit
type="switch"
label="Show Label Above Button"
name="showLabel"
v-model="configModel.showLabel"
help="Display the label text above the button"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="switch"
label="Show Text on Button"
name="showButtonText"
v-model="configModel.showButtonText"
help="Display text on the button (icon-only if disabled)"
:classes="{ outer: 'field-wrapper' }"
/>
</div>
<FormKit <FormKit
type="select" v-if="configModel.showButtonText !== false"
label="Button Size" type="text"
name="size" label="Button Text"
v-model="configModel.size" name="buttonText"
:options="[ v-model="configModel.buttonText"
{ label: 'Small', value: 'sm' }, help="Text displayed on the button (uses label if empty)"
{ label: 'Medium', value: 'md' },
{ label: 'Large', value: 'lg' }
]"
help="Size of the button"
:classes="{ outer: 'field-wrapper' }" :classes="{ outer: 'field-wrapper' }"
placeholder="e.g., Submit, Save, Continue"
/> />
<!-- Button Link Settings -->
<div class="mt-6 space-y-3">
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Button Link Settings</h5>
<FormKit
type="select"
label="Link Type"
name="linkType"
v-model="configModel.linkType"
:options="[
{ label: 'No Link', value: 'none' },
{ label: 'Custom URL', value: 'url' },
{ label: 'Process Link', value: 'process' }
]"
help="Choose how the button should behave when clicked"
:classes="{ outer: 'field-wrapper' }"
/>
<!-- Custom URL Link -->
<div v-if="configModel.linkType === 'url'" class="space-y-3">
<FormKit
type="text"
label="URL"
name="linkUrl"
v-model="configModel.linkUrl"
help="Enter the URL to navigate to when button is clicked"
:classes="{ outer: 'field-wrapper' }"
placeholder="https://example.com"
validation="url"
/>
<FormKit
type="select"
label="Open In"
name="linkTarget"
v-model="configModel.linkTarget"
:options="[
{ label: 'Same Window', value: '_self' },
{ label: 'New Window/Tab', value: '_blank' }
]"
help="Choose how the link should open"
:classes="{ outer: 'field-wrapper' }"
/>
</div>
<!-- Process Link -->
<div v-if="configModel.linkType === 'process'" class="space-y-3">
<FormKit
type="select"
label="Select Process"
name="linkProcessId"
v-model="configModel.linkProcessId"
:options="publishedProcesses"
help="Choose a published process to link to"
:classes="{ outer: 'field-wrapper' }"
placeholder="Select a process..."
/>
<FormKit
type="select"
label="Open In"
name="linkTarget"
v-model="configModel.linkTarget"
:options="[
{ label: 'Same Window', value: '_self' },
{ label: 'New Window/Tab', value: '_blank' }
]"
help="Choose how the link should open"
:classes="{ outer: 'field-wrapper' }"
/>
<!-- Iframe Parameters -->
<div class="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<h6 class="text-sm font-medium text-blue-800 mb-3">Iframe Integration Parameters</h6>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<FormKit
type="switch"
label="Debug Mode"
name="iframeDebug"
v-model="configModel.iframeDebug"
help="Enable debug mode (show UI chrome) - OFF for iframe mode"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="switch"
label="Hide Completion"
name="iframeHideComplete"
v-model="configModel.iframeHideComplete"
help="Hide completion message (auto-advance)"
:classes="{ outer: 'field-wrapper' }"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<FormKit
type="select"
label="Theme"
name="iframeTheme"
v-model="configModel.iframeTheme"
:options="[
{ label: 'Default', value: '' },
{ label: 'Dark', value: 'dark' },
{ label: 'Light', value: 'light' }
]"
help="Apply custom theme to the workflow"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="text"
label="Custom Parameters"
name="iframeCustomParams"
v-model="configModel.iframeCustomParams"
help="Additional URL parameters (e.g., 'param1=value1&param2=value2')"
:classes="{ outer: 'field-wrapper' }"
placeholder="param1=value1&param2=value2"
/>
</div>
<div class="mt-3 p-2 bg-gray-100 rounded text-xs text-gray-600">
<strong>Generated URL Preview:</strong><br>
<code class="text-blue-600">{{ getIframeUrlPreview() }}</code>
</div>
</div>
</div>
</div>
<!-- Button Action Script -->
<div class="mt-6 space-y-3">
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Button Click Action</h5>
<p class="text-xs text-gray-600">
Write JavaScript code to execute when this button is clicked. You can access form data and perform actions.
</p>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-3">
<div class="mb-2 text-xs text-gray-600">
<strong>Available functions:</strong> getField(), setField(), showField(), hideField(), enableField(), disableField(), showSuccess(), showError(), showInfo()
</div>
<textarea
v-model="configModel.onClick"
class="w-full h-32 font-mono text-sm p-2 border border-gray-300 rounded"
placeholder="// Example:
// Get values from other fields
const name = getField('name');
const email = getField('email');
// Show a message
if (name && email) {
showSuccess('Form data is valid!');
} else {
showError('Please fill in all required fields');
}"
></textarea>
</div>
<!-- Quick Templates -->
<div class="flex flex-wrap gap-2">
<button
@click="insertButtonScriptTemplate('validation')"
class="text-xs px-2 py-1 bg-blue-50 text-blue-700 border border-blue-200 rounded hover:bg-blue-100"
>
Insert Validation Example
</button>
<button
@click="insertButtonScriptTemplate('calculation')"
class="text-xs px-2 py-1 bg-blue-50 text-blue-700 border border-blue-200 rounded hover:bg-blue-100"
>
Insert Calculation Example
</button>
<button
@click="insertButtonScriptTemplate('toggle')"
class="text-xs px-2 py-1 bg-blue-50 text-blue-700 border border-blue-200 rounded hover:bg-blue-100"
>
Insert Toggle Fields Example
</button>
</div>
</div>
</template> </template>
<!-- Dynamic List Configuration --> <!-- Dynamic List Configuration -->
@ -703,6 +1037,255 @@
</div> </div>
</template> </template>
<!-- Data Table Configuration -->
<template v-if="component.type === 'repeating-table'">
<div class="space-y-6">
<!-- Table Limits -->
<div class="grid grid-cols-2 gap-4">
<FormKit
type="number"
label="Minimum Records"
name="minRecords"
v-model="configModel.minRecords"
help="Minimum number of records required"
:classes="{ outer: 'field-wrapper' }"
:min="0"
placeholder="0"
/>
<FormKit
type="number"
label="Maximum Records"
name="maxRecords"
v-model="configModel.maxRecords"
help="Maximum number of records allowed"
:classes="{ outer: 'field-wrapper' }"
:min="1"
placeholder="50"
/>
</div>
<!-- Button Text Settings -->
<div class="grid grid-cols-2 gap-4">
<FormKit
type="text"
label="Add Button Text"
name="buttonText"
v-model="configModel.buttonText"
help="Text for the add record button"
:classes="{ outer: 'field-wrapper' }"
placeholder="Add Record"
/>
<FormKit
type="text"
label="Edit Button Text"
name="editText"
v-model="configModel.editText"
help="Text for the edit button"
:classes="{ outer: 'field-wrapper' }"
placeholder="Edit"
/>
<FormKit
type="text"
label="Delete Button Text"
name="deleteText"
v-model="configModel.deleteText"
help="Text for the delete button"
:classes="{ outer: 'field-wrapper' }"
placeholder="Delete"
/>
</div>
<!-- Table Behavior Settings -->
<div class="space-y-4">
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Table Behavior</h5>
<div class="grid grid-cols-2 gap-4">
<FormKit
type="switch"
label="Show Row Numbers"
name="showRowNumbers"
v-model="configModel.showRowNumbers"
help="Display row numbers in the table"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="switch"
label="Allow Edit"
name="allowEdit"
v-model="configModel.allowEdit"
help="Allow editing existing records"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="switch"
label="Allow Delete"
name="allowDelete"
v-model="configModel.allowDelete"
help="Allow deleting records"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="switch"
label="Confirm Delete"
name="confirmDelete"
v-model="configModel.confirmDelete"
help="Require confirmation before deleting"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="switch"
label="Enable Search"
name="enableSearch"
v-model="configModel.enableSearch"
help="Add search functionality to the table"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="switch"
label="Enable Export"
name="enableExport"
v-model="configModel.enableExport"
help="Allow exporting table data"
:classes="{ outer: 'field-wrapper' }"
/>
</div>
</div>
<!-- Table Columns Management -->
<div class="space-y-4">
<div class="flex justify-between items-center">
<h5 class="text-sm font-medium text-gray-700">Table Columns</h5>
<RsButton @click="addTableColumn" variant="primary" size="sm">
<Icon name="heroicons:plus" class="w-4 h-4 mr-1" />
Add Column
</RsButton>
</div>
<div class="border rounded-md p-3 bg-gray-50 space-y-3">
<div v-for="(column, index) in (configModel.columns || [])" :key="index" class="border p-3 rounded bg-white">
<div class="flex justify-between items-center mb-3">
<h4 class="font-medium text-sm text-gray-800">Column {{ index + 1 }}</h4>
<button
@click="removeTableColumn(index)"
class="text-red-500 hover:text-red-700"
type="button"
>
<Icon name="material-symbols:delete-outline" class="w-4 h-4" />
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormKit
type="text"
label="Column Name"
v-model="column.name"
placeholder="e.g., name, email, phone"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="text"
label="Column Label"
v-model="column.label"
placeholder="e.g., Name, Email, Phone"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="select"
label="Column Type"
v-model="column.type"
:options="[
{ label: 'Text', value: 'text' },
{ label: 'Number', value: 'number' },
{ label: 'Email', value: 'email' },
{ label: 'Phone', value: 'tel' },
{ label: 'Date', value: 'date' },
{ label: 'Time', value: 'time' },
{ label: 'URL', value: 'url' },
{ label: 'Select', value: 'select' },
{ label: 'Checkbox', value: 'checkbox' },
{ label: 'Text Area', value: 'textarea' }
]"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="text"
label="Placeholder"
v-model="column.placeholder"
placeholder="Enter placeholder text"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="text"
label="Validation Rules"
v-model="column.validation"
placeholder="e.g., required|email"
:classes="{ outer: 'field-wrapper' }"
/>
<div class="flex items-center">
<label class="flex items-center">
<input type="checkbox" v-model="column.required" class="mr-2" />
<span class="text-sm text-gray-700">Required Field</span>
</label>
</div>
</div>
<!-- Options for select type -->
<div v-if="column.type === 'select'" class="mt-4">
<div class="flex justify-between items-center mb-2">
<h6 class="text-sm font-medium text-gray-700">Select Options</h6>
<button
@click="addColumnOption(index)"
class="text-blue-600 hover:text-blue-800 text-sm"
type="button"
>
<Icon name="heroicons:plus" class="w-3 h-3 mr-1" />
Add Option
</button>
</div>
<div class="space-y-2">
<div v-for="(option, optionIndex) in (column.options || [])" :key="optionIndex" class="flex items-center space-x-2">
<input
type="text"
v-model="option.label"
placeholder="Option label"
class="flex-1 border border-gray-300 rounded px-2 py-1 text-sm"
/>
<input
type="text"
v-model="option.value"
placeholder="Option value"
class="flex-1 border border-gray-300 rounded px-2 py-1 text-sm"
/>
<button
@click="removeColumnOption(index, optionIndex)"
class="text-red-500 hover:text-red-700"
type="button"
>
<Icon name="material-symbols:delete-outline" class="w-3 h-3" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<!-- Repeating Group Configuration --> <!-- Repeating Group Configuration -->
<template v-if="component.type === 'repeating-group'"> <template v-if="component.type === 'repeating-group'">
<div class="space-y-6"> <div class="space-y-6">
@ -902,6 +1485,7 @@
/> />
<FormKit <FormKit
v-if="configModel.showHeader"
type="select" type="select"
label="Header Size" label="Header Size"
name="headerSize" name="headerSize"
@ -916,6 +1500,7 @@
/> />
<FormKit <FormKit
v-if="configModel.showHeader"
type="switch" type="switch"
label="Collapsible" label="Collapsible"
name="collapsible" name="collapsible"
@ -925,13 +1510,22 @@
/> />
<FormKit <FormKit
v-if="configModel.showHeader && configModel.collapsible"
type="switch" type="switch"
label="Start Collapsed" label="Start Collapsed"
name="collapsed" name="collapsed"
v-model="configModel.collapsed" v-model="configModel.collapsed"
help="Start with this section collapsed" help="Start with this section collapsed"
:classes="{ outer: 'field-wrapper' }" :classes="{ outer: 'field-wrapper' }"
:disabled="!configModel.collapsible" />
<FormKit
type="switch"
label="Show Placeholder"
name="showPlaceholder"
v-model="configModel.showPlaceholder"
help="Show placeholder when empty in builder mode"
:classes="{ outer: 'field-wrapper' }"
/> />
</div> </div>
</div> </div>
@ -1744,6 +2338,7 @@ const showField = (fieldName) => {
help: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'hidden', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group'], help: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'hidden', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group'],
value: ['heading', 'paragraph', 'hidden'], value: ['heading', 'paragraph', 'hidden'],
width: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'heading', 'paragraph', 'form-section', 'info-display', 'dynamic-list', 'repeating-table', 'repeating-group'], width: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'heading', 'paragraph', 'form-section', 'info-display', 'dynamic-list', 'repeating-table', 'repeating-group'],
rows: ['textarea'],
options: ['select', 'checkbox', 'radio'], options: ['select', 'checkbox', 'radio'],
conditionalLogic: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group'], conditionalLogic: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group'],
readonly: ['text', 'number', 'email', 'textarea', 'mask', 'url', 'tel'] readonly: ['text', 'number', 'email', 'textarea', 'mask', 'url', 'tel']
@ -1787,6 +2382,113 @@ const isTextBasedField = computed(() => {
return ['text', 'textarea', 'email', 'password', 'url', 'tel'].includes(props.component?.type) return ['text', 'textarea', 'email', 'password', 'url', 'tel'].includes(props.component?.type)
}) })
// Published processes for button linking
const publishedProcesses = ref([])
// Fetch published processes when component is a button
const fetchPublishedProcesses = async () => {
if (props.component?.type === 'button') {
try {
const response = await $fetch('/api/process?status=published&limit=1000')
if (response.success) {
publishedProcesses.value = response.data.processes.map(process => ({
label: process.processName,
value: process.processUUID
}))
}
} catch (error) {
console.error('Error fetching published processes:', error)
publishedProcesses.value = []
}
}
}
// Watch for component changes to fetch processes when needed
watch(() => props.component, (newComponent) => {
if (newComponent?.type === 'button') {
fetchPublishedProcesses()
}
}, { immediate: true })
// Generate iframe URL preview
const getIframeUrlPreview = () => {
if (!configModel.value.linkProcessId) {
return 'Select a process first...'
}
const baseUrl = `${window.location.origin}/workflow/${configModel.value.linkProcessId}`
const params = new URLSearchParams()
// Add debug parameter (false = iframe mode, true = debug mode)
if (configModel.value.iframeDebug !== undefined) {
params.append('debug', configModel.value.iframeDebug ? 'true' : 'false')
}
// Add hideComplete parameter
if (configModel.value.iframeHideComplete) {
params.append('hideComplete', 'true')
}
// Add theme parameter
if (configModel.value.iframeTheme) {
params.append('theme', configModel.value.iframeTheme)
}
// Add custom parameters
if (configModel.value.iframeCustomParams) {
const customParams = new URLSearchParams(configModel.value.iframeCustomParams)
customParams.forEach((value, key) => {
params.append(key, value)
})
}
const queryString = params.toString()
return queryString ? `${baseUrl}?${queryString}` : baseUrl
}
// Generate custom button styles for preview
const getCustomButtonStyles = () => {
const styles = {
backgroundColor: configModel.value.customBackgroundColor || '#3b82f6',
color: configModel.value.customTextColor || '#ffffff',
border: 'none',
cursor: 'pointer'
}
// Add border if specified
if (configModel.value.customBorderColor && configModel.value.customBorderWidth) {
styles.border = `${configModel.value.customBorderWidth}px solid ${configModel.value.customBorderColor}`
}
// Add border radius
if (configModel.value.customBorderRadius) {
styles.borderRadius = `${configModel.value.customBorderRadius}px`
}
// Add hover effects
const hoverEffect = configModel.value.customHoverEffect
if (hoverEffect && hoverEffect !== 'none') {
switch (hoverEffect) {
case 'darken':
styles[':hover'] = { filter: 'brightness(0.9)' }
break
case 'lighten':
styles[':hover'] = { filter: 'brightness(1.1)' }
break
case 'scale':
styles[':hover'] = { transform: 'scale(1.05)' }
break
case 'glow':
styles[':hover'] = {
boxShadow: `0 0 10px ${configModel.value.customBackgroundColor || '#3b82f6'}`
}
break
}
}
return styles
}
// Type changing functionality // Type changing functionality
const compatibilityGroups = { const compatibilityGroups = {
// Text-based inputs (can switch between each other) // Text-based inputs (can switch between each other)
@ -2162,6 +2864,80 @@ const handleReset = () => {
} }
} }
// Button script template helper
const insertButtonScriptTemplate = (templateType) => {
let template = '';
switch (templateType) {
case 'validation':
template = `// Form validation example
const name = getField('name');
const email = getField('email');
const phone = getField('phone');
// Validate required fields
if (!name || !email) {
showError('Please fill in all required fields');
return;
}
// Validate email format
if (email && !email.includes('@')) {
showError('Please enter a valid email address');
return;
}
// Success message
showSuccess('Form validation passed!');
`;
break;
case 'calculation':
template = `// Calculation example
const quantity = Number(getField('quantity') || 0);
const price = Number(getField('price') || 0);
const taxRate = 0.08; // 8% tax
// Calculate subtotal
const subtotal = quantity * price;
setField('subtotal', subtotal.toFixed(2));
// Calculate tax
const tax = subtotal * taxRate;
setField('tax', tax.toFixed(2));
// Calculate total
const total = subtotal + tax;
setField('total', total.toFixed(2));
showInfo('Calculation completed');
`;
break;
case 'toggle':
template = `// Toggle fields visibility example
const selectedOption = getField('options');
// Hide all detail fields first
hideField('details_option_1');
hideField('details_option_2');
hideField('details_option_3');
// Show only the relevant detail field
if (selectedOption === 'option_1') {
showField('details_option_1');
} else if (selectedOption === 'option_2') {
showField('details_option_2');
} else if (selectedOption === 'option_3') {
showField('details_option_3');
}
`;
break;
default:
return;
}
configModel.value.onClick = template;
};
const handleClose = () => { const handleClose = () => {
isOpen.value = false isOpen.value = false
emit('close') emit('close')
@ -2431,6 +3207,24 @@ const getDefaultPropsForType = (type) => {
}, },
paragraph: { paragraph: {
value: 'Enter some descriptive text here.' value: 'Enter some descriptive text here.'
},
button: {
type: 'button',
buttonType: 'button',
variant: 'primary',
size: 'md',
showLabel: false,
showButtonText: true,
buttonText: '',
icon: '',
linkType: 'none', // 'none', 'url', 'process'
linkUrl: '',
linkProcessId: '',
linkTarget: '_self', // '_self', '_blank'
iframeDebug: false, // Show/hide debug UI
iframeHideComplete: false, // Hide completion message
iframeTheme: '', // Custom theme
iframeCustomParams: '' // Additional URL parameters
} }
} }

View File

@ -256,7 +256,8 @@ const categories = [
{ id: 'onboarding', name: 'Onboarding' }, { id: 'onboarding', name: 'Onboarding' },
{ id: 'support', name: 'Support & Service' }, { id: 'support', name: 'Support & Service' },
{ id: 'business', name: 'Business Operations' }, { id: 'business', name: 'Business Operations' },
{ id: 'automation', name: 'Automation' } { id: 'automation', name: 'Automation' },
{ id: 'data-driven', name: 'Data-Driven Workflows' }
]; ];
// Active category // Active category
@ -1207,6 +1208,832 @@ const templates = [
{ id: 'e40', source: 'form-10', target: 'notification-11', sourceHandle: 'form-10-bottom', targetHandle: 'notification-11-top', type: 'smoothstep' }, { id: 'e40', source: 'form-10', target: 'notification-11', sourceHandle: 'form-10-bottom', targetHandle: 'notification-11-top', type: 'smoothstep' },
{ id: 'e41', source: 'notification-11', target: 'end-6', sourceHandle: 'notification-11-bottom', targetHandle: 'end-6-top', type: 'smoothstep' } { id: 'e41', source: 'notification-11', target: 'end-6', sourceHandle: 'notification-11-bottom', targetHandle: 'end-6-top', type: 'smoothstep' }
] ]
},
{
id: 'url-data-capture',
name: 'URL Data Capture & Form Pre-fill',
description: 'Capture user ID from URL parameters, fetch user data via API, and pre-fill a form for editing.',
category: 'data-driven',
complexity: 'Simple',
nodeCount: 4,
edgeCount: 3,
variables: [
{
name: 'userId',
type: 'string',
scope: 'global',
description: 'User ID captured from URL parameter',
defaultValue: '',
captureFromUrl: true,
urlParameterName: 'id'
},
{
name: 'userData',
type: 'object',
scope: 'global',
description: 'User data retrieved from API',
defaultValue: {}
},
{
name: 'formSubmissionResult',
type: 'object',
scope: 'global',
description: 'Result of form submission',
defaultValue: {}
}
],
nodes: [
{
id: 'start-url-1',
type: 'start',
position: { x: 100, y: 100 },
data: {
label: 'Start',
description: 'Begin URL data capture process',
backgroundColor: '#dcfce7',
borderColor: '#10b981',
textColor: '#065f46',
shape: 'circle'
}
},
{
id: 'api-fetch-user',
type: 'api',
position: { x: 300, y: 100 },
data: {
label: 'Fetch User Data',
description: 'Retrieve user information from API',
apiMethod: 'GET',
apiUrl: '/api/users/{userId}',
headers: [
{ key: 'Content-Type', value: 'application/json' },
{ key: 'Authorization', value: 'Bearer YOUR_API_TOKEN' }
],
outputVariable: 'userData',
errorVariable: 'apiError',
continueOnError: false,
backgroundColor: '#eff6ff',
borderColor: '#3b82f6',
textColor: '#1e40af',
shape: 'rectangle'
}
},
{
id: 'form-edit-user',
type: 'form',
position: { x: 500, y: 100 },
data: {
label: 'Edit User Information',
description: 'Form pre-filled with user data for editing',
formId: null,
formName: 'User Information Form',
inputMappings: [
{ processVariable: 'userData.name', formField: 'fullName' },
{ processVariable: 'userData.email', formField: 'email' },
{ processVariable: 'userData.phone', formField: 'phoneNumber' }
],
outputMappings: [
{ formField: 'fullName', processVariable: 'updatedName' },
{ formField: 'email', processVariable: 'updatedEmail' },
{ formField: 'phoneNumber', processVariable: 'updatedPhone' }
],
backgroundColor: '#faf5ff',
borderColor: '#9333ea',
textColor: '#6b21a8',
shape: 'rectangle'
}
},
{
id: 'end-url-1',
type: 'end',
position: { x: 700, y: 100 },
data: {
label: 'End',
description: 'Process completed',
backgroundColor: '#fee2e2',
borderColor: '#dc2626',
textColor: '#991b1b',
shape: 'circle'
}
}
],
edges: [
{ id: 'e-url-1', source: 'start-url-1', target: 'api-fetch-user', sourceHandle: 'start-url-1-bottom', targetHandle: 'api-fetch-user-top', type: 'smoothstep' },
{ id: 'e-url-2', source: 'api-fetch-user', target: 'form-edit-user', sourceHandle: 'api-fetch-user-bottom', targetHandle: 'form-edit-user-top', type: 'smoothstep' },
{ id: 'e-url-3', source: 'form-edit-user', target: 'end-url-1', sourceHandle: 'form-edit-user-bottom', targetHandle: 'end-url-1-top', type: 'smoothstep' }
]
},
{
id: 'dynamic-survey',
name: 'Dynamic Survey with Conditional Logic',
description: 'Multi-step survey with conditional questions based on URL parameters and previous answers.',
category: 'data-driven',
complexity: 'Medium',
nodeCount: 6,
edgeCount: 7,
variables: [
{
name: 'surveyType',
type: 'string',
scope: 'global',
description: 'Type of survey from URL parameter',
defaultValue: 'general',
captureFromUrl: true,
urlParameterName: 'type'
},
{
name: 'userRole',
type: 'string',
scope: 'global',
description: 'User role from URL parameter',
defaultValue: 'user',
captureFromUrl: true,
urlParameterName: 'role'
},
{
name: 'basicInfo',
type: 'object',
scope: 'global',
description: 'Basic information from first form',
defaultValue: {}
},
{
name: 'isManager',
type: 'boolean',
scope: 'global',
description: 'Whether user is in management role',
defaultValue: false
},
{
name: 'surveyResults',
type: 'object',
scope: 'global',
description: 'Complete survey results',
defaultValue: {}
}
],
nodes: [
{
id: 'start-survey',
type: 'start',
position: { x: 100, y: 200 },
data: {
label: 'Start Survey',
description: 'Begin dynamic survey process',
backgroundColor: '#dcfce7',
borderColor: '#10b981',
textColor: '#065f46',
shape: 'circle'
}
},
{
id: 'form-basic-info',
type: 'form',
position: { x: 300, y: 200 },
data: {
label: 'Basic Information',
description: 'Collect basic user information',
formId: null,
formName: 'Basic Information Form',
outputMappings: [
{ formField: 'name', processVariable: 'basicInfo.name' },
{ formField: 'department', processVariable: 'basicInfo.department' },
{ formField: 'experience', processVariable: 'basicInfo.experience' }
],
backgroundColor: '#faf5ff',
borderColor: '#9333ea',
textColor: '#6b21a8',
shape: 'rectangle'
}
},
{
id: 'business-rule-role-check',
type: 'business-rule',
position: { x: 500, y: 200 },
data: {
label: 'Check User Role',
description: 'Determine if user is manager based on role parameter',
ruleGroups: [
{
name: 'Manager Check',
conditions: [
{
variable: 'userRole',
operator: 'eq',
value: 'manager',
minValue: null,
maxValue: null
}
],
actions: [
{
type: 'set_variable',
variable: 'isManager',
value: 'true'
}
]
}
],
backgroundColor: '#fdf4ff',
borderColor: '#a855f7',
textColor: '#7c3aed',
shape: 'rectangle'
}
},
{
id: 'gateway-manager-check',
type: 'gateway',
position: { x: 700, y: 200 },
data: {
label: 'Is Manager?',
description: 'Route based on manager status',
conditions: [
{
variable: 'isManager',
operator: 'eq',
value: 'true',
output: 'Manager Path'
}
],
defaultPath: 'Employee Path',
backgroundColor: '#fff7ed',
borderColor: '#f97316',
textColor: '#c2410c',
shape: 'diamond'
}
},
{
id: 'form-manager-questions',
type: 'form',
position: { x: 900, y: 150 },
data: {
label: 'Manager Questions',
description: 'Additional questions for managers',
formId: null,
formName: 'Manager Survey Form',
outputMappings: [
{ formField: 'teamSize', processVariable: 'surveyResults.teamSize' },
{ formField: 'managementStyle', processVariable: 'surveyResults.managementStyle' },
{ formField: 'budgetResponsibility', processVariable: 'surveyResults.budgetResponsibility' }
],
backgroundColor: '#faf5ff',
borderColor: '#9333ea',
textColor: '#6b21a8',
shape: 'rectangle'
}
},
{
id: 'form-employee-questions',
type: 'form',
position: { x: 900, y: 250 },
data: {
label: 'Employee Questions',
description: 'Questions for regular employees',
formId: null,
formName: 'Employee Survey Form',
outputMappings: [
{ formField: 'jobSatisfaction', processVariable: 'surveyResults.satisfaction' },
{ formField: 'workloadRating', processVariable: 'surveyResults.workload' },
{ formField: 'careerGoals', processVariable: 'surveyResults.goals' }
],
backgroundColor: '#faf5ff',
borderColor: '#9333ea',
textColor: '#6b21a8',
shape: 'rectangle'
}
},
{
id: 'end-survey',
type: 'end',
position: { x: 1100, y: 200 },
data: {
label: 'Survey Complete',
description: 'Survey completed successfully',
backgroundColor: '#fee2e2',
borderColor: '#dc2626',
textColor: '#991b1b',
shape: 'circle'
}
}
],
edges: [
{ id: 'e-survey-1', source: 'start-survey', target: 'form-basic-info', sourceHandle: 'start-survey-bottom', targetHandle: 'form-basic-info-top', type: 'smoothstep' },
{ id: 'e-survey-2', source: 'form-basic-info', target: 'business-rule-role-check', sourceHandle: 'form-basic-info-bottom', targetHandle: 'business-rule-role-check-top', type: 'smoothstep' },
{ id: 'e-survey-3', source: 'business-rule-role-check', target: 'gateway-manager-check', sourceHandle: 'business-rule-role-check-bottom', targetHandle: 'gateway-manager-check-top', type: 'smoothstep' },
{ id: 'e-survey-4', source: 'gateway-manager-check', target: 'form-manager-questions', sourceHandle: 'gateway-manager-check-right', targetHandle: 'form-manager-questions-left', type: 'smoothstep', label: 'Manager Path' },
{ id: 'e-survey-5', source: 'gateway-manager-check', target: 'form-employee-questions', sourceHandle: 'gateway-manager-check-bottom', targetHandle: 'form-employee-questions-left', type: 'smoothstep', label: 'Employee Path' },
{ id: 'e-survey-6', source: 'form-manager-questions', target: 'end-survey', sourceHandle: 'form-manager-questions-bottom', targetHandle: 'end-survey-top', type: 'smoothstep' },
{ id: 'e-survey-7', source: 'form-employee-questions', target: 'end-survey', sourceHandle: 'form-employee-questions-bottom', targetHandle: 'end-survey-left', type: 'smoothstep' }
]
},
{
id: 'order-processing',
name: 'Order Processing with Status Updates',
description: 'Process orders with automatic status updates and notifications based on order ID from URL.',
category: 'business',
complexity: 'Medium',
nodeCount: 7,
edgeCount: 8,
variables: [
{
name: 'orderId',
type: 'string',
scope: 'global',
description: 'Order ID from URL parameter',
defaultValue: '',
captureFromUrl: true,
urlParameterName: 'orderId'
},
{
name: 'priority',
type: 'string',
scope: 'global',
description: 'Order priority from URL parameter',
defaultValue: 'normal',
captureFromUrl: true,
urlParameterName: 'priority'
},
{
name: 'orderData',
type: 'object',
scope: 'global',
description: 'Order details from API',
defaultValue: {}
},
{
name: 'isHighPriority',
type: 'boolean',
scope: 'global',
description: 'Whether order is high priority',
defaultValue: false
},
{
name: 'processingResult',
type: 'object',
scope: 'global',
description: 'Result of order processing',
defaultValue: {}
}
],
nodes: [
{
id: 'start-order',
type: 'start',
position: { x: 100, y: 300 },
data: {
label: 'Start Processing',
description: 'Begin order processing',
backgroundColor: '#dcfce7',
borderColor: '#10b981',
textColor: '#065f46',
shape: 'circle'
}
},
{
id: 'api-get-order',
type: 'api',
position: { x: 300, y: 300 },
data: {
label: 'Fetch Order',
description: 'Get order details from system',
apiMethod: 'GET',
apiUrl: '/api/orders/{orderId}',
headers: [
{ key: 'Content-Type', value: 'application/json' },
{ key: 'Authorization', value: 'Bearer YOUR_API_TOKEN' }
],
outputVariable: 'orderData',
errorVariable: 'orderError',
continueOnError: false,
backgroundColor: '#eff6ff',
borderColor: '#3b82f6',
textColor: '#1e40af',
shape: 'rectangle'
}
},
{
id: 'business-rule-priority',
type: 'business-rule',
position: { x: 500, y: 300 },
data: {
label: 'Check Priority',
description: 'Determine processing priority',
ruleGroups: [
{
name: 'High Priority Check',
conditions: [
{
variable: 'priority',
operator: 'eq',
value: 'high',
minValue: null,
maxValue: null
}
],
actions: [
{
type: 'set_variable',
variable: 'isHighPriority',
value: 'true'
}
]
}
],
backgroundColor: '#fdf4ff',
borderColor: '#a855f7',
textColor: '#7c3aed',
shape: 'rectangle'
}
},
{
id: 'gateway-priority-check',
type: 'gateway',
position: { x: 700, y: 300 },
data: {
label: 'High Priority?',
description: 'Route based on priority',
conditions: [
{
variable: 'isHighPriority',
operator: 'eq',
value: 'true',
output: 'Express Processing'
}
],
defaultPath: 'Standard Processing',
backgroundColor: '#fff7ed',
borderColor: '#f97316',
textColor: '#c2410c',
shape: 'diamond'
}
},
{
id: 'api-express-process',
type: 'api',
position: { x: 900, y: 250 },
data: {
label: 'Express Processing',
description: 'Process high priority order',
apiMethod: 'POST',
apiUrl: '/api/orders/{orderId}/process-express',
requestBody: '{"orderId": "{orderId}", "priority": "high"}',
headers: [
{ key: 'Content-Type', value: 'application/json' }
],
outputVariable: 'processingResult',
backgroundColor: '#eff6ff',
borderColor: '#3b82f6',
textColor: '#1e40af',
shape: 'rectangle'
}
},
{
id: 'api-standard-process',
type: 'api',
position: { x: 900, y: 350 },
data: {
label: 'Standard Processing',
description: 'Process regular order',
apiMethod: 'POST',
apiUrl: '/api/orders/{orderId}/process-standard',
requestBody: '{"orderId": "{orderId}", "priority": "normal"}',
headers: [
{ key: 'Content-Type', value: 'application/json' }
],
outputVariable: 'processingResult',
backgroundColor: '#eff6ff',
borderColor: '#3b82f6',
textColor: '#1e40af',
shape: 'rectangle'
}
},
{
id: 'notification-complete',
type: 'notification',
position: { x: 1100, y: 300 },
data: {
label: 'Notify Completion',
description: 'Send processing complete notification',
notificationType: 'success',
recipientType: 'email',
recipientEmail: '{orderData.customerEmail}',
subject: 'Order Processing Complete',
message: 'Your order {orderId} has been processed successfully.',
priority: 'medium',
backgroundColor: '#f0f9ff',
borderColor: '#0ea5e9',
textColor: '#0284c7',
shape: 'rectangle'
}
},
{
id: 'end-order',
type: 'end',
position: { x: 1300, y: 300 },
data: {
label: 'Complete',
description: 'Order processing complete',
backgroundColor: '#fee2e2',
borderColor: '#dc2626',
textColor: '#991b1b',
shape: 'circle'
}
}
],
edges: [
{ id: 'e-order-1', source: 'start-order', target: 'api-get-order', sourceHandle: 'start-order-bottom', targetHandle: 'api-get-order-top', type: 'smoothstep' },
{ id: 'e-order-2', source: 'api-get-order', target: 'business-rule-priority', sourceHandle: 'api-get-order-bottom', targetHandle: 'business-rule-priority-top', type: 'smoothstep' },
{ id: 'e-order-3', source: 'business-rule-priority', target: 'gateway-priority-check', sourceHandle: 'business-rule-priority-bottom', targetHandle: 'gateway-priority-check-top', type: 'smoothstep' },
{ id: 'e-order-4', source: 'gateway-priority-check', target: 'api-express-process', sourceHandle: 'gateway-priority-check-right', targetHandle: 'api-express-process-left', type: 'smoothstep', label: 'Express Processing' },
{ id: 'e-order-5', source: 'gateway-priority-check', target: 'api-standard-process', sourceHandle: 'gateway-priority-check-bottom', targetHandle: 'api-standard-process-left', type: 'smoothstep', label: 'Standard Processing' },
{ id: 'e-order-6', source: 'api-express-process', target: 'notification-complete', sourceHandle: 'api-express-process-bottom', targetHandle: 'notification-complete-top', type: 'smoothstep' },
{ id: 'e-order-7', source: 'api-standard-process', target: 'notification-complete', sourceHandle: 'api-standard-process-bottom', targetHandle: 'notification-complete-left', type: 'smoothstep' },
{ id: 'e-order-8', source: 'notification-complete', target: 'end-order', sourceHandle: 'notification-complete-bottom', targetHandle: 'end-order-top', type: 'smoothstep' }
]
},
{
id: 'customer-feedback-analysis',
name: 'Customer Feedback Analysis',
description: 'Automated feedback collection and analysis with sentiment detection and response routing.',
category: 'automation',
complexity: 'Complex',
nodeCount: 8,
edgeCount: 10,
variables: [
{
name: 'customerId',
type: 'string',
scope: 'global',
description: 'Customer ID from URL parameter',
defaultValue: '',
captureFromUrl: true,
urlParameterName: 'customer'
},
{
name: 'feedbackType',
type: 'string',
scope: 'global',
description: 'Type of feedback from URL parameter',
defaultValue: 'general',
captureFromUrl: true,
urlParameterName: 'type'
},
{
name: 'customerData',
type: 'object',
scope: 'global',
description: 'Customer information from API',
defaultValue: {}
},
{
name: 'feedbackData',
type: 'object',
scope: 'global',
description: 'Feedback form submission data',
defaultValue: {}
},
{
name: 'sentimentAnalysis',
type: 'object',
scope: 'global',
description: 'AI sentiment analysis results',
defaultValue: {}
},
{
name: 'isNegativeFeedback',
type: 'boolean',
scope: 'global',
description: 'Whether feedback is negative',
defaultValue: false
},
{
name: 'escalationRequired',
type: 'boolean',
scope: 'global',
description: 'Whether escalation is needed',
defaultValue: false
}
],
nodes: [
{
id: 'start-feedback',
type: 'start',
position: { x: 100, y: 400 },
data: {
label: 'Start Feedback',
description: 'Begin feedback collection process',
backgroundColor: '#dcfce7',
borderColor: '#10b981',
textColor: '#065f46',
shape: 'circle'
}
},
{
id: 'api-get-customer',
type: 'api',
position: { x: 300, y: 400 },
data: {
label: 'Get Customer Info',
description: 'Fetch customer details',
apiMethod: 'GET',
apiUrl: '/api/customers/{customerId}',
headers: [
{ key: 'Content-Type', value: 'application/json' },
{ key: 'Authorization', value: 'Bearer YOUR_API_TOKEN' }
],
outputVariable: 'customerData',
errorVariable: 'customerError',
continueOnError: false,
backgroundColor: '#eff6ff',
borderColor: '#3b82f6',
textColor: '#1e40af',
shape: 'rectangle'
}
},
{
id: 'form-feedback',
type: 'form',
position: { x: 500, y: 400 },
data: {
label: 'Feedback Form',
description: 'Collect customer feedback',
formId: null,
formName: 'Customer Feedback Form',
inputMappings: [
{ processVariable: 'customerData.name', formField: 'customerName' },
{ processVariable: 'customerData.email', formField: 'customerEmail' },
{ processVariable: 'feedbackType', formField: 'feedbackCategory' }
],
outputMappings: [
{ formField: 'rating', processVariable: 'feedbackData.rating' },
{ formField: 'comments', processVariable: 'feedbackData.comments' },
{ formField: 'improvementSuggestions', processVariable: 'feedbackData.suggestions' }
],
backgroundColor: '#faf5ff',
borderColor: '#9333ea',
textColor: '#6b21a8',
shape: 'rectangle'
}
},
{
id: 'api-sentiment-analysis',
type: 'api',
position: { x: 700, y: 400 },
data: {
label: 'Analyze Sentiment',
description: 'AI-powered sentiment analysis',
apiMethod: 'POST',
apiUrl: '/api/ai/sentiment-analysis',
requestBody: '{"text": "{feedbackData.comments}", "context": "customer_feedback"}',
headers: [
{ key: 'Content-Type', value: 'application/json' },
{ key: 'X-API-Key', value: 'YOUR_AI_API_KEY' }
],
outputVariable: 'sentimentAnalysis',
backgroundColor: '#eff6ff',
borderColor: '#3b82f6',
textColor: '#1e40af',
shape: 'rectangle'
}
},
{
id: 'business-rule-sentiment',
type: 'business-rule',
position: { x: 900, y: 400 },
data: {
label: 'Evaluate Sentiment',
description: 'Determine if feedback requires escalation',
ruleGroups: [
{
name: 'Negative Sentiment',
conditions: [
{
variable: 'sentimentAnalysis.sentiment',
operator: 'eq',
value: 'negative',
minValue: null,
maxValue: null
}
],
actions: [
{
type: 'set_variable',
variable: 'isNegativeFeedback',
value: 'true'
}
]
},
{
name: 'Low Rating',
conditions: [
{
variable: 'feedbackData.rating',
operator: 'lte',
value: '2',
minValue: null,
maxValue: null
}
],
actions: [
{
type: 'set_variable',
variable: 'escalationRequired',
value: 'true'
}
]
}
],
backgroundColor: '#fdf4ff',
borderColor: '#a855f7',
textColor: '#7c3aed',
shape: 'rectangle'
}
},
{
id: 'gateway-escalation',
type: 'gateway',
position: { x: 1100, y: 400 },
data: {
label: 'Needs Escalation?',
description: 'Check if escalation is required',
conditions: [
{
variable: 'escalationRequired',
operator: 'eq',
value: 'true',
output: 'Escalate'
}
],
defaultPath: 'Standard Response',
backgroundColor: '#fff7ed',
borderColor: '#f97316',
textColor: '#c2410c',
shape: 'diamond'
}
},
{
id: 'notification-escalate',
type: 'notification',
position: { x: 1300, y: 350 },
data: {
label: 'Escalate to Manager',
description: 'Notify manager of negative feedback',
notificationType: 'warning',
recipientType: 'role',
recipientRole: 'customer_success_manager',
subject: 'Urgent: Negative Customer Feedback',
message: 'Customer {customerData.name} has provided negative feedback requiring immediate attention.',
priority: 'high',
backgroundColor: '#f0f9ff',
borderColor: '#0ea5e9',
textColor: '#0284c7',
shape: 'rectangle'
}
},
{
id: 'notification-standard',
type: 'notification',
position: { x: 1300, y: 450 },
data: {
label: 'Thank Customer',
description: 'Send standard thank you message',
notificationType: 'success',
recipientType: 'variable',
recipientVariable: 'customerData.email',
subject: 'Thank you for your feedback',
message: 'Thank you for taking the time to provide feedback. We appreciate your input.',
priority: 'medium',
backgroundColor: '#f0f9ff',
borderColor: '#0ea5e9',
textColor: '#0284c7',
shape: 'rectangle'
}
},
{
id: 'end-feedback',
type: 'end',
position: { x: 1500, y: 400 },
data: {
label: 'Complete',
description: 'Feedback processing complete',
backgroundColor: '#fee2e2',
borderColor: '#dc2626',
textColor: '#991b1b',
shape: 'circle'
}
}
],
edges: [
{ id: 'e-feedback-1', source: 'start-feedback', target: 'api-get-customer', sourceHandle: 'start-feedback-bottom', targetHandle: 'api-get-customer-top', type: 'smoothstep' },
{ id: 'e-feedback-2', source: 'api-get-customer', target: 'form-feedback', sourceHandle: 'api-get-customer-bottom', targetHandle: 'form-feedback-top', type: 'smoothstep' },
{ id: 'e-feedback-3', source: 'form-feedback', target: 'api-sentiment-analysis', sourceHandle: 'form-feedback-bottom', targetHandle: 'api-sentiment-analysis-top', type: 'smoothstep' },
{ id: 'e-feedback-4', source: 'api-sentiment-analysis', target: 'business-rule-sentiment', sourceHandle: 'api-sentiment-analysis-bottom', targetHandle: 'business-rule-sentiment-top', type: 'smoothstep' },
{ id: 'e-feedback-5', source: 'business-rule-sentiment', target: 'gateway-escalation', sourceHandle: 'business-rule-sentiment-bottom', targetHandle: 'gateway-escalation-top', type: 'smoothstep' },
{ id: 'e-feedback-6', source: 'gateway-escalation', target: 'notification-escalate', sourceHandle: 'gateway-escalation-right', targetHandle: 'notification-escalate-left', type: 'smoothstep', label: 'Escalate' },
{ id: 'e-feedback-7', source: 'gateway-escalation', target: 'notification-standard', sourceHandle: 'gateway-escalation-bottom', targetHandle: 'notification-standard-left', type: 'smoothstep', label: 'Standard Response' },
{ id: 'e-feedback-8', source: 'notification-escalate', target: 'end-feedback', sourceHandle: 'notification-escalate-bottom', targetHandle: 'end-feedback-top', type: 'smoothstep' },
{ id: 'e-feedback-9', source: 'notification-standard', target: 'end-feedback', sourceHandle: 'notification-standard-bottom', targetHandle: 'end-feedback-left', type: 'smoothstep' }
]
} }
]; ];
@ -1246,7 +2073,8 @@ const getTemplateIcon = (template) => {
'onboarding': 'material-symbols:person-add-outline', 'onboarding': 'material-symbols:person-add-outline',
'support': 'material-symbols:support-agent', 'support': 'material-symbols:support-agent',
'business': 'material-symbols:business-center-outline', 'business': 'material-symbols:business-center-outline',
'automation': 'material-symbols:precision-manufacturing-outline' 'automation': 'material-symbols:precision-manufacturing-outline',
'data-driven': 'material-symbols:data-usage'
}; };
return icons[template.category] || 'material-symbols:account-tree'; return icons[template.category] || 'material-symbols:account-tree';

View File

@ -55,16 +55,31 @@
<!-- Data Table --> <!-- Data Table -->
<div class="table-container"> <div class="table-container">
<!-- Performance warning for too many columns -->
<div v-if="config.columns.length > 20" class="performance-warning">
<div class="warning-content">
<Icon name="heroicons:information-circle" class="warning-icon" />
<div class="warning-text">
<p class="warning-title">Performance Notice</p>
<p class="warning-message">
This table has {{ config.columns.length }} columns. For optimal performance, only the first 20 columns are displayed.
Consider reducing the number of columns for better user experience.
</p>
</div>
</div>
</div>
<div v-if="paginatedData.length > 0" class="table-wrapper"> <div v-if="paginatedData.length > 0" class="table-wrapper">
<div class="table-content"> <div class="table-content" ref="tableContainer">
<table class="data-table"> <table class="data-table">
<thead class="table-header-row"> <thead class="table-header-row">
<tr> <tr>
<th v-if="config.showRowNumbers" class="row-number-header">#</th> <th v-if="config.showRowNumbers" class="row-number-header">#</th>
<th <th
v-for="column in config.columns" v-for="column in visibleColumns"
:key="column.name" :key="column.name"
class="column-header" class="column-header"
:style="{ width: getColumnWidth(column) }"
> >
<div class="column-header-content"> <div class="column-header-content">
<span class="header-text">{{ column.label }}</span> <span class="header-text">{{ column.label }}</span>
@ -77,7 +92,7 @@
<tbody class="table-body"> <tbody class="table-body">
<tr <tr
v-for="(record, index) in paginatedData" v-for="(record, index) in paginatedData"
:key="index" :key="getRecordKey(record, index)"
class="data-row" class="data-row"
:class="{ 'row-hover': !isPreview }" :class="{ 'row-hover': !isPreview }"
> >
@ -85,12 +100,17 @@
<div class="row-number">{{ getRowNumber(index) }}</div> <div class="row-number">{{ getRowNumber(index) }}</div>
</td> </td>
<td <td
v-for="column in config.columns" v-for="column in visibleColumns"
:key="column.name" :key="column.name"
class="data-cell" class="data-cell"
:style="{ width: getColumnWidth(column) }"
> >
<div class="cell-content"> <div class="cell-content">
<span class="cell-value">{{ formatCellValue(record[column.name], column) }}</span> <LazyCellValue
:value="record[column.name]"
:column="column"
:record="record"
/>
</div> </div>
</td> </td>
<td v-if="showActions" class="actions-cell"> <td v-if="showActions" class="actions-cell">
@ -124,20 +144,16 @@
<!-- Empty State --> <!-- Empty State -->
<div v-else class="empty-state"> <div v-else class="empty-state">
<div class="empty-content"> <div class="empty-content">
<div class="empty-icon-container"> <Icon name="heroicons:table-cells" class="empty-icon" />
<Icon name="heroicons:table-cells" class="empty-icon" /> <h3 class="empty-title">No records yet</h3>
</div> <p class="empty-message">
<div class="empty-text"> {{ config.help || 'Add your first record to get started.' }}
<h4 class="empty-title">No records yet</h4> </p>
<p class="empty-description">
{{ searchQuery ? 'No records match your search criteria.' : 'Click "' + (config.buttonText || 'Add Record') + '" to add your first record.' }}
</p>
</div>
<RsButton <RsButton
v-if="!searchQuery && !isAddDisabled" v-if="!isAddDisabled"
@click="openAddModal" @click="openAddModal"
variant="primary" variant="primary"
size="md" size="sm"
> >
<Icon name="heroicons:plus" class="w-4 h-4 mr-2" /> <Icon name="heroicons:plus" class="w-4 h-4 mr-2" />
{{ config.buttonText || 'Add Record' }} {{ config.buttonText || 'Add Record' }}
@ -147,92 +163,56 @@
</div> </div>
<!-- Pagination --> <!-- Pagination -->
<div v-if="filteredData.length > 0 && totalPages > 1" class="pagination-container"> <div v-if="totalPages > 1" class="pagination-container">
<div class="pagination-info"> <div class="pagination-info">
<span class="pagination-text"> Showing {{ startRecord }} to {{ endRecord }} of {{ filteredData.length }} records
Showing {{ startRecord }} to {{ endRecord }} of {{ filteredData.length }} records
</span>
</div> </div>
<div class="pagination-controls"> <div class="pagination-controls">
<div class="page-size-selector"> <RsButton
<label class="page-size-label">Show:</label> @click="goToPage(currentPage - 1)"
<select v-model="pageSize" @change="currentPage = 1" class="page-size-select"> :disabled="currentPage === 1"
<option value="5">5</option> variant="secondary-outline"
<option value="10">10</option> size="sm"
<option value="25">25</option> >
<option value="50">50</option> <Icon name="heroicons:chevron-left" class="w-4 h-4" />
</select> Previous
<span class="page-size-suffix">per page</span> </RsButton>
<div class="page-numbers">
<RsButton
v-for="page in visiblePages"
:key="page"
@click="page === '...' ? null : goToPage(page)"
:variant="page === currentPage ? 'primary' : 'secondary-outline'"
size="sm"
:disabled="page === '...'"
class="page-button"
>
{{ page }}
</RsButton>
</div> </div>
<div class="page-navigation"> <RsButton
<RsButton @click="goToPage(currentPage + 1)"
@click="goToPage(1)" :disabled="currentPage === totalPages"
:disabled="currentPage === 1" variant="secondary-outline"
variant="secondary-outline" size="sm"
size="sm" >
title="First page" Next
> <Icon name="heroicons:chevron-right" class="w-4 h-4" />
<Icon name="heroicons:chevron-double-left" class="w-4 h-4" /> </RsButton>
</RsButton>
<RsButton
@click="goToPage(currentPage - 1)"
:disabled="currentPage === 1"
variant="secondary-outline"
size="sm"
title="Previous page"
>
<Icon name="heroicons:chevron-left" class="w-4 h-4" />
</RsButton>
<div class="page-numbers">
<RsButton
v-for="page in visiblePages"
:key="page"
@click="goToPage(page)"
:variant="page === currentPage ? 'primary' : 'secondary-outline'"
size="sm"
>
{{ page }}
</RsButton>
</div>
<RsButton
@click="goToPage(currentPage + 1)"
:disabled="currentPage === totalPages"
variant="secondary-outline"
size="sm"
title="Next page"
>
<Icon name="heroicons:chevron-right" class="w-4 h-4" />
</RsButton>
<RsButton
@click="goToPage(totalPages)"
:disabled="currentPage === totalPages"
variant="secondary-outline"
size="sm"
title="Last page"
>
<Icon name="heroicons:chevron-double-right" class="w-4 h-4" />
</RsButton>
</div>
</div> </div>
</div> </div>
<!-- Add/Edit Modal --> <!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal"> <div v-if="showModal" class="modal-overlay" @click="closeModal">
<div class="modal-container"> <div class="modal-container" @click.stop>
<div class="modal-header"> <div class="modal-header">
<div class="modal-title-section"> <h3 class="modal-title">
<h3 class="modal-title"> {{ editingIndex !== null ? 'Edit Record' : 'Add Record' }}
{{ editingIndex !== null ? 'Edit Record' : 'Add New Record' }} </h3>
</h3>
</div>
<RsButton @click="closeModal" variant="secondary-text" size="sm"> <RsButton @click="closeModal" variant="secondary-text" size="sm">
<Icon name="heroicons:x-mark" class="w-6 h-6" /> <Icon name="heroicons:x-mark" class="w-4 h-4" />
</RsButton> </RsButton>
</div> </div>
@ -280,23 +260,19 @@
</div> </div>
<!-- Delete Confirmation Modal --> <!-- Delete Confirmation Modal -->
<div v-if="showDeleteConfirm" class="modal-overlay" @click.self="cancelDelete"> <div v-if="showDeleteConfirm" class="modal-overlay" @click="cancelDelete">
<div class="modal-container modal-small"> <div class="modal-container delete-modal" @click.stop>
<div class="modal-header"> <div class="modal-header">
<div class="modal-title-section"> <h3 class="modal-title">Confirm Delete</h3>
<h3 class="modal-title">Confirm Delete</h3>
</div>
<RsButton @click="cancelDelete" variant="secondary-text" size="sm"> <RsButton @click="cancelDelete" variant="secondary-text" size="sm">
<Icon name="heroicons:x-mark" class="w-6 h-6" /> <Icon name="heroicons:x-mark" class="w-4 h-4" />
</RsButton> </RsButton>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="delete-warning"> <div class="warning-content">
<div class="warning-icon-container"> <Icon name="heroicons:exclamation-triangle" class="warning-icon" />
<Icon name="heroicons:exclamation-triangle" class="warning-icon" /> <div class="warning-text">
</div>
<div class="warning-content">
<p class="warning-title">Are you sure you want to delete this record?</p> <p class="warning-title">Are you sure you want to delete this record?</p>
<p class="warning-message">This action cannot be undone.</p> <p class="warning-message">This action cannot be undone.</p>
</div> </div>
@ -320,6 +296,91 @@
<script setup> <script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue' import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { useDebounceFn } from '@vueuse/core'
// Lazy cell component for better performance
const LazyCellValue = defineComponent({
props: {
value: {
type: [String, Number, Boolean, Object],
default: null
},
column: {
type: Object,
required: true
},
record: {
type: Object,
required: true
}
},
setup(props) {
const isVisible = ref(false)
const cellRef = ref(null)
const observer = ref(null)
onMounted(() => {
if (cellRef.value) {
observer.value = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
isVisible.value = true
observer.value?.disconnect()
}
})
},
{ threshold: 0.1 }
)
observer.value.observe(cellRef.value)
}
})
onUnmounted(() => {
observer.value?.disconnect()
})
const formatValue = computed(() => {
if (!isVisible.value) return ''
const value = props.value
if (value === null || value === undefined || value === '') {
return '-'
}
if (props.column.type === 'date') {
return new Date(value).toLocaleDateString()
}
if (props.column.type === 'time') {
return value
}
if (props.column.type === 'checkbox') {
return value ? 'Yes' : 'No'
}
if (props.column.type === 'select' && props.column.options) {
const option = props.column.options.find(opt => opt.value === value)
return option ? option.label : value
}
return value.toString()
})
return {
isVisible,
cellRef,
formatValue
}
},
template: `
<span ref="cellRef" class="cell-value">
{{ formatValue }}
</span>
`
})
const props = defineProps({ const props = defineProps({
config: { config: {
@ -347,12 +408,28 @@ const editingIndex = ref(null)
const deleteIndex = ref(null) const deleteIndex = ref(null)
const formData = ref({}) const formData = ref({})
const formId = ref(`table-form-${uuidv4()}`) const formId = ref(`table-form-${uuidv4()}`)
const tableContainer = ref(null)
// Pagination state // Pagination state
const currentPage = ref(1) const currentPage = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
// Computed properties // Performance optimizations
const columnCache = ref(new Map())
const recordKeys = ref(new Map())
// Debounced search for better performance
const debouncedSearch = useDebounceFn((query) => {
// Clear cache when search changes
columnCache.value.clear()
}, 300)
// Watch search query with debouncing
watch(searchQuery, (newQuery) => {
debouncedSearch(newQuery)
})
// Computed properties with memoization
const filteredData = computed(() => { const filteredData = computed(() => {
if (!searchQuery.value) return data.value if (!searchQuery.value) return data.value
@ -365,6 +442,46 @@ const filteredData = computed(() => {
}) })
}) })
// Column virtualization for large datasets
const visibleColumns = computed(() => {
const columns = props.config.columns
const maxColumns = 20 // Limit visible columns for performance
if (columns.length <= maxColumns) {
return columns
}
// Return first 20 columns for performance
return columns.slice(0, maxColumns)
})
// Column width calculation
const getColumnWidth = (column) => {
const baseWidth = 150
const typeWidths = {
text: 150,
number: 100,
email: 200,
tel: 120,
date: 120,
time: 100,
url: 200,
select: 150,
checkbox: 80,
textarea: 200
}
return `${typeWidths[column.type] || baseWidth}px`
}
// Record key generation for better Vue rendering
const getRecordKey = (record, index) => {
if (!recordKeys.value.has(record)) {
recordKeys.value.set(record, `record-${index}-${Date.now()}`)
}
return recordKeys.value.get(record)
}
const isAddDisabled = computed(() => { const isAddDisabled = computed(() => {
return props.config.maxRecords && data.value.length >= props.config.maxRecords return props.config.maxRecords && data.value.length >= props.config.maxRecords
}) })
@ -429,6 +546,9 @@ const visiblePages = computed(() => {
// Watch for external data changes // Watch for external data changes
watch(() => props.modelValue, (newValue) => { watch(() => props.modelValue, (newValue) => {
data.value = [...newValue] data.value = [...newValue]
// Clear caches when data changes
columnCache.value.clear()
recordKeys.value.clear()
}, { deep: true }) }, { deep: true })
// Watch for internal data changes and emit // Watch for internal data changes and emit
@ -512,29 +632,8 @@ const getOriginalIndex = (record) => {
return data.value.findIndex(item => item === record) return data.value.findIndex(item => item === record)
} }
const formatCellValue = (value, column) => { const getRowNumber = (index) => {
if (value === null || value === undefined || value === '') { return startRecord.value + index
return '-'
}
if (column.type === 'date') {
return new Date(value).toLocaleDateString()
}
if (column.type === 'time') {
return value
}
if (column.type === 'checkbox') {
return value ? 'Yes' : 'No'
}
if (column.type === 'select' && column.options) {
const option = column.options.find(opt => opt.value === value)
return option ? option.label : value
}
return value.toString()
} }
const getFormKitType = (columnType) => { const getFormKitType = (columnType) => {
@ -586,17 +685,115 @@ const goToPage = (page) => {
currentPage.value = page currentPage.value = page
} }
const getRowNumber = (index) => { // Cleanup on unmount
return (currentPage.value - 1) * pageSize.value + index + 1 onUnmounted(() => {
} columnCache.value.clear()
recordKeys.value.clear()
// Watch for search changes to reset pagination
watch(searchQuery, () => {
currentPage.value = 1
}) })
</script> </script>
<style scoped> <style scoped>
/* Performance optimizations */
.table-content {
overflow-x: auto;
overflow-y: auto;
max-height: 600px;
will-change: transform;
}
.data-table {
table-layout: fixed;
width: 100%;
border-collapse: collapse;
}
.column-header,
.data-cell {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 0;
}
.cell-content {
overflow: hidden;
text-overflow: ellipsis;
}
.cell-value {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Virtual scrolling optimizations */
.data-row {
contain: layout style paint;
}
/* Lazy loading optimizations */
.cell-value:empty {
min-height: 1.2em;
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Performance warning styles */
.performance-warning {
@apply bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-4;
}
.warning-content {
@apply flex items-start space-x-3;
}
.warning-icon {
@apply w-5 h-5 text-yellow-600 mt-0.5 flex-shrink-0;
}
.warning-text {
@apply flex-1;
}
.warning-title {
@apply text-sm font-medium text-yellow-800 mb-1;
}
.warning-message {
@apply text-xs text-yellow-700;
}
/* Responsive optimizations */
@media (max-width: 768px) {
.table-content {
max-height: 400px;
}
.column-header,
.data-cell {
min-width: 80px;
max-width: 120px;
}
}
@media (max-width: 480px) {
.table-content {
max-height: 300px;
}
.column-header,
.data-cell {
min-width: 60px;
max-width: 100px;
}
}
.repeating-table-container { .repeating-table-container {
@apply border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm w-full; @apply border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm w-full;
min-width: 0; min-width: 0;

View File

@ -172,6 +172,7 @@
v-model="localNodeData.outputVariable" v-model="localNodeData.outputVariable"
:availableVariables="availableVariables" :availableVariables="availableVariables"
:allowCreate="true" :allowCreate="true"
:allowObjectPath="false"
@change="saveChanges" @change="saveChanges"
/> />
</div> </div>
@ -188,6 +189,7 @@
v-model="localNodeData.errorVariable" v-model="localNodeData.errorVariable"
:availableVariables="availableVariables" :availableVariables="availableVariables"
:allowCreate="true" :allowCreate="true"
:allowObjectPath="false"
@change="saveChanges" @change="saveChanges"
/> />
</div> </div>

View File

@ -4,7 +4,7 @@
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="flex-1"> <div class="flex-1">
<select <select
:value="modelValue" :value="baseVariableName"
@change="handleVariableSelect" @change="handleVariableSelect"
:class="[ :class="[
'form-select w-full', 'form-select w-full',
@ -47,6 +47,63 @@
</RsButton> </RsButton>
</div> </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 --> <!-- Variable Info Display -->
<div v-if="selectedVariable" class="mt-2 p-2 bg-gray-50 rounded-md border text-sm"> <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 justify-between">
@ -224,6 +281,10 @@ const props = defineProps({
required: { required: {
type: Boolean, type: Boolean,
default: false default: false
},
allowObjectPath: {
type: Boolean,
default: true // Enable object property path input
} }
}); });
@ -239,14 +300,32 @@ const newVariableDefaultValue = ref('');
const newVariableDescription = ref(''); const newVariableDescription = ref('');
const nameValidationError = ref(''); const nameValidationError = ref('');
// Object property path state
const propertyPath = ref('');
const propertyPathError = ref('');
// Computed properties // 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(() => { const selectedVariable = computed(() => {
return props.availableVariables.find(v => v.name === props.modelValue); 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(() => { const hasError = computed(() => {
if (!props.required && !props.modelValue) return false; if (!props.required && !props.modelValue) return false;
if (props.modelValue && !selectedVariable.value) return true; if (props.modelValue && !selectedVariable.value) return true;
if (propertyPathError.value) return true;
return false; return false;
}); });
@ -255,7 +334,10 @@ const errorMessage = computed(() => {
return 'Variable selection is required'; return 'Variable selection is required';
} }
if (props.modelValue && !selectedVariable.value) { if (props.modelValue && !selectedVariable.value) {
return `Variable "${props.modelValue}" not found`; return `Variable "${baseVariableName.value}" not found`;
}
if (propertyPathError.value) {
return propertyPathError.value;
} }
return ''; return '';
}); });
@ -305,9 +387,74 @@ const canCreateVariable = computed(() => {
// Methods // Methods
const handleVariableSelect = (event) => { const handleVariableSelect = (event) => {
emit('update:modelValue', event.target.value); 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 = () => { const openCreateVariable = () => {
showCreateVariable.value = true; showCreateVariable.value = true;
resetCreateForm(); resetCreateForm();

View File

@ -479,6 +479,28 @@
help="A clear description helps others understand the purpose of this variable" help="A clear description helps others understand the purpose of this variable"
/> />
<FormKit
name="captureFromUrl"
v-model="variableForm.captureFromUrl"
type="checkbox"
label="Capture from URL Parameters"
help="Enable this to automatically capture the variable value from URL query parameters during workflow execution"
/>
<FormKit
v-if="variableForm.captureFromUrl"
name="urlParameterName"
v-model="variableForm.urlParameterName"
type="text"
label="URL Parameter Name"
placeholder="Enter the URL parameter name (e.g. 'userId' for ?userId=123)"
validation="required"
:validation-messages="{
required: 'URL parameter name is required when capture from URL is enabled',
}"
help="The name of the URL query parameter to capture (without the ? or &)"
/>
<div class="flex justify-end space-x-2 pt-4 border-t border-gray-200"> <div class="flex justify-end space-x-2 pt-4 border-t border-gray-200">
<RsButton type="button" @click="closeModal" variant="tertiary"> <RsButton type="button" @click="closeModal" variant="tertiary">
Cancel Cancel
@ -512,7 +534,9 @@ const variableForm = ref({
type: "string", type: "string",
scope: "global", scope: "global",
description: "", description: "",
defaultValue: "" defaultValue: "",
captureFromUrl: false,
urlParameterName: ""
}); });
// Variable type options with descriptions // Variable type options with descriptions
@ -1498,7 +1522,9 @@ const resetForm = () => {
type: "string", type: "string",
scope: "global", scope: "global",
description: "", description: "",
defaultValue: "" defaultValue: "",
captureFromUrl: false,
urlParameterName: ""
}; };
editingVariable.value = null; editingVariable.value = null;
}; };
@ -1532,7 +1558,9 @@ const saveVariable = async (formData) => {
type: formData.type, type: formData.type,
scope: "global", scope: "global",
description: formData.description, description: formData.description,
value: processedDefaultValue value: processedDefaultValue,
captureFromUrl: formData.captureFromUrl || false,
urlParameterName: formData.urlParameterName || ""
}; };
if (editingVariable.value) { if (editingVariable.value) {

View File

@ -1,7 +1,12 @@
// Utility to safely get a field value from a form data object // Utility to safely get a field value from a form data object
export function safeGetField(field, formData) { export function safeGetField(field, formData) {
if (formData && Object.prototype.hasOwnProperty.call(formData, field)) { if (formData && Object.prototype.hasOwnProperty.call(formData, field)) {
return formData[field] !== undefined && formData[field] !== null ? formData[field] : ''; const value = formData[field];
// If the value is undefined or null, return empty string for backward compatibility
if (value === undefined || value === null) {
return '';
}
return value;
} }
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
// Only warn in development // Only warn in development

View File

@ -398,65 +398,6 @@ onMounted(() => {
</template> </template>
</rs-card> </rs-card>
</div> </div>
<!-- Development Roadmap -->
<rs-card>
<template #header>
<div class="flex items-center">
<Icon name="material-symbols:road" class="w-5 h-5 mr-2 text-purple-500" />
Development Roadmap & System Guide
</div>
</template>
<template #body>
<div class="space-y-6">
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 class="font-semibold text-blue-900 mb-2">🚀 Getting Started with Corrad BPM</h3>
<p class="text-blue-800 text-sm mb-3">
Follow these steps to start developing business processes in the system:
</p>
<ol class="list-decimal list-inside space-y-1 text-sm text-blue-800">
<li><strong>Design Forms:</strong> Use Form Builder to create data collection forms</li>
<li><strong>Create Processes:</strong> Use Process Builder to design workflow logic</li>
<li><strong>Configure Integrations:</strong> Set up API calls and business rules</li>
<li><strong>Test & Deploy:</strong> Test process execution and publish to production</li>
</ol>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div
v-for="phase in developmentRoadmap"
:key="phase.phase"
class="border border-gray-200 rounded-lg p-4"
>
<div class="flex items-center justify-between mb-3">
<h4 class="font-semibold text-gray-900">{{ phase.phase }}</h4>
<RsBadge :class="getStatusColor(phase.status)" size="sm">
{{ phase.status.replace('-', ' ') }}
</RsBadge>
</div>
<ul class="space-y-2">
<li
v-for="item in phase.items"
:key="item"
class="flex items-start text-sm text-gray-600"
>
<Icon
:name="phase.status === 'completed' ? 'material-symbols:check-circle' :
phase.status === 'in-progress' ? 'material-symbols:radio-button-partial' :
'material-symbols:radio-button-unchecked'"
:class="phase.status === 'completed' ? 'text-green-500' :
phase.status === 'in-progress' ? 'text-blue-500' :
'text-gray-400'"
class="w-4 h-4 mr-2 mt-0.5 flex-shrink-0"
/>
{{ item }}
</li>
</ul>
</div>
</div>
</div>
</template>
</rs-card>
</div> </div>
</template> </template>

View File

@ -7,7 +7,7 @@
<!-- Left section - Logo and navigation --> <!-- Left section - Logo and navigation -->
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<Icon <Icon
@click="navigateTo('/', { external: true })" @click="navigateTo('/form-builder/manage')"
name="ph:arrow-circle-left-duotone" name="ph:arrow-circle-left-duotone"
class="cursor-pointer w-6 h-6 hover:text-gray-600 text-gray-500" class="cursor-pointer w-6 h-6 hover:text-gray-600 text-gray-500"
/> />
@ -91,9 +91,14 @@
<Icon name="material-symbols:code" class="mr-2 w-4 h-4" /> <Icon name="material-symbols:code" class="mr-2 w-4 h-4" />
<span>Form Settings</span> <span>Form Settings</span>
</button> </button>
<button @click="navigateToManage(); showDropdown = false" class="w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center">
<Icon name="material-symbols:settings" class="mr-2 w-4 h-4" /> <!-- Help -->
<span>Manage Forms</span> <div class="px-3 py-1 text-xs font-medium text-gray-500 uppercase tracking-wide mt-2">
Help
</div>
<button @click="showHelpModal = true; showDropdown = false" class="w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center">
<Icon name="material-symbols:help-outline" class="mr-2 w-4 h-4" />
<span>How to Use</span>
</button> </button>
</div> </div>
</div> </div>
@ -101,7 +106,7 @@
</header> </header>
<!-- Main Content Area --> <!-- Main Content Area -->
<div class="flex-1 flex overflow-hidden"> <div v-if="hasCurrentForm" class="flex-1 flex overflow-hidden">
<!-- Left Panel - Components Sidebar --> <!-- Left Panel - Components Sidebar -->
<div <div
v-if="!isPreview" v-if="!isPreview"
@ -295,16 +300,20 @@
</template> </template>
</div> </div>
<!-- Only show submit button if no submit button components exist in the form --> <!-- Only show submit button if enabled and no submit button components exist in the form -->
<FormKit <FormKit
v-if="!formStore.formComponents.some(comp => comp.type === 'button' && comp.props.buttonType === 'submit')" v-if="formStore.submitButton.enabled && !formStore.formComponents.some(comp => comp.type === 'button' && comp.props.buttonType === 'submit')"
type="submit" type="submit"
label="Submit" :label="formStore.submitButton.label || 'Submit'"
class="form-submit mt-6" class="form-submit mt-6"
:class="{ :class="{
'mx-4 mb-4': selectedDevice !== 'Desktop', 'mx-4 mb-4': selectedDevice !== 'Desktop',
'mx-0 mb-0': selectedDevice === 'Desktop' 'mx-0 mb-0': selectedDevice === 'Desktop'
}" }"
:classes="{
input: getSubmitButtonStyles(formStore.submitButton.category, formStore.submitButton.color).classes
}"
:style="getSubmitButtonStyles(formStore.submitButton.category, formStore.submitButton.color).style"
/> />
</FormKit> </FormKit>
</div> </div>
@ -504,6 +513,116 @@
:classes="{ outer: 'mb-0', input: 'text-sm' }" :classes="{ outer: 'mb-0', input: 'text-sm' }"
/> />
</div> </div>
<!-- Rows (for textarea) -->
<div v-if="showQuickField('rows')" class="setting-item">
<label class="setting-label">Number of Rows</label>
<FormKit
type="number"
v-model="quickSettings.rows"
@input="updateQuickSetting('rows', $event)"
:placeholder="getPlaceholder('rows')"
:classes="{ outer: 'mb-0', input: 'text-sm' }"
min="1"
max="20"
/>
</div>
<!-- Link Type (for button) -->
<div v-if="showQuickField('linkType')" class="setting-item">
<label class="setting-label">Link Type</label>
<FormKit
type="select"
v-model="quickSettings.linkType"
@input="updateQuickSetting('linkType', $event)"
:options="[
{ label: 'No Link', value: 'none' },
{ label: 'Custom URL', value: 'url' },
{ label: 'Process Link', value: 'process' }
]"
:classes="{ outer: 'mb-0', input: 'text-sm' }"
/>
</div>
<!-- Link URL (for button) -->
<div v-if="showQuickField('linkUrl') && quickSettings.linkType === 'url'" class="setting-item">
<label class="setting-label">URL</label>
<FormKit
type="text"
v-model="quickSettings.linkUrl"
@input="updateQuickSetting('linkUrl', $event)"
placeholder="https://example.com"
:classes="{ outer: 'mb-0', input: 'text-sm' }"
/>
</div>
<!-- Link Process (for button) -->
<div v-if="showQuickField('linkProcessId') && quickSettings.linkType === 'process'" class="setting-item">
<label class="setting-label">Process</label>
<FormKit
type="select"
v-model="quickSettings.linkProcessId"
@input="updateQuickSetting('linkProcessId', $event)"
:options="publishedProcesses"
placeholder="Select a process..."
:classes="{ outer: 'mb-0', input: 'text-sm' }"
/>
</div>
<!-- Iframe Parameters (for button with process link) -->
<div v-if="showQuickField('iframeDebug') && quickSettings.linkType === 'process'" class="space-y-2">
<div class="setting-item">
<label class="setting-toggle">
<input
type="checkbox"
v-model="quickSettings.iframeDebug"
@change="updateQuickSetting('iframeDebug', $event.target.checked)"
class="toggle-input"
/>
<span class="toggle-slider"></span>
<span class="toggle-label">Debug Mode (OFF = iframe)</span>
</label>
</div>
<div class="setting-item">
<label class="setting-toggle">
<input
type="checkbox"
v-model="quickSettings.iframeHideComplete"
@change="updateQuickSetting('iframeHideComplete', $event.target.checked)"
class="toggle-input"
/>
<span class="toggle-slider"></span>
<span class="toggle-label">Hide Completion</span>
</label>
</div>
<div class="setting-item">
<label class="setting-label">Theme</label>
<FormKit
type="select"
v-model="quickSettings.iframeTheme"
@input="updateQuickSetting('iframeTheme', $event)"
:options="[
{ label: 'Default', value: '' },
{ label: 'Dark', value: 'dark' },
{ label: 'Light', value: 'light' }
]"
:classes="{ outer: 'mb-0', input: 'text-sm' }"
/>
</div>
<div class="setting-item">
<label class="setting-label">Custom Params</label>
<FormKit
type="text"
v-model="quickSettings.iframeCustomParams"
@input="updateQuickSetting('iframeCustomParams', $event)"
placeholder="param1=value1&param2=value2"
:classes="{ outer: 'mb-0', input: 'text-sm' }"
/>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -549,7 +668,63 @@
</div> </div>
</div> </div>
<!-- Empty state - No form selected -->
<div v-else class="flex-1 flex items-center justify-center bg-gray-50">
<div class="text-center p-8 max-w-md">
<Icon name="material-symbols:description" class="w-16 h-16 mx-auto mb-4 text-gray-400" />
<h2 class="text-xl font-semibold text-gray-800 mb-2">Create a New Form</h2>
<p class="text-gray-600 mb-6">Get started by creating a new form or navigate back to manage your existing forms.</p>
<div class="space-y-3">
<div class="mb-4">
<FormKit
v-model="newFormName"
type="text"
label="Form Name"
placeholder="Enter a name for your new form"
validation="required"
/>
<FormKit
v-model="newFormDescription"
type="textarea"
label="Description (Optional)"
placeholder="Enter a description"
:rows="3"
/>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormKit
v-model="newFormCategory"
type="select"
label="Category (Optional)"
placeholder="Select a category"
:options="categoryOptions"
/>
<FormKit
v-model="newFormGroup"
type="text"
label="Group (Optional)"
placeholder="Enter a group name"
/>
</div>
</div>
<div class="flex justify-center gap-4">
<RsButton @click="createNewForm" variant="primary" :disabled="!newFormName.trim()">
<Icon name="material-symbols:add" class="mr-1" />
Create Form
</RsButton>
<RsButton @click="navigateToManage" variant="tertiary">
<Icon name="material-symbols:arrow-back" class="mr-1" />
Back to Forms
</RsButton>
</div>
</div>
</div>
</div>
<!-- Form Settings Modal --> <!-- Form Settings Modal -->
<RsModal v-model="showFormSettings" title="Form Settings & Scripts" size="xl" position="center"> <RsModal v-model="showFormSettings" title="Form Settings & Scripts" size="xl" position="center">
@ -573,9 +748,122 @@
help="Brief description of what this form is for" help="Brief description of what this form is for"
rows="3" rows="3"
/> />
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormKit
type="select"
label="Category"
v-model="formStore.formCategory"
:options="categoryOptions"
help="Categorize your form for better organization"
/>
<FormKit
type="text"
label="Group"
v-model="formStore.formGroup"
help="Group related forms together"
/>
</div>
</div> </div>
</template> </template>
<!-- Submit Button Settings Tab -->
<template #submit>
<div class="p-4 space-y-4">
<div class="bg-blue-50 border border-blue-200 text-blue-800 p-3 rounded mb-4">
<div class="flex items-start">
<Icon name="material-symbols:info" class="w-5 h-5 mr-2 mt-0.5" />
<div>
<h4 class="font-medium text-sm">Submit Button Configuration</h4>
<p class="text-xs mt-1">
Configure the default submit button that appears at the bottom of your form.
Note: If you add a custom submit button component to your form, the default button will be hidden automatically.
</p>
</div>
</div>
</div>
<!-- Enable/Disable Submit Button -->
<div class="space-y-2">
<label class="flex items-center">
<input
type="checkbox"
v-model="formStore.submitButton.enabled"
class="form-checkbox h-4 w-4 text-blue-600 focus:ring-blue-500"
/>
<span class="ml-2 text-sm font-medium text-gray-700">Show default submit button</span>
</label>
<p class="text-xs text-gray-500 ml-6">
When enabled, a submit button will appear at the bottom of your form unless you add a custom submit button component.
</p>
</div>
<!-- Submit Button Label -->
<div v-if="formStore.submitButton.enabled">
<FormKit
type="text"
label="Submit Button Text"
v-model="formStore.submitButton.label"
help="The text that appears on the submit button"
validation="required"
placeholder="Submit"
/>
</div>
<!-- Submit Button Category -->
<div v-if="formStore.submitButton.enabled">
<FormKit
type="select"
label="Button Style Category"
v-model="formStore.submitButton.category"
help="Primary buttons are more prominent, secondary are more subtle"
:options="[
{ label: 'Primary', value: 'primary' },
{ label: 'Secondary', value: 'secondary' }
]"
/>
</div>
<!-- Submit Button Color -->
<div v-if="formStore.submitButton.enabled">
<FormKit
type="select"
label="Button Color"
v-model="formStore.submitButton.color"
help="Choose the color theme for the submit button"
:options="[
{ label: 'Primary', value: 'primary' },
{ label: 'Secondary', value: 'secondary' },
{ label: 'Success', value: 'success' },
{ label: 'Info', value: 'info' },
{ label: 'Warning', value: 'warning' },
{ label: 'Danger', value: 'danger' }
]"
/>
</div>
<!-- Preview Section -->
<div v-if="formStore.submitButton.enabled" class="border-t pt-4">
<h4 class="text-sm font-medium text-gray-700 mb-2">Preview</h4>
<div class="bg-gray-50 p-4 rounded border">
<FormKit
type="submit"
:label="formStore.submitButton.label || 'Submit'"
:classes="{
input: getSubmitButtonStyles(formStore.submitButton.category, formStore.submitButton.color).classes
}"
:style="getSubmitButtonStyles(formStore.submitButton.category, formStore.submitButton.color).style"
disabled
/>
<p class="text-xs text-gray-500 mt-2">
This is how your submit button will appear in the form.
</p>
</div>
</div>
</div>
</template>
<!-- Custom JavaScript Tab --> <!-- Custom JavaScript Tab -->
<template #javascript> <template #javascript>
<div class="p-4"> <div class="p-4">
@ -942,6 +1230,206 @@
@restored="handleFormRestored" @restored="handleFormRestored"
/> />
<!-- Help Modal -->
<RsModal v-model="showHelpModal" title="How to Use Form Builder" size="xl" position="center">
<div class="p-6 space-y-6">
<!-- Getting Started -->
<div class="help-section">
<h3 class="text-lg font-semibold text-gray-900 mb-3 flex items-center">
<Icon name="material-symbols:rocket-launch" class="w-5 h-5 mr-2 text-blue-600" />
Getting Started
</h3>
<div class="space-y-3 text-sm text-gray-700">
<div class="flex items-start space-x-3">
<div class="w-6 h-6 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center text-xs font-bold mt-0.5">1</div>
<div>
<strong>Name Your Form:</strong> Enter a descriptive name for your form at the top of the page.
</div>
</div>
<div class="flex items-start space-x-3">
<div class="w-6 h-6 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center text-xs font-bold mt-0.5">2</div>
<div>
<strong>Add Components:</strong> Drag and drop components from the left sidebar to the canvas area.
</div>
</div>
<div class="flex items-start space-x-3">
<div class="w-6 h-6 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center text-xs font-bold mt-0.5">3</div>
<div>
<strong>Configure Fields:</strong> Click on any field to configure its settings in the right panel.
</div>
</div>
</div>
</div>
<!-- Component Categories -->
<div class="help-section">
<h3 class="text-lg font-semibold text-gray-900 mb-3 flex items-center">
<Icon name="material-symbols:category" class="w-5 h-5 mr-2 text-green-600" />
Component Categories
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div class="space-y-2">
<h4 class="font-medium text-gray-800">Basic Inputs</h4>
<ul class="text-gray-600 space-y-1">
<li> Text, Number, Email, Password</li>
<li> Text Area, URL, Phone</li>
<li> Date, Time, Date & Time</li>
<li> Color Picker, Range Slider</li>
</ul>
</div>
<div class="space-y-2">
<h4 class="font-medium text-gray-800">Selection Inputs</h4>
<ul class="text-gray-600 space-y-1">
<li> Dropdown, Radio Buttons</li>
<li> Checkboxes, Switch Toggle</li>
<li> File Upload, Drop Zone</li>
<li> OTP Verification</li>
</ul>
</div>
<div class="space-y-2">
<h4 class="font-medium text-gray-800">Layout Elements</h4>
<ul class="text-gray-600 space-y-1">
<li> Headings, Paragraphs</li>
<li> Dividers, Form Sections</li>
<li> Information Display</li>
<li> Action Buttons</li>
</ul>
</div>
<div class="space-y-2">
<h4 class="font-medium text-gray-800">Advanced Features</h4>
<ul class="text-gray-600 space-y-1">
<li> Dynamic Lists</li>
<li> Repeating Groups</li>
<li> Conditional Logic</li>
<li> Custom Scripts</li>
</ul>
</div>
</div>
</div>
<!-- Field Configuration -->
<div class="help-section">
<h3 class="text-lg font-semibold text-gray-900 mb-3 flex items-center">
<Icon name="material-symbols:settings" class="w-5 h-5 mr-2 text-purple-600" />
Field Configuration
</h3>
<div class="space-y-3 text-sm text-gray-700">
<div class="bg-gray-50 p-3 rounded-lg">
<h4 class="font-medium text-gray-800 mb-2">Quick Settings (Right Panel)</h4>
<ul class="space-y-1 text-gray-600">
<li> <strong>Label:</strong> Field name displayed to users</li>
<li> <strong>Name:</strong> Internal field identifier</li>
<li> <strong>Placeholder:</strong> Hint text in empty fields</li>
<li> <strong>Required:</strong> Make field mandatory</li>
<li> <strong>Width:</strong> Control field size (S, M, L, XL)</li>
</ul>
</div>
<div class="bg-gray-50 p-3 rounded-lg">
<h4 class="font-medium text-gray-800 mb-2">Advanced Settings (Settings Icon)</h4>
<ul class="space-y-1 text-gray-600">
<li> <strong>Validation:</strong> Add custom validation rules</li>
<li> <strong>Conditional Logic:</strong> Show/hide based on other fields</li>
<li> <strong>Custom Scripts:</strong> Add JavaScript functionality</li>
<li> <strong>Button Links:</strong> Configure process links and iframe parameters</li>
</ul>
</div>
</div>
</div>
<!-- Button Features -->
<div class="help-section">
<h3 class="text-lg font-semibold text-gray-900 mb-3 flex items-center">
<Icon name="material-symbols:smart-button" class="w-5 h-5 mr-2 text-orange-600" />
Button Features
</h3>
<div class="space-y-3 text-sm text-gray-700">
<div class="bg-blue-50 p-3 rounded-lg border border-blue-200">
<h4 class="font-medium text-blue-800 mb-2">Link Configuration</h4>
<ul class="space-y-1 text-blue-700">
<li> <strong>No Link:</strong> Regular button behavior</li>
<li> <strong>Custom URL:</strong> Link to any website</li>
<li> <strong>Process Link:</strong> Link to published workflows</li>
</ul>
</div>
<div class="bg-green-50 p-3 rounded-lg border border-green-200">
<h4 class="font-medium text-green-800 mb-2">Iframe Integration</h4>
<ul class="space-y-1 text-green-700">
<li> <strong>Debug Mode:</strong> Control UI visibility (OFF = iframe mode)</li>
<li> <strong>Hide Completion:</strong> Auto-advance to next step</li>
<li> <strong>Theme:</strong> Apply custom themes (Dark/Light)</li>
<li> <strong>Custom Parameters:</strong> Add additional URL parameters</li>
</ul>
</div>
</div>
</div>
<!-- Tips & Tricks -->
<div class="help-section">
<h3 class="text-lg font-semibold text-gray-900 mb-3 flex items-center">
<Icon name="material-symbols:lightbulb" class="w-5 h-5 mr-2 text-yellow-600" />
Tips & Tricks
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div class="space-y-2">
<div class="bg-yellow-50 p-3 rounded-lg border border-yellow-200">
<h4 class="font-medium text-yellow-800 mb-1">Keyboard Shortcuts</h4>
<ul class="text-yellow-700 space-y-1">
<li> <kbd class="bg-yellow-200 px-1 rounded">Ctrl+S</kbd> Save form</li>
<li> <kbd class="bg-yellow-200 px-1 rounded">Ctrl+Z</kbd> Undo changes</li>
<li> <kbd class="bg-yellow-200 px-1 rounded">Delete</kbd> Remove selected field</li>
</ul>
</div>
</div>
<div class="space-y-2">
<div class="bg-purple-50 p-3 rounded-lg border border-purple-200">
<h4 class="font-medium text-purple-800 mb-1">Best Practices</h4>
<ul class="text-purple-700 space-y-1">
<li> Use descriptive field names</li>
<li> Group related fields together</li>
<li> Test your form in preview mode</li>
<li> Save your work frequently</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Preview Mode -->
<div class="help-section">
<h3 class="text-lg font-semibold text-gray-900 mb-3 flex items-center">
<Icon name="material-symbols:preview" class="w-5 h-5 mr-2 text-indigo-600" />
Preview Mode
</h3>
<div class="space-y-3 text-sm text-gray-700">
<p>Click the <strong>Preview</strong> button to see how your form will look to users. In preview mode:</p>
<ul class="space-y-1 text-gray-600">
<li> Test form functionality and validation</li>
<li> Check responsive design on different devices</li>
<li> Verify all field configurations work correctly</li>
<li> Test button links and iframe integrations</li>
</ul>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-between items-center">
<div class="text-sm text-gray-500">
Need more help? Check our documentation or contact support.
</div>
<div class="flex gap-2">
<RsButton @click="showHelpModal = false" variant="tertiary">
Close
</RsButton>
<RsButton @click="openDocumentation" variant="primary">
<Icon name="material-symbols:description" class="mr-1" />
View Documentation
</RsButton>
</div>
</div>
</template>
</RsModal>
</div> </div>
</template> </template>
@ -967,6 +1455,13 @@ const router = useRouter();
const formStore = useFormBuilderStore(); const formStore = useFormBuilderStore();
let toast; let toast;
// Track if we're creating a new form
const isCreatingForm = ref(false);
const newFormName = ref('');
const newFormDescription = ref('');
const newFormCategory = ref('');
const newFormGroup = ref('');
// Try to use the toast composable if available // Try to use the toast composable if available
try { try {
toast = useToast(); toast = useToast();
@ -985,6 +1480,7 @@ const isPreview = ref(false);
const showFormSettings = ref(false); const showFormSettings = ref(false);
const showTemplatesModal = ref(false); const showTemplatesModal = ref(false);
const showFormHistoryModal = ref(false); const showFormHistoryModal = ref(false);
const showHelpModal = ref(false);
const showDropdown = ref(false); const showDropdown = ref(false);
const leftSidebarTab = ref('components'); const leftSidebarTab = ref('components');
const showFieldSettingsPanel = ref(true); const showFieldSettingsPanel = ref(true);
@ -995,6 +1491,48 @@ const pendingNavigation = ref(null);
const navigationTarget = ref(null); const navigationTarget = ref(null);
const activeSettingsTab = ref('info'); const activeSettingsTab = ref('info');
// Published processes for button linking
const publishedProcesses = ref([]);
// Fetch published processes for button linking
const fetchPublishedProcesses = async () => {
try {
const response = await $fetch('/api/process?status=published&limit=1000')
if (response.success) {
publishedProcesses.value = response.data.processes.map(process => ({
label: process.processName,
value: process.processUUID
}))
}
} catch (error) {
console.error('Error fetching published processes:', error)
publishedProcesses.value = []
}
}
// Open documentation
const openDocumentation = () => {
// Open documentation in new tab
window.open('/documentation', '_blank')
}
// Helper function to get submit button CSS classes and styles based on category and color
const getSubmitButtonStyles = (category, color) => {
const baseClasses = 'px-4 py-2 rounded font-medium transition-all duration-200 text-white border-0';
// Use CSS custom properties from theme.css with opacity based on category
const opacity = category === 'primary' ? '1' : '0.8';
const hoverOpacity = category === 'primary' ? '0.9' : '0.7';
return {
classes: baseClasses,
style: {
backgroundColor: `rgba(var(--color-${color}), ${opacity})`,
'--hover-bg': `rgba(var(--color-${color}), ${hoverOpacity})`
}
};
};
// Responsive device preview state // Responsive device preview state
const selectedDevice = ref('Desktop'); const selectedDevice = ref('Desktop');
const deviceSizes = ref([ const deviceSizes = ref([
@ -1032,6 +1570,7 @@ const conditionalLogicEngine = ref(null);
// Settings tabs configuration // Settings tabs configuration
const settingsTabs = [ const settingsTabs = [
{ key: 'info', label: 'Form Info', icon: 'material-symbols:info-outline' }, { key: 'info', label: 'Form Info', icon: 'material-symbols:info-outline' },
{ key: 'submit', label: 'Submit Button', icon: 'material-symbols:play-arrow' },
{ key: 'javascript', label: 'JavaScript', icon: 'material-symbols:code' }, { key: 'javascript', label: 'JavaScript', icon: 'material-symbols:code' },
{ key: 'css', label: 'CSS', icon: 'material-symbols:palette-outline' }, { key: 'css', label: 'CSS', icon: 'material-symbols:palette-outline' },
{ key: 'events', label: 'Events', icon: 'material-symbols:event-outline' }, { key: 'events', label: 'Events', icon: 'material-symbols:event-outline' },
@ -1048,6 +1587,30 @@ const formName = computed({
} }
}); });
// Category options for form creation
const categoryOptions = [
{ label: 'Select Category', value: '' },
{ label: 'Forms', value: 'forms' },
{ label: 'Surveys', value: 'surveys' },
{ label: 'Applications', value: 'applications' },
{ label: 'Feedback', value: 'feedback' },
{ label: 'Registration', value: 'registration' },
{ label: 'Other', value: 'other' }
];
// Computed to check if we have a current form loaded
const hasCurrentForm = computed(() => {
// Consider a form loaded if any of these conditions are true:
// 1. Form has components (user has added fields)
// 2. Form has an ID (saved form)
// 3. Form name has been changed from default (form creation completed)
return formStore.formComponents.length > 0 ||
formStore.currentFormId ||
(formStore.formName &&
formStore.formName.trim() !== '' &&
formStore.formName !== 'New Form');
});
// Form JSON representation for developer view // Form JSON representation for developer view
const formJson = computed(() => { const formJson = computed(() => {
return { return {
@ -1062,7 +1625,8 @@ const formJson = computed(() => {
customScript: formStore.formCustomScript, customScript: formStore.formCustomScript,
customCSS: formStore.formCustomCSS, customCSS: formStore.formCustomCSS,
formEvents: formStore.formEvents, formEvents: formStore.formEvents,
scriptMode: formStore.scriptMode scriptMode: formStore.scriptMode,
submitButton: formStore.submitButton
}; };
}); });
@ -1269,6 +1833,30 @@ const applyJsonChanges = () => {
formStore.scriptMode = importedJson.scriptMode; formStore.scriptMode = importedJson.scriptMode;
} }
// Import submit button settings if available
if (importedJson.submitButton) {
formStore.submitButton = {
enabled: importedJson.submitButton.enabled !== undefined ? importedJson.submitButton.enabled : true,
label: importedJson.submitButton.label || 'Submit',
category: importedJson.submitButton.category || 'primary',
color: importedJson.submitButton.color || 'primary'
};
// Handle backward compatibility with old variant format
if (importedJson.submitButton.variant && !importedJson.submitButton.category && !importedJson.submitButton.color) {
const variantMapping = {
'primary': { category: 'primary', color: 'primary' },
'secondary': { category: 'secondary', color: 'secondary' },
'success': { category: 'primary', color: 'success' },
'warning': { category: 'primary', color: 'warning' },
'danger': { category: 'primary', color: 'danger' }
};
const mapping = variantMapping[importedJson.submitButton.variant] || { category: 'primary', color: 'primary' };
formStore.submitButton.category = mapping.category;
formStore.submitButton.color = mapping.color;
}
}
// Mark as having unsaved changes // Mark as having unsaved changes
formStore.hasUnsavedChanges = true; formStore.hasUnsavedChanges = true;
@ -1562,6 +2150,9 @@ onMounted(async () => {
console.error('Error loading form from ID:', error); console.error('Error loading form from ID:', error);
toast.error(`Failed to load form: ${error.message || 'Unknown error'}`); toast.error(`Failed to load form: ${error.message || 'Unknown error'}`);
} }
} else {
// No form ID provided, ensure we start with a clean slate
formStore.clearForm();
} }
} catch (error) { } catch (error) {
console.error('Error initializing form builder:', error); console.error('Error initializing form builder:', error);
@ -2056,6 +2647,54 @@ const navigateToManage = () => {
navigationTarget.value = "/form-builder/manage"; navigationTarget.value = "/form-builder/manage";
}; };
// Handle creating a new form
const createNewForm = async () => {
if (!newFormName.value.trim()) {
toast.error('Please enter a form name');
return;
}
try {
// Set the form name and description in the store
formStore.setFormName(newFormName.value.trim());
formStore.formDescription = newFormDescription.value.trim();
formStore.formCategory = newFormCategory.value.trim() || null;
formStore.formGroup = newFormGroup.value.trim() || null;
// Clear existing form components to start fresh
formStore.formComponents = [];
formStore.currentFormId = null; // This ensures we create a new form instead of updating existing
// Save the form to the database immediately
const savedForm = await formStore.saveForm();
if (savedForm) {
// Update URL to include the form ID
const newPath = `/form-builder?id=${savedForm.formUUID}`;
window.history.replaceState({}, '', newPath);
// Reset navigation confirmation
navigationConfirmed.value = false;
// Reset form variables
isCreatingForm.value = false;
newFormName.value = '';
newFormDescription.value = '';
newFormCategory.value = '';
newFormGroup.value = '';
// Show success message
toast.success(`Form "${formStore.formName}" created and saved successfully`);
} else {
toast.error('Failed to save form to database. Please try again.');
}
} catch (error) {
console.error('Error creating form:', error);
toast.error(`Failed to create form: ${error.message || 'Unknown error'}`);
}
};
const handleOptimizeLayout = () => { const handleOptimizeLayout = () => {
formStore.optimizeGridLayout(); formStore.optimizeGridLayout();
}; };
@ -2495,8 +3134,21 @@ watch(() => formStore.selectedComponent, (newComponent) => {
label: newComponent.props.label || '', label: newComponent.props.label || '',
name: newComponent.props.name || '', name: newComponent.props.name || '',
placeholder: newComponent.props.placeholder || '', placeholder: newComponent.props.placeholder || '',
rows: newComponent.props.rows || 3,
linkType: newComponent.props.linkType || 'none',
linkUrl: newComponent.props.linkUrl || '',
linkProcessId: newComponent.props.linkProcessId || '',
iframeDebug: newComponent.props.iframeDebug || false,
iframeHideComplete: newComponent.props.iframeHideComplete || false,
iframeTheme: newComponent.props.iframeTheme || '',
iframeCustomParams: newComponent.props.iframeCustomParams || '',
required: newComponent.props.validation?.includes('required') || false required: newComponent.props.validation?.includes('required') || false
}; };
// Fetch published processes if this is a button component
if (newComponent.type === 'button') {
fetchPublishedProcesses();
}
} }
}, { immediate: true }); }, { immediate: true });
@ -2513,6 +3165,14 @@ const showQuickField = (fieldName) => {
label: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button'], label: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button'],
name: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'hidden', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button'], name: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'hidden', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button'],
placeholder: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select'], placeholder: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select'],
rows: ['textarea'],
linkType: ['button'],
linkUrl: ['button'],
linkProcessId: ['button'],
iframeDebug: ['button'],
iframeHideComplete: ['button'],
iframeTheme: ['button'],
iframeCustomParams: ['button'],
width: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'heading', 'paragraph', 'info-display'], width: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'heading', 'paragraph', 'info-display'],
required: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'date', 'time', 'datetime-local', 'file', 'otp', 'dropzone'] required: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'date', 'time', 'datetime-local', 'file', 'otp', 'dropzone']
}; };
@ -2527,7 +3187,8 @@ const getPlaceholder = (fieldName) => {
const placeholders = { const placeholders = {
label: `Enter ${formStore.selectedComponent.type} label`, label: `Enter ${formStore.selectedComponent.type} label`,
name: `${formStore.selectedComponent.type}_field`, name: `${formStore.selectedComponent.type}_field`,
placeholder: 'Enter placeholder text...' placeholder: 'Enter placeholder text...',
rows: '3'
}; };
return placeholders[fieldName] || ''; return placeholders[fieldName] || '';
@ -2634,6 +3295,10 @@ const handleFormRestored = (restoredForm) => {
</script> </script>
<style scoped> <style scoped>
/* Submit button hover effects using CSS custom properties */
:deep(.formkit-input[type="submit"]:hover) {
background-color: var(--hover-bg) !important;
}
.form-name-input { .form-name-input {
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;

View File

@ -47,36 +47,70 @@
<!-- Main Content Area --> <!-- Main Content Area -->
<div class="flex-1 p-6 overflow-auto"> <div class="flex-1 p-6 overflow-auto">
<!-- Filters and Search --> <!-- Filters and Search -->
<div class="mb-6 flex flex-col sm:flex-row gap-4"> <div class="mb-6 bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div class="flex-1 max-w-md"> <div class="flex flex-col lg:flex-row gap-4">
<FormKit <div class="flex-1 max-w-md">
v-model="searchQuery" <FormKit
type="text" v-model="searchQuery"
placeholder="Search forms..." type="text"
:classes="{ placeholder="Search forms by name or description..."
outer: 'mb-0', :classes="{
wrapper: 'relative', outer: 'mb-0',
inner: 'relative', wrapper: 'relative',
input: 'w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500', inner: 'relative',
prefixIcon: 'absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 pointer-events-none' input: 'w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500',
}" prefixIcon: 'absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 pointer-events-none'
> }"
<template #prefixIcon> >
<Icon name="material-symbols:search" class="text-lg" /> <template #prefixIcon>
</template> <Icon name="material-symbols:search" class="text-lg" />
</FormKit> </template>
</div> </FormKit>
</div>
<div class="flex gap-2">
<RsButton @click="clearFilters" variant="secondary" size="sm" :disabled="loading" v-if="searchQuery">
<Icon name="material-symbols:filter-alt-off" class="mr-1" />
Clear Filters
</RsButton>
<RsButton @click="loadForms" variant="tertiary" size="sm" :disabled="loading"> <div class="min-w-48">
<Icon name="material-symbols:refresh" class="mr-1" /> <FormKit
Refresh v-model="selectedCategory"
</RsButton> type="select"
:options="categoryOptions"
placeholder="Filter by category"
:classes="{
outer: 'mb-0',
input: 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white'
}"
/>
</div>
<div class="min-w-48">
<FormKit
v-model="selectedGroup"
type="select"
:options="groupOptions"
placeholder="Filter by group"
:classes="{
outer: 'mb-0',
input: 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white'
}"
/>
</div>
<div class="flex gap-2">
<RsButton
@click="clearFilters"
variant="secondary"
size="sm"
:disabled="loading"
v-if="hasActiveFilters"
>
<Icon name="material-symbols:filter-alt-off" class="mr-1" />
Clear
</RsButton>
<RsButton @click="loadForms" variant="tertiary" size="sm" :disabled="loading">
<Icon name="material-symbols:refresh" class="mr-1" />
Refresh
</RsButton>
</div>
</div> </div>
</div> </div>
@ -88,112 +122,233 @@
</div> </div>
</div> </div>
<!-- Forms Table --> <!-- Forms Grid -->
<div v-else class="bg-white shadow-md rounded-lg overflow-hidden"> <div v-else-if="filteredForms.length > 0" class="grid gap-4">
<table class="w-full table-auto"> <div
<thead class="bg-gray-50 border-b"> v-for="form in paginatedForms"
<tr> :key="form.id"
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th> class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow"
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th> >
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Components</th> <div class="flex items-start justify-between">
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th> <div class="flex-1">
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Updated</th> <div class="flex items-center gap-3 mb-2">
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> <h3 class="text-lg font-semibold text-gray-900">{{ form.name || 'Untitled Form' }}</h3>
</tr> <div class="flex gap-2">
</thead> <RsBadge v-if="form.category" :variant="getCategoryColor(form.category)" size="sm">
<tbody class="divide-y divide-gray-200"> {{ form.category }}
<tr v-if="filteredForms.length === 0"> </RsBadge>
<td colspan="6" class="px-6 py-12 text-center text-gray-500"> <RsBadge v-if="form.group" variant="secondary" size="sm">
<div class="flex flex-col items-center"> {{ form.group }}
<Icon name="material-symbols:description-outline" class="w-12 h-12 text-gray-300 mb-2" /> </RsBadge>
<p class="text-lg font-medium mb-1">
{{ searchQuery ? 'No forms match your search' : 'No forms found' }}
</p>
<p class="text-sm mb-2">
{{ searchQuery ? 'Try adjusting your search terms' : 'Create your first form to get started' }}
</p>
<!-- Show current search if any -->
<div v-if="searchQuery" class="text-xs text-gray-400 mb-4">
<div>Search: "{{ searchQuery }}"</div>
</div>
<div class="flex gap-2">
<RsButton v-if="searchQuery" @click="clearFilters" variant="secondary" size="sm">
<Icon name="material-symbols:filter-alt-off" class="mr-1" />
Clear Filters
</RsButton>
<RsButton v-if="!searchQuery" @click="createNewForm" variant="primary" size="sm">
<Icon name="material-symbols:add" class="mr-1" />
Create New Form
</RsButton>
</div>
</div> </div>
</td> </div>
</tr>
<tr v-for="form in filteredForms" :key="form.id" class="hover:bg-gray-50"> <p class="text-gray-600 mb-3">
<td class="px-6 py-4 whitespace-nowrap"> {{ form.description || 'No description available' }}
<div class="font-medium text-gray-900">{{ form.name || 'Untitled Form' }}</div> </p>
<div class="text-sm text-gray-500">ID: {{ form.id }}</div>
</td> <div class="flex items-center gap-6 text-sm text-gray-500">
<td class="px-6 py-4"> <div class="flex items-center gap-1">
<div class="text-sm text-gray-700 max-w-xs truncate"> <Icon name="material-symbols:fingerprint" class="w-4 h-4" />
{{ form.description || 'No description' }} ID: {{ form.id }}
</div> </div>
</td> <div class="flex items-center gap-1">
<td class="px-6 py-4 whitespace-nowrap"> <Icon name="material-symbols:calendar-add-on-outline" class="w-4 h-4" />
<div class="text-sm text-gray-700"> Created {{ formatDate(form.createdAt) }}
{{ form.components ? form.components.length : 0 }} components
</div> </div>
</td> <div class="flex items-center gap-1">
<td class="px-6 py-4 whitespace-nowrap"> <Icon name="material-symbols:schedule" class="w-4 h-4" />
<div class="text-sm text-gray-500">{{ formatDate(form.createdAt) }}</div> Updated {{ formatDate(form.updatedAt) }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-500">{{ formatDate(form.updatedAt) }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex space-x-2 justify-end">
<button
@click="editForm(form.id)"
class="p-1 text-blue-600 hover:text-blue-900 hover:bg-blue-50 rounded"
title="Edit Form"
:disabled="loading"
>
<Icon name="material-symbols:edit" class="text-lg" />
</button>
<button
@click="duplicateForm(form)"
class="p-1 text-indigo-600 hover:text-indigo-900 hover:bg-indigo-50 rounded"
title="Duplicate Form"
:disabled="loading"
>
<Icon name="material-symbols:content-copy" class="text-lg" />
</button>
<button
@click="exportForm(form)"
class="p-1 text-green-600 hover:text-green-900 hover:bg-green-50 rounded"
title="Export Form as JSON"
:disabled="loading"
>
<Icon name="material-symbols:download" class="text-lg" />
</button>
<button
@click="confirmDelete(form.id)"
class="p-1 text-red-600 hover:text-red-900 hover:bg-red-50 rounded"
title="Delete Form"
:disabled="loading"
>
<Icon name="material-symbols:delete" class="text-lg" />
</button>
</div> </div>
</td> </div>
</tr> </div>
</tbody>
</table> <div class="flex items-center gap-2 ml-4">
<button
@click="editForm(form.id)"
class="p-2 text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded-lg transition-colors"
title="Edit Form"
:disabled="loading"
>
<Icon name="material-symbols:edit" class="text-lg" />
</button>
<button
@click="duplicateForm(form)"
class="p-2 text-indigo-600 hover:text-indigo-800 hover:bg-indigo-50 rounded-lg transition-colors"
title="Duplicate Form"
:disabled="loading"
>
<Icon name="material-symbols:content-copy" class="text-lg" />
</button>
<button
@click="exportForm(form)"
class="p-2 text-green-600 hover:text-green-800 hover:bg-green-50 rounded-lg transition-colors"
title="Export Form as JSON"
:disabled="loading"
>
<Icon name="material-symbols:download" class="text-lg" />
</button>
<button
@click="confirmDelete(form.id)"
class="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-lg transition-colors"
title="Delete Form"
:disabled="loading"
>
<Icon name="material-symbols:delete" class="text-lg" />
</button>
</div>
</div>
</div>
</div>
<!-- Pagination -->
<div v-if="filteredForms.length > 0 && totalPages > 1" class="mt-6 bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
<!-- Pagination Info -->
<div class="text-sm text-gray-700">
Showing {{ paginationInfo.start }}-{{ paginationInfo.end }} of {{ paginationInfo.total }} forms
</div>
<!-- Items per page selector -->
<div class="flex items-center gap-2">
<span class="text-sm text-gray-700">Items per page:</span>
<FormKit
:model-value="itemsPerPage"
@update:model-value="changeItemsPerPage"
type="select"
:options="[
{ label: '5', value: 5 },
{ label: '10', value: 10 },
{ label: '20', value: 20 },
{ label: '50', value: 50 }
]"
:classes="{
outer: 'mb-0',
input: 'px-2 py-1 border border-gray-300 rounded text-sm bg-white min-w-16'
}"
/>
</div>
<!-- Pagination Controls -->
<div class="flex items-center gap-1">
<!-- Previous Button -->
<button
@click="prevPage"
:disabled="!hasPrevPage"
class="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Icon name="material-symbols:chevron-left" class="w-4 h-4" />
</button>
<!-- Page Numbers -->
<template v-if="totalPages <= 7">
<!-- Show all pages if 7 or fewer -->
<button
v-for="page in totalPages"
:key="page"
@click="goToPage(page)"
:class="[
'px-3 py-2 text-sm border rounded-lg transition-colors',
page === currentPage
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
]"
>
{{ page }}
</button>
</template>
<template v-else>
<!-- Show first page -->
<button
@click="goToPage(1)"
:class="[
'px-3 py-2 text-sm border rounded-lg transition-colors',
1 === currentPage
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
]"
>
1
</button>
<!-- Show ellipsis if needed -->
<span v-if="currentPage > 3" class="px-2 text-gray-500">...</span>
<!-- Show pages around current page -->
<button
v-for="page in [currentPage - 1, currentPage, currentPage + 1].filter(p => p > 1 && p < totalPages)"
:key="page"
@click="goToPage(page)"
:class="[
'px-3 py-2 text-sm border rounded-lg transition-colors',
page === currentPage
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
]"
>
{{ page }}
</button>
<!-- Show ellipsis if needed -->
<span v-if="currentPage < totalPages - 2" class="px-2 text-gray-500">...</span>
<!-- Show last page -->
<button
v-if="totalPages > 1"
@click="goToPage(totalPages)"
:class="[
'px-3 py-2 text-sm border rounded-lg transition-colors',
totalPages === currentPage
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
]"
>
{{ totalPages }}
</button>
</template>
<!-- Next Button -->
<button
@click="nextPage"
:disabled="!hasNextPage"
class="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Icon name="material-symbols:chevron-right" class="w-4 h-4" />
</button>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else-if="filteredForms.length === 0" class="bg-white rounded-xl shadow-sm border border-gray-200 p-12 text-center">
<Icon name="material-symbols:description-outline" class="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 class="text-lg font-medium text-gray-900 mb-2">
{{ hasActiveFilters ? 'No forms match your filters' : 'No forms found' }}
</h3>
<p class="text-gray-600 mb-6">
{{ hasActiveFilters ? 'Try adjusting your search or filter criteria' : 'Create your first form to get started' }}
</p>
<!-- Show current filters if any -->
<div v-if="hasActiveFilters" class="text-sm text-gray-400 mb-6 space-y-1">
<div v-if="searchQuery">Search: "{{ searchQuery }}"</div>
<div v-if="selectedCategory">Category: {{ selectedCategory }}</div>
<div v-if="selectedGroup">Group: {{ selectedGroup }}</div>
</div>
<div class="flex justify-center gap-3">
<RsButton v-if="hasActiveFilters" @click="clearFilters" variant="secondary">
<Icon name="material-symbols:filter-alt-off" class="mr-2" />
Clear Filters
</RsButton>
<RsButton v-if="!hasActiveFilters" @click="createNewForm" variant="primary">
<Icon name="material-symbols:add" class="mr-2" />
Create New Form
</RsButton>
</div>
</div> </div>
<!-- Global Loading Overlay --> <!-- Global Loading Overlay -->
@ -385,6 +540,8 @@ try {
// State // State
const searchQuery = ref(''); const searchQuery = ref('');
const selectedCategory = ref('');
const selectedGroup = ref('');
const loading = ref(false); const loading = ref(false);
const showDeleteConfirm = ref(false); const showDeleteConfirm = ref(false);
const formToDelete = ref(null); const formToDelete = ref(null);
@ -398,7 +555,33 @@ const jsonContent = ref('');
const jsonValidationMessage = ref(''); const jsonValidationMessage = ref('');
const jsonIsValid = ref(false); const jsonIsValid = ref(false);
// Filtered forms // Pagination state
const currentPage = ref(1);
const itemsPerPage = ref(5); // Set to 5 to test pagination
const totalItems = ref(0);
// Computed properties for grouping
const categoryOptions = computed(() => {
const categories = [...new Set(formStore.savedForms.map(form => form.category).filter(Boolean))];
return [
{ label: 'All Categories', value: '' },
...categories.map(cat => ({ label: cat, value: cat }))
];
});
const groupOptions = computed(() => {
const groups = [...new Set(formStore.savedForms.map(form => form.group).filter(Boolean))];
return [
{ label: 'All Groups', value: '' },
...groups.map(group => ({ label: group, value: group }))
];
});
const hasActiveFilters = computed(() => {
return searchQuery.value || selectedCategory.value || selectedGroup.value;
});
// Filtered forms (all forms after applying filters)
const filteredForms = computed(() => { const filteredForms = computed(() => {
let filtered = formStore.savedForms; let filtered = formStore.savedForms;
@ -412,9 +595,52 @@ const filteredForms = computed(() => {
); );
} }
// Filter by category
if (selectedCategory.value) {
filtered = filtered.filter(form => form.category === selectedCategory.value);
}
// Filter by group
if (selectedGroup.value) {
filtered = filtered.filter(form => form.group === selectedGroup.value);
}
// Update total items for pagination
totalItems.value = filtered.length;
return filtered; return filtered;
}); });
// Paginated forms (forms for current page)
const paginatedForms = computed(() => {
const start = (currentPage.value - 1) * itemsPerPage.value;
const end = start + itemsPerPage.value;
return filteredForms.value.slice(start, end);
});
// Pagination computed properties
const totalPages = computed(() => {
return Math.ceil(totalItems.value / itemsPerPage.value);
});
const hasNextPage = computed(() => {
return currentPage.value < totalPages.value;
});
const hasPrevPage = computed(() => {
return currentPage.value > 1;
});
const paginationInfo = computed(() => {
const start = totalItems.value === 0 ? 0 : (currentPage.value - 1) * itemsPerPage.value + 1;
const end = Math.min(currentPage.value * itemsPerPage.value, totalItems.value);
return {
start,
end,
total: totalItems.value
};
});
// Format date for display // Format date for display
const formatDate = (isoString) => { const formatDate = (isoString) => {
if (!isoString) return ''; if (!isoString) return '';
@ -459,18 +685,17 @@ const duplicateForm = async (form) => {
loading.value = true; loading.value = true;
const newName = `${form.name || 'Form'} (Copy)`; const newName = `${form.name || 'Form'} (Copy)`;
// Create a copy of the form // Load the original form first
const duplicatedForm = {
...form,
id: null, // Will get new ID when saved
name: newName,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
// Load the original form, duplicate it, and save as new
await formStore.loadForm(form.id); await formStore.loadForm(form.id);
// Clear the current form ID to ensure a new form is created
formStore.currentFormId = null;
// Update the form name for the duplicate
formStore.setFormName(newName); formStore.setFormName(newName);
// Mark as having unsaved changes and save as new form
formStore.hasUnsavedChanges = true;
await formStore.saveForm(); await formStore.saveForm();
// Refresh the form list // Refresh the form list
@ -552,8 +777,10 @@ onMounted(async () => {
await loadForms(); await loadForms();
}); });
// Watch for changes in search and reload forms // Watch for changes in search and filters
watch([searchQuery], () => { watch([searchQuery, selectedCategory, selectedGroup], () => {
// Reset to first page when filters change
currentPage.value = 1;
// Debounce the search to avoid too many API calls // Debounce the search to avoid too many API calls
clearTimeout(searchTimeout); clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => { searchTimeout = setTimeout(() => {
@ -566,9 +793,49 @@ let searchTimeout = null;
// Clear all filters // Clear all filters
const clearFilters = () => { const clearFilters = () => {
searchQuery.value = ''; searchQuery.value = '';
selectedCategory.value = '';
selectedGroup.value = '';
currentPage.value = 1; // Reset to first page
// loadForms will be called automatically by the watcher // loadForms will be called automatically by the watcher
}; };
// Pagination methods
const goToPage = (page) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page;
}
};
const nextPage = () => {
if (hasNextPage.value) {
currentPage.value++;
}
};
const prevPage = () => {
if (hasPrevPage.value) {
currentPage.value--;
}
};
const changeItemsPerPage = (newItemsPerPage) => {
itemsPerPage.value = newItemsPerPage;
currentPage.value = 1; // Reset to first page when changing items per page
};
// Helper function to get category color
const getCategoryColor = (category) => {
const colors = {
'forms': 'primary',
'surveys': 'success',
'applications': 'info',
'feedback': 'warning',
'registration': 'danger',
'default': 'secondary'
};
return colors[category?.toLowerCase()] || colors.default;
};
// Clean up the search timeout on component unmount // Clean up the search timeout on component unmount
onUnmounted(() => { onUnmounted(() => {
clearTimeout(searchTimeout); clearTimeout(searchTimeout);
@ -826,13 +1093,35 @@ const closeImportModal = () => {
</script> </script>
<style scoped> <style scoped>
/* Custom styles for better UX */ /* Custom transitions and animations */
.hover\:bg-gray-50:hover { .transition-colors {
transition: background-color 0.15s ease-in-out; transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.transition-shadow {
transition-property: box-shadow;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
} }
button:disabled { button:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
/* Glassmorphism effect for cards */
.bg-white {
backdrop-filter: blur(10px);
}
/* Line clamp utility for description text */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
</style> </style>

View File

@ -3149,10 +3149,6 @@ const sendToBack = () => {
<Icon name="material-symbols:tune" class="mr-2 w-4 h-4" /> <Icon name="material-symbols:tune" class="mr-2 w-4 h-4" />
<span>Process Settings</span> <span>Process Settings</span>
</button> </button>
<button @click="confirmNavigation('/process-builder/manage'); showDropdown = false" class="w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center">
<Icon name="material-symbols:settings" class="mr-2 w-4 h-4" />
<span>Manage Processes</span>
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -29,6 +29,11 @@ const sortBy = ref('processCreatedDate');
const sortOrder = ref('desc'); const sortOrder = ref('desc');
const currentView = ref('dashboard'); // 'dashboard', 'list', 'analytics' const currentView = ref('dashboard'); // 'dashboard', 'list', 'analytics'
// Pagination state
const currentPage = ref(1);
const itemsPerPage = ref(20); // Default to 20 items per page
const totalItems = ref(0);
// Dashboard metrics and data // Dashboard metrics and data
const dashboardMetrics = ref({ const dashboardMetrics = ref({
totalProcesses: 0, totalProcesses: 0,
@ -66,9 +71,66 @@ const categoryOptions = [
{ value: 'Procurement', label: 'Procurement' } { value: 'Procurement', label: 'Procurement' }
]; ];
// Filtered processes // Filtered processes (all processes after applying filters)
const filteredProcesses = computed(() => { const filteredProcesses = computed(() => {
return processStore.processes; let filtered = processStore.processes;
// Apply search filter
if (searchQuery.value.trim()) {
const query = searchQuery.value.toLowerCase().trim();
filtered = filtered.filter(process =>
process.name?.toLowerCase().includes(query) ||
process.description?.toLowerCase().includes(query) ||
process.category?.toLowerCase().includes(query) ||
process.creator?.userFullName?.toLowerCase().includes(query) ||
process.creator?.userUsername?.toLowerCase().includes(query)
);
}
// Apply status filter
if (statusFilter.value) {
filtered = filtered.filter(process => process.status === statusFilter.value);
}
// Apply category filter
if (categoryFilter.value) {
filtered = filtered.filter(process => process.category === categoryFilter.value);
}
// Update total items for pagination
totalItems.value = filtered.length;
return filtered;
});
// Paginated processes (processes for current page)
const paginatedProcesses = computed(() => {
const start = (currentPage.value - 1) * itemsPerPage.value;
const end = start + itemsPerPage.value;
return filteredProcesses.value.slice(start, end);
});
// Pagination computed properties
const totalPages = computed(() => {
return Math.ceil(totalItems.value / itemsPerPage.value);
});
const hasNextPage = computed(() => {
return currentPage.value < totalPages.value;
});
const hasPrevPage = computed(() => {
return currentPage.value > 1;
});
const paginationInfo = computed(() => {
const start = totalItems.value === 0 ? 0 : (currentPage.value - 1) * itemsPerPage.value + 1;
const end = Math.min(currentPage.value * itemsPerPage.value, totalItems.value);
return {
start,
end,
total: totalItems.value
};
}); });
// Load dashboard summary data from API // Load dashboard summary data from API
@ -190,7 +252,8 @@ const loadProcesses = async () => {
try { try {
const options = { const options = {
sortBy: sortBy.value, sortBy: sortBy.value,
sortOrder: sortOrder.value sortOrder: sortOrder.value,
limit: 1000 // Set a high limit to get all processes
}; };
if (statusFilter.value) { if (statusFilter.value) {
@ -220,6 +283,8 @@ const loadProcesses = async () => {
// Watch for changes in filters and reload processes // Watch for changes in filters and reload processes
watch([searchQuery, statusFilter, categoryFilter], () => { watch([searchQuery, statusFilter, categoryFilter], () => {
// Reset to first page when filters change
currentPage.value = 1;
clearTimeout(searchTimeout); clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => { searchTimeout = setTimeout(() => {
loadProcesses(); loadProcesses();
@ -283,8 +348,24 @@ const publishProcess = async (processId) => {
loading.value = true; loading.value = true;
await processStore.publishProcess(processId); await processStore.publishProcess(processId);
await loadProcesses(); await loadProcesses();
toast.success('Process published successfully');
} catch (error) { } catch (error) {
console.error('Error publishing process:', error); console.error('Error publishing process:', error);
toast.error('Failed to publish process: ' + (error.message || 'Unknown error'));
} finally {
loading.value = false;
}
};
const unpublishProcess = async (processId) => {
try {
loading.value = true;
await processStore.unpublishProcess(processId);
await loadProcesses();
toast.success('Process unpublished successfully');
} catch (error) {
console.error('Error unpublishing process:', error);
toast.error('Failed to unpublish process: ' + (error.message || 'Unknown error'));
} finally { } finally {
loading.value = false; loading.value = false;
} }
@ -303,6 +384,14 @@ const restoreProcess = async (processId) => {
}; };
const confirmDelete = (processId) => { const confirmDelete = (processId) => {
const process = processStore.processes.find(p => p.id === processId);
// If process is published, show a message instead of allowing deletion
if (process && process.status === 'published') {
toast.warning('Please unpublish the process first before deleting it.');
return;
}
processToDelete.value = processId; processToDelete.value = processId;
showDeleteConfirm.value = true; showDeleteConfirm.value = true;
}; };
@ -325,6 +414,31 @@ const clearFilters = () => {
searchQuery.value = ''; searchQuery.value = '';
statusFilter.value = ''; statusFilter.value = '';
categoryFilter.value = ''; categoryFilter.value = '';
currentPage.value = 1; // Reset to first page
};
// Pagination methods
const goToPage = (page) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page;
}
};
const nextPage = () => {
if (hasNextPage.value) {
currentPage.value++;
}
};
const prevPage = () => {
if (hasPrevPage.value) {
currentPage.value--;
}
};
const changeItemsPerPage = (newItemsPerPage) => {
itemsPerPage.value = newItemsPerPage;
currentPage.value = 1; // Reset to first page when changing items per page
}; };
// Load processes on component mount // Load processes on component mount
@ -629,8 +743,9 @@ const copyWorkflowLink = async (processId) => {
<!-- Processes Grid --> <!-- Processes Grid -->
<div v-else-if="filteredProcesses.length > 0" class="grid gap-4"> <div v-else-if="filteredProcesses.length > 0" class="grid gap-4">
<div <div
v-for="process in filteredProcesses" v-for="process in paginatedProcesses"
:key="process.id" :key="process.id"
class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow"
> >
@ -710,13 +825,23 @@ const copyWorkflowLink = async (processId) => {
<button <button
v-if="process.status === 'draft'" v-if="process.status === 'draft'"
@click="publishProcess(process.id)" @click="publishProcess(process.id)"
class="p-2 text-green-600 hover:text-green-800 hover:bg-green-50 rounded-lg transition-colors" class="p-2 text-green-600 hover:text-green-800 hover:bg-green-50 rounded-lg transition-colors"
title="Publish Process" title="Publish Process"
:disabled="loading" :disabled="loading"
> >
<Icon name="material-symbols:publish" class="text-lg" /> <Icon name="material-symbols:publish" class="text-lg" />
</button> </button>
<button
v-if="process.status === 'published'"
@click="unpublishProcess(process.id)"
class="p-2 text-orange-600 hover:text-orange-800 hover:bg-orange-50 rounded-lg transition-colors"
title="Unpublish Process (Move to Draft)"
:disabled="loading"
>
<Icon name="material-symbols:unpublished" class="text-lg" />
</button>
<button <button
@click="duplicateProcess(process)" @click="duplicateProcess(process)"
class="p-2 text-indigo-600 hover:text-indigo-800 hover:bg-indigo-50 rounded-lg transition-colors" class="p-2 text-indigo-600 hover:text-indigo-800 hover:bg-indigo-50 rounded-lg transition-colors"
@ -728,8 +853,8 @@ const copyWorkflowLink = async (processId) => {
<button <button
@click="confirmDelete(process.id)" @click="confirmDelete(process.id)"
class="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-lg transition-colors" class="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-lg transition-colors"
title="Delete Process" :title="process.status === 'published' ? 'Unpublish first to delete this process' : 'Delete Process'"
:disabled="loading || process.status === 'published'" :disabled="loading || process.status === 'published'"
> >
<Icon name="material-symbols:delete" class="text-lg" /> <Icon name="material-symbols:delete" class="text-lg" />
@ -740,8 +865,127 @@ const copyWorkflowLink = async (processId) => {
</div> </div>
</div> </div>
<!-- Pagination -->
<div v-if="filteredProcesses.length > 0 && totalPages > 1" class="mt-6 bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
<!-- Pagination Info -->
<div class="text-sm text-gray-700">
Showing {{ paginationInfo.start }}-{{ paginationInfo.end }} of {{ paginationInfo.total }} processes
</div>
<!-- Items per page selector -->
<div class="flex items-center gap-2">
<span class="text-sm text-gray-700">Items per page:</span>
<FormKit
:model-value="itemsPerPage"
@update:model-value="changeItemsPerPage"
type="select"
:options="[
{ label: '5', value: 5 },
{ label: '10', value: 10 },
{ label: '20', value: 20 },
{ label: '50', value: 50 },
{ label: '100', value: 100 }
]"
:classes="{
outer: 'mb-0',
input: 'px-2 py-1 border border-gray-300 rounded text-sm bg-white min-w-16'
}"
/>
</div>
<!-- Pagination Controls -->
<div class="flex items-center gap-1">
<!-- Previous Button -->
<button
@click="prevPage"
:disabled="!hasPrevPage"
class="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Icon name="material-symbols:chevron-left" class="w-4 h-4" />
</button>
<!-- Page Numbers -->
<template v-if="totalPages <= 7">
<!-- Show all pages if 7 or fewer -->
<button
v-for="page in totalPages"
:key="page"
@click="goToPage(page)"
:class="[
'px-3 py-2 text-sm border rounded-lg transition-colors',
page === currentPage
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
]"
>
{{ page }}
</button>
</template>
<template v-else>
<!-- Show first page -->
<button
@click="goToPage(1)"
:class="[
'px-3 py-2 text-sm border rounded-lg transition-colors',
1 === currentPage
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
]"
>
1
</button>
<!-- Show ellipsis if needed -->
<span v-if="currentPage > 3" class="px-2 text-gray-500">...</span>
<!-- Show pages around current page -->
<button
v-for="page in [currentPage - 1, currentPage, currentPage + 1].filter(p => p > 1 && p < totalPages)"
:key="page"
@click="goToPage(page)"
:class="[
'px-3 py-2 text-sm border rounded-lg transition-colors',
page === currentPage
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
]"
>
{{ page }}
</button>
<!-- Show ellipsis if needed -->
<span v-if="currentPage < totalPages - 2" class="px-2 text-gray-500">...</span>
<!-- Show last page -->
<button
v-if="totalPages > 1"
@click="goToPage(totalPages)"
:class="[
'px-3 py-2 text-sm border rounded-lg transition-colors',
totalPages === currentPage
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
]"
>
{{ totalPages }}
</button>
</template>
<!-- Next Button -->
<button
@click="nextPage"
:disabled="!hasNextPage"
class="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Icon name="material-symbols:chevron-right" class="w-4 h-4" />
</button>
</div>
</div>
</div>
<!-- Empty State --> <!-- Empty State -->
<div v-else class="bg-white rounded-xl shadow-sm border border-gray-200 p-12 text-center"> <div v-else-if="filteredProcesses.length === 0" class="bg-white rounded-xl shadow-sm border border-gray-200 p-12 text-center">
<Icon name="material-symbols:folder-open-outline" class="w-16 h-16 text-gray-300 mx-auto mb-4" /> <Icon name="material-symbols:folder-open-outline" class="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 class="text-lg font-medium text-gray-900 mb-2"> <h3 class="text-lg font-medium text-gray-900 mb-2">
{{ (searchQuery || statusFilter || categoryFilter) ? 'No processes match your filters' : 'No processes found' }} {{ (searchQuery || statusFilter || categoryFilter) ? 'No processes match your filters' : 'No processes found' }}
@ -790,7 +1034,7 @@ const copyWorkflowLink = async (processId) => {
This process will be moved to trash but not permanently deleted. You can restore it later from the "Deleted" filter if needed. This process will be moved to trash but not permanently deleted. You can restore it later from the "Deleted" filter if needed.
</p> </p>
<p class="text-gray-500 text-xs mt-2"> <p class="text-gray-500 text-xs mt-2">
Note: Published processes cannot be deleted and must be unpublished first. Note: To delete a published process, first unpublish it using the orange unpublish button.
</p> </p>
</div> </div>
</div> </div>

View File

@ -84,6 +84,84 @@ watch(isProcessComplete, (newValue) => {
} }
}); });
// Capture URL parameters for variables that are configured to do so
const captureUrlParameters = () => {
try {
console.log('[Workflow] Capturing URL parameters...');
// Get current URL parameters
const urlParams = new URLSearchParams(window.location.search);
console.log('[Workflow] Available URL parameters:', Object.fromEntries(urlParams.entries()));
// Check if we have process variables defined
const variableSource = process.value?.variables || process.value?.processVariables;
if (!variableSource) {
console.log('[Workflow] No process variables defined, skipping URL parameter capture');
return;
}
// Iterate through process variables to find ones configured for URL capture
Object.entries(variableSource).forEach(([variableName, variableConfig]) => {
if (variableConfig && variableConfig.captureFromUrl && variableConfig.urlParameterName) {
const paramName = variableConfig.urlParameterName;
const paramValue = urlParams.get(paramName);
if (paramValue !== null) {
// Convert the parameter value based on the variable type
let convertedValue = paramValue;
switch (variableConfig.type) {
case 'number':
case 'int':
case 'decimal':
convertedValue = Number(paramValue);
if (isNaN(convertedValue)) {
console.warn(`[Workflow] Could not convert URL parameter "${paramName}" value "${paramValue}" to number for variable "${variableName}"`);
convertedValue = variableConfig.value || 0; // Use default value
}
break;
case 'boolean':
convertedValue = paramValue.toLowerCase() === 'true' || paramValue === '1';
break;
case 'object':
try {
convertedValue = JSON.parse(paramValue);
} catch (e) {
console.warn(`[Workflow] Could not parse URL parameter "${paramName}" value "${paramValue}" as JSON for variable "${variableName}"`);
convertedValue = variableConfig.value || {}; // Use default value
}
break;
case 'date':
case 'datetime':
const dateValue = new Date(paramValue);
if (isNaN(dateValue.getTime())) {
console.warn(`[Workflow] Could not parse URL parameter "${paramName}" value "${paramValue}" as date for variable "${variableName}"`);
convertedValue = variableConfig.value || null; // Use default value
} else {
convertedValue = variableConfig.type === 'date' ? dateValue.toISOString().split('T')[0] : dateValue.toISOString();
}
break;
case 'string':
default:
convertedValue = String(paramValue);
break;
}
// Set the variable value
processVariables.value[variableName] = convertedValue;
console.log(`[Workflow] Captured URL parameter "${paramName}" = "${paramValue}" -> variable "${variableName}" = `, convertedValue);
} else {
console.log(`[Workflow] URL parameter "${paramName}" not found for variable "${variableName}"`);
}
}
});
console.log('[Workflow] Final process variables after URL capture:', processVariables.value);
} catch (err) {
console.error('[Workflow] Error capturing URL parameters:', err);
}
};
// Load process data // Load process data
const loadProcess = async () => { const loadProcess = async () => {
try { try {
@ -95,7 +173,6 @@ const loadProcess = async () => {
if (response.success) { if (response.success) {
process.value = response.process; // includes processDefinition process.value = response.process; // includes processDefinition
console.log('[Workflow] Process loaded:', process.value.processName, process.value.processDefinition);
// Check if process is published // Check if process is published
const status = process.value.processStatus || process.value.status || 'draft'; const status = process.value.processStatus || process.value.status || 'draft';
@ -104,8 +181,20 @@ const loadProcess = async () => {
return; return;
} }
// Initialize process variables from DB (process.processVariables) // Initialize process variables from DB - extract values from variable definitions
processVariables.value = process.value.processVariables ? { ...process.value.processVariables } : {}; processVariables.value = {};
// Try both possible property names for variables
const variableSource = process.value.variables || process.value.processVariables;
if (variableSource) {
Object.entries(variableSource).forEach(([name, variableDefinition]) => {
// Set the actual value, defaulting to the variable's default value or empty string
processVariables.value[name] = variableDefinition.value || variableDefinition.defaultValue || '';
});
}
// Capture URL parameters for variables configured to do so
captureUrlParameters();
// Start the process execution (case instance) // Start the process execution (case instance)
await startProcessExecution(); await startProcessExecution();
@ -567,11 +656,18 @@ function buildApiRequest(nodeData, variables) {
// 4. Authorization // 4. Authorization
if (nodeData.authorization && nodeData.authorization.type && nodeData.authorization.type !== 'none') { if (nodeData.authorization && nodeData.authorization.type && nodeData.authorization.type !== 'none') {
const auth = nodeData.authorization; const auth = nodeData.authorization;
if (auth.type === 'bearer' && auth.token) { if (auth.type === 'bearer' && auth.token) {
headers['Authorization'] = `Bearer ${substituteVariables(auth.token, variables)}`; headers['Authorization'] = `Bearer ${substituteVariables(auth.token, variables)}`;
} else if (auth.type === 'basic' && auth.username && auth.password) { } else if (auth.type === 'basic') {
const token = btoa(`${substituteVariables(auth.username, variables)}:${substituteVariables(auth.password, variables)}`); if (auth.token) {
headers['Authorization'] = `Basic ${token}`; // Basic Auth with token (JWT or other token)
headers['Authorization'] = `Basic ${substituteVariables(auth.token, variables)}`;
} else if (auth.username && auth.password) {
// Basic Auth with username/password
const token = btoa(`${substituteVariables(auth.username, variables)}:${substituteVariables(auth.password, variables)}`);
headers['Authorization'] = `Basic ${token}`;
}
} else if (auth.type === 'apiKey' && auth.key && auth.value) { } else if (auth.type === 'apiKey' && auth.key && auth.value) {
if (auth.in === 'header') { if (auth.in === 'header') {
headers[substituteVariables(auth.key, variables)] = substituteVariables(auth.value, variables); headers[substituteVariables(auth.key, variables)] = substituteVariables(auth.value, variables);
@ -624,26 +720,45 @@ const executeCurrentStep = async () => {
if (currentNode.value?.type === 'api') { if (currentNode.value?.type === 'api') {
console.log(`[Workflow] Executing API node: ${currentNode.value.data?.label || currentNode.value.label}`); console.log(`[Workflow] Executing API node: ${currentNode.value.data?.label || currentNode.value.label}`);
const nodeData = currentNode.value.data || {}; const nodeData = currentNode.value.data || {};
// Use new structure if present
if (nodeData.body || nodeData.headers || nodeData.params || nodeData.authorization) { // Use new structure if present (check for any new structure properties)
const { url, headers, body } = buildApiRequest(nodeData, processVariables.value); if (nodeData.body !== undefined || nodeData.headers !== undefined || nodeData.params !== undefined || nodeData.authorization !== undefined) {
const apiMethod = nodeData.apiMethod || 'GET';
const outputVariable = nodeData.outputVariable || 'apiResponse'; const outputVariable = nodeData.outputVariable || 'apiResponse';
const errorVariable = nodeData.errorVariable || 'apiError'; const errorVariable = nodeData.errorVariable || 'apiError';
const continueOnError = nodeData.continueOnError || false; const continueOnError = nodeData.continueOnError || false;
try { try {
const response = await $fetch(url, { // Use proxy endpoint to avoid CORS issues
method: apiMethod, const response = await $fetch('/api/process/workflow-api-call', {
headers, method: 'POST',
body: ['GET', 'HEAD'].includes(apiMethod) ? undefined : body, body: {
nodeData,
processVariables: processVariables.value
}
}); });
processVariables.value[outputVariable] = response;
processVariables.value[errorVariable] = null; if (response.success) {
console.log('[Workflow] API call success. Output variable set:', outputVariable, response); processVariables.value[outputVariable] = response.data;
if (canAutoProgress(currentNode.value)) { processVariables.value[errorVariable] = null;
moveToNextStep(); console.log('[Workflow] API call success. Output variable set:', outputVariable, response.data);
if (canAutoProgress(currentNode.value)) {
moveToNextStep();
} else {
console.log('[Workflow] API completed, multiple paths available - waiting for user choice');
}
} else { } else {
console.log('[Workflow] API completed, multiple paths available - waiting for user choice'); processVariables.value[errorVariable] = response.error;
console.error('[Workflow] API call failed:', response.error);
if (continueOnError) {
if (canAutoProgress(currentNode.value)) {
moveToNextStep();
} else {
console.log('[Workflow] API failed but continuing, multiple paths available - waiting for user choice');
}
} else {
error.value = 'API call failed: ' + (response.error.message || response.error);
notifyParentOfError(error.value);
}
} }
} catch (err) { } catch (err) {
processVariables.value[errorVariable] = err; processVariables.value[errorVariable] = err;
@ -661,6 +776,7 @@ const executeCurrentStep = async () => {
} }
} else { } else {
// Fallback: old structure // Fallback: old structure
const { const {
apiUrl, apiUrl,
apiMethod = 'GET', apiMethod = 'GET',
@ -914,6 +1030,14 @@ const handleScriptFieldChange = ({ fieldName, value }) => {
formStore.updatePreviewFormData(formData.value); formStore.updatePreviewFormData(formData.value);
}; };
// Handle form data updates from ComponentPreview
const handleFormDataUpdate = (updatedData) => {
// Update the form data with the new data
formData.value = { ...formData.value, ...updatedData };
// Also update form store to keep them in sync
formStore.updatePreviewFormData(formData.value);
};
// Handle conditional logic script generation // Handle conditional logic script generation
const handleConditionalLogicGenerated = (generatedScript) => { const handleConditionalLogicGenerated = (generatedScript) => {
console.log('[WorkflowExecution] Conditional logic script generated'); console.log('[WorkflowExecution] Conditional logic script generated');
@ -1102,7 +1226,38 @@ watch(currentStep, async (newStep) => {
// Update form store with form components and data for ComponentPreview // Update form store with form components and data for ComponentPreview
if (currentForm.value?.formComponents) { if (currentForm.value?.formComponents) {
formStore.formComponents = currentForm.value.formComponents; formStore.formComponents = currentForm.value.formComponents;
formStore.updatePreviewFormData(formData.value);
// Initialize repeating groups in form data
const updatedFormData = { ...formData.value };
currentForm.value.formComponents.forEach(component => {
if (component.type === 'repeating-group' && component.props?.name) {
const groupName = component.props.name;
const minItems = component.props.minItems || 1;
// If the field doesn't exist or is not an array, initialize it
if (!updatedFormData[groupName] || !Array.isArray(updatedFormData[groupName])) {
const initialGroups = [];
for (let i = 0; i < minItems; i++) {
const newGroup = {};
// Add fields from configuration
if (component.props.fields) {
component.props.fields.forEach(field => {
newGroup[field.name] = '';
});
}
initialGroups.push(newGroup);
}
updatedFormData[groupName] = initialGroups;
}
}
});
formData.value = updatedFormData;
formStore.updatePreviewFormData(updatedFormData);
} }
} }
} else if (currentNode.value.type === "page-wrapper") { } else if (currentNode.value.type === "page-wrapper") {
@ -1476,6 +1631,39 @@ function getConditionGroupResult(conditionGroup, variables) {
finalResult: evaluateConditionGroup(conditionGroup, variables) finalResult: evaluateConditionGroup(conditionGroup, variables)
}; };
} }
// Helper: Get submit button variant for RsButton
const getSubmitButtonVariant = () => {
// If form has submit button configuration, use it
if (currentForm.value?.submitButton) {
const { category } = currentForm.value.submitButton;
return category || 'primary';
}
// Fallback to primary
return 'primary';
};
// Helper: Get submit button styles using theme colors
const getWorkflowSubmitButtonStyle = () => {
// If form has submit button configuration, use it
if (currentForm.value?.submitButton) {
const { category = 'primary', color = 'primary' } = currentForm.value.submitButton;
// Use CSS custom properties from theme.css with opacity based on category
const opacity = category === 'primary' ? '1' : '0.8';
const hoverOpacity = category === 'primary' ? '0.9' : '0.7';
return {
backgroundColor: `rgba(var(--color-${color}), ${opacity})`,
'--hover-bg': `rgba(var(--color-${color}), ${hoverOpacity})`,
color: 'white',
border: 'none'
};
}
// Fallback to default styling
return {};
};
</script> </script>
<template> <template>
@ -1648,18 +1836,20 @@ function getConditionGroupResult(conditionGroup, variables) {
:component="component" :component="component"
:is-preview="false" :is-preview="false"
:field-states="fieldStates" :field-states="fieldStates"
@form-data-updated="handleFormDataUpdate"
/> />
</template> </template>
<!-- Place submit button in a full-width row at the end of the grid --> <!-- Submit button - respects form builder configuration -->
<div class="col-span-12 flex justify-start pt-6 border-t border-gray-200"> <div v-if="currentForm?.submitButton?.enabled !== false" class="col-span-12 flex justify-start pt-6 border-t border-gray-200">
<RsButton <RsButton
@click="validateAndSubmit" @click="validateAndSubmit"
:disabled="stepLoading" :disabled="stepLoading"
variant="primary" :variant="getSubmitButtonVariant()"
:style="getWorkflowSubmitButtonStyle()"
class="col-span-12" class="col-span-12"
> >
<Icon v-if="stepLoading" name="material-symbols:progress-activity" class="w-4 h-4 animate-spin mr-2" /> <Icon v-if="stepLoading" name="material-symbols:progress-activity" class="w-4 h-4 animate-spin mr-2" />
{{ stepLoading ? 'Processing...' : 'Submit' }} {{ stepLoading ? 'Processing...' : (currentForm?.submitButton?.label || 'Submit') }}
</RsButton> </RsButton>
</div> </div>
</div> </div>
@ -1972,4 +2162,9 @@ function getConditionGroupResult(conditionGroup, variables) {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms; transition-duration: 300ms;
} }
/* Submit button hover effects using CSS custom properties */
:deep(.rs-button:hover) {
background-color: var(--hover-bg) !important;
}
</style> </style>

View File

@ -1,73 +1,82 @@
{ {
"$schema": "http://json-schema.org/draft-07/schema#", "$schema": "http://json-schema.org/draft-07/schema#",
"definitions": { "definitions": {
"user": { "caseInstance": {
"type": "object", "type": "object",
"properties": { "properties": {
"userID": { "caseID": {
"type": "integer" "type": "integer"
}, },
"userSecretKey": { "caseUUID": {
"type": "string"
},
"caseName": {
"type": "string"
},
"caseStatus": {
"type": "string",
"default": "active"
},
"caseVariables": {
"type": [ "type": [
"number",
"string", "string",
"boolean",
"object",
"array",
"null" "null"
] ]
}, },
"userUsername": { "caseSettings": {
"type": [ "type": [
"number",
"string", "string",
"boolean",
"object",
"array",
"null" "null"
] ]
}, },
"userPassword": { "caseDefinition": {
"type": [ "type": [
"number",
"string", "string",
"boolean",
"object",
"array",
"null" "null"
] ]
}, },
"userFullName": { "caseCreatedDate": {
"type": [ "type": "string",
"string", "format": "date-time"
"null"
]
}, },
"userEmail": { "caseModifiedDate": {
"type": [
"string",
"null"
]
},
"userPhone": {
"type": [
"string",
"null"
]
},
"userStatus": {
"type": [
"string",
"null"
]
},
"userCreatedDate": {
"type": [ "type": [
"string", "string",
"null" "null"
], ],
"format": "date-time" "format": "date-time"
}, },
"userModifiedDate": { "caseCompletedDate": {
"type": [ "type": [
"string", "string",
"null" "null"
], ],
"format": "date-time" "format": "date-time"
}, },
"caseInstance": { "startedBy": {
"type": "array", "anyOf": [
"items": { {
"$ref": "#/definitions/caseInstance" "$ref": "#/definitions/user"
} },
{
"type": "null"
}
]
},
"process": {
"$ref": "#/definitions/process"
}, },
"caseTimeline": { "caseTimeline": {
"type": "array", "type": "array",
@ -75,123 +84,45 @@
"$ref": "#/definitions/caseTimeline" "$ref": "#/definitions/caseTimeline"
} }
}, },
"forms": {
"type": "array",
"items": {
"$ref": "#/definitions/form"
}
},
"formHistoryEntries": {
"type": "array",
"items": {
"$ref": "#/definitions/formHistory"
}
},
"processes": {
"type": "array",
"items": {
"$ref": "#/definitions/process"
}
},
"processHistoryEntries": {
"type": "array",
"items": {
"$ref": "#/definitions/processHistory"
}
},
"task": { "task": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/task" "$ref": "#/definitions/task"
} }
},
"userrole": {
"type": "array",
"items": {
"$ref": "#/definitions/userrole"
}
},
"startedCases": {
"type": "array",
"items": {
"$ref": "#/definitions/caseInstance"
}
},
"assignedTasks": {
"type": "array",
"items": {
"$ref": "#/definitions/task"
}
},
"caseTimelineEntries": {
"type": "array",
"items": {
"$ref": "#/definitions/caseTimeline"
}
} }
} }
}, },
"role": { "caseTimeline": {
"type": "object", "type": "object",
"properties": { "properties": {
"roleID": { "timelineID": {
"type": "integer" "type": "integer"
}, },
"roleName": { "timelineType": {
"type": "string"
},
"timelineDescription": {
"type": [ "type": [
"string", "string",
"null" "null"
] ]
}, },
"roleDescription": { "timelineDate": {
"type": [
"string",
"null"
]
},
"roleStatus": {
"type": [
"string",
"null"
]
},
"roleCreatedDate": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"roleModifiedDate": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"userrole": {
"type": "array",
"items": {
"$ref": "#/definitions/userrole"
}
}
}
},
"userrole": {
"type": "object",
"properties": {
"userRoleID": {
"type": "integer"
},
"userRoleCreatedDate": {
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"
}, },
"role": { "caseInstance": {
"$ref": "#/definitions/role" "$ref": "#/definitions/caseInstance"
}, },
"user": { "user": {
"$ref": "#/definitions/user" "anyOf": [
{
"$ref": "#/definitions/user"
},
{
"type": "null"
}
]
} }
} }
}, },
@ -267,6 +198,38 @@
], ],
"default": "safe" "default": "safe"
}, },
"submitButton": {
"type": [
"number",
"string",
"boolean",
"object",
"array",
"null"
]
},
"formCategory": {
"type": [
"string",
"null"
]
},
"formTags": {
"type": [
"number",
"string",
"boolean",
"object",
"array",
"null"
]
},
"formGroup": {
"type": [
"string",
"null"
]
},
"creator": { "creator": {
"anyOf": [ "anyOf": [
{ {
@ -277,13 +240,13 @@
} }
] ]
}, },
"history": { "formHistory": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/formHistory" "$ref": "#/definitions/formHistory"
} }
}, },
"tasks": { "task": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/task" "$ref": "#/definitions/task"
@ -350,6 +313,16 @@
"null" "null"
] ]
}, },
"submitButton": {
"type": [
"number",
"string",
"boolean",
"object",
"array",
"null"
]
},
"versionNumber": { "versionNumber": {
"type": "integer" "type": "integer"
}, },
@ -366,7 +339,7 @@
"form": { "form": {
"$ref": "#/definitions/form" "$ref": "#/definitions/form"
}, },
"savedByUser": { "user": {
"anyOf": [ "anyOf": [
{ {
"$ref": "#/definitions/user" "$ref": "#/definitions/user"
@ -507,17 +480,11 @@
} }
] ]
}, },
"history": { "processHistory": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/processHistory" "$ref": "#/definitions/processHistory"
} }
},
"cases": {
"type": "array",
"items": {
"$ref": "#/definitions/caseInstance"
}
} }
} }
}, },
@ -625,7 +592,7 @@
"process": { "process": {
"$ref": "#/definitions/process" "$ref": "#/definitions/process"
}, },
"savedByUser": { "user": {
"anyOf": [ "anyOf": [
{ {
"$ref": "#/definitions/user" "$ref": "#/definitions/user"
@ -637,93 +604,48 @@
} }
} }
}, },
"caseInstance": { "role": {
"type": "object", "type": "object",
"properties": { "properties": {
"caseID": { "roleID": {
"type": "integer" "type": "integer"
}, },
"caseUUID": { "roleName": {
"type": "string"
},
"caseName": {
"type": "string"
},
"caseStatus": {
"type": "string",
"default": "active"
},
"caseVariables": {
"type": [ "type": [
"number",
"string", "string",
"boolean",
"object",
"array",
"null" "null"
] ]
}, },
"caseSettings": { "roleDescription": {
"type": [ "type": [
"number",
"string", "string",
"boolean",
"object",
"array",
"null" "null"
] ]
}, },
"caseDefinition": { "roleStatus": {
"type": [ "type": [
"number",
"string", "string",
"boolean",
"object",
"array",
"null" "null"
] ]
}, },
"caseCreatedDate": { "roleCreatedDate": {
"type": "string",
"format": "date-time"
},
"caseModifiedDate": {
"type": [ "type": [
"string", "string",
"null" "null"
], ],
"format": "date-time" "format": "date-time"
}, },
"caseCompletedDate": { "roleModifiedDate": {
"type": [ "type": [
"string", "string",
"null" "null"
], ],
"format": "date-time" "format": "date-time"
}, },
"process": { "userrole": {
"$ref": "#/definitions/process"
},
"startedBy": {
"anyOf": [
{
"$ref": "#/definitions/user"
},
{
"type": "null"
}
]
},
"tasks": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/task" "$ref": "#/definitions/userrole"
}
},
"timeline": {
"type": "array",
"items": {
"$ref": "#/definitions/caseTimeline"
} }
} }
} }
@ -775,10 +697,10 @@
], ],
"format": "date-time" "format": "date-time"
}, },
"case": { "caseInstance": {
"$ref": "#/definitions/caseInstance" "$ref": "#/definitions/caseInstance"
}, },
"assignedTo": { "user": {
"anyOf": [ "anyOf": [
{ {
"$ref": "#/definitions/user" "$ref": "#/definitions/user"
@ -800,51 +722,144 @@
} }
} }
}, },
"caseTimeline": { "user": {
"type": "object", "type": "object",
"properties": { "properties": {
"timelineID": { "userID": {
"type": "integer" "type": "integer"
}, },
"timelineType": { "userSecretKey": {
"type": "string"
},
"timelineDescription": {
"type": [ "type": [
"string", "string",
"null" "null"
] ]
}, },
"timelineDate": { "userUsername": {
"type": [
"string",
"null"
]
},
"userPassword": {
"type": [
"string",
"null"
]
},
"userFullName": {
"type": [
"string",
"null"
]
},
"userEmail": {
"type": [
"string",
"null"
]
},
"userPhone": {
"type": [
"string",
"null"
]
},
"userStatus": {
"type": [
"string",
"null"
]
},
"userCreatedDate": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"userModifiedDate": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"caseInstance": {
"type": "array",
"items": {
"$ref": "#/definitions/caseInstance"
}
},
"caseTimeline": {
"type": "array",
"items": {
"$ref": "#/definitions/caseTimeline"
}
},
"form": {
"type": "array",
"items": {
"$ref": "#/definitions/form"
}
},
"formHistory": {
"type": "array",
"items": {
"$ref": "#/definitions/formHistory"
}
},
"process": {
"type": "array",
"items": {
"$ref": "#/definitions/process"
}
},
"processHistory": {
"type": "array",
"items": {
"$ref": "#/definitions/processHistory"
}
},
"task": {
"type": "array",
"items": {
"$ref": "#/definitions/task"
}
},
"userrole": {
"type": "array",
"items": {
"$ref": "#/definitions/userrole"
}
}
}
},
"userrole": {
"type": "object",
"properties": {
"userRoleID": {
"type": "integer"
},
"userRoleCreatedDate": {
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"
}, },
"case": { "role": {
"$ref": "#/definitions/caseInstance" "$ref": "#/definitions/role"
}, },
"createdBy": { "user": {
"anyOf": [ "$ref": "#/definitions/user"
{
"$ref": "#/definitions/user"
},
{
"type": "null"
}
]
} }
} }
} }
}, },
"type": "object", "type": "object",
"properties": { "properties": {
"user": { "caseInstance": {
"$ref": "#/definitions/user" "$ref": "#/definitions/caseInstance"
}, },
"role": { "caseTimeline": {
"$ref": "#/definitions/role" "$ref": "#/definitions/caseTimeline"
},
"userrole": {
"$ref": "#/definitions/userrole"
}, },
"form": { "form": {
"$ref": "#/definitions/form" "$ref": "#/definitions/form"
@ -858,14 +873,17 @@
"processHistory": { "processHistory": {
"$ref": "#/definitions/processHistory" "$ref": "#/definitions/processHistory"
}, },
"caseInstance": { "role": {
"$ref": "#/definitions/caseInstance" "$ref": "#/definitions/role"
}, },
"task": { "task": {
"$ref": "#/definitions/task" "$ref": "#/definitions/task"
}, },
"caseTimeline": { "user": {
"$ref": "#/definitions/caseTimeline" "$ref": "#/definitions/user"
},
"userrole": {
"$ref": "#/definitions/userrole"
} }
} }
} }

View File

@ -0,0 +1,9 @@
-- Add form grouping fields
ALTER TABLE `form` ADD COLUMN `formCategory` VARCHAR(100) NULL;
ALTER TABLE `form` ADD COLUMN `formTags` JSON NULL;
ALTER TABLE `form` ADD COLUMN `formGroup` VARCHAR(100) NULL;
-- Add indexes for better performance
CREATE INDEX `IDX_form_category` ON `form`(`formCategory`);
CREATE INDEX `IDX_form_group` ON `form`(`formGroup`);
CREATE INDEX `IDX_form_status` ON `form`(`formStatus`);

View File

@ -12,50 +12,42 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
model user { model caseInstance {
userID Int @id @default(autoincrement()) caseID Int @id @default(autoincrement())
userSecretKey String? @db.VarChar(255) caseUUID String @unique @db.VarChar(36)
userUsername String? @db.VarChar(255) processID Int
userPassword String? @db.VarChar(255) caseName String @db.VarChar(255)
userFullName String? @db.VarChar(255) caseStatus String @default("active") @db.VarChar(50)
userEmail String? @db.VarChar(255) caseStartedBy Int?
userPhone String? @db.VarChar(255) caseVariables Json?
userStatus String? @db.VarChar(255) caseSettings Json?
userCreatedDate DateTime? @db.DateTime(0) caseDefinition Json?
userModifiedDate DateTime? @db.DateTime(0) caseCreatedDate DateTime @default(now()) @db.DateTime(0)
caseInstance caseInstance[] caseModifiedDate DateTime? @db.DateTime(0)
caseTimeline caseTimeline[] caseCompletedDate DateTime? @db.DateTime(0)
forms form[] @relation("FormCreator") startedBy user? @relation(fields: [caseStartedBy], references: [userID])
formHistoryEntries formHistory[] process process @relation(fields: [processID], references: [processID])
processes process[] @relation("ProcessCreator") caseTimeline caseTimeline[]
processHistoryEntries processHistory[] task task[]
task task[]
userrole userrole[] @@index([processID], map: "FK_case_process")
startedCases caseInstance[] @relation("CaseStartedBy") @@index([caseStartedBy], map: "FK_case_startedBy")
assignedTasks task[] @relation("TaskAssignedTo") @@index([caseStatus], map: "IDX_case_status")
caseTimelineEntries caseTimeline[]
} }
model role { model caseTimeline {
roleID Int @id @default(autoincrement()) timelineID Int @id @default(autoincrement())
roleName String? @db.VarChar(255) caseID Int
roleDescription String? @db.VarChar(255) timelineType String @db.VarChar(50)
roleStatus String? @db.VarChar(255) timelineDescription String? @db.Text
roleCreatedDate DateTime? @db.DateTime(0) timelineDate DateTime @default(now()) @db.DateTime(0)
roleModifiedDate DateTime? @db.DateTime(0) timelineCreatedBy Int?
userrole userrole[] caseInstance caseInstance @relation(fields: [caseID], references: [caseID])
} user user? @relation(fields: [timelineCreatedBy], references: [userID])
model userrole { @@index([caseID], map: "FK_caseTimeline_case")
userRoleID Int @id @default(autoincrement()) @@index([timelineCreatedBy], map: "FK_caseTimeline_createdBy")
userRoleUserID Int @default(0) @@index([timelineDate], map: "IDX_caseTimeline_date")
userRoleRoleID Int @default(0)
userRoleCreatedDate DateTime @db.DateTime(0)
role role @relation(fields: [userRoleRoleID], references: [roleID], onDelete: NoAction, onUpdate: NoAction, map: "FK_userrole_role")
user user @relation(fields: [userRoleUserID], references: [userID], onDelete: NoAction, onUpdate: NoAction, map: "FK_userrole_user")
@@index([userRoleRoleID], map: "FK_userrole_role")
@@index([userRoleUserID], map: "FK_userrole_user")
} }
model form { model form {
@ -67,16 +59,23 @@ model form {
formStatus String @default("active") @db.VarChar(50) formStatus String @default("active") @db.VarChar(50)
formCreatedBy Int? formCreatedBy Int?
formCreatedDate DateTime @default(now()) @db.DateTime(0) formCreatedDate DateTime @default(now()) @db.DateTime(0)
formModifiedDate DateTime? @updatedAt @db.DateTime(0) formModifiedDate DateTime? @db.DateTime(0)
customCSS String? @db.Text customCSS String? @db.Text
customScript String? @db.LongText customScript String? @db.LongText
formEvents Json? formEvents Json?
scriptMode String? @default("safe") @db.VarChar(20) scriptMode String? @default("safe") @db.VarChar(20)
creator user? @relation("FormCreator", fields: [formCreatedBy], references: [userID]) submitButton Json?
history formHistory[] @relation("FormHistoryEntries") formCategory String? @db.VarChar(100)
tasks task[] formTags Json?
formGroup String? @db.VarChar(100)
creator user? @relation(fields: [formCreatedBy], references: [userID])
formHistory formHistory[]
task task[]
@@index([formCreatedBy], map: "FK_form_creator") @@index([formCreatedBy], map: "FK_form_creator")
@@index([formCategory], map: "IDX_form_category")
@@index([formGroup], map: "IDX_form_group")
@@index([formStatus], map: "IDX_form_status")
} }
model formHistory { model formHistory {
@ -91,17 +90,18 @@ model formHistory {
customScript String? @db.LongText customScript String? @db.LongText
formEvents Json? formEvents Json?
scriptMode String? @db.VarChar(20) scriptMode String? @db.VarChar(20)
submitButton Json?
versionNumber Int versionNumber Int
changeDescription String? @db.Text changeDescription String? @db.Text
savedBy Int? savedBy Int?
savedDate DateTime @default(now()) @db.DateTime(0) savedDate DateTime @default(now()) @db.DateTime(0)
form form @relation("FormHistoryEntries", fields: [formID], references: [formID], onDelete: Cascade) form form @relation(fields: [formID], references: [formID], onDelete: Cascade)
savedByUser user? @relation(fields: [savedBy], references: [userID]) user user? @relation(fields: [savedBy], references: [userID])
@@index([formID], map: "FK_formHistory_form") @@index([formID], map: "FK_formHistory_form")
@@index([savedBy], map: "FK_formHistory_savedBy") @@index([savedBy], map: "FK_formHistory_savedBy")
@@index([formUUID], map: "IDX_formHistory_uuid")
@@index([savedDate], map: "IDX_formHistory_date") @@index([savedDate], map: "IDX_formHistory_date")
@@index([formUUID], map: "IDX_formHistory_uuid")
} }
model process { model process {
@ -114,7 +114,7 @@ model process {
processStatus String @default("draft") @db.VarChar(50) processStatus String @default("draft") @db.VarChar(50)
processCreatedBy Int? processCreatedBy Int?
processCreatedDate DateTime @default(now()) @db.DateTime(0) processCreatedDate DateTime @default(now()) @db.DateTime(0)
processModifiedDate DateTime? @updatedAt @db.DateTime(0) processModifiedDate DateTime? @db.DateTime(0)
isTemplate Boolean @default(false) isTemplate Boolean @default(false)
processCategory String? @db.VarChar(100) processCategory String? @db.VarChar(100)
processOwner String? @db.VarChar(255) processOwner String? @db.VarChar(255)
@ -125,13 +125,12 @@ model process {
templateCategory String? @db.VarChar(100) templateCategory String? @db.VarChar(100)
processDeletedDate DateTime? @db.DateTime(0) processDeletedDate DateTime? @db.DateTime(0)
caseInstance caseInstance[] caseInstance caseInstance[]
creator user? @relation("ProcessCreator", fields: [processCreatedBy], references: [userID]) creator user? @relation(fields: [processCreatedBy], references: [userID])
history processHistory[] @relation("ProcessHistoryEntries") processHistory processHistory[]
cases caseInstance[]
@@index([processCreatedBy], map: "FK_process_creator") @@index([processCreatedBy], map: "FK_process_creator")
@@index([processStatus], map: "IDX_process_status")
@@index([processCategory], map: "IDX_process_category") @@index([processCategory], map: "IDX_process_category")
@@index([processStatus], map: "IDX_process_status")
@@index([isTemplate], map: "IDX_process_template") @@index([isTemplate], map: "IDX_process_template")
} }
@ -155,72 +154,77 @@ model processHistory {
changeDescription String? @db.Text changeDescription String? @db.Text
savedBy Int? savedBy Int?
savedDate DateTime @default(now()) @db.DateTime(0) savedDate DateTime @default(now()) @db.DateTime(0)
process process @relation("ProcessHistoryEntries", fields: [processID], references: [processID], onDelete: Cascade) process process @relation(fields: [processID], references: [processID], onDelete: Cascade)
savedByUser user? @relation(fields: [savedBy], references: [userID]) user user? @relation(fields: [savedBy], references: [userID])
@@index([processID], map: "FK_processHistory_process") @@index([processID], map: "FK_processHistory_process")
@@index([savedBy], map: "FK_processHistory_savedBy") @@index([savedBy], map: "FK_processHistory_savedBy")
@@index([processUUID], map: "IDX_processHistory_uuid")
@@index([savedDate], map: "IDX_processHistory_date") @@index([savedDate], map: "IDX_processHistory_date")
@@index([processUUID], map: "IDX_processHistory_uuid")
} }
model caseInstance { model role {
caseID Int @id @default(autoincrement()) roleID Int @id @default(autoincrement())
caseUUID String @unique @db.VarChar(36) roleName String? @db.VarChar(255)
processID Int roleDescription String? @db.VarChar(255)
caseName String @db.VarChar(255) roleStatus String? @db.VarChar(255)
caseStatus String @default("active") @db.VarChar(50) roleCreatedDate DateTime? @db.DateTime(0)
caseStartedBy Int? roleModifiedDate DateTime? @db.DateTime(0)
caseVariables Json? userrole userrole[]
caseSettings Json?
caseDefinition Json?
caseCreatedDate DateTime @default(now()) @db.DateTime(0)
caseModifiedDate DateTime? @updatedAt @db.DateTime(0)
caseCompletedDate DateTime? @db.DateTime(0)
process process @relation(fields: [processID], references: [processID])
startedBy user? @relation("CaseStartedBy", fields: [caseStartedBy], references: [userID])
tasks task[]
timeline caseTimeline[]
@@index([processID], map: "FK_case_process")
@@index([caseStartedBy], map: "FK_case_startedBy")
@@index([caseStatus], map: "IDX_case_status")
} }
model task { model task {
taskID Int @id @default(autoincrement()) taskID Int @id @default(autoincrement())
taskUUID String @unique @db.VarChar(36) taskUUID String @unique @db.VarChar(36)
caseID Int caseID Int
taskName String @db.VarChar(255) taskName String @db.VarChar(255)
taskType String @db.VarChar(50) taskType String @db.VarChar(50)
taskStatus String @default("pending") @db.VarChar(50) taskStatus String @default("pending") @db.VarChar(50)
taskAssignedTo Int? taskAssignedTo Int?
taskFormID Int? taskFormID Int?
taskData Json? taskData Json?
taskCreatedDate DateTime @default(now()) @db.DateTime(0) taskCreatedDate DateTime @default(now()) @db.DateTime(0)
taskModifiedDate DateTime? @updatedAt @db.DateTime(0) taskModifiedDate DateTime? @db.DateTime(0)
taskCompletedDate DateTime? @db.DateTime(0) taskCompletedDate DateTime? @db.DateTime(0)
case caseInstance @relation(fields: [caseID], references: [caseID]) caseInstance caseInstance @relation(fields: [caseID], references: [caseID])
assignedTo user? @relation("TaskAssignedTo", fields: [taskAssignedTo], references: [userID]) user user? @relation(fields: [taskAssignedTo], references: [userID])
form form? @relation(fields: [taskFormID], references: [formID]) form form? @relation(fields: [taskFormID], references: [formID])
@@index([caseID], map: "FK_task_case")
@@index([taskAssignedTo], map: "FK_task_assignedTo") @@index([taskAssignedTo], map: "FK_task_assignedTo")
@@index([caseID], map: "FK_task_case")
@@index([taskFormID], map: "FK_task_form") @@index([taskFormID], map: "FK_task_form")
@@index([taskStatus], map: "IDX_task_status") @@index([taskStatus], map: "IDX_task_status")
} }
model caseTimeline { model user {
timelineID Int @id @default(autoincrement()) userID Int @id @default(autoincrement())
caseID Int userSecretKey String? @db.VarChar(255)
timelineType String @db.VarChar(50) userUsername String? @db.VarChar(255)
timelineDescription String? @db.Text userPassword String? @db.VarChar(255)
timelineDate DateTime @default(now()) @db.DateTime(0) userFullName String? @db.VarChar(255)
timelineCreatedBy Int? userEmail String? @db.VarChar(255)
case caseInstance @relation(fields: [caseID], references: [caseID]) userPhone String? @db.VarChar(255)
createdBy user? @relation(fields: [timelineCreatedBy], references: [userID]) userStatus String? @db.VarChar(255)
userCreatedDate DateTime? @db.DateTime(0)
@@index([caseID], map: "FK_caseTimeline_case") userModifiedDate DateTime? @db.DateTime(0)
@@index([timelineCreatedBy], map: "FK_caseTimeline_createdBy") caseInstance caseInstance[]
@@index([timelineDate], map: "IDX_caseTimeline_date") caseTimeline caseTimeline[]
form form[]
formHistory formHistory[]
process process[]
processHistory processHistory[]
task task[]
userrole userrole[]
}
model userrole {
userRoleID Int @id @default(autoincrement())
userRoleUserID Int @default(0)
userRoleRoleID Int @default(0)
userRoleCreatedDate DateTime @db.DateTime(0)
role role @relation(fields: [userRoleRoleID], references: [roleID], onDelete: NoAction, onUpdate: NoAction, map: "FK_userrole_role")
user user @relation(fields: [userRoleUserID], references: [userID], onDelete: NoAction, onUpdate: NoAction, map: "FK_userrole_user")
@@index([userRoleRoleID], map: "FK_userrole_role")
@@index([userRoleUserID], map: "FK_userrole_user")
} }

View File

@ -58,9 +58,10 @@ export default defineEventHandler(async (event) => {
customScript: currentForm.customScript, customScript: currentForm.customScript,
formEvents: currentForm.formEvents, formEvents: currentForm.formEvents,
scriptMode: currentForm.scriptMode, scriptMode: currentForm.scriptMode,
submitButton: currentForm.submitButton,
versionNumber: nextVersionNumber, versionNumber: nextVersionNumber,
changeDescription: body.changeDescription || null, changeDescription: body.changeDescription || null,
savedBy: body.savedBy || currentForm.formCreatedBy, savedBy: event.context.user?.userID || currentForm.formCreatedBy,
savedDate: currentForm.formModifiedDate || currentForm.formCreatedDate savedDate: currentForm.formModifiedDate || currentForm.formCreatedDate
} }
}); });
@ -104,6 +105,22 @@ export default defineEventHandler(async (event) => {
if (body.scriptMode !== undefined) { if (body.scriptMode !== undefined) {
updateData.scriptMode = body.scriptMode; updateData.scriptMode = body.scriptMode;
} }
if (body.submitButton !== undefined) {
updateData.submitButton = body.submitButton;
}
if (body.formCategory !== undefined) {
updateData.formCategory = body.formCategory && body.formCategory.trim() ? body.formCategory.trim() : null;
}
if (body.formTags !== undefined) {
updateData.formTags = body.formTags;
}
if (body.formGroup !== undefined) {
updateData.formGroup = body.formGroup && body.formGroup.trim() ? body.formGroup.trim() : null;
}
// Try to update by UUID first // Try to update by UUID first
let form; let form;

View File

@ -25,11 +25,15 @@ export default defineEventHandler(async (event) => {
formDescription: body.formDescription || null, formDescription: body.formDescription || null,
formComponents: body.components || [], formComponents: body.components || [],
formStatus: body.status || 'active', formStatus: body.status || 'active',
formCreatedBy: body.createdBy || null, // In a real app, this would come from the authenticated user
customScript: body.customScript || null, customScript: body.customScript || null,
customCSS: body.customCSS || null, customCSS: body.customCSS || null,
formEvents: body.formEvents || null, formEvents: body.formEvents || null,
scriptMode: body.scriptMode || 'safe' scriptMode: body.scriptMode || 'safe',
submitButton: body.submitButton || null,
formCategory: body.formCategory && body.formCategory.trim() ? body.formCategory.trim() : null,
formTags: body.formTags || null,
formGroup: body.formGroup && body.formGroup.trim() ? body.formGroup.trim() : null,
formCreatedBy: event.context.user?.userID || undefined,
} }
}); });

View File

@ -21,6 +21,9 @@ export default defineEventHandler(async (event) => {
formStatus: true, formStatus: true,
formCreatedDate: true, formCreatedDate: true,
formModifiedDate: true, formModifiedDate: true,
formCategory: true,
formGroup: true,
formTags: true,
// Don't include the full components data to keep response size small // Don't include the full components data to keep response size small
creator: { creator: {
select: { select: {

View File

@ -0,0 +1,84 @@
import { PrismaClient } from '@prisma/client';
// Initialize Prisma client
const prisma = new PrismaClient();
export default defineEventHandler(async (event) => {
try {
// Get the process ID from the route parameter
const processId = getRouterParam(event, 'id');
if (!processId) {
return {
success: false,
error: 'Process ID is required'
};
}
// Check if the ID is a UUID or numeric ID
const isUUID = processId.length === 36 && processId.includes('-');
// First, get the current process to validate it can be unpublished
const currentProcess = await prisma.process.findFirst({
where: isUUID
? { processUUID: processId }
: { processID: parseInt(processId) }
});
if (!currentProcess) {
return {
success: false,
error: 'Process not found'
};
}
// Check if the process is currently published
if (currentProcess.processStatus !== 'published') {
return {
success: false,
error: 'Process is not currently published'
};
}
// Update the process status to draft
const unpublishedProcess = await prisma.process.update({
where: isUUID
? { processUUID: processId }
: { processID: parseInt(processId) },
data: {
processStatus: 'draft'
},
include: {
creator: {
select: {
userID: true,
userFullName: true,
userUsername: true
}
}
}
});
return {
success: true,
message: 'Process unpublished successfully',
process: unpublishedProcess
};
} catch (error) {
console.error('Error unpublishing process:', error);
// Handle specific Prisma errors
if (error.code === 'P2025') {
return {
success: false,
error: 'Process not found'
};
}
return {
success: false,
error: 'Failed to unpublish process',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
};
}
});

View File

@ -53,6 +53,10 @@ export default defineEventHandler(async (event) => {
// Calculate pagination // Calculate pagination
const skip = (parseInt(page) - 1) * parseInt(limit); const skip = (parseInt(page) - 1) * parseInt(limit);
const take = parseInt(limit); const take = parseInt(limit);
// If limit is very high (like 1000), return all processes without pagination
// This allows the frontend to handle client-side pagination
const shouldReturnAll = parseInt(limit) >= 1000;
// Build orderBy clause // Build orderBy clause
const orderBy = {}; const orderBy = {};
@ -63,8 +67,8 @@ export default defineEventHandler(async (event) => {
prisma.process.findMany({ prisma.process.findMany({
where, where,
orderBy, orderBy,
skip, skip: shouldReturnAll ? 0 : skip,
take, take: shouldReturnAll ? undefined : take,
select: { select: {
processID: true, processID: true,
processUUID: true, processUUID: true,
@ -93,9 +97,9 @@ export default defineEventHandler(async (event) => {
]); ]);
// Calculate pagination info // Calculate pagination info
const totalPages = Math.ceil(totalCount / take); const totalPages = shouldReturnAll ? 1 : Math.ceil(totalCount / take);
const hasNextPage = parseInt(page) < totalPages; const hasNextPage = shouldReturnAll ? false : parseInt(page) < totalPages;
const hasPrevPage = parseInt(page) > 1; const hasPrevPage = shouldReturnAll ? false : parseInt(page) > 1;
return { return {
success: true, success: true,

View File

@ -0,0 +1,198 @@
/**
* Workflow API Call Proxy Endpoint
*
* This endpoint acts as a proxy for API calls made during workflow execution.
* It handles the new API node structure with proper authorization and avoids CORS issues.
*/
// Helper function to substitute variables in a string
function substituteVariables(str, variables) {
if (typeof str !== 'string') return str;
// Replace {{variable}} first
str = str.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (match, varName) => {
const value = variables[varName];
if (value === undefined || value === null) return '';
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
});
// Then replace {variable}
str = str.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, varName) => {
const value = variables[varName];
if (value === undefined || value === null) return '';
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
});
return str;
}
// Build API request from node data
function buildApiRequest(nodeData, variables) {
// 1. URL (with param substitution)
let url = substituteVariables(nodeData.apiUrl, variables);
// 2. Params (for GET, DELETE, etc.)
let params = Array.isArray(nodeData.params) ? nodeData.params : [];
if (params.length) {
const query = params
.filter(p => p.key)
.map(p => `${encodeURIComponent(substituteVariables(p.key, variables))}=${encodeURIComponent(substituteVariables(p.value, variables))}`)
.join('&');
if (query) {
url += (url.includes('?') ? '&' : '?') + query;
}
}
// 3. Headers
let headers = {};
if (Array.isArray(nodeData.headers)) {
nodeData.headers.forEach(h => {
if (h.key) headers[substituteVariables(h.key, variables)] = substituteVariables(h.value, variables);
});
} else if (typeof nodeData.headers === 'object') {
headers = { ...nodeData.headers };
}
// 4. Authorization
if (nodeData.authorization && nodeData.authorization.type && nodeData.authorization.type !== 'none') {
const auth = nodeData.authorization;
if (auth.type === 'bearer' && auth.token) {
headers['Authorization'] = `Bearer ${substituteVariables(auth.token, variables)}`;
} else if (auth.type === 'basic') {
if (auth.token) {
// Basic Auth with token (JWT or other token)
headers['Authorization'] = `Basic ${substituteVariables(auth.token, variables)}`;
} else if (auth.username && auth.password) {
// Basic Auth with username/password
const token = Buffer.from(`${substituteVariables(auth.username, variables)}:${substituteVariables(auth.password, variables)}`).toString('base64');
headers['Authorization'] = `Basic ${token}`;
}
} else if (auth.type === 'apiKey' && auth.key && auth.value) {
if (auth.in === 'header') {
headers[substituteVariables(auth.key, variables)] = substituteVariables(auth.value, variables);
} else if (auth.in === 'query') {
url += (url.includes('?') ? '&' : '?') + `${encodeURIComponent(substituteVariables(auth.key, variables))}=${encodeURIComponent(substituteVariables(auth.value, variables))}`;
}
}
}
// 5. Body
let body;
if (nodeData.body && nodeData.body.type && nodeData.body.type !== 'none') {
if (['form-data', 'x-www-form-urlencoded'].includes(nodeData.body.type)) {
const dataArr = Array.isArray(nodeData.body.data) ? nodeData.body.data : [];
if (nodeData.body.type === 'form-data') {
// For server-side, we'll use URLSearchParams for form-data
const formData = new URLSearchParams();
dataArr.forEach(item => {
if (item.key) formData.append(substituteVariables(item.key, variables), substituteVariables(item.value, variables));
});
body = formData.toString();
headers['Content-Type'] = 'application/x-www-form-urlencoded';
} else {
// x-www-form-urlencoded
body = dataArr
.filter(item => item.key)
.map(item => `${encodeURIComponent(substituteVariables(item.key, variables))}=${encodeURIComponent(substituteVariables(item.value, variables))}`)
.join('&');
headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
} else if (nodeData.body.type === 'raw') {
body = substituteVariables(nodeData.body.data, variables);
// Try to detect JSON
if (body && body.trim().startsWith('{')) {
headers['Content-Type'] = 'application/json';
}
}
}
return { url, headers, body };
}
export default defineEventHandler(async (event) => {
try {
// Get request body
const body = await readBody(event);
// Extract node configuration and process variables
const { nodeData, processVariables } = body;
// Validate input
if (!nodeData || !nodeData.apiUrl) {
return {
success: false,
error: {
message: 'Invalid API node configuration. Missing apiUrl.'
}
};
}
// Build the API request
const { url, headers, body: requestBody } = buildApiRequest(nodeData, processVariables);
const apiMethod = nodeData.apiMethod || 'GET';
const outputVariable = nodeData.outputVariable || 'apiResponse';
const errorVariable = nodeData.errorVariable || 'apiError';
const continueOnError = nodeData.continueOnError || false;
// Prepare fetch options
const fetchOptions = {
method: apiMethod,
headers
};
// Add body for non-GET requests
if (!['GET', 'HEAD'].includes(apiMethod) && requestBody) {
fetchOptions.body = requestBody;
}
// Make the API call
const response = await fetch(url, fetchOptions);
// Get response data
let responseData;
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
responseData = await response.json();
} else {
responseData = await response.text();
}
// Prepare result
const result = {
success: response.ok,
data: responseData,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries([...response.headers.entries()])
};
if (!response.ok) {
result.error = {
message: `API call failed with status ${response.status}`,
status: response.status,
statusText: response.statusText,
data: responseData
};
}
return result;
} catch (error) {
return {
success: false,
error: {
message: error.message || 'An error occurred while making the API call',
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
}
};
}
});

View File

@ -26,11 +26,24 @@ export const useFormBuilderStore = defineStore('formBuilder', {
}, },
scriptMode: 'safe', // 'safe' or 'advanced' scriptMode: 'safe', // 'safe' or 'advanced'
// Submit button configuration
submitButton: {
enabled: true,
label: 'Submit',
category: 'primary', // primary, secondary
color: 'primary' // primary, secondary, success, info, warning, danger
},
// Form preview data // Form preview data
previewFormData: {}, previewFormData: {},
// Form history tracking // Form history tracking
lastChangeDescription: null, lastChangeDescription: null,
// Form grouping
formCategory: '',
formTags: [],
formGroup: '',
}), }),
getters: { getters: {
@ -364,6 +377,30 @@ export const useFormBuilderStore = defineStore('formBuilder', {
}; };
this.formComponents.push(newComponent); this.formComponents.push(newComponent);
// Initialize preview form data for repeating groups
if (newComponent.type === 'repeating-group' && newComponent.props?.name) {
const groupName = newComponent.props.name;
const minItems = newComponent.props.minItems || 1;
const initialGroups = [];
for (let i = 0; i < minItems; i++) {
const newGroup = {};
// Add fields from configuration
if (newComponent.props.fields) {
newComponent.props.fields.forEach(field => {
newGroup[field.name] = '';
});
}
initialGroups.push(newGroup);
}
const updatedFormData = { ...this.previewFormData, [groupName]: initialGroups };
this.updatePreviewFormData(updatedFormData);
}
// Explicitly select the new component // Explicitly select the new component
this.selectedComponentId = newComponentId; this.selectedComponentId = newComponentId;
this.hasUnsavedChanges = true; this.hasUnsavedChanges = true;
@ -638,15 +675,18 @@ export const useFormBuilderStore = defineStore('formBuilder', {
// Fetch forms from the API // Fetch forms from the API
const forms = await this.getForms(); const forms = await this.getForms();
// Transform to the format expected by the UI // Transform to the format expected by the UI
this.savedForms = forms.map(form => ({ this.savedForms = forms.map(form => ({
id: form.formUUID, id: form.formUUID,
name: form.formName, name: form.formName,
description: form.formDescription || '', description: form.formDescription || '',
components: form.formComponents || [], components: form.formComponents || [],
createdAt: form.formCreatedDate, category: form.formCategory || '',
updatedAt: form.formModifiedDate group: form.formGroup || '',
})); tags: form.formTags || [],
createdAt: form.formCreatedDate,
updatedAt: form.formModifiedDate
}));
return this.savedForms; return this.savedForms;
} catch (error) { } catch (error) {
@ -668,11 +708,15 @@ export const useFormBuilderStore = defineStore('formBuilder', {
})), })),
customScript: this.formCustomScript, customScript: this.formCustomScript,
customCSS: this.formCustomCSS, customCSS: this.formCustomCSS,
formEvents: this.formEvents, formEvents: this.formEvents,
scriptMode: this.scriptMode, scriptMode: this.scriptMode,
// Add user info and change description for history tracking submitButton: this.submitButton,
savedBy: 1, // TODO: Get from authenticated user formCategory: this.formCategory,
changeDescription: this.lastChangeDescription || null formTags: this.formTags,
formGroup: this.formGroup,
// Add change description for history tracking
changeDescription: this.lastChangeDescription || null,
// Note: savedBy will be handled by the server using the authenticated user from auth middleware
}; };
// Determine if this is a new form or an update // Determine if this is a new form or an update
@ -746,10 +790,15 @@ export const useFormBuilderStore = defineStore('formBuilder', {
this.formComponents = []; this.formComponents = [];
this.selectedComponentId = null; this.selectedComponentId = null;
// Set form data // Set form data
this.formName = result.form.formName; this.formName = result.form.formName;
this.formDescription = result.form.formDescription || ''; this.formDescription = result.form.formDescription || '';
this.currentFormId = result.form.formUUID; this.currentFormId = result.form.formUUID;
// Load grouping data
this.formCategory = result.form.formCategory || '';
this.formTags = result.form.formTags || [];
this.formGroup = result.form.formGroup || '';
// Load custom scripts and settings // Load custom scripts and settings
this.formCustomScript = result.form.customScript || ''; this.formCustomScript = result.form.customScript || '';
@ -761,6 +810,12 @@ export const useFormBuilderStore = defineStore('formBuilder', {
onValidation: false onValidation: false
}; };
this.scriptMode = result.form.scriptMode || 'safe'; this.scriptMode = result.form.scriptMode || 'safe';
this.submitButton = result.form.submitButton || {
enabled: true,
label: 'Submit',
category: 'primary',
color: 'primary'
};
// Transform components from DB format to store format // Transform components from DB format to store format
if (Array.isArray(result.form.formComponents)) { if (Array.isArray(result.form.formComponents)) {
@ -770,6 +825,33 @@ export const useFormBuilderStore = defineStore('formBuilder', {
})); }));
} }
// Initialize preview form data with repeating groups
const initialFormData = {};
this.formComponents.forEach(component => {
if (component.type === 'repeating-group' && component.props?.name) {
const groupName = component.props.name;
const minItems = component.props.minItems || 1;
const initialGroups = [];
for (let i = 0; i < minItems; i++) {
const newGroup = {};
// Add fields from configuration
if (component.props.fields) {
component.props.fields.forEach(field => {
newGroup[field.name] = '';
});
}
initialGroups.push(newGroup);
}
initialFormData[groupName] = initialGroups;
}
});
this.updatePreviewFormData(initialFormData);
// Clear and initialize history when loading a form // Clear and initialize history when loading a form
this.actionHistory = []; this.actionHistory = [];
this.currentHistoryIndex = -1; this.currentHistoryIndex = -1;
@ -820,10 +902,15 @@ export const useFormBuilderStore = defineStore('formBuilder', {
// Clear form data // Clear form data
this.formComponents = []; this.formComponents = [];
this.selectedComponentId = null; this.selectedComponentId = null;
this.formName = 'New Form'; this.formName = 'New Form';
this.formDescription = ''; this.formDescription = '';
this.currentFormId = null; this.currentFormId = null;
this.hasUnsavedChanges = false; this.hasUnsavedChanges = false;
// Reset grouping fields
this.formCategory = '';
this.formTags = [];
this.formGroup = '';
// Reset custom scripts and settings // Reset custom scripts and settings
this.formCustomScript = ''; this.formCustomScript = '';
@ -840,6 +927,9 @@ export const useFormBuilderStore = defineStore('formBuilder', {
this.actionHistory = []; this.actionHistory = [];
this.currentHistoryIndex = -1; this.currentHistoryIndex = -1;
// Clear preview form data
this.updatePreviewFormData({});
// Record the initial empty state // Record the initial empty state
this.recordHistory('new_form', { this.recordHistory('new_form', {
message: 'Created a new empty form' message: 'Created a new empty form'

View File

@ -633,6 +633,39 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
} }
}, },
/**
* Unpublish a process (change status from published to draft)
*/
async unpublishProcess(processId) {
try {
const response = await $fetch(`/api/process/${processId}/unpublish`, {
method: 'POST'
});
if (response.success) {
// Update local state if process exists in the array
const process = this.processes.find(p => p.id === processId);
if (process) {
process.status = 'draft';
process.updatedAt = response.process.processModifiedDate;
}
// Update current process if it's the same one
if (this.currentProcess && this.currentProcess.id === processId) {
this.currentProcess.status = 'draft';
this.currentProcess.updatedAt = response.process.processModifiedDate;
}
return true;
} else {
throw new Error(response.error || 'Failed to unpublish process');
}
} catch (error) {
console.error('Error unpublishing process:', error);
throw error;
}
},
/** /**
* Duplicate a process * Duplicate a process
*/ */
@ -1342,7 +1375,9 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
type: variable.type || 'string', type: variable.type || 'string',
scope: variable.scope || 'global', scope: variable.scope || 'global',
value: variable.value, value: variable.value,
description: variable.description || '' description: variable.description || '',
captureFromUrl: variable.captureFromUrl || false,
urlParameterName: variable.urlParameterName || ''
}; };
this.unsavedChanges = true; this.unsavedChanges = true;