Add Form and Process History Modals with Versioning Support

- Introduced FormHistoryModal and ProcessHistoryModal components for viewing and restoring previous versions of forms and processes.
- Implemented version tracking in the backend with new API endpoints for fetching and restoring historical data.
- Added database migrations for form and process history tables to support versioning functionality.
- Enhanced form and process update logic to save previous versions before modifications.
- Updated documentation to include details on the new history system and its benefits for data management.
- Improved user experience with intuitive modals for accessing historical data and restoring previous versions.
This commit is contained in:
Afiq 2025-05-30 19:10:43 +08:00
parent 8805484de2
commit 33dc901107
34 changed files with 2434 additions and 209 deletions

View File

@ -0,0 +1,377 @@
<template>
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div class="bg-white rounded-lg shadow-xl max-w-6xl w-full mx-4 max-h-[90vh] overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between p-6 border-b border-gray-200">
<div>
<h2 class="text-xl font-semibold text-gray-800">Form History</h2>
<p class="text-sm text-gray-600 mt-1">{{ formInfo.formName }}</p>
</div>
<button @click="$emit('close')" class="text-gray-400 hover:text-gray-600">
<Icon name="heroicons:x-mark" class="h-6 w-6" />
</button>
</div>
<!-- Content -->
<div class="flex h-[calc(90vh-120px)]">
<!-- Version List (Left Side) -->
<div class="w-1/3 border-r border-gray-200 bg-gray-50">
<div class="p-4 border-b border-gray-200">
<h3 class="font-medium text-gray-900">Versions</h3>
<p class="text-sm text-gray-600">{{ totalVersions }} total versions</p>
</div>
<div class="overflow-y-auto h-full pb-20">
<!-- Current Version -->
<div class="p-4 bg-blue-50 border-b border-gray-200">
<div class="flex items-center justify-between">
<div>
<div class="flex items-center space-x-2">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Current
</span>
<span class="text-sm font-medium text-gray-900">Latest Version</span>
</div>
<p class="text-xs text-gray-600 mt-1">
{{ formatDate(currentVersion?.formModifiedDate || currentVersion?.formCreatedDate) }}
</p>
<p class="text-xs text-gray-500">
by {{ currentVersion?.creator?.userFullName || 'Unknown' }}
</p>
</div>
<button
@click="previewCurrent"
class="text-blue-600 hover:text-blue-800 text-sm"
:class="{ 'font-semibold': selectedVersion === 'current' }"
>
Preview
</button>
</div>
</div>
<!-- Historical Versions -->
<div
v-for="version in history"
:key="version.historyID"
class="p-4 border-b border-gray-200 hover:bg-gray-100 cursor-pointer"
:class="{ 'bg-blue-50': selectedVersion?.historyID === version.historyID }"
@click="selectVersion(version)"
>
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="flex items-center space-x-2">
<span class="text-sm font-medium text-gray-900">
Version {{ version.versionNumber }}
</span>
<span v-if="version.changeDescription" class="text-xs text-gray-500">
- {{ version.changeDescription }}
</span>
</div>
<p class="text-xs text-gray-600 mt-1">
{{ formatDate(version.savedDate) }}
</p>
<p class="text-xs text-gray-500">
by {{ version.savedByUser?.userFullName || 'Unknown' }}
</p>
</div>
<div class="flex items-center space-x-2">
<button
@click.stop="previewVersion(version)"
class="text-blue-600 hover:text-blue-800 text-sm"
>
Preview
</button>
<button
@click.stop="restoreVersion(version)"
class="text-green-600 hover:text-green-800 text-sm"
:disabled="isRestoring"
>
Restore
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Preview Area (Right Side) -->
<div class="flex-1 bg-white">
<div class="p-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<h3 class="font-medium text-gray-900">
{{ previewData ? 'Preview' : 'Select a version to preview' }}
</h3>
<div v-if="previewData && selectedVersion !== 'current'" class="flex items-center space-x-2">
<button
@click="restoreVersion(selectedVersion)"
:disabled="isRestoring"
class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700 disabled:opacity-50"
>
{{ isRestoring ? 'Restoring...' : 'Restore This Version' }}
</button>
</div>
</div>
</div>
<div class="overflow-y-auto h-full pb-20">
<!-- Preview Content -->
<div v-if="previewData" class="p-6">
<!-- Form Info -->
<div class="mb-6 p-4 bg-gray-50 rounded-lg">
<h4 class="font-medium text-gray-900 mb-2">Form Information</h4>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-600">Name:</span>
<span class="ml-2 font-medium">{{ previewData.formName }}</span>
</div>
<div>
<span class="text-gray-600">Components:</span>
<span class="ml-2 font-medium">{{ previewData.formComponents?.length || 0 }}</span>
</div>
<div class="col-span-2" v-if="previewData.formDescription">
<span class="text-gray-600">Description:</span>
<span class="ml-2">{{ previewData.formDescription }}</span>
</div>
<div v-if="previewData.versionInfo" class="col-span-2">
<span class="text-gray-600">Version:</span>
<span class="ml-2 font-medium">{{ previewData.versionInfo.versionNumber }}</span>
<span class="ml-2 text-gray-500">
({{ formatDate(previewData.versionInfo.savedDate) }})
</span>
</div>
</div>
</div>
<!-- Form Components Preview -->
<div class="space-y-4">
<h4 class="font-medium text-gray-900">Form Components</h4>
<div class="border rounded-lg p-4 bg-white">
<div v-if="previewData.formComponents?.length" class="space-y-3">
<div
v-for="(component, index) in previewData.formComponents"
:key="index"
class="flex items-center justify-between p-3 border rounded-lg bg-gray-50"
>
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<Icon :name="getComponentIcon(component.type)" class="h-4 w-4 text-blue-600" />
</div>
<div>
<div class="font-medium text-gray-900">
{{ component.props?.label || component.type }}
</div>
<div class="text-sm text-gray-600">
{{ component.props?.name || `${component.type}_${index + 1}` }}
</div>
</div>
</div>
<div class="text-sm text-gray-500">
{{ getComponentTypeName(component.type) }}
</div>
</div>
</div>
<div v-else class="text-center py-8 text-gray-500">
No components in this version
</div>
</div>
</div>
<!-- Custom Scripts Preview (if any) -->
<div v-if="previewData.customScript" class="mt-6">
<h4 class="font-medium text-gray-900 mb-2">Custom Scripts</h4>
<div class="bg-gray-900 text-gray-100 p-4 rounded-lg text-sm font-mono">
<pre class="whitespace-pre-wrap">{{ previewData.customScript }}</pre>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="flex items-center justify-center h-full text-gray-500">
<div class="text-center">
<Icon name="heroicons:document-text" class="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>Select a version to preview its contents</p>
</div>
</div>
</div>
</div>
</div>
<!-- Footer with Close Button -->
<div class="flex justify-end p-4 border-t border-gray-200 bg-gray-50">
<button
@click="$emit('close')"
class="px-4 py-2 text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Close
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useFormBuilderStore } from '~/stores/formBuilder'
const props = defineProps({
isOpen: {
type: Boolean,
default: false
},
formId: {
type: String,
required: true
}
})
const emit = defineEmits(['close', 'restored'])
const formStore = useFormBuilderStore()
// Try to use toast composable if available, with fallback
let toast
try {
toast = useToast()
} catch (error) {
toast = {
success: (msg) => console.log('Success:', msg),
error: (msg) => console.error('Error:', msg),
info: (msg) => console.info('Info:', msg),
warning: (msg) => console.warn('Warning:', msg)
}
}
// Reactive data
const history = ref([])
const currentVersion = ref(null)
const formInfo = ref({})
const totalVersions = ref(0)
const selectedVersion = ref(null)
const previewData = ref(null)
const isRestoring = ref(false)
// Computed
const isLoading = computed(() => history.value.length === 0 && props.isOpen)
// Methods
const loadHistory = async () => {
try {
const result = await formStore.getFormHistory(props.formId)
history.value = result.history
currentVersion.value = result.currentVersion
formInfo.value = result.form
totalVersions.value = result.totalVersions
} catch (error) {
console.error('Error loading form history:', error)
toast.error('Failed to load form history')
}
}
const selectVersion = (version) => {
selectedVersion.value = version
previewVersion(version)
}
const previewCurrent = () => {
selectedVersion.value = 'current'
previewData.value = {
formName: currentVersion.value.formName,
formDescription: currentVersion.value.formDescription,
formComponents: currentVersion.value.formComponents,
customScript: currentVersion.value.customScript,
customCSS: currentVersion.value.customCSS,
formEvents: currentVersion.value.formEvents,
scriptMode: currentVersion.value.scriptMode
}
}
const previewVersion = async (version) => {
try {
if (version === 'current') {
previewCurrent()
return
}
const result = await formStore.loadFormVersionPreview(props.formId, version.historyID)
previewData.value = result
} catch (error) {
console.error('Error loading version preview:', error)
toast.error('Failed to load version preview')
}
}
const restoreVersion = async (version) => {
if (isRestoring.value) return
const confirmed = confirm(`Are you sure you want to restore to version ${version.versionNumber}? This will create a new version with the restored content.`)
if (!confirmed) return
isRestoring.value = true
try {
const result = await formStore.restoreFormVersion(props.formId, version)
toast.success(result.message || 'Form restored successfully')
emit('restored', result)
emit('close')
} catch (error) {
console.error('Error restoring version:', error)
toast.error('Failed to restore form version')
} finally {
isRestoring.value = false
}
}
const formatDate = (dateString) => {
if (!dateString) return 'Unknown'
const date = new Date(dateString)
return date.toLocaleString()
}
const getComponentIcon = (type) => {
const icons = {
'text': 'heroicons:document-text',
'textarea': 'heroicons:document-text',
'number': 'heroicons:hashtag',
'email': 'heroicons:at-symbol',
'password': 'heroicons:lock-closed',
'select': 'heroicons:chevron-down',
'checkbox': 'heroicons:check-circle',
'radio': 'heroicons:radio',
'date': 'heroicons:calendar',
'file': 'heroicons:document-arrow-up',
'button': 'heroicons:cursor-arrow-rays',
'heading': 'heroicons:h1',
'paragraph': 'heroicons:document-text'
}
return icons[type] || 'heroicons:square-3-stack-3d'
}
const getComponentTypeName = (type) => {
const names = {
'text': 'Text Field',
'textarea': 'Text Area',
'number': 'Number Field',
'email': 'Email Field',
'password': 'Password Field',
'select': 'Select Dropdown',
'checkbox': 'Checkbox Group',
'radio': 'Radio Group',
'date': 'Date Picker',
'file': 'File Upload',
'button': 'Button',
'heading': 'Heading',
'paragraph': 'Paragraph'
}
return names[type] || type.charAt(0).toUpperCase() + type.slice(1)
}
// Watchers
watch(() => props.isOpen, (newValue) => {
if (newValue) {
loadHistory()
} else {
// Reset state when modal closes
selectedVersion.value = null
previewData.value = null
}
})
</script>

