diff --git a/web/features/new-rag/__tests__/create-knowledge-page.spec.tsx b/web/features/new-rag/__tests__/create-knowledge-page.spec.tsx index aa04734ae4f..082beb024c3 100644 --- a/web/features/new-rag/__tests__/create-knowledge-page.spec.tsx +++ b/web/features/new-rag/__tests__/create-knowledge-page.spec.tsx @@ -22,7 +22,6 @@ const serviceMock = vi.hoisted(() => ({ updateKfsSource: vi.fn(), updateSyncPolicy: vi.fn(), upload: vi.fn(), - uploadBulk: vi.fn(), listKey: vi.fn(() => ['console', 'knowledgeFs', 'listKnowledgeSpaces']), sourcesKey: vi.fn(() => ['console', 'knowledgeFs', 'sources']), documentsKey: vi.fn(() => ['console', 'knowledgeFs', 'documents']), @@ -49,6 +48,10 @@ const systemFeaturesStateMock = vi.hoisted(() => ({ rbacEnabled: true, })) +const datasourceAuthStateMock = vi.hoisted(() => ({ + result: [] as Array>, +})) + vi.mock('@/next/navigation', () => ({ useRouter: () => routerMock, useSearchParams: () => ({ @@ -156,6 +159,13 @@ vi.mock('@/service/datasets', () => ({ createFirecrawlTask: serviceMock.createCrawl, })) +vi.mock('@/service/use-datasource', () => ({ + useGetDataSourceListAuth: () => ({ + data: { result: datasourceAuthStateMock.result }, + isPending: false, + }), +})) + const createdKnowledge = { control_space_id: 'e735c1dc-d2b8-4dc4-86dc-abaf2fb7d084', model_setup_required: false, @@ -206,17 +216,17 @@ vi.mock('../knowledge-fs-upload', () => ({ uploadKnowledgeFsDocuments: async ( knowledgeSpaceId: string, uploads: Array<{ file: File; id: string }>, + _progress: Map, + onProgress?: (file: File, phase: 'completed' | 'pending') => void, ) => { - const files = uploads.map(({ file }) => file) - if (files.length === 1) - return serviceMock.upload({ - body: { file: files[0] }, + for (const { file } of uploads) { + onProgress?.(file, 'pending') + await serviceMock.upload({ + body: { file }, params: { control_space_id: knowledgeSpaceId }, }) - return serviceMock.uploadBulk({ - body: { files }, - params: { control_space_id: knowledgeSpaceId }, - }) + onProgress?.(file, 'completed') + } }, })) @@ -249,6 +259,14 @@ describe('CreateKnowledgePage', () => { beforeEach(() => { vi.clearAllMocks() globalThis.sessionStorage.clear() + datasourceAuthStateMock.result = [ + { + credentials_list: [{ id: 'notion-credential-1' }], + name: 'Notion', + plugin_id: 'langgenius/notion_datasource', + provider: 'notion', + }, + ] serviceMock.create.mockResolvedValue(createdKnowledge) serviceMock.createCrawl.mockResolvedValue({ job_id: 'crawl-job-1' }) serviceMock.createKfsSource.mockResolvedValue(kfsSourceResponse()) @@ -348,11 +366,6 @@ describe('CreateKnowledgePage', () => { serviceMock.upload.mockResolvedValue({ id: 'document-1', }) - serviceMock.uploadBulk.mockResolvedValue({ - accepted: 2, - excluded: 0, - items: [], - }) permissionStateMock.keys = ['dataset.acl.access_config'] systemFeaturesStateMock.uploadEnabled = true systemFeaturesStateMock.rbacEnabled = true @@ -368,19 +381,17 @@ describe('CreateKnowledgePage', () => { vi.restoreAllMocks() }) - it('keeps create reachable and reports an empty knowledge name', async () => { + it('keeps create disabled until the knowledge name is provided', async () => { const user = userEvent.setup() renderPage() const createButton = screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle', }) + expect(createButton).toBeDisabled() + + await user.type(screen.getByRole('textbox', { name: 'dataset.newKnowledge.name' }), 'Handbook') expect(createButton).toBeEnabled() - - await user.click(createButton) - - expect(await screen.findByText('dataset.newKnowledge.nameRequired')).toBeInTheDocument() - expect(serviceMock.create).not.toHaveBeenCalled() }) it('creates a private empty knowledge space, invalidates the list, and navigates', async () => { @@ -784,18 +795,9 @@ describe('CreateKnowledgePage', () => { expect(onlineDocuments).toBeChecked() expect(screen.queryByRole('alert')).not.toBeInTheDocument() expect(screen.getByText('Notion')).toBeInTheDocument() - expect(screen.getByText('dataset.newKnowledge.notionNotConnected')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'dataset.newKnowledge.connectNotion' })).toBeEnabled() - expect( - screen.queryByRole('textbox', { name: 'dataset.newKnowledge.sourceName' }), - ).not.toBeInTheDocument() - expect( - screen.queryByRole('combobox', { name: 'dataset.newKnowledge.syncPolicy' }), - ).not.toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.connectNotion' })) - expect(screen.getByRole('alert')).toHaveTextContent( - 'dataset.newKnowledge.sourceSetupBackendDependency', - ) + expect(screen.queryByText('dataset.newKnowledge.notionNotConnected')).not.toBeInTheDocument() + expect(screen.getByRole('textbox', { name: 'dataset.newKnowledge.sourceName' })).toBeEnabled() + expect(screen.getByRole('combobox', { name: 'dataset.newKnowledge.syncPolicy' })).toBeEnabled() await user.click(screen.getByRole('radio', { name: 'Google Docs' })) expect(screen.getByRole('textbox', { name: 'dataset.newKnowledge.sourceName' })).toBeEnabled() expect(screen.getByRole('combobox', { name: 'dataset.newKnowledge.syncPolicy' })).toBeEnabled() @@ -812,6 +814,27 @@ describe('CreateKnowledgePage', () => { expect(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' })).toBeDisabled() }) + it('offers to connect Notion when the provider has no credential', async () => { + const user = userEvent.setup() + datasourceAuthStateMock.result = [] + renderPage() + + await user.click(screen.getByRole('radio', { name: 'dataset.newKnowledge.connectSource' })) + await user.click(screen.getByRole('radio', { name: 'dataset.newKnowledge.onlineDocuments' })) + + expect(screen.getByText('dataset.newKnowledge.notionNotConnected')).toBeInTheDocument() + expect( + screen.queryByRole('textbox', { name: 'dataset.newKnowledge.sourceName' }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('combobox', { name: 'dataset.newKnowledge.syncPolicy' }), + ).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.connectNotion' })) + expect(screen.getByRole('alert')).toHaveTextContent( + 'dataset.newKnowledge.sourceSetupBackendDependency', + ) + }) + it('disables upload before creating a space when direct upload is unavailable', () => { navigationMock.startMode = 'upload' systemFeaturesStateMock.uploadEnabled = false @@ -918,9 +941,49 @@ describe('CreateKnowledgePage', () => { url: 'https://docs.dify.ai', }) expect(await screen.findByText('Getting started')).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }), + ).not.toBeInTheDocument() expect(screen.getByRole('checkbox', { name: 'dataset.newKnowledge.selectAll' })).toBeEnabled() expect(screen.getByRole('checkbox', { name: 'Getting started' })).toBeEnabled() expect(screen.getByRole('button', { name: 'dataset.newKnowledge.reCrawl' })).toBeEnabled() + const syncPolicy = screen.getByRole('combobox', { + name: 'dataset.newKnowledge.syncPolicy', + }) + expect(syncPolicy).toHaveTextContent('dataset.newKnowledge.syncPolicyDaily') + await user.click(syncPolicy) + await user.click( + await screen.findByRole('option', { name: 'dataset.newKnowledge.syncPolicyManual' }), + ) + expect(syncPolicy).toHaveTextContent('dataset.newKnowledge.syncPolicyManual') + expect(screen.getByText('Getting started')).toBeInTheDocument() + }) + + it('shows and can stop an ongoing website crawl', async () => { + const user = userEvent.setup() + navigationMock.startMode = 'source' + serviceMock.getCrawlStatus.mockImplementation(() => new Promise(() => {})) + renderPage() + await fillRequiredFields(user) + await user.type( + screen.getByPlaceholderText('dataset.newKnowledge.rootUrlPlaceholder'), + 'https://docs.dify.ai', + ) + await user.type( + screen.getByPlaceholderText('dataset.newKnowledge.sourceNamePlaceholder'), + 'Dify docs', + ) + + await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' })) + + expect(await screen.findByRole('status')).toHaveTextContent( + 'dataset.newKnowledge.crawlingPages', + ) + const stopButton = screen.getByRole('button', { name: 'dataset.newKnowledge.stopCrawl' }) + expect(stopButton).toBeEnabled() + + await user.click(stopButton) + expect(screen.getByRole('status')).toHaveTextContent('dataset.newKnowledge.crawlStopped') }) it('submits selected preview URLs for the server-side crawl import', async () => { @@ -1111,27 +1174,59 @@ describe('CreateKnowledgePage', () => { expect(serviceMock.create).not.toHaveBeenCalled() }) - it('shows the real uploading state on each valid file row', async () => { + it('marks only the file currently being uploaded as pending', async () => { const user = userEvent.setup() navigationMock.startMode = 'upload' + vi.mocked(globalThis.crypto.randomUUID) + .mockReturnValueOnce('11111111-1111-4111-8111-111111111111') + .mockReturnValueOnce('22222222-2222-4222-8222-222222222222') + .mockReturnValue('33333333-3333-4333-8333-333333333333') serviceMock.upload.mockImplementation(() => new Promise(() => {})) renderPage() await fillRequiredFields(user) - await user.upload( + fireEvent.change( screen.getByLabelText('dataset.newKnowledge.uploadFiles', { selector: 'input[type="file"]', }), - new File(['content'], 'handbook.md', { type: 'text/markdown' }), + { + target: { + files: [ + new File(['content'], 'handbook.md', { type: 'text/markdown' }), + new File(['content'], 'policy.pdf', { type: 'application/pdf' }), + ], + }, + }, ) const queue = screen.getByRole('list', { name: 'dataset.newKnowledge.uploadFiles' }) - const preview = within(queue).getByRole('button', { name: 'dataset.newKnowledge.preview' }) - expect(preview).toBeEnabled() + const handbookRow = within(queue).getByText('handbook.md').closest('li') + const policyRow = within(queue).getByText('policy.pdf').closest('li') + expect(handbookRow).not.toBeNull() + expect(policyRow).not.toBeNull() + expect( + within(queue).getAllByRole('button', { name: 'dataset.newKnowledge.preview' }), + ).toHaveLength(2) expect(screen.queryByText('dataset.newKnowledge.previewUnavailable')).not.toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' })) - expect(await within(queue).findByText('dataset.newKnowledge.uploadingFiles')).toBeVisible() - expect(within(queue).queryByRole('button', { name: 'dataset.newKnowledge.preview' })).toBeNull() + expect( + await within(handbookRow as HTMLElement).findByText('dataset.newKnowledge.uploadingFiles'), + ).toBeVisible() + expect( + within(handbookRow as HTMLElement).queryByRole('button', { + name: 'dataset.newKnowledge.preview', + }), + ).toBeNull() + expect( + within(policyRow as HTMLElement).getByText( + 'dataset.newKnowledge.uploadCharactersUnavailable', + ), + ).toBeVisible() + expect( + within(policyRow as HTMLElement).getByRole('button', { + name: 'dataset.newKnowledge.preview', + }), + ).toBeEnabled() }) it('previews a selected file locally without uploading it', () => { @@ -1242,6 +1337,9 @@ describe('CreateKnowledgePage', () => { expect( screen.getByPlaceholderText('dataset.newKnowledge.descriptionPlaceholder'), ).toBeInTheDocument() + expect( + screen.getByRole('textbox', { name: /^dataset\.newKnowledge\.description$/ }), + ).toBeInTheDocument() expect(screen.getByText('dataset.newKnowledge.descriptionHelp')).toBeInTheDocument() expect(screen.getByText('dataset.newKnowledge.startWithHelp')).toBeInTheDocument() expect( diff --git a/web/features/new-rag/components/create-knowledge-dialog-parts.tsx b/web/features/new-rag/components/create-knowledge-dialog-parts.tsx index 0423e6f9a48..5accb886e64 100644 --- a/web/features/new-rag/components/create-knowledge-dialog-parts.tsx +++ b/web/features/new-rag/components/create-knowledge-dialog-parts.tsx @@ -11,6 +11,7 @@ export function StartMode({ children, description, disabled = false, + endAdornment, icon, selected = false, title, @@ -19,6 +20,7 @@ export function StartMode({ children?: ReactNode description: string disabled?: boolean + endAdornment?: ReactNode icon: string selected?: boolean title: string @@ -45,7 +47,8 @@ export function StartMode({ aria-describedby={disabled ? `${descriptionId} ${unavailableId}` : descriptionId} disabled={disabled} className={cn( - 'relative flex min-h-16 w-full items-center gap-3 px-4 py-3.5 text-left outline-hidden', + 'relative flex w-full items-center gap-3 px-4 text-left outline-hidden', + value === 'empty' || !selected ? 'min-h-15.5 py-3' : 'min-h-16 py-3.5', 'hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:ring-inset', 'data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:hover:bg-transparent', )} @@ -62,12 +65,7 @@ export function StartMode({ {description} - {value === 'source' && ( - - )} + {endAdornment} {disabled && ( {t(($) => $['cornerLabel.unavailable'])} diff --git a/web/features/new-rag/crawl-selection-form.tsx b/web/features/new-rag/crawl-selection-form.tsx index ea6fdef00d5..7d924c1f23b 100644 --- a/web/features/new-rag/crawl-selection-form.tsx +++ b/web/features/new-rag/crawl-selection-form.tsx @@ -242,7 +242,7 @@ export function CrawlPreviewPageSelection({ return (
-
+

{t(($) => $['newKnowledge.selectAll'])} -
    +
      {pages.map((page, index) => { const skipReason = pageSkipReasons.get(page.pageId) const selectable = !skipReason diff --git a/web/features/new-rag/create-knowledge-page.tsx b/web/features/new-rag/create-knowledge-page.tsx index de9d85320e8..f8ee316aec7 100644 --- a/web/features/new-rag/create-knowledge-page.tsx +++ b/web/features/new-rag/create-knowledge-page.tsx @@ -5,7 +5,7 @@ import type { CreateKnowledgeExitReason } from './components/create-knowledge-ex import type { KnowledgeVisibility } from './create-knowledge-workflow' import type { WebsiteCrawlPreviewSelection } from './create-source-setup' import type { QueuedUpload } from './create-upload-queue' -import type { KnowledgeFsUploadProgress } from './knowledge-fs-upload' +import type { KnowledgeFsUploadPhase, KnowledgeFsUploadProgress } from './knowledge-fs-upload' import type { NewKnowledgeSourceDraft, NewKnowledgeStartMode } from './routes' import { Button } from '@langgenius/dify-ui/button' import { @@ -111,6 +111,9 @@ export function CreateKnowledgePage() { const [modelSetupDialogOpen, setModelSetupDialogOpen] = useState(false) const [submissionLocked, setSubmissionLocked] = useState(false) const [uploading, setUploading] = useState(false) + const [uploadPhases, setUploadPhases] = useState>( + () => new Map(), + ) const [uploadError, setUploadError] = useState(false) const [exitReason, setExitReason] = useState(null) const idempotencyKeyRef = useRef(undefined) @@ -122,6 +125,7 @@ export function CreateKnowledgePage() { const createMutation = useMutation({ mutationFn: createKnowledge }) const submissionPending = createMutation.isPending || uploading const createErrorMessage = t(($) => $['newKnowledge.createFailed']) + const nameSubmissionBlocked = !name.trim() const uploadSubmissionBlocked = startMode === 'upload' && (!uploadAvailable || !uploads.length || uploads.some((upload) => upload.issue)) @@ -336,6 +340,13 @@ export function CreateKnowledgePage() { created.control_space_id, uploads.map(({ file, id }) => ({ file, id })), uploadProgressRef.current, + (file, phase) => { + setUploadPhases((current) => { + const next = new Map(current) + next.set(file, phase) + return next + }) + }, ) } catch { setUploadError(true) @@ -422,7 +433,7 @@ export function CreateKnowledgePage() {
      -
      +
      - - {t(($) => $['newKnowledge.description'])}{' '} - {tCommon(($) => $['label.optional'])} - + {t(($) => $['newKnowledge.description'])}