- Updated FormBuilderFieldSettingsModal.vue and sideMenuNested.vue to replace text input for icon selection with an IconBrowser component, improving user experience for selecting Material Design icons. - Enhanced CustomActionButton.vue to differentiate between internal and external URLs when navigating, ensuring proper handling of external links. - Modified settings.local.json to include additional WebFetch commands for improved documentation access during development.
186 lines
4.9 KiB
Vue
186 lines
4.9 KiB
Vue
<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;
|
|
|
|
// Check if URL is external (has protocol or starts with //)
|
|
const isExternalUrl = (url) => {
|
|
return /^https?:\/\//.test(url) || /^\/\//.test(url) || !url.startsWith('/');
|
|
};
|
|
|
|
if (openInNewTab) {
|
|
window.open(finalUrl, '_blank', 'noopener,noreferrer');
|
|
} else {
|
|
// Use navigateTo for SPA navigation if available, otherwise use window.location
|
|
if (typeof navigateTo === 'function') {
|
|
if (isExternalUrl(finalUrl)) {
|
|
// For external URLs, use navigateTo with external: true option
|
|
await navigateTo(finalUrl, { external: true });
|
|
} else {
|
|
// For internal URLs, use regular navigateTo
|
|
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> |