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:
parent
103663b66b
commit
55fb5a9c54
@ -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>
|
@ -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);
|
||||
};
|
||||
|
||||
|
@ -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'
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -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
|
||||
|
250
components/FormBuilderHistory.vue
Normal file
250
components/FormBuilderHistory.vue
Normal 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>
|
@ -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);
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user