snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-15 15:14:58 +05:00
commit 23059a1de3
5252 changed files with 573834 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function IssueCycleSelect(props: TIssueCycleSelect) {
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 h-7.5 rounded-sm"
buttonClassName={`text-body-xs-medium justify-between ${issue?.cycle_id ? "" : "text-placeholder"}`}
placeholder={t("cycle.no_cycle")}
hideIcon
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
/>
</div>
);
});

View File

@@ -0,0 +1,65 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import type { TIdentifierTextProps, TIdentifierTextVariant, TIssueIdentifierSize } from "@plane/types";
import { cn } from "@plane/utils";
const SIZE_MAP: Record<TIssueIdentifierSize, string> = {
xs: "text-caption-sm-regular",
sm: "text-caption-sm-medium",
md: "text-caption-md-medium",
lg: "text-caption-lg-medium",
};
const VARIANT_MAP: Record<TIdentifierTextVariant, string> = {
default: "text-tertiary",
secondary: "text-secondary",
tertiary: "text-tertiary",
primary: "text-primary",
"primary-subtle": "text-primary/80",
success: "text-success-primary",
};
export function IdentifierText(props: TIdentifierTextProps) {
const { identifier, enableClickToCopyIdentifier = false, size = "lg", variant = "default" } = props;
// handlers
const handleCopyIssueIdentifier = () => {
if (enableClickToCopyIdentifier) {
navigator.clipboard
.writeText(identifier)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Work item ID copied to clipboard",
});
return;
})
.catch(() => {
console.error("Failed to copy work item ID");
});
}
};
const textSizeClassName = SIZE_MAP[size];
const variantClassName = VARIANT_MAP[variant];
return (
<Tooltip label="Click to copy" disabled={!enableClickToCopyIdentifier}>
<button
type="button"
className={cn("text-12 font-medium whitespace-nowrap text-tertiary", textSizeClassName, variantClassName, {
"cursor-pointer": enableClickToCopyIdentifier,
})}
onClick={handleCopyIssueIdentifier}
disabled={!enableClickToCopyIdentifier}
>
{identifier}
</button>
</Tooltip>
);
}

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,86 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import type { E_SORT_ORDER, TActivityFilters, EActivityFilterType } from "@plane/constants";
import { BASE_ACTIVITY_FILTER_TYPES, 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";
// 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 = observer(function IssueActivityCommentRoot(props: TIssueActivityCommentRoot) {
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);
return (
<div>
{filteredActivityAndComments.map((activityComment, index) => {
const comment = getCommentById(activityComment.id);
return activityComment.activity_type === "COMMENT" ? (
<CommentCard
key={activityComment.id}
workspaceSlug={workspaceSlug}
entityId={issueId}
comment={comment}
activityOperations={activityOperations}
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
showAccessSpecifier={!!showAccessSpecifier}
showCopyLinkOption={!isIntakeIssue}
disabled={disabled}
projectId={projectId}
enableReplies
/>
) : BASE_ACTIVITY_FILTER_TYPES.includes(activityComment.activity_type as EActivityFilterType) ? (
<IssueActivityItem
key={activityComment.id}
activityId={activityComment.id}
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
/>
) : null;
})}
</div>
);
});

View File

@@ -0,0 +1,68 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { FilterOutline, TickOutline } from "@makeplane/propel/icons";
// plane imports
import type { TActivityFilters, TActivityFilterOption } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IconButton } from "@plane/propel/icon-button";
import { PopoverMenu } from "@plane/ui";
// helper
import { cn } from "@plane/utils";
// constants
type TActivityFilter = {
selectedFilters: TActivityFilters[];
filterOptions: TActivityFilterOption[];
};
export const ActivityFilter = observer(function ActivityFilter(props: TActivityFilter) {
const { selectedFilters = [], filterOptions } = props;
// hooks
const { t } = useTranslation();
return (
<PopoverMenu
buttonClassName="outline-none"
button={
<>
<IconButton variant="tertiary" icon={FilterOutline} />
{selectedFilters.length < filterOptions.length && (
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-accent-primary" />
)}
</>
}
panelClassName="p-2 rounded-md border border-subtle bg-surface-1"
data={filterOptions}
keyExtractor={(item) => item.key}
render={(item) => (
<div
key={item.key}
className="flex cursor-pointer items-center gap-2 rounded-xs p-1 px-2 text-13 transition-all hover:bg-layer-1"
onClick={item.onClick}
>
<div
className={cn(
"flex h-3 w-3 flex-shrink-0 items-center justify-center rounded-xs bg-surface-2 transition-all",
{
"bg-accent-primary text-on-color": item.isSelected,
"bg-layer-1 text-placeholder": item.isSelected && selectedFilters.length === 1,
"bg-surface-2": !item.isSelected,
}
)}
>
{item.isSelected && <TickOutline className="h-2.5 w-2.5" />}
</div>
<div className={cn("whitespace-nowrap", item.isSelected ? "text-primary" : "text-secondary")}>
{t(item.labelTranslationKey)}
</div>
</div>
)}
/>
);
});

View File

