[WEB-5151]feat: advanced exports (#4567)
This commit is contained in:
@@ -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,
|
||||
|
||||
+4
@@ -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,
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user