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.
This commit is contained in:
Md Afiq Iskandar 2025-07-31 10:28:34 +08:00
parent 47c296fcc0
commit 7df119417a
12 changed files with 4531 additions and 2 deletions

648
CLAUDE.md
View File

@ -1,5 +1,7 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## AI Guidance
- This documentation is for AI memory/context. Use it to answer questions, generate code, and reason about the system.
- Always follow the schema and field descriptions exactly as written.
@ -390,4 +392,648 @@ This codebase represents a sophisticated, production-ready BPM platform with com
- **Touch Support**: Mobile-optimized drag-and-drop with gesture recognition
- **Keyboard Shortcuts**: Ctrl+1/2/3 for panel management, standard undo/redo support
- **Responsive Canvas**: Adaptive grid layout with zoom and pan capabilities
- **Device Preview**: Mobile, tablet, and desktop preview modes for form integration testing
- **Device Preview**: Mobile, tablet, and desktop preview modes for form integration testing
## Process Node Architecture Deep Dive
### Node Creation and Registration System
**Available Node Types (`/components/process-flow/ProcessBuilderComponents.vue`):**
- **Core Node Types**: start, end, form, api, gateway, notification, business-rule, script, html, subprocess
- **Design Elements**: swimlane-horizontal, swimlane-vertical, rectangle-shape, text-annotation
**Node Definition Structure:**
```javascript
{
type: 'node-type', // Required: Unique node type identifier
name: 'Display Name', // Required: Human-readable name for UI
category: 'Core|Shape', // Required: Category for component palette
icon: 'material-symbols:icon', // Required: Icon for component palette
description: 'Node purpose', // Required: Tooltip description
defaultProps: { // Required: Default node configuration
label: 'Node Label', // Default label shown on canvas
data: { // Node-specific configuration data
// Core properties (all nodes)
description: 'Description', // Node description for tooltips
shape: 'rectangle|diamond|circle', // Visual shape
backgroundColor: '#ffffff', // Background color
borderColor: '#000000', // Border color
textColor: '#000000', // Text color
// Node-type specific properties
// (varies by node type - see specific node schemas below)
}
}
}
```
### Node Implementation Components
**Vue Component Structure (all in `/components/process-flow/custom/`):**
- **Node Rendering**: `[NodeType]Node.vue` (e.g., `FormNode.vue`, `ApiNode.vue`)
- **Configuration Modal**: `[NodeType]NodeConfigurationModal.vue`
- **Configuration Component**: `[NodeType]NodeConfiguration.vue`
**Required Node Component Structure:**
```vue
<script setup>
import { Handle, Position } from '@vue-flow/core'
import { useProcessBuilderStore } from '~/stores/processBuilder'
import ValidationIndicator from '../ValidationIndicator.vue'
// Standard props all nodes receive
const props = defineProps([
'id', // Node ID (string)
'type', // Node type (string)
'label', // Node label (string)
'selected', // Selection state (boolean)
'data' // Custom data object
])
// Standard validation integration
const processStore = useProcessBuilderStore()
const validationIssues = computed(() => {
return processStore.getNodeValidation(props.id)
})
// Standard node styling
const nodeStyle = computed(() => ({
'--node-bg-color': props.data?.backgroundColor || '#ffffff',
'--node-border-color': props.data?.borderColor || '#000000',
'--node-text-color': props.data?.textColor || '#000000',
backgroundColor: props.data?.backgroundColor || '#ffffff',
borderColor: props.data?.borderColor || '#000000',
color: props.data?.textColor || '#000000'
}))
// Standard click handler
const emit = defineEmits(['node-click'])
const onClick = () => emit('node-click', props.id)
</script>
<template>
<div
:class="['custom-node', `node-${type}`, `shape-${data?.shape || 'rectangle'}`, { 'selected': selected }]"
:style="nodeStyle"
@click="onClick"
>
<!-- Required: 4-directional handles for all functional nodes -->
<Handle type="target" :position="Position.Top" :id="`${id}-top`" />
<Handle type="target" :position="Position.Left" :id="`${id}-left`" />
<Handle type="source" :position="Position.Right" :id="`${id}-right`" />
<Handle type="source" :position="Position.Bottom" :id="`${id}-bottom`" />
<!-- Required: Validation indicator -->
<ValidationIndicator :node-id="id" :validation-issues="validationIssues" />
<!-- Node content -->
<div class="custom-node-content">
<!-- Node-specific content -->
</div>
</div>
</template>
```
### Node Data Schemas by Type
#### Form Node (`type: 'form'`)
```javascript
data: {
// Required fields
formId: null, // Form ID reference
formName: null, // Form name for display
// Assignment configuration
assignedRoles: [], // Array of role IDs
assignedUsers: [], // Array of user IDs
assignmentType: 'role', // 'role' | 'user' | 'variable'
assignmentVariable: '', // Variable name for dynamic assignment
// Variable mapping
inputMappings: [], // Array of { processVar, formField } mappings
outputMappings: [], // Array of { formField, processVar } mappings
// Timing configuration
dueDate: null, // Due date for task
dueDateVariable: '', // Variable for dynamic due date
priority: 'medium', // 'low' | 'medium' | 'high'
// Visual properties (inherited from base)
shape: 'rectangle',
backgroundColor: '#faf5ff',
borderColor: '#9333ea',
textColor: '#6b21a8'
}
```
#### API Node (`type: 'api'`)
```javascript
data: {
// Required API configuration
apiMethod: 'GET', // HTTP method
apiUrl: '', // API endpoint URL
// Request configuration
requestBody: '', // JSON request body
headers: '{"Content-Type": "application/json"}', // Request headers (JSON string)
// Authentication
authType: 'none', // 'none' | 'bearer' | 'basic' | 'apikey'
authToken: '', // Auth token/API key
authUsername: '', // Basic auth username
authPassword: '', // Basic auth password
// Response handling
outputVariable: 'apiResponse', // Variable to store response
errorVariable: 'apiError', // Variable to store errors
continueOnError: false, // Continue process flow on API error
// Timeout configuration
timeout: 30000, // Request timeout in milliseconds
// Visual properties
shape: 'rectangle',
backgroundColor: '#eff6ff',
borderColor: '#3b82f6',
textColor: '#1e40af'
}
```
#### Gateway Node (`type: 'gateway'`)
```javascript
data: {
// Decision logic
conditions: [], // Array of condition objects:
// [{
// id: string,
// variable: string,
// operator: '==' | '!=' | '>' | '<' | '>=' | '<=' | 'contains' | 'startsWith' | 'endsWith',
// value: any,
// logicalOperator: 'AND' | 'OR',
// path: string // Edge label for this condition
// }]
defaultPath: 'Default', // Default path when no conditions match
evaluationMode: 'sequential', // 'sequential' | 'parallel'
// Visual properties
shape: 'diamond',
backgroundColor: '#fff7ed',
borderColor: '#f97316',
textColor: '#c2410c'
}
```
#### Business Rule Node (`type: 'business-rule'`)
```javascript
data: {
// Rule groups for complex business logic
ruleGroups: [], // Array of rule group objects:
// [{
// id: string,
// name: string,
// operator: 'AND' | 'OR',
// rules: [{
// id: string,
// condition: string, // JavaScript expression
// action: 'SET' | 'CALCULATE' | 'VALIDATE',
// variable: string, // Target variable
// value: any, // Value to set/calculate
// errorMessage: string // Error message for validation
// }]
// }]
priority: 'medium', // Execution priority
executionMode: 'sequential', // 'sequential' | 'parallel'
continueOnError: false, // Continue on rule failure
// Visual properties
shape: 'rectangle',
backgroundColor: '#fdf4ff',
borderColor: '#a855f7',
textColor: '#7c3aed'
}
```
#### Script Node (`type: 'script'`)
```javascript
data: {
// Script configuration
scriptCode: '', // JavaScript code to execute
scriptLanguage: 'javascript', // Currently only 'javascript' supported
// Variable mapping
inputVariables: [], // Array of variable names to pass to script
outputVariables: [], // Array of variable names to extract from script
// Execution configuration
timeout: 30000, // Script execution timeout
sandbox: true, // Execute in sandboxed environment
// Visual properties
shape: 'rectangle',
backgroundColor: '#f9fafb',
borderColor: '#6b7280',
textColor: '#374151'
}
```
#### Notification Node (`type: 'notification'`)
```javascript
data: {
// Notification configuration
notificationType: 'info', // 'info' | 'warning' | 'error' | 'success'
// Recipients
recipientType: 'user', // 'user' | 'role' | 'email' | 'variable'
recipientUser: '', // User ID for 'user' type
recipientRole: '', // Role ID for 'role' type
recipientVariable: '', // Variable name for 'variable' type
recipientEmail: '', // Email address for 'email' type
// Message content
subject: '', // Notification subject/title
message: '', // Notification message body
// Delivery options
deliveryOptions: {
inApp: true, // In-app notification
email: false, // Email notification
sms: false // SMS notification
},
// Timing
priority: 'medium', // 'low' | 'medium' | 'high' | 'urgent'
expiration: {
enabled: false, // Enable expiration
value: 24, // Expiration value
unit: 'hours' // 'minutes' | 'hours' | 'days'
},
// Visual properties
shape: 'rectangle',
backgroundColor: '#f0f9ff',
borderColor: '#0ea5e9',
textColor: '#0284c7'
}
```
#### HTML Node (`type: 'html'`)
```javascript
data: {
// Content configuration
htmlCode: '<!-- HTML content -->', // HTML content
cssCode: '', // CSS styles
jsCode: '', // JavaScript code
// Variable integration
inputVariables: [], // Variables to pass to HTML context
outputVariables: [], // Variables to extract from HTML
allowVariableAccess: true, // Allow HTML to access process variables
// Behavior
autoRefresh: false, // Auto-refresh content
refreshInterval: 30000, // Refresh interval in milliseconds
// Visual properties
shape: 'rectangle',
backgroundColor: '#e0f2fe',
borderColor: '#0ea5e9',
textColor: '#0c4a6e'
}
```
#### Subprocess Node (`type: 'subprocess'`)
```javascript
data: {
// Subprocess configuration
subprocessId: null, // Target subprocess ID
subprocessName: '', // Subprocess name for display
// Variable mapping
inputMappings: [], // Map parent variables to subprocess
outputMappings: [], // Map subprocess variables to parent
// Execution configuration
executionMode: 'synchronous', // 'synchronous' | 'asynchronous'
inheritVariables: true, // Inherit parent process variables
// Visual properties
shape: 'rectangle',
backgroundColor: '#f0fdfa',
borderColor: '#14b8a6',
textColor: '#134e4a'
}
```
### Node Store Management (`/stores/processBuilder.js`)
**Key Node Operations:**
- `addNode(node)`: Add node to process (lines 700-752)
- `updateNode(nodeId, updates)`: Update existing node (lines 757-766)
- `deleteNode(nodeId)`: Remove node and connected edges (lines 771-814)
- `getNodeValidation(nodeId)`: Get validation issues for node (lines 78-80)
**Node State Management:**
- Nodes stored in `currentProcess.nodes` array
- Selection tracked in `selectedNodeId`
- Validation results in `validationResults` Map
- Auto-save triggers on node changes with 2-second debounce
### Adding New Node Types
**Step 1: Define Node Type in ProcessBuilderComponents.vue**
```javascript
// Add to availableComponents array
{
type: 'your-node-type',
name: 'Your Node Name',
category: 'Core',
icon: 'material-symbols:your-icon',
description: 'Node description',
defaultProps: {
label: 'Your Node',
data: {
// Your node-specific data structure
// Always include visual properties:
shape: 'rectangle',
backgroundColor: '#ffffff',
borderColor: '#000000',
textColor: '#000000'
}
}
}
```
**Step 2: Create Node Vue Component**
Create `/components/process-flow/custom/YourNodeTypeNode.vue` following the standard node component structure.
**Step 3: Create Configuration Components**
- `/components/process-flow/YourNodeTypeConfiguration.vue` - Configuration UI
- `/components/process-flow/YourNodeTypeConfigurationModal.vue` - Modal wrapper
**Step 4: Register in Main Process Builder**
Add imports and modal state management in `/pages/process-builder/index.vue`.
**Step 5: Add Validation Rules (Optional)**
Implement validation logic in the process store's validation system.
### Node Connection System
**Handle Configuration:**
- All functional nodes must have 4 directional handles: top, bottom, left, right
- Handle IDs follow pattern: `${nodeId}-${direction}`
- Use Vue Flow's Handle component with proper positioning
- Connection validation via `isValidConnection` callback
**Edge Management:**
- Edges stored in `currentProcess.edges` array
- Edge IDs typically follow pattern: `${sourceNodeId}-${targetNodeId}`
- Support for conditional edges with labels on gateway nodes
- Automatic cleanup when nodes are deleted
### Node Validation System
**Validation Integration:**
- Each node can have validation issues (errors, warnings, info)
- ValidationIndicator component shows issues visually
- Validation results stored in process store's validationResults Map
- Real-time validation updates trigger UI changes
**Common Validation Patterns:**
- Required field validation (formId for form nodes, apiUrl for API nodes)
- Connection validation (start/end node requirements)
- Variable reference validation
- Configuration completeness checks
## PageWrapper Architecture
### Overview
The **PageWrapper** node is a revolutionary approach to page rendering that allows multiple child components (FormNode, HtmlNode, TableNode) to be rendered together as a single page. This replaces the previous one-node-per-page limitation.
### Key Concepts
- **Container-based Rendering**: PageWrapper acts as a container that manages multiple child components
- **Conditional Logic**: Each child component can have its own show/hide conditions based on process variables
- **Variable Mapping**: Independent input/output variable mapping for each child component
- **Flexible Layouts**: Grid, flex, and stacked layout options with responsive design
- **Backward Compatibility**: Existing standalone FormNode and HtmlNode continue to work
### PageWrapper Node Structure
```javascript
{
type: 'page-wrapper',
data: {
// Layout configuration
layout: 'grid', // 'grid' | 'flex' | 'stacked'
columns: 2, // for grid layout
gap: '1rem', // spacing between components
padding: '1rem', // container padding
// Child components configuration
childNodes: [
{
id: 'child-1', // Unique child ID
type: 'form', // 'form' | 'html' | 'table'
nodeId: 'form-123', // Reference to actual form/html/table node
position: { row: 1, col: 1 }, // Grid position (for grid layout)
conditionalLogic: {
enabled: true,
variable: 'showForm',
operator: '==',
value: true
},
variableMapping: {
inputs: [{ processVar: 'userName', childVar: 'name' }],
outputs: [{ childVar: 'email', processVar: 'userEmail' }]
}
}
],
// Page-level settings
title: 'Multi-Component Page',
backgroundColor: '#ffffff',
customCSS: '', // Page-level CSS
customJS: '', // Page-level JavaScript
pageVariables: {} // Page-scoped variables
}
}
```
### Child Component Types
#### Form Child Component
- **Purpose**: Renders a form within the PageWrapper
- **Configuration**: References an existing form by `nodeId`
- **Variable Mapping**: Maps process variables to form fields and vice versa
- **Conditional Display**: Can be shown/hidden based on process variables
#### HTML Child Component
- **Purpose**: Renders HTML content within the PageWrapper
- **Configuration**: Contains HTML, CSS, and JavaScript code
- **Variable Integration**: HTML can access and update process variables
- **Interactive Elements**: Supports buttons, forms, and custom interactions
#### Table Child Component (Future)
- **Purpose**: Renders tabular data within the PageWrapper
- **Configuration**: Data source, columns, filtering, sorting options
- **Actions**: Row selection, inline editing, custom actions
### Implementation Architecture
#### Core Components
1. **PageWrapperNode.vue** (`/components/process-flow/custom/`)
- Visual representation in process builder
- Shows child component count and layout type
- Handles selection and configuration access
2. **PageWrapperConfiguration.vue** (`/components/process-flow/`)
- Configuration UI for PageWrapper settings
- Child component management (add, remove, configure)
- Layout and styling options
3. **PageWrapperConfigurationModal.vue** (`/components/process-flow/`)
- Modal wrapper for configuration
- Layout preview functionality
- Integration with process builder
4. **PageWrapperRenderer.vue** (`/components/process-flow/`)
- Runtime rendering engine for PageWrapper execution
- Handles child component rendering and layout
- Manages variable mapping and conditional logic
- Coordinates form submissions and HTML interactions
#### Child Renderers
1. **ChildFormRenderer.vue** (`/components/process-flow/`)
- Renders form components within PageWrapper
- Handles form validation and submission
- Applies input/output variable mappings
- Supports conditional logic and field states
2. **ChildHtmlRenderer.vue** (`/components/process-flow/`)
- Renders HTML content within PageWrapper
- Executes custom CSS and JavaScript
- Handles HTML interactions and variable updates
- Provides safe execution context for custom scripts
### Process Store Integration
#### Parent-Child Node Management
- `addChildNode(parentId, childNodeConfig)`: Add child to PageWrapper
- `updateChildNode(parentId, childId, updates)`: Update child configuration
- `removeChildNode(parentId, childId)`: Remove child from PageWrapper
- `getChildNodes(parentId)`: Get all children of PageWrapper
- `getChildNode(parentId, childId)`: Get specific child configuration
#### Enhanced Node Operations
- `deleteNodeWithChildren(nodeId)`: Handles PageWrapper deletion with cleanup
- `isChildNode(nodeId)`: Check if node is referenced as child
- `getParentNodeId(childNodeId)`: Find parent PageWrapper for child reference
### Workflow Execution Integration
#### Execution Flow
1. **Process Reaches PageWrapper**: Node type 'page-wrapper' detected
2. **Child Component Loading**: Each child component loads its referenced content
3. **Variable Mapping**: Process variables mapped to child components as inputs
4. **Conditional Rendering**: Child components shown/hidden based on conditions
5. **User Interaction**: Users interact with forms, HTML, tables simultaneously
6. **Data Collection**: Variable mappings collect outputs from child components
7. **Page Submission**: All child data merged and process continues
#### Event Handling
- `handlePageWrapperSubmit`: Processes page-level submission
- `handleChildFormSubmit`: Handles individual form submissions within page
- `handleChildHtmlAction`: Processes HTML interactions (buttons, forms)
- `handleVariableUpdate`: Updates process variables from child components
- `handleValidationError`: Manages validation errors across child components
### Layout System
#### Grid Layout
- **Columns**: Configurable number of columns (1-6)
- **Positioning**: Child components specify row and column
- **Responsive**: Automatically stacks on mobile devices
- **Gap**: Configurable spacing between grid items
#### Flex Layout
- **Direction**: Horizontal with wrap support
- **Responsive**: Components adapt to available width
- **Alignment**: Flexible arrangement with equal spacing
- **Mobile-friendly**: Stacks vertically on small screens
#### Stacked Layout
- **Vertical**: All components arranged vertically
- **Full-width**: Each component takes full container width
- **Spacing**: Consistent vertical spacing between components
- **Simple**: No complex positioning required
### Variable Management
#### Input Mapping
- Maps process variables to child component fields
- Applied when PageWrapper loads
- Supports type conversion and default values
- Real-time updates when process variables change
#### Output Mapping
- Maps child component fields to process variables
- Applied on form submission or HTML interaction
- Supports data transformation and validation
- Merges data from multiple child components
#### Page-level Variables
- Scoped to the PageWrapper instance
- Shared between child components
- Can be used for component coordination
- Persisted during page interaction
### Conditional Logic System
#### Variable-based Conditions
- Show/hide child components based on process variables
- Supports multiple operators: ==, !=, >, <, >=, <=
- Works with string, number, and boolean values
- Real-time evaluation when variables change
#### Complex Logic (Future)
- AND/OR combinations
- Multiple condition groups
- JavaScript expressions
- Time-based conditions
### Migration Strategy
#### From Standalone Nodes
1. **Assessment**: Identify processes with sequential form/HTML nodes
2. **Grouping**: Group related nodes into logical pages
3. **Configuration**: Create PageWrapper with child components
4. **Variable Mapping**: Set up input/output mappings
5. **Testing**: Verify functionality matches original flow
#### Backward Compatibility
- Existing processes continue to work unchanged
- Standalone FormNode and HtmlNode still supported
- Gradual migration path available
- No breaking changes to existing functionality
### Development Guidelines
#### Creating Child Components
1. **Reference Existing Nodes**: Child components reference existing form/HTML nodes
2. **Variable Mapping**: Always configure input/output mappings
3. **Conditional Logic**: Use for dynamic UI behavior
4. **Layout Consideration**: Design for responsive layouts
5. **Testing**: Test with various screen sizes and variable combinations
#### Performance Considerations
- **Lazy Loading**: Child components load content on demand
- **Conditional Rendering**: Hidden components don't render
- **Variable Watching**: Efficient reactivity system
- **Memory Management**: Proper cleanup on component destruction
#### Best Practices
- **Logical Grouping**: Group related functionality into single PageWrapper
- **Clear Naming**: Use descriptive titles and IDs for child components
- **Variable Scope**: Use appropriate variable mapping to avoid conflicts
- **Layout Planning**: Consider mobile-first design approach
- **Testing**: Validate with realistic data and user scenarios

