- 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.
565 lines
15 KiB
Vue
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> |