[WEB-5151]feat: advanced exports (#4567)

This commit is contained in:
Vamsi Krishna
2025-10-30 20:46:49 +05:30
committed by GitHub
parent 9c1376a840
commit 165cdbfb84
26 changed files with 1243 additions and 356 deletions
+1 -1
View File
@@ -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"
)
@@ -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,
@@ -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(() => {
<div className="hidden sm:block">New</div> Epic
</Button>
)}
{projectId && (
<EpicLayoutQuickActions workspaceSlug={workspaceSlug?.toString()} projectId={projectId.toString()} />
)}
</Header.RightItem>
</Header>
</>
@@ -0,0 +1 @@
export { useQuickActionsFactory } from "@/components/common/quick-actions-factory";
@@ -1,2 +1 @@
export * from "./modal";
export * from "./use-end-cycle";
@@ -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,
});
-65
View File
@@ -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) => <></>;
@@ -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,
}),
};
};
@@ -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 };
};
@@ -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<Props> = 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<Props> = 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<Props> = 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<Props> = 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<Props> = observer((props) => {
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
{isCurrentCycle && (
<EndCycleModal
isOpen={isEndCycleModalOpen}
handleClose={() => setEndCycleModalOpen(false)}
cycleId={cycleId}
projectId={projectId}
workspaceSlug={workspaceSlug}
transferrableIssuesCount={transferrableIssuesCount}
cycleName={cycleDetails.name}
/>
)}
{additionalModals}
</div>
)}
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
@@ -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<Props> = 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}
<CustomMenu
ellipsis
placement="bottom-end"
closeOnSelect
maxHeight="lg"
className="flex-shrink-0 flex items-center justify-center size-[26px] bg-custom-background-80/70 rounded"
>
{MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={item.action}
className={cn("flex items-center gap-2", {
"text-custom-text-400": item.disabled,
})}
disabled={item.disabled}
>
{item.icon && <item.icon className="h-3 w-3" />}
<span>{item.title}</span>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</>
);
});
@@ -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<HTMLDivElement>;
@@ -52,7 +49,6 @@ export const ModuleQuickActions: React.FC<Props> = 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<Props> = 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<Props> = 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<Props> = 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<Props> = observer((props) => {
handleClose={() => setArchiveModuleModal(false)}
/>
<DeleteModuleModal data={moduleDetails} isOpen={deleteModal} onClose={() => setDeleteModal(false)} />
{additionalModals}
</div>
)}
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
@@ -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<Props> = 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<Props> = observer((props) => {
/>
<DeleteProjectViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
<PublishViewModal isOpen={isPublishModalOpen} onClose={() => setPublishModalOpen(false)} view={view} />
{additionalModals}
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
<CustomMenu ellipsis placement="bottom-end" closeOnSelect buttonClassName={customClassName}>
{MENU_ITEMS.map((item) => {
@@ -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<Props> = 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<Props> = 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 (
<CustomMenu.MenuItem
@@ -0,0 +1,474 @@
import { useState } from "react";
import { StopCircle, Download, Lock, LockOpen } from "lucide-react";
import type { TContextMenuItem } from "@plane/ui";
import { E_FEATURE_FLAGS } from "@plane/constants";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { EIssuesStoreType } from "@plane/types";
// core
import { useQuickActionsFactory as useCoreQuickActionsFactory } from "@/components/common/quick-actions-factory";
import { useIssues } from "@/hooks/store/use-issues";
import { useProjectView } from "@/hooks/store/use-project-view";
import { useWorkItemFilters } from "@/hooks/store/work-item-filters/use-work-item-filters";
import { EndCycleModal } from "@/plane-web/components/cycles/end-cycle";
import { ExportModal } from "@/plane-web/components/issues/issue-layouts/export-modal";
import type { TExportProvider } from "@/plane-web/components/issues/issue-layouts/export-modal";
import { useFlag } from "@/plane-web/hooks/store";
import exportService from "@/plane-web/services/export.service";
type FeatureResult = {
items: TContextMenuItem[];
modals: JSX.Element | null;
};
/**
* EE-specific factory extending the core factory with EE-only menu item creators and features
*/
export const useQuickActionsFactory = () => {
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 ? (
<EndCycleModal
isOpen={isOpen}
handleClose={() => 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: (
<div className="flex items-center gap-1 text-xs text-custom-text-200">
<a
href={`/${props.workspaceSlug}/settings/exports`}
target="_blank"
rel="noopener noreferrer"
className="text-custom-primary px-2 py-1 hover:bg-custom-background-90 font-medium rounded"
>
View Exports
</a>
</div>
),
});
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 ? (
<ExportModal isOpen={isOpen} onClose={() => 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: (
<div className="flex items-center gap-1 text-xs text-custom-text-200">
<a
href={`/${props.workspaceSlug}/settings/exports`}
target="_blank"
rel="noopener noreferrer"
className="text-custom-primary px-2 py-1 hover:bg-custom-background-90 font-medium rounded"
>
View Exports
</a>
</div>
),
});
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 ? (
<ExportModal isOpen={isOpen} onClose={() => 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: (
<div className="flex items-center gap-1 text-xs text-custom-text-200">
<a
href={`/${props.workspaceSlug}/settings/exports`}
target="_blank"
rel="noopener noreferrer"
className="text-custom-primary px-2 py-1 hover:bg-custom-background-90 font-medium rounded"
>
View Exports
</a>
</div>
),
});
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 ? (
<ExportModal isOpen={isOpen} onClose={() => 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: (
<div className="flex items-center gap-1 text-xs text-custom-text-200">
<a
href={`/${props.workspaceSlug}/settings/exports`}
target="_blank"
rel="noopener noreferrer"
className="text-custom-primary px-2 py-1 hover:bg-custom-background-90 font-medium rounded"
>
View Exports
</a>
</div>
),
});
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 ? (
<ExportModal isOpen={isOpen} onClose={() => 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: (
<div className="flex items-center gap-1 text-xs text-custom-text-200">
<a
href={`/${props.workspaceSlug}/settings/exports`}
target="_blank"
rel="noopener noreferrer"
className="text-custom-primary px-2 py-1 hover:bg-custom-background-90 font-medium rounded"
>
View Exports
</a>
</div>
),
});
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 ? (
<ExportModal isOpen={isOpen} onClose={() => setIsOpen(false)} onConfirm={handleExport} />
) : null;
return { items, modals };
},
};
};
@@ -1,2 +1 @@
export * from "./modal";
export * from "./use-end-cycle";
@@ -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,
};
};
@@ -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 (
<>
<CustomMenu
ellipsis
placement="bottom-end"
closeOnSelect
maxHeight="lg"
className="flex-shrink-0 flex items-center justify-center size-[26px] bg-custom-background-80/70 rounded"
>
{items.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={item.action}
className={cn("flex items-center gap-2", {
"text-custom-text-400": item.disabled,
})}
disabled={item.disabled}
>
{item.icon && <item.icon className="h-3 w-3" />}
<span>{item.title}</span>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
{modals}
</>
);
};
@@ -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(() => {
) : (
<></>
)}
<LayoutQuickActions workspaceSlug={workspaceSlug} projectId={projectId} storeType={EIssuesStoreType.PROJECT} />
</Header.RightItem>
</Header>
);
@@ -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<void>;
defaultProvider?: TExportProvider;
};
export const ExportModal: React.FC<Props> = (props) => {
const { isOpen, onClose, onConfirm, defaultProvider = null } = props;
const [provider, setProvider] = useState<TExportProvider | null>(defaultProvider);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleConfirm = async () => {
if (!provider) return;
setIsSubmitting(true);
await onConfirm(provider);
setIsSubmitting(false);
onClose();
};
return (
<ModalCore isOpen={isOpen} handleClose={onClose} width={EModalWidth.SM} position={EModalPosition.TOP}>
<div className="p-5">
<h3 className="text-base text-custom-text-100 font-medium mb-2">Export</h3>
<div className="space-y-2">
<p className="font-medium text-sm text-custom-text-200">Format</p>
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="provider" checked={provider === "csv"} onChange={() => setProvider("csv")} />
<span className="text-xs">CSV</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="provider" checked={provider === "json"} onChange={() => setProvider("json")} />
<span className="text-xs">JSON</span>
</label>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-custom-border-200 p-3">
<Button variant="neutral-primary" size="sm" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" size="sm" onClick={handleConfirm} disabled={!provider || isSubmitting}>
Continue
</Button>
</div>
</ModalCore>
);
};
@@ -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 (
<>
<CustomMenu
ellipsis
placement="bottom-end"
closeOnSelect
maxHeight="lg"
className="flex-shrink-0 flex items-center justify-center size-[26px] bg-custom-background-80/70 rounded"
>
{items.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={item.action}
className={cn("flex items-center gap-2", {
"text-custom-text-400": item.disabled,
})}
disabled={item.disabled}
>
{item.icon && <item.icon className="h-3 w-3" />}
<span>{item.title}</span>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
{modals}
</>
);
};
@@ -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(() => {
) : (
<></>
)}
<IntakeHeaderQuickActions workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
</Header.RightItem>
</Header>
);
+7 -73
View File
@@ -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<unknown>) => {
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 (
+131
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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;
+1
View File
@@ -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",
@@ -116,6 +116,7 @@ export interface IFilterInstance<P extends TFilterProperty, E extends TExternalF
updateView: () => Promise<void>;
// expression options actions
updateExpressionOptions: (newOptions: Partial<TExpressionOptions<E>>) => void;
getExternalExpression: () => E;
}
type TFilterParams<P extends TFilterProperty, E extends TExternalFilter> = {
@@ -504,7 +505,7 @@ export class FilterInstance<P extends TFilterProperty, E extends TExternalFilter
*/
saveView: IFilterInstance<P, E>["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<P extends TFilterProperty, E extends TExternalFilter
*/
updateView: IFilterInstance<P, E>["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<P extends TFilterProperty, E extends TExternalFilter
};
});
/**
* Returns the external filter representation of the filter instance.
* @returns The external filter representation of the filter instance.
*/
getExternalExpression = computedFn(() =>
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<P extends TFilterProperty, E extends TExternalFilter
this.initialFilterExpression = cloneDeep(this.expression);
}
/**
* Returns the external filter representation of the filter instance.
* @returns The external filter representation of the filter instance.
*/
private _getExternalExpression = computedFn(() =>
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());
}
}