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:
parent
b7d6f42e76
commit
9c6f1ef666
@ -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>
|
||||
|
175
components/CustomActionButton.vue
Normal file
175
components/CustomActionButton.vue
Normal 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>
|
@ -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)
|
||||
|
@ -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>
|
||||
|
@ -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':
|
||||
|
Loading…
x
Reference in New Issue
Block a user