Compare commits

38 Commits

Author SHA1 Message Date
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
115 changed files with 5440 additions and 2012 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,11 +7,13 @@
"build": "next build",
"start": "next start",
"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": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@mantine/carousel": "^8.2.4",
"@mantine/core": "8.1.2",
"@mantine/form": "^8.1.3",
"@mantine/hooks": "8.1.2",
@ -24,6 +26,8 @@
"@tanstack/react-query": "^5.83.0",
"axios": "^1.11.0",
"classnames": "^2.5.1",
"embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.7",
"i18n-iso-countries": "^7.14.0",
"lexorank": "^1.0.5",
@ -56,7 +60,9 @@
"@types/node": "^22.13.11",
"@types/react": "19.1.8",
"@types/react-redux": "^7.1.34",
"@types/react-slick": "^0",
"@types/redux-persist": "^4.3.1",
"@types/slick-carousel": "^1",
"autoprefixer": "^10.4.21",
"babel-loader": "^10.0.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,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,7 @@
.board {
min-width: 130px;
flex-wrap: nowrap;
gap: 3px;
}

View File

@ -0,0 +1,61 @@
import React, { FC, useState } from "react";
import { Box, Flex, Group, Text } from "@mantine/core";
import styles from "@/app/deals/components/desktop/Board/Board.module.css";
import BoardMenu from "@/app/deals/components/shared/BoardMenu/BoardMenu";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import SmallPageBlock from "@/components/layout/SmallPageBlock/SmallPageBlock";
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
import { BoardSchema } from "@/lib/client";
type Props = {
board: BoardSchema;
};
const Board: FC<Props> = ({ board }) => {
const { selectedBoard } = useBoardsContext();
const [isHovered, setIsHovered] = useState(false);
const { onUpdateBoard } = useBoardsContext();
return (
<Flex p={"lg"}>
<SmallPageBlock active={selectedBoard?.id === board.id}>
<Group
px={"md"}
py={"xs"}
bdrs={"lg"}
justify={"space-between"}
className={styles.board}
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>{board.name}</Text>
</Box>
<BoardMenu
isHovered={isHovered}
board={board}
startEditing={startEditing}
/>
</>
)}
modalTitle={"Редактирование доски"}
/>
</Group>
</SmallPageBlock>
</Flex>
);
};
export default Board;

View File

@ -0,0 +1,50 @@
"use client";
import React from "react";
import { Group, ScrollArea } from "@mantine/core";
import Board from "@/app/deals/components/desktop/Board/Board";
import CreateBoardButton from "@/app/deals/components/desktop/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%"}>
<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,34 @@
import { IconPlus } from "@tabler/icons-react";
import { Box } from "@mantine/core";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import SmallPageBlock from "@/components/layout/SmallPageBlock/SmallPageBlock";
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
const CreateBoardButton = () => {
const { onCreateBoard } = useBoardsContext();
return (
<SmallPageBlock style={{ cursor: "pointer" }}>
<InPlaceInput
placeholder={"Название доски"}
onComplete={onCreateBoard}
getChildren={startEditing => (
<Box
p={"sm"}
onClick={startEditing}>
<IconPlus />
</Box>
)}
modalTitle={"Создание доски"}
inputStyles={{
wrapper: {
marginLeft: 15,
marginRight: 15,
},
}}
/>
</SmallPageBlock>
);
};
export default CreateBoardButton;

View File

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

View File

@ -0,0 +1,29 @@
import React, { FC } from "react";
import classNames from "classnames";
import { Box, Text } from "@mantine/core";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import { BoardSchema } from "@/lib/client";
import styles from "./BoardMobile.module.css";
type Props = {
board: BoardSchema;
};
const BoardMobile: FC<Props> = ({ board }) => {
const { selectedBoard } = useBoardsContext();
return (
<Box
px={"md"}
py={"xs"}
className={classNames(
styles["board-mobile"],
selectedBoard?.id === board.id &&
styles["board-mobile-selected"]
)}>
<Text style={{ textWrap: "nowrap" }}>{board.name}</Text>
</Box>
);
};
export default BoardMobile;

View File

@ -0,0 +1,51 @@
"use client";
import React from "react";
import { Group, ScrollArea } from "@mantine/core";
import BoardMobile from "@/app/deals/components/mobile/BoardMobile/BoardMobile";
import CreateBoardButtonMobile from "@/app/deals/components/mobile/CreateBoardButtonMobile/CreateBoardButtonMobile";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import SortableDnd from "@/components/dnd/SortableDnd";
import useIsMobile from "@/hooks/useIsMobile";
import { BoardSchema } from "@/lib/client";
const BoardsMobile = () => {
const { boards, setSelectedBoard, onUpdateBoard } = useBoardsContext();
const isMobile = useIsMobile();
const renderBoard = (board: BoardSchema) => <BoardMobile 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={"100vw"}
mt={5}>
<Group
wrap={"nowrap"}
gap={0}>
<SortableDnd
initialItems={boards}
renderItem={renderBoard}
onDragEnd={onDragEnd}
onItemClick={selectBoard}
containerStyle={{ flexWrap: "nowrap" }}
dragHandleStyle={{ cursor: "pointer" }}
disabled={isMobile}
/>
<CreateBoardButtonMobile />
</Group>
</ScrollArea>
);
};
export default BoardsMobile;

View File

@ -0,0 +1,11 @@
.create-button {
padding: 10px 10px 11px 10px;
border-bottom: 2px solid gray;
}
.spacer {
height: 46px;
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 styles from "@/app/deals/components/mobile/CreateBoardButtonMobile/CreateBoardButtonMobile.module.css";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
const CreateBoardButtonMobile = () => {
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 CreateBoardButtonMobile;

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,12 @@
.container {
flex-wrap: nowrap;
border-bottom: solid dodgerblue 3px;
margin-bottom: 3px;
cursor: pointer;
@media (max-width: 48em) {
width: 80vw;
margin-right: 8vw;
}
}

View File

@ -0,0 +1,43 @@
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>
<Box className={styles.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: {
padding: 4,
},
}}
/>
</Box>
</Stack>
);
};
export default CreateStatusButton;

View File

