feat: Enhance dataset dropdown functionality with export and delete options

This commit is contained in:
twwu 2025-07-09 13:42:24 +08:00
parent 8fc15c83d0
commit 59c3305dcc
8 changed files with 189 additions and 26 deletions

View File

@ -4,6 +4,17 @@ import ActionButton from '../../base/action-button'
import { RiMoreFill } from '@remixicon/react'
import cn from '@/utils/classnames'
import Menu from './menu'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import type { DataSet } from '@/models/datasets'
import { datasetDetailQueryKeyPrefix, useResetDatasetList } from '@/service/knowledge/use-dataset'
import { useInvalid } from '@/service/use-base'
import { useExportPipelineDSL } from '@/service/use-pipeline'
import Toast from '../../base/toast'
import { useTranslation } from 'react-i18next'
import RenameDatasetModal from '../../datasets/rename-modal'
import { checkIsUsedInApp, deleteDataset } from '@/service/datasets'
import Confirm from '../../base/confirm'
type DropDownProps = {
expand: boolean
@ -12,12 +23,81 @@ type DropDownProps = {
const DropDown = ({
expand,
}: DropDownProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [showRenameModal, setShowRenameModal] = useState(false)
const [confirmMessage, setConfirmMessage] = useState<string>('')
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const isCurrentWorkspaceDatasetOperator = useAppContextWithSelector(state => state.isCurrentWorkspaceDatasetOperator)
const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet
const handleTrigger = useCallback(() => {
setOpen(prev => !prev)
}, [])
const resetDatasetList = useResetDatasetList()
const invalidDatasetDetail = useInvalid([...datasetDetailQueryKeyPrefix, dataset.id])
const refreshDataset = useCallback(() => {
resetDatasetList()
invalidDatasetDetail()
}, [invalidDatasetDetail, resetDatasetList])
const openRenameModal = useCallback(() => {
setShowRenameModal(true)
handleTrigger()
}, [handleTrigger])
const { mutateAsync: exportPipelineConfig } = useExportPipelineDSL()
const handleExportPipeline = useCallback(async (include = false) => {
const { pipeline_id, name } = dataset
if (!pipeline_id)
return
handleTrigger()
try {
const { data } = await exportPipelineConfig({
pipelineId: pipeline_id,
include,
})
const a = document.createElement('a')
const file = new Blob([data], { type: 'application/yaml' })
a.href = URL.createObjectURL(file)
a.download = `${name}.yml`
a.click()
}
catch {
Toast.notify({ type: 'error', message: t('app.exportFailed') })
}
}, [dataset, exportPipelineConfig, handleTrigger, t])
const detectIsUsedByApp = useCallback(async () => {
try {
const { is_using: isUsedByApp } = await checkIsUsedInApp(dataset.id)
setConfirmMessage(isUsedByApp ? t('dataset.datasetUsedByApp')! : t('dataset.deleteDatasetConfirmContent')!)
setShowConfirmDelete(true)
}
catch (e: any) {
const res = await e.json()
Toast.notify({ type: 'error', message: res?.message || 'Unknown error' })
}
finally {
handleTrigger()
}
}, [dataset.id, handleTrigger, t])
const onConfirmDelete = useCallback(async () => {
try {
await deleteDataset(dataset.id)
Toast.notify({ type: 'success', message: t('dataset.datasetDeleted') })
refreshDataset()
}
finally {
setShowConfirmDelete(false)
}
}, [dataset.id, refreshDataset, t])
return (
<PortalToFollowElem
open={open}
@ -36,8 +116,30 @@ const DropDown = ({
</ActionButton>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent>
<Menu />
<Menu
showDelete={!isCurrentWorkspaceDatasetOperator}
openRenameModal={openRenameModal}
handleExportPipeline={handleExportPipeline}
detectIsUsedByApp={detectIsUsedByApp}
/>
</PortalToFollowElemContent>
{showRenameModal && (
<RenameDatasetModal
show={showRenameModal}
dataset={dataset!}
onClose={() => setShowRenameModal(false)}
onSuccess={refreshDataset}
/>
)}
{showConfirmDelete && (
<Confirm
title={t('dataset.deleteDatasetConfirmTitle')}
content={confirmMessage}
isShow={showConfirmDelete}
onConfirm={onConfirmDelete}
onCancel={() => setShowConfirmDelete(false)}
/>
)}
</PortalToFollowElem>
)
}

View File

@ -4,7 +4,7 @@ import type { RemixiconComponentType } from '@remixicon/react'
type MenuItemProps = {
name: string
Icon: RemixiconComponentType
handleClick?: () => void
handleClick?: (e: React.MouseEvent<HTMLDivElement>) => void
}
const MenuItem = ({
@ -15,9 +15,7 @@ const MenuItem = ({
return (
<div
className='flex items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover'
onClick={() => {
handleClick?.()
}}
onClick={handleClick}
>
<Icon className='size-4 text-text-tertiary' />
<span className='system-md-regular px-1 text-text-secondary'>{name}</span>

View File

@ -1,19 +1,68 @@
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import React from 'react'
import { useTranslation } from 'react-i18next'
import MenuItem from './menu-item'
import { RiEditLine } from '@remixicon/react'
import { noop } from 'lodash-es'
import { RiDeleteBinLine, RiEditLine, RiFileDownloadLine } from '@remixicon/react'
import Divider from '../../base/divider'
const Menu = () => {
type MenuProps = {
showDelete: boolean
openRenameModal: () => void
handleExportPipeline: () => void
detectIsUsedByApp: () => void
}
const Menu = ({
showDelete,
openRenameModal,
handleExportPipeline,
detectIsUsedByApp,
}: MenuProps) => {
const { t } = useTranslation()
const dataset = useDatasetDetailContextWithSelector(state => state.dataset)
const onClickRename = async (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.preventDefault()
openRenameModal()
}
const onClickExport = async (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.preventDefault()
handleExportPipeline()
}
const onClickDelete = async (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.preventDefault()
detectIsUsedByApp()
}
return (
<div className='flex w-[200px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]'>
<div className='flex flex-col p-1'>
<MenuItem Icon={RiEditLine} name={t('common.operation.edit')} handleClick={noop} />
<MenuItem
Icon={RiEditLine}
name={t('common.operation.edit')}
handleClick={onClickRename}
/>
<MenuItem
Icon={RiFileDownloadLine}
name={t('datasetPipeline.operations.exportPipeline')}
handleClick={onClickExport}
/>
</div>
{showDelete && (
<>
<Divider type='horizontal' className='my-0 bg-divider-subtle' />
<div className='flex flex-col p-1'>
<MenuItem
Icon={RiDeleteBinLine}
name={t('common.operation.delete')}
handleClick={onClickDelete}
/>
</div>
</>
)}
</div>
)
}

View File

@ -83,6 +83,10 @@ const DatasetCard = ({
return dayjs(time * 1_000).locale(language === 'zh_Hans' ? 'zh-cn' : language.replace('_', '-')).fromNow()
}, [language])
const openRenameModal = useCallback(() => {
setShowRenameModal(true)
}, [])
const { mutateAsync: exportPipelineConfig } = useExportPipelineDSL()
const handleExportPipeline = useCallback(async (include = false) => {
@ -117,13 +121,12 @@ const DatasetCard = ({
try {
const { is_using: isUsedByApp } = await checkIsUsedInApp(dataset.id)
setConfirmMessage(isUsedByApp ? t('dataset.datasetUsedByApp')! : t('dataset.deleteDatasetConfirmContent')!)
setShowConfirmDelete(true)
}
catch (e: any) {
const res = await e.json()
Toast.notify({ type: 'error', message: res?.message || 'Unknown error' })
}
setShowConfirmDelete(true)
}, [dataset.id, t])
const onConfirmDelete = useCallback(async () => {
@ -133,9 +136,9 @@ const DatasetCard = ({
if (onSuccess)
onSuccess()
}
catch {
finally {
setShowConfirmDelete(false)
}
setShowConfirmDelete(false)
}, [dataset.id, onSuccess, t])
useEffect(() => {
@ -262,11 +265,9 @@ const DatasetCard = ({
htmlContent={
<Operations
showDelete={!isCurrentWorkspaceDatasetOperator}
openRenameModal={() => {
setShowRenameModal(true)
}}
detectIsUsedByApp={detectIsUsedByApp}
openRenameModal={openRenameModal}
handleExportPipeline={handleExportPipeline}
detectIsUsedByApp={detectIsUsedByApp}
/>
}
className={'z-20 min-w-[186px]'}

View File

@ -45,14 +45,18 @@ const DatasetNav = () => {
id: currentDataset.id,
name: currentDataset.name,
icon: currentDataset.icon_info.icon,
icon_type: currentDataset.icon_info.icon_type,
icon_background: currentDataset.icon_info.icon_background,
icon_url: currentDataset.icon_info.icon_url,
} as Omit<NavItem, 'link'>}
navigationItems={datasetItems.map(dataset => ({
id: dataset.id,
name: dataset.name,
link: dataset.provider === 'external' ? `/datasets/${dataset.id}/hitTesting` : `/datasets/${dataset.id}/documents`,
icon: dataset.icon_info.icon,
icon_type: dataset.icon_info.icon_type,
icon_background: dataset.icon_info.icon_background,
icon_url: dataset.icon_info.icon_url,
})) as NavItem[]}
createText={t('common.menus.newDataset')}
onCreate={() => router.push(`${basePath}/datasets/create`)}

View File

@ -42,6 +42,7 @@ const Nav = ({
useEffect(() => {
if (pathname === link)
setLinkLastSearchParams(searchParams.toString())
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname, searchParams])
return (
@ -53,11 +54,11 @@ const Nav = ({
<Link href={link + (linkLastSearchParams && `?${linkLastSearchParams}`)}>
<div
onClick={() => setAppDetail()}
className={classNames(`
flex items-center h-7 px-2.5 cursor-pointer rounded-[10px]
${isActivated ? 'text-components-main-nav-nav-button-text-active' : 'text-components-main-nav-nav-button-text'}
${curNav && isActivated && 'hover:bg-components-main-nav-nav-button-bg-active-hover'}
`)}
className={classNames(
'flex h-7 cursor-pointer items-center rounded-[10px] px-2.5',
isActivated ? 'text-components-main-nav-nav-button-text-active' : 'text-components-main-nav-nav-button-text',
curNav && isActivated && 'hover:bg-components-main-nav-nav-button-bg-active-hover',
)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>

View File

@ -84,7 +84,13 @@ const NavSelector = ({ curNav, navigationItems, createText, isApp, onCreate, onL
router.push(nav.link)
}} title={nav.name}>
<div className='relative mr-2 h-6 w-6 rounded-md'>
<AppIcon size='tiny' iconType={nav.icon_type} icon={nav.icon} background={nav.icon_background} imageUrl={nav.icon_url} />
<AppIcon
size='tiny'
iconType={nav.icon_type}
icon={nav.icon}
background={nav.icon_background}
imageUrl={nav.icon_url}
/>
{!!nav.mode && (
<span className={cn(
'absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded border-[0.5px] border-[rgba(0,0,0,0.02)] bg-white p-0.5 shadow-sm',

View File

@ -40,9 +40,11 @@ export const useResetDatasetList = () => {
return useReset([...DatasetListKey])
}
export const datasetDetailQueryKeyPrefix = [NAME_SPACE, 'detail']
export const useDatasetDetail = (datasetId: string) => {
return useQuery({
queryKey: [NAME_SPACE, 'detail', datasetId],
queryKey: [...datasetDetailQueryKeyPrefix, datasetId],
queryFn: () => get<DataSet>(`/datasets/${datasetId}`),
enabled: !!datasetId,
})