- 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.
494 lines
12 KiB
Vue
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> |