diff --git a/apps/app/components/core/board-view/all-boards.tsx b/apps/app/components/core/board-view/all-boards.tsx index 55517a827..27aaace9b 100644 --- a/apps/app/components/core/board-view/all-boards.tsx +++ b/apps/app/components/core/board-view/all-boards.tsx @@ -1,16 +1,14 @@ // hooks -import useIssueView from "hooks/use-issue-view"; +import useProjectIssuesView from "hooks/use-issues-view"; // components import { SingleBoard } from "components/core/board-view/single-board"; // types -import { IIssue, IProjectMember, IState, UserAuth } from "types"; +import { IIssue, IState, UserAuth } from "types"; type Props = { type: "issue" | "cycle" | "module"; - issues: IIssue[]; states: IState[] | undefined; - members: IProjectMember[] | undefined; - addIssueToState: (groupTitle: string, stateId: string | null) => void; + addIssueToState: (groupTitle: string) => void; makeIssueCopy: (issue: IIssue) => void; handleEditIssue: (issue: IIssue) => void; openIssuesListModal?: (() => void) | null; @@ -22,9 +20,7 @@ type Props = { export const AllBoards: React.FC<Props> = ({ type, - issues, states, - members, addIssueToState, makeIssueCopy, handleEditIssue, @@ -34,56 +30,35 @@ export const AllBoards: React.FC<Props> = ({ removeIssue, userAuth, }) => { - const { groupedByIssues, groupByProperty: selectedGroup, orderBy } = useIssueView(issues); + const { groupedByIssues, groupByProperty: selectedGroup, orderBy } = useProjectIssuesView(); return ( <> {groupedByIssues ? ( - <div className="h-[calc(100vh-140px)] w-full"> - <div className="horizontal-scroll-enable flex h-full gap-x-3.5 overflow-x-auto overflow-y-hidden"> - {Object.keys(groupedByIssues).map((singleGroup, index) => { - const currentState = - selectedGroup === "state_detail.name" - ? states?.find((s) => s.name === singleGroup) - : null; + <div className="horizontal-scroll-enable flex h-[calc(100vh-140px)] gap-x-4"> + {Object.keys(groupedByIssues).map((singleGroup, index) => { + const currentState = + selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null; - const stateId = - selectedGroup === "state_detail.name" - ? states?.find((s) => s.name === singleGroup)?.id ?? null - : null; - - const bgColor = - selectedGroup === "state_detail.name" - ? states?.find((s) => s.name === singleGroup)?.color - : "#000000"; - - return ( - <SingleBoard - key={index} - type={type} - currentState={currentState} - bgColor={bgColor} - groupTitle={singleGroup} - groupedByIssues={groupedByIssues} - selectedGroup={selectedGroup} - members={members} - handleEditIssue={handleEditIssue} - makeIssueCopy={makeIssueCopy} - addIssueToState={() => addIssueToState(singleGroup, stateId)} - handleDeleteIssue={handleDeleteIssue} - openIssuesListModal={openIssuesListModal ?? null} - orderBy={orderBy} - handleTrashBox={handleTrashBox} - removeIssue={removeIssue} - userAuth={userAuth} - /> - ); - })} - </div> + return ( + <SingleBoard + key={index} + type={type} + currentState={currentState} + groupTitle={singleGroup} + handleEditIssue={handleEditIssue} + makeIssueCopy={makeIssueCopy} + addIssueToState={() => addIssueToState(singleGroup)} + handleDeleteIssue={handleDeleteIssue} + openIssuesListModal={openIssuesListModal ?? null} + handleTrashBox={handleTrashBox} + removeIssue={removeIssue} + userAuth={userAuth} + /> + ); + })} </div> - ) : ( - <div className="flex h-full w-full items-center justify-center">Loading...</div> - )} + ) : null} </> ); }; diff --git a/apps/app/components/core/board-view/board-header.tsx b/apps/app/components/core/board-view/board-header.tsx index 8237eb8f6..e06d0ce8b 100644 --- a/apps/app/components/core/board-view/board-header.tsx +++ b/apps/app/components/core/board-view/board-header.tsx @@ -1,52 +1,42 @@ import React from "react"; +// hooks +import useIssuesView from "hooks/use-issues-view"; // icons import { ArrowsPointingInIcon, ArrowsPointingOutIcon, PlusIcon } from "@heroicons/react/24/outline"; +import { getStateGroupIcon } from "components/icons"; // helpers import { addSpaceIfCamelCase } from "helpers/string.helper"; // types -import { IIssue, IProjectMember, IState, NestedKeyOf } from "types"; -import { getStateGroupIcon } from "components/icons"; +import { IState } from "types"; type Props = { - groupedByIssues: { - [key: string]: IIssue[]; - }; currentState?: IState | null; - selectedGroup: NestedKeyOf<IIssue> | null; groupTitle: string; - bgColor?: string; addIssueToState: () => void; - members: IProjectMember[] | undefined; isCollapsed: boolean; setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>; }; export const BoardHeader: React.FC<Props> = ({ - groupedByIssues, currentState, - selectedGroup, groupTitle, - bgColor, addIssueToState, isCollapsed, setIsCollapsed, - members, }) => { - const createdBy = - selectedGroup === "created_by" - ? members?.find((m) => m.member.id === groupTitle)?.member.first_name ?? "loading..." - : null; + const { groupedByIssues, groupByProperty: selectedGroup } = useIssuesView(); - let assignees: any; - if (selectedGroup === "assignees") { - assignees = groupTitle && groupTitle !== "" ? groupTitle.split(",") : []; - assignees = - assignees.length > 0 - ? assignees - .map((a: string) => members?.find((m) => m.member.id === a)?.member.first_name) - .join(", ") - : "No assignee"; - } + let bgColor = "#000000"; + if (selectedGroup === "state") bgColor = currentState?.color ?? "#000000"; + + if (selectedGroup === "priority") + groupTitle === "high" + ? (bgColor = "#dc2626") + : groupTitle === "medium" + ? (bgColor = "#f97316") + : groupTitle === "low" + ? (bgColor = "#22c55e") + : (bgColor = "#ff0000"); return ( <div @@ -67,14 +57,12 @@ export const BoardHeader: React.FC<Props> = ({ writingMode: !isCollapsed ? "vertical-rl" : "horizontal-tb", }} > - {selectedGroup === "created_by" - ? createdBy - : selectedGroup === "assignees" - ? assignees + {selectedGroup === "state" + ? addSpaceIfCamelCase(currentState?.name ?? "") : addSpaceIfCamelCase(groupTitle)} </h2> <span className="ml-0.5 rounded-full bg-gray-100 py-1 px-3 text-sm"> - {groupedByIssues[groupTitle].length} + {groupedByIssues?.[groupTitle].length ?? 0} </span> </div> </div> diff --git a/apps/app/components/core/board-view/single-board.tsx b/apps/app/components/core/board-view/single-board.tsx index 8e4226030..e987c700d 100644 --- a/apps/app/components/core/board-view/single-board.tsx +++ b/apps/app/components/core/board-view/single-board.tsx @@ -6,6 +6,7 @@ import { useRouter } from "next/router"; import StrictModeDroppable from "components/dnd/StrictModeDroppable"; import { Draggable } from "react-beautiful-dnd"; // hooks +import useIssuesView from "hooks/use-issues-view"; import useIssuesProperties from "hooks/use-issue-properties"; // components import { BoardHeader, SingleBoardIssue } from "components/core"; @@ -16,24 +17,17 @@ import { PlusIcon } from "@heroicons/react/24/outline"; // helpers import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; // types -import { IIssue, IProjectMember, IState, NestedKeyOf, UserAuth } from "types"; +import { IIssue, IState, UserAuth } from "types"; type Props = { type?: "issue" | "cycle" | "module"; currentState?: IState | null; - bgColor?: string; groupTitle: string; - groupedByIssues: { - [key: string]: IIssue[]; - }; - selectedGroup: NestedKeyOf<IIssue> | null; - members: IProjectMember[] | undefined; handleEditIssue: (issue: IIssue) => void; makeIssueCopy: (issue: IIssue) => void; addIssueToState: () => void; handleDeleteIssue: (issue: IIssue) => void; openIssuesListModal?: (() => void) | null; - orderBy: NestedKeyOf<IIssue> | null; handleTrashBox: (isDragging: boolean) => void; removeIssue: ((bridgeId: string) => void) | null; userAuth: UserAuth; @@ -42,17 +36,12 @@ type Props = { export const SingleBoard: React.FC<Props> = ({ type, currentState, - bgColor, groupTitle, - groupedByIssues, - selectedGroup, - members, handleEditIssue, makeIssueCopy, addIssueToState, handleDeleteIssue, openIssuesListModal, - orderBy, handleTrashBox, removeIssue, userAuth, @@ -60,35 +49,24 @@ export const SingleBoard: React.FC<Props> = ({ // collapse/expand const [isCollapsed, setIsCollapsed] = useState(true); + const { groupedByIssues, groupByProperty: selectedGroup, orderBy } = useIssuesView(); + const router = useRouter(); const { workspaceSlug, projectId } = router.query; const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string); - if (selectedGroup === "priority") - groupTitle === "high" - ? (bgColor = "#dc2626") - : groupTitle === "medium" - ? (bgColor = "#f97316") - : groupTitle === "low" - ? (bgColor = "#22c55e") - : (bgColor = "#ff0000"); - const isNotAllowed = userAuth.isGuest || userAuth.isViewer; return ( - <div className={`h-full flex-shrink-0 rounded ${!isCollapsed ? "" : "w-96 bg-gray-50"}`}> + <div className={`h-full flex-shrink-0 ${!isCollapsed ? "" : "w-96 bg-gray-50"}`}> <div className={`${!isCollapsed ? "" : "flex h-full flex-col space-y-3"}`}> <BoardHeader addIssueToState={addIssueToState} currentState={currentState} - bgColor={bgColor} - selectedGroup={selectedGroup} groupTitle={groupTitle} - groupedByIssues={groupedByIssues} isCollapsed={isCollapsed} setIsCollapsed={setIsCollapsed} - members={members} /> <StrictModeDroppable key={groupTitle} droppableId={groupTitle}> {(provided, snapshot) => ( @@ -115,14 +93,12 @@ export const SingleBoard: React.FC<Props> = ({ </div> </> )} - {groupedByIssues[groupTitle].map((issue, index: number) => ( + {groupedByIssues?.[groupTitle].map((issue, index) => ( <Draggable key={issue.id} draggableId={issue.id} index={index} - isDragDisabled={ - isNotAllowed || selectedGroup === "created_by" || selectedGroup === "assignees" - } + isDragDisabled={isNotAllowed} > {(provided, snapshot) => ( <SingleBoardIssue @@ -130,16 +106,17 @@ export const SingleBoard: React.FC<Props> = ({ provided={provided} snapshot={snapshot} type={type} - issue={issue} + index={index} selectedGroup={selectedGroup} + issue={issue} + groupTitle={groupTitle} properties={properties} editIssue={() => handleEditIssue(issue)} makeIssueCopy={() => makeIssueCopy(issue)} handleDeleteIssue={handleDeleteIssue} - orderBy={orderBy} handleTrashBox={handleTrashBox} removeIssue={() => { - removeIssue && removeIssue(issue.bridge); + if (removeIssue && issue.bridge_id) removeIssue(issue.bridge_id); }} userAuth={userAuth} /> diff --git a/apps/app/components/core/board-view/single-issue.tsx b/apps/app/components/core/board-view/single-issue.tsx index cfb3c4ad7..60e02648e 100644 --- a/apps/app/components/core/board-view/single-issue.tsx +++ b/apps/app/components/core/board-view/single-issue.tsx @@ -15,6 +15,7 @@ import { // services import issuesService from "services/issues.service"; // hooks +import useIssuesView from "hooks/use-issues-view"; import useToast from "hooks/use-toast"; // components import { @@ -33,31 +34,30 @@ import { TrashIcon, } from "@heroicons/react/24/outline"; // helpers +import { handleIssuesMutation } from "constants/issue"; import { copyTextToClipboard, truncateText } from "helpers/string.helper"; // types -import { - CycleIssueResponse, - IIssue, - ModuleIssueResponse, - NestedKeyOf, - Properties, - UserAuth, -} from "types"; +import { IIssue, Properties, UserAuth } from "types"; // fetch-keys -import { CYCLE_ISSUES, MODULE_ISSUES, PROJECT_ISSUES_LIST } from "constants/fetch-keys"; +import { + CYCLE_ISSUES_WITH_PARAMS, + MODULE_ISSUES_WITH_PARAMS, + PROJECT_ISSUES_LIST_WITH_PARAMS, +} from "constants/fetch-keys"; type Props = { type?: string; provided: DraggableProvided; snapshot: DraggableStateSnapshot; issue: IIssue; - selectedGroup: NestedKeyOf<IIssue> | null; properties: Properties; + groupTitle?: string; + index: number; + selectedGroup: "priority" | "state" | "labels" | null; editIssue: () => void; makeIssueCopy: () => void; removeIssue?: (() => void) | null; handleDeleteIssue: (issue: IIssue) => void; - orderBy: NestedKeyOf<IIssue> | null; handleTrashBox: (isDragging: boolean) => void; userAuth: UserAuth; }; @@ -67,13 +67,14 @@ export const SingleBoardIssue: React.FC<Props> = ({ provided, snapshot, issue, - selectedGroup, properties, + index, + selectedGroup, editIssue, makeIssueCopy, removeIssue, + groupTitle, handleDeleteIssue, - orderBy, handleTrashBox, userAuth, }) => { @@ -81,6 +82,8 @@ export const SingleBoardIssue: React.FC<Props> = ({ const [contextMenu, setContextMenu] = useState(false); const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }); + const { orderBy } = useIssuesView(); + const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId } = router.query; @@ -91,75 +94,55 @@ export const SingleBoardIssue: React.FC<Props> = ({ if (!workspaceSlug || !projectId) return; if (cycleId) - mutate<CycleIssueResponse[]>( - CYCLE_ISSUES(cycleId as string), - (prevData) => { - const updatedIssues = (prevData ?? []).map((p) => { - if (p.issue_detail.id === issue.id) { - return { - ...p, - issue_detail: { - ...p.issue_detail, - ...formData, - assignees: formData.assignees_list ?? p.issue_detail.assignees_list, - }, - }; - } - return p; - }); - return [...updatedIssues]; - }, + mutate< + | { + [key: string]: IIssue[]; + } + | IIssue[] + >( + CYCLE_ISSUES_WITH_PARAMS(cycleId as string), + (prevData) => + handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData), false ); if (moduleId) - mutate<ModuleIssueResponse[]>( - MODULE_ISSUES(moduleId as string), - (prevData) => { - const updatedIssues = (prevData ?? []).map((p) => { - if (p.issue_detail.id === issue.id) { - return { - ...p, - issue_detail: { - ...p.issue_detail, - ...formData, - assignees: formData.assignees_list ?? p.issue_detail.assignees_list, - }, - }; - } - return p; - }); - return [...updatedIssues]; - }, + mutate< + | { + [key: string]: IIssue[]; + } + | IIssue[] + >( + MODULE_ISSUES_WITH_PARAMS(moduleId as string), + (prevData) => + handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData), false ); - mutate<IIssue[]>( - PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string), + mutate< + | { + [key: string]: IIssue[]; + } + | IIssue[] + >( + PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string), (prevData) => - (prevData ?? []).map((p) => { - if (p.id === issue.id) - return { ...p, ...formData, assignees: formData.assignees_list ?? p.assignees_list }; - - return p; - }), - + handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData), false ); issuesService .patchIssue(workspaceSlug as string, projectId as string, issue.id, formData) .then((res) => { - if (cycleId) mutate(CYCLE_ISSUES(cycleId as string)); - if (moduleId) mutate(MODULE_ISSUES(moduleId as string)); - - mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)); + if (cycleId) mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string)); + if (moduleId) mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string)); + mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string)); }) .catch((error) => { console.log(error); }); }, - [workspaceSlug, projectId, cycleId, moduleId, issue] + [workspaceSlug, projectId, cycleId, moduleId, issue, groupTitle, index, selectedGroup] ); const getStyle = ( @@ -168,9 +151,7 @@ export const SingleBoardIssue: React.FC<Props> = ({ ) => { if (orderBy === "sort_order") return style; if (!snapshot.isDragging) return {}; - if (!snapshot.isDropAnimating) { - return style; - } + if (!snapshot.isDropAnimating) return style; return { ...style, @@ -301,7 +282,7 @@ export const SingleBoardIssue: React.FC<Props> = ({ {properties.labels && issue.label_details.length > 0 && ( <div className="flex flex-wrap gap-1"> {issue.label_details.map((label) => ( - <span + <div key={label.id} className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs" > @@ -312,7 +293,7 @@ export const SingleBoardIssue: React.FC<Props> = ({ }} /> {label.name} - </span> + </div> ))} </div> )} diff --git a/apps/app/components/core/issues-view-filter.tsx b/apps/app/components/core/issues-view-filter.tsx index f6e8bd662..bff7113d3 100644 --- a/apps/app/components/core/issues-view-filter.tsx +++ b/apps/app/components/core/issues-view-filter.tsx @@ -10,7 +10,7 @@ import issuesService from "services/issues.service"; import stateService from "services/state.service"; // hooks import useIssuesProperties from "hooks/use-issue-properties"; -import useIssueView from "hooks/use-issue-view"; +import useIssuesView from "hooks/use-issues-view"; // headless ui import { Popover, Transition } from "@headlessui/react"; // ui @@ -29,11 +29,7 @@ import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, STATE_LIST } from "constants/fet import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue"; import { PRIORITIES } from "constants/project"; -type Props = { - issues?: IIssue[]; -}; - -export const IssuesFilterView: React.FC<Props> = ({ issues }) => { +export const IssuesFilterView: React.FC = () => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -44,12 +40,12 @@ export const IssuesFilterView: React.FC<Props> = ({ issues }) => { groupByProperty, setGroupByProperty, setOrderBy, - setFilterIssue, orderBy, - filterIssue, + filters, + setFilters, resetFilterToDefault, setNewFilterDefaultView, - } = useIssueView(issues ?? []); + } = useIssuesView(); const [properties, setProperties] = useIssuesProperties( workspaceSlug as string, @@ -79,208 +75,182 @@ export const IssuesFilterView: React.FC<Props> = ({ issues }) => { ); return ( - <> - {issues && issues.length > 0 && ( - <div className="flex items-center gap-2"> - <div className="flex items-center gap-x-1"> - <button - type="button" - className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${ - issueView === "list" ? "bg-gray-200" : "" - }`} - onClick={() => setIssueViewToList()} - > - <ListBulletIcon className="h-4 w-4" /> - </button> - <button - type="button" - className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${ - issueView === "kanban" ? "bg-gray-200" : "" - }`} - onClick={() => setIssueViewToKanban()} - > - <Squares2X2Icon className="h-4 w-4" /> - </button> - </div> - <CustomMenu - label={ - <span className="flex items-center gap-2 rounded-md py-1 text-xs font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-900 focus:outline-none"> - Filters - </span> - } + <div className="flex items-center gap-2"> + <div className="flex items-center gap-x-1"> + <button + type="button" + className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${ + issueView === "list" ? "bg-gray-200" : "" + }`} + onClick={() => setIssueViewToList()} + > + <ListBulletIcon className="h-4 w-4" /> + </button> + <button + type="button" + className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${ + issueView === "kanban" ? "bg-gray-200" : "" + }`} + onClick={() => setIssueViewToKanban()} + > + <Squares2X2Icon className="h-4 w-4" /> + </button> + </div> + <CustomMenu + customButton={ + <button + type="button" + className="group flex items-center gap-2 rounded-md border bg-transparent p-2 text-xs font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-900 focus:outline-none" > - <h4 className="px-1 py-2 font-medium">Status</h4> - {statesList?.map((state) => ( - <CustomMenu.MenuItem onClick={() => {}}> - <>{state.name}</> - </CustomMenu.MenuItem> - ))} - <h4 className="px-1 py-2 font-medium">Members</h4> - {members?.map((member) => ( - <CustomMenu.MenuItem onClick={() => {}}> - <> - {member.member.first_name && member.member.first_name !== "" - ? member.member.first_name + " " + member.member.last_name - : member.member.email} - </> - </CustomMenu.MenuItem> - ))} - <h4 className="px-1 py-2 font-medium">Labels</h4> - {issueLabels?.map((label) => ( - <CustomMenu.MenuItem onClick={() => {}}> - <>{label.name}</> - </CustomMenu.MenuItem> - ))} - <h4 className="px-1 py-2 font-medium">Priority</h4> - {PRIORITIES?.map((priority) => ( - <CustomMenu.MenuItem onClick={() => {}}> - <span className="capitalize">{priority ?? "None"}</span> - </CustomMenu.MenuItem> - ))} - </CustomMenu> - <Popover className="relative"> - {({ open }) => ( - <> - <Popover.Button - className={`group flex items-center gap-2 rounded-md border bg-transparent p-2 text-xs font-medium hover:bg-gray-100 hover:text-gray-900 focus:outline-none ${ - open ? "bg-gray-100 text-gray-900" : "text-gray-500" - }`} - > - <span>View</span> - <ChevronDownIcon className="h-4 w-4" aria-hidden="true" /> - </Popover.Button> + Filters + <ChevronDownIcon className="h-4 w-4" aria-hidden="true" /> + </button> + } + optionsPosition="right" + > + <CustomMenu.MenuItem + onClick={() => + setFilters({ + assignees: ["72d6ad43-41ff-4907-9980-2f5ee8745ad3"], + }) + } + > + Member- Aaryan + </CustomMenu.MenuItem> + </CustomMenu> + <Popover className="relative"> + {({ open }) => ( + <> + <Popover.Button + className={`group flex items-center gap-2 rounded-md border bg-transparent p-2 text-xs font-medium hover:bg-gray-100 hover:text-gray-900 focus:outline-none ${ + open ? "bg-gray-100 text-gray-900" : "text-gray-500" + }`} + > + View + <ChevronDownIcon className="h-4 w-4" aria-hidden="true" /> + </Popover.Button> - <Transition - as={React.Fragment} - enter="transition ease-out duration-200" - enterFrom="opacity-0 translate-y-1" - enterTo="opacity-100 translate-y-0" - leave="transition ease-in duration-150" - leaveFrom="opacity-100 translate-y-0" - leaveTo="opacity-0 translate-y-1" - > - <Popover.Panel className="absolute right-0 z-20 mt-1 w-screen max-w-xs transform overflow-hidden rounded-lg bg-white p-3 shadow-lg"> - <div className="relative divide-y-2"> - {issues && ( - <div className="space-y-4 pb-3"> - <div className="flex items-center justify-between"> - <h4 className="text-sm text-gray-600">Group by</h4> - <CustomMenu - label={ - GROUP_BY_OPTIONS.find((option) => option.key === groupByProperty) - ?.name ?? "Select" - } - width="lg" + <Transition + as={React.Fragment} + enter="transition ease-out duration-200" + enterFrom="opacity-0 translate-y-1" + enterTo="opacity-100 translate-y-0" + leave="transition ease-in duration-150" + leaveFrom="opacity-100 translate-y-0" + leaveTo="opacity-0 translate-y-1" + > + <Popover.Panel className="absolute right-0 z-20 mt-1 w-screen max-w-xs transform overflow-hidden rounded-lg bg-white p-3 shadow-lg"> + <div className="relative divide-y-2"> + <div className="space-y-4 pb-3"> + <div className="flex items-center justify-between"> + <h4 className="text-sm text-gray-600">Group by</h4> + <CustomMenu + label={ + GROUP_BY_OPTIONS.find((option) => option.key === groupByProperty)?.name ?? + "Select" + } + width="lg" + > + {GROUP_BY_OPTIONS.map((option) => + issueView === "kanban" && option.key === null ? null : ( + <CustomMenu.MenuItem + key={option.key} + onClick={() => setGroupByProperty(option.key)} > - {GROUP_BY_OPTIONS.map((option) => - issueView === "kanban" && option.key === null ? null : ( - <CustomMenu.MenuItem - key={option.key} - onClick={() => setGroupByProperty(option.key)} - > - {option.name} - </CustomMenu.MenuItem> - ) - )} - </CustomMenu> - </div> - <div className="flex items-center justify-between"> - <h4 className="text-sm text-gray-600">Order by</h4> - <CustomMenu - label={ - ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ?? - "Select" - } - width="lg" - > - {ORDER_BY_OPTIONS.map((option) => - groupByProperty === "priority" && - option.key === "priority" ? null : ( - <CustomMenu.MenuItem - key={option.key} - onClick={() => { - setOrderBy(option.key); - }} - > - {option.name} - </CustomMenu.MenuItem> - ) - )} - </CustomMenu> - </div> - <div className="flex items-center justify-between"> - <h4 className="text-sm text-gray-600">Issue type</h4> - <CustomMenu - label={ - FILTER_ISSUE_OPTIONS.find((option) => option.key === filterIssue) - ?.name ?? "Select" - } - width="lg" - > - {FILTER_ISSUE_OPTIONS.map((option) => ( - <CustomMenu.MenuItem - key={option.key} - onClick={() => setFilterIssue(option.key)} - > - {option.name} - </CustomMenu.MenuItem> - ))} - </CustomMenu> - </div> - <div className="relative flex justify-end gap-x-3"> - <button - type="button" - className="text-xs" - onClick={() => resetFilterToDefault()} - > - Reset to default - </button> - <button - type="button" - className="text-xs font-medium text-theme" - onClick={() => setNewFilterDefaultView()} - > - Set as default - </button> - </div> - </div> - )} - <div className="space-y-2 py-3"> - <h4 className="text-sm text-gray-600">Display Properties</h4> - <div className="flex flex-wrap items-center gap-2"> - {Object.keys(properties).map((key) => { - if ( - issueView === "kanban" && - ((groupByProperty === "state_detail.name" && key === "state") || - (groupByProperty === "priority" && key === "priority")) - ) - return; - - return ( - <button - key={key} - type="button" - className={`rounded border px-2 py-1 text-xs capitalize ${ - properties[key as keyof Properties] - ? "border-theme bg-theme text-white" - : "border-gray-300" - }`} - onClick={() => setProperties(key as keyof Properties)} - > - {key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)} - </button> - ); - })} - </div> - </div> + {option.name} + </CustomMenu.MenuItem> + ) + )} + </CustomMenu> </div> - </Popover.Panel> - </Transition> - </> - )} - </Popover> - </div> - )} - </> + <div className="flex items-center justify-between"> + <h4 className="text-sm text-gray-600">Order by</h4> + <CustomMenu + label={ + ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ?? + "Select" + } + width="lg" + > + {ORDER_BY_OPTIONS.map((option) => + groupByProperty === "priority" && option.key === "priority" ? null : ( + <CustomMenu.MenuItem + key={option.key} + onClick={() => { + setOrderBy(option.key); + }} + > + {option.name} + </CustomMenu.MenuItem> + ) + )} + </CustomMenu> + </div> + <div className="flex items-center justify-between"> + <h4 className="text-sm text-gray-600">Issue type</h4> + <CustomMenu + label={ + FILTER_ISSUE_OPTIONS.find((option) => option.key === filters.type) + ?.name ?? "Select" + } + width="lg" + > + {FILTER_ISSUE_OPTIONS.map((option) => ( + <CustomMenu.MenuItem + key={option.key} + onClick={() => + setFilters({ + type: option.key, + }) + } + > + {option.name} + </CustomMenu.MenuItem> + ))} + </CustomMenu> + </div> + <div className="relative flex justify-end gap-x-3"> + <button + type="button" + className="text-xs" + onClick={() => resetFilterToDefault()} + > + Reset to default + </button> + <button + type="button" + className="text-xs font-medium text-theme" + onClick={() => setNewFilterDefaultView()} + > + Set as default + </button> + </div> + </div> + <div className="space-y-2 py-3"> + <h4 className="text-sm text-gray-600">Display Properties</h4> + <div className="flex flex-wrap items-center gap-2"> + {Object.keys(properties).map((key) => ( + <button + key={key} + type="button" + className={`rounded border px-2 py-1 text-xs capitalize ${ + properties[key as keyof Properties] + ? "border-theme bg-theme text-white" + : "border-gray-300" + }`} + onClick={() => setProperties(key as keyof Properties)} + > + {key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)} + </button> + ))} + </div> + </div> + </div> + </Popover.Panel> + </Transition> + </> + )} + </Popover> + </div> ); }; diff --git a/apps/app/components/core/issues-view.tsx b/apps/app/components/core/issues-view.tsx index 779338321..a6357117c 100644 --- a/apps/app/components/core/issues-view.tsx +++ b/apps/app/components/core/issues-view.tsx @@ -12,13 +12,13 @@ import stateService from "services/state.service"; import projectService from "services/project.service"; import modulesService from "services/modules.service"; // hooks -import useIssueView from "hooks/use-issue-view"; +import useIssuesView from "hooks/use-issues-view"; // components import { AllLists, AllBoards } from "components/core"; import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; import StrictModeDroppable from "components/dnd/StrictModeDroppable"; // icons -import { TrashIcon } from "@heroicons/react/24/outline"; +import { PlusIcon, RectangleStackIcon, TrashIcon } from "@heroicons/react/24/outline"; // helpers import { getStatesList } from "helpers/state.helper"; // types @@ -26,32 +26,29 @@ import { CycleIssueResponse, IIssue, ModuleIssueResponse, UserAuth } from "types // fetch-keys import { CYCLE_ISSUES, + CYCLE_ISSUES_WITH_PARAMS, MODULE_ISSUES, - PROJECT_ISSUES_LIST, + MODULE_ISSUES_WITH_PARAMS, + PROJECT_ISSUES_LIST_WITH_PARAMS, PROJECT_MEMBERS, STATE_LIST, } from "constants/fetch-keys"; +import { EmptySpace, EmptySpaceItem } from "components/ui"; type Props = { type?: "issue" | "cycle" | "module"; - issues: IIssue[]; openIssuesListModal?: () => void; userAuth: UserAuth; }; -export const IssuesView: React.FC<Props> = ({ - type = "issue", - issues, - openIssuesListModal, - userAuth, -}) => { +export const IssuesView: React.FC<Props> = ({ type = "issue", openIssuesListModal, userAuth }) => { // create issue modal const [createIssueModal, setCreateIssueModal] = useState(false); const [preloadedData, setPreloadedData] = useState< (Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined >(undefined); - // updates issue modal + // update issue modal const [editIssueModal, setEditIssueModal] = useState(false); const [issueToEdit, setIssueToEdit] = useState< (IIssue & { actionType: "edit" | "delete" }) | undefined @@ -68,11 +65,13 @@ export const IssuesView: React.FC<Props> = ({ const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { - issueView, groupedByIssues, + issueView, groupByProperty: selectedGroup, orderBy, - } = useIssueView(issues); + filters, + setFilters, + } = useIssuesView(); const { data: stateGroups } = useSWR( workspaceSlug && projectId ? STATE_LIST(projectId as string) : null, @@ -101,7 +100,7 @@ export const IssuesView: React.FC<Props> = ({ (result: DropResult) => { setTrashBox(false); - if (!result.destination || !workspaceSlug || !projectId) return; + if (!result.destination || !workspaceSlug || !projectId || !groupedByIssues) return; const { source, destination } = result; @@ -156,90 +155,99 @@ export const IssuesView: React.FC<Props> = ({ draggedItem.sort_order = newSortOrder; } - if (orderBy === "sort_order" || source.droppableId !== destination.droppableId) { - const sourceGroup = source.droppableId; // source group id - const destinationGroup = destination.droppableId; // destination group id + const destinationGroup = destination.droppableId; // destination group id - if (!sourceGroup || !destinationGroup) return; + if (orderBy === "sort_order" || source.droppableId !== destination.droppableId) { + // different group/column; + + // source.droppableId !== destination.droppableId -> even if order by is not sort_order, + // if the issue is moved to a different group, then we will change the group of the + // dragged item(or issue) if (selectedGroup === "priority") draggedItem.priority = destinationGroup; - else if (selectedGroup === "state_detail.name") { - const destinationState = states?.find((s) => s.name === destinationGroup); + else if (selectedGroup === "state") draggedItem.state = destinationGroup; + } - if (!destinationState) return; + const sourceGroup = source.droppableId; // source group id - draggedItem.state = destinationState.id; - draggedItem.state_detail = destinationState; - } - - if (cycleId) - mutate<CycleIssueResponse[]>( - CYCLE_ISSUES(cycleId as string), - (prevData) => { - if (!prevData) return prevData; - const updatedIssues = prevData.map((issue) => { - if (issue.issue_detail.id === draggedItem.id) { - return { - ...issue, - issue_detail: draggedItem, - }; - } - return issue; - }); - return [...updatedIssues]; - }, - false - ); - - if (moduleId) - mutate<ModuleIssueResponse[]>( - MODULE_ISSUES(moduleId as string), - (prevData) => { - if (!prevData) return prevData; - const updatedIssues = prevData.map((issue) => { - if (issue.issue_detail.id === draggedItem.id) { - return { - ...issue, - issue_detail: draggedItem, - }; - } - return issue; - }); - return [...updatedIssues]; - }, - false - ); - - mutate<IIssue[]>( - PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string), + // TODO: move this mutation logic to a separate function + if (cycleId) + mutate<{ + [key: string]: IIssue[]; + }>( + CYCLE_ISSUES_WITH_PARAMS(cycleId as string), (prevData) => { if (!prevData) return prevData; - const updatedIssues = prevData.map((i) => { - if (i.id === draggedItem.id) return draggedItem; + const sourceGroupArray = prevData[sourceGroup]; + const destinationGroupArray = prevData[destinationGroup]; - return i; - }); + sourceGroupArray.splice(source.index, 1); + destinationGroupArray.splice(destination.index, 0, draggedItem); - return updatedIssues; + return { + ...prevData, + [sourceGroup]: sourceGroupArray, + [destinationGroup]: destinationGroupArray, + }; + }, + false + ); + else if (moduleId) + mutate<{ + [key: string]: IIssue[]; + }>( + MODULE_ISSUES_WITH_PARAMS(moduleId as string), + (prevData) => { + if (!prevData) return prevData; + + const sourceGroupArray = prevData[sourceGroup]; + const destinationGroupArray = prevData[destinationGroup]; + + sourceGroupArray.splice(source.index, 1); + destinationGroupArray.splice(destination.index, 0, draggedItem); + + return { + ...prevData, + [sourceGroup]: sourceGroupArray, + [destinationGroup]: destinationGroupArray, + }; + }, + false + ); + else + mutate<{ [key: string]: IIssue[] }>( + PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string), + (prevData) => { + if (!prevData) return prevData; + + const sourceGroupArray = prevData[sourceGroup]; + const destinationGroupArray = prevData[destinationGroup]; + + sourceGroupArray.splice(source.index, 1); + destinationGroupArray.splice(destination.index, 0, draggedItem); + + return { + ...prevData, + [sourceGroup]: sourceGroupArray, + [destinationGroup]: destinationGroupArray, + }; }, false ); - // patch request - issuesService - .patchIssue(workspaceSlug as string, projectId as string, draggedItem.id, { - priority: draggedItem.priority, - state: draggedItem.state, - sort_order: draggedItem.sort_order, - }) - .then((res) => { - if (cycleId) mutate(CYCLE_ISSUES(cycleId as string)); - if (moduleId) mutate(MODULE_ISSUES(moduleId as string)); - - mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)); - }); - } + // patch request + issuesService + .patchIssue(workspaceSlug as string, projectId as string, draggedItem.id, { + priority: draggedItem.priority, + state: draggedItem.state, + sort_order: draggedItem.sort_order, + }) + .then(() => { + if (cycleId) mutate(CYCLE_ISSUES(cycleId as string)); + if (moduleId) mutate(MODULE_ISSUES(moduleId as string)); + mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string)); + }); } }, [ @@ -250,17 +258,15 @@ export const IssuesView: React.FC<Props> = ({ projectId, selectedGroup, orderBy, - states, handleDeleteIssue, ] ); const addIssueToState = useCallback( - (groupTitle: string, stateId: string | null) => { + (groupTitle: string) => { setCreateIssueModal(true); if (selectedGroup) setPreloadedData({ - state: stateId ?? undefined, [selectedGroup]: groupTitle, actionType: "createIssue", }); @@ -372,69 +378,116 @@ export const IssuesView: React.FC<Props> = ({ isOpen={deleteIssueModal} data={issueToDelete} /> - - <div className="relative"> - <DragDropContext onDragEnd={handleOnDragEnd}> - <StrictModeDroppable droppableId="trashBox"> - {(provided, snapshot) => ( - <div - className={`${ - trashBox ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0" - } fixed top-9 right-9 z-20 flex h-28 w-96 items-center justify-center gap-2 rounded border-2 border-red-500 bg-red-100 p-3 text-xs font-medium italic text-red-500 ${ - snapshot.isDraggingOver ? "bg-red-500 text-white" : "" - } duration-200`} - ref={provided.innerRef} - {...provided.droppableProps} + <div className="flex items-center gap-2"> + {Object.keys(filters).map((key) => { + if (filters[key as keyof typeof filters] !== null) + return ( + <button + key={key} + type="button" + className="rounded bg-black p-2 text-xs text-white" + onClick={() => + setFilters({ + [key]: null, + }) + } > - <TrashIcon className="h-4 w-4" /> - Drop issue here to delete - </div> - )} - </StrictModeDroppable> - {issueView === "list" ? ( - <AllLists - type={type} - issues={issues} - states={states} - members={members} - addIssueToState={addIssueToState} - makeIssueCopy={makeIssueCopy} - handleEditIssue={handleEditIssue} - handleDeleteIssue={handleDeleteIssue} - openIssuesListModal={type !== "issue" ? openIssuesListModal : null} - removeIssue={ - type === "cycle" - ? removeIssueFromCycle - : type === "module" - ? removeIssueFromModule - : null - } - userAuth={userAuth} - /> - ) : ( - <AllBoards - type={type} - issues={issues} - states={states} - members={members} - addIssueToState={addIssueToState} - makeIssueCopy={makeIssueCopy} - handleEditIssue={handleEditIssue} - openIssuesListModal={type !== "issue" ? openIssuesListModal : null} - handleDeleteIssue={handleDeleteIssue} - handleTrashBox={handleTrashBox} - removeIssue={ - type === "cycle" - ? removeIssueFromCycle - : type === "module" - ? removeIssueFromModule - : null - } - userAuth={userAuth} - /> - )} - </DragDropContext> + Remove {key} filter + </button> + ); + })} </div> + <DragDropContext onDragEnd={handleOnDragEnd}> + <StrictModeDroppable droppableId="trashBox"> + {(provided, snapshot) => ( + <div + className={`${ + trashBox ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0" + } fixed top-9 right-9 z-20 flex h-28 w-96 flex-col items-center justify-center gap-2 rounded border-2 border-red-500 bg-red-100 p-3 text-xs font-medium italic text-red-500 ${ + snapshot.isDraggingOver ? "bg-red-500 text-white" : "" + } duration-200`} + ref={provided.innerRef} + {...provided.droppableProps} + > + <TrashIcon className="h-4 w-4" /> + Drop issue here to delete + {provided.placeholder} + </div> + )} + </StrictModeDroppable> + {groupedByIssues ? ( + Object.keys(groupedByIssues).length > 0 ? ( + <> + {issueView === "list" ? ( + <AllLists + type={type} + states={states} + members={members} + addIssueToState={addIssueToState} + makeIssueCopy={makeIssueCopy} + handleEditIssue={handleEditIssue} + handleDeleteIssue={handleDeleteIssue} + openIssuesListModal={type !== "issue" ? openIssuesListModal : null} + removeIssue={ + type === "cycle" + ? removeIssueFromCycle + : type === "module" + ? removeIssueFromModule + : null + } + userAuth={userAuth} + /> + ) : ( + <AllBoards + type={type} + states={states} + addIssueToState={addIssueToState} + makeIssueCopy={makeIssueCopy} + handleEditIssue={handleEditIssue} + openIssuesListModal={type !== "issue" ? openIssuesListModal : null} + handleDeleteIssue={handleDeleteIssue} + handleTrashBox={handleTrashBox} + removeIssue={ + type === "cycle" + ? removeIssueFromCycle + : type === "module" + ? removeIssueFromModule + : null + } + userAuth={userAuth} + /> + )} + </> + ) : ( + <div className="grid h-full w-full place-items-center px-4 sm:px-0"> + <EmptySpace + title="You don't have any issue yet." + description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done." + Icon={RectangleStackIcon} + > + <EmptySpaceItem + title="Create a new issue" + description={ + <span> + Use <pre className="inline rounded bg-gray-200 px-2 py-1">C</pre> shortcut to + create a new issue + </span> + } + Icon={PlusIcon} + action={() => { + const e = new KeyboardEvent("keydown", { + key: "c", + }); + document.dispatchEvent(e); + }} + /> + </EmptySpace> + </div> + ) + ) : ( + <p className="text-center">Loading...</p> + )} + </DragDropContext> </> ); }; diff --git a/apps/app/components/core/list-view/all-lists.tsx b/apps/app/components/core/list-view/all-lists.tsx index 0e067513d..750dd3bc2 100644 --- a/apps/app/components/core/list-view/all-lists.tsx +++ b/apps/app/components/core/list-view/all-lists.tsx @@ -1,5 +1,5 @@ // hooks -import useIssueView from "hooks/use-issue-view"; +import useIssuesView from "hooks/use-issues-view"; // components import { SingleList } from "components/core/list-view/single-list"; // types @@ -8,7 +8,6 @@ import { IIssue, IProjectMember, IState, UserAuth } from "types"; // types type Props = { type: "issue" | "cycle" | "module"; - issues: IIssue[]; states: IState[] | undefined; members: IProjectMember[] | undefined; addIssueToState: (groupTitle: string, stateId: string | null) => void; @@ -22,7 +21,6 @@ type Props = { export const AllLists: React.FC<Props> = ({ type, - issues, states, members, addIssueToState, @@ -33,44 +31,35 @@ export const AllLists: React.FC<Props> = ({ removeIssue, userAuth, }) => { - const { groupedByIssues, groupByProperty: selectedGroup } = useIssueView(issues); + const { groupedByIssues, groupByProperty: selectedGroup } = useIssuesView(); return ( - <div className="flex flex-col space-y-5"> - {Object.keys(groupedByIssues).map((singleGroup) => { - const currentState = - selectedGroup === "state_detail.name" - ? states?.find((s) => s.name === singleGroup) - : null; - const stateId = - selectedGroup === "state_detail.name" - ? states?.find((s) => s.name === singleGroup)?.id ?? null - : null; - const bgColor = - selectedGroup === "state_detail.name" - ? states?.find((s) => s.name === singleGroup)?.color - : "#000000"; + <> + {groupedByIssues && ( + <div className="flex flex-col space-y-5"> + {Object.keys(groupedByIssues).map((singleGroup) => { + const stateId = selectedGroup === "state" ? singleGroup : null; - return ( - <SingleList - key={singleGroup} - type={type} - currentState={currentState} - bgColor={bgColor} - groupTitle={singleGroup} - groupedByIssues={groupedByIssues} - selectedGroup={selectedGroup} - members={members} - addIssueToState={() => addIssueToState(singleGroup, stateId)} - makeIssueCopy={makeIssueCopy} - handleEditIssue={handleEditIssue} - handleDeleteIssue={handleDeleteIssue} - openIssuesListModal={type !== "issue" ? openIssuesListModal : null} - removeIssue={removeIssue} - userAuth={userAuth} - /> - ); - })} - </div> + return ( + <SingleList + key={singleGroup} + type={type} + groupTitle={singleGroup} + groupedByIssues={groupedByIssues} + selectedGroup={selectedGroup} + members={members} + addIssueToState={() => addIssueToState(singleGroup, stateId)} + makeIssueCopy={makeIssueCopy} + handleEditIssue={handleEditIssue} + handleDeleteIssue={handleDeleteIssue} + openIssuesListModal={type !== "issue" ? openIssuesListModal : null} + removeIssue={removeIssue} + userAuth={userAuth} + /> + ); + })} + </div> + )} + </> ); }; diff --git a/apps/app/components/core/list-view/single-issue.tsx b/apps/app/components/core/list-view/single-issue.tsx index bfbec1db7..e8900f432 100644 --- a/apps/app/components/core/list-view/single-issue.tsx +++ b/apps/app/components/core/list-view/single-issue.tsx @@ -16,7 +16,8 @@ import { ViewPrioritySelect, ViewStateSelect, } from "components/issues/view-select"; - +// hooks +import useIssueView from "hooks/use-issues-view"; // ui import { Tooltip, CustomMenu, ContextMenu } from "components/ui"; // icons @@ -28,16 +29,23 @@ import { } from "@heroicons/react/24/outline"; // helpers import { copyTextToClipboard, truncateText } from "helpers/string.helper"; +import { handleIssuesMutation } from "constants/issue"; // types -import { CycleIssueResponse, IIssue, ModuleIssueResponse, Properties, UserAuth } from "types"; +import { IIssue, Properties, UserAuth } from "types"; // fetch-keys -import { CYCLE_ISSUES, MODULE_ISSUES, PROJECT_ISSUES_LIST } from "constants/fetch-keys"; +import { + CYCLE_ISSUES_WITH_PARAMS, + MODULE_ISSUES_WITH_PARAMS, + PROJECT_ISSUES_LIST_WITH_PARAMS, +} from "constants/fetch-keys"; type Props = { type?: string; issue: IIssue; properties: Properties; + groupTitle?: string; editIssue: () => void; + index: number; makeIssueCopy: () => void; removeIssue?: (() => void) | null; handleDeleteIssue: (issue: IIssue) => void; @@ -49,8 +57,10 @@ export const SingleListIssue: React.FC<Props> = ({ issue, properties, editIssue, + index, makeIssueCopy, removeIssue, + groupTitle, handleDeleteIssue, userAuth, }) => { @@ -63,80 +73,62 @@ export const SingleListIssue: React.FC<Props> = ({ const { setToastAlert } = useToast(); + const { groupByProperty: selectedGroup } = useIssueView(); + const partialUpdateIssue = useCallback( (formData: Partial<IIssue>) => { if (!workspaceSlug || !projectId) return; if (cycleId) - mutate<CycleIssueResponse[]>( - CYCLE_ISSUES(cycleId as string), - (prevData) => { - const updatedIssues = (prevData ?? []).map((p) => { - if (p.issue_detail.id === issue.id) { - return { - ...p, - issue_detail: { - ...p.issue_detail, - ...formData, - assignees: formData.assignees_list ?? p.issue_detail.assignees_list, - }, - }; - } - return p; - }); - return [...updatedIssues]; - }, + mutate< + | { + [key: string]: IIssue[]; + } + | IIssue[] + >( + CYCLE_ISSUES_WITH_PARAMS(cycleId as string), + (prevData) => + handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData), false ); if (moduleId) - mutate<ModuleIssueResponse[]>( - MODULE_ISSUES(moduleId as string), - (prevData) => { - const updatedIssues = (prevData ?? []).map((p) => { - if (p.issue_detail.id === issue.id) { - return { - ...p, - issue_detail: { - ...p.issue_detail, - ...formData, - assignees: formData.assignees_list ?? p.issue_detail.assignees_list, - }, - }; - } - return p; - }); - return [...updatedIssues]; - }, + mutate< + | { + [key: string]: IIssue[]; + } + | IIssue[] + >( + MODULE_ISSUES_WITH_PARAMS(moduleId as string), + (prevData) => + handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData), false ); - mutate<IIssue[]>( - PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string), + mutate< + | { + [key: string]: IIssue[]; + } + | IIssue[] + >( + PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string), (prevData) => - (prevData ?? []).map((p) => { - if (p.id === issue.id) - return { ...p, ...formData, assignees: formData.assignees_list ?? p.assignees_list }; - - return p; - }), - + handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData), false ); issuesService .patchIssue(workspaceSlug as string, projectId as string, issue.id, formData) .then((res) => { - if (cycleId) mutate(CYCLE_ISSUES(cycleId as string)); - if (moduleId) mutate(MODULE_ISSUES(moduleId as string)); - - mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)); + if (cycleId) mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string)); + if (moduleId) mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string)); + mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string)); }) .catch((error) => { console.log(error); }); }, - [workspaceSlug, projectId, cycleId, moduleId, issue] + [workspaceSlug, projectId, cycleId, moduleId, issue, groupTitle, index, selectedGroup] ); const handleCopyText = () => { diff --git a/apps/app/components/core/list-view/single-list.tsx b/apps/app/components/core/list-view/single-list.tsx index 1ca09ff99..4b07fb330 100644 --- a/apps/app/components/core/list-view/single-list.tsx +++ b/apps/app/components/core/list-view/single-list.tsx @@ -12,7 +12,7 @@ import { getStateGroupIcon } from "components/icons"; // helpers import { addSpaceIfCamelCase } from "helpers/string.helper"; // types -import { IIssue, IProjectMember, IState, NestedKeyOf, UserAuth } from "types"; +import { IIssue, IProjectMember, IState, UserAuth } from "types"; import { CustomMenu } from "components/ui"; type Props = { @@ -23,7 +23,7 @@ type Props = { groupedByIssues: { [key: string]: IIssue[]; }; - selectedGroup: NestedKeyOf<IIssue> | null; + selectedGroup: "priority" | "state" | "labels" | null; members: IProjectMember[] | undefined; addIssueToState: () => void; makeIssueCopy: (issue: IIssue) => void; @@ -55,22 +55,6 @@ export const SingleList: React.FC<Props> = ({ const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string); - const createdBy = - selectedGroup === "created_by" - ? members?.find((m) => m.member.id === groupTitle)?.member.first_name ?? "Loading..." - : null; - - let assignees: any; - if (selectedGroup === "assignees") { - assignees = groupTitle && groupTitle !== "" ? groupTitle.split(",") : []; - assignees = - assignees.length > 0 - ? assignees - .map((a: string) => members?.find((m) => m.member.id === a)?.member.first_name) - .join(", ") - : "No assignee"; - } - return ( <Disclosure key={groupTitle} as="div" defaultOpen> {({ open }) => ( @@ -82,7 +66,7 @@ export const SingleList: React.FC<Props> = ({ > <Disclosure.Button> <div className="flex items-center gap-x-3"> - {selectedGroup !== null && selectedGroup === "state_detail.name" ? ( + {selectedGroup !== null && selectedGroup === "state" ? ( <span> {currentState && getStateGroupIcon(currentState.group, "20", "20", bgColor)} </span> @@ -91,11 +75,7 @@ export const SingleList: React.FC<Props> = ({ )} {selectedGroup !== null ? ( <h2 className="text-xl font-semibold capitalize leading-6 text-gray-800"> - {selectedGroup === "created_by" - ? createdBy - : selectedGroup === "assignees" - ? assignees - : addSpaceIfCamelCase(groupTitle)} + {addSpaceIfCamelCase(groupTitle)} </h2> ) : ( <h2 className="font-medium leading-5">All Issues</h2> @@ -105,7 +85,6 @@ export const SingleList: React.FC<Props> = ({ </span> </div> </Disclosure.Button> - {type === "issue" ? ( <button type="button" @@ -145,17 +124,19 @@ export const SingleList: React.FC<Props> = ({ <Disclosure.Panel> {groupedByIssues[groupTitle] ? ( groupedByIssues[groupTitle].length > 0 ? ( - groupedByIssues[groupTitle].map((issue: IIssue) => ( + groupedByIssues[groupTitle].map((issue, index) => ( <SingleListIssue key={issue.id} type={type} issue={issue} properties={properties} + groupTitle={groupTitle} + index={index} editIssue={() => handleEditIssue(issue)} makeIssueCopy={() => makeIssueCopy(issue)} handleDeleteIssue={handleDeleteIssue} removeIssue={() => { - removeIssue && removeIssue(issue.bridge); + if (removeIssue !== null && issue.bridge_id) removeIssue(issue.bridge_id); }} userAuth={userAuth} /> diff --git a/apps/app/components/core/sidebar/sidebar-progress-stats.tsx b/apps/app/components/core/sidebar/sidebar-progress-stats.tsx index 8c9c7028b..6e06a2f11 100644 --- a/apps/app/components/core/sidebar/sidebar-progress-stats.tsx +++ b/apps/app/components/core/sidebar/sidebar-progress-stats.tsx @@ -179,6 +179,7 @@ export const SidebarProgressStats: React.FC<Props> = ({ {members?.map((member, index) => { const totalArray = issues?.filter((i) => i.assignees?.includes(member.member.id)); const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed"); + if (totalArray.length > 0) { return ( <SingleProgressStats @@ -223,9 +224,10 @@ export const SidebarProgressStats: React.FC<Props> = ({ )} </Tab.Panel> <Tab.Panel as="div" className="flex w-full flex-col "> - {issueLabels?.map((issue, index) => { - const totalArray = issues?.filter((i) => i.labels?.includes(issue.id)); + {issueLabels?.map((label, index) => { + const totalArray = issues?.filter((i) => i.labels?.includes(label.id)); const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed"); + if (totalArray.length > 0) { return ( <SingleProgressStats @@ -235,10 +237,11 @@ export const SidebarProgressStats: React.FC<Props> = ({ <span className="block h-3 w-3 rounded-full " style={{ - backgroundColor: issue.color, + backgroundColor: + label.color && label.color !== "" ? label.color : "#000000", }} /> - <span className="text-xs capitalize">{issue.name}</span> + <span className="text-xs capitalize">{label.name}</span> </div> } completed={completeArray.length} diff --git a/apps/app/components/core/sidebar/single-progress-stats.tsx b/apps/app/components/core/sidebar/single-progress-stats.tsx index 58e684f61..6066a3deb 100644 --- a/apps/app/components/core/sidebar/single-progress-stats.tsx +++ b/apps/app/components/core/sidebar/single-progress-stats.tsx @@ -6,14 +6,23 @@ type TSingleProgressStatsProps = { title: any; completed: number; total: number; + onClick?: () => void; + selected?: boolean; }; export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({ title, completed, total, + onClick, + selected = false, }) => ( - <div className="flex w-full items-center justify-between py-3 text-xs"> + <div + className={`flex w-full items-center justify-between py-3 text-xs ${ + onClick ? "cursor-pointer hover:bg-gray-100" : "" + } ${selected ? "bg-gray-100" : ""}`} + onClick={onClick} + > <div className="flex w-1/2 items-center justify-start gap-2">{title}</div> <div className="flex w-1/2 items-center justify-end gap-1 px-2"> <div className="flex h-5 items-center justify-center gap-1 "> diff --git a/apps/app/components/cycles/sidebar.tsx b/apps/app/components/cycles/sidebar.tsx index 674ac16c7..d45e72358 100644 --- a/apps/app/components/cycles/sidebar.tsx +++ b/apps/app/components/cycles/sidebar.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState } from "react"; import { useRouter } from "next/router"; import Image from "next/image"; -import { mutate } from "swr"; +import useSWR, { mutate } from "swr"; // react-hook-form import { useForm } from "react-hook-form"; @@ -36,25 +36,17 @@ import { capitalizeFirstLetter, copyTextToClipboard } from "helpers/string.helpe import { groupBy } from "helpers/array.helper"; import { renderDateFormat, renderShortDate } from "helpers/date-time.helper"; // types -import { CycleIssueResponse, ICycle, IIssue } from "types"; +import { ICycle, IIssue } from "types"; // fetch-keys -import { CYCLE_DETAILS } from "constants/fetch-keys"; +import { CYCLE_DETAILS, CYCLE_ISSUES } from "constants/fetch-keys"; type Props = { - issues: IIssue[]; cycle: ICycle | undefined; isOpen: boolean; - cycleIssues: CycleIssueResponse[]; cycleStatus: string; }; -export const CycleDetailsSidebar: React.FC<Props> = ({ - issues, - cycle, - isOpen, - cycleIssues, - cycleStatus, -}) => { +export const CycleDetailsSidebar: React.FC<Props> = ({ cycle, isOpen, cycleStatus }) => { const [cycleDeleteModal, setCycleDeleteModal] = useState(false); const [startDateRange, setStartDateRange] = useState<Date | null>(new Date()); const [endDateRange, setEndDateRange] = useState<Date | null>(null); @@ -69,13 +61,25 @@ export const CycleDetailsSidebar: React.FC<Props> = ({ end_date: new Date().toString(), }; + const { data: issues } = useSWR<IIssue[]>( + workspaceSlug && projectId && cycleId ? CYCLE_ISSUES(cycleId as string) : null, + workspaceSlug && projectId && cycleId + ? () => + cyclesService.getCycleIssues( + workspaceSlug as string, + projectId as string, + cycleId as string + ) + : null + ); + const groupedIssues = { backlog: [], unstarted: [], started: [], cancelled: [], completed: [], - ...groupBy(cycleIssues ?? [], "issue_detail.state_detail.group"), + ...groupBy(issues ?? [], "state_detail.group"), }; const { reset } = useForm({ @@ -131,9 +135,10 @@ export const CycleDetailsSidebar: React.FC<Props> = ({ const isStartValid = new Date(`${cycle?.start_date}`) <= new Date(); const isEndValid = new Date(`${cycle?.end_date}`) >= new Date(`${cycle?.start_date}`); - const progressPercentage = cycleIssues - ? Math.round((groupedIssues.completed.length / cycleIssues?.length) * 100) + const progressPercentage = issues + ? Math.round((groupedIssues.completed.length / issues?.length) * 100) : null; + return ( <> <DeleteCycleModal isOpen={cycleDeleteModal} setIsOpen={setCycleDeleteModal} data={cycle} /> @@ -305,10 +310,10 @@ export const CycleDetailsSidebar: React.FC<Props> = ({ <span className="h-4 w-4"> <ProgressBar value={groupedIssues.completed.length} - maxValue={cycleIssues?.length} + maxValue={issues?.length} /> </span> - {groupedIssues.completed.length}/{cycleIssues?.length} + {groupedIssues.completed.length}/{issues?.length} </div> </div> </div> @@ -324,7 +329,7 @@ export const CycleDetailsSidebar: React.FC<Props> = ({ <div className="flex w-full items-center justify-between gap-2 "> <div className="flex items-center justify-start gap-2 text-sm"> <span className="font-medium text-gray-500">Progress</span> - {!open && cycleIssues && progressPercentage ? ( + {!open && issues && progressPercentage ? ( <span className="rounded bg-[#09A953]/10 px-1.5 py-0.5 text-xs text-[#09A953]"> {progressPercentage ? `${progressPercentage}%` : ""} </span> @@ -359,7 +364,7 @@ export const CycleDetailsSidebar: React.FC<Props> = ({ </span> <span> Pending Issues -{" "} - {cycleIssues?.length - groupedIssues.completed.length}{" "} + {issues?.length ?? 0 - groupedIssues.completed.length}{" "} </span> </div> @@ -376,7 +381,7 @@ export const CycleDetailsSidebar: React.FC<Props> = ({ </div> <div className="relative h-40 w-80"> <ProgressChart - issues={issues} + issues={issues ?? []} start={cycle?.start_date ?? ""} end={cycle?.end_date ?? ""} /> @@ -403,7 +408,7 @@ export const CycleDetailsSidebar: React.FC<Props> = ({ <span className="font-medium text-gray-500">Other Information</span> </div> - {issues.length > 0 ? ( + {(issues?.length ?? 0) > 0 ? ( <Disclosure.Button> <ChevronDownIcon className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`} @@ -419,9 +424,12 @@ export const CycleDetailsSidebar: React.FC<Props> = ({ </div> <Transition show={open}> <Disclosure.Panel> - {issues.length > 0 ? ( + {(issues?.length ?? 0) > 0 ? ( <div className=" h-full w-full py-4"> - <SidebarProgressStats issues={issues} groupedIssues={groupedIssues} /> + <SidebarProgressStats + issues={issues ?? []} + groupedIssues={groupedIssues} + /> </div> ) : ( "" diff --git a/apps/app/components/cycles/single-cycle-card.tsx b/apps/app/components/cycles/single-cycle-card.tsx index 23279d559..607c0cc04 100644 --- a/apps/app/components/cycles/single-cycle-card.tsx +++ b/apps/app/components/cycles/single-cycle-card.tsx @@ -30,7 +30,6 @@ import { capitalizeFirstLetter, copyTextToClipboard, truncateText } from "helper import { CompletedCyclesResponse, CurrentAndUpcomingCyclesResponse, - CycleIssueResponse, DraftCyclesResponse, ICycle, } from "types"; @@ -65,7 +64,7 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = (props) => { const { workspaceSlug, projectId } = router.query; const { setToastAlert } = useToast(); - const { data: cycleIssues } = useSWR<CycleIssueResponse[]>( + const { data: cycleIssues } = useSWR( workspaceSlug && projectId && cycle.id ? CYCLE_ISSUES(cycle.id as string) : null, workspaceSlug && projectId && cycle.id ? () => cyclesService.getCycleIssues(workspaceSlug as string, projectId as string, cycle.id) diff --git a/apps/app/components/issues/view-select/due-date.tsx b/apps/app/components/issues/view-select/due-date.tsx index b36ca6db0..f897eaad9 100644 --- a/apps/app/components/issues/view-select/due-date.tsx +++ b/apps/app/components/issues/view-select/due-date.tsx @@ -28,6 +28,8 @@ export const ViewDueDateSelect: React.FC<Props> = ({ issue, partialUpdateIssue, onChange={(val) => partialUpdateIssue({ target_date: val, + priority: issue.priority, + state: issue.state, }) } className={issue?.target_date ? "w-[6.5rem]" : "w-[3rem] text-center"} diff --git a/apps/app/components/issues/view-select/priority.tsx b/apps/app/components/issues/view-select/priority.tsx index 6ca81037e..e5078e71d 100644 --- a/apps/app/components/issues/view-select/priority.tsx +++ b/apps/app/components/issues/view-select/priority.tsx @@ -25,8 +25,10 @@ export const ViewPrioritySelect: React.FC<Props> = ({ isNotAllowed, }) => ( <CustomSelect - value={issue.state} - onChange={(data: string) => partialUpdateIssue({ priority: data })} + value={issue.priority} + onChange={(data: string) => + partialUpdateIssue({ priority: data, state: issue.state, target_date: issue.target_date }) + } maxHeight="md" customButton={ <button diff --git a/apps/app/components/issues/view-select/state.tsx b/apps/app/components/issues/view-select/state.tsx index b76a3c237..a4ea4d25b 100644 --- a/apps/app/components/issues/view-select/state.tsx +++ b/apps/app/components/issues/view-select/state.tsx @@ -58,7 +58,13 @@ export const ViewStateSelect: React.FC<Props> = ({ return ( <CustomSearchSelect value={issue.state} - onChange={(data: string) => partialUpdateIssue({ state: data })} + onChange={(data: string) => + partialUpdateIssue({ + state: data, + priority: issue.priority, + target_date: issue.target_date, + }) + } options={options} label={ <Tooltip diff --git a/apps/app/components/modules/sidebar.tsx b/apps/app/components/modules/sidebar.tsx index 76f99f42e..c9221dffd 100644 --- a/apps/app/components/modules/sidebar.tsx +++ b/apps/app/components/modules/sidebar.tsx @@ -33,11 +33,11 @@ import ProgressChart from "components/core/sidebar/progress-chart"; // ui import { CustomMenu, CustomSelect, Loader, ProgressBar } from "components/ui"; // helpers -import { renderDateFormat, renderShortDate, timeAgo } from "helpers/date-time.helper"; +import { renderDateFormat, renderShortDate } from "helpers/date-time.helper"; import { capitalizeFirstLetter, copyTextToClipboard } from "helpers/string.helper"; import { groupBy } from "helpers/array.helper"; // types -import { IIssue, IModule, ModuleIssueResponse, ModuleLink, UserAuth } from "types"; +import { IIssue, IModule, ModuleLink, UserAuth } from "types"; // fetch-keys import { MODULE_DETAILS } from "constants/fetch-keys"; // constant @@ -55,7 +55,7 @@ type Props = { issues: IIssue[]; module?: IModule; isOpen: boolean; - moduleIssues: ModuleIssueResponse[] | undefined; + moduleIssues?: IIssue[]; userAuth: UserAuth; }; diff --git a/apps/app/components/states/single-state.tsx b/apps/app/components/states/single-state.tsx index 18472171f..677d41a46 100644 --- a/apps/app/components/states/single-state.tsx +++ b/apps/app/components/states/single-state.tsx @@ -21,7 +21,6 @@ import { groupBy, orderArrayBy } from "helpers/array.helper"; import { orderStateGroups } from "helpers/state.helper"; // types import { IState } from "types"; -import { StateGroup } from "components/states"; // fetch-keys import { STATE_LIST } from "constants/fetch-keys"; import { getStateGroupIcon } from "components/icons"; diff --git a/apps/app/constants/fetch-keys.ts b/apps/app/constants/fetch-keys.ts index a7903c1cb..4f66ce10e 100644 --- a/apps/app/constants/fetch-keys.ts +++ b/apps/app/constants/fetch-keys.ts @@ -1,3 +1,5 @@ +import { IIssueFilterOptions } from "types"; + export const CURRENT_USER = "CURRENT_USER"; export const USER_WORKSPACE_INVITATIONS = "USER_WORKSPACE_INVITATIONS"; export const USER_WORKSPACES = "USER_WORKSPACES"; @@ -24,6 +26,8 @@ export const PROJECT_INVITATIONS = "PROJECT_INVITATIONS"; export const PROJECT_ISSUES_LIST = (workspaceSlug: string, projectId: string) => `PROJECT_ISSUES_LIST_${workspaceSlug}_${projectId}`; +export const PROJECT_ISSUES_LIST_WITH_PARAMS = (projectId: string) => + `PROJECT_ISSUES_LIST_WITH_PARAMS_${projectId}`; export const PROJECT_ISSUES_DETAILS = (issueId: string) => `PROJECT_ISSUES_DETAILS_${issueId}`; export const PROJECT_ISSUES_PROPERTIES = (projectId: string) => `PROJECT_ISSUES_PROPERTIES_${projectId}`; @@ -36,6 +40,7 @@ export const PROJECT_GITHUB_REPOSITORY = (projectId: string) => export const CYCLE_LIST = (projectId: string) => `CYCLE_LIST_${projectId}`; export const CYCLE_ISSUES = (cycleId: string) => `CYCLE_ISSUES_${cycleId}`; +export const CYCLE_ISSUES_WITH_PARAMS = (cycleId: string) => `CYCLE_ISSUES_WITH_PARAMS_${cycleId}`; export const CYCLE_DETAILS = (cycleId: string) => `CYCLE_DETAILS_${cycleId}`; export const CYCLE_CURRENT_AND_UPCOMING_LIST = (projectId: string) => `CYCLE_CURRENT_AND_UPCOMING_LIST_${projectId}`; @@ -50,6 +55,8 @@ export const USER_PROJECT_VIEW = (projectId: string) => `USER_PROJECT_VIEW_${pro export const MODULE_LIST = (projectId: string) => `MODULE_LIST_${projectId}`; export const MODULE_ISSUES = (moduleId: string) => `MODULE_ISSUES_${moduleId}`; +export const MODULE_ISSUES_WITH_PARAMS = (moduleId: string) => + `MODULE_ISSUES_WITH_PARAMS_${moduleId}`; export const MODULE_DETAILS = (moduleId: string) => `MODULE_DETAILS_${moduleId}`; export const VIEWS_LIST = (projectId: string) => `VIEWS_LIST_${projectId}`; diff --git a/apps/app/constants/issue.ts b/apps/app/constants/issue.ts index 46657d762..d7983cf4f 100644 --- a/apps/app/constants/issue.ts +++ b/apps/app/constants/issue.ts @@ -1,15 +1,17 @@ -// types -import { IIssue, NestedKeyOf } from "types"; - -export const GROUP_BY_OPTIONS: Array<{ name: string; key: NestedKeyOf<IIssue> | null }> = [ - { name: "State", key: "state_detail.name" }, +export const GROUP_BY_OPTIONS: Array<{ + name: string; + key: "state" | "priority" | "labels" | null; +}> = [ + { name: "State", key: "state" }, { name: "Priority", key: "priority" }, - { name: "Created By", key: "created_by" }, - { name: "Assignee", key: "assignees" }, + { name: "Labels", key: "labels" }, { name: "None", key: null }, ]; -export const ORDER_BY_OPTIONS: Array<{ name: string; key: NestedKeyOf<IIssue> | null }> = [ +export const ORDER_BY_OPTIONS: Array<{ + name: string; + key: "created_at" | "updated_at" | "priority" | "sort_order"; +}> = [ { name: "Manual", key: "sort_order" }, { name: "Last created", key: "created_at" }, { name: "Last updated", key: "updated_at" }, @@ -18,7 +20,7 @@ export const ORDER_BY_OPTIONS: Array<{ name: string; key: NestedKeyOf<IIssue> | export const FILTER_ISSUE_OPTIONS: Array<{ name: string; - key: "activeIssue" | "backlogIssue" | null; + key: "active" | "backlog" | null; }> = [ { name: "All", @@ -26,10 +28,78 @@ export const FILTER_ISSUE_OPTIONS: Array<{ }, { name: "Active Issues", - key: "activeIssue", + key: "active", }, { name: "Backlog Issues", - key: "backlogIssue", + key: "backlog", }, ]; + +import { IIssue } from "types"; + +type THandleIssuesMutation = ( + formData: Partial<IIssue>, + oldGroupTitle: string, + selectedGroupBy: "state" | "priority" | "labels" | null, + issueIndex: number, + prevData?: + | { + [key: string]: IIssue[]; + } + | IIssue[] +) => + | { + [key: string]: IIssue[]; + } + | IIssue[] + | undefined; + +export const handleIssuesMutation: THandleIssuesMutation = ( + formData, + oldGroupTitle, + selectedGroupBy, + issueIndex, + prevData +) => { + if (!prevData) return prevData; + + if (Array.isArray(prevData)) { + const updatedIssue = { + ...prevData[issueIndex], + ...formData, + assignees: formData?.assignees_list ?? prevData[issueIndex]?.assignees_list, + }; + + prevData.splice(issueIndex, 1, updatedIssue); + + return [...prevData]; + } else { + const oldGroup = prevData[oldGroupTitle ?? ""] ?? []; + + let newGroup: IIssue[] = []; + + if (selectedGroupBy === "priority") { + newGroup = prevData[formData.priority ?? ""] ?? []; + } else if (selectedGroupBy === "state") { + newGroup = prevData[formData.state ?? ""] ?? []; + } + + const updatedIssue = { + ...oldGroup[issueIndex], + ...formData, + assignees: formData?.assignees_list ?? oldGroup[issueIndex]?.assignees_list, + }; + + oldGroup.splice(issueIndex, 1); + newGroup.push(updatedIssue); + + const groupThatIsUpdated = selectedGroupBy === "priority" ? formData.priority : formData.state; + + return { + ...prevData, + [oldGroupTitle ?? ""]: oldGroup, + [groupThatIsUpdated ?? ""]: newGroup, + }; + } +}; diff --git a/apps/app/contexts/issue-view.context.tsx b/apps/app/contexts/issue-view.context.tsx index 498608fbe..c032d15d5 100644 --- a/apps/app/contexts/issue-view.context.tsx +++ b/apps/app/contexts/issue-view.context.tsx @@ -2,24 +2,29 @@ import { createContext, useCallback, useEffect, useReducer } from "react"; import { useRouter } from "next/router"; -import useSWR from "swr"; +import useSWR, { mutate } from "swr"; // components import ToastAlert from "components/toast-alert"; // services import projectService from "services/project.service"; // types -import type { IIssue, NestedKeyOf } from "types"; +import { IIssueFilterOptions, IProjectMember, NestedKeyOf } from "types"; // fetch-keys -import { USER_PROJECT_VIEW } from "constants/fetch-keys"; +import { + CYCLE_ISSUES_WITH_PARAMS, + MODULE_ISSUES_WITH_PARAMS, + PROJECT_ISSUES_LIST_WITH_PARAMS, + USER_PROJECT_VIEW, +} from "constants/fetch-keys"; export const issueViewContext = createContext<ContextType>({} as ContextType); type IssueViewProps = { - issueView: "list" | "kanban" | null; - groupByProperty: NestedKeyOf<IIssue> | null; - filterIssue: "activeIssue" | "backlogIssue" | null; - orderBy: NestedKeyOf<IIssue> | null; + issueView: "list" | "kanban"; + groupByProperty: "state" | "priority" | "labels" | null; + orderBy: "created_at" | "updated_at" | "priority" | "sort_order"; + filters: IIssueFilterOptions; }; type ReducerActionType = { @@ -27,20 +32,16 @@ type ReducerActionType = { | "REHYDRATE_THEME" | "SET_ISSUE_VIEW" | "SET_ORDER_BY_PROPERTY" - | "SET_FILTER_ISSUES" + | "SET_FILTERS" | "SET_GROUP_BY_PROPERTY" | "RESET_TO_DEFAULT"; payload?: Partial<IssueViewProps>; }; -type ContextType = { - orderBy: NestedKeyOf<IIssue> | null; - issueView: "list" | "kanban" | null; - groupByProperty: NestedKeyOf<IIssue> | null; - filterIssue: "activeIssue" | "backlogIssue" | null; - setGroupByProperty: (property: NestedKeyOf<IIssue> | null) => void; - setOrderBy: (property: NestedKeyOf<IIssue> | null) => void; - setFilterIssue: (property: "activeIssue" | "backlogIssue" | null) => void; +type ContextType = IssueViewProps & { + setGroupByProperty: (property: "state" | "priority" | "labels" | null) => void; + setOrderBy: (property: "created_at" | "updated_at" | "priority" | "sort_order") => void; + setFilters: (filters: Partial<IIssueFilterOptions>) => void; resetFilterToDefault: () => void; setNewFilterDefaultView: () => void; setIssueViewToKanban: () => void; @@ -48,10 +49,10 @@ type ContextType = { }; type StateType = { - issueView: "list" | "kanban" | null; - groupByProperty: NestedKeyOf<IIssue> | null; - filterIssue: "activeIssue" | "backlogIssue" | null; - orderBy: NestedKeyOf<IIssue> | null; + issueView: "list" | "kanban"; + groupByProperty: "state" | "priority" | "labels" | null; + orderBy: "created_at" | "updated_at" | "priority" | "sort_order"; + filters: IIssueFilterOptions; }; type ReducerFunctionType = (state: StateType, action: ReducerActionType) => StateType; @@ -59,7 +60,13 @@ export const initialState: StateType = { issueView: "list", groupByProperty: null, orderBy: "created_at", - filterIssue: null, + filters: { + type: null, + assignees: null, + labels: null, + issue__assignees__id: null, + issue__labels__id: null, + }, }; export const reducer: ReducerFunctionType = (state, action) => { @@ -69,6 +76,7 @@ export const reducer: ReducerFunctionType = (state, action) => { case "REHYDRATE_THEME": { let collapsed: any = localStorage.getItem("collapsed"); collapsed = collapsed ? JSON.parse(collapsed) : false; + return { ...initialState, ...payload, collapsed }; } @@ -77,6 +85,7 @@ export const reducer: ReducerFunctionType = (state, action) => { ...state, issueView: payload?.issueView || "list", }; + return { ...state, ...newState, @@ -88,6 +97,7 @@ export const reducer: ReducerFunctionType = (state, action) => { ...state, groupByProperty: payload?.groupByProperty || null, }; + return { ...state, ...newState, @@ -97,19 +107,24 @@ export const reducer: ReducerFunctionType = (state, action) => { case "SET_ORDER_BY_PROPERTY": { const newState = { ...state, - orderBy: payload?.orderBy || null, + orderBy: payload?.orderBy || "created_at", }; + return { ...state, ...newState, }; } - case "SET_FILTER_ISSUES": { + case "SET_FILTERS": { const newState = { ...state, - filterIssue: payload?.filterIssue || null, + filters: { + ...state.filters, + ...payload, + }, }; + return { ...state, ...newState, @@ -135,8 +150,21 @@ const saveDataToServer = async (workspaceSlug: string, projectID: string, state: }); }; -const setNewDefault = async (workspaceSlug: string, projectID: string, state: any) => { - await projectService.setProjectView(workspaceSlug, projectID, { +const setNewDefault = async (workspaceSlug: string, projectId: string, state: any) => { + mutate<IProjectMember>( + workspaceSlug && projectId ? USER_PROJECT_VIEW(projectId as string) : null, + (prevData) => { + if (!prevData) return prevData; + + return { + ...prevData, + view_props: state, + }; + }, + false + ); + + await projectService.setProjectView(workspaceSlug, projectId, { view_props: state, default_props: state, }); @@ -146,7 +174,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> = const [state, dispatch] = useReducer(reducer, initialState); const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { data: myViewProps, mutate: mutateMyViewProps } = useSWR( workspaceSlug && projectId ? USER_PROJECT_VIEW(projectId as string) : null, @@ -162,10 +190,11 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> = issueView: "kanban", }, }); + dispatch({ type: "SET_GROUP_BY_PROPERTY", payload: { - groupByProperty: "state_detail.name", + groupByProperty: "state", }, }); @@ -174,7 +203,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> = saveDataToServer(workspaceSlug as string, projectId as string, { ...state, issueView: "kanban", - groupByProperty: "state_detail.name", + groupByProperty: "state", }); }, [workspaceSlug, projectId, state]); @@ -185,6 +214,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> = issueView: "list", }, }); + dispatch({ type: "SET_GROUP_BY_PROPERTY", payload: { @@ -194,15 +224,28 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> = if (!workspaceSlug || !projectId) return; + mutateMyViewProps((prevData) => { + if (!prevData) return prevData; + + return { + ...prevData, + view_props: { + ...state, + issueView: "list", + groupByProperty: null, + }, + }; + }, false); + saveDataToServer(workspaceSlug as string, projectId as string, { ...state, issueView: "list", groupByProperty: null, }); - }, [workspaceSlug, projectId, state]); + }, [workspaceSlug, projectId, state, mutateMyViewProps]); const setGroupByProperty = useCallback( - (property: NestedKeyOf<IIssue> | null) => { + (property: "state" | "priority" | "labels" | null) => { dispatch({ type: "SET_GROUP_BY_PROPERTY", payload: { @@ -211,16 +254,29 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> = }); if (!workspaceSlug || !projectId) return; + + mutateMyViewProps((prevData) => { + if (!prevData) return prevData; + + return { + ...prevData, + view_props: { + ...state, + groupByProperty: property, + }, + }; + }, false); + saveDataToServer(workspaceSlug as string, projectId as string, { ...state, groupByProperty: property, }); }, - [projectId, workspaceSlug, state] + [projectId, workspaceSlug, state, mutateMyViewProps] ); const setOrderBy = useCallback( - (property: NestedKeyOf<IIssue> | null) => { + (property: "created_at" | "updated_at" | "priority" | "sort_order") => { dispatch({ type: "SET_ORDER_BY_PROPERTY", payload: { @@ -229,34 +285,70 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> = }); if (!workspaceSlug || !projectId) return; + + mutateMyViewProps((prevData) => { + if (!prevData) return prevData; + + return { + ...prevData, + view_props: { + ...state, + orderBy: property, + }, + }; + }, false); + saveDataToServer(workspaceSlug as string, projectId as string, { ...state, orderBy: property, }); }, - [projectId, workspaceSlug, state] + [projectId, workspaceSlug, state, mutateMyViewProps] ); - const setFilterIssue = useCallback( - (property: "activeIssue" | "backlogIssue" | null) => { + const setFilters = useCallback( + (property: Partial<IIssueFilterOptions>) => { dispatch({ - type: "SET_FILTER_ISSUES", + type: "SET_FILTERS", payload: { - filterIssue: property, + filters: { + ...state.filters, + ...property, + }, }, }); if (!workspaceSlug || !projectId) return; + + mutateMyViewProps((prevData) => { + if (!prevData) return prevData; + + return { + ...prevData, + view_props: { + ...state, + filters: { + ...state.filters, + ...property, + }, + }, + }; + }, false); + saveDataToServer(workspaceSlug as string, projectId as string, { ...state, - filterIssue: property, + filters: { + ...state.filters, + ...property, + }, }); }, - [projectId, workspaceSlug, state] + [projectId, workspaceSlug, state, mutateMyViewProps] ); const setNewDefaultView = useCallback(() => { if (!workspaceSlug || !projectId) return; + setNewDefault(workspaceSlug as string, projectId as string, state).then(() => { mutateMyViewProps(); }); @@ -267,7 +359,9 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> = type: "RESET_TO_DEFAULT", payload: myViewProps?.default_props, }); + if (!workspaceSlug || !projectId) return; + saveDataToServer(workspaceSlug as string, projectId as string, myViewProps?.default_props); }, [projectId, workspaceSlug, myViewProps]); @@ -278,6 +372,20 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> = }); }, [myViewProps]); + useEffect(() => { + // TODO: think of a better way to do this + if (cycleId) { + mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string), {}, false); + mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string)); + } else if (moduleId) { + mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string), {}, false); + mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string)); + } else { + mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string), {}, false); + mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string)); + } + }, [state, projectId, cycleId, moduleId]); + return ( <issueViewContext.Provider value={{ @@ -286,8 +394,8 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> = setGroupByProperty, orderBy: state.orderBy, setOrderBy, - filterIssue: state.filterIssue, - setFilterIssue, + filters: state.filters, + setFilters, resetFilterToDefault: resetToDefault, setNewFilterDefaultView: setNewDefaultView, setIssueViewToKanban, diff --git a/apps/app/contexts/theme.context.tsx b/apps/app/contexts/theme.context.tsx index d66dd1ce3..c51cdca1a 100644 --- a/apps/app/contexts/theme.context.tsx +++ b/apps/app/contexts/theme.context.tsx @@ -81,7 +81,7 @@ export const ThemeContextProvider: React.FC<{ children: React.ReactNode }> = ({ useEffect(() => { dispatch({ type: "REHYDRATE_THEME", - payload: myViewProps?.view_props, + payload: myViewProps?.view_props as any, }); }, [myViewProps]); diff --git a/apps/app/helpers/common.helper.ts b/apps/app/helpers/common.helper.ts index b2d2489cc..887fe8052 100644 --- a/apps/app/helpers/common.helper.ts +++ b/apps/app/helpers/common.helper.ts @@ -1,5 +1,6 @@ export const debounce = (func: any, wait: number, immediate: boolean = false) => { let timeout: any; + return function executedFunction(...args: any) { const later = () => { timeout = null; diff --git a/apps/app/hooks/use-issue-view.tsx b/apps/app/hooks/use-issue-view.tsx deleted file mode 100644 index 43d7838cf..000000000 --- a/apps/app/hooks/use-issue-view.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { useContext } from "react"; - -import { useRouter } from "next/router"; - -import useSWR from "swr"; - -// services -import stateService from "services/state.service"; -// contexts -import { issueViewContext } from "contexts/issue-view.context"; -// helpers -import { groupBy, orderArrayBy } from "helpers/array.helper"; -import { getStatesList } from "helpers/state.helper"; -// types -import { IIssue, IState } from "types"; -// fetch-keys -import { STATE_LIST } from "constants/fetch-keys"; -// constants -import { PRIORITIES } from "constants/project"; - -const useIssueView = (projectIssues: IIssue[]) => { - const { - issueView, - groupByProperty, - setGroupByProperty, - orderBy, - setOrderBy, - filterIssue, - setFilterIssue, - resetFilterToDefault, - setNewFilterDefaultView, - setIssueViewToKanban, - setIssueViewToList, - } = useContext(issueViewContext); - - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - - const { data: stateGroups } = useSWR( - workspaceSlug && projectId ? STATE_LIST(projectId as string) : null, - workspaceSlug && projectId - ? () => stateService.getStates(workspaceSlug as string, projectId as string) - : null - ); - const states = getStatesList(stateGroups ?? {}); - - let groupedByIssues: { - [key: string]: IIssue[]; - } = {}; - - const groupIssues = (states: IState[], issues: IIssue[]) => ({ - ...(groupByProperty === "state_detail.name" - ? Object.fromEntries( - states - ?.sort((a, b) => a.sequence - b.sequence) - ?.map((state) => [ - state.name, - issues.filter((issue) => issue.state === state.name) ?? [], - ]) ?? [] - ) - : groupByProperty === "priority" - ? Object.fromEntries( - PRIORITIES.map((priority) => [ - priority, - issues.filter((issue) => issue.priority === priority) ?? [], - ]) - ) - : {}), - ...groupBy(issues ?? [], groupByProperty ?? ""), - }); - - groupedByIssues = groupIssues(states ?? [], projectIssues); - - if (filterIssue) { - if (filterIssue === "activeIssue") { - const filteredStates = states?.filter( - (s) => s.group === "started" || s.group === "unstarted" - ); - const filteredIssues = projectIssues.filter( - (i) => i.state_detail.group === "started" || i.state_detail.group === "unstarted" - ); - - groupedByIssues = groupIssues(filteredStates ?? [], filteredIssues); - } else if (filterIssue === "backlogIssue") { - const filteredStates = states?.filter( - (s) => s.group === "backlog" || s.group === "cancelled" - ); - const filteredIssues = projectIssues.filter( - (i) => i.state_detail.group === "backlog" || i.state_detail.group === "cancelled" - ); - - groupedByIssues = groupIssues(filteredStates ?? [], filteredIssues); - } - } - - if (orderBy) { - groupedByIssues = Object.fromEntries( - Object.entries(groupedByIssues).map(([key, value]) => [ - key, - orderArrayBy(value, orderBy, orderBy === "sort_order" ? "ascending" : "descending"), - ]) - ); - } - - if (groupByProperty === "priority") { - delete groupedByIssues.None; - if (orderBy === "priority") setOrderBy("created_at"); - } - - return { - groupedByIssues, - issueView, - groupByProperty, - setGroupByProperty, - orderBy, - setOrderBy, - filterIssue, - setFilterIssue, - resetFilterToDefault, - setNewFilterDefaultView, - setIssueViewToKanban, - setIssueViewToList, - } as const; -}; - -export default useIssueView; diff --git a/apps/app/hooks/use-issues-view.tsx b/apps/app/hooks/use-issues-view.tsx new file mode 100644 index 000000000..17fd2f833 --- /dev/null +++ b/apps/app/hooks/use-issues-view.tsx @@ -0,0 +1,121 @@ +import { useContext, useMemo } from "react"; + +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// contexts +import { issueViewContext } from "contexts/issue-view.context"; +// services +import issuesService from "services/issues.service"; +import cyclesService from "services/cycles.service"; +import modulesService from "services/modules.service"; +// fetch-keys +import { + CYCLE_ISSUES_WITH_PARAMS, + MODULE_ISSUES_WITH_PARAMS, + PROJECT_ISSUES_LIST_WITH_PARAMS, +} from "constants/fetch-keys"; + +// types +import type { IIssue } from "types"; + +const useIssuesView = () => { + const { + issueView, + groupByProperty, + setGroupByProperty, + orderBy, + setOrderBy, + filters, + setFilters, + resetFilterToDefault, + setNewFilterDefaultView, + setIssueViewToKanban, + setIssueViewToList, + } = useContext(issueViewContext); + + const router = useRouter(); + const { workspaceSlug, projectId, cycleId, moduleId } = router.query; + + const params: any = { + order_by: orderBy, + group_by: groupByProperty, + assignees: filters.assignees ? filters.assignees.join(",") : undefined, + type: filters.type ? filters.type : undefined, + labels: filters.labels ? filters.labels.join(",") : undefined, + issue__assignees__id: filters.issue__assignees__id + ? filters.issue__assignees__id.join(",") + : undefined, + issue__labels__id: filters.issue__labels__id ? filters.issue__labels__id.join(",") : undefined, + }; + + const { data: projectIssues } = useSWR( + workspaceSlug && projectId && params + ? PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string) + : null, + workspaceSlug && projectId && params + ? () => + issuesService.getIssuesWithParams(workspaceSlug as string, projectId as string, params) + : null + ); + + const { data: cycleIssues } = useSWR( + workspaceSlug && projectId && cycleId && params + ? CYCLE_ISSUES_WITH_PARAMS(cycleId as string) + : null, + workspaceSlug && projectId && cycleId && params + ? () => + cyclesService.getCycleIssuesWithParams( + workspaceSlug as string, + projectId as string, + cycleId as string, + params + ) + : null + ); + + const { data: moduleIssues } = useSWR( + workspaceSlug && projectId && moduleId && params + ? MODULE_ISSUES_WITH_PARAMS(moduleId as string) + : null, + workspaceSlug && projectId && moduleId && params + ? () => + modulesService.getModuleIssuesWithParams( + workspaceSlug as string, + projectId as string, + moduleId as string, + params + ) + : null + ); + + const groupedByIssues: + | { + [key: string]: IIssue[]; + } + | undefined = useMemo(() => { + const issuesToGroup = cycleIssues ?? moduleIssues ?? projectIssues; + + if (Array.isArray(issuesToGroup)) return { allIssues: issuesToGroup }; + else return issuesToGroup; + }, [projectIssues, cycleIssues, moduleIssues]); + + return { + groupedByIssues, + issueView, + groupByProperty, + setGroupByProperty, + orderBy, + setOrderBy, + filters, + setFilters, + params, + resetFilterToDefault, + setNewFilterDefaultView, + setIssueViewToKanban, + setIssueViewToList, + } as const; +}; + +export default useIssuesView; diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx index 90d04bf35..5e2a94815 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/router"; import useSWR, { mutate } from "swr"; import { GetServerSidePropsContext } from "next"; // icons -import { ArrowLeftIcon, ListBulletIcon, PlusIcon } from "@heroicons/react/24/outline"; +import { ArrowLeftIcon } from "@heroicons/react/24/outline"; import { CyclesIcon } from "components/icons"; // lib import { requiredAdmin, requiredAuth } from "lib/auth"; @@ -21,21 +21,15 @@ import issuesServices from "services/issues.service"; import cycleServices from "services/cycles.service"; import projectService from "services/project.service"; // ui -import { CustomMenu, EmptySpace, EmptySpaceItem, Spinner } from "components/ui"; +import { CustomMenu } from "components/ui"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; // helpers import { truncateText } from "helpers/string.helper"; import { getDateRangeStatus } from "helpers/date-time.helper"; // types -import { CycleIssueResponse, UserAuth } from "types"; +import { UserAuth } from "types"; // fetch-keys -import { - CYCLE_ISSUES, - CYCLE_LIST, - PROJECT_ISSUES_LIST, - PROJECT_DETAILS, - CYCLE_DETAILS, -} from "constants/fetch-keys"; +import { CYCLE_ISSUES, CYCLE_LIST, PROJECT_DETAILS, CYCLE_DETAILS } from "constants/fetch-keys"; const SingleCycle: React.FC<UserAuth> = (props) => { const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false); @@ -51,15 +45,6 @@ const SingleCycle: React.FC<UserAuth> = (props) => { : null ); - const { data: issues } = useSWR( - workspaceSlug && projectId - ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) - : null, - workspaceSlug && projectId - ? () => issuesServices.getIssues(workspaceSlug as string, projectId as string) - : null - ); - const { data: cycles } = useSWR( workspaceSlug && projectId ? CYCLE_LIST(projectId as string) : null, workspaceSlug && projectId @@ -84,7 +69,7 @@ const SingleCycle: React.FC<UserAuth> = (props) => { ? getDateRangeStatus(cycleDetails?.start_date, cycleDetails?.end_date) : ""; - const { data: cycleIssues } = useSWR<CycleIssueResponse[]>( + const { data: issues } = useSWR( workspaceSlug && projectId && cycleId ? CYCLE_ISSUES(cycleId as string) : null, workspaceSlug && projectId && cycleId ? () => @@ -96,13 +81,6 @@ const SingleCycle: React.FC<UserAuth> = (props) => { : null ); - const cycleIssuesArray = cycleIssues?.map((issue) => ({ - ...issue.issue_detail, - sub_issues_count: issue.sub_issues_count, - bridge: issue.id, - cycle: cycleId as string, - })); - const openIssuesListModal = () => { setCycleIssuesListModal(true); }; @@ -164,7 +142,7 @@ const SingleCycle: React.FC<UserAuth> = (props) => { <div className={`flex items-center gap-2 ${cycleSidebar ? "mr-[24rem]" : ""} duration-300`} > - <IssuesFilterView issues={cycleIssuesArray ?? []} /> + <IssuesFilterView /> <button type="button" className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-100 ${ @@ -177,59 +155,10 @@ const SingleCycle: React.FC<UserAuth> = (props) => { </div> } > - {cycleIssuesArray ? ( - cycleIssuesArray.length > 0 ? ( - <div className={`h-full ${cycleSidebar ? "mr-[24rem]" : ""} duration-300`}> - <IssuesView - type="cycle" - issues={cycleIssuesArray ?? []} - userAuth={props} - openIssuesListModal={openIssuesListModal} - /> - </div> - ) : ( - <div - className={`flex h-full flex-col items-center justify-center px-4 ${ - cycleSidebar ? "mr-[24rem]" : "" - } duration-300`} - > - <EmptySpace - title="You don't have any issue yet." - description="A cycle is a fixed time period where a team commits to a set number of issues from their backlog. Cycles are usually one, two, or four weeks long." - Icon={CyclesIcon} - > - <EmptySpaceItem - title="Create a new issue" - description="Click to create a new issue inside the cycle." - Icon={PlusIcon} - action={() => { - const e = new KeyboardEvent("keydown", { - key: "c", - }); - document.dispatchEvent(e); - }} - /> - <EmptySpaceItem - title="Add an existing issue" - description="Open list" - Icon={ListBulletIcon} - action={openIssuesListModal} - /> - </EmptySpace> - </div> - ) - ) : ( - <div className="flex h-full w-full items-center justify-center"> - <Spinner /> - </div> - )} - <CycleDetailsSidebar - cycleStatus={cycleStatus} - issues={cycleIssuesArray ?? []} - cycle={cycleDetails} - isOpen={cycleSidebar} - cycleIssues={cycleIssues ?? []} - /> + <div className={`h-full ${cycleSidebar ? "mr-[24rem]" : ""} duration-300`}> + <IssuesView type="cycle" userAuth={props} openIssuesListModal={openIssuesListModal} /> + </div> + <CycleDetailsSidebar cycleStatus={cycleStatus} cycle={cycleDetails} isOpen={cycleSidebar} /> </AppLayout> </IssueViewContextProvider> ); diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/index.tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/index.tsx index 74ec6041c..952f1d488 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/index.tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/index.tsx @@ -5,7 +5,6 @@ import useSWR from "swr"; // lib import { requiredAdmin, requiredAuth } from "lib/auth"; // services -import issuesServices from "services/issues.service"; import projectService from "services/project.service"; // layouts import AppLayout from "layouts/app-layout"; @@ -14,31 +13,20 @@ import { IssueViewContextProvider } from "contexts/issue-view.context"; // components import { IssuesFilterView, IssuesView } from "components/core"; // ui -import { Spinner, EmptySpace, EmptySpaceItem, HeaderButton, EmptyState } from "components/ui"; +import { HeaderButton } from "components/ui"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; // icons -import { RectangleStackIcon, PlusIcon } from "@heroicons/react/24/outline"; +import { PlusIcon } from "@heroicons/react/24/outline"; // types import type { UserAuth } from "types"; import type { GetServerSidePropsContext, NextPage } from "next"; // fetch-keys -import { PROJECT_DETAILS, PROJECT_ISSUES_LIST } from "constants/fetch-keys"; -// image -import emptyIssue from "public/empty-state/empty-issue.svg"; +import { PROJECT_DETAILS } from "constants/fetch-keys"; const ProjectIssues: NextPage<UserAuth> = (props) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; - const { data: projectIssues } = useSWR( - workspaceSlug && projectId - ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) - : null, - workspaceSlug && projectId - ? () => issuesServices.getIssues(workspaceSlug as string, projectId as string) - : null - ); - const { data: projectDetails } = useSWR( workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null, workspaceSlug && projectId @@ -57,7 +45,7 @@ const ProjectIssues: NextPage<UserAuth> = (props) => { } right={ <div className="flex items-center gap-2"> - <IssuesFilterView issues={projectIssues?.filter((p) => p.parent === null) ?? []} /> + <IssuesFilterView /> <HeaderButton Icon={PlusIcon} label="Add Issue" @@ -71,26 +59,7 @@ const ProjectIssues: NextPage<UserAuth> = (props) => { </div> } > - {projectIssues ? ( - projectIssues.length > 0 ? ( - <IssuesView - issues={projectIssues?.filter((p) => p.parent === null) ?? []} - userAuth={props} - /> - ) : ( - <EmptyState - type="issue" - title="Create New Issue" - description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done. - Create a new issue" - imgURL={emptyIssue} - /> - ) - ) : ( - <div className="flex h-full w-full items-center justify-center"> - <Spinner /> - </div> - )} + <IssuesView userAuth={props} /> </AppLayout> </IssueViewContextProvider> ); diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx index 7bfe0052e..7b0d0ae89 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx @@ -30,7 +30,7 @@ import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; // helpers import { truncateText } from "helpers/string.helper"; // types -import { IModule, ModuleIssueResponse, UserAuth } from "types"; +import { IModule, UserAuth } from "types"; // fetch-keys import { @@ -63,7 +63,7 @@ const SingleModule: React.FC<UserAuth> = (props) => { : null ); - const { data: moduleIssues } = useSWR<ModuleIssueResponse[]>( + const { data: moduleIssues } = useSWR( workspaceSlug && projectId && moduleId ? MODULE_ISSUES(moduleId as string) : null, workspaceSlug && projectId && moduleId ? () => @@ -87,13 +87,6 @@ const SingleModule: React.FC<UserAuth> = (props) => { : null ); - const moduleIssuesArray = moduleIssues?.map((issue) => ({ - ...issue.issue_detail, - sub_issues_count: issue.sub_issues_count, - bridge: issue.id, - module: moduleId as string, - })); - const handleAddIssuesToModule = async (data: { issues: string[] }) => { if (!workspaceSlug || !projectId) return; @@ -153,7 +146,7 @@ const SingleModule: React.FC<UserAuth> = (props) => { <div className={`flex items-center gap-2 ${moduleSidebar ? "mr-[24rem]" : ""} duration-300`} > - <IssuesFilterView issues={moduleIssuesArray ?? []} /> + <IssuesFilterView /> <button type="button" className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-100 ${ @@ -166,12 +159,11 @@ const SingleModule: React.FC<UserAuth> = (props) => { </div> } > - {moduleIssuesArray ? ( - moduleIssuesArray.length > 0 ? ( + {moduleIssues ? ( + moduleIssues.length > 0 ? ( <div className={`h-full ${moduleSidebar ? "mr-[24rem]" : ""} duration-300`}> <IssuesView type="module" - issues={moduleIssuesArray ?? []} userAuth={props} openIssuesListModal={openIssuesListModal} /> @@ -213,7 +205,7 @@ const SingleModule: React.FC<UserAuth> = (props) => { </div> )} <ModuleDetailsSidebar - issues={moduleIssuesArray ?? []} + issues={moduleIssues ?? []} module={moduleDetails} isOpen={moduleSidebar} moduleIssues={moduleIssues} diff --git a/apps/app/services/cycles.service.ts b/apps/app/services/cycles.service.ts index 5c13574a2..fd938a6fb 100644 --- a/apps/app/services/cycles.service.ts +++ b/apps/app/services/cycles.service.ts @@ -1,7 +1,15 @@ // services import APIService from "services/api.service"; // types -import type { CompletedCyclesResponse, CurrentAndUpcomingCyclesResponse, DraftCyclesResponse, ICycle } from "types"; +import type { + CycleIssueResponse, + CompletedCyclesResponse, + CurrentAndUpcomingCyclesResponse, + DraftCyclesResponse, + ICycle, + IIssue, + IIssueViewOptions, +} from "types"; const { NEXT_PUBLIC_API_BASE_URL } = process.env; @@ -38,7 +46,11 @@ class ProjectCycleServices extends APIService { }); } - async getCycleIssues(workspaceSlug: string, projectId: string, cycleId: string): Promise<any> { + async getCycleIssues( + workspaceSlug: string, + projectId: string, + cycleId: string + ): Promise<IIssue[]> { return this.get( `/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/cycle-issues/` ) @@ -48,6 +60,22 @@ class ProjectCycleServices extends APIService { }); } + async getCycleIssuesWithParams( + workspaceSlug: string, + projectId: string, + cycleId: string, + queries?: IIssueViewOptions + ): Promise<IIssue[] | { [key: string]: IIssue[] }> { + return this.get( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/cycle-issues/`, + { params: queries } + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async updateCycle( workspaceSlug: string, projectId: string, @@ -88,18 +116,28 @@ class ProjectCycleServices extends APIService { }); } - async cycleDateCheck(workspaceSlug: string, projectId: string, data: { - start_date: string, - end_date: string - }): Promise<any> { - return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/date-check/`, data) + async cycleDateCheck( + workspaceSlug: string, + projectId: string, + data: { + start_date: string; + end_date: string; + } + ): Promise<any> { + return this.post( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/date-check/`, + data + ) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; }); } - async getCurrentAndUpcomingCycles(workspaceSlug: string, projectId: string): Promise<CurrentAndUpcomingCyclesResponse> { + async getCurrentAndUpcomingCycles( + workspaceSlug: string, + projectId: string + ): Promise<CurrentAndUpcomingCyclesResponse> { return this.get( `/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/current-upcoming-cycles/` ) @@ -110,16 +148,17 @@ class ProjectCycleServices extends APIService { } async getDraftCycles(workspaceSlug: string, projectId: string): Promise<DraftCyclesResponse> { - return this.get( - `/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/draft-cycles/` - ) + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/draft-cycles/`) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; }); } - async getCompletedCycles(workspaceSlug: string, projectId: string): Promise<CompletedCyclesResponse> { + async getCompletedCycles( + workspaceSlug: string, + projectId: string + ): Promise<CompletedCyclesResponse> { return this.get( `/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/completed-cycles/` ) @@ -136,21 +175,29 @@ class ProjectCycleServices extends APIService { cycle: string; } ): Promise<any> { - return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-favorite-cycles/`, data) + return this.post( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/user-favorite-cycles/`, + data + ) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; }); } - async removeCycleFromFavorites(workspaceSlug: string, projectId: string, cycleId: string): Promise<any> { - return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-favorite-cycles/${cycleId}/`) + async removeCycleFromFavorites( + workspaceSlug: string, + projectId: string, + cycleId: string + ): Promise<any> { + return this.delete( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/user-favorite-cycles/${cycleId}/` + ) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; }); } - } export default new ProjectCycleServices(); diff --git a/apps/app/services/issues.service.ts b/apps/app/services/issues.service.ts index 036cf36db..cb362dc05 100644 --- a/apps/app/services/issues.service.ts +++ b/apps/app/services/issues.service.ts @@ -1,7 +1,7 @@ // services import APIService from "services/api.service"; // type -import type { IIssue, IIssueActivity, IIssueComment } from "types"; +import type { IIssue, IIssueActivity, IIssueComment, IIssueViewOptions } from "types"; const { NEXT_PUBLIC_API_BASE_URL } = process.env; @@ -26,6 +26,20 @@ class ProjectIssuesServices extends APIService { }); } + async getIssuesWithParams( + workspaceSlug: string, + projectId: string, + queries?: any + ): Promise<IIssue[] | { [key: string]: IIssue[] }> { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/`, { + params: queries, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async retrieve(workspaceSlug: string, projectId: string, issueId: string): Promise<any> { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/`) .then((response) => response?.data) diff --git a/apps/app/services/modules.service.ts b/apps/app/services/modules.service.ts index b874a7dc8..e1cde9cc9 100644 --- a/apps/app/services/modules.service.ts +++ b/apps/app/services/modules.service.ts @@ -1,7 +1,7 @@ // services import APIService from "services/api.service"; // types -import type { IModule } from "types"; +import type { IIssueViewOptions, IModule, ModuleIssueResponse, IIssue } from "types"; const { NEXT_PUBLIC_API_BASE_URL } = process.env; @@ -76,7 +76,11 @@ class ProjectIssuesServices extends APIService { }); } - async getModuleIssues(workspaceSlug: string, projectId: string, moduleId: string): Promise<any> { + async getModuleIssues( + workspaceSlug: string, + projectId: string, + moduleId: string + ): Promise<IIssue[]> { return this.get( `/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-issues/` ) @@ -86,6 +90,27 @@ class ProjectIssuesServices extends APIService { }); } + async getModuleIssuesWithParams( + workspaceSlug: string, + projectId: string, + moduleId: string, + queries?: IIssueViewOptions + ): Promise< + | IIssue[] + | { + [key: string]: IIssue[]; + } + > { + return this.get( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-issues/`, + { params: queries } + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async addIssuesToModule( workspaceSlug: string, projectId: string, @@ -159,15 +184,24 @@ class ProjectIssuesServices extends APIService { module: string; } ): Promise<any> { - return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-favorite-modules/`, data) + return this.post( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/user-favorite-modules/`, + data + ) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; }); } - async removeModuleFromFavorites(workspaceSlug: string, projectId: string, moduleId: string): Promise<any> { - return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-favorite-modules/${moduleId}/`) + async removeModuleFromFavorites( + workspaceSlug: string, + projectId: string, + moduleId: string + ): Promise<any> { + return this.delete( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/user-favorite-modules/${moduleId}/` + ) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; diff --git a/apps/app/types/issues.d.ts b/apps/app/types/issues.d.ts index 091f47200..42d3cc84f 100644 --- a/apps/app/types/issues.d.ts +++ b/apps/app/types/issues.d.ts @@ -67,7 +67,7 @@ export interface IIssue { blockers: any[]; blockers_list: string[]; blocks_list: string[]; - bridge: string; + bridge_id?: string | null; completed_at: Date; created_at: Date; created_by: string; @@ -206,3 +206,17 @@ export interface IIssueActivity { issue_comment: string | null; actor: string; } + +export interface IIssueFilterOptions { + type: "active" | "backlog" | null; + assignees: string[] | null; + labels: string[] | null; + issue__assignees__id: string[] | null; + issue__labels__id: string[] | null; +} + +export interface IIssueViewOptions { + group_by: "state" | "priority" | "labels" | null; + order_by: "created_at" | "updated_at" | "priority" | "sort_order"; + filters: IIssueFilterOptions; +} diff --git a/apps/app/types/projects.d.ts b/apps/app/types/projects.d.ts index a25f26a26..917f828d1 100644 --- a/apps/app/types/projects.d.ts +++ b/apps/app/types/projects.d.ts @@ -1,4 +1,4 @@ -import type { IUserLite, IWorkspace } from "./"; +import type { IIssueFilterOptions, IUserLite, IWorkspace } from "./"; export interface IProject { cover_image: string | null; @@ -34,11 +34,10 @@ export interface IFavoriteProject { } type ProjectViewTheme = { - collapsed: boolean; - issueView: "list" | "kanban" | null; - groupByProperty: NestedKeyOf<IIssue> | null; - filterIssue: "activeIssue" | "backlogIssue" | null; - orderBy: NestedKeyOf<IIssue> | null; + issueView: "list" | "kanban"; + groupByProperty: "state" | "priority" | "labels" | null; + orderBy: "created_at" | "updated_at" | "priority" | "sort_order"; + filters: IIssueFilterOptions; }; export interface IProjectMember {