Files
nocodb/packages/nc-gui/components/nc/Alert.vue

491 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
import type { AlertProps } from 'ant-design-vue/es'
import { getI18n } from '~/plugins/a.i18n'
/**
* NcAlert Component
*
* A customizable alert component with optional icons, descriptions, actions, and notifications.
* Can be used as a standalone alert or inside the `message` notification system.
*
* @example
* ```vue
* <NcAlert
* type="error"
* message="Something went wrong"
* description="We couldnt complete your request. Please try again."
* :closable="true"
* :copy-text="'ERR_CODE_404'"
* />
* ```
*/
export interface NcAlertProps extends Pick<AlertProps, 'showIcon' | 'message' | 'description' | 'closable'> {
/**
* type toast will be used only in message.toast('simple toast message')
*/
type: AlertProps['type'] | 'toast'
/**
* Controls the visibility of the alert.
* @default true
*/
visible?: boolean
/**
* Whether the alert has a border.
* @default true
*/
bordered?: boolean
/**
* Aligns the content vertically.
* - `top`: Align to the top
* - `center`: Align to the center
* @default 'top'
*/
align?: 'top' | 'center'
/**
* The text to be copied when clicking the copy button.
*/
copyText?: any
/**
* Show toast msg after copying the text
*/
copyTextToastMessage?: string
/**
* Tooltip text for the copy button.
* @default 'tooltip.copyErrorCode' (from i18n)
*/
copyBtnTooltip?: string
/**
* Custom class for the message text.
*/
messageClass?: string
/**
* Custom class for the description text.
*/
descriptionClass?: string
/**
* Whether this alert is used inside a notification message.
* @default false
*/
isNotification?: boolean
/**
* Duration before the alert disappears (in seconds).
* If not provided, uses default Ant Design message duration.
*/
duration?: number
/**
* Whether to show a visual progress bar for the remaining duration.
* @default true
*/
showDuration?: boolean
/**
* Show background color
* @default false
*/
background?: boolean
}
const props = withDefaults(defineProps<NcAlertProps>(), {
visible: true,
showIcon: true,
bordered: true,
align: 'top',
messageClass: '',
descriptionClass: '',
isNotification: false,
showDuration: true,
background: false,
})
/**
* Emits events when the alert is closed or visibility changes.
*/
const emits = defineEmits<{
/**
* Event triggered when visibility is updated.
* @param value - The new visibility state
*/
(e: 'update:visible', value: boolean): void
/**
* Event triggered when the alert is closed.
*/
(e: 'close'): void
}>()
const vVisible = useVModel(props, 'visible', emits, { defaultValue: true })
const { type } = toRefs(props)
const slots = useSlots()
const { t } = getI18n().global
const { copy } = useCopy()
const isMessageAvailable = computed(() => !!(slots.message || props.message))
const isDescriptionAvailable = computed(() => !!(slots.description || props.description))
const align = computed<NcAlertProps['align']>(() => {
return isMessageAvailable.value && isDescriptionAvailable.value ? props.align : 'center'
})
/**
* Tracks whether the text has been copied successfully.
*/
const isCopied = ref<boolean>(false)
const copyText = computed(() => props.copyText?.toString() ?? '')
const copyBtnTooltip = computed(() =>
ncIsUndefined(props.copyBtnTooltip) && props.type === 'error' ? t('tooltip.copyErrorCode') : props.copyBtnTooltip,
)
let copiedTimeoutId: any
/**
* Handles the copy button click event.
* Copies the `copyText` value to the clipboard and shows a success indicator.
*/
const onClickCopy = async () => {
if (copiedTimeoutId) {
clearTimeout(copiedTimeoutId)
}
if (!copyText.value) return
try {
await copy(copyText.value)
isCopied.value = true
if (props.copyTextToastMessage) {
message.toast(props.copyTextToastMessage)
}
copiedTimeoutId = setTimeout(() => {
isCopied.value = false
clearTimeout(copiedTimeoutId)
}, 3000)
} catch (e: any) {
message.error(e.message)
}
}
/**
* Computes the appropriate icon based on the alert type.
*/
const iconName = computed<IconMapKey>(() => {
if (type.value === 'error') {
return 'ncAlertCircleFilled'
}
if (type.value === 'warning') {
return 'alertTriangleSolid'
}
if (type.value === 'info') {
return 'ncInfoSolid'
}
return 'circleCheckSolid'
})
/**
* Handles alert close action.
*/
const handleClose = () => {
vVisible.value = false
emits('close')
}
/**
* Remaining duration of the alert in seconds.
*/
const remDuration = ref(props.duration ?? ANT_MESSAGE_DURATION)
/**
* Tracks the start time of the alert.
*/
const startTime = ref(performance.now())
/**
* Computes the progress percentage based on remaining duration.
*/
const remDurationPercent = computed(() => (remDuration.value / (props.duration ?? ANT_MESSAGE_DURATION)) * 100)
let frameId: number
/**
* Updates the progress bar smoothly using requestAnimationFrame.
*/
const updateProgress = () => {
const elapsedTime = (performance.now() - startTime.value) / 1000 // Convert ms to seconds
const totalDuration = props.duration ?? ANT_MESSAGE_DURATION
const remaining = Math.max(totalDuration - elapsedTime, 0)
// Lerp (smooth transition instead of abrupt frame jumps)
remDuration.value = remDuration.value * 0.9 + remaining * 0.1
if (remDuration.value > 0.01) {
// Stop when close to zero
frameId = requestAnimationFrame(updateProgress)
} else {
remDuration.value = 0 // Ensure it reaches zero exactly
}
}
/**
* Starts the progress bar animation when the component is mounted.
*/
onMounted(() => {
if (!props.showDuration) return
startTime.value = performance.now()
updateProgress()
})
/**
* Cancels the animation frame when the component is unmounted.
*/
onUnmounted(() => {
cancelAnimationFrame(frameId)
})
</script>
<template>
<div
v-if="vVisible"
class="nc-alert group"
:class="[
`nc-alert-type-${type}`,
{
'items-center': align === 'center',
'items-start': align === 'top',
'no-border': !bordered,
'nc-alert-notification': isNotification,
'nc-show-background': background,
},
]"
>
<div v-if="showIcon" class="nc-alert-icon-wrapper">
<slot name="icon">
<GeneralIcon :icon="iconName" class="nc-alert-icon" />
</slot>
</div>
<div class="nc-alert-content flex-1">
<div v-if="message || $slots.message" class="nc-alert-message" :class="messageClass">
<slot name="message">{{ message }}</slot>
</div>
<NcTooltip
v-if="description || $slots.description"
:title="description"
:line-clamp="isNotification ? 2 : 3"
show-on-truncate-only
:disabled="!description"
>
<div
class="nc-alert-description"
:class="[
descriptionClass,
{
'nc-only-description': isDescriptionAvailable && !isMessageAvailable,
},
]"
>
<slot name="description">{{ description }}</slot>
</div>
</NcTooltip>
</div>
<div v-if="$slots.action || copyText || closable" class="nc-alert-action">
<slot name="action"> </slot>
<NcTooltip
v-if="copyText"
:title="copyBtnTooltip"
:disabled="!copyBtnTooltip"
class="nc-alert-action-copy"
:class="{
'invisible group-hover:visible transition-all': isNotification,
}"
>
<NcButton size="xsmall" type="text" @click.stop="onClickCopy">
<div class="flex children:flex-none relative h-4 w-4">
<Transition name="icon-fade" :duration="200">
<GeneralIcon v-if="isCopied" icon="check" class="h-4 w-4 opacity-80" />
<GeneralIcon v-else icon="copy" class="h-4 w-4 opacity-80" />
</Transition>
</div>
</NcButton>
</NcTooltip>
<slot v-if="closable" name="closable" :handle-close="handleClose">
<NcButton size="xsmall" type="text" @click.stop="handleClose">
<GeneralIcon icon="close" class="text-nc-content-gray-subtle" />
</NcButton>
</slot>
</div>
<div
v-if="isNotification && showDuration"
class="nc-alert-progress-wrapper"
:class="{
'bg-nc-bg-brand': remDurationPercent > 0,
'bg-nc-bg-gray-medium': remDurationPercent <= 0,
}"
>
<div
class="nc-alert-progress"
:style="{
width: `${remDurationPercent}%`,
}"
></div>
</div>
</div>
</template>
<style lang="scss" scoped>
.nc-alert {
@apply flex gap-4;
&:not(.nc-alert-notification) {
@apply rounded-lg p-4 w-full border-1 border-nc-border-gray-medium;
}
&.nc-alert-notification {
@apply min-w-[calc(100vw_-_64px)] md:min-w-[308px] max-w-[488px] w-[calc(30vw_-_32px)];
.nc-alert-content {
.nc-alert-description {
@apply line-clamp-2;
}
}
&.nc-alert-type-toast {
@apply min-w-[fit-content] md:min-w-[fit-content] max-w-[350px] w-[fit-content];
}
}
&.no-border {
@apply border-none;
}
.nc-alert-icon-wrapper {
@apply flex children:flex-none;
.nc-alert-icon {
@apply h-6 w-6;
}
}
.nc-alert-content {
@apply flex flex-col gap-1;
.nc-alert-message {
@apply text-base text-nc-content-gray font-weight-700;
}
.nc-alert-description {
@apply text-sm font-weight-500 line-clamp-3;
&:not(.nc-only-description) {
@apply text-nc-content-gray-muted;
}
&.nc-only-description {
@apply text-nc-content-gray;
}
}
}
.nc-alert-action {
@apply flex items-center gap-3 children:flex-none;
}
&.nc-alert-type-success,
&.nc-alert-type-undefined {
.nc-alert-icon-wrapper {
@apply text-green-700;
}
&.nc-show-background {
@apply bg-nc-bg-green-light dark:bg-nc-green-20;
}
}
&.nc-alert-type-error {
.nc-alert-icon-wrapper {
@apply text-red-700;
}
&.nc-show-background {
@apply bg-nc-bg-red-light dark:bg-nc-red-20;
}
}
&.nc-alert-type-warning {
.nc-alert-icon-wrapper {
@apply text-orange-700;
}
&.nc-show-background {
@apply bg-nc-bg-orange-light dark:bg-nc-orange-20;
}
}
&.nc-alert-type-info {
.nc-alert-icon-wrapper {
@apply text-nc-content-brand;
}
&.nc-show-background {
@apply bg-nc-bg-brand dark:bg-nc-brand-20;
}
}
.nc-alert-progress-wrapper {
@apply absolute bottom-0 left-0 right-0 h-1;
.nc-alert-progress {
@apply h-full bg-nc-brand-400;
}
}
}
</style>
<style lang="scss">
.ant-message {
@apply z-1053;
.ant-message-notice {
&:has(.nc-alert-notification) {
.ant-message-notice-content {
@apply bg-nc-bg-default !rounded-lg p-4 gap-4 box-border border-1 border-nc-border-gray-medium text-left relative overflow-hidden;
.ant-message-custom-content > span {
@apply flex-none w-full block;
}
&:has(.nc-alert-type-toast) {
@apply py-2.5 px-3 bg-gray-700 border-gray-700;
.nc-alert-description {
@apply text-base-white;
}
}
}
}
}
}
</style>