@ -0,0 +1,9 @@
.container {
@mixin light {
background-color: var(--color-light-aqua);
}
@mixin dark {
background-color: var(--mantine-color-dark-7);
}
}

View File

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

View File

@ -1,8 +1,8 @@
import React, { FC, useMemo } from "react";
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 { SortableItem } from "@/components/SortableDnd/SortableItem";
type Props = {
deal: DealSchema;
@ -15,9 +15,9 @@ const DealContainer: FC<Props> = ({ deal }) => {
<Box>
<SortableItem
dragHandleStyle={{ cursor: "pointer" }}
id={deal.id}>
{dealBody}
</SortableItem>
id={deal.id}
renderItem={() => dealBody}
/>
</Box>
);
};

View File

@ -0,0 +1,89 @@
"use client";
import React, { FC, ReactNode } from "react";
import { Group, ScrollArea } from "@mantine/core";
import CreateStatusButton from "@/app/deals/components/shared/CreateStatusButton/CreateStatusButton";
import DealCard from "@/app/deals/components/shared/DealCard/DealCard";
import DealContainer from "@/app/deals/components/shared/DealContainer/DealContainer";
import StatusColumnWrapper from "@/app/deals/components/shared/StatusColumnWrapper/StatusColumnWrapper";
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 { deals } = useDealsContext();
const isMobile = useIsMobile();
const {
sortedStatuses,
handleDragStart,
handleDragOver,
handleDragEnd,
activeStatus,
activeDeal,
} = useDealsAndStatusesDnd();
const renderFunnelDnd = () => (
<FunnelDnd
containers={sortedStatuses}
items={deals}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
getContainerId={(status: StatusSchema) => `${status.id}-status`}
getItemsByContainer={(status: StatusSchema, items: DealSchema[]) =>
sortByLexorank(
items.filter(deal => deal.statusId === status.id)
)
}
renderContainer={(
status: StatusSchema,
funnelColumnComponent: ReactNode
) => (
<StatusColumnWrapper
status={status}
isDragging={activeStatus?.id === status.id}>
{funnelColumnComponent}
</StatusColumnWrapper>
)}
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}
isDragging>
{children}
</StatusColumnWrapper>
)}
disabledColumns={isMobile}
/>
);
if (isMobile) return renderFunnelDnd();
return (
<ScrollArea
offsetScrollbars={"x"}
scrollbarSize={"0.5rem"}>
<Group
align={"start"}
wrap={"nowrap"}
gap={"xs"}>
{renderFunnelDnd()}
<CreateStatusButton />
</Group>
</ScrollArea>
);
};
export default Funnel;

View File

@ -0,0 +1,71 @@
"use client";
import { IconChevronLeft, IconSettings } from "@tabler/icons-react";
import { Box, Group, Stack, Text } from "@mantine/core";
import Boards from "@/app/deals/components/desktop/Boards/Boards";
import BoardsMobile from "@/app/deals/components/mobile/BoardsMobile/BoardsMobile";
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={"space-between"}
wrap={"nowrap"}
pr={"md"}>
<Boards />
<ColorSchemeToggle />
<ProjectSelect
data={projects}
value={selectedProject}
onChange={value => value && setSelectedProject(value)}
/>
</Group>
);
};
const getMobileHeader = () => {
return (
<Stack gap={0}>
<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>
<BoardsMobile />
</Stack>
);
};
return (
<Group
justify={"flex-end"}
w={"100%"}>
{isMobile ? getMobileHeader() : getDesktopHeader()}
</Group>
);
};
export default Header;

View File

@ -0,0 +1,13 @@
.container {
min-width: 150px;
width: 15vw;
@media (max-width: 48em) {
width: 80vw;
}
}
.header {
border-bottom: solid dodgerblue 3px;
}

View File

