mirror of
https://github.com/langgenius/dify.git
synced 2026-05-12 12:00:41 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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}`)
|
||||
}}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user