diff --git a/backend/src/routes/correspondents.rs b/backend/src/routes/correspondents.rs index 24ac7a9..9bf3ba7 100644 --- a/backend/src/routes/correspondents.rs +++ b/backend/src/routes/correspondents.rs @@ -167,7 +167,8 @@ pub async fn update_correspondent( } if new_name.is_none() && new_metadata.is_none() { - return Err(AppError::bad_request("no changes supplied")); + let usage = load_usage_for_correspondent(&mut conn, correspondent_id)?; + return Ok(Json(build_summary(existing.clone(), usage))); } let mut changeset = CorrespondentChangeset::default(); diff --git a/backend/src/routes/folders.rs b/backend/src/routes/folders.rs index e0d0658..9692c60 100644 --- a/backend/src/routes/folders.rs +++ b/backend/src/routes/folders.rs @@ -39,7 +39,9 @@ pub struct EnsureFolderPathRequest { #[derive(Deserialize)] pub struct UpdateFolderRequest { - pub parent_id: Option, + #[serde(default)] + pub parent_id: Option>, + pub name: Option, } #[derive(Serialize)] @@ -597,50 +599,69 @@ pub async fn delete_folder( Ok(StatusCode::NO_CONTENT) } -pub async fn update_folder_parent( +pub async fn update_folder( State(state): State, Path(folder_id): Path, Json(payload): Json, ) -> AppResult { - if payload.parent_id == Some(folder_id) { - return Err(AppError::bad_request("folder cannot be its own parent")); - } - let mut conn = state.db()?; conn.transaction::<(), AppError, _>(|conn| { let folder: Folder = folders::table.find(folder_id).first(conn)?; - let current_parent = folder.parent_id; - let next_parent = payload.parent_id; - if current_parent == next_parent { - return Ok(()); - } + let mut next_parent = folder.parent_id; + let mut parent_changed = false; - if let Some(parent_id) = next_parent { - let _parent: Folder = folders::table.find(parent_id).first(conn)?; - } - - if let Some(parent_id) = next_parent { - let descendant_ids = gather_descendant_folder_ids(conn, folder_id)?; - if descendant_ids.contains(&parent_id) { - return Err(AppError::bad_request( - "cannot move folder into itself or a descendant", - )); + if let Some(parent_request) = payload.parent_id { + if parent_request == Some(folder_id) { + return Err(AppError::bad_request("folder cannot be its own parent")); } + + if let Some(parent_id) = parent_request { + let _parent: Folder = folders::table.find(parent_id).first(conn)?; + + let descendant_ids = gather_descendant_folder_ids(conn, folder_id)?; + if descendant_ids.contains(&parent_id) { + return Err(AppError::bad_request( + "cannot move folder into itself or a descendant", + )); + } + } + + parent_changed = parent_request != folder.parent_id; + next_parent = parent_request; + } + + let mut new_name = folder.name.clone(); + let mut name_changed = false; + + if let Some(name) = payload.name { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err(AppError::bad_request("name must not be empty")); + } + + if trimmed != folder.name { + new_name = trimmed.to_string(); + name_changed = true; + } + } + + if !parent_changed && !name_changed { + return Ok(()); } let conflict = if let Some(parent_id) = next_parent { folders::table .filter(folders::parent_id.eq(Some(parent_id))) - .filter(folders::name.eq(&folder.name)) + .filter(folders::name.eq(&new_name)) .filter(folders::id.ne(folder_id)) .first::(conn) .optional()? } else { folders::table .filter(folders::parent_id.is_null()) - .filter(folders::name.eq(&folder.name)) + .filter(folders::name.eq(&new_name)) .filter(folders::id.ne(folder_id)) .first::(conn) .optional()? @@ -652,11 +673,12 @@ pub async fn update_folder_parent( )); } - let new_path = build_path_cache(conn, next_parent, &folder.name)?; + let new_path = build_path_cache(conn, next_parent, &new_name)?; diesel::update(folders::table.find(folder_id)) .set(( folders::parent_id.eq(next_parent), + folders::name.eq(&new_name), folders::path_cache.eq(new_path), )) .execute(conn)?; diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index bea53cd..7d6fac0 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -95,7 +95,7 @@ pub fn create_router(state: AppState) -> Router<()> { .route("/path", post(folders::ensure_folder_path)) .route( "/:id", - delete(folders::delete_folder).patch(folders::update_folder_parent), + delete(folders::delete_folder).patch(folders::update_folder), ) .route("/:id/contents", get(folders::list_folder_contents)) .route("/:id/documents", get(folders::search_documents)); diff --git a/backend/src/routes/tags.rs b/backend/src/routes/tags.rs index 0d50f1d..2476e19 100644 --- a/backend/src/routes/tags.rs +++ b/backend/src/routes/tags.rs @@ -112,7 +112,16 @@ pub async fn update_tag( if matches!(label_class, NullableValue::Omitted) && matches!(color_class, NullableValue::Omitted) { - return Err(AppError::bad_request("no changes supplied")); + let usage_count: i64 = document_tags::table + .filter(document_tags::tag_id.eq(tag_id)) + .select(count_star()) + .first(&mut conn)?; + return Ok(Json(TagCatalogEntry { + id: existing.id, + label: existing.label.clone(), + color: existing.color.clone(), + usage_count, + })); } let mut new_label: Option = None; @@ -163,7 +172,16 @@ pub async fn update_tag( } if !label_changed && !color_changed { - return Err(AppError::bad_request("no changes supplied")); + let usage_count: i64 = document_tags::table + .filter(document_tags::tag_id.eq(tag_id)) + .select(count_star()) + .first(&mut conn)?; + return Ok(Json(TagCatalogEntry { + id: existing.id, + label: existing.label.clone(), + color: existing.color.clone(), + usage_count, + })); } let changeset = UpdateTagChangeset { diff --git a/backend/tests/folders_flow.rs b/backend/tests/folders_flow.rs index 3935408..5d11f87 100644 --- a/backend/tests/folders_flow.rs +++ b/backend/tests/folders_flow.rs @@ -15,10 +15,14 @@ struct FolderResponse { #[derive(Deserialize)] struct FolderInfo { id: Uuid, + name: String, + path_cache: Option, } #[derive(Deserialize)] struct FolderContents { + folder: Option, + subfolders: Vec, documents: Vec, } @@ -39,6 +43,14 @@ struct EnsureFolderPath<'a> { segments: &'a [&'a str], } +#[derive(Serialize)] +struct UpdateFolderRequest { + #[serde(skip_serializing_if = "Option::is_none")] + parent_id: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + name: Option, +} + #[derive(Serialize)] struct MoveDocumentRequest { folder_id: Option, @@ -215,3 +227,80 @@ async fn ensure_path_creates_nested_folders() -> Result<()> { app.cleanup().await?; Ok(()) } + +#[tokio::test] +async fn folder_rename_updates_name_and_child_paths() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "renamepass"; + app.insert_user("rename-admin", password, "admin").await?; + let token = app.login_token("rename-admin", password).await?; + + let parent_resp = app + .post_json( + "/api/folders", + &CreateFolder { + name: "Projects", + parent_id: None, + }, + Some(&token), + ) + .await?; + assert_eq!(parent_resp.status(), StatusCode::OK); + let parent_body = body_to_vec(parent_resp.into_body()).await?; + let parent: FolderResponse = serde_json::from_slice(&parent_body)?; + + let child_resp = app + .post_json( + "/api/folders", + &CreateFolder { + name: "Q1", + parent_id: Some(parent.folder.id), + }, + Some(&token), + ) + .await?; + assert_eq!(child_resp.status(), StatusCode::OK); + let child_body = body_to_vec(child_resp.into_body()).await?; + let child: FolderResponse = serde_json::from_slice(&child_body)?; + + let rename_resp = app + .patch_json( + &format!("/api/folders/{}", parent.folder.id), + &UpdateFolderRequest { + parent_id: None, + name: Some("Archive".to_string()), + }, + Some(&token), + ) + .await?; + assert_eq!(rename_resp.status(), StatusCode::NO_CONTENT); + + let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?; + assert_eq!(root_contents.status(), StatusCode::OK); + let root_body = body_to_vec(root_contents.into_body()).await?; + let root: FolderContents = serde_json::from_slice(&root_body)?; + let renamed = root + .subfolders + .iter() + .find(|f| f.id == parent.folder.id) + .expect("renamed folder present"); + assert_eq!(renamed.name, "Archive"); + assert_eq!(renamed.path_cache.as_deref(), Some("/Archive")); + + let child_contents = app + .get( + &format!("/api/folders/{}/contents", child.folder.id), + Some(&token), + ) + .await?; + assert_eq!(child_contents.status(), StatusCode::OK); + let child_contents_body = body_to_vec(child_contents.into_body()).await?; + let child_details: FolderContents = serde_json::from_slice(&child_contents_body)?; + let child_folder = child_details.folder.expect("child folder info"); + assert_eq!(child_folder.path_cache.as_deref(), Some("/Archive/Q1")); + + app.cleanup().await?; + Ok(()) +} diff --git a/docs/api.txt b/docs/api.txt index 1d7ad90..8783660 100644 --- a/docs/api.txt +++ b/docs/api.txt @@ -49,7 +49,7 @@ Folders - GET /api/folders/:id/contents - List subfolders and documents inside a folder; use `root` for the workspace root. - GET /api/folders/:id/documents - Search within a folder tree with optional `query` and `tags` filters. - DELETE /api/folders/:id - Soft-delete a folder. -- PATCH /api/folders/:id - Change a folder's parent. +- PATCH /api/folders/:id - Update a folder's parent (`parent_id`) and/or rename it (`name`). Tags ---- diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx index 565c169..8e033de 100644 --- a/frontend/src/detail/DetailPanel.jsx +++ b/frontend/src/detail/DetailPanel.jsx @@ -598,39 +598,37 @@ const DetailPanel = ({ {commonTags.length ? commonTags.join(', ') : 'None'} - {commonTags.length > 0 && ( -
- Bulk tag operations -
{ - event.preventDefault(); - const input = event.currentTarget.elements.tag; - const value = input.value.trim(); - if (!value) return; - onBulkTagAdd?.({ label: value, input }); - }} - > - - -
-
{ - event.preventDefault(); - const input = event.currentTarget.elements.tag; - const value = input.value.trim(); - if (!value) return; - onBulkTagRemove?.({ label: value, input }); - }} - > - - -
-
- )} +
+ Bulk tag operations +
{ + event.preventDefault(); + const input = event.currentTarget.elements.tag; + const value = input.value.trim(); + if (!value) return; + onBulkTagAdd?.({ label: value, input }); + }} + > + + +
+
{ + event.preventDefault(); + const input = event.currentTarget.elements.tag; + const value = input.value.trim(); + if (!value) return; + onBulkTagRemove?.({ label: value, input }); + }} + > + + +
+
{tags.map((tag) => (