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"
|
@update:model-value="updateTableData"
|
||||||
@debug-data="(data) => console.log('[ComponentPreview] Table debug data:', data)"
|
@debug-data="(data) => console.log('[ComponentPreview] Table debug data:', data)"
|
||||||
@mounted="() => console.log('[ComponentPreview] RepeatingTable mounted with data:', getTableData(component.props.name))"
|
@mounted="() => console.log('[ComponentPreview] RepeatingTable mounted with data:', getTableData(component.props.name))"
|
||||||
|
@custom-action="handleTableCustomAction"
|
||||||
|
@custom-navigation="handleTableCustomNavigation"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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
|
// Get access to the form builder store
|
||||||
const formStore = useFormBuilderStore();
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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>
|
</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>
|
</div>
|
||||||
</template>
|
</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) => {
|
const removeNestedComponent = (index) => {
|
||||||
if (configModel.value.children) {
|
if (configModel.value.children) {
|
||||||
configModel.value.children.splice(index, 1)
|
configModel.value.children.splice(index, 1)
|
||||||
|
@ -132,6 +132,16 @@
|
|||||||
>
|
>
|
||||||
<Icon name="heroicons:trash" class="w-4 h-4" />
|
<Icon name="heroicons:trash" class="w-4 h-4" />
|
||||||
</RsButton>
|
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -296,6 +306,7 @@
|
|||||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import { useDebounceFn } from '@vueuse/core'
|
import { useDebounceFn } from '@vueuse/core'
|
||||||
|
import CustomActionButton from './CustomActionButton.vue'
|
||||||
|
|
||||||
// SimpleCellValue component removed - using direct template rendering instead
|
// 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
|
// Reactive state
|
||||||
const data = ref([...(props.modelValue || [])])
|
const data = ref([...(props.modelValue || [])])
|
||||||
@ -433,7 +444,7 @@ const isAddDisabled = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const showActions = 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(() => {
|
const showRecordCount = computed(() => {
|
||||||
@ -775,6 +786,19 @@ onUnmounted(() => {
|
|||||||
columnCache.value.clear()
|
columnCache.value.clear()
|
||||||
recordKeys.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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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' }
|
{ name: 'email', label: 'Email', type: 'email', required: true, placeholder: 'Enter email', validation: 'required|email', width: '250px' }
|
||||||
];
|
];
|
||||||
defaultProps.defaultData = Array.isArray(defaultProps.defaultData) ? defaultProps.defaultData : [];
|
defaultProps.defaultData = Array.isArray(defaultProps.defaultData) ? defaultProps.defaultData : [];
|
||||||
|
// Add custom actions support
|
||||||
|
defaultProps.customActions = Array.isArray(defaultProps.customActions) ? defaultProps.customActions : [];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'info-display':
|
case 'info-display':
|
||||||
|
Loading…
x
Reference in New Issue
Block a user