@ -0,0 +1,70 @@
import React, { ReactNode } from "react";
import { Box, Group, Text } from "@mantine/core";
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";
import styles from "./StatusColumnWrapper.module.css";
type Props = {
status: StatusSchema;
isDragging?: boolean;
children: ReactNode;
};
const StatusColumnWrapper = ({
status,
children,
isDragging = false,
}: Props) => {
const { onUpdateStatus } = useStatusesContext();
const handleSave = (value: string) => {
const newValue = value.trim();
if (newValue && newValue !== status.name) {
onUpdateStatus(status.id, { name: newValue });
}
};
return (
<Box className={styles.container}>
<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>
{children}
</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,
} from "react";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import { BoardSchema } from "@/lib/client";
import useBoardsList from "@/hooks/useBoardsList";
import { useBoardsOperations } from "@/hooks/useBoardsOperations";
import { BoardSchema, UpdateBoardSchema } from "@/lib/client";
type BoardsContextState = {
boards: BoardSchema[];
@ -17,16 +18,27 @@ type BoardsContextState = {
selectedBoard: BoardSchema | null;
setSelectedBoard: React.Dispatch<React.SetStateAction<BoardSchema | null>>;
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 useBoardsContextState = () => {
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>(
null
);
const [isEditorDrawerOpened, setIsEditorDrawerOpened] =
useState<boolean>(false);
useEffect(() => {
if (boards.length > 0 && selectedBoard === null) {
@ -34,22 +46,36 @@ const useBoardsContextState = () => {
return;
}
if (selectedBoard) {
let newBoard = boards.find(board => board.id === selectedBoard.id);
if (!selectedBoard) return;
if (!newBoard && boards.length > 0) {
newBoard = boards[0];
}
setSelectedBoard(newBoard ?? null);
let newBoard = boards.find(board => board.id === selectedBoard.id);
if (!newBoard && boards.length > 0) {
newBoard = boards[0];
}
setSelectedBoard(newBoard ?? null);
}, [boards]);
const { onCreateBoard, onUpdateBoard, onDeleteBoard } = useBoardsOperations(
{
boards,
setBoards,
refetchBoards,
projectId: project?.id,
}
);
return {
boards,
setBoards,
selectedBoard,
setSelectedBoard,
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,
useState,
} from "react";
import { ProjectSchema } from "@/lib/client";
import useProjectsList from "@/hooks/useProjectsList";
import { useProjectsOperations } from "@/hooks/useProjectsOperations";
import { ProjectSchema, UpdateProjectSchema } from "@/lib/client";
type ProjectsContextState = {
selectedProject: ProjectSchema | null;
@ -16,6 +17,11 @@ type ProjectsContextState = {
React.SetStateAction<ProjectSchema | null>
>;
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>(
@ -23,7 +29,13 @@ const ProjectsContext = createContext<ProjectsContextState | undefined>(
);
const useProjectsContextState = () => {
const { projects } = useProjectsList();
const [isEditorDrawerOpened, setIsEditorDrawerOpened] =
useState<boolean>(false);
const {
projects,
setProjects,
refetch: refetchProjects,
} = useProjectsList();
const [selectedProject, setSelectedProject] =
useState<ProjectSchema | null>(null);
@ -43,10 +55,22 @@ const useProjectsContextState = () => {
setSelectedProject(null);
}, [projects]);
const { onCreateProject, onUpdateProject, onDeleteProject } =
useProjectsOperations({
projects,
setProjects,
refetchProjects,
});
return {
projects,
selectedProject,
setSelectedProject,
onCreateProject,
onUpdateProject,
onDeleteProject,
isEditorDrawerOpened,
setIsEditorDrawerOpened,
};
};

View File

@ -1,18 +1,42 @@
"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 { DealSchema, StatusSchema } from "@/lib/client";
import useDealsList from "@/hooks/useDealsList";
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 = {
statuses: StatusSchema[];
setStatuses: React.Dispatch<React.SetStateAction<StatusSchema[]>>;
deals: DealSchema[];
setDeals: React.Dispatch<React.SetStateAction<DealSchema[]>>;
updateStatus: UseMutationResult<
UpdateStatusResponse,
AxiosError<HttpValidationError>,
Options<UpdateStatusData>
>;
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>(
@ -28,24 +52,42 @@ const useStatusesContextState = () => {
} = useStatusesList({
boardId: selectedBoard?.id,
});
const {
deals,
setDeals,
refetch: refetchDeals,
} = useDealsList({ boardId: selectedBoard?.id });
const [isEditorDrawerOpened, setIsEditorDrawerOpened] =
useState<boolean>(false);
useEffect(() => {
refetchStatuses();
}, [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 {
statuses,
setStatuses,
deals,
setDeals,
updateStatus,
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,46 @@
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}
menuIconSize={22}
/>
</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,25 +1,27 @@
import { useMemo, useState } from "react";
import { DragOverEvent, DragStartEvent, Over } from "@dnd-kit/core";
import { useDebouncedCallback } from "@mantine/hooks";
import { useDealsContext } from "@/app/deals/contexts/DealsContext";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import useGetNewRank from "@/app/deals/hooks/useGetNewRank";
import { getStatusId, isStatusId } from "@/app/deals/utils/statusId";
import { DealSchema, StatusSchema } from "@/lib/client";
import { sortByLexorank } from "@/utils/lexorank";
type Props = {
onDealDragEnd: (
dealId: number,
statusId: number,
lexorank?: string
) => void;
onStatusDragEnd: (statusId: number, lexorank: string) => void;
type ReturnType = {
sortedStatuses: StatusSchema[];
handleDragStart: ({ active }: DragStartEvent) => void;
handleDragOver: ({ active, over }: DragOverEvent) => void;
handleDragEnd: ({ active, over }: DragOverEvent) => void;
activeStatus: StatusSchema | null;
activeDeal: DealSchema | null;
};
const useDealsAndStatusesDnd = (props: Props) => {
const useDealsAndStatusesDnd = (): ReturnType => {
const [activeDeal, setActiveDeal] = useState<DealSchema | 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 {
@ -173,7 +175,20 @@ const useDealsAndStatusesDnd = (props: Props) => {
const newRank = getNewStatusRank(activeStatusId, overStatusId);
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) => {
@ -189,7 +204,25 @@ const useDealsAndStatusesDnd = (props: Props) => {
);
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) => {

View File

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

View File

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

View File

@ -1,28 +1,37 @@
import { Divider } from "@mantine/core";
import Boards from "@/app/deals/components/Boards/Boards";
import Header from "@/app/deals/components/Header/Header";
import StatusColumns from "@/app/deals/components/StatusColumns/StatusColumns";
import Funnel from "@/app/deals/components/shared/Funnel/Funnel";
import Header from "@/app/deals/components/shared/Header/Header";
import { BoardsContextProvider } from "@/app/deals/contexts/BoardsContext";
import { ProjectsContextProvider } from "@/app/deals/contexts/ProjectsContext";
import { StatusesContextProvider } from "@/app/deals/contexts/StatusesContext";
import PageBlock from "@/components/PageBlock/PageBlock";
import PageContainer from "@/components/PageContainer/PageContainer";
import BoardStatusesEditorDrawer from "@/app/deals/drawers/BoardStatusesEditorDrawer";
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() {
return (
<PageContainer>
<PageBlock>
<ProjectsContextProvider>
<BoardsContextProvider>
<ProjectsContextProvider>
<BoardsContextProvider>
<PageContainer>
<PageBlock
transparent
style={{ padding: 0 }}>
<Header />
<Boards />
<Divider my={"xl"} />
</PageBlock>
<PageBlock className={"mobile-padding-height"}>
<StatusesContextProvider>
<StatusColumns />
<DealsContextProvider>
<Funnel />
</DealsContextProvider>
<BoardStatusesEditorDrawer />
</StatusesContextProvider>
</BoardsContextProvider>
</ProjectsContextProvider>
</PageBlock>
</PageContainer>
<ProjectBoardsEditorDrawer />
<ProjectsEditorDrawer />
</PageBlock>
</PageContainer>
</BoardsContextProvider>
</ProjectsContextProvider>
);
}

View File

@ -1,7 +1,20 @@
@import "tailwindcss";
:root {
/* Colors */
--color-light-gray-blue: #f4f7fd;
--color-light-aqua: #e0f0f4;
--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 {
@mixin light {
background-color: whitesmoke;
background-color: var(--color-light-gray-blue);
}
}

View File

@ -1,5 +1,6 @@
import "@mantine/core/styles.css";
import "@mantine/notifications/styles.css";
import "@mantine/carousel/styles.css";
import { ReactNode } from "react";
import {
ColorSchemeScript,
@ -40,10 +41,6 @@ export default function RootLayout({ children }: Props) {
rel="shortcut icon"
href="/favicon.svg"
/>
<link
rel="stylesheet"
href="global.css"
/>
<meta
name="viewport"
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,33 @@
.overlay {
cursor: grabbing;
border-radius: 10px;
@media (max-width: 48em) {
@mixin dark {
box-shadow: var(--dark-shadow);
}
@mixin light {
box-shadow: var(--light-shadow);
}
}
}
.indicator {
height: 4px;
@mixin light {
background-color: lightgray;
}
@mixin dark {
background-color: var(--mantine-color-dark-6);
}
&[data-active] {
@mixin light {
background-color: gray;
}
@mixin dark {
background-color: var(--mantine-color-dark-5);
}
}
}

View File

@ -0,0 +1,154 @@
"use client";
import React, { ReactNode } from "react";
import {
closestCorners,
DndContext,
DragEndEvent,
DragOverEvent,
DragStartEvent,
} from "@dnd-kit/core";
import {
horizontalListSortingStrategy,
SortableContext,
} from "@dnd-kit/sortable";
import { Carousel } from "@mantine/carousel";
import { Group } from "@mantine/core";
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 styles from "./FunnelDnd.module.css";
type Props<TContainer, TItem> = {
containers: TContainer[];
items: TItem[];
onDragStart: (event: DragStartEvent) => void;
onDragOver: (event: DragOverEvent) => void;
onDragEnd: (event: DragEndEvent) => void;
renderContainer: (container: TContainer, children: ReactNode) => 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;
disabledColumns?: boolean;
};
const FunnelDnd = <
TContainer extends BaseDraggable,
TItem extends BaseDraggable,
>({
containers,
items,
onDragStart,
onDragOver,
onDragEnd,
renderContainer,
renderContainerOverlay,
renderItem,
renderItemOverlay,
getContainerId,
getItemsByContainer,
activeContainer,
activeItem,
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);
const item = (
<SortableItem
key={containerId}
id={containerId}
disabled={disabledColumns}
renderItem={() =>
renderContainer(
container,
<FunnelColumn
id={containerId}
items={containerItems}
renderItem={renderItem}
/>
)
}
/>
);
if (!isMobile) return item;
return <Carousel.Slide key={containerId}>{item}</Carousel.Slide>;
});
const renderBody = () => {
if (isMobile) {
return (
<Carousel
slideSize={"80%"}
slideGap={"md"}
pb={"xl"}
withControls={false}
withIndicators
classNames={styles}>
{renderContainers()}
<CreateStatusButton />
</Carousel>
);
}
return (
<Group
gap={"xs"}
wrap="nowrap"
align="start">
{renderContainers()}
</Group>
);
};
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
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,151 @@
"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 { 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";
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;
};
const SortableDnd = <T extends BaseItem>({
initialItems,
renderItem,
renderDraggable,
dragHandleStyle,
onDragEnd,
onItemClick,
containerStyle,
vertical,
disabled = 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);
};
return (
<DndContext
modifiers={[restrictToHorizontalAxis]}
sensors={sensors}
onDragStart={({ active }) => setActive(active)}
onDragEnd={onDragEndLocal}
onDragCancel={() => setActive(null)}>
<SortableContext
items={items}
disabled={disabled}>
<Flex
gap={0}
style={{
flexWrap: "nowrap",
flexDirection: vertical ? "column" : "row",
...containerStyle,
}}
role="application">
{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>
</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) {
return (
<DragOverlay dropAnimation={dropAnimationConfig}>
{children}
<div style={{ cursor: "grabbing" }}>{children}</div>
</DragOverlay>
);
}

View File

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

View File

@ -0,0 +1,32 @@
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",
...style,
}}
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,61 @@
.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: 100% !important;
@media (min-width: 48em) {
height: 89vh;
}
}
.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 classNames from "classnames";
import styles from "./PageBlock.module.css";
5
import { Box } from "@mantine/core";
type Props = {
children: ReactNode;
style?: CSSProperties;
className?: string;
fullHeight?: boolean;
fullHeightFixed?: boolean;
noBorderRadius?: boolean;
fullScreenMobile?: boolean;
transparent?: boolean;
};
const PageBlock: FC<Props> = ({
children,
style,
className = "",
fullHeight = false,
fullHeightFixed = false,
noBorderRadius = false,
fullScreenMobile = false,
transparent = false,
}) => {
return (
<div
<Box
bdrs={"lg"}
style={style}
className={classNames(
styles.container,
fullHeight && styles["container-full-height"],
fullHeightFixed && styles["container-full-height-fixed"],
noBorderRadius && styles["container-no-border-radius"],
fullScreenMobile && styles["container-full-screen-mobile"]
fullScreenMobile && styles["container-full-screen-mobile"],
transparent && styles.transparent,
styles[className]
)}>
{children}
</div>
</Box>
);
};
export default PageBlock;

View File

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

View File

@ -0,0 +1,33 @@
.container {
@mixin dark {
background-color: var(--mantine-color-dark-7-5);
box-shadow: var(--dark-shadow);
}
@mixin light {
background-color: var(--color-light-whitesmoke);
box-shadow: var(--light-shadow);
}
}
.container-active {
@mixin dark {
background-color: var(--mantine-color-dark-8);
box-shadow: var(--dark-thick-shadow);
}
@mixin light {
background-color: white;
box-shadow: var(--light-thick-shadow);
}
}
.container:hover {
@mixin dark {
background-color: var(--mantine-color-dark-8);
box-shadow: var(--dark-thick-shadow);
}
@mixin light {
background-color: white;
box-shadow: var(--light-thick-shadow);
}
}

View File

@ -0,0 +1,25 @@
import { CSSProperties, FC, ReactNode } from "react";
import classNames from "classnames";
import styles from "@/components/layout/SmallPageBlock/SmallPageBlock.module.css";
import { Box } from "@mantine/core";
type Props = {
children: ReactNode;
style?: CSSProperties;
active?: boolean;
};
const SmallPageBlock: FC<Props> = ({ children, style, active = false }) => {
return (
<Box
bdrs={"lg"}
className={classNames(
styles.container,
active && styles["container-active"]
)}
style={style}>
{children}
</Box>
);
};
export default SmallPageBlock;

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 => ({
...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 { ProjectSchema } from "@/lib/client";
import { getProjectsOptions } from "@/lib/client/@tanstack/react-query.gen";
const useProjectsList = () => {
const [projects, setProjects] = useState<ProjectSchema[]>([]);
const { data, refetch, isLoading } = useQuery({
...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;

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
import { type Options, getBoards, updateBoard, getDeals, updateDeal, getProjects, getStatuses, updateStatus } from '../sdk.gen';
import { queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import type { GetBoardsData, UpdateBoardData, UpdateBoardError, UpdateBoardResponse2, GetDealsData, UpdateDealData, UpdateDealError, UpdateDealResponse2, GetProjectsData, GetStatusesData, UpdateStatusData, UpdateStatusError, UpdateStatusResponse2 } from '../types.gen';
import type { AxiosError } from 'axios';
import { client as _heyApiClient } from '../client.gen';
import { queryOptions, type UseMutationOptions } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { client as _heyApiClient } from "../client.gen";
import {
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> = [
Pick<TOptions, 'baseURL' | 'body' | 'headers' | 'path' | 'query'> & {
Pick<TOptions, "baseURL" | "body" | "headers" | "path" | "query"> & {
_id: string;
_infinite?: boolean;
}
},
];
const createQueryKey = <TOptions extends Options>(id: string, options?: TOptions, infinite?: boolean): [
QueryKey<TOptions>[0]
] => {
const params: QueryKey<TOptions>[0] = { _id: id, baseURL: options?.baseURL || (options?.client ?? _heyApiClient).getConfig().baseURL } as QueryKey<TOptions>[0];
const createQueryKey = <TOptions extends Options>(
id: string,
options?: TOptions,
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) {
params._infinite = infinite;
}
@ -32,12 +90,11 @@ const createQueryKey = <TOptions extends Options>(id: string, options?: TOptions
if (options?.query) {
params.query = options.query;
}
return [
params
];
return [params];
};
export const getBoardsQueryKey = (options: Options<GetBoardsData>) => createQueryKey('getBoards', options);
export const getBoardsQueryKey = (options: Options<GetBoardsData>) =>
createQueryKey("getBoards", options);
/**
* Get Boards
@ -49,32 +106,118 @@ export const getBoardsOptions = (options: Options<GetBoardsData>) => {
...options,
...queryKey[0],
signal,
throwOnError: true
throwOnError: true,
});
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
*/
export const updateBoardMutation = (options?: Partial<Options<UpdateBoardData>>): UseMutationOptions<UpdateBoardResponse2, AxiosError<UpdateBoardError>, Options<UpdateBoardData>> => {
const mutationOptions: UseMutationOptions<UpdateBoardResponse2, AxiosError<UpdateBoardError>, Options<UpdateBoardData>> = {
mutationFn: async (localOptions) => {
export const updateBoardMutation = (
options?: Partial<Options<UpdateBoardData>>
): UseMutationOptions<
UpdateBoardResponse2,
AxiosError<UpdateBoardError>,
Options<UpdateBoardData>
> => {
const mutationOptions: UseMutationOptions<
UpdateBoardResponse2,
AxiosError<UpdateBoardError>,
Options<UpdateBoardData>
> = {
mutationFn: async localOptions => {
const { data } = await updateBoard({
...options,
...localOptions,
throwOnError: true
throwOnError: true,
});
return data;
}
},
};
return mutationOptions;
};
export const getDealsQueryKey = (options: Options<GetDealsData>) => createQueryKey('getDeals', options);
export const getDealsQueryKey = (options: Options<GetDealsData>) =>
createQueryKey("getDeals", options);
/**
* Get Deals
@ -86,32 +229,43 @@ export const getDealsOptions = (options: Options<GetDealsData>) => {
...options,
...queryKey[0],
signal,
throwOnError: true
throwOnError: true,
});
return data;
},
queryKey: getDealsQueryKey(options)
queryKey: getDealsQueryKey(options),
});
};
/**
* Update Deal
*/
export const updateDealMutation = (options?: Partial<Options<UpdateDealData>>): UseMutationOptions<UpdateDealResponse2, AxiosError<UpdateDealError>, Options<UpdateDealData>> => {
const mutationOptions: UseMutationOptions<UpdateDealResponse2, AxiosError<UpdateDealError>, Options<UpdateDealData>> = {
mutationFn: async (localOptions) => {
export const updateDealMutation = (
options?: Partial<Options<UpdateDealData>>
): UseMutationOptions<
UpdateDealResponse2,
AxiosError<UpdateDealError>,
Options<UpdateDealData>
> => {
const mutationOptions: UseMutationOptions<
UpdateDealResponse2,
AxiosError<UpdateDealError>,
Options<UpdateDealData>
> = {
mutationFn: async localOptions => {
const { data } = await updateDeal({
...options,
...localOptions,
throwOnError: true
throwOnError: true,
});
return data;
}
},
};
return mutationOptions;
};
export const getProjectsQueryKey = (options?: Options<GetProjectsData>) => createQueryKey('getProjects', options);
export const getProjectsQueryKey = (options?: Options<GetProjectsData>) =>
createQueryKey("getProjects", options);
/**
* Get Projects
@ -123,15 +277,118 @@ export const getProjectsOptions = (options?: Options<GetProjectsData>) => {
...options,
...queryKey[0],
signal,
throwOnError: true
throwOnError: true,
});
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
@ -143,27 +400,112 @@ export const getStatusesOptions = (options: Options<GetStatusesData>) => {
...options,
...queryKey[0],
signal,
throwOnError: true
throwOnError: true,
});
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
*/
export const updateStatusMutation = (options?: Partial<Options<UpdateStatusData>>): UseMutationOptions<UpdateStatusResponse2, AxiosError<UpdateStatusError>, Options<UpdateStatusData>> => {
const mutationOptions: UseMutationOptions<UpdateStatusResponse2, AxiosError<UpdateStatusError>, Options<UpdateStatusData>> = {
mutationFn: async (localOptions) => {
export const updateStatusMutation = (
options?: Partial<Options<UpdateStatusData>>
): UseMutationOptions<
UpdateStatusResponse2,
AxiosError<UpdateStatusError>,
Options<UpdateStatusData>
> => {
const mutationOptions: UseMutationOptions<
UpdateStatusResponse2,
AxiosError<UpdateStatusError>,
Options<UpdateStatusData>
> = {
mutationFn: async localOptions => {
const { data } = await updateStatus({
...options,
...localOptions,
throwOnError: true
throwOnError: true,
});
return data;
}
},
};
return mutationOptions;
};

View File

@ -1,8 +1,13 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { ClientOptions } from './types.gen';
import { type Config, type ClientOptions as DefaultClientOptions, createClient, createConfig } from './client';
import { createClientConfig } from '../../hey-api-config';
import { createClientConfig } from "../../hey-api-config";
import {
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
@ -12,8 +17,15 @@ import { createClientConfig } from '../../hey-api-config';
* `setConfig()`. This is useful for example if you're using Next.js
* 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>({
baseURL: '/api'
})));
export const client = createClient(
createClientConfig(
createConfig<ClientOptions>({
baseURL: "/api",
})
)
);

View File

@ -1,115 +1,114 @@
import type { AxiosError, RawAxiosRequestHeaders } from 'axios';
import axios from 'axios';
import type { Client, Config } from './types';
import type { AxiosError, RawAxiosRequestHeaders } from "axios";
import axios from "axios";
import type { Client, Config } from "./types";
import {
buildUrl,
createConfig,
mergeConfigs,
mergeHeaders,
setAuthParams,
} from './utils';
buildUrl,
createConfig,
mergeConfigs,
mergeHeaders,
setAuthParams,
} from "./utils";
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
const { auth, ...configWithoutAuth } = _config;
const instance = axios.create(configWithoutAuth);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { auth, ...configWithoutAuth } = _config;
const instance = axios.create(configWithoutAuth);
const getConfig = (): Config => ({ ..._config });
const getConfig = (): Config => ({ ..._config });
const setConfig = (config: Config): Config => {
_config = mergeConfigs(_config, config);
instance.defaults = {
...instance.defaults,
..._config,
// @ts-expect-error
headers: mergeHeaders(instance.defaults.headers, _config.headers),
};
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),
const setConfig = (config: Config): Config => {
_config = mergeConfigs(_config, config);
instance.defaults = {
...instance.defaults,
..._config,
// @ts-expect-error
headers: mergeHeaders(instance.defaults.headers, _config.headers),
};
return getConfig();
};
if (opts.security) {
await setAuthParams({
...opts,
security: opts.security,
});
}
// @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.requestValidator) {
await opts.requestValidator(opts);
}
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.security) {
await setAuthParams({
...opts,
security: opts.security,
});
}
if (opts.responseTransformer) {
data = await opts.responseTransformer(data);
if (opts.requestValidator) {
await opts.requestValidator(opts);
}
}
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;
}
};
if (opts.body && opts.bodySerializer) {
opts.body = opts.bodySerializer(opts.body);
}
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;
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) {
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 { QuerySerializerOptions } from '../core/bodySerializer';
export type { Auth } from "../core/auth";
export type { QuerySerializerOptions } from "../core/bodySerializer";
export {
formDataBodySerializer,
jsonBodySerializer,
urlSearchParamsBodySerializer,
} from '../core/bodySerializer';
export { buildClientParams } from '../core/params';
export { createClient } from './client';
formDataBodySerializer,
jsonBodySerializer,
urlSearchParamsBodySerializer,
} from "../core/bodySerializer";
export { buildClientParams } from "../core/params";
export { createClient } from "./client";
export type {
Client,
ClientOptions,
Config,
CreateClientConfig,
Options,
OptionsLegacyParser,
RequestOptions,
RequestResult,
TDataShape,
} from './types';
export { createConfig } from './utils';
Client,
ClientOptions,
Config,
CreateClientConfig,
Options,
OptionsLegacyParser,
RequestOptions,
RequestResult,
TDataShape,
} from "./types";
export { createConfig } from "./utils";

View File

@ -1,138 +1,141 @@
import type {
AxiosError,
AxiosInstance,
AxiosRequestHeaders,
AxiosResponse,
AxiosStatic,
CreateAxiosDefaults,
} from 'axios';
import type { Auth } from '../core/auth';
import type {
Client as CoreClient,
Config as CoreConfig,
} from '../core/types';
AxiosError,
AxiosInstance,
AxiosRequestHeaders,
AxiosResponse,
AxiosStatic,
CreateAxiosDefaults,
} from "axios";
import type { Auth } from "../core/auth";
import type { Client as CoreClient, Config as CoreConfig } from "../core/types";
export interface Config<T extends ClientOptions = ClientOptions>
extends Omit<CreateAxiosDefaults, 'auth' | 'baseURL' | 'headers' | 'method'>,
CoreConfig {
/**
* Axios implementation. You can use this option to provide a custom
* Axios instance.
*
* @default axios
*/
axios?: AxiosStatic;
/**
* Base URL for all requests made by this client.
*/
baseURL?: T['baseURL'];
/**
* An object containing any HTTP headers that you want to pre-populate your
* `Headers` object with.
*
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
*/
headers?:
| AxiosRequestHeaders
| Record<
string,
| string
| number
| boolean
| (string | number | boolean)[]
| null
| undefined
| unknown
>;
/**
* Throw an error instead of returning it in the response?
*
* @default false
*/
throwOnError?: T['throwOnError'];
extends Omit<
CreateAxiosDefaults,
"auth" | "baseURL" | "headers" | "method"
>,
CoreConfig {
/**
* Axios implementation. You can use this option to provide a custom
* Axios instance.
*
* @default axios
*/
axios?: AxiosStatic;
/**
* Base URL for all requests made by this client.
*/
baseURL?: T["baseURL"];
/**
* An object containing any HTTP headers that you want to pre-populate your
* `Headers` object with.
*
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
*/
headers?:
| AxiosRequestHeaders
| Record<
string,
| string
| number
| boolean
| (string | number | boolean)[]
| null
| undefined
| unknown
>;
/**
* Throw an error instead of returning it in the response?
*
* @default false
*/
throwOnError?: T["throwOnError"];
}
export interface RequestOptions<
ThrowOnError extends boolean = boolean,
Url extends string = string,
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends Config<{
throwOnError: ThrowOnError;
}> {
/**
* Any body that you want to add to your request.
*
* {@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 {
throwOnError: ThrowOnError;
}> {
/**
* Any body that you want to add to your request.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
*/
body?: unknown;
path?: 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;
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.
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
override?: Config<ClientOptions & T>,
override?: Config<ClientOptions & T>
) => Config<Required<ClientOptions> & T>;
export interface TDataShape {
body?: unknown;
headers?: unknown;
path?: unknown;
query?: unknown;
url: string;
body?: unknown;
headers?: unknown;
path?: unknown;
query?: unknown;
url: string;
}
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
export type Options<
TData extends TDataShape = TDataShape,
ThrowOnError extends boolean = boolean,
> = OmitKeys<RequestOptions<ThrowOnError>, 'body' | 'path' | 'query' | 'url'> &
Omit<TData, 'url'>;
TData extends TDataShape = TDataShape,
ThrowOnError extends boolean = boolean,
> = OmitKeys<RequestOptions<ThrowOnError>, "body" | "path" | "query" | "url"> &
Omit<TData, "url">;
export type OptionsLegacyParser<
TData = unknown,
ThrowOnError extends boolean = boolean,
TData = unknown,
ThrowOnError extends boolean = boolean,
> = TData extends { body?: any }
? TData extends { headers?: any }
? OmitKeys<RequestOptions<ThrowOnError>, 'body' | 'headers' | 'url'> & TData
: OmitKeys<RequestOptions<ThrowOnError>, 'body' | 'url'> &
TData &
Pick<RequestOptions<ThrowOnError>, 'headers'>
: TData extends { headers?: any }
? OmitKeys<RequestOptions<ThrowOnError>, 'headers' | 'url'> &
TData &
Pick<RequestOptions<ThrowOnError>, 'body'>
: OmitKeys<RequestOptions<ThrowOnError>, 'url'> & TData;
? TData extends { headers?: any }
? OmitKeys<RequestOptions<ThrowOnError>, "body" | "headers" | "url"> &
TData
: OmitKeys<RequestOptions<ThrowOnError>, "body" | "url"> &
TData &
Pick<RequestOptions<ThrowOnError>, "headers">
: TData extends { headers?: any }
? OmitKeys<RequestOptions<ThrowOnError>, "headers" | "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 {
QuerySerializer,
QuerySerializerOptions,
} from '../core/bodySerializer';
import type { ArraySeparatorStyle } from '../core/pathSerializer';
QuerySerializer,
QuerySerializerOptions,
} from "../core/bodySerializer";
import type { ArraySeparatorStyle } from "../core/pathSerializer";
import {
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from '../core/pathSerializer';
import type { Client, ClientOptions, Config, RequestOptions } from './types';
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from "../core/pathSerializer";
import type { Client, ClientOptions, Config, RequestOptions } from "./types";
interface PathSerializer {
path: Record<string, unknown>;
url: string;
path: Record<string, unknown>;
url: string;
}
const PATH_PARAM_RE = /\{[^{}]+\}/g;
const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
let url = _url;
const matches = _url.match(PATH_PARAM_RE);
if (matches) {
for (const match of matches) {
let explode = false;
let name = match.substring(1, match.length - 1);
let style: ArraySeparatorStyle = 'simple';
let url = _url;
const matches = _url.match(PATH_PARAM_RE);
if (matches) {
for (const match of matches) {
let explode = false;
let name = match.substring(1, match.length - 1);
let style: ArraySeparatorStyle = "simple";
if (name.endsWith('*')) {
explode = true;
name = name.substring(0, name.length - 1);
}
if (name.endsWith("*")) {
explode = true;
name = name.substring(0, name.length - 1);
}
if (name.startsWith('.')) {
name = name.substring(1);
style = 'label';
} else if (name.startsWith(';')) {
name = name.substring(1);
style = 'matrix';
}
if (name.startsWith(".")) {
name = name.substring(1);
style = "label";
} else if (name.startsWith(";")) {
name = name.substring(1);
style = "matrix";
}
const value = path[name];
const value = path[name];
if (value === undefined || value === null) {
continue;
}
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
url = url.replace(
match,
serializeArrayParam({ explode, name, style, value }),
);
continue;
}
if (Array.isArray(value)) {
url = url.replace(
match,
serializeArrayParam({ explode, name, style, value })
);
continue;
}
if (typeof value === 'object') {
url = url.replace(
match,
serializeObjectParam({
explode,
name,
style,
value: value as Record<string, unknown>,
valueOnly: true,
}),
);
continue;
}
if (typeof value === "object") {
url = url.replace(
match,
serializeObjectParam({
explode,
name,
style,
value: value as Record<string, unknown>,
valueOnly: true,
})
);
continue;
}
if (style === 'matrix') {
url = url.replace(
match,
`;${serializePrimitiveParam({
name,
value: value as string,
})}`,
);
continue;
}
if (style === "matrix") {
url = url.replace(
match,
`;${serializePrimitiveParam({
name,
value: value as string,
})}`
);
continue;
}
const replaceValue = encodeURIComponent(
style === 'label' ? `.${value as string}` : (value as string),
);
url = url.replace(match, replaceValue);
const replaceValue = encodeURIComponent(
style === "label" ? `.${value as string}` : (value as string)
);
url = url.replace(match, replaceValue);
}
}
}
return url;
return url;
};
export const createQuerySerializer = <T = unknown>({
allowReserved,
array,
object,
allowReserved,
array,
object,
}: QuerySerializerOptions = {}) => {
const querySerializer = (queryParams: T) => {
const search: string[] = [];
if (queryParams && typeof queryParams === 'object') {
for (const name in queryParams) {
const value = queryParams[name];
const querySerializer = (queryParams: T) => {
const search: string[] = [];
if (queryParams && typeof queryParams === "object") {
for (const name in queryParams) {
const value = queryParams[name];
if (value === undefined || value === null) {
continue;
}
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
const serializedArray = serializeArrayParam({
allowReserved,
explode: true,
name,
style: 'form',
value,
...array,
});
if (serializedArray) search.push(serializedArray);
} else if (typeof value === 'object') {
const serializedObject = serializeObjectParam({
allowReserved,
explode: true,
name,
style: 'deepObject',
value: value as Record<string, unknown>,
...object,
});
if (serializedObject) search.push(serializedObject);
} else {
const serializedPrimitive = serializePrimitiveParam({
allowReserved,
name,
value: value as string,
});
if (serializedPrimitive) search.push(serializedPrimitive);
if (Array.isArray(value)) {
const serializedArray = serializeArrayParam({
allowReserved,
explode: true,
name,
style: "form",
value,
...array,
});
if (serializedArray) search.push(serializedArray);
} else if (typeof value === "object") {
const serializedObject = serializeObjectParam({
allowReserved,
explode: true,
name,
style: "deepObject",
value: value as Record<string, unknown>,
...object,
});
if (serializedObject) search.push(serializedObject);
} else {
const serializedPrimitive = serializePrimitiveParam({
allowReserved,
name,
value: value as string,
});
if (serializedPrimitive) search.push(serializedPrimitive);
}
}
}
}
}
return search.join('&');
};
return querySerializer;
return search.join("&");
};
return querySerializer;
};
export const setAuthParams = async ({
security,
...options
}: Pick<Required<RequestOptions>, 'security'> &
Pick<RequestOptions, 'auth' | 'query'> & {
headers: Record<any, unknown>;
}) => {
for (const auth of security) {
const token = await getAuthToken(auth, options.auth);
security,
...options
}: Pick<Required<RequestOptions>, "security"> &
Pick<RequestOptions, "auth" | "query"> & {
headers: Record<any, unknown>;
}) => {
for (const auth of security) {
const token = await getAuthToken(auth, options.auth);
if (!token) {
continue;
}
const name = auth.name ?? 'Authorization';
switch (auth.in) {
case 'query':
if (!options.query) {
options.query = {};
if (!token) {
continue;
}
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) => {
const url = getUrl({
path: options.path,
// let `paramsSerializer()` handle query params if it exists
query: !options.paramsSerializer ? options.query : undefined,
querySerializer:
typeof options.querySerializer === 'function'
? options.querySerializer
: createQuerySerializer(options.querySerializer),
url: options.url,
});
return url;
export const buildUrl: Client["buildUrl"] = options => {
const url = getUrl({
path: options.path,
// let `paramsSerializer()` handle query params if it exists
query: !options.paramsSerializer ? options.query : undefined,
querySerializer:
typeof options.querySerializer === "function"
? options.querySerializer
: createQuerySerializer(options.querySerializer),
url: options.url,
});
return url;
};
export const getUrl = ({
path,
query,
querySerializer,
url: _url,
path,
query,
querySerializer,
url: _url,
}: {
path?: Record<string, unknown>;
query?: Record<string, unknown>;
querySerializer: QuerySerializer;
url: string;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
querySerializer: QuerySerializer;
url: string;
}) => {
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
let url = pathUrl;
if (path) {
url = defaultPathSerializer({ path, url });
}
let search = query ? querySerializer(query) : '';
if (search.startsWith('?')) {
search = search.substring(1);
}
if (search) {
url += `?${search}`;
}
return url;
const pathUrl = _url.startsWith("/") ? _url : `/${_url}`;
let url = pathUrl;
if (path) {
url = defaultPathSerializer({ path, url });
}
let search = query ? querySerializer(query) : "";
if (search.startsWith("?")) {
search = search.substring(1);
}
if (search) {
url += `?${search}`;
}
return url;
};
export const mergeConfigs = (a: Config, b: Config): Config => {
const config = { ...a, ...b };
config.headers = mergeHeaders(a.headers, b.headers);
return config;
const config = { ...a, ...b };
config.headers = mergeHeaders(a.headers, b.headers);
return config;
};
/**
* Special Axios headers keywords allowing to set headers by request method.
*/
export const axiosHeadersKeywords = [
'common',
'delete',
'get',
'head',
'patch',
'post',
'put',
"common",
"delete",
"get",
"head",
"patch",
"post",
"put",
] as const;
export const mergeHeaders = (
...headers: Array<Required<Config>['headers'] | undefined>
...headers: Array<Required<Config>["headers"] | undefined>
): Record<any, unknown> => {
const mergedHeaders: Record<any, unknown> = {};
for (const header of headers) {
if (!header || typeof header !== 'object') {
continue;
}
const iterator = Object.entries(header);
for (const [key, value] of iterator) {
if (
axiosHeadersKeywords.includes(
key as (typeof axiosHeadersKeywords)[number],
) &&
typeof value === 'object'
) {
mergedHeaders[key] = {
...(mergedHeaders[key] as Record<any, unknown>),
...value,
};
} else if (value === null) {
delete mergedHeaders[key];
} else if (Array.isArray(value)) {
for (const v of value) {
// @ts-expect-error
mergedHeaders[key] = [...(mergedHeaders[key] ?? []), v as string];
const mergedHeaders: Record<any, unknown> = {};
for (const header of headers) {
if (!header || typeof header !== "object") {
continue;
}
const iterator = Object.entries(header);
for (const [key, value] of iterator) {
if (
axiosHeadersKeywords.includes(
key as (typeof axiosHeadersKeywords)[number]
) &&
typeof value === "object"
) {
mergedHeaders[key] = {
...(mergedHeaders[key] as Record<any, unknown>),
...value,
};
} else if (value === null) {
delete mergedHeaders[key];
} else if (Array.isArray(value)) {
for (const v of value) {
// @ts-expect-error
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>(
override: Config<Omit<ClientOptions, keyof T> & T> = {},
override: Config<Omit<ClientOptions, keyof T> & T> = {}
): Config<Omit<ClientOptions, keyof T> & T> => ({
...override,
...override,
});

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