mirror of
https://github.com/makeplane/plane.git
synced 2026-04-30 13:09:36 -05:00
[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:
committed by
GitHub
parent
82eea3e802
commit
4353cc0c4a
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -116,6 +116,8 @@ from .issue.base import (
|
||||
IssuePaginatedViewSet,
|
||||
IssueDetailEndpoint,
|
||||
IssueBulkUpdateDateEndpoint,
|
||||
IssueMetaEndpoint,
|
||||
IssueDetailIdentifierEndpoint,
|
||||
)
|
||||
|
||||
from .issue.activity import IssueActivityEndpoint
|
||||
|
||||
@@ -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
@@ -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;
|
||||
+23
-87
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
-73
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
+10
-5
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user