corrad-bp/components/RepeatingTable.vue
Afiq cf3a2c1a58 Add Repeating Table Component and Enhance Form Builder Functionality
- Introduced a new RepeatingTable component for structured data collection, allowing users to add, edit, and delete records dynamically.
- Enhanced FormBuilderComponents to include the RepeatingTable in the available components list with default properties.
- Updated FormBuilderFieldSettingsModal to support configuration options for the RepeatingTable, including button texts, record limits, and display settings.
- Implemented data management functions for updating table data and handling dynamic list items in the FormBuilder.
- Improved ComponentPreview to render the RepeatingTable component correctly in preview mode.
- Enhanced user experience with intuitive UI elements and responsive design for the new table component.
- Updated documentation to reflect the addition of the RepeatingTable and its configuration options.
2025-05-31 10:45:56 +08:00

1015 lines
25 KiB
Vue

<template>
<div class="repeating-table-container">
<!-- Header Section -->
<div class="repeating-table-header">
<div class="header-content">
<div class="header-left">
<h3 v-if="config.label" class="table-title">{{ config.label }}</h3>
<p v-if="config.help" class="table-help">{{ config.help }}</p>
</div>
<div v-if="showRecordCount" class="header-right">
<div class="record-count">
{{ data.length }} record{{ data.length !== 1 ? 's' : '' }}
<span v-if="config.maxRecords" class="record-limit">/ {{ config.maxRecords }} max</span>
</div>
</div>
</div>
</div>
<!-- Controls Section -->
<div v-if="config.enableSearch || (!isAddDisabled && data.length > 0)" class="controls-section">
<!-- Search Bar -->
<div v-if="config.enableSearch" class="search-container">
<div class="search-input-wrapper">
<Icon name="heroicons:magnifying-glass" class="search-icon" />
<input
v-model="searchQuery"
type="text"
placeholder="Search records..."
class="search-input"
/>
<RsButton
v-if="searchQuery"
@click="searchQuery = ''"
variant="secondary-text"
size="sm"
title="Clear search"
>
<Icon name="heroicons:x-mark" class="w-4 h-4" />
</RsButton>
</div>
</div>
<!-- Add Button (only show when there are existing records) -->
<RsButton
v-if="data.length > 0"
@click="openAddModal"
:disabled="isAddDisabled"
variant="primary"
size="sm"
>
<Icon name="heroicons:plus" class="w-4 h-4 mr-2" />
{{ config.buttonText || 'Add Record' }}
</RsButton>
</div>
<!-- Data Table -->
<div class="table-container">
<div v-if="paginatedData.length > 0" class="table-wrapper">
<div class="table-content">
<table class="data-table">
<thead class="table-header-row">
<tr>
<th v-if="config.showRowNumbers" class="row-number-header">#</th>
<th
v-for="column in config.columns"
:key="column.name"
class="column-header"
>
<div class="column-header-content">
<span class="header-text">{{ column.label }}</span>
<span v-if="column.required" class="required-indicator">*</span>
</div>
</th>
<th v-if="showActions" class="actions-header">Actions</th>
</tr>
</thead>
<tbody class="table-body">
<tr
v-for="(record, index) in paginatedData"
:key="index"
class="data-row"
:class="{ 'row-hover': !isPreview }"
>
<td v-if="config.showRowNumbers" class="row-number-cell">
<div class="row-number">{{ getRowNumber(index) }}</div>
</td>
<td
v-for="column in config.columns"
:key="column.name"
class="data-cell"
>
<div class="cell-content">
<span class="cell-value">{{ formatCellValue(record[column.name], column) }}</span>
</div>
</td>
<td v-if="showActions" class="actions-cell">
<div class="action-buttons-row">
<RsButton
v-if="config.allowEdit"
@click="openEditModal(record, getOriginalIndex(record))"
variant="info-text"
size="sm"
title="Edit record"
>
<Icon name="heroicons:pencil-square" class="w-4 h-4" />
</RsButton>
<RsButton
v-if="config.allowDelete"
@click="deleteRecord(getOriginalIndex(record))"
variant="danger-text"
size="sm"
title="Delete record"
>
<Icon name="heroicons:trash" class="w-4 h-4" />
</RsButton>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Empty State -->
<div v-else class="empty-state">
<div class="empty-content">
<div class="empty-icon-container">
<Icon name="heroicons:table-cells" class="empty-icon" />
</div>
<div class="empty-text">
<h4 class="empty-title">No records yet</h4>
<p class="empty-description">
{{ searchQuery ? 'No records match your search criteria.' : 'Click "' + (config.buttonText || 'Add Record') + '" to add your first record.' }}
</p>
</div>
<RsButton
v-if="!searchQuery && !isAddDisabled"
@click="openAddModal"
variant="primary"
size="md"
>
<Icon name="heroicons:plus" class="w-4 h-4 mr-2" />
{{ config.buttonText || 'Add Record' }}
</RsButton>
</div>
</div>
</div>
<!-- Pagination -->
<div v-if="filteredData.length > 0 && totalPages > 1" class="pagination-container">
<div class="pagination-info">
<span class="pagination-text">
Showing {{ startRecord }} to {{ endRecord }} of {{ filteredData.length }} records
</span>
</div>
<div class="pagination-controls">
<div class="page-size-selector">
<label class="page-size-label">Show:</label>
<select v-model="pageSize" @change="currentPage = 1" class="page-size-select">
<option value="5">5</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
</select>
<span class="page-size-suffix">per page</span>
</div>
<div class="page-navigation">
<RsButton
@click="goToPage(1)"
:disabled="currentPage === 1"
variant="secondary-outline"
size="sm"
title="First page"
>
<Icon name="heroicons:chevron-double-left" class="w-4 h-4" />
</RsButton>
<RsButton
@click="goToPage(currentPage - 1)"
:disabled="currentPage === 1"
variant="secondary-outline"
size="sm"
title="Previous page"
>
<Icon name="heroicons:chevron-left" class="w-4 h-4" />
</RsButton>
<div class="page-numbers">
<RsButton
v-for="page in visiblePages"
:key="page"
@click="goToPage(page)"
:variant="page === currentPage ? 'primary' : 'secondary-outline'"
size="sm"
>
{{ page }}
</RsButton>
</div>
<RsButton
@click="goToPage(currentPage + 1)"
:disabled="currentPage === totalPages"
variant="secondary-outline"
size="sm"
title="Next page"
>
<Icon name="heroicons:chevron-right" class="w-4 h-4" />
</RsButton>
<RsButton
@click="goToPage(totalPages)"
:disabled="currentPage === totalPages"
variant="secondary-outline"
size="sm"
title="Last page"
>
<Icon name="heroicons:chevron-double-right" class="w-4 h-4" />
</RsButton>
</div>
</div>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal-container">
<div class="modal-header">
<div class="modal-title-section">
<h3 class="modal-title">
{{ editingIndex !== null ? 'Edit Record' : 'Add New Record' }}
</h3>
</div>
<RsButton @click="closeModal" variant="secondary-text" size="sm">
<Icon name="heroicons:x-mark" class="w-6 h-6" />
</RsButton>
</div>
<div class="modal-body">
<FormKit
:id="formId"
type="form"
:actions="false"
v-model="formData"
@submit="saveRecord"
class="record-form"
>
<div class="form-grid">
<div
v-for="column in config.columns"
:key="column.name"
class="form-field"
:class="{ 'form-field-full': column.type === 'textarea' }"
>
<FormKit
:type="getFormKitType(column.type)"
:label="column.label"
:name="column.name"
:placeholder="column.placeholder"
:validation="getValidationRules(column)"
:required="column.required"
:options="column.options"
:classes="{ outer: 'field-wrapper' }"
/>
</div>
</div>
</FormKit>
</div>
<div class="modal-footer">
<RsButton @click="closeModal" variant="secondary-outline">
Cancel
</RsButton>
<RsButton @click="submitForm" variant="primary">
<Icon name="heroicons:check" class="w-4 h-4 mr-2" />
{{ editingIndex !== null ? 'Update Record' : 'Add Record' }}
</RsButton>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteConfirm" class="modal-overlay" @click.self="cancelDelete">
<div class="modal-container modal-small">
<div class="modal-header">
<div class="modal-title-section">
<h3 class="modal-title">Confirm Delete</h3>
</div>
<RsButton @click="cancelDelete" variant="secondary-text" size="sm">
<Icon name="heroicons:x-mark" class="w-6 h-6" />
</RsButton>
</div>
<div class="modal-body">
<div class="delete-warning">
<div class="warning-icon-container">
<Icon name="heroicons:exclamation-triangle" class="warning-icon" />
</div>
<div class="warning-content">
<p class="warning-title">Are you sure you want to delete this record?</p>
<p class="warning-message">This action cannot be undone.</p>
</div>
</div>
</div>
<div class="modal-footer">
<RsButton @click="cancelDelete" variant="secondary-outline">
Cancel
</RsButton>
<RsButton @click="confirmDelete" variant="danger">
<Icon name="heroicons:trash" class="w-4 h-4 mr-2" />
Delete Record
</RsButton>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { v4 as uuidv4 } from 'uuid'
const props = defineProps({
config: {
type: Object,
required: true
},
modelValue: {
type: Array,
default: () => []
},
isPreview: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue'])
// Reactive state
const data = ref([...props.modelValue])
const searchQuery = ref('')
const showModal = ref(false)
const showDeleteConfirm = ref(false)
const editingIndex = ref(null)
const deleteIndex = ref(null)
const formData = ref({})
const formId = ref(`table-form-${uuidv4()}`)
// Pagination state
const currentPage = ref(1)
const pageSize = ref(10)
// Computed properties
const filteredData = computed(() => {
if (!searchQuery.value) return data.value
const query = searchQuery.value.toLowerCase()
return data.value.filter(record => {
return props.config.columns.some(column => {
const value = record[column.name]
return value && value.toString().toLowerCase().includes(query)
})
})
})
const isAddDisabled = computed(() => {
return props.config.maxRecords && data.value.length >= props.config.maxRecords
})
const showActions = computed(() => {
return (props.config.allowEdit || props.config.allowDelete) && !props.isPreview
})
const showRecordCount = computed(() => {
return data.value.length > 0 || props.config.maxRecords
})
// Pagination computed properties
const totalPages = computed(() => {
return Math.ceil(filteredData.value.length / pageSize.value)
})
const paginatedData = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filteredData.value.slice(start, end)
})
const startRecord = computed(() => {
if (filteredData.value.length === 0) return 0
return (currentPage.value - 1) * pageSize.value + 1
})
const endRecord = computed(() => {
const end = currentPage.value * pageSize.value
return Math.min(end, filteredData.value.length)
})
const visiblePages = computed(() => {
const delta = 2
const range = []
const rangeWithDots = []
for (let i = Math.max(2, currentPage.value - delta);
i <= Math.min(totalPages.value - 1, currentPage.value + delta);
i++) {
range.push(i)
}
if (currentPage.value - delta > 2) {
rangeWithDots.push(1, '...')
} else {
rangeWithDots.push(1)
}
rangeWithDots.push(...range)
if (currentPage.value + delta < totalPages.value - 1) {
rangeWithDots.push('...', totalPages.value)
} else {
rangeWithDots.push(totalPages.value)
}
return rangeWithDots.filter((item, index, array) => array.indexOf(item) === index)
})
// Watch for external data changes
watch(() => props.modelValue, (newValue) => {
data.value = [...newValue]
}, { deep: true })
// Watch for internal data changes and emit
watch(data, (newData) => {
emit('update:modelValue', [...newData])
}, { deep: true })
// Methods
const openAddModal = () => {
if (props.isPreview) return
editingIndex.value = null
formData.value = {}
// Initialize form data with empty values
props.config.columns.forEach(column => {
formData.value[column.name] = getDefaultValue(column.type)
})
showModal.value = true
}
const openEditModal = (record, index) => {
if (props.isPreview) return
editingIndex.value = index
formData.value = { ...record }
showModal.value = true
}
const closeModal = () => {
showModal.value = false
editingIndex.value = null
formData.value = {}
}
const submitForm = () => {
// Trigger form submission programmatically
const form = document.getElementById(formId.value)
if (form) {
form.requestSubmit()
}
}
const saveRecord = (formData) => {
if (editingIndex.value !== null) {
// Update existing record
data.value[editingIndex.value] = { ...formData }
} else {
// Add new record
data.value.push({ ...formData })
}
closeModal()
}
const deleteRecord = (index) => {
if (props.isPreview) return
if (props.config.confirmDelete) {
deleteIndex.value = index
showDeleteConfirm.value = true
} else {
data.value.splice(index, 1)
}
}
const confirmDelete = () => {
if (deleteIndex.value !== null) {
data.value.splice(deleteIndex.value, 1)
}
cancelDelete()
}
const cancelDelete = () => {
showDeleteConfirm.value = false
deleteIndex.value = null
}
const getOriginalIndex = (record) => {
return data.value.findIndex(item => item === record)
}
const formatCellValue = (value, column) => {
if (value === null || value === undefined || value === '') {
return '-'
}
if (column.type === 'date') {
return new Date(value).toLocaleDateString()
}
if (column.type === 'time') {
return value
}
if (column.type === 'checkbox') {
return value ? 'Yes' : 'No'
}
if (column.type === 'select' && column.options) {
const option = column.options.find(opt => opt.value === value)
return option ? option.label : value
}
return value.toString()
}
const getFormKitType = (columnType) => {
const typeMap = {
text: 'text',
number: 'number',
email: 'email',
tel: 'tel',
date: 'date',
time: 'time',
url: 'url',
select: 'select',
checkbox: 'checkbox',
textarea: 'textarea'
}
return typeMap[columnType] || 'text'
}
const getValidationRules = (column) => {
let rules = []
if (column.required) {
rules.push('required')
}
if (column.validation) {
const customRules = column.validation.split('|').filter(rule => rule.trim())
rules.push(...customRules)
}
return rules.join('|')
}
const getDefaultValue = (type) => {
switch (type) {
case 'number':
return null
case 'checkbox':
return false
case 'date':
case 'time':
return ''
default:
return ''
}
}
const goToPage = (page) => {
currentPage.value = page
}
const getRowNumber = (index) => {
return (currentPage.value - 1) * pageSize.value + index + 1
}
// Watch for search changes to reset pagination
watch(searchQuery, () => {
currentPage.value = 1
})
</script>
<style scoped>
.repeating-table-container {
@apply border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm w-full;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
.repeating-table-header {
@apply p-4 md:p-6 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-gray-200;
min-height: auto;
box-sizing: border-box;
}
.header-content {
/* Mobile first: flex-col, items-center (horizontal centering), gap-2 */
@apply flex flex-col items-center gap-2 w-full;
/* SM and up: flex-row, justify-between, items-baseline (vertical for text), gap-3 */
@apply sm:flex-row sm:justify-between sm:items-baseline sm:gap-3;
}
.header-left {
@apply flex-1 min-w-0; /* Allows shrinking, good for title/help */
}
.table-title {
@apply text-lg font-semibold text-gray-800 leading-tight; /* Ensure it can wrap if very long, though not the current issue */
}
.table-help {
@apply text-sm text-gray-600 break-words leading-relaxed mt-1; /* Spacing below title if help exists */
}
.header-right {
@apply flex items-center flex-shrink-0; /* flex-shrink-0 prevents the badge from shrinking */
}
.record-count {
@apply text-sm text-gray-600 bg-white px-3 py-1 rounded-full border border-gray-200 font-medium whitespace-nowrap;
}
.record-limit {
@apply text-gray-500;
}
.controls-section {
@apply flex flex-col md:flex-row md:justify-between md:items-center gap-3 p-4 bg-gray-50 border-b border-gray-200;
}
.search-container {
@apply flex-1 max-w-md;
}
.search-input-wrapper {
@apply relative;
}
.search-icon {
@apply absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400;
}
.search-input {
@apply w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm;
}
.clear-search {
@apply absolute right-3 top-1/2 transform -translate-y-1/2;
}
.add-button {
@apply inline-flex items-center justify-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium text-sm min-w-max;
}
.add-button.disabled {
@apply bg-gray-400 cursor-not-allowed hover:bg-gray-400;
}
.table-container {
@apply overflow-hidden;
}
.table-wrapper {
@apply overflow-x-auto;
}
.table-content {
@apply mx-4 md:mx-6;
}
.data-table {
@apply w-full divide-y divide-gray-200;
}
.table-header-row {
@apply bg-gray-50;
}
.column-header,
.row-number-header,
.actions-header {
@apply px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider;
}
.row-number-header {
@apply w-16 text-center;
}
.actions-header {
@apply w-32 text-center;
}
.column-header-content {
@apply flex items-center;
}
.header-text {
@apply font-medium text-gray-700;
}
.required-indicator {
@apply text-red-500 ml-1 font-bold;
}
.table-body {
@apply bg-white divide-y divide-gray-200;
}
.data-row {
@apply transition-colors duration-150;
}
.data-row.row-hover:hover {
@apply bg-blue-50;
}
.row-number-cell,
.data-cell,
.actions-cell {
@apply px-6 py-4 whitespace-nowrap;
}
.row-number {
@apply flex items-center justify-center w-8 h-8 bg-gray-100 rounded-full text-sm text-gray-600 font-medium;
}
.cell-content {
@apply flex items-center;
}
.cell-value {
@apply text-sm text-gray-900;
}
.action-buttons-row {
@apply flex justify-center space-x-2;
}
.action-button {
@apply p-2 rounded-md transition-colors duration-150;
}
.edit-button {
@apply text-blue-600 hover:text-blue-800 hover:bg-blue-100;
}
.delete-button {
@apply text-red-600 hover:text-red-800 hover:bg-red-100;
}
.empty-state {
@apply py-16 px-6;
}
.empty-content {
@apply flex flex-col items-center text-center max-w-md mx-auto;
}
.empty-icon-container {
@apply mb-6;
}
.empty-icon {
@apply w-16 h-16 text-gray-300;
}
.empty-text {
@apply mb-6;
}
.empty-title {
@apply text-xl font-medium text-gray-900 mb-2;
}
.empty-description {
@apply text-gray-500 leading-relaxed;
}
.empty-add-button {
@apply inline-flex items-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium;
}
.modal-overlay {
@apply fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm;
}
.modal-container {
@apply bg-white rounded-xl shadow-xl max-w-3xl w-full mx-4 max-h-[90vh] overflow-hidden;
}
.modal-container.modal-small {
@apply max-w-md;
}
.modal-header {
@apply flex justify-between items-start p-6 border-b border-gray-200 bg-gray-50;
}
.modal-title-section {
@apply flex-1 pr-4;
}
.modal-title {
@apply text-xl font-semibold text-gray-800 mb-1;
}
.modal-close {
@apply text-gray-400 hover:text-gray-600 transition-colors p-1 rounded-md hover:bg-gray-200;
}
.modal-body {
@apply p-6 overflow-y-auto max-h-[60vh];
}
.form-grid {
@apply grid grid-cols-1 md:grid-cols-2 gap-6;
}
.form-field {
@apply col-span-1;
}
.form-field-full {
@apply col-span-full;
}
.field-wrapper {
@apply mb-0;
}
.modal-footer {
@apply flex justify-end space-x-3 p-6 border-t border-gray-200 bg-gray-50;
}
.modal-button {
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-150;
}
.cancel-button {
@apply text-gray-700 bg-white border border-gray-300 hover:bg-gray-50;
}
.save-button {
@apply text-white bg-blue-600 hover:bg-blue-700;
}
.delete-warning {
@apply flex items-start space-x-4;
}
.warning-icon-container {
@apply flex-shrink-0;
}
.warning-icon {
@apply w-6 h-6 text-amber-500;
}
.warning-content {
@apply flex-1;
}
.warning-title {
@apply font-medium text-gray-900 mb-2;
}
.warning-message {
@apply text-gray-600 text-sm;
}
.delete-confirm-button {
@apply text-white bg-red-600 hover:bg-red-700;
}
/* Pagination Styles */
.pagination-container {
@apply flex flex-col md:flex-row md:justify-between md:items-center gap-4 p-4 md:p-6 bg-gray-50 border-t border-gray-200;
}
.pagination-info {
@apply flex items-center;
}
.pagination-text {
@apply text-sm text-gray-700 font-medium;
}
.pagination-controls {
@apply flex flex-col sm:flex-row items-center gap-4;
}
.page-size-selector {
@apply flex items-center gap-2 text-sm;
}
.page-size-label {
@apply text-gray-700 font-medium;
}
.page-size-select {
@apply border border-gray-300 rounded-md px-3 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500;
}
.page-size-suffix {
@apply text-gray-600;
}
.page-navigation {
@apply flex items-center gap-1;
}
.pagination-button {
@apply flex items-center justify-center w-8 h-8 text-sm text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 hover:text-gray-700 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-white disabled:hover:text-gray-500 transition-colors;
}
.page-numbers {
@apply flex items-center gap-1 mx-2;
}
.page-number-button {
@apply flex items-center justify-center w-8 h-8 text-sm text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors;
}
.page-number-button.active {
@apply text-white bg-blue-600 border-blue-600 hover:bg-blue-700;
}
/* Responsive design */
@media (max-width: 768px) {
.repeating-table-header {
@apply p-3;
}
.header-content {
@apply flex-col gap-2;
}
.table-title {
@apply text-base font-semibold leading-tight;
}
.table-help {
@apply text-sm leading-relaxed;
}
.header-right {
@apply mt-2;
}
.controls-section {
@apply p-3 gap-3;
}
.table-content {
@apply mx-2;
}
.form-grid {
@apply grid-cols-1;
}
.table-container {
@apply text-sm;
}
.row-number-cell,
.data-cell,
.actions-cell {
@apply px-3 py-3;
}
.column-header,
.row-number-header,
.actions-header {
@apply px-3 py-2;
}
.modal-container {
@apply mx-2;
}
.empty-state {
@apply py-8 px-4;
}
.pagination-container {
@apply p-3 gap-3;
}
.pagination-controls {
@apply flex-col gap-3;
}
.page-navigation {
@apply justify-center;
}
}
</style>