fix: improve accessibility in legacy knowledge creation (#42726)

This commit is contained in:
Joel authored and GitHub committed 2026-09-22 09:37:03 +00:00
1 parent db9b9b8993
commit 963a28b497
79 files changed
+1444 -1411

No files matched your search

-45
View File
@@ -1560,19 +1560,6 @@
"count": 3
}
},
"web/app/components/datasets/create/file-preview/index.tsx": {
"eslint-react/set-state-in-effect": {
"count": 1
}
},
"web/app/components/datasets/create/file-uploader/components/file-list-item.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 2
},
"jsx-a11y/no-static-element-interactions": {
"count": 2
}
},
"web/app/components/datasets/create/file-uploader/hooks/use-file-upload.ts": {
"no-restricted-imports": {
"count": 1
@@ -1583,14 +1570,6 @@
"count": 1
}
},
"web/app/components/datasets/create/step-one/components/data-source-type-selector.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/datasets/create/step-one/components/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 3
@@ -1601,14 +1580,6 @@
"count": 1
}
},
"web/app/components/datasets/create/step-one/index.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/datasets/create/step-two/__tests__/index.spec.tsx": {
"dify/require-disable-directive-description": {
"count": 3
@@ -1619,14 +1590,6 @@
"count": 5
}
},
"web/app/components/datasets/create/step-two/components/option-card.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/datasets/create/step-two/hooks/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 6
@@ -2024,14 +1987,6 @@
"count": 1
}
},
"web/app/components/datasets/settings/option-card.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/explore/item-operation/__tests__/index.spec.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
@@ -1,4 +1,5 @@
'use client'
import type { ComponentProps } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import {
Drawer,
@@ -21,6 +22,7 @@ type IFloatRightContainerProps = {
panelClassName?: string
title?: string
mask?: boolean
finalFocus?: ComponentProps<typeof DrawerPopup>['finalFocus']
}
const FloatRightContainer = ({
@@ -32,6 +34,7 @@ const FloatRightContainer = ({
panelClassName,
title,
mask = true,
finalFocus,
}: IFloatRightContainerProps) => {
const { t } = useTranslation()
@@ -50,6 +53,7 @@ const FloatRightContainer = ({
<DrawerBackdrop className={cn(!mask && 'bg-transparent')} />
<DrawerViewport>
<DrawerPopup
finalFocus={finalFocus}
className={cn(
'data-[swipe-direction=right]:w-full data-[swipe-direction=right]:max-w-sm',
panelClassName,
@@ -44,7 +44,7 @@ describe('ParamItem', () => {
it('should render a switch when hasSwitch is true', () => {
render(<ParamItem {...defaultProps} hasSwitch />)
expect(screen.getByRole('switch')).toBeInTheDocument()
expect(screen.getByRole('switch', { name: 'Test Param' })).toBeInTheDocument()
})
it('should not render a switch by default', () => {
+6 -1
View File
@@ -19,6 +19,7 @@ import {
SliderTrack,
} from '@langgenius/dify-ui/slider'
import { Switch } from '@langgenius/dify-ui/switch'
import { useId } from 'react'
type Props = Readonly<{
className?: string
@@ -53,6 +54,7 @@ const ParamItem: FC<Props> = ({
hasSwitch,
onSwitchChange,
}) => {
const labelId = useId()
return (
<Fieldset className={className}>
<FieldsetLegend className="sr-only">{name}</FieldsetLegend>
@@ -60,6 +62,7 @@ const ParamItem: FC<Props> = ({
<div className="flex h-6 items-center">
{hasSwitch && (
<Switch
aria-labelledby={labelId}
size="md"
className="mr-2"
checked={enable}
@@ -69,7 +72,9 @@ const ParamItem: FC<Props> = ({
}}
/>
)}
<span className="mr-1 system-sm-semibold text-text-secondary">{name}</span>
<span id={labelId} className="mr-1 system-sm-semibold text-text-secondary">
{name}
</span>
{!noTooltip && tip && (
<Infotip>
<InfotipTrigger aria-label={tip} />
@@ -41,18 +41,20 @@ const PreviewDocumentPicker: FC<Props> = ({ className, value, files, onChange })
render={
<div
className={cn(
'flex h-6 items-center rounded-md px-1 select-none hover:bg-state-base-hover data-popup-open:bg-state-base-hover',
'flex h-6 max-w-full min-w-0 items-center rounded-md px-1 select-none hover:bg-state-base-hover data-popup-open:bg-state-base-hover',
className,
)}
>
<FileIcon name={name} extension={extension} size="lg" />
<div className="ml-1 flex flex-col items-start">
<div className="flex items-center space-x-0.5">
<span className={cn('max-w-50 truncate system-md-semibold text-text-primary')}>
<div className="ml-1 flex min-w-0 flex-col items-start">
<div className="flex max-w-full items-center space-x-0.5">
<span
className={cn('max-w-50 min-w-0 truncate system-md-semibold text-text-primary')}
>
{' '}
{name || '--'}
</span>
<ArrowIcon className="h-4.5 w-4.5 text-text-primary" />
<ArrowIcon className="h-4.5 w-4.5 shrink-0 text-text-primary" />
</div>
</div>
</div>
@@ -63,7 +65,7 @@ const PreviewDocumentPicker: FC<Props> = ({ className, value, files, onChange })
sideOffset={4}
className="border-none bg-transparent shadow-none"
>
<div className="w-98 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]">
<div className="w-98 max-w-[calc(100vw-2rem)] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]">
{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 })}
@@ -72,7 +74,7 @@ const PreviewDocumentPicker: FC<Props> = ({ className, value, files, onChange })
{files?.length > 0 ? (
<PreviewDocumentList list={files} onChange={handleChange} />
) : (
<div className="mt-2 flex h-25 w-90 items-center justify-center">
<div className="mt-2 flex h-25 w-90 items-center justify-center max-sm:w-full">
<LoadingPlaceholder />
</div>
)}
@@ -1,6 +1,7 @@
'use client'
import type { FC } from 'react'
import type { RetrievalConfig } from '@/types/app'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { RETRIEVE_METHOD } from '@/types/app'
@@ -18,26 +19,33 @@ const EconomicalRetrievalMethodConfig: FC<Props> = ({ disabled = false, value, o
const { t } = useTranslation()
return (
<OptionCard
id={RETRIEVE_METHOD.keywordSearch}
<RadioGroup<RETRIEVE_METHOD>
aria-label={t(($) => $['form.retrievalSetting.method'], { ns: 'datasetSettings' })}
className="block"
value={RETRIEVE_METHOD.keywordSearch}
disabled={disabled}
icon={<span aria-hidden className="i-custom-vender-knowledge-vector-search size-4" />}
iconActiveColor="text-util-colors-purple-purple-600"
title={t(($) => $['retrieval.keyword_search.title'], { ns: 'dataset' })}
description={t(($) => $['retrieval.keyword_search.description'], { ns: 'dataset' })}
isActive
effectColor={EffectColor.purple}
showEffectColor
showChildren
className="gap-x-2"
>
<RetrievalParamConfig
type={RETRIEVE_METHOD.keywordSearch}
value={value}
onChange={onChange}
<OptionCard
id={RETRIEVE_METHOD.keywordSearch}
disabled={disabled}
/>
</OptionCard>
icon={<span aria-hidden className="i-custom-vender-knowledge-vector-search size-4" />}
iconActiveColor="text-util-colors-purple-purple-600"
title={t(($) => $['retrieval.keyword_search.title'], { ns: 'dataset' })}
description={t(($) => $['retrieval.keyword_search.description'], { ns: 'dataset' })}
isActive
effectColor={EffectColor.purple}
showEffectColor
showChildren
className="gap-x-2"
>
<RetrievalParamConfig
type={RETRIEVE_METHOD.keywordSearch}
value={value}
onChange={onChange}
disabled={disabled}
/>
</OptionCard>
</RadioGroup>
)
}
export default React.memo(EconomicalRetrievalMethodConfig)
@@ -1,5 +1,6 @@
import type { RetrievalConfig } from '@/types/app'
import { fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { DEFAULT_WEIGHTED_SCORE, RerankingModeEnum, WeightedScoreEnum } from '@/models/datasets'
import { consoleQuery } from '@/service/console'
@@ -102,6 +103,30 @@ describe('RetrievalMethodConfig', () => {
mockIsRerankDefaultModelValid = true
})
it('selects one retrieval mode with arrow keys and keeps parameter controls outside radios', async () => {
const user = userEvent.setup()
const Owner = () => {
const [value, setValue] = React.useState(createMockRetrievalConfig())
return <RetrievalMethodConfig value={value} onChange={setValue} />
}
render(<Owner />)
const semantic = screen.getByRole('radio', { name: 'dataset.retrieval.semantic_search.title' })
expect(
screen.getByRole('radiogroup', { name: 'datasetSettings.form.retrievalSetting.method' }),
).toContainElement(semantic)
expect(semantic).toBeChecked()
expect(semantic).not.toContainElement(screen.getByRole('button', { name: 'Update Top K' }))
semantic.focus()
await user.keyboard('{ArrowDown}')
const fullText = screen.getByRole('radio', { name: 'dataset.retrieval.full_text_search.title' })
expect(fullText).toHaveFocus()
expect(fullText).toBeChecked()
expect(semantic).not.toBeChecked()
await user.click(screen.getByRole('button', { name: 'Update Top K' }))
expect(fullText).toBeChecked()
expect(screen.getAllByRole('radio', { checked: true })).toHaveLength(1)
})
// Tests for basic rendering
describe('Rendering', () => {
it('should render all three retrieval methods when all are supported', () => {
@@ -213,19 +238,19 @@ describe('RetrievalMethodConfig', () => {
renderComponent({ disabled: true })
// When disabled, clicking should not trigger onChange
const semanticOption = screen
.getByText('dataset.retrieval.semantic_search.title')
.closest('div[class*="cursor"]')
expect(semanticOption).toHaveClass('cursor-not-allowed')
const semanticOption = screen.getByRole('radio', {
name: 'dataset.retrieval.semantic_search.title',
})
expect(semanticOption).toHaveAttribute('aria-disabled', 'true')
})
it('should default disabled to false', () => {
renderComponent()
const semanticOption = screen
.getByText('dataset.retrieval.semantic_search.title')
.closest('div[class*="cursor"]')
expect(semanticOption).not.toHaveClass('cursor-not-allowed')
const semanticOption = screen.getByRole('radio', {
name: 'dataset.retrieval.semantic_search.title',
})
expect(semanticOption).not.toBeDisabled()
})
})
@@ -238,9 +263,9 @@ describe('RetrievalMethodConfig', () => {
onChange,
})
const semanticOption = screen
.getByText('dataset.retrieval.semantic_search.title')
.closest('div[class*="cursor-pointer"]')
const semanticOption = screen.getByRole('radio', {
name: 'dataset.retrieval.semantic_search.title',
})
fireEvent.click(semanticOption!)
expect(onChange).toHaveBeenCalledTimes(1)
@@ -259,9 +284,9 @@ describe('RetrievalMethodConfig', () => {
onChange,
})
const fullTextOption = screen
.getByText('dataset.retrieval.full_text_search.title')
.closest('div[class*="cursor-pointer"]')
const fullTextOption = screen.getByRole('radio', {
name: 'dataset.retrieval.full_text_search.title',
})
fireEvent.click(fullTextOption!)
expect(onChange).toHaveBeenCalledTimes(1)
@@ -280,9 +305,9 @@ describe('RetrievalMethodConfig', () => {
onChange,
})
const hybridOption = screen
.getByText('dataset.retrieval.hybrid_search.title')
.closest('div[class*="cursor-pointer"]')
const hybridOption = screen.getByRole('radio', {
name: 'dataset.retrieval.hybrid_search.title',
})
fireEvent.click(hybridOption!)
expect(onChange).toHaveBeenCalledTimes(1)
@@ -301,9 +326,9 @@ describe('RetrievalMethodConfig', () => {
onChange,
})
const semanticOption = screen
.getByText('dataset.retrieval.semantic_search.title')
.closest('div[class*="cursor-pointer"]')
const semanticOption = screen.getByRole('radio', {
name: 'dataset.retrieval.semantic_search.title',
})
fireEvent.click(semanticOption!)
expect(onChange).not.toHaveBeenCalled()
@@ -317,9 +342,9 @@ describe('RetrievalMethodConfig', () => {
disabled: true,
})
const fullTextOption = screen
.getByText('dataset.retrieval.full_text_search.title')
.closest('div[class*="cursor"]')
const fullTextOption = screen.getByRole('radio', {
name: 'dataset.retrieval.full_text_search.title',
})
fireEvent.click(fullTextOption!)
expect(onChange).not.toHaveBeenCalled()
@@ -358,9 +383,9 @@ describe('RetrievalMethodConfig', () => {
onChange,
})
const semanticOption = screen
.getByText('dataset.retrieval.semantic_search.title')
.closest('div[class*="cursor-pointer"]')
const semanticOption = screen.getByRole('radio', {
name: 'dataset.retrieval.semantic_search.title',
})
fireEvent.click(semanticOption!)
expect(onChange).toHaveBeenCalledWith(
@@ -388,9 +413,9 @@ describe('RetrievalMethodConfig', () => {
onChange,
})
const semanticOption = screen
.getByText('dataset.retrieval.semantic_search.title')
.closest('div[class*="cursor-pointer"]')
const semanticOption = screen.getByRole('radio', {
name: 'dataset.retrieval.semantic_search.title',
})
fireEvent.click(semanticOption!)
expect(onChange).toHaveBeenCalledWith(
@@ -415,9 +440,9 @@ describe('RetrievalMethodConfig', () => {
onChange,
})
const semanticOption = screen
.getByText('dataset.retrieval.semantic_search.title')
.closest('div[class*="cursor-pointer"]')
const semanticOption = screen.getByRole('radio', {
name: 'dataset.retrieval.semantic_search.title',
})
fireEvent.click(semanticOption!)
expect(onChange).toHaveBeenCalledWith(
@@ -440,9 +465,9 @@ describe('RetrievalMethodConfig', () => {
onChange,
})
const hybridOption = screen
.getByText('dataset.retrieval.hybrid_search.title')
.closest('div[class*="cursor-pointer"]')
const hybridOption = screen.getByRole('radio', {
name: 'dataset.retrieval.hybrid_search.title',
})
fireEvent.click(hybridOption!)
expect(onChange).toHaveBeenCalledWith(
@@ -467,9 +492,9 @@ describe('RetrievalMethodConfig', () => {
onChange,
})
const hybridOption = screen
.getByText('dataset.retrieval.hybrid_search.title')
.closest('div[class*="cursor-pointer"]')
const hybridOption = screen.getByRole('radio', {
name: 'dataset.retrieval.hybrid_search.title',
})
fireEvent.click(hybridOption!)
expect(onChange).toHaveBeenCalledWith(
@@ -489,9 +514,9 @@ describe('RetrievalMethodConfig', () => {
onChange,
})
const hybridOption = screen
.getByText('dataset.retrieval.hybrid_search.title')
.closest('div[class*="cursor-pointer"]')
const hybridOption = screen.getByRole('radio', {
name: 'dataset.retrieval.hybrid_search.title',
})
fireEvent.click(hybridOption!)
expect(onChange).toHaveBeenCalledWith(
@@ -532,9 +557,9 @@ describe('RetrievalMethodConfig', () => {
onChange,
})
const hybridOption = screen
.getByText('dataset.retrieval.hybrid_search.title')
.closest('div[class*="cursor-pointer"]')
const hybridOption = screen.getByRole('radio', {
name: 'dataset.retrieval.hybrid_search.title',
})
fireEvent.click(hybridOption!)
expect(onChange).toHaveBeenCalledWith(
@@ -557,9 +582,9 @@ describe('RetrievalMethodConfig', () => {
onChange,
})
const hybridOption = screen
.getByText('dataset.retrieval.hybrid_search.title')
.closest('div[class*="cursor-pointer"]')
const hybridOption = screen.getByRole('radio', {
name: 'dataset.retrieval.hybrid_search.title',
})
fireEvent.click(hybridOption!)
expect(onChange).toHaveBeenCalledWith(
@@ -587,9 +612,9 @@ describe('RetrievalMethodConfig', () => {
const { rerender } = render(<RetrievalMethodConfig value={value1} onChange={onChange} />)
const semanticOption = screen
.getByText('dataset.retrieval.semantic_search.title')
.closest('div[class*="cursor-pointer"]')
const semanticOption = screen.getByRole('radio', {
name: 'dataset.retrieval.semantic_search.title',
})
fireEvent.click(semanticOption!)
expect(onChange).toHaveBeenCalledTimes(1)
@@ -609,9 +634,9 @@ describe('RetrievalMethodConfig', () => {
rerender(<RetrievalMethodConfig value={value} onChange={onChange2} />)
const semanticOption = screen
.getByText('dataset.retrieval.semantic_search.title')
.closest('div[class*="cursor-pointer"]')
const semanticOption = screen.getByRole('radio', {
name: 'dataset.retrieval.semantic_search.title',
})
fireEvent.click(semanticOption!)
expect(onChange1).not.toHaveBeenCalled()
@@ -667,9 +692,9 @@ describe('RetrievalMethodConfig', () => {
onChange,
})
const semanticOption = screen
.getByText('dataset.retrieval.semantic_search.title')
.closest('div[class*="cursor-pointer"]')
const semanticOption = screen.getByRole('radio', {
name: 'dataset.retrieval.semantic_search.title',
})
fireEvent.click(semanticOption!)
expect(onChange).toHaveBeenCalledWith(
@@ -698,9 +723,9 @@ describe('RetrievalMethodConfig', () => {
onChange,
})
const hybridOption = screen
.getByText('dataset.retrieval.hybrid_search.title')
.closest('div[class*="cursor-pointer"]')
const hybridOption = screen.getByRole('radio', {
name: 'dataset.retrieval.hybrid_search.title',
})
fireEvent.click(hybridOption!)
expect(onChange).toHaveBeenCalledWith(
@@ -729,9 +754,9 @@ describe('RetrievalMethodConfig', () => {
onChange,
})
const hybridOption = screen
.getByText('dataset.retrieval.hybrid_search.title')
.closest('div[class*="cursor-pointer"]')
const hybridOption = screen.getByRole('radio', {
name: 'dataset.retrieval.hybrid_search.title',
})
fireEvent.click(hybridOption!)
expect(onChange).toHaveBeenCalledWith(
@@ -751,12 +776,12 @@ describe('RetrievalMethodConfig', () => {
onChange,
})
const fullTextOption = screen
.getByText('dataset.retrieval.full_text_search.title')
.closest('div[class*="cursor-pointer"]')
const hybridOption = screen
.getByText('dataset.retrieval.hybrid_search.title')
.closest('div[class*="cursor-pointer"]')
const fullTextOption = screen.getByRole('radio', {
name: 'dataset.retrieval.full_text_search.title',
})
const hybridOption = screen.getByRole('radio', {
name: 'dataset.retrieval.hybrid_search.title',
})
// Rapid clicks
fireEvent.click(fullTextOption!)
@@ -829,18 +854,18 @@ describe('RetrievalMethodConfig', () => {
describe('disabled prop variations', () => {
it('should handle disabled=true', () => {
renderComponent({ disabled: true })
const option = screen
.getByText('dataset.retrieval.semantic_search.title')
.closest('div[class*="cursor"]')
expect(option).toHaveClass('cursor-not-allowed')
const option = screen.getByRole('radio', {
name: 'dataset.retrieval.semantic_search.title',
})
expect(option).toHaveAttribute('aria-disabled', 'true')
})
it('should handle disabled=false', () => {
renderComponent({ disabled: false })
const option = screen
.getByText('dataset.retrieval.semantic_search.title')
.closest('div[class*="cursor"]')
expect(option).toHaveClass('cursor-pointer')
const option = screen.getByRole('radio', {
name: 'dataset.retrieval.semantic_search.title',
})
expect(option).not.toBeDisabled()
})
})
@@ -884,7 +909,7 @@ describe('RetrievalMethodConfig', () => {
// The hybrid search option should have the recommended badge
// This is verified by checking the isRecommended prop passed to OptionCard
const hybridTitle = screen.getByText('dataset.retrieval.hybrid_search.title')
const hybridCard = hybridTitle.closest('div[class*="cursor"]')
const hybridCard = hybridTitle.closest('[role=radio]')
// Should contain recommended badge from OptionCard
expect(
@@ -1,6 +1,7 @@
'use client'
import type { FC } from 'react'
import type { RetrievalConfig } from '@/types/app'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { useQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useCallback } from 'react'
@@ -105,7 +106,13 @@ const RetrievalMethodConfig: FC<Props> = ({
)
return (
<div className="flex flex-col gap-y-2">
<RadioGroup<RETRIEVE_METHOD>
aria-label={t(($) => $['form.retrievalSetting.method'], { ns: 'datasetSettings' })}
value={value.search_method}
onValueChange={onSwitch}
disabled={disabled}
className="flex flex-col items-stretch gap-x-0 gap-y-2"
>
{supportedRetrievalMethods.has(RETRIEVE_METHOD.semantic) && (
<OptionCard
id={RETRIEVE_METHOD.semantic}
@@ -115,7 +122,6 @@ const RetrievalMethodConfig: FC<Props> = ({
title={t(($) => $['retrieval.semantic_search.title'], { ns: 'dataset' })}
description={t(($) => $['retrieval.semantic_search.description'], { ns: 'dataset' })}
isActive={value.search_method === RETRIEVE_METHOD.semantic}
onClick={onSwitch}
effectColor={EffectColor.purple}
showEffectColor
showChildren={value.search_method === RETRIEVE_METHOD.semantic}
@@ -139,7 +145,6 @@ const RetrievalMethodConfig: FC<Props> = ({
title={t(($) => $['retrieval.full_text_search.title'], { ns: 'dataset' })}
description={t(($) => $['retrieval.full_text_search.description'], { ns: 'dataset' })}
isActive={value.search_method === RETRIEVE_METHOD.fullText}
onClick={onSwitch}
effectColor={EffectColor.purple}
showEffectColor
showChildren={value.search_method === RETRIEVE_METHOD.fullText}
@@ -163,7 +168,6 @@ const RetrievalMethodConfig: FC<Props> = ({
title={t(($) => $['retrieval.hybrid_search.title'], { ns: 'dataset' })}
description={t(($) => $['retrieval.hybrid_search.description'], { ns: 'dataset' })}
isActive={value.search_method === RETRIEVE_METHOD.hybrid}
onClick={onSwitch}
effectColor={EffectColor.purple}
showEffectColor
isRecommended
@@ -179,7 +183,7 @@ const RetrievalMethodConfig: FC<Props> = ({
/>
</OptionCard>
)}
</div>
</RadioGroup>
)
}
export default React.memo(RetrievalMethodConfig)
@@ -145,7 +145,9 @@ describe('RetrievalParamConfig', () => {
/>,
)
expect(screen.getByRole('switch')).toBeChecked()
expect(
screen.getByRole('switch', { name: 'common.modelProvider.rerankModel.key' }),
).toBeChecked()
})
it('should render model selector when reranking is enabled', () => {
@@ -211,7 +213,7 @@ describe('RetrievalParamConfig', () => {
/>,
)
fireEvent.click(screen.getByRole('switch'))
fireEvent.click(screen.getByRole('switch', { name: 'common.modelProvider.rerankModel.key' }))
expect(mockOnChange).toHaveBeenCalledWith({
...config,
@@ -230,7 +232,7 @@ describe('RetrievalParamConfig', () => {
/>,
)
fireEvent.click(screen.getByRole('switch'))
fireEvent.click(screen.getByRole('switch', { name: 'common.modelProvider.rerankModel.key' }))
expect(mockNotify).toHaveBeenCalledWith('workflow.errorMsg.rerankModelRequired')
})
@@ -354,7 +356,9 @@ describe('RetrievalParamConfig', () => {
/>,
)
expect(screen.getByRole('switch')).toBeChecked()
expect(
screen.getByRole('switch', { name: 'common.modelProvider.rerankModel.key' }),
).toBeChecked()
})
it('should hide score threshold when reranking is disabled for full text search', () => {
@@ -6,7 +6,7 @@ import { Infotip, InfotipContent, InfotipTrigger } from '@langgenius/dify-ui/inf
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { Switch } from '@langgenius/dify-ui/switch'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import { useCallback, useId, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import WeightedScore from '@/app/components/app/configuration/dataset-config/params-config/weighted-score'
import ScoreThresholdItem from '@/app/components/base/param-item/score-threshold-item'
@@ -40,6 +40,7 @@ const RetrievalParamConfig: FC<Props> = ({
onChange,
}) => {
const { t } = useTranslation()
const rerankLabelId = useId()
const canToggleRerankModalEnable = type !== RETRIEVE_METHOD.hybrid
const isEconomical = type === RETRIEVE_METHOD.keywordSearch
const isHybridSearch = type === RETRIEVE_METHOD.hybrid
@@ -111,12 +112,13 @@ const RetrievalParamConfig: FC<Props> = ({
]
return (
<div>
<div className="@container/retrieval">
{!isEconomical && !isHybridSearch && (
<div>
<div className="mb-2 flex items-center space-x-2">
{canToggleRerankModalEnable && (
<Switch
aria-labelledby={rerankLabelId}
size="md"
checked={value.reranking_enable}
onCheckedChange={handleToggleRerankEnable}
@@ -124,7 +126,7 @@ const RetrievalParamConfig: FC<Props> = ({
/>
)}
<div className="flex items-center">
<span className="mr-0.5 system-sm-semibold text-text-secondary">
<span id={rerankLabelId} className="mr-0.5 system-sm-semibold text-text-secondary">
{t(($) => $['modelProvider.rerankModel.key'], { ns: 'common' })}
</span>
<Infotip>
@@ -181,7 +183,7 @@ const RetrievalParamConfig: FC<Props> = ({
</div>
)}
{!isHybridSearch && (
<div className={cn(!isEconomical && 'mt-4', 'space-between flex space-x-4')}>
<div className={cn(!isEconomical && 'mt-4', 'flex gap-4 @max-[28rem]/retrieval:flex-col')}>
<TopKItem
className="grow"
value={value.top_k}
@@ -227,7 +229,7 @@ const RetrievalParamConfig: FC<Props> = ({
aria-label={t(($) => $['modelProvider.rerankModel.key'], { ns: 'common' })}
value={value.reranking_mode}
onValueChange={handleChangeRerankMode}
className="mb-4 flex gap-2"
className="mb-4 flex gap-2 @max-[28rem]/retrieval:flex-col"
>
{rerankingModeOptions.map((option) => (
<RadioCard<RerankingModeEnum>
@@ -314,7 +316,9 @@ const RetrievalParamConfig: FC<Props> = ({
)}
</>
)}
<div className={cn(!isEconomical && 'mt-4', 'space-between flex space-x-6')}>
<div
className={cn(!isEconomical && 'mt-4', 'flex gap-6 @max-[28rem]/retrieval:flex-col')}
>
<TopKItem
className="grow"
value={value.top_k}
@@ -103,7 +103,7 @@ describe('EmbeddingProcess', () => {
render(<EmbeddingProcess datasetId="dataset-1" batchId="batch-1" />)
expect(screen.getByText('datasetDocuments.embedding.processing')).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('datasetDocuments.embedding.processing')
})
it('shows that document indexing has completed', () => {
@@ -111,7 +111,7 @@ describe('EmbeddingProcess', () => {
render(<EmbeddingProcess datasetId="dataset-1" batchId="batch-1" />)
expect(screen.getByText('datasetDocuments.embedding.completed')).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('datasetDocuments.embedding.completed')
})
it('shows the vector-space admission alert after processing completes', () => {
@@ -131,7 +131,7 @@ describe('EmbeddingProcess', () => {
render(<EmbeddingProcess datasetId="dataset-1" batchId="batch-1" />)
expect(screen.getByText('datasetDocuments.embedding.completed')).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('datasetDocuments.embedding.completed')
expect(
screen.getByText('vector space admission alert 61MB / 50MB without upgrade'),
).toBeInTheDocument()
@@ -47,6 +47,10 @@ describe('IndexingProgressItem', () => {
// Name appears in both the file-icon mock and the display div; verify at least one
expect(screen.getAllByText('test.pdf').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('50%')).toBeInTheDocument()
expect(screen.getByRole('progressbar', { name: 'test.pdf' })).toHaveAttribute(
'aria-valuenow',
'50',
)
})
it('should render file icon for FILE source type', () => {
@@ -74,16 +78,21 @@ describe('IndexingProgressItem', () => {
expect(screen.getByTestId('notion-icon')).toBeInTheDocument()
})
it('should render success icon for completed status', () => {
render(
it('announces a file completing after processing without retaining its progress bar', () => {
const { rerender } = render(<IndexingProgressItem detail={makeDetail()} name="done.pdf" />)
const status = screen.getByRole('status')
expect(status).toBeEmptyDOMElement()
expect(screen.getByRole('progressbar', { name: 'done.pdf' })).toBeInTheDocument()
rerender(
<IndexingProgressItem
detail={makeDetail({ indexing_status: 'completed' })}
name="done.pdf"
/>,
)
// No progress percentage should be shown for completed
expect(screen.queryByText(/%/)).not.toBeInTheDocument()
expect(status).toHaveTextContent('done.pdf: datasetDocuments.embedding.completed')
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
})
it('opens the full indexing error from the warning button', async () => {
@@ -95,7 +104,7 @@ describe('IndexingProgressItem', () => {
/>,
)
expect(screen.queryByText('Parse failed')).not.toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('broken.pdf: common.error: Parse failed')
await user.click(screen.getByRole('button', { name: 'common.error' }))
expect(await screen.findByRole('dialog')).toHaveTextContent('Parse failed')
await user.keyboard('{Escape}')
@@ -38,10 +38,14 @@ const StatusHeader: FC<{ isEmbedding: boolean; isCompleted: boolean }> = ({
const { t } = useTranslation()
return (
<div className="flex items-center gap-x-1 system-md-semibold-uppercase text-text-secondary">
<div
role="status"
aria-atomic="true"
className="flex items-center gap-x-1 system-md-semibold-uppercase text-text-secondary"
>
{isEmbedding && (
<>
<RiLoader2Fill className="size-4 animate-spin" />
<RiLoader2Fill aria-hidden="true" className="size-4 animate-spin" />
<span>{t(($) => $['embedding.processing'], { ns: 'datasetDocuments' })}</span>
</>
)}
@@ -59,7 +63,7 @@ const ActionButtons: FC<{
const { t } = useTranslation()
return (
<div className="mt-6 flex items-center gap-x-2 py-2">
<div className="mt-6 flex flex-wrap items-center gap-2 py-2">
<Link
href={apiReferenceUrl}
target="_blank"
@@ -3,6 +3,7 @@ import type { IndexingStatusResponse } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import { Infotip, InfotipContent, InfotipTrigger } from '@langgenius/dify-ui/infotip'
import { RiCheckboxCircleFill } from '@remixicon/react'
import { useId } from 'react'
import { useTranslation } from 'react-i18next'
import NotionIcon from '@/app/components/base/notion-icon'
import PriorityLabel from '@/app/components/billing/priority-label'
@@ -22,7 +23,7 @@ const StatusIcon: FC<{ status: string; error?: string }> = ({ status, error }) =
const { t } = useTranslation()
if (status === 'completed')
return <RiCheckboxCircleFill className="size-4 shrink-0 text-text-success" />
return <RiCheckboxCircleFill aria-hidden="true" className="size-4 shrink-0 text-text-success" />
if (status === 'error') {
const errorLabel = error || t(($) => $.error, { ns: 'common' })
@@ -74,9 +75,19 @@ const IndexingProgressItem: FC<IndexingProgressItemProps> = ({
sourceType,
notionIcon,
}) => {
const { t } = useTranslation()
const nameId = useId()
const isEmbedding = isSourceEmbedding(detail)
const percent = getSourcePercent(detail)
const isError = detail.indexing_status === 'error'
const statusText =
detail.indexing_status === 'completed'
? t(($) => $['embedding.completed'], { ns: 'datasetDocuments' })
: isError
? `${t(($) => $.error, { ns: 'common' })}: ${detail.error || ''}`
: detail.indexing_status === 'paused'
? t(($) => $['embedding.paused'], { ns: 'datasetDocuments' })
: ''
return (
<div
@@ -88,13 +99,23 @@ const IndexingProgressItem: FC<IndexingProgressItemProps> = ({
{isEmbedding && (
<div
className="absolute top-0 left-0 h-full min-w-0.5 border-r-2 border-r-components-progress-bar-progress-highlight bg-components-progress-bar-progress"
role="progressbar"
aria-labelledby={nameId}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent}
style={{ width: `${percent}%` }}
/>
)}
<span role="status" aria-atomic="true" className="sr-only">
{statusText && `${name}: ${statusText}`}
</span>
<div className="z-1 flex h-full items-center gap-1 pr-2 pl-1.5">
<SourceTypeIcon sourceType={sourceType} name={name} notionIcon={notionIcon} />
<div className="flex w-0 grow items-center gap-1" title={name}>
<div className="truncate system-xs-medium text-text-secondary">{name}</div>
<div id={nameId} className="truncate system-xs-medium text-text-secondary">
{name}
</div>
<PriorityLabel className="ml-0" />
</div>
{isEmbedding && <div className="shrink-0 text-xs text-text-secondary">{`${percent}%`}</div>}
@@ -1,628 +1,129 @@
import type { MockedFunction } from 'vite-plus/test'
import type { TextContentResponse } from '@dify/contracts/api/console/files/types.gen'
import type { ReactElement } from 'react'
import type { CustomFile as File } from '@/models/datasets'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { fetchFilePreview } from '@/service/common'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import FilePreview from '../index'
// Mock the fetchFilePreview service
vi.mock('@/service/common', () => ({
fetchFilePreview: vi.fn(),
const mocks = vi.hoisted(() => ({
request: vi.fn<(url: string) => Promise<Response>>(),
}))
vi.mock('@/service/base', () => ({ request: mocks.request }))
const mockFetchFilePreview = fetchFilePreview as MockedFunction<typeof fetchFilePreview>
// Factory function to create mock file objects
const createMockFile = (overrides: Partial<File> = {}): File => {
const fileName = overrides.name ?? 'test-file.txt'
// Create a plain object that looks like a File with CustomFile properties
// We can't use Object.assign on a real File because 'name' is a getter-only property
return {
name: fileName,
size: 1024,
type: 'text/plain',
lastModified: Date.now(),
const createFile = (overrides: Partial<File> = {}): File =>
Object.assign(new globalThis.File(['Preview'], 'document.txt', { type: 'text/plain' }), {
id: 'file-123',
extension: 'txt',
mime_type: 'text/plain',
created_by: 'user-1',
created_at: Date.now(),
...overrides,
} as File
})
const response = (content: string) => Response.json({ content } satisfies TextContentResponse)
const renderPreview = (ui: ReactElement) => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(ui, {
wrapper: ({ children }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
),
})
}
// Helper to render FilePreview with default props
const renderFilePreview = (props: Partial<{ file?: File; hidePreview: () => void }> = {}) => {
const defaultProps = {
file: createMockFile(),
hidePreview: vi.fn(),
...props,
}
return {
...render(<FilePreview {...defaultProps} />),
props: defaultProps,
}
}
// Helper to find the loading spinner element
const findLoadingSpinner = (container: HTMLElement) => {
return within(container).queryByRole('progressbar')
}
// FilePreview Component Tests
// Exercise generated query options and transport; mock only the HTTP boundary.
describe('FilePreview', () => {
beforeEach(() => {
vi.clearAllMocks()
// Default successful API response
mockFetchFilePreview.mockResolvedValue({ content: 'Preview content here' })
mocks.request.mockResolvedValue(response('Preview content'))
})
// Rendering Tests - Verify component renders properly
describe('Rendering', () => {
it('should render file preview header', async () => {
renderFilePreview()
it('loads the selected file through the generated endpoint and announces success', async () => {
let resolveRequest!: (value: Response) => void
mocks.request.mockReturnValue(
new Promise((resolve) => {
resolveRequest = resolve
}),
)
renderPreview(<FilePreview file={createFile()} hidePreview={vi.fn()} />)
expect(screen.getByText('datasetCreation.stepOne.filePreview'))!.toBeInTheDocument()
})
expect(
screen.getByRole('region', { name: 'datasetCreation.stepOne.filePreview' }),
).toBeInTheDocument()
expect(
screen.getByRole('heading', { level: 2, name: 'datasetCreation.stepOne.filePreview' }),
).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('document.txt: common.loading')
expect(screen.getByRole('progressbar')).toBeInTheDocument()
await waitFor(() => expect(mocks.request).toHaveBeenCalledOnce())
expect(mocks.request.mock.calls[0]?.[0]).toContain('/files/file-123/preview')
it('should render close button with XMarkIcon', async () => {
renderFilePreview()
const closeButton = screen.getByRole('button', { name: /operation\.close$/ })
expect(closeButton)!.toBeInTheDocument()
const xMarkIcon = closeButton?.querySelector('svg')
expect(xMarkIcon)!.toBeInTheDocument()
})
it('should render file name without extension', async () => {
const file = createMockFile({ name: 'document.pdf' })
renderFilePreview({ file })
await waitFor(() => {
expect(screen.getByText('document'))!.toBeInTheDocument()
})
})
it('should render file extension', async () => {
const file = createMockFile({ extension: 'pdf' })
renderFilePreview({ file })
expect(screen.getByText('.pdf'))!.toBeInTheDocument()
})
await act(async () => resolveRequest(response('Preview content')))
expect(await screen.findByText('Preview content')).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('document.txt: common.api.success')
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
})
// Loading State Tests
describe('Loading State', () => {
it('should show loading indicator initially', async () => {
// Arrange - Delay API response to keep loading state
mockFetchFilePreview.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ content: 'test' }), 100)),
it.each([undefined, createFile({ id: undefined }), createFile({ id: '' })])(
'does not request or announce a preview without an uploaded file ID (%#)',
(file) => {
renderPreview(<FilePreview file={file} hidePreview={vi.fn()} />)
expect(mocks.request).not.toHaveBeenCalled()
expect(screen.getByRole('status')).toBeEmptyDOMElement()
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
},
)
it('starts the preview when an upload receives its file ID', async () => {
const { rerender } = renderPreview(
<FilePreview file={createFile({ id: undefined })} hidePreview={vi.fn()} />,
)
expect(mocks.request).not.toHaveBeenCalled()
rerender(<FilePreview file={createFile()} hidePreview={vi.fn()} />)
expect(await screen.findByText('Preview content')).toBeInTheDocument()
expect(mocks.request).toHaveBeenCalledOnce()
})
it('announces failure and stops loading when the request fails', async () => {
mocks.request.mockResolvedValue(new Response(null, { status: 500 }))
renderPreview(<FilePreview file={createFile()} hidePreview={vi.fn()} />)
await waitFor(() =>
expect(screen.getByRole('status')).toHaveTextContent('document.txt: common.api.actionFailed'),
)
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
expect(mocks.request).toHaveBeenCalledOnce()
})
it('keeps the current preview when an earlier file request finishes last', async () => {
let resolveFirst!: (value: Response) => void
mocks.request
.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirst = resolve
}),
)
const { container } = renderFilePreview()
const loadingElement = findLoadingSpinner(container)
expect(loadingElement)!.toBeInTheDocument()
})
it('should hide loading indicator after content loads', async () => {
mockFetchFilePreview.mockResolvedValue({ content: 'Loaded content' })
const { container } = renderFilePreview()
await waitFor(() => {
expect(screen.getByText('Loaded content'))!.toBeInTheDocument()
})
// Loading should be gone
const loadingElement = findLoadingSpinner(container)
expect(loadingElement).not.toBeInTheDocument()
})
it('should show loading when file changes', async () => {
const file1 = createMockFile({ id: 'file-1', name: 'file1.txt' })
const file2 = createMockFile({ id: 'file-2', name: 'file2.txt' })
let resolveFirst: (value: { content: string }) => void
let resolveSecond: (value: { content: string }) => void
mockFetchFilePreview
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirst = resolve
}),
)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSecond = resolve
}),
)
// Act - Initial render
const { rerender, container } = render(<FilePreview file={file1} hidePreview={vi.fn()} />)
// First file loading - spinner should be visible
// First file loading - spinner should be visible
expect(findLoadingSpinner(container))!.toBeInTheDocument()
// Resolve first file
await act(async () => {
resolveFirst({ content: 'Content 1' })
})
await waitFor(() => {
expect(screen.getByText('Content 1'))!.toBeInTheDocument()
})
// Rerender with new file
rerender(<FilePreview file={file2} hidePreview={vi.fn()} />)
// Should show loading again
await waitFor(() => {
expect(findLoadingSpinner(container))!.toBeInTheDocument()
})
// Resolve second file
await act(async () => {
resolveSecond({ content: 'Content 2' })
})
await waitFor(() => {
expect(screen.getByText('Content 2'))!.toBeInTheDocument()
})
})
.mockResolvedValueOnce(response('Latest preview'))
const { rerender } = renderPreview(
<FilePreview file={createFile({ id: 'first' })} hidePreview={vi.fn()} />,
)
await waitFor(() => expect(mocks.request).toHaveBeenCalledOnce())
rerender(<FilePreview file={createFile({ id: 'second' })} hidePreview={vi.fn()} />)
expect(await screen.findByText('Latest preview')).toBeInTheDocument()
expect(mocks.request.mock.calls[1]?.[0]).toContain('/files/second/preview')
await act(async () => resolveFirst(response('Stale preview')))
expect(screen.getByText('Latest preview')).toBeInTheDocument()
expect(screen.queryByText('Stale preview')).not.toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('common.api.success')
})
// API Call Tests
describe('API Calls', () => {
it('should call fetchFilePreview with correct fileID', async () => {
const file = createMockFile({ id: 'test-file-id' })
renderFilePreview({ file })
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledWith({ fileID: 'test-file-id' })
})
})
it('should not call fetchFilePreview when file is undefined', async () => {
renderFilePreview({ file: undefined })
expect(mockFetchFilePreview).not.toHaveBeenCalled()
})
it('should not call fetchFilePreview when file has no id', async () => {
const file = createMockFile({ id: undefined })
renderFilePreview({ file })
expect(mockFetchFilePreview).not.toHaveBeenCalled()
})
it('should call fetchFilePreview again when file changes', async () => {
const file1 = createMockFile({ id: 'file-1' })
const file2 = createMockFile({ id: 'file-2' })
const { rerender } = render(<FilePreview file={file1} hidePreview={vi.fn()} />)
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledWith({ fileID: 'file-1' })
})
rerender(<FilePreview file={file2} hidePreview={vi.fn()} />)
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledWith({ fileID: 'file-2' })
expect(mockFetchFilePreview).toHaveBeenCalledTimes(2)
})
})
it('should handle API success and display content', async () => {
mockFetchFilePreview.mockResolvedValue({ content: 'File preview content from API' })
renderFilePreview()
await waitFor(() => {
expect(screen.getByText('File preview content from API'))!.toBeInTheDocument()
})
})
it('should keep the preview header visible when loading fails', async () => {
mockFetchFilePreview.mockRejectedValue(new Error('Network error'))
renderFilePreview()
await waitFor(() => {
expect(screen.getByText('datasetCreation.stepOne.filePreview'))!.toBeInTheDocument()
})
})
it('should handle empty content response', async () => {
mockFetchFilePreview.mockResolvedValue({ content: '' })
const { container } = renderFilePreview()
// Assert - Should still render without loading
await waitFor(() => {
const loadingElement = findLoadingSpinner(container)
expect(loadingElement).not.toBeInTheDocument()
})
})
it('announces success for an empty preview', async () => {
mocks.request.mockResolvedValue(response(''))
renderPreview(<FilePreview file={createFile()} hidePreview={vi.fn()} />)
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('common.api.success'))
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
})
describe('User Interactions', () => {
it('should call hidePreview when close button is clicked', async () => {
const hidePreview = vi.fn()
renderFilePreview({ hidePreview })
fireEvent.click(screen.getByRole('button', { name: /operation\.close$/ }))
expect(hidePreview).toHaveBeenCalledTimes(1)
})
it('should call hidePreview with event object when clicked', async () => {
const hidePreview = vi.fn()
renderFilePreview({ hidePreview })
fireEvent.click(screen.getByRole('button', { name: /operation\.close$/ }))
// Assert - onClick receives the event object
expect(hidePreview).toHaveBeenCalled()
expect(hidePreview.mock.calls[0]![0]).toBeDefined()
})
it('should handle multiple clicks on close button', async () => {
const hidePreview = vi.fn()
renderFilePreview({ hidePreview })
const closeButton = screen.getByRole('button', { name: /operation\.close$/ })
fireEvent.click(closeButton)
fireEvent.click(closeButton)
fireEvent.click(closeButton)
expect(hidePreview).toHaveBeenCalledTimes(3)
})
})
describe('State Management', () => {
it('should initialize with loading state true', async () => {
// Arrange - Keep loading indefinitely (never resolves)
mockFetchFilePreview.mockImplementation(
() =>
new Promise(() => {
/* intentionally empty */
}),
)
const { container } = renderFilePreview()
const loadingElement = findLoadingSpinner(container)
expect(loadingElement)!.toBeInTheDocument()
})
it('should update previewContent state after successful fetch', async () => {
mockFetchFilePreview.mockResolvedValue({ content: 'New preview content' })
renderFilePreview()
await waitFor(() => {
expect(screen.getByText('New preview content'))!.toBeInTheDocument()
})
})
it('should reset loading to true when file changes', async () => {
const file1 = createMockFile({ id: 'file-1' })
const file2 = createMockFile({ id: 'file-2' })
mockFetchFilePreview.mockResolvedValueOnce({ content: 'Content 1' }).mockImplementationOnce(
() =>
new Promise(() => {
/* never resolves */
}),
)
const { rerender, container } = render(<FilePreview file={file1} hidePreview={vi.fn()} />)
await waitFor(() => {
expect(screen.getByText('Content 1'))!.toBeInTheDocument()
})
// Change file
rerender(<FilePreview file={file2} hidePreview={vi.fn()} />)
// Assert - Loading should be shown again
await waitFor(() => {
const loadingElement = findLoadingSpinner(container)
expect(loadingElement)!.toBeInTheDocument()
})
})
it('should preserve content until new content loads', async () => {
const file1 = createMockFile({ id: 'file-1' })
const file2 = createMockFile({ id: 'file-2' })
let resolveSecond: (value: { content: string }) => void
mockFetchFilePreview.mockResolvedValueOnce({ content: 'Content 1' }).mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSecond = resolve
}),
)
const { rerender } = render(<FilePreview file={file1} hidePreview={vi.fn()} />)
await waitFor(() => {
expect(screen.getByText('Content 1'))!.toBeInTheDocument()
})
// Change file - loading should replace content
rerender(<FilePreview file={file2} hidePreview={vi.fn()} />)
// Resolve second fetch
await act(async () => {
resolveSecond({ content: 'Content 2' })
})
await waitFor(() => {
expect(screen.getByText('Content 2'))!.toBeInTheDocument()
expect(screen.queryByText('Content 1')).not.toBeInTheDocument()
})
})
})
describe('Props', () => {
describe('file prop', () => {
it('should render correctly with file prop', async () => {
const file = createMockFile({ name: 'my-document.pdf', extension: 'pdf' })
renderFilePreview({ file })
expect(screen.getByText('my-document'))!.toBeInTheDocument()
expect(screen.getByText('.pdf'))!.toBeInTheDocument()
})
it('should render correctly without file prop', async () => {
renderFilePreview({ file: undefined })
// Assert - Header should still render
// Assert - Header should still render
expect(screen.getByText('datasetCreation.stepOne.filePreview'))!.toBeInTheDocument()
})
it('should handle file with multiple dots in name', async () => {
const file = createMockFile({ name: 'my.document.v2.pdf' })
renderFilePreview({ file })
// Assert - Should join all parts except last with comma
// Assert - Should join all parts except last with comma
expect(screen.getByText('my,document,v2'))!.toBeInTheDocument()
})
it('should handle file with no extension in name', async () => {
const file = createMockFile({ name: 'README' })
const { container } = renderFilePreview({ file })
// Assert - getFileName returns empty for single segment, but component still renders
const fileNameElement = container.querySelector('[class*="fileName"]')
expect(fileNameElement)!.toBeInTheDocument()
// The first span (file name) should be empty
const fileNameSpan = fileNameElement?.querySelector('span:first-child')
expect(fileNameSpan?.textContent).toBe('')
})
})
describe('hidePreview prop', () => {
it('should accept hidePreview callback', async () => {
const hidePreview = vi.fn()
renderFilePreview({ hidePreview })
// Assert - No errors thrown
// Assert - No errors thrown
expect(screen.getByText('datasetCreation.stepOne.filePreview'))!.toBeInTheDocument()
})
})
})
describe('Edge Cases', () => {
it('should handle file with undefined id', async () => {
const file = createMockFile({ id: undefined })
renderFilePreview({ file })
expect(mockFetchFilePreview).not.toHaveBeenCalled()
})
it('should handle file with empty string id', async () => {
const file = createMockFile({ id: '' })
renderFilePreview({ file })
// Assert - Empty string is falsy, should not call API
expect(mockFetchFilePreview).not.toHaveBeenCalled()
})
it('should handle very long file names', async () => {
const longName = `${'a'.repeat(200)}.pdf`
const file = createMockFile({ name: longName })
renderFilePreview({ file })
expect(screen.getByText('a'.repeat(200)))!.toBeInTheDocument()
})
it('should handle file with special characters in name', async () => {
const file = createMockFile({ name: 'file-with_special@#$%.txt' })
renderFilePreview({ file })
expect(screen.getByText('file-with_special@#$%'))!.toBeInTheDocument()
})
it('should handle very long preview content', async () => {
const longContent = 'x'.repeat(10000)
mockFetchFilePreview.mockResolvedValue({ content: longContent })
renderFilePreview()
await waitFor(() => {
expect(screen.getByText(longContent))!.toBeInTheDocument()
})
})
it('should handle preview content with special characters safely', async () => {
const specialContent = '<script>alert("xss")</script>\n\t& < > "'
mockFetchFilePreview.mockResolvedValue({ content: specialContent })
const { container } = renderFilePreview()
// Assert - Should render as text, not execute scripts
await waitFor(() => {
const contentDiv = container.querySelector('[class*="fileContent"]')
expect(contentDiv)!.toBeInTheDocument()
// Content is escaped by React, so HTML entities are displayed
expect(contentDiv?.textContent).toContain('alert')
})
})
it('should handle preview content with unicode', async () => {
const unicodeContent = '中文内容 🚀 émojis & spëcîal çhàrs'
mockFetchFilePreview.mockResolvedValue({ content: unicodeContent })
renderFilePreview()
await waitFor(() => {
expect(screen.getByText(unicodeContent))!.toBeInTheDocument()
})
})
it('should handle preview content with newlines', async () => {
const multilineContent = 'Line 1\nLine 2\nLine 3'
mockFetchFilePreview.mockResolvedValue({ content: multilineContent })
const { container } = renderFilePreview()
// Assert - Content should be in the DOM
await waitFor(() => {
const contentDiv = container.querySelector('[class*="fileContent"]')
expect(contentDiv)!.toBeInTheDocument()
expect(contentDiv?.textContent).toContain('Line 1')
expect(contentDiv?.textContent).toContain('Line 2')
expect(contentDiv?.textContent).toContain('Line 3')
})
})
})
// Side Effects and Cleanup Tests
describe('Side Effects and Cleanup', () => {
it('should trigger effect when file prop changes', async () => {
const file1 = createMockFile({ id: 'file-1' })
const file2 = createMockFile({ id: 'file-2' })
const { rerender } = render(<FilePreview file={file1} hidePreview={vi.fn()} />)
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledTimes(1)
})
rerender(<FilePreview file={file2} hidePreview={vi.fn()} />)
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledTimes(2)
})
})
it('should not trigger effect when hidePreview changes', async () => {
const file = createMockFile()
const hidePreview1 = vi.fn()
const hidePreview2 = vi.fn()
const { rerender } = render(<FilePreview file={file} hidePreview={hidePreview1} />)
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledTimes(1)
})
rerender(<FilePreview file={file} hidePreview={hidePreview2} />)
// Assert - Should not call API again (file didn't change)
// Note: This depends on useEffect dependency array only including [file]
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledTimes(1)
})
})
it('should handle rapid file changes', async () => {
const files = Array.from({ length: 5 }, (_, i) => createMockFile({ id: `file-${i}` }))
const { rerender } = render(<FilePreview file={files[0]} hidePreview={vi.fn()} />)
// Rapidly change files
for (let i = 1; i < files.length; i++)
rerender(<FilePreview file={files[i]} hidePreview={vi.fn()} />)
// Assert - Should have called API for each file
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledTimes(5)
})
})
it('should handle file changing from defined to undefined', async () => {
const file = createMockFile()
const { rerender } = render(<FilePreview file={file} hidePreview={vi.fn()} />)
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledTimes(1)
})
rerender(<FilePreview file={undefined} hidePreview={vi.fn()} />)
expect(mockFetchFilePreview).toHaveBeenCalledTimes(1)
})
})
// getFileName Helper Tests
describe('getFileName Helper', () => {
it('should extract name without extension for simple filename', async () => {
const file = createMockFile({ name: 'document.pdf' })
renderFilePreview({ file })
expect(screen.getByText('document'))!.toBeInTheDocument()
})
it('should handle filename with multiple dots', async () => {
const file = createMockFile({ name: 'file.name.with.dots.txt' })
renderFilePreview({ file })
// Assert - Should join all parts except last with comma
// Assert - Should join all parts except last with comma
expect(screen.getByText('file,name,with,dots'))!.toBeInTheDocument()
})
it('should return empty for filename without dot', async () => {
const file = createMockFile({ name: 'nodotfile' })
const { container } = renderFilePreview({ file })
// Assert - slice(0, -1) on single element array returns empty
const fileNameElement = container.querySelector('[class*="fileName"]')
const firstSpan = fileNameElement?.querySelector('span:first-child')
expect(firstSpan?.textContent).toBe('')
})
it('should return empty string when file is undefined', async () => {
const { container } = renderFilePreview({ file: undefined })
// Assert - File name area should have empty first span
const fileNameElement = container.querySelector('.system-xs-medium')
expect(fileNameElement)!.toBeInTheDocument()
})
it('closes the preview through its named button', async () => {
const user = userEvent.setup()
const hidePreview = vi.fn()
renderPreview(<FilePreview file={createFile()} hidePreview={hidePreview} />)
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(hidePreview).toHaveBeenCalledOnce()
})
})
@@ -2,11 +2,12 @@
import type { CustomFile as File } from '@/models/datasets'
import { XMarkIcon } from '@heroicons/react/20/solid'
import { cn } from '@langgenius/dify-ui/cn'
import { skipToken, useQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useId } from 'react'
import { useTranslation } from 'react-i18next'
import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder'
import { fetchFilePreview } from '@/service/common'
import { consoleQuery } from '@/service/console'
import s from './index.module.css'
type IProps = {
@@ -16,16 +17,22 @@ type IProps = {
const FilePreview = ({ file, hidePreview }: IProps) => {
const { t } = useTranslation()
const [previewContent, setPreviewContent] = useState('')
const [loading, setLoading] = useState(true)
const getPreviewContent = async (fileID: string) => {
try {
const res = await fetchFilePreview({ fileID })
setPreviewContent(res.content)
setLoading(false)
} catch {}
}
const headingId = useId()
const fileID = file?.id
const { data, isPending, isError } = useQuery(
consoleQuery.files.byFileId.preview.get.queryOptions({
input: fileID ? { params: { file_id: fileID } } : skipToken,
retry: false,
}),
)
const loading = !!fileID && isPending
const statusText = !fileID
? ''
: loading
? t(($) => $.loading, { ns: 'common' })
: isError
? t(($) => $['api.actionFailed'], { ns: 'common' })
: t(($) => $['api.success'], { ns: 'common' })
const getFileName = (currentFile?: File) => {
if (!currentFile) return ''
@@ -33,18 +40,11 @@ const FilePreview = ({ file, hidePreview }: IProps) => {
return arr.slice(0, -1).join()
}
useEffect(() => {
if (file?.id) {
setLoading(true)
getPreviewContent(file.id)
}
}, [file])
return (
<div className={cn(s.filePreview, 'h-full')}>
<section aria-labelledby={headingId} className={cn(s.filePreview, 'h-full')}>
<div className={cn(s.previewHeader)}>
<div className={cn(s.title, 'title-md-semi-bold')}>
<span>{t(($) => $['stepOne.filePreview'], { ns: 'datasetCreation' })}</span>
<h2 id={headingId}>{t(($) => $['stepOne.filePreview'], { ns: 'datasetCreation' })}</h2>
<button
type="button"
className="flex size-6 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
@@ -59,11 +59,20 @@ const FilePreview = ({ file, hidePreview }: IProps) => {
<span className={cn(s.filetype)}>.{file?.extension}</span>
</div>
</div>
<div
role="status"
aria-atomic="true"
className={cn(isError ? 'px-8 pt-4 text-text-destructive' : 'sr-only')}
>
{statusText && `${file?.name}: ${statusText}`}
</div>
<div className={cn(s.previewContent)}>
{loading && <LoadingPlaceholder />}
{!loading && <div className={cn(s.fileContent, 'body-md-regular')}>{previewContent}</div>}
{!loading && !isError && (
<div className={cn(s.fileContent, 'body-md-regular')}>{data?.content}</div>
)}
</div>
</div>
</section>
)
}
@@ -1,9 +1,10 @@
import type { CustomFile as File, FileItem } from '@/models/datasets'
import { fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { consoleQuery } from '@/service/console'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { PROGRESS_NOT_STARTED } from '../constants'
import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants'
import FileUploader from '../index'
const render = (ui: React.ReactElement) => {
@@ -163,19 +164,18 @@ describe('FileUploader', () => {
})
describe('event handlers', () => {
it('should handle file preview click', () => {
it('should handle file preview click', async () => {
const user = userEvent.setup()
const onPreview = vi.fn()
const fileItem = createMockFileItem({
file: createMockFile({ id: 'file-id' } as Partial<File>),
})
const { container } = render(
<FileUploader {...defaultProps} fileList={[fileItem]} onPreview={onPreview} />,
)
render(<FileUploader {...defaultProps} fileList={[fileItem]} onPreview={onPreview} />)
// Find the file list item container by its class pattern
const fileElement = container.querySelector('[class*="flex h-12"]')
if (fileElement) fireEvent.click(fileElement)
await user.click(
screen.getByRole('button', { name: 'datasetCreation.stepOne.filePreview test.pdf' }),
)
expect(onPreview).toHaveBeenCalledWith(fileItem.file)
})
@@ -210,6 +210,78 @@ describe('FileUploader', () => {
})
})
describe('upload announcements', () => {
it('keeps a live status mounted before any upload starts', () => {
render(<FileUploader {...defaultProps} />)
expect(screen.getByRole('status')).toBeEmptyDOMElement()
})
it('keeps progress announcements stable until the server confirms the file', () => {
const fileItem = createMockFileItem({ fileID: 'upload-1', progress: 0 })
const { rerender } = render(<FileUploader {...defaultProps} fileList={[fileItem]} />)
const status = screen.getByRole('status')
expect(status).toHaveTextContent('test.pdf: common.loading')
for (const progress of [25, 75, PROGRESS_COMPLETE]) {
rerender(<FileUploader {...defaultProps} fileList={[{ ...fileItem, progress }]} />)
expect(screen.getByRole('status')).toHaveTextContent('test.pdf: common.loading')
expect(screen.getByRole('status')).not.toHaveTextContent(
'datasetCreation.stepOne.uploader.completed',
)
}
rerender(
<FileUploader
{...defaultProps}
fileList={[
{
...fileItem,
progress: PROGRESS_COMPLETE,
file: createMockFile({ id: 'server-file-id' }),
},
]}
/>,
)
expect(screen.getByRole('status')).toHaveTextContent(
'test.pdf: datasetCreation.stepOne.uploader.completed',
)
expect(screen.getByRole('status')).not.toHaveTextContent('common.loading')
})
it('announces failed and completed files by name within a batch', () => {
const pendingFiles = [
createMockFileItem({
fileID: 'upload-1',
file: createMockFile({ name: 'first.pdf' }),
progress: 10,
}),
createMockFileItem({
fileID: 'upload-2',
file: createMockFile({ name: 'second.pdf' }),
progress: 20,
}),
]
const { rerender } = render(<FileUploader {...defaultProps} fileList={pendingFiles} />)
rerender(
<FileUploader
{...defaultProps}
fileList={[
{ ...pendingFiles[0]!, progress: PROGRESS_ERROR },
{
...pendingFiles[1]!,
progress: PROGRESS_COMPLETE,
file: createMockFile({ name: 'second.pdf', id: 'second-server-id' }),
},
]}
/>,
)
const status = screen.getByRole('status')
expect(status).toHaveTextContent('first.pdf: datasetCreation.stepOne.uploader.failed')
expect(status).toHaveTextContent('second.pdf: datasetCreation.stepOne.uploader.completed')
expect(status).toHaveAttribute('aria-atomic', 'false')
})
})
describe('upload progress', () => {
it('should show progress chart for uploading files', () => {
const fileItem = createMockFileItem({ progress: 50 })
@@ -251,12 +323,6 @@ describe('FileUploader', () => {
})
describe('styling', () => {
it('should have correct container width', () => {
const { container } = render(<FileUploader {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('w-160')
})
it('should have proper spacing', () => {
const { container } = render(<FileUploader {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
@@ -1,6 +1,7 @@
import type { FileListItemProps } from '../file-list-item'
import type { CustomFile as File, FileItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../../constants'
import FileListItem from '../file-list-item'
@@ -113,8 +114,10 @@ describe('FileListItem', () => {
})
it('should render delete button', () => {
const { container } = render(<FileListItem {...defaultProps} />)
const deleteButton = container.querySelector('.cursor-pointer')
render(<FileListItem {...defaultProps} />)
const deleteButton = screen.getByRole('button', {
name: 'common.operation.remove test-document.pdf',
})
expect(deleteButton).toBeInTheDocument()
})
})
@@ -190,6 +193,25 @@ describe('FileListItem', () => {
})
})
it('supports keyboard preview and removal without triggering both actions', async () => {
const user = userEvent.setup()
const fileItem = createMockFileItem({ file: createMockFile({ id: 'uploaded-id' }) })
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
await user.tab()
expect(
screen.getByRole('button', { name: 'datasetCreation.stepOne.filePreview test-document.pdf' }),
).toHaveFocus()
await user.keyboard('{Enter}')
expect(defaultProps.onPreview).toHaveBeenCalledWith(fileItem.file)
await user.tab()
expect(
screen.getByRole('button', { name: 'common.operation.remove test-document.pdf' }),
).toHaveFocus()
await user.keyboard(' ')
expect(defaultProps.onRemove).toHaveBeenCalledWith(fileItem.fileID)
expect(defaultProps.onPreview).toHaveBeenCalledOnce()
})
describe('event handlers', () => {
it('should call onPreview when item is clicked with file id', () => {
const onPreview = vi.fn()
@@ -198,7 +220,9 @@ describe('FileListItem', () => {
})
render(<FileListItem {...defaultProps} fileItem={fileItem} onPreview={onPreview} />)
const item = screen.getByText('test-document.pdf').closest('[class*="flex h-12"]')!
const item = screen.getByRole('button', {
name: 'datasetCreation.stepOne.filePreview test-document.pdf',
})
fireEvent.click(item)
expect(onPreview).toHaveBeenCalledTimes(1)
@@ -210,7 +234,9 @@ describe('FileListItem', () => {
const fileItem = createMockFileItem()
render(<FileListItem {...defaultProps} fileItem={fileItem} onPreview={onPreview} />)
const item = screen.getByText('test-document.pdf').closest('[class*="flex h-12"]')!
const item = screen.getByRole('button', {
name: 'datasetCreation.stepOne.filePreview test-document.pdf',
})
fireEvent.click(item)
expect(onPreview).not.toHaveBeenCalled()
@@ -219,11 +245,11 @@ describe('FileListItem', () => {
it('should call onRemove when delete button is clicked', () => {
const onRemove = vi.fn()
const fileItem = createMockFileItem()
const { container } = render(
<FileListItem {...defaultProps} fileItem={fileItem} onRemove={onRemove} />,
)
render(<FileListItem {...defaultProps} fileItem={fileItem} onRemove={onRemove} />)
const deleteButton = container.querySelector('.cursor-pointer')!
const deleteButton = screen.getByRole('button', {
name: 'common.operation.remove test-document.pdf',
})
fireEvent.click(deleteButton)
expect(onRemove).toHaveBeenCalledTimes(1)
@@ -236,7 +262,7 @@ describe('FileListItem', () => {
const fileItem = createMockFileItem({
file: createMockFile({ id: 'uploaded-id' } as Partial<File>),
})
const { container } = render(
render(
<FileListItem
{...defaultProps}
fileItem={fileItem}
@@ -245,7 +271,9 @@ describe('FileListItem', () => {
/>,
)
const deleteButton = container.querySelector('.cursor-pointer')!
const deleteButton = screen.getByRole('button', {
name: 'common.operation.remove test-document.pdf',
})
fireEvent.click(deleteButton)
expect(onRemove).toHaveBeenCalledTimes(1)
@@ -1,7 +1,9 @@
'use client'
import type { CustomFile as File, FileItem } from '@/models/datasets'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { RiDeleteBinLine, RiErrorWarningFill } from '@remixicon/react'
import { useMemo } from 'react'
import { useId, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import DocumentFileIcon from '@/app/components/datasets/common/document-file-icon'
import useTheme from '@/hooks/use-theme'
import dynamic from '@/next/dynamic'
@@ -20,6 +22,10 @@ export type FileListItemProps = {
}
const FileListItem = ({ fileItem, onPreview, onRemove }: FileListItemProps) => {
const { t } = useTranslation()
const fileNameId = useId()
const previewLabelId = useId()
const removeLabelId = useId()
const { theme } = useTheme()
const chartColor = useMemo(() => (theme === Theme.dark ? '#5289ff' : '#296dff'), [theme])
@@ -30,36 +36,39 @@ const FileListItem = ({ fileItem, onPreview, onRemove }: FileListItemProps) => {
if (fileItem.file?.id) onPreview(fileItem.file)
}
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation()
onRemove(fileItem.fileID)
}
return (
<div
onClick={handleClick}
className="flex h-12 max-w-160 items-center rounded-lg border border-components-panel-border bg-components-panel-on-panel-item-bg text-xs leading-3 text-text-tertiary shadow-xs"
>
<div className="flex w-12 shrink-0 items-center justify-center">
<DocumentFileIcon
size="xl"
className="shrink-0"
name={fileItem.file.name}
extension={getFileExtension(fileItem.file.name)}
/>
</div>
<div className="flex shrink grow flex-col gap-0.5">
<div className="flex w-full">
<div className="w-0 grow truncate text-sm/4 text-text-secondary">
{fileItem.file.name}
<div className="flex h-12 max-w-160 items-center rounded-lg border border-components-panel-border bg-components-panel-on-panel-item-bg text-xs leading-3 text-text-tertiary shadow-xs">
<button
type="button"
disabled={!fileItem.file?.id}
aria-labelledby={`${previewLabelId} ${fileNameId}`}
onClick={handleClick}
className="flex h-full min-w-0 grow items-center rounded-lg text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-input-border-active"
>
<span id={previewLabelId} className="sr-only">
{t(($) => $['stepOne.filePreview'], { ns: 'datasetCreation' })}
</span>
<div className="flex w-12 shrink-0 items-center justify-center">
<DocumentFileIcon
size="xl"
className="shrink-0"
name={fileItem.file.name}
extension={getFileExtension(fileItem.file.name)}
/>
</div>
<div className="flex min-w-0 shrink grow flex-col gap-0.5">
<div className="flex w-full">
<div id={fileNameId} className="w-0 grow truncate text-sm/4 text-text-secondary">
{fileItem.file.name}
</div>
</div>
<div className="w-full truncate leading-3 text-text-tertiary">
<span className="uppercase">{getFileExtension(fileItem.file.name)}</span>
<span className="px-1 text-text-quaternary">·</span>
<span>{formatFileSize(fileItem.file.size)}</span>
</div>
</div>
<div className="w-full truncate leading-3 text-text-tertiary">
<span className="uppercase">{getFileExtension(fileItem.file.name)}</span>
<span className="px-1 text-text-quaternary">·</span>
<span>{formatFileSize(fileItem.file.size)}</span>
</div>
</div>
</button>
<div className="flex w-16 shrink-0 items-center justify-end gap-1 pr-3">
{isUploading && (
<SimplePieChart
@@ -70,12 +79,16 @@ const FileListItem = ({ fileItem, onPreview, onRemove }: FileListItemProps) => {
/>
)}
{isError && <RiErrorWarningFill className="size-4 text-text-destructive" />}
<span
className="flex size-6 cursor-pointer items-center justify-center"
onClick={handleRemove}
>
<RiDeleteBinLine className="size-4 text-text-tertiary" />
<span id={removeLabelId} className="sr-only">
{t(($) => $['operation.remove'], { ns: 'common' })}
</span>
<IconButton
size="sm"
aria-labelledby={`${removeLabelId} ${fileNameId}`}
onClick={() => onRemove(fileItem.fileID)}
>
<RiDeleteBinLine aria-hidden="true" className="size-4" />
</IconButton>
</div>
</div>
)
@@ -4,6 +4,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import FileListItem from './components/file-list-item'
import UploadDropzone from './components/upload-dropzone'
import { PROGRESS_ERROR } from './constants'
import { useFileUpload } from './hooks/use-file-upload'
type IFileUploaderProps = {
@@ -50,9 +51,25 @@ const FileUploader = ({
})
return (
<div className="mb-5 w-160">
<div className={cn('mb-1 text-sm/6 font-semibold text-text-secondary', titleClassName)}>
<div className="mb-5 w-full max-w-160">
<h2 className={cn('mb-1 text-sm/6 font-semibold text-text-secondary', titleClassName)}>
{t(($) => $['stepOne.uploader.title'], { ns: 'datasetCreation' })}
</h2>
<div role="status" aria-atomic="false" aria-relevant="additions text" className="sr-only">
{fileList.map(({ fileID, file, progress }) => {
// Transport progress can reach 100 before the server accepts the file.
// Keep each announcement stable until its upload state changes.
const message =
progress === PROGRESS_ERROR
? t(($) => $['stepOne.uploader.failed'], { ns: 'datasetCreation' })
: file.id
? t(($) => $['stepOne.uploader.completed'], { ns: 'datasetCreation' })
: progress >= 0
? t(($) => $.loading, { ns: 'common' })
: undefined
return message ? <div key={fileID}>{`${file.name}: ${message}`}</div> : null
})}
</div>
{!hideUpload && (
@@ -3,6 +3,7 @@ import type { DataSourceAuth } from '@/app/components/header/account-setting/dat
import type { NotionPage } from '@/models/common'
import type { CrawlOptions, CrawlResultItem, DataSet, FileItem } from '@/models/datasets'
import { fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { DataSourceType } from '@/models/datasets'
import { consoleQuery } from '@/service/console'
@@ -246,6 +247,26 @@ const defaultProps = {
// StepOne Component Tests
describe('StepOne', () => {
it('returns keyboard focus to the file preview trigger when closing the preview', async () => {
const user = userEvent.setup()
render(<StepOne {...defaultProps} />)
const trigger = screen.getByRole('button', { name: 'Preview' })
await user.click(trigger)
await user.click(screen.getByRole('button', { name: 'Hide' }))
expect(screen.queryByTestId('file-preview')).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
})
it('keeps focus on the newly selected data source when clearing a file preview', async () => {
const user = userEvent.setup()
render(<StepOne {...defaultProps} />)
await user.click(screen.getByRole('button', { name: 'Preview' }))
const source = screen.getByRole('radio', { name: 'datasetCreation.stepOne.dataSourceType.web' })
await user.click(source)
expect(screen.queryByTestId('file-preview')).not.toBeInTheDocument()
expect(source).toHaveFocus()
})
beforeEach(() => {
vi.clearAllMocks()
mockDatasetDetail = undefined
@@ -1,5 +1,6 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { DataSourceType } from '@/models/datasets'
vi.mock('@/config', () => ({
@@ -51,6 +52,24 @@ describe('DataSourceTypeSelector', () => {
expect(defaultProps.onClearPreviews).toHaveBeenCalledWith(DataSourceType.NOTION)
})
it('changes source with the keyboard and exposes the selected option', async () => {
const user = userEvent.setup()
const Example = () => {
const [value, setValue] = useState<DataSourceType>(DataSourceType.FILE)
return <DataSourceTypeSelector {...defaultProps} currentType={value} onChange={setValue} />
}
render(<Example />)
await user.tab()
expect(
screen.getByRole('radio', { name: 'datasetCreation.stepOne.dataSourceType.file' }),
).toHaveFocus()
await user.keyboard('{ArrowRight}')
expect(
screen.getByRole('radio', { name: 'datasetCreation.stepOne.dataSourceType.notion' }),
).toBeChecked()
expect(defaultProps.onClearPreviews).toHaveBeenCalledWith(DataSourceType.NOTION)
})
it('does not change data source type while disabled', async () => {
const user = userEvent.setup()
render(<DataSourceTypeSelector {...defaultProps} disabled />)
@@ -1,6 +1,7 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import { RadioGroup, RadioItem } from '@langgenius/dify-ui/radio-group'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
@@ -12,6 +13,7 @@ import { DataSourceType } from '@/models/datasets'
import s from '../index.module.css'
type DataSourceTypeSelectorProps = {
labelledBy?: string
currentType: DataSourceType
disabled: boolean
onChange: (type: DataSourceType) => void
@@ -50,6 +52,7 @@ const DATA_SOURCE_OPTIONS: DataSourceOption[] = [
* Data source type selector component for choosing between file, notion, and web sources.
*/
function DataSourceTypeSelector({
labelledBy,
currentType,
disabled,
onChange,
@@ -79,17 +82,24 @@ function DataSourceTypeSelector({
)
return (
<div className="mb-8 grid grid-cols-3 gap-4">
<RadioGroup
aria-labelledby={labelledBy}
aria-label={labelledBy ? undefined : t(($) => $['steps.one'], { ns: 'datasetCreation' })}
value={currentType}
disabled={disabled}
onValueChange={handleTypeChange}
className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-3"
>
{visibleOptions.map((option) => (
<div
<RadioItem
key={option.type}
value={option.type}
className={cn(
s.dataSourceItem,
'system-sm-medium',
'system-sm-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-input-border-active',
currentType === option.type && s.active,
disabled && currentType !== option.type && s.disabled,
)}
onClick={() => handleTypeChange(option.type)}
>
<span className={cn(s.datasetIcon, option.iconClass)} />
<span
@@ -98,9 +108,9 @@ function DataSourceTypeSelector({
>
{t(($) => $[option.labelKey], { ns: 'datasetCreation' })}
</span>
</div>
</RadioItem>
))}
</div>
</RadioGroup>
)
}
@@ -37,7 +37,7 @@ function PreviewPanel({
const { t } = useTranslation()
return (
<div className="h-full w-1/2 overflow-y-auto">
<div className="w-full min-w-0 xl:h-full xl:w-1/2 xl:overflow-y-auto">
{currentFile && <FilePreview file={currentFile} hidePreview={hideFilePreview} />}
{currentNotionPage && (
<NotionPagePreview
@@ -2,7 +2,7 @@
import type { NotionPage } from '@/models/common'
import type { CrawlResultItem } from '@/models/datasets'
import { useCallback, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
type PreviewState = {
currentFile: File | undefined
@@ -12,7 +12,7 @@ type PreviewState = {
type PreviewActions = {
showFilePreview: (file: File) => void
hideFilePreview: () => void
hideFilePreview: (restoreFocus?: boolean) => void
showNotionPagePreview: (page: NotionPage) => void
hideNotionPagePreview: () => void
showWebsitePreview: (website: CrawlResultItem) => void
@@ -26,16 +26,21 @@ type UsePreviewStateReturn = PreviewState & PreviewActions
* Handles file, notion page, and website preview visibility.
*/
function usePreviewState(): UsePreviewStateReturn {
const filePreviewTrigger = useRef<HTMLElement | null>(null)
const [currentFile, setCurrentFile] = useState<File | undefined>()
const [currentNotionPage, setCurrentNotionPage] = useState<NotionPage | undefined>()
const [currentWebsite, setCurrentWebsite] = useState<CrawlResultItem | undefined>()
const showFilePreview = useCallback((file: File) => {
filePreviewTrigger.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null
setCurrentFile(file)
}, [])
const hideFilePreview = useCallback(() => {
const hideFilePreview = useCallback((restoreFocus = false) => {
setCurrentFile(undefined)
if (restoreFocus && filePreviewTrigger.current?.isConnected) filePreviewTrigger.current.focus()
filePreviewTrigger.current = null
}, [])
const showNotionPagePreview = useCallback((page: NotionPage) => {
@@ -8,7 +8,7 @@ import { RiFolder6Line } from '@remixicon/react'
import { useQuery } from '@tanstack/react-query'
import { useBoolean } from 'ahooks'
import { useAtomValue } from 'jotai'
import { useCallback, useMemo } from 'react'
import { useCallback, useId, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import NotionConnector from '@/app/components/base/notion-connector'
import { NotionPageSelector } from '@/app/components/base/notion-page-selector'
@@ -100,6 +100,7 @@ const StepOne = ({
authedDataSourceList,
}: IStepOneProps) => {
const { t } = useTranslation()
const sourceLabelId = useId()
const dataset = useDatasetDetailContextWithSelector((state) => state.dataset)
const deploymentEdition = useAtomValue(deploymentEditionAtom)
const { data: plan } = useQuery(
@@ -212,18 +213,22 @@ const StepOne = ({
])
return (
<div className="size-full overflow-x-auto">
<div className="flex h-full w-full min-w-360">
<div className="size-full min-w-0 overflow-y-auto">
<div className="flex min-h-full w-full min-w-0 flex-col xl:h-full xl:flex-row">
{/* Left Panel - Form */}
<div className="relative h-full w-1/2 overflow-y-auto">
<div className="flex justify-end">
<div className="relative w-full min-w-0 xl:h-full xl:w-1/2 xl:overflow-y-auto">
<div className="flex min-w-0 justify-end">
<div className={cn(s.form)}>
{shouldShowDataSourceTypeList && (
<>
<div className={cn(s.stepHeader, 'system-md-semibold text-text-secondary')}>
<h1
id={sourceLabelId}
className={cn(s.stepHeader, 'system-md-semibold text-text-secondary')}
>
{t(($) => $['steps.one'], { ns: 'datasetCreation' })}
</div>
</h1>
<DataSourceTypeSelector
labelledBy={sourceLabelId}
currentType={dataSourceType}
disabled={dataSourceTypeDisable}
onChange={changeType}
@@ -276,13 +281,18 @@ const StepOne = ({
{dataSourceType === DataSourceType.NOTION && (
<>
{!isNotionAuthed && (
<div className={cn('mb-8 w-160', !shouldShowDataSourceTypeList && 'mt-12')}>
<div
className={cn(
'mb-8 w-full max-w-160',
!shouldShowDataSourceTypeList && 'mt-12',
)}
>
<NotionConnector onSetting={onSetting} />
</div>
)}
{isNotionAuthed && (
<>
<div className="mb-8 w-160">
<div className="mb-8 w-full max-w-160">
<NotionPageSelector
value={notionPages.map((page) => page.page_id)}
onSelect={updateNotionPages}
@@ -322,7 +332,12 @@ const StepOne = ({
{/* Web Data Source */}
{dataSourceType === DataSourceType.WEB && (
<>
<div className={cn('mb-8 w-160', !shouldShowDataSourceTypeList && 'mt-12')}>
<div
className={cn(
'mb-8 w-full max-w-160',
!shouldShowDataSourceTypeList && 'mt-12',
)}
>
<Website
onPreview={showWebsitePreview}
checkedCrawlResult={websitePages}
@@ -350,13 +365,14 @@ const StepOne = ({
{!datasetId && (
<>
<div className="my-8 h-px max-w-160 bg-divider-regular" />
<span
className="inline-flex cursor-pointer items-center text-[13px] leading-4 text-text-accent"
<button
type="button"
className="inline-flex cursor-pointer items-center rounded-sm text-left text-[13px] leading-4 text-text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-input-border-active"
onClick={openModal}
>
<RiFolder6Line className="mr-1 size-4" />
<RiFolder6Line aria-hidden="true" className="mr-1 size-4 shrink-0" />
{t(($) => $['stepOne.emptyDatasetCreation'], { ns: 'datasetCreation' })}
</span>
</button>
</>
)}
</div>
@@ -371,7 +387,7 @@ const StepOne = ({
currentWebsite={currentWebsite}
notionCredentialId={notionCredentialId}
isShowPlanUpgradeModal={isShowPlanUpgradeModal}
hideFilePreview={hideFilePreview}
hideFilePreview={() => hideFilePreview(true)}
hideNotionPagePreview={hideNotionPagePreview}
hideWebsitePreview={hideWebsitePreview}
hidePlanUpgradeModal={hidePlanUpgradeModal}
@@ -22,14 +22,18 @@ describe('StepThree', () => {
it('shows the created dataset while its documents are processed', () => {
render(<StepThree datasetName="Product docs" />)
expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument()
expect(
screen.getByRole('heading', { level: 1, name: 'datasetCreation.stepThree.creationTitle' }),
).toBeInTheDocument()
expect(screen.getByText('Product docs')).toBeInTheDocument()
})
it('shows the target dataset while additional documents are processed', () => {
render(<StepThree datasetId="dataset-1" datasetName="Product docs" />)
expect(screen.getByText('datasetCreation.stepThree.additionTitle')).toBeInTheDocument()
expect(
screen.getByRole('heading', { level: 1, name: 'datasetCreation.stepThree.additionTitle' }),
).toBeInTheDocument()
expect(screen.getByText(/Product docs/)).toBeInTheDocument()
expect(screen.queryByText('datasetCreation.stepThree.creationTitle')).not.toBeInTheDocument()
})
@@ -7,7 +7,6 @@ import * as React from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { useDocLink } from '@/context/i18n'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import EmbeddingProcess from '../embedding-process'
type StepThreeProps = {
@@ -28,8 +27,6 @@ const StepThree = ({
const { t } = useTranslation()
const docLink = useDocLink()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const iconInfo = creationCache?.dataset?.icon_info || {
icon: '📙',
icon_type: 'emoji',
@@ -38,15 +35,15 @@ const StepThree = ({
}
return (
<div className="flex size-full max-h-full justify-center overflow-y-auto">
<div className="h-full max-w-240 shrink-0 grow overflow-y-auto px-14 sm:px-16">
<div className="flex size-full max-h-full min-w-0 flex-col overflow-y-auto xl:flex-row xl:justify-center">
<div className="max-w-240 min-w-0 shrink-0 grow px-4 sm:px-8 xl:shrink xl:px-16">
<div className="mx-auto max-w-160 pt-10 pb-8">
{!datasetId && (
<>
<div className="flex flex-col gap-y-1 pb-3">
<div className="title-2xl-semi-bold text-text-primary">
<h1 className="title-2xl-semi-bold text-text-primary">
{t(($) => $['stepThree.creationTitle'], { ns: 'datasetCreation' })}
</div>
</h1>
<div className="system-sm-regular text-text-tertiary">
{t(($) => $['stepThree.creationContent'], { ns: 'datasetCreation' })}
</div>
@@ -60,11 +57,11 @@ const StepThree = ({
imageUrl={iconInfo.icon_url}
className="shrink-0"
/>
<div className="flex grow flex-col gap-y-1">
<div className="flex min-w-0 grow flex-col gap-y-1">
<div className="flex h-6 items-center system-sm-semibold text-text-secondary">
{t(($) => $['stepThree.label'], { ns: 'datasetCreation' })}
</div>
<div className="w-full truncate rounded-lg bg-components-input-bg-normal p-2 system-sm-regular text-components-input-text-filled">
<div className="w-full rounded-lg bg-components-input-bg-normal p-2 system-sm-regular wrap-anywhere text-components-input-text-filled">
<span className="px-1">{datasetName || creationCache?.dataset?.name}</span>
</div>
</div>
@@ -74,9 +71,9 @@ const StepThree = ({
)}
{datasetId && (
<div className="flex flex-col gap-y-1 pb-3">
<div className="title-2xl-semi-bold text-text-primary">
<h1 className="title-2xl-semi-bold text-text-primary">
{t(($) => $['stepThree.additionTitle'], { ns: 'datasetCreation' })}
</div>
</h1>
<div className="system-sm-regular text-text-tertiary">{`${t(($) => $['stepThree.additionP1'], { ns: 'datasetCreation' })} ${datasetName || creationCache?.dataset?.name} ${t(($) => $['stepThree.additionP2'], { ns: 'datasetCreation' })}`}</div>
</div>
)}
@@ -91,29 +88,27 @@ const StepThree = ({
/>
</div>
</div>
{!isMobile && (
<div className="shrink-0 pt-22 pr-8 text-xs">
<div className="flex w-82 flex-col gap-3 rounded-xl bg-background-section p-6 text-text-tertiary">
<div className="flex size-10 items-center justify-center rounded-[10px] bg-components-card-bg shadow-lg">
<RiBookOpenLine className="size-5 text-text-accent" />
</div>
<div className="text-base font-semibold text-text-secondary">
{t(($) => $['stepThree.sideTipTitle'], { ns: 'datasetCreation' })}
</div>
<div className="text-text-tertiary">
{t(($) => $['stepThree.sideTipContent'], { ns: 'datasetCreation' })}
</div>
<a
href={docLink('/use-dify/knowledge/integrate-knowledge-within-application')}
target="_blank"
rel="noreferrer noopener"
className="system-sm-regular text-text-accent"
>
{t(($) => $['addDocuments.stepThree.learnMore'], { ns: 'datasetPipeline' })}
</a>
<div className="shrink-0 px-4 pb-8 text-xs sm:px-8 xl:pt-22 xl:pl-0">
<div className="flex w-full max-w-82 flex-col gap-3 rounded-xl bg-background-section p-6 text-text-tertiary">
<div className="flex size-10 items-center justify-center rounded-[10px] bg-components-card-bg shadow-lg">
<RiBookOpenLine className="size-5 text-text-accent" />
</div>
<h2 className="text-base font-semibold text-text-secondary">
{t(($) => $['stepThree.sideTipTitle'], { ns: 'datasetCreation' })}
</h2>
<div className="text-text-tertiary">
{t(($) => $['stepThree.sideTipContent'], { ns: 'datasetCreation' })}
</div>
<a
href={docLink('/use-dify/knowledge/integrate-knowledge-within-application')}
target="_blank"
rel="noreferrer noopener"
className="system-sm-regular text-text-accent"
>
{t(($) => $['addDocuments.stepThree.learnMore'], { ns: 'datasetPipeline' })}
</a>
</div>
)}
</div>
</div>
)
}
@@ -15,12 +15,22 @@ import type {
Rules,
} from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { act, cleanup, fireEvent, renderHook, screen } from '@testing-library/react'
import {
act,
cleanup,
fireEvent,
renderHook,
screen,
waitFor,
within,
} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {
ConfigurationMethodEnum,
ModelStatusEnum,
ModelTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { ChunkingMode, DataSourceType, ProcessMode } from '@/models/datasets'
import { expectLoadingButton } from '@/test/button'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
@@ -43,6 +53,11 @@ import escape from '../hooks/escape'
import unescape from '../hooks/unescape'
import StepTwo from '../index'
vi.mock('@/hooks/use-breakpoints', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/hooks/use-breakpoints')>()
return { ...actual, default: vi.fn(() => actual.MediaType.pc) }
})
const mockDataset: Pick<
DataSet,
| 'id'
@@ -123,6 +138,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
}))
const mockFetchDefaultProcessRuleMutate = vi.fn()
const mockResetFileEstimate = vi.fn()
vi.mock('@/service/knowledge/use-create-dataset', () => ({
useFetchDefaultProcessRule: ({
onSuccess,
@@ -154,7 +170,7 @@ vi.mock('@/service/knowledge/use-create-dataset', () => ({
data: undefined,
isIdle: true,
isPending: false,
reset: vi.fn(),
reset: mockResetFileEstimate,
}),
useFetchFileIndexingEstimateForNotion: () => ({
mutate: vi.fn(),
@@ -1846,6 +1862,8 @@ describe('PreviewPanel', () => {
const defaultProps = {
isMobile: false,
isOpen: false,
onClose: vi.fn(),
dataSourceType: DataSourceType.FILE,
currentDocForm: ChunkingMode.text,
estimate: undefined as FileIndexingEstimateResponse | undefined,
@@ -1947,7 +1965,7 @@ describe('PreviewPanel', () => {
/>,
)
expect(screen.getByText(/25/))!.toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('25')
})
it('should render parent-child preview when docForm is parentChild', () => {
@@ -2332,6 +2350,53 @@ describe('StepTwo Component', () => {
}
describe('Rendering', () => {
it('switches chunking modes as one radio group without nesting parameter controls in a radio', async () => {
const user = userEvent.setup()
render(<StepTwo {...defaultStepTwoProps} />)
const group = screen.getByRole('radiogroup', { name: 'datasetCreation.stepTwo.segmentation' })
const general = within(group).getByRole('radio', { name: 'datasetCreation.stepTwo.general' })
const parentChild = within(group).getByRole('radio', {
name: 'datasetCreation.stepTwo.parentChild',
})
expect(general).toBeChecked()
expect(general).not.toContainElement(
screen.getByRole('button', { name: 'datasetCreation.stepTwo.previewChunk' }),
)
general.focus()
await user.keyboard('{ArrowDown}')
expect(parentChild).toBeChecked()
expect(general).not.toBeChecked()
expect(parentChild).not.toContainElement(
screen.getByRole('button', { name: 'datasetCreation.stepTwo.previewChunk' }),
)
await user.keyboard(' ')
expect(parentChild).toBeChecked()
await user.keyboard('{ArrowUp}')
expect(general).toBeChecked()
expect(parentChild).not.toBeChecked()
})
it.each(['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'])(
'keeps General selected and its preview intact when editing the delimiter with %s',
async (key) => {
const user = userEvent.setup()
render(<StepTwo {...defaultStepTwoProps} />)
const input = screen.getByRole('textbox', { name: 'datasetCreation.stepTwo.separator' })
await user.click(input)
await user.keyboard(key === 'ArrowLeft' ? '{Home}' : '{End}')
mockResetFileEstimate.mockClear()
await user.keyboard(`{${key}}`)
expect(screen.getByRole('radio', { name: 'datasetCreation.stepTwo.general' })).toBeChecked()
expect(
screen.getByRole('radio', { name: 'datasetCreation.stepTwo.parentChild' }),
).not.toBeChecked()
expect(input).toBeInTheDocument()
expect(input).toHaveFocus()
expect(mockResetFileEstimate).not.toHaveBeenCalled()
},
)
it('should show general chunking options when not in upload', () => {
render(<StepTwo {...defaultStepTwoProps} />)
// Should render the segmentation section
@@ -2386,6 +2451,27 @@ describe('StepTwo Component', () => {
expect(onStepChange).toHaveBeenCalledWith(1)
})
it('opens the mobile preview on request and restores focus to its button after closing', async () => {
vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile)
try {
const user = userEvent.setup()
render(<StepTwo {...defaultStepTwoProps} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
const trigger = screen.getByRole('button', { name: 'datasetCreation.stepTwo.previewChunk' })
await user.click(trigger)
const drawer = await screen.findByRole('dialog', {
name: 'datasetCreation.stepTwo.preview',
})
await user.click(within(drawer).getByRole('button', { name: 'common.operation.close' }))
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
})
} finally {
vi.mocked(useBreakpoints).mockReturnValue(MediaType.pc)
}
})
it('should trigger updatePreview when preview button is clicked', () => {
render(<StepTwo {...defaultStepTwoProps} />)
// GeneralChunkingOptions renders a "Preview Chunk" button
@@ -1,4 +1,5 @@
import type { PreProcessingRule } from '@/models/datasets'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { fireEvent, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { ChunkingMode } from '@/models/datasets'
@@ -24,7 +25,9 @@ vi.mock('@/app/components/datasets/settings/summary-index-setting', () => ({
const ns = 'datasetCreation'
const render = (ui: React.ReactElement) =>
renderWithConsoleQuery(ui, { systemFeatures: { deployment_edition: 'COMMUNITY' } })
renderWithConsoleQuery(<RadioGroup aria-label="Chunking mode">{ui}</RadioGroup>, {
systemFeatures: { deployment_edition: 'COMMUNITY' },
})
const createRules = (): PreProcessingRule[] => [
{ id: 'remove_extra_spaces', enabled: true },
@@ -110,22 +113,6 @@ describe('GeneralChunkingOptions', () => {
fireEvent.click(screen.getByText(`${ns}.stepTwo.removeUrlEmails`))
expect(onRuleToggle).toHaveBeenCalledWith('remove_urls_emails')
})
it('should call onDocFormChange with text mode when card switched', () => {
const onDocFormChange = vi.fn()
render(
<GeneralChunkingOptions
{...defaultProps}
isActive={false}
onDocFormChange={onDocFormChange}
/>,
)
// OptionCard fires onSwitched which calls onDocFormChange(ChunkingMode.text)
// Since isActive=false, clicking the card triggers the switch
const titleEl = screen.getByText(`${ns}.stepTwo.general`)
fireEvent.click(titleEl.closest('[class*="rounded-xl"]')!)
expect(onDocFormChange).toHaveBeenCalledWith(ChunkingMode.text)
})
})
describe('QA Mode (CE Edition)', () => {
@@ -1,6 +1,8 @@
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { RetrievalConfig } from '@/types/app'
import { fireEvent, screen } from '@testing-library/react'
import { fireEvent, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { ChunkingMode } from '@/models/datasets'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
@@ -202,7 +204,36 @@ describe('IndexingModeSection', () => {
})
describe('Index Type Switching', () => {
it('should call onIndexTypeChange when switching to qualified', () => {
it('exposes mutually exclusive indexing choices and switches them with arrow keys', async () => {
const user = userEvent.setup()
const Harness = () => {
const [indexType, setIndexType] = useState<IndexingType>(IndexingType.QUALIFIED)
return (
<IndexingModeSection
{...defaultProps}
indexType={indexType}
onIndexTypeChange={setIndexType}
/>
)
}
render(<Harness />)
const group = screen.getByRole('radiogroup', { name: `${ns}.stepTwo.indexMode` })
const qualified = within(group).getByRole('radio', { name: /stepTwo.qualified/ })
const economical = within(group).getByRole('radio', { name: `${ns}.stepTwo.economical` })
expect(qualified).toBeChecked()
qualified.focus()
await user.keyboard('{ArrowDown}')
expect(economical).toBeChecked()
expect(qualified).not.toBeChecked()
await user.keyboard(' ')
expect(economical).toBeChecked()
await user.keyboard('{ArrowUp}')
expect(qualified).toBeChecked()
expect(economical).not.toBeChecked()
expect(qualified).not.toContainElement(screen.getByRole('button', { name: 'Select Model' }))
})
it('should call onIndexTypeChange when switching to qualified', async () => {
const onIndexTypeChange = vi.fn()
render(
<IndexingModeSection
@@ -211,19 +242,20 @@ describe('IndexingModeSection', () => {
onIndexTypeChange={onIndexTypeChange}
/>,
)
const qualifiedCard = screen
.getByText(`${ns}.stepTwo.qualified`)
.closest('[class*="rounded-xl"]')!
fireEvent.click(qualifiedCard)
expect(onIndexTypeChange).toHaveBeenCalledWith(IndexingType.QUALIFIED)
const user = userEvent.setup()
const qualifiedCard = screen.getByRole('radio', {
name: /datasetCreation.stepTwo.qualified/,
})
qualifiedCard.focus()
await user.keyboard(' ')
expect(onIndexTypeChange).toHaveBeenCalledWith(IndexingType.QUALIFIED, expect.anything())
})
it('should disable economical when docForm is QA', () => {
render(<IndexingModeSection {...defaultProps} docForm={ChunkingMode.qa} />)
// The economical option card should have disabled styling
const economicalText = screen.getByText(`${ns}.stepTwo.economical`)
const card = economicalText.closest('[class*="rounded-xl"]')
expect(card)!.toHaveClass('pointer-events-none')
const card = screen.getByRole('radio', { name: `${ns}.stepTwo.economical` })
expect(card).toBeDisabled()
expect(screen.getByText(`${ns}.stepTwo.notAvailableForQA`))!.toBeInTheDocument()
})
@@ -1,5 +1,6 @@
import type { ParentChildConfig } from '../../hooks'
import type { PreProcessingRule } from '@/models/datasets'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { fireEvent, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { ChunkingMode } from '@/models/datasets'
@@ -25,7 +26,9 @@ vi.mock('@/app/components/datasets/settings/summary-index-setting', () => ({
const ns = 'datasetCreation'
const render = (ui: React.ReactElement) =>
renderWithConsoleQuery(ui, { systemFeatures: { deployment_edition: 'COMMUNITY' } })
renderWithConsoleQuery(<RadioGroup aria-label="Chunking mode">{ui}</RadioGroup>, {
systemFeatures: { deployment_edition: 'COMMUNITY' },
})
const createRules = (): PreProcessingRule[] => [
{ id: 'remove_extra_spaces', enabled: true },
@@ -46,7 +49,6 @@ const defaultProps = {
isActive: true,
isInUpload: false,
isNotUploadInEmptyDataset: false,
onDocFormChange: vi.fn(),
onChunkForContextChange: vi.fn(),
onParentDelimiterChange: vi.fn(),
onParentMaxLengthChange: vi.fn(),
@@ -120,16 +122,6 @@ describe('ParentChildOptions', () => {
expect(onRuleToggle).toHaveBeenCalledWith('remove_urls_emails')
})
it('should call onDocFormChange with parentChild when card switched', () => {
const onDocFormChange = vi.fn()
render(
<ParentChildOptions {...defaultProps} isActive={false} onDocFormChange={onDocFormChange} />,
)
const titleEl = screen.getByText(`${ns}.stepTwo.parentChild`)
fireEvent.click(titleEl.closest('[class*="rounded-xl"]')!)
expect(onDocFormChange).toHaveBeenCalledWith(ChunkingMode.parentChild)
})
it('should call onChunkForContextChange when full-doc chosen', () => {
const onChunkForContextChange = vi.fn()
render(
@@ -1,18 +1,16 @@
import type { ParentChildConfig } from '../../hooks'
import type { FileIndexingEstimateResponse } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useRef, useState } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { ChunkingMode, DataSourceType } from '@/models/datasets'
import { PreviewPanel } from '../preview-panel'
vi.mock('@/app/components/base/float-right-container', () => ({
default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="float-container">{children}</div>
),
}))
vi.mock('@/app/components/base/badge', () => ({
default: ({ text }: { text: string }) => <span data-testid="badge">{text}</span>,
default: ({ children }: { children: React.ReactNode }) => (
<span data-testid="badge">{children}</span>
),
}))
vi.mock('@/app/components/base/skeleton', () => ({
@@ -66,15 +64,6 @@ vi.mock('../../../../preview/container', () => ({
),
}))
vi.mock('../../../../preview/header', () => ({
PreviewHeader: ({ children, title }: { children: React.ReactNode; title: string }) => (
<div data-testid="preview-header">
{title}
{children}
</div>
),
}))
vi.mock('@/config', () => ({
FULL_DOC_PREVIEW_LENGTH: 3,
}))
@@ -82,6 +71,8 @@ vi.mock('@/config', () => ({
describe('PreviewPanel', () => {
const defaultProps = {
isMobile: false,
isOpen: false,
onClose: vi.fn(),
dataSourceType: DataSourceType.FILE,
currentDocForm: ChunkingMode.text,
parentChildConfig: { chunkForContext: 'paragraph' } as ParentChildConfig,
@@ -98,9 +89,64 @@ describe('PreviewPanel', () => {
it('should render preview header with title', () => {
render(<PreviewPanel {...defaultProps} />)
expect(screen.getByTestId('preview-header')).toHaveTextContent(
'datasetCreation.stepTwo.preview',
expect(
screen.getByRole('heading', { name: 'datasetCreation.stepTwo.preview', level: 2 }),
).toHaveTextContent('datasetCreation.stepTwo.preview')
})
it('announces preview loading and completed chunk count through a persistent status', () => {
const { rerender } = render(<PreviewPanel {...defaultProps} isPending />)
expect(screen.getByRole('status')).toHaveTextContent('common.loading')
rerender(
<PreviewPanel
{...defaultProps}
estimate={{ total_segments: 3, preview: [] } as unknown as FileIndexingEstimateResponse}
/>,
)
expect(screen.getByRole('status')).toHaveTextContent(
'datasetCreation.stepTwo.previewChunkCount',
)
})
it('allows mobile preview to close, restore focus, and reopen', async () => {
const user = userEvent.setup()
const MobilePreview = () => {
const [isOpen, setIsOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
return (
<>
<button ref={triggerRef} type="button" onClick={() => setIsOpen(true)}>
Preview chunks
</button>
<PreviewPanel
{...defaultProps}
isMobile
isOpen={isOpen}
onClose={() => setIsOpen(false)}
finalFocus={triggerRef}
/>
</>
)
}
render(<MobilePreview />)
const trigger = screen.getByRole('button', { name: 'Preview chunks' })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await user.click(trigger)
const dialog = await screen.findByRole('dialog', { name: 'datasetCreation.stepTwo.preview' })
await user.click(within(dialog).getByRole('button', { name: 'common.operation.close' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
await waitFor(() => expect(trigger).toHaveFocus())
await user.keyboard('{Enter}')
expect(
await screen.findByRole('dialog', { name: 'datasetCreation.stepTwo.preview' }),
).toBeInTheDocument()
await user.keyboard('{Escape}')
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
await waitFor(() => expect(trigger).toHaveFocus())
})
it('should render document picker', () => {
@@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import type { FC, MouseEventHandler } from 'react'
import type {
PreProcessingRule,
SummaryIndexSetting as SummaryIndexSettingType,
@@ -49,7 +49,7 @@ type GeneralChunkingOptionsProps = {
onRuleToggle: (id: string) => void
onDocFormChange: (form: ChunkingMode) => void
onDocLanguageChange: (lang: string) => void
onPreview: () => void
onPreview: MouseEventHandler<HTMLButtonElement>
onReset: () => void
// Locale
locale: string
@@ -113,7 +113,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
activeHeaderClassName="bg-dataset-option-card-blue-gradient"
description={t(($) => $['stepTwo.generalTip'], { ns: 'datasetCreation' })}
isActive={isActive}
onSwitched={() => onDocFormChange(ChunkingMode.text)}
value={ChunkingMode.text}
actions={
<>
<Button variant="secondary-accent" onClick={onPreview}>
@@ -167,7 +167,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
{isNonCloudEdition && (
<>
<Separator orientation="horizontal" className="my-4 h-[0.5px] bg-divider-subtle" />
<div className="flex items-center py-0.5">
<div className="flex flex-wrap items-center gap-y-2 py-0.5">
<label
className={`flex items-center ${hasCurrentDatasetDocForm ? '' : 'cursor-pointer'}`}
>
@@ -207,7 +207,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
background:
'linear-gradient(92deg, rgba(247, 144, 9, 0.1) 0%, rgba(255, 255, 255, 0.00) 100%)',
}}
className="mt-2 flex h-10 items-center gap-2 rounded-xl border border-components-panel-border px-3 text-xs shadow-xs backdrop-blur-[5px]"
className="mt-2 flex min-h-10 items-center gap-2 rounded-xl border border-components-panel-border px-3 text-xs shadow-xs backdrop-blur-[5px]"
>
<RiAlertFill className="size-4 text-text-warning-secondary" />
<span className="system-xs-medium text-text-primary">
@@ -13,6 +13,7 @@ import {
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { Separator } from '@langgenius/dify-ui/separator'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
@@ -85,9 +86,9 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
return (
<>
{/* Index Mode */}
<div className="mb-1 system-md-semibold text-text-secondary">
<h2 className="mb-1 system-md-semibold text-text-secondary">
{t(($) => $['stepTwo.indexMode'], { ns: 'datasetCreation' })}
</div>
</h2>
<AlertDialog
open={isQAConfirmDialogOpen}
onOpenChange={(open) => {
@@ -113,13 +114,18 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
<div className="flex items-stretch gap-2">
<RadioGroup<IndexingType>
aria-label={t(($) => $['stepTwo.indexMode'], { ns: 'datasetCreation' })}
value={indexType}
onValueChange={onIndexTypeChange}
className="flex flex-col items-stretch gap-2 @min-[552px]/chunkfields:flex-row"
>
{/* Qualified option */}
{(!hasSetIndexType || (hasSetIndexType && indexType === IndexingType.QUALIFIED)) && (
<OptionCard
className="flex-1 self-stretch"
title={
<div className="flex items-center">
<div className="flex flex-wrap items-center gap-y-1">
{t(($) => $['stepTwo.qualified'], { ns: 'datasetCreation' })}
<Badge
className={cn(
@@ -141,7 +147,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
icon={<img src={indexMethodIcon.high_quality} alt="" />}
isActive={!hasSetIndexType && indexType === IndexingType.QUALIFIED}
disabled={hasSetIndexType}
onSwitched={() => onIndexTypeChange(IndexingType.QUALIFIED)}
value={IndexingType.QUALIFIED}
/>
)}
@@ -157,14 +163,14 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
icon={<img src={indexMethodIcon.economical} alt="" />}
isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL}
disabled={hasSetIndexType || !!economicalDisabledReason}
onSwitched={() => onIndexTypeChange(IndexingType.ECONOMICAL)}
value={IndexingType.ECONOMICAL}
/>
)}
</div>
</RadioGroup>
{/* High quality tip */}
{!hasSetIndexType && indexType === IndexingType.QUALIFIED && (
<div className="mt-2 flex h-10 items-center gap-x-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs backdrop-blur-[5px]">
<div className="mt-2 flex min-h-10 items-center gap-x-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs backdrop-blur-[5px]">
<div className="absolute inset-0 bg-dataset-warning-message-bg opacity-40"></div>
<div className="p-1">
<span className="size-4 text-text-warning-secondary" />
@@ -188,14 +194,14 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
{/* Embedding model */}
{indexType === IndexingType.QUALIFIED && (
<div className="mt-5">
<div
<h2
className={cn(
'mb-1 system-md-semibold text-text-secondary',
datasetId && 'flex items-center justify-between',
)}
>
{t(($) => $['form.embeddingModel'], { ns: 'datasetSettings' })}
</div>
</h2>
<MultimodalRetrievalGuidanceLearnMore className="mb-2" />
<MultimodalRetrievalGuidance
variant="create"
@@ -226,9 +232,9 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
<div>
{!isModelAndRetrievalConfigDisabled ? (
<div className="mb-1">
<div className="mb-0.5 system-md-semibold text-text-secondary">
<h2 className="mb-0.5 system-md-semibold text-text-secondary">
{t(($) => $['form.retrievalSetting.title'], { ns: 'datasetSettings' })}
</div>
</h2>
<div className="body-xs-regular text-text-tertiary">
<a
target="_blank"
@@ -1,5 +1,7 @@
import type { ComponentProps, FC, ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { RadioItem } from '@langgenius/dify-ui/radio-group'
import { useId } from 'react'
const TriangleArrow: FC<ComponentProps<'svg'>> = (props) => (
<svg
@@ -25,19 +27,31 @@ type OptionCardHeaderProps = {
activeClassName?: string
effectImg?: string
disabled?: boolean
titleId?: string
descriptionId?: string
}
const OptionCardHeader: FC<OptionCardHeaderProps> = (props) => {
const { icon, title, description, isActive, activeClassName, effectImg, disabled } = props
const {
icon,
title,
description,
isActive,
activeClassName,
effectImg,
disabled,
titleId,
descriptionId,
} = props
return (
<div
className={cn(
'relative flex flex-1 overflow-hidden rounded-t-xl',
'relative flex min-w-0 flex-1 overflow-hidden rounded-t-xl',
isActive && activeClassName,
!disabled && 'cursor-pointer',
)}
>
<div className="relative flex size-14 items-center justify-center overflow-hidden">
<div className="relative flex size-14 shrink-0 items-center justify-center overflow-hidden">
{isActive && effectImg && (
<img
src={effectImg}
@@ -59,15 +73,20 @@ const OptionCardHeader: FC<OptionCardHeaderProps> = (props) => {
isActive && 'text-components-panel-bg',
)}
/>
<div className="flex-1 space-y-0.5 py-3 pr-4">
<div className="system-md-semibold text-text-secondary">{title}</div>
<div className="system-xs-regular text-text-tertiary">{description}</div>
<div className="min-w-0 flex-1 space-y-0.5 py-3 pr-4">
<div id={titleId} className="system-md-semibold text-text-secondary">
{title}
</div>
<div id={descriptionId} className="system-xs-regular text-text-tertiary">
{description}
</div>
</div>
</div>
)
}
type OptionCardProps = {
type OptionCardProps<Value> = {
value: Value
icon: ReactNode
className?: string
activeHeaderClassName?: string
@@ -76,12 +95,11 @@ type OptionCardProps = {
isActive?: boolean
actions?: ReactNode
effectImg?: string
onSwitched?: () => void
noHighlight?: boolean
disabled?: boolean
} & Omit<ComponentProps<'div'>, 'title' | 'onClick'>
export const OptionCard: FC<OptionCardProps> = ({ ref, ...props }) => {
export const OptionCard = <Value,>({ ref, ...props }: OptionCardProps<Value>) => {
const {
icon,
className,
@@ -93,44 +111,62 @@ export const OptionCard: FC<OptionCardProps> = ({ ref, ...props }) => {
activeHeaderClassName,
style,
effectImg,
onSwitched,
value,
noHighlight,
disabled,
...rest
} = props
const titleId = useId()
const descriptionId = useId()
return (
<div
className={cn(
'flex flex-col rounded-xl bg-components-option-card-option-bg shadow-xs',
'flex min-w-0 flex-col rounded-xl bg-components-option-card-option-bg shadow-xs',
isActive && !noHighlight
? 'border-[1.5px] border-components-option-card-option-selected-border'
: 'border border-components-option-card-option-border',
disabled && 'pointer-events-none opacity-50',
disabled && 'opacity-50',
className,
)}
style={{
...style,
}}
onClick={() => {
if (!isActive && !disabled) onSwitched?.()
}}
{...rest}
ref={ref}
>
<OptionCardHeader
icon={icon}
title={title}
description={description}
isActive={isActive && !noHighlight}
activeClassName={activeHeaderClassName}
effectImg={effectImg}
<RadioItem<Value>
value={value}
nativeButton
render={<button type="button" />}
disabled={disabled}
/>
aria-labelledby={titleId}
aria-describedby={descriptionId}
className="flex w-full min-w-0 rounded-t-xl border-0 bg-transparent p-0 text-left outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:ring-inset"
>
<OptionCardHeader
titleId={titleId}
descriptionId={descriptionId}
icon={icon}
title={title}
description={description}
isActive={isActive && !noHighlight}
activeClassName={activeHeaderClassName}
effectImg={effectImg}
disabled={disabled}
/>
</RadioItem>
{/** Body */}
{!!(isActive && (children || actions)) && (
<div className="rounded-b-xl bg-components-panel-bg px-4 py-3">
<div
role="presentation"
className="rounded-b-xl bg-components-panel-bg px-4 py-3"
onKeyDown={(event) => {
// Keep parameter arrow keys from navigating the enclosing radio group.
if (event.key.startsWith('Arrow')) event.stopPropagation()
}}
>
{children}
{!!actions && <div className="mt-4 flex gap-2">{actions}</div>}
{!!actions && <div className="mt-4 flex flex-wrap gap-2">{actions}</div>}
</div>
)}
</div>
@@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import type { FC, MouseEventHandler } from 'react'
import type { ParentChildConfig } from '../hooks'
import type {
ParentMode,
@@ -45,14 +45,13 @@ type ParentChildOptionsProps = {
isInUpload: boolean
isNotUploadInEmptyDataset: boolean
// Actions
onDocFormChange: (form: ChunkingMode) => void
onChunkForContextChange: (mode: ParentMode) => void
onParentDelimiterChange: (value: string) => void
onParentMaxLengthChange: (value: number) => void
onChildDelimiterChange: (value: string) => void
onChildMaxLengthChange: (value: number) => void
onRuleToggle: (id: string) => void
onPreview: () => void
onPreview: MouseEventHandler<HTMLButtonElement>
onReset: () => void
showSummaryIndexSetting?: boolean
}
@@ -65,7 +64,6 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
isActive,
isInUpload,
isNotUploadInEmptyDataset,
onDocFormChange,
onChunkForContextChange,
onParentDelimiterChange,
onParentMaxLengthChange,
@@ -102,7 +100,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
activeHeaderClassName="bg-dataset-option-card-blue-gradient"
description={t(($) => $['stepTwo.parentChildTip'], { ns: 'datasetCreation' })}
isActive={isActive}
onSwitched={() => onDocFormChange(ChunkingMode.parentChild)}
value={ChunkingMode.parentChild}
actions={
<>
<Button variant="secondary-accent" onClick={onPreview}>
@@ -139,7 +137,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
title={t(($) => $['stepTwo.paragraph'], { ns: 'datasetCreation' })}
description={t(($) => $['stepTwo.paragraphTip'], { ns: 'datasetCreation' })}
chosenConfig={
<div className="flex gap-3">
<div className="flex flex-col gap-3 @min-[552px]/chunkfields:flex-row">
<DelimiterInput
value={parentChildConfig.parent.delimiter}
tooltip={t(($) => $['stepTwo.parentChildDelimiterTip'], {
@@ -174,7 +172,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
</div>
<Separator decorative className="my-2 h-[0.5px] grow" variant="gradient" />
</div>
<div className="mt-1 flex gap-3">
<div className="mt-1 flex flex-col gap-3 @min-[552px]/chunkfields:flex-row">
<DelimiterInput
value={parentChildConfig.child.delimiter}
tooltip={t(($) => $['stepTwo.parentChildChunkDelimiterTip'], {
@@ -1,11 +1,10 @@
'use client'
import type { FC } from 'react'
import type { ComponentProps, FC } from 'react'
import type { ParentChildConfig } from '../hooks'
import type { DataSourceType, FileIndexingEstimateResponse } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import { RiSearchEyeLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import FloatRightContainer from '@/app/components/base/float-right-container'
@@ -23,11 +22,13 @@ import SummaryLabel from '../../../documents/detail/completed/common/summary-lab
import { PreviewSlice } from '../../../formatted-text/flavours/preview-slice'
import { FormattedText } from '../../../formatted-text/formatted'
import PreviewContainer from '../../../preview/container'
import { PreviewHeader } from '../../../preview/header'
type PreviewPanelProps = {
// State
isMobile: boolean
isOpen: boolean
onClose: () => void
finalFocus?: ComponentProps<typeof FloatRightContainer>['finalFocus']
dataSourceType: DataSourceType
currentDocForm: ChunkingMode
estimate?: FileIndexingEstimateResponse
@@ -45,6 +46,9 @@ type PreviewPanelProps = {
export const PreviewPanel: FC<PreviewPanelProps> = ({
isMobile,
isOpen,
onClose,
finalFocus,
dataSourceType: _dataSourceType,
currentDocForm,
estimate,
@@ -59,11 +63,33 @@ export const PreviewPanel: FC<PreviewPanelProps> = ({
const { t } = useTranslation()
return (
<FloatRightContainer isMobile={isMobile} isOpen={true} onClose={noop}>
<FloatRightContainer
isMobile={isMobile}
isOpen={!isMobile || isOpen}
onClose={onClose}
showClose
title={t(($) => $['stepTwo.preview'], { ns: 'datasetCreation' })}
finalFocus={finalFocus}
>
<div role="status" aria-atomic="true" className="sr-only">
{isPending
? t(($) => $.loading, { ns: 'common' })
: estimate
? t(($) => $['stepTwo.previewChunkCount'], {
ns: 'datasetCreation',
count: estimate.total_segments ?? estimate.qa_preview?.length ?? 0,
})
: ''}
</div>
<PreviewContainer
header={
<PreviewHeader title={t(($) => $['stepTwo.preview'], { ns: 'datasetCreation' })}>
<div className="flex items-center gap-1">
<div>
{!isMobile && (
<h2 className="mb-1 px-1 system-2xs-semibold-uppercase text-text-accent uppercase">
{t(($) => $['stepTwo.preview'], { ns: 'datasetCreation' })}
</h2>
)}
<div className="flex min-w-0 flex-wrap items-center gap-1">
<PreviewDocumentPicker
files={
pickerFiles as Array<Required<{ id: string; name: string; extension: string }>>
@@ -72,23 +98,21 @@ export const PreviewPanel: FC<PreviewPanelProps> = ({
value={isSetting ? pickerFiles[0] : pickerValue}
/>
{currentDocForm !== ChunkingMode.qa && (
<Badge
text={
t(($) => $['stepTwo.previewChunkCount'], {
ns: 'datasetCreation',
count: estimate?.total_segments || 0,
}) as string
}
/>
<Badge>
{t(($) => $['stepTwo.previewChunkCount'], {
ns: 'datasetCreation',
count: estimate?.total_segments || 0,
})}
</Badge>
)}
</div>
</PreviewHeader>
</div>
}
className={cn(
'relative flex h-full w-1/2 shrink-0 p-4 pr-0',
isMobile && 'w-full max-w-131',
'relative flex min-w-0 shrink-0 p-4',
isMobile ? 'min-h-0 w-full flex-1' : 'h-full w-1/2 pr-0',
)}
mainClassName="space-y-6"
mainClassName="space-y-6 wrap-break-word"
>
{/* QA Preview */}
{currentDocForm === ChunkingMode.qa &&
@@ -176,7 +200,7 @@ export const PreviewPanel: FC<PreviewPanelProps> = ({
</SkeletonRow>
<SkeletonRectangle className="w-full" />
<SkeletonRectangle className="w-full" />
<SkeletonRectangle className="w-105.5" />
<SkeletonRectangle className="w-full max-w-105.5" />
</SkeletonContainer>
))}
</div>
@@ -24,7 +24,7 @@ export const StepTwoFooter: FC<StepTwoFooterProps> = ({
if (!isSetting) {
return (
<div className="mt-8 flex items-center py-2">
<div className="mt-8 flex flex-wrap items-center gap-y-2 py-2">
<Button onClick={onPrevious}>
<RiArrowLeftLine className="size-4" />
{t(($) => $['stepTwo.previousStep'], { ns: 'datasetCreation' })}
@@ -37,7 +37,7 @@ export const StepTwoFooter: FC<StepTwoFooterProps> = ({
}
return (
<div className="mt-8 flex items-center py-2">
<div className="mt-8 flex flex-wrap items-center gap-y-2 py-2">
<Button loading={isCreating} variant="primary" onClick={onCreate}>
{t(($) => $['stepTwo.save'], { ns: 'datasetCreation' })}
</Button>
@@ -1,10 +1,11 @@
'use client'
import type { FC } from 'react'
import type { FC, MouseEvent } from 'react'
import type { StepTwoProps } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { Separator } from '@langgenius/dify-ui/separator'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocale } from '#i18n'
import { toast } from '@/app/notifications'
@@ -61,6 +62,8 @@ const StepTwo: FC<StepTwoProps> = ({
const { t } = useTranslation()
const locale = useLocale()
const isMobile = useBreakpoints() === MediaType.mobile
const [isPreviewOpen, setIsPreviewOpen] = useState(false)
const previewTriggerRef = useRef<HTMLButtonElement | null>(null)
const currentDataset = useDatasetDetailContextWithSelector((s) => s.dataset)
const mutateDatasetRes = useDatasetDetailContextWithSelector((s) => s.mutateDatasetRes)
@@ -183,21 +186,26 @@ const StepTwo: FC<StepTwoProps> = ({
[indexing, segmentation, estimateHook],
)
const updatePreview = useCallback(() => {
if (
segmentation.segmentationType === ProcessMode.general &&
segmentation.maxChunkLength > MAXIMUM_CHUNK_TOKEN_LENGTH
) {
toast.error(
t(($) => $['stepTwo.maxLengthCheck'], {
ns: 'datasetCreation',
limit: MAXIMUM_CHUNK_TOKEN_LENGTH,
}),
)
return
}
estimateHook.fetchEstimate()
}, [segmentation, t, estimateHook])
const updatePreview = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
if (
segmentation.segmentationType === ProcessMode.general &&
segmentation.maxChunkLength > MAXIMUM_CHUNK_TOKEN_LENGTH
) {
toast.error(
t(($) => $['stepTwo.maxLengthCheck'], {
ns: 'datasetCreation',
limit: MAXIMUM_CHUNK_TOKEN_LENGTH,
}),
)
return
}
previewTriggerRef.current = event.currentTarget
setIsPreviewOpen(true)
estimateHook.fetchEstimate()
},
[segmentation, t, estimateHook],
)
const handleCreate = useCallback(async () => {
if (!canCreateDocument) return
@@ -267,61 +275,70 @@ const StepTwo: FC<StepTwoProps> = ({
isInInit
return (
<div className="flex size-full">
<div className={cn('flex size-full min-w-0', isMobile && 'flex-col overflow-y-auto')}>
<div
className={cn('relative h-full w-1/2 overflow-y-auto py-6', isMobile ? 'px-4' : 'px-12')}
className={cn(
'@container/chunkfields relative min-w-0 py-6',
isMobile ? 'w-full shrink-0 px-4' : 'h-full w-1/2 overflow-y-auto px-12',
)}
>
<div className="mb-1 system-md-semibold text-text-secondary">
<h1 className="mb-1 system-md-semibold text-text-secondary">
{t(($) => $['stepTwo.segmentation'], { ns: 'datasetCreation' })}
</div>
{showGeneralOption && (
<GeneralChunkingOptions
segmentIdentifier={segmentation.segmentIdentifier}
maxChunkLength={segmentation.maxChunkLength}
overlap={segmentation.overlap}
rules={segmentation.rules}
currentDocForm={currentDocForm}
docLanguage={docLanguage}
isActive={generalChunkingModes.includes(currentDocForm)}
isInUpload={isInUpload}
isNotUploadInEmptyDataset={isNotUploadInEmptyDataset}
hasCurrentDatasetDocForm={!!currentDataset?.doc_form}
onSegmentIdentifierChange={(value) => segmentation.setSegmentIdentifier(value, true)}
onMaxChunkLengthChange={segmentation.setMaxChunkLength}
onOverlapChange={segmentation.setOverlap}
onRuleToggle={segmentation.toggleRule}
onDocFormChange={handleDocFormChange}
onDocLanguageChange={setDocLanguage}
onPreview={updatePreview}
onReset={segmentation.resetToDefaults}
locale={locale}
showSummaryIndexSetting={showSummaryIndexSetting}
summaryIndexSetting={segmentation.summaryIndexSetting}
onSummaryIndexSettingChange={segmentation.handleSummaryIndexSettingChange}
/>
)}
{showParentChildOption && (
<ParentChildOptions
parentChildConfig={segmentation.parentChildConfig}
rules={segmentation.rules}
currentDocForm={currentDocForm}
isActive={currentDocForm === ChunkingMode.parentChild}
isInUpload={isInUpload}
isNotUploadInEmptyDataset={isNotUploadInEmptyDataset}
onDocFormChange={handleDocFormChange}
onChunkForContextChange={segmentation.setChunkForContext}
onParentDelimiterChange={(v) => segmentation.updateParentConfig('delimiter', v)}
onParentMaxLengthChange={(v) => segmentation.updateParentConfig('maxLength', v)}
onChildDelimiterChange={(v) => segmentation.updateChildConfig('delimiter', v)}
onChildMaxLengthChange={(v) => segmentation.updateChildConfig('maxLength', v)}
onRuleToggle={segmentation.toggleRule}
onPreview={updatePreview}
onReset={segmentation.resetToDefaults}
showSummaryIndexSetting={showSummaryIndexSetting}
summaryIndexSetting={segmentation.summaryIndexSetting}
onSummaryIndexSettingChange={segmentation.handleSummaryIndexSettingChange}
/>
)}
</h1>
<RadioGroup<ChunkingMode>
className="block"
aria-label={t(($) => $['stepTwo.segmentation'], { ns: 'datasetCreation' })}
value={currentDocForm === ChunkingMode.qa ? ChunkingMode.text : currentDocForm}
onValueChange={handleDocFormChange}
>
{showGeneralOption && (
<GeneralChunkingOptions
segmentIdentifier={segmentation.segmentIdentifier}
maxChunkLength={segmentation.maxChunkLength}
overlap={segmentation.overlap}
rules={segmentation.rules}
currentDocForm={currentDocForm}
docLanguage={docLanguage}
isActive={generalChunkingModes.includes(currentDocForm)}
isInUpload={isInUpload}
isNotUploadInEmptyDataset={isNotUploadInEmptyDataset}
hasCurrentDatasetDocForm={!!currentDataset?.doc_form}
onSegmentIdentifierChange={(value) => segmentation.setSegmentIdentifier(value, true)}
onMaxChunkLengthChange={segmentation.setMaxChunkLength}
onOverlapChange={segmentation.setOverlap}
onRuleToggle={segmentation.toggleRule}
onDocFormChange={handleDocFormChange}
onDocLanguageChange={setDocLanguage}
onPreview={updatePreview}
onReset={segmentation.resetToDefaults}
locale={locale}
showSummaryIndexSetting={showSummaryIndexSetting}
summaryIndexSetting={segmentation.summaryIndexSetting}
onSummaryIndexSettingChange={segmentation.handleSummaryIndexSettingChange}
/>
)}
{showParentChildOption && (
<ParentChildOptions
parentChildConfig={segmentation.parentChildConfig}
rules={segmentation.rules}
currentDocForm={currentDocForm}
isActive={currentDocForm === ChunkingMode.parentChild}
isInUpload={isInUpload}
isNotUploadInEmptyDataset={isNotUploadInEmptyDataset}
onChunkForContextChange={segmentation.setChunkForContext}
onParentDelimiterChange={(v) => segmentation.updateParentConfig('delimiter', v)}
onParentMaxLengthChange={(v) => segmentation.updateParentConfig('maxLength', v)}
onChildDelimiterChange={(v) => segmentation.updateChildConfig('delimiter', v)}
onChildMaxLengthChange={(v) => segmentation.updateChildConfig('maxLength', v)}
onRuleToggle={segmentation.toggleRule}
onPreview={updatePreview}
onReset={segmentation.resetToDefaults}
showSummaryIndexSetting={showSummaryIndexSetting}
summaryIndexSetting={segmentation.summaryIndexSetting}
onSummaryIndexSettingChange={segmentation.handleSummaryIndexSettingChange}
/>
)}
</RadioGroup>
<Separator className="my-5 h-[0.5px]" />
<IndexingModeSection
indexType={indexing.indexType}
@@ -350,6 +367,9 @@ const StepTwo: FC<StepTwoProps> = ({
</div>
<PreviewPanel
isMobile={isMobile}
isOpen={isPreviewOpen}
onClose={() => setIsPreviewOpen(false)}
finalFocus={previewTriggerRef}
dataSourceType={dataSourceType}
currentDocForm={currentDocForm}
estimate={estimateHook.estimate}
@@ -11,13 +11,13 @@ export type StepperProps = {
export const Stepper: FC<StepperProps> = (props) => {
const { steps, activeIndex } = props
return (
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-3">
{steps.map((step, index) => {
const isLast = index === steps.length - 1
return (
<Fragment key={index}>
<StepperStep {...step} activeIndex={activeIndex} index={index} />
{!isLast && <div className="h-px w-4 bg-divider-deep" />}
{!isLast && <div className="hidden h-px w-4 bg-divider-deep sm:block" />}
</Fragment>
)
})}
@@ -29,7 +29,7 @@ export const TopBar: FC<TopBarProps> = (props) => {
return (
<div
className={cn(
'relative flex h-13 shrink-0 items-center justify-between border-b border-b-divider-subtle',
'relative flex shrink-0 flex-col items-stretch border-b border-b-divider-subtle xl:min-h-13 xl:flex-row xl:items-center xl:justify-between',
className,
)}
>
@@ -45,7 +45,7 @@ export const TopBar: FC<TopBarProps> = (props) => {
{t(($) => $['steps.header.fallbackRoute'], { ns: 'datasetCreation' })}
</p>
</Link>
<div className="absolute top-1/2 left-1/2 -translate-1/2">
<div className="min-w-0 px-4 pb-3 xl:absolute xl:top-1/2 xl:left-1/2 xl:-translate-1/2 xl:p-0">
<Stepper
steps={Array.from({ length: 3 }, (_, i) => ({
name: t(($) => $[STEP_T_MAP[(i + 1) as keyof typeof STEP_T_MAP]], {
@@ -3,6 +3,7 @@ import type { FC } from 'react'
import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types'
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import { RadioGroup, RadioItem } from '@langgenius/dify-ui/radio-group'
import { useQueryState } from 'nuqs'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
@@ -45,6 +46,7 @@ const Website: FC<Props> = ({
authedDataSourceList,
}) => {
const { t } = useTranslation()
const providerLabelId = React.useId()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const [selectedProvider, setSelectedProvider] = useState<DataSourceProvider>(
DataSourceProvider.jinaReader,
@@ -73,64 +75,60 @@ const Website: FC<Props> = ({
return (
<div>
<div className="mb-4">
<div className="mb-2 system-md-medium text-text-secondary">
<h2 id={providerLabelId} className="mb-2 system-md-medium text-text-secondary">
{t(($) => $['stepOne.website.chooseProvider'], { ns: 'datasetCreation' })}
</div>
<div className="flex space-x-2">
</h2>
<RadioGroup
aria-labelledby={providerLabelId}
value={selectedProvider}
onValueChange={(provider: DataSourceProvider) => {
setSelectedProvider(provider)
onCrawlProviderChange(provider)
}}
className="flex flex-wrap gap-2"
>
{ENABLE_WEBSITE_JINAREADER && (
<button
type="button"
<RadioItem
value={DataSourceProvider.jinaReader}
className={cn(
'flex items-center justify-center rounded-lg px-4 py-2',
'flex items-center justify-center rounded-lg px-4 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-input-border-active',
selectedProvider === DataSourceProvider.jinaReader
? 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-sm-medium text-text-primary'
: `border border-components-option-card-option-border bg-components-option-card-option-bg system-sm-regular text-text-secondary hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs hover:shadow-shadow-shadow-3`,
)}
onClick={() => {
setSelectedProvider(DataSourceProvider.jinaReader)
onCrawlProviderChange(DataSourceProvider.jinaReader)
}}
>
<span className={cn(s.jinaLogo, 'mr-2')} />
<span>Jina Reader</span>
</button>
</RadioItem>
)}
{ENABLE_WEBSITE_FIRECRAWL && (
<button
type="button"
<RadioItem
value={DataSourceProvider.fireCrawl}
className={cn(
'rounded-lg px-4 py-2',
'rounded-lg px-4 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-input-border-active',
selectedProvider === DataSourceProvider.fireCrawl
? 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-sm-medium text-text-primary'
: `border border-components-option-card-option-border bg-components-option-card-option-bg system-sm-regular text-text-secondary hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs hover:shadow-shadow-shadow-3`,
)}
onClick={() => {
setSelectedProvider(DataSourceProvider.fireCrawl)
onCrawlProviderChange(DataSourceProvider.fireCrawl)
}}
>
🔥 Firecrawl
</button>
</RadioItem>
)}
{ENABLE_WEBSITE_WATERCRAWL && (
<button
type="button"
<RadioItem
value={DataSourceProvider.waterCrawl}
className={cn(
'flex items-center justify-center rounded-lg px-4 py-2',
'flex items-center justify-center rounded-lg px-4 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-input-border-active',
selectedProvider === DataSourceProvider.waterCrawl
? 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-sm-medium text-text-primary'
: `border border-components-option-card-option-border bg-components-option-card-option-bg system-sm-regular text-text-secondary hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs hover:shadow-shadow-shadow-3`,
)}
onClick={() => {
setSelectedProvider(DataSourceProvider.waterCrawl)
onCrawlProviderChange(DataSourceProvider.waterCrawl)
}}
>
<span className={cn(s.watercrawlLogo, 'mr-2')} />
<span>WaterCrawl</span>
</button>
</RadioItem>
)}
</div>
</RadioGroup>
</div>
{source && selectedProvider === DataSourceProvider.fireCrawl && (
<Firecrawl
@@ -74,7 +74,7 @@ describe('SummaryIndexSetting', () => {
/>,
)
// Find and click the switch
const switchEl = screen.getByRole('switch')
const switchEl = screen.getByRole('switch', { name: `${ns}.form.summaryAutoGen` })
fireEvent.click(switchEl)
expect(onChange).toHaveBeenCalledWith({ enable: true })
})
@@ -1,5 +1,7 @@
import type { ChunkingMode } from '@/models/datasets'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import OptionCard from '../option-card'
import { useChunkStructure } from './hooks'
@@ -8,10 +10,16 @@ type ChunkStructureProps = {
}
const ChunkStructure = ({ chunkStructure }: ChunkStructureProps) => {
const { t } = useTranslation()
const { options } = useChunkStructure()
return (
<div className="flex flex-col gap-y-1">
<RadioGroup<ChunkingMode>
aria-label={t(($) => $['form.chunkStructure.title'], { ns: 'datasetSettings' })}
value={chunkStructure}
disabled
className="flex flex-col items-stretch gap-x-0 gap-y-1"
>
{options.map((option) => (
<OptionCard
key={option.id}
@@ -27,7 +35,7 @@ const ChunkStructure = ({ chunkStructure }: ChunkStructureProps) => {
disabled
/>
))}
</div>
</RadioGroup>
)
}
@@ -1,243 +1,110 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { IndexingType } from '../../../create/step-two'
import IndexMethod from '../index'
const Settings = ({
disabled = false,
currentValue = IndexingType.ECONOMICAL,
}: {
disabled?: boolean
currentValue?: IndexingType
}) => {
const [value, setValue] = useState<IndexingType>(currentValue)
const [keywordNumber, setKeywordNumber] = useState(10)
return (
<IndexMethod
value={value}
onChange={setValue}
currentValue={currentValue}
disabled={disabled}
keywordNumber={keywordNumber}
onKeywordNumberChange={setKeywordNumber}
/>
)
}
describe('IndexMethod', () => {
const defaultProps = {
value: IndexingType.QUALIFIED,
onChange: vi.fn(),
keywordNumber: 10,
onKeywordNumberChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
it('hides inactive economy parameters and preserves their draft when switching back', async () => {
const user = userEvent.setup()
render(<Settings />)
const group = screen.getByRole('radiogroup', { name: 'datasetSettings.form.indexMethod' })
const economy = within(group).getByRole('radio', {
name: 'datasetSettings.form.indexMethodEconomy',
})
const qualified = within(group).getByRole('radio', {
name: 'datasetCreation.stepTwo.qualified',
})
const input = screen.getByRole('textbox', { name: 'datasetSettings.form.numberOfKeywords' })
expect(economy).toBeChecked()
expect(economy).not.toContainElement(input)
await user.clear(input)
await user.type(input, '25')
await user.tab()
await user.click(qualified)
expect(qualified).toBeChecked()
expect(economy).not.toBeChecked()
expect(
screen.queryByRole('textbox', { name: 'datasetSettings.form.numberOfKeywords' }),
).not.toBeInTheDocument()
await user.click(economy)
expect(
screen.getByRole('textbox', { name: 'datasetSettings.form.numberOfKeywords' }),
).toHaveValue('25')
})
const getKeywordSlider = () =>
screen.getByLabelText('datasetSettings.form.numberOfKeywords', {
selector: 'input[type="range"]',
})
it.each(['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'])(
'keeps Economy selected while editing keywords with %s',
async (key) => {
const user = userEvent.setup()
render(<Settings />)
const input = screen.getByRole('textbox', { name: 'datasetSettings.form.numberOfKeywords' })
await user.click(input)
await user.keyboard(key === 'ArrowLeft' ? '{Home}' : '{End}')
await user.keyboard(`{${key}}`)
describe('Rendering', () => {
it('should render High Quality option', () => {
render(<IndexMethod {...defaultProps} />)
expect(screen.getByText(/stepTwo\.qualified/))!.toBeInTheDocument()
})
expect(
screen.getByRole('radio', { name: 'datasetSettings.form.indexMethodEconomy' }),
).toBeChecked()
expect(
screen.getByRole('radio', { name: 'datasetCreation.stepTwo.qualified' }),
).not.toBeChecked()
expect(input).toBeInTheDocument()
expect(input).toHaveFocus()
},
)
it('should render Economy option', () => {
render(<IndexMethod {...defaultProps} />)
expect(screen.getAllByText(/form\.indexMethodEconomy/).length).toBeGreaterThan(0)
})
it('should render High Quality description', () => {
render(<IndexMethod {...defaultProps} />)
expect(screen.getByText(/form\.indexMethodHighQualityTip/))!.toBeInTheDocument()
})
it('should render Economy description', () => {
render(<IndexMethod {...defaultProps} />)
expect(screen.getByText(/form\.indexMethodEconomyTip/))!.toBeInTheDocument()
})
it('should render recommended badge on High Quality', () => {
render(<IndexMethod {...defaultProps} />)
expect(screen.getByText(/stepTwo\.recommend/))!.toBeInTheDocument()
})
it('uses arrow keys to select one index method without toggling the selected radio off', async () => {
const user = userEvent.setup()
render(<Settings />)
const economy = screen.getByRole('radio', { name: 'datasetSettings.form.indexMethodEconomy' })
const qualified = screen.getByRole('radio', { name: 'datasetCreation.stepTwo.qualified' })
economy.focus()
await user.keyboard('{ArrowUp}')
expect(qualified).toHaveFocus()
expect(qualified).toBeChecked()
expect(economy).not.toBeChecked()
await user.keyboard(' ')
expect(qualified).toBeChecked()
})
describe('Active State', () => {
it('should mark High Quality as active when value is QUALIFIED', () => {
const { container } = render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} />)
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
})
it('should mark Economy as active when value is ECONOMICAL', () => {
const { container } = render(
<IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} />,
)
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
})
it('prevents editing keywords or selection while settings are disabled', async () => {
const user = userEvent.setup()
render(<Settings disabled />)
const input = screen.getByRole('textbox', { name: 'datasetSettings.form.numberOfKeywords' })
expect(input).toBeDisabled()
const qualified = screen.getByRole('radio', { name: 'datasetCreation.stepTwo.qualified' })
await user.click(qualified)
expect(qualified).not.toBeChecked()
})
describe('User Interactions', () => {
it('should call onChange with QUALIFIED when High Quality is clicked', () => {
const handleChange = vi.fn()
render(
<IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} onChange={handleChange} />,
)
// Find and click High Quality option
const highQualityTitle = screen.getByText(/stepTwo\.qualified/)
const card = highQualityTitle.closest('div')?.parentElement?.parentElement?.parentElement
fireEvent.click(card!)
expect(handleChange).toHaveBeenCalledWith(IndexingType.QUALIFIED)
})
it('should call onChange with ECONOMICAL when Economy is clicked', () => {
const handleChange = vi.fn()
render(
<IndexMethod
{...defaultProps}
value={IndexingType.QUALIFIED}
onChange={handleChange}
currentValue={IndexingType.ECONOMICAL}
/>,
)
// Find and click Economy option - use getAllByText and get the first one (title)
const economyTitles = screen.getAllByText(/form\.indexMethodEconomy/)
const economyTitle = economyTitles[0]
const card = economyTitle!.closest('div')?.parentElement?.parentElement?.parentElement
fireEvent.click(card!)
expect(handleChange).toHaveBeenCalledWith(IndexingType.ECONOMICAL)
})
it('should not call onChange when clicking already active option', () => {
const handleChange = vi.fn()
render(
<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} onChange={handleChange} />,
)
const highQualityTitle = screen.getByText(/stepTwo\.qualified/)
const card = highQualityTitle.closest('div')?.parentElement?.parentElement?.parentElement
fireEvent.click(card!)
expect(handleChange).not.toHaveBeenCalled()
})
})
describe('Disabled State', () => {
it('should disable both options when disabled is true', () => {
const { container } = render(<IndexMethod {...defaultProps} disabled={true} />)
const disabledCards = container.querySelectorAll('.cursor-not-allowed')
expect(disabledCards.length).toBeGreaterThan(0)
})
it('should disable Economy option when currentValue is QUALIFIED', () => {
const handleChange = vi.fn()
render(
<IndexMethod
{...defaultProps}
currentValue={IndexingType.QUALIFIED}
onChange={handleChange}
value={IndexingType.ECONOMICAL}
/>,
)
// Try to click Economy option - use getAllByText and get the first one (title)
const economyTitles = screen.getAllByText(/form\.indexMethodEconomy/)
const economyTitle = economyTitles[0]
const card = economyTitle!.closest('div')?.parentElement?.parentElement?.parentElement
fireEvent.click(card!)
// Should not call onChange because Economy is disabled when current is QUALIFIED
expect(handleChange).not.toHaveBeenCalled()
})
})
describe('KeywordNumber', () => {
it('should render KeywordNumber component inside Economy option', () => {
render(<IndexMethod {...defaultProps} />)
expect(getKeywordSlider())!.toBeInTheDocument()
})
it('should pass keywordNumber to KeywordNumber component', () => {
render(<IndexMethod {...defaultProps} keywordNumber={25} />)
const input = screen.getByRole('textbox')
expect(input)!.toHaveValue('25')
})
it('should keep keyword number input visible next to steppers', () => {
render(<IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} keywordNumber={25} />)
const input = screen.getByRole('textbox')
expect(input)!.toHaveClass('w-12')
expect(input)!.toHaveClass('flex-none')
expect(input)!.toHaveClass('text-center')
})
it('should call onKeywordNumberChange when KeywordNumber changes', () => {
const handleKeywordChange = vi.fn()
render(<IndexMethod {...defaultProps} onKeywordNumberChange={handleKeywordChange} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '30' } })
expect(handleKeywordChange).toHaveBeenCalled()
})
})
describe('Tooltip', () => {
it('should show tooltip when hovering over disabled Economy option', () => {
// The tooltip is shown via Popover when hovering
// This is controlled by useHover hook
render(<IndexMethod {...defaultProps} currentValue={IndexingType.QUALIFIED} />)
// The tooltip content should exist in DOM but may not be visible
// We just verify the component renders without error
expect(screen.getAllByText(/form\.indexMethodEconomy/).length).toBeGreaterThan(0)
})
})
describe('Effect Colors', () => {
it('should show orange effect color for High Quality option', () => {
const { container } = render(<IndexMethod {...defaultProps} />)
const orangeEffect = container.querySelector('.bg-util-colors-orange-orange-500')
expect(orangeEffect)!.toBeInTheDocument()
})
it('should show indigo effect color for Economy option', () => {
const { container } = render(<IndexMethod {...defaultProps} />)
const indigoEffect = container.querySelector('.bg-util-colors-indigo-indigo-600')
expect(indigoEffect)!.toBeInTheDocument()
})
})
describe('Props', () => {
it('should update active state when value prop changes', () => {
const { rerender, container } = render(
<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} />,
)
let activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
rerender(
<IndexMethod
{...defaultProps}
value={IndexingType.ECONOMICAL}
currentValue={IndexingType.ECONOMICAL}
/>,
)
activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
})
})
describe('Edge Cases', () => {
it('should handle undefined currentValue', () => {
render(<IndexMethod {...defaultProps} currentValue={undefined} />)
// Should render without error
// Should render without error
expect(screen.getByText(/stepTwo\.qualified/))!.toBeInTheDocument()
})
it('should handle minimum keywordNumber', () => {
render(<IndexMethod {...defaultProps} keywordNumber={0} />)
const input = screen.getByRole('textbox')
expect(input)!.toHaveValue('0')
})
it('should handle max keywordNumber', () => {
render(<IndexMethod {...defaultProps} keywordNumber={50} />)
const input = screen.getByRole('textbox')
expect(input)!.toHaveValue('50')
})
it('does not allow an existing high quality dataset to downgrade to economy', async () => {
const user = userEvent.setup()
render(<Settings currentValue={IndexingType.QUALIFIED} />)
const economy = screen.getByRole('radio', { name: 'datasetSettings.form.indexMethodEconomy' })
await user.click(economy)
expect(economy).not.toBeChecked()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
})
@@ -1,6 +1,6 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { useTranslation } from 'react-i18next'
import { IndexingType } from '../../create/step-two'
import { EffectColor } from '../chunk-structure/types'
@@ -28,12 +28,17 @@ const IndexMethod = ({
const isEconomyDisabled = currentValue === IndexingType.QUALIFIED
return (
<div className={cn('flex flex-col gap-y-2')}>
<RadioGroup<IndexingType>
aria-label={t(($) => $['form.indexMethod'], { ns: 'datasetSettings' })}
value={value}
onValueChange={onChange}
disabled={disabled}
className="flex flex-col items-stretch gap-x-0 gap-y-2"
>
{/* High Quality */}
<OptionCard
id={IndexingType.QUALIFIED}
isActive={value === IndexingType.QUALIFIED}
onClick={onChange}
icon={<span aria-hidden className="i-custom-vender-knowledge-high-quality size-4.5" />}
iconActiveColor="text-util-colors-orange-orange-500"
title={t(($) => $['stepTwo.qualified'], { ns: 'datasetCreation' })}
@@ -50,7 +55,6 @@ const IndexMethod = ({
<OptionCard
id={IndexingType.ECONOMICAL}
isActive={value === IndexingType.ECONOMICAL}
onClick={onChange}
icon={<span aria-hidden className="i-custom-vender-knowledge-economic size-4.5" />}
iconActiveColor="text-util-colors-indigo-indigo-600"
title={t(($) => $['form.indexMethodEconomy'], { ns: 'datasetSettings' })}
@@ -61,10 +65,11 @@ const IndexMethod = ({
disabled={disabled || isEconomyDisabled}
effectColor={EffectColor.indigo}
showEffectColor
showChildren
showChildren={value === IndexingType.ECONOMICAL}
className="gap-x-2"
>
<KeywordNumber
disabled={disabled || isEconomyDisabled}
keywordNumber={keywordNumber}
onKeywordNumberChange={onKeywordNumberChange}
/>
@@ -80,7 +85,7 @@ const IndexMethod = ({
</PopoverContent>
)}
</Popover>
</div>
</RadioGroup>
)
}
@@ -24,11 +24,12 @@ const MIN_KEYWORD_NUMBER = 0
const MAX_KEYWORD_NUMBER = 50
type KeyWordNumberProps = {
disabled?: boolean
keywordNumber: number
onKeywordNumberChange: (value: number) => void
}
const KeyWordNumber = ({ keywordNumber, onKeywordNumberChange }: KeyWordNumberProps) => {
const KeyWordNumber = ({ disabled, keywordNumber, onKeywordNumberChange }: KeyWordNumberProps) => {
const { t } = useTranslation()
const label = t(($) => $['form.numberOfKeywords'], { ns: 'datasetSettings' })
@@ -50,6 +51,7 @@ const KeyWordNumber = ({ keywordNumber, onKeywordNumberChange }: KeyWordNumberPr
</Infotip>
</div>
<Slider
disabled={disabled}
className="mr-3 w-51.5 shrink-0"
value={keywordNumber}
min={MIN_KEYWORD_NUMBER}
@@ -65,6 +67,7 @@ const KeyWordNumber = ({ keywordNumber, onKeywordNumberChange }: KeyWordNumberPr
</SliderControl>
</Slider>
<NumberField
disabled={disabled}
className="w-18.5 shrink-0"
min={MIN_KEYWORD_NUMBER}
max={MAX_KEYWORD_NUMBER}
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { RadioItem } from '@langgenius/dify-ui/radio-group'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
@@ -23,7 +24,6 @@ type OptionCardProps<T> = {
effectColor?: EffectColor
showEffectColor?: boolean
disabled?: boolean
onClick?: (id: T) => void
children?: ReactNode
showChildren?: boolean
ref?: React.Ref<HTMLDivElement>
@@ -40,28 +40,34 @@ const OptionCard = <T,>({
effectColor,
showEffectColor,
disabled,
onClick,
children,
showChildren,
ref,
}: OptionCardProps<T>) => {
const { t } = useTranslation()
const titleId = React.useId()
const descriptionId = React.useId()
return (
<div
ref={ref}
className={cn(
'cursor-pointer overflow-hidden rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg',
'min-w-0 overflow-hidden rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg',
isActive &&
'border border-components-option-card-option-selected-border ring-[1px] ring-components-option-card-option-selected-border',
disabled && 'cursor-not-allowed opacity-50',
)}
onClick={() => {
if (isActive || disabled) return
onClick?.(id)
}}
>
<div className={cn('relative flex rounded-t-xl p-2', className)}>
<RadioItem<T>
value={id}
disabled={disabled}
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
className={cn(
'relative flex w-full cursor-pointer rounded-t-xl border-0 bg-transparent p-2 text-left outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:ring-inset',
className,
)}
>
{effectColor && showEffectColor && (
<div
className={cn(
@@ -80,20 +86,33 @@ const OptionCard = <T,>({
{icon}
</div>
)}
<div className="flex grow flex-col gap-y-0.5 py-px">
<div className="flex min-w-0 grow flex-col gap-y-0.5 py-px">
<div className="flex items-center gap-x-1">
<span className="system-sm-medium text-text-secondary">{title}</span>
<span id={titleId} className="system-sm-medium text-text-secondary">
{title}
</span>
{isRecommended && (
<Badge className="h-4.5 border-text-accent-secondary text-text-accent-secondary">
{t(($) => $['stepTwo.recommend'], { ns: 'datasetCreation' })}
</Badge>
)}
</div>
{description && <div className="system-xs-regular text-text-tertiary">{description}</div>}
{description && (
<div id={descriptionId} className="system-xs-regular text-text-tertiary">
{description}
</div>
)}
</div>
</div>
</RadioItem>
{!!(children && showChildren) && (
<div className="relative rounded-b-xl bg-components-panel-bg p-4">
<div
role="presentation"
className="relative rounded-b-xl bg-components-panel-bg p-4"
onKeyDown={(event) => {
// Keep parameter arrow keys from navigating the enclosing radio group.
if (event.key.startsWith('Arrow')) event.stopPropagation()
}}
>
<span
aria-hidden
className="absolute -top-2.75 left-3.5 i-custom-vender-knowledge-arrow-shape size-4 text-components-panel-bg"
@@ -4,7 +4,7 @@ import { Infotip, InfotipContent, InfotipTrigger } from '@langgenius/dify-ui/inf
import { Switch } from '@langgenius/dify-ui/switch'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { useQuery } from '@tanstack/react-query'
import { memo, useCallback, useMemo } from 'react'
import { memo, useCallback, useId, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { ModelSelector } from '@/app/components/header/account-setting/model-provider-page/model-selector'
@@ -23,6 +23,7 @@ const SummaryIndexSetting = ({
readonly = false,
}: SummaryIndexSettingProps) => {
const { t } = useTranslation()
const summaryLabelId = useId()
const { data: textGenerationModelList = [] } = useQuery(
consoleQuery.workspaces.current.models.modelTypes.byModelType.get.queryOptions({
input: { params: { model_type: ModelTypeEnum.textGeneration } },
@@ -72,7 +73,9 @@ const SummaryIndexSetting = ({
<div>
<div className="flex h-6 items-center justify-between">
<div className="flex items-center system-sm-semibold-uppercase text-text-secondary">
{t(($) => $['form.summaryAutoGen'], { ns: 'datasetSettings' })}
<span id={summaryLabelId}>
{t(($) => $['form.summaryAutoGen'], { ns: 'datasetSettings' })}
</span>
<Infotip>
<InfotipTrigger
aria-label={t(($) => $['form.summaryAutoGenTip'], { ns: 'datasetSettings' })}
@@ -86,6 +89,7 @@ const SummaryIndexSetting = ({
</Infotip>
</div>
<Switch
aria-labelledby={summaryLabelId}
checked={summaryIndexSetting?.enable ?? false}
onCheckedChange={handleSummaryIndexEnableChange}
size="md"
@@ -132,13 +136,14 @@ const SummaryIndexSetting = ({
<div className="space-y-4">
<div className="flex gap-x-1">
<div className="flex h-7 w-45 shrink-0 items-center pt-1">
<div className="system-sm-semibold text-text-secondary">
<div id={summaryLabelId} className="system-sm-semibold text-text-secondary">
{t(($) => $['form.summaryAutoGen'], { ns: 'datasetSettings' })}
</div>
</div>
<div className="py-1.5">
<div className="flex items-center system-sm-semibold text-text-secondary">
<Switch
aria-labelledby={summaryLabelId}
className="mr-2"
checked={summaryIndexSetting?.enable ?? false}
onCheckedChange={handleSummaryIndexEnableChange}
@@ -208,13 +213,14 @@ const SummaryIndexSetting = ({
<div className="space-y-3">
<div className="flex h-6 items-center">
<Switch
aria-labelledby={summaryLabelId}
className="mr-2"
checked={summaryIndexSetting?.enable ?? false}
onCheckedChange={handleSummaryIndexEnableChange}
size="md"
disabled={readonly}
/>
<div className="system-sm-semibold text-text-secondary">
<div id={summaryLabelId} className="system-sm-semibold text-text-secondary">
{t(($) => $['form.summaryAutoGen'], { ns: 'datasetSettings' })}
</div>
</div>
@@ -0,0 +1,55 @@
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { render } from '@/test/console/render'
import { ResponsiveMainNav } from '../responsive-main-nav'
vi.mock('../index', () => ({
MainNav: () => <div data-testid="main-nav-content">Navigation content</div>,
}))
describe('ResponsiveMainNav', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
const setCompactViewport = (matches: boolean) => {
vi.spyOn(window, 'matchMedia').mockImplementation((media) => ({
matches,
media,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}))
}
it('keeps navigation inline on wide screens', () => {
setCompactViewport(false)
render(<ResponsiveMainNav />)
expect(screen.getByTestId('main-nav-content')).toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('makes navigation available in a dismissible drawer on narrow screens', async () => {
setCompactViewport(true)
const user = userEvent.setup()
render(<ResponsiveMainNav />)
expect(screen.queryByTestId('main-nav-content')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /navigation\.primary/ }))
const drawer = await screen.findByRole('dialog', { name: /navigation\.primary/ })
expect(within(drawer).getByTestId('main-nav-content')).toBeInTheDocument()
await user.click(within(drawer).getByRole('button', { name: /operation\.close/ }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(screen.getByRole('button', { name: /navigation\.primary/ })).toBeInTheDocument()
})
})
+11 -1
View File
@@ -2,6 +2,7 @@
import type { ReactNode } from 'react'
import type { MainNavProps } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import { useAtomValue } from 'jotai'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
@@ -11,6 +12,7 @@ import { isCurrentWorkspaceDatasetOperatorAtom } from '@/context/workspace-state
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
import { usePathname } from '@/next/navigation'
import { MainNav } from '.'
import { ResponsiveMainNav } from './responsive-main-nav'
import { shouldHideMainNavigation, shouldUseDetailSidebar } from './routes'
import { MAIN_CONTENT_ID, SkipNav } from './skip-nav'
@@ -42,6 +44,7 @@ const MainNavLayout = ({ children, detailSidebar, initialPlatform }: MainNavLayo
const { t } = useTranslation('common')
const pathname = usePathname()
const isCurrentWorkspaceDatasetOperator = useAtomValue(isCurrentWorkspaceDatasetOperatorAtom)
const useResponsiveNavigation = pathname === '/datasets/create'
const hideMainNavigation = shouldHideMainNavigation(pathname)
const useDetailSidebar = shouldUseDetailSidebar(pathname, {
agentV2Enabled: isAgentV2Enabled(),
@@ -49,11 +52,18 @@ const MainNavLayout = ({ children, detailSidebar, initialPlatform }: MainNavLayo
})
return (
<div className="flex h-0 min-h-0 min-w-0 grow overflow-hidden bg-background-body">
<div
className={cn(
'flex h-0 min-h-0 min-w-0 grow overflow-hidden bg-background-body',
useResponsiveNavigation && 'flex-col md:flex-row',
)}
>
<SkipNav>{t(($) => $['navigation.skipToMain'])}</SkipNav>
<AppDetailStoreCleanup />
{hideMainNavigation ? null : useDetailSidebar ? (
detailSidebar
) : useResponsiveNavigation ? (
<ResponsiveMainNav initialPlatform={initialPlatform} />
) : (
<MainNav initialPlatform={initialPlatform} />
)}
@@ -0,0 +1,64 @@
'use client'
import type { MainNavProps } from './types'
import { Button } from '@langgenius/dify-ui/button'
import {
Drawer,
DrawerBackdrop,
DrawerCloseButton,
DrawerContent,
DrawerPopup,
DrawerPortal,
DrawerTitle,
DrawerTrigger,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { useSyncExternalStore } from 'react'
import { useTranslation } from 'react-i18next'
import { MainNav } from '.'
const compactNavigationQuery = '(width < 48rem)'
const subscribe = (onChange: () => void) => {
const mediaQuery = window.matchMedia(compactNavigationQuery)
mediaQuery.addEventListener('change', onChange)
return () => mediaQuery.removeEventListener('change', onChange)
}
const getSnapshot = () => window.matchMedia(compactNavigationQuery).matches
const getServerSnapshot = () => false
export function ResponsiveMainNav({ initialPlatform }: Pick<MainNavProps, 'initialPlatform'>) {
const { t } = useTranslation('common')
const isCompact = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
if (!isCompact) return <MainNav initialPlatform={initialPlatform} />
return (
<div className="shrink-0 p-2">
<Drawer swipeDirection="left">
<DrawerTrigger render={<Button variant="secondary" />}>
<span aria-hidden="true" className="i-ri-menu-line size-4" />
{t(($) => $['navigation.primary'])}
</DrawerTrigger>
<DrawerPortal>
<DrawerBackdrop className="fixed" />
<DrawerViewport>
<DrawerPopup>
<div className="flex shrink-0 items-center justify-between gap-2 p-3">
<DrawerTitle className="system-md-semibold">
{t(($) => $['navigation.primary'])}
</DrawerTitle>
<DrawerCloseButton aria-label={t(($) => $['operation.close'])} />
</div>
<DrawerContent className="touch-auto p-0 pb-0">
<MainNav
initialPlatform={initialPlatform}
className="h-auto min-h-full w-full overflow-visible [&>div]:shrink-0 [&>div]:overflow-visible"
/>
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
</div>
)
}
@@ -23,6 +23,7 @@
"stepOne.uploader.button": "اسحب وأفلت الملف أو المجلد، أو",
"stepOne.uploader.buttonSingleFile": "اسحب وأفلت الملف، أو",
"stepOne.uploader.change": "تغيير",
"stepOne.uploader.completed": "اكتمل رفع الملف",
"stepOne.uploader.failed": "فشل التحميل",
"stepOne.uploader.tip": "يدعم {{supportTypes}}. بحد أقصى {{batchCount}} في الدفعة الواحدة و {{size}} ميجابايت لكل منها.",
"stepOne.uploader.tipWithTotalLimit": "يدعم {{supportTypes}}. بحد أقصى {{batchCount}} في الدفعة الواحدة و {{size}} ميجابايت لكل منها. الحد الأقصى الإجمالي {{totalCount}} ملفات.",
@@ -23,6 +23,7 @@
"stepOne.uploader.button": "Faylı və ya qovluğu sürükləyib buraxın və ya",
"stepOne.uploader.buttonSingleFile": "Faylı sürükləyib buraxın və ya",
"stepOne.uploader.change": "Dəyiş",
"stepOne.uploader.completed": "Yükləmə tamamlandı",
"stepOne.uploader.failed": "Yükləmək mümkün olmadı",
"stepOne.uploader.tip": "{{supportTypes}} dəstəklənir. Bir dəfəyə ən çox {{batchCount}} fayl, hər biri ən çox {{size}} MB.",
"stepOne.uploader.tipWithTotalLimit": "{{supportTypes}} dəstəklənir. Bir dəfəyə ən çox {{batchCount}} fayl, hər biri ən çox {{size}} MB. Ümumilikdə ən çox {{totalCount}} fayl.",
@@ -23,6 +23,7 @@
"stepOne.uploader.button": "Dateien und Ordner hierher ziehen oder klicken",
"stepOne.uploader.buttonSingleFile": "Datei hierher ziehen oder klicken",
"stepOne.uploader.change": "Ändern",
"stepOne.uploader.completed": "Upload abgeschlossen",
"stepOne.uploader.failed": "Hochladen fehlgeschlagen",
"stepOne.uploader.tip": "Unterstützt {{supportTypes}}. Maximal {{batchCount}} Dateien pro Batch und {{size}} MB pro Datei.",
"stepOne.uploader.tipWithTotalLimit": "Unterstützt {{supportTypes}}. Maximal {{batchCount}} Dateien pro Batch und {{size}} MB pro Datei. Insgesamt maximal {{totalCount}} Dateien.",
@@ -23,6 +23,7 @@
"stepOne.uploader.button": "Drag and drop file or folder, or",
"stepOne.uploader.buttonSingleFile": "Drag and drop file, or",
"stepOne.uploader.change": "Change",
"stepOne.uploader.completed": "Upload complete",
"stepOne.uploader.failed": "Upload failed",
"stepOne.uploader.tip": "Supports {{supportTypes}}. Max {{batchCount}} in a batch and {{size}} MB each.",
"stepOne.uploader.tipWithTotalLimit": "Supports {{supportTypes}}. Max {{batchCount}} in a batch and {{size}} MB each. Max total {{totalCount}} files.",
@@ -23,6 +23,7 @@
"stepOne.uploader.button": "Arrastre y suelte archivos o carpetas, o",
"stepOne.uploader.buttonSingleFile": "Arrastre y suelte archivo, o",
"stepOne.uploader.change": "Cambiar",
"stepOne.uploader.completed": "Carga completada",
"stepOne.uploader.failed": "Error al cargar",
"stepOne.uploader.tip": "Soporta {{supportTypes}}. Máximo {{batchCount}} archivos por lote y {{size}} MB cada uno.",
"stepOne.uploader.tipWithTotalLimit": "Soporta {{supportTypes}}. Máximo {{batchCount}} archivos por lote y {{size}} MB cada uno. Total máximo de {{totalCount}} archivos.",
@@ -23,6 +23,7 @@
"stepOne.uploader.button": "فایل ها یا پوشه ها را بکشید و رها کنید یا",
"stepOne.uploader.buttonSingleFile": "فایل را بکشید و رها کنید یا",
"stepOne.uploader.change": "تغییر",
"stepOne.uploader.completed": "بارگذاری کامل شد",
"stepOne.uploader.failed": "بارگذاری ناموفق بود",
"stepOne.uploader.tip": "پشتیبانی از {{supportTypes}}. حداکثر {{batchCount}} فایل در هر دسته و {{size}} مگابایت برای هر فایل.",
"stepOne.uploader.tipWithTotalLimit": "پشتیبانی از {{supportTypes}}. حداکثر {{batchCount}} فایل در هر دسته و {{size}} مگابایت برای هر فایل. حداکثر کل {{totalCount}} فایل.",
@@ -23,6 +23,7 @@
"stepOne.uploader.button": "Faites glisser et déposez des fichiers ou des dossiers, ou",
"stepOne.uploader.buttonSingleFile": "Faites glisser et déposez un fichier, ou",
"stepOne.uploader.change": "Changer",
"stepOne.uploader.completed": "Téléversement terminé",
"stepOne.uploader.failed": "Le téléchargement a échoué",
"stepOne.uploader.tip": "Prend en charge {{supportTypes}}. Maximum {{batchCount}} fichiers par lot et {{size}} MB chacun.",
"stepOne.uploader.tipWithTotalLimit": "Prend en charge {{supportTypes}}. Maximum {{batchCount}} fichiers par lot et {{size}} MB chacun. Maximum total de {{totalCount}} fichiers.",
@@ -23,6 +23,7 @@
"stepOne.uploader.button": "फ़ाइलों या फ़ोल्डरों को खींचें और छोड़ें, या",
"stepOne.uploader.buttonSingleFile": "फ़ाइल को खींचें और छोड़ें, या",
"stepOne.uploader.change": "बदलें",
"stepOne.uploader.completed": "अपलोड पूरा हुआ",
"stepOne.uploader.failed": "अपलोड विफल रहा",
"stepOne.uploader.tip": "{{supportTypes}} समर्थित है। एक बैच में अधिकतम {{batchCount}} फ़ाइलें और प्रत्येक {{size}} MB।",
"stepOne.uploader.tipWithTotalLimit": "{{supportTypes}} समर्थित है। एक बैच में अधिकतम {{batchCount}} फ़ाइलें और प्रत्येक {{size}} MB। कुल अधिकतम {{totalCount}} फ़ाइलें।",
@@ -23,6 +23,7 @@
"stepOne.uploader.button": "Seret dan lepas file atau folder, atau",
"stepOne.uploader.buttonSingleFile": "Seret dan lepas file, atau",
"stepOne.uploader.change": "Ubah",
"stepOne.uploader.completed": "Unggahan selesai",
"stepOne.uploader.failed": "Upload gagal",
"stepOne.uploader.tip": "Mendukung {{supportTypes}}. Maksimal {{batchCount}} dalam satu batch dan {{size}} MB masing-masing.",
"stepOne.uploader.tipWithTotalLimit": "Mendukung {{supportTypes}}. Maksimal {{batchCount}} dalam satu batch dan {{size}} MB masing-masing. Total maksimal {{totalCount}} file.",
@@ -23,6 +23,7 @@
"stepOne.uploader.button": "Trascina e rilascia file o cartelle, oppure",
"stepOne.uploader.buttonSingleFile": "Trascina e rilascia un file, oppure",
"stepOne.uploader.change": "Cambia",
"stepOne.uploader.completed": "Caricamento completato",
"stepOne.uploader.failed": "Caricamento fallito",
"stepOne.uploader.tip": "Supporta {{supportTypes}}. Massimo {{batchCount}} file per batch e {{size}} MB ciascuno.",
"stepOne.uploader.tipWithTotalLimit": "Supporta {{supportTypes}}. Massimo {{batchCount}} file per batch e {{size}} MB ciascuno. Totale massimo {{totalCount}} file.",
@@ -23,6 +23,7 @@
"stepOne.uploader.button": "ファイルまたはフォルダをドラッグアンドドロップする",
"stepOne.uploader.buttonSingleFile": "ファイルをドラッグアンドドロップする",
"stepOne.uploader.change": "変更",
"stepOne.uploader.completed": "アップロード完了",
"stepOne.uploader.failed": "アップロードに失敗しました",
"stepOne.uploader.tip": "{{supportTypes}}をサポートしています。1バッチあたり最大{{batchCount}}ファイル、各ファイル{{size}}MB まで。",
"stepOne.uploader.tipWithTotalLimit": "{{supportTypes}}をサポートしています。1バッチあたり最大{{batchCount}}ファイル、各ファイル{{size}}MB まで。合計最大{{totalCount}}ファイル。",
@@ -23,6 +23,7 @@
"stepOne.uploader.button": "파일이나 폴더를 끌어서 놓기",
"stepOne.uploader.buttonSingleFile": "파일을 끌어서 놓기",
"stepOne.uploader.change": "변경",
"stepOne.uploader.completed": "업로드 완료",
"stepOne.uploader.failed": "업로드에 실패했습니다",
"stepOne.uploader.tip": "{{supportTypes}}을(를) 지원합니다. 배치당 최대 {{batchCount}}개 파일, 각 파일당 {{size}}MB까지.",
"stepOne.uploader.tipWithTotalLimit": "{{supportTypes}}을(를) 지원합니다. 배치당 최대 {{batchCount}}개 파일, 각 파일당 {{size}}MB까지. 총 최대 {{totalCount}}개 파일.",
@@ -23,6 +23,7 @@
"stepOne.uploader.button": "ລາກແລ້ວວາງໄຟລ໌ ຫຼື ໂຟນເດີ, ຫຼື",
"stepOne.uploader.buttonSingleFile": "ລາກແລ້ວວາງໄຟລ໌, ຫຼື",
"stepOne.uploader.change": "ປ່ຽນແປງ",
"stepOne.uploader.completed": "ອັບໂຫຼດສຳເລັດ",
"stepOne.uploader.failed": "ອັບໂຫຼດລົ້ມເຫຼວ",
"stepOne.uploader.tip": "ຮອງຮັບ {{supportTypes}}. ສູງສຸດ {{batchCount}} ໄຟລ໌ຕໍ່ຄັ້ງ ແລະ ແຕ່ລະໄຟລ໌ບໍ່ເກີນ {{size}} MB.",
"stepOne.uploader.tipWithTotalLimit": "ຮອງຮັບ {{supportTypes}}. ສູງສຸດ {{batchCount}} ໄຟລ໌ຕໍ່ຄັ້ງ ແລະ ແຕ່ລະໄຟລ໌ບໍ່ເກີນ {{size}} MB. ສູງສຸດທັງໝົດ {{totalCount}} ໄຟລ໌.",
@@ -23,6 +23,7 @@
"stepOne.uploader.button": "Drag and drop file or folder, or",
"stepOne.uploader.buttonSingleFile": "Drag and drop file, or",
"stepOne.uploader.change": "Change",
"stepOne.uploader.completed": "Upload voltooid",
"stepOne.uploader.failed": "Upload failed",
"stepOne.uploader.tip": "Ondersteunt {{supportTypes}}. Maximaal {{batchCount}} per batch en {{size}} MB per bestand.",
"stepOne.uploader.tipWithTotalLimit": "Ondersteunt {{supportTypes}}. Maximaal {{batchCount}} per batch en {{size}} MB per bestand. Maximaal {{totalCount}} bestanden in totaal.",
@@ -23,6 +23,7 @@
"stepOne.uploader.button": "Przeciągnij i upuść pliki lub foldery lub",
"stepOne.uploader.buttonSingleFile": "Przeciągnij i upuść plik lub",
"stepOne.uploader.change": "Zmień",
"stepOne.uploader.completed": "Przesyłanie zakończone",
"stepOne.uploader.failed": "Przesyłanie nie powiodło się",
"stepOne.uploader.tip": "Obsługuje {{supportTypes}}. Maksymalnie {{batchCount}} plików w partii, każdy do {{size}} MB.",
"stepOne.uploader.tipWithTotalLimit": "Obsługuje {{supportTypes}}. Maksymalnie {{batchCount}} plików w partii, każdy do {{size}} MB. Łącznie maksymalnie {{totalCount}} plików.",
@@ -23,6 +23,7 @@
"stepOne.uploader.button": "Arraste e solte arquivos ou pastas, ou",
"stepOne.uploader.buttonSingleFile": "Arraste e solte um arquivo, ou",
"stepOne.uploader.change": "Alterar",
"stepOne.uploader.completed": "Upload concluído",
"stepOne.uploader.failed": "Falha no envio",
"stepOne.uploader.tip": "Suporta {{supportTypes}}. Máximo de {{batchCount}} arquivos por lote e {{size}} MB cada.",
"stepOne.uploader.tipWithTotalLimit": "Suporta {{supportTypes}}. Máximo de {{batchCount}} arquivos por lote e {{size}} MB cada. Total máximo de {{totalCount}} arquivos.",
@@ -23,6 +23,7 @@
"stepOne.uploader.button": "Trageți și plasați fișiere sau foldere sau",
"stepOne.uploader.buttonSingleFile": "Trageți și plasați un fișier sau",
"stepOne.uploader.change": "Schimbă",
"stepOne.uploader.completed": "Încărcare finalizată",
"stepOne.uploader.failed": "Încărcarea a eșuat",
"stepOne.uploader.tip": "Acceptă {{supportTypes}}. Maxim {{batchCount}} fișiere pe lot și {{size}} MB fiecare.",
"stepOne.uploader.tipWithTotalLimit": "Acceptă {{supportTypes}}. Maxim {{batchCount}} fișiere pe lot și {{size}} MB fiecare. Total maxim {{totalCount}} fișiere.",
@@ -23,6 +23,7 @@
"stepOne.uploader.button": "Перетащите файлы или папки или",
"stepOne.uploader.buttonSingleFile": "Перетащите файл или",
"stepOne.uploader.change": "Изменить",
"stepOne.uploader.completed": "Загрузка завершена",
"stepOne.uploader.failed": "Ошибка загрузки",
"stepOne.uploader.tip": "Поддерживаются {{supportTypes}}. Максимум {{batchCount}} файлов за раз, каждый до {{size}} МБ.",
"stepOne.uploader.tipWithTotalLimit": "Поддерживаются {{supportTypes}}. Максимум {{batchCount}} файлов за раз, каждый до {{size}} МБ. Всего максимум {{totalCount}} файлов.",
@@ -23,6 +23,7 @@
"stepOne.uploader.button": "Povleci in spusti datoteke ali mape oz",
"stepOne.uploader.buttonSingleFile": "Povleci in spusti datoteko oz",
"stepOne.uploader.change": "Zamenjaj",
"stepOne.uploader.completed": "Nalaganje končano",
"stepOne.uploader.failed": "Nalaganje ni uspelo",
"stepOne.uploader.tip": "Podpira {{supportTypes}}. Največje število datotek v seriji: {{batchCount}}, vsaka do {{size}} MB.",
"stepOne.uploader.tipWithTotalLimit": "Podpira {{supportTypes}}. Največje število datotek v seriji: {{batchCount}}, vsaka do {{size}} MB. Skupaj največ {{totalCount}} datotek.",
@@ -23,6 +23,7 @@
"stepOne.uploader.button": "ลากและวางไฟล์หรือโฟลเดอร์หรือ",
"stepOne.uploader.buttonSingleFile": "ลากและวางไฟล์หรือ",
"stepOne.uploader.change": "เปลี่ยน",
"stepOne.uploader.completed": "อัปโหลดเสร็จสมบูรณ์",
"stepOne.uploader.failed": "อัปโหลดล้มเหลว",
"stepOne.uploader.tip": "รองรับ {{supportTypes}} สูงสุด {{batchCount}} ไฟล์ต่อชุดและ {{size}} MB แต่ละไฟล์",
"stepOne.uploader.tipWithTotalLimit": "รองรับ {{supportTypes}} สูงสุด {{batchCount}} ไฟล์ต่อชุดและ {{size}} MB แต่ละไฟล์ รวมสูงสุด {{totalCount}} ไฟล์",
@@ -23,6 +23,7 @@
"stepOne.uploader.button": "Dosyaları veya klasörleri sürükleyip bırakın veya",
"stepOne.uploader.buttonSingleFile": "Dosyayı sürükleyip bırakın veya",
"stepOne.uploader.change": "Değiştir",
"stepOne.uploader.completed": "Yükleme tamamlandı",
"stepOne.uploader.failed": "Yükleme başarısız",
"stepOne.uploader.tip": "{{supportTypes}} destekler. Parti başına en fazla {{batchCount}} dosya ve her biri {{size}} MB.",
"stepOne.uploader.tipWithTotalLimit": "{{supportTypes}} destekler. Parti başına en fazla {{batchCount}} dosya ve her biri {{size}} MB. Toplam en fazla {{totalCount}} dosya.",
@@ -23,6 +23,7 @@
"stepOne.uploader.button": "Перетягніть файли або папки або",
"stepOne.uploader.buttonSingleFile": "Перетягніть файл або",
"stepOne.uploader.change": "Змінити",
"stepOne.uploader.completed": "Завантаження завершено",
"stepOne.uploader.failed": "Завантаження не вдалося",
"stepOne.uploader.tip": "Підтримуються {{supportTypes}}. Максимум {{batchCount}} файлів за раз, кожен до {{size}} МБ.",
"stepOne.uploader.tipWithTotalLimit": "Підтримуються {{supportTypes}}. Максимум {{batchCount}} файлів за раз, кожен до {{size}} МБ. Загалом максимум {{totalCount}} файлів.",
@@ -23,6 +23,7 @@
"stepOne.uploader.button": "Kéo và thả các tập tin hoặc thư mục, hoặc",
"stepOne.uploader.buttonSingleFile": "Kéo và thả tệp hoặc",
"stepOne.uploader.change": "Thay đổi",
"stepOne.uploader.completed": "Tải lên hoàn tất",
"stepOne.uploader.failed": "Tải lên thất bại",
"stepOne.uploader.tip": "Hỗ trợ {{supportTypes}}. Tối đa {{batchCount}} tệp trong một lô và {{size}} MB mỗi tệp.",
"stepOne.uploader.tipWithTotalLimit": "Hỗ trợ {{supportTypes}}. Tối đa {{batchCount}} tệp trong một lô và {{size}} MB mỗi tệp. Tổng tối đa {{totalCount}} tệp.",
@@ -23,6 +23,7 @@
"stepOne.uploader.button": "拖拽文件或文件夹至此,或者",
"stepOne.uploader.buttonSingleFile": "拖拽文件至此,或者",
"stepOne.uploader.change": "更改文件",
"stepOne.uploader.completed": "上传完成",
"stepOne.uploader.failed": "上传失败",
"stepOne.uploader.tip": "已支持 {{supportTypes}},每批最多 {{batchCount}} 个文件,每个文件不超过 {{size}} MB。",
"stepOne.uploader.tipWithTotalLimit": "已支持 {{supportTypes}},每批最多 {{batchCount}} 个文件,每个文件不超过 {{size}} MB,总数不超过 {{totalCount}} 个文件。",
@@ -23,6 +23,7 @@
"stepOne.uploader.button": "拖拽檔案或檔案夾至此,或者",
"stepOne.uploader.buttonSingleFile": "拖拽檔案至此,或者",
"stepOne.uploader.change": "更改檔案",
"stepOne.uploader.completed": "上傳完成",
"stepOne.uploader.failed": "上傳失敗",
"stepOne.uploader.tip": "支援 {{supportTypes}}。每批最多 {{batchCount}} 個檔案,每個檔案不超過 {{size}} MB。",
"stepOne.uploader.tipWithTotalLimit": "支援 {{supportTypes}}。每批最多 {{batchCount}} 個檔案,每個檔案不超過 {{size}} MB,總數不超過 {{totalCount}} 個檔案。",
-3
View File
@@ -104,9 +104,6 @@ export const ownershipTransfer = (
{ body },
)
export const fetchFilePreview = ({ fileID }: { fileID: string }): Promise<{ content: string }> => {
return get<{ content: string }>(`/files/${fileID}/preview`)
}
export const updateWorkspaceInfo = ({
url,
body,