refactor(web): migrate confirm dialogs to base/ui/alert-dialog (#35127)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
This commit is contained in:
Coding On Star 2026-04-14 22:46:26 +08:00 committed by GitHub
parent 381c518b23
commit 2c58b424a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
82 changed files with 1958 additions and 1646 deletions

View File

@ -207,20 +207,6 @@ vi.mock('@/app/components/app/switch-app-modal', () => ({
},
}))
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, onConfirm, onCancel, title }: Record<string, unknown>) => {
if (!isShow)
return null
return (
<div data-testid="confirm-delete-modal">
<span>{title as string}</span>
<button data-testid="confirm-delete" onClick={onConfirm as () => void}>Delete</button>
<button data-testid="cancel-delete" onClick={onCancel as () => void}>Cancel</button>
</div>
)
},
}))
vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
default: ({ onConfirm, onClose }: Record<string, unknown>) => (
<div data-testid="dsl-export-confirm-modal">
@ -342,14 +328,16 @@ describe('App Card Operations Flow', () => {
fireEvent.click(deleteBtn)
})
const confirmBtn = screen.queryByTestId('confirm-delete')
if (confirmBtn) {
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(screen.getByText('app.deleteAppConfirmTitle')).toBeInTheDocument()
})
await waitFor(() => {
expect(mockDeleteAppMutation).toHaveBeenCalledWith('app-to-delete')
})
}
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Deletable App' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mockDeleteAppMutation).toHaveBeenCalledWith('app-to-delete')
})
}
})
})

View File

