- Introduced a new 'Page Wrapper' node type to allow multiple components (forms, HTML, tables) to be rendered as a single page, enhancing the flexibility of the process builder. - Implemented child node management within the Page Wrapper, including adding, updating, and removing child nodes through the process store. - Enhanced the ProcessFlowCanvas to support the new Page Wrapper node, including event handling for child node interactions and configuration. - Updated the workflow page to handle submissions and interactions for Page Wrapper nodes, ensuring seamless integration with existing process flows. - Improved documentation in CLAUDE.md to provide guidance on the new Page Wrapper architecture and its components. - Refactored related components and store logic to accommodate the new functionality, ensuring a cohesive user experience across the application.
367 lines
10 KiB
Vue
367 lines
10 KiB
Vue
<template>
|
|
<div class="child-form-renderer">
|
|
<!-- Form Header -->
|
|
<div v-if="formTitle || formDescription" class="form-header mb-4">
|
|
<h3 v-if="formTitle" class="text-lg font-semibold text-gray-900 mb-2">
|
|
{{ formTitle }}
|
|
</h3>
|
|
<p v-if="formDescription" class="text-sm text-gray-600">
|
|
{{ formDescription }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Loading State -->
|
|
<div v-if="isLoading" class="loading-state flex justify-center py-8">
|
|
<div class="text-center">
|
|
<Icon name="material-symbols:progress-activity" class="w-6 h-6 animate-spin text-blue-500 mx-auto mb-2" />
|
|
<p class="text-gray-500 text-sm">Loading form...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error State -->
|
|
<div v-else-if="loadError" class="error-state bg-red-50 border border-red-200 rounded-lg p-4">
|
|
<div class="flex items-center">
|
|
<Icon name="material-symbols:error" class="w-5 h-5 text-red-600 mr-2" />
|
|
<span class="text-red-800 text-sm">{{ loadError }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Form Content -->
|
|
<div v-else-if="formComponents && formComponents.length > 0" class="form-content">
|
|
<!-- FormKit Form -->
|
|
<FormKit
|
|
ref="formRef"
|
|
type="form"
|
|
:value="localFormData"
|
|
@submit="handleFormSubmit"
|
|
@input="handleFormChange"
|
|
:actions="false"
|
|
:disabled="isSubmitting"
|
|
class="space-y-4"
|
|
>
|
|
<!-- Dynamic Components -->
|
|
<ComponentPreview
|
|
v-for="(component, index) in formComponents"
|
|
:key="`${component.props?.name || index}-${formDataKey}`"
|
|
:component="component"
|
|
:formData="localFormData"
|
|
:isPreview="false"
|
|
:fieldStates="fieldStates"
|
|
@updateFormData="handleComponentUpdate"
|
|
/>
|
|
|
|
<!-- Submit Button (if not handled by parent) -->
|
|
<div v-if="showSubmitButton" class="form-actions mt-6">
|
|
<button
|
|
type="button"
|
|
@click="submitForm"
|
|
:disabled="isSubmitting || hasValidationErrors"
|
|
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<Icon v-if="isSubmitting" name="material-symbols:progress-activity" class="w-4 h-4 mr-2 animate-spin" />
|
|
{{ submitButtonText }}
|
|
</button>
|
|
</div>
|
|
</FormKit>
|
|
|
|
<!-- Conditional Logic Engine -->
|
|
<ConditionalLogicEngine
|
|
v-if="conditionalLogicScript"
|
|
:script="conditionalLogicScript"
|
|
:formData="localFormData"
|
|
:processVariables="processVariables"
|
|
@fieldStateChange="handleFieldStateChange"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Empty Form State -->
|
|
<div v-else class="empty-form-state text-center py-8">
|
|
<Icon name="material-symbols:description" class="w-12 h-12 text-gray-300 mx-auto mb-2" />
|
|
<p class="text-gray-500 text-sm">No form configured for this component</p>
|
|
<p class="text-gray-400 text-xs mt-1">Component ID: {{ childNode.nodeId || 'Not set' }}</p>
|
|
</div>
|
|
|
|
<!-- Validation Errors -->
|
|
<div v-if="validationErrors && Object.keys(validationErrors).length > 0" class="validation-errors mt-4">
|
|
<div class="bg-red-50 border border-red-200 rounded-lg p-3">
|
|
<div class="flex items-start">
|
|
<Icon name="material-symbols:error" class="w-5 h-5 text-red-600 mr-2 mt-0.5 flex-shrink-0" />
|
|
<div class="flex-1">
|
|
<h4 class="text-sm font-medium text-red-800 mb-1">Please fix the following errors:</h4>
|
|
<ul class="text-sm text-red-700 space-y-1">
|
|
<li v-for="(error, field) in validationErrors" :key="field">
|
|
<strong>{{ getFieldLabel(field) }}:</strong> {{ error }}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
|
import { FormKit } from '@formkit/vue'
|
|
import { Icon } from '#components'
|
|
import ComponentPreview from '~/components/ComponentPreview.vue'
|
|
import ConditionalLogicEngine from '~/components/ConditionalLogicEngine.vue'
|
|
|
|
const props = defineProps({
|
|
childNode: {
|
|
type: Object,
|
|
required: true
|
|
},
|
|
processVariables: {
|
|
type: Object,
|
|
default: () => ({})
|
|
},
|
|
formData: {
|
|
type: Object,
|
|
default: () => ({})
|
|
},
|
|
showSubmitButton: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
submitButtonText: {
|
|
type: String,
|
|
default: 'Submit'
|
|
}
|
|
})
|
|
|
|
const emit = defineEmits(['form-submit', 'form-change', 'validation-error'])
|
|
|
|
// Reactive data
|
|
const isLoading = ref(false)
|
|
const isSubmitting = ref(false)
|
|
const loadError = ref(null)
|
|
const formComponents = ref([])
|
|
const localFormData = ref({})
|
|
const fieldStates = ref({})
|
|
const validationErrors = ref({})
|
|
const conditionalLogicScript = ref('')
|
|
const formDataKey = ref(0)
|
|
const formRef = ref(null)
|
|
|
|
// Computed properties
|
|
const formTitle = computed(() => {
|
|
return props.childNode.formTitle || props.childNode.title || null
|
|
})
|
|
|
|
const formDescription = computed(() => {
|
|
return props.childNode.formDescription || props.childNode.description || null
|
|
})
|
|
|
|
const hasValidationErrors = computed(() => {
|
|
return Object.keys(validationErrors.value).length > 0
|
|
})
|
|
|
|
// Methods
|
|
const loadFormData = async () => {
|
|
if (!props.childNode.nodeId) {
|
|
console.warn('Child node has no nodeId specified')
|
|
return
|
|
}
|
|
|
|
isLoading.value = true
|
|
loadError.value = null
|
|
|
|
try {
|
|
console.log('Loading form data for child node:', props.childNode.nodeId)
|
|
|
|
const response = await $fetch(`/api/forms/${props.childNode.nodeId}`)
|
|
|
|
if (response.success) {
|
|
const form = response.form
|
|
formComponents.value = form.formComponents || []
|
|
conditionalLogicScript.value = form.customScript || ''
|
|
|
|
console.log('Form data loaded for child:', form.formName, formComponents.value.length, 'components')
|
|
} else {
|
|
throw new Error(response.message || 'Failed to load form')
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading form data:', error)
|
|
loadError.value = error.message || 'Failed to load form data'
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
const applyInputMappings = () => {
|
|
if (!props.childNode.variableMapping || !props.childNode.variableMapping.inputs) {
|
|
return
|
|
}
|
|
|
|
const mappedData = { ...localFormData.value }
|
|
|
|
props.childNode.variableMapping.inputs.forEach(mapping => {
|
|
if (mapping.processVar && mapping.childVar) {
|
|
const processValue = props.processVariables[mapping.processVar]
|
|
if (processValue !== undefined) {
|
|
mappedData[mapping.childVar] = processValue
|
|
console.log(`Mapped process variable ${mapping.processVar} (${processValue}) to form field ${mapping.childVar}`)
|
|
}
|
|
}
|
|
})
|
|
|
|
localFormData.value = mappedData
|
|
formDataKey.value += 1 // Trigger re-render
|
|
}
|
|
|
|
const handleFormChange = (formData) => {
|
|
localFormData.value = { ...formData }
|
|
emit('form-change', formData)
|
|
}
|
|
|
|
const handleComponentUpdate = (updatedData) => {
|
|
localFormData.value = { ...localFormData.value, ...updatedData }
|
|
emit('form-change', localFormData.value)
|
|
}
|
|
|
|
const handleFormSubmit = (formData) => {
|
|
console.log('Child form submit:', props.childNode.id, formData)
|
|
|
|
// Validate form data
|
|
const errors = validateFormData(formData)
|
|
if (Object.keys(errors).length > 0) {
|
|
validationErrors.value = errors
|
|
emit('validation-error', errors)
|
|
return
|
|
}
|
|
|
|
// Clear validation errors
|
|
validationErrors.value = {}
|
|
emit('validation-error', {})
|
|
|
|
// Emit submit event
|
|
emit('form-submit', formData)
|
|
}
|
|
|
|
const submitForm = () => {
|
|
if (formRef.value && formRef.value.node && typeof formRef.value.node.submit === 'function') {
|
|
formRef.value.node.submit()
|
|
}
|
|
}
|
|
|
|
const validateFormData = (formData) => {
|
|
const errors = {}
|
|
|
|
// Basic validation - can be enhanced based on form component validation rules
|
|
formComponents.value.forEach(component => {
|
|
const field = component.props
|
|
if (!field) return
|
|
|
|
const fieldName = field.name
|
|
const fieldValue = formData[fieldName]
|
|
|
|
// Required field validation
|
|
if (field.validation && field.validation.includes('required')) {
|
|
if (fieldValue === undefined || fieldValue === null || fieldValue === '') {
|
|
errors[fieldName] = 'This field is required'
|
|
}
|
|
}
|
|
|
|
// Email validation
|
|
if (field.validation && field.validation.includes('email') && fieldValue) {
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
if (!emailRegex.test(fieldValue)) {
|
|
errors[fieldName] = 'Please enter a valid email address'
|
|
}
|
|
}
|
|
|
|
// Number validation
|
|
if (field.type === 'number' && fieldValue !== undefined && fieldValue !== '') {
|
|
if (isNaN(fieldValue)) {
|
|
errors[fieldName] = 'Please enter a valid number'
|
|
}
|
|
}
|
|
})
|
|
|
|
return errors
|
|
}
|
|
|
|
const getFieldLabel = (fieldName) => {
|
|
const component = formComponents.value.find(c => c.props?.name === fieldName)
|
|
return component?.props?.label || fieldName
|
|
}
|
|
|
|
const handleFieldStateChange = (fieldName, state) => {
|
|
fieldStates.value[fieldName] = state
|
|
}
|
|
|
|
// Watch for changes in child node configuration
|
|
watch(() => props.childNode.nodeId, (newNodeId) => {
|
|
if (newNodeId) {
|
|
loadFormData()
|
|
}
|
|
}, { immediate: true })
|
|
|
|
// Watch for changes in form data prop
|
|
watch(() => props.formData, (newFormData) => {
|
|
localFormData.value = { ...newFormData }
|
|
formDataKey.value += 1
|
|
}, { deep: true, immediate: true })
|
|
|
|
// Watch for process variable changes and apply input mappings
|
|
watch(() => props.processVariables, () => {
|
|
applyInputMappings()
|
|
}, { deep: true, immediate: true })
|
|
|
|
// Apply input mappings when form loads
|
|
watch(() => formComponents.value.length, () => {
|
|
if (formComponents.value.length > 0) {
|
|
nextTick(() => {
|
|
applyInputMappings()
|
|
})
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.child-form-renderer {
|
|
width: 100%;
|
|
min-height: 200px;
|
|
}
|
|
|
|
.form-header {
|
|
border-bottom: 1px solid #f3f4f6;
|
|
padding-bottom: 0.75rem;
|
|
}
|
|
|
|
.loading-state,
|
|
.empty-form-state {
|
|
min-height: 150px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.form-content {
|
|
width: 100%;
|
|
}
|
|
|
|
.form-actions {
|
|
border-top: 1px solid #f3f4f6;
|
|
padding-top: 1rem;
|
|
}
|
|
|
|
.validation-errors {
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
/* FormKit styling overrides */
|
|
:deep(.formkit-form) {
|
|
width: 100%;
|
|
}
|
|
|
|
:deep(.formkit-input) {
|
|
width: 100%;
|
|
}
|
|
|
|
:deep(.formkit-wrapper) {
|
|
margin-bottom: 1rem;
|
|
}
|
|
</style> |