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"
:validation="component.props.validation" :validation-visibility="isPreview ? 'live' : 'blur'"
: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"
:mask="component.props.mask || undefined" :digits="component.props.digits || 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',
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 -->
<div v-else-if="component.type === 'heading'" class="py-2">
@ -325,15 +332,70 @@
<!-- Button Component -->
<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 }}
</label>
<RsButton :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">
{{ component.props.label || 'Button' }}
</RsButton>
<!-- Custom Button with Custom Colors -->
<div v-if="component.props.variant === 'custom'" class="inline-block">
<!-- Link Button with Custom Colors -->
<a v-if="component.props.linkType && component.props.linkType !== 'none' && getButtonLink()"
: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">
{{ component.props.help }}
@ -394,7 +456,7 @@
'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">
<!-- Render nested components if they exist -->
<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" />
</template>
</div>
<!-- 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>
<!-- No placeholder in preview mode when empty -->
</div>
<!-- In form builder mode, show drop zone -->
@ -477,14 +529,15 @@
class="section-drop-placeholder"
:class="{
'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)"
@dragleave="handleSectionDragLeave($event, component.id)"
@drop="handleSectionDrop($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" />
<p class="text-sm text-gray-500 text-center mb-1">Drop Components Here</p>
<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
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
@ -609,6 +693,39 @@ watch(() => props.component.props.defaultItems, (newDefaultItems, oldDefaultItem
}
}, { 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
const addGroupItem = () => {
if (props.isPreview) return;
@ -635,6 +752,9 @@ const addGroupItem = () => {
// Update the form data
const updatedData = { ...formStore.previewFormData, [groupName]: currentGroups };
formStore.updatePreviewFormData(updatedData);
// Also emit an event to notify parent components about the data change
emit('form-data-updated', updatedData);
};
const removeGroupItem = (index) => {
@ -646,12 +766,22 @@ const removeGroupItem = (index) => {
// Get current groups
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
currentGroups.splice(index, 1);
// Update the form data
const updatedData = { ...formStore.previewFormData, [groupName]: currentGroups };
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 = () => {
@ -1247,6 +1377,106 @@ const saveNestedComponentSettings = (updatedComponent) => {
// Close the modal
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>
<style scoped>
@ -1271,6 +1501,32 @@ const saveNestedComponentSettings = (updatedComponent) => {
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-container {
display: flex;
@ -1485,10 +1741,55 @@ const saveNestedComponentSettings = (updatedComponent) => {
background-color: #f1f5f9;
}
.section-drop-placeholder.hidden-placeholder {
border: none;
background-color: transparent;
padding: 0;
min-height: 40px;
}
/* Ghost styles for dragging */
.ghost {
opacity: 0.5;
background-color: #dbeafe !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>

View File

@ -598,6 +598,10 @@ const availableComponents = [
variant: 'primary', // primary, secondary, success, warning, danger
size: 'md', // sm, md, lg
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
}
},
@ -623,6 +627,7 @@ const availableComponents = [
spacing: 'normal', // compact, normal, relaxed
width: '100%',
gridColumn: 'span 12',
showPlaceholder: true, // Whether to show the placeholder in builder mode
children: [], // Array to hold nested components
// Conditional Logic Properties
conditionalLogic: {

View File

@ -218,6 +218,19 @@
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
v-if="showField('readonly')"
type="switch"
@ -490,26 +503,347 @@
{ label: 'Secondary (Gray)', value: 'secondary' },
{ label: 'Success (Green)', value: 'success' },
{ 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"
:classes="{ outer: 'field-wrapper' }"
/>
</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
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"
v-if="configModel.showButtonText !== false"
type="text"
label="Button Text"
name="buttonText"
v-model="configModel.buttonText"
help="Text displayed on the button (uses label if empty)"
: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>
<!-- Dynamic List Configuration -->
@ -703,6 +1037,255 @@
</div>
</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 -->
<template v-if="component.type === 'repeating-group'">
<div class="space-y-6">
@ -902,6 +1485,7 @@
/>
<FormKit
v-if="configModel.showHeader"
type="select"
label="Header Size"
name="headerSize"
@ -916,6 +1500,7 @@
/>
<FormKit
v-if="configModel.showHeader"
type="switch"
label="Collapsible"
name="collapsible"
@ -925,13 +1510,22 @@
/>
<FormKit
v-if="configModel.showHeader && configModel.collapsible"
type="switch"
label="Start Collapsed"
name="collapsed"
v-model="configModel.collapsed"
help="Start with this section collapsed"
: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>
@ -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'],
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'],
rows: ['textarea'],
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'],
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)
})
// 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
const compatibilityGroups = {
// 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 = () => {
isOpen.value = false
emit('close')
@ -2431,6 +3207,24 @@ const getDefaultPropsForType = (type) => {
},
paragraph: {
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: 'support', name: 'Support & Service' },
{ id: 'business', name: 'Business Operations' },
{ id: 'automation', name: 'Automation' }
{ id: 'automation', name: 'Automation' },
{ id: 'data-driven', name: 'Data-Driven Workflows' }
];
// 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: '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',
'support': 'material-symbols:support-agent',
'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';

View File

@ -55,16 +55,31 @@
<!-- Data Table -->
<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 class="table-content">
<div class="table-content" ref="tableContainer">
<table class="data-table">
<thead class="table-header-row">
<tr>
<th v-if="config.showRowNumbers" class="row-number-header">#</th>
<th
v-for="column in config.columns"
v-for="column in visibleColumns"
:key="column.name"
class="column-header"
:style="{ width: getColumnWidth(column) }"
>
<div class="column-header-content">
<span class="header-text">{{ column.label }}</span>
@ -77,7 +92,7 @@
<tbody class="table-body">
<tr
v-for="(record, index) in paginatedData"
:key="index"
:key="getRecordKey(record, index)"
class="data-row"
:class="{ 'row-hover': !isPreview }"
>
@ -85,12 +100,17 @@
<div class="row-number">{{ getRowNumber(index) }}</div>
</td>
<td
v-for="column in config.columns"
v-for="column in visibleColumns"
:key="column.name"
class="data-cell"
:style="{ width: getColumnWidth(column) }"
>
<div class="cell-content">
<span class="cell-value">{{ formatCellValue(record[column.name], column) }}</span>
<LazyCellValue
:value="record[column.name]"
:column="column"
:record="record"
/>
</div>
</td>
<td v-if="showActions" class="actions-cell">
@ -124,20 +144,16 @@
<!-- Empty State -->
<div v-else class="empty-state">
<div class="empty-content">
<div class="empty-icon-container">
<Icon name="heroicons:table-cells" class="empty-icon" />
</div>
<div class="empty-text">
<h4 class="empty-title">No records yet</h4>
<p class="empty-description">
{{ searchQuery ? 'No records match your search criteria.' : 'Click "' + (config.buttonText || 'Add Record') + '" to add your first record.' }}
</p>
</div>
<Icon name="heroicons:table-cells" class="empty-icon" />
<h3 class="empty-title">No records yet</h3>
<p class="empty-message">
{{ config.help || 'Add your first record to get started.' }}
</p>
<RsButton
v-if="!searchQuery && !isAddDisabled"
v-if="!isAddDisabled"
@click="openAddModal"
variant="primary"
size="md"
size="sm"
>
<Icon name="heroicons:plus" class="w-4 h-4 mr-2" />
{{ config.buttonText || 'Add Record' }}
@ -147,92 +163,56 @@
</div>
<!-- Pagination -->
<div v-if="filteredData.length > 0 && totalPages > 1" class="pagination-container">
<div v-if="totalPages > 1" class="pagination-container">
<div class="pagination-info">
<span class="pagination-text">
Showing {{ startRecord }} to {{ endRecord }} of {{ filteredData.length }} records
</span>
Showing {{ startRecord }} to {{ endRecord }} of {{ filteredData.length }} records
</div>
<div class="pagination-controls">
<div class="page-size-selector">
<label class="page-size-label">Show:</label>
<select v-model="pageSize" @change="currentPage = 1" class="page-size-select">
<option value="5">5</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
</select>
<span class="page-size-suffix">per page</span>
<RsButton
@click="goToPage(currentPage - 1)"
:disabled="currentPage === 1"
variant="secondary-outline"
size="sm"
>
<Icon name="heroicons:chevron-left" class="w-4 h-4" />
Previous
</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 class="page-navigation">
<RsButton
@click="goToPage(1)"
:disabled="currentPage === 1"
variant="secondary-outline"
size="sm"
title="First page"
>
<Icon name="heroicons:chevron-double-left" class="w-4 h-4" />
</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>
<RsButton
@click="goToPage(currentPage + 1)"
:disabled="currentPage === totalPages"
variant="secondary-outline"
size="sm"
>
Next
<Icon name="heroicons:chevron-right" class="w-4 h-4" />
</RsButton>
</div>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal-container">
<div v-if="showModal" class="modal-overlay" @click="closeModal">
<div class="modal-container" @click.stop>
<div class="modal-header">
<div class="modal-title-section">
<h3 class="modal-title">
{{ editingIndex !== null ? 'Edit Record' : 'Add New Record' }}
</h3>
</div>
<h3 class="modal-title">
{{ editingIndex !== null ? 'Edit Record' : 'Add Record' }}
</h3>
<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>
</div>
@ -280,23 +260,19 @@
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteConfirm" class="modal-overlay" @click.self="cancelDelete">
<div class="modal-container modal-small">
<div v-if="showDeleteConfirm" class="modal-overlay" @click="cancelDelete">
<div class="modal-container delete-modal" @click.stop>
<div class="modal-header">
<div class="modal-title-section">
<h3 class="modal-title">Confirm Delete</h3>
</div>
<h3 class="modal-title">Confirm Delete</h3>
<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>
</div>
<div class="modal-body">
<div class="delete-warning">
<div class="warning-icon-container">
<Icon name="heroicons:exclamation-triangle" class="warning-icon" />
</div>
<div class="warning-content">
<div class="warning-content">
<Icon name="heroicons:exclamation-triangle" class="warning-icon" />
<div class="warning-text">
<p class="warning-title">Are you sure you want to delete this record?</p>
<p class="warning-message">This action cannot be undone.</p>
</div>
@ -320,6 +296,91 @@
<script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
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({
config: {
@ -347,12 +408,28 @@ const editingIndex = ref(null)
const deleteIndex = ref(null)
const formData = ref({})
const formId = ref(`table-form-${uuidv4()}`)
const tableContainer = ref(null)
// Pagination state
const currentPage = ref(1)
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(() => {
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(() => {
return props.config.maxRecords && data.value.length >= props.config.maxRecords
})
@ -429,6 +546,9 @@ const visiblePages = computed(() => {
// Watch for external data changes
watch(() => props.modelValue, (newValue) => {
data.value = [...newValue]
// Clear caches when data changes
columnCache.value.clear()
recordKeys.value.clear()
}, { deep: true })
// Watch for internal data changes and emit
@ -512,29 +632,8 @@ const getOriginalIndex = (record) => {
return data.value.findIndex(item => item === record)
}
const formatCellValue = (value, column) => {
if (value === null || value === undefined || value === '') {
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 getRowNumber = (index) => {
return startRecord.value + index
}
const getFormKitType = (columnType) => {
@ -586,17 +685,115 @@ const goToPage = (page) => {
currentPage.value = page
}
const getRowNumber = (index) => {
return (currentPage.value - 1) * pageSize.value + index + 1
}
// Watch for search changes to reset pagination
watch(searchQuery, () => {
currentPage.value = 1
// Cleanup on unmount
onUnmounted(() => {
columnCache.value.clear()
recordKeys.value.clear()
})
</script>
<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 {
@apply border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm w-full;
min-width: 0;

View File

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

View File

@ -4,7 +4,7 @@
<div class="flex items-center space-x-2">
<div class="flex-1">
<select
:value="modelValue"
:value="baseVariableName"
@change="handleVariableSelect"
:class="[
'form-select w-full',
@ -47,6 +47,63 @@
</RsButton>
</div>
<!-- Object Property Path Input (for object/array variables) -->
<div v-if="selectedVariable && isObjectType(selectedVariable.type) && allowObjectPath" class="mt-2">
<label class="block text-xs font-medium text-gray-600 mb-1">
Object Property Path (optional)
</label>
<div class="flex items-center space-x-2">
<div class="flex-1 relative">
<div class="flex items-center border rounded-md focus-within:ring-2 focus-within:ring-purple-500 focus-within:border-purple-500" :class="propertyPathError ? 'border-red-500' : 'border-gray-300'">
<span class="px-3 py-2 text-sm text-gray-600 bg-gray-50 border-r border-gray-300 rounded-l-md">
{{ baseVariableName }}.
</span>
<input
v-model="propertyPath"
type="text"
placeholder="data.user.name or [0].title"
class="flex-1 px-3 py-2 text-sm border-0 rounded-r-md focus:outline-none focus:ring-0"
@input="handlePropertyPathChange"
@blur="validatePropertyPath"
/>
</div>
</div>
<button
v-if="propertyPath"
@click="clearPropertyPath"
class="p-2 text-gray-400 hover:text-gray-600 transition-colors"
title="Clear property path"
>
<Icon name="material-symbols:close" class="w-4 h-4" />
</button>
</div>
<!-- Property Path Examples -->
<div class="mt-1 text-xs text-gray-500">
<details class="cursor-pointer">
<summary class="hover:text-gray-700">Examples</summary>
<div class="mt-1 space-y-1 pl-2 border-l-2 border-gray-200">
<div><code class="bg-gray-100 px-1">data.user.name</code> - Access nested object property</div>
<div><code class="bg-gray-100 px-1">items[0].title</code> - Access first array item property</div>
<div><code class="bg-gray-100 px-1">results.users[2].email</code> - Complex nested access</div>
<div><code class="bg-gray-100 px-1">response.data.attributes.value</code> - Deep nesting</div>
</div>
</details>
</div>
<!-- Property Path Error -->
<div v-if="propertyPathError" class="mt-1 text-red-600 text-xs flex items-center">
<Icon name="material-symbols:error" class="w-3 h-3 mr-1" />
{{ propertyPathError }}
</div>
<!-- Final Variable Path Preview -->
<div v-if="finalVariablePath && finalVariablePath !== baseVariableName" class="mt-2 p-2 bg-blue-50 border border-blue-200 rounded-md">
<div class="text-xs font-medium text-blue-800 mb-1">Full Variable Path:</div>
<code class="text-sm text-blue-700 bg-blue-100 px-2 py-1 rounded">{{ finalVariablePath }}</code>
</div>
</div>
<!-- Variable Info Display -->
<div v-if="selectedVariable" class="mt-2 p-2 bg-gray-50 rounded-md border text-sm">
<div class="flex items-center justify-between">
@ -224,6 +281,10 @@ const props = defineProps({
required: {
type: Boolean,
default: false
},
allowObjectPath: {
type: Boolean,
default: true // Enable object property path input
}
});
@ -239,14 +300,32 @@ const newVariableDefaultValue = ref('');
const newVariableDescription = ref('');
const nameValidationError = ref('');
// Object property path state
const propertyPath = ref('');
const propertyPathError = ref('');
// Computed properties
const baseVariableName = computed(() => {
// Extract base variable name from modelValue (strip property path)
const value = props.modelValue || '';
const dotIndex = value.indexOf('.');
return dotIndex > -1 ? value.substring(0, dotIndex) : value;
});
const selectedVariable = computed(() => {
return props.availableVariables.find(v => v.name === 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(() => {
if (!props.required && !props.modelValue) return false;
if (props.modelValue && !selectedVariable.value) return true;
if (propertyPathError.value) return true;
return false;
});
@ -255,7 +334,10 @@ const errorMessage = computed(() => {
return 'Variable selection is required';
}
if (props.modelValue && !selectedVariable.value) {
return `Variable "${props.modelValue}" not found`;
return `Variable "${baseVariableName.value}" not found`;
}
if (propertyPathError.value) {
return propertyPathError.value;
}
return '';
});
@ -305,9 +387,74 @@ const canCreateVariable = computed(() => {
// Methods
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 = () => {
showCreateVariable.value = true;
resetCreateForm();

View File

@ -479,6 +479,28 @@
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">
<RsButton type="button" @click="closeModal" variant="tertiary">
Cancel
@ -512,7 +534,9 @@ const variableForm = ref({
type: "string",
scope: "global",
description: "",
defaultValue: ""
defaultValue: "",
captureFromUrl: false,
urlParameterName: ""
});
// Variable type options with descriptions
@ -1498,7 +1522,9 @@ const resetForm = () => {
type: "string",
scope: "global",
description: "",
defaultValue: ""
defaultValue: "",
captureFromUrl: false,
urlParameterName: ""
};
editingVariable.value = null;
};
@ -1532,7 +1558,9 @@ const saveVariable = async (formData) => {
type: formData.type,
scope: "global",
description: formData.description,
value: processedDefaultValue
value: processedDefaultValue,
captureFromUrl: formData.captureFromUrl || false,
urlParameterName: formData.urlParameterName || ""
};
if (editingVariable.value) {

View File

@ -1,7 +1,12 @@
// Utility to safely get a field value from a form data object
export function safeGetField(field, formData) {
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') {
// Only warn in development

View File

@ -398,65 +398,6 @@ onMounted(() => {
</template>
</rs-card>
</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>
</template>

View File

@ -7,7 +7,7 @@
<!-- Left section - Logo and navigation -->
<div class="flex items-center gap-4">
<Icon
@click="navigateTo('/', { external: true })"
@click="navigateTo('/form-builder/manage')"
name="ph:arrow-circle-left-duotone"
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" />
<span>Form Settings</span>
</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" />
<span>Manage Forms</span>
<!-- Help -->
<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>
</div>
</div>
@ -101,7 +106,7 @@
</header>
<!-- Main Content Area -->
<div class="flex-1 flex overflow-hidden">
<div v-if="hasCurrentForm" class="flex-1 flex overflow-hidden">
<!-- Left Panel - Components Sidebar -->
<div
v-if="!isPreview"
@ -295,16 +300,20 @@
</template>
</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
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"
label="Submit"
:label="formStore.submitButton.label || 'Submit'"
class="form-submit mt-6"
:class="{
'mx-4 mb-4': 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>
</div>
@ -504,6 +513,116 @@
:classes="{ outer: 'mb-0', input: 'text-sm' }"
/>
</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>
@ -549,7 +668,63 @@
</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 -->
<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"
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>
</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 -->
<template #javascript>
<div class="p-4">
@ -942,6 +1230,206 @@
@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>
</template>
@ -967,6 +1455,13 @@ const router = useRouter();
const formStore = useFormBuilderStore();
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 {
toast = useToast();
@ -985,6 +1480,7 @@ const isPreview = ref(false);
const showFormSettings = ref(false);
const showTemplatesModal = ref(false);
const showFormHistoryModal = ref(false);
const showHelpModal = ref(false);
const showDropdown = ref(false);
const leftSidebarTab = ref('components');
const showFieldSettingsPanel = ref(true);
@ -995,6 +1491,48 @@ const pendingNavigation = ref(null);
const navigationTarget = ref(null);
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
const selectedDevice = ref('Desktop');
const deviceSizes = ref([
@ -1032,6 +1570,7 @@ const conditionalLogicEngine = ref(null);
// Settings tabs configuration
const settingsTabs = [
{ 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: 'css', label: 'CSS', icon: 'material-symbols:palette-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
const formJson = computed(() => {
return {
@ -1062,7 +1625,8 @@ const formJson = computed(() => {
customScript: formStore.formCustomScript,
customCSS: formStore.formCustomCSS,
formEvents: formStore.formEvents,
scriptMode: formStore.scriptMode
scriptMode: formStore.scriptMode,
submitButton: formStore.submitButton
};
});
@ -1269,6 +1833,30 @@ const applyJsonChanges = () => {
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
formStore.hasUnsavedChanges = true;
@ -1562,6 +2150,9 @@ onMounted(async () => {
console.error('Error loading form from ID:', 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) {
console.error('Error initializing form builder:', error);
@ -2056,6 +2647,54 @@ const navigateToManage = () => {
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 = () => {
formStore.optimizeGridLayout();
};
@ -2495,8 +3134,21 @@ watch(() => formStore.selectedComponent, (newComponent) => {
label: newComponent.props.label || '',
name: newComponent.props.name || '',
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
};
// Fetch published processes if this is a button component
if (newComponent.type === 'button') {
fetchPublishedProcesses();
}
}
}, { 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'],
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'],
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'],
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 = {
label: `Enter ${formStore.selectedComponent.type} label`,
name: `${formStore.selectedComponent.type}_field`,
placeholder: 'Enter placeholder text...'
placeholder: 'Enter placeholder text...',
rows: '3'
};
return placeholders[fieldName] || '';
@ -2634,6 +3295,10 @@ const handleFormRestored = (restoredForm) => {
</script>
<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 {
width: 100%;
max-width: 400px;

View File

@ -47,36 +47,70 @@
<!-- Main Content Area -->
<div class="flex-1 p-6 overflow-auto">
<!-- Filters and Search -->
<div class="mb-6 flex flex-col sm:flex-row gap-4">
<div class="flex-1 max-w-md">
<FormKit
v-model="searchQuery"
type="text"
placeholder="Search forms..."
:classes="{
outer: 'mb-0',
wrapper: 'relative',
inner: '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',
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>
</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>
<div class="mb-6 bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div class="flex flex-col lg:flex-row gap-4">
<div class="flex-1 max-w-md">
<FormKit
v-model="searchQuery"
type="text"
placeholder="Search forms by name or description..."
:classes="{
outer: 'mb-0',
wrapper: 'relative',
inner: 'relative',
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>
</FormKit>
</div>
<RsButton @click="loadForms" variant="tertiary" size="sm" :disabled="loading">
<Icon name="material-symbols:refresh" class="mr-1" />
Refresh
</RsButton>
<div class="min-w-48">
<FormKit
v-model="selectedCategory"
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>
@ -88,112 +122,233 @@
</div>
</div>
<!-- Forms Table -->
<div v-else class="bg-white shadow-md rounded-lg overflow-hidden">
<table class="w-full table-auto">
<thead class="bg-gray-50 border-b">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<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>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Updated</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-if="filteredForms.length === 0">
<td colspan="6" class="px-6 py-12 text-center text-gray-500">
<div class="flex flex-col items-center">
<Icon name="material-symbols:description-outline" class="w-12 h-12 text-gray-300 mb-2" />
<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>
<!-- Forms Grid -->
<div v-else-if="filteredForms.length > 0" class="grid gap-4">
<div
v-for="form in paginatedForms"
:key="form.id"
class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow"
>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="text-lg font-semibold text-gray-900">{{ form.name || 'Untitled Form' }}</h3>
<div class="flex gap-2">
<RsBadge v-if="form.category" :variant="getCategoryColor(form.category)" size="sm">
{{ form.category }}
</RsBadge>
<RsBadge v-if="form.group" variant="secondary" size="sm">
{{ form.group }}
</RsBadge>
</div>
</td>
</tr>
<tr v-for="form in filteredForms" :key="form.id" class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<div class="font-medium text-gray-900">{{ form.name || 'Untitled Form' }}</div>
<div class="text-sm text-gray-500">ID: {{ form.id }}</div>
</td>
<td class="px-6 py-4">
<div class="text-sm text-gray-700 max-w-xs truncate">
{{ form.description || 'No description' }}
</div>
<p class="text-gray-600 mb-3">
{{ form.description || 'No description available' }}
</p>
<div class="flex items-center gap-6 text-sm text-gray-500">
<div class="flex items-center gap-1">
<Icon name="material-symbols:fingerprint" class="w-4 h-4" />
ID: {{ form.id }}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-700">
{{ form.components ? form.components.length : 0 }} components
<div class="flex items-center gap-1">
<Icon name="material-symbols:calendar-add-on-outline" class="w-4 h-4" />
Created {{ formatDate(form.createdAt) }}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-500">{{ formatDate(form.createdAt) }}</div>
</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 class="flex items-center gap-1">
<Icon name="material-symbols:schedule" class="w-4 h-4" />
Updated {{ formatDate(form.updatedAt) }}
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<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>
<!-- Global Loading Overlay -->
@ -385,6 +540,8 @@ try {
// State
const searchQuery = ref('');
const selectedCategory = ref('');
const selectedGroup = ref('');
const loading = ref(false);
const showDeleteConfirm = ref(false);
const formToDelete = ref(null);
@ -398,7 +555,33 @@ const jsonContent = ref('');
const jsonValidationMessage = ref('');
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(() => {
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;
});
// 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
const formatDate = (isoString) => {
if (!isoString) return '';
@ -459,18 +685,17 @@ const duplicateForm = async (form) => {
loading.value = true;
const newName = `${form.name || 'Form'} (Copy)`;
// Create a copy of the form
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
// Load the original form first
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);
// Mark as having unsaved changes and save as new form
formStore.hasUnsavedChanges = true;
await formStore.saveForm();
// Refresh the form list
@ -552,8 +777,10 @@ onMounted(async () => {
await loadForms();
});
// Watch for changes in search and reload forms
watch([searchQuery], () => {
// Watch for changes in search and filters
watch([searchQuery, selectedCategory, selectedGroup], () => {
// Reset to first page when filters change
currentPage.value = 1;
// Debounce the search to avoid too many API calls
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
@ -566,9 +793,49 @@ let searchTimeout = null;
// Clear all filters
const clearFilters = () => {
searchQuery.value = '';
selectedCategory.value = '';
selectedGroup.value = '';
currentPage.value = 1; // Reset to first page
// 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
onUnmounted(() => {
clearTimeout(searchTimeout);
@ -826,13 +1093,35 @@ const closeImportModal = () => {
</script>
<style scoped>
/* Custom styles for better UX */
.hover\:bg-gray-50:hover {
transition: background-color 0.15s ease-in-out;
/* Custom transitions and animations */
.transition-colors {
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 {
opacity: 0.5;
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>

View File

@ -3149,10 +3149,6 @@ const sendToBack = () => {
<Icon name="material-symbols:tune" class="mr-2 w-4 h-4" />
<span>Process Settings</span>
</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>

View File

@ -29,6 +29,11 @@ const sortBy = ref('processCreatedDate');
const sortOrder = ref('desc');
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
const dashboardMetrics = ref({
totalProcesses: 0,
@ -66,9 +71,66 @@ const categoryOptions = [
{ value: 'Procurement', label: 'Procurement' }
];
// Filtered processes
// Filtered processes (all processes after applying filters)
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
@ -190,7 +252,8 @@ const loadProcesses = async () => {
try {
const options = {
sortBy: sortBy.value,
sortOrder: sortOrder.value
sortOrder: sortOrder.value,
limit: 1000 // Set a high limit to get all processes
};
if (statusFilter.value) {
@ -220,6 +283,8 @@ const loadProcesses = async () => {
// Watch for changes in filters and reload processes
watch([searchQuery, statusFilter, categoryFilter], () => {
// Reset to first page when filters change
currentPage.value = 1;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
loadProcesses();
@ -283,8 +348,24 @@ const publishProcess = async (processId) => {
loading.value = true;
await processStore.publishProcess(processId);
await loadProcesses();
toast.success('Process published successfully');
} catch (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 {
loading.value = false;
}
@ -303,6 +384,14 @@ const restoreProcess = async (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;
showDeleteConfirm.value = true;
};
@ -325,6 +414,31 @@ const clearFilters = () => {
searchQuery.value = '';
statusFilter.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
@ -629,8 +743,9 @@ const copyWorkflowLink = async (processId) => {
<!-- Processes Grid -->
<div v-else-if="filteredProcesses.length > 0" class="grid gap-4">
<div
v-for="process in filteredProcesses"
v-for="process in paginatedProcesses"
:key="process.id"
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
v-if="process.status === 'draft'"
@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"
:disabled="loading"
>
<Icon name="material-symbols:publish" class="text-lg" />
</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
@click="duplicateProcess(process)"
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
@click="confirmDelete(process.id)"
class="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-lg transition-colors"
title="Delete Process"
class="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-lg transition-colors"
:title="process.status === 'published' ? 'Unpublish first to delete this process' : 'Delete Process'"
:disabled="loading || process.status === 'published'"
>
<Icon name="material-symbols:delete" class="text-lg" />
@ -740,8 +865,127 @@ const copyWorkflowLink = async (processId) => {
</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 -->
<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" />
<h3 class="text-lg font-medium text-gray-900 mb-2">
{{ (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.
</p>
<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>
</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
const loadProcess = async () => {
try {
@ -95,7 +173,6 @@ const loadProcess = async () => {
if (response.success) {
process.value = response.process; // includes processDefinition
console.log('[Workflow] Process loaded:', process.value.processName, process.value.processDefinition);
// Check if process is published
const status = process.value.processStatus || process.value.status || 'draft';
@ -104,8 +181,20 @@ const loadProcess = async () => {
return;
}
// Initialize process variables from DB (process.processVariables)
processVariables.value = process.value.processVariables ? { ...process.value.processVariables } : {};
// Initialize process variables from DB - extract values from variable definitions
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)
await startProcessExecution();
@ -567,11 +656,18 @@ function buildApiRequest(nodeData, variables) {
// 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' && auth.username && auth.password) {
const token = btoa(`${substituteVariables(auth.username, variables)}:${substituteVariables(auth.password, variables)}`);
headers['Authorization'] = `Basic ${token}`;
} 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 = btoa(`${substituteVariables(auth.username, variables)}:${substituteVariables(auth.password, variables)}`);
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);
@ -624,26 +720,45 @@ const executeCurrentStep = async () => {
if (currentNode.value?.type === 'api') {
console.log(`[Workflow] Executing API node: ${currentNode.value.data?.label || currentNode.value.label}`);
const nodeData = currentNode.value.data || {};
// Use new structure if present
if (nodeData.body || nodeData.headers || nodeData.params || nodeData.authorization) {
const { url, headers, body } = buildApiRequest(nodeData, processVariables.value);
const apiMethod = nodeData.apiMethod || 'GET';
// Use new structure if present (check for any new structure properties)
if (nodeData.body !== undefined || nodeData.headers !== undefined || nodeData.params !== undefined || nodeData.authorization !== undefined) {
const outputVariable = nodeData.outputVariable || 'apiResponse';
const errorVariable = nodeData.errorVariable || 'apiError';
const continueOnError = nodeData.continueOnError || false;
try {
const response = await $fetch(url, {
method: apiMethod,
headers,
body: ['GET', 'HEAD'].includes(apiMethod) ? undefined : body,
// Use proxy endpoint to avoid CORS issues
const response = await $fetch('/api/process/workflow-api-call', {
method: 'POST',
body: {
nodeData,
processVariables: processVariables.value
}
});
processVariables.value[outputVariable] = response;
processVariables.value[errorVariable] = null;
console.log('[Workflow] API call success. Output variable set:', outputVariable, response);
if (canAutoProgress(currentNode.value)) {
moveToNextStep();
if (response.success) {
processVariables.value[outputVariable] = response.data;
processVariables.value[errorVariable] = null;
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 {
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) {
processVariables.value[errorVariable] = err;
@ -661,6 +776,7 @@ const executeCurrentStep = async () => {
}
} else {
// Fallback: old structure
const {
apiUrl,
apiMethod = 'GET',
@ -914,6 +1030,14 @@ const handleScriptFieldChange = ({ fieldName, 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
const handleConditionalLogicGenerated = (generatedScript) => {
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
if (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") {
@ -1476,6 +1631,39 @@ function getConditionGroupResult(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>
<template>
@ -1648,18 +1836,20 @@ function getConditionGroupResult(conditionGroup, variables) {
:component="component"
:is-preview="false"
:field-states="fieldStates"
@form-data-updated="handleFormDataUpdate"
/>
</template>
<!-- Place submit button in a full-width row at the end of the grid -->
<div class="col-span-12 flex justify-start pt-6 border-t border-gray-200">
<!-- Submit button - respects form builder configuration -->
<div v-if="currentForm?.submitButton?.enabled !== false" class="col-span-12 flex justify-start pt-6 border-t border-gray-200">
<RsButton
@click="validateAndSubmit"
:disabled="stepLoading"
variant="primary"
:variant="getSubmitButtonVariant()"
:style="getWorkflowSubmitButtonStyle()"
class="col-span-12"
>
<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>
</div>
</div>
@ -1972,4 +2162,9 @@ function getConditionGroupResult(conditionGroup, variables) {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
}
/* Submit button hover effects using CSS custom properties */
:deep(.rs-button:hover) {
background-color: var(--hover-bg) !important;
}
</style>

View File

@ -1,73 +1,82 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"user": {
"caseInstance": {
"type": "object",
"properties": {
"userID": {
"caseID": {
"type": "integer"
},
"userSecretKey": {
"caseUUID": {
"type": "string"
},
"caseName": {
"type": "string"
},
"caseStatus": {
"type": "string",
"default": "active"
},
"caseVariables": {
"type": [
"number",
"string",
"boolean",
"object",
"array",
"null"
]
},
"userUsername": {
"caseSettings": {
"type": [
"number",
"string",
"boolean",
"object",
"array",
"null"
]
},
"userPassword": {
"caseDefinition": {
"type": [
"number",
"string",
"boolean",
"object",
"array",
"null"
]
},
"userFullName": {
"type": [
"string",
"null"
]
"caseCreatedDate": {
"type": "string",
"format": "date-time"
},
"userEmail": {
"type": [
"string",
"null"
]
},
"userPhone": {
"type": [
"string",
"null"
]
},
"userStatus": {
"type": [
"string",
"null"
]
},
"userCreatedDate": {
"caseModifiedDate": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"userModifiedDate": {
"caseCompletedDate": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"caseInstance": {
"type": "array",
"items": {
"$ref": "#/definitions/caseInstance"
}
"startedBy": {
"anyOf": [
{
"$ref": "#/definitions/user"
},
{
"type": "null"
}
]
},
"process": {
"$ref": "#/definitions/process"
},
"caseTimeline": {
"type": "array",
@ -75,123 +84,45 @@
"$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": {
"type": "array",
"items": {
"$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",
"properties": {
"roleID": {
"timelineID": {
"type": "integer"
},
"roleName": {
"timelineType": {
"type": "string"
},
"timelineDescription": {
"type": [
"string",
"null"
]
},
"roleDescription": {
"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": {
"timelineDate": {
"type": "string",
"format": "date-time"
},
"role": {
"$ref": "#/definitions/role"
"caseInstance": {
"$ref": "#/definitions/caseInstance"
},
"user": {
"$ref": "#/definitions/user"
"anyOf": [
{
"$ref": "#/definitions/user"
},
{
"type": "null"
}
]
}
}
},
@ -267,6 +198,38 @@
],
"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": {
"anyOf": [
{
@ -277,13 +240,13 @@
}
]
},
"history": {
"formHistory": {
"type": "array",
"items": {
"$ref": "#/definitions/formHistory"
}
},
"tasks": {
"task": {
"type": "array",
"items": {
"$ref": "#/definitions/task"
@ -350,6 +313,16 @@
"null"
]
},
"submitButton": {
"type": [
"number",
"string",
"boolean",
"object",
"array",
"null"
]
},
"versionNumber": {
"type": "integer"
},
@ -366,7 +339,7 @@
"form": {
"$ref": "#/definitions/form"
},
"savedByUser": {
"user": {
"anyOf": [
{
"$ref": "#/definitions/user"
@ -507,17 +480,11 @@
}
]
},
"history": {
"processHistory": {
"type": "array",
"items": {
"$ref": "#/definitions/processHistory"
}
},
"cases": {
"type": "array",
"items": {
"$ref": "#/definitions/caseInstance"
}
}
}
},
@ -625,7 +592,7 @@
"process": {
"$ref": "#/definitions/process"
},
"savedByUser": {
"user": {
"anyOf": [
{
"$ref": "#/definitions/user"
@ -637,93 +604,48 @@
}
}
},
"caseInstance": {
"role": {
"type": "object",
"properties": {
"caseID": {
"roleID": {
"type": "integer"
},
"caseUUID": {
"type": "string"
},
"caseName": {
"type": "string"
},
"caseStatus": {
"type": "string",
"default": "active"
},
"caseVariables": {
"roleName": {
"type": [
"number",
"string",
"boolean",
"object",
"array",
"null"
]
},
"caseSettings": {
"roleDescription": {
"type": [
"number",
"string",
"boolean",
"object",
"array",
"null"
]
},
"caseDefinition": {
"roleStatus": {
"type": [
"number",
"string",
"boolean",
"object",
"array",
"null"
]
},
"caseCreatedDate": {
"type": "string",
"format": "date-time"
},
"caseModifiedDate": {
"roleCreatedDate": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"caseCompletedDate": {
"roleModifiedDate": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"process": {
"$ref": "#/definitions/process"
},
"startedBy": {
"anyOf": [
{
"$ref": "#/definitions/user"
},
{
"type": "null"
}
]
},
"tasks": {
"userrole": {
"type": "array",
"items": {
"$ref": "#/definitions/task"
}
},
"timeline": {
"type": "array",
"items": {
"$ref": "#/definitions/caseTimeline"
"$ref": "#/definitions/userrole"
}
}
}
@ -775,10 +697,10 @@
],
"format": "date-time"
},
"case": {
"caseInstance": {
"$ref": "#/definitions/caseInstance"
},
"assignedTo": {
"user": {
"anyOf": [
{
"$ref": "#/definitions/user"
@ -800,51 +722,144 @@
}
}
},
"caseTimeline": {
"user": {
"type": "object",
"properties": {
"timelineID": {
"userID": {
"type": "integer"
},
"timelineType": {
"type": "string"
},
"timelineDescription": {
"userSecretKey": {
"type": [
"string",
"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",
"format": "date-time"
},
"case": {
"$ref": "#/definitions/caseInstance"
"role": {
"$ref": "#/definitions/role"
},
"createdBy": {
"anyOf": [
{
"$ref": "#/definitions/user"
},
{
"type": "null"
}
]
"user": {
"$ref": "#/definitions/user"
}
}
}
},
"type": "object",
"properties": {
"user": {
"$ref": "#/definitions/user"
"caseInstance": {
"$ref": "#/definitions/caseInstance"
},
"role": {
"$ref": "#/definitions/role"
},
"userrole": {
"$ref": "#/definitions/userrole"
"caseTimeline": {
"$ref": "#/definitions/caseTimeline"
},
"form": {
"$ref": "#/definitions/form"
@ -858,14 +873,17 @@
"processHistory": {
"$ref": "#/definitions/processHistory"
},
"caseInstance": {
"$ref": "#/definitions/caseInstance"
"role": {
"$ref": "#/definitions/role"
},
"task": {
"$ref": "#/definitions/task"
},
"caseTimeline": {
"$ref": "#/definitions/caseTimeline"
"user": {
"$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")
}
model user {
userID Int @id @default(autoincrement())
userSecretKey String? @db.VarChar(255)
userUsername String? @db.VarChar(255)
userPassword String? @db.VarChar(255)
userFullName String? @db.VarChar(255)
userEmail String? @db.VarChar(255)
userPhone String? @db.VarChar(255)
userStatus String? @db.VarChar(255)
userCreatedDate DateTime? @db.DateTime(0)
userModifiedDate DateTime? @db.DateTime(0)
caseInstance caseInstance[]
caseTimeline caseTimeline[]
forms form[] @relation("FormCreator")
formHistoryEntries formHistory[]
processes process[] @relation("ProcessCreator")
processHistoryEntries processHistory[]
task task[]
userrole userrole[]
startedCases caseInstance[] @relation("CaseStartedBy")
assignedTasks task[] @relation("TaskAssignedTo")
caseTimelineEntries caseTimeline[]
model caseInstance {
caseID Int @id @default(autoincrement())
caseUUID String @unique @db.VarChar(36)
processID Int
caseName String @db.VarChar(255)
caseStatus String @default("active") @db.VarChar(50)
caseStartedBy Int?
caseVariables Json?
caseSettings Json?
caseDefinition Json?
caseCreatedDate DateTime @default(now()) @db.DateTime(0)
caseModifiedDate DateTime? @db.DateTime(0)
caseCompletedDate DateTime? @db.DateTime(0)
startedBy user? @relation(fields: [caseStartedBy], references: [userID])
process process @relation(fields: [processID], references: [processID])
caseTimeline caseTimeline[]
task task[]
@@index([processID], map: "FK_case_process")
@@index([caseStartedBy], map: "FK_case_startedBy")
@@index([caseStatus], map: "IDX_case_status")
}
model role {
roleID Int @id @default(autoincrement())
roleName String? @db.VarChar(255)
roleDescription String? @db.VarChar(255)
roleStatus String? @db.VarChar(255)
roleCreatedDate DateTime? @db.DateTime(0)
roleModifiedDate DateTime? @db.DateTime(0)
userrole userrole[]
}
model caseTimeline {
timelineID Int @id @default(autoincrement())
caseID Int
timelineType String @db.VarChar(50)
timelineDescription String? @db.Text
timelineDate DateTime @default(now()) @db.DateTime(0)
timelineCreatedBy Int?
caseInstance caseInstance @relation(fields: [caseID], references: [caseID])
user user? @relation(fields: [timelineCreatedBy], references: [userID])
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")
@@index([caseID], map: "FK_caseTimeline_case")
@@index([timelineCreatedBy], map: "FK_caseTimeline_createdBy")
@@index([timelineDate], map: "IDX_caseTimeline_date")
}
model form {
@ -67,16 +59,23 @@ model form {
formStatus String @default("active") @db.VarChar(50)
formCreatedBy Int?
formCreatedDate DateTime @default(now()) @db.DateTime(0)
formModifiedDate DateTime? @updatedAt @db.DateTime(0)
formModifiedDate DateTime? @db.DateTime(0)
customCSS String? @db.Text
customScript String? @db.LongText
formEvents Json?
scriptMode String? @default("safe") @db.VarChar(20)
creator user? @relation("FormCreator", fields: [formCreatedBy], references: [userID])
history formHistory[] @relation("FormHistoryEntries")
tasks task[]
submitButton Json?
formCategory String? @db.VarChar(100)
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([formCategory], map: "IDX_form_category")
@@index([formGroup], map: "IDX_form_group")
@@index([formStatus], map: "IDX_form_status")
}
model formHistory {
@ -91,17 +90,18 @@ model formHistory {
customScript String? @db.LongText
formEvents Json?
scriptMode String? @db.VarChar(20)
submitButton Json?
versionNumber Int
changeDescription String? @db.Text
savedBy Int?
savedDate DateTime @default(now()) @db.DateTime(0)
form form @relation("FormHistoryEntries", fields: [formID], references: [formID], onDelete: Cascade)
savedByUser user? @relation(fields: [savedBy], references: [userID])
form form @relation(fields: [formID], references: [formID], onDelete: Cascade)
user user? @relation(fields: [savedBy], references: [userID])
@@index([formID], map: "FK_formHistory_form")
@@index([savedBy], map: "FK_formHistory_savedBy")
@@index([formUUID], map: "IDX_formHistory_uuid")
@@index([savedDate], map: "IDX_formHistory_date")
@@index([formUUID], map: "IDX_formHistory_uuid")
}
model process {
@ -114,7 +114,7 @@ model process {
processStatus String @default("draft") @db.VarChar(50)
processCreatedBy Int?
processCreatedDate DateTime @default(now()) @db.DateTime(0)
processModifiedDate DateTime? @updatedAt @db.DateTime(0)
processModifiedDate DateTime? @db.DateTime(0)
isTemplate Boolean @default(false)
processCategory String? @db.VarChar(100)
processOwner String? @db.VarChar(255)
@ -125,13 +125,12 @@ model process {
templateCategory String? @db.VarChar(100)
processDeletedDate DateTime? @db.DateTime(0)
caseInstance caseInstance[]
creator user? @relation("ProcessCreator", fields: [processCreatedBy], references: [userID])
history processHistory[] @relation("ProcessHistoryEntries")
cases caseInstance[]
creator user? @relation(fields: [processCreatedBy], references: [userID])
processHistory processHistory[]
@@index([processCreatedBy], map: "FK_process_creator")
@@index([processStatus], map: "IDX_process_status")
@@index([processCategory], map: "IDX_process_category")
@@index([processStatus], map: "IDX_process_status")
@@index([isTemplate], map: "IDX_process_template")
}
@ -155,72 +154,77 @@ model processHistory {
changeDescription String? @db.Text
savedBy Int?
savedDate DateTime @default(now()) @db.DateTime(0)
process process @relation("ProcessHistoryEntries", fields: [processID], references: [processID], onDelete: Cascade)
savedByUser user? @relation(fields: [savedBy], references: [userID])
process process @relation(fields: [processID], references: [processID], onDelete: Cascade)
user user? @relation(fields: [savedBy], references: [userID])
@@index([processID], map: "FK_processHistory_process")
@@index([savedBy], map: "FK_processHistory_savedBy")
@@index([processUUID], map: "IDX_processHistory_uuid")
@@index([savedDate], map: "IDX_processHistory_date")
@@index([processUUID], map: "IDX_processHistory_uuid")
}
model caseInstance {
caseID Int @id @default(autoincrement())
caseUUID String @unique @db.VarChar(36)
processID Int
caseName String @db.VarChar(255)
caseStatus String @default("active") @db.VarChar(50)
caseStartedBy Int?
caseVariables Json?
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 role {
roleID Int @id @default(autoincrement())
roleName String? @db.VarChar(255)
roleDescription String? @db.VarChar(255)
roleStatus String? @db.VarChar(255)
roleCreatedDate DateTime? @db.DateTime(0)
roleModifiedDate DateTime? @db.DateTime(0)
userrole userrole[]
}
model task {
taskID Int @id @default(autoincrement())
taskUUID String @unique @db.VarChar(36)
caseID Int
taskName String @db.VarChar(255)
taskType String @db.VarChar(50)
taskStatus String @default("pending") @db.VarChar(50)
taskAssignedTo Int?
taskFormID Int?
taskData Json?
taskCreatedDate DateTime @default(now()) @db.DateTime(0)
taskModifiedDate DateTime? @updatedAt @db.DateTime(0)
taskID Int @id @default(autoincrement())
taskUUID String @unique @db.VarChar(36)
caseID Int
taskName String @db.VarChar(255)
taskType String @db.VarChar(50)
taskStatus String @default("pending") @db.VarChar(50)
taskAssignedTo Int?
taskFormID Int?
taskData Json?
taskCreatedDate DateTime @default(now()) @db.DateTime(0)
taskModifiedDate DateTime? @db.DateTime(0)
taskCompletedDate DateTime? @db.DateTime(0)
case caseInstance @relation(fields: [caseID], references: [caseID])
assignedTo user? @relation("TaskAssignedTo", fields: [taskAssignedTo], references: [userID])
form form? @relation(fields: [taskFormID], references: [formID])
caseInstance caseInstance @relation(fields: [caseID], references: [caseID])
user user? @relation(fields: [taskAssignedTo], references: [userID])
form form? @relation(fields: [taskFormID], references: [formID])
@@index([caseID], map: "FK_task_case")
@@index([taskAssignedTo], map: "FK_task_assignedTo")
@@index([caseID], map: "FK_task_case")
@@index([taskFormID], map: "FK_task_form")
@@index([taskStatus], map: "IDX_task_status")
}
model caseTimeline {
timelineID Int @id @default(autoincrement())
caseID Int
timelineType String @db.VarChar(50)
timelineDescription String? @db.Text
timelineDate DateTime @default(now()) @db.DateTime(0)
timelineCreatedBy Int?
case caseInstance @relation(fields: [caseID], references: [caseID])
createdBy user? @relation(fields: [timelineCreatedBy], references: [userID])
@@index([caseID], map: "FK_caseTimeline_case")
@@index([timelineCreatedBy], map: "FK_caseTimeline_createdBy")
@@index([timelineDate], map: "IDX_caseTimeline_date")
model user {
userID Int @id @default(autoincrement())
userSecretKey String? @db.VarChar(255)
userUsername String? @db.VarChar(255)
userPassword String? @db.VarChar(255)
userFullName String? @db.VarChar(255)
userEmail String? @db.VarChar(255)
userPhone String? @db.VarChar(255)
userStatus String? @db.VarChar(255)
userCreatedDate DateTime? @db.DateTime(0)
userModifiedDate DateTime? @db.DateTime(0)
caseInstance caseInstance[]
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,
formEvents: currentForm.formEvents,
scriptMode: currentForm.scriptMode,
submitButton: currentForm.submitButton,
versionNumber: nextVersionNumber,
changeDescription: body.changeDescription || null,
savedBy: body.savedBy || currentForm.formCreatedBy,
savedBy: event.context.user?.userID || currentForm.formCreatedBy,
savedDate: currentForm.formModifiedDate || currentForm.formCreatedDate
}
});
@ -104,6 +105,22 @@ export default defineEventHandler(async (event) => {
if (body.scriptMode !== undefined) {
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
let form;

View File

@ -25,11 +25,15 @@ export default defineEventHandler(async (event) => {
formDescription: body.formDescription || null,
formComponents: body.components || [],
formStatus: body.status || 'active',
formCreatedBy: body.createdBy || null, // In a real app, this would come from the authenticated user
customScript: body.customScript || null,
customCSS: body.customCSS || 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,
formCreatedDate: true,
formModifiedDate: true,
formCategory: true,
formGroup: true,
formTags: true,
// Don't include the full components data to keep response size small
creator: {
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
const skip = (parseInt(page) - 1) * 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
const orderBy = {};
@ -63,8 +67,8 @@ export default defineEventHandler(async (event) => {
prisma.process.findMany({
where,
orderBy,
skip,
take,
skip: shouldReturnAll ? 0 : skip,
take: shouldReturnAll ? undefined : take,
select: {
processID: true,
processUUID: true,
@ -93,9 +97,9 @@ export default defineEventHandler(async (event) => {
]);
// Calculate pagination info
const totalPages = Math.ceil(totalCount / take);
const hasNextPage = parseInt(page) < totalPages;
const hasPrevPage = parseInt(page) > 1;
const totalPages = shouldReturnAll ? 1 : Math.ceil(totalCount / take);
const hasNextPage = shouldReturnAll ? false : parseInt(page) < totalPages;
const hasPrevPage = shouldReturnAll ? false : parseInt(page) > 1;
return {
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'
// Submit button configuration
submitButton: {
enabled: true,
label: 'Submit',
category: 'primary', // primary, secondary
color: 'primary' // primary, secondary, success, info, warning, danger
},
// Form preview data
previewFormData: {},
// Form history tracking
lastChangeDescription: null,
// Form history tracking
lastChangeDescription: null,
// Form grouping
formCategory: '',
formTags: [],
formGroup: '',
}),
getters: {
@ -364,6 +377,30 @@ export const useFormBuilderStore = defineStore('formBuilder', {
};
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
this.selectedComponentId = newComponentId;
this.hasUnsavedChanges = true;
@ -638,15 +675,18 @@ export const useFormBuilderStore = defineStore('formBuilder', {
// Fetch forms from the API
const forms = await this.getForms();
// Transform to the format expected by the UI
this.savedForms = forms.map(form => ({
id: form.formUUID,
name: form.formName,
description: form.formDescription || '',
components: form.formComponents || [],
createdAt: form.formCreatedDate,
updatedAt: form.formModifiedDate
}));
// Transform to the format expected by the UI
this.savedForms = forms.map(form => ({
id: form.formUUID,
name: form.formName,
description: form.formDescription || '',
components: form.formComponents || [],
category: form.formCategory || '',
group: form.formGroup || '',
tags: form.formTags || [],
createdAt: form.formCreatedDate,
updatedAt: form.formModifiedDate
}));
return this.savedForms;
} catch (error) {
@ -668,11 +708,15 @@ export const useFormBuilderStore = defineStore('formBuilder', {
})),
customScript: this.formCustomScript,
customCSS: this.formCustomCSS,
formEvents: this.formEvents,
scriptMode: this.scriptMode,
// Add user info and change description for history tracking
savedBy: 1, // TODO: Get from authenticated user
changeDescription: this.lastChangeDescription || null
formEvents: this.formEvents,
scriptMode: this.scriptMode,
submitButton: this.submitButton,
formCategory: this.formCategory,
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
@ -746,10 +790,15 @@ export const useFormBuilderStore = defineStore('formBuilder', {
this.formComponents = [];
this.selectedComponentId = null;
// Set form data
this.formName = result.form.formName;
this.formDescription = result.form.formDescription || '';
this.currentFormId = result.form.formUUID;
// Set form data
this.formName = result.form.formName;
this.formDescription = result.form.formDescription || '';
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
this.formCustomScript = result.form.customScript || '';
@ -761,6 +810,12 @@ export const useFormBuilderStore = defineStore('formBuilder', {
onValidation: false
};
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
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
this.actionHistory = [];
this.currentHistoryIndex = -1;
@ -820,10 +902,15 @@ export const useFormBuilderStore = defineStore('formBuilder', {
// Clear form data
this.formComponents = [];
this.selectedComponentId = null;
this.formName = 'New Form';
this.formDescription = '';
this.currentFormId = null;
this.hasUnsavedChanges = false;
this.formName = 'New Form';
this.formDescription = '';
this.currentFormId = null;
this.hasUnsavedChanges = false;
// Reset grouping fields
this.formCategory = '';
this.formTags = [];
this.formGroup = '';
// Reset custom scripts and settings
this.formCustomScript = '';
@ -840,6 +927,9 @@ export const useFormBuilderStore = defineStore('formBuilder', {
this.actionHistory = [];
this.currentHistoryIndex = -1;
// Clear preview form data
this.updatePreviewFormData({});
// Record the initial empty state
this.recordHistory('new_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
*/
@ -1342,7 +1375,9 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
type: variable.type || 'string',
scope: variable.scope || 'global',
value: variable.value,
description: variable.description || ''
description: variable.description || '',
captureFromUrl: variable.captureFromUrl || false,
urlParameterName: variable.urlParameterName || ''
};
this.unsavedChanges = true;