refactor(web): migrate base/popoversto ui/dropdown-menu and ui/select (#35278)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
This commit is contained in:
Coding On Star 2026-04-16 13:13:17 +08:00 committed by GitHub
parent b665eaa015
commit c661d5c43a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 2088 additions and 2186 deletions

View File

@ -258,6 +258,10 @@ const renderAppCard = (app?: Partial<App>) => {
return render(<AppCard app={createMockApp(app)} onRefresh={mockOnRefresh} />)
}
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()
})
})
})
})

View File

@ -188,8 +188,7 @@ const renderComponent = (
}
const openOperationsPopover = async (user: ReturnType<typeof userEvent.setup>) => {
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)
}

View File

@ -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<OperationsMenuProps> = ({
list,
onClose,
onBulkImport,
onClearAll,
onExportJsonl,
}) => {
const { t } = useTranslation()
const locale = useLocale()
const { CSVDownloader, Type } = useCSVDownloader()
const annotationUnavailable = list.length === 0
return (
<div className="w-full py-1">
<button
type="button"
className="mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50"
onClick={() => {
onClose()
onBulkImport()
}}
>
<span aria-hidden className="i-custom-vender-line-files-file-plus-02 h-4 w-4 text-text-tertiary" />
<span className="grow text-left system-sm-regular text-text-secondary">{t('table.header.bulkImport', { ns: 'appAnnotation' })}</span>
</button>
<Menu as="div" className="relative h-full w-full">
<MenuButton className="mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50">
<span aria-hidden className="i-custom-vender-line-files-file-download-02 h-4 w-4 text-text-tertiary" />
<span className="grow text-left system-sm-regular text-text-secondary">{t('table.header.bulkExport', { ns: 'appAnnotation' })}</span>
<span aria-hidden className="i-custom-vender-line-arrows-chevron-right h-[14px] w-[14px] shrink-0 text-text-tertiary" />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems
className={cn(
'absolute top-px left-1 z-10 min-w-[100px] origin-top-right -translate-x-full rounded-xl border-[0.5px] border-components-panel-on-panel-item-bg bg-components-panel-bg py-1 shadow-xs',
)}
>
<CSVDownloader
type={Type.Link}
filename={`annotations-${locale}`}
bom={true}
data={[
locale !== LanguagesSupported[1] ? CSV_HEADER_QA_EN : CSV_HEADER_QA_CN,
...list.map(item => [item.question, item.answer]),
]}
>
<button
type="button"
disabled={annotationUnavailable}
className="mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50"
onClick={onClose}
>
<span className="grow text-left system-sm-regular text-text-secondary">CSV</span>
</button>
</CSVDownloader>
<button
type="button"
disabled={annotationUnavailable}
className={cn('mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50', 'border-0!')}
onClick={() => {
onClose()
onExportJsonl()
}}
>
<span className="grow text-left system-sm-regular text-text-secondary">JSONL</span>
</button>
</MenuItems>
</Transition>
</Menu>
<button
type="button"
onClick={() => {
onClose()
onClearAll()
}}
className="mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 text-red-600 hover:bg-red-50 disabled:opacity-50"
>
<span aria-hidden className="i-ri-delete-bin-line h-4 w-4" />
<span className="grow text-left system-sm-regular">
{t('table.header.clearAll', { ns: 'appAnnotation' })}
</span>
</button>
</div>
)
}
const HeaderOptions: FC<Props> = ({
appId,
onAdd,
@ -45,22 +156,7 @@ const HeaderOptions: FC<Props> = ({
}) => {
const { t } = useTranslation()
const locale = useLocale()
const { CSVDownloader, Type } = useCSVDownloader()
const [list, setList] = useState<AnnotationItemBasic[]>([])
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<Props> = ({
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<Props> = ({
setShowClearConfirm(false)
}
}
const Operations = () => {
return (
<div className="w-full py-1">
<button
type="button"
className="mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50"
onClick={() => {
setShowBulkImportModal(true)
}}
>
<FilePlus02 className="h-4 w-4 text-text-tertiary" />
<span className="grow text-left system-sm-regular text-text-secondary">{t('table.header.bulkImport', { ns: 'appAnnotation' })}</span>
</button>
<Menu as="div" className="relative h-full w-full">
<MenuButton className="mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50">
<FileDownload02 className="h-4 w-4 text-text-tertiary" />
<span className="grow text-left system-sm-regular text-text-secondary">{t('table.header.bulkExport', { ns: 'appAnnotation' })}</span>
<ChevronRight className="h-[14px] w-[14px] shrink-0 text-text-tertiary" />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems
className={cn(
'absolute top-px left-1 z-10 min-w-[100px] origin-top-right -translate-x-full rounded-xl border-[0.5px] border-components-panel-on-panel-item-bg bg-components-panel-bg py-1 shadow-xs',
)}
>
<CSVDownloader
type={Type.Link}
filename={`annotations-${locale}`}
bom={true}
data={[
locale !== LanguagesSupported[1] ? CSV_HEADER_QA_EN : CSV_HEADER_QA_CN,
...list.map(item => [item.question, item.answer]),
]}
>
<button type="button" disabled={annotationUnavailable} className="mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50">
<span className="grow text-left system-sm-regular text-text-secondary">CSV</span>
</button>
</CSVDownloader>
<button type="button" disabled={annotationUnavailable} className={cn('mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50', 'border-0!')} onClick={JSONLOutput}>
<span className="grow text-left system-sm-regular text-text-secondary">JSONL</span>
</button>
</MenuItems>
</Transition>
</Menu>
<button
type="button"
onClick={handleClearAll}
className="mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 text-red-600 hover:bg-red-50 disabled:opacity-50"
>
<RiDeleteBinLine className="h-4 w-4" />
<span className="grow text-left system-sm-regular">
{t('table.header.clearAll', { ns: 'appAnnotation' })}
</span>
</button>
</div>
)
}
const [showAddModal, setShowAddModal] = React.useState(false)
return (
<div className="flex space-x-2">
<Button variant="primary" onClick={() => setShowAddModal(true)}>
<RiAddLine className="mr-0.5 h-4 w-4" />
<span aria-hidden className="mr-0.5 i-ri-add-line h-4 w-4" />
<div>{t('table.header.addAnnotation', { ns: 'appAnnotation' })}</div>
</Button>
<CustomPopover
htmlContent={<Operations />}
position="br"
trigger="click"
btnElement={
<RiMoreFill className="h-4 w-4" />
}
btnClassName="btn btn-secondary btn-medium w-8 p-0"
className="z-20! h-fit w-[155px]!"
popupClassName="w-full! overflow-visible!"
manualClose
/>
<DropdownMenu open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className="mr-0 box-border inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-0 text-components-button-secondary-text shadow-xs backdrop-blur-[5px] hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover data-popup-open:border-components-button-secondary-border-hover data-popup-open:bg-components-button-secondary-bg-hover"
>
<span aria-hidden className="i-ri-more-fill h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-[155px] overflow-visible py-0"
>
<OperationsMenu
list={list}
onClose={() => setIsOperationsMenuOpen(false)}
onBulkImport={handleShowBulkImportModal}
onClearAll={handleClearAll}
onExportJsonl={handleExportJsonl}
/>
</DropdownMenuContent>
</DropdownMenu>
{showAddModal && (
<AddAnnotationModal
isShow={showAddModal}

View File

@ -8,6 +8,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Avatar } from '@/app/components/base/ui/avatar'
import { Button } from '@/app/components/base/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover'
import { useSelector } from '@/context/app-context'
import { SubjectType } from '@/models/access-control'
import { useSearchForWhiteListCandidates } from '@/service/access-control'
@ -15,7 +16,6 @@ import useAccessControlStore from '../../../../context/access-control-store'
import Checkbox from '../../base/checkbox'
import Input from '../../base/input'
import Loading from '../../base/loading'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
export default function AddMemberOrGroupDialog() {
const { t } = useTranslation()
@ -45,15 +45,21 @@ export default function AddMemberOrGroupDialog() {
}, [isLoading, fetchNextPage, anchorRef, data])
return (
<PortalToFollowElem open={open} onOpenChange={setOpen} offset={{ crossAxis: 300 }} placement="bottom-end">
<PortalToFollowElemTrigger asChild>
<Button variant="ghost-accent" size="small" className="flex shrink-0 items-center gap-x-0.5" onClick={() => setOpen(!open)}>
<RiAddCircleFill className="h-4 w-4" />
<span>{t('operation.add', { ns: 'common' })}</span>
</Button>
</PortalToFollowElemTrigger>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<Button variant="ghost-accent" size="small" className="flex shrink-0 items-center gap-x-0.5">
<RiAddCircleFill className="h-4 w-4" />
<span>{t('operation.add', { ns: 'common' })}</span>
</Button>
)}
/>
{open && <FloatingOverlay />}
<PortalToFollowElemContent className="z-100">
<PopoverContent
placement="bottom-end"
alignOffset={300}
popupClassName="border-none bg-transparent shadow-none"
>
<div className="relative flex max-h-[400px] w-[400px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
<div className="sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]">
<Input value={keyword} onChange={handleKeywordChange} showLeftIcon placeholder={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' }) as string} />
@ -81,8 +87,8 @@ export default function AddMemberOrGroupDialog() {
)
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -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 (
<>
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 4,
crossAxis: crossAxisOffset,
}}
onOpenChange={handleOpenChange}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<Button
variant="primary"
className="py-2 pr-2 pl-3"
disabled={disabled}
>
{t('common.publish', { ns: 'workflow' })}
<span className="i-ri-arrow-down-s-line h-4 w-4 text-components-button-primary-text" />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-11">
<PopoverTrigger
render={(
<Button
variant="primary"
className="py-2 pr-2 pl-3"
disabled={disabled}
>
{t('common.publish', { ns: 'workflow' })}
<span className="i-ri-arrow-down-s-line h-4 w-4 text-components-button-primary-text" />
</Button>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={4}
alignOffset={crossAxisOffset}
popupClassName="border-none bg-transparent shadow-none"
>
<div className="w-[320px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
<PublisherSummarySection
debugWithMultipleModel={debugWithMultipleModel}
@ -308,7 +304,10 @@ const AppPublisher = ({
isAppAccessSet={isAppAccessSet}
isLoading={Boolean(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects))}
accessMode={appDetail?.access_mode}
onClick={() => setShowAppAccessControl(true)}
onClick={() => {
handleOpenChange(false)
setShowAppAccessControl(true)
}}
/>
<PublisherActionsSection
appDetail={appDetail}
@ -317,9 +316,12 @@ const AppPublisher = ({
disabledFunctionTooltip={disabledFunctionTooltip}
handleEmbed={() => {
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}
/>
</div>
</PortalToFollowElemContent>
</PopoverContent>
<EmbeddedModal
siteInfo={appDetail?.site}
isShow={embeddingModalOpen}
@ -343,7 +345,7 @@ const AppPublisher = ({
accessToken={accessToken}
/>
{showAppAccessControl && <AccessControl app={appDetail!} onConfirm={handleAccessControlUpdate} onClose={() => { setShowAppAccessControl(false) }} />}
</PortalToFollowElem>
</Popover>
</>
)
}

View File

@ -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 (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 4,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<Button variant="ghost" size="small" className={cn('')}>
<RiSettings2Line className="h-3.5 w-3.5" />
<div className="ml-1">{t('voice.settings', { ns: 'appDebug' })}</div>
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 50 }}>
<PopoverTrigger
render={(
<Button variant="ghost" size="small" className={cn('')}>
<RiSettings2Line className="h-3.5 w-3.5" />
<div className="ml-1">{t('voice.settings', { ns: 'appDebug' })}</div>
</Button>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={4}
popupClassName="border-none bg-transparent shadow-none"
>
<div className="w-80 space-y-3 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg p-4 shadow-lg sm:w-[412px]">
<ParamConfigContent />
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}
export default memo(ParamsConfig)

View File

@ -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 }) => (
<div data-testid="portal-elem" data-open={open ? 'true' : 'false'}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="portal-content">{children}</div>
),
}))
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 }) => (
<PopoverContext.Provider value={{ open, onOpenChange }}>
<div data-testid="popover-root" data-open={open ? 'true' : 'false'}>
{children}
</div>
</PopoverContext.Provider>
),
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 <button type="button" data-testid="popover-trigger" onClick={handleClick}>{content}</button>
},
PopoverContent: ({ children }: { children: React.ReactNode }) => {
const context = React.useContext(PopoverContext)
if (!context?.open)
return null
return <div data-testid="popover-content">{children}</div>
},
}
})
describe('ModelInfo', () => {
const defaultModel = {
@ -92,42 +116,46 @@ describe('ModelInfo', () => {
it('should be closed by default', () => {
render(<ModelInfo model={defaultModel} />)
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(<ModelInfo model={defaultModel} />)
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(<ModelInfo model={defaultModel} />)
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(<ModelInfo model={defaultModel} />)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByText('detail.modelParams')).toBeInTheDocument()
})
it('should render temperature parameter', () => {
render(<ModelInfo model={defaultModel} />)
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(<ModelInfo model={defaultModel} />)
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(<ModelInfo model={defaultModel} />)
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(<ModelInfo model={defaultModel} />)
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(<ModelInfo model={defaultModel} />)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByText('Stop')).toBeInTheDocument()
expect(screen.getByText('END')).toBeInTheDocument()
@ -171,6 +203,7 @@ describe('ModelInfo', () => {
}
render(<ModelInfo model={modelWithNoParams} />)
fireEvent.click(screen.getByRole('button'))
const dashes = screen.getAllByText('-')
expect(dashes.length).toBeGreaterThan(0)
@ -186,6 +219,7 @@ describe('ModelInfo', () => {
}
render(<ModelInfo model={modelWithInvalidStop} />)
fireEvent.click(screen.getByRole('button'))
const stopValues = screen.getAllByText('-')
expect(stopValues.length).toBeGreaterThan(0)
@ -201,6 +235,7 @@ describe('ModelInfo', () => {
}
render(<ModelInfo model={modelWithMultipleStops} />)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByText('END,STOP,DONE')).toBeInTheDocument()
})

View File

@ -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<Props> = ({
showMode
/>
</div>
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={4}
>
<div className="relative">
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
className="block"
>
<div className={cn(
'cursor-pointer rounded-r-lg bg-components-button-tertiary-bg p-2 hover:bg-components-button-tertiary-bg-hover',
open && 'bg-components-button-tertiary-bg-hover',
<PopoverTrigger
render={(
<button type="button" className="block border-none bg-transparent p-0">
<div className={cn(
'cursor-pointer rounded-r-lg bg-components-button-tertiary-bg p-2 hover:bg-components-button-tertiary-bg-hover',
open && 'bg-components-button-tertiary-bg-hover',
)}
>
<RiInformation2Line className="h-4 w-4 text-text-tertiary" />
</div>
</button>
)}
>
<RiInformation2Line className="h-4 w-4 text-text-tertiary" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1002">
/>
<PopoverContent
placement="bottom-end"
sideOffset={4}
popupClassName="border-none bg-transparent shadow-none"
>
<div className="relative w-[280px] overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg px-4 pt-3 pb-2 shadow-xl">
<div className="mb-1 h-6 system-sm-semibold-uppercase text-text-secondary">{t('detail.modelParams', { ns: 'appLog' })}</div>
<div className="py-1">
@ -101,9 +100,9 @@ const ModelInfo: FC<Props> = ({
})}
</div>
</div>
</PortalToFollowElemContent>
</PopoverContent>
</div>
</PortalToFollowElem>
</Popover>
</div>
)
}

View File

@ -133,6 +133,7 @@ vi.mock('@/utils/time', () => ({
// Mock dynamic imports
vi.mock('@/next/dynamic', () => ({
default: (importFn: () => Promise<unknown>) => {
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<DropdownMenuContextValue | null>(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
}) => (
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
<div data-testid="dropdown-menu" data-open={open}>
{children}
</div>
</DropdownMenuContext>
),
DropdownMenuTrigger: ({
children,
className,
onClick,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const { isOpen, setOpen } = useDropdownMenuContext()
return (
<button
data-testid="dropdown-menu-trigger"
type="button"
className={className}
onClick={(e) => {
onClick?.(e)
setOpen(!isOpen)
}}
{...props}
>
{children}
</button>
)
},
DropdownMenuContent: ({
children,
className,
popupClassName,
}: {
children: React.ReactNode
className?: string
popupClassName?: string
}) => {
const { isOpen } = useDropdownMenuContext()
if (!isOpen)
return null
return (
<div data-testid="dropdown-menu-content" role="menu" className={[className, popupClassName].filter(Boolean).join(' ')}>
{children}
</div>
)
},
DropdownMenuItem: ({
children,
className,
onClick,
destructive,
}: {
children: React.ReactNode
className?: string
onClick?: React.MouseEventHandler<HTMLButtonElement>
destructive?: boolean
}) => {
const { setOpen } = useDropdownMenuContext()
return (
<button
data-testid="dropdown-menu-item"
role="menuitem"
type="button"
className={className}
data-destructive={destructive}
onClick={(e) => {
onClick?.(e)
setOpen(false)
}}
>
{children}
</button>
)
},
DropdownMenuSeparator: () => <hr data-testid="dropdown-menu-separator" />,
}
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(<AppCard app={mockApp} />)
// 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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={chatApp} />)
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(<AppCard app={completionApp} />)
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(<AppCard app={workflowApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
// 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(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
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(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
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(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={chatApp} />)
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(<AppCard app={chatApp} />)
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(<AppCard app={chatApp} onRefresh={mockOnRefresh} />)
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(<AppCard app={chatApp} />)
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(<AppCard app={completionApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={workflowApp} />)
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(<AppCard app={workflowApp} />)
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(<AppCard app={workflowApp} />)
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(<AppCard app={advancedChatApp} />)
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(<AppCard app={workflowApp} />)
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(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
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(<AppCard app={workflowApp} />)
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(<AppCard app={chatApp} onRefresh={mockOnRefresh} />)
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(<AppCard app={completionApp} />)
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(<AppCard app={workflowApp} />)
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(<AppCard app={mockApp} />)
// 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(<AppCard app={mockApp} />)
// 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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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<string>, options?: { onError?: (err: unknown) => void }) => {
options?.onError?.('Window failed')
})
render(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={draftTriggerApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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
? (
<div role="alertdialog">
<button type="button" data-testid="keep-open-dialog" onClick={() => onOpenChange?.(true)}>Keep open</button>
<button type="button" data-testid="force-close-dialog" onClick={() => onOpenChange?.(false)}>Force close</button>
{children}
</div>
)
: null
),
AlertDialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AlertDialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AlertDialogDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AlertDialogActions: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AlertDialogCancelButton: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button type="button" {...props}>{children}</button>,
AlertDialogConfirmButton: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & { loading?: boolean }) => <button type="button" {...props}>{children}</button>,
})
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(<IsolatedAppCard app={mockApp} />)
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(<IsolatedAppCard app={mockApp} />)
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(<IsolatedAppCard app={mockApp} />)
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
})
})
})

View File

@ -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<AppCardOperationsMenuProps> = ({
app,
shouldShowSwitchOption,
shouldShowOpenInExploreOption,
shouldShowAccessControlOption,
onEdit,
onDuplicate,
onExport,
onSwitch,
onDelete,
onAccessControl,
}) => {
const { t } = useTranslation()
const openAsyncWindow = useAsyncWindowOpen()
const handleMenuAction = useCallback((e: React.MouseEvent<HTMLElement>, action: () => void) => {
e.stopPropagation()
e.preventDefault()
action()
}, [])
const handleOpenInstalledApp = useCallback(async (e: React.MouseEvent<HTMLElement>) => {
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 (
<>
<DropdownMenuItem className="gap-2 px-3" onClick={e => handleMenuAction(e, onEdit)}>
<span className="system-sm-regular text-text-secondary">{t('editApp', { ns: 'app' })}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2 px-3" onClick={e => handleMenuAction(e, onDuplicate)}>
<span className="system-sm-regular text-text-secondary">{t('duplicate', { ns: 'app' })}</span>
</DropdownMenuItem>
<DropdownMenuItem className="gap-2 px-3" onClick={e => handleMenuAction(e, onExport)}>
<span className="system-sm-regular text-text-secondary">{t('export', { ns: 'app' })}</span>
</DropdownMenuItem>
{shouldShowSwitchOption && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2 px-3" onClick={e => handleMenuAction(e, onSwitch)}>
<span className="text-sm leading-5 text-text-secondary">{t('switch', { ns: 'app' })}</span>
</DropdownMenuItem>
</>
)}
{shouldShowOpenInExploreOption && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2 px-3" onClick={handleOpenInstalledApp}>
<span className="system-sm-regular text-text-secondary">{t('openInExplore', { ns: 'app' })}</span>
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
{shouldShowAccessControlOption && (
<>
<DropdownMenuItem className="gap-2 px-3" onClick={e => handleMenuAction(e, onAccessControl)}>
<span className="text-sm leading-5 text-text-secondary">{t('accessControl', { ns: 'app' })}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
destructive
className="gap-2 px-3"
onClick={e => handleMenuAction(e, onDelete)}
>
<span className="system-sm-regular">
{t('operation.delete', { ns: 'common' })}
</span>
</DropdownMenuItem>
</>
)
}
type AppCardOperationsMenuContentProps = Omit<AppCardOperationsMenuProps, 'shouldShowOpenInExploreOption'>
const AppCardOperationsMenuContent: React.FC<AppCardOperationsMenuContentProps> = (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 (
<AppCardOperationsMenu
{...props}
shouldShowOpenInExploreOption={shouldShowOpenInExploreOption}
/>
)
}
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<EnvironmentVariable[]>([])
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<HTMLButtonElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
setShowEditModal(true)
}
const onClickDuplicate = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
setShowDuplicateModal(true)
}
const onClickExport = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
exportCheck()
}
const onClickSwitch = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
setShowSwitchModal(true)
}
const onClickDelete = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
setShowConfirmDelete(true)
}
const onClickAccessControl = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
setShowAccessControl(true)
}
const onClickInstalledApp = async (e: React.MouseEvent<HTMLButtonElement>) => {
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 (
<div className="relative flex w-full flex-col py-1" onMouseLeave={onMouseLeave}>
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickSettings}>
<span className="system-sm-regular text-text-secondary">{t('editApp', { ns: 'app' })}</span>
</button>
<Divider className="my-1" />
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickDuplicate}>
<span className="system-sm-regular text-text-secondary">{t('duplicate', { ns: 'app' })}</span>
</button>
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickExport}>
<span className="system-sm-regular text-text-secondary">{t('export', { ns: 'app' })}</span>
</button>
{(app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) && (
<>
<Divider className="my-1" />
<button
type="button"
className="mx-1 flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover"
onClick={onClickSwitch}
>
<span className="text-sm leading-5 text-text-secondary">{t('switch', { ns: 'app' })}</span>
</button>
</>
)}
{
!app.has_draft_trigger && (
(!systemFeatures.webapp_auth.enabled)
? (
<>
<Divider className="my-1" />
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickInstalledApp}>
<span className="system-sm-regular text-text-secondary">{t('openInExplore', { ns: 'app' })}</span>
</button>
</>
)
: !(isGettingUserCanAccessApp || !userCanAccessApp?.result) && (
<>
<Divider className="my-1" />
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickInstalledApp}>
<span className="system-sm-regular text-text-secondary">{t('openInExplore', { ns: 'app' })}</span>
</button>
</>
)
)
}
<Divider className="my-1" />
{
systemFeatures.webapp_auth.enabled && isCurrentWorkspaceEditor && (
<>
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickAccessControl}>
<span className="text-sm leading-5 text-text-secondary">{t('accessControl', { ns: 'app' })}</span>
</button>
<Divider className="my-1" />
</>
)
}
<button
type="button"
className="group mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 py-[6px] hover:bg-state-destructive-hover"
onClick={onClickDelete}
>
<span className="system-sm-regular text-text-secondary group-hover:text-text-destructive">
{t('operation.delete', { ns: 'common' })}
</span>
</button>
</div>
)
}
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<Tag[]>(app.tags)
useEffect(() => {
@ -414,28 +454,28 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
<div className="flex h-5 w-5 items-center justify-center">
{app.access_mode === AccessMode.PUBLIC && (
<Tooltip asChild={false} popupContent={t('accessItemsDescription.anyone', { ns: 'app' })}>
<RiGlobalLine className="h-4 w-4 text-text-quaternary" />
<span aria-hidden className="i-ri-global-line h-4 w-4 text-text-quaternary" />
</Tooltip>
)}
{app.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && (
<Tooltip asChild={false} popupContent={t('accessItemsDescription.specific', { ns: 'app' })}>
<RiLockLine className="h-4 w-4 text-text-quaternary" />
<span aria-hidden className="i-ri-lock-line h-4 w-4 text-text-quaternary" />
</Tooltip>
)}
{app.access_mode === AccessMode.ORGANIZATION && (
<Tooltip asChild={false} popupContent={t('accessItemsDescription.organization', { ns: 'app' })}>
<RiBuildingLine className="h-4 w-4 text-text-quaternary" />
<span aria-hidden className="i-ri-building-line h-4 w-4 text-text-quaternary" />
</Tooltip>
)}
{app.access_mode === AccessMode.EXTERNAL_MEMBERS && (
<Tooltip asChild={false} popupContent={t('accessItemsDescription.external', { ns: 'app' })}>
<RiVerifiedBadgeLine className="h-4 w-4 text-text-quaternary" />
<span aria-hidden className="i-ri-verified-badge-line h-4 w-4 text-text-quaternary" />
</Tooltip>
)}
</div>
</div>
</div>
<div className="title-wrapper h-[90px] px-[14px] text-xs leading-normal text-text-tertiary">
<div className="h-[90px] px-[14px] text-xs leading-normal text-text-tertiary">
<div
className="line-clamp-2"
title={app.description}
@ -453,7 +493,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
e.preventDefault()
}}
>
<div className="mr-[41px] w-full grow group-hover:mr-0!">
<div className="mr-[41px] w-full grow">
<TagSelector
position="bl"
type="app"
@ -465,32 +505,69 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
/>
</div>
</div>
<div className="mx-1 hidden! h-[14px] w-px shrink-0 bg-divider-regular group-hover:flex!" />
<div className="hidden! shrink-0 group-hover:flex!">
<CustomPopover
htmlContent={<Operations />}
position="br"
trigger="click"
btnElement={(
<div
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md"
>
<span className="sr-only">{t('operation.more', { ns: 'common' })}</span>
<RiMoreFill aria-hidden className="h-4 w-4 text-text-tertiary" />
</div>
)}
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!',
<div
className={cn(
'absolute top-1/2 right-[6px] flex -translate-y-1/2 items-center transition-opacity',
isOperationsMenuOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100',
)}
>
<div className="mx-1 h-[14px] w-px shrink-0 bg-divider-regular" />
<DropdownMenu open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
isOperationsMenuOpen ? 'bg-state-base-hover shadow-none' : 'bg-transparent',
'flex h-8 w-8 items-center justify-center rounded-md border-none p-2 hover:bg-state-base-hover',
)}
popupClassName={
(app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT)
? 'w-[256px]! translate-x-[-224px]'
: 'w-[216px]! translate-x-[-128px]'
}
className="z-20! h-fit"
/>
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md">
<span className="sr-only">{t('operation.more', { ns: 'common' })}</span>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</div>
</DropdownMenuTrigger>
{isOperationsMenuOpen && (
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName={operationsMenuWidthClassName}
>
{systemFeatures.webapp_auth.enabled
? (
<AppCardOperationsMenuContent
app={app}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowAccessControlOption={shouldShowAccessControlOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
/>
)
: (
<AppCardOperationsMenu
app={app}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowOpenInExploreOption={!app.has_draft_trigger}
shouldShowAccessControlOption={shouldShowAccessControlOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
/>
)}
</DropdownMenuContent>
)}
</DropdownMenu>
</div>
</>
)}

