Enhance Form Builder and Management Features

- 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.
This commit is contained in:
Md Afiq Iskandar 2025-08-05 17:14:04 +08:00
parent 8f84b00a9e
commit c43d9b6849
10 changed files with 414 additions and 85 deletions

View File

@ -7,7 +7,7 @@
<!-- Left section - Logo and navigation -->
<div class="flex items-center gap-4">
<Icon
@click="navigateTo('/', { external: true })"
@click="navigateTo('/form-builder/manage')"
name="ph:arrow-circle-left-duotone"
class="cursor-pointer w-6 h-6 hover:text-gray-600 text-gray-500"
/>
@ -91,17 +91,13 @@
<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>
</div>
</div>
</header>
<!-- Main Content Area -->
<div class="flex-1 flex overflow-hidden">
<div v-if="hasCurrentForm" class="flex-1 flex overflow-hidden">
<!-- Left Panel - Components Sidebar -->
<div
v-if="!isPreview"
@ -553,7 +549,63 @@
</div>
</div>
<!-- Empty state - No form selected -->
<div v-else class="flex-1 flex items-center justify-center bg-gray-50">
<div class="text-center p-8 max-w-md">
<Icon name="material-symbols:description" class="w-16 h-16 mx-auto mb-4 text-gray-400" />
<h2 class="text-xl font-semibold text-gray-800 mb-2">Create a New Form</h2>
<p class="text-gray-600 mb-6">Get started by creating a new form or navigate back to manage your existing forms.</p>
<div class="space-y-3">
<div class="mb-4">
<FormKit
v-model="newFormName"
type="text"
label="Form Name"
placeholder="Enter a name for your new form"
validation="required"
/>
<FormKit
v-model="newFormDescription"
type="textarea"
label="Description (Optional)"
placeholder="Enter a description"
:rows="3"
/>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormKit
v-model="newFormCategory"
type="select"
label="Category (Optional)"
placeholder="Select a category"
:options="categoryOptions"
/>
<FormKit
v-model="newFormGroup"
type="text"
label="Group (Optional)"
placeholder="Enter a group name"
/>
</div>
</div>
<div class="flex justify-center gap-4">
<RsButton @click="createNewForm" variant="primary" :disabled="!newFormName.trim()">
<Icon name="material-symbols:add" class="mr-1" />
Create Form
</RsButton>
<RsButton @click="navigateToManage" variant="tertiary">
<Icon name="material-symbols:arrow-back" class="mr-1" />
Back to Forms
</RsButton>
</div>
</div>
</div>
</div>
<!-- Form Settings Modal -->
<RsModal v-model="showFormSettings" title="Form Settings & Scripts" size="xl" position="center">
@ -577,6 +629,23 @@
help="Brief description of what this form is for"
rows="3"
/>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormKit
type="select"
label="Category"
v-model="formStore.formCategory"
:options="categoryOptions"
help="Categorize your form for better organization"
/>
<FormKit
type="text"
label="Group"
v-model="formStore.formGroup"
help="Group related forms together"
/>
</div>
</div>
</template>
@ -1067,6 +1136,13 @@ const router = useRouter();
const formStore = useFormBuilderStore();
let toast;
// Track if we're creating a new form
const isCreatingForm = ref(false);
const newFormName = ref('');
const newFormDescription = ref('');
const newFormCategory = ref('');
const newFormGroup = ref('');
// Try to use the toast composable if available
try {
toast = useToast();
@ -1166,6 +1242,30 @@ const formName = computed({
}
});
// Category options for form creation
const categoryOptions = [
{ label: 'Select Category', value: '' },
{ label: 'Forms', value: 'forms' },
{ label: 'Surveys', value: 'surveys' },
{ label: 'Applications', value: 'applications' },
{ label: 'Feedback', value: 'feedback' },
{ label: 'Registration', value: 'registration' },
{ label: 'Other', value: 'other' }
];
// Computed to check if we have a current form loaded
const hasCurrentForm = computed(() => {
// Consider a form loaded if any of these conditions are true:
// 1. Form has components (user has added fields)
// 2. Form has an ID (saved form)
// 3. Form name has been changed from default (form creation completed)
return formStore.formComponents.length > 0 ||
formStore.currentFormId ||
(formStore.formName &&
formStore.formName.trim() !== '' &&
formStore.formName !== 'New Form');
});
// Form JSON representation for developer view
const formJson = computed(() => {
return {
@ -1705,6 +1805,9 @@ onMounted(async () => {
console.error('Error loading form from ID:', error);
toast.error(`Failed to load form: ${error.message || 'Unknown error'}`);
}
} else {
// No form ID provided, ensure we start with a clean slate
formStore.clearForm();
}
} catch (error) {
console.error('Error initializing form builder:', error);
@ -2199,6 +2302,54 @@ const navigateToManage = () => {
navigationTarget.value = "/form-builder/manage";
};
// Handle creating a new form
const createNewForm = async () => {
if (!newFormName.value.trim()) {
toast.error('Please enter a form name');
return;
}
try {
// Set the form name and description in the store
formStore.setFormName(newFormName.value.trim());
formStore.formDescription = newFormDescription.value.trim();
formStore.formCategory = newFormCategory.value.trim() || null;
formStore.formGroup = newFormGroup.value.trim() || null;
// Clear existing form components to start fresh
formStore.formComponents = [];
formStore.currentFormId = null; // This ensures we create a new form instead of updating existing
// Save the form to the database immediately
const savedForm = await formStore.saveForm();
if (savedForm) {
// Update URL to include the form ID
const newPath = `/form-builder?id=${savedForm.formUUID}`;
window.history.replaceState({}, '', newPath);
// Reset navigation confirmation
navigationConfirmed.value = false;
// Reset form variables
isCreatingForm.value = false;
newFormName.value = '';
newFormDescription.value = '';
newFormCategory.value = '';
newFormGroup.value = '';
// Show success message
toast.success(`Form "${formStore.formName}" created and saved successfully`);
} else {
toast.error('Failed to save form to database. Please try again.');
}
} catch (error) {
console.error('Error creating form:', error);
toast.error(`Failed to create form: ${error.message || 'Unknown error'}`);
}
};
const handleOptimizeLayout = () => {
formStore.optimizeGridLayout();
};

View File

@ -52,7 +52,7 @@
<FormKit
v-model="searchQuery"
type="text"
placeholder="Search forms..."
placeholder="Search forms by name or description..."
:classes="{
outer: 'mb-0',
wrapper: 'relative',
@ -68,7 +68,35 @@
</div>
<div class="flex gap-2">
<RsButton @click="clearFilters" variant="secondary" size="sm" :disabled="loading" v-if="searchQuery">
<!-- 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>
@ -95,7 +123,8 @@
<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">Components</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>
@ -103,7 +132,7 @@
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-if="filteredForms.length === 0">
<td colspan="6" class="px-6 py-12 text-center text-gray-500">
<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">
@ -119,11 +148,11 @@
</div>
<div class="flex gap-2">
<RsButton v-if="searchQuery" @click="clearFilters" variant="secondary" size="sm">
<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="!searchQuery" @click="createNewForm" variant="primary" size="sm">
<RsButton v-if="!hasActiveFilters" @click="createNewForm" variant="primary" size="sm">
<Icon name="material-symbols:add" class="mr-1" />
Create New Form
</RsButton>
@ -137,14 +166,34 @@
<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 truncate">
{{ form.description || 'No description' }}
<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 class="text-sm text-gray-700">
{{ form.components ? form.components.length : 0 }} components
<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>
@ -385,6 +434,8 @@ try {
// State
const searchQuery = ref('');
const selectedCategory = ref('');
const selectedGroup = ref('');
const loading = ref(false);
const showDeleteConfirm = ref(false);
const formToDelete = ref(null);
@ -398,6 +449,27 @@ 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;
@ -412,6 +484,16 @@ const filteredForms = computed(() => {
);
}
// 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;
});
@ -552,8 +634,8 @@ onMounted(async () => {
await loadForms();
});
// Watch for changes in search and reload forms
watch([searchQuery], () => {
// Watch for changes in search and filters
watch([searchQuery, selectedCategory, selectedGroup], () => {
// Debounce the search to avoid too many API calls
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
@ -566,9 +648,24 @@ 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);
@ -835,4 +932,13 @@ 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>

View File

@ -3003,10 +3003,6 @@ const sendToBack = () => {
<Icon name="material-symbols:tune" class="mr-2 w-4 h-4" />
<span>Process Settings</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>
</button>
</div>
</div>
</div>

View File

@ -208,6 +208,28 @@
"null"
]
},
"formCategory": {
"type": [
"string",
"null"
]
},
"formTags": {
"type": [
"number",
"string",
"boolean",
"object",
"array",
"null"
]
},
"formGroup": {
"type": [
"string",
"null"
]
},
"creator": {
"anyOf": [
{

View File

@ -0,0 +1,9 @@
-- Add form grouping fields
ALTER TABLE `form` ADD COLUMN `formCategory` VARCHAR(100) NULL;
ALTER TABLE `form` ADD COLUMN `formTags` JSON NULL;
ALTER TABLE `form` ADD COLUMN `formGroup` VARCHAR(100) NULL;
-- Add indexes for better performance
CREATE INDEX `IDX_form_category` ON `form`(`formCategory`);
CREATE INDEX `IDX_form_group` ON `form`(`formGroup`);
CREATE INDEX `IDX_form_status` ON `form`(`formStatus`);

View File

@ -65,11 +65,17 @@ model form {
formEvents Json?
scriptMode String? @default("safe") @db.VarChar(20)
submitButton Json?
formCategory String? @db.VarChar(100)
formTags Json?
formGroup String? @db.VarChar(100)
creator user? @relation(fields: [formCreatedBy], references: [userID])
formHistory formHistory[]
task task[]
@@index([formCreatedBy], map: "FK_form_creator")
@@index([formCategory], map: "IDX_form_category")
@@index([formGroup], map: "IDX_form_group")
@@index([formStatus], map: "IDX_form_status")
}
model formHistory {

View File

@ -61,7 +61,7 @@ export default defineEventHandler(async (event) => {
submitButton: currentForm.submitButton,
versionNumber: nextVersionNumber,
changeDescription: body.changeDescription || null,
savedBy: body.savedBy || currentForm.formCreatedBy,
savedBy: event.context.user?.userID || currentForm.formCreatedBy,
savedDate: currentForm.formModifiedDate || currentForm.formCreatedDate
}
});
@ -110,6 +110,18 @@ export default defineEventHandler(async (event) => {
updateData.submitButton = body.submitButton;
}
if (body.formCategory !== undefined) {
updateData.formCategory = body.formCategory && body.formCategory.trim() ? body.formCategory.trim() : null;
}
if (body.formTags !== undefined) {
updateData.formTags = body.formTags;
}
if (body.formGroup !== undefined) {
updateData.formGroup = body.formGroup && body.formGroup.trim() ? body.formGroup.trim() : null;
}
// Try to update by UUID first
let form;
try {

View File

@ -25,12 +25,15 @@ export default defineEventHandler(async (event) => {
formDescription: body.formDescription || null,
formComponents: body.components || [],
formStatus: body.status || 'active',
formCreatedBy: body.createdBy || null, // In a real app, this would come from the authenticated user
customScript: body.customScript || null,
customCSS: body.customCSS || null,
formEvents: body.formEvents || null,
scriptMode: body.scriptMode || 'safe',
submitButton: body.submitButton || null
submitButton: body.submitButton || null,
formCategory: body.formCategory && body.formCategory.trim() ? body.formCategory.trim() : null,
formTags: body.formTags || null,
formGroup: body.formGroup && body.formGroup.trim() ? body.formGroup.trim() : null,
formCreatedBy: event.context.user?.userID || undefined,
}
});

View File

@ -21,6 +21,9 @@ export default defineEventHandler(async (event) => {
formStatus: true,
formCreatedDate: true,
formModifiedDate: true,
formCategory: true,
formGroup: true,
formTags: true,
// Don't include the full components data to keep response size small
creator: {
select: {

View File

@ -39,6 +39,11 @@ export const useFormBuilderStore = defineStore('formBuilder', {
// Form history tracking
lastChangeDescription: null,
// Form grouping
formCategory: '',
formTags: [],
formGroup: '',
}),
getters: {
@ -652,6 +657,9 @@ export const useFormBuilderStore = defineStore('formBuilder', {
name: form.formName,
description: form.formDescription || '',
components: form.formComponents || [],
category: form.formCategory || '',
group: form.formGroup || '',
tags: form.formTags || [],
createdAt: form.formCreatedDate,
updatedAt: form.formModifiedDate
}));
@ -679,9 +687,12 @@ export const useFormBuilderStore = defineStore('formBuilder', {
formEvents: this.formEvents,
scriptMode: this.scriptMode,
submitButton: this.submitButton,
// Add user info and change description for history tracking
savedBy: 1, // TODO: Get from authenticated user
changeDescription: this.lastChangeDescription || null
formCategory: this.formCategory,
formTags: this.formTags,
formGroup: this.formGroup,
// Add change description for history tracking
changeDescription: this.lastChangeDescription || null,
// Note: savedBy will be handled by the server using the authenticated user from auth middleware
};
// Determine if this is a new form or an update
@ -760,6 +771,11 @@ export const useFormBuilderStore = defineStore('formBuilder', {
this.formDescription = result.form.formDescription || '';
this.currentFormId = result.form.formUUID;
// Load grouping data
this.formCategory = result.form.formCategory || '';
this.formTags = result.form.formTags || [];
this.formGroup = result.form.formGroup || '';
// Load custom scripts and settings
this.formCustomScript = result.form.customScript || '';
this.formCustomCSS = result.form.customCSS || '';
@ -840,6 +856,11 @@ export const useFormBuilderStore = defineStore('formBuilder', {
this.currentFormId = null;
this.hasUnsavedChanges = false;
// Reset grouping fields
this.formCategory = '';
this.formTags = [];
this.formGroup = '';
// Reset custom scripts and settings
this.formCustomScript = '';
this.formCustomCSS = '';