Enhance Custom Actions Functionality in Form Builder Components

- Updated ComponentPreview.vue to handle custom actions and navigation events, improving interaction capabilities within the component.
- Introduced custom actions management in FormBuilderFieldSettingsModal.vue, allowing users to add, configure, and remove custom action buttons for table rows.
- Enhanced RepeatingTable.vue to display custom action buttons and handle their click and navigation events, providing a more dynamic user experience.
- Modified formBuilder.js to support custom actions in the default properties, ensuring proper initialization and management of custom actions.
This commit is contained in:
Afiq 2025-08-08 11:17:37 +08:00
parent b7d6f42e76
commit 9c6f1ef666
5 changed files with 350 additions and 3 deletions

View File

@ -492,6 +492,8 @@
@update:model-value="updateTableData"
@debug-data="(data) => console.log('[ComponentPreview] Table debug data:', data)"
@mounted="() => console.log('[ComponentPreview] RepeatingTable mounted with data:', getTableData(component.props.name))"
@custom-action="handleTableCustomAction"
@custom-navigation="handleTableCustomNavigation"
/>
</div>
</div>
@ -873,7 +875,7 @@ const props = defineProps({
}
});
const emit = defineEmits(['select-nested-component', 'form-data-updated', 'button-clicked']);
const emit = defineEmits(['select-nested-component', 'form-data-updated', 'button-clicked', 'custom-action', 'custom-navigation']);
// Get access to the form builder store
const formStore = useFormBuilderStore();
@ -2700,6 +2702,20 @@ onMounted(() => {
});
}
});
// Custom Action Handlers for RepeatingTable
const handleTableCustomAction = ({ action, rowData, event }) => {
console.log('[ComponentPreview] Table custom action:', { action, rowData, event })
// Emit to parent if needed
emit('custom-action', { action, rowData, event })
}
const handleTableCustomNavigation = ({ action, url, rowData }) => {
console.log('[ComponentPreview] Table custom navigation:', { action, url, rowData })
// Navigation is already handled by CustomActionButton
// Emit to parent if additional handling is needed
emit('custom-navigation', { action, url, rowData })
}
</script>
<style scoped>

View File

