From c661d5c43a8105d964616175368f2387d75f21cf Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Thu, 16 Apr 2026 13:13:17 +0800 Subject: [PATCH] refactor(web): migrate base/popoversto ui/dropdown-menu and ui/select (#35278) Co-authored-by: CodingOnStar Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: yyh --- .../apps/app-card-operations-flow.test.tsx | 131 ++-- .../header-opts/__tests__/index.spec.tsx | 3 +- .../app/annotation/header-opts/index.tsx | 253 ++++---- .../add-member-or-group-pop.tsx | 28 +- .../components/app/app-publisher/index.tsx | 72 +-- .../config-vision/param-config.tsx | 36 +- .../app/log/__tests__/model-info.spec.tsx | 69 ++- web/app/components/app/log/model-info.tsx | 43 +- .../apps/__tests__/app-card.spec.tsx | 575 ++++++++++++++---- web/app/components/apps/app-card.tsx | 409 ++++++++----- .../base/icons/src/vender/line/files/index.ts | 1 - .../base/popover/__tests__/index.spec.tsx | 272 --------- .../components/base/popover/index.stories.tsx | 120 ---- web/app/components/base/popover/index.tsx | 127 ---- .../__tests__/selector.spec.tsx | 63 +- .../components/base/tag-management/panel.tsx | 3 +- .../base/tag-management/selector.tsx | 98 ++- .../template-card/__tests__/actions.spec.tsx | 16 +- .../list/template-card/actions.tsx | 51 +- .../list/template-card/operations.tsx | 11 +- .../language-select/__tests__/index.spec.tsx | 551 ++++------------- .../create/step-two/language-select/index.tsx | 77 ++- .../document-list/__tests__/index.spec.tsx | 3 +- .../documents/components/operations.tsx | 163 +++-- .../segment-add/__tests__/index.spec.tsx | 32 +- .../documents/detail/segment-add/index.tsx | 92 +-- .../datasets/extra-info/api-access/index.tsx | 59 +- .../datasets/extra-info/service-api/index.tsx | 59 +- .../dataset-card/__tests__/index.spec.tsx | 6 +- .../__tests__/operation-item.spec.tsx | 5 +- .../__tests__/operations-dropdown.spec.tsx | 2 +- .../list/dataset-card/operation-item.tsx | 8 +- .../datasets/list/dataset-card/operations.tsx | 23 +- .../__tests__/create-metadata-modal.spec.tsx | 65 +- .../__tests__/select-metadata-modal.spec.tsx | 76 ++- .../create-metadata-modal.tsx | 28 +- .../select-metadata-modal.tsx | 40 +- .../__tests__/json-importer.spec.tsx | 276 +++++++++ .../json-importer.tsx | 56 +- .../components/add-variable/index.tsx | 60 +- .../components/variable-modal-trigger.tsx | 52 +- .../panel/env-panel/variable-trigger.tsx | 52 +- web/docs/overlay-migration.md | 1 - web/eslint-suppressions.json | 100 +-- web/eslint.constants.mjs | 7 - 45 files changed, 2088 insertions(+), 2186 deletions(-) delete mode 100644 web/app/components/base/popover/__tests__/index.spec.tsx delete mode 100644 web/app/components/base/popover/index.stories.tsx delete mode 100644 web/app/components/base/popover/index.tsx create mode 100644 web/app/components/workflow/nodes/llm/components/json-schema-config-modal/__tests__/json-importer.spec.tsx diff --git a/web/__tests__/apps/app-card-operations-flow.test.tsx b/web/__tests__/apps/app-card-operations-flow.test.tsx index 8e45367db4..4b1c05e7ae 100644 --- a/web/__tests__/apps/app-card-operations-flow.test.tsx +++ b/web/__tests__/apps/app-card-operations-flow.test.tsx @@ -258,6 +258,10 @@ const renderAppCard = (app?: Partial) => { return render() } +const openOperationsMenu = () => { + fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' })) +} + describe('App Card Operations Flow', () => { beforeEach(() => { vi.clearAllMocks() @@ -313,32 +317,19 @@ describe('App Card Operations Flow', () => { it('should show delete confirmation and call API on confirm', async () => { renderAppCard({ id: 'app-to-delete', name: 'Deletable App' }) - // Find and click the more button (popover trigger) - const moreIcons = document.querySelectorAll('svg') - const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]')) + openOperationsMenu() + fireEvent.click(await screen.findByText('common.operation.delete')) - if (moreFill) { - const btn = moreFill.closest('[class*="cursor-pointer"]') - if (btn) - fireEvent.click(btn) + await waitFor(() => { + expect(screen.getByText('app.deleteAppConfirmTitle')).toBeInTheDocument() + }) - await waitFor(() => { - const deleteBtn = screen.queryByText('common.operation.delete') - if (deleteBtn) - fireEvent.click(deleteBtn) - }) + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Deletable App' } }) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) - await waitFor(() => { - expect(screen.getByText('app.deleteAppConfirmTitle')).toBeInTheDocument() - }) - - 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') - }) - } + await waitFor(() => { + expect(mockDeleteAppMutation).toHaveBeenCalledWith('app-to-delete') + }) }) }) @@ -347,34 +338,18 @@ describe('App Card Operations Flow', () => { it('should open edit modal and call updateAppInfo on confirm', async () => { renderAppCard({ id: 'app-edit', name: 'Editable App' }) - const moreIcons = document.querySelectorAll('svg') - const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]')) + openOperationsMenu() + fireEvent.click(await screen.findByText('app.editApp')) + fireEvent.click(await screen.findByTestId('confirm-edit')) - if (moreFill) { - const btn = moreFill.closest('[class*="cursor-pointer"]') - if (btn) - fireEvent.click(btn) - - await waitFor(() => { - const editBtn = screen.queryByText('app.editApp') - if (editBtn) - fireEvent.click(editBtn) - }) - - const confirmEdit = screen.queryByTestId('confirm-edit') - if (confirmEdit) { - fireEvent.click(confirmEdit) - - await waitFor(() => { - expect(updateAppInfo).toHaveBeenCalledWith( - expect.objectContaining({ - appID: 'app-edit', - name: 'Updated App Name', - }), - ) - }) - } - } + await waitFor(() => { + expect(updateAppInfo).toHaveBeenCalledWith( + expect.objectContaining({ + appID: 'app-edit', + name: 'Updated App Name', + }), + ) + }) }) }) @@ -383,26 +358,14 @@ describe('App Card Operations Flow', () => { it('should call exportAppConfig for completion apps', async () => { renderAppCard({ id: 'app-export', mode: AppModeEnum.COMPLETION, name: 'Export App' }) - const moreIcons = document.querySelectorAll('svg') - const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]')) + openOperationsMenu() + fireEvent.click(await screen.findByText('app.export')) - if (moreFill) { - const btn = moreFill.closest('[class*="cursor-pointer"]') - if (btn) - fireEvent.click(btn) - - await waitFor(() => { - const exportBtn = screen.queryByText('app.export') - if (exportBtn) - fireEvent.click(exportBtn) - }) - - await waitFor(() => { - expect(exportAppConfig).toHaveBeenCalledWith( - expect.objectContaining({ appID: 'app-export' }), - ) - }) - } + await waitFor(() => { + expect(exportAppConfig).toHaveBeenCalledWith( + expect.objectContaining({ appID: 'app-export' }), + ) + }) }) }) @@ -422,35 +385,21 @@ describe('App Card Operations Flow', () => { it('should show switch option for chat mode apps', async () => { renderAppCard({ id: 'app-switch', mode: AppModeEnum.CHAT }) - const moreIcons = document.querySelectorAll('svg') - const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]')) + openOperationsMenu() - if (moreFill) { - const btn = moreFill.closest('[class*="cursor-pointer"]') - if (btn) - fireEvent.click(btn) - - await waitFor(() => { - expect(screen.queryByText('app.switch')).toBeInTheDocument() - }) - } + await waitFor(() => { + expect(screen.queryByText('app.switch')).toBeInTheDocument() + }) }) it('should not show switch option for workflow apps', async () => { renderAppCard({ id: 'app-wf', mode: AppModeEnum.WORKFLOW, name: 'WF App' }) - const moreIcons = document.querySelectorAll('svg') - const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]')) + openOperationsMenu() - if (moreFill) { - const btn = moreFill.closest('[class*="cursor-pointer"]') - if (btn) - fireEvent.click(btn) - - await waitFor(() => { - expect(screen.queryByText('app.switch')).not.toBeInTheDocument() - }) - } + await waitFor(() => { + expect(screen.queryByText('app.switch')).not.toBeInTheDocument() + }) }) }) }) diff --git a/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx b/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx index 41e757d6d0..a4e2d98917 100644 --- a/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx @@ -188,8 +188,7 @@ const renderComponent = ( } const openOperationsPopover = async (user: ReturnType) => { - const trigger = document.querySelector('button.btn.btn-secondary') as HTMLButtonElement - expect(trigger).toBeTruthy() + const trigger = screen.getByRole('button', { name: 'common.operation.more' }) as HTMLButtonElement await user.click(trigger) } diff --git a/web/app/components/app/annotation/header-opts/index.tsx b/web/app/components/app/annotation/header-opts/index.tsx index 101898b87e..0871a722f8 100644 --- a/web/app/components/app/annotation/header-opts/index.tsx +++ b/web/app/components/app/annotation/header-opts/index.tsx @@ -3,21 +3,18 @@ import type { FC } from 'react' import type { AnnotationItemBasic } from '../type' import { Menu, MenuButton, MenuItems, Transition } from '@headlessui/react' import { cn } from '@langgenius/dify-ui/cn' -import { - RiAddLine, - RiDeleteBinLine, - RiMoreFill, -} from '@remixicon/react' import * as React from 'react' import { Fragment, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useCSVDownloader, } from 'react-papaparse' -import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows' -import { FileDownload02, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files' -import CustomPopover from '@/app/components/base/popover' import { Button } from '@/app/components/base/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { useLocale } from '@/context/i18n' import { LanguagesSupported } from '@/i18n-config/language' @@ -37,6 +34,120 @@ type Props = { controlUpdateList: number } +type OperationsMenuProps = { + list: AnnotationItemBasic[] + onClose: () => void + onBulkImport: () => void + onClearAll: () => void + onExportJsonl: () => void +} + +const buildAnnotationJsonlRecords = (list: AnnotationItemBasic[]) => list.map( + (item: AnnotationItemBasic) => { + return `{"messages": [{"role": "system", "content": ""}, {"role": "user", "content": ${JSON.stringify(item.question)}}, {"role": "assistant", "content": ${JSON.stringify(item.answer)}}]}` + }, +) + +const downloadAnnotationJsonl = (list: AnnotationItemBasic[], locale: string) => { + const content = buildAnnotationJsonlRecords(list).join('\n') + const file = new Blob([content], { type: 'application/jsonl' }) + downloadBlob({ data: file, fileName: `annotations-${locale}.jsonl` }) +} + +const OperationsMenu: FC = ({ + list, + onClose, + onBulkImport, + onClearAll, + onExportJsonl, +}) => { + const { t } = useTranslation() + const locale = useLocale() + const { CSVDownloader, Type } = useCSVDownloader() + const annotationUnavailable = list.length === 0 + + return ( +
+ + + + + {t('table.header.bulkExport', { ns: 'appAnnotation' })} + + + + + [item.question, item.answer]), + ]} + > + + + + + + + +
+ ) +} + const HeaderOptions: FC = ({ appId, onAdd, @@ -45,22 +156,7 @@ const HeaderOptions: FC = ({ }) => { const { t } = useTranslation() const locale = useLocale() - const { CSVDownloader, Type } = useCSVDownloader() const [list, setList] = useState([]) - const annotationUnavailable = list.length === 0 - - const listTransformer = (list: AnnotationItemBasic[]) => list.map( - (item: AnnotationItemBasic) => { - const dataString = `{"messages": [{"role": "system", "content": ""}, {"role": "user", "content": ${JSON.stringify(item.question)}}, {"role": "assistant", "content": ${JSON.stringify(item.answer)}}]}` - return dataString - }, - ) - - const JSONLOutput = () => { - const content = listTransformer(list).join('\n') - const file = new Blob([content], { type: 'application/jsonl' }) - downloadBlob({ data: file, fileName: `annotations-${locale}.jsonl` }) - } const fetchList = React.useCallback(async () => { const { data }: any = await fetchExportAnnotationList(appId) @@ -77,9 +173,16 @@ const HeaderOptions: FC = ({ const [showBulkImportModal, setShowBulkImportModal] = useState(false) const [showClearConfirm, setShowClearConfirm] = useState(false) - const handleClearAll = () => { + const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false) + const handleShowBulkImportModal = React.useCallback(() => { + setShowBulkImportModal(true) + }, []) + const handleClearAll = React.useCallback(() => { setShowClearConfirm(true) - } + }, []) + const handleExportJsonl = React.useCallback(() => { + downloadAnnotationJsonl(list, locale) + }, [list, locale]) const handleConfirmed = async () => { try { await clearAllAnnotations(appId) @@ -92,92 +195,36 @@ const HeaderOptions: FC = ({ setShowClearConfirm(false) } } - const Operations = () => { - return ( -
- - - - - {t('table.header.bulkExport', { ns: 'appAnnotation' })} - - - - - [item.question, item.answer]), - ]} - > - - - - - - - -
- ) - } const [showAddModal, setShowAddModal] = React.useState(false) return (
- } - position="br" - trigger="click" - btnElement={ - - } - btnClassName="btn btn-secondary btn-medium w-8 p-0" - className="z-20! h-fit w-[155px]!" - popupClassName="w-full! overflow-visible!" - manualClose - /> + + + + + + setIsOperationsMenuOpen(false)} + onBulkImport={handleShowBulkImportModal} + onClearAll={handleClearAll} + onExportJsonl={handleExportJsonl} + /> + + {showAddModal && ( - - - + + + + {t('operation.add', { ns: 'common' })} + + )} + /> {open && } - +
@@ -81,8 +87,8 @@ export default function AddMemberOrGroupDialog() { ) }
- - + + ) } diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 07bd04b954..6738860298 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -15,12 +15,8 @@ import { useTranslation } from 'react-i18next' import EmbeddedModal from '@/app/components/app/overview/embedded' import { useStore as useAppStore } from '@/app/components/app/store' import { trackEvent } from '@/app/components/base/amplitude' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { Button } from '@/app/components/base/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover' import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' import { WorkflowContext } from '@/app/components/workflow/context' @@ -182,20 +178,18 @@ const AppPublisher = ({ catch { } }, [onRestore]) - const handleTrigger = useCallback(() => { - const state = !open - + const handleOpenChange = useCallback((nextOpen: boolean) => { if (disabled) { setOpen(false) return } - onToggle?.(state) - setOpen(state) + onToggle?.(nextOpen) + setOpen(nextOpen) - if (state) + if (nextOpen) setPublished(false) - }, [disabled, onToggle, open]) + }, [disabled, onToggle]) const handleOpenInExplore = useCallback(async () => { await openAsyncWindow(async () => { @@ -267,26 +261,28 @@ const AppPublisher = ({ return ( <> - - - - - + + {t('common.publish', { ns: 'workflow' })} + + + )} + /> +
setShowAppAccessControl(true)} + onClick={() => { + handleOpenChange(false) + setShowAppAccessControl(true) + }} /> { setEmbeddingModalOpen(true) - handleTrigger() + handleOpenChange(false) + }} + handleOpenInExplore={() => { + handleOpenChange(false) + handleOpenInExplore() }} - handleOpenInExplore={handleOpenInExplore} handlePublish={handlePublish} hasHumanInputNode={hasHumanInputNode} hasTriggerNode={hasTriggerNode} @@ -334,7 +336,7 @@ const AppPublisher = ({ workflowToolMessage={workflowToolMessage} />
-
+ {showAppAccessControl && { setShowAppAccessControl(false) }} />} -
+ ) } diff --git a/web/app/components/app/configuration/config-vision/param-config.tsx b/web/app/components/app/configuration/config-vision/param-config.tsx index ded130ba7a..3be4c60aa9 100644 --- a/web/app/components/app/configuration/config-vision/param-config.tsx +++ b/web/app/components/app/configuration/config-vision/param-config.tsx @@ -4,12 +4,8 @@ import { cn } from '@langgenius/dify-ui/cn' import { RiSettings2Line } from '@remixicon/react' import { memo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { Button } from '@/app/components/base/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover' import ParamConfigContent from './param-config-content' const ParamsConfig: FC = () => { @@ -17,26 +13,28 @@ const ParamsConfig: FC = () => { const [open, setOpen] = useState(false) return ( - - setOpen(v => !v)}> - - - + + +
{t('voice.settings', { ns: 'appDebug' })}
+ + )} + /> +
-
-
+ + ) } export default memo(ParamsConfig) diff --git a/web/app/components/app/log/__tests__/model-info.spec.tsx b/web/app/components/app/log/__tests__/model-info.spec.tsx index 3b62406758..f41aaf4c00 100644 --- a/web/app/components/app/log/__tests__/model-info.spec.tsx +++ b/web/app/components/app/log/__tests__/model-info.spec.tsx @@ -34,17 +34,41 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-name' ), })) -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => ( -
{children}
- ), - PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( -
{children}
- ), - PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), -})) +vi.mock('@/app/components/base/ui/popover', async () => { + const React = await import('react') + const PopoverContext = React.createContext<{ open: boolean, onOpenChange?: (open: boolean) => void } | null>(null) + + return { + Popover: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + +
+ {children} +
+
+ ), + PopoverTrigger: ({ children, render }: { children?: React.ReactNode, render?: React.ReactNode }) => { + const context = React.useContext(PopoverContext) + const content = render ?? children + const handleClick = () => { + context?.onOpenChange?.(!context.open) + } + + if (React.isValidElement(content)) { + const element = content as React.ReactElement<{ onClick?: () => void }> + return React.cloneElement(element, { onClick: handleClick }) + } + + return + }, + PopoverContent: ({ children }: { children: React.ReactNode }) => { + const context = React.useContext(PopoverContext) + if (!context?.open) + return null + + return
{children}
+ }, + } +}) describe('ModelInfo', () => { const defaultModel = { @@ -92,42 +116,46 @@ describe('ModelInfo', () => { it('should be closed by default', () => { render() - expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false') + expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'false') + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() }) it('should open when info button is clicked', () => { render() - const trigger = screen.getByTestId('portal-trigger') + const trigger = screen.getByRole('button') fireEvent.click(trigger) - expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'true') + expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'true') + expect(screen.getByTestId('popover-content')).toBeInTheDocument() }) it('should close when info button is clicked again', () => { render() - const trigger = screen.getByTestId('portal-trigger') + const trigger = screen.getByRole('button') // Open fireEvent.click(trigger) - expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'true') + expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'true') // Close fireEvent.click(trigger) - expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false') + expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'false') }) }) describe('Model Parameters Display', () => { it('should render model params header', () => { render() + fireEvent.click(screen.getByRole('button')) expect(screen.getByText('detail.modelParams')).toBeInTheDocument() }) it('should render temperature parameter', () => { render() + fireEvent.click(screen.getByRole('button')) expect(screen.getByText('Temperature')).toBeInTheDocument() expect(screen.getByText('0.7')).toBeInTheDocument() @@ -135,6 +163,7 @@ describe('ModelInfo', () => { it('should render top_p parameter', () => { render() + fireEvent.click(screen.getByRole('button')) expect(screen.getByText('Top P')).toBeInTheDocument() expect(screen.getByText('0.9')).toBeInTheDocument() @@ -142,6 +171,7 @@ describe('ModelInfo', () => { it('should render presence_penalty parameter', () => { render() + fireEvent.click(screen.getByRole('button')) expect(screen.getByText('Presence Penalty')).toBeInTheDocument() expect(screen.getByText('0.1')).toBeInTheDocument() @@ -149,6 +179,7 @@ describe('ModelInfo', () => { it('should render max_tokens parameter', () => { render() + fireEvent.click(screen.getByRole('button')) expect(screen.getByText('Max Token')).toBeInTheDocument() expect(screen.getByText('2048')).toBeInTheDocument() @@ -156,6 +187,7 @@ describe('ModelInfo', () => { it('should render stop parameter as comma-separated values', () => { render() + fireEvent.click(screen.getByRole('button')) expect(screen.getByText('Stop')).toBeInTheDocument() expect(screen.getByText('END')).toBeInTheDocument() @@ -171,6 +203,7 @@ describe('ModelInfo', () => { } render() + fireEvent.click(screen.getByRole('button')) const dashes = screen.getAllByText('-') expect(dashes.length).toBeGreaterThan(0) @@ -186,6 +219,7 @@ describe('ModelInfo', () => { } render() + fireEvent.click(screen.getByRole('button')) const stopValues = screen.getAllByText('-') expect(stopValues.length).toBeGreaterThan(0) @@ -201,6 +235,7 @@ describe('ModelInfo', () => { } render() + fireEvent.click(screen.getByRole('button')) expect(screen.getByText('END,STOP,DONE')).toBeInTheDocument() }) diff --git a/web/app/components/app/log/model-info.tsx b/web/app/components/app/log/model-info.tsx index 5fe0eb590b..e768f30a50 100644 --- a/web/app/components/app/log/model-info.tsx +++ b/web/app/components/app/log/model-info.tsx @@ -6,11 +6,7 @@ import { } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' +import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover' import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelIcon from '@/app/components/header/account-setting/model-provider-page/model-icon' import ModelName from '@/app/components/header/account-setting/model-provider-page/model-name' @@ -68,26 +64,29 @@ const ModelInfo: FC = ({ showMode />
-
- setOpen(v => !v)} - className="block" - > -
+
+ +
+ )} - > - -
-
- + /> +
{t('detail.modelParams', { ns: 'appLog' })}
@@ -101,9 +100,9 @@ const ModelInfo: FC = ({ })}
-
+
-
+
) } diff --git a/web/app/components/apps/__tests__/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx index f3ae07eb79..2d01dfb320 100644 --- a/web/app/components/apps/__tests__/app-card.spec.tsx +++ b/web/app/components/apps/__tests__/app-card.spec.tsx @@ -133,6 +133,7 @@ vi.mock('@/utils/time', () => ({ // Mock dynamic imports vi.mock('@/next/dynamic', () => ({ default: (importFn: () => Promise) => { + void importFn().catch(() => {}) const fnString = importFn.toString() if (fnString.includes('create-app-modal') || fnString.includes('explore/create-app-modal')) { @@ -189,22 +190,107 @@ vi.mock('@/next/dynamic', () => ({ }, })) -// Popover uses @headlessui/react portals - mock for controlled interaction testing -vi.mock('@/app/components/base/popover', () => { - type PopoverHtmlContent = React.ReactNode | ((state: { open: boolean, onClose: () => void, onClick: () => void }) => React.ReactNode) - type MockPopoverProps = { htmlContent: PopoverHtmlContent, btnElement: React.ReactNode, btnClassName?: string | ((open: boolean) => string) } - const MockPopover = ({ htmlContent, btnElement, btnClassName }: MockPopoverProps) => { - const [isOpen, setIsOpen] = React.useState(false) - const computedClassName = typeof btnClassName === 'function' ? btnClassName(isOpen) : '' - return React.createElement('div', { 'data-testid': 'custom-popover', 'className': computedClassName }, React.createElement('div', { - 'onClick': () => setIsOpen(!isOpen), - 'data-testid': 'popover-trigger', - }, btnElement), isOpen && React.createElement('div', { - 'data-testid': 'popover-content', - 'onMouseLeave': () => setIsOpen(false), - }, typeof htmlContent === 'function' ? htmlContent({ open: isOpen, onClose: () => setIsOpen(false), onClick: () => setIsOpen(false) }) : htmlContent)) +vi.mock('@/app/components/base/ui/dropdown-menu', () => { + type DropdownMenuContextValue = { + isOpen: boolean + setOpen: (open: boolean) => void + } + const DropdownMenuContext = React.createContext(null) + + const useDropdownMenuContext = () => { + const context = React.use(DropdownMenuContext) + if (!context) + throw new Error('DropdownMenu components must be wrapped in DropdownMenu') + return context + } + + return { + DropdownMenu: ({ + children, + open = false, + onOpenChange, + }: { + children: React.ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void + }) => ( + +
+ {children} +
+
+ ), + DropdownMenuTrigger: ({ + children, + className, + onClick, + ...props + }: React.ButtonHTMLAttributes) => { + const { isOpen, setOpen } = useDropdownMenuContext() + return ( + + ) + }, + DropdownMenuContent: ({ + children, + className, + popupClassName, + }: { + children: React.ReactNode + className?: string + popupClassName?: string + }) => { + const { isOpen } = useDropdownMenuContext() + if (!isOpen) + return null + + return ( +
+ {children} +
+ ) + }, + DropdownMenuItem: ({ + children, + className, + onClick, + destructive, + }: { + children: React.ReactNode + className?: string + onClick?: React.MouseEventHandler + destructive?: boolean + }) => { + const { setOpen } = useDropdownMenuContext() + return ( + + ) + }, + DropdownMenuSeparator: () =>
, } - return { __esModule: true, default: MockPopover } }) // Tooltip uses portals - minimal mock preserving popup content as title attribute @@ -285,9 +371,9 @@ describe('AppCard', () => { it('should render app icon', () => { // AppIcon component renders the emoji icon from app data const { container } = render() - // Check that the icon container is rendered (AppIcon renders within the card) - const iconElement = container.querySelector('[class*="icon"]') || container.querySelector('img') - expect(iconElement || screen.getByText(mockApp.icon)).toBeTruthy() + const emojiIcon = container.querySelector(`em-emoji[id="${mockApp.icon}"]`) + const imageIcon = container.querySelector('img') + expect(emojiIcon || imageIcon).toBeTruthy() }) it('should render app type icon', () => { @@ -370,45 +456,45 @@ describe('AppCard', () => { }) describe('Operations Menu', () => { - it('should render operations popover', () => { + it('should render operations dropdown menu', () => { render() - expect(screen.getByTestId('custom-popover')).toBeInTheDocument() + expect(screen.getByTestId('dropdown-menu')).toBeInTheDocument() }) - it('should show edit option when popover is opened', async () => { + it('should show edit option when dropdown menu is opened', async () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { expect(screen.getByText('app.editApp')).toBeInTheDocument() }) }) - it('should show duplicate option when popover is opened', async () => { + it('should show duplicate option when dropdown menu is opened', async () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { expect(screen.getByText('app.duplicate')).toBeInTheDocument() }) }) - it('should show export option when popover is opened', async () => { + it('should show export option when dropdown menu is opened', async () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { expect(screen.getByText('app.export')).toBeInTheDocument() }) }) - it('should show delete option when popover is opened', async () => { + it('should show delete option when dropdown menu is opened', async () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { expect(screen.getByText('common.operation.delete')).toBeInTheDocument() @@ -419,7 +505,7 @@ describe('AppCard', () => { const chatApp = { ...mockApp, mode: AppModeEnum.CHAT } render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { expect(screen.getByText(/switch/i)).toBeInTheDocument() @@ -430,7 +516,7 @@ describe('AppCard', () => { const completionApp = { ...mockApp, mode: AppModeEnum.COMPLETION } render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { expect(screen.getByText(/switch/i)).toBeInTheDocument() @@ -441,7 +527,7 @@ describe('AppCard', () => { const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW } render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { expect(screen.queryByText(/switch/i)).not.toBeInTheDocument() @@ -453,7 +539,7 @@ describe('AppCard', () => { it('should open edit modal when edit button is clicked', async () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { const editButton = screen.getByText('app.editApp') @@ -468,7 +554,7 @@ describe('AppCard', () => { it('should open duplicate modal when duplicate button is clicked', async () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { const duplicateButton = screen.getByText('app.duplicate') @@ -483,16 +569,16 @@ describe('AppCard', () => { it('should open confirm dialog when delete button is clicked', async () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) - fireEvent.click(await screen.findByRole('button', { name: 'common.operation.delete' })) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) + fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) expect(await screen.findByRole('alertdialog')).toBeInTheDocument() }) it('should close confirm dialog when cancel is clicked', async () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) - fireEvent.click(await screen.findByRole('button', { name: 'common.operation.delete' })) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) + fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) expect(await screen.findByRole('alertdialog')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) await waitFor(() => { @@ -500,10 +586,23 @@ describe('AppCard', () => { }) }) + it('should not submit delete when confirmation text does not match', async () => { + render() + + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) + fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) + + const form = (await screen.findByRole('alertdialog')).querySelector('form') + expect(form).toBeTruthy() + fireEvent.submit(form!) + + expect(mockDeleteAppMutation).not.toHaveBeenCalled() + }) + it('should close edit modal when onHide is called', async () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { fireEvent.click(screen.getByText('app.editApp')) }) @@ -523,7 +622,7 @@ describe('AppCard', () => { it('should close duplicate modal when onHide is called', async () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { fireEvent.click(screen.getByText('app.duplicate')) }) @@ -539,6 +638,28 @@ describe('AppCard', () => { expect(screen.queryByTestId('duplicate-modal')).not.toBeInTheDocument() }) }) + + it('should clear delete confirmation input after closing the dialog', async () => { + render() + + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) + fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) + + const deleteInput = await screen.findByRole('textbox') + fireEvent.change(deleteInput, { target: { value: 'partial name' } }) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + + await waitFor(() => { + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) + fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) + + await waitFor(() => { + expect(screen.getByRole('textbox')).toHaveValue('') + }) + }) }) describe('Styling', () => { @@ -559,9 +680,9 @@ describe('AppCard', () => { it('should call deleteApp API when confirming delete', async () => { render() - // Open popover and click delete - fireEvent.click(screen.getByTestId('popover-trigger')) - fireEvent.click(await screen.findByRole('button', { name: 'common.operation.delete' })) + // Open dropdown menu and click delete + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) + fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) expect(await screen.findByRole('alertdialog')).toBeInTheDocument() // Fill in the confirmation input with app name @@ -578,8 +699,8 @@ describe('AppCard', () => { it('should not call onRefresh after successful delete', async () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) - fireEvent.click(await screen.findByRole('button', { name: 'common.operation.delete' })) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) + fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) expect(await screen.findByRole('alertdialog')).toBeInTheDocument() // Fill in the confirmation input with app name @@ -599,8 +720,8 @@ describe('AppCard', () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) - fireEvent.click(await screen.findByRole('button', { name: 'common.operation.delete' })) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) + fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) expect(await screen.findByRole('alertdialog')).toBeInTheDocument() // Fill in the confirmation input with app name @@ -615,10 +736,28 @@ describe('AppCard', () => { }) }) + it('should handle delete failure without an error message', async () => { + ;(mockDeleteAppMutation as Mock).mockRejectedValueOnce({}) + + render() + + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) + fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) + expect(await screen.findByRole('alertdialog')).toBeInTheDocument() + + fireEvent.change(screen.getByRole('textbox'), { target: { value: mockApp.name } }) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) + + await waitFor(() => { + expect(mockDeleteAppMutation).toHaveBeenCalled() + expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'app.appDeleteFailed' }) + }) + }) + it('should call updateAppInfo API when editing app', async () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { fireEvent.click(screen.getByText('app.editApp')) }) @@ -634,10 +773,30 @@ describe('AppCard', () => { }) }) + it('should edit successfully without onRefresh callback', async () => { + render() + + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) + await waitFor(() => { + fireEvent.click(screen.getByText('app.editApp')) + }) + + await waitFor(() => { + expect(screen.getByTestId('edit-app-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('confirm-edit-modal')) + + await waitFor(() => { + expect(appsService.updateAppInfo).toHaveBeenCalled() + expect(screen.queryByTestId('edit-app-modal')).not.toBeInTheDocument() + }) + }) + it('should call copyApp API when duplicating app', async () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { fireEvent.click(screen.getByText('app.duplicate')) }) @@ -656,7 +815,7 @@ describe('AppCard', () => { it('should call onPlanInfoChanged after successful duplication', async () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { fireEvent.click(screen.getByText('app.duplicate')) }) @@ -672,12 +831,33 @@ describe('AppCard', () => { }) }) + it('should duplicate successfully without onRefresh callback', async () => { + render() + + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) + await waitFor(() => { + fireEvent.click(screen.getByText('app.duplicate')) + }) + + await waitFor(() => { + expect(screen.getByTestId('duplicate-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('confirm-duplicate-modal')) + + await waitFor(() => { + expect(appsService.copyApp).toHaveBeenCalled() + expect(mockOnPlanInfoChanged).toHaveBeenCalled() + expect(screen.queryByTestId('duplicate-modal')).not.toBeInTheDocument() + }) + }) + it('should handle copy failure', async () => { (appsService.copyApp as Mock).mockRejectedValueOnce(new Error('Copy failed')) render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { fireEvent.click(screen.getByText('app.duplicate')) }) @@ -697,7 +877,7 @@ describe('AppCard', () => { it('should call exportAppConfig API when exporting', async () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { fireEvent.click(screen.getByText('app.export')) }) @@ -712,7 +892,7 @@ describe('AppCard', () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { fireEvent.click(screen.getByText('app.export')) }) @@ -729,7 +909,7 @@ describe('AppCard', () => { const chatApp = { ...mockApp, mode: AppModeEnum.CHAT } render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { fireEvent.click(screen.getByText('app.switch')) }) @@ -743,7 +923,7 @@ describe('AppCard', () => { const chatApp = { ...mockApp, mode: AppModeEnum.CHAT } render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { fireEvent.click(screen.getByText('app.switch')) }) @@ -763,7 +943,7 @@ describe('AppCard', () => { const chatApp = { ...mockApp, mode: AppModeEnum.CHAT } render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { fireEvent.click(screen.getByText('app.switch')) }) @@ -779,11 +959,31 @@ describe('AppCard', () => { }) }) + it('should close switch modal after success without onRefresh callback', async () => { + const chatApp = { ...mockApp, mode: AppModeEnum.CHAT } + render() + + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) + await waitFor(() => { + fireEvent.click(screen.getByText('app.switch')) + }) + + await waitFor(() => { + expect(screen.getByTestId('switch-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('confirm-switch-modal')) + + await waitFor(() => { + expect(screen.queryByTestId('switch-modal')).not.toBeInTheDocument() + }) + }) + it('should open switch modal for completion mode apps', async () => { const completionApp = { ...mockApp, mode: AppModeEnum.COMPLETION } render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { fireEvent.click(screen.getByText('app.switch')) }) @@ -795,10 +995,10 @@ describe('AppCard', () => { }) describe('Open in Explore', () => { - it('should show open in explore option when popover is opened', async () => { + it('should show open in explore option when dropdown menu is opened', async () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { expect(screen.getByText('app.openInExplore')).toBeInTheDocument() @@ -811,7 +1011,7 @@ describe('AppCard', () => { const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW } render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { fireEvent.click(screen.getByText('app.export')) }) @@ -829,7 +1029,7 @@ describe('AppCard', () => { const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW } render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { fireEvent.click(screen.getByText('app.export')) }) @@ -839,11 +1039,33 @@ describe('AppCard', () => { }) }) + it('should export workflow directly when environment_variables is undefined', async () => { + (workflowService.fetchWorkflowDraft as Mock).mockResolvedValueOnce({}) + + const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW } + render() + + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) + await waitFor(() => { + fireEvent.click(screen.getByText('app.export')) + }) + + await waitFor(() => { + expect(workflowService.fetchWorkflowDraft).toHaveBeenCalledWith(`/apps/${workflowApp.id}/workflows/draft`) + expect(appsService.exportAppConfig).toHaveBeenCalledWith({ + appID: workflowApp.id, + include: false, + }) + }) + + expect(screen.queryByTestId('dsl-export-modal')).not.toBeInTheDocument() + }) + it('should check for secret environment variables in advanced chat apps', async () => { const advancedChatApp = { ...mockApp, mode: AppModeEnum.ADVANCED_CHAT } render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { fireEvent.click(screen.getByText('app.export')) }) @@ -861,7 +1083,7 @@ describe('AppCard', () => { const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW } render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { fireEvent.click(screen.getByText('app.export')) }) @@ -952,7 +1174,7 @@ describe('AppCard', () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { fireEvent.click(screen.getByText('app.editApp')) }) @@ -969,10 +1191,32 @@ describe('AppCard', () => { }) }) + it('should fall back to the default edit failure message', async () => { + (appsService.updateAppInfo as Mock).mockRejectedValueOnce({ message: '' }) + + render() + + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) + await waitFor(() => { + fireEvent.click(screen.getByText('app.editApp')) + }) + + await waitFor(() => { + expect(screen.getByTestId('edit-app-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('confirm-edit-modal')) + + await waitFor(() => { + expect(appsService.updateAppInfo).toHaveBeenCalled() + expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'app.editFailed' }) + }) + }) + it('should close edit modal after successful edit', async () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { fireEvent.click(screen.getByText('app.editApp')) }) @@ -1011,7 +1255,7 @@ describe('AppCard', () => { const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW } render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { fireEvent.click(screen.getByText('app.export')) }) @@ -1031,7 +1275,7 @@ describe('AppCard', () => { const chatApp = createMockApp({ mode: AppModeEnum.CHAT }) render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { fireEvent.click(screen.getByText('app.switch')) }) @@ -1048,12 +1292,12 @@ describe('AppCard', () => { }) }) - it('should render popover menu with correct styling for different app modes', async () => { + it('should render dropdown menu with correct styling for different app modes', async () => { // Test completion mode styling const completionApp = createMockApp({ mode: AppModeEnum.COMPLETION }) const { unmount } = render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { expect(screen.getByText('app.editApp')).toBeInTheDocument() }) @@ -1064,7 +1308,7 @@ describe('AppCard', () => { const workflowApp = createMockApp({ mode: AppModeEnum.WORKFLOW }) render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { expect(screen.getByText('app.editApp')).toBeInTheDocument() }) @@ -1086,45 +1330,26 @@ describe('AppCard', () => { fireEvent.click(tagSelectorWrapper) }) - it('should handle popover mouse leave', async () => { + it('should close operations menu after selecting an item', async () => { render() - // Open popover - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { - expect(screen.getByTestId('popover-content')).toBeInTheDocument() + expect(screen.getByTestId('dropdown-menu-content')).toBeInTheDocument() }) - // Trigger mouse leave on the outer popover-content - fireEvent.mouseLeave(screen.getByTestId('popover-content')) + fireEvent.click(screen.getByText('app.editApp')) await waitFor(() => { - expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() + expect(screen.queryByTestId('dropdown-menu-content')).not.toBeInTheDocument() + expect(screen.getByTestId('edit-app-modal')).toBeInTheDocument() }) }) - it('should handle operations menu mouse leave', async () => { - render() - - // Open popover - fireEvent.click(screen.getByTestId('popover-trigger')) - await waitFor(() => { - expect(screen.getByText('app.editApp')).toBeInTheDocument() - }) - - // Find the Operations wrapper div (contains the menu items) - const editButton = screen.getByText('app.editApp') - const operationsWrapper = editButton.closest('div.relative') - - // Trigger mouse leave on the Operations wrapper to call onMouseLeave - if (operationsWrapper) - fireEvent.mouseLeave(operationsWrapper) - }) - it('should click open in explore button', async () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { const openInExploreBtn = screen.getByText('app.openInExplore') fireEvent.click(openInExploreBtn) @@ -1147,7 +1372,7 @@ describe('AppCard', () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { const openInExploreBtn = screen.getByText('app.openInExplore') fireEvent.click(openInExploreBtn) @@ -1173,7 +1398,7 @@ describe('AppCard', () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { const openInExploreBtn = screen.getByText('app.openInExplore') fireEvent.click(openInExploreBtn) @@ -1183,13 +1408,49 @@ describe('AppCard', () => { expect(exploreService.fetchInstalledAppList).toHaveBeenCalled() }) }) + + it('should show string errors from open in explore onError callback', async () => { + mockOpenAsyncWindow.mockImplementationOnce(async (_callback: () => Promise, options?: { onError?: (err: unknown) => void }) => { + options?.onError?.('Window failed') + }) + + render() + + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) + await waitFor(() => { + fireEvent.click(screen.getByText('app.openInExplore')) + }) + + await waitFor(() => { + expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'Window failed' }) + }) + }) + + it('should handle non-Error rejections from open in explore', async () => { + const nonErrorRejection = { toString: () => 'Window rejected' } + + mockOpenAsyncWindow.mockImplementationOnce(async () => { + return Promise.reject(nonErrorRejection) + }) + + render() + + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) + await waitFor(() => { + fireEvent.click(screen.getByText('app.openInExplore')) + }) + + await waitFor(() => { + expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'Window rejected' }) + }) + }) }) describe('Access Control', () => { it('should render operations menu correctly', async () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { expect(screen.getByText('app.editApp')).toBeInTheDocument() expect(screen.getByText('app.duplicate')).toBeInTheDocument() @@ -1215,7 +1476,7 @@ describe('AppCard', () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { const openInExploreBtn = screen.getByText('app.openInExplore') fireEvent.click(openInExploreBtn) @@ -1236,7 +1497,7 @@ describe('AppCard', () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { const openInExploreBtn = screen.getByText('app.openInExplore') fireEvent.click(openInExploreBtn) @@ -1253,7 +1514,7 @@ describe('AppCard', () => { const draftTriggerApp = createMockApp({ has_draft_trigger: true }) render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { expect(screen.getByText('app.editApp')).toBeInTheDocument() // openInExplore should not be shown for draft trigger apps @@ -1278,7 +1539,7 @@ describe('AppCard', () => { it('should show access control option when webapp_auth is enabled', async () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { expect(screen.getByText('app.accessControl')).toBeInTheDocument() }) @@ -1287,7 +1548,7 @@ describe('AppCard', () => { it('should click access control button', async () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { const accessControlBtn = screen.getByText('app.accessControl') fireEvent.click(accessControlBtn) @@ -1301,7 +1562,7 @@ describe('AppCard', () => { it('should close access control modal and call onRefresh', async () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { fireEvent.click(screen.getByText('app.accessControl')) }) @@ -1318,10 +1579,29 @@ describe('AppCard', () => { }) }) + it('should close access control modal after confirm without onRefresh callback', async () => { + render() + + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) + await waitFor(() => { + fireEvent.click(screen.getByText('app.accessControl')) + }) + + await waitFor(() => { + expect(screen.getByTestId('access-control-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('confirm-access-control')) + + await waitFor(() => { + expect(screen.queryByTestId('access-control-modal')).not.toBeInTheDocument() + }) + }) + it('should show open in explore when userCanAccessApp is true', async () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { expect(screen.getByText('app.openInExplore')).toBeInTheDocument() }) @@ -1330,7 +1610,7 @@ describe('AppCard', () => { it('should close access control modal when onClose is called', async () => { render() - fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) await waitFor(() => { fireEvent.click(screen.getByText('app.accessControl')) }) @@ -1347,4 +1627,87 @@ describe('AppCard', () => { }) }) }) + + describe('Delete dialog guards', () => { + const createMockAlertDialogModule = () => ({ + AlertDialog: ({ open, onOpenChange, children }: { open: boolean, onOpenChange?: (open: boolean) => void, children: React.ReactNode }) => ( + open + ? ( +
+ + + {children} +
+ ) + : null + ), + AlertDialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogDescription: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogActions: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogCancelButton: ({ children, ...props }: React.ButtonHTMLAttributes) => , + AlertDialogConfirmButton: ({ children, ...props }: React.ButtonHTMLAttributes & { loading?: boolean }) => , + }) + + it('should reset delete input when dialog closes', async () => { + vi.resetModules() + vi.doMock('@/app/components/base/ui/alert-dialog', createMockAlertDialogModule) + + const { default: IsolatedAppCard } = await import('../app-card') + render() + + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) + fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) + fireEvent.change(await screen.findByRole('textbox'), { target: { value: 'partial name' } }) + + fireEvent.click(screen.getByTestId('force-close-dialog')) + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) + fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) + + expect(await screen.findByRole('textbox')).toHaveValue('') + + vi.doUnmock('@/app/components/base/ui/alert-dialog') + }) + + it('should keep delete input when dialog remains open', async () => { + vi.resetModules() + vi.doMock('@/app/components/base/ui/alert-dialog', createMockAlertDialogModule) + + const { default: IsolatedAppCard } = await import('../app-card') + render() + + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) + fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) + fireEvent.change(await screen.findByRole('textbox'), { target: { value: 'partial name' } }) + + fireEvent.click(screen.getByTestId('keep-open-dialog')) + + expect(await screen.findByRole('alertdialog')).toBeInTheDocument() + expect(await screen.findByRole('textbox')).toHaveValue('partial name') + + vi.doUnmock('@/app/components/base/ui/alert-dialog') + }) + + it('should keep delete dialog open when close is requested during deletion', async () => { + vi.resetModules() + mockDeleteMutationPending = true + vi.doMock('@/app/components/base/ui/alert-dialog', createMockAlertDialogModule) + + const { default: IsolatedAppCard } = await import('../app-card') + render() + + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) + fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) + expect(await screen.findByRole('alertdialog')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('force-close-dialog')) + + expect(await screen.findByRole('alertdialog')).toBeInTheDocument() + + vi.doUnmock('@/app/components/base/ui/alert-dialog') + mockDeleteMutationPending = false + }) + }) }) diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index d48372bdf0..fe3e67b577 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -1,22 +1,18 @@ 'use client' import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal' -import type { HtmlContentProps } from '@/app/components/base/popover' import type { Tag } from '@/app/components/base/tag-management/constant' import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' import type { EnvironmentVariable } from '@/app/components/workflow/types' import type { WorkflowOnlineUser } from '@/models/app' import type { App } from '@/types/app' import { cn } from '@langgenius/dify-ui/cn' -import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill, RiVerifiedBadgeLine } from '@remixicon/react' import * as React from 'react' import { useCallback, useEffect, useId, useMemo, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { AppTypeIcon } from '@/app/components/app/type-selector' import AppIcon from '@/app/components/base/app-icon' -import Divider from '@/app/components/base/divider' import Input from '@/app/components/base/input' -import CustomPopover from '@/app/components/base/popover' import TagSelector from '@/app/components/base/tag-management/selector' import Tooltip from '@/app/components/base/tooltip' import { @@ -28,6 +24,13 @@ import { AlertDialogDescription, AlertDialogTitle, } from '@/app/components/base/ui/alert-dialog' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { toast } from '@/app/components/base/ui/toast' import { UserAvatarList } from '@/app/components/base/user-avatar-list' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' @@ -71,6 +74,134 @@ type AppCardProps = { onRefresh?: () => void } +type AppCardOperationsMenuProps = { + app: App + shouldShowSwitchOption: boolean + shouldShowOpenInExploreOption: boolean + shouldShowAccessControlOption: boolean + onEdit: () => void + onDuplicate: () => void + onExport: () => void + onSwitch: () => void + onDelete: () => void + onAccessControl: () => void +} + +const AppCardOperationsMenu: React.FC = ({ + app, + shouldShowSwitchOption, + shouldShowOpenInExploreOption, + shouldShowAccessControlOption, + onEdit, + onDuplicate, + onExport, + onSwitch, + onDelete, + onAccessControl, +}) => { + const { t } = useTranslation() + const openAsyncWindow = useAsyncWindowOpen() + + const handleMenuAction = useCallback((e: React.MouseEvent, action: () => void) => { + e.stopPropagation() + e.preventDefault() + action() + }, []) + + const handleOpenInstalledApp = useCallback(async (e: React.MouseEvent) => { + e.stopPropagation() + e.preventDefault() + try { + await openAsyncWindow(async () => { + const { installed_apps } = await fetchInstalledAppList(app.id) + if (installed_apps?.length > 0) + return `${basePath}/explore/installed/${installed_apps[0].id}` + throw new Error('No app found in Explore') + }, { + onError: (err) => { + toast.error(`${err.message || err}`) + }, + }) + } + catch (e: unknown) { + const message = e instanceof Error ? e.message : `${e}` + toast.error(message) + } + }, [app.id, openAsyncWindow]) + + return ( + <> + handleMenuAction(e, onEdit)}> + {t('editApp', { ns: 'app' })} + + + handleMenuAction(e, onDuplicate)}> + {t('duplicate', { ns: 'app' })} + + handleMenuAction(e, onExport)}> + {t('export', { ns: 'app' })} + + {shouldShowSwitchOption && ( + <> + + handleMenuAction(e, onSwitch)}> + {t('switch', { ns: 'app' })} + + + )} + {shouldShowOpenInExploreOption && ( + <> + + + {t('openInExplore', { ns: 'app' })} + + + )} + + {shouldShowAccessControlOption && ( + <> + handleMenuAction(e, onAccessControl)}> + {t('accessControl', { ns: 'app' })} + + + + )} + handleMenuAction(e, onDelete)} + > + + {t('operation.delete', { ns: 'common' })} + + + + ) +} + +type AppCardOperationsMenuContentProps = Omit + +const AppCardOperationsMenuContent: React.FC = (props) => { + const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({ + appId: props.app.id, + enabled: systemFeatures.webapp_auth.enabled, + }) + + const shouldShowOpenInExploreOption = !props.app.has_draft_trigger + && ( + !systemFeatures.webapp_auth.enabled + || (!isGettingUserCanAccessApp && Boolean(userCanAccessApp?.result)) + ) + + return ( + + ) +} + const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => { const { t } = useTranslation() const deleteAppNameInputId = useId() @@ -78,7 +209,6 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => { const { isCurrentWorkspaceEditor } = useAppContext() const { onPlanInfoChanged } = useProviderContext() const { push } = useRouter() - const openAsyncWindow = useAsyncWindowOpen() const [showEditModal, setShowEditModal] = useState(false) const [showDuplicateModal, setShowDuplicateModal] = useState(false) @@ -86,6 +216,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => { const [showConfirmDelete, setShowConfirmDelete] = useState(false) const [confirmDeleteInput, setConfirmDeleteInput] = useState('') const [showAccessControl, setShowAccessControl] = useState(false) + const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false) const [secretEnvList, setSecretEnvList] = useState([]) const { mutateAsync: mutateDeleteApp, isPending: isDeleting } = useDeleteAppMutation() @@ -121,6 +252,41 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => { void onConfirmDelete() }, [isDeleteConfirmDisabled, onConfirmDelete]) + const handleShowEditModal = useCallback(() => { + setIsOperationsMenuOpen(false) + queueMicrotask(() => { + setShowEditModal(true) + }) + }, []) + + const handleShowDuplicateModal = useCallback(() => { + setIsOperationsMenuOpen(false) + queueMicrotask(() => { + setShowDuplicateModal(true) + }) + }, []) + + const handleShowSwitchModal = useCallback(() => { + setIsOperationsMenuOpen(false) + queueMicrotask(() => { + setShowSwitchModal(true) + }) + }, []) + + const handleShowDeleteConfirm = useCallback(() => { + setIsOperationsMenuOpen(false) + queueMicrotask(() => { + setShowConfirmDelete(true) + }) + }, []) + + const handleShowAccessControl = useCallback(() => { + setIsOperationsMenuOpen(false) + queueMicrotask(() => { + setShowAccessControl(true) + }) + }, []) + const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({ name, icon_type, @@ -189,6 +355,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => { } const exportCheck = async () => { + setIsOperationsMenuOpen(false) if (app.mode !== AppModeEnum.WORKFLOW && app.mode !== AppModeEnum.ADVANCED_CHAT) { onExport() return @@ -219,136 +386,9 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => { setShowAccessControl(false) }, [onRefresh, setShowAccessControl]) - const Operations = (props: HtmlContentProps) => { - const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({ appId: app?.id, enabled: (!!props?.open && systemFeatures.webapp_auth.enabled) }) - const onMouseLeave = async () => { - props.onClose?.() - } - const onClickSettings = async (e: React.MouseEvent) => { - e.stopPropagation() - props.onClick?.() - e.preventDefault() - setShowEditModal(true) - } - const onClickDuplicate = async (e: React.MouseEvent) => { - e.stopPropagation() - props.onClick?.() - e.preventDefault() - setShowDuplicateModal(true) - } - const onClickExport = async (e: React.MouseEvent) => { - e.stopPropagation() - props.onClick?.() - e.preventDefault() - exportCheck() - } - const onClickSwitch = async (e: React.MouseEvent) => { - e.stopPropagation() - props.onClick?.() - e.preventDefault() - setShowSwitchModal(true) - } - const onClickDelete = async (e: React.MouseEvent) => { - e.stopPropagation() - props.onClick?.() - e.preventDefault() - setShowConfirmDelete(true) - } - const onClickAccessControl = async (e: React.MouseEvent) => { - e.stopPropagation() - props.onClick?.() - e.preventDefault() - setShowAccessControl(true) - } - const onClickInstalledApp = async (e: React.MouseEvent) => { - e.stopPropagation() - props.onClick?.() - e.preventDefault() - try { - await openAsyncWindow(async () => { - const { installed_apps } = await fetchInstalledAppList(app.id) - if (installed_apps?.length > 0) - return `${basePath}/explore/installed/${installed_apps[0].id}` - throw new Error('No app found in Explore') - }, { - onError: (err) => { - toast.error(`${err.message || err}`) - }, - }) - } - catch (e: unknown) { - const message = e instanceof Error ? e.message : `${e}` - toast.error(message) - } - } - return ( -
- - - - - {(app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) && ( - <> - - - - )} - { - !app.has_draft_trigger && ( - (!systemFeatures.webapp_auth.enabled) - ? ( - <> - - - - ) - : !(isGettingUserCanAccessApp || !userCanAccessApp?.result) && ( - <> - - - - ) - ) - } - - { - systemFeatures.webapp_auth.enabled && isCurrentWorkspaceEditor && ( - <> - - - - ) - } - -
- ) - } + const shouldShowSwitchOption = app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT + const shouldShowAccessControlOption = systemFeatures.webapp_auth.enabled && isCurrentWorkspaceEditor + const operationsMenuWidthClassName = shouldShowSwitchOption ? 'w-[256px]' : 'w-[216px]' const [tags, setTags] = useState(app.tags) useEffect(() => { @@ -414,28 +454,28 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
{app.access_mode === AccessMode.PUBLIC && ( - + )} {app.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && ( - + )} {app.access_mode === AccessMode.ORGANIZATION && ( - + )} {app.access_mode === AccessMode.EXTERNAL_MEMBERS && ( - + )}
-
+
{ e.preventDefault() }} > -
+
{ />
-
-
- } - position="br" - trigger="click" - btnElement={( -
- {t('operation.more', { ns: 'common' })} - -
- )} - btnClassName={open => - cn( - open ? 'bg-state-base-hover! shadow-none!' : 'bg-transparent!', - 'h-8 w-8 rounded-md border-none p-2! hover:bg-state-base-hover!', +
+
+ + + onClick={(e) => { + e.stopPropagation() + e.preventDefault() + }} + > +
+ {t('operation.more', { ns: 'common' })} + +
+
+ {isOperationsMenuOpen && ( + + {systemFeatures.webapp_auth.enabled + ? ( + + ) + : ( + + )} + + )} +
)} diff --git a/web/app/components/base/icons/src/vender/line/files/index.ts b/web/app/components/base/icons/src/vender/line/files/index.ts index afdc65cb24..cbeb3d0181 100644 --- a/web/app/components/base/icons/src/vender/line/files/index.ts +++ b/web/app/components/base/icons/src/vender/line/files/index.ts @@ -3,7 +3,6 @@ export { default as CopyCheck } from './CopyCheck' export { default as FileArrow01 } from './FileArrow01' -export { default as FileDownload02 } from './FileDownload02' export { default as FilePlus01 } from './FilePlus01' export { default as FilePlus02 } from './FilePlus02' diff --git a/web/app/components/base/popover/__tests__/index.spec.tsx b/web/app/components/base/popover/__tests__/index.spec.tsx deleted file mode 100644 index 13c846edd5..0000000000 --- a/web/app/components/base/popover/__tests__/index.spec.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import CustomPopover from '..' - -const CloseButtonContent = ({ onClick }: { onClick?: () => void }) => ( - -) - -describe('CustomPopover', () => { - const defaultProps = { - btnElement: Trigger, - htmlContent:
Popover Content
, - } - - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - if (vi.isFakeTimers?.()) - vi.clearAllTimers() - vi.restoreAllMocks() - vi.useRealTimers() - }) - - describe('Rendering', () => { - it('should render the trigger element', () => { - render() - expect(screen.getByTestId('trigger')).toBeInTheDocument() - }) - - it('should render string as htmlContent', async () => { - render() - await act(async () => { - fireEvent.click(screen.getByTestId('trigger')) - }) - expect(screen.getByText('String Content')).toBeInTheDocument() - }) - }) - - describe('Interactions', () => { - it('should toggle when clicking the button', async () => { - vi.useRealTimers() - const user = userEvent.setup() - render() - const trigger = screen.getByTestId('trigger') - - await user.click(trigger) - expect(screen.getByTestId('content')).toBeInTheDocument() - - await user.click(trigger) - - await waitFor(() => { - expect(screen.queryByTestId('content')).not.toBeInTheDocument() - }) - }) - - it('should open on hover when trigger is "hover" (default)', async () => { - render() - - expect(screen.queryByTestId('content')).not.toBeInTheDocument() - - const triggerContainer = screen.getByTestId('trigger').closest('div') - if (!triggerContainer) - throw new Error('Trigger container not found') - - await act(async () => { - fireEvent.mouseEnter(triggerContainer) - }) - - expect(screen.getByTestId('content')).toBeInTheDocument() - }) - - it('should close after delay on mouse leave when trigger is "hover"', async () => { - vi.useRealTimers() - const user = userEvent.setup() - render() - - const trigger = screen.getByTestId('trigger') - - await user.hover(trigger) - expect(screen.getByTestId('content')).toBeInTheDocument() - - await user.unhover(trigger) - - await waitFor(() => { - expect(screen.queryByTestId('content')).not.toBeInTheDocument() - }, { timeout: 2000 }) - }) - - it('should stay open when hovering over the popover content', async () => { - vi.useRealTimers() - const user = userEvent.setup() - render() - - const trigger = screen.getByTestId('trigger') - await user.hover(trigger) - expect(screen.getByTestId('content')).toBeInTheDocument() - - // Leave trigger but enter content - await user.unhover(trigger) - const content = screen.getByTestId('content') - await user.hover(content) - - // Wait for the timeout duration - await act(async () => { - await new Promise(resolve => setTimeout(resolve, 200)) - }) - - // Should still be open because we are hovering the content - expect(screen.getByTestId('content')).toBeInTheDocument() - - // Now leave content - await user.unhover(content) - - await waitFor(() => { - expect(screen.queryByTestId('content')).not.toBeInTheDocument() - }, { timeout: 2000 }) - }) - - it('should cancel close timeout when re-entering during hover delay', async () => { - render() - - const triggerContainer = screen.getByTestId('trigger').closest('div') - if (!triggerContainer) - throw new Error('Trigger container not found') - - await act(async () => { - fireEvent.mouseEnter(triggerContainer) - }) - - await act(async () => { - fireEvent.mouseLeave(triggerContainer!) - }) - - await act(async () => { - vi.advanceTimersByTime(50) // Halfway through timeout - fireEvent.mouseEnter(triggerContainer!) - }) - - await act(async () => { - vi.advanceTimersByTime(1000) // Much longer than the original timeout - }) - - expect(screen.getByTestId('content')).toBeInTheDocument() - }) - - it('should not open when disabled', async () => { - render() - - await act(async () => { - fireEvent.click(screen.getByTestId('trigger')) - }) - - expect(screen.queryByTestId('content')).not.toBeInTheDocument() - }) - - it('should pass close function to htmlContent when manualClose is true', async () => { - vi.useRealTimers() - - render( - } - trigger="click" - manualClose={true} - />, - ) - - await act(async () => { - fireEvent.click(screen.getByTestId('trigger')) - }) - - expect(screen.getByTestId('content')).toBeInTheDocument() - - await act(async () => { - fireEvent.click(screen.getByTestId('content')) - }) - - await waitFor(() => { - expect(screen.queryByTestId('content')).not.toBeInTheDocument() - }) - }) - - it('should not close when mouse leaves while already closed', async () => { - render() - const triggerContainer = screen.getByTestId('trigger').closest('div') - if (!triggerContainer) - throw new Error('Trigger container not found') - - await act(async () => { - fireEvent.mouseLeave(triggerContainer) - }) - - await act(async () => { - vi.runAllTimers() - }) - - expect(screen.queryByTestId('content')).not.toBeInTheDocument() - }) - }) - - describe('Props', () => { - it('should apply custom class names', async () => { - render( - , - ) - - await act(async () => { - fireEvent.click(screen.getByTestId('trigger')) - }) - - expect(document.querySelector('.wrapper-class')).toBeInTheDocument() - expect(document.querySelector('.popup-inner-class')).toBeInTheDocument() - - const button = screen.getByTestId('trigger').parentElement - expect(button).toHaveClass('btn-class') - }) - - it('should handle btnClassName as a function', () => { - render( - open ? 'btn-open' : 'btn-closed'} - />, - ) - - const button = screen.getByTestId('trigger').parentElement - expect(button).toHaveClass('btn-closed') - }) - - it('should align popover panel to left when position is bl', async () => { - render( - , - ) - - await act(async () => { - fireEvent.click(screen.getByTestId('trigger')) - }) - - const panel = screen.getByTestId('content').closest('.absolute') - expect(panel).toHaveClass('left-0') - }) - - it('should align popover panel to right when position is br', async () => { - render( - , - ) - - await act(async () => { - fireEvent.click(screen.getByTestId('trigger')) - }) - - const panel = screen.getByTestId('content').closest('.absolute') - expect(panel).toHaveClass('right-0') - }) - }) -}) diff --git a/web/app/components/base/popover/index.stories.tsx b/web/app/components/base/popover/index.stories.tsx deleted file mode 100644 index 0076c1852b..0000000000 --- a/web/app/components/base/popover/index.stories.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { useState } from 'react' -import CustomPopover from '.' - -type PopoverContentProps = { - open?: boolean - onClose?: () => void - onClick?: () => void - title: string - description: string -} - -const PopoverContent = ({ title, description, onClose }: PopoverContentProps) => { - return ( -
-
- {title} -
-

{description}

- -
- ) -} - -const Template = ({ - trigger = 'hover', - position = 'bottom', - manualClose, - disabled, -}: { - trigger?: 'click' | 'hover' - position?: 'bottom' | 'bl' | 'br' - manualClose?: boolean - disabled?: boolean -}) => { - const [hoverHint] = useState( - trigger === 'hover' - ? 'Hover over the badge to reveal quick tips.' - : 'Click the badge to open the contextual menu.', - ) - - return ( -
-

{hoverHint}

-
- Popover trigger} - htmlContent={( - - )} - /> -
-
- ) -} - -const meta = { - title: 'Base/Feedback/Popover', - component: Template, - parameters: { - layout: 'centered', - docs: { - description: { - component: 'Headless UI popover wrapper supporting hover and click triggers. These examples highlight alignment controls and manual closing.', - }, - }, - }, - argTypes: { - trigger: { - control: 'radio', - options: ['hover', 'click'], - }, - position: { - control: 'radio', - options: ['bottom', 'bl', 'br'], - }, - manualClose: { control: 'boolean' }, - disabled: { control: 'boolean' }, - }, - args: { - trigger: 'hover', - position: 'bottom', - manualClose: false, - disabled: false, - }, - tags: ['autodocs'], -} satisfies Meta - -export default meta -type Story = StoryObj - -export const HoverPopover: Story = {} - -export const ClickPopover: Story = { - args: { - trigger: 'click', - position: 'br', - }, -} - -export const DisabledState: Story = { - args: { - disabled: true, - }, -} diff --git a/web/app/components/base/popover/index.tsx b/web/app/components/base/popover/index.tsx deleted file mode 100644 index d07f8c9a41..0000000000 --- a/web/app/components/base/popover/index.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react' -import { cn } from '@langgenius/dify-ui/cn' -import { cloneElement, Fragment, isValidElement, useRef } from 'react' - -export type HtmlContentProps = { - open?: boolean - onClose?: () => void - onClick?: () => void -} - -type IPopover = { - className?: string - htmlContent: React.ReactNode - popupClassName?: string - trigger?: 'click' | 'hover' - position?: 'bottom' | 'br' | 'bl' - btnElement?: string | React.ReactNode - btnClassName?: string | ((open: boolean) => string) - manualClose?: boolean - disabled?: boolean -} - -const timeoutDuration = 100 - -export default function CustomPopover({ - trigger = 'hover', - position = 'bottom', - htmlContent, - popupClassName, - btnElement, - className, - btnClassName, - manualClose, - disabled = false, -}: IPopover) { - const buttonRef = useRef(null) - const timeOutRef = useRef(null) - - const onMouseEnter = (isOpen: boolean) => { - if (timeOutRef.current != null) - window.clearTimeout(timeOutRef.current) - if (!isOpen) - buttonRef.current?.click() - } - - const onMouseLeave = (isOpen: boolean) => { - timeOutRef.current = window.setTimeout(() => { - if (isOpen) - buttonRef.current?.click() - }, timeoutDuration) - } - - return ( - - {({ open }: { open: boolean }) => { - return ( - <> -
onMouseLeave(open), - onMouseEnter: () => onMouseEnter(open), - })} - > - - {btnElement} - - - onMouseLeave(open), - onMouseEnter: () => onMouseEnter(open), - }) - } - > - {({ close }) => ( -
onMouseLeave(open), - onMouseEnter: () => onMouseEnter(open), - }) - } - > - {isValidElement(htmlContent) - ? cloneElement(htmlContent as React.ReactElement, { - open, - onClose: close, - ...(manualClose - ? { - onClick: close, - } - : {}), - }) - : htmlContent} -
- )} -
-
-
- - ) - }} -
- ) -} diff --git a/web/app/components/base/tag-management/__tests__/selector.spec.tsx b/web/app/components/base/tag-management/__tests__/selector.spec.tsx index 3c74ac9fe6..6285164193 100644 --- a/web/app/components/base/tag-management/__tests__/selector.spec.tsx +++ b/web/app/components/base/tag-management/__tests__/selector.spec.tsx @@ -1,7 +1,6 @@ import type { Tag } from '@/app/components/base/tag-management/constant' import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import * as React from 'react' import { act } from 'react' import TagSelector from '../selector' import { useStore as useTagStore } from '../store' @@ -38,54 +37,6 @@ vi.mock('@/service/tag', () => ({ unBindTag, })) -// Mock popover for deterministic open/close behavior in unit tests. -vi.mock('@/app/components/base/popover', () => { - type PopoverContentProps = { - open?: boolean - onClose?: () => void - } - type MockPopoverProps = { - htmlContent: React.ReactNode - btnElement?: React.ReactNode - btnClassName?: string | ((open: boolean) => string) - } - - const MockPopover = ({ htmlContent, btnElement, btnClassName }: MockPopoverProps) => { - const [isOpen, setIsOpen] = React.useState(false) - const computedClassName = typeof btnClassName === 'function' - ? btnClassName(isOpen) - : btnClassName - - const content = React.isValidElement(htmlContent) - // eslint-disable-next-line react/no-clone-element - ? React.cloneElement(htmlContent as React.ReactElement, { - open: isOpen, - onClose: () => setIsOpen(false), - }) - : htmlContent - - return ( -
- - {isOpen && ( -
- {content} -
- )} -
- ) - } - - return { __esModule: true, default: MockPopover } -}) - // i18n keys rendered in "ns.key" format const i18n = { addTag: 'common.tag.addTag', @@ -109,6 +60,12 @@ const defaultProps = { } describe('TagSelector', () => { + const getPanelTagRow = (tagName: string) => { + const row = screen.getAllByTestId('tag-row').find(tagRow => within(tagRow).queryByText(tagName)) + expect(row).toBeDefined() + return row as HTMLElement + } + beforeEach(() => { vi.clearAllMocks() vi.mocked(fetchTagList).mockResolvedValue(appTags) @@ -223,8 +180,8 @@ describe('TagSelector', () => { const triggerButton = screen.getByRole('button', { name: /Frontend/i }) await user.click(triggerButton) - const popoverContent = await screen.findByTestId('popover-content') - await user.click(within(popoverContent).getByText('Backend')) + await screen.findByPlaceholderText(i18n.selectorPlaceholder) + await user.click(getPanelTagRow('Backend')) // Close panel to trigger unmount side effects. await user.click(triggerButton) @@ -244,8 +201,8 @@ describe('TagSelector', () => { const triggerButton = screen.getByRole('button', { name: /Frontend/i }) await user.click(triggerButton) - const popoverContent = await screen.findByTestId('popover-content') - await user.click(within(popoverContent).getByText('Frontend')) + await screen.findByPlaceholderText(i18n.selectorPlaceholder) + await user.click(getPanelTagRow('Frontend')) // Close panel to trigger unmount side effects. await user.click(triggerButton) diff --git a/web/app/components/base/tag-management/panel.tsx b/web/app/components/base/tag-management/panel.tsx index a705b70369..cceb09b4d7 100644 --- a/web/app/components/base/tag-management/panel.tsx +++ b/web/app/components/base/tag-management/panel.tsx @@ -1,5 +1,4 @@ import type { TagSelectorProps } from './selector' -import type { HtmlContentProps } from '@/app/components/base/popover' import type { Tag } from '@/app/components/base/tag-management/constant' import { useUnmount } from 'ahooks' import { noop } from 'es-toolkit/function' @@ -15,7 +14,7 @@ import { useStore as useTagStore } from './store' type PanelProps = { onCreate: () => void -} & HtmlContentProps & TagSelectorProps +} & TagSelectorProps const Panel = (props: PanelProps) => { const { t } = useTranslation() const { targetID, type, value, selectedTags, onCacheUpdate, onChange, onCreate } = props diff --git a/web/app/components/base/tag-management/selector.tsx b/web/app/components/base/tag-management/selector.tsx index 9b478086ce..a6d4c04413 100644 --- a/web/app/components/base/tag-management/selector.tsx +++ b/web/app/components/base/tag-management/selector.tsx @@ -1,8 +1,13 @@ import type { FC } from 'react' import type { Tag } from '@/app/components/base/tag-management/constant' import { cn } from '@langgenius/dify-ui/cn' -import { useCallback, useMemo } from 'react' -import CustomPopover from '@/app/components/base/popover' +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/app/components/base/ui/popover' import { fetchTagList } from '@/service/tag' import Panel from './panel' import { useStore as useTagStore } from './store' @@ -17,7 +22,7 @@ export type TagSelectorProps = { selectedTags: Tag[] onCacheUpdate: (tags: Tag[]) => void onChange?: () => void - minWidth?: string + minWidth?: number | string } const TagSelector: FC = ({ @@ -31,8 +36,10 @@ const TagSelector: FC = ({ onChange, minWidth, }) => { + const { t } = useTranslation() const tagList = useTagStore(s => s.tagList) const setTagList = useTagStore(s => s.setTagList) + const [open, setOpen] = useState(false) const getTagList = useCallback(async () => { const res = await fetchTagList(type) @@ -45,35 +52,64 @@ const TagSelector: FC = ({ return [] }, [selectedTags, tagList]) - return ( - <> - {isPopover && ( - - )} - position={position} - trigger="click" - btnElement={} - btnClassName={open => - cn( - open ? 'bg-state-base-hover! text-text-secondary!' : 'bg-transparent!', - 'w-full! border-0! p-0! text-text-tertiary! hover:bg-state-base-hover! hover:text-text-secondary!', - )} - popupClassName={cn('w-full! ring-0!', minWidth && 'min-w-80!')} - className="z-20! h-fit w-full!" - /> - )} - + const placement = useMemo(() => { + if (position === 'bl') + return 'bottom-start' as const + if (position === 'br') + return 'bottom-end' as const + return 'bottom' as const + }, [position]) + const resolvedMinWidth = useMemo(() => { + if (minWidth == null) + return undefined + + return typeof minWidth === 'number' ? `${minWidth}px` : minWidth + }, [minWidth]) + + const triggerLabel = useMemo(() => { + if (tags.length) + return tags.join(', ') + + return t('tag.addTag', { ns: 'common' }) + }, [tags, t]) + + if (!isPopover) + return null + + return ( + + + + + + + + ) } diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/actions.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/actions.spec.tsx index 0ac767667e..999843a138 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/actions.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/actions.spec.tsx @@ -37,14 +37,12 @@ describe('Actions', () => { it('should render add icon', () => { const { container } = render() - const icons = container.querySelectorAll('svg') - expect(icons.length).toBeGreaterThan(0) + expect(container.querySelector('.i-ri-add-line')).toBeInTheDocument() }) it('should render arrow icon for details', () => { const { container } = render() - const icons = container.querySelectorAll('svg') - expect(icons.length).toBeGreaterThan(1) + expect(container.querySelector('.i-ri-arrow-right-up-line')).toBeInTheDocument() }) }) @@ -83,6 +81,16 @@ describe('Actions', () => { expect(defaultProps.handleShowTemplateDetails).toHaveBeenCalledTimes(1) }) + + it('should open more operations menu and close it after selecting edit', async () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' })) + const editButton = await screen.findByText(/operations\.editInfo/i) + fireEvent.click(editButton) + + expect(defaultProps.openEditModal).toHaveBeenCalledTimes(1) + }) }) describe('Layout', () => { diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/actions.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/actions.tsx index c2e9a6ba5a..af366b0188 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/actions.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/actions.tsx @@ -1,8 +1,12 @@ -import { RiAddLine, RiArrowRightUpLine, RiMoreFill } from '@remixicon/react' +import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' import { useTranslation } from 'react-i18next' -import CustomPopover from '@/app/components/base/popover' import { Button } from '@/app/components/base/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import Operations from './operations' type ActionsProps = { @@ -23,15 +27,21 @@ const Actions = ({ handleDelete, }: ActionsProps) => { const { t } = useTranslation() + const [isMoreOperationsOpen, setIsMoreOperationsOpen] = React.useState(false) return ( -
+
{ showMoreOperations && ( - + e.stopPropagation()} + > + + + setIsMoreOperationsOpen(false)} /> - )} - className="z-20 min-w-[160px]" - popupClassName="rounded-xl bg-none shadow-none ring-0 min-w-[160px]" - position="br" - trigger="click" - btnElement={ - - } - btnClassName="size-8 cursor-pointer justify-center rounded-lg p-0 shadow-xs shadow-shadow-shadow-3" - /> + + ) }
diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/operations.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/operations.tsx index 7e4db84b52..64111565f1 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/operations.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/operations.tsx @@ -6,30 +6,35 @@ type OperationsProps = { openEditModal: () => void onDelete: () => void onExport: () => void + onClose?: () => void } const Operations = ({ openEditModal, onDelete, onExport, + onClose, }: OperationsProps) => { const { t } = useTranslation() const onClickEdit = (e: React.MouseEvent) => { e.stopPropagation() e.preventDefault() + onClose?.() openEditModal() } const onClickExport = (e: React.MouseEvent) => { e.stopPropagation() e.preventDefault() + onClose?.() onExport() } const onClickDelete = (e: React.MouseEvent) => { e.stopPropagation() e.preventDefault() + onClose?.() onDelete() } @@ -40,7 +45,7 @@ const Operations = ({ className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover" onClick={onClickEdit} > - + {t('operations.editInfo', { ns: 'datasetPipeline' })}
@@ -48,7 +53,7 @@ const Operations = ({ className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover" onClick={onClickExport} > - + {t('operations.exportPipeline', { ns: 'datasetPipeline' })}
@@ -59,7 +64,7 @@ const Operations = ({ className="group flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-destructive-hover" onClick={onClickDelete} > - + {t('operation.delete', { ns: 'common' })}
diff --git a/web/app/components/datasets/create/step-two/language-select/__tests__/index.spec.tsx b/web/app/components/datasets/create/step-two/language-select/__tests__/index.spec.tsx index aa648e1df4..9c34552988 100644 --- a/web/app/components/datasets/create/step-two/language-select/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/step-two/language-select/__tests__/index.spec.tsx @@ -1,13 +1,12 @@ import type { ILanguageSelectProps } from '../index' -import { fireEvent, render, screen } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import * as React from 'react' import { languages } from '@/i18n-config/language' import LanguageSelect from '../index' -// Get supported languages for test assertions -const supportedLanguages = languages.filter(lang => lang.supported) +const supportedLanguages = languages.filter(language => language.supported) -// Test data builder for props const createDefaultProps = (overrides?: Partial): ILanguageSelectProps => ({ currentLanguage: 'English', onSelect: vi.fn(), @@ -15,264 +14,163 @@ const createDefaultProps = (overrides?: Partial): ILanguag ...overrides, }) +const openSelect = async () => { + await act(async () => { + fireEvent.click(screen.getByRole('combobox', { name: 'language' })) + }) + return screen.findByRole('listbox') +} + describe('LanguageSelect', () => { beforeEach(() => { vi.clearAllMocks() }) - // Rendering Tests - Verify component renders correctly + // Rendering describe('Rendering', () => { - it('should render without crashing', () => { - const props = createDefaultProps() + it('should render the current language in the trigger', () => { + render() - render() - - expect(screen.getByText('English')).toBeInTheDocument() + const trigger = screen.getByRole('combobox', { name: 'language' }) + expect(trigger).toBeInTheDocument() + expect(trigger).toHaveTextContent('English') }) - it('should render current language text', () => { - const props = createDefaultProps({ currentLanguage: 'Chinese Simplified' }) + it('should render non-listed current language values', () => { + render() - render() - - expect(screen.getByText('Chinese Simplified')).toBeInTheDocument() + expect(screen.getByRole('combobox', { name: 'language' })).toHaveTextContent('NonExistentLanguage') }) - it('should render dropdown arrow icon', () => { - const props = createDefaultProps() + it('should render a placeholder when current language is empty', () => { + render() - const { container } = render() - - // Assert - RiArrowDownSLine renders as SVG - const svgIcon = container.querySelector('svg') - expect(svgIcon).toBeInTheDocument() - }) - - it('should render all supported languages in dropdown when opened', () => { - const props = createDefaultProps() - render() - - // Act - Click button to open dropdown - const button = screen.getByRole('button') - fireEvent.click(button) - - // Assert - All supported languages should be visible - // Use getAllByText because current language appears both in button and dropdown - supportedLanguages.forEach((lang) => { - expect(screen.getAllByText(lang.prompt_name).length).toBeGreaterThanOrEqual(1) - }) - }) - - it('should render check icon for selected language', () => { - const selectedLanguage = 'Japanese' - const props = createDefaultProps({ currentLanguage: selectedLanguage }) - render() - - const button = screen.getByRole('button') - fireEvent.click(button) - - // Assert - The selected language option should have a check icon - const languageOptions = screen.getAllByText(selectedLanguage) - // One in the button, one in the dropdown list - expect(languageOptions.length).toBeGreaterThanOrEqual(1) + expect(screen.getByRole('combobox', { name: 'language' }).textContent).toBe('\u00A0') }) }) - // Props Testing - Verify all prop variations work correctly - describe('Props', () => { - describe('currentLanguage prop', () => { - it('should display English when currentLanguage is English', () => { - const props = createDefaultProps({ currentLanguage: 'English' }) - render() - expect(screen.getByText('English')).toBeInTheDocument() - }) + // Dropdown behavior + describe('Dropdown behavior', () => { + it('should render all supported languages when the select is opened', async () => { + render() - it('should display Chinese Simplified when currentLanguage is Chinese Simplified', () => { - const props = createDefaultProps({ currentLanguage: 'Chinese Simplified' }) - render() - expect(screen.getByText('Chinese Simplified')).toBeInTheDocument() + expect(await openSelect()).toBeInTheDocument() + supportedLanguages.forEach((language) => { + expect(screen.getByRole('option', { name: language.prompt_name })).toBeInTheDocument() }) + }) - it('should display Japanese when currentLanguage is Japanese', () => { - const props = createDefaultProps({ currentLanguage: 'Japanese' }) - render() - expect(screen.getByText('Japanese')).toBeInTheDocument() + it('should only render supported languages in the dropdown', async () => { + render() + + await openSelect() + + const unsupportedLanguages = languages.filter(language => !language.supported) + unsupportedLanguages.forEach((language) => { + expect(screen.queryByRole('option', { name: language.prompt_name })).not.toBeInTheDocument() }) + }) - it.each(supportedLanguages.map(l => l.prompt_name))( - 'should display %s as current language', - (language) => { - const props = createDefaultProps({ currentLanguage: language }) - render() - expect(screen.getByText(language)).toBeInTheDocument() + it('should mark the selected language inside the opened list', async () => { + render() + + await openSelect() + + const selectedOption = await screen.findByRole('option', { name: 'Japanese' }) + expect(selectedOption).toHaveAttribute('aria-selected', 'true') + }) + }) + + // Interaction + describe('Interaction', () => { + it('should call onSelect when a different language is chosen', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + render() + + await user.click(screen.getByRole('combobox', { name: 'language' })) + const listbox = await screen.findByRole('listbox') + await user.click(within(listbox).getByRole('option', { name: 'French' })) + + await waitFor(() => { + expect(onSelect).toHaveBeenCalledTimes(1) + expect(onSelect).toHaveBeenCalledWith('French') + }) + }) + + it('should re-render with the new language value', () => { + const { rerender } = render() + + rerender() + + expect(screen.getByRole('combobox', { name: 'language' })).toHaveTextContent('French') + }) + + it('should ignore null values emitted by the select control', async () => { + vi.resetModules() + vi.doMock('@/app/components/base/ui/select', () => ({ + Select: ({ onValueChange, children }: { onValueChange?: (value: string | null) => void, children: React.ReactNode }) => { + React.useEffect(() => { + onValueChange?.(null) + }, [onValueChange]) + return
{children}
}, - ) - }) + SelectTrigger: ({ children, ...props }: React.ButtonHTMLAttributes) => , + SelectContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectItem: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectItemText: ({ children }: { children: React.ReactNode }) => {children}, + SelectItemIndicator: () => null, + })) - describe('disabled prop', () => { - it('should have disabled button when disabled is true', () => { - const props = createDefaultProps({ disabled: true }) + const { default: IsolatedLanguageSelect } = await import('../index') + const onSelect = vi.fn() - render() + render() - const button = screen.getByRole('button') - expect(button).toBeDisabled() + await waitFor(() => { + expect(onSelect).not.toHaveBeenCalled() }) - it('should have enabled button when disabled is false', () => { - const props = createDefaultProps({ disabled: false }) - - render() - - const button = screen.getByRole('button') - expect(button).not.toBeDisabled() - }) - - it('should have enabled button when disabled is undefined', () => { - const props = createDefaultProps() - delete (props as Partial).disabled - - render() - - const button = screen.getByRole('button') - expect(button).not.toBeDisabled() - }) - - it('should apply disabled styling when disabled is true', () => { - const props = createDefaultProps({ disabled: true }) - - const { container } = render() - - // Assert - Check for disabled class on text elements - const disabledTextElement = container.querySelector('.text-components-button-tertiary-text-disabled') - expect(disabledTextElement).toBeInTheDocument() - }) - - it('should apply cursor-not-allowed styling when disabled', () => { - const props = createDefaultProps({ disabled: true }) - - const { container } = render() - - const elementWithCursor = container.querySelector('.cursor-not-allowed') - expect(elementWithCursor).toBeInTheDocument() - }) - }) - - describe('onSelect prop', () => { - it('should be callable as a function', () => { - const mockOnSelect = vi.fn() - const props = createDefaultProps({ onSelect: mockOnSelect }) - render() - - // Open dropdown and click a language - const button = screen.getByRole('button') - fireEvent.click(button) - - const germanOption = screen.getByText('German') - fireEvent.click(germanOption) - - expect(mockOnSelect).toHaveBeenCalledWith('German') - }) + vi.doUnmock('@/app/components/base/ui/select') }) }) - // User Interactions - Test event handlers - describe('User Interactions', () => { - it('should open dropdown when button is clicked', () => { - const props = createDefaultProps() - render() + // Disabled state + describe('Disabled state', () => { + it('should disable the trigger when disabled is true', () => { + render() - const button = screen.getByRole('button') - fireEvent.click(button) - - // Assert - Check if dropdown content is visible - expect(screen.getAllByText('English').length).toBeGreaterThanOrEqual(1) + const trigger = screen.getByRole('combobox', { name: 'language' }) + expect(trigger).toBeDisabled() + expect(trigger).toHaveClass('cursor-not-allowed') }) - it('should call onSelect when a language option is clicked', () => { - const mockOnSelect = vi.fn() - const props = createDefaultProps({ onSelect: mockOnSelect }) - render() + it('should not open the listbox when disabled', () => { + render() - const button = screen.getByRole('button') - fireEvent.click(button) - const frenchOption = screen.getByText('French') - fireEvent.click(frenchOption) + fireEvent.click(screen.getByRole('combobox', { name: 'language' })) - expect(mockOnSelect).toHaveBeenCalledTimes(1) - expect(mockOnSelect).toHaveBeenCalledWith('French') - }) - - it('should call onSelect with correct language when selecting different languages', () => { - const mockOnSelect = vi.fn() - const props = createDefaultProps({ onSelect: mockOnSelect }) - render() - - // Act & Assert - Test multiple language selections - const testLanguages = ['Korean', 'Spanish', 'Italian'] - - testLanguages.forEach((lang) => { - mockOnSelect.mockClear() - const button = screen.getByRole('button') - fireEvent.click(button) - const languageOption = screen.getByText(lang) - fireEvent.click(languageOption) - expect(mockOnSelect).toHaveBeenCalledWith(lang) - }) - }) - - it('should not open dropdown when disabled', () => { - const props = createDefaultProps({ disabled: true }) - render() - - const button = screen.getByRole('button') - fireEvent.click(button) - - // Assert - Dropdown should not open, only one instance of the current language should exist - const englishElements = screen.getAllByText('English') - expect(englishElements.length).toBe(1) // Only the button text, not dropdown - }) - - it('should not call onSelect when component is disabled', () => { - const mockOnSelect = vi.fn() - const props = createDefaultProps({ onSelect: mockOnSelect, disabled: true }) - render() - - const button = screen.getByRole('button') - fireEvent.click(button) - - expect(mockOnSelect).not.toHaveBeenCalled() - }) - - it('should handle rapid consecutive clicks', () => { - const mockOnSelect = vi.fn() - const props = createDefaultProps({ onSelect: mockOnSelect }) - render() - - // Act - Rapid clicks - const button = screen.getByRole('button') - fireEvent.click(button) - fireEvent.click(button) - fireEvent.click(button) - - // Assert - Component should not crash - expect(button).toBeInTheDocument() + expect(screen.queryByRole('listbox')).not.toBeInTheDocument() }) }) - // Component Memoization - Test React.memo behavior - describe('Memoization', () => { + // Styling and memoization + describe('Styling and memoization', () => { + it('should apply the compact tertiary trigger styles', () => { + render() + + const trigger = screen.getByRole('combobox', { name: 'language' }) + expect(trigger).toHaveClass('mx-1', 'bg-components-button-tertiary-bg', 'text-components-button-tertiary-text') + }) + it('should be wrapped with React.memo', () => { - // Assert - Check component has memo wrapper expect(LanguageSelect.$$typeof).toBe(Symbol.for('react.memo')) }) - it('should not re-render when props remain the same', () => { - const mockOnSelect = vi.fn() - const props = createDefaultProps({ onSelect: mockOnSelect }) + it('should avoid re-rendering when props stay the same', () => { const renderSpy = vi.fn() + const props = createDefaultProps() - // Create a wrapper component to track renders const TrackedLanguageSelect: React.FC = (trackedProps) => { renderSpy() return @@ -282,224 +180,7 @@ describe('LanguageSelect', () => { const { rerender } = render() rerender() - // Assert - Should only render once due to same props expect(renderSpy).toHaveBeenCalledTimes(1) }) - - it('should re-render when currentLanguage changes', () => { - const props = createDefaultProps({ currentLanguage: 'English' }) - - const { rerender } = render() - expect(screen.getByText('English')).toBeInTheDocument() - - rerender() - - expect(screen.getByText('French')).toBeInTheDocument() - }) - - it('should re-render when disabled changes', () => { - const props = createDefaultProps({ disabled: false }) - - const { rerender } = render() - expect(screen.getByRole('button')).not.toBeDisabled() - - rerender() - - expect(screen.getByRole('button')).toBeDisabled() - }) - }) - - // Edge Cases - Test boundary conditions and error handling - describe('Edge Cases', () => { - it('should handle empty string as currentLanguage', () => { - const props = createDefaultProps({ currentLanguage: '' }) - - render() - - // Assert - Component should still render - const button = screen.getByRole('button') - expect(button).toBeInTheDocument() - }) - - it('should handle non-existent language as currentLanguage', () => { - const props = createDefaultProps({ currentLanguage: 'NonExistentLanguage' }) - - render() - - // Assert - Should display the value even if not in list - expect(screen.getByText('NonExistentLanguage')).toBeInTheDocument() - }) - - it('should handle special characters in language names', () => { - // Arrange - Turkish has special character in prompt_name - const props = createDefaultProps({ currentLanguage: 'Türkçe' }) - - render() - - expect(screen.getByText('Türkçe')).toBeInTheDocument() - }) - - it('should handle very long language names', () => { - const longLanguageName = 'A'.repeat(100) - const props = createDefaultProps({ currentLanguage: longLanguageName }) - - render() - - // Assert - Should not crash and should display the text - expect(screen.getByText(longLanguageName)).toBeInTheDocument() - }) - - it('should render correct number of language options', () => { - const props = createDefaultProps() - render() - - const button = screen.getByRole('button') - fireEvent.click(button) - - // Assert - Should show all supported languages - const expectedCount = supportedLanguages.length - // Each language appears in the dropdown (use getAllByText because current language appears twice) - supportedLanguages.forEach((lang) => { - expect(screen.getAllByText(lang.prompt_name).length).toBeGreaterThanOrEqual(1) - }) - expect(supportedLanguages.length).toBe(expectedCount) - }) - - it('should only show supported languages in dropdown', () => { - const props = createDefaultProps() - render() - - const button = screen.getByRole('button') - fireEvent.click(button) - - // Assert - All displayed languages should be supported - const allLanguages = languages - const unsupportedLanguages = allLanguages.filter(lang => !lang.supported) - - unsupportedLanguages.forEach((lang) => { - expect(screen.queryByText(lang.prompt_name)).not.toBeInTheDocument() - }) - }) - - it('should handle undefined onSelect gracefully when clicking', () => { - // Arrange - This tests TypeScript boundary, but runtime should not crash - const props = createDefaultProps() - - render() - const button = screen.getByRole('button') - fireEvent.click(button) - const option = screen.getByText('German') - - // Assert - Should not throw - expect(() => fireEvent.click(option)).not.toThrow() - }) - - it('should maintain selection state visually with check icon', () => { - const props = createDefaultProps({ currentLanguage: 'Russian' }) - const { container } = render() - - const button = screen.getByRole('button') - fireEvent.click(button) - - // Assert - Find the check icon (RiCheckLine) in the dropdown - // The selected option should have a check icon next to it - const checkIcons = container.querySelectorAll('svg.text-text-accent') - expect(checkIcons.length).toBeGreaterThanOrEqual(1) - }) - }) - - // Accessibility - Basic accessibility checks - describe('Accessibility', () => { - it('should have accessible button element', () => { - const props = createDefaultProps() - - render() - - const button = screen.getByRole('button') - expect(button).toBeInTheDocument() - }) - - it('should have clickable language options', () => { - const props = createDefaultProps() - render() - - const button = screen.getByRole('button') - fireEvent.click(button) - - // Assert - Options should be clickable (have cursor-pointer class) - const options = screen.getAllByText(/English|French|German|Japanese/i) - expect(options.length).toBeGreaterThan(0) - }) - }) - - // Integration with Popover - Test Popover behavior - describe('Popover Integration', () => { - it('should use manualClose prop on Popover', () => { - const mockOnSelect = vi.fn() - const props = createDefaultProps({ onSelect: mockOnSelect }) - - render() - const button = screen.getByRole('button') - fireEvent.click(button) - - // Assert - Popover should be open - expect(screen.getAllByText('English').length).toBeGreaterThanOrEqual(1) - }) - - it('should have correct popup z-index class', () => { - const props = createDefaultProps() - const { container } = render() - - const button = screen.getByRole('button') - fireEvent.click(button) - - // Assert - Check for z-20 class (popupClassName='z-20') - // This is applied to the Popover - expect(container.querySelector('.z-20')).toBeTruthy() - }) - }) - - // Styling Tests - Verify correct CSS classes applied - describe('Styling', () => { - it('should apply tertiary button styling', () => { - const props = createDefaultProps() - const { container } = render() - - // Assert - Check for tertiary button classes (Tailwind v4 uses ! suffix) - expect(container.querySelector('.bg-components-button-tertiary-bg\\!')).toBeInTheDocument() - }) - - it('should apply hover styling class to options', () => { - const props = createDefaultProps() - const { container } = render() - - const button = screen.getByRole('button') - fireEvent.click(button) - - // Assert - Options should have hover class - const optionWithHover = container.querySelector('.hover\\:bg-state-base-hover') - expect(optionWithHover).toBeInTheDocument() - }) - - it('should apply correct text styling to language options', () => { - const props = createDefaultProps() - const { container } = render() - - const button = screen.getByRole('button') - fireEvent.click(button) - - // Assert - Check for system-sm-medium class on options - const styledOption = container.querySelector('.system-sm-medium') - expect(styledOption).toBeInTheDocument() - }) - - it('should apply disabled styling to icon when disabled', () => { - const props = createDefaultProps({ disabled: true }) - const { container } = render() - - // Assert - Check for disabled text color on icon - const disabledIcon = container.querySelector('.text-components-button-tertiary-text-disabled') - expect(disabledIcon).toBeInTheDocument() - }) }) }) diff --git a/web/app/components/datasets/create/step-two/language-select/index.tsx b/web/app/components/datasets/create/step-two/language-select/index.tsx index 4a7683576f..4f13ff2752 100644 --- a/web/app/components/datasets/create/step-two/language-select/index.tsx +++ b/web/app/components/datasets/create/step-two/language-select/index.tsx @@ -1,9 +1,8 @@ 'use client' import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react' import * as React from 'react' -import Popover from '@/app/components/base/popover' +import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@/app/components/base/ui/select' import { languages } from '@/i18n-config/language' export type ILanguageSelectProps = { @@ -17,48 +16,42 @@ const LanguageSelect: FC = ({ onSelect, disabled, }) => { + const supportedLanguages = languages.filter(language => language.supported) + return ( - { + if (value == null) + return + onSelect(value) + }} disabled={disabled} - popupClassName="z-20" - htmlContent={( -
- {languages.filter(language => language.supported).map(({ prompt_name }) => ( -
onSelect(prompt_name)} - > - {prompt_name} - {(currentLanguage === prompt_name) && } -
- ))} -
- )} - btnElement={( -
- - {currentLanguage} - - -
- )} - btnClassName={() => cn( - '!hover:bg-components-button-tertiary-bg mx-1! rounded-md border-0! bg-components-button-tertiary-bg! px-1.5! py-1!', - disabled ? 'bg-components-button-tertiary-bg-disabled' : '', - )} - className="left-1! z-20! h-fit w-[140px]! translate-x-0!" - /> + > + + {currentLanguage ||  } + + + {supportedLanguages.map(({ prompt_name }) => ( + + {prompt_name} + + + ))} + + ) } export default React.memo(LanguageSelect) diff --git a/web/app/components/datasets/documents/components/document-list/__tests__/index.spec.tsx b/web/app/components/datasets/documents/components/document-list/__tests__/index.spec.tsx index 478302d983..97ae1c92a1 100644 --- a/web/app/components/datasets/documents/components/document-list/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/components/document-list/__tests__/index.spec.tsx @@ -380,8 +380,7 @@ describe('DocumentList', () => { }) } - // After clicking rename, the modal should potentially be visible - expect(screen.getByRole('table')).toBeInTheDocument() + expect(screen.getByRole('dialog', { name: 'datasetDocuments.list.table.rename' })).toBeInTheDocument() }) it('should call onUpdate when document is renamed', () => { diff --git a/web/app/components/datasets/documents/components/operations.tsx b/web/app/components/datasets/documents/components/operations.tsx index e2bd6f7c92..e7bbb03c94 100644 --- a/web/app/components/datasets/documents/components/operations.tsx +++ b/web/app/components/datasets/documents/components/operations.tsx @@ -2,15 +2,12 @@ import type { OperationName } from '../types' import type { CommonResponse } from '@/models/common' import type { DocumentDownloadResponse } from '@/service/datasets' import { cn } from '@langgenius/dify-ui/cn' -import { RiArchive2Line, RiDeleteBinLine, RiDownload2Line, RiEditLine, RiEqualizer2Line, RiLoopLeftLine, RiMoreFill, RiPauseCircleLine, RiPlayCircleLine } from '@remixicon/react' import { useBoolean, useDebounceFn } from 'ahooks' import { noop } from 'es-toolkit/function' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' 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 { @@ -22,6 +19,11 @@ import { AlertDialogDescription, AlertDialogTitle, } from '@/app/components/base/ui/alert-dialog' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { toast } from '@/app/components/base/ui/toast' import { IS_CE_EDITION } from '@/config' import { DataSourceType, DocumentActionType } from '@/models/datasets' @@ -53,6 +55,7 @@ type OperationsProps = { const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSelectedIdChange, onUpdate, scene = 'list', className = '' }: OperationsProps) => { const { id, name, enabled = false, archived = false, data_source_type, display_status } = detail || {} const [showModal, setShowModal] = useState(false) + const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false) const [deleting, setDeleting] = useState(false) const { t } = useTranslation() const router = useRouter() @@ -68,7 +71,7 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele const { mutateAsync: pauseDocument } = useDocumentPause() const { mutateAsync: resumeDocument } = useDocumentResume() const isListScene = scene === 'list' - const onOperate = async (operationName: OperationName) => { + const onOperate = useCallback(async (operationName: OperationName) => { let opApi switch (operationName) { case 'archive': @@ -116,7 +119,25 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele } if (operationName === DocumentActionType.delete) setDeleting(false) - } + }, [ + archiveDocument, + data_source_type, + datasetId, + deleteDocument, + disableDocument, + enableDocument, + generateSummary, + id, + onSelectedIdChange, + onUpdate, + pauseDocument, + resumeDocument, + selectedIds, + syncDocument, + syncWebsite, + t, + unArchiveDocument, + ]) const { run: handleSwitch } = useDebounceFn((operationName: OperationName) => { if (operationName === DocumentActionType.enable && enabled) return @@ -139,6 +160,9 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele const handleRenamed = useCallback(() => { onUpdate() }, [onUpdate]) + const closeOperationsMenu = useCallback(() => { + setIsOperationsMenuOpen(false) + }, []) const handleDownload = useCallback(async () => { // Avoid repeated clicks while the signed URL request is in-flight. if (isDownloading) @@ -152,6 +176,28 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele // Trigger download without navigating away (helps avoid duplicate downloads in some browsers). downloadUrl({ url: res.url, fileName: name }) }, [datasetId, downloadDocument, id, isDownloading, name, t]) + const handleShowRename = useCallback(() => { + closeOperationsMenu() + handleShowRenameModal({ + id: detail.id, + name: detail.name, + }) + }, [closeOperationsMenu, detail.id, detail.name, handleShowRenameModal]) + const handleMenuOperation = useCallback((operationName: OperationName) => { + closeOperationsMenu() + void onOperate(operationName) + }, [closeOperationsMenu, onOperate]) + const handleDeleteClick = useCallback(() => { + closeOperationsMenu() + setShowModal(true) + }, [closeOperationsMenu]) + const handleDownloadClick = useCallback((evt: React.MouseEvent) => { + evt.preventDefault() + evt.stopPropagation() + evt.nativeEvent.stopImmediatePropagation?.() + closeOperationsMenu() + void handleDownload() + }, [closeOperationsMenu, handleDownload]) return (
e.stopPropagation()}> {isListScene && !embeddingAvailable && ()} @@ -179,49 +225,56 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele : 'p-0.5 hover:bg-state-base-hover')} onClick={() => router.push(`/datasets/${datasetId}/documents/${detail.id}/settings`)} > - + - + { + e.stopPropagation() + e.preventDefault() + }} + > +
+ +
+
+
{!archived && ( <> -
{ - handleShowRenameModal({ - id: detail.id, - name: detail.name, - }) - }} - > - +
+ {t('list.table.rename', { ns: 'datasetDocuments' })}
{data_source_type === DataSourceType.FILE && ( -
{ - evt.preventDefault() - evt.stopPropagation() - evt.nativeEvent.stopImmediatePropagation?.() - handleDownload() - }} - > - +
+ {t('list.action.download', { ns: 'datasetDocuments' })}
)} {['notion_import', DataSourceType.WEB].includes(data_source_type) && ( -
onOperate('sync')}> - +
handleMenuOperation('sync')}> + {t('list.action.sync', { ns: 'datasetDocuments' })}
)} {IS_CE_EDITION && ( -
onOperate('summary')}> - +
handleMenuOperation('summary')}> + {t('list.action.summary', { ns: 'datasetDocuments' })}
)} @@ -230,62 +283,44 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele )} {archived && data_source_type === DataSourceType.FILE && ( <> -
{ - evt.preventDefault() - evt.stopPropagation() - evt.nativeEvent.stopImmediatePropagation?.() - handleDownload() - }} - > - +
+ {t('list.action.download', { ns: 'datasetDocuments' })}
)} {!archived && display_status?.toLowerCase() === 'indexing' && ( -
onOperate('pause')}> - +
handleMenuOperation('pause')}> + {t('list.action.pause', { ns: 'datasetDocuments' })}
)} {!archived && display_status?.toLowerCase() === 'paused' && ( -
onOperate('resume')}> - +
handleMenuOperation('resume')}> + {t('list.action.resume', { ns: 'datasetDocuments' })}
)} {!archived && ( -
onOperate('archive')}> - +
handleMenuOperation('archive')}> + {t('list.action.archive', { ns: 'datasetDocuments' })}
)} {archived && ( -
onOperate('un_archive')}> - +
handleMenuOperation('un_archive')}> + {t('list.action.unarchive', { ns: 'datasetDocuments' })}
)} -
setShowModal(true)}> - +
+ {t('list.action.delete', { ns: 'datasetDocuments' })}
- )} - trigger="click" - position="br" - btnElement={( -
- -
- )} - btnClassName={open => cn(isListScene ? s.actionIconWrapperList : s.actionIconWrapperDetail, open ? '!hover:bg-state-base-hover !shadow-none' : '!bg-transparent')} - popupClassName="!w-full" - className={`!z-20 flex h-fit !w-[200px] justify-end ${className}`} - /> + + )} !open && setShowModal(false)}> diff --git a/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx index 7f95e42bb7..35b62915da 100644 --- a/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx @@ -1,4 +1,3 @@ -import type { ReactNode } from 'react' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { Plan } from '@/app/components/billing/type' @@ -30,18 +29,6 @@ vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({ ), })) -// Mock Popover -vi.mock('@/app/components/base/popover', () => ({ - default: ({ htmlContent, btnElement, disabled }: { htmlContent: ReactNode, btnElement: ReactNode, disabled?: boolean }) => ( -
- -
{htmlContent}
-
- ), -})) - describe('SegmentAdd', () => { beforeEach(() => { vi.clearAllMocks() @@ -70,10 +57,10 @@ describe('SegmentAdd', () => { expect(screen.getByText(/list\.action\.addButton/i)).toBeInTheDocument() }) - it('should render popover for batch add', () => { + it('should render dropdown trigger for batch add', () => { render() - expect(screen.getByTestId('popover')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /list\.action\.batchAdd/i })).toBeInTheDocument() }) }) @@ -152,17 +139,20 @@ describe('SegmentAdd', () => { expect(mockClearProcessStatus).toHaveBeenCalledTimes(1) }) - it('should render batch add option in popover', () => { + it('should render batch add option in dropdown', async () => { render() - expect(screen.getByText(/list\.action\.batchAdd/i)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /list\.action\.batchAdd/i })) + + expect(await screen.findByRole('menuitem', { name: /list\.action\.batchAdd/i })).toBeInTheDocument() }) - it('should call showBatchModal when batch add is clicked', () => { + it('should call showBatchModal when batch add is clicked', async () => { const mockShowBatchModal = vi.fn() render() - fireEvent.click(screen.getByText(/list\.action\.batchAdd/i)) + fireEvent.click(screen.getByRole('button', { name: /list\.action\.batchAdd/i })) + fireEvent.click(await screen.findByRole('menuitem', { name: /list\.action\.batchAdd/i })) expect(mockShowBatchModal).toHaveBeenCalledTimes(1) }) @@ -177,10 +167,10 @@ describe('SegmentAdd', () => { expect(addButton).toBeDisabled() }) - it('should disable popover button when embedding is true', () => { + it('should disable batch menu trigger when embedding is true', () => { render() - expect(screen.getByTestId('popover-btn')).toBeDisabled() + expect(screen.getByRole('button', { name: /list\.action\.batchAdd/i })).toBeDisabled() }) it('should apply disabled styling when embedding is true', () => { diff --git a/web/app/components/datasets/documents/detail/segment-add/index.tsx b/web/app/components/datasets/documents/detail/segment-add/index.tsx index cffeaa9263..db810b1d6b 100644 --- a/web/app/components/datasets/documents/detail/segment-add/index.tsx +++ b/web/app/components/datasets/documents/detail/segment-add/index.tsx @@ -1,18 +1,16 @@ 'use client' import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { - RiAddLine, - RiArrowDownSLine, - RiErrorWarningFill, - RiLoader2Line, -} from '@remixicon/react' import { useBoolean } from 'ahooks' import * as React from 'react' -import { useCallback, useMemo } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { CheckCircle } from '@/app/components/base/icons/src/vender/solid/general' -import Popover from '@/app/components/base/popover' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal' import { Plan } from '@/app/components/billing/type' import { useProviderContext } from '@/context/provider-context' @@ -47,6 +45,8 @@ const SegmentAdd: FC = ({ const { plan, enableBilling } = useProviderContext() const { type } = plan const canAdd = enableBilling ? type !== Plan.sandbox : true + const [isBatchMenuOpen, setIsBatchMenuOpen] = useState(false) + const batchMenuAnchorRef = useRef(null) const withNeedUpgradeCheck = useCallback((fn: () => void) => { return () => { @@ -72,14 +72,14 @@ const SegmentAdd: FC = ({ shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]" >
- + {t('list.batchModal.processing', { ns: 'datasetDocuments' })}
)} {importStatus === ProcessStatus.COMPLETED && (
- + {t('list.batchModal.completed', { ns: 'datasetDocuments' })}
@@ -91,7 +91,7 @@ const SegmentAdd: FC = ({ {importStatus === ProcessStatus.ERROR && (
- + {t('list.batchModal.error', { ns: 'datasetDocuments' })}
@@ -105,10 +105,12 @@ const SegmentAdd: FC = ({ } return ( -
- + +
+ +
+
+
- +
- )} - btnElement={( -
- -
- )} - btnClassName={open => cn( - `!hover:bg-state-base-hover rounded-l-none! rounded-r-lg! border-0! p-2! backdrop-blur-[5px] - disabled:cursor-not-allowed disabled:bg-transparent disabled:hover:bg-transparent`, - open ? 'bg-state-base-hover!' : '', - )} - popupClassName="min-w-[128px]! bg-components-panel-bg-blur! rounded-xl! border-[0.5px] ring-0! - border-components-panel-border shadow-xl! shadow-shadow-shadow-5! backdrop-blur-[5px]" - className="h-fit min-w-[128px]" - disabled={embedding} - /> +
+ {isShowPlanUpgradeModal && ( { - setOpen(!open) - } - return (
- - -
+
+ + {expand &&
{t('appMenus.apiAccess', { ns: 'common' })}
} + +
+ )} - > - - {expand &&
{t('appMenus.apiAccess', { ns: 'common' })}
} - -
-
- + /> + - -
+ +
) } diff --git a/web/app/components/datasets/extra-info/service-api/index.tsx b/web/app/components/datasets/extra-info/service-api/index.tsx index 33a6d32251..c4494f4ed7 100644 --- a/web/app/components/datasets/extra-info/service-api/index.tsx +++ b/web/app/components/datasets/extra-info/service-api/index.tsx @@ -2,7 +2,7 @@ import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' +import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover' import Indicator from '@/app/components/header/indicator' import Card from './card' @@ -16,45 +16,42 @@ const ServiceApi = ({ const { t } = useTranslation() const [open, setOpen] = useState(false) - const handleToggle = () => { - setOpen(!open) - } - return (
- - -
+
+ +
{t('serviceApi.title', { ns: 'dataset' })}
+
+ )} - > - -
{t('serviceApi.title', { ns: 'dataset' })}
-
-
- + /> + - -
+ +
) } diff --git a/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx index 29139da114..f6c7e1e93d 100644 --- a/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx @@ -56,9 +56,9 @@ vi.mock('../components/dataset-card-modals', () => ({ default: () =>
, })) vi.mock('../components/tag-area', () => ({ - default: React.forwardRef void }>(({ onClick }, ref) => ( -
- )), + default: ({ onClick }: { onClick: (e: React.MouseEvent) => void, ref?: React.Ref }) => ( +
+ ), })) vi.mock('../components/operations-dropdown', () => ({ default: () =>
, diff --git a/web/app/components/datasets/list/dataset-card/__tests__/operation-item.spec.tsx b/web/app/components/datasets/list/dataset-card/__tests__/operation-item.spec.tsx index 335f193146..7aadbb4a41 100644 --- a/web/app/components/datasets/list/dataset-card/__tests__/operation-item.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/__tests__/operation-item.spec.tsx @@ -1,11 +1,10 @@ -import { RiEditLine } from '@remixicon/react' import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import OperationItem from '../operation-item' describe('OperationItem', () => { const defaultProps = { - Icon: RiEditLine, + iconClassName: 'i-ri-edit-line', name: 'Edit', } @@ -17,7 +16,7 @@ describe('OperationItem', () => { it('should render the icon', () => { const { container } = render() - const icon = container.querySelector('svg') + const icon = container.querySelector('.i-ri-edit-line') expect(icon).toBeInTheDocument() expect(icon).toHaveClass('size-4', 'text-text-tertiary') }) diff --git a/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx index 690e01113f..2bb138e6dc 100644 --- a/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx @@ -1,6 +1,6 @@ import type { DataSet } from '@/models/datasets' import { fireEvent, render, screen } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import OperationsDropdown from '../operations-dropdown' diff --git a/web/app/components/datasets/list/dataset-card/operation-item.tsx b/web/app/components/datasets/list/dataset-card/operation-item.tsx index afa0f174e8..668c9a3c53 100644 --- a/web/app/components/datasets/list/dataset-card/operation-item.tsx +++ b/web/app/components/datasets/list/dataset-card/operation-item.tsx @@ -1,14 +1,14 @@ -import type { RemixiconComponentType } from '@remixicon/react' +import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' type OperationItemProps = { - Icon: RemixiconComponentType + iconClassName: string name: string handleClick?: () => void } const OperationItem = ({ - Icon, + iconClassName, name, handleClick, }: OperationItemProps) => { @@ -21,7 +21,7 @@ const OperationItem = ({ handleClick?.() }} > - + {name} diff --git a/web/app/components/datasets/list/dataset-card/operations.tsx b/web/app/components/datasets/list/dataset-card/operations.tsx index 0deed2c4c9..04a5b8aef7 100644 --- a/web/app/components/datasets/list/dataset-card/operations.tsx +++ b/web/app/components/datasets/list/dataset-card/operations.tsx @@ -11,6 +11,7 @@ type OperationsProps = { openRenameModal: () => void handleExportPipeline: () => void detectIsUsedByApp: () => void + onClose?: () => void } const Operations = ({ @@ -19,17 +20,33 @@ const Operations = ({ openRenameModal, handleExportPipeline, detectIsUsedByApp, + onClose, }: OperationsProps) => { const { t } = useTranslation() + const handleRename = () => { + onClose?.() + openRenameModal() + } + + const handleExport = () => { + onClose?.() + handleExportPipeline() + } + + const handleDelete = () => { + onClose?.() + detectIsUsedByApp() + } + return ( <> - + {t('operation.edit', { ns: 'common' })} {showExportPipeline && ( - + {t('operations.exportPipeline', { ns: 'datasetPipeline' })} @@ -37,7 +54,7 @@ const Operations = ({ {showDelete && ( <> - + {t('operation.delete', { ns: 'common' })} diff --git a/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-metadata-modal.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-metadata-modal.spec.tsx index 3a8ed6b909..1449ef8f60 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-metadata-modal.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-metadata-modal.spec.tsx @@ -3,14 +3,15 @@ import { describe, expect, it, vi } from 'vitest' import { DataType } from '../../types' import CreateMetadataModal from '../create-metadata-modal' -type PortalProps = { +type PopoverProps = { children: React.ReactNode open: boolean + onOpenChange?: (open: boolean) => void } type TriggerProps = { - children: React.ReactNode - onClick: () => void + children?: React.ReactNode + render?: React.ReactNode } type ContentProps = { @@ -25,18 +26,37 @@ type CreateContentProps = { hasBack?: boolean } -// Mock PortalToFollowElem components -vi.mock('../../../../base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open }: PortalProps) => ( -
{children}
- ), - PortalToFollowElemTrigger: ({ children, onClick }: TriggerProps) => ( -
{children}
- ), - PortalToFollowElemContent: ({ children, className }: ContentProps) => ( -
{children}
- ), -})) +vi.mock('../../../../base/ui/popover', async () => { + const React = await import('react') + const PopoverContext = React.createContext<{ open: boolean, onOpenChange?: (open: boolean) => void } | null>(null) + + return { + Popover: ({ children, open, onOpenChange }: PopoverProps) => ( + +
{children}
+
+ ), + PopoverTrigger: ({ children, render }: TriggerProps) => { + const context = React.useContext(PopoverContext) + const content = render ?? children + const handleClick = () => context?.onOpenChange?.(!context.open) + + if (React.isValidElement(content)) { + const element = content as React.ReactElement<{ onClick?: () => void }> + return React.cloneElement(element, { onClick: handleClick }) + } + + return + }, + PopoverContent: ({ children, className }: ContentProps) => { + const context = React.useContext(PopoverContext) + if (!context?.open) + return null + + return
{children}
+ }, + } +}) // Mock CreateContent component vi.mock('../create-content', () => ({ @@ -63,9 +83,8 @@ describe('CreateMetadataModal', () => { onSave={vi.fn()} />, ) - // Portal wrapper should exist but closed - expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument() - expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'false') + expect(screen.getByTestId('popover-root')).toBeInTheDocument() + expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'false') }) it('should render content when open', () => { @@ -77,7 +96,7 @@ describe('CreateMetadataModal', () => { onSave={vi.fn()} />, ) - expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument() + expect(screen.getByTestId('popover-root')).toBeInTheDocument() expect(screen.getByTestId('create-content')).toBeInTheDocument() }) @@ -130,7 +149,7 @@ describe('CreateMetadataModal', () => { popupLeft={50} />, ) - expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument() + expect(screen.getByTestId('popover-root')).toBeInTheDocument() }) }) @@ -146,7 +165,7 @@ describe('CreateMetadataModal', () => { />, ) - fireEvent.click(screen.getByTestId('portal-trigger')) + fireEvent.click(screen.getByTestId('trigger-button')) expect(setOpen).toHaveBeenCalledWith(true) }) @@ -215,7 +234,7 @@ describe('CreateMetadataModal', () => { />, ) - expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'false') + expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'false') rerender( { />, ) - expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'true') + expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'true') }) it('should handle different trigger elements', () => { diff --git a/web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata-modal.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata-modal.spec.tsx index 800ffc3586..2a31169b15 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata-modal.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata-modal.spec.tsx @@ -9,14 +9,15 @@ type MetadataItem = { type: DataType } -type PortalProps = { +type PopoverProps = { children: React.ReactNode open: boolean + onOpenChange?: (open: boolean) => void } type TriggerProps = { - children: React.ReactNode - onClick: () => void + children?: React.ReactNode + render?: React.ReactNode } type ContentProps = { @@ -49,18 +50,37 @@ vi.mock('@/service/knowledge/use-metadata', () => ({ }), })) -// Mock PortalToFollowElem components -vi.mock('../../../../base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open }: PortalProps) => ( -
{children}
- ), - PortalToFollowElemTrigger: ({ children, onClick }: TriggerProps) => ( -
{children}
- ), - PortalToFollowElemContent: ({ children }: ContentProps) => ( -
{children}
- ), -})) +vi.mock('../../../../base/ui/popover', async () => { + const React = await import('react') + const PopoverContext = React.createContext<{ open: boolean, onOpenChange?: (open: boolean) => void } | null>(null) + + return { + Popover: ({ children, open, onOpenChange }: PopoverProps) => ( + +
{children}
+
+ ), + PopoverTrigger: ({ children, render }: TriggerProps) => { + const context = React.useContext(PopoverContext) + const content = render ?? children + const handleClick = () => context?.onOpenChange?.(!context.open) + + if (React.isValidElement(content)) { + const element = content as React.ReactElement<{ onClick?: () => void }> + return React.cloneElement(element, { onClick: handleClick }) + } + + return + }, + PopoverContent: ({ children }: ContentProps) => { + const context = React.useContext(PopoverContext) + if (!context?.open) + return null + + return
{children}
+ }, + } +}) // Mock SelectMetadata component vi.mock('../select-metadata', () => ({ @@ -99,7 +119,7 @@ describe('SelectMetadataModal', () => { onManage={vi.fn()} />, ) - expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument() + expect(screen.getByTestId('popover-root')).toBeInTheDocument() }) it('should render trigger element', () => { @@ -115,7 +135,7 @@ describe('SelectMetadataModal', () => { expect(screen.getByTestId('trigger-button')).toBeInTheDocument() }) - it('should render SelectMetadata by default', () => { + it('should not render SelectMetadata before opening', () => { render( { onManage={vi.fn()} />, ) - expect(screen.getByTestId('select-metadata')).toBeInTheDocument() + expect(screen.queryByTestId('select-metadata')).not.toBeInTheDocument() }) it('should pass dataset metadata to SelectMetadata', () => { @@ -138,6 +158,7 @@ describe('SelectMetadataModal', () => { onManage={vi.fn()} />, ) + fireEvent.click(screen.getByTestId('trigger-button')) expect(screen.getByTestId('list-count')).toHaveTextContent('2') }) }) @@ -154,10 +175,10 @@ describe('SelectMetadataModal', () => { />, ) - fireEvent.click(screen.getByTestId('portal-trigger')) + fireEvent.click(screen.getByTestId('trigger-button')) - // State should toggle - expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument() + expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'true') + expect(screen.getByTestId('select-metadata')).toBeInTheDocument() }) it('should call onSelect and close when item is selected', () => { @@ -172,6 +193,7 @@ describe('SelectMetadataModal', () => { />, ) + fireEvent.click(screen.getByTestId('trigger-button')) fireEvent.click(screen.getByTestId('select-item')) expect(handleSelect).toHaveBeenCalledWith({ @@ -192,6 +214,7 @@ describe('SelectMetadataModal', () => { />, ) + fireEvent.click(screen.getByTestId('trigger-button')) fireEvent.click(screen.getByTestId('new-btn')) await waitFor(() => { @@ -211,6 +234,7 @@ describe('SelectMetadataModal', () => { />, ) + fireEvent.click(screen.getByTestId('trigger-button')) fireEvent.click(screen.getByTestId('manage-btn')) expect(handleManage).toHaveBeenCalled() @@ -230,6 +254,7 @@ describe('SelectMetadataModal', () => { ) // Go to create step + fireEvent.click(screen.getByTestId('trigger-button')) fireEvent.click(screen.getByTestId('new-btn')) await waitFor(() => { @@ -257,6 +282,7 @@ describe('SelectMetadataModal', () => { ) // Go to create step + fireEvent.click(screen.getByTestId('trigger-button')) fireEvent.click(screen.getByTestId('new-btn')) await waitFor(() => { @@ -287,7 +313,7 @@ describe('SelectMetadataModal', () => { popupPlacement="bottom-start" />, ) - expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument() + expect(screen.getByTestId('popover-root')).toBeInTheDocument() }) it('should accept custom popupOffset', () => { @@ -301,7 +327,7 @@ describe('SelectMetadataModal', () => { popupOffset={{ mainAxis: 10, crossAxis: 5 }} />, ) - expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument() + expect(screen.getByTestId('popover-root')).toBeInTheDocument() }) }) @@ -317,7 +343,7 @@ describe('SelectMetadataModal', () => { />, ) - expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument() + expect(screen.getByTestId('popover-root')).toBeInTheDocument() rerender( { />, ) - expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument() + expect(screen.getByTestId('popover-root')).toBeInTheDocument() }) it('should handle empty trigger', () => { diff --git a/web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.tsx b/web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.tsx index cf94bc5206..1e2e8fd9e8 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.tsx @@ -2,7 +2,7 @@ import type { FC } from 'react' import type { Props as CreateContentProps } from './create-content' import * as React from 'react' -import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../../base/portal-to-follow-elem' +import { Popover, PopoverContent, PopoverTrigger } from '../../../base/ui/popover' import CreateContent from './create-content' type Props = { @@ -20,25 +20,25 @@ const CreateMetadataModal: FC = ({ popupLeft = 20, ...createContentProps }) => { + const triggerElement = React.isValidElement(trigger) + ? trigger + : + return ( - - setOpen(!open)} + + - {trigger} - - setOpen(false)} onBack={() => setOpen(false)} /> - - + + ) } diff --git a/web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx b/web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx index 70c0d67856..f33a85642e 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx @@ -1,12 +1,12 @@ 'use client' -import type { Placement } from '@floating-ui/react' import type { FC } from 'react' import type { MetadataItem } from '../types' import type { Props as CreateContentProps } from './create-content' +import type { Placement } from '@/app/components/base/ui/placement' import * as React from 'react' import { useCallback, useState } from 'react' +import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover' import { useDatasetMetaData } from '@/service/knowledge/use-metadata' -import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../../base/portal-to-follow-elem' import CreateContent from './create-content' import SelectMetadata from './select-metadata' @@ -38,25 +38,31 @@ const SelectMetadataModal: FC = ({ const [open, setOpen] = useState(false) const [step, setStep] = useState(Step.select) + const triggerElement = React.isValidElement(trigger) + ? trigger + : + const handleOpenChange = useCallback((nextOpen: boolean) => { + setOpen(nextOpen) + if (!nextOpen) + setStep(Step.select) + }, []) const handleSave = useCallback(async (data: MetadataItem) => { await onSave(data) setStep(Step.select) }, [onSave]) return ( - - setOpen(!open)} - className="block" + + - {trigger} - - {step === Step.select ? ( = ({ }} list={datasetMetaData?.doc_metadata || []} onNew={() => setStep(Step.create)} - onManage={onManage} + onManage={() => { + setOpen(false) + setStep(Step.select) + onManage() + }} /> ) : ( @@ -77,8 +87,8 @@ const SelectMetadataModal: FC = ({ onClose={() => setStep(Step.select)} /> )} - - + + ) } diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/__tests__/json-importer.spec.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/__tests__/json-importer.spec.tsx new file mode 100644 index 0000000000..9344673a35 --- /dev/null +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/__tests__/json-importer.spec.tsx @@ -0,0 +1,276 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { JSON_SCHEMA_MAX_DEPTH } from '@/config' +import JsonImporter from '../json-importer' + +const mockEmit = vi.fn() +const mockCheckJsonDepth = vi.fn() +const visualEditorState = { + advancedEditing: false, + isAddingNewField: false, +} + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('../visual-editor/context', () => ({ + useMittContext: () => ({ + emit: mockEmit, + }), +})) + +vi.mock('../visual-editor/store', () => ({ + useVisualEditorStore: (selector: (state: typeof visualEditorState) => unknown) => selector(visualEditorState), +})) + +vi.mock('../../../utils', () => ({ + checkJsonDepth: (...args: unknown[]) => mockCheckJsonDepth(...args), +})) + +vi.mock('../code-editor', () => ({ + default: ({ + value, + onUpdate, + }: { + value: string + onUpdate: (value: string) => void + }) => ( +