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,56 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { LucideIcon } from "lucide-react";
// plane ui
import { useTranslation } from "@plane/i18n";
import type { ISvgIcons } from "@plane/propel/icons";
import { Tooltip } from "@makeplane/propel/components/tooltip";
// plane utils
import { cn } from "@plane/utils";
type Props = {
onChange: (value: number) => void;
value: number;
accessSpecifiers: {
key: number;
i18n_label: string;
icon: LucideIcon | React.FC<ISvgIcons>;
}[];
isMobile?: boolean;
};
export function AccessField(props: Props) {
const { onChange, value, accessSpecifiers, isMobile = false } = props;
const { t } = useTranslation();
return (
<div className="flex flex-shrink-0 items-stretch gap-0.5 rounded-sm border-[1px] border-subtle p-1">
{accessSpecifiers.map((access, index) => {
const label = t(access.i18n_label);
return (
<Tooltip key={access.key} label={label} layout="stacked" disabled={isMobile}>
<button
type="button"
aria-label={label}
onClick={() => onChange(access.key)}
className={cn(
"relative flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-xs p-1 transition-all",
value === access.key ? "bg-layer-1" : "hover:bg-layer-1"
)}
tabIndex={2 + index}
>
<access.icon
className={cn("h-3.5 w-3.5 transition-all", value === access.key ? "text-primary" : "text-placeholder")}
strokeWidth={2}
/>
</button>
</Tooltip>
);
})}
</div>
);
}

View File

