diff --git a/web/features/new-rag/__tests__/document-detail-page.spec.tsx b/web/features/new-rag/__tests__/document-detail-page.spec.tsx index dfdc1821401..4d121c9ebdb 100644 --- a/web/features/new-rag/__tests__/document-detail-page.spec.tsx +++ b/web/features/new-rag/__tests__/document-detail-page.spec.tsx @@ -100,6 +100,15 @@ const permissionState = vi.hoisted(() => ({ })) const reindexMutation = vi.hoisted(() => ({ mutateAsync: vi.fn() })) const cancelMutation = vi.hoisted(() => ({ mutateAsync: vi.fn() })) +const patchDocumentMetadata = vi.hoisted(() => vi.fn()) +const listLogicalDocuments = vi.hoisted(() => vi.fn()) +const metadataDocumentsQuery = vi.hoisted(() => ({ + data: [] as LogicalDocument[] | undefined, + error: null as unknown, + isFetching: false, + isPending: false, + refetch: vi.fn(), +})) const routerMock = vi.hoisted(() => ({ push: vi.fn() })) const settingsState = vi.hoisted(() => ({ configurationState: 'active' as 'active' | 'setup-required', @@ -335,7 +344,8 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { }, useMutation: (options: { mutationKind?: string }) => options.mutationKind === 'cancel' ? cancelMutation : reindexMutation, - useQuery: (options: { queryKind?: string }) => { + useQuery: (options: { queryKey?: readonly unknown[]; queryKind?: string }) => { + if (options.queryKey?.includes('document-metadata-documents')) return metadataDocumentsQuery if (options.queryKind === 'settings') return { data: { @@ -354,6 +364,22 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { }) vi.mock('@/service/client', () => ({ + consoleClient: { + knowledgeFs: { + spaces: { + byControlSpaceId: { + documents: { + byDocumentId: { + patch: patchDocumentMetadata, + }, + }, + logicalDocuments: { + get: listLogicalDocuments, + }, + }, + }, + }, + }, consoleQuery: { knowledgeFs: { spaces: { @@ -556,6 +582,10 @@ describe('DocumentDetailPage', () => { tasksQuery.isFetchNextPageError = false tasksQuery.isFetchingNextPage = false tasksQuery.isPending = false + metadataDocumentsQuery.data = [] + metadataDocumentsQuery.error = null + metadataDocumentsQuery.isFetching = false + metadataDocumentsQuery.isPending = false permissionState.refresh.mockResolvedValue({ data: { dataset: { default_permission_keys: ['dataset.acl.edit'] } }, error: null, @@ -572,6 +602,13 @@ describe('DocumentDetailPage', () => { })) reindexMutation.mutateAsync.mockResolvedValue(queuedReindexResult()) cancelMutation.mutateAsync.mockResolvedValue(taskApiResponse(task({ state: 'canceled' }))) + patchDocumentMetadata.mockImplementation(async () => + logicalDocumentApiResponse(logicalDocument({ rowVersion: 3 })), + ) + listLogicalDocuments.mockResolvedValue({ + data: [logicalDocumentApiResponse(logicalDocument())], + next_cursor: null, + }) queryClient.invalidateQueries.mockResolvedValue(undefined) }) @@ -751,11 +788,395 @@ describe('DocumentDetailPage', () => { }) expect(startLabeling).toBeEnabled() await user.click(startLabeling) - expect(toastState.info).toHaveBeenCalledWith('dataset.newKnowledge.filtersUnavailable') + expect( + await screen.findByRole('button', { name: 'dataset.metadata.addMetadata' }), + ).toBeInTheDocument() + expect(toastState.info).not.toHaveBeenCalled() expect(screen.getByTestId('chunk-content-scroll')).toBe(previousContentScroller) expect(screen.getByRole('heading', { name: 'Setup requirements' })).toBeInTheDocument() }) + it('updates document metadata through the KnowledgeFS metadata endpoint', async () => { + const user = userEvent.setup() + documentQuery.data = logicalDocument({ + knowledgeSpaceId: 'remote-space-1', + userMetadata: { category: 'support', sourceName: 'Notion support SOP' }, + }) + + render() + + expect(screen.getByText('category')).toBeInTheDocument() + expect(screen.queryByText('sourceName')).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) + const valueInput = await screen.findByRole('textbox', { name: 'category' }) + await user.clear(valueInput) + await user.type(valueInput, 'security') + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(patchDocumentMetadata).toHaveBeenCalledWith({ + body: { expectedRowVersion: 2, patch: { category: 'security' } }, + params: { control_space_id: 'space-1', document_id: 'document-1' }, + }) + await waitFor(() => expect(toastState.success).toHaveBeenCalledWith('common.api.actionSuccess')) + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['knowledge-fs', 'document'], + }) + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['knowledge-fs', 'documents'], + }) + }) + + it('lets users choose the type of a new document metadata field', async () => { + const user = userEvent.setup() + metadataDocumentsQuery.data = [ + logicalDocument(), + logicalDocument({ + id: 'document-2', + rowVersion: 4, + userMetadata: { priority: 0 }, + }), + ] + + render() + + await user.click( + screen.getByRole('button', { + name: 'dataset.metadata.documentMetadata.startLabeling', + }), + ) + await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })) + await user.click(await screen.findByRole('option', { name: /priority/ })) + const valueInput = screen.getByRole('spinbutton', { name: 'priority' }) + await user.clear(valueInput) + await user.type(valueInput, '42') + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(patchDocumentMetadata).toHaveBeenCalledWith({ + body: { expectedRowVersion: 2, patch: { priority: 42 } }, + params: { control_space_id: 'space-1', document_id: 'document-1' }, + }) + }) + + it('keeps selected number and time metadata empty until the user enters a value', async () => { + const user = userEvent.setup() + metadataDocumentsQuery.data = [ + logicalDocument(), + logicalDocument({ + id: 'document-2', + userMetadata: { + priority: 7, + reviewed_at: '2026-08-04T10:00:00.000Z', + }, + }), + ] + + render() + + await user.click( + screen.getByRole('button', { + name: 'dataset.metadata.documentMetadata.startLabeling', + }), + ) + await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })) + await user.click(await screen.findByRole('option', { name: /priority/ })) + expect(screen.getByLabelText('priority')).toHaveValue(null) + + await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })) + await user.click(await screen.findByRole('option', { name: /reviewed_at/ })) + expect(screen.getByLabelText('reviewed_at')).toHaveValue('') + }) + + it('preserves a time field editor when this document has an empty value', async () => { + const user = userEvent.setup() + documentQuery.data = logicalDocument({ userMetadata: { reviewed_at: '' } }) + metadataDocumentsQuery.data = [ + logicalDocument({ userMetadata: { reviewed_at: '' } }), + logicalDocument({ + id: 'document-2', + userMetadata: { reviewed_at: '2026-08-04T10:00:00.000Z' }, + }), + ] + + render() + + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) + + expect(screen.getByLabelText('reviewed_at')).toHaveAttribute('type', 'datetime-local') + }) + + it('keeps the edit action busy while resolving metadata types', async () => { + const user = userEvent.setup() + let resolveMetadataRefetch!: (value: { data: LogicalDocument[] }) => void + documentQuery.data = logicalDocument({ userMetadata: { category: '' } }) + metadataDocumentsQuery.data = undefined + metadataDocumentsQuery.refetch.mockImplementation( + () => + new Promise((resolve) => { + resolveMetadataRefetch = resolve + }), + ) + + render() + + const editButton = screen.getByRole('button', { name: 'common.operation.edit' }) + await user.click(editButton) + + expect(editButton).toHaveAttribute('aria-disabled', 'true') + await user.click(editButton) + expect(metadataDocumentsQuery.refetch).toHaveBeenCalledOnce() + + await act(async () => { + resolveMetadataRefetch({ data: [logicalDocument({ userMetadata: { category: '' } })] }) + }) + expect(await screen.findByLabelText('category')).toBeInTheDocument() + }) + + it('converts UTC metadata timestamps to local datetime input values', async () => { + const user = userEvent.setup() + const getTimezoneOffset = vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(-480) + documentQuery.data = logicalDocument({ + userMetadata: { reviewed_at: '2026-08-04T10:00:00.000Z' }, + }) + metadataDocumentsQuery.data = [documentQuery.data] + + render() + + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) + + expect(screen.getByLabelText('reviewed_at')).toHaveValue('2026-08-04T18:00') + getTimezoneOffset.mockRestore() + }) + + it('creates a reusable metadata field across KnowledgeFS documents', async () => { + const user = userEvent.setup() + const secondDocument = logicalDocument({ id: 'document-2', rowVersion: 4 }) + listLogicalDocuments.mockResolvedValue({ + data: [ + logicalDocumentApiResponse(logicalDocument()), + logicalDocumentApiResponse(secondDocument), + ], + next_cursor: null, + }) + + render() + + await user.click( + screen.getByRole('button', { + name: 'dataset.metadata.documentMetadata.startLabeling', + }), + ) + await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })) + await user.click( + screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' }), + ) + await user.click(screen.getByRole('button', { name: 'number' })) + await user.type( + screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }), + 'priority', + ) + await user.keyboard('{Enter}') + + expect(listLogicalDocuments).toHaveBeenCalledWith({ + params: { control_space_id: 'space-1' }, + query: {}, + }) + expect(patchDocumentMetadata).toHaveBeenCalledWith({ + body: { expectedRowVersion: 2, patch: { priority: 0 } }, + params: { control_space_id: 'space-1', document_id: 'document-1' }, + }) + expect(patchDocumentMetadata).toHaveBeenCalledWith({ + body: { expectedRowVersion: 4, patch: { priority: 0 } }, + params: { control_space_id: 'space-1', document_id: 'document-2' }, + }) + }) + + it('keeps a newly created field in the current document draft', async () => { + const user = userEvent.setup() + const currentDocument = logicalDocument() + const secondDocument = logicalDocument({ id: 'document-2', rowVersion: 4 }) + const documents = [currentDocument, secondDocument] + documentQuery.data = currentDocument + metadataDocumentsQuery.data = documents + listLogicalDocuments.mockImplementation(async () => ({ + data: documents.map(logicalDocumentApiResponse), + next_cursor: null, + })) + patchDocumentMetadata.mockImplementation( + async ({ + body, + params, + }: { + body: { patch: Record } + params: { document_id: string } + }) => { + const index = documents.findIndex((candidate) => candidate.id === params.document_id) + if (index < 0) throw new Error(`Unknown document ${params.document_id}`) + const candidate = documents[index] + if (!candidate) throw new Error(`Unknown document ${params.document_id}`) + const updated = logicalDocument({ + ...candidate, + id: candidate.id, + rowVersion: candidate.rowVersion + 1, + userMetadata: { ...candidate.userMetadata, ...body.patch }, + }) + documents[index] = updated + if (updated.id === currentDocument.id) documentQuery.data = updated + return logicalDocumentApiResponse(updated) + }, + ) + + render() + + await user.click( + screen.getByRole('button', { + name: 'dataset.metadata.documentMetadata.startLabeling', + }), + ) + await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })) + await user.click( + screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' }), + ) + await user.click(screen.getByRole('button', { name: 'number' })) + await user.type( + screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }), + 'priority', + ) + await user.keyboard('{Enter}') + await waitFor(() => expect(screen.getByLabelText('priority')).toHaveValue(0)) + + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(patchDocumentMetadata).toHaveBeenCalledTimes(2) + expect(patchDocumentMetadata).not.toHaveBeenCalledWith( + expect.objectContaining({ body: expect.objectContaining({ patch: { priority: null } }) }), + ) + }) + + it('keeps metadata creation unavailable while the full field list is loading', async () => { + const user = userEvent.setup() + metadataDocumentsQuery.isPending = true + + render() + + await user.click( + screen.getByRole('button', { + name: 'dataset.metadata.documentMetadata.startLabeling', + }), + ) + await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })) + + expect( + screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' }), + ).toBeDisabled() + }) + + it('keeps the metadata create form open when creation fails', async () => { + const user = userEvent.setup() + patchDocumentMetadata.mockRejectedValueOnce(new Error('metadata update failed')) + + render() + + await user.click( + screen.getByRole('button', { + name: 'dataset.metadata.documentMetadata.startLabeling', + }), + ) + await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })) + await user.click( + screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' }), + ) + const nameInput = screen.getByRole('textbox', { + name: 'dataset.metadata.createMetadata.name', + }) + const createDialog = nameInput.closest('[role="dialog"]')! + await user.type(nameInput, 'category') + await user.click(within(createDialog).getByRole('button', { name: 'common.operation.save' })) + + expect(nameInput).toHaveValue('category') + expect(nameInput).toBeInTheDocument() + await waitFor(() => + expect(toastState.error).toHaveBeenCalledWith('dataset.newKnowledge.settings.saveFailed'), + ) + }) + + it('validates a new metadata name before submitting it', async () => { + const user = userEvent.setup() + metadataDocumentsQuery.data = [ + logicalDocument({ id: 'document-2', userMetadata: { existing_field: '' } }), + ] + + render() + + await user.click( + screen.getByRole('button', { + name: 'dataset.metadata.documentMetadata.startLabeling', + }), + ) + await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })) + await user.click( + screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' }), + ) + const dialog = screen.getByRole('dialog') + const nameInput = within(dialog).getByRole('textbox', { + name: 'dataset.metadata.createMetadata.name', + }) + const save = within(dialog).getByRole('button', { name: 'common.operation.save' }) + expect(save).toBeDisabled() + + await user.type(nameInput, 'Priority') + expect(nameInput).toHaveAttribute('aria-invalid', 'true') + expect(within(dialog).getByRole('alert')).toHaveTextContent( + 'dataset.metadata.checkName.invalid', + ) + expect(save).toBeDisabled() + + await user.clear(nameInput) + await user.type(nameInput, 'existing_field') + expect(nameInput).toHaveAttribute('aria-invalid', 'true') + expect(within(dialog).getByRole('alert')).toHaveTextContent( + 'dataset.metadata.checkName.duplicate', + ) + expect(save).toBeDisabled() + + await user.clear(nameInput) + await user.type(nameInput, 'a'.repeat(256)) + expect(within(dialog).getByRole('alert')).toHaveTextContent( + 'dataset.metadata.checkName.tooLong', + ) + expect(save).toBeDisabled() + + await user.clear(nameInput) + await user.type(nameInput, 'sourceName') + expect(within(dialog).getByRole('alert')).toHaveTextContent( + 'dataset.metadata.checkName.invalid', + ) + expect(save).toBeDisabled() + + await user.clear(nameInput) + await user.type(nameInput, 'priority_1') + expect(nameInput).not.toHaveAttribute('aria-invalid') + expect(within(dialog).queryByRole('alert')).not.toBeInTheDocument() + expect(save).toBeEnabled() + }) + + it('opens the New RAG metadata manager from the document picker', async () => { + const user = userEvent.setup() + + render() + + await user.click( + screen.getByRole('button', { + name: 'dataset.metadata.documentMetadata.startLabeling', + }), + ) + await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })) + await user.click( + screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.manageAction' }), + ) + + expect(routerMock.push).toHaveBeenCalledWith('/datasets/new/space-1/documents?metadata=1') + }) + it('supports tree keyboard navigation, collapse, and selection', async () => { const user = userEvent.setup() chunksQuery.data = { diff --git a/web/features/new-rag/__tests__/documents-page.spec.tsx b/web/features/new-rag/__tests__/documents-page.spec.tsx index 73fe03c2602..79bde46dd30 100644 --- a/web/features/new-rag/__tests__/documents-page.spec.tsx +++ b/web/features/new-rag/__tests__/documents-page.spec.tsx @@ -63,6 +63,14 @@ const retryMutation = vi.hoisted(() => ({ mutateAsync: vi.fn() })) const reindexMutation = vi.hoisted(() => ({ mutateAsync: vi.fn() })) const removeDocumentMutation = vi.hoisted(() => vi.fn()) const renameDocumentMutation = vi.hoisted(() => vi.fn()) +const listLogicalDocuments = vi.hoisted(() => vi.fn()) +const metadataDocumentsQuery = vi.hoisted(() => ({ + data: undefined as LogicalDocument[] | undefined, + error: null as unknown, + isFetching: false, + isPending: false, + refetch: vi.fn(), +})) const updateSourceMutation = vi.hoisted(() => vi.fn()) const uploadMutation = vi.hoisted(() => ({ mutateAsync: vi.fn() })) const bulkUploadMutation = vi.hoisted(() => ({ mutateAsync: vi.fn() })) @@ -344,15 +352,25 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { if (options.mutationKind === 'bulk-upload') return bulkUploadMutation return uploadMutation }, - useQuery: () => ({ - data: { - configuration_state: settingsState.configurationState, - embedding: null, - retrieval: null, - revision: 1, - }, - refetch: settingsState.refetch, - }), + useQuery: (options: { queryKey?: readonly unknown[] }) => { + if (options.queryKey?.includes('document-metadata-documents')) + return { + ...metadataDocumentsQuery, + data: + metadataDocumentsQuery.data ?? + documentsQuery.data?.pages.flatMap((page) => page.items) ?? + [], + } + return { + data: { + configuration_state: settingsState.configurationState, + embedding: null, + retrieval: null, + revision: 1, + }, + refetch: settingsState.refetch, + } + }, useQueryClient: () => queryClient, } }) @@ -404,6 +422,7 @@ vi.mock('@/service/client', () => ({ }, }, logicalDocuments: { + get: listLogicalDocuments, byDocumentId: { delete: removeDocumentMutation, }, @@ -618,6 +637,11 @@ describe('DocumentsPage', () => { documentsQuery.isPending = false documentsQuery.isRefetching = false documentsQuery.refetch.mockResolvedValue({ error: null }) + metadataDocumentsQuery.data = undefined + metadataDocumentsQuery.error = null + metadataDocumentsQuery.isFetching = false + metadataDocumentsQuery.isPending = false + metadataDocumentsQuery.refetch.mockResolvedValue({ error: null }) tasksQuery.data = { pages: [{ items: [] }] } tasksQuery.dataUpdatedAt = 0 tasksQuery.dataUpdateCount = 0 @@ -682,15 +706,21 @@ describe('DocumentsPage', () => { job: { id: 'delete-1', state: 'accepted' }, status_url: '/delete-1', }) - renameDocumentMutation.mockImplementation(async ({ body }: { body: { patch: unknown } }) => - documentApiResponse( - document({ - rowVersion: 2, - userMetadata: { - displayName: String((body.patch as { displayName: string }).displayName), - }, - }), + listLogicalDocuments.mockImplementation(async () => ({ + data: (documentsQuery.data?.pages.flatMap((page) => page.items) ?? []).map( + documentApiResponse, ), + next_cursor: null, + })) + renameDocumentMutation.mockImplementation( + async ({ body }: { body: { patch: Record } }) => { + const userMetadata = { ...document().userMetadata } + for (const [name, value] of Object.entries(body.patch)) { + if (value === null) delete userMetadata[name] + else userMetadata[name] = value + } + return documentApiResponse(document({ rowVersion: 2, userMetadata })) + }, ) updateSourceMutation.mockImplementation( async ({ body }: { body: { status: Source['status'] } }) => @@ -783,7 +813,26 @@ describe('DocumentsPage', () => { const metadata = screen.getByRole('button', { name: 'dataset.newKnowledge.metadata' }) expect(metadata).toBeEnabled() await user.click(metadata) - expect(toastMock.info).toHaveBeenCalledWith('dataset.newKnowledge.filtersUnavailable') + expect( + await screen.findByRole('heading', { name: 'dataset.metadata.metadata' }), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: 'dataset.metadata.datasetMetadata.addMetaData', + }), + ).toBeEnabled() + expect(screen.queryByText('sourceName')).not.toBeInTheDocument() + expect(screen.queryByText('document_name')).not.toBeInTheDocument() + expect(screen.queryByText('uploader')).not.toBeInTheDocument() + expect(screen.queryByText('upload_date')).not.toBeInTheDocument() + expect(screen.queryByText('last_update_date')).not.toBeInTheDocument() + expect(screen.queryByText('source')).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'common.operation.close' })) + await waitFor(() => { + expect( + screen.queryByRole('heading', { name: 'dataset.metadata.metadata' }), + ).not.toBeInTheDocument() + }) const rowActions = screen.getByRole('button', { name: /dataset\.newKnowledge\.documentActions/, }) @@ -808,6 +857,378 @@ describe('DocumentsPage', () => { expect(screen.queryByText('Ready handbook.pdf')).not.toBeInTheDocument() }) + it('creates metadata through the KnowledgeFS document metadata endpoint', async () => { + const user = userEvent.setup() + documentsQuery.data = { + pages: [{ items: [document({ id: 'one', title: 'One.pdf' })] }], + } + listLogicalDocuments + .mockResolvedValueOnce({ + data: [documentApiResponse(document({ id: 'one', title: 'One.pdf' }))], + next_cursor: 'next-page', + }) + .mockResolvedValueOnce({ + data: [ + documentApiResponse( + document({ id: 'two', rowVersion: 4, title: 'Two.pdf', userMetadata: {} }), + ), + ], + next_cursor: null, + }) + + render() + + await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.metadata' })) + await user.click( + await screen.findByRole('button', { + name: 'dataset.metadata.datasetMetadata.addMetaData', + }), + ) + await user.type( + screen.getByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }), + 'category', + ) + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(renameDocumentMutation).toHaveBeenCalledWith({ + body: { expectedRowVersion: 1, patch: { category: '' } }, + params: { control_space_id: 'space-1', document_id: 'one' }, + }) + expect(renameDocumentMutation).toHaveBeenCalledWith({ + body: { expectedRowVersion: 4, patch: { category: '' } }, + params: { control_space_id: 'space-1', document_id: 'two' }, + }) + expect(listLogicalDocuments).toHaveBeenNthCalledWith(1, { + params: { control_space_id: 'space-1' }, + query: {}, + }) + expect(listLogicalDocuments).toHaveBeenNthCalledWith(2, { + params: { control_space_id: 'space-1' }, + query: { cursor: 'next-page' }, + }) + await waitFor(() => + expect( + screen.queryByRole('textbox', { name: 'dataset.metadata.createMetadata.name' }), + ).not.toBeInTheDocument(), + ) + }) + + it('resumes a partially failed metadata creation without rewriting completed documents', async () => { + const user = userEvent.setup() + const currentDocuments = Array.from({ length: 6 }, (_, index) => + document({ + id: `document-${index + 1}`, + rowVersion: index + 1, + title: `Document ${index + 1}.pdf`, + userMetadata: {}, + }), + ) + documentsQuery.data = { pages: [{ items: currentDocuments }] } + listLogicalDocuments.mockImplementation(async () => ({ + data: currentDocuments.map(documentApiResponse), + next_cursor: null, + })) + let rejectSecondDocument = true + renameDocumentMutation.mockImplementation( + async ({ + body, + params, + }: { + body: { patch: Record } + params: { document_id: string } + }) => { + if (params.document_id === 'document-2' && rejectSecondDocument) { + rejectSecondDocument = false + throw new Error('conflict') + } + const index = currentDocuments.findIndex((candidate) => candidate.id === params.document_id) + if (index < 0) throw new Error(`Unknown document ${params.document_id}`) + const candidate = currentDocuments[index] + if (!candidate) throw new Error(`Unknown document ${params.document_id}`) + const updatedMetadata = { ...candidate.userMetadata } + for (const [name, value] of Object.entries(body.patch)) { + if (value === null) delete updatedMetadata[name] + else updatedMetadata[name] = value + } + const updated = document({ + ...candidate, + id: candidate.id, + rowVersion: candidate.rowVersion + 1, + userMetadata: updatedMetadata, + }) + currentDocuments[index] = updated + return documentApiResponse(updated) + }, + ) + + render() + + await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.metadata' })) + await user.click( + await screen.findByRole('button', { + name: 'dataset.metadata.datasetMetadata.addMetaData', + }), + ) + const nameInput = screen.getByRole('textbox', { + name: 'dataset.metadata.createMetadata.name', + }) + await user.type(nameInput, 'category') + await user.keyboard('{Enter}') + + await waitFor(() => expect(toastMock.error).toHaveBeenCalled()) + expect(nameInput).toBeInTheDocument() + expect(renameDocumentMutation).toHaveBeenCalledTimes(6) + + await user.keyboard('{Enter}') + + await waitFor(() => expect(nameInput).not.toBeInTheDocument()) + expect(renameDocumentMutation).toHaveBeenCalledTimes(7) + expect( + renameDocumentMutation.mock.calls.filter( + ([request]) => request.params.document_id === 'document-1', + ), + ).toHaveLength(1) + expect( + renameDocumentMutation.mock.calls.filter( + ([request]) => request.params.document_id === 'document-2', + ), + ).toHaveLength(2) + }) + + it('disables metadata creation until the full document metadata query completes', async () => { + const user = userEvent.setup() + documentsQuery.data = { + pages: [{ items: [document({ id: 'one', title: 'One.pdf' })] }], + } + metadataDocumentsQuery.isPending = true + + render() + + await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.metadata' })) + + expect( + await screen.findByRole('button', { + name: 'dataset.metadata.datasetMetadata.addMetaData', + }), + ).toBeDisabled() + }) + + it('lets users retry when the metadata document query fails', async () => { + const user = userEvent.setup() + documentsQuery.data = { + pages: [{ items: [document({ id: 'one', title: 'One.pdf' })] }], + } + metadataDocumentsQuery.error = new Error('metadata query failed') + + render() + + await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.metadata' })) + expect( + await screen.findByText('dataset.newKnowledge.documentLoadErrorDescription'), + ).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'common.operation.retry' })) + + expect(metadataDocumentsQuery.refetch).toHaveBeenCalledOnce() + }) + + it('validates a metadata name in the metadata drawer before submitting it', async () => { + const user = userEvent.setup() + documentsQuery.data = { + pages: [ + { + items: [ + document({ + id: 'one', + title: 'One.pdf', + userMetadata: { existing_field: 'support' }, + }), + ], + }, + ], + } + + render() + + await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.metadata' })) + await user.click( + await screen.findByRole('button', { + name: 'dataset.metadata.datasetMetadata.addMetaData', + }), + ) + const nameInput = screen.getByRole('textbox', { + name: 'dataset.metadata.createMetadata.name', + }) + const createDialog = nameInput.closest('[role="dialog"]')! + const save = within(createDialog).getByRole('button', { name: 'common.operation.save' }) + expect(save).toBeDisabled() + + await user.type(nameInput, '11') + expect(nameInput).toHaveAttribute('aria-invalid', 'true') + expect(within(createDialog).getByRole('alert')).toHaveTextContent( + 'dataset.metadata.checkName.invalid', + ) + expect(save).toBeDisabled() + + await user.clear(nameInput) + await user.type(nameInput, 'existing_field') + expect(within(createDialog).getByRole('alert')).toHaveTextContent( + 'dataset.metadata.checkName.duplicate', + ) + + await user.clear(nameInput) + await user.type(nameInput, 'a'.repeat(256)) + expect(within(createDialog).getByRole('alert')).toHaveTextContent( + 'dataset.metadata.checkName.tooLong', + ) + + await user.clear(nameInput) + await user.type(nameInput, 'displayName') + expect(within(createDialog).getByRole('alert')).toHaveTextContent( + 'dataset.metadata.checkName.invalid', + ) + + await user.clear(nameInput) + await user.type(nameInput, 'priority_1') + expect(nameInput).not.toHaveAttribute('aria-invalid') + expect(within(createDialog).queryByRole('alert')).not.toBeInTheDocument() + expect(save).toBeEnabled() + }) + + it('renames metadata through the KnowledgeFS document metadata endpoint', async () => { + const user = userEvent.setup() + documentsQuery.data = { + pages: [ + { + items: [ + document({ + id: 'one', + title: 'One.pdf', + userMetadata: { category: 'support', sourceName: 'Notion support SOP' }, + }), + ], + }, + ], + } + + render() + + await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.metadata' })) + await user.click(await screen.findByRole('button', { name: 'common.operation.edit' })) + const nameInput = screen.getByRole('textbox', { + name: 'dataset.metadata.datasetMetadata.name', + }) + await user.clear(nameInput) + await user.type(nameInput, 'topic') + await user.keyboard('{Enter}') + + expect(renameDocumentMutation).toHaveBeenCalledWith({ + body: { + expectedRowVersion: 1, + patch: { category: null, topic: 'support' }, + }, + params: { control_space_id: 'space-1', document_id: 'one' }, + }) + }) + + it('resumes a partially failed metadata rename from the open dialog', async () => { + const user = userEvent.setup() + const currentDocuments = [ + document({ id: 'one', userMetadata: { category: 'support' } }), + document({ id: 'two', rowVersion: 2, userMetadata: { category: 'sales' } }), + ] + documentsQuery.data = { pages: [{ items: currentDocuments }] } + listLogicalDocuments.mockImplementation(async () => ({ + data: currentDocuments.map(documentApiResponse), + next_cursor: null, + })) + let rejectSecondDocument = true + renameDocumentMutation.mockImplementation( + async ({ + body, + params, + }: { + body: { patch: Record } + params: { document_id: string } + }) => { + if (params.document_id === 'two' && rejectSecondDocument) { + rejectSecondDocument = false + throw new Error('conflict') + } + const index = currentDocuments.findIndex((candidate) => candidate.id === params.document_id) + if (index < 0) throw new Error(`Unknown document ${params.document_id}`) + const candidate = currentDocuments[index] + if (!candidate) throw new Error(`Unknown document ${params.document_id}`) + const updatedMetadata = { ...candidate.userMetadata } + for (const [name, value] of Object.entries(body.patch)) { + if (value === null) delete updatedMetadata[name] + else updatedMetadata[name] = value + } + const updated = document({ + ...candidate, + id: candidate.id, + rowVersion: candidate.rowVersion + 1, + userMetadata: updatedMetadata, + }) + currentDocuments[index] = updated + return documentApiResponse(updated) + }, + ) + + render() + + await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.metadata' })) + await user.click(await screen.findByRole('button', { name: 'common.operation.edit' })) + const nameInput = screen.getByRole('textbox', { + name: 'dataset.metadata.datasetMetadata.name', + }) + await user.clear(nameInput) + await user.type(nameInput, 'topic') + await user.keyboard('{Enter}') + + await waitFor(() => expect(toastMock.error).toHaveBeenCalled()) + expect(nameInput).toBeInTheDocument() + + await user.keyboard('{Enter}') + + await waitFor(() => expect(nameInput).not.toBeInTheDocument()) + expect(renameDocumentMutation).toHaveBeenCalledTimes(3) + expect( + renameDocumentMutation.mock.calls.filter(([request]) => request.params.document_id === 'one'), + ).toHaveLength(1) + expect( + renameDocumentMutation.mock.calls.filter(([request]) => request.params.document_id === 'two'), + ).toHaveLength(2) + }) + + it('deletes metadata through the KnowledgeFS document metadata endpoint', async () => { + const user = userEvent.setup() + documentsQuery.data = { + pages: [ + { + items: [ + document({ + id: 'one', + title: 'One.pdf', + userMetadata: { category: 'support', sourceName: 'Notion support SOP' }, + }), + ], + }, + ], + } + + render() + + await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.metadata' })) + await user.click(await screen.findByRole('button', { name: 'common.operation.remove' })) + await user.click(screen.getByRole('button', { name: 'common.operation.confirm' })) + + expect(renameDocumentMutation).toHaveBeenCalledWith({ + body: { expectedRowVersion: 1, patch: { category: null } }, + params: { control_space_id: 'space-1', document_id: 'one' }, + }) + }) + it('starts re-indexing from a document row action', async () => { const user = userEvent.setup() documentsQuery.data = { diff --git a/web/features/new-rag/document-chunk-detail.tsx b/web/features/new-rag/document-chunk-detail.tsx index 1aa0043f848..f50b0766a7b 100644 --- a/web/features/new-rag/document-chunk-detail.tsx +++ b/web/features/new-rag/document-chunk-detail.tsx @@ -10,6 +10,7 @@ import { useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { Markdown } from '@/app/components/base/markdown' import { chunkCharacterCount, chunkContentParts } from './document-detail-model' +import { DocumentMetadataCard } from './document-metadata-card' function formatBytes(bytes: number, locale: string) { const numberFormat = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }) @@ -52,6 +53,8 @@ function ChunkMarker({ label }: { label: string }) { } export function DocumentChunkDetail({ + canEdit, + controlSpaceId, document, chunks, chunksComplete, @@ -60,6 +63,8 @@ export function DocumentChunkDetail({ revision, selectedChunkId, }: { + canEdit: boolean + controlSpaceId: string document: LogicalDocument chunks: DocumentRevisionChunk[] chunksComplete: boolean @@ -180,20 +185,12 @@ export function DocumentChunkDetail({