mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
fix: improve accessibility in legacy knowledge creation (#42726)
This commit is contained in:
79 files changed
+1444
-1411
No files matched your search
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
+102
-77
@@ -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()
|
||||
|
||||
+14
-5
@@ -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
|
||||
|
||||
+38
-10
@@ -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
|
||||
|
||||
+19
@@ -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 />)
|
||||
|
||||
+16
-6
@@ -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
|
||||
|
||||
+4
-17
@@ -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)', () => {
|
||||
|
||||
+42
-10
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
+4
-12
@@ -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(
|
||||
|
||||
+65
-19
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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}} 個檔案。",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user