diff --git a/package.json b/package.json index 5a343154..cda1dda2 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,8 @@ "@mantine/hooks": "^7.0.0", "@mantine/notifications": "^7.0.0", "@tabler/icons-react": "^2.44.0", - "@tanstack/react-query": "^4.23.0", - "@tanstack/react-query-devtools": "^4.23.0", + "@tanstack/react-query": "^5.0.0", + "@tanstack/react-query-devtools": "^5.0.0", "@types/file-saver": "^2.0.5", "@types/lodash": "^4.14.202", "axios": "^1.6.1", diff --git a/src/components/BacklogView/BacklogView.tsx b/src/components/BacklogView/BacklogView.tsx index 15e178eb..542238cb 100644 --- a/src/components/BacklogView/BacklogView.tsx +++ b/src/components/BacklogView/BacklogView.tsx @@ -13,16 +13,17 @@ import { Title, } from "@mantine/core" import { IconSearch } from "@tabler/icons-react" -import { useQueries, useQuery } from "@tanstack/react-query" -import { ChangeEvent, useEffect, useState } from "react" +import {QueriesResults, useQueries, useQuery} from "@tanstack/react-query" +import { ChangeEvent, useEffect, useMemo, useState } from "react" import { DragDropContext } from "@hello-pangea/dnd" import { useNavigate } from "react-router-dom" import { Issue, Sprint } from "types" +import { UseQueryOptions } from "@tanstack/react-query/src/types"; import { useCanvasStore } from "../../lib/Store" import { CreateIssueModal } from "../CreateIssue/CreateIssueModal" import { CreateExportModal } from "../CreateExport/CreateExportModal" import { CreateSprint } from "./CreateSprint/CreateSprint" -import { searchIssuesFilter, sortIssuesByRank } from "./helpers/backlogHelpers" +import { BacklogKey, IssuesState, searchMatchesIssue, sortIssuesByRank } from "./helpers/backlogHelpers" import { onDragEnd } from "./helpers/draggingHelpers" import { getBacklogIssues, @@ -46,93 +47,68 @@ export function BacklogView() { const [search, setSearch] = useState("") const [createExportModalOpened, setCreateExportModalOpened] = useState(false) - const [issuesWrappers, setIssuesWrappers] = useState( - new Map() - ) - const [searchedissuesWrappers, setSearchedissuesWrappers] = useState( - new Map() - ) - const updateIssuesWrapper = ( - key: string, - value: { issues: Issue[]; sprint?: Sprint } - ) => { - setIssuesWrappers((map) => new Map(map.set(key, value))) - setSearchedissuesWrappers((map) => new Map(map.set(key, value))) - } - const { data: sprints, isError: isErrorSprints } = useQuery({ queryKey: ["sprints", currentBoardId], queryFn: () => getSprints(currentBoardId), enabled: !!currentBoardId, + select: (fetchedSprints: Sprint[]) => Object.fromEntries(fetchedSprints.map((s) => [s.id, s])), + initialData: [], }) - const sprintsIssuesResults = useQueries({ - queries: - !isErrorSprints && sprints - ? sprints?.map((sprint) => ({ - queryKey: ["issues", "sprints", projectKey, sprints, sprint.id], - queryFn: () => getIssuesBySprint(sprint.id), - enabled: !!projectKey && !!sprints, - onSuccess: (issues: Issue[]) => { - updateIssuesWrapper(sprint.name, { - sprint, - issues: issues - .filter( - (issue: Issue) => - issue.type !== "Epic" && issue.type !== "Subtask" - ) - .sort((issueA: Issue, issueB: Issue) => - sortIssuesByRank(issueA, issueB) - ), - }) - searchIssuesFilter( - search, - issuesWrappers, - searchedissuesWrappers, - setSearchedissuesWrappers - ) - }, - })) - : [], - }) - const isErrorSprintsIssues = sprintsIssuesResults.some( - ({ isError }) => isError - ) - - const { isLoading: isLoadingBacklogIssues, isError: isErrorBacklogIssues } = - useQuery({ - queryKey: ["issues", projectKey, currentBoardId], - queryFn: () => getBacklogIssues(projectKey, currentBoardId), - enabled: !!projectKey, - onSuccess: (backlogIssues) => { - updateIssuesWrapper("Backlog", { - sprint: undefined, - issues: - backlogIssues && backlogIssues instanceof Array - ? backlogIssues - .filter( - (issue: Issue) => - issue.type !== "Subtask" - ) - .sort((issueA: Issue, issueB: Issue) => - sortIssuesByRank(issueA, issueB) - ) - : [], - }) - searchIssuesFilter( - search, - issuesWrappers, - searchedissuesWrappers, - setSearchedissuesWrappers - ) + const issueQueries = useQueries>>({ + queries: [ + { + queryKey: ["issues", projectKey, currentBoardId], // IMPROVE: Change this issue key to contain "backlog" + queryFn: () => getBacklogIssues(projectKey, currentBoardId), + enabled: !!projectKey, + select: (backlogIssues: Issue[]): [string, IssuesState] => [ + BacklogKey, + { + issues: backlogIssues + .filter((issue: Issue) => issue.type !== "Epic" && issue.type !== "Subtask") + .sort(sortIssuesByRank), + sprintId: undefined + }, + ], + initialData: [], }, - }) + ...(Object.values(sprints).map((sprint): UseQueryOptions => ({ + queryKey: ["issues", "sprints", projectKey, Object.keys(sprints), sprint.id], // IMPROVE: Change this issue key to not contain sprints + queryFn: () => getIssuesBySprint(sprint.id), + enabled: !!projectKey && !!sprints && !isErrorSprints, + select: (issues: Issue[]) => [ + sprint.name, + { + issues: issues + .filter((issue: Issue) => issue.type !== "Epic" && issue.type !== "Subtask") + .sort(sortIssuesByRank), + sprintId: sprint.id + }, + ], + initialData: [], + }))), + ], + combine: (results: QueriesResults>>) => + results.map(result => result) + }) + const [issuesWrapper, setIssuesWrapper] = useState(new Map()); useEffect(() => { - resizeDivider() - }, [isLoadingBacklogIssues]) + // Generally, using useEffect to sync state should be avoided. But since we need our state to be assignable AND + // reactive AND derivable, we found no other solution than to use useEffect. + setIssuesWrapper(new Map(issueQueries.map((query) => query.data!))) + }, [issueQueries]) + const updateIssuesWrapper = (key: string, newState: IssuesState) => setIssuesWrapper(new Map(issuesWrapper.set(key, newState))) + const searchedIssuesWrapper = useMemo(() => new Map( + Array.from(issuesWrapper.keys()).map((key) => [ + key, + issuesWrapper.get(key)!.issues.filter((i) => searchMatchesIssue(search, i)), + ]), + ), [issuesWrapper, search]) + + useEffect(resizeDivider, [issueQueries]); - if (isErrorSprints || isErrorBacklogIssues || isErrorSprintsIssues) + if (isErrorSprints || issueQueries.some(query => query.isError)) return (
@@ -144,18 +120,8 @@ export function BacklogView() {
) - const handleSearchChange = (event: ChangeEvent) => { - const currentSearch = event.currentTarget.value - setSearch(currentSearch) - searchIssuesFilter( - currentSearch, - issuesWrappers, - searchedissuesWrappers, - setSearchedissuesWrappers - ) - } - - if (isLoadingBacklogIssues) + // This check might be broken. It does not trigger everytime we think it does. Might need to force a rerender. + if (issueQueries.some(query => query.isPending)) return (
{projectKey ? ( @@ -206,7 +172,7 @@ export function BacklogView() { placeholder="Search by issue summary, key, epic, labels, creator or assignee.." leftSection={} value={search} - onChange={handleSearchChange} + onChange={(event: ChangeEvent) => { setSearch(event.currentTarget.value) }} /> @@ -215,7 +181,7 @@ export function BacklogView() { onDragEnd={(dropResult) => onDragEnd({ ...dropResult, - issuesWrappers, + issuesWrapper, updateIssuesWrapper, }) } @@ -229,11 +195,11 @@ export function BacklogView() { minWidth: "260px", }} > - {searchedissuesWrappers.get("Backlog") && ( + {searchedIssuesWrapper.get(BacklogKey) && ( // IMPROVE: Maybe this check can be removed entirely, please evaluate issue.type !== "Epic")} + issues={searchedIssuesWrapper.get(BacklogKey)!} /> )} @@ -284,13 +250,11 @@ export function BacklogView() { }} > issuesWrapper.sprint !== undefined - ) as unknown as { - issues: Issue[] - sprint: Sprint - }[] + sprints={sprints} + issueWrapper={ + Object.fromEntries(Array.from(searchedIssuesWrapper.keys()) + .filter((key) => key !== BacklogKey) + .map((key) => [issuesWrapper.get(key)!.sprintId, searchedIssuesWrapper.get(key)!])) } /> diff --git a/src/components/BacklogView/IssuesWrapper/SprintsPanel.tsx b/src/components/BacklogView/IssuesWrapper/SprintsPanel.tsx index cf86cea8..e819e600 100644 --- a/src/components/BacklogView/IssuesWrapper/SprintsPanel.tsx +++ b/src/components/BacklogView/IssuesWrapper/SprintsPanel.tsx @@ -13,9 +13,11 @@ import {storyPointsAccumulator} from "../../common/StoryPoints/status-accumulato import {useCanvasStore} from "../../../lib/Store"; export function SprintsPanel({ - sprintsWithIssues, + sprints, + issueWrapper, }: { - sprintsWithIssues: { issues: Issue[]; sprint: Sprint }[] + sprints: { [_: string]: Sprint } + issueWrapper: { [_: string]: Issue[] } }) { const colorScheme = useColorScheme() @@ -43,10 +45,12 @@ export function SprintsPanel({ }, })} > - {sprintsWithIssues - .sort(({ sprint: sprintA }, { sprint: sprintB }) => - sortSprintsByActive(sprintA, sprintB) - ) + {Object.keys(issueWrapper) + .map((sprintId) => ({ + issues: issueWrapper[sprintId], + sprint: sprints[sprintId] + })) + .sort(({ sprint: sprintA }, { sprint: sprintB }) => sortSprintsByActive(sprintA, sprintB)) .map(({ issues, sprint }) => ( `${count} ${noun}${count !== 1 ? suffix : ""}` @@ -17,58 +19,11 @@ export const sortSprintsByActive = (sprintA: Sprint, sprintB: Sprint) => { export const sortIssuesByRank = (issueA: Issue, issueB: Issue) => issueA.rank.localeCompare(issueB.rank) -export const searchIssuesFilter = ( - currentSearch: string, - issuesWrappers: Map< - string, - { - issues: Issue[] - sprint?: Sprint | undefined - } - >, - searchedissueWrapper: Map< - string, - { - issues: Issue[] - sprint?: Sprint | undefined - } - >, - setSearchedissueWrapper: Dispatch< - SetStateAction< - Map< - string, - { - issues: Issue[] - sprint?: Sprint | undefined - } - > - > - > -) => { - searchedissueWrapper.forEach((issueWrapper, issueWrapperKey) => { - const newIssueWrapper: { - issues: Issue[] - sprint?: Sprint | undefined - } = { issues: [], sprint: issueWrapper.sprint } - newIssueWrapper.sprint = issueWrapper.sprint - newIssueWrapper.issues = issuesWrappers - .get(issueWrapperKey)! - .issues.filter( - (issue: Issue) => - issue.summary.toLowerCase().includes(currentSearch.toLowerCase()) || - issue.epic.summary?.toLowerCase().includes(currentSearch.toLowerCase()) || - issue.assignee?.displayName - ?.toLowerCase() - .includes(currentSearch.toLowerCase()) || - issue.issueKey.toLowerCase().includes(currentSearch.toLowerCase()) || - issue.creator?.toLowerCase().includes(currentSearch.toLowerCase()) || - issue.labels?.some((label: string) => - label.toLowerCase().includes(currentSearch.toLowerCase()) - ) || - currentSearch === "" - ) - setSearchedissueWrapper( - (map) => new Map(map.set(issueWrapperKey, newIssueWrapper)) - ) - }) -} +export const searchMatchesIssue = (search: string, issue: Issue) => + search === "" || + issue.summary.toLowerCase().includes(search.toLowerCase()) || + issue.epic.summary?.toLowerCase().includes(search.toLowerCase()) || + issue.assignee?.displayName?.toLowerCase().includes(search.toLowerCase()) || + issue.issueKey.toLowerCase().includes(search.toLowerCase()) || + issue.creator?.toLowerCase().includes(search.toLowerCase()) || + issue.labels?.some((label: string) => label.toLowerCase().includes(search.toLowerCase())) diff --git a/src/components/BacklogView/helpers/draggingHelpers.ts b/src/components/BacklogView/helpers/draggingHelpers.ts index f63d6262..8a2e169f 100644 --- a/src/components/BacklogView/helpers/draggingHelpers.ts +++ b/src/components/BacklogView/helpers/draggingHelpers.ts @@ -1,86 +1,43 @@ import { DropResult } from "@hello-pangea/dnd" -import { Issue, Sprint } from "types" +import { Issue } from "types" +import { BacklogKey, IssuesState } from "./backlogHelpers"; export const onDragEnd = ({ source, destination, - issuesWrappers, + issuesWrapper, updateIssuesWrapper, }: DropResult & { - issuesWrappers: Map - updateIssuesWrapper: ( - key: string, - value: { issues: Issue[]; sprint?: Sprint } - ) => void -}) => { - if (destination === undefined || destination === null) return null - - if ( - source.droppableId === destination.droppableId && - destination.index === source.index - ) - return null - - const start = issuesWrappers.get(source.droppableId)! - const startId = source.droppableId - const end = issuesWrappers.get(destination.droppableId)! - const endId = destination.droppableId - const movedIssueKey = start.issues[source.index].issueKey - const destinationSprintId = end.sprint?.id - - if (start === end) { - const newList = start.issues.filter( - (_: Issue, idx: number) => idx !== source.index - ) - newList.splice(destination.index, 0, start.issues[source.index]) - - const keyOfIssueRankedBefore = - destination.index === 0 ? "" : newList[destination.index - 1].issueKey - const keyOfIssueRankedAfter = - destination.index === newList.length - 1 - ? "" - : newList[destination.index + 1].issueKey - - if (destinationSprintId) { - window.provider.moveIssueToSprintAndRank( - destinationSprintId, - movedIssueKey, - keyOfIssueRankedAfter, - keyOfIssueRankedBefore - ) - } else if (destination.droppableId === "Backlog") { - window.provider.rankIssueInBacklog( - movedIssueKey, - keyOfIssueRankedAfter, - keyOfIssueRankedBefore - ) - } - - updateIssuesWrapper(startId, { - ...start, - issues: newList, - }) - return null + issuesWrapper: Map + updateIssuesWrapper: (key: string, newState: IssuesState) => void +}): void => { + if (!destination) + return + if (source.droppableId === destination.droppableId && destination.index === source.index) + return + + const startState = issuesWrapper.get(source.droppableId)! + const endState = issuesWrapper.get(destination.droppableId)! + const movedIssueKey = startState.issues[source.index].issueKey + const destinationSprintId = endState.sprintId + + let newEndList; + if (startState.sprintId === endState.sprintId) { + newEndList = startState.issues.filter((_: Issue, idx: number) => idx !== source.index) + newEndList.splice(destination.index, 0, startState.issues[source.index]) + + updateIssuesWrapper(source.droppableId, { ...startState, issues: newEndList }) + } else { + const newStartList = startState.issues.filter((_: Issue, idx: number) => idx !== source.index) + newEndList = endState.issues.slice() + newEndList.splice(destination.index, 0, startState.issues[source.index]) + + updateIssuesWrapper(source.droppableId, { ...startState, issues: newStartList }) + updateIssuesWrapper(destination.droppableId, { ...endState, issues: newEndList }) } - const newStartIssues = start.issues.filter( - (_: Issue, idx: number) => idx !== source.index - ) - const newEndIssues = end.issues.slice() - newEndIssues.splice(destination.index, 0, start.issues[source.index]) - - updateIssuesWrapper(startId, { - ...start, - issues: newStartIssues, - }) - updateIssuesWrapper(endId, { ...end, issues: newEndIssues }) - - const keyOfIssueRankedBefore = - destination.index === 0 ? "" : newEndIssues[destination.index - 1].issueKey - const keyOfIssueRankedAfter = - destination.index === newEndIssues.length - 1 - ? "" - : newEndIssues[destination.index + 1].issueKey + const keyOfIssueRankedBefore = destination.index === 0 ? "" : newEndList[destination.index - 1].issueKey + const keyOfIssueRankedAfter = destination.index === newEndList.length - 1 ? "" : newEndList[destination.index + 1].issueKey if (destinationSprintId) { window.provider.moveIssueToSprintAndRank( @@ -89,7 +46,7 @@ export const onDragEnd = ({ keyOfIssueRankedAfter, keyOfIssueRankedBefore ) - } else if (destination.droppableId === "Backlog") { + } else if (destination.droppableId === BacklogKey) { window.provider.moveIssueToBacklog(movedIssueKey).then(() => { window.provider.rankIssueInBacklog( movedIssueKey, @@ -98,6 +55,4 @@ export const onDragEnd = ({ ) }) } - - return null } diff --git a/src/components/DetailView/Components/AddSubtask/AddSubtask.tsx b/src/components/DetailView/Components/AddSubtask/AddSubtask.tsx index 6a3f1a84..f687a1cb 100644 --- a/src/components/DetailView/Components/AddSubtask/AddSubtask.tsx +++ b/src/components/DetailView/Components/AddSubtask/AddSubtask.tsx @@ -58,7 +58,7 @@ export function AddSubtask({ /> - {createSubtask.isLoading && } + {createSubtask.isPending && } ) } diff --git a/src/components/DetailView/Components/EditableEpic/EditableEpic.tsx b/src/components/DetailView/Components/EditableEpic/EditableEpic.tsx index 1a8f507d..eb0cffbd 100644 --- a/src/components/DetailView/Components/EditableEpic/EditableEpic.tsx +++ b/src/components/DetailView/Components/EditableEpic/EditableEpic.tsx @@ -46,7 +46,7 @@ export function EditableEpic({ return ( - {mutationEpic.isLoading && } + {mutationEpic.isPending && } {showEpicInput ? ( - {deleteSubtask.isLoading && } + {deleteSubtask.isPending && } - {deleteSubtask.isLoading && } + {deleteSubtask.isPending && } state.selectedProject?.key) const currentBoardId = boardIds[0] - const [childIssues, setChildIssues] = useState([]) - - const { isLoading: isLoadingChildIssues } = useQuery({ + const { isLoading: isLoadingChildIssues, data: childIssues } = useQuery({ queryKey: ["issues", projectKey, currentBoardId, issueKey], queryFn: () => getIssuesByProject(projectKey, currentBoardId), enabled: !!projectKey, - onSuccess: (newChildIssues) => { - setChildIssues( - newChildIssues - ?.filter((issue: Issue) => issue.epic.issueKey === issueKey) - ?.sort((issueA: Issue, issueB: Issue) => sortIssuesByRank(issueA, issueB)) - ?? [], - ) - }, + initialData: [], + select: (newChildIssues) => + newChildIssues + ?.filter((issue: Issue) => issue.epic.issueKey === issueKey) + ?.sort((issueA: Issue, issueB: Issue) => sortIssuesByRank(issueA, issueB)) ?? [], }) const getStatusNamesInCategory = (category: StatusType) => issueStatusByCategory[category]?.map((s) => (s.name)) ?? [] diff --git a/src/components/EpicView/EpicView.tsx b/src/components/EpicView/EpicView.tsx index dfeb609b..ae4cff37 100644 --- a/src/components/EpicView/EpicView.tsx +++ b/src/components/EpicView/EpicView.tsx @@ -4,42 +4,23 @@ import {useState} from "react"; import {useQuery} from "@tanstack/react-query"; import {useCanvasStore} from "../../lib/Store"; import {CreateIssueModal} from "../CreateIssue/CreateIssueModal"; -import {Issue} from "../../../types"; import {EpicWrapper} from "./EpicWrapper"; import {getEpics} from "./helpers/queryFetchers"; import {useColorScheme} from "../../common/color-scheme"; - - export function EpicView() { const colorScheme = useColorScheme() const navigate = useNavigate() const projectName = useCanvasStore((state) => state.selectedProject?.name) const [createIssueModalOpened, setCreateIssueModalOpened] = useState(false) const projectKey = useCanvasStore((state) => state.selectedProject?.key) - const [EpicWrappers, setEpicWrappers] = useState( - new Map() - ) - - const updateEpicWrapper = ( - key: string, - value: { issues: Issue[]} - ) => { - setEpicWrappers((map) => new Map(map.set(key, value))) - } - const {isLoading: isLoadingEpics} = - useQuery({ - queryKey: ["epics", projectKey], - queryFn: () => getEpics(projectKey), - enabled: !!projectKey, - onSuccess: (epics) => { - updateEpicWrapper("EpicView", { - issues: - epics && epics instanceof Array ? epics : [] - }) - }, - }) + const { isLoading: isLoadingEpics, data: epics} = useQuery({ + queryKey: ["epics", projectKey], + queryFn: () => getEpics(projectKey), + enabled: !!projectKey, + initialData: [], + }) if (isLoadingEpics) return (
@@ -87,11 +68,9 @@ export function EpicView() { maxHeight: "calc(100vh - 230px)" }} > - {EpicWrappers.get("EpicView") &&( - + - )}