Add form builder components and functionality
- Introduced `FormBuilderComponents.vue`, `FormBuilderCanvas.vue`, and `FormBuilderConfiguration.vue` for managing form elements. - Added `ComponentPreview.vue` for rendering previews of form components. - Implemented state management using Pinia in `stores/formBuilder.js` to handle form components and saved forms. - Created pages for form builder interface (`index.vue`) and form management (`manage.vue`). - Integrated toast notifications with `useToast.js` for user feedback. - Documented the form builder structure and features in `FORM_BUILDER_DOCUMENTATION.md` and `FORM_BUILDER_TECHNICAL_APPENDIX.md`. - Established a responsive layout and drag-and-drop functionality for a seamless user experience.
This commit is contained in:
parent
1022033690
commit
316420282b
85
components/ComponentPreview.vue
Normal file
85
components/ComponentPreview.vue
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
<template>
|
||||||
|
<div class="component-preview">
|
||||||
|
<!-- Basic Input Types -->
|
||||||
|
<FormKit
|
||||||
|
v-if="isInputType"
|
||||||
|
:id="`preview-${component.id}`"
|
||||||
|
:type="component.props.type"
|
||||||
|
:name="component.props.name"
|
||||||
|
:label="component.props.label"
|
||||||
|
:help="component.props.help"
|
||||||
|
:placeholder="component.props.placeholder"
|
||||||
|
:validation="component.props.validation"
|
||||||
|
:validation-visibility="isPreview ? 'live' : 'blur'"
|
||||||
|
:disabled="isPreview"
|
||||||
|
:options="component.props.options || undefined"
|
||||||
|
:value="component.props.value || undefined"
|
||||||
|
:accept="component.props.accept || undefined"
|
||||||
|
:max="component.props.max || undefined"
|
||||||
|
:preserve-events="isPreview"
|
||||||
|
@input.capture.stop="isPreview ? $event.stopPropagation() : null"
|
||||||
|
@click.capture.stop="isPreview ? $event.stopPropagation() : null"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Heading -->
|
||||||
|
<div v-else-if="component.type === 'heading'" class="py-2">
|
||||||
|
<component
|
||||||
|
:is="`h${component.props.level || 2}`"
|
||||||
|
class="font-semibold"
|
||||||
|
:class="{
|
||||||
|
'text-2xl': component.props.level === 2,
|
||||||
|
'text-xl': component.props.level === 3,
|
||||||
|
'text-lg': component.props.level === 4
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ component.props.value }}
|
||||||
|
</component>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Paragraph -->
|
||||||
|
<div v-else-if="component.type === 'paragraph'" class="py-2">
|
||||||
|
<p class="text-gray-600">{{ component.props.value }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<div v-else-if="component.type === 'divider'" class="py-4">
|
||||||
|
<hr class="border-gray-200" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unknown Component Type Fallback -->
|
||||||
|
<div v-else class="p-4 border border-dashed border-gray-300 rounded">
|
||||||
|
<div class="text-gray-500">Unknown component type: {{ component.type }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
component: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
isPreview: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if the component is a standard FormKit input type
|
||||||
|
const isInputType = computed(() => {
|
||||||
|
const inputTypes = [
|
||||||
|
'text', 'textarea', 'number', 'email', 'password',
|
||||||
|
'date', 'time', 'datetime-local', 'url', 'tel',
|
||||||
|
'select', 'checkbox', 'radio', 'file', 'range',
|
||||||
|
'color', 'hidden', 'group', 'repeater'
|
||||||
|
];
|
||||||
|
|
||||||
|
return inputTypes.includes(props.component.type);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.component-preview {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
141
components/FormBuilderCanvas.vue
Normal file
141
components/FormBuilderCanvas.vue
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
<template>
|
||||||
|
<div class="form-builder-canvas">
|
||||||
|
<div v-if="formComponents.length === 0" class="flex flex-col items-center justify-center h-full py-16 text-gray-400">
|
||||||
|
<Icon name="material-symbols:layers-outline" class="w-16 h-16 mb-4 text-gray-300" />
|
||||||
|
<p class="text-base font-medium">Drag components here</p>
|
||||||
|
<p class="text-xs mt-1">Or click a component from the sidebar</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<draggable
|
||||||
|
v-else
|
||||||
|
v-model="componentList"
|
||||||
|
group="form-components"
|
||||||
|
item-key="id"
|
||||||
|
handle=".drag-handle"
|
||||||
|
ghost-class="ghost"
|
||||||
|
animation="300"
|
||||||
|
@end="onDragEnd"
|
||||||
|
>
|
||||||
|
<template #item="{ element, index }">
|
||||||
|
<div
|
||||||
|
class="form-component relative mb-3 border rounded-md overflow-hidden transition-all"
|
||||||
|
:class="{
|
||||||
|
'ring-2 ring-blue-400 bg-blue-50 border-transparent': selectedComponentId === element.id,
|
||||||
|
'bg-white border-gray-200 hover:border-gray-300': selectedComponentId !== element.id
|
||||||
|
}"
|
||||||
|
@click.capture="selectComponent(element)"
|
||||||
|
>
|
||||||
|
<div class="component-actions absolute right-1.5 top-1.5 flex space-x-1 z-10">
|
||||||
|
<button
|
||||||
|
class="p-1 text-gray-400 hover:text-gray-600 rounded"
|
||||||
|
title="Drag to reorder"
|
||||||
|
>
|
||||||
|
<span class="drag-handle cursor-move">
|
||||||
|
<Icon name="material-symbols:drag-indicator" class="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1 text-gray-400 hover:text-red-500 rounded"
|
||||||
|
title="Delete component"
|
||||||
|
@click.stop="deleteComponent(element.id)"
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:delete-outline" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3">
|
||||||
|
<component-preview
|
||||||
|
:component="element"
|
||||||
|
:is-preview="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import draggable from 'vuedraggable';
|
||||||
|
import { onMounted, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
formComponents: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['select-component', 'move-component', 'delete-component']);
|
||||||
|
|
||||||
|
const selectedComponentId = ref(null);
|
||||||
|
|
||||||
|
// Watch for changes in formComponents
|
||||||
|
watch(() => props.formComponents, (newComponents) => {
|
||||||
|
// If the currently selected component is no longer in the list, deselect it
|
||||||
|
if (selectedComponentId.value &&
|
||||||
|
!newComponents.some(comp => comp.id === selectedComponentId.value)) {
|
||||||
|
selectedComponentId.value = null;
|
||||||
|
}
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
// Create a reactive list with the form components
|
||||||
|
const componentList = computed({
|
||||||
|
get: () => props.formComponents,
|
||||||
|
set: (value) => {
|
||||||
|
// Do nothing here to avoid reactivity loops - let onDragEnd handle the changes
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle component selection
|
||||||
|
const selectComponent = (component) => {
|
||||||
|
selectedComponentId.value = component.id;
|
||||||
|
|
||||||
|
// Use a copy of the component to prevent reactivity issues
|
||||||
|
const componentCopy = JSON.parse(JSON.stringify(component));
|
||||||
|
emit('select-component', componentCopy);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle component deletion
|
||||||
|
const deleteComponent = (id) => {
|
||||||
|
if (selectedComponentId.value === id) {
|
||||||
|
selectedComponentId.value = null;
|
||||||
|
}
|
||||||
|
emit('delete-component', id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle drag end event for reordering
|
||||||
|
const onDragEnd = (event) => {
|
||||||
|
if (event.oldIndex !== event.newIndex) {
|
||||||
|
emit('move-component', {
|
||||||
|
oldIndex: event.oldIndex,
|
||||||
|
newIndex: event.newIndex
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ghost {
|
||||||
|
opacity: 0.5;
|
||||||
|
background: #e0f2fe;
|
||||||
|
border: 1px dashed #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-component {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-component:hover .component-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-actions {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-component:hover {
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
</style>
|
329
components/FormBuilderComponents.vue
Normal file
329
components/FormBuilderComponents.vue
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
<template>
|
||||||
|
<div class="form-components">
|
||||||
|
<div v-for="group in groupedComponents" :key="group.category" class="mb-2">
|
||||||
|
<RsCollapse>
|
||||||
|
<RsCollapseItem
|
||||||
|
:title="group.category"
|
||||||
|
:open="group.category === 'Basic Inputs'"
|
||||||
|
class="text-sm font-medium"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-1 gap-1.5 mt-2">
|
||||||
|
<div
|
||||||
|
v-for="component in group.components"
|
||||||
|
:key="component.type"
|
||||||
|
class="component-item border border-gray-200 rounded p-2 flex items-center cursor-grab hover:bg-gray-50 transition-colors"
|
||||||
|
:class="{ 'hidden': !matchesSearch(component) }"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onDragStart($event, component)"
|
||||||
|
@click="addComponent(component)"
|
||||||
|
>
|
||||||
|
<div class="bg-gray-100 p-1.5 rounded mr-2 flex items-center justify-center w-8 h-8 flex-shrink-0">
|
||||||
|
<Icon :name="component.icon" class="w-4 h-4 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="font-medium text-sm truncate">{{ component.name }}</div>
|
||||||
|
<div class="text-xs text-gray-500 truncate">{{ component.description }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RsCollapseItem>
|
||||||
|
</RsCollapse>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const emit = defineEmits(['add-component']);
|
||||||
|
const searchQuery = ref('');
|
||||||
|
|
||||||
|
// Define available form components
|
||||||
|
const availableComponents = [
|
||||||
|
// Basic inputs
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
name: 'Text Field',
|
||||||
|
category: 'Basic Inputs',
|
||||||
|
icon: 'material-symbols:text-fields',
|
||||||
|
description: 'Single line text input',
|
||||||
|
defaultProps: {
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Enter text...',
|
||||||
|
help: '',
|
||||||
|
validation: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'textarea',
|
||||||
|
name: 'Text Area',
|
||||||
|
category: 'Basic Inputs',
|
||||||
|
icon: 'material-symbols:article-outline',
|
||||||
|
description: 'Multi-line text input',
|
||||||
|
defaultProps: {
|
||||||
|
type: 'textarea',
|
||||||
|
placeholder: 'Enter text...',
|
||||||
|
help: '',
|
||||||
|
validation: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'number',
|
||||||
|
name: 'Number',
|
||||||
|
category: 'Basic Inputs',
|
||||||
|
icon: 'material-symbols:counter-1-outline',
|
||||||
|
description: 'Numeric input field',
|
||||||
|
defaultProps: {
|
||||||
|
type: 'number',
|
||||||
|
placeholder: '0',
|
||||||
|
help: '',
|
||||||
|
validation: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'email',
|
||||||
|
name: 'Email',
|
||||||
|
category: 'Basic Inputs',
|
||||||
|
icon: 'material-symbols:mail-outline',
|
||||||
|
description: 'Email address input',
|
||||||
|
defaultProps: {
|
||||||
|
type: 'email',
|
||||||
|
placeholder: 'email@example.com',
|
||||||
|
help: '',
|
||||||
|
validation: 'email'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'password',
|
||||||
|
name: 'Password',
|
||||||
|
category: 'Basic Inputs',
|
||||||
|
icon: 'material-symbols:password',
|
||||||
|
description: 'Password input field',
|
||||||
|
defaultProps: {
|
||||||
|
type: 'password',
|
||||||
|
placeholder: 'Enter password...',
|
||||||
|
help: '',
|
||||||
|
validation: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Selection inputs
|
||||||
|
{
|
||||||
|
type: 'select',
|
||||||
|
name: 'Select Dropdown',
|
||||||
|
category: 'Selection Inputs',
|
||||||
|
icon: 'material-symbols:arrow-drop-down-circle-outline',
|
||||||
|
description: 'Dropdown select menu',
|
||||||
|
defaultProps: {
|
||||||
|
type: 'select',
|
||||||
|
placeholder: 'Select an option',
|
||||||
|
help: '',
|
||||||
|
options: [
|
||||||
|
{ label: 'Option 1', value: 'option_1' },
|
||||||
|
{ label: 'Option 2', value: 'option_2' },
|
||||||
|
{ label: 'Option 3', value: 'option_3' }
|
||||||
|
],
|
||||||
|
validation: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'checkbox',
|
||||||
|
name: 'Checkbox',
|
||||||
|
category: 'Selection Inputs',
|
||||||
|
icon: 'material-symbols:check-box-outline',
|
||||||
|
description: 'Single checkbox input',
|
||||||
|
defaultProps: {
|
||||||
|
type: 'checkbox',
|
||||||
|
help: '',
|
||||||
|
validation: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'radio',
|
||||||
|
name: 'Radio Group',
|
||||||
|
category: 'Selection Inputs',
|
||||||
|
icon: 'material-symbols:radio-button-checked-outline',
|
||||||
|
description: 'Radio button group',
|
||||||
|
defaultProps: {
|
||||||
|
type: 'radio',
|
||||||
|
help: '',
|
||||||
|
options: [
|
||||||
|
{ label: 'Option 1', value: 'option_1' },
|
||||||
|
{ label: 'Option 2', value: 'option_2' },
|
||||||
|
{ label: 'Option 3', value: 'option_3' }
|
||||||
|
],
|
||||||
|
validation: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Date and Time
|
||||||
|
{
|
||||||
|
type: 'date',
|
||||||
|
name: 'Date Picker',
|
||||||
|
category: 'Date and Time',
|
||||||
|
icon: 'material-symbols:calendar-month-outline',
|
||||||
|
description: 'Date selection input',
|
||||||
|
defaultProps: {
|
||||||
|
type: 'date',
|
||||||
|
help: '',
|
||||||
|
validation: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'time',
|
||||||
|
name: 'Time Picker',
|
||||||
|
category: 'Date and Time',
|
||||||
|
icon: 'material-symbols:schedule-outline',
|
||||||
|
description: 'Time selection input',
|
||||||
|
defaultProps: {
|
||||||
|
type: 'time',
|
||||||
|
help: '',
|
||||||
|
validation: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'datetime-local',
|
||||||
|
name: 'Date & Time',
|
||||||
|
category: 'Date and Time',
|
||||||
|
icon: 'material-symbols:event-outline',
|
||||||
|
description: 'Date and time selection',
|
||||||
|
defaultProps: {
|
||||||
|
type: 'datetime-local',
|
||||||
|
help: '',
|
||||||
|
validation: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Advanced
|
||||||
|
{
|
||||||
|
type: 'file',
|
||||||
|
name: 'File Upload',
|
||||||
|
category: 'Advanced',
|
||||||
|
icon: 'material-symbols:upload-file-outline',
|
||||||
|
description: 'File upload input',
|
||||||
|
defaultProps: {
|
||||||
|
type: 'file',
|
||||||
|
help: '',
|
||||||
|
accept: '*/*',
|
||||||
|
validation: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'repeater',
|
||||||
|
name: 'Repeater',
|
||||||
|
category: 'Advanced',
|
||||||
|
icon: 'material-symbols:add-box-outline',
|
||||||
|
description: 'Repeatable group of fields',
|
||||||
|
defaultProps: {
|
||||||
|
type: 'repeater',
|
||||||
|
help: '',
|
||||||
|
max: 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
name: 'Group',
|
||||||
|
category: 'Advanced',
|
||||||
|
icon: 'material-symbols:folder-outline',
|
||||||
|
description: 'Group of related fields',
|
||||||
|
defaultProps: {
|
||||||
|
type: 'group',
|
||||||
|
help: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Layout
|
||||||
|
{
|
||||||
|
type: 'heading',
|
||||||
|
name: 'Heading',
|
||||||
|
category: 'Layout',
|
||||||
|
icon: 'material-symbols:title',
|
||||||
|
description: 'Section heading text',
|
||||||
|
defaultProps: {
|
||||||
|
value: 'Section Heading',
|
||||||
|
level: 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
name: 'Paragraph',
|
||||||
|
category: 'Layout',
|
||||||
|
icon: 'material-symbols:text-snippet-outline',
|
||||||
|
description: 'Paragraph of text',
|
||||||
|
defaultProps: {
|
||||||
|
value: 'Enter some descriptive text here.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'divider',
|
||||||
|
name: 'Divider',
|
||||||
|
category: 'Layout',
|
||||||
|
icon: 'material-symbols:horizontal-rule',
|
||||||
|
description: 'Horizontal divider line',
|
||||||
|
defaultProps: {}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Group components by category
|
||||||
|
const groupedComponents = computed(() => {
|
||||||
|
const grouped = {};
|
||||||
|
|
||||||
|
availableComponents.forEach(component => {
|
||||||
|
if (!grouped[component.category]) {
|
||||||
|
grouped[component.category] = {
|
||||||
|
category: component.category,
|
||||||
|
components: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped[component.category].components.push(component);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.values(grouped);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if component matches search query
|
||||||
|
const matchesSearch = (component) => {
|
||||||
|
if (!searchQuery.value) return true;
|
||||||
|
|
||||||
|
const query = searchQuery.value.toLowerCase();
|
||||||
|
return (
|
||||||
|
component.name.toLowerCase().includes(query) ||
|
||||||
|
component.description.toLowerCase().includes(query) ||
|
||||||
|
component.type.toLowerCase().includes(query) ||
|
||||||
|
component.category.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle drag start event
|
||||||
|
const onDragStart = (event, component) => {
|
||||||
|
// Set the drag data
|
||||||
|
event.dataTransfer.effectAllowed = 'copy';
|
||||||
|
event.dataTransfer.setData('component', JSON.stringify(component));
|
||||||
|
|
||||||
|
// Let browser handle the drag image naturally
|
||||||
|
// Don't call emit here to avoid double component creation
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add a click handler for adding components directly
|
||||||
|
const addComponent = (component) => {
|
||||||
|
emit('add-component', component);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.component-item {
|
||||||
|
transition: transform 0.1s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-item:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.collapse-title) {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.collapse-content) {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
437
components/FormBuilderConfiguration.vue
Normal file
437
components/FormBuilderConfiguration.vue
Normal file
@ -0,0 +1,437 @@
|
|||||||
|
<template>
|
||||||
|
<div class="config-panel">
|
||||||
|
<template v-if="component">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="text-xs text-gray-500">{{ component.type }}</div>
|
||||||
|
<div class="text-base font-medium text-gray-700">{{ component.props.label || component.name }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="custom-tabs">
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<div class="custom-tab-nav">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.id"
|
||||||
|
@click="activeTab = tab.id"
|
||||||
|
class="custom-tab-button"
|
||||||
|
:class="{ 'active': activeTab === tab.id }"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div class="custom-tab-content p-4 border border-gray-200 rounded-b bg-white">
|
||||||
|
<!-- Basic Tab -->
|
||||||
|
<div v-if="activeTab === 'basic'" class="space-y-3">
|
||||||
|
<!-- Standard Fields For All Components -->
|
||||||
|
<FormKit
|
||||||
|
v-if="showField('label')"
|
||||||
|
type="text"
|
||||||
|
label="Label"
|
||||||
|
name="label"
|
||||||
|
v-model="configModel.label"
|
||||||
|
help="The label displayed above the field"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormKit
|
||||||
|
v-if="showField('name')"
|
||||||
|
type="text"
|
||||||
|
label="Field Name"
|
||||||
|
name="name"
|
||||||
|
v-model="configModel.name"
|
||||||
|
help="The name used when submitting the form"
|
||||||
|
validation="required"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormKit
|
||||||
|
v-if="showField('placeholder')"
|
||||||
|
type="text"
|
||||||
|
label="Placeholder"
|
||||||
|
name="placeholder"
|
||||||
|
v-model="configModel.placeholder"
|
||||||
|
help="Text shown when field is empty"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormKit
|
||||||
|
v-if="showField('help')"
|
||||||
|
type="textarea"
|
||||||
|
label="Help Text"
|
||||||
|
name="help"
|
||||||
|
v-model="configModel.help"
|
||||||
|
help="Helpful text displayed below the field"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Value Fields For Text Components -->
|
||||||
|
<FormKit
|
||||||
|
v-if="showField('value')"
|
||||||
|
type="textarea"
|
||||||
|
label="Content"
|
||||||
|
name="value"
|
||||||
|
v-model="configModel.value"
|
||||||
|
help="The content to display"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Level Field For Heading -->
|
||||||
|
<FormKit
|
||||||
|
v-if="showField('level')"
|
||||||
|
type="select"
|
||||||
|
label="Heading Level"
|
||||||
|
name="level"
|
||||||
|
v-model="configModel.level"
|
||||||
|
:options="[
|
||||||
|
{ label: 'Heading 1 (Large)', value: 1 },
|
||||||
|
{ label: 'Heading 2 (Medium)', value: 2 },
|
||||||
|
{ label: 'Heading 3 (Small)', value: 3 },
|
||||||
|
{ label: 'Heading 4 (Extra Small)', value: 4 }
|
||||||
|
]"
|
||||||
|
help="Size of the heading"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Options For Select/Radio/Checkbox -->
|
||||||
|
<div v-if="showField('options')">
|
||||||
|
<div class="flex justify-between items-center mb-1">
|
||||||
|
<label class="text-sm font-medium">Options</label>
|
||||||
|
<button
|
||||||
|
class="text-xs text-blue-600 hover:text-blue-700 flex items-center"
|
||||||
|
@click="addOption"
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:add-circle-outline" class="w-3.5 h-3.5 mr-0.5" />
|
||||||
|
Add Option
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border rounded bg-gray-50 divide-y">
|
||||||
|
<div
|
||||||
|
v-for="(option, index) in configModel.options"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center p-2"
|
||||||
|
>
|
||||||
|
<div class="flex-1 grid grid-cols-2 gap-2">
|
||||||
|
<FormKit
|
||||||
|
type="text"
|
||||||
|
placeholder="Label"
|
||||||
|
v-model="option.label"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<FormKit
|
||||||
|
type="text"
|
||||||
|
placeholder="Value"
|
||||||
|
v-model="option.value"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="ml-1 p-1 text-gray-400 hover:text-red-500 rounded"
|
||||||
|
@click="removeOption(index)"
|
||||||
|
title="Remove option"
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:delete-outline" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="configModel.options && configModel.options.length === 0" class="p-2 text-center text-gray-500 text-xs">
|
||||||
|
No options added yet
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Accept Types for File Upload -->
|
||||||
|
<FormKit
|
||||||
|
v-if="showField('accept')"
|
||||||
|
type="text"
|
||||||
|
label="Accepted File Types"
|
||||||
|
name="accept"
|
||||||
|
v-model="configModel.accept"
|
||||||
|
help="File types, e.g. '.jpg,.png,image/*'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Max items for repeater -->
|
||||||
|
<FormKit
|
||||||
|
v-if="showField('max')"
|
||||||
|
type="number"
|
||||||
|
label="Maximum Items"
|
||||||
|
name="max"
|
||||||
|
v-model="configModel.max"
|
||||||
|
help="Maximum number of repeatable items"
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Validation Tab -->
|
||||||
|
<div v-if="activeTab === 'validation'" class="space-y-3">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="flex justify-between items-center mb-1">
|
||||||
|
<label class="text-sm font-medium">Available Validations</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
v-for="validator in availableValidators"
|
||||||
|
:key="validator.name"
|
||||||
|
@click="addValidator(validator.name)"
|
||||||
|
class="px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded flex items-center"
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:add-circle-outline" class="w-3 h-3 mr-0.5 text-blue-600" />
|
||||||
|
{{ validator.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormKit
|
||||||
|
type="textarea"
|
||||||
|
label="Validation Rules"
|
||||||
|
name="validation"
|
||||||
|
v-model="configModel.validation"
|
||||||
|
help="Comma-separated validation rules"
|
||||||
|
placeholder="e.g., required,email,min:5"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="text-xs text-gray-600 mt-2 bg-gray-50 p-2 rounded-md">
|
||||||
|
<div class="font-medium mb-1 text-gray-700">Common Validations:</div>
|
||||||
|
<div class="grid grid-cols-2 gap-x-2 gap-y-1">
|
||||||
|
<div><code>required</code> - Required field</div>
|
||||||
|
<div><code>email</code> - Valid email</div>
|
||||||
|
<div><code>min:8</code> - Min length</div>
|
||||||
|
<div><code>max:100</code> - Max length</div>
|
||||||
|
<div><code>url</code> - Valid URL</div>
|
||||||
|
<div><code>date</code> - Valid date</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormKit
|
||||||
|
type="checkbox"
|
||||||
|
label="Required Field"
|
||||||
|
name="isRequired"
|
||||||
|
v-model="isRequired"
|
||||||
|
help="Make this field required for form submission"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Advanced Tab -->
|
||||||
|
<div v-if="activeTab === 'advanced'" class="space-y-3">
|
||||||
|
<FormKit
|
||||||
|
v-if="showField('id')"
|
||||||
|
type="text"
|
||||||
|
label="Component ID"
|
||||||
|
name="componentId"
|
||||||
|
:value="component.id"
|
||||||
|
help="Unique identifier for this component"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Show JSON representation -->
|
||||||
|
<div class="bg-gray-50 p-2 rounded-md">
|
||||||
|
<div class="text-xs font-medium mb-1">Component JSON:</div>
|
||||||
|
<pre class="text-xs overflow-auto max-h-32">{{ JSON.stringify(component, null, 2) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useDebounceFn } from '@vueuse/core';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
component: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update-component']);
|
||||||
|
|
||||||
|
// Define available tabs
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'basic', label: 'Basic' },
|
||||||
|
{ id: 'validation', label: 'Validation' },
|
||||||
|
{ id: 'advanced', label: 'Advanced' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Set active tab
|
||||||
|
const activeTab = ref('basic');
|
||||||
|
|
||||||
|
// Create a deep copy of props to prevent direct mutation
|
||||||
|
const configModel = ref(JSON.parse(JSON.stringify(props.component.props || {})));
|
||||||
|
|
||||||
|
// Watch for component changes from parent, but with a deep equality check to avoid loops
|
||||||
|
watch(() => props.component, (newComponent) => {
|
||||||
|
// Deep compare objects before updating to prevent unnecessary reactivity
|
||||||
|
if (JSON.stringify(configModel.value) !== JSON.stringify(newComponent.props)) {
|
||||||
|
configModel.value = JSON.parse(JSON.stringify(newComponent.props || {}));
|
||||||
|
}
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
// Watch for changes in the configuration and emit updates using a debounce
|
||||||
|
const debouncedEmit = useDebounceFn(() => {
|
||||||
|
const updatedComponent = {
|
||||||
|
...props.component,
|
||||||
|
props: JSON.parse(JSON.stringify(configModel.value))
|
||||||
|
};
|
||||||
|
emit('update-component', updatedComponent);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// Use debounced emit to avoid feedback loops
|
||||||
|
watch(configModel, () => {
|
||||||
|
debouncedEmit();
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
// Computed property to determine if the required validation is present
|
||||||
|
const isRequired = computed({
|
||||||
|
get: () => {
|
||||||
|
const validation = configModel.value.validation || '';
|
||||||
|
return validation.includes('required');
|
||||||
|
},
|
||||||
|
set: (value) => {
|
||||||
|
let validation = configModel.value.validation || '';
|
||||||
|
|
||||||
|
// Remove existing required validation if present
|
||||||
|
validation = validation.split(',')
|
||||||
|
.filter(rule => rule.trim() !== 'required')
|
||||||
|
.join(',');
|
||||||
|
|
||||||
|
// Add required validation if checked
|
||||||
|
if (value) {
|
||||||
|
validation = 'required' + (validation ? ',' + validation : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
configModel.value.validation = validation;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Define available validators that can be added
|
||||||
|
const availableValidators = [
|
||||||
|
{ name: 'required', label: 'Required' },
|
||||||
|
{ name: 'email', label: 'Email' },
|
||||||
|
{ name: 'url', label: 'URL' },
|
||||||
|
{ name: 'number', label: 'Number' },
|
||||||
|
{ name: 'min:5', label: 'Min:5' },
|
||||||
|
{ name: 'max:100', label: 'Max:100' },
|
||||||
|
{ name: 'between:5,10', label: 'Between' },
|
||||||
|
{ name: 'date', label: 'Date' },
|
||||||
|
{ name: 'matches:/pattern/', label: 'Pattern' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add a validator to the validation string
|
||||||
|
const addValidator = (validator) => {
|
||||||
|
let validation = configModel.value.validation || '';
|
||||||
|
|
||||||
|
// Check if this validator is already included
|
||||||
|
if (!validation.includes(validator)) {
|
||||||
|
validation = validation ? validation + ',' + validator : validator;
|
||||||
|
configModel.value.validation = validation;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine which fields to show based on component type
|
||||||
|
const showField = (fieldName) => {
|
||||||
|
const componentType = props.component.type;
|
||||||
|
|
||||||
|
switch (fieldName) {
|
||||||
|
case 'label':
|
||||||
|
return !['heading', 'paragraph', 'divider'].includes(componentType);
|
||||||
|
case 'name':
|
||||||
|
return !['heading', 'paragraph', 'divider'].includes(componentType);
|
||||||
|
case 'placeholder':
|
||||||
|
return ['text', 'textarea', 'email', 'password', 'number', 'select'].includes(componentType);
|
||||||
|
case 'help':
|
||||||
|
return !['heading', 'paragraph', 'divider'].includes(componentType);
|
||||||
|
case 'value':
|
||||||
|
return ['heading', 'paragraph'].includes(componentType);
|
||||||
|
case 'level':
|
||||||
|
return componentType === 'heading';
|
||||||
|
case 'options':
|
||||||
|
return ['select', 'radio', 'checkbox'].includes(componentType);
|
||||||
|
case 'accept':
|
||||||
|
return componentType === 'file';
|
||||||
|
case 'max':
|
||||||
|
return componentType === 'repeater';
|
||||||
|
case 'id':
|
||||||
|
return true; // Always show component ID in advanced tab
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add a new option to select/radio/checkbox
|
||||||
|
const addOption = () => {
|
||||||
|
if (!configModel.value.options) {
|
||||||
|
configModel.value.options = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
configModel.value.options.push({
|
||||||
|
label: `Option ${configModel.value.options.length + 1}`,
|
||||||
|
value: `option_${configModel.value.options.length + 1}`
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove an option from select/radio/checkbox
|
||||||
|
const removeOption = (index) => {
|
||||||
|
configModel.value.options.splice(index, 1);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.config-panel {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.formkit-inner) {
|
||||||
|
min-height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.formkit-label) {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.formkit-help) {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-tabs {
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-tab-nav {
|
||||||
|
display: flex;
|
||||||
|
background-color: #f9fafb;
|
||||||
|
border-top-left-radius: 0.375rem;
|
||||||
|
border-top-right-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-tab-button {
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-grow: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-tab-button:hover {
|
||||||
|
color: #4b5563;
|
||||||
|
background-color: rgba(243, 244, 246, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-tab-button.active {
|
||||||
|
color: #3b82f6;
|
||||||
|
border-bottom-color: #3b82f6;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-tab-content {
|
||||||
|
border-top: none;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
}
|
||||||
|
</style>
|
39
composables/useToast.js
Normal file
39
composables/useToast.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { useToast as useVueToast } from 'vue-toastification';
|
||||||
|
|
||||||
|
export const useToast = () => {
|
||||||
|
const toast = useVueToast();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: (message, options = {}) => {
|
||||||
|
toast.success(message, {
|
||||||
|
timeout: 3000,
|
||||||
|
position: 'top-right',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
error: (message, options = {}) => {
|
||||||
|
toast.error(message, {
|
||||||
|
timeout: 5000,
|
||||||
|
position: 'top-right',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
warning: (message, options = {}) => {
|
||||||
|
toast.warning(message, {
|
||||||
|
timeout: 4000,
|
||||||
|
position: 'top-right',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
info: (message, options = {}) => {
|
||||||
|
toast.info(message, {
|
||||||
|
timeout: 3000,
|
||||||
|
position: 'top-right',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
167
doc/FORM_BUILDER_DOCUMENTATION.md
Normal file
167
doc/FORM_BUILDER_DOCUMENTATION.md
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
# Form Builder Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Form Builder is a drag-and-drop interface for creating dynamic forms. It allows users to build forms by selecting input components, configuring them, and arranging them in the desired order. The form builder follows the design pattern of the code playground with a full-screen empty layout.
|
||||||
|
|
||||||
|
## Files Structure
|
||||||
|
|
||||||
|
### Pages
|
||||||
|
|
||||||
|
1. **`pages/form-builder/index.vue`**
|
||||||
|
- Main form builder interface with three-panel layout
|
||||||
|
- Drag and drop interface for building forms
|
||||||
|
- Uses empty layout for fullscreen experience
|
||||||
|
- Contains form name input and save/preview actions
|
||||||
|
- [View File](pages/form-builder/index.vue)
|
||||||
|
|
||||||
|
2. **`pages/form-builder/manage.vue`**
|
||||||
|
- Form management interface
|
||||||
|
- Lists saved forms with search functionality
|
||||||
|
- Provides edit and delete actions for each form
|
||||||
|
- Uses empty layout and matches design of main page
|
||||||
|
- [View File](pages/form-builder/manage.vue)
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
3. **`components/FormBuilderComponents.vue`**
|
||||||
|
- Left panel component that displays available form elements
|
||||||
|
- Categorizes components into groups (Basic Inputs, Selection Inputs, etc.)
|
||||||
|
- Provides search functionality for finding components
|
||||||
|
- Components can be dragged to the canvas
|
||||||
|
- Uses Material Design icons
|
||||||
|
- [View File](components/FormBuilderComponents.vue)
|
||||||
|
|
||||||
|
4. **`components/FormBuilderCanvas.vue`**
|
||||||
|
- Middle panel component that displays the form being built
|
||||||
|
- Handles drag-and-drop reordering of components
|
||||||
|
- Renders form elements with preview mode
|
||||||
|
- Provides selection, deletion, and reordering capabilities
|
||||||
|
- Uses vuedraggable for drag-and-drop functionality
|
||||||
|
- [View File](components/FormBuilderCanvas.vue)
|
||||||
|
|
||||||
|
5. **`components/FormBuilderConfiguration.vue`**
|
||||||
|
- Right panel component for configuring selected form elements
|
||||||
|
- Tabbed interface with Basic, Validation, and Advanced sections
|
||||||
|
- Dynamically shows/hides fields based on component type
|
||||||
|
- Provides rich validation options
|
||||||
|
- [View File](components/FormBuilderConfiguration.vue)
|
||||||
|
|
||||||
|
6. **`components/ComponentPreview.vue`**
|
||||||
|
- Renders a preview of form components
|
||||||
|
- Handles different component types (inputs, headings, paragraphs, etc.)
|
||||||
|
- Uses FormKit for rendering form inputs
|
||||||
|
- Provides formatting for non-input elements (headings, paragraphs, dividers)
|
||||||
|
- [View File](components/ComponentPreview.vue)
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
7. **`stores/formBuilder.js`**
|
||||||
|
- Pinia store for managing form builder state
|
||||||
|
- Handles form components, selected component, dragging state
|
||||||
|
- Provides actions for adding, updating, moving, and deleting components
|
||||||
|
- Handles form saving and loading with localStorage
|
||||||
|
- Persists saved forms
|
||||||
|
- [View File](stores/formBuilder.js)
|
||||||
|
|
||||||
|
### Utilities
|
||||||
|
|
||||||
|
8. **`composables/useToast.js`**
|
||||||
|
- Toast notification composable for consistent toast messages
|
||||||
|
- Wraps Vue Toastification with standardized configurations
|
||||||
|
- Provides success, error, warning, and info methods
|
||||||
|
- [View File](composables/useToast.js)
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### Form Building
|
||||||
|
- Drag-and-drop interface for adding components to the form
|
||||||
|
- Reordering components via drag-and-drop
|
||||||
|
- Configuring component properties (labels, placeholders, validation, etc.)
|
||||||
|
- Real-time preview of the form being built
|
||||||
|
|
||||||
|
### Component Types
|
||||||
|
- **Basic Inputs**: Text, Textarea, Number, Email, Password
|
||||||
|
- **Selection Inputs**: Select, Checkbox, Radio
|
||||||
|
- **Date and Time**: Date Picker, Time Picker, Date & Time
|
||||||
|
- **Advanced**: File Upload, Repeater, Group
|
||||||
|
- **Layout**: Heading, Paragraph, Divider
|
||||||
|
|
||||||
|
### Form Management
|
||||||
|
- Save forms to localStorage
|
||||||
|
- List all saved forms
|
||||||
|
- Search for forms by name
|
||||||
|
- Edit existing forms
|
||||||
|
- Delete forms
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
- Built-in validation options (required, email, min, max, etc.)
|
||||||
|
- Quick validation buttons
|
||||||
|
- Validation rule syntax help
|
||||||
|
- Required field checkbox
|
||||||
|
|
||||||
|
## UI/UX Design
|
||||||
|
|
||||||
|
- Professional dark header with logo and main actions
|
||||||
|
- Three-panel layout for Components, Canvas, and Configuration
|
||||||
|
- Material Design icons throughout
|
||||||
|
- Empty layout for fullscreen experience
|
||||||
|
- Responsive design that works on mobile and desktop
|
||||||
|
- Consistent styling with the Code Playground
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Layout Structure
|
||||||
|
- Header with navigation and primary actions
|
||||||
|
- Form name input section
|
||||||
|
- Three-panel main content area
|
||||||
|
- Preview modal for testing the form
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
- Centralized Pinia store for form state
|
||||||
|
- Computed properties for derived state
|
||||||
|
- Watchers for reactivity
|
||||||
|
- Local storage for persistence
|
||||||
|
|
||||||
|
### Component Communication
|
||||||
|
- Props for parent-to-child data flow
|
||||||
|
- Events for child-to-parent communication
|
||||||
|
- Store actions for cross-component communication
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
- Tailwind CSS for utility-first styling
|
||||||
|
- Custom transitions and animations
|
||||||
|
- Responsive breakpoints
|
||||||
|
- Consistent color scheme
|
||||||
|
|
||||||
|
## Usage Instructions
|
||||||
|
|
||||||
|
1. Navigate to `/form-builder` to access the form builder
|
||||||
|
2. Add components by dragging them from the left panel to the canvas
|
||||||
|
3. Select a component on the canvas to configure it in the right panel
|
||||||
|
4. Save the form using the Save button in the header
|
||||||
|
5. Preview the form by clicking the Preview button
|
||||||
|
6. Manage saved forms by clicking the Manage Forms button
|
||||||
|
7. Search, edit, or delete forms in the management interface
|
||||||
|
|
||||||
|
## Development Notes
|
||||||
|
|
||||||
|
- The form builder uses vuedraggable for drag-and-drop functionality
|
||||||
|
- FormKit is used for all form inputs
|
||||||
|
- Material Design icons are used throughout
|
||||||
|
- The form builder follows the design patterns established in the code playground
|
||||||
|
- All components are responsive and work on mobile devices
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- Backend integration for saving forms to a database
|
||||||
|
- Form publishing and sharing functionality
|
||||||
|
- Form versioning
|
||||||
|
- Form templates
|
||||||
|
- More advanced validation options
|
||||||
|
- Conditional display of form fields
|
||||||
|
- Multi-page forms
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
This documentation was generated on April 9, 2025.
|
346
doc/FORM_BUILDER_TECHNICAL_APPENDIX.md
Normal file
346
doc/FORM_BUILDER_TECHNICAL_APPENDIX.md
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
# Form Builder Technical Appendix
|
||||||
|
|
||||||
|
This document provides technical details and code snippets for the form builder implementation.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Component Structure](#component-structure)
|
||||||
|
2. [State Management](#state-management)
|
||||||
|
3. [Drag and Drop Implementation](#drag-and-drop-implementation)
|
||||||
|
4. [FormKit Integration](#formkit-integration)
|
||||||
|
5. [Validation System](#validation-system)
|
||||||
|
6. [Icons and UI Elements](#icons-and-ui-elements)
|
||||||
|
7. [Responsive Design](#responsive-design)
|
||||||
|
|
||||||
|
## Component Structure
|
||||||
|
|
||||||
|
The form builder is built with a modular component structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
pages/
|
||||||
|
├── form-builder/
|
||||||
|
│ ├── index.vue # Main form builder page
|
||||||
|
│ └── manage.vue # Form management page
|
||||||
|
components/
|
||||||
|
├── FormBuilderComponents.vue # Left panel components
|
||||||
|
├── FormBuilderCanvas.vue # Middle panel canvas
|
||||||
|
├── FormBuilderConfiguration.vue # Right panel configuration
|
||||||
|
└── ComponentPreview.vue # Component rendering
|
||||||
|
stores/
|
||||||
|
└── formBuilder.js # State management
|
||||||
|
composables/
|
||||||
|
└── useToast.js # Toast notifications
|
||||||
|
```
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
### Pinia Store (stores/formBuilder.js)
|
||||||
|
|
||||||
|
The form builder uses Pinia for state management:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export const useFormBuilderStore = defineStore('formBuilder', {
|
||||||
|
state: () => ({
|
||||||
|
formComponents: [],
|
||||||
|
selectedComponentId: null,
|
||||||
|
formName: 'New Form',
|
||||||
|
formDescription: '',
|
||||||
|
isDraggingOver: false,
|
||||||
|
savedForms: []
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
selectedComponent: (state) => {
|
||||||
|
return state.selectedComponentId
|
||||||
|
? state.formComponents.find(c => c.id === state.selectedComponentId)
|
||||||
|
: null;
|
||||||
|
},
|
||||||
|
|
||||||
|
formConfig: (state) => {
|
||||||
|
return {
|
||||||
|
id: uuidv4(),
|
||||||
|
name: state.formName,
|
||||||
|
description: state.formDescription,
|
||||||
|
components: state.formComponents.map(c => ({
|
||||||
|
type: c.type,
|
||||||
|
props: c.props
|
||||||
|
})),
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
addComponent(component) {
|
||||||
|
const newComponent = {
|
||||||
|
...component,
|
||||||
|
id: uuidv4(),
|
||||||
|
props: {
|
||||||
|
...component.defaultProps,
|
||||||
|
name: `${component.type}_${this.formComponents.length + 1}`,
|
||||||
|
label: `${component.name} ${this.formComponents.length + 1}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.formComponents.push(newComponent);
|
||||||
|
this.selectComponent(newComponent.id);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ... other actions
|
||||||
|
},
|
||||||
|
|
||||||
|
persist: {
|
||||||
|
paths: ['savedForms']
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Drag and Drop Implementation
|
||||||
|
|
||||||
|
### Draggable Components
|
||||||
|
|
||||||
|
The form builder uses `vuedraggable` for drag-and-drop functionality:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- FormBuilderCanvas.vue -->
|
||||||
|
<draggable
|
||||||
|
v-model="componentList"
|
||||||
|
group="form-components"
|
||||||
|
item-key="id"
|
||||||
|
handle=".drag-handle"
|
||||||
|
ghost-class="ghost"
|
||||||
|
animation="300"
|
||||||
|
@end="onDragEnd"
|
||||||
|
>
|
||||||
|
<template #item="{ element, index }">
|
||||||
|
<div class="form-component relative mb-4 border rounded-md overflow-hidden">
|
||||||
|
<!-- Component content -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Drag Start Handler
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// FormBuilderComponents.vue
|
||||||
|
const onDragStart = (event, component) => {
|
||||||
|
event.dataTransfer.effectAllowed = 'copy';
|
||||||
|
event.dataTransfer.setData('component', JSON.stringify(component));
|
||||||
|
emit('add-component', component);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Drop Handler
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// pages/form-builder/index.vue
|
||||||
|
const handleDrop = (event) => {
|
||||||
|
formStore.setDraggingOver(false);
|
||||||
|
const componentData = JSON.parse(event.dataTransfer.getData('component'));
|
||||||
|
formStore.addComponent(componentData);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## FormKit Integration
|
||||||
|
|
||||||
|
### Component Preview
|
||||||
|
|
||||||
|
The form builder uses FormKit to render form components:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- ComponentPreview.vue -->
|
||||||
|
<FormKit
|
||||||
|
v-if="isInputType"
|
||||||
|
:id="`preview-${component.id}`"
|
||||||
|
:type="component.props.type"
|
||||||
|
:name="component.props.name"
|
||||||
|
:label="component.props.label"
|
||||||
|
:help="component.props.help"
|
||||||
|
:placeholder="component.props.placeholder"
|
||||||
|
:validation="component.props.validation"
|
||||||
|
:validation-visibility="isPreview ? 'live' : 'blur'"
|
||||||
|
:disabled="isPreview"
|
||||||
|
:options="component.props.options || undefined"
|
||||||
|
:value="component.props.value || undefined"
|
||||||
|
:accept="component.props.accept || undefined"
|
||||||
|
:max="component.props.max || undefined"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation System
|
||||||
|
|
||||||
|
### Validation Configuration
|
||||||
|
|
||||||
|
The configuration panel provides validation options:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- FormBuilderConfiguration.vue -->
|
||||||
|
<RsTabItem title="Validation">
|
||||||
|
<div class="space-y-4 pt-2">
|
||||||
|
<div class="mb-4">
|
||||||
|
<h4 class="text-sm font-medium mb-2">Available Validations</h4>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<RsButton
|
||||||
|
v-for="validator in availableValidators"
|
||||||
|
:key="validator.name"
|
||||||
|
size="sm"
|
||||||
|
variant="tertiary"
|
||||||
|
class="text-left justify-start"
|
||||||
|
@click="addValidator(validator.name)"
|
||||||
|
>
|
||||||
|
{{ validator.label }}
|
||||||
|
</RsButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormKit
|
||||||
|
type="textarea"
|
||||||
|
label="Validation Rules"
|
||||||
|
name="validation"
|
||||||
|
v-model="configModel.validation"
|
||||||
|
help="Comma-separated validation rules (e.g., required,email,length:5,15)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Validation help -->
|
||||||
|
</div>
|
||||||
|
</RsTabItem>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Validators
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const availableValidators = [
|
||||||
|
{ name: 'required', label: 'Required' },
|
||||||
|
{ name: 'email', label: 'Email' },
|
||||||
|
{ name: 'url', label: 'URL' },
|
||||||
|
{ name: 'number', label: 'Number' },
|
||||||
|
{ name: 'min:5', label: 'Min Length/Value' },
|
||||||
|
{ name: 'max:100', label: 'Max Length/Value' },
|
||||||
|
{ name: 'between:5,10', label: 'Between Range' },
|
||||||
|
{ name: 'date', label: 'Date' },
|
||||||
|
{ name: 'matches:/pattern/', label: 'Pattern Match' }
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## Icons and UI Elements
|
||||||
|
|
||||||
|
### Material Design Icons
|
||||||
|
|
||||||
|
The form builder uses Material Design icons:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<Icon name="material-symbols:layers-outline" class="w-16 h-16 mb-4" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Icons
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// FormBuilderComponents.vue
|
||||||
|
const availableComponents = [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
name: 'Text Field',
|
||||||
|
category: 'Basic Inputs',
|
||||||
|
icon: 'material-symbols:text-fields',
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
// ...
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## Responsive Design
|
||||||
|
|
||||||
|
### Responsive Layout
|
||||||
|
|
||||||
|
The form builder uses a responsive layout that adapts to different screen sizes:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- pages/form-builder/index.vue -->
|
||||||
|
<div class="flex-1 flex flex-col lg:flex-row overflow-hidden">
|
||||||
|
<!-- Left Panel -->
|
||||||
|
<div class="lg:w-1/4 border-r border-gray-200 bg-white overflow-hidden flex flex-col">
|
||||||
|
<!-- Left panel content -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Middle Panel -->
|
||||||
|
<div class="lg:w-2/4 bg-white overflow-hidden flex flex-col border-t lg:border-t-0 lg:border-r border-gray-200">
|
||||||
|
<!-- Middle panel content -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Panel -->
|
||||||
|
<div class="lg:w-1/4 bg-white overflow-hidden flex flex-col border-t lg:border-t-0">
|
||||||
|
<!-- Right panel content -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Responsive Header
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<header class="bg-gray-800 p-2 flex flex-wrap items-center justify-between text-white">
|
||||||
|
<div class="flex items-center mb-2 sm:mb-0 gap-4">
|
||||||
|
<!-- Logo and back button -->
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center space-x-2">
|
||||||
|
<!-- Actions -->
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Data Structure
|
||||||
|
|
||||||
|
### Form Component Structure
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Example of a form component object
|
||||||
|
{
|
||||||
|
id: "550e8400-e29b-41d4-a716-446655440000", // UUID
|
||||||
|
type: "text",
|
||||||
|
name: "Text Field",
|
||||||
|
category: "Basic Inputs",
|
||||||
|
icon: "material-symbols:text-fields",
|
||||||
|
props: {
|
||||||
|
type: "text",
|
||||||
|
name: "text_1",
|
||||||
|
label: "Text Field 1",
|
||||||
|
placeholder: "Enter text...",
|
||||||
|
help: "",
|
||||||
|
validation: "required"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Form Configuration Structure
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Example of a saved form
|
||||||
|
{
|
||||||
|
id: "550e8400-e29b-41d4-a716-446655440000", // UUID
|
||||||
|
name: "Contact Form",
|
||||||
|
description: "",
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
props: {
|
||||||
|
type: "text",
|
||||||
|
name: "name",
|
||||||
|
label: "Name",
|
||||||
|
placeholder: "Enter your name",
|
||||||
|
validation: "required"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "email",
|
||||||
|
props: {
|
||||||
|
type: "email",
|
||||||
|
name: "email",
|
||||||
|
label: "Email",
|
||||||
|
placeholder: "Enter your email",
|
||||||
|
validation: "required,email"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
createdAt: "2025-04-09T03:30:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
272
pages/form-builder/index.vue
Normal file
272
pages/form-builder/index.vue
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-screen bg-gray-50">
|
||||||
|
<!-- Header Bar -->
|
||||||
|
<header
|
||||||
|
class="bg-gray-800 px-4 py-4 flex items-center justify-between text-white shadow-md"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Icon
|
||||||
|
@click="navigateTo('/', { external: true })"
|
||||||
|
name="ph:arrow-circle-left-duotone"
|
||||||
|
class="cursor-pointer w-6 h-6"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src="@/assets/img/logo/logo-word-white.svg"
|
||||||
|
alt="Corrad Logo"
|
||||||
|
class="h-7"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<FormKit
|
||||||
|
type="text"
|
||||||
|
name="formName"
|
||||||
|
placeholder="Form Name"
|
||||||
|
v-model="formStore.formName"
|
||||||
|
validation="required"
|
||||||
|
validation-visibility="live"
|
||||||
|
:validation-messages="{ required: 'Please enter a form name' }"
|
||||||
|
class="form-name-input"
|
||||||
|
:classes="{
|
||||||
|
outer: 'mb-0',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RsButton @click="handleSave" variant="primary" size="sm">
|
||||||
|
<Icon name="material-symbols:save" class="mr-1" />
|
||||||
|
Save Form
|
||||||
|
</RsButton>
|
||||||
|
|
||||||
|
<RsButton @click="handlePreview" variant="secondary" size="sm">
|
||||||
|
<Icon name="material-symbols:preview" class="mr-1" />
|
||||||
|
Preview
|
||||||
|
</RsButton>
|
||||||
|
|
||||||
|
<RsButton @click="navigateToManage" variant="secondary" size="sm">
|
||||||
|
<Icon name="material-symbols:settings" class="mr-1" />
|
||||||
|
Manage Forms
|
||||||
|
</RsButton>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Content Area -->
|
||||||
|
<div class="flex-1 flex overflow-hidden">
|
||||||
|
<!-- Left Panel - Components Sidebar -->
|
||||||
|
<div
|
||||||
|
class="w-64 bg-white border-r border-gray-200 flex flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
Available Components
|
||||||
|
</h2>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
class="px-2 py-1 pr-7 border border-gray-300 rounded text-xs w-24"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
name="material-symbols:search"
|
||||||
|
class="absolute right-2 top-1/2 transform -translate-y-1/2 w-3.5 h-3.5 text-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-y-auto flex-1 p-2">
|
||||||
|
<FormBuilderComponents @add-component="handleAddComponent" />
|
||||||
|
</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-16"
|
||||||
|
>
|
||||||
|
<h2 class="text-sm font-medium text-gray-700">Form Canvas</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-500">Drag components to build your form</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-auto p-6">
|
||||||
|
<div
|
||||||
|
class="border-2 border-dashed border-gray-300 bg-white rounded-md min-h-[calc(100vh-200px)] p-4"
|
||||||
|
:class="{ 'border-blue-400 bg-blue-50': formStore.isDraggingOver }"
|
||||||
|
@dragover.prevent="handleDragOver"
|
||||||
|
@dragleave.prevent="handleDragLeave"
|
||||||
|
@drop.prevent="handleDrop"
|
||||||
|
>
|
||||||
|
<FormBuilderCanvas
|
||||||
|
:form-components="formStore.formComponents"
|
||||||
|
@select-component="handleSelectComponent"
|
||||||
|
@move-component="handleMoveComponent"
|
||||||
|
@delete-component="handleDeleteComponent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Panel - Configuration Sidebar -->
|
||||||
|
<div
|
||||||
|
class="w-72 bg-white border-l border-gray-200 flex flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="p-3 border-b border-gray-200 bg-gray-50 h-16 flex items-center"
|
||||||
|
>
|
||||||
|
<h2 class="text-sm font-medium text-gray-700">Configuration</h2>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-y-auto flex-1 p-3">
|
||||||
|
<FormBuilderConfiguration
|
||||||
|
v-if="formStore.selectedComponent"
|
||||||
|
:component="formStore.selectedComponent"
|
||||||
|
@update-component="handleUpdateComponent"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex items-center justify-center h-full text-gray-500"
|
||||||
|
>
|
||||||
|
<div class="text-center p-4">
|
||||||
|
<Icon
|
||||||
|
name="material-symbols:settings-suggest"
|
||||||
|
class="w-12 h-12 mx-auto mb-3 text-gray-300"
|
||||||
|
/>
|
||||||
|
<p class="text-sm font-medium">No component selected</p>
|
||||||
|
<p class="text-xs mt-1">Select a component to configure</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview Modal -->
|
||||||
|
<RsModal v-model="showPreview" title="Form Preview" size="xl">
|
||||||
|
<div class="max-h-[70vh] overflow-y-auto p-4">
|
||||||
|
<FormKit type="form" @submit="handlePreviewSubmit">
|
||||||
|
<template
|
||||||
|
v-for="(component, index) in formStore.formComponents"
|
||||||
|
:key="index"
|
||||||
|
>
|
||||||
|
<component-preview :component="component" :is-preview="false" />
|
||||||
|
</template>
|
||||||
|
<FormKit type="submit" label="Submit" />
|
||||||
|
</FormKit>
|
||||||
|
</div>
|
||||||
|
</RsModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useFormBuilderStore } from "~/stores/formBuilder";
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
title: "Form Builder",
|
||||||
|
description: "Create dynamic forms with drag and drop",
|
||||||
|
layout: "empty",
|
||||||
|
middleware: ["auth"],
|
||||||
|
requiresAuth: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const formStore = useFormBuilderStore();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
// Initialize the form builder
|
||||||
|
onMounted(() => {
|
||||||
|
formStore.loadSavedForms();
|
||||||
|
});
|
||||||
|
|
||||||
|
const showPreview = ref(false);
|
||||||
|
|
||||||
|
// Handler methods
|
||||||
|
const handleAddComponent = (component) => {
|
||||||
|
formStore.addComponent(component);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectComponent = (component) => {
|
||||||
|
formStore.selectComponent(component.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateComponent = (updatedComponent) => {
|
||||||
|
formStore.updateComponent(updatedComponent);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoveComponent = ({ oldIndex, newIndex }) => {
|
||||||
|
formStore.moveComponent({ oldIndex, newIndex });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteComponent = (id) => {
|
||||||
|
formStore.deleteComponent(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event) => {
|
||||||
|
formStore.setDraggingOver(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (event) => {
|
||||||
|
formStore.setDraggingOver(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (event) => {
|
||||||
|
formStore.setDraggingOver(false);
|
||||||
|
const componentData = JSON.parse(event.dataTransfer.getData("component"));
|
||||||
|
formStore.addComponent(componentData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
formStore.saveForm();
|
||||||
|
toast.success("Form saved successfully");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreview = () => {
|
||||||
|
if (formStore.formComponents.length === 0) {
|
||||||
|
toast.error("Please add at least one component to the form");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showPreview.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreviewSubmit = (formData) => {
|
||||||
|
console.log("Form submitted:", formData);
|
||||||
|
showPreview.value = false;
|
||||||
|
toast.success("Form submitted successfully");
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateToManage = () => {
|
||||||
|
router.push("/form-builder/manage");
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form-name-input :deep(.formkit-inner) {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
color: white;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
</style>
|
146
pages/form-builder/manage.vue
Normal file
146
pages/form-builder/manage.vue
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-screen bg-gray-50">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="bg-gray-800 p-2 flex flex-wrap items-center justify-between text-white">
|
||||||
|
<div class="flex items-center mb-2 sm:mb-0 gap-4">
|
||||||
|
<Icon
|
||||||
|
@click="navigateTo('/', { external: true })"
|
||||||
|
name="ph:arrow-circle-left-duotone"
|
||||||
|
class="cursor-pointer w-6 h-6"
|
||||||
|
/>
|
||||||
|
<img src="@/assets/img/logo/logo-word-white.svg" alt="Corrad Logo" class="h-8 block mr-2" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center space-x-2">
|
||||||
|
<RsButton @click="navigateToBuilder" variant="primary" class="mr-2">
|
||||||
|
<Icon name="material-symbols:add" class="mr-2" />
|
||||||
|
Create New Form
|
||||||
|
</RsButton>
|
||||||
|
<h1 class="text-lg font-semibold">Manage Forms</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div class="flex-1 overflow-auto p-6">
|
||||||
|
<div class="container mx-auto">
|
||||||
|
<!-- Search bar -->
|
||||||
|
<div class="bg-white p-4 rounded-lg shadow-sm mb-6">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<h2 class="text-xl font-medium">Saved Forms</h2>
|
||||||
|
<div class="relative w-full md:w-64">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="Search forms..."
|
||||||
|
class="w-full px-3 py-2 pl-9 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<Icon name="material-symbols:search" class="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Forms list -->
|
||||||
|
<div class="bg-white rounded-lg shadow-sm">
|
||||||
|
<div v-if="formStore.savedForms.length === 0" class="text-center py-16 text-gray-500">
|
||||||
|
<Icon name="material-symbols:file-copy-outline" class="w-16 h-16 mx-auto mb-4" />
|
||||||
|
<p class="text-lg font-medium">No forms found</p>
|
||||||
|
<p class="text-sm mb-4">Start by creating a new form</p>
|
||||||
|
<RsButton @click="navigateToBuilder" variant="primary">Create Form</RsButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<RsTable
|
||||||
|
:field="['Form Name', 'Created', 'Actions']"
|
||||||
|
:data="filteredForms"
|
||||||
|
:options="{ striped: true, hover: true }"
|
||||||
|
:optionsAdvanced="{ sortable: true, responsive: true }"
|
||||||
|
>
|
||||||
|
<template #cell-2="{ row, index }">
|
||||||
|
<div class="flex space-x-2 justify-end">
|
||||||
|
<RsButton @click="editForm(row.id)" size="sm" variant="tertiary">
|
||||||
|
<template #prepend>
|
||||||
|
<Icon name="material-symbols:edit-outline" class="w-4 h-4" />
|
||||||
|
</template>
|
||||||
|
Edit
|
||||||
|
</RsButton>
|
||||||
|
<RsButton @click="deleteForm(row.id)" size="sm" variant="tertiary" class="text-red-500">
|
||||||
|
<template #prepend>
|
||||||
|
<Icon name="material-symbols:delete-outline" class="w-4 h-4" />
|
||||||
|
</template>
|
||||||
|
Delete
|
||||||
|
</RsButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</RsTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useFormBuilderStore } from '~/stores/formBuilder';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
title: "Manage Forms",
|
||||||
|
description: "Manage your created forms",
|
||||||
|
layout: "empty",
|
||||||
|
middleware: ["auth"],
|
||||||
|
requiresAuth: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const formStore = useFormBuilderStore();
|
||||||
|
const toast = useToast();
|
||||||
|
const searchQuery = ref('');
|
||||||
|
|
||||||
|
// Initialize and load forms
|
||||||
|
onMounted(() => {
|
||||||
|
formStore.loadSavedForms();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format date for display
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filtered and formatted forms for table display
|
||||||
|
const filteredForms = computed(() => {
|
||||||
|
return formStore.savedForms
|
||||||
|
.filter(form => {
|
||||||
|
if (!searchQuery.value) return true;
|
||||||
|
return form.name.toLowerCase().includes(searchQuery.value.toLowerCase());
|
||||||
|
})
|
||||||
|
.map(form => ({
|
||||||
|
id: form.id,
|
||||||
|
'Form Name': form.name,
|
||||||
|
'Created': formatDate(form.createdAt)
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigation and action handlers
|
||||||
|
const navigateToBuilder = () => {
|
||||||
|
router.push('/form-builder');
|
||||||
|
};
|
||||||
|
|
||||||
|
const editForm = (formId) => {
|
||||||
|
formStore.loadForm(formId);
|
||||||
|
router.push('/form-builder');
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteForm = (formId) => {
|
||||||
|
if (confirm('Are you sure you want to delete this form?')) {
|
||||||
|
const index = formStore.savedForms.findIndex(f => f.id === formId);
|
||||||
|
if (index !== -1) {
|
||||||
|
formStore.savedForms.splice(index, 1);
|
||||||
|
localStorage.setItem('savedForms', JSON.stringify(formStore.savedForms));
|
||||||
|
toast.success('Form deleted successfully');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
138
stores/formBuilder.js
Normal file
138
stores/formBuilder.js
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
export const useFormBuilderStore = defineStore('formBuilder', {
|
||||||
|
state: () => ({
|
||||||
|
formComponents: [],
|
||||||
|
selectedComponentId: null,
|
||||||
|
formName: 'New Form',
|
||||||
|
formDescription: '',
|
||||||
|
isDraggingOver: false,
|
||||||
|
savedForms: []
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
selectedComponent: (state) => {
|
||||||
|
return state.selectedComponentId
|
||||||
|
? state.formComponents.find(c => c.id === state.selectedComponentId)
|
||||||
|
: null;
|
||||||
|
},
|
||||||
|
|
||||||
|
formConfig: (state) => {
|
||||||
|
return {
|
||||||
|
id: uuidv4(),
|
||||||
|
name: state.formName,
|
||||||
|
description: state.formDescription,
|
||||||
|
components: state.formComponents.map(c => ({
|
||||||
|
type: c.type,
|
||||||
|
props: c.props
|
||||||
|
})),
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
addComponent(component) {
|
||||||
|
const newComponent = {
|
||||||
|
...component,
|
||||||
|
id: uuidv4(),
|
||||||
|
props: {
|
||||||
|
...component.defaultProps,
|
||||||
|
name: `${component.type}_${this.formComponents.length + 1}`,
|
||||||
|
label: `${component.name} ${this.formComponents.length + 1}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.formComponents.push(newComponent);
|
||||||
|
this.selectComponent(newComponent.id);
|
||||||
|
},
|
||||||
|
|
||||||
|
selectComponent(id) {
|
||||||
|
this.selectedComponentId = id;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateComponent(updatedComponent) {
|
||||||
|
const index = this.formComponents.findIndex(c => c.id === updatedComponent.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.formComponents[index] = JSON.parse(JSON.stringify(updatedComponent));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
moveComponent({ oldIndex, newIndex }) {
|
||||||
|
if (oldIndex !== newIndex) {
|
||||||
|
const component = this.formComponents.splice(oldIndex, 1)[0];
|
||||||
|
this.formComponents.splice(newIndex, 0, component);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteComponent(id) {
|
||||||
|
const index = this.formComponents.findIndex(c => c.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.formComponents.splice(index, 1);
|
||||||
|
if (this.selectedComponentId === id) {
|
||||||
|
this.selectedComponentId = null;
|
||||||
|
|
||||||
|
// If there are other components, select the first one after deletion
|
||||||
|
if (this.formComponents.length > 0) {
|
||||||
|
// Select the component at the same index, or the last component if we deleted the last one
|
||||||
|
const newIndex = Math.min(index, this.formComponents.length - 1);
|
||||||
|
this.selectedComponentId = this.formComponents[newIndex].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setDraggingOver(isDragging) {
|
||||||
|
this.isDraggingOver = isDragging;
|
||||||
|
},
|
||||||
|
|
||||||
|
saveForm() {
|
||||||
|
const formData = this.formConfig;
|
||||||
|
|
||||||
|
// Add to saved forms array
|
||||||
|
const existingIndex = this.savedForms.findIndex(f => f.id === formData.id);
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
this.savedForms[existingIndex] = formData;
|
||||||
|
} else {
|
||||||
|
this.savedForms.push(formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to localStorage for persistence
|
||||||
|
localStorage.setItem('savedForms', JSON.stringify(this.savedForms));
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
},
|
||||||
|
|
||||||
|
loadForm(formId) {
|
||||||
|
const savedForms = JSON.parse(localStorage.getItem('savedForms') || '[]');
|
||||||
|
const form = savedForms.find(f => f.id === formId);
|
||||||
|
|
||||||
|
if (form) {
|
||||||
|
this.formName = form.name;
|
||||||
|
this.formDescription = form.description;
|
||||||
|
this.formComponents = form.components.map(c => ({
|
||||||
|
...c,
|
||||||
|
id: uuidv4()
|
||||||
|
}));
|
||||||
|
this.selectedComponentId = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearForm() {
|
||||||
|
this.formComponents = [];
|
||||||
|
this.selectedComponentId = null;
|
||||||
|
this.formName = 'New Form';
|
||||||
|
this.formDescription = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
loadSavedForms() {
|
||||||
|
const savedForms = JSON.parse(localStorage.getItem('savedForms') || '[]');
|
||||||
|
this.savedForms = savedForms;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
persist: {
|
||||||
|
paths: ['savedForms']
|
||||||
|
}
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user