diff --git a/apiserver/plane/app/urls/issue.py b/apiserver/plane/app/urls/issue.py index f09370d7d16..6c5e450331f 100644 --- a/apiserver/plane/app/urls/issue.py +++ b/apiserver/plane/app/urls/issue.py @@ -26,6 +26,8 @@ IssueBulkUpdateDateEndpoint, IssueVersionEndpoint, IssueDescriptionVersionEndpoint, + IssueMetaEndpoint, + IssueDetailIdentifierEndpoint, ) urlpatterns = [ @@ -278,4 +280,14 @@ IssueDescriptionVersionEndpoint.as_view(), name="page-versions", ), + path( + "workspaces//projects//issues//meta/", + IssueMetaEndpoint.as_view(), + name="issue-meta", + ), + path( + "workspaces//work-items/-/", + IssueDetailIdentifierEndpoint.as_view(), + name="issue-detail-identifier", + ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 04162efd9b7..684179d9045 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -116,6 +116,8 @@ IssuePaginatedViewSet, IssueDetailEndpoint, IssueBulkUpdateDateEndpoint, + IssueMetaEndpoint, + IssueDetailIdentifierEndpoint, ) from .issue.activity import IssueActivityEndpoint diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py index c37a3cad12f..fbd4d7c504f 100644 --- a/apiserver/plane/app/views/issue/base.py +++ b/apiserver/plane/app/views/issue/base.py @@ -1096,3 +1096,178 @@ def post(self, request, slug, project_id): 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) diff --git a/packages/types/src/issues/activity/base.d.ts b/packages/types/src/issues/activity/base.d.ts index 63f365d893c..7b5653aede9 100644 --- a/packages/types/src/issues/activity/base.d.ts +++ b/packages/types/src/issues/activity/base.d.ts @@ -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; diff --git a/web/app/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx b/web/app/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx new file mode 100644 index 00000000000..bf19d8f0de2 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx @@ -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 ( +
+ +
+ + + + + ) + ) : ( + + + + ) + } + /> + } + /> + + } + /> + } + /> + + + } + /> + +
+
+ + {projectId && issueId && ( + + )} + +
+ ); +}); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/layout.tsx b/web/app/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/layout.tsx rename to web/app/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx b/web/app/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx new file mode 100644 index 00000000000..e315f354970 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx @@ -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 ( + <> + + {error ? ( + router.push(`/${workspaceSlug}/workspace-views/all-issues/`), + }} + /> + ) : issueLoader ? ( + +
+ + + + +
+
+ + + + +
+
+ ) : ( + workspaceSlug && + projectId && + issueId && ( + + + + ) + )} + + ); +}); + +export default IssueDetailsPage; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx index ffc50ccb472..0ad3aca950d 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx @@ -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 ( - <> - - {error ? ( - router.push(`/${workspaceSlug}/projects/${projectId}/issues`), - }} - /> - ) : issueLoader ? ( - -
- - - - -
-
- - - - -
-
- ) : ( - workspaceSlug && - projectId && - issueId && ( - - ) - )} - +
+ +
); }); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/header.tsx deleted file mode 100644 index c737e151847..00000000000 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/header.tsx +++ /dev/null @@ -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 ( -
- -
- - - - } - /> - } - /> - - - } - /> - -
-
- - - -
- ); -}); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx index cdd4f708077..8e1d194356a 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx @@ -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 }) => ( - {children} -); +const ProjectDetailLayout = ({ children }: { children: ReactNode }) => { + // router + const { workspaceSlug, projectId } = useParams(); + return ( + + {children} + + ); +}; export default ProjectDetailLayout; diff --git a/web/ce/layouts/project-wrapper.tsx b/web/ce/layouts/project-wrapper.tsx index a9223210994..585ed567bf3 100644 --- a/web/ce/layouts/project-wrapper.tsx +++ b/web/ce/layouts/project-wrapper.tsx @@ -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 = observer((props) => { // props - const { children } = props; + const { workspaceSlug, projectId, children } = props; - return {children}; + return ( + + {children} + + ); }); diff --git a/web/core/components/command-palette/helpers.tsx b/web/core/components/command-palette/helpers.tsx index b6310840bee..05885ebbd1d 100644 --- a/web/core/components/command-palette/helpers.tsx +++ b/web/core/components/command-palette/helpers.tsx @@ -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: { ), 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: { diff --git a/web/core/components/core/activity.tsx b/web/core/components/core/activity.tsx index e912943da37..3e90637225a 100644 --- a/web/core/components/core/activity.tsx +++ b/web/core/components/core/activity.tsx @@ -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 ( { {activity?.issue_detail ? ( = (props) => { {issue.name} = 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 ( 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 = 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 ( 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 = 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 ( 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 = 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 ( 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 = 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 ( 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 = 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 ( onClick(issue)} className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80" > diff --git a/web/core/components/home/widgets/recents/issue.tsx b/web/core/components/home/widgets/recents/issue.tsx index 0c3f9ac6a1b..63675da80f1 100644 --- a/web/core/components/home/widgets/recents/issue.tsx +++ b/web/core/components/home/widgets/recents/issue.tsx @@ -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 ( = 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 = 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 = observer((p variant="neutral-primary" prependIcon={} size="sm" - onClick={() => handleCopyIssueLink(issueLink)} + onClick={() => handleCopyIssueLink(workItemLink)} > {t("inbox_issue.actions.copy")} - - router.push(`/${workspaceSlug}/projects/${issue?.project_id}/issues/${currentInboxIssueId}`) - } - target="_self" - > + router.push(workItemLink)} target="_self"> @@ -438,7 +440,7 @@ export const InboxIssueActionsHeader: FC = observer((p handleCopyIssueLink(issueLink)} + handleCopyIssueLink={() => handleCopyIssueLink(workItemLink)} setAcceptIssueModal={setAcceptIssueModal} setDeclineIssueModal={setDeclineIssueModal} handleIssueSnoozeAction={handleIssueSnoozeAction} diff --git a/web/core/components/inbox/content/inbox-issue-mobile-header.tsx b/web/core/components/inbox/content/inbox-issue-mobile-header.tsx index d157c1a392a..93b82358719 100644 --- a/web/core/components/inbox/content/inbox-issue-mobile-header.tsx +++ b/web/core/components/inbox/content/inbox-issue-mobile-header.tsx @@ -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 = 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 = 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 (
{isNotificationEmbed && ( @@ -132,11 +146,7 @@ export const InboxIssueActionsMobileHeader: React.FC = observer((props) = )} {isAcceptedOrDeclined && ( - - router.push(`/${workspaceSlug}/projects/${issue?.project_id}/issues/${currentInboxIssueId}`) - } - > + router.push(workItemLink)}>
Open work item diff --git a/web/core/components/inbox/content/issue-properties.tsx b/web/core/components/inbox/content/issue-properties.tsx index db7ad8e4189..e189df9fa5c 100644 --- a/web/core/components/inbox/content/issue-properties.tsx +++ b/web/core/components/inbox/content/issue-properties.tsx @@ -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 = 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 (
@@ -169,9 +178,9 @@ export const InboxIssueContentProperties: React.FC = observer((props) =>
{ - router.push(`/${workspaceSlug}/projects/${projectId}/issues/${duplicateIssueDetails?.id}`); + router.push(duplicateWorkItemLink); }} target="_self" > diff --git a/web/core/components/issues/create-issue-toast-action-items.tsx b/web/core/components/issues/create-issue-toast-action-items.tsx index 9f29278020d..16f2a2d7576 100644 --- a/web/core/components/issues/create-issue-toast-action-items.tsx +++ b/web/core/components/issues/create-issue-toast-action-items.tsx @@ -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 = 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) => { try { - await copyUrlToClipboard(issueLink); + await copyUrlToClipboard(workItemLink, false); setCopied(true); setTimeout(() => setCopied(false), 3000); } catch (error) { @@ -44,7 +54,7 @@ export const CreateIssueToastActionItems: FC = obs return (
({ 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"), diff --git a/web/core/components/issues/issue-detail/issue-activity/activity/actions/helpers/issue-link.tsx b/web/core/components/issues/issue-detail/issue-activity/activity/actions/helpers/issue-link.tsx index 224f13bde25..661b513ecf6 100644 --- a/web/core/components/issues/issue-detail/issue-activity/activity/actions/helpers/issue-link.tsx +++ b/web/core/components/issues/issue-detail/issue-activity/activity/actions/helpers/issue-link.tsx @@ -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 = (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 ( = (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 = 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 = 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({ diff --git a/web/core/components/issues/issue-detail/parent/root.tsx b/web/core/components/issues/issue-detail/parent/root.tsx index 26401ccec19..66345b4aef8 100644 --- a/web/core/components/issues/issue-detail/parent/root.tsx +++ b/web/core/components/issues/issue-detail/parent/root.tsx @@ -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 = 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 = 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 ( <>
- handleRedirection(workspaceSlug, parentIssue, isMobile)} - > +
diff --git a/web/core/components/issues/issue-detail/parent/sibling-item.tsx b/web/core/components/issues/issue-detail/parent/sibling-item.tsx index 8fb9bbd9a29..2947e49dfa6 100644 --- a/web/core/components/issues/issue-detail/parent/sibling-item.tsx +++ b/web/core/components/issues/issue-detail/parent/sibling-item.tsx @@ -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 = 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 ( <> - + {issueDetail.project_id && projectDetails?.identifier && ( = observer((pro > = 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 ( <> diff --git a/web/core/components/issues/issue-layouts/calendar/issue-block.tsx b/web/core/components/issues/issue-layouts/calendar/issue-block.tsx index 8a03ef4c840..127ebfb475e 100644 --- a/web/core/components/issues/issue-layouts/calendar/issue-block.tsx +++ b/web/core/components/issues/issue-layouts/calendar/issue-block.tsx @@ -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(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 ( 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} diff --git a/web/core/components/issues/issue-layouts/gantt/blocks.tsx b/web/core/components/issues/issue-layouts/gantt/blocks.tsx index bd25dc916ed..f74e42232b1 100644 --- a/web/core/components/issues/issue-layouts/gantt/blocks.tsx +++ b/web/core/components/issues/issue-layouts/gantt/blocks.tsx @@ -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 = 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 = observer((props) => { handleRedirection(workspaceSlug, issueDetails, isMobile); }; + const workItemLink = generateWorkItemLink({ + workspaceSlug, + projectId: issueDetails?.project_id, + issueId, + projectIdentifier, + sequenceId: issueDetails?.sequence_id, + isEpic, + }); + return ( = 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 = 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 = observer((props) => { > { //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 ( handleIssuePeekOverview(issue)} className="w-full cursor-pointer" disabled={!!issue?.tempId || issue?.is_draft} diff --git a/web/core/components/issues/issue-layouts/properties/all-properties.tsx b/web/core/components/issues/issue-layouts/properties/all-properties.tsx index f3759c9308c..bd5b5839dd4 100644 --- a/web/core/components/issues/issue-layouts/properties/all-properties.tsx +++ b/web/core/components/issues/issue-layouts/properties/all-properties.tsx @@ -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 = 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; diff --git a/web/core/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx b/web/core/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx index 32c62fac8d4..92c5b14d5bb 100644 --- a/web/core/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx +++ b/web/core/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx @@ -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 = 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", diff --git a/web/core/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx b/web/core/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx index 15ebccc1f06..6886f8ce822 100644 --- a/web/core/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx +++ b/web/core/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx @@ -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 = 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 = 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", diff --git a/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx b/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx index 14de0874704..c224e2edb8c 100644 --- a/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx +++ b/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx @@ -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 = 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 = 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", diff --git a/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx b/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx index 58d517d4bb0..8112a723223 100644 --- a/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx +++ b/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx @@ -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 = 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 = 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; diff --git a/web/core/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/web/core/components/issues/issue-layouts/spreadsheet/issue-row.tsx index b801904c580..0c87dfae5b2 100644 --- a/web/core/components/issues/issue-layouts/spreadsheet/issue-row.tsx +++ b/web/core/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -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 ( <> { className="relative md:sticky left-0 z-10 group/list-block bg-custom-background-100" > 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", diff --git a/web/core/components/issues/parent-issues-list-modal.tsx b/web/core/components/issues/parent-issues-list-modal.tsx index 30a10df6bae..e289caf2a53 100644 --- a/web/core/components/issues/parent-issues-list-modal.tsx +++ b/web/core/components/issues/parent-issues-list-modal.tsx @@ -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 = ({ {issue.name}
= 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) => { 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 = observer((pr - removeRoutePeekId()}> + removeRoutePeekId()}> diff --git a/web/core/components/issues/relations/issue-list-item.tsx b/web/core/components/issues/relations/issue-list-item.tsx index 4860e315dbc..c90d8c1a896 100644 --- a/web/core/components/issues/relations/issue-list-item.tsx +++ b/web/core/components/issues/relations/issue-list-item.tsx @@ -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 = 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 = observer((props) => { const handleCopyIssueLink = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); - issueOperations.copyText(issueLink); + issueOperations.copyText(workItemLink); }; const handleRemoveRelation = (e: React.MouseEvent) => { @@ -105,7 +115,7 @@ export const RelationIssueListItem: FC = observer((props) => {
handleIssuePeekOverview(issue)} className="w-full cursor-pointer" > diff --git a/web/core/components/issues/sub-issues/issue-list-item.tsx b/web/core/components/issues/sub-issues/issue-list-item.tsx index 4e8e759dfad..4d10587fd7b 100644 --- a/web/core/components/issues/sub-issues/issue-list-item.tsx +++ b/web/core/components/issues/sub-issues/issue-list-item.tsx @@ -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 = 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 (
handleIssuePeekOverview(issue)} className="w-full cursor-pointer" > @@ -194,7 +203,7 @@ export const IssueListItem: React.FC = observer((props) => { onClick={(e) => { e.stopPropagation(); e.preventDefault(); - subIssueOperations.copyText(`${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`); + subIssueOperations.copyText(workItemLink); }} >
diff --git a/web/core/components/issues/sub-issues/root.tsx b/web/core/components/issues/sub-issues/root.tsx index 7e912eec7c1..509ea713530 100644 --- a/web/core/components/issues/sub-issues/root.tsx +++ b/web/core/components/issues/sub-issues/root.tsx @@ -135,7 +135,7 @@ export const SubIssuesRoot: FC = 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!", diff --git a/web/core/components/workspace/sidebar/project-navigation.tsx b/web/core/components/workspace/sidebar/project-navigation.tsx index ec5b7119b7e..a8a76ad6083 100644 --- a/web/core/components/workspace/sidebar/project-navigation.tsx +++ b/web/core/components/workspace/sidebar/project-navigation.tsx @@ -153,7 +153,11 @@ export const ProjectNavigation: FC = observer((props) => {
{ // 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 }); } diff --git a/web/core/layouts/auth-layout/project-wrapper.tsx b/web/core/layouts/auth-layout/project-wrapper.tsx index 7ab6b6f605e..b2653fbb656 100644 --- a/web/core/layouts/auth-layout/project-wrapper.tsx +++ b/web/core/layouts/auth-layout/project-wrapper.tsx @@ -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 = 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 diff --git a/web/core/services/issue/issue.service.ts b/web/core/services/issue/issue.service.ts index 8fa711ff431..203b319c7d3 100644 --- a/web/core/services/issue/issue.service.ts +++ b/web/core/services/issue/issue.service.ts @@ -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 { + 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; + }); + } } diff --git a/web/core/store/issue/issue-details/issue.store.ts b/web/core/store/issue/issue-details/issue.store.ts index 5d7264a905c..fd3dcedf6cf 100644 --- a/web/core/store/issue/issue-details/issue.store.ts +++ b/web/core/store/issue/issue-details/issue.store.ts @@ -32,6 +32,7 @@ export interface IIssueStoreActions { removeModuleIds: string[] ) => Promise; removeIssueFromModule: (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => Promise; + fetchIssueWithIdentifier: (workspaceSlug: string, project_identifier: string, sequence_id: string) => Promise; } 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; + }; } diff --git a/web/core/store/issue/issue-details/root.store.ts b/web/core/store/issue/issue-details/root.store.ts index c72ef1a77d7..63faf0571a3 100644 --- a/web/core/store/issue/issue-details/root.store.ts +++ b/web/core/store/issue/issue-details/root.store.ts @@ -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) => this.issue.updateIssue(workspaceSlug, projectId, issueId, data); removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) => diff --git a/web/core/store/issue/issue.store.ts b/web/core/store/issue/issue.store.ts index 5f40f2443ee..6efd1dccdc1 100644 --- a/web/core/store/issue/issue.store.ts +++ b/web/core/store/issue/issue.store.ts @@ -15,19 +15,23 @@ import { IssueService } from "@/services/issue"; export type IIssueStore = { // observables issuesMap: Record; // Record defines issue_id as key and TIssue as value + issuesIdentifierMap: Record; // Record defines issue_identifier as key and issue_id as value // actions getIssues(workspaceSlug: string, projectId: string, issueIds: string[]): Promise; addIssue(issues: TIssue[]): void; + addIssueIdentifier(issueIdentifier: string, issueId: string): void; updateIssue(issueId: string, issue: Partial): 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 diff --git a/web/helpers/issue.helper.ts b/web/helpers/issue.helper.ts index 55239b50109..f7e6334dd6a 100644 --- a/web/helpers/issue.helper.ts +++ b/web/helpers/issue.helper.ts @@ -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; +}; diff --git a/web/helpers/string.helper.ts b/web/helpers/string.helper.ts index 85cc872bf88..3f31d73ac1a 100644 --- a/web/helpers/string.helper.ts +++ b/web/helpers/string.helper.ts @@ -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 => {