@@ -0,0 +1,44 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { ArchiveOutline, RefreshOutline } from "@makeplane/propel/icons";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent } from "./";
// ui
type TIssueArchivedAtActivity = { activityId: string; ends: "top" | "bottom" | undefined };
export const IssueArchivedAtActivity = observer(function IssueArchivedAtActivity(props: TIssueArchivedAtActivity) {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={
activity.new_value === "restore" ? (
<RefreshOutline className="h-3.5 w-3.5 text-secondary" aria-hidden="true" />
) : (
<ArchiveOutline className="h-3.5 w-3.5 text-secondary" 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>
);
});

View File

@@ -0,0 +1,48 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// icons
import { MembersOutline } from "@makeplane/propel/icons";
// 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 = observer(function IssueAssigneeActivity(props: TIssueAssigneeActivity) {
const { activityId, ends, showIssue = true } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<MembersOutline className="h-3.5 w-3.5 flex-shrink-0 text-secondary" />}
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-primary capitalize hover:underline"
>
{activity.new_value && activity.new_value !== "" ? activity.new_value : activity.old_value}
</a>
{showIssue && (activity.old_value === "" ? ` to ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,39 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { AttachOutline } from "@makeplane/propel/icons";
// 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 = observer(function IssueAttachmentActivity(props: TIssueAttachmentActivity) {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<AttachOutline width={14} height={14} className="text-secondary" 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>
);
});

View File

@@ -0,0 +1,74 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// hooks
import { CyclesOutline } from "@makeplane/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 = observer(function IssueCycleActivity(props: TIssueCycleActivity) {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<CyclesOutline className="h-4 w-4 flex-shrink-0 text-secondary" />}
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-primary 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-primary 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-primary hover:underline"
>
<span className="truncate"> {activity.new_value}</span>
</a>
</>
)}
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,53 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import { WorkItemsOutline } from "@makeplane/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 = observer(function IssueDefaultActivity(props: TIssueDefaultActivity) {
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={<WorkItemsOutline width={14} height={14} className="text-secondary" 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>
);
});

View File

@@ -0,0 +1,39 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { AlignLeftOutline } from "@makeplane/propel/icons";
// 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 = observer(function IssueDescriptionActivity(props: TIssueDescriptionActivity) {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<AlignLeftOutline width={14} height={14} className="text-secondary" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
updated the description
{showIssue ? ` of ` : ``}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,41 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { EstimateOutline } from "@makeplane/propel/icons";
// 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 = observer(function IssueEstimateActivity(props: TIssueEstimateActivity) {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<EstimateOutline className="h-3.5 w-3.5 text-secondary" 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>
);
});

View File

@@ -0,0 +1,64 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
import { HierarchyOutline } from "@makeplane/propel/icons";
// plane imports
import { Tooltip } from "@makeplane/propel/components/tooltip";
import { renderFormattedTime, renderFormattedDate, calculateTimeAgo } from "@plane/utils";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { usePlatformOS } from "@/hooks/use-platform-os";
// local imports
import { IssueUser } from "../";
import { IssueCreatorDisplay } from "./issue-creator";
type TIssueActivityBlockComponent = {
icon?: ReactNode;
activityId: string;
ends: "top" | "bottom" | undefined;
children: ReactNode;
customUserName?: string;
};
export function IssueActivityBlockComponent(props: TIssueActivityBlockComponent) {
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-caption-sm-regular ${
ends === "top" ? `pb-2` : ends === "bottom" ? `pt-2` : `py-2`
}`}
>
<div className="absolute top-0 bottom-0 left-[13px] w-px bg-layer-3" aria-hidden />
<div className="z-[4] flex h-7 w-7 flex-shrink-0 items-center justify-center overflow-hidden rounded-lg border border-subtle bg-layer-2 text-secondary shadow-raised-100">
{icon ? icon : <HierarchyOutline className="h-3.5 w-3.5" />}
</div>
<div className="w-full truncate text-secondary">
{!activity?.field && activity?.verb === "created" ? (
<IssueCreatorDisplay activityId={activityId} customUserName={customUserName} />
) : (
<IssueUser activityId={activityId} customUserName={customUserName} />
)}
<span> {children} </span>
<span>
<Tooltip
label={`${renderFormattedDate(activity.created_at)}, ${renderFormattedTime(activity.created_at)}`}
disabled={isMobile}
>
<span className="whitespace-nowrap text-tertiary"> {calculateTimeAgo(activity.created_at)}</span>
</Tooltip>
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { TIssueActivity } from "@plane/types";
export const getRelationActivityContent = (activity: TIssueActivity | undefined): string | undefined => {
if (!activity) return;
switch (activity.field) {
case "blocking":
return activity.old_value === ""
? `marked this work item is blocking work item `
: `removed the blocking work item `;
case "blocked_by":
return activity.old_value === ""
? `marked this work item is being blocked by `
: `removed this work item being blocked by work item `;
case "duplicate":
return activity.old_value === ""
? `marked this work item as duplicate of `
: `removed this work item as a duplicate of `;
case "relates_to":
return activity.old_value === "" ? `marked that this work item relates to ` : `removed the relation from `;
}
return;
};

View File

@@ -0,0 +1,41 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import Link from "next/link";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
type TIssueUser = {
activityId: string;
customUserName?: string;
};
export function IssueCreatorDisplay(props: TIssueUser) {
const { activityId, customUserName } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<>
{customUserName ? (
<span className="font-medium text-primary">{customUserName || "Plane"}</span>
) : (
<Link
href={`/${activity?.workspace_detail?.slug}/profile/${activity?.actor_detail?.id}`}
className="font-medium text-primary hover:underline"
>
{activity.actor_detail?.display_name}
</Link>
)}
</>
);
}

View File

