dify
This commit is contained in:
193
dify/web/app/components/base/toast/index.spec.tsx
Normal file
193
dify/web/app/components/base/toast/index.spec.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import React from 'react'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import Toast, { ToastProvider, useToastContext } from '.'
|
||||
import '@testing-library/jest-dom'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
// Mock timers for testing timeouts
|
||||
jest.useFakeTimers()
|
||||
|
||||
const TestComponent = () => {
|
||||
const { notify, close } = useToastContext()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => notify({ message: 'Notification message', type: 'info' })}>
|
||||
Show Toast
|
||||
</button>
|
||||
<button type="button" onClick={close}>Close Toast</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Toast', () => {
|
||||
describe('Toast Component', () => {
|
||||
test('renders toast with correct type and message', () => {
|
||||
render(
|
||||
<ToastProvider>
|
||||
<Toast type="success" message="Success message" />
|
||||
</ToastProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Success message')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders with different types', () => {
|
||||
const { rerender } = render(
|
||||
<ToastProvider>
|
||||
<Toast type="success" message="Success message" />
|
||||
</ToastProvider>,
|
||||
)
|
||||
|
||||
expect(document.querySelector('.text-text-success')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<ToastProvider>
|
||||
<Toast type="error" message="Error message" />
|
||||
</ToastProvider>,
|
||||
)
|
||||
|
||||
expect(document.querySelector('.text-text-destructive')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders with custom component', () => {
|
||||
render(
|
||||
<ToastProvider>
|
||||
<Toast
|
||||
message="Message with custom component"
|
||||
customComponent={<span data-testid="custom-component">Custom</span>}
|
||||
/>
|
||||
</ToastProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders children content', () => {
|
||||
render(
|
||||
<ToastProvider>
|
||||
<Toast message="Message with children">
|
||||
<span>Additional information</span>
|
||||
</Toast>
|
||||
</ToastProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Additional information')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('does not render close button when close is undefined', () => {
|
||||
// Create a modified context where close is undefined
|
||||
const CustomToastContext = React.createContext({ notify: noop, close: undefined })
|
||||
|
||||
// Create a wrapper component using the custom context
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<CustomToastContext.Provider value={{ notify: noop, close: undefined }}>
|
||||
{children}
|
||||
</CustomToastContext.Provider>
|
||||
)
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<Toast message="No close button" type="info" />
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('No close button')).toBeInTheDocument()
|
||||
// Ensure the close button is not rendered
|
||||
expect(document.querySelector('.h-4.w-4.shrink-0.text-text-tertiary')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToastProvider and Context', () => {
|
||||
test('shows and hides toast using context', async () => {
|
||||
render(
|
||||
<ToastProvider>
|
||||
<TestComponent />
|
||||
</ToastProvider>,
|
||||
)
|
||||
|
||||
// No toast initially
|
||||
expect(screen.queryByText('Notification message')).not.toBeInTheDocument()
|
||||
|
||||
// Show toast
|
||||
act(() => {
|
||||
screen.getByText('Show Toast').click()
|
||||
})
|
||||
expect(screen.getByText('Notification message')).toBeInTheDocument()
|
||||
|
||||
// Close toast
|
||||
act(() => {
|
||||
screen.getByText('Close Toast').click()
|
||||
})
|
||||
expect(screen.queryByText('Notification message')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('automatically hides toast after duration', async () => {
|
||||
render(
|
||||
<ToastProvider>
|
||||
<TestComponent />
|
||||
</ToastProvider>,
|
||||
)
|
||||
|
||||
// Show toast
|
||||
act(() => {
|
||||
screen.getByText('Show Toast').click()
|
||||
})
|
||||
expect(screen.getByText('Notification message')).toBeInTheDocument()
|
||||
|
||||
// Fast-forward timer
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000) // Default for info type is 3000ms
|
||||
})
|
||||
|
||||
// Toast should be gone
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Notification message')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Toast.notify static method', () => {
|
||||
test('creates and removes toast from DOM', async () => {
|
||||
act(() => {
|
||||
// Call the static method
|
||||
Toast.notify({ message: 'Static notification', type: 'warning' })
|
||||
})
|
||||
|
||||
// Toast should be in document
|
||||
expect(screen.getByText('Static notification')).toBeInTheDocument()
|
||||
|
||||
// Fast-forward timer
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(6000) // Default for warning type is 6000ms
|
||||
})
|
||||
|
||||
// Toast should be removed
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Static notification')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
test('calls onClose callback after duration', async () => {
|
||||
const onCloseMock = jest.fn()
|
||||
act(() => {
|
||||
Toast.notify({
|
||||
message: 'Closing notification',
|
||||
type: 'success',
|
||||
onClose: onCloseMock,
|
||||
})
|
||||
})
|
||||
|
||||
// Fast-forward timer
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000) // Default for success type is 3000ms
|
||||
})
|
||||
|
||||
// onClose should be called
|
||||
await waitFor(() => {
|
||||
expect(onCloseMock).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
104
dify/web/app/components/base/toast/index.stories.tsx
Normal file
104
dify/web/app/components/base/toast/index.stories.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useCallback } from 'react'
|
||||
import Toast, { ToastProvider, useToastContext } from '.'
|
||||
|
||||
const ToastControls = () => {
|
||||
const { notify } = useToastContext()
|
||||
|
||||
const trigger = useCallback((type: 'success' | 'error' | 'warning' | 'info') => {
|
||||
notify({
|
||||
type,
|
||||
message: `This is a ${type} toast`,
|
||||
children: type === 'info' ? 'Additional details can live here.' : undefined,
|
||||
})
|
||||
}, [notify])
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => trigger('success')}
|
||||
>
|
||||
Success
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => trigger('info')}
|
||||
>
|
||||
Info
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => trigger('warning')}
|
||||
>
|
||||
Warning
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => trigger('error')}
|
||||
>
|
||||
Error
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ToastProviderDemo = () => {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<div className="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Toast provider</div>
|
||||
<ToastControls />
|
||||
</div>
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const StaticToastDemo = () => {
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Static API</div>
|
||||
<button
|
||||
type="button"
|
||||
className="self-start rounded-md border border-divider-subtle bg-background-default px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
const handle = Toast.notify({
|
||||
type: 'success',
|
||||
message: 'Saved changes',
|
||||
duration: 2000,
|
||||
})
|
||||
setTimeout(() => handle.clear?.(), 2500)
|
||||
}}
|
||||
>
|
||||
Trigger Toast.notify()
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Feedback/Toast',
|
||||
component: ToastProviderDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'ToastProvider based notifications and the static Toast.notify helper. Buttons showcase each toast variant.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof ToastProviderDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Provider: Story = {}
|
||||
|
||||
export const StaticApi: Story = {
|
||||
render: () => <StaticToastDemo />,
|
||||
}
|
||||
175
dify/web/app/components/base/toast/index.tsx
Normal file
175
dify/web/app/components/base/toast/index.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client'
|
||||
import type { ReactNode } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import {
|
||||
RiAlertFill,
|
||||
RiCheckboxCircleFill,
|
||||
RiCloseLine,
|
||||
RiErrorWarningFill,
|
||||
RiInformation2Fill,
|
||||
} from '@remixicon/react'
|
||||
import { createContext, useContext } from 'use-context-selector'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import cn from '@/utils/classnames'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
export type IToastProps = {
|
||||
type?: 'success' | 'error' | 'warning' | 'info'
|
||||
size?: 'md' | 'sm'
|
||||
duration?: number
|
||||
message: string
|
||||
children?: ReactNode
|
||||
onClose?: () => void
|
||||
className?: string
|
||||
customComponent?: ReactNode
|
||||
}
|
||||
type IToastContext = {
|
||||
notify: (props: IToastProps) => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export type ToastHandle = {
|
||||
clear?: VoidFunction
|
||||
}
|
||||
|
||||
export const ToastContext = createContext<IToastContext>({} as IToastContext)
|
||||
export const useToastContext = () => useContext(ToastContext)
|
||||
const Toast = ({
|
||||
type = 'info',
|
||||
size = 'md',
|
||||
message,
|
||||
children,
|
||||
className,
|
||||
customComponent,
|
||||
}: IToastProps) => {
|
||||
const { close } = useToastContext()
|
||||
// sometimes message is react node array. Not handle it.
|
||||
if (typeof message !== 'string')
|
||||
return null
|
||||
|
||||
return <div className={cn(
|
||||
className,
|
||||
'fixed z-[9999] mx-8 my-4 w-[360px] grow overflow-hidden rounded-xl',
|
||||
'border border-components-panel-border-subtle bg-components-panel-bg-blur shadow-sm',
|
||||
'top-0',
|
||||
'right-0',
|
||||
size === 'md' ? 'p-3' : 'p-2',
|
||||
className,
|
||||
)}>
|
||||
<div className={cn(
|
||||
'absolute inset-0 -z-10 opacity-40',
|
||||
type === 'success' && 'bg-toast-success-bg',
|
||||
type === 'warning' && 'bg-toast-warning-bg',
|
||||
type === 'error' && 'bg-toast-error-bg',
|
||||
type === 'info' && 'bg-toast-info-bg',
|
||||
)}
|
||||
/>
|
||||
<div className={cn('flex', size === 'md' ? 'gap-1' : 'gap-0.5')}>
|
||||
<div className={cn('flex items-center justify-center', size === 'md' ? 'p-0.5' : 'p-1')}>
|
||||
{type === 'success' && <RiCheckboxCircleFill className={cn('text-text-success', size === 'md' ? 'h-5 w-5' : 'h-4 w-4')} aria-hidden="true" />}
|
||||
{type === 'error' && <RiErrorWarningFill className={cn('text-text-destructive', size === 'md' ? 'h-5 w-5' : 'h-4 w-4')} aria-hidden="true" />}
|
||||
{type === 'warning' && <RiAlertFill className={cn('text-text-warning-secondary', size === 'md' ? 'h-5 w-5' : 'h-4 w-4')} aria-hidden="true" />}
|
||||
{type === 'info' && <RiInformation2Fill className={cn('text-text-accent', size === 'md' ? 'h-5 w-5' : 'h-4 w-4')} aria-hidden="true" />}
|
||||
</div>
|
||||
<div className={cn('flex grow flex-col items-start gap-1 py-1', size === 'md' ? 'px-1' : 'px-0.5')}>
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className='system-sm-semibold text-text-primary [word-break:break-word]'>{message}</div>
|
||||
{customComponent}
|
||||
</div>
|
||||
{children && <div className='system-xs-regular text-text-secondary'>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
{close
|
||||
&& (<ActionButton className='z-[1000]' onClick={close}>
|
||||
<RiCloseLine className='h-4 w-4 shrink-0 text-text-tertiary' />
|
||||
</ActionButton>)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export const ToastProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode
|
||||
}) => {
|
||||
const placeholder: IToastProps = {
|
||||
type: 'info',
|
||||
message: 'Toast message',
|
||||
duration: 6000,
|
||||
}
|
||||
const [params, setParams] = React.useState<IToastProps>(placeholder)
|
||||
const defaultDuring = (params.type === 'success' || params.type === 'info') ? 3000 : 6000
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (mounted) {
|
||||
setTimeout(() => {
|
||||
setMounted(false)
|
||||
}, params.duration || defaultDuring)
|
||||
}
|
||||
}, [defaultDuring, mounted, params.duration])
|
||||
|
||||
return <ToastContext.Provider value={{
|
||||
notify: (props) => {
|
||||
setMounted(true)
|
||||
setParams(props)
|
||||
},
|
||||
close: () => setMounted(false),
|
||||
}}>
|
||||
{mounted && <Toast {...params} />}
|
||||
{children}
|
||||
</ToastContext.Provider>
|
||||
}
|
||||
|
||||
Toast.notify = ({
|
||||
type,
|
||||
size = 'md',
|
||||
message,
|
||||
duration,
|
||||
className,
|
||||
customComponent,
|
||||
onClose,
|
||||
}: Pick<IToastProps, 'type' | 'size' | 'message' | 'duration' | 'className' | 'customComponent' | 'onClose'>): ToastHandle => {
|
||||
const defaultDuring = (type === 'success' || type === 'info') ? 3000 : 6000
|
||||
const toastHandler: ToastHandle = {}
|
||||
|
||||
if (typeof window === 'object') {
|
||||
const holder = document.createElement('div')
|
||||
const root = createRoot(holder)
|
||||
|
||||
toastHandler.clear = () => {
|
||||
if (holder) {
|
||||
root.unmount()
|
||||
holder.remove()
|
||||
}
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
root.render(
|
||||
<ToastContext.Provider value={{
|
||||
notify: noop,
|
||||
close: () => {
|
||||
if (holder) {
|
||||
root.unmount()
|
||||
holder.remove()
|
||||
}
|
||||
onClose?.()
|
||||
},
|
||||
}}>
|
||||
<Toast type={type} size={size} message={message} duration={duration} className={className} customComponent={customComponent} />
|
||||
</ToastContext.Provider>,
|
||||
)
|
||||
document.body.appendChild(holder)
|
||||
const d = duration ?? defaultDuring
|
||||
if (d > 0)
|
||||
setTimeout(toastHandler.clear, d)
|
||||
}
|
||||
|
||||
return toastHandler
|
||||
}
|
||||
|
||||
export default Toast
|
||||
44
dify/web/app/components/base/toast/style.module.css
Normal file
44
dify/web/app/components/base/toast/style.module.css
Normal file
@@ -0,0 +1,44 @@
|
||||
.toast {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: fixed;
|
||||
z-index: 99999999;
|
||||
width: 1.84rem;
|
||||
height: 1.80rem;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translateX(-50%) translateY(-50%);
|
||||
background: #000000;
|
||||
box-shadow: 0 -.04rem .1rem 1px rgba(255, 255, 255, 0.1);
|
||||
border-radius: .1rem .1rem .1rem .1rem;
|
||||
}
|
||||
|
||||
.main {
|
||||
width: 2rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: .2rem;
|
||||
height: .4rem;
|
||||
background: center center no-repeat;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
/* .success {
|
||||
background-image: url('./icons/success.svg');
|
||||
}
|
||||
|
||||
.warning {
|
||||
background-image: url('./icons/warning.svg');
|
||||
}
|
||||
|
||||
.error {
|
||||
background-image: url('./icons/error.svg');
|
||||
} */
|
||||
|
||||
.text {
|
||||
text-align: center;
|
||||
font-size: .2rem;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
Reference in New Issue
Block a user