View File

@ -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'

View File

@ -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 }) => (
<button data-testid="content" onClick={onClick}>Close Me</button>
)
describe('CustomPopover', () => {
const defaultProps = {
btnElement: <span data-testid="trigger">Trigger</span>,
htmlContent: <div data-testid="content">Popover Content</div>,
}
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
if (vi.isFakeTimers?.())
vi.clearAllTimers()
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('Rendering', () => {
it('should render the trigger element', () => {
render(<CustomPopover {...defaultProps} />)
expect(screen.getByTestId('trigger')).toBeInTheDocument()
})
it('should render string as htmlContent', async () => {
render(<CustomPopover {...defaultProps} htmlContent="String Content" trigger="click" />)
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(<CustomPopover {...defaultProps} trigger="click" />)
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(<CustomPopover {...defaultProps} />)
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(<CustomPopover {...defaultProps} />)
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(<CustomPopover {...defaultProps} />)
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(<CustomPopover {...defaultProps} />)
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(<CustomPopover {...defaultProps} disabled={true} trigger="click" />)
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(
<CustomPopover
{...defaultProps}
htmlContent={<CloseButtonContent />}
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(<CustomPopover {...defaultProps} />)
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(
<CustomPopover
{...defaultProps}
trigger="click"
className="wrapper-class"
popupClassName="popup-inner-class"
btnClassName="btn-class"
/>,
)
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(
<CustomPopover
{...defaultProps}
btnClassName={(open: boolean) => 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(
<CustomPopover
{...defaultProps}
trigger="click"
position="bl"
/>,
)
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(
<CustomPopover
{...defaultProps}
trigger="click"
position="br"
/>,
)
await act(async () => {
fireEvent.click(screen.getByTestId('trigger'))
})
const panel = screen.getByTestId('content').closest('.absolute')
expect(panel).toHaveClass('right-0')
})
})
})

View File

@ -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 (
<div className="flex min-w-[220px] flex-col gap-2 p-3">
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-text-tertiary">
{title}
</div>
<p className="text-sm leading-5 text-text-secondary">{description}</p>
<button
type="button"
className="self-start rounded-md border border-divider-subtle px-2 py-1 text-xs font-medium text-text-tertiary hover:bg-state-base-hover"
onClick={onClose}
>
Dismiss
</button>
</div>
)
}
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 (
<div className="flex w-full max-w-lg flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<p className="text-sm text-text-secondary">{hoverHint}</p>
<div className="flex flex-wrap items-center gap-6">
<CustomPopover
trigger={trigger}
position={position}
manualClose={manualClose}
disabled={disabled}
btnElement={<span className="text-xs font-medium text-text-secondary">Popover trigger</span>}
htmlContent={(
<PopoverContent
title={trigger === 'hover' ? 'Quick help' : 'More actions'}
description={trigger === 'hover'
? 'Use hover-triggered popovers for light contextual hints and inline docs.'
: 'Click-triggered popovers are ideal for menus that require user decisions.'}
/>
)}
/>
</div>
</div>
)
}
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<typeof Template>
export default meta
type Story = StoryObj<typeof meta>
export const HoverPopover: Story = {}
export const ClickPopover: Story = {
args: {
trigger: 'click',
position: 'br',
},
}
export const DisabledState: Story = {
args: {
disabled: true,
},
}

View File

@ -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<HTMLButtonElement>(null)
const timeOutRef = useRef<number | null>(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 (
<Popover className="relative">
{({ open }: { open: boolean }) => {
return (
<>
<div
{...(trigger !== 'hover'
? {}
: {
onMouseLeave: () => onMouseLeave(open),
onMouseEnter: () => onMouseEnter(open),
})}
>
<PopoverButton
ref={buttonRef}
disabled={disabled}
className={cn(
'group inline-flex items-center rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 text-base font-medium hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover focus:outline-hidden',
open && 'border-components-button-secondary-border bg-components-button-secondary-bg-hover',
(btnClassName && typeof btnClassName === 'string') && btnClassName,
(btnClassName && typeof btnClassName !== 'string') && btnClassName?.(open),
)}
>
{btnElement}
</PopoverButton>
<Transition as={Fragment}>
<PopoverPanel
className={cn(
'absolute z-10 mt-1 w-full max-w-sm px-4 sm:px-0 lg:max-w-3xl',
position === 'bottom' && 'left-1/2 -translate-x-1/2',
position === 'bl' && 'left-0',
position === 'br' && 'right-0',
className,
)}
{...(trigger !== 'hover'
? {}
: {
onMouseLeave: () => onMouseLeave(open),
onMouseEnter: () => onMouseEnter(open),
})
}
>
{({ close }) => (
<div
className={cn('w-fit min-w-[130px] overflow-hidden rounded-lg bg-components-panel-bg shadow-lg ring-1 ring-black/5', popupClassName)}
{...(trigger !== 'hover'
? {}
: {
onMouseLeave: () => onMouseLeave(open),
onMouseEnter: () => onMouseEnter(open),
})
}
>
{isValidElement(htmlContent)
? cloneElement(htmlContent as React.ReactElement<HtmlContentProps>, {
open,
onClose: close,
...(manualClose
? {
onClick: close,
}
: {}),
})
: htmlContent}
</div>
)}
</PopoverPanel>
</Transition>
</div>
</>
)
}}
</Popover>
)
}

View File

@ -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<PopoverContentProps>, {
open: isOpen,
onClose: () => setIsOpen(false),
})
: htmlContent
return (
<div data-testid="custom-popover">
<button
type="button"
aria-expanded={isOpen}
className={computedClassName}
onClick={() => setIsOpen(prev => !prev)}
>
{btnElement}
</button>
{isOpen && (
<div data-testid="popover-content">
{content}
</div>
)}
</div>
)
}
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)