@@ -0,0 +1,55 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Tooltip } from "@makeplane/propel/components/tooltip";
import { generateWorkItemLink } from "@plane/utils";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { usePlatformOS } from "@/hooks/use-platform-os";
type TIssueLink = {
activityId: string;
};
export function IssueLink(props: TIssueLink) {
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
label={activity.issue_detail ? activity.issue_detail.name : "This work item has been deleted"}
layout="stacked"
disabled={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-primary hover:underline"
>
{activity.issue_detail
? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`
: "Work items"}{" "}
<span className="font-regular">{activity.issue_detail?.name}</span>
</a>
</Tooltip>
);
}

View File

@@ -0,0 +1,41 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import Link from "next/link";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
type TIssueUser = {
activityId: string;
customUserName?: string;
};
export function IssueUser(props: TIssueUser) {
const { activityId, customUserName } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<>
{customUserName ? (
<span className="font-medium text-primary">{customUserName}</span>
) : (
<Link
href={`/${activity?.workspace_detail?.slug}/profile/${activity?.actor_detail?.id}`}
className="font-medium text-primary hover:underline"
>
{activity.actor_detail?.display_name}
</Link>
)}
</>
);
}

View File

@@ -0,0 +1,51 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// hooks
import { IntakeOutline } from "@makeplane/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 = observer(function IssueInboxActivity(props: TIssueInboxActivity) {
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={<IntakeOutline className="h-4 w-4 flex-shrink-0 text-secondary" />}
activityId={activityId}
ends={ends}
>
<>{getInboxActivityMessage()}</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,30 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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";

View File

@@ -0,0 +1,27 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Tooltip } from "@makeplane/propel/components/tooltip";
type TIssueLabelPill = { name?: string; color?: string };
export function LabelActivityChip(props: TIssueLabelPill) {
const { name, color } = props;
return (
<Tooltip label={name ?? ""} layout="stacked">
<span className="inline-flex w-min max-w-32 flex-shrink-0 cursor-default items-center gap-2 truncate rounded-full border border-strong px-2 py-0.5 text-11 whitespace-nowrap">
<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-primary">{name}</span>
</span>
</Tooltip>
);
}

View File

@@ -0,0 +1,47 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { LabelsOutline } from "@makeplane/propel/icons";
// 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 = observer(function IssueLabelActivity(props: TIssueLabelActivity) {
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={<LabelsOutline height={14} width={14} className="text-secondary" />}
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>
);
});

View File

@@ -0,0 +1,75 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { ChatOutline } from "@makeplane/propel/icons";
// 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 = observer(function IssueLinkActivity(props: TIssueLinkActivity) {
const { activityId, showIssue = false, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<ChatOutline width={14} height={14} className="text-secondary" 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-primary 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-primary 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-primary hover:underline"
>
link
</a>
</>
)}
{showIssue && (activity.verb === "created" ? ` to ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,74 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// hooks
import { ModuleOutline } from "@makeplane/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 = observer(function IssueModuleActivity(props: TIssueModuleActivity) {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<ModuleOutline className="h-4 w-4 flex-shrink-0 text-secondary" />}
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-primary 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-primary 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-primary hover:underline"
>
<span className="truncate"> {activity.old_value}</span>
</a>
</>
)}
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,35 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { TextOutline } from "@makeplane/propel/icons";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent } from "./";
type TIssueNameActivity = { activityId: string; ends: "top" | "bottom" | undefined };
export const IssueNameActivity = observer(function IssueNameActivity(props: TIssueNameActivity) {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<TextOutline width={14} height={14} className="text-secondary" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>set the name to {activity.new_value}.</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,44 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { ParentOutline } from "@makeplane/propel/icons";
// 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 = observer(function IssueParentActivity(props: TIssueParentActivity) {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<ParentOutline className="h-3.5 w-3.5 text-secondary" 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-primary">{activity.new_value}</span>
) : (
<span className="font-medium text-primary">{activity.old_value}</span>
)}
{showIssue && (activity.new_value ? ` for ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,39 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { PriorityOutline } from "@makeplane/propel/icons";
// 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 = observer(function IssuePriorityActivity(props: TIssuePriorityActivity) {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<PriorityOutline className="h-3.5 w-3.5 text-secondary" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
set the priority to <span className="font-medium text-primary">{activity.new_value}</span>
{showIssue ? ` for ` : ``}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,46 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// types
import type { TIssueRelationTypes } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { useTimeLineRelationOptions } from "@/components/relations";
// local helpers
import { IssueActivityBlockComponent } from "./";
import { getRelationActivityContent } from "./helpers/activity";
type TIssueRelationActivity = { activityId: string; ends: "top" | "bottom" | undefined };
export const IssueRelationActivity = observer(function IssueRelationActivity(props: TIssueRelationActivity) {
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-primary">{activity.new_value}.</span>
) : (
<span className="font-medium text-primary">{activity.old_value}.</span>
)}
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,46 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { CalendarOutline } from "@makeplane/propel/icons";
// 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 = observer(function IssueStartDateActivity(props: TIssueStartDateActivity) {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<CalendarOutline width={14} height={14} className="text-secondary" 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-primary">{renderFormattedDate(activity.new_value)}</span>
</>
)}
{showIssue && (activity.new_value ? ` for ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,40 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// hooks
import { StateOutline } from "@makeplane/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 = observer(function IssueStateActivity(props: TIssueStateActivity) {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<StateOutline className="h-4 w-4 flex-shrink-0 text-secondary" />}
activityId={activityId}
ends={ends}
>
<>
set the state to <span className="font-medium text-primary">{activity.new_value}</span>
{showIssue ? ` for ` : ``}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,46 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { CalendarOutline } from "@makeplane/propel/icons";
// 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 = observer(function IssueTargetDateActivity(props: TIssueTargetDateActivity) {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<CalendarOutline width={14} height={14} className="text-secondary" 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-primary">{renderFormattedDate(activity.new_value)}</span>
</>
)}
{showIssue && (activity.new_value ? ` for ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,97 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// helpers
import { getValidKeysFromObject } from "@plane/utils";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useTimeLineRelationOptions } from "@/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 = observer(function IssueActivityItem(props: TIssueActivityItem) {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
// oxlint-disable-next-line no-empty-pattern
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} />;
default:
return null;
}
});

View File

@@ -0,0 +1,34 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// plane imports
import type { TActivityFilters, TActivityFilterOption } from "@plane/constants";
import { ACTIVITY_FILTER_TYPE_OPTIONS } from "@plane/constants";
// components
import { ActivityFilter } from "@/components/issues/issue-detail/issue-activity";
export type TActivityFilterRoot = {
selectedFilters: TActivityFilters[];
toggleFilter: (filter: TActivityFilters) => void;
projectId: string;
isIntakeIssue?: boolean;
};
export function ActivityFilterRoot(props: TActivityFilterRoot) {
const { selectedFilters, toggleFilter } = props;
const filters: TActivityFilterOption[] = Object.entries(ACTIVITY_FILTER_TYPE_OPTIONS).map(([key, value]) => {
const filterKey = key as TActivityFilters;
return {
key: filterKey,
labelTranslationKey: value.labelTranslationKey,
isSelected: selectedFilters.includes(filterKey),
onClick: () => toggleFilter(filterKey),
};
});
return <ActivityFilter selectedFilters={selectedFilters} filterOptions={filters} />;
}

View File

@@ -0,0 +1,216 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 useWorkItemCommentOperations = (
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, duplicateEditorAsset } = 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"));
}
},
duplicateCommentAsset: async (assetId, commentId) => {
try {
if (!workspaceSlug || !projectId) throw new Error("Missing fields");
const res = await duplicateEditorAsset({
assetId,
entityId: commentId || undefined,
entityType: EFileAssetType.COMMENT_DESCRIPTION,
projectId,
workspaceSlug,
});
return res;
} catch {
throw new Error("Asset duplication failed. Please try again later.");
}
},
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;
};

View File

@@ -0,0 +1,16 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";
export * from "./activity-comment-root";
// activity
export * from "./activity/activity-list";
export * from "./activity-filter";
// sort
export * from "./sort-root";

View File

@@ -0,0 +1,39 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// plane imports
import { Loader } from "@plane/ui";
export function IssueActivityLoader() {
return (
<Loader className="space-y-8">
<div className="flex items-start gap-3">
<Loader.Item className="shrink-0" height="28px" width="28px" />
<div className="w-full space-y-2">
<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="w-full space-y-2">
<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="w-full space-y-2">
<Loader.Item height="8px" width="60%" />
<Loader.Item height="8px" width="40%" />
<Loader.Item height="10px" width="100%" />
</div>
</div>
</Loader>
);
}

View File

@@ -0,0 +1,130 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 } 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 { useProject } from "@/hooks/store/use-project";
// local imports
import { IssueActivityCommentRoot } from "./activity-comment-root";
import { useWorkItemCommentOperations } from "./helper";
import { ActivitySortRoot } from "./sort-root";
import { ActivityFilterRoot } from "./filter-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 = observer(function IssueActivity(props: TIssueActivity) {
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);
const { getProjectById } = useProject();
// 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 = useWorkItemCommentOperations(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">
{/* header */}
<div className="flex items-center justify-between">
<div className="text-h5-medium text-primary">{t("common.activity")}</div>
<div className="flex items-center gap-2">
<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>
);
});

View File

@@ -0,0 +1,22 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { memo } from "react";
import { SortAscendingOutline, SortDescendingOutline } from "@makeplane/propel/icons";
// plane package imports
import { E_SORT_ORDER } from "@plane/constants";
import { IconButton } from "@plane/propel/icon-button";
export type TActivitySortRoot = {
sortOrder: E_SORT_ORDER;
toggleSort: () => void;
};
export const ActivitySortRoot = memo(function ActivitySortRoot(props: TActivitySortRoot) {
const SortIcon = props.sortOrder === E_SORT_ORDER.ASC ? SortAscendingOutline : SortDescendingOutline;
return <IconButton variant="tertiary" icon={SortIcon} onClick={props.toggleSort} />;
});
ActivitySortRoot.displayName = "ActivitySortRoot";

View File

@@ -0,0 +1,165 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useRef } from "react";
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { LinkOutline } from "@makeplane/propel/icons";
import { IconButton } from "@plane/propel/icon-button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import { EIssuesStoreType } from "@plane/types";
import { generateWorkItemLink, copyTextToClipboard } from "@plane/utils";
// 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 = observer(function IssueDetailQuickActions(props: 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 = async () => {
try {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
await copyTextToClipboard(`${originURL}${workItemLink}`);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
message: t("common.copied_to_clipboard"),
});
} catch (_error) {
setToast({
title: t("toast.error"),
type: TOAST_TYPE.ERROR,
});
}
};
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`;
await deleteIssue(workspaceSlug, projectId, issueId);
router.push(redirectionPath);
} catch (_error) {
setToast({
title: t("toast.error"),
type: TOAST_TYPE.ERROR,
message: t("entity.delete.failed", { entity: t("issue.label", { count: 1 }) }),
});
}
};
const handleArchiveIssue = async () => {
try {
await archiveIssue(workspaceSlug, projectId, issueId);
router.push(`/${workspaceSlug}/projects/${projectId}/issues`);
} catch (_error) {
setToast({
title: t("toast.error"),
type: TOAST_TYPE.ERROR,
message: t("issue.archive.failed.message"),
});
}
};
const handleRestore = async () => {
if (!workspaceSlug || !projectId || !issueId) return;
try {
await restoreIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString());
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("issue.restore.success.title"),
message: t("issue.restore.success.message"),
});
router.push(workItemLink);
} catch (_error) {
setToast({
title: t("toast.error"),
type: TOAST_TYPE.ERROR,
message: t("issue.restore.failed.message"),
});
}
};
return (
<>
<div className="flex flex-shrink-0 items-center justify-end">
<div className="flex flex-wrap items-center gap-2">
{currentUser && !issue?.archived_at && (
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
)}
<div className="flex flex-wrap items-center gap-2 text-tertiary">
<Tooltip label={t("common.actions.copy_link")} disabled={isMobile}>
<IconButton variant="secondary" size="lg" onClick={handleCopyText} icon={LinkOutline} />
</Tooltip>
<WorkItemDetailQuickActions
parentRef={parentRef}
issue={issue}
handleDelete={handleDeleteIssue}
handleArchive={handleArchiveIssue}
handleRestore={handleRestore}
/>
</div>
</div>
</div>
</>
);
});

