[WEB-3268] feat: url pattern (#6546)

* feat: meta endpoint for issue

* chore: add detail endpoint

* chore: getIssueMetaFromURL and retrieveWithIdentifier endpoint added

* chore: issue store updated

* chore: move issue detail to new route and add redirection for old route

* fix: issue details permission

* fix: work item detail header

* chore: generateWorkItemLink helper function added

* chore: copyTextToClipboard helper function updated

* chore: workItemLink updated

* chore: workItemLink updated

* chore: workItemLink updated

* fix: issues navigation tab active status

* fix: invalid workitem error state

* chore: peek view parent issue redirection improvement

* fix: issue detail endpoint to not return epics and intake issue

* fix: workitem empty state redirection and header

* fix: workitem empty state redirection and header

* chore: code refactor

* chore: project auth wrapper improvement

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
This commit is contained in:
Anmol Singh Bhatia
2025-02-15 05:05:00 +05:30
committed by GitHub
parent 82eea3e802
commit 4353cc0c4a
51 changed files with 1032 additions and 282 deletions
+12
View File
@@ -26,6 +26,8 @@ from plane.app.views import (
IssueBulkUpdateDateEndpoint,
IssueVersionEndpoint,
IssueDescriptionVersionEndpoint,
IssueMetaEndpoint,
IssueDetailIdentifierEndpoint,
)
urlpatterns = [
@@ -278,4 +280,14 @@ urlpatterns = [
IssueDescriptionVersionEndpoint.as_view(),
name="page-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/meta/",
IssueMetaEndpoint.as_view(),
name="issue-meta",
),
path(
"workspaces/<str:slug>/work-items/<str:project_identifier>-<str:issue_identifier>/",
IssueDetailIdentifierEndpoint.as_view(),
name="issue-detail-identifier",
),
]
+2
View File
@@ -116,6 +116,8 @@ from .issue.base import (
IssuePaginatedViewSet,
IssueDetailEndpoint,
IssueBulkUpdateDateEndpoint,
IssueMetaEndpoint,
IssueDetailIdentifierEndpoint,
)
from .issue.activity import IssueActivityEndpoint
+175
View File
@@ -1096,3 +1096,178 @@ class IssueBulkUpdateDateEndpoint(BaseAPIView):
return Response(
{"message": "Issues updated successfully"}, status=status.HTTP_200_OK
)
class IssueMetaEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT")
def get(self, request, slug, project_id, issue_id):
issue = Issue.issue_objects.only("sequence_id", "project__identifier").get(
id=issue_id, project_id=project_id, workspace__slug=slug
)
return Response(
{
"sequence_id": issue.sequence_id,
"project_identifier": issue.project.identifier,
},
status=status.HTTP_200_OK,
)
class IssueDetailIdentifierEndpoint(BaseAPIView):
def get(self, request, slug, project_identifier, issue_identifier):
# Fetch the project
project = Project.objects.get(
identifier__iexact=project_identifier,
workspace__slug=slug,
)
# Check if the user is a member of the project
if not ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project.id,
member=request.user,
is_active=True,
).exists():
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_403_FORBIDDEN,
)
# Fetch the issue
issue = (
Issue.issue_objects.filter(project_id=project.id)
.filter(workspace__slug=slug)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[
:1
]
)
)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(sequence_id=issue_identifier)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=Q(
~Q(labels__id__isnull=True)
& Q(label_issue__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=Q(
~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True)
& Q(issue_assignee__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=Q(
~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("issue", "actor"),
)
)
.prefetch_related(
Prefetch(
"issue_link",
queryset=IssueLink.objects.select_related("created_by"),
)
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
workspace__slug=slug,
project_id=project.id,
issue__sequence_id=issue_identifier,
subscriber=request.user,
)
)
)
).first()
# Check if the issue exists
if not issue:
return Response(
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
"""
if the role is guest and guest_view_all_features is false and owned by is not
the requesting user then dont show the issue
"""
if (
ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project.id,
member=request.user,
role=5,
is_active=True,
).exists()
and not project.guest_view_all_features
and not issue.created_by == request.user
):
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_400_BAD_REQUEST,
)
recent_visited_task.delay(
slug=slug,
entity_name="issue",
entity_identifier=str(issue.id),
user_id=str(request.user.id),
project_id=str(project.id),
)
# Serialize the issue
serializer = IssueDetailSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
+1 -1
View File
@@ -26,7 +26,7 @@ export type TIssueActivityProjectDetail = {
export type TIssueActivityIssueDetail = {
id: string;
sequence_id: boolean;
sequence_id: number;
sort_order: boolean;
name: string;
description_html: string;
@@ -0,0 +1,94 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Briefcase } from "lucide-react";
// ui
import { Breadcrumbs, LayersIcon, Header, Logo } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
import { IssueDetailQuickActions } from "@/components/issues";
// hooks
import { useIssueDetail, useProject } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
export const ProjectIssueDetailsHeader = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug, workItem } = useParams();
// store hooks
const { getProjectById, loader } = useProject();
const {
issue: { getIssueById, getIssueIdByIdentifier },
} = useIssueDetail();
// derived values
const issueId = getIssueIdByIdentifier(workItem?.toString());
const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined;
const projectId = issueDetails ? issueDetails?.project_id : undefined;
const projectDetails = projectId ? getProjectById(projectId?.toString()) : undefined;
if (!workspaceSlug || !projectId || !issueId) return null;
return (
<Header>
<Header.LeftItem>
<div>
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
label={projectDetails?.name ?? "Project"}
icon={
projectDetails ? (
projectDetails && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<Logo logo={projectDetails?.logo_props} size={16} />
</span>
)
) : (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<Briefcase className="h-4 w-4" />
</span>
)
}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href={`/${workspaceSlug}/projects/${projectId}/issues`}
label="Issues"
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
label={
projectDetails && issueDetails ? `${projectDetails.identifier}-${issueDetails.sequence_id}` : ""
}
/>
}
/>
</Breadcrumbs>
</div>
</Header.LeftItem>
<Header.RightItem>
{projectId && issueId && (
<IssueDetailQuickActions
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
issueId={issueId?.toString()}
/>
)}
</Header.RightItem>
</Header>
);
});
@@ -0,0 +1,115 @@
"use client";
import React, { useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { useTheme } from "next-themes";
import useSWR from "swr";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Loader } from "@plane/ui";
// components
import { EmptyState } from "@/components/common";
import { PageHead } from "@/components/core";
import { IssueDetailRoot } from "@/components/issues";
// hooks
import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store";
// assets
import { useAppRouter } from "@/hooks/use-app-router";
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
import emptyIssueDark from "@/public/empty-state/search/issues-dark.webp";
import emptyIssueLight from "@/public/empty-state/search/issues-light.webp";
const IssueDetailsPage = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug, workItem } = useParams();
// hooks
const { resolvedTheme } = useTheme();
// store hooks
const { t } = useTranslation();
const {
fetchIssueWithIdentifier,
issue: { getIssueById },
} = useIssueDetail();
const { getProjectById } = useProject();
const { toggleIssueDetailSidebar, issueDetailSidebarCollapsed } = useAppTheme();
const projectIdentifier = workItem?.toString().split("-")[0];
const sequence_id = workItem?.toString().split("-")[1];
// fetching issue details
const { data, isLoading, error } = useSWR(
workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null,
workspaceSlug && workItem
? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id)
: null
);
const issueId = data?.id;
const projectId = data?.project_id;
// derived values
const issue = getIssueById(issueId?.toString() || "") || undefined;
const project = (issue?.project_id && getProjectById(issue?.project_id)) || undefined;
const issueLoader = !issue || isLoading;
const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined;
useEffect(() => {
const handleToggleIssueDetailSidebar = () => {
if (window && window.innerWidth < 768) {
toggleIssueDetailSidebar(true);
}
if (window && issueDetailSidebarCollapsed && window.innerWidth >= 768) {
toggleIssueDetailSidebar(false);
}
};
window.addEventListener("resize", handleToggleIssueDetailSidebar);
handleToggleIssueDetailSidebar();
return () => window.removeEventListener("resize", handleToggleIssueDetailSidebar);
}, [issueDetailSidebarCollapsed, toggleIssueDetailSidebar]);
return (
<>
<PageHead title={pageTitle} />
{error ? (
<EmptyState
image={resolvedTheme === "dark" ? emptyIssueDark : emptyIssueLight}
title={t("issue.empty_state.issue_detail.title")}
description={t("issue.empty_state.issue_detail.description")}
primaryButton={{
text: t("issue.empty_state.issue_detail.primary_button.text"),
onClick: () => router.push(`/${workspaceSlug}/workspace-views/all-issues/`),
}}
/>
) : issueLoader ? (
<Loader className="flex h-full gap-5 p-5">
<div className="basis-2/3 space-y-2">
<Loader.Item height="30px" width="40%" />
<Loader.Item height="15px" width="60%" />
<Loader.Item height="15px" width="60%" />
<Loader.Item height="15px" width="40%" />
</div>
<div className="basis-1/3 space-y-3">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</div>
</Loader>
) : (
workspaceSlug &&
projectId &&
issueId && (
<ProjectAuthWrapper workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()}>
<IssueDetailRoot
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
issueId={issueId.toString()}
/>
</ProjectAuthWrapper>
)
)}
</>
);
});
export default IssueDetailsPage;
@@ -1,107 +1,43 @@
"use client";
import React, { useEffect } from "react";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { useTheme } from "next-themes";
import useSWR from "swr";
// i18n
import { useTranslation } from "@plane/i18n";
// ui
import { Loader } from "@plane/ui";
// components
import { EmptyState } from "@/components/common";
import { PageHead } from "@/components/core";
import { IssueDetailRoot } from "@/components/issues";
import { LogoSpinner } from "@/components/common";
// hooks
import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store";
// assets
import { useAppRouter } from "@/hooks/use-app-router";
import emptyIssueDark from "@/public/empty-state/search/issues-dark.webp";
import emptyIssueLight from "@/public/empty-state/search/issues-light.webp";
// services
import { IssueService } from "@/services/issue/issue.service";
const issueService = new IssueService();
const IssueDetailsPage = observer(() => {
// i18n
const { t } = useTranslation();
// router
const router = useAppRouter();
const { workspaceSlug, projectId, issueId } = useParams();
// hooks
const { resolvedTheme } = useTheme();
// store hooks
const {
fetchIssue,
issue: { getIssueById },
} = useIssueDetail();
const { getProjectById } = useProject();
const { toggleIssueDetailSidebar, issueDetailSidebarCollapsed } = useAppTheme();
// fetching work item details
const { isLoading, error } = useSWR(
workspaceSlug && projectId && issueId ? `ISSUE_DETAIL_${workspaceSlug}_${projectId}_${issueId}` : null,
workspaceSlug && projectId && issueId
? () => fetchIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString())
: null
);
// derived values
const issue = getIssueById(issueId?.toString() || "") || undefined;
const project = (issue?.project_id && getProjectById(issue?.project_id)) || undefined;
const issueLoader = !issue || isLoading;
const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined;
useEffect(() => {
const handleToggleIssueDetailSidebar = () => {
if (window && window.innerWidth < 768) {
toggleIssueDetailSidebar(true);
}
if (window && issueDetailSidebarCollapsed && window.innerWidth >= 768) {
toggleIssueDetailSidebar(false);
const redirectToBrowseUrl = async () => {
if (!workspaceSlug || !projectId || !issueId) return;
try {
const meta = await issueService.getIssueMetaFromURL(
workspaceSlug.toString(),
projectId.toString(),
issueId.toString()
);
router.push(`/${workspaceSlug}/browse/${meta.project_identifier}-${meta.sequence_id}`);
} catch (error) {
console.error(error);
}
};
window.addEventListener("resize", handleToggleIssueDetailSidebar);
handleToggleIssueDetailSidebar();
return () => window.removeEventListener("resize", handleToggleIssueDetailSidebar);
}, [issueDetailSidebarCollapsed, toggleIssueDetailSidebar]);
redirectToBrowseUrl();
}, [workspaceSlug, projectId, issueId, router]);
return (
<>
<PageHead title={pageTitle} />
{error ? (
<EmptyState
image={resolvedTheme === "dark" ? emptyIssueDark : emptyIssueLight}
title={t("issue.empty_state.issue_detail.title")}
description={t("issue.empty_state.issue_detail.description")}
primaryButton={{
text: t("issue.empty_state.issue_detail.primary_button.text"),
onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/issues`),
}}
/>
) : issueLoader ? (
<Loader className="flex h-full gap-5 p-5">
<div className="basis-2/3 space-y-2">
<Loader.Item height="30px" width="40%" />
<Loader.Item height="15px" width="60%" />
<Loader.Item height="15px" width="60%" />
<Loader.Item height="15px" width="40%" />
</div>
<div className="basis-1/3 space-y-3">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</div>
</Loader>
) : (
workspaceSlug &&
projectId &&
issueId && (
<IssueDetailRoot
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
issueId={issueId.toString()}
/>
)
)}
</>
<div className="flex items-center justify-center size-full">
<LogoSpinner />
</div>
);
});
@@ -1,73 +0,0 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// i18n
import { useTranslation } from "@plane/i18n";
// ui
import { Breadcrumbs, LayersIcon, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
import { IssueDetailQuickActions } from "@/components/issues";
// hooks
import { useIssueDetail, useProject } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
export const ProjectIssueDetailsHeader = observer(() => {
const { t } = useTranslation();
// router
const router = useAppRouter();
const { workspaceSlug, projectId, issueId } = useParams();
// store hooks
const { currentProjectDetails, loader } = useProject();
const {
issue: { getIssueById },
} = useIssueDetail();
// derived values
const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined;
return (
<Header>
<Header.LeftItem>
<div>
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<ProjectBreadcrumb />
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href={`/${workspaceSlug}/projects/${projectId}/issues`}
label={t("issue.label", { count: 2 })} // count is for pluralization
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
label={
currentProjectDetails && issueDetails
? `${currentProjectDetails.identifier}-${issueDetails.sequence_id}`
: ""
}
/>
}
/>
</Breadcrumbs>
</div>
</Header.LeftItem>
<Header.RightItem>
<IssueDetailQuickActions
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
issueId={issueId.toString()}
/>
</Header.RightItem>
</Header>
);
});
@@ -1,11 +1,18 @@
"use client";
import { ReactNode } from "react";
import { useParams } from "next/navigation";
// plane web layouts
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
const ProjectDetailLayout = ({ children }: { children: ReactNode }) => (
<ProjectAuthWrapper>{children}</ProjectAuthWrapper>
);
const ProjectDetailLayout = ({ children }: { children: ReactNode }) => {
// router
const { workspaceSlug, projectId } = useParams();
return (
<ProjectAuthWrapper workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()}>
{children}
</ProjectAuthWrapper>
);
};
export default ProjectDetailLayout;
+8 -2
View File
@@ -4,12 +4,18 @@ import { observer } from "mobx-react";
import { ProjectAuthWrapper as CoreProjectAuthWrapper } from "@/layouts/auth-layout";
export type IProjectAuthWrapper = {
workspaceSlug: string;
projectId: string;
children: React.ReactNode;
};
export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
// props
const { children } = props;
const { workspaceSlug, projectId, children } = props;
return <CoreProjectAuthWrapper>{children}</CoreProjectAuthWrapper>;
return (
<CoreProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
{children}
</CoreProjectAuthWrapper>
);
});
@@ -11,6 +11,8 @@ import {
} from "@plane/types";
// ui
import { ContrastIcon, DiceIcon } from "@plane/ui";
// helpers
import { generateWorkItemLink } from "@/helpers/issue.helper";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
@@ -48,7 +50,13 @@ export const commandGroups: {
</div>
),
path: (issue: IWorkspaceIssueSearchResult) =>
`/${issue?.workspace__slug}/projects/${issue?.project_id}/issues/${issue?.id}`,
generateWorkItemLink({
workspaceSlug: issue?.workspace__slug,
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier: issue.project__identifier,
sequenceId: issue?.sequence_id,
}),
title: "Work items",
},
issue_view: {
+10 -3
View File
@@ -24,6 +24,7 @@ import { IIssueActivity } from "@plane/types";
import { Tooltip, BlockedIcon, BlockerIcon, RelatedIcon, LayersIcon, DiceIcon, Intake } from "@plane/ui";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
import { capitalizeFirstLetter } from "@/helpers/string.helper";
import { useLabel } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
@@ -34,6 +35,14 @@ export const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
const { workspaceSlug } = useParams();
const { isMobile } = usePlatformOS();
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug.toString() ?? activity.workspace_detail?.slug,
projectId: activity?.project,
issueId: activity?.issue,
projectIdentifier: activity?.project_detail?.identifier,
sequenceId: activity?.issue_detail?.sequence_id,
});
return (
<Tooltip
tooltipContent={activity?.issue_detail ? activity.issue_detail.name : "This work item has been deleted"}
@@ -42,9 +51,7 @@ export const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
{activity?.issue_detail ? (
<a
aria-disabled={activity.issue === null}
href={`${`/${workspaceSlug ?? activity.workspace_detail?.slug}/projects/${activity.project}/issues/${
activity.issue
}`}`}
href={workItemLink}
target={activity.issue === null ? "_self" : "_blank"}
rel={activity.issue === null ? "" : "noopener noreferrer"}
className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline"
@@ -10,6 +10,7 @@ import { ISearchIssueResponse, TProjectIssuesSearchParams } from "@plane/types";
// ui
import { Button, Loader, ToggleSwitch, Tooltip, TOAST_TYPE, setToast } from "@plane/ui";
// helpers
import { generateWorkItemLink } from "@/helpers/issue.helper";
import { getTabIndex } from "@/helpers/tab-indices.helper";
// hooks
import useDebounce from "@/hooks/use-debounce";
@@ -274,7 +275,13 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
<span className="truncate">{issue.name}</span>
</div>
<a
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
href={generateWorkItemLink({
workspaceSlug,
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier: issue.project__identifier,
sequenceId: issue?.sequence_id,
})}
target="_blank"
className="z-1 relative hidden flex-shrink-0 text-custom-text-200 hover:text-custom-text-100 group-hover:block"
rel="noopener noreferrer"
@@ -9,6 +9,7 @@ import { Avatar, AvatarGroup, ControlLink, PriorityIcon } from "@plane/ui";
// helpers
import { findTotalDaysInRange, getDate, renderFormattedDate } from "@/helpers/date-time.helper";
import { getFileURL } from "@/helpers/file.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useIssueDetail, useMember, useProject } from "@/hooks/store";
// plane web components
@@ -41,9 +42,17 @@ export const AssignedUpcomingIssueListItem: React.FC<IssueListItemProps> = obser
const targetDate = getDate(issueDetails.target_date);
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issueDetails?.project_id,
issueId: issueDetails?.id,
projectIdentifier: projectDetails?.identifier,
sequenceId: issueDetails?.sequence_id,
});
return (
<ControlLink
href={`/${workspaceSlug}/projects/${issueDetails.project_id}/issues/${issueDetails.id}`}
href={workItemLink}
onClick={() => onClick(issueDetails)}
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
>
@@ -101,9 +110,17 @@ export const AssignedOverdueIssueListItem: React.FC<IssueListItemProps> = observ
const dueBy = findTotalDaysInRange(getDate(issueDetails.target_date), new Date(), false) ?? 0;
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issueDetails?.project_id,
issueId: issueDetails?.id,
projectIdentifier: projectDetails?.identifier,
sequenceId: issueDetails?.sequence_id,
});
return (
<ControlLink
href={`/${workspaceSlug}/projects/${issueDetails.project_id}/issues/${issueDetails.id}`}
href={workItemLink}
onClick={() => onClick(issueDetails)}
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
>
@@ -154,9 +171,17 @@ export const AssignedCompletedIssueListItem: React.FC<IssueListItemProps> = obse
const projectDetails = getProjectById(issueDetails.project_id);
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issueDetails?.project_id,
issueId: issueDetails?.id,
projectIdentifier: projectDetails?.identifier,
sequenceId: issueDetails?.sequence_id,
});
return (
<ControlLink
href={`/${workspaceSlug}/projects/${issueDetails.project_id}/issues/${issueDetails.id}`}
href={workItemLink}
onClick={() => onClick(issueDetails)}
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
>
@@ -193,9 +218,17 @@ export const CreatedUpcomingIssueListItem: React.FC<IssueListItemProps> = observ
const projectDetails = getProjectById(issue.project_id);
const targetDate = getDate(issue.target_date);
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier: projectDetails?.identifier,
sequenceId: issue?.sequence_id,
});
return (
<ControlLink
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
href={workItemLink}
onClick={() => onClick(issue)}
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
>
@@ -253,9 +286,17 @@ export const CreatedOverdueIssueListItem: React.FC<IssueListItemProps> = observe
const dueBy: number = findTotalDaysInRange(getDate(issue.target_date), new Date(), false) ?? 0;
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier: projectDetails?.identifier,
sequenceId: issue?.sequence_id,
});
return (
<ControlLink
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
href={workItemLink}
onClick={() => onClick(issue)}
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
>
@@ -311,9 +352,17 @@ export const CreatedCompletedIssueListItem: React.FC<IssueListItemProps> = obser
const projectDetails = getProjectById(issue.project_id);
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier: projectDetails?.identifier,
sequenceId: issue?.sequence_id,
});
return (
<ControlLink
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
href={workItemLink}
onClick={() => onClick(issue)}
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
>
@@ -7,8 +7,9 @@ import { ListItem } from "@/components/core/list";
import { MemberDropdown } from "@/components/dropdowns";
// helpers
import { calculateTimeAgo } from "@/helpers/date-time.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useIssueDetail, useProjectState } from "@/hooks/store";
import { useIssueDetail, useProject, useProjectState } from "@/hooks/store";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
@@ -22,13 +23,22 @@ export const RecentIssue = (props: BlockProps) => {
// hooks
const { getStateById } = useProjectState();
const { setPeekIssue } = useIssueDetail();
const { getProjectIdentifierById } = useProject();
// derived values
const issueDetails: TIssueEntityData = activity.entity_data as TIssueEntityData;
const projectIdentifier = getProjectIdentifierById(issueDetails?.project_id);
if (!issueDetails) return <></>;
const state = getStateById(issueDetails?.state);
const workItemLink = `/${workspaceSlug}/projects/${issueDetails?.project_id}/issues/${issueDetails.id}`;
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug?.toString(),
projectId: issueDetails?.project_id,
issueId: issueDetails?.id,
projectIdentifier,
sequenceId: issueDetails?.sequence_id,
});
return (
<ListItem
@@ -32,6 +32,7 @@ import { CreateUpdateIssueModal, NameDescriptionUpdateStatus } from "@/component
// helpers
import { findHowManyDaysLeft } from "@/helpers/date-time.helper";
import { EInboxIssueStatus } from "@/helpers/inbox.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useUser, useProjectInbox, useProject, useUserPermissions } from "@/hooks/store";
@@ -104,7 +105,6 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
const currentInboxIssueId = inboxIssue?.issue?.id;
const issueLink = `${workspaceSlug}/projects/${issue?.project_id}/issues/${currentInboxIssueId}`;
const intakeIssueLink = `${workspaceSlug}/projects/${issue?.project_id}/inbox/?currentTab=${currentTab}&inboxIssueId=${currentInboxIssueId}`;
const redirectIssue = (): string | undefined => {
@@ -229,6 +229,14 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
if (!inboxIssue) return null;
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug?.toString(),
projectId: issue?.project_id,
issueId: currentInboxIssueId,
projectIdentifier: currentProjectDetails?.identifier,
sequenceId: issue?.sequence_id,
});
return (
<>
<>
@@ -358,17 +366,11 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
variant="neutral-primary"
prependIcon={<Link className="h-2.5 w-2.5" />}
size="sm"
onClick={() => handleCopyIssueLink(issueLink)}
onClick={() => handleCopyIssueLink(workItemLink)}
>
{t("inbox_issue.actions.copy")}
</Button>
<ControlLink
href={`/${workspaceSlug}/projects/${issue?.project_id}/issues/${currentInboxIssueId}`}
onClick={() =>
router.push(`/${workspaceSlug}/projects/${issue?.project_id}/issues/${currentInboxIssueId}`)
}
target="_self"
>
<ControlLink href={workItemLink} onClick={() => router.push(workItemLink)} target="_self">
<Button variant="neutral-primary" prependIcon={<ExternalLink className="h-2.5 w-2.5" />} size="sm">
{t("inbox_issue.actions.open")}
</Button>
@@ -438,7 +440,7 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
<InboxIssueActionsMobileHeader
inboxIssue={inboxIssue}
isSubmitting={isSubmitting}
handleCopyIssueLink={() => handleCopyIssueLink(issueLink)}
handleCopyIssueLink={() => handleCopyIssueLink(workItemLink)}
setAcceptIssueModal={setAcceptIssueModal}
setDeclineIssueModal={setDeclineIssueModal}
handleIssueSnoozeAction={handleIssueSnoozeAction}
@@ -23,7 +23,9 @@ import { NameDescriptionUpdateStatus } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
import { findHowManyDaysLeft } from "@/helpers/date-time.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useProject } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
// store types
import type { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
@@ -77,6 +79,8 @@ export const InboxIssueActionsMobileHeader: React.FC<Props> = observer((props) =
handleActionWithPermission,
} = props;
const router = useAppRouter();
const { getProjectIdentifierById } = useProject();
const issue = inboxIssue?.issue;
const currentInboxIssueId = issue?.id;
// days left for snooze
@@ -84,6 +88,16 @@ export const InboxIssueActionsMobileHeader: React.FC<Props> = observer((props) =
if (!issue || !inboxIssue) return null;
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug?.toString(),
projectId: issue?.project_id,
issueId: currentInboxIssueId,
projectIdentifier,
sequenceId: issue?.sequence_id,
});
return (
<Header variant={EHeaderVariant.SECONDARY} className="justify-start">
{isNotificationEmbed && (
@@ -132,11 +146,7 @@ export const InboxIssueActionsMobileHeader: React.FC<Props> = observer((props) =
</CustomMenu.MenuItem>
)}
{isAcceptedOrDeclined && (
<CustomMenu.MenuItem
onClick={() =>
router.push(`/${workspaceSlug}/projects/${issue?.project_id}/issues/${currentInboxIssueId}`)
}
>
<CustomMenu.MenuItem onClick={() => router.push(workItemLink)}>
<div className="flex items-center gap-2">
<ExternalLink size={14} strokeWidth={2} />
Open work item
@@ -10,6 +10,7 @@ import { DateDropdown, PriorityDropdown, MemberDropdown, StateDropdown } from "@
import { IssueLabel, TIssueOperations } from "@/components/issues";
// helper
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useProject } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
@@ -34,6 +35,14 @@ export const InboxIssueContentProperties: React.FC<Props> = observer((props) =>
minDate?.setDate(minDate.getDate());
if (!issue || !issue?.id) return <></>;
const duplicateWorkItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug?.toString(),
projectId,
issueId: duplicateIssueDetails?.id,
projectIdentifier: currentProjectDetails?.identifier,
sequenceId: duplicateIssueDetails?.sequence_id,
});
return (
<div className="flex w-full flex-col divide-y-2 divide-custom-border-200">
<div className="w-full overflow-y-auto">
@@ -169,9 +178,9 @@ export const InboxIssueContentProperties: React.FC<Props> = observer((props) =>
</div>
<ControlLink
href={`/${workspaceSlug}/projects/${projectId}/issues/${duplicateIssueDetails?.id}`}
href={duplicateWorkItemLink}
onClick={() => {
router.push(`/${workspaceSlug}/projects/${projectId}/issues/${duplicateIssueDetails?.id}`);
router.push(duplicateWorkItemLink);
}}
target="_self"
>
@@ -2,9 +2,10 @@
import React, { FC, useState } from "react";
import { observer } from "mobx-react";
// helpers
import { generateWorkItemLink } from "@/helpers/issue.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useIssueDetail } from "@/hooks/store";
import { useIssueDetail, useProject } from "@/hooks/store";
type TCreateIssueToastActionItems = {
workspaceSlug: string;
@@ -21,17 +22,26 @@ export const CreateIssueToastActionItems: FC<TCreateIssueToastActionItems> = obs
const {
issue: { getIssueById },
} = useIssueDetail();
const { getProjectIdentifierById } = useProject();
// derived values
const issue = getIssueById(issueId);
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
if (!issue) return null;
const issueLink = `${workspaceSlug}/projects/${projectId}/${isEpic ? "epics" : "issues"}/${issueId}`;
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issue?.project_id,
issueId,
projectIdentifier,
sequenceId: issue?.sequence_id,
isEpic,
});
const copyToClipboard = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
try {
await copyUrlToClipboard(issueLink);
await copyUrlToClipboard(workItemLink, false);
setCopied(true);
setTimeout(() => setCopied(false), 3000);
} catch (error) {
@@ -44,7 +54,7 @@ export const CreateIssueToastActionItems: FC<TCreateIssueToastActionItems> = obs
return (
<div className="flex items-center gap-1 text-xs text-custom-text-200">
<a
href={`/${workspaceSlug}/projects/${projectId}/${isEpic ? "epics" : "issues"}/${issueId}/`}
href={workItemLink}
target="_blank"
rel="noopener noreferrer"
className="text-custom-primary px-2 py-1 hover:bg-custom-background-90 font-medium rounded"
@@ -31,7 +31,7 @@ export const useRelationOperations = (
() => ({
copyText: (text: string) => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${text}`).then(() => {
copyTextToClipboard(`${originURL}${text}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
@@ -3,6 +3,7 @@
import { FC } from "react";
// hooks
import { Tooltip } from "@plane/ui";
import { generateWorkItemLink } from "@/helpers/issue.helper";
import { useIssueDetail } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// ui
@@ -21,6 +22,14 @@ export const IssueLink: FC<TIssueLink> = (props) => {
const activity = getActivityById(activityId);
if (!activity) return <></>;
const workItemLink = generateWorkItemLink({
workspaceSlug: activity.workspace_detail?.slug,
projectId: activity.project,
issueId: activity.issue,
projectIdentifier: activity.project_detail.identifier,
sequenceId: activity.issue_detail.sequence_id,
});
return (
<Tooltip
tooltipContent={activity.issue_detail ? activity.issue_detail.name : "This work item has been deleted"}
@@ -28,11 +37,7 @@ export const IssueLink: FC<TIssueLink> = (props) => {
>
<a
aria-disabled={activity.issue === null}
href={`${
activity.issue_detail
? `/${activity.workspace_detail?.slug}/projects/${activity.project}/issues/${activity.issue}`
: "#"
}`}
href={`${activity.issue_detail ? workItemLink : "#"}`}
target={activity.issue === null ? "_self" : "_blank"}
rel={activity.issue === null ? "" : "noopener noreferrer"}
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
@@ -18,12 +18,14 @@ import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
import { ArchiveIssueModal, DeleteIssueModal, IssueSubscription } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks
import {
useEventTracker,
useIssueDetail,
useIssues,
useProject,
useProjectState,
useUser,
useUserPermissions,
@@ -53,6 +55,7 @@ export const IssueDetailQuickActions: FC<Props> = observer((props) => {
const { allowPermissions } = useUserPermissions();
const { isMobile } = usePlatformOS();
const { getStateById } = useProjectState();
const { getProjectIdentifierById } = useProject();
const {
issue: { getIssueById },
removeIssue,
@@ -72,11 +75,20 @@ export const IssueDetailQuickActions: FC<Props> = observer((props) => {
if (!issue) return <></>;
const stateDetails = getStateById(issue.state_id);
const projectIdentifier = getProjectIdentifierById(projectId);
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug,
projectId,
issueId,
projectIdentifier,
sequenceId: issue?.sequence_id,
});
// handlers
const handleCopyText = () => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`).then(() => {
copyTextToClipboard(`${originURL}${workItemLink}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
@@ -145,7 +157,7 @@ export const IssueDetailQuickActions: FC<Props> = observer((props) => {
title: t("issue.restore.success.title"),
message: t("issue.restore.success.message"),
});
router.push(`/${workspaceSlug}/projects/${projectId}/issues/${issueId}`);
router.push(workItemLink);
})
.catch(() => {
setToast({
@@ -2,14 +2,17 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/navigation";
import { MinusCircle } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { TIssue } from "@plane/types";
// component
// ui
import { ControlLink, CustomMenu } from "@plane/ui";
// helpers
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useIssues, useProjectState } from "@/hooks/store";
import { useIssues, useProject, useProjectState } from "@/hooks/store";
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
@@ -28,14 +31,20 @@ export type TIssueParentDetail = {
export const IssueParentDetail: FC<TIssueParentDetail> = observer((props) => {
const { workspaceSlug, projectId, issueId, issue, issueOperations } = props;
// router
const router = useRouter();
const { t } = useTranslation();
// hooks
const { issueMap } = useIssues();
const { getProjectStates } = useProjectState();
const { handleRedirection } = useIssuePeekOverviewRedirection();
const { isMobile } = usePlatformOS();
const { getProjectIdentifierById } = useProject();
// derived values
const parentIssue = issueMap?.[issue.parent_id || ""] || undefined;
const isParentEpic = parentIssue?.is_epic;
const projectIdentifier = getProjectIdentifierById(parentIssue?.project_id);
const issueParentState = getProjectStates(parentIssue?.project_id)?.find(
(state) => state?.id === parentIssue?.state_id
@@ -44,13 +53,24 @@ export const IssueParentDetail: FC<TIssueParentDetail> = observer((props) => {
if (!parentIssue) return <></>;
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: parentIssue?.project_id,
issueId: parentIssue.id,
projectIdentifier,
sequenceId: parentIssue.sequence_id,
isEpic: isParentEpic,
});
const handleParentIssueClick = () => {
if (isParentEpic) router.push(workItemLink);
else handleRedirection(workspaceSlug, parentIssue, isMobile);
};
return (
<>
<div className="mb-5 flex w-min items-center gap-3 whitespace-nowrap rounded-md border border-custom-border-300 bg-custom-background-80 px-2.5 py-1 text-xs">
<ControlLink
href={`/${workspaceSlug}/projects/${parentIssue?.project_id}/issues/${parentIssue.id}`}
onClick={() => handleRedirection(workspaceSlug, parentIssue, isMobile)}
>
<ControlLink href={workItemLink} onClick={handleParentIssueClick}>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2.5">
<span className="block h-2 w-2 rounded-full" style={{ backgroundColor: stateColor }} />
@@ -5,6 +5,8 @@ import { observer } from "mobx-react";
import Link from "next/link";
// ui
import { CustomMenu } from "@plane/ui";
// helpers
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useIssueDetail, useProject } from "@/hooks/store";
// plane web components
@@ -23,19 +25,24 @@ export const IssueParentSiblingItem: FC<TIssueParentSiblingItem> = observer((pro
issue: { getIssueById },
} = useIssueDetail();
// derived values
const issueDetail = (issueId && getIssueById(issueId)) || undefined;
if (!issueDetail) return <></>;
const projectDetails = (issueDetail.project_id && getProjectById(issueDetail.project_id)) || undefined;
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issueDetail?.project_id,
issueId: issueDetail?.id,
projectIdentifier: projectDetails?.identifier,
sequenceId: issueDetail?.sequence_id,
});
return (
<>
<CustomMenu.MenuItem key={issueDetail.id}>
<Link
href={`/${workspaceSlug}/projects/${issueDetail?.project_id as string}/issues/${issueDetail.id}`}
target="_blank"
className="flex items-center gap-2 py-0.5"
>
<Link href={workItemLink} target="_blank" className="flex items-center gap-2 py-0.5">
{issueDetail.project_id && projectDetails?.identifier && (
<IssueIdentifier
projectId={issueDetail.project_id}
@@ -11,6 +11,7 @@ import { RelatedIcon, Tooltip, TOAST_TYPE, setToast } from "@plane/ui";
import { ExistingIssuesListModal } from "@/components/core";
// helpers
import { cn } from "@/helpers/common.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useIssueDetail, useIssues, useProject } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
@@ -115,7 +116,13 @@ export const IssueRelationSelect: React.FC<TIssueRelationSelect> = observer((pro
>
<Tooltip tooltipHeading="Title" tooltipContent={currentIssue.name} isMobile={isMobile}>
<Link
href={`/${workspaceSlug}/projects/${projectDetails?.id}/issues/${currentIssue.id}`}
href={generateWorkItemLink({
workspaceSlug,
projectId: projectDetails?.id,
issueId: currentIssue.id,
projectIdentifier: projectDetails?.identifier,
sequenceId: currentIssue?.sequence_id,
})}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium"
@@ -332,7 +332,12 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
// issue details
const issue = getIssueById(issueId);
// checking if issue is editable, based on user role
const isEditable = allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT);
const isEditable = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
workspaceSlug,
projectId
);
return (
<>
@@ -13,8 +13,9 @@ import { TIssue } from "@plane/types";
import { Tooltip, ControlLink } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useIssueDetail, useIssues, useProjectState } from "@/hooks/store";
import { useIssueDetail, useIssues, useProject, useProjectState } from "@/hooks/store";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
import { usePlatformOS } from "@/hooks/use-platform-os";
@@ -40,15 +41,17 @@ export const CalendarIssueBlock = observer(
const blockRef = useRef(null);
const menuActionRef = useRef<HTMLDivElement | null>(null);
// hooks
const { workspaceSlug, projectId } = useParams();
const { workspaceSlug } = useParams();
const { getProjectStates } = useProjectState();
const { getIsIssuePeeked } = useIssueDetail();
const { handleRedirection } = useIssuePeekOverviewRedirection(isEpic);
const { isMobile } = usePlatformOS();
const storeType = useIssueStoreType() as CalendarStoreType;
const { issuesFilter } = useIssues(storeType);
const { getProjectIdentifierById } = useProject();
const stateColor = getProjectStates(issue?.project_id)?.find((state) => state?.id == issue?.state_id)?.color || "";
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
// handlers
const handleIssuePeekOverview = (issue: TIssue) => handleRedirection(workspaceSlug.toString(), issue, isMobile);
@@ -72,10 +75,20 @@ export const CalendarIssueBlock = observer(
const placement = isMenuActionRefAboveScreenBottom ? "bottom-end" : "top-end";
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug?.toString(),
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier,
sequenceId: issue?.sequence_id,
isEpic,
isArchived: !!issue?.archived_at,
});
return (
<ControlLink
id={`issue-${issue.id}`}
href={`/${workspaceSlug?.toString()}/projects/${projectId?.toString()}/issues/${issue.id}`}
href={workItemLink}
onClick={() => handleIssuePeekOverview(issue)}
className="block w-full text-sm text-custom-text-100 rounded border-b md:border-[1px] border-custom-border-200 hover:border-custom-border-400"
disabled={!!issue?.tempId || isMobile}
@@ -8,8 +8,9 @@ import { Tooltip, ControlLink } from "@plane/ui";
import { SIDEBAR_WIDTH } from "@/components/gantt-chart/constants";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useIssueDetail, useIssues, useProjectState } from "@/hooks/store";
import { useIssueDetail, useIssues, useProject, useProjectState } from "@/hooks/store";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
import { usePlatformOS } from "@/hooks/use-platform-os";
@@ -90,12 +91,14 @@ export const IssueGanttSidebarBlock: React.FC<Props> = observer((props) => {
const { isMobile } = usePlatformOS();
const storeType = useIssueStoreType() as GanttStoreType;
const { issuesFilter } = useIssues(storeType);
const { getProjectIdentifierById } = useProject();
// handlers
const { handleRedirection } = useIssuePeekOverviewRedirection(isEpic);
// derived values
const issueDetails = getIssueById(issueId);
const projectIdentifier = getProjectIdentifierById(issueDetails?.project_id);
const handleIssuePeekOverview = (e: any) => {
e.stopPropagation(true);
@@ -103,10 +106,19 @@ export const IssueGanttSidebarBlock: React.FC<Props> = observer((props) => {
handleRedirection(workspaceSlug, issueDetails, isMobile);
};
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issueDetails?.project_id,
issueId,
projectIdentifier,
sequenceId: issueDetails?.sequence_id,
isEpic,
});
return (
<ControlLink
id={`issue-${issueId}`}
href={`/${workspaceSlug}/projects/${issueDetails?.project_id}/${isEpic ? "epics" : "issues"}/${issueDetails?.id}`}
href={workItemLink}
onClick={handleIssuePeekOverview}
className="line-clamp-1 w-full cursor-pointer text-sm text-custom-text-100"
disabled={!!issueDetails?.tempId}
@@ -17,8 +17,9 @@ import RenderIfVisible from "@/components/core/render-if-visible-HOC";
import { HIGHLIGHT_CLASS } from "@/components/issues/issue-layouts/utils";
// helpers
import { cn } from "@/helpers/common.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useIssueDetail, useKanbanView } from "@/hooks/store";
import { useIssueDetail, useKanbanView, useProject } from "@/hooks/store";
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
@@ -130,6 +131,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => {
const { workspaceSlug: routerWorkspaceSlug } = useParams();
const workspaceSlug = routerWorkspaceSlug?.toString();
// hooks
const { getProjectIdentifierById } = useProject();
const { getIsIssuePeeked } = useIssueDetail(isEpic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES);
const { handleRedirection } = useIssuePeekOverviewRedirection(isEpic);
const { isMobile } = usePlatformOS();
@@ -147,6 +149,17 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => {
const canEditIssueProperties = canEditProperties(issue?.project_id ?? undefined);
const isDragAllowed = canDragIssuesInCurrentGrouping && !issue?.tempId && canEditIssueProperties;
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issue?.project_id,
issueId,
projectIdentifier,
sequenceId: issue?.sequence_id,
isEpic,
isArchived: !!issue?.archived_at,
});
useOutsideClickDetector(cardRef, () => {
cardRef?.current?.classList?.remove(HIGHLIGHT_CLASS);
@@ -215,9 +228,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => {
>
<ControlLink
id={getIssueBlockId(issueId, groupId, subGroupId)}
href={`/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}${isEpic ? "epics" : "issues"}/${
issue.id
}`}
href={workItemLink}
ref={cardRef}
className={cn(
"block rounded border-[1px] outline-[0.5px] outline-transparent w-full border-custom-border-200 bg-custom-background-100 text-sm transition-all hover:border-custom-border-400",
@@ -16,6 +16,7 @@ import { MultipleSelectEntityAction } from "@/components/core";
import { IssueProperties } from "@/components/issues/issue-layouts/properties";
// helpers
import { cn } from "@/helpers/common.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store";
import { TSelectionHelper } from "@/hooks/use-multiple-select";
@@ -149,10 +150,19 @@ export const IssueBlock = observer((props: IssueBlockProps) => {
//TODO: add better logic. This is to have a min width for ID/Key based on the length of project identifier
const keyMinWidth = displayProperties?.key ? (projectIdentifier?.length ?? 0) * 7 : 0;
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issue?.project_id,
issueId,
projectIdentifier,
sequenceId: issue?.sequence_id,
isEpic,
isArchived: !!issue?.archived_at,
});
return (
<ControlLink
id={`issue-${issue.id}`}
href={`/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}${isEpic ? "epics" : "issues"}/${issue.id}`}
href={workItemLink}
onClick={() => handleIssuePeekOverview(issue)}
className="w-full cursor-pointer"
disabled={!!issue?.tempId || issue?.is_draft}
@@ -27,7 +27,7 @@ import {
// helpers
import { cn } from "@/helpers/common.helper";
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
import { generateWorkItemLink, shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
// hooks
import { useEventTracker, useLabel, useIssues, useProjectState, useProject, useProjectEstimates } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
@@ -247,17 +247,17 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
});
};
const redirectToIssueDetail = () => {
router.push(
`/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}${isEpic ? "epics" : "issues"}/${issue.id}#sub-issues`
);
// router.push({
// pathname: `/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}issues/${
// issue.id
// }`,
// hash: "sub-issues",
// });
};
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug?.toString(),
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier: projectDetails?.identifier,
sequenceId: issue?.sequence_id,
isArchived: !!issue?.archived_at,
isEpic,
});
const redirectToIssueDetail = () => router.push(`${workItemLink}#sub-issues`);
if (!displayProperties || !issue.project_id) return null;
@@ -14,9 +14,10 @@ import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, set
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useEventTracker, useProjectState } from "@/hooks/store";
import { useEventTracker, useProject, useProjectState } from "@/hooks/store";
// types
import { IQuickActionProps } from "../list/list-view-types";
@@ -42,18 +43,26 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
// store hooks
const { setTrackElement } = useEventTracker();
const { getStateById } = useProjectState();
const { getProjectIdentifierById } = useProject();
// derived values
const stateDetails = getStateById(issue.state_id);
const isEditingAllowed = !readOnly;
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
// auth
const isArchivingAllowed = handleArchive && isEditingAllowed;
const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group);
const issueLink = `${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`;
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug.toString(),
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier,
sequenceId: issue?.sequence_id,
});
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
const handleOpenInNewTab = () => window.open(workItemLink, "_blank");
const handleCopyIssueLink = () =>
copyUrlToClipboard(issueLink).then(() =>
copyUrlToClipboard(workItemLink, false).then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link copied",
@@ -14,9 +14,10 @@ import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, set
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useEventTracker, useIssues, useProjectState, useUserPermissions } from "@/hooks/store";
import { useEventTracker, useIssues, useProject, useProjectState, useUserPermissions } from "@/hooks/store";
// types
import { IQuickActionProps } from "../list/list-view-types";
@@ -45,8 +46,10 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
const { issuesFilter } = useIssues(EIssuesStoreType.CYCLE);
const { allowPermissions } = useUserPermissions();
const { getStateById } = useProjectState();
const { getProjectIdentifierById } = useProject();
// derived values
const stateDetails = getStateById(issue.state_id);
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
// auth
const isEditingAllowed =
allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT) && !readOnly;
@@ -56,12 +59,18 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`;
const issueLink = `${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`;
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug.toString(),
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier,
sequenceId: issue?.sequence_id,
});
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
const handleOpenInNewTab = () => window.open(workItemLink, "_blank");
const handleCopyIssueLink = () =>
copyUrlToClipboard(issueLink).then(() =>
copyUrlToClipboard(workItemLink, false).then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link copied",
@@ -14,9 +14,10 @@ import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, set
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useIssues, useEventTracker, useProjectState, useUserPermissions } from "@/hooks/store";
import { useIssues, useEventTracker, useProjectState, useUserPermissions, useProject } from "@/hooks/store";
// types
import { IQuickActionProps } from "../list/list-view-types";
@@ -45,8 +46,10 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
const { issuesFilter } = useIssues(EIssuesStoreType.MODULE);
const { allowPermissions } = useUserPermissions();
const { getStateById } = useProjectState();
const { getProjectIdentifierById } = useProject();
// derived values
const stateDetails = getStateById(issue.state_id);
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
// auth
const isEditingAllowed =
allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT) && !readOnly;
@@ -56,12 +59,18 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`;
const issueLink = `${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`;
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug.toString(),
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier,
sequenceId: issue?.sequence_id,
});
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
const handleOpenInNewTab = () => window.open(workItemLink, "_blank");
const handleCopyIssueLink = () =>
copyUrlToClipboard(issueLink).then(() =>
copyUrlToClipboard(workItemLink, false).then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link copied",
@@ -15,9 +15,10 @@ import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, set
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useEventTracker, useIssues, useProjectState, useUserPermissions } from "@/hooks/store";
import { useEventTracker, useIssues, useProject, useProjectState, useUserPermissions } from "@/hooks/store";
// types
import { IQuickActionProps } from "../list/list-view-types";
@@ -48,9 +49,11 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
const { setTrackElement } = useEventTracker();
const { issuesFilter } = useIssues(EIssuesStoreType.PROJECT);
const { getStateById } = useProjectState();
const { getProjectIdentifierById } = useProject();
// derived values
const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`;
const stateDetails = getStateById(issue.state_id);
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
// auth
const isEditingAllowed =
allowPermissions(
@@ -63,16 +66,23 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group);
const isDeletingAllowed = isEditingAllowed;
const issueLink = `${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`;
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug.toString(),
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier,
sequenceId: issue?.sequence_id,
});
const handleCopyIssueLink = () =>
copyUrlToClipboard(issueLink).then(() =>
copyUrlToClipboard(workItemLink, false).then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link copied",
message: "Work item link copied to clipboard",
})
);
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
const handleOpenInNewTab = () => window.open(workItemLink, "_blank");
const isDraftIssue = pathname?.includes("draft-issues") || false;
@@ -16,6 +16,7 @@ import { MultipleSelectEntityAction } from "@/components/core";
import RenderIfVisible from "@/components/core/render-if-visible-HOC";
// helper
import { cn } from "@/helpers/common.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useIssueDetail, useIssues, useProject } from "@/hooks/store";
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
@@ -231,6 +232,7 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
const disableUserActions = !canEditProperties(issueDetail.project_id ?? undefined);
const subIssuesCount = issueDetail?.sub_issues_count ?? 0;
const isIssueSelected = selectionHelpers.getIsEntitySelected(issueDetail.id);
const projectIdentifier = getProjectIdentifierById(issueDetail.project_id);
const canSelectIssues = !disableUserActions && !selectionHelpers.isSelectionDisabled;
@@ -239,6 +241,15 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
? (getProjectIdentifierById(issueDetail.project_id)?.length ?? 0 + 5) * 7
: 0;
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug?.toString(),
projectId: issueDetail?.project_id,
issueId,
projectIdentifier,
sequenceId: issueDetail?.sequence_id,
isEpic,
});
return (
<>
<td
@@ -248,7 +259,7 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
className="relative md:sticky left-0 z-10 group/list-block bg-custom-background-100"
>
<ControlLink
href={`/${workspaceSlug}/projects/${issueDetail.project_id}/${isEpic ? "epics" : "issues"}/${issueId}`}
href={workItemLink}
onClick={() => handleIssuePeekOverview(issueDetail)}
className={cn(
"group clickable cursor-pointer h-11 w-[28rem] flex items-center text-sm after:absolute border-r-[0.5px] z-10 border-custom-border-200 bg-transparent group-[.selected-issue-row]:bg-custom-primary-100/5 group-[.selected-issue-row]:hover:bg-custom-primary-100/10",
@@ -15,6 +15,7 @@ import { Loader } from "@plane/ui";
// components
import { IssueSearchModalEmptyState } from "@/components/core";
// helpers
import { generateWorkItemLink } from "@/helpers/issue.helper";
import { getTabIndex } from "@/helpers/tab-indices.helper";
// hooks
import useDebounce from "@/hooks/use-debounce";
@@ -197,7 +198,13 @@ export const ParentIssuesListModal: React.FC<Props> = ({
<span className="truncate">{issue.name}</span>
</div>
<a
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
href={generateWorkItemLink({
workspaceSlug: workspaceSlug.toString(),
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier: issue.project__identifier,
sequenceId: issue?.sequence_id,
})}
target="_blank"
className="z-1 relative hidden flex-shrink-0 text-custom-text-200 hover:text-custom-text-100 group-hover:block"
rel="noopener noreferrer"
@@ -25,9 +25,10 @@ import {
import { IssueSubscription, NameDescriptionUpdateStatus } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// store hooks
import { useIssueDetail, useProjectState, useUser } from "@/hooks/store";
import { useIssueDetail, useProject, useProjectState, useUser } from "@/hooks/store";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
export type TPeekModes = "side-peek" | "modal" | "full-screen";
@@ -90,17 +91,26 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
} = useIssueDetail();
const { getStateById } = useProjectState();
const { isMobile } = usePlatformOS();
const { getProjectIdentifierById } = useProject();
// derived values
const issueDetails = getIssueById(issueId);
const stateDetails = issueDetails ? getStateById(issueDetails?.state_id) : undefined;
const currentMode = PEEK_OPTIONS.find((m) => m.key === peekMode);
const projectIdentifier = getProjectIdentifierById(issueDetails?.project_id);
const issueLink = `${workspaceSlug}/projects/${projectId}/${isArchived ? "archives/" : ""}issues/${issueId}`;
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issueDetails?.project_id,
issueId,
projectIdentifier,
sequenceId: issueDetails?.sequence_id,
isArchived,
});
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
copyUrlToClipboard(issueLink).then(() => {
copyUrlToClipboard(workItemLink, false).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
@@ -127,7 +137,7 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
</Tooltip>
<Tooltip tooltipContent={t("issue.open_in_full_screen")} isMobile={isMobile}>
<Link href={`/${issueLink}`} onClick={() => removeRoutePeekId()}>
<Link href={workItemLink} onClick={() => removeRoutePeekId()}>
<MoveDiagonal className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</Link>
</Tooltip>
@@ -10,6 +10,8 @@ import { TIssue, TIssueServiceType } from "@plane/types";
import { ControlLink, CustomMenu, Tooltip } from "@plane/ui";
// components
import { RelationIssueProperty } from "@/components/issues/relations";
// helpers
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useIssueDetail, useProject, useProjectState } from "@/hooks/store";
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
@@ -63,13 +65,21 @@ export const RelationIssueListItem: FC<Props> = observer((props) => {
(issue?.project_id && getProjectStates(issue?.project_id)?.find((state) => issue?.state_id == state.id)) ||
undefined;
if (!issue || !projectId) return <></>;
const issueLink = `/${workspaceSlug}/projects/${projectId}/${issue.is_epic ? "epics" : "issues"}/${issue.id}`;
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug.toString(),
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier: projectDetail?.identifier,
sequenceId: issue?.sequence_id,
isEpic: issue?.is_epic,
});
// handlers
const handleIssuePeekOverview = (issue: TIssue) => {
if (issue.is_epic) {
// open epics in new tab
window.open(issueLink, "_blank");
window.open(workItemLink, "_blank");
return;
}
handleRedirection(workspaceSlug, issue, isMobile);
@@ -92,7 +102,7 @@ export const RelationIssueListItem: FC<Props> = observer((props) => {
const handleCopyIssueLink = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
issueOperations.copyText(issueLink);
issueOperations.copyText(workItemLink);
};
const handleRemoveRelation = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
@@ -105,7 +115,7 @@ export const RelationIssueListItem: FC<Props> = observer((props) => {
<div key={relationIssueId}>
<ControlLink
id={`issue-${issue.id}`}
href={issueLink}
href={workItemLink}
onClick={() => handleIssuePeekOverview(issue)}
className="w-full cursor-pointer"
>
@@ -10,6 +10,7 @@ import { TIssue, TIssueServiceType } from "@plane/types";
import { ControlLink, CustomMenu, Tooltip } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useIssueDetail, useProject, useProjectState } from "@/hooks/store";
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
@@ -85,11 +86,19 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
// check if current issue is the root issue
const isCurrentIssueRoot = issueId === rootIssueId;
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier: projectDetail?.identifier,
sequenceId: issue?.sequence_id,
});
return (
<div key={issueId}>
<ControlLink
id={`issue-${issue.id}`}
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
href={workItemLink}
onClick={() => handleIssuePeekOverview(issue)}
className="w-full cursor-pointer"
>
@@ -194,7 +203,7 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
subIssueOperations.copyText(`${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`);
subIssueOperations.copyText(workItemLink);
}}
>
<div className="flex items-center gap-2">
@@ -135,7 +135,7 @@ export const SubIssuesRoot: FC<ISubIssuesRoot> = observer((props) => {
() => ({
copyText: (text: string) => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${text}`).then(() => {
copyTextToClipboard(`${originURL}${text}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
@@ -153,7 +153,11 @@ export const ProjectNavigation: FC<TProjectItemsProps> = observer((props) => {
<Link href={item.href} onClick={handleProjectClick}>
<SidebarNavItem
className={`pl-[18px] ${isSidebarCollapsed ? "p-0 size-7 justify-center mx-auto" : ""}`}
isActive={pathname.includes(item.href)}
isActive={
item.key === "issues"
? pathname.includes(item.href) || pathname.includes(`/${workspaceSlug}/browse/`)
: pathname.includes(item.href)
}
>
<div className="flex items-center gap-1.5 py-[1px]">
<item.icon
@@ -3,8 +3,10 @@ import { useRouter } from "next/navigation";
import { EIssueServiceType } from "@plane/constants";
// types
import { TIssue } from "@plane/types";
// helpers
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useIssueDetail } from "./store";
import { useIssueDetail, useProject } from "./store";
const useIssuePeekOverviewRedirection = (isEpic: boolean = false) => {
// router
@@ -13,6 +15,7 @@ const useIssuePeekOverviewRedirection = (isEpic: boolean = false) => {
const { getIsIssuePeeked, setPeekIssue } = useIssueDetail(
isEpic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES
);
const { getProjectIdentifierById } = useProject();
const handleRedirection = (
workspaceSlug: string | undefined,
@@ -22,12 +25,20 @@ const useIssuePeekOverviewRedirection = (isEpic: boolean = false) => {
) => {
if (!issue) return;
const { project_id, id, archived_at, tempId } = issue;
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: project_id,
issueId: id,
projectIdentifier,
sequenceId: issue?.sequence_id,
isEpic,
isArchived: !!archived_at,
});
if (workspaceSlug && project_id && id && !getIsIssuePeeked(id) && !tempId) {
const issuePath = `/${workspaceSlug}/projects/${project_id}/${archived_at ? "archives/" : ""}${isEpic ? "epics" : "issues"}/${id}`;
if (isMobile) {
router.push(issuePath);
router.push(workItemLink);
} else {
setPeekIssue({ workspaceSlug, projectId: project_id, issueId: id, nestingLevel, isArchived: !!archived_at });
}
@@ -2,7 +2,6 @@
import { FC, ReactNode, useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
@@ -33,14 +32,14 @@ import { persistence } from "@/local-db/storage.sqlite";
// plane web constants
interface IProjectAuthWrapper {
workspaceSlug: string;
projectId: string;
children: ReactNode;
isLoading?: boolean;
}
export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
const { children, isLoading: isParentLoading = false } = props;
// router
const { workspaceSlug, projectId } = useParams();
const { workspaceSlug, projectId, children, isLoading: isParentLoading = false } = props;
// plane hooks
const { t } = useTranslation();
// store hooks
+40
View File
@@ -439,4 +439,44 @@ export class IssueService extends APIService {
throw error?.response?.data;
});
}
async getIssueMetaFromURL(
workspaceSlug: string,
projectId: string,
issueId: string
): Promise<{
project_identifier: string;
sequence_id: string;
}> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/meta/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async retrieveWithIdentifier(
workspaceSlug: string,
project_identifier: string,
issue_sequence: string,
queries?: any
): Promise<TIssue> {
return this.get(`/api/workspaces/${workspaceSlug}/work-items/${project_identifier}-${issue_sequence}/`, {
params: queries,
})
.then((response) => {
// skip issue update when the service type is epic
if (response.data && this.serviceType === EIssueServiceType.ISSUES) {
updateIssue({ ...response.data, is_local_update: 1 });
}
// add is_epic flag when the service type is epic
if (response.data && this.serviceType === EIssueServiceType.EPICS) {
response.data.is_epic = true;
}
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
}
@@ -32,6 +32,7 @@ export interface IIssueStoreActions {
removeModuleIds: string[]
) => Promise<void>;
removeIssueFromModule: (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => Promise<void>;
fetchIssueWithIdentifier: (workspaceSlug: string, project_identifier: string, sequence_id: string) => Promise<TIssue>;
}
export interface IIssueStore extends IIssueStoreActions {
@@ -39,6 +40,7 @@ export interface IIssueStore extends IIssueStoreActions {
getIsLocalDBIssueDescription: (issueId: string | undefined) => boolean;
// helper methods
getIssueById: (issueId: string) => TIssue | undefined;
getIssueIdByIdentifier: (issueIdentifier: string) => string | undefined;
}
export class IssueStore implements IIssueStore {
@@ -86,6 +88,11 @@ export class IssueStore implements IIssueStore {
return this.rootIssueDetailStore.rootIssueStore.issues.getIssueById(issueId) ?? undefined;
});
getIssueIdByIdentifier = computedFn((issueIdentifier: string) => {
if (!issueIdentifier) return undefined;
return this.rootIssueDetailStore.rootIssueStore.issues.getIssueIdByIdentifier(issueIdentifier) ?? undefined;
});
// actions
fetchIssue = async (workspaceSlug: string, projectId: string, issueId: string, issueStatus = "DEFAULT") => {
const query = {
@@ -285,4 +292,65 @@ export class IssueStore implements IIssueStore {
await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId);
return currentModule;
};
fetchIssueWithIdentifier = async (workspaceSlug: string, project_identifier: string, sequence_id: string) => {
const query = {
expand: "issue_reactions,issue_attachments,issue_link,parent",
};
const issue = await this.issueService.retrieveWithIdentifier(workspaceSlug, project_identifier, sequence_id, query);
const issueIdentifier = `${project_identifier}-${sequence_id}`;
const issueId = issue?.id;
const projectId = issue?.project_id;
if (!issue || !projectId || !issueId) throw new Error("Issue not found");
const issuePayload = this.addIssueToStore(issue);
this.rootIssueDetailStore.rootIssueStore.issues.addIssue([issuePayload]);
// handle parent issue if exists
if (issue?.parent && issue?.parent?.id && issue?.parent?.project_id) {
this.issueService.retrieve(workspaceSlug, issue.parent.project_id, issue.parent.id).then((res) => {
this.rootIssueDetailStore.rootIssueStore.issues.addIssue([res]);
});
}
// add identifiers to map
this.rootIssueDetailStore.rootIssueStore.issues.addIssueIdentifier(issueIdentifier, issueId);
// add related data
if (issue.issue_reactions) this.rootIssueDetailStore.addReactions(issue.id, issue.issue_reactions);
if (issue.issue_link) this.rootIssueDetailStore.addLinks(issue.id, issue.issue_link);
if (issue.issue_attachments) this.rootIssueDetailStore.addAttachments(issue.id, issue.issue_attachments);
this.rootIssueDetailStore.addSubscription(issue.id, issue.is_subscribed);
// fetch related data
// issue reactions
if (issue.issue_reactions) this.rootIssueDetailStore.addReactions(issueId, issue.issue_reactions);
// fetch issue links
if (issue.issue_link) this.rootIssueDetailStore.addLinks(issueId, issue.issue_link);
// fetch issue attachments
if (issue.issue_attachments) this.rootIssueDetailStore.addAttachments(issueId, issue.issue_attachments);
this.rootIssueDetailStore.addSubscription(issueId, issue.is_subscribed);
// fetch issue activity
this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId);
// fetch issue comments
this.rootIssueDetailStore.comment.fetchComments(workspaceSlug, projectId, issueId);
// fetch sub issues
this.rootIssueDetailStore.subIssues.fetchSubIssues(workspaceSlug, projectId, issueId);
// fetch issue relations
this.rootIssueDetailStore.relation.fetchRelations(workspaceSlug, projectId, issueId);
// fetching states
// TODO: check if this function is required
this.rootIssueDetailStore.rootIssueStore.rootStore.state.fetchProjectStates(workspaceSlug, projectId);
return issue;
};
}
@@ -259,6 +259,8 @@ export class IssueDetail implements IIssueDetail {
issueId: string,
issueStatus: "DEFAULT" | "DRAFT" = "DEFAULT"
) => this.issue.fetchIssue(workspaceSlug, projectId, issueId, issueStatus);
fetchIssueWithIdentifier = async (workspaceSlug: string, projectIdentifier: string, sequenceId: string) =>
this.issue.fetchIssueWithIdentifier(workspaceSlug, projectIdentifier, sequenceId);
updateIssue = async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) =>
this.issue.updateIssue(workspaceSlug, projectId, issueId, data);
removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) =>
+29
View File
@@ -15,19 +15,23 @@ import { IssueService } from "@/services/issue";
export type IIssueStore = {
// observables
issuesMap: Record<string, TIssue>; // Record defines issue_id as key and TIssue as value
issuesIdentifierMap: Record<string, string>; // Record defines issue_identifier as key and issue_id as value
// actions
getIssues(workspaceSlug: string, projectId: string, issueIds: string[]): Promise<TIssue[]>;
addIssue(issues: TIssue[]): void;
addIssueIdentifier(issueIdentifier: string, issueId: string): void;
updateIssue(issueId: string, issue: Partial<TIssue>): void;
removeIssue(issueId: string): void;
// helper methods
getIssueById(issueId: string): undefined | TIssue;
getIssueIdByIdentifier(issueIdentifier: string): undefined | string;
getIssuesByIds(issueIds: string[], type: "archived" | "un-archived"): TIssue[]; // Record defines issue_id as key and TIssue as value
};
export class IssueStore implements IIssueStore {
// observables
issuesMap: { [issue_id: string]: TIssue } = {};
issuesIdentifierMap: { [issue_identifier: string]: string } = {};
// service
issueService;
@@ -35,8 +39,10 @@ export class IssueStore implements IIssueStore {
makeObservable(this, {
// observable
issuesMap: observable,
issuesIdentifierMap: observable,
// actions
addIssue: action,
addIssueIdentifier: action,
updateIssue: action,
removeIssue: action,
});
@@ -59,6 +65,19 @@ export class IssueStore implements IIssueStore {
});
};
/**
* @description This method will add issue_identifier to the issuesIdentifierMap
* @param issueIdentifier
* @param issueId
* @returns {void}
*/
addIssueIdentifier = (issueIdentifier: string, issueId: string) => {
if (!issueIdentifier || !issueId) return;
runInAction(() => {
set(this.issuesIdentifierMap, issueIdentifier, issueId);
});
};
getIssues = async (workspaceSlug: string, projectId: string, issueIds: string[]) => {
const issues = await this.issueService.retrieveIssues(workspaceSlug, projectId, issueIds);
@@ -116,6 +135,16 @@ export class IssueStore implements IIssueStore {
return this.issuesMap[issueId];
});
/**
* @description This method will return the issue_id from the issuesIdentifierMap
* @param {string} issueIdentifier
* @returns {string | undefined}
*/
getIssueIdByIdentifier = computedFn((issueIdentifier: string) => {
if (!issueIdentifier || !this.issuesIdentifierMap[issueIdentifier]) return undefined;
return this.issuesIdentifierMap[issueIdentifier];
});
/**
* @description This method will return the issues from the issuesMap
* @param {string[]} issueIds
+24
View File
@@ -322,3 +322,27 @@ export const getIssuesShouldFallbackToServer = (queries: any) => {
return false;
};
export const generateWorkItemLink = ({
workspaceSlug,
projectId,
issueId,
projectIdentifier,
sequenceId,
isArchived = false,
isEpic = false,
}: {
workspaceSlug: string | undefined | null;
projectId: string | undefined | null;
issueId: string | undefined | null;
projectIdentifier: string | undefined | null;
sequenceId: string | number | undefined | null;
isArchived?: boolean;
isEpic?: boolean;
}): string => {
const archiveIssueLink = `/${workspaceSlug}/projects/${projectId}/archives/issues/${issueId}`;
const epicLink = `/${workspaceSlug}/projects/${projectId}/epics/${issueId}`;
const workItemLink = `/${workspaceSlug}/browse/${projectIdentifier}-${sequenceId}/`;
return isArchived ? archiveIssueLink : isEpic ? epicLink : workItemLink;
};
+3 -2
View File
@@ -68,14 +68,15 @@ export const copyTextToClipboard = async (text: string) => {
/**
* @description: This function copies the url to clipboard after prepending the origin URL to it
* @param {string} path
* @param {boolean} addSlash
* @example:
* const text = copyUrlToClipboard("path");
* copied URL: origin_url/path
*/
export const copyUrlToClipboard = async (path: string) => {
export const copyUrlToClipboard = async (path: string, addSlash: boolean = true) => {
const originUrl = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
await copyTextToClipboard(`${originUrl}/${path}`);
await copyTextToClipboard(`${originUrl}${addSlash ? "/" : ""}${path}`);
};
export const generateRandomColor = (string: string): string => {