Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 47 additions & 42 deletions packages/dify-ui/src/toast/index.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
import * as React from 'react'
import { expect, within } from 'storybook/test'
import { toast, ToastHost } from '.'
import { Button } from '../button'

const longToastTitle =
'operation error S3: PutObject, exceeded maximum number of attempts, 3, StatusCode: 0, RequestID: , HostID: , request send failed'
Expand Down Expand Up @@ -155,58 +156,62 @@ const StackExamples = () => {
}

const PromiseExamples = () => {
const createPromiseToast = () => {
const request = new Promise<string>((resolve) => {
window.setTimeout(() => resolve('The deployment is now available in production.'), 1400)
})
const [pendingExample, setPendingExample] = React.useState<'success' | 'error' | null>(null)

void toast.promise(request, {
loading: {
type: 'info',
title: 'Deploying workflow',
description: 'Provisioning runtime and publishing the latest version.',
},
success: (result) => ({
type: 'success',
title: 'Deployment complete',
description: result,
}),
error: () => ({
type: 'error',
title: 'Deployment failed',
description: 'The release could not be completed.',
}),
})
}
const exportDsl = async (outcome: 'success' | 'error') => {
if (pendingExample) return

const createRejectingPromiseToast = () => {
const request = new Promise<string>((_, reject) => {
window.setTimeout(() => reject(new Error('intentional story failure')), 1200)
setPendingExample(outcome)
const request = new Promise<string>((resolve, reject) => {
window.setTimeout(() => {
if (outcome === 'success') resolve('customer-support-agent.yml')
else reject(new Error('The DSL could not be generated.'))
}, 1400)
})

void toast.promise(request, {
loading: 'Validating model credentials…',
success: 'Credentials verified',
error: () => ({
type: 'error',
title: 'Credentials rejected',
description: 'The model provider returned an authentication error.',
}),
})
await toast
.promise(request, {
loading: {
title: 'Preparing DSL export',
description: 'Collecting the app configuration and generating a YAML file.',
},
success: (fileName) => ({
title: 'Download started',
description: `${fileName} was sent to your browser.`,
timeout: 3000,
}),
error: () => ({
title: 'Export failed',
description: 'The DSL could not be generated. Try again.',
}),
})
.catch(() => undefined)

setPendingExample(null)
}

return (
<ExampleCard
eyebrow="Promise"
title="Async lifecycle"
description="The promise helper should swap the same toast through loading, success, and error states instead of growing the stack unnecessarily."
title="Export lifecycle"
description="A single toast follows the export from preparation to browser handoff, while the trigger prevents duplicate requests."
>
<button type="button" className={buttonClassName} onClick={createPromiseToast}>
Promise success
</button>
<button type="button" className={buttonClassName} onClick={createRejectingPromiseToast}>
Promise error
</button>
<Button
variant="secondary"
loading={pendingExample === 'success'}
disabled={pendingExample === 'error'}
onClick={() => exportDsl('success')}
>
Export DSL
</Button>
<Button
variant="secondary"
loading={pendingExample === 'error'}
disabled={pendingExample === 'success'}
onClick={() => exportDsl('error')}
>
Simulate failure
</Button>
</ExampleCard>
)
}
Expand Down
20 changes: 13 additions & 7 deletions packages/dify-ui/src/toast/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ type ToastToneStyle = {
}

const TOAST_TONE_STYLES = {
loading: {
iconClassName: 'i-ri-loader-2-line animate-spin text-text-accent motion-reduce:animate-none',
gradientClassName:
'from-components-badge-status-light-normal-halo to-background-gradient-mask-transparent',
},
success: {
iconClassName: 'i-ri-checkbox-circle-fill text-text-success',
gradientClassName:
Expand All @@ -41,7 +46,8 @@ const TOAST_TONE_STYLES = {
const toastCloseLabel = 'Close notification'
const toastViewportLabel = 'Notifications'

type ToastType = keyof typeof TOAST_TONE_STYLES
type ToastRenderType = keyof typeof TOAST_TONE_STYLES
type ToastType = Exclude<ToastRenderType, 'loading'>

type ToastAddOptions = Omit<
ToastManagerAddOptions<ToastData>,
Expand Down Expand Up @@ -96,12 +102,12 @@ type ToastApi = {

const toastManager = BaseToast.createToastManager<ToastData>()

function isToastType(type: string): type is ToastType {
function isToastRenderType(type: string): type is ToastRenderType {
return Object.prototype.hasOwnProperty.call(TOAST_TONE_STYLES, type)
}

function getToastType(type?: string): ToastType | undefined {
return type && isToastType(type) ? type : undefined
function getToastRenderType(type?: string): ToastRenderType | undefined {
return type && isToastRenderType(type) ? type : undefined
}

function addToast(options: ToastAddOptions) {
Expand Down Expand Up @@ -145,19 +151,19 @@ export const toast: ToastApi = Object.assign(showToast, {
promise: promiseToast,
})

function ToastIcon({ type }: { type?: ToastType }) {
function ToastIcon({ type }: { type?: ToastRenderType }) {
return type ? (
<span aria-hidden="true" className={cn('h-5 w-5', TOAST_TONE_STYLES[type].iconClassName)} />
) : null
}

function getToneGradientClasses(type?: ToastType) {
function getToneGradientClasses(type?: ToastRenderType) {
if (type) return TOAST_TONE_STYLES[type].gradientClassName
return 'from-background-default-subtle to-background-gradient-mask-transparent'
}

function ToastCard({ toast: toastItem }: { toast: ToastObject<ToastData> }) {
const toastType = getToastType(toastItem.type)
const toastType = getToastRenderType(toastItem.type)

return (
<BaseToast.Root
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,25 @@ vi.mock('../app-operations', () => ({
primaryOperations,
secondaryOperations,
}: {
primaryOperations?: Array<{ id: string; title: string; onClick: () => void }>
primaryOperations?: Array<{
id: string
title: string
onClick: () => void
disabled?: boolean
loading?: boolean
}>
secondaryOperations?: Array<{ id: string; title: string; onClick: () => void; type?: string }>
}) => (
<div data-testid="app-operations">
{primaryOperations?.map((op) => (
<button key={op.id} type="button" data-testid={`op-${op.id}`} onClick={op.onClick}>
<button
key={op.id}
type="button"
data-testid={`op-${op.id}`}
data-loading={op.loading || undefined}
disabled={op.disabled}
onClick={op.onClick}
>
{op.title}
</button>
))}
Expand Down Expand Up @@ -140,6 +153,7 @@ describe('AppInfoDetailPanel', () => {
show: true,
onClose: vi.fn(),
openModal: vi.fn(),
isExporting: false,
exportCheck: vi.fn(),
}

Expand Down Expand Up @@ -247,6 +261,13 @@ describe('AppInfoDetailPanel', () => {
expect(defaultProps.exportCheck).toHaveBeenCalledTimes(1)
})

it('should show the export operation as loading while export is pending', () => {
render(<AppInfoDetailPanel {...defaultProps} isExporting />)

expect(screen.getByTestId('op-export')).toHaveAttribute('data-loading', 'true')
expect(screen.getByTestId('op-export')).not.toBeDisabled()
})

it('should render delete operation', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.getByTestId('op-delete')).toBeInTheDocument()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import type { App, AppSSO } from '@/types/app'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { expectLoadingButton } from '@/test/button'
import { AppModeEnum } from '@/types/app'
import AppInfoModals from '../app-info-modals'

Expand Down Expand Up @@ -131,6 +130,7 @@ const defaultProps = {
onEdit: vi.fn(),
onCopy: vi.fn(),
onExport: vi.fn(async () => {}),
isExporting: false,
exportCheck: vi.fn(),
handleConfirmExport: vi.fn(async () => {}),
onConfirmDelete: vi.fn(),
Expand Down Expand Up @@ -305,43 +305,14 @@ describe('AppInfoModals', () => {
expect(defaultProps.handleConfirmExport).toHaveBeenCalledTimes(1)
})

it('should disable export confirm button and avoid duplicate submits while confirming export', async () => {
let resolveConfirmExport: () => void
const handleConfirmExport = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveConfirmExport = resolve
}),
)
const user = userEvent.setup()

it('should show the export warning confirmation as pending during export', async () => {
await act(async () => {
render(
<AppInfoModals
{...defaultProps}
activeModal="exportWarning"
handleConfirmExport={handleConfirmExport}
/>,
)
render(<AppInfoModals {...defaultProps} activeModal="exportWarning" isExporting />)
})

const confirmButton = await screen.findByRole('button', { name: 'common.operation.confirm' })

const firstClick = user.click(confirmButton)
await waitFor(() => {
expectLoadingButton(confirmButton)
expect(confirmButton).toHaveTextContent('common.operation.exporting')
})
await user.click(confirmButton)

expect(handleConfirmExport).toHaveBeenCalledTimes(1)

resolveConfirmExport!()
await firstClick
await waitFor(() => {
expect(confirmButton).not.toBeDisabled()
expect(confirmButton).toHaveTextContent('common.operation.confirm')
})
expect(
await screen.findByRole('button', { name: 'common.operation.exporting' }),
).toBeInTheDocument()
})

it('should call exportCheck when backup on importDSL modal', async () => {
Expand Down
Loading