@ -133,26 +133,6 @@ vi.mock('@/app/components/base/drawer', () => ({
),
}))
vi.mock('@/app/components/base/confirm', () => ({
default: ({ title, isShow, onConfirm, onCancel }: {
title: string
content: string
isShow: boolean
onConfirm: () => void
onCancel: () => void
}) => (
isShow
? (
<div data-testid="confirm-dialog">
<span>{title}</span>
<button data-testid="confirm-ok" onClick={onConfirm}>Confirm</button>
<button data-testid="confirm-cancel" onClick={onCancel}>Cancel</button>
</div>
)
: null
),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
default: { notify: vi.fn() },
toast: {
@ -242,6 +222,8 @@ vi.mock('@/app/components/tools/provider/tool-item', () => ({
const { default: ProviderDetail } = await import('@/app/components/tools/provider/detail')
const getDeleteConfirmButton = () => screen.getByRole('button', { name: /operation\.confirm$/ })
const makeCollection = (overrides: Partial<Collection> = {}): Collection => ({
id: 'test-collection',
name: 'test_collection',
@ -465,11 +447,10 @@ describe('Tool Provider Detail Flow Integration', () => {
fireEvent.click(screen.getByTestId('custom-modal-remove'))
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
expect(screen.getByText('Delete Tool')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('confirm-ok'))
fireEvent.click(getDeleteConfirmButton())
await waitFor(() => {
expect(mockRemoveCustomCollection).toHaveBeenCalledWith('test_collection')
expect(mockOnRefreshData).toHaveBeenCalled()
@ -527,10 +508,10 @@ describe('Tool Provider Detail Flow Integration', () => {
fireEvent.click(screen.getByTestId('wf-modal-remove'))
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
expect(screen.getByText('Delete Tool')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('confirm-ok'))
fireEvent.click(getDeleteConfirmButton())
await waitFor(() => {
expect(mockDeleteWorkflowTool).toHaveBeenCalledWith('test-collection')
expect(mockOnRefreshData).toHaveBeenCalled()

View File

@ -5,7 +5,6 @@ import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import Divider from '@/app/components/base/divider'
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
@ -14,6 +13,15 @@ import {
PortalToFollowElemContent,
} from '@/app/components/base/portal-to-follow-elem'
import { Button } from '@/app/components/base/ui/button'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { toast } from '@/app/components/base/ui/toast'
import { addTracingConfig, removeTracingConfig, updateTracingConfig } from '@/service/apps'
import { docURL } from './config'
@ -679,14 +687,24 @@ const ProviderConfigModal: FC<Props> = ({
</PortalToFollowElem>
)
: (
<Confirm
isShow
type="warning"
title={t(`${I18N_PREFIX}.removeConfirmTitle`, { ns: 'app', key: t(`tracing.${type}.title`, { ns: 'app' }) })!}
content={t(`${I18N_PREFIX}.removeConfirmContent`, { ns: 'app' })}
onConfirm={handleRemove}
onCancel={hideRemoveConfirm}
/>
<AlertDialog open onOpenChange={open => !open && hideRemoveConfirm()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t(`${I18N_PREFIX}.removeConfirmTitle`, { ns: 'app', key: t(`tracing.${type}.title`, { ns: 'app' }) })!}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t(`${I18N_PREFIX}.removeConfirmContent`, { ns: 'app' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={handleRemove}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)}
</>
)

View File

@ -1,5 +1,5 @@
import type { App, AppSSO } from '@/types/app'
import { act, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { AppModeEnum } from '@/types/app'
@ -36,24 +36,6 @@ vi.mock('@/app/components/app/duplicate-modal', () => ({
),
}))
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, title, onConfirm, onCancel }: {
isShow: boolean
title: string
onConfirm: () => void
onCancel: () => void
}) => (
isShow
? (
<div data-testid="confirm-modal" data-title={title}>
<button type="button" onClick={onConfirm}>Confirm</button>
<button type="button" onClick={onCancel}>Cancel</button>
</div>
)
: null
),
}))
vi.mock('@/app/components/workflow/update-dsl-modal', () => ({
default: ({ onCancel, onBackup }: { onCancel: () => void, onBackup: () => void }) => (
<div data-testid="import-dsl-modal">
@ -113,7 +95,7 @@ describe('AppInfoModals', () => {
render(<AppInfoModals {...defaultProps} activeModal={null} />)
})
expect(screen.queryByTestId('switch-modal')).not.toBeInTheDocument()
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
expect(screen.queryByText('app.deleteAppConfirmTitle')).not.toBeInTheDocument()
})
it('should render SwitchAppModal when activeModal is switch', async () => {
@ -143,14 +125,13 @@ describe('AppInfoModals', () => {
})
})
it('should render Confirm for delete when activeModal is delete', async () => {
it('should render delete alert dialog when activeModal is delete', async () => {
await act(async () => {
render(<AppInfoModals {...defaultProps} activeModal="delete" />)
})
await waitFor(() => {
const confirm = screen.getByTestId('confirm-modal')
expect(confirm).toBeInTheDocument()
expect(confirm).toHaveAttribute('data-title', 'app.deleteAppConfirmTitle')
expect(screen.getByText('app.deleteAppConfirmTitle')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})
@ -163,14 +144,12 @@ describe('AppInfoModals', () => {
})
})
it('should render export warning Confirm when activeModal is exportWarning', async () => {
it('should render export warning alert dialog when activeModal is exportWarning', async () => {
await act(async () => {
render(<AppInfoModals {...defaultProps} activeModal="exportWarning" />)
})
await waitFor(() => {
const confirm = screen.getByTestId('confirm-modal')
expect(confirm).toBeInTheDocument()
expect(confirm).toHaveAttribute('data-title', 'workflow.sidebar.exportWarning')
expect(screen.getByText('workflow.sidebar.exportWarning')).toBeInTheDocument()
})
})
@ -202,20 +181,47 @@ describe('AppInfoModals', () => {
render(<AppInfoModals {...defaultProps} activeModal="delete" />)
})
await waitFor(() => expect(screen.getByText('Cancel')).toBeInTheDocument())
await user.click(screen.getByText('Cancel'))
await waitFor(() => expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument())
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(defaultProps.closeModal).toHaveBeenCalledTimes(1)
})
it('should clear the delete confirmation input when delete modal is cancelled', async () => {
const user = userEvent.setup()
await act(async () => {
render(<AppInfoModals {...defaultProps} activeModal="delete" />)
})
const input = await screen.findByRole('textbox')
await user.type(input, 'wrong-name')
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(defaultProps.closeModal).toHaveBeenCalledTimes(1)
expect(input).toHaveValue('')
})
it('should not confirm delete when the form is submitted with unmatched input', async () => {
await act(async () => {
render(<AppInfoModals {...defaultProps} activeModal="delete" />)
})
const form = document.querySelector('form')
expect(form).toBeTruthy()
fireEvent.submit(form!)
expect(defaultProps.onConfirmDelete).not.toHaveBeenCalled()
})
it('should call onConfirmDelete when confirm on delete modal', async () => {
const user = userEvent.setup()
await act(async () => {
render(<AppInfoModals {...defaultProps} activeModal="delete" />)
})
await waitFor(() => expect(screen.getByText('Confirm')).toBeInTheDocument())
await user.click(screen.getByText('Confirm'))
await user.type(screen.getByRole('textbox'), 'Test App')
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(defaultProps.onConfirmDelete).toHaveBeenCalledTimes(1)
})
@ -226,8 +232,8 @@ describe('AppInfoModals', () => {
render(<AppInfoModals {...defaultProps} activeModal="exportWarning" />)
})
await waitFor(() => expect(screen.getByText('Confirm')).toBeInTheDocument())
await user.click(screen.getByText('Confirm'))
await waitFor(() => expect(screen.getByRole('button', { name: 'common.operation.confirm' })).toBeInTheDocument())
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(defaultProps.handleConfirmExport).toHaveBeenCalledTimes(1)
})

View File

@ -6,12 +6,21 @@ import type { App, AppSSO } from '@/types/app'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import dynamic from '@/next/dynamic'
const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), { ssr: false })
const CreateAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), { ssr: false })
const DuplicateAppModal = dynamic(() => import('@/app/components/app/duplicate-modal'), { ssr: false })
const Confirm = dynamic(() => import('@/app/components/base/confirm'), { ssr: false })
const UpdateDSLModal = dynamic(() => import('@/app/components/workflow/update-dsl-modal'), { ssr: false })
const DSLExportConfirmModal = dynamic(() => import('@/app/components/workflow/dsl-export-confirm-modal'), { ssr: false })
@ -44,6 +53,12 @@ const AppInfoModals = ({
}: AppInfoModalsProps) => {
const { t } = useTranslation()
const [confirmDeleteInput, setConfirmDeleteInput] = useState('')
const isDeleteConfirmDisabled = confirmDeleteInput !== appDetail.name
const handleDeleteDialogClose = () => {
setConfirmDeleteInput('')
closeModal()
}
return (
<>
@ -85,39 +100,73 @@ const AppInfoModals = ({
onHide={closeModal}
/>
)}
{activeModal === 'delete' && (
<Confirm
title={t('deleteAppConfirmTitle', { ns: 'app' })}
content={t('deleteAppConfirmContent', { ns: 'app' })}
isShow
confirmInputLabel={t('deleteAppConfirmInputLabel', { ns: 'app', appName: appDetail.name })}
confirmInputPlaceholder={t('deleteAppConfirmInputPlaceholder', { ns: 'app' })}
confirmInputValue={confirmDeleteInput}
onConfirmInputChange={setConfirmDeleteInput}
confirmInputMatchValue={appDetail.name}
onConfirm={onConfirmDelete}
onCancel={() => {
setConfirmDeleteInput('')
closeModal()
}}
/>
)}
<AlertDialog open={activeModal === 'delete'} onOpenChange={open => !open && handleDeleteDialogClose()}>
<AlertDialogContent>
<form
className="flex flex-col"
onSubmit={(e) => {
e.preventDefault()
if (isDeleteConfirmDisabled)
return
onConfirmDelete()
}}
>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('deleteAppConfirmTitle', { ns: 'app' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('deleteAppConfirmContent', { ns: 'app' })}
</AlertDialogDescription>
<div className="mt-2">
<label className="mb-1 block system-sm-regular text-text-secondary">
{t('deleteAppConfirmInputLabel', { ns: 'app', appName: appDetail.name })}
</label>
<Input
type="text"
autoComplete="off"
spellCheck={false}
placeholder={t('deleteAppConfirmInputPlaceholder', { ns: 'app' })}
value={confirmDeleteInput}
onChange={e => setConfirmDeleteInput(e.target.value)}
/>
</div>
</div>
<AlertDialogActions>
<AlertDialogCancelButton type="button">
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton type="submit" disabled={isDeleteConfirmDisabled}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</form>
</AlertDialogContent>
</AlertDialog>
{activeModal === 'importDSL' && (
<UpdateDSLModal
onCancel={closeModal}
onBackup={exportCheck}
/>
)}
{activeModal === 'exportWarning' && (
<Confirm
type="info"
isShow
title={t('sidebar.exportWarning', { ns: 'workflow' })}
content={t('sidebar.exportWarningDesc', { ns: 'workflow' })}
onConfirm={handleConfirmExport}
onCancel={closeModal}
/>
)}
<AlertDialog open={activeModal === 'exportWarning'} onOpenChange={open => !open && closeModal()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('sidebar.exportWarning', { ns: 'workflow' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('sidebar.exportWarningDesc', { ns: 'workflow' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton tone="default" onClick={handleConfirmExport}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
{secretEnvList.length > 0 && (
<DSLExportConfirmModal
envList={secretEnvList}

View File

@ -137,33 +137,6 @@ vi.mock('@/app/components/datasets/rename-modal', () => ({
},
}))
vi.mock('@/app/components/base/confirm', () => ({
default: ({
isShow,
onConfirm,
onCancel,
title,
content,
}: {
isShow: boolean
onConfirm: () => void
onCancel: () => void
title: string
content: string
}) => {
if (!isShow)
return null
return (
<div data-testid="confirm-dialog">
<span>{title}</span>
<span>{content}</span>
<button type="button" onClick={onConfirm}>confirm</button>
<button type="button" onClick={onCancel}>cancel</button>
</div>
)
},
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
@ -221,13 +194,13 @@ describe('Dropdown callback coverage', () => {
await user.click(screen.getByText('common.operation.delete'))
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
expect(screen.getByText('dataset.deleteDatasetConfirmTitle')).toBeInTheDocument()
})
await user.click(screen.getByText('cancel'))
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => {
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
expect(screen.queryByText('dataset.deleteDatasetConfirmTitle')).not.toBeInTheDocument()
})
})
@ -273,6 +246,6 @@ describe('Dropdown callback coverage', () => {
await waitFor(() => {
expect(mockToast).toHaveBeenCalledWith('check failed', { type: 'error' })
})
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
expect(screen.queryByText('dataset.deleteDatasetConfirmTitle')).not.toBeInTheDocument()
})
})

View File

@ -14,8 +14,16 @@ import { useExportPipelineDSL } from '@/service/use-pipeline'
import { cn } from '@/utils/classnames'
import { downloadBlob } from '@/utils/download'
import ActionButton from '../../base/action-button'
import Confirm from '../../base/confirm'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '../../base/ui/alert-dialog'
import RenameDatasetModal from '../../datasets/rename-modal'
import Menu from './menu'
@ -135,15 +143,26 @@ const DropDown = ({
onSuccess={refreshDataset}
/>
)}
{showConfirmDelete && (
<Confirm
title={t('deleteDatasetConfirmTitle', { ns: 'dataset' })}
content={confirmMessage}
isShow={showConfirmDelete}
onConfirm={onConfirmDelete}
onCancel={() => setShowConfirmDelete(false)}
/>
)}
<AlertDialog open={showConfirmDelete} onOpenChange={open => !open && setShowConfirmDelete(false)}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('deleteDatasetConfirmTitle', { ns: 'dataset' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{confirmMessage}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={onConfirmDelete}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</PortalToFollowElem>
)
}

View File

@ -1,4 +1,4 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import * as React from 'react'
import BatchAction from '../batch-action'
@ -29,14 +29,28 @@ describe('BatchAction', () => {
render(<BatchAction {...baseProps} onBatchDelete={onBatchDelete} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.delete' }))
await screen.findByText('appAnnotation.list.delete.title')
const dialog = await screen.findByRole('alertdialog')
expect(within(dialog).getByText('appAnnotation.list.delete.title')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getAllByRole('button', { name: 'common.operation.delete' })[1])
})
fireEvent.click(within(dialog).getByRole('button', { name: 'common.operation.delete' }))
await waitFor(() => {
expect(onBatchDelete).toHaveBeenCalledTimes(1)
})
})
it('should hide delete confirmation when cancel is clicked', async () => {
const onBatchDelete = vi.fn()
render(<BatchAction {...baseProps} onBatchDelete={onBatchDelete} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.delete' }))
const dialog = await screen.findByRole('alertdialog')
fireEvent.click(within(dialog).getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => {
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
expect(onBatchDelete).not.toHaveBeenCalled()
})
})

View File

@ -3,8 +3,15 @@ import { RiDeleteBinLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import Divider from '@/app/components/base/divider'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { cn } from '@/utils/classnames'
const i18nPrefix = 'batchAction'
@ -41,38 +48,42 @@ const BatchAction: FC<IBatchActionProps> = ({
return (
<div className={cn('pointer-events-none flex w-full justify-center', className)}>
<div className="pointer-events-auto flex items-center gap-x-1 radius-lg border border-components-actionbar-border-accent bg-components-actionbar-bg-accent p-1 shadow-xl shadow-shadow-shadow-5 backdrop-blur-[5px]">
<div className="inline-flex items-center gap-x-2 py-1 pl-2 pr-3">
<div className="inline-flex items-center gap-x-2 py-1 pr-3 pl-2">
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-text-accent px-1 py-0.5 text-xs font-medium text-text-primary-on-surface">
{selectedIds.length}
</span>
<span className="text-[13px] font-semibold leading-[16px] text-text-accent">{t(`${i18nPrefix}.selected`, { ns: 'appAnnotation' })}</span>
<span className="text-[13px] leading-[16px] font-semibold text-text-accent">{t(`${i18nPrefix}.selected`, { ns: 'appAnnotation' })}</span>
</div>
<Divider type="vertical" className="mx-0.5 h-3.5 bg-divider-regular" />
<div className="flex cursor-pointer items-center gap-x-0.5 px-3 py-2" onClick={showDeleteConfirm}>
<RiDeleteBinLine className="h-4 w-4 text-components-button-destructive-ghost-text" />
<button type="button" className="px-0.5 text-[13px] font-medium leading-[16px] text-components-button-destructive-ghost-text">
<button type="button" className="px-0.5 text-[13px] leading-[16px] font-medium text-components-button-destructive-ghost-text">
{t('operation.delete', { ns: 'common' })}
</button>
</div>
<Divider type="vertical" className="mx-0.5 h-3.5 bg-divider-regular" />
<button type="button" className="px-3.5 py-2 text-[13px] font-medium leading-[16px] text-components-button-ghost-text" onClick={onCancel}>
<button type="button" className="px-3.5 py-2 text-[13px] leading-[16px] font-medium text-components-button-ghost-text" onClick={onCancel}>
{t('operation.cancel', { ns: 'common' })}
</button>
</div>
{
isShowDeleteConfirm && (
<Confirm
isShow
title={t('list.delete.title', { ns: 'appAnnotation' })}
confirmText={t('operation.delete', { ns: 'common' })}
onConfirm={handleBatchDelete}
onCancel={hideDeleteConfirm}
isLoading={isDeleting}
isDisabled={isDeleting}
/>
)
}
<AlertDialog open={isShowDeleteConfirm} onOpenChange={open => !open && hideDeleteConfirm()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('list.delete.title', { ns: 'appAnnotation' })}
</AlertDialogTitle>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={isDeleting} disabled={isDeleting} onClick={handleBatchDelete}>
{t('operation.delete', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@ -3,7 +3,14 @@
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
type Props = {
isShow: boolean
@ -17,15 +24,26 @@ const ClearAllAnnotationsConfirmModal: FC<Props> = ({
onConfirm,
}) => {
const { t } = useTranslation()
const title = t('table.header.clearAllConfirm', { ns: 'appAnnotation' })
return (
<Confirm
isShow={isShow}
onCancel={onHide}
onConfirm={onConfirm}
type="danger"
title={t('table.header.clearAllConfirm', { ns: 'appAnnotation' })}
/>
<AlertDialog open={isShow} onOpenChange={open => !open && onHide()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{title}
</AlertDialogTitle>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={onConfirm}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@ -332,6 +332,24 @@ describe('EditAnnotationModal', () => {
// Assert
expect(mockOnRemove).toHaveBeenCalled()
})
it('should hide confirm modal when removal is cancelled', async () => {
const props = {
...defaultProps,
annotationId: 'test-annotation-id',
}
const user = userEvent.setup()
render(<EditAnnotationModal {...props} />)
await user.click(screen.getByText('appAnnotation.editModal.removeThisCache'))
expect(screen.getByText('appDebug.feature.annotation.removeConfirm')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => {
expect(screen.queryByText('appDebug.feature.annotation.removeConfirm')).not.toBeInTheDocument()
})
})
})
// Edge Cases (REQUIRED)

View File

@ -3,9 +3,16 @@ import type { FC } from 'react'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import Drawer from '@/app/components/base/drawer-plus'
import { MessageCheckRemove } from '@/app/components/base/icons/src/vender/line/communication'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { toast } from '@/app/components/base/ui/toast'
import AnnotationFull from '@/app/components/billing/annotation-full'
import { useProviderContext } from '@/context/provider-context'
@ -106,16 +113,33 @@ const EditAnnotationModal: FC<Props> = ({
readonly={isAdd && isAnnotationFull}
onSave={editedContent => handleSave(EditItemType.Answer, editedContent)}
/>
<Confirm
isShow={showModal}
onCancel={() => setShowModal(false)}
onConfirm={() => {
onRemove()
setShowModal(false)
onHide()
}}
title={t('feature.annotation.removeConfirm', { ns: 'appDebug' })}
/>
<AlertDialog open={showModal} onOpenChange={open => !open && setShowModal(false)}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle
title={t('feature.annotation.removeConfirm', { ns: 'appDebug' })}
className="w-full truncate title-2xl-semi-bold text-text-primary"
>
{t('feature.annotation.removeConfirm', { ns: 'appDebug' })}
</AlertDialogTitle>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
tone="destructive"
onClick={() => {
onRemove()
setShowModal(false)
onHide()
}}
>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
)}

View File

@ -2,7 +2,14 @@
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
type Props = {
isShow: boolean
@ -16,14 +23,26 @@ const RemoveAnnotationConfirmModal: FC<Props> = ({
onRemove,
}) => {
const { t } = useTranslation()
const title = t('feature.annotation.removeConfirm', { ns: 'appDebug' })
return (
<Confirm
isShow={isShow}
onCancel={onHide}
onConfirm={onRemove}
title={t('feature.annotation.removeConfirm', { ns: 'appDebug' })}
/>
<AlertDialog open={isShow} onOpenChange={open => !open && onHide()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{title}
</AlertDialogTitle>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton tone="destructive" onClick={onRemove}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)
}
export default React.memo(RemoveAnnotationConfirmModal)

View File

@ -157,4 +157,17 @@ describe('ViewAnnotationModal', () => {
expect(props.onHide).toHaveBeenCalledTimes(1)
})
})
it('should close the remove confirmation when cancelled', async () => {
renderComponent()
fireEvent.click(screen.getByText('appAnnotation.editModal.removeThisCache'))
expect(await screen.findByText('appDebug.feature.annotation.removeConfirm')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => {
expect(screen.queryByText('appDebug.feature.annotation.removeConfirm')).not.toBeInTheDocument()
})
})
})

View File

@ -5,11 +5,18 @@ import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import Confirm from '@/app/components/base/confirm'
import Drawer from '@/app/components/base/drawer-plus'
import { MessageCheckRemove } from '@/app/components/base/icons/src/vender/line/communication'
import Pagination from '@/app/components/base/pagination'
import TabSlider from '@/app/components/base/tab-slider-plain'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { APP_PAGE_LIMIT } from '@/config'
import useTimestamp from '@/hooks/use-timestamp'
import { fetchHitHistoryList } from '@/service/annotation'
@ -212,16 +219,33 @@ const ViewAnnotationModal: FC<Props> = ({
<div className="space-y-6 p-6 pb-4">
{activeTab === TabType.annotation ? annotationTab : hitHistoryTab}
</div>
<Confirm
isShow={showModal}
onCancel={() => setShowModal(false)}
onConfirm={async () => {
await onRemove()
setShowModal(false)
onHide()
}}
title={t('feature.annotation.removeConfirm', { ns: 'appDebug' })}
/>
<AlertDialog open={showModal} onOpenChange={open => !open && setShowModal(false)}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle
title={t('feature.annotation.removeConfirm', { ns: 'appDebug' })}
className="w-full truncate title-2xl-semi-bold text-text-primary"
>
{t('feature.annotation.removeConfirm', { ns: 'appDebug' })}
</AlertDialogTitle>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
tone="destructive"
onClick={async () => {
await onRemove()
setShowModal(false)
onHide()
}}
>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
)}
foot={id

View File

@ -1,5 +1,5 @@
/* eslint-disable ts/no-explicit-any */
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import FeaturesWrappedAppPublisher from '../features-wrapper'
const mockSetFeatures = vi.fn()
@ -137,4 +137,23 @@ describe('FeaturesWrappedAppPublisher', () => {
}))
})
})
it('should close restore confirmation without restoring when cancelled', async () => {
render(
<FeaturesWrappedAppPublisher
publishedConfig={publishedConfig as any}
/>,
)
fireEvent.click(screen.getByText('restore-through-wrapper'))
const dialog = screen.getByRole('alertdialog')
fireEvent.click(within(dialog).getByRole('button', { name: 'operation.cancel' }))
await waitFor(() => {
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
expect(publishedConfig.modelConfig.resetAppConfig).not.toHaveBeenCalled()
expect(mockSetFeatures).not.toHaveBeenCalled()
})
})

View File

@ -6,9 +6,17 @@ import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppPublisher from '@/app/components/app/app-publisher'
import Confirm from '@/app/components/base/confirm'
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { Resolution } from '@/types/app'
@ -74,15 +82,24 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
onRestore: () => setRestoreConfirmOpen(true),
}}
/>
{restoreConfirmOpen && (
<Confirm
title={t('resetConfig.title', { ns: 'appDebug' })}
content={t('resetConfig.message', { ns: 'appDebug' })}
isShow={restoreConfirmOpen}
onConfirm={handleConfirm}
onCancel={() => setRestoreConfirmOpen(false)}
/>
)}
<AlertDialog open={restoreConfirmOpen} onOpenChange={open => !open && setRestoreConfirmOpen(false)}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('resetConfig.title', { ns: 'appDebug' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('resetConfig.message', { ns: 'appDebug' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={handleConfirm}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@ -11,8 +11,16 @@ import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ReactSortable } from 'react-sortablejs'
import { useContext } from 'use-context-selector'
import Confirm from '@/app/components/base/confirm'
import Tooltip from '@/app/components/base/tooltip'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { toast } from '@/app/components/base/ui/toast'
import { InputVarType } from '@/app/components/workflow/types'
import ConfigContext from '@/context/debug-configuration'
@ -264,7 +272,7 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
>
{!hasVar && (
<div className="mt-1 px-3 pb-3">
<div className="pb-1 pt-2 text-xs text-text-tertiary">{t('notSetVar', { ns: 'appDebug' })}</div>
<div className="pt-2 pb-1 text-xs text-text-tertiary">{t('notSetVar', { ns: 'appDebug' })}</div>
</div>
)}
{hasVar && (
@ -313,18 +321,29 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
/>
)}
{isShowDeleteContextVarModal && (
<Confirm
isShow={isShowDeleteContextVarModal}
title={t('feature.dataSet.queryVariable.deleteContextVarTitle', { ns: 'appDebug', varName: promptVariables[removeIndex as number]?.name })}
content={t('feature.dataSet.queryVariable.deleteContextVarTip', { ns: 'appDebug' })}
onConfirm={() => {
didRemoveVar(removeIndex as number)
hideDeleteContextVarModal()
}}
onCancel={hideDeleteContextVarModal}
/>
)}
<AlertDialog open={isShowDeleteContextVarModal} onOpenChange={open => !open && hideDeleteContextVarModal()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('feature.dataSet.queryVariable.deleteContextVarTitle', { ns: 'appDebug', varName: promptVariables[removeIndex as number]?.name })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('feature.dataSet.queryVariable.deleteContextVarTip', { ns: 'appDebug' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton
onClick={() => {
didRemoveVar(removeIndex as number)
hideDeleteContextVarModal()
}}
>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</Panel>
)

View File

@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { AppModeEnum } from '@/types/app'
import GetAutomaticRes from '../get-automatic-res'
@ -237,6 +237,42 @@ describe('GetAutomaticRes', () => {
}))
})
it('should close overwrite confirmation without applying the generated result when cancelled', async () => {
mockGenerateBasicAppFirstTimeRule.mockResolvedValue({
prompt: 'generated prompt',
variables: ['city'],
opening_statement: 'hello there',
})
render(
<GetAutomaticRes
mode={AppModeEnum.CHAT}
isShow
onClose={mockOnClose}
onFinished={mockOnFinished}
flowId="flow-1"
isBasicMode
/>,
)
fireEvent.click(screen.getByText('set-basic-instruction'))
fireEvent.click(screen.getByText('generate.generate'))
await waitFor(() => {
expect(screen.getByTestId('result-panel')).toHaveTextContent('generated prompt')
})
fireEvent.click(screen.getByText('apply-result'))
const dialog = await screen.findByRole('alertdialog')
fireEvent.click(within(dialog).getByRole('button', { name: 'operation.cancel' }))
await waitFor(() => {
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
expect(mockOnFinished).not.toHaveBeenCalled()
})
it('should request workflow generation and surface service errors', async () => {
mockGenerateRule.mockResolvedValue({
error: 'generation failed',

View File

@ -19,12 +19,19 @@ import { useBoolean, useSessionStorageState } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import { Generator } from '@/app/components/base/icons/src/vender/other'
import Loading from '@/app/components/base/loading'
import Modal from '@/app/components/base/modal'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { Button } from '@/app/components/base/ui/button'
import { toast } from '@/app/components/base/ui/toast'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
@ -387,18 +394,29 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
)}
{isLoading && renderLoading}
{isShowAutoPromptResPlaceholder() && <ResPlaceholder />}
{isShowConfirmOverwrite && (
<Confirm
title={t('generate.overwriteTitle', { ns: 'appDebug' })}
content={t('generate.overwriteMessage', { ns: 'appDebug' })}
isShow
onConfirm={() => {
hideShowConfirmOverwrite()
onFinished(current!)
}}
onCancel={hideShowConfirmOverwrite}
/>
)}
<AlertDialog open={isShowConfirmOverwrite} onOpenChange={open => !open && hideShowConfirmOverwrite()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('generate.overwriteTitle', { ns: 'appDebug' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('generate.overwriteMessage', { ns: 'appDebug' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton
onClick={() => {
hideShowConfirmOverwrite()
onFinished(current!)
}}
>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
</Modal>
)

View File

@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { AppModeEnum } from '@/types/app'
import GetCodeGeneratorResModal from '../get-code-generator-res'
@ -223,6 +223,43 @@ describe('GetCodeGeneratorResModal', () => {
}))
})
it('should close overwrite confirmation without applying the generated code when cancelled', async () => {
mockGenerateRule.mockResolvedValue({
code: 'print("hello")',
})
render(
<GetCodeGeneratorResModal
flowId="flow-1"
nodeId="node-1"
currentCode="print(1)"
mode={AppModeEnum.CHAT}
isShow
codeLanguages={CodeLanguage.python3}
onClose={mockOnClose}
onFinished={mockOnFinished}
/>,
)
fireEvent.click(screen.getByText('set-code-instruction'))
fireEvent.click(screen.getByText('set-code-output'))
fireEvent.click(screen.getByText('codegen.generate'))
await waitFor(() => {
expect(screen.getByTestId('code-result-panel')).toHaveTextContent('print("hello")')
})
fireEvent.click(screen.getByText('apply-code-result'))
const dialog = await screen.findByRole('alertdialog')
fireEvent.click(within(dialog).getByRole('button', { name: 'operation.cancel' }))
await waitFor(() => {
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
expect(mockOnFinished).not.toHaveBeenCalled()
})
it('should surface service errors without creating a result version', async () => {
mockGenerateRule.mockResolvedValue({
error: 'generation failed',

View File

@ -10,10 +10,18 @@ import {
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import { Generator } from '@/app/components/base/icons/src/vender/other'
import Loading from '@/app/components/base/loading'
import Modal from '@/app/components/base/modal'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { Button } from '@/app/components/base/ui/button'
import { toast } from '@/app/components/base/ui/toast'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
@ -263,18 +271,29 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
</div>
)}
</div>
{isShowConfirmOverwrite && (
<Confirm
title={t('codegen.overwriteConfirmTitle', { ns: 'appDebug' })}
content={t('codegen.overwriteConfirmMessage', { ns: 'appDebug' })}
isShow
onConfirm={() => {
hideShowConfirmOverwrite()
onFinished(current!)
}}
onCancel={hideShowConfirmOverwrite}
/>
)}
<AlertDialog open={isShowConfirmOverwrite} onOpenChange={open => !open && hideShowConfirmOverwrite()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('codegen.overwriteConfirmTitle', { ns: 'appDebug' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('codegen.overwriteConfirmMessage', { ns: 'appDebug' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton
onClick={() => {
hideShowConfirmOverwrite()
onFinished(current!)
}}
>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</Modal>
)
}

View File

@ -153,28 +153,26 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({
</div>
</div>
{showUseGPT4Confirm && (
<AlertDialog open={showUseGPT4Confirm} onOpenChange={open => !open && setShowUseGPT4Confirm(false)}>
<AlertDialogContent>
<div className="flex flex-col items-start gap-2 self-stretch px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full title-2xl-semi-bold text-text-primary">
{t('trailUseGPT4Info.title', { ns: 'appDebug' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('trailUseGPT4Info.description', { ns: 'appDebug' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton tone="default">
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton variant="primary" tone="default" onClick={onConfirmUseGPT4}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)}
<AlertDialog open={showUseGPT4Confirm} onOpenChange={open => !open && setShowUseGPT4Confirm(false)}>
<AlertDialogContent>
<div className="flex flex-col items-start gap-2 self-stretch px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full title-2xl-semi-bold text-text-primary">
{t('trailUseGPT4Info.title', { ns: 'appDebug' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('trailUseGPT4Info.description', { ns: 'appDebug' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton tone="default">
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton variant="primary" tone="default" onClick={onConfirmUseGPT4}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
{isShowSelectDataSet && (
<SelectDataSet

View File

@ -1,8 +1,8 @@
import type { AppDetailResponse } from '@/models/app'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import { AppCardAccessControlSection, AppCardOperations, createAppCardOperations } from '../app-card-sections'
import { AppCardAccessControlSection, AppCardOperations, AppCardUrlSection, createAppCardOperations } from '../app-card-sections'
describe('app-card-sections', () => {
const t = (key: string) => key
@ -100,4 +100,31 @@ describe('app-card-sections', () => {
expect(screen.getByText('overview.appInfo.customize.entry')).toBeInTheDocument()
expect(AppModeEnum.CHAT).toBe('chat')
})
it('should invoke regenerate dialog callbacks from the url section', () => {
const onRegenerate = vi.fn()
const onHideRegenerateConfirm = vi.fn()
render(
<AppCardUrlSection
t={t as never}
isApp
accessibleUrl="https://example.com/apps/demo"
showConfirmDelete
isCurrentWorkspaceManager
genLoading={false}
onRegenerate={onRegenerate}
onShowRegenerateConfirm={vi.fn()}
onHideRegenerateConfirm={onHideRegenerateConfirm}
/>,
)
const dialog = screen.getByRole('alertdialog')
fireEvent.click(within(dialog).getByRole('button', { name: /operation\.cancel/i }))
expect(onHideRegenerateConfirm).toHaveBeenCalled()
fireEvent.click(within(dialog).getByRole('button', { name: /operation\.confirm/i }))
expect(onRegenerate).toHaveBeenCalledTimes(1)
})
})

View File

@ -180,28 +180,26 @@ export const AppCardUrlSection = ({
<CopyFeedback content={accessibleUrl} className="size-6!" />
{isApp && <ShareQRCode content={accessibleUrl} />}
{isApp && <Divider type="vertical" className="mx-0.5! h-3.5! shrink-0" />}
{showConfirmDelete && (
<AlertDialog open={showConfirmDelete} onOpenChange={open => !open && onHideRegenerateConfirm()}>
<AlertDialogContent>
<div className="flex flex-col items-start gap-2 self-stretch pt-6 pr-6 pb-4 pl-6">
<AlertDialogTitle className="w-full title-2xl-semi-bold text-text-primary">
{t('overview.appInfo.regenerate', { ns: 'appOverview' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('overview.appInfo.regenerateNotice', { ns: 'appOverview' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton onClick={onHideRegenerateConfirm}>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={onRegenerate}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)}
<AlertDialog open={showConfirmDelete} onOpenChange={open => !open && onHideRegenerateConfirm()}>
<AlertDialogContent>
<div className="flex flex-col items-start gap-2 self-stretch pt-6 pr-6 pb-4 pl-6">
<AlertDialogTitle className="w-full title-2xl-semi-bold text-text-primary">
{t('overview.appInfo.regenerate', { ns: 'appOverview' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('overview.appInfo.regenerateNotice', { ns: 'appOverview' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton onClick={onHideRegenerateConfirm}>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={onRegenerate}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
{isApp && isCurrentWorkspaceManager && (
<MaybeTooltip content={t('overview.appInfo.regenerate', { ns: 'appOverview' }) || ''}>
<div

View File

@ -335,6 +335,7 @@ describe('SwitchAppModal', () => {
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(screen.queryByRole('button', { name: 'common.operation.confirm' })).not.toBeInTheDocument()
expect(screen.getByRole('checkbox')).not.toBeChecked()
})
it('should toggle remove-original from the checkbox control itself', async () => {

View File

@ -8,11 +8,19 @@ import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import AppIcon from '@/app/components/base/app-icon'
import Checkbox from '@/app/components/base/checkbox'
import Confirm from '@/app/components/base/confirm'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import { Button } from '@/app/components/base/ui/button'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { toast } from '@/app/components/base/ui/toast'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
@ -91,6 +99,14 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo
setShowConfirmDelete(true)
}, [removeOriginal])
const handleConfirmDeleteOpenChange = (open: boolean) => {
if (open)
return
setShowConfirmDelete(false)
setRemoveOriginal(false)
}
return (
<>
<Modal
@ -156,18 +172,29 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo
</div>
</div>
</Modal>
{showConfirmDelete && (
<Confirm
title={t('deleteAppConfirmTitle', { ns: 'app' })}
content={t('deleteAppConfirmContent', { ns: 'app' })}
isShow={showConfirmDelete}
onConfirm={() => setShowConfirmDelete(false)}
onCancel={() => {
setShowConfirmDelete(false)
setRemoveOriginal(false)
}}
/>
)}
<AlertDialog
open={showConfirmDelete}
onOpenChange={handleConfirmDeleteOpenChange}
>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('deleteAppConfirmTitle', { ns: 'app' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('deleteAppConfirmContent', { ns: 'app' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={() => setShowConfirmDelete(false)}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@ -5,7 +5,15 @@ import ActionButton from '@/app/components/base/action-button'
import AppIcon from '@/app/components/base/app-icon'
import InputsFormContent from '@/app/components/base/chat/chat-with-history/inputs-form/content'
import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
import Confirm from '@/app/components/base/confirm'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { useChatWithHistoryContext } from './context'
import MobileOperationDropdown from './header/mobile-operation-dropdown'
import Operation from './header/operation'
@ -78,7 +86,7 @@ const HeaderInMobile = () => {
imageUrl={appData?.site.icon_url}
background={appData?.site.icon_background}
/>
<div className="truncate text-text-secondary system-md-semibold">
<div className="truncate system-md-semibold text-text-secondary">
{appData?.site.title}
</div>
</>
@ -121,7 +129,7 @@ const HeaderInMobile = () => {
<div className="flex h-full w-[calc(100vw-40px)] flex-col rounded-xl bg-components-panel-bg shadow-lg backdrop-blur-xs" onClick={e => e.stopPropagation()}>
<div className="flex items-center gap-3 rounded-t-2xl border-b border-divider-subtle px-4 py-3">
<div className="i-custom-public-other-message-3-fill h-6 w-6 shrink-0" />
<div className="grow text-text-secondary system-xl-semibold">{t('chat.chatSettingsTitle', { ns: 'share' })}</div>
<div className="grow system-xl-semibold text-text-secondary">{t('chat.chatSettingsTitle', { ns: 'share' })}</div>
</div>
<div className="p-4">
<InputsFormContent />
@ -129,15 +137,24 @@ const HeaderInMobile = () => {
</div>
</div>
)}
{!!showConfirm && (
<Confirm
title={t('chat.deleteConversation.title', { ns: 'share' })}
content={t('chat.deleteConversation.content', { ns: 'share' }) || ''}
isShow
onCancel={handleCancelConfirm}
onConfirm={handleDelete}
/>
)}
<AlertDialog open={!!showConfirm} onOpenChange={open => !open && handleCancelConfirm()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('chat.deleteConversation.title', { ns: 'share' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('chat.deleteConversation.content', { ns: 'share' }) || ''}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={handleDelete}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
{showRename && (
<RenameModal
isShow

View File

@ -10,8 +10,16 @@ import ActionButton, { ActionButtonState } from '@/app/components/base/action-bu
import AppIcon from '@/app/components/base/app-icon'
import ViewFormDropdown from '@/app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown'
import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
import Confirm from '@/app/components/base/confirm'
import Tooltip from '@/app/components/base/tooltip'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { cn } from '@/utils/classnames'
import {
useChatWithHistoryContext,
@ -89,7 +97,7 @@ const Header = () => {
/>
</div>
{!currentConversationId && (
<div className={cn('grow truncate text-text-secondary system-md-semibold')}>{appData?.site.title}</div>
<div className={cn('grow truncate system-md-semibold text-text-secondary')}>{appData?.site.title}</div>
)}
{currentConversationId && currentConversationItem && isSidebarCollapsed && (
<>
@ -141,15 +149,24 @@ const Header = () => {
)}
</div>
</div>
{!!showConfirm && (
<Confirm
title={t('chat.deleteConversation.title', { ns: 'share' })}
content={t('chat.deleteConversation.content', { ns: 'share' }) || ''}
isShow
onCancel={handleCancelConfirm}
onConfirm={handleDelete}
/>
)}
<AlertDialog open={!!showConfirm} onOpenChange={open => !open && handleCancelConfirm()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('chat.deleteConversation.title', { ns: 'share' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('chat.deleteConversation.content', { ns: 'share' }) || ''}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={handleDelete}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
{showRename && (
<RenameModal
isShow

View File

@ -106,22 +106,6 @@ vi.mock('@/app/components/base/modal', () => ({
},
}))
// Mock Confirm
vi.mock('@/app/components/base/confirm', () => ({
default: ({ onCancel, onConfirm, title, content, isShow }: { onCancel: () => void, onConfirm: () => void, title: string, content?: React.ReactNode, isShow: boolean }) => {
if (!isShow)
return null
return (
<div data-testid="confirm-dialog">
<div data-testid="confirm-title">{title}</div>
<button data-testid="confirm-cancel" onClick={onCancel}>Cancel</button>
<div data-testid="confirm-content">{content}</div>
<button data-testid="confirm-confirm" onClick={onConfirm}>Confirm</button>
</div>
)
},
}))
describe('Sidebar Index', () => {
const mockContextValue = {
isInstalledApp: false,
@ -475,8 +459,7 @@ describe('Sidebar Index', () => {
render(<Sidebar />)
await user.click(screen.getByTestId('delete-1'))
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
expect(screen.getByTestId('confirm-title')).toBeInTheDocument()
expect(screen.getByText('share.chat.deleteConversation.title')).toBeInTheDocument()
})
it('should call handleDeleteConversation when confirm is clicked', async () => {
@ -490,7 +473,7 @@ describe('Sidebar Index', () => {
render(<Sidebar />)
await user.click(screen.getByTestId('delete-1'))
await user.click(screen.getByTestId('confirm-confirm'))
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(handleDeleteConversation).toHaveBeenCalledWith('1', expect.objectContaining({
onSuccess: expect.any(Function),
@ -502,11 +485,11 @@ describe('Sidebar Index', () => {
render(<Sidebar />)
await user.click(screen.getByTestId('delete-1'))
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
expect(screen.getByText('share.chat.deleteConversation.title')).toBeInTheDocument()
await user.click(screen.getByTestId('confirm-cancel'))
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => {
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
expect(screen.queryByText('share.chat.deleteConversation.title')).not.toBeInTheDocument()
})
})
@ -525,7 +508,7 @@ describe('Sidebar Index', () => {
render(<Sidebar />)
await user.click(screen.getByTestId('delete-1'))
await user.click(screen.getByTestId('confirm-confirm'))
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(handleDeleteConversation).toHaveBeenCalledWith('1', expect.any(Object))
})
@ -837,7 +820,7 @@ describe('Sidebar Index', () => {
// Delete it
await user.click(screen.getByTestId('delete-1'))
await user.click(screen.getByTestId('confirm-confirm'))
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(handleDeleteConversation).toHaveBeenCalled()
})
@ -901,8 +884,8 @@ describe('Sidebar Index', () => {
try {
render(<Sidebar />)
await user.click(screen.getByTestId('delete-1'))
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
expect(screen.getByTestId('confirm-content')).toBeEmptyDOMElement()
expect(screen.getByText('share.chat.deleteConversation.title')).toBeInTheDocument()
expect(screen.queryByText('share.chat.deleteConversation.content')).not.toBeInTheDocument()
}
finally {
useTranslationSpy.mockRestore()

View File

@ -13,9 +13,17 @@ import ActionButton from '@/app/components/base/action-button'
import AppIcon from '@/app/components/base/app-icon'
import List from '@/app/components/base/chat/chat-with-history/sidebar/list'
import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
import Confirm from '@/app/components/base/confirm'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import { Button } from '@/app/components/base/ui/button'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import MenuDropdown from '@/app/components/share/text-generation/menu-dropdown'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { cn } from '@/utils/classnames'
@ -167,15 +175,24 @@ const Sidebar = ({ isPanel, panelVisible }: Props) => {
</div>
)}
</div>
{!!showConfirm && (
<Confirm
title={t('chat.deleteConversation.title', { ns: 'share' })}
content={deleteConversationContent}
isShow
onCancel={handleCancelConfirm}
onConfirm={handleDelete}
/>
)}
<AlertDialog open={!!showConfirm} onOpenChange={open => !open && handleCancelConfirm()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('chat.deleteConversation.title', { ns: 'share' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{deleteConversationContent}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={handleDelete}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
{showRename && (
<RenameModal
isShow

View File

@ -1,117 +0,0 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import Confirm from '..'
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom')
return {
...actual,
createPortal: (children: React.ReactNode) => children,
}
})
const onCancel = vi.fn()
const onConfirm = vi.fn()
describe('Confirm Component', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('renders confirm correctly', () => {
render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} />)
expect(screen.getByText('test title')).toBeInTheDocument()
})
it('does not render on isShow false', () => {
const { container } = render(<Confirm isShow={false} title="test title" onCancel={onCancel} onConfirm={onConfirm} />)
expect(container.firstChild).toBeNull()
})
it('hides after delay when isShow changes to false', () => {
vi.useFakeTimers()
const { rerender } = render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} />)
expect(screen.getByText('test title')).toBeInTheDocument()
rerender(<Confirm isShow={false} title="test title" onCancel={onCancel} onConfirm={onConfirm} />)
act(() => {
vi.advanceTimersByTime(200)
})
expect(screen.queryByText('test title')).not.toBeInTheDocument()
vi.useRealTimers()
})
it('renders content when provided', () => {
render(<Confirm isShow={true} title="title" content="some description" onCancel={onCancel} onConfirm={onConfirm} />)
expect(screen.getByText('some description')).toBeInTheDocument()
})
})
describe('Props', () => {
it('showCancel prop works', () => {
render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} showCancel={false} />)
expect(screen.getByRole('button', { name: 'common.operation.confirm' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.operation.cancel' })).not.toBeInTheDocument()
})
it('showConfirm prop works', () => {
render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} showConfirm={false} />)
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.operation.confirm' })).not.toBeInTheDocument()
})
it('renders custom confirm and cancel text', () => {
render(<Confirm isShow={true} title="title" confirmText="Yes" cancelText="No" onCancel={onCancel} onConfirm={onConfirm} />)
expect(screen.getByRole('button', { name: 'Yes' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'No' })).toBeInTheDocument()
})
it('disables confirm button when isDisabled is true', () => {
render(<Confirm isShow={true} title="title" isDisabled={true} onCancel={onCancel} onConfirm={onConfirm} />)
expect(screen.getByRole('button', { name: 'common.operation.confirm' })).toBeDisabled()
})
})
describe('User Interactions', () => {
it('clickAway is handled properly', () => {
render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} />)
const overlay = screen.getByTestId('confirm-overlay') as HTMLElement
expect(overlay).toBeTruthy()
fireEvent.mouseDown(overlay)
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('overlay click stops propagation', () => {
render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} />)
const overlay = screen.getByTestId('confirm-overlay') as HTMLElement
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true })
const preventDefaultSpy = vi.spyOn(clickEvent, 'preventDefault')
const stopPropagationSpy = vi.spyOn(clickEvent, 'stopPropagation')
overlay.dispatchEvent(clickEvent)
expect(preventDefaultSpy).toHaveBeenCalled()
expect(stopPropagationSpy).toHaveBeenCalled()
})
it('does not close on click away when maskClosable is false', () => {
render(<Confirm isShow={true} title="test title" maskClosable={false} onCancel={onCancel} onConfirm={onConfirm} />)
const overlay = screen.getByTestId('confirm-overlay') as HTMLElement
fireEvent.mouseDown(overlay)
expect(onCancel).not.toHaveBeenCalled()
})
it('escape keyboard event works', () => {
render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} />)
fireEvent.keyDown(document, { key: 'Escape' })
expect(onCancel).toHaveBeenCalledTimes(1)
expect(onConfirm).not.toHaveBeenCalled()
})
it('Enter keyboard event works', () => {
render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} />)
fireEvent.keyDown(document, { key: 'Enter' })
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(onCancel).not.toHaveBeenCalled()
})
})
})

View File

@ -1,216 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useState } from 'react'
import { Button } from '@/app/components/base/ui/button'
import Confirm from '.'
const meta = {
title: 'Base/Feedback/Confirm',
component: Confirm,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Confirmation dialog component that supports warning and info types, with customizable button text and behavior.',
},
},
},
tags: ['autodocs'],
argTypes: {
type: {
control: 'select',
options: ['info', 'warning'],
description: 'Dialog type',
},
isShow: {
control: 'boolean',
description: 'Whether to show the dialog',
},
title: {
control: 'text',
description: 'Dialog title',
},
content: {
control: 'text',
description: 'Dialog content',
},
confirmText: {
control: 'text',
description: 'Confirm button text',
},
cancelText: {
control: 'text',
description: 'Cancel button text',
},
isLoading: {
control: 'boolean',
description: 'Confirm button loading state',
},
isDisabled: {
control: 'boolean',
description: 'Confirm button disabled state',
},
showConfirm: {
control: 'boolean',
description: 'Whether to show confirm button',
},
showCancel: {
control: 'boolean',
description: 'Whether to show cancel button',
},
maskClosable: {
control: 'boolean',
description: 'Whether clicking mask closes dialog',
},
},
args: {
onConfirm: () => {
console.log('✅ User clicked confirm')
},
onCancel: () => {
console.log('❌ User clicked cancel')
},
},
} satisfies Meta<typeof Confirm>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const ConfirmDemo = (args: any) => {
const [isShow, setIsShow] = useState(false)
return (
<div>
<Button variant="primary" onClick={() => setIsShow(true)}>
Open Dialog
</Button>
<Confirm
{...args}
isShow={isShow}
onConfirm={() => {
console.log('✅ User clicked confirm')
setIsShow(false)
}}
onCancel={() => {
console.log('❌ User clicked cancel')
setIsShow(false)
}}
/>
</div>
)
}
// Basic warning dialog - Delete action
export const WarningDialog: Story = {
render: args => <ConfirmDemo {...args} />,
args: {
type: 'warning',
title: 'Delete Confirmation',
content: 'Are you sure you want to delete this project? This action cannot be undone.',
isShow: false,
},
}
// Info dialog
export const InfoDialog: Story = {
render: args => <ConfirmDemo {...args} />,
args: {
type: 'info',
title: 'Notice',
content: 'Your changes have been saved. Do you want to proceed to the next step?',
isShow: false,
},
}
// Custom button text
export const CustomButtonText: Story = {
render: args => <ConfirmDemo {...args} />,
args: {
type: 'warning',
title: 'Exit Editor',
content: 'You have unsaved changes. Are you sure you want to exit?',
confirmText: 'Discard Changes',
cancelText: 'Continue Editing',
isShow: false,
},
}
// Loading state
export const LoadingState: Story = {
render: args => <ConfirmDemo {...args} />,
args: {
type: 'warning',
title: 'Deleting...',
content: 'Please wait while we delete the file...',
isLoading: true,
isShow: false,
},
}
// Disabled state
export const DisabledState: Story = {
render: args => <ConfirmDemo {...args} />,
args: {
type: 'info',
title: 'Verification Required',
content: 'Please complete email verification before proceeding.',
isDisabled: true,
isShow: false,
},
}
// Alert style - Confirm button only
export const AlertStyle: Story = {
render: args => <ConfirmDemo {...args} />,
args: {
type: 'info',
title: 'Success',
content: 'Your settings have been updated!',
showCancel: false,
confirmText: 'Got it',
isShow: false,
},
}
// Dangerous action - Long content
export const DangerousAction: Story = {
render: args => <ConfirmDemo {...args} />,
args: {
type: 'warning',
title: 'Permanently Delete Account',
content: 'This action will permanently delete your account and all associated data, including: all projects and files, collaboration history, and personal settings. This action cannot be reversed!',
confirmText: 'Delete My Account',
cancelText: 'Keep My Account',
isShow: false,
},
}
// Non-closable mask
export const NotMaskClosable: Story = {
render: args => <ConfirmDemo {...args} />,
args: {
type: 'warning',
title: 'Important Action',
content: 'This action requires your explicit choice. Clicking outside will not close this dialog.',
maskClosable: false,
isShow: false,
},
}
// Full feature demo - Playground
export const Playground: Story = {
render: args => <ConfirmDemo {...args} />,
args: {
type: 'warning',
title: 'This is a title',
content: 'This is the dialog content text...',
confirmText: undefined,
cancelText: undefined,
isLoading: false,
isDisabled: false,
showConfirm: true,
showCancel: true,
maskClosable: true,
isShow: false,
},
}

View File

@ -1,164 +0,0 @@
/**
* @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'
import { useTranslation } from 'react-i18next'
import { Button } from '@/app/components/base/ui/button'
import Tooltip from '../tooltip'
/** @deprecated Use `@/app/components/base/ui/alert-dialog` instead. */
export type IConfirm = {
className?: string
isShow: boolean
type?: 'info' | 'warning' | 'danger'
title: string
content?: React.ReactNode
confirmText?: string | null
onConfirm: () => void
cancelText?: string
onCancel: () => void
isLoading?: boolean
isDisabled?: boolean
showConfirm?: boolean
showCancel?: boolean
maskClosable?: boolean
confirmInputLabel?: string
confirmInputPlaceholder?: string
confirmInputValue?: string
onConfirmInputChange?: (value: string) => void
confirmInputMatchValue?: string
}
function Confirm({
isShow,
type = 'warning',
title,
content,
confirmText,
cancelText,
onConfirm,
onCancel,
showConfirm = true,
showCancel = true,
isLoading = false,
isDisabled = false,
maskClosable = true,
confirmInputLabel,
confirmInputPlaceholder,
confirmInputValue = '',
onConfirmInputChange,
confirmInputMatchValue,
}: IConfirm) {
const { t } = useTranslation()
const dialogRef = useRef<HTMLDivElement>(null)
const titleRef = useRef<HTMLDivElement>(null)
const [isVisible, setIsVisible] = useState(isShow)
const [isTitleTruncated, setIsTitleTruncated] = useState(false)
const confirmTxt = confirmText || `${t('operation.confirm', { ns: 'common' })}`
const cancelTxt = cancelText || `${t('operation.cancel', { ns: 'common' })}`
const isConfirmDisabled = isDisabled || (confirmInputMatchValue ? confirmInputValue !== confirmInputMatchValue : false)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape')
onCancel()
if (event.key === 'Enter' && isShow && !isConfirmDisabled) {
event.preventDefault()
onConfirm()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [onCancel, onConfirm, isShow, isConfirmDisabled])
const handleClickOutside = (event: MouseEvent) => {
if (maskClosable && dialogRef.current && !dialogRef.current.contains(event.target as Node))
onCancel()
}
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [maskClosable])
useEffect(() => {
if (isShow) {
setIsVisible(true)
}
else {
const timer = setTimeout(() => setIsVisible(false), 200)
return () => clearTimeout(timer)
}
}, [isShow])
useEffect(() => {
if (titleRef.current) {
const isOverflowing = titleRef.current.scrollWidth > titleRef.current.clientWidth
setIsTitleTruncated(isOverflowing)
}
}, [title, isVisible])
if (!isVisible)
return null
return createPortal(
<div
className="fixed inset-0 z-10000000 flex items-center justify-center bg-background-overlay"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
data-testid="confirm-overlay"
>
<div ref={dialogRef} className="relative w-full max-w-[480px] overflow-hidden">
<div className="shadows-shadow-lg flex max-w-full flex-col items-start rounded-2xl border-[0.5px] border-solid border-components-panel-border bg-components-panel-bg">
<div className="flex flex-col items-start gap-2 self-stretch pt-6 pr-6 pb-4 pl-6">
<Tooltip
popupContent={title}
disabled={!isTitleTruncated}
portalContentClassName="z-10000001!"
asChild={false}
triggerClassName="w-full"
>
<div ref={titleRef} className="w-full truncate title-2xl-semi-bold text-text-primary">
{title}
</div>
</Tooltip>
<div className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">{content}</div>
{confirmInputLabel && (
<div className="mt-2">
<label className="mb-1 block system-sm-regular text-text-secondary">
{confirmInputLabel}
</label>
<input
type="text"
className="border-components-input-border bg-components-input-bg focus:border-components-input-border-focus focus:ring-components-input-border-focus h-9 w-full rounded-lg border px-3 text-sm text-text-primary placeholder:text-text-quaternary focus:ring-1 focus:outline-hidden"
placeholder={confirmInputPlaceholder}
value={confirmInputValue}
onChange={e => onConfirmInputChange?.(e.target.value)}
/>
</div>
)}
</div>
<div className="flex items-start justify-end gap-2 self-stretch p-6">
{showCancel && <Button onClick={onCancel}>{cancelTxt}</Button>}
{showConfirm && <Button variant="primary" tone={type !== 'info' ? 'destructive' : 'default'} loading={isLoading} disabled={isConfirmDisabled} onClick={onConfirm}>{confirmTxt}</Button>}
</div>
</div>
</div>
</div>,
document.body,
)
}
export default React.memo(Confirm)

View File

@ -3,8 +3,16 @@ import type { Tag } from '@/app/components/base/tag-management/constant'
import { useDebounceFn } from 'ahooks'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import Tooltip from '@/app/components/base/tooltip'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { toast } from '@/app/components/base/ui/toast'
import { deleteTag, updateTag } from '@/service/tag'
import { cn } from '@/utils/classnames'
@ -118,16 +126,34 @@ const TagItemEditor: FC<TagItemEditorProps> = ({ tag }) => {
)}
{isEditing && (<input className="shrink-0 appearance-none caret-primary-600 outline-none placeholder:text-text-quaternary" autoFocus value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && editTag(tag.id, name)} onBlur={() => editTag(tag.id, name)} />)}
</div>
<Confirm
title={`${t('tag.delete', { ns: 'common' })} "${tag.name}"`}
isShow={showRemoveModal}
content={t('tag.deleteTip', { ns: 'common' })}
onConfirm={() => {
handleRemove()
setShowRemoveModal(false)
}}
onCancel={() => setShowRemoveModal(false)}
/>
<AlertDialog open={showRemoveModal} onOpenChange={open => !open && setShowRemoveModal(false)}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle
title={`${t('tag.delete', { ns: 'common' })} "${tag.name}"`}
className="w-full truncate title-2xl-semi-bold text-text-primary"
>
{`${t('tag.delete', { ns: 'common' })} "${tag.name}"`}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('tag.deleteTip', { ns: 'common' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
onClick={() => {
handleRemove()
setShowRemoveModal(false)
}}
>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@ -37,33 +37,6 @@ vi.mock('@/utils/download', () => ({
downloadUrl: vi.fn(),
}))
// Capture Confirm callbacks
let _capturedOnConfirm: (() => void) | undefined
let _capturedOnCancel: (() => void) | undefined
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, onConfirm, onCancel, title, content }: {
isShow: boolean
onConfirm: () => void
onCancel: () => void
title: string
content: string
}) => {
_capturedOnConfirm = onConfirm
_capturedOnCancel = onCancel
return isShow
? (
<div data-testid="confirm-dialog">
<div data-testid="confirm-title">{title}</div>
<div data-testid="confirm-content">{content}</div>
<button data-testid="confirm-cancel" onClick={onCancel}>Cancel</button>
<button data-testid="confirm-submit" onClick={onConfirm}>Confirm</button>
</div>
)
: null
},
}))
// Capture Actions callbacks
let _capturedHandleDelete: (() => void) | undefined
let _capturedHandleExportDSL: (() => void) | undefined
@ -182,13 +155,14 @@ describe('TemplateCard', () => {
type: 'customized' as const,
}
const getDeleteConfirmButton = () => screen.getByRole('button', { name: 'common.operation.confirm' })
const getDeleteCancelButton = () => screen.getByRole('button', { name: 'common.operation.cancel' })
beforeEach(() => {
vi.clearAllMocks()
mockToastSuccess.mockReset()
mockToastError.mockReset()
mockIsExporting = false
_capturedOnConfirm = undefined
_capturedOnCancel = undefined
_capturedHandleDelete = undefined
_capturedHandleExportDSL = undefined
_capturedOpenEditModal = undefined
@ -507,7 +481,7 @@ describe('TemplateCard', () => {
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.deletePipeline.title')).toBeInTheDocument()
})
})
@ -517,14 +491,13 @@ describe('TemplateCard', () => {
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.deletePipeline.title')).toBeInTheDocument()
})
const cancelButton = screen.getByTestId('confirm-cancel')
fireEvent.click(cancelButton)
fireEvent.click(getDeleteCancelButton())
await waitFor(() => {
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
expect(screen.queryByText('datasetPipeline.deletePipeline.title')).not.toBeInTheDocument()
})
})
@ -539,11 +512,10 @@ describe('TemplateCard', () => {
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.deletePipeline.title')).toBeInTheDocument()
})
const confirmButton = screen.getByTestId('confirm-submit')
fireEvent.click(confirmButton)
fireEvent.click(getDeleteConfirmButton())
await waitFor(() => {
expect(mockDeletePipeline).toHaveBeenCalledWith('pipeline-1', expect.any(Object))
@ -561,11 +533,10 @@ describe('TemplateCard', () => {
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.deletePipeline.title')).toBeInTheDocument()
})
const confirmButton = screen.getByTestId('confirm-submit')
fireEvent.click(confirmButton)
fireEvent.click(getDeleteConfirmButton())
await waitFor(() => {
expect(mockInvalidCustomizedTemplateList).toHaveBeenCalled()
@ -583,14 +554,13 @@ describe('TemplateCard', () => {
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.deletePipeline.title')).toBeInTheDocument()
})
const confirmButton = screen.getByTestId('confirm-submit')
fireEvent.click(confirmButton)
fireEvent.click(getDeleteConfirmButton())
await waitFor(() => {
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
expect(screen.queryByText('datasetPipeline.deletePipeline.title')).not.toBeInTheDocument()
})
})
})

View File

@ -3,8 +3,16 @@ import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import Confirm from '@/app/components/base/confirm'
import Modal from '@/app/components/base/modal'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { toast } from '@/app/components/base/ui/toast'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { useRouter } from '@/next/navigation'
@ -156,15 +164,24 @@ const TemplateCard = ({
/>
</Modal>
)}
{showDeleteConfirm && (
<Confirm
title={t('deletePipeline.title', { ns: 'datasetPipeline' })}
content={t('deletePipeline.content', { ns: 'datasetPipeline' })}
isShow={showDeleteConfirm}
onConfirm={onConfirmDelete}
onCancel={onCancelDelete}
/>
)}
<AlertDialog open={showDeleteConfirm} onOpenChange={open => !open && onCancelDelete()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('deletePipeline.title', { ns: 'datasetPipeline' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('deletePipeline.content', { ns: 'datasetPipeline' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={onConfirmDelete}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
{showDetailModal && (
<Modal
isShow={showDetailModal}

View File

@ -7,12 +7,20 @@ import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import Divider from '@/app/components/base/divider'
import { SearchLinesSparkle } from '@/app/components/base/icons/src/vender/knowledge'
import CustomPopover from '@/app/components/base/popover'
import Switch from '@/app/components/base/switch'
import Tooltip from '@/app/components/base/tooltip'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { toast } from '@/app/components/base/ui/toast'
import { IS_CE_EDITION } from '@/config'
import { DataSourceType, DocumentActionType } from '@/models/datasets'
@ -158,7 +166,7 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele
</Tooltip>
)
: <Switch value={enabled} onChange={v => handleSwitch(v ? 'enable' : 'disable')} size="md" />}
<Divider className="!ml-4 !mr-2 !h-3" type="vertical" />
<Divider className="!mr-2 !ml-4 !h-3" type="vertical" />
</>
)}
{embeddingAvailable && (
@ -280,8 +288,24 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele
/>
</>
)}
{showModal
&& (<Confirm isShow={showModal} isLoading={deleting} isDisabled={deleting} title={t('list.delete.title', { ns: 'datasetDocuments' })} content={t('list.delete.content', { ns: 'datasetDocuments' })} confirmText={t('operation.sure', { ns: 'common' })} onConfirm={() => onOperate('delete')} onCancel={() => setShowModal(false)} />)}
<AlertDialog open={showModal} onOpenChange={open => !open && setShowModal(false)}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('list.delete.title', { ns: 'datasetDocuments' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('list.delete.content', { ns: 'datasetDocuments' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={deleting} disabled={deleting} onClick={() => onOperate('delete')}>
{t('operation.sure', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
{isShowRenameModal && currDocument && (<RenameModal datasetId={datasetId} documentId={currDocument.id} name={currDocument.name} onClose={setShowRenameModalFalse} onSaved={handleRenamed} />)}
</div>

View File

@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import BatchAction from '../batch-action'
@ -105,6 +105,21 @@ describe('BatchAction', () => {
expect(mockOnBatchDelete).toHaveBeenCalledTimes(1)
})
})
it('should close delete confirmation when cancel is clicked', async () => {
const mockOnBatchDelete = vi.fn()
render(<BatchAction {...defaultProps} onBatchDelete={mockOnBatchDelete} />)
fireEvent.click(screen.getByText(/batchAction\.delete/i))
const dialog = await screen.findByRole('alertdialog')
fireEvent.click(within(dialog).getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => {
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
expect(mockOnBatchDelete).not.toHaveBeenCalled()
})
})
// Optional props tests

View File

@ -3,10 +3,18 @@ import { RiArchive2Line, RiCheckboxCircleLine, RiCloseCircleLine, RiDeleteBinLin
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import Divider from '@/app/components/base/divider'
import { SearchLinesSparkle } from '@/app/components/base/icons/src/vender/knowledge'
import { Button } from '@/app/components/base/ui/button'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { IS_CE_EDITION } from '@/config'
import { cn } from '@/utils/classnames'
@ -147,20 +155,24 @@ const BatchAction: FC<IBatchActionProps> = ({
<span className="px-0.5">{t(`${i18nPrefix}.cancel`, { ns: 'dataset' })}</span>
</Button>
</div>
{
isShowDeleteConfirm && (
<Confirm
isShow
title={t('list.delete.title', { ns: 'datasetDocuments' })}
content={t('list.delete.content', { ns: 'datasetDocuments' })}
confirmText={t('operation.sure', { ns: 'common' })}
onConfirm={handleBatchDelete}
onCancel={hideDeleteConfirm}
isLoading={isDeleting}
isDisabled={isDeleting}
/>
)
}
<AlertDialog open={isShowDeleteConfirm} onOpenChange={open => !open && hideDeleteConfirm()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('list.delete.title', { ns: 'datasetDocuments' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('list.delete.content', { ns: 'datasetDocuments' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={isDeleting} disabled={isDeleting} onClick={handleBatchDelete}>
{t('operation.sure', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@ -5,10 +5,17 @@ import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import Confirm from '@/app/components/base/confirm'
import Divider from '@/app/components/base/divider'
import Switch from '@/app/components/base/switch'
import Tooltip from '@/app/components/base/tooltip'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import ImageList from '@/app/components/datasets/common/image-list'
import { ChunkingMode } from '@/models/datasets'
import { cn } from '@/utils/classnames'
@ -137,7 +144,7 @@ const SegmentCard: FC<ISegmentCardProps> = ({
data-testid="segment-card"
className={cn(
'chunk-card group/card w-full rounded-xl px-3',
isFullDocMode ? '' : 'pb-2 pt-2.5 hover:bg-dataset-chunk-detail-card-hover-bg',
isFullDocMode ? '' : 'pt-2.5 pb-2 hover:bg-dataset-chunk-detail-card-hover-bg',
focused.segmentContent ? 'bg-dataset-chunk-detail-card-hover-bg' : '',
className,
)}
@ -170,7 +177,7 @@ const SegmentCard: FC<ISegmentCardProps> = ({
<div className="flex items-center">
<StatusItem status={enabled ? 'enabled' : 'disabled'} reverse textCls="text-text-tertiary system-xs-regular" />
{embeddingAvailable && (
<div className="absolute -right-2.5 -top-2 z-20 hidden items-center gap-x-0.5 radius-lg border-[0.5px]
<div className="absolute -top-2 -right-2.5 z-20 hidden items-center gap-x-0.5 radius-lg border-[0.5px]
border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-md backdrop-blur-[5px] group-hover/card:flex"
>
{!archived && (
@ -254,7 +261,7 @@ const SegmentCard: FC<ISegmentCardProps> = ({
? (
<button
type="button"
className="system-xs-semibold-uppercase mb-2 mt-0.5 text-text-accent"
className="mt-0.5 mb-2 system-xs-semibold-uppercase text-text-accent"
onClick={() => onClick?.()}
>
{t('operation.viewMore', { ns: 'common' })}
@ -276,16 +283,21 @@ const SegmentCard: FC<ISegmentCardProps> = ({
/>
)
}
{showModal
&& (
<Confirm
isShow={showModal}
title={t('segment.delete', { ns: 'datasetDocuments' })}
confirmText={t('operation.sure', { ns: 'common' })}
onConfirm={async () => { await onDelete?.(id) }}
onCancel={() => setShowModal(false)}
/>
)}
<AlertDialog open={showModal} onOpenChange={open => !open && setShowModal(false)}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('segment.delete', { ns: 'datasetDocuments' })}
</AlertDialogTitle>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={async () => { await onDelete?.(id) }}>
{t('operation.sure', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@ -4,9 +4,17 @@ import { RiBook2Line, RiCloseLine, RiInformation2Line, RiLock2Fill } from '@remi
import { memo, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Confirm from '@/app/components/base/confirm'
import { PortalToFollowElem, PortalToFollowElemContent } from '@/app/components/base/portal-to-follow-elem'
import Tooltip from '@/app/components/base/tooltip'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { Button } from '@/app/components/base/ui/button'
import { toast } from '@/app/components/base/ui/toast'
import { createExternalAPI } from '@/service/datasets'
@ -176,7 +184,27 @@ const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({ data, onSave, onCan
{t('externalAPIForm.encrypted.end', { ns: 'dataset' })}
</div>
</div>
{showConfirm && (datasetBindings?.length ?? 0) > 0 && (<Confirm isShow={showConfirm} type="warning" title="Warning" content={`${t('editExternalAPIConfirmWarningContent.front', { ns: 'dataset' })} ${datasetBindings?.length} ${t('editExternalAPIConfirmWarningContent.end', { ns: 'dataset' })}`} onCancel={() => setShowConfirm(false)} onConfirm={handleSave} />)}
<AlertDialog
open={showConfirm && (datasetBindings?.length ?? 0) > 0}
onOpenChange={open => !open && setShowConfirm(false)}
>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
Warning
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{`${t('editExternalAPIConfirmWarningContent.front', { ns: 'dataset' })} ${datasetBindings?.length} ${t('editExternalAPIConfirmWarningContent.end', { ns: 'dataset' })}`}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={handleSave}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>

View File

@ -8,8 +8,16 @@ import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Confirm from '@/app/components/base/confirm'
import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { useExternalKnowledgeApi } from '@/context/external-knowledge-api-context'
import { useModalContext } from '@/context/modal-context'
import { checkUsageExternalAPI, deleteExternalAPI, fetchExternalAPI, updateExternalAPI } from '@/service/datasets'
@ -115,7 +123,7 @@ const ExternalKnowledgeAPICard: React.FC<ExternalKnowledgeAPICardProps> = ({ api
<ApiConnectionMod className="h-4 w-4" />
<div className="system-sm-medium">{api.name}</div>
</div>
<div className="system-xs-regular self-stretch text-text-tertiary">{api.settings.endpoint}</div>
<div className="self-stretch system-xs-regular text-text-tertiary">{api.settings.endpoint}</div>
</div>
<div className="flex items-start gap-1">
<ActionButton onClick={handleEditClick}>
@ -131,20 +139,26 @@ const ExternalKnowledgeAPICard: React.FC<ExternalKnowledgeAPICardProps> = ({ api
</ActionButton>
</div>
</div>
{showConfirm && (
<Confirm
isShow={showConfirm}
title={`${t('deleteExternalAPIConfirmWarningContent.title.front', { ns: 'dataset' })} ${api.name}${t('deleteExternalAPIConfirmWarningContent.title.end', { ns: 'dataset' })}`}
content={
usageCount > 0
? `${t('deleteExternalAPIConfirmWarningContent.content.front', { ns: 'dataset' })} ${usageCount} ${t('deleteExternalAPIConfirmWarningContent.content.end', { ns: 'dataset' })}`
: t('deleteExternalAPIConfirmWarningContent.noConnectionContent', { ns: 'dataset' })
}
type="warning"
onConfirm={handleConfirmDelete}
onCancel={() => setShowConfirm(false)}
/>
)}
<AlertDialog open={showConfirm} onOpenChange={open => !open && setShowConfirm(false)}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{`${t('deleteExternalAPIConfirmWarningContent.title.front', { ns: 'dataset' })} ${api.name}${t('deleteExternalAPIConfirmWarningContent.title.end', { ns: 'dataset' })}`}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{usageCount > 0
? `${t('deleteExternalAPIConfirmWarningContent.content.front', { ns: 'dataset' })} ${usageCount} ${t('deleteExternalAPIConfirmWarningContent.content.end', { ns: 'dataset' })}`
: t('deleteExternalAPIConfirmWarningContent.noConnectionContent', { ns: 'dataset' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={handleConfirmDelete}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@ -19,28 +19,6 @@ vi.mock('../../../../rename-modal', () => ({
),
}))
// Mock Confirm component since it uses createPortal which can cause issues in tests
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, title, content, onConfirm, onCancel }: {
isShow: boolean
title: string
content?: React.ReactNode
onConfirm: () => void
onCancel: () => void
}) => (
isShow
? (
<div data-testid="confirm-modal">
<div data-testid="confirm-title">{title}</div>
<div data-testid="confirm-content">{content}</div>
<button onClick={onCancel} role="button" aria-label="cancel">Cancel</button>
<button onClick={onConfirm} role="button" aria-label="confirm">Confirm</button>
</div>
)
: null
),
}))
describe('DatasetCardModals', () => {
const mockDataset: DataSet = {
id: 'dataset-1',

View File

@ -1,7 +1,15 @@
import type { DataSet } from '@/models/datasets'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import RenameDatasetModal from '../../../rename-modal'
type ModalState = {
@ -39,15 +47,26 @@ const DatasetCardModals = ({
onSuccess={onSuccess}
/>
)}
{modalState.showConfirmDelete && (
<Confirm
title={t('deleteDatasetConfirmTitle', { ns: 'dataset' })}
content={modalState.confirmMessage}
isShow={modalState.showConfirmDelete}
onConfirm={onConfirmDelete}
onCancel={onCloseConfirm}
/>
)}
<AlertDialog open={modalState.showConfirmDelete} onOpenChange={open => !open && onCloseConfirm()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('deleteDatasetConfirmTitle', { ns: 'dataset' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{modalState.confirmMessage}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={onConfirmDelete}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@ -6,13 +6,21 @@ import { useBoolean, useHover } from 'ahooks'
import * as React from 'react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import Drawer from '@/app/components/base/drawer'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import Switch from '@/app/components/base/switch'
import Tooltip from '@/app/components/base/tooltip'
import { Button } from '@/app/components/base/ui/button'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { toast } from '@/app/components/base/ui/toast'
import CreateModal from '@/app/components/datasets/metadata/metadata-dataset/create-metadata-modal'
import { cn } from '@/utils/classnames'
@ -95,16 +103,24 @@ const Item: FC<ItemProps> = ({
<RiDeleteBinLine className="size-4 cursor-pointer" onClick={showDeleteConfirm} />
</div>
</div>
{isShowDeleteConfirm && (
<Confirm
isShow
type="warning"
title={t('metadata.datasetMetadata.deleteTitle', { ns: 'dataset' })}
content={t('metadata.datasetMetadata.deleteContent', { ns: 'dataset', name: payload.name })}
onConfirm={handleDelete}
onCancel={hideDeleteConfirm}
/>
)}
<AlertDialog open={isShowDeleteConfirm} onOpenChange={open => !open && hideDeleteConfirm()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('metadata.datasetMetadata.deleteTitle', { ns: 'dataset' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('metadata.datasetMetadata.deleteContent', { ns: 'dataset', name: payload.name })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={handleDelete}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
)

View File

@ -1,4 +1,4 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach } from 'vitest'
import SecretKeyModal from '../secret-key-modal'
@ -477,6 +477,32 @@ describe('SecretKeyModal', () => {
expect(mockDelAppApikey).not.toHaveBeenCalled()
})
it('should close confirm dialog when Escape is pressed', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
await act(async () => {
await user.click(deleteButton!)
vi.runAllTimers()
})
await waitFor(() => {
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
})
await flushTransitions()
await act(async () => {
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
vi.runAllTimers()
})
await waitFor(() => {
expect(screen.queryByText('appApi.actionMsg.deleteConfirmTitle')).not.toBeInTheDocument()
})
})
})
describe('delete key for dataset', () => {

View File

@ -7,11 +7,19 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Confirm from '@/app/components/base/confirm'
import CopyFeedback from '@/app/components/base/copy-feedback'
import Loading from '@/app/components/base/loading'
import Modal from '@/app/components/base/modal'
import { Button } from '@/app/components/base/ui/button'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { useAppContext } from '@/context/app-context'
import useTimestamp from '@/hooks/use-timestamp'
import {
@ -87,6 +95,14 @@ const SecretKeyModal = ({
return `${token.slice(0, 3)}...${token.slice(-20)}`
}
const handleDeleteConfirmOpenChange = (open: boolean) => {
if (open)
return
setDelKeyId('')
setShowConfirmDelete(false)
}
return (
<Modal isShow={isShow} onClose={onClose} title={`${t('apiKeyModal.apiSecretKey', { ns: 'appApi' })}`} className={`${s.customModal} flex flex-col px-8`}>
<div className="-mt-6 -mr-2 mb-4 flex justify-end">
@ -135,18 +151,29 @@ const SecretKeyModal = ({
</Button>
</div>
<SecretKeyGenerateModal className="shrink-0" isShow={isVisible} onClose={() => setVisible(false)} newKey={newKey} />
{showConfirmDelete && (
<Confirm
title={`${t('actionMsg.deleteConfirmTitle', { ns: 'appApi' })}`}
content={`${t('actionMsg.deleteConfirmTips', { ns: 'appApi' })}`}
isShow={showConfirmDelete}
onConfirm={onDel}
onCancel={() => {
setDelKeyId('')
setShowConfirmDelete(false)
}}
/>
)}
<AlertDialog
open={showConfirmDelete}
onOpenChange={handleDeleteConfirmOpenChange}
>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('actionMsg.deleteConfirmTitle', { ns: 'appApi' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('actionMsg.deleteConfirmTips', { ns: 'appApi' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={onDel}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</Modal>
)
}

View File

@ -105,8 +105,10 @@ describe('Item Component', () => {
// Act
fireEvent.click(screen.getByText('common.operation.delete'))
const dialog = screen.getByTestId('confirm-overlay')
const confirmButton = within(dialog).getByText('common.operation.delete')
const dialog = screen.getByRole('alertdialog', {
name: /common\.operation\.delete.*Test Extension.*\?/i,
})
const confirmButton = within(dialog).getByRole('button', { name: 'common.operation.delete' })
fireEvent.click(confirmButton)
// Assert
@ -123,8 +125,10 @@ describe('Item Component', () => {
// Act
fireEvent.click(screen.getByText('common.operation.delete'))
const dialog = screen.getByTestId('confirm-overlay')
const confirmButton = within(dialog).getByText('common.operation.delete')
const dialog = screen.getByRole('alertdialog', {
name: /common\.operation\.delete.*Test Extension.*\?/i,
})
const confirmButton = within(dialog).getByRole('button', { name: 'common.operation.delete' })
fireEvent.click(confirmButton)
// Assert
@ -133,14 +137,16 @@ describe('Item Component', () => {
})
})
it('should close delete confirmation when clicking cancel button', () => {
it('should close delete confirmation when clicking cancel button', async () => {
// Act
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.operation.delete'))
fireEvent.click(screen.getByText('common.operation.cancel'))
// Assert
expect(screen.queryByText(/common\.operation\.delete.*Test Extension.*\?/i)).not.toBeInTheDocument()
await waitFor(() => {
expect(screen.queryByText(/common\.operation\.delete.*Test Extension.*\?/i)).not.toBeInTheDocument()
})
})
it('should not call delete API when canceling deletion', () => {

View File

@ -6,7 +6,14 @@ import {
} from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { Button } from '@/app/components/base/ui/button'
import { useModalContext } from '@/context/modal-context'
import { deleteApiBasedExtension } from '@/service/common'
@ -57,18 +64,21 @@ const Item: FC<ItemProps> = ({
{t('operation.delete', { ns: 'common' })}
</Button>
</div>
{
showDeleteConfirm
&& (
<Confirm
isShow={showDeleteConfirm}
onCancel={() => setShowDeleteConfirm(false)}
title={`${t('operation.delete', { ns: 'common' })} “${data.name}”?`}
onConfirm={handleDeleteApiBasedExtension}
confirmText={t('operation.delete', { ns: 'common' }) || ''}
/>
)
}
<AlertDialog open={showDeleteConfirm} onOpenChange={open => !open && setShowDeleteConfirm(false)}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{`${t('operation.delete', { ns: 'common' })} \u201C${data.name}\u201D?`}
</AlertDialogTitle>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={handleDeleteApiBasedExtension}>
{t('operation.delete', { ns: 'common' }) || ''}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@ -8,7 +8,14 @@ import {
useRef,
} from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import {
ApiKeyModal,
usePluginAuthAction,
@ -123,7 +130,7 @@ const Card = ({
<div className="system-md-semibold text-text-primary">
{renderI18nObject(label)}
</div>
<div className="system-xs-regular flex h-4 items-center text-text-tertiary">
<div className="flex h-4 items-center system-xs-regular text-text-tertiary">
{author}
<div className="mx-0.5 text-text-quaternary">/</div>
{name}
@ -135,7 +142,7 @@ const Card = ({
onUpdate={handleAuthUpdate}
/>
</div>
<div className="system-xs-medium flex h-4 items-center pl-3 text-text-tertiary">
<div className="flex h-4 items-center pl-3 system-xs-medium text-text-tertiary">
{t('auth.connectedWorkspace', { ns: 'plugin' })}
<div className="ml-3 h-px grow bg-divider-subtle"></div>
</div>
@ -157,23 +164,27 @@ const Card = ({
{
!credentials_list.length && (
<div className="p-3 pt-1">
<div className="system-xs-regular flex h-10 items-center justify-center radius-lg bg-background-section text-text-tertiary">
<div className="flex h-10 items-center justify-center radius-lg bg-background-section system-xs-regular text-text-tertiary">
{t('auth.emptyAuth', { ns: 'plugin' })}
</div>
</div>
)
}
{
deleteCredentialId && (
<Confirm
isShow
title={t('list.delete.title', { ns: 'datasetDocuments' })}
isDisabled={doingAction}
onCancel={closeConfirm}
onConfirm={handleConfirm}
/>
)
}
<AlertDialog open={!!deleteCredentialId} onOpenChange={open => !open && closeConfirm()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('list.delete.title', { ns: 'datasetDocuments' })}
</AlertDialogTitle>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton disabled={doingAction} onClick={handleConfirm}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
{
!!editValues && (
<ApiKeyModal

View File

@ -1,5 +1,5 @@
import type { Credential, CustomModel, ModelProvider } from '../../../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { ConfigurationMethodEnum, ModelTypeEnum } from '../../../declarations'
import Authorized from '../index'
@ -198,4 +198,39 @@ describe('Authorized', () => {
fireEvent.click(screen.getByRole('button', { name: /common.operation.confirm/i }))
expect(mockHandleConfirmDelete).toHaveBeenCalled()
})
it('should close confirm dialog when deletion is cancelled', () => {
mockDeleteCredentialId = 'cred-1'
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
const dialog = screen.getByRole('alertdialog')
fireEvent.click(within(dialog).getByRole('button', { name: /operation.cancel/i }))
expect(mockCloseConfirmDelete).toHaveBeenCalledTimes(1)
})
it('should disable the confirm button while deletion is in progress', () => {
mockDeleteCredentialId = 'cred-1'
mockDoingAction = true
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
const dialog = screen.getByRole('alertdialog')
expect(within(dialog).getByRole('button', { name: /common.operation.confirm/i })).toBeDisabled()
})
})

View File

@ -19,12 +19,19 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { Button } from '@/app/components/base/ui/button'
import { cn } from '@/utils/classnames'
import { useAuth } from '../hooks'
@ -240,17 +247,21 @@ const Authorized = ({
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
{
deleteCredentialId && (
<Confirm
isShow
title={t('modelProvider.confirmDelete', { ns: 'common' })}
isDisabled={doingAction}
onCancel={closeConfirmDelete}
onConfirm={handleConfirmDelete}
/>
)
}
<AlertDialog open={!!deleteCredentialId} onOpenChange={open => !open && closeConfirmDelete()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('modelProvider.confirmDelete', { ns: 'common' })}
</AlertDialogTitle>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton disabled={doingAction} onClick={handleConfirmDelete}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@ -1,9 +1,16 @@
import type { Credential, CustomConfigurationModelFixedFields, ModelItem, ModelLoadBalancingConfig, ModelLoadBalancingConfigEntry, ModelProvider } from '../declarations'
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import Loading from '@/app/components/base/loading'
import Modal from '@/app/components/base/modal'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { Button } from '@/app/components/base/ui/button'
import { toast } from '@/app/components/base/ui/toast'
import { SwitchCredentialInLoadBalancing } from '@/app/components/header/account-setting/model-provider-page/model-auth'
@ -266,7 +273,21 @@ const ModelLoadBalancingModal = ({ provider, configurateMethod, currentCustomCon
</>
)}
</Modal>
{deleteModel && (<Confirm isShow title={t('modelProvider.confirmDelete', { ns: 'common' })} onCancel={closeConfirmDelete} onConfirm={handleDeleteModel} isDisabled={doingAction} />)}
<AlertDialog open={!!deleteModel} onOpenChange={open => !open && closeConfirmDelete()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('modelProvider.confirmDelete', { ns: 'common' })}
</AlertDialogTitle>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton disabled={doingAction} onClick={handleDeleteModel}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@ -12,12 +12,19 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { Button } from '@/app/components/base/ui/button'
import { toast } from '@/app/components/base/ui/toast'
import Indicator from '@/app/components/header/indicator'
@ -318,17 +325,21 @@ const Authorized = ({
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
{
deleteCredentialId && (
<Confirm
isShow
title={t('list.delete.title', { ns: 'datasetDocuments' })}
isDisabled={doingAction}
onCancel={closeConfirm}
onConfirm={handleConfirm}
/>
)
}
<AlertDialog open={!!deleteCredentialId} onOpenChange={open => !open && closeConfirm()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('list.delete.title', { ns: 'datasetDocuments' })}
</AlertDialogTitle>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton disabled={doingAction} onClick={handleConfirm}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
{
!!editValues && (
<ApiKeyModal

View File

@ -1,5 +1,5 @@
import type { EndpointListItem, PluginDetail } from '../../types'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import EndpointCard from '../endpoint-card'
@ -136,7 +136,6 @@ const mockPluginDetail: PluginDetail = {
describe('EndpointCard', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
// Reset failure flags
failureFlags.enable = false
failureFlags.disable = false
@ -152,6 +151,12 @@ describe('EndpointCard', () => {
vi.useRealTimers()
})
const waitForAlertDialogToClose = async () => {
await waitFor(() => {
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
}
describe('Rendering', () => {
it('should render endpoint name', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
@ -243,6 +248,7 @@ describe('EndpointCard', () => {
describe('Copy Functionality', () => {
it('should reset copy state after timeout', async () => {
vi.useFakeTimers()
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const allButtons = screen.getAllByRole('button')
@ -276,19 +282,19 @@ describe('EndpointCard', () => {
expect(mockHandleChange).toHaveBeenCalled()
})
it('should hide disable confirm and revert state when cancel clicked', () => {
it('should hide disable confirm and revert state when cancel clicked', async () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
fireEvent.click(screen.getByRole('switch'))
expect(screen.getByText('plugin.detailPanel.endpointDisableTip')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
await waitForAlertDialogToClose()
// Confirm should be hidden
expect(screen.queryByText('plugin.detailPanel.endpointDisableTip')).not.toBeInTheDocument()
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
})
it('should hide delete confirm when cancel clicked', () => {
it('should hide delete confirm when cancel clicked', async () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const allButtons = screen.getAllByRole('button')
@ -296,8 +302,7 @@ describe('EndpointCard', () => {
expect(screen.getByText('plugin.detailPanel.endpointDeleteTip')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(screen.queryByText('plugin.detailPanel.endpointDeleteTip')).not.toBeInTheDocument()
await waitForAlertDialogToClose()
})
it('should hide edit modal when cancel clicked', () => {

View File

@ -6,10 +6,17 @@ import * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Confirm from '@/app/components/base/confirm'
import { CopyCheck } from '@/app/components/base/icons/src/vender/line/files'
import Switch from '@/app/components/base/switch'
import Tooltip from '@/app/components/base/tooltip'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { toast } from '@/app/components/base/ui/toast'
import Indicator from '@/app/components/header/indicator'
import { addDefaultValue, toolCredentialToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
@ -122,6 +129,14 @@ const EndpointCard = ({
setIsCopied(true)
}
const handleDisableConfirmOpenChange = (open: boolean) => {
if (open)
return
hideDisableConfirm()
setActive(true)
}
useEffect(() => {
if (isCopied) {
const timer = setTimeout(() => {
@ -139,7 +154,7 @@ const EndpointCard = ({
<div className="rounded-xl bg-background-section-burn p-0.5">
<div className="group radius-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-2.5 pl-3">
<div className="flex items-center">
<div className="mb-1 flex h-6 grow items-center gap-1 text-text-secondary system-md-semibold">
<div className="mb-1 flex h-6 grow items-center gap-1 system-md-semibold text-text-secondary">
<RiLoginCircleLine className="h-4 w-4" />
<div>{data.name}</div>
</div>
@ -154,8 +169,8 @@ const EndpointCard = ({
</div>
{data.declaration.endpoints.filter(endpoint => !endpoint.hidden).map((endpoint, index) => (
<div key={index} className="flex h-6 items-center">
<div className="w-12 shrink-0 text-text-tertiary system-xs-regular">{endpoint.method}</div>
<div className="group/item flex grow items-center truncate text-text-secondary system-xs-regular">
<div className="w-12 shrink-0 system-xs-regular text-text-tertiary">{endpoint.method}</div>
<div className="group/item flex grow items-center truncate system-xs-regular text-text-secondary">
<div title={`${data.url}${endpoint.path}`} className="truncate">{`${data.url}${endpoint.path}`}</div>
<Tooltip popupContent={t(`operation.${isCopied ? 'copied' : 'copy'}`, { ns: 'common' })} position="top">
<ActionButton className="ml-2 hidden shrink-0 group-hover/item:flex" onClick={() => handleCopy(`${data.url}${endpoint.path}`)}>
@ -168,13 +183,13 @@ const EndpointCard = ({
</div>
<div className="flex items-center justify-between p-2 pl-3">
{active && (
<div className="flex items-center gap-1 text-util-colors-green-green-600 system-xs-semibold-uppercase">
<div className="flex items-center gap-1 system-xs-semibold-uppercase text-util-colors-green-green-600">
<Indicator color="green" />
{t('detailPanel.serviceOk', { ns: 'plugin' })}
</div>
)}
{!active && (
<div className="flex items-center gap-1 text-text-tertiary system-xs-semibold-uppercase">
<div className="flex items-center gap-1 system-xs-semibold-uppercase text-text-tertiary">
<Indicator color="gray" />
{t('detailPanel.disabled', { ns: 'plugin' })}
</div>
@ -186,27 +201,47 @@ const EndpointCard = ({
size="sm"
/>
</div>
{isShowDisableConfirm && (
<Confirm
isShow
title={t('detailPanel.endpointDisableTip', { ns: 'plugin' })}
content={<div>{t('detailPanel.endpointDisableContent', { ns: 'plugin', name: data.name })}</div>}
onCancel={() => {
hideDisableConfirm()
setActive(true)
}}
onConfirm={() => disableEndpoint(endpointID)}
/>
)}
{isShowDeleteConfirm && (
<Confirm
isShow
title={t('detailPanel.endpointDeleteTip', { ns: 'plugin' })}
content={<div>{t('detailPanel.endpointDeleteContent', { ns: 'plugin', name: data.name })}</div>}
onCancel={hideDeleteConfirm}
onConfirm={() => deleteEndpoint(endpointID)}
/>
)}
<AlertDialog
open={isShowDisableConfirm}
onOpenChange={handleDisableConfirmOpenChange}
>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('detailPanel.endpointDisableTip', { ns: 'plugin' })}
</AlertDialogTitle>
<div className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('detailPanel.endpointDisableContent', { ns: 'plugin', name: data.name })}
</div>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={() => disableEndpoint(endpointID)}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={isShowDeleteConfirm} onOpenChange={open => !open && hideDeleteConfirm()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('detailPanel.endpointDeleteTip', { ns: 'plugin' })}
</AlertDialogTitle>
<div className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('detailPanel.endpointDeleteContent', { ns: 'plugin', name: data.name })}
</div>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={() => deleteEndpoint(endpointID)}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
{isShowEndpointModal && (
<EndpointModal
formSchemas={formSchemas as any}

View File

@ -59,18 +59,18 @@ export const DeleteConfirm = (props: Props) => {
return (
<AlertDialog open={isShow} onOpenChange={handleOpenChange}>
<AlertDialogContent backdropProps={{ forceRender: true }}>
<div className="flex flex-col gap-2 px-6 pb-4 pt-6">
<AlertDialogTitle title={t(`${tPrefix}.title`, { ns: 'pluginTrigger', name: currentName })} className="w-full truncate text-text-primary title-2xl-semi-bold">
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t(`${tPrefix}.title`, { ns: 'pluginTrigger', name: currentName })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full whitespace-pre-wrap wrap-break-word text-text-tertiary system-md-regular">
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{workflowsInUse > 0
? t(`${tPrefix}.contentWithApps`, { ns: 'pluginTrigger', count: workflowsInUse })
: t(`${tPrefix}.content`, { ns: 'pluginTrigger' })}
</AlertDialogDescription>
{workflowsInUse > 0 && (
<div className="mt-6">
<div className="mb-2 text-text-secondary system-sm-medium">
<div className="mb-2 system-sm-medium text-text-secondary">
{t(`${tPrefix}.confirmInputTip`, { ns: 'pluginTrigger', name: currentName })}
</div>
<Input

View File

@ -92,30 +92,6 @@ vi.mock('../../../base/tooltip', () => ({
),
}))
// Mock Confirm - uses createPortal which has issues in test environment
vi.mock('../../../base/confirm', () => ({
default: ({ isShow, title, content, onCancel, onConfirm, isLoading, isDisabled }: {
isShow: boolean
title: string
content: React.ReactNode
onCancel: () => void
onConfirm: () => void
isLoading: boolean
isDisabled: boolean
}) => {
if (!isShow)
return null
return (
<div data-testid="confirm-modal" data-loading={isLoading} data-disabled={isDisabled}>
<div data-testid="confirm-title">{title}</div>
<div data-testid="confirm-content">{content}</div>
<button data-testid="confirm-cancel" onClick={onCancel}>Cancel</button>
<button data-testid="confirm-ok" onClick={onConfirm} disabled={isDisabled}>Confirm</button>
</div>
)
},
}))
// ==================== Test Utilities ====================
type ActionProps = {
@ -151,6 +127,9 @@ const createActionProps = (overrides: Partial<ActionProps> = {}): ActionProps =>
...overrides,
})
const getDeleteConfirmButton = () => screen.getByRole('button', { name: /common\.operation\.confirm/ })
const getDeleteCancelButton = () => screen.getByRole('button', { name: 'common.operation.cancel' })
// ==================== Tests ====================
// Helper to find action buttons (real ActionButton component uses type="button")
@ -277,8 +256,7 @@ describe('Action Component', () => {
fireEvent.click(getActionButtons()[0])
// Assert
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
expect(screen.getByTestId('confirm-title')).toHaveTextContent('plugin.action.delete')
expect(screen.getByText('plugin.action.delete')).toBeInTheDocument()
})
it('should display plugin name in delete confirm content', () => {
@ -309,12 +287,14 @@ describe('Action Component', () => {
// Act
render(<Action {...props} />)
fireEvent.click(getActionButtons()[0])
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
expect(screen.getByText('plugin.action.delete')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('confirm-cancel'))
fireEvent.click(getDeleteCancelButton())
// Assert
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
return waitFor(() => {
expect(screen.queryByText('plugin.action.delete')).not.toBeInTheDocument()
})
})
it('should call uninstallPlugin when confirm is clicked', async () => {
@ -329,7 +309,7 @@ describe('Action Component', () => {
// Act
render(<Action {...props} />)
fireEvent.click(getActionButtons()[0])
fireEvent.click(screen.getByTestId('confirm-ok'))
fireEvent.click(getDeleteConfirmButton())
// Assert
await waitFor(() => {
@ -351,7 +331,7 @@ describe('Action Component', () => {
// Act
render(<Action {...props} />)
fireEvent.click(getActionButtons()[0])
fireEvent.click(screen.getByTestId('confirm-ok'))
fireEvent.click(getDeleteConfirmButton())
// Assert
await waitFor(() => {
@ -373,7 +353,7 @@ describe('Action Component', () => {
// Act
render(<Action {...props} />)
fireEvent.click(getActionButtons()[0])
fireEvent.click(screen.getByTestId('confirm-ok'))
fireEvent.click(getDeleteConfirmButton())
// Assert
await waitFor(() => {
@ -395,7 +375,7 @@ describe('Action Component', () => {
// Act
render(<Action {...props} />)
fireEvent.click(getActionButtons()[0])
fireEvent.click(screen.getByTestId('confirm-ok'))
fireEvent.click(getDeleteConfirmButton())
// Assert
await waitFor(() => {
@ -422,17 +402,17 @@ describe('Action Component', () => {
// Act
render(<Action {...props} />)
fireEvent.click(getActionButtons()[0])
fireEvent.click(screen.getByTestId('confirm-ok'))
fireEvent.click(getDeleteConfirmButton())
// Assert - Loading state
await waitFor(() => {
expect(screen.getByTestId('confirm-modal')).toHaveAttribute('data-loading', 'true')
expect(getDeleteConfirmButton()).toBeDisabled()
})
// Resolve and check modal closes
resolveUninstall!({ success: true })
await waitFor(() => {
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
expect(screen.queryByText('plugin.action.delete')).not.toBeInTheDocument()
})
})
})
@ -699,7 +679,7 @@ describe('Action Component', () => {
// Act - First render and delete
const { rerender } = render(<Action {...props} />)
fireEvent.click(getActionButtons()[0])
fireEvent.click(screen.getByTestId('confirm-ok'))
fireEvent.click(getDeleteConfirmButton())
await waitFor(() => {
expect(mockUninstallPlugin).toHaveBeenCalledWith('stable-install-id')
@ -709,7 +689,7 @@ describe('Action Component', () => {
mockUninstallPlugin.mockClear()
rerender(<Action {...props} />)
fireEvent.click(getActionButtons()[0])
fireEvent.click(screen.getByTestId('confirm-ok'))
fireEvent.click(getDeleteConfirmButton())
await waitFor(() => {
expect(mockUninstallPlugin).toHaveBeenCalledWith('stable-install-id')
@ -735,7 +715,7 @@ describe('Action Component', () => {
// Act
const { rerender } = render(<Action {...props1} />)
fireEvent.click(getActionButtons()[0])
fireEvent.click(screen.getByTestId('confirm-ok'))
fireEvent.click(getDeleteConfirmButton())
await waitFor(() => {
expect(mockUninstallPlugin).toHaveBeenCalledWith('install-1')
@ -744,7 +724,7 @@ describe('Action Component', () => {
mockUninstallPlugin.mockClear()
rerender(<Action {...props2} />)
fireEvent.click(getActionButtons()[0])
fireEvent.click(screen.getByTestId('confirm-ok'))
fireEvent.click(getDeleteConfirmButton())
await waitFor(() => {
expect(mockUninstallPlugin).toHaveBeenCalledWith('install-2')
@ -772,7 +752,7 @@ describe('Action Component', () => {
// Act
const { rerender } = render(<Action {...props1} />)
fireEvent.click(getActionButtons()[0])
fireEvent.click(screen.getByTestId('confirm-ok'))
fireEvent.click(getDeleteConfirmButton())
await waitFor(() => {
expect(onDelete1).toHaveBeenCalled()
@ -781,7 +761,7 @@ describe('Action Component', () => {
rerender(<Action {...props2} />)
fireEvent.click(getActionButtons()[0])
fireEvent.click(screen.getByTestId('confirm-ok'))
fireEvent.click(getDeleteConfirmButton())
await waitFor(() => {
expect(onDelete2).toHaveBeenCalled()
@ -847,17 +827,16 @@ describe('Action Component', () => {
// Act
render(<Action {...props} />)
fireEvent.click(getActionButtons()[0])
fireEvent.click(screen.getByTestId('confirm-ok'))
fireEvent.click(getDeleteConfirmButton())
// The confirm button should be disabled during deletion
expect(screen.getByTestId('confirm-modal')).toHaveAttribute('data-loading', 'true')
expect(screen.getByTestId('confirm-modal')).toHaveAttribute('data-disabled', 'true')
expect(getDeleteConfirmButton()).toBeDisabled()
// Resolve the deletion
resolveFirst!({ success: true })
await waitFor(() => {
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
expect(screen.queryByText('plugin.action.delete')).not.toBeInTheDocument()
})
})

View File

@ -7,12 +7,19 @@ import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { toast } from '@/app/components/base/ui/toast'
import { useModalContext } from '@/context/modal-context'
import { uninstallPlugin } from '@/service/plugins'
import { useInvalidateInstalledPluginList } from '@/service/use-plugins'
import ActionButton from '../../base/action-button'
import Confirm from '../../base/confirm'
import Tooltip from '../../base/tooltip'
import { checkForUpdates, fetchReleases } from '../install-plugin/hooks'
import PluginInfo from '../plugin-page/plugin-info'
@ -151,24 +158,27 @@ const Action: FC<Props> = ({
onHide={hidePluginInfo}
/>
)}
<Confirm
isShow={isShowDeleteConfirm}
title={t(`${i18nPrefix}.delete`, { ns: 'plugin' })}
content={(
<div>
{t(`${i18nPrefix}.deleteContentLeft`, { ns: 'plugin' })}
<span className="system-md-semibold">{pluginName}</span>
{t(`${i18nPrefix}.deleteContentRight`, { ns: 'plugin' })}
<br />
{/* // todo: add usedInApps */}
{/* {usedInApps > 0 && t(`${i18nPrefix}.usedInApps`, { num: usedInApps })} */}
<AlertDialog open={isShowDeleteConfirm} onOpenChange={open => !open && hideDeleteConfirm()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t(`${i18nPrefix}.delete`, { ns: 'plugin' })}
</AlertDialogTitle>
<div className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t(`${i18nPrefix}.deleteContentLeft`, { ns: 'plugin' })}
<span className="system-md-semibold">{pluginName}</span>
{t(`${i18nPrefix}.deleteContentRight`, { ns: 'plugin' })}
<br />
</div>
</div>
)}
onCancel={hideDeleteConfirm}
onConfirm={handleDelete}
isLoading={deleting}
isDisabled={deleting}
/>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={deleting} disabled={deleting} onClick={handleDelete}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Conversion from '../conversion'
@ -47,29 +47,6 @@ vi.mock('@/app/components/base/ui/button', () => ({
),
}))
vi.mock('@/app/components/base/confirm', () => ({
default: ({
isShow,
onConfirm,
onCancel,
title,
}: {
isShow: boolean
onConfirm: () => void
onCancel: () => void
title: string
}) =>
isShow
? (
<div data-testid="confirm-modal">
<span>{title}</span>
<button data-testid="confirm-btn" onClick={onConfirm}>Confirm</button>
<button data-testid="cancel-btn" onClick={onCancel}>Cancel</button>
</div>
)
: null,
}))
vi.mock('../screenshot', () => ({
default: () => <div data-testid="screenshot" />,
}))
@ -112,11 +89,10 @@ describe('Conversion', () => {
it('should show confirm modal when convert button clicked', () => {
render(<Conversion />)
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
expect(screen.queryByText('datasetPipeline.conversion.confirm.title')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('datasetPipeline.operations.convert'))
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.conversion.confirm.title')).toBeInTheDocument()
})
@ -124,17 +100,19 @@ describe('Conversion', () => {
render(<Conversion />)
fireEvent.click(screen.getByText('datasetPipeline.operations.convert'))
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.conversion.confirm.title')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('cancel-btn'))
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
return waitFor(() => {
expect(screen.queryByText('datasetPipeline.conversion.confirm.title')).not.toBeInTheDocument()
})
})
it('should call convert when confirm is clicked', () => {
render(<Conversion />)
fireEvent.click(screen.getByText('datasetPipeline.operations.convert'))
fireEvent.click(screen.getByTestId('confirm-btn'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(mockConvert).toHaveBeenCalledWith('ds-123', expect.objectContaining({
onSuccess: expect.any(Function),
@ -150,7 +128,7 @@ describe('Conversion', () => {
render(<Conversion />)
fireEvent.click(screen.getByText('datasetPipeline.operations.convert'))
fireEvent.click(screen.getByTestId('confirm-btn'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(mockToast.success).toHaveBeenCalledWith('datasetPipeline.conversion.successMessage')
expect(mockInvalidDatasetDetail).toHaveBeenCalled()
@ -164,7 +142,7 @@ describe('Conversion', () => {
render(<Conversion />)
fireEvent.click(screen.getByText('datasetPipeline.operations.convert'))
fireEvent.click(screen.getByTestId('confirm-btn'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(mockToast.error).toHaveBeenCalledWith('datasetPipeline.conversion.errorMessage')
})
@ -177,7 +155,7 @@ describe('Conversion', () => {
render(<Conversion />)
fireEvent.click(screen.getByText('datasetPipeline.operations.convert'))
fireEvent.click(screen.getByTestId('confirm-btn'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(mockToast.error).toHaveBeenCalledWith('datasetPipeline.conversion.errorMessage')
})

View File

@ -472,21 +472,23 @@ describe('Conversion', () => {
const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i })
fireEvent.click(convertButton)
// Real Confirm renders title and content via portal
// AlertDialog renders title and content via portal.
expect(screen.getByText('datasetPipeline.conversion.confirm.title')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.conversion.confirm.content')).toBeInTheDocument()
})
it('should hide confirm modal when cancel is clicked', () => {
it('should hide confirm modal when cancel is clicked', async () => {
render(<Conversion />)
const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i })
fireEvent.click(convertButton)
expect(screen.getByText('datasetPipeline.conversion.confirm.title')).toBeInTheDocument()
// Real Confirm renders cancel button with i18n text
// AlertDialog close is async because it unmounts after state updates.
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(screen.queryByText('datasetPipeline.conversion.confirm.title')).not.toBeInTheDocument()
await waitFor(() => {
expect(screen.queryByText('datasetPipeline.conversion.confirm.title')).not.toBeInTheDocument()
})
})
})

View File

@ -1,7 +1,15 @@
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { Button } from '@/app/components/base/ui/button'
import { toast } from '@/app/components/base/ui/toast'
import { useParams } from '@/next/navigation'
@ -39,6 +47,8 @@ const Conversion = () => {
const handleCancelConversion = useCallback(() => {
setShowConfirmModal(false)
}, [])
const confirmTitle = t('conversion.confirm.title', { ns: 'datasetPipeline' })
const confirmContent = t('conversion.confirm.content', { ns: 'datasetPipeline' })
return (
<div className="flex h-full w-full items-center justify-center bg-background-body p-6 pb-16">
<div className="flex rounded-2xl border-[0.5px] border-components-card-border bg-components-card-bg shadow-sm shadow-shadow-shadow-4">
@ -69,7 +79,26 @@ const Conversion = () => {
</div>
</div>
</div>
{showConfirmModal && (<Confirm title={t('conversion.confirm.title', { ns: 'datasetPipeline' })} content={t('conversion.confirm.content', { ns: 'datasetPipeline' })} isShow={showConfirmModal} onConfirm={handleConvert} onCancel={handleCancelConversion} isLoading={isPending} isDisabled={isPending} />)}
<AlertDialog open={showConfirmModal} onOpenChange={open => !open && handleCancelConversion()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{confirmTitle}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{confirmContent}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={isPending} disabled={isPending} onClick={handleConvert}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@ -688,15 +688,11 @@ describe('publisher', () => {
expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument()
})
const cancelButtons = screen.getAllByRole('button')
const cancelButton = cancelButtons.find(btn =>
btn.className.includes('cancel') || btn.textContent?.includes('Cancel'),
)
if (cancelButton)
fireEvent.click(cancelButton)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
// Note: This test verifies the confirm modal can be displayed
expect(screen.getByText('pipeline.common.confirmPublishContent')).toBeInTheDocument()
await waitFor(() => {
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
})
it('should publish when confirm is clicked in confirm modal', async () => {
@ -711,7 +707,11 @@ describe('publisher', () => {
expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument()
})
expect(screen.getByText('pipeline.common.confirmPublishContent')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mockPublishWorkflow).toHaveBeenCalled()
})
})
})

View File

@ -87,24 +87,6 @@ vi.mock('@/app/components/base/ui/button', () => ({
),
}))
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, onConfirm, onCancel, title }: {
isShow: boolean
onConfirm: () => void
onCancel: () => void
title: string
}) =>
isShow
? (
<div data-testid="confirm-modal">
<span>{title}</span>
<button data-testid="publish-confirm" onClick={onConfirm}>OK</button>
<button data-testid="publish-cancel" onClick={onCancel}>Cancel</button>
</div>
)
: null,
}))
vi.mock('@/app/components/base/divider', () => ({
default: () => <hr />,
}))

View File

@ -5,10 +5,18 @@ import { useBoolean, useKeyPress } from 'ahooks'
import { memo, useCallback, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import Confirm from '@/app/components/base/confirm'
import Divider from '@/app/components/base/divider'
import { SparklesSoft } from '@/app/components/base/icons/src/public/common'
import PremiumBadge from '@/app/components/base/premium-badge'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { Button } from '@/app/components/base/ui/button'
import { toast } from '@/app/components/base/ui/toast'
import { useChecklistBeforePublish } from '@/app/components/workflow/hooks'
@ -224,7 +232,29 @@ const Popup = () => {
</div>
</Button>
</div>
{confirmVisible && (<Confirm isShow={confirmVisible} title={t('common.confirmPublish', { ns: 'pipeline' })} content={t('common.confirmPublishContent', { ns: 'pipeline' })} onCancel={hideConfirm} onConfirm={handlePublish} isDisabled={publishing} />)}
<AlertDialog open={confirmVisible} onOpenChange={open => !open && hideConfirm()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle
title={t('common.confirmPublish', { ns: 'pipeline' })}
className="w-full truncate title-2xl-semi-bold text-text-primary"
>
{t('common.confirmPublish', { ns: 'pipeline' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('common.confirmPublishContent', { ns: 'pipeline' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton disabled={publishing} onClick={() => void handlePublish()}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
{showPublishAsKnowledgePipelineModal && (<PublishAsKnowledgePipelineModal confirmDisabled={isPublishingAsCustomizedPipeline} onConfirm={handlePublishAsKnowledgePipeline} onCancel={hidePublishAsKnowledgePipelineModal} />)}
</div>
)

View File

@ -49,31 +49,6 @@ vi.mock('../modal', () => ({
},
}))
// Mock the Confirm dialog
type ConfirmDialogProps = {
isShow: boolean
onConfirm: () => void
onCancel: () => void
isLoading: boolean
}
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, onConfirm, onCancel, isLoading }: ConfirmDialogProps) => {
if (!isShow)
return null
return (
<div data-testid="confirm-dialog">
<button data-testid="confirm-delete-btn" onClick={onConfirm} disabled={isLoading}>
{isLoading ? 'Deleting...' : 'Confirm Delete'}
</button>
<button data-testid="cancel-delete-btn" onClick={onCancel}>
Cancel
</button>
</div>
)
},
}))
// Mock the OperationDropdown
type OperationDropdownProps = {
onEdit: () => void
@ -172,6 +147,9 @@ describe('MCPCard', () => {
onDeleted: vi.fn(),
}
const getDeleteConfirmButton = () => screen.getByRole('button', { name: 'common.operation.confirm' })
const getDeleteCancelButton = () => screen.getByRole('button', { name: 'common.operation.cancel' })
beforeEach(() => {
mockUpdateMCP.mockClear()
mockDeleteMCP.mockClear()
@ -450,7 +428,7 @@ describe('MCPCard', () => {
// Confirm dialog should be shown
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.delete')).toBeInTheDocument()
})
})
@ -462,15 +440,14 @@ describe('MCPCard', () => {
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.delete')).toBeInTheDocument()
})
// Cancel
const cancelBtn = screen.getByTestId('cancel-delete-btn')
fireEvent.click(cancelBtn)
fireEvent.click(getDeleteCancelButton())
await waitFor(() => {
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
expect(screen.queryByText('tools.mcp.delete')).not.toBeInTheDocument()
})
})
@ -483,12 +460,11 @@ describe('MCPCard', () => {
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.delete')).toBeInTheDocument()
})
// Confirm delete
const confirmBtn = screen.getByTestId('confirm-delete-btn')
fireEvent.click(confirmBtn)
fireEvent.click(getDeleteConfirmButton())
await waitFor(() => {
expect(mockDeleteMCP).toHaveBeenCalledWith('mcp-1')
@ -506,12 +482,11 @@ describe('MCPCard', () => {
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.delete')).toBeInTheDocument()
})
// Confirm delete
const confirmBtn = screen.getByTestId('confirm-delete-btn')
fireEvent.click(confirmBtn)
fireEvent.click(getDeleteConfirmButton())
await waitFor(() => {
expect(mockDeleteMCP).toHaveBeenCalled()

View File

@ -84,20 +84,6 @@ vi.mock('../../modal', () => ({
},
}))
// Mock Confirm dialog
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, onConfirm, onCancel, title }: { isShow: boolean, onConfirm: () => void, onCancel: () => void, title: string }) => {
if (!isShow)
return null
return (
<div data-testid="confirm-dialog" data-title={title}>
<button data-testid="confirm-btn" onClick={onConfirm}>Confirm</button>
<button data-testid="cancel-btn" onClick={onCancel}>Cancel</button>
</div>
)
},
}))
// Mock OperationDropdown
vi.mock('../operation-dropdown', () => ({
default: ({ onEdit, onRemove }: { onEdit: () => void, onRemove: () => void }) => (
@ -165,6 +151,9 @@ describe('MCPDetailContent', () => {
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const getConfirmButton = () => screen.getByRole('button', { name: 'common.operation.confirm' })
const getCancelButton = () => screen.getByRole('button', { name: 'common.operation.cancel' })
const createMockDetail = (overrides = {}): ToolWithProvider => ({
id: 'mcp-1',
name: 'Test MCP Server',
@ -494,7 +483,7 @@ describe('MCPDetailContent', () => {
fireEvent.click(updateBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.toolUpdateConfirmTitle')).toBeInTheDocument()
})
})
@ -514,12 +503,11 @@ describe('MCPDetailContent', () => {
fireEvent.click(updateBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.toolUpdateConfirmTitle')).toBeInTheDocument()
})
// Confirm the update
const confirmBtn = screen.getByTestId('confirm-btn')
fireEvent.click(confirmBtn)
fireEvent.click(getConfirmButton())
await waitFor(() => {
expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1')
@ -636,7 +624,7 @@ describe('MCPDetailContent', () => {
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.delete')).toBeInTheDocument()
})
})
@ -648,15 +636,14 @@ describe('MCPDetailContent', () => {
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.delete')).toBeInTheDocument()
})
// Cancel
const cancelBtn = screen.getByTestId('cancel-btn')
fireEvent.click(cancelBtn)
fireEvent.click(getCancelButton())
await waitFor(() => {
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
expect(screen.queryByText('tools.mcp.delete')).not.toBeInTheDocument()
})
})
@ -669,12 +656,11 @@ describe('MCPDetailContent', () => {
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.delete')).toBeInTheDocument()
})
// Confirm delete
const confirmBtn = screen.getByTestId('confirm-btn')
fireEvent.click(confirmBtn)
fireEvent.click(getConfirmButton())
await waitFor(() => {
expect(mockDeleteMCP).toHaveBeenCalledWith('mcp-1')
@ -692,12 +678,11 @@ describe('MCPDetailContent', () => {
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.delete')).toBeInTheDocument()
})
// Confirm delete
const confirmBtn = screen.getByTestId('confirm-btn')
fireEvent.click(confirmBtn)
fireEvent.click(getConfirmButton())
await waitFor(() => {
expect(mockDeleteMCP).toHaveBeenCalled()
@ -840,15 +825,14 @@ describe('MCPDetailContent', () => {
fireEvent.click(updateBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.toolUpdateConfirmTitle')).toBeInTheDocument()
})
// Cancel the update
const cancelBtn = screen.getByTestId('cancel-btn')
fireEvent.click(cancelBtn)
fireEvent.click(getCancelButton())
await waitFor(() => {
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
expect(screen.queryByText('tools.mcp.toolUpdateConfirmTitle')).not.toBeInTheDocument()
})
})
})

View File

@ -12,8 +12,16 @@ import * as React from 'react'
import { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Confirm from '@/app/components/base/confirm'
import Tooltip from '@/app/components/base/tooltip'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { Button } from '@/app/components/base/ui/button'
import Indicator from '@/app/components/header/indicator'
import Icon from '@/app/components/plugins/card/base/card-icon'
@ -286,30 +294,42 @@ const MCPDetailContent: FC<Props> = ({
onHide={hideUpdateModal}
/>
)}
{isShowDeleteConfirm && (
<Confirm
isShow
title={t('mcp.delete', { ns: 'tools' })}
content={(
<div>
<AlertDialog open={isShowDeleteConfirm} onOpenChange={open => !open && hideDeleteConfirm()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('mcp.delete', { ns: 'tools' })}
</AlertDialogTitle>
<div className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('mcp.deleteConfirmTitle', { ns: 'tools', mcp: detail.name })}
</div>
)}
onCancel={hideDeleteConfirm}
onConfirm={handleDelete}
isLoading={deleting}
isDisabled={deleting}
/>
)}
{isShowUpdateConfirm && (
<Confirm
isShow
title={t('mcp.toolUpdateConfirmTitle', { ns: 'tools' })}
content={t('mcp.toolUpdateConfirmContent', { ns: 'tools' })}
onCancel={hideUpdateConfirm}
onConfirm={handleUpdateTools}
/>
)}
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={deleting} disabled={deleting} onClick={handleDelete}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={isShowUpdateConfirm} onOpenChange={open => !open && hideUpdateConfirm()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('mcp.toolUpdateConfirmTitle', { ns: 'tools' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('mcp.toolUpdateConfirmContent', { ns: 'tools' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={handleUpdateTools}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@ -6,12 +6,20 @@ import type { AppSSO } from '@/types/app'
import { RiEditLine, RiLoopLeftLine } from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import CopyFeedback from '@/app/components/base/copy-feedback'
import Divider from '@/app/components/base/divider'
import { Mcp } from '@/app/components/base/icons/src/vender/other'
import Switch from '@/app/components/base/switch'
import Tooltip from '@/app/components/base/tooltip'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { Button } from '@/app/components/base/ui/button'
import Indicator from '@/app/components/header/indicator'
import MCPServerModal from '@/app/components/tools/mcp/mcp-server-modal'
@ -295,16 +303,24 @@ const MCPServiceCard: FC<IAppCardProps> = ({
/>
)}
{showConfirmDelete && (
<Confirm
type="warning"
title={t('overview.appInfo.regenerate', { ns: 'appOverview' })}
content={t('mcp.server.reGen', { ns: 'tools' })}
isShow={showConfirmDelete}
onConfirm={onConfirmRegenerate}
onCancel={closeConfirmDelete}
/>
)}
<AlertDialog open={showConfirmDelete} onOpenChange={open => !open && closeConfirmDelete()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('overview.appInfo.regenerate', { ns: 'appOverview' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('mcp.server.reGen', { ns: 'tools' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={onConfirmRegenerate}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@ -4,7 +4,14 @@ import { RiHammerFill } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import Indicator from '@/app/components/header/indicator'
import Icon from '@/app/components/plugins/card/base/card-icon'
import { useAppContext } from '@/context/app-context'
@ -87,34 +94,34 @@ const MCPCard = ({
<Icon src={data.icon} />
</div>
<div className="grow">
<div className="system-md-semibold mb-1 truncate text-text-secondary" title={data.name}>{data.name}</div>
<div className="mb-1 truncate system-md-semibold text-text-secondary" title={data.name}>{data.name}</div>
<div className="system-xs-regular text-text-tertiary">{data.server_identifier}</div>
</div>
</div>
<div className="flex items-center gap-1 rounded-b-xl pb-2.5 pl-4 pr-2.5 pt-1.5">
<div className="flex items-center gap-1 rounded-b-xl pt-1.5 pr-2.5 pb-2.5 pl-4">
<div className="flex w-0 grow items-center gap-2">
<div className="flex items-center gap-1">
<RiHammerFill className="h-3 w-3 shrink-0 text-text-quaternary" />
{data.tools.length > 0 && (
<div className="system-xs-regular shrink-0 text-text-tertiary">{t('mcp.toolsCount', { ns: 'tools', count: data.tools.length })}</div>
<div className="shrink-0 system-xs-regular text-text-tertiary">{t('mcp.toolsCount', { ns: 'tools', count: data.tools.length })}</div>
)}
{!data.tools.length && (
<div className="system-xs-regular shrink-0 text-text-tertiary">{t('mcp.noTools', { ns: 'tools' })}</div>
<div className="shrink-0 system-xs-regular text-text-tertiary">{t('mcp.noTools', { ns: 'tools' })}</div>
)}
</div>
<div className={cn('system-xs-regular text-divider-deep', (!data.is_team_authorization || !data.tools.length) && 'sm:hidden')}>/</div>
<div className={cn('system-xs-regular truncate text-text-tertiary', (!data.is_team_authorization || !data.tools.length) && ' sm:hidden')} title={`${t('mcp.updateTime', { ns: 'tools' })} ${formatTimeFromNow(data.updated_at! * 1000)}`}>{`${t('mcp.updateTime', { ns: 'tools' })} ${formatTimeFromNow(data.updated_at! * 1000)}`}</div>
<div className={cn('truncate system-xs-regular text-text-tertiary', (!data.is_team_authorization || !data.tools.length) && 'sm:hidden')} title={`${t('mcp.updateTime', { ns: 'tools' })} ${formatTimeFromNow(data.updated_at! * 1000)}`}>{`${t('mcp.updateTime', { ns: 'tools' })} ${formatTimeFromNow(data.updated_at! * 1000)}`}</div>
</div>
{data.is_team_authorization && data.tools.length > 0 && <Indicator color="green" className="shrink-0" />}
{(!data.is_team_authorization || !data.tools.length) && (
<div className="system-xs-medium flex shrink-0 items-center gap-1 rounded-md border border-util-colors-red-red-500 bg-components-badge-bg-red-soft px-1.5 py-0.5 text-util-colors-red-red-500">
<div className="flex shrink-0 items-center gap-1 rounded-md border border-util-colors-red-red-500 bg-components-badge-bg-red-soft px-1.5 py-0.5 system-xs-medium text-util-colors-red-red-500">
{t('mcp.noConfigured', { ns: 'tools' })}
<Indicator color="red" />
</div>
)}
</div>
{isCurrentWorkspaceManager && (
<div className={cn('absolute right-2.5 top-2.5 hidden group-hover:block', isOperationShow && 'block')} onClick={e => e.stopPropagation()}>
<div className={cn('absolute top-2.5 right-2.5 hidden group-hover:block', isOperationShow && 'block')} onClick={e => e.stopPropagation()}>
<OperationDropdown
inCard
onOpenChange={setIsOperationShow}
@ -131,21 +138,24 @@ const MCPCard = ({
onHide={hideUpdateModal}
/>
)}
{isShowDeleteConfirm && (
<Confirm
isShow
title={t('mcp.delete', { ns: 'tools' })}
content={(
<div>
<AlertDialog open={isShowDeleteConfirm} onOpenChange={open => !open && hideDeleteConfirm()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('mcp.delete', { ns: 'tools' })}
</AlertDialogTitle>
<div className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('mcp.deleteConfirmTitle', { ns: 'tools', mcp: data.name })}
</div>
)}
onCancel={hideDeleteConfirm}
onConfirm={handleDelete}
isLoading={deleting}
isDisabled={deleting}
/>
)}
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={deleting} disabled={deleting} onClick={handleDelete}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@ -79,19 +79,6 @@ vi.mock('@/app/components/base/drawer', () => ({
isOpen ? <div data-testid="drawer">{children}</div> : null,
}))
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, onConfirm, onCancel, title }: { isShow: boolean, onConfirm: () => void, onCancel: () => void, title: string }) =>
isShow
? (
<div data-testid="confirm-dialog">
<span>{title}</span>
<button data-testid="confirm-btn" onClick={onConfirm}>Confirm</button>
<button data-testid="cancel-btn" onClick={onCancel}>Cancel</button>
</div>
)
: null,
}))
const mockToastSuccess = vi.hoisted(() => vi.fn())
const mockToastError = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/base/ui/toast', () => ({
@ -170,6 +157,9 @@ const createMockCollection = (overrides?: Partial<Collection>): Collection => ({
...overrides,
})
const getDeleteConfirmButton = () => screen.getByRole('button', { name: 'common.operation.confirm' })
const getDeleteCancelButton = () => screen.getByRole('button', { name: 'common.operation.cancel' })
describe('ProviderDetail', () => {
const mockOnHide = vi.fn()
const mockOnRefreshData = vi.fn()
@ -552,9 +542,9 @@ describe('ProviderDetail', () => {
})
fireEvent.click(screen.getByText('tools.createTool.editAction'))
fireEvent.click(screen.getByTestId('edit-remove'))
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
expect(screen.getByText('tools.createTool.deleteToolConfirmTitle')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByTestId('confirm-btn'))
fireEvent.click(getDeleteConfirmButton())
})
await waitFor(() => {
expect(mockRemoveCustomCollection).toHaveBeenCalledWith('test-collection')
@ -626,9 +616,9 @@ describe('ProviderDetail', () => {
})
fireEvent.click(screen.getByText('tools.createTool.editAction'))
fireEvent.click(screen.getByTestId('wf-remove'))
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
expect(screen.getByText('tools.createTool.deleteToolConfirmTitle')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByTestId('confirm-btn'))
fireEvent.click(getDeleteConfirmButton())
})
await waitFor(() => {
expect(mockDeleteWorkflowTool).toHaveBeenCalledWith('test-id')
@ -710,9 +700,11 @@ describe('ProviderDetail', () => {
})
fireEvent.click(screen.getByText('tools.createTool.editAction'))
fireEvent.click(screen.getByTestId('edit-remove'))
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('cancel-btn'))
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
expect(screen.getByText('tools.createTool.deleteToolConfirmTitle')).toBeInTheDocument()
fireEvent.click(getDeleteCancelButton())
await waitFor(() => {
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
})
})
})

View File

@ -8,10 +8,18 @@ import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Confirm from '@/app/components/base/confirm'
import Drawer from '@/app/components/base/drawer'
import { LinkExternal02, Settings01 } from '@/app/components/base/icons/src/vender/line/general'
import Loading from '@/app/components/base/loading'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { Button } from '@/app/components/base/ui/button'
import { toast } from '@/app/components/base/ui/toast'
import { ConfigurationMethodEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
@ -401,15 +409,24 @@ const ProviderDetail = ({
onSave={updateWorkflowToolProvider}
/>
)}
{showConfirmDelete && (
<Confirm
title={t('createTool.deleteToolConfirmTitle', { ns: 'tools' })}
content={t('createTool.deleteToolConfirmContent', { ns: 'tools' })}
isShow={showConfirmDelete}
onConfirm={handleConfirmDelete}
onCancel={() => setShowConfirmDelete(false)}
/>
)}
<AlertDialog open={showConfirmDelete} onOpenChange={open => !open && setShowConfirmDelete(false)}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('createTool.deleteToolConfirmTitle', { ns: 'tools' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('createTool.deleteToolConfirmContent', { ns: 'tools' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={handleConfirmDelete}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
</Drawer>
)

View File

@ -30,10 +30,10 @@ const WorkflowOnboardingModal: FC<WorkflowOnboardingModalProps> = ({
<div className="pb-4">
<div className="mb-6">
<DialogTitle className="mb-2 text-text-primary title-2xl-semi-bold">
<DialogTitle className="mb-2 title-2xl-semi-bold text-text-primary">
{t('onboarding.title', { ns: 'workflow' })}
</DialogTitle>
<DialogDescription className="leading-4 text-text-tertiary body-xs-regular">
<DialogDescription className="body-xs-regular leading-4 text-text-tertiary">
{t('onboarding.description', { ns: 'workflow' })}
</DialogDescription>
</div>
@ -47,7 +47,7 @@ const WorkflowOnboardingModal: FC<WorkflowOnboardingModalProps> = ({
{/* TODO: reduce z-1002 to match base/ui primitives after legacy overlay migration completes */}
<DialogPortal>
<div className="pointer-events-none fixed left-1/2 top-1/2 z-1002 flex -translate-x-1/2 translate-y-[165px] items-center gap-1 text-text-quaternary body-xs-regular">
<div className="pointer-events-none fixed top-1/2 left-1/2 z-1002 flex -translate-x-1/2 translate-y-[165px] items-center gap-1 body-xs-regular text-text-quaternary">
<span>{t('onboarding.escTip.press', { ns: 'workflow' })}</span>
<ShortcutsName keys={[t('onboarding.escTip.key', { ns: 'workflow' })]} textColor="secondary" />
<span>{t('onboarding.escTip.toDismiss', { ns: 'workflow' })}</span>

View File

@ -408,4 +408,46 @@ describe('Workflow edge event wiring', () => {
expect(store.getState().edgeMenu).toBeUndefined()
})
it('should render confirm description and clear showConfirm when cancelled', async () => {
const onConfirm = vi.fn()
const { store } = renderSubject({
initialStoreState: {
showConfirm: {
title: 'Confirm title',
desc: 'Confirm description',
onConfirm,
},
},
})
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
expect(screen.getByText('Confirm title')).toBeInTheDocument()
expect(screen.getByText('Confirm description')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => {
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
expect(store.getState().showConfirm).toBeUndefined()
expect(onConfirm).not.toHaveBeenCalled()
})
it('should call showConfirm.onConfirm when confirm is clicked', () => {
const onConfirm = vi.fn()
renderSubject({
initialStoreState: {
showConfirm: {
title: 'Confirm title',
onConfirm,
},
},
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(onConfirm).toHaveBeenCalledTimes(1)
})
})

View File

@ -23,6 +23,7 @@ import {
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import ReactFlow, {
Background,
ReactFlowProvider,
@ -34,9 +35,17 @@ import ReactFlow, {
useReactFlow,
useStoreApi,
} from 'reactflow'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { IS_DEV } from '@/config'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import dynamic from '@/next/dynamic'
import {
useAllBuiltInTools,
useAllCustomTools,
@ -103,10 +112,6 @@ import { WorkflowHistoryProvider } from './workflow-history-store'
import 'reactflow/dist/style.css'
import './style.css'
const Confirm = dynamic(() => import('@/app/components/base/confirm'), {
ssr: false,
})
const nodeTypes = {
[CUSTOM_NODE]: CustomNode,
[CUSTOM_NOTE_NODE]: CustomNoteNode,
@ -133,6 +138,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
children,
onWorkflowDataUpdate,
}) => {
const { t } = useTranslation()
const workflowContainerRef = useRef<HTMLDivElement>(null)
const workflowStore = useWorkflowStore()
const reactflow = useReactFlow()
@ -396,7 +402,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
<SyncingDataModal />
<CandidateNode />
<div
className="pointer-events-none absolute left-0 top-0 z-10 flex w-12 items-center justify-center p-1 pl-2"
className="pointer-events-none absolute top-0 left-0 z-10 flex w-12 items-center justify-center p-1 pl-2"
style={{ height: controlHeight }}
>
<Control />
@ -407,17 +413,26 @@ export const Workflow: FC<WorkflowProps> = memo(({
<EdgeContextmenu />
<SelectionContextmenu />
<HelpLine />
{
!!showConfirm && (
<Confirm
isShow
onCancel={() => setShowConfirm(undefined)}
onConfirm={showConfirm.onConfirm}
title={showConfirm.title}
content={showConfirm.desc}
/>
)
}
<AlertDialog open={!!showConfirm} onOpenChange={open => !open && setShowConfirm(undefined)}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{showConfirm?.title}
</AlertDialogTitle>
{showConfirm?.desc && (
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{showConfirm.desc}
</AlertDialogDescription>
)}
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={showConfirm?.onConfirm}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
{children}
<ReactFlow
nodeTypes={nodeTypes}

View File

@ -0,0 +1,52 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import RemoveEffectVarConfirm from '../remove-effect-var-confirm'
describe('RemoveEffectVarConfirm', () => {
it('should render title and content when open', () => {
render(
<RemoveEffectVarConfirm
isShow
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>,
)
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
expect(screen.getByText('workflow.common.effectVarConfirm.title')).toBeInTheDocument()
expect(screen.getByText('workflow.common.effectVarConfirm.content')).toBeInTheDocument()
})
it('should call onConfirm when confirm is clicked', () => {
const onConfirm = vi.fn()
render(
<RemoveEffectVarConfirm
isShow
onConfirm={onConfirm}
onCancel={vi.fn()}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(onConfirm).toHaveBeenCalledTimes(1)
})
it('should call onCancel when cancel is clicked', async () => {
const onCancel = vi.fn()
render(
<RemoveEffectVarConfirm
isShow
onConfirm={vi.fn()}
onCancel={onCancel}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => {
expect(onCancel).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -2,7 +2,15 @@
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
type Props = {
isShow: boolean
@ -17,15 +25,30 @@ const RemoveVarConfirm: FC<Props> = ({
onCancel,
}) => {
const { t } = useTranslation()
const title = t(`${i18nPrefix}.title`, { ns: 'workflow' })
const content = t(`${i18nPrefix}.content`, { ns: 'workflow' })
return (
<Confirm
isShow={isShow}
title={t(`${i18nPrefix}.title`, { ns: 'workflow' })}
content={t(`${i18nPrefix}.content`, { ns: 'workflow' })}
onConfirm={onConfirm}
onCancel={onCancel}
/>
<AlertDialog open={isShow} onOpenChange={open => !open && onCancel()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{title}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{content}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={onConfirm}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)
}
export default React.memo(RemoveVarConfirm)

View File

@ -8,7 +8,6 @@ This document tracks the migration away from legacy overlay APIs.
- `@/app/components/base/portal-to-follow-elem`
- `@/app/components/base/tooltip`
- `@/app/components/base/modal`
- `@/app/components/base/confirm`
- `@/app/components/base/select` (including `custom` / `pure`)
- `@/app/components/base/popover`
- `@/app/components/base/dropdown`

View File

@ -132,7 +132,10 @@
},
"app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"perfectionist/sort-imports": {
"count": 1
}
},
"app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-panel.tsx": {
@ -281,7 +284,7 @@
},
"app/components/app-sidebar/dataset-info/dropdown.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"ts/no-explicit-any": {
"count": 1
@ -334,14 +337,6 @@
"count": 1
}
},
"app/components/app/annotation/batch-action.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 4
}
},
"app/components/app/annotation/batch-add-annotation-modal/csv-downloader.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 7
@ -364,11 +359,6 @@
"count": 2
}
},
"app/components/app/annotation/clear-all-annotations-confirm-modal/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/app/annotation/edit-annotation-modal/edit-item/index.tsx": {
"erasable-syntax-only/enums": {
"count": 1
@ -381,9 +371,6 @@
}
},
"app/components/app/annotation/edit-annotation-modal/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 2
}
@ -423,11 +410,6 @@
"count": 8
}
},
"app/components/app/annotation/remove-annotation-confirm-modal/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/app/annotation/type.ts": {
"erasable-syntax-only/enums": {
"count": 2
@ -437,9 +419,6 @@
"erasable-syntax-only/enums": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 5
},
@ -469,9 +448,6 @@
}
},
"app/components/app/app-publisher/features-wrapper.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 4
}
@ -586,9 +562,6 @@
},
"app/components/app/configuration/config-var/index.tsx": {
"no-restricted-imports": {
"count": 2
},
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
@ -699,7 +672,7 @@
},
"app/components/app/configuration/config/automatic/get-automatic-res.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"react/set-state-in-effect": {
"count": 4
@ -762,7 +735,7 @@
},
"app/components/app/configuration/config/code-generator/get-code-generator-res.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"react/set-state-in-effect": {
"count": 4
@ -1085,7 +1058,10 @@
},
"app/components/app/switch-app-modal/index.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"perfectionist/sort-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 1
@ -1387,21 +1363,12 @@
}
},
"app/components/base/chat/chat-with-history/header-in-mobile.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 2
},
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/base/chat/chat-with-history/header/index.tsx": {
"no-restricted-imports": {
"count": 2
},
"tailwindcss/enforce-consistent-class-order": {
"count": 1
},
"ts/no-explicit-any": {
@ -1448,7 +1415,7 @@
}
},
"app/components/base/chat/chat-with-history/sidebar/index.tsx": {
"no-restricted-imports": {
"perfectionist/sort-imports": {
"count": 1
}
},
@ -1683,19 +1650,6 @@
"count": 3
}
},
"app/components/base/confirm/index.stories.tsx": {
"no-console": {
"count": 4
},
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/base/confirm/index.tsx": {
"react/set-state-in-effect": {
"count": 2
}
},
"app/components/base/content-dialog/index.stories.tsx": {
"react/set-state-in-effect": {
"count": 1
@ -3296,7 +3250,7 @@
},
"app/components/base/tag-management/tag-item-editor.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 2
@ -3709,7 +3663,7 @@
},
"app/components/datasets/create-from-pipeline/list/template-card/index.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"app/components/datasets/create-from-pipeline/list/template-card/operations.tsx": {
@ -3986,10 +3940,7 @@
},
"app/components/datasets/documents/components/operations.tsx": {
"no-restricted-imports": {
"count": 3
},
"tailwindcss/enforce-consistent-class-order": {
"count": 1
"count": 2
}
},
"app/components/datasets/documents/components/rename-modal.tsx": {
@ -4200,7 +4151,7 @@
}
},
"app/components/datasets/documents/detail/completed/common/batch-action.tsx": {
"no-restricted-imports": {
"perfectionist/sort-imports": {
"count": 1
}
},
@ -4293,10 +4244,7 @@
},
"app/components/datasets/documents/detail/completed/segment-card/index.tsx": {
"no-restricted-imports": {
"count": 2
},
"tailwindcss/enforce-consistent-class-order": {
"count": 3
"count": 1
}
},
"app/components/datasets/documents/detail/completed/segment-detail.tsx": {
@ -4426,20 +4374,12 @@
},
"app/components/datasets/external-api/external-api-modal/index.tsx": {
"no-restricted-imports": {
"count": 3
"count": 2
},
"react/set-state-in-effect": {
"count": 1
}
},
"app/components/datasets/external-api/external-knowledge-api-card/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/datasets/external-knowledge-base/create/ExternalApiSelect.tsx": {
"react/set-state-in-effect": {
"count": 1
@ -4586,11 +4526,6 @@
"count": 9
}
},
"app/components/datasets/list/dataset-card/components/dataset-card-modals.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/datasets/list/dataset-card/components/description.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
@ -4695,7 +4630,10 @@
},
"app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx": {
"no-restricted-imports": {
"count": 3
"count": 2
},
"perfectionist/sort-imports": {
"count": 1
}
},
"app/components/datasets/metadata/metadata-dataset/field.tsx": {
@ -4864,7 +4802,10 @@
},
"app/components/develop/secret-key/secret-key-modal.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"perfectionist/sort-imports": {
"count": 1
}
},
"app/components/develop/tag.tsx": {
@ -5094,11 +5035,6 @@
"count": 2
}
},
"app/components/header/account-setting/api-based-extension-page/item.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/header/account-setting/api-based-extension-page/modal.tsx": {
"no-restricted-imports": {
"count": 1
@ -5118,12 +5054,6 @@
}
},
"app/components/header/account-setting/data-source-page-new/card.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 3
},
"ts/no-explicit-any": {
"count": 2
}
@ -5298,7 +5228,7 @@
},
"app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx": {
"no-restricted-imports": {
"count": 3
"count": 2
},
"ts/no-explicit-any": {
"count": 2
@ -5502,7 +5432,7 @@
},
"app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"react/set-state-in-effect": {
"count": 1
@ -5821,7 +5751,7 @@
},
"app/components/plugins/plugin-auth/authorized/index.tsx": {
"no-restricted-imports": {
"count": 3
"count": 2
},
"ts/no-explicit-any": {
"count": 2
@ -5954,10 +5884,7 @@
},
"app/components/plugins/plugin-detail-panel/endpoint-card.tsx": {
"no-restricted-imports": {
"count": 2
},
"tailwindcss/enforce-consistent-class-order": {
"count": 5
"count": 1
},
"ts/no-explicit-any": {
"count": 2
@ -6075,11 +6002,6 @@
"count": 1
}
},
"app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 4
}
},
"app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx": {
"erasable-syntax-only/enums": {
"count": 1
@ -6212,7 +6134,7 @@
},
"app/components/plugins/plugin-item/action.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"app/components/plugins/plugin-item/index.tsx": {
@ -6392,11 +6314,6 @@
"count": 2
}
},
"app/components/rag-pipeline/components/conversion.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/rag-pipeline/components/panel/input-field/editor/form/hidden-fields.tsx": {
"react/component-hook-factories": {
"count": 1
@ -6542,11 +6459,6 @@
"count": 1
}
},
"app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
@ -6760,7 +6672,7 @@
},
"app/components/tools/mcp/detail/content.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"ts/no-explicit-any": {
"count": 3
@ -6807,7 +6719,7 @@
},
"app/components/tools/mcp/mcp-service-card.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"app/components/tools/mcp/modal.tsx": {
@ -6816,15 +6728,6 @@
}
},
"app/components/tools/mcp/provider-card.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 7
},
"tailwindcss/no-unnecessary-whitespace": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
@ -6852,11 +6755,6 @@
"count": 1
}
},
"app/components/tools/provider/detail.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/tools/provider/empty.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
@ -6914,11 +6812,6 @@
"count": 2
}
},
"app/components/workflow-app/components/workflow-onboarding-modal/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 3
}
},
"app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
@ -7371,9 +7264,6 @@
}
},
"app/components/workflow/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
@ -7724,11 +7614,6 @@
"count": 1
}
},
"app/components/workflow/nodes/_base/components/remove-effect-var-confirm.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/workflow/nodes/_base/components/retry/retry-on-node.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
@ -10363,9 +10248,6 @@
}
},
"hooks/use-pay.tsx": {
"no-restricted-imports": {
"count": 2
},
"react/set-state-in-effect": {
"count": 4
}

View File

@ -52,13 +52,6 @@ export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [
],
message: 'Deprecated: use @/app/components/base/ui/select instead. See issue #32767.',
},
{
group: [
'**/base/confirm',
'**/base/confirm/index',
],
message: 'Deprecated: use @/app/components/base/ui/alert-dialog instead. See issue #32767.',
},
{
group: [
'**/base/popover',

View File

@ -1,13 +1,22 @@
'use client'
import type { IConfirm } from '@/app/components/base/confirm'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import {
AlertDialog,
AlertDialogActions,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { useRouter, useSearchParams } from '@/next/navigation'
import { useNotionBinding } from '@/service/use-common'
type ConfirmType = Pick<IConfirm, 'type' | 'title' | 'content'>
type ConfirmType = {
type: 'info' | 'warning'
title: string
}
const useAnthropicCheckPay = () => {
const { t } = useTranslation()
@ -96,16 +105,32 @@ export const CheckModal = () => {
if (!confirmInfo || !showPayStatusModal)
return null
const description = (confirmInfo as { desc?: string }).desc || ''
return (
<Confirm
isShow
onCancel={handleCancelShowPayStatusModal}
onConfirm={handleCancelShowPayStatusModal}
showCancel={false}
type={confirmInfo.type === 'info' ? 'info' : 'warning'}
title={confirmInfo.title}
content={(confirmInfo as unknown as { desc: string }).desc || ''}
confirmText={(confirmInfo.type === 'info' && t('operation.ok', { ns: 'common' })) || ''}
/>
<AlertDialog open={showPayStatusModal} onOpenChange={open => !open && handleCancelShowPayStatusModal()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{confirmInfo.title}
</AlertDialogTitle>
{description && (
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{description}
</AlertDialogDescription>
)}
</div>
<AlertDialogActions>
<AlertDialogConfirmButton
tone={confirmInfo.type !== 'info' ? 'destructive' : 'default'}
onClick={handleCancelShowPayStatusModal}
>
{confirmInfo.type === 'info'
? t('operation.ok', { ns: 'common' })
: t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)
}