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
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:
56
apps/web/core/components/common/access-field.tsx
Normal file
56
apps/web/core/components/common/access-field.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
apps/web/core/components/common/activity/activity-block.tsx
Normal file
60
apps/web/core/components/common/activity/activity-block.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
apps/web/core/components/common/activity/activity-item.tsx
Normal file
35
apps/web/core/components/common/activity/activity-item.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
291
apps/web/core/components/common/activity/helper.tsx
Normal file
291
apps/web/core/components/common/activity/helper.tsx
Normal 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, " ")} `,
|
||||
};
|
||||
}
|
||||
};
|
||||
43
apps/web/core/components/common/activity/user.tsx
Normal file
43
apps/web/core/components/common/activity/user.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
60
apps/web/core/components/common/applied-filters/date.tsx
Normal file
60
apps/web/core/components/common/applied-filters/date.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
59
apps/web/core/components/common/applied-filters/members.tsx
Normal file
59
apps/web/core/components/common/applied-filters/members.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
50
apps/web/core/components/common/avatar-group-overflow.tsx
Normal file
50
apps/web/core/components/common/avatar-group-overflow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
apps/web/core/components/common/breadcrumb-link.tsx
Normal file
86
apps/web/core/components/common/breadcrumb-link.tsx
Normal 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>;
|
||||
});
|
||||
28
apps/web/core/components/common/count-chip.tsx
Normal file
28
apps/web/core/components/common/count-chip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
apps/web/core/components/common/cover-image.tsx
Normal file
50
apps/web/core/components/common/cover-image.tsx
Normal 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} />;
|
||||
}
|
||||
48
apps/web/core/components/common/empty-state.tsx
Normal file
48
apps/web/core/components/common/empty-state.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
apps/web/core/components/common/extended-app-header.tsx
Normal file
33
apps/web/core/components/common/extended-app-header.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
82
apps/web/core/components/common/filters/created-at.tsx
Normal file
82
apps/web/core/components/common/filters/created-at.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
116
apps/web/core/components/common/filters/created-by.tsx
Normal file
116
apps/web/core/components/common/filters/created-by.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
43
apps/web/core/components/common/latest-feature-block.tsx
Normal file
43
apps/web/core/components/common/latest-feature-block.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
60
apps/web/core/components/common/layout-error-boundary.tsx
Normal file
60
apps/web/core/components/common/layout-error-boundary.tsx
Normal 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>;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
22
apps/web/core/components/common/logo-spinner.tsx
Normal file
22
apps/web/core/components/common/logo-spinner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
apps/web/core/components/common/modal/global.tsx
Normal file
32
apps/web/core/components/common/modal/global.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
110
apps/web/core/components/common/new-empty-state.tsx
Normal file
110
apps/web/core/components/common/new-empty-state.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
apps/web/core/components/common/page-access-icon.tsx
Normal file
23
apps/web/core/components/common/page-access-icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
apps/web/core/components/common/pro-icon.tsx
Normal file
19
apps/web/core/components/common/pro-icon.tsx
Normal 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)} />;
|
||||
}
|
||||
94
apps/web/core/components/common/quick-actions-factory.tsx
Normal file
94
apps/web/core/components/common/quick-actions-factory.tsx
Normal 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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
153
apps/web/core/components/common/quick-actions-helper.tsx
Normal file
153
apps/web/core/components/common/quick-actions-helper.tsx
Normal 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 });
|
||||
55
apps/web/core/components/common/switcher-label.tsx
Normal file
55
apps/web/core/components/common/switcher-label.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user