Compare commits

55 Commits

Author SHA1 Message Date
0bb546940a refactor: removed unused page block 2025-08-18 11:36:10 +04:00
83432b3f33 feat: boards on desktop as on mobile 2025-08-18 11:35:52 +04:00
49b1a235be feat: scrollable columns with deals 2025-08-18 09:45:54 +04:00
19a386319c feat: mock deal view 2025-08-17 20:28:50 +04:00
3ccebeb123 fix: centered columns in funnel dnd for mobile 2025-08-17 19:35:21 +04:00
e5e87f775d fix: fixed deal dragging from another column to the current 2025-08-17 18:33:05 +04:00
85ed974f5e fix: fixed status button for mobile 2025-08-17 10:48:51 +04:00
92efe3fb66 fix: set default collision detection algorithm for funnel 2025-08-17 10:47:07 +04:00
c405c802aa fix: fixed columns draggables and styles 2025-08-17 10:38:28 +04:00
4ff663536e fix: removed mantine carousel from dependencies 2025-08-16 20:00:48 +04:00
2e9ed02722 feat: swiper for boards on desktop 2025-08-16 19:57:22 +04:00
a4bcd62189 fix: fixed scroll color and width of columns slides on desktop 2025-08-16 19:03:53 +04:00
0a13070d9e feat: swiper 2025-08-16 14:59:37 +04:00
219689b947 feat: selected board style, boards spacing, text font size 2025-08-16 09:20:01 +04:00
3ece4677fb fix: hidden creating statuses when board is not selected 2025-08-15 11:11:29 +04:00
3d213cb0d9 Merge remote-tracking branch 'origin/main' 2025-08-15 11:03:22 +04:00
6d0c48be23 feat: margin for a carousel container 2025-08-15 11:03:06 +04:00
a169600908 refactor: remove size prop from theme configuration 2025-08-14 23:16:21 +03:00
43355b6ce3 refactor: css variables for colors and shadows 2025-08-14 18:18:24 +04:00
28004dc2a0 refactor: return types for hooks 2025-08-14 16:15:10 +04:00
c3b0da1e0d refactor: obvious mixin light 2025-08-14 15:27:49 +04:00
8fb4121ed1 fix: fixed in place input, refactored create board button for mobile 2025-08-14 12:51:39 +04:00
95e49eafc1 refactor: in place input division 2025-08-14 12:32:42 +04:00
255a39e2bb refactor: removed constant sizes 2025-08-14 12:15:09 +04:00
b6cec9a308 fix: mantine carousel 2025-08-14 11:16:33 +04:00
20ade53d52 fix: fixed dnd of boards 2025-08-13 22:12:14 +04:00
7932f3f5c8 fix: fixed scrolling by draggable on mobile 2025-08-13 18:18:37 +04:00
0836e4f0ca fix: removed back button on projects editor 2025-08-13 15:17:22 +04:00
90582b329e feat: projects create, update, delete 2025-08-13 15:03:09 +04:00
f2bba7e469 feat: styled create status button and header 2025-08-13 10:51:02 +04:00
838c9640a1 feat: division between mobile and desktop components, boards for mobile 2025-08-13 09:55:27 +04:00
1a98facd72 feat: scrolling of dnd during dragging, visible overlay for mobile 2025-08-12 19:15:11 +04:00
5144c83e93 feat: layouts and styles for desktop and mobile 2025-08-12 14:23:55 +04:00
6715e4bd38 fix: replaced isMobile with mantine hook 2025-08-10 19:48:29 +04:00
7815f99fa4 feat: raw slider for deals on mobile 2025-08-10 19:29:02 +04:00
54cf883a3c fix: sortable dnd twitching fix 2025-08-09 18:36:53 +04:00
45dc8901fd feat: color scheme toggle 2025-08-09 17:41:37 +04:00
067094c78a fix: removed autofocus on drawers 2025-08-09 17:19:38 +04:00
301821a682 feat: statuses dnd editor for mobile 2025-08-09 17:07:45 +04:00
9fb9e794db feat: boards dnd editor for mobile 2025-08-09 15:51:23 +04:00
e3137de46d feat: boards dnd editor for mobile 2025-08-09 10:13:25 +04:00
5ecdd3d887 feat: disable dnds for mobile 2025-08-08 18:06:42 +04:00
d3febcdfb0 feat: confirm modals on deleting 2025-08-08 15:32:56 +04:00
afad1b4605 feat: boards and statuses editing and creating for mobiles 2025-08-08 15:01:10 +04:00
f52fde0097 feat: status creating 2025-08-08 11:31:27 +04:00
e29664ecc5 feat: status editing and deleting 2025-08-07 15:46:11 +04:00
7e2dd9763b feat: board name editing 2025-08-07 12:31:00 +04:00
41f8d19d49 feat: board deletion 2025-08-07 10:13:08 +04:00
335fbfe81c feat: board creation and actions dropdown 2025-08-07 09:19:30 +04:00
4b843d8e5d refactor: moved dnd part from Funnel into FunnelDnd 2025-08-06 18:21:07 +04:00
96c53380e0 refactor: separation of shared components 2025-08-06 11:39:44 +04:00
9a780e99ae update .dockerignore to ensure source maps are ignored 2025-08-06 04:50:42 +03:00
1047a0b5fe feat: add .dockerignore and update Dockerfile for improved caching 2025-08-05 22:40:17 +03:00
573f50acc1 feat: add Dockerfile for multi-stage build and remove global stylesheet link 2025-08-05 21:54:02 +03:00
24edefa242 feat: add environment variable for API URL and update client configuration 2025-08-05 20:20:00 +03:00
113 changed files with 5456 additions and 2024 deletions

21
.dockerignore Normal file
View File

@ -0,0 +1,21 @@
.storybook
tests
__tests__
*.spec.ts
*.test.ts
node_modules
.yarn/cache
.eslint
.prettier
.stylelint
.env
.idea
.git
.gitignore
Dockerfile
README.md
*.log
test
docs
coverage
*.map

1
.env.example Normal file
View File

@ -0,0 +1 @@
NEXT_PUBLIC_API_URL=http://your.api/api

48
Dockerfile Normal file
View File

@ -0,0 +1,48 @@
FROM node:lts-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
RUN corepack enable
COPY .yarn ./.yarn
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .yarnrc.yml ./
RUN yarn && rm -rf .yarn/cache .yarn/unplugged .yarn/build-state.yml
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN yarn build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View File

@ -7,10 +7,11 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"generate-client": "openapi-ts && prettier --write ./src/client/**/*.ts && git add ./src/client" "generate-client": "openapi-ts && prettier --write ./src/lib/client/**/*.ts && git add ./src/lib/client"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@mantine/core": "8.1.2", "@mantine/core": "8.1.2",
"@mantine/form": "^8.1.3", "@mantine/form": "^8.1.3",
@ -35,6 +36,7 @@
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
"sharp": "^0.34.3", "sharp": "^0.34.3",
"swiper": "^11.2.10",
"zod": "^4.0.14" "zod": "^4.0.14"
}, },
"devDependencies": { "devDependencies": {
@ -56,7 +58,9 @@
"@types/node": "^22.13.11", "@types/node": "^22.13.11",
"@types/react": "19.1.8", "@types/react": "19.1.8",
"@types/react-redux": "^7.1.34", "@types/react-redux": "^7.1.34",
"@types/react-slick": "^0",
"@types/redux-persist": "^4.3.1", "@types/redux-persist": "^4.3.1",
"@types/slick-carousel": "^1",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"babel-loader": "^10.0.0", "babel-loader": "^10.0.0",
"eslint": "^9.29.0", "eslint": "^9.29.0",

View File

@ -1,13 +0,0 @@
import React, { FC } from "react";
import { Box } from "@mantine/core";
import { BoardSchema } from "@/lib/client";
type Props = {
board: BoardSchema;
};
const Board: FC<Props> = ({ board }) => {
return <Box miw={100} style={{ borderWidth: 1, margin: 0 }}>{board.name}</Box>;
};
export default Board;

View File

@ -1,65 +0,0 @@
"use client";
import React from "react";
import { useMutation } from "@tanstack/react-query";
import { ScrollArea } from "@mantine/core";
import Board from "@/app/deals/components/Board/Board";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import { BoardSchema } from "@/lib/client";
import { updateBoardMutation } from "@/lib/client/@tanstack/react-query.gen";
import SortableDnd from "@/components/SortableDnd";
import { notifications } from "@/lib/notifications";
const Boards = () => {
const { boards, setSelectedBoard, refetchBoards } = 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) => {
return <Board board={board} />;
};
const onDragEnd = (itemId: number, newLexorank: string) => {
updateBoard.mutate({
path: {
boardId: itemId,
},
body: {
board: {
lexorank: newLexorank,
},
},
});
};
const selectBoard = (board: BoardSchema) => {
setSelectedBoard(board);
};
return (
<ScrollArea
offsetScrollbars={"x"}
scrollbars={"x"}
scrollbarSize={0}
w={"100%"}>
<SortableDnd
initialItems={boards}
renderItem={renderBoard}
onDragEnd={onDragEnd}
onItemClick={selectBoard}
rowStyle={{ flexWrap: "nowrap" }}
/>
</ScrollArea>
);
};
export default Boards;

View File

@ -1,12 +0,0 @@
import { Card } from "@mantine/core";
import { DealSchema } from "@/lib/client";
type Props = {
deal: DealSchema;
};
const DealCard = ({ deal }: Props) => {
return <Card>{deal.name}</Card>;
};
export default DealCard;

View File

@ -1,36 +0,0 @@
import React from "react";
import { defaultDropAnimation, DragOverlay } from "@dnd-kit/core";
import DealCard from "@/app/deals/components/DealCard/DealCard";
import StatusColumn from "@/app/deals/components/StatusColumn/StatusColumn";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import { DealSchema, StatusSchema } from "@/lib/client";
type Props = {
activeDeal: DealSchema | null;
activeStatus: StatusSchema | null;
};
const DndOverlay = ({ activeStatus, activeDeal }: Props) => {
const { deals } = useStatusesContext();
return (
<DragOverlay dropAnimation={defaultDropAnimation}>
<div style={{ cursor: "grabbing" }}>
{activeDeal ? (
<DealCard deal={activeDeal} />
) : activeStatus ? (
<StatusColumn
id={`${activeStatus.id}-status`}
status={activeStatus}
deals={deals.filter(
deal => deal.statusId === activeStatus.id
)}
isDragging
/>
) : null}
</div>
</DragOverlay>
);
};
export default DndOverlay;

View File

@ -1,24 +0,0 @@
"use client";
import { Group } from "@mantine/core";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import ProjectSelect from "@/components/selects/ProjectSelect/ProjectSelect";
const Header = () => {
const { projects, setSelectedProject, selectedProject } =
useProjectsContext();
return (
<Group
justify={"flex-end"}
w={"100%"}>
<ProjectSelect
data={projects}
value={selectedProject}
onChange={value => value && setSelectedProject(value)}
/>
</Group>
);
};
export default Header;

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

@ -1,67 +0,0 @@
import React, { useMemo } from "react";
import { useDroppable } from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { Box, Stack, Text } from "@mantine/core";
import DealContainer from "@/app/deals/components/DealContainer/DealContainer";
import { DealSchema, StatusSchema } from "@/lib/client";
import { sortByLexorank } from "@/utils/lexorank";
type Props = {
id: string;
status: StatusSchema;
deals: DealSchema[];
isDragging?: boolean;
};
const StatusColumn = ({ id, status, deals, isDragging }: Props) => {
const { setNodeRef } = useDroppable({ id });
const sortedDeals = useMemo(
() => sortByLexorank(deals.filter(deal => deal.statusId === status.id)),
[deals]
);
const columnBody = useMemo(() => {
return (
<SortableContext
id={id}
items={sortedDeals}
strategy={verticalListSortingStrategy}>
<Stack
gap={"xs"}
ref={setNodeRef}>
{sortedDeals.map(deal => (
<DealContainer
key={deal.id}
deal={deal}
/>
))}
</Stack>
</SortableContext>
);
}, [sortedDeals]);
return (
<Box
style={{
backgroundColor: "#eee",
padding: 2,
width: "15vw",
minWidth: 150,
}}>
<Text
style={{
cursor: "grab",
userSelect: "none",
opacity: isDragging ? 0.5 : 1,
}}>
{status.name}
</Text>
{columnBody}
</Box>
);
};
export default StatusColumn;

View File

@ -1,76 +0,0 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import StatusColumnsDnd from "@/app/deals/components/StatusColumnsDnd/StatusColumnsDnd";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import {
updateDealMutation,
updateStatusMutation,
} from "@/lib/client/@tanstack/react-query.gen";
import { notifications } from "@/lib/notifications";
const StatusColumns = () => {
const { refetchStatuses, refetchDeals } = useStatusesContext();
const updateStatus = useMutation({
...updateStatusMutation(),
onError: error => {
console.error(error);
notifications.error({
message: error.response?.data?.detail as string | undefined,
});
refetchStatuses();
},
});
const updateDeals = useMutation({
...updateDealMutation(),
onError: error => {
console.error(error);
notifications.error({
message: error.response?.data?.detail as string | undefined,
});
refetchDeals();
},
});
const onDealDragEnd = (
dealId: number,
statusId: number,
lexorank?: string
) => {
updateDeals.mutate({
path: {
dealId,
},
body: {
deal: {
statusId,
lexorank,
},
},
});
};
const onStatusDragEnd = (statusId: number, lexorank: string) => {
updateStatus.mutate({
path: {
statusId,
},
body: {
status: {
lexorank,
},
},
});
};
return (
<StatusColumnsDnd
onDealDragEnd={onDealDragEnd}
onStatusDragEnd={onStatusDragEnd}
/>
);
};
export default StatusColumns;

View File

@ -1,80 +0,0 @@
"use client";
import React, { FC } from "react";
import { closestCorners, DndContext } from "@dnd-kit/core";
import {
horizontalListSortingStrategy,
SortableContext,
} from "@dnd-kit/sortable";
import { Group, ScrollArea } from "@mantine/core";
import DndOverlay from "@/app/deals/components/DndOverlay/DndOverlay";
import StatusColumn from "@/app/deals/components/StatusColumn/StatusColumn";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import useDealsAndStatusesDnd from "@/app/deals/hooks/useDealsAndStatusesDnd";
import { SortableItem } from "@/components/SortableDnd/SortableItem";
import useDndSensors from "../../hooks/useSensors";
type Props = {
onDealDragEnd: (
dealId: number,
statusId: number,
lexorank?: string
) => void;
onStatusDragEnd: (statusId: number, lexorank: string) => void;
};
const StatusColumnsDnd: FC<Props> = props => {
const { deals } = useStatusesContext();
const {
sortedStatuses,
handleDragStart,
handleDragOver,
handleDragEnd,
activeStatus,
activeDeal,
} = useDealsAndStatusesDnd(props);
const sensors = useDndSensors();
return (
<ScrollArea
offsetScrollbars={"x"}
scrollbarSize={"0.5rem"}>
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}>
<SortableContext
items={sortedStatuses.map(status => `${status.id}-status`)}
strategy={horizontalListSortingStrategy}>
<Group
gap={"xs"}
wrap={"nowrap"}
align={"start"}>
{sortedStatuses.map(status => (
<SortableItem
key={status.id}
id={`${status.id}-status`}>
<StatusColumn
id={`${status.id}-status`}
status={status}
deals={deals}
isDragging={activeStatus?.id === status.id}
/>
</SortableItem>
))}
<DndOverlay
activeStatus={activeStatus}
activeDeal={activeDeal}
/>
</Group>
</SortableContext>
</DndContext>
</ScrollArea>
);
};
export default StatusColumnsDnd;

View File

@ -0,0 +1,14 @@
.board {
min-width: 50px;
flex-wrap: nowrap;
gap: 3px;
border-top-left-radius: 15px;
border-top-right-radius: 15px;
border-bottom: 2px solid gray;
}
.board-selected {
border: 2px solid gray;
border-bottom: 0;
}

View File

@ -0,0 +1,63 @@
import React, { FC, useState } from "react";
import classNames from "classnames";
import { Box, Group, Text } from "@mantine/core";
import BoardMenu from "@/app/deals/components/shared/BoardMenu/BoardMenu";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
import useIsMobile from "@/hooks/useIsMobile";
import { BoardSchema } from "@/lib/client";
import styles from "./Board.module.css";
type Props = {
board: BoardSchema;
};
const Board: FC<Props> = ({ board }) => {
const { selectedBoard, onUpdateBoard } = useBoardsContext();
const isMobile = useIsMobile();
const [isHovered, setIsHovered] = useState(false);
return (
<Group
px={"md"}
py={"xs"}
className={classNames(
styles.board,
selectedBoard?.id === board.id && styles["board-selected"]
)}
justify={"space-between"}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}>
<InPlaceInput
defaultValue={board.name}
onComplete={value => onUpdateBoard(board.id, { name: value })}
inputStyles={{
input: {
height: 25,
minHeight: 25,
},
}}
getChildren={startEditing => (
<>
<Box>
<Text style={{ textWrap: "nowrap" }}>
{board.name}
</Text>
</Box>
<BoardMenu
isHovered={
!isMobile &&
(selectedBoard?.id === board.id || isHovered)
}
board={board}
startEditing={startEditing}
/>
</>
)}
modalTitle={"Редактирование доски"}
/>
</Group>
);
};
export default Board;

View File

