corrad-bp/components/process-flow/PageWrapperConfiguration.vue
Md Afiq Iskandar 7df119417a Add Page Wrapper Node and Enhance Process Flow Management
- Introduced a new 'Page Wrapper' node type to allow multiple components (forms, HTML, tables) to be rendered as a single page, enhancing the flexibility of the process builder.
- Implemented child node management within the Page Wrapper, including adding, updating, and removing child nodes through the process store.
- Enhanced the ProcessFlowCanvas to support the new Page Wrapper node, including event handling for child node interactions and configuration.
- Updated the workflow page to handle submissions and interactions for Page Wrapper nodes, ensuring seamless integration with existing process flows.
- Improved documentation in CLAUDE.md to provide guidance on the new Page Wrapper architecture and its components.
- Refactored related components and store logic to accommodate the new functionality, ensuring a cohesive user experience across the application.
2025-07-31 10:28:34 +08:00

882 lines
26 KiB
Vue

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