corrad-bp/components/process-flow/PageWrapperRenderer.vue
Md Afiq Iskandar 7df119417a Add Page Wrapper Node and Enhance Process Flow Management
- 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.
2025-07-31 10:28:34 +08:00

565 lines
15 KiB
Vue

<template>
<div class="page-wrapper-renderer" :style="containerStyle">
<!-- Page Title -->
<div v-if="pageWrapperData.title && !hideTitle" class="page-title mb-6">
<h2 class="text-2xl font-bold text-gray-900">{{ pageWrapperData.title }}</h2>
<p v-if="pageWrapperData.description" class="text-gray-600 mt-2">
{{ pageWrapperData.description }}
</p>
</div>
<!-- Child Components Container -->
<div
v-if="visibleChildNodes.length > 0"
:class="containerClasses"
:style="layoutStyles"
>
<!-- Render each visible child component -->
<div
v-for="childNode in visibleChildNodes"
:key="childNode.id"
:class="getChildContainerClasses(childNode)"
:style="getChildContainerStyles(childNode)"
>
<!-- Form Component -->
<div v-if="childNode.type === 'form'" class="child-form-component">
<ChildFormRenderer
:childNode="childNode"
:processVariables="processVariables"
:formData="getChildFormData(childNode.id)"
@form-submit="handleChildFormSubmit(childNode, $event)"
@form-change="handleChildFormChange(childNode, $event)"
@validation-error="handleChildValidationError(childNode, $event)"
/>
</div>
<!-- HTML Component -->
<div v-else-if="childNode.type === 'html'" class="child-html-component">
<ChildHtmlRenderer
:childNode="childNode"
:processVariables="processVariables"
@html-action="handleChildHtmlAction(childNode, $event)"
@variable-update="handleChildVariableUpdate(childNode, $event)"
/>
</div>
<!-- Table Component (Future) -->
<div v-else-if="childNode.type === 'table'" class="child-table-component">
<ChildTableRenderer
v-if="hasTableRenderer"
:childNode="childNode"
:processVariables="processVariables"
@table-action="handleChildTableAction(childNode, $event)"
@row-select="handleChildTableRowSelect(childNode, $event)"
/>
<div v-else class="table-placeholder bg-gray-100 border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
<Icon name="material-symbols:table" class="w-12 h-12 text-gray-400 mx-auto mb-2" />
<p class="text-gray-500">Table component coming soon</p>
</div>
</div>
<!-- Unknown Component Type -->
<div v-else class="unknown-component bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div class="flex items-center">
<Icon name="material-symbols:warning" class="w-5 h-5 text-yellow-600 mr-2" />
<span class="text-yellow-800">Unknown component type: {{ childNode.type }}</span>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="empty-state text-center py-12">
<Icon name="material-symbols:dashboard" class="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 class="text-lg font-medium text-gray-900 mb-2">No Components</h3>
<p class="text-gray-500">This page wrapper doesn't have any child components configured.</p>
</div>
<!-- Page Actions -->
<div v-if="showPageActions" class="page-actions mt-8 flex justify-end space-x-3">
<button
v-if="canGoBack"
@click="handleGoBack"
type="button"
class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<Icon name="material-symbols:arrow-back" class="w-4 h-4 mr-2" />
Back
</button>
<button
@click="handlePageSubmit"
:disabled="isSubmitting || hasValidationErrors"
type="button"
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 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" />
<Icon v-else name="material-symbols:arrow-forward" class="w-4 h-4 mr-2" />
{{ submitButtonText }}
</button>
</div>
<!-- Debug Info (Development only) -->
<div v-if="showDebugInfo && isDevelopment" class="debug-info mt-8 p-4 bg-gray-50 border border-gray-200 rounded-lg">
<h4 class="text-sm font-medium text-gray-700 mb-2">Debug Information</h4>
<div class="text-xs text-gray-600 space-y-1">
<div><strong>Layout:</strong> {{ pageWrapperData.layout }}</div>
<div><strong>Child Nodes:</strong> {{ childNodes.length }}</div>
<div><strong>Visible Nodes:</strong> {{ visibleChildNodes.length }}</div>
<div><strong>Form Data:</strong> {{ JSON.stringify(childFormData, null, 2) }}</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { Icon } from '#components'
// Import child renderers (we'll create these)
import ChildFormRenderer from './ChildFormRenderer.vue'
import ChildHtmlRenderer from './ChildHtmlRenderer.vue'
// ChildTableRenderer will be created later
const hasTableRenderer = false
const props = defineProps({
pageWrapperData: {
type: Object,
required: true
},
processVariables: {
type: Object,
default: () => ({})
},
hideTitle: {
type: Boolean,
default: false
},
showPageActions: {
type: Boolean,
default: true
},
canGoBack: {
type: Boolean,
default: false
},
submitButtonText: {
type: String,
default: 'Continue'
},
showDebugInfo: {
type: Boolean,
default: false
}
})
const emit = defineEmits([
'page-submit',
'page-back',
'child-form-submit',
'child-form-change',
'child-html-action',
'child-table-action',
'variable-update',
'validation-error'
])
// Reactive data
const isSubmitting = ref(false)
const childFormData = ref({})
const validationErrors = ref({})
const isDevelopment = process.env.NODE_ENV === 'development'
// Computed properties
const childNodes = computed(() => {
return props.pageWrapperData.childNodes || []
})
const visibleChildNodes = computed(() => {
return childNodes.value.filter(childNode => {
if (!childNode.conditionalLogic || !childNode.conditionalLogic.enabled) {
return true
}
const { variable, operator, value } = childNode.conditionalLogic
const variableValue = props.processVariables[variable]
switch (operator) {
case '==':
return variableValue == value
case '!=':
return variableValue != value
case '>':
return Number(variableValue) > Number(value)
case '<':
return Number(variableValue) < Number(value)
case '>=':
return Number(variableValue) >= Number(value)
case '<=':
return Number(variableValue) <= Number(value)
default:
return true
}
})
})
const hasValidationErrors = computed(() => {
return Object.keys(validationErrors.value).length > 0
})
const containerStyle = computed(() => {
const styles = {
padding: props.pageWrapperData.padding || '1rem'
}
if (props.pageWrapperData.backgroundColor) {
styles.backgroundColor = props.pageWrapperData.backgroundColor
}
return styles
})
const containerClasses = computed(() => {
const layout = props.pageWrapperData.layout || 'grid'
const classes = ['child-components-container']
switch (layout) {
case 'grid':
classes.push('grid')
break
case 'flex':
classes.push('flex', 'flex-wrap')
break
case 'stacked':
classes.push('space-y-4')
break
}
return classes
})
const layoutStyles = computed(() => {
const layout = props.pageWrapperData.layout || 'grid'
const gap = props.pageWrapperData.gap || '1rem'
const styles = { gap }
if (layout === 'grid') {
const columns = props.pageWrapperData.columns || 2
styles.gridTemplateColumns = `repeat(${columns}, 1fr)`
}
return styles
})
// Methods
const getChildContainerClasses = (childNode) => {
const classes = ['child-component-wrapper']
if (props.pageWrapperData.layout === 'flex') {
classes.push('flex-1', 'min-w-0')
}
return classes
}
const getChildContainerStyles = (childNode) => {
const layout = props.pageWrapperData.layout || 'grid'
const styles = {}
if (layout === 'grid' && childNode.position) {
if (childNode.position.row) {
styles.gridRow = childNode.position.row
}
if (childNode.position.col) {
styles.gridColumn = childNode.position.col
}
}
return styles
}
const getChildFormData = (childNodeId) => {
return childFormData.value[childNodeId] || {}
}
// Event handlers
const handleChildFormSubmit = (childNode, formData) => {
console.log('Child form submitted:', childNode.id, formData)
// Store form data
childFormData.value[childNode.id] = formData
// Apply output variable mappings
if (childNode.variableMapping && childNode.variableMapping.outputs) {
childNode.variableMapping.outputs.forEach(mapping => {
if (mapping.childVar && mapping.processVar) {
const childValue = formData[mapping.childVar]
if (childValue !== undefined) {
emit('variable-update', {
variable: mapping.processVar,
value: childValue,
source: `child-${childNode.id}`
})
}
}
})
}
emit('child-form-submit', { childNode, formData })
}
const handleChildFormChange = (childNode, formData) => {
// Store form data
childFormData.value[childNode.id] = formData
emit('child-form-change', { childNode, formData })
}
const handleChildValidationError = (childNode, errors) => {
if (errors && Object.keys(errors).length > 0) {
validationErrors.value[childNode.id] = errors
} else {
delete validationErrors.value[childNode.id]
}
emit('validation-error', { childNode, errors })
}
const handleChildHtmlAction = (childNode, action) => {
console.log('Child HTML action:', childNode.id, action)
emit('child-html-action', { childNode, action })
}
const handleChildVariableUpdate = (childNode, { variable, value }) => {
emit('variable-update', {
variable,
value,
source: `child-${childNode.id}`
})
}
const handleChildTableAction = (childNode, action) => {
console.log('Child table action:', childNode.id, action)
emit('child-table-action', { childNode, action })
}
const handleChildTableRowSelect = (childNode, row) => {
console.log('Child table row selected:', childNode.id, row)
// Handle table row selection
}
const handleGoBack = () => {
emit('page-back')
}
const handlePageSubmit = async () => {
if (isSubmitting.value || hasValidationErrors.value) return
isSubmitting.value = true
try {
// Validate all child components
const allFormData = {}
const allErrors = {}
visibleChildNodes.value.forEach(childNode => {
if (childNode.type === 'form') {
const formData = getChildFormData(childNode.id)
allFormData[childNode.id] = formData
// Check for validation errors (this would be enhanced with actual validation)
const errors = validationErrors.value[childNode.id]
if (errors) {
allErrors[childNode.id] = errors
}
}
})
if (Object.keys(allErrors).length > 0) {
console.warn('Page submission blocked due to validation errors:', allErrors)
return
}
emit('page-submit', {
pageWrapperData: props.pageWrapperData,
childFormData: allFormData,
processVariables: props.processVariables
})
} finally {
isSubmitting.value = false
}
}
// Apply input variable mappings when process variables change
watch(() => props.processVariables, (newVars) => {
childNodes.value.forEach(childNode => {
if (childNode.variableMapping && childNode.variableMapping.inputs) {
const childData = childFormData.value[childNode.id] || {}
childNode.variableMapping.inputs.forEach(mapping => {
if (mapping.processVar && mapping.childVar) {
const processValue = newVars[mapping.processVar]
if (processValue !== undefined) {
childData[mapping.childVar] = processValue
}
}
})
childFormData.value[childNode.id] = childData
}
})
}, { deep: true, immediate: true })
// Inject custom CSS
let customStyleElement = null
const injectCustomCSS = (cssCode) => {
// Remove existing style element
if (customStyleElement) {
customStyleElement.remove()
customStyleElement = null
}
// Inject new styles if provided
if (cssCode && cssCode.trim()) {
customStyleElement = document.createElement('style')
customStyleElement.textContent = cssCode
document.head.appendChild(customStyleElement)
}
}
// Watch for custom CSS changes
watch(() => props.pageWrapperData.customCSS, (newCSS) => {
nextTick(() => {
injectCustomCSS(newCSS)
})
}, { immediate: true })
// Execute custom JavaScript
const executeCustomJS = (jsCode) => {
if (jsCode && jsCode.trim()) {
try {
// Create a safe context for custom JS execution
const context = {
processVariables: props.processVariables,
childFormData: childFormData.value,
updateVariable: (variable, value) => {
emit('variable-update', { variable, value, source: 'custom-js' })
}
}
// Execute the custom JS with the context
const func = new Function('context', jsCode)
func(context)
} catch (error) {
console.error('Error executing custom JavaScript:', error)
}
}
}
// Execute custom JS when component mounts and when it changes
onMounted(() => {
if (props.pageWrapperData.customJS) {
executeCustomJS(props.pageWrapperData.customJS)
}
})
watch(() => props.pageWrapperData.customJS, (newJS) => {
executeCustomJS(newJS)
})
// Cleanup
onUnmounted(() => {
if (customStyleElement) {
customStyleElement.remove()
customStyleElement = null
}
})
</script>
<style scoped>
.page-wrapper-renderer {
min-height: 400px;
width: 100%;
}
.page-title {
border-bottom: 1px solid #e5e7eb;
padding-bottom: 1rem;
}
.child-components-container {
width: 100%;
}
.child-component-wrapper {
position: relative;
}
.child-form-component,
.child-html-component,
.child-table-component {
height: 100%;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1rem;
background: white;
}
.table-placeholder {
min-height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.unknown-component {
min-height: 100px;
display: flex;
align-items: center;
justify-content: center;
}
.empty-state {
min-height: 300px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.page-actions {
border-top: 1px solid #e5e7eb;
padding-top: 1rem;
}
.debug-info {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.child-components-container.grid {
grid-template-columns: 1fr !important;
}
.child-components-container.flex {
flex-direction: column;
}
.page-actions {
flex-direction: column;
space-x: 0;
}
.page-actions button {
margin-top: 0.75rem;
}
.page-actions button:first-child {
margin-top: 0;
}
}
</style>