- Updated the form builder to allow users to create new forms with additional fields for category and group, improving organization and usability. - Introduced an empty state in the form builder to guide users in creating new forms. - Enhanced the management page with new filters for category and group, allowing for better form organization and retrieval. - Updated the database schema to include new fields for form category, tags, and group, along with corresponding API adjustments for form creation and updates. - Improved the user interface with better handling of form descriptions and added visual indicators for categories and groups in the forms table.
945 lines
31 KiB
Vue
945 lines
31 KiB
Vue
<template>
|
|
<div class="flex flex-col h-screen bg-gray-50">
|
|
<!-- Header Bar -->
|
|
<header
|
|
class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between shadow-sm"
|
|
>
|
|
<div class="flex items-center gap-4">
|
|
<Icon
|
|
@click="goToDashboard"
|
|
name="ph:arrow-circle-left-duotone"
|
|
class="cursor-pointer w-6 h-6 hover:text-gray-600 text-gray-500"
|
|
/>
|
|
<div class="flex items-center gap-3">
|
|
<img
|
|
src="@/assets/img/logo/logo-word-black.svg"
|
|
alt="Corrad Logo"
|
|
class="h-8"
|
|
/>
|
|
<div class="border-l border-gray-300 pl-3">
|
|
<h1 class="text-xl font-semibold text-gray-900">Form Management</h1>
|
|
<p class="text-sm text-gray-500">Manage and organize your forms</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<!-- Import Form Button with hidden file input -->
|
|
<input
|
|
type="file"
|
|
ref="jsonFileInput"
|
|
accept=".json"
|
|
style="display: none"
|
|
@change="handleJsonImport"
|
|
/>
|
|
<RsButton @click="showImportModal = true" variant="secondary" size="sm" :disabled="loading">
|
|
<Icon name="material-symbols:upload" class="mr-1" />
|
|
Import Form
|
|
</RsButton>
|
|
|
|
<RsButton @click="createNewForm" variant="primary" size="sm">
|
|
<Icon name="material-symbols:add" class="mr-1" />
|
|
Create New Form
|
|
</RsButton>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Main Content Area -->
|
|
<div class="flex-1 p-6 overflow-auto">
|
|
<!-- Filters and Search -->
|
|
<div class="mb-6 flex flex-col sm:flex-row gap-4">
|
|
<div class="flex-1 max-w-md">
|
|
<FormKit
|
|
v-model="searchQuery"
|
|
type="text"
|
|
placeholder="Search forms by name or description..."
|
|
:classes="{
|
|
outer: 'mb-0',
|
|
wrapper: 'relative',
|
|
inner: 'relative',
|
|
input: 'w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500',
|
|
prefixIcon: 'absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 pointer-events-none'
|
|
}"
|
|
>
|
|
<template #prefixIcon>
|
|
<Icon name="material-symbols:search" class="text-lg" />
|
|
</template>
|
|
</FormKit>
|
|
</div>
|
|
|
|
<div class="flex gap-2">
|
|
<!-- Category Filter -->
|
|
<div class="relative">
|
|
<FormKit
|
|
v-model="selectedCategory"
|
|
type="select"
|
|
placeholder="All Categories"
|
|
:options="categoryOptions"
|
|
:classes="{
|
|
outer: 'mb-0',
|
|
input: 'min-w-[140px] text-sm'
|
|
}"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Group Filter -->
|
|
<div class="relative">
|
|
<FormKit
|
|
v-model="selectedGroup"
|
|
type="select"
|
|
placeholder="All Groups"
|
|
:options="groupOptions"
|
|
:classes="{
|
|
outer: 'mb-0',
|
|
input: 'min-w-[140px] text-sm'
|
|
}"
|
|
/>
|
|
</div>
|
|
|
|
<RsButton @click="clearFilters" variant="secondary" size="sm" :disabled="loading" v-if="hasActiveFilters">
|
|
<Icon name="material-symbols:filter-alt-off" class="mr-1" />
|
|
Clear Filters
|
|
</RsButton>
|
|
|
|
<RsButton @click="loadForms" variant="tertiary" size="sm" :disabled="loading">
|
|
<Icon name="material-symbols:refresh" class="mr-1" />
|
|
Refresh
|
|
</RsButton>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading State -->
|
|
<div v-if="loading && formStore.savedForms.length === 0" class="flex justify-center items-center py-12">
|
|
<div class="text-center">
|
|
<Icon name="material-symbols:progress-activity" class="w-8 h-8 animate-spin text-blue-500 mx-auto mb-2" />
|
|
<p class="text-gray-500">Loading forms...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Forms Table -->
|
|
<div v-else class="bg-white shadow-md rounded-lg overflow-hidden">
|
|
<table class="w-full table-auto">
|
|
<thead class="bg-gray-50 border-b">
|
|
<tr>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Category</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Group</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Updated</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200">
|
|
<tr v-if="filteredForms.length === 0">
|
|
<td colspan="7" class="px-6 py-12 text-center text-gray-500">
|
|
<div class="flex flex-col items-center">
|
|
<Icon name="material-symbols:description-outline" class="w-12 h-12 text-gray-300 mb-2" />
|
|
<p class="text-lg font-medium mb-1">
|
|
{{ searchQuery ? 'No forms match your search' : 'No forms found' }}
|
|
</p>
|
|
<p class="text-sm mb-2">
|
|
{{ searchQuery ? 'Try adjusting your search terms' : 'Create your first form to get started' }}
|
|
</p>
|
|
|
|
<!-- Show current search if any -->
|
|
<div v-if="searchQuery" class="text-xs text-gray-400 mb-4">
|
|
<div>Search: "{{ searchQuery }}"</div>
|
|
</div>
|
|
|
|
<div class="flex gap-2">
|
|
<RsButton v-if="hasActiveFilters" @click="clearFilters" variant="secondary" size="sm">
|
|
<Icon name="material-symbols:filter-alt-off" class="mr-1" />
|
|
Clear Filters
|
|
</RsButton>
|
|
<RsButton v-if="!hasActiveFilters" @click="createNewForm" variant="primary" size="sm">
|
|
<Icon name="material-symbols:add" class="mr-1" />
|
|
Create New Form
|
|
</RsButton>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr v-for="form in filteredForms" :key="form.id" class="hover:bg-gray-50">
|
|
<td class="px-6 py-4 whitespace-nowrap">
|
|
<div class="font-medium text-gray-900">{{ form.name || 'Untitled Form' }}</div>
|
|
<div class="text-sm text-gray-500">ID: {{ form.id }}</div>
|
|
</td>
|
|
<td class="px-6 py-4">
|
|
<div class="text-sm text-gray-700 max-w-xs">
|
|
<div
|
|
v-if="form.description && form.description.trim()"
|
|
class="line-clamp-2 cursor-help"
|
|
:title="form.description.length > 100 ? form.description : ''"
|
|
>
|
|
{{ form.description }}
|
|
</div>
|
|
<div v-else class="text-gray-400 italic">
|
|
No description
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap">
|
|
<div v-if="form.category" class="text-sm">
|
|
<RsBadge :variant="getCategoryColor(form.category)" size="sm">
|
|
{{ form.category }}
|
|
</RsBadge>
|
|
</div>
|
|
<div v-else class="text-sm text-gray-400 italic">No category</div>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap">
|
|
<div v-if="form.group" class="text-sm">
|
|
<RsBadge variant="secondary" size="sm">
|
|
{{ form.group }}
|
|
</RsBadge>
|
|
</div>
|
|
<div v-else class="text-sm text-gray-400 italic">No group</div>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap">
|
|
<div class="text-sm text-gray-500">{{ formatDate(form.createdAt) }}</div>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap">
|
|
<div class="text-sm text-gray-500">{{ formatDate(form.updatedAt) }}</div>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
<div class="flex space-x-2 justify-end">
|
|
<button
|
|
@click="editForm(form.id)"
|
|
class="p-1 text-blue-600 hover:text-blue-900 hover:bg-blue-50 rounded"
|
|
title="Edit Form"
|
|
:disabled="loading"
|
|
>
|
|
<Icon name="material-symbols:edit" class="text-lg" />
|
|
</button>
|
|
|
|
<button
|
|
@click="duplicateForm(form)"
|
|
class="p-1 text-indigo-600 hover:text-indigo-900 hover:bg-indigo-50 rounded"
|
|
title="Duplicate Form"
|
|
:disabled="loading"
|
|
>
|
|
<Icon name="material-symbols:content-copy" class="text-lg" />
|
|
</button>
|
|
|
|
<button
|
|
@click="exportForm(form)"
|
|
class="p-1 text-green-600 hover:text-green-900 hover:bg-green-50 rounded"
|
|
title="Export Form as JSON"
|
|
:disabled="loading"
|
|
>
|
|
<Icon name="material-symbols:download" class="text-lg" />
|
|
</button>
|
|
|
|
<button
|
|
@click="confirmDelete(form.id)"
|
|
class="p-1 text-red-600 hover:text-red-900 hover:bg-red-50 rounded"
|
|
title="Delete Form"
|
|
:disabled="loading"
|
|
>
|
|
<Icon name="material-symbols:delete" class="text-lg" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Global Loading Overlay -->
|
|
<div v-if="loading && formStore.savedForms.length > 0" class="fixed inset-0 bg-black bg-opacity-20 flex items-center justify-center z-50">
|
|
<div class="bg-white rounded-lg p-6 flex items-center space-x-3">
|
|
<Icon name="material-symbols:progress-activity" class="w-6 h-6 animate-spin text-blue-500" />
|
|
<span class="text-gray-700">Processing...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Unsaved Changes Modal -->
|
|
<RsModal v-model="showUnsavedChangesModal" title="Unsaved Changes" size="md" position="center">
|
|
<div class="p-4">
|
|
<div class="flex items-center mb-4">
|
|
<Icon name="material-symbols:warning-outline" class="text-yellow-500 w-8 h-8 mr-3 flex-shrink-0" />
|
|
<div>
|
|
<p class="text-gray-600 font-medium mb-1">You have unsaved changes</p>
|
|
<p class="text-gray-600 text-sm">
|
|
Are you sure you want to create a new form? Your unsaved changes will be lost.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<template #footer>
|
|
<div class="flex justify-end gap-2">
|
|
<RsButton @click="cancelNavigation" variant="tertiary" :disabled="loading">
|
|
Cancel
|
|
</RsButton>
|
|
<RsButton @click="confirmNavigation" variant="danger" :disabled="loading">
|
|
Create New Form
|
|
</RsButton>
|
|
</div>
|
|
</template>
|
|
</RsModal>
|
|
|
|
<!-- Delete confirmation dialog -->
|
|
<RsModal v-model="showDeleteConfirm" title="Confirm Delete" size="md" position="center">
|
|
<div class="p-4">
|
|
<div class="flex items-center mb-4">
|
|
<Icon name="material-symbols:warning-outline" class="text-yellow-500 w-8 h-8 mr-3 flex-shrink-0" />
|
|
<div>
|
|
<p class="text-gray-600 font-medium mb-1">Delete Form</p>
|
|
<p class="text-gray-600 text-sm">
|
|
Are you sure you want to delete this form? This action cannot be undone and will permanently remove all form data.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<template #footer>
|
|
<div class="flex justify-end gap-2">
|
|
<RsButton @click="cancelDelete" variant="tertiary" :disabled="loading">
|
|
Cancel
|
|
</RsButton>
|
|
<RsButton @click="deleteForm" variant="danger" :disabled="loading">
|
|
<Icon v-if="loading" name="material-symbols:progress-activity" class="w-4 h-4 animate-spin mr-1" />
|
|
Delete
|
|
</RsButton>
|
|
</div>
|
|
</template>
|
|
</RsModal>
|
|
|
|
<!-- Import Form Modal -->
|
|
<RsModal v-model="showImportModal" title="Import Form from JSON" size="lg" position="center">
|
|
<div class="p-4">
|
|
<div class="mb-4">
|
|
<p class="text-gray-600 mb-4">
|
|
Import a form by uploading a JSON file or pasting JSON content directly.
|
|
The form will be created as a new form in your database.
|
|
</p>
|
|
|
|
<!-- File Upload Option -->
|
|
<div class="mb-6">
|
|
<h3 class="text-sm font-medium text-gray-700 mb-2">Option 1: Upload JSON File</h3>
|
|
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
|
|
<input
|
|
type="file"
|
|
ref="modalJsonFileInput"
|
|
accept=".json"
|
|
style="display: none"
|
|
@change="handleModalJsonImport"
|
|
/>
|
|
<Icon name="material-symbols:upload-file" class="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
|
<p class="text-sm text-gray-600 mb-3">Click to select a JSON file</p>
|
|
<RsButton @click="$refs.modalJsonFileInput.click()" variant="secondary" size="sm">
|
|
<Icon name="material-symbols:upload" class="mr-1" />
|
|
Select JSON File
|
|
</RsButton>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Paste JSON Option -->
|
|
<div class="mb-4">
|
|
<h3 class="text-sm font-medium text-gray-700 mb-2">Option 2: Paste JSON Content</h3>
|
|
<FormKit
|
|
v-model="jsonContent"
|
|
type="textarea"
|
|
placeholder="Paste your form JSON here..."
|
|
rows="10"
|
|
:classes="{
|
|
outer: 'mb-0',
|
|
input: 'font-mono text-sm'
|
|
}"
|
|
/>
|
|
</div>
|
|
|
|
<!-- JSON Validation Status -->
|
|
<div v-if="jsonValidationMessage" class="mb-4">
|
|
<div
|
|
class="p-3 rounded border text-sm"
|
|
:class="{
|
|
'bg-green-50 border-green-200 text-green-700': jsonIsValid,
|
|
'bg-red-50 border-red-200 text-red-700': !jsonIsValid
|
|
}"
|
|
>
|
|
<div class="flex items-center">
|
|
<Icon
|
|
:name="jsonIsValid ? 'material-symbols:check-circle' : 'material-symbols:error'"
|
|
class="w-4 h-4 mr-2"
|
|
/>
|
|
{{ jsonValidationMessage }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Documentation Link -->
|
|
<div class="bg-blue-50 border border-blue-200 rounded p-3">
|
|
<div class="flex items-start">
|
|
<Icon name="material-symbols:info" class="w-4 h-4 text-blue-600 mr-2 mt-0.5" />
|
|
<div class="text-sm text-blue-700">
|
|
<strong>Need help with the JSON format?</strong>
|
|
<br>
|
|
Check the complete documentation for the form JSON structure and examples in the project documentation.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<template #footer>
|
|
<div class="flex justify-end gap-2">
|
|
<RsButton @click="closeImportModal" variant="tertiary" :disabled="loading">
|
|
Cancel
|
|
</RsButton>
|
|
<RsButton
|
|
@click="importFromPastedJson"
|
|
variant="primary"
|
|
:disabled="loading || !jsonContent || !jsonIsValid"
|
|
>
|
|
<Icon v-if="loading" name="material-symbols:progress-activity" class="w-4 h-4 animate-spin mr-1" />
|
|
Import Form
|
|
</RsButton>
|
|
</div>
|
|
</template>
|
|
</RsModal>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, watch, onUnmounted } from 'vue';
|
|
import { useFormBuilderStore } from '~/stores/formBuilder';
|
|
import { useRouter } from 'vue-router';
|
|
|
|
// Define page meta
|
|
definePageMeta({
|
|
title: "Form Management",
|
|
description: "Manage your forms",
|
|
layout: "empty",
|
|
middleware: ["auth"],
|
|
requiresAuth: true,
|
|
});
|
|
|
|
// Initialize the store and router
|
|
const formStore = useFormBuilderStore();
|
|
const router = useRouter();
|
|
|
|
// Initialize toast with fallback
|
|
let toast;
|
|
try {
|
|
toast = useToast();
|
|
} catch (error) {
|
|
// Create a simple toast object if composable is not available
|
|
toast = {
|
|
success: (msg) => console.log('Success:', msg),
|
|
error: (msg) => console.error('Error:', msg),
|
|
info: (msg) => console.info('Info:', msg),
|
|
warning: (msg) => console.warn('Warning:', msg)
|
|
};
|
|
}
|
|
|
|
// State
|
|
const searchQuery = ref('');
|
|
const selectedCategory = ref('');
|
|
const selectedGroup = ref('');
|
|
const loading = ref(false);
|
|
const showDeleteConfirm = ref(false);
|
|
const formToDelete = ref(null);
|
|
const sortBy = ref('createdAt');
|
|
const sortOrder = ref('desc');
|
|
const showUnsavedChangesModal = ref(false);
|
|
const jsonFileInput = ref(null);
|
|
const showImportModal = ref(false);
|
|
const modalJsonFileInput = ref(null);
|
|
const jsonContent = ref('');
|
|
const jsonValidationMessage = ref('');
|
|
const jsonIsValid = ref(false);
|
|
|
|
// Computed properties for grouping
|
|
const categoryOptions = computed(() => {
|
|
const categories = [...new Set(formStore.savedForms.map(form => form.category).filter(Boolean))];
|
|
return [
|
|
{ label: 'All Categories', value: '' },
|
|
...categories.map(cat => ({ label: cat, value: cat }))
|
|
];
|
|
});
|
|
|
|
const groupOptions = computed(() => {
|
|
const groups = [...new Set(formStore.savedForms.map(form => form.group).filter(Boolean))];
|
|
return [
|
|
{ label: 'All Groups', value: '' },
|
|
...groups.map(group => ({ label: group, value: group }))
|
|
];
|
|
});
|
|
|
|
const hasActiveFilters = computed(() => {
|
|
return searchQuery.value || selectedCategory.value || selectedGroup.value;
|
|
});
|
|
|
|
// Filtered forms
|
|
const filteredForms = computed(() => {
|
|
let filtered = formStore.savedForms;
|
|
|
|
// Filter by search query
|
|
if (searchQuery.value) {
|
|
const query = searchQuery.value.toLowerCase();
|
|
filtered = filtered.filter(
|
|
form =>
|
|
(form.name && form.name.toLowerCase().includes(query)) ||
|
|
(form.description && form.description.toLowerCase().includes(query))
|
|
);
|
|
}
|
|
|
|
// Filter by category
|
|
if (selectedCategory.value) {
|
|
filtered = filtered.filter(form => form.category === selectedCategory.value);
|
|
}
|
|
|
|
// Filter by group
|
|
if (selectedGroup.value) {
|
|
filtered = filtered.filter(form => form.group === selectedGroup.value);
|
|
}
|
|
|
|
return filtered;
|
|
});
|
|
|
|
// Format date for display
|
|
const formatDate = (isoString) => {
|
|
if (!isoString) return '';
|
|
|
|
const date = new Date(isoString);
|
|
return new Intl.DateTimeFormat('en-US', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
minute: 'numeric'
|
|
}).format(date);
|
|
};
|
|
|
|
// Load forms from API
|
|
const loadForms = async () => {
|
|
loading.value = true;
|
|
try {
|
|
await formStore.loadSavedForms();
|
|
} catch (error) {
|
|
console.error('Error loading forms:', error);
|
|
toast.error('Failed to load forms: ' + (error.message || 'Unknown error'));
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
// Edit a form
|
|
const editForm = async (formId) => {
|
|
try {
|
|
// Navigate to form builder with the form ID as a query parameter
|
|
router.push(`/form-builder?id=${formId}`);
|
|
} catch (error) {
|
|
console.error('Error navigating to form:', error);
|
|
toast.error('Failed to open form');
|
|
}
|
|
};
|
|
|
|
// Duplicate a form
|
|
const duplicateForm = async (form) => {
|
|
try {
|
|
loading.value = true;
|
|
const newName = `${form.name || 'Form'} (Copy)`;
|
|
|
|
// Create a copy of the form
|
|
const duplicatedForm = {
|
|
...form,
|
|
id: null, // Will get new ID when saved
|
|
name: newName,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString()
|
|
};
|
|
|
|
// Load the original form, duplicate it, and save as new
|
|
await formStore.loadForm(form.id);
|
|
formStore.setFormName(newName);
|
|
await formStore.saveForm();
|
|
|
|
// Refresh the form list
|
|
await loadForms();
|
|
|
|
toast.success(`Form "${newName}" duplicated successfully`);
|
|
} catch (error) {
|
|
console.error('Error duplicating form:', error);
|
|
toast.error('Failed to duplicate form: ' + (error.message || 'Unknown error'));
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
// Delete a form
|
|
const deleteForm = async () => {
|
|
if (!formToDelete.value) return;
|
|
|
|
try {
|
|
loading.value = true;
|
|
await formStore.deleteForm(formToDelete.value);
|
|
|
|
// Refresh the form list
|
|
await loadForms();
|
|
|
|
showDeleteConfirm.value = false;
|
|
formToDelete.value = null;
|
|
|
|
toast.success('Form deleted successfully');
|
|
} catch (error) {
|
|
console.error('Error deleting form:', error);
|
|
toast.error('Failed to delete form: ' + (error.message || 'Unknown error'));
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
// Show delete confirmation
|
|
const confirmDelete = (formId) => {
|
|
formToDelete.value = formId;
|
|
showDeleteConfirm.value = true;
|
|
};
|
|
|
|
// Close delete confirmation
|
|
const cancelDelete = () => {
|
|
showDeleteConfirm.value = false;
|
|
formToDelete.value = null;
|
|
};
|
|
|
|
// Create a new form
|
|
const createNewForm = () => {
|
|
if (formStore.hasUnsavedChanges) {
|
|
showUnsavedChangesModal.value = true;
|
|
} else {
|
|
// Clear current form to start fresh
|
|
formStore.clearForm();
|
|
router.push('/form-builder');
|
|
}
|
|
};
|
|
|
|
// Navigation handlers for unsaved changes
|
|
const cancelNavigation = () => {
|
|
showUnsavedChangesModal.value = false;
|
|
};
|
|
|
|
const confirmNavigation = () => {
|
|
showUnsavedChangesModal.value = false;
|
|
formStore.clearForm();
|
|
router.push('/form-builder');
|
|
};
|
|
|
|
// Go back to main dashboard
|
|
const goToDashboard = () => {
|
|
router.push('/');
|
|
};
|
|
|
|
// Load forms on component mount
|
|
onMounted(async () => {
|
|
await loadForms();
|
|
});
|
|
|
|
// Watch for changes in search and filters
|
|
watch([searchQuery, selectedCategory, selectedGroup], () => {
|
|
// Debounce the search to avoid too many API calls
|
|
clearTimeout(searchTimeout);
|
|
searchTimeout = setTimeout(() => {
|
|
loadForms();
|
|
}, 500);
|
|
});
|
|
|
|
let searchTimeout = null;
|
|
|
|
// Clear all filters
|
|
const clearFilters = () => {
|
|
searchQuery.value = '';
|
|
selectedCategory.value = '';
|
|
selectedGroup.value = '';
|
|
// loadForms will be called automatically by the watcher
|
|
};
|
|
|
|
// Helper function to get category color
|
|
const getCategoryColor = (category) => {
|
|
const colors = {
|
|
'forms': 'primary',
|
|
'surveys': 'success',
|
|
'applications': 'info',
|
|
'feedback': 'warning',
|
|
'registration': 'danger',
|
|
'default': 'secondary'
|
|
};
|
|
return colors[category?.toLowerCase()] || colors.default;
|
|
};
|
|
|
|
// Clean up the search timeout on component unmount
|
|
onUnmounted(() => {
|
|
clearTimeout(searchTimeout);
|
|
});
|
|
|
|
// Helper function to get default category for component type
|
|
const getDefaultCategory = (type) => {
|
|
const categories = {
|
|
'text': 'Basic Inputs',
|
|
'textarea': 'Basic Inputs',
|
|
'number': 'Basic Inputs',
|
|
'email': 'Basic Inputs',
|
|
'password': 'Basic Inputs',
|
|
'select': 'Selection Inputs',
|
|
'checkbox': 'Selection Inputs',
|
|
'radio': 'Selection Inputs',
|
|
'heading': 'Layout',
|
|
'paragraph': 'Layout',
|
|
'divider': 'Layout',
|
|
'file': 'Advanced',
|
|
'button': 'Advanced'
|
|
};
|
|
return categories[type] || 'Basic Inputs';
|
|
};
|
|
|
|
// Helper function to get default icon for component type
|
|
const getDefaultIcon = (type) => {
|
|
const icons = {
|
|
'text': 'material-symbols:text-fields',
|
|
'textarea': 'material-symbols:article-outline',
|
|
'number': 'material-symbols:counter-1-outline',
|
|
'email': 'material-symbols:mail-outline',
|
|
'select': 'material-symbols:arrow-drop-down-circle-outline',
|
|
'checkbox': 'material-symbols:check-box-outline',
|
|
'radio': 'material-symbols:radio-button-checked-outline',
|
|
'heading': 'material-symbols:title',
|
|
'paragraph': 'material-symbols:text-snippet-outline',
|
|
'file': 'material-symbols:upload-file-outline',
|
|
'button': 'material-symbols:smart-button'
|
|
};
|
|
return icons[type] || 'material-symbols:add';
|
|
};
|
|
|
|
// Handle JSON file import
|
|
const handleJsonImport = async (event) => {
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
|
|
loading.value = true;
|
|
|
|
try {
|
|
const reader = new FileReader();
|
|
reader.onload = async (e) => {
|
|
try {
|
|
const importedJson = JSON.parse(e.target.result);
|
|
|
|
// Validate imported JSON structure
|
|
if (!importedJson.formName || !Array.isArray(importedJson.components)) {
|
|
throw new Error('Invalid form JSON structure. Must include formName and components array.');
|
|
}
|
|
|
|
// Create the form in the database using the import API
|
|
const response = await fetch('/api/forms/import', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(importedJson)
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
// Refresh the form list
|
|
await loadForms();
|
|
toast.success(`Form "${importedJson.formName}" imported successfully`);
|
|
} else {
|
|
throw new Error(result.error || 'Failed to import form');
|
|
}
|
|
} catch (err) {
|
|
console.error('Error processing imported JSON:', err);
|
|
toast.error(`Failed to import form: ${err.message}`);
|
|
} finally {
|
|
loading.value = false;
|
|
// Reset file input
|
|
event.target.value = '';
|
|
}
|
|
};
|
|
|
|
reader.onerror = () => {
|
|
toast.error('Error reading file');
|
|
loading.value = false;
|
|
event.target.value = '';
|
|
};
|
|
|
|
reader.readAsText(file);
|
|
} catch (error) {
|
|
console.error('Error importing form:', error);
|
|
toast.error('Failed to import form: ' + error.message);
|
|
loading.value = false;
|
|
event.target.value = '';
|
|
}
|
|
};
|
|
|
|
// Export form as JSON
|
|
const exportForm = async (form) => {
|
|
try {
|
|
loading.value = true;
|
|
|
|
// Fetch the complete form data
|
|
const response = await fetch(`/api/forms/${form.id}`);
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
const formData = result.form;
|
|
|
|
// Create the export JSON structure
|
|
const exportJson = {
|
|
formName: formData.formName,
|
|
formDescription: formData.formDescription || '',
|
|
formId: null, // Set to null for import as new form
|
|
components: formData.formComponents || [],
|
|
customScript: formData.customScript || '',
|
|
customCSS: formData.customCSS || '',
|
|
formEvents: formData.formEvents || {
|
|
onLoad: false,
|
|
onFieldChange: false,
|
|
onSubmit: false,
|
|
onValidation: false
|
|
},
|
|
scriptMode: formData.scriptMode || 'safe'
|
|
};
|
|
|
|
// Create and download the JSON file
|
|
const jsonBlob = new Blob([JSON.stringify(exportJson, null, 2)], {
|
|
type: 'application/json'
|
|
});
|
|
const url = URL.createObjectURL(jsonBlob);
|
|
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = `${formData.formName.replace(/\s+/g, '-').toLowerCase()}_${new Date().toISOString().split('T')[0]}.json`;
|
|
link.click();
|
|
|
|
URL.revokeObjectURL(url);
|
|
toast.success(`Form "${formData.formName}" exported successfully`);
|
|
} else {
|
|
throw new Error(result.error || 'Failed to fetch form data');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error exporting form:', error);
|
|
toast.error(`Failed to export form: ${error.message || 'Unknown error'}`);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
// Watch for JSON content changes to validate
|
|
watch(jsonContent, (newContent) => {
|
|
if (!newContent.trim()) {
|
|
jsonValidationMessage.value = '';
|
|
jsonIsValid.value = false;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(newContent);
|
|
|
|
// Validate basic structure
|
|
if (!parsed.formName) {
|
|
jsonValidationMessage.value = 'JSON is valid but missing required "formName" field';
|
|
jsonIsValid.value = false;
|
|
return;
|
|
}
|
|
|
|
if (!Array.isArray(parsed.components)) {
|
|
jsonValidationMessage.value = 'JSON is valid but missing required "components" array';
|
|
jsonIsValid.value = false;
|
|
return;
|
|
}
|
|
|
|
jsonValidationMessage.value = `Valid form JSON with ${parsed.components.length} components`;
|
|
jsonIsValid.value = true;
|
|
} catch (error) {
|
|
jsonValidationMessage.value = `Invalid JSON: ${error.message}`;
|
|
jsonIsValid.value = false;
|
|
}
|
|
});
|
|
|
|
// Handle modal JSON file import
|
|
const handleModalJsonImport = async (event) => {
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
|
|
try {
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
jsonContent.value = e.target.result;
|
|
// Reset file input
|
|
event.target.value = '';
|
|
};
|
|
reader.readAsText(file);
|
|
} catch (error) {
|
|
toast.error('Error reading file: ' + error.message);
|
|
event.target.value = '';
|
|
}
|
|
};
|
|
|
|
// Import from pasted JSON content
|
|
const importFromPastedJson = async () => {
|
|
if (!jsonContent.value || !jsonIsValid.value) {
|
|
toast.error('Please provide valid JSON content');
|
|
return;
|
|
}
|
|
|
|
loading.value = true;
|
|
|
|
try {
|
|
const importedJson = JSON.parse(jsonContent.value);
|
|
|
|
// Create the form in the database using the import API
|
|
const response = await fetch('/api/forms/import', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(importedJson)
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
// Refresh the form list
|
|
await loadForms();
|
|
toast.success(`Form "${importedJson.formName}" imported successfully`);
|
|
closeImportModal();
|
|
} else {
|
|
throw new Error(result.error || 'Failed to import form');
|
|
}
|
|
} catch (err) {
|
|
console.error('Error importing form:', err);
|
|
toast.error(`Failed to import form: ${err.message}`);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
// Close import modal and reset state
|
|
const closeImportModal = () => {
|
|
showImportModal.value = false;
|
|
jsonContent.value = '';
|
|
jsonValidationMessage.value = '';
|
|
jsonIsValid.value = false;
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Custom styles for better UX */
|
|
.hover\:bg-gray-50:hover {
|
|
transition: background-color 0.15s ease-in-out;
|
|
}
|
|
|
|
button:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* Line clamp utility for description text */
|
|
.line-clamp-2 {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
</style>
|