mirror of
https://github.com/langgenius/dify.git
synced 2026-04-16 18:39:18 +08:00
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:
parent
b665eaa015
commit
c661d5c43a
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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> </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)
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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)}>
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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" />,
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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`)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user