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,44 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { ArrowNarrowRightOutline } from "@makeplane/propel/icons";
import { Tooltip } from "@makeplane/propel/components/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 function IssuePeekOverviewError(props: TIssuePeekOverviewError) {
const { removeRoutePeekId } = props;
// hooks
const { isMobile } = usePlatformOS();
return (
<div className="relative flex h-full w-full flex-col overflow-hidden">
<div className="flex flex-shrink-0 justify-start">
<Tooltip label="Close the peek view" disabled={isMobile}>
<button onClick={removeRoutePeekId} className="m-5 h-5 w-5">
<ArrowNarrowRightOutline className="h-4 w-4 text-tertiary hover:text-secondary" />
</button>
</Tooltip>
</div>
<div className="h-full w-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>
);
}

View File

@@ -0,0 +1,236 @@
/**
* 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";
import Link from "next/link";
import {
ArrowNarrowRightOutline,
DragDropOutline,
FullScreenPeekOutline,
LinkOutline,
ModalPeekOutline,
SidePeekOutline,
} from "@makeplane/propel/icons";
// plane imports
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import type { TNameDescriptionLoader } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
import { CustomSelect } from "@plane/ui";
import { copyUrlToClipboard, generateWorkItemLink } 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 { 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";
import { IconButton } from "@plane/propel/icon-button";
export type TPeekModes = "side-peek" | "modal" | "full-screen";
const PEEK_OPTIONS: { key: TPeekModes; icon: any; i18n_title: string }[] = [
{
key: "side-peek",
icon: SidePeekOutline,
i18n_title: "common.side_peek",
},
{
key: "modal",
icon: ModalPeekOutline,
i18n_title: "common.modal",
},
{
key: "full-screen",
icon: FullScreenPeekOutline,
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 = observer(function IssuePeekOverviewHeader(props: PeekOverviewHeaderProps) {
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);
});
} 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 () => {
await archiveIssue(workspaceSlug, projectId, issueId);
// check and remove if issue is peeked
if (getIsIssuePeeked(issueId)) {
removeRoutePeekId();
}
};
return (
<div
className={`relative flex items-center justify-between p-4 ${
currentMode?.key === "full-screen" ? "border-b border-subtle" : ""
}`}
>
<div className="flex items-center gap-4">
<Tooltip label={t("common.close_peek_view")} disabled={isMobile}>
<button onClick={removeRoutePeekId}>
<ArrowNarrowRightOutline className="h-4 w-4 text-tertiary hover:text-secondary" />
</button>
</Tooltip>
<Tooltip label={t("issue.open_in_full_screen")} disabled={isMobile}>
<Link href={workItemLink} onClick={() => removeRoutePeekId()}>
<DragDropOutline className="h-4 w-4 text-tertiary hover:text-secondary" />
</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 label={t("common.toggle_peek_view_layout")} disabled={isMobile}>
<button type="button" className="">
<currentMode.icon className="h-4 w-4 text-tertiary hover:text-secondary" />
</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-secondary" : "text-placeholder hover:text-secondary"
}`}
>
<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-2">
{currentUser && !isArchived && (
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
)}
<Tooltip label={t("common.actions.copy_link")} disabled={isMobile}>
<IconButton variant="secondary" size="lg" onClick={handleCopyText} icon={LinkOutline} />
</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>
);
});

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,171 @@
// 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 } 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 { 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 { useUser } from "@/hooks/store/user";
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// plane web components
import { IssueTypeSwitcher } from "@/components/issues/issue-type-switcher";
// plane web hooks
// 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 | null>;
workspaceSlug: string;
projectId: string;
issueId: string;
issueOperations: TIssueOperations;
disabled: boolean;
isArchived: boolean;
isSubmitting: TNameDescriptionLoader;
setIsSubmitting: (value: TNameDescriptionLoader) => void;
};
export const PeekOverviewIssueDetails = observer(function PeekOverviewIssueDetails(props: Props) {
const { editorRef, workspaceSlug, issueId, issueOperations, disabled, isArchived, isSubmitting, setIsSubmitting } =
props;
// store hooks
const { data: currentUser } = useUser();
const {
issue: { getIssueById },
} = useIssueDetail();
const { getUserDetails } = useMember();
// reload confirmation
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
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]);
// derived values
const issue = issueId ? getIssueById(issueId) : undefined;
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} />
</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}
/>
<DescriptionInput
issueSequenceId={issue.sequence_id}
containerClassName="-ml-3 border-none"
disabled={disabled || isArchived}
editorRef={editorRef}
entityId={issue.id}
fileAssetType={EFileAssetType.ISSUE_DESCRIPTION}
initialValue={issueDescription}
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" } : {}),
});
}}
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>
);
});

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 { ArrowNarrowRightOutline } from "@makeplane/propel/icons";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import { Loader } from "@plane/ui";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
type TIssuePeekOverviewLoader = {
removeRoutePeekId: () => void;
};
export function IssuePeekOverviewLoader(props: TIssuePeekOverviewLoader) {
const { removeRoutePeekId } = props;
// hooks
const { isMobile } = usePlatformOS();
return (
<Loader className="h-screen w-full space-y-6 overflow-hidden p-5">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Tooltip label="Close the peek view" disabled={isMobile}>
<button onClick={removeRoutePeekId}>
<ArrowNarrowRightOutline className="h-4 w-4 text-tertiary hover:text-secondary" />
</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 items-center justify-between 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>
);
}

View File

@@ -0,0 +1,15 @@
/**
* 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 { IssuePeekOverview } from "@/components/issues/peek-overview";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
export const HomePeekOverviewsRoot = observer(function HomePeekOverviewsRoot() {
const { peekIssue } = useIssueDetail();
return peekIssue ? <IssuePeekOverview /> : null;
});

View File

@@ -0,0 +1,245 @@
/**
* 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 icons
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";
import { SidebarPropertyListItem } from "@/components/common/layout/sidebar/property-list-item";
// 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 { IssueParentSelectRoot } from "@/components/issues/parent-select-root";
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 = observer(function PeekOverviewProperties(props: IPeekOverviewProperties) {
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-body-xs-medium">{t("common.properties")}</h6>
<div className={`mt-3 w-full space-y-3 ${disabled ? "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}
disabled={disabled}
buttonVariant="transparent-with-text"
className="group w-full grow"
buttonContainerClassName="w-full text-left h-7.5"
buttonClassName={`text-body-xs-medium ${issue?.state_id ? "" : "text-placeholder"}`}
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={disabled}
projectId={projectId}
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-medium 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={disabled}
buttonVariant="transparent-with-text"
className="h-7.5 w-full grow rounded-sm"
buttonContainerClassName="w-full text-left h-7.5"
buttonClassName={`text-body-xs-medium whitespace-nowrap [&_svg]:size-3.5 ${!issue?.priority || issue?.priority === "none" ? "text-placeholder" : ""}`}
/>
</SidebarPropertyListItem>
{createdByDetails && (
<SidebarPropertyListItem icon={UserOutline} label={t("common.created_by")} childrenClassName="px-2">
<ButtonAvatars
showTooltip
userIds={createdByDetails?.display_name?.includes("-intake") ? null : createdByDetails?.id}
/>
<span className="grow truncate text-body-xs-medium leading-5 text-secondary">
{createdByDetails?.display_name?.includes("-intake") ? "Plane" : createdByDetails?.display_name}
</span>
</SidebarPropertyListItem>
)}
<SidebarPropertyListItem icon={StartDateOutline} label={t("common.order_by.start_date")}>
<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="group w-full grow"
buttonContainerClassName="w-full text-left h-7.5"
buttonClassName={`text-body-xs-medium ${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
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="group w-full grow"
buttonContainerClassName="w-full text-left h-7.5"
buttonClassName={cn("text-body-xs-medium", {
"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>
{isEstimateEnabled && (
<SidebarPropertyListItem icon={EstimateOutline} label={t("common.estimate")}>
<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="group w-full grow"
buttonContainerClassName="w-full text-left h-7.5"
buttonClassName={`text-body-xs-medium ${issue?.estimate_point !== undefined ? "" : "text-placeholder"}`}
placeholder="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={disabled}
/>
</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={disabled}
/>
</SidebarPropertyListItem>
)}
<SidebarPropertyListItem icon={ParentOutline} label={t("common.parent")}>
<IssueParentSelectRoot
className="h-7.5 w-full grow"
disabled={disabled}
issueId={issueId}
issueOperations={issueOperations}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
</SidebarPropertyListItem>
<SidebarPropertyListItem icon={LabelsOutline} label={t("common.labels")}>
<IssueLabel workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} disabled={disabled} />
</SidebarPropertyListItem>
</div>
</div>
);
});

View File

@@ -0,0 +1,252 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 } 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 { 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 "@/hooks/use-issue-properties";
// local imports
import type { TIssueOperations } from "../issue-detail";
import { IssueView } from "./view";
export const IssuePeekOverview = observer(function IssuePeekOverview(props: IWorkItemPeekOverview) {
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);
return;
})
.catch((_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(() => {
removeRoutePeekId();
return;
});
} catch (_error) {
setToast({
title: t("toast.error"),
type: TOAST_TYPE.ERROR,
message: t("entity.delete.failed", { entity: t("issue.label", { count: 1 }) }),
});
}
},
archive: async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
if (!issues?.archiveIssue) return;
await issues.archiveIssue(workspaceSlug, projectId, issueId);
} catch (error) {
console.error("Error archiving the issue", 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"),
});
} catch (_error) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("issue.restore.failed.message"),
});
}
},
addCycleToIssue: async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => {
try {
await issues.addCycleToIssue(workspaceSlug, projectId, cycleId, issueId);
fetchActivities(workspaceSlug, projectId, issueId);
} catch (_error) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("issue.add.cycle.failed"),
});
}
},
addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => {
try {
await issues.addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
} catch (_error) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("issue.add.cycle.failed"),
});
}
},
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);
} catch (error) {
console.error("Error removing issue from cycle", 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);
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);
} catch (error) {
console.error("Error removing issue from module", 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}
/>
);
});

View File

@@ -0,0 +1,273 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function IssueView(props: IIssueView) {
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 },
} = useIssueDetail();
const { isAnyModalOpen: isAnyEpicModalOpen } = useIssueDetail(EIssueServiceType.EPICS);
const issue = getIssueById(issueId);
// remove peek id
const removeRoutePeekId = () => {
setPeekIssue(undefined);
if (embedIssue && embedRemoveCurrentNotification) embedRemoveCurrentNotification();
};
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,
["main-sidebar"]
);
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-sm border border-subtle bg-surface-1 transition-all duration-300"
: `h-full w-full`,
!embedIssue && {
"top-0 right-0 bottom-0 w-full border-0 border-l md:w-[50%]": peekMode === "side-peek",
"top-[8.33%] left-[8.33%] size-5/6": peekMode === "modal",
"absolute inset-0 m-4": peekMode === "full-screen",
}
);
const shouldUsePortal = !embedIssue;
const portalContainer = document.getElementById("full-screen-portal") as HTMLElement;
const content = (
<div className="w-full text-body-sm-regular">
{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 relative scrollbar-md h-full w-full overflow-hidden overflow-y-auto">
{["side-peek", "modal"].includes(peekMode) ? (
<div className="relative flex flex-col gap-3 space-y-3 px-8 py-5">
<PeekOverviewIssueDetails
editorRef={editorRef}
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
issueOperations={issueOperations}
disabled={disabled}
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}
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={`vertical-scrollbar scrollbar-sm h-full !w-[400px] flex-shrink-0 overflow-hidden border-l border-subtle p-4 py-5 ${
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}</>;
});