@ -0,0 +1,55 @@
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 = {
board: BoardSchema;
startEditing: () => void;
isHovered?: boolean;
};
const BoardMenu: FC<Props> = ({ board, startEditing, isHovered = true }) => {
const { selectedBoard, onDeleteBoard } = useBoardsContext();
return (
<Menu>
<Menu.Target>
<Box
style={{
opacity:
isHovered || selectedBoard?.id === board.id ? 1 : 0,
cursor: "pointer",
}}
onClick={e => e.stopPropagation()}>
<IconDotsVertical />
</Box>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={e => {
e.stopPropagation();
startEditing();
}}>
<Group wrap={"nowrap"}>
<IconEdit />
<Text>Переименовать</Text>
</Group>
</Menu.Item>
<Menu.Item
onClick={e => {
e.stopPropagation();
onDeleteBoard(board);
}}>
<Group wrap={"nowrap"}>
<IconTrash />
<Text>Удалить</Text>
</Group>
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
export default BoardMenu;

View File

@ -0,0 +1,52 @@
"use client";
import React from "react";
import { Group, ScrollArea } from "@mantine/core";
import Board from "@/app/deals/components/shared/Board/Board";
import CreateBoardButton from "@/app/deals/components/shared/CreateBoardButton/CreateBoardButton";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import SortableDnd from "@/components/dnd/SortableDnd";
import useIsMobile from "@/hooks/useIsMobile";
import { BoardSchema } from "@/lib/client";
const Boards = () => {
const { boards, setSelectedBoard, onUpdateBoard } = useBoardsContext();
const isMobile = useIsMobile();
const renderBoard = (board: BoardSchema) => <Board board={board} />;
const onDragEnd = (itemId: number, newLexorank: string) => {
onUpdateBoard(itemId, { lexorank: newLexorank });
};
const selectBoard = (board: BoardSchema) => {
setSelectedBoard(board);
};
return (
<ScrollArea
offsetScrollbars={"x"}
scrollbars={"x"}
scrollbarSize={0}
w={"100%"}
mt={"xs"}
mb={"sm"}>
<Group
wrap={"nowrap"}
gap={0}>
<SortableDnd
initialItems={boards}
renderItem={renderBoard}
onDragEnd={onDragEnd}
onItemClick={selectBoard}
containerStyle={{ flexWrap: "nowrap" }}
dragHandleStyle={{ cursor: "pointer" }}
disabled={isMobile}
/>
<CreateBoardButton />
</Group>
</ScrollArea>
);
};
export default Boards;

View File

@ -0,0 +1,11 @@
.create-button {
padding: 10px 10px 9px;
border-bottom: 2px solid gray;
}
.spacer {
height: 45px;
border-bottom: 2px solid gray;
width: 100%;
}

View File

@ -0,0 +1,35 @@
import { IconPlus } from "@tabler/icons-react";
import { Box, Space } from "@mantine/core";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
import styles from "./CreateBoardButton.module.css";
const CreateBoardButton = () => {
const { onCreateBoard } = useBoardsContext();
return (
<>
<InPlaceInput
placeholder={"Название доски"}
onComplete={onCreateBoard}
getChildren={startEditing => (
<Box
onClick={startEditing}
className={styles["create-button"]}>
<IconPlus />
</Box>
)}
modalTitle={"Создание доски"}
inputStyles={{
wrapper: {
marginLeft: 15,
marginRight: 15,
},
}}
/>
<Space className={styles.spacer} />
</>
);
};
export default CreateBoardButton;

View File

@ -0,0 +1,21 @@
.container {
cursor: pointer;
height: calc(100vh - 150px);
@media (max-width: 48em) {
width: 80vw;
}
}
.inner-container {
border-radius: var(--mantine-spacing-md);
flex-wrap: nowrap;
@mixin light {
background-color: var(--color-light-aqua);
}
@mixin dark {
background-color: var(--mantine-color-dark-6);
}
}

View File

@ -0,0 +1,45 @@
import React from "react";
import { IconPlus } from "@tabler/icons-react";
import { Box, Center, Group, Stack, Text } from "@mantine/core";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
import useIsMobile from "@/hooks/useIsMobile";
import styles from "./CreateStatusButton.module.css";
const CreateStatusButton = () => {
const { onCreateStatus } = useStatusesContext();
const isMobile = useIsMobile();
return (
<Stack className={styles.container}>
<Box className={styles["inner-container"]}>
<InPlaceInput
placeholder={"Название колонки"}
onComplete={onCreateStatus}
getChildren={startEditing => (
<Center
p={"sm"}
onClick={() => startEditing()}>
<Group
gap={"xs"}
wrap={"nowrap"}
align={"start"}>
<IconPlus />
{isMobile && <Text>Добавить</Text>}
</Group>
</Center>
)}
modalTitle={"Создание колонки"}
inputStyles={{
wrapper: {
paddingInline: "var(--mantine-spacing-md)",
paddingBlock: "var(--mantine-spacing-xs)",
},
}}
/>
</Box>
</Stack>
);
};
export default CreateStatusButton;

View File

@ -0,0 +1,30 @@
.container {
padding: var(--mantine-spacing-xs);
gap: var(--mantine-spacing-xs);
@mixin light {
background-color: var(--color-light-white-blue);
}
@mixin dark {
background-color: var(--mantine-color-dark-7);
}
}
.first-tag {
@mixin light {
background-color: lightblue;
}
@mixin dark {
background-color: darkslateblue;
}
}
.second-tag {
@mixin light {
background-color: lightgray;
}
@mixin dark {
background-color: var(--mantine-color-dark-4);
}
}

View File

@ -0,0 +1,26 @@
import { Card, Group, Pill, Stack, Text } from "@mantine/core";
import { DealSchema } from "@/lib/client";
import styles from "./DealCard.module.css";
type Props = {
deal: DealSchema;
};
const DealCard = ({ deal }: Props) => {
return (
<Card className={styles.container}>
<Text c={"dodgerblue"}>{deal.name}</Text>
<Stack gap={0}>
<Text>Wb электросталь</Text>
<Text>19 000 руб.</Text>
<Text>130 тов.</Text>
</Stack>
<Group gap={"xs"}>
<Pill className={styles["first-tag"]}>Срочно</Pill>
<Pill className={styles["second-tag"]}>Бесплатно</Pill>
</Group>
</Card>
);
};
export default DealCard;

View File

@ -1,8 +1,8 @@
import React, { FC, useMemo } from "react"; import React, { FC, useMemo } from "react";
import { Box } from "@mantine/core"; import { Box } from "@mantine/core";
import DealCard from "@/app/deals/components/DealCard/DealCard"; import DealCard from "@/app/deals/components/shared/DealCard/DealCard";
import SortableItem from "@/components/dnd/SortableItem";
import { DealSchema } from "@/lib/client"; import { DealSchema } from "@/lib/client";
import { SortableItem } from "@/components/SortableDnd/SortableItem";
type Props = { type Props = {
deal: DealSchema; deal: DealSchema;
@ -15,9 +15,9 @@ const DealContainer: FC<Props> = ({ deal }) => {
<Box> <Box>
<SortableItem <SortableItem
dragHandleStyle={{ cursor: "pointer" }} dragHandleStyle={{ cursor: "pointer" }}
id={deal.id}> id={deal.id}
{dealBody} renderItem={() => dealBody}
</SortableItem> />
</Box> </Box>
); );
}; };

View File

@ -0,0 +1,89 @@
"use client";
import React, { FC, ReactNode } from "react";
import DealCard from "@/app/deals/components/shared/DealCard/DealCard";
import DealContainer from "@/app/deals/components/shared/DealContainer/DealContainer";
import StatusColumnHeader from "@/app/deals/components/shared/StatusColumnHeader/StatusColumnHeader";
import StatusColumnWrapper from "@/app/deals/components/shared/StatusColumnWrapper/StatusColumnWrapper";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import { useDealsContext } from "@/app/deals/contexts/DealsContext";
import useDealsAndStatusesDnd from "@/app/deals/hooks/useDealsAndStatusesDnd";
import FunnelDnd from "@/components/dnd/FunnelDnd/FunnelDnd";
import useIsMobile from "@/hooks/useIsMobile";
import { DealSchema, StatusSchema } from "@/lib/client";
import { sortByLexorank } from "@/utils/lexorank";
const Funnel: FC = () => {
const { selectedBoard } = useBoardsContext();
const { deals } = useDealsContext();
const isMobile = useIsMobile();
const {
sortedStatuses,
handleDragStart,
handleDragOver,
handleDragEnd,
activeStatus,
activeDeal,
swiperRef,
} = useDealsAndStatusesDnd();
return (
<FunnelDnd
containers={sortedStatuses}
items={deals}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
swiperRef={swiperRef}
getContainerId={(status: StatusSchema) => `${status.id}-status`}
getItemsByContainer={(status: StatusSchema, items: DealSchema[]) =>
sortByLexorank(
items.filter(deal => deal.statusId === status.id)
)
}
renderContainer={(
status: StatusSchema,
funnelColumnComponent: ReactNode,
renderDraggable
) => (
<StatusColumnWrapper
status={status}
renderHeader={renderDraggable}>
{funnelColumnComponent}
</StatusColumnWrapper>
)}
renderContainerHeader={status => (
<StatusColumnHeader
status={status}
isDragging={activeStatus?.id === status.id}
/>
)}
renderItem={(deal: DealSchema) => (
<DealContainer
key={deal.id}
deal={deal}
/>
)}
activeContainer={activeStatus}
activeItem={activeDeal}
renderItemOverlay={(deal: DealSchema) => <DealCard deal={deal} />}
renderContainerOverlay={(status: StatusSchema, children) => (
<StatusColumnWrapper
status={status}
renderHeader={() => (
<StatusColumnHeader
status={status}
isDragging={activeStatus?.id === status.id}
/>
)}>
{children}
</StatusColumnWrapper>
)}
disabledColumns={isMobile}
isCreatingContainerEnabled={!!selectedBoard}
/>
);
};
export default Funnel;

View File

@ -0,0 +1,73 @@
"use client";
import { IconChevronLeft, IconSettings } from "@tabler/icons-react";
import { Box, Group, Stack, Text } from "@mantine/core";
import Boards from "@/app/deals/components/shared/Boards/Boards";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import ProjectSelect from "@/components/selects/ProjectSelect/ProjectSelect";
import { ColorSchemeToggle } from "@/components/ui/ColorSchemeToggle/ColorSchemeToggle";
import useIsMobile from "@/hooks/useIsMobile";
const Header = () => {
const {
projects,
setSelectedProject,
selectedProject,
setIsEditorDrawerOpened: setIsProjectsDrawerOpened,
} = useProjectsContext();
const { setIsEditorDrawerOpened } = useBoardsContext();
const isMobile = useIsMobile();
const getDesktopHeader = () => {
return (
<Group
w={"100%"}
justify={"end"}
wrap={"nowrap"}
pr={"md"}>
<Group wrap={"nowrap"}>
<ColorSchemeToggle />
<ProjectSelect
data={projects}
value={selectedProject}
onChange={value => value && setSelectedProject(value)}
/>
</Group>
</Group>
);
};
const getMobileHeader = () => {
return (
<Group justify={"space-between"}>
<Box
p={"md"}
onClick={() => setIsProjectsDrawerOpened(true)}>
<IconChevronLeft />
</Box>
<Text>{selectedProject?.name}</Text>
<Box
p={"md"}
onClick={() => setIsEditorDrawerOpened(true)}>
<IconSettings />
</Box>
</Group>
);
};
return (
<Group
justify={"flex-end"}
w={"100%"}>
<Stack
gap={0}
w={"100%"}>
{isMobile ? getMobileHeader() : getDesktopHeader()}
<Boards />
</Stack>
</Group>
);
};
export default Header;

View File

@ -0,0 +1,4 @@
.header {
border-bottom: solid dodgerblue 3px;
}

View File

@ -0,0 +1,62 @@
import React, { FC } from "react";
import { Group, Text } from "@mantine/core";
import styles from "./StatusColumnHeader.module.css";
import StatusMenu from "@/app/deals/components/shared/StatusMenu/StatusMenu";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
import { StatusSchema } from "@/lib/client";
type Props = {
status: StatusSchema;
isDragging: boolean;
};
const StatusColumnHeader: FC<Props> = ({ status, isDragging }) => {
const { onUpdateStatus } = useStatusesContext();
const handleSave = (value: string) => {
const newValue = value.trim();
if (newValue && newValue !== status.name) {
onUpdateStatus(status.id, { name: newValue });
}
};
return (
<Group
justify={"space-between"}
p={"sm"}
wrap={"nowrap"}
mb={"xs"}
className={styles.header}>
<InPlaceInput
defaultValue={status.name}
onComplete={value => handleSave(value)}
inputStyles={{
input: {
height: 25,
minHeight: 25,
},
}}
getChildren={startEditing => (
<>
<Text
style={{
cursor: "grab",
userSelect: "none",
opacity: isDragging ? 0.5 : 1,
}}>
{status.name}
</Text>
<StatusMenu
status={status}
handleEdit={startEditing}
/>
</>
)}
modalTitle={"Редактирование статуса"}
/>
</Group>
);
};
export default StatusColumnHeader;

View File

@ -0,0 +1,20 @@
.container {
height: calc(100vh - 150px);
@media (max-width: 48em) {
width: 80vw;
}
}
.inner-container {
border-radius: var(--mantine-spacing-md);
gap: 0;
@mixin light {
background-color: var(--color-light-aqua);
}
@mixin dark {
background-color: var(--mantine-color-dark-6);
}
}

View File

@ -0,0 +1,30 @@
import React, { ReactNode } from "react";
import { Box, ScrollArea, Stack } from "@mantine/core";
import { StatusSchema } from "@/lib/client";
import styles from "./StatusColumnWrapper.module.css";
type Props = {
status: StatusSchema;
renderHeader: () => ReactNode;
children: ReactNode;
};
const StatusColumnWrapper = ({ renderHeader, children }: Props) => {
return (
<Box className={styles.container}>
<Stack
px={"xs"}
pb={"xs"}
className={styles["inner-container"]}>
{renderHeader()}
<ScrollArea
offsetScrollbars={"y"}
scrollbars={"y"}>
<Stack mah={"calc(100vh - 220px)"}>{children}</Stack>
</ScrollArea>
</Stack>
</Box>
);
};
export default StatusColumnWrapper;

View File

@ -0,0 +1,71 @@
import React, { FC } from "react";
import {
IconDotsVertical,
IconEdit,
IconExchange,
IconTrash,
} from "@tabler/icons-react";
import { Box, Group, Menu, Text } from "@mantine/core";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import useIsMobile from "@/hooks/useIsMobile";
import { StatusSchema } from "@/lib/client";
type Props = {
status: StatusSchema;
handleEdit: () => void;
};
const StatusMenu: FC<Props> = ({ status, handleEdit }) => {
const isMobile = useIsMobile();
const { onDeleteStatus, setIsEditorDrawerOpened } = useStatusesContext();
return (
<Menu>
<Menu.Target>
<Box
style={{
cursor: "pointer",
}}
onClick={e => e.stopPropagation()}>
<IconDotsVertical />
</Box>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={e => {
e.stopPropagation();
handleEdit();
}}>
<Group wrap={"nowrap"}>
<IconEdit />
<Text>Переименовать</Text>
</Group>
</Menu.Item>
<Menu.Item
onClick={e => {
e.stopPropagation();
onDeleteStatus(status);
}}>
<Group wrap={"nowrap"}>
<IconTrash />
<Text>Удалить</Text>
</Group>
</Menu.Item>
{isMobile && (
<Menu.Item
onClick={e => {
e.stopPropagation();
setIsEditorDrawerOpened(true);
}}>
<Group wrap={"nowrap"}>
<IconExchange />
<Text>Изменить порядок</Text>
</Group>
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
);
};
export default StatusMenu;

View File

@ -8,8 +8,9 @@ import React, {
useState, useState,
} from "react"; } from "react";
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 { useBoardsOperations } from "@/hooks/useBoardsOperations";
import { BoardSchema, UpdateBoardSchema } from "@/lib/client";
type BoardsContextState = { type BoardsContextState = {
boards: BoardSchema[]; boards: BoardSchema[];
@ -17,16 +18,27 @@ 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;
onCreateBoard: (name: string) => void;
onUpdateBoard: (boardId: number, board: UpdateBoardSchema) => void;
onDeleteBoard: (board: BoardSchema) => void;
isEditorDrawerOpened: boolean;
setIsEditorDrawerOpened: React.Dispatch<React.SetStateAction<boolean>>;
}; };
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
); );
const [isEditorDrawerOpened, setIsEditorDrawerOpened] =
useState<boolean>(false);
useEffect(() => { useEffect(() => {
if (boards.length > 0 && selectedBoard === null) { if (boards.length > 0 && selectedBoard === null) {
@ -34,22 +46,36 @@ const useBoardsContextState = () => {
return; return;
} }
if (selectedBoard) { if (!selectedBoard) return;
let newBoard = boards.find(board => board.id === selectedBoard.id);
if (!newBoard && boards.length > 0) { let newBoard = boards.find(board => board.id === selectedBoard.id);
newBoard = boards[0];
} if (!newBoard && boards.length > 0) {
setSelectedBoard(newBoard ?? null); newBoard = boards[0];
} }
setSelectedBoard(newBoard ?? null);
}, [boards]); }, [boards]);
const { onCreateBoard, onUpdateBoard, onDeleteBoard } = useBoardsOperations(
{
boards,
setBoards,
refetchBoards,
projectId: project?.id,
}
);
return { return {
boards, boards,
setBoards, setBoards,
selectedBoard, selectedBoard,
setSelectedBoard, setSelectedBoard,
refetchBoards, refetchBoards,
onCreateBoard,
onUpdateBoard,
onDeleteBoard,
isEditorDrawerOpened,
setIsEditorDrawerOpened,
}; };
}; };

View File

@ -0,0 +1,80 @@
"use client";
import React, { createContext, FC, useContext } from "react";
import { useMutation, UseMutationResult } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import useDealsList from "@/hooks/useDealsList";
import {
DealSchema,
HttpValidationError,
Options,
UpdateDealData,
UpdateDealResponse,
} from "@/lib/client";
import { updateDealMutation } from "@/lib/client/@tanstack/react-query.gen";
import { notifications } from "@/lib/notifications";
type DealsContextState = {
deals: DealSchema[];
setDeals: React.Dispatch<React.SetStateAction<DealSchema[]>>;
updateDeal: UseMutationResult<
UpdateDealResponse,
AxiosError<HttpValidationError>,
Options<UpdateDealData>
>;
refetchDeals: () => void;
};
const DealsContext = createContext<DealsContextState | undefined>(undefined);
const useDealsContextState = () => {
const { selectedBoard } = useBoardsContext();
const {
deals,
setDeals,
refetch: refetchDeals,
} = useDealsList({ boardId: selectedBoard?.id });
const updateDeal = useMutation({
...updateDealMutation(),
onError: error => {
console.error(error);
notifications.error({
message: error.response?.data?.detail as string | undefined,
});
refetchDeals();
},
});
return {
deals,
setDeals,
updateDeal,
refetchDeals,
};
};
type DealsContextProviderProps = {
children: React.ReactNode;
};
export const DealsContextProvider: FC<DealsContextProviderProps> = ({
children,
}) => {
const state = useDealsContextState();
return (
<DealsContext.Provider value={state}>{children}</DealsContext.Provider>
);
};
export const useDealsContext = () => {
const context = useContext(DealsContext);
if (!context) {
throw new Error(
"useDealsContext must be used within a DealsContextProvider"
);
}
return context;
};

View File

