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:
parent
47c296fcc0
commit
7df119417a
648
CLAUDE.md
648
CLAUDE.md
@ -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
|
367
components/process-flow/ChildFormRenderer.vue
Normal file
367
components/process-flow/ChildFormRenderer.vue
Normal 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>
|
494
components/process-flow/ChildHtmlRenderer.vue
Normal file
494
components/process-flow/ChildHtmlRenderer.vue
Normal 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>
|
882
components/process-flow/PageWrapperConfiguration.vue
Normal file
882
components/process-flow/PageWrapperConfiguration.vue
Normal 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 */ .my-custom-class { margin: 10px; padding: 20px; }"
|
||||
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 console.log('Page loaded'); // Access process variables // 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>
|
226
components/process-flow/PageWrapperConfigurationModal.vue
Normal file
226
components/process-flow/PageWrapperConfigurationModal.vue
Normal 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>
|
565
components/process-flow/PageWrapperRenderer.vue
Normal file
565
components/process-flow/PageWrapperRenderer.vue
Normal 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>
|
@ -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
|
||||
{
|
||||
|
@ -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(() => {
|
||||
|
800
components/process-flow/custom/PageWrapperNode.vue
Normal file
800
components/process-flow/custom/PageWrapperNode.vue
Normal 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>
|
@ -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"
|
||||
|
@ -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">
|
||||
|
@ -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
|
||||
*/
|
||||
|
Loading…
x
Reference in New Issue
Block a user