- Updated the form builder to allow users to create new forms with additional fields for category and group, improving organization and usability. - Introduced an empty state in the form builder to guide users in creating new forms. - Enhanced the management page with new filters for category and group, allowing for better form organization and retrieval. - Updated the database schema to include new fields for form category, tags, and group, along with corresponding API adjustments for form creation and updates. - Improved the user interface with better handling of form descriptions and added visual indicators for categories and groups in the forms table.
3402 lines
123 KiB
Vue
3402 lines
123 KiB
Vue
<template>
|
||
<div class="flex flex-col h-screen bg-gray-50">
|
||
<!-- Header Bar -->
|
||
<header
|
||
class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between shadow-sm"
|
||
>
|
||
<!-- Left section - Logo and navigation -->
|
||
<div class="flex items-center gap-4">
|
||
<Icon
|
||
@click="navigateTo('/form-builder/manage')"
|
||
name="ph:arrow-circle-left-duotone"
|
||
class="cursor-pointer w-6 h-6 hover:text-gray-600 text-gray-500"
|
||
/>
|
||
<div class="flex items-center gap-3">
|
||
<img
|
||
src="@/assets/img/logo/logo-word-black.svg"
|
||
alt="Corrad Logo"
|
||
class="h-8"
|
||
/>
|
||
<div class="border-l border-gray-300 pl-3">
|
||
<h1 class="text-xl font-semibold text-gray-900">Form Builder</h1>
|
||
<p class="text-sm text-gray-500">Create and manage form layouts</p>
|
||
</div>
|
||
<div v-if="isPreview" class="bg-blue-500 text-white text-xs px-3 py-1 rounded-full ml-2 font-medium">
|
||
Preview Mode
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Middle section - Form name -->
|
||
<div class="flex-1 flex justify-center items-center mx-8">
|
||
<FormKit
|
||
v-if="!isPreview"
|
||
type="text"
|
||
name="formName"
|
||
placeholder="Form Name"
|
||
v-model="formName"
|
||
validation="required"
|
||
validation-visibility="live"
|
||
:validation-messages="{ required: 'Please enter a form name' }"
|
||
class="form-name-input max-w-md"
|
||
:classes="{
|
||
outer: 'mb-0 w-full',
|
||
}"
|
||
/>
|
||
<div v-else class="text-lg font-medium">{{ formName }}</div>
|
||
</div>
|
||
|
||
<!-- Right section - Actions -->
|
||
<div class="flex items-center">
|
||
<!-- Primary Action Group -->
|
||
<div class="flex items-center mr-1 md:mr-2 border-r border-gray-200 pr-1 md:pr-2">
|
||
<RsButton v-if="!isPreview" @click="handleSave" variant="primary" size="sm" class="mr-1 md:mr-2">
|
||
<Icon name="material-symbols:save" class="mr-0 md:mr-1" />
|
||
<span class="hidden md:inline">Save</span>
|
||
</RsButton>
|
||
|
||
<RsButton @click="togglePreview" :variant="isPreview ? 'primary' : 'secondary'" size="sm">
|
||
<Icon :name="isPreview ? 'material-symbols:edit' : 'material-symbols:preview'" class="mr-0 md:mr-1" />
|
||
<span class="hidden md:inline">{{ isPreview ? 'Edit' : 'Preview' }}</span>
|
||
</RsButton>
|
||
</div>
|
||
|
||
|
||
|
||
<!-- More Actions Dropdown -->
|
||
<div v-if="!isPreview" class="relative">
|
||
<RsButton @click="showDropdown = !showDropdown" variant="tertiary" size="sm" class="flex items-center">
|
||
<Icon name="material-symbols:more-vert" class="w-4 h-4 md:w-5 md:h-5" />
|
||
</RsButton>
|
||
|
||
<div v-if="showDropdown" class="dropdown-menu absolute right-0 mt-2 bg-white rounded shadow-lg py-1 z-10 w-48 text-gray-800">
|
||
<!-- Form Actions -->
|
||
<div class="px-3 py-1 text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||
Form Actions
|
||
</div>
|
||
<button @click="showTemplatesModal = true; showDropdown = false" class="w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center">
|
||
<Icon name="material-symbols:description-outline" class="mr-2 w-4 h-4" />
|
||
<span>Templates</span>
|
||
</button>
|
||
<button v-if="formStore.currentFormId" @click="showFormHistoryModal = true; showDropdown = false" class="w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center">
|
||
<Icon name="material-symbols:history" class="mr-2 w-4 h-4" />
|
||
<span>History</span>
|
||
</button>
|
||
|
||
<!-- Settings -->
|
||
<div class="px-3 py-1 text-xs font-medium text-gray-500 uppercase tracking-wide mt-2">
|
||
Settings
|
||
</div>
|
||
<button @click="showFormSettings = true; showDropdown = false" class="w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center">
|
||
<Icon name="material-symbols:code" class="mr-2 w-4 h-4" />
|
||
<span>Form Settings</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Main Content Area -->
|
||
<div v-if="hasCurrentForm" class="flex-1 flex overflow-hidden">
|
||
<!-- Left Panel - Components Sidebar -->
|
||
<div
|
||
v-if="!isPreview"
|
||
class="w-64 bg-white border-r border-gray-200 flex flex-col overflow-hidden"
|
||
>
|
||
<div class="border-b border-gray-200">
|
||
<div class="flex">
|
||
<button
|
||
@click="leftSidebarTab = 'components'"
|
||
class="px-4 py-3 text-sm font-medium flex-1"
|
||
:class="{
|
||
'text-blue-600 border-b-2 border-blue-600': leftSidebarTab === 'components',
|
||
'text-gray-500 hover:text-gray-700': leftSidebarTab !== 'components'
|
||
}"
|
||
>
|
||
<div class="flex items-center justify-center">
|
||
<Icon name="material-symbols:category" class="w-4 h-4 mr-1.5" />
|
||
Components
|
||
</div>
|
||
</button>
|
||
<button
|
||
@click="leftSidebarTab = 'history'"
|
||
class="px-4 py-3 text-sm font-medium flex-1"
|
||
:class="{
|
||
'text-blue-600 border-b-2 border-blue-600': leftSidebarTab === 'history',
|
||
'text-gray-500 hover:text-gray-700': leftSidebarTab !== 'history'
|
||
}"
|
||
>
|
||
<div class="flex items-center justify-center">
|
||
<Icon name="material-symbols:history" class="w-4 h-4 mr-1.5" />
|
||
History
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Components Tab Content -->
|
||
<div v-if="leftSidebarTab === 'components'" class="overflow-y-auto flex-1">
|
||
<div
|
||
class="p-3 border-b border-gray-200 bg-gray-50 flex justify-between items-center"
|
||
>
|
||
<h2 class="text-sm font-medium text-gray-700">Components</h2>
|
||
<div class="relative">
|
||
<button class="px-2 py-1 text-xs text-gray-500 hover:text-gray-700">
|
||
<Icon name="material-symbols:refresh" class="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<FormBuilderComponents @add-component="handleAddComponent" />
|
||
</div>
|
||
|
||
<!-- History Tab Content -->
|
||
<div v-else-if="leftSidebarTab === 'history'" class="overflow-y-auto flex-1">
|
||
<div
|
||
class="p-3 border-b border-gray-200 bg-gray-50 flex justify-between items-center"
|
||
>
|
||
<h2 class="text-sm font-medium text-gray-700">History</h2>
|
||
<div class="flex items-center space-x-1">
|
||
<button
|
||
@click="formStore.undo()"
|
||
class="p-1 text-xs rounded text-gray-500 hover:text-gray-700 disabled:opacity-50"
|
||
:disabled="!formStore.canUndo"
|
||
title="Undo"
|
||
>
|
||
<Icon name="material-symbols:undo" class="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
@click="formStore.redo()"
|
||
class="p-1 text-xs rounded text-gray-500 hover:text-gray-700 disabled:opacity-50"
|
||
:disabled="formStore.currentHistoryIndex >= formStore.actionHistory.length - 1"
|
||
title="Redo"
|
||
>
|
||
<Icon name="material-symbols:redo" class="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<FormBuilderHistory />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Middle Panel - Form Canvas -->
|
||
<div class="flex-1 flex flex-col overflow-hidden">
|
||
<div
|
||
class="py-2 px-4 bg-gray-50 border-b border-gray-200 flex justify-between items-center h-12"
|
||
>
|
||
<h2 class="text-sm font-medium text-gray-700">{{ isPreview ? 'Form Preview' : 'Form Canvas' }}</h2>
|
||
<div class="flex items-center gap-2">
|
||
<!-- Device Preview Controls (only show in preview mode) -->
|
||
<div v-if="isPreview" class="flex items-center bg-white border border-gray-200 rounded-lg p-1">
|
||
<button
|
||
v-for="device in deviceSizes"
|
||
:key="device.name"
|
||
@click="selectedDevice = device.name"
|
||
class="px-3 py-1 text-xs font-medium rounded transition-colors flex items-center space-x-1"
|
||
:class="{
|
||
'bg-blue-500 text-white': selectedDevice === device.name,
|
||
'text-gray-600 hover:text-gray-800 hover:bg-gray-100': selectedDevice !== device.name
|
||
}"
|
||
:title="`Preview on ${device.name} (${device.width}px)`"
|
||
>
|
||
<Icon :name="device.icon" class="w-3 h-3" />
|
||
<span>{{ device.name }}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<span class="text-xs text-gray-500">
|
||
{{ isPreview ? 'Preview mode - Form is interactive' : 'Drag components to build your form' }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="flex-1 overflow-auto p-6">
|
||
<div
|
||
id="form-drop-zone"
|
||
class="border-2 border-dashed border-gray-300 bg-white rounded-md min-h-[calc(100vh-170px)] p-4"
|
||
:class="{
|
||
'border-blue-400 bg-blue-50': formStore.isDraggingOver && !isPreview,
|
||
'border-solid': isPreview,
|
||
'preview-mode': isPreview
|
||
}"
|
||
@dragover="!isPreview && handleDragOver($event)"
|
||
@dragleave="!isPreview && handleDragLeave($event)"
|
||
@drop="!isPreview && handleDrop($event)"
|
||
@dragenter="!isPreview && handleDragEnter($event)"
|
||
>
|
||
<!-- Preview Mode -->
|
||
<div v-if="isPreview" class="form-container">
|
||
<!-- Device Frame Container -->
|
||
<div
|
||
class="device-preview-container mx-auto"
|
||
:class="[
|
||
currentDeviceConfig.class,
|
||
{
|
||
'device-frame': selectedDevice !== 'Desktop'
|
||
}
|
||
]"
|
||
:style="{
|
||
maxWidth: selectedDevice === 'Desktop' ? '100%' : `${currentDeviceConfig.width}px`,
|
||
height: selectedDevice === 'Desktop' ? 'auto' : `${currentDeviceConfig.height}px`
|
||
}"
|
||
>
|
||
<!-- Device Screen -->
|
||
<div
|
||
class="device-screen"
|
||
:class="{
|
||
'overflow-y-auto': selectedDevice !== 'Desktop',
|
||
'bg-white rounded-lg shadow-xl border': selectedDevice !== 'Desktop',
|
||
'scrollable-device': selectedDevice !== 'Desktop'
|
||
}"
|
||
:style="{
|
||
height: selectedDevice === 'Desktop' ? 'auto' : '100%',
|
||
overflowY: selectedDevice === 'Desktop' ? 'visible' : 'auto'
|
||
}"
|
||
>
|
||
<FormScriptEngine
|
||
ref="formScriptEngine"
|
||
:form-data="previewFormData"
|
||
:custom-script="formStore.formCustomScript"
|
||
:custom-css="formStore.formCustomCSS"
|
||
:form-events="formStore.formEvents"
|
||
:script-mode="formStore.scriptMode"
|
||
@field-change="handleScriptFieldChange"
|
||
@field-validate="handleScriptFieldValidate"
|
||
/>
|
||
|
||
<ConditionalLogicEngine
|
||
ref="conditionalLogicEngine"
|
||
:form-components="formStore.formComponents"
|
||
:form-data="previewFormData"
|
||
@script-generated="handleConditionalLogicGenerated"
|
||
/>
|
||
|
||
<FormKit
|
||
ref="previewForm"
|
||
type="form"
|
||
@submit="handlePreviewSubmit"
|
||
:actions="false"
|
||
v-model="previewFormData"
|
||
>
|
||
<div
|
||
class="grid-preview-container"
|
||
:class="{
|
||
'p-4': selectedDevice !== 'Desktop',
|
||
'p-6': selectedDevice === 'Desktop'
|
||
}"
|
||
>
|
||
<template
|
||
v-for="(component, index) in formStore.formComponents"
|
||
:key="index"
|
||
>
|
||
<component-preview :component="component" :is-preview="false" />
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Only show submit button if enabled and no submit button components exist in the form -->
|
||
<FormKit
|
||
v-if="formStore.submitButton.enabled && !formStore.formComponents.some(comp => comp.type === 'button' && comp.props.buttonType === 'submit')"
|
||
type="submit"
|
||
:label="formStore.submitButton.label || 'Submit'"
|
||
class="form-submit mt-6"
|
||
:class="{
|
||
'mx-4 mb-4': selectedDevice !== 'Desktop',
|
||
'mx-0 mb-0': selectedDevice === 'Desktop'
|
||
}"
|
||
:classes="{
|
||
input: getSubmitButtonStyles(formStore.submitButton.category, formStore.submitButton.color).classes
|
||
}"
|
||
:style="getSubmitButtonStyles(formStore.submitButton.category, formStore.submitButton.color).style"
|
||
/>
|
||
</FormKit>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Edit Mode -->
|
||
<div v-else class="relative">
|
||
<FormBuilderCanvas
|
||
:form-components="formStore.formComponents"
|
||
@select-component="handleSelectComponent"
|
||
@select-nested-component="handleSelectNestedComponent"
|
||
@move-component="handleMoveComponent"
|
||
@delete-component="handleDeleteComponent"
|
||
@update-component="handleUpdateComponent"
|
||
@optimize-layout="handleOptimizeLayout"
|
||
/>
|
||
|
||
<!-- Instruction Overlay when no component is selected -->
|
||
<div
|
||
v-if="!isPreview && formStore.formComponents.length > 0 && !formStore.selectedComponent"
|
||
class="absolute top-4 left-1/2 transform -translate-x-1/2 z-30"
|
||
>
|
||
<div class="instruction-tooltip">
|
||
<div class="flex items-center space-x-2">
|
||
<Icon name="heroicons:cursor-arrow-rays" class="w-5 h-5 text-blue-600" />
|
||
<span class="text-sm font-medium text-gray-700">Click any field to configure its settings</span>
|
||
<Icon name="heroicons:cog-6-tooth" class="w-4 h-4 text-gray-500" />
|
||
</div>
|
||
<div class="instruction-arrow"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Right Panel - Field Settings (Collapsible) -->
|
||
<div
|
||
v-if="!isPreview"
|
||
class="field-settings-panel"
|
||
:class="{
|
||
'panel-expanded': showFieldSettingsPanel,
|
||
'panel-collapsed': !showFieldSettingsPanel
|
||
}"
|
||
>
|
||
|
||
<!-- Expanded Panel Content -->
|
||
<div
|
||
v-if="showFieldSettingsPanel"
|
||
class="panel-content"
|
||
>
|
||
<!-- Panel Header with Toggle -->
|
||
<div class="panel-header">
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex items-center space-x-2">
|
||
<Icon name="heroicons:cog-6-tooth" class="w-5 h-5 text-gray-600" />
|
||
<h3 class="text-sm font-semibold text-gray-800">Field Settings</h3>
|
||
</div>
|
||
<button
|
||
@click="toggleFieldSettingsPanel"
|
||
class="panel-header-toggle-btn"
|
||
title="Collapse Settings Panel"
|
||
>
|
||
<Icon name="heroicons:chevron-right" class="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Selected Component Info or Empty State -->
|
||
<div class="panel-body">
|
||
<div v-if="!formStore.selectedComponent" class="empty-state">
|
||
<div class="text-center py-8">
|
||
<Icon name="heroicons:cursor-arrow-rays" class="w-12 h-12 text-gray-300 mx-auto mb-3" />
|
||
<h4 class="text-sm font-medium text-gray-600 mb-2">No Field Selected</h4>
|
||
<p class="text-xs text-gray-500 leading-relaxed">
|
||
Click on any form field in the canvas to configure its settings here.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else class="component-settings">
|
||
<!-- Component Header -->
|
||
<div class="component-header">
|
||
<div class="flex items-center space-x-3">
|
||
<div class="component-icon">
|
||
<Icon :name="getComponentIcon(formStore.selectedComponent.type)" class="w-6 h-6 text-blue-600" />
|
||
</div>
|
||
<div class="flex-1 min-w-0">
|
||
<h4 class="text-sm font-semibold text-gray-900 truncate">
|
||
{{ getComponentTypeName(formStore.selectedComponent.type) }}
|
||
</h4>
|
||
<p class="text-xs text-gray-500 truncate">
|
||
{{ formStore.selectedComponent.props.label || formStore.selectedComponent.props.name || 'Untitled' }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Quick Actions -->
|
||
<div class="quick-actions">
|
||
<button
|
||
@click="openFullSettingsModal"
|
||
class="action-btn primary"
|
||
title="Open full settings modal"
|
||
>
|
||
<Icon name="heroicons:adjustments-horizontal" class="w-4 h-4 mr-1.5" />
|
||
Full Settings
|
||
</button>
|
||
<button
|
||
@click="duplicateComponent"
|
||
class="action-btn secondary"
|
||
title="Duplicate this component"
|
||
>
|
||
<Icon name="heroicons:document-duplicate" class="w-4 h-4 mr-1.5" />
|
||
Duplicate
|
||
</button>
|
||
<button
|
||
@click="deleteComponent"
|
||
class="action-btn danger"
|
||
title="Delete this component"
|
||
>
|
||
<Icon name="heroicons:trash" class="w-4 h-4 mr-1.5" />
|
||
Delete
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Inline Quick Settings -->
|
||
<div class="quick-settings">
|
||
<h5 class="settings-section-title">Quick Settings</h5>
|
||
|
||
<!-- Label Field -->
|
||
<div v-if="showQuickField('label')" class="setting-item">
|
||
<label class="setting-label">
|
||
{{ formStore.selectedComponent.type === 'button' ? 'Button Text' : 'Field Label' }}
|
||
</label>
|
||
<FormKit
|
||
type="text"
|
||
v-model="quickSettings.label"
|
||
@input="updateQuickSetting('label', $event)"
|
||
:placeholder="getPlaceholder('label')"
|
||
:classes="{ outer: 'mb-0', input: 'text-sm' }"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Name Field -->
|
||
<div v-if="showQuickField('name')" class="setting-item">
|
||
<label class="setting-label">Field Name</label>
|
||
<FormKit
|
||
type="text"
|
||
v-model="quickSettings.name"
|
||
@input="updateQuickSetting('name', $event)"
|
||
:placeholder="getPlaceholder('name')"
|
||
:classes="{ outer: 'mb-0', input: 'text-sm' }"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Width Setting -->
|
||
<div v-if="showQuickField('width')" class="setting-item">
|
||
<label class="setting-label">Field Width</label>
|
||
<div class="width-selector-compact">
|
||
<button
|
||
v-for="option in compactWidthOptions"
|
||
:key="option.value"
|
||
@click="updateQuickSetting('width', option.value, option.gridColumns)"
|
||
class="width-btn"
|
||
:class="{ 'active': getComponentWidthPercent() === option.value }"
|
||
:title="option.description"
|
||
>
|
||
{{ option.name }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Required Toggle -->
|
||
<div v-if="showQuickField('required')" class="setting-item">
|
||
<label class="setting-toggle">
|
||
<input
|
||
type="checkbox"
|
||
v-model="quickSettings.required"
|
||
@change="updateQuickValidation('required', $event.target.checked)"
|
||
class="toggle-input"
|
||
/>
|
||
<span class="toggle-slider"></span>
|
||
<span class="toggle-label">Required Field</span>
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Placeholder -->
|
||
<div v-if="showQuickField('placeholder')" class="setting-item">
|
||
<label class="setting-label">Placeholder Text</label>
|
||
<FormKit
|
||
type="text"
|
||
v-model="quickSettings.placeholder"
|
||
@input="updateQuickSetting('placeholder', $event)"
|
||
:placeholder="getPlaceholder('placeholder')"
|
||
:classes="{ outer: 'mb-0', input: 'text-sm' }"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Collapsed State -->
|
||
<div
|
||
v-else
|
||
class="collapsed-info"
|
||
>
|
||
<!-- Collapsed Header with Toggle -->
|
||
<div class="collapsed-header">
|
||
<button
|
||
@click="toggleFieldSettingsPanel"
|
||
class="collapsed-toggle-btn"
|
||
title="Expand Settings Panel"
|
||
>
|
||
<Icon name="heroicons:chevron-left" class="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Collapsed Content -->
|
||
<div class="collapsed-content">
|
||
<!-- Selected Component Info -->
|
||
<div v-if="formStore.selectedComponent" class="selected-component-collapsed">
|
||
<div class="component-icon-collapsed">
|
||
<Icon :name="getComponentIcon(formStore.selectedComponent.type)" class="w-4 h-4 text-blue-600" />
|
||
</div>
|
||
<div class="component-type-badge">
|
||
{{ getComponentTypeShort(formStore.selectedComponent.type) }}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- No Selection State -->
|
||
<div v-else class="no-selection-collapsed">
|
||
<Icon name="heroicons:cursor-arrow-rays" class="w-4 h-4 text-gray-400 mb-2" />
|
||
<div class="text-xs text-gray-400 text-center writing-mode-vertical">
|
||
Select field
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Empty state - No form selected -->
|
||
<div v-else class="flex-1 flex items-center justify-center bg-gray-50">
|
||
<div class="text-center p-8 max-w-md">
|
||
<Icon name="material-symbols:description" class="w-16 h-16 mx-auto mb-4 text-gray-400" />
|
||
<h2 class="text-xl font-semibold text-gray-800 mb-2">Create a New Form</h2>
|
||
<p class="text-gray-600 mb-6">Get started by creating a new form or navigate back to manage your existing forms.</p>
|
||
|
||
<div class="space-y-3">
|
||
<div class="mb-4">
|
||
<FormKit
|
||
v-model="newFormName"
|
||
type="text"
|
||
label="Form Name"
|
||
placeholder="Enter a name for your new form"
|
||
validation="required"
|
||
/>
|
||
|
||
<FormKit
|
||
v-model="newFormDescription"
|
||
type="textarea"
|
||
label="Description (Optional)"
|
||
placeholder="Enter a description"
|
||
:rows="3"
|
||
/>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<FormKit
|
||
v-model="newFormCategory"
|
||
type="select"
|
||
label="Category (Optional)"
|
||
placeholder="Select a category"
|
||
:options="categoryOptions"
|
||
/>
|
||
|
||
<FormKit
|
||
v-model="newFormGroup"
|
||
type="text"
|
||
label="Group (Optional)"
|
||
placeholder="Enter a group name"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex justify-center gap-4">
|
||
<RsButton @click="createNewForm" variant="primary" :disabled="!newFormName.trim()">
|
||
<Icon name="material-symbols:add" class="mr-1" />
|
||
Create Form
|
||
</RsButton>
|
||
|
||
<RsButton @click="navigateToManage" variant="tertiary">
|
||
<Icon name="material-symbols:arrow-back" class="mr-1" />
|
||
Back to Forms
|
||
</RsButton>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Form Settings Modal -->
|
||
<RsModal v-model="showFormSettings" title="Form Settings & Scripts" size="xl" position="center">
|
||
<div>
|
||
<RsTab :tabs="settingsTabs" v-model="activeSettingsTab">
|
||
<!-- Form Info Tab -->
|
||
<template #info>
|
||
<div class="p-4 space-y-4">
|
||
<FormKit
|
||
type="text"
|
||
label="Form Name"
|
||
v-model="formStore.formName"
|
||
help="Name of your form"
|
||
validation="required"
|
||
/>
|
||
|
||
<FormKit
|
||
type="textarea"
|
||
label="Form Description"
|
||
v-model="formStore.formDescription"
|
||
help="Brief description of what this form is for"
|
||
rows="3"
|
||
/>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<FormKit
|
||
type="select"
|
||
label="Category"
|
||
v-model="formStore.formCategory"
|
||
:options="categoryOptions"
|
||
help="Categorize your form for better organization"
|
||
/>
|
||
|
||
<FormKit
|
||
type="text"
|
||
label="Group"
|
||
v-model="formStore.formGroup"
|
||
help="Group related forms together"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Submit Button Settings Tab -->
|
||
<template #submit>
|
||
<div class="p-4 space-y-4">
|
||
<div class="bg-blue-50 border border-blue-200 text-blue-800 p-3 rounded mb-4">
|
||
<div class="flex items-start">
|
||
<Icon name="material-symbols:info" class="w-5 h-5 mr-2 mt-0.5" />
|
||
<div>
|
||
<h4 class="font-medium text-sm">Submit Button Configuration</h4>
|
||
<p class="text-xs mt-1">
|
||
Configure the default submit button that appears at the bottom of your form.
|
||
Note: If you add a custom submit button component to your form, the default button will be hidden automatically.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Enable/Disable Submit Button -->
|
||
<div class="space-y-2">
|
||
<label class="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
v-model="formStore.submitButton.enabled"
|
||
class="form-checkbox h-4 w-4 text-blue-600 focus:ring-blue-500"
|
||
/>
|
||
<span class="ml-2 text-sm font-medium text-gray-700">Show default submit button</span>
|
||
</label>
|
||
<p class="text-xs text-gray-500 ml-6">
|
||
When enabled, a submit button will appear at the bottom of your form unless you add a custom submit button component.
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Submit Button Label -->
|
||
<div v-if="formStore.submitButton.enabled">
|
||
<FormKit
|
||
type="text"
|
||
label="Submit Button Text"
|
||
v-model="formStore.submitButton.label"
|
||
help="The text that appears on the submit button"
|
||
validation="required"
|
||
placeholder="Submit"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Submit Button Category -->
|
||
<div v-if="formStore.submitButton.enabled">
|
||
<FormKit
|
||
type="select"
|
||
label="Button Style Category"
|
||
v-model="formStore.submitButton.category"
|
||
help="Primary buttons are more prominent, secondary are more subtle"
|
||
:options="[
|
||
{ label: 'Primary', value: 'primary' },
|
||
{ label: 'Secondary', value: 'secondary' }
|
||
]"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Submit Button Color -->
|
||
<div v-if="formStore.submitButton.enabled">
|
||
<FormKit
|
||
type="select"
|
||
label="Button Color"
|
||
v-model="formStore.submitButton.color"
|
||
help="Choose the color theme for the submit button"
|
||
:options="[
|
||
{ label: 'Primary', value: 'primary' },
|
||
{ label: 'Secondary', value: 'secondary' },
|
||
{ label: 'Success', value: 'success' },
|
||
{ label: 'Info', value: 'info' },
|
||
{ label: 'Warning', value: 'warning' },
|
||
{ label: 'Danger', value: 'danger' }
|
||
]"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Preview Section -->
|
||
<div v-if="formStore.submitButton.enabled" class="border-t pt-4">
|
||
<h4 class="text-sm font-medium text-gray-700 mb-2">Preview</h4>
|
||
<div class="bg-gray-50 p-4 rounded border">
|
||
<FormKit
|
||
type="submit"
|
||
:label="formStore.submitButton.label || 'Submit'"
|
||
:classes="{
|
||
input: getSubmitButtonStyles(formStore.submitButton.category, formStore.submitButton.color).classes
|
||
}"
|
||
:style="getSubmitButtonStyles(formStore.submitButton.category, formStore.submitButton.color).style"
|
||
disabled
|
||
/>
|
||
<p class="text-xs text-gray-500 mt-2">
|
||
This is how your submit button will appear in the form.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Custom JavaScript Tab -->
|
||
<template #javascript>
|
||
<div class="p-4">
|
||
<div class="mb-4">
|
||
<h3 class="text-lg font-medium mb-2">Custom JavaScript</h3>
|
||
<p class="text-sm text-gray-600 mb-4">
|
||
Write custom JavaScript to add dynamic behavior to your form. Scripts run securely in a sandboxed environment with access to form fields and utility functions.
|
||
</p>
|
||
|
||
<!-- Helper Functions Reference -->
|
||
<div class="mb-4 p-3 bg-blue-50 rounded border border-blue-200">
|
||
<details>
|
||
<summary class="text-sm font-medium text-blue-800 cursor-pointer">📚 Core Functions</summary>
|
||
<div class="mt-2 text-xs text-blue-700 space-y-1">
|
||
<div><code>getField('name')</code> - Get field value</div>
|
||
<div><code>setField('name', value)</code> - Set field value and trigger events</div>
|
||
<div><code>hideField('name')</code> - Hide a field</div>
|
||
<div><code>showField('name')</code> - Show a field</div>
|
||
<div><code>disableField('name')</code> - Disable a field</div>
|
||
<div><code>enableField('name')</code> - Enable a field</div>
|
||
<div><code>validateField('name')</code> - Trigger field validation</div>
|
||
<div><code>getAllFieldValues()</code> - Get all form values as object</div>
|
||
<div><code>onFieldChange(['field1', 'field2'], callback)</code> - Listen for field changes</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
<!-- User Interface Functions -->
|
||
<div class="mb-4 p-3 bg-green-50 rounded border border-green-200">
|
||
<details>
|
||
<summary class="text-sm font-medium text-green-800 cursor-pointer">🎨 UI & Notifications</summary>
|
||
<div class="mt-2 text-xs text-green-700 space-y-1">
|
||
<div><code>showSuccess('message')</code> - Display success notification</div>
|
||
<div><code>showError('message')</code> - Display error notification</div>
|
||
<div><code>showInfo('message')</code> - Display info notification</div>
|
||
<div><code>querySelector('selector')</code> - Safe DOM querying within form</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
<!-- Utility Functions -->
|
||
<div class="mb-4 p-3 bg-purple-50 rounded border border-purple-200">
|
||
<details>
|
||
<summary class="text-sm font-medium text-purple-800 cursor-pointer">🛠️ Utilities</summary>
|
||
<div class="mt-2 text-xs text-purple-700 space-y-1">
|
||
<div><code>Math.*</code> - Mathematical functions (Math.round, Math.max, etc.)</div>
|
||
<div><code>Date</code> - Date object for date/time operations</div>
|
||
<div><code>Number()</code>, <code>String()</code>, <code>Array</code> - Type conversion</div>
|
||
<div><code>setTimeout()</code>, <code>setInterval()</code> - Timing functions</div>
|
||
<div><code>console.log()</code>, <code>console.warn()</code> - Debug logging</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
</div>
|
||
|
||
<RsCodeMirror
|
||
v-model="formStore.formCustomScript"
|
||
language="javascript"
|
||
height="300px"
|
||
placeholder="// Example: Hide/show fields based on selection
|
||
// onFieldChange('customer_type', (value) => {
|
||
// if (value === 'business') {
|
||
// showField('company_name');
|
||
// showField('tax_id');
|
||
// showInfo('Business information fields are now visible');
|
||
// } else {
|
||
// hideField('company_name');
|
||
// hideField('tax_id');
|
||
// }
|
||
// });
|
||
|
||
// Example: Real-time calculations
|
||
// onFieldChange(['quantity', 'price'], () => {
|
||
// const qty = Number(getField('quantity')) || 0;
|
||
// const price = Number(getField('price')) || 0;
|
||
// const total = Math.round((qty * price) * 100) / 100;
|
||
// setField('total', total);
|
||
//
|
||
// if (total > 1000) {
|
||
// showSuccess('Discount applied for orders over $1000!');
|
||
// }
|
||
// });
|
||
|
||
// Example: Conditional validation
|
||
// onFieldChange('email', (value) => {
|
||
// if (value && !value.includes('@')) {
|
||
// showError('Please enter a valid email address');
|
||
// }
|
||
// });"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Custom CSS Tab -->
|
||
<template #css>
|
||
<div class="p-4">
|
||
<div class="mb-4">
|
||
<h3 class="text-lg font-medium mb-2">Custom CSS</h3>
|
||
<p class="text-sm text-gray-600 mb-4">
|
||
Add custom styles to enhance your form appearance. Use <code class="bg-gray-100 px-1 rounded">.form-container</code> to target the form.
|
||
</p>
|
||
|
||
<!-- CSS Helper Reference -->
|
||
<div class="mb-4 p-3 bg-green-50 rounded border border-green-200">
|
||
<details>
|
||
<summary class="text-sm font-medium text-green-800 cursor-pointer">🎨 CSS Selectors</summary>
|
||
<div class="mt-2 text-xs text-green-700 space-y-1">
|
||
<div><code>.form-container</code> - Main form wrapper</div>
|
||
<div><code>.form-field</code> - Individual form fields</div>
|
||
<div><code>.form-field[data-name="fieldName"]</code> - Specific field</div>
|
||
<div><code>.form-section</code> - Form sections</div>
|
||
<div><code>.form-submit</code> - Submit button</div>
|
||
<div><code>.info-display</code> - Info display components</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
</div>
|
||
|
||
<RsCodeMirror
|
||
v-model="formStore.formCustomCSS"
|
||
language="css"
|
||
height="300px"
|
||
placeholder="/* Example: Custom field styling */
|
||
/* .form-field[data-name='customer_name'] {
|
||
background: #f0f9ff;
|
||
border: 2px solid #0ea5e9;
|
||
}
|
||
|
||
/* Example: Hide field initially */
|
||
/* .form-field[data-name='hidden_field'] {
|
||
display: none;
|
||
}
|
||
|
||
/* Example: Custom info display styling */
|
||
/* .info-display {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
border-radius: 12px;
|
||
} */"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Script Events Tab -->
|
||
<template #events>
|
||
<div class="p-4">
|
||
<div class="mb-4">
|
||
<h3 class="text-lg font-medium mb-2">Form Events</h3>
|
||
<p class="text-sm text-gray-600 mb-4">
|
||
Configure when your custom scripts should run. Field change events are detected automatically in real-time when users interact with form fields.
|
||
</p>
|
||
|
||
<!-- Important Note -->
|
||
<div class="mb-4 p-3 bg-amber-50 rounded border border-amber-200">
|
||
<div class="flex items-start">
|
||
<Icon name="material-symbols:info" class="w-4 h-4 text-amber-600 mr-2 mt-0.5" />
|
||
<div class="text-xs text-amber-700">
|
||
<strong>Note:</strong> The "On Field Change" event must be enabled for <code>onFieldChange()</code> callbacks to work in your scripts.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-4">
|
||
<div class="border rounded p-4">
|
||
<h4 class="font-medium mb-2">Event Triggers</h4>
|
||
<div class="space-y-2">
|
||
<label class="flex items-center">
|
||
<input type="checkbox" v-model="formStore.formEvents.onLoad" class="mr-2">
|
||
<span class="text-sm">On Form Load</span>
|
||
</label>
|
||
<label class="flex items-center">
|
||
<input type="checkbox" v-model="formStore.formEvents.onFieldChange" class="mr-2">
|
||
<span class="text-sm">On Field Change</span>
|
||
</label>
|
||
<label class="flex items-center">
|
||
<input type="checkbox" v-model="formStore.formEvents.onSubmit" class="mr-2">
|
||
<span class="text-sm">Before Form Submit</span>
|
||
</label>
|
||
<label class="flex items-center">
|
||
<input type="checkbox" v-model="formStore.formEvents.onValidation" class="mr-2">
|
||
<span class="text-sm">On Field Validation</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="border rounded p-4">
|
||
<h4 class="font-medium mb-2">Script Execution Mode</h4>
|
||
<div class="space-y-3">
|
||
<label class="flex items-start">
|
||
<input type="radio" v-model="formStore.scriptMode" value="safe" class="mr-2 mt-0.5">
|
||
<div>
|
||
<span class="text-sm font-medium text-green-700">Safe Mode (Recommended)</span>
|
||
<p class="text-xs text-gray-600 mt-1">Scripts run in a secure sandbox with access only to form functions. Best for most use cases.</p>
|
||
</div>
|
||
</label>
|
||
<label class="flex items-start">
|
||
<input type="radio" v-model="formStore.scriptMode" value="advanced" class="mr-2 mt-0.5">
|
||
<div>
|
||
<span class="text-sm font-medium text-orange-700">Advanced Mode</span>
|
||
<p class="text-xs text-gray-600 mt-1">Provides broader JavaScript access but with security restrictions. Use with caution.</p>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- JSON Tab -->
|
||
<template #json>
|
||
<div class="p-4">
|
||
<div class="mb-4">
|
||
<h3 class="text-lg font-medium mb-2">Form Configuration</h3>
|
||
<p class="text-sm text-gray-600 mb-4">
|
||
This section displays the complete form configuration as JSON for developers.
|
||
</p>
|
||
|
||
<!-- Form metadata -->
|
||
<div class="bg-gray-50 p-3 rounded border mb-4 text-sm">
|
||
<div class="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<span class="font-medium">Component Count:</span> {{ formStore.formComponents.length }}
|
||
</div>
|
||
<div>
|
||
<span class="font-medium">Form ID:</span> {{ formStore.currentFormId || 'Not saved yet' }}
|
||
</div>
|
||
<div>
|
||
<span class="font-medium">Has Script:</span> {{ formStore.formCustomScript ? 'Yes' : 'No' }}
|
||
</div>
|
||
<div>
|
||
<span class="font-medium">Has Custom CSS:</span> {{ formStore.formCustomCSS ? 'Yes' : 'No' }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Action buttons -->
|
||
<div class="flex flex-wrap justify-between mb-2 gap-2">
|
||
<div>
|
||
<!-- Import button and hidden file input -->
|
||
<input
|
||
type="file"
|
||
ref="jsonFileInput"
|
||
accept=".json"
|
||
style="display: none"
|
||
@change="handleJsonImport"
|
||
/>
|
||
<RsButton @click="$refs.jsonFileInput.click()" size="sm" variant="primary">
|
||
<Icon name="material-symbols:upload" class="mr-1" />
|
||
Import JSON
|
||
</RsButton>
|
||
</div>
|
||
|
||
<div class="flex space-x-2">
|
||
<RsButton @click="toggleJsonEditMode" size="sm" :variant="isEditingJson ? 'primary' : 'secondary'">
|
||
<Icon :name="isEditingJson ? 'material-symbols:edit-off' : 'material-symbols:edit'" class="mr-1" />
|
||
{{ isEditingJson ? 'View Mode' : 'Edit Mode' }}
|
||
</RsButton>
|
||
<RsButton @click="downloadJsonFile" size="sm" variant="secondary">
|
||
<Icon name="material-symbols:download" class="mr-1" />
|
||
Download
|
||
</RsButton>
|
||
<RsButton @click="copyJsonToClipboard" size="sm" variant="secondary">
|
||
<Icon name="material-symbols:content-copy" class="mr-1" />
|
||
Copy
|
||
</RsButton>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Warning message when in edit mode -->
|
||
<div v-if="isEditingJson" class="bg-yellow-50 border border-yellow-200 text-yellow-800 p-3 rounded mb-4">
|
||
<div class="flex items-start">
|
||
<Icon name="material-symbols:warning" class="w-5 h-5 mr-2 mt-0.5" />
|
||
<div>
|
||
<h4 class="font-medium text-sm">Warning: Direct JSON Editing</h4>
|
||
<p class="text-xs mt-1">
|
||
Changes made to JSON directly will replace your form configuration when you click "Apply JSON Changes".
|
||
Ensure the JSON is valid before applying changes.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<RsCodeMirror
|
||
v-if="isEditingJson"
|
||
v-model="editableJsonString"
|
||
language="json"
|
||
height="300px"
|
||
:readonly="false"
|
||
/>
|
||
<RsCodeMirror
|
||
v-else
|
||
:value="formJsonString"
|
||
language="json"
|
||
height="300px"
|
||
:readonly="true"
|
||
/>
|
||
|
||
<!-- Apply changes button only visible in edit mode -->
|
||
<div v-if="isEditingJson" class="mt-4 flex justify-end">
|
||
<RsButton @click="applyJsonChanges" variant="primary" size="md">
|
||
Apply JSON Changes
|
||
</RsButton>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</RsTab>
|
||
</div>
|
||
<template #footer>
|
||
<div class="flex justify-end gap-2">
|
||
<RsButton @click="showFormSettings = false" variant="tertiary">
|
||
Cancel
|
||
</RsButton>
|
||
<RsButton @click="saveFormSettings" variant="primary">
|
||
Save Settings
|
||
</RsButton>
|
||
</div>
|
||
</template>
|
||
</RsModal>
|
||
|
||
<!-- Unsaved Changes Modal -->
|
||
<RsModal v-model="showUnsavedChangesModal" title="Unsaved Changes" size="md" position="center">
|
||
<div class="p-4">
|
||
<div class="flex items-center mb-4">
|
||
<Icon name="material-symbols:warning-outline" class="text-yellow-500 w-8 h-8 mr-3" />
|
||
<div>
|
||
<h3 class="font-medium text-lg">You have unsaved changes</h3>
|
||
<p class="text-gray-600">Are you sure you want to leave? Your changes will be lost.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<div class="flex justify-end gap-2">
|
||
<RsButton @click="cancelNavigation" variant="tertiary">
|
||
Stay
|
||
</RsButton>
|
||
<RsButton @click="confirmNavigation" variant="danger">
|
||
Leave
|
||
</RsButton>
|
||
</div>
|
||
</template>
|
||
</RsModal>
|
||
|
||
<!-- Add Templates Modal -->
|
||
<FormTemplatesModal
|
||
v-model="showTemplatesModal"
|
||
@select-template="applyFormTemplate"
|
||
/>
|
||
|
||
<!-- Field Settings Modal -->
|
||
<FormBuilderFieldSettingsModal
|
||
v-model="showFieldSettings"
|
||
:component="formStore.selectedComponent"
|
||
@update-component="handleUpdateComponent"
|
||
@close="showFieldSettings = false"
|
||
/>
|
||
|
||
<!-- Form History Modal -->
|
||
<FormHistoryModal
|
||
:is-open="showFormHistoryModal"
|
||
:form-id="formStore.currentFormId"
|
||
@close="showFormHistoryModal = false"
|
||
@restored="handleFormRestored"
|
||
/>
|
||
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { useFormBuilderStore } from "~/stores/formBuilder";
|
||
import { nextTick } from 'vue';
|
||
import FormBuilderHistory from "~/components/FormBuilderHistory.vue";
|
||
import FormTemplatesModal from '~/components/FormTemplatesModal.vue';
|
||
import FormBuilderFieldSettingsModal from '~/components/FormBuilderFieldSettingsModal.vue';
|
||
import FormScriptEngine from '~/components/FormScriptEngine.vue';
|
||
import ConditionalLogicEngine from '~/components/ConditionalLogicEngine.vue';
|
||
import FormHistoryModal from '~/components/FormHistoryModal.vue';
|
||
|
||
definePageMeta({
|
||
title: "Form Builder",
|
||
description: "Create dynamic forms with drag and drop",
|
||
layout: "empty",
|
||
middleware: ["auth"],
|
||
requiresAuth: true,
|
||
});
|
||
|
||
const router = useRouter();
|
||
const formStore = useFormBuilderStore();
|
||
let toast;
|
||
|
||
// Track if we're creating a new form
|
||
const isCreatingForm = ref(false);
|
||
const newFormName = ref('');
|
||
const newFormDescription = ref('');
|
||
const newFormCategory = ref('');
|
||
const newFormGroup = ref('');
|
||
|
||
// Try to use the toast composable if available
|
||
try {
|
||
toast = useToast();
|
||
} catch (error) {
|
||
// Create a simple toast object if composable is not available
|
||
toast = {
|
||
success: (msg) => console.log('Success:', msg),
|
||
error: (msg) => console.error('Error:', msg),
|
||
info: (msg) => console.info('Info:', msg),
|
||
warning: (msg) => console.warn('Warning:', msg)
|
||
};
|
||
}
|
||
|
||
// Reactive state
|
||
const isPreview = ref(false);
|
||
const showFormSettings = ref(false);
|
||
const showTemplatesModal = ref(false);
|
||
const showFormHistoryModal = ref(false);
|
||
const showDropdown = ref(false);
|
||
const leftSidebarTab = ref('components');
|
||
const showFieldSettingsPanel = ref(true);
|
||
const showFieldSettings = ref(false);
|
||
const showUnsavedChangesModal = ref(false);
|
||
const navigationConfirmed = ref(false);
|
||
const pendingNavigation = ref(null);
|
||
const navigationTarget = ref(null);
|
||
const activeSettingsTab = ref('info');
|
||
|
||
// Helper function to get submit button CSS classes and styles based on category and color
|
||
const getSubmitButtonStyles = (category, color) => {
|
||
const baseClasses = 'px-4 py-2 rounded font-medium transition-all duration-200 text-white border-0';
|
||
|
||
// Use CSS custom properties from theme.css with opacity based on category
|
||
const opacity = category === 'primary' ? '1' : '0.8';
|
||
const hoverOpacity = category === 'primary' ? '0.9' : '0.7';
|
||
|
||
return {
|
||
classes: baseClasses,
|
||
style: {
|
||
backgroundColor: `rgba(var(--color-${color}), ${opacity})`,
|
||
'--hover-bg': `rgba(var(--color-${color}), ${hoverOpacity})`
|
||
}
|
||
};
|
||
};
|
||
|
||
// Responsive device preview state
|
||
const selectedDevice = ref('Desktop');
|
||
const deviceSizes = ref([
|
||
{
|
||
name: 'Mobile',
|
||
width: 375,
|
||
height: 667,
|
||
icon: 'material-symbols:smartphone',
|
||
class: 'device-mobile'
|
||
},
|
||
{
|
||
name: 'Tablet',
|
||
width: 768,
|
||
height: 1024,
|
||
icon: 'material-symbols:tablet',
|
||
class: 'device-tablet'
|
||
},
|
||
{
|
||
name: 'Desktop',
|
||
width: 1200,
|
||
height: 800,
|
||
icon: 'material-symbols:desktop-windows',
|
||
class: 'device-desktop'
|
||
}
|
||
]);
|
||
|
||
const currentDeviceConfig = computed(() => {
|
||
return deviceSizes.value.find(device => device.name === selectedDevice.value) || deviceSizes.value[2];
|
||
});
|
||
|
||
const previewForm = ref(null);
|
||
const formScriptEngine = ref(null);
|
||
const conditionalLogicEngine = ref(null);
|
||
|
||
// Settings tabs configuration
|
||
const settingsTabs = [
|
||
{ key: 'info', label: 'Form Info', icon: 'material-symbols:info-outline' },
|
||
{ key: 'submit', label: 'Submit Button', icon: 'material-symbols:play-arrow' },
|
||
{ key: 'javascript', label: 'JavaScript', icon: 'material-symbols:code' },
|
||
{ key: 'css', label: 'CSS', icon: 'material-symbols:palette-outline' },
|
||
{ key: 'events', label: 'Events', icon: 'material-symbols:event-outline' },
|
||
{ key: 'json', label: 'Source', icon: 'material-symbols:data-object' }
|
||
];
|
||
|
||
// Computed property for form name with getter and setter
|
||
const formName = computed({
|
||
get: () => formStore.formName,
|
||
set: (value) => {
|
||
if (value !== formStore.formName) {
|
||
formStore.setFormName(value);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Category options for form creation
|
||
const categoryOptions = [
|
||
{ label: 'Select Category', value: '' },
|
||
{ label: 'Forms', value: 'forms' },
|
||
{ label: 'Surveys', value: 'surveys' },
|
||
{ label: 'Applications', value: 'applications' },
|
||
{ label: 'Feedback', value: 'feedback' },
|
||
{ label: 'Registration', value: 'registration' },
|
||
{ label: 'Other', value: 'other' }
|
||
];
|
||
|
||
// Computed to check if we have a current form loaded
|
||
const hasCurrentForm = computed(() => {
|
||
// Consider a form loaded if any of these conditions are true:
|
||
// 1. Form has components (user has added fields)
|
||
// 2. Form has an ID (saved form)
|
||
// 3. Form name has been changed from default (form creation completed)
|
||
return formStore.formComponents.length > 0 ||
|
||
formStore.currentFormId ||
|
||
(formStore.formName &&
|
||
formStore.formName.trim() !== '' &&
|
||
formStore.formName !== 'New Form');
|
||
});
|
||
|
||
// Form JSON representation for developer view
|
||
const formJson = computed(() => {
|
||
return {
|
||
formName: formStore.formName,
|
||
formDescription: formStore.formDescription,
|
||
formId: formStore.currentFormId,
|
||
components: formStore.formComponents.map(component => ({
|
||
id: component.id,
|
||
type: component.type,
|
||
props: component.props
|
||
})),
|
||
customScript: formStore.formCustomScript,
|
||
customCSS: formStore.formCustomCSS,
|
||
formEvents: formStore.formEvents,
|
||
scriptMode: formStore.scriptMode,
|
||
submitButton: formStore.submitButton
|
||
};
|
||
});
|
||
|
||
// Convert form JSON to formatted string
|
||
const formJsonString = computed(() => {
|
||
return JSON.stringify(formJson.value, null, 2);
|
||
});
|
||
|
||
// JSON editing state
|
||
const isEditingJson = ref(false);
|
||
const editableJsonString = ref('');
|
||
|
||
// Toggle between read-only and edit mode for JSON
|
||
const toggleJsonEditMode = () => {
|
||
if (!isEditingJson.value) {
|
||
// Switching to edit mode - initialize the editable string
|
||
editableJsonString.value = formJsonString.value;
|
||
}
|
||
isEditingJson.value = !isEditingJson.value;
|
||
};
|
||
|
||
// Apply changes from JSON editor
|
||
const applyJsonChanges = () => {
|
||
try {
|
||
const importedJson = JSON.parse(editableJsonString.value);
|
||
|
||
// Validate imported JSON
|
||
if (!importedJson.formName || !Array.isArray(importedJson.components)) {
|
||
throw new Error('Invalid form JSON structure. Must include formName and components array.');
|
||
}
|
||
|
||
// Import form data
|
||
formStore.setFormName(importedJson.formName);
|
||
formStore.formDescription = importedJson.formDescription || '';
|
||
formStore.formComponents = [];
|
||
|
||
// Import components
|
||
if (importedJson.components.length > 0) {
|
||
importedJson.components.forEach((component, index) => {
|
||
if (component.type && component.props) {
|
||
// Process component properties based on type
|
||
let processedProps = { ...component.props };
|
||
|
||
// Ensure required grid properties
|
||
if (!processedProps.width) {
|
||
processedProps.width = '100%';
|
||
}
|
||
|
||
if (!processedProps.gridColumn) {
|
||
processedProps.gridColumn = 'span 12';
|
||
}
|
||
|
||
// Handle special component types
|
||
switch (component.type) {
|
||
case 'image-preview':
|
||
// Ensure all required image preview properties
|
||
processedProps = {
|
||
label: processedProps.label || 'Image Preview',
|
||
name: processedProps.name || `image_preview_${index + 1}`,
|
||
help: processedProps.help || '',
|
||
imageUrl: processedProps.imageUrl || 'https://placehold.co/600x400',
|
||
altText: processedProps.altText || 'Preview image',
|
||
caption: processedProps.caption || '',
|
||
showZoom: processedProps.showZoom !== undefined ? processedProps.showZoom : true,
|
||
showCaption: processedProps.showCaption !== undefined ? processedProps.showCaption : true,
|
||
maxWidth: processedProps.maxWidth || '100%',
|
||
height: processedProps.height || 'auto',
|
||
...processedProps
|
||
};
|
||
break;
|
||
|
||
case 'repeating-group':
|
||
// Ensure all required repeating group properties
|
||
processedProps = {
|
||
label: processedProps.label || 'Repeating Group',
|
||
name: processedProps.name || `repeating_group_${index + 1}`,
|
||
help: processedProps.help || '',
|
||
minItems: processedProps.minItems !== undefined ? processedProps.minItems : 1,
|
||
maxItems: processedProps.maxItems !== undefined ? processedProps.maxItems : 10,
|
||
buttonText: processedProps.buttonText || 'Add Item',
|
||
removeText: processedProps.removeText || 'Remove',
|
||
fields: processedProps.fields || [
|
||
{ type: 'text', name: 'field_1', label: 'Field 1', placeholder: 'Enter value' }
|
||
],
|
||
...processedProps
|
||
};
|
||
break;
|
||
|
||
case 'dynamic-list':
|
||
// Ensure all required dynamic list properties
|
||
processedProps = {
|
||
label: processedProps.label || 'Dynamic List',
|
||
name: processedProps.name || `dynamic_list_${index + 1}`,
|
||
help: processedProps.help || '',
|
||
placeholder: processedProps.placeholder || 'Enter item',
|
||
buttonText: processedProps.buttonText || 'Add Item',
|
||
minItems: processedProps.minItems !== undefined ? processedProps.minItems : 0,
|
||
maxItems: processedProps.maxItems !== undefined ? processedProps.maxItems : 20,
|
||
defaultItems: Array.isArray(processedProps.defaultItems) ? processedProps.defaultItems : ['Item 1', 'Item 2'],
|
||
...processedProps
|
||
};
|
||
break;
|
||
|
||
case 'info-display':
|
||
// Ensure all required info display properties
|
||
processedProps = {
|
||
title: processedProps.title || 'Information',
|
||
name: processedProps.name || `info_display_${index + 1}`,
|
||
help: processedProps.help || '',
|
||
layout: processedProps.layout || 'vertical',
|
||
showBorder: processedProps.showBorder !== undefined ? processedProps.showBorder : true,
|
||
backgroundColor: processedProps.backgroundColor || '#f8fafc',
|
||
fields: Array.isArray(processedProps.fields) ? processedProps.fields : [
|
||
{ label: 'Info Item', value: 'Value', key: 'item_1' }
|
||
],
|
||
...processedProps
|
||
};
|
||
break;
|
||
|
||
case 'file':
|
||
// Ensure all required file upload properties
|
||
processedProps = {
|
||
label: processedProps.label || 'File Upload',
|
||
name: processedProps.name || `file_upload_${index + 1}`,
|
||
help: processedProps.help || 'Upload a file',
|
||
accept: processedProps.accept || '.pdf,.doc,.docx,.jpg,.jpeg,.png',
|
||
...processedProps
|
||
};
|
||
break;
|
||
|
||
case 'heading':
|
||
// Ensure all required heading properties
|
||
processedProps = {
|
||
value: processedProps.value || 'Heading',
|
||
level: processedProps.level || 2,
|
||
...processedProps
|
||
};
|
||
break;
|
||
|
||
case 'paragraph':
|
||
// Ensure all required paragraph properties
|
||
processedProps = {
|
||
value: processedProps.value || 'Paragraph text',
|
||
...processedProps
|
||
};
|
||
break;
|
||
|
||
case 'select':
|
||
case 'radio':
|
||
case 'checkbox':
|
||
// Ensure options array exists
|
||
if (!Array.isArray(processedProps.options) || processedProps.options.length === 0) {
|
||
processedProps.options = [
|
||
{ label: 'Option 1', value: 'option_1' },
|
||
{ label: 'Option 2', value: 'option_2' }
|
||
];
|
||
}
|
||
break;
|
||
|
||
default:
|
||
// Basic properties for all other component types
|
||
if (!processedProps.label && !['heading', 'paragraph', 'divider'].includes(component.type)) {
|
||
processedProps.label = component.type.charAt(0).toUpperCase() + component.type.slice(1) + ' ' + (index + 1);
|
||
}
|
||
|
||
if (!processedProps.name && !['heading', 'paragraph', 'divider'].includes(component.type)) {
|
||
processedProps.name = `${component.type}_${index + 1}`;
|
||
}
|
||
break;
|
||
}
|
||
|
||
// Create component in expected format
|
||
const formattedComponent = {
|
||
type: component.type,
|
||
name: processedProps.label || component.type,
|
||
category: getDefaultCategory(component.type),
|
||
icon: getDefaultIcon(component.type),
|
||
defaultProps: processedProps
|
||
};
|
||
|
||
// Add component to form
|
||
formStore.addComponent(formattedComponent);
|
||
} else {
|
||
console.warn('Skipping invalid component:', component);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Import custom code if available
|
||
if (importedJson.customScript !== undefined) {
|
||
formStore.formCustomScript = importedJson.customScript;
|
||
}
|
||
|
||
if (importedJson.customCSS !== undefined) {
|
||
formStore.formCustomCSS = importedJson.customCSS;
|
||
}
|
||
|
||
// Import events if available
|
||
if (importedJson.formEvents) {
|
||
formStore.formEvents = importedJson.formEvents;
|
||
}
|
||
|
||
if (importedJson.scriptMode) {
|
||
formStore.scriptMode = importedJson.scriptMode;
|
||
}
|
||
|
||
// Import submit button settings if available
|
||
if (importedJson.submitButton) {
|
||
formStore.submitButton = {
|
||
enabled: importedJson.submitButton.enabled !== undefined ? importedJson.submitButton.enabled : true,
|
||
label: importedJson.submitButton.label || 'Submit',
|
||
category: importedJson.submitButton.category || 'primary',
|
||
color: importedJson.submitButton.color || 'primary'
|
||
};
|
||
|
||
// Handle backward compatibility with old variant format
|
||
if (importedJson.submitButton.variant && !importedJson.submitButton.category && !importedJson.submitButton.color) {
|
||
const variantMapping = {
|
||
'primary': { category: 'primary', color: 'primary' },
|
||
'secondary': { category: 'secondary', color: 'secondary' },
|
||
'success': { category: 'primary', color: 'success' },
|
||
'warning': { category: 'primary', color: 'warning' },
|
||
'danger': { category: 'primary', color: 'danger' }
|
||
};
|
||
const mapping = variantMapping[importedJson.submitButton.variant] || { category: 'primary', color: 'primary' };
|
||
formStore.submitButton.category = mapping.category;
|
||
formStore.submitButton.color = mapping.color;
|
||
}
|
||
}
|
||
|
||
// Mark as having unsaved changes
|
||
formStore.hasUnsavedChanges = true;
|
||
|
||
// Exit edit mode and update the editable JSON
|
||
isEditingJson.value = false;
|
||
|
||
toast.success('Form configuration updated from JSON');
|
||
} catch (err) {
|
||
console.error('Error applying JSON changes:', err);
|
||
toast.error(`Failed to apply JSON changes: ${err.message}`);
|
||
}
|
||
};
|
||
|
||
// Function to copy JSON to clipboard
|
||
const copyJsonToClipboard = () => {
|
||
navigator.clipboard.writeText(formJsonString.value)
|
||
.then(() => {
|
||
toast.success('JSON copied to clipboard');
|
||
})
|
||
.catch(err => {
|
||
console.error('Failed to copy JSON:', err);
|
||
toast.error('Failed to copy JSON to clipboard');
|
||
});
|
||
};
|
||
|
||
// Function to download JSON file
|
||
const downloadJsonFile = () => {
|
||
try {
|
||
const jsonBlob = new Blob([formJsonString.value], { type: 'application/json' });
|
||
const url = URL.createObjectURL(jsonBlob);
|
||
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = `${formStore.formName.replace(/\s+/g, '-').toLowerCase()}_${new Date().toISOString().split('T')[0]}.json`;
|
||
link.click();
|
||
|
||
URL.revokeObjectURL(url);
|
||
toast.success('JSON file downloaded');
|
||
} catch (err) {
|
||
console.error('Error downloading JSON:', err);
|
||
toast.error('Failed to download JSON file');
|
||
}
|
||
};
|
||
|
||
// Reference to file input element
|
||
const jsonFileInput = ref(null);
|
||
|
||
// Function to handle JSON import
|
||
const handleJsonImport = (event) => {
|
||
const file = event.target.files[0];
|
||
if (!file) return;
|
||
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
try {
|
||
const importedJson = JSON.parse(e.target.result);
|
||
|
||
// If in edit mode, just update the editable content
|
||
if (isEditingJson.value) {
|
||
editableJsonString.value = JSON.stringify(importedJson, null, 2);
|
||
toast.success('JSON loaded into editor. Click "Apply JSON Changes" to update the form.');
|
||
} else {
|
||
// Confirm before replacing current form
|
||
if (formStore.formComponents.length > 0) {
|
||
if (!confirm("This will replace your current form content. Continue?")) {
|
||
// Reset file input
|
||
event.target.value = '';
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Validate imported JSON
|
||
if (!importedJson.formName || !Array.isArray(importedJson.components)) {
|
||
throw new Error('Invalid form JSON structure');
|
||
}
|
||
|
||
// Import form data
|
||
formStore.setFormName(importedJson.formName);
|
||
formStore.formDescription = importedJson.formDescription || '';
|
||
formStore.formComponents = [];
|
||
|
||
// Import components
|
||
if (importedJson.components.length > 0) {
|
||
importedJson.components.forEach((component, index) => {
|
||
if (component.type && component.props) {
|
||
// Process component properties based on type
|
||
let processedProps = { ...component.props };
|
||
|
||
// Ensure required grid properties
|
||
if (!processedProps.width) {
|
||
processedProps.width = '100%';
|
||
}
|
||
|
||
if (!processedProps.gridColumn) {
|
||
processedProps.gridColumn = 'span 12';
|
||
}
|
||
|
||
// Handle special component types
|
||
switch (component.type) {
|
||
case 'image-preview':
|
||
// Ensure all required image preview properties
|
||
processedProps = {
|
||
label: processedProps.label || 'Image Preview',
|
||
name: processedProps.name || `image_preview_${index + 1}`,
|
||
help: processedProps.help || '',
|
||
imageUrl: processedProps.imageUrl || 'https://placehold.co/600x400',
|
||
altText: processedProps.altText || 'Preview image',
|
||
caption: processedProps.caption || '',
|
||
showZoom: processedProps.showZoom !== undefined ? processedProps.showZoom : true,
|
||
showCaption: processedProps.showCaption !== undefined ? processedProps.showCaption : true,
|
||
maxWidth: processedProps.maxWidth || '100%',
|
||
height: processedProps.height || 'auto',
|
||
...processedProps
|
||
};
|
||
break;
|
||
|
||
case 'repeating-group':
|
||
// Ensure all required repeating group properties
|
||
processedProps = {
|
||
label: processedProps.label || 'Repeating Group',
|
||
name: processedProps.name || `repeating_group_${index + 1}`,
|
||
help: processedProps.help || '',
|
||
minItems: processedProps.minItems !== undefined ? processedProps.minItems : 1,
|
||
maxItems: processedProps.maxItems !== undefined ? processedProps.maxItems : 10,
|
||
buttonText: processedProps.buttonText || 'Add Item',
|
||
removeText: processedProps.removeText || 'Remove',
|
||
fields: processedProps.fields || [
|
||
{ type: 'text', name: 'field_1', label: 'Field 1', placeholder: 'Enter value' }
|
||
],
|
||
...processedProps
|
||
};
|
||
break;
|
||
|
||
case 'dynamic-list':
|
||
// Ensure all required dynamic list properties
|
||
processedProps = {
|
||
label: processedProps.label || 'Dynamic List',
|
||
name: processedProps.name || `dynamic_list_${index + 1}`,
|
||
help: processedProps.help || '',
|
||
placeholder: processedProps.placeholder || 'Enter item',
|
||
buttonText: processedProps.buttonText || 'Add Item',
|
||
minItems: processedProps.minItems !== undefined ? processedProps.minItems : 0,
|
||
maxItems: processedProps.maxItems !== undefined ? processedProps.maxItems : 20,
|
||
defaultItems: Array.isArray(processedProps.defaultItems) ? processedProps.defaultItems : ['Item 1', 'Item 2'],
|
||
...processedProps
|
||
};
|
||
break;
|
||
|
||
case 'info-display':
|
||
// Ensure all required info display properties
|
||
processedProps = {
|
||
title: processedProps.title || 'Information',
|
||
name: processedDefaultProps.name || `info_display_${index + 1}`,
|
||
help: processedDefaultProps.help || '',
|
||
layout: processedDefaultProps.layout || 'vertical',
|
||
showBorder: processedDefaultProps.showBorder !== undefined ? processedDefaultProps.showBorder : true,
|
||
backgroundColor: processedDefaultProps.backgroundColor || '#f8fafc',
|
||
fields: Array.isArray(processedDefaultProps.fields) ? processedDefaultProps.fields : [
|
||
{ label: 'Info Item', value: 'Value', key: 'item_1' }
|
||
],
|
||
...processedDefaultProps
|
||
};
|
||
break;
|
||
|
||
case 'file':
|
||
// Ensure all required file upload properties
|
||
processedProps = {
|
||
label: processedDefaultProps.label || 'File Upload',
|
||
name: processedDefaultProps.name || `file_upload_${index + 1}`,
|
||
help: processedDefaultProps.help || 'Upload a file',
|
||
accept: processedDefaultProps.accept || '.pdf,.doc,.docx,.jpg,.jpeg,.png',
|
||
...processedDefaultProps
|
||
};
|
||
break;
|
||
|
||
case 'heading':
|
||
// Ensure all required heading properties
|
||
processedProps = {
|
||
value: processedDefaultProps.value || 'Heading',
|
||
level: processedDefaultProps.level || 2,
|
||
...processedDefaultProps
|
||
};
|
||
break;
|
||
|
||
case 'paragraph':
|
||
// Ensure all required paragraph properties
|
||
processedProps = {
|
||
value: processedDefaultProps.value || 'Paragraph text',
|
||
...processedDefaultProps
|
||
};
|
||
break;
|
||
|
||
case 'select':
|
||
case 'radio':
|
||
case 'checkbox':
|
||
// Ensure options array exists
|
||
if (!Array.isArray(processedDefaultProps.options) || processedDefaultProps.options.length === 0) {
|
||
processedDefaultProps.options = [
|
||
{ label: 'Option 1', value: 'option_1' },
|
||
{ label: 'Option 2', value: 'option_2' }
|
||
];
|
||
}
|
||
// Fall through for other basic properties
|
||
|
||
default:
|
||
// Basic properties for all other component types
|
||
if (!processedDefaultProps.label && !['heading', 'paragraph', 'divider'].includes(component.type)) {
|
||
processedDefaultProps.label = component.name || component.type.charAt(0).toUpperCase() + component.type.slice(1);
|
||
}
|
||
|
||
if (!processedDefaultProps.name && !['heading', 'paragraph', 'divider'].includes(component.type)) {
|
||
processedDefaultProps.name = `${component.type}_${index + 1}`;
|
||
}
|
||
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Create a component in the expected format for addComponent
|
||
const formattedComponent = {
|
||
type: component.type,
|
||
name: component.name || component.type,
|
||
category: getDefaultCategory(component.type),
|
||
icon: getDefaultIcon(component.type),
|
||
// Use the processed default props
|
||
defaultProps: processedDefaultProps
|
||
};
|
||
|
||
console.log('Formatted component:', formattedComponent);
|
||
|
||
// Add the component to the form
|
||
formStore.addComponent(formattedComponent);
|
||
});
|
||
}
|
||
|
||
// Import custom code if available
|
||
if (importedJson.customScript) {
|
||
formStore.formCustomScript = importedJson.customScript;
|
||
}
|
||
|
||
if (importedJson.customCSS) {
|
||
formStore.formCustomCSS = importedJson.customCSS;
|
||
}
|
||
|
||
// Import events if available
|
||
if (importedJson.formEvents) {
|
||
formStore.formEvents = importedJson.formEvents;
|
||
}
|
||
|
||
if (importedJson.scriptMode) {
|
||
formStore.scriptMode = importedJson.scriptMode;
|
||
}
|
||
|
||
// Mark as having unsaved changes
|
||
formStore.hasUnsavedChanges = true;
|
||
|
||
toast.success('Form imported successfully');
|
||
}
|
||
} catch (err) {
|
||
console.error('Error importing JSON:', err);
|
||
toast.error(`Failed to import JSON: ${err.message}`);
|
||
}
|
||
|
||
// Reset file input
|
||
event.target.value = '';
|
||
};
|
||
|
||
reader.onerror = () => {
|
||
toast.error('Error reading file');
|
||
// Reset file input
|
||
event.target.value = '';
|
||
};
|
||
|
||
reader.readAsText(file);
|
||
};
|
||
|
||
// Initialize the form builder
|
||
onMounted(async () => {
|
||
try {
|
||
await formStore.loadSavedForms();
|
||
|
||
// Check if there's a form ID in the URL query parameters
|
||
const route = useRoute();
|
||
const formId = route.query.id;
|
||
|
||
if (formId) {
|
||
try {
|
||
await formStore.loadForm(formId);
|
||
toast.success(`Form '${formStore.formName}' loaded successfully`);
|
||
} catch (error) {
|
||
console.error('Error loading form from ID:', error);
|
||
toast.error(`Failed to load form: ${error.message || 'Unknown error'}`);
|
||
}
|
||
} else {
|
||
// No form ID provided, ensure we start with a clean slate
|
||
formStore.clearForm();
|
||
}
|
||
} catch (error) {
|
||
console.error('Error initializing form builder:', error);
|
||
toast.error(`Initialization error: ${error.message || 'Unknown error'}`);
|
||
}
|
||
|
||
// Add the beforeunload event listener
|
||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||
|
||
// Setup keyboard shortcuts
|
||
window.addEventListener('keydown', handleKeyboardShortcuts);
|
||
|
||
// Close dropdown when clicking outside
|
||
document.addEventListener('click', handleClickOutside);
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
// Remove event listeners
|
||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||
window.removeEventListener('keydown', handleKeyboardShortcuts);
|
||
document.removeEventListener('click', handleClickOutside);
|
||
});
|
||
|
||
// Show warning if there are unsaved changes
|
||
const handleBeforeUnload = (event) => {
|
||
if (formStore.hasUnsavedChanges) {
|
||
event.preventDefault();
|
||
event.returnValue = '';
|
||
return '';
|
||
}
|
||
};
|
||
|
||
// Handle keyboard shortcuts
|
||
const handleKeyboardShortcuts = (event) => {
|
||
// Undo: Ctrl+Z
|
||
if (event.ctrlKey && event.key === 'z' && !event.shiftKey) {
|
||
event.preventDefault();
|
||
if (formStore.canUndo) {
|
||
formStore.undo();
|
||
}
|
||
}
|
||
|
||
// Redo: Ctrl+Y or Ctrl+Shift+Z
|
||
if ((event.ctrlKey && event.key === 'y') ||
|
||
(event.ctrlKey && event.shiftKey && event.key === 'z')) {
|
||
event.preventDefault();
|
||
if (formStore.currentHistoryIndex < formStore.actionHistory.length - 1) {
|
||
formStore.redo();
|
||
}
|
||
}
|
||
};
|
||
|
||
// Navigation guards
|
||
// Add navigation guard
|
||
onBeforeRouteLeave((to, from, next) => {
|
||
// If navigation was already confirmed or there are no unsaved changes, proceed
|
||
if (navigationConfirmed.value || !formStore.hasUnsavedChanges) {
|
||
next();
|
||
return;
|
||
}
|
||
|
||
// Otherwise show the confirmation modal
|
||
showUnsavedChangesModal.value = true;
|
||
pendingNavigation.value = () => {
|
||
navigationConfirmed.value = true;
|
||
next();
|
||
};
|
||
next(false);
|
||
});
|
||
|
||
// Navigation handlers
|
||
const cancelNavigation = () => {
|
||
showUnsavedChangesModal.value = false;
|
||
pendingNavigation.value = null;
|
||
navigationTarget.value = null;
|
||
navigationConfirmed.value = false;
|
||
};
|
||
|
||
const confirmNavigation = () => {
|
||
showUnsavedChangesModal.value = false;
|
||
|
||
if (pendingNavigation.value) {
|
||
pendingNavigation.value();
|
||
} else if (navigationTarget.value) {
|
||
navigationConfirmed.value = true; // Mark as confirmed before navigating
|
||
router.push(navigationTarget.value);
|
||
}
|
||
};
|
||
|
||
// Handler methods
|
||
const handleAddComponent = (component) => {
|
||
formStore.addComponent(component);
|
||
};
|
||
|
||
const handleSelectComponent = (component) => {
|
||
if (!component || !component.id) return;
|
||
formStore.selectComponent(component.id);
|
||
};
|
||
|
||
const handleSelectNestedComponent = (nestedComponent) => {
|
||
if (!nestedComponent || !nestedComponent.id) return;
|
||
|
||
// For nested components, we need to handle the selection differently
|
||
// since they're not in the main formComponents array but inside section children
|
||
formStore.selectedComponent = nestedComponent;
|
||
formStore.selectedComponentId = nestedComponent.id;
|
||
|
||
// Ensure the settings panel is open
|
||
if (!showFieldSettingsPanel.value) {
|
||
showFieldSettingsPanel.value = true;
|
||
}
|
||
|
||
console.log('Selected nested component:', nestedComponent);
|
||
};
|
||
|
||
const handleUpdateComponent = (updatedComponent) => {
|
||
if (!updatedComponent || !updatedComponent.id) return;
|
||
|
||
// Check if this is a nested component inside a section
|
||
let foundInSection = false;
|
||
|
||
// Look for the component in section children
|
||
formStore.formComponents.forEach(component => {
|
||
if (component.type === 'form-section' && component.props.children) {
|
||
const nestedIndex = component.props.children.findIndex(child => child.id === updatedComponent.id);
|
||
if (nestedIndex !== -1) {
|
||
// Update the nested component
|
||
component.props.children[nestedIndex] = updatedComponent;
|
||
// Update the entire section to trigger reactivity
|
||
formStore.updateComponent(component);
|
||
foundInSection = true;
|
||
console.log('Updated nested component in section:', updatedComponent);
|
||
return;
|
||
}
|
||
}
|
||
});
|
||
|
||
// If not found in sections, update as a main component
|
||
if (!foundInSection) {
|
||
formStore.updateComponent(updatedComponent);
|
||
}
|
||
};
|
||
|
||
const handleMoveComponent = ({ oldIndex, newIndex }) => {
|
||
formStore.moveComponent({ oldIndex, newIndex });
|
||
};
|
||
|
||
const handleDeleteComponent = (id) => {
|
||
if (!id) return;
|
||
|
||
// Check if this is a nested component inside a section
|
||
let foundInSection = false;
|
||
|
||
// Look for the component in section children
|
||
formStore.formComponents.forEach(component => {
|
||
if (component.type === 'form-section' && component.props.children) {
|
||
const nestedIndex = component.props.children.findIndex(child => child.id === id);
|
||
if (nestedIndex !== -1) {
|
||
// Remove the nested component
|
||
const deletedComponent = component.props.children.splice(nestedIndex, 1)[0];
|
||
// Update the entire section to trigger reactivity
|
||
formStore.updateComponent(component);
|
||
foundInSection = true;
|
||
|
||
// Clear selection if the deleted component was selected
|
||
if (formStore.selectedComponentId === id) {
|
||
formStore.selectedComponent = null;
|
||
formStore.selectedComponentId = null;
|
||
}
|
||
|
||
console.log('Deleted nested component from section:', deletedComponent);
|
||
return;
|
||
}
|
||
}
|
||
});
|
||
|
||
// If not found in sections, delete as a main component
|
||
if (!foundInSection) {
|
||
formStore.deleteComponent(id);
|
||
}
|
||
};
|
||
|
||
const handleDragOver = (event) => {
|
||
// Always prevent default to enable drop
|
||
event.preventDefault();
|
||
|
||
// Set visual feedback
|
||
formStore.setDraggingOver(true);
|
||
|
||
// Set the drop effect
|
||
event.dataTransfer.dropEffect = 'copy';
|
||
};
|
||
|
||
const handleDragLeave = (event) => {
|
||
// Check if we're really leaving the drop zone or just entering a child element
|
||
const dropZone = document.getElementById('form-drop-zone');
|
||
const relatedTarget = event.relatedTarget;
|
||
|
||
if (!dropZone || !dropZone.contains(relatedTarget)) {
|
||
formStore.setDraggingOver(false);
|
||
}
|
||
|
||
event.preventDefault();
|
||
};
|
||
|
||
const handleDrop = (event) => {
|
||
// Always prevent default
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
// Reset drag over state
|
||
formStore.setDraggingOver(false);
|
||
|
||
try {
|
||
// Get the data from all possible formats
|
||
let componentData;
|
||
const formats = event.dataTransfer.types;
|
||
|
||
// Log available formats for debugging
|
||
console.log('Available formats:', formats);
|
||
|
||
// Try text/plain first (most compatible format)
|
||
if (formats.includes('text/plain')) {
|
||
const plainText = event.dataTransfer.getData('text/plain');
|
||
if (plainText) {
|
||
try {
|
||
componentData = JSON.parse(plainText);
|
||
} catch (e) {
|
||
console.warn('Failed to parse text/plain data', e);
|
||
}
|
||
}
|
||
}
|
||
|
||
// If that failed, try the custom format
|
||
if (!componentData && formats.includes('component')) {
|
||
const componentText = event.dataTransfer.getData('component');
|
||
if (componentText) {
|
||
try {
|
||
componentData = JSON.parse(componentText);
|
||
} catch (e) {
|
||
console.warn('Failed to parse component data', e);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Safari fallback: if no data was received via dataTransfer, check global variable
|
||
if (!componentData && window.__draggedComponentData) {
|
||
console.log('Using fallback drag data for Safari');
|
||
componentData = window.__draggedComponentData;
|
||
// Clear the fallback data
|
||
window.__draggedComponentData = null;
|
||
}
|
||
|
||
// If we have valid component data, add it
|
||
if (componentData) {
|
||
formStore.addComponent(componentData);
|
||
} else {
|
||
console.warn('No valid component data found in drop event');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error processing dropped component:', error);
|
||
}
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
if (!formStore.formName.trim()) {
|
||
toast.error("Please enter a form name");
|
||
return;
|
||
}
|
||
|
||
if (formStore.formComponents.length === 0) {
|
||
toast.error("Please add at least one component to the form");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const savedForm = await formStore.saveForm();
|
||
toast.success("Form saved successfully");
|
||
|
||
// Update URL to include the form ID without reloading the page
|
||
const newPath = `/form-builder?id=${savedForm.formUUID}`;
|
||
window.history.replaceState({}, '', newPath);
|
||
} catch (error) {
|
||
console.error("Error saving form:", error);
|
||
toast.error(`Failed to save form: ${error.message || 'Unknown error'}`);
|
||
}
|
||
};
|
||
|
||
const togglePreview = () => {
|
||
if (!isPreview.value && formStore.formComponents.length === 0) {
|
||
toast.error("Please add at least one component to the form");
|
||
return;
|
||
}
|
||
|
||
isPreview.value = !isPreview.value;
|
||
};
|
||
|
||
const handlePreviewSubmit = (formData) => {
|
||
console.log("Form submitted:", formData);
|
||
toast.success("Form submitted successfully");
|
||
};
|
||
|
||
const saveFormSettings = () => {
|
||
// Form settings are automatically saved through the store's reactive updates
|
||
showFormSettings.value = false;
|
||
toast.success("Form settings saved successfully");
|
||
};
|
||
|
||
// Preview form data for script interactions
|
||
const previewFormData = ref({});
|
||
|
||
// Initialize preview form data with default values
|
||
watchEffect(() => {
|
||
console.log('[FormBuilder] watchEffect for previewFormData initialization. Current form components:', formStore.formComponents.length);
|
||
const existingFormData = { ...previewFormData.value }; // Preserve current user inputs
|
||
let newDefaults = {};
|
||
let hasNewComponents = false;
|
||
|
||
formStore.formComponents.forEach(component => {
|
||
if (component.props.name) {
|
||
// If field is not already in existingFormData, it's a new component or needs initialization
|
||
if (!existingFormData.hasOwnProperty(component.props.name)) {
|
||
hasNewComponents = true;
|
||
// Set default values based on component type
|
||
switch (component.type) {
|
||
case 'checkbox':
|
||
newDefaults[component.props.name] = [];
|
||
break;
|
||
case 'number':
|
||
case 'range':
|
||
newDefaults[component.props.name] = component.props.value || 0;
|
||
break;
|
||
case 'switch':
|
||
newDefaults[component.props.name] = component.props.value !== undefined ? component.props.value : false;
|
||
break;
|
||
case 'color':
|
||
newDefaults[component.props.name] = component.props.value || '#3b82f6';
|
||
break;
|
||
case 'hidden':
|
||
newDefaults[component.props.name] = component.props.value || '';
|
||
break;
|
||
case 'repeating-group':
|
||
const initialGroups = [];
|
||
const minItems = component.props.minItems || 1;
|
||
for (let i = 0; i < minItems; i++) {
|
||
const group = {};
|
||
if (Array.isArray(component.props.fields)) {
|
||
component.props.fields.forEach(field => {
|
||
switch (field.type) {
|
||
case 'number': group[field.name] = 0; break;
|
||
case 'checkbox': group[field.name] = []; break;
|
||
case 'select':
|
||
const defaultOption = Array.isArray(field.options) && field.options.length > 0 ? field.options[0].value : '';
|
||
group[field.name] = field.value || defaultOption || '';
|
||
break;
|
||
default: group[field.name] = field.value || '';
|
||
}
|
||
});
|
||
}
|
||
initialGroups.push(group);
|
||
}
|
||
newDefaults[component.props.name] = initialGroups;
|
||
break;
|
||
case 'dynamic-list':
|
||
newDefaults[component.props.name] = Array.isArray(component.props.defaultItems) ? [...component.props.defaultItems] : [];
|
||
break;
|
||
case 'select':
|
||
case 'radio':
|
||
if (Array.isArray(component.props.options) && component.props.options.length > 0) {
|
||
newDefaults[component.props.name] = component.props.value || component.props.options[0].value;
|
||
} else {
|
||
newDefaults[component.props.name] = '';
|
||
}
|
||
break;
|
||
case 'date':
|
||
case 'time':
|
||
case 'datetime-local':
|
||
case 'file': // file inputs are handled by FormKit, typically initialized to null or specific file objects
|
||
newDefaults[component.props.name] = component.props.value || null;
|
||
break;
|
||
// image-preview, info-display, button don't usually hold data in previewFormData directly
|
||
case 'image-preview':
|
||
case 'info-display':
|
||
case 'button':
|
||
break;
|
||
default:
|
||
newDefaults[component.props.name] = component.props.value || '';
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// Only update previewFormData if it's the initial load (empty) or new components were added that need defaults
|
||
// This prevents overwriting user input when existing components change their props (which also triggers this watchEffect)
|
||
const isInitialLoad = Object.keys(previewFormData.value).length === 0 && Object.keys(newDefaults).length > 0;
|
||
|
||
if (isInitialLoad || hasNewComponents) {
|
||
console.log('[FormBuilder] Initializing/merging preview form data. Initial load:', isInitialLoad, 'Has new components:', hasNewComponents);
|
||
previewFormData.value = { ...existingFormData, ...newDefaults };
|
||
console.log('[FormBuilder] Preview form data after init/merge:', previewFormData.value);
|
||
} else {
|
||
console.log('[FormBuilder] Skipping full previewFormData re-initialization to preserve user input.');
|
||
}
|
||
});
|
||
|
||
// Handle script-driven field changes
|
||
const handleScriptFieldChange = ({ fieldName, value }) => {
|
||
console.log('[FormBuilder] Script field change:', fieldName, '=', value);
|
||
|
||
// Update the reactive form data
|
||
const newFormData = {
|
||
...previewFormData.value,
|
||
[fieldName]: value
|
||
};
|
||
|
||
previewFormData.value = newFormData;
|
||
|
||
// Make form data accessible to component previews
|
||
formStore.updatePreviewFormData(newFormData);
|
||
|
||
// Try to force FormKit form to update
|
||
nextTick(() => {
|
||
console.log('[FormBuilder] Updated form data:', newFormData);
|
||
|
||
// Try to access the FormKit form node and update it directly
|
||
if (previewForm.value && previewForm.value.node) {
|
||
try {
|
||
previewForm.value.node.input(newFormData);
|
||
console.log('[FormBuilder] Force updated FormKit form');
|
||
} catch (error) {
|
||
console.warn('[FormBuilder] Could not force update FormKit form:', error);
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
// Handle script-driven field validation
|
||
const handleScriptFieldValidate = ({ fieldName }) => {
|
||
// Could integrate with FormKit validation here
|
||
console.log(`Validating field: ${fieldName}`);
|
||
};
|
||
|
||
// Handle FormKit form input events
|
||
const handleFormKitInput = (formData, node) => {
|
||
console.log('[FormBuilder] FormKit input event received!');
|
||
console.log('[FormBuilder] FormKit formData:', JSON.parse(JSON.stringify(formData)));
|
||
console.log('[FormBuilder] Current previewFormData before update:', JSON.parse(JSON.stringify(previewFormData.value)));
|
||
|
||
// Update our reactive form data - this should trigger the FormScriptEngine watcher
|
||
const oldPreviewData = { ...previewFormData.value };
|
||
previewFormData.value = { ...formData };
|
||
|
||
console.log('[FormBuilder] Updated previewFormData to:', JSON.parse(JSON.stringify(previewFormData.value)));
|
||
console.log('[FormBuilder] Did previewFormData actually change?', JSON.stringify(oldPreviewData) !== JSON.stringify(previewFormData.value));
|
||
|
||
// Make form data accessible to component previews
|
||
formStore.updatePreviewFormData(previewFormData.value);
|
||
|
||
console.log('[FormBuilder] FormStore preview data updated to:', JSON.parse(JSON.stringify(formStore.previewFormData)));
|
||
};
|
||
|
||
// Make form data accessible to component previews (for UI rendering, not for triggering script engine)
|
||
watchEffect(() => {
|
||
if (formStore) { // Ensure formStore is available
|
||
formStore.updatePreviewFormData(previewFormData.value);
|
||
}
|
||
});
|
||
|
||
// Watch for changes in previewFormData to trigger FormScriptEngine
|
||
watch(() => previewFormData.value, (newData, oldData) => {
|
||
if (!isPreview.value) return; // Only in preview mode
|
||
|
||
console.log('[FormBuilder] previewFormData watcher triggered!');
|
||
console.log('[FormBuilder] New data:', JSON.parse(JSON.stringify(newData)));
|
||
console.log('[FormBuilder] Old data:', oldData ? JSON.parse(JSON.stringify(oldData)) : 'undefined');
|
||
|
||
// Update form store
|
||
formStore.updatePreviewFormData(newData);
|
||
|
||
console.log('[FormBuilder] FormStore preview data updated to:', JSON.parse(JSON.stringify(formStore.previewFormData)));
|
||
}, { deep: true, immediate: false });
|
||
|
||
const navigateToManage = () => {
|
||
// If already confirmed or no unsaved changes, navigate directly
|
||
if (navigationConfirmed.value || !formStore.hasUnsavedChanges) {
|
||
router.push("/form-builder/manage");
|
||
return;
|
||
}
|
||
|
||
// Otherwise show confirmation modal
|
||
showUnsavedChangesModal.value = true;
|
||
navigationTarget.value = "/form-builder/manage";
|
||
};
|
||
|
||
// Handle creating a new form
|
||
const createNewForm = async () => {
|
||
if (!newFormName.value.trim()) {
|
||
toast.error('Please enter a form name');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Set the form name and description in the store
|
||
formStore.setFormName(newFormName.value.trim());
|
||
formStore.formDescription = newFormDescription.value.trim();
|
||
formStore.formCategory = newFormCategory.value.trim() || null;
|
||
formStore.formGroup = newFormGroup.value.trim() || null;
|
||
|
||
// Clear existing form components to start fresh
|
||
formStore.formComponents = [];
|
||
formStore.currentFormId = null; // This ensures we create a new form instead of updating existing
|
||
|
||
// Save the form to the database immediately
|
||
const savedForm = await formStore.saveForm();
|
||
|
||
if (savedForm) {
|
||
// Update URL to include the form ID
|
||
const newPath = `/form-builder?id=${savedForm.formUUID}`;
|
||
window.history.replaceState({}, '', newPath);
|
||
|
||
// Reset navigation confirmation
|
||
navigationConfirmed.value = false;
|
||
|
||
// Reset form variables
|
||
isCreatingForm.value = false;
|
||
newFormName.value = '';
|
||
newFormDescription.value = '';
|
||
newFormCategory.value = '';
|
||
newFormGroup.value = '';
|
||
|
||
// Show success message
|
||
toast.success(`Form "${formStore.formName}" created and saved successfully`);
|
||
} else {
|
||
toast.error('Failed to save form to database. Please try again.');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Error creating form:', error);
|
||
toast.error(`Failed to create form: ${error.message || 'Unknown error'}`);
|
||
}
|
||
};
|
||
|
||
const handleOptimizeLayout = () => {
|
||
formStore.optimizeGridLayout();
|
||
};
|
||
|
||
// Add the new handler function
|
||
const handleDragEnter = (event) => {
|
||
// Prevent default to allow drop
|
||
event.preventDefault();
|
||
// Set visual feedback
|
||
formStore.setDraggingOver(true);
|
||
};
|
||
|
||
function handleClickOutside(event) {
|
||
const dropdown = document.querySelector('.dropdown');
|
||
if (dropdown && !dropdown.contains(event.target)) {
|
||
showDropdown.value = false;
|
||
}
|
||
}
|
||
|
||
// Handle applying a template
|
||
const applyFormTemplate = (template) => {
|
||
// Confirm if there's already content in the form
|
||
if (formStore.formComponents.length > 0) {
|
||
if (!confirm("This will replace your current form content. Continue?")) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
try {
|
||
console.log('Applying template:', template.name);
|
||
console.log('Template components:', template.components ? template.components.length : 0);
|
||
|
||
// Set form name if it's a new form or user allows overwrite
|
||
if (formStore.formName === 'New Form' || confirm("Update the form name to match the template?")) {
|
||
formStore.setFormName(template.name);
|
||
}
|
||
|
||
// Reset the form components and form ID (to ensure this becomes a new form)
|
||
formStore.formComponents = [];
|
||
formStore.currentFormId = null; // Important: This ensures we create a new form instead of updating existing
|
||
|
||
// Add the template components
|
||
if (template.components && template.components.length > 0) {
|
||
// Process each template component
|
||
template.components.forEach((component, index) => {
|
||
console.log(`Processing component ${index + 1}:`, component.type);
|
||
|
||
// Handle special component types with specific properties
|
||
let processedDefaultProps = {};
|
||
|
||
if (component.defaultProps) {
|
||
// Clone the default props to avoid reference issues
|
||
processedDefaultProps = JSON.parse(JSON.stringify(component.defaultProps));
|
||
|
||
// Ensure required grid properties are present
|
||
if (!processedDefaultProps.width) {
|
||
processedDefaultProps.width = '100%';
|
||
}
|
||
|
||
if (!processedDefaultProps.gridColumn) {
|
||
processedDefaultProps.gridColumn = 'span 12';
|
||
}
|
||
|
||
// Ensure specific properties for each component type
|
||
switch (component.type) {
|
||
case 'image-preview':
|
||
// Ensure all required image preview properties
|
||
processedDefaultProps = {
|
||
label: processedDefaultProps.label || 'Image Preview',
|
||
name: processedDefaultProps.name || `image_preview_${index + 1}`,
|
||
help: processedDefaultProps.help || '',
|
||
imageUrl: processedDefaultProps.imageUrl || 'https://placehold.co/600x400',
|
||
altText: processedDefaultProps.altText || 'Preview image',
|
||
caption: processedDefaultProps.caption || '',
|
||
showZoom: processedDefaultProps.showZoom !== undefined ? processedDefaultProps.showZoom : true,
|
||
showCaption: processedDefaultProps.showCaption !== undefined ? processedDefaultProps.showCaption : true,
|
||
maxWidth: processedDefaultProps.maxWidth || '100%',
|
||
height: processedDefaultProps.height || 'auto',
|
||
...processedDefaultProps
|
||
};
|
||
break;
|
||
|
||
case 'repeating-group':
|
||
// Ensure all required repeating group properties
|
||
processedDefaultProps = {
|
||
label: processedDefaultProps.label || 'Repeating Group',
|
||
name: processedDefaultProps.name || `repeating_group_${index + 1}`,
|
||
help: processedDefaultProps.help || '',
|
||
minItems: processedDefaultProps.minItems !== undefined ? processedDefaultProps.minItems : 1,
|
||
maxItems: processedDefaultProps.maxItems !== undefined ? processedDefaultProps.maxItems : 10,
|
||
buttonText: processedDefaultProps.buttonText || 'Add Item',
|
||
removeText: processedDefaultProps.removeText || 'Remove',
|
||
fields: processedDefaultProps.fields || [
|
||
{ type: 'text', name: 'field_1', label: 'Field 1', placeholder: 'Enter value' }
|
||
],
|
||
...processedDefaultProps
|
||
};
|
||
break;
|
||
|
||
case 'dynamic-list':
|
||
// Ensure all required dynamic list properties
|
||
processedDefaultProps = {
|
||
label: processedDefaultProps.label || 'Dynamic List',
|
||
name: processedDefaultProps.name || `dynamic_list_${index + 1}`,
|
||
help: processedDefaultProps.help || '',
|
||
placeholder: processedDefaultProps.placeholder || 'Enter item',
|
||
buttonText: processedDefaultProps.buttonText || 'Add Item',
|
||
minItems: processedDefaultProps.minItems !== undefined ? processedDefaultProps.minItems : 0,
|
||
maxItems: processedDefaultProps.maxItems !== undefined ? processedDefaultProps.maxItems : 20,
|
||
defaultItems: Array.isArray(processedDefaultProps.defaultItems) ? processedDefaultProps.defaultItems : ['Item 1', 'Item 2'],
|
||
...processedDefaultProps
|
||
};
|
||
break;
|
||
|
||
case 'info-display':
|
||
// Ensure all required info display properties
|
||
processedDefaultProps = {
|
||
title: processedDefaultProps.title || 'Information',
|
||
name: processedDefaultProps.name || `info_display_${index + 1}`,
|
||
help: processedDefaultProps.help || '',
|
||
layout: processedDefaultProps.layout || 'vertical',
|
||
showBorder: processedDefaultProps.showBorder !== undefined ? processedDefaultProps.showBorder : true,
|
||
backgroundColor: processedDefaultProps.backgroundColor || '#f8fafc',
|
||
fields: Array.isArray(processedDefaultProps.fields) ? processedDefaultProps.fields : [
|
||
{ label: 'Info Item', value: 'Value', key: 'item_1' }
|
||
],
|
||
...processedDefaultProps
|
||
};
|
||
break;
|
||
|
||
case 'file':
|
||
// Ensure all required file upload properties
|
||
processedDefaultProps = {
|
||
label: processedDefaultProps.label || 'File Upload',
|
||
name: processedDefaultProps.name || `file_upload_${index + 1}`,
|
||
help: processedDefaultProps.help || 'Upload a file',
|
||
accept: processedDefaultProps.accept || '.pdf,.doc,.docx,.jpg,.jpeg,.png',
|
||
...processedDefaultProps
|
||
};
|
||
break;
|
||
|
||
case 'heading':
|
||
// Ensure all required heading properties
|
||
processedDefaultProps = {
|
||
value: processedDefaultProps.value || 'Heading',
|
||
level: processedDefaultProps.level || 2,
|
||
...processedDefaultProps
|
||
};
|
||
break;
|
||
|
||
case 'paragraph':
|
||
// Ensure all required paragraph properties
|
||
processedDefaultProps = {
|
||
value: processedDefaultProps.value || 'Paragraph text',
|
||
...processedDefaultProps
|
||
};
|
||
break;
|
||
|
||
case 'select':
|
||
case 'radio':
|
||
case 'checkbox':
|
||
// Ensure options array exists
|
||
if (!Array.isArray(processedDefaultProps.options) || processedDefaultProps.options.length === 0) {
|
||
processedDefaultProps.options = [
|
||
{ label: 'Option 1', value: 'option_1' },
|
||
{ label: 'Option 2', value: 'option_2' }
|
||
];
|
||
}
|
||
// Fall through for other basic properties
|
||
|
||
default:
|
||
// Basic properties for all other component types
|
||
if (!processedDefaultProps.label && !['heading', 'paragraph', 'divider'].includes(component.type)) {
|
||
processedDefaultProps.label = component.name || component.type.charAt(0).toUpperCase() + component.type.slice(1);
|
||
}
|
||
|
||
if (!processedDefaultProps.name && !['heading', 'paragraph', 'divider'].includes(component.type)) {
|
||
processedDefaultProps.name = `${component.type}_${index + 1}`;
|
||
}
|
||
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Create a component in the expected format for addComponent
|
||
const formattedComponent = {
|
||
type: component.type,
|
||
name: component.name || component.type,
|
||
category: getDefaultCategory(component.type),
|
||
icon: getDefaultIcon(component.type),
|
||
// Use the processed default props
|
||
defaultProps: processedDefaultProps
|
||
};
|
||
|
||
console.log('Formatted component:', formattedComponent);
|
||
|
||
// Add the component to the form
|
||
formStore.addComponent(formattedComponent);
|
||
});
|
||
|
||
console.log('Total components added:', formStore.formComponents.length);
|
||
} else {
|
||
console.warn('No components found in template');
|
||
}
|
||
|
||
// Set script and CSS if available
|
||
if (template.script) {
|
||
formStore.formCustomScript = template.script;
|
||
console.log('Applied custom script');
|
||
}
|
||
|
||
if (template.css) {
|
||
formStore.formCustomCSS = template.css;
|
||
console.log('Applied custom CSS');
|
||
}
|
||
|
||
// Enable events if the template uses scripts
|
||
if (template.hasScript) {
|
||
formStore.formEvents = {
|
||
onLoad: true,
|
||
onFieldChange: true,
|
||
onSubmit: template.script && template.script.includes('onSubmit'),
|
||
onValidation: template.script && template.script.includes('onValidation')
|
||
};
|
||
console.log('Enabled form events');
|
||
}
|
||
|
||
// Mark as having unsaved changes
|
||
formStore.hasUnsavedChanges = true;
|
||
|
||
// Show success message
|
||
toast.success(`Template "${template.name}" applied successfully`);
|
||
} catch (error) {
|
||
console.error('Error applying template:', error);
|
||
toast.error('Failed to apply template: ' + error.message);
|
||
}
|
||
};
|
||
|
||
// Helper function to get default category for component type
|
||
const getDefaultCategory = (type) => {
|
||
const categories = {
|
||
// Basic inputs
|
||
'text': 'Basic Inputs',
|
||
'textarea': 'Basic Inputs',
|
||
'number': 'Basic Inputs',
|
||
'email': 'Basic Inputs',
|
||
'password': 'Basic Inputs',
|
||
'url': 'Basic Inputs',
|
||
'tel': 'Basic Inputs',
|
||
'hidden': 'Basic Inputs',
|
||
'date': 'Basic Inputs',
|
||
'time': 'Basic Inputs',
|
||
'datetime-local': 'Basic Inputs',
|
||
|
||
// Selection inputs
|
||
'select': 'Selection Inputs',
|
||
'checkbox': 'Selection Inputs',
|
||
'radio': 'Selection Inputs',
|
||
'switch': 'Selection Inputs',
|
||
'range': 'Selection Inputs',
|
||
'color': 'Selection Inputs',
|
||
|
||
// Layout elements
|
||
'heading': 'Layout',
|
||
'paragraph': 'Layout',
|
||
'divider': 'Layout',
|
||
'info-display': 'Layout',
|
||
|
||
// Advanced inputs
|
||
'file': 'Advanced',
|
||
'button': 'Advanced',
|
||
'image-preview': 'Advanced',
|
||
'repeating-group': 'Advanced',
|
||
'dynamic-list': 'Advanced'
|
||
};
|
||
|
||
return categories[type] || 'Basic Inputs';
|
||
};
|
||
|
||
// Helper function to get default icon for component type
|
||
const getDefaultIcon = (type) => {
|
||
const icons = {
|
||
// Basic inputs
|
||
'text': 'material-symbols:text-fields',
|
||
'textarea': 'material-symbols:article-outline',
|
||
'number': 'material-symbols:counter-1-outline',
|
||
'email': 'material-symbols:mail-outline',
|
||
'password': 'material-symbols:password',
|
||
'url': 'material-symbols:link',
|
||
'tel': 'heroicons:device-phone-mobile',
|
||
'hidden': 'material-symbols:visibility-off',
|
||
'date': 'material-symbols:calendar-month-outline',
|
||
'time': 'material-symbols:schedule-outline',
|
||
'datetime-local': 'material-symbols:event-outline',
|
||
|
||
// Selection inputs
|
||
'select': 'material-symbols:arrow-drop-down-circle-outline',
|
||
'checkbox': 'material-symbols:check-box-outline',
|
||
'radio': 'material-symbols:radio-button-checked-outline',
|
||
'switch': 'material-symbols:toggle-on',
|
||
'range': 'material-symbols:linear-scale',
|
||
'color': 'material-symbols:palette',
|
||
|
||
// Layout elements
|
||
'heading': 'material-symbols:title',
|
||
'paragraph': 'material-symbols:text-snippet-outline',
|
||
'divider': 'material-symbols:horizontal-rule',
|
||
'info-display': 'material-symbols:info-outline',
|
||
|
||
// Advanced inputs
|
||
'file': 'material-symbols:upload-file-outline',
|
||
'button': 'material-symbols:smart-button',
|
||
'image-preview': 'material-symbols:image-outline',
|
||
'repeating-group': 'heroicons:list-bullet',
|
||
'dynamic-list': 'material-symbols:format-list-bulleted'
|
||
};
|
||
|
||
return icons[type] || 'material-symbols:add';
|
||
};
|
||
|
||
// Component icon and name helper functions (available to template)
|
||
const getComponentIcon = (type) => {
|
||
const icons = {
|
||
text: 'heroicons:document-text',
|
||
textarea: 'heroicons:document-text',
|
||
number: 'heroicons:hashtag',
|
||
email: 'heroicons:envelope',
|
||
password: 'heroicons:key',
|
||
url: 'heroicons:link',
|
||
tel: 'heroicons:device-phone-mobile',
|
||
mask: 'heroicons:pencil-square',
|
||
hidden: 'heroicons:eye-slash',
|
||
select: 'heroicons:chevron-down',
|
||
checkbox: 'heroicons:check-badge',
|
||
radio: 'heroicons:radio',
|
||
switch: 'material-symbols:toggle-on',
|
||
date: 'heroicons:calendar-days',
|
||
time: 'heroicons:clock',
|
||
'datetime-local': 'heroicons:calendar',
|
||
range: 'heroicons:adjustments-horizontal',
|
||
color: 'heroicons:swatch',
|
||
file: 'heroicons:document-arrow-up',
|
||
otp: 'heroicons:key',
|
||
dropzone: 'heroicons:cloud-arrow-up',
|
||
button: 'heroicons:cursor-arrow-rays',
|
||
heading: 'heroicons:h1',
|
||
paragraph: 'heroicons:document-text',
|
||
divider: 'heroicons:minus',
|
||
'info-display': 'heroicons:information-circle'
|
||
}
|
||
return icons[type] || 'heroicons:square-3-stack-3d'
|
||
}
|
||
|
||
const getComponentTypeName = (type) => {
|
||
const names = {
|
||
text: 'Text Field',
|
||
textarea: 'Text Area',
|
||
number: 'Number Field',
|
||
email: 'Email Field',
|
||
password: 'Password Field',
|
||
url: 'URL Field',
|
||
tel: 'Phone Field',
|
||
mask: 'Masked Input',
|
||
hidden: 'Hidden Field',
|
||
select: 'Dropdown Menu',
|
||
checkbox: 'Checkboxes',
|
||
radio: 'Radio Buttons',
|
||
switch: 'Switch Toggle',
|
||
date: 'Date Picker',
|
||
time: 'Time Picker',
|
||
'datetime-local': 'Date & Time',
|
||
range: 'Range Slider',
|
||
color: 'Color Picker',
|
||
file: 'File Upload',
|
||
otp: 'Verification Code',
|
||
dropzone: 'File Drop Zone',
|
||
button: 'Action Button',
|
||
heading: 'Heading Text',
|
||
paragraph: 'Paragraph Text',
|
||
divider: 'Divider Line',
|
||
'info-display': 'Information Display'
|
||
}
|
||
return names[type] || 'Form Field'
|
||
}
|
||
|
||
const getComponentTypeShort = (type) => {
|
||
const shortNames = {
|
||
text: 'TXT',
|
||
textarea: 'TXT',
|
||
number: 'NUM',
|
||
email: 'EML',
|
||
password: 'PWD',
|
||
url: 'URL',
|
||
tel: 'TEL',
|
||
mask: 'MSK',
|
||
hidden: 'HID',
|
||
select: 'SEL',
|
||
checkbox: 'CHK',
|
||
radio: 'RAD',
|
||
switch: 'SWT',
|
||
date: 'DTE',
|
||
time: 'TME',
|
||
'datetime-local': 'DTM',
|
||
range: 'RNG',
|
||
color: 'CLR',
|
||
file: 'FIL',
|
||
otp: 'OTP',
|
||
dropzone: 'DRP',
|
||
button: 'BTN',
|
||
heading: 'H' + (formStore.selectedComponent?.props?.level || '2'),
|
||
paragraph: 'TXT',
|
||
divider: 'DIV',
|
||
'info-display': 'INF'
|
||
}
|
||
return shortNames[type] || 'FLD'
|
||
}
|
||
|
||
// Quick settings panel state and functions
|
||
const quickSettings = ref({});
|
||
|
||
// Compact width options for the sidebar
|
||
const compactWidthOptions = [
|
||
{ name: 'S', value: 25, gridColumns: 3, description: 'Small (25%)' },
|
||
{ name: 'M', value: 50, gridColumns: 6, description: 'Medium (50%)' },
|
||
{ name: 'L', value: 75, gridColumns: 9, description: 'Large (75%)' },
|
||
{ name: 'XL', value: 100, gridColumns: 12, description: 'Extra Large (100%)' }
|
||
];
|
||
|
||
// Watch for selected component changes to update quick settings
|
||
watch(() => formStore.selectedComponent, (newComponent) => {
|
||
if (newComponent) {
|
||
// Auto-open panel when component is selected
|
||
showFieldSettingsPanel.value = true;
|
||
|
||
// Initialize quick settings
|
||
quickSettings.value = {
|
||
label: newComponent.props.label || '',
|
||
name: newComponent.props.name || '',
|
||
placeholder: newComponent.props.placeholder || '',
|
||
required: newComponent.props.validation?.includes('required') || false
|
||
};
|
||
}
|
||
}, { immediate: true });
|
||
|
||
// Toggle panel function
|
||
const toggleFieldSettingsPanel = () => {
|
||
showFieldSettingsPanel.value = !showFieldSettingsPanel.value;
|
||
};
|
||
|
||
// Show/hide specific quick fields based on component type
|
||
const showQuickField = (fieldName) => {
|
||
if (!formStore.selectedComponent) return false;
|
||
|
||
const fieldConfig = {
|
||
label: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button'],
|
||
name: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'hidden', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button'],
|
||
placeholder: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select'],
|
||
width: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'heading', 'paragraph', 'info-display'],
|
||
required: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'date', 'time', 'datetime-local', 'file', 'otp', 'dropzone']
|
||
};
|
||
|
||
return fieldConfig[fieldName]?.includes(formStore.selectedComponent.type) || false;
|
||
};
|
||
|
||
// Get placeholder text for different fields
|
||
const getPlaceholder = (fieldName) => {
|
||
if (!formStore.selectedComponent) return '';
|
||
|
||
const placeholders = {
|
||
label: `Enter ${formStore.selectedComponent.type} label`,
|
||
name: `${formStore.selectedComponent.type}_field`,
|
||
placeholder: 'Enter placeholder text...'
|
||
};
|
||
|
||
return placeholders[fieldName] || '';
|
||
};
|
||
|
||
// Update quick setting and sync with component
|
||
const updateQuickSetting = (key, value, gridColumns = null) => {
|
||
if (!formStore.selectedComponent) return;
|
||
|
||
const updatedComponent = { ...formStore.selectedComponent };
|
||
|
||
if (key === 'width') {
|
||
updatedComponent.props.width = `${value}%`;
|
||
if (gridColumns) {
|
||
updatedComponent.props.gridColumn = `span ${gridColumns}`;
|
||
}
|
||
} else {
|
||
updatedComponent.props[key] = value;
|
||
}
|
||
|
||
formStore.updateComponent(updatedComponent);
|
||
quickSettings.value[key] = value;
|
||
};
|
||
|
||
// Update validation settings
|
||
const updateQuickValidation = (rule, enabled) => {
|
||
if (!formStore.selectedComponent) return;
|
||
|
||
const updatedComponent = { ...formStore.selectedComponent };
|
||
let current = updatedComponent.props.validation || '';
|
||
const rules = current.split('|').filter(r => r && r !== rule);
|
||
|
||
if (enabled) {
|
||
rules.push(rule);
|
||
}
|
||
|
||
updatedComponent.props.validation = rules.join('|');
|
||
formStore.updateComponent(updatedComponent);
|
||
quickSettings.value.required = enabled;
|
||
};
|
||
|
||
// Get current component width percentage
|
||
const getComponentWidthPercent = () => {
|
||
if (!formStore.selectedComponent) return 100;
|
||
const width = formStore.selectedComponent.props.width || '100%';
|
||
return parseInt(width.replace('%', ''));
|
||
};
|
||
|
||
// Open full settings modal
|
||
const openFullSettingsModal = () => {
|
||
showFieldSettings.value = true;
|
||
};
|
||
|
||
// Duplicate component
|
||
const duplicateComponent = () => {
|
||
if (!formStore.selectedComponent) return;
|
||
|
||
const originalComponent = formStore.selectedComponent;
|
||
const duplicatedProps = { ...originalComponent.props };
|
||
|
||
// Update name to avoid conflicts
|
||
if (duplicatedProps.name) {
|
||
duplicatedProps.name = duplicatedProps.name + '_copy';
|
||
}
|
||
|
||
// Update label to indicate it's a copy
|
||
if (duplicatedProps.label) {
|
||
duplicatedProps.label = duplicatedProps.label + ' (Copy)';
|
||
}
|
||
|
||
const componentToDuplicate = {
|
||
type: originalComponent.type,
|
||
name: getComponentTypeName(originalComponent.type) + ' (Copy)',
|
||
category: getDefaultCategory(originalComponent.type),
|
||
icon: getComponentIcon(originalComponent.type),
|
||
defaultProps: duplicatedProps
|
||
};
|
||
|
||
formStore.addComponent(componentToDuplicate);
|
||
toast.success('Component duplicated successfully');
|
||
};
|
||
|
||
// Delete component
|
||
const deleteComponent = () => {
|
||
if (!formStore.selectedComponent) return;
|
||
|
||
if (confirm('Are you sure you want to delete this component?')) {
|
||
formStore.deleteComponent(formStore.selectedComponent.id);
|
||
toast.success('Component deleted successfully');
|
||
}
|
||
};
|
||
|
||
const handleConditionalLogicGenerated = (script) => {
|
||
// Add the generated script to the form's custom script
|
||
formStore.formCustomScript += `\n// Conditional Logic Script\n${script}`;
|
||
toast.success('Conditional logic script added successfully');
|
||
};
|
||
|
||
const handleFormRestored = (restoredForm) => {
|
||
// Handle form restoration logic
|
||
console.log('Form restored:', restoredForm);
|
||
// You might want to update the form state or show a success message
|
||
};
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* Submit button hover effects using CSS custom properties */
|
||
:deep(.formkit-input[type="submit"]:hover) {
|
||
background-color: var(--hover-bg) !important;
|
||
}
|
||
.form-name-input {
|
||
width: 100%;
|
||
max-width: 400px;
|
||
}
|
||
|
||
.form-name-input :deep(.formkit-inner) {
|
||
background-color: rgba(255, 255, 255, 0.1);
|
||
border-color: rgba(255, 255, 255, 0.2);
|
||
color: white;
|
||
}
|
||
|
||
.form-name-input :deep(.formkit-inner:focus-within) {
|
||
border-color: rgba(255, 255, 255, 0.5);
|
||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.form-name-input :deep(input::placeholder) {
|
||
color: rgba(255, 255, 255, 0.6);
|
||
}
|
||
|
||
.form-name-input :deep(.formkit-message) {
|
||
color: rgba(255, 200, 200, 0.9);
|
||
font-size: 0.7rem;
|
||
position: absolute;
|
||
}
|
||
|
||
.grid-preview-container {
|
||
display: grid;
|
||
grid-template-columns: repeat(12, 1fr);
|
||
grid-auto-flow: row dense;
|
||
column-gap: 16px;
|
||
row-gap: 16px;
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.form-container {
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
padding: 1rem;
|
||
}
|
||
|
||
.form-submit {
|
||
margin-top: 2rem;
|
||
width: 100%;
|
||
}
|
||
|
||
/* Drop zone styling */
|
||
#form-drop-zone {
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
#form-drop-zone:not(.border-solid) {
|
||
cursor: default;
|
||
}
|
||
|
||
#form-drop-zone.border-blue-400 {
|
||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.3);
|
||
}
|
||
|
||
/* Preview mode styling */
|
||
.preview-mode {
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
background-color: white;
|
||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||
border-radius: 8px !important;
|
||
border-color: #e5e7eb !important;
|
||
}
|
||
|
||
/* Floating Action Button */
|
||
.field-settings-fab {
|
||
@apply w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg hover:shadow-xl transition-all duration-200 flex items-center justify-center;
|
||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||
}
|
||
|
||
.field-settings-fab:hover {
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
/* Instruction Tooltip */
|
||
.instruction-tooltip {
|
||
@apply bg-white border border-gray-200 rounded-lg shadow-lg px-4 py-3 relative;
|
||
animation: fadeInBounce 0.6s ease-out;
|
||
}
|
||
|
||
.instruction-arrow {
|
||
@apply absolute top-full left-1/2 transform -translate-x-1/2;
|
||
width: 0;
|
||
height: 0;
|
||
border-left: 8px solid transparent;
|
||
border-right: 8px solid transparent;
|
||
border-top: 8px solid #f3f4f6;
|
||
}
|
||
|
||
.instruction-arrow::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: -9px;
|
||
left: -8px;
|
||
width: 0;
|
||
height: 0;
|
||
border-left: 8px solid transparent;
|
||
border-right: 8px solid transparent;
|
||
border-top: 8px solid #d1d5db;
|
||
}
|
||
|
||
@keyframes fadeInBounce {
|
||
0% {
|
||
opacity: 0;
|
||
transform: translateY(-10px);
|
||
}
|
||
60% {
|
||
opacity: 1;
|
||
transform: translateY(5px);
|
||
}
|
||
100% {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
/* Enhanced hover states for form components */
|
||
.hover\:bg-blue-25:hover {
|
||
background-color: rgba(59, 130, 246, 0.04);
|
||
}
|
||
|
||
/* FAB Tooltip */
|
||
.fab-tooltip {
|
||
@apply absolute bottom-full right-0 mb-2 opacity-0 pointer-events-none transition-opacity duration-300;
|
||
}
|
||
|
||
.tooltip-arrow {
|
||
@apply absolute top-full right-4;
|
||
width: 0;
|
||
height: 0;
|
||
border-left: 4px solid transparent;
|
||
border-right: 4px solid transparent;
|
||
border-top: 4px solid #1f2937;
|
||
}
|
||
|
||
/* Enhanced component selection feedback */
|
||
.form-component {
|
||
position: relative;
|
||
}
|
||
|
||
.form-component::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: -2px;
|
||
left: -2px;
|
||
right: -2px;
|
||
bottom: -2px;
|
||
background: linear-gradient(45deg, #3b82f6, #1d4ed8);
|
||
border-radius: 8px;
|
||
opacity: 0;
|
||
z-index: -1;
|
||
transition: opacity 0.3s ease;
|
||
}
|
||
|
||
.form-component.ring-2::before {
|
||
opacity: 0.1;
|
||
}
|
||
|
||
/* Field Settings Panel */
|
||
.field-settings-panel {
|
||
@apply relative bg-white border-l border-gray-200 flex flex-col overflow-hidden transition-all duration-300 ease-in-out;
|
||
}
|
||
|
||
.panel-expanded {
|
||
@apply w-80;
|
||
}
|
||
|
||
.panel-collapsed {
|
||
@apply w-12;
|
||
}
|
||
|
||
.panel-header-toggle-btn {
|
||
@apply text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100;
|
||
}
|
||
|
||
.collapsed-toggle-btn {
|
||
@apply w-8 h-8 bg-white border border-gray-200 rounded-lg flex items-center justify-center text-gray-500 hover:text-gray-700 hover:border-gray-300 hover:bg-gray-50 transition-all duration-200 shadow-sm;
|
||
}
|
||
|
||
.panel-content {
|
||
@apply flex-1 flex flex-col overflow-hidden;
|
||
}
|
||
|
||
.panel-header {
|
||
@apply px-4 py-3 border-b border-gray-200 bg-gray-50;
|
||
}
|
||
|
||
.panel-body {
|
||
@apply flex-1 overflow-y-auto;
|
||
}
|
||
|
||
.empty-state {
|
||
@apply h-full flex items-center justify-center;
|
||
}
|
||
|
||
.component-settings {
|
||
@apply p-4 space-y-4;
|
||
}
|
||
|
||
.component-header {
|
||
@apply pb-3 border-b border-gray-100;
|
||
}
|
||
|
||
.component-icon {
|
||
@apply w-10 h-10 rounded-lg bg-blue-50 flex items-center justify-center flex-shrink-0;
|
||
}
|
||
|
||
.quick-actions {
|
||
@apply grid grid-cols-1 gap-2;
|
||
}
|
||
|
||
.action-btn {
|
||
@apply flex items-center justify-center px-3 py-2 text-sm font-medium rounded-md transition-colors duration-200;
|
||
}
|
||
|
||
.action-btn.primary {
|
||
@apply bg-blue-600 text-white hover:bg-blue-700;
|
||
}
|
||
|
||
.action-btn.secondary {
|
||
@apply bg-gray-100 text-gray-700 hover:bg-gray-200;
|
||
}
|
||
|
||
.action-btn.danger {
|
||
@apply bg-red-100 text-red-700 hover:bg-red-200;
|
||
}
|
||
|
||
.quick-settings {
|
||
@apply space-y-3;
|
||
}
|
||
|
||
.settings-section-title {
|
||
@apply text-xs font-semibold text-gray-600 uppercase tracking-wide mb-3;
|
||
}
|
||
|
||
.setting-item {
|
||
@apply space-y-1.5;
|
||
}
|
||
|
||
.setting-label {
|
||
@apply block text-xs font-medium text-gray-700;
|
||
}
|
||
|
||
.width-selector-compact {
|
||
@apply flex space-x-1;
|
||
}
|
||
|
||
.width-btn {
|
||
@apply flex-1 px-2 py-1.5 text-xs font-medium text-center rounded-md border border-gray-200 bg-white hover:border-blue-300 hover:bg-blue-50 transition-colors duration-200;
|
||
}
|
||
|
||
.width-btn.active {
|
||
@apply border-blue-500 bg-blue-50 text-blue-700 ring-1 ring-blue-200;
|
||
}
|
||
|
||
.setting-toggle {
|
||
@apply flex items-center cursor-pointer;
|
||
}
|
||
|
||
.toggle-input {
|
||
@apply sr-only;
|
||
}
|
||
|
||
.toggle-slider {
|
||
@apply relative inline-block w-8 h-4 mr-2 bg-gray-200 rounded-full transition-colors duration-200 ease-in-out;
|
||
}
|
||
|
||
.setting-toggle input:checked + .toggle-slider {
|
||
@apply bg-blue-600;
|
||
}
|
||
|
||
.toggle-slider::before {
|
||
@apply absolute top-0.5 left-0.5 w-3 h-3 bg-white rounded-full transition-transform duration-200 ease-in-out;
|
||
content: '';
|
||
}
|
||
|
||
.setting-toggle input:checked + .toggle-slider::before {
|
||
@apply transform translate-x-4;
|
||
}
|
||
|
||
.toggle-label {
|
||
@apply text-xs font-medium text-gray-700;
|
||
}
|
||
|
||
.collapsed-info {
|
||
@apply h-full flex flex-col px-2 py-3;
|
||
}
|
||
|
||
.collapsed-header {
|
||
@apply mb-4 flex justify-center;
|
||
}
|
||
|
||
.collapsed-content {
|
||
@apply text-center w-full flex-1 flex flex-col justify-center items-center;
|
||
}
|
||
|
||
.selected-component-collapsed {
|
||
@apply flex flex-col items-center space-y-2;
|
||
}
|
||
|
||
.component-icon-collapsed {
|
||
@apply w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center;
|
||
}
|
||
|
||
.component-type-badge {
|
||
@apply text-xs font-bold text-blue-700 bg-blue-100 px-1.5 py-1 rounded whitespace-nowrap;
|
||
writing-mode: vertical-rl;
|
||
text-orientation: upright;
|
||
}
|
||
|
||
.no-selection-collapsed {
|
||
@apply flex flex-col items-center space-y-2;
|
||
}
|
||
|
||
.writing-mode-vertical {
|
||
writing-mode: vertical-rl;
|
||
text-orientation: mixed;
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
/* Device Preview Styles */
|
||
.device-preview-container {
|
||
transition: all 0.3s ease-in-out;
|
||
position: relative;
|
||
}
|
||
|
||
.device-frame {
|
||
position: relative;
|
||
border-radius: 20px;
|
||
padding: 20px;
|
||
background: #1a1a1a;
|
||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.device-frame.device-mobile {
|
||
border-radius: 25px;
|
||
padding: 15px;
|
||
background: linear-gradient(145deg, #2a2a2a, #1a1a1a);
|
||
max-height: 80vh; /* Ensure it fits in viewport */
|
||
}
|
||
|
||
.device-frame.device-tablet {
|
||
border-radius: 20px;
|
||
padding: 20px;
|
||
background: linear-gradient(145deg, #2a2a2a, #1a1a1a);
|
||
max-height: 85vh; /* Ensure it fits in viewport */
|
||
}
|
||
|
||
.device-screen {
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 15px;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
.device-screen.scrollable-device {
|
||
overflow-y: auto;
|
||
scroll-behavior: smooth;
|
||
-webkit-overflow-scrolling: touch; /* iOS smooth scrolling */
|
||
}
|
||
|
||
/* Custom scrollbar for device screens */
|
||
.device-screen.scrollable-device::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
|
||
.device-screen.scrollable-device::-webkit-scrollbar-track {
|
||
background: rgba(0, 0, 0, 0.1);
|
||
border-radius: 3px;
|
||
margin: 10px 0;
|
||
}
|
||
|
||
.device-screen.scrollable-device::-webkit-scrollbar-thumb {
|
||
background: rgba(0, 0, 0, 0.3);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.device-screen.scrollable-device::-webkit-scrollbar-thumb:hover {
|
||
background: rgba(0, 0, 0, 0.5);
|
||
}
|
||
|
||
.device-mobile .device-screen {
|
||
border-radius: 20px;
|
||
min-height: 100%;
|
||
}
|
||
|
||
.device-tablet .device-screen {
|
||
border-radius: 15px;
|
||
min-height: 100%;
|
||
}
|
||
|
||
/* Device-specific responsive adjustments */
|
||
.device-mobile .grid-preview-container {
|
||
font-size: 14px;
|
||
padding: 1rem !important;
|
||
}
|
||
|
||
.device-mobile .grid-preview-container .formkit-wrapper {
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.device-tablet .grid-preview-container {
|
||
font-size: 15px;
|
||
padding: 1.5rem !important;
|
||
}
|
||
|
||
/* Form content adjustments for devices */
|
||
.device-mobile .form-submit,
|
||
.device-tablet .form-submit {
|
||
position: sticky;
|
||
bottom: 0;
|
||
background: white;
|
||
border-top: 1px solid #e5e7eb;
|
||
margin-top: 2rem;
|
||
padding-top: 1rem;
|
||
z-index: 10;
|
||
}
|
||
|
||
/* Scroll fade indicators */
|
||
.device-screen.scrollable-device::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 20px;
|
||
background: linear-gradient(to bottom, rgba(255,255,255,0.9), rgba(255,255,255,0));
|
||
pointer-events: none;
|
||
z-index: 5;
|
||
}
|
||
|
||
.device-screen.scrollable-device::after {
|
||
content: '';
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 20px;
|
||
background: linear-gradient(to top, rgba(255,255,255,0.9), rgba(255,255,255,0));
|
||
pointer-events: none;
|
||
z-index: 5;
|
||
}
|
||
|
||
/* Responsive grid adjustments */
|
||
@media (max-width: 768px) {
|
||
.device-preview-container {
|
||
transform: scale(0.8);
|
||
transform-origin: top center;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 480px) {
|
||
.device-preview-container {
|
||
transform: scale(0.6);
|
||
transform-origin: top center;
|
||
}
|
||
}
|
||
</style>
|