diff --git a/apps/api/plane/app/views/exporter/base.py b/apps/api/plane/app/views/exporter/base.py
index 183514d039..d3604b56f7 100644
--- a/apps/api/plane/app/views/exporter/base.py
+++ b/apps/api/plane/app/views/exporter/base.py
@@ -66,7 +66,7 @@ class ExportIssuesEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def get(self, request, slug):
- exporter_history = ExporterHistory.objects.filter(workspace__slug=slug, type="issue_exports").select_related(
+ exporter_history = ExporterHistory.objects.filter(workspace__slug=slug).exclude(type="issue_worklogs").select_related(
"workspace", "initiated_by"
)
diff --git a/apps/api/plane/ee/views/app/exporter/module.py b/apps/api/plane/ee/views/app/exporter/module.py
index e5f1806e84..84b4df7d77 100644
--- a/apps/api/plane/ee/views/app/exporter/module.py
+++ b/apps/api/plane/ee/views/app/exporter/module.py
@@ -33,18 +33,20 @@ class ProjectModuleExportEndpoint(BaseAPIView):
# Get the filters
filters = request.data.get("filters", {})
- rich_filters = request.data.get("rich_filters", None)
+ rich_filters = request.data.get("rich_filters", {})
# Add the module filter to module id
- filters["module"] = module_id
+ filters["module"] = str(module_id)
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
-
+
+
+
# Create the exporter
exporter = ExporterHistory.objects.create(
workspace=workspace,
- project=[project_id],
+ project=[str(project_id)],
initiated_by=request.user,
provider=provider,
type="module_exports",
@@ -56,7 +58,7 @@ class ProjectModuleExportEndpoint(BaseAPIView):
issue_export_task.delay(
provider=exporter.provider,
workspace_id=workspace.id,
- project_ids=[project_id],
+ project_ids=[str(project_id)],
token_id=exporter.token,
multiple=False,
slug=slug,
diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/epics/(list)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/epics/(list)/header.tsx
index 2d2b88e1f1..a70a0b4c25 100644
--- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/epics/(list)/header.tsx
+++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/epics/(list)/header.tsx
@@ -20,6 +20,7 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
import { CreateUpdateEpicModal } from "@/plane-web/components/epics/epic-modal";
+import { EpicLayoutQuickActions } from "@/plane-web/components/epics/quick-actions/layout-quick-actions";
import { useIssueTypes } from "@/plane-web/hooks/store";
export const EpicsHeader = observer(() => {
@@ -96,6 +97,9 @@ export const EpicsHeader = observer(() => {
New
Epic
)}
+ {projectId && (
+
+ )}
>
diff --git a/apps/web/ce/components/common/quick-actions-factory.tsx b/apps/web/ce/components/common/quick-actions-factory.tsx
new file mode 100644
index 0000000000..a59a61e533
--- /dev/null
+++ b/apps/web/ce/components/common/quick-actions-factory.tsx
@@ -0,0 +1 @@
+export { useQuickActionsFactory } from "@/components/common/quick-actions-factory";
diff --git a/apps/web/ce/components/cycles/end-cycle/index.ts b/apps/web/ce/components/cycles/end-cycle/index.ts
index 2e60c45619..031608e25f 100644
--- a/apps/web/ce/components/cycles/end-cycle/index.ts
+++ b/apps/web/ce/components/cycles/end-cycle/index.ts
@@ -1,2 +1 @@
export * from "./modal";
-export * from "./use-end-cycle";
diff --git a/apps/web/ce/components/cycles/end-cycle/use-end-cycle.tsx b/apps/web/ce/components/cycles/end-cycle/use-end-cycle.tsx
deleted file mode 100644
index c1bf626185..0000000000
--- a/apps/web/ce/components/cycles/end-cycle/use-end-cycle.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-export const useEndCycle = (isCurrentCycle: boolean) => ({
- isEndCycleModalOpen: false,
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- setEndCycleModalOpen: (value: boolean) => {},
- endCycleContextMenu: undefined,
-});
diff --git a/apps/web/ce/components/views/helper.tsx b/apps/web/ce/components/views/helper.tsx
index d2932ddac6..1063bfcec4 100644
--- a/apps/web/ce/components/views/helper.tsx
+++ b/apps/web/ce/components/views/helper.tsx
@@ -1,7 +1,4 @@
-import { ExternalLink, Link, Pencil, Trash2 } from "lucide-react";
-import { useTranslation } from "@plane/i18n";
import type { EIssueLayoutTypes, IProjectView } from "@plane/types";
-import type { TContextMenuItem } from "@plane/ui";
import type { TWorkspaceLayoutProps } from "@/components/views/helper";
export type TLayoutSelectionProps = {
@@ -14,67 +11,5 @@ export const GlobalViewLayoutSelection = (props: TLayoutSelectionProps) => <>>
export const WorkspaceAdditionalLayouts = (props: TWorkspaceLayoutProps) => <>>;
-export type TMenuItemsFactoryProps = {
- isOwner: boolean;
- isAdmin: boolean;
- setDeleteViewModal: (open: boolean) => void;
- setCreateUpdateViewModal: (open: boolean) => void;
- handleOpenInNewTab: () => void;
- handleCopyText: () => void;
- isLocked: boolean;
- workspaceSlug: string;
- projectId?: string;
- viewId: string;
-};
-
-export const useMenuItemsFactory = (props: TMenuItemsFactoryProps) => {
- const { isOwner, isAdmin, setDeleteViewModal, setCreateUpdateViewModal, handleOpenInNewTab, handleCopyText } = props;
-
- const { t } = useTranslation();
-
- const editMenuItem = () => ({
- key: "edit",
- action: () => setCreateUpdateViewModal(true),
- title: t("edit"),
- icon: Pencil,
- shouldRender: isOwner,
- });
-
- const openInNewTabMenuItem = () => ({
- key: "open-new-tab",
- action: handleOpenInNewTab,
- title: t("open_in_new_tab"),
- icon: ExternalLink,
- });
-
- const copyLinkMenuItem = () => ({
- key: "copy-link",
- action: handleCopyText,
- title: t("copy_link"),
- icon: Link,
- });
-
- const deleteMenuItem = () => ({
- key: "delete",
- action: () => setDeleteViewModal(true),
- title: t("delete"),
- icon: Trash2,
- shouldRender: isOwner || isAdmin,
- });
-
- return {
- editMenuItem,
- openInNewTabMenuItem,
- copyLinkMenuItem,
- deleteMenuItem,
- };
-};
-
-export const useViewMenuItems = (props: TMenuItemsFactoryProps): TContextMenuItem[] => {
- const factory = useMenuItemsFactory(props);
-
- return [factory.editMenuItem(), factory.openInNewTabMenuItem(), factory.copyLinkMenuItem(), factory.deleteMenuItem()];
-};
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const AdditionalHeaderItems = (view: IProjectView) => <>>;
diff --git a/apps/web/core/components/common/quick-actions-factory.tsx b/apps/web/core/components/common/quick-actions-factory.tsx
new file mode 100644
index 0000000000..52e6bfbd30
--- /dev/null
+++ b/apps/web/core/components/common/quick-actions-factory.tsx
@@ -0,0 +1,82 @@
+import { Pencil, ExternalLink, Link, Trash2, ArchiveRestoreIcon } from "lucide-react";
+import { useTranslation } from "@plane/i18n";
+import { ArchiveIcon } from "@plane/propel/icons";
+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: Pencil,
+ action: handler,
+ shouldRender,
+ }),
+
+ createOpenInNewTabMenuItem: (handler: () => void): TContextMenuItem => ({
+ key: "open-new-tab",
+ title: t("open_in_new_tab"),
+ icon: ExternalLink,
+ action: handler,
+ }),
+
+ createCopyLinkMenuItem: (handler: () => void): TContextMenuItem => ({
+ key: "copy-link",
+ title: t("copy_link"),
+ icon: Link,
+ action: handler,
+ }),
+
+ createArchiveMenuItem: (
+ handler: () => void,
+ opts: { shouldRender?: boolean; disabled?: boolean; description?: string }
+ ): TContextMenuItem => ({
+ key: "archive",
+ title: t("archive"),
+ icon: ArchiveIcon,
+ 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: ArchiveRestoreIcon,
+ action: handler,
+ shouldRender,
+ }),
+
+ createDeleteMenuItem: (handler: () => void, shouldRender: boolean = true): TContextMenuItem => ({
+ key: "delete",
+ title: t("delete"),
+ icon: Trash2,
+ 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: ExternalLink,
+ action: handler,
+ }),
+
+ createCopyLayoutLinkMenuItem: (handler: () => void): TContextMenuItem => ({
+ key: "copy-link",
+ title: "Copy link",
+ icon: Link,
+ action: handler,
+ }),
+ };
+};
diff --git a/apps/web/core/components/common/quick-actions-helper.tsx b/apps/web/core/components/common/quick-actions-helper.tsx
new file mode 100644
index 0000000000..873e4142db
--- /dev/null
+++ b/apps/web/core/components/common/quick-actions-helper.tsx
@@ -0,0 +1,231 @@
+// types
+import type { ICycle, IModule, IProjectView, IWorkspaceView } from "@plane/types";
+import type { TContextMenuItem } from "@plane/ui";
+// hooks
+import { useQuickActionsFactory } from "@/plane-web/components/common/quick-actions-factory";
+
+// Types
+export interface UseCycleMenuItemsProps {
+ cycleDetails: ICycle;
+ isEditingAllowed: boolean;
+ workspaceSlug: string;
+ projectId: string;
+ cycleId: string;
+ handleEdit: () => void;
+ handleArchive: () => void;
+ handleRestore: () => void;
+ handleDelete: () => void;
+ handleCopyLink: () => void;
+ handleOpenInNewTab: () => void;
+}
+
+export interface UseModuleMenuItemsProps {
+ moduleDetails: IModule;
+ isEditingAllowed: boolean;
+ workspaceSlug: string;
+ projectId: string;
+ moduleId: string;
+ handleEdit: () => void;
+ handleArchive: () => void;
+ handleRestore: () => void;
+ handleDelete: () => void;
+ handleCopyLink: () => void;
+ handleOpenInNewTab: () => void;
+}
+
+export interface UseViewMenuItemsProps {
+ isOwner: boolean;
+ isAdmin: boolean;
+ workspaceSlug: string;
+ projectId?: string;
+ view: IProjectView | IWorkspaceView;
+ handleEdit: () => void;
+ handleDelete: () => void;
+ handleCopyLink: () => void;
+ handleOpenInNewTab: () => void;
+}
+
+export interface UseLayoutMenuItemsProps {
+ workspaceSlug: string;
+ projectId: string;
+ storeType: "PROJECT" | "EPIC";
+ handleCopyLink: () => void;
+ handleOpenInNewTab: () => void;
+}
+
+export type MenuResult = {
+ items: TContextMenuItem[];
+ modals: JSX.Element | null;
+};
+
+export const useCycleMenuItems = (props: UseCycleMenuItemsProps): MenuResult => {
+ const factory = useQuickActionsFactory();
+ const { cycleDetails, isEditingAllowed, workspaceSlug, projectId, cycleId, ...handlers } = props;
+
+ const isArchived = !!cycleDetails?.archived_at;
+ const isCompleted = cycleDetails?.status?.toLowerCase() === "completed";
+ const isCurrentCycle = cycleDetails?.status?.toLowerCase() === "current";
+ const transferrableIssuesCount = cycleDetails
+ ? cycleDetails.total_issues - (cycleDetails.cancelled_issues + cycleDetails.completed_issues)
+ : 0;
+
+ const endCycleFeature = factory.useCycleEndFeature?.({
+ workspaceSlug,
+ projectId,
+ cycleId,
+ cycleName: cycleDetails.name,
+ isCurrentCycle,
+ transferrableIssuesCount,
+ });
+
+ const exportFeature = factory.useCycleExportFeature?.({
+ workspaceSlug,
+ projectId,
+ cycleId,
+ isArchived,
+ });
+
+ // Assemble final menu items - order defined here
+ const items = [
+ factory.createEditMenuItem(handlers.handleEdit, isEditingAllowed && !isCompleted && !isArchived),
+ factory.createOpenInNewTabMenuItem(handlers.handleOpenInNewTab),
+ factory.createCopyLinkMenuItem(handlers.handleCopyLink),
+ ...(endCycleFeature?.items ?? []),
+ ...(exportFeature?.items ?? []),
+ 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);
+
+ // Assemble final modals
+ const modals = (
+ <>
+ {endCycleFeature?.modals}
+ {exportFeature?.modals}
+ >
+ );
+
+ return { items, modals };
+};
+
+export const useModuleMenuItems = (props: UseModuleMenuItemsProps): MenuResult => {
+ const factory = useQuickActionsFactory();
+ const { moduleDetails, isEditingAllowed, workspaceSlug, projectId, moduleId, ...handlers } = props;
+
+ const isArchived = !!moduleDetails?.archived_at;
+ const moduleState = moduleDetails?.status?.toLocaleLowerCase();
+ const isInArchivableGroup = !!moduleState && ["completed", "cancelled"].includes(moduleState);
+
+ const exportFeature = factory.useModuleExportFeature?.({
+ workspaceSlug,
+ projectId,
+ moduleId,
+ isArchived,
+ });
+
+ // Assemble final menu items - order defined here
+ const items = [
+ factory.createEditMenuItem(handlers.handleEdit, isEditingAllowed && !isArchived),
+ factory.createOpenInNewTabMenuItem(handlers.handleOpenInNewTab),
+ factory.createCopyLinkMenuItem(handlers.handleCopyLink),
+ ...(exportFeature?.items ?? []),
+ 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),
+ ].filter((item) => item.shouldRender !== false);
+
+ // Assemble final modals
+ const modals = exportFeature?.modals ?? null;
+
+ return { items, modals };
+};
+
+export const useViewMenuItems = (props: UseViewMenuItemsProps): MenuResult => {
+ const factory = useQuickActionsFactory();
+ const { workspaceSlug, isOwner, isAdmin, projectId, view, ...handlers } = props;
+
+ if (!view) return { items: [], modals: null };
+
+ const lockFeature = factory.useViewLockFeature?.({
+ workspaceSlug,
+ projectId,
+ viewId: view.id,
+ isLocked: view.is_locked,
+ isOwner,
+ });
+
+ const exportFeature = factory.useViewExportFeature?.({
+ workspaceSlug,
+ projectId,
+ viewId: view.id,
+ });
+
+ // Assemble final menu items - order defined here
+ const items = [
+ factory.createEditMenuItem(handlers.handleEdit, isOwner),
+ ...(lockFeature?.items ?? []),
+ factory.createOpenInNewTabMenuItem(handlers.handleOpenInNewTab),
+ factory.createCopyLinkMenuItem(handlers.handleCopyLink),
+ ...(exportFeature?.items ?? []),
+ factory.createDeleteMenuItem(handlers.handleDelete, isOwner || isAdmin),
+ ].filter((item) => item.shouldRender !== false);
+
+ // Assemble final modals
+ const modals = exportFeature?.modals ?? null;
+
+ return { items, modals };
+};
+
+export const useLayoutMenuItems = (props: UseLayoutMenuItemsProps): MenuResult => {
+ const factory = useQuickActionsFactory();
+ const { workspaceSlug, projectId, storeType, ...handlers } = props;
+
+ const exportFeature = factory.useLayoutExportFeature?.({
+ workspaceSlug,
+ projectId,
+ storeType,
+ });
+
+ // Assemble final menu items - order defined here
+ const items = [
+ factory.createOpenInNewTab(handlers.handleOpenInNewTab),
+ factory.createCopyLayoutLinkMenuItem(handlers.handleCopyLink),
+ ...(exportFeature?.items ?? []),
+ ].filter((item) => item.shouldRender !== false);
+
+ // Assemble final modals
+ const modals = exportFeature?.modals ?? null;
+
+ return { items, modals };
+};
+
+export const useIntakeHeaderMenuItems = (props: {
+ workspaceSlug: string;
+ projectId: string;
+ handleCopyLink: () => void;
+}): MenuResult => {
+ const factory = useQuickActionsFactory();
+
+ const exportFeature = factory.useIntakeExportFeature?.({
+ workspaceSlug: props.workspaceSlug,
+ projectId: props.projectId,
+ });
+
+ // Assemble final menu items - order defined here
+ const items = [factory.createCopyLinkMenuItem(props.handleCopyLink), ...(exportFeature?.items ?? [])].filter(
+ (item) => item.shouldRender !== false
+ );
+
+ // Assemble final modals
+ const modals = exportFeature?.modals ?? null;
+
+ return { items, modals };
+};
diff --git a/apps/web/core/components/cycles/quick-actions.tsx b/apps/web/core/components/cycles/quick-actions.tsx
index 242e91eca1..4c18531980 100644
--- a/apps/web/core/components/cycles/quick-actions.tsx
+++ b/apps/web/core/components/cycles/quick-actions.tsx
@@ -3,8 +3,6 @@
import { useState } from "react";
import { observer } from "mobx-react";
-// icons
-import { ArchiveRestoreIcon, ExternalLink, LinkIcon, Pencil, Trash2 } from "lucide-react";
// ui
import {
CYCLE_TRACKER_EVENTS,
@@ -13,7 +11,6 @@ import {
CYCLE_TRACKER_ELEMENTS,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
-import { ArchiveIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TContextMenuItem } from "@plane/ui";
import { ContextMenu, CustomMenu } from "@plane/ui";
@@ -24,7 +21,7 @@ import { captureClick, captureError, captureSuccess } from "@/helpers/event-trac
import { useCycle } from "@/hooks/store/use-cycle";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
-import { useEndCycle, EndCycleModal } from "@/plane-web/components/cycles";
+import { useCycleMenuItems } from "@/components/common/quick-actions-helper";
// local imports
import { ArchiveCycleModal } from "./archived-cycles/modal";
import { CycleDeleteModal } from "./delete-modal";
@@ -52,12 +49,6 @@ export const CycleQuickActions: React.FC = observer((props) => {
const { t } = useTranslation();
// derived values
const cycleDetails = getCycleById(cycleId);
- const isArchived = !!cycleDetails?.archived_at;
- const isCompleted = cycleDetails?.status?.toLowerCase() === "completed";
- const isCurrentCycle = cycleDetails?.status?.toLowerCase() === "current";
- const transferrableIssuesCount = cycleDetails
- ? cycleDetails.total_issues - (cycleDetails.cancelled_issues + cycleDetails.completed_issues)
- : 0;
// auth
const isEditingAllowed = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
@@ -66,8 +57,6 @@ export const CycleQuickActions: React.FC = observer((props) => {
projectId
);
- const { isEndCycleModalOpen, setEndCycleModalOpen, endCycleContextMenu } = useEndCycle(isCurrentCycle);
-
const cycleLink = `${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`;
const handleCopyText = () =>
copyUrlToClipboard(cycleLink).then(() => {
@@ -79,12 +68,6 @@ export const CycleQuickActions: React.FC = observer((props) => {
});
const handleOpenInNewTab = () => window.open(`/${cycleLink}`, "_blank");
- const handleEditCycle = () => {
- setUpdateModal(true);
- };
-
- const handleArchiveCycle = () => setArchiveCycleModal(true);
-
const handleRestoreCycle = async () =>
await restoreCycle(workspaceSlug, projectId, cycleId)
.then(() => {
@@ -115,60 +98,24 @@ export const CycleQuickActions: React.FC = observer((props) => {
});
});
- const handleDeleteCycle = () => {
- setDeleteModal(true);
- };
+ // Use unified menu hook from plane-web (resolves to CE or EE)
+ const menuResult = useCycleMenuItems({
+ cycleDetails: cycleDetails!,
+ workspaceSlug,
+ projectId,
+ cycleId,
+ isEditingAllowed,
+ handleEdit: () => setUpdateModal(true),
+ handleArchive: () => setArchiveCycleModal(true),
+ handleRestore: handleRestoreCycle,
+ handleDelete: () => setDeleteModal(true),
+ handleCopyLink: handleCopyText,
+ handleOpenInNewTab,
+ });
- const MENU_ITEMS: TContextMenuItem[] = [
- {
- key: "edit",
- title: t("edit"),
- icon: Pencil,
- action: handleEditCycle,
- shouldRender: isEditingAllowed && !isCompleted && !isArchived,
- },
- {
- key: "open-new-tab",
- action: handleOpenInNewTab,
- title: t("open_in_new_tab"),
- icon: ExternalLink,
- shouldRender: !isArchived,
- },
- {
- key: "copy-link",
- action: handleCopyText,
- title: t("copy_link"),
- icon: LinkIcon,
- shouldRender: !isArchived,
- },
- {
- key: "archive",
- action: handleArchiveCycle,
- title: t("archive"),
- description: isCompleted ? undefined : t("project_cycles.only_completed_cycles_can_be_archived"),
- icon: ArchiveIcon,
- className: "items-start",
- iconClassName: "mt-1",
- shouldRender: isEditingAllowed && !isArchived,
- disabled: !isCompleted,
- },
- {
- key: "restore",
- action: handleRestoreCycle,
- title: t("restore"),
- icon: ArchiveRestoreIcon,
- shouldRender: isEditingAllowed && isArchived,
- },
- {
- key: "delete",
- action: handleDeleteCycle,
- title: t("delete"),
- icon: Trash2,
- shouldRender: isEditingAllowed && !isCompleted && !isArchived,
- },
- ];
-
- if (endCycleContextMenu) MENU_ITEMS.splice(3, 0, endCycleContextMenu);
+ // Handle both CE (array) and EE (object) return types
+ const MENU_ITEMS: TContextMenuItem[] = Array.isArray(menuResult) ? menuResult : menuResult.items;
+ const additionalModals = Array.isArray(menuResult) ? null : menuResult.modals;
const CONTEXT_MENU_ITEMS = MENU_ITEMS.map((item) => ({
...item,
@@ -205,17 +152,7 @@ export const CycleQuickActions: React.FC = observer((props) => {
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
- {isCurrentCycle && (
- setEndCycleModalOpen(false)}
- cycleId={cycleId}
- projectId={projectId}
- workspaceSlug={workspaceSlug}
- transferrableIssuesCount={transferrableIssuesCount}
- cycleName={cycleDetails.name}
- />
- )}
+ {additionalModals}
)}
diff --git a/apps/web/core/components/issues/layout-quick-actions.tsx b/apps/web/core/components/issues/layout-quick-actions.tsx
new file mode 100644
index 0000000000..fad93fc1da
--- /dev/null
+++ b/apps/web/core/components/issues/layout-quick-actions.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { observer } from "mobx-react";
+import { TOAST_TYPE, setToast } from "@plane/propel/toast";
+import type { TContextMenuItem } from "@plane/ui";
+import { CustomMenu } from "@plane/ui";
+import { copyUrlToClipboard, cn } from "@plane/utils";
+import { useLayoutMenuItems } from "@/components/common/quick-actions-helper";
+
+type Props = {
+ workspaceSlug: string;
+ projectId: string;
+ storeType: "PROJECT" | "EPIC";
+};
+
+export const LayoutQuickActions: React.FC = observer((props) => {
+ const { workspaceSlug, projectId, storeType } = props;
+
+ const layoutLink = `${workspaceSlug}/projects/${projectId}/${storeType === "EPIC" ? "epics" : "issues"}`;
+
+ const handleCopyLink = () =>
+ copyUrlToClipboard(layoutLink).then(() => {
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Link copied",
+ message: `${storeType === "EPIC" ? "Epics" : "Work items"} link copied to clipboard.`,
+ });
+ });
+
+ const handleOpenInNewTab = () => window.open(`/${layoutLink}`, "_blank");
+
+ // Use unified menu hook from plane-web (resolves to CE or EE)
+ const menuResult = useLayoutMenuItems({
+ workspaceSlug,
+ projectId,
+ storeType,
+ handleCopyLink,
+ handleOpenInNewTab,
+ });
+
+ // Handle both CE (array) and EE (object) return types
+ const MENU_ITEMS: TContextMenuItem[] = Array.isArray(menuResult) ? menuResult : menuResult.items;
+ const additionalModals = Array.isArray(menuResult) ? null : menuResult.modals;
+
+ return (
+ <>
+ {additionalModals}
+
+ {MENU_ITEMS.map((item) => {
+ if (item.shouldRender === false) return null;
+ return (
+
+ {item.icon && }
+ {item.title}
+
+ );
+ })}
+
+ >
+ );
+});
diff --git a/apps/web/core/components/modules/quick-actions.tsx b/apps/web/core/components/modules/quick-actions.tsx
index cc7c470a5d..8189bf4a4b 100644
--- a/apps/web/core/components/modules/quick-actions.tsx
+++ b/apps/web/core/components/modules/quick-actions.tsx
@@ -3,8 +3,6 @@
import { useState } from "react";
import { observer } from "mobx-react";
-// icons
-import { ArchiveRestoreIcon, ExternalLink, LinkIcon, Pencil, Trash2 } from "lucide-react";
// plane imports
import {
EUserPermissions,
@@ -13,8 +11,6 @@ import {
MODULE_TRACKER_EVENTS,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
-// ui
-import { ArchiveIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TContextMenuItem } from "@plane/ui";
import { ContextMenu, CustomMenu } from "@plane/ui";
@@ -27,6 +23,7 @@ import { captureClick, captureSuccess, captureError } from "@/helpers/event-trac
import { useModule } from "@/hooks/store/use-module";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
+import { useModuleMenuItems } from "@/components/common/quick-actions-helper";
type Props = {
parentRef: React.RefObject;
@@ -52,7 +49,6 @@ export const ModuleQuickActions: React.FC = observer((props) => {
const { t } = useTranslation();
// derived values
const moduleDetails = getModuleById(moduleId);
- const isArchived = !!moduleDetails?.archived_at;
// auth
const isEditingAllowed = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
@@ -61,9 +57,6 @@ export const ModuleQuickActions: React.FC = observer((props) => {
projectId
);
- const moduleState = moduleDetails?.status?.toLocaleLowerCase();
- const isInArchivableGroup = !!moduleState && ["completed", "cancelled"].includes(moduleState);
-
const moduleLink = `${workspaceSlug}/projects/${projectId}/modules/${moduleId}`;
const handleCopyText = () =>
copyUrlToClipboard(moduleLink).then(() => {
@@ -75,12 +68,6 @@ export const ModuleQuickActions: React.FC = observer((props) => {
});
const handleOpenInNewTab = () => window.open(`/${moduleLink}`, "_blank");
- const handleEditModule = () => {
- setEditModal(true);
- };
-
- const handleArchiveModule = () => setArchiveModuleModal(true);
-
const handleRestoreModule = async () =>
await restoreModule(workspaceSlug, projectId, moduleId)
.then(() => {
@@ -108,58 +95,24 @@ export const ModuleQuickActions: React.FC = observer((props) => {
});
});
- const handleDeleteModule = () => {
- setDeleteModal(true);
- };
+ // Use unified menu hook from plane-web (resolves to CE or EE)
+ const menuResult = useModuleMenuItems({
+ moduleDetails: moduleDetails!,
+ workspaceSlug,
+ projectId,
+ moduleId,
+ isEditingAllowed,
+ handleEdit: () => setEditModal(true),
+ handleArchive: () => setArchiveModuleModal(true),
+ handleRestore: handleRestoreModule,
+ handleDelete: () => setDeleteModal(true),
+ handleCopyLink: handleCopyText,
+ handleOpenInNewTab,
+ });
- const MENU_ITEMS: TContextMenuItem[] = [
- {
- key: "edit",
- title: t("edit"),
- icon: Pencil,
- action: handleEditModule,
- shouldRender: isEditingAllowed && !isArchived,
- },
- {
- key: "open-new-tab",
- action: handleOpenInNewTab,
- title: t("open_in_new_tab"),
- icon: ExternalLink,
- shouldRender: !isArchived,
- },
- {
- key: "copy-link",
- action: handleCopyText,
- title: t("copy_link"),
- icon: LinkIcon,
- shouldRender: !isArchived,
- },
- {
- key: "archive",
- action: handleArchiveModule,
- title: t("archive"),
- description: isInArchivableGroup ? undefined : t("project_module.quick_actions.archive_module_description"),
- icon: ArchiveIcon,
- className: "items-start",
- iconClassName: "mt-1",
- shouldRender: isEditingAllowed && !isArchived,
- disabled: !isInArchivableGroup,
- },
- {
- key: "restore",
- action: handleRestoreModule,
- title: t("restore"),
- icon: ArchiveRestoreIcon,
- shouldRender: isEditingAllowed && isArchived,
- },
- {
- key: "delete",
- action: handleDeleteModule,
- title: t("delete"),
- icon: Trash2,
- shouldRender: isEditingAllowed,
- },
- ];
+ // Handle both CE (array) and EE (object) return types
+ const MENU_ITEMS: TContextMenuItem[] = Array.isArray(menuResult) ? menuResult : menuResult.items;
+ const additionalModals = Array.isArray(menuResult) ? null : menuResult.modals;
const CONTEXT_MENU_ITEMS: TContextMenuItem[] = MENU_ITEMS.map((item) => ({
...item,
@@ -190,6 +143,7 @@ export const ModuleQuickActions: React.FC = observer((props) => {
handleClose={() => setArchiveModuleModal(false)}
/>
setDeleteModal(false)} />
+ {additionalModals}
)}
diff --git a/apps/web/core/components/views/quick-actions.tsx b/apps/web/core/components/views/quick-actions.tsx
index 96cf8b4c34..eb041ffe2d 100644
--- a/apps/web/core/components/views/quick-actions.tsx
+++ b/apps/web/core/components/views/quick-actions.tsx
@@ -14,7 +14,7 @@ import { copyUrlToClipboard, cn } from "@plane/utils";
import { captureClick } from "@/helpers/event-tracker.helper";
// hooks
import { useUser, useUserPermissions } from "@/hooks/store/user";
-import { useViewMenuItems } from "@/plane-web/components/views/helper";
+import { useViewMenuItems } from "@/components/common/quick-actions-helper";
import { PublishViewModal, useViewPublish } from "@/plane-web/components/views/publish";
// local imports
import { DeleteProjectViewModal } from "./delete-view-modal";
@@ -56,19 +56,22 @@ export const ViewQuickActions: React.FC = observer((props) => {
});
const handleOpenInNewTab = () => window.open(`/${viewLink}`, "_blank");
- const MENU_ITEMS: TContextMenuItem[] = useViewMenuItems({
+ const menuResult = useViewMenuItems({
isOwner,
isAdmin,
- setDeleteViewModal,
- setCreateUpdateViewModal,
- handleOpenInNewTab,
- handleCopyText,
- isLocked: view.is_locked,
workspaceSlug,
projectId,
- viewId: view.id,
+ view,
+ handleEdit: () => setCreateUpdateViewModal(true),
+ handleDelete: () => setDeleteViewModal(true),
+ handleCopyLink: handleCopyText,
+ handleOpenInNewTab,
});
+ // Handle both CE (array) and EE (object) return types
+ const MENU_ITEMS: TContextMenuItem[] = Array.isArray(menuResult) ? menuResult : menuResult.items;
+ const additionalModals = Array.isArray(menuResult) ? null : menuResult.modals;
+
if (publishContextMenu) MENU_ITEMS.splice(2, 0, publishContextMenu);
const CONTEXT_MENU_ITEMS = MENU_ITEMS.map((item) => ({
@@ -90,6 +93,7 @@ export const ViewQuickActions: React.FC = observer((props) => {
/>
setDeleteViewModal(false)} />
setPublishModalOpen(false)} view={view} />
+ {additionalModals}
{MENU_ITEMS.map((item) => {
diff --git a/apps/web/core/components/workspace/views/quick-action.tsx b/apps/web/core/components/workspace/views/quick-action.tsx
index 7998e7255a..7e30b3bda5 100644
--- a/apps/web/core/components/workspace/views/quick-action.tsx
+++ b/apps/web/core/components/workspace/views/quick-action.tsx
@@ -6,14 +6,13 @@ import { observer } from "mobx-react";
import { EUserPermissions, EUserPermissionsLevel, GLOBAL_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IWorkspaceView } from "@plane/types";
-import type { TContextMenuItem } from "@plane/ui";
import { CustomMenu } from "@plane/ui";
import { copyUrlToClipboard, cn } from "@plane/utils";
// helpers
import { captureClick } from "@/helpers/event-tracker.helper";
// hooks
import { useUser, useUserPermissions } from "@/hooks/store/user";
-import { useViewMenuItems } from "@/plane-web/components/views/helper";
+import { useViewMenuItems } from "@/components/common/quick-actions-helper";
// local imports
import { DeleteGlobalViewModal } from "./delete-view-modal";
import { CreateUpdateWorkspaceViewModal } from "./modal";
@@ -46,16 +45,15 @@ export const WorkspaceViewQuickActions: React.FC = observer((props) => {
});
const handleOpenInNewTab = () => window.open(`/${viewLink}`, "_blank");
- const MENU_ITEMS: TContextMenuItem[] = useViewMenuItems({
+ const { items } = useViewMenuItems({
isOwner,
isAdmin,
- setDeleteViewModal,
- setCreateUpdateViewModal: setUpdateViewModal,
+ handleDelete: () => setDeleteViewModal(true),
+ handleEdit: () => setUpdateViewModal(true),
handleOpenInNewTab,
- handleCopyText,
- isLocked: view.is_locked,
+ handleCopyLink: handleCopyText,
workspaceSlug,
- viewId: view.id,
+ view,
});
return (
@@ -68,7 +66,7 @@ export const WorkspaceViewQuickActions: React.FC = observer((props) => {
closeOnSelect
buttonClassName="flex-shrink-0 flex items-center justify-center size-[26px] bg-custom-background-80/70 rounded"
>
- {MENU_ITEMS.map((item) => {
+ {items.map((item) => {
if (item.shouldRender === false) return null;
return (
{
+ const coreFactory = useCoreQuickActionsFactory();
+
+ return {
+ ...coreFactory,
+
+ // EE-specific menu items
+ createEndCycleMenuItem: (handler: () => void, shouldRender: boolean = true): TContextMenuItem => ({
+ key: "end-cycle",
+ title: "End Cycle",
+ icon: StopCircle,
+ action: handler,
+ shouldRender,
+ }),
+
+ createExportMenuItem: (handler: () => void, shouldRender: boolean = true): TContextMenuItem => ({
+ key: "export",
+ title: "Export",
+ icon: Download,
+ action: handler,
+ shouldRender,
+ }),
+
+ createLockMenuItem: (
+ handler: () => void,
+ opts: { isLocked: boolean; shouldRender?: boolean }
+ ): TContextMenuItem => ({
+ key: "toggle-lock",
+ title: opts.isLocked ? "Unlock" : "Lock",
+ icon: opts.isLocked ? LockOpen : Lock,
+ action: handler,
+ shouldRender: opts.shouldRender,
+ }),
+
+ // EE feature: End Cycle with modal
+ useCycleEndFeature: (props: {
+ workspaceSlug: string;
+ projectId: string;
+ cycleId: string;
+ cycleName: string;
+ isCurrentCycle: boolean;
+ transferrableIssuesCount: number;
+ }): FeatureResult => {
+ const [isOpen, setIsOpen] = useState(false);
+ const isEnabled = useFlag(props.workspaceSlug, E_FEATURE_FLAGS.CYCLE_PROGRESS_CHARTS);
+
+ const items =
+ isEnabled && props.isCurrentCycle
+ ? [
+ {
+ key: "end-cycle",
+ title: "End Cycle",
+ icon: StopCircle,
+ action: () => setIsOpen(true),
+ shouldRender: true,
+ } as TContextMenuItem,
+ ]
+ : [];
+
+ const modals =
+ isEnabled && props.isCurrentCycle ? (
+ setIsOpen(false)}
+ cycleId={props.cycleId}
+ projectId={props.projectId}
+ workspaceSlug={props.workspaceSlug}
+ transferrableIssuesCount={props.transferrableIssuesCount}
+ cycleName={props.cycleName}
+ />
+ ) : null;
+
+ return { items, modals };
+ },
+
+ // EE feature: Export for Cycles
+ useCycleExportFeature: (props: {
+ workspaceSlug: string;
+ projectId: string;
+ cycleId: string;
+ isArchived: boolean;
+ }): FeatureResult => {
+ const [isOpen, setIsOpen] = useState(false);
+ const { getFilter } = useWorkItemFilters();
+ const richFilters = getFilter(EIssuesStoreType.CYCLE, props.cycleId)?.getExternalExpression();
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { issuesFilter: _issuesFilter } = useIssues(EIssuesStoreType.CYCLE);
+ const isEnabled = useFlag(props.workspaceSlug, E_FEATURE_FLAGS.ADVANCED_EXPORTS);
+
+ const handleExport = async (provider: TExportProvider) => {
+ try {
+ await exportService.exportCycleWorkItems(
+ props.workspaceSlug,
+ props.projectId,
+ props.cycleId,
+ provider,
+ richFilters
+ );
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Export started",
+ message: "Your export will be ready soon.",
+ actionItems: (
+
+ ),
+ });
+ setIsOpen(false);
+ } catch (_error) {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message: "Failed to export cycle. Please try again.",
+ });
+ }
+ };
+
+ const items =
+ isEnabled && !props.isArchived
+ ? [
+ {
+ key: "export",
+ title: "Export",
+ icon: Download,
+ action: () => setIsOpen(true),
+ shouldRender: true,
+ } as TContextMenuItem,
+ ]
+ : [];
+
+ const modals = isEnabled ? (
+ setIsOpen(false)} onConfirm={handleExport} />
+ ) : null;
+
+ return { items, modals };
+ },
+
+ // EE feature: Export for Modules
+ useModuleExportFeature: (props: {
+ workspaceSlug: string;
+ projectId: string;
+ moduleId: string;
+ isArchived: boolean;
+ }): FeatureResult => {
+ const [isOpen, setIsOpen] = useState(false);
+ const { getFilter } = useWorkItemFilters();
+ const richFilters = getFilter(EIssuesStoreType.MODULE, props.moduleId)?.getExternalExpression();
+ const isEnabled = useFlag(props.workspaceSlug, E_FEATURE_FLAGS.ADVANCED_EXPORTS);
+
+ const handleExport = async (provider: TExportProvider) => {
+ try {
+ await exportService.exportModuleWorkItems(
+ props.workspaceSlug,
+ props.projectId,
+ props.moduleId,
+ provider,
+ richFilters
+ );
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Export started",
+ message: "Your export will be ready soon.",
+ actionItems: (
+
+ ),
+ });
+ setIsOpen(false);
+ } catch (_error) {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message: "Failed to export module. Please try again.",
+ });
+ }
+ };
+
+ const items =
+ isEnabled && !props.isArchived
+ ? [
+ {
+ key: "export",
+ title: "Export",
+ icon: Download,
+ action: () => setIsOpen(true),
+ shouldRender: true,
+ } as TContextMenuItem,
+ ]
+ : [];
+
+ const modals = isEnabled ? (
+ setIsOpen(false)} onConfirm={handleExport} />
+ ) : null;
+
+ return { items, modals };
+ },
+
+ // EE feature: View Lock
+ useViewLockFeature: (props: {
+ workspaceSlug: string;
+ projectId?: string;
+ viewId: string;
+ isLocked: boolean;
+ isOwner: boolean;
+ }): FeatureResult => {
+ const { lockView, unLockView } = useProjectView();
+ const isEnabled = useFlag(props.workspaceSlug, E_FEATURE_FLAGS.VIEW_LOCK);
+
+ const handleToggleLock = async () => {
+ if (!props.projectId) return;
+ const operation = props.isLocked ? unLockView : lockView;
+ await operation(props.workspaceSlug, props.projectId, props.viewId)
+ .then(() => {
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Success!",
+ message: props.isLocked ? "View unlocked successfully." : "View locked successfully.",
+ });
+ })
+ .catch(() => {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message: props.isLocked
+ ? "View could not be unlocked. Please try again later."
+ : "View could not be locked. Please try again later.",
+ });
+ });
+ };
+
+ const items =
+ isEnabled && props.isOwner
+ ? [
+ {
+ key: "toggle-lock",
+ title: props.isLocked ? "Unlock" : "Lock",
+ icon: props.isLocked ? LockOpen : Lock,
+ action: handleToggleLock,
+ shouldRender: true,
+ } as TContextMenuItem,
+ ]
+ : [];
+
+ return { items, modals: null };
+ },
+
+ // EE feature: Export for Views
+ useViewExportFeature: (props: { workspaceSlug: string; projectId?: string; viewId: string }): FeatureResult => {
+ const [isOpen, setIsOpen] = useState(false);
+ const { getFilter } = useWorkItemFilters();
+ const richFilterEntityType = props.projectId ? EIssuesStoreType.PROJECT_VIEW : EIssuesStoreType.GLOBAL;
+ const richFilters = getFilter(richFilterEntityType, props.viewId)?.getExternalExpression();
+ const isEnabled = useFlag(props.workspaceSlug, E_FEATURE_FLAGS.ADVANCED_EXPORTS);
+
+ const handleExport = async (provider: TExportProvider) => {
+ try {
+ if (props.projectId) {
+ await exportService.exportProjectViewWorkItems(
+ props.workspaceSlug,
+ props.projectId,
+ props.viewId,
+ provider,
+ richFilters
+ );
+ } else {
+ await exportService.exportWorkspaceViewWorkItems(props.workspaceSlug, props.viewId, provider, richFilters);
+ }
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Export started",
+ message: "Your export will be ready soon.",
+ actionItems: (
+
+ ),
+ });
+ setIsOpen(false);
+ } catch (_error) {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message: "Failed to export view. Please try again.",
+ });
+ }
+ };
+
+ const items = isEnabled
+ ? [
+ {
+ key: "export",
+ title: "Export",
+ icon: Download,
+ action: () => setIsOpen(true),
+ shouldRender: true,
+ } as TContextMenuItem,
+ ]
+ : [];
+
+ const modals = isEnabled ? (
+ setIsOpen(false)} onConfirm={handleExport} />
+ ) : null;
+
+ return { items, modals };
+ },
+
+ // EE feature: Export for Layouts
+ useLayoutExportFeature: (props: {
+ workspaceSlug: string;
+ projectId: string;
+ storeType: "PROJECT" | "EPIC";
+ }): FeatureResult => {
+ const [isOpen, setIsOpen] = useState(false);
+ const { getFilter } = useWorkItemFilters();
+ const richFilterEntityType = props.storeType === "EPIC" ? EIssuesStoreType.EPIC : EIssuesStoreType.PROJECT;
+ const richFilters = getFilter(richFilterEntityType, props.projectId)?.getExternalExpression();
+ const isEnabled = useFlag(props.workspaceSlug, E_FEATURE_FLAGS.ADVANCED_EXPORTS);
+
+ const handleExport = async (provider: TExportProvider) => {
+ try {
+ if (props.storeType === "EPIC") {
+ await exportService.exportEpics(props.workspaceSlug, props.projectId, provider, richFilters);
+ } else {
+ await exportService.exportWorkItems(props.workspaceSlug, props.projectId, provider, richFilters);
+ }
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Export started",
+ message: "Your export will be ready soon.",
+ actionItems: (
+
+ ),
+ });
+ setIsOpen(false);
+ } catch (_error) {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message: `Failed to export ${props.storeType === "EPIC" ? "epics" : "work items"}. Please try again.`,
+ });
+ }
+ };
+
+ const items = isEnabled
+ ? [
+ {
+ key: "export",
+ title: "Export",
+ icon: Download,
+ action: () => setIsOpen(true),
+ shouldRender: true,
+ } as TContextMenuItem,
+ ]
+ : [];
+
+ const modals = isEnabled ? (
+ setIsOpen(false)} onConfirm={handleExport} />
+ ) : null;
+
+ return { items, modals };
+ },
+
+ // EE feature: Export for Intake
+ useIntakeExportFeature: (props: { workspaceSlug: string; projectId: string }): FeatureResult => {
+ const [isOpen, setIsOpen] = useState(false);
+ const isEnabled = useFlag(props.workspaceSlug, E_FEATURE_FLAGS.ADVANCED_EXPORTS);
+
+ const handleExport = async (provider: TExportProvider) => {
+ try {
+ await exportService.exportIntakeWorkItems(props.workspaceSlug, props.projectId, provider);
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Export started",
+ message: "Your export will be ready soon.",
+ actionItems: (
+
+ ),
+ });
+ setIsOpen(false);
+ } catch (_error) {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message: "Failed to export intake. Please try again.",
+ });
+ }
+ };
+
+ const items = isEnabled
+ ? [
+ {
+ key: "export",
+ title: "Export",
+ icon: Download,
+ action: () => setIsOpen(true),
+ shouldRender: true,
+ } as TContextMenuItem,
+ ]
+ : [];
+
+ const modals = isEnabled ? (
+ setIsOpen(false)} onConfirm={handleExport} />
+ ) : null;
+
+ return { items, modals };
+ },
+ };
+};
diff --git a/apps/web/ee/components/cycles/end-cycle/index.ts b/apps/web/ee/components/cycles/end-cycle/index.ts
index 2e60c45619..031608e25f 100644
--- a/apps/web/ee/components/cycles/end-cycle/index.ts
+++ b/apps/web/ee/components/cycles/end-cycle/index.ts
@@ -1,2 +1 @@
export * from "./modal";
-export * from "./use-end-cycle";
diff --git a/apps/web/ee/components/cycles/end-cycle/use-end-cycle.tsx b/apps/web/ee/components/cycles/end-cycle/use-end-cycle.tsx
deleted file mode 100644
index abed34cc4a..0000000000
--- a/apps/web/ee/components/cycles/end-cycle/use-end-cycle.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { useState } from "react";
-import { useParams } from "next/navigation";
-import { StopCircle } from "lucide-react";
-//sto
-import { E_FEATURE_FLAGS } from "@plane/constants";
-import { useFlag } from "@/plane-web/hooks/store";
-
-export const useEndCycle = (isCurrentCycle: boolean) => {
- const [isEndCycleModalOpen, setEndCycleModalOpen] = useState(false);
- // router
- const { workspaceSlug } = useParams();
- // store hooks
- const isEndCycleEnabled = useFlag(workspaceSlug?.toString(), E_FEATURE_FLAGS.CYCLE_PROGRESS_CHARTS);
-
- const endCycleContextMenu = {
- key: "end-cycle",
- title: "End Cycle",
- icon: StopCircle,
- action: () => setEndCycleModalOpen(true),
- shouldRender: isCurrentCycle,
- };
-
- return {
- isEndCycleModalOpen,
- setEndCycleModalOpen,
- endCycleContextMenu: isEndCycleEnabled ? endCycleContextMenu : undefined,
- };
-};
diff --git a/apps/web/ee/components/epics/quick-actions/layout-quick-actions.tsx b/apps/web/ee/components/epics/quick-actions/layout-quick-actions.tsx
new file mode 100644
index 0000000000..b68e88d28c
--- /dev/null
+++ b/apps/web/ee/components/epics/quick-actions/layout-quick-actions.tsx
@@ -0,0 +1,56 @@
+import { cn, CustomMenu } from "@plane/ui";
+import { copyUrlToClipboard } from "@plane/utils";
+import { useLayoutMenuItems } from "@/components/common/quick-actions-helper";
+
+type Props = {
+ workspaceSlug: string;
+ projectId: string;
+};
+
+export const EpicLayoutQuickActions = (props: Props) => {
+ const { workspaceSlug, projectId } = props;
+
+ const handleCopyLink = () => {
+ copyUrlToClipboard(`/${workspaceSlug}/projects/${projectId}/epics`);
+ };
+
+ const handleOpenInNewTab = () => window.open(`/${workspaceSlug}/projects/${projectId}/epics`, "_blank");
+
+ const { items, modals } = useLayoutMenuItems({
+ workspaceSlug,
+ projectId,
+ storeType: "EPIC",
+ handleCopyLink,
+ handleOpenInNewTab,
+ });
+
+ return (
+ <>
+
+ {items.map((item) => {
+ if (item.shouldRender === false) return null;
+ return (
+
+ {item.icon && }
+ {item.title}
+
+ );
+ })}
+
+ {modals}
+ >
+ );
+};
diff --git a/apps/web/ee/components/issues/advanced-header.tsx b/apps/web/ee/components/issues/advanced-header.tsx
index f3217a2ee1..d1b93ad0ba 100644
--- a/apps/web/ee/components/issues/advanced-header.tsx
+++ b/apps/web/ee/components/issues/advanced-header.tsx
@@ -24,6 +24,7 @@ import { CountChip } from "@/components/common/count-chip";
import { HeaderFilters } from "@/components/issues/filters";
// helpers
// hooks
+import { LayoutQuickActions } from "@/components/issues/layout-quick-actions";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
@@ -118,6 +119,7 @@ export const AdvancedIssuesHeader = observer(() => {
) : (
<>>
)}
+
);
diff --git a/apps/web/ee/components/issues/issue-layouts/export-modal.tsx b/apps/web/ee/components/issues/issue-layouts/export-modal.tsx
new file mode 100644
index 0000000000..9be868aecc
--- /dev/null
+++ b/apps/web/ee/components/issues/issue-layouts/export-modal.tsx
@@ -0,0 +1,53 @@
+import React, { useState } from "react";
+import { Button } from "@plane/propel/button";
+import { ModalCore, EModalWidth, EModalPosition } from "@plane/ui";
+export type TExportProvider = "csv" | "xlsx" | "json";
+
+type Props = {
+ isOpen: boolean;
+ onClose: () => void;
+ onConfirm: (provider: TExportProvider) => void | Promise;
+ defaultProvider?: TExportProvider;
+};
+
+export const ExportModal: React.FC = (props) => {
+ const { isOpen, onClose, onConfirm, defaultProvider = null } = props;
+ const [provider, setProvider] = useState(defaultProvider);
+
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const handleConfirm = async () => {
+ if (!provider) return;
+ setIsSubmitting(true);
+ await onConfirm(provider);
+ setIsSubmitting(false);
+ onClose();
+ };
+
+ return (
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/ee/components/projects/settings/intake/header/quick-actions.tsx b/apps/web/ee/components/projects/settings/intake/header/quick-actions.tsx
new file mode 100644
index 0000000000..c905a725ec
--- /dev/null
+++ b/apps/web/ee/components/projects/settings/intake/header/quick-actions.tsx
@@ -0,0 +1,48 @@
+import { cn, CustomMenu } from "@plane/ui";
+import { copyUrlToClipboard } from "@plane/utils";
+import { useIntakeHeaderMenuItems } from "@/components/common/quick-actions-helper";
+
+type Props = {
+ workspaceSlug: string;
+ projectId: string;
+};
+
+export const IntakeHeaderQuickActions = (props: Props) => {
+ const { workspaceSlug, projectId } = props;
+
+ const handleCopyLink = () => {
+ copyUrlToClipboard(`/${workspaceSlug}/projects/${projectId}/intake`);
+ };
+
+ const { items, modals } = useIntakeHeaderMenuItems({ workspaceSlug, projectId, handleCopyLink });
+
+ return (
+ <>
+
+ {items.map((item) => {
+ if (item.shouldRender === false) return null;
+ return (
+
+ {item.icon && }
+ {item.title}
+
+ );
+ })}
+
+ {modals}
+ >
+ );
+};
diff --git a/apps/web/ee/components/projects/settings/intake/header/root.tsx b/apps/web/ee/components/projects/settings/intake/header/root.tsx
index 3c80b5a7d8..d766826121 100644
--- a/apps/web/ee/components/projects/settings/intake/header/root.tsx
+++ b/apps/web/ee/components/projects/settings/intake/header/root.tsx
@@ -22,6 +22,7 @@ import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/com
// local components
import { useFlag } from "@/plane-web/hooks/store";
import IntakeTooltip from "../intake-tooltip";
+import { IntakeHeaderQuickActions } from "./quick-actions";
export const ProjectInboxHeader: FC = observer(() => {
// states
@@ -137,6 +138,7 @@ export const ProjectInboxHeader: FC = observer(() => {
) : (
<>>
)}
+
);
diff --git a/apps/web/ee/components/views/helper.tsx b/apps/web/ee/components/views/helper.tsx
index 109c41abd6..4bdf38fa9a 100644
--- a/apps/web/ee/components/views/helper.tsx
+++ b/apps/web/ee/components/views/helper.tsx
@@ -1,19 +1,19 @@
import { useEffect } from "react";
-import { Lock, LockIcon, LockOpen } from "lucide-react";
+import { LockIcon } from "lucide-react";
import { E_FEATURE_FLAGS } from "@plane/constants";
-import { setToast, TOAST_TYPE } from "@plane/propel/toast";
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
-import type { TContextMenuItem } from "@plane/ui";
-import type { TLayoutSelectionProps, TMenuItemsFactoryProps } from "@/ce/components/views/helper";
-import { useMenuItemsFactory as useCoreMenuItemsFactory } from "@/ce/components/views/helper";
import { LayoutSelection } from "@/components/issues/issue-layouts/filters";
import type { TWorkspaceLayoutProps } from "@/components/views/helper";
-import { useGlobalView } from "@/hooks/store/use-global-view";
import { useIssues } from "@/hooks/store/use-issues";
-import { useProjectView } from "@/hooks/store/use-project-view";
import { useFlag } from "@/plane-web/hooks/store";
import { WorkspaceGanttRoot } from "../issues/issue-layouts/gantt/root";
+export type TLayoutSelectionProps = {
+ onChange: (layout: EIssueLayoutTypes) => void;
+ selectedLayout: EIssueLayoutTypes;
+ workspaceSlug: string;
+};
+
const ALLOWED_LAYOUTS = [EIssueLayoutTypes.SPREADSHEET, EIssueLayoutTypes.GANTT];
/**
@@ -53,72 +53,6 @@ export const WorkspaceAdditionalLayouts = (props: TWorkspaceLayoutProps) => {
}
};
-const useMenuItemsFactory = (props: TMenuItemsFactoryProps) => {
- const { isLocked, workspaceSlug, projectId, viewId } = props;
- const factory = useCoreMenuItemsFactory(props);
-
- const { lockView: lockProjectView, unLockView: unLockProjectView } = useProjectView();
-
- const { lockView: lockGlobalView, unLockView: unLockGlobalView } = useGlobalView();
-
- const handleToggleResult = (promise: Promise) => {
- promise
- .then(() => {
- setToast({
- type: TOAST_TYPE.SUCCESS,
- title: "Success!",
- message: isLocked ? "View unlocked successfully." : "View locked successfully.",
- });
- })
- .catch(() => {
- setToast({
- type: TOAST_TYPE.ERROR,
- title: "Error!",
- message: isLocked
- ? "View could not be unlocked. Please try again later."
- : "View could not be locked. Please try again later.",
- });
- });
- };
-
- const toggleViewLock = () => {
- if (projectId) {
- const operation = isLocked ? unLockProjectView : lockProjectView;
- return handleToggleResult(operation(workspaceSlug, projectId, viewId));
- }
- const operation = isLocked ? unLockGlobalView : lockGlobalView;
- return handleToggleResult(operation(workspaceSlug, viewId));
- };
-
- const toggleLockMenuItem = () => ({
- key: "toggle-lock",
- action: () => toggleViewLock(),
- title: isLocked ? "Unlock" : "Lock",
- icon: isLocked ? LockOpen : Lock,
- });
-
- return {
- ...factory,
- toggleLockMenuItem,
- };
-};
-
-export const useViewMenuItems = (props: TMenuItemsFactoryProps): TContextMenuItem[] => {
- const factory = useMenuItemsFactory(props);
-
- const isViewLockEnabled = useFlag(props.workspaceSlug, E_FEATURE_FLAGS.VIEW_LOCK);
-
- const isOwner = props.isOwner || false;
-
- return [
- factory.editMenuItem(),
- isViewLockEnabled && isOwner ? factory.toggleLockMenuItem() : null,
- factory.openInNewTabMenuItem(),
- factory.copyLinkMenuItem(),
- factory.deleteMenuItem(),
- ].filter(Boolean) as TContextMenuItem[];
-};
-
export const AdditionalHeaderItems = ({ isLocked }: { isLocked: boolean }) => {
if (!isLocked) return null;
return (
diff --git a/apps/web/ee/services/export.service.ts b/apps/web/ee/services/export.service.ts
new file mode 100644
index 0000000000..59b4b94aaa
--- /dev/null
+++ b/apps/web/ee/services/export.service.ts
@@ -0,0 +1,131 @@
+/* eslint-disable no-useless-catch */
+import { API_BASE_URL } from "@plane/constants";
+import type { TWorkItemFilterExpression } from "@plane/types";
+import { APIService } from "@/services/api.service";
+
+export type TExportProvider = "csv" | "xlsx" | "json";
+
+export class ExportService extends APIService {
+ constructor() {
+ super(API_BASE_URL);
+ }
+
+ async exportWorkItems(
+ workspaceSlug: string,
+ projectId: string,
+ provider: TExportProvider,
+ rich_filters?: TWorkItemFilterExpression | null
+ ): Promise {
+ try {
+ await this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/work-items/export/`, {
+ provider,
+ rich_filters,
+ });
+ } catch (error) {
+ throw error;
+ }
+ }
+
+ async exportCycleWorkItems(
+ workspaceSlug: string,
+ projectId: string,
+ cycleId: string,
+ provider: TExportProvider,
+ rich_filters?: TWorkItemFilterExpression | null
+ ): Promise {
+ try {
+ await this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/export/`, {
+ provider,
+ rich_filters,
+ });
+ } catch (error) {
+ throw error;
+ }
+ }
+
+ async exportModuleWorkItems(
+ workspaceSlug: string,
+ projectId: string,
+ moduleId: string,
+ provider: TExportProvider,
+ rich_filters?: TWorkItemFilterExpression | null
+ ): Promise {
+ try {
+ await this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/export/`, {
+ provider,
+ rich_filters,
+ });
+ } catch (error) {
+ throw error;
+ }
+ }
+
+ async exportProjectViewWorkItems(
+ workspaceSlug: string,
+ projectId: string,
+ viewId: string,
+ provider: TExportProvider,
+ rich_filters?: TWorkItemFilterExpression | null
+ ): Promise {
+ try {
+ await this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/views/${viewId}/export/`, {
+ provider,
+ rich_filters,
+ });
+ } catch (error) {
+ throw error;
+ }
+ }
+
+ async exportWorkspaceViewWorkItems(
+ workspaceSlug: string,
+ viewId: string,
+ provider: TExportProvider,
+ rich_filters?: TWorkItemFilterExpression | null
+ ): Promise {
+ try {
+ await this.post(`/api/workspaces/${workspaceSlug}/views/${viewId}/export/`, {
+ provider,
+ rich_filters,
+ });
+ } catch (error) {
+ throw error;
+ }
+ }
+
+ async exportIntakeWorkItems(
+ workspaceSlug: string,
+ projectId: string,
+ provider: TExportProvider,
+ rich_filters?: TWorkItemFilterExpression | null
+ ): Promise {
+ try {
+ await this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/intakes/export/`, {
+ provider,
+ rich_filters,
+ });
+ } catch (error) {
+ throw error;
+ }
+ }
+
+ async exportEpics(
+ workspaceSlug: string,
+ projectId: string,
+ provider: TExportProvider,
+ rich_filters?: TWorkItemFilterExpression | null
+ ): Promise {
+ try {
+ await this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/epics/export/`, {
+ provider,
+ rich_filters,
+ });
+ } catch (error) {
+ throw error;
+ }
+ }
+}
+
+const exportService = new ExportService();
+
+export default exportService;
diff --git a/packages/constants/src/feature-flag.ts b/packages/constants/src/feature-flag.ts
index 3d9bdea190..38b9636a93 100644
--- a/packages/constants/src/feature-flag.ts
+++ b/packages/constants/src/feature-flag.ts
@@ -33,6 +33,7 @@ export enum E_FEATURE_FLAGS {
GLOBAL_VIEWS_TIMELINE = "GLOBAL_VIEWS_TIMELINE",
COPY_WORK_ITEM = "COPY_WORK_ITEM",
LINK_PAGES = "LINK_PAGES",
+ ADVANCED_EXPORTS = "ADVANCED_EXPORTS",
MILESTONES = "MILESTONES",
AUTO_SCHEDULE_CYCLES = "AUTO_SCHEDULE_CYCLES",
diff --git a/packages/shared-state/src/store/rich-filters/filter.ts b/packages/shared-state/src/store/rich-filters/filter.ts
index 94236036bc..b86b20f876 100644
--- a/packages/shared-state/src/store/rich-filters/filter.ts
+++ b/packages/shared-state/src/store/rich-filters/filter.ts
@@ -116,6 +116,7 @@ export interface IFilterInstance Promise;
// expression options actions
updateExpressionOptions: (newOptions: Partial>) => void;
+ getExternalExpression: () => E;
}
type TFilterParams = {
@@ -504,7 +505,7 @@ export class FilterInstance
["saveView"] = action(async () => {
if (this.canSaveView && this.saveViewOptions) {
- await this.saveViewOptions.onViewSave(this._getExternalExpression());
+ await this.saveViewOptions.onViewSave(this.getExternalExpression());
} else {
console.warn("Cannot save view: invalid expression or missing options.");
}
@@ -515,7 +516,7 @@ export class FilterInstance
["updateView"] = action(async () => {
if (this.canUpdateView && this.updateViewOptions) {
- await this.updateViewOptions.onViewUpdate(this._getExternalExpression());
+ await this.updateViewOptions.onViewUpdate(this.getExternalExpression());
this._resetInitialFilterExpression();
} else {
console.warn("Cannot update view: invalid expression or missing options.");
@@ -533,6 +534,14 @@ export class FilterInstance
+ this.adapter.toExternal(sanitizeAndStabilizeExpression(toJS(this.expression)))
+ );
+
// ------------ private helpers ------------
/**
* Resets the initial filter expression to the current expression.
@@ -541,18 +550,10 @@ export class FilterInstance
- this.adapter.toExternal(sanitizeAndStabilizeExpression(toJS(this.expression)))
- );
-
/**
* Notifies the parent component of the expression change.
*/
private _notifyExpressionChange(): void {
- this.onExpressionChange?.(this._getExternalExpression());
+ this.onExpressionChange?.(this.getExternalExpression());
}
}