refactor(web): migrate searchable pickers to combobox (#36066)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh 2026-05-12 13:34:19 +08:00 committed by GitHub
parent 4bb987eca3
commit cd90d7ffc1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 735 additions and 1239 deletions

View File

@ -246,11 +246,6 @@
"count": 1
}
},
"web/app/components/app/app-access-control/add-member-or-group-pop.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/app-publisher/features-wrapper.tsx": {
"ts/no-explicit-any": {
"count": 4

View File

@ -9,6 +9,7 @@ Shared design tokens, the `cn()` utility, CSS-first Tailwind styles, and headles
- No imports from `web/`. No dependencies on next / i18next / ky / jotai / zustand.
- One component per folder: `src/<name>/index.tsx`, optional `index.stories.tsx` and `__tests__/index.spec.tsx`. Add a matching `./<name>` subpath to `package.json#exports`.
- Props pattern: `Omit<BaseXxx.Root.Props, 'className' | ...> & VariantProps<typeof xxxVariants> & { /* custom */ }`.
- Use plain `Omit<...>` only for non-union Base UI props. When a prop changes the valid shape of related props (for example `value` / `defaultValue`, `multiple` / `value`, or `clearable` / `onChange`), model that relationship with an explicit discriminated union or a distributive helper instead of flattening the props.
- When a component accepts a prop typed from a shared internal module, `export type` it from that component so consumers import it from the component subpath.
## Overlay Primitive Selection: Tooltip vs PreviewCard vs Popover

View File

@ -254,9 +254,7 @@ describe('AddMemberOrGroupDialog', () => {
await user.click(expandButton)
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup])
const memberLabel = screen.getByText(baseMember.name)
const memberCheckbox = memberLabel.parentElement?.previousElementSibling as HTMLElement
fireEvent.click(memberCheckbox)
await user.click(screen.getByRole('option', { name: /Member One/ }))
expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember])
})
@ -277,13 +275,13 @@ describe('AddMemberOrGroupDialog', () => {
await user.type(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder'), 'Group')
expect(document.querySelector('.spin-animation')).toBeInTheDocument()
const groupCheckbox = screen.getByText(baseGroup.name).closest('div')?.previousElementSibling as HTMLElement
fireEvent.click(groupCheckbox)
fireEvent.click(groupCheckbox)
const groupOption = screen.getByRole('option', { name: /Group One/ })
fireEvent.click(groupOption)
fireEvent.click(groupOption)
const memberCheckbox = screen.getByText(baseMember.name).parentElement?.previousElementSibling as HTMLElement
fireEvent.click(memberCheckbox)
fireEvent.click(memberCheckbox)
const memberOption = screen.getByRole('option', { name: /Member One/ })
fireEvent.click(memberOption)
fireEvent.click(memberOption)
fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand'))
fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.allMembers'))
@ -307,7 +305,7 @@ describe('AddMemberOrGroupDialog', () => {
await user.click(screen.getByText('common.operation.add'))
expect(screen.getByText('app.accessControlDialog.operateGroupAndMember.noResult')).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('app.accessControlDialog.operateGroupAndMember.noResult')
})
})

View File

@ -1,5 +1,5 @@
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
import { fireEvent, render, screen } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import useAccessControlStore from '@/context/access-control-store'
import { SubjectType } from '@/models/access-control'
@ -106,8 +106,7 @@ describe('AddMemberOrGroupDialog', () => {
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup])
const memberCheckbox = screen.getByText(baseMember.name).parentElement?.previousElementSibling as HTMLElement
fireEvent.click(memberCheckbox)
await user.click(screen.getByRole('option', { name: /Member One/ }))
expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember])
})
@ -125,6 +124,31 @@ describe('AddMemberOrGroupDialog', () => {
await user.click(screen.getByText('common.operation.add'))
expect(screen.getByText('app.accessControlDialog.operateGroupAndMember.noResult')).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('app.accessControlDialog.operateGroupAndMember.noResult')
})
it('should keep breadcrumbs visible when the current group has no candidates', async () => {
useAccessControlStore.setState({
selectedGroupsForBreadcrumb: [baseGroup],
})
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
data: { pages: [{ currPage: 1, subjects: [], hasMore: false }] },
})
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
expect(screen.getByRole('button', { name: 'app.accessControlDialog.operateGroupAndMember.allMembers' })).toBeInTheDocument()
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('app.accessControlDialog.operateGroupAndMember.noResult')
await user.click(screen.getByRole('button', { name: 'app.accessControlDialog.operateGroupAndMember.allMembers' }))
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([])
})
})

View File