View File

@ -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

View File

@ -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<TagSelectorProps> = ({
@ -31,8 +36,10 @@ const TagSelector: FC<TagSelectorProps> = ({
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<TagSelectorProps> = ({
return []
}, [selectedTags, tagList])
return (
<>
{isPopover && (
<CustomPopover
htmlContent={(
<Panel
type={type}
targetID={targetID}
value={value}
selectedTags={selectedTags}
onCacheUpdate={onCacheUpdate}
onChange={onChange}
onCreate={getTagList}
/>
)}
position={position}
trigger="click"
btnElement={<Trigger tags={tags} />}
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
aria-label={triggerLabel}
className={cn(
open ? 'bg-state-base-hover' : 'bg-transparent',
'block w-full rounded-lg border-0 p-0 text-left focus:outline-hidden',
)}
>
<Trigger tags={tags} />
</PopoverTrigger>
<PopoverContent
placement={placement}
sideOffset={4}
popupClassName="overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]"
popupProps={{
style: {
width: 'var(--anchor-width, auto)',
minWidth: resolvedMinWidth,
},
}}
>
<Panel
type={type}
targetID={targetID}
value={value}
selectedTags={selectedTags}
onCacheUpdate={onCacheUpdate}
onChange={onChange}
onCreate={getTagList}
/>
</PopoverContent>
</Popover>
)
}

View File

@ -37,14 +37,12 @@ describe('Actions', () => {
it('should render add icon', () => {
const { container } = render(<Actions {...defaultProps} />)
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(<Actions {...defaultProps} />)
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(<Actions {...defaultProps} />)
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', () => {

View File

@ -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 (
<div className="absolute bottom-0 left-0 z-10 hidden w-full items-center gap-x-1 bg-pipeline-template-card-hover-bg p-4 pt-8 group-hover:flex">
<div
className={cn(
'absolute bottom-0 left-0 z-10 w-full items-center gap-x-1 bg-pipeline-template-card-hover-bg p-4 pt-8',
isMoreOperationsOpen ? 'flex' : 'hidden group-hover:flex',
)}
>
<Button
variant="primary"
onClick={onApplyTemplate}
className="grow gap-x-0.5"
>
<RiAddLine className="size-4" />
<span aria-hidden className="i-ri-add-line size-4" />
<span className="px-0.5">{t('operations.choose', { ns: 'datasetPipeline' })}</span>
</Button>
<Button
@ -39,28 +49,35 @@ const Actions = ({
onClick={handleShowTemplateDetails}
className="grow gap-x-0.5"
>
<RiArrowRightUpLine className="size-4" />
<span aria-hidden className="i-ri-arrow-right-up-line size-4" />
<span className="px-0.5">{t('operations.details', { ns: 'datasetPipeline' })}</span>
</Button>
{
showMoreOperations && (
<CustomPopover
htmlContent={(
<DropdownMenu open={isMoreOperationsOpen} onOpenChange={setIsMoreOperationsOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
'flex size-8 cursor-pointer items-center justify-center rounded-lg p-0 shadow-xs shadow-shadow-shadow-3',
isMoreOperationsOpen && 'bg-state-base-hover',
)}
onClick={e => e.stopPropagation()}
>
<span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="min-w-[160px] border-0 bg-transparent py-0 shadow-none backdrop-blur-none"
>
<Operations
openEditModal={openEditModal}
onExport={handleExportDSL}
onDelete={handleDelete}
onClose={() => 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={
<RiMoreFill className="size-4 text-text-tertiary" />
}
btnClassName="size-8 cursor-pointer justify-center rounded-lg p-0 shadow-xs shadow-shadow-shadow-3"
/>
</DropdownMenuContent>
</DropdownMenu>
)
}
</div>

View File

@ -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<HTMLDivElement>) => {
e.stopPropagation()
e.preventDefault()
onClose?.()
openEditModal()
}
const onClickExport = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.preventDefault()
onClose?.()
onExport()
}
const onClickDelete = (e: React.MouseEvent<HTMLDivElement>) => {
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}
>
<span className="system-md-regular px-1 text-text-secondary">
<span className="px-1 system-md-regular text-text-secondary">
{t('operations.editInfo', { ns: 'datasetPipeline' })}
</span>
</div>
@ -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}
>
<span className="system-md-regular px-1 text-text-secondary">
<span className="px-1 system-md-regular text-text-secondary">
{t('operations.exportPipeline', { ns: 'datasetPipeline' })}
</span>
</div>
@ -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}
>
<span className="system-md-regular px-1 text-text-secondary group-hover:text-text-destructive">
<span className="px-1 system-md-regular text-text-secondary group-hover:text-text-destructive">
{t('operation.delete', { ns: 'common' })}
</span>
</div>

View File

@ -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>): ILanguageSelectProps => ({
currentLanguage: 'English',
onSelect: vi.fn(),
@ -15,264 +14,163 @@ const createDefaultProps = (overrides?: Partial<ILanguageSelectProps>): 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(<LanguageSelect {...createDefaultProps()} />)
render(<LanguageSelect {...props} />)
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(<LanguageSelect {...createDefaultProps({ currentLanguage: 'NonExistentLanguage' })} />)
render(<LanguageSelect {...props} />)
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(<LanguageSelect {...createDefaultProps({ currentLanguage: '' })} />)
const { container } = render(<LanguageSelect {...props} />)
// 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(<LanguageSelect {...props} />)
// 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(<LanguageSelect {...props} />)
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(<LanguageSelect {...props} />)
expect(screen.getByText('English')).toBeInTheDocument()
})
// Dropdown behavior
describe('Dropdown behavior', () => {
it('should render all supported languages when the select is opened', async () => {
render(<LanguageSelect {...createDefaultProps()} />)
it('should display Chinese Simplified when currentLanguage is Chinese Simplified', () => {
const props = createDefaultProps({ currentLanguage: 'Chinese Simplified' })
render(<LanguageSelect {...props} />)
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(<LanguageSelect {...props} />)
expect(screen.getByText('Japanese')).toBeInTheDocument()
it('should only render supported languages in the dropdown', async () => {
render(<LanguageSelect {...createDefaultProps()} />)
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(<LanguageSelect {...props} />)
expect(screen.getByText(language)).toBeInTheDocument()
it('should mark the selected language inside the opened list', async () => {
render(<LanguageSelect {...createDefaultProps({ currentLanguage: 'Japanese' })} />)
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(<LanguageSelect {...createDefaultProps({ onSelect })} />)
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(<LanguageSelect {...createDefaultProps()} />)
rerender(<LanguageSelect {...createDefaultProps({ currentLanguage: 'French' })} />)
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 <div>{children}</div>
},
)
})
SelectTrigger: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button type="button" {...props}>{children}</button>,
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItemText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
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(<LanguageSelect {...props} />)
render(<IsolatedLanguageSelect currentLanguage="English" onSelect={onSelect} />)
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(<LanguageSelect {...props} />)
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<ILanguageSelectProps>).disabled
render(<LanguageSelect {...props} />)
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(<LanguageSelect {...props} />)
// 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(<LanguageSelect {...props} />)
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(<LanguageSelect {...props} />)
// 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(<LanguageSelect {...props} />)
// Disabled state
describe('Disabled state', () => {
it('should disable the trigger when disabled is true', () => {
render(<LanguageSelect {...createDefaultProps({ disabled: true })} />)
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(<LanguageSelect {...props} />)
it('should not open the listbox when disabled', () => {
render(<LanguageSelect {...createDefaultProps({ disabled: true })} />)
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(<LanguageSelect {...props} />)
// 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(<LanguageSelect {...props} />)
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(<LanguageSelect {...props} />)
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(<LanguageSelect {...props} />)
// 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(<LanguageSelect {...createDefaultProps()} />)
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<ILanguageSelectProps> = (trackedProps) => {
renderSpy()
return <LanguageSelect {...trackedProps} />
@ -282,224 +180,7 @@ describe('LanguageSelect', () => {
const { rerender } = render(<MemoizedTracked {...props} />)
rerender(<MemoizedTracked {...props} />)
// 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(<LanguageSelect {...props} />)
expect(screen.getByText('English')).toBeInTheDocument()
rerender(<LanguageSelect {...props} currentLanguage="French" />)
expect(screen.getByText('French')).toBeInTheDocument()
})
it('should re-render when disabled changes', () => {
const props = createDefaultProps({ disabled: false })
const { rerender } = render(<LanguageSelect {...props} />)
expect(screen.getByRole('button')).not.toBeDisabled()
rerender(<LanguageSelect {...props} disabled={true} />)
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(<LanguageSelect {...props} />)
// 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(<LanguageSelect {...props} />)
// 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(<LanguageSelect {...props} />)
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(<LanguageSelect {...props} />)
// 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(<LanguageSelect {...props} />)
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(<LanguageSelect {...props} />)
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(<LanguageSelect {...props} />)
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(<LanguageSelect {...props} />)
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(<LanguageSelect {...props} />)
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
it('should have clickable language options', () => {
const props = createDefaultProps()
render(<LanguageSelect {...props} />)
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(<LanguageSelect {...props} />)
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(<LanguageSelect {...props} />)
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(<LanguageSelect {...props} />)
// 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(<LanguageSelect {...props} />)
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(<LanguageSelect {...props} />)
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(<LanguageSelect {...props} />)
// Assert - Check for disabled text color on icon
const disabledIcon = container.querySelector('.text-components-button-tertiary-text-disabled')
expect(disabledIcon).toBeInTheDocument()
})
})
})

View File

@ -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<ILanguageSelectProps> = ({
onSelect,
disabled,
}) => {
const supportedLanguages = languages.filter(language => language.supported)
return (
<Popover
manualClose
trigger="click"
<Select
value={currentLanguage}
onValueChange={(value) => {
if (value == null)
return
onSelect(value)
}}
disabled={disabled}
popupClassName="z-20"
htmlContent={(
<div className="w-full p-1">
{languages.filter(language => language.supported).map(({ prompt_name }) => (
<div
key={prompt_name}
className="inline-flex w-full cursor-pointer items-center justify-between rounded-lg px-3 py-2 hover:bg-state-base-hover"
onClick={() => onSelect(prompt_name)}
>
<span className="system-sm-medium text-text-secondary">{prompt_name}</span>
{(currentLanguage === prompt_name) && <RiCheckLine className="size-4 text-text-accent" />}
</div>
))}
</div>
)}
btnElement={(
<div className={cn('inline-flex items-center gap-x-px', disabled && 'cursor-not-allowed')}>
<span className={cn(
'px-[3px] system-xs-semibold text-components-button-tertiary-text',
disabled ? 'text-components-button-tertiary-text-disabled' : '',
)}
>
{currentLanguage}
</span>
<RiArrowDownSLine className={cn(
'size-3.5 text-components-button-tertiary-text',
disabled ? 'text-components-button-tertiary-text-disabled' : '',
)}
/>
</div>
)}
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!"
/>
>
<SelectTrigger
size="small"
aria-label="language"
className={cn(
'mx-1 w-auto shrink-0 bg-components-button-tertiary-bg text-components-button-tertiary-text hover:bg-components-button-tertiary-bg',
disabled && 'cursor-not-allowed bg-components-button-tertiary-bg-disabled text-components-button-tertiary-text-disabled hover:bg-components-button-tertiary-bg-disabled',
)}
>
{currentLanguage || <span>&nbsp;</span>}
</SelectTrigger>
<SelectContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-max"
listClassName="no-scrollbar"
>
{supportedLanguages.map(({ prompt_name }) => (
<SelectItem key={prompt_name} value={prompt_name}>
<SelectItemText>{prompt_name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}
export default React.memo(LanguageSelect)

View File

@ -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', () => {

View File

@ -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<HTMLDivElement>) => {
evt.preventDefault()
evt.stopPropagation()
evt.nativeEvent.stopImmediatePropagation?.()
closeOperationsMenu()
void handleDownload()
}, [closeOperationsMenu, handleDownload])
return (
<div className="flex items-center" onClick={e => e.stopPropagation()}>
{isListScene && !embeddingAvailable && (<Switch checked={false} onCheckedChange={noop} disabled={true} size="md" />)}
@ -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`)}
>
<RiEqualizer2Line className="h-4 w-4 text-components-button-secondary-text" />
<span aria-hidden className="i-ri-equalizer-2-line h-4 w-4 text-components-button-secondary-text" />
</button>
</Tooltip>
<CustomPopover
htmlContent={(
<DropdownMenu open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
isListScene ? s.actionIconWrapperList : s.actionIconWrapperDetail,
'inline-flex items-center justify-center',
!isListScene && '!h-8 !w-8 rounded-lg backdrop-blur-[5px]',
isOperationsMenuOpen
? '!shadow-none hover:!bg-state-base-hover'
: isListScene && '!bg-transparent',
)}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<div className={cn(s.commonIcon)}>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-components-button-secondary-text" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName={cn('w-[200px] py-0', className)}
>
<div className="w-full py-1">
{!archived && (
<>
<div
className={s.actionItem}
onClick={() => {
handleShowRenameModal({
id: detail.id,
name: detail.name,
})
}}
>
<RiEditLine className="h-4 w-4 text-text-tertiary" />
<div className={s.actionItem} onClick={handleShowRename}>
<span aria-hidden className="i-ri-edit-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.table.rename', { ns: 'datasetDocuments' })}</span>
</div>
{data_source_type === DataSourceType.FILE && (
<div
className={s.actionItem}
onClick={(evt) => {
evt.preventDefault()
evt.stopPropagation()
evt.nativeEvent.stopImmediatePropagation?.()
handleDownload()
}}
>
<RiDownload2Line className="h-4 w-4 text-text-tertiary" />
<div className={s.actionItem} onClick={handleDownloadClick}>
<span aria-hidden className="i-ri-download-2-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.download', { ns: 'datasetDocuments' })}</span>
</div>
)}
{['notion_import', DataSourceType.WEB].includes(data_source_type) && (
<div className={s.actionItem} onClick={() => onOperate('sync')}>
<RiLoopLeftLine className="h-4 w-4 text-text-tertiary" />
<div className={s.actionItem} onClick={() => handleMenuOperation('sync')}>
<span aria-hidden className="i-ri-loop-left-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.sync', { ns: 'datasetDocuments' })}</span>
</div>
)}
{IS_CE_EDITION && (
<div className={s.actionItem} onClick={() => onOperate('summary')}>
<SearchLinesSparkle className="h-4 w-4 text-text-tertiary" />
<div className={s.actionItem} onClick={() => handleMenuOperation('summary')}>
<span aria-hidden className="i-custom-vender-knowledge-search-lines-sparkle h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.summary', { ns: 'datasetDocuments' })}</span>
</div>
)}
@ -230,62 +283,44 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele
)}
{archived && data_source_type === DataSourceType.FILE && (
<>
<div
className={s.actionItem}
onClick={(evt) => {
evt.preventDefault()
evt.stopPropagation()
evt.nativeEvent.stopImmediatePropagation?.()
handleDownload()
}}
>
<RiDownload2Line className="h-4 w-4 text-text-tertiary" />
<div className={s.actionItem} onClick={handleDownloadClick}>
<span aria-hidden className="i-ri-download-2-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.download', { ns: 'datasetDocuments' })}</span>
</div>
<Divider className="my-1" />
</>
)}
{!archived && display_status?.toLowerCase() === 'indexing' && (
<div className={s.actionItem} onClick={() => onOperate('pause')}>
<RiPauseCircleLine className="h-4 w-4 text-text-tertiary" />
<div className={s.actionItem} onClick={() => handleMenuOperation('pause')}>
<span aria-hidden className="i-ri-pause-circle-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.pause', { ns: 'datasetDocuments' })}</span>
</div>
)}
{!archived && display_status?.toLowerCase() === 'paused' && (
<div className={s.actionItem} onClick={() => onOperate('resume')}>
<RiPlayCircleLine className="h-4 w-4 text-text-tertiary" />
<div className={s.actionItem} onClick={() => handleMenuOperation('resume')}>
<span aria-hidden className="i-ri-play-circle-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.resume', { ns: 'datasetDocuments' })}</span>
</div>
)}
{!archived && (
<div className={s.actionItem} onClick={() => onOperate('archive')}>
<RiArchive2Line className="h-4 w-4 text-text-tertiary" />
<div className={s.actionItem} onClick={() => handleMenuOperation('archive')}>
<span aria-hidden className="i-ri-archive-2-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.archive', { ns: 'datasetDocuments' })}</span>
</div>
)}
{archived && (
<div className={s.actionItem} onClick={() => onOperate('un_archive')}>
<RiArchive2Line className="h-4 w-4 text-text-tertiary" />
<div className={s.actionItem} onClick={() => handleMenuOperation('un_archive')}>
<span aria-hidden className="i-ri-archive-2-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.unarchive', { ns: 'datasetDocuments' })}</span>
</div>
)}
<div className={cn(s.actionItem, s.deleteActionItem, 'group')} onClick={() => setShowModal(true)}>
<RiDeleteBinLine className="h-4 w-4 text-text-tertiary group-hover:text-text-destructive" />
<div className={cn(s.actionItem, s.deleteActionItem, 'group')} onClick={handleDeleteClick}>
<span aria-hidden className="i-ri-delete-bin-line h-4 w-4 text-text-tertiary group-hover:text-text-destructive" />
<span className={cn(s.actionName, 'group-hover:text-text-destructive')}>{t('list.action.delete', { ns: 'datasetDocuments' })}</span>
</div>
</div>
)}
trigger="click"
position="br"
btnElement={(
<div className={cn(s.commonIcon)}>
<RiMoreFill className="h-4 w-4 text-components-button-secondary-text" />
</div>
)}
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}`}
/>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
<AlertDialog open={showModal} onOpenChange={open => !open && setShowModal(false)}>

View File

@ -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 }) => (
<div data-testid="popover">
<button data-testid="popover-btn" disabled={disabled}>
{btnElement}
</button>
<div data-testid="popover-content">{htmlContent}</div>
</div>
),
}))
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(<SegmentAdd {...defaultProps} />)
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(<SegmentAdd {...defaultProps} />)
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(<SegmentAdd {...defaultProps} showBatchModal={mockShowBatchModal} />)
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(<SegmentAdd {...defaultProps} embedding={true} />)
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', () => {

View File

@ -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<ISegmentAddProps> = ({
const { plan, enableBilling } = useProviderContext()
const { type } = plan
const canAdd = enableBilling ? type !== Plan.sandbox : true
const [isBatchMenuOpen, setIsBatchMenuOpen] = useState(false)
const batchMenuAnchorRef = useRef<HTMLDivElement>(null)
const withNeedUpgradeCheck = useCallback((fn: () => void) => {
return () => {
@ -72,14 +72,14 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]"
>
<div className={cn('absolute top-0 left-0 z-0 h-full border-r-[1.5px] border-r-components-progress-bar-progress-highlight bg-components-progress-bar-progress', importStatus === ProcessStatus.WAITING ? 'w-3/12' : 'w-2/3')} />
<RiLoader2Line className="mr-1 h-4 w-4 animate-spin" />
<span aria-hidden className="mr-1 i-ri-loader-2-line h-4 w-4 animate-spin" />
<span className="z-10 pr-0.5 system-sm-medium">{t('list.batchModal.processing', { ns: 'datasetDocuments' })}</span>
</div>
)}
{importStatus === ProcessStatus.COMPLETED && (
<div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]">
<div className="inline-flex items-center border-r border-r-divider-subtle px-2.5 py-2 text-text-success">
<CheckCircle className="mr-1 h-4 w-4" />
<span aria-hidden className="mr-1 i-custom-vender-solid-general-check-circle h-4 w-4" />
<span className="pr-0.5 system-sm-medium">{t('list.batchModal.completed', { ns: 'datasetDocuments' })}</span>
</div>
<div className="m-1 inline-flex items-center">
@ -91,7 +91,7 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
{importStatus === ProcessStatus.ERROR && (
<div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]">
<div className="inline-flex items-center border-r border-r-divider-subtle px-2.5 py-2 text-text-destructive">
<RiErrorWarningFill className="mr-1 h-4 w-4" />
<span aria-hidden className="mr-1 i-ri-error-warning-fill h-4 w-4" />
<span className="pr-0.5 system-sm-medium">{t('list.batchModal.error', { ns: 'datasetDocuments' })}</span>
</div>
<div className="m-1 inline-flex items-center">
@ -105,10 +105,12 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
}
return (
<div className={cn(
'relative z-20 flex items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]',
embedding && 'border-components-button-secondary-border-disabled bg-components-button-secondary-bg-disabled',
)}
<div
ref={batchMenuAnchorRef}
className={cn(
'relative z-20 flex items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]',
embedding && 'border-components-button-secondary-border-disabled bg-components-button-secondary-bg-disabled',
)}
>
<button
type="button"
@ -117,42 +119,44 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
onClick={withNeedUpgradeCheck(showNewSegmentModal)}
disabled={embedding}
>
<RiAddLine className={cn('h-4 w-4', textColor)} />
<span aria-hidden className={cn('i-ri-add-line h-4 w-4', textColor)} />
<span className={cn('ml-0.5 px-0.5 text-[13px] leading-[16px] font-medium capitalize', textColor)}>
{t('list.action.addButton', { ns: 'datasetDocuments' })}
</span>
</button>
<Popover
position="br"
manualClose
trigger="click"
htmlContent={(
// need to wrapper the button with div when manualClose is true
<DropdownMenu open={isBatchMenuOpen} onOpenChange={setIsBatchMenuOpen}>
<DropdownMenuTrigger
aria-label={t('list.action.batchAdd', { ns: 'datasetDocuments' })}
disabled={embedding}
className={cn(
`rounded-l-none rounded-r-lg border-0 p-2 backdrop-blur-[5px]
hover:bg-state-base-hover disabled:cursor-not-allowed disabled:bg-transparent disabled:hover:bg-transparent`,
isBatchMenuOpen && 'bg-state-base-hover',
)}
>
<div className="flex items-center justify-center">
<span aria-hidden className={cn('i-ri-arrow-down-s-line h-4 w-4', textColor)} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
positionerProps={{ anchor: batchMenuAnchorRef }}
popupClassName="w-[var(--anchor-width)] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-0 shadow-xl shadow-shadow-shadow-5 backdrop-blur-[5px]"
>
<div className="w-full p-1">
<button
type="button"
className="flex w-full items-center rounded-lg px-2 py-1.5 system-md-regular text-text-secondary"
onClick={withNeedUpgradeCheck(showBatchModal)}
<DropdownMenuItem
className="h-auto w-full px-2 py-1.5 system-md-regular"
onClick={() => {
setIsBatchMenuOpen(false)
withNeedUpgradeCheck(showBatchModal)()
}}
>
{t('list.action.batchAdd', { ns: 'datasetDocuments' })}
</button>
</DropdownMenuItem>
</div>
)}
btnElement={(
<div className="flex items-center justify-center">
<RiArrowDownSLine className={cn('h-4 w-4', textColor)} />
</div>
)}
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}
/>
</DropdownMenuContent>
</DropdownMenu>
{isShowPlanUpgradeModal && (
<PlanUpgradeModal
show

View File

@ -3,7 +3,7 @@ import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge'
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'
@ -19,45 +19,42 @@ const ApiAccess = ({
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleToggle = () => {
setOpen(!open)
}
return (
<div className="p-3 pt-2">
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="top-start"
offset={{
mainAxis: 4,
crossAxis: -4,
}}
>
<PortalToFollowElemTrigger
className="w-full"
onClick={handleToggle}
>
<div className={cn(
'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border border-components-panel-border px-3',
!expand && 'w-8 justify-center',
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
<PopoverTrigger
render={(
<button type="button" className="w-full border-none bg-transparent p-0 text-left">
<div className={cn(
'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border border-components-panel-border px-3',
!expand && 'w-8 justify-center',
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
)}
>
<ApiAggregate className="size-4 shrink-0 text-text-secondary" />
{expand && <div className="grow system-sm-medium text-text-secondary">{t('appMenus.apiAccess', { ns: 'common' })}</div>}
<Indicator
className={cn('shrink-0', !expand && 'absolute -top-px -right-px')}
color={apiEnabled ? 'green' : 'yellow'}
/>
</div>
</button>
)}
>
<ApiAggregate className="size-4 shrink-0 text-text-secondary" />
{expand && <div className="grow system-sm-medium text-text-secondary">{t('appMenus.apiAccess', { ns: 'common' })}</div>}
<Indicator
className={cn('shrink-0', !expand && 'absolute -top-px -right-px')}
color={apiEnabled ? 'green' : 'yellow'}
/>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-10">
/>
<PopoverContent
placement="top-start"
sideOffset={4}
alignOffset={-4}
popupClassName="border-none bg-transparent shadow-none"
>
<Card
apiEnabled={apiEnabled}
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
</div>
)
}

View File

@ -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 (
<div>
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="top-start"
offset={{
mainAxis: 4,
crossAxis: -4,
}}
>
<PortalToFollowElemTrigger
className="w-full"
onClick={handleToggle}
>
<div className={cn(
'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border-[0.5px] border-components-button-secondary-border-hover bg-components-button-secondary-bg px-3',
open ? 'bg-components-button-secondary-bg-hover' : 'hover:bg-components-button-secondary-bg-hover',
<PopoverTrigger
render={(
<button type="button" className="w-full border-none bg-transparent p-0 text-left">
<div className={cn(
'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border-[0.5px] border-components-button-secondary-border-hover bg-components-button-secondary-bg px-3',
open ? 'bg-components-button-secondary-bg-hover' : 'hover:bg-components-button-secondary-bg-hover',
)}
>
<Indicator
className={cn('shrink-0')}
color={
apiBaseUrl ? 'green' : 'yellow'
}
/>
<div className="grow system-sm-medium text-text-secondary">{t('serviceApi.title', { ns: 'dataset' })}</div>
</div>
</button>
)}
>
<Indicator
className={cn('shrink-0')}
color={
apiBaseUrl ? 'green' : 'yellow'
}
/>
<div className="grow system-sm-medium text-text-secondary">{t('serviceApi.title', { ns: 'dataset' })}</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-10">
/>
<PopoverContent
placement="top-start"
sideOffset={4}
alignOffset={-4}
popupClassName="border-none bg-transparent shadow-none"
>
<Card
apiBaseUrl={apiBaseUrl}
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
</div>
)
}

View File

@ -56,9 +56,9 @@ vi.mock('../components/dataset-card-modals', () => ({
default: () => <div data-testid="card-modals" />,
}))
vi.mock('../components/tag-area', () => ({
default: React.forwardRef<HTMLDivElement, { onClick: (e: React.MouseEvent) => void }>(({ onClick }, ref) => (
<div ref={ref} data-testid="tag-area" onClick={onClick} />
)),
default: ({ onClick }: { onClick: (e: React.MouseEvent) => void, ref?: React.Ref<HTMLDivElement> }) => (
<div data-testid="tag-area" onClick={onClick} />
),
}))
vi.mock('../components/operations-dropdown', () => ({
default: () => <div data-testid="operations-dropdown" />,

View File

@ -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(<OperationItem {...defaultProps} />)
const icon = container.querySelector('svg')
const icon = container.querySelector('.i-ri-edit-line')
expect(icon).toBeInTheDocument()
expect(icon).toHaveClass('size-4', 'text-text-tertiary')
})

View File

@ -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'

View File

@ -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?.()
}}
>
<Icon className="size-4 text-text-tertiary" />
<span aria-hidden className={cn(iconClassName, 'size-4 text-text-tertiary')} />
<span className="system-md-regular px-1 text-text-secondary">
{name}
</span>

View File

@ -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 (
<>
<DropdownMenuItem onClick={openRenameModal}>
<DropdownMenuItem onClick={handleRename}>
<span aria-hidden className="i-ri-edit-line size-4 text-text-tertiary" />
{t('operation.edit', { ns: 'common' })}
</DropdownMenuItem>
{showExportPipeline && (
<DropdownMenuItem onClick={handleExportPipeline}>
<DropdownMenuItem onClick={handleExport}>
<span aria-hidden className="i-ri-file-download-line size-4 text-text-tertiary" />
{t('operations.exportPipeline', { ns: 'datasetPipeline' })}
</DropdownMenuItem>
@ -37,7 +54,7 @@ const Operations = ({
{showDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem destructive onClick={detectIsUsedByApp}>
<DropdownMenuItem destructive onClick={handleDelete}>
<span aria-hidden className="i-ri-delete-bin-line size-4" />
{t('operation.delete', { ns: 'common' })}
</DropdownMenuItem>

View File

@ -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) => (
<div data-testid="portal-wrapper" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: TriggerProps) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children, className }: ContentProps) => (
<div data-testid="portal-content" className={className}>{children}</div>
),
}))
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) => (
<PopoverContext.Provider value={{ open, onOpenChange }}>
<div data-testid="popover-root" data-open={String(open)}>{children}</div>
</PopoverContext.Provider>
),
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 <button type="button" data-testid="popover-trigger" onClick={handleClick}>{content}</button>
},
PopoverContent: ({ children, className }: ContentProps) => {
const context = React.useContext(PopoverContext)
if (!context?.open)
return null
return <div data-testid="popover-content" className={className}>{children}</div>
},
}
})
// 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(
<CreateMetadataModal
@ -226,7 +245,7 @@ describe('CreateMetadataModal', () => {
/>,
)
expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'true')
expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'true')
})
it('should handle different trigger elements', () => {

View File

@ -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) => (
<div data-testid="portal-wrapper" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: TriggerProps) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: ContentProps) => (
<div data-testid="portal-content">{children}</div>
),
}))
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) => (
<PopoverContext.Provider value={{ open, onOpenChange }}>
<div data-testid="popover-root" data-open={String(open)}>{children}</div>
</PopoverContext.Provider>
),
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 <button type="button" data-testid="popover-trigger" onClick={handleClick}>{content}</button>
},
PopoverContent: ({ children }: ContentProps) => {
const context = React.useContext(PopoverContext)
if (!context?.open)
return null
return <div data-testid="popover-content">{children}</div>
},
}
})
// 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(
<SelectMetadataModal
datasetId="dataset-1"
@ -125,7 +145,7 @@ describe('SelectMetadataModal', () => {
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(
<SelectMetadataModal
@ -329,7 +355,7 @@ describe('SelectMetadataModal', () => {
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
expect(screen.getByTestId('popover-root')).toBeInTheDocument()
})
it('should handle empty trigger', () => {

View File

@ -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<Props> = ({
popupLeft = 20,
...createContentProps
}) => {
const triggerElement = React.isValidElement(trigger)
? trigger
: <button type="button">{trigger}</button>
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="left-start"
offset={{
mainAxis: popupLeft,
crossAxis: -38,
}}
>
<PortalToFollowElemTrigger
onClick={() => setOpen(!open)}
<PopoverTrigger render={triggerElement as React.ReactElement} />
<PopoverContent
placement="left-start"
sideOffset={popupLeft}
alignOffset={-38}
popupClassName="border-none bg-transparent shadow-none"
>
{trigger}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
<CreateContent {...createContentProps} onClose={() => setOpen(false)} onBack={() => setOpen(false)} />
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -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<Props> = ({
const [open, setOpen] = useState(false)
const [step, setStep] = useState(Step.select)
const triggerElement = React.isValidElement(trigger)
? trigger
: <button type="button">{trigger}</button>
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 (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement={popupPlacement}
offset={popupOffset}
onOpenChange={handleOpenChange}
>
<PortalToFollowElemTrigger
onClick={() => setOpen(!open)}
className="block"
<PopoverTrigger render={triggerElement as React.ReactElement} />
<PopoverContent
placement={popupPlacement}
sideOffset={popupOffset.mainAxis}
alignOffset={popupOffset.crossAxis}
popupClassName="border-none bg-transparent shadow-none"
>
{trigger}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
{step === Step.select
? (
<SelectMetadata
@ -66,7 +72,11 @@ const SelectMetadataModal: FC<Props> = ({
}}
list={datasetMetaData?.doc_metadata || []}
onNew={() => setStep(Step.create)}
onManage={onManage}
onManage={() => {
setOpen(false)
setStep(Step.select)
onManage()
}}
/>
)
: (
@ -77,8 +87,8 @@ const SelectMetadataModal: FC<Props> = ({
onClose={() => setStep(Step.select)}
/>
)}
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -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
}) => (
<textarea
aria-label="json-editor"
value={value}
onChange={e => onUpdate(e.target.value)}
/>
),
}))
vi.mock('../error-message', () => ({
default: ({ message }: { message: string }) => <div data-testid="error-message">{message}</div>,
}))
vi.mock('@/app/components/base/ui/popover', async () => {
const ReactModule = await vi.importActual<typeof import('react')>('react')
const PopoverContext = ReactModule.createContext<{
open: boolean
onOpenChange?: (open: boolean) => void
} | null>(null)
const PopoverTrigger = ReactModule.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
({ children, onClick, ...props }, ref) => {
const context = ReactModule.useContext(PopoverContext)
return (
<button
ref={ref}
type="button"
{...props}
onClick={(event) => {
onClick?.(event)
context?.onOpenChange?.(!context.open)
}}
>
{children}
</button>
)
},
)
PopoverTrigger.displayName = 'PopoverTrigger'
return {
Popover: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode
open: boolean
onOpenChange?: (open: boolean) => void
}) => (
<PopoverContext.Provider value={{ open, onOpenChange }}>
{children}
</PopoverContext.Provider>
),
PopoverTrigger,
PopoverContent: ({ children }: { children: React.ReactNode }) => {
const context = ReactModule.useContext(PopoverContext)
if (!context?.open)
return null
return <div data-testid="popover-content">{children}</div>
},
}
})
describe('JsonImporter', () => {
const mockOnSubmit = vi.fn()
const mockUpdateBtnWidth = vi.fn()
const throwUnknown = (error: unknown): never => {
throw error
}
beforeEach(() => {
vi.clearAllMocks()
visualEditorState.advancedEditing = false
visualEditorState.isAddingNewField = false
mockCheckJsonDepth.mockReturnValue(1)
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
width: 88,
height: 32,
top: 0,
right: 0,
bottom: 0,
left: 0,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('measures the trigger width and opens the importer without quitting editing by default', async () => {
const user = userEvent.setup()
render(
<JsonImporter
onSubmit={mockOnSubmit}
updateBtnWidth={mockUpdateBtnWidth}
/>,
)
expect(mockUpdateBtnWidth).toHaveBeenCalledWith(88)
await user.click(screen.getByRole('button', { name: 'nodes.llm.jsonSchema.import' }))
expect(screen.getByTestId('popover-content')).toBeInTheDocument()
expect(mockEmit).not.toHaveBeenCalled()
})
it('emits quitEditing when opening while advanced editing is active', async () => {
visualEditorState.advancedEditing = true
const user = userEvent.setup()
render(
<JsonImporter
onSubmit={mockOnSubmit}
updateBtnWidth={mockUpdateBtnWidth}
/>,
)
await user.click(screen.getByRole('button', { name: 'nodes.llm.jsonSchema.import' }))
expect(mockEmit).toHaveBeenCalledWith('quitEditing', {})
})
it('shows a parse error when the root value is not an object', async () => {
const user = userEvent.setup()
render(
<JsonImporter
onSubmit={mockOnSubmit}
updateBtnWidth={mockUpdateBtnWidth}
/>,
)
await user.click(screen.getByRole('button', { name: 'nodes.llm.jsonSchema.import' }))
fireEvent.change(screen.getByLabelText('json-editor'), { target: { value: '[]' } })
await user.click(screen.getByRole('button', { name: 'operation.submit' }))
expect(screen.getByTestId('error-message')).toHaveTextContent('Root must be an object, not an array or primitive value.')
expect(mockOnSubmit).not.toHaveBeenCalled()
})
it('shows a depth error when the schema exceeds the configured maximum', async () => {
mockCheckJsonDepth.mockReturnValue(JSON_SCHEMA_MAX_DEPTH + 1)
const user = userEvent.setup()
render(
<JsonImporter
onSubmit={mockOnSubmit}
updateBtnWidth={mockUpdateBtnWidth}
/>,
)
await user.click(screen.getByRole('button', { name: 'nodes.llm.jsonSchema.import' }))
fireEvent.change(screen.getByLabelText('json-editor'), { target: { value: '{"foo":{"bar":1}}' } })
await user.click(screen.getByRole('button', { name: 'operation.submit' }))
expect(screen.getByTestId('error-message')).toHaveTextContent(`Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`)
expect(mockOnSubmit).not.toHaveBeenCalled()
})
it('shows the parser error when JSON.parse throws an Error', async () => {
const user = userEvent.setup()
const parseSpy = vi.spyOn(JSON, 'parse').mockImplementationOnce(() => {
throw new Error('Malformed JSON payload')
})
render(
<JsonImporter
onSubmit={mockOnSubmit}
updateBtnWidth={mockUpdateBtnWidth}
/>,
)
await user.click(screen.getByRole('button', { name: 'nodes.llm.jsonSchema.import' }))
fireEvent.change(screen.getByLabelText('json-editor'), { target: { value: '{"foo":1}' } })
await user.click(screen.getByRole('button', { name: 'operation.submit' }))
expect(screen.getByTestId('error-message')).toHaveTextContent('Malformed JSON payload')
expect(mockOnSubmit).not.toHaveBeenCalled()
parseSpy.mockRestore()
})
it('falls back to the default invalid JSON message when JSON.parse throws a non-Error value', async () => {
const user = userEvent.setup()
const parseSpy = vi.spyOn(JSON, 'parse').mockImplementationOnce(() => throwUnknown(Object.create(null)))
render(
<JsonImporter
onSubmit={mockOnSubmit}
updateBtnWidth={mockUpdateBtnWidth}
/>,
)
await user.click(screen.getByRole('button', { name: 'nodes.llm.jsonSchema.import' }))
fireEvent.change(screen.getByLabelText('json-editor'), { target: { value: '{"foo":1}' } })
await user.click(screen.getByRole('button', { name: 'operation.submit' }))
expect(screen.getByTestId('error-message')).toHaveTextContent('Invalid JSON')
expect(mockOnSubmit).not.toHaveBeenCalled()
parseSpy.mockRestore()
})
it('submits valid JSON and closes the popover from footer actions', async () => {
const user = userEvent.setup()
render(
<JsonImporter
onSubmit={mockOnSubmit}
updateBtnWidth={mockUpdateBtnWidth}
/>,
)
await user.click(screen.getByRole('button', { name: 'nodes.llm.jsonSchema.import' }))
fireEvent.change(screen.getByLabelText('json-editor'), { target: { value: '{"foo":"bar"}' } })
await user.click(screen.getByRole('button', { name: 'operation.submit' }))
expect(mockOnSubmit).toHaveBeenCalledWith({ foo: 'bar' })
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'nodes.llm.jsonSchema.import' }))
await user.click(screen.getByRole('button', { name: 'operation.cancel' }))
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
})
})

View File

@ -4,8 +4,8 @@ import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useEffect, useRef, 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 { JSON_SCHEMA_MAX_DEPTH } from '@/config'
import { checkJsonDepth } from '../../utils'
import CodeEditor from './code-editor'
@ -26,7 +26,7 @@ const JsonImporter: FC<JsonImporterProps> = ({
const [open, setOpen] = useState(false)
const [json, setJson] = useState('')
const [parseError, setParseError] = useState<any>(null)
const importBtnRef = useRef<HTMLElement>(null)
const importBtnRef = useRef<HTMLButtonElement>(null)
const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
const { emit } = useMittContext()
@ -36,14 +36,13 @@ const JsonImporter: FC<JsonImporterProps> = ({
const rect = importBtnRef.current.getBoundingClientRect()
updateBtnWidth(rect.width)
}
}, [])
}, [updateBtnWidth])
const handleTrigger = useCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => {
e.stopPropagation()
if (advancedEditing || isAddingNewField)
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (nextOpen && (advancedEditing || isAddingNewField))
emit('quitEditing', {})
setOpen(!open)
}, [open, advancedEditing, isAddingNewField, emit])
setOpen(nextOpen)
}, [advancedEditing, emit, isAddingNewField])
const onClose = useCallback(() => {
setOpen(false)
@ -77,27 +76,26 @@ const JsonImporter: FC<JsonImporterProps> = ({
}, [onSubmit, json])
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 4,
crossAxis: 16,
}}
onOpenChange={handleOpenChange}
>
<PortalToFollowElemTrigger ref={importBtnRef} onClick={handleTrigger}>
<button
type="button"
className={cn(
'flex shrink-0 rounded-md px-1.5 py-1 system-xs-medium text-text-tertiary hover:bg-components-button-ghost-bg-hover',
open && 'bg-components-button-ghost-bg-hover',
)}
>
<span className="px-0.5">{t('nodes.llm.jsonSchema.import', { ns: 'workflow' })}</span>
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-100">
<PopoverTrigger
ref={importBtnRef}
onClick={e => e.stopPropagation()}
className={cn(
'flex shrink-0 rounded-md px-1.5 py-1 system-xs-medium text-text-tertiary hover:bg-components-button-ghost-bg-hover',
open && 'bg-components-button-ghost-bg-hover',
)}
>
<span className="px-0.5">{t('nodes.llm.jsonSchema.import', { ns: 'workflow' })}</span>
</PopoverTrigger>
<PopoverContent
placement="bottom-end"
sideOffset={4}
alignOffset={16}
popupClassName="border-none bg-transparent shadow-none"
>
<div className="flex w-[400px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9">
{/* Title */}
<div className="relative px-3 pt-3.5 pb-1">
@ -129,8 +127,8 @@ const JsonImporter: FC<JsonImporterProps> = ({
</Button>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -11,11 +11,7 @@ import {
useState,
} from 'react'
import { Plus02 } from '@/app/components/base/icons/src/vender/line/general'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover'
import AddVariablePopup from '@/app/components/workflow/nodes/_base/components/add-variable-popup'
import { useVariableAssigner } from '../../hooks'
@ -50,39 +46,43 @@ const AddVariable = ({
variableAssignerNodeData.selected && 'flex!',
)}
>
<PortalToFollowElem
placement="right"
offset={4}
<Popover
open={open}
onOpenChange={setOpen}
>
<PortalToFollowElemTrigger
onClick={() => setOpen(!open)}
<PopoverTrigger
render={(
<button type="button" className="block border-none bg-transparent p-0">
<div
className={cn(
'group/addvariable flex items-center justify-center',
'h-4 w-4 cursor-pointer',
'hover:rounded-full hover:bg-primary-600',
open && 'rounded-full! bg-primary-600!',
)}
>
<Plus02
className={cn(
'h-2.5 w-2.5 text-text-tertiary',
'group-hover/addvariable:text-text-primary',
open && 'text-text-primary!',
)}
/>
</div>
</button>
)}
/>
<PopoverContent
placement="right"
sideOffset={4}
popupClassName="border-none bg-transparent shadow-none"
>
<div
className={cn(
'group/addvariable flex items-center justify-center',
'h-4 w-4 cursor-pointer',
'hover:rounded-full hover:bg-primary-600',
open && 'rounded-full! bg-primary-600!',
)}
>
<Plus02
className={cn(
'h-2.5 w-2.5 text-text-tertiary',
'group-hover/addvariable:text-text-primary',
open && 'text-text-primary!',
)}
/>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
<AddVariablePopup
onSelect={handleSelectVariable}
availableVars={availableVars}
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
</div>
)
}

View File

@ -3,12 +3,8 @@ import type { ConversationVariable } from '@/app/components/workflow/types'
import { RiAddLine } 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 { Button } from '@/app/components/base/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover'
import VariableModal from '@/app/components/workflow/panel/chat-variable-panel/components/variable-modal'
type Props = {
@ -29,33 +25,31 @@ const VariableModalTrigger = ({
onSave,
}: Props) => {
const { t } = useTranslation()
const handleOpenChange = React.useCallback((nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen)
onClose()
}, [onClose, setOpen])
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={() => {
setOpen(v => !v)
if (open)
onClose()
}}
placement="left-start"
offset={{
mainAxis: 8,
alignmentAxis: showTip ? -278 : -48,
}}
onOpenChange={handleOpenChange}
>
<PortalToFollowElemTrigger onClick={() => {
setOpen(v => !v)
if (open)
onClose()
}}
<PopoverTrigger
render={(
<Button variant="primary">
<RiAddLine className="mr-1 h-4 w-4" />
<span className="system-sm-medium">{t('chatVariable.button', { ns: 'workflow' })}</span>
</Button>
)}
/>
<PopoverContent
placement="left-start"
sideOffset={8}
alignOffset={showTip ? -278 : -48}
popupClassName="border-none bg-transparent shadow-none"
>
<Button variant="primary">
<RiAddLine className="mr-1 h-4 w-4" />
<span className="system-sm-medium">{t('chatVariable.button', { ns: 'workflow' })}</span>
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-11">
<VariableModal
chatVar={chatVar}
onSave={onSave}
@ -64,8 +58,8 @@ const VariableModalTrigger = ({
setOpen(false)
}}
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -3,12 +3,8 @@ import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { RiAddLine } 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 { Button } from '@/app/components/base/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover'
import VariableModal from '@/app/components/workflow/panel/env-panel/variable-modal'
type Props = {
@ -27,33 +23,31 @@ const VariableTrigger = ({
onSave,
}: Props) => {
const { t } = useTranslation()
const handleOpenChange = React.useCallback((nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen)
onClose()
}, [onClose, setOpen])
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={() => {
setOpen(v => !v)
if (open)
onClose()
}}
placement="left-start"
offset={{
mainAxis: 8,
alignmentAxis: -104,
}}
onOpenChange={handleOpenChange}
>
<PortalToFollowElemTrigger onClick={() => {
setOpen(v => !v)
if (open)
onClose()
}}
<PopoverTrigger
render={(
<Button variant="primary">
<RiAddLine className="mr-1 h-4 w-4" />
<span className="system-sm-medium">{t('env.envPanelButton', { ns: 'workflow' })}</span>
</Button>
)}
/>
<PopoverContent
placement="left-start"
sideOffset={8}
alignOffset={-104}
popupClassName="border-none bg-transparent shadow-none"
>
<Button variant="primary">
<RiAddLine className="mr-1 h-4 w-4" />
<span className="system-sm-medium">{t('env.envPanelButton', { ns: 'workflow' })}</span>
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-11">
<VariableModal
env={env}
onSave={onSave}
@ -62,8 +56,8 @@ const VariableTrigger = ({
setOpen(false)
}}
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -9,7 +9,6 @@ This document tracks the migration away from legacy overlay APIs.
- `@/app/components/base/tooltip`
- `@/app/components/base/modal`
- `@/app/components/base/select` (including `custom` / `pure`)
- `@/app/components/base/popover`
- `@/app/components/base/dropdown`
- `@/app/components/base/dialog`
- `@/app/components/base/toast` (including `context`)

View File

@ -311,15 +311,6 @@
}
},
"app/components/app/annotation/header-opts/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/component-hook-factories": {
"count": 1
},
"react/no-nested-component-definitions": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@ -348,11 +339,6 @@
"count": 1
}
},
"app/components/app/app-access-control/add-member-or-group-pop.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/app/app-access-control/specific-groups-or-members.tsx": {
"no-restricted-imports": {
"count": 1
@ -367,9 +353,6 @@
}
},
"app/components/app/app-publisher/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 5
}
@ -464,11 +447,6 @@
"count": 1
}
},
"app/components/app/configuration/config-vision/param-config.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/app/configuration/config/agent/agent-setting/index.tsx": {
"react/set-state-in-effect": {
"count": 1
@ -764,9 +742,6 @@
}
},
"app/components/app/log/model-info.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
@ -854,12 +829,6 @@
},
"app/components/apps/app-card.tsx": {
"no-restricted-imports": {
"count": 3
},
"react/component-hook-factories": {
"count": 1
},
"react/no-nested-component-definitions": {
"count": 1
},
"react/set-state-in-effect": {
@ -1780,7 +1749,7 @@
},
"app/components/base/icons/src/vender/line/files/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 7
"count": 6
}
},
"app/components/base/icons/src/vender/line/financeAndECommerce/index.ts": {
@ -2210,11 +2179,6 @@
"count": 1
}
},
"app/components/base/popover/index.stories.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/base/portal-to-follow-elem/index.stories.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
@ -2579,18 +2543,10 @@
}
},
"app/components/base/tag-management/panel.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 5
}
},
"app/components/base/tag-management/selector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/base/tag-management/tag-item-editor.tsx": {
"no-restricted-imports": {
"count": 1
@ -2875,11 +2831,6 @@
"count": 1
}
},
"app/components/datasets/create-from-pipeline/list/template-card/actions.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/datasets/create-from-pipeline/list/template-card/content.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 3
@ -2900,11 +2851,6 @@
"count": 1
}
},
"app/components/datasets/create-from-pipeline/list/template-card/operations.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 3
}
},
"app/components/datasets/create/embedding-process/indexing-progress-item.tsx": {
"no-restricted-imports": {
"count": 1
@ -2984,11 +2930,6 @@
"count": 1
}
},
"app/components/datasets/create/step-two/language-select/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/datasets/create/step-two/preview-item/index.tsx": {
"erasable-syntax-only/enums": {
"count": 1
@ -3104,7 +3045,7 @@
},
"app/components/datasets/documents/components/operations.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"app/components/datasets/documents/components/rename-modal.tsx": {
@ -3409,9 +3350,6 @@
"erasable-syntax-only/enums": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-refresh/only-export-components": {
"count": 1
}
@ -3460,16 +3398,6 @@
"count": 4
}
},
"app/components/datasets/extra-info/api-access/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/datasets/extra-info/service-api/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/datasets/extra-info/statistics.tsx": {
"no-restricted-imports": {
"count": 1
@ -3577,9 +3505,6 @@
}
},
"app/components/datasets/metadata/metadata-dataset/create-metadata-modal.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@ -3597,9 +3522,6 @@
"app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx": {
"erasable-syntax-only/enums": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"app/components/datasets/metadata/metadata-dataset/select-metadata.tsx": {
@ -6730,9 +6652,6 @@
}
},
"app/components/workflow/nodes/llm/components/json-schema-config-modal/json-importer.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
@ -7258,11 +7177,6 @@
"count": 1
}
},
"app/components/workflow/nodes/variable-assigner/components/add-variable/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/workflow/nodes/variable-assigner/components/var-group-item.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 3
@ -7400,11 +7314,6 @@
"count": 2
}
},
"app/components/workflow/panel/chat-variable-panel/components/variable-modal-trigger.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/workflow/panel/chat-variable-panel/components/variable-type-select.tsx": {
"no-restricted-imports": {
"count": 1
@ -7462,11 +7371,6 @@
"count": 1
}
},
"app/components/workflow/panel/env-panel/variable-trigger.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/workflow/panel/human-input-form-list.tsx": {
"ts/no-explicit-any": {
"count": 1

View File

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