@ -0,0 +1,175 @@
<template>
<RsButton
:variant="getButtonVariant()"
:size="action.size || 'sm'"
:disabled="disabled || loading"
@click="handleClick"
:class="buttonClasses"
:title="action.label"
>
<Icon v-if="loading" name="material-symbols:progress-activity" class="animate-spin w-4 h-4" />
<Icon v-else :name="getButtonIcon()" class="w-4 h-4" />
<span v-if="showLabel" class="ml-1">{{ action.label }}</span>
</RsButton>
</template>
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
action: {
type: Object,
required: true,
validator: (action) => {
return action && typeof action.label === 'string' && typeof action.url === 'string';
}
},
rowData: {
type: Object,
default: () => ({})
},
showLabel: {
type: Boolean,
default: true
},
disabled: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['click', 'navigate']);
const loading = ref(false);
// Computed properties
const buttonClasses = computed(() => {
const classes = ['custom-action-btn'];
return classes.join(' ');
});
// Get button variant - match edit/delete button styling
const getButtonVariant = () => {
const variantMapping = {
'primary': 'info-text',
'secondary': 'info-text',
'success': 'success-text',
'warning': 'warning-text',
'danger': 'danger-text',
'info': 'info-text'
};
return variantMapping[props.action.variant] || 'info-text';
};
// Get button icon with default fallback
const getButtonIcon = () => {
if (props.action.icon) {
return props.action.icon;
}
// Default icons based on variant/action type
const variantIcons = {
'primary': 'heroicons:cursor-arrow-rays',
'secondary': 'heroicons:cursor-arrow-rays',
'success': 'heroicons:check-circle',
'warning': 'heroicons:exclamation-triangle',
'danger': 'heroicons:exclamation-triangle',
'info': 'heroicons:information-circle'
};
return variantIcons[props.action.variant] || 'heroicons:cursor-arrow-rays';
};
// Methods
const substituteUrlPlaceholders = (url, rowData) => {
if (!url || typeof url !== 'string') return url;
// Replace placeholders like {id}, {name}, etc. with actual row data values
return url.replace(/\{([^}]+)\}/g, (match, key) => {
const value = rowData[key];
if (value !== undefined && value !== null) {
return encodeURIComponent(String(value));
}
// If key not found, leave placeholder as is or return empty string
console.warn(`CustomActionButton: Placeholder '${key}' not found in row data:`, rowData);
return '';
});
};
const handleClick = async (event) => {
event.preventDefault();
event.stopPropagation();
try {
loading.value = true;
// Emit click event for custom handling
emit('click', { action: props.action, rowData: props.rowData, event });
// Handle confirmation dialog if specified
if (props.action.confirmMessage) {
const confirmed = confirm(props.action.confirmMessage);
if (!confirmed) {
return;
}
}
// Process URL with substitutions
let finalUrl = props.action.url;
if (props.action.urlType === 'dynamic') {
finalUrl = substituteUrlPlaceholders(props.action.url, props.rowData);
}
if (!finalUrl) {
console.error('CustomActionButton: No URL provided or URL substitution failed');
return;
}
console.log(`[CustomActionButton] Navigating to: ${finalUrl}`);
// Emit navigate event
emit('navigate', {
action: props.action,
url: finalUrl,
rowData: props.rowData
});
// Handle navigation based on target type
const openInNewTab = props.action.target === '_blank' || props.action.openInNewTab;
if (openInNewTab) {
window.open(finalUrl, '_blank', 'noopener,noreferrer');
} else {
// Use navigateTo for SPA navigation if available, otherwise use window.location
if (typeof navigateTo === 'function') {
await navigateTo(finalUrl);
} else {
window.location.href = finalUrl;
}
}
} catch (error) {
console.error('CustomActionButton: Error handling click:', error);
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.custom-action-btn {
transition: all 0.2s ease;
}
.custom-action-btn:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.custom-action-btn:active {
transform: translateY(0);
}
</style>

View File

@ -1258,6 +1258,114 @@ if (name && email) {
</div>
</div>
</div>
<!-- Custom Actions Management -->
<div class="space-y-4">
<div class="flex justify-between items-center">
<h5 class="text-sm font-medium text-gray-700">Custom Actions</h5>
<RsButton @click="addCustomAction" variant="primary" size="sm">
<Icon name="heroicons:plus" class="w-4 h-4 mr-1" />
Add Action
</RsButton>
</div>
<p class="text-xs text-gray-500">Add custom action buttons that appear in each table row</p>
<div class="border rounded-md p-3 bg-gray-50 space-y-3">
<div v-for="(action, index) in (configModel.customActions || [])" :key="index" class="border p-3 rounded bg-white">
<div class="flex justify-between items-center mb-3">
<h4 class="font-medium text-sm text-gray-800">Action {{ index + 1 }}</h4>
<button
@click="removeCustomAction(index)"
class="text-red-500 hover:text-red-700"
type="button"
>
<Icon name="material-symbols:delete-outline" class="w-4 h-4" />
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormKit
type="text"
label="Action Label"
v-model="action.label"
placeholder="e.g., View Details, Edit Item"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="text"
label="Action URL"
v-model="action.url"
placeholder="e.g., /details/{id} or https://example.com/edit/{id}"
help="Use {fieldName} for dynamic values from row data"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="select"
label="URL Type"
v-model="action.urlType"
:options="[
{ label: 'Static URL', value: 'static' },
{ label: 'Dynamic URL (with placeholders)', value: 'dynamic' }
]"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="select"
label="Button Style"
v-model="action.variant"
:options="[
{ label: 'Default (Blue)', value: 'primary' },
{ label: 'Secondary (Gray)', value: 'secondary' },
{ label: 'Success (Green)', value: 'success' },
{ label: 'Warning (Orange)', value: 'warning' },
{ label: 'Danger (Red)', value: 'danger' },
{ label: 'Info (Blue)', value: 'info' }
]"
help="Matches the edit/delete button styling"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="text"
label="Icon (Optional)"
v-model="action.icon"
placeholder="e.g., heroicons:eye, material-symbols:edit"
help="Leave empty for automatic icon based on button style"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="select"
label="Target"
v-model="action.target"
:options="[
{ label: 'Same Tab', value: '_self' },
{ label: 'New Tab', value: '_blank' }
]"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="text"
label="Confirmation Message (Optional)"
v-model="action.confirmMessage"
placeholder="e.g., Are you sure you want to proceed?"
help="Shows confirmation dialog before navigation"
:classes="{ outer: 'field-wrapper' }"
/>
</div>
</div>
<div v-if="!configModel.customActions || configModel.customActions.length === 0" class="text-center py-4 text-gray-500">
<Icon name="heroicons:cursor-arrow-rays" class="w-8 h-8 mx-auto mb-2 text-gray-400" />
<p class="text-sm">No custom actions configured</p>
<p class="text-xs">Add custom actions to enable additional functionality in your table rows</p>
</div>
</div>
</div>
</div>
</template>
@ -3934,6 +4042,28 @@ const removeColumnOption = (columnIndex, optionIndex) => {
}
}
// Custom actions management for repeating-table
const addCustomAction = () => {
if (!configModel.value.customActions) {
configModel.value.customActions = []
}
configModel.value.customActions.push({
label: 'New Action',
url: '/action/{id}',
urlType: 'dynamic',
variant: 'primary',
target: '_self',
icon: '',
confirmMessage: ''
})
}
const removeCustomAction = (index) => {
if (configModel.value.customActions) {
configModel.value.customActions.splice(index, 1)
}
}
const removeNestedComponent = (index) => {
if (configModel.value.children) {
configModel.value.children.splice(index, 1)

View File

@ -132,6 +132,16 @@
>
<Icon name="heroicons:trash" class="w-4 h-4" />
</RsButton>
<!-- Custom Action Buttons -->
<CustomActionButton
v-for="action in config.customActions"
:key="action.id || action.label"
:action="action"
:row-data="record"
:show-label="false"
@click="handleCustomAction"
@navigate="handleCustomNavigation"
/>
</div>
</td>
</tr>
@ -296,6 +306,7 @@
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { v4 as uuidv4 } from 'uuid'
import { useDebounceFn } from '@vueuse/core'
import CustomActionButton from './CustomActionButton.vue'
// SimpleCellValue component removed - using direct template rendering instead
@ -314,7 +325,7 @@ const props = defineProps({
}
})
const emit = defineEmits(['update:modelValue', 'debug-data', 'mounted'])
const emit = defineEmits(['update:modelValue', 'debug-data', 'mounted', 'custom-action', 'custom-navigation'])
// Reactive state
const data = ref([...(props.modelValue || [])])
@ -433,7 +444,7 @@ const isAddDisabled = computed(() => {
})
const showActions = computed(() => {
return props.config.allowEdit || props.config.allowDelete
return props.config.allowEdit || props.config.allowDelete || (props.config.customActions && props.config.customActions.length > 0)
})
const showRecordCount = computed(() => {
@ -775,6 +786,19 @@ onUnmounted(() => {
columnCache.value.clear()
recordKeys.value.clear()
})
// Custom Action Handlers
const handleCustomAction = ({ action, rowData, event }) => {
console.log('[RepeatingTable] Custom action clicked:', { action, rowData })
// Emit custom action event to parent for additional handling
emit('custom-action', { action, rowData, event })
}
const handleCustomNavigation = async ({ action, url, rowData }) => {
console.log('[RepeatingTable] Custom navigation:', { action, url, rowData })
// Emit navigation event to parent for additional handling
emit('custom-navigation', { action, url, rowData })
}
</script>
<style scoped>

View File

@ -273,6 +273,8 @@ export const useFormBuilderStore = defineStore('formBuilder', {
{ name: 'email', label: 'Email', type: 'email', required: true, placeholder: 'Enter email', validation: 'required|email', width: '250px' }
];
defaultProps.defaultData = Array.isArray(defaultProps.defaultData) ? defaultProps.defaultData : [];
// Add custom actions support
defaultProps.customActions = Array.isArray(defaultProps.customActions) ? defaultProps.customActions : [];
break;
case 'info-display':