feat: board creation and actions dropdown

This commit is contained in:
2025-08-07 09:19:30 +04:00
parent 4b843d8e5d
commit 335fbfe81c
12 changed files with 341 additions and 61 deletions

View File

@ -1,5 +1,7 @@
import React, { FC } from "react"; import React, { FC, useState } from "react";
import { Box } from "@mantine/core"; 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"; import { BoardSchema } from "@/lib/client";
type Props = { type Props = {
@ -7,7 +9,54 @@ type Props = {
}; };
const Board: FC<Props> = ({ board }) => { const Board: FC<Props> = ({ board }) => {
return <Box miw={100} style={{ borderWidth: 1, margin: 0 }}>{board.name}</Box>; const [isHovered, setIsHovered] = useState(false);
const { selectedBoard } = useBoardsContext();
return (
<Group
miw={130}
px={"md"}
py={"xs"}
justify={"space-between"}
wrap={"nowrap"}
style={{ borderWidth: 1 }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}>
<Text>{board.name}</Text>
<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>
<Group wrap={"nowrap"}>
<IconEdit />
<Text>Переименовать</Text>
</Group>
</Menu.Item>
<Menu.Item>
<Group wrap={"nowrap"}>
<IconTrash />
<Text>Удалить</Text>
</Group>
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
);
}; };
export default Board; export default Board;

View File

@ -2,12 +2,13 @@
import React from "react"; import React from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { 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 { useBoardsContext } from "@/app/deals/contexts/BoardsContext"; import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
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 { updateBoardMutation } from "@/lib/client/@tanstack/react-query.gen";
import SortableDnd from "@/components/dnd/SortableDnd";
import { notifications } from "@/lib/notifications"; import { notifications } from "@/lib/notifications";
const Boards = () => { const Boards = () => {
@ -51,6 +52,7 @@ const Boards = () => {
scrollbars={"x"} scrollbars={"x"}
scrollbarSize={0} scrollbarSize={0}
w={"100%"}> w={"100%"}>
<Group wrap={"nowrap"}>
<SortableDnd <SortableDnd
initialItems={boards} initialItems={boards}
renderItem={renderBoard} renderItem={renderBoard}
@ -58,6 +60,8 @@ const Boards = () => {
onItemClick={selectBoard} onItemClick={selectBoard}
rowStyle={{ flexWrap: "nowrap" }} rowStyle={{ flexWrap: "nowrap" }}
/> />
<CreateBoardButton />
</Group>
</ScrollArea> </ScrollArea>
); );
}; };

View File

@ -0,0 +1,54 @@
import { useState } from "react";
import { IconCheck, IconPlus, IconX } from "@tabler/icons-react";
import { Box, Group, TextInput } from "@mantine/core";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
const CreateBoardButton = () => {
const { onCreateBoardClick } = useBoardsContext();
const [isWriting, setIsWriting] = useState<boolean>(false);
const [name, setName] = useState<string>("");
const onStartCreating = () => {
setName("");
setIsWriting(true);
};
const onCancelCreating = () => setIsWriting(false);
const onCompleteCreating = () => {
if (name) {
onCreateBoardClick(name);
}
setIsWriting(false);
};
return (
<Box style={{ cursor: "pointer" }}>
{isWriting ? (
<Group>
<TextInput
placeholder={"Название доски"}
variant={"unstyled"}
value={name}
onChange={e => setName(e.target.value)}
onKeyDown={e =>
e.key === "Enter" && onCompleteCreating()
}
/>
<Box onClick={onCompleteCreating}>
<IconCheck />
</Box>
<Box onClick={onCancelCreating}>
<IconX />
</Box>
</Group>
) : (
<Box onClick={onStartCreating}>
<IconPlus />
</Box>
)}
</Box>
);
};
export default CreateBoardButton;

View File

@ -6,7 +6,6 @@ type Props = {
}; };
const DealCard = ({ deal }: Props) => { const DealCard = ({ deal }: Props) => {
console.log("deal");
return <Card>{deal.name}</Card>; return <Card>{deal.name}</Card>;
}; };

View File

@ -1,37 +0,0 @@
import React from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
type Props = {
children: React.ReactNode;
id: string;
};
const SortableItem = ({ children, id }: Props) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}>
{children}
</div>
);
};
export default SortableItem;

View File