View File

@@ -0,0 +1,42 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import type { TIssueIdentifierProps } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProject } from "@/hooks/store/use-project";
import { IdentifierText } from "@/components/issues/issue-detail/identifier-text";
export const IssueIdentifier = observer(function IssueIdentifier(props: TIssueIdentifierProps) {
const { projectId, variant, size, displayProperties, enableClickToCopyIdentifier = false } = props;
// store hooks
const { getProjectIdentifierById } = useProject();
const {
issue: { getIssueById },
} = useIssueDetail();
// Determine if the component is using store data or not
const isUsingStoreData = "issueId" in props;
// derived values
const issue = isUsingStoreData ? getIssueById(props.issueId) : null;
const projectIdentifier = isUsingStoreData ? getProjectIdentifierById(projectId) : props.projectIdentifier;
const issueSequenceId = isUsingStoreData ? issue?.sequence_id : props.issueSequenceId;
const shouldRenderIssueID = displayProperties ? displayProperties.key : true;
if (!shouldRenderIssueID) return null;
return (
<div className="flex shrink-0 items-center space-x-2">
<IdentifierText
identifier={`${projectIdentifier}-${issueSequenceId}`}
enableClickToCopyIdentifier={enableClickToCopyIdentifier}
variant={variant}
size={size}
/>
</div>
);
});

View File

@@ -0,0 +1,178 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState, Fragment, useEffect } from "react";
import { TwitterPicker } from "react-color";
import { Controller, useForm } from "react-hook-form";
import { usePopper } from "react-popper";
import { AddOutline, CloseOutline, LoadingOutline } from "@makeplane/propel/icons";
import { Popover } from "@headlessui/react";
import { Field } from "@makeplane/propel/components/field";
import { Input, InputGroup } from "@makeplane/propel/components/input";
import type { IIssueLabel } from "@plane/types";
// hooks
// 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 function LabelCreate(props: ILabelCreate) {
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-subtle p-0.5 px-2 text-11 text-tertiary transition-all hover:bg-surface-2 hover:text-secondary"
onClick={handleIsCreateToggle}
>
<div className="flex-shrink-0">
{isCreateToggle ? <CloseOutline className="h-2.5 w-2.5" /> : <AddOutline 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-sm"
style={{
backgroundColor: value ?? "black",
}}
/>
)}
</button>
</Popover.Button>
<Popover.Panel className="fixed z-10">
<div
className="max-w-xs p-2 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 } }) => (
<Field name="name" invalid={Boolean(errors.name)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="name"
name="name"
type="text"
value={value ?? ""}
onChange={onChange}
ref={ref}
placeholder="Title"
disabled={isSubmitting}
/>
</InputGroup>
</Field>
)}
/>
<button
type="button"
className="grid place-items-center rounded-sm bg-danger-primary p-1"
onClick={() => setIsCreateToggle(false)}
disabled={disabled}
>
<CloseOutline className="h-3.5 w-3.5 text-on-color" />
</button>
<button
type="submit"
className="grid place-items-center rounded-sm bg-success-primary p-1"
disabled={isSubmitting}
>
{isSubmitting ? (
<LoadingOutline className="spin h-3.5 w-3.5 text-on-color" />
) : (
<AddOutline className="h-3.5 w-3.5 text-on-color" />
)}
</button>
</form>
)}
</>
);
}

View File

@@ -0,0 +1,13 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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";

View File

@@ -0,0 +1,46 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { Button } from "@plane/propel/button";
import { CloseOutline, LabelsFilled } from "@makeplane/propel/icons";
// 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 = observer(function LabelListItem(props: TLabelListItem) {
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 (
<Button variant="tertiary" size="sm" key={labelId} onClick={handleLabel} disabled={disabled}>
<LabelsFilled className="size-3" color={label.color ?? "#000000"} />
<span className="text-body-xs-regular">{label.name}</span>
{!disabled && <CloseOutline className="h-2.5 w-2.5 transition-all group-hover:text-danger-primary" />}
</Button>
);
});

View File

@@ -0,0 +1,43 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function LabelList(props: TLabelList) {
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}
/>
))}
</>
);
});

View File

@@ -0,0 +1,122 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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
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 = observer(function IssueLabel(props: TIssueLabel) {
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 min-h-7.5 w-full flex-wrap items-center gap-1 px-2">
<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>
);
});

View File