@@ -0,0 +1,60 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { FC, ReactNode } from "react";
import { HierarchyOutline } from "@makeplane/propel/icons";
// types
import { Tooltip } from "@makeplane/propel/components/tooltip";
import type { TWorkspaceBaseActivity } from "@plane/types";
// ui
// helpers
import { renderFormattedTime, renderFormattedDate, calculateTimeAgo } from "@plane/utils";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
// local components
import { User } from "./user";
type TActivityBlockComponent = {
icon?: FC<{ className?: string }>;
activity: TWorkspaceBaseActivity;
ends: "top" | "bottom" | undefined;
children: ReactNode;
customUserName?: string;
};
export function ActivityBlockComponent(props: TActivityBlockComponent) {
const { icon: Icon, activity, ends, children, customUserName } = props;
// hooks
const { isMobile } = usePlatformOS();
if (!activity) return <></>;
return (
<div
className={`relative flex items-start gap-2 text-caption-sm-regular ${
ends === "top" ? `pb-3` : ends === "bottom" ? `pt-3` : `py-3`
}`}
>
<div className="z-[4] mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-subtle text-secondary shadow-raised-100">
{Icon ? <Icon className="h-3.5 w-3.5 shrink-0" /> : <HierarchyOutline className="h-3.5 w-3.5 shrink-0" />}
</div>
<div className="w-full text-secondary">
<div className="line-clamp-2">
<User activity={activity} customUserName={customUserName} /> {children}
</div>
<div className="mt-1">
<Tooltip
label={`${renderFormattedDate(activity.created_at)}, ${renderFormattedTime(activity.created_at)}`}
disabled={isMobile}
>
<span className="cursor-help font-medium whitespace-nowrap text-tertiary">
{calculateTimeAgo(activity.created_at)}
</span>
</Tooltip>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import type { TProjectActivity } from "@plane/types";
import { ActivityBlockComponent } from "./activity-block";
import { iconsMap, messages } from "./helper";
type TActivityItem = {
activity: TProjectActivity;
showProject?: boolean;
ends?: "top" | "bottom" | undefined;
};
export const ActivityItem = observer(function ActivityItem(props: TActivityItem) {
const { activity, ends } = props;
if (!activity) return null;
const activityType = activity.field;
if (!activityType) return null;
const { message, customUserName } = messages(activity);
const icon = iconsMap[activityType] || iconsMap.default;
return (
<ActivityBlockComponent icon={icon} activity={activity} ends={ends} customUserName={customUserName}>
<>{message}</>
</ActivityBlockComponent>
);
});

View File

@@ -0,0 +1,291 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { FC, ReactNode } from "react";
import {
AlignLeftOutline,
ArchiveOutline,
AttachOutline,
ClockOutline,
CyclesOutline,
DocumentationOutline,
DueDateOutline,
EstimateOutline,
GitBranchOutline,
GlobeOutline,
GridOutline,
HashOutline,
HierarchyOutline,
InboxOutline,
IntakeOutline,
LabelsOutline,
LinkOutline,
MembersOutline,
ModuleOutline,
PriorityOutline,
RefreshOutline,
StartDateOutline,
StateOutline,
SubscribeOutline,
TextOutline,
TimeTrackingOutline,
ToDoOutline,
WorkItemsOutline,
} from "@makeplane/propel/icons";
// components
import { store } from "@/lib/store-context";
import type { TProjectActivity } from "@plane/types";
type ActivityIconMap = {
[key: string]: FC<{ className?: string }>;
};
export const iconsMap: ActivityIconMap = {
priority: PriorityOutline,
archived_at: ArchiveOutline,
restored: RefreshOutline,
link: LinkOutline,
start_date: StartDateOutline,
target_date: DueDateOutline,
label: LabelsOutline,
inbox: InboxOutline,
description: AlignLeftOutline,
assignee: MembersOutline,
attachment: AttachOutline,
name: TextOutline,
state: StateOutline,
estimate: EstimateOutline,
cycle: CyclesOutline,
module: ModuleOutline,
page: DocumentationOutline,
network: GlobeOutline,
identifier: HashOutline,
timezone: ClockOutline,
is_project_updates_enabled: SubscribeOutline,
is_epic_enabled: GridOutline,
is_workflow_enabled: GitBranchOutline,
is_time_tracking_enabled: TimeTrackingOutline,
is_issue_type_enabled: ToDoOutline,
default: HierarchyOutline,
module_view: ModuleOutline,
cycle_view: CyclesOutline,
issue_views_view: WorkItemsOutline,
page_view: DocumentationOutline,
intake_view: IntakeOutline,
};
export const messages = (activity: TProjectActivity): { message: string | ReactNode; customUserName?: string } => {
const activityType = activity.field;
const newValue = activity.new_value;
const oldValue = activity.old_value;
const verb = activity.verb;
const workspaceDetail = store.workspaceRoot.getWorkspaceById(activity.workspace);
const getBooleanActionText = (value: string | undefined) => {
if (value === "true") return "enabled";
if (value === "false") return "disabled";
return verb;
};
switch (activityType) {
case "priority":
return {
message: (
<>
set the priority to <span className="font-medium text-primary">{newValue || "none"}</span>
</>
),
};
case "archived_at":
return {
message: newValue === "restore" ? "restored the project" : "archived the project",
customUserName: newValue === "archive" ? "Plane" : undefined,
};
case "name":
return {
message: (
<>
renamed the project to <span className="font-medium text-primary">{newValue}</span>
</>
),
};
case "description":
return {
message: newValue ? "updated the project description" : "removed the project description",
};
case "start_date":
return {
message: (
<>
{newValue ? (
<>
set the start date to <span className="font-medium text-primary">{newValue}</span>
</>
) : (
"removed the start date"
)}
</>
),
};
case "target_date":
return {
message: (
<>
{newValue ? (
<>
set the target date to <span className="font-medium text-primary">{newValue}</span>
</>
) : (
"removed the target date"
)}
</>
),
};
case "state":
return {
message: (
<>
set the state to <span className="font-medium text-primary">{newValue || "none"}</span>
</>
),
};
case "estimate":
return {
message: (
<>
{newValue ? (
<>
set the estimate point to <span className="font-medium text-primary">{newValue}</span>
</>
) : (
<>
removed the estimate point
{oldValue && (
<>
{" "}
<span className="font-medium text-primary">{oldValue}</span>
</>
)}
</>
)}
</>
),
};
case "cycles":
return {
message: (
<>
<span>
{verb} this project {verb === "removed" ? "from" : "to"} the cycle{" "}
</span>
{verb !== "removed" ? (
<a
href={`/${workspaceDetail?.slug}/projects/${activity.project}/cycles/${activity.new_identifier}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex font-medium text-primary"
>
{activity.new_value}
</a>
) : (
<span className="font-medium text-primary">{activity.old_value || "Unknown cycle"}</span>
)}
</>
),
};
case "modules":
return {
message: (
<>
<span>
{verb} this project {verb === "removed" ? "from" : "to"} the module{" "}
</span>
<span className="font-medium text-primary">
{verb === "removed" ? oldValue : newValue || "Unknown module"}
</span>
</>
),
};
case "labels":
return {
message: (
<>
{verb} the label{" "}
<span className="font-medium text-primary">{newValue || oldValue || "Untitled label"}</span>
</>
),
};
case "inbox":
return {
message: <>{newValue ? "enabled" : "disabled"} inbox</>,
};
case "page":
return {
message: (
<>
{newValue ? "created" : "removed"} the project page{" "}
<span className="font-medium text-primary">{newValue || oldValue || "Untitled page"}</span>
</>
),
};
case "network":
return {
message: <>{newValue ? "enabled" : "disabled"} network access</>,
};
case "identifier":
return {
message: (
<>
updated project identifier to <span className="font-medium text-primary">{newValue || "none"}</span>
</>
),
};
case "timezone":
return {
message: (
<>
changed project timezone to <span className="font-medium text-primary">{newValue || "default"}</span>
</>
),
};
case "module_view":
case "cycle_view":
case "issue_views_view":
case "page_view":
case "intake_view":
return {
message: (
<>
{getBooleanActionText(newValue)} {activityType.replace(/_view$/, "").replace(/_/g, " ")} view
</>
),
};
case "is_project_updates_enabled":
return {
message: <>{getBooleanActionText(newValue)} project updates</>,
};
case "is_epic_enabled":
return {
message: <>{getBooleanActionText(newValue)} epics</>,
};
case "is_workflow_enabled":
return {
message: <>{getBooleanActionText(newValue)} custom workflow</>,
};
case "is_time_tracking_enabled":
return {
message: <>{getBooleanActionText(newValue)} time tracking</>,
};
case "is_issue_type_enabled":
return {
message: <>{getBooleanActionText(newValue)} work item types</>,
};
default:
return {
message: `${verb} ${activityType?.replace(/_/g, " ")} `,
};
}
};

View File

@@ -0,0 +1,43 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import Link from "next/link";
// types
import type { TWorkspaceBaseActivity } from "@plane/types";
// store hooks
import { useMember } from "@/hooks/store/use-member";
import { useWorkspace } from "@/hooks/store/use-workspace";
type TUser = {
activity: TWorkspaceBaseActivity;
customUserName?: string;
};
export const User = observer(function User(props: TUser) {
const { activity, customUserName } = props;
// store hooks
const { getUserDetails } = useMember();
const { getWorkspaceById } = useWorkspace();
// derived values
const actorDetail = getUserDetails(activity.actor);
const workspaceDetail = getWorkspaceById(activity.workspace);
return (
<>
{customUserName || actorDetail?.display_name?.includes("-intake") ? (
<span className="font-medium text-primary">{customUserName || "Plane"}</span>
) : (
<Link
href={`/${workspaceDetail?.slug}/profile/${actorDetail?.id}`}
className="font-medium text-primary hover:underline"
>
{actorDetail?.display_name}
</Link>
)}
</>
);
});

View File

@@ -0,0 +1,60 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// icons
import { DATE_BEFORE_FILTER_OPTIONS } from "@plane/constants";
import { CloseOutline } from "@makeplane/propel/icons";
// plane constants
import { renderFormattedDate, capitalizeFirstLetter } from "@plane/utils";
// helpers
type Props = {
editable: boolean | undefined;
handleRemove: (val: string) => void;
values: string[];
};
export const AppliedDateFilters = observer(function AppliedDateFilters(props: Props) {
const { editable, handleRemove, values } = props;
const getDateLabel = (value: string): string => {
let dateLabel = "";
const dateDetails = DATE_BEFORE_FILTER_OPTIONS.find((d) => d.value === value);
if (dateDetails) dateLabel = dateDetails.name;
else {
const dateParts = value.split(";");
if (dateParts.length === 2) {
const [date, time] = dateParts;
dateLabel = `${capitalizeFirstLetter(time)} ${renderFormattedDate(date)}`;
}
}
return dateLabel;
};
return (
<>
{values.map((date) => (
<div key={date} className="flex items-center gap-1 rounded-sm bg-layer-1 px-1.5 py-1 text-11">
<span className="normal-case">{getDateLabel(date)}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-tertiary hover:text-secondary"
onClick={() => handleRemove(date)}
>
<CloseOutline height={10} width={10} />
</button>
)}
</div>
))}
</>
);
});

View File

@@ -0,0 +1,59 @@
/**
* 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 { CloseOutline } from "@makeplane/propel/icons";
// plane ui
import { Avatar } from "@makeplane/propel/components/avatar";
// helpers
import { getFileURL } from "@plane/utils";
// types
import { useMember } from "@/hooks/store/use-member";
type Props = {
handleRemove: (val: string) => void;
values: string[];
editable: boolean | undefined;
};
export const AppliedMembersFilters = observer(function AppliedMembersFilters(props: Props) {
const { handleRemove, values, editable } = props;
// store hooks
const {
workspace: { getWorkspaceMemberDetails },
} = useMember();
return (
<>
{values.map((memberId) => {
const memberDetails = getWorkspaceMemberDetails(memberId)?.member;
if (!memberDetails) return null;
return (
<div key={memberId} className="flex items-center gap-1 rounded-sm bg-layer-1 px-1.5 py-1 text-11">
<Avatar
alt={memberDetails.display_name}
fallback={memberDetails.display_name?.[0]?.toUpperCase()}
src={getFileURL(memberDetails.avatar_url)}
size="2xs"
/>
<span className="normal-case">{memberDetails.display_name}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-tertiary hover:text-secondary"
onClick={() => handleRemove(memberId)}
>
<CloseOutline height={10} width={10} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@@ -0,0 +1,50 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
// plane imports
import { Avatar } from "@makeplane/propel/components/avatar";
import { AvatarGroup, type AvatarGroupSize } from "@makeplane/propel/components/avatar-group";
type Props = {
children: React.ReactNode;
/**
* Shared size for every avatar in the stack.
*/
size: AvatarGroupSize;
/**
* The maximum number of avatars to display. If the number of children exceeds this value, the
* additional avatars are replaced by a count of the remaining avatars.
* @default 2
*/
max?: number;
};
/**
* An overlapping avatar stack that collapses the tail into a `+N` counter.
*
* Propel's `AvatarGroup` renders every child it is given, so the capping behaviour that
* `@plane/ui`'s `AvatarGroup` provided through `max` lives here as app chrome. The overflow
* indicator is a Propel `Avatar` with a `fallback`, matching the Avatar Group overflow pattern.
*/
export function AvatarGroupOverflow(props: Props) {
const { children, size, max = 2 } = props;
const avatars = React.Children.toArray(children);
const totalAvatars = avatars.length;
// If there is only one avatar past the limit, render it instead of a "+1" counter.
const maxAvatarsToRender = totalAvatars <= max + 1 ? max + 1 : max;
const overflowCount = totalAvatars - max;
return (
<AvatarGroup size={size}>
{avatars.slice(0, maxAvatarsToRender)}
{maxAvatarsToRender < totalAvatars && (
<Avatar alt={`${overflowCount} more members`} fallback={`+${overflowCount}`} />
)}
</AvatarGroup>
);
}

View File

@@ -0,0 +1,86 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useMemo } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { Breadcrumbs } from "@plane/ui";
import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = {
label?: string;
href?: string;
icon?: React.ReactNode;
disableTooltip?: boolean;
isLast?: boolean;
};
const IconWrapper = React.memo(function IconWrapper({ icon }: { icon: React.ReactNode }) {
return <div className="flex size-4 items-center justify-center overflow-hidden !text-16">{icon}</div>;
});
IconWrapper.displayName = "IconWrapper";
const LabelWrapper = React.memo(function LabelWrapper({ label }: { label: React.ReactNode }) {
return <div className="relative line-clamp-1 block max-w-[150px] truncate overflow-hidden text-primary">{label}</div>;
});
LabelWrapper.displayName = "LabelWrapper";
const BreadcrumbContent = React.memo(function BreadcrumbContent({
icon,
label,
}: {
icon?: React.ReactNode;
label?: React.ReactNode;
}) {
if (!icon && !label) return null;
return (
<>
{icon && <IconWrapper icon={icon} />}
{label && <LabelWrapper label={label} />}
</>
);
});
BreadcrumbContent.displayName = "BreadcrumbContent";
const ItemWrapper = React.memo(function ItemWrapper({
children,
...props
}: React.ComponentProps<typeof Breadcrumbs.ItemWrapper>) {
return <Breadcrumbs.ItemWrapper {...props}>{children}</Breadcrumbs.ItemWrapper>;
});
ItemWrapper.displayName = "ItemWrapper";
export const BreadcrumbLink = observer(function BreadcrumbLink(props: Props) {
const { href, label, icon, disableTooltip = false, isLast = false } = props;
const { isMobile } = usePlatformOS();
const itemWrapperProps = useMemo(
(): Omit<React.ComponentProps<typeof ItemWrapper>, "children"> => ({
label: label?.toString(),
disableTooltip: isMobile || disableTooltip,
type: href && href !== "" ? "link" : "text",
isLast,
}),
[href, label, isMobile, disableTooltip, isLast]
);
const content = useMemo(() => <BreadcrumbContent icon={icon} label={label} />, [icon, label]);
if (href) {
return (
<Link href={href}>
<ItemWrapper {...itemWrapperProps}>{content}</ItemWrapper>
</Link>
);
}
return <ItemWrapper {...itemWrapperProps}>{content}</ItemWrapper>;
});

View File

@@ -0,0 +1,28 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
//
import { cn } from "@plane/utils";
type TCountChip = {
count: string | number;
className?: string;
};
export function CountChip(props: TCountChip) {
const { count, className = "" } = props;
return (
<div
className={cn(
"relative flex flex-shrink-0 items-center justify-center rounded-xl bg-accent-primary/20 px-2.5 py-0.5 text-caption-sm-semibold text-accent-primary",
className
)}
>
{count}
</div>
);
}

View File

@@ -0,0 +1,50 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { cn } from "@plane/utils";
// helpers
import { getCoverImageDisplayURL, DEFAULT_COVER_IMAGE_URL } from "@/helpers/cover-image.helper";
type TCoverImageProps = {
/** The cover image URL - can be static, uploaded, or external */
src: string | null | undefined;
/** Alt text for the image */
alt?: string;
/** Additional className for the image or skeleton */
className?: string;
/** Whether to show default image when src is null/undefined. If false, shows loading skeleton */
showDefaultWhenEmpty?: boolean;
/** Custom fallback URL to use instead of DEFAULT_COVER_IMAGE_URL */
fallbackUrl?: string;
} & React.ComponentProps<"img">;
/**
* A reusable cover image component that handles:
* - Loading states with skeleton
* - Static images (local assets)
* - Uploaded images (processed through getFileURL)
* - External URLs
* - Fallback to default cover image
*/
export function CoverImage(props: TCoverImageProps) {
const {
src,
alt = "Cover image",
className,
showDefaultWhenEmpty = false,
fallbackUrl = DEFAULT_COVER_IMAGE_URL,
...restProps
} = props;
// Show loading skeleton when src is undefined/null and we don't want to show default
if (!src && !showDefaultWhenEmpty) {
return <div className={cn("animate-pulse bg-layer-2", className)} />;
}
const displayUrl = getCoverImageDisplayURL(src, fallbackUrl);
return <img src={displayUrl} alt={alt} className={cn("object-cover", className)} {...restProps} />;
}

View File

@@ -0,0 +1,48 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
// ui
import { Button } from "@plane/propel/button";
type Props = {
title: string;
description?: React.ReactNode;
image: any;
primaryButton?: {
icon?: any;
text: string;
onClick: () => void;
};
secondaryButton?: React.ReactNode;
disabled?: boolean;
};
export function EmptyState({ title, description, image, primaryButton, secondaryButton, disabled = false }: Props) {
return (
<div className={`flex h-full w-full items-center justify-center`}>
<div className="flex w-full flex-col items-center text-center">
<img src={image} className="w-52 object-contain sm:w-60" alt={primaryButton?.text || "button image"} />
<h6 className="mt-6 mb-3 text-18 font-semibold sm:mt-8">{title}</h6>
{description && <p className="mb-7 px-5 text-tertiary sm:mb-8">{description}</p>}
<div className="flex items-center gap-4">
{primaryButton && (
<Button
variant="primary"
prependIcon={primaryButton.icon}
onClick={primaryButton.onClick}
disabled={disabled}
>
{primaryButton.text}
</Button>
)}
{secondaryButton}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
import { observer } from "mobx-react";
import { useParams } from "react-router";
// components
import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
export const ExtendedAppHeader = observer(function ExtendedAppHeader(props: { header: ReactNode }) {
const { header } = props;
// params
const { projectId, workItem } = useParams();
// preferences
const { preferences: projectPreferences } = useProjectNavigationPreferences();
// store hooks
const { sidebarCollapsed } = useAppTheme();
// derived values
const shouldShowSidebarToggleButton = projectPreferences.navigationMode === "ACCORDION" || (!projectId && !workItem);
return (
<>
{sidebarCollapsed && shouldShowSidebarToggleButton && <AppSidebarToggleButton />}
<div className="w-full">{header}</div>
</>
);
});

View File

@@ -0,0 +1,82 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { DATE_BEFORE_FILTER_OPTIONS } from "@plane/constants";
import { isInDateFormat } from "@plane/utils";
// components
import { DateFilterModal } from "@/components/core/filters/date-filter-modal";
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string | string[]) => void;
searchQuery: string;
};
export const FilterCreatedDate = observer(function FilterCreatedDate(props: Props) {
const { appliedFilters, handleUpdate, searchQuery } = props;
const [previewEnabled, setPreviewEnabled] = useState(true);
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const filteredOptions = DATE_BEFORE_FILTER_OPTIONS.filter((d) =>
d.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const isCustomDateSelected = () => {
const isValidDateSelected = appliedFilters?.filter((f) => isInDateFormat(f.split(";")[0])) || [];
return isValidDateSelected.length > 0 ? true : false;
};
const handleCustomDate = () => {
if (isCustomDateSelected()) {
const updateAppliedFilters = appliedFilters?.filter((f) => f.includes("-")) || [];
handleUpdate(updateAppliedFilters);
} else setIsDateFilterModalOpen(true);
};
return (
<>
{isDateFilterModalOpen && (
<DateFilterModal
handleClose={() => setIsDateFilterModalOpen(false)}
isOpen={isDateFilterModalOpen}
onSelect={(val) => handleUpdate(val)}
title="Created date"
/>
)}
<FilterHeader
title={`Created date${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
<>
{filteredOptions.map((option) => (
<FilterOption
key={option.value}
isChecked={appliedFilters?.includes(option.value) ? true : false}
onClick={() => handleUpdate(option.value)}
title={option.name}
multiple
/>
))}
<FilterOption isChecked={isCustomDateSelected()} onClick={handleCustomDate} title="Custom" multiple />
</>
) : (
<p className="text-11 text-placeholder italic">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,116 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useMemo, useState } from "react";
import { sortBy } from "lodash-es";
import { observer } from "mobx-react";
// ui
import { Avatar } from "@makeplane/propel/components/avatar";
import { Loader } from "@plane/ui";
// components
import { getFileURL } from "@plane/utils";
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
// helpers
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUser } from "@/hooks/store/user";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
memberIds: string[] | undefined;
searchQuery: string;
};
export const FilterCreatedBy = observer(function FilterCreatedBy(props: Props) {
const { appliedFilters, handleUpdate, memberIds, searchQuery } = props;
// states
const [itemsToRender, setItemsToRender] = useState(5);
const [previewEnabled, setPreviewEnabled] = useState(true);
// store hooks
const { getUserDetails } = useMember();
const { data: currentUser } = useUser();
const appliedFiltersCount = appliedFilters?.length ?? 0;
const sortedOptions = useMemo(() => {
const filteredOptions = (memberIds || []).filter((memberId) =>
getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase())
);
return sortBy(filteredOptions, [
(memberId) => !(appliedFilters ?? []).includes(memberId),
(memberId) => memberId !== currentUser?.id,
(memberId) => getUserDetails(memberId)?.display_name.toLowerCase(),
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
const handleViewToggle = () => {
if (!sortedOptions) return;
if (itemsToRender === sortedOptions.length) setItemsToRender(5);
else setItemsToRender(sortedOptions.length);
};
return (
<>
<FilterHeader
title={`Created by${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{sortedOptions ? (
sortedOptions.length > 0 ? (
<>
{sortedOptions.slice(0, itemsToRender).map((memberId) => {
const member = getUserDetails(memberId);
if (!member) return null;
return (
<FilterOption
key={`member-${member.id}`}
isChecked={appliedFilters?.includes(member.id) ? true : false}
onClick={() => handleUpdate(member.id)}
icon={
<Avatar
alt={member.display_name}
fallback={member.display_name?.[0]?.toUpperCase()}
src={getFileURL(member.avatar_url)}
size="xs"
/>
}
title={currentUser?.id === member.id ? "You" : member?.display_name}
/>
);
})}
{sortedOptions.length > 5 && (
<button
type="button"
className="ml-8 text-11 font-medium text-accent-primary"
onClick={handleViewToggle}
>
{itemsToRender === sortedOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-11 text-placeholder italic">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,43 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import Link from "next/link";
import { useTheme } from "next-themes";
// icons
import { ThoughtsOutline } from "@makeplane/propel/icons";
// images
import latestFeatures from "@/app/assets/onboarding/onboarding-pages.webp?url";
export function LatestFeatureBlock() {
const { resolvedTheme } = useTheme();
return (
<>
<div className="mx-auto mt-16 flex rounded-[3.5px] border border-subtle bg-surface-1 py-2 sm:w-96">
<ThoughtsOutline className="mx-3 mr-2 h-7 w-7" />
<p className="text-left text-13 text-primary">
Pages gets a facelift! Write anything and use Galileo to help you start.{" "}
<Link href="https://plane.so/changelog" target="_blank" rel="noopener noreferrer">
<span className="text-13 font-medium underline hover:cursor-pointer">Learn more</span>
</Link>
</p>
</div>
<div
className={`mx-auto mt-8 overflow-hidden rounded-md border border-subtle object-cover sm:h-52 sm:w-96 ${
resolvedTheme === "dark" ? "bg-surface-1" : "bg-layer-2"
}`}
>
<div className="h-[90%]">
<img
src={latestFeatures}
alt="Plane Work items"
className={`-mt-2 ml-10 h-full rounded-md ${resolvedTheme === "dark" ? "bg-surface-1" : "bg-layer-2"}`}
/>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,60 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Component, Fragment } from "react";
import type { ErrorInfo, ReactNode } from "react";
import { WarningTriangleOutline } from "@makeplane/propel/icons";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
type Props = {
children: ReactNode;
};
type State = {
hasError: boolean;
retryKey: number;
};
function LayoutErrorFallback({ onRetry }: { onRetry: () => void }) {
const { t } = useTranslation();
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-center">
<WarningTriangleOutline className="size-8 text-tertiary" />
<p className="text-14 text-secondary">{t("something_went_wrong")}</p>
<Button variant="secondary" size="sm" onClick={onRetry}>
{t("common.retry")}
</Button>
</div>
);
}
// Catches render crashes from a single issue layout (list/kanban/spreadsheet/calendar/gantt)
// so a bad group/column shape degrades to a local fallback instead of taking down the whole page.
export class LayoutErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, retryKey: 0 };
static getDerivedStateFromError(): Partial<State> {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
// eslint-disable-next-line no-console
console.error("Issue layout crashed", error, info);
}
handleRetry = () => {
this.setState((prev) => ({ hasError: false, retryKey: prev.retryKey + 1 }));
};
render() {
if (this.state.hasError) {
return <LayoutErrorFallback onRetry={this.handleRetry} />;
}
return <Fragment key={this.state.retryKey}>{this.props.children}</Fragment>;
}
}

View File

@@ -0,0 +1,31 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
import { cn } from "@plane/utils";
type TSidebarPropertyListItemProps = {
icon: React.FC<{ className?: string }>;
label: string;
children: ReactNode;
appendElement?: ReactNode;
childrenClassName?: string;
};
export function SidebarPropertyListItem(props: TSidebarPropertyListItemProps) {
const { icon: Icon, label, children, appendElement, childrenClassName } = props;
return (
<div className="flex items-start gap-2">
<div className="flex h-7.5 w-30 shrink-0 items-center gap-1.5 text-body-xs-regular text-tertiary">
<Icon className="size-4 shrink-0" />
<span>{label}</span>
{appendElement}
</div>
<div className={cn("flex grow flex-wrap items-center gap-1", childrenClassName)}>{children}</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useTheme } from "next-themes";
// assets
import LogoSpinnerDark from "@/app/assets/images/logo-spinner-dark.gif?url";
import LogoSpinnerLight from "@/app/assets/images/logo-spinner-light.gif?url";
export function LogoSpinner() {
const { resolvedTheme } = useTheme();
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerDark : LogoSpinnerLight;
return (
<div className="flex items-center justify-center">
<img src={logoSrc} alt="logo" className="h-6 w-auto object-contain sm:h-11" />
</div>
);
}

View File

@@ -0,0 +1,32 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { lazy, Suspense } from "react";
import { observer } from "mobx-react";
const ProfileSettingsModal = lazy(() =>
import("@/components/settings/profile/modal").then((module) => ({
default: module.ProfileSettingsModal,
}))
);
type TGlobalModalsProps = {
workspaceSlug: string;
};
/**
* GlobalModals component manages all workspace-level modals across Plane applications.
*
* This includes:
* - Profile settings modal
*/
export const GlobalModals = observer(function GlobalModals(_props: TGlobalModalsProps) {
return (
<Suspense fallback={null}>
<ProfileSettingsModal />
</Suspense>
);
});

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 React, { useState } from "react";
// ui
import { Button } from "@plane/propel/button";
type Props = {
title: string;
description?: React.ReactNode;
image: any;
comicBox?: {
direction: "left" | "right";
title: string;
description: string;
extraPadding?: boolean;
};
primaryButton?: {
icon?: any;
text: string;
onClick: () => void;
};
disabled?: boolean;
};
export function NewEmptyState({ title, description, image, primaryButton, disabled = false, comicBox }: Props) {
const [isHovered, setIsHovered] = useState(false);
const handleMouseEnter = () => {
setIsHovered(true);
};
const handleMouseLeave = () => {
setIsHovered(false);
};
return (
<div className="flex items-center justify-center overflow-y-auto">
<div className="flex h-full w-full flex-col items-center justify-center">
<div className="shadow-sm m-5 flex max-w-6xl flex-col gap-5 rounded-xl border border-subtle px-10 py-7 md:m-8">
<h3 className="text-20 font-semibold">{title}</h3>
{description && <p className="text-16">{description}</p>}
<div className="relative w-full max-w-6xl">
<img src={image} className="h-full w-full object-contain" alt={primaryButton?.text || "button image"} />
</div>
<div className="relative flex items-start justify-center">
{primaryButton && (
<Button
className={`relative m-3 max-w-min !px-6 ${comicBox?.direction === "left" ? "flex-row-reverse" : ""}`}
size="xl"
variant="primary"
onClick={primaryButton.onClick}
disabled={disabled}
>
{primaryButton.text}
<div
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={`bg-blue-300 absolute ${
comicBox?.direction === "left" ? "left-0 ml-2" : "right-0 mr-2"
} z-10 h-2.5 w-2.5 animate-ping rounded-full`}
/>
<div
className={`bg-blue-400/40 absolute ${
comicBox?.direction === "left" ? "left-0 ml-2.5" : "right-0 mr-2.5"
} h-1.5 w-1.5 rounded-full`}
/>
</Button>
)}
{comicBox &&
isHovered &&
(comicBox.direction === "right" ? (
<div
className={`absolute top-0 left-1/2 flex max-w-sm ${
comicBox?.extraPadding ? "ml-[125px]" : "ml-[90px]"
} pb-5`}
>
<div className="relative mt-5 h-0 w-0 border-t-[11px] border-r-[11px] border-b-[11px] border-subtle border-y-transparent">
<div className="border-surface-1 absolute top-[-10px] right-[-12px] h-0 w-0 border-t-[10px] border-r-[10px] border-b-[10px] border-y-transparent" />
</div>
<div className="rounded-md border border-subtle bg-surface-1">
<h1 className="p-5">
<h3 className="text-16 font-semibold">{comicBox?.title}</h3>
<h4 className="mt-1 text-13">{comicBox?.description}</h4>
</h1>
</div>
</div>
) : (
<div className="absolute top-0 right-1/2 mr-[90px] flex max-w-sm flex-row-reverse pb-5">
<div className="relative mt-5 h-0 w-0 border-t-[11px] border-b-[11px] border-l-[11px] border-subtle border-y-transparent">
<div className="border-surface-1 absolute top-[-10px] left-[-12px] h-0 w-0 border-t-[10px] border-b-[10px] border-l-[10px] border-y-transparent" />
</div>
<div className="rounded-md border border-subtle bg-surface-1">
<h1 className="p-5">
<h3 className="text-16 font-semibold">{comicBox?.title}</h3>
<h4 className="mt-1 text-13">{comicBox?.description}</h4>
</h1>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { ArchiveOutline, GlobeOutline, LockOutline } from "@makeplane/propel/icons";
import { EPageAccess } from "@plane/constants";
import type { TPage } from "@plane/types";
export function PageAccessIcon(page: TPage) {
return (
<div>
{page.archived_at ? (
<ArchiveOutline className="h-2.5 w-2.5 text-tertiary" />
) : page.access === EPageAccess.PUBLIC ? (
<GlobeOutline className="h-2.5 w-2.5 text-tertiary" />
) : (
<LockOutline className="h-2.5 w-2.5 text-tertiary" />
)}
</div>
);
}

View File

@@ -0,0 +1,19 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Crown } from "lucide-react";
// helpers
import { cn } from "@plane/utils";
type TProIcon = {
className?: string;
};
export function ProIcon(props: TProIcon) {
const { className } = props;
return <Crown className={cn("text-amber-400 inline-block h-3.5 w-3.5", className)} />;
}

View File

@@ -0,0 +1,94 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import {
ArchiveOutline,
DeleteOutline,
EditOutline,
LinkOutline,
NewTabOutline,
RestoreOutline,
} from "@makeplane/propel/icons";
import { useTranslation } from "@plane/i18n";
import type { TContextMenuItem } from "@plane/ui";
/**
* Unified factory for creating menu items across all entities (cycles, modules, views, epics)
*/
export const useQuickActionsFactory = () => {
const { t } = useTranslation();
return {
// Common menu items
createEditMenuItem: (handler: () => void, shouldRender: boolean = true): TContextMenuItem => ({
key: "edit",
title: t("edit"),
icon: EditOutline,
action: handler,
shouldRender,
}),
createOpenInNewTabMenuItem: (handler: () => void): TContextMenuItem => ({
key: "open-new-tab",
title: t("open_in_new_tab"),
icon: NewTabOutline,
action: handler,
}),
createCopyLinkMenuItem: (handler: () => void): TContextMenuItem => ({
key: "copy-link",
title: t("copy_link"),
icon: LinkOutline,
action: handler,
}),
createArchiveMenuItem: (
handler: () => void,
opts: { shouldRender?: boolean; disabled?: boolean; description?: string }
): TContextMenuItem => ({
key: "archive",
title: t("archive"),
icon: ArchiveOutline,
action: handler,
className: "items-start",
iconClassName: "mt-1",
description: opts.description,
disabled: opts.disabled,
shouldRender: opts.shouldRender,
}),
createRestoreMenuItem: (handler: () => void, shouldRender: boolean = true): TContextMenuItem => ({
key: "restore",
title: t("restore"),
icon: RestoreOutline,
action: handler,
shouldRender,
}),
createDeleteMenuItem: (handler: () => void, shouldRender: boolean = true): TContextMenuItem => ({
key: "delete",
title: t("delete"),
icon: DeleteOutline,
action: handler,
shouldRender,
}),
// Layout-level actions (for work item list views)
createOpenInNewTab: (handler: () => void): TContextMenuItem => ({
key: "open-in-new-tab",
title: "Open in new tab",
icon: NewTabOutline,
action: handler,
}),
createCopyLayoutLinkMenuItem: (handler: () => void): TContextMenuItem => ({
key: "copy-link",
title: "Copy link",
icon: LinkOutline,
action: handler,
}),
};
};

View File

@@ -0,0 +1,153 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// @types/react 19 removed the global JSX namespace; it is imported from react now.
import type { JSX } from "react";
// types
import type { ICycle, IModule, IProjectView, IWorkspaceView } from "@plane/types";
import type { TContextMenuItem } from "@plane/ui";
// hooks
import { useQuickActionsFactory } from "@/components/common/quick-actions-factory";
// Types
interface UseCycleMenuItemsProps {
cycleDetails: ICycle | undefined;
isEditingAllowed: boolean;
workspaceSlug: string;
projectId: string;
cycleId: string;
handleEdit: () => void;
handleArchive: () => void;
handleRestore: () => void;
handleDelete: () => void;
handleCopyLink: () => void;
handleOpenInNewTab: () => void;
}
interface UseModuleMenuItemsProps {
moduleDetails: IModule | undefined;
isEditingAllowed: boolean;
workspaceSlug: string;
projectId: string;
moduleId: string;
handleEdit: () => void;
handleArchive: () => void;
handleRestore: () => void;
handleDelete: () => void;
handleCopyLink: () => void;
handleOpenInNewTab: () => void;
}
interface UseViewMenuItemsProps {
isOwner: boolean;
isAdmin: boolean;
workspaceSlug: string;
projectId?: string;
view: IProjectView | IWorkspaceView;
handleEdit: () => void;
handleDelete: () => void;
handleCopyLink: () => void;
handleOpenInNewTab: () => void;
}
interface UseLayoutMenuItemsProps {
workspaceSlug: string;
projectId: string;
storeType: "PROJECT" | "EPIC";
handleCopyLink: () => void;
handleOpenInNewTab: () => void;
}
type MenuResult = {
items: TContextMenuItem[];
modals: JSX.Element | null;
};
export const useCycleMenuItems = (props: UseCycleMenuItemsProps): MenuResult => {
const factory = useQuickActionsFactory();
const { cycleDetails, isEditingAllowed, ...handlers } = props;
const isArchived = !!cycleDetails?.archived_at;
const isCompleted = cycleDetails?.status?.toLowerCase() === "completed";
// Assemble final menu items - order defined here
const items = [
factory.createEditMenuItem(handlers.handleEdit, isEditingAllowed && !isCompleted && !isArchived),
factory.createOpenInNewTabMenuItem(handlers.handleOpenInNewTab),
factory.createCopyLinkMenuItem(handlers.handleCopyLink),
factory.createArchiveMenuItem(handlers.handleArchive, {
shouldRender: isEditingAllowed && !isArchived,
disabled: !isCompleted,
description: isCompleted ? undefined : "Only completed cycles can be archived",
}),
factory.createRestoreMenuItem(handlers.handleRestore, isEditingAllowed && isArchived),
factory.createDeleteMenuItem(handlers.handleDelete, isEditingAllowed && !isCompleted && !isArchived),
].filter((item) => item.shouldRender !== false);
return { items, modals: null };
};
export const useModuleMenuItems = (props: UseModuleMenuItemsProps): MenuResult => {
const factory = useQuickActionsFactory();
const { moduleDetails, isEditingAllowed, ...handlers } = props;
const isArchived = !!moduleDetails?.archived_at;
const moduleState = moduleDetails?.status?.toLocaleLowerCase();
const isInArchivableGroup = !!moduleState && ["completed", "cancelled"].includes(moduleState);
// Assemble final menu items - order defined here
const items = [
factory.createEditMenuItem(handlers.handleEdit, isEditingAllowed && !isArchived),
factory.createOpenInNewTabMenuItem(handlers.handleOpenInNewTab),
factory.createCopyLinkMenuItem(handlers.handleCopyLink),
factory.createArchiveMenuItem(handlers.handleArchive, {
shouldRender: isEditingAllowed && !isArchived,
disabled: !isInArchivableGroup,
description: isInArchivableGroup ? undefined : "Only completed or cancelled modules can be archived",
}),
factory.createRestoreMenuItem(handlers.handleRestore, isEditingAllowed && isArchived),
factory.createDeleteMenuItem(handlers.handleDelete, isEditingAllowed && !isArchived),
].filter((item) => item.shouldRender !== false);
return { items, modals: null };
};
export const useViewMenuItems = (props: UseViewMenuItemsProps): MenuResult => {
const factory = useQuickActionsFactory();
const { workspaceSlug, isOwner, isAdmin, projectId, view, ...handlers } = props;
if (!view) return { items: [], modals: null };
// Assemble final menu items - order defined here
const items = [
factory.createEditMenuItem(handlers.handleEdit, isOwner),
factory.createOpenInNewTabMenuItem(handlers.handleOpenInNewTab),
factory.createCopyLinkMenuItem(handlers.handleCopyLink),
factory.createDeleteMenuItem(handlers.handleDelete, isOwner || isAdmin),
].filter((item) => item.shouldRender !== false);
return { items, modals: null };
};
export const useLayoutMenuItems = (props: UseLayoutMenuItemsProps): MenuResult => {
const factory = useQuickActionsFactory();
const { ...handlers } = props;
// Assemble final menu items - order defined here
const items = [
factory.createOpenInNewTab(handlers.handleOpenInNewTab),
factory.createCopyLayoutLinkMenuItem(handlers.handleCopyLink),
].filter((item) => item.shouldRender !== false);
return { items, modals: null };
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const useIntakeHeaderMenuItems = (props: {
workspaceSlug: string;
projectId: string;
handleCopyLink: () => void;
}): MenuResult => ({ items: [], modals: null });

View File

@@ -0,0 +1,55 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { FC } from "react";
import { Logo } from "@plane/propel/emoji-icon-picker";
import type { ISvgIcons } from "@plane/propel/icons";
import type { TLogoProps } from "@plane/types";
import { getFileURL, truncateText } from "@plane/utils";
type TSwitcherIconProps = {
logo_props?: TLogoProps;
logo_url?: string;
LabelIcon: FC<ISvgIcons>;
size?: number;
type?: "lucide" | "material";
};
export function SwitcherIcon({ logo_props, logo_url, LabelIcon, size = 12, type = "lucide" }: TSwitcherIconProps) {
if (logo_props?.in_use) {
return <Logo logo={logo_props} size={size} type={type} />;
}
if (logo_url) {
return (
<img
src={getFileURL(logo_url)}
alt="logo"
className="rounded-xs object-cover"
style={{ height: size, width: size }}
/>
);
}
return <LabelIcon height={size} width={size} />;
}
type TSwitcherLabelProps = {
logo_props?: TLogoProps;
logo_url?: string;
name?: string;
LabelIcon: FC<ISvgIcons>;
type?: "lucide" | "material";
};
export function SwitcherLabel(props: TSwitcherLabelProps) {
const { logo_props, name, LabelIcon, logo_url, type = "lucide" } = props;
return (
<div className="flex items-center gap-1 text-secondary">
<SwitcherIcon logo_props={logo_props} logo_url={logo_url} LabelIcon={LabelIcon} type={type} />
{truncateText(name ?? "", 40)}
</div>
);
}