From 84dca83ecdd8708d64ca5fb82ba70aa9cad17986 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:56:27 +0800 Subject: [PATCH] feat(web): add base AlertDialog with app-card migration example (#32933) Signed-off-by: yyh --- .gitignore | 1 + .../apps/app-card-operations-flow.test.tsx | 14 +- .../apps/app-list-browsing-flow.test.tsx | 4 + web/__tests__/apps/create-app-flow.test.tsx | 4 + .../apps/__tests__/app-card.spec.tsx | 93 ++++------- .../components/apps/__tests__/list.spec.tsx | 4 + web/app/components/apps/app-card.tsx | 65 +++++--- web/app/components/base/confirm/index.tsx | 6 + .../ui/alert-dialog/__tests__/index.spec.tsx | 145 ++++++++++++++++++ .../components/base/ui/alert-dialog/index.tsx | 106 +++++++++++++ web/contract/console/apps.ts | 14 ++ web/contract/router.ts | 4 + web/docs/overlay-migration.md | 2 + web/eslint-suppressions.json | 142 ++++++++++++++--- web/eslint.config.mjs | 6 + web/service/use-apps.ts | 25 +++ 16 files changed, 529 insertions(+), 106 deletions(-) create mode 100644 web/app/components/base/ui/alert-dialog/__tests__/index.spec.tsx create mode 100644 web/app/components/base/ui/alert-dialog/index.tsx create mode 100644 web/contract/console/apps.ts diff --git a/.gitignore b/.gitignore index 7bd919f095..8200d70afe 100644 --- a/.gitignore +++ b/.gitignore @@ -222,6 +222,7 @@ mise.toml # AI Assistant .roo/ +/.claude/worktrees/ api/.env.backup /clickzetta diff --git a/web/__tests__/apps/app-card-operations-flow.test.tsx b/web/__tests__/apps/app-card-operations-flow.test.tsx index 1aa6706b82..c3e8410955 100644 --- a/web/__tests__/apps/app-card-operations-flow.test.tsx +++ b/web/__tests__/apps/app-card-operations-flow.test.tsx @@ -14,7 +14,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import AppCard from '@/app/components/apps/app-card' import { AccessMode } from '@/models/access-control' -import { deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps' +import { exportAppConfig, updateAppInfo } from '@/service/apps' import { AppModeEnum } from '@/types/app' let mockIsCurrentWorkspaceEditor = true @@ -26,6 +26,8 @@ let mockSystemFeatures = { const mockRouterPush = vi.fn() const mockNotify = vi.fn() const mockOnPlanInfoChanged = vi.fn() +const mockDeleteAppMutation = vi.fn().mockResolvedValue(undefined) +let mockDeleteMutationPending = false vi.mock('next/navigation', () => ({ useRouter: () => ({ @@ -117,6 +119,13 @@ vi.mock('@/service/tag', () => ({ fetchTagList: vi.fn().mockResolvedValue([]), })) +vi.mock('@/service/use-apps', () => ({ + useDeleteAppMutation: () => ({ + mutateAsync: mockDeleteAppMutation, + isPending: mockDeleteMutationPending, + }), +})) + vi.mock('@/service/apps', () => ({ deleteApp: vi.fn().mockResolvedValue({}), updateAppInfo: vi.fn().mockResolvedValue({}), @@ -270,6 +279,7 @@ const renderAppCard = (app?: Partial) => { describe('App Card Operations Flow', () => { beforeEach(() => { vi.clearAllMocks() + mockDeleteMutationPending = false mockIsCurrentWorkspaceEditor = true mockSystemFeatures = { branding: { enabled: false }, @@ -341,7 +351,7 @@ describe('App Card Operations Flow', () => { fireEvent.click(confirmBtn) await waitFor(() => { - expect(deleteApp).toHaveBeenCalledWith('app-to-delete') + expect(mockDeleteAppMutation).toHaveBeenCalledWith('app-to-delete') }) } } diff --git a/web/__tests__/apps/app-list-browsing-flow.test.tsx b/web/__tests__/apps/app-list-browsing-flow.test.tsx index 19288ecd95..079f667dbc 100644 --- a/web/__tests__/apps/app-list-browsing-flow.test.tsx +++ b/web/__tests__/apps/app-list-browsing-flow.test.tsx @@ -104,6 +104,10 @@ vi.mock('@/service/use-apps', () => ({ error: mockError, refetch: mockRefetch, }), + useDeleteAppMutation: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), })) vi.mock('@/hooks/use-pay', () => ({ diff --git a/web/__tests__/apps/create-app-flow.test.tsx b/web/__tests__/apps/create-app-flow.test.tsx index a0976d32cc..4ac9824ddd 100644 --- a/web/__tests__/apps/create-app-flow.test.tsx +++ b/web/__tests__/apps/create-app-flow.test.tsx @@ -91,6 +91,10 @@ vi.mock('@/service/use-apps', () => ({ error: null, refetch: mockRefetch, }), + useDeleteAppMutation: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), })) vi.mock('@/hooks/use-pay', () => ({ diff --git a/web/app/components/apps/__tests__/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx index ee36d471fd..9bc23ce199 100644 --- a/web/app/components/apps/__tests__/app-card.spec.tsx +++ b/web/app/components/apps/__tests__/app-card.spec.tsx @@ -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() 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() 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() 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() 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') }) }) }) diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index a9bef08243..989bf6a788 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -106,6 +106,10 @@ vi.mock('@/service/use-apps', () => ({ error: mockServiceState.error, refetch: mockRefetch, }), + useDeleteAppMutation: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), })) vi.mock('@/service/tag', () => ({ diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index 8f268da02c..8dc5c82e13 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -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([]) + 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) => {
- + {t('operation.more', { ns: 'common' })} +
)} btnClassName={open => @@ -495,15 +511,26 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { onSuccess={onSwitch} /> )} - {showConfirmDelete && ( - setShowConfirmDelete(false)} - /> - )} + + +
+ + {t('deleteAppConfirmTitle', { ns: 'app' })} + + + {t('deleteAppConfirmContent', { ns: 'app' })} + +
+ + + {t('operation.cancel', { ns: 'common' })} + + + {t('operation.confirm', { ns: 'common' })} + + +
+
{secretEnvList.length > 0 && ( { + describe('Rendering', () => { + it('should render alert dialog content when dialog is open', () => { + render( + + + Confirm Delete + This action cannot be undone. + + , + ) + + 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( + + + Hidden Title + + , + ) + + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className to popup', () => { + render( + + + Title + + , + ) + + const dialog = screen.getByRole('alertdialog') + expect(dialog).toHaveClass('custom-class') + }) + + it('should not render a close button by default', () => { + render( + + + Title + + , + ) + + 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( + + Open Dialog + + Action Required + Please confirm the action. + Cancel + + , + ) + + 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( + + + Action Required + + Confirm + + + , + ) + + 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( + + Open Dialog + + Action Required + + Cancel + Confirm + + + , + ) + + 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() + }) + }) + }) +}) diff --git a/web/app/components/base/ui/alert-dialog/index.tsx b/web/app/components/base/ui/alert-dialog/index.tsx new file mode 100644 index 0000000000..8d48c5b998 --- /dev/null +++ b/web/app/components/base/ui/alert-dialog/index.tsx @@ -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, 'children' | 'className'> + backdropProps?: Omit, 'className'> +} + +export function AlertDialogContent({ + children, + className, + overlayClassName, + popupProps, + backdropProps, +}: AlertDialogContentProps) { + return ( + + + + {children} + + + ) +} + +type AlertDialogActionsProps = React.ComponentPropsWithoutRef<'div'> + +export function AlertDialogActions({ className, ...props }: AlertDialogActionsProps) { + return ( +
+ ) +} + +type AlertDialogCancelButtonProps = Omit & { + children: React.ReactNode + closeProps?: Omit, 'children' | 'render'> +} + +export function AlertDialogCancelButton({ + children, + closeProps, + ...buttonProps +}: AlertDialogCancelButtonProps) { + return ( + } + > + {children} + + ) +} + +type AlertDialogConfirmButtonProps = ButtonProps + +export function AlertDialogConfirmButton({ + variant = 'primary', + destructive = true, + ...props +}: AlertDialogConfirmButtonProps) { + return ( +