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

View File

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

View File

@ -295,16 +295,20 @@
</template> </template>
</div> </div>
<!-- Only show submit button if no submit button components exist in the form --> <!-- Only show submit button if enabled and no submit button components exist in the form -->
<FormKit <FormKit
v-if="!formStore.formComponents.some(comp => comp.type === 'button' && comp.props.buttonType === 'submit')" v-if="formStore.submitButton.enabled && !formStore.formComponents.some(comp => comp.type === 'button' && comp.props.buttonType === 'submit')"
type="submit" type="submit"
label="Submit" :label="formStore.submitButton.label || 'Submit'"
class="form-submit mt-6" class="form-submit mt-6"
:class="{ :class="{
'mx-4 mb-4': selectedDevice !== 'Desktop', 'mx-4 mb-4': selectedDevice !== 'Desktop',
'mx-0 mb-0': selectedDevice === 'Desktop' 'mx-0 mb-0': selectedDevice === 'Desktop'
}" }"
:classes="{
input: getSubmitButtonStyles(formStore.submitButton.category, formStore.submitButton.color).classes
}"
:style="getSubmitButtonStyles(formStore.submitButton.category, formStore.submitButton.color).style"
/> />
</FormKit> </FormKit>
</div> </div>
@ -576,6 +580,102 @@
</div> </div>
</template> </template>
<!-- Submit Button Settings Tab -->
<template #submit>
<div class="p-4 space-y-4">
<div class="bg-blue-50 border border-blue-200 text-blue-800 p-3 rounded mb-4">
<div class="flex items-start">
<Icon name="material-symbols:info" class="w-5 h-5 mr-2 mt-0.5" />
<div>
<h4 class="font-medium text-sm">Submit Button Configuration</h4>
<p class="text-xs mt-1">
Configure the default submit button that appears at the bottom of your form.
Note: If you add a custom submit button component to your form, the default button will be hidden automatically.
</p>
</div>
</div>
</div>
<!-- Enable/Disable Submit Button -->
<div class="space-y-2">
<label class="flex items-center">
<input
type="checkbox"
v-model="formStore.submitButton.enabled"
class="form-checkbox h-4 w-4 text-blue-600 focus:ring-blue-500"
/>
<span class="ml-2 text-sm font-medium text-gray-700">Show default submit button</span>
</label>
<p class="text-xs text-gray-500 ml-6">
When enabled, a submit button will appear at the bottom of your form unless you add a custom submit button component.
</p>
</div>
<!-- Submit Button Label -->
<div v-if="formStore.submitButton.enabled">
<FormKit
type="text"
label="Submit Button Text"
v-model="formStore.submitButton.label"
help="The text that appears on the submit button"
validation="required"
placeholder="Submit"
/>
</div>
<!-- Submit Button Category -->
<div v-if="formStore.submitButton.enabled">
<FormKit
type="select"
label="Button Style Category"
v-model="formStore.submitButton.category"
help="Primary buttons are more prominent, secondary are more subtle"
:options="[
{ label: 'Primary', value: 'primary' },
{ label: 'Secondary', value: 'secondary' }
]"
/>
</div>
<!-- Submit Button Color -->
<div v-if="formStore.submitButton.enabled">
<FormKit
type="select"
label="Button Color"
v-model="formStore.submitButton.color"
help="Choose the color theme for the submit button"
:options="[
{ label: 'Primary', value: 'primary' },
{ label: 'Secondary', value: 'secondary' },
{ label: 'Success', value: 'success' },
{ label: 'Info', value: 'info' },
{ label: 'Warning', value: 'warning' },
{ label: 'Danger', value: 'danger' }
]"
/>
</div>
<!-- Preview Section -->
<div v-if="formStore.submitButton.enabled" class="border-t pt-4">
<h4 class="text-sm font-medium text-gray-700 mb-2">Preview</h4>
<div class="bg-gray-50 p-4 rounded border">
<FormKit
type="submit"
:label="formStore.submitButton.label || 'Submit'"
:classes="{
input: getSubmitButtonStyles(formStore.submitButton.category, formStore.submitButton.color).classes
}"
:style="getSubmitButtonStyles(formStore.submitButton.category, formStore.submitButton.color).style"
disabled
/>
<p class="text-xs text-gray-500 mt-2">
This is how your submit button will appear in the form.
</p>
</div>
</div>
</div>
</template>
<!-- Custom JavaScript Tab --> <!-- Custom JavaScript Tab -->
<template #javascript> <template #javascript>
<div class="p-4"> <div class="p-4">
@ -995,6 +1095,23 @@ const pendingNavigation = ref(null);
const navigationTarget = ref(null); const navigationTarget = ref(null);
const activeSettingsTab = ref('info'); 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 // Responsive device preview state
const selectedDevice = ref('Desktop'); const selectedDevice = ref('Desktop');
const deviceSizes = ref([ const deviceSizes = ref([
@ -1032,6 +1149,7 @@ const conditionalLogicEngine = ref(null);
// Settings tabs configuration // Settings tabs configuration
const settingsTabs = [ const settingsTabs = [
{ key: 'info', label: 'Form Info', icon: 'material-symbols:info-outline' }, { key: 'info', label: 'Form Info', icon: 'material-symbols:info-outline' },
{ key: 'submit', label: 'Submit Button', icon: 'material-symbols:play-arrow' },
{ key: 'javascript', label: 'JavaScript', icon: 'material-symbols:code' }, { key: 'javascript', label: 'JavaScript', icon: 'material-symbols:code' },
{ key: 'css', label: 'CSS', icon: 'material-symbols:palette-outline' }, { key: 'css', label: 'CSS', icon: 'material-symbols:palette-outline' },
{ key: 'events', label: 'Events', icon: 'material-symbols:event-outline' }, { key: 'events', label: 'Events', icon: 'material-symbols:event-outline' },
@ -1062,7 +1180,8 @@ const formJson = computed(() => {
customScript: formStore.formCustomScript, customScript: formStore.formCustomScript,
customCSS: formStore.formCustomCSS, customCSS: formStore.formCustomCSS,
formEvents: formStore.formEvents, formEvents: formStore.formEvents,
scriptMode: formStore.scriptMode scriptMode: formStore.scriptMode,
submitButton: formStore.submitButton
}; };
}); });
@ -1269,6 +1388,30 @@ const applyJsonChanges = () => {
formStore.scriptMode = importedJson.scriptMode; formStore.scriptMode = importedJson.scriptMode;
} }
// Import submit button settings if available
if (importedJson.submitButton) {
formStore.submitButton = {
enabled: importedJson.submitButton.enabled !== undefined ? importedJson.submitButton.enabled : true,
label: importedJson.submitButton.label || 'Submit',
category: importedJson.submitButton.category || 'primary',
color: importedJson.submitButton.color || 'primary'
};
// Handle backward compatibility with old variant format
if (importedJson.submitButton.variant && !importedJson.submitButton.category && !importedJson.submitButton.color) {
const variantMapping = {
'primary': { category: 'primary', color: 'primary' },
'secondary': { category: 'secondary', color: 'secondary' },
'success': { category: 'primary', color: 'success' },
'warning': { category: 'primary', color: 'warning' },
'danger': { category: 'primary', color: 'danger' }
};
const mapping = variantMapping[importedJson.submitButton.variant] || { category: 'primary', color: 'primary' };
formStore.submitButton.category = mapping.category;
formStore.submitButton.color = mapping.color;
}
}
// Mark as having unsaved changes // Mark as having unsaved changes
formStore.hasUnsavedChanges = true; formStore.hasUnsavedChanges = true;
@ -2634,6 +2777,10 @@ const handleFormRestored = (restoredForm) => {
</script> </script>
<style scoped> <style scoped>
/* Submit button hover effects using CSS custom properties */
:deep(.formkit-input[type="submit"]:hover) {
background-color: var(--hover-bg) !important;
}
.form-name-input { .form-name-input {
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;

View File

@ -1488,6 +1488,39 @@ function getConditionGroupResult(conditionGroup, variables) {
finalResult: evaluateConditionGroup(conditionGroup, variables) finalResult: evaluateConditionGroup(conditionGroup, variables)
}; };
} }
// Helper: Get submit button variant for RsButton
const getSubmitButtonVariant = () => {
// If form has submit button configuration, use it
if (currentForm.value?.submitButton) {
const { category } = currentForm.value.submitButton;
return category || 'primary';
}
// Fallback to primary
return 'primary';
};
// Helper: Get submit button styles using theme colors
const getWorkflowSubmitButtonStyle = () => {
// If form has submit button configuration, use it
if (currentForm.value?.submitButton) {
const { category = 'primary', color = 'primary' } = currentForm.value.submitButton;
// Use CSS custom properties from theme.css with opacity based on category
const opacity = category === 'primary' ? '1' : '0.8';
const hoverOpacity = category === 'primary' ? '0.9' : '0.7';
return {
backgroundColor: `rgba(var(--color-${color}), ${opacity})`,
'--hover-bg': `rgba(var(--color-${color}), ${hoverOpacity})`,
color: 'white',
border: 'none'
};
}
// Fallback to default styling
return {};
};
</script> </script>
<template> <template>
@ -1662,16 +1695,17 @@ function getConditionGroupResult(conditionGroup, variables) {
:field-states="fieldStates" :field-states="fieldStates"
/> />
</template> </template>
<!-- Place submit button in a full-width row at the end of the grid --> <!-- Submit button - respects form builder configuration -->
<div class="col-span-12 flex justify-start pt-6 border-t border-gray-200"> <div v-if="currentForm?.submitButton?.enabled !== false" class="col-span-12 flex justify-start pt-6 border-t border-gray-200">
<RsButton <RsButton
@click="validateAndSubmit" @click="validateAndSubmit"
:disabled="stepLoading" :disabled="stepLoading"
variant="primary" :variant="getSubmitButtonVariant()"
:style="getWorkflowSubmitButtonStyle()"
class="col-span-12" class="col-span-12"
> >
<Icon v-if="stepLoading" name="material-symbols:progress-activity" class="w-4 h-4 animate-spin mr-2" /> <Icon v-if="stepLoading" name="material-symbols:progress-activity" class="w-4 h-4 animate-spin mr-2" />
{{ stepLoading ? 'Processing...' : 'Submit' }} {{ stepLoading ? 'Processing...' : (currentForm?.submitButton?.label || 'Submit') }}
</RsButton> </RsButton>
</div> </div>
</div> </div>
@ -1964,4 +1998,9 @@ function getConditionGroupResult(conditionGroup, variables) {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms; transition-duration: 300ms;
} }
/* Submit button hover effects using CSS custom properties */
:deep(.rs-button:hover) {
background-color: var(--hover-bg) !important;
}
</style> </style>

View File

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

View File

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

View File

@ -58,6 +58,7 @@ export default defineEventHandler(async (event) => {
customScript: currentForm.customScript, customScript: currentForm.customScript,
formEvents: currentForm.formEvents, formEvents: currentForm.formEvents,
scriptMode: currentForm.scriptMode, scriptMode: currentForm.scriptMode,
submitButton: currentForm.submitButton,
versionNumber: nextVersionNumber, versionNumber: nextVersionNumber,
changeDescription: body.changeDescription || null, changeDescription: body.changeDescription || null,
savedBy: body.savedBy || currentForm.formCreatedBy, savedBy: body.savedBy || currentForm.formCreatedBy,
@ -104,6 +105,10 @@ export default defineEventHandler(async (event) => {
if (body.scriptMode !== undefined) { if (body.scriptMode !== undefined) {
updateData.scriptMode = body.scriptMode; updateData.scriptMode = body.scriptMode;
} }
if (body.submitButton !== undefined) {
updateData.submitButton = body.submitButton;
}
// Try to update by UUID first // Try to update by UUID first
let form; let form;

View File

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

View File

@ -26,6 +26,14 @@ export const useFormBuilderStore = defineStore('formBuilder', {
}, },
scriptMode: 'safe', // 'safe' or 'advanced' scriptMode: 'safe', // 'safe' or 'advanced'
// Submit button configuration
submitButton: {
enabled: true,
label: 'Submit',
category: 'primary', // primary, secondary
color: 'primary' // primary, secondary, success, info, warning, danger
},
// Form preview data // Form preview data
previewFormData: {}, previewFormData: {},
@ -670,6 +678,7 @@ export const useFormBuilderStore = defineStore('formBuilder', {
customCSS: this.formCustomCSS, customCSS: this.formCustomCSS,
formEvents: this.formEvents, formEvents: this.formEvents,
scriptMode: this.scriptMode, scriptMode: this.scriptMode,
submitButton: this.submitButton,
// Add user info and change description for history tracking // Add user info and change description for history tracking
savedBy: 1, // TODO: Get from authenticated user savedBy: 1, // TODO: Get from authenticated user
changeDescription: this.lastChangeDescription || null changeDescription: this.lastChangeDescription || null
@ -761,6 +770,12 @@ export const useFormBuilderStore = defineStore('formBuilder', {
onValidation: false onValidation: false
}; };
this.scriptMode = result.form.scriptMode || 'safe'; this.scriptMode = result.form.scriptMode || 'safe';
this.submitButton = result.form.submitButton || {
enabled: true,
label: 'Submit',
category: 'primary',
color: 'primary'
};
// Transform components from DB format to store format // Transform components from DB format to store format
if (Array.isArray(result.form.formComponents)) { if (Array.isArray(result.form.formComponents)) {