@ -8,11 +8,16 @@ type Props = {
children: ReactNode; children: ReactNode;
}; };
const StatusColumnWrapper = ({ status, children, isDragging = false }: Props) => { const StatusColumnWrapper = ({
status,
children,
isDragging = false,
}: Props) => {
return ( return (
<Box <Box
style={{ style={{
backgroundColor: "#eee", borderWidth: 1,
borderColor: "gray",
padding: 2, padding: 2,
width: "15vw", width: "15vw",
minWidth: 150, minWidth: 150,

View File

@ -7,9 +7,14 @@ 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 { BoardSchema } from "@/lib/client";
import useBoardsList from "@/hooks/useBoardsList"; import useBoardsList from "@/hooks/useBoardsList";
import { BoardSchema } from "@/lib/client";
import { createBoardMutation } 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[];
@ -17,13 +22,18 @@ 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;
}; };
const BoardsContext = createContext<BoardsContextState | undefined>(undefined); const BoardsContext = createContext<BoardsContextState | undefined>(undefined);
const useBoardsContextState = () => { const useBoardsContextState = () => {
const { selectedProject: project } = useProjectsContext(); const { selectedProject: project } = useProjectsContext();
const { boards, setBoards, refetch: refetchBoards } = useBoardsList({ projectId: project?.id }); const {
boards,
setBoards,
refetch: refetchBoards,
} = useBoardsList({ projectId: project?.id });
const [selectedBoard, setSelectedBoard] = useState<BoardSchema | null>( const [selectedBoard, setSelectedBoard] = useState<BoardSchema | null>(
null null
); );
@ -44,12 +54,45 @@ const useBoardsContextState = () => {
} }
}, [boards]); }, [boards]);
const createBoard = useMutation({
...createBoardMutation(),
onError: error => {
console.error(error);
notifications.error({
message: error.response?.data?.detail as string | undefined,
});
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(),
},
},
});
};
return { return {
boards, boards,
setBoards, setBoards,
selectedBoard, selectedBoard,
setSelectedBoard, setSelectedBoard,
refetchBoards, refetchBoards,
onCreateBoardClick,
}; };
}; };

View File

