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 ( + +
+

Export

+
+

Format

+ + +
+
+
+ + +
+
+ ); +}; 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()); } }