feat(web): add base AlertDialog with app-card migration example (#32933)

Signed-off-by: yyh <yuanyouhuilyz@gmail.com>
This commit is contained in:
yyh
2026-03-04 13:56:27 +08:00
committed by GitHub
parent dfc6de69c3
commit 84dca83ecd
16 changed files with 529 additions and 106 deletions

View File

@ -63,6 +63,15 @@ vi.mock('@/service/apps', () => ({
exportAppConfig: vi.fn(() => Promise.resolve({ data: 'yaml: content' })),
}))
const mockDeleteAppMutation = vi.fn(() => Promise.resolve())
let mockDeleteMutationPending = false
vi.mock('@/service/use-apps', () => ({
useDeleteAppMutation: () => ({
mutateAsync: mockDeleteAppMutation,
isPending: mockDeleteMutationPending,
}),
}))
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: vi.fn(() => Promise.resolve({ environment_variables: [] })),
}))
@ -146,13 +155,6 @@ vi.mock('next/dynamic', () => ({
return React.createElement('div', { 'data-testid': 'switch-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-switch-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'confirm-switch-modal' }, 'Switch'))
}
}
if (fnString.includes('base/confirm')) {
return function MockConfirm({ isShow, onCancel, onConfirm }: { isShow: boolean, onCancel: () => void, onConfirm: () => void }) {
if (!isShow)
return null
return React.createElement('div', { 'data-testid': 'confirm-dialog' }, React.createElement('button', { 'onClick': onCancel, 'data-testid': 'cancel-confirm' }, 'Cancel'), React.createElement('button', { 'onClick': onConfirm, 'data-testid': 'confirm-confirm' }, 'Confirm'))
}
}
if (fnString.includes('dsl-export-confirm-modal')) {
return function MockDSLExportModal({ onClose, onConfirm }: { onClose?: () => void, onConfirm?: (withSecrets: boolean) => void }) {
return React.createElement('div', { 'data-testid': 'dsl-export-modal' }, React.createElement('button', { 'onClick': () => onClose?.(), 'data-testid': 'close-dsl-export' }, 'Close'), React.createElement('button', { 'onClick': () => onConfirm?.(true), 'data-testid': 'confirm-dsl-export' }, 'Export with secrets'), React.createElement('button', { 'onClick': () => onConfirm?.(false), 'data-testid': 'confirm-dsl-export-no-secrets' }, 'Export without secrets'))
@ -235,6 +237,7 @@ describe('AppCard', () => {
vi.clearAllMocks()
mockOpenAsyncWindow.mockReset()
mockWebappAuthEnabled = false
mockDeleteMutationPending = false
})
describe('Rendering', () => {
@ -461,35 +464,19 @@ describe('AppCard', () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
await waitFor(() => {
const deleteButton = screen.getByText('common.operation.delete')
fireEvent.click(deleteButton)
})
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.delete' }))
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
})
it('should close confirm dialog when cancel is clicked', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.delete' }))
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => {
const deleteButton = screen.getByText('common.operation.delete')
fireEvent.click(deleteButton)
})
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('cancel-confirm'))
await waitFor(() => {
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
})
@ -554,59 +541,41 @@ describe('AppCard', () => {
// Open popover and click delete
fireEvent.click(screen.getByTestId('popover-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('common.operation.delete'))
})
// Confirm delete
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('confirm-confirm'))
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.delete' }))
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(appsService.deleteApp).toHaveBeenCalled()
expect(mockDeleteAppMutation).toHaveBeenCalled()
})
})
it('should call onRefresh after successful delete', async () => {
it('should not call onRefresh after successful delete', async () => {
render(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('common.operation.delete'))
})
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.delete' }))
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('confirm-confirm'))
await waitFor(() => {
expect(mockOnRefresh).toHaveBeenCalled()
expect(mockDeleteAppMutation).toHaveBeenCalled()
})
expect(mockOnRefresh).not.toHaveBeenCalled()
})
it('should handle delete failure', async () => {
(appsService.deleteApp as Mock).mockRejectedValueOnce(new Error('Delete failed'))
;(mockDeleteAppMutation as Mock).mockRejectedValueOnce(new Error('Delete failed'))
render(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('common.operation.delete'))
})
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.delete' }))
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('confirm-confirm'))
await waitFor(() => {
expect(appsService.deleteApp).toHaveBeenCalled()
expect(mockDeleteAppMutation).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: expect.stringContaining('Delete failed') })
})
})

View File