View File

@ -0,0 +1,393 @@
<template>
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div class="bg-white rounded-lg shadow-xl max-w-6xl w-full mx-4 max-h-[90vh] overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between p-6 border-b border-gray-200">
<div>
<h2 class="text-xl font-semibold text-gray-800">Process History</h2>
<p class="text-sm text-gray-600 mt-1">{{ processInfo.processName }}</p>
</div>
<button @click="$emit('close')" class="text-gray-400 hover:text-gray-600">
<Icon name="heroicons:x-mark" class="h-6 w-6" />
</button>
</div>
<!-- Content -->
<div class="flex h-[calc(90vh-120px)]">
<!-- Version List (Left Side) -->
<div class="w-1/3 border-r border-gray-200 bg-gray-50">
<div class="p-4 border-b border-gray-200">
<h3 class="font-medium text-gray-900">Versions</h3>
<p class="text-sm text-gray-600">{{ totalVersions }} total versions</p>
</div>
<div class="overflow-y-auto h-full pb-20">
<!-- Current Version -->
<div class="p-4 bg-blue-50 border-b border-gray-200">
<div class="flex items-center justify-between">
<div>
<div class="flex items-center space-x-2">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Current
</span>
<span class="text-sm font-medium text-gray-900">Latest Version</span>
</div>
<p class="text-xs text-gray-600 mt-1">
{{ formatDate(currentVersion?.processModifiedDate || currentVersion?.processCreatedDate) }}
</p>
<p class="text-xs text-gray-500">
by {{ currentVersion?.creator?.userFullName || 'Unknown' }}
</p>
</div>
<button
@click="previewCurrent"
class="text-blue-600 hover:text-blue-800 text-sm"
:class="{ 'font-semibold': selectedVersion === 'current' }"
>
Preview
</button>
</div>
</div>
<!-- Historical Versions -->
<div
v-for="version in history"
:key="version.historyID"
class="p-4 border-b border-gray-200 hover:bg-gray-100 cursor-pointer"
:class="{ 'bg-blue-50': selectedVersion?.historyID === version.historyID }"
@click="selectVersion(version)"
>
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="flex items-center space-x-2">
<span class="text-sm font-medium text-gray-900">
Version {{ version.versionNumber }}
</span>
<span v-if="version.changeDescription" class="text-xs text-gray-500">
- {{ version.changeDescription }}
</span>
</div>
<p class="text-xs text-gray-600 mt-1">
{{ formatDate(version.savedDate) }}
</p>
<p class="text-xs text-gray-500">
by {{ version.savedByUser?.userFullName || 'Unknown' }}
</p>
</div>
<div class="flex items-center space-x-2">
<button
@click.stop="previewVersion(version)"
class="text-blue-600 hover:text-blue-800 text-sm"
>
Preview
</button>
<button
@click.stop="restoreVersion(version)"
class="text-green-600 hover:text-green-800 text-sm"
:disabled="isRestoring"
>
Restore
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Preview Area (Right Side) -->
<div class="flex-1 bg-white">
<div class="p-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<h3 class="font-medium text-gray-900">
{{ previewData ? 'Preview' : 'Select a version to preview' }}
</h3>
<div v-if="previewData && selectedVersion !== 'current'" class="flex items-center space-x-2">
<button
@click="restoreVersion(selectedVersion)"
:disabled="isRestoring"
class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700 disabled:opacity-50"
>
{{ isRestoring ? 'Restoring...' : 'Restore This Version' }}
</button>
</div>
</div>
</div>
<div class="overflow-y-auto h-full pb-20">
<!-- Preview Content -->
<div v-if="previewData" class="p-6">
<!-- Process Info -->
<div class="mb-6 p-4 bg-gray-50 rounded-lg">
<h4 class="font-medium text-gray-900 mb-2">Process Information</h4>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-600">Name:</span>
<span class="ml-2 font-medium">{{ previewData.processName }}</span>
</div>
<div>
<span class="text-gray-600">Status:</span>
<span class="ml-2 font-medium capitalize">{{ previewData.processStatus }}</span>
</div>
<div>
<span class="text-gray-600">Nodes:</span>
<span class="ml-2 font-medium">{{ previewData.processDefinition?.nodes?.length || 0 }}</span>
</div>
<div>
<span class="text-gray-600">Connections:</span>
<span class="ml-2 font-medium">{{ previewData.processDefinition?.edges?.length || 0 }}</span>
</div>
<div v-if="previewData.processCategory" class="col-span-2">
<span class="text-gray-600">Category:</span>
<span class="ml-2">{{ previewData.processCategory }}</span>
</div>
<div class="col-span-2" v-if="previewData.processDescription">
<span class="text-gray-600">Description:</span>
<span class="ml-2">{{ previewData.processDescription }}</span>
</div>
<div v-if="previewData.versionInfo" class="col-span-2">
<span class="text-gray-600">Version:</span>
<span class="ml-2 font-medium">{{ previewData.versionInfo.versionNumber }}</span>
<span class="ml-2 text-gray-500">
({{ formatDate(previewData.versionInfo.savedDate) }})
</span>
</div>
</div>
</div>
<!-- Process Nodes Preview -->
<div class="space-y-4">
<h4 class="font-medium text-gray-900">Process Nodes</h4>
<div class="border rounded-lg p-4 bg-white">
<div v-if="previewData.processDefinition?.nodes?.length" class="space-y-3">
<div
v-for="(node, index) in previewData.processDefinition.nodes"
:key="index"
class="flex items-center justify-between p-3 border rounded-lg bg-gray-50"
>
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<Icon :name="getNodeIcon(node.type)" class="h-4 w-4 text-blue-600" />
</div>
<div>
<div class="font-medium text-gray-900">
{{ node.data?.label || node.label || node.type }}
</div>
<div class="text-sm text-gray-600">
{{ node.id }}
</div>
</div>
</div>
<div class="text-sm text-gray-500">
{{ getNodeTypeName(node.type) }}
</div>
</div>
</div>
<div v-else class="text-center py-8 text-gray-500">
No nodes in this version
</div>
</div>
</div>
<!-- Process Variables Preview (if any) -->
<div v-if="previewData.processVariables && Object.keys(previewData.processVariables).length > 0" class="mt-6">
<h4 class="font-medium text-gray-900 mb-2">Process Variables</h4>
<div class="bg-gray-900 text-gray-100 p-4 rounded-lg text-sm font-mono">
<pre class="whitespace-pre-wrap">{{ JSON.stringify(previewData.processVariables, null, 2) }}</pre>
</div>
</div>
<!-- Process Settings Preview (if any) -->
<div v-if="previewData.processSettings && Object.keys(previewData.processSettings).length > 0" class="mt-6">
<h4 class="font-medium text-gray-900 mb-2">Process Settings</h4>
<div class="bg-gray-900 text-gray-100 p-4 rounded-lg text-sm font-mono">
<pre class="whitespace-pre-wrap">{{ JSON.stringify(previewData.processSettings, null, 2) }}</pre>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="flex items-center justify-center h-full text-gray-500">
<div class="text-center">
<Icon name="heroicons:document-text" class="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>Select a version to preview its contents</p>
</div>
</div>
</div>
</div>
</div>
<!-- Footer with Close Button -->
<div class="flex justify-end p-4 border-t border-gray-200 bg-gray-50">
<button
@click="$emit('close')"
class="px-4 py-2 text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Close
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useProcessBuilderStore } from '~/stores/processBuilder'
const props = defineProps({
isOpen: {
type: Boolean,
default: false
},
processId: {
type: String,
required: true
}
})
const emit = defineEmits(['close', 'restored'])
const processStore = useProcessBuilderStore()
// Try to use toast composable if available, with fallback
let toast
try {
toast = useToast()
} catch (error) {
toast = {
success: (msg) => console.log('Success:', msg),
error: (msg) => console.error('Error:', msg),
info: (msg) => console.info('Info:', msg),
warning: (msg) => console.warn('Warning:', msg)
}
}
// Reactive data
const history = ref([])
const currentVersion = ref(null)
const processInfo = ref({})
const totalVersions = ref(0)
const selectedVersion = ref(null)
const previewData = ref(null)
const isRestoring = ref(false)
// Computed
const isLoading = computed(() => history.value.length === 0 && props.isOpen)
// Methods
const loadHistory = async () => {
try {
const result = await processStore.getProcessHistory(props.processId)
history.value = result.history
currentVersion.value = result.currentVersion
processInfo.value = result.form
totalVersions.value = result.totalVersions
} catch (error) {
console.error('Error loading process history:', error)
toast.error('Failed to load process history')
}
}
const selectVersion = (version) => {
selectedVersion.value = version
previewVersion(version)
}
const previewCurrent = () => {
selectedVersion.value = 'current'
previewData.value = {
processName: currentVersion.value.processName,
processDescription: currentVersion.value.processDescription,
processDefinition: currentVersion.value.processDefinition,
processStatus: currentVersion.value.processStatus,
processCategory: currentVersion.value.processCategory,
processOwner: currentVersion.value.processOwner,
processPermissions: currentVersion.value.processPermissions,
processPriority: currentVersion.value.processPriority,
processSettings: currentVersion.value.processSettings,
processVariables: currentVersion.value.processVariables,
templateCategory: currentVersion.value.templateCategory
}
}
const previewVersion = async (version) => {
try {
if (version === 'current') {
previewCurrent()
return
}
const result = await processStore.loadProcessVersionPreview(props.processId, version.historyID)
previewData.value = result
} catch (error) {
console.error('Error loading version preview:', error)
toast.error('Failed to load version preview')
}
}
const restoreVersion = async (version) => {
if (isRestoring.value) return
const confirmed = confirm(`Are you sure you want to restore to version ${version.versionNumber}? This will create a new version with the restored content.`)
if (!confirmed) return
isRestoring.value = true
try {
const result = await processStore.restoreProcessVersion(props.processId, version)
toast.success(result.message || 'Process restored successfully')
emit('restored', result)
emit('close')
} catch (error) {
console.error('Error restoring version:', error)
toast.error('Failed to restore process version')
} finally {
isRestoring.value = false
}
}
const formatDate = (dateString) => {
if (!dateString) return 'Unknown'
const date = new Date(dateString)
return date.toLocaleString()
}
const getNodeIcon = (type) => {
const icons = {
'start': 'heroicons:play-circle',
'end': 'heroicons:stop-circle',
'task': 'heroicons:rectangle-stack',
'form': 'heroicons:document-text',
'gateway': 'heroicons:arrows-pointing-out',
'script': 'heroicons:code-bracket',
'api': 'heroicons:cloud-arrow-down',
'notification': 'heroicons:bell',
'business-rule': 'heroicons:document-check'
}
return icons[type] || 'heroicons:rectangle-stack'
}
const getNodeTypeName = (type) => {
const names = {
'start': 'Start Event',
'end': 'End Event',
'task': 'Task',
'form': 'Form Task',
'gateway': 'Gateway',
'script': 'Script Task',
'api': 'API Call',
'notification': 'Notification',
'business-rule': 'Business Rule'
}
return names[type] || type.charAt(0).toUpperCase() + type.slice(1)
}
// Watchers
watch(() => props.isOpen, (newValue) => {
if (newValue) {
loadHistory()
} else {
// Reset state when modal closes
selectedVersion.value = null
previewData.value = null
}
})
</script>