@@ -0,0 +1,225 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Fragment, useState } from "react";
import { observer } from "mobx-react";
import { usePopper } from "react-popper";
import { AddOutline, LoadingOutline, SearchOutline, TickOutline } from "@makeplane/propel/icons";
import { Combobox } from "@headlessui/react";
// plane imports
import { EUserPermissionsLevel, getRandomLabelColor } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
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 = observer(function IssueLabelSelect(props: IIssueLabelSelect) {
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-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
const issueLabels = values ?? [];
const label = <span className="text-body-xs-medium text-placeholder">{t("label.select")}</span>;
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="size-full flex-shrink-0 text-left"
value={issueLabels}
onChange={(value) => onSelect(value)}
multiple
>
<Combobox.Button as={Fragment}>
<Button
ref={setReferenceElement}
type="button"
variant="tertiary"
size="sm"
prependIcon={<AddOutline />}
onClick={() => !projectLabels && fetchLabels()}
>
{label}
</Button>
</Combobox.Button>
<Combobox.Options as="ul" className="fixed z-10">
<div
className={`z-10 my-1 w-48 rounded-sm border border-strong bg-surface-1 py-2.5 text-11 whitespace-nowrap shadow-raised-200 focus:outline-none`}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="px-2">
<div className="flex w-full items-center justify-start rounded-sm border border-subtle bg-surface-2 px-2">
<SearchOutline className="h-3.5 w-3.5 text-tertiary" />
<Combobox.Input
className="w-full bg-transparent px-2 py-1 text-11 text-secondary placeholder:text-placeholder 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 mt-2 scrollbar-sm max-h-48 overflow-y-scroll px-2 pr-0`}>
{isLoading ? (
<p className="text-center text-secondary">{t("common.loading")}</p>
) : filteredOptions.length > 0 ? (
<ul className="space-y-1">
{filteredOptions.map((option) => (
<Combobox.Option
as="li"
key={option.value}
value={option.value}
className={({ selected }) =>
`flex cursor-pointer items-center justify-between gap-2 truncate rounded-sm px-1 py-1.5 select-none hover:bg-layer-1 ${
selected ? "text-primary" : "text-secondary"
}`
}
>
{({ selected }) => (
<>
{option.content}
{selected && (
<div className="flex-shrink-0">
<TickOutline className={`h-3.5 w-3.5`} />
</div>
)}
</>
)}
</Combobox.Option>
))}
</ul>
) : submitting ? (
<LoadingOutline className="spin h-3.5 w-3.5" />
) : canCreateLabel ? (
<ul className="space-y-1">
<Combobox.Option
as="li"
value={query}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!query.length) return;
handleAddLabel(query);
}}
className={`text-left text-secondary ${query.length ? "cursor-pointer" : "cursor-default"}`}
>
{query.length ? (
<>
{/* TODO: Translate here */}+ Add <span className="text-primary">&quot;{query}&quot;</span> to
labels
</>
) : (
t("label.create.type")
)}
</Combobox.Option>
</ul>
) : (
<p className="text-left text-secondary">{t("common.search.no_matching_results")}</p>
)}
</div>
</div>
</Combobox.Options>
</Combobox>
</>
);
});

View File

@@ -0,0 +1,37 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// 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 function IssueLabelSelectRoot(props: TIssueLabelSelectRoot) {
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}
/>
);
}

View File

@@ -0,0 +1,163 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { Field } from "@makeplane/propel/components/field";
import { Input, InputGroup } from "@makeplane/propel/components/input";
import { useTranslation } from "@plane/i18n";
// plane types
import { Button } from "@plane/propel/button";
import type { TIssueLinkEditableFields, TIssueServiceType } from "@plane/types";
// plane ui
import { 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 = observer(function IssueLinkCreateUpdateModal(
props: TIssueLinkCreateEditModal
) {
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-h4-medium text-secondary">
{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-secondary">
{t("common.url")}
</label>
<Controller
control={control}
name="url"
rules={{
required: "URL is required",
}}
render={({ field: { value, onChange, ref } }) => (
<Field name="url" invalid={Boolean(errors.url)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="url"
type="text"
value={value}
onChange={onChange}
ref={ref}
placeholder={t("common.type_or_paste_a_url")}
/>
</InputGroup>
</Field>
)}
/>
{errors.url && (
<span className="text-caption-sm-regular text-danger-primary">{t("common.url_is_invalid")}</span>
)}
</div>
<div>
<label htmlFor="title" className="mb-2 text-secondary">
{t("common.display_title")}
<span className="block text-caption-xs-regular">{t("common.optional")}</span>
</label>
<Controller
control={control}
name="title"
render={({ field: { value, onChange, ref } }) => (
<Field name="title" invalid={Boolean(errors.title)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="title"
type="text"
value={value}
onChange={onChange}
ref={ref}
placeholder={t("common.link_title_placeholder")}
/>
</InputGroup>
</Field>
)}
/>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t-[0.5px] border-subtle px-5 py-4">
<Button variant="secondary" size="lg" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button variant="primary" size="lg" 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>
);
});

View File

@@ -0,0 +1,12 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";
export * from "./links";
export * from "./link-detail";
export * from "./link-item";
export * from "./link-list";

View File

@@ -0,0 +1,126 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { DeleteOutline, EditOutline, NewTabOutline } from "@makeplane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import { getIconForLink, copyTextToClipboard, calculateTimeAgo } from "@plane/utils";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
import { usePlatformOS } from "@/hooks/use-platform-os";
// types
import type { TLinkOperationsModal } from "./create-update-link-modal";
export type TIssueLinkDetail = {
linkId: string;
linkOperations: TLinkOperationsModal;
isNotAllowed: boolean;
};
export function IssueLinkDetail(props: TIssueLinkDetail) {
// 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-surface-2 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 flex-shrink-0 stroke-2 text-tertiary group-hover:text-primary" />
</span>
<Tooltip
label={linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url}
layout="stacked"
disabled={isMobile}
>
<span className="truncate text-11">
{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-layer-1"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleIssueLinkModal(true);
}}
>
<EditOutline className="h-3 w-3 stroke-[1.5] text-secondary" />
</button>
<a
href={linkDetail.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center p-1 hover:bg-layer-1"
>
<NewTabOutline className="h-3 w-3 stroke-[1.5] text-secondary" />
</a>
<button
type="button"
className="flex items-center justify-center p-1 hover:bg-layer-1"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
linkOperations.remove(linkDetail.id);
}}
>
<DeleteOutline className="h-3 w-3" />
</button>
</div>
)}
</div>
<div className="px-5">
<p className="mt-0.5 stroke-[1.5] text-11 text-tertiary">
Added {calculateTimeAgo(linkDetail.created_at)}
<br />
{createdByDetails && (
<>
by {createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name}
</>
)}
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,128 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { CopyOutline, DeleteOutline, EditOutline, LinkOutline } from "@makeplane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@makeplane/propel/components/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 = observer(function IssueLinkItem(props: TIssueLinkItem) {
// 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 3xl:col-span-2 col-span-12 flex h-10 flex-shrink-0 items-center justify-between gap-3 rounded-sm border-[0.5px] border-subtle bg-surface-2 px-3 hover:bg-layer-1 lg:col-span-6 xl:col-span-4 2xl:col-span-3"
>
<div className="flex min-w-0 flex-1 items-center gap-2.5">
{faviconUrl ? (
<img src={faviconUrl} alt="favicon" className="size-4 flex-shrink-0" />
) : (
<LinkOutline className="size-4 flex-shrink-0 text-tertiary group-hover:text-primary" />
)}
<Tooltip label={linkDetail.url} layout="stacked" disabled={isMobile}>
<a
href={linkDetail.url}
target="_blank"
rel="noopener noreferrer"
className="flex w-0 flex-1 cursor-pointer items-center text-body-xs-regular"
>
<span className="w-0 flex-1 truncate">
{linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url}
{linkTitle && linkTitle !== "" && (
<span className="text-caption-sm-regular text-placeholder"> {linkTitle}</span>
)}
</span>
</a>
</Tooltip>
</div>
<div className="flex flex-shrink-0 items-center gap-1">
<p className="group-hover-text-secondary p-1 align-bottom text-caption-sm-regular leading-5 text-placeholder">
{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 cursor-pointer place-items-center rounded-sm p-1 text-placeholder outline-none group-hover:text-secondary hover:bg-layer-1"
>
<CopyOutline className="h-3.5 w-3.5 stroke-[1.5]" />
</span>
<CustomMenu
ellipsis
buttonClassName="text-placeholder group-hover:text-secondary"
placement="bottom-end"
closeOnSelect
disabled={isNotAllowed}
>
<CustomMenu.MenuItem
className="flex items-center gap-2"
onClick={() => {
toggleIssueLinkModal(true);
}}
>
<EditOutline className="h-3 w-3 stroke-[1.5] text-secondary" />
{t("common.actions.edit")}
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
className="flex items-center gap-2"
onClick={() => {
linkOperations.remove(linkDetail.id);
}}
>
<DeleteOutline className="h-3 w-3" />
{t("common.actions.delete")}
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</>
);
});