@ -1,8 +1,8 @@
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import { type Options, getBoards, updateBoard, getDeals, updateDeal, getProjects, getStatuses, updateStatus } from '../sdk.gen'; import { type Options, getBoards, createBoard, updateBoard, getDeals, updateDeal, getProjects, getStatuses, updateStatus } from '../sdk.gen';
import { queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import type { GetBoardsData, UpdateBoardData, UpdateBoardError, UpdateBoardResponse2, GetDealsData, UpdateDealData, UpdateDealError, UpdateDealResponse2, GetProjectsData, GetStatusesData, UpdateStatusData, UpdateStatusError, UpdateStatusResponse2 } from '../types.gen'; import type { GetBoardsData, CreateBoardData, CreateBoardError, CreateBoardResponse2, UpdateBoardData, UpdateBoardError, UpdateBoardResponse2, GetDealsData, UpdateDealData, UpdateDealError, UpdateDealResponse2, GetProjectsData, GetStatusesData, UpdateStatusData, UpdateStatusError, UpdateStatusResponse2 } from '../types.gen';
import type { AxiosError } from 'axios'; import type { AxiosError } from 'axios';
import { client as _heyApiClient } from '../client.gen'; import { client as _heyApiClient } from '../client.gen';
@ -57,6 +57,43 @@ export const getBoardsOptions = (options: Options<GetBoardsData>) => {
}); });
}; };
export const createBoardQueryKey = (options: Options<CreateBoardData>) => createQueryKey('createBoard', options);
/**
* Create Board
*/
export const createBoardOptions = (options: Options<CreateBoardData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await createBoard({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: createBoardQueryKey(options)
});
};
/**
* Create Board
*/
export const createBoardMutation = (options?: Partial<Options<CreateBoardData>>): UseMutationOptions<CreateBoardResponse2, AxiosError<CreateBoardError>, Options<CreateBoardData>> => {
const mutationOptions: UseMutationOptions<CreateBoardResponse2, AxiosError<CreateBoardError>, Options<CreateBoardData>> = {
mutationFn: async (localOptions) => {
const { data } = await createBoard({
...options,
...localOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/** /**
* Update Board * Update Board
*/ */

View File

@ -1,8 +1,8 @@
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import type { Options as ClientOptions, TDataShape, Client } from './client'; import type { Options as ClientOptions, TDataShape, Client } from './client';
import type { GetBoardsData, GetBoardsResponses, GetBoardsErrors, UpdateBoardData, UpdateBoardResponses, UpdateBoardErrors, GetDealsData, GetDealsResponses, GetDealsErrors, UpdateDealData, UpdateDealResponses, UpdateDealErrors, GetProjectsData, GetProjectsResponses, GetStatusesData, GetStatusesResponses, GetStatusesErrors, UpdateStatusData, UpdateStatusResponses, UpdateStatusErrors } from './types.gen'; import type { GetBoardsData, GetBoardsResponses, GetBoardsErrors, CreateBoardData, CreateBoardResponses, CreateBoardErrors, UpdateBoardData, UpdateBoardResponses, UpdateBoardErrors, GetDealsData, GetDealsResponses, GetDealsErrors, UpdateDealData, UpdateDealResponses, UpdateDealErrors, GetProjectsData, GetProjectsResponses, GetStatusesData, GetStatusesResponses, GetStatusesErrors, UpdateStatusData, UpdateStatusResponses, UpdateStatusErrors } from './types.gen';
import { zGetBoardsData, zGetBoardsResponse2, zUpdateBoardData, zUpdateBoardResponse2, zGetDealsData, zGetDealsResponse2, zUpdateDealData, zUpdateDealResponse2, zGetProjectsData, zGetProjectsResponse2, zGetStatusesData, zGetStatusesResponse2, zUpdateStatusData, zUpdateStatusResponse2 } from './zod.gen'; import { zGetBoardsData, zGetBoardsResponse2, zCreateBoardData, zCreateBoardResponse2, zUpdateBoardData, zUpdateBoardResponse2, zGetDealsData, zGetDealsResponse2, zUpdateDealData, zUpdateDealResponse2, zGetProjectsData, zGetProjectsResponse2, zGetStatusesData, zGetStatusesResponse2, zUpdateStatusData, zUpdateStatusResponse2 } from './zod.gen';
import { client as _heyApiClient } from './client.gen'; import { client as _heyApiClient } from './client.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & { export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
@ -36,6 +36,27 @@ export const getBoards = <ThrowOnError extends boolean = false>(options: Options
}); });
}; };
/**
* Create Board
*/
export const createBoard = <ThrowOnError extends boolean = false>(options: Options<CreateBoardData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<CreateBoardResponses, CreateBoardErrors, ThrowOnError>({
requestValidator: async (data) => {
return await zCreateBoardData.parseAsync(data);
},
responseType: 'json',
responseValidator: async (data) => {
return await zCreateBoardResponse2.parseAsync(data);
},
url: '/board/',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
};
/** /**
* Update Board * Update Board
*/ */

View File

@ -4,14 +4,50 @@
* BoardSchema * BoardSchema
*/ */
export type BoardSchema = { export type BoardSchema = {
/**
* Id
*/
id: number;
/** /**
* Name * Name
*/ */
name: string; name: string;
/** /**
* Id * Lexorank
*/ */
id: number; lexorank: string;
};
/**
* CreateBoardRequest
*/
export type CreateBoardRequest = {
board: CreateBoardSchema;
};
/**
* CreateBoardResponse
*/
export type CreateBoardResponse = {
/**
* Message
*/
message: string;
board: BoardSchema;
};
/**
* CreateBoardSchema
*/
export type CreateBoardSchema = {
/**
* Name
*/
name: string;
/**
* Projectid
*/
projectId: number;
/** /**
* Lexorank * Lexorank
*/ */
@ -267,6 +303,31 @@ export type GetBoardsResponses = {
export type GetBoardsResponse2 = GetBoardsResponses[keyof GetBoardsResponses]; export type GetBoardsResponse2 = GetBoardsResponses[keyof GetBoardsResponses];
export type CreateBoardData = {
body: CreateBoardRequest;
path?: never;
query?: never;
url: '/board/';
};
export type CreateBoardErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type CreateBoardError = CreateBoardErrors[keyof CreateBoardErrors];
export type CreateBoardResponses = {
/**
* Successful Response
*/
200: CreateBoardResponse;
};
export type CreateBoardResponse2 = CreateBoardResponses[keyof CreateBoardResponses];
export type UpdateBoardData = { export type UpdateBoardData = {
body: UpdateBoardRequest; body: UpdateBoardRequest;
path: { path: {

View File

@ -6,11 +6,35 @@ import { z } from 'zod';
* BoardSchema * BoardSchema
*/ */
export const zBoardSchema = z.object({ export const zBoardSchema = z.object({
name: z.string(),
id: z.int(), id: z.int(),
name: z.string(),
lexorank: z.string() lexorank: z.string()
}); });
/**
* CreateBoardSchema
*/
export const zCreateBoardSchema = z.object({
name: z.string(),
projectId: z.int(),
lexorank: z.string()
});
/**
* CreateBoardRequest
*/
export const zCreateBoardRequest = z.object({
board: zCreateBoardSchema
});
/**
* CreateBoardResponse
*/
export const zCreateBoardResponse = z.object({
message: z.string(),
board: zBoardSchema
});
/** /**
* DealSchema * DealSchema
*/ */
@ -186,6 +210,17 @@ export const zGetBoardsData = z.object({
*/ */
export const zGetBoardsResponse2 = zGetBoardsResponse; export const zGetBoardsResponse2 = zGetBoardsResponse;
export const zCreateBoardData = z.object({
body: zCreateBoardRequest,
path: z.optional(z.never()),
query: z.optional(z.never())
});
/**
* Successful Response
*/
export const zCreateBoardResponse2 = zCreateBoardResponse;
export const zUpdateBoardData = z.object({ export const zUpdateBoardData = z.object({
body: zUpdateBoardRequest, body: zUpdateBoardRequest,
path: z.object({ path: z.object({

View File

@ -21,6 +21,15 @@ export function sortByLexorank<T extends LexorankSortable>(items: T[]): T[] {
return items.sort(compareByLexorank); return items.sort(compareByLexorank);
} }
export function getMaxByLexorank<T extends LexorankSortable>(
items: T[]
): T | null {
return items.reduce(
(max, item) => (!max || item.lexorank > max.lexorank ? item : max),
null as T | null
);
}
export function getNewLexorank( export function getNewLexorank(
left?: LexoRank | null, left?: LexoRank | null,
right?: LexoRank | null right?: LexoRank | null