Initial commit: Plane
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled
Synced from upstream: 8853637e981ed7d8a6cff32bd98e7afe20f54362
This commit is contained in:
41
apps/web/core/components/issues/peek-overview/error.tsx
Normal file
41
apps/web/core/components/issues/peek-overview/error.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { MoveRight } from "lucide-react";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
// assets
|
||||
import emptyIssue from "@/app/assets/empty-state/issue.svg?url";
|
||||
// components
|
||||
import { EmptyState } from "@/components/common/empty-state";
|
||||
// hooks
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
type TIssuePeekOverviewError = {
|
||||
removeRoutePeekId: () => void;
|
||||
};
|
||||
|
||||
export const IssuePeekOverviewError: FC<TIssuePeekOverviewError> = (props) => {
|
||||
const { removeRoutePeekId } = props;
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
return (
|
||||
<div className="w-full h-full overflow-hidden relative flex flex-col">
|
||||
<div className="flex-shrink-0 flex justify-start">
|
||||
<Tooltip tooltipContent="Close the peek view" isMobile={isMobile}>
|
||||
<button onClick={removeRoutePeekId} className="w-5 h-5 m-5">
|
||||
<MoveRight className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-full">
|
||||
<EmptyState
|
||||
image={emptyIssue ?? undefined}
|
||||
title="Work item does not exist"
|
||||
description="The work item you are looking for does not exist, has been archived, or has been deleted."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
254
apps/web/core/components/issues/peek-overview/header.tsx
Normal file
254
apps/web/core/components/issues/peek-overview/header.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { Link2, MoveDiagonal, MoveRight } from "lucide-react";
|
||||
// plane imports
|
||||
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { CenterPanelIcon, FullScreenPanelIcon, SidePanelIcon } from "@plane/propel/icons";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { TNameDescriptionLoader } from "@plane/types";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
import { CustomSelect } from "@plane/ui";
|
||||
import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils";
|
||||
// helpers
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
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";
|
||||
// hooks
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// local imports
|
||||
import { IssueSubscription } from "../issue-detail/subscription";
|
||||
import { WorkItemDetailQuickActions } from "../issue-layouts/quick-action-dropdowns";
|
||||
import { NameDescriptionUpdateStatus } from "../issue-update-status";
|
||||
|
||||
export type TPeekModes = "side-peek" | "modal" | "full-screen";
|
||||
|
||||
const PEEK_OPTIONS: { key: TPeekModes; icon: any; i18n_title: string }[] = [
|
||||
{
|
||||
key: "side-peek",
|
||||
icon: SidePanelIcon,
|
||||
i18n_title: "common.side_peek",
|
||||
},
|
||||
{
|
||||
key: "modal",
|
||||
icon: CenterPanelIcon,
|
||||
i18n_title: "common.modal",
|
||||
},
|
||||
{
|
||||
key: "full-screen",
|
||||
icon: FullScreenPanelIcon,
|
||||
i18n_title: "common.full_screen",
|
||||
},
|
||||
];
|
||||
|
||||
export type PeekOverviewHeaderProps = {
|
||||
peekMode: TPeekModes;
|
||||
setPeekMode: (value: TPeekModes) => void;
|
||||
removeRoutePeekId: () => void;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
isArchived: boolean;
|
||||
disabled: boolean;
|
||||
embedIssue: boolean;
|
||||
toggleDeleteIssueModal: (value: boolean) => void;
|
||||
toggleArchiveIssueModal: (value: boolean) => void;
|
||||
toggleDuplicateIssueModal: (value: boolean) => void;
|
||||
toggleEditIssueModal: (value: boolean) => void;
|
||||
handleRestoreIssue: () => Promise<void>;
|
||||
isSubmitting: TNameDescriptionLoader;
|
||||
};
|
||||
|
||||
export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((props) => {
|
||||
const {
|
||||
peekMode,
|
||||
setPeekMode,
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
isArchived,
|
||||
disabled,
|
||||
embedIssue = false,
|
||||
removeRoutePeekId,
|
||||
toggleDeleteIssueModal,
|
||||
toggleArchiveIssueModal,
|
||||
toggleDuplicateIssueModal,
|
||||
toggleEditIssueModal,
|
||||
handleRestoreIssue,
|
||||
isSubmitting,
|
||||
} = props;
|
||||
// ref
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
setPeekIssue,
|
||||
removeIssue,
|
||||
archiveIssue,
|
||||
getIsIssuePeeked,
|
||||
} = useIssueDetail();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
// derived values
|
||||
const issueDetails = getIssueById(issueId);
|
||||
const currentMode = PEEK_OPTIONS.find((m) => m.key === peekMode);
|
||||
const projectIdentifier = getProjectIdentifierById(issueDetails?.project_id);
|
||||
const {
|
||||
issues: { removeIssue: removeArchivedIssue },
|
||||
} = useIssues(EIssuesStoreType.ARCHIVED);
|
||||
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug,
|
||||
projectId: issueDetails?.project_id,
|
||||
issueId,
|
||||
projectIdentifier,
|
||||
sequenceId: issueDetails?.sequence_id,
|
||||
isArchived,
|
||||
});
|
||||
|
||||
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
copyUrlToClipboard(workItemLink).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("common.link_copied"),
|
||||
message: t("common.link_copied_to_clipboard"),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteIssue = async () => {
|
||||
try {
|
||||
const deleteIssue = issueDetails?.archived_at ? removeArchivedIssue : removeIssue;
|
||||
|
||||
return deleteIssue(workspaceSlug, projectId, issueId).then(() => {
|
||||
setPeekIssue(undefined);
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
title: t("toast.error"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: t("entity.delete.failed", { entity: t("issue.label", { count: 1 }) }),
|
||||
});
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchiveIssue = async () => {
|
||||
try {
|
||||
await archiveIssue(workspaceSlug, projectId, issueId);
|
||||
// check and remove if issue is peeked
|
||||
if (getIsIssuePeeked(issueId)) {
|
||||
removeRoutePeekId();
|
||||
}
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex items-center justify-between p-4 ${
|
||||
currentMode?.key === "full-screen" ? "border-b border-custom-border-200" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<Tooltip tooltipContent={t("common.close_peek_view")} isMobile={isMobile}>
|
||||
<button onClick={removeRoutePeekId}>
|
||||
<MoveRight className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip tooltipContent={t("issue.open_in_full_screen")} isMobile={isMobile}>
|
||||
<Link href={workItemLink} onClick={() => removeRoutePeekId()}>
|
||||
<MoveDiagonal className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
|
||||
</Link>
|
||||
</Tooltip>
|
||||
{currentMode && embedIssue === false && (
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<CustomSelect
|
||||
value={currentMode}
|
||||
onChange={(val: any) => setPeekMode(val)}
|
||||
customButton={
|
||||
<Tooltip tooltipContent={t("common.toggle_peek_view_layout")} isMobile={isMobile}>
|
||||
<button type="button" className="">
|
||||
<currentMode.icon className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{PEEK_OPTIONS.map((mode) => (
|
||||
<CustomSelect.Option key={mode.key} value={mode.key}>
|
||||
<div
|
||||
className={`flex items-center gap-1.5 ${
|
||||
currentMode.key === mode.key
|
||||
? "text-custom-text-200"
|
||||
: "text-custom-text-400 hover:text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
<mode.icon className="-my-1 h-4 w-4 flex-shrink-0" />
|
||||
{t(mode.i18n_title)}
|
||||
</div>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<NameDescriptionUpdateStatus isSubmitting={isSubmitting} />
|
||||
<div className="flex items-center gap-4">
|
||||
{currentUser && !isArchived && (
|
||||
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
|
||||
)}
|
||||
<Tooltip tooltipContent={t("common.actions.copy_link")} isMobile={isMobile}>
|
||||
<button type="button" onClick={handleCopyText}>
|
||||
<Link2 className="h-4 w-4 -rotate-45 text-custom-text-300 hover:text-custom-text-200" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{issueDetails && (
|
||||
<WorkItemDetailQuickActions
|
||||
parentRef={parentRef}
|
||||
issue={issueDetails}
|
||||
handleDelete={handleDeleteIssue}
|
||||
handleArchive={handleArchiveIssue}
|
||||
handleRestore={handleRestoreIssue}
|
||||
readOnly={disabled}
|
||||
toggleDeleteIssueModal={toggleDeleteIssueModal}
|
||||
toggleArchiveIssueModal={toggleArchiveIssueModal}
|
||||
toggleDuplicateIssueModal={toggleDuplicateIssueModal}
|
||||
toggleEditIssueModal={toggleEditIssueModal}
|
||||
isPeekMode
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
1
apps/web/core/components/issues/peek-overview/index.ts
Normal file
1
apps/web/core/components/issues/peek-overview/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
190
apps/web/core/components/issues/peek-overview/issue-detail.tsx
Normal file
190
apps/web/core/components/issues/peek-overview/issue-detail.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"use-client";
|
||||
import type { FC } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import type { EditorRefApi } from "@plane/editor";
|
||||
import { EFileAssetType } from "@plane/types";
|
||||
import type { TNameDescriptionLoader } from "@plane/types";
|
||||
// components
|
||||
import { getTextContent } from "@plane/utils";
|
||||
// components
|
||||
import { DescriptionVersionsRoot } from "@/components/core/description-versions";
|
||||
import { DescriptionInput } from "@/components/editor/rich-text/description-input";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
||||
// plane web components
|
||||
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe/duplicate-popover";
|
||||
import { IssueTypeSwitcher } from "@/plane-web/components/issues/issue-details/issue-type-switcher";
|
||||
// plane web hooks
|
||||
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
|
||||
// services
|
||||
import { WorkItemVersionService } from "@/services/issue";
|
||||
// local components
|
||||
import type { TIssueOperations } from "../issue-detail";
|
||||
import { IssueParentDetail } from "../issue-detail/parent";
|
||||
import { IssueReaction } from "../issue-detail/reactions";
|
||||
import { IssueTitleInput } from "../title-input";
|
||||
// services init
|
||||
const workItemVersionService = new WorkItemVersionService();
|
||||
|
||||
type Props = {
|
||||
editorRef: React.RefObject<EditorRefApi>;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
issueOperations: TIssueOperations;
|
||||
disabled: boolean;
|
||||
isArchived: boolean;
|
||||
isSubmitting: TNameDescriptionLoader;
|
||||
setIsSubmitting: (value: TNameDescriptionLoader) => void;
|
||||
};
|
||||
|
||||
export const PeekOverviewIssueDetails: FC<Props> = observer((props) => {
|
||||
const { editorRef, workspaceSlug, issueId, issueOperations, disabled, isArchived, isSubmitting, setIsSubmitting } =
|
||||
props;
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getProjectById } = useProject();
|
||||
const { getUserDetails } = useMember();
|
||||
// reload confirmation
|
||||
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitting === "submitted") {
|
||||
setShowAlert(false);
|
||||
setTimeout(async () => {
|
||||
setIsSubmitting("saved");
|
||||
}, 2000);
|
||||
} else if (isSubmitting === "submitting") {
|
||||
setShowAlert(true);
|
||||
}
|
||||
}, [isSubmitting, setShowAlert, setIsSubmitting]);
|
||||
|
||||
// derived values
|
||||
const issue = issueId ? getIssueById(issueId) : undefined;
|
||||
const projectDetails = issue?.project_id ? getProjectById(issue?.project_id) : undefined;
|
||||
// debounced duplicate issues swr
|
||||
const { duplicateIssues } = useDebouncedDuplicateIssues(
|
||||
workspaceSlug,
|
||||
projectDetails?.workspace.toString(),
|
||||
projectDetails?.id,
|
||||
{
|
||||
name: issue?.name,
|
||||
description_html: getTextContent(issue?.description_html),
|
||||
issueId: issue?.id,
|
||||
}
|
||||
);
|
||||
|
||||
if (!issue || !issue.project_id) return <></>;
|
||||
|
||||
const issueDescription =
|
||||
issue.description_html !== undefined || issue.description_html !== null
|
||||
? issue.description_html != ""
|
||||
? issue.description_html
|
||||
: "<p></p>"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{issue.parent_id && (
|
||||
<IssueParentDetail
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
issueId={issueId}
|
||||
issue={issue}
|
||||
issueOperations={issueOperations}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<IssueTypeSwitcher issueId={issueId} disabled={isArchived || disabled} />
|
||||
{duplicateIssues?.length > 0 && (
|
||||
<DeDupeIssuePopoverRoot
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
rootIssueId={issueId}
|
||||
issues={duplicateIssues}
|
||||
issueOperations={issueOperations}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<IssueTitleInput
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
issueId={issue.id}
|
||||
isSubmitting={isSubmitting}
|
||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||
issueOperations={issueOperations}
|
||||
disabled={disabled || isArchived}
|
||||
value={issue.name}
|
||||
containerClassName="-ml-3"
|
||||
/>
|
||||
|
||||
<DescriptionInput
|
||||
containerClassName="-ml-3 border-none"
|
||||
disabled={disabled || isArchived}
|
||||
editorRef={editorRef}
|
||||
entityId={issue.id}
|
||||
fileAssetType={EFileAssetType.ISSUE_DESCRIPTION}
|
||||
initialValue={issueDescription}
|
||||
onSubmit={async (value) => {
|
||||
if (!issue.id || !issue.project_id) return;
|
||||
await issueOperations.update(workspaceSlug, issue.project_id, issue.id, {
|
||||
description_html: value,
|
||||
});
|
||||
}}
|
||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||
projectId={issue.project_id}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{currentUser && (
|
||||
<IssueReaction
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
issueId={issueId}
|
||||
currentUser={currentUser}
|
||||
disabled={isArchived}
|
||||
/>
|
||||
)}
|
||||
{!disabled && (
|
||||
<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: disabled || isArchived,
|
||||
}}
|
||||
fetchHandlers={{
|
||||
listDescriptionVersions: (issueId) =>
|
||||
workItemVersionService.listDescriptionVersions(
|
||||
workspaceSlug,
|
||||
issue.project_id?.toString() ?? "",
|
||||
issueId
|
||||
),
|
||||
retrieveDescriptionVersion: (issueId, versionId) =>
|
||||
workItemVersionService.retrieveDescriptionVersion(
|
||||
workspaceSlug,
|
||||
issue.project_id?.toString() ?? "",
|
||||
issueId,
|
||||
versionId
|
||||
),
|
||||
}}
|
||||
handleRestore={(descriptionHTML) => editorRef.current?.setEditorValue(descriptionHTML, true)}
|
||||
projectId={issue.project_id}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
107
apps/web/core/components/issues/peek-overview/loader.tsx
Normal file
107
apps/web/core/components/issues/peek-overview/loader.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { MoveRight } from "lucide-react";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { Loader } from "@plane/ui";
|
||||
// hooks
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
type TIssuePeekOverviewLoader = {
|
||||
removeRoutePeekId: () => void;
|
||||
};
|
||||
|
||||
export const IssuePeekOverviewLoader: FC<TIssuePeekOverviewLoader> = (props) => {
|
||||
const { removeRoutePeekId } = props;
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
return (
|
||||
<Loader className="w-full h-screen overflow-hidden p-5 space-y-6">
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip tooltipContent="Close the peek view" isMobile={isMobile}>
|
||||
<button onClick={removeRoutePeekId}>
|
||||
<MoveRight className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Loader.Item width="30px" height="30px" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader.Item width="80px" height="30px" />
|
||||
<Loader.Item width="30px" height="30px" />
|
||||
<Loader.Item width="30px" height="30px" />
|
||||
<Loader.Item width="30px" height="30px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* issue title and description and comments */}
|
||||
<div className="space-y-3">
|
||||
<Loader.Item width="100px" height="20px" />
|
||||
|
||||
<div className="space-y-1">
|
||||
<Loader.Item width="300px" height="15px" />
|
||||
<Loader.Item width="400px" height="15px" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader.Item width="20px" height="15px" />
|
||||
<Loader.Item width="500px" height="15px" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader.Item width="20px" height="15px" />
|
||||
<Loader.Item width="200px" height="15px" />
|
||||
</div>
|
||||
<Loader.Item width="300px" height="15px" />
|
||||
<Loader.Item width="200px" height="15px" />
|
||||
</div>
|
||||
|
||||
<Loader.Item width="30px" height="30px" />
|
||||
</div>
|
||||
|
||||
{/* sub issues */}
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<Loader.Item width="80px" height="20px" />
|
||||
<Loader.Item width="100px" height="20px" />
|
||||
</div>
|
||||
|
||||
{/* attachments */}
|
||||
<div className="space-y-3">
|
||||
<Loader.Item width="80px" height="20px" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader.Item width="250px" height="50px" />
|
||||
<Loader.Item width="250px" height="50px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* properties */}
|
||||
<div className="space-y-3">
|
||||
<Loader.Item width="80px" height="20px" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-8">
|
||||
<Loader.Item width="150px" height="25px" />
|
||||
<Loader.Item width="150px" height="25px" />
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
<Loader.Item width="150px" height="25px" />
|
||||
<Loader.Item width="150px" height="25px" />
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
<Loader.Item width="150px" height="25px" />
|
||||
<Loader.Item width="150px" height="25px" />
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
<Loader.Item width="150px" height="25px" />
|
||||
<Loader.Item width="150px" height="25px" />
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
<Loader.Item width="150px" height="25px" />
|
||||
<Loader.Item width="150px" height="25px" />
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
<Loader.Item width="150px" height="25px" />
|
||||
<Loader.Item width="150px" height="25px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Loader>
|
||||
);
|
||||
};
|
||||
325
apps/web/core/components/issues/peek-overview/properties.tsx
Normal file
325
apps/web/core/components/issues/peek-overview/properties.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui icons
|
||||
import {
|
||||
CycleIcon,
|
||||
StatePropertyIcon,
|
||||
ModuleIcon,
|
||||
MembersPropertyIcon,
|
||||
PriorityPropertyIcon,
|
||||
StartDatePropertyIcon,
|
||||
DueDatePropertyIcon,
|
||||
LabelPropertyIcon,
|
||||
UserCirclePropertyIcon,
|
||||
EstimatePropertyIcon,
|
||||
ParentPropertyIcon,
|
||||
} from "@plane/propel/icons";
|
||||
import { cn, getDate, renderFormattedPayloadDate, shouldHighlightIssueDueDate } from "@plane/utils";
|
||||
// components
|
||||
import { DateDropdown } from "@/components/dropdowns/date";
|
||||
import { EstimateDropdown } from "@/components/dropdowns/estimate";
|
||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
|
||||
import { PriorityDropdown } from "@/components/dropdowns/priority";
|
||||
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
|
||||
// helpers
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
// plane web components
|
||||
import { WorkItemAdditionalSidebarProperties } from "@/plane-web/components/issues/issue-details/additional-properties";
|
||||
import { IssueParentSelectRoot } from "@/plane-web/components/issues/issue-details/parent-select-root";
|
||||
import { TransferHopInfo } from "@/plane-web/components/issues/issue-details/sidebar/transfer-hop-info";
|
||||
import { DateAlert } from "@/plane-web/components/issues/issue-details/sidebar.tsx/date-alert";
|
||||
import { IssueWorklogProperty } from "@/plane-web/components/issues/worklog/property";
|
||||
import type { TIssueOperations } from "../issue-detail";
|
||||
import { IssueCycleSelect } from "../issue-detail/cycle-select";
|
||||
import { IssueLabel } from "../issue-detail/label";
|
||||
import { IssueModuleSelect } from "../issue-detail/module-select";
|
||||
|
||||
interface IPeekOverviewProperties {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
issueOperations: TIssueOperations;
|
||||
}
|
||||
|
||||
export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, issueOperations, disabled } = props;
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getStateById } = useProjectState();
|
||||
const { getUserDetails } = useMember();
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
if (!issue) return <></>;
|
||||
const createdByDetails = getUserDetails(issue?.created_by);
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
const isEstimateEnabled = projectDetails?.estimate;
|
||||
const stateDetails = getStateById(issue.state_id);
|
||||
|
||||
const minDate = getDate(issue.start_date);
|
||||
minDate?.setDate(minDate.getDate());
|
||||
|
||||
const maxDate = getDate(issue.target_date);
|
||||
maxDate?.setDate(maxDate.getDate());
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h6 className="text-sm font-medium">{t("common.properties")}</h6>
|
||||
{/* TODO: render properties using a common component */}
|
||||
<div className={`w-full space-y-2 mt-3 ${disabled ? "opacity-60" : ""}`}>
|
||||
{/* state */}
|
||||
<div className="flex w-full items-center gap-3 h-8">
|
||||
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">
|
||||
<StatePropertyIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t("common.state")}</span>
|
||||
</div>
|
||||
<StateDropdown
|
||||
value={issue?.state_id}
|
||||
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })}
|
||||
projectId={projectId}
|
||||
disabled={disabled}
|
||||
buttonVariant="transparent-with-text"
|
||||
className="w-3/4 flex-grow group"
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName="text-sm"
|
||||
dropdownArrow
|
||||
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* assignee */}
|
||||
<div className="flex w-full items-center gap-3 h-8">
|
||||
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">
|
||||
<MembersPropertyIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t("common.assignees")}</span>
|
||||
</div>
|
||||
<MemberDropdown
|
||||
value={issue?.assignee_ids ?? undefined}
|
||||
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })}
|
||||
disabled={disabled}
|
||||
projectId={projectId}
|
||||
placeholder={t("issue.add.assignee")}
|
||||
multiple
|
||||
buttonVariant={issue?.assignee_ids?.length > 1 ? "transparent-without-text" : "transparent-with-text"}
|
||||
className="w-3/4 flex-grow group"
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName={`text-sm justify-between ${issue?.assignee_ids?.length > 0 ? "" : "text-custom-text-400"}`}
|
||||
hideIcon={issue.assignee_ids?.length === 0}
|
||||
dropdownArrow
|
||||
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* priority */}
|
||||
<div className="flex w-full items-center gap-3 h-8">
|
||||
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">
|
||||
<PriorityPropertyIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t("common.priority")}</span>
|
||||
</div>
|
||||
<PriorityDropdown
|
||||
value={issue?.priority}
|
||||
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { priority: val })}
|
||||
disabled={disabled}
|
||||
buttonVariant="border-with-text"
|
||||
className="w-3/4 flex-grow rounded px-2 hover:bg-custom-background-80 group"
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName="w-min h-auto whitespace-nowrap"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* created by */}
|
||||
{createdByDetails && (
|
||||
<div className="flex w-full items-center gap-3 h-8">
|
||||
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">
|
||||
<UserCirclePropertyIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t("common.created_by")}</span>
|
||||
</div>
|
||||
<div className="w-full h-full flex items-center gap-1.5 rounded px-2 py-0.5 text-sm justify-between cursor-not-allowed">
|
||||
<ButtonAvatars
|
||||
showTooltip
|
||||
userIds={createdByDetails?.display_name.includes("-intake") ? null : createdByDetails?.id}
|
||||
/>
|
||||
<span className="flex-grow truncate leading-5">
|
||||
{createdByDetails?.display_name.includes("-intake") ? "Plane" : createdByDetails?.display_name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* start date */}
|
||||
<div className="flex w-full items-center gap-3 h-8">
|
||||
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">
|
||||
<StartDatePropertyIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t("common.order_by.start_date")}</span>
|
||||
</div>
|
||||
<DateDropdown
|
||||
value={issue.start_date}
|
||||
onChange={(val) =>
|
||||
issueOperations.update(workspaceSlug, projectId, issueId, {
|
||||
start_date: val ? renderFormattedPayloadDate(val) : null,
|
||||
})
|
||||
}
|
||||
placeholder={t("issue.add.start_date")}
|
||||
buttonVariant="transparent-with-text"
|
||||
maxDate={maxDate ?? undefined}
|
||||
disabled={disabled}
|
||||
className="w-3/4 flex-grow group"
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName={`text-sm ${issue?.start_date ? "" : "text-custom-text-400"}`}
|
||||
hideIcon
|
||||
clearIconClassName="h-3 w-3 hidden group-hover:inline"
|
||||
// TODO: add this logic
|
||||
// showPlaceholderIcon
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* due date */}
|
||||
<div className="flex w-full items-center gap-3 h-8">
|
||||
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">
|
||||
<DueDatePropertyIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t("common.order_by.due_date")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DateDropdown
|
||||
value={issue.target_date}
|
||||
onChange={(val) =>
|
||||
issueOperations.update(workspaceSlug, projectId, issueId, {
|
||||
target_date: val ? renderFormattedPayloadDate(val) : null,
|
||||
})
|
||||
}
|
||||
placeholder={t("issue.add.due_date")}
|
||||
buttonVariant="transparent-with-text"
|
||||
minDate={minDate ?? undefined}
|
||||
disabled={disabled}
|
||||
className="w-3/4 flex-grow group"
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName={cn("text-sm", {
|
||||
"text-custom-text-400": !issue.target_date,
|
||||
"text-red-500": shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group),
|
||||
})}
|
||||
hideIcon
|
||||
clearIconClassName="h-3 w-3 hidden group-hover:inline !text-custom-text-100"
|
||||
// TODO: add this logic
|
||||
// showPlaceholderIcon
|
||||
/>
|
||||
{issue.target_date && <DateAlert date={issue.target_date} workItem={issue} projectId={projectId} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* estimate */}
|
||||
{isEstimateEnabled && (
|
||||
<div className="flex w-full items-center gap-3 h-8">
|
||||
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">
|
||||
<EstimatePropertyIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t("common.estimate")}</span>
|
||||
</div>
|
||||
<EstimateDropdown
|
||||
value={issue.estimate_point ?? undefined}
|
||||
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { estimate_point: val })}
|
||||
projectId={projectId}
|
||||
disabled={disabled}
|
||||
buttonVariant="transparent-with-text"
|
||||
className="w-3/4 flex-grow group"
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName={`text-sm ${issue?.estimate_point !== undefined ? "" : "text-custom-text-400"}`}
|
||||
placeholder="None"
|
||||
hideIcon
|
||||
dropdownArrow
|
||||
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projectDetails?.module_view && (
|
||||
<div className="flex w-full items-center gap-3 min-h-8 h-full">
|
||||
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">
|
||||
<ModuleIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t("common.modules")}</span>
|
||||
</div>
|
||||
<IssueModuleSelect
|
||||
className="w-3/4 flex-grow"
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
issueOperations={issueOperations}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projectDetails?.cycle_view && (
|
||||
<div className="flex w-full items-center gap-3 h-8">
|
||||
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">
|
||||
<CycleIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t("common.cycle")}</span>
|
||||
<TransferHopInfo workItem={issue} />
|
||||
</div>
|
||||
<IssueCycleSelect
|
||||
className="w-3/4 flex-grow"
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
issueOperations={issueOperations}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* parent */}
|
||||
<div className="flex w-full items-center gap-3 h-8">
|
||||
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">
|
||||
<ParentPropertyIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<p>{t("common.parent")}</p>
|
||||
</div>
|
||||
<IssueParentSelectRoot
|
||||
className="w-3/4 flex-grow h-full"
|
||||
disabled={disabled}
|
||||
issueId={issueId}
|
||||
issueOperations={issueOperations}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* label */}
|
||||
<div className="flex w-full items-center gap-3 min-h-8">
|
||||
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">
|
||||
<LabelPropertyIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t("common.labels")}</span>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3 truncate">
|
||||
<IssueLabel workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} disabled={disabled} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IssueWorklogProperty
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<WorkItemAdditionalSidebarProperties
|
||||
workItemId={issue.id}
|
||||
workItemTypeId={issue.type_id}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
isEditable={!disabled}
|
||||
isPeekView
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
321
apps/web/core/components/issues/peek-overview/root.tsx
Normal file
321
apps/web/core/components/issues/peek-overview/root.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
// Plane imports
|
||||
import useSWR from "swr";
|
||||
import { EUserPermissions, EUserPermissionsLevel, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/propel/toast";
|
||||
import type { IWorkItemPeekOverview, TIssue } from "@plane/types";
|
||||
import { EIssueServiceType, EIssuesStoreType } from "@plane/types";
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
|
||||
import { useWorkItemProperties } from "@/plane-web/hooks/use-issue-properties";
|
||||
// local imports
|
||||
import type { TIssueOperations } from "../issue-detail";
|
||||
import { IssueView } from "./view";
|
||||
|
||||
export const IssuePeekOverview: FC<IWorkItemPeekOverview> = observer((props) => {
|
||||
const {
|
||||
embedIssue = false,
|
||||
embedRemoveCurrentNotification,
|
||||
is_draft = false,
|
||||
storeType: issueStoreFromProps,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
// router
|
||||
const pathname = usePathname();
|
||||
// store hook
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
const {
|
||||
issues: { restoreIssue },
|
||||
} = useIssues(EIssuesStoreType.ARCHIVED);
|
||||
const {
|
||||
peekIssue,
|
||||
setPeekIssue,
|
||||
issue: { fetchIssue },
|
||||
fetchActivities,
|
||||
} = useIssueDetail();
|
||||
const issueStoreType = useIssueStoreType();
|
||||
const storeType = issueStoreFromProps ?? issueStoreType;
|
||||
const { issues } = useIssues(storeType);
|
||||
|
||||
useWorkItemProperties(
|
||||
peekIssue?.projectId,
|
||||
peekIssue?.workspaceSlug,
|
||||
peekIssue?.issueId,
|
||||
storeType === EIssuesStoreType.EPIC ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES
|
||||
);
|
||||
// state
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const removeRoutePeekId = useCallback(() => {
|
||||
setPeekIssue(undefined);
|
||||
if (embedIssue) embedRemoveCurrentNotification?.();
|
||||
}, [embedIssue, embedRemoveCurrentNotification, setPeekIssue]);
|
||||
|
||||
const issueOperations: TIssueOperations = useMemo(
|
||||
() => ({
|
||||
fetch: async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
try {
|
||||
setError(false);
|
||||
await fetchIssue(workspaceSlug, projectId, issueId);
|
||||
} catch (error) {
|
||||
setError(true);
|
||||
console.error("Error fetching the parent issue", error);
|
||||
}
|
||||
},
|
||||
update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
|
||||
if (issues?.updateIssue) {
|
||||
await issues
|
||||
.updateIssue(workspaceSlug, projectId, issueId, data)
|
||||
.then(async () => {
|
||||
fetchActivities(workspaceSlug, projectId, issueId);
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
setToast({
|
||||
title: t("toast.error"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: t("entity.update.failed", { entity: t("issue.label", { count: 1 }) }),
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
remove: async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
try {
|
||||
return issues?.removeIssue(workspaceSlug, projectId, issueId).then(() => {
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
removeRoutePeekId();
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
title: t("toast.error"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: t("entity.delete.failed", { entity: t("issue.label", { count: 1 }) }),
|
||||
});
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
},
|
||||
archive: async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
try {
|
||||
if (!issues?.archiveIssue) return;
|
||||
await issues.archiveIssue(workspaceSlug, projectId, issueId);
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
},
|
||||
restore: async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
try {
|
||||
await restoreIssue(workspaceSlug, projectId, issueId);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("issue.restore.success.title"),
|
||||
message: t("issue.restore.success.message"),
|
||||
});
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.restore,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: t("issue.restore.failed.message"),
|
||||
});
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.restore,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
},
|
||||
addCycleToIssue: async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => {
|
||||
try {
|
||||
await issues.addCycleToIssue(workspaceSlug, projectId, cycleId, issueId);
|
||||
fetchActivities(workspaceSlug, projectId, issueId);
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: t("issue.add.cycle.failed"),
|
||||
});
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
},
|
||||
addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => {
|
||||
try {
|
||||
await issues.addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueIds },
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: t("issue.add.cycle.failed"),
|
||||
});
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueIds },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
},
|
||||
removeIssueFromCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => {
|
||||
try {
|
||||
const removeFromCyclePromise = issues.removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId);
|
||||
setPromiseToast(removeFromCyclePromise, {
|
||||
loading: t("issue.remove.cycle.loading"),
|
||||
success: {
|
||||
title: t("toast.success"),
|
||||
message: () => t("issue.remove.cycle.success"),
|
||||
},
|
||||
error: {
|
||||
title: t("toast.error"),
|
||||
message: () => t("issue.remove.cycle.failed"),
|
||||
},
|
||||
});
|
||||
await removeFromCyclePromise;
|
||||
fetchActivities(workspaceSlug, projectId, issueId);
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
},
|
||||
changeModulesInIssue: async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
addModuleIds: string[],
|
||||
removeModuleIds: string[]
|
||||
) => {
|
||||
const promise = await issues.changeModulesInIssue(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
addModuleIds,
|
||||
removeModuleIds
|
||||
);
|
||||
fetchActivities(workspaceSlug, projectId, issueId);
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
return promise;
|
||||
},
|
||||
removeIssueFromModule: async (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => {
|
||||
try {
|
||||
const removeFromModulePromise = issues.removeIssuesFromModule(workspaceSlug, projectId, moduleId, [issueId]);
|
||||
setPromiseToast(removeFromModulePromise, {
|
||||
loading: t("issue.remove.module.loading"),
|
||||
success: {
|
||||
title: t("toast.success"),
|
||||
message: () => t("issue.remove.module.success"),
|
||||
},
|
||||
error: {
|
||||
title: t("toast.error"),
|
||||
message: () => t("issue.remove.module.failed"),
|
||||
},
|
||||
});
|
||||
await removeFromModulePromise;
|
||||
fetchActivities(workspaceSlug, projectId, issueId);
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[fetchIssue, is_draft, issues, fetchActivities, pathname, removeRoutePeekId, restoreIssue]
|
||||
);
|
||||
|
||||
const { isLoading } = useSWR(
|
||||
["peek-issue", peekIssue?.workspaceSlug, peekIssue?.projectId, peekIssue?.issueId],
|
||||
() => peekIssue && issueOperations.fetch(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId),
|
||||
{
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
}
|
||||
);
|
||||
|
||||
if (!peekIssue?.workspaceSlug || !peekIssue?.projectId || !peekIssue?.issueId) return <></>;
|
||||
|
||||
// Check if issue is editable, based on user role
|
||||
const isEditable = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
peekIssue?.workspaceSlug,
|
||||
peekIssue?.projectId
|
||||
);
|
||||
|
||||
return (
|
||||
<IssueView
|
||||
workspaceSlug={peekIssue.workspaceSlug}
|
||||
projectId={peekIssue.projectId}
|
||||
issueId={peekIssue.issueId}
|
||||
isLoading={isLoading}
|
||||
isError={error}
|
||||
is_archived={!!peekIssue.isArchived}
|
||||
disabled={!isEditable}
|
||||
embedIssue={embedIssue}
|
||||
embedRemoveCurrentNotification={embedRemoveCurrentNotification}
|
||||
issueOperations={issueOperations}
|
||||
/>
|
||||
);
|
||||
});
|
||||
269
apps/web/core/components/issues/peek-overview/view.tsx
Normal file
269
apps/web/core/components/issues/peek-overview/view.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import type { FC } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { createPortal } from "react-dom";
|
||||
// plane imports
|
||||
import type { EditorRefApi } from "@plane/editor";
|
||||
import type { TNameDescriptionLoader } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import useKeypress from "@/hooks/use-keypress";
|
||||
import usePeekOverviewOutsideClickDetector from "@/hooks/use-peek-overview-outside-click";
|
||||
// local imports
|
||||
import type { TIssueOperations } from "../issue-detail";
|
||||
import { IssueActivity } from "../issue-detail/issue-activity";
|
||||
import { IssueDetailWidgets } from "../issue-detail-widgets";
|
||||
import { IssuePeekOverviewError } from "./error";
|
||||
import type { TPeekModes } from "./header";
|
||||
import { IssuePeekOverviewHeader } from "./header";
|
||||
import { PeekOverviewIssueDetails } from "./issue-detail";
|
||||
import { IssuePeekOverviewLoader } from "./loader";
|
||||
import { PeekOverviewProperties } from "./properties";
|
||||
|
||||
interface IIssueView {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
is_archived: boolean;
|
||||
disabled?: boolean;
|
||||
embedIssue?: boolean;
|
||||
embedRemoveCurrentNotification?: () => void;
|
||||
issueOperations: TIssueOperations;
|
||||
}
|
||||
|
||||
export const IssueView: FC<IIssueView> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
isLoading,
|
||||
isError,
|
||||
is_archived,
|
||||
disabled = false,
|
||||
embedIssue = false,
|
||||
embedRemoveCurrentNotification,
|
||||
issueOperations,
|
||||
} = props;
|
||||
// states
|
||||
const [peekMode, setPeekMode] = useState<TPeekModes>("side-peek");
|
||||
const [isSubmitting, setIsSubmitting] = useState<TNameDescriptionLoader>("saved");
|
||||
const [isDeleteIssueModalOpen, setIsDeleteIssueModalOpen] = useState(false);
|
||||
const [isArchiveIssueModalOpen, setIsArchiveIssueModalOpen] = useState(false);
|
||||
const [isDuplicateIssueModalOpen, setIsDuplicateIssueModalOpen] = useState(false);
|
||||
const [isEditIssueModalOpen, setIsEditIssueModalOpen] = useState(false);
|
||||
// ref
|
||||
const issuePeekOverviewRef = useRef<HTMLDivElement>(null);
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
// store hooks
|
||||
const {
|
||||
setPeekIssue,
|
||||
isAnyModalOpen,
|
||||
issue: { getIssueById, getIsLocalDBIssueDescription },
|
||||
} = useIssueDetail();
|
||||
const { isAnyModalOpen: isAnyEpicModalOpen } = useIssueDetail(EIssueServiceType.EPICS);
|
||||
const issue = getIssueById(issueId);
|
||||
// remove peek id
|
||||
const removeRoutePeekId = () => {
|
||||
setPeekIssue(undefined);
|
||||
if (embedIssue && embedRemoveCurrentNotification) embedRemoveCurrentNotification();
|
||||
};
|
||||
|
||||
const isLocalDBIssueDescription = getIsLocalDBIssueDescription(issueId);
|
||||
|
||||
const toggleDeleteIssueModal = (value: boolean) => setIsDeleteIssueModalOpen(value);
|
||||
const toggleArchiveIssueModal = (value: boolean) => setIsArchiveIssueModalOpen(value);
|
||||
const toggleDuplicateIssueModal = (value: boolean) => setIsDuplicateIssueModalOpen(value);
|
||||
const toggleEditIssueModal = (value: boolean) => setIsEditIssueModalOpen(value);
|
||||
|
||||
const isAnyLocalModalOpen =
|
||||
isDeleteIssueModalOpen || isArchiveIssueModalOpen || isDuplicateIssueModalOpen || isEditIssueModalOpen;
|
||||
|
||||
usePeekOverviewOutsideClickDetector(
|
||||
issuePeekOverviewRef,
|
||||
() => {
|
||||
const isAnyDropbarOpen = editorRef.current?.isAnyDropbarOpen();
|
||||
if (!embedIssue) {
|
||||
if (!isAnyModalOpen && !isAnyEpicModalOpen && !isAnyLocalModalOpen && !isAnyDropbarOpen) {
|
||||
removeRoutePeekId();
|
||||
}
|
||||
}
|
||||
},
|
||||
issueId
|
||||
);
|
||||
|
||||
const handleKeyDown = () => {
|
||||
const editorImageFullScreenModalElement = document.querySelector(".editor-image-full-screen-modal");
|
||||
const dropdownElement = document.activeElement?.tagName === "INPUT";
|
||||
const isAnyDropbarOpen = editorRef.current?.isAnyDropbarOpen();
|
||||
if (!isAnyModalOpen && !dropdownElement && !isAnyDropbarOpen && !editorImageFullScreenModalElement) {
|
||||
removeRoutePeekId();
|
||||
const issueElement = document.getElementById(`issue-${issueId}`);
|
||||
if (issueElement) issueElement?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
useKeypress("Escape", () => !embedIssue && handleKeyDown());
|
||||
|
||||
const handleRestore = async () => {
|
||||
if (!issueOperations.restore) return;
|
||||
await issueOperations.restore(workspaceSlug, projectId, issueId);
|
||||
removeRoutePeekId();
|
||||
};
|
||||
|
||||
const peekOverviewIssueClassName = cn(
|
||||
!embedIssue
|
||||
? "absolute z-[25] flex flex-col overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 transition-all duration-300"
|
||||
: `w-full h-full`,
|
||||
!embedIssue && {
|
||||
"top-0 bottom-0 right-0 w-full md:w-[50%] border-0 border-l": peekMode === "side-peek",
|
||||
"size-5/6 top-[8.33%] left-[8.33%]": peekMode === "modal",
|
||||
"inset-0 m-4 absolute": peekMode === "full-screen",
|
||||
}
|
||||
);
|
||||
|
||||
const shouldUsePortal = !embedIssue;
|
||||
|
||||
const portalContainer = document.getElementById("full-screen-portal") as HTMLElement;
|
||||
|
||||
const content = (
|
||||
<div className="w-full !text-base">
|
||||
{issueId && (
|
||||
<div
|
||||
ref={issuePeekOverviewRef}
|
||||
className={peekOverviewIssueClassName}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), 0px 1px 16px 0px rgba(16, 24, 40, 0.12)",
|
||||
}}
|
||||
>
|
||||
{isError ? (
|
||||
<div className="relative h-screen w-full overflow-hidden">
|
||||
<IssuePeekOverviewError removeRoutePeekId={removeRoutePeekId} />
|
||||
</div>
|
||||
) : (
|
||||
isLoading && <IssuePeekOverviewLoader removeRoutePeekId={removeRoutePeekId} />
|
||||
)}
|
||||
{!isLoading && !isError && issue && (
|
||||
<>
|
||||
{/* header */}
|
||||
<IssuePeekOverviewHeader
|
||||
peekMode={peekMode}
|
||||
setPeekMode={(value) => setPeekMode(value)}
|
||||
removeRoutePeekId={removeRoutePeekId}
|
||||
toggleDeleteIssueModal={toggleDeleteIssueModal}
|
||||
toggleArchiveIssueModal={toggleArchiveIssueModal}
|
||||
toggleDuplicateIssueModal={toggleDuplicateIssueModal}
|
||||
toggleEditIssueModal={toggleEditIssueModal}
|
||||
handleRestoreIssue={handleRestore}
|
||||
isArchived={is_archived}
|
||||
issueId={issueId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
isSubmitting={isSubmitting}
|
||||
disabled={disabled}
|
||||
embedIssue={embedIssue}
|
||||
/>
|
||||
{/* content */}
|
||||
<div className="vertical-scrollbar scrollbar-md relative h-full w-full overflow-hidden overflow-y-auto">
|
||||
{["side-peek", "modal"].includes(peekMode) ? (
|
||||
<div className="relative flex flex-col gap-3 px-8 py-5 space-y-3">
|
||||
<PeekOverviewIssueDetails
|
||||
editorRef={editorRef}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
issueOperations={issueOperations}
|
||||
disabled={disabled || isLocalDBIssueDescription}
|
||||
isArchived={is_archived}
|
||||
isSubmitting={isSubmitting}
|
||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||
/>
|
||||
|
||||
<div className="py-2">
|
||||
<IssueDetailWidgets
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled || is_archived}
|
||||
issueServiceType={EIssueServiceType.ISSUES}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PeekOverviewProperties
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
issueOperations={issueOperations}
|
||||
disabled={disabled || is_archived}
|
||||
/>
|
||||
|
||||
<IssueActivity
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={is_archived}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="vertical-scrollbar flex h-full w-full overflow-auto">
|
||||
<div className="relative h-full w-full space-y-6 overflow-auto p-4 py-5">
|
||||
<div className="space-y-3">
|
||||
<PeekOverviewIssueDetails
|
||||
editorRef={editorRef}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
issueOperations={issueOperations}
|
||||
disabled={disabled || isLocalDBIssueDescription}
|
||||
isArchived={is_archived}
|
||||
isSubmitting={isSubmitting}
|
||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||
/>
|
||||
|
||||
<div className="py-2">
|
||||
<IssueDetailWidgets
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={EIssueServiceType.ISSUES}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<IssueActivity
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={is_archived}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`h-full !w-[400px] flex-shrink-0 border-l border-custom-border-200 p-4 py-5 overflow-hidden vertical-scrollbar scrollbar-sm ${
|
||||
is_archived ? "pointer-events-none" : ""
|
||||
}`}
|
||||
>
|
||||
<PeekOverviewProperties
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
issueOperations={issueOperations}
|
||||
disabled={disabled || is_archived}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return <>{shouldUsePortal && portalContainer ? createPortal(content, portalContainer) : content}</>;
|
||||
});
|
||||
Reference in New Issue
Block a user