View File

@@ -0,0 +1,50 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function LinkList(props: TLinkList) {
// 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">
{issueLinks.map((linkId) => (
<IssueLinkItem
key={linkId}
linkId={linkId}
linkOperations={linkOperations}
isNotAllowed={disabled}
issueServiceType={issueServiceType}
/>
))}
</div>
);
});

View File

@@ -0,0 +1,43 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function IssueLinkList(props: TIssueLinkList) {
// 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>
);
});

View File

@@ -0,0 +1,146 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useMemo, useState } from "react";
import { AddOutline } from "@makeplane/propel/icons";
// 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 function IssueLinkRoot(props: TIssueLinkRoot) {
// 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-11">
<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-sm p-1 duration-300 outline-none hover:bg-surface-2 ${
disabled ? "cursor-not-allowed" : "cursor-pointer"
}`}
onClick={() => toggleIssueLinkModal(true)}
disabled={disabled}
>
<AddOutline className="h-4 w-4" />
</button>
)}
</div>
<div>
<IssueLinkList issueId={issueId} linkOperations={handleLinkOperations} disabled={disabled} />
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,186 @@
// oxlint-disable no-shadow
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 { EFileAssetType, EIssueServiceType } from "@plane/types";
// components
import { DescriptionVersionsRoot } from "@/components/core/description-versions";
import { DescriptionInput } from "@/components/editor/rich-text/description-input";
import { IssueTypeSwitcher } from "@/components/issues/issue-type-switcher";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
import { useUser } from "@/hooks/store/user";
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
import useSize from "@/hooks/use-window-size";
// services
import { WorkItemVersionService } from "@/services/issue";
// local imports
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 = observer(function IssueMainContent(props: 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 { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
// derived values
const issue = issueId ? getIssueById(issueId) : undefined;
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
if (isSubmitting === "submitted") {
setShowAlert(false);
timer = setTimeout(() => setIsSubmitting("saved"), 2000);
} else if (isSubmitting === "submitting") setShowAlert(true);
return () => clearTimeout(timer);
}, [isSubmitting, setShowAlert, setIsSubmitting]);
if (!issue || !issue.project_id) return <></>;
const isPeekModeActive = Boolean(peekIssue);
return (
<>
<div className="space-y-4 rounded-lg">
{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} />
</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}
/>
<DescriptionInput
issueSequenceId={issue.sequence_id}
containerClassName="-ml-6 border-none p-0! pl-6!"
disabled={isArchived || !isEditable}
editorRef={editorRef}
entityId={issue.id}
fileAssetType={EFileAssetType.ISSUE_DESCRIPTION}
initialValue={issue.description_html}
key={issue.id}
onSubmit={async (value, isMigrationUpdate) => {
if (!issue.id || !issue.project_id) return;
await issueOperations.update(workspaceSlug, issue.project_id, issue.id, {
description_html: value.description_html,
...(isMigrationUpdate ? { skip_activity: "true" } : {}),
});
}}
projectId={issue.project_id}
setIsSubmitting={(value) => setIsSubmitting(value)}
workspaceSlug={workspaceSlug}
/>
<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} />
</>
);
});

View File

@@ -0,0 +1,84 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function IssueModuleSelect(props: TIssueModuleSelect) {
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 text-left rounded-sm"
buttonClassName={`text-body-xs-medium justify-between ${issue?.module_ids?.length ? "" : "text-placeholder"}`}
buttonVariant="transparent-with-text"
hideIcon
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
multiple
itemClassName="px-2"
/>
</div>
);
});

View File

@@ -0,0 +1,138 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useTranslation } from "@plane/i18n";
import { CloseOutline, EditOutline } from "@makeplane/propel/icons";
// plane imports
import { Tooltip } from "@makeplane/propel/components/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";
// components
import { IssueIdentifier } from "@/components/issues/issue-detail/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 = observer(function IssueParentSelect(props: TIssueParentSelect) {
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 rounded-sm px-2 py-0.5 outline-none",
{
"cursor-not-allowed": disabled,
"hover:bg-layer-transparent-hover": !disabled,
"bg-layer-transparent-selected": isParentIssueModalOpen,
},
className
)}
onClick={() => toggleParentIssueModal(issue.id)}
disabled={disabled}
>
{issue.parent_id && parentIssue ? (
<div className="flex items-center gap-1.5">
<Tooltip label={`Title: ${parentIssue.name}`} layout="stacked" disabled={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}
size="xs"
variant="secondary"
/>
)}
</Link>
</Tooltip>
{!disabled && (
<Tooltip label={t("common.remove")} side="bottom" disabled={isMobile}>
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleRemoveSubIssue(workspaceSlug, projectId, parentIssue.id, issueId);
}}
>
<CloseOutline className="h-2.5 w-2.5 text-tertiary hover:text-danger-primary" />
</span>
</Tooltip>
)}
</div>
) : (
<span className="text-body-xs-medium text-placeholder">{t("issue.add.parent")}</span>
)}
{!disabled && (
<span
className={cn("flex-shrink-0 p-1 opacity-0 group-hover:opacity-100", {
"text-placeholder": !issue.parent_id && !parentIssue,
})}
>
<EditOutline className="h-2.5 w-2.5 flex-shrink-0" />
</span>
)}
</button>
</>
);
});

View File

@@ -0,0 +1,10 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";
export * from "./siblings";
export * from "./sibling-item";

View File

@@ -0,0 +1,110 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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
import { IssueIdentifier } from "@/components/issues/issue-detail/issue-identifier";
// 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";
// 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 = observer(function IssueParentDetail(props: TIssueParentDetail) {
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 rounded-md border border-strong bg-layer-1 px-2.5 py-1 text-11 whitespace-nowrap">
<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}
size="xs"
variant="secondary"
/>
)}
</div>
<span className="truncate text-primary">{(parentIssue?.name ?? "").substring(0, 50)}</span>
</div>
</ControlLink>
<CustomMenu ellipsis optionsClassName="p-1.5">
<div className="border-b border-strong text-11 font-medium text-secondary">{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-danger-primary"
>
<MinusCircle className="h-4 w-4" />
<span>{t("issue.remove.parent.label")}</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</>
);
});

View File

@@ -0,0 +1,65 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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";
// components
import { IssueIdentifier } from "@/components/issues/issue-detail/issue-identifier";
type TIssueParentSiblingItem = {
workspaceSlug: string;
issueId: string;
};
export const IssueParentSiblingItem = observer(function IssueParentSiblingItem(props: TIssueParentSiblingItem) {
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}
size="xs"
/>
)}
</div>
</CustomMenu.MenuItem>
</>
);
});

View File

@@ -0,0 +1,61 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function IssueParentSiblings(props: TIssueParentSiblings) {
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 px-1 py-1 text-left text-11 whitespace-nowrap text-secondary">
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 px-1 py-1 text-left text-11 whitespace-nowrap text-secondary">
No sibling work items
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./issue";
export * from "./issue-comment";

View File

@@ -0,0 +1,143 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { stringToEmoji } from "@plane/propel/emoji-icon-picker";
import { EmojiReactionGroup, EmojiReactionPicker } from "@plane/propel/emoji-reaction";
import type { EmojiReactionType } from "@plane/propel/emoji-reaction";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IUser } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
export type TIssueCommentReaction = {
workspaceSlug: string;
projectId: string;
commentId: string;
currentUser: IUser;
disabled?: boolean;
};
export const IssueCommentReaction = observer(function IssueCommentReaction(props: TIssueCommentReaction) {
const { workspaceSlug, projectId, commentId, currentUser, disabled = false } = props;
// state
const [isPickerOpen, setIsPickerOpen] = useState(false);
// 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);
return reactionUsers;
};
// Transform reactions data to Propel EmojiReactionType format
const reactions: EmojiReactionType[] = useMemo(() => {
if (!reactionIds) return [];
return Object.keys(reactionIds)
.filter((reaction) => reactionIds[reaction]?.length > 0)
.map((reaction) => ({
emoji: stringToEmoji(reaction),
count: reactionIds[reaction].length,
reacted: userReactions.includes(reaction),
users: getReactionUsers(reaction),
}));
}, [reactionIds, userReactions]);
const handleReactionClick = (emoji: string) => {
if (disabled) return;
// Convert emoji back to decimal string format for the API
const emojiCodePoints = Array.from(emoji).map((char) => char.codePointAt(0));
const reactionString = emojiCodePoints.join("-");
issueCommentReactionOperations.react(reactionString);
};
const handleEmojiSelect = (emoji: string) => {
// emoji is already in decimal string format from EmojiReactionPicker
issueCommentReactionOperations.react(emoji);
};
return (
<div className="relative mt-4">
<EmojiReactionPicker
isOpen={isPickerOpen}
handleToggle={setIsPickerOpen}
onChange={handleEmojiSelect}
disabled={disabled}
label={
<EmojiReactionGroup
reactions={reactions}
onReactionClick={handleReactionClick}
showAddButton={!disabled}
onAddReaction={() => setIsPickerOpen(true)}
/>
}
placement="bottom-start"
/>
</div>
);
});

View File

@@ -0,0 +1,149 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { stringToEmoji } from "@plane/propel/emoji-icon-picker";
import { EmojiReactionGroup, EmojiReactionPicker } from "@plane/propel/emoji-reaction";
import type { EmojiReactionType } from "@plane/propel/emoji-reaction";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IUser } from "@plane/types";
// hooks
// ui
import { cn } from "@plane/utils";
// helpers
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
// types
export type TIssueReaction = {
workspaceSlug: string;
projectId: string;
issueId: string;
currentUser: IUser;
disabled?: boolean;
className?: string;
};
export const IssueReaction = observer(function IssueReaction(props: TIssueReaction) {
const { workspaceSlug, projectId, issueId, currentUser, disabled = false, className = "" } = props;
// state
const [isPickerOpen, setIsPickerOpen] = useState(false);
// 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);
return reactionUsers;
};
// Transform reactions data to Propel EmojiReactionType format
const reactions: EmojiReactionType[] = useMemo(() => {
if (!reactionIds) return [];
return Object.keys(reactionIds)
.filter((reaction) => reactionIds[reaction]?.length > 0)
.map((reaction) => ({
emoji: stringToEmoji(reaction),
count: reactionIds[reaction].length,
reacted: userReactions.includes(reaction),
users: getReactionUsers(reaction),
}));
}, [reactionIds, userReactions]);
const handleReactionClick = (emoji: string) => {
if (disabled) return;
// Convert emoji back to decimal string format for the API
const emojiCodePoints = Array.from(emoji).map((char) => char.codePointAt(0));
const reactionString = emojiCodePoints.join("-");
issueReactionOperations.react(reactionString);
};
const handleEmojiSelect = (emoji: string) => {
// emoji is already in decimal string format from EmojiReactionPicker
issueReactionOperations.react(emoji);
};
return (
<div className={cn("relative mt-4", className)}>
<EmojiReactionPicker
isOpen={isPickerOpen}
handleToggle={setIsPickerOpen}
onChange={handleEmojiSelect}
disabled={disabled}
label={
<EmojiReactionGroup
reactions={reactions}
onReactionClick={handleReactionClick}
showAddButton={!disabled}
onAddReaction={() => setIsPickerOpen(true)}
/>
}
placement="bottom-start"
/>
</div>
);
});

View File

@@ -0,0 +1,173 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { CloseOutline, EditOutline } from "@makeplane/propel/icons";
// Plane
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@makeplane/propel/components/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 "@/components/relations";
import type { TIssueRelationTypes } from "@plane/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 = observer(function IssueRelationSelect(props: TIssueRelationSelect) {
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-sm px-2 py-0.5 outline-none",
{
"cursor-not-allowed": disabled,
"hover:bg-layer-1": !disabled,
"bg-layer-1": 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-sm px-1.5 pt-1 pb-1 leading-3 hover:bg-surface-2 ${currRelationOption?.className}`}
>
<Tooltip label={`Title: ${currentIssue.name}`} layout="stacked" disabled={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-caption-sm-medium"
onClick={(e) => e.stopPropagation()}
>
{`${projectDetails?.identifier}-${currentIssue?.sequence_id}`}
</Link>
</Tooltip>
{!disabled && (
<Tooltip label="Remove" side="bottom" disabled={isMobile}>
{/* eslint-disable-next-line jsx_a11y/click-events-have-key-events oxlint-disable-next-line jsx_a11y/no-static-element-interactions */}
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
removeRelation(workspaceSlug, projectId, issueId, relationKey, relationIssueId);
}}
>
<CloseOutline className="h-2.5 w-2.5 text-tertiary hover:text-danger-primary" />
</span>
</Tooltip>
)}
</div>
);
})}
</div>
) : (
<span className="text-body-xs-regular text-placeholder">{currRelationOption?.placeholder}</span>
)}
{!disabled && (
<span
className={cn("flex-shrink-0 p-1 opacity-0 group-hover:opacity-100", {
"text-placeholder": relationIssueIds.length === 0,
})}
>
<EditOutline className="h-2.5 w-2.5 flex-shrink-0" />
</span>
)}
</div>
</button>
</>
);
});

