kishalll commited on
Commit
0b895f3
·
1 Parent(s): cb1048d

feat: add document rename support

Browse files
backend/app/routes/documents.py CHANGED
@@ -20,7 +20,14 @@ from sqlalchemy.orm import Session
20
 
21
  from app.database import get_db
22
  from app.models import User, Document
23
- from app.schemas import DocumentResponse, DocumentListResponse, DocumentStatusResponse, ChunkSettings, UploadUrl
 
 
 
 
 
 
 
24
  from app.auth import get_current_user
25
  from app.config import get_settings
26
  from app.rag.chunker import chunk_document, get_page_count
@@ -550,6 +557,34 @@ def list_documents(
550
  )
551
 
552
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
553
  @router.get("/{document_id}", response_model=DocumentResponse)
554
  def get_document(
555
  document_id: str,
 
20
 
21
  from app.database import get_db
22
  from app.models import User, Document
23
+ from app.schemas import (
24
+ DocumentResponse,
25
+ DocumentListResponse,
26
+ DocumentStatusResponse,
27
+ DocumentRename,
28
+ ChunkSettings,
29
+ UploadUrl,
30
+ )
31
  from app.auth import get_current_user
32
  from app.config import get_settings
33
  from app.rag.chunker import chunk_document, get_page_count
 
557
  )
558
 
559
 
560
+ @router.patch("/{document_id}", response_model=DocumentResponse)
561
+ def rename_document(
562
+ document_id: str,
563
+ rename: DocumentRename,
564
+ user: User = Depends(get_current_user),
565
+ db: Session = Depends(get_db),
566
+ ):
567
+ """
568
+ Rename an uploaded document without changing its stored file or vector data.
569
+ """
570
+ doc = db.query(Document).filter(
571
+ Document.id == document_id,
572
+ Document.is_deleted.is_(False),
573
+ ).first()
574
+
575
+ if not doc:
576
+ raise HTTPException(status_code=404, detail="Document not found")
577
+
578
+ if str(doc.user_id) != str(user.id):
579
+ raise HTTPException(status_code=403, detail="You do not have permission to rename this document")
580
+
581
+ doc.original_name = rename.name
582
+ db.commit()
583
+ db.refresh(doc)
584
+
585
+ return DocumentResponse.model_validate(doc)
586
+
587
+
588
  @router.get("/{document_id}", response_model=DocumentResponse)