View File

@ -0,0 +1,134 @@
# Form History & Versioning System
## Overview
The form history system provides automatic versioning for all forms, allowing users to view previous versions and restore to any point in time. Every time a user saves a form, the previous version is automatically archived in the `formHistory` table.
## Database Schema
### formHistory Table
- `historyID` - Primary key for the history entry
- `formID` - Foreign key linking to the main form
- `formUUID` - UUID of the form for easy reference
- `formName` - Name of the form at the time of save
- `formDescription` - Description at the time of save
- `formComponents` - JSON of all form components at the time of save
- `formStatus` - Status (active/inactive) at the time of save
- `customCSS` - Custom CSS code at the time of save
- `customScript` - Custom JavaScript code at the time of save
- `formEvents` - Form events configuration at the time of save
- `scriptMode` - Script execution mode (safe/advanced) at the time of save
- `versionNumber` - Sequential version number (1, 2, 3...)
- `changeDescription` - Optional description of changes made
- `savedBy` - User ID who saved this version
- `savedDate` - Timestamp when this version was saved
## How It Works
### Automatic Versioning
1. When a user clicks "Save" on a form
2. The system first retrieves the current form data
3. The current form data is saved to `formHistory` with the next sequential version number
4. The form is then updated with the new data
5. Each save creates a complete snapshot of the form state
### Version Management
- **Version Numbers**: Sequential integers starting from 1
- **Change Descriptions**: Optional descriptions that can be added when saving
- **User Tracking**: Each version records who made the changes
- **Complete Snapshots**: Each version contains all form data, not just changes
## API Endpoints
### Get Form History
```
GET /api/forms/{formId}/history
```
Returns all versions of a form with metadata.
### Get Specific Version
```
GET /api/forms/{formId}/version/{versionId}
```
Returns details of a specific version. `versionId` can be either the `historyID` or `versionNumber`.
### Restore Version
```
POST /api/forms/{formId}/restore
```
Body:
```json
{
"versionNumber": 5,
"restoredBy": 1,
"changeDescription": "Restored to working version"
}
```
## Frontend Features
### History Modal
- **Version List**: Shows all versions with timestamps and change descriptions
- **Preview**: Users can preview any version before restoring
- **Restore**: One-click restore to any previous version
- **User Information**: Shows who made each change
### History Button
- Appears in the form builder header next to "Templates"
- Only visible for saved forms (not new forms)
- Opens the history modal for the current form
## Store Functions
### Form Builder Store Methods
- `getFormHistory(formId)` - Get all versions of a form
- `getFormVersion(formId, versionId)` - Get specific version details
- `restoreFormVersion(formId, versionData)` - Restore to a specific version
- `loadFormVersionPreview(formId, versionId)` - Load version for preview only
- `setChangeDescription(description)` - Set description for next save
## Usage Examples
### Basic Save with Description
```javascript
// Set optional change description
formStore.setChangeDescription("Added validation to email field");
// Save form (automatically creates history entry)
await formStore.saveForm();
```
### View Form History
```javascript
const history = await formStore.getFormHistory(formId);
console.log(`Form has ${history.totalVersions} versions`);
```
### Restore to Previous Version
```javascript
const versionToRestore = history.history[2]; // Version 3
await formStore.restoreFormVersion(formId, versionToRestore, "Reverted problematic changes");
```
## Benefits
1. **Never Lose Data**: Every form state is preserved
2. **Easy Rollback**: One-click restore to any previous version
3. **Change Tracking**: See who made changes and when
4. **Preview Before Restore**: View any version before making it current
5. **Audit Trail**: Complete history of all form modifications
## Implementation Notes
- **Storage Efficiency**: Each version stores complete form data for simplicity and reliability
- **Cascade Deletion**: When a form is deleted, all its history is automatically removed
- **Foreign Key Constraints**: Ensures data integrity between forms and history
- **Indexing**: Optimized for fast lookups by form, date, and UUID
## Future Enhancements
- **Diff View**: Show differences between versions
- **Branch/Merge**: Allow creating branches of forms
- **Bulk Operations**: Restore multiple forms to specific dates
- **Export History**: Download form history as JSON/CSV
- **Retention Policy**: Automatic cleanup of old versions

View File

