feat: implement tag rename and delete functionality in UI and API

This commit is contained in:
2025-12-23 11:26:12 +01:00
parent 307601aa4b
commit bef655a92e
6 changed files with 247 additions and 30 deletions

View File

@@ -1,4 +1,4 @@
import { Home, Archive, Settings, Tag, ChevronRight } from "lucide-react" import { Home, Archive, Settings, Tag, ChevronRight, Pencil, Trash2, MoreHorizontal } from "lucide-react"
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -9,13 +9,22 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
import { Link, useLocation, useSearchParams } from "react-router-dom" import { Link, useLocation, useSearchParams, useNavigate } from "react-router-dom"
import { SettingsDialog } from "@/components/settings-dialog" import { SettingsDialog } from "@/components/settings-dialog"
import { useState } from "react" import { useState } from "react"
import { useTags } from "@/hooks/use-notes" import { useTags, useDeleteTag, useRenameTag } from "@/hooks/use-notes"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { toast } from "sonner"
const items = [ const items = [
{ {
@@ -30,6 +39,111 @@ const items = [
}, },
] ]
interface TagItemProps {
tag: { id: string; name: string };
isActive: boolean;
}
function TagItem({ tag, isActive }: TagItemProps) {
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(tag.name);
const { mutate: deleteTag } = useDeleteTag();
const { mutate: renameTag } = useRenameTag();
const navigate = useNavigate();
const handleDelete = () => {
if (confirm(`Delete tag "${tag.name}"? Notes will keep their content.`)) {
deleteTag(tag.id, {
onSuccess: () => {
toast.success("Tag deleted");
navigate("/");
},
onError: (err: any) => toast.error(err.message)
});
}
};
const handleRename = () => {
if (editName.trim() && editName.trim() !== tag.name) {
renameTag({ id: tag.id, name: editName.trim() }, {
onSuccess: () => {
toast.success("Tag renamed");
setIsEditing(false);
},
onError: (err: any) => {
toast.error(err.message);
setEditName(tag.name);
}
});
} else {
setIsEditing(false);
setEditName(tag.name);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleRename();
} else if (e.key === "Escape") {
setIsEditing(false);
setEditName(tag.name);
}
};
if (isEditing) {
return (
<SidebarMenuItem>
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={handleRename}
onKeyDown={handleKeyDown}
autoFocus
className="h-7 text-xs"
/>
</SidebarMenuItem>
);
}
return (
<SidebarMenuItem className="group/tag">
<SidebarMenuButton
asChild
isActive={isActive}
tooltip={tag.name}
className="pr-0"
>
<Link to={`/?tag=${encodeURIComponent(tag.name)}`} className="flex items-center justify-between w-full">
<Badge variant="secondary" className="text-xs px-1.5 py-0">
{tag.name}
</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover/tag:opacity-100 transition-opacity"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem onClick={(e) => { e.preventDefault(); setIsEditing(true); }}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Rename
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => { e.preventDefault(); handleDelete(); }} className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
export function AppSidebar() { export function AppSidebar() {
const location = useLocation(); const location = useLocation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@@ -86,19 +200,11 @@ export function AppSidebar() {
<SidebarMenu> <SidebarMenu>
{tags && tags.length > 0 ? ( {tags && tags.length > 0 ? (
tags.map((tag: { id: string; name: string }) => ( tags.map((tag: { id: string; name: string }) => (
<SidebarMenuItem key={tag.id}> <TagItem
<SidebarMenuButton key={tag.id}
asChild tag={tag}
isActive={activeTag === tag.name} isActive={activeTag === tag.name}
tooltip={tag.name} />
>
<Link to={`/?tag=${encodeURIComponent(tag.name)}`}>
<Badge variant="secondary" className="text-xs px-1.5 py-0">
{tag.name}
</Badge>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)) ))
) : ( ) : (
<div className="px-2 py-1.5 text-xs text-muted-foreground"> <div className="px-2 py-1.5 text-xs text-muted-foreground">
@@ -117,3 +223,4 @@ export function AppSidebar() {
</> </>
) )
} }

View File

@@ -177,3 +177,29 @@ export function useTags() {
queryFn: () => api.get("/tags"), queryFn: () => api.get("/tags"),
}); });
} }
export function useDeleteTag() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.delete(`/tags/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["notes"] });
},
});
}
export function useRenameTag() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
api.patch(`/tags/${id}`, { name }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["notes"] });
},
});
}

View File

@@ -110,6 +110,13 @@ pub struct CreateTagRequest {
pub name: String, pub name: String,
} }
/// Request to rename a tag
#[derive(Debug, Deserialize, Validate)]
pub struct RenameTagRequest {
#[validate(length(min = 1, max = 50, message = "Tag name must be 1-50 characters"))]
pub name: String,
}
/// Login request /// Login request
#[derive(Debug, Deserialize, Validate)] #[derive(Debug, Deserialize, Validate)]
pub struct LoginRequest { pub struct LoginRequest {

View File

@@ -36,5 +36,8 @@ pub fn api_v1_router() -> Router<AppState> {
.route("/import", post(import_export::import_data)) .route("/import", post(import_export::import_data))
// Tag routes // Tag routes
.route("/tags", get(tags::list_tags).post(tags::create_tag)) .route("/tags", get(tags::list_tags).post(tags::create_tag))
.route("/tags/{id}", delete(tags::delete_tag)) .route(
"/tags/{id}",
delete(tags::delete_tag).patch(tags::rename_tag),
)
} }

View File

@@ -1,9 +1,9 @@
//! Tag route handlers //! Tag route handlers
use axum::{ use axum::{
Json,
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
Json,
}; };
use axum_login::{AuthSession, AuthUser}; use axum_login::{AuthSession, AuthUser};
use uuid::Uuid; use uuid::Uuid;
@@ -12,7 +12,7 @@ use validator::Validate;
use notes_domain::TagService; use notes_domain::TagService;
use crate::auth::AuthBackend; use crate::auth::AuthBackend;
use crate::dto::{CreateTagRequest, TagResponse}; use crate::dto::{CreateTagRequest, RenameTagRequest, TagResponse};
use crate::error::{ApiError, ApiResult}; use crate::error::{ApiError, ApiResult};
use crate::state::AppState; use crate::state::AppState;
@@ -22,14 +22,18 @@ pub async fn list_tags(
State(state): State<AppState>, State(state): State<AppState>,
auth: AuthSession<AuthBackend>, auth: AuthSession<AuthBackend>,
) -> ApiResult<Json<Vec<TagResponse>>> { ) -> ApiResult<Json<Vec<TagResponse>>> {
let user = auth.user.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized("Login required".to_string())))?; let user = auth
.user
.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized(
"Login required".to_string(),
)))?;
let user_id = user.id(); let user_id = user.id();
let service = TagService::new(state.tag_repo); let service = TagService::new(state.tag_repo);
let tags = service.list_tags(user_id).await?; let tags = service.list_tags(user_id).await?;
let response: Vec<TagResponse> = tags.into_iter().map(TagResponse::from).collect(); let response: Vec<TagResponse> = tags.into_iter().map(TagResponse::from).collect();
Ok(Json(response)) Ok(Json(response))
} }
@@ -40,18 +44,50 @@ pub async fn create_tag(
auth: AuthSession<AuthBackend>, auth: AuthSession<AuthBackend>,
Json(payload): Json<CreateTagRequest>, Json(payload): Json<CreateTagRequest>,
) -> ApiResult<(StatusCode, Json<TagResponse>)> { ) -> ApiResult<(StatusCode, Json<TagResponse>)> {
let user = auth.user.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized("Login required".to_string())))?; let user = auth
.user
.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized(
"Login required".to_string(),
)))?;
let user_id = user.id(); let user_id = user.id();
payload.validate().map_err(|e| ApiError::validation(e.to_string()))?; payload
.validate()
.map_err(|e| ApiError::validation(e.to_string()))?;
let service = TagService::new(state.tag_repo); let service = TagService::new(state.tag_repo);
let tag = service.create_tag(user_id, &payload.name).await?; let tag = service.create_tag(user_id, &payload.name).await?;
Ok((StatusCode::CREATED, Json(TagResponse::from(tag)))) Ok((StatusCode::CREATED, Json(TagResponse::from(tag))))
} }
/// Rename a tag
/// PATCH /api/v1/tags/:id
pub async fn rename_tag(
State(state): State<AppState>,
auth: AuthSession<AuthBackend>,
Path(id): Path<Uuid>,
Json(payload): Json<RenameTagRequest>,
) -> ApiResult<Json<TagResponse>> {
let user = auth
.user
.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized(
"Login required".to_string(),
)))?;
let user_id = user.id();
payload
.validate()
.map_err(|e| ApiError::validation(e.to_string()))?;
let service = TagService::new(state.tag_repo);
let tag = service.rename_tag(id, user_id, &payload.name).await?;
Ok(Json(TagResponse::from(tag)))
}
/// Delete a tag /// Delete a tag
/// DELETE /api/v1/tags/:id /// DELETE /api/v1/tags/:id
pub async fn delete_tag( pub async fn delete_tag(
@@ -59,12 +95,16 @@ pub async fn delete_tag(
auth: AuthSession<AuthBackend>, auth: AuthSession<AuthBackend>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> ApiResult<StatusCode> { ) -> ApiResult<StatusCode> {
let user = auth.user.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized("Login required".to_string())))?; let user = auth
.user
.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized(
"Login required".to_string(),
)))?;
let user_id = user.id(); let user_id = user.id();
let service = TagService::new(state.tag_repo); let service = TagService::new(state.tag_repo);
service.delete_tag(id, user_id).await?; service.delete_tag(id, user_id).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }

View File

@@ -277,6 +277,40 @@ impl TagService {
self.tag_repo.delete(id).await self.tag_repo.delete(id).await
} }
/// Rename a tag
pub async fn rename_tag(&self, id: Uuid, user_id: Uuid, new_name: &str) -> DomainResult<Tag> {
let new_name = new_name.trim().to_lowercase();
if new_name.is_empty() {
return Err(DomainError::validation("Tag name cannot be empty"));
}
// Find the existing tag
let mut tag = self
.tag_repo
.find_by_id(id)
.await?
.ok_or(DomainError::TagNotFound(id))?;
// Authorization check
if tag.user_id != user_id {
return Err(DomainError::unauthorized(
"Cannot rename another user's tag",
));
}
// Check if new name already exists (and it's not the same tag)
if let Some(existing) = self.tag_repo.find_by_name(user_id, &new_name).await? {
if existing.id != id {
return Err(DomainError::TagAlreadyExists(new_name));
}
}
// Update the name
tag.name = new_name;
self.tag_repo.save(&tag).await?;
Ok(tag)
}
} }
/// Service for User operations (OIDC-ready) /// Service for User operations (OIDC-ready)