View File

@@ -0,0 +1,271 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useMemo } from "react";
import { observer } from "mobx-react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } 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";
// assets
import emptyIssue from "@/app/assets/empty-state/issue.svg?url";
// components
import { EmptyState } from "@/components/common/empty-state";
// hooks
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";
// 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 = observer(function IssueDetailRoot(props: TIssueDetailRoot) {
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);
} catch (error) {
console.log("Error in updating issue:", 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") }),
});
} 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") }),
});
}
},
archive: async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
await archiveIssue(workspaceSlug, projectId, issueId);
} catch (error) {
console.log("Error in archiving issue:", error);
}
},
addCycleToIssue: async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => {
try {
await addCycleToIssue(workspaceSlug, projectId, cycleId, issueId);
} catch (_error) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("common.error.label"),
message: t("issue.add.cycle.failed"),
});
}
},
addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => {
try {
await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
} catch (_error) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("common.error.label"),
message: t("issue.add.cycle.failed"),
});
}
},
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;
} catch (error) {
console.log("Error in removing issue from cycle:", 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;
} catch (error) {
console.log("Error in removing issue from module:", error);
}
},
changeModulesInIssue: async (
workspaceSlug: string,
projectId: string,
issueId: string,
addModuleIds: string[],
removeModuleIds: string[]
) => {
const promise = await changeModulesInIssue(workspaceSlug, projectId, issueId, addModuleIds, removeModuleIds);
return promise;
},
}),
[
is_archived,
fetchIssue,
updateIssue,
removeIssue,
archiveIssue,
removeArchivedIssue,
addIssueToCycle,
addCycleToIssue,
removeIssueFromCycle,
changeModulesInIssue,
removeIssueFromModule,
t,
]
);
// 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="h-full w-full space-y-6 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-subtle bg-surface-1 sm:w-1/2 md:relative md:w-1/4 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 />
</>
);
});