@ -7,8 +7,9 @@ import React, {
useEffect, useEffect,
useState, useState,
} from "react"; } from "react";
import { ProjectSchema } from "@/lib/client";
import useProjectsList from "@/hooks/useProjectsList"; import useProjectsList from "@/hooks/useProjectsList";
import { useProjectsOperations } from "@/hooks/useProjectsOperations";
import { ProjectSchema, UpdateProjectSchema } from "@/lib/client";
type ProjectsContextState = { type ProjectsContextState = {
selectedProject: ProjectSchema | null; selectedProject: ProjectSchema | null;
@ -16,6 +17,11 @@ type ProjectsContextState = {
React.SetStateAction<ProjectSchema | null> React.SetStateAction<ProjectSchema | null>
>; >;
projects: ProjectSchema[]; projects: ProjectSchema[];
onCreateProject: (name: string) => void;
onUpdateProject: (projectId: number, project: UpdateProjectSchema) => void;
onDeleteProject: (project: ProjectSchema) => void;
isEditorDrawerOpened: boolean;
setIsEditorDrawerOpened: React.Dispatch<React.SetStateAction<boolean>>;
}; };
const ProjectsContext = createContext<ProjectsContextState | undefined>( const ProjectsContext = createContext<ProjectsContextState | undefined>(
@ -23,7 +29,13 @@ const ProjectsContext = createContext<ProjectsContextState | undefined>(
); );
const useProjectsContextState = () => { const useProjectsContextState = () => {
const { projects } = useProjectsList(); const [isEditorDrawerOpened, setIsEditorDrawerOpened] =
useState<boolean>(false);
const {
projects,
setProjects,
refetch: refetchProjects,
} = useProjectsList();
const [selectedProject, setSelectedProject] = const [selectedProject, setSelectedProject] =
useState<ProjectSchema | null>(null); useState<ProjectSchema | null>(null);
@ -43,10 +55,22 @@ const useProjectsContextState = () => {
setSelectedProject(null); setSelectedProject(null);
}, [projects]); }, [projects]);
const { onCreateProject, onUpdateProject, onDeleteProject } =
useProjectsOperations({
projects,
setProjects,
refetchProjects,
});
return { return {
projects, projects,
selectedProject, selectedProject,
setSelectedProject, setSelectedProject,
onCreateProject,
onUpdateProject,
onDeleteProject,
isEditorDrawerOpened,
setIsEditorDrawerOpened,
}; };
}; };

View File

@ -1,18 +1,42 @@
"use client"; "use client";
import React, { createContext, FC, useContext, useEffect } from "react"; import React, {
createContext,
FC,
useContext,
useEffect,
useState,
} from "react";
import { useMutation, UseMutationResult } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext"; import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import { DealSchema, StatusSchema } from "@/lib/client";
import useDealsList from "@/hooks/useDealsList";
import useStatusesList from "@/hooks/useStatusesList"; import useStatusesList from "@/hooks/useStatusesList";
import { useStatusesOperations } from "@/hooks/useStatusesOperations";
import {
HttpValidationError,
Options,
StatusSchema,
UpdateStatusData,
UpdateStatusResponse,
UpdateStatusSchema,
} from "@/lib/client";
import { updateStatusMutation } from "@/lib/client/@tanstack/react-query.gen";
import { notifications } from "@/lib/notifications";
type StatusesContextState = { type StatusesContextState = {
statuses: StatusSchema[]; statuses: StatusSchema[];
setStatuses: React.Dispatch<React.SetStateAction<StatusSchema[]>>; setStatuses: React.Dispatch<React.SetStateAction<StatusSchema[]>>;
deals: DealSchema[]; updateStatus: UseMutationResult<
setDeals: React.Dispatch<React.SetStateAction<DealSchema[]>>; UpdateStatusResponse,
AxiosError<HttpValidationError>,
Options<UpdateStatusData>
>;
refetchStatuses: () => void; refetchStatuses: () => void;
refetchDeals: () => void; onCreateStatus: (name: string) => void;
onUpdateStatus: (statusId: number, status: UpdateStatusSchema) => void;
onDeleteStatus: (status: StatusSchema) => void;
isEditorDrawerOpened: boolean;
setIsEditorDrawerOpened: React.Dispatch<React.SetStateAction<boolean>>;
}; };
const StatusesContext = createContext<StatusesContextState | undefined>( const StatusesContext = createContext<StatusesContextState | undefined>(
@ -28,24 +52,42 @@ const useStatusesContextState = () => {
} = useStatusesList({ } = useStatusesList({
boardId: selectedBoard?.id, boardId: selectedBoard?.id,
}); });
const [isEditorDrawerOpened, setIsEditorDrawerOpened] =
const { useState<boolean>(false);
deals,
setDeals,
refetch: refetchDeals,
} = useDealsList({ boardId: selectedBoard?.id });
useEffect(() => { useEffect(() => {
refetchStatuses(); refetchStatuses();
}, [selectedBoard]); }, [selectedBoard]);
const updateStatus = useMutation({
...updateStatusMutation(),
onError: error => {
console.error(error);
notifications.error({
message: error.response?.data?.detail as string | undefined,
});
refetchStatuses();
},
});
const { onCreateStatus, onUpdateStatus, onDeleteStatus } =
useStatusesOperations({
statuses,
setStatuses,
refetchStatuses,
boardId: selectedBoard?.id,
});
return { return {
statuses, statuses,
setStatuses, setStatuses,
deals, updateStatus,
setDeals,
refetchStatuses, refetchStatuses,
refetchDeals, onCreateStatus,
onUpdateStatus,
onDeleteStatus,
isEditorDrawerOpened,
setIsEditorDrawerOpened,
}; };
}; };

View File

@ -0,0 +1,87 @@
"use client";
import React, { FC, ReactNode } from "react";
import { IconChevronLeft, IconGripVertical } from "@tabler/icons-react";
import { Box, Center, Divider, Drawer, Group, rem, Text } from "@mantine/core";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import CreateStatusButton from "@/app/deals/drawers/BoardStatusesEditorDrawer/components/CreateStatusButton";
import StatusMobile from "@/app/deals/drawers/BoardStatusesEditorDrawer/components/StatusMobile";
import SortableDnd from "@/components/dnd/SortableDnd";
import { StatusSchema } from "@/lib/client";
const BoardStatusesEditorDrawer: FC = () => {
const {
statuses,
onUpdateStatus,
isEditorDrawerOpened,
setIsEditorDrawerOpened,
} = useStatusesContext();
const { selectedBoard } = useBoardsContext();
const onClose = () => setIsEditorDrawerOpened(false);
const renderDraggable = () => (
<Box p={"xs"}>
<IconGripVertical />
</Box>
);
const renderStatus = (
status: StatusSchema,
renderDraggable?: (item: StatusSchema) => ReactNode
) => {
return (
<Group wrap={"nowrap"}>
{renderDraggable && renderDraggable(status)}
<StatusMobile status={status} />
</Group>
);
};
const onDragEnd = (itemId: number, newLexorank: string) => {
onUpdateStatus(itemId, { lexorank: newLexorank });
};
return (
<Drawer
size={"100%"}
position={"right"}
onClose={onClose}
removeScrollProps={{ allowPinchZoom: true }}
withCloseButton={false}
opened={isEditorDrawerOpened}
trapFocus={false}
styles={{
body: {
height: "100%",
display: "flex",
flexDirection: "column",
gap: rem(10),
},
}}>
<Group justify={"space-between"}>
<Box
onClick={onClose}
p={"xs"}>
<IconChevronLeft />
</Box>
<Center>
<Text>{selectedBoard?.name}</Text>
</Center>
<Box p={"lg"} />
</Group>
<Divider />
<SortableDnd
initialItems={statuses}
onDragEnd={onDragEnd}
renderItem={renderStatus}
renderDraggable={renderDraggable}
dragHandleStyle={{ width: "auto" }}
vertical
/>
<CreateStatusButton />
</Drawer>
);
};
export default BoardStatusesEditorDrawer;

View File

@ -0,0 +1,32 @@
import { IconPlus } from "@tabler/icons-react";
import { Box, Group, Text } from "@mantine/core";
import { modals } from "@mantine/modals";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
const CreateStatusButton = () => {
const { onCreateStatus } = useStatusesContext();
const onStartCreating = () => {
modals.openContextModal({
modal: "enterNameModal",
title: "Создание колонки",
withCloseButton: true,
innerProps: {
onComplete: onCreateStatus,
},
});
};
return (
<Group
ml={"xs"}
onClick={onStartCreating}>
<IconPlus />
<Box mt={5}>
<Text>Создать колонку</Text>
</Box>
</Group>
);
};
export default CreateStatusButton;

View File

@ -0,0 +1,45 @@
import React, { FC } from "react";
import { Box, Group, Text } from "@mantine/core";
import { modals } from "@mantine/modals";
import BoardMenu from "@/app/deals/components/shared/BoardMenu/BoardMenu";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import { StatusSchema } from "@/lib/client";
type Props = {
status: StatusSchema;
};
const StatusMobile: FC<Props> = ({ status }) => {
const { onUpdateStatus } = useStatusesContext();
const startEditing = () => {
modals.openContextModal({
modal: "enterNameModal",
title: "Редактирование статуса",
withCloseButton: true,
innerProps: {
onComplete: name => onUpdateStatus(status.id, { name }),
defaultValue: status.name,
},
});
};
return (
<Group
w={"100%"}
pr={"md"}
py={"xs"}
justify={"space-between"}
wrap={"nowrap"}>
<Box>
<Text>{status.name}</Text>
</Box>
<BoardMenu
board={status}
startEditing={startEditing}
/>
</Group>
);
};
export default StatusMobile;

View File

@ -0,0 +1,3 @@
import BoardStatusesEditorDrawer from "@/app/deals/drawers/BoardStatusesEditorDrawer/BoardStatusesEditorDrawer";
export default BoardStatusesEditorDrawer;

View File

@ -0,0 +1,87 @@
"use client";
import React, { FC, ReactNode } from "react";
import { IconChevronLeft, IconGripVertical } from "@tabler/icons-react";
import { Box, Center, Divider, Drawer, Group, rem, Text } from "@mantine/core";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import BoardMobile from "@/app/deals/drawers/ProjectBoardsEditorDrawer/components/BoardMobile";
import CreateBoardButton from "@/app/deals/drawers/ProjectBoardsEditorDrawer/components/CreateBoardButton";
import SortableDnd from "@/components/dnd/SortableDnd";
import { BoardSchema } from "@/lib/client";
const ProjectBoardsEditorDrawer: FC = () => {
const {
boards,
onUpdateBoard,
isEditorDrawerOpened,
setIsEditorDrawerOpened,
} = useBoardsContext();
const { selectedProject } = useProjectsContext();
const onClose = () => setIsEditorDrawerOpened(false);
const renderDraggable = () => (
<Box p={"xs"}>
<IconGripVertical />
</Box>
);
const renderBoard = (
board: BoardSchema,
renderDraggable?: (item: BoardSchema) => ReactNode
) => {
return (
<Group wrap={"nowrap"}>
{renderDraggable && renderDraggable(board)}
<BoardMobile board={board} />
</Group>
);
};
const onDragEnd = (itemId: number, newLexorank: string) => {
onUpdateBoard(itemId, { lexorank: newLexorank });
};
return (
<Drawer
size={"100%"}
position={"right"}
onClose={onClose}
removeScrollProps={{ allowPinchZoom: true }}
withCloseButton={false}
opened={isEditorDrawerOpened}
trapFocus={false}
styles={{
body: {
height: "100%",
display: "flex",
flexDirection: "column",
gap: rem(10),
},
}}>
<Group justify={"space-between"}>
<Box
onClick={onClose}
p={"xs"}>
<IconChevronLeft />
</Box>
<Center>
<Text>{selectedProject?.name}</Text>
</Center>
<Box p={"lg"} />
</Group>
<Divider />
<SortableDnd
initialItems={boards}
onDragEnd={onDragEnd}
renderItem={renderBoard}
renderDraggable={renderDraggable}
dragHandleStyle={{ width: "auto" }}
vertical
/>
<CreateBoardButton />
</Drawer>
);
};
export default ProjectBoardsEditorDrawer;

View File

@ -0,0 +1,45 @@
import React, { FC } from "react";
import { Box, Group, Text } from "@mantine/core";
import { modals } from "@mantine/modals";
import BoardMenu from "@/app/deals/components/shared/BoardMenu/BoardMenu";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import { BoardSchema } from "@/lib/client";
type Props = {
board: BoardSchema;
};
const BoardMobile: FC<Props> = ({ board }) => {
const { onUpdateBoard } = useBoardsContext();
const startEditing = () => {
modals.openContextModal({
modal: "enterNameModal",
title: "Редактирование доски",
withCloseButton: true,
innerProps: {
onComplete: name => onUpdateBoard(board.id, { name }),
defaultValue: board.name,
},
});
};
return (
<Group
w={"100%"}
pr={"md"}
py={"xs"}
justify={"space-between"}
wrap={"nowrap"}>
<Box>
<Text>{board.name}</Text>
</Box>
<BoardMenu
board={board}
startEditing={startEditing}
/>
</Group>
);
};
export default BoardMobile;

View File

@ -0,0 +1,30 @@
import { IconPlus } from "@tabler/icons-react";
import { Box, Group, Text } from "@mantine/core";
import { modals } from "@mantine/modals";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
const CreateBoardButton = () => {
const { onCreateBoard } = useBoardsContext();
const onStartCreating = () => {
modals.openContextModal({
modal: "enterNameModal",
title: "Создание доски",
withCloseButton: true,
innerProps: {
onComplete: onCreateBoard,
},
});
};
return (
<Group ml={"xs"} onClick={onStartCreating}>
<IconPlus />
<Box mt={5}>
<Text>Создать доску</Text>
</Box>
</Group>
);
};
export default CreateBoardButton;

View File

@ -0,0 +1,3 @@
import ProjectBoardsEditorDrawer from "@/app/deals/drawers/ProjectBoardsEditorDrawer/ProjectBoardsEditorDrawer";
export default ProjectBoardsEditorDrawer;

View File

@ -0,0 +1,16 @@
.project:hover {
@mixin light {
background-color: whitesmoke;
}
@mixin dark {
background-color: var(--mantine-color-dark-6);
}
}
.menu-button {
cursor: pointer;
}
.project:hover:has(.menu-button:hover) {
background: none; /* or revert to normal */
}

View File

@ -0,0 +1,48 @@
"use client";
import React, { FC } from "react";
import { Center, Divider, Drawer, rem, Stack, Text } from "@mantine/core";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import CreateProjectButton from "@/app/deals/drawers/ProjectsEditorDrawer/components/CreateProjectButton";
import ProjectMobile from "@/app/deals/drawers/ProjectsEditorDrawer/components/ProjectMobile";
const ProjectsEditorDrawer: FC = () => {
const { projects, isEditorDrawerOpened, setIsEditorDrawerOpened } =
useProjectsContext();
const onClose = () => setIsEditorDrawerOpened(false);
return (
<Drawer
size={"100%"}
position={"left"}
onClose={onClose}
removeScrollProps={{ allowPinchZoom: true }}
withCloseButton={false}
opened={isEditorDrawerOpened}
trapFocus={false}
styles={{
body: {
height: "100%",
display: "flex",
flexDirection: "column",
gap: rem(10),
},
}}>
<Center>
<Text>Проекты</Text>
</Center>
<Divider />
<Stack gap={0}>
{projects.map((project, index) => (
<ProjectMobile
key={index}
project={project}
/>
))}
</Stack>
<CreateProjectButton />
</Drawer>
);
};
export default ProjectsEditorDrawer;

View File

@ -0,0 +1,32 @@
import { IconPlus } from "@tabler/icons-react";
import { Box, Group, Text } from "@mantine/core";
import { modals } from "@mantine/modals";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
const CreateProjectButton = () => {
const { onCreateProject } = useProjectsContext();
const onStartCreating = () => {
modals.openContextModal({
modal: "enterNameModal",
title: "Создание проекта",
withCloseButton: true,
innerProps: {
onComplete: onCreateProject,
},
});
};
return (
<Group
ml={"xs"}
onClick={onStartCreating}>
<IconPlus />
<Box mt={5}>
<Text>Создать проект</Text>
</Box>
</Group>
);
};
export default CreateProjectButton;

View File

