feat: init
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// hooks
|
||||
// components
|
||||
import { cn } from "@plane/utils";
|
||||
import { CycleDropdown } from "@/components/dropdowns/cycle";
|
||||
// ui
|
||||
// helpers
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// types
|
||||
import type { TIssueOperations } from "./root";
|
||||
|
||||
type TIssueCycleSelect = {
|
||||
className?: string;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
issueOperations: TIssueOperations;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const IssueCycleSelect: React.FC<TIssueCycleSelect> = observer((props) => {
|
||||
const { className = "", workspaceSlug, projectId, issueId, issueOperations, disabled = false } = props;
|
||||
const { t } = useTranslation();
|
||||
// states
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
const disableSelect = disabled || isUpdating;
|
||||
|
||||
const handleIssueCycleChange = async (cycleId: string | null) => {
|
||||
if (!issue || issue.cycle_id === cycleId) return;
|
||||
setIsUpdating(true);
|
||||
if (cycleId) await issueOperations.addCycleToIssue?.(workspaceSlug, projectId, cycleId, issueId);
|
||||
else await issueOperations.removeIssueFromCycle?.(workspaceSlug, projectId, issue.cycle_id ?? "", issueId);
|
||||
setIsUpdating(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full items-center gap-1", className)}>
|
||||
<CycleDropdown
|
||||
value={issue?.cycle_id ?? null}
|
||||
onChange={handleIssueCycleChange}
|
||||
projectId={projectId}
|
||||
disabled={disableSelect}
|
||||
buttonVariant="transparent-with-text"
|
||||
className="group w-full"
|
||||
buttonContainerClassName="w-full text-left rounded"
|
||||
buttonClassName={`text-sm justify-between ${issue?.cycle_id ? "" : "text-custom-text-400"}`}
|
||||
placeholder={t("cycle.no_cycle")}
|
||||
hideIcon
|
||||
dropdownArrow
|
||||
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
1
apps/web/core/components/issues/issue-detail/index.ts
Normal file
1
apps/web/core/components/issues/issue-detail/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import type { E_SORT_ORDER, TActivityFilters } from "@plane/constants";
|
||||
import { EActivityFilterType, filterActivityOnSelectedFilters } from "@plane/constants";
|
||||
import type { TCommentsOperations } from "@plane/types";
|
||||
// components
|
||||
import { CommentCard } from "@/components/comments/card/root";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// plane web components
|
||||
import { IssueAdditionalPropertiesActivity } from "@/plane-web/components/issues/issue-details/issue-properties-activity";
|
||||
import { IssueActivityWorklog } from "@/plane-web/components/issues/worklog/activity/root";
|
||||
// local imports
|
||||
import { IssueActivityItem } from "./activity/activity-list";
|
||||
import { IssueActivityLoader } from "./loader";
|
||||
|
||||
type TIssueActivityCommentRoot = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
isIntakeIssue: boolean;
|
||||
issueId: string;
|
||||
selectedFilters: TActivityFilters[];
|
||||
activityOperations: TCommentsOperations;
|
||||
showAccessSpecifier?: boolean;
|
||||
disabled?: boolean;
|
||||
sortOrder: E_SORT_ORDER;
|
||||
};
|
||||
|
||||
export const IssueActivityCommentRoot: FC<TIssueActivityCommentRoot> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
isIntakeIssue,
|
||||
issueId,
|
||||
selectedFilters,
|
||||
activityOperations,
|
||||
showAccessSpecifier,
|
||||
projectId,
|
||||
disabled,
|
||||
sortOrder,
|
||||
} = props;
|
||||
// store hooks
|
||||
const {
|
||||
activity: { getActivityAndCommentsByIssueId },
|
||||
comment: { getCommentById },
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const activityAndComments = getActivityAndCommentsByIssueId(issueId, sortOrder);
|
||||
|
||||
if (!activityAndComments) return <IssueActivityLoader />;
|
||||
|
||||
if (activityAndComments.length <= 0) return null;
|
||||
|
||||
const filteredActivityAndComments = filterActivityOnSelectedFilters(activityAndComments, selectedFilters);
|
||||
|
||||
const BASE_ACTIVITY_FILTER_TYPES = [
|
||||
EActivityFilterType.ACTIVITY,
|
||||
EActivityFilterType.STATE,
|
||||
EActivityFilterType.ASSIGNEE,
|
||||
EActivityFilterType.DEFAULT,
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{filteredActivityAndComments.map((activityComment, index) => {
|
||||
const comment = getCommentById(activityComment.id);
|
||||
return activityComment.activity_type === "COMMENT" ? (
|
||||
<CommentCard
|
||||
key={activityComment.id}
|
||||
workspaceSlug={workspaceSlug}
|
||||
comment={comment}
|
||||
activityOperations={activityOperations}
|
||||
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
|
||||
showAccessSpecifier={!!showAccessSpecifier}
|
||||
showCopyLinkOption={!isIntakeIssue}
|
||||
disabled={disabled}
|
||||
projectId={projectId}
|
||||
/>
|
||||
) : BASE_ACTIVITY_FILTER_TYPES.includes(activityComment.activity_type as EActivityFilterType) ? (
|
||||
<IssueActivityItem
|
||||
activityId={activityComment.id}
|
||||
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
|
||||
/>
|
||||
) : activityComment.activity_type === "ISSUE_ADDITIONAL_PROPERTIES_ACTIVITY" ? (
|
||||
<IssueAdditionalPropertiesActivity
|
||||
activityId={activityComment.id}
|
||||
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
|
||||
/>
|
||||
) : activityComment.activity_type === "WORKLOG" ? (
|
||||
<IssueActivityWorklog
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
activityComment={activityComment}
|
||||
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Check, ListFilter } from "lucide-react";
|
||||
import type { TActivityFilters, TActivityFilterOption } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { PopoverMenu } from "@plane/ui";
|
||||
// helper
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
|
||||
type TActivityFilter = {
|
||||
selectedFilters: TActivityFilters[];
|
||||
filterOptions: TActivityFilterOption[];
|
||||
};
|
||||
|
||||
export const ActivityFilter: FC<TActivityFilter> = observer((props) => {
|
||||
const { selectedFilters = [], filterOptions } = props;
|
||||
|
||||
// hooks
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PopoverMenu
|
||||
buttonClassName="outline-none"
|
||||
button={
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
prependIcon={<ListFilter className="h-3 w-3" />}
|
||||
className="relative"
|
||||
>
|
||||
<span className="text-custom-text-200">{t("common.filters")}</span>
|
||||
{selectedFilters.length < filterOptions.length && (
|
||||
<span className="absolute h-2 w-2 -right-0.5 -top-0.5 bg-custom-primary-100 rounded-full" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
panelClassName="p-2 rounded-md border border-custom-border-200 bg-custom-background-100"
|
||||
data={filterOptions}
|
||||
keyExtractor={(item) => item.key}
|
||||
render={(item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex items-center gap-2 text-sm cursor-pointer px-2 p-1 transition-all hover:bg-custom-background-80 rounded-sm"
|
||||
onClick={item.onClick}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 w-3 h-3 flex justify-center items-center rounded-sm transition-all bg-custom-background-90",
|
||||
{
|
||||
"bg-custom-primary text-white": item.isSelected,
|
||||
"bg-custom-background-80 text-custom-text-400": item.isSelected && selectedFilters.length === 1,
|
||||
"bg-custom-background-90": !item.isSelected,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{item.isSelected && <Check className="h-2.5 w-2.5" />}
|
||||
</div>
|
||||
<div className={cn("whitespace-nowrap", item.isSelected ? "text-custom-text-100" : "text-custom-text-200")}>
|
||||
{t(item.labelTranslationKey)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
// hooks
|
||||
import { ArchiveIcon } from "@plane/propel/icons";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent } from "./";
|
||||
// ui
|
||||
|
||||
type TIssueArchivedAtActivity = { activityId: string; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueArchivedAtActivity: FC<TIssueArchivedAtActivity> = observer((props) => {
|
||||
const { activityId, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={
|
||||
activity.new_value === "restore" ? (
|
||||
<RotateCcw className="h-3.5 w-3.5 text-custom-text-200" aria-hidden="true" />
|
||||
) : (
|
||||
<ArchiveIcon className="h-3.5 w-3.5 text-custom-text-200" aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
customUserName={activity.new_value === "archive" ? "Plane" : undefined}
|
||||
>
|
||||
{activity.new_value === "restore" ? "restored the work item" : "archived the work item"}.
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// icons
|
||||
import { Users } from "lucide-react";
|
||||
// hooks;
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent, IssueLink } from "./";
|
||||
|
||||
type TIssueAssigneeActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueAssigneeActivity: FC<TIssueAssigneeActivity> = observer((props) => {
|
||||
const { activityId, ends, showIssue = true } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<Users className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-200" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
{activity.old_value === "" ? `added a new assignee ` : `removed the assignee `}
|
||||
<a
|
||||
href={`/${activity.workspace_detail?.slug}/profile/${activity.new_identifier ?? activity.old_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center font-medium text-custom-text-100 hover:underline capitalize"
|
||||
>
|
||||
{activity.new_value && activity.new_value !== "" ? activity.new_value : activity.old_value}
|
||||
</a>
|
||||
{showIssue && (activity.old_value === "" ? ` to ` : ` from `)}
|
||||
{showIssue && <IssueLink activityId={activityId} />}.
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Paperclip } from "lucide-react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent, IssueLink } from "./";
|
||||
|
||||
type TIssueAttachmentActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueAttachmentActivity: FC<TIssueAttachmentActivity> = observer((props) => {
|
||||
const { activityId, showIssue = true, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<Paperclip size={14} className="text-custom-text-200" aria-hidden="true" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
{activity.verb === "created" ? `uploaded a new attachment` : `removed an attachment`}
|
||||
{showIssue && (activity.verb === "created" ? ` to ` : ` from `)}
|
||||
{showIssue && <IssueLink activityId={activityId} />}.
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { CycleIcon } from "@plane/propel/icons";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent } from "./";
|
||||
// icons
|
||||
|
||||
type TIssueCycleActivity = { activityId: string; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueCycleActivity: FC<TIssueCycleActivity> = observer((props) => {
|
||||
const { activityId, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<CycleIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
{activity.verb === "created" ? (
|
||||
<>
|
||||
<span>added this work item to the cycle </span>
|
||||
<a
|
||||
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/cycles/${activity.new_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="truncate">{activity.new_value}</span>
|
||||
</a>
|
||||
</>
|
||||
) : activity.verb === "updated" ? (
|
||||
<>
|
||||
<span>set the cycle to </span>
|
||||
<a
|
||||
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/cycles/${activity.new_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="truncate"> {activity.new_value}</span>
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>removed the work item from the cycle </span>
|
||||
<a
|
||||
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/cycles/${activity.old_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="truncate"> {activity.new_value}</span>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { WorkItemsIcon } from "@plane/propel/icons";
|
||||
import { EInboxIssueSource } from "@plane/types";
|
||||
// hooks
|
||||
import { capitalizeFirstLetter } from "@plane/utils";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// local imports
|
||||
import { IssueActivityBlockComponent } from "./";
|
||||
|
||||
type TIssueDefaultActivity = { activityId: string; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueDefaultActivity: FC<TIssueDefaultActivity> = observer((props) => {
|
||||
const { activityId, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
const source = activity.source_data?.source;
|
||||
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
activityId={activityId}
|
||||
icon={<WorkItemsIcon width={14} height={14} className="text-custom-text-200" aria-hidden="true" />}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
{activity.verb === "created" ? (
|
||||
source && source !== EInboxIssueSource.IN_APP ? (
|
||||
<span>
|
||||
created the work item via{" "}
|
||||
<span className="font-medium">{capitalizeFirstLetter(source.toLowerCase() || "")}</span>.
|
||||
</span>
|
||||
) : (
|
||||
<span> created the work item.</span>
|
||||
)
|
||||
) : (
|
||||
<span> deleted a work item.</span>
|
||||
)}
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { AlignLeft } from "lucide-react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent, IssueLink } from "./";
|
||||
|
||||
type TIssueDescriptionActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueDescriptionActivity: FC<TIssueDescriptionActivity> = observer((props) => {
|
||||
const { activityId, showIssue = true, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<AlignLeft size={14} className="text-custom-text-200" aria-hidden="true" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
updated the description
|
||||
{showIssue ? ` of ` : ``}
|
||||
{showIssue && <IssueLink activityId={activityId} />}.
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Triangle } from "lucide-react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent, IssueLink } from "./";
|
||||
|
||||
type TIssueEstimateActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueEstimateActivity: FC<TIssueEstimateActivity> = observer((props) => {
|
||||
const { activityId, showIssue = true, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<Triangle size={14} className="text-custom-text-200" aria-hidden="true" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
{activity.new_value ? `set the estimate point to ` : `removed the estimate point`}
|
||||
{activity.new_value ? activity.new_value : activity?.old_value}
|
||||
{showIssue && (activity.new_value ? ` to ` : ` from `)}
|
||||
{showIssue && <IssueLink activityId={activityId} />}.
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { Network } from "lucide-react";
|
||||
// plane imports
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { renderFormattedTime, renderFormattedDate, calculateTimeAgo } from "@plane/utils";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web imports
|
||||
import { IssueCreatorDisplay } from "@/plane-web/components/issues/issue-details/issue-creator";
|
||||
// local imports
|
||||
import { IssueUser } from "../";
|
||||
|
||||
type TIssueActivityBlockComponent = {
|
||||
icon?: ReactNode;
|
||||
activityId: string;
|
||||
ends: "top" | "bottom" | undefined;
|
||||
children: ReactNode;
|
||||
customUserName?: string;
|
||||
};
|
||||
|
||||
export const IssueActivityBlockComponent: FC<TIssueActivityBlockComponent> = (props) => {
|
||||
const { icon, activityId, ends, children, customUserName } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
const { isMobile } = usePlatformOS();
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<div
|
||||
className={`relative flex items-center gap-3 text-xs ${
|
||||
ends === "top" ? `pb-2` : ends === "bottom" ? `pt-2` : `py-2`
|
||||
}`}
|
||||
>
|
||||
<div className="absolute left-[13px] top-0 bottom-0 w-0.5 bg-custom-background-80" aria-hidden />
|
||||
<div className="flex-shrink-0 ring-6 w-7 h-7 rounded-full overflow-hidden flex justify-center items-center z-[4] bg-custom-background-80 text-custom-text-200">
|
||||
{icon ? icon : <Network className="w-3.5 h-3.5" />}
|
||||
</div>
|
||||
<div className="w-full truncate text-custom-text-200">
|
||||
{!activity?.field && activity?.verb === "created" ? (
|
||||
<IssueCreatorDisplay activityId={activityId} customUserName={customUserName} />
|
||||
) : (
|
||||
<IssueUser activityId={activityId} customUserName={customUserName} />
|
||||
)}
|
||||
<span> {children} </span>
|
||||
<span>
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={`${renderFormattedDate(activity.created_at)}, ${renderFormattedTime(activity.created_at)}`}
|
||||
>
|
||||
<span className="whitespace-nowrap text-custom-text-350"> {calculateTimeAgo(activity.created_at)}</span>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
// hooks
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { generateWorkItemLink } from "@plane/utils";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// ui
|
||||
|
||||
type TIssueLink = {
|
||||
activityId: string;
|
||||
};
|
||||
|
||||
export const IssueLink: FC<TIssueLink> = (props) => {
|
||||
const { activityId } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
const { isMobile } = usePlatformOS();
|
||||
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"}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<a
|
||||
aria-disabled={activity.issue === null}
|
||||
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"
|
||||
>
|
||||
{activity.issue_detail
|
||||
? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`
|
||||
: "Work items"}{" "}
|
||||
<span className="font-normal">{activity.issue_detail?.name}</span>
|
||||
</a>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FC } from "react";
|
||||
import Link from "next/link";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
|
||||
type TIssueUser = {
|
||||
activityId: string;
|
||||
customUserName?: string;
|
||||
};
|
||||
|
||||
export const IssueUser: FC<TIssueUser> = (props) => {
|
||||
const { activityId, customUserName } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
{customUserName ? (
|
||||
<span className="text-custom-text-100 font-medium">{customUserName}</span>
|
||||
) : (
|
||||
<Link
|
||||
href={`/${activity?.workspace_detail?.slug}/profile/${activity?.actor_detail?.id}`}
|
||||
className="hover:underline text-custom-text-100 font-medium"
|
||||
>
|
||||
{activity.actor_detail?.display_name}
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { IntakeIcon } from "@plane/propel/icons";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent } from "./";
|
||||
// icons
|
||||
|
||||
type TIssueInboxActivity = { activityId: string; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueInboxActivity: FC<TIssueInboxActivity> = observer((props) => {
|
||||
const { activityId, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
const getInboxActivityMessage = () => {
|
||||
switch (activity?.verb) {
|
||||
case "-1":
|
||||
return "declined this work item from intake.";
|
||||
case "0":
|
||||
return "snoozed this work item.";
|
||||
case "1":
|
||||
return "accepted this work item from intake.";
|
||||
case "2":
|
||||
return "declined this work item from intake by marking a duplicate work item.";
|
||||
default:
|
||||
return "updated intake work item status.";
|
||||
}
|
||||
};
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<IntakeIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>{getInboxActivityMessage()}</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
export * from "./default";
|
||||
export * from "./name";
|
||||
export * from "./description";
|
||||
export * from "./state";
|
||||
export * from "./assignee";
|
||||
export * from "./priority";
|
||||
export * from "./estimate";
|
||||
export * from "./parent";
|
||||
export * from "./relation";
|
||||
export * from "./start_date";
|
||||
export * from "./target_date";
|
||||
export * from "./cycle";
|
||||
export * from "./module";
|
||||
export * from "./label";
|
||||
export * from "./link";
|
||||
export * from "./attachment";
|
||||
export * from "./archived-at";
|
||||
export * from "./inbox";
|
||||
export * from "./label-activity-chip";
|
||||
|
||||
// helpers
|
||||
export * from "./helpers/activity-block";
|
||||
export * from "./helpers/issue-user";
|
||||
export * from "./helpers/issue-link";
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { FC } from "react";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
|
||||
type TIssueLabelPill = { name?: string; color?: string };
|
||||
|
||||
export const LabelActivityChip: FC<TIssueLabelPill> = (props) => {
|
||||
const { name, color } = props;
|
||||
return (
|
||||
<Tooltip tooltipContent={name}>
|
||||
<span className="inline-flex w-min max-w-32 cursor-default flex-shrink-0 items-center gap-2 truncate whitespace-nowrap rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: color ?? "#000000",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="flex-shrink truncate font-medium text-custom-text-100">{name}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Tag } from "lucide-react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
// components
|
||||
import { IssueActivityBlockComponent, IssueLink, LabelActivityChip } from "./";
|
||||
|
||||
type TIssueLabelActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueLabelActivity: FC<TIssueLabelActivity> = observer((props) => {
|
||||
const { activityId, showIssue = true, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
const { getLabelById } = useLabel();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
const oldLabelColor = getLabelById(activity?.old_identifier ?? "")?.color;
|
||||
const newLabelColor = getLabelById(activity?.new_identifier ?? "")?.color;
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<Tag size={14} className="text-custom-text-200" aria-hidden="true" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
{activity.old_value === "" ? `added a new label ` : `removed the label `}
|
||||
<LabelActivityChip
|
||||
name={activity.old_value === "" ? activity.new_value : activity.old_value}
|
||||
color={activity.old_value === "" ? newLabelColor : oldLabelColor}
|
||||
/>
|
||||
{showIssue && (activity.old_value === "" ? ` to ` : ` from `)}
|
||||
{showIssue && <IssueLink activityId={activityId} />}
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent, IssueLink } from "./";
|
||||
|
||||
type TIssueLinkActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueLinkActivity: FC<TIssueLinkActivity> = observer((props) => {
|
||||
const { activityId, showIssue = false, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<MessageSquare size={14} className="text-custom-text-200" aria-hidden="true" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
{activity.verb === "created" ? (
|
||||
<>
|
||||
<span>added </span>
|
||||
<a
|
||||
href={`${activity.new_value}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
link
|
||||
</a>
|
||||
</>
|
||||
) : activity.verb === "updated" ? (
|
||||
<>
|
||||
<span>updated the </span>
|
||||
<a
|
||||
href={`${activity.old_value}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
link
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>removed this </span>
|
||||
<a
|
||||
href={`${activity.old_value}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
link
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{showIssue && (activity.verb === "created" ? ` to ` : ` from `)}
|
||||
{showIssue && <IssueLink activityId={activityId} />}.
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { ModuleIcon } from "@plane/propel/icons";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent } from "./";
|
||||
// icons
|
||||
|
||||
type TIssueModuleActivity = { activityId: string; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueModuleActivity: FC<TIssueModuleActivity> = observer((props) => {
|
||||
const { activityId, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<ModuleIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
{activity.verb === "created" ? (
|
||||
<>
|
||||
<span>added this work item to the module </span>
|
||||
<a
|
||||
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/modules/${activity.new_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="truncate">{activity.new_value}</span>
|
||||
</a>
|
||||
</>
|
||||
) : activity.verb === "updated" ? (
|
||||
<>
|
||||
<span>set the module to </span>
|
||||
<a
|
||||
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/modules/${activity.new_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="truncate"> {activity.new_value}</span>
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>removed the work item from the module </span>
|
||||
<a
|
||||
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/modules/${activity.old_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="truncate"> {activity.old_value}</span>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Type } from "lucide-react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent } from "./";
|
||||
|
||||
type TIssueNameActivity = { activityId: string; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueNameActivity: FC<TIssueNameActivity> = observer((props) => {
|
||||
const { activityId, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<Type size={14} className="text-custom-text-200" aria-hidden="true" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>set the name to {activity.new_value}.</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { LayoutPanelTop } from "lucide-react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent, IssueLink } from "./";
|
||||
|
||||
type TIssueParentActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueParentActivity: FC<TIssueParentActivity> = observer((props) => {
|
||||
const { activityId, showIssue = true, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<LayoutPanelTop size={14} className="text-custom-text-200" aria-hidden="true" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
{activity.new_value ? `set the parent to ` : `removed the parent `}
|
||||
{activity.new_value ? (
|
||||
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
|
||||
) : (
|
||||
<span className="font-medium text-custom-text-100">{activity.old_value}</span>
|
||||
)}
|
||||
{showIssue && (activity.new_value ? ` for ` : ` from `)}
|
||||
{showIssue && <IssueLink activityId={activityId} />}.
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Signal } from "lucide-react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent, IssueLink } from "./";
|
||||
|
||||
type TIssuePriorityActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssuePriorityActivity: FC<TIssuePriorityActivity> = observer((props) => {
|
||||
const { activityId, showIssue = true, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<Signal size={14} className="text-custom-text-200" aria-hidden="true" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
set the priority to <span className="font-medium text-custom-text-100">{activity.new_value}</span>
|
||||
{showIssue ? ` for ` : ``}
|
||||
{showIssue && <IssueLink activityId={activityId} />}.
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// Plane-web
|
||||
import { getRelationActivityContent, useTimeLineRelationOptions } from "@/plane-web/components/relations";
|
||||
import type { TIssueRelationTypes } from "@/plane-web/types";
|
||||
//
|
||||
import { IssueActivityBlockComponent } from "./";
|
||||
|
||||
type TIssueRelationActivity = { activityId: string; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueRelationActivity: FC<TIssueRelationActivity> = observer((props) => {
|
||||
const { activityId, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
|
||||
const activityContent = getRelationActivityContent(activity);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={activity.field ? ISSUE_RELATION_OPTIONS[activity.field as TIssueRelationTypes]?.icon(14) : <></>}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
{activityContent}
|
||||
{activity.old_value === "" ? (
|
||||
<span className="font-medium text-custom-text-100">{activity.new_value}.</span>
|
||||
) : (
|
||||
<span className="font-medium text-custom-text-100">{activity.old_value}.</span>
|
||||
)}
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
// hooks
|
||||
import { renderFormattedDate } from "@plane/utils";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent, IssueLink } from "./";
|
||||
// helpers
|
||||
|
||||
type TIssueStartDateActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueStartDateActivity: FC<TIssueStartDateActivity> = observer((props) => {
|
||||
const { activityId, showIssue = true, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<CalendarDays size={14} className="text-custom-text-200" aria-hidden="true" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
{activity.new_value ? `set the start date to ` : `removed the start date `}
|
||||
{activity.new_value && (
|
||||
<>
|
||||
<span className="font-medium text-custom-text-100">{renderFormattedDate(activity.new_value)}</span>
|
||||
</>
|
||||
)}
|
||||
{showIssue && (activity.new_value ? ` for ` : ` from `)}
|
||||
{showIssue && <IssueLink activityId={activityId} />}.
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { DoubleCircleIcon } from "@plane/propel/icons";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent, IssueLink } from "./";
|
||||
// icons
|
||||
|
||||
type TIssueStateActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueStateActivity: FC<TIssueStateActivity> = observer((props) => {
|
||||
const { activityId, showIssue = true, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<DoubleCircleIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
set the state to <span className="font-medium text-custom-text-100">{activity.new_value}</span>
|
||||
{showIssue ? ` for ` : ``}
|
||||
{showIssue && <IssueLink activityId={activityId} />}.
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
// hooks
|
||||
import { renderFormattedDate } from "@plane/utils";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent, IssueLink } from "./";
|
||||
// helpers
|
||||
|
||||
type TIssueTargetDateActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueTargetDateActivity: FC<TIssueTargetDateActivity> = observer((props) => {
|
||||
const { activityId, showIssue = true, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<CalendarDays size={14} className="text-custom-text-200" aria-hidden="true" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
{activity.new_value ? `set the due date to ` : `removed the due date `}
|
||||
{activity.new_value && (
|
||||
<>
|
||||
<span className="font-medium text-custom-text-100">{renderFormattedDate(activity.new_value)}</span>
|
||||
</>
|
||||
)}
|
||||
{showIssue && (activity.new_value ? ` for ` : ` from `)}
|
||||
{showIssue && <IssueLink activityId={activityId} />}.
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// helpers
|
||||
import { getValidKeysFromObject } from "@plane/utils";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// plane web components
|
||||
import { IssueTypeActivity, AdditionalActivityRoot } from "@/plane-web/components/issues/issue-details";
|
||||
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
|
||||
// local components
|
||||
import {
|
||||
IssueDefaultActivity,
|
||||
IssueNameActivity,
|
||||
IssueDescriptionActivity,
|
||||
IssueStateActivity,
|
||||
IssueAssigneeActivity,
|
||||
IssuePriorityActivity,
|
||||
IssueEstimateActivity,
|
||||
IssueParentActivity,
|
||||
IssueRelationActivity,
|
||||
IssueStartDateActivity,
|
||||
IssueTargetDateActivity,
|
||||
IssueCycleActivity,
|
||||
IssueModuleActivity,
|
||||
IssueLabelActivity,
|
||||
IssueLinkActivity,
|
||||
IssueAttachmentActivity,
|
||||
IssueArchivedAtActivity,
|
||||
IssueInboxActivity,
|
||||
} from "./actions";
|
||||
|
||||
type TIssueActivityItem = {
|
||||
activityId: string;
|
||||
ends: "top" | "bottom" | undefined;
|
||||
};
|
||||
|
||||
export const IssueActivityItem: FC<TIssueActivityItem> = observer((props) => {
|
||||
const { activityId, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
comment: {},
|
||||
} = useIssueDetail();
|
||||
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
|
||||
const activityRelations = getValidKeysFromObject(ISSUE_RELATION_OPTIONS);
|
||||
|
||||
const componentDefaultProps = { activityId, ends };
|
||||
|
||||
const activityField = getActivityById(activityId)?.field;
|
||||
switch (activityField) {
|
||||
case null: // default issue creation
|
||||
return <IssueDefaultActivity {...componentDefaultProps} />;
|
||||
case "state":
|
||||
return <IssueStateActivity {...componentDefaultProps} showIssue={false} />;
|
||||
case "name":
|
||||
return <IssueNameActivity {...componentDefaultProps} />;
|
||||
case "description":
|
||||
return <IssueDescriptionActivity {...componentDefaultProps} showIssue={false} />;
|
||||
case "assignees":
|
||||
return <IssueAssigneeActivity {...componentDefaultProps} showIssue={false} />;
|
||||
case "priority":
|
||||
return <IssuePriorityActivity {...componentDefaultProps} showIssue={false} />;
|
||||
case "estimate_points":
|
||||
case "estimate_categories":
|
||||
case "estimate_point" /* This case is to handle all the older recorded activities for estimates. Field changed from "estimate_point" -> `estimate_${estimate_type}`*/:
|
||||
return <IssueEstimateActivity {...componentDefaultProps} showIssue={false} />;
|
||||
case "parent":
|
||||
return <IssueParentActivity {...componentDefaultProps} showIssue={false} />;
|
||||
case activityRelations.find((field) => field === activityField):
|
||||
return <IssueRelationActivity {...componentDefaultProps} />;
|
||||
case "start_date":
|
||||
return <IssueStartDateActivity {...componentDefaultProps} showIssue={false} />;
|
||||
case "target_date":
|
||||
return <IssueTargetDateActivity {...componentDefaultProps} showIssue={false} />;
|
||||
case "cycles":
|
||||
return <IssueCycleActivity {...componentDefaultProps} />;
|
||||
case "modules":
|
||||
return <IssueModuleActivity {...componentDefaultProps} />;
|
||||
case "labels":
|
||||
return <IssueLabelActivity {...componentDefaultProps} showIssue={false} />;
|
||||
case "link":
|
||||
return <IssueLinkActivity {...componentDefaultProps} showIssue={false} />;
|
||||
case "attachment":
|
||||
return <IssueAttachmentActivity {...componentDefaultProps} showIssue={false} />;
|
||||
case "archived_at":
|
||||
return <IssueArchivedAtActivity {...componentDefaultProps} />;
|
||||
case "intake":
|
||||
case "inbox":
|
||||
return <IssueInboxActivity {...componentDefaultProps} />;
|
||||
case "type":
|
||||
return <IssueTypeActivity {...componentDefaultProps} />;
|
||||
default:
|
||||
return <AdditionalActivityRoot {...componentDefaultProps} field={activityField} />;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { EFileAssetType } from "@plane/types";
|
||||
import type { TCommentsOperations } from "@plane/types";
|
||||
import { copyUrlToClipboard, formatTextList, generateWorkItemLink } from "@plane/utils";
|
||||
import { useEditorAsset } from "@/hooks/store/use-editor-asset";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
export const useCommentOperations = (
|
||||
workspaceSlug: string | undefined,
|
||||
projectId: string | undefined,
|
||||
issueId: string | undefined
|
||||
): TCommentsOperations => {
|
||||
// store hooks
|
||||
const {
|
||||
commentReaction: { getCommentReactionsByCommentId, commentReactionsByUser, getCommentReactionById },
|
||||
createComment,
|
||||
updateComment,
|
||||
removeComment,
|
||||
createCommentReaction,
|
||||
removeCommentReaction,
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getProjectById } = useProject();
|
||||
const { getUserDetails } = useMember();
|
||||
const { uploadEditorAsset } = useEditorAsset();
|
||||
const { data: currentUser } = useUser();
|
||||
// derived values
|
||||
const issueDetails = issueId ? getIssueById(issueId) : undefined;
|
||||
const projectDetails = projectId ? getProjectById(projectId) : undefined;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
const operations: TCommentsOperations = useMemo(() => {
|
||||
// Define operations object with all methods
|
||||
const ops: TCommentsOperations = {
|
||||
copyCommentLink: (id) => {
|
||||
if (!workspaceSlug || !issueDetails) return;
|
||||
try {
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug,
|
||||
projectId: issueDetails.project_id,
|
||||
issueId,
|
||||
projectIdentifier: projectDetails?.identifier,
|
||||
sequenceId: issueDetails.sequence_id,
|
||||
});
|
||||
const commentLink = `${workItemLink}#comment-${id}`;
|
||||
copyUrlToClipboard(commentLink).then(() => {
|
||||
setToast({
|
||||
title: t("common.success"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: t("issue.comments.copy_link.success"),
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in copying comment link:", error);
|
||||
setToast({
|
||||
title: t("common.error.label"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: t("issue.comments.copy_link.error"),
|
||||
});
|
||||
}
|
||||
},
|
||||
createComment: async (data) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
|
||||
const comment = await createComment(workspaceSlug, projectId, issueId, data);
|
||||
setToast({
|
||||
title: t("common.success"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: t("issue.comments.create.success"),
|
||||
});
|
||||
return comment;
|
||||
} catch {
|
||||
setToast({
|
||||
title: t("common.error.label"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: t("issue.comments.create.error"),
|
||||
});
|
||||
}
|
||||
},
|
||||
updateComment: async (commentId, data) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
|
||||
await updateComment(workspaceSlug, projectId, issueId, commentId, data);
|
||||
setToast({
|
||||
title: t("common.success"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: t("issue.comments.update.success"),
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
title: t("common.error.label"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: t("issue.comments.update.error"),
|
||||
});
|
||||
}
|
||||
},
|
||||
removeComment: async (commentId) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
|
||||
await removeComment(workspaceSlug, projectId, issueId, commentId);
|
||||
setToast({
|
||||
title: t("common.success"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: t("issue.comments.remove.success"),
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
title: t("common.error.label"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: t("issue.comments.remove.error"),
|
||||
});
|
||||
}
|
||||
},
|
||||
uploadCommentAsset: async (blockId, file, commentId) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId) throw new Error("Missing fields");
|
||||
const res = await uploadEditorAsset({
|
||||
blockId,
|
||||
data: {
|
||||
entity_identifier: commentId ?? "",
|
||||
entity_type: EFileAssetType.COMMENT_DESCRIPTION,
|
||||
},
|
||||
file,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
});
|
||||
return res;
|
||||
} catch (error) {
|
||||
console.log("Error in uploading comment asset:", error);
|
||||
throw new Error(t("issue.comments.upload.error"));
|
||||
}
|
||||
},
|
||||
addCommentReaction: async (commentId, reaction) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !commentId) throw new Error("Missing fields");
|
||||
await createCommentReaction(workspaceSlug, projectId, commentId, reaction);
|
||||
setToast({
|
||||
title: "Success!",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: "Reaction created successfully",
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
title: "Error!",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: "Reaction creation failed",
|
||||
});
|
||||
}
|
||||
},
|
||||
deleteCommentReaction: async (commentId, reaction) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !commentId || !currentUser?.id) throw new Error("Missing fields");
|
||||
removeCommentReaction(workspaceSlug, projectId, commentId, reaction, currentUser.id);
|
||||
setToast({
|
||||
title: "Success!",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: "Reaction removed successfully",
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
title: "Error!",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: "Reaction remove failed",
|
||||
});
|
||||
}
|
||||
},
|
||||
react: async (commentId, reactionEmoji, userReactions) => {
|
||||
if (userReactions.includes(reactionEmoji)) await ops.deleteCommentReaction(commentId, reactionEmoji);
|
||||
else await ops.addCommentReaction(commentId, reactionEmoji);
|
||||
},
|
||||
reactionIds: (commentId) => getCommentReactionsByCommentId(commentId),
|
||||
userReactions: (commentId) =>
|
||||
currentUser ? commentReactionsByUser(commentId, currentUser?.id).map((r) => r.reaction) : [],
|
||||
getReactionUsers: (reaction, reactionIds) => {
|
||||
const reactionUsers = (reactionIds?.[reaction] || [])
|
||||
.map((reactionId) => {
|
||||
const reactionDetails = getCommentReactionById(reactionId);
|
||||
return reactionDetails ? getUserDetails(reactionDetails.actor)?.display_name : null;
|
||||
})
|
||||
.filter((displayName): displayName is string => !!displayName);
|
||||
const formattedUsers = formatTextList(reactionUsers);
|
||||
return formattedUsers;
|
||||
},
|
||||
};
|
||||
return ops;
|
||||
}, [workspaceSlug, projectId, issueId, createComment, updateComment, uploadEditorAsset, removeComment]);
|
||||
|
||||
return operations;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
export * from "./root";
|
||||
|
||||
export * from "./activity-comment-root";
|
||||
|
||||
// activity
|
||||
export * from "./activity/activity-list";
|
||||
export * from "./activity-filter";
|
||||
|
||||
// sort
|
||||
export * from "./sort-root";
|
||||
@@ -0,0 +1,31 @@
|
||||
// plane imports
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const IssueActivityLoader = () => (
|
||||
<Loader className="space-y-8">
|
||||
<div className="flex items-start gap-3">
|
||||
<Loader.Item className="shrink-0" height="28px" width="28px" />
|
||||
<div className="space-y-2 w-full">
|
||||
<Loader.Item height="8px" width="60%" />
|
||||
<Loader.Item height="8px" width="40%" />
|
||||
<Loader.Item height="10px" width="100%" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Loader.Item className="shrink-0" height="28px" width="28px" />
|
||||
<div className="space-y-2 w-full">
|
||||
<Loader.Item height="8px" width="40%" />
|
||||
<Loader.Item height="8px" width="60%" />
|
||||
<Loader.Item height="10px" width="80%" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Loader.Item className="shrink-0" height="28px" width="28px" />
|
||||
<div className="space-y-2 w-full">
|
||||
<Loader.Item height="8px" width="60%" />
|
||||
<Loader.Item height="8px" width="40%" />
|
||||
<Loader.Item height="10px" width="100%" />
|
||||
</div>
|
||||
</div>
|
||||
</Loader>
|
||||
);
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useMemo } from "react";
|
||||
import uniq from "lodash-es/uniq";
|
||||
import { observer } from "mobx-react";
|
||||
// plane package imports
|
||||
import type { TActivityFilters } from "@plane/constants";
|
||||
import { E_SORT_ORDER, defaultActivityFilters, EUserPermissions } from "@plane/constants";
|
||||
import { useLocalStorage } from "@plane/hooks";
|
||||
// i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
//types
|
||||
import type { TFileSignedURLResponse, TIssueComment } from "@plane/types";
|
||||
// components
|
||||
import { CommentCreate } from "@/components/comments/comment-create";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web components
|
||||
import { ActivityFilterRoot } from "@/plane-web/components/issues/worklog/activity/filter-root";
|
||||
import { IssueActivityWorklogCreateButton } from "@/plane-web/components/issues/worklog/activity/worklog-create-button";
|
||||
import { IssueActivityCommentRoot } from "./activity-comment-root";
|
||||
import { useCommentOperations } from "./helper";
|
||||
import { ActivitySortRoot } from "./sort-root";
|
||||
|
||||
type TIssueActivity = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled?: boolean;
|
||||
isIntakeIssue?: boolean;
|
||||
};
|
||||
|
||||
export type TActivityOperations = {
|
||||
createComment: (data: Partial<TIssueComment>) => Promise<TIssueComment>;
|
||||
updateComment: (commentId: string, data: Partial<TIssueComment>) => Promise<void>;
|
||||
removeComment: (commentId: string) => Promise<void>;
|
||||
uploadCommentAsset: (blockId: string, file: File, commentId?: string) => Promise<TFileSignedURLResponse>;
|
||||
};
|
||||
|
||||
export const IssueActivity: FC<TIssueActivity> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled = false, isIntakeIssue = false } = props;
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { setValue: setFilterValue, storedValue: selectedFilters } = useLocalStorage(
|
||||
"issue_activity_filters",
|
||||
defaultActivityFilters
|
||||
);
|
||||
const { setValue: setSortOrder, storedValue: sortOrder } = useLocalStorage("activity_sort_order", E_SORT_ORDER.ASC);
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
|
||||
const { getProjectById } = useProject();
|
||||
const { data: currentUser } = useUser();
|
||||
// derived values
|
||||
const issue = issueId ? getIssueById(issueId) : undefined;
|
||||
const currentUserProjectRole = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId);
|
||||
const isAdmin = currentUserProjectRole === EUserPermissions.ADMIN;
|
||||
const isGuest = currentUserProjectRole === EUserPermissions.GUEST;
|
||||
const isAssigned = issue?.assignee_ids && currentUser?.id ? issue?.assignee_ids.includes(currentUser?.id) : false;
|
||||
const isWorklogButtonEnabled = !isIntakeIssue && !isGuest && (isAdmin || isAssigned);
|
||||
// toggle filter
|
||||
const toggleFilter = (filter: TActivityFilters) => {
|
||||
if (!selectedFilters) return;
|
||||
let _filters = [];
|
||||
if (selectedFilters.includes(filter)) {
|
||||
if (selectedFilters.length === 1) return selectedFilters; // Ensure at least one filter is applied
|
||||
_filters = selectedFilters.filter((f) => f !== filter);
|
||||
} else {
|
||||
_filters = [...selectedFilters, filter];
|
||||
}
|
||||
|
||||
setFilterValue(uniq(_filters));
|
||||
};
|
||||
|
||||
const toggleSortOrder = () => {
|
||||
setSortOrder(sortOrder === E_SORT_ORDER.ASC ? E_SORT_ORDER.DESC : E_SORT_ORDER.ASC);
|
||||
};
|
||||
|
||||
// helper hooks
|
||||
const activityOperations = useCommentOperations(workspaceSlug, projectId, issueId);
|
||||
|
||||
const project = getProjectById(projectId);
|
||||
const renderCommentCreationBox = useMemo(
|
||||
() => (
|
||||
<CommentCreate
|
||||
workspaceSlug={workspaceSlug}
|
||||
entityId={issueId}
|
||||
activityOperations={activityOperations}
|
||||
showToolbarInitially
|
||||
projectId={projectId}
|
||||
/>
|
||||
),
|
||||
[workspaceSlug, issueId, activityOperations, projectId]
|
||||
);
|
||||
if (!project) return <></>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pt-3">
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-lg text-custom-text-100">{t("common.activity")}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isWorklogButtonEnabled && (
|
||||
<IssueActivityWorklogCreateButton
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
<ActivitySortRoot sortOrder={sortOrder || E_SORT_ORDER.ASC} toggleSort={toggleSortOrder} />
|
||||
<ActivityFilterRoot
|
||||
selectedFilters={selectedFilters || defaultActivityFilters}
|
||||
toggleFilter={toggleFilter}
|
||||
isIntakeIssue={isIntakeIssue}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* rendering activity */}
|
||||
<div className="space-y-3">
|
||||
<div className="min-h-[200px]">
|
||||
<div className="space-y-3">
|
||||
{!disabled && sortOrder === E_SORT_ORDER.DESC && renderCommentCreationBox}
|
||||
<IssueActivityCommentRoot
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
isIntakeIssue={isIntakeIssue}
|
||||
issueId={issueId}
|
||||
selectedFilters={selectedFilters || defaultActivityFilters}
|
||||
activityOperations={activityOperations}
|
||||
showAccessSpecifier={!!project.anchor}
|
||||
disabled={disabled}
|
||||
sortOrder={sortOrder || E_SORT_ORDER.ASC}
|
||||
/>
|
||||
{!disabled && sortOrder === E_SORT_ORDER.ASC && renderCommentCreationBox}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { memo } from "react";
|
||||
import { ArrowUpWideNarrow, ArrowDownWideNarrow } from "lucide-react";
|
||||
// plane package imports
|
||||
import type { E_SORT_ORDER } from "@plane/constants";
|
||||
import { getButtonStyling } from "@plane/propel/button";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
export type TActivitySortRoot = {
|
||||
sortOrder: E_SORT_ORDER;
|
||||
toggleSort: () => void;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
};
|
||||
export const ActivitySortRoot: FC<TActivitySortRoot> = memo((props) => (
|
||||
<div
|
||||
className={cn(
|
||||
getButtonStyling("neutral-primary", "sm"),
|
||||
"px-2 text-custom-text-300 cursor-pointer",
|
||||
props.className
|
||||
)}
|
||||
onClick={() => {
|
||||
props.toggleSort();
|
||||
}}
|
||||
>
|
||||
{props.sortOrder === "asc" ? (
|
||||
<ArrowUpWideNarrow className={cn("size-4", props.iconClassName)} />
|
||||
) : (
|
||||
<ArrowDownWideNarrow className={cn("size-4", props.iconClassName)} />
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
|
||||
ActivitySortRoot.displayName = "ActivitySortRoot";
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import React, { useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { LinkIcon } from "lucide-react";
|
||||
// plane imports
|
||||
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
import { generateWorkItemLink, copyTextToClipboard } from "@plane/utils";
|
||||
// helpers
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// local imports
|
||||
import { WorkItemDetailQuickActions } from "../issue-layouts/quick-action-dropdowns";
|
||||
import { IssueSubscription } from "./subscription";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
};
|
||||
|
||||
export const IssueDetailQuickActions: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
// ref
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
|
||||
// hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
removeIssue,
|
||||
archiveIssue,
|
||||
} = useIssueDetail();
|
||||
const {
|
||||
issues: { restoreIssue },
|
||||
} = useIssues(EIssuesStoreType.ARCHIVED);
|
||||
const {
|
||||
issues: { removeIssue: removeArchivedIssue },
|
||||
} = useIssues(EIssuesStoreType.ARCHIVED);
|
||||
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
if (!issue) return <></>;
|
||||
|
||||
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}${workItemLink}`).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("common.link_copied"),
|
||||
message: t("common.copied_to_clipboard"),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteIssue = async () => {
|
||||
try {
|
||||
const deleteIssue = issue?.archived_at ? removeArchivedIssue : removeIssue;
|
||||
const redirectionPath = issue?.archived_at
|
||||
? `/${workspaceSlug}/projects/${projectId}/archives/issues`
|
||||
: `/${workspaceSlug}/projects/${projectId}/issues`;
|
||||
|
||||
return deleteIssue(workspaceSlug, projectId, issueId).then(() => {
|
||||
router.push(redirectionPath);
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
title: t("toast.error "),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: t("entity.delete.failed", { entity: t("issue.label", { count: 1 }) }),
|
||||
});
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchiveIssue = async () => {
|
||||
try {
|
||||
await archiveIssue(workspaceSlug, projectId, issueId);
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/issues`);
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async () => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
await restoreIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString())
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("issue.restore.success.title"),
|
||||
message: t("issue.restore.success.message"),
|
||||
});
|
||||
router.push(workItemLink);
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: t("issue.restore.failed.message"),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-end flex-shrink-0">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{currentUser && !issue?.archived_at && (
|
||||
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2.5 text-custom-text-300">
|
||||
<Tooltip tooltipContent={t("common.actions.copy_link")} isMobile={isMobile}>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-5 w-5 place-items-center rounded hover:text-custom-text-200 focus:outline-none focus:ring-2 focus:ring-custom-primary"
|
||||
onClick={handleCopyText}
|
||||
>
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<WorkItemDetailQuickActions
|
||||
parentRef={parentRef}
|
||||
issue={issue}
|
||||
handleDelete={handleDeleteIssue}
|
||||
handleArchive={handleArchiveIssue}
|
||||
handleRestore={handleRestore}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useState, Fragment, useEffect } from "react";
|
||||
import { TwitterPicker } from "react-color";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { usePopper } from "react-popper";
|
||||
import { Plus, X, Loader } from "lucide-react";
|
||||
import { Popover } from "@headlessui/react";
|
||||
import type { IIssueLabel } from "@plane/types";
|
||||
// hooks
|
||||
import { Input } from "@plane/ui";
|
||||
// ui
|
||||
// types
|
||||
import type { TLabelOperations } from "./root";
|
||||
|
||||
type ILabelCreate = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
values: string[];
|
||||
labelOperations: TLabelOperations;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const defaultValues: Partial<IIssueLabel> = {
|
||||
name: "",
|
||||
color: "#ff0000",
|
||||
};
|
||||
|
||||
export const LabelCreate: FC<ILabelCreate> = (props) => {
|
||||
const { workspaceSlug, projectId, issueId, values, labelOperations, disabled = false } = props;
|
||||
// state
|
||||
const [isCreateToggle, setIsCreateToggle] = useState(false);
|
||||
const handleIsCreateToggle = () => setIsCreateToggle(!isCreateToggle);
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
// react hook form
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
reset,
|
||||
control,
|
||||
setFocus,
|
||||
} = useForm<Partial<IIssueLabel>>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: "bottom-start",
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCreateToggle) return;
|
||||
|
||||
setFocus("name");
|
||||
reset();
|
||||
}, [isCreateToggle, reset, setFocus]);
|
||||
|
||||
const handleLabel = async (formData: Partial<IIssueLabel>) => {
|
||||
if (!workspaceSlug || !projectId || isSubmitting) return;
|
||||
|
||||
const labelResponse = await labelOperations.createLabel(workspaceSlug, projectId, formData);
|
||||
const currentLabels = [...(values || []), labelResponse.id];
|
||||
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: currentLabels });
|
||||
handleIsCreateToggle();
|
||||
reset(defaultValues);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded-full border border-custom-border-100 p-0.5 px-2 text-xs text-custom-text-300 transition-all hover:bg-custom-background-90 hover:text-custom-text-200"
|
||||
onClick={handleIsCreateToggle}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{isCreateToggle ? <X className="h-2.5 w-2.5" /> : <Plus className="h-2.5 w-2.5" />}
|
||||
</div>
|
||||
<div className="flex-shrink-0">{isCreateToggle ? "Cancel" : "New"}</div>
|
||||
</div>
|
||||
|
||||
{isCreateToggle && (
|
||||
<form className="relative flex items-center gap-x-2 p-1" onSubmit={handleSubmit(handleLabel)}>
|
||||
<div>
|
||||
<Controller
|
||||
name="color"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Popover>
|
||||
<>
|
||||
<Popover.Button as={Fragment}>
|
||||
<button type="button" ref={setReferenceElement} className="grid place-items-center outline-none">
|
||||
{value && value?.trim() !== "" && (
|
||||
<span
|
||||
className="h-5 w-5 rounded"
|
||||
style={{
|
||||
backgroundColor: value ?? "black",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</Popover.Button>
|
||||
<Popover.Panel className="fixed z-10">
|
||||
<div
|
||||
className="p-2 max-w-xs sm:px-0"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<TwitterPicker triangle={"hide"} color={value} onChange={(value) => onChange(value.hex)} />
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</>
|
||||
</Popover>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
rules={{
|
||||
required: "This is required",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
value={value ?? ""}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.name)}
|
||||
placeholder="Title"
|
||||
className="w-full text-xs px-1.5 py-1"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center rounded bg-red-500 p-1"
|
||||
onClick={() => setIsCreateToggle(false)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-white" />
|
||||
</button>
|
||||
<button type="submit" className="grid place-items-center rounded bg-green-500 p-1" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<Loader className="spin h-3.5 w-3.5 text-white" />
|
||||
) : (
|
||||
<Plus className="h-3.5 w-3.5 text-white" />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from "./root";
|
||||
|
||||
export * from "./label-list";
|
||||
export * from "./label-list-item";
|
||||
export * from "./create-label";
|
||||
export * from "./select/root";
|
||||
export * from "./select/label-select";
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { X } from "lucide-react";
|
||||
// types
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import type { TLabelOperations } from "./root";
|
||||
|
||||
type TLabelListItem = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
labelId: string;
|
||||
values: string[];
|
||||
labelOperations: TLabelOperations;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export const LabelListItem: FC<TLabelListItem> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, labelId, values, labelOperations, disabled } = props;
|
||||
// hooks
|
||||
const { getLabelById } = useLabel();
|
||||
|
||||
const label = getLabelById(labelId);
|
||||
|
||||
const handleLabel = async () => {
|
||||
if (values && !disabled) {
|
||||
const currentLabels = values.filter((_labelId) => _labelId !== labelId);
|
||||
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: currentLabels });
|
||||
}
|
||||
};
|
||||
|
||||
if (!label) return <></>;
|
||||
return (
|
||||
<div
|
||||
key={labelId}
|
||||
className={`transition-all relative flex items-center gap-1 truncate border border-custom-border-100 rounded-full text-xs p-0.5 px-1 group ${
|
||||
!disabled ? "cursor-pointer hover:border-red-500/50 hover:bg-red-500/20" : "cursor-not-allowed"
|
||||
} `}
|
||||
onClick={handleLabel}
|
||||
>
|
||||
<div
|
||||
className="rounded-full h-2 w-2 flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: label.color ?? "#000000",
|
||||
}}
|
||||
/>
|
||||
<div className="truncate">{label.name}</div>
|
||||
{!disabled && (
|
||||
<div className="flex-shrink-0">
|
||||
<X className="transition-all h-2.5 w-2.5 group-hover:text-red-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { LabelListItem } from "./label-list-item";
|
||||
// types
|
||||
import type { TLabelOperations } from "./root";
|
||||
|
||||
type TLabelList = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
values: string[];
|
||||
labelOperations: TLabelOperations;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export const LabelList: FC<TLabelList> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, values, labelOperations, disabled } = props;
|
||||
const issueLabels = values || undefined;
|
||||
|
||||
if (!issueId || !issueLabels) return <></>;
|
||||
return (
|
||||
<>
|
||||
{issueLabels.map((labelId) => (
|
||||
<LabelListItem
|
||||
key={labelId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
labelId={labelId}
|
||||
values={issueLabels}
|
||||
labelOperations={labelOperations}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
120
apps/web/core/components/issues/issue-detail/label/root.tsx
Normal file
120
apps/web/core/components/issues/issue-detail/label/root.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IIssueLabel, TIssue, TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// components
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
// ui
|
||||
// types
|
||||
import { LabelList, IssueLabelSelectRoot } from "./";
|
||||
// TODO: Fix this import statement, as core should not import from ee
|
||||
// eslint-disable-next-line import/order
|
||||
|
||||
export type TIssueLabel = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
isInboxIssue?: boolean;
|
||||
onLabelUpdate?: (labelIds: string[]) => void;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
};
|
||||
|
||||
export type TLabelOperations = {
|
||||
updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
|
||||
createLabel: (workspaceSlug: string, projectId: string, data: Partial<IIssueLabel>) => Promise<any>;
|
||||
};
|
||||
|
||||
export const IssueLabel: FC<TIssueLabel> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
disabled = false,
|
||||
isInboxIssue = false,
|
||||
onLabelUpdate,
|
||||
issueServiceType = EIssueServiceType.ISSUES,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { updateIssue } = useIssueDetail(issueServiceType);
|
||||
const { createLabel } = useLabel();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail(issueServiceType);
|
||||
const { getIssueInboxByIssueId } = useProjectInbox();
|
||||
|
||||
const issue = isInboxIssue ? getIssueInboxByIssueId(issueId)?.issue : getIssueById(issueId);
|
||||
|
||||
const labelOperations: TLabelOperations = useMemo(
|
||||
() => ({
|
||||
updateIssue: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
|
||||
try {
|
||||
if (onLabelUpdate) onLabelUpdate(data.label_ids || []);
|
||||
else await updateIssue(workspaceSlug, projectId, issueId, data);
|
||||
} catch (error) {
|
||||
setToast({
|
||||
title: t("toast.error"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: t("entity.update.failed", { entity: t("issue.label", { count: 1 }) }),
|
||||
});
|
||||
}
|
||||
},
|
||||
createLabel: async (workspaceSlug: string, projectId: string, data: Partial<IIssueLabel>) => {
|
||||
try {
|
||||
const labelResponse = await createLabel(workspaceSlug, projectId, data);
|
||||
if (!isInboxIssue)
|
||||
setToast({
|
||||
title: t("toast.success"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: t("label.create.success"),
|
||||
});
|
||||
return labelResponse;
|
||||
} catch (error) {
|
||||
let errMessage = t("label.create.failed");
|
||||
if (error && (error as any).error === "Label with the same name already exists in the project")
|
||||
errMessage = t("label.create.already_exists");
|
||||
|
||||
setToast({
|
||||
title: t("toast.error"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: errMessage,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}),
|
||||
[updateIssue, createLabel, onLabelUpdate]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-wrap items-center gap-1">
|
||||
<LabelList
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
values={issue?.label_ids || []}
|
||||
labelOperations={labelOperations}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{!disabled && (
|
||||
<IssueLabelSelectRoot
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
values={issue?.label_ids || []}
|
||||
labelOperations={labelOperations}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { Check, Loader, Search, Tag } from "lucide-react";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { EUserPermissionsLevel, getRandomLabelColor } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { IIssueLabel } from "@plane/types";
|
||||
import { EUserProjectRoles } from "@plane/types";
|
||||
// helpers
|
||||
import { getTabIndex } from "@plane/utils";
|
||||
// hooks
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
//constants
|
||||
export interface IIssueLabelSelect {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
values: string[];
|
||||
onSelect: (_labelIds: string[]) => void;
|
||||
onAddLabel: (workspaceSlug: string, projectId: string, data: Partial<IIssueLabel>) => Promise<any>;
|
||||
}
|
||||
|
||||
export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, values, onSelect, onAddLabel } = props;
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { fetchProjectLabels, getProjectLabels } = useLabel();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// states
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||
|
||||
const canCreateLabel =
|
||||
projectId && allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId);
|
||||
|
||||
const projectLabels = getProjectLabels(projectId);
|
||||
|
||||
const { baseTabIndex } = getTabIndex(undefined, isMobile);
|
||||
|
||||
const fetchLabels = () => {
|
||||
setIsLoading(true);
|
||||
if (!projectLabels && workspaceSlug && projectId)
|
||||
fetchProjectLabels(workspaceSlug, projectId).then(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
const options = (projectLabels ?? []).map((label) => ({
|
||||
value: label.id,
|
||||
query: label.name,
|
||||
content: (
|
||||
<div className="flex items-center justify-start gap-2 overflow-hidden">
|
||||
<span
|
||||
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color,
|
||||
}}
|
||||
/>
|
||||
<div className="line-clamp-1 inline-block truncate">{label.name}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const filteredOptions =
|
||||
query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: "bottom-end",
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const issueLabels = values ?? [];
|
||||
|
||||
const label = (
|
||||
<div
|
||||
className={`relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded-full border border-custom-border-100 p-0.5 px-2 py-0.5 text-xs text-custom-text-300 transition-all hover:bg-custom-background-90 hover:text-custom-text-200`}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<Tag className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
<div className="flex-shrink-0">{t("label.select")}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const searchInputKeyDown = async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (query !== "" && e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
setQuery("");
|
||||
}
|
||||
|
||||
if (query !== "" && e.key === "Enter" && !e.nativeEvent.isComposing && canCreateLabel) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
await handleAddLabel(query);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddLabel = async (labelName: string) => {
|
||||
setSubmitting(true);
|
||||
const label = await onAddLabel(workspaceSlug, projectId, { name: labelName, color: getRandomLabelColor() });
|
||||
onSelect([...values, label.id]);
|
||||
setQuery("");
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
if (!issueId || !values) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Combobox
|
||||
as="div"
|
||||
className={`w-auto max-w-full flex-shrink-0 text-left`}
|
||||
value={issueLabels}
|
||||
onChange={(value) => onSelect(value)}
|
||||
multiple
|
||||
>
|
||||
<Combobox.Button as={Fragment}>
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className="cursor-pointer rounded"
|
||||
onClick={() => !projectLabels && fetchLabels()}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</Combobox.Button>
|
||||
|
||||
<Combobox.Options className="fixed z-10">
|
||||
<div
|
||||
className={`z-10 my-1 w-48 whitespace-nowrap rounded border border-custom-border-300 bg-custom-background-100 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none`}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div className="px-2">
|
||||
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-300" />
|
||||
<Combobox.Input
|
||||
className="w-full bg-transparent px-2 py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("common.search.label")}
|
||||
displayValue={(assigned: any) => assigned?.name}
|
||||
onKeyDown={searchInputKeyDown}
|
||||
tabIndex={baseTabIndex}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`vertical-scrollbar scrollbar-sm mt-2 max-h-48 space-y-1 overflow-y-scroll px-2 pr-0`}>
|
||||
{isLoading ? (
|
||||
<p className="text-center text-custom-text-200">{t("common.loading")}</p>
|
||||
) : filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ selected }) =>
|
||||
`flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 hover:bg-custom-background-80 ${
|
||||
selected ? "text-custom-text-100" : "text-custom-text-200"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
{option.content}
|
||||
{selected && (
|
||||
<div className="flex-shrink-0">
|
||||
<Check className={`h-3.5 w-3.5`} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : submitting ? (
|
||||
<Loader className="spin h-3.5 w-3.5" />
|
||||
) : canCreateLabel ? (
|
||||
<Combobox.Option
|
||||
value={query}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!query.length) return;
|
||||
handleAddLabel(query);
|
||||
}}
|
||||
className={`text-left text-custom-text-200 ${query.length ? "cursor-pointer" : "cursor-default"}`}
|
||||
>
|
||||
{query.length ? (
|
||||
<>
|
||||
{/* TODO: Translate here */}+ Add{" "}
|
||||
<span className="text-custom-text-100">"{query}"</span> to labels
|
||||
</>
|
||||
) : (
|
||||
t("label.create.type")
|
||||
)}
|
||||
</Combobox.Option>
|
||||
) : (
|
||||
<p className="text-left text-custom-text-200 ">{t("common.search.no_matching_results")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { FC } from "react";
|
||||
// components
|
||||
import type { TLabelOperations } from "../root";
|
||||
import { IssueLabelSelect } from "./label-select";
|
||||
// types
|
||||
|
||||
type TIssueLabelSelectRoot = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
values: string[];
|
||||
labelOperations: TLabelOperations;
|
||||
};
|
||||
|
||||
export const IssueLabelSelectRoot: FC<TIssueLabelSelectRoot> = (props) => {
|
||||
const { workspaceSlug, projectId, issueId, values, labelOperations } = props;
|
||||
|
||||
const handleLabel = async (_labelIds: string[]) => {
|
||||
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: _labelIds });
|
||||
};
|
||||
|
||||
return (
|
||||
<IssueLabelSelect
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
values={values}
|
||||
onSelect={handleLabel}
|
||||
onAddLabel={labelOperations.createLabel}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// plane types
|
||||
import { Button } from "@plane/propel/button";
|
||||
import type { TIssueLinkEditableFields, TIssueServiceType } from "@plane/types";
|
||||
// plane ui
|
||||
import { Input, ModalCore } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// types
|
||||
import type { TLinkOperations } from "./root";
|
||||
|
||||
export type TLinkOperationsModal = Exclude<TLinkOperations, "remove">;
|
||||
|
||||
export type TIssueLinkCreateFormFieldOptions = TIssueLinkEditableFields & {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type TIssueLinkCreateEditModal = {
|
||||
isModalOpen: boolean;
|
||||
handleOnClose?: () => void;
|
||||
linkOperations: TLinkOperationsModal;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
const defaultValues: TIssueLinkCreateFormFieldOptions = {
|
||||
title: "",
|
||||
url: "",
|
||||
};
|
||||
|
||||
export const IssueLinkCreateUpdateModal: FC<TIssueLinkCreateEditModal> = observer((props) => {
|
||||
const { isModalOpen, handleOnClose, linkOperations, issueServiceType } = props;
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
// react hook form
|
||||
const {
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
} = useForm<TIssueLinkCreateFormFieldOptions>({
|
||||
defaultValues,
|
||||
});
|
||||
// store hooks
|
||||
const { issueLinkData: preloadedData, setIssueLinkData } = useIssueDetail(issueServiceType);
|
||||
|
||||
const onClose = () => {
|
||||
setIssueLinkData(null);
|
||||
if (handleOnClose) handleOnClose();
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: TIssueLinkCreateFormFieldOptions) => {
|
||||
const parsedUrl = formData.url.startsWith("http") ? formData.url : `http://${formData.url}`;
|
||||
try {
|
||||
if (!formData || !formData.id) await linkOperations.create({ title: formData.title, url: parsedUrl });
|
||||
else await linkOperations.update(formData.id, { title: formData.title, url: parsedUrl });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("error", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalOpen) reset({ ...defaultValues, ...preloadedData });
|
||||
}, [preloadedData, reset, isModalOpen]);
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isModalOpen} handleClose={onClose}>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="space-y-5 p-5">
|
||||
<h3 className="text-xl font-medium text-custom-text-200">
|
||||
{preloadedData?.id ? t("common.update_link") : t("common.add_link")}
|
||||
</h3>
|
||||
<div className="mt-2 space-y-3">
|
||||
<div>
|
||||
<label htmlFor="url" className="mb-2 text-custom-text-200">
|
||||
{t("common.url")}
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="url"
|
||||
rules={{
|
||||
required: "URL is required",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="url"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.url)}
|
||||
placeholder={t("common.type_or_paste_a_url")}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.url && <span className="text-xs text-red-500">{t("common.url_is_invalid")}</span>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="title" className="mb-2 text-custom-text-200">
|
||||
{t("common.display_title")}
|
||||
<span className="text-[10px] block">{t("common.optional")}</span>
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="title"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.title)}
|
||||
placeholder={t("common.link_title_placeholder")}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
|
||||
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
||||
{`${
|
||||
preloadedData?.id
|
||||
? isSubmitting
|
||||
? t("common.updating")
|
||||
: t("common.update")
|
||||
: isSubmitting
|
||||
? t("common.adding")
|
||||
: t("common.add")
|
||||
} ${t("common.link")}`}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./root";
|
||||
|
||||
export * from "./links";
|
||||
export * from "./link-detail";
|
||||
export * from "./link-item";
|
||||
export * from "./link-list";
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
// hooks
|
||||
// ui
|
||||
import { Pencil, Trash2, ExternalLink } from "lucide-react";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { getIconForLink, copyTextToClipboard, calculateTimeAgo } from "@plane/utils";
|
||||
// icons
|
||||
// types
|
||||
// helpers
|
||||
//
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import type { TLinkOperationsModal } from "./create-update-link-modal";
|
||||
|
||||
export type TIssueLinkDetail = {
|
||||
linkId: string;
|
||||
linkOperations: TLinkOperationsModal;
|
||||
isNotAllowed: boolean;
|
||||
};
|
||||
|
||||
export const IssueLinkDetail: FC<TIssueLinkDetail> = (props) => {
|
||||
// props
|
||||
const { linkId, linkOperations, isNotAllowed } = props;
|
||||
// hooks
|
||||
const {
|
||||
toggleIssueLinkModal: toggleIssueLinkModalStore,
|
||||
link: { getLinkById },
|
||||
setIssueLinkData,
|
||||
} = useIssueDetail();
|
||||
const { getUserDetails } = useMember();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const linkDetail = getLinkById(linkId);
|
||||
if (!linkDetail) return <></>;
|
||||
|
||||
const Icon = getIconForLink(linkDetail.url);
|
||||
|
||||
const toggleIssueLinkModal = (modalToggle: boolean) => {
|
||||
toggleIssueLinkModalStore(modalToggle);
|
||||
setIssueLinkData(linkDetail);
|
||||
};
|
||||
|
||||
const createdByDetails = getUserDetails(linkDetail.created_by_id);
|
||||
|
||||
return (
|
||||
<div key={linkId}>
|
||||
<div className="relative flex flex-col rounded-md bg-custom-background-90 p-2.5">
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-start justify-between gap-2"
|
||||
onClick={() => {
|
||||
copyTextToClipboard(linkDetail.url);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link copied!",
|
||||
message: "Link copied to clipboard",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2 truncate">
|
||||
<span className="py-1">
|
||||
<Icon className="size-3 stroke-2 text-custom-text-350 group-hover:text-custom-text-100 flex-shrink-0" />
|
||||
</span>
|
||||
<Tooltip
|
||||
tooltipContent={linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<span className="truncate text-xs">
|
||||
{linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{!isNotAllowed && (
|
||||
<div className="z-[1] flex flex-shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleIssueLinkModal(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
|
||||
</button>
|
||||
<a
|
||||
href={linkDetail.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
linkOperations.remove(linkDetail.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-5">
|
||||
<p className="mt-0.5 stroke-[1.5] text-xs text-custom-text-300">
|
||||
Added {calculateTimeAgo(linkDetail.created_at)}
|
||||
<br />
|
||||
{createdByDetails && (
|
||||
<>
|
||||
by {createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
121
apps/web/core/components/issues/issue-detail/links/link-item.tsx
Normal file
121
apps/web/core/components/issues/issue-detail/links/link-item.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Pencil, Trash2, Copy, Link } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { calculateTimeAgo, copyTextToClipboard } from "@plane/utils";
|
||||
// helpers
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import type { TLinkOperationsModal } from "./create-update-link-modal";
|
||||
|
||||
type TIssueLinkItem = {
|
||||
linkId: string;
|
||||
linkOperations: TLinkOperationsModal;
|
||||
isNotAllowed: boolean;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const IssueLinkItem: FC<TIssueLinkItem> = observer((props) => {
|
||||
// props
|
||||
const { linkId, linkOperations, isNotAllowed, issueServiceType = EIssueServiceType.ISSUES } = props;
|
||||
// hooks
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
toggleIssueLinkModal: toggleIssueLinkModalStore,
|
||||
setIssueLinkData,
|
||||
link: { getLinkById },
|
||||
} = useIssueDetail(issueServiceType);
|
||||
const { isMobile } = usePlatformOS();
|
||||
const linkDetail = getLinkById(linkId);
|
||||
if (!linkDetail) return <></>;
|
||||
|
||||
// const Icon = getIconForLink(linkDetail.url);
|
||||
const faviconUrl: string | undefined = linkDetail.metadata?.favicon;
|
||||
const linkTitle: string | undefined = linkDetail.metadata?.title;
|
||||
|
||||
const toggleIssueLinkModal = (modalToggle: boolean) => {
|
||||
toggleIssueLinkModalStore(modalToggle);
|
||||
setIssueLinkData(linkDetail);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
key={linkId}
|
||||
className="group col-span-12 lg:col-span-6 xl:col-span-4 2xl:col-span-3 3xl:col-span-2 flex items-center justify-between gap-3 h-10 flex-shrink-0 px-3 bg-custom-background-90 hover:bg-custom-background-80 border-[0.5px] border-custom-border-200 rounded"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 truncate flex-grow">
|
||||
{faviconUrl ? (
|
||||
<img src={faviconUrl} alt="favicon" className="size-4" />
|
||||
) : (
|
||||
<Link className="size-4 text-custom-text-350 group-hover:text-custom-text-100" />
|
||||
)}
|
||||
<Tooltip tooltipContent={linkDetail.url} isMobile={isMobile}>
|
||||
<a
|
||||
href={linkDetail.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate text-sm cursor-pointer flex-grow flex items-center gap-3"
|
||||
>
|
||||
{linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url}
|
||||
|
||||
{linkTitle && linkTitle !== "" && <span className="text-custom-text-400 text-xs">{linkTitle}</span>}
|
||||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<p className="p-1 text-xs align-bottom leading-5 text-custom-text-400 group-hover-text-custom-text-200">
|
||||
{calculateTimeAgo(linkDetail.created_at)}
|
||||
</p>
|
||||
<span
|
||||
onClick={() => {
|
||||
copyTextToClipboard(linkDetail.url);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("common.link_copied"),
|
||||
message: t("common.link_copied_to_clipboard"),
|
||||
});
|
||||
}}
|
||||
className="relative grid place-items-center rounded p-1 text-custom-text-400 outline-none group-hover:text-custom-text-200 cursor-pointer hover:bg-custom-background-80"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
</span>
|
||||
<CustomMenu
|
||||
ellipsis
|
||||
buttonClassName="text-custom-text-400 group-hover:text-custom-text-200"
|
||||
placement="bottom-end"
|
||||
closeOnSelect
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
<CustomMenu.MenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
toggleIssueLinkModal(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
|
||||
{t("common.actions.edit")}
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
linkOperations.remove(linkDetail.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{t("common.actions.delete")}
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
// computed
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// local imports
|
||||
import { IssueLinkItem } from "./link-item";
|
||||
import type { TLinkOperations } from "./root";
|
||||
|
||||
type TLinkOperationsModal = Exclude<TLinkOperations, "create">;
|
||||
|
||||
type TLinkList = {
|
||||
issueId: string;
|
||||
linkOperations: TLinkOperationsModal;
|
||||
disabled?: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const LinkList: FC<TLinkList> = observer((props) => {
|
||||
// props
|
||||
const { issueId, linkOperations, disabled = false, issueServiceType } = props;
|
||||
// hooks
|
||||
const {
|
||||
link: { getLinksByIssueId },
|
||||
} = useIssueDetail(issueServiceType);
|
||||
|
||||
const issueLinks = getLinksByIssueId(issueId);
|
||||
|
||||
if (!issueLinks) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 py-4">
|
||||
{issueLinks.map((linkId) => (
|
||||
<IssueLinkItem
|
||||
key={linkId}
|
||||
linkId={linkId}
|
||||
linkOperations={linkOperations}
|
||||
isNotAllowed={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
38
apps/web/core/components/issues/issue-detail/links/links.tsx
Normal file
38
apps/web/core/components/issues/issue-detail/links/links.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// computed
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { IssueLinkDetail } from "./link-detail";
|
||||
// hooks
|
||||
import type { TLinkOperations } from "./root";
|
||||
|
||||
export type TLinkOperationsModal = Exclude<TLinkOperations, "create">;
|
||||
|
||||
export type TIssueLinkList = {
|
||||
issueId: string;
|
||||
linkOperations: TLinkOperationsModal;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const IssueLinkList: FC<TIssueLinkList> = observer((props) => {
|
||||
// props
|
||||
const { issueId, linkOperations, disabled = false } = props;
|
||||
// hooks
|
||||
const {
|
||||
link: { getLinksByIssueId },
|
||||
} = useIssueDetail();
|
||||
|
||||
const issueLinks = getLinksByIssueId(issueId);
|
||||
|
||||
if (!issueLinks) return <></>;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{issueLinks &&
|
||||
issueLinks.length > 0 &&
|
||||
issueLinks.map((linkId) => (
|
||||
<IssueLinkDetail key={linkId} linkId={linkId} linkOperations={linkOperations} isNotAllowed={disabled} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
142
apps/web/core/components/issues/issue-detail/links/root.tsx
Normal file
142
apps/web/core/components/issues/issue-detail/links/root.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
// plane imports
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TIssueLink } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// local imports
|
||||
import { IssueLinkCreateUpdateModal } from "./create-update-link-modal";
|
||||
import { IssueLinkList } from "./links";
|
||||
|
||||
export type TLinkOperations = {
|
||||
create: (data: Partial<TIssueLink>) => Promise<void>;
|
||||
update: (linkId: string, data: Partial<TIssueLink>) => Promise<void>;
|
||||
remove: (linkId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export type TIssueLinkRoot = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const IssueLinkRoot: FC<TIssueLinkRoot> = (props) => {
|
||||
// props
|
||||
const { workspaceSlug, projectId, issueId, disabled = false } = props;
|
||||
// hooks
|
||||
const { toggleIssueLinkModal: toggleIssueLinkModalStore, createLink, updateLink, removeLink } = useIssueDetail();
|
||||
// state
|
||||
const [isIssueLinkModal, setIsIssueLinkModal] = useState(false);
|
||||
const toggleIssueLinkModal = useCallback(
|
||||
(modalToggle: boolean) => {
|
||||
toggleIssueLinkModalStore(modalToggle);
|
||||
setIsIssueLinkModal(modalToggle);
|
||||
},
|
||||
[toggleIssueLinkModalStore]
|
||||
);
|
||||
|
||||
const handleLinkOperations: TLinkOperations = useMemo(
|
||||
() => ({
|
||||
create: async (data: Partial<TIssueLink>) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
|
||||
await createLink(workspaceSlug, projectId, issueId, data);
|
||||
setToast({
|
||||
message: "The link has been successfully created",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link created",
|
||||
});
|
||||
toggleIssueLinkModal(false);
|
||||
} catch (error: any) {
|
||||
setToast({
|
||||
message: error?.data?.error ?? "The link could not be created",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Link not created",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
update: async (linkId: string, data: Partial<TIssueLink>) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
|
||||
await updateLink(workspaceSlug, projectId, issueId, linkId, data);
|
||||
setToast({
|
||||
message: "The link has been successfully updated",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link updated",
|
||||
});
|
||||
toggleIssueLinkModal(false);
|
||||
} catch (error) {
|
||||
setToast({
|
||||
message: "The link could not be updated",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Link not updated",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
remove: async (linkId: string) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
|
||||
await removeLink(workspaceSlug, projectId, issueId, linkId);
|
||||
setToast({
|
||||
message: "The link has been successfully removed",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link removed",
|
||||
});
|
||||
toggleIssueLinkModal(false);
|
||||
} catch {
|
||||
setToast({
|
||||
message: "The link could not be removed",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Link not removed",
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
[workspaceSlug, projectId, issueId, createLink, updateLink, removeLink, toggleIssueLinkModal]
|
||||
);
|
||||
|
||||
const handleOnClose = () => {
|
||||
toggleIssueLinkModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IssueLinkCreateUpdateModal
|
||||
isModalOpen={isIssueLinkModal}
|
||||
handleOnClose={handleOnClose}
|
||||
linkOperations={handleLinkOperations}
|
||||
issueServiceType={EIssueServiceType.ISSUES}
|
||||
/>
|
||||
|
||||
<div className="py-1 text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h4>Links</h4>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-custom-background-90 ${
|
||||
disabled ? "cursor-not-allowed" : "cursor-pointer"
|
||||
}`}
|
||||
onClick={() => toggleIssueLinkModal(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<IssueLinkList issueId={issueId} linkOperations={handleLinkOperations} disabled={disabled} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
199
apps/web/core/components/issues/issue-detail/main-content.tsx
Normal file
199
apps/web/core/components/issues/issue-detail/main-content.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import type { EditorRefApi } from "@plane/editor";
|
||||
import type { TNameDescriptionLoader } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
import { getTextContent } from "@plane/utils";
|
||||
// components
|
||||
import { DescriptionVersionsRoot } from "@/components/core/description-versions";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
||||
import useSize from "@/hooks/use-window-size";
|
||||
// plane web components
|
||||
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe/duplicate-popover";
|
||||
import { IssueTypeSwitcher } from "@/plane-web/components/issues/issue-details/issue-type-switcher";
|
||||
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
|
||||
// services
|
||||
import { WorkItemVersionService } from "@/services/issue";
|
||||
// local imports
|
||||
import { IssueDescriptionInput } from "../description-input";
|
||||
import { IssueDetailWidgets } from "../issue-detail-widgets";
|
||||
import { NameDescriptionUpdateStatus } from "../issue-update-status";
|
||||
import { PeekOverviewProperties } from "../peek-overview/properties";
|
||||
import { IssueTitleInput } from "../title-input";
|
||||
import { IssueActivity } from "./issue-activity";
|
||||
import { IssueParentDetail } from "./parent";
|
||||
import { IssueReaction } from "./reactions";
|
||||
import type { TIssueOperations } from "./root";
|
||||
// services init
|
||||
const workItemVersionService = new WorkItemVersionService();
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
issueOperations: TIssueOperations;
|
||||
isEditable: boolean;
|
||||
isArchived: boolean;
|
||||
};
|
||||
|
||||
export const IssueMainContent: React.FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, issueOperations, isEditable, isArchived } = props;
|
||||
// refs
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
// states
|
||||
const [isSubmitting, setIsSubmitting] = useState<TNameDescriptionLoader>("saved");
|
||||
// hooks
|
||||
const windowSize = useSize();
|
||||
const { data: currentUser } = useUser();
|
||||
const { getUserDetails } = useMember();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
peekIssue,
|
||||
} = useIssueDetail();
|
||||
const { getProjectById } = useProject();
|
||||
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
|
||||
// derived values
|
||||
const projectDetails = getProjectById(projectId);
|
||||
const issue = issueId ? getIssueById(issueId) : undefined;
|
||||
// debounced duplicate issues swr
|
||||
const { duplicateIssues } = useDebouncedDuplicateIssues(
|
||||
workspaceSlug,
|
||||
projectDetails?.workspace.toString(),
|
||||
projectDetails?.id,
|
||||
{
|
||||
name: issue?.name,
|
||||
description_html: getTextContent(issue?.description_html),
|
||||
issueId: issue?.id,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitting === "submitted") {
|
||||
setShowAlert(false);
|
||||
setTimeout(async () => setIsSubmitting("saved"), 2000);
|
||||
} else if (isSubmitting === "submitting") setShowAlert(true);
|
||||
}, [isSubmitting, setShowAlert, setIsSubmitting]);
|
||||
|
||||
if (!issue || !issue.project_id) return <></>;
|
||||
|
||||
const isPeekModeActive = Boolean(peekIssue);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg space-y-4">
|
||||
{issue.parent_id && (
|
||||
<IssueParentDetail
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
issue={issue}
|
||||
issueOperations={issueOperations}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mb-2.5 flex items-center justify-between gap-4">
|
||||
<IssueTypeSwitcher issueId={issueId} disabled={isArchived || !isEditable} />
|
||||
<div className="flex items-center gap-3">
|
||||
<NameDescriptionUpdateStatus isSubmitting={isSubmitting} />
|
||||
{duplicateIssues?.length > 0 && (
|
||||
<DeDupeIssuePopoverRoot
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
rootIssueId={issueId}
|
||||
issues={duplicateIssues}
|
||||
issueOperations={issueOperations}
|
||||
renderDeDupeActionModals={!isPeekModeActive}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IssueTitleInput
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
issueId={issue.id}
|
||||
isSubmitting={isSubmitting}
|
||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||
issueOperations={issueOperations}
|
||||
disabled={isArchived || !isEditable}
|
||||
value={issue.name}
|
||||
containerClassName="-ml-3"
|
||||
/>
|
||||
|
||||
<IssueDescriptionInput
|
||||
editorRef={editorRef}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
issueId={issue.id}
|
||||
initialValue={issue.description_html}
|
||||
disabled={isArchived || !isEditable}
|
||||
issueOperations={issueOperations}
|
||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||
containerClassName="-ml-3 border-none"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{currentUser && (
|
||||
<IssueReaction
|
||||
className="flex-shrink-0"
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
currentUser={currentUser}
|
||||
disabled={isArchived}
|
||||
/>
|
||||
)}
|
||||
{isEditable && (
|
||||
<DescriptionVersionsRoot
|
||||
className="flex-shrink-0"
|
||||
entityInformation={{
|
||||
createdAt: issue.created_at ? new Date(issue.created_at) : new Date(),
|
||||
createdByDisplayName: getUserDetails(issue.created_by ?? "")?.display_name ?? "",
|
||||
id: issueId,
|
||||
isRestoreDisabled: !isEditable || isArchived,
|
||||
}}
|
||||
fetchHandlers={{
|
||||
listDescriptionVersions: (issueId) =>
|
||||
workItemVersionService.listDescriptionVersions(workspaceSlug, projectId, issueId),
|
||||
retrieveDescriptionVersion: (issueId, versionId) =>
|
||||
workItemVersionService.retrieveDescriptionVersion(workspaceSlug, projectId, issueId, versionId),
|
||||
}}
|
||||
handleRestore={(descriptionHTML) => editorRef.current?.setEditorValue(descriptionHTML, true)}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IssueDetailWidgets
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={!isEditable || isArchived}
|
||||
renderWidgetModals={!isPeekModeActive}
|
||||
issueServiceType={EIssueServiceType.ISSUES}
|
||||
/>
|
||||
|
||||
{windowSize[0] < 768 && (
|
||||
<PeekOverviewProperties
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
issueOperations={issueOperations}
|
||||
disabled={!isEditable || isArchived}
|
||||
/>
|
||||
)}
|
||||
|
||||
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} disabled={isArchived} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import React, { useState } from "react";
|
||||
import { xor } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// hooks
|
||||
// components
|
||||
import { cn } from "@plane/utils";
|
||||
import { ModuleDropdown } from "@/components/dropdowns/module/dropdown";
|
||||
// ui
|
||||
// helpers
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// types
|
||||
import type { TIssueOperations } from "./root";
|
||||
|
||||
type TIssueModuleSelect = {
|
||||
className?: string;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
issueOperations: TIssueOperations;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const IssueModuleSelect: React.FC<TIssueModuleSelect> = observer((props) => {
|
||||
const { className = "", workspaceSlug, projectId, issueId, issueOperations, disabled = false } = props;
|
||||
const { t } = useTranslation();
|
||||
// states
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
const disableSelect = disabled || isUpdating;
|
||||
|
||||
const handleIssueModuleChange = async (moduleIds: string[]) => {
|
||||
if (!issue || !issue.module_ids) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
const updatedModuleIds = xor(issue.module_ids, moduleIds);
|
||||
const modulesToAdd: string[] = [];
|
||||
const modulesToRemove: string[] = [];
|
||||
|
||||
for (const moduleId of updatedModuleIds) {
|
||||
if (issue.module_ids.includes(moduleId)) {
|
||||
modulesToRemove.push(moduleId);
|
||||
} else {
|
||||
modulesToAdd.push(moduleId);
|
||||
}
|
||||
}
|
||||
|
||||
await issueOperations.changeModulesInIssue?.(workspaceSlug, projectId, issueId, modulesToAdd, modulesToRemove);
|
||||
|
||||
setIsUpdating(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn(`flex h-full items-center gap-1`, className)}>
|
||||
<ModuleDropdown
|
||||
projectId={projectId}
|
||||
value={issue?.module_ids ?? []}
|
||||
onChange={handleIssueModuleChange}
|
||||
placeholder={t("module.no_module")}
|
||||
disabled={disableSelect}
|
||||
className="group h-full w-full"
|
||||
buttonContainerClassName="w-full rounded"
|
||||
buttonClassName={`min-h-8 text-sm justify-between ${issue?.module_ids?.length ? "" : "text-custom-text-400"}`}
|
||||
buttonVariant="transparent-with-text"
|
||||
hideIcon
|
||||
dropdownArrow
|
||||
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
|
||||
multiple
|
||||
itemClassName="px-2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
132
apps/web/core/components/issues/issue-detail/parent-select.tsx
Normal file
132
apps/web/core/components/issues/issue-detail/parent-select.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { Pencil, X } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web components
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
|
||||
// local imports
|
||||
import { ParentIssuesListModal } from "../parent-issues-list-modal";
|
||||
|
||||
type TIssueParentSelect = {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
issueId: string;
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
handleParentIssue: (_issueId?: string | null) => Promise<void>;
|
||||
handleRemoveSubIssue: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
parentIssueId: string,
|
||||
issueId: string
|
||||
) => Promise<void>;
|
||||
workItemLink: string;
|
||||
};
|
||||
|
||||
export const IssueParentSelect: React.FC<TIssueParentSelect> = observer((props) => {
|
||||
const {
|
||||
className = "",
|
||||
disabled = false,
|
||||
issueId,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
handleParentIssue,
|
||||
handleRemoveSubIssue,
|
||||
workItemLink,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { isParentIssueModalOpen, toggleParentIssueModal } = useIssueDetail();
|
||||
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
const parentIssue = issue?.parent_id ? getIssueById(issue.parent_id) : undefined;
|
||||
const parentIssueProjectDetails =
|
||||
parentIssue && parentIssue.project_id ? getProjectById(parentIssue.project_id) : undefined;
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
if (!issue) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ParentIssuesListModal
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
isOpen={isParentIssueModalOpen === issueId}
|
||||
handleClose={() => toggleParentIssueModal(null)}
|
||||
onChange={(issue: any) => handleParentIssue(issue?.id)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"group flex items-center justify-between gap-2 px-2 py-0.5 rounded outline-none",
|
||||
{
|
||||
"cursor-not-allowed": disabled,
|
||||
"hover:bg-custom-background-80": !disabled,
|
||||
"bg-custom-background-80": isParentIssueModalOpen,
|
||||
},
|
||||
className
|
||||
)}
|
||||
onClick={() => toggleParentIssueModal(issue.id)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{issue.parent_id && parentIssue ? (
|
||||
<div className="flex items-center gap-1 bg-green-500/20 rounded px-1.5 py-1">
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={parentIssue.name} isMobile={isMobile}>
|
||||
<Link href={workItemLink} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
|
||||
{parentIssue?.project_id && parentIssueProjectDetails && (
|
||||
<IssueIdentifier
|
||||
projectId={parentIssue.project_id}
|
||||
issueTypeId={parentIssue.type_id}
|
||||
projectIdentifier={parentIssueProjectDetails?.identifier}
|
||||
issueSequenceId={parentIssue.sequence_id}
|
||||
textContainerClassName="text-xs font-medium text-green-700"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
|
||||
{!disabled && (
|
||||
<Tooltip tooltipContent={t("common.remove")} position="bottom" isMobile={isMobile}>
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleRemoveSubIssue(workspaceSlug, projectId, parentIssue.id, issueId);
|
||||
}}
|
||||
>
|
||||
<X className="h-2.5 w-2.5 text-custom-text-300 hover:text-red-500" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-custom-text-400">{t("issue.add.parent")}</span>
|
||||
)}
|
||||
{!disabled && (
|
||||
<span
|
||||
className={cn("p-1 flex-shrink-0 opacity-0 group-hover:opacity-100", {
|
||||
"text-custom-text-400": !issue.parent_id && !parentIssue,
|
||||
})}
|
||||
>
|
||||
<Pencil className="h-2.5 w-2.5 flex-shrink-0" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./root";
|
||||
|
||||
export * from "./siblings";
|
||||
export * from "./sibling-item";
|
||||
109
apps/web/core/components/issues/issue-detail/parent/root.tsx
Normal file
109
apps/web/core/components/issues/issue-detail/parent/root.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { MinusCircle } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TIssue } from "@plane/types";
|
||||
// component
|
||||
// ui
|
||||
import { ControlLink, CustomMenu } from "@plane/ui";
|
||||
// helpers
|
||||
import { generateWorkItemLink } from "@plane/utils";
|
||||
// hooks
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web components
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
|
||||
// types
|
||||
import type { TIssueOperations } from "../root";
|
||||
import { IssueParentSiblings } from "./siblings";
|
||||
|
||||
export type TIssueParentDetail = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
issue: TIssue;
|
||||
issueOperations: TIssueOperations;
|
||||
};
|
||||
|
||||
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
|
||||
);
|
||||
const stateColor = issueParentState?.color || undefined;
|
||||
|
||||
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={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 }} />
|
||||
{parentIssue.project_id && (
|
||||
<IssueIdentifier
|
||||
projectId={parentIssue.project_id}
|
||||
issueId={parentIssue.id}
|
||||
textContainerClassName="text-xs text-custom-text-200"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate text-custom-text-100">{(parentIssue?.name ?? "").substring(0, 50)}</span>
|
||||
</div>
|
||||
</ControlLink>
|
||||
|
||||
<CustomMenu ellipsis optionsClassName="p-1.5">
|
||||
<div className="border-b border-custom-border-300 text-xs font-medium text-custom-text-200">
|
||||
{t("issue.sibling.label")}
|
||||
</div>
|
||||
|
||||
<IssueParentSiblings workspaceSlug={workspaceSlug} currentIssue={issue} parentIssue={parentIssue} />
|
||||
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: null })}
|
||||
className="flex items-center gap-2 py-2 text-red-500"
|
||||
>
|
||||
<MinusCircle className="h-4 w-4" />
|
||||
<span>{t("issue.remove.parent.label")}</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// helpers
|
||||
import { generateWorkItemLink } from "@plane/utils";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
// plane web components
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
|
||||
|
||||
type TIssueParentSiblingItem = {
|
||||
workspaceSlug: string;
|
||||
issueId: string;
|
||||
};
|
||||
|
||||
export const IssueParentSiblingItem: FC<TIssueParentSiblingItem> = observer((props) => {
|
||||
const { workspaceSlug, issueId } = props;
|
||||
// hooks
|
||||
const { getProjectById } = useProject();
|
||||
const {
|
||||
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}
|
||||
onClick={() => window.open(workItemLink, "_blank", "noopener,noreferrer")}
|
||||
>
|
||||
<div className="flex items-center gap-2 py-0.5">
|
||||
{issueDetail.project_id && projectDetails?.identifier && (
|
||||
<IssueIdentifier
|
||||
projectId={issueDetail.project_id}
|
||||
issueTypeId={issueDetail.type_id}
|
||||
projectIdentifier={projectDetails?.identifier}
|
||||
issueSequenceId={issueDetail.sequence_id}
|
||||
textContainerClassName="text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import type { TIssue } from "@plane/types";
|
||||
// components
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// types
|
||||
import { IssueParentSiblingItem } from "./sibling-item";
|
||||
|
||||
export type TIssueParentSiblings = {
|
||||
workspaceSlug: string;
|
||||
currentIssue: TIssue;
|
||||
parentIssue: TIssue;
|
||||
};
|
||||
|
||||
export const IssueParentSiblings: FC<TIssueParentSiblings> = observer((props) => {
|
||||
const { workspaceSlug, currentIssue, parentIssue } = props;
|
||||
// hooks
|
||||
const {
|
||||
fetchSubIssues,
|
||||
subIssues: { subIssuesByIssueId },
|
||||
} = useIssueDetail();
|
||||
|
||||
const { isLoading } = useSWR(
|
||||
parentIssue && parentIssue.project_id
|
||||
? `ISSUE_PARENT_CHILD_ISSUES_${workspaceSlug}_${parentIssue.project_id}_${parentIssue.id}`
|
||||
: null,
|
||||
parentIssue && parentIssue.project_id
|
||||
? () => fetchSubIssues(workspaceSlug, parentIssue.project_id!, parentIssue.id)
|
||||
: null
|
||||
);
|
||||
|
||||
const subIssueIds = (parentIssue && subIssuesByIssueId(parentIssue.id)) || undefined;
|
||||
|
||||
return (
|
||||
<div className="my-1">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 whitespace-nowrap px-1 py-1 text-left text-xs text-custom-text-200">
|
||||
Loading
|
||||
</div>
|
||||
) : subIssueIds && subIssueIds.length > 0 ? (
|
||||
subIssueIds.map(
|
||||
(issueId) =>
|
||||
currentIssue.id != issueId && (
|
||||
<IssueParentSiblingItem key={issueId} workspaceSlug={workspaceSlug} issueId={issueId} />
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<div className="flex items-center gap-2 whitespace-nowrap px-1 py-1 text-left text-xs text-custom-text-200">
|
||||
No sibling work items
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./reaction-selector";
|
||||
|
||||
export * from "./issue";
|
||||
// export * from "./issue-comment";
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { IUser } from "@plane/types";
|
||||
// components
|
||||
import { cn, formatTextList } from "@plane/utils";
|
||||
// helper
|
||||
import { renderEmoji } from "@/helpers/emoji.helper";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
// types
|
||||
import { ReactionSelector } from "./reaction-selector";
|
||||
|
||||
export type TIssueCommentReaction = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
commentId: string;
|
||||
currentUser: IUser;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const IssueCommentReaction: FC<TIssueCommentReaction> = observer((props) => {
|
||||
const { workspaceSlug, projectId, commentId, currentUser, disabled = false } = props;
|
||||
|
||||
// hooks
|
||||
const {
|
||||
commentReaction: { getCommentReactionsByCommentId, commentReactionsByUser, getCommentReactionById },
|
||||
createCommentReaction,
|
||||
removeCommentReaction,
|
||||
} = useIssueDetail();
|
||||
const { getUserDetails } = useMember();
|
||||
|
||||
const reactionIds = getCommentReactionsByCommentId(commentId);
|
||||
const userReactions = commentReactionsByUser(commentId, currentUser.id).map((r) => r.reaction);
|
||||
|
||||
const issueCommentReactionOperations = useMemo(
|
||||
() => ({
|
||||
create: async (reaction: string) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !commentId) throw new Error("Missing fields");
|
||||
await createCommentReaction(workspaceSlug, projectId, commentId, reaction);
|
||||
setToast({
|
||||
title: "Success!",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: "Reaction created successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
title: "Error!",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: "Reaction creation failed",
|
||||
});
|
||||
}
|
||||
},
|
||||
remove: async (reaction: string) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !commentId || !currentUser?.id) throw new Error("Missing fields");
|
||||
removeCommentReaction(workspaceSlug, projectId, commentId, reaction, currentUser.id);
|
||||
setToast({
|
||||
title: "Success!",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: "Reaction removed successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
title: "Error!",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: "Reaction remove failed",
|
||||
});
|
||||
}
|
||||
},
|
||||
react: async (reaction: string) => {
|
||||
if (userReactions.includes(reaction)) await issueCommentReactionOperations.remove(reaction);
|
||||
else await issueCommentReactionOperations.create(reaction);
|
||||
},
|
||||
}),
|
||||
[workspaceSlug, projectId, commentId, currentUser, createCommentReaction, removeCommentReaction, userReactions]
|
||||
);
|
||||
|
||||
const getReactionUsers = (reaction: string): string => {
|
||||
const reactionUsers = (reactionIds?.[reaction] || [])
|
||||
.map((reactionId) => {
|
||||
const reactionDetails = getCommentReactionById(reactionId);
|
||||
return reactionDetails
|
||||
? getUserDetails(reactionDetails?.actor)?.display_name || reactionDetails?.display_name
|
||||
: null;
|
||||
})
|
||||
.filter((displayName): displayName is string => !!displayName);
|
||||
const formattedUsers = formatTextList(reactionUsers);
|
||||
return formattedUsers;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative mt-4 flex items-center gap-1.5">
|
||||
{!disabled && (
|
||||
<ReactionSelector
|
||||
size="md"
|
||||
position="top"
|
||||
value={userReactions}
|
||||
onSelect={issueCommentReactionOperations.react}
|
||||
/>
|
||||
)}
|
||||
|
||||
{reactionIds &&
|
||||
Object.keys(reactionIds || {}).map(
|
||||
(reaction) =>
|
||||
reactionIds[reaction]?.length > 0 && (
|
||||
<>
|
||||
<Tooltip tooltipContent={getReactionUsers(reaction)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && issueCommentReactionOperations.react(reaction)}
|
||||
key={reaction}
|
||||
className={cn(
|
||||
"flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100",
|
||||
userReactions.includes(reaction) ? "bg-custom-primary-100/10" : "bg-custom-background-80",
|
||||
{
|
||||
"cursor-not-allowed": disabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span>{renderEmoji(reaction)}</span>
|
||||
<span className={userReactions.includes(reaction) ? "text-custom-primary-100" : ""}>
|
||||
{(reactionIds || {})[reaction].length}{" "}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
133
apps/web/core/components/issues/issue-detail/reactions/issue.tsx
Normal file
133
apps/web/core/components/issues/issue-detail/reactions/issue.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { IUser } from "@plane/types";
|
||||
// hooks
|
||||
// ui
|
||||
import { cn, formatTextList } from "@plane/utils";
|
||||
// helpers
|
||||
import { renderEmoji } from "@/helpers/emoji.helper";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
// types
|
||||
import { ReactionSelector } from "./reaction-selector";
|
||||
|
||||
export type TIssueReaction = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
currentUser: IUser;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const IssueReaction: FC<TIssueReaction> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, currentUser, disabled = false, className = "" } = props;
|
||||
// hooks
|
||||
const {
|
||||
reaction: { getReactionsByIssueId, reactionsByUser, getReactionById },
|
||||
createReaction,
|
||||
removeReaction,
|
||||
} = useIssueDetail();
|
||||
const { getUserDetails } = useMember();
|
||||
|
||||
const reactionIds = getReactionsByIssueId(issueId);
|
||||
const userReactions = reactionsByUser(issueId, currentUser.id).map((r) => r.reaction);
|
||||
|
||||
const issueReactionOperations = useMemo(
|
||||
() => ({
|
||||
create: async (reaction: string) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
|
||||
await createReaction(workspaceSlug, projectId, issueId, reaction);
|
||||
setToast({
|
||||
title: "Success!",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: "Reaction created successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
title: "Error!",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: "Reaction creation failed",
|
||||
});
|
||||
}
|
||||
},
|
||||
remove: async (reaction: string) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId || !currentUser?.id) throw new Error("Missing fields");
|
||||
await removeReaction(workspaceSlug, projectId, issueId, reaction, currentUser.id);
|
||||
setToast({
|
||||
title: "Success!",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: "Reaction removed successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
title: "Error!",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: "Reaction remove failed",
|
||||
});
|
||||
}
|
||||
},
|
||||
react: async (reaction: string) => {
|
||||
if (userReactions.includes(reaction)) await issueReactionOperations.remove(reaction);
|
||||
else await issueReactionOperations.create(reaction);
|
||||
},
|
||||
}),
|
||||
[workspaceSlug, projectId, issueId, currentUser, createReaction, removeReaction, userReactions]
|
||||
);
|
||||
|
||||
const getReactionUsers = (reaction: string): string => {
|
||||
const reactionUsers = (reactionIds?.[reaction] || [])
|
||||
.map((reactionId) => {
|
||||
const reactionDetails = getReactionById(reactionId);
|
||||
return reactionDetails
|
||||
? getUserDetails(reactionDetails?.actor)?.display_name || reactionDetails?.display_name
|
||||
: null;
|
||||
})
|
||||
.filter((displayName): displayName is string => !!displayName);
|
||||
|
||||
const formattedUsers = formatTextList(reactionUsers);
|
||||
return formattedUsers;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("relative mt-4 flex items-center gap-1.5", className)}>
|
||||
{!disabled && (
|
||||
<ReactionSelector size="md" position="top" value={userReactions} onSelect={issueReactionOperations.react} />
|
||||
)}
|
||||
{reactionIds &&
|
||||
Object.keys(reactionIds || {}).map(
|
||||
(reaction) =>
|
||||
reactionIds[reaction]?.length > 0 && (
|
||||
<>
|
||||
<Tooltip tooltipContent={getReactionUsers(reaction)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && issueReactionOperations.react(reaction)}
|
||||
key={reaction}
|
||||
className={cn(
|
||||
"flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100",
|
||||
userReactions.includes(reaction) ? "bg-custom-primary-100/10" : "bg-custom-background-80",
|
||||
{
|
||||
"cursor-not-allowed": disabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span>{renderEmoji(reaction)}</span>
|
||||
<span className={userReactions.includes(reaction) ? "text-custom-primary-100" : ""}>
|
||||
{(reactionIds || {})[reaction].length}{" "}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Fragment } from "react";
|
||||
import { SmilePlus } from "lucide-react";
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
// helper
|
||||
import { renderEmoji } from "@/helpers/emoji.helper";
|
||||
// icons
|
||||
|
||||
const reactionEmojis = ["128077", "128078", "128516", "128165", "128533", "129505", "9992", "128064"];
|
||||
|
||||
interface Props {
|
||||
size?: "sm" | "md" | "lg";
|
||||
position?: "top" | "bottom";
|
||||
value?: string | string[] | null;
|
||||
onSelect: (emoji: string) => void;
|
||||
}
|
||||
|
||||
export const ReactionSelector: React.FC<Props> = (props) => {
|
||||
const { onSelect, position, size } = props;
|
||||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
{({ open, close: closePopover }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`${
|
||||
open ? "" : "text-opacity-90"
|
||||
} group inline-flex items-center rounded-md bg-custom-background-80 focus:outline-none`}
|
||||
>
|
||||
<span
|
||||
className={`flex items-center justify-center rounded-md px-2 ${
|
||||
size === "sm" ? "h-6 w-6" : size === "md" ? "h-7 w-7" : "h-8 w-8"
|
||||
}`}
|
||||
>
|
||||
<SmilePlus className="h-3.5 w-3.5 text-custom-text-100" />
|
||||
</span>
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel
|
||||
className={`absolute z-10 bg-custom-sidebar-background-100 ${
|
||||
position === "top" ? "-top-12" : "-bottom-12"
|
||||
}`}
|
||||
>
|
||||
<div className="rounded-md border border-custom-border-200 bg-custom-sidebar-background-100 p-1">
|
||||
<div className="flex gap-x-1">
|
||||
{reactionEmojis.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelect(emoji);
|
||||
closePopover();
|
||||
}}
|
||||
className="flex select-none items-center justify-between rounded-md p-1 text-sm hover:bg-custom-sidebar-background-90"
|
||||
>
|
||||
{renderEmoji(emoji)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
167
apps/web/core/components/issues/issue-detail/relation-select.tsx
Normal file
167
apps/web/core/components/issues/issue-detail/relation-select.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { Pencil, X } from "lucide-react";
|
||||
// Plane
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { ISearchIssueResponse } from "@plane/types";
|
||||
import { cn, generateWorkItemLink } from "@plane/utils";
|
||||
// components
|
||||
import { ExistingIssuesListModal } from "@/components/core/modals/existing-issues-list-modal";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// Plane web imports
|
||||
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
|
||||
import type { TIssueRelationTypes } from "@/plane-web/types";
|
||||
import type { TRelationObject } from "../issue-detail-widgets/relations";
|
||||
|
||||
type TIssueRelationSelect = {
|
||||
className?: string;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
relationKey: TIssueRelationTypes;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const IssueRelationSelect: React.FC<TIssueRelationSelect> = observer((props) => {
|
||||
const { className = "", workspaceSlug, projectId, issueId, relationKey, disabled = false } = props;
|
||||
// hooks
|
||||
const { getProjectById } = useProject();
|
||||
const {
|
||||
createRelation,
|
||||
removeRelation,
|
||||
relation: { getRelationByIssueIdRelationType },
|
||||
isRelationModalOpen,
|
||||
toggleRelationModal,
|
||||
} = useIssueDetail();
|
||||
const { issueMap } = useIssues();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const relationIssueIds = getRelationByIssueIdRelationType(issueId, relationKey);
|
||||
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
|
||||
|
||||
const onSubmit = async (data: ISearchIssueResponse[]) => {
|
||||
if (data.length === 0) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Please select at least one work item.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await createRelation(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
relationKey,
|
||||
data.map((i) => i.id)
|
||||
);
|
||||
|
||||
toggleRelationModal(null, null);
|
||||
};
|
||||
|
||||
if (!relationIssueIds) return null;
|
||||
|
||||
const isRelationKeyModalActive =
|
||||
isRelationModalOpen?.relationType === relationKey && isRelationModalOpen?.issueId === issueId;
|
||||
|
||||
const currRelationOption: TRelationObject | undefined = ISSUE_RELATION_OPTIONS[relationKey];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExistingIssuesListModal
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
isOpen={isRelationKeyModalActive}
|
||||
handleClose={() => toggleRelationModal(null, null)}
|
||||
searchParams={{ issue_relation: true, issue_id: issueId }}
|
||||
handleOnSubmit={onSubmit}
|
||||
workspaceLevelToggle
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"group flex items-center gap-2 rounded px-2 py-0.5 outline-none",
|
||||
{
|
||||
"cursor-not-allowed": disabled,
|
||||
"hover:bg-custom-background-80": !disabled,
|
||||
"bg-custom-background-80": isRelationKeyModalActive,
|
||||
},
|
||||
className
|
||||
)}
|
||||
onClick={() => toggleRelationModal(issueId, relationKey)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className="flex w-full items-start justify-between">
|
||||
{relationIssueIds.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-2 py-0.5">
|
||||
{relationIssueIds.map((relationIssueId) => {
|
||||
const currentIssue = issueMap[relationIssueId];
|
||||
if (!currentIssue) return;
|
||||
|
||||
const projectDetails = getProjectById(currentIssue.project_id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={relationIssueId}
|
||||
className={`group flex items-center gap-1 rounded px-1.5 pb-1 pt-1 leading-3 hover:bg-custom-background-90 ${currRelationOption?.className}`}
|
||||
>
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={currentIssue.name} isMobile={isMobile}>
|
||||
<Link
|
||||
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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{`${projectDetails?.identifier}-${currentIssue?.sequence_id}`}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
{!disabled && (
|
||||
<Tooltip tooltipContent="Remove" position="bottom" isMobile={isMobile}>
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
removeRelation(workspaceSlug, projectId, issueId, relationKey, relationIssueId);
|
||||
}}
|
||||
>
|
||||
<X className="h-2.5 w-2.5 text-custom-text-300 hover:text-red-500" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-custom-text-400">{currRelationOption?.placeholder}</span>
|
||||
)}
|
||||
{!disabled && (
|
||||
<span
|
||||
className={cn("flex-shrink-0 p-1 opacity-0 group-hover:opacity-100", {
|
||||
"text-custom-text-400": relationIssueIds.length === 0,
|
||||
})}
|
||||
>
|
||||
<Pencil className="h-2.5 w-2.5 flex-shrink-0" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
});
|
||||
335
apps/web/core/components/issues/issue-detail/root.tsx
Normal file
335
apps/web/core/components/issues/issue-detail/root.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/propel/toast";
|
||||
import type { TIssue } from "@plane/types";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
// components
|
||||
import { EmptyState } from "@/components/common/empty-state";
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// images
|
||||
import emptyIssue from "@/public/empty-state/issue.svg";
|
||||
// local components
|
||||
import { IssuePeekOverview } from "../peek-overview";
|
||||
import { IssueMainContent } from "./main-content";
|
||||
import { IssueDetailsSidebar } from "./sidebar";
|
||||
|
||||
export type TIssueOperations = {
|
||||
fetch: (workspaceSlug: string, projectId: string, issueId: string, loader?: boolean) => Promise<void>;
|
||||
update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
|
||||
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
archive?: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
restore?: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
addCycleToIssue?: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
|
||||
addIssueToCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise<void>;
|
||||
removeIssueFromCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
|
||||
removeIssueFromModule?: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
moduleId: string,
|
||||
issueId: string
|
||||
) => Promise<void>;
|
||||
changeModulesInIssue?: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
addModuleIds: string[],
|
||||
removeModuleIds: string[]
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export type TIssueDetailRoot = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
is_archived?: boolean;
|
||||
};
|
||||
|
||||
export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
||||
const { t } = useTranslation();
|
||||
const { workspaceSlug, projectId, issueId, is_archived = false } = props;
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
fetchIssue,
|
||||
updateIssue,
|
||||
removeIssue,
|
||||
archiveIssue,
|
||||
addCycleToIssue,
|
||||
addIssueToCycle,
|
||||
removeIssueFromCycle,
|
||||
changeModulesInIssue,
|
||||
removeIssueFromModule,
|
||||
} = useIssueDetail();
|
||||
const {
|
||||
issues: { removeIssue: removeArchivedIssue },
|
||||
} = useIssues(EIssuesStoreType.ARCHIVED);
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { issueDetailSidebarCollapsed } = useAppTheme();
|
||||
|
||||
const issueOperations: TIssueOperations = useMemo(
|
||||
() => ({
|
||||
fetch: async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
try {
|
||||
await fetchIssue(workspaceSlug, projectId, issueId);
|
||||
} catch (error) {
|
||||
console.error("Error fetching the parent issue:", error);
|
||||
}
|
||||
},
|
||||
update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
|
||||
try {
|
||||
await updateIssue(workspaceSlug, projectId, issueId, data);
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error in updating issue:", error);
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
setToast({
|
||||
title: t("common.error.label"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: t("entity.update.failed", { entity: t("issue.label") }),
|
||||
});
|
||||
}
|
||||
},
|
||||
remove: async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
try {
|
||||
if (is_archived) await removeArchivedIssue(workspaceSlug, projectId, issueId);
|
||||
else await removeIssue(workspaceSlug, projectId, issueId);
|
||||
setToast({
|
||||
title: t("common.success"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: t("entity.delete.success", { entity: t("issue.label") }),
|
||||
});
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error in deleting issue:", error);
|
||||
setToast({
|
||||
title: t("common.error.label"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: t("entity.delete.failed", { entity: t("issue.label") }),
|
||||
});
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
},
|
||||
archive: async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
try {
|
||||
await archiveIssue(workspaceSlug, projectId, issueId);
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error in archiving issue:", error);
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
},
|
||||
addCycleToIssue: async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => {
|
||||
try {
|
||||
await addCycleToIssue(workspaceSlug, projectId, cycleId, issueId);
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("common.error.label"),
|
||||
message: t("issue.add.cycle.failed"),
|
||||
});
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
},
|
||||
addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => {
|
||||
try {
|
||||
await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("common.error.label"),
|
||||
message: t("issue.add.cycle.failed"),
|
||||
});
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
},
|
||||
removeIssueFromCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => {
|
||||
try {
|
||||
const removeFromCyclePromise = removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId);
|
||||
setPromiseToast(removeFromCyclePromise, {
|
||||
loading: t("issue.remove.cycle.loading"),
|
||||
success: {
|
||||
title: t("common.success"),
|
||||
message: () => t("issue.remove.cycle.success"),
|
||||
},
|
||||
error: {
|
||||
title: t("common.error.label"),
|
||||
message: () => t("issue.remove.cycle.failed"),
|
||||
},
|
||||
});
|
||||
await removeFromCyclePromise;
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
},
|
||||
removeIssueFromModule: async (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => {
|
||||
try {
|
||||
const removeFromModulePromise = removeIssueFromModule(workspaceSlug, projectId, moduleId, issueId);
|
||||
setPromiseToast(removeFromModulePromise, {
|
||||
loading: t("issue.remove.module.loading"),
|
||||
success: {
|
||||
title: t("common.success"),
|
||||
message: () => t("issue.remove.module.success"),
|
||||
},
|
||||
error: {
|
||||
title: t("common.error.label"),
|
||||
message: () => t("issue.remove.module.failed"),
|
||||
},
|
||||
});
|
||||
await removeFromModulePromise;
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
},
|
||||
changeModulesInIssue: async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
addModuleIds: string[],
|
||||
removeModuleIds: string[]
|
||||
) => {
|
||||
const promise = await changeModulesInIssue(workspaceSlug, projectId, issueId, addModuleIds, removeModuleIds);
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
return promise;
|
||||
},
|
||||
}),
|
||||
[
|
||||
is_archived,
|
||||
fetchIssue,
|
||||
updateIssue,
|
||||
removeIssue,
|
||||
archiveIssue,
|
||||
removeArchivedIssue,
|
||||
addIssueToCycle,
|
||||
addCycleToIssue,
|
||||
removeIssueFromCycle,
|
||||
changeModulesInIssue,
|
||||
removeIssueFromModule,
|
||||
t,
|
||||
issueId,
|
||||
]
|
||||
);
|
||||
|
||||
// issue details
|
||||
const issue = getIssueById(issueId);
|
||||
// checking if issue is editable, based on user role
|
||||
const isEditable = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug,
|
||||
projectId
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!issue ? (
|
||||
<EmptyState
|
||||
image={emptyIssue}
|
||||
title={t("issue.empty_state.issue_detail.title")}
|
||||
description={t("issue.empty_state.issue_detail.description")}
|
||||
primaryButton={{
|
||||
text: t("issue.empty_state.issue_detail.primary_button.text"),
|
||||
onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/issues`),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full overflow-hidden">
|
||||
<div className="max-w-2/3 h-full w-full space-y-8 overflow-y-auto px-9 py-5">
|
||||
<IssueMainContent
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
issueOperations={issueOperations}
|
||||
isEditable={isEditable}
|
||||
isArchived={is_archived}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="fixed right-0 z-[5] h-full w-full min-w-[300px] border-l border-custom-border-200 bg-custom-sidebar-background-100 py-5 sm:w-1/2 md:relative md:w-1/3 lg:min-w-80 xl:min-w-96"
|
||||
style={issueDetailSidebarCollapsed ? { right: `-${window?.innerWidth || 0}px` } : {}}
|
||||
>
|
||||
<IssueDetailsSidebar
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
issueOperations={issueOperations}
|
||||
isEditable={!is_archived && isEditable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* peek overview */}
|
||||
<IssuePeekOverview />
|
||||
</>
|
||||
);
|
||||
});
|
||||
310
apps/web/core/components/issues/issue-detail/sidebar.tsx
Normal file
310
apps/web/core/components/issues/issue-detail/sidebar.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { CalendarCheck2, CalendarClock, LayoutPanelTop, Signal, Tag, Triangle, UserCircle2, Users } from "lucide-react";
|
||||
// i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { CycleIcon, DoubleCircleIcon, ModuleIcon } from "@plane/propel/icons";
|
||||
import { cn, getDate, renderFormattedPayloadDate, shouldHighlightIssueDueDate } from "@plane/utils";
|
||||
// components
|
||||
import { DateDropdown } from "@/components/dropdowns/date";
|
||||
import { EstimateDropdown } from "@/components/dropdowns/estimate";
|
||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
|
||||
import { PriorityDropdown } from "@/components/dropdowns/priority";
|
||||
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
|
||||
// hooks
|
||||
import { useProjectEstimates } from "@/hooks/store/estimates";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
// plane web components
|
||||
// components
|
||||
import { WorkItemAdditionalSidebarProperties } from "@/plane-web/components/issues/issue-details/additional-properties";
|
||||
import { IssueParentSelectRoot } from "@/plane-web/components/issues/issue-details/parent-select-root";
|
||||
import { IssueWorklogProperty } from "@/plane-web/components/issues/worklog/property";
|
||||
import { IssueCycleSelect } from "./cycle-select";
|
||||
import { IssueLabel } from "./label";
|
||||
import { IssueModuleSelect } from "./module-select";
|
||||
import type { TIssueOperations } from "./root";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
issueOperations: TIssueOperations;
|
||||
isEditable: boolean;
|
||||
};
|
||||
|
||||
export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
const { t } = useTranslation();
|
||||
const { workspaceSlug, projectId, issueId, issueOperations, isEditable } = props;
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { areEstimateEnabledByProjectId } = useProjectEstimates();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getUserDetails } = useMember();
|
||||
const { getStateById } = useProjectState();
|
||||
const issue = getIssueById(issueId);
|
||||
if (!issue) return <></>;
|
||||
|
||||
const createdByDetails = getUserDetails(issue.created_by);
|
||||
|
||||
// derived values
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
const stateDetails = getStateById(issue.state_id);
|
||||
|
||||
const minDate = issue.start_date ? getDate(issue.start_date) : null;
|
||||
minDate?.setDate(minDate.getDate());
|
||||
|
||||
const maxDate = issue.target_date ? getDate(issue.target_date) : null;
|
||||
maxDate?.setDate(maxDate.getDate());
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center h-full w-full flex-col divide-y-2 divide-custom-border-200 overflow-hidden">
|
||||
<div className="h-full w-full overflow-y-auto px-6">
|
||||
<h5 className="mt-6 text-sm font-medium">{t("common.properties")}</h5>
|
||||
{/* TODO: render properties using a common component */}
|
||||
<div className={`mb-2 mt-3 space-y-2.5 ${!isEditable ? "opacity-60" : ""}`}>
|
||||
<div className="flex h-8 items-center gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
||||
<DoubleCircleIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t("common.state")}</span>
|
||||
</div>
|
||||
<StateDropdown
|
||||
value={issue?.state_id}
|
||||
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
disabled={!isEditable}
|
||||
buttonVariant="transparent-with-text"
|
||||
className="group w-3/5 flex-grow"
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName="text-sm"
|
||||
dropdownArrow
|
||||
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex h-8 items-center gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
||||
<Users className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t("common.assignees")}</span>
|
||||
</div>
|
||||
<MemberDropdown
|
||||
value={issue?.assignee_ids ?? undefined}
|
||||
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })}
|
||||
disabled={!isEditable}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
placeholder={t("issue.add.assignee")}
|
||||
multiple
|
||||
buttonVariant={issue?.assignee_ids?.length > 1 ? "transparent-without-text" : "transparent-with-text"}
|
||||
className="group w-3/5 flex-grow"
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName={`text-sm justify-between ${
|
||||
issue?.assignee_ids?.length > 0 ? "" : "text-custom-text-400"
|
||||
}`}
|
||||
hideIcon={issue.assignee_ids?.length === 0}
|
||||
dropdownArrow
|
||||
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex h-8 items-center gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
||||
<Signal className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t("common.priority")}</span>
|
||||
</div>
|
||||
<PriorityDropdown
|
||||
value={issue?.priority}
|
||||
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { priority: val })}
|
||||
disabled={!isEditable}
|
||||
buttonVariant="border-with-text"
|
||||
className="w-3/5 flex-grow rounded px-2 hover:bg-custom-background-80"
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName="w-min h-auto whitespace-nowrap"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{createdByDetails && (
|
||||
<div className="flex h-8 items-center gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
||||
<UserCircle2 className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t("common.created_by")}</span>
|
||||
</div>
|
||||
<div className="w-full h-full flex items-center gap-1.5 rounded px-2 py-0.5 text-sm justify-between cursor-not-allowed">
|
||||
<ButtonAvatars showTooltip userIds={createdByDetails.id} />
|
||||
<span className="flex-grow truncate text-xs leading-5">{createdByDetails?.display_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex h-8 items-center gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
||||
<CalendarClock className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t("common.order_by.start_date")}</span>
|
||||
</div>
|
||||
<DateDropdown
|
||||
placeholder={t("issue.add.start_date")}
|
||||
value={issue.start_date}
|
||||
onChange={(val) =>
|
||||
issueOperations.update(workspaceSlug, projectId, issueId, {
|
||||
start_date: val ? renderFormattedPayloadDate(val) : null,
|
||||
})
|
||||
}
|
||||
maxDate={maxDate ?? undefined}
|
||||
disabled={!isEditable}
|
||||
buttonVariant="transparent-with-text"
|
||||
className="group w-3/5 flex-grow"
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName={`text-sm ${issue?.start_date ? "" : "text-custom-text-400"}`}
|
||||
hideIcon
|
||||
clearIconClassName="h-3 w-3 hidden group-hover:inline"
|
||||
// TODO: add this logic
|
||||
// showPlaceholderIcon
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex h-8 items-center gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
||||
<CalendarCheck2 className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t("common.order_by.due_date")}</span>
|
||||
</div>
|
||||
<DateDropdown
|
||||
placeholder={t("issue.add.due_date")}
|
||||
value={issue.target_date}
|
||||
onChange={(val) =>
|
||||
issueOperations.update(workspaceSlug, projectId, issueId, {
|
||||
target_date: val ? renderFormattedPayloadDate(val) : null,
|
||||
})
|
||||
}
|
||||
minDate={minDate ?? undefined}
|
||||
disabled={!isEditable}
|
||||
buttonVariant="transparent-with-text"
|
||||
className="group w-3/5 flex-grow"
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName={cn("text-sm", {
|
||||
"text-custom-text-400": !issue.target_date,
|
||||
"text-red-500": shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group),
|
||||
})}
|
||||
hideIcon
|
||||
clearIconClassName="h-3 w-3 hidden group-hover:inline !text-custom-text-100"
|
||||
// TODO: add this logic
|
||||
// showPlaceholderIcon
|
||||
/>
|
||||
</div>
|
||||
|
||||
{projectId && areEstimateEnabledByProjectId(projectId) && (
|
||||
<div className="flex h-8 items-center gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
||||
<Triangle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t("common.estimate")}</span>
|
||||
</div>
|
||||
<EstimateDropdown
|
||||
value={issue?.estimate_point ?? undefined}
|
||||
onChange={(val: string | undefined) =>
|
||||
issueOperations.update(workspaceSlug, projectId, issueId, { estimate_point: val })
|
||||
}
|
||||
projectId={projectId}
|
||||
disabled={!isEditable}
|
||||
buttonVariant="transparent-with-text"
|
||||
className="group w-3/5 flex-grow"
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName={`text-sm ${issue?.estimate_point !== null ? "" : "text-custom-text-400"}`}
|
||||
placeholder={t("common.none")}
|
||||
hideIcon
|
||||
dropdownArrow
|
||||
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projectDetails?.module_view && (
|
||||
<div className="flex min-h-8 gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 gap-1 pt-2 text-sm text-custom-text-300">
|
||||
<ModuleIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t("common.modules")}</span>
|
||||
</div>
|
||||
<IssueModuleSelect
|
||||
className="w-3/5 flex-grow"
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
issueOperations={issueOperations}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projectDetails?.cycle_view && (
|
||||
<div className="flex h-8 items-center gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
||||
<CycleIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t("common.cycle")}</span>
|
||||
</div>
|
||||
<IssueCycleSelect
|
||||
className="w-3/5 flex-grow"
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
issueOperations={issueOperations}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex h-8 items-center gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
||||
<LayoutPanelTop className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t("common.parent")}</span>
|
||||
</div>
|
||||
<IssueParentSelectRoot
|
||||
className="h-full w-3/5 flex-grow"
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
issueOperations={issueOperations}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-8 gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 gap-1 pt-2 text-sm text-custom-text-300">
|
||||
<Tag className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t("common.labels")}</span>
|
||||
</div>
|
||||
<div className="h-full min-h-8 w-3/5 flex-grow">
|
||||
<IssueLabel
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IssueWorklogProperty
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
|
||||
<WorkItemAdditionalSidebarProperties
|
||||
workItemId={issue.id}
|
||||
workItemTypeId={issue.type_id}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
isEditable={isEditable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
101
apps/web/core/components/issues/issue-detail/subscription.tsx
Normal file
101
apps/web/core/components/issues/issue-detail/subscription.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { isNil } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
import { Bell, BellOff } from "lucide-react";
|
||||
// plane-i18n
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// UI
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
import { Loader } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
|
||||
export type TIssueSubscription = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
serviceType?: EIssueServiceType;
|
||||
};
|
||||
|
||||
export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, serviceType = EIssueServiceType.ISSUES } = props;
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const {
|
||||
subscription: { getSubscriptionByIssueId },
|
||||
createSubscription,
|
||||
removeSubscription,
|
||||
} = useIssueDetail(serviceType);
|
||||
// state
|
||||
const [loading, setLoading] = useState(false);
|
||||
// hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
const isSubscribed = getSubscriptionByIssueId(issueId);
|
||||
const isEditable = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug,
|
||||
projectId
|
||||
);
|
||||
|
||||
const handleSubscription = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isSubscribed) await removeSubscription(workspaceSlug, projectId, issueId);
|
||||
else await createSubscription(workspaceSlug, projectId, issueId);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("toast.success"),
|
||||
message: isSubscribed
|
||||
? t("issue.subscription.actions.unsubscribed")
|
||||
: t("issue.subscription.actions.subscribed"),
|
||||
});
|
||||
setLoading(false);
|
||||
} catch {
|
||||
setLoading(false);
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: t("common.error.message"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isNil(isSubscribed))
|
||||
return (
|
||||
<Loader>
|
||||
<Loader.Item width="106px" height="28px" />
|
||||
</Loader>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
size="sm"
|
||||
prependIcon={isSubscribed ? <BellOff /> : <Bell className="h-3 w-3" />}
|
||||
variant="outline-primary"
|
||||
className="hover:!bg-custom-primary-100/20"
|
||||
onClick={handleSubscription}
|
||||
disabled={!isEditable || loading}
|
||||
>
|
||||
{loading ? (
|
||||
<span>
|
||||
<span className="hidden sm:block">{t("common.loading")}</span>
|
||||
</span>
|
||||
) : isSubscribed ? (
|
||||
<div className="hidden sm:block">{t("common.actions.unsubscribe")}</div>
|
||||
) : (
|
||||
<div className="hidden sm:block">{t("common.actions.subscribe")}</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user