corrad-bp/components/process-flow/ChildHtmlRenderer.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

494 lines
12 KiB
Vue

<template>
<div class="child-html-renderer">
<!-- HTML Header -->
<div v-if="htmlTitle || htmlDescription" class="html-header mb-4">
<h3 v-if="htmlTitle" class="text-lg font-semibold text-gray-900 mb-2">
{{ htmlTitle }}
</h3>
<p v-if="htmlDescription" class="text-sm text-gray-600">
{{ htmlDescription }}
</p>
</div>
<!-- HTML Content -->
<div class="html-content-container">
<div
v-if="interpolatedHtmlContent"
v-html="interpolatedHtmlContent"
class="custom-html-content"
ref="htmlContentRef"
></div>
<!-- Empty State -->
<div v-else class="empty-html-state text-center py-8">
<Icon name="material-symbols:code" class="w-12 h-12 text-gray-300 mx-auto mb-2" />
<p class="text-gray-500 text-sm">No HTML content configured</p>
<p class="text-gray-400 text-xs mt-1">Component ID: {{ childNode.nodeId || 'Not set' }}</p>
</div>
</div>
<!-- HTML Actions (if defined in HTML) -->
<div v-if="showActions" class="html-actions mt-4 flex justify-end space-x-2">
<button
v-for="action in availableActions"
:key="action.id"
@click="handleHtmlAction(action)"
:class="getActionButtonClasses(action)"
:disabled="action.disabled"
>
<Icon v-if="action.icon" :name="action.icon" class="w-4 h-4 mr-2" />
{{ action.label }}
</button>
</div>
<!-- Debug Info (if enabled) -->
<div v-if="showDebugInfo && isDevelopment" class="html-debug mt-4 p-3 bg-gray-50 border border-gray-200 rounded text-xs text-gray-600">
<div><strong>Variables Available:</strong> {{ Object.keys(processVariables).join(', ') || 'None' }}</div>
<div><strong>HTML Length:</strong> {{ (childNode.htmlCode || '').length }} characters</div>
<div><strong>CSS Length:</strong> {{ (childNode.cssCode || '').length }} characters</div>
<div><strong>JS Length:</strong> {{ (childNode.jsCode || '').length }} characters</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { Icon } from '#components'
const props = defineProps({
childNode: {
type: Object,
required: true
},
processVariables: {
type: Object,
default: () => ({})
},
showActions: {
type: Boolean,
default: true
},
showDebugInfo: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['html-action', 'variable-update'])
// Reactive data
const htmlContentRef = ref(null)
const availableActions = ref([])
const isDevelopment = process.env.NODE_ENV === 'development'
// Style and script elements for cleanup
let customStyleElement = null
let customScriptElement = null
// Computed properties
const htmlTitle = computed(() => {
return props.childNode.title || props.childNode.htmlTitle || null
})
const htmlDescription = computed(() => {
return props.childNode.description || props.childNode.htmlDescription || null
})
const rawHtmlContent = computed(() => {
return props.childNode.htmlCode || props.childNode.htmlContent || ''
})
const interpolatedHtmlContent = computed(() => {
let content = rawHtmlContent.value
if (!content) return ''
// Replace process variables in the HTML content
// Format: {{variableName}} or ${variableName}
content = content.replace(/\{\{(\w+)\}\}/g, (match, varName) => {
const value = props.processVariables[varName]
return value !== undefined ? String(value) : match
})
content = content.replace(/\$\{(\w+)\}/g, (match, varName) => {
const value = props.processVariables[varName]
return value !== undefined ? String(value) : match
})
return content
})
const customCSS = computed(() => {
return props.childNode.cssCode || ''
})
const customJS = computed(() => {
return props.childNode.jsCode || ''
})
// Methods
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.setAttribute('data-child-html-css', props.childNode.id)
// Scope CSS to this component by prefixing with component class
const scopedCSS = cssCode.replace(/([^}]+\{)/g, (match, selector) => {
// Don't scope selectors that already contain the component class or are global
if (selector.includes('.custom-html-content') || selector.trim().startsWith('@')) {
return match
}
return `.custom-html-content ${selector}`
})
customStyleElement.textContent = scopedCSS
document.head.appendChild(customStyleElement)
}
}
const executeCustomJS = (jsCode) => {
if (!jsCode || !jsCode.trim()) return
try {
// Create a safe execution context
const context = {
// Available variables
processVariables: { ...props.processVariables },
// Helper functions
updateVariable: (variable, value) => {
emit('variable-update', { variable, value })
},
triggerAction: (actionId, data = {}) => {
emit('html-action', { type: 'custom', actionId, data })
},
showMessage: (message, type = 'info') => {
emit('html-action', { type: 'message', message, messageType: type })
},
// DOM element reference
element: htmlContentRef.value,
// Utility functions
console: {
log: (...args) => console.log('[Child HTML JS]', ...args),
warn: (...args) => console.warn('[Child HTML JS]', ...args),
error: (...args) => console.error('[Child HTML JS]', ...args)
}
}
// Execute the custom JavaScript with context
const func = new Function('context', `
const { processVariables, updateVariable, triggerAction, showMessage, element, console } = context;
${jsCode}
`)
func(context)
} catch (error) {
console.error('Error executing child HTML JavaScript:', error)
emit('html-action', {
type: 'error',
error: error.message,
source: 'custom-js'
})
}
}
const setupEventListeners = () => {
if (!htmlContentRef.value) return
// Set up click event listeners for buttons with data-action attributes
const actionButtons = htmlContentRef.value.querySelectorAll('[data-action]')
actionButtons.forEach(button => {
button.addEventListener('click', (event) => {
event.preventDefault()
const action = {
type: 'button-click',
actionId: button.getAttribute('data-action'),
value: button.getAttribute('data-value') || null,
data: button.dataset || {}
}
handleHtmlAction(action)
})
})
// Set up form submission listeners
const forms = htmlContentRef.value.querySelectorAll('form[data-action]')
forms.forEach(form => {
form.addEventListener('submit', (event) => {
event.preventDefault()
const formData = new FormData(form)
const data = {}
for (let [key, value] of formData.entries()) {
data[key] = value
}
const action = {
type: 'form-submit',
actionId: form.getAttribute('data-action'),
data
}
handleHtmlAction(action)
})
})
// Set up input change listeners for real-time variable updates
const variableInputs = htmlContentRef.value.querySelectorAll('[data-variable]')
variableInputs.forEach(input => {
input.addEventListener('input', (event) => {
const variable = input.getAttribute('data-variable')
const value = event.target.value
emit('variable-update', { variable, value })
})
})
}
const handleHtmlAction = (action) => {
console.log('HTML action triggered:', action)
emit('html-action', action)
}
const getActionButtonClasses = (action) => {
const baseClasses = 'px-4 py-2 rounded-md text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2'
switch (action.type || 'default') {
case 'primary':
return `${baseClasses} bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500`
case 'secondary':
return `${baseClasses} bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500`
case 'danger':
return `${baseClasses} bg-red-600 text-white hover:bg-red-700 focus:ring-red-500`
case 'success':
return `${baseClasses} bg-green-600 text-white hover:bg-green-700 focus:ring-green-500`
default:
return `${baseClasses} bg-gray-100 text-gray-700 hover:bg-gray-200 focus:ring-gray-500`
}
}
// Watch for HTML content changes
watch(interpolatedHtmlContent, () => {
nextTick(() => {
setupEventListeners()
})
})
// Watch for CSS changes
watch(customCSS, (newCSS) => {
nextTick(() => {
injectCustomCSS(newCSS)
})
}, { immediate: true })
// Watch for JS changes
watch(customJS, (newJS) => {
nextTick(() => {
executeCustomJS(newJS)
})
})
// Watch for process variable changes and re-execute JS
watch(() => props.processVariables, () => {
if (customJS.value) {
nextTick(() => {
executeCustomJS(customJS.value)
})
}
}, { deep: true })
// Setup on mount
onMounted(() => {
nextTick(() => {
setupEventListeners()
if (customJS.value) {
executeCustomJS(customJS.value)
}
})
})
// Cleanup on unmount
onUnmounted(() => {
if (customStyleElement) {
customStyleElement.remove()
customStyleElement = null
}
if (customScriptElement) {
customScriptElement.remove()
customScriptElement = null
}
})
</script>
<style scoped>
.child-html-renderer {
width: 100%;
min-height: 200px;
}
.html-header {
border-bottom: 1px solid #f3f4f6;
padding-bottom: 0.75rem;
}
.html-content-container {
width: 100%;
position: relative;
}
.custom-html-content {
width: 100%;
line-height: 1.6;
}
.empty-html-state {
min-height: 150px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.html-actions {
border-top: 1px solid #f3f4f6;
padding-top: 1rem;
}
.html-debug {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
/* Global styles for HTML content */
:deep(.custom-html-content) {
/* Typography */
h1, h2, h3, h4, h5, h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
line-height: 1.25;
}
h1 { font-size: 2em; }
h2 { font-size: 1.5em; }
h3 { font-size: 1.25em; }
h4 { font-size: 1.125em; }
p {
margin-bottom: 1em;
}
/* Lists */
ul, ol {
margin-bottom: 1em;
padding-left: 1.5em;
}
/* Links */
a {
color: #3b82f6;
text-decoration: underline;
}
a:hover {
color: #1d4ed8;
}
/* Forms */
input, textarea, select {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
}
input:focus, textarea:focus, select:focus {
outline: none;
ring: 2px;
ring-color: #3b82f6;
border-color: #3b82f6;
}
button {
padding: 0.5rem 1rem;
background-color: #3b82f6;
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
}
button:hover {
background-color: #1d4ed8;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1em;
}
th, td {
padding: 0.5rem;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
th {
font-weight: 600;
background-color: #f9fafb;
}
/* Images */
img {
max-width: 100%;
height: auto;
}
/* Code */
code {
background-color: #f3f4f6;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.875em;
}
pre {
background-color: #f3f4f6;
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin-bottom: 1em;
}
pre code {
background: none;
padding: 0;
}
}
</style>