Merge branch 'develop' of github.com:makeplane/plane into dev/migration_priority_drafts_relation_props

This commit is contained in:
NarayanBavisetti 2023-09-13 19:51:56 +05:30
commit f5e7abb142
42 changed files with 1234 additions and 321 deletions

View File

@ -517,6 +517,7 @@ class CycleIssueViewSet(BaseViewSet):
try: try:
order_by = request.GET.get("order_by", "created_at") order_by = request.GET.get("order_by", "created_at")
group_by = request.GET.get("group_by", False) group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
issues = ( issues = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
@ -555,9 +556,15 @@ class CycleIssueViewSet(BaseViewSet):
issues_data = IssueStateSerializer(issues, many=True).data issues_data = IssueStateSerializer(issues, many=True).data
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by: if group_by:
return Response( return Response(
group_results(issues_data, group_by), group_results(issues_data, group_by, sub_group_by),
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )

View File

@ -453,9 +453,16 @@ class UserWorkSpaceIssues(BaseAPIView):
## Grouping the results ## Grouping the results
group_by = request.GET.get("group_by", False) group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by: if group_by:
return Response( return Response(
group_results(issues, group_by), status=status.HTTP_200_OK group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK
) )
return Response(issues, status=status.HTTP_200_OK) return Response(issues, status=status.HTTP_200_OK)

View File

@ -308,6 +308,7 @@ class ModuleIssueViewSet(BaseViewSet):
try: try:
order_by = request.GET.get("order_by", "created_at") order_by = request.GET.get("order_by", "created_at")
group_by = request.GET.get("group_by", False) group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
issues = ( issues = (
Issue.issue_objects.filter(issue_module__module_id=module_id) Issue.issue_objects.filter(issue_module__module_id=module_id)
@ -346,9 +347,15 @@ class ModuleIssueViewSet(BaseViewSet):
issues_data = IssueStateSerializer(issues, many=True).data issues_data = IssueStateSerializer(issues, many=True).data
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by: if group_by:
return Response( return Response(
group_results(issues_data, group_by), group_results(issues_data, group_by, sub_group_by),
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )

View File

@ -13,7 +13,7 @@ import useToast from "hooks/use-toast";
// components // components
import { EmailPasswordForm, GithubLoginButton, GoogleLoginButton, EmailCodeForm } from "components/accounts"; import { EmailPasswordForm, GithubLoginButton, GoogleLoginButton, EmailCodeForm } from "components/accounts";
// images // images
const imagePrefix = process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX ? "/spaces/" : ""; const imagePrefix = process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX ? "/spaces" : "";
export const SignInView = observer(() => { export const SignInView = observer(() => {
const { user: userStore } = useMobxStore(); const { user: userStore } = useMobxStore();

View File

@ -5,7 +5,7 @@ import { useMobxStore } from "lib/mobx/store-provider";
// components // components
import { OnBoardingForm } from "components/accounts/onboarding-form"; import { OnBoardingForm } from "components/accounts/onboarding-form";
const imagePrefix = process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX ? "/spaces/" : ""; const imagePrefix = process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX ? "/spaces" : "";
const OnBoardingPage = () => { const OnBoardingPage = () => {
const { user: userStore } = useMobxStore(); const { user: userStore } = useMobxStore();

View File

@ -1,4 +1,4 @@
import React, { useCallback } from "react"; import React, { useCallback, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -77,6 +77,8 @@ export const AllViews: React.FC<Props> = ({
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const [myIssueProjectId, setMyIssueProjectId] = useState<string | null>(null);
const { user } = useUser(); const { user } = useUser();
const { memberRole } = useProjectMyMembership(); const { memberRole } = useProjectMyMembership();
@ -90,6 +92,10 @@ export const AllViews: React.FC<Props> = ({
); );
const states = getStatesList(stateGroups); const states = getStatesList(stateGroups);
const handleMyIssueOpen = (issue: IIssue) => {
setMyIssueProjectId(issue.project);
};
const handleTrashBox = useCallback( const handleTrashBox = useCallback(
(isDragging: boolean) => { (isDragging: boolean) => {
if (isDragging && !trashBox) setTrashBox(true); if (isDragging && !trashBox) setTrashBox(true);
@ -128,6 +134,8 @@ export const AllViews: React.FC<Props> = ({
handleIssueAction={handleIssueAction} handleIssueAction={handleIssueAction}
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null} openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
removeIssue={removeIssue} removeIssue={removeIssue}
myIssueProjectId={myIssueProjectId}
handleMyIssueOpen={handleMyIssueOpen}
disableUserActions={disableUserActions} disableUserActions={disableUserActions}
disableAddIssueOption={disableAddIssueOption} disableAddIssueOption={disableAddIssueOption}
user={user} user={user}
@ -143,6 +151,8 @@ export const AllViews: React.FC<Props> = ({
handleIssueAction={handleIssueAction} handleIssueAction={handleIssueAction}
handleTrashBox={handleTrashBox} handleTrashBox={handleTrashBox}
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null} openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
myIssueProjectId={myIssueProjectId}
handleMyIssueOpen={handleMyIssueOpen}
removeIssue={removeIssue} removeIssue={removeIssue}
states={states} states={states}
user={user} user={user}
@ -166,7 +176,9 @@ export const AllViews: React.FC<Props> = ({
userAuth={memberRole} userAuth={memberRole}
/> />
) : ( ) : (
displayFilters?.layout === "gantt_chart" && <GanttChartView /> displayFilters?.layout === "gantt_chart" && (
<GanttChartView disableUserActions={disableUserActions} />
)
)} )}
</> </>
) : router.pathname.includes("archived-issues") ? ( ) : router.pathname.includes("archived-issues") ? (

View File

@ -1,5 +1,12 @@
import { useRouter } from "next/router";
//hook
import useMyIssues from "hooks/my-issues/use-my-issues";
import useIssuesView from "hooks/use-issues-view";
import useProfileIssues from "hooks/use-profile-issues";
// components // components
import { SingleBoard } from "components/core/views/board-view/single-board"; import { SingleBoard } from "components/core/views/board-view/single-board";
import { IssuePeekOverview } from "components/issues";
// icons // icons
import { StateGroupIcon } from "components/icons"; import { StateGroupIcon } from "components/icons";
// helpers // helpers
@ -16,6 +23,8 @@ type Props = {
handleTrashBox: (isDragging: boolean) => void; handleTrashBox: (isDragging: boolean) => void;
openIssuesListModal?: (() => void) | null; openIssuesListModal?: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null; removeIssue: ((bridgeId: string, issueId: string) => void) | null;
myIssueProjectId?: string | null;
handleMyIssueOpen?: (issue: IIssue) => void;
states: IState[] | undefined; states: IState[] | undefined;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
userAuth: UserAuth; userAuth: UserAuth;
@ -30,16 +39,40 @@ export const AllBoards: React.FC<Props> = ({
handleIssueAction, handleIssueAction,
handleTrashBox, handleTrashBox,
openIssuesListModal, openIssuesListModal,
myIssueProjectId,
handleMyIssueOpen,
removeIssue, removeIssue,
states, states,
user, user,
userAuth, userAuth,
viewProps, viewProps,
}) => { }) => {
const router = useRouter();
const { workspaceSlug, projectId, userId } = router.query;
const isProfileIssue =
router.pathname.includes("assigned") ||
router.pathname.includes("created") ||
router.pathname.includes("subscribed");
const isMyIssue = router.pathname.includes("my-issues");
const { mutateIssues } = useIssuesView();
const { mutateMyIssues } = useMyIssues(workspaceSlug?.toString());
const { mutateProfileIssues } = useProfileIssues(workspaceSlug?.toString(), userId?.toString());
const { displayFilters, groupedIssues } = viewProps; const { displayFilters, groupedIssues } = viewProps;
return ( return (
<> <>
<IssuePeekOverview
handleMutation={() =>
isMyIssue ? mutateMyIssues() : isProfileIssue ? mutateProfileIssues() : mutateIssues()
}
projectId={myIssueProjectId ? myIssueProjectId : projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
readOnly={disableUserActions}
/>
{groupedIssues ? ( {groupedIssues ? (
<div className="horizontal-scroll-enable flex h-full gap-x-4 p-8"> <div className="horizontal-scroll-enable flex h-full gap-x-4 p-8">
{Object.keys(groupedIssues).map((singleGroup, index) => { {Object.keys(groupedIssues).map((singleGroup, index) => {
@ -63,6 +96,7 @@ export const AllBoards: React.FC<Props> = ({
handleIssueAction={handleIssueAction} handleIssueAction={handleIssueAction}
handleTrashBox={handleTrashBox} handleTrashBox={handleTrashBox}
openIssuesListModal={openIssuesListModal ?? null} openIssuesListModal={openIssuesListModal ?? null}
handleMyIssueOpen={handleMyIssueOpen}
removeIssue={removeIssue} removeIssue={removeIssue}
user={user} user={user}
userAuth={userAuth} userAuth={userAuth}

View File

@ -26,6 +26,7 @@ type Props = {
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleTrashBox: (isDragging: boolean) => void; handleTrashBox: (isDragging: boolean) => void;
openIssuesListModal?: (() => void) | null; openIssuesListModal?: (() => void) | null;
handleMyIssueOpen?: (issue: IIssue) => void;
removeIssue: ((bridgeId: string, issueId: string) => void) | null; removeIssue: ((bridgeId: string, issueId: string) => void) | null;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
userAuth: UserAuth; userAuth: UserAuth;
@ -42,6 +43,7 @@ export const SingleBoard: React.FC<Props> = ({
handleIssueAction, handleIssueAction,
handleTrashBox, handleTrashBox,
openIssuesListModal, openIssuesListModal,
handleMyIssueOpen,
removeIssue, removeIssue,
user, user,
userAuth, userAuth,
@ -50,7 +52,7 @@ export const SingleBoard: React.FC<Props> = ({
// collapse/expand // collapse/expand
const [isCollapsed, setIsCollapsed] = useState(true); const [isCollapsed, setIsCollapsed] = useState(true);
const { displayFilters, groupedIssues, properties } = viewProps; const { displayFilters, groupedIssues } = viewProps;
const router = useRouter(); const router = useRouter();
const { cycleId, moduleId } = router.query; const { cycleId, moduleId } = router.query;
@ -135,6 +137,7 @@ export const SingleBoard: React.FC<Props> = ({
makeIssueCopy={() => handleIssueAction(issue, "copy")} makeIssueCopy={() => handleIssueAction(issue, "copy")}
handleDeleteIssue={() => handleIssueAction(issue, "delete")} handleDeleteIssue={() => handleIssueAction(issue, "delete")}
handleTrashBox={handleTrashBox} handleTrashBox={handleTrashBox}
handleMyIssueOpen={handleMyIssueOpen}
removeIssue={() => { removeIssue={() => {
if (removeIssue && issue.bridge_id) if (removeIssue && issue.bridge_id)
removeIssue(issue.bridge_id, issue.id); removeIssue(issue.bridge_id, issue.id);

View File

@ -1,6 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { mutate } from "swr"; import { mutate } from "swr";
@ -58,6 +57,7 @@ type Props = {
index: number; index: number;
editIssue: () => void; editIssue: () => void;
makeIssueCopy: () => void; makeIssueCopy: () => void;
handleMyIssueOpen?: (issue: IIssue) => void;
removeIssue?: (() => void) | null; removeIssue?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void; handleDeleteIssue: (issue: IIssue) => void;
handleTrashBox: (isDragging: boolean) => void; handleTrashBox: (isDragging: boolean) => void;
@ -75,6 +75,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
index, index,
editIssue, editIssue,
makeIssueCopy, makeIssueCopy,
handleMyIssueOpen,
removeIssue, removeIssue,
groupTitle, groupTitle,
handleDeleteIssue, handleDeleteIssue,
@ -187,6 +188,17 @@ export const SingleBoardIssue: React.FC<Props> = ({
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false)); useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
const openPeekOverview = () => {
const { query } = router;
if (handleMyIssueOpen) handleMyIssueOpen(issue);
router.push({
pathname: router.pathname,
query: { ...query, peekIssue: issue.id },
});
};
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions; const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions;
return ( return (
@ -286,16 +298,22 @@ export const SingleBoardIssue: React.FC<Props> = ({
)} )}
</div> </div>
)} )}
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
<a className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{properties.key && ( {properties.key && (
<div className="text-xs font-medium text-custom-text-200"> <div className="text-xs font-medium text-custom-text-200">
{issue.project_detail.identifier}-{issue.sequence_id} {issue.project_detail.identifier}-{issue.sequence_id}
</div> </div>
)} )}
<h5 className="text-sm break-words line-clamp-2">{issue.name}</h5> <button
</a> type="button"
</Link> className="text-sm text-left break-words line-clamp-2"
onClick={openPeekOverview}
>
{issue.name}
</button>
</div>
<div <div
className={`flex items-center gap-2 text-xs ${ className={`flex items-center gap-2 text-xs ${
isDropdownActive ? "" : "overflow-x-scroll" isDropdownActive ? "" : "overflow-x-scroll"

View File

@ -12,6 +12,7 @@ import issuesService from "services/issues.service";
import useCalendarIssuesView from "hooks/use-calendar-issues-view"; import useCalendarIssuesView from "hooks/use-calendar-issues-view";
// components // components
import { SingleCalendarDate, CalendarHeader } from "components/core"; import { SingleCalendarDate, CalendarHeader } from "components/core";
import { IssuePeekOverview } from "components/issues";
// ui // ui
import { Spinner } from "components/ui"; import { Spinner } from "components/ui";
// helpers // helpers
@ -60,7 +61,8 @@ export const CalendarView: React.FC<Props> = ({
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const { calendarIssues, params, displayFilters, setDisplayFilters } = useCalendarIssuesView(); const { calendarIssues, mutateIssues, params, displayFilters, setDisplayFilters } =
useCalendarIssuesView();
const totalDate = eachDayOfInterval({ const totalDate = eachDayOfInterval({
start: calendarDates.startDate, start: calendarDates.startDate,
@ -170,75 +172,85 @@ export const CalendarView: React.FC<Props> = ({
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions; const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions;
return calendarIssues ? ( return (
<div className="h-full overflow-y-auto"> <>
<DragDropContext onDragEnd={onDragEnd}> <IssuePeekOverview
<div className="h-full rounded-lg p-8 text-custom-text-200"> handleMutation={() => mutateIssues()}
<CalendarHeader projectId={projectId?.toString() ?? ""}
isMonthlyView={isMonthlyView} workspaceSlug={workspaceSlug?.toString() ?? ""}
setIsMonthlyView={setIsMonthlyView} readOnly={disableUserActions}
showWeekEnds={showWeekEnds} />
setShowWeekEnds={setShowWeekEnds} {calendarIssues ? (
currentDate={currentDate} <div className="h-full overflow-y-auto">
setCurrentDate={setCurrentDate} <DragDropContext onDragEnd={onDragEnd}>
changeDateRange={changeDateRange} <div className="h-full rounded-lg p-8 text-custom-text-200">
/> <CalendarHeader
isMonthlyView={isMonthlyView}
setIsMonthlyView={setIsMonthlyView}
showWeekEnds={showWeekEnds}
setShowWeekEnds={setShowWeekEnds}
currentDate={currentDate}
setCurrentDate={setCurrentDate}
changeDateRange={changeDateRange}
/>
<div
className={`grid auto-rows-[minmax(36px,1fr)] rounded-lg ${
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
}`}
>
{weeks.map((date, index) => (
<div <div
key={index} className={`grid auto-rows-[minmax(36px,1fr)] rounded-lg ${
className={`flex items-center justify-start gap-2 border-custom-border-200 bg-custom-background-90 p-1.5 text-base font-medium text-custom-text-200 ${ showWeekEnds ? "grid-cols-7" : "grid-cols-5"
!isMonthlyView
? showWeekEnds
? (index + 1) % 7 === 0
? ""
: "border-r"
: (index + 1) % 5 === 0
? ""
: "border-r"
: ""
}`} }`}
> >
<span> {weeks.map((date, index) => (
{isMonthlyView <div
? formatDate(date, "eee").substring(0, 3) key={index}
: formatDate(date, "eee")} className={`flex items-center justify-start gap-2 border-custom-border-200 bg-custom-background-90 p-1.5 text-base font-medium text-custom-text-200 ${
</span> !isMonthlyView
{!isMonthlyView && <span>{formatDate(date, "d")}</span>} ? showWeekEnds
? (index + 1) % 7 === 0
? ""
: "border-r"
: (index + 1) % 5 === 0
? ""
: "border-r"
: ""
}`}
>
<span>
{isMonthlyView
? formatDate(date, "eee").substring(0, 3)
: formatDate(date, "eee")}
</span>
{!isMonthlyView && <span>{formatDate(date, "d")}</span>}
</div>
))}
</div> </div>
))}
</div>
<div <div
className={`grid h-full ${isMonthlyView ? "auto-rows-min" : ""} ${ className={`grid h-full ${isMonthlyView ? "auto-rows-min" : ""} ${
showWeekEnds ? "grid-cols-7" : "grid-cols-5" showWeekEnds ? "grid-cols-7" : "grid-cols-5"
} `} } `}
> >
{currentViewDaysData.map((date, index) => ( {currentViewDaysData.map((date, index) => (
<SingleCalendarDate <SingleCalendarDate
key={`${date}-${index}`} key={`${date}-${index}`}
index={index} index={index}
date={date} date={date}
handleIssueAction={handleIssueAction} handleIssueAction={handleIssueAction}
addIssueToDate={addIssueToDate} addIssueToDate={addIssueToDate}
isMonthlyView={isMonthlyView} isMonthlyView={isMonthlyView}
showWeekEnds={showWeekEnds} showWeekEnds={showWeekEnds}
user={user} user={user}
isNotAllowed={isNotAllowed} isNotAllowed={isNotAllowed}
/> />
))} ))}
</div> </div>
</div>
</DragDropContext>
</div> </div>
</DragDropContext> ) : (
</div> <div className="flex h-full w-full items-center justify-center">
) : ( <Spinner />
<div className="flex h-full w-full items-center justify-center"> </div>
<Spinner /> )}
</div> </>
); );
}; };

View File

@ -1,6 +1,5 @@
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { mutate } from "swr"; import { mutate } from "swr";
@ -158,6 +157,15 @@ export const SingleCalendarIssue: React.FC<Props> = ({
? Object.values(properties).some((value) => value === true) ? Object.values(properties).some((value) => value === true)
: false; : false;
const openPeekOverview = () => {
const { query } = router;
router.push({
pathname: router.pathname,
query: { ...query, peekIssue: issue.id },
});
};
return ( return (
<div <div
key={index} key={index}
@ -193,23 +201,27 @@ export const SingleCalendarIssue: React.FC<Props> = ({
</CustomMenu> </CustomMenu>
</div> </div>
)} )}
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
<a className="flex w-full cursor-pointer flex-col items-start justify-center gap-1.5"> <button
{properties.key && ( type="button"
<Tooltip className="flex w-full cursor-pointer flex-col items-start justify-center gap-1.5"
tooltipHeading="Issue ID" onClick={openPeekOverview}
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`} >
> {properties.key && (
<span className="flex-shrink-0 text-xs text-custom-text-200"> <Tooltip
{issue.project_detail?.identifier}-{issue.sequence_id} tooltipHeading="Issue ID"
</span> tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
</Tooltip> >
)} <span className="flex-shrink-0 text-xs text-custom-text-200">
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}> {issue.project_detail?.identifier}-{issue.sequence_id}
<span className="text-xs text-custom-text-100">{truncateText(issue.name, 25)}</span> </span>
</Tooltip> </Tooltip>
</a> )}
</Link> <Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
<span className="text-xs text-custom-text-100">{truncateText(issue.name, 25)}</span>
</Tooltip>
</button>
{displayProperties && ( {displayProperties && (
<div className="relative mt-1.5 w-full flex flex-wrap items-center gap-2 text-xs"> <div className="relative mt-1.5 w-full flex flex-wrap items-center gap-2 text-xs">
{properties.priority && ( {properties.priority && (

View File

@ -6,20 +6,24 @@ import { IssueGanttChartView } from "components/issues";
import { ModuleIssuesGanttChartView } from "components/modules"; import { ModuleIssuesGanttChartView } from "components/modules";
import { ViewIssuesGanttChartView } from "components/views"; import { ViewIssuesGanttChartView } from "components/views";
export const GanttChartView = () => { type Props = {
disableUserActions: boolean;
};
export const GanttChartView: React.FC<Props> = ({ disableUserActions }) => {
const router = useRouter(); const router = useRouter();
const { cycleId, moduleId, viewId } = router.query; const { cycleId, moduleId, viewId } = router.query;
return ( return (
<> <>
{cycleId ? ( {cycleId ? (
<CycleIssuesGanttChartView /> <CycleIssuesGanttChartView disableUserActions={disableUserActions} />
) : moduleId ? ( ) : moduleId ? (
<ModuleIssuesGanttChartView /> <ModuleIssuesGanttChartView disableUserActions={disableUserActions} />
) : viewId ? ( ) : viewId ? (
<ViewIssuesGanttChartView /> <ViewIssuesGanttChartView disableUserActions={disableUserActions} />
) : ( ) : (
<IssueGanttChartView /> <IssueGanttChartView disableUserActions={disableUserActions} />
)} )}
</> </>
); );

View File

@ -19,7 +19,7 @@ import useIssuesProperties from "hooks/use-issue-properties";
import useProjectMembers from "hooks/use-project-members"; import useProjectMembers from "hooks/use-project-members";
// components // components
import { FiltersList, AllViews } from "components/core"; import { FiltersList, AllViews } from "components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; import { CreateUpdateIssueModal, DeleteIssueModal, IssuePeekOverview } from "components/issues";
import { CreateUpdateViewModal } from "components/views"; import { CreateUpdateViewModal } from "components/views";
// ui // ui
import { PrimaryButton, SecondaryButton } from "components/ui"; import { PrimaryButton, SecondaryButton } from "components/ui";
@ -462,6 +462,7 @@ export const IssuesView: React.FC<Props> = ({
data={issueToDelete} data={issueToDelete}
user={user} user={user}
/> />
{areFiltersApplied && ( {areFiltersApplied && (
<> <>
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-0"> <div className="flex items-center justify-between gap-2 px-5 pt-3 pb-0">

View File

@ -1,5 +1,12 @@
import { useRouter } from "next/router";
// hooks
import useMyIssues from "hooks/my-issues/use-my-issues";
import useIssuesView from "hooks/use-issues-view";
import useProfileIssues from "hooks/use-profile-issues";
// components // components
import { SingleList } from "components/core/views/list-view/single-list"; import { SingleList } from "components/core/views/list-view/single-list";
import { IssuePeekOverview } from "components/issues";
// types // types
import { ICurrentUserResponse, IIssue, IIssueViewProps, IState, UserAuth } from "types"; import { ICurrentUserResponse, IIssue, IIssueViewProps, IState, UserAuth } from "types";
@ -9,6 +16,8 @@ type Props = {
addIssueToGroup: (groupTitle: string) => void; addIssueToGroup: (groupTitle: string) => void;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
openIssuesListModal?: (() => void) | null; openIssuesListModal?: (() => void) | null;
myIssueProjectId?: string | null;
handleMyIssueOpen?: (issue: IIssue) => void;
removeIssue: ((bridgeId: string, issueId: string) => void) | null; removeIssue: ((bridgeId: string, issueId: string) => void) | null;
disableUserActions: boolean; disableUserActions: boolean;
disableAddIssueOption?: boolean; disableAddIssueOption?: boolean;
@ -23,16 +32,39 @@ export const AllLists: React.FC<Props> = ({
disableUserActions, disableUserActions,
disableAddIssueOption = false, disableAddIssueOption = false,
openIssuesListModal, openIssuesListModal,
handleMyIssueOpen,
myIssueProjectId,
removeIssue, removeIssue,
states, states,
user, user,
userAuth, userAuth,
viewProps, viewProps,
}) => { }) => {
const router = useRouter();
const { workspaceSlug, projectId, userId } = router.query;
const isProfileIssue =
router.pathname.includes("assigned") ||
router.pathname.includes("created") ||
router.pathname.includes("subscribed");
const isMyIssue = router.pathname.includes("my-issues");
const { mutateIssues } = useIssuesView();
const { mutateMyIssues } = useMyIssues(workspaceSlug?.toString());
const { mutateProfileIssues } = useProfileIssues(workspaceSlug?.toString(), userId?.toString());
const { displayFilters, groupedIssues } = viewProps; const { displayFilters, groupedIssues } = viewProps;
return ( return (
<> <>
<IssuePeekOverview
handleMutation={() =>
isMyIssue ? mutateMyIssues() : isProfileIssue ? mutateProfileIssues() : mutateIssues()
}
projectId={myIssueProjectId ? myIssueProjectId : projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
readOnly={disableUserActions}
/>
{groupedIssues && ( {groupedIssues && (
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
{Object.keys(groupedIssues).map((singleGroup) => { {Object.keys(groupedIssues).map((singleGroup) => {
@ -51,6 +83,7 @@ export const AllLists: React.FC<Props> = ({
currentState={currentState} currentState={currentState}
addIssueToGroup={() => addIssueToGroup(singleGroup)} addIssueToGroup={() => addIssueToGroup(singleGroup)}
handleIssueAction={handleIssueAction} handleIssueAction={handleIssueAction}
handleMyIssueOpen={handleMyIssueOpen}
openIssuesListModal={openIssuesListModal} openIssuesListModal={openIssuesListModal}
removeIssue={removeIssue} removeIssue={removeIssue}
disableUserActions={disableUserActions} disableUserActions={disableUserActions}

View File

@ -61,6 +61,7 @@ type Props = {
makeIssueCopy: () => void; makeIssueCopy: () => void;
removeIssue?: (() => void) | null; removeIssue?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void; handleDeleteIssue: (issue: IIssue) => void;
handleMyIssueOpen?: (issue: IIssue) => void;
disableUserActions: boolean; disableUserActions: boolean;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
userAuth: UserAuth; userAuth: UserAuth;
@ -76,6 +77,7 @@ export const SingleListIssue: React.FC<Props> = ({
removeIssue, removeIssue,
groupTitle, groupTitle,
handleDeleteIssue, handleDeleteIssue,
handleMyIssueOpen,
disableUserActions, disableUserActions,
user, user,
userAuth, userAuth,
@ -178,6 +180,16 @@ export const SingleListIssue: React.FC<Props> = ({
? `/${workspaceSlug}/projects/${issue.project}/archived-issues/${issue.id}` ? `/${workspaceSlug}/projects/${issue.project}/archived-issues/${issue.id}`
: `/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`; : `/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`;
const openPeekOverview = (issue: IIssue) => {
const { query } = router;
if (handleMyIssueOpen) handleMyIssueOpen(issue);
router.push({
pathname: router.pathname,
query: { ...query, peekIssue: issue.id },
});
};
const isNotAllowed = const isNotAllowed =
userAuth.isGuest || userAuth.isViewer || disableUserActions || isArchivedIssues; userAuth.isGuest || userAuth.isViewer || disableUserActions || isArchivedIssues;
@ -220,23 +232,27 @@ export const SingleListIssue: React.FC<Props> = ({
}} }}
> >
<div className="flex-grow cursor-pointer min-w-[200px] whitespace-nowrap overflow-hidden overflow-ellipsis"> <div className="flex-grow cursor-pointer min-w-[200px] whitespace-nowrap overflow-hidden overflow-ellipsis">
<Link href={issuePath}> <div className="group relative flex items-center gap-2">
<a className="group relative flex items-center gap-2"> {properties.key && (
{properties.key && ( <Tooltip
<Tooltip tooltipHeading="Issue ID"
tooltipHeading="Issue ID" tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`} >
> <span className="flex-shrink-0 text-xs text-custom-text-200">
<span className="flex-shrink-0 text-xs text-custom-text-200"> {issue.project_detail?.identifier}-{issue.sequence_id}
{issue.project_detail?.identifier}-{issue.sequence_id} </span>
</span>
</Tooltip>
)}
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
<span className="truncate text-[0.825rem] text-custom-text-100">{issue.name}</span>
</Tooltip> </Tooltip>
</a> )}
</Link> <Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
<button
type="button"
className="truncate text-[0.825rem] text-custom-text-100"
onClick={() => openPeekOverview(issue)}
>
{issue.name}
</button>
</Tooltip>
</div>
</div> </div>
<div <div

View File

@ -41,6 +41,7 @@ type Props = {
addIssueToGroup: () => void; addIssueToGroup: () => void;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
openIssuesListModal?: (() => void) | null; openIssuesListModal?: (() => void) | null;
handleMyIssueOpen?: (issue: IIssue) => void;
removeIssue: ((bridgeId: string, issueId: string) => void) | null; removeIssue: ((bridgeId: string, issueId: string) => void) | null;
disableUserActions: boolean; disableUserActions: boolean;
disableAddIssueOption?: boolean; disableAddIssueOption?: boolean;
@ -55,6 +56,7 @@ export const SingleList: React.FC<Props> = ({
addIssueToGroup, addIssueToGroup,
handleIssueAction, handleIssueAction,
openIssuesListModal, openIssuesListModal,
handleMyIssueOpen,
removeIssue, removeIssue,
disableUserActions, disableUserActions,
disableAddIssueOption = false, disableAddIssueOption = false,
@ -251,6 +253,7 @@ export const SingleList: React.FC<Props> = ({
editIssue={() => handleIssueAction(issue, "edit")} editIssue={() => handleIssueAction(issue, "edit")}
makeIssueCopy={() => handleIssueAction(issue, "copy")} makeIssueCopy={() => handleIssueAction(issue, "copy")}
handleDeleteIssue={() => handleIssueAction(issue, "delete")} handleDeleteIssue={() => handleIssueAction(issue, "delete")}
handleMyIssueOpen={handleMyIssueOpen}
removeIssue={() => { removeIssue={() => {
if (removeIssue !== null && issue.bridge_id) if (removeIssue !== null && issue.bridge_id)
removeIssue(issue.bridge_id, issue.id); removeIssue(issue.bridge_id, issue.id);

View File

@ -8,11 +8,15 @@ import { updateGanttIssue } from "components/gantt-chart/hooks/block-update";
import useProjectDetails from "hooks/use-project-details"; import useProjectDetails from "hooks/use-project-details";
// components // components
import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart"; import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart";
import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues"; import { IssueGanttBlock, IssueGanttSidebarBlock, IssuePeekOverview } from "components/issues";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
export const CycleIssuesGanttChartView = () => { type Props = {
disableUserActions: boolean;
};
export const CycleIssuesGanttChartView: React.FC<Props> = ({ disableUserActions }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query; const { workspaceSlug, projectId, cycleId } = router.query;
@ -30,23 +34,31 @@ export const CycleIssuesGanttChartView = () => {
const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15; const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15;
return ( return (
<div className="w-full h-full"> <>
<GanttChartRoot <IssuePeekOverview
border={false} handleMutation={() => mutateGanttIssues()}
title="Issues" projectId={projectId?.toString() ?? ""}
loaderTitle="Issues" workspaceSlug={workspaceSlug?.toString() ?? ""}
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null} readOnly={disableUserActions}
blockUpdateHandler={(block, payload) =>
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
}
SidebarBlockRender={IssueGanttSidebarBlock}
BlockRender={IssueGanttBlock}
enableBlockLeftResize={isAllowed}
enableBlockRightResize={isAllowed}
enableBlockMove={isAllowed}
enableReorder={displayFilters.order_by === "sort_order" && isAllowed}
bottomSpacing
/> />
</div> <div className="w-full h-full">
<GanttChartRoot
border={false}
title="Issues"
loaderTitle="Issues"
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
blockUpdateHandler={(block, payload) =>
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
}
SidebarBlockRender={IssueGanttSidebarBlock}
BlockRender={IssueGanttBlock}
enableBlockLeftResize={isAllowed}
enableBlockRightResize={isAllowed}
enableBlockMove={isAllowed}
enableReorder={displayFilters.order_by === "sort_order" && isAllowed}
bottomSpacing
/>
</div>
</>
); );
}; };

View File

@ -83,3 +83,4 @@ export * from "./archive-icon";
export * from "./clock-icon"; export * from "./clock-icon";
export * from "./bell-icon"; export * from "./bell-icon";
export * from "./single-comment-icon"; export * from "./single-comment-icon";
export * from "./related-icon";

View File

@ -0,0 +1,41 @@
import React from "react";
import type { Props } from "./types";
export const RelatedIcon: React.FC<Props> = ({
width = "24",
height = "24",
color = "rgb(var(--color-text-200))",
className,
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20 13L20 6C20 5.46957 19.7893 4.96086 19.4142 4.58579C19.0391 4.21071 18.5304 4 18 4L4 4C3.46957 4 2.96086 4.21071 2.58579 4.58579C2.21071 4.96086 2 5.46957 2 6L2 20C2 20.5304 2.21071 21.0391 2.58579 21.4142C2.96086 21.7893 3.46957 22 4 22L11 22"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12.125 19.25L9 16.125L12.125 13"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M20 22V18.1818C20 17.6032 19.7366 17.0482 19.2678 16.639C18.7989 16.2299 18.163 16 17.5 16H10"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);

View File

@ -23,14 +23,7 @@ import {
import { CreateStateModal } from "components/states"; import { CreateStateModal } from "components/states";
import { CreateLabelModal } from "components/labels"; import { CreateLabelModal } from "components/labels";
// ui // ui
import { import { CustomMenu, Input, PrimaryButton, SecondaryButton, ToggleSwitch } from "components/ui";
CustomMenu,
Input,
Loader,
PrimaryButton,
SecondaryButton,
ToggleSwitch,
} from "components/ui";
import { TipTapEditor } from "components/tiptap"; import { TipTapEditor } from "components/tiptap";
// icons // icons
import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline";

View File

@ -11,13 +11,21 @@ import { IIssue } from "types";
export const IssueGanttBlock = ({ data }: { data: IIssue }) => { export const IssueGanttBlock = ({ data }: { data: IIssue }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query;
const openPeekOverview = () => {
const { query } = router;
router.push({
pathname: router.pathname,
query: { ...query, peekIssue: data.id },
});
};
return ( return (
<div <div
className="flex items-center relative h-full w-full rounded cursor-pointer" className="flex items-center relative h-full w-full rounded cursor-pointer"
style={{ backgroundColor: data?.state_detail?.color }} style={{ backgroundColor: data?.state_detail?.color }}
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/issues/${data?.id}`)} onClick={openPeekOverview}
> >
<div className="absolute top-0 left-0 h-full w-full bg-custom-background-100/50" /> <div className="absolute top-0 left-0 h-full w-full bg-custom-background-100/50" />
<Tooltip <Tooltip
@ -43,14 +51,22 @@ export const IssueGanttBlock = ({ data }: { data: IIssue }) => {
// rendering issues on gantt sidebar // rendering issues on gantt sidebar
export const IssueGanttSidebarBlock = ({ data }: { data: IIssue }) => { export const IssueGanttSidebarBlock = ({ data }: { data: IIssue }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query;
const duration = findTotalDaysInRange(data?.start_date ?? "", data?.target_date ?? "", true); const duration = findTotalDaysInRange(data?.start_date ?? "", data?.target_date ?? "", true);
const openPeekOverview = () => {
const { query } = router;
router.push({
pathname: router.pathname,
query: { ...query, peekIssue: data.id },
});
};
return ( return (
<div <div
className="relative w-full flex items-center gap-2 h-full cursor-pointer" className="relative w-full flex items-center gap-2 h-full cursor-pointer"
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/issues/${data?.id}`)} onClick={openPeekOverview}
> >
<StateGroupIcon stateGroup={data?.state_detail?.group} color={data?.state_detail?.color} /> <StateGroupIcon stateGroup={data?.state_detail?.group} color={data?.state_detail?.color} />
<div className="text-xs text-custom-text-300 flex-shrink-0"> <div className="text-xs text-custom-text-300 flex-shrink-0">

View File

@ -8,11 +8,15 @@ import { updateGanttIssue } from "components/gantt-chart/hooks/block-update";
import useProjectDetails from "hooks/use-project-details"; import useProjectDetails from "hooks/use-project-details";
// components // components
import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart"; import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart";
import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues"; import { IssueGanttBlock, IssueGanttSidebarBlock, IssuePeekOverview } from "components/issues";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
export const IssueGanttChartView = () => { type Props = {
disableUserActions: boolean;
};
export const IssueGanttChartView: React.FC<Props> = ({ disableUserActions }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -29,23 +33,31 @@ export const IssueGanttChartView = () => {
const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15; const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15;
return ( return (
<div className="w-full h-full"> <>
<GanttChartRoot <IssuePeekOverview
border={false} handleMutation={() => mutateGanttIssues()}
title="Issues" projectId={projectId?.toString() ?? ""}
loaderTitle="Issues" workspaceSlug={workspaceSlug?.toString() ?? ""}
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null} readOnly={disableUserActions}
blockUpdateHandler={(block, payload) =>
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
}
BlockRender={IssueGanttBlock}
SidebarBlockRender={IssueGanttSidebarBlock}
enableBlockLeftResize={isAllowed}
enableBlockRightResize={isAllowed}
enableBlockMove={isAllowed}
enableReorder={displayFilters.order_by === "sort_order" && isAllowed}
bottomSpacing
/> />
</div> <div className="w-full h-full">
<GanttChartRoot
border={false}
title="Issues"
loaderTitle="Issues"
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
blockUpdateHandler={(block, payload) =>
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
}
BlockRender={IssueGanttBlock}
SidebarBlockRender={IssueGanttSidebarBlock}
enableBlockLeftResize={isAllowed}
enableBlockRightResize={isAllowed}
enableBlockMove={isAllowed}
enableReorder={displayFilters.order_by === "sort_order" && isAllowed}
bottomSpacing
/>
</div>
</>
); );
}; };

View File

@ -57,7 +57,7 @@ export const MyIssuesView: React.FC<Props> = ({
const { user } = useUserAuth(); const { user } = useUserAuth();
const { groupedIssues, mutateMyIssues, isEmpty, params } = useMyIssues(workspaceSlug?.toString()); const { groupedIssues, mutateMyIssues, isEmpty, params } = useMyIssues(workspaceSlug?.toString());
const { filters, setFilters, displayFilters, setDisplayFilters, properties } = useMyIssuesFilters( const { filters, setFilters, displayFilters, properties } = useMyIssuesFilters(
workspaceSlug?.toString() workspaceSlug?.toString()
); );

View File

@ -1,11 +1,13 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// react-hook-form // react-hook-form
import { UseFormWatch } from "react-hook-form"; import { UseFormWatch } from "react-hook-form";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useUser from "hooks/use-user";
// services
import issuesService from "services/issues.service";
// components // components
import { ExistingIssuesListModal } from "components/core"; import { ExistingIssuesListModal } from "components/core";
// icons // icons
@ -29,10 +31,11 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
}) => { }) => {
const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false); const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false);
const { user } = useUser();
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug, projectId } = router.query;
const handleClose = () => { const handleClose = () => {
setIsBlockedModalOpen(false); setIsBlockedModalOpen(false);
@ -62,21 +65,39 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
}, },
})); }));
const newBlocked = [...watch("blocked_issues"), ...selectedIssues]; if (!user) return;
issuesService
.createIssueRelation(workspaceSlug as string, projectId as string, issueId as string, user, {
related_list: [
...selectedIssues.map((issue) => ({
issue: issueId as string,
relation_type: "blocked_by" as const,
related_issue_detail: issue.blocked_issue_detail,
related_issue: issue.blocked_issue_detail.id,
})),
],
})
.then((response) => {
submitChanges({
related_issues: [
...watch("related_issues")?.filter((i) => i.relation_type !== "blocked_by"),
...response,
],
});
});
submitChanges({
blocked_issues: newBlocked,
blocks_list: newBlocked.map((i) => i.blocked_issue_detail?.id ?? ""),
});
handleClose(); handleClose();
}; };
const blockedByIssue = watch("related_issues")?.filter((i) => i.relation_type === "blocked_by");
return ( return (
<> <>
<ExistingIssuesListModal <ExistingIssuesListModal
isOpen={isBlockedModalOpen} isOpen={isBlockedModalOpen}
handleClose={() => setIsBlockedModalOpen(false)} handleClose={() => setIsBlockedModalOpen(false)}
searchParams={{ blocker_blocked_by: true, issue_id: issueId }} searchParams={{ issue_relation: true, issue_id: issueId }}
handleOnSubmit={onSubmit} handleOnSubmit={onSubmit}
workspaceLevelToggle workspaceLevelToggle
/> />
@ -87,33 +108,42 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
</div> </div>
<div className="space-y-1 sm:basis-1/2"> <div className="space-y-1 sm:basis-1/2">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{watch("blocked_issues") && watch("blocked_issues").length > 0 {blockedByIssue && blockedByIssue.length > 0
? watch("blocked_issues").map((issue) => ( ? blockedByIssue.map((relation) => (
<div <div
key={issue.blocked_issue_detail?.id} key={relation.related_issue_detail?.id}
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500/20 hover:bg-red-500/20" className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500/20 hover:bg-red-500/20"
> >
<a <a
href={`/${workspaceSlug}/projects/${issue.blocked_issue_detail?.project_detail.id}/issues/${issue.blocked_issue_detail?.id}`} href={`/${workspaceSlug}/projects/${relation.related_issue_detail?.project_detail.id}/issues/${relation.related_issue_detail?.id}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1" className="flex items-center gap-1"
> >
<BlockedIcon height={10} width={10} /> <BlockedIcon height={10} width={10} />
{`${issue.blocked_issue_detail?.project_detail.identifier}-${issue.blocked_issue_detail?.sequence_id}`} {`${relation.related_issue_detail?.project_detail.identifier}-${relation.related_issue_detail?.sequence_id}`}
</a> </a>
<button <button
type="button" type="button"
className="opacity-0 duration-300 group-hover:opacity-100" className="opacity-0 duration-300 group-hover:opacity-100"
onClick={() => { onClick={() => {
const updatedBlocked = watch("blocked_issues").filter( const updatedRelations = watch("related_issues")?.filter(
(i) => i.blocked_issue_detail?.id !== issue.blocked_issue_detail?.id (i) => i.id !== relation.id
); );
submitChanges({ submitChanges({
blocked_issues: updatedBlocked, related_issues: updatedRelations,
blocks_list: updatedBlocked.map((i) => i.blocked_issue_detail?.id ?? ""),
}); });
if (!user) return;
issuesService.deleteIssueRelation(
workspaceSlug as string,
projectId as string,
issueId as string,
relation.id,
user
);
}} }}
> >
<XMarkIcon className="h-2 w-2" /> <XMarkIcon className="h-2 w-2" />

View File

@ -6,8 +6,11 @@ import { useRouter } from "next/router";
import { UseFormWatch } from "react-hook-form"; import { UseFormWatch } from "react-hook-form";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useUser from "hooks/use-user";
// components // components
import { ExistingIssuesListModal } from "components/core"; import { ExistingIssuesListModal } from "components/core";
// services
import issuesService from "services/issues.service";
// icons // icons
import { XMarkIcon } from "@heroicons/react/24/outline"; import { XMarkIcon } from "@heroicons/react/24/outline";
import { BlockerIcon } from "components/icons"; import { BlockerIcon } from "components/icons";
@ -29,15 +32,19 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
}) => { }) => {
const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false); const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false);
const { user } = useUser();
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug, projectId } = router.query;
const handleClose = () => { const handleClose = () => {
setIsBlockerModalOpen(false); setIsBlockerModalOpen(false);
}; };
const blockerIssue =
watch("issue_relations")?.filter((i) => i.relation_type === "blocked_by") || [];
const onSubmit = async (data: ISearchIssueResponse[]) => { const onSubmit = async (data: ISearchIssueResponse[]) => {
if (data.length === 0) { if (data.length === 0) {
setToastAlert({ setToastAlert({
@ -62,12 +69,33 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
}, },
})); }));
const newBlockers = [...watch("blocker_issues"), ...selectedIssues]; if (!user) return;
issuesService
.createIssueRelation(workspaceSlug as string, projectId as string, issueId as string, user, {
related_list: [
...selectedIssues.map((issue) => ({
issue: issue.blocker_issue_detail.id,
relation_type: "blocked_by" as const,
related_issue: issueId as string,
related_issue_detail: issue.blocker_issue_detail,
})),
],
})
.then((response) => {
submitChanges({
issue_relations: [
...blockerIssue,
...(response ?? []).map((i: any) => ({
id: i.id,
relation_type: i.relation_type,
issue_detail: i.related_issue_detail,
issue: i.related_issue,
})),
],
});
});
submitChanges({
blocker_issues: newBlockers,
blockers_list: newBlockers.map((i) => i.blocker_issue_detail?.id ?? ""),
});
handleClose(); handleClose();
}; };
@ -76,7 +104,7 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
<ExistingIssuesListModal <ExistingIssuesListModal
isOpen={isBlockerModalOpen} isOpen={isBlockerModalOpen}
handleClose={() => setIsBlockerModalOpen(false)} handleClose={() => setIsBlockerModalOpen(false)}
searchParams={{ blocker_blocked_by: true, issue_id: issueId }} searchParams={{ issue_relation: true, issue_id: issueId }}
handleOnSubmit={onSubmit} handleOnSubmit={onSubmit}
workspaceLevelToggle workspaceLevelToggle
/> />
@ -87,35 +115,42 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
</div> </div>
<div className="space-y-1 sm:basis-1/2"> <div className="space-y-1 sm:basis-1/2">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{watch("blocker_issues") && watch("blocker_issues").length > 0 {blockerIssue && blockerIssue.length > 0
? watch("blocker_issues").map((issue) => ( ? blockerIssue.map((relation) => (
<div <div
key={issue.blocker_issue_detail?.id} key={relation.issue_detail?.id}
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20" className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20"
> >
<a <a
href={`/${workspaceSlug}/projects/${issue.blocker_issue_detail?.project_detail.id}/issues/${issue.blocker_issue_detail?.id}`} href={`/${workspaceSlug}/projects/${relation.issue_detail?.project_detail.id}/issues/${relation.issue_detail?.id}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1" className="flex items-center gap-1"
> >
<BlockerIcon height={10} width={10} /> <BlockerIcon height={10} width={10} />
{`${issue.blocker_issue_detail?.project_detail.identifier}-${issue.blocker_issue_detail?.sequence_id}`} {`${relation.issue_detail?.project_detail.identifier}-${relation.issue_detail?.sequence_id}`}
</a> </a>
<button <button
type="button" type="button"
className="opacity-0 duration-300 group-hover:opacity-100" className="opacity-0 duration-300 group-hover:opacity-100"
onClick={() => { onClick={() => {
const updatedBlockers = watch("blocker_issues").filter( const updatedBlockers = blockerIssue.filter(
(i) => i.blocker_issue_detail?.id !== issue.blocker_issue_detail?.id (i) => i.issue_detail?.id !== relation.issue_detail?.id
); );
submitChanges({ submitChanges({
blocker_issues: updatedBlockers, issue_relations: updatedBlockers,
blockers_list: updatedBlockers.map(
(i) => i.blocker_issue_detail?.id ?? ""
),
}); });
if (!user) return;
issuesService.deleteIssueRelation(
workspaceSlug as string,
projectId as string,
relation.issue_detail?.id as string,
relation.id,
user
);
}} }}
> >
<XMarkIcon className="h-2 w-2" /> <XMarkIcon className="h-2 w-2" />

View File

@ -0,0 +1,172 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
// react-hook-form
import { UseFormWatch } from "react-hook-form";
// hooks
import useToast from "hooks/use-toast";
import useUser from "hooks/use-user";
// icons
import { X, CopyPlus } from "lucide-react";
// components
import { BlockerIcon } from "components/icons";
import { ExistingIssuesListModal } from "components/core";
// services
import issuesService from "services/issues.service";
// types
import { BlockeIssueDetail, IIssue, ISearchIssueResponse } from "types";
type Props = {
issueId?: string;
submitChanges: (formData: Partial<IIssue>) => void;
watch: UseFormWatch<IIssue>;
disabled?: boolean;
};
export const SidebarDuplicateSelect: React.FC<Props> = (props) => {
const { issueId, submitChanges, watch, disabled = false } = props;
const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false);
const { user } = useUser();
const { setToastAlert } = useToast();
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const handleClose = () => {
setIsDuplicateModalOpen(false);
};
const onSubmit = async (data: ISearchIssueResponse[]) => {
if (data.length === 0) {
setToastAlert({
type: "error",
title: "Error!",
message: "Please select at least one issue.",
});
return;
}
const selectedIssues: { blocker_issue_detail: BlockeIssueDetail }[] = data.map((i) => ({
blocker_issue_detail: {
id: i.id,
name: i.name,
sequence_id: i.sequence_id,
project_detail: {
id: i.project_id,
identifier: i.project__identifier,
name: i.project__name,
},
},
}));
if (!user) return;
issuesService
.createIssueRelation(workspaceSlug as string, projectId as string, issueId as string, user, {
related_list: [
...selectedIssues.map((issue) => ({
issue: issueId as string,
related_issue_detail: issue.blocker_issue_detail,
related_issue: issue.blocker_issue_detail.id,
relation_type: "duplicate" as const,
})),
],
})
.then((response) => {
submitChanges({
related_issues: [...watch("related_issues"), ...(response ?? [])],
});
});
handleClose();
};
const duplicateIssuesRelation = [
...(watch("related_issues")?.filter((i) => i.relation_type === "duplicate") ?? []),
...(watch("issue_relations") ?? [])
?.filter((i) => i.relation_type === "duplicate")
.map((i) => ({
...i,
related_issue_detail: i.issue_detail,
related_issue: i.issue_detail?.id,
})),
];
return (
<>
<ExistingIssuesListModal
isOpen={isDuplicateModalOpen}
handleClose={() => setIsDuplicateModalOpen(false)}
searchParams={{ issue_relation: true, issue_id: issueId }}
handleOnSubmit={onSubmit}
workspaceLevelToggle
/>
<div className="flex flex-wrap items-start py-2">
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
<CopyPlus className="h-4 w-4 flex-shrink-0" />
<p>Duplicate</p>
</div>
<div className="space-y-1 sm:basis-1/2">
<div className="flex flex-wrap gap-1">
{duplicateIssuesRelation && duplicateIssuesRelation.length > 0
? duplicateIssuesRelation.map((relation) => (
<div
key={relation.related_issue_detail?.id}
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20"
>
<a
href={`/${workspaceSlug}/projects/${relation.related_issue_detail?.project_detail.id}/issues/${relation.related_issue_detail?.id}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1"
>
<BlockerIcon height={10} width={10} />
{`${relation.related_issue_detail?.project_detail.identifier}-${relation.related_issue_detail?.sequence_id}`}
</a>
<button
type="button"
className="opacity-0 duration-300 group-hover:opacity-100"
onClick={() => {
const updatedBlockers = duplicateIssuesRelation.filter(
(i) => i.related_issue_detail?.id !== relation.related_issue_detail?.id
);
submitChanges({
related_issues: updatedBlockers,
});
if (!user) return;
issuesService.deleteIssueRelation(
workspaceSlug as string,
projectId as string,
issueId as string,
relation.id,
user
);
}}
>
<X className="h-2 w-2" />
</button>
</div>
))
: null}
</div>
<button
type="button"
className={`bg-custom-background-80 text-xs text-custom-text-200 rounded px-2.5 py-0.5 ${
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
}`}
onClick={() => setIsDuplicateModalOpen(true)}
disabled={disabled}
>
Select issues
</button>
</div>
</div>
</>
);
};

View File

@ -8,3 +8,5 @@ export * from "./module";
export * from "./parent"; export * from "./parent";
export * from "./priority"; export * from "./priority";
export * from "./state"; export * from "./state";
export * from "./duplicate";
export * from "./relates-to";

View File

@ -0,0 +1,172 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
// react-hook-form
import { UseFormWatch } from "react-hook-form";
// hooks
import useToast from "hooks/use-toast";
import useUser from "hooks/use-user";
// icons
import { X } from "lucide-react";
import { BlockerIcon, RelatedIcon } from "components/icons";
// components
import { ExistingIssuesListModal } from "components/core";
// services
import issuesService from "services/issues.service";
// types
import { BlockeIssueDetail, IIssue, ISearchIssueResponse } from "types";
type Props = {
issueId?: string;
submitChanges: (formData: Partial<IIssue>) => void;
watch: UseFormWatch<IIssue>;
disabled?: boolean;
};
export const SidebarRelatesSelect: React.FC<Props> = (props) => {
const { issueId, submitChanges, watch, disabled = false } = props;
const [isRelatesToModalOpen, setIsRelatesToModalOpen] = useState(false);
const { user } = useUser();
const { setToastAlert } = useToast();
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const handleClose = () => {
setIsRelatesToModalOpen(false);
};
const onSubmit = async (data: ISearchIssueResponse[]) => {
if (data.length === 0) {
setToastAlert({
type: "error",
title: "Error!",
message: "Please select at least one issue.",
});
return;
}
const selectedIssues: { blocker_issue_detail: BlockeIssueDetail }[] = data.map((i) => ({
blocker_issue_detail: {
id: i.id,
name: i.name,
sequence_id: i.sequence_id,
project_detail: {
id: i.project_id,
identifier: i.project__identifier,
name: i.project__name,
},
},
}));
if (!user) return;
issuesService
.createIssueRelation(workspaceSlug as string, projectId as string, issueId as string, user, {
related_list: [
...selectedIssues.map((issue) => ({
issue: issueId as string,
related_issue_detail: issue.blocker_issue_detail,
related_issue: issue.blocker_issue_detail.id,
relation_type: "relates_to" as const,
})),
],
})
.then((response) => {
submitChanges({
related_issues: [...watch("related_issues"), ...(response ?? [])],
});
});
handleClose();
};
const relatedToIssueRelation = [
...(watch("related_issues")?.filter((i) => i.relation_type === "relates_to") ?? []),
...(watch("issue_relations") ?? [])
?.filter((i) => i.relation_type === "relates_to")
.map((i) => ({
...i,
related_issue_detail: i.issue_detail,
related_issue: i.issue_detail?.id,
})),
];
return (
<>
<ExistingIssuesListModal
isOpen={isRelatesToModalOpen}
handleClose={() => setIsRelatesToModalOpen(false)}
searchParams={{ issue_relation: true, issue_id: issueId }}
handleOnSubmit={onSubmit}
workspaceLevelToggle
/>
<div className="flex flex-wrap items-start py-2">
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
<RelatedIcon className="h-4 w-4 flex-shrink-0" />
<p>Relates to</p>
</div>
<div className="space-y-1 sm:basis-1/2">
<div className="flex flex-wrap gap-1">
{relatedToIssueRelation && relatedToIssueRelation.length > 0
? relatedToIssueRelation.map((relation) => (
<div
key={relation.related_issue_detail?.id}
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20"
>
<a
href={`/${workspaceSlug}/projects/${relation.related_issue_detail?.project_detail.id}/issues/${relation.related_issue_detail?.id}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1"
>
<BlockerIcon height={10} width={10} />
{`${relation.related_issue_detail?.project_detail.identifier}-${relation.related_issue_detail?.sequence_id}`}
</a>
<button
type="button"
className="opacity-0 duration-300 group-hover:opacity-100"
onClick={() => {
const updatedBlockers = relatedToIssueRelation.filter(
(i) => i.related_issue_detail?.id !== relation.related_issue_detail?.id
);
submitChanges({
related_issues: updatedBlockers,
});
if (!user) return;
issuesService.deleteIssueRelation(
workspaceSlug as string,
projectId as string,
issueId as string,
relation.id,
user
);
}}
>
<X className="h-2 w-2" />
</button>
</div>
))
: null}
</div>
<button
type="button"
className={`bg-custom-background-80 text-xs text-custom-text-200 rounded px-2.5 py-0.5 ${
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
}`}
onClick={() => setIsRelatesToModalOpen(true)}
disabled={disabled}
>
Select issues
</button>
</div>
</div>
</>
);
};

View File

@ -30,6 +30,8 @@ import {
SidebarStateSelect, SidebarStateSelect,
SidebarEstimateSelect, SidebarEstimateSelect,
SidebarLabelSelect, SidebarLabelSelect,
SidebarDuplicateSelect,
SidebarRelatesSelect,
} from "components/issues"; } from "components/issues";
// ui // ui
import { CustomDatePicker, Icon } from "components/ui"; import { CustomDatePicker, Icon } from "components/ui";
@ -76,6 +78,8 @@ type Props = {
| "delete" | "delete"
| "all" | "all"
| "subscribe" | "subscribe"
| "duplicate"
| "relates_to"
)[]; )[];
uneditable?: boolean; uneditable?: boolean;
}; };
@ -464,7 +468,19 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && ( {(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && (
<SidebarBlockerSelect <SidebarBlockerSelect
issueId={issueId as string} issueId={issueId as string}
submitChanges={submitChanges} submitChanges={(data: any) => {
mutate<IIssue>(
ISSUE_DETAILS(issueId as string),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...data,
};
},
false
);
}}
watch={watchIssue} watch={watchIssue}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable} disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
/> />
@ -472,7 +488,59 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && ( {(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && (
<SidebarBlockedSelect <SidebarBlockedSelect
issueId={issueId as string} issueId={issueId as string}
submitChanges={submitChanges} submitChanges={(data: any) => {
mutate<IIssue>(
ISSUE_DETAILS(issueId as string),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...data,
};
},
false
);
}}
watch={watchIssue}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("duplicate")) && (
<SidebarDuplicateSelect
issueId={issueId as string}
submitChanges={(data: any) => {
mutate<IIssue>(
ISSUE_DETAILS(issueId as string),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...data,
};
},
false
);
}}
watch={watchIssue}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("relates_to")) && (
<SidebarRelatesSelect
issueId={issueId as string}
submitChanges={(data: any) => {
mutate<IIssue>(
ISSUE_DETAILS(issueId as string),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...data,
};
},
false
);
}}
watch={watchIssue} watch={watchIssue}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable} disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
/> />

View File

@ -1,5 +1,3 @@
import { FC } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// hooks // hooks
@ -10,13 +8,13 @@ import { updateGanttIssue } from "components/gantt-chart/hooks/block-update";
import useProjectDetails from "hooks/use-project-details"; import useProjectDetails from "hooks/use-project-details";
// components // components
import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart"; import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart";
import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues"; import { IssueGanttBlock, IssueGanttSidebarBlock, IssuePeekOverview } from "components/issues";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
type Props = {}; type Props = { disableUserActions: boolean };
export const ModuleIssuesGanttChartView: FC<Props> = ({}) => { export const ModuleIssuesGanttChartView: React.FC<Props> = ({ disableUserActions }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, moduleId } = router.query; const { workspaceSlug, projectId, moduleId } = router.query;
@ -34,23 +32,31 @@ export const ModuleIssuesGanttChartView: FC<Props> = ({}) => {
const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15; const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15;
return ( return (
<div className="w-full h-full"> <>
<GanttChartRoot <IssuePeekOverview
border={false} handleMutation={() => mutateGanttIssues()}
title="Issues" projectId={projectId?.toString() ?? ""}
loaderTitle="Issues" workspaceSlug={workspaceSlug?.toString() ?? ""}
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null} readOnly={disableUserActions}
blockUpdateHandler={(block, payload) =>
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
}
SidebarBlockRender={IssueGanttSidebarBlock}
BlockRender={IssueGanttBlock}
enableBlockLeftResize={isAllowed}
enableBlockRightResize={isAllowed}
enableBlockMove={isAllowed}
enableReorder={displayFilters.order_by === "sort_order" && isAllowed}
bottomSpacing
/> />
</div> <div className="w-full h-full">
<GanttChartRoot
border={false}
title="Issues"
loaderTitle="Issues"
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
blockUpdateHandler={(block, payload) =>
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
}
SidebarBlockRender={IssueGanttSidebarBlock}
BlockRender={IssueGanttBlock}
enableBlockLeftResize={isAllowed}
enableBlockRightResize={isAllowed}
enableBlockMove={isAllowed}
enableReorder={displayFilters.order_by === "sort_order" && isAllowed}
bottomSpacing
/>
</div>
</>
); );
}; };

View File

@ -1,5 +1,3 @@
import { FC } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// hooks // hooks
@ -9,13 +7,13 @@ import { updateGanttIssue } from "components/gantt-chart/hooks/block-update";
import useProjectDetails from "hooks/use-project-details"; import useProjectDetails from "hooks/use-project-details";
// components // components
import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart"; import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart";
import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues"; import { IssueGanttBlock, IssueGanttSidebarBlock, IssuePeekOverview } from "components/issues";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
type Props = {}; type Props = { disableUserActions: boolean };
export const ViewIssuesGanttChartView: FC<Props> = ({}) => { export const ViewIssuesGanttChartView: React.FC<Props> = ({ disableUserActions }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, viewId } = router.query; const { workspaceSlug, projectId, viewId } = router.query;
@ -31,22 +29,30 @@ export const ViewIssuesGanttChartView: FC<Props> = ({}) => {
const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15; const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15;
return ( return (
<div className="w-full h-full"> <>
<GanttChartRoot <IssuePeekOverview
border={false} handleMutation={() => mutateGanttIssues()}
title="Issues" projectId={projectId?.toString() ?? ""}
loaderTitle="Issues" workspaceSlug={workspaceSlug?.toString() ?? ""}
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null} readOnly={disableUserActions}
blockUpdateHandler={(block, payload) =>
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
}
SidebarBlockRender={IssueGanttSidebarBlock}
BlockRender={IssueGanttBlock}
enableBlockLeftResize={isAllowed}
enableBlockRightResize={isAllowed}
enableBlockMove={isAllowed}
enableReorder={isAllowed}
/> />
</div> <div className="w-full h-full">
<GanttChartRoot
border={false}
title="Issues"
loaderTitle="Issues"
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
blockUpdateHandler={(block, payload) =>
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
}
SidebarBlockRender={IssueGanttSidebarBlock}
BlockRender={IssueGanttBlock}
enableBlockLeftResize={isAllowed}
enableBlockRightResize={isAllowed}
enableBlockMove={isAllowed}
enableReorder={isAllowed}
/>
</div>
</>
); );
}; };

View File

@ -4,9 +4,21 @@ import React, { useState } from "react";
// next // next
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// swr
import { mutate } from "swr";
// react hook forms // react hook forms
import { Control, Controller, useWatch } from "react-hook-form"; import { Control, Controller, useWatch } from "react-hook-form";
// services
import issuesService from "services/issues.service";
// hooks
import useUser from "hooks/use-user";
// fetch keys
import { ISSUE_DETAILS } from "constants/fetch-keys";
// icons // icons
import { BlockedIcon, BlockerIcon } from "components/icons"; import { BlockedIcon, BlockerIcon } from "components/icons";
import { ChevronDown, PlayIcon, User, X, CalendarDays, LayoutGrid, Users } from "lucide-react"; import { ChevronDown, PlayIcon, User, X, CalendarDays, LayoutGrid, Users } from "lucide-react";
@ -26,6 +38,7 @@ import {
EstimateSelect, EstimateSelect,
ParentSelect, ParentSelect,
BlockerSelect, BlockerSelect,
BlockedSelect,
} from "components/web-view"; } from "components/web-view";
// types // types
@ -39,15 +52,16 @@ type Props = {
export const IssuePropertiesDetail: React.FC<Props> = (props) => { export const IssuePropertiesDetail: React.FC<Props> = (props) => {
const { control, submitChanges } = props; const { control, submitChanges } = props;
const blockerIssue = useWatch({ const blockerIssue =
control, useWatch({
name: "blocker_issues", control,
}); name: "issue_relations",
})?.filter((i) => i.relation_type === "blocked_by") || [];
const blockedIssue = useWatch({ const blockedIssue = useWatch({
control, control,
name: "blocked_issues", name: "related_issues",
}); })?.filter((i) => i.relation_type === "blocked_by");
const startDate = useWatch({ const startDate = useWatch({
control, control,
@ -55,12 +69,28 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
}); });
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug, projectId, issueId } = router.query;
const { user } = useUser();
const [isViewAllOpen, setIsViewAllOpen] = useState(false); const [isViewAllOpen, setIsViewAllOpen] = useState(false);
const { isEstimateActive } = useEstimateOption(); const { isEstimateActive } = useEstimateOption();
const handleMutation = (data: any) => {
mutate<IIssue>(
ISSUE_DETAILS(issueId as string),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...data,
};
},
false
);
};
return ( return (
<div> <div>
<Label>Details</Label> <Label>Details</Label>
@ -188,51 +218,80 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
<span className="text-sm text-custom-text-400">Blocking</span> <span className="text-sm text-custom-text-400">Blocking</span>
</div> </div>
<div> <div>
<Controller <BlockerSelect
control={control} value={null}
name="blocker_issues" onChange={(val) => {
render={({ field: { value } }) => ( if (!user || !workspaceSlug || !projectId || !issueId) return;
<BlockerSelect
value={value} issuesService
onChange={(val) => .createIssueRelation(
submitChanges({ workspaceSlug as string,
blocker_issues: val, projectId as string,
blockers_list: val?.map((i: any) => i.blocker_issue_detail?.id ?? ""), issueId as string,
}) user,
} {
/> related_list: [
)} ...val.map((issue: any) => ({
issue: issue.blocker_issue_detail.id,
relation_type: "blocked_by" as const,
related_issue: issueId as string,
related_issue_detail: issue.blocker_issue_detail,
})),
],
}
)
.then((response) => {
handleMutation({
issue_relations: [
...blockerIssue,
...(response ?? []).map((i: any) => ({
id: i.id,
relation_type: i.relation_type,
issue_detail: i.related_issue_detail,
issue: i.related_issue,
})),
],
});
});
}}
/> />
</div> </div>
</div> </div>
{blockerIssue && {blockerIssue &&
blockerIssue.map((issue) => ( blockerIssue.map((issue) => (
<div <div
key={issue.blocker_issue_detail?.id} key={issue.issue_detail?.id}
className="group inline-flex mr-1 cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20" className="group inline-flex mr-1 cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20"
> >
<a <a
href={`/${workspaceSlug}/projects/${issue.blocker_issue_detail?.project_detail.id}/issues/${issue.blocker_issue_detail?.id}`} href={`/${workspaceSlug}/projects/${issue.issue_detail?.project_detail.id}/issues/${issue.issue_detail?.id}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1" className="flex items-center gap-1"
> >
<BlockerIcon height={10} width={10} /> <BlockerIcon height={10} width={10} />
{`${issue.blocker_issue_detail?.project_detail.identifier}-${issue.blocker_issue_detail?.sequence_id}`} {`${issue.issue_detail?.project_detail.identifier}-${issue.issue_detail?.sequence_id}`}
</a> </a>
<button <button
type="button" type="button"
className="duration-300" className="duration-300"
onClick={() => { onClick={() => {
const updatedBlockers = blockerIssue.filter( const updatedBlockers = blockerIssue.filter(
(i) => i.blocker_issue_detail?.id !== issue.blocker_issue_detail?.id (i) => i.issue_detail?.id !== issue.issue_detail?.id
); );
submitChanges({ if (!user) return;
blocker_issues: updatedBlockers,
blockers_list: updatedBlockers.map( issuesService.deleteIssueRelation(
(i) => i.blocker_issue_detail?.id ?? "" workspaceSlug as string,
), projectId as string,
issueId as string,
issue.id,
user
);
handleMutation({
issue_relations: updatedBlockers,
}); });
}} }}
> >
@ -250,49 +309,80 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
<span className="text-sm text-custom-text-400">Blocked by</span> <span className="text-sm text-custom-text-400">Blocked by</span>
</div> </div>
<div> <div>
<Controller <BlockedSelect
control={control} value={null}
name="blocked_issues" onChange={(val) => {
render={({ field: { value } }) => ( if (!user || !workspaceSlug || !projectId || !issueId) return;
<BlockerSelect
value={value} issuesService
onChange={(val) => .createIssueRelation(
submitChanges({ workspaceSlug as string,
blocked_issues: val, projectId as string,
blocks_list: val?.map((i: any) => i.blocker_issue_detail?.id ?? ""), issueId as string,
}) user,
} {
/> related_list: [
)} ...val.map((issue: any) => ({
issue: issue.blocked_issue_detail.id,
relation_type: "blocked_by" as const,
related_issue: issueId as string,
related_issue_detail: issue.blocked_issue_detail,
})),
],
}
)
.then((response) => {
handleMutation({
related_issues: [
...blockedIssue,
...(response ?? []).map((i: any) => ({
id: i.id,
relation_type: i.relation_type,
issue_detail: i.related_issue_detail,
issue: i.related_issue,
})),
],
});
});
}}
/> />
</div> </div>
</div> </div>
{blockedIssue && {blockedIssue &&
blockedIssue.map((issue) => ( blockedIssue.map((issue) => (
<div <div
key={issue.blocked_issue_detail?.id} key={issue.related_issue_detail?.id}
className="group inline-flex mr-1 cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500/20 hover:bg-red-500/20" className="group inline-flex mr-1 cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500/20 hover:bg-red-500/20"
> >
<a <a
href={`/${workspaceSlug}/projects/${issue.blocked_issue_detail?.project_detail.id}/issues/${issue.blocked_issue_detail?.id}`} href={`/${workspaceSlug}/projects/${issue.related_issue_detail?.project_detail.id}/issues/${issue.related_issue_detail?.id}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1" className="flex items-center gap-1"
> >
<BlockedIcon height={10} width={10} /> <BlockedIcon height={10} width={10} />
{`${issue?.blocked_issue_detail?.project_detail?.identifier}-${issue?.blocked_issue_detail?.sequence_id}`} {`${issue?.related_issue_detail?.project_detail?.identifier}-${issue?.related_issue_detail?.sequence_id}`}
</a> </a>
<button <button
type="button" type="button"
className="duration-300" className="duration-300"
onClick={() => { onClick={() => {
const updatedBlocked = blockedIssue.filter( const updatedBlocked = blockedIssue.filter(
(i) => i.blocked_issue_detail?.id !== issue.blocked_issue_detail?.id (i) => i.related_issue_detail?.id !== issue.related_issue_detail?.id
); );
submitChanges({ if (!user) return;
blocked_issues: updatedBlocked,
blocks_list: updatedBlocked.map((i) => i.blocked_issue_detail?.id ?? ""), issuesService.deleteIssueRelation(
workspaceSlug as string,
projectId as string,
issueId as string,
issue.id,
user
);
handleMutation({
related_issues: updatedBlocked,
}); });
}} }}
> >

View File

@ -66,7 +66,7 @@ export const BlockedSelect: React.FC<Props> = (props) => {
<ExistingIssuesListModal <ExistingIssuesListModal
isOpen={isBlockedModalOpen} isOpen={isBlockedModalOpen}
handleClose={() => setIsBlockedModalOpen(false)} handleClose={() => setIsBlockedModalOpen(false)}
searchParams={{ blocker_blocked_by: true, issue_id: issueId!.toString() }} searchParams={{ issue_relation: true, issue_id: issueId!.toString() }}
handleOnSubmit={onSubmit} handleOnSubmit={onSubmit}
workspaceLevelToggle workspaceLevelToggle
/> />

View File

@ -66,7 +66,7 @@ export const BlockerSelect: React.FC<Props> = (props) => {
<ExistingIssuesListModal <ExistingIssuesListModal
isOpen={isBlockerModalOpen} isOpen={isBlockerModalOpen}
handleClose={() => setIsBlockerModalOpen(false)} handleClose={() => setIsBlockerModalOpen(false)}
searchParams={{ blocker_blocked_by: true, issue_id: issueId!.toString() }} searchParams={{ issue_relation: true, issue_id: issueId!.toString() }}
handleOnSubmit={onSubmit} handleOnSubmit={onSubmit}
workspaceLevelToggle workspaceLevelToggle
/> />

View File

@ -44,7 +44,7 @@ const useCalendarIssuesView = () => {
target_date: displayFilters?.calendar_date_range, target_date: displayFilters?.calendar_date_range,
}; };
const { data: projectCalendarIssues } = useSWR( const { data: projectCalendarIssues, mutate: mutateProjectCalendarIssues } = useSWR(
workspaceSlug && projectId workspaceSlug && projectId
? PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params) ? PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params)
: null, : null,
@ -54,7 +54,7 @@ const useCalendarIssuesView = () => {
: null : null
); );
const { data: cycleCalendarIssues } = useSWR( const { data: cycleCalendarIssues, mutate: mutateCycleCalendarIssues } = useSWR(
workspaceSlug && projectId && cycleId workspaceSlug && projectId && cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params) ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params)
: null, : null,
@ -69,7 +69,7 @@ const useCalendarIssuesView = () => {
: null : null
); );
const { data: moduleCalendarIssues } = useSWR( const { data: moduleCalendarIssues, mutate: mutateModuleCalendarIssues } = useSWR(
workspaceSlug && projectId && moduleId workspaceSlug && projectId && moduleId
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params) ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params)
: null, : null,
@ -84,7 +84,7 @@ const useCalendarIssuesView = () => {
: null : null
); );
const { data: viewCalendarIssues } = useSWR( const { data: viewCalendarIssues, mutate: mutateViewCalendarIssues } = useSWR(
workspaceSlug && projectId && viewId && params ? VIEW_ISSUES(viewId.toString(), params) : null, workspaceSlug && projectId && viewId && params ? VIEW_ISSUES(viewId.toString(), params) : null,
workspaceSlug && projectId && viewId && params workspaceSlug && projectId && viewId && params
? () => ? () =>
@ -104,6 +104,13 @@ const useCalendarIssuesView = () => {
displayFilters, displayFilters,
setDisplayFilters, setDisplayFilters,
calendarIssues: calendarIssues ?? [], calendarIssues: calendarIssues ?? [],
mutateIssues: cycleId
? mutateCycleCalendarIssues
: moduleId
? mutateModuleCalendarIssues
: viewId
? mutateViewCalendarIssues
: mutateProjectCalendarIssues,
filters, filters,
setFilters, setFilters,
params, params,

View File

@ -95,8 +95,8 @@ const Editor: NextPage = () => {
...formData, ...formData,
}; };
delete payload.blocker_issues; delete payload.issue_relations;
delete payload.blocked_issues; delete payload.related_issues;
await issuesService await issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user) .patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user)
.catch((e) => { .catch((e) => {

View File

@ -86,8 +86,8 @@ const IssueDetailsPage: NextPage = () => {
...formData, ...formData,
}; };
delete payload.blocker_issues; delete payload.related_issues;
delete payload.blocked_issues; delete payload.issue_relations;
await issuesService await issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user) .patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user)

View File

@ -110,8 +110,8 @@ const MobileWebViewIssueDetail = () => {
...formData, ...formData,
}; };
delete payload.blocker_issues; delete payload.issue_relations;
delete payload.blocked_issues; delete payload.related_issues;
await issuesService await issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user) .patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user)

View File

@ -149,6 +149,52 @@ class ProjectIssuesServices extends APIService {
}); });
} }
async createIssueRelation(
workspaceSlug: string,
projectId: string,
issueId: string,
user: ICurrentUserResponse,
data: {
related_list: Array<{
relation_type: "duplicate" | "relates_to" | "blocked_by";
related_issue: string;
}>;
}
) {
return this.post(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/issue-relation/`,
data
)
.then((response) => {
if (trackEvent)
trackEventServices.trackIssueRelationEvent(response.data, "ISSUE_RELATION_CREATE", user);
return response?.data;
})
.catch((error) => {
throw error?.response;
});
}
async deleteIssueRelation(
workspaceSlug: string,
projectId: string,
issueId: string,
relationId: string,
user: ICurrentUserResponse
) {
return this.delete(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/issue-relation/${relationId}/`
)
.then((response) => {
if (trackEvent)
trackEventServices.trackIssueRelationEvent(response.data, "ISSUE_RELATION_DELETE", user);
return response?.data;
})
.catch((error) => {
throw error?.response;
});
}
async createIssueProperties(workspaceSlug: string, projectId: string, data: any): Promise<any> { async createIssueProperties(workspaceSlug: string, projectId: string, data: any): Promise<any> {
return this.post( return this.post(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-properties/`, `/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-properties/`,

View File

@ -328,6 +328,22 @@ class TrackEventServices extends APIService {
}); });
} }
async trackIssueRelationEvent(
data: any,
eventName: "ISSUE_RELATION_CREATE" | "ISSUE_RELATION_DELETE",
user: ICurrentUserResponse
): Promise<any> {
return this.request({
url: "/api/track-event",
method: "POST",
data: {
eventName,
extra: data,
user: user,
},
});
}
async trackIssueMovedToCycleOrModuleEvent( async trackIssueMovedToCycleOrModuleEvent(
data: any, data: any,
eventName: eventName:

27
web/types/issues.d.ts vendored
View File

@ -71,6 +71,15 @@ export interface linkDetails {
url: string; url: string;
} }
export type IssueRelationType = "duplicate" | "relates_to" | "blocked_by";
export interface IssueRelation {
id: string;
issue: string;
related_issue: string;
relation_type: IssueRelationType;
}
export interface IIssue { export interface IIssue {
archived_at: string; archived_at: string;
assignees: string[]; assignees: string[];
@ -78,10 +87,20 @@ export interface IIssue {
assignees_list: string[]; assignees_list: string[];
attachment_count: number; attachment_count: number;
attachments: any[]; attachments: any[];
blocked_issues: { blocked_issue_detail?: BlockeIssueDetail }[]; issue_relations: {
blocker_issues: { blocker_issue_detail?: BlockeIssueDetail }[]; id: string;
blockers_list: string[]; issue: string;
blocks_list: string[]; issue_detail: BlockeIssueDetail;
relation_type: IssueRelationType;
related_issue: string;
}[];
related_issues: {
id: string;
issue: string;
related_issue_detail: BlockeIssueDetail;
relation_type: IssueRelationType;
related_issue: string;
}[];
bridge_id?: string | null; bridge_id?: string | null;
completed_at: Date; completed_at: Date;
created_at: string; created_at: string;

View File

@ -130,7 +130,7 @@ export interface GithubRepositoriesResponse {
export type TProjectIssuesSearchParams = { export type TProjectIssuesSearchParams = {
search: string; search: string;
parent?: boolean; parent?: boolean;
blocker_blocked_by?: boolean; issue_relation?: boolean;
cycle?: boolean; cycle?: boolean;
module?: boolean; module?: boolean;
sub_issue?: boolean; sub_issue?: boolean;