feat: board name editing

This commit is contained in:
2025-08-07 12:31:00 +04:00
parent 41f8d19d49
commit 7e2dd9763b
6 changed files with 251 additions and 128 deletions

View File

@ -1,6 +1,6 @@
import React, { FC, useState } from "react"; import React, { FC, useEffect, useRef, useState } from "react";
import { IconDotsVertical, IconEdit, IconTrash } from "@tabler/icons-react"; import { Group, Text, TextInput } from "@mantine/core";
import { Box, Group, Menu, Text } from "@mantine/core"; import BoardMenu from "@/app/deals/components/Board/BoardMenu";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext"; import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import { BoardSchema } from "@/lib/client"; import { BoardSchema } from "@/lib/client";
@ -10,7 +10,48 @@ type Props = {
const Board: FC<Props> = ({ board }) => { const Board: FC<Props> = ({ board }) => {
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const { selectedBoard, onDeleteBoardClick } = useBoardsContext(); const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(board.name);
const { onUpdateBoard } = useBoardsContext();
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
}
}, [isEditing]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
isEditing &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
handleSave();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () =>
document.removeEventListener("mousedown", handleClickOutside);
}, [isEditing, editValue]);
const handleSave = () => {
const newValue = editValue.trim();
if (newValue && newValue !== board.name) {
onUpdateBoard(board.id, { name: newValue });
}
setIsEditing(false);
};
const handleEdit = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();
setEditValue(board.name);
setIsEditing(true);
};
return ( return (
<Group <Group
@ -22,39 +63,34 @@ const Board: FC<Props> = ({ board }) => {
style={{ borderWidth: 1 }} style={{ borderWidth: 1 }}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}> onMouseLeave={() => setIsHovered(false)}>
<Text>{board.name}</Text> {isEditing ? (
<Menu> <TextInput
<Menu.Target> ref={inputRef}
<Box value={editValue}
style={{ onChange={e => setEditValue(e.target.value)}
opacity: onKeyDown={e => {
isHovered || selectedBoard?.id === board.id if (e.key === "Enter") handleSave();
? 1 if (e.key === "Escape") {
: 0, setEditValue(board.name);
cursor: "pointer", setIsEditing(false);
}
}} }}
onClick={e => { variant="unstyled"
e.preventDefault(); styles={{
e.stopPropagation(); input: {
}}> height: 25,
<IconDotsVertical size={16} /> minHeight: 25,
</Box> },
</Menu.Target> }}
<Menu.Dropdown> />
<Menu.Item> ) : (
<Group wrap={"nowrap"}> <Text>{board.name}</Text>
<IconEdit /> )}
<Text>Переименовать</Text> <BoardMenu
</Group> isHovered={isHovered}
</Menu.Item> board={board}
<Menu.Item onClick={() => onDeleteBoardClick(board.id)}> handleEdit={handleEdit}
<Group wrap={"nowrap"}> />
<IconTrash />
<Text>Удалить</Text>
</Group>
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group> </Group>
); );
}; };

View File