@ -2,27 +2,27 @@
<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"
class="bg-gray-800 px-6 py-3 flex items-center justify-between text-white shadow-lg border-b border-gray-700"
>
<!-- Left section - Logo and navigation -->
<div class="flex items-center gap-3">
<div class="flex items-center gap-4">
<Icon
@click="navigateTo('/', { external: true })"
name="ph:arrow-circle-left-duotone"
class="cursor-pointer w-6 h-6"
class="cursor-pointer w-6 h-6 hover:text-gray-300 transition-colors"
/>
<img
src="@/assets/img/logo/logo-word-white.svg"
alt="Corrad Logo"
class="h-7"
/>
<div v-if="isPreview" class="bg-blue-500 text-white text-xs px-2 py-1 rounded-full ml-2">
<div v-if="isPreview" class="bg-blue-500 text-white text-xs px-3 py-1 rounded-full ml-2 font-medium">
Preview Mode
</div>
</div>
<!-- Middle section - Form name -->
<div class="flex-1 flex justify-center items-center mx-4">
<div class="flex-1 flex justify-center items-center mx-8">
<FormKit
v-if="!isPreview"
type="text"
@ -41,45 +41,54 @@
</div>
<!-- Right section - Actions -->
<div class="flex items-center">
<!-- Primary actions -->
<div class="flex items-center mr-2 border-r border-gray-600 pr-2">
<RsButton v-if="!isPreview" @click="handleSave" variant="primary" size="sm" class="mr-2">
<Icon name="material-symbols:save" class="mr-1" />
<div class="flex items-center space-x-3">
<!-- Primary Action Group -->
<div class="flex items-center space-x-2">
<RsButton v-if="!isPreview" @click="handleSave" variant="primary" size="sm">
<Icon name="material-symbols:save" class="mr-1.5" />
Save
</RsButton>
</RsButton>
<RsButton @click="togglePreview" :variant="isPreview ? 'primary' : 'secondary'" size="sm">
<Icon :name="isPreview ? 'material-symbols:edit' : 'material-symbols:preview'" class="mr-1" />
<Icon :name="isPreview ? 'material-symbols:edit' : 'material-symbols:preview'" class="mr-1.5" />
{{ isPreview ? 'Edit' : 'Preview' }}
</RsButton>
</RsButton>
</div>
<!-- Templates button -->
<div v-if="!isPreview" class="mr-2 border-r border-gray-600 pr-2">
<!-- Secondary Action Group -->
<div v-if="!isPreview" class="flex items-center space-x-2">
<RsButton @click="showTemplatesModal = true" variant="secondary" size="sm">
<Icon name="material-symbols:description-outline" class="mr-1" />
<Icon name="material-symbols:description-outline" class="mr-1.5" />
Templates
</RsButton>
</RsButton>
<!-- Form History button - only show if form is saved -->
<RsButton
v-if="formStore.currentFormId"
@click="showFormHistoryModal = true"
variant="secondary"
size="sm"
>
<Icon name="material-symbols:history" class="mr-1.5" />
History
</RsButton>
</div>
<!-- Secondary actions - only in edit mode -->
<div v-if="!isPreview" class="flex items-center">
<div class="dropdown relative">
<RsButton @click="showDropdown = !showDropdown" variant="tertiary" size="sm" class="flex items-center">
<Icon name="material-symbols:more-vert" class="w-5 h-5" />
</RsButton>
<div v-if="showDropdown" class="dropdown-menu absolute right-0 mt-2 bg-white rounded shadow-lg py-1 z-10 w-48 text-gray-800">
<button @click="showFormSettings = true; showDropdown = false" class="w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center">
<Icon name="material-symbols:code" class="mr-2 w-4 h-4" />
<span>Form Settings</span>
</button>
<button @click="navigateToManage(); showDropdown = false" class="w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center">
<Icon name="material-symbols:settings" class="mr-2 w-4 h-4" />
<span>Manage Forms</span>
</button>
</div>
<!-- More Actions Dropdown -->
<div v-if="!isPreview" class="relative">
<RsButton @click="showDropdown = !showDropdown" variant="tertiary" size="sm" class="flex items-center">
<Icon name="material-symbols:more-vert" class="w-5 h-5" />
</RsButton>
<div v-if="showDropdown" class="dropdown-menu absolute right-0 mt-2 bg-white rounded-lg shadow-lg py-2 z-10 w-48 text-gray-800 border border-gray-200">
<button @click="showFormSettings = true; showDropdown = false" class="w-full text-left px-4 py-2 hover:bg-gray-50 flex items-center transition-colors">
<Icon name="material-symbols:code" class="mr-3 w-4 h-4 text-gray-500" />
<span>Form Settings</span>
</button>
<button @click="navigateToManage(); showDropdown = false" class="w-full text-left px-4 py-2 hover:bg-gray-50 flex items-center transition-colors">
<Icon name="material-symbols:settings" class="mr-3 w-4 h-4 text-gray-500" />
<span>Manage Forms</span>
</button>
</div>
</div>
</div>
@ -861,6 +870,14 @@
@close="showFieldSettings = false"
/>
<!-- Form History Modal -->
<FormHistoryModal
:is-open="showFormHistoryModal"
:form-id="formStore.currentFormId"
@close="showFormHistoryModal = false"
@restored="handleFormRestored"
/>
</div>
</template>
@ -872,6 +889,7 @@ import FormTemplatesModal from '~/components/FormTemplatesModal.vue';
import FormBuilderFieldSettingsModal from '~/components/FormBuilderFieldSettingsModal.vue';
import FormScriptEngine from '~/components/FormScriptEngine.vue';
import ConditionalLogicEngine from '~/components/ConditionalLogicEngine.vue';
import FormHistoryModal from '~/components/FormHistoryModal.vue';
definePageMeta({
title: "Form Builder",
@ -911,6 +929,7 @@ const showDropdown = ref(false);
const showTemplatesModal = ref(false);
const showFieldSettings = ref(false);
const showFieldSettingsPanel = ref(false);
const showFormHistoryModal = ref(false);
const previewForm = ref(null);
const formScriptEngine = ref(null);
const conditionalLogicEngine = ref(null);
@ -2443,6 +2462,12 @@ const handleConditionalLogicGenerated = (script) => {
formStore.formCustomScript += `\n// Conditional Logic Script\n${script}`;
toast.success('Conditional logic script added successfully');
};
const handleFormRestored = (restoredForm) => {
// Handle form restoration logic
console.log('Form restored:', restoredForm);
// You might want to update the form state or show a success message
};
</script>
<style scoped>

View File

@ -20,6 +20,7 @@ import BusinessRuleNodeConfigurationModal from '~/components/process-flow/Busine
import NotificationNodeConfigurationModal from '~/components/process-flow/NotificationNodeConfigurationModal.vue';
import ProcessTemplatesModal from '~/components/ProcessTemplatesModal.vue';
import ProcessSettingsModal from '~/components/process-flow/ProcessSettingsModal.vue';
import ProcessHistoryModal from '~/components/ProcessHistoryModal.vue';
// Define page meta
definePageMeta({
@ -88,6 +89,7 @@ const showNotificationConfigModal = ref(false);
const showTemplatesModal = ref(false);
const showProcessSettings = ref(false);
const showDropdown = ref(false);
const showProcessHistoryModal = ref(false);
// Component definitions
const components = [
@ -997,6 +999,14 @@ const handleNotificationNodeUpdate = (updatedData) => {
}
};
// Handle process restoration from history
const handleProcessRestored = (restoredProcess) => {
// The process has been restored in the backend, so we need to reload it
console.log('Process restored:', restoredProcess);
// The current process will be automatically updated by the store
toast.success('Process has been restored successfully');
};
// Navigate to variables page
const navigateToVariables = () => {
confirmNavigation('/variables');
@ -1086,6 +1096,14 @@ watch(() => processStore.hasUnsavedChanges, (hasChanges) => {
Templates
</RsButton>
</div>
<!-- Process History button - only show if process is saved -->
<div v-if="processStore.currentProcess && processStore.currentProcess.id" class="mr-2 border-r border-gray-600 pr-2">
<RsButton @click="showProcessHistoryModal = true" variant="secondary" size="sm">
<Icon name="material-symbols:history" class="mr-1" />
History
</RsButton>
</div>
<!-- Secondary actions -->
<div class="flex items-center">
@ -1099,10 +1117,6 @@ watch(() => processStore.hasUnsavedChanges, (hasChanges) => {
<Icon name="material-symbols:tune" class="mr-2 w-4 h-4" />
<span>Process Settings</span>
</button>
<button @click="navigateToVariables(); showDropdown = false" class="w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center">
<Icon name="material-symbols:data-object" class="mr-2 w-4 h-4" />
<span>Variables</span>
</button>
<button @click="confirmNavigation('/process-builder/manage'); showDropdown = false" class="w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center">
<Icon name="material-symbols:settings" class="mr-2 w-4 h-4" />
<span>Manage Processes</span>
@ -1332,6 +1346,14 @@ watch(() => processStore.hasUnsavedChanges, (hasChanges) => {
<ProcessSettingsModal
v-model="showProcessSettings"
/>
<!-- Process History Modal -->
<ProcessHistoryModal
:is-open="showProcessHistoryModal"
:process-id="processStore.currentProcess?.id"
@close="showProcessHistoryModal = false"
@restored="handleProcessRestored"
/>
</div>
</template>

View File

@ -69,12 +69,24 @@
"$ref": "#/definitions/form"
}
},
"formHistoryEntries": {
"type": "array",
"items": {
"$ref": "#/definitions/formHistory"
}
},
"processes": {
"type": "array",
"items": {
"$ref": "#/definitions/process"
}
},
"processHistoryEntries": {
"type": "array",
"items": {
"$ref": "#/definitions/processHistory"
}
},
"userrole": {
"type": "array",
"items": {
@ -228,6 +240,99 @@
"type": "null"
}
]
},
"history": {
"type": "array",
"items": {
"$ref": "#/definitions/formHistory"
}
}
}
},
"formHistory": {
"type": "object",
"properties": {
"historyID": {
"type": "integer"
},
"formUUID": {
"type": "string"
},
"formName": {
"type": "string"
},
"formDescription": {
"type": [
"string",
"null"
]
},
"formComponents": {
"type": [
"number",
"string",
"boolean",
"object",
"array",
"null"
]
},
"formStatus": {
"type": "string"
},
"customCSS": {
"type": [
"string",
"null"
]
},
"customScript": {
"type": [
"string",
"null"
]
},
"formEvents": {
"type": [
"number",
"string",
"boolean",
"object",
"array",
"null"
]
},
"scriptMode": {
"type": [
"string",
"null"
]
},
"versionNumber": {
"type": "integer"
},
"changeDescription": {
"type": [
"string",
"null"
]
},
"savedDate": {
"type": "string",
"format": "date-time"
},
"form": {
"$ref": "#/definitions/form"
},
"savedByUser": {
"anyOf": [
{
"$ref": "#/definitions/user"
},
{
"type": "null"
}
]
}
}
},
@ -353,6 +458,128 @@
"type": "null"
}
]
},
"history": {
"type": "array",
"items": {
"$ref": "#/definitions/processHistory"
}
}
}
},
"processHistory": {
"type": "object",
"properties": {
"historyID": {
"type": "integer"
},
"processUUID": {
"type": "string"
},
"processName": {
"type": "string"
},
"processDescription": {
"type": [
"string",
"null"
]
},
"processDefinition": {
"type": [
"number",
"string",
"boolean",
"object",
"array",
"null"
]
},
"processVersion": {
"type": "integer"
},
"processStatus": {
"type": "string"
},
"processCategory": {
"type": [
"string",
"null"
]
},
"processOwner": {
"type": [
"string",
"null"
]
},
"processPermissions": {
"type": [
"number",
"string",
"boolean",
"object",
"array",
"null"
]
},
"processPriority": {
"type": [
"string",
"null"
]
},
"processSettings": {
"type": [
"number",
"string",
"boolean",
"object",
"array",
"null"
]
},
"processVariables": {
"type": [
"number",
"string",
"boolean",
"object",
"array",
"null"
]
},
"templateCategory": {
"type": [
"string",
"null"
]
},
"versionNumber": {
"type": "integer"
},
"changeDescription": {
"type": [
"string",
"null"
]
},
"savedDate": {
"type": "string",
"format": "date-time"
},
"process": {
"$ref": "#/definitions/process"
},
"savedByUser": {
"anyOf": [
{
"$ref": "#/definitions/user"
},
{
"type": "null"
}
]
}
}
}
@ -371,8 +598,14 @@
"form": {
"$ref": "#/definitions/form"
},
"formHistory": {
"$ref": "#/definitions/formHistory"
},
"process": {
"$ref": "#/definitions/process"
},
"processHistory": {
"$ref": "#/definitions/processHistory"
}
}
}

View File

