diff --git a/workspaces/intelligent-assistant/.changeset/bright-knives-dance.md b/workspaces/intelligent-assistant/.changeset/bright-knives-dance.md new file mode 100644 index 00000000000..3e33f62be71 --- /dev/null +++ b/workspaces/intelligent-assistant/.changeset/bright-knives-dance.md @@ -0,0 +1,6 @@ +--- +'@red-hat-developer-hub/backstage-plugin-intelligent-assistant': minor +'@red-hat-developer-hub/backstage-plugin-intelligent-assistant-backend': minor +--- + +Add inline rename for notebook resources with double-click or kebab menu, frontend conflict validation, and backend PATCH endpoint with rollback on failure diff --git a/workspaces/intelligent-assistant/e2e-tests/pages/NotebookSurfacePage.ts b/workspaces/intelligent-assistant/e2e-tests/pages/NotebookSurfacePage.ts index b44e579772c..86c51b1d255 100644 --- a/workspaces/intelligent-assistant/e2e-tests/pages/NotebookSurfacePage.ts +++ b/workspaces/intelligent-assistant/e2e-tests/pages/NotebookSurfacePage.ts @@ -223,8 +223,7 @@ export class NotebookSurfacePage { firstListedDocumentOverflowMenuToggle(): Locator { return this.chatbotRegion() .getByRole('button', { - name: this.t['notebook.document.delete'], - exact: true, + name: new RegExp(`^${this.t['aria.options.label']} `), }) .first(); } diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/documents/documentService.test.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/documents/documentService.test.ts index 7ce5e71d13b..0e9f261d2d1 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/documents/documentService.test.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/documents/documentService.test.ts @@ -15,7 +15,7 @@ */ import { mockServices } from '@backstage/backend-test-utils'; -import { NotFoundError } from '@backstage/errors'; +import { ConflictError, NotFoundError } from '@backstage/errors'; import { setupServer } from 'msw/node'; @@ -288,6 +288,64 @@ describe('DocumentService', () => { }); }); + describe('renameDocument', () => { + it('should rename a document successfully', async () => { + const fileId = await documentService.uploadFile( + 'Content', + 'Original Name', + ); + await documentService.upsertDocument( + sessionId, + 'Original Name', + 'text', + fileId, + ); + + await documentService.renameDocument( + sessionId, + 'Original Name', + 'New Name', + ); + + const documents = await documentService.listDocuments(sessionId); + expect(documents).toHaveLength(1); + expect(documents[0].document_id).toBe('New Name'); + }); + + it('should throw NotFoundError for non-existent document', async () => { + await expect( + documentService.renameDocument(sessionId, 'Non-existent', 'New Name'), + ).rejects.toThrow(NotFoundError); + }); + + it('should throw ConflictError when new title already exists', async () => { + const fileId1 = await documentService.uploadFile('Content 1', 'Doc A'); + await documentService.upsertDocument(sessionId, 'Doc A', 'text', fileId1); + + const fileId2 = await documentService.uploadFile('Content 2', 'Doc B'); + await documentService.upsertDocument(sessionId, 'Doc B', 'text', fileId2); + + await expect( + documentService.renameDocument(sessionId, 'Doc A', 'Doc B'), + ).rejects.toThrow(ConflictError); + }); + + it('should preserve other documents when renaming', async () => { + const fileId1 = await documentService.uploadFile('Content 1', 'Doc A'); + await documentService.upsertDocument(sessionId, 'Doc A', 'text', fileId1); + + const fileId2 = await documentService.uploadFile('Content 2', 'Doc B'); + await documentService.upsertDocument(sessionId, 'Doc B', 'text', fileId2); + + await documentService.renameDocument(sessionId, 'Doc A', 'Doc A Renamed'); + + const documents = await documentService.listDocuments(sessionId); + expect(documents).toHaveLength(2); + expect(documents.map(d => d.document_id)).toContain('Doc A Renamed'); + expect(documents.map(d => d.document_id)).toContain('Doc B'); + }); + }); + describe('deleteDocument', () => { it('should delete a document successfully', async () => { const fileId = await documentService.uploadFile( diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/documents/documentService.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/documents/documentService.ts index 0816d885898..edcd0acdaba 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/documents/documentService.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/documents/documentService.ts @@ -259,6 +259,74 @@ export class DocumentService { return documents; } + /** + * Rename a document by updating its title attribute in the vector store. + * Deletes the old vector store file entry and re-creates it with the + * same underlying file but an updated title attribute. + * @param sessionId - Vector store ID + * @param currentTitle - Current document title + * @param newTitle - New document title + * @throws NotFoundError if document not found + * @throws ConflictError if newTitle conflicts with existing document + */ + async renameDocument( + sessionId: string, + currentTitle: string, + newTitle: string, + ): Promise { + const existingFile = await this.findFileByTitle(sessionId, currentTitle); + if (!existingFile) { + throw new NotFoundError(`Document not found: ${currentTitle}`); + } + + const conflicting = await this.findFileByTitle(sessionId, newTitle); + if (conflicting) { + throw new ConflictError( + `A document with the title "${newTitle}" already exists in this session`, + ); + } + + const attrs = existingFile.attributes || {}; + const fileId = existingFile.id; + + await this.client.vectorStores.files.delete(sessionId, fileId); + + try { + await this.client.vectorStores.files.create(sessionId, { + file_id: fileId, + chunking_strategy: this.chunkingStrategy, + attributes: { + ...attrs, + title: newTitle, + updated_at: new Date().toISOString(), + }, + }); + } catch (error) { + this.logger.error( + `Failed to re-create vector store entry after delete during rename of "${currentTitle}" to "${newTitle}". Attempting rollback.`, + ); + try { + await this.client.vectorStores.files.create(sessionId, { + file_id: fileId, + chunking_strategy: this.chunkingStrategy, + attributes: attrs, + }); + this.logger.info( + `Rollback succeeded: restored "${currentTitle}" in session ${sessionId}`, + ); + } catch (rollbackError) { + this.logger.error( + `Rollback failed: document "${currentTitle}" (file ${fileId}) is orphaned in session ${sessionId}`, + ); + } + throw error; + } + + this.logger.info( + `Renamed document "${currentTitle}" to "${newTitle}" in session ${sessionId}`, + ); + } + /** * Delete a document from the vector store and Files API * @param sessionId - Vector store ID diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/notebooksRouter.test.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/notebooksRouter.test.ts index f4541d3795d..60121ba4450 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/notebooksRouter.test.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/notebooksRouter.test.ts @@ -311,6 +311,87 @@ describe('Notebooks Router', () => { }); }); + describe('PATCH /v1/sessions/:sessionId/documents/:documentId', () => { + it('should rename a document', async () => { + await request(app) + .put(`/notebooks/v1/sessions/${sessionId}/documents`) + .field('title', 'Original Name') + .field('fileType', 'txt') + .attach('file', Buffer.from('Content'), 'test.txt'); + + const response = await request(app) + .patch( + `/notebooks/v1/sessions/${sessionId}/documents/${encodeURIComponent('Original Name')}`, + ) + .send({ title: 'New Name' }); + + expect(response.status).toBe(200); + expect(response.body.document_id).toBe('New Name'); + expect(response.body.message).toContain('renamed successfully'); + + const listResponse = await request(app).get( + `/notebooks/v1/sessions/${sessionId}/documents`, + ); + expect( + listResponse.body.documents.map((d: any) => d.document_id), + ).toContain('New Name'); + }); + + it('should return 400 if title is missing', async () => { + const response = await request(app) + .patch( + `/notebooks/v1/sessions/${sessionId}/documents/${encodeURIComponent('Some Doc')}`, + ) + .send({}); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('title is required'); + }); + + it('should return 400 if title is empty string', async () => { + const response = await request(app) + .patch( + `/notebooks/v1/sessions/${sessionId}/documents/${encodeURIComponent('Some Doc')}`, + ) + .send({ title: ' ' }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('title is required'); + }); + + it('should return 404 for non-existent document', async () => { + const response = await request(app) + .patch( + `/notebooks/v1/sessions/${sessionId}/documents/${encodeURIComponent('Non-existent')}`, + ) + .send({ title: 'New Name' }); + + expect(response.status).toBe(404); + }); + + it('should return 409 when new title conflicts', async () => { + await request(app) + .put(`/notebooks/v1/sessions/${sessionId}/documents`) + .field('title', 'Doc A') + .field('fileType', 'txt') + .attach('file', Buffer.from('Content A'), 'a.txt'); + + await request(app) + .put(`/notebooks/v1/sessions/${sessionId}/documents`) + .field('title', 'Doc B') + .field('fileType', 'txt') + .attach('file', Buffer.from('Content B'), 'b.txt'); + + const response = await request(app) + .patch( + `/notebooks/v1/sessions/${sessionId}/documents/${encodeURIComponent('Doc A')}`, + ) + .send({ title: 'Doc B' }); + + expect(response.status).toBe(409); + }); + }); + describe('DELETE /v1/sessions/:sessionId/documents/:documentId', () => { it('should delete document', async () => { await request(app) diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/notebooksRouters.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/notebooksRouters.ts index 4d0f5864a66..19b95d69bbc 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/notebooksRouters.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/notebooksRouters.ts @@ -442,6 +442,31 @@ export async function createNotebooksRouter( }), ); + notebooksRouter.patch( + '/v1/sessions/:sessionId/documents/:documentId', + generalRateLimiter, + requireNotebooksPermission, + requireSessionOwnership(), + withAuth(async (req, res) => { + const { sessionId, documentId } = req.params; + const { title } = req.body; + + if (!title || typeof title !== 'string' || !title.trim()) { + handleError(logger, res, 'title is required'); + return; + } + + await documentService.renameDocument(sessionId, documentId, title.trim()); + res.json( + createDocumentResponse( + title.trim(), + sessionId, + 'Document renamed successfully', + ), + ); + }), + ); + notebooksRouter.delete( '/v1/sessions/:sessionId/documents/:documentId', generalRateLimiter, diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/report-alpha.api.md b/workspaces/intelligent-assistant/plugins/intelligent-assistant/report-alpha.api.md index 3b1aeb164dd..2beebdd9904 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/report-alpha.api.md +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/report-alpha.api.md @@ -98,6 +98,11 @@ export const intelligentAssistantTranslationRef: TranslationRef< readonly 'notebook.overwrite.modal.title': string; readonly 'notebook.overwrite.modal.description': string; readonly 'notebook.overwrite.modal.action': string; + readonly 'notebook.document.rename': string; + readonly 'notebook.document.rename.tooltip': string; + readonly 'notebook.document.rename.success': string; + readonly 'notebook.document.rename.error': string; + readonly 'notebook.document.rename.conflict': string; readonly 'notebook.document.delete': string; readonly 'notebook.document.delete.title': string; readonly 'notebook.document.delete.description': string; diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/api/NotebooksApiClient.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/api/NotebooksApiClient.ts index e8c017939cb..69c315d1002 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/api/NotebooksApiClient.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/api/NotebooksApiClient.ts @@ -200,6 +200,21 @@ export class NotebooksApiClient implements NotebooksAPI { })); } + async renameDocument( + sessionId: string, + documentId: string, + newTitle: string, + ) { + const baseUrl = await this.getBaseUrl(); + await this.fetchJson( + `${baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}/documents/${encodeURIComponent(documentId)}`, + { + method: 'PATCH', + body: JSON.stringify({ title: newTitle }), + }, + ); + } + async deleteDocument(sessionId: string, documentId: string) { const baseUrl = await this.getBaseUrl(); await this.fetchJson( diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/api/notebooksApi.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/api/notebooksApi.ts index 66a56c72805..7c12177db4d 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/api/notebooksApi.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/api/notebooksApi.ts @@ -44,6 +44,11 @@ export type NotebooksAPI = { newTitle?: string, ) => Promise; listDocuments: (sessionId: string) => Promise; + renameDocument: ( + sessionId: string, + documentId: string, + newTitle: string, + ) => Promise; deleteDocument: (sessionId: string, documentId: string) => Promise; getDocumentStatus: ( sessionId: string, diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/DocumentSidebar.test.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/DocumentSidebar.test.tsx index f01c397457f..6f0cc367e71 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/DocumentSidebar.test.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/DocumentSidebar.test.tsx @@ -139,4 +139,144 @@ describe('DocumentSidebar', () => { expect(onToggleCollapse).toHaveBeenCalledTimes(1); }); + + describe('inline rename', () => { + const onRenameDocument = jest.fn(); + const documents = [mockDocument('doc-1', 'readme.md')]; + + const renameProps = { + ...defaultProps, + documents, + onRenameDocument, + }; + + it('should enter edit mode on double-click with base name only', () => { + render(); + + const fileName = screen.getByText('readme.md'); + fireEvent.doubleClick(fileName); + + const input = screen.getByRole('textbox'); + expect(input).toBeInTheDocument(); + expect(input).toHaveValue('readme'); + expect(screen.getByText('.md')).toBeInTheDocument(); + }); + + it('should call onRenameDocument with full filename on Enter', () => { + render(); + + fireEvent.doubleClick(screen.getByText('readme.md')); + + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: 'updated' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onRenameDocument).toHaveBeenCalledWith('doc-1', 'updated.md'); + }); + + it('should cancel editing on Escape', () => { + render(); + + fireEvent.doubleClick(screen.getByText('readme.md')); + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: 'changed' } }); + fireEvent.keyDown(input, { key: 'Escape' }); + + expect(onRenameDocument).not.toHaveBeenCalled(); + expect(screen.getByText('readme.md')).toBeInTheDocument(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + }); + + it('should not call onRenameDocument if name is unchanged', () => { + render(); + + fireEvent.doubleClick(screen.getByText('readme.md')); + const input = screen.getByRole('textbox'); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onRenameDocument).not.toHaveBeenCalled(); + }); + + it('should not call onRenameDocument if name is empty', () => { + render(); + + fireEvent.doubleClick(screen.getByText('readme.md')); + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: ' ' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onRenameDocument).not.toHaveBeenCalled(); + }); + + it('should enter edit mode from kebab Rename action with base name', () => { + render(); + + const kebab = screen.getByRole('button', { + name: /Options readme\.md/i, + }); + fireEvent.click(kebab); + + const renameItem = screen.getByText('Rename'); + fireEvent.click(renameItem); + + const input = screen.getByRole('textbox'); + expect(input).toBeInTheDocument(); + expect(input).toHaveValue('readme'); + expect(screen.getByText('.md')).toBeInTheDocument(); + }); + + it('should show rename tooltip on filename', () => { + render(); + + const fileName = screen.getByText('readme.md'); + expect(fileName).toHaveAttribute('title', 'Double-click to rename'); + }); + + it('should show error and block save when name conflicts with existing document', () => { + const twoDocuments = [ + mockDocument('doc-1', 'readme.md'), + mockDocument('doc-2', 'notes.md'), + ]; + render( + , + ); + + fireEvent.doubleClick(screen.getByText('readme.md')); + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: 'notes' } }); + + expect( + screen.getByText('A document with this name already exists.'), + ).toBeInTheDocument(); + + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onRenameDocument).not.toHaveBeenCalled(); + }); + + it('should not show error when name does not conflict', () => { + const twoDocuments = [ + mockDocument('doc-1', 'readme.md'), + mockDocument('doc-2', 'notes.md'), + ]; + render( + , + ); + + fireEvent.doubleClick(screen.getByText('readme.md')); + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: 'unique-name' } }); + + expect( + screen.queryByText('A document with this name already exists.'), + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/DocumentSidebar.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/DocumentSidebar.tsx index 34111003bb8..4a295109aa2 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/DocumentSidebar.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/DocumentSidebar.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { makeStyles, Typography } from '@material-ui/core'; import { @@ -22,8 +22,11 @@ import { Dropdown, DropdownItem, DropdownList, + HelperText, + HelperTextItem, MenuToggle, Spinner, + TextInput, Tooltip, } from '@patternfly/react-core'; import { EllipsisVIcon, PlusCircleIcon } from '@patternfly/react-icons'; @@ -87,7 +90,7 @@ const useStyles = makeStyles(theme => ({ }, documentItem: { display: 'flex', - alignItems: 'center', + alignItems: 'flex-start', gap: theme.spacing(1), padding: `${theme.spacing(1)}px ${theme.spacing(0.5)}px`, borderRadius: 4, @@ -106,6 +109,40 @@ const useStyles = makeStyles(theme => ({ fontSize: '0.875rem', lineHeight: '1.25rem', }, + renameContainer: { + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap' as const, + flex: 1, + minWidth: 0, + }, + renameInput: { + flex: 1, + minWidth: 0, + alignItems: 'center', + // Optional: PF6 default is 1px which is barely visible; can be removed if subtle error border is acceptable + '--pf-v6-c-form-control--m-error--after--BorderWidth': '2px', + '--pf-v6-c-form-control--FontSize': '0.875rem', + '--pf-v6-c-form-control--LineHeight': '1.25rem', + '--pf-v6-c-form-control--before--BorderStyle': 'none', + '& input': { + padding: '2px 4px', + outline: 'none', + }, + }, + renameExtension: { + flexShrink: 0, + fontSize: '0.875rem', + lineHeight: '1.25rem', + whiteSpace: 'nowrap', + }, + renameHelperText: { + width: '100%', + paddingTop: 4, + '& .pf-v6-c-helper-text__item-text': { + color: 'var(--pf-t--global--color--status--danger--default)', + }, + }, spinnerContainer: { flexShrink: 0, }, @@ -122,6 +159,18 @@ const useStyles = makeStyles(theme => ({ }, })); +const splitFileName = ( + fileName: string, +): { baseName: string; extension: string } => { + if (!fileName) return { baseName: '', extension: '' }; + const lastDot = fileName.lastIndexOf('.'); + if (lastDot <= 0) return { baseName: fileName, extension: '' }; + return { + baseName: fileName.slice(0, lastDot), + extension: fileName.slice(lastDot), + }; +}; + type DocumentSidebarProps = { notebookName: string; documents: SessionDocument[]; @@ -133,6 +182,7 @@ type DocumentSidebarProps = { onToggleCollapse: () => void; onAddDocument: () => void; onDeleteDocument?: (documentId: string) => void; + onRenameDocument?: (documentId: string, newTitle: string) => void; }; export const DocumentSidebar = ({ @@ -146,10 +196,82 @@ export const DocumentSidebar = ({ onToggleCollapse, onAddDocument, onDeleteDocument, + onRenameDocument, }: DocumentSidebarProps) => { const classes = useStyles(); const { t } = useTranslation(); const [openMenuDocId, setOpenMenuDocId] = useState(null); + const [editingDocId, setEditingDocId] = useState(null); + const [editName, setEditName] = useState(''); + const inputRef = useRef(null); + const savingRef = useRef(false); + + const startEditing = useCallback((docId: string, currentTitle: string) => { + const { baseName } = splitFileName(currentTitle); + setEditingDocId(docId); + setEditName(baseName); + setOpenMenuDocId(null); + setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, 0); + }, []); + + const cancelEditing = useCallback(() => { + setEditingDocId(null); + setEditName(''); + savingRef.current = false; + }, []); + + const getConflictError = useCallback( + (docId: string, originalTitle: string): string | null => { + const trimmedBase = editName.trim(); + if (!trimmedBase) return null; + const { baseName, extension } = splitFileName(originalTitle); + if (trimmedBase === baseName) return null; + const newFullName = trimmedBase + extension; + const conflict = documents.some( + d => d.document_id !== docId && d.title === newFullName, + ); + return conflict ? t('notebook.document.rename.conflict') : null; + }, + [editName, documents, t], + ); + + const saveRename = useCallback( + (docId: string, originalTitle: string) => { + if (savingRef.current) return; + const trimmedBase = editName.trim(); + const { baseName, extension } = splitFileName(originalTitle); + if (!trimmedBase || trimmedBase === baseName) { + cancelEditing(); + return; + } + const newFullName = trimmedBase + extension; + if ( + documents.some(d => d.document_id !== docId && d.title === newFullName) + ) { + return; + } + savingRef.current = true; + onRenameDocument?.(docId, newFullName); + cancelEditing(); + }, + [editName, documents, onRenameDocument, cancelEditing], + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent, docId: string, originalTitle: string) => { + if (event.key === 'Enter') { + event.preventDefault(); + saveRename(docId, originalTitle); + } else if (event.key === 'Escape') { + event.preventDefault(); + cancelEditing(); + } + }, + [saveRename, cancelEditing], + ); if (collapsed) { return null; @@ -221,7 +343,50 @@ export const DocumentSidebar = ({ {documents.map(doc => (
- {doc.title} + {editingDocId === doc.document_id ? ( + (() => { + const conflictError = getConflictError( + doc.document_id, + doc.title, + ); + return ( +
+ setEditName(value)} + onBlur={cancelEditing} + onKeyDown={event => + handleKeyDown(event, doc.document_id, doc.title) + } + validated={conflictError ? 'error' : 'default'} + aria-label={t('notebook.document.rename')} + /> + + {splitFileName(doc.title).extension} + + {conflictError && ( +
+ + + {conflictError} + + +
+ )} +
+ ); + })() + ) : ( + startEditing(doc.document_id, doc.title)} + > + {doc.title} + + )} {deletingDocumentIds?.has(doc.document_id) ? (
)} > + { + event.stopPropagation(); + startEditing(doc.document_id, doc.title); + }} + > + {t('notebook.document.rename')} + { diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx index b07d0bd4688..3610e82c812 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx @@ -52,6 +52,7 @@ import { useDocumentStatusPolling, type PendingUpload, } from '../../hooks/notebooks/useDocumentStatusPolling'; +import { useRenameDocument } from '../../hooks/notebooks/useRenameDocument'; import { useConversationMessages } from '../../hooks/useConversationMessages'; import { CreateMessageVariables } from '../../hooks/useCreateCoversationMessage'; import { useNotebookWelcomePrompts } from '../../hooks/useNotebookWelcomePrompts'; @@ -330,6 +331,8 @@ export const NotebookView = ({ setDeleteDocumentTarget({ id: documentId, name: documentId }); }, []); + const { mutateAsync: renameDocument } = useRenameDocument(); + const onComplete = useCallback( (message: string) => { setIsSendButtonDisabled(false); @@ -406,6 +409,36 @@ export const NotebookView = ({ const [isOverwriteModalOpen, setIsOverwriteModalOpen] = useState(false); const [filesToAddToModal, setFilesToAddToModal] = useState([]); + const handleRenameDocument = useCallback( + async (documentId: string, newTitle: string) => { + try { + await renameDocument({ sessionId, documentId, newTitle }); + setToastAlerts(prev => [ + { + key: Date.now() + documentId, + title: (t as Function)('notebook.document.rename.success', { + documentName: newTitle, + }) as string, + variant: 'success', + }, + ...prev, + ]); + } catch { + setToastAlerts(prev => [ + { + key: Date.now() + documentId, + title: (t as Function)('notebook.document.rename.error', { + documentName: documentId, + }) as string, + variant: 'danger', + }, + ...prev, + ]); + } + }, + [renameDocument, sessionId, t], + ); + const confirmDeleteDocument = useCallback(async () => { if (!deleteDocumentTarget) return; const { id: documentId, name: documentName } = deleteDocumentTarget; @@ -579,6 +612,7 @@ export const NotebookView = ({ onToggleCollapse={() => setSidebarCollapsed(prev => !prev)} onAddDocument={handleOpenUploadModal} onDeleteDocument={handleDeleteDocument} + onRenameDocument={handleRenameDocument} /> ); diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/notebooks/useRenameDocument.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/notebooks/useRenameDocument.ts new file mode 100644 index 00000000000..9b19b992649 --- /dev/null +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/notebooks/useRenameDocument.ts @@ -0,0 +1,55 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useApi } from '@backstage/core-plugin-api'; + +import { + useMutation, + useQueryClient, + type UseMutationResult, +} from '@tanstack/react-query'; + +import { notebooksApiRef } from '../../api/notebooksApi'; + +type RenameDocumentPayload = { + sessionId: string; + documentId: string; + newTitle: string; +}; + +export const useRenameDocument = (): UseMutationResult< + void, + unknown, + RenameDocumentPayload +> => { + const notebooksApi = useApi(notebooksApiRef); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (payload: RenameDocumentPayload) => { + await notebooksApi.renameDocument( + payload.sessionId, + payload.documentId, + payload.newTitle, + ); + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: ['notebooks', 'documents', variables.sessionId], + }); + }, + }); +}; diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/de.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/de.ts index 6dd9cea222a..ca7e4adba3d 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/de.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/de.ts @@ -179,6 +179,14 @@ const intelligentAssistantTranslationDe = createTranslationMessages({ 'modal.save': 'Speichern', 'modal.title.edit': 'Anhang bearbeiten', 'modal.title.preview': 'Anhang in der Vorschau anzeigen', + 'notebook.document.rename': 'Umbenennen', + 'notebook.document.rename.tooltip': 'Doppelklick zum Umbenennen', + 'notebook.document.rename.success': + '"{{documentName}}" erfolgreich umbenannt.', + 'notebook.document.rename.error': + 'Umbenennung von "{{documentName}}" fehlgeschlagen.', + 'notebook.document.rename.conflict': + 'Ein Dokument mit diesem Namen existiert bereits.', 'notebook.document.delete': 'Löschen', 'notebook.document.delete.action': 'Entfernen', 'notebook.document.delete.description': diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/es.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/es.ts index bbf6b0768ee..34fcbaa4bfb 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/es.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/es.ts @@ -175,6 +175,13 @@ const intelligentAssistantTranslationEs = createTranslationMessages({ 'modal.save': 'Guardar', 'modal.title.edit': 'Modificar archivo adjunto', 'modal.title.preview': 'Previsualizar archivo adjunto', + 'notebook.document.rename': 'Renombrar', + 'notebook.document.rename.tooltip': 'Doble clic para renombrar', + 'notebook.document.rename.success': + '"{{documentName}}" renombrado correctamente.', + 'notebook.document.rename.error': 'Error al renombrar "{{documentName}}".', + 'notebook.document.rename.conflict': + 'Ya existe un documento con este nombre.', 'notebook.document.delete': 'Eliminar', 'notebook.document.delete.action': 'Eliminar', 'notebook.document.delete.description': diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/fr.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/fr.ts index eb54f9c883e..1781c951b51 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/fr.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/fr.ts @@ -179,6 +179,14 @@ const intelligentAssistantTranslationFr = createTranslationMessages({ 'modal.save': 'Sauvegarder', 'modal.title.edit': 'Modifier la pièce jointe', 'modal.title.preview': 'Aperçu de la pièce jointe', + 'notebook.document.rename': 'Renommer', + 'notebook.document.rename.tooltip': 'Double-cliquez pour renommer', + 'notebook.document.rename.success': + '"{{documentName}}" renommé avec succès.', + 'notebook.document.rename.error': + 'Échec du renommage de "{{documentName}}".', + 'notebook.document.rename.conflict': + 'Un document portant ce nom existe déjà.', 'notebook.document.delete': 'Supprimer', 'notebook.document.delete.action': 'Supprimer', 'notebook.document.delete.description': diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/it.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/it.ts index 28aeab3f461..e49643631cc 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/it.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/it.ts @@ -177,6 +177,14 @@ const intelligentAssistantTranslationIt = createTranslationMessages({ 'modal.save': 'Salva', 'modal.title.edit': 'Modifica allegato', 'modal.title.preview': 'Anteprima allegato', + 'notebook.document.rename': 'Rinomina', + 'notebook.document.rename.tooltip': 'Doppio clic per rinominare', + 'notebook.document.rename.success': + '"{{documentName}}" rinominato con successo.', + 'notebook.document.rename.error': + 'Impossibile rinominare "{{documentName}}".', + 'notebook.document.rename.conflict': + 'Esiste già un documento con questo nome.', 'notebook.document.delete': 'Elimina', 'notebook.document.delete.action': 'Rimuovi', 'notebook.document.delete.description': diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/ja.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/ja.ts index d53434282c5..64ea533155e 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/ja.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/ja.ts @@ -174,6 +174,14 @@ const intelligentAssistantTranslationJa = createTranslationMessages({ 'modal.save': '保存', 'modal.title.edit': '添付ファイルの編集', 'modal.title.preview': '添付ファイルのプレビュー', + 'notebook.document.rename': '名前の変更', + 'notebook.document.rename.tooltip': 'ダブルクリックで名前を変更', + 'notebook.document.rename.success': + '「{{documentName}}」の名前が変更されました。', + 'notebook.document.rename.error': + '「{{documentName}}」の名前変更に失敗しました。', + 'notebook.document.rename.conflict': + 'この名前のドキュメントは既に存在します。', 'notebook.document.delete': '削除', 'notebook.document.delete.action': '削除', 'notebook.document.delete.description': diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/ref.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/ref.ts index c4fb6cf6e9d..af94c941205 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/ref.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/ref.ts @@ -105,6 +105,13 @@ export const intelligentAssistantMessages = { 'notebook.overwrite.modal.description': 'The following files already exist in this notebook. Do you want to overwrite them with the new versions?', 'notebook.overwrite.modal.action': 'Overwrite', + 'notebook.document.rename': 'Rename', + 'notebook.document.rename.tooltip': 'Double-click to rename', + 'notebook.document.rename.success': + '"{{documentName}}" renamed successfully.', + 'notebook.document.rename.error': 'Failed to rename "{{documentName}}".', + 'notebook.document.rename.conflict': + 'A document with this name already exists.', 'notebook.document.delete': 'Delete', 'notebook.document.delete.title': 'Remove resource?', 'notebook.document.delete.description':