@ -0,0 +1,50 @@
import React, { FC } from "react";
import { IconDotsVertical, IconEdit, IconTrash } from "@tabler/icons-react";
import { Box, Group, Menu, Text } from "@mantine/core";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import { BoardSchema } from "@/lib/client";
type Props = {
isHovered: boolean;
board: BoardSchema;
handleEdit: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
};
const BoardMenu: FC<Props> = ({ isHovered, board, handleEdit }) => {
const { selectedBoard, onDeleteBoard } = useBoardsContext();
return (
<Menu>
<Menu.Target>
<Box
style={{
opacity:
isHovered || selectedBoard?.id === board.id ? 1 : 0,
cursor: "pointer",
}}
onClick={e => {
e.preventDefault();
e.stopPropagation();
}}>
<IconDotsVertical size={16} />
</Box>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={handleEdit}>
<Group wrap={"nowrap"}>
<IconEdit />
<Text>Переименовать</Text>
</Group>
</Menu.Item>
<Menu.Item onClick={() => onDeleteBoard(board.id)}>
<Group wrap={"nowrap"}>
<IconTrash />
<Text>Удалить</Text>
</Group>
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
export default BoardMenu;

View File

@ -1,45 +1,22 @@
"use client"; "use client";
import React from "react"; import React from "react";
import { useMutation } from "@tanstack/react-query";
import { Group, ScrollArea } from "@mantine/core"; import { Group, ScrollArea } from "@mantine/core";
import Board from "@/app/deals/components/Board/Board"; import Board from "@/app/deals/components/Board/Board";
import CreateBoardButton from "@/app/deals/components/CreateBoardButton/CreateBoardButton"; import CreateBoardButton from "@/app/deals/components/CreateBoardButton/CreateBoardButton";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext"; import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import SortableDnd from "@/components/dnd/SortableDnd"; import SortableDnd from "@/components/dnd/SortableDnd";
import { BoardSchema } from "@/lib/client"; import { BoardSchema } from "@/lib/client";
import { updateBoardMutation } from "@/lib/client/@tanstack/react-query.gen";
import { notifications } from "@/lib/notifications";
const Boards = () => { const Boards = () => {
const { boards, setSelectedBoard, refetchBoards } = useBoardsContext(); const { boards, setSelectedBoard, onUpdateBoard } = useBoardsContext();
const updateBoard = useMutation({
...updateBoardMutation(),
onError: error => {
console.error(error);
notifications.error({
message: error.response?.data?.detail as string | undefined,
});
refetchBoards();
},
});
const renderBoard = (board: BoardSchema) => { const renderBoard = (board: BoardSchema) => {
return <Board board={board} />; return <Board board={board} />;
}; };
const onDragEnd = (itemId: number, newLexorank: string) => { const onDragEnd = (itemId: number, newLexorank: string) => {
updateBoard.mutate({ onUpdateBoard(itemId, { lexorank: newLexorank });
path: {
boardId: itemId,
},
body: {
board: {
lexorank: newLexorank,
},
},
});
}; };
const selectBoard = (board: BoardSchema) => { const selectBoard = (board: BoardSchema) => {

View File

@ -4,7 +4,7 @@ import { Box, Group, TextInput } from "@mantine/core";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext"; import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
const CreateBoardButton = () => { const CreateBoardButton = () => {
const { onCreateBoardClick } = useBoardsContext(); const { onCreateBoard } = useBoardsContext();
const [isWriting, setIsWriting] = useState<boolean>(false); const [isWriting, setIsWriting] = useState<boolean>(false);
const [name, setName] = useState<string>(""); const [name, setName] = useState<string>("");
@ -17,7 +17,7 @@ const CreateBoardButton = () => {
const onCompleteCreating = () => { const onCompleteCreating = () => {
if (name) { if (name) {
onCreateBoardClick(name); onCreateBoard(name);
} }
setIsWriting(false); setIsWriting(false);
}; };

View File

@ -7,17 +7,10 @@ import React, {
useEffect, useEffect,
useState, useState,
} from "react"; } from "react";
import { useMutation } from "@tanstack/react-query";
import { LexoRank } from "lexorank";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext"; import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import useBoardsList from "@/hooks/useBoardsList"; import useBoardsList from "@/hooks/useBoardsList";
import { BoardSchema } from "@/lib/client"; import { useBoardsOperations } from "@/hooks/useBoardsOperations";
import { import { BoardSchema, UpdateBoardSchema } from "@/lib/client";
createBoardMutation,
deleteBoardMutation,
} from "@/lib/client/@tanstack/react-query.gen";
import { notifications } from "@/lib/notifications";
import { getMaxByLexorank, getNewLexorank } from "@/utils/lexorank";
type BoardsContextState = { type BoardsContextState = {
boards: BoardSchema[]; boards: BoardSchema[];
@ -25,8 +18,9 @@ type BoardsContextState = {
selectedBoard: BoardSchema | null; selectedBoard: BoardSchema | null;
setSelectedBoard: React.Dispatch<React.SetStateAction<BoardSchema | null>>; setSelectedBoard: React.Dispatch<React.SetStateAction<BoardSchema | null>>;
refetchBoards: () => void; refetchBoards: () => void;
onCreateBoardClick: (name: string) => void; onCreateBoard: (name: string) => void;
onDeleteBoardClick: (boardId: number) => void; onUpdateBoard: (boardId: number, board: UpdateBoardSchema) => void;
onDeleteBoard: (boardId: number) => void;
}; };
const BoardsContext = createContext<BoardsContextState | undefined>(undefined); const BoardsContext = createContext<BoardsContextState | undefined>(undefined);
@ -58,66 +52,24 @@ const useBoardsContextState = () => {
} }
}, [boards]); }, [boards]);
const createBoard = useMutation({ const { onCreateBoard, onUpdateBoard, onDeleteBoard } = useBoardsOperations(
...createBoardMutation(), {
onError: error => { boards,
console.error(error); setBoards,
notifications.error({ refetchBoards,
message: error.response?.data?.detail as string | undefined, projectId: project?.id,
}); }
refetchBoards();
},
onSuccess: res => {
setBoards([...boards, res.board]);
},
});
const onCreateBoardClick = (name: string) => {
if (!project) return;
const lastBoard = getMaxByLexorank(boards);
const newLexorank = getNewLexorank(
lastBoard ? LexoRank.parse(lastBoard.lexorank) : null
); );
createBoard.mutate({
body: {
board: {
name,
projectId: project.id,
lexorank: newLexorank.toString(),
},
},
});
};
const deleteBoard = useMutation({
...deleteBoardMutation(),
onError: error => {
console.error(error);
notifications.error({
message: error.response?.data?.detail as string | undefined,
});
refetchBoards();
},
});
const onDeleteBoardClick = (boardId: number) => {
deleteBoard.mutate({
path: {
boardId,
},
});
setBoards(boards => boards.filter(board => board.id !== boardId));
};
return { return {
boards, boards,
setBoards, setBoards,
selectedBoard, selectedBoard,
setSelectedBoard, setSelectedBoard,
refetchBoards, refetchBoards,
onCreateBoardClick, onCreateBoard,
onDeleteBoardClick, onUpdateBoard,
onDeleteBoard,
}; };
}; };

View File

@ -0,0 +1,108 @@
import React from "react";
import { useMutation } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { LexoRank } from "lexorank";
import {
BoardSchema,
HttpValidationError,
UpdateBoardSchema,
} from "@/lib/client";
import {
createBoardMutation,
deleteBoardMutation,
updateBoardMutation,
} from "@/lib/client/@tanstack/react-query.gen";
import { notifications } from "@/lib/notifications";
import { getMaxByLexorank, getNewLexorank } from "@/utils/lexorank";
type UseBoardsOperationsProps = {
boards: BoardSchema[];
setBoards: React.Dispatch<React.SetStateAction<BoardSchema[]>>;
refetchBoards: () => void;
projectId?: number;
};
export const useBoardsOperations = ({
boards,
setBoards,
refetchBoards,
projectId,
}: UseBoardsOperationsProps) => {
const onError = (error: AxiosError<HttpValidationError>) => {
console.error(error);
notifications.error({
message: error.response?.data?.detail as string | undefined,
});
refetchBoards();
};
const createBoard = useMutation({
...createBoardMutation(),
onError,
onSuccess: res => {
setBoards([...boards, res.board]);
},
});
const updateBoard = useMutation({
...updateBoardMutation(),
onError,
});
const deleteBoard = useMutation({
...deleteBoardMutation(),
onError,
});
const onCreateBoard = (name: string) => {
if (!projectId) return;
const lastBoard = getMaxByLexorank(boards);
const newLexorank = getNewLexorank(
lastBoard ? LexoRank.parse(lastBoard.lexorank) : null
);
createBoard.mutate({
body: {
board: {
name,
projectId,
lexorank: newLexorank.toString(),
},
},
});
};
const onUpdateBoard = (boardId: number, board: UpdateBoardSchema) => {
updateBoard.mutate({
path: { boardId },
body: { board },
});
setBoards(boards =>
boards.map(oldBoard =>
oldBoard.id !== boardId
? oldBoard
: {
id: oldBoard.id,
name: board.name ? board.name : oldBoard.name,
lexorank: board.lexorank
? board.lexorank
: oldBoard.lexorank,
}
)
);
};
const onDeleteBoard = (boardId: number) => {
deleteBoard.mutate({
path: { boardId },
});
setBoards(boards => boards.filter(board => board.id !== boardId));
};
return {
onCreateBoard,
onUpdateBoard,
onDeleteBoard,
};
};