@ -0,0 +1,51 @@
import React, { FC } from "react";
import { IconDotsVertical, IconEdit, IconTrash } from "@tabler/icons-react";
import { Box, Group, Menu, Text } from "@mantine/core";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import { ProjectSchema } from "@/lib/client";
import styles from "./../ProjectsEditorDrawer.module.css";
type Props = {
project: ProjectSchema;
startEditing: () => void;
};
const ProjectMenu: FC<Props> = ({ project, startEditing }) => {
const { onDeleteProject } = useProjectsContext();
return (
<Menu>
<Menu.Target>
<Box
className={styles["menu-button"]}
onClick={e => e.stopPropagation()}>
<IconDotsVertical />
</Box>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={e => {
e.stopPropagation();
startEditing();
}}>
<Group wrap={"nowrap"}>
<IconEdit />
<Text>Переименовать</Text>
</Group>
</Menu.Item>
<Menu.Item
onClick={e => {
e.stopPropagation();
onDeleteProject(project);
}}>
<Group wrap={"nowrap"}>
<IconTrash />
<Text>Удалить</Text>
</Group>
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
export default ProjectMenu;

View File

@ -0,0 +1,54 @@
import React, { FC } from "react";
import { Box, Group, Text } from "@mantine/core";
import { modals } from "@mantine/modals";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import ProjectMenu from "@/app/deals/drawers/ProjectsEditorDrawer/components/ProjectMenu";
import { ProjectSchema } from "@/lib/client";
import styles from "./../ProjectsEditorDrawer.module.css";
type Props = {
project: ProjectSchema;
};
const ProjectMobile: FC<Props> = ({ project }) => {
const { onUpdateProject, setSelectedProject, setIsEditorDrawerOpened } =
useProjectsContext();
const startEditing = () => {
modals.openContextModal({
modal: "enterNameModal",
title: "Редактирование проекта",
withCloseButton: true,
innerProps: {
onComplete: name => onUpdateProject(project.id, { name }),
defaultValue: project.name,
},
});
};
const onClick = () => {
setSelectedProject(project);
setIsEditorDrawerOpened(false);
};
return (
<Group
w={"100%"}
pl={"xs"}
py={"xs"}
justify={"space-between"}
wrap={"nowrap"}
className={styles.project}
onClick={onClick}>
<Box>
<Text>{project.name}</Text>
</Box>
<ProjectMenu
project={project}
startEditing={startEditing}
/>
</Group>
);
};
export default ProjectMobile;

View File

@ -0,0 +1,3 @@
import ProjectsEditorDrawer from "@/app/deals/drawers/ProjectsEditorDrawer/ProjectsEditorDrawer";
export default ProjectsEditorDrawer;

View File

@ -1,26 +1,33 @@
import { useMemo, useState } from "react"; import { RefObject, useMemo, useRef, useState } from "react";
import { DragOverEvent, DragStartEvent, Over } from "@dnd-kit/core"; import { DragOverEvent, DragStartEvent, Over } from "@dnd-kit/core";
import { SwiperRef } from "swiper/swiper-react";
import { useDebouncedCallback } from "@mantine/hooks"; import { useDebouncedCallback } from "@mantine/hooks";
import { useDealsContext } from "@/app/deals/contexts/DealsContext";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext"; import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import useGetNewRank from "@/app/deals/hooks/useGetNewRank"; import useGetNewRank from "@/app/deals/hooks/useGetNewRank";
import { getStatusId, isStatusId } from "@/app/deals/utils/statusId"; import { getStatusId, isStatusId } from "@/app/deals/utils/statusId";
import useIsMobile from "@/hooks/useIsMobile";
import { DealSchema, StatusSchema } from "@/lib/client"; import { DealSchema, StatusSchema } from "@/lib/client";
import { sortByLexorank } from "@/utils/lexorank"; import { sortByLexorank } from "@/utils/lexorank";
type Props = { type ReturnType = {
onDealDragEnd: ( sortedStatuses: StatusSchema[];
dealId: number, handleDragStart: ({ active }: DragStartEvent) => void;
statusId: number, handleDragOver: ({ active, over }: DragOverEvent) => void;
lexorank?: string handleDragEnd: ({ active, over }: DragOverEvent) => void;
) => void; activeStatus: StatusSchema | null;
onStatusDragEnd: (statusId: number, lexorank: string) => void; activeDeal: DealSchema | null;
swiperRef: RefObject<SwiperRef | null>;
}; };
const useDealsAndStatusesDnd = (props: Props) => { const useDealsAndStatusesDnd = (): ReturnType => {
const swiperRef = useRef<SwiperRef>(null);
const [activeDeal, setActiveDeal] = useState<DealSchema | null>(null); const [activeDeal, setActiveDeal] = useState<DealSchema | null>(null);
const [activeStatus, setActiveStatus] = useState<StatusSchema | null>(null); const [activeStatus, setActiveStatus] = useState<StatusSchema | null>(null);
const { statuses, deals, setDeals, setStatuses } = useStatusesContext(); const { statuses, setStatuses, updateStatus } = useStatusesContext();
const { deals, setDeals, updateDeal } = useDealsContext();
const sortedStatuses = useMemo(() => sortByLexorank(statuses), [statuses]); const sortedStatuses = useMemo(() => sortByLexorank(statuses), [statuses]);
const isMobile = useIsMobile();
const { const {
getNewRankForSameStatus, getNewRankForSameStatus,
@ -37,10 +44,55 @@ const useDealsAndStatusesDnd = (props: Props) => {
return statuses.find(status => status.id === deal.statusId); return statuses.find(status => status.id === deal.statusId);
}; };
const swipeSliderDuringDrag = (activeId: number, over: Over) => {
const activeStatus = getStatusByDealId(activeId);
const swiperActiveStatus =
statuses[swiperRef.current?.swiper.activeIndex ?? 0];
if (swiperActiveStatus.id !== activeStatus?.id) return;
const activeStatusLexorank = activeStatus?.lexorank;
let overStatusLexorank: string | undefined;
if (typeof over.id === "string" && isStatusId(over.id)) {
const overStatusId = getStatusId(over.id);
overStatusLexorank = statuses.find(
s => s.id === overStatusId
)?.lexorank;
} else {
overStatusLexorank = getStatusByDealId(Number(over.id))?.lexorank;
}
if (
!activeStatusLexorank ||
!overStatusLexorank ||
!swiperRef.current?.swiper
)
return;
const activeIndex = sortedStatuses.findIndex(
s => s.lexorank === activeStatusLexorank
);
const overIndex = sortedStatuses.findIndex(
s => s.lexorank === overStatusLexorank
);
if (activeIndex > overIndex) {
swiperRef.current.swiper.slidePrev();
return;
}
if (activeIndex < overIndex) {
swiperRef.current.swiper.slideNext();
}
};
const handleDragOver = ({ active, over }: DragOverEvent) => { const handleDragOver = ({ active, over }: DragOverEvent) => {
if (!over) return; if (!over) return;
const activeId = active.id as string | number; const activeId = active.id as string | number;
if (isMobile && typeof activeId !== "string") {
swipeSliderDuringDrag(activeId, over);
}
if (typeof activeId === "string" && isStatusId(activeId)) { if (typeof activeId === "string" && isStatusId(activeId)) {
handleColumnDragOver(activeId, over); handleColumnDragOver(activeId, over);
return; return;
@ -173,7 +225,20 @@ const useDealsAndStatusesDnd = (props: Props) => {
const newRank = getNewStatusRank(activeStatusId, overStatusId); const newRank = getNewStatusRank(activeStatusId, overStatusId);
if (!newRank) return; if (!newRank) return;
props.onStatusDragEnd?.(activeStatusId, newRank); onStatusDragEnd?.(activeStatusId, newRank);
};
const onStatusDragEnd = (statusId: number, lexorank: string) => {
updateStatus.mutate({
path: {
statusId,
},
body: {
status: {
lexorank,
},
},
});
}; };
const handleDealDragEnd = (activeId: number | string, over: Over) => { const handleDealDragEnd = (activeId: number | string, over: Over) => {
@ -189,7 +254,25 @@ const useDealsAndStatusesDnd = (props: Props) => {
); );
if (!overStatusId) return; if (!overStatusId) return;
props.onDealDragEnd(activeDealId, overStatusId, newLexorank); onDealDragEnd(activeDealId, overStatusId, newLexorank);
};
const onDealDragEnd = (
dealId: number,
statusId: number,
lexorank?: string
) => {
updateDeal.mutate({
path: {
dealId,
},
body: {
deal: {
statusId,
lexorank,
},
},
});
}; };
const handleDragStart = ({ active }: DragStartEvent) => { const handleDragStart = ({ active }: DragStartEvent) => {
@ -209,6 +292,7 @@ const useDealsAndStatusesDnd = (props: Props) => {
}; };
return { return {
swiperRef,
sortedStatuses, sortedStatuses,
handleDragStart, handleDragStart,
handleDragOver, handleDragOver,

View File

@ -3,14 +3,30 @@ import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import { DealSchema } from "@/lib/client"; import { DealSchema } from "@/lib/client";
import { getNewLexorank, sortByLexorank } from "@/utils/lexorank"; import { getNewLexorank, sortByLexorank } from "@/utils/lexorank";
const useGetNewRank = () => { type NewRankGetters = {
getNewRankForSameStatus: (
statusDeals: DealSchema[],
overDealIndex: number,
activeDealId: number
) => string;
getNewRankForAnotherStatus: (
statusDeals: DealSchema[],
overDealIndex: number
) => string;
getNewStatusRank: (
activeStatusId: number,
overStatusId: number
) => string | null;
};
const useGetNewRank = (): NewRankGetters => {
const { statuses } = useStatusesContext(); const { statuses } = useStatusesContext();
const getNewRankForSameStatus = ( const getNewRankForSameStatus = (
statusDeals: DealSchema[], statusDeals: DealSchema[],
overDealIndex: number, overDealIndex: number,
activeDealId: number activeDealId: number
) => { ): string => {
const activeDealIndex = statusDeals.findIndex( const activeDealIndex = statusDeals.findIndex(
deal => deal.id === activeDealId deal => deal.id === activeDealId
); );
@ -34,7 +50,7 @@ const useGetNewRank = () => {
const getNewRankForAnotherStatus = ( const getNewRankForAnotherStatus = (
statusDeals: DealSchema[], statusDeals: DealSchema[],
overDealIndex: number overDealIndex: number
) => { ): string => {
const leftLexorank = const leftLexorank =
overDealIndex > 0 overDealIndex > 0
? LexoRank.parse(statusDeals[overDealIndex - 1].lexorank) ? LexoRank.parse(statusDeals[overDealIndex - 1].lexorank)
@ -46,7 +62,10 @@ const useGetNewRank = () => {
return getNewLexorank(leftLexorank, rightLexorank).toString(); return getNewLexorank(leftLexorank, rightLexorank).toString();
}; };
const getNewStatusRank = (activeStatusId: number, overStatusId: number) => { const getNewStatusRank = (
activeStatusId: number,
overStatusId: number
): string | null => {
const sortedStatusList = sortByLexorank(statuses); const sortedStatusList = sortByLexorank(statuses);
const overIndex = sortedStatusList.findIndex( const overIndex = sortedStatusList.findIndex(
s => s.id === overStatusId s => s.id === overStatusId

View File

@ -1,6 +1,6 @@
import { import {
KeyboardSensor, KeyboardSensor,
PointerSensor, MouseSensor,
TouchSensor, TouchSensor,
useSensor, useSensor,
useSensors, useSensors,
@ -8,18 +8,21 @@ import {
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable"; import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
const useDndSensors = () => { const useDndSensors = () => {
const sensorOptions = {
activationConstraint: {
distance: 5,
},
};
return useSensors( return useSensors(
useSensor(PointerSensor, sensorOptions), useSensor(MouseSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, { useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates, coordinateGetter: sortableKeyboardCoordinates,
}), }),
useSensor(TouchSensor, sensorOptions) useSensor(TouchSensor, {
activationConstraint: {
delay: 300,
tolerance: 5,
},
})
); );
}; };

View File

@ -1,28 +1,33 @@
import { Divider } from "@mantine/core"; import Funnel from "@/app/deals/components/shared/Funnel/Funnel";
import Boards from "@/app/deals/components/Boards/Boards"; import Header from "@/app/deals/components/shared/Header/Header";
import Header from "@/app/deals/components/Header/Header";
import StatusColumns from "@/app/deals/components/StatusColumns/StatusColumns";
import { BoardsContextProvider } from "@/app/deals/contexts/BoardsContext"; import { BoardsContextProvider } from "@/app/deals/contexts/BoardsContext";
import { ProjectsContextProvider } from "@/app/deals/contexts/ProjectsContext"; import { ProjectsContextProvider } from "@/app/deals/contexts/ProjectsContext";
import { StatusesContextProvider } from "@/app/deals/contexts/StatusesContext"; import { StatusesContextProvider } from "@/app/deals/contexts/StatusesContext";
import PageBlock from "@/components/PageBlock/PageBlock"; import BoardStatusesEditorDrawer from "@/app/deals/drawers/BoardStatusesEditorDrawer";
import PageContainer from "@/components/PageContainer/PageContainer"; import ProjectBoardsEditorDrawer from "@/app/deals/drawers/ProjectBoardsEditorDrawer";
import ProjectsEditorDrawer from "@/app/deals/drawers/ProjectsEditorDrawer";
import PageBlock from "@/components/layout/PageBlock/PageBlock";
import PageContainer from "@/components/layout/PageContainer/PageContainer";
import { DealsContextProvider } from "./contexts/DealsContext";
export default function DealsPage() { export default function DealsPage() {
return ( return (
<PageContainer> <ProjectsContextProvider>
<PageBlock> <BoardsContextProvider>
<ProjectsContextProvider> <PageContainer>
<BoardsContextProvider> <PageBlock className={"mobile-padding-height"}>
<Header /> <Header />
<Boards />
<Divider my={"xl"} />
<StatusesContextProvider> <StatusesContextProvider>
<StatusColumns /> <DealsContextProvider>
<Funnel />
</DealsContextProvider>
<BoardStatusesEditorDrawer />
</StatusesContextProvider> </StatusesContextProvider>
</BoardsContextProvider> <ProjectBoardsEditorDrawer />
</ProjectsContextProvider> <ProjectsEditorDrawer />
</PageBlock> </PageBlock>
</PageContainer> </PageContainer>
</BoardsContextProvider>
</ProjectsContextProvider>
); );
} }

View File

@ -1,7 +1,21 @@
@import "tailwindcss"; @import "tailwindcss";
:root {
/* Colors */
--color-light-gray-blue: #f4f7fd;
--color-light-aqua: #e0f0f4;
--color-light-white-blue: #f5fbfc;
--color-light-whitesmoke: #fcfdff;
--mantine-color-dark-7-5: #212121;
/* Shadows */
--light-shadow: 2px 2px 5px darkgray;
--light-thick-shadow: 4px 4px 10px darkgray;
--dark-shadow: 1px 1px 10px 1px var(--mantine-color-dark-6);
--dark-thick-shadow: 5px 5px 10px 1px var(--mantine-color-dark-6);
}
body { body {
@mixin light { @mixin light {
background-color: whitesmoke; background-color: var(--color-light-gray-blue);
} }
} }

View File

@ -1,5 +1,8 @@
import "@mantine/core/styles.css"; import "@mantine/core/styles.css";
import "@mantine/notifications/styles.css"; import "@mantine/notifications/styles.css";
import "swiper/css";
import "swiper/css/pagination";
import "swiper/css/scrollbar";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { import {
ColorSchemeScript, ColorSchemeScript,
@ -40,10 +43,6 @@ export default function RootLayout({ children }: Props) {
rel="shortcut icon" rel="shortcut icon"
href="/favicon.svg" href="/favicon.svg"
/> />
<link
rel="stylesheet"
href="global.css"
/>
<meta <meta
name="viewport" name="viewport"
content="minimum-scale=1, initial-scale=1, width=device-width, user-scalable=no" content="minimum-scale=1, initial-scale=1, width=device-width, user-scalable=no"

View File

@ -1,28 +0,0 @@
"use client";
import { Button, Group, useMantineColorScheme } from "@mantine/core";
import { modals } from "@mantine/modals";
export function ColorSchemeToggle() {
const { setColorScheme } = useMantineColorScheme();
const openTestModal = () => {
modals.openContextModal({
modal: "testModal",
title: "Тест",
withCloseButton: false,
innerProps: {},
});
};
return (
<Group
justify="center"
mt="xl">
<Button onClick={() => setColorScheme("light")}>Light</Button>
<Button onClick={() => setColorScheme("dark")}>Dark</Button>
<Button onClick={() => setColorScheme("auto")}>Auto</Button>
<Button onClick={() => openTestModal()}>Modal</Button>
</Group>
);
}

View File

@ -1,30 +0,0 @@
import React, { FC, ReactNode } from "react";
import { useDraggable } from "@dnd-kit/core";
type Props = {
children: ReactNode;
};
const Draggable: FC<Props> = props => {
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: "draggable",
});
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
}
: undefined;
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}>
{props.children}
</div>
);
};
export default Draggable;

View File

@ -1,25 +0,0 @@
import React, { FC, ReactNode } from "react";
import { useDroppable } from "@dnd-kit/core";
type Props = {
children: ReactNode;
}
const Droppable: FC<Props> = ({ children }) => {
const { isOver, setNodeRef } = useDroppable({
id: "droppable",
});
const style = {
color: isOver ? "green" : undefined,
};
return (
<div
ref={setNodeRef}
style={style}>
{children}
</div>
);
}
export default Droppable;

View File

@ -1,41 +0,0 @@
.container {
border-radius: rem(40);
background-color: white;
@mixin dark {
background-color: var(--mantine-color-dark-8);
box-shadow: 5px 5px 30px 1px var(--mantine-color-dark-6);
}
@mixin light {
box-shadow: 5px 5px 24px rgba(0, 0, 0, 0.16);
}
padding: rem(35);
}
.container-full-height {
min-height: calc(100vh - (rem(20) * 2));
}
.container-full-height-fixed {
height: calc(100vh - (rem(20) * 2));
}
.container-no-border-radius {
border-radius: 0 !important;
}
.container-full-screen-mobile {
@media (max-width: 48em) {
min-height: 100vh;
height: 100vh;
width: 100vw;
border-radius: 0 !important;
padding: rem(40) rem(20) rem(20);
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
overflow-y: auto;
}
}

View File

@ -1,27 +0,0 @@
import React, { CSSProperties, ReactNode, useContext } from "react";
import SortableItemContext from "@/components/SortableDnd/SortableItemContext";
type Props = {
children: ReactNode;
style?: CSSProperties;
};
const DragHandle = ({ children, style }: Props) => {
const { attributes, listeners, ref } = useContext(SortableItemContext);
return (
<div
{...attributes}
{...listeners}
style={{
width: "100%",
cursor: "grab",
...style,
}}
ref={ref}>
{children}
</div>
);
};
export default DragHandle;

View File

@ -1,130 +0,0 @@
"use client";
import React, {
CSSProperties,
ReactNode,
useEffect,
useMemo,
useState,
} from "react";
import { Active, DndContext, DragEndEvent } from "@dnd-kit/core";
import { SortableContext } from "@dnd-kit/sortable";
import { LexoRank } from "lexorank";
import { Box, Group } from "@mantine/core";
import useDndSensors from "@/app/deals/hooks/useSensors";
import { SortableItem } from "@/components/SortableDnd/SortableItem";
import { SortableOverlay } from "@/components/SortableDnd/SortableOverlay";
import { getNewLexorank, sortByLexorank } from "@/utils/lexorank";
type BaseItem = {
id: number;
lexorank: string;
};
type Props<T extends BaseItem> = {
initialItems: T[];
renderItem: (item: T) => ReactNode;
onDragEnd: (itemId: number, newLexorank: string) => void;
onItemClick: (item: T) => void;
rowStyle?: CSSProperties;
itemStyle?: CSSProperties;
};
const SortableDnd = <T extends BaseItem>({
initialItems,
renderItem,
onDragEnd,
onItemClick,
rowStyle,
itemStyle,
}: Props<T>) => {
const [active, setActive] = useState<Active | null>(null);
const [items, setItems] = useState<T[]>([]);
const activeItem = useMemo(
() => initialItems.find(item => item.id === active?.id),
[active, items]
);
useEffect(() => {
setItems(sortByLexorank(initialItems));
}, [initialItems]);
const sensors = useDndSensors();
const onDragEndLocal = ({ active, over }: DragEndEvent) => {
if (over && active.id !== over?.id && activeItem) {
const overIndex: number = items.findIndex(
({ id }) => id === over.id
);
const activeIndex: number = items.findIndex(
({ id }) => id === activeItem.id
);
let leftIndex = overIndex;
let rightIndex = overIndex + 1;
if (overIndex < activeIndex) {
leftIndex = overIndex - 1;
rightIndex = overIndex;
}
const leftLexorank: LexoRank | null =
leftIndex >= 0
? LexoRank.parse(items[leftIndex].lexorank)
: null;
const rightLexorank: LexoRank | null =
rightIndex < items.length
? LexoRank.parse(items[rightIndex].lexorank)
: null;
const newLexorank = getNewLexorank(
leftLexorank,
rightLexorank
).toString();
items[activeIndex].lexorank = newLexorank;
onDragEnd(items[activeIndex].id, newLexorank);
const sortedItems = sortByLexorank(items);
setItems([...sortedItems]);
}
setActive(null);
};
return (
<DndContext
sensors={sensors}
onDragStart={({ active }) => setActive(active)}
onDragEnd={onDragEndLocal}
onDragCancel={() => setActive(null)}>
<SortableContext items={items}>
<Group
gap={0}
style={rowStyle}
role="application">
{items.map((item, index) => (
<Box
key={index}
onClick={e => {
e.preventDefault();
e.stopPropagation();
onItemClick(item);
}}>
<SortableItem
dragHandleStyle={{ cursor: "pointer" }}
itemStyle={itemStyle}
id={item.id}>
{renderItem(item)}
</SortableItem>
</Box>
))}
</Group>
</SortableContext>
<SortableOverlay>
<div style={{ cursor: "grabbing" }}>
{activeItem ? renderItem(activeItem) : null}
</div>
</SortableOverlay>
</DndContext>
);
};
export default SortableDnd;

View File

@ -1,54 +0,0 @@
import React, { CSSProperties, PropsWithChildren, useMemo } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import DragHandle from "@/components/SortableDnd/DragHandle";
import SortableItemContext from "./SortableItemContext";
type Props = {
id: number | string;
itemStyle?: CSSProperties;
dragHandleStyle?: CSSProperties;
};
export const SortableItem = ({
children,
itemStyle,
id,
dragHandleStyle,
}: PropsWithChildren<Props>) => {
const {
attributes,
isDragging,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
} = useSortable({ id });
const context = useMemo(
() => ({
attributes,
listeners,
ref: setActivatorNodeRef,
}),
[attributes, listeners, setActivatorNodeRef]
);
const style: CSSProperties = {
opacity: isDragging ? 0.4 : undefined,
transform: CSS.Translate.toString(transform),
transition,
...itemStyle,
};
return (
<SortableItemContext.Provider value={context}>
<div
ref={setNodeRef}
style={style}>
<DragHandle style={dragHandleStyle}>{children}</DragHandle>
</div>
</SortableItemContext.Provider>
);
};

View File

@ -1,16 +0,0 @@
import type { DraggableSyntheticListeners } from "@dnd-kit/core";
import { createContext } from "react";
interface Context {
attributes: Record<string, any>;
listeners: DraggableSyntheticListeners;
ref: (node: HTMLElement | null) => void;
}
const SortableItemContext = createContext<Context>({
attributes: {},
listeners: undefined,
ref() {},
});
export default SortableItemContext;

View File

@ -1,3 +0,0 @@
import SortableDnd from "@/components/SortableDnd/SortableDnd";
export default SortableDnd;

View File

@ -0,0 +1,42 @@
import React, { ReactNode } from "react";
import { useDroppable } from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { Stack } from "@mantine/core";
import { BaseDraggable } from "@/components/dnd/types/types";
type Props<TItem> = {
id: string;
items: TItem[];
renderItem: (item: TItem) => ReactNode;
children?: ReactNode;
};
const FunnelColumn = <TItem extends BaseDraggable>({
id,
items,
renderItem,
children,
}: Props<TItem>) => {
const { setNodeRef } = useDroppable({ id });
return (
<>
{children}
<SortableContext
id={id}
items={items}
strategy={verticalListSortingStrategy}>
<Stack
gap="xs"
ref={setNodeRef}>
{items.map(renderItem)}
</Stack>
</SortableContext>
</>
);
};
export default FunnelColumn;

View File

@ -0,0 +1,27 @@
.overlay {
cursor: grabbing;
border-radius: 10px;
@media (max-width: 48em) {
@mixin dark {
box-shadow: var(--dark-shadow);
}
@mixin light {
box-shadow: var(--light-shadow);
}
}
}
.swiper-container :global(.swiper-scrollbar-drag) {
@mixin dark {
background-color: var(--mantine-color-dark-9);
}
@mixin light {
background-color: grey;
}
}
.swiper-container :global(.swiper-slide) {
padding-bottom: 20px;
}

View File

@ -0,0 +1,186 @@
"use client";
import React, { ReactNode, RefObject } from "react";
import {
DndContext,
DragEndEvent,
DragOverEvent,
DragStartEvent,
} from "@dnd-kit/core";
import {
horizontalListSortingStrategy,
SortableContext,
} from "@dnd-kit/sortable";
import { FreeMode, Mousewheel, Pagination, Scrollbar } from "swiper/modules";
import { Swiper, SwiperRef, SwiperSlide } from "swiper/react";
import CreateStatusButton from "@/app/deals/components/shared/CreateStatusButton/CreateStatusButton";
import useDndSensors from "@/app/deals/hooks/useSensors";
import FunnelColumn from "@/components/dnd/FunnelDnd/FunnelColumn";
import FunnelOverlay from "@/components/dnd/FunnelDnd/FunnelOverlay";
import { BaseDraggable } from "@/components/dnd/types/types";
import useIsMobile from "@/hooks/useIsMobile";
import SortableItem from "../SortableItem";
import classes from "./FunnelDnd.module.css";
type Props<TContainer, TItem> = {
containers: TContainer[];
items: TItem[];
onDragStart: (event: DragStartEvent) => void;
onDragOver: (event: DragOverEvent) => void;
onDragEnd: (event: DragEndEvent) => void;
swiperRef: RefObject<SwiperRef | null>;
renderContainer: (
container: TContainer,
children: ReactNode,
renderDraggable: () => ReactNode
) => ReactNode;
renderContainerHeader: (container: TContainer) => ReactNode;
renderContainerOverlay: (
container: TContainer,
children: ReactNode
) => ReactNode;
renderItem: (item: TItem) => ReactNode;
renderItemOverlay: (item: TItem) => ReactNode;
getContainerId: (container: TContainer) => string;
getItemsByContainer: (container: TContainer, items: TItem[]) => TItem[];
activeContainer: TContainer | null;
activeItem: TItem | null;
isCreatingContainerEnabled?: boolean;
disabledColumns?: boolean;
};
const FunnelDnd = <
TContainer extends BaseDraggable,
TItem extends BaseDraggable,
>({
containers,
items,
onDragStart,
onDragOver,
onDragEnd,
swiperRef,
renderContainer,
renderContainerHeader,
renderContainerOverlay,
renderItem,
renderItemOverlay,
getContainerId,
getItemsByContainer,
activeContainer,
activeItem,
isCreatingContainerEnabled = true,
disabledColumns = false,
}: Props<TContainer, TItem>) => {
const sensors = useDndSensors();
const isMobile = useIsMobile();
const renderContainers = () =>
containers.map(container => {
const containerItems = getItemsByContainer(container, items);
const containerId = getContainerId(container);
return (
<SwiperSlide
style={{ width: 250 }}
key={containerId}>
<SortableItem
key={containerId}
id={containerId}
disabled={disabledColumns}
renderItem={renderDraggable =>
renderContainer(
container,
<FunnelColumn
id={containerId}
items={containerItems}
renderItem={renderItem}
/>,
renderDraggable!
)
}
renderDraggable={() => renderContainerHeader(container)}
/>
</SwiperSlide>
);
});
const renderBody = () => {
if (isMobile) {
return (
<Swiper
ref={swiperRef}
className={classes["swiper-container"]}
slidesPerView={1.1}
style={{ paddingLeft: "10vw", paddingRight: "2vw" }}
modules={[Pagination]}
freeMode={{ enabled: false }}
pagination={{ enabled: true, clickable: true }}>
{renderContainers()}
{isCreatingContainerEnabled && (
<SwiperSlide>
<CreateStatusButton />
</SwiperSlide>
)}
</Swiper>
);
}
return (
<Swiper
ref={swiperRef}
className={classes["swiper-container"]}
modules={[Scrollbar, Mousewheel, FreeMode]}
spaceBetween={15}
slidesPerView={"auto"}
scrollbar={{ hide: false }}
mousewheel={{
enabled: true,
sensitivity: 0.2,
releaseOnEdges: true,
}}
freeMode={{ enabled: true }}
grabCursor>
{renderContainers()}
{isCreatingContainerEnabled && (
<SwiperSlide style={{ width: 50 }}>
<CreateStatusButton />
</SwiperSlide>
)}
</Swiper>
);
};
return (
<DndContext
sensors={sensors}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}>
<SortableContext
items={containers.map(getContainerId)}
strategy={horizontalListSortingStrategy}>
{renderBody()}
<FunnelOverlay
activeContainer={activeContainer}
activeItem={activeItem}
renderContainer={container => {
const containerItems = getItemsByContainer(
container,
items
);
const containerId = getContainerId(container);
return renderContainerOverlay(
container,
<FunnelColumn
id={containerId}
items={containerItems}
renderItem={renderItem}
/>
);
}}
renderItem={renderItemOverlay}
/>
</SortableContext>
</DndContext>
);
};
export default FunnelDnd;

View File

@ -0,0 +1,31 @@
import React, { ReactNode } from "react";
import { defaultDropAnimation, DragOverlay } from "@dnd-kit/core";
import styles from "@/components/dnd/FunnelDnd/FunnelDnd.module.css";
type Props<TContainer, TItem> = {
activeContainer: TContainer | null;
activeItem: TItem | null;
renderContainer: (container: TContainer) => ReactNode;
renderItem: (item: TItem) => ReactNode;
};
const FunnelOverlay = <TContainer, TItem>({
activeContainer,
activeItem,
renderContainer,
renderItem,
}: Props<TContainer, TItem>) => {
return (
<DragOverlay dropAnimation={defaultDropAnimation}>
<div className={styles.overlay}>
{activeItem
? renderItem(activeItem)
: activeContainer
? renderContainer(activeContainer)
: null}
</div>
</DragOverlay>
);
};
export default FunnelOverlay;

View File

@ -0,0 +1,3 @@
import FunnelDnd from "./FunnelDnd";
export default FunnelDnd;

View File

@ -0,0 +1,9 @@
.swiper-container :global(.swiper-scrollbar-drag) {
@mixin dark {
background-color: var(--mantine-color-dark-9);
}
@mixin light {
background-color: grey;
}
}

View File

@ -0,0 +1,201 @@
"use client";
import React, {
CSSProperties,
ReactNode,
useEffect,
useMemo,
useState,
} from "react";
import { Active, DndContext, DragEndEvent } from "@dnd-kit/core";
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
import { SortableContext } from "@dnd-kit/sortable";
import { LexoRank } from "lexorank";
import { FreeMode, Mousewheel, Scrollbar } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import { Box, Flex } from "@mantine/core";
import useDndSensors from "@/app/deals/hooks/useSensors";
import { SortableOverlay } from "@/components/dnd/SortableDnd/SortableOverlay";
import SortableItem from "@/components/dnd/SortableItem";
import { getNewLexorank, sortByLexorank } from "@/utils/lexorank";
import classes from "./SortableDnd.module.css";
type BaseItem = {
id: number;
lexorank: string;
};
type Props<T extends BaseItem> = {
initialItems: T[];
renderItem: (
item: T,
renderDraggable?: (item: T) => ReactNode
) => ReactNode;
renderDraggable?: (item: T) => ReactNode; // if not passed - the whole item renders as draggable
dragHandleStyle?: CSSProperties;
onDragEnd: (itemId: number, newLexorank: string) => void;
onItemClick?: (item: T) => void;
containerStyle?: CSSProperties;
vertical?: boolean;
disabled?: boolean;
swiperEnabled?: boolean;
};
const SortableDnd = <T extends BaseItem>({
initialItems,
renderItem,
renderDraggable,
dragHandleStyle,
onDragEnd,
onItemClick,
containerStyle,
vertical = false,
disabled = false,
swiperEnabled = false,
}: Props<T>) => {
const [active, setActive] = useState<Active | null>(null);
const [items, setItems] = useState<T[]>([]);
const activeItem = useMemo(
() => initialItems.find(item => item.id === active?.id),
[active, items]
);
useEffect(() => {
setItems(sortByLexorank(initialItems));
}, [initialItems]);
const sensors = useDndSensors();
const onDragEndLocal = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over?.id || !activeItem) {
setActive(null);
return;
}
const overIndex: number = items.findIndex(({ id }) => id === over.id);
const activeIndex: number = items.findIndex(
({ id }) => id === activeItem.id
);
let leftIndex = overIndex;
let rightIndex = overIndex + 1;
if (overIndex < activeIndex) {
leftIndex = overIndex - 1;
rightIndex = overIndex;
}
const leftLexorank: LexoRank | null =
leftIndex >= 0 ? LexoRank.parse(items[leftIndex].lexorank) : null;
const rightLexorank: LexoRank | null =
rightIndex < items.length
? LexoRank.parse(items[rightIndex].lexorank)
: null;
const newLexorank = getNewLexorank(
leftLexorank,
rightLexorank
).toString();
items[activeIndex].lexorank = newLexorank;
onDragEnd(items[activeIndex].id, newLexorank);
const sortedItems = sortByLexorank(items);
setItems([...sortedItems]);
setActive(null);
};
const renderWithSwiper = () => (
<Swiper
modules={[Scrollbar, Mousewheel, FreeMode]}
spaceBetween={15}
slidesPerView={"auto"}
scrollbar={{ hide: false }}
mousewheel={{
enabled: true,
sensitivity: 0.2,
}}
className={classes["swiper-container"]}
style={containerStyle}
direction={vertical ? "vertical" : "horizontal"}
freeMode={{ enabled: true }}
grabCursor>
{items.map((item, index) => (
<SwiperSlide
style={{ width: "fit-content" }}
key={index}
onClick={e => {
if (!onItemClick) return;
e.stopPropagation();
onItemClick(item);
}}>
<SortableItem
id={item.id}
disabled={disabled}
renderItem={renderDraggable =>
renderItem(item, renderDraggable)
}
renderDraggable={
renderDraggable
? () => renderDraggable(item)
: undefined
}
dragHandleStyle={dragHandleStyle}
/>
</SwiperSlide>
))}
</Swiper>
);
const renderWithFlex = () => (
<Flex
style={{
gap: 0,
flexWrap: "nowrap",
flexDirection: vertical ? "column" : "row",
...containerStyle,
}}>
{items.map((item, index) => (
<Box
key={index}
onClick={e => {
if (!onItemClick) return;
e.stopPropagation();
onItemClick(item);
}}>
<SortableItem
id={item.id}
disabled={disabled}
renderItem={renderDraggable =>
renderItem(item, renderDraggable)
}
renderDraggable={
renderDraggable
? () => renderDraggable(item)
: undefined
}
dragHandleStyle={dragHandleStyle}
/>
</Box>
))}
</Flex>
);
return (
<DndContext
modifiers={[restrictToHorizontalAxis]}
sensors={sensors}
onDragStart={({ active }) => setActive(active)}
onDragEnd={onDragEndLocal}
onDragCancel={() => setActive(null)}>
<SortableContext
items={items}
disabled={disabled}>
{swiperEnabled ? renderWithSwiper() : renderWithFlex()}
</SortableContext>
<SortableOverlay>
{activeItem ? renderItem(activeItem, renderDraggable) : null}
</SortableOverlay>
</DndContext>
);
};
export default SortableDnd;

View File

@ -18,7 +18,7 @@ const dropAnimationConfig: DropAnimation = {
export function SortableOverlay({ children }: PropsWithChildren) { export function SortableOverlay({ children }: PropsWithChildren) {
return ( return (
<DragOverlay dropAnimation={dropAnimationConfig}> <DragOverlay dropAnimation={dropAnimationConfig}>
{children} <div style={{ cursor: "grabbing" }}>{children}</div>
</DragOverlay> </DragOverlay>
); );
} }

View File

@ -0,0 +1,3 @@
import SortableDnd from "@/components/dnd/SortableDnd/SortableDnd";
export default SortableDnd;

View File

@ -0,0 +1,34 @@
import React, { CSSProperties, ReactNode } from "react";
import { useDraggable } from "@dnd-kit/core";
type Props = {
id: number | string;
children: ReactNode;
style?: CSSProperties;
disabled?: boolean;
};
const DragHandle = ({ id, children, style, disabled }: Props) => {
const { attributes, listeners, setNodeRef } = useDraggable({
id,
disabled,
});
return (
<div
{...attributes}
{...listeners}
style={{
width: "100wv",
cursor: disabled ? "default" : "grab",
touchAction: "auto",
...style,
}}
className={disabled ? "" : "swiper-no-swiping"}
ref={setNodeRef}>
{children}
</div>
);
};
export default DragHandle;

View File

@ -0,0 +1,59 @@
import React, { CSSProperties, ReactNode } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import DragHandle from "./DragHandle";
type Props = {
id: number | string;
renderItem: (renderDraggable?: () => ReactNode) => ReactNode;
renderDraggable?: () => ReactNode; // if not passed - the whole item renders as draggable
disabled?: boolean;
dragHandleStyle?: CSSProperties;
};
const SortableItem = ({
renderItem,
dragHandleStyle,
renderDraggable,
id,
disabled = false,
}: Props) => {
const { isDragging, setNodeRef, transform, transition } = useSortable({
id,
animateLayoutChanges: () => false,
});
const style: CSSProperties = {
opacity: isDragging ? 0.4 : undefined,
transform: CSS.Translate.toString(transform),
transition,
};
const renderDragHandle = () => (
<DragHandle
id={id}
style={dragHandleStyle}
disabled={disabled}>
{renderDraggable && renderDraggable()}
</DragHandle>
);
return (
<div
ref={setNodeRef}
style={style}>
{renderDraggable ? (
renderItem(renderDragHandle)
) : (
<DragHandle
id={id}
style={dragHandleStyle}
disabled={disabled}>
{renderItem()}
</DragHandle>
)}
</div>
);
};
export default SortableItem;

View File

@ -0,0 +1,3 @@
import SortableItem from "./SortableItem";
export default SortableItem;

View File

@ -0,0 +1,3 @@
export type BaseDraggable = {
id: number;
};

View File

@ -0,0 +1,58 @@
.container {
@mixin dark {
background-color: var(--mantine-color-dark-8);
}
@mixin light {
background-color: white;
}
@media (min-width: 48em) {
padding: var(--mantine-spacing-md);
@mixin dark {
box-shadow: var(--dark-thick-shadow);
}
@mixin light {
box-shadow: var(--light-thick-shadow);
}
}
}
.mobile-padding-height {
height: 100vh;
}
.container-full-height {
min-height: calc(100vh - (var(--mantine-spacing-md) * 2));
}
.container-full-height-fixed {
height: calc(100vh - (var(--mantine-spacing-md) * 2));
}
.container-no-border-radius {
border-radius: 0 !important;
}
.container-full-screen-mobile {
@media (max-width: 48em) {
min-height: 100vh;
height: 100vh;
width: 100vw;
border-radius: 0 !important;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
overflow-y: auto;
}
}
.transparent {
@media (min-width: 48em) {
box-shadow: none !important;
background-color: transparent !important;
}
}

View File

@ -1,36 +1,44 @@
import { CSSProperties, FC, ReactNode } from "react"; import { CSSProperties, FC, ReactNode } from "react";
import classNames from "classnames"; import classNames from "classnames";
import styles from "./PageBlock.module.css"; import styles from "./PageBlock.module.css";
5 import { Box } from "@mantine/core";
type Props = { type Props = {
children: ReactNode; children: ReactNode;
style?: CSSProperties; style?: CSSProperties;
className?: string;
fullHeight?: boolean; fullHeight?: boolean;
fullHeightFixed?: boolean; fullHeightFixed?: boolean;
noBorderRadius?: boolean; noBorderRadius?: boolean;
fullScreenMobile?: boolean; fullScreenMobile?: boolean;
transparent?: boolean;
}; };
const PageBlock: FC<Props> = ({ const PageBlock: FC<Props> = ({
children, children,
style, style,
className = "",
fullHeight = false, fullHeight = false,
fullHeightFixed = false, fullHeightFixed = false,
noBorderRadius = false, noBorderRadius = false,
fullScreenMobile = false, fullScreenMobile = false,
transparent = false,
}) => { }) => {
return ( return (
<div <Box
bdrs={"lg"}
style={style} style={style}
className={classNames( className={classNames(
styles.container, styles.container,
fullHeight && styles["container-full-height"], fullHeight && styles["container-full-height"],
fullHeightFixed && styles["container-full-height-fixed"], fullHeightFixed && styles["container-full-height-fixed"],
noBorderRadius && styles["container-no-border-radius"], noBorderRadius && styles["container-no-border-radius"],
fullScreenMobile && styles["container-full-screen-mobile"] fullScreenMobile && styles["container-full-screen-mobile"],
transparent && styles.transparent,
styles[className]
)}> )}>
{children} {children}
</div> </Box>
); );
}; };
export default PageBlock; export default PageBlock;

View File

@ -1,7 +1,10 @@
.container { .container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: rem(10); min-height: 100vh;
min-height: 86vh;
background-color: transparent; background-color: transparent;
@media (min-width: 48em) {
gap: rem(10);
}
} }

View File

@ -0,0 +1,26 @@
.container {
@mixin dark {
background-color: var(--mantine-color-dark-7);
}
}
.icon {
width: rem(18px);
height: rem(18px);
}
.light {
display: block;
}
.dark {
display: none;
}
:global([data-mantine-color-scheme='dark']) .light {
display: none;
}
:global([data-mantine-color-scheme='dark']) .dark {
display: block;
}

View File

@ -0,0 +1,40 @@
"use client";
import { IconMoon, IconSun } from "@tabler/icons-react";
import classNames from "classnames";
import {
ActionIcon,
useComputedColorScheme,
useMantineColorScheme,
} from "@mantine/core";
import style from "./ColorSchemeToggle.module.css";
export function ColorSchemeToggle() {
const { setColorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme(undefined, {
getInitialValueInEffect: true,
});
const toggleColorScheme = () => {
setColorScheme(computedColorScheme === "light" ? "dark" : "light");
};
return (
<ActionIcon
onClick={toggleColorScheme}
variant="default"
size="xl"
radius="lg"
aria-label="Toggle color scheme"
className={style.container}>
<IconSun
className={classNames(style.icon, style.light)}
stroke={1.5}
/>
<IconMoon
className={classNames(style.icon, style.dark)}
stroke={1.5}
/>
</ActionIcon>
);
}

View File

@ -0,0 +1,26 @@
import React, { FC, ReactNode } from "react";
import { Styles } from "@mantine/core/lib/core/styles-api/styles-api.types";
import useIsMobile from "@/hooks/useIsMobile";
import InPlaceInputDesktop from "./InPlaceInputDesktop";
import InPlaceInputMobile from "./InPlaceInputMobile";
type Props = {
defaultValue?: string;
onComplete: (value: string) => void;
placeholder?: string;
getChildren: (startEditing: () => void) => ReactNode;
inputStyles?: Styles<any>;
modalTitle?: string;
};
const InPlaceInput: FC<Props> = (props) => {
const isMobile = useIsMobile();
if (isMobile) {
return <InPlaceInputMobile {...props} />;
}
return <InPlaceInputDesktop {...props} />;
};
export default InPlaceInput;

View File

@ -0,0 +1,86 @@
import React, { FC, ReactNode, useEffect, useRef, useState } from "react";
import { TextInput } from "@mantine/core";
import { Styles } from "@mantine/core/lib/core/styles-api/styles-api.types";
type Props = {
defaultValue?: string;
onComplete: (value: string) => void;
placeholder?: string;
getChildren: (startEditing: () => void) => ReactNode;
inputStyles?: Styles<any>;
};
const InPlaceInputDesktop: FC<Props> = ({
onComplete,
placeholder,
inputStyles,
getChildren,
defaultValue = "",
}) => {
const [isWriting, setIsWriting] = useState<boolean>(false);
const [value, setValue] = useState<string>(defaultValue);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isWriting && inputRef.current) {
inputRef.current.focus();
}
}, [isWriting]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
isWriting &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
onCompleteCreating();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () =>
document.removeEventListener("mousedown", handleClickOutside);
}, [isWriting, value]);
const onStartCreating = () => {
setValue(defaultValue);
setIsWriting(true);
};
const onCancelCreating = () => {
setValue(defaultValue);
setIsWriting(false);
};
const onCompleteCreating = () => {
const localValue = value.trim();
if (localValue) {
onComplete(localValue);
}
setIsWriting(false);
};
if (isWriting) {
return (
<TextInput
ref={inputRef}
placeholder={placeholder}
variant={"unstyled"}
value={value}
onChange={e => setValue(e.target.value)}
onKeyDown={e => {
e.stopPropagation();
if (e.key === "Enter") onCompleteCreating();
if (e.key === "Escape") onCancelCreating();
}}
styles={inputStyles}
miw={150}
/>
);
}
return getChildren(onStartCreating);
};
export default InPlaceInputDesktop;

View File

@ -0,0 +1,32 @@
import { FC, ReactNode } from "react";
import { modals } from "@mantine/modals";
type Props = {
defaultValue?: string;
onComplete: (value: string) => void;
getChildren: (startEditing: () => void) => ReactNode;
modalTitle?: string;
};
const InPlaceInputMobile: FC<Props> = ({
onComplete,
getChildren,
modalTitle = "",
defaultValue = "",
}) => {
const onStartCreating = () => {
modals.openContextModal({
modal: "enterNameModal",
title: modalTitle,
withCloseButton: true,
innerProps: {
onComplete,
defaultValue,
},
});
};
return getChildren(onStartCreating);
};
export default InPlaceInputMobile;

View File

@ -2,5 +2,5 @@ import type { CreateClientConfig } from "@/lib/client/client.gen";
export const createClientConfig: CreateClientConfig = config => ({ export const createClientConfig: CreateClientConfig = config => ({
...config, ...config,
baseUrl: "http://crm.logidex.ru/api", baseURL: process.env.NEXT_PUBLIC_API_URL,
}); });

View File

@ -0,0 +1,128 @@
import React from "react";
import { useMutation } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { LexoRank } from "lexorank";
import { Text } from "@mantine/core";
import { modals } from "@mantine/modals";
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;
};
type BoardsOperations = {
onCreateBoard: (name: string) => void;
onUpdateBoard: (boardId: number, board: UpdateBoardSchema) => void;
onDeleteBoard: (board: BoardSchema) => void;
};
export const useBoardsOperations = ({
boards,
setBoards,
refetchBoards,
projectId,
}: UseBoardsOperationsProps): BoardsOperations => {
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 = (board: BoardSchema) => {
modals.openConfirmModal({
title: "Удаление доски",
children: (
<Text>
Вы уверены, что хотите удалить доску "{board.name}"?
</Text>
),
labels: { confirm: "Да", cancel: "Нет" },
confirmProps: { color: "red" },
onConfirm: () => {
deleteBoard.mutate({
path: { boardId: board.id },
});
setBoards(boards => boards.filter(b => b.id !== board.id));
},
});
};
return {
onCreateBoard,
onUpdateBoard,
onDeleteBoard,
};
};

7
src/hooks/useIsMobile.ts Normal file
View File

@ -0,0 +1,7 @@
import { useMediaQuery } from "@mantine/hooks";
const useIsMobile = (): boolean => {
return useMediaQuery("(max-width: 48em)");
};
export default useIsMobile;

View File

@ -1,12 +1,22 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ProjectSchema } from "@/lib/client";
import { getProjectsOptions } from "@/lib/client/@tanstack/react-query.gen"; import { getProjectsOptions } from "@/lib/client/@tanstack/react-query.gen";
const useProjectsList = () => { const useProjectsList = () => {
const [projects, setProjects] = useState<ProjectSchema[]>([]);
const { data, refetch, isLoading } = useQuery({ const { data, refetch, isLoading } = useQuery({
...getProjectsOptions(), ...getProjectsOptions(),
}); });
const projects = !data ? [] : data.projects;
return { projects, refetch, isLoading }; useEffect(() => {
if (data?.projects) {
setProjects(data.projects);
}
}, [data?.projects]);
return { projects, setProjects, refetch, isLoading };
}; };
export default useProjectsList; export default useProjectsList;

View File

@ -0,0 +1,118 @@
import React from "react";
import { useMutation } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { Text } from "@mantine/core";
import { modals } from "@mantine/modals";
import {
HttpValidationError,
ProjectSchema,
UpdateProjectSchema,
} from "@/lib/client";
import {
createProjectMutation,
deleteProjectMutation,
updateProjectMutation,
} from "@/lib/client/@tanstack/react-query.gen";
import { notifications } from "@/lib/notifications";
type Props = {
projects: ProjectSchema[];
setProjects: React.Dispatch<React.SetStateAction<ProjectSchema[]>>;
refetchProjects: () => void;
};
type ProjectsOperations = {
onCreateProject: (name: string) => void;
onUpdateProject: (projectId: number, project: UpdateProjectSchema) => void;
onDeleteProject: (project: ProjectSchema) => void;
};
export const useProjectsOperations = ({
projects,
setProjects,
refetchProjects,
}: Props): ProjectsOperations => {
const onError = (error: AxiosError<HttpValidationError>) => {
console.error(error);
notifications.error({
message: error.response?.data?.detail as string | undefined,
});
refetchProjects();
};
const createProject = useMutation({
...createProjectMutation(),
onError,
onSuccess: res => {
setProjects([...projects, res.project]);
},
});
const updateProject = useMutation({
...updateProjectMutation(),
onError,
});
const deleteProject = useMutation({
...deleteProjectMutation(),
onError,
});
const onCreateProject = (name: string) => {
createProject.mutate({
body: {
project: {
name,
},
},
});
};
const onUpdateProject = (
projectId: number,
project: UpdateProjectSchema
) => {
updateProject.mutate({
query: { projectId },
body: { project },
});
setProjects(boards =>
boards.map(oldProject =>
oldProject.id !== projectId
? oldProject
: {
id: oldProject.id,
name: project.name ? project.name : oldProject.name,
}
)
);
};
const onDeleteProject = (project: ProjectSchema) => {
modals.openConfirmModal({
title: "Удаление проекта",
children: (
<Text>
Вы уверены, что хотите удалить проект "{project.name}"?
</Text>
),
labels: { confirm: "Да", cancel: "Нет" },
confirmProps: { color: "red" },
onConfirm: () => {
deleteProject.mutate({
query: { projectId: project.id },
});
setProjects(projects =>
projects.filter(p => p.id !== project.id)
);
},
});
};
return {
onCreateProject,
onUpdateProject,
onDeleteProject,
};
};

View File

@ -0,0 +1,130 @@
import React from "react";
import { useMutation } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { LexoRank } from "lexorank";
import { Text } from "@mantine/core";
import { modals } from "@mantine/modals";
import {
HttpValidationError,
StatusSchema,
UpdateStatusSchema,
} from "@/lib/client";
import {
createStatusMutation,
deleteStatusMutation,
updateStatusMutation,
} from "@/lib/client/@tanstack/react-query.gen";
import { notifications } from "@/lib/notifications";
import { getMaxByLexorank, getNewLexorank } from "@/utils/lexorank";
type Props = {
statuses: StatusSchema[];
setStatuses: React.Dispatch<React.SetStateAction<StatusSchema[]>>;
refetchStatuses: () => void;
boardId?: number;
};
type StatusesOperations = {
onCreateStatus: (name: string) => void;
onUpdateStatus: (statusId: number, status: UpdateStatusSchema) => void;
onDeleteStatus: (status: StatusSchema) => void;
};
export const useStatusesOperations = ({
statuses,
setStatuses,
refetchStatuses,
boardId,
}: Props): StatusesOperations => {
const onError = (error: AxiosError<HttpValidationError>) => {
console.error(error);
notifications.error({
message: error.response?.data?.detail as string | undefined,
});
refetchStatuses();
};
const createStatus = useMutation({
...createStatusMutation(),
onError,
onSuccess: res => {
setStatuses([...statuses, res.status]);
},
});
const updateStatus = useMutation({
...updateStatusMutation(),
onError,
});
const deleteStatus = useMutation({
...deleteStatusMutation(),
onError,
});
const onCreateStatus = (name: string) => {
if (!boardId) return;
const lastStatus = getMaxByLexorank(statuses);
const newLexorank = getNewLexorank(
lastStatus ? LexoRank.parse(lastStatus.lexorank) : null
);
createStatus.mutate({
body: {
status: {
name,
boardId,
lexorank: newLexorank.toString(),
},
},
});
};
const onUpdateStatus = (statusId: number, status: UpdateStatusSchema) => {
updateStatus.mutate({
path: { statusId },
body: { status },
});
setStatuses(statuses =>
statuses.map(oldStatus =>
oldStatus.id !== statusId
? oldStatus
: {
id: oldStatus.id,
name: status.name ? status.name : oldStatus.name,
lexorank: status.lexorank
? status.lexorank
: oldStatus.lexorank,
}
)
);
};
const onDeleteStatus = (status: StatusSchema) => {
modals.openConfirmModal({
title: "Удаление колонки",
children: (
<Text>
Вы уверены, что хотите удалить колонку "{status.name}"?
</Text>
),
labels: { confirm: "Да", cancel: "Нет" },
confirmProps: { color: "red" },
onConfirm: () => {
deleteStatus.mutate({
path: { statusId: status.id },
});
setStatuses(statuses =>
statuses.filter(s => s.id !== status.id)
);
},
});
};
return {
onCreateStatus,
onUpdateStatus,
onDeleteStatus,
};
};

View File

@ -1,22 +1,80 @@
// 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 { queryOptions, type UseMutationOptions } from "@tanstack/react-query";
import { queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import type { AxiosError } from "axios";
import type { GetBoardsData, UpdateBoardData, UpdateBoardError, UpdateBoardResponse2, GetDealsData, UpdateDealData, UpdateDealError, UpdateDealResponse2, GetProjectsData, GetStatusesData, UpdateStatusData, UpdateStatusError, UpdateStatusResponse2 } from '../types.gen'; import { client as _heyApiClient } from "../client.gen";
import type { AxiosError } from 'axios'; import {
import { client as _heyApiClient } from '../client.gen'; createBoard,
createProject,
createStatus,
deleteBoard,
deleteProject,
deleteStatus,
getBoards,
getDeals,
getProjects,
getStatuses,
updateBoard,
updateDeal,
updateProject,
updateStatus,
type Options,
} from "../sdk.gen";
import type {
CreateBoardData,
CreateBoardError,
CreateBoardResponse2,
CreateProjectData,
CreateProjectError,
CreateProjectResponse2,
CreateStatusData,
CreateStatusError,
CreateStatusResponse2,
DeleteBoardData,
DeleteBoardError,
DeleteBoardResponse2,
DeleteProjectData,
DeleteProjectError,
DeleteProjectResponse2,
DeleteStatusData,
DeleteStatusError,
DeleteStatusResponse2,
GetBoardsData,
GetDealsData,
GetProjectsData,
GetStatusesData,
UpdateBoardData,
UpdateBoardError,
UpdateBoardResponse2,
UpdateDealData,
UpdateDealError,
UpdateDealResponse2,
UpdateProjectData,
UpdateProjectError,
UpdateProjectResponse2,
UpdateStatusData,
UpdateStatusError,
UpdateStatusResponse2,
} from "../types.gen";
export type QueryKey<TOptions extends Options> = [ export type QueryKey<TOptions extends Options> = [
Pick<TOptions, 'baseURL' | 'body' | 'headers' | 'path' | 'query'> & { Pick<TOptions, "baseURL" | "body" | "headers" | "path" | "query"> & {
_id: string; _id: string;
_infinite?: boolean; _infinite?: boolean;
} },
]; ];
const createQueryKey = <TOptions extends Options>(id: string, options?: TOptions, infinite?: boolean): [ const createQueryKey = <TOptions extends Options>(
QueryKey<TOptions>[0] id: string,
] => { options?: TOptions,
const params: QueryKey<TOptions>[0] = { _id: id, baseURL: options?.baseURL || (options?.client ?? _heyApiClient).getConfig().baseURL } as QueryKey<TOptions>[0]; infinite?: boolean
): [QueryKey<TOptions>[0]] => {
const params: QueryKey<TOptions>[0] = {
_id: id,
baseURL:
options?.baseURL ||
(options?.client ?? _heyApiClient).getConfig().baseURL,
} as QueryKey<TOptions>[0];
if (infinite) { if (infinite) {
params._infinite = infinite; params._infinite = infinite;
} }
@ -32,12 +90,11 @@ const createQueryKey = <TOptions extends Options>(id: string, options?: TOptions
if (options?.query) { if (options?.query) {
params.query = options.query; params.query = options.query;
} }
return [ return [params];
params
];
}; };
export const getBoardsQueryKey = (options: Options<GetBoardsData>) => createQueryKey('getBoards', options); export const getBoardsQueryKey = (options: Options<GetBoardsData>) =>
createQueryKey("getBoards", options);
/** /**
* Get Boards * Get Boards
@ -49,32 +106,118 @@ export const getBoardsOptions = (options: Options<GetBoardsData>) => {
...options, ...options,
...queryKey[0], ...queryKey[0],
signal, signal,
throwOnError: true throwOnError: true,
}); });
return data; return data;
}, },
queryKey: getBoardsQueryKey(options) queryKey: getBoardsQueryKey(options),
}); });
}; };
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;
};
/**
* Delete Board
*/
export const deleteBoardMutation = (
options?: Partial<Options<DeleteBoardData>>
): UseMutationOptions<
DeleteBoardResponse2,
AxiosError<DeleteBoardError>,
Options<DeleteBoardData>
> => {
const mutationOptions: UseMutationOptions<
DeleteBoardResponse2,
AxiosError<DeleteBoardError>,
Options<DeleteBoardData>
> = {
mutationFn: async localOptions => {
const { data } = await deleteBoard({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
/** /**
* Update Board * Update Board
*/ */
export const updateBoardMutation = (options?: Partial<Options<UpdateBoardData>>): UseMutationOptions<UpdateBoardResponse2, AxiosError<UpdateBoardError>, Options<UpdateBoardData>> => { export const updateBoardMutation = (
const mutationOptions: UseMutationOptions<UpdateBoardResponse2, AxiosError<UpdateBoardError>, Options<UpdateBoardData>> = { options?: Partial<Options<UpdateBoardData>>
mutationFn: async (localOptions) => { ): UseMutationOptions<
UpdateBoardResponse2,
AxiosError<UpdateBoardError>,
Options<UpdateBoardData>
> => {
const mutationOptions: UseMutationOptions<
UpdateBoardResponse2,
AxiosError<UpdateBoardError>,
Options<UpdateBoardData>
> = {
mutationFn: async localOptions => {
const { data } = await updateBoard({ const { data } = await updateBoard({
...options, ...options,
...localOptions, ...localOptions,
throwOnError: true throwOnError: true,
}); });
return data; return data;
} },
}; };
return mutationOptions; return mutationOptions;
}; };
export const getDealsQueryKey = (options: Options<GetDealsData>) => createQueryKey('getDeals', options); export const getDealsQueryKey = (options: Options<GetDealsData>) =>
createQueryKey("getDeals", options);
/** /**
* Get Deals * Get Deals
@ -86,32 +229,43 @@ export const getDealsOptions = (options: Options<GetDealsData>) => {
...options, ...options,
...queryKey[0], ...queryKey[0],
signal, signal,
throwOnError: true throwOnError: true,
}); });
return data; return data;
}, },
queryKey: getDealsQueryKey(options) queryKey: getDealsQueryKey(options),
}); });
}; };
/** /**
* Update Deal * Update Deal
*/ */
export const updateDealMutation = (options?: Partial<Options<UpdateDealData>>): UseMutationOptions<UpdateDealResponse2, AxiosError<UpdateDealError>, Options<UpdateDealData>> => { export const updateDealMutation = (
const mutationOptions: UseMutationOptions<UpdateDealResponse2, AxiosError<UpdateDealError>, Options<UpdateDealData>> = { options?: Partial<Options<UpdateDealData>>
mutationFn: async (localOptions) => { ): UseMutationOptions<
UpdateDealResponse2,
AxiosError<UpdateDealError>,
Options<UpdateDealData>
> => {
const mutationOptions: UseMutationOptions<
UpdateDealResponse2,
AxiosError<UpdateDealError>,
Options<UpdateDealData>
> = {
mutationFn: async localOptions => {
const { data } = await updateDeal({ const { data } = await updateDeal({
...options, ...options,
...localOptions, ...localOptions,
throwOnError: true throwOnError: true,
}); });
return data; return data;
} },
}; };
return mutationOptions; return mutationOptions;
}; };
export const getProjectsQueryKey = (options?: Options<GetProjectsData>) => createQueryKey('getProjects', options); export const getProjectsQueryKey = (options?: Options<GetProjectsData>) =>
createQueryKey("getProjects", options);
/** /**
* Get Projects * Get Projects
@ -123,15 +277,118 @@ export const getProjectsOptions = (options?: Options<GetProjectsData>) => {
...options, ...options,
...queryKey[0], ...queryKey[0],
signal, signal,
throwOnError: true throwOnError: true,
}); });
return data; return data;
}, },
queryKey: getProjectsQueryKey(options) queryKey: getProjectsQueryKey(options),
}); });
}; };
export const getStatusesQueryKey = (options: Options<GetStatusesData>) => createQueryKey('getStatuses', options); export const createProjectQueryKey = (options: Options<CreateProjectData>) =>
createQueryKey("createProject", options);
/**
* Create Project
*/
export const createProjectOptions = (options: Options<CreateProjectData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await createProject({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: createProjectQueryKey(options),
});
};
/**
* Create Project
*/
export const createProjectMutation = (
options?: Partial<Options<CreateProjectData>>
): UseMutationOptions<
CreateProjectResponse2,
AxiosError<CreateProjectError>,
Options<CreateProjectData>
> => {
const mutationOptions: UseMutationOptions<
CreateProjectResponse2,
AxiosError<CreateProjectError>,
Options<CreateProjectData>
> = {
mutationFn: async localOptions => {
const { data } = await createProject({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
/**
* Delete Project
*/
export const deleteProjectMutation = (
options?: Partial<Options<DeleteProjectData>>
): UseMutationOptions<
DeleteProjectResponse2,
AxiosError<DeleteProjectError>,
Options<DeleteProjectData>
> => {
const mutationOptions: UseMutationOptions<
DeleteProjectResponse2,
AxiosError<DeleteProjectError>,
Options<DeleteProjectData>
> = {
mutationFn: async localOptions => {
const { data } = await deleteProject({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
/**
* Update Project
*/
export const updateProjectMutation = (
options?: Partial<Options<UpdateProjectData>>
): UseMutationOptions<
UpdateProjectResponse2,
AxiosError<UpdateProjectError>,
Options<UpdateProjectData>
> => {
const mutationOptions: UseMutationOptions<
UpdateProjectResponse2,
AxiosError<UpdateProjectError>,
Options<UpdateProjectData>
> = {
mutationFn: async localOptions => {
const { data } = await updateProject({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
export const getStatusesQueryKey = (options: Options<GetStatusesData>) =>
createQueryKey("getStatuses", options);
/** /**
* Get Statuses * Get Statuses
@ -143,27 +400,112 @@ export const getStatusesOptions = (options: Options<GetStatusesData>) => {
...options, ...options,
...queryKey[0], ...queryKey[0],
signal, signal,
throwOnError: true throwOnError: true,
}); });
return data; return data;
}, },
queryKey: getStatusesQueryKey(options) queryKey: getStatusesQueryKey(options),
}); });
}; };
export const createStatusQueryKey = (options: Options<CreateStatusData>) =>
createQueryKey("createStatus", options);
/**
* Create Status
*/
export const createStatusOptions = (options: Options<CreateStatusData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await createStatus({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: createStatusQueryKey(options),
});
};
/**
* Create Status
*/
export const createStatusMutation = (
options?: Partial<Options<CreateStatusData>>
): UseMutationOptions<
CreateStatusResponse2,
AxiosError<CreateStatusError>,
Options<CreateStatusData>
> => {
const mutationOptions: UseMutationOptions<
CreateStatusResponse2,
AxiosError<CreateStatusError>,
Options<CreateStatusData>
> = {
mutationFn: async localOptions => {
const { data } = await createStatus({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
/**
* Delete Status
*/
export const deleteStatusMutation = (
options?: Partial<Options<DeleteStatusData>>
): UseMutationOptions<
DeleteStatusResponse2,
AxiosError<DeleteStatusError>,
Options<DeleteStatusData>
> => {
const mutationOptions: UseMutationOptions<
DeleteStatusResponse2,
AxiosError<DeleteStatusError>,
Options<DeleteStatusData>
> = {
mutationFn: async localOptions => {
const { data } = await deleteStatus({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
/** /**
* Update Status * Update Status
*/ */
export const updateStatusMutation = (options?: Partial<Options<UpdateStatusData>>): UseMutationOptions<UpdateStatusResponse2, AxiosError<UpdateStatusError>, Options<UpdateStatusData>> => { export const updateStatusMutation = (
const mutationOptions: UseMutationOptions<UpdateStatusResponse2, AxiosError<UpdateStatusError>, Options<UpdateStatusData>> = { options?: Partial<Options<UpdateStatusData>>
mutationFn: async (localOptions) => { ): UseMutationOptions<
UpdateStatusResponse2,
AxiosError<UpdateStatusError>,
Options<UpdateStatusData>
> => {
const mutationOptions: UseMutationOptions<
UpdateStatusResponse2,
AxiosError<UpdateStatusError>,
Options<UpdateStatusData>
> = {
mutationFn: async localOptions => {
const { data } = await updateStatus({ const { data } = await updateStatus({
...options, ...options,
...localOptions, ...localOptions,
throwOnError: true throwOnError: true,
}); });
return data; return data;
} },
}; };
return mutationOptions; return mutationOptions;
}; };

View File

@ -1,8 +1,13 @@
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import type { ClientOptions } from './types.gen'; import { createClientConfig } from "../../hey-api-config";
import { type Config, type ClientOptions as DefaultClientOptions, createClient, createConfig } from './client'; import {
import { createClientConfig } from '../../hey-api-config'; createClient,
createConfig,
type Config,
type ClientOptions as DefaultClientOptions,
} from "./client";
import type { ClientOptions } from "./types.gen";
/** /**
* The `createClientConfig()` function will be called on client initialization * The `createClientConfig()` function will be called on client initialization
@ -12,8 +17,15 @@ import { createClientConfig } from '../../hey-api-config';
* `setConfig()`. This is useful for example if you're using Next.js * `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values. * to ensure your client always has the correct values.
*/ */
export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> = (override?: Config<DefaultClientOptions & T>) => Config<Required<DefaultClientOptions> & T>; export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> =
(
override?: Config<DefaultClientOptions & T>
) => Config<Required<DefaultClientOptions> & T>;
export const client = createClient(createClientConfig(createConfig<ClientOptions>({ export const client = createClient(
baseURL: '/api' createClientConfig(
}))); createConfig<ClientOptions>({
baseURL: "/api",
})
)
);

View File

@ -1,115 +1,114 @@
import type { AxiosError, RawAxiosRequestHeaders } from 'axios'; import type { AxiosError, RawAxiosRequestHeaders } from "axios";
import axios from 'axios'; import axios from "axios";
import type { Client, Config } from "./types";
import type { Client, Config } from './types';
import { import {
buildUrl, buildUrl,
createConfig, createConfig,
mergeConfigs, mergeConfigs,
mergeHeaders, mergeHeaders,
setAuthParams, setAuthParams,
} from './utils'; } from "./utils";
export const createClient = (config: Config = {}): Client => { export const createClient = (config: Config = {}): Client => {
let _config = mergeConfigs(createConfig(), config); let _config = mergeConfigs(createConfig(), config);
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { auth, ...configWithoutAuth } = _config; const { auth, ...configWithoutAuth } = _config;
const instance = axios.create(configWithoutAuth); const instance = axios.create(configWithoutAuth);
const getConfig = (): Config => ({ ..._config }); const getConfig = (): Config => ({ ..._config });
const setConfig = (config: Config): Config => { const setConfig = (config: Config): Config => {
_config = mergeConfigs(_config, config); _config = mergeConfigs(_config, config);
instance.defaults = { instance.defaults = {
...instance.defaults, ...instance.defaults,
..._config, ..._config,
// @ts-expect-error // @ts-expect-error
headers: mergeHeaders(instance.defaults.headers, _config.headers), headers: mergeHeaders(instance.defaults.headers, _config.headers),
}; };
return getConfig(); return getConfig();
};
// @ts-expect-error
const request: Client['request'] = async (options) => {
const opts = {
..._config,
...options,
axios: options.axios ?? _config.axios ?? instance,
headers: mergeHeaders(_config.headers, options.headers),
}; };
if (opts.security) { // @ts-expect-error
await setAuthParams({ const request: Client["request"] = async options => {
...opts, const opts = {
security: opts.security, ..._config,
}); ...options,
} axios: options.axios ?? _config.axios ?? instance,
headers: mergeHeaders(_config.headers, options.headers),
};
if (opts.requestValidator) { if (opts.security) {
await opts.requestValidator(opts); await setAuthParams({
} ...opts,
security: opts.security,
if (opts.body && opts.bodySerializer) { });
opts.body = opts.bodySerializer(opts.body);
}
const url = buildUrl(opts);
try {
// assign Axios here for consistency with fetch
const _axios = opts.axios!;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { auth, ...optsWithoutAuth } = opts;
const response = await _axios({
...optsWithoutAuth,
baseURL: opts.baseURL as string,
data: opts.body,
headers: opts.headers as RawAxiosRequestHeaders,
// let `paramsSerializer()` handle query params if it exists
params: opts.paramsSerializer ? opts.query : undefined,
url,
});
let { data } = response;
if (opts.responseType === 'json') {
if (opts.responseValidator) {
await opts.responseValidator(data);
} }
if (opts.responseTransformer) { if (opts.requestValidator) {
data = await opts.responseTransformer(data); await opts.requestValidator(opts);
} }
}
return { if (opts.body && opts.bodySerializer) {
...response, opts.body = opts.bodySerializer(opts.body);
data: data ?? {}, }
};
} catch (error) {
const e = error as AxiosError;
if (opts.throwOnError) {
throw e;
}
// @ts-expect-error
e.error = e.response?.data ?? {};
return e;
}
};
return { const url = buildUrl(opts);
buildUrl,
delete: (options) => request({ ...options, method: 'DELETE' }), try {
get: (options) => request({ ...options, method: 'GET' }), // assign Axios here for consistency with fetch
getConfig, const _axios = opts.axios!;
head: (options) => request({ ...options, method: 'HEAD' }), // eslint-disable-next-line @typescript-eslint/no-unused-vars
instance, const { auth, ...optsWithoutAuth } = opts;
options: (options) => request({ ...options, method: 'OPTIONS' }), const response = await _axios({
patch: (options) => request({ ...options, method: 'PATCH' }), ...optsWithoutAuth,
post: (options) => request({ ...options, method: 'POST' }), baseURL: opts.baseURL as string,
put: (options) => request({ ...options, method: 'PUT' }), data: opts.body,
request, headers: opts.headers as RawAxiosRequestHeaders,
setConfig, // let `paramsSerializer()` handle query params if it exists
} as Client; params: opts.paramsSerializer ? opts.query : undefined,
url,
});
let { data } = response;
if (opts.responseType === "json") {
if (opts.responseValidator) {
await opts.responseValidator(data);
}
if (opts.responseTransformer) {
data = await opts.responseTransformer(data);
}
}
return {
...response,
data: data ?? {},
};
} catch (error) {
const e = error as AxiosError;
if (opts.throwOnError) {
throw e;
}
// @ts-expect-error
e.error = e.response?.data ?? {};
return e;
}
};
return {
buildUrl,
delete: options => request({ ...options, method: "DELETE" }),
get: options => request({ ...options, method: "GET" }),
getConfig,
head: options => request({ ...options, method: "HEAD" }),
instance,
options: options => request({ ...options, method: "OPTIONS" }),
patch: options => request({ ...options, method: "PATCH" }),
post: options => request({ ...options, method: "POST" }),
put: options => request({ ...options, method: "PUT" }),
request,
setConfig,
} as Client;
}; };

View File

@ -1,21 +1,21 @@
export type { Auth } from '../core/auth'; export type { Auth } from "../core/auth";
export type { QuerySerializerOptions } from '../core/bodySerializer'; export type { QuerySerializerOptions } from "../core/bodySerializer";
export { export {
formDataBodySerializer, formDataBodySerializer,
jsonBodySerializer, jsonBodySerializer,
urlSearchParamsBodySerializer, urlSearchParamsBodySerializer,
} from '../core/bodySerializer'; } from "../core/bodySerializer";
export { buildClientParams } from '../core/params'; export { buildClientParams } from "../core/params";
export { createClient } from './client'; export { createClient } from "./client";
export type { export type {
Client, Client,
ClientOptions, ClientOptions,
Config, Config,
CreateClientConfig, CreateClientConfig,
Options, Options,
OptionsLegacyParser, OptionsLegacyParser,
RequestOptions, RequestOptions,
RequestResult, RequestResult,
TDataShape, TDataShape,
} from './types'; } from "./types";
export { createConfig } from './utils'; export { createConfig } from "./utils";

View File

@ -1,138 +1,141 @@
import type { import type {
AxiosError, AxiosError,
AxiosInstance, AxiosInstance,
AxiosRequestHeaders, AxiosRequestHeaders,
AxiosResponse, AxiosResponse,
AxiosStatic, AxiosStatic,
CreateAxiosDefaults, CreateAxiosDefaults,
} from 'axios'; } from "axios";
import type { Auth } from "../core/auth";
import type { Auth } from '../core/auth'; import type { Client as CoreClient, Config as CoreConfig } from "../core/types";
import type {
Client as CoreClient,
Config as CoreConfig,
} from '../core/types';
export interface Config<T extends ClientOptions = ClientOptions> export interface Config<T extends ClientOptions = ClientOptions>
extends Omit<CreateAxiosDefaults, 'auth' | 'baseURL' | 'headers' | 'method'>, extends Omit<
CoreConfig { CreateAxiosDefaults,
/** "auth" | "baseURL" | "headers" | "method"
* Axios implementation. You can use this option to provide a custom >,
* Axios instance. CoreConfig {
* /**
* @default axios * Axios implementation. You can use this option to provide a custom
*/ * Axios instance.
axios?: AxiosStatic; *
/** * @default axios
* Base URL for all requests made by this client. */
*/ axios?: AxiosStatic;
baseURL?: T['baseURL']; /**
/** * Base URL for all requests made by this client.
* An object containing any HTTP headers that you want to pre-populate your */
* `Headers` object with. baseURL?: T["baseURL"];
* /**
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} * An object containing any HTTP headers that you want to pre-populate your
*/ * `Headers` object with.
headers?: *
| AxiosRequestHeaders * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
| Record< */
string, headers?:
| string | AxiosRequestHeaders
| number | Record<
| boolean string,
| (string | number | boolean)[] | string
| null | number
| undefined | boolean
| unknown | (string | number | boolean)[]
>; | null
/** | undefined
* Throw an error instead of returning it in the response? | unknown
* >;
* @default false /**
*/ * Throw an error instead of returning it in the response?
throwOnError?: T['throwOnError']; *
* @default false
*/
throwOnError?: T["throwOnError"];
} }
export interface RequestOptions< export interface RequestOptions<
ThrowOnError extends boolean = boolean, ThrowOnError extends boolean = boolean,
Url extends string = string, Url extends string = string,
> extends Config<{ > extends Config<{
throwOnError: ThrowOnError; throwOnError: ThrowOnError;
}> { }> {
/** /**
* Any body that you want to add to your request. * Any body that you want to add to your request.
* *
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body} * {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
*/ */
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
/**
* Security mechanism(s) to use for the request.
*/
security?: ReadonlyArray<Auth>;
url: Url;
}
export type RequestResult<
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = boolean,
> = ThrowOnError extends true
? Promise<
AxiosResponse<
TData extends Record<string, unknown> ? TData[keyof TData] : TData
>
>
: Promise<
| (AxiosResponse<
TData extends Record<string, unknown> ? TData[keyof TData] : TData
> & { error: undefined })
| (AxiosError<
TError extends Record<string, unknown> ? TError[keyof TError] : TError
> & {
data: undefined;
error: TError extends Record<string, unknown>
? TError[keyof TError]
: TError;
})
>;
export interface ClientOptions {
baseURL?: string;
throwOnError?: boolean;
}
type MethodFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
>(
options: Omit<RequestOptions<ThrowOnError>, 'method'>,
) => RequestResult<TData, TError, ThrowOnError>;
type RequestFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
>(
options: Omit<RequestOptions<ThrowOnError>, 'method'> &
Pick<Required<RequestOptions<ThrowOnError>>, 'method'>,
) => RequestResult<TData, TError, ThrowOnError>;
type BuildUrlFn = <
TData extends {
body?: unknown; body?: unknown;
path?: Record<string, unknown>; path?: Record<string, unknown>;
query?: Record<string, unknown>; query?: Record<string, unknown>;
url: string; /**
}, * Security mechanism(s) to use for the request.
*/
security?: ReadonlyArray<Auth>;
url: Url;
}
export type RequestResult<
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = boolean,
> = ThrowOnError extends true
? Promise<
AxiosResponse<
TData extends Record<string, unknown> ? TData[keyof TData] : TData
>
>
: Promise<
| (AxiosResponse<
TData extends Record<string, unknown>
? TData[keyof TData]
: TData
> & { error: undefined })
| (AxiosError<
TError extends Record<string, unknown>
? TError[keyof TError]
: TError
> & {
data: undefined;
error: TError extends Record<string, unknown>
? TError[keyof TError]
: TError;
})
>;
export interface ClientOptions {
baseURL?: string;
throwOnError?: boolean;
}
type MethodFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
>( >(
options: Pick<TData, 'url'> & Omit<Options<TData>, 'axios'>, options: Omit<RequestOptions<ThrowOnError>, "method">
) => RequestResult<TData, TError, ThrowOnError>;
type RequestFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
>(
options: Omit<RequestOptions<ThrowOnError>, "method"> &
Pick<Required<RequestOptions<ThrowOnError>>, "method">
) => RequestResult<TData, TError, ThrowOnError>;
type BuildUrlFn = <
TData extends {
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
url: string;
},
>(
options: Pick<TData, "url"> & Omit<Options<TData>, "axios">
) => string; ) => string;
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn> & { export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn> & {
instance: AxiosInstance; instance: AxiosInstance;
}; };
/** /**
@ -144,36 +147,37 @@ export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn> & {
* to ensure your client always has the correct values. * to ensure your client always has the correct values.
*/ */
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = ( export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
override?: Config<ClientOptions & T>, override?: Config<ClientOptions & T>
) => Config<Required<ClientOptions> & T>; ) => Config<Required<ClientOptions> & T>;
export interface TDataShape { export interface TDataShape {
body?: unknown; body?: unknown;
headers?: unknown; headers?: unknown;
path?: unknown; path?: unknown;
query?: unknown; query?: unknown;
url: string; url: string;
} }
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>; type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
export type Options< export type Options<
TData extends TDataShape = TDataShape, TData extends TDataShape = TDataShape,
ThrowOnError extends boolean = boolean, ThrowOnError extends boolean = boolean,
> = OmitKeys<RequestOptions<ThrowOnError>, 'body' | 'path' | 'query' | 'url'> & > = OmitKeys<RequestOptions<ThrowOnError>, "body" | "path" | "query" | "url"> &
Omit<TData, 'url'>; Omit<TData, "url">;
export type OptionsLegacyParser< export type OptionsLegacyParser<
TData = unknown, TData = unknown,
ThrowOnError extends boolean = boolean, ThrowOnError extends boolean = boolean,
> = TData extends { body?: any } > = TData extends { body?: any }
? TData extends { headers?: any } ? TData extends { headers?: any }
? OmitKeys<RequestOptions<ThrowOnError>, 'body' | 'headers' | 'url'> & TData ? OmitKeys<RequestOptions<ThrowOnError>, "body" | "headers" | "url"> &
: OmitKeys<RequestOptions<ThrowOnError>, 'body' | 'url'> & TData
TData & : OmitKeys<RequestOptions<ThrowOnError>, "body" | "url"> &
Pick<RequestOptions<ThrowOnError>, 'headers'> TData &
: TData extends { headers?: any } Pick<RequestOptions<ThrowOnError>, "headers">
? OmitKeys<RequestOptions<ThrowOnError>, 'headers' | 'url'> & : TData extends { headers?: any }
TData & ? OmitKeys<RequestOptions<ThrowOnError>, "headers" | "url"> &
Pick<RequestOptions<ThrowOnError>, 'body'> TData &
: OmitKeys<RequestOptions<ThrowOnError>, 'url'> & TData; Pick<RequestOptions<ThrowOnError>, "body">
: OmitKeys<RequestOptions<ThrowOnError>, "url"> & TData;

View File

@ -1,286 +1,292 @@
import { getAuthToken } from '../core/auth'; import { getAuthToken } from "../core/auth";
import type { import type {
QuerySerializer, QuerySerializer,
QuerySerializerOptions, QuerySerializerOptions,
} from '../core/bodySerializer'; } from "../core/bodySerializer";
import type { ArraySeparatorStyle } from '../core/pathSerializer'; import type { ArraySeparatorStyle } from "../core/pathSerializer";
import { import {
serializeArrayParam, serializeArrayParam,
serializeObjectParam, serializeObjectParam,
serializePrimitiveParam, serializePrimitiveParam,
} from '../core/pathSerializer'; } from "../core/pathSerializer";
import type { Client, ClientOptions, Config, RequestOptions } from './types'; import type { Client, ClientOptions, Config, RequestOptions } from "./types";
interface PathSerializer { interface PathSerializer {
path: Record<string, unknown>; path: Record<string, unknown>;
url: string; url: string;
} }
const PATH_PARAM_RE = /\{[^{}]+\}/g; const PATH_PARAM_RE = /\{[^{}]+\}/g;
const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
let url = _url; let url = _url;
const matches = _url.match(PATH_PARAM_RE); const matches = _url.match(PATH_PARAM_RE);
if (matches) { if (matches) {
for (const match of matches) { for (const match of matches) {
let explode = false; let explode = false;
let name = match.substring(1, match.length - 1); let name = match.substring(1, match.length - 1);
let style: ArraySeparatorStyle = 'simple'; let style: ArraySeparatorStyle = "simple";
if (name.endsWith('*')) { if (name.endsWith("*")) {
explode = true; explode = true;
name = name.substring(0, name.length - 1); name = name.substring(0, name.length - 1);
} }
if (name.startsWith('.')) { if (name.startsWith(".")) {
name = name.substring(1); name = name.substring(1);
style = 'label'; style = "label";
} else if (name.startsWith(';')) { } else if (name.startsWith(";")) {
name = name.substring(1); name = name.substring(1);
style = 'matrix'; style = "matrix";
} }
const value = path[name]; const value = path[name];
if (value === undefined || value === null) { if (value === undefined || value === null) {
continue; continue;
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
url = url.replace( url = url.replace(
match, match,
serializeArrayParam({ explode, name, style, value }), serializeArrayParam({ explode, name, style, value })
); );
continue; continue;
} }
if (typeof value === 'object') { if (typeof value === "object") {
url = url.replace( url = url.replace(
match, match,
serializeObjectParam({ serializeObjectParam({
explode, explode,
name, name,
style, style,
value: value as Record<string, unknown>, value: value as Record<string, unknown>,
valueOnly: true, valueOnly: true,
}), })
); );
continue; continue;
} }
if (style === 'matrix') { if (style === "matrix") {
url = url.replace( url = url.replace(
match, match,
`;${serializePrimitiveParam({ `;${serializePrimitiveParam({
name, name,
value: value as string, value: value as string,
})}`, })}`
); );
continue; continue;
} }
const replaceValue = encodeURIComponent( const replaceValue = encodeURIComponent(
style === 'label' ? `.${value as string}` : (value as string), style === "label" ? `.${value as string}` : (value as string)
); );
url = url.replace(match, replaceValue); url = url.replace(match, replaceValue);
}
} }
} return url;
return url;
}; };
export const createQuerySerializer = <T = unknown>({ export const createQuerySerializer = <T = unknown>({
allowReserved, allowReserved,
array, array,
object, object,
}: QuerySerializerOptions = {}) => { }: QuerySerializerOptions = {}) => {
const querySerializer = (queryParams: T) => { const querySerializer = (queryParams: T) => {
const search: string[] = []; const search: string[] = [];
if (queryParams && typeof queryParams === 'object') { if (queryParams && typeof queryParams === "object") {
for (const name in queryParams) { for (const name in queryParams) {
const value = queryParams[name]; const value = queryParams[name];
if (value === undefined || value === null) { if (value === undefined || value === null) {
continue; continue;
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
const serializedArray = serializeArrayParam({ const serializedArray = serializeArrayParam({
allowReserved, allowReserved,
explode: true, explode: true,
name, name,
style: 'form', style: "form",
value, value,
...array, ...array,
}); });
if (serializedArray) search.push(serializedArray); if (serializedArray) search.push(serializedArray);
} else if (typeof value === 'object') { } else if (typeof value === "object") {
const serializedObject = serializeObjectParam({ const serializedObject = serializeObjectParam({
allowReserved, allowReserved,
explode: true, explode: true,
name, name,
style: 'deepObject', style: "deepObject",
value: value as Record<string, unknown>, value: value as Record<string, unknown>,
...object, ...object,
}); });
if (serializedObject) search.push(serializedObject); if (serializedObject) search.push(serializedObject);
} else { } else {
const serializedPrimitive = serializePrimitiveParam({ const serializedPrimitive = serializePrimitiveParam({
allowReserved, allowReserved,
name, name,
value: value as string, value: value as string,
}); });
if (serializedPrimitive) search.push(serializedPrimitive); if (serializedPrimitive) search.push(serializedPrimitive);
}
}
} }
} return search.join("&");
} };
return search.join('&'); return querySerializer;
};
return querySerializer;
}; };
export const setAuthParams = async ({ export const setAuthParams = async ({
security, security,
...options ...options
}: Pick<Required<RequestOptions>, 'security'> & }: Pick<Required<RequestOptions>, "security"> &
Pick<RequestOptions, 'auth' | 'query'> & { Pick<RequestOptions, "auth" | "query"> & {
headers: Record<any, unknown>; headers: Record<any, unknown>;
}) => { }) => {
for (const auth of security) { for (const auth of security) {
const token = await getAuthToken(auth, options.auth); const token = await getAuthToken(auth, options.auth);
if (!token) { if (!token) {
continue; continue;
}
const name = auth.name ?? 'Authorization';
switch (auth.in) {
case 'query':
if (!options.query) {
options.query = {};
} }
options.query[name] = token;
break;
case 'cookie': {
const value = `${name}=${token}`;
if ('Cookie' in options.headers && options.headers['Cookie']) {
options.headers['Cookie'] = `${options.headers['Cookie']}; ${value}`;
} else {
options.headers['Cookie'] = value;
}
break;
}
case 'header':
default:
options.headers[name] = token;
break;
}
return; const name = auth.name ?? "Authorization";
}
switch (auth.in) {
case "query":
if (!options.query) {
options.query = {};
}
options.query[name] = token;
break;
case "cookie": {
const value = `${name}=${token}`;
if ("Cookie" in options.headers && options.headers["Cookie"]) {
options.headers["Cookie"] =
`${options.headers["Cookie"]}; ${value}`;
} else {
options.headers["Cookie"] = value;
}
break;
}
case "header":
default:
options.headers[name] = token;
break;
}
return;
}
}; };
export const buildUrl: Client['buildUrl'] = (options) => { export const buildUrl: Client["buildUrl"] = options => {
const url = getUrl({ const url = getUrl({
path: options.path, path: options.path,
// let `paramsSerializer()` handle query params if it exists // let `paramsSerializer()` handle query params if it exists
query: !options.paramsSerializer ? options.query : undefined, query: !options.paramsSerializer ? options.query : undefined,
querySerializer: querySerializer:
typeof options.querySerializer === 'function' typeof options.querySerializer === "function"
? options.querySerializer ? options.querySerializer
: createQuerySerializer(options.querySerializer), : createQuerySerializer(options.querySerializer),
url: options.url, url: options.url,
}); });
return url; return url;
}; };
export const getUrl = ({ export const getUrl = ({
path, path,
query, query,
querySerializer, querySerializer,
url: _url, url: _url,
}: { }: {
path?: Record<string, unknown>; path?: Record<string, unknown>;
query?: Record<string, unknown>; query?: Record<string, unknown>;
querySerializer: QuerySerializer; querySerializer: QuerySerializer;
url: string; url: string;
}) => { }) => {
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; const pathUrl = _url.startsWith("/") ? _url : `/${_url}`;
let url = pathUrl; let url = pathUrl;
if (path) { if (path) {
url = defaultPathSerializer({ path, url }); url = defaultPathSerializer({ path, url });
} }
let search = query ? querySerializer(query) : ''; let search = query ? querySerializer(query) : "";
if (search.startsWith('?')) { if (search.startsWith("?")) {
search = search.substring(1); search = search.substring(1);
} }
if (search) { if (search) {
url += `?${search}`; url += `?${search}`;
} }
return url; return url;
}; };
export const mergeConfigs = (a: Config, b: Config): Config => { export const mergeConfigs = (a: Config, b: Config): Config => {
const config = { ...a, ...b }; const config = { ...a, ...b };
config.headers = mergeHeaders(a.headers, b.headers); config.headers = mergeHeaders(a.headers, b.headers);
return config; return config;
}; };
/** /**
* Special Axios headers keywords allowing to set headers by request method. * Special Axios headers keywords allowing to set headers by request method.
*/ */
export const axiosHeadersKeywords = [ export const axiosHeadersKeywords = [
'common', "common",
'delete', "delete",
'get', "get",
'head', "head",
'patch', "patch",
'post', "post",
'put', "put",
] as const; ] as const;
export const mergeHeaders = ( export const mergeHeaders = (
...headers: Array<Required<Config>['headers'] | undefined> ...headers: Array<Required<Config>["headers"] | undefined>
): Record<any, unknown> => { ): Record<any, unknown> => {
const mergedHeaders: Record<any, unknown> = {}; const mergedHeaders: Record<any, unknown> = {};
for (const header of headers) { for (const header of headers) {
if (!header || typeof header !== 'object') { if (!header || typeof header !== "object") {
continue; continue;
} }
const iterator = Object.entries(header); const iterator = Object.entries(header);
for (const [key, value] of iterator) { for (const [key, value] of iterator) {
if ( if (
axiosHeadersKeywords.includes( axiosHeadersKeywords.includes(
key as (typeof axiosHeadersKeywords)[number], key as (typeof axiosHeadersKeywords)[number]
) && ) &&
typeof value === 'object' typeof value === "object"
) { ) {
mergedHeaders[key] = { mergedHeaders[key] = {
...(mergedHeaders[key] as Record<any, unknown>), ...(mergedHeaders[key] as Record<any, unknown>),
...value, ...value,
}; };
} else if (value === null) { } else if (value === null) {
delete mergedHeaders[key]; delete mergedHeaders[key];
} else if (Array.isArray(value)) { } else if (Array.isArray(value)) {
for (const v of value) { for (const v of value) {
// @ts-expect-error // @ts-expect-error
mergedHeaders[key] = [...(mergedHeaders[key] ?? []), v as string]; mergedHeaders[key] = [
...(mergedHeaders[key] ?? []),
v as string,
];
}
} else if (value !== undefined) {
// assume object headers are meant to be JSON stringified, i.e. their
// content value in OpenAPI specification is 'application/json'
mergedHeaders[key] =
typeof value === "object"
? JSON.stringify(value)
: (value as string);
}
} }
} else if (value !== undefined) {
// assume object headers are meant to be JSON stringified, i.e. their
// content value in OpenAPI specification is 'application/json'
mergedHeaders[key] =
typeof value === 'object' ? JSON.stringify(value) : (value as string);
}
} }
} return mergedHeaders;
return mergedHeaders;
}; };
export const createConfig = <T extends ClientOptions = ClientOptions>( export const createConfig = <T extends ClientOptions = ClientOptions>(
override: Config<Omit<ClientOptions, keyof T> & T> = {}, override: Config<Omit<ClientOptions, keyof T> & T> = {}
): Config<Omit<ClientOptions, keyof T> & T> => ({ ): Config<Omit<ClientOptions, keyof T> & T> => ({
...override, ...override,
}); });

View File

@ -1,40 +1,40 @@
export type AuthToken = string | undefined; export type AuthToken = string | undefined;
export interface Auth { export interface Auth {
/** /**
* Which part of the request do we use to send the auth? * Which part of the request do we use to send the auth?
* *
* @default 'header' * @default 'header'
*/ */
in?: 'header' | 'query' | 'cookie'; in?: "header" | "query" | "cookie";
/** /**
* Header or query parameter name. * Header or query parameter name.
* *
* @default 'Authorization' * @default 'Authorization'
*/ */
name?: string; name?: string;
scheme?: 'basic' | 'bearer'; scheme?: "basic" | "bearer";
type: 'apiKey' | 'http'; type: "apiKey" | "http";
} }
export const getAuthToken = async ( export const getAuthToken = async (
auth: Auth, auth: Auth,
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken, callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken
): Promise<string | undefined> => { ): Promise<string | undefined> => {
const token = const token =
typeof callback === 'function' ? await callback(auth) : callback; typeof callback === "function" ? await callback(auth) : callback;
if (!token) { if (!token) {
return; return;
} }
if (auth.scheme === 'bearer') { if (auth.scheme === "bearer") {
return `Bearer ${token}`; return `Bearer ${token}`;
} }
if (auth.scheme === 'basic') { if (auth.scheme === "basic") {
return `Basic ${btoa(token)}`; return `Basic ${btoa(token)}`;
} }
return token; return token;
}; };

View File

@ -1,88 +1,92 @@
import type { import type {
ArrayStyle, ArrayStyle,
ObjectStyle, ObjectStyle,
SerializerOptions, SerializerOptions,
} from './pathSerializer'; } from "./pathSerializer";
export type QuerySerializer = (query: Record<string, unknown>) => string; export type QuerySerializer = (query: Record<string, unknown>) => string;
export type BodySerializer = (body: any) => any; export type BodySerializer = (body: any) => any;
export interface QuerySerializerOptions { export interface QuerySerializerOptions {
allowReserved?: boolean; allowReserved?: boolean;
array?: SerializerOptions<ArrayStyle>; array?: SerializerOptions<ArrayStyle>;
object?: SerializerOptions<ObjectStyle>; object?: SerializerOptions<ObjectStyle>;
} }
const serializeFormDataPair = ( const serializeFormDataPair = (
data: FormData, data: FormData,
key: string, key: string,
value: unknown, value: unknown
): void => { ): void => {
if (typeof value === 'string' || value instanceof Blob) { if (typeof value === "string" || value instanceof Blob) {
data.append(key, value); data.append(key, value);
} else { } else {
data.append(key, JSON.stringify(value)); data.append(key, JSON.stringify(value));
} }
}; };
const serializeUrlSearchParamsPair = ( const serializeUrlSearchParamsPair = (
data: URLSearchParams, data: URLSearchParams,
key: string, key: string,
value: unknown, value: unknown
): void => { ): void => {
if (typeof value === 'string') { if (typeof value === "string") {
data.append(key, value); data.append(key, value);
} else { } else {
data.append(key, JSON.stringify(value)); data.append(key, JSON.stringify(value));
} }
}; };
export const formDataBodySerializer = { export const formDataBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>( bodySerializer: <
body: T, T extends Record<string, any> | Array<Record<string, any>>,
): FormData => { >(
const data = new FormData(); body: T
): FormData => {
const data = new FormData();
Object.entries(body).forEach(([key, value]) => { Object.entries(body).forEach(([key, value]) => {
if (value === undefined || value === null) { if (value === undefined || value === null) {
return; return;
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
value.forEach((v) => serializeFormDataPair(data, key, v)); value.forEach(v => serializeFormDataPair(data, key, v));
} else { } else {
serializeFormDataPair(data, key, value); serializeFormDataPair(data, key, value);
} }
}); });
return data; return data;
}, },
}; };
export const jsonBodySerializer = { export const jsonBodySerializer = {
bodySerializer: <T>(body: T): string => bodySerializer: <T>(body: T): string =>
JSON.stringify(body, (_key, value) => JSON.stringify(body, (_key, value) =>
typeof value === 'bigint' ? value.toString() : value, typeof value === "bigint" ? value.toString() : value
), ),
}; };
export const urlSearchParamsBodySerializer = { export const urlSearchParamsBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>( bodySerializer: <
body: T, T extends Record<string, any> | Array<Record<string, any>>,
): string => { >(
const data = new URLSearchParams(); body: T
): string => {
const data = new URLSearchParams();
Object.entries(body).forEach(([key, value]) => { Object.entries(body).forEach(([key, value]) => {
if (value === undefined || value === null) { if (value === undefined || value === null) {
return; return;
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); value.forEach(v => serializeUrlSearchParamsPair(data, key, v));
} else { } else {
serializeUrlSearchParamsPair(data, key, value); serializeUrlSearchParamsPair(data, key, value);
} }
}); });
return data.toString(); return data.toString();
}, },
}; };

Some files were not shown because too many files have changed in this diff Show More