@ -1,110 +1,207 @@
'use client'
import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox'
import type { AccessControlAccount, AccessControlGroup, Subject, SubjectAccount, SubjectGroup } from '@/models/access-control'
import { FloatingOverlay } from '@floating-ui/react'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxInputGroup,
ComboboxItem,
ComboboxItemText,
ComboboxList,
ComboboxStatus,
ComboboxTrigger,
} from '@langgenius/dify-ui/combobox'
import { RiAddCircleFill, RiArrowRightSLine, RiOrganizationChart } from '@remixicon/react'
import { useDebounce } from 'ahooks'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from '@/context/app-context'
import { SubjectType } from '@/models/access-control'
import { useSearchForWhiteListCandidates } from '@/service/access-control'
import useAccessControlStore from '../../../../context/access-control-store'
import Checkbox from '../../base/checkbox'
import Input from '../../base/input'
import Loading from '../../base/loading'
export default function AddMemberOrGroupDialog() {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [keyword, setKeyword] = useState('')
const scrollRootRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const specificGroups = useAccessControlStore(s => s.specificGroups)
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
const specificMembers = useAccessControlStore(s => s.specificMembers)
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
const debouncedKeyword = useDebounce(keyword, { wait: 500 })
const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1]
const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates({ keyword: debouncedKeyword, groupId: lastAvailableGroup?.id, resultsPerPage: 10 }, open)
const handleKeywordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setKeyword(e.target.value)
}
const pages = data?.pages ?? []
const subjects = pages.flatMap(page => page.subjects ?? [])
const selectedSubjects = [
...specificGroups.map(groupToSubject),
...specificMembers.map(memberToSubject),
]
const hasResults = pages.length > 0 && subjects.length > 0
const shouldShowBreadcrumb = hasResults || selectedGroupsForBreadcrumb.length > 0
const hasMore = pages[pages.length - 1]?.hasMore ?? false
const anchorRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const hasMore = data?.pages?.[0]?.hasMore ?? false
let observer: IntersectionObserver | undefined
if (anchorRef.current) {
observer = new IntersectionObserver((entries) => {
if (entries[0]!.isIntersecting && !isLoading && hasMore)
fetchNextPage()
}, { rootMargin: '20px' })
}, { root: scrollRootRef.current, rootMargin: '20px' })
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [isLoading, fetchNextPage, anchorRef, data])
}, [isLoading, fetchNextPage, hasMore])
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen)
setKeyword('')
setOpen(nextOpen)
}
const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => {
if (details.reason !== 'item-press')
setKeyword(inputValue)
}
const handleValueChange = (nextSubjects: Subject[]) => {
const nextGroups: AccessControlGroup[] = []
const nextMembers: AccessControlAccount[] = []
for (const subject of nextSubjects) {
if (subject.subjectType === SubjectType.GROUP)
nextGroups.push((subject as SubjectGroup).groupData)
else
nextMembers.push((subject as SubjectAccount).accountData)
}
setSpecificGroups(nextGroups)
setSpecificMembers(nextMembers)
}
return (
<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 />}
<PopoverContent
<Combobox<Subject, true>
multiple
open={open}
value={selectedSubjects}
inputValue={keyword}
items={subjects}
itemToStringLabel={getSubjectLabel}
itemToStringValue={getSubjectValue}
isItemEqualToValue={isSameSubject}
filter={null}
onOpenChange={handleOpenChange}
onInputValueChange={handleInputValueChange}
onValueChange={handleValueChange}
>
<ComboboxTrigger
aria-label={t('operation.add', { ns: 'common' })}
icon={false}
size="small"
className="flex h-6 w-auto shrink-0 items-center gap-x-0.5 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-open:bg-state-accent-hover"
>
<RiAddCircleFill className="h-4 w-4" aria-hidden="true" />
<span>{t('operation.add', { ns: 'common' })}</span>
</ComboboxTrigger>
<ComboboxContent
placement="bottom-end"
alignOffset={300}
popupClassName="border-none bg-transparent shadow-none"
popupClassName="relative flex max-h-[400px] w-[400px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]"
>
<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 ref={scrollRootRef} className="min-h-0 overflow-y-auto">
<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} />
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
<ComboboxInput
aria-label={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })}
placeholder={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })}
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
/>
</ComboboxInputGroup>
</div>
{
isLoading
? <div className="p-1"><Loading /></div>
: (data?.pages?.length ?? 0) > 0
? (
<>
<div className="flex h-7 items-center px-2 py-0.5">
<SelectedGroupsBreadCrumb />
</div>
<div className="p-1">
{renderGroupOrMember(data?.pages ?? [])}
{isLoading
? (
<ComboboxStatus className="p-1">
<Loading />
</ComboboxStatus>
)
: (
<>
{shouldShowBreadcrumb && (
<div className="flex h-7 items-center px-2 py-0.5">
<SelectedGroupsBreadCrumb />
</div>
)}
{hasResults
? (
<>
<ComboboxList className="max-h-none p-1">
{(subject: Subject) => <SubjectItem key={getSubjectValue(subject)} subject={subject} />}
</ComboboxList>
{isFetchingNextPage && <Loading />}
</div>
<div ref={anchorRef} className="h-0"> </div>
</>
)
: (
<div className="flex h-7 items-center justify-center px-2 py-0.5">
<span className="system-xs-regular text-text-tertiary">{t('accessControlDialog.operateGroupAndMember.noResult', { ns: 'app' })}</span>
</div>
)
}
<div ref={anchorRef} className="h-0" />
</>
)
: (
<ComboboxEmpty className="flex h-7 items-center justify-center px-2 py-0.5">
{t('accessControlDialog.operateGroupAndMember.noResult', { ns: 'app' })}
</ComboboxEmpty>
)}
</>
)}
</div>
</PopoverContent>
</Popover>
</ComboboxContent>
</Combobox>
)
}
type GroupOrMemberData = { subjects: Subject[], currPage: number }[]
function renderGroupOrMember(data: GroupOrMemberData) {
return data?.map((page) => {
return (
<div key={`search_group_member_page_${page.currPage}`}>
{page.subjects?.map((item, index) => {
if (item.subjectType === SubjectType.GROUP)
return <GroupItem key={index} group={(item as SubjectGroup).groupData} />
return <MemberItem key={index} member={(item as SubjectAccount).accountData} />
})}
</div>
)
}) ?? null
function groupToSubject(group: AccessControlGroup): SubjectGroup {
return {
subjectId: group.id,
subjectType: SubjectType.GROUP,
groupData: group,
}
}
function memberToSubject(member: AccessControlAccount): SubjectAccount {
return {
subjectId: member.id,
subjectType: SubjectType.ACCOUNT,
accountData: member,
}
}
function getSubjectLabel(subject: Subject) {
if (subject.subjectType === SubjectType.GROUP)
return (subject as SubjectGroup).groupData.name
return (subject as SubjectAccount).accountData.name
}
function getSubjectValue(subject: Subject) {
return `${subject.subjectType}:${subject.subjectId}`
}
function isSameSubject(item: Subject, value: Subject) {
return item.subjectId === value.subjectId && item.subjectType === value.subjectType
}
function SubjectItem({ subject }: { subject: Subject }) {
if (subject.subjectType === SubjectType.GROUP)
return <GroupItem group={(subject as SubjectGroup).groupData} subject={subject} />
return <MemberItem member={(subject as SubjectAccount).accountData} subject={subject} />
}
function SelectedGroupsBreadCrumb() {
@ -112,13 +209,13 @@ function SelectedGroupsBreadCrumb() {
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
const { t } = useTranslation()
const handleBreadCrumbClick = useCallback((index: number) => {
const handleBreadCrumbClick = (index: number) => {
const newGroups = selectedGroupsForBreadcrumb.slice(0, index + 1)
setSelectedGroupsForBreadcrumb(newGroups)
}, [setSelectedGroupsForBreadcrumb, selectedGroupsForBreadcrumb])
const handleReset = useCallback(() => {
}
const handleReset = () => {
setSelectedGroupsForBreadcrumb([])
}, [setSelectedGroupsForBreadcrumb])
}
const hasBreadcrumb = selectedGroupsForBreadcrumb.length > 0
return (
@ -162,104 +259,111 @@ function SelectedGroupsBreadCrumb() {
type GroupItemProps = {
group: AccessControlGroup
subject: Subject
}
function GroupItem({ group }: GroupItemProps) {
function GroupItem({ group, subject }: GroupItemProps) {
const { t } = useTranslation()
const specificGroups = useAccessControlStore(s => s.specificGroups)
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
const isChecked = specificGroups.some(g => g.id === group.id)
const handleCheckChange = useCallback(() => {
if (!isChecked) {
const newGroups = [...specificGroups, group]
setSpecificGroups(newGroups)
}
else {
const newGroups = specificGroups.filter(g => g.id !== group.id)
setSpecificGroups(newGroups)
}
}, [specificGroups, setSpecificGroups, group, isChecked])
const handleExpandClick = useCallback(() => {
const handleExpandClick = () => {
setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group])
}, [selectedGroupsForBreadcrumb, setSelectedGroupsForBreadcrumb, group])
}
return (
<BaseItem>
<Checkbox checked={isChecked} className="h-4 w-4 shrink-0" onCheck={handleCheckChange} />
<div className="item-center flex grow">
<div className="mr-2 h-5 w-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex h-full w-full items-center justify-center">
<RiOrganizationChart className="h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" />
<div className="flex items-center gap-2 rounded-lg hover:bg-state-base-hover">
<BaseItem subject={subject}>
<SelectionBox checked={isChecked} />
<ComboboxItemText className="flex grow items-center px-0">
<div className="mr-2 h-5 w-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex h-full w-full items-center justify-center">
<RiOrganizationChart className="h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" aria-hidden="true" />
</div>
</div>
</div>
<p className="mr-1 system-sm-medium text-text-secondary">{group.name}</p>
<p className="system-xs-regular text-text-tertiary">{group.groupSize}</p>
</div>
<span className="mr-1 system-sm-medium text-text-secondary">{group.name}</span>
<span className="system-xs-regular text-text-tertiary">{group.groupSize}</span>
</ComboboxItemText>
</BaseItem>
<Button
size="small"
disabled={isChecked}
variant="ghost-accent"
className="flex shrink-0 items-center justify-between px-1.5 py-1"
className="mr-1 flex shrink-0 items-center justify-between px-1.5 py-1"
onPointerDown={event => event.preventDefault()}
onClick={handleExpandClick}
>
<span className="px-[3px]">{t('accessControlDialog.operateGroupAndMember.expand', { ns: 'app' })}</span>
<RiArrowRightSLine className="h-4 w-4" />
<RiArrowRightSLine className="h-4 w-4" aria-hidden="true" />
</Button>
</BaseItem>
</div>
)
}
type MemberItemProps = {
member: AccessControlAccount
subject: Subject
}
function MemberItem({ member }: MemberItemProps) {
function MemberItem({ member, subject }: MemberItemProps) {
const currentUser = useSelector(s => s.userProfile)
const { t } = useTranslation()
const specificMembers = useAccessControlStore(s => s.specificMembers)
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
const isChecked = specificMembers.some(m => m.id === member.id)
const handleCheckChange = useCallback(() => {
if (!isChecked) {
const newMembers = [...specificMembers, member]
setSpecificMembers(newMembers)
}
else {
const newMembers = specificMembers.filter(m => m.id !== member.id)
setSpecificMembers(newMembers)
}
}, [specificMembers, setSpecificMembers, member, isChecked])
return (
<BaseItem className="pr-3">
<Checkbox checked={isChecked} className="h-4 w-4 shrink-0" onCheck={handleCheckChange} />
<div className="flex grow items-center">
<BaseItem subject={subject} className="pr-3">
<SelectionBox checked={isChecked} />
<ComboboxItemText className="flex grow items-center px-0">
<div className="mr-2 h-5 w-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex h-full w-full items-center justify-center">
<Avatar size="xxs" avatar={null} name={member.name} />
</div>
</div>
<p className="mr-1 system-sm-medium text-text-secondary">{member.name}</p>
<span className="mr-1 system-sm-medium text-text-secondary">{member.name}</span>
{currentUser.email === member.email && (
<p className="system-xs-regular text-text-tertiary">
<span className="system-xs-regular text-text-tertiary">
(
{t('you', { ns: 'common' })}
)
</p>
</span>
)}
</div>
<p className="system-xs-regular text-text-quaternary">{member.email}</p>
</ComboboxItemText>
<span className="system-xs-regular text-text-quaternary">{member.email}</span>
</BaseItem>
)
}
type BaseItemProps = {
className?: string
subject: Subject
children: React.ReactNode
}
function BaseItem({ children, className }: BaseItemProps) {
function BaseItem({ children, className, subject }: BaseItemProps) {
return (
<div className={cn('flex cursor-pointer items-center space-x-2 p-1 pl-2 hover:rounded-lg hover:bg-state-base-hover', className)}>
<ComboboxItem
value={subject}
className={cn(
'mx-0 flex min-h-8 grow grid-cols-none items-center gap-2 rounded-lg p-1 pl-2',
className,
)}
>
{children}
</div>
</ComboboxItem>
)
}
function SelectionBox({ checked }: { checked: boolean }) {
return (
<span
aria-hidden="true"
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-sm shadow-xs shadow-shadow-shadow-3',
checked
? 'bg-components-checkbox-bg text-components-checkbox-icon'
: 'border border-components-checkbox-border bg-components-checkbox-bg-unchecked',
)}
>
{checked && <span className="i-ri-check-line size-3" />}
</span>
)
}

View File

@ -1,6 +1,8 @@
import type { DocumentItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SimpleDocumentDetail } from '@/models/datasets'
import { Combobox } from '@langgenius/dify-ui/combobox'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ChunkingMode, DataSourceType } from '@/models/datasets'
import DocumentList from '../document-list'
vi.mock('../../document-file-icon', () => ({
@ -13,37 +15,92 @@ vi.mock('../../document-file-icon', () => ({
),
}))
const createDocument = (overrides: Partial<SimpleDocumentDetail> = {}): SimpleDocumentDetail => ({
id: 'doc-1',
batch: 'batch-1',
position: 1,
dataset_id: 'dataset-1',
data_source_type: DataSourceType.FILE,
data_source_info: {
upload_file: {
id: 'file-1',
name: 'report.pdf',
size: 1024,
extension: 'pdf',
mime_type: 'application/pdf',
created_by: 'user-1',
created_at: Date.now(),
},
job_id: 'job-1',
url: '',
},
dataset_process_rule_id: 'rule-1',
name: 'report',
created_from: 'web',
created_by: 'user-1',
created_at: Date.now(),
indexing_status: 'completed',
display_status: 'enabled',
doc_form: ChunkingMode.text,
doc_language: 'en',
enabled: true,
word_count: 1000,
archived: false,
updated_at: Date.now(),
hit_count: 0,
data_source_detail_dict: {
upload_file: {
name: 'report.pdf',
extension: 'pdf',
},
},
...overrides,
})
const renderDocumentList = (list: SimpleDocumentDetail[], onValueChange = vi.fn()) => ({
onValueChange,
...render(
<Combobox
open
items={list}
itemToStringLabel={document => document.name}
itemToStringValue={document => document.id}
onValueChange={onValueChange}
>
<DocumentList />
</Combobox>,
),
})
describe('DocumentList', () => {
const mockList = [
{ id: 'doc-1', name: 'report', extension: 'pdf' },
{ id: 'doc-2', name: 'data', extension: 'csv' },
] as DocumentItem[]
const onChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render all documents', () => {
render(<DocumentList list={mockList} onChange={onChange} />)
expect(screen.getByText('report')).toBeInTheDocument()
expect(screen.getByText('data')).toBeInTheDocument()
})
it('should render documents as combobox options', () => {
renderDocumentList([
createDocument({ id: 'doc-1', name: 'report' }),
createDocument({ id: 'doc-2', name: 'data' }),
])
it('should render file icons', () => {
render(<DocumentList list={mockList} onChange={onChange} />)
expect(screen.getByRole('option', { name: /report/ })).toBeInTheDocument()
expect(screen.getByRole('option', { name: /data/ })).toBeInTheDocument()
expect(screen.getAllByTestId('file-icon')).toHaveLength(2)
})
it('should call onChange with document on click', () => {
render(<DocumentList list={mockList} onChange={onChange} />)
fireEvent.click(screen.getByText('report'))
expect(onChange).toHaveBeenCalledWith(mockList[0])
it('should keep item spacing symmetric with the search field', () => {
renderDocumentList([createDocument({ id: 'doc-1', name: 'report' })])
expect(screen.getByRole('option', { name: /report/ })).toHaveClass('px-3')
})
it('should render empty list without errors', () => {
const { container } = render(<DocumentList list={[]} onChange={onChange} />)
expect(container.firstChild).toBeInTheDocument()
it('should select a document through combobox value change', async () => {
const user = userEvent.setup()
const selectedDocument = createDocument({ id: 'doc-1', name: 'report' })
const { onValueChange } = renderDocumentList([selectedDocument])
await user.click(screen.getByRole('option', { name: /report/ }))
expect(onValueChange).toHaveBeenCalledWith(selectedDocument, expect.any(Object))
})
})

View File

@ -1,43 +1,49 @@
'use client'
import type { FC } from 'react'
import type { DocumentItem } from '@/models/datasets'
import type { SimpleDocumentDetail } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useCallback } from 'react'
import {
ComboboxItem,
ComboboxItemText,
ComboboxList,
} from '@langgenius/dify-ui/combobox'
import FileIcon from '../document-file-icon'
type Props = {
className?: string
list: DocumentItem[]
onChange: (value: DocumentItem) => void
}
const DocumentList: FC<Props> = ({
className,
list,
onChange,
}) => {
const handleChange = useCallback((item: DocumentItem) => {
return () => onChange(item)
}, [onChange])
function getDocumentExtension(document: SimpleDocumentDetail) {
const detailExtension = document.data_source_detail_dict?.upload_file?.extension
if (detailExtension)
return detailExtension
const dataSourceInfo = document.data_source_info
if (dataSourceInfo && 'upload_file' in dataSourceInfo)
return dataSourceInfo.upload_file.extension
return ''
}
export default function DocumentList({
className,
}: Props) {
return (
<div className={cn('max-h-[calc(100vh-120px)] overflow-auto', className)}>
{list.map((item) => {
const { id, name, extension } = item
<ComboboxList className={cn('max-h-[calc(100vh-120px)] p-0', className)}>
{(item: SimpleDocumentDetail) => {
const extension = getDocumentExtension(item)
return (
<div
key={id}
className="flex h-8 cursor-pointer items-center space-x-2 rounded-lg px-2 hover:bg-state-base-hover"
onClick={handleChange(item)}
<ComboboxItem
key={item.id}
value={item}
className="mx-0 flex h-8 grid-cols-none items-center gap-2 rounded-lg px-3 py-0"
>
<FileIcon name={item.name} extension={extension} size="lg" />
<div className="truncate text-sm text-text-secondary">{name}</div>
</div>
<ComboboxItemText className="min-w-0 px-0 system-sm-regular text-text-secondary">
{item.name}
</ComboboxItemText>
</ComboboxItem>
)
})}
</div>
}}
</ComboboxList>
)
}
export default React.memo(DocumentList)

View File

@ -1,20 +1,22 @@
'use client'
import type { FC } from 'react'
import type { DocumentItem, ParentMode, SimpleDocumentDetail } from '@/models/datasets'
import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox'
import type { ParentMode, SimpleDocumentDetail } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxInputGroup,
ComboboxStatus,
ComboboxTrigger,
ComboboxValue,
} from '@langgenius/dify-ui/combobox'
import { RiArrowDownSLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useDeferredValue, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { GeneralChunk, ParentChildChunk } from '@/app/components/base/icons/src/vender/knowledge'
import Loading from '@/app/components/base/loading'
import SearchInput from '@/app/components/base/search-input'
import { ChunkingMode } from '@/models/datasets'
import { useDocumentList } from '@/service/knowledge/use-document'
import FileIcon from '../document-file-icon'
@ -22,116 +24,177 @@ import DocumentList from './document-list'
type Props = {
datasetId: string
value: {
name?: string
extension?: string
chunkingMode?: ChunkingMode
parentMode?: ParentMode
}
value?: SimpleDocumentDetail | null
parentMode?: ParentMode
onChange: (value: SimpleDocumentDetail) => void
}
const DocumentPicker: FC<Props> = ({
function getDocumentLabel(document: SimpleDocumentDetail) {
return document.name
}
function getDocumentValue(document: SimpleDocumentDetail) {
return document.id
}
function isSameDocument(item: SimpleDocumentDetail, value: SimpleDocumentDetail) {
return item.id === value.id
}
function getDocumentExtension(document?: SimpleDocumentDetail | null) {
if (!document)
return ''
const detailExtension = document.data_source_detail_dict?.upload_file?.extension
if (detailExtension)
return detailExtension
const dataSourceInfo = document.data_source_info
if (dataSourceInfo && 'upload_file' in dataSourceInfo)
return dataSourceInfo.upload_file.extension
return ''
}
function DocumentPickerTriggerValue({
document,
parentMode,
}: {
document?: SimpleDocumentDetail | null
parentMode?: ParentMode
}) {
const { t } = useTranslation()
const isGeneralMode = document?.doc_form === ChunkingMode.text
const isParentChild = document?.doc_form === ChunkingMode.parentChild
const isQAMode = document?.doc_form === ChunkingMode.qa
const TypeIcon = isParentChild ? ParentChildChunk : GeneralChunk
const ArrowIcon = RiArrowDownSLine
const parentModeLabel = (() => {
if (!parentMode)
return '--'
return parentMode === 'paragraph' ? t('parentMode.paragraph', { ns: 'dataset' }) : t('parentMode.fullDoc', { ns: 'dataset' })
})()
return (
<span className="flex min-w-0 items-center gap-1.5">
<FileIcon name={document?.name} extension={getDocumentExtension(document)} size="xl" />
<span className="flex min-w-0 flex-col items-start">
<span className="flex max-w-full min-w-0 items-center gap-1">
<span className="max-w-[280px] min-w-0 truncate system-md-semibold text-text-primary">
{document?.name || '--'}
</span>
<ArrowIcon className="h-4 w-4 shrink-0 text-text-primary" aria-hidden="true" />
</span>
<span className="flex h-3 max-w-[300px] items-center gap-0.5 text-text-tertiary">
<TypeIcon className="h-3 w-3 shrink-0" />
<span className={cn('truncate system-2xs-medium-uppercase', isParentChild && 'mt-0.5')}>
{isGeneralMode && t('chunkingMode.general', { ns: 'dataset' })}
{isQAMode && t('chunkingMode.qa', { ns: 'dataset' })}
{isParentChild && `${t('chunkingMode.parentChild', { ns: 'dataset' })} · ${parentModeLabel}`}
</span>
</span>
</span>
</span>
)
}
export function DocumentPicker({
datasetId,
value,
parentMode,
onChange,
}) => {
}: Props) {
const { t } = useTranslation()
const {
name,
extension,
chunkingMode,
parentMode,
} = value
const [query, setQuery] = useState('')
const [searchValue, setSearchValue] = useState('')
const deferredSearchValue = useDeferredValue(searchValue)
const { data } = useDocumentList({
datasetId,
query: {
keyword: query,
keyword: deferredSearchValue,
page: 1,
limit: 20,
},
})
const documentsList = data?.data
const isGeneralMode = chunkingMode === ChunkingMode.text
const isParentChild = chunkingMode === ChunkingMode.parentChild
const isQAMode = chunkingMode === ChunkingMode.qa
const TypeIcon = isParentChild ? ParentChildChunk : GeneralChunk
const documentsList = data?.data ?? []
const [open, {
set: setOpen,
}] = useBoolean(false)
const ArrowIcon = RiArrowDownSLine
const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => {
if (details.reason !== 'item-press')
setSearchValue(inputValue)
}
const handleChange = useCallback(({ id }: DocumentItem) => {
onChange(documentsList?.find(item => item.id === id) as SimpleDocumentDetail)
setOpen(false)
}, [documentsList, onChange, setOpen])
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen)
setSearchValue('')
}
const parentModeLabel = useMemo(() => {
if (!parentMode)
return '--'
return parentMode === 'paragraph' ? t('parentMode.paragraph', { ns: 'dataset' }) : t('parentMode.fullDoc', { ns: 'dataset' })
}, [parentMode, t])
const handleDocumentChange = (document: SimpleDocumentDetail | null) => {
if (!document)
return
onChange(document)
setSearchValue('')
}
return (
<Popover
open={open}
onOpenChange={setOpen}
<Combobox<SimpleDocumentDetail>
items={documentsList}
value={value ?? null}
inputValue={searchValue}
onOpenChange={handleOpenChange}
onInputValueChange={handleInputValueChange}
onValueChange={handleDocumentChange}
isItemEqualToValue={isSameDocument}
itemToStringLabel={getDocumentLabel}
itemToStringValue={getDocumentValue}
filter={null}
>
<PopoverTrigger
nativeButton={false}
render={(
<div className={cn('ml-1 flex cursor-pointer items-center rounded-lg px-2 py-0.5 select-none hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
<FileIcon name={name} extension={extension} size="xl" />
<div className="mr-0.5 ml-1 flex flex-col items-start">
<div className="flex items-center space-x-0.5">
<span className={cn('system-md-semibold text-text-primary')}>
{' '}
{name || '--'}
</span>
<ArrowIcon className="h-4 w-4 text-text-primary" />
</div>
<div className="flex h-3 items-center space-x-0.5 text-text-tertiary">
<TypeIcon className="h-3 w-3" />
<span className={cn('system-2xs-medium-uppercase', isParentChild && 'mt-0.5' /* to icon problem cause not ver align */)}>
{isGeneralMode && t('chunkingMode.general', { ns: 'dataset' })}
{isQAMode && t('chunkingMode.qa', { ns: 'dataset' })}
{isParentChild && `${t('chunkingMode.parentChild', { ns: 'dataset' })} · ${parentModeLabel}`}
</span>
</div>
</div>
</div>
<ComboboxTrigger
aria-label={value?.name || t('operation.search', { ns: 'common' })}
icon={false}
className={cn(
'ml-1 flex h-auto w-auto rounded-lg border-0 bg-transparent px-2 py-1 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active data-open:bg-state-base-hover',
)}
/>
<PopoverContent
>
<ComboboxValue>
{(document: SimpleDocumentDetail | null) => (
<DocumentPickerTriggerValue document={document} parentMode={parentMode} />
)}
</ComboboxValue>
</ComboboxTrigger>
<ComboboxContent
placement="bottom-start"
sideOffset={0}
popupClassName="border-none bg-transparent shadow-none"
popupClassName="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-lg backdrop-blur-[5px]"
>
<div className="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 pt-2 shadow-lg backdrop-blur-[5px]">
<SearchInput value={query} onChange={setQuery} className="mx-1" />
{documentsList
? (
<DocumentList
className="mt-2"
list={documentsList.map(d => ({
id: d.id,
name: d.name,
extension: d.data_source_detail_dict?.upload_file?.extension || '',
}))}
onChange={handleChange}
/>
)
: (
<div className="mt-2 flex h-[100px] w-[360px] items-center justify-center">
<Loading />
</div>
)}
</div>
</PopoverContent>
</Popover>
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
<ComboboxInput
aria-label={t('operation.search', { ns: 'common' })}
placeholder={t('operation.search', { ns: 'common' })}
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
/>
</ComboboxInputGroup>
{data
? (
documentsList.length > 0
? (
<DocumentList
className="mt-2"
/>
)
: (
<ComboboxEmpty className="mt-2 flex h-[100px] w-full items-center justify-center">
{t('noData', { ns: 'common' })}
</ComboboxEmpty>
)
)
: (
<ComboboxStatus className="mt-2 flex h-[100px] w-full items-center justify-center">
<Loading />
</ComboboxStatus>
)}
</ComboboxContent>
</Combobox>
)
}
export default React.memo(DocumentPicker)

View File

@ -14,7 +14,6 @@ import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import FileIcon from '../document-file-icon'
import DocumentList from './document-list'
type Props = {
className?: string
@ -74,7 +73,7 @@ const PreviewDocumentPicker: FC<Props> = ({
{files?.length > 1 && <div className="flex h-8 items-center pl-2 system-xs-medium-uppercase text-text-tertiary">{t('preprocessDocument', { ns: 'dataset', num: files.length })}</div>}
{files?.length > 0
? (
<DocumentList
<PreviewDocumentList
list={files}
onChange={handleChange}
/>
@ -90,3 +89,27 @@ const PreviewDocumentPicker: FC<Props> = ({
)
}
export default React.memo(PreviewDocumentPicker)
function PreviewDocumentList({
list,
onChange,
}: {
list: DocumentItem[]
onChange: (value: DocumentItem) => void
}) {
return (
<div className="max-h-[calc(100vh-120px)] overflow-auto">
{list.map(item => (
<button
key={item.id}
type="button"
className="flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg border-0 bg-transparent px-2 text-left hover:bg-state-base-hover"
onClick={() => onChange(item)}
>
<FileIcon name={item.name} extension={item.extension} size="lg" />
<span className="truncate text-sm text-text-secondary">{item.name}</span>
</button>
))}
</div>
)
}

View File

@ -1,6 +1,7 @@
import type { SimpleDocumentDetail } from '@/models/datasets'
import { render } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import { ChunkingMode, DataSourceType } from '@/models/datasets'
import { DocumentTitle } from '../document-title'
@ -11,13 +12,23 @@ vi.mock('@/next/navigation', () => ({
}),
}))
// Mock DocumentPicker
vi.mock('../../../common/document-picker', () => ({
default: ({ datasetId, value, onChange }: { datasetId: string, value: unknown, onChange: (doc: { id: string }) => void }) => (
DocumentPicker: ({
datasetId,
value,
parentMode,
onChange,
}: {
datasetId: string
value?: SimpleDocumentDetail | null
parentMode?: string
onChange: (doc: { id: string }) => void
}) => (
<div
data-testid="document-picker"
data-dataset-id={datasetId}
data-value={JSON.stringify(value)}
data-value-id={value?.id ?? ''}
data-parent-mode={parentMode ?? ''}
onClick={() => onChange({ id: 'new-doc-id' })}
>
Document Picker
@ -25,6 +36,42 @@ vi.mock('../../../common/document-picker', () => ({
),
}))
const createDocument = (overrides: Partial<SimpleDocumentDetail> = {}): SimpleDocumentDetail => ({
id: 'doc-1',
batch: 'batch-1',
position: 1,
dataset_id: 'dataset-1',
data_source_type: DataSourceType.FILE,
data_source_info: {
upload_file: {
id: 'file-1',
name: 'document.pdf',
size: 1024,
extension: 'pdf',
mime_type: 'application/pdf',
created_by: 'user-1',
created_at: Date.now(),
},
job_id: 'job-1',
url: '',
},
dataset_process_rule_id: 'rule-1',
name: 'Document 1',
created_from: 'web',
created_by: 'user-1',
created_at: Date.now(),
indexing_status: 'completed',
display_status: 'enabled',
doc_form: ChunkingMode.text,
doc_language: 'en',
enabled: true,
word_count: 1000,
archived: false,
updated_at: Date.now(),
hit_count: 0,
...overrides,
})
describe('DocumentTitle', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -69,31 +116,26 @@ describe('DocumentTitle', () => {
expect(getByTestId('document-picker').getAttribute('data-dataset-id')).toBe('test-dataset-id')
})
it('should pass value props to DocumentPicker', () => {
it('should pass the selected document to DocumentPicker', () => {
const document = createDocument({ id: 'doc-current' })
const { getByTestId } = render(
<DocumentTitle
datasetId="dataset-1"
name="test-document"
extension="pdf"
chunkingMode={ChunkingMode.text}
parent_mode="paragraph"
document={document}
parentMode="paragraph"
/>,
)
const value = JSON.parse(getByTestId('document-picker').getAttribute('data-value') || '{}')
expect(value.name).toBe('test-document')
expect(value.extension).toBe('pdf')
expect(value.chunkingMode).toBe(ChunkingMode.text)
expect(value.parentMode).toBe('paragraph')
expect(getByTestId('document-picker')).toHaveAttribute('data-value-id', 'doc-current')
expect(getByTestId('document-picker')).toHaveAttribute('data-parent-mode', 'paragraph')
})
it('should default parentMode to paragraph when parent_mode is undefined', () => {
it('should pass no parent mode when it is undefined', () => {
const { getByTestId } = render(
<DocumentTitle datasetId="dataset-1" />,
)
const value = JSON.parse(getByTestId('document-picker').getAttribute('data-value') || '{}')
expect(value.parentMode).toBe('paragraph')
expect(getByTestId('document-picker')).toHaveAttribute('data-parent-mode', '')
})
it('should apply custom wrapperCls', () => {
@ -119,24 +161,23 @@ describe('DocumentTitle', () => {
})
describe('Edge Cases', () => {
it('should handle undefined optional props', () => {
it('should handle an empty document value', () => {
const { getByTestId } = render(
<DocumentTitle datasetId="dataset-1" />,
)
const value = JSON.parse(getByTestId('document-picker').getAttribute('data-value') || '{}')
expect(value.name).toBeUndefined()
expect(value.extension).toBeUndefined()
expect(getByTestId('document-picker')).toHaveAttribute('data-value-id', '')
})
it('should maintain structure when rerendered', () => {
const { rerender, getByTestId } = render(
<DocumentTitle datasetId="dataset-1" name="doc1" />,
<DocumentTitle datasetId="dataset-1" document={createDocument({ id: 'doc-1' })} />,
)
rerender(<DocumentTitle datasetId="dataset-2" name="doc2" />)
rerender(<DocumentTitle datasetId="dataset-2" document={createDocument({ id: 'doc-2' })} />)
expect(getByTestId('document-picker').getAttribute('data-dataset-id')).toBe('dataset-2')
expect(getByTestId('document-picker').getAttribute('data-value-id')).toBe('doc-2')
})
})
})

View File

@ -114,9 +114,20 @@ vi.mock('../batch-modal', () => ({
}))
vi.mock('../document-title', () => ({
DocumentTitle: ({ name, extension }: { name?: string, extension?: string }) => (
<div data-testid="document-title" data-extension={extension}>{name}</div>
),
DocumentTitle: ({
document,
}: {
document?: {
name?: string
data_source_detail_dict?: { upload_file?: { extension?: string } }
data_source_info?: { upload_file?: { extension?: string } }
} | null
}) => {
const extension = document?.data_source_detail_dict?.upload_file?.extension
?? document?.data_source_info?.upload_file?.extension
return <div data-testid="document-title" data-extension={extension}>{document?.name}</div>
},
}))
vi.mock('../segment-add', () => ({

View File

@ -1,39 +1,29 @@
import type { FC } from 'react'
import type { ChunkingMode, ParentMode } from '@/models/datasets'
import type { ParentMode, SimpleDocumentDetail } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import { useRouter } from '@/next/navigation'
import DocumentPicker from '../../common/document-picker'
import { DocumentPicker } from '../../common/document-picker'
type DocumentTitleProps = {
datasetId: string
extension?: string
name?: string
chunkingMode?: ChunkingMode
parent_mode?: ParentMode
iconCls?: string
textCls?: string
document?: SimpleDocumentDetail | null
parentMode?: ParentMode
wrapperCls?: string
}
export const DocumentTitle: FC<DocumentTitleProps> = ({
export function DocumentTitle({
datasetId,
extension,
name,
chunkingMode,
parent_mode,
document,
parentMode,
wrapperCls,
}) => {
}: DocumentTitleProps) {
const router = useRouter()
return (
<div className={cn('flex flex-1 items-center justify-start', wrapperCls)}>
<DocumentPicker
datasetId={datasetId}
value={{
name,
extension,
chunkingMode,
parentMode: parent_mode || 'paragraph',
}}
value={document}
parentMode={parentMode}
onChange={(doc) => {
router.push(`/datasets/${datasetId}/documents/${doc.id}`)
}}

View File

@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import type { DataSourceInfo, DocumentDisplayStatus, FileItem, FullDocumentDetail, LegacyDataSourceInfo } from '@/models/datasets'
import type { DocumentDisplayStatus, FileItem, FullDocumentDetail } from '@/models/datasets'
import type { SegmentImportStatus } from '@/types/dataset'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
@ -38,10 +38,6 @@ const NON_TERMINAL_DISPLAY_STATUSES = new Set<typeof DisplayStatusList[number]>(
DisplayStatusList.filter(s => s === 'queuing' || s === 'indexing' || s === 'paused'),
)
const isLegacyDataSourceInfo = (info?: DataSourceInfo): info is LegacyDataSourceInfo => {
return !!info && 'upload_file' in info
}
const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
const router = useRouter()
const searchParams = useSearchParams()
@ -123,14 +119,6 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
const embedding = NON_TERMINAL_DISPLAY_STATUSES.has(documentDetail?.display_status as DocumentDisplayStatus)
const documentUploadFile = useMemo(() => {
if (!documentDetail?.data_source_info)
return undefined
if (isLegacyDataSourceInfo(documentDetail.data_source_info))
return documentDetail.data_source_info.upload_file
return undefined
}, [documentDetail?.data_source_info])
const invalidChunkList = useInvalid(useSegmentListKey)
const invalidChildChunkList = useInvalid(useChildSegmentListKey)
const invalidDocumentList = useInvalidDocumentList(datasetId)
@ -212,11 +200,9 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
</button>
<DocumentTitle
datasetId={datasetId}
extension={documentUploadFile?.extension}
name={documentDetail?.name}
document={documentDetail}
wrapperCls="mr-2"
parent_mode={parentMode}
chunkingMode={documentDetail?.doc_form as ChunkingMode}
parentMode={parentMode}
/>
<div className="flex flex-wrap items-center">
{embeddingAvailable && documentDetail && !documentDetail.archived && !isFullDocMode && (