View File

@ -0,0 +1,367 @@
<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>

View File

@ -0,0 +1,494 @@
<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>

View File

@ -0,0 +1,882 @@
<template>
<div class="page-wrapper-configuration">
<!-- Page Information -->
<div class="mb-6">
<h4 class="text-sm font-medium text-gray-700 mb-3 flex items-center">
<Icon name="material-symbols:info-outline" class="w-4 h-4 mr-2 text-blue-500" />
Page Information
</h4>
<!-- Page Title -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Page Title</label>
<input
type="text"
v-model="localData.title"
placeholder="e.g., Customer Registration, Dashboard, Settings"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
@input="emitUpdate"
/>
</div>
<!-- Description -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">
Description
<span class="text-xs text-gray-500">(Optional)</span>
</label>
<textarea
v-model="localData.description"
placeholder="Brief description of what this page does..."
rows="2"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
@input="emitUpdate"
></textarea>
</div>
</div>
<!-- Layout Selection -->
<div class="mb-6">
<h4 class="text-sm font-medium text-gray-700 mb-3 flex items-center">
<Icon name="material-symbols:view-module" class="w-4 h-4 mr-2 text-blue-500" />
How should components be arranged?
</h4>
<!-- Visual Layout Selection -->
<div class="grid grid-cols-1 gap-3">
<!-- Grid Layout -->
<div
:class="[
'layout-option',
localData.layout === 'grid' ? 'selected' : ''
]"
@click="selectLayout('grid')"
>
<div class="layout-preview">
<div class="grid-preview">
<div class="preview-item"></div>
<div class="preview-item"></div>
<div class="preview-item"></div>
<div class="preview-item"></div>
</div>
</div>
<div class="layout-info">
<h5 class="font-medium text-gray-800">Organized Rows & Columns</h5>
<p class="text-xs text-gray-600">Perfect for forms, cards, or structured content</p>
</div>
<div class="layout-radio">
<input
type="radio"
:checked="localData.layout === 'grid'"
class="w-4 h-4 text-blue-600 focus:ring-blue-500"
readonly
/>
</div>
</div>
<!-- Flex Layout -->
<div
:class="[
'layout-option',
localData.layout === 'flex' ? 'selected' : ''
]"
@click="selectLayout('flex')"
>
<div class="layout-preview">
<div class="flex-preview">
<div class="preview-item"></div>
<div class="preview-item"></div>
<div class="preview-item"></div>
</div>
</div>
<div class="layout-info">
<h5 class="font-medium text-gray-800">Side by Side</h5>
<p class="text-xs text-gray-600">Components arranged horizontally, responsive</p>
</div>
<div class="layout-radio">
<input
type="radio"
:checked="localData.layout === 'flex'"
class="w-4 h-4 text-blue-600 focus:ring-blue-500"
readonly
/>
</div>
</div>
<!-- Stacked Layout -->
<div
:class="[
'layout-option',
localData.layout === 'stacked' ? 'selected' : ''
]"
@click="selectLayout('stacked')"
>
<div class="layout-preview">
<div class="stacked-preview">
<div class="preview-item"></div>
<div class="preview-item"></div>
<div class="preview-item"></div>
</div>
</div>
<div class="layout-info">
<h5 class="font-medium text-gray-800">One Below Another</h5>
<p class="text-xs text-gray-600">Components stacked vertically, great for mobile</p>
</div>
<div class="layout-radio">
<input
type="radio"
:checked="localData.layout === 'stacked'"
class="w-4 h-4 text-blue-600 focus:ring-blue-500"
readonly
/>
</div>
</div>
</div>
</div>
<!-- Layout-Specific Settings -->
<div class="mb-6" v-if="localData.layout">
<h4 class="text-sm font-medium text-gray-700 mb-3 flex items-center">
<Icon name="material-symbols:tune" class="w-4 h-4 mr-2 text-blue-500" />
Layout Settings
</h4>
<!-- Grid Columns (only for grid layout) -->
<div v-if="localData.layout === 'grid'" class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">
How many columns?
<span class="text-xs text-gray-500">(1-6 columns)</span>
</label>
<div class="grid grid-cols-6 gap-2">
<button
v-for="num in 6"
:key="num"
:class="[
'column-selector',
localData.columns === num ? 'selected' : ''
]"
@click="selectColumns(num)"
>
{{ num }}
</button>
</div>
</div>
<!-- Component Spacing -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">
Space between components
<span class="text-xs text-gray-500">(How much gap between each component)</span>
</label>
<div class="spacing-options">
<div
v-for="option in spacingOptions"
:key="option.value"
:class="[
'spacing-option',
localData.gap === option.value ? 'selected' : ''
]"
@click="selectSpacing(option.value)"
>
<div class="spacing-preview" :style="{ gap: option.preview }">
<div class="spacing-item"></div>
<div class="spacing-item"></div>
</div>
<span class="spacing-label">{{ option.label }}</span>
</div>
</div>
</div>
<!-- Container Padding -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">
Space around content
<span class="text-xs text-gray-500">(Padding inside the container)</span>
</label>
<div class="spacing-options">
<div
v-for="option in paddingOptions"
:key="option.value"
:class="[
'spacing-option',
localData.padding === option.value ? 'selected' : ''
]"
@click="selectPadding(option.value)"
>
<div class="padding-preview">
<div class="padding-container" :style="{ padding: option.preview }">
<div class="padding-content"></div>
</div>
</div>
<span class="spacing-label">{{ option.label }}</span>
</div>
</div>
</div>
</div>
<!-- Components in this Page -->
<div class="mb-6">
<h4 class="text-sm font-medium text-gray-700 mb-3 flex items-center">
<Icon name="material-symbols:widgets" class="w-4 h-4 mr-2 text-blue-500" />
Components in this Page
</h4>
<!-- Drag and Drop Instruction -->
<div v-if="!localData.childNodes || localData.childNodes.length === 0" class="drag-instruction">
<div class="drag-drop-area">
<Icon name="material-symbols:drag-indicator" class="w-8 h-8 text-gray-300 mb-2" />
<h5 class="font-medium text-gray-600 mb-1">Add Components</h5>
<p class="text-sm text-gray-500 mb-3">
Drag <strong>Form</strong>, <strong>HTML</strong>, or <strong>Table</strong> nodes from the component palette into the PageWrapper container on the canvas.
</p>
<div class="flex items-center justify-center space-x-4 text-xs text-gray-400">
<div class="flex items-center">
<Icon name="material-symbols:description-outline" class="w-4 h-4 mr-1" />
Form
</div>
<div class="flex items-center">
<Icon name="material-symbols:code" class="w-4 h-4 mr-1" />
HTML
</div>
<div class="flex items-center">
<Icon name="material-symbols:table" class="w-4 h-4 mr-1" />
Table
</div>
</div>
</div>
</div>
<!-- Component List -->
<div v-else class="space-y-3">
<div class="component-list-header">
<p class="text-sm text-gray-600 mb-3">
<Icon name="material-symbols:check-circle" class="w-4 h-4 mr-1 text-green-500 inline" />
{{ localData.childNodes.length }} component{{ localData.childNodes.length !== 1 ? 's' : '' }} added
</p>
</div>
<div
v-for="(childNode, index) in localData.childNodes"
:key="childNode.id || index"
class="component-item"
>
<div class="component-header">
<div class="component-info">
<Icon :name="getChildNodeIcon(childNode.type)" class="w-5 h-5 mr-3 text-blue-500" />
<div>
<h5 class="font-medium text-gray-800">{{ getChildNodeTypeName(childNode.type) }}</h5>
<p class="text-xs text-gray-500">{{ childNode.nodeId || 'No ID specified' }}</p>
</div>
</div>
<button
@click="removeChildNode(index)"
class="remove-btn"
title="Remove component"
>
<Icon name="material-symbols:close" class="w-4 h-4" />
</button>
</div>
<!-- Component Settings -->
<div class="component-settings" v-if="showComponentSettings[index]">
<div class="settings-grid">
<!-- Component ID -->
<div class="setting-item">
<label class="setting-label">Component Reference</label>
<input
type="text"
v-model="childNode.nodeId"
placeholder="e.g., user-form, contact-info"
class="setting-input"
@input="emitUpdate"
/>
<p class="setting-help">Unique identifier for this component</p>
</div>
<!-- Grid Position (for grid layout) -->
<div v-if="localData.layout === 'grid'" class="setting-item col-span-2">
<label class="setting-label">Position in Grid</label>
<div class="grid grid-cols-2 gap-2">
<div>
<input
type="number"
v-model.number="childNode.position.row"
placeholder="Row"
min="1"
class="setting-input"
@input="emitUpdate"
/>
<p class="text-xs text-gray-500">Row</p>
</div>
<div>
<input
type="number"
v-model.number="childNode.position.col"
placeholder="Column"
min="1"
:max="localData.columns"
class="setting-input"
@input="emitUpdate"
/>
<p class="text-xs text-gray-500">Column</p>
</div>
</div>
</div>
<!-- Conditional Logic -->
<div class="setting-item col-span-2">
<div class="flex items-center mb-2">
<input
type="checkbox"
:id="`conditional-${index}`"
v-model="childNode.conditionalLogic.enabled"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
@change="emitUpdate"
/>
<label :for="`conditional-${index}`" class="ml-2 block text-sm font-medium text-gray-600">
Show/hide based on conditions
</label>
</div>
<div v-if="childNode.conditionalLogic.enabled" class="conditional-settings">
<p class="text-xs text-gray-500 mb-3">Show this component when:</p>
<div class="space-y-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Variable</label>
<VariableBrowser
v-model="childNode.conditionalLogic.variable"
:availableVariables="availableVariables"
placeholder="Select variable"
:allowCreate="false"
@update:modelValue="emitUpdate"
/>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Condition</label>
<select
v-model="childNode.conditionalLogic.operator"
class="setting-input text-xs"
@change="emitUpdate"
>
<option value="==">equals</option>
<option value="!=">doesn't equal</option>
<option value=">">is greater than</option>
<option value="<">is less than</option>
<option value=">=">greater or equal</option>
<option value="<=">less or equal</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Value</label>
<input
type="text"
v-model="childNode.conditionalLogic.value"
placeholder="Enter value"
class="setting-input text-xs"
@input="emitUpdate"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Settings Toggle -->
<button
@click="toggleComponentSettings(index)"
class="settings-toggle"
>
<Icon
:name="showComponentSettings[index] ? 'material-symbols:expand-less' : 'material-symbols:expand-more'"
class="w-4 h-4 mr-1"
/>
{{ showComponentSettings[index] ? 'Hide Settings' : 'Show Settings' }}
</button>
</div>
</div>
</div>
<!-- Advanced Options -->
<div class="mb-6">
<button
@click="showAdvancedOptions = !showAdvancedOptions"
class="advanced-toggle"
>
<Icon name="material-symbols:settings" class="w-4 h-4 mr-2" />
<span>Advanced Options</span>
<Icon
:name="showAdvancedOptions ? 'material-symbols:expand-less' : 'material-symbols:expand-more'"
class="w-4 h-4 ml-2"
/>
</button>
<div v-if="showAdvancedOptions" class="advanced-options">
<!-- Background Color -->
<div class="advanced-setting">
<label class="advanced-label">Background Color</label>
<div class="color-picker-container">
<input
type="color"
v-model="localData.backgroundColor"
class="color-picker"
@input="emitUpdate"
/>
<span class="color-value">{{ localData.backgroundColor }}</span>
</div>
</div>
<!-- Custom CSS -->
<div class="advanced-setting">
<label class="advanced-label">Custom CSS Styling</label>
<textarea
v-model="localData.customCSS"
placeholder="/* Add custom CSS styles here */&#10;.my-custom-class {&#10; margin: 10px;&#10; padding: 20px;&#10;}"
rows="4"
class="code-textarea"
@input="emitUpdate"
></textarea>
<p class="advanced-help">Add custom CSS to style your page components</p>
</div>
<!-- Custom JavaScript -->
<div class="advanced-setting">
<label class="advanced-label">Custom JavaScript</label>
<textarea
v-model="localData.customJS"
placeholder="// Add custom JavaScript here&#10;console.log('Page loaded');&#10;&#10;// Access process variables&#10;// const userRole = processVariables.userRole;"
rows="4"
class="code-textarea"
@input="emitUpdate"
></textarea>
<p class="advanced-help">Add custom JavaScript for dynamic behavior</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { Icon } from '#components'
import VariableBrowser from './VariableBrowser.vue'
const props = defineProps({
nodeData: {
type: Object,
required: true
},
availableVariables: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['update'])
// Reactive data
const showAdvancedOptions = ref(false)
const showComponentSettings = ref({})
// Local data copy to prevent direct prop mutation
const localData = ref({
title: 'Multi-Component Page',
description: 'Multi-component page container',
layout: 'grid',
columns: 2,
gap: '1rem',
padding: '1rem',
childNodes: [],
backgroundColor: '#ffffff',
customCSS: '',
customJS: '',
pageVariables: {},
...props.nodeData
})
// Spacing and padding options for visual selection
const spacingOptions = [
{ label: 'None', value: '0', preview: '0px' },
{ label: 'Small', value: '0.5rem', preview: '4px' },
{ label: 'Medium', value: '1rem', preview: '8px' },
{ label: 'Large', value: '1.5rem', preview: '12px' },
{ label: 'Extra Large', value: '2rem', preview: '16px' }
]
const paddingOptions = [
{ label: 'None', value: '0', preview: '0px' },
{ label: 'Small', value: '0.5rem', preview: '2px' },
{ label: 'Medium', value: '1rem', preview: '4px' },
{ label: 'Large', value: '1.5rem', preview: '6px' },
{ label: 'Extra Large', value: '2rem', preview: '8px' }
]
// Layout selection methods
const selectLayout = (layoutType) => {
localData.value.layout = layoutType
// Set smart defaults based on layout
if (layoutType === 'grid') {
localData.value.columns = localData.value.columns || 2
}
emitUpdate()
}
const selectColumns = (numColumns) => {
localData.value.columns = numColumns
emitUpdate()
}
const selectSpacing = (spacing) => {
localData.value.gap = spacing
emitUpdate()
}
const selectPadding = (padding) => {
localData.value.padding = padding
emitUpdate()
}
// Component settings management
const toggleComponentSettings = (index) => {
showComponentSettings.value[index] = !showComponentSettings.value[index]
}
// Generate unique ID for child nodes
const generateChildNodeId = () => {
return `child-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
}
// Remove child node
const removeChildNode = (index) => {
localData.value.childNodes.splice(index, 1)
// Clean up settings state
delete showComponentSettings.value[index]
emitUpdate()
}
// Get child node type icon
const getChildNodeIcon = (type) => {
const iconMap = {
form: 'material-symbols:description-outline',
html: 'material-symbols:code',
table: 'material-symbols:table'
}
return iconMap[type] || 'material-symbols:extension'
}
// Get child node type display name
const getChildNodeTypeName = (type) => {
const nameMap = {
form: 'Form Component',
html: 'HTML Content',
table: 'Table Component'
}
return nameMap[type] || 'Unknown Component'
}
// Emit update event
const emitUpdate = () => {
emit('update', { ...localData.value })
}
// Initialize child nodes with proper structure
const initializeChildNodes = () => {
if (localData.value.childNodes) {
localData.value.childNodes = localData.value.childNodes.map(child => ({
...child,
conditionalLogic: {
enabled: false,
variable: '',
operator: '==',
value: '',
...child.conditionalLogic
},
position: {
row: 1,
col: 1,
...child.position
}
}))
}
}
// Watch for prop changes
watch(() => props.nodeData, (newData) => {
localData.value = { ...localData.value, ...newData }
initializeChildNodes()
}, { deep: true, immediate: true })
</script>
<style scoped>
.page-wrapper-configuration {
/* max-height: 600px; */
overflow-y: auto;
}
/* Custom scrollbar */
.page-wrapper-configuration::-webkit-scrollbar {
width: 6px;
}
.page-wrapper-configuration::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.page-wrapper-configuration::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.page-wrapper-configuration::-webkit-scrollbar-thumb:hover {
background: #a1a1a1;
}
/* Layout Selection Styles */
.layout-option {
@apply border border-gray-200 rounded-lg p-4 cursor-pointer transition-all duration-200 bg-white hover:border-blue-300 hover:shadow-sm;
}
.layout-option.selected {
@apply border-blue-500 bg-blue-50 shadow-sm;
}
.layout-option {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 12px;
align-items: center;
}
.layout-preview {
width: 48px;
height: 32px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 4px;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.grid-preview {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px;
width: 100%;
height: 100%;
}
.flex-preview {
display: flex;
gap: 2px;
width: 100%;
height: 100%;
}
.stacked-preview {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
height: 100%;
}
.preview-item {
background: #3b82f6;
border-radius: 1px;
min-height: 6px;
flex: 1;
}
.layout-info h5 {
margin: 0 0 2px 0;
font-size: 14px;
}
.layout-info p {
margin: 0;
line-height: 1.3;
}
.layout-radio input {
pointer-events: none;
}
/* Column Selector */
.column-selector {
@apply w-10 h-10 border border-gray-300 rounded-md flex items-center justify-center text-sm font-medium transition-all duration-200 bg-white hover:border-blue-400;
}
.column-selector.selected {
@apply bg-blue-500 text-white border-blue-500;
}
/* Spacing Options */
.spacing-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
gap: 8px;
}
.spacing-option {
@apply border border-gray-200 rounded-lg p-3 cursor-pointer transition-all duration-200 bg-white hover:border-blue-300 text-center;
}
.spacing-option.selected {
@apply border-blue-500 bg-blue-50;
}
.spacing-preview {
display: flex;
align-items: center;
justify-content: center;
height: 20px;
margin-bottom: 6px;
}
.spacing-item {
width: 12px;
height: 12px;
background: #94a3b8;
border-radius: 2px;
}
.padding-preview {
height: 20px;
margin-bottom: 6px;
display: flex;
align-items: center;
justify-content: center;
}
.padding-container {
border: 1px solid #94a3b8;
border-radius: 2px;
width: 20px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.padding-content {
width: 8px;
height: 8px;
background: #94a3b8;
border-radius: 1px;
}
.spacing-label {
font-size: 12px;
font-weight: 500;
color: #4b5563;
}
/* Drag Instruction */
.drag-instruction {
margin: 16px 0;
}
.drag-drop-area {
@apply border-2 border-dashed border-gray-300 rounded-lg p-6 text-center bg-gray-50;
}
/* Component List */
.component-item {
@apply border border-gray-200 rounded-lg bg-white transition-all duration-200;
}
.component-header {
@apply p-4 flex items-center justify-between;
}
.component-info {
@apply flex items-center flex-1;
}
.component-info h5 {
@apply text-sm font-medium text-gray-800 m-0;
}
.component-info p {
@apply text-xs text-gray-500 m-0;
}
.remove-btn {
@apply w-8 h-8 flex items-center justify-center rounded-md text-gray-400 hover:text-red-600 hover:bg-red-50 transition-all duration-200;
}
/* Component Settings */
.component-settings {
@apply border-t border-gray-100 p-4 bg-gray-50;
}
.settings-grid {
@apply grid grid-cols-2 gap-4;
}
.setting-item {
@apply space-y-2;
}
.setting-label {
@apply block text-sm font-medium text-gray-700;
}
.setting-input {
@apply w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500;
}
.setting-help {
@apply text-xs text-gray-500 m-0;
}
.conditional-settings {
@apply p-4 bg-white border border-gray-200 rounded-md mt-2;
}
.settings-toggle {
@apply w-full p-2 text-sm text-gray-600 hover:text-blue-600 hover:bg-blue-50 transition-all duration-200 flex items-center justify-center border-t border-gray-100;
}
/* Advanced Options */
.advanced-toggle {
@apply w-full p-3 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium text-gray-700 flex items-center justify-center transition-all duration-200;
}
.advanced-options {
@apply mt-4 space-y-4 p-4 bg-gray-50 rounded-lg border border-gray-200;
}
.advanced-setting {
@apply space-y-2;
}
.advanced-label {
@apply block text-sm font-medium text-gray-700;
}
.color-picker-container {
@apply flex items-center space-x-3;
}
.color-picker {
@apply w-12 h-10 border border-gray-300 rounded-md cursor-pointer;
}
.color-value {
@apply text-sm font-mono text-gray-600;
}
.code-textarea {
@apply w-full px-3 py-2 border border-gray-300 rounded-md text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-900 text-green-400;
}
.advanced-help {
@apply text-xs text-gray-500 m-0;
}
</style>

View File

@ -0,0 +1,226 @@
<template>
<RsModal
v-model="showModal"
title="Page Wrapper Configuration"
size="xl"
position="center"
:okCallback="saveAndClose"
okTitle="Save"
:cancelCallback="closeModal"
>
<template #body>
<div class="mb-6">
<div class="flex items-start">
<div class="mr-4 text-gray-500 flex-shrink-0 mt-1">
<Icon name="material-symbols:web-asset" class="text-2xl" />
</div>
<div>
<h3 class="text-lg font-semibold mb-1">Configure Page Wrapper</h3>
<p class="text-sm text-gray-600">
Create a multi-component page by adding forms, HTML content, and tables. Configure layout,
conditional logic, and variable mappings for each child component.
</p>
</div>
</div>
</div>
<!-- Main configuration area -->
<PageWrapperConfiguration
ref="pageWrapperConfigRef"
:nodeData="localNodeData"
:availableVariables="availableVariables"
@update="handleUpdate"
/>
<!-- Quick Reference Guide -->
<div class="mt-6 bg-blue-50 p-4 rounded-md border border-blue-100">
<h4 class="font-medium text-blue-700 mb-2 flex items-center">
<Icon name="material-symbols:info-outline" class="mr-1" />
Quick Reference Guide
</h4>
<div class="text-sm text-blue-700">
<ul class="list-disc list-inside space-y-1">
<li><strong>Grid Layout:</strong> Organize components in rows and columns</li>
<li><strong>Flex Layout:</strong> Responsive horizontal or vertical arrangement</li>
<li><strong>Stacked Layout:</strong> Components arranged vertically</li>
<li><strong>Conditional Logic:</strong> Show/hide components based on process variables</li>
<li><strong>Variable Mapping:</strong> Pass data between process and child components</li>
</ul>
</div>
</div>
<!-- Layout Preview -->
<div class="mt-6 bg-gray-50 p-4 rounded-md border border-gray-200">
<h4 class="font-medium text-gray-700 mb-2 flex items-center">
<Icon name="material-symbols:preview" class="mr-1" />
Layout Preview
</h4>
<div class="bg-white border border-gray-200 rounded p-3 min-h-24">
<div v-if="localNodeData.childNodes && localNodeData.childNodes.length > 0">
<!-- Grid Preview -->
<div
v-if="localNodeData.layout === 'grid'"
class="grid gap-2"
:style="{
gridTemplateColumns: `repeat(${localNodeData.columns || 2}, 1fr)`,
gap: previewGap
}"
>
<div
v-for="(child, index) in localNodeData.childNodes"
:key="child.id || index"
class="bg-blue-100 border border-blue-200 rounded p-2 text-xs text-blue-700 text-center"
:style="{
gridRow: child.position?.row || 1,
gridColumn: child.position?.col || 1
}"
>
<Icon :name="getChildNodeIcon(child.type)" class="w-3 h-3 mx-auto mb-1" />
{{ getChildNodeTypeName(child.type) }}
</div>
</div>
<!-- Flex Preview -->
<div
v-else-if="localNodeData.layout === 'flex'"
class="flex flex-wrap"
:style="{ gap: previewGap }"
>
<div
v-for="(child, index) in localNodeData.childNodes"
:key="child.id || index"
class="bg-blue-100 border border-blue-200 rounded p-2 text-xs text-blue-700 text-center flex-1 min-w-32"
>
<Icon :name="getChildNodeIcon(child.type)" class="w-3 h-3 mx-auto mb-1" />
{{ getChildNodeTypeName(child.type) }}
</div>
</div>
<!-- Stacked Preview -->
<div
v-else
class="space-y-2"
:style="{ gap: previewGap }"
>
<div
v-for="(child, index) in localNodeData.childNodes"
:key="child.id || index"
class="bg-blue-100 border border-blue-200 rounded p-2 text-xs text-blue-700 text-center"
>
<Icon :name="getChildNodeIcon(child.type)" class="w-3 h-3 mx-auto mb-1" />
{{ getChildNodeTypeName(child.type) }}
</div>
</div>
</div>
<div v-else class="text-center text-gray-400 py-4">
<Icon name="material-symbols:dashboard" class="w-8 h-8 mx-auto mb-2" />
<p class="text-sm">No child components to preview</p>
</div>
</div>
</div>
</template>
</RsModal>
</template>
<script setup>
import { ref, watch, computed } from 'vue';
import PageWrapperConfiguration from './PageWrapperConfiguration.vue';
import { Icon } from '#components';
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
nodeData: {
type: Object,
required: true
},
availableVariables: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['update:modelValue', 'update']);
const showModal = ref(props.modelValue);
const localNodeData = ref({ ...props.nodeData });
const pageWrapperConfigRef = ref(null);
// Watch for changes to modelValue prop to sync modal visibility
watch(() => props.modelValue, (value) => {
showModal.value = value;
});
// Watch for changes to showModal to emit update:modelValue
watch(() => showModal.value, (value) => {
emit('update:modelValue', value);
});
// Watch for changes to nodeData prop
watch(() => props.nodeData, (value) => {
// Create deep copy to prevent reactivity issues
localNodeData.value = {
...value,
childNodes: Array.isArray(value.childNodes)
? value.childNodes.map(child => ({ ...child }))
: []
};
}, { deep: true, immediate: true });
// Handle updates from the configuration component
const handleUpdate = (updatedData) => {
localNodeData.value = { ...localNodeData.value, ...updatedData };
};
// Save and close modal
const saveAndClose = () => {
emit('update', localNodeData.value);
showModal.value = false;
};
// Close modal without saving
const closeModal = () => {
showModal.value = false;
};
// Get child node type icon
const getChildNodeIcon = (type) => {
const iconMap = {
form: 'material-symbols:description-outline',
html: 'material-symbols:code',
table: 'material-symbols:table'
}
return iconMap[type] || 'material-symbols:extension'
}
// Get child node type display name
const getChildNodeTypeName = (type) => {
const nameMap = {
form: 'Form',
html: 'HTML',
table: 'Table'
}
return nameMap[type] || 'Unknown'
}
// Convert gap value for preview
const previewGap = computed(() => {
const gap = localNodeData.value.gap || '1rem';
// Convert rem values to pixels for preview (assuming 16px = 1rem)
if (gap.includes('rem')) {
const value = parseFloat(gap);
return `${value * 16}px`;
}
return gap;
});
</script>
<style scoped>
/* Modal content styling */
.modal-content {
max-height: 80vh;
overflow-y: auto;
}
</style>

View File

@ -0,0 +1,565 @@
<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>

View File

@ -281,6 +281,43 @@ const availableComponents = [
}
}
},
{
type: 'page-wrapper',
name: 'Page Wrapper',
category: 'Core',
icon: 'material-symbols:web-asset',
description: 'Container for multiple components (forms, HTML, tables) to render as a single page',
defaultProps: {
label: 'Page Wrapper',
data: {
description: 'Multi-component page container',
// Layout configuration
layout: 'grid', // 'grid' | 'flex' | 'stacked'
columns: 2, // for grid layout
gap: '1rem', // spacing between child components
padding: '1rem', // container padding
// Child nodes array
childNodes: [], // Array of child node configurations
// Page-level settings
title: 'Multi-Component Page',
backgroundColor: '#ffffff',
customCSS: '',
customJS: '',
// Variable management
pageVariables: {}, // Page-scoped variables
// Visual properties
shape: 'rectangle',
backgroundColor: '#f8f9fa',
borderColor: '#6c757d',
textColor: '#495057'
}
}
},
// Design Elements / Shapes
{

View File

@ -8,6 +8,7 @@ import {
defineExpose,
nextTick,
markRaw,
provide,
} from "vue";
import { VueFlow, useVueFlow, Panel } from "@vue-flow/core";
import { Background } from "@vue-flow/background";
@ -34,6 +35,7 @@ import SwimlaneHorizontal from "~/components/process-flow/custom/SwimlaneHorizon
import SwimlaneVertical from "~/components/process-flow/custom/SwimlaneVertical.vue";
import TextAnnotation from "~/components/process-flow/custom/TextAnnotation.vue";
import ProcessGroup from "~/components/process-flow/custom/ProcessGroup.vue";
import PageWrapperNode from "~/components/process-flow/custom/PageWrapperNode.vue";
import "@vue-flow/core/dist/style.css";
import "@vue-flow/core/dist/theme-default.css";
import "@vue-flow/controls/dist/style.css";
@ -59,6 +61,7 @@ const customNodeTypes = {
"swimlane-vertical": markRaw(SwimlaneVertical),
"text-annotation": markRaw(TextAnnotation),
"process-group": markRaw(ProcessGroup),
"page-wrapper": markRaw(PageWrapperNode),
};
// Add Material Icons import
@ -93,6 +96,10 @@ const emit = defineEmits([
"edgeSelected",
"selectionChange",
"nodesSelection",
"child-click",
"header-click",
"child-add",
"child-drop",
]);
// Get the flow instance
@ -305,6 +312,35 @@ const onPaneClick = () => {
emit("paneClick");
};
// Handle child node events from PageWrapper
const onChildNodeClick = (eventData) => {
console.log('Canvas received child-click:', eventData);
emit("child-click", eventData);
};
const onPageWrapperHeaderClick = (nodeId) => {
console.log('Canvas received header-click:', nodeId);
emit("header-click", nodeId);
};
const onChildAdd = (parentId) => {
console.log('Canvas received child-add:', parentId);
emit("child-add", parentId);
};
const onChildDrop = (eventData) => {
console.log('Canvas received child-drop:', eventData);
emit("child-drop", eventData);
};
// Provide child node event handlers to PageWrapper components
provide('childNodeEventHandlers', {
onChildNodeClick,
onPageWrapperHeaderClick,
onChildAdd,
onChildDrop,
});
// Window resize handler
const resizeFlow = () => {
setTimeout(() => {

View File

@ -0,0 +1,800 @@
<script setup>
import { Handle, Position } from '@vue-flow/core'
import { useProcessBuilderStore } from '~/stores/processBuilder'
import ValidationIndicator from '../ValidationIndicator.vue'
import { inject } from 'vue'
// Define props that Vue Flow passes to custom nodes
const props = defineProps([
'id', // Node ID
'type', // Node type
'label', // Node label
'selected', // Selection state
'data' // Custom data object
])
// Validation functionality - get from store
const processStore = useProcessBuilderStore()
// Inject child node event handlers from ProcessFlowCanvas
const childNodeEventHandlers = inject('childNodeEventHandlers', {
onChildNodeClick: () => console.warn('Child node event handler not available'),
onPageWrapperHeaderClick: () => console.warn('Header click event handler not available'),
onChildAdd: () => console.warn('Child add event handler not available'),
onChildDrop: () => console.warn('Child drop event handler not available'),
})
// Get validation issues for this node
const validationIssues = computed(() => {
return processStore.getNodeValidation(props.id)
})
// Reactive state
const isDragOver = ref(false)
const selectedChildId = ref(null)
// Computed properties for node display
const nodeLabel = computed(() => {
return props.label || (props.data && props.data.label) || 'Page Wrapper'
})
const visualChildNodes = computed(() => {
return props.data?.childNodes || []
})
const layoutType = computed(() => {
return props.data?.layout || 'grid'
})
const hasChildNodes = computed(() => {
return visualChildNodes.value.length > 0
})
// Node styling following standard pattern
const nodeStyle = computed(() => {
const backgroundColor = props.data?.backgroundColor || '#f8fafc'
const borderColor = props.data?.borderColor || '#475569'
const textColor = props.data?.textColor || '#334155'
return {
'--node-bg-color': backgroundColor,
'--node-border-color': borderColor,
'--node-text-color': textColor,
backgroundColor: backgroundColor,
borderColor: borderColor,
color: textColor
}
})
// Container sizing
const containerSize = computed(() => {
// Base size following standard node pattern but larger for container
const baseWidth = 220
const baseHeight = 120
// Additional space for child nodes
const childCount = visualChildNodes.value.length
const extraWidth = Math.min(childCount * 40, 200) // Max 200px extra
const extraHeight = Math.ceil(childCount / 3) * 30 // Height based on rows
return {
width: `${baseWidth + extraWidth}px`,
minHeight: `${baseHeight + extraHeight}px`
}
})
// Child container classes and styles
const childContainerClasses = computed(() => {
const layout = layoutType.value
const classes = ['child-nodes-grid']
if (layout === 'grid') {
classes.push('grid-layout')
} else if (layout === 'flex') {
classes.push('flex-layout')
} else {
classes.push('stacked-layout')
}
return classes
})
const childContainerStyle = computed(() => {
const layout = layoutType.value
const gap = props.data?.gap || '4px'
const childCount = visualChildNodes.value.length
if (layout === 'grid') {
const columns = Math.min(props.data?.columns || 2, Math.max(childCount, 1))
return {
display: 'grid',
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap: gap,
alignItems: 'start'
}
} else if (layout === 'flex') {
return {
display: 'flex',
flexWrap: 'wrap',
gap: gap,
alignItems: 'center',
justifyContent: 'flex-start'
}
} else {
return {
display: 'flex',
flexDirection: 'column',
gap: gap,
alignItems: 'stretch'
}
}
})
// Layout-specific styling
const layoutIndicatorClass = computed(() => {
const layout = layoutType.value
return `layout-${layout}`
})
const layoutIndicatorStyle = computed(() => {
const layout = layoutType.value
if (layout === 'grid') {
return {
background: 'linear-gradient(90deg, #dbeafe 25%, transparent 25%), linear-gradient(#dbeafe 25%, transparent 25%)',
backgroundSize: '8px 8px'
}
} else if (layout === 'flex') {
return {
background: 'linear-gradient(90deg, #dcfce7 0%, transparent 50%)',
borderLeft: '3px solid #22c55e'
}
} else {
return {
background: 'linear-gradient(0deg, #fef3c7 0%, transparent 50%)',
borderTop: '3px solid #f59e0b'
}
}
})
// Computed for shape class
const shapeClass = computed(() => {
const shape = props.data?.shape || 'rectangle'
return `shape-${shape}`
})
// Get child node styling
const getChildNodeStyle = (childNode) => {
const styles = {}
if (layoutType.value === 'grid' && childNode.position) {
styles.gridRow = childNode.position.row || 'auto'
styles.gridColumn = childNode.position.col || 'auto'
}
return styles
}
// Get child node type icon
const getChildNodeIcon = (type) => {
const iconMap = {
form: 'material-symbols:description-outline',
html: 'material-symbols:code',
table: 'material-symbols:table'
}
return iconMap[type] || 'material-symbols:extension'
}
// Get child node type display name
const getChildNodeTypeName = (type) => {
const nameMap = {
form: 'Form',
html: 'HTML',
table: 'Table'
}
return nameMap[type] || 'Unknown'
}
// Layout display helpers
const getLayoutDisplayName = () => {
const nameMap = {
grid: 'Grid',
flex: 'Side by Side',
stacked: 'Stacked'
}
return nameMap[layoutType.value] || 'Grid'
}
const getLayoutIcon = () => {
const iconMap = {
grid: 'material-symbols:grid-view',
flex: 'material-symbols:view-column',
stacked: 'material-symbols:view-agenda'
}
return iconMap[layoutType.value] || 'material-symbols:grid-view'
}
const getLayoutColorClass = () => {
const colorMap = {
grid: 'text-blue-600',
flex: 'text-green-600',
stacked: 'text-amber-600'
}
return colorMap[layoutType.value] || 'text-blue-600'
}
// Event handlers
const onClick = () => {
// Handle regular node click (not used for PageWrapper)
console.log('PageWrapper node clicked (general):', props.id)
}
const onHeaderClick = () => {
childNodeEventHandlers.onPageWrapperHeaderClick(props.id)
}
const onChildClick = (childNode) => {
selectedChildId.value = childNode.id
childNodeEventHandlers.onChildNodeClick({
parentId: props.id,
childNode: childNode
})
}
const onAddChild = () => {
childNodeEventHandlers.onChildAdd(props.id)
}
// Drag and drop handlers
const onDragOver = (event) => {
event.preventDefault()
event.stopPropagation()
isDragOver.value = true
event.dataTransfer.dropEffect = 'copy'
}
const onDragLeave = (event) => {
event.preventDefault()
event.stopPropagation()
isDragOver.value = false
}
const onDrop = (event) => {
event.preventDefault()
event.stopPropagation()
isDragOver.value = false
try {
const dragData = JSON.parse(event.dataTransfer.getData('text/plain'))
// Check if it's a valid child node type
if (['form', 'html', 'table'].includes(dragData.type)) {
childNodeEventHandlers.onChildDrop({
parentId: props.id,
childData: dragData,
dropPosition: {
x: event.offsetX,
y: event.offsetY
}
})
}
} catch (error) {
console.warn('Invalid drag data:', error)
}
}
const onChildDragStart = (event) => {
// Prevent child nodes from being dragged out for now
event.preventDefault()
}
</script>
<template>
<div
:class="['custom-node', 'node-page-wrapper', shapeClass, { 'selected': selected, 'drop-target': isDragOver }]"
:style="{ ...nodeStyle, ...containerSize }"
@click="onClick"
>
<!-- Input handles -->
<Handle
type="target"
:position="Position.Top"
class="handle-top"
:id="id + '-top'"
:style="{ zIndex: 1000 }"
:isConnectable="true"
:isValidConnection="() => true"
/>
<Handle
type="target"
:position="Position.Left"
class="handle-left"
:id="id + '-left'"
:style="{ zIndex: 1000 }"
:isConnectable="true"
:isValidConnection="() => true"
/>
<!-- Output handles -->
<Handle
type="source"
:position="Position.Right"
class="handle-right"
:id="id + '-right'"
:style="{ zIndex: 1000 }"
:isConnectable="true"
:isValidConnection="() => true"
/>
<Handle
type="source"
:position="Position.Bottom"
class="handle-bottom"
:id="id + '-bottom'"
:style="{ zIndex: 1000 }"
:isConnectable="true"
:isValidConnection="() => true"
/>
<!-- Validation Indicator -->
<ValidationIndicator
:node-id="id"
:validation-issues="validationIssues"
/>
<!-- Standard Node Content Structure -->
<div class="custom-node-content" @click.stop="onHeaderClick">
<!-- Node Header -->
<div class="flex items-center mb-1">
<div class="custom-node-icon">
<i class="material-icons text-slate-600">web_asset</i>
</div>
<div class="custom-node-label" :title="nodeLabel">{{ nodeLabel }}</div>
</div>
<!-- Node Details -->
<div class="node-details">
<p class="node-description">{{ data?.description || 'Page container with components' }}</p>
<div class="node-rule-detail flex items-center justify-between text-xs mt-1">
<span class="node-rule-detail-label">Layout:</span>
<span class="node-rule-detail-value ml-1 font-medium" :class="getLayoutColorClass()">
{{ getLayoutDisplayName() }}
</span>
</div>
<div class="node-rule-detail flex items-center justify-between text-xs mt-1">
<span class="node-rule-detail-label">Components:</span>
<span class="node-rule-detail-value ml-1 font-medium text-slate-600">
{{ visualChildNodes.length }}
</span>
</div>
</div>
</div>
<!-- Child Components Area (Always visible for drag-and-drop) -->
<div
class="child-components-area"
:class="[layoutIndicatorClass, { 'drag-over': isDragOver }]"
:style="layoutIndicatorStyle"
>
<!-- Layout Type Indicator -->
<div class="layout-indicator">
<Icon :name="getLayoutIcon()" class="w-3 h-3" />
<span class="layout-name">{{ getLayoutDisplayName() }}</span>
</div>
<!-- Child Components Grid -->
<div
v-if="hasChildNodes"
class="child-components-grid"
:class="childContainerClasses"
:style="childContainerStyle"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
>
<div
v-for="childNode in visualChildNodes"
:key="childNode.id"
:class="['child-component', layoutType, { 'selected': childNode.id === selectedChildId }]"
@click.stop="onChildClick(childNode)"
@dragstart="onChildDragStart"
draggable="true"
:title="`${getChildNodeTypeName(childNode.type)}: ${childNode.label || 'Unnamed'}`"
>
<Icon :name="getChildNodeIcon(childNode.type)" class="w-3 h-3" />
<span class="child-label">{{ childNode.label || getChildNodeTypeName(childNode.type) }}</span>
<Icon v-if="childNode.conditionalLogic?.enabled" name="material-symbols:rule" class="w-2 h-2 text-orange-500" />
</div>
</div>
<!-- Drop Zone (Always visible for drag and drop) -->
<div
class="drop-zone-compact"
:class="{ 'drag-over': isDragOver, 'has-children': hasChildNodes }"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
>
<Icon name="material-symbols:add" class="w-4 h-4 text-gray-300" />
<span class="text-xs text-gray-400">
{{ hasChildNodes ? 'Drop more components' : 'Drag Form, HTML, or Table nodes here' }}
</span>
</div>
<!-- Add Child Button -->
<div v-if="hasChildNodes" class="add-child-button" @click.stop="onAddChild">
<Icon name="material-symbols:add" class="w-3 h-3 text-gray-400" />
</div>
</div>
</div>
</template>
<style scoped>
/* PageWrapper Node - Following standard node pattern */
.node-page-wrapper {
background: white;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
padding: 0;
border: 1px solid #ddd;
border-left: 4px solid #475569;
position: relative;
font-size: 12px;
transition: all 0.2s;
cursor: pointer;
}
.node-page-wrapper:hover {
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.node-page-wrapper.selected {
border-color: #ff6b6b;
box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.2);
}
.node-page-wrapper.drop-target {
border-left-color: #10b981;
background-color: #f0fdf4;
border-color: #10b981;
}
/* Standard Node Content Structure */
.custom-node-content {
padding: 8px;
position: relative;
z-index: 2;
cursor: pointer;
}
.custom-node-icon {
margin-right: 6px;
display: inline-flex;
align-items: center;
}
.custom-node-icon .material-icons {
font-size: 16px;
}
.custom-node-label {
font-weight: 500;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 140px;
}
.node-details {
margin-top: 4px;
}
.node-description {
margin-bottom: 2px;
color: #666;
white-space: normal;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
font-size: 10px;
}
.node-rule-detail {
display: flex;
font-size: 10px;
color: #666;
align-items: center;
margin-bottom: 2px;
}
.node-rule-detail-label {
font-weight: 500;
margin-right: 4px;
}
.node-rule-detail-value {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100px;
}
/* Child Components Area */
.child-components-area {
border-top: 1px solid #f3f4f6;
padding: 6px 8px 8px 8px;
background: #fafafa;
min-height: 50px;
position: relative;
transition: all 0.2s ease;
}
.child-components-area.drag-over {
background: #f0fdf4;
border-top-color: #10b981;
}
/* Layout-specific area styling */
.child-components-area.layout-grid {
background: linear-gradient(90deg, #dbeafe 25%, transparent 25%),
linear-gradient(#dbeafe 25%, transparent 25%);
background-size: 6px 6px;
background-position: 0 0, 0 0;
}
.child-components-area.layout-flex {
background: linear-gradient(90deg, #dcfce7 0%, transparent 100%);
border-left: 2px solid #22c55e;
}
.child-components-area.layout-stacked {
background: linear-gradient(180deg, #fef3c7 0%, transparent 100%);
border-top: 2px solid #f59e0b;
}
/* Layout Indicator */
.layout-indicator {
position: absolute;
top: 3px;
left: 4px;
display: flex;
align-items: center;
gap: 2px;
padding: 2px 4px;
background: rgba(255, 255, 255, 0.95);
border-radius: 8px;
font-size: 8px;
font-weight: 500;
color: #64748b;
backdrop-filter: blur(2px);
z-index: 10;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.layout-name {
font-size: 7px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.child-components-grid {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
.child-components-grid.grid-layout {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(60px, 1fr));
gap: 4px;
}
.child-components-grid.flex-layout {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.child-components-grid.stacked-layout {
display: flex;
flex-direction: column;
gap: 2px;
}
/* Child Components */
.child-component {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 3px;
padding: 3px 6px;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
gap: 3px;
font-size: 9px;
min-width: 50px;
max-width: 100px;
user-select: none;
position: relative;
}
.child-component:hover {
background: #f1f5f9;
border-color: #cbd5e1;
transform: translateY(-1px);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.child-component.selected {
background: #dbeafe;
border-color: #3b82f6;
box-shadow: 0 0 0 1px #3b82f6;
}
.child-component:active {
transform: scale(0.98);
opacity: 0.8;
}
/* Layout-specific child component styling */
.child-component.grid {
border-left: 3px solid #3b82f6;
background: linear-gradient(135deg, #dbeafe 0%, #ffffff 100%);
}
.child-component.flex {
border-left: 3px solid #22c55e;
background: linear-gradient(135deg, #dcfce7 0%, #ffffff 100%);
flex: 1;
min-width: 60px;
}
.child-component.stacked {
border-top: 3px solid #f59e0b;
background: linear-gradient(135deg, #fef3c7 0%, #ffffff 100%);
width: 100%;
max-width: none;
}
/* Drag feedback */
.child-component[draggable="true"]:hover {
cursor: grab;
}
.child-component[draggable="true"]:active {
cursor: grabbing;
}
.child-label {
font-weight: 500;
color: #334155;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
/* Drop Zone */
.drop-zone-compact {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 8px;
border: 1px dashed #d1d5db;
border-radius: 4px;
background: #f9fafb;
transition: all 0.2s ease;
min-height: 28px;
margin-top: 6px;
cursor: pointer;
clear: both;
}
.drop-zone-compact:hover {
border-color: #9ca3af;
background: #f3f4f6;
}
.drop-zone-compact.drag-over {
border-color: #10b981;
background: #f0fdf4;
color: #059669;
border-style: solid;
}
.drop-zone-compact.has-children {
margin-top: 6px;
padding: 6px;
min-height: 28px;
}
/* Add Child Button */
.add-child-button {
position: absolute;
top: 3px;
right: 4px;
width: 18px;
height: 18px;
border: 1px dashed #9ca3af;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
background: rgba(255, 255, 255, 0.95);
transition: all 0.15s ease;
z-index: 10;
backdrop-filter: blur(2px);
}
.add-child-button:hover {
border-color: #3b82f6;
background: #eff6ff;
color: #3b82f6;
}
/* Handle styles - Following standard pattern */
.handle-top, .handle-bottom, .handle-left, .handle-right {
width: 12px !important;
height: 12px !important;
border-radius: 50% !important;
background: #fff !important;
border: 2px solid #666 !important;
opacity: 0;
transition: all 0.2s ease;
cursor: crosshair;
z-index: 100 !important;
}
.node-page-wrapper:hover .handle-top,
.node-page-wrapper:hover .handle-bottom,
.node-page-wrapper:hover .handle-left,
.node-page-wrapper:hover .handle-right {
opacity: 1;
}
.handle-top:hover, .handle-bottom:hover,
.handle-left:hover, .handle-right:hover {
border-color: #3b82f6 !important;
background: #eff6ff !important;
}
/* Shape variations */
.shape-rectangle {
border-radius: 4px;
}
.shape-diamond {
border-radius: 0;
transform: rotate(45deg);
}
.shape-circle {
border-radius: 50%;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.child-components-grid.grid-layout {
grid-template-columns: 1fr !important;
}
.child-components-grid.flex-layout {
flex-direction: column;
}
.child-component {
min-width: 80px;
max-width: none;
}
}
</style>

View File

@ -20,6 +20,7 @@ import NotificationNodeConfigurationModal from '~/components/process-flow/Notifi
import ScriptNodeConfigurationModal from '~/components/process-flow/ScriptNodeConfigurationModal.vue';
import HtmlNodeConfigurationModal from '~/components/process-flow/HtmlNodeConfigurationModal.vue';
import SubprocessNodeConfigurationModal from '~/components/process-flow/SubprocessNodeConfigurationModal.vue';
import PageWrapperConfigurationModal from '~/components/process-flow/PageWrapperConfigurationModal.vue';
import ProcessTemplatesModal from '~/components/ProcessTemplatesModal.vue';
import ProcessSettingsModal from '~/components/process-flow/ProcessSettingsModal.vue';
import ProcessHistoryModal from '~/components/ProcessHistoryModal.vue';
@ -137,6 +138,7 @@ const showNotificationConfigModal = ref(false);
const showScriptConfigModal = ref(false);
const showHtmlConfigModal = ref(false);
const showSubprocessConfigModal = ref(false);
const showPageWrapperConfigModal = ref(false);
const showTemplatesModal = ref(false);
const showProcessSettings = ref(false);
const showDropdown = ref(false);
@ -561,7 +563,8 @@ const handleKeyboardShortcuts = (event) => {
showNotificationConfigModal.value ||
showScriptConfigModal.value ||
showHtmlConfigModal.value ||
showSubprocessConfigModal.value ||
showSubprocessConfigModal.value ||
showPageWrapperConfigModal.value ||
showTemplatesModal.value ||
showProcessSettings.value ||
showProcessHistoryModal.value ||
@ -1183,6 +1186,127 @@ const onEdgeSelected = (edge) => {
processStore.selectEdge(edge.id);
};
// Handle child node click within PageWrapper
const onChildNodeClick = ({ parentId, childNode }) => {
console.log('Child node clicked:', childNode.id, 'in parent:', parentId);
// Create a pseudo-node for the child to work with existing configuration system
const childNodeData = {
id: `${parentId}-child-${childNode.id}`,
type: childNode.type,
label: childNode.label || getChildNodeTypeName(childNode.type),
data: {
...childNode,
isChildNode: true,
parentId: parentId,
// Map child node properties to match expected node data structure
formId: childNode.nodeId, // For form children
htmlCode: childNode.htmlCode, // For HTML children
cssCode: childNode.cssCode,
jsCode: childNode.jsCode
}
};
// Set selected data to enable configuration
selectedNodeData.value = childNodeData;
selectedNode.value = null; // Clear parent node selection
selectedEdgeData.value = null;
// Don't select in store since it's not a real process node
processStore.selectedNodeId = null;
};
// Handle header click on PageWrapper (configure container)
const onPageWrapperHeaderClick = (nodeId) => {
console.log('PageWrapper header clicked:', nodeId);
// Find the actual PageWrapper node
const node = processStore.currentProcess?.nodes.find(n => n.id === nodeId);
if (node) {
onNodeSelected(node);
}
};
// Handle adding child to PageWrapper
const onChildAdd = (parentId) => {
console.log('Add child to PageWrapper:', parentId);
// Show component palette or add default child
// For now, add a default form child
const newChild = {
id: `child-${Date.now()}`,
type: 'form',
label: 'New Form',
nodeId: null, // Will be set when form is selected
position: { row: 1, col: 1 },
conditionalLogic: {
enabled: false,
variable: '',
operator: '==',
value: ''
},
variableMapping: {
inputs: [],
outputs: []
}
};
// Add to parent node
processStore.addChildNode(parentId, newChild);
// Show success message
if (toast) {
toast.success('Child component added');
}
};
// Handle dropping node into PageWrapper
const onChildDrop = ({ parentId, childData, dropPosition }) => {
console.log('Node dropped into PageWrapper:', childData.type, 'into', parentId);
// Create child node configuration from dropped data
const newChild = {
id: `child-${Date.now()}`,
type: childData.type,
label: childData.label || getChildNodeTypeName(childData.type),
nodeId: null, // Will be set when actual node is selected/created
position: {
row: Math.ceil(dropPosition.y / 80) || 1,
col: Math.ceil(dropPosition.x / 120) || 1
},
conditionalLogic: {
enabled: false,
variable: '',
operator: '==',
value: ''
},
variableMapping: {
inputs: [],
outputs: []
},
// Copy relevant data from dropped component
...childData.data
};
// Add to parent node
processStore.addChildNode(parentId, newChild);
// Show success message
if (toast) {
toast.success(`${getChildNodeTypeName(childData.type)} added to page`);
}
};
// Helper function to get child node type name
const getChildNodeTypeName = (type) => {
const nameMap = {
form: 'Form',
html: 'HTML Content',
table: 'Table'
};
return nameMap[type] || 'Component';
};
// Update edge label
const updateEdgeLabel = (value) => {
if (selectedEdgeData.value) {
@ -2300,6 +2424,28 @@ const handleSubprocessNodeUpdate = async (updatedData) => {
}
};
// Handle Page Wrapper node update
const handlePageWrapperNodeUpdate = async (updatedData) => {
if (selectedNodeData.value && selectedNodeData.value.type === 'page-wrapper') {
// Make sure to update the label both in data and at the root level
const newLabel = updatedData.title || updatedData.label || 'Page Wrapper';
// Update the data
selectedNodeData.value.data = {
...updatedData,
label: newLabel // Ensure label is in data
};
// Also update the root label
selectedNodeData.value.label = newLabel;
// Update the node in store and refresh
await updateNodeInStore();
console.log('Page Wrapper node updated:', selectedNodeData.value.id, 'Child nodes:', updatedData.childNodes?.length || 0);
}
};
// Handle process restoration from history
const handleProcessRestored = (restoredProcess) => {
// The process has been restored in the backend, so we need to reload it
@ -3064,6 +3210,10 @@ const sendToBack = () => {
@pane-click="onPaneClick"
@nodes-change="onNodesChange"
@edges-change="onEdgesChange"
@child-click="onChildNodeClick"
@header-click="onPageWrapperHeaderClick"
@child-add="onChildAdd"
@child-drop="onChildDrop"
class="w-full h-full"
/>
@ -3553,6 +3703,15 @@ const sendToBack = () => {
Configure Sub-process
</RsButton>
</div>
<!-- Page Wrapper Configuration -->
<div v-if="selectedNodeData.type === 'page-wrapper'">
<p class="text-xs text-gray-600 mb-3">Configure multi-component page layout and child components.</p>
<RsButton @click="showPageWrapperConfigModal = true" variant="primary" class="w-full">
<Icon name="material-symbols:web-asset" class="w-4 h-4 mr-2" />
Configure Page Wrapper
</RsButton>
</div>
</div>
<!-- Shape Information -->
@ -3732,6 +3891,16 @@ const sendToBack = () => {
@update="handleSubprocessNodeUpdate"
/>
<!-- Page Wrapper Configuration Modal -->
<PageWrapperConfigurationModal
v-if="selectedNodeData && selectedNodeData.type === 'page-wrapper'"
v-model="showPageWrapperConfigModal"
:key="`page-wrapper-${selectedNodeData.id}-${variablesUpdateKey}`"
:nodeData="selectedNodeData.data"
:availableVariables="gatewayAvailableVariables"
@update="handlePageWrapperNodeUpdate"
/>
<!-- Process Templates Modal -->
<ProcessTemplatesModal
v-model="showTemplatesModal"

View File

@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
import FormScriptEngine from '~/components/FormScriptEngine.vue';
import ConditionalLogicEngine from '~/components/ConditionalLogicEngine.vue';
import ComponentPreview from '~/components/ComponentPreview.vue';
import PageWrapperRenderer from '~/components/process-flow/PageWrapperRenderer.vue';
import { useFormBuilderStore } from '~/stores/formBuilder';
import { FormKit } from '@formkit/vue';
@ -861,6 +862,7 @@ const getStepInfo = (node) => {
'decision': { label: 'Decision Point', icon: 'material-symbols:alt-route', color: 'yellow' },
'gateway': { label: 'Decision Gateway', icon: 'material-symbols:fork-right', color: 'yellow' },
'html': { label: 'HTML Content', icon: 'material-symbols:code-blocks', color: 'indigo' },
'page-wrapper': { label: 'Multi-Component Page', icon: 'material-symbols:web-asset', color: 'teal' },
'notification': { label: 'Notification', icon: 'material-symbols:notifications', color: 'pink' },
'end': { label: 'Process Complete', icon: 'material-symbols:check-circle', color: 'green' }
};
@ -868,6 +870,17 @@ const getStepInfo = (node) => {
return stepTypes[node?.type] || { label: 'Unknown Step', icon: 'material-symbols:help', color: 'gray' };
};
// Get next step label for submit buttons
const getNextStepLabel = () => {
if (nextNode.value) {
if (nextNode.value.type === 'end') {
return 'Complete Process';
}
return `Continue to ${getStepInfo(nextNode.value).label}`;
}
return 'Continue';
};
// Load form data from database
const loadFormData = async (formId) => {
try {
@ -1092,6 +1105,11 @@ watch(currentStep, async (newStep) => {
formStore.updatePreviewFormData(formData.value);
}
}
} else if (currentNode.value.type === "page-wrapper") {
// PageWrapper nodes are user-interactive and don't auto-advance
// They will be handled in the template rendering section
console.log(`[Workflow] Processing PageWrapper node: ${currentNode.value.data?.title || currentNode.value.label}`);
return;
} else if (["decision", "gateway"].includes(currentNode.value.type)) {
await executeDecisionNode();
}
@ -1129,6 +1147,92 @@ const validateAndSubmit = () => {
}
};
// Handle PageWrapper submit
const handlePageWrapperSubmit = async (pageData) => {
try {
stepLoading.value = true;
console.log('[Workflow] PageWrapper submitted. Data:', pageData);
// Merge all child form data into process variables
if (pageData.childFormData) {
Object.values(pageData.childFormData).forEach(childData => {
Object.assign(processVariables.value, childData);
});
}
// Apply any page-level variable updates
if (pageData.processVariables) {
Object.assign(processVariables.value, pageData.processVariables);
}
moveToNextStep();
console.log('[Workflow] After PageWrapper submit, current node:', currentNode.value);
// Auto-execute next step if it's API/script
if (currentNode.value && ['api', 'script'].includes(currentNode.value.type)) {
await executeCurrentStep();
}
} catch (error) {
console.error('[Workflow] Error in PageWrapper submit:', error);
} finally {
stepLoading.value = false;
}
};
// Handle child form submit within PageWrapper
const handleChildFormSubmit = ({ childNode, formData }) => {
console.log('[Workflow] Child form submitted:', childNode.id, formData);
// Form data is already handled by PageWrapperRenderer through variable mapping
};
// Handle child form change within PageWrapper
const handleChildFormChange = ({ childNode, formData }) => {
console.log('[Workflow] Child form changed:', childNode.id, formData);
// Real-time form updates - could be used for auto-save or validation
};
// Handle child HTML action within PageWrapper
const handleChildHtmlAction = ({ childNode, action }) => {
console.log('[Workflow] Child HTML action:', childNode.id, action);
// Handle different HTML action types
switch (action.type) {
case 'button-click':
console.log(`Button clicked: ${action.actionId}`, action.data);
break;
case 'form-submit':
console.log(`HTML form submitted: ${action.actionId}`, action.data);
// Apply form data to process variables
Object.assign(processVariables.value, action.data);
break;
case 'message':
// Could integrate with toast notifications
console.log(`HTML message: ${action.message}`);
break;
case 'error':
console.error(`HTML error: ${action.error}`);
break;
}
};
// Handle child table action within PageWrapper
const handleChildTableAction = ({ childNode, action }) => {
console.log('[Workflow] Child table action:', childNode.id, action);
// Future: Handle table interactions like row selection, filtering, etc.
};
// Handle variable updates from child components
const handleVariableUpdate = ({ variable, value, source }) => {
console.log(`[Workflow] Variable updated by ${source}: ${variable} = ${value}`);
processVariables.value[variable] = value;
};
// Handle validation errors from child components
const handleValidationError = ({ childNode, errors }) => {
console.log('[Workflow] Validation errors in child:', childNode.id, errors);
// Could be used to prevent page submission or show error messages
};
// Handle decision node execution (automatic or manual)
const executeDecisionNode = async () => {
try {
@ -1703,6 +1807,26 @@ function getConditionGroupResult(conditionGroup, variables) {
</div>
</div>
<!-- Page Wrapper Step -->
<div v-else-if="currentNode.type === 'page-wrapper'" :class="isIframeMode ? 'p-2' : 'bg-white rounded-xl shadow-sm border border-gray-200 p-6'">
<PageWrapperRenderer
:pageWrapperData="currentNode.data"
:processVariables="processVariables"
:hideTitle="isIframeMode"
:showPageActions="true"
:canGoBack="false"
:submitButtonText="getNextStepLabel()"
@page-submit="handlePageWrapperSubmit"
@page-back="moveToNextStep"
@child-form-submit="handleChildFormSubmit"
@child-form-change="handleChildFormChange"
@child-html-action="handleChildHtmlAction"
@child-table-action="handleChildTableAction"
@variable-update="handleVariableUpdate"
@validation-error="handleValidationError"
/>
</div>
<!-- Decision/Gateway Step -->
<div v-else-if="['decision', 'gateway'].includes(currentNode.type)" :class="isIframeMode ? 'p-2' : 'bg-white rounded-xl shadow-sm border border-gray-200 p-6'">
<div v-if="!isIframeMode" class="mb-4">

View File

@ -813,6 +813,189 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
}
},
/**
* Add a child node to a PageWrapper parent
*/
addChildNode(parentId, childNodeConfig) {
if (!this.currentProcess) return;
const parentNode = this.currentProcess.nodes.find(n => n.id === parentId);
if (!parentNode || parentNode.type !== 'page-wrapper') {
console.warn('Parent node not found or not a PageWrapper:', parentId);
return;
}
// Ensure childNodes array exists
if (!parentNode.data.childNodes) {
parentNode.data.childNodes = [];
}
// Generate unique child node ID if not provided
const childNode = {
id: childNodeConfig.id || `child-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
type: childNodeConfig.type || 'form',
nodeId: childNodeConfig.nodeId || '',
position: childNodeConfig.position || { row: 1, col: 1 },
conditionalLogic: {
enabled: false,
variable: '',
operator: '==',
value: '',
...childNodeConfig.conditionalLogic
},
variableMapping: {
inputs: [],
outputs: [],
...childNodeConfig.variableMapping
}
};
parentNode.data.childNodes.push(childNode);
this.saveToHistory('Add child node');
this.unsavedChanges = true;
console.log('👶 Store: Added child node', childNode.id, 'to parent', parentId);
return childNode;
},
/**
* Update a child node within a PageWrapper parent
*/
updateChildNode(parentId, childId, updates) {
if (!this.currentProcess) return;
const parentNode = this.currentProcess.nodes.find(n => n.id === parentId);
if (!parentNode || parentNode.type !== 'page-wrapper') {
console.warn('Parent node not found or not a PageWrapper:', parentId);
return;
}
if (!parentNode.data.childNodes) {
return;
}
const childIndex = parentNode.data.childNodes.findIndex(c => c.id === childId);
if (childIndex !== -1) {
Object.assign(parentNode.data.childNodes[childIndex], updates);
this.saveToHistory('Update child node');
this.unsavedChanges = true;
console.log('📝 Store: Updated child node', childId, 'in parent', parentId);
}
},
/**
* Remove a child node from a PageWrapper parent
*/
removeChildNode(parentId, childId) {
if (!this.currentProcess) return;
const parentNode = this.currentProcess.nodes.find(n => n.id === parentId);
if (!parentNode || parentNode.type !== 'page-wrapper') {
console.warn('Parent node not found or not a PageWrapper:', parentId);
return;
}
if (!parentNode.data.childNodes) {
return;
}
const childIndex = parentNode.data.childNodes.findIndex(c => c.id === childId);
if (childIndex !== -1) {
parentNode.data.childNodes.splice(childIndex, 1);
this.saveToHistory('Remove child node');
this.unsavedChanges = true;
console.log('🗑️ Store: Removed child node', childId, 'from parent', parentId);
return true;
}
return false;
},
/**
* Get all child nodes for a PageWrapper parent
*/
getChildNodes(parentId) {
if (!this.currentProcess) return [];
const parentNode = this.currentProcess.nodes.find(n => n.id === parentId);
if (!parentNode || parentNode.type !== 'page-wrapper') {
return [];
}
return parentNode.data.childNodes || [];
},
/**
* Get a specific child node by ID
*/
getChildNode(parentId, childId) {
const childNodes = this.getChildNodes(parentId);
return childNodes.find(c => c.id === childId) || null;
},
/**
* Check if a node is a child node (has a parent)
*/
isChildNode(nodeId) {
if (!this.currentProcess) return false;
// Find any PageWrapper node that contains this node as a child
return this.currentProcess.nodes.some(node => {
if (node.type === 'page-wrapper' && node.data.childNodes) {
return node.data.childNodes.some(child => child.nodeId === nodeId);
}
return false;
});
},
/**
* Get the parent node ID for a child node
*/
getParentNodeId(childNodeId) {
if (!this.currentProcess) return null;
const parentNode = this.currentProcess.nodes.find(node => {
if (node.type === 'page-wrapper' && node.data.childNodes) {
return node.data.childNodes.some(child => child.nodeId === childNodeId);
}
return false;
});
return parentNode ? parentNode.id : null;
},
/**
* Enhanced delete node that handles PageWrapper child nodes
*/
deleteNodeWithChildren(nodeId) {
if (!this.currentProcess) return;
const node = this.currentProcess.nodes.find(n => n.id === nodeId);
if (!node) return false;
// If it's a PageWrapper, we don't need to do anything special
// Child nodes are just configuration data, not separate process nodes
// If it's a regular node that might be referenced by child nodes,
// we should clean up those references
if (node.type === 'form' || node.type === 'html' || node.type === 'table') {
// Find any PageWrapper nodes that reference this node as a child
this.currentProcess.nodes.forEach(parentNode => {
if (parentNode.type === 'page-wrapper' && parentNode.data.childNodes) {
// Remove any child node configurations that reference this node
parentNode.data.childNodes = parentNode.data.childNodes.filter(
child => child.nodeId !== nodeId
);
}
});
}
// Now delete the node normally
return this.deleteNode(nodeId);
},
/**
* Add an edge to the current process
*/