refactor(web): migrate multi-checkbox lists to CheckboxGroup (#36381)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh
2026-05-19 15:55:33 +08:00
committed by GitHub
parent 34a89416f7
commit 4b2badb6f2
17 changed files with 243 additions and 391 deletions

View File

@ -1986,9 +1986,6 @@
},
"react-refresh/only-export-components": {
"count": 1
},
"react/use-memo": {
"count": 1
}
},
"web/app/components/datasets/documents/detail/completed/segment-list.tsx": {

View File

@ -111,77 +111,27 @@ describe('Segment CRUD Flow', () => {
})
describe('Segment Selection → Batch Operations', () => {
const segments = [
createSegment('seg-1'),
createSegment('seg-2'),
createSegment('seg-3'),
]
it('should manage individual segment selection', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
const { result } = renderHook(() => useSegmentSelection())
act(() => {
result.current.onSelected('seg-1')
result.current.onSelectedSegmentIdsChange(['seg-1'])
})
expect(result.current.selectedSegmentIds).toContain('seg-1')
act(() => {
result.current.onSelected('seg-2')
result.current.onSelectedSegmentIdsChange(['seg-1', 'seg-2'])
})
expect(result.current.selectedSegmentIds).toContain('seg-1')
expect(result.current.selectedSegmentIds).toContain('seg-2')
expect(result.current.selectedSegmentIds).toHaveLength(2)
})
it('should toggle selection on repeated click', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
act(() => {
result.current.onSelected('seg-1')
})
expect(result.current.selectedSegmentIds).toContain('seg-1')
act(() => {
result.current.onSelected('seg-1')
})
expect(result.current.selectedSegmentIds).not.toContain('seg-1')
})
it('should support select all toggle', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
act(() => {
result.current.onSelectedAll()
})
expect(result.current.selectedSegmentIds).toHaveLength(3)
expect(result.current.isAllSelected).toBe(true)
act(() => {
result.current.onSelectedAll()
})
expect(result.current.selectedSegmentIds).toHaveLength(0)
expect(result.current.isAllSelected).toBe(false)
})
it('should detect partial selection via isSomeSelected', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
act(() => {
result.current.onSelected('seg-1')
})
// After selecting one of three, isSomeSelected should be true
expect(result.current.selectedSegmentIds).toEqual(['seg-1'])
expect(result.current.isSomeSelected).toBe(true)
expect(result.current.isAllSelected).toBe(false)
})
it('should clear selection via onCancelBatchOperation', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
const { result } = renderHook(() => useSegmentSelection())
act(() => {
result.current.onSelected('seg-1')
result.current.onSelected('seg-2')
result.current.onSelectedSegmentIdsChange(['seg-1', 'seg-2'])
})
expect(result.current.selectedSegmentIds).toHaveLength(2)
@ -271,7 +221,7 @@ describe('Segment CRUD Flow', () => {
useSearchFilter({ onPageChange: vi.fn() }),
)
const { result: selectionResult } = renderHook(() =>
useSegmentSelection(segments),
useSegmentSelection(),
)
const { result: modalResult } = renderHook(() =>
useModalState({ onNewSegmentModalChange: vi.fn() }),
@ -284,7 +234,7 @@ describe('Segment CRUD Flow', () => {
// Select a segment
act(() => {
selectionResult.current.onSelected('seg-1')
selectionResult.current.onSelectedSegmentIdsChange(['seg-1'])
})
// Open detail modal

View File

@ -137,36 +137,40 @@ vi.mock('../hooks/use-child-segment-data', () => ({
},
}))
vi.mock('../components/menu-bar', () => ({
default: ({ totalText, onInputChange, inputValue, isLoading, onSelectedAll, onChangeStatus }: {
totalText: string
onInputChange: (value: string) => void
inputValue: string
isLoading: boolean
onSelectedAll?: () => void
onChangeStatus?: (item: { value: string | number, name: string }) => void
}) => (
<div data-testid="menu-bar">
<span data-testid="total-text">{totalText}</span>
<input
data-testid="search-input"
value={inputValue}
onChange={e => onInputChange(e.target.value)}
disabled={isLoading}
/>
{onSelectedAll && (
<button data-testid="select-all-button" onClick={onSelectedAll}>Select All</button>
)}
{onChangeStatus && (
<>
<button data-testid="status-enabled" onClick={() => onChangeStatus({ value: 1, name: 'Enabled' })}>Enabled</button>
<button data-testid="status-disabled" onClick={() => onChangeStatus({ value: 0, name: 'Disabled' })}>Disabled</button>
<button data-testid="status-all" onClick={() => onChangeStatus({ value: 'all', name: 'All' })}>All</button>
</>
)}
</div>
),
}))
vi.mock('../components/menu-bar', async () => {
const { Checkbox } = await import('@langgenius/dify-ui/checkbox')
return {
default: ({ hasSelectableSegments, totalText, onInputChange, inputValue, isLoading, onChangeStatus }: {
hasSelectableSegments: boolean
totalText: string
onInputChange: (value: string) => void
inputValue: string
isLoading: boolean
onChangeStatus?: (item: { value: string | number, name: string }) => void
}) => (
<div data-testid="menu-bar">
<span data-testid="total-text">{totalText}</span>
<input
data-testid="search-input"
value={inputValue}
onChange={e => onInputChange(e.target.value)}
disabled={isLoading}
/>
{hasSelectableSegments
? <Checkbox parent data-testid="select-all-button" aria-label="Select All" disabled={isLoading} />
: <span data-testid="select-all-spacer" aria-hidden />}
{onChangeStatus && (
<>
<button data-testid="status-enabled" onClick={() => onChangeStatus({ value: 1, name: 'Enabled' })}>Enabled</button>
<button data-testid="status-disabled" onClick={() => onChangeStatus({ value: 0, name: 'Disabled' })}>Disabled</button>
<button data-testid="status-all" onClick={() => onChangeStatus({ value: 'all', name: 'All' })}>All</button>
</>
)}
</div>
),
}
})
vi.mock('../components/drawer-group', () => ({
DrawerGroup: () => <div data-testid="drawer-group" />,
@ -751,6 +755,17 @@ describe('Batch Action Callbacks', () => {
})
})
it('should not render select all when there are no current page segments', () => {
mockSegmentListData.data = []
mockSegmentListData.total = 0
render(<Completed {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.queryByTestId('select-all-button')).not.toBeInTheDocument()
expect(screen.getByTestId('select-all-spacer')).toBeInTheDocument()
expect(screen.queryByTestId('batch-action')).not.toBeInTheDocument()
})
it('should call onChangeSwitch with true when batch enable is clicked', async () => {
render(<Completed {...defaultProps} />, { wrapper: createWrapper() })

View File

@ -123,8 +123,6 @@ describe('SegmentList', () => {
ref: null,
isLoading: false,
items: [createMockSegment('seg-1', 'Segment 1 content')],
selectedSegmentIds: [],
onSelected: vi.fn(),
onClick: vi.fn(),
onChangeSwitch: vi.fn(),
onDelete: vi.fn(),
@ -289,18 +287,10 @@ describe('SegmentList', () => {
expect(screen.getAllByRole('checkbox')).toHaveLength(defaultProps.items.length)
})
it('should pass selectedSegmentIds to check state', () => {
const { container } = render(<SegmentList {...defaultProps} selectedSegmentIds={['seg-1']} />)
it('should label each segment checkbox', () => {
render(<SegmentList {...defaultProps} />)
// Assert - component should render with selected state
expect(container.firstChild).toBeInTheDocument()
})
it('should handle empty selectedSegmentIds', () => {
const { container } = render(<SegmentList {...defaultProps} selectedSegmentIds={[]} />)
// Assert - component should render
expect(container.firstChild).toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: 'datasetDocuments.segment.chunk 1' })).toBeInTheDocument()
})
})

View File

@ -1,3 +1,4 @@
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MenuBar from '../menu-bar'
@ -16,9 +17,7 @@ vi.mock('../../status-item', () => ({
describe('MenuBar', () => {
const defaultProps = {
isAllSelected: false,
isSomeSelected: false,
onSelectedAll: vi.fn(),
hasSelectableSegments: true,
isLoading: false,
totalText: '10 Chunks',
statusList: [
@ -38,49 +37,63 @@ describe('MenuBar', () => {
vi.clearAllMocks()
})
const renderMenuBar = (props: Partial<typeof defaultProps> = {}) => {
return render(
<CheckboxGroup value={[]} onValueChange={vi.fn()} allValues={['seg-1']}>
<MenuBar {...defaultProps} {...props} />
</CheckboxGroup>,
)
}
it('should render total text', () => {
render(<MenuBar {...defaultProps} />)
renderMenuBar()
expect(screen.getByText('10 Chunks')).toBeInTheDocument()
})
it('should render checkbox', () => {
render(<MenuBar {...defaultProps} />)
renderMenuBar()
expect(screen.getByRole('checkbox', { name: 'common.operation.selectAll' })).toBeInTheDocument()
})
it('should not render select all checkbox when there are no selectable segments', () => {
renderMenuBar({ hasSelectableSegments: false })
expect(screen.queryByRole('checkbox', { name: 'common.operation.selectAll' })).not.toBeInTheDocument()
})
it('should call onInputChange when input changes', () => {
render(<MenuBar {...defaultProps} />)
renderMenuBar()
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test search' } })
expect(defaultProps.onInputChange).toHaveBeenCalledWith('test search')
})
it('should render display toggle', () => {
render(<MenuBar {...defaultProps} />)
renderMenuBar()
expect(screen.getByTestId('display-toggle')).toBeInTheDocument()
})
it('should call toggleCollapsed when display toggle clicked', () => {
render(<MenuBar {...defaultProps} />)
renderMenuBar()
fireEvent.click(screen.getByTestId('display-toggle'))
expect(defaultProps.toggleCollapsed).toHaveBeenCalled()
})
it('should call onInputChange with empty string when input is cleared', () => {
render(<MenuBar {...defaultProps} inputValue="some text" />)
renderMenuBar({ inputValue: 'some text' })
const clearButton = screen.getByRole('button', { name: 'common.operation.clear' })
fireEvent.click(clearButton)
expect(defaultProps.onInputChange).toHaveBeenCalledWith('')
})
it('should render select with status items via renderOption', () => {
render(<MenuBar {...defaultProps} />)
renderMenuBar()
expect(screen.getByText('All')).toBeInTheDocument()
})
it('should call renderOption for each item when dropdown is opened', async () => {
render(<MenuBar {...defaultProps} />)
renderMenuBar()
const selectButton = screen.getByRole('combobox')
fireEvent.click(selectButton)

View File

@ -84,8 +84,6 @@ describe('GeneralModeContent', () => {
embeddingAvailable: true,
isLoadingSegmentList: false,
segments: [{ id: 'seg-1' }, { id: 'seg-2' }] as SegmentDetailModel[],
selectedSegmentIds: [],
onSelected: vi.fn(),
onChangeSwitch: vi.fn(),
onDelete: vi.fn(),
onClickCard: vi.fn(),

View File

@ -1,5 +1,4 @@
'use client'
import type { FC } from 'react'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
@ -16,9 +15,7 @@ type Item = {
} & Record<string, unknown>
type MenuBarProps = {
isAllSelected: boolean
isSomeSelected: boolean
onSelectedAll: () => void
hasSelectableSegments: boolean
isLoading: boolean
totalText: string
statusList: Item[]
@ -30,10 +27,8 @@ type MenuBarProps = {
toggleCollapsed: () => void
}
const MenuBar: FC<MenuBarProps> = ({
isAllSelected,
isSomeSelected,
onSelectedAll,
function MenuBar({
hasSelectableSegments,
isLoading,
totalText,
statusList,
@ -43,20 +38,24 @@ const MenuBar: FC<MenuBarProps> = ({
onInputChange,
isCollapsed,
toggleCollapsed,
}) => {
}: MenuBarProps) {
const { t } = useTranslation()
const selectedStatus = statusList.find(item => item.value === selectDefaultValue) ?? null
return (
<div className={s.docSearchWrapper}>
<Checkbox
className="shrink-0"
checked={isAllSelected}
indeterminate={!isAllSelected && isSomeSelected}
aria-label={t('operation.selectAll', { ns: 'common' })}
onCheckedChange={() => onSelectedAll()}
disabled={isLoading}
/>
{hasSelectableSegments
? (
<Checkbox
className="shrink-0"
parent
aria-label={t('operation.selectAll', { ns: 'common' })}
disabled={isLoading}
/>
)
: (
<span className="size-4 shrink-0" aria-hidden />
)}
<div className="flex-1 pl-5 system-sm-semibold-uppercase text-text-secondary">{totalText}</div>
<Select
value={selectedStatus ? String(selectedStatus.value) : null}

View File

@ -78,8 +78,6 @@ type GeneralModeContentProps = {
embeddingAvailable: boolean
isLoadingSegmentList: boolean
segments: SegmentDetailModel[]
selectedSegmentIds: string[]
onSelected: (segId: string) => void
onChangeSwitch: (enable: boolean, segId?: string) => Promise<void>
onDelete: (segId?: string) => Promise<void>
onClickCard: (detail: SegmentDetailModel, isEditMode?: boolean) => void
@ -95,8 +93,6 @@ export const GeneralModeContent: FC<GeneralModeContentProps> = ({
embeddingAvailable,
isLoadingSegmentList,
segments,
selectedSegmentIds,
onSelected,
onChangeSwitch,
onDelete,
onClickCard,
@ -112,8 +108,6 @@ export const GeneralModeContent: FC<GeneralModeContentProps> = ({
embeddingAvailable={embeddingAvailable}
isLoading={isLoadingSegmentList}
items={segments}
selectedSegmentIds={selectedSegmentIds}
onSelected={onSelected}
onChangeSwitch={onChangeSwitch}
onDelete={onDelete}
onClick={onClickCard}

View File

@ -1,83 +1,33 @@
import type { SegmentDetailModel } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSegmentSelection } from '../use-segment-selection'
import { mergeCurrentPageSelectedSegmentIds, useSegmentSelection } from '../use-segment-selection'
describe('useSegmentSelection', () => {
const segments = [
{ id: 'seg-1', content: 'A' },
{ id: 'seg-2', content: 'B' },
{ id: 'seg-3', content: 'C' },
] as unknown as SegmentDetailModel[]
beforeEach(() => {
vi.clearAllMocks()
})
it('should initialize with empty selection', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
expect(result.current.selectedSegmentIds).toEqual([])
expect(result.current.isAllSelected).toBe(false)
expect(result.current.isSomeSelected).toBe(false)
})
it('should select a segment', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
act(() => {
result.current.onSelected('seg-1')
})
expect(result.current.selectedSegmentIds).toEqual(['seg-1'])
expect(result.current.isSomeSelected).toBe(true)
expect(result.current.isAllSelected).toBe(false)
})
it('should deselect a selected segment', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
act(() => {
result.current.onSelected('seg-1')
})
act(() => {
result.current.onSelected('seg-1')
})
const { result } = renderHook(() => useSegmentSelection())
expect(result.current.selectedSegmentIds).toEqual([])
})
it('should select all segments', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
it('should update selected segment ids', () => {
const { result } = renderHook(() => useSegmentSelection())
act(() => {
result.current.onSelectedAll()
result.current.onSelectedSegmentIdsChange(['seg-1', 'seg-2'])
})
expect(result.current.selectedSegmentIds).toEqual(['seg-1', 'seg-2', 'seg-3'])
expect(result.current.isAllSelected).toBe(true)
})
it('should deselect all when all are selected', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
act(() => {
result.current.onSelectedAll()
})
act(() => {
result.current.onSelectedAll()
})
expect(result.current.selectedSegmentIds).toEqual([])
expect(result.current.isAllSelected).toBe(false)
expect(result.current.selectedSegmentIds).toEqual(['seg-1', 'seg-2'])
})
it('should cancel batch operation', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
const { result } = renderHook(() => useSegmentSelection())
act(() => {
result.current.onSelected('seg-1')
result.current.onSelected('seg-2')
result.current.onSelectedSegmentIdsChange(['seg-1'])
})
act(() => {
result.current.onCancelBatchOperation()
@ -87,10 +37,10 @@ describe('useSegmentSelection', () => {
})
it('should clear selection', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
const { result } = renderHook(() => useSegmentSelection())
act(() => {
result.current.onSelected('seg-1')
result.current.onSelectedSegmentIdsChange(['seg-1'])
})
act(() => {
result.current.clearSelection()
@ -99,61 +49,19 @@ describe('useSegmentSelection', () => {
expect(result.current.selectedSegmentIds).toEqual([])
})
it('should handle empty segments array', () => {
const { result } = renderHook(() => useSegmentSelection([]))
expect(result.current.isAllSelected).toBe(false)
expect(result.current.isSomeSelected).toBe(false)
it('should merge current page selection without dropping selected ids from other pages', () => {
expect(mergeCurrentPageSelectedSegmentIds({
selectedSegmentIds: ['page-1-a', 'page-1-b'],
currentPageSegmentIds: ['page-2-a', 'page-2-b'],
nextCurrentPageSelectedSegmentIds: ['page-2-a'],
})).toEqual(['page-1-a', 'page-1-b', 'page-2-a'])
})
it('should allow multiple selections', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
act(() => {
result.current.onSelected('seg-1')
})
act(() => {
result.current.onSelected('seg-2')
})
expect(result.current.selectedSegmentIds).toEqual(['seg-1', 'seg-2'])
expect(result.current.isSomeSelected).toBe(true)
expect(result.current.isAllSelected).toBe(false)
})
it('should preserve selection of segments not in current list', () => {
const { result, rerender } = renderHook(
({ segs }) => useSegmentSelection(segs),
{ initialProps: { segs: segments } },
)
act(() => {
result.current.onSelected('seg-1')
})
// Rerender with different segment list (simulating page change)
const newSegments = [
{ id: 'seg-4', content: 'D' },
{ id: 'seg-5', content: 'E' },
] as unknown as SegmentDetailModel[]
rerender({ segs: newSegments })
// Previously selected segment should still be in selectedSegmentIds
expect(result.current.selectedSegmentIds).toContain('seg-1')
})
it('should select remaining unselected segments when onSelectedAll is called with partial selection', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
act(() => {
result.current.onSelected('seg-1')
})
act(() => {
result.current.onSelectedAll()
})
expect(result.current.selectedSegmentIds).toEqual(expect.arrayContaining(['seg-1', 'seg-2', 'seg-3']))
expect(result.current.isAllSelected).toBe(true)
it('should replace only current page selected ids when current page selection changes', () => {
expect(mergeCurrentPageSelectedSegmentIds({
selectedSegmentIds: ['page-1-a', 'page-2-a', 'page-2-b'],
currentPageSegmentIds: ['page-2-a', 'page-2-b'],
nextCurrentPageSelectedSegmentIds: ['page-2-b'],
})).toEqual(['page-1-a', 'page-2-b'])
})
})

View File

@ -6,4 +6,4 @@ export { useSearchFilter } from './use-search-filter'
export { useSegmentListData } from './use-segment-list-data'
export { useSegmentSelection } from './use-segment-selection'
export { mergeCurrentPageSelectedSegmentIds, useSegmentSelection } from './use-segment-selection'

View File

@ -1,43 +1,39 @@
import type { SegmentDetailModel } from '@/models/datasets'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useState } from 'react'
type UseSegmentSelectionReturn = {
selectedSegmentIds: string[]
isAllSelected: boolean
isSomeSelected: boolean
onSelected: (segId: string) => void
onSelectedAll: () => void
onSelectedSegmentIdsChange: (segmentIds: string[]) => void
onCancelBatchOperation: () => void
clearSelection: () => void
}
export const useSegmentSelection = (segments: SegmentDetailModel[]): UseSegmentSelectionReturn => {
type MergeCurrentPageSelectedSegmentIdsOptions = {
selectedSegmentIds: string[]
currentPageSegmentIds: string[]
nextCurrentPageSelectedSegmentIds: string[]
}
export const mergeCurrentPageSelectedSegmentIds = ({
selectedSegmentIds,
currentPageSegmentIds,
nextCurrentPageSelectedSegmentIds,
}: MergeCurrentPageSelectedSegmentIdsOptions) => {
const currentPageSegmentIdSet = new Set(currentPageSegmentIds)
const selectedSegmentIdsOutsideCurrentPage = selectedSegmentIds.filter(segmentId => !currentPageSegmentIdSet.has(segmentId))
return [
...selectedSegmentIdsOutsideCurrentPage,
...nextCurrentPageSelectedSegmentIds,
]
}
export const useSegmentSelection = (): UseSegmentSelectionReturn => {
const [selectedSegmentIds, setSelectedSegmentIds] = useState<string[]>([])
const onSelected = useCallback((segId: string) => {
setSelectedSegmentIds(prev =>
prev.includes(segId)
? prev.filter(id => id !== segId)
: [...prev, segId],
)
const onSelectedSegmentIdsChange = useCallback((segmentIds: string[]) => {
setSelectedSegmentIds(segmentIds)
}, [])
const isAllSelected = useMemo(() => {
return segments.length > 0 && segments.every(seg => selectedSegmentIds.includes(seg.id))
}, [segments, selectedSegmentIds])
const isSomeSelected = useMemo(() => {
return segments.some(seg => selectedSegmentIds.includes(seg.id))
}, [segments, selectedSegmentIds])
const onSelectedAll = useCallback(() => {
setSelectedSegmentIds((prev) => {
const currentAllSegIds = segments.map(seg => seg.id)
const prevSelectedIds = prev.filter(item => !currentAllSegIds.includes(item))
return [...prevSelectedIds, ...(isAllSelected ? [] : currentAllSegIds)]
})
}, [segments, isAllSelected])
const onCancelBatchOperation = useCallback(() => {
setSelectedSegmentIds([])
}, [])
@ -48,10 +44,7 @@ export const useSegmentSelection = (segments: SegmentDetailModel[]): UseSegmentS
return {
selectedSegmentIds,
isAllSelected,
isSomeSelected,
onSelected,
onSelectedAll,
onSelectedSegmentIdsChange,
onCancelBatchOperation,
clearSelection,
}

View File

@ -2,7 +2,9 @@
import type { FC } from 'react'
import type { SegmentListContextValue } from './segment-list-context'
import type { SegmentImportStatus } from '@/types/dataset'
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import Pagination from '@/app/components/base/pagination'
import {
@ -17,6 +19,7 @@ import { DrawerGroup } from './components/drawer-group'
import MenuBar from './components/menu-bar'
import { FullDocModeContent, GeneralModeContent } from './components/segment-list-content'
import {
mergeCurrentPageSelectedSegmentIds,
useChildSegmentData,
useModalState,
useSearchFilter,
@ -49,6 +52,7 @@ const Completed: FC<ICompletedProps> = ({
importStatus,
archived,
}) => {
const { t } = useTranslation()
const docForm = useDocumentContext(s => s.docForm)
// Pagination state
@ -65,8 +69,8 @@ const Completed: FC<ICompletedProps> = ({
onNewSegmentModalChange,
})
// Selection state (need segments first, so we use a placeholder initially)
const [segmentsForSelection, setSegmentsForSelection] = useState<string[]>([])
// Selection state
const selectionState = useSegmentSelection()
// Invalidation hooks for child segment data
const invalidChunkListAll = useInvalid(useChunkListAllKey)
@ -95,21 +99,29 @@ const Completed: FC<ICompletedProps> = ({
const segmentListDataHook = useSegmentListData({
searchValue: searchFilter.searchValue,
selectedStatus: searchFilter.selectedStatus,
selectedSegmentIds: segmentsForSelection,
selectedSegmentIds: selectionState.selectedSegmentIds,
importStatus,
currentPage,
limit,
onCloseSegmentDetail: modalState.onCloseSegmentDetail,
clearSelection: () => setSegmentsForSelection([]),
clearSelection: selectionState.clearSelection,
})
// Selection state (with actual segments)
const selectionState = useSegmentSelection(segmentListDataHook.segments)
// Sync selection state for segment list data hook
useMemo(() => {
setSegmentsForSelection(selectionState.selectedSegmentIds)
}, [selectionState.selectedSegmentIds])
const segmentIds = useMemo(
() => segmentListDataHook.segments.map(segment => segment.id),
[segmentListDataHook.segments],
)
const currentPageSegmentIdSet = useMemo(() => new Set(segmentIds), [segmentIds])
const currentPageSelectedSegmentIds = useMemo(() => {
return selectionState.selectedSegmentIds.filter(segmentId => currentPageSegmentIdSet.has(segmentId))
}, [currentPageSegmentIdSet, selectionState.selectedSegmentIds])
const handleCurrentPageSelectedSegmentIdsChange = useCallback((nextCurrentPageSelectedSegmentIds: string[]) => {
selectionState.onSelectedSegmentIdsChange(mergeCurrentPageSelectedSegmentIds({
selectedSegmentIds: selectionState.selectedSegmentIds,
currentPageSegmentIds: segmentIds,
nextCurrentPageSelectedSegmentIds,
}))
}, [segmentIds, selectionState.selectedSegmentIds, selectionState.onSelectedSegmentIdsChange])
// Child segment data
const childSegmentDataHook = useChildSegmentData({
@ -153,24 +165,6 @@ const Completed: FC<ICompletedProps> = ({
return (
<SegmentListContext.Provider value={contextValue}>
{/* Menu Bar */}
{!segmentListDataHook.isFullDocMode && (
<MenuBar
isAllSelected={selectionState.isAllSelected}
isSomeSelected={selectionState.isSomeSelected}
onSelectedAll={selectionState.onSelectedAll}
isLoading={segmentListDataHook.isLoadingSegmentList}
totalText={segmentListDataHook.totalText}
statusList={searchFilter.statusList}
selectDefaultValue={searchFilter.selectDefaultValue}
onChangeStatus={searchFilter.onChangeStatus}
inputValue={searchFilter.inputValue}
onInputChange={searchFilter.handleInputChange}
isCollapsed={modalState.isCollapsed}
toggleCollapsed={modalState.toggleCollapsed}
/>
)}
{/* Segment list */}
{segmentListDataHook.isFullDocMode
? (
@ -192,22 +186,40 @@ const Completed: FC<ICompletedProps> = ({
/>
)
: (
<GeneralModeContent
segmentListRef={segmentListDataHook.segmentListRef}
embeddingAvailable={embeddingAvailable}
isLoadingSegmentList={segmentListDataHook.isLoadingSegmentList}
segments={segmentListDataHook.segments}
selectedSegmentIds={selectionState.selectedSegmentIds}
onSelected={selectionState.onSelected}
onChangeSwitch={segmentListDataHook.onChangeSwitch}
onDelete={segmentListDataHook.onDelete}
onClickCard={modalState.onClickCard}
archived={archived}
onDeleteChildChunk={childSegmentDataHook.onDeleteChildChunk}
handleAddNewChildChunk={modalState.handleAddNewChildChunk}
onClickSlice={modalState.onClickSlice}
onClearFilter={searchFilter.onClearFilter}
/>
<CheckboxGroup
aria-label={t('segment.chunk', { ns: 'datasetDocuments' })}
value={currentPageSelectedSegmentIds}
onValueChange={nextSegmentIds => handleCurrentPageSelectedSegmentIdsChange(nextSegmentIds)}
allValues={segmentIds}
className="flex min-h-0 grow flex-col"
>
<MenuBar
hasSelectableSegments={segmentIds.length > 0}
isLoading={segmentListDataHook.isLoadingSegmentList}
totalText={segmentListDataHook.totalText}
statusList={searchFilter.statusList}
selectDefaultValue={searchFilter.selectDefaultValue}
onChangeStatus={searchFilter.onChangeStatus}
inputValue={searchFilter.inputValue}
onInputChange={searchFilter.handleInputChange}
isCollapsed={modalState.isCollapsed}
toggleCollapsed={modalState.toggleCollapsed}
/>
<GeneralModeContent
segmentListRef={segmentListDataHook.segmentListRef}
embeddingAvailable={embeddingAvailable}
isLoadingSegmentList={segmentListDataHook.isLoadingSegmentList}
segments={segmentListDataHook.segments}
onChangeSwitch={segmentListDataHook.onChangeSwitch}
onDelete={segmentListDataHook.onDelete}
onClickCard={modalState.onClickCard}
archived={archived}
onDeleteChildChunk={childSegmentDataHook.onDeleteChildChunk}
handleAddNewChildChunk={modalState.handleAddNewChildChunk}
onClickSlice={modalState.onClickSlice}
onClearFilter={searchFilter.onClearFilter}
/>
</CheckboxGroup>
)}
{/* Pagination */}

View File

@ -15,8 +15,6 @@ import ParagraphListSkeleton from './skeleton/paragraph-list-skeleton'
type ISegmentListProps = {
isLoading: boolean
items: SegmentDetailModel[]
selectedSegmentIds: string[]
onSelected: (segId: string) => void
onClick: (detail: SegmentDetailModel, isEditMode?: boolean) => void
onChangeSwitch: (enabled: boolean, segId?: string) => Promise<void>
onDelete: (segId: string) => Promise<void>
@ -33,8 +31,6 @@ const SegmentList = (
ref,
isLoading,
items,
selectedSegmentIds,
onSelected,
onClick: onClickCard,
onChangeSwitch,
onDelete,
@ -84,9 +80,8 @@ const SegmentList = (
<Checkbox
key={`${segItem.id}-checkbox`}
className="mt-3.5 shrink-0"
checked={selectedSegmentIds.includes(segItem.id)}
value={segItem.id}
aria-label={`${t('segment.chunk', { ns: 'datasetDocuments' })} ${segItem.position}`}
onCheckedChange={() => onSelected(segItem.id)}
/>
<div className="min-w-0 grow">
<SegmentCard

View File

@ -1,6 +1,7 @@
'use client'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
import {
Popover,
PopoverContent,
@ -28,12 +29,6 @@ const TagsFilter = ({
const [searchText, setSearchText] = useState('')
const { tags: options, tagsMap } = useTags()
const filteredOptions = options.filter(option => option.label.toLowerCase().includes(searchText.toLowerCase()))
const handleCheck = (id: string) => {
if (tags.includes(id))
onTagsChange(tags.filter((tag: string) => tag !== id))
else
onTagsChange([...tags, id])
}
const selectedTagsLength = tags.length
return (
@ -85,7 +80,12 @@ const TagsFilter = ({
placeholder={t('searchTags', { ns: 'pluginTags' }) || ''}
/>
</div>
<div className="max-h-[448px] overflow-y-auto p-1">
<CheckboxGroup
aria-label={t('allTags', { ns: 'pluginTags' })}
value={tags}
onValueChange={nextTags => onTagsChange(nextTags)}
className="max-h-[448px] overflow-y-auto p-1"
>
{
filteredOptions.map(option => (
<label
@ -94,8 +94,7 @@ const TagsFilter = ({
>
<Checkbox
className="mr-1"
checked={tags.includes(option.name)}
onCheckedChange={() => handleCheck(option.name)}
value={option.name}
/>
<div className="px-1 system-sm-medium text-text-secondary">
{option.label}
@ -103,7 +102,7 @@ const TagsFilter = ({
</label>
))
}
</div>
</CheckboxGroup>
</div>
</PopoverContent>
</Popover>

View File

@ -1,6 +1,7 @@
'use client'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
@ -29,12 +30,6 @@ const CategoriesFilter = ({
const [searchText, setSearchText] = useState('')
const { categories: options, categoriesMap } = useCategories()
const filteredOptions = options.filter(option => option.name.toLowerCase().includes(searchText.toLowerCase()))
const handleCheck = (id: string) => {
if (value.includes(id))
onChange(value.filter(tag => tag !== id))
else
onChange([...value, id])
}
const selectedTagsLength = value.length
return (
@ -105,7 +100,12 @@ const CategoriesFilter = ({
placeholder={t('searchCategories', { ns: 'plugin' })}
/>
</div>
<div className="max-h-[448px] overflow-y-auto p-1">
<CheckboxGroup
aria-label={t('allCategories', { ns: 'plugin' })}
value={value}
onValueChange={nextValue => onChange(nextValue)}
className="max-h-[448px] overflow-y-auto p-1"
>
{
filteredOptions.map(option => (
<label
@ -114,8 +114,7 @@ const CategoriesFilter = ({
>
<Checkbox
className="mr-1"
checked={value.includes(option.name)}
onCheckedChange={() => handleCheck(option.name)}
value={option.name}
/>
<div className="px-1 system-sm-medium text-text-secondary">
{option.label}
@ -123,7 +122,7 @@ const CategoriesFilter = ({
</label>
))
}
</div>
</CheckboxGroup>
</div>
</PopoverContent>
</Popover>

View File

@ -1,6 +1,7 @@
'use client'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
@ -29,12 +30,6 @@ const TagsFilter = ({
const [searchText, setSearchText] = useState('')
const { tags: options, getTagLabel } = useTags()
const filteredOptions = options.filter(option => option.name.toLowerCase().includes(searchText.toLowerCase()))
const handleCheck = (id: string) => {
if (value.includes(id))
onChange(value.filter(tag => tag !== id))
else
onChange([...value, id])
}
const selectedTagsLength = value.length
return (
@ -103,7 +98,12 @@ const TagsFilter = ({
placeholder={t('searchTags', { ns: 'pluginTags' })}
/>
</div>
<div className="max-h-[448px] overflow-y-auto p-1">
<CheckboxGroup
aria-label={t('allTags', { ns: 'pluginTags' })}
value={value}
onValueChange={nextValue => onChange(nextValue)}
className="max-h-[448px] overflow-y-auto p-1"
>
{
filteredOptions.map(option => (
<label
@ -112,8 +112,7 @@ const TagsFilter = ({
>
<Checkbox
className="mr-1"
checked={value.includes(option.name)}
onCheckedChange={() => handleCheck(option.name)}
value={option.name}
/>
<div className="px-1 system-sm-medium text-text-secondary">
{option.label}
@ -121,7 +120,7 @@ const TagsFilter = ({
</label>
))
}
</div>
</CheckboxGroup>
</div>
</PopoverContent>
</Popover>

View File

@ -1,6 +1,5 @@
import type { FC } from 'react'
import type { Label } from '@/app/components/tools/labels/constant'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
@ -8,7 +7,7 @@ import {
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useDebounceFn } from 'ahooks'
import { useMemo, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Tag03 } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import Input from '@/app/components/base/input'
@ -19,10 +18,10 @@ type LabelSelectorProps = {
onChange: (v: string[]) => void
}
const LabelSelector: FC<LabelSelectorProps> = ({
function LabelSelector({
value,
onChange,
}) => {
}: LabelSelectorProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
@ -39,20 +38,8 @@ const LabelSelector: FC<LabelSelectorProps> = ({
handleSearch()
}
const filteredLabelList = useMemo(() => {
return labelList.filter(label => label.name.includes(searchKeywords))
}, [labelList, searchKeywords])
const selectedLabels = useMemo(() => {
return value.map(v => labelList.find(l => l.name === v)?.label).join(', ')
}, [value, labelList])
const selectLabel = (label: Label) => {
if (value.includes(label.name))
onChange(value.filter(v => v !== label.name))
else
onChange([...value, label.name])
}
const filteredLabelList = labelList.filter(label => label.name.includes(searchKeywords))
const selectedLabels = value.map(v => labelList.find(l => l.name === v)?.label).join(', ')
return (
<Popover open={open} onOpenChange={setOpen}>
@ -86,7 +73,12 @@ const LabelSelector: FC<LabelSelectorProps> = ({
onClear={() => handleKeywordsChange('')}
/>
</div>
<div className="max-h-[264px] overflow-y-auto p-1">
<CheckboxGroup
aria-label={t('createTool.toolInput.labelPlaceholder', { ns: 'tools' })}
value={value}
onValueChange={nextValue => onChange(nextValue)}
className="max-h-[264px] overflow-y-auto p-1"
>
{filteredLabelList.map(label => (
<label
key={label.name}
@ -94,8 +86,7 @@ const LabelSelector: FC<LabelSelectorProps> = ({
>
<Checkbox
className="shrink-0"
checked={value.includes(label.name)}
onCheckedChange={() => selectLabel(label)}
value={label.name}
/>
<div title={label.label} className="grow truncate text-sm leading-5 text-text-secondary">{label.label}</div>
</label>
@ -106,7 +97,7 @@ const LabelSelector: FC<LabelSelectorProps> = ({
<div className="text-xs leading-[14px] text-text-tertiary">{t('tag.noTag', { ns: 'common' })}</div>
</div>
)}
</div>
</CheckboxGroup>
</div>
</PopoverContent>
</div>