589
  def get_document(
590
  document_id: str,
backend/app/schemas.py CHANGED
@@ -1,7 +1,7 @@
1
  """
2
  Pydantic schemas for API request/response validation.
3
  """
4
- from pydantic import BaseModel, EmailStr, Field
5
  from typing import Optional, List
6
  from datetime import datetime
7
  from app.models import UserRole
@@ -110,6 +110,18 @@ class DocumentResponse(BaseModel):
110
  from_attributes = True
111
 
112
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  class DocumentStatusResponse(BaseModel):
114
  id: str
115
  status: str
 
1
  """
2
  Pydantic schemas for API request/response validation.
3
  """
4
+ from pydantic import BaseModel, EmailStr, Field, field_validator
5
  from typing import Optional, List
6
  from datetime import datetime
7
  from app.models import UserRole
 
110
  from_attributes = True
111
 
112
 
113
+ class DocumentRename(BaseModel):
114
+ name: str = Field(..., min_length=1, max_length=255)
115
+
116
+ @field_validator("name")
117
+ @classmethod
118
+ def validate_name(cls, value: str) -> str:
119
+ stripped = value.strip()
120
+ if not stripped:
121
+ raise ValueError("Document name cannot be empty")
122
+ return stripped
123
+
124
+
125
  class DocumentStatusResponse(BaseModel):
126
  id: str
127
  status: str
backend/tests/test_documents.py CHANGED
@@ -40,6 +40,66 @@ def test_upload_rejects_unsupported_extension_before_deep_validation(client, aut
40
  assert "not supported" in response.json()["detail"]
41
 
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  def test_ingest_document_builds_and_saves_graph(db_session, monkeypatch, tmp_path, user):
44
  document = Document(
45
  user_id=user.id,
 
40
  assert "not supported" in response.json()["detail"]
41
 
42
 
43
+ def test_rename_document_updates_original_name(client, auth_headers, ready_document, db_session):
44
+ response = client.patch(
45
+ f"/api/v1/documents/{ready_document.id}",
46
+ headers=auth_headers,
47
+ json={"name": " renamed-report.pdf "},
48
+ )
49
+
50
+ assert response.status_code == 200
51
+ payload = response.json()
52
+ assert payload["id"] == ready_document.id
53
+ assert payload["original_name"] == "renamed-report.pdf"
54
+
55
+ db_session.refresh(ready_document)
56
+ assert ready_document.original_name == "renamed-report.pdf"
57
+ assert ready_document.filename == "ready.txt"
58
+
59
+
60
+ def test_rename_document_rejects_empty_name(client, auth_headers, ready_document):
61
+ response = client.patch(
62
+ f"/api/v1/documents/{ready_document.id}",
63
+ headers=auth_headers,
64
+ json={"name": " "},
65
+ )
66
+
67
+ assert response.status_code == 422
68
+
69
+
70
+ def test_rename_document_returns_404_for_missing_document(client, auth_headers):
71
+ response = client.patch(
72
+ "/api/v1/documents/00000000-0000-0000-0000-000000000000",
73
+ headers=auth_headers,
74
+ json={"name": "missing.pdf"},
75
+ )
76
+
77
+ assert response.status_code == 404
78
+
79
+
80
+ def test_rename_document_returns_403_for_other_users_document(client, auth_headers, db_session, other_user):
81
+ other_document = Document(
82
+ user_id=other_user.id,
83
+ filename="other.txt",
84
+ original_name="other.txt",
85
+ file_size=64,
86
+ status="ready",
87
+ )
88
+ db_session.add(other_document)
89
+ db_session.commit()
90
+ db_session.refresh(other_document)
91
+
92
+ response = client.patch(
93
+ f"/api/v1/documents/{other_document.id}",
94
+ headers=auth_headers,
95
+ json={"name": "renamed.txt"},
96
+ )
97
+
98
+ assert response.status_code == 403
99
+ db_session.refresh(other_document)
100
+ assert other_document.original_name == "other.txt"
101
+
102
+
103
  def test_ingest_document_builds_and_saves_graph(db_session, monkeypatch, tmp_path, user):
104
  document = Document(
105
  user_id=user.id,
frontend/src/app/dashboard/page.tsx CHANGED
@@ -68,6 +68,13 @@ export default function DashboardPage() {
68
  const [connectionError, setConnectionError] = useState("");
69
  const [documentsLoading, setDocumentsLoading] = useState(true);
70
 
 
 
 
 
 
 
 
71
  // Auth guard
72
  useEffect(() => {
73
  if (!loading && !user) router.replace("/login");
@@ -162,6 +169,7 @@ export default function DashboardPage() {
162
  setPdfPage(1);
163
  }}
164
  onDocumentsChange={loadDocuments}
 
165
  />
166
  );
167
 
 
68
  const [connectionError, setConnectionError] = useState("");
69
  const [documentsLoading, setDocumentsLoading] = useState(true);
70
 
71
+ const handleDocumentRenamed = useCallback((renamedDocument: DocInfo) => {
72
+ setDocuments((current) =>
73
+ current.map((document) => (document.id === renamedDocument.id ? renamedDocument : document))
74
+ );
75
+ setActiveDoc((current) => (current?.id === renamedDocument.id ? renamedDocument : current));
76
+ }, []);
77
+
78
  // Auth guard
79
  useEffect(() => {
80
  if (!loading && !user) router.replace("/login");
 
169
  setPdfPage(1);
170
  }}
171
  onDocumentsChange={loadDocuments}
172
+ onDocumentRenamed={handleDocumentRenamed}
173
  />
174
  );
175
 
frontend/src/components/document/DocumentSidebar.tsx CHANGED
@@ -7,6 +7,7 @@ import { api } from "@/lib/api";
7
  import { ScrollArea } from "@/components/ui/scroll-area";
8
  import { Skeleton } from "@/components/ui/skeleton";
9
  import { Button } from "@/components/ui/button";
 
10
  import { Badge } from "@/components/ui/badge";
11
  import { Progress } from "@/components/ui/progress";
12
  import {
@@ -23,6 +24,7 @@ interface Props {
23
  loading?: boolean;
24
  onSelectDoc: (doc: DocInfo) => void;
25
  onDocumentsChange: () => void;
 
26
  }
27
 
28
  function DocumentListSkeleton() {
@@ -47,13 +49,23 @@ function DocumentListSkeleton() {
47
  );
48
  }
49
 
50
- export default function DocumentSidebar({ documents = [], activeDoc, loading = false, onSelectDoc, onDocumentsChange }: Props) {
 
 
 
 
 
 
 
51
  const { t } = useTranslation();
52
  const [uploading, setUploading] = useState(false);
53
  const [uploadProgress, setUploadProgress] = useState(0);
54
  const [uploadError, setUploadError] = useState("");
55
  const [deleting, setDeleting] = useState<string | null>(null);
56
  const [settingsDoc, setSettingsDoc] = useState<DocInfo | null>(null);
 
 
 
57
 
58
  const onDrop = useCallback(
59
  (acceptedFiles: File[]) => {
@@ -119,6 +131,66 @@ export default function DocumentSidebar({ documents = [], activeDoc, loading = f
119
  setSettingsDoc(doc);
120
  };
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  const statusIcon = (status: string) => {
123
  switch (status) {
124
  case "ready":
@@ -196,22 +268,45 @@ export default function DocumentSidebar({ documents = [], activeDoc, loading = f
196
  </div>
197
  ) : (
198
  <div className="space-y-1 pb-3">
199
- {documents.map((doc) => (
200
- <button
201
- key={doc.id}
202
- onClick={() => doc.status === "ready" && onSelectDoc(doc)}
203
- className={`w-full text-left p-2.5 rounded-lg transition-all duration-200 group
204
- ${activeDoc?.id === doc.id
205
- ? "bg-primary/15 border border-primary/30"
206
- : "hover:bg-sidebar-accent border border-transparent"}
207
- ${doc.status !== "ready" ? "opacity-60 cursor-default" : "cursor-pointer"}`}
208
- >
209
- <div className="flex items-start gap-2.5">
210
- {statusIcon(doc.status)}
211
- <div className="flex-1 min-w-0">
212
- <p className="text-sm font-medium truncate leading-tight">
213
- {doc.original_name}
214
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  <p className="text-xs text-muted-foreground mt-1">
216
  {doc.summary || "📄 No summary available"}
217
  </p>
@@ -242,8 +337,8 @@ export default function DocumentSidebar({ documents = [], activeDoc, loading = f
242
  </Badge>
243
  )}
244
  </div>
245
- </div>
246
- <div className="flex items-center gap-1 shrink-0">
247
  {/* Action buttons (Settings and Delete) are only visible on hover and when the document is ready. The settings button is disabled if the document is not ready, and the delete button shows a loader when the document is being deleted. */}
248
  <Button
249
  variant="ghost"
@@ -267,10 +362,11 @@ export default function DocumentSidebar({ documents = [], activeDoc, loading = f
267
  <Trash2 className="w-3 h-3 text-destructive" />
268
  )}
269
  </Button>
 
270
  </div>
271
  </div>
272
- </button>
273
- ))}
274
  </div>
275
  )}
276
  </ScrollArea>
 
7
  import { ScrollArea } from "@/components/ui/scroll-area";
8
  import { Skeleton } from "@/components/ui/skeleton";
9
  import { Button } from "@/components/ui/button";
10
+ import { Input } from "@/components/ui/input";
11
  import { Badge } from "@/components/ui/badge";
12
  import { Progress } from "@/components/ui/progress";
13
  import {
 
24
  loading?: boolean;
25
  onSelectDoc: (doc: DocInfo) => void;
26
  onDocumentsChange: () => void;
27
+ onDocumentRenamed: (doc: DocInfo) => void;
28
  }
29
 
30
  function DocumentListSkeleton() {
 
49
  );
50
  }
51
 
52
+ export default function DocumentSidebar({
53
+ documents = [],
54
+ activeDoc,
55
+ loading = false,
56
+ onSelectDoc,
57
+ onDocumentsChange,
58
+ onDocumentRenamed,
59
+ }: Props) {
60
  const { t } = useTranslation();
61
  const [uploading, setUploading] = useState(false);
62
  const [uploadProgress, setUploadProgress] = useState(0);
63
  const [uploadError, setUploadError] = useState("");
64
  const [deleting, setDeleting] = useState<string | null>(null);
65
  const [settingsDoc, setSettingsDoc] = useState<DocInfo | null>(null);
66
+ const [editingDocId, setEditingDocId] = useState<string | null>(null);
67
+ const [draftName, setDraftName] = useState("");
68
+ const [renamingDocId, setRenamingDocId] = useState<string | null>(null);
69
 
70
  const onDrop = useCallback(
71
  (acceptedFiles: File[]) => {
 
131
  setSettingsDoc(doc);
132
  };
133
 
134
+ const startRename = (doc: DocInfo, e?: React.MouseEvent) => {
135
+ e?.stopPropagation();
136
+ setEditingDocId(doc.id);
137
+ setDraftName(doc.original_name);
138
+ };
139
+
140
+ const cancelRename = () => {
141
+ setEditingDocId(null);
142
+ setDraftName("");
143
+ };
144
+
145
+ const submitRename = async (doc: DocInfo) => {
146
+ const nextName = draftName.trim();
147
+
148
+ if (!nextName) {
149
+ toast.error("Document name cannot be empty");
150
+ return;
151
+ }
152
+
153
+ if (nextName === doc.original_name) {
154
+ cancelRename();
155
+ return;
156
+ }
157
+
158
+ const optimisticDoc = { ...doc, original_name: nextName };
159
+ onDocumentRenamed(optimisticDoc);
160
+ setRenamingDocId(doc.id);
161
+
162
+ try {
163
+ const updatedDoc = await api.renameDocument<DocInfo>(doc.id, nextName);
164
+ onDocumentRenamed(updatedDoc);
165
+ cancelRename();
166
+ toast.success("Document renamed");
167
+ } catch (err) {
168
+ onDocumentRenamed(doc);
169
+ const message = err instanceof Error ? err.message : "Rename failed";
170
+ toast.error(message);
171
+ } finally {
172
+ setRenamingDocId(null);
173
+ }
174
+ };
175
+
176
+ const handleRenameKeyDown = (doc: DocInfo, e: React.KeyboardEvent<HTMLInputElement>) => {
177
+ if (e.key === "Enter") {
178
+ e.preventDefault();
179
+ void submitRename(doc);
180
+ } else if (e.key === "Escape") {
181
+ e.preventDefault();
182
+ cancelRename();
183
+ }
184
+ };
185
+
186
+ const handleRowKeyDown = (doc: DocInfo, e: React.KeyboardEvent<HTMLDivElement>) => {
187
+ if (editingDocId === doc.id || doc.status !== "ready") return;
188
+ if (e.key === "Enter" || e.key === " ") {
189
+ e.preventDefault();
190
+ onSelectDoc(doc);
191
+ }
192
+ };
193
+
194
  const statusIcon = (status: string) => {
195
  switch (status) {
196
  case "ready":
 
268
  </div>
269
  ) : (
270
  <div className="space-y-1 pb-3">
271
+ {documents.map((doc) => {
272
+ const isEditing = editingDocId === doc.id;
273
+ const isRenaming = renamingDocId === doc.id;
274
+
275
+ return (
276
+ <div
277
+ key={doc.id}
278
+ role="button"
279
+ tabIndex={doc.status === "ready" ? 0 : -1}
280
+ onClick={() => doc.status === "ready" && !isEditing && onSelectDoc(doc)}
281
+ onKeyDown={(e) => handleRowKeyDown(doc, e)}
282
+ className={`w-full text-left p-2.5 rounded-lg transition-all duration-200 group
283
+ ${activeDoc?.id === doc.id
284
+ ? "bg-primary/15 border border-primary/30"
285
+ : "hover:bg-sidebar-accent border border-transparent"}
286
+ ${doc.status !== "ready" ? "opacity-60 cursor-default" : "cursor-pointer"}`}
287
+ >
288
+ <div className="flex items-start gap-2.5">
289
+ {statusIcon(doc.status)}
290
+ <div className="flex-1 min-w-0">
291
+ {isEditing ? (
292
+ <Input
293
+ value={draftName}
294
+ onChange={(e) => setDraftName(e.target.value)}
295
+ onClick={(e) => e.stopPropagation()}
296
+ onKeyDown={(e) => handleRenameKeyDown(doc, e)}
297
+ disabled={isRenaming}
298
+ autoFocus
299
+ className="h-7 px-2 text-sm font-medium"
300
+ />
301
+ ) : (
302
+ <p
303
+ className="text-sm font-medium truncate leading-tight"
304
+ onDoubleClick={(e) => startRename(doc, e)}
305
+ title="Double-click to rename"
306
+ >
307
+ {doc.original_name}
308
+ </p>
309
+ )}
310
  <p className="text-xs text-muted-foreground mt-1">
311
  {doc.summary || "📄 No summary available"}
312
  </p>
 
337
  </Badge>
338
  )}
339
  </div>
340
+ </div>
341
+ <div className="flex items-center gap-1 shrink-0">
342
  {/* Action buttons (Settings and Delete) are only visible on hover and when the document is ready. The settings button is disabled if the document is not ready, and the delete button shows a loader when the document is being deleted. */}
343
  <Button
344
  variant="ghost"
 
362
  <Trash2 className="w-3 h-3 text-destructive" />
363
  )}
364
  </Button>
365
+ </div>
366
  </div>
367
  </div>
368
+ );
369
+ })}
370
  </div>
371
  )}
372
  </ScrollArea>
frontend/src/lib/api.ts CHANGED
@@ -224,6 +224,33 @@ class ApiClient {
224
  return res.json();
225
  }
226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  async postForm<T>(path: string, formData: FormData, options?: FetchOptions): Promise<T> {
228
  const token = options?.token || this.getToken();
229
  const headers: HeadersInit = {};
 
224
  return res.json();
225
  }
226
 
227
+ async patch<T>(path: string, body?: unknown, options?: FetchOptions): Promise<T> {
228
+ const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
229
+ method: "PATCH",
230
+ headers: this.getHeaders(options?.token),
231
+ body: body ? JSON.stringify(body) : undefined,
232
+ ...options,
233
+ });
234
+
235
+ // Auto-refresh on 401
236
+ if (res.status === 401 && !options?._skipRefresh) {
237
+ const newToken = await this.tryRefreshToken();
238
+ if (newToken) {
239
+ return this.patch<T>(path, body, { ...options, token: newToken, _skipRefresh: true });
240
+ }
241
+ }
242
+
243
+ if (!res.ok) {
244
+ throw new Error(await this.getErrorMessage(res, res.statusText || "Request failed"));
245
+ }
246
+
247
+ return res.json();
248
+ }
249
+
250
+ async renameDocument<T>(documentId: string, name: string): Promise<T> {
251
+ return this.patch<T>(`/api/v1/documents/${documentId}`, { name });
252
+ }
253
+
254
  async postForm<T>(path: string, formData: FormData, options?: FetchOptions): Promise<T> {
255
  const token = options?.token || this.getToken();
256
  const headers: HeadersInit = {};