View File

@@ -0,0 +1,256 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// i18n
import { useTranslation } from "@plane/i18n";
// ui
import {
CyclesOutline,
DueDateOutline,
EstimateOutline,
LabelsOutline,
MembersOutline,
ModuleOutline,
ParentOutline,
PriorityOutline,
StartDateOutline,
StateOutline,
UserOutline,
} from "@makeplane/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";
// components
import { IssueParentSelectRoot } from "@/components/issues/parent-select-root";
import { SidebarPropertyListItem } from "@/components/common/layout/sidebar/property-list-item";
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 = observer(function IssueDetailsSidebar(props: 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 h-full w-full flex-col items-center divide-y-2 divide-subtle-1 overflow-hidden">
<div className="h-full w-full overflow-y-auto px-6">
<h5 className="mt-5 text-body-xs-medium">{t("common.properties")}</h5>
<div className={`mt-4 mb-2 space-y-2.5 truncate ${!isEditable ? "opacity-60" : ""}`}>
<SidebarPropertyListItem icon={StateOutline} label={t("common.state")}>
<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-full grow"
buttonContainerClassName="w-full text-left h-7.5"
buttonClassName="text-body-xs-regular"
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
/>
</SidebarPropertyListItem>
<SidebarPropertyListItem icon={MembersOutline} label={t("common.assignees")}>
<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-full grow"
buttonContainerClassName="w-full text-left h-7.5"
buttonClassName={`text-body-xs-regular justify-between ${issue?.assignee_ids?.length > 0 ? "" : "text-placeholder"}`}
hideIcon={issue.assignee_ids?.length === 0}
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
/>
</SidebarPropertyListItem>
<SidebarPropertyListItem icon={PriorityOutline} label={t("common.priority")}>
<PriorityDropdown
value={issue?.priority}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { priority: val })}
disabled={!isEditable}
buttonVariant="transparent-with-text"
className="h-7.5 w-full grow rounded-sm"
buttonContainerClassName="size-full text-left"
buttonClassName="size-full px-2 py-0.5 whitespace-nowrap [&_svg]:size-3.5"
/>
</SidebarPropertyListItem>
{createdByDetails && (
<SidebarPropertyListItem icon={UserOutline} label={t("common.created_by")}>
<div className="flex gap-2 px-2">
<ButtonAvatars showTooltip userIds={createdByDetails.id} />
<span className="grow truncate text-body-xs-regular leading-5">{createdByDetails?.display_name}</span>
</div>
</SidebarPropertyListItem>
)}
<SidebarPropertyListItem icon={StartDateOutline} label={t("common.order_by.start_date")}>
<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-full grow"
buttonContainerClassName="w-full text-left h-7.5"
buttonClassName={`text-body-xs-regular ${issue?.start_date ? "" : "text-placeholder"}`}
hideIcon
clearIconClassName="h-3 w-3 hidden group-hover:inline"
/>
</SidebarPropertyListItem>
<SidebarPropertyListItem icon={DueDateOutline} label={t("common.order_by.due_date")}>
<div className="flex w-full items-center gap-2">
<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-full grow"
buttonContainerClassName="w-full text-left h-7.5"
buttonClassName={cn("text-body-xs-regular", {
"text-placeholder": !issue.target_date,
"text-danger-primary": shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group),
})}
hideIcon
clearIconClassName="h-3 w-3 hidden group-hover:inline text-primary"
/>
</div>
</SidebarPropertyListItem>
{projectId && areEstimateEnabledByProjectId(projectId) && (
<SidebarPropertyListItem icon={EstimateOutline} label={t("common.estimate")}>
<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-full grow"
buttonContainerClassName="w-full text-left h-7.5"
buttonClassName={`text-body-xs-regular ${issue?.estimate_point !== null ? "" : "text-placeholder"}`}
placeholder={t("common.none")}
hideIcon
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
/>
</SidebarPropertyListItem>
)}
{projectDetails?.module_view && (
<SidebarPropertyListItem icon={ModuleOutline} label={t("common.modules")}>
<IssueModuleSelect
className="w-full grow"
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
issueOperations={issueOperations}
disabled={!isEditable}
/>
</SidebarPropertyListItem>
)}
{projectDetails?.cycle_view && (
<SidebarPropertyListItem icon={CyclesOutline} label={t("common.cycle")} appendElement={null}>
<IssueCycleSelect
className="h-7.5 w-full grow"
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
issueOperations={issueOperations}
disabled={!isEditable}
/>
</SidebarPropertyListItem>
)}
<SidebarPropertyListItem icon={ParentOutline} label={t("common.parent")}>
<IssueParentSelectRoot
className="h-7.5 w-full grow"
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
issueOperations={issueOperations}
disabled={!isEditable}
/>
</SidebarPropertyListItem>
<SidebarPropertyListItem icon={LabelsOutline} label={t("common.labels")}>
<IssueLabel
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={!isEditable}
/>
</SidebarPropertyListItem>
</div>
</div>
</div>
</>
);
});

View File

@@ -0,0 +1,104 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { isNil } from "lodash-es";
import { observer } from "mobx-react";
import { SubscribeOutline, UnsubscribeOutline } from "@makeplane/propel/icons";
// 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 = observer(function IssueSubscription(props: TIssueSubscription) {
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
prependIcon={isSubscribed ? <UnsubscribeOutline /> : <SubscribeOutline className="h-3 w-3" />}
variant="secondary"
className="hover:!bg-accent-primary/20"
onClick={handleSubscription}
disabled={!isEditable || loading}
size="lg"
>
{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>
);
});