Enhance Form Builder with History Management and Component Updates

- Implemented a history management system to track actions such as adding, updating, moving, and deleting components.
- Added a new `FormBuilderHistory` component to display action history with undo/redo functionality.
- Updated the `FormBuilderCanvas` and `FormBuilderComponents` to support history tracking and improved component selection.
- Enhanced the form store to manage action history, including limits on history length and state restoration.
- Improved user experience by integrating keyboard shortcuts for undo and redo actions.
- Refactored various components to ensure proper state management and history recording during component modifications.
This commit is contained in:
Md Afiq Iskandar 2025-04-15 12:11:29 +08:00
parent 103663b66b
commit 55fb5a9c54
7 changed files with 977 additions and 121 deletions

View File

@ -1,17 +1,17 @@
<template>
<div class="component-preview" :style="componentStyle">
<!-- Basic Input Types -->
<!-- Basic Input Types (including radio and checkbox) -->
<FormKit
v-if="isInputType"
:id="`preview-${component.id}`"
:type="component.props.type"
:type="component.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"
:readonly="isPreview"
:options="component.props.options || undefined"
:value="component.props.value || undefined"
:accept="component.props.accept || undefined"
@ -19,8 +19,36 @@
:preserve-events="isPreview"
@input.capture.stop="isPreview ? $event.stopPropagation() : null"
@click.capture.stop="isPreview ? $event.stopPropagation() : null"
:classes="component.type === 'checkbox' ? {
wrapper: 'mb-1',
options: 'space-y-0.5'
} : {}"
:class="{ 'canvas-component': isPreview }"
/>
<!-- Group Component -->
<div v-else-if="component.type === 'group'" class="py-2 border border-gray-200 p-3 rounded">
<div class="text-sm font-medium mb-2">{{ component.props.label || 'Group' }}</div>
<div class="text-xs text-gray-500">{{ component.props.help || 'A group can contain other form elements' }}</div>
<div class="mt-3 p-3 bg-gray-50 border border-dashed border-gray-300 rounded text-center text-xs text-gray-400">
Empty group - add components here in the final form
</div>
</div>
<!-- Repeater Component -->
<div v-else-if="component.type === 'repeater'" class="py-2 border border-gray-200 p-3 rounded">
<div class="text-sm font-medium mb-2">{{ component.props.label || 'Repeater' }}</div>
<div class="text-xs text-gray-500">{{ component.props.help || 'A repeater allows adding multiple instances of fields' }}</div>
<div class="mt-3 p-3 bg-gray-50 border border-dashed border-gray-300 rounded text-center text-xs text-gray-400">
Empty repeater - add components here in the final form
</div>
<div class="mt-2 flex justify-end">
<button class="text-xs px-2 py-1 bg-gray-100 text-gray-500 rounded disabled:opacity-50" disabled>
Add Item
</button>
</div>
</div>
<!-- Heading -->
<div v-else-if="component.type === 'heading'" class="py-2">
<component
@ -32,13 +60,13 @@
'text-lg': component.props.level === 4
}"
>
{{ component.props.value }}
{{ component.props.value || 'Heading Text' }}
</component>
</div>
<!-- Paragraph -->
<div v-else-if="component.type === 'paragraph'" class="py-2">
<p class="text-gray-600">{{ component.props.value }}</p>
<p class="text-gray-600">{{ component.props.value || 'Paragraph text goes here' }}</p>
</div>
<!-- Divider -->
@ -65,13 +93,24 @@ const props = defineProps({
}
});
// Check if the component is a standard FormKit input type
// Check if the component is a standard FormKit input type (excluding specialized components)
const isStandardInputType = computed(() => {
const standardInputTypes = [
'text', 'textarea', 'number', 'email', 'password',
'date', 'time', 'datetime-local', 'url', 'tel',
'select', 'radio', 'file', 'range', 'color', 'hidden'
];
return standardInputTypes.includes(props.component.type);
});
// Check if the component is any FormKit input type (including checkbox)
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'
'color', 'hidden'
];
return inputTypes.includes(props.component.type);
@ -99,4 +138,56 @@ const componentStyle = computed(() => {
.component-preview {
width: 100%;
}
:deep(.formkit-wrapper) {
margin-bottom: 0.5rem !important;
}
:deep(.formkit-options) {
gap: 0.25rem !important;
}
/* Improve visibility of disabled inputs in the canvas view */
:deep(.canvas-component.formkit-disabled),
:deep(.canvas-component .formkit-disabled) {
opacity: 1 !important;
}
:deep(.canvas-component .formkit-input) {
opacity: 1 !important;
background-color: white !important;
border-color: #d1d5db !important;
color: #111827 !important;
cursor: default !important;
}
:deep(.canvas-component .formkit-inner) {
background-color: #ffffff !important;
border-color: #d1d5db !important;
}
:deep(.canvas-component .formkit-label) {
opacity: 1 !important;
color: #374151 !important;
font-weight: 500 !important;
}
:deep(.canvas-component .formkit-help) {
opacity: 1 !important;
color: #6b7280 !important;
}
:deep(.canvas-component[data-type="checkbox"] .formkit-input),
:deep(.canvas-component[data-type="radio"] .formkit-input) {
opacity: 1 !important;
border-color: #9ca3af !important;
}
:deep(.canvas-component[data-type="select"] .formkit-input) {
appearance: none !important;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e") !important;
background-position: right 0.5rem center !important;
background-repeat: no-repeat !important;
background-size: 1.5em 1.5em !important;
}
</style>

View File

@ -117,10 +117,15 @@ const componentList = computed({
// Handle component selection
const selectComponent = (component) => {
if (!component || !component.id) return;
selectedComponentId.value = component.id;
// Use a copy of the component to prevent reactivity issues
// But make sure to keep the original ID
const componentCopy = JSON.parse(JSON.stringify(component));
componentCopy.id = component.id; // Ensure ID preservation
emit('select-component', componentCopy);
};

View File

@ -207,13 +207,20 @@ const availableComponents = [
},
{
type: 'checkbox',
name: 'Checkbox',
name: 'Checkbox Group',
category: 'Selection Inputs',
icon: 'material-symbols:check-box-outline',
description: 'Single checkbox input',
description: 'Multiple checkbox options',
defaultProps: {
type: 'checkbox',
help: '',
label: 'Checkbox Group',
name: 'checkbox',
help: 'Select all that apply',
options: [
{ label: 'Option 1', value: 'option_1' },
{ label: 'Option 2', value: 'option_2' },
{ label: 'Option 3', value: 'option_3' }
],
validation: ''
}
},
@ -295,7 +302,9 @@ const availableComponents = [
description: 'Repeatable group of fields',
defaultProps: {
type: 'repeater',
help: '',
label: 'Repeater Section',
name: 'repeater',
help: 'Add multiple items',
max: 5
}
},
@ -307,7 +316,9 @@ const availableComponents = [
description: 'Group of related fields',
defaultProps: {
type: 'group',
help: ''
label: 'Field Group',
name: 'group',
help: 'Group of related fields'
}
},

View File

@ -10,7 +10,7 @@
<!-- Tab Navigation -->
<div class="custom-tab-nav">
<button
v-for="tab in tabs"
v-for="tab in availableTabs"
:key="tab.id"
@click="activeTab = tab.id"
class="custom-tab-button"
@ -187,52 +187,101 @@
</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 v-if="activeTab === 'validation'" class="space-y-4">
<!-- Validation tab -->
<FormKit v-if="showValidationTab" type="group" id="validation-section">
<div class="flex flex-col mt-4 border p-4 rounded-md">
<h3 class="font-medium text-gray-700 mb-4">Common Validations</h3>
<!-- Required field -->
<div class="mb-3 flex items-center">
<input
type="checkbox"
id="validation-required"
v-model="isRequired"
class="mr-2 h-4 w-4 text-blue-600 focus:ring-blue-500"
>
<label for="validation-required" class="text-sm text-gray-700">Required Field</label>
</div>
<!-- Email format validation -->
<div class="mb-3 flex items-center">
<input
type="checkbox"
id="validation-email"
v-model="isEmail"
class="mr-2 h-4 w-4 text-blue-600 focus:ring-blue-500"
>
<label for="validation-email" class="text-sm text-gray-700">Email Format</label>
</div>
<!-- URL format validation -->
<div class="mb-3 flex items-center">
<input
type="checkbox"
id="validation-url"
v-model="isUrl"
class="mr-2 h-4 w-4 text-blue-600 focus:ring-blue-500"
>
<label for="validation-url" class="text-sm text-gray-700">URL Format</label>
</div>
<!-- Numbers only validation -->
<div class="mb-3 flex items-center">
<input
type="checkbox"
id="validation-number"
v-model="isNumber"
class="mr-2 h-4 w-4 text-blue-600 focus:ring-blue-500"
>
<label for="validation-number" class="text-sm text-gray-700">Numbers Only</label>
</div>
<!-- Min/Max validations -->
<div class="grid grid-cols-2 gap-4 mt-2">
<!-- Min value/length -->
<div class="mb-3">
<label for="validation-min" class="block text-sm text-gray-700 mb-1">Minimum Value/Length</label>
<input
type="number"
id="validation-min"
v-model="minValue"
class="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="Enter minimum"
>
</div>
<!-- Max value/length -->
<div class="mb-3">
<label for="validation-max" class="block text-sm text-gray-700 mb-1">Maximum Value/Length</label>
<input
type="number"
id="validation-max"
v-model="maxValue"
class="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="Enter maximum"
>
</div>
</div>
</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>
<!-- Current validation rules (read-only) -->
<div class="mt-4">
<label for="current-validation-rules" class="block text-sm font-medium text-gray-700 mb-1">
Current Validation Rules
</label>
<textarea
id="current-validation-rules"
v-model="configModel.validation"
readonly
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm bg-gray-50"
rows="3"
></textarea>
<p class="text-xs text-gray-500 mt-1">
These are the validation rules that will be applied to this field.
</p>
</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"
/>
</FormKit>
</div>
<!-- Advanced Tab -->
@ -278,30 +327,38 @@ const tabs = [
{ id: 'advanced', label: 'Advanced' }
];
// Set active tab
// State variables
const activeTab = ref('basic');
// Create a deep copy of props to prevent direct mutation
const configModel = ref(JSON.parse(JSON.stringify(props.component.props || {})));
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 for component changes from parent, with a better approach to handle updates
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 });
if (!newComponent) return;
// Make a fresh copy to ensure reactivity
configModel.value = JSON.parse(JSON.stringify(newComponent.props || {}));
}, { immediate: true, deep: true });
// Watch for changes in the configuration and emit updates using a debounce
const debouncedEmit = useDebounceFn(() => {
if (!props.component) return;
// Create a new component object with updated props
const updatedComponent = {
...props.component,
id: props.component.id, // Ensure ID is preserved
props: JSON.parse(JSON.stringify(configModel.value))
};
emit('update-component', updatedComponent);
// Only emit if there are actual changes
if (JSON.stringify(updatedComponent.props) !== JSON.stringify(props.component.props)) {
emit('update-component', updatedComponent);
}
}, 100);
// Use debounced emit to avoid feedback loops
// Watch for changes to configModel and emit updates
watch(configModel, () => {
debouncedEmit();
}, { deep: true });
@ -313,22 +370,105 @@ const isRequired = computed({
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;
updateValidation('required', value);
}
});
// Email validation
const isEmail = computed({
get: () => {
const validation = configModel.value.validation || '';
return validation.includes('email');
},
set: (value) => {
updateValidation('email', value);
}
});
// URL validation
const isUrl = computed({
get: () => {
const validation = configModel.value.validation || '';
return validation.includes('url');
},
set: (value) => {
updateValidation('url', value);
}
});
// Number validation
const isNumber = computed({
get: () => {
const validation = configModel.value.validation || '';
return validation.includes('number');
},
set: (value) => {
updateValidation('number', value);
}
});
// Min value/length
const minValue = computed({
get: () => {
const validation = configModel.value.validation || '';
const minMatch = validation.match(/min:(\d+)/);
return minMatch ? parseInt(minMatch[1]) : null;
},
set: (value) => {
if (value === null || value === '') {
// Remove min validation if empty
updateValidation('min', false);
} else {
// Update with new value
updateValidation('min', true, value);
}
}
});
// Max value/length
const maxValue = computed({
get: () => {
const validation = configModel.value.validation || '';
const maxMatch = validation.match(/max:(\d+)/);
return maxMatch ? parseInt(maxMatch[1]) : null;
},
set: (value) => {
if (value === null || value === '') {
// Remove max validation if empty
updateValidation('max', false);
} else {
// Update with new value
updateValidation('max', true, value);
}
}
});
// Helper function to update validation string
const updateValidation = (rule, isActive, value = null) => {
// Parse current validation into array
let validationRules = (configModel.value.validation || '')
.split(',')
.map(r => r.trim())
.filter(r => r !== '');
// Remove existing instance of the rule (including any with parameters)
validationRules = validationRules.filter(r => !r.startsWith(`${rule}:`) && r !== rule);
// Add rule if it should be active
if (isActive) {
if (value !== null) {
// For rules with parameters like min:5
validationRules.push(`${rule}:${value}`);
} else {
// For simple rules like required
validationRules.push(rule);
}
}
// Update the validation string
configModel.value.validation = validationRules.join(',');
};
// Define available validators that can be added
const availableValidators = [
{ name: 'required', label: 'Required' },
@ -443,6 +583,28 @@ const getComponentWidthPercent = () => {
return match ? parseInt(match[1]) : 100;
};
// Computed property to determine if validation tab should be shown
const showValidationTab = computed(() => {
const nonValidationComponents = ['section', 'page', 'wizard', 'checkbox', 'repeater', 'group'];
return !nonValidationComponents.includes(props.component.type);
});
const availableTabs = computed(() => {
return [
{ id: 'basic', label: 'Basic' },
...(showValidationTab.value ? [{ id: 'validation', label: 'Validation' }] : []),
{ id: 'advanced', label: 'Advanced' }
];
});
// If activeTab is set to 'validation' but component type doesn't support validation,
// automatically switch to 'basic' tab
watch(showValidationTab, (showValidation) => {
if (!showValidation && activeTab.value === 'validation') {
activeTab.value = 'basic';
}
}, { immediate: true });
const setComponentWidth = (widthPercent) => {
// Convert precise percentages to exact grid column spans
// This ensures the visual appearance matches the percentage

View File

@ -0,0 +1,250 @@
<template>
<div class="history-panel">
<div v-if="formStore.actionHistory.length === 0" class="p-4 text-center text-gray-500">
<p class="text-sm">No history yet</p>
<p class="text-xs mt-1">Actions will appear here</p>
</div>
<div v-else class="history-entries">
<div class="history-controls px-3 py-2 border-b border-gray-200 flex items-center justify-between">
<div class="flex items-center">
<button
@click="handleUndo"
class="p-1.5 text-gray-500 hover:text-gray-700 disabled:opacity-50 disabled:cursor-not-allowed rounded"
:disabled="!canUndo"
title="Undo (Ctrl+Z)"
>
<div class="flex items-center">
<Icon name="material-symbols:undo" class="w-4 h-4" />
<span class="ml-1 text-xs hidden sm:inline">Undo</span>
<span class="ml-1 text-xs text-gray-400 hidden sm:inline">(Ctrl+Z)</span>
</div>
</button>
<button
@click="handleRedo"
class="p-1.5 text-gray-500 hover:text-gray-700 disabled:opacity-50 disabled:cursor-not-allowed rounded ml-1"
:disabled="!canRedo"
title="Redo (Ctrl+Y)"
>
<div class="flex items-center">
<Icon name="material-symbols:redo" class="w-4 h-4" />
<span class="ml-1 text-xs hidden sm:inline">Redo</span>
<span class="ml-1 text-xs text-gray-400 hidden sm:inline">(Ctrl+Y)</span>
</div>
</button>
</div>
<div class="text-xs text-gray-500 flex items-center">
<span class="mr-1">{{ formStore.actionHistory.length > 0 ? formStore.currentHistoryIndex + 1 : 0 }} / {{ formStore.actionHistory.length }}</span>
<span
v-if="formStore.actionHistory.length > 0"
class="hint-text text-blue-400 cursor-help ml-1"
title="Ctrl+Z: Undo | Ctrl+Y: Redo | Click on any history entry to jump to that state"
>
<Icon name="material-symbols:info-outline" class="w-3.5 h-3.5" />
</span>
</div>
</div>
<div class="history-list overflow-y-auto flex-1">
<div
v-for="(entry, index) in formStore.historyEntries"
:key="entry.id"
@click="jumpToHistoryState(index)"
class="history-entry px-3 py-1.5 border-b border-gray-100 flex items-center cursor-pointer"
:class="{
'bg-blue-50': entry.isCurrent,
'hover:bg-gray-50': !entry.isCurrent
}"
>
<div class="flex-1">
<div class="flex items-center">
<div class="history-action-icon mr-2">
<Icon
:name="getActionIcon(entry.action)"
class="w-4 h-4"
:class="getActionIconColor(entry.action)"
/>
</div>
<div class="text-sm font-medium">{{ getActionName(entry.action) }}</div>
</div>
<div class="text-xs text-gray-500 mt-0.5">{{ getActionDescription(entry) }}</div>
</div>
<div class="ml-2 text-xs text-gray-400">
{{ formatTimestamp(entry.timestamp) }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useFormBuilderStore } from "~/stores/formBuilder";
import { computed } from 'vue';
const formStore = useFormBuilderStore();
// Computed properties for undo/redo capabilities
const canUndo = computed(() => formStore.currentHistoryIndex > 0);
const canRedo = computed(() =>
formStore.actionHistory.length > 0 &&
formStore.currentHistoryIndex < formStore.actionHistory.length - 1
);
// Handle undo and redo actions
const handleUndo = () => {
formStore.undo();
};
const handleRedo = () => {
formStore.redo();
};
// Jump to a specific history state
const jumpToHistoryState = (index) => {
if (index === formStore.currentHistoryIndex) return;
// Get the target history entry
const targetEntry = formStore.actionHistory[index];
if (!targetEntry) return;
// Direct restore to target state
formStore.restoreStateFromHistory(targetEntry.formState);
// Update the current history index to match
formStore.currentHistoryIndex = index;
// Mark as having unsaved changes
formStore.hasUnsavedChanges = true;
};
// Format timestamp (e.g., "5m ago")
const formatTimestamp = (timestamp) => {
if (!timestamp) return '';
const date = new Date(timestamp);
const now = new Date();
const diffMs = now - date;
const diffSec = Math.round(diffMs / 1000);
const diffMin = Math.round(diffSec / 60);
if (diffMin < 1) return 'just now';
if (diffMin === 1) return '1m ago';
if (diffMin < 60) return `${diffMin}m ago`;
const diffHour = Math.round(diffMin / 60);
if (diffHour === 1) return '1h ago';
if (diffHour < 24) return `${diffHour}h ago`;
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
// Get the icon for a specific action
const getActionIcon = (action) => {
switch (action) {
case 'new_form': return 'material-symbols:note-add';
case 'add_component': return 'material-symbols:add-circle-outline';
case 'update_component': return 'material-symbols:edit-outline';
case 'delete_component': return 'material-symbols:delete-outline';
case 'move_component': return 'material-symbols:swap-vert';
case 'change_form_name': return 'material-symbols:edit-document';
case 'change_form_description': return 'material-symbols:description-outline';
case 'save_form': return 'material-symbols:save';
case 'load_form': return 'material-symbols:folder-open';
default: return 'material-symbols:history';
}
};
// Get the color class for action icons
const getActionIconColor = (action) => {
switch (action) {
case 'new_form': return 'text-blue-600';
case 'add_component': return 'text-green-600';
case 'update_component': return 'text-blue-600';
case 'delete_component': return 'text-red-600';
case 'move_component': return 'text-purple-600';
case 'save_form': return 'text-green-600';
case 'load_form': return 'text-blue-600';
default: return 'text-gray-600';
}
};
// Get a human-readable name for the action
const getActionName = (action) => {
switch (action) {
case 'new_form': return 'New Form';
case 'add_component': return 'Add Component';
case 'update_component': return 'Update Component';
case 'delete_component': return 'Delete Component';
case 'move_component': return 'Move Component';
case 'change_form_name': return 'Rename Form';
case 'change_form_description': return 'Edit Description';
case 'save_form': return 'Save Form';
case 'load_form': return 'Load Form';
default: return 'Action';
}
};
// Get a description for the specific action
const getActionDescription = (entry) => {
const { action, details } = entry;
switch (action) {
case 'new_form':
return 'Created a new empty form';
case 'add_component':
return `Added ${details.componentName || details.componentType}`;
case 'update_component':
return `Updated ${details.componentName || details.componentType}`;
case 'delete_component':
return `Deleted ${details.componentName || details.componentType}`;
case 'move_component':
return `Moved ${details.componentName} from position ${details.oldIndex + 1} to ${details.newIndex + 1}`;
case 'change_form_name':
return `Changed form name to "${details.newName}"`;
case 'change_form_description':
return `Updated form description`;
case 'save_form':
return `Saved form "${details.formName}" with ${details.componentCount} components`;
case 'load_form':
return `Loaded form "${details.formName}"`;
default:
return 'Unknown action';
}
};
</script>
<style scoped>
.history-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.history-entries {
display: flex;
flex-direction: column;
height: 100%;
}
.history-list {
height: calc(100% - 40px);
}
.history-entry {
transition: background-color 0.1s ease;
}
.history-entry.bg-blue-50 {
border-left: 3px solid #3b82f6;
}
</style>

View File

@ -55,19 +55,79 @@
<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">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" />
<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>
<div class="overflow-y-auto flex-1">
<!-- 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 -->
@ -179,6 +239,7 @@
<script setup>
import { useFormBuilderStore } from "~/stores/formBuilder";
import FormBuilderHistory from "~/components/FormBuilderHistory.vue";
definePageMeta({
title: "Form Builder",
@ -197,6 +258,7 @@ const showUnsavedChangesModal = ref(false);
const pendingNavigation = ref(null);
const navigationTarget = ref(null);
const navigationConfirmed = ref(false);
const leftSidebarTab = ref('components');
// Computed property for form name with getter and setter
const formName = computed({
@ -214,11 +276,15 @@ onMounted(() => {
// Add the beforeunload event listener
window.addEventListener('beforeunload', handleBeforeUnload);
// Setup keyboard shortcuts
window.addEventListener('keydown', handleKeyboardShortcuts);
});
onUnmounted(() => {
// Remove the beforeunload event listener
// Remove event listeners
window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('keydown', handleKeyboardShortcuts);
});
// Show warning if there are unsaved changes
@ -230,6 +296,26 @@ const handleBeforeUnload = (event) => {
}
};
// 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) => {
@ -273,10 +359,12 @@ const handleAddComponent = (component) => {
};
const handleSelectComponent = (component) => {
if (!component || !component.id) return;
formStore.selectComponent(component.id);
};
const handleUpdateComponent = (updatedComponent) => {
if (!updatedComponent || !updatedComponent.id) return;
formStore.updateComponent(updatedComponent);
};
@ -285,6 +373,7 @@ const handleMoveComponent = ({ oldIndex, newIndex }) => {
};
const handleDeleteComponent = (id) => {
if (!id) return;
formStore.deleteComponent(id);
};

View File

@ -9,7 +9,10 @@ export const useFormBuilderStore = defineStore('formBuilder', {
formDescription: '',
isDraggingOver: false,
savedForms: [],
hasUnsavedChanges: false
hasUnsavedChanges: false,
actionHistory: [],
currentHistoryIndex: -1,
maxHistoryLength: 30 // Maximum number of history entries to keep
}),
getters: {
@ -30,17 +33,134 @@ export const useFormBuilderStore = defineStore('formBuilder', {
})),
createdAt: new Date().toISOString()
};
},
canUndo: (state) => {
return state.currentHistoryIndex > 0;
},
historyEntries: (state) => {
return state.actionHistory.map((entry, index) => ({
...entry,
isCurrent: index === state.currentHistoryIndex
}));
}
},
actions: {
// History Management
recordHistory(action, details = {}) {
// Remove any future history entries if we're not at the end
if (this.currentHistoryIndex < this.actionHistory.length - 1) {
this.actionHistory = this.actionHistory.slice(0, this.currentHistoryIndex + 1);
}
// Ensure we have a deep clone of the current state
// Make sure to preserve the exact component IDs
const currentComponents = this.formComponents.map(component => {
const copy = JSON.parse(JSON.stringify(component));
// Ensure the ID is preserved exactly
if (component.id) {
copy.id = component.id;
}
return copy;
});
// Create a new history entry
const historyEntry = {
id: uuidv4(),
action,
details,
formState: {
components: currentComponents,
name: this.formName,
description: this.formDescription,
selectedComponentId: this.selectedComponentId
},
timestamp: new Date()
};
// Add to history and update index
this.actionHistory.push(historyEntry);
this.currentHistoryIndex = this.actionHistory.length - 1;
// Limit history length
if (this.actionHistory.length > this.maxHistoryLength) {
this.actionHistory = this.actionHistory.slice(this.actionHistory.length - this.maxHistoryLength);
this.currentHistoryIndex = this.actionHistory.length - 1;
}
},
// Helper method to restore state from a history entry
restoreStateFromHistory(historyState) {
// Completely replace components with deep clone
if (Array.isArray(historyState.components)) {
// Make a deep clone to ensure we break all references
this.formComponents = historyState.components.map(component => ({
...JSON.parse(JSON.stringify(component)),
id: component.id // Preserve the exact ID
}));
} else {
this.formComponents = [];
}
// Update other state properties
this.formName = historyState.name || 'New Form';
this.formDescription = historyState.description || '';
// Make sure the selectedComponentId references a valid component
this.selectedComponentId = historyState.selectedComponentId || null;
if (this.selectedComponentId) {
// Verify the selected component exists in the restored state
const selectedExists = this.formComponents.some(c => c.id === this.selectedComponentId);
if (!selectedExists) {
this.selectedComponentId = this.formComponents.length > 0 ? this.formComponents[0].id : null;
}
}
},
undo() {
if (!this.canUndo) return;
// Get current and previous entries
const currentEntry = this.actionHistory[this.currentHistoryIndex];
this.currentHistoryIndex--;
const previousEntry = this.actionHistory[this.currentHistoryIndex];
// Restore the state from previous entry
this.restoreStateFromHistory(previousEntry.formState);
// Mark as having unsaved changes
this.hasUnsavedChanges = true;
},
redo() {
if (this.currentHistoryIndex >= this.actionHistory.length - 1) return;
// Move forward one step in history
this.currentHistoryIndex++;
const nextEntry = this.actionHistory[this.currentHistoryIndex];
// Restore the state from next entry
this.restoreStateFromHistory(nextEntry.formState);
// Mark as having unsaved changes
this.hasUnsavedChanges = true;
},
addComponent(component) {
// Store the state before the change for history
const beforeComponents = [...this.formComponents];
// Find optimal grid placement for the new component
const { gridColumn, rowIndex, width } = this.findOptimalGridPlacement();
const newComponentId = uuidv4();
const newComponent = {
...component,
id: uuidv4(),
id: newComponentId,
props: {
...component.defaultProps,
name: `${component.type}_${this.formComponents.length + 1}`,
@ -51,8 +171,21 @@ export const useFormBuilderStore = defineStore('formBuilder', {
};
this.formComponents.push(newComponent);
this.selectComponent(newComponent.id);
// Explicitly select the new component
this.selectedComponentId = newComponentId;
this.hasUnsavedChanges = true;
// Record the action in history
this.recordHistory('add_component', {
componentType: component.type,
componentId: newComponentId,
componentName: newComponent.props.label,
beforeState: {
components: beforeComponents,
selectedComponentId: null // Was null before adding
},
newComponent: newComponent
});
},
// Find optimal placement for a new component in the grid
@ -137,32 +270,82 @@ export const useFormBuilderStore = defineStore('formBuilder', {
},
selectComponent(id) {
// Don't record history for selection changes
this.selectedComponentId = id;
},
updateComponent(updatedComponent) {
const index = this.formComponents.findIndex(c => c.id === updatedComponent.id);
if (index !== -1) {
// Store old component for history
const oldComponent = { ...this.formComponents[index] };
const beforeComponents = [...this.formComponents];
// Update the component
this.formComponents[index] = JSON.parse(JSON.stringify(updatedComponent));
this.hasUnsavedChanges = true;
// Record in history
this.recordHistory('update_component', {
componentId: updatedComponent.id,
componentType: updatedComponent.type,
componentName: updatedComponent.props.label,
oldComponent: oldComponent,
newComponent: this.formComponents[index],
beforeState: {
components: beforeComponents,
selectedComponentId: this.selectedComponentId
}
});
}
},
moveComponent({ oldIndex, newIndex }) {
if (oldIndex !== newIndex) {
const component = this.formComponents.splice(oldIndex, 1)[0];
this.formComponents.splice(newIndex, 0, component);
// Record before state
const beforeComponents = [...this.formComponents];
const componentToMove = { ...this.formComponents[oldIndex] };
const beforeOrder = this.formComponents.map(c => c.id);
// Perform the move
this.formComponents.splice(oldIndex, 1);
this.formComponents.splice(newIndex, 0, componentToMove);
// Optimize layout after reordering
this.optimizeGridLayout();
this.hasUnsavedChanges = true;
// Record in history
this.recordHistory('move_component', {
componentId: componentToMove.id,
componentName: componentToMove.props.label,
oldIndex,
newIndex,
beforeOrder,
afterOrder: this.formComponents.map(c => c.id),
beforeState: {
components: beforeComponents,
selectedComponentId: this.selectedComponentId
}
});
}
},
deleteComponent(id) {
const index = this.formComponents.findIndex(c => c.id === id);
if (index !== -1) {
// Store the component for history
const deletedComponent = { ...this.formComponents[index] };
// Store the current state before deletion for history
const beforeComponents = [...this.formComponents];
// Remove the component
this.formComponents.splice(index, 1);
// Update selection if the deleted component was selected
if (this.selectedComponentId === id) {
this.selectedComponentId = null;
@ -177,6 +360,19 @@ export const useFormBuilderStore = defineStore('formBuilder', {
// Optimize layout after deletion
this.optimizeGridLayout();
this.hasUnsavedChanges = true;
// Record in history
this.recordHistory('delete_component', {
componentId: id,
componentType: deletedComponent.type,
componentName: deletedComponent.props.label,
componentIndex: index,
deletedComponent: deletedComponent,
beforeState: {
components: beforeComponents,
selectedComponentId: this.selectedComponentId
}
});
}
},
@ -184,6 +380,40 @@ export const useFormBuilderStore = defineStore('formBuilder', {
this.isDraggingOver = isDragging;
},
setFormName(name) {
const oldName = this.formName;
if (this.formName !== name) {
this.formName = name;
this.hasUnsavedChanges = true;
// Record in history
this.recordHistory('change_form_name', {
oldName,
newName: name
});
}
},
setFormDescription(description) {
const oldDescription = this.formDescription;
if (this.formDescription !== description) {
this.formDescription = description;
this.hasUnsavedChanges = true;
// Record in history
this.recordHistory('change_form_description', {
oldDescription,
newDescription: description
});
}
},
resetUnsavedChanges() {
this.hasUnsavedChanges = false;
},
saveForm() {
const formData = this.formConfig;
@ -200,50 +430,68 @@ export const useFormBuilderStore = defineStore('formBuilder', {
this.hasUnsavedChanges = false;
// Record in history
this.recordHistory('save_form', {
formName: this.formName,
formDescription: this.formDescription,
componentCount: this.formComponents.length
});
return formData;
},
clearForm() {
// Capture the current state before clearing
const oldComponents = [...this.formComponents];
const oldName = this.formName;
const oldDescription = this.formDescription;
// Clear form data
this.formComponents = [];
this.selectedComponentId = null;
this.formName = 'New Form';
this.formDescription = '';
this.hasUnsavedChanges = false;
// Clear history when starting a new form and add initial state
this.actionHistory = [];
this.currentHistoryIndex = -1;
// Record the initial empty state
this.recordHistory('new_form', {
message: 'Created a new empty form'
});
},
loadForm(formId) {
const savedForms = JSON.parse(localStorage.getItem('savedForms') || '[]');
const form = savedForms.find(f => f.id === formId);
if (form) {
// Clear existing data
this.formComponents = [];
this.selectedComponentId = null;
// Set form data
this.formName = form.name;
this.formDescription = form.description;
this.formComponents = form.components.map(c => ({
...c,
id: uuidv4()
}));
this.selectedComponentId = null;
// Clear and initialize history when loading a form
this.actionHistory = [];
this.currentHistoryIndex = -1;
// Record initial state in history
this.recordHistory('load_form', {
formName: form.name,
formId: formId
});
}
},
setFormName(name) {
if (this.formName !== name) {
this.formName = name;
this.hasUnsavedChanges = true;
}
},
setFormDescription(description) {
if (this.formDescription !== description) {
this.formDescription = description;
this.hasUnsavedChanges = true;
}
},
resetUnsavedChanges() {
this.hasUnsavedChanges = false;
},
clearForm() {
this.formComponents = [];
this.selectedComponentId = null;
this.formName = 'New Form';
this.formDescription = '';
this.hasUnsavedChanges = false;
},
loadSavedForms() {
const savedForms = JSON.parse(localStorage.getItem('savedForms') || '[]');
this.savedForms = savedForms;