@ -106,6 +106,10 @@ vi.mock('@/service/use-apps', () => ({
error: mockServiceState.error,
refetch: mockRefetch,
}),
useDeleteAppMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}))
vi.mock('@/service/tag', () => ({

View File

@ -20,6 +20,15 @@ import CustomPopover from '@/app/components/base/popover'
import TagSelector from '@/app/components/base/tag-management/selector'
import Toast, { ToastContext } from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
@ -27,8 +36,9 @@ import { useProviderContext } from '@/context/provider-context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { AccessMode } from '@/models/access-control'
import { useGetUserCanAccessApp } from '@/service/access-control'
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
import { copyApp, exportAppConfig, updateAppInfo } from '@/service/apps'
import { fetchInstalledAppList } from '@/service/explore'
import { useDeleteAppMutation } from '@/service/use-apps'
import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
@ -46,9 +56,6 @@ const DuplicateAppModal = dynamic(() => import('@/app/components/app/duplicate-m
const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), {
ssr: false,
})
const Confirm = dynamic(() => import('@/app/components/base/confirm'), {
ssr: false,
})
const DSLExportConfirmModal = dynamic(() => import('@/app/components/workflow/dsl-export-confirm-modal'), {
ssr: false,
})
@ -76,13 +83,12 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [showAccessControl, setShowAccessControl] = useState(false)
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
const { mutateAsync: mutateDeleteApp, isPending: isDeleting } = useDeleteAppMutation()
const onConfirmDelete = useCallback(async () => {
try {
await deleteApp(app.id)
await mutateDeleteApp(app.id)
notify({ type: 'success', message: t('appDeleted', { ns: 'app' }) })
if (onRefresh)
onRefresh()
onPlanInfoChanged()
}
catch (e: any) {
@ -91,8 +97,17 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
message: `${t('appDeleteFailed', { ns: 'app' })}${'message' in e ? `: ${e.message}` : ''}`,
})
}
setShowConfirmDelete(false)
}, [app.id, notify, onPlanInfoChanged, onRefresh, t])
finally {
setShowConfirmDelete(false)
}
}, [app.id, mutateDeleteApp, notify, onPlanInfoChanged, t])
const onDeleteDialogOpenChange = useCallback((open: boolean) => {
if (isDeleting)
return
setShowConfirmDelete(open)
}, [isDeleting])
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
name,
@ -438,7 +453,8 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
<div
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md"
>
<RiMoreFill className="h-4 w-4 text-text-tertiary" />
<span className="sr-only">{t('operation.more', { ns: 'common' })}</span>
<RiMoreFill aria-hidden className="h-4 w-4 text-text-tertiary" />
</div>
)}
btnClassName={open =>
@ -495,15 +511,26 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
onSuccess={onSwitch}
/>
)}
{showConfirmDelete && (
<Confirm
title={t('deleteAppConfirmTitle', { ns: 'app' })}
content={t('deleteAppConfirmContent', { ns: 'app' })}
isShow={showConfirmDelete}
onConfirm={onConfirmDelete}
onCancel={() => setShowConfirmDelete(false)}
/>
)}
<AlertDialog open={showConfirmDelete} onOpenChange={onDeleteDialogOpenChange}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pb-4 pt-6">
<AlertDialogTitle className="text-text-primary title-2xl-semi-bold">
{t('deleteAppConfirmTitle', { ns: 'app' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full whitespace-pre-wrap break-words text-text-tertiary system-md-regular">
{t('deleteAppConfirmContent', { ns: 'app' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton disabled={isDeleting}>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={isDeleting} disabled={isDeleting} onClick={onConfirmDelete}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
{secretEnvList.length > 0 && (
<DSLExportConfirmModal
envList={secretEnvList}

View File

@ -1,3 +1,8 @@
/**
* @deprecated Use `@/app/components/base/ui/alert-dialog` instead.
* See issue #32767 for migration details.
*/
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
@ -5,6 +10,7 @@ import { useTranslation } from 'react-i18next'
import Button from '../button'
import Tooltip from '../tooltip'
/** @deprecated Use `@/app/components/base/ui/alert-dialog` instead. */
export type IConfirm = {
className?: string
isShow: boolean

View File

@ -0,0 +1,145 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogClose,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
AlertDialogTrigger,
} from '../index'
describe('AlertDialog wrapper', () => {
describe('Rendering', () => {
it('should render alert dialog content when dialog is open', () => {
render(
<AlertDialog open>
<AlertDialogContent>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
<AlertDialogDescription>This action cannot be undone.</AlertDialogDescription>
</AlertDialogContent>
</AlertDialog>,
)
const dialog = screen.getByRole('alertdialog')
expect(dialog).toHaveTextContent('Confirm Delete')
expect(dialog).toHaveTextContent('This action cannot be undone.')
})
it('should not render content when dialog is closed', () => {
render(
<AlertDialog open={false}>
<AlertDialogContent>
<AlertDialogTitle>Hidden Title</AlertDialogTitle>
</AlertDialogContent>
</AlertDialog>,
)
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className to popup', () => {
render(
<AlertDialog open>
<AlertDialogContent className="custom-class">
<AlertDialogTitle>Title</AlertDialogTitle>
</AlertDialogContent>
</AlertDialog>,
)
const dialog = screen.getByRole('alertdialog')
expect(dialog).toHaveClass('custom-class')
})
it('should not render a close button by default', () => {
render(
<AlertDialog open>
<AlertDialogContent>
<AlertDialogTitle>Title</AlertDialogTitle>
</AlertDialogContent>
</AlertDialog>,
)
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should open and close dialog when trigger and close are clicked', async () => {
render(
<AlertDialog>
<AlertDialogTrigger>Open Dialog</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogTitle>Action Required</AlertDialogTitle>
<AlertDialogDescription>Please confirm the action.</AlertDialogDescription>
<AlertDialogClose>Cancel</AlertDialogClose>
</AlertDialogContent>
</AlertDialog>,
)
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Open Dialog' }))
expect(await screen.findByRole('alertdialog')).toHaveTextContent('Action Required')
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
await waitFor(() => {
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
})
})
describe('Composition Helpers', () => {
it('should render actions wrapper and default confirm button styles', () => {
render(
<AlertDialog open>
<AlertDialogContent>
<AlertDialogTitle>Action Required</AlertDialogTitle>
<AlertDialogActions data-testid="actions" className="custom-actions">
<AlertDialogConfirmButton>Confirm</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>,
)
expect(screen.getByTestId('actions')).toHaveClass('flex', 'items-start', 'justify-end', 'gap-2', 'self-stretch', 'p-6', 'custom-actions')
const confirmButton = screen.getByRole('button', { name: 'Confirm' })
expect(confirmButton).toHaveClass('btn-primary')
expect(confirmButton).toHaveClass('btn-destructive')
})
it('should keep dialog open after confirm click and close via cancel helper', async () => {
const onConfirm = vi.fn()
render(
<AlertDialog>
<AlertDialogTrigger>Open Dialog</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogTitle>Action Required</AlertDialogTitle>
<AlertDialogActions>
<AlertDialogCancelButton>Cancel</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={onConfirm}>Confirm</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>,
)
fireEvent.click(screen.getByRole('button', { name: 'Open Dialog' }))
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }))
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
await waitFor(() => {
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
})
})
})

View File

@ -0,0 +1,106 @@
'use client'
import type { ButtonProps } from '@/app/components/base/button'
import { AlertDialog as BaseAlertDialog } from '@base-ui/react/alert-dialog'
import * as React from 'react'
import Button from '@/app/components/base/button'
import { cn } from '@/utils/classnames'
// z-index strategy (relies on root `isolation: isolate` in layout.tsx):
// All overlay primitives (Tooltip / Popover / Dropdown / Select / Dialog / AlertDialog) — z-50
// Overlays share the same z-index; DOM order handles stacking when multiple are open.
// This ensures overlays inside an AlertDialog (e.g. a Tooltip on a dialog button) render
// above the dialog backdrop instead of being clipped by it.
// Toast — z-[99], always on top (defined in toast component)
export const AlertDialog = BaseAlertDialog.Root
export const AlertDialogTrigger = BaseAlertDialog.Trigger
export const AlertDialogTitle = BaseAlertDialog.Title
export const AlertDialogDescription = BaseAlertDialog.Description
export const AlertDialogClose = BaseAlertDialog.Close
type AlertDialogContentProps = {
children: React.ReactNode
className?: string
overlayClassName?: string
popupProps?: Omit<React.ComponentPropsWithoutRef<typeof BaseAlertDialog.Popup>, 'children' | 'className'>
backdropProps?: Omit<React.ComponentPropsWithoutRef<typeof BaseAlertDialog.Backdrop>, 'className'>
}
export function AlertDialogContent({
children,
className,
overlayClassName,
popupProps,
backdropProps,
}: AlertDialogContentProps) {
return (
<BaseAlertDialog.Portal>
<BaseAlertDialog.Backdrop
{...backdropProps}
className={cn(
'fixed inset-0 z-50 bg-background-overlay',
'transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
overlayClassName,
)}
/>
<BaseAlertDialog.Popup
{...popupProps}
className={cn(
'fixed left-1/2 top-1/2 z-50 max-h-[calc(100vh-2rem)] w-[480px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto overscroll-contain rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
'transition-[transform,scale,opacity] duration-150 data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
className,
)}
>
{children}
</BaseAlertDialog.Popup>
</BaseAlertDialog.Portal>
)
}
type AlertDialogActionsProps = React.ComponentPropsWithoutRef<'div'>
export function AlertDialogActions({ className, ...props }: AlertDialogActionsProps) {
return (
<div
className={cn('flex items-start justify-end gap-2 self-stretch p-6', className)}
{...props}
/>
)
}
type AlertDialogCancelButtonProps = Omit<ButtonProps, 'children'> & {
children: React.ReactNode
closeProps?: Omit<React.ComponentPropsWithoutRef<typeof BaseAlertDialog.Close>, 'children' | 'render'>
}
export function AlertDialogCancelButton({
children,
closeProps,
...buttonProps
}: AlertDialogCancelButtonProps) {
return (
<BaseAlertDialog.Close
{...closeProps}
render={<Button {...buttonProps} />}
>
{children}
</BaseAlertDialog.Close>
)
}
type AlertDialogConfirmButtonProps = ButtonProps
export function AlertDialogConfirmButton({
variant = 'primary',
destructive = true,
...props
}: AlertDialogConfirmButtonProps) {
return (
<Button
variant={variant}
destructive={destructive}
{...props}
/>
)
}