diff --git a/package-lock.json b/package-lock.json index 8dab4a7f6..419d43ded 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,9 @@ "react-hot-toast": "2.4.1", "react-input-mask": "2.0.4", "react-remark": "2.1.0", + "react-sortablejs": "6.1.4", "remark-emoji": "4.0.1", + "sortablejs": "1.15.3", "throttle-debounce": "5.0.0", "tinykeys": "1.4.0", "zod": "3.22.4" @@ -88,8 +90,10 @@ "@types/nodemailer": "6.4.14", "@types/pg": "8.11.4", "@types/react": "18.2.48", + "@types/react-beautiful-dnd": "13.1.8", "@types/react-dom": "18.2.19", "@types/react-input-mask": "3.0.5", + "@types/sortablejs": "1.15.8", "@types/throttle-debounce": "5.0.2", "@typescript-eslint/eslint-plugin": "5.62.0", "@typescript-eslint/parser": "5.62.0", @@ -2694,9 +2698,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.9.tgz", - "integrity": "sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.7.tgz", + "integrity": "sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -9102,6 +9106,15 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-beautiful-dnd": { + "version": "13.1.8", + "resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.8.tgz", + "integrity": "sha512-E3TyFsro9pQuK4r8S/OL6G99eq7p8v29sX0PM7oT8Z+PJfZvSQTx4zTQbUJ+QZXioAF0e7TGBEcA1XhYhCweyQ==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-dom": { "version": "18.2.19", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.19.tgz", @@ -9183,6 +9196,11 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/sortablejs": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.8.tgz", + "integrity": "sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==" + }, "node_modules/@types/throttle-debounce": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@types/throttle-debounce/-/throttle-debounce-5.0.2.tgz", @@ -23445,6 +23463,31 @@ } } }, + "node_modules/react-sortablejs": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/react-sortablejs/-/react-sortablejs-6.1.4.tgz", + "integrity": "sha512-fc7cBosfhnbh53Mbm6a45W+F735jwZ1UFIYSrIqcO/gRIFoDyZeMtgKlpV4DdyQfbCzdh5LoALLTDRxhMpTyXQ==", + "dependencies": { + "classnames": "2.3.1", + "tiny-invariant": "1.2.0" + }, + "peerDependencies": { + "@types/sortablejs": "1", + "react": ">=16.9.0", + "react-dom": ">=16.9.0", + "sortablejs": "1" + } + }, + "node_modules/react-sortablejs/node_modules/classnames": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz", + "integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==" + }, + "node_modules/react-sortablejs/node_modules/tiny-invariant": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.2.0.tgz", + "integrity": "sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg==" + }, "node_modules/react-ssr-prepass": { "version": "1.5.0", "license": "MIT", @@ -25187,6 +25230,11 @@ "react-dom": "^18.0.0" } }, + "node_modules/sortablejs": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.3.tgz", + "integrity": "sha512-zdK3/kwwAK1cJgy1rwl1YtNTbRmc8qW/+vgXf75A7NHag5of4pyI6uK86ktmQETyWRH7IGaE73uZOOBcGxgqZg==" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index 0d9be4284..1c07de893 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,9 @@ "react-hot-toast": "2.4.1", "react-input-mask": "2.0.4", "react-remark": "2.1.0", + "react-sortablejs": "6.1.4", "remark-emoji": "4.0.1", + "sortablejs": "1.15.3", "throttle-debounce": "5.0.0", "tinykeys": "1.4.0", "zod": "3.22.4" @@ -112,8 +114,10 @@ "@types/nodemailer": "6.4.14", "@types/pg": "8.11.4", "@types/react": "18.2.48", + "@types/react-beautiful-dnd": "13.1.8", "@types/react-dom": "18.2.19", "@types/react-input-mask": "3.0.5", + "@types/sortablejs": "1.15.8", "@types/throttle-debounce": "5.0.2", "@typescript-eslint/eslint-plugin": "5.62.0", "@typescript-eslint/parser": "5.62.0", diff --git a/src/components/Kanban/Kanban.module.css b/src/components/Kanban/Kanban.module.css index 4805d2ef8..723841c5a 100644 --- a/src/components/Kanban/Kanban.module.css +++ b/src/components/Kanban/Kanban.module.css @@ -1,3 +1,7 @@ +.KanbanColumn { + display: flex; + flex-direction: column; +} .KanbanState{ padding: var(--gap-m) 0; position: sticky; @@ -7,6 +11,9 @@ background: linear-gradient(var(--background) 70%, rgba(0, 0, 0, 0) 100%); z-index: 2; } +.KanbanSortableList { + flex: 1; +} .KanbanLink { display: block; diff --git a/src/components/Kanban/Kanban.tsx b/src/components/Kanban/Kanban.tsx index f3e5d4816..4567608e2 100644 --- a/src/components/Kanban/Kanban.tsx +++ b/src/components/Kanban/Kanban.tsx @@ -1,7 +1,8 @@ -import React, { FC, MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { ComponentProps, FC, MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { KanbanColumn, KanbanContainer } from '@taskany/bricks/harmony'; import { QueryStatus } from '@tanstack/react-query'; import { nullable, useIntersectionLoader } from '@taskany/bricks'; +import { ReactSortable } from 'react-sortablejs'; import { trpc } from '../../utils/trpcClient'; import { FilterById, State } from '../../../trpc/inferredTypes'; @@ -24,6 +25,7 @@ type onLoadingStateChange = (state: LoadingState) => void; interface KanbanStateColumnProps { state: State; + shownStates: State[]; projectId: string; filterPreset?: FilterById; partnershipProject?: string[]; @@ -66,6 +68,7 @@ const intersectionOptions = { const KanbanStateColumn: FC = ({ state, + shownStates, projectId, filterPreset, partnershipProject, @@ -75,22 +78,24 @@ const KanbanStateColumn: FC = ({ preset: filterPreset, }); - const { data, isFetching, fetchNextPage, hasNextPage, status } = - trpc.v2.project.getProjectGoalsById.useInfiniteQuery( - { - id: projectId, - goalsQuery: { - ...queryState, - partnershipProject: partnershipProject || undefined, - state: [state.id], - }, + const getColumnQuery = useCallback( + (stateId: string) => ({ + id: projectId, + goalsQuery: { + ...queryState, + partnershipProject: partnershipProject || undefined, + state: [stateId], }, - { - keepPreviousData: true, - staleTime: refreshInterval, - getNextPageParam: ({ pagination }) => pagination.offset, - }, - ); + }), + [queryState, partnershipProject, projectId], + ); + + const { data, isFetching, fetchNextPage, hasNextPage, status } = + trpc.v2.project.getProjectGoalsById.useInfiniteQuery(getColumnQuery(state.id), { + keepPreviousData: true, + staleTime: refreshInterval, + getNextPageParam: ({ pagination }) => pagination.offset, + }); const goals = useMemo(() => { if (data == null) { @@ -103,6 +108,12 @@ const KanbanStateColumn: FC = ({ }, []); }, [data]); + const [list, setList] = useState(goals); + + useEffect(() => { + setList(goals); + }, [goals]); + const loadingState = useMemo(() => { if (isFetching) { return 'loading'; @@ -132,37 +143,130 @@ const KanbanStateColumn: FC = ({ intersectionOptions, ); + const stateChangeMutations = trpc.goal.switchState.useMutation(); + const utils = trpc.useContext(); + + const onDragEnd = useCallback['onEnd']>>( + async (result) => { + const goalId = result.item.id; + const newStateId = result.to.id; + const oldStateId = result.from.id; + + const state = newStateId ? shownStates.find(({ id }) => newStateId === id) : null; + + if (!state) { + return; + } + + const data = utils.v2.project.getProjectGoalsById.getInfiniteData(getColumnQuery(oldStateId)); + + const goals = + data?.pages?.reduce<(typeof data)['pages'][number]['goals']>((acc, cur) => { + acc.push(...cur.goals); + return acc; + }, []) || []; + + const goal = goals.find((goal) => goal.id === goalId); + + await utils.v2.project.getProjectGoalsById.setInfiniteData(getColumnQuery(oldStateId), (data) => { + if (!data) { + return { + pages: [], + pageParams: [], + }; + } + + return { + ...data, + pages: data.pages.map((page) => ({ + ...page, + goals: page.goals.filter((goal) => goal.id !== goalId), + })), + }; + }); + + if (newStateId && goal) { + await utils.v2.project.getProjectGoalsById.setInfiniteData(getColumnQuery(newStateId), (data) => { + if (!data) { + return { + pages: [], + pageParams: [], + }; + } + + const [first, ...rest] = data.pages; + + const updatedFirst = { + ...first, + goals: [goal, ...first.goals], + }; + + return { + ...data, + pages: [updatedFirst, ...rest], + }; + }); + } + + await stateChangeMutations.mutate( + { + id: goalId, + state, + }, + { + onSettled: () => { + utils.v2.project.getProjectGoalsById.invalidate({ + id: projectId, + }); + }, + }, + ); + }, + [stateChangeMutations, utils, getColumnQuery, shownStates, projectId], + ); + return ( - + - - {goals.map((goal) => { - return ( - - + {list.map((goal) => { + return ( + - - ); - })} + key={goal.id} + href={routes.goal(goal._shortId)} + onClick={onGoalPreviewShow({ + _shortId: goal._shortId, + title: goal.title, + })} + className={s.KanbanLink} + > + + + ); + })} +
@@ -197,6 +301,7 @@ export const Kanban = ({ id, filterPreset, partnershipProject }: KanbanProps) => {shownStates.map((state) => (