@ -0,0 +1,25 @@
-- Create formHistory table for form versioning
CREATE TABLE `formHistory` (
`historyID` int NOT NULL AUTO_INCREMENT,
`formID` int NOT NULL,
`formUUID` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`formName` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`formDescription` text COLLATE utf8mb4_unicode_ci,
`formComponents` json NOT NULL,
`formStatus` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`customCSS` text COLLATE utf8mb4_unicode_ci,
`customScript` longtext COLLATE utf8mb4_unicode_ci,
`formEvents` json DEFAULT NULL,
`scriptMode` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`versionNumber` int NOT NULL,
`changeDescription` text COLLATE utf8mb4_unicode_ci,
`savedBy` int DEFAULT NULL,
`savedDate` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`historyID`),
KEY `FK_formHistory_form` (`formID`),
KEY `FK_formHistory_savedBy` (`savedBy`),
KEY `IDX_formHistory_uuid` (`formUUID`),
KEY `IDX_formHistory_date` (`savedDate`),
CONSTRAINT `FK_formHistory_form` FOREIGN KEY (`formID`) REFERENCES `form` (`formID`) ON DELETE CASCADE,
CONSTRAINT `FK_formHistory_savedBy` FOREIGN KEY (`savedBy`) REFERENCES `user` (`userID`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@ -0,0 +1,33 @@
-- Add process history table for version tracking
CREATE TABLE `processHistory` (
`historyID` INTEGER NOT NULL AUTO_INCREMENT,
`processID` INTEGER NOT NULL,
`processUUID` VARCHAR(36) NOT NULL,
`processName` VARCHAR(255) NOT NULL,
`processDescription` TEXT NULL,
`processDefinition` JSON NOT NULL,
`processVersion` INTEGER NOT NULL,
`processStatus` VARCHAR(50) NOT NULL,
`processCategory` VARCHAR(100) NULL,
`processOwner` VARCHAR(255) NULL,
`processPermissions` JSON NULL,
`processPriority` VARCHAR(50) NULL,
`processSettings` JSON NULL,
`processVariables` JSON NULL,
`templateCategory` VARCHAR(100) NULL,
`versionNumber` INTEGER NOT NULL,
`changeDescription` TEXT NULL,
`savedBy` INTEGER NULL,
`savedDate` DATETIME(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
INDEX `FK_processHistory_process`(`processID`),
INDEX `FK_processHistory_savedBy`(`savedBy`),
INDEX `IDX_processHistory_uuid`(`processUUID`),
INDEX `IDX_processHistory_date`(`savedDate`),
PRIMARY KEY (`historyID`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- Add foreign key constraints
ALTER TABLE `processHistory` ADD CONSTRAINT `processHistory_processID_fkey` FOREIGN KEY (`processID`) REFERENCES `process`(`processID`) ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE `processHistory` ADD CONSTRAINT `processHistory_savedBy_fkey` FOREIGN KEY (`savedBy`) REFERENCES `user`(`userID`) ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -13,19 +13,21 @@ datasource db {
}
model user {
userID Int @id @default(autoincrement())
userSecretKey String? @db.VarChar(255)
userUsername String? @db.VarChar(255)
userPassword String? @db.VarChar(255)
userFullName String? @db.VarChar(255)
userEmail String? @db.VarChar(255)
userPhone String? @db.VarChar(255)
userStatus String? @db.VarChar(255)
userCreatedDate DateTime? @db.DateTime(0)
userModifiedDate DateTime? @db.DateTime(0)
forms form[] @relation("FormCreator")
processes process[] @relation("ProcessCreator")
userrole userrole[]
userID Int @id @default(autoincrement())
userSecretKey String? @db.VarChar(255)
userUsername String? @db.VarChar(255)
userPassword String? @db.VarChar(255)
userFullName String? @db.VarChar(255)
userEmail String? @db.VarChar(255)
userPhone String? @db.VarChar(255)
userStatus String? @db.VarChar(255)
userCreatedDate DateTime? @db.DateTime(0)
userModifiedDate DateTime? @db.DateTime(0)
forms form[] @relation("FormCreator")
formHistoryEntries formHistory[]
processes process[] @relation("ProcessCreator")
processHistoryEntries processHistory[]
userrole userrole[]
}
model role {
@ -51,48 +53,104 @@ model userrole {
}
model form {
formID Int @id @default(autoincrement())
formUUID String @unique @db.VarChar(36)
formName String @db.VarChar(255)
formDescription String? @db.Text
formID Int @id @default(autoincrement())
formUUID String @unique @db.VarChar(36)
formName String @db.VarChar(255)
formDescription String? @db.Text
formComponents Json
formStatus String @default("active") @db.VarChar(50)
formStatus String @default("active") @db.VarChar(50)
formCreatedBy Int?
formCreatedDate DateTime @default(now()) @db.DateTime(0)
formModifiedDate DateTime? @updatedAt @db.DateTime(0)
customCSS String? @db.Text
customScript String? @db.LongText
formCreatedDate DateTime @default(now()) @db.DateTime(0)
formModifiedDate DateTime? @updatedAt @db.DateTime(0)
customCSS String? @db.Text
customScript String? @db.LongText
formEvents Json?
scriptMode String? @default("safe") @db.VarChar(20)
creator user? @relation("FormCreator", fields: [formCreatedBy], references: [userID])
scriptMode String? @default("safe") @db.VarChar(20)
creator user? @relation("FormCreator", fields: [formCreatedBy], references: [userID])
history formHistory[] @relation("FormHistoryEntries")
@@index([formCreatedBy], map: "FK_form_creator")
}
model formHistory {
historyID Int @id @default(autoincrement())
formID Int
formUUID String @db.VarChar(36)
formName String @db.VarChar(255)
formDescription String? @db.Text
formComponents Json
formStatus String @db.VarChar(50)
customCSS String? @db.Text
customScript String? @db.LongText
formEvents Json?
scriptMode String? @db.VarChar(20)
versionNumber Int
changeDescription String? @db.Text
savedBy Int?
savedDate DateTime @default(now()) @db.DateTime(0)
form form @relation("FormHistoryEntries", fields: [formID], references: [formID], onDelete: Cascade)
savedByUser user? @relation(fields: [savedBy], references: [userID])
@@index([formID], map: "FK_formHistory_form")
@@index([savedBy], map: "FK_formHistory_savedBy")
@@index([formUUID], map: "IDX_formHistory_uuid")
@@index([savedDate], map: "IDX_formHistory_date")
}
model process {
processID Int @id @default(autoincrement())
processUUID String @unique @db.VarChar(36)
processName String @db.VarChar(255)
processDescription String? @db.Text
processID Int @id @default(autoincrement())
processUUID String @unique @db.VarChar(36)
processName String @db.VarChar(255)
processDescription String? @db.Text
processDefinition Json
processVersion Int @default(1)
processStatus String @default("draft") @db.VarChar(50)
processVersion Int @default(1)
processStatus String @default("draft") @db.VarChar(50)
processCreatedBy Int?
processCreatedDate DateTime @default(now()) @db.DateTime(0)
processModifiedDate DateTime? @updatedAt @db.DateTime(0)
isTemplate Boolean @default(false)
processCategory String? @db.VarChar(100)
processOwner String? @db.VarChar(255)
processCreatedDate DateTime @default(now()) @db.DateTime(0)
processModifiedDate DateTime? @updatedAt @db.DateTime(0)
isTemplate Boolean @default(false)
processCategory String? @db.VarChar(100)
processOwner String? @db.VarChar(255)
processPermissions Json?
processPriority String? @default("normal") @db.VarChar(50)
processPriority String? @default("normal") @db.VarChar(50)
processSettings Json?
processVariables Json?
templateCategory String? @db.VarChar(100)
processDeletedDate DateTime? @db.DateTime(0)
creator user? @relation("ProcessCreator", fields: [processCreatedBy], references: [userID])
templateCategory String? @db.VarChar(100)
processDeletedDate DateTime? @db.DateTime(0)
creator user? @relation("ProcessCreator", fields: [processCreatedBy], references: [userID])
history processHistory[] @relation("ProcessHistoryEntries")
@@index([processCreatedBy], map: "FK_process_creator")
@@index([processStatus], map: "IDX_process_status")
@@index([processCategory], map: "IDX_process_category")
@@index([isTemplate], map: "IDX_process_template")
}
model processHistory {
historyID Int @id @default(autoincrement())
processID Int
processUUID String @db.VarChar(36)
processName String @db.VarChar(255)
processDescription String? @db.Text
processDefinition Json
processVersion Int
processStatus String @db.VarChar(50)
processCategory String? @db.VarChar(100)
processOwner String? @db.VarChar(255)
processPermissions Json?
processPriority String? @db.VarChar(50)
processSettings Json?
processVariables Json?
templateCategory String? @db.VarChar(100)
versionNumber Int
changeDescription String? @db.Text
savedBy Int?
savedDate DateTime @default(now()) @db.DateTime(0)
process process @relation("ProcessHistoryEntries", fields: [processID], references: [processID], onDelete: Cascade)
savedByUser user? @relation(fields: [savedBy], references: [userID])
@@index([processID], map: "FK_processHistory_process")
@@index([savedBy], map: "FK_processHistory_savedBy")
@@index([processUUID], map: "IDX_processHistory_uuid")
@@index([savedDate], map: "IDX_processHistory_date")
}

View File

@ -18,6 +18,59 @@ export default defineEventHandler(async (event) => {
error: 'Form name is required'
};
}
// First, get the current form data to save to history
let currentForm;
try {
currentForm = await prisma.form.findFirst({
where: {
OR: [
{ formUUID: id },
{ formID: !isNaN(parseInt(id)) ? parseInt(id) : -1 }
]
}
});
} catch (e) {
console.error('Error fetching current form for history:', e);
}
// If we found the current form, save it to history before updating
if (currentForm) {
try {
// Get the next version number
const lastHistory = await prisma.formHistory.findFirst({
where: { formID: currentForm.formID },
orderBy: { versionNumber: 'desc' }
});
const nextVersionNumber = lastHistory ? lastHistory.versionNumber + 1 : 1;
// Save current form data to history
await prisma.formHistory.create({
data: {
formID: currentForm.formID,
formUUID: currentForm.formUUID,
formName: currentForm.formName,
formDescription: currentForm.formDescription,
formComponents: currentForm.formComponents,
formStatus: currentForm.formStatus,
customCSS: currentForm.customCSS,
customScript: currentForm.customScript,
formEvents: currentForm.formEvents,
scriptMode: currentForm.scriptMode,
versionNumber: nextVersionNumber,
changeDescription: body.changeDescription || null,
savedBy: body.savedBy || currentForm.formCreatedBy,
savedDate: currentForm.formModifiedDate || currentForm.formCreatedDate
}
});
console.log(`Saved form ${currentForm.formUUID} version ${nextVersionNumber} to history`);
} catch (historyError) {
console.error('Error saving form to history:', historyError);
// Continue with update even if history save fails
}
}
// Prepare update data
const updateData = {
@ -51,7 +104,7 @@ export default defineEventHandler(async (event) => {
if (body.scriptMode !== undefined) {
updateData.scriptMode = body.scriptMode;
}
// Try to update by UUID first
let form;
try {
@ -73,7 +126,8 @@ export default defineEventHandler(async (event) => {
return {
success: true,
form
form,
versionSaved: currentForm ? true : false
};
} catch (error) {
console.error(`Error updating form ${id}:`, error);

View File

@ -0,0 +1,83 @@
import { PrismaClient } from '@prisma/client';
// Initialize Prisma client
const prisma = new PrismaClient();
export default defineEventHandler(async (event) => {
// Get the form ID from the route params
const id = event.context.params.id;
try {
// First, find the form to get its internal ID
let form;
try {
form = await prisma.form.findFirst({
where: {
OR: [
{ formUUID: id },
{ formID: !isNaN(parseInt(id)) ? parseInt(id) : -1 }
]
},
select: { formID: true, formUUID: true, formName: true }
});
} catch (e) {
throw new Error('Form not found');
}
if (!form) {
return {
success: false,
error: 'Form not found'
};
}
// Get all history entries for this form
const history = await prisma.formHistory.findMany({
where: { formID: form.formID },
include: {
savedByUser: {
select: {
userID: true,
userFullName: true,
userUsername: true
}
}
},
orderBy: { versionNumber: 'desc' }
});
// Get current form info for comparison
const currentForm = await prisma.form.findUnique({
where: { formID: form.formID },
include: {
creator: {
select: {
userID: true,
userFullName: true,
userUsername: true
}
}
}
});
return {
success: true,
form: {
formID: form.formID,
formUUID: form.formUUID,
formName: form.formName
},
currentVersion: currentForm,
history,
totalVersions: history.length
};
} catch (error) {
console.error(`Error fetching form history for ${id}:`, error);
return {
success: false,
error: 'Failed to fetch form history',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
};
}
});

View File

@ -0,0 +1,166 @@
import { PrismaClient } from '@prisma/client';
// Initialize Prisma client
const prisma = new PrismaClient();
export default defineEventHandler(async (event) => {
// Get the form ID from the route params
const id = event.context.params.id;
try {
// Parse the request body
const body = await readBody(event);
// Validate required fields
if (!body.versionNumber && !body.historyID) {
return {
success: false,
error: 'Either versionNumber or historyID is required'
};
}
// First, find the form to get its internal ID
let form;
try {
form = await prisma.form.findFirst({
where: {
OR: [
{ formUUID: id },
{ formID: !isNaN(parseInt(id)) ? parseInt(id) : -1 }
]
}
});
} catch (e) {
throw new Error('Form not found');
}
if (!form) {
return {
success: false,
error: 'Form not found'
};
}
// Find the specific version to restore
let historyEntry;
if (body.historyID) {
historyEntry = await prisma.formHistory.findFirst({
where: {
historyID: body.historyID,
formID: form.formID
}
});
} else {
historyEntry = await prisma.formHistory.findFirst({
where: {
formID: form.formID,
versionNumber: body.versionNumber
}
});
}
if (!historyEntry) {
return {
success: false,
error: 'Version not found in history'
};
}
// Save current form to history before restoring (creating a new version)
try {
// Get the next version number
const lastHistory = await prisma.formHistory.findFirst({
where: { formID: form.formID },
orderBy: { versionNumber: 'desc' }
});
const nextVersionNumber = lastHistory ? lastHistory.versionNumber + 1 : 1;
// Save current state to history
await prisma.formHistory.create({
data: {
formID: form.formID,
formUUID: form.formUUID,
formName: form.formName,
formDescription: form.formDescription,
formComponents: form.formComponents,
formStatus: form.formStatus,
customCSS: form.customCSS,
customScript: form.customScript,
formEvents: form.formEvents,
scriptMode: form.scriptMode,
versionNumber: nextVersionNumber,
changeDescription: `Pre-restore backup before restoring to version ${historyEntry.versionNumber}`,
savedBy: body.restoredBy || form.formCreatedBy,
savedDate: form.formModifiedDate || form.formCreatedDate
}
});
} catch (historyError) {
console.error('Error creating pre-restore backup:', historyError);
// Continue with restore even if backup fails
}
// Restore the form to the selected version
const restoredForm = await prisma.form.update({
where: { formID: form.formID },
data: {
formName: historyEntry.formName,
formDescription: historyEntry.formDescription,
formComponents: historyEntry.formComponents,
formStatus: historyEntry.formStatus,
customCSS: historyEntry.customCSS,
customScript: historyEntry.customScript,
formEvents: historyEntry.formEvents,
scriptMode: historyEntry.scriptMode,
formModifiedDate: new Date()
}
});
// Create a history entry for the restore action
try {
const lastHistoryAfterRestore = await prisma.formHistory.findFirst({
where: { formID: form.formID },
orderBy: { versionNumber: 'desc' }
});
const nextVersionAfterRestore = lastHistoryAfterRestore ? lastHistoryAfterRestore.versionNumber + 1 : 1;
await prisma.formHistory.create({
data: {
formID: form.formID,
formUUID: form.formUUID,
formName: historyEntry.formName,
formDescription: historyEntry.formDescription,
formComponents: historyEntry.formComponents,
formStatus: historyEntry.formStatus,
customCSS: historyEntry.customCSS,
customScript: historyEntry.customScript,
formEvents: historyEntry.formEvents,
scriptMode: historyEntry.scriptMode,
versionNumber: nextVersionAfterRestore,
changeDescription: `Restored from version ${historyEntry.versionNumber}`,
savedBy: body.restoredBy || form.formCreatedBy,
savedDate: new Date()
}
});
} catch (restoreHistoryError) {
console.error('Error creating restore history entry:', restoreHistoryError);
}
return {
success: true,
form: restoredForm,
restoredFromVersion: historyEntry.versionNumber,
restoredFromDate: historyEntry.savedDate,
message: `Form successfully restored to version ${historyEntry.versionNumber}`
};
} catch (error) {
console.error(`Error restoring form ${id}:`, error);
return {
success: false,
error: 'Failed to restore form',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
};
}
});

View File

@ -0,0 +1,130 @@
import { PrismaClient } from '@prisma/client';
// Initialize Prisma client
const prisma = new PrismaClient();
export default defineEventHandler(async (event) => {
// Get the form ID and version ID from the route params
const { id: formId, versionId } = event.context.params;
try {
// First, find the form to get its internal ID
let form;
try {
form = await prisma.form.findFirst({
where: {
OR: [
{ formUUID: formId },
{ formID: !isNaN(parseInt(formId)) ? parseInt(formId) : -1 }
]
},
select: { formID: true, formUUID: true, formName: true }
});
} catch (e) {
throw new Error('Form not found');
}
if (!form) {
return {
success: false,
error: 'Form not found'
};
}
// Find the specific version - can be either historyID or versionNumber
let historyEntry;
if (!isNaN(parseInt(versionId))) {
// Try as historyID first
historyEntry = await prisma.formHistory.findFirst({
where: {
historyID: parseInt(versionId),
formID: form.formID
},
include: {
savedByUser: {
select: {
userID: true,
userFullName: true,
userUsername: true
}
}
}
});
// If not found, try as versionNumber
if (!historyEntry) {
historyEntry = await prisma.formHistory.findFirst({
where: {
formID: form.formID,
versionNumber: parseInt(versionId)
},
include: {
savedByUser: {
select: {
userID: true,
userFullName: true,
userUsername: true
}
}
}
});
}
}
if (!historyEntry) {
return {
success: false,
error: 'Version not found'
};
}
// Get adjacent versions for navigation
const previousVersion = await prisma.formHistory.findFirst({
where: {
formID: form.formID,
versionNumber: { lt: historyEntry.versionNumber }
},
orderBy: { versionNumber: 'desc' },
select: { historyID: true, versionNumber: true, savedDate: true }
});
const nextVersion = await prisma.formHistory.findFirst({
where: {
formID: form.formID,
versionNumber: { gt: historyEntry.versionNumber }
},
orderBy: { versionNumber: 'asc' },
select: { historyID: true, versionNumber: true, savedDate: true }
});
// Get total version count
const totalVersions = await prisma.formHistory.count({
where: { formID: form.formID }
});
return {
success: true,
version: historyEntry,
navigation: {
previous: previousVersion,
next: nextVersion,
totalVersions,
currentPosition: historyEntry.versionNumber
},
formInfo: {
formID: form.formID,
formUUID: form.formUUID,
formName: form.formName
}
};
} catch (error) {
console.error(`Error fetching form version ${formId}/${versionId}:`, error);
return {
success: false,
error: 'Failed to fetch form version',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
};
}
});

View File

@ -18,95 +18,23 @@ export default defineEventHandler(async (event) => {
// Parse the request body
const body = await readBody(event);
// Validate required fields
if (!body.processName) {
return {
success: false,
error: 'Process name is required'
};
}
// Check if the ID is a UUID or numeric ID
const isUUID = processId.length === 36 && processId.includes('-');
// Build update data
const updateData = {};
// Basic fields
if (body.processName !== undefined) updateData.processName = body.processName;
if (body.processDescription !== undefined) updateData.processDescription = body.processDescription;
if (body.processCategory !== undefined) updateData.processCategory = body.processCategory;
if (body.processPriority !== undefined) updateData.processPriority = body.processPriority;
if (body.processOwner !== undefined) updateData.processOwner = body.processOwner;
if (body.isTemplate !== undefined) updateData.isTemplate = body.isTemplate;
if (body.templateCategory !== undefined) updateData.templateCategory = body.templateCategory;
// Get current process to check status
const currentProcess = await prisma.process.findFirst({
// Find the existing process first to save its current state to history
const existingProcess = await prisma.process.findFirst({
where: isUUID
? { processUUID: processId }
: { processID: parseInt(processId) },
select: {
processStatus: true,
processVersion: true
}
});
if (!currentProcess) {
return {
success: false,
error: 'Process not found'
};
}
// Handle status changes with validation
if (body.processStatus !== undefined) {
const currentStatus = currentProcess.processStatus;
const newStatus = body.processStatus;
// Validate status transitions
if (currentStatus === 'published' && newStatus === 'draft') {
// Allow unpublishing only if explicitly requested
if (body.allowUnpublish !== true) {
return {
success: false,
error: 'Cannot change published process to draft without explicit confirmation. Use allowUnpublish: true.'
};
}
}
updateData.processStatus = newStatus;
}
// If no status provided, preserve current status (don't change it)
// Process definition (nodes, edges, viewport)
if (body.nodes !== undefined || body.edges !== undefined || body.viewport !== undefined) {
updateData.processDefinition = {
nodes: body.nodes || [],
edges: body.edges || [],
viewport: body.viewport || { x: 0, y: 0, zoom: 1 }
};
}
// Process variables
if (body.variables !== undefined) {
updateData.processVariables = Object.keys(body.variables).length > 0 ? body.variables : null;
}
// Process settings
if (body.settings !== undefined) {
updateData.processSettings = Object.keys(body.settings).length > 0 ? body.settings : null;
}
// Process permissions
if (body.permissions !== undefined) {
updateData.processPermissions = Object.keys(body.permissions).length > 0 ? body.permissions : null;
}
// Version increment if major changes
if (body.incrementVersion === true) {
updateData.processVersion = currentProcess.processVersion + 1;
}
// Update the process
const updatedProcess = await prisma.process.update({
where: isUUID
? { processUUID: processId }
: { processID: parseInt(processId) },
data: updateData,
include: {
creator: {
select: {
@ -118,20 +46,97 @@ export default defineEventHandler(async (event) => {
}
});
return {
success: true,
process: updatedProcess
};
} catch (error) {
console.error('Error updating process:', error);
// Handle specific Prisma errors
if (error.code === 'P2025') {
if (!existingProcess) {
return {
success: false,
error: 'Process not found'
};
}
// Get the next version number for history
const lastHistory = await prisma.processHistory.findFirst({
where: { processID: existingProcess.processID },
orderBy: { versionNumber: 'desc' },
select: { versionNumber: true }
});
const nextVersionNumber = (lastHistory?.versionNumber || 0) + 1;
// Save current state to history before updating
await prisma.processHistory.create({
data: {
processID: existingProcess.processID,
processUUID: existingProcess.processUUID,
processName: existingProcess.processName,
processDescription: existingProcess.processDescription,
processDefinition: existingProcess.processDefinition,
processVersion: existingProcess.processVersion,
processStatus: existingProcess.processStatus,
processCategory: existingProcess.processCategory,
processOwner: existingProcess.processOwner,
processPermissions: existingProcess.processPermissions,
processPriority: existingProcess.processPriority,
processSettings: existingProcess.processSettings,
processVariables: existingProcess.processVariables,
templateCategory: existingProcess.templateCategory,
versionNumber: nextVersionNumber,
changeDescription: body.changeDescription || null,
savedBy: body.savedBy || existingProcess.processCreatedBy
}
});
// Prepare process definition
const processDefinition = {
nodes: body.nodes || [],
edges: body.edges || [],
viewport: body.viewport || { x: 0, y: 0, zoom: 1 }
};
// Prepare process variables (if any)
const processVariables = body.variables || {};
// Prepare process settings (if any)
const processSettings = body.settings || {};
// Prepare process permissions (if any)
const processPermissions = body.permissions || {};
// Update the process
const updatedProcess = await prisma.process.update({
where: isUUID
? { processUUID: processId }
: { processID: parseInt(processId) },
data: {
processName: body.processName,
processDescription: body.processDescription || null,
processCategory: body.processCategory || null,
processPriority: body.processPriority || 'normal',
processOwner: body.processOwner || null,
processDefinition: processDefinition,
processVariables: Object.keys(processVariables).length > 0 ? processVariables : null,
processSettings: Object.keys(processSettings).length > 0 ? processSettings : null,
processPermissions: Object.keys(processPermissions).length > 0 ? processPermissions : null,
processStatus: body.processStatus || existingProcess.processStatus,
processVersion: existingProcess.processVersion + 1
},
include: {
creator: {
select: {
userID: true,
userFullName: true,
userUsername: true
}
}
}
});
return {
success: true,
process: updatedProcess,
message: 'Process updated successfully and previous version saved to history'
};
} catch (error) {
console.error('Error updating process:', error);
return {
success: false,

View File

@ -0,0 +1,83 @@
import { PrismaClient } from '@prisma/client';
// Initialize Prisma client
const prisma = new PrismaClient();
export default defineEventHandler(async (event) => {
try {
// Get the process ID from the route parameter
const processId = getRouterParam(event, 'id');
if (!processId) {
return {
success: false,
error: 'Process ID is required'
};
}
// Check if the ID is a UUID or numeric ID
const isUUID = processId.length === 36 && processId.includes('-');
// Find the current process
const currentProcess = await prisma.process.findFirst({
where: isUUID
? { processUUID: processId }
: { processID: parseInt(processId) },
include: {
creator: {
select: {
userID: true,
userFullName: true,
userUsername: true
}
}
}
});
if (!currentProcess) {
return {
success: false,
error: 'Process not found'
};
}
// Get process history ordered by version number (newest first)
const history = await prisma.processHistory.findMany({
where: { processID: currentProcess.processID },
include: {
savedByUser: {
select: {
userID: true,
userFullName: true,
userUsername: true
}
}
},
orderBy: { versionNumber: 'desc' }
});
// Get total count of versions
const totalVersions = history.length + 1; // +1 for current version
return {
success: true,
data: {
currentVersion: currentProcess,
history: history,
form: {
processName: currentProcess.processName,
processDescription: currentProcess.processDescription
},
totalVersions: totalVersions
}
};
} catch (error) {
console.error('Error getting process history:', error);
return {
success: false,
error: 'Failed to get process history',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
};
}
});

View File

@ -15,40 +15,98 @@ export default defineEventHandler(async (event) => {
};
}
// Check if the ID is a UUID or numeric ID
const isUUID = processId.length === 36 && processId.includes('-');
// Parse the request body
const body = await readBody(event);
const { historyId, versionNumber, restoredBy } = body;
// First, check if the process exists and is deleted
const existingProcess = await prisma.process.findFirst({
where: {
...(isUUID
? { processUUID: processId }
: { processID: parseInt(processId) }),
processStatus: 'deleted'
},
select: {
processID: true,
processName: true,
processStatus: true
}
});
if (!existingProcess) {
if (!historyId && !versionNumber) {
return {
success: false,
error: 'Deleted process not found'
error: 'History ID or version number is required'
};
}
// Restore the process by setting status back to draft
const restoredProcess = await prisma.process.update({
// Check if the ID is a UUID or numeric ID
const isUUID = processId.length === 36 && processId.includes('-');
// Find the current process
const currentProcess = await prisma.process.findFirst({
where: isUUID
? { processUUID: processId }
: { processID: parseInt(processId) },
: { processID: parseInt(processId) }
});
if (!currentProcess) {
return {
success: false,
error: 'Process not found'
};
}
// Find the history record to restore
const historyRecord = await prisma.processHistory.findFirst({
where: historyId
? { historyID: parseInt(historyId) }
: {
processID: currentProcess.processID,
versionNumber: parseInt(versionNumber)
}
});
if (!historyRecord) {
return {
success: false,
error: 'History record not found'
};
}
// Save current state to history before restoring
const lastHistory = await prisma.processHistory.findFirst({
where: { processID: currentProcess.processID },
orderBy: { versionNumber: 'desc' },
select: { versionNumber: true }
});
const nextVersionNumber = (lastHistory?.versionNumber || 0) + 1;
await prisma.processHistory.create({
data: {
processStatus: 'draft', // Restore as draft for safety
processDeletedDate: null, // Clear deletion date
processModifiedDate: new Date()
processID: currentProcess.processID,
processUUID: currentProcess.processUUID,
processName: currentProcess.processName,
processDescription: currentProcess.processDescription,
processDefinition: currentProcess.processDefinition,
processVersion: currentProcess.processVersion,
processStatus: currentProcess.processStatus,
processCategory: currentProcess.processCategory,
processOwner: currentProcess.processOwner,
processPermissions: currentProcess.processPermissions,
processPriority: currentProcess.processPriority,
processSettings: currentProcess.processSettings,
processVariables: currentProcess.processVariables,
templateCategory: currentProcess.templateCategory,
versionNumber: nextVersionNumber,
changeDescription: `Restored to version ${historyRecord.versionNumber}`,
savedBy: restoredBy || currentProcess.processCreatedBy
}
});
// Restore the process from history
const restoredProcess = await prisma.process.update({
where: { processID: currentProcess.processID },
data: {
processName: historyRecord.processName,
processDescription: historyRecord.processDescription,
processDefinition: historyRecord.processDefinition,
processStatus: historyRecord.processStatus,
processCategory: historyRecord.processCategory,
processOwner: historyRecord.processOwner,
processPermissions: historyRecord.processPermissions,
processPriority: historyRecord.processPriority,
processSettings: historyRecord.processSettings,
processVariables: historyRecord.processVariables,
templateCategory: historyRecord.templateCategory,
processVersion: currentProcess.processVersion + 1
},
include: {
creator: {
@ -60,23 +118,15 @@ export default defineEventHandler(async (event) => {
}
}
});
return {
success: true,
message: `Process "${existingProcess.processName}" has been restored`,
process: restoredProcess
process: restoredProcess,
message: `Process restored to version ${historyRecord.versionNumber} successfully`
};
} catch (error) {
console.error('Error restoring process:', error);
// Handle specific Prisma errors
if (error.code === 'P2025') {
return {
success: false,
error: 'Process not found'
};
}
return {
success: false,
error: 'Failed to restore process',

View File

@ -0,0 +1,94 @@
import { PrismaClient } from '@prisma/client';
// Initialize Prisma client
const prisma = new PrismaClient();
export default defineEventHandler(async (event) => {
try {
// Get the process ID and version ID from the route parameters
const processId = getRouterParam(event, 'id');
const versionId = getRouterParam(event, 'versionId');
if (!processId || !versionId) {
return {
success: false,
error: 'Process ID and version ID are required'
};
}
// Check if the ID is a UUID or numeric ID
const isUUID = processId.length === 36 && processId.includes('-');
// Find the current process
const currentProcess = await prisma.process.findFirst({
where: isUUID
? { processUUID: processId }
: { processID: parseInt(processId) }
});
if (!currentProcess) {
return {
success: false,
error: 'Process not found'
};
}
// Find the specific version
const versionRecord = await prisma.processHistory.findFirst({
where: {
processID: currentProcess.processID,
historyID: parseInt(versionId)
},
include: {
savedByUser: {
select: {
userID: true,
userFullName: true,
userUsername: true
}
}
}
});
if (!versionRecord) {
return {
success: false,
error: 'Version not found'
};
}
// Format the version data for preview
const versionData = {
processName: versionRecord.processName,
processDescription: versionRecord.processDescription,
processDefinition: versionRecord.processDefinition,
processStatus: versionRecord.processStatus,
processCategory: versionRecord.processCategory,
processOwner: versionRecord.processOwner,
processPermissions: versionRecord.processPermissions,
processPriority: versionRecord.processPriority,
processSettings: versionRecord.processSettings,
processVariables: versionRecord.processVariables,
templateCategory: versionRecord.templateCategory,
versionInfo: {
versionNumber: versionRecord.versionNumber,
savedDate: versionRecord.savedDate,
savedBy: versionRecord.savedByUser,
changeDescription: versionRecord.changeDescription
}
};
return {
success: true,
data: versionData
};
} catch (error) {
console.error('Error getting process version:', error);
return {
success: false,
error: 'Failed to get process version',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
};
}
});

View File

@ -28,6 +28,9 @@ export const useFormBuilderStore = defineStore('formBuilder', {
// Form preview data
previewFormData: {},
// Form history tracking
lastChangeDescription: null,
}),
getters: {
@ -651,7 +654,10 @@ export const useFormBuilderStore = defineStore('formBuilder', {
customScript: this.formCustomScript,
customCSS: this.formCustomCSS,
formEvents: this.formEvents,
scriptMode: this.scriptMode
scriptMode: this.scriptMode,
// Add user info and change description for history tracking
savedBy: 1, // TODO: Get from authenticated user
changeDescription: this.lastChangeDescription || null
};
// Determine if this is a new form or an update
@ -684,12 +690,14 @@ export const useFormBuilderStore = defineStore('formBuilder', {
// Update store state with the saved form
this.currentFormId = result.form.formUUID;
this.hasUnsavedChanges = false;
this.lastChangeDescription = null; // Reset after save
// Record in history
this.recordHistory('save_form', {
formName: this.formName,
formDescription: this.formDescription,
componentCount: this.formComponents.length
componentCount: this.formComponents.length,
versionSaved: result.versionSaved
});
return result.form;
@ -931,6 +939,115 @@ export const useFormBuilderStore = defineStore('formBuilder', {
}
};
}
},
// Get form history/versions
async getFormHistory(formId = null) {
try {
const id = formId || this.currentFormId;
if (!id) {
throw new Error('No form ID provided');
}
const response = await fetch(`/api/forms/${id}/history`);
const result = await response.json();
if (result.success) {
return result;
} else {
throw new Error(result.error || 'Failed to fetch form history');
}
} catch (error) {
console.error('Error fetching form history:', error);
throw error;
}
},
// Get specific form version details
async getFormVersion(formId, versionId) {
try {
const response = await fetch(`/api/forms/${formId}/version/${versionId}`);
const result = await response.json();
if (result.success) {
return result;
} else {
throw new Error(result.error || 'Failed to fetch form version');
}
} catch (error) {
console.error('Error fetching form version:', error);
throw error;
}
},
// Restore form to a specific version
async restoreFormVersion(formId, versionData, changeDescription = null) {
try {
const response = await fetch(`/api/forms/${formId}/restore`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
versionNumber: versionData.versionNumber,
historyID: versionData.historyID,
restoredBy: 1, // TODO: Get from authenticated user
changeDescription: changeDescription || `Restored to version ${versionData.versionNumber}`
})
});
const result = await response.json();
if (result.success) {
// Reload the form to reflect the restored state
await this.loadForm(formId);
return result;
} else {
throw new Error(result.error || 'Failed to restore form version');
}
} catch (error) {
console.error('Error restoring form version:', error);
throw error;
}
},
// Load form version for preview (without changing current form)
async loadFormVersionPreview(formId, versionId) {
try {
const versionResult = await this.getFormVersion(formId, versionId);
if (versionResult.success) {
const version = versionResult.version;
// Return the version data in a format compatible with form preview
return {
formName: version.formName,
formDescription: version.formDescription,
formComponents: version.formComponents,
customScript: version.customScript,
customCSS: version.customCSS,
formEvents: version.formEvents,
scriptMode: version.scriptMode,
versionInfo: {
versionNumber: version.versionNumber,
savedDate: version.savedDate,
savedBy: version.savedByUser,
changeDescription: version.changeDescription
}
};
} else {
throw new Error('Failed to load version preview');
}
} catch (error) {
console.error('Error loading form version preview:', error);
throw error;
}
},
// Set change description for next save
setChangeDescription(description) {
this.lastChangeDescription = description;
}
},

View File

@ -10,7 +10,8 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
selectedEdgeId: null,
history: [],
historyIndex: -1,
unsavedChanges: false
unsavedChanges: false,
lastChangeDescription: ''
}),
getters: {
@ -737,6 +738,148 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
});
}
this.historyIndex = 0;
},
/**
* Get process version history
*/
async getProcessHistory(processId) {
try {
const response = await $fetch(`/api/process/${processId}/history`);
if (response.success) {
return response.data;
} else {
throw new Error(response.error || 'Failed to get process history');
}
} catch (error) {
console.error('Error getting process history:', error);
throw error;
}
},
/**
* Get specific process version details
*/
async getProcessVersion(processId, versionId) {
try {
const response = await $fetch(`/api/process/${processId}/version/${versionId}`);
if (response.success) {
return response.data;
} else {
throw new Error(response.error || 'Failed to get process version');
}
} catch (error) {
console.error('Error getting process version:', error);
throw error;
}
},
/**
* Restore process to a previous version
*/
async restoreProcessVersion(processId, version) {
try {
const requestData = {
historyId: version.historyID,
versionNumber: version.versionNumber,
restoredBy: 1 // TODO: Get from auth store
};
const response = await $fetch(`/api/process/${processId}/restore`, {
method: 'POST',
body: requestData
});
if (response.success) {
// Update local state with restored process
if (this.currentProcess && this.currentProcess.id === processId) {
await this.loadProcess(processId);
}
return response;
} else {
throw new Error(response.error || 'Failed to restore process version');
}
} catch (error) {
console.error('Error restoring process version:', error);
throw error;
}
},
/**
* Load process version for preview (without changing current process)
*/
async loadProcessVersionPreview(processId, versionId) {
try {
const response = await $fetch(`/api/process/${processId}/version/${versionId}`);
if (response.success) {
return response.data;
} else {
throw new Error(response.error || 'Failed to load process version preview');
}
} catch (error) {
console.error('Error loading process version preview:', error);
throw error;
}
},
/**
* Set change description for next save
*/
setChangeDescription(description) {
this.lastChangeDescription = description;
},
/**
* Enhanced save process with change tracking
*/
async saveProcessWithDescription(changeDescription = '') {
if (!this.currentProcess) return false;
try {
const processData = {
processName: this.currentProcess.name,
processDescription: this.currentProcess.description,
nodes: this.currentProcess.nodes,
edges: this.currentProcess.edges,
viewport: this.currentProcess.viewport || { x: 0, y: 0, zoom: 1 },
variables: useVariableStore().getAllVariables.process || {},
settings: this.currentProcess.settings || {},
permissions: this.currentProcess.permissions || {},
changeDescription: changeDescription || this.lastChangeDescription,
savedBy: 1 // TODO: Get from auth store
};
const response = await $fetch(`/api/process/${this.currentProcess.id}`, {
method: 'PUT',
body: processData
});
if (response.success) {
// Update local state with server response
const apiProcess = response.process;
this.currentProcess.updatedAt = apiProcess.processModifiedDate;
this.currentProcess.version = apiProcess.processVersion;
// Update in processes array if it exists there
const index = this.processes.findIndex(p => p.id === this.currentProcess.id);
if (index !== -1) {
this.processes[index] = { ...this.currentProcess };
}
this.unsavedChanges = false;
this.lastChangeDescription = '';
return response;
} else {
throw new Error(response.error || 'Failed to save process');
}
} catch (error) {
console.error('Error saving process:', error);
throw error;
}
}
}
});