Enhance Form Builder Submit Button Configuration and Variable Handling

- Introduced a new submit button configuration in the form builder, allowing users to enable/disable the default submit button and customize its label, category, and color.
- Updated VariableBrowser.vue to support object property path input for variables, including validation and error handling for property paths.
- Enhanced ApiNodeConfiguration.vue to prevent object path creation for output and error variables.
- Improved workflow page to respect form builder submit button settings, ensuring consistent behavior across the application.
- Added helper functions for managing submit button styles and variants, enhancing the overall user experience.
This commit is contained in:
Afiq 2025-08-04 16:25:56 +08:00
parent 707d8fe3b0
commit 8f84b00a9e
9 changed files with 391 additions and 13 deletions

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

@ -295,16 +295,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>
@ -576,6 +580,102 @@
</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">
@ -995,6 +1095,23 @@ const pendingNavigation = ref(null);
const navigationTarget = ref(null);
const activeSettingsTab = ref('info');
// 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 +1149,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' },
@ -1062,7 +1180,8 @@ const formJson = computed(() => {
customScript: formStore.formCustomScript,
customCSS: formStore.formCustomCSS,
formEvents: formStore.formEvents,
scriptMode: formStore.scriptMode
scriptMode: formStore.scriptMode,
submitButton: formStore.submitButton
};
});
@ -1269,6 +1388,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;
@ -2634,6 +2777,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

@ -1488,6 +1488,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>
@ -1662,16 +1695,17 @@ function getConditionGroupResult(conditionGroup, variables) {
:field-states="fieldStates"
/>
</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>
@ -1964,4 +1998,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

@ -198,6 +198,16 @@
],
"default": "safe"
},
"submitButton": {
"type": [
"number",
"string",
"boolean",
"object",
"array",
"null"
]
},
"creator": {
"anyOf": [
{
@ -281,6 +291,16 @@
"null"
]
},
"submitButton": {
"type": [
"number",
"string",
"boolean",
"object",
"array",
"null"
]
},
"versionNumber": {
"type": "integer"
},

View File

@ -64,6 +64,7 @@ model form {
customScript String? @db.LongText
formEvents Json?
scriptMode String? @default("safe") @db.VarChar(20)
submitButton Json?
creator user? @relation(fields: [formCreatedBy], references: [userID])
formHistory formHistory[]
task task[]
@ -83,6 +84,7 @@ model formHistory {
customScript String? @db.LongText
formEvents Json?
scriptMode String? @db.VarChar(20)
submitButton Json?
versionNumber Int
changeDescription String? @db.Text
savedBy Int?

View File

@ -58,6 +58,7 @@ 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,
@ -104,6 +105,10 @@ export default defineEventHandler(async (event) => {
if (body.scriptMode !== undefined) {
updateData.scriptMode = body.scriptMode;
}
if (body.submitButton !== undefined) {
updateData.submitButton = body.submitButton;
}
// Try to update by UUID first
let form;

View File

@ -29,7 +29,8 @@ export default defineEventHandler(async (event) => {
customScript: body.customScript || null,
customCSS: body.customCSS || null,
formEvents: body.formEvents || null,
scriptMode: body.scriptMode || 'safe'
scriptMode: body.scriptMode || 'safe',
submitButton: body.submitButton || null
}
});

View File

@ -26,6 +26,14 @@ 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: {},
@ -670,6 +678,7 @@ export const useFormBuilderStore = defineStore('formBuilder', {
customCSS: this.formCustomCSS,
formEvents: this.formEvents,
scriptMode: this.scriptMode,
submitButton: this.submitButton,
// Add user info and change description for history tracking
savedBy: 1, // TODO: Get from authenticated user
changeDescription: this.lastChangeDescription || null
@ -761,6 +770,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)) {