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

* feat: meta endpoint for issue

* chore: add detail endpoint

* chore: getIssueMetaFromURL and retrieveWithIdentifier endpoint added

* chore: issue store updated

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

* fix: issue details permission

* fix: work item detail header

* chore: generateWorkItemLink helper function added

* chore: copyTextToClipboard helper function updated

* chore: workItemLink updated

* chore: workItemLink updated

* chore: workItemLink updated

* fix: issues navigation tab active status

* fix: invalid workitem error state

* chore: peek view parent issue redirection improvement

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

* fix: workitem empty state redirection and header

* fix: workitem empty state redirection and header

* chore: code refactor

* chore: project auth wrapper improvement

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
This commit is contained in:
Anmol Singh Bhatia 2025-02-15 05:05:00 +05:30 committed by GitHub
parent 82eea3e802
commit 4353cc0c4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 1032 additions and 282 deletions

View file

@ -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: {

View file

@ -24,6 +24,7 @@ import { IIssueActivity } from "@plane/types";
import { Tooltip, BlockedIcon, BlockerIcon, RelatedIcon, LayersIcon, DiceIcon, Intake } from "@plane/ui";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
import { capitalizeFirstLetter } from "@/helpers/string.helper";
import { useLabel } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
@ -34,6 +35,14 @@ export const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
const { workspaceSlug } = useParams();
const { isMobile } = usePlatformOS();
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug.toString() ?? activity.workspace_detail?.slug,
projectId: activity?.project,
issueId: activity?.issue,
projectIdentifier: activity?.project_detail?.identifier,
sequenceId: activity?.issue_detail?.sequence_id,
});
return (
<Tooltip
tooltipContent={activity?.issue_detail ? activity.issue_detail.name : "This work item has been deleted"}
@ -42,9 +51,7 @@ export const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
{activity?.issue_detail ? (
<a
aria-disabled={activity.issue === null}
href={`${`/${workspaceSlug ?? activity.workspace_detail?.slug}/projects/${activity.project}/issues/${
activity.issue
}`}`}
href={workItemLink}
target={activity.issue === null ? "_self" : "_blank"}
rel={activity.issue === null ? "" : "noopener noreferrer"}
className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline"

View file

@ -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"

View file

@ -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"
>

View file

@ -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

View file

@ -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}

View file

@ -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

View file

@ -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"
>

View file

@ -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"

View file

@ -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"),

View file

@ -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"

View file

@ -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({

View file

@ -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 }} />

View file

@ -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}

View file

@ -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"

View file

@ -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 (
<>

View file

@ -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}

View file

@ -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}

View file

@ -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",

View file

@ -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}

View file

@ -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;

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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;

View file

@ -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",

View file

@ -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"

View file

@ -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>

View file

@ -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"
>

View file

@ -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">

View file

@ -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!",

View file

@ -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

View file

@ -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 });
}

View file

@ -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

View file

@ -439,4 +439,44 @@ export class IssueService extends APIService {
throw error?.response?.data;
});
}
async getIssueMetaFromURL(
workspaceSlug: string,
projectId: string,
issueId: string
): Promise<{
project_identifier: string;
sequence_id: string;
}> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/meta/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async retrieveWithIdentifier(
workspaceSlug: string,
project_identifier: string,
issue_sequence: string,
queries?: any
): Promise<TIssue> {
return this.get(`/api/workspaces/${workspaceSlug}/work-items/${project_identifier}-${issue_sequence}/`, {
params: queries,
})
.then((response) => {
// skip issue update when the service type is epic
if (response.data && this.serviceType === EIssueServiceType.ISSUES) {
updateIssue({ ...response.data, is_local_update: 1 });
}
// add is_epic flag when the service type is epic
if (response.data && this.serviceType === EIssueServiceType.EPICS) {
response.data.is_epic = true;
}
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
}

View file

@ -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;
};
}

View file

@ -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) =>

View file

@ -15,19 +15,23 @@ import { IssueService } from "@/services/issue";
export type IIssueStore = {
// observables
issuesMap: Record<string, TIssue>; // Record defines issue_id as key and TIssue as value
issuesIdentifierMap: Record<string, string>; // Record defines issue_identifier as key and issue_id as value
// actions
getIssues(workspaceSlug: string, projectId: string, issueIds: string[]): Promise<TIssue[]>;
addIssue(issues: TIssue[]): void;
addIssueIdentifier(issueIdentifier: string, issueId: string): void;
updateIssue(issueId: string, issue: Partial<TIssue>): void;
removeIssue(issueId: string): void;
// helper methods
getIssueById(issueId: string): undefined | TIssue;
getIssueIdByIdentifier(issueIdentifier: string): undefined | string;
getIssuesByIds(issueIds: string[], type: "archived" | "un-archived"): TIssue[]; // Record defines issue_id as key and TIssue as value
};
export class IssueStore implements IIssueStore {
// observables
issuesMap: { [issue_id: string]: TIssue } = {};
issuesIdentifierMap: { [issue_identifier: string]: string } = {};
// service
issueService;
@ -35,8 +39,10 @@ export class IssueStore implements IIssueStore {
makeObservable(this, {
// observable
issuesMap: observable,
issuesIdentifierMap: observable,
// actions
addIssue: action,
addIssueIdentifier: action,
updateIssue: action,
removeIssue: action,
});
@ -59,6 +65,19 @@ export class IssueStore implements IIssueStore {
});
};
/**
* @description This method will add issue_identifier to the issuesIdentifierMap
* @param issueIdentifier
* @param issueId
* @returns {void}
*/
addIssueIdentifier = (issueIdentifier: string, issueId: string) => {
if (!issueIdentifier || !issueId) return;
runInAction(() => {
set(this.issuesIdentifierMap, issueIdentifier, issueId);
});
};
getIssues = async (workspaceSlug: string, projectId: string, issueIds: string[]) => {
const issues = await this.issueService.retrieveIssues(workspaceSlug, projectId, issueIds);
@ -116,6 +135,16 @@ export class IssueStore implements IIssueStore {
return this.issuesMap[issueId];
});
/**
* @description This method will return the issue_id from the issuesIdentifierMap
* @param {string} issueIdentifier
* @returns {string | undefined}
*/
getIssueIdByIdentifier = computedFn((issueIdentifier: string) => {
if (!issueIdentifier || !this.issuesIdentifierMap[issueIdentifier]) return undefined;
return this.issuesIdentifierMap[issueIdentifier];
});
/**
* @description This method will return the issues from the issuesMap
* @param {string[]} issueIds