[WEB-2388] dev: workspace draft issues (#5772)

* chore: workspace draft page added

* chore: workspace draft issues services added

* chore: workspace draft issue store added

* chore: workspace draft issue filter store added

* chore: issue rendering

* conflicts: resolved merge conflicts

* conflicts: handled draft issue store

* chore: draft issue modal

* chore: code optimisation

* chore: ui changes

* chore: workspace draft store and modal updated

* chore: workspace draft issue component added

* chore: updated store and workflow in draft issues

* chore: updated issue draft store

* chore: updated issue type cleanup in components

* chore: code refactor

* fix: build error

* fix: quick actions

* fix: update mutation

* fix: create update modal

* chore: commented project draft issue code

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
Anmol Singh Bhatia 2024-10-10 19:12:34 +05:30 committed by GitHub
parent e9158f820f
commit 332d2d5c68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 1895 additions and 190 deletions

View file

@ -44,18 +44,11 @@ from plane.utils.issue_filters import issue_filters
class WorkspaceDraftIssueViewSet(BaseViewSet): class WorkspaceDraftIssueViewSet(BaseViewSet):
model = DraftIssue model = DraftIssue
@method_decorator(gzip_page) def get_queryset(self):
@allow_permission( return (
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" DraftIssue.objects.filter(workspace__slug=self.kwargs.get("slug"))
)
def list(self, request, slug):
filters = issue_filters(request.query_params, "GET")
issues = (
DraftIssue.objects.filter(workspace__slug=slug)
.filter(created_by=request.user)
.select_related("workspace", "project", "state", "parent") .select_related("workspace", "project", "state", "parent")
.prefetch_related( .prefetch_related(
"assignees", "labels", "draft_issue_module__module" "assignees", "labels", "draft_issue_module__module"
@ -91,6 +84,17 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
Value([], output_field=ArrayField(UUIDField())), Value([], output_field=ArrayField(UUIDField())),
), ),
) )
).distinct()
@method_decorator(gzip_page)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
def list(self, request, slug):
filters = issue_filters(request.query_params, "GET")
issues = (
self.get_queryset()
.filter(created_by=request.user)
.order_by("-created_at") .order_by("-created_at")
) )
@ -120,7 +124,34 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
) )
if serializer.is_valid(): if serializer.is_valid():
serializer.save() serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED) issue = (
self.get_queryset()
.filter(pk=serializer.data.get("id"))
.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"created_at",
"updated_at",
"created_by",
"updated_by",
)
.first()
)
return Response(issue, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission( @allow_permission(
@ -131,45 +162,7 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
) )
def partial_update(self, request, slug, pk): def partial_update(self, request, slug, pk):
issue = ( issue = (
DraftIssue.objects.filter(workspace__slug=slug) self.get_queryset().filter(pk=pk, created_by=request.user).first()
.filter(pk=pk)
.filter(created_by=request.user)
.select_related("workspace", "project", "state", "parent")
.prefetch_related(
"assignees", "labels", "draft_issue_module__module"
)
.annotate(cycle_id=F("draft_issue_cycle__cycle_id"))
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"draft_issue_module__module_id",
distinct=True,
filter=~Q(draft_issue_module__module_id__isnull=True)
& Q(
draft_issue_module__module__archived_at__isnull=True
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.first()
) )
if not issue: if not issue:
@ -202,46 +195,8 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
) )
def retrieve(self, request, slug, pk=None): def retrieve(self, request, slug, pk=None):
issue = ( issue = (
DraftIssue.objects.filter(workspace__slug=slug) self.get_queryset().filter(pk=pk, created_by=request.user).first()
.filter(pk=pk)
.filter(created_by=request.user)
.select_related("workspace", "project", "state", "parent")
.prefetch_related(
"assignees", "labels", "draft_issue_module__module"
) )
.annotate(cycle_id=F("draft_issue_cycle__cycle_id"))
.filter(pk=pk)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"draft_issue_module__module_id",
distinct=True,
filter=~Q(draft_issue_module__module_id__isnull=True)
& Q(
draft_issue_module__module__archived_at__isnull=True
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).first()
if not issue: if not issue:
return Response( return Response(
@ -268,42 +223,7 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
level="WORKSPACE", level="WORKSPACE",
) )
def create_draft_to_issue(self, request, slug, draft_id): def create_draft_to_issue(self, request, slug, draft_id):
draft_issue = ( draft_issue = self.get_queryset().filter(pk=draft_id).first()
DraftIssue.objects.filter(workspace__slug=slug, pk=draft_id)
.annotate(cycle_id=F("draft_issue_cycle__cycle_id"))
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"draft_issue_module__module_id",
distinct=True,
filter=~Q(draft_issue_module__module_id__isnull=True)
& Q(
draft_issue_module__module__archived_at__isnull=True
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.select_related("project", "workspace")
.first()
)
if not draft_issue.project_id: if not draft_issue.project_id:
return Response( return Response(

View file

@ -150,7 +150,7 @@ class OffsetPaginator:
raise BadPaginationError("Pagination offset cannot be negative") raise BadPaginationError("Pagination offset cannot be negative")
results = queryset[offset:stop] results = queryset[offset:stop]
print(limit, "limit")
if cursor.value != limit: if cursor.value != limit:
results = results[-(limit + 1) :] results = results[-(limit + 1) :]
@ -761,7 +761,7 @@ class BasePaginator:
): ):
"""Paginate the request""" """Paginate the request"""
per_page = self.get_per_page(request, default_per_page, max_per_page) per_page = self.get_per_page(request, default_per_page, max_per_page)
print(per_page, "per_page")
# Convert the cursor value to integer and float from string # Convert the cursor value to integer and float from string
input_cursor = None input_cursor = None
try: try:
@ -788,6 +788,7 @@ class BasePaginator:
paginator = paginator_cls(**paginator_kwargs) paginator = paginator_cls(**paginator_kwargs)
try: try:
print(per_page, "per_page 2")
cursor_result = paginator.get_result( cursor_result = paginator.get_result(
limit=per_page, cursor=input_cursor limit=per_page, cursor=input_cursor
) )

View file

@ -29,3 +29,4 @@ export * from "./pragmatic";
export * from "./publish"; export * from "./publish";
export * from "./workspace-notifications"; export * from "./workspace-notifications";
export * from "./favorite"; export * from "./favorite";
export * from "./workspace-draft-issues/base";

View file

@ -10,6 +10,7 @@ export * from "./issue_relation";
export * from "./issue_sub_issues"; export * from "./issue_sub_issues";
export * from "./activity/base"; export * from "./activity/base";
export type TLoader = export type TLoader =
| "init-loader" | "init-loader"
| "mutation" | "mutation"

View file

@ -0,0 +1,61 @@
import { TIssuePriorities } from "../issues";
export type TWorkspaceDraftIssue = {
id: string;
name: string;
sort_order: number;
state_id: string | undefined;
priority: TIssuePriorities | undefined;
label_ids: string[];
assignee_ids: string[];
estimate_point: string | undefined;
project_id: string | undefined;
parent_id: string | undefined;
cycle_id: string | undefined;
module_ids: string[] | undefined;
start_date: string | undefined;
target_date: string | undefined;
completed_at: string | undefined;
created_at: string;
updated_at: string;
created_by: string;
updated_by: string;
is_draft: boolean;
};
export type TWorkspaceDraftPaginationInfo<T> = {
next_cursor: string | undefined;
prev_cursor: string | undefined;
next_page_results: boolean | undefined;
prev_page_results: boolean | undefined;
total_pages: number | undefined;
count: number | undefined; // current paginated results count
total_count: number | undefined; // total available results count
total_results: number | undefined;
results: T[] | undefined;
extra_stats: string | undefined;
grouped_by: string | undefined;
sub_grouped_by: string | undefined;
};
export type TWorkspaceDraftQueryParams = {
per_page: number;
cursor: string;
};
export type TWorkspaceDraftIssueLoader =
| "init-loader"
| "empty-state"
| "mutation"
| "pagination"
| "loaded"
| "create"
| "update"
| "delete"
| "move"
| undefined;

View file

@ -0,0 +1,62 @@
"use client";
import { FC, useState } from "react";
import { observer } from "mobx-react";
import { PenSquare } from "lucide-react";
// ui
import { Breadcrumbs, Button, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
import { CreateUpdateIssueModal } from "@/components/issues";
// constants
import { EIssuesStoreType } from "@/constants/issue";
// hooks
import { useUserPermissions } from "@/hooks/store";
// plane-web
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
export const WorkspaceDraftHeader: FC = observer(() => {
// state
const [isDraftIssueModalOpen, setIsDraftIssueModalOpen] = useState(false);
// store hooks
const { allowPermissions } = useUserPermissions();
// check if user is authorized to create draft issue
const isAuthorizedUser = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
return (
<>
<CreateUpdateIssueModal
isOpen={isDraftIssueModalOpen}
storeType={EIssuesStoreType.WORKSPACE_DRAFT}
onClose={() => setIsDraftIssueModalOpen(false)}
isDraft
/>
<Header>
<Header.LeftItem>
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
type="text"
link={<BreadcrumbLink label={`Draft`} icon={<PenSquare className="h-4 w-4 text-custom-text-300" />} />}
/>
</Breadcrumbs>
</Header.LeftItem>
<Header.RightItem>
<Button
variant="primary"
size="sm"
className="items-center gap-1"
onClick={() => setIsDraftIssueModalOpen(true)}
disabled={!isAuthorizedUser}
>
Draft <span className="hidden sm:inline-block">issue</span>
</Button>
</Header.RightItem>
</Header>
</>
);
});

View file

@ -0,0 +1,13 @@
"use client";
import { AppHeader, ContentWrapper } from "@/components/core";
import { WorkspaceDraftHeader } from "./header";
export default function WorkspaceDraftLayout({ children }: { children: React.ReactNode }) {
return (
<>
<AppHeader header={<WorkspaceDraftHeader />} />
<ContentWrapper>{children}</ContentWrapper>
</>
);
}

View file

@ -0,0 +1,27 @@
"use client";
import { useParams } from "next/navigation";
// components
import { PageHead } from "@/components/core";
import { WorkspaceDraftIssuesRoot } from "@/components/issues/workspace-draft";
const WorkspaceDraftPage = () => {
// router
const { workspaceSlug: routeWorkspaceSlug } = useParams();
const pageTitle = "Workspace Draft";
// derived values
const workspaceSlug = (routeWorkspaceSlug as string) || undefined;
if (!workspaceSlug) return null;
return (
<>
<PageHead title={pageTitle} />
<div className="relative h-full w-full overflow-hidden overflow-y-auto">
<WorkspaceDraftIssuesRoot workspaceSlug={workspaceSlug} />
</div>
</>
);
};
export default WorkspaceDraftPage;

View file

@ -0,0 +1,48 @@
import React, { useCallback } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { WorkspaceDraftIssueQuickActions } from "@/components/issues/issue-layouts/quick-action-dropdowns";
import { IssuePeekOverview } from "@/components/issues/peek-overview";
import { EIssuesStoreType } from "@/constants/issue";
import { useUserPermissions } from "@/hooks/store";
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties";
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
import { BaseListRoot } from "../../../list/base-list-root";
export const WorkspaceDraftIssueLayoutRoot = observer(() => {
// router
const { workspaceSlug } = useParams();
//swr hook for fetching issue properties
useWorkspaceIssueProperties(workspaceSlug);
// store
const { allowPermissions } = useUserPermissions();
const canEditProperties = useCallback(
(projectId: string | undefined) => {
if (!projectId) return false;
return allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
workspaceSlug.toString(),
projectId
);
},
[workspaceSlug, allowPermissions]
);
return (
<IssuesStoreContext.Provider value={EIssuesStoreType.WORKSPACE_DRAFT}>
<div className="relative flex h-full w-full flex-col overflow-hidden">
<div className="relative h-full w-full overflow-auto">
<BaseListRoot
QuickActions={WorkspaceDraftIssueQuickActions}
canEditPropertiesBasedOnProject={canEditProperties}
/>
<IssuePeekOverview is_draft />
</div>
</div>
</IssuesStoreContext.Provider>
);
});

View file

@ -25,7 +25,8 @@ type ListStoreType =
| EIssuesStoreType.PROJECT_VIEW | EIssuesStoreType.PROJECT_VIEW
| EIssuesStoreType.DRAFT | EIssuesStoreType.DRAFT
| EIssuesStoreType.PROFILE | EIssuesStoreType.PROFILE
| EIssuesStoreType.ARCHIVED; | EIssuesStoreType.ARCHIVED
| EIssuesStoreType.WORKSPACE_DRAFT;
interface IBaseListRoot { interface IBaseListRoot {
QuickActions: FC<IQuickActionProps>; QuickActions: FC<IQuickActionProps>;
addIssuesToView?: (issueIds: string[]) => Promise<any>; addIssuesToView?: (issueIds: string[]) => Promise<any>;
@ -62,7 +63,8 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
const { workspaceSlug, projectId } = useParams(); const { workspaceSlug, projectId } = useParams();
const { updateFilters } = useIssuesActions(storeType); const { updateFilters } = useIssuesActions(storeType);
const collapsedGroups = issuesFilter?.issueFilters?.kanbanFilters || { group_by: [], sub_group_by: [] } as TIssueKanbanFilters; const collapsedGroups =
issuesFilter?.issueFilters?.kanbanFilters || ({ group_by: [], sub_group_by: [] } as TIssueKanbanFilters);
useEffect(() => { useEffect(() => {
fetchIssues("init-loader", { canGroup: true, perPageCount: group_by ? 50 : 100 }, viewId); fetchIssues("init-loader", { canGroup: true, perPageCount: group_by ? 50 : 100 }, viewId);
@ -122,15 +124,14 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
} else { } else {
collapsedGroups.push(value); collapsedGroups.push(value);
} }
updateFilters(projectId?.toString() ?? "", EIssueFilterType.KANBAN_FILTERS, updateFilters(projectId?.toString() ?? "", EIssueFilterType.KANBAN_FILTERS, {
{ group_by: collapsedGroups } as TIssueKanbanFilters group_by: collapsedGroups,
); } as TIssueKanbanFilters);
} }
}, },
[workspaceSlug, issuesFilter, projectId, updateFilters] [workspaceSlug, issuesFilter, projectId, updateFilters]
); );
return ( return (
<IssueLayoutHOC layout={EIssueLayoutTypes.LIST}> <IssueLayoutHOC layout={EIssueLayoutTypes.LIST}>
<div className={`relative size-full bg-custom-background-90`}> <div className={`relative size-full bg-custom-background-90`}>

View file

@ -71,7 +71,7 @@ export const List: React.FC<IList> = observer((props) => {
isCompletedCycle = false, isCompletedCycle = false,
loadMoreIssues, loadMoreIssues,
handleCollapsedGroups, handleCollapsedGroups,
collapsedGroups collapsedGroups,
} = props; } = props;
const storeType = useIssueStoreType(); const storeType = useIssueStoreType();
@ -133,7 +133,6 @@ export const List: React.FC<IList> = observer((props) => {
} else { } else {
entities = orderedGroups; entities = orderedGroups;
} }
return ( return (
<div className="relative size-full flex flex-col"> <div className="relative size-full flex flex-col">
{groups && ( {groups && (

View file

@ -9,6 +9,7 @@ export interface IQuickActionProps {
handleRemoveFromView?: () => Promise<void>; handleRemoveFromView?: () => Promise<void>;
handleArchive?: () => Promise<void>; handleArchive?: () => Promise<void>;
handleRestore?: () => Promise<void>; handleRestore?: () => Promise<void>;
handleMoveToIssues?: () => Promise<void>;
customActionButton?: React.ReactElement; customActionButton?: React.ReactElement;
portalElement?: HTMLDivElement | null; portalElement?: HTMLDivElement | null;
readOnly?: boolean; readOnly?: boolean;

View file

@ -37,9 +37,7 @@ import { WithDisplayPropertiesHOC } from "./with-display-properties-HOC";
export interface IIssueProperties { export interface IIssueProperties {
issue: TIssue; issue: TIssue;
updateIssue: updateIssue: ((projectId: string | null, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
| ((projectId: string | null, issueId: string, data: Partial<TIssue>) => Promise<void>)
| undefined;
displayProperties: IIssueDisplayProperties | undefined; displayProperties: IIssueDisplayProperties | undefined;
isReadOnly: boolean; isReadOnly: boolean;
className: string; className: string;

View file

@ -4,3 +4,4 @@ export * from "./project-issue";
export * from "./archived-issue"; export * from "./archived-issue";
export * from "./draft-issue"; export * from "./draft-issue";
export * from "./all-issue"; export * from "./all-issue";
export * from "../../workspace-draft/quick-action";

View file

@ -50,7 +50,7 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
const { fetchModuleDetails } = useModule(); const { fetchModuleDetails } = useModule();
const { issues } = useIssues(storeType); const { issues } = useIssues(storeType);
const { issues: projectIssues } = useIssues(EIssuesStoreType.PROJECT); const { issues: projectIssues } = useIssues(EIssuesStoreType.PROJECT);
const { issues: draftIssues } = useIssues(EIssuesStoreType.DRAFT); const { issues: draftIssues } = useIssues(EIssuesStoreType.WORKSPACE_DRAFT);
const { fetchIssue } = useIssueDetail(); const { fetchIssue } = useIssueDetail();
const { handleCreateUpdatePropertyValues } = useIssueModal(); const { handleCreateUpdatePropertyValues } = useIssueModal();
// pathname // pathname
@ -151,10 +151,9 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
try { try {
let response; let response;
// if draft issue, use draft issue store to create issue // if draft issue, use draft issue store to create issue
if (is_draft_issue) { if (is_draft_issue) {
response = await draftIssues.createIssue(workspaceSlug.toString(), payload.project_id, payload); response = await draftIssues.createIssue(workspaceSlug.toString(), payload);
} }
// if cycle id in payload does not match the cycleId in url // if cycle id in payload does not match the cycleId in url
// or if the moduleIds in Payload does not match the moduleId in url // or if the moduleIds in Payload does not match the moduleId in url
@ -213,8 +212,8 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
payload: { ...response, state: "SUCCESS" }, payload: { ...response, state: "SUCCESS" },
path: pathname, path: pathname,
}); });
!createMore && handleClose(); if (!createMore) handleClose();
if (createMore) issueTitleRef && issueTitleRef?.current?.focus(); if (createMore && issueTitleRef) issueTitleRef?.current?.focus();
setDescription("<p></p>"); setDescription("<p></p>");
setChangesMade(null); setChangesMade(null);
return response; return response;
@ -237,9 +236,8 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
if (!workspaceSlug || !payload.project_id || !data?.id) return; if (!workspaceSlug || !payload.project_id || !data?.id) return;
try { try {
isDraft if (isDraft) await draftIssues.updateIssue(workspaceSlug.toString(), data.id, payload);
? await draftIssues.updateIssue(workspaceSlug.toString(), payload.project_id, data.id, payload) else if (updateIssue) await updateIssue(payload.project_id, data.id, payload);
: updateIssue && (await updateIssue(payload.project_id, data.id, payload));
// add other property values // add other property values
await handleCreateUpdatePropertyValues({ await handleCreateUpdatePropertyValues({
@ -260,6 +258,7 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
}); });
handleClose(); handleClose();
} catch (error) { } catch (error) {
console.error(error);
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: "Error!",
@ -314,7 +313,7 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
issueTitleRef={issueTitleRef} issueTitleRef={issueTitleRef}
onChange={handleFormChange} onChange={handleFormChange}
onClose={handleClose} onClose={handleClose}
onSubmit={handleFormSubmit} onSubmit={(payload) => handleFormSubmit(payload, isDraft)}
projectId={activeProjectId} projectId={activeProjectId}
isCreateMoreToggleEnabled={createMore} isCreateMoreToggleEnabled={createMore}
onCreateMoreToggleChange={handleCreateMoreToggleChange} onCreateMoreToggleChange={handleCreateMoreToggleChange}
@ -332,7 +331,7 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
onClose={() => handleClose(false)} onClose={() => handleClose(false)}
isCreateMoreToggleEnabled={createMore} isCreateMoreToggleEnabled={createMore}
onCreateMoreToggleChange={handleCreateMoreToggleChange} onCreateMoreToggleChange={handleCreateMoreToggleChange}
onSubmit={handleFormSubmit} onSubmit={(payload) => handleFormSubmit(payload, isDraft)}
projectId={activeProjectId} projectId={activeProjectId}
isDraft={isDraft} isDraft={isDraft}
/> />

View file

@ -16,7 +16,7 @@ import { isEmptyHtmlString } from "@/helpers/string.helper";
import { useIssueModal } from "@/hooks/context/use-issue-modal"; import { useIssueModal } from "@/hooks/context/use-issue-modal";
import { useEventTracker } from "@/hooks/store"; import { useEventTracker } from "@/hooks/store";
// services // services
import { IssueDraftService } from "@/services/issue"; import workspaceDraftService from "@/services/issue/workspace_draft.service";
// local components // local components
import { IssueFormRoot } from "./form"; import { IssueFormRoot } from "./form";
@ -33,8 +33,6 @@ export interface DraftIssueProps {
isDraft: boolean; isDraft: boolean;
} }
const issueDraftService = new IssueDraftService();
export const DraftIssueLayout: React.FC<DraftIssueProps> = observer((props) => { export const DraftIssueLayout: React.FC<DraftIssueProps> = observer((props) => {
const { const {
changesMade, changesMade,
@ -95,10 +93,11 @@ export const DraftIssueLayout: React.FC<DraftIssueProps> = observer((props) => {
const payload = { const payload = {
...changesMade, ...changesMade,
name: changesMade?.name && changesMade?.name?.trim() !== "" ? changesMade.name?.trim() : "Untitled", name: changesMade?.name && changesMade?.name?.trim() !== "" ? changesMade.name?.trim() : "Untitled",
project_id: projectId,
}; };
const response = await issueDraftService const response = await workspaceDraftService
.createDraftIssue(workspaceSlug.toString(), projectId.toString(), payload) .createIssue(workspaceSlug.toString(), payload)
.then((res) => { .then((res) => {
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,

View file

@ -266,7 +266,9 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
)} )}
<form onSubmit={handleSubmit((data) => handleFormSubmit(data))}> <form onSubmit={handleSubmit((data) => handleFormSubmit(data))}>
<div className="p-5"> <div className="p-5">
<h3 className="text-xl font-medium text-custom-text-200 pb-2">{data?.id ? "Update" : "Create new"} issue</h3> <h3 className="text-xl font-medium text-custom-text-200 pb-2">
{data?.id ? "Update" : isDraft ? "Create draft" : "Create new"} issue
</h3>
{/* Disable project selection if editing an issue */} {/* Disable project selection if editing an issue */}
<div className="flex items-center pt-2 pb-4 gap-x-1"> <div className="flex items-center pt-2 pb-4 gap-x-1">
<IssueProjectSelect <IssueProjectSelect
@ -397,31 +399,6 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
> >
Discard Discard
</Button> </Button>
{isDraft && (
<>
{data?.id ? (
<Button
variant="neutral-primary"
size="sm"
loading={isSubmitting}
onClick={handleSubmit((data) => handleFormSubmit({ ...data, is_draft: false }))}
tabIndex={getIndex("draft_button")}
>
{isSubmitting ? "Moving" : "Move from draft"}
</Button>
) : (
<Button
variant="neutral-primary"
size="sm"
loading={isSubmitting}
onClick={handleSubmit((data) => handleFormSubmit(data, true))}
tabIndex={getIndex("draft_button")}
>
{isSubmitting ? "Saving" : "Save as draft"}
</Button>
)}
</>
)}
<Button <Button
variant="primary" variant="primary"
type="submit" type="submit"
@ -430,7 +407,15 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
loading={isSubmitting} loading={isSubmitting}
tabIndex={isDraft ? getIndex("submit_button") : getIndex("draft_button")} tabIndex={isDraft ? getIndex("submit_button") : getIndex("draft_button")}
> >
{data?.id ? (isSubmitting ? "Updating" : "Update") : isSubmitting ? "Creating" : "Create"} {data?.id
? isSubmitting
? "Updating"
: "Update"
: isSubmitting
? "Creating"
: isDraft
? "Create draft issue"
: "Create"}
</Button> </Button>
</div> </div>
</div> </div>

View file

@ -0,0 +1,105 @@
"use client";
import { useEffect, useState } from "react";
// types
import { TWorkspaceDraftIssue } from "@plane/types";
// ui
import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui";
// constants
import { PROJECT_ERROR_MESSAGES } from "@/constants/project";
// hooks
import { useIssues, useProject, useUser, useUserPermissions } from "@/hooks/store";
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
type Props = {
isOpen: boolean;
handleClose: () => void;
dataId?: string | null | undefined;
data?: TWorkspaceDraftIssue;
isSubIssue?: boolean;
onSubmit?: () => Promise<void>;
};
export const WorkspaceDraftIssueDeleteIssueModal: React.FC<Props> = (props) => {
const { dataId, data, isOpen, handleClose, isSubIssue = false, onSubmit } = props;
// states
const [isDeleting, setIsDeleting] = useState(false);
// store hooks
const { issueMap } = useIssues();
const { getProjectById } = useProject();
const { allowPermissions } = useUserPermissions();
const { data: currentUser } = useUser();
// derived values
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
useEffect(() => {
setIsDeleting(false);
}, [isOpen]);
if (!dataId && !data) return null;
// derived values
const issue = data ? data : issueMap[dataId!];
const projectDetails = getProjectById(issue?.project_id);
const isIssueCreator = issue?.created_by === currentUser?.id;
const authorized = isIssueCreator || canPerformProjectAdminActions;
const onClose = () => {
setIsDeleting(false);
handleClose();
};
const handleIssueDelete = async () => {
setIsDeleting(true);
if (!authorized) {
setToast({
title: PROJECT_ERROR_MESSAGES.permissionError.title,
type: TOAST_TYPE.ERROR,
message: PROJECT_ERROR_MESSAGES.permissionError.message,
});
onClose();
return;
}
if (onSubmit)
await onSubmit()
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: `${isSubIssue ? "Sub-issue" : "Issue"} deleted successfully`,
});
onClose();
})
.catch((errors) => {
const isPermissionError = errors?.error === "Only admin or creator can delete the issue";
const currentError = isPermissionError
? PROJECT_ERROR_MESSAGES.permissionError
: PROJECT_ERROR_MESSAGES.issueDeleteError;
setToast({
title: currentError.title,
type: TOAST_TYPE.ERROR,
message: currentError.message,
});
})
.finally(() => onClose());
};
return (
<AlertModalCore
handleClose={onClose}
handleSubmit={handleIssueDelete}
isSubmitting={isDeleting}
isOpen={isOpen}
title="Delete issue"
content={
<>
Are you sure you want to delete issue{" "}
<span className="break-words font-medium text-custom-text-100">{projectDetails?.identifier}</span>
{""}? All of the data related to the issue will be permanently removed. This action cannot be undone.
</>
}
/>
);
};

View file

@ -0,0 +1,127 @@
"use client";
import React, { FC, useRef } from "react";
import { observer } from "mobx-react";
// ui
import { Row, Tooltip } from "@plane/ui";
// helper
import { cn } from "@/helpers/common.helper";
// hooks
import { useAppTheme, useProject, useWorkspaceDraftIssues } from "@/hooks/store";
// plane-web components
import { IdentifierText } from "@/plane-web/components/issues";
// local components
import { WorkspaceDraftIssueQuickActions } from "../issue-layouts";
import { DraftIssueProperties } from "./draft-issue-properties";
type Props = {
workspaceSlug: string;
issueId: string;
};
export const DraftIssueBlock: FC<Props> = observer((props) => {
// props
const { workspaceSlug, issueId } = props;
// hooks
const { getIssueById, updateIssue, deleteIssue, moveIssue } = useWorkspaceDraftIssues();
const { sidebarCollapsed: isSidebarCollapsed } = useAppTheme();
const { getProjectIdentifierById } = useProject();
// ref
const issueRef = useRef<HTMLDivElement | null>(null);
// derived values
const issue = getIssueById(issueId);
const projectIdentifier = (issue && issue.project_id && getProjectIdentifierById(issue.project_id)) || undefined;
if (!issue || !projectIdentifier) return null;
return (
<div id={`issue-${issue.id}`} className=" relative border-b border-b-custom-border-200 w-full cursor-pointer">
<Row
ref={issueRef}
className={cn(
"group/list-block min-h-11 relative flex flex-col gap-3 bg-custom-background-100 hover:bg-custom-background-90 py-3 text-sm transition-colors border border-transparent last:border-b-transparent",
{
"md:flex-row md:items-center": isSidebarCollapsed,
"lg:flex-row lg:items-center": !isSidebarCollapsed,
}
)}
>
<div className="flex w-full truncate">
<div className="flex flex-grow items-center gap-0.5 truncate">
<div className="flex items-center gap-1">
{/* {displayProperties && (displayProperties.key || displayProperties.issue_type) && ( */}
<div className="flex-shrink-0">
{issue.project_id && (
<div className="flex items-center space-x-2">
<IdentifierText
identifier={projectIdentifier}
enableClickToCopyIdentifier
textContainerClassName="text-xs font-medium text-custom-text-300"
/>
</div>
)}
</div>
{/* )} */}
{/* sub-issues chevron */}
<div className="size-4 grid place-items-center flex-shrink-0" />
</div>
<Tooltip
tooltipContent={issue.name}
// isMobile={isMobile}
position="top-left"
// disabled={isCurrentBlockDragging}
renderByDefault={false}
>
<p className="w-full truncate cursor-pointer text-sm text-custom-text-100">{issue.name}</p>
</Tooltip>
</div>
{/* quick actions */}
<div
className={cn("block border border-custom-border-300 rounded", {
"md:hidden": isSidebarCollapsed,
"lg:hidden": !isSidebarCollapsed,
})}
>
<WorkspaceDraftIssueQuickActions
parentRef={issueRef}
issue={issue}
handleUpdate={async (data) => updateIssue(workspaceSlug, issueId, data)}
handleDelete={async () => deleteIssue(workspaceSlug, issueId)}
handleMoveToIssues={async () => moveIssue(workspaceSlug, issueId, issue)}
/>
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<DraftIssueProperties
className={`relative flex flex-wrap ${isSidebarCollapsed ? "md:flex-grow md:flex-shrink-0" : "lg:flex-grow lg:flex-shrink-0"} items-center gap-2 whitespace-nowrap`}
issue={issue}
updateIssue={async (projectId, issueId, data) => {
await updateIssue(workspaceSlug, issueId, data);
}}
activeLayout="List"
/>
<div
className={cn("hidden", {
"md:flex": isSidebarCollapsed,
"lg:flex": !isSidebarCollapsed,
})}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<WorkspaceDraftIssueQuickActions
parentRef={issueRef}
issue={issue}
handleUpdate={async (data) => updateIssue(workspaceSlug, issueId, data)}
handleDelete={async () => deleteIssue(workspaceSlug, issueId)}
handleMoveToIssues={async () => moveIssue(workspaceSlug, issueId, issue)}
/>
</div>
</div>
</Row>
</div>
);
});

View file

@ -0,0 +1,299 @@
"use client";
import { useCallback, useMemo } from "react";
import xor from "lodash/xor";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
// icons
import { CalendarCheck2, CalendarClock } from "lucide-react";
// types
import { TIssue, TIssuePriorities, TWorkspaceDraftIssue } from "@plane/types";
// components
import {
DateDropdown,
EstimateDropdown,
PriorityDropdown,
MemberDropdown,
ModuleDropdown,
CycleDropdown,
StateDropdown,
} from "@/components/dropdowns";
// constants
import { ISSUE_UPDATED } from "@/constants/event-tracker";
// helpers
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
// hooks
import {
useEventTracker,
useLabel,
useProjectState,
useProject,
useProjectEstimates,
useWorkspaceDraftIssues,
} from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// local components
import { IssuePropertyLabels } from "../issue-layouts";
export interface IIssueProperties {
issue: TWorkspaceDraftIssue;
updateIssue:
| ((projectId: string | null, issueId: string, data: Partial<TWorkspaceDraftIssue>) => Promise<void>)
| undefined;
className: string;
activeLayout: string;
}
export const DraftIssueProperties: React.FC<IIssueProperties> = observer((props) => {
const { issue, updateIssue, activeLayout, className } = props;
// store hooks
const { getProjectById } = useProject();
const { labelMap } = useLabel();
const { captureIssueEvent } = useEventTracker();
const { addCycleToIssue, addModulesToIssue } = useWorkspaceDraftIssues();
const { areEstimateEnabledByProjectId } = useProjectEstimates();
const { getStateById } = useProjectState();
const { isMobile } = usePlatformOS();
const projectDetails = getProjectById(issue.project_id);
// router
const { workspaceSlug } = useParams();
const pathname = usePathname();
const currentLayout = `${activeLayout} layout`;
// derived values
const stateDetails = getStateById(issue.state_id);
const issueOperations = useMemo(
() => ({
addModulesToIssue: async (moduleIds: string[]) => {
if (!workspaceSlug || !issue.id) return;
await addModulesToIssue(workspaceSlug.toString(), issue.id, moduleIds);
},
removeModulesFromIssue: async (moduleIds: string[]) => {
if (!workspaceSlug || !issue.id) return;
await addModulesToIssue(workspaceSlug.toString(), issue.id, moduleIds);
},
addIssueToCycle: async (cycleId: string) => {
if (!workspaceSlug || !issue.id) return;
await addCycleToIssue(workspaceSlug.toString(), issue.id, cycleId);
},
removeIssueFromCycle: async () => {
if (!workspaceSlug || !issue.id) return;
// TODO: To be checked
await addCycleToIssue(workspaceSlug.toString(), issue.id, "");
},
}),
[workspaceSlug, issue, addCycleToIssue, addModulesToIssue]
);
const handleState = (stateId: string) =>
issue?.project_id && updateIssue && updateIssue(issue.project_id, issue.id, { state_id: stateId });
const handlePriority = (value: TIssuePriorities) =>
issue?.project_id && updateIssue && updateIssue(issue.project_id, issue.id, { priority: value });
const handleLabel = (ids: string[]) =>
issue?.project_id && updateIssue && updateIssue(issue.project_id, issue.id, { label_ids: ids });
const handleAssignee = (ids: string[]) =>
issue?.project_id && updateIssue && updateIssue(issue.project_id, issue.id, { assignee_ids: ids });
const handleModule = useCallback(
(moduleIds: string[] | null) => {
if (!issue || !issue.module_ids || !moduleIds) return;
const updatedModuleIds = xor(issue.module_ids, moduleIds);
const modulesToAdd: string[] = [];
const modulesToRemove: string[] = [];
for (const moduleId of updatedModuleIds)
if (issue.module_ids.includes(moduleId)) modulesToRemove.push(moduleId);
else modulesToAdd.push(moduleId);
if (modulesToAdd.length > 0) issueOperations.addModulesToIssue(modulesToAdd);
if (modulesToRemove.length > 0) issueOperations.removeModulesFromIssue(modulesToRemove);
},
[issueOperations, currentLayout, pathname, issue]
);
const handleCycle = useCallback(
(cycleId: string | null) => {
if (!issue || issue.cycle_id === cycleId) return;
if (cycleId) issueOperations.addIssueToCycle?.(cycleId);
else issueOperations.removeIssueFromCycle?.();
},
[issue, issueOperations, currentLayout, pathname]
);
const handleStartDate = (date: Date | null) =>
issue?.project_id &&
updateIssue &&
updateIssue(issue.project_id, issue.id, {
start_date: date ? (renderFormattedPayloadDate(date) ?? undefined) : undefined,
});
const handleTargetDate = (date: Date | null) =>
issue?.project_id &&
updateIssue &&
updateIssue(issue.project_id, issue.id, {
target_date: date ? (renderFormattedPayloadDate(date) ?? undefined) : undefined,
});
const handleEstimate = (value: string | undefined) =>
issue?.project_id && updateIssue && updateIssue(issue.project_id, issue.id, { estimate_point: value });
if (!issue.project_id) return null;
const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || [];
const minDate = getDate(issue.start_date);
minDate?.setDate(minDate.getDate());
const maxDate = getDate(issue.target_date);
maxDate?.setDate(maxDate.getDate());
const handleEventPropagation = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
};
return (
<div className={className}>
{/* basic properties */}
{/* state */}
<div className="h-5" onClick={handleEventPropagation}>
<StateDropdown
buttonContainerClassName="truncate max-w-40"
value={issue.state_id}
onChange={handleState}
projectId={issue.project_id}
buttonVariant="border-with-text"
renderByDefault={isMobile}
showTooltip
/>
</div>
{/* priority */}
<div className="h-5" onClick={handleEventPropagation}>
<PriorityDropdown
value={issue?.priority}
onChange={handlePriority}
buttonVariant="border-without-text"
buttonClassName="border"
renderByDefault={isMobile}
showTooltip
/>
</div>
{/* label */}
<div className="h-5" onClick={handleEventPropagation}>
<IssuePropertyLabels
projectId={issue?.project_id || null}
value={issue?.label_ids || null}
defaultOptions={defaultLabelOptions}
onChange={handleLabel}
renderByDefault={isMobile}
hideDropdownArrow
/>
</div>
{/* start date */}
<div className="h-5" onClick={handleEventPropagation}>
<DateDropdown
value={issue.start_date ?? null}
onChange={handleStartDate}
maxDate={maxDate}
placeholder="Start date"
icon={<CalendarClock className="h-3 w-3 flex-shrink-0" />}
buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"}
optionsClassName="z-10"
renderByDefault={isMobile}
showTooltip
/>
</div>
{/* target/due date */}
<div className="h-5" onClick={handleEventPropagation}>
<DateDropdown
value={issue?.target_date ?? null}
onChange={handleTargetDate}
minDate={minDate}
placeholder="Due date"
icon={<CalendarCheck2 className="h-3 w-3 flex-shrink-0" />}
buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"}
buttonClassName={
shouldHighlightIssueDueDate(issue?.target_date || null, stateDetails?.group) ? "text-red-500" : ""
}
clearIconClassName="!text-custom-text-100"
optionsClassName="z-10"
renderByDefault={isMobile}
showTooltip
/>
</div>
{/* assignee */}
<div className="h-5" onClick={handleEventPropagation}>
<MemberDropdown
projectId={issue?.project_id}
value={issue?.assignee_ids}
onChange={handleAssignee}
multiple
buttonVariant={issue.assignee_ids?.length > 0 ? "transparent-without-text" : "border-without-text"}
buttonClassName={issue.assignee_ids?.length > 0 ? "hover:bg-transparent px-0" : ""}
showTooltip={issue?.assignee_ids?.length === 0}
placeholder="Assignees"
optionsClassName="z-10"
tooltipContent=""
renderByDefault={isMobile}
/>
</div>
{/* modules */}
{projectDetails?.module_view && (
<div className="h-5" onClick={handleEventPropagation}>
<ModuleDropdown
buttonContainerClassName="truncate max-w-40"
projectId={issue?.project_id}
value={issue?.module_ids ?? []}
onChange={handleModule}
renderByDefault={isMobile}
multiple
buttonVariant="border-with-text"
showCount
showTooltip
/>
</div>
)}
{/* cycles */}
{projectDetails?.cycle_view && (
<div className="h-5" onClick={handleEventPropagation}>
<CycleDropdown
buttonContainerClassName="truncate max-w-40"
projectId={issue?.project_id}
value={issue?.cycle_id || null}
onChange={handleCycle}
buttonVariant="border-with-text"
renderByDefault={isMobile}
showTooltip
/>
</div>
)}
{/* estimates */}
{issue.project_id && areEstimateEnabledByProjectId(issue.project_id?.toString()) && (
<div className="h-5" onClick={handleEventPropagation}>
<EstimateDropdown
value={issue.estimate_point ?? undefined}
onChange={handleEstimate}
projectId={issue.project_id}
buttonVariant="border-with-text"
renderByDefault={isMobile}
showTooltip
/>
</div>
)}
</div>
);
});

View file

@ -0,0 +1,33 @@
"use client";
import { FC, Fragment, useState } from "react";
// components
import { EmptyState } from "@/components/empty-state";
import { CreateUpdateIssueModal } from "@/components/issues";
// constants
import { EmptyStateType } from "@/constants/empty-state";
import { EIssuesStoreType } from "@/constants/issue";
export const WorkspaceDraftEmptyState: FC = () => {
// state
const [isDraftIssueModalOpen, setIsDraftIssueModalOpen] = useState(false);
return (
<Fragment>
<CreateUpdateIssueModal
isOpen={isDraftIssueModalOpen}
storeType={EIssuesStoreType.WORKSPACE_DRAFT}
onClose={() => setIsDraftIssueModalOpen(false)}
isDraft
/>
<div className="relative h-full w-full overflow-y-auto">
<EmptyState
type={EmptyStateType.WORKSPACE_DRAFT_ISSUES}
primaryButtonOnClick={() => {
setIsDraftIssueModalOpen(true);
}}
/>
</div>
</Fragment>
);
};

View file

@ -0,0 +1,4 @@
export * from "./draft-issue-block";
export * from "./draft-issue-properties";
export * from "./delete-modal";
export * from "./root";

View file

@ -0,0 +1,20 @@
"use client";
import { FC } from "react";
// components
import { ListLoaderItemRow } from "@/components/ui";
type TWorkspaceDraftIssuesLoader = {
items?: number;
};
export const WorkspaceDraftIssuesLoader: FC<TWorkspaceDraftIssuesLoader> = (props) => {
const { items = 14 } = props;
return (
<div className="relative h-full w-full">
{[...Array(items)].map((_, index) => (
<ListLoaderItemRow key={index} />
))}
</div>
);
};

View file

@ -0,0 +1,160 @@
"use client";
import { useState } from "react";
import { Placement } from "@popperjs/core";
import omit from "lodash/omit";
import { observer } from "mobx-react";
// icons
import { Copy, Pencil, SquareStackIcon, Trash2 } from "lucide-react";
// types
import { TWorkspaceDraftIssue } from "@plane/types";
// ui
import { ContextMenu, CustomMenu, TContextMenuItem } from "@plane/ui";
// components
import { CreateUpdateIssueModal } from "@/components/issues";
// constant
import { EIssuesStoreType } from "@/constants/issue";
// helpers
import { cn } from "@/helpers/common.helper";
// local components
import { WorkspaceDraftIssueDeleteIssueModal } from "./delete-modal";
export interface IQuickActionProps {
issue: TWorkspaceDraftIssue;
handleDelete: () => Promise<void>;
handleUpdate: (payload: Partial<TWorkspaceDraftIssue>) => Promise<TWorkspaceDraftIssue | undefined>;
handleMoveToIssues?: () => Promise<void>;
customActionButton?: React.ReactElement;
portalElement?: HTMLDivElement | null;
placements?: Placement;
parentRef: React.RefObject<HTMLElement>;
}
export const WorkspaceDraftIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
const {
issue,
handleDelete,
handleUpdate,
handleMoveToIssues,
customActionButton,
portalElement,
placements = "bottom-end",
parentRef,
} = props;
// states
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
const [issueToEdit, setIssueToEdit] = useState<TWorkspaceDraftIssue | undefined>(undefined);
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const duplicateIssuePayload = omit(
{
...issue,
name: `${issue.name} (copy)`,
is_draft: true,
},
["id"]
);
const MENU_ITEMS: TContextMenuItem[] = [
{
key: "edit",
title: "Edit",
icon: Pencil,
action: () => {
setIssueToEdit(issue);
setCreateUpdateIssueModal(true);
},
},
{
key: "make-a-copy",
title: "Make a copy",
icon: Copy,
action: () => {
setCreateUpdateIssueModal(true);
},
},
{
key: "move-to-issues",
title: "Move to issues",
icon: SquareStackIcon,
action: () => handleMoveToIssues && handleMoveToIssues(),
},
{
key: "delete",
title: "Delete",
icon: Trash2,
action: () => {
setDeleteIssueModal(true);
},
},
];
return (
<>
<WorkspaceDraftIssueDeleteIssueModal
data={issue}
isOpen={deleteIssueModal}
handleClose={() => setDeleteIssueModal(false)}
onSubmit={handleDelete}
/>
<CreateUpdateIssueModal
isOpen={createUpdateIssueModal}
onClose={() => {
setCreateUpdateIssueModal(false);
setIssueToEdit(undefined);
}}
data={issueToEdit ?? duplicateIssuePayload}
onSubmit={async (data) => {
if (issueToEdit && handleUpdate) await handleUpdate(data as TWorkspaceDraftIssue);
}}
storeType={EIssuesStoreType.WORKSPACE_DRAFT}
fetchIssueDetails={false}
isDraft
/>
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
<CustomMenu
ellipsis
customButton={customActionButton}
portalElement={portalElement}
placement={placements}
menuItemsClassName="z-[14]"
maxHeight="lg"
useCaptureForOutsideClick
closeOnSelect
>
{MENU_ITEMS.map((item) => (
<CustomMenu.MenuItem
key={item.key}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
item.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-custom-text-400": item.disabled,
},
item.className
)}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("text-custom-text-300 whitespace-pre-line", {
"text-custom-text-400": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
</>
);
});

View file

@ -0,0 +1,70 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// constants
import { EDraftIssuePaginationType } from "@/constants/workspace-drafts";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useWorkspaceDraftIssues } from "@/hooks/store";
// components
import { DraftIssueBlock } from "./draft-issue-block";
import { WorkspaceDraftEmptyState } from "./empty-state";
import { WorkspaceDraftIssuesLoader } from "./loader";
type TWorkspaceDraftIssuesRoot = {
workspaceSlug: string;
};
export const WorkspaceDraftIssuesRoot: FC<TWorkspaceDraftIssuesRoot> = observer((props) => {
const { workspaceSlug } = props;
// hooks
const { loader, paginationInfo, fetchIssues, issuesMap, issueIds } = useWorkspaceDraftIssues();
// fetching issues
useSWR(
workspaceSlug && issueIds.length <= 0 ? `WORKSPACE_DRAFT_ISSUES_${workspaceSlug}` : null,
workspaceSlug && issueIds.length <= 0 ? async () => await fetchIssues(workspaceSlug, "init-loader") : null
);
// handle nest issues
const handleNextIssues = async () => {
if (!paginationInfo?.next_page_results) return;
await fetchIssues(workspaceSlug, "pagination", EDraftIssuePaginationType.NEXT);
};
if (loader === "init-loader" && issueIds.length <= 0) {
return <WorkspaceDraftIssuesLoader items={14} />;
}
if (loader === "empty-state" && issueIds.length <= 0) return <WorkspaceDraftEmptyState />;
return (
<div className="relative">
<div className="relative">
{issueIds.map((issueId: string) => (
<DraftIssueBlock key={issueId} workspaceSlug={workspaceSlug} issueId={issueId} />
))}
</div>
{loader === "pagination" && issueIds.length >= 0 ? (
<WorkspaceDraftIssuesLoader items={1} />
) : (
<div
className={cn(
"h-11 pl-6 p-3 text-sm font-medium bg-custom-background-100 border-b border-custom-border-200 transition-all",
{
"text-custom-primary-100 hover:text-custom-primary-200 cursor-pointer underline-offset-2 hover:underline":
paginationInfo?.next_page_results,
"text-custom-text-300 cursor-not-allowed": !paginationInfo?.next_page_results,
}
)}
onClick={handleNextIssues}
>
Load More &darr;
</div>
)}
</div>
);
});

View file

@ -428,7 +428,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} )}
{isAuthorized && ( {/* {isAuthorized && (
<CustomMenu.MenuItem> <CustomMenu.MenuItem>
<Link href={`/${workspaceSlug}/projects/${project?.id}/draft-issues/`}> <Link href={`/${workspaceSlug}/projects/${project?.id}/draft-issues/`}>
<div className="flex items-center justify-start gap-2"> <div className="flex items-center justify-start gap-2">
@ -437,7 +437,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
</div> </div>
</Link> </Link>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} )} */}
<CustomMenu.MenuItem onClick={handleCopyText}> <CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3.5 w-3.5 stroke-[1.5]" /> <LinkIcon className="h-3.5 w-3.5 stroke-[1.5]" />

View file

@ -102,7 +102,7 @@ export const SidebarQuickActions = observer(() => {
<PenSquare className="size-4" /> <PenSquare className="size-4" />
{!isSidebarCollapsed && <span className="text-sm font-medium">New issue</span>} {!isSidebarCollapsed && <span className="text-sm font-medium">New issue</span>}
</button> </button>
{!disabled && workspaceDraftIssue && ( {/* {!disabled && workspaceDraftIssue && (
<> <>
{!isSidebarCollapsed && ( {!isSidebarCollapsed && (
<button type="button" className="grid place-items-center"> <button type="button" className="grid place-items-center">
@ -127,7 +127,7 @@ export const SidebarQuickActions = observer(() => {
</div> </div>
)} )}
</> </>
)} )} */}
</div> </div>
<button <button
className={cn( className={cn(

View file

@ -2,7 +2,7 @@
import { linearGradientDef } from "@nivo/core"; import { linearGradientDef } from "@nivo/core";
// icons // icons
import { BarChart2, Briefcase, Home, Inbox, Layers } from "lucide-react"; import { BarChart2, Briefcase, Home, Inbox, Layers, PenSquare } from "lucide-react";
// types // types
import { TIssuesListTypes, TStateGroups } from "@plane/types"; import { TIssuesListTypes, TStateGroups } from "@plane/types";
// ui // ui
@ -329,4 +329,12 @@ export const SIDEBAR_USER_MENU_ITEMS: {
highlight: (pathname: string, baseUrl: string) => pathname.includes(`${baseUrl}/notifications/`), highlight: (pathname: string, baseUrl: string) => pathname.includes(`${baseUrl}/notifications/`),
Icon: Inbox, Icon: Inbox,
}, },
{
key: "drafts",
label: "Drafts",
href: `/drafts`,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
highlight: (pathname: string, baseUrl: string) => pathname.includes(`${baseUrl}/drafts/`),
Icon: PenSquare,
},
]; ];

View file

@ -105,6 +105,8 @@ export enum EmptyStateType {
INBOX_SIDEBAR_CLOSED_TAB = "inbox-sidebar-closed-tab", INBOX_SIDEBAR_CLOSED_TAB = "inbox-sidebar-closed-tab",
INBOX_SIDEBAR_FILTER_EMPTY_STATE = "inbox-sidebar-filter-empty-state", INBOX_SIDEBAR_FILTER_EMPTY_STATE = "inbox-sidebar-filter-empty-state",
INBOX_DETAIL_EMPTY_STATE = "inbox-detail-empty-state", INBOX_DETAIL_EMPTY_STATE = "inbox-detail-empty-state",
WORKSPACE_DRAFT_ISSUES = "workspace-draft-issues",
} }
const emptyStateDetails = { const emptyStateDetails = {
@ -757,6 +759,17 @@ const emptyStateDetails = {
title: "Select an issue to view its details.", title: "Select an issue to view its details.",
path: "/empty-state/intake/issue-detail", path: "/empty-state/intake/issue-detail",
}, },
[EmptyStateType.WORKSPACE_DRAFT_ISSUES]: {
key: EmptyStateType.WORKSPACE_DRAFT_ISSUES,
title: "No Draft Issues Yet",
description: "There are no draft issues in your workspace right now. Begin by adding your first one.",
path: "/empty-state/workspace-draft/issue",
primaryButton: {
text: "Create draft issue",
},
accessType: "workspace",
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
},
} as const; } as const;
export const EMPTY_STATE_DETAILS: Record<EmptyStateType, EmptyStateDetails> = emptyStateDetails; export const EMPTY_STATE_DETAILS: Record<EmptyStateType, EmptyStateDetails> = emptyStateDetails;

View file

@ -30,6 +30,7 @@ export enum EIssuesStoreType {
ARCHIVED = "ARCHIVED", ARCHIVED = "ARCHIVED",
DRAFT = "DRAFT", DRAFT = "DRAFT",
DEFAULT = "DEFAULT", DEFAULT = "DEFAULT",
WORKSPACE_DRAFT = "WORKSPACE_DRAFT",
} }
export enum EIssueLayoutTypes { export enum EIssueLayoutTypes {

View file

@ -0,0 +1,6 @@
export enum EDraftIssuePaginationType {
INIT = "INIT",
NEXT = "NEXT",
PREV = "PREV",
CURRENT = "CURRENT",
}

View file

@ -31,3 +31,4 @@ export * from "./use-webhook";
export * from "./use-workspace"; export * from "./use-workspace";
export * from "./user"; export * from "./user";
export * from "./use-transient"; export * from "./use-transient";
export * from "./workspace-draft";

View file

@ -13,6 +13,7 @@ import { IProfileIssues, IProfileIssuesFilter } from "@/store/issue/profile";
import { IProjectIssues, IProjectIssuesFilter } from "@/store/issue/project"; import { IProjectIssues, IProjectIssuesFilter } from "@/store/issue/project";
import { IProjectViewIssues, IProjectViewIssuesFilter } from "@/store/issue/project-views"; import { IProjectViewIssues, IProjectViewIssuesFilter } from "@/store/issue/project-views";
import { IWorkspaceIssues, IWorkspaceIssuesFilter } from "@/store/issue/workspace"; import { IWorkspaceIssues, IWorkspaceIssuesFilter } from "@/store/issue/workspace";
import { IWorkspaceDraftIssues, IWorkspaceDraftIssuesFilter } from "@/store/issue/workspace-draft";
// constants // constants
type defaultIssueStore = { type defaultIssueStore = {
@ -24,6 +25,10 @@ export type TStoreIssues = {
issues: IWorkspaceIssues; issues: IWorkspaceIssues;
issuesFilter: IWorkspaceIssuesFilter; issuesFilter: IWorkspaceIssuesFilter;
}; };
[EIssuesStoreType.WORKSPACE_DRAFT]: defaultIssueStore & {
issues: IWorkspaceDraftIssues;
issuesFilter: IWorkspaceDraftIssuesFilter;
};
[EIssuesStoreType.PROFILE]: defaultIssueStore & { [EIssuesStoreType.PROFILE]: defaultIssueStore & {
issues: IProfileIssues; issues: IProfileIssues;
issuesFilter: IProfileIssuesFilter; issuesFilter: IProfileIssuesFilter;
@ -72,6 +77,16 @@ export const useIssues = <T extends EIssuesStoreType>(storeType?: T): TStoreIssu
issues: context.issue.workspaceIssues, issues: context.issue.workspaceIssues,
issuesFilter: context.issue.workspaceIssuesFilter, issuesFilter: context.issue.workspaceIssuesFilter,
}) as TStoreIssues[T]; }) as TStoreIssues[T];
case EIssuesStoreType.WORKSPACE_DRAFT:
return merge(defaultStore, {
issues: context.issue.workspaceDraftIssues,
issuesFilter: context.issue.workspaceDraftIssuesFilter,
}) as TStoreIssues[T];
case EIssuesStoreType.WORKSPACE_DRAFT:
return merge(defaultStore, {
issues: context.issue.workspaceDraftIssues,
issuesFilter: context.issue.workspaceDraftIssuesFilter,
}) as TStoreIssues[T];
case EIssuesStoreType.PROFILE: case EIssuesStoreType.PROFILE:
return merge(defaultStore, { return merge(defaultStore, {
issues: context.issue.profileIssues, issues: context.issue.profileIssues,

View file

@ -0,0 +1,2 @@
export * from "./use-workspace-draft-issue";
export * from "./use-workspace-draft-issue-filters";

View file

@ -0,0 +1,12 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import { IWorkspaceDraftIssues } from "@/store/issue/workspace-draft";
export const useWorkspaceDraftIssueFilters = (): IWorkspaceDraftIssues => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useWorkspaceDraftIssueFilters must be used within StoreProvider");
return context.issue.workspaceDraftIssues;
};

View file

@ -0,0 +1,12 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import { IWorkspaceDraftIssues } from "@/store/issue/workspace-draft";
export const useWorkspaceDraftIssues = (): IWorkspaceDraftIssues => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useWorkspaceDraftIssues must be used within StoreProvider");
return context.issue.workspaceDraftIssues;
};

View file

@ -45,6 +45,7 @@ export const useIssuesActions = (storeType: EIssuesStoreType): IssueActions => {
const profileIssueActions = useProfileIssueActions(); const profileIssueActions = useProfileIssueActions();
const draftIssueActions = useDraftIssueActions(); const draftIssueActions = useDraftIssueActions();
const archivedIssueActions = useArchivedIssueActions(); const archivedIssueActions = useArchivedIssueActions();
const workspaceDraftIssueActions = useWorkspaceDraftIssueActions();
switch (storeType) { switch (storeType) {
case EIssuesStoreType.PROJECT_VIEW: case EIssuesStoreType.PROJECT_VIEW:
@ -61,6 +62,8 @@ export const useIssuesActions = (storeType: EIssuesStoreType): IssueActions => {
return moduleIssueActions; return moduleIssueActions;
case EIssuesStoreType.GLOBAL: case EIssuesStoreType.GLOBAL:
return globalIssueActions; return globalIssueActions;
case EIssuesStoreType.WORKSPACE_DRAFT:
return workspaceDraftIssueActions;
case EIssuesStoreType.PROJECT: case EIssuesStoreType.PROJECT:
default: default:
return projectIssueActions; return projectIssueActions;
@ -737,3 +740,80 @@ const useGlobalIssueActions = () => {
[createIssue, updateIssue, removeIssue, updateFilters] [createIssue, updateIssue, removeIssue, updateFilters]
); );
}; };
const useWorkspaceDraftIssueActions = () => {
// router
const { workspaceSlug: routerWorkspaceSlug, globalViewId: routerGlobalViewId } = useParams();
const workspaceSlug = routerWorkspaceSlug?.toString();
const globalViewId = routerGlobalViewId?.toString();
// store hooks
const { issues, issuesFilter } = useIssues(EIssuesStoreType.WORKSPACE_DRAFT);
const fetchIssues = useCallback(
async (loadType: TLoader, options: IssuePaginationOptions) => {
if (!workspaceSlug) return;
return issues.fetchIssues(workspaceSlug.toString(), loadType, options);
},
[workspaceSlug, issues]
);
const fetchNextIssues = useCallback(async () => {
if (!workspaceSlug) return;
return issues.fetchNextIssues(workspaceSlug.toString());
}, [workspaceSlug, issues]);
const createIssue = useCallback(
async (projectId: string | undefined | null, data: Partial<TIssue>) => {
if (!workspaceSlug || !projectId) return;
return await issues.createWorkspaceDraftIssue(workspaceSlug, data);
},
[issues, workspaceSlug]
);
const updateIssue = useCallback(
async (projectId: string | undefined | null, issueId: string, data: Partial<TIssue>) => {
if (!workspaceSlug || !projectId) return;
return await issues.updateWorkspaceDraftIssue(workspaceSlug, issueId, data);
},
[issues, workspaceSlug]
);
const removeIssue = useCallback(
async (projectId: string | undefined | null, issueId: string) => {
if (!workspaceSlug || !projectId) return;
return await issues.deleteWorkspaceDraftIssue(workspaceSlug, issueId);
},
[issues, workspaceSlug]
);
const moveToIssue = useCallback(
async (workspaceSlug: string, issueId: string, data: Partial<TIssue>) => {
if (!workspaceSlug || !issueId || !data) return;
return await issues.moveToIssues(workspaceSlug, issueId, data);
},
[issues]
);
const updateFilters = useCallback(
async (
projectId: string,
filterType: EIssueFilterType,
filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters
) => {
filters = filters as IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties;
if (!globalViewId || !workspaceSlug) return;
return await issuesFilter.updateFilters(workspaceSlug, filterType, filters);
},
[globalViewId, workspaceSlug, issuesFilter]
);
return useMemo(
() => ({
fetchIssues,
fetchNextIssues,
createIssue,
updateIssue,
removeIssue,
updateFilters,
moveToIssue,
}),
[fetchIssues, fetchNextIssues, createIssue, updateIssue, removeIssue, updateFilters, moveToIssue]
);
};

View file

@ -7,3 +7,4 @@ export * from "./issue_attachment.service";
export * from "./issue_activity.service"; export * from "./issue_activity.service";
export * from "./issue_comment.service"; export * from "./issue_comment.service";
export * from "./issue_relation.service"; export * from "./issue_relation.service";
export * from "./workspace_draft.service";

View file

@ -0,0 +1,73 @@
import { TWorkspaceDraftIssue, TWorkspaceDraftPaginationInfo } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
export class WorkspaceDraftService extends APIService {
constructor() {
super(API_BASE_URL);
}
async getIssues(
workspaceSlug: string,
query: object = {}
): Promise<TWorkspaceDraftPaginationInfo<TWorkspaceDraftIssue> | undefined> {
return this.get(`/api/workspaces/${workspaceSlug}/draft-issues/`, { params: { ...query } })
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getIssueById(workspaceSlug: string, issueId: string): Promise<TWorkspaceDraftIssue | undefined> {
return this.get(`/api/workspaces/${workspaceSlug}/draft-issues/${issueId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async createIssue(
workspaceSlug: string,
payload: Partial<TWorkspaceDraftIssue>
): Promise<TWorkspaceDraftIssue | undefined> {
return this.post(`/api/workspaces/${workspaceSlug}/draft-issues/`, payload)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async updateIssue(
workspaceSlug: string,
issueId: string,
payload: Partial<TWorkspaceDraftIssue>
): Promise<TWorkspaceDraftIssue | undefined> {
return this.patch(`/api/workspaces/${workspaceSlug}/draft-issues/${issueId}/`, payload)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async deleteIssue(workspaceSlug: string, issueId: string): Promise<void> {
return this.delete(`/api/workspaces/${workspaceSlug}/draft-issues/${issueId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async moveIssue(workspaceSlug: string, issueId: string, payload: Partial<TWorkspaceDraftIssue>): Promise<void> {
return this.post(`/api/workspaces/${workspaceSlug}/draft-to-issue/${issueId}/`, payload)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
}
const workspaceDraftService = new WorkspaceDraftService();
export default workspaceDraftService;

View file

@ -23,7 +23,8 @@ import {
IProjectViewIssues, IProjectViewIssues,
ProjectViewIssues, ProjectViewIssues,
} from "./project-views"; } from "./project-views";
import { IWorkspaceIssuesFilter, WorkspaceIssuesFilter, IWorkspaceIssues, WorkspaceIssues } from "./workspace"; import { WorkspaceIssuesFilter, IWorkspaceIssues, WorkspaceIssues, IWorkspaceIssuesFilter } from "./workspace";
import { IWorkspaceDraftIssues, IWorkspaceDraftIssuesFilter, WorkspaceDraftIssues, WorkspaceDraftIssuesFilter } from "./workspace-draft";
export interface IIssueRootStore { export interface IIssueRootStore {
currentUserId: string | undefined; currentUserId: string | undefined;
@ -55,6 +56,9 @@ export interface IIssueRootStore {
workspaceIssuesFilter: IWorkspaceIssuesFilter; workspaceIssuesFilter: IWorkspaceIssuesFilter;
workspaceIssues: IWorkspaceIssues; workspaceIssues: IWorkspaceIssues;
workspaceDraftIssuesFilter: IWorkspaceDraftIssuesFilter;
workspaceDraftIssues: IWorkspaceDraftIssues;
profileIssuesFilter: IProfileIssuesFilter; profileIssuesFilter: IProfileIssuesFilter;
profileIssues: IProfileIssues; profileIssues: IProfileIssues;
@ -110,6 +114,9 @@ export class IssueRootStore implements IIssueRootStore {
workspaceIssuesFilter: IWorkspaceIssuesFilter; workspaceIssuesFilter: IWorkspaceIssuesFilter;
workspaceIssues: IWorkspaceIssues; workspaceIssues: IWorkspaceIssues;
workspaceDraftIssuesFilter: IWorkspaceDraftIssuesFilter;
workspaceDraftIssues: IWorkspaceDraftIssues;
profileIssuesFilter: IProfileIssuesFilter; profileIssuesFilter: IProfileIssuesFilter;
profileIssues: IProfileIssues; profileIssues: IProfileIssues;
@ -190,6 +197,9 @@ export class IssueRootStore implements IIssueRootStore {
this.profileIssuesFilter = new ProfileIssuesFilter(this); this.profileIssuesFilter = new ProfileIssuesFilter(this);
this.profileIssues = new ProfileIssues(this, this.profileIssuesFilter); this.profileIssues = new ProfileIssues(this, this.profileIssuesFilter);
this.workspaceDraftIssuesFilter = new WorkspaceDraftIssuesFilter(this);
this.workspaceDraftIssues = new WorkspaceDraftIssues(this, this.workspaceDraftIssuesFilter);
this.projectIssuesFilter = new ProjectIssuesFilter(this); this.projectIssuesFilter = new ProjectIssuesFilter(this);
this.projectIssues = new ProjectIssues(this, this.projectIssuesFilter); this.projectIssues = new ProjectIssues(this, this.projectIssuesFilter);

View file

@ -0,0 +1,254 @@
import isEmpty from "lodash/isEmpty";
import set from "lodash/set";
import { action, computed, makeObservable, observable, runInAction } from "mobx";
// base class
import { computedFn } from "mobx-utils";
import {
IIssueFilterOptions,
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
TIssueKanbanFilters,
IIssueFilters,
TIssueParams,
IssuePaginationOptions,
} from "@plane/types";
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
import { handleIssueQueryParamsByLayout } from "@/helpers/issue.helper";
import { IssueFiltersService } from "@/services/issue_filter.service";
import { IBaseIssueFilterStore, IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
// helpers
// types
import { IIssueRootStore } from "../root.store";
// constants
// services
export interface IWorkspaceDraftIssuesFilter extends IBaseIssueFilterStore {
// observables
workspaceSlug: string;
//helper actions
getFilterParams: (
options: IssuePaginationOptions,
userId: string,
cursor: string | undefined,
groupId: string | undefined,
subGroupId: string | undefined
) => Partial<Record<TIssueParams, string | boolean>>;
// action
fetchFilters: (workspaceSlug: string) => Promise<void>;
updateFilters: (
workspaceSlug: string,
filterType: EIssueFilterType,
filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters
) => Promise<void>;
}
export class WorkspaceDraftIssuesFilter extends IssueFilterHelperStore implements IWorkspaceDraftIssuesFilter {
// observables
workspaceSlug: string = "";
filters: { [userId: string]: IIssueFilters } = {};
// root store
rootIssueStore: IIssueRootStore;
// services
issueFilterService;
constructor(_rootStore: IIssueRootStore) {
super();
makeObservable(this, {
// observables
workspaceSlug: observable.ref,
filters: observable,
// computed
issueFilters: computed,
appliedFilters: computed,
// actions
fetchFilters: action,
updateFilters: action,
});
// root store
this.rootIssueStore = _rootStore;
// services
this.issueFilterService = new IssueFiltersService();
}
get issueFilters() {
const workspaceSlug = this.rootIssueStore.workspaceSlug;
if (!workspaceSlug) return undefined;
return this.getIssueFilters(workspaceSlug);
}
get appliedFilters() {
const workspaceSlug = this.rootIssueStore.workspaceSlug;
if (!workspaceSlug) return undefined;
return this.getAppliedFilters(workspaceSlug);
}
getIssueFilters(workspaceSlug: string) {
const displayFilters = this.filters[workspaceSlug] || undefined;
if (isEmpty(displayFilters)) return undefined;
const _filters: IIssueFilters = this.computedIssueFilters(displayFilters);
return _filters;
}
getAppliedFilters(workspaceSlug: string) {
const userFilters = this.getIssueFilters(workspaceSlug);
if (!userFilters) return undefined;
const filteredParams = handleIssueQueryParamsByLayout(userFilters?.displayFilters?.layout, "profile_issues");
if (!filteredParams) return undefined;
const filteredRouteParams: Partial<Record<TIssueParams, string | boolean>> = this.computedFilteredParams(
userFilters?.filters as IIssueFilterOptions,
userFilters?.displayFilters as IIssueDisplayFilterOptions,
filteredParams
);
return filteredRouteParams;
}
getFilterParams = computedFn(
(
options: IssuePaginationOptions,
userId: string,
cursor: string | undefined,
groupId: string | undefined,
subGroupId: string | undefined
) => {
const filterParams = this.getAppliedFilters(this.workspaceSlug);
const paginationParams = this.getPaginationParams(filterParams, options, cursor, groupId, subGroupId);
return paginationParams;
}
);
fetchFilters = async (workspaceSlug: string) => {
this.workspaceSlug = workspaceSlug;
const _filters = this.handleIssuesLocalFilters.get(
EIssuesStoreType.PROFILE,
workspaceSlug,
workspaceSlug,
undefined
);
const filters: IIssueFilterOptions = this.computedFilters(_filters?.filters);
const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(_filters?.display_filters);
const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties);
const kanbanFilters = {
group_by: _filters?.kanban_filters?.group_by || [],
sub_group_by: _filters?.kanban_filters?.sub_group_by || [],
};
runInAction(() => {
set(this.filters, [workspaceSlug, "filters"], filters);
set(this.filters, [workspaceSlug, "displayFilters"], displayFilters);
set(this.filters, [workspaceSlug, "displayProperties"], displayProperties);
set(this.filters, [workspaceSlug, "kanbanFilters"], kanbanFilters);
});
};
updateFilters = async (
workspaceSlug: string,
type: EIssueFilterType,
filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters
) => {
try {
if (isEmpty(this.filters) || isEmpty(this.filters[workspaceSlug]) || isEmpty(filters)) return;
const _filters = {
filters: this.filters[workspaceSlug].filters as IIssueFilterOptions,
displayFilters: this.filters[workspaceSlug].displayFilters as IIssueDisplayFilterOptions,
displayProperties: this.filters[workspaceSlug].displayProperties as IIssueDisplayProperties,
kanbanFilters: this.filters[workspaceSlug].kanbanFilters as TIssueKanbanFilters,
};
switch (type) {
case EIssueFilterType.FILTERS: {
const updatedFilters = filters as IIssueFilterOptions;
_filters.filters = { ..._filters.filters, ...updatedFilters };
runInAction(() => {
Object.keys(updatedFilters).forEach((_key) => {
set(this.filters, [workspaceSlug, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]);
});
});
this.rootIssueStore.profileIssues.fetchIssuesWithExistingPagination(workspaceSlug, workspaceSlug, "mutation");
this.handleIssuesLocalFilters.set(EIssuesStoreType.PROFILE, type, workspaceSlug, workspaceSlug, undefined, {
filters: _filters.filters,
});
break;
}
case EIssueFilterType.DISPLAY_FILTERS: {
const updatedDisplayFilters = filters as IIssueDisplayFilterOptions;
_filters.displayFilters = { ..._filters.displayFilters, ...updatedDisplayFilters };
// set sub_group_by to null if group_by is set to null
if (_filters.displayFilters.group_by === null) {
_filters.displayFilters.sub_group_by = null;
updatedDisplayFilters.sub_group_by = null;
}
// set sub_group_by to null if layout is switched to kanban group_by and sub_group_by are same
if (
_filters.displayFilters.layout === "kanban" &&
_filters.displayFilters.group_by === _filters.displayFilters.sub_group_by
) {
_filters.displayFilters.sub_group_by = null;
updatedDisplayFilters.sub_group_by = null;
}
// set group_by to priority if layout is switched to kanban and group_by is null
if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) {
_filters.displayFilters.group_by = "priority";
updatedDisplayFilters.group_by = "priority";
}
runInAction(() => {
Object.keys(updatedDisplayFilters).forEach((_key) => {
set(
this.filters,
[workspaceSlug, "displayFilters", _key],
updatedDisplayFilters[_key as keyof IIssueDisplayFilterOptions]
);
});
});
this.rootIssueStore.profileIssues.fetchIssuesWithExistingPagination(workspaceSlug, workspaceSlug, "mutation");
this.handleIssuesLocalFilters.set(EIssuesStoreType.PROFILE, type, workspaceSlug, workspaceSlug, undefined, {
display_filters: _filters.displayFilters,
});
break;
}
case EIssueFilterType.DISPLAY_PROPERTIES: {
const updatedDisplayProperties = filters as IIssueDisplayProperties;
_filters.displayProperties = { ..._filters.displayProperties, ...updatedDisplayProperties };
runInAction(() => {
Object.keys(updatedDisplayProperties).forEach((_key) => {
set(
this.filters,
[workspaceSlug, "displayProperties", _key],
updatedDisplayProperties[_key as keyof IIssueDisplayProperties]
);
});
});
this.handleIssuesLocalFilters.set(EIssuesStoreType.PROFILE, type, workspaceSlug, workspaceSlug, undefined, {
display_properties: _filters.displayProperties,
});
break;
}
default:
break;
}
} catch (error) {
if (workspaceSlug) this.fetchFilters(workspaceSlug);
throw error;
}
};
}

View file

@ -0,0 +1,2 @@
export * from "./issue.store";
export * from "./filter.store";

View file

@ -0,0 +1,280 @@
import orderBy from "lodash/orderBy";
import set from "lodash/set";
import unset from "lodash/unset";
import update from "lodash/update";
import { action, computed, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
import {
TWorkspaceDraftIssue,
TWorkspaceDraftPaginationInfo,
TWorkspaceDraftIssueLoader,
TWorkspaceDraftQueryParams,
} from "@plane/types";
// constants
import { EDraftIssuePaginationType } from "@/constants/workspace-drafts";
// helpers
import { getCurrentDateTimeInISO, convertToISODateString } from "@/helpers/date-time.helper";
// services
import workspaceDraftService from "@/services/issue/workspace_draft.service";
import { IIssueDetail } from "../issue-details/root.store";
import { clone } from "lodash";
export type TDraftIssuePaginationType = EDraftIssuePaginationType;
export interface IWorkspaceDraftIssues {
// observables
issuesMap: Record<string, TWorkspaceDraftIssue>;
paginationInfo: Omit<TWorkspaceDraftPaginationInfo<TWorkspaceDraftIssue>, "results"> | undefined;
loader: TWorkspaceDraftIssueLoader;
// computed
issueIds: string[];
// computed functions
getIssueById: (issueId: string) => TWorkspaceDraftIssue | undefined;
// helper actions
addIssue: (issues: TWorkspaceDraftIssue[]) => void;
mutateIssue: (issueId: string, data: Partial<TWorkspaceDraftIssue>) => void;
removeIssue: (issueId: string) => void;
// actions
fetchIssues: (
workspaceSlug: string,
loadType: TWorkspaceDraftIssueLoader,
paginationType?: TDraftIssuePaginationType
) => Promise<TWorkspaceDraftPaginationInfo<TWorkspaceDraftIssue> | undefined>;
createIssue: (
workspaceSlug: string,
payload: Partial<TWorkspaceDraftIssue>
) => Promise<TWorkspaceDraftIssue | undefined>;
updateIssue: (
workspaceSlug: string,
issueId: string,
payload: Partial<TWorkspaceDraftIssue>
) => Promise<TWorkspaceDraftIssue | undefined>;
deleteIssue: (workspaceSlug: string, issueId: string) => Promise<void>;
moveIssue: (workspaceSlug: string, issueId: string, payload: Partial<TWorkspaceDraftIssue>) => Promise<void>;
addCycleToIssue: (
workspaceSlug: string,
issueId: string,
cycleId: string
) => Promise<TWorkspaceDraftIssue | undefined>;
addModulesToIssue: (
workspaceSlug: string,
issueId: string,
moduleIds: string[]
) => Promise<TWorkspaceDraftIssue | undefined>;
}
export class WorkspaceDraftIssues implements IWorkspaceDraftIssues {
// local constants
paginatedCount = 50;
// observables
paginationInfo: Omit<TWorkspaceDraftPaginationInfo<TWorkspaceDraftIssue>, "results"> | undefined = undefined;
loader: TWorkspaceDraftIssueLoader = undefined;
issuesMap: Record<string, TWorkspaceDraftIssue> = {};
constructor(private store: IIssueDetail) {
makeObservable(this, {
paginationInfo: observable,
loader: observable.ref,
issuesMap: observable,
// computed
issueIds: computed,
// action
fetchIssues: action,
createIssue: action,
updateIssue: action,
deleteIssue: action,
moveIssue: action,
addCycleToIssue: action,
addModulesToIssue: action,
});
}
// computed
get issueIds() {
if (Object.keys(this.issuesMap).length <= 0) return [];
return orderBy(Object.values(this.issuesMap), (issue) => convertToISODateString(issue["created_at"]), ["asc"]).map(
(issue) => issue?.id
);
}
// computed functions
getIssueById = computedFn((issueId: string) => {
if (!issueId || !this.issuesMap[issueId]) return undefined;
return this.issuesMap[issueId];
});
// helper actions
addIssue = (issues: TWorkspaceDraftIssue[]) => {
if (issues && issues.length <= 0) return;
runInAction(() => {
issues.forEach((issue) => {
if (!this.issuesMap[issue.id]) set(this.issuesMap, issue.id, issue);
else update(this.issuesMap, issue.id, (prevIssue) => ({ ...prevIssue, ...issue }));
});
});
};
mutateIssue = (issueId: string, issue: Partial<TWorkspaceDraftIssue>) => {
if (!issue || !issueId || !this.issuesMap[issueId]) return;
runInAction(() => {
set(this.issuesMap, [issueId, "updated_at"], getCurrentDateTimeInISO());
Object.keys(issue).forEach((key) => {
set(this.issuesMap, [issueId, key], issue[key as keyof TWorkspaceDraftIssue]);
});
});
};
removeIssue = (issueId: string) => {
if (!issueId || !this.issuesMap[issueId]) return;
runInAction(() => unset(this.issuesMap, issueId));
};
generateNotificationQueryParams = (
paramType: TDraftIssuePaginationType,
filterParams = {}
): TWorkspaceDraftQueryParams => {
const queryCursorNext: string =
paramType === EDraftIssuePaginationType.INIT
? `${this.paginatedCount}:0:0`
: paramType === EDraftIssuePaginationType.CURRENT
? `${this.paginatedCount}:${0}:0`
: paramType === EDraftIssuePaginationType.NEXT && this.paginationInfo
? (this.paginationInfo?.next_cursor ?? `${this.paginatedCount}:${0}:0`)
: `${this.paginatedCount}:${0}:0`;
const queryParams: TWorkspaceDraftQueryParams = {
per_page: this.paginatedCount,
cursor: queryCursorNext,
...filterParams,
};
return queryParams;
};
// actions
fetchIssues = async (
workspaceSlug: string,
loadType: TWorkspaceDraftIssueLoader,
paginationType: TDraftIssuePaginationType = EDraftIssuePaginationType.INIT
) => {
try {
this.loader = loadType;
// filter params and pagination params
const filterParams = {};
const params = this.generateNotificationQueryParams(paginationType, filterParams);
// fetching the paginated workspace draft issues
const draftIssuesResponse = await workspaceDraftService.getIssues(workspaceSlug, { ...params });
if (!draftIssuesResponse) return undefined;
const { results, ...paginationInfo } = draftIssuesResponse;
runInAction(() => {
if (results && results.length > 0) {
this.addIssue(results as TWorkspaceDraftIssue[]);
this.loader = undefined;
} else {
this.loader = "empty-state";
}
set(this, "paginationInfo", paginationInfo);
});
return draftIssuesResponse;
} catch (error) {
// set loader to undefined if errored out
this.loader = undefined;
throw error;
}
};
createIssue = async (
workspaceSlug: string,
payload: Partial<TWorkspaceDraftIssue>
): Promise<TWorkspaceDraftIssue | undefined> => {
try {
this.loader = "create";
const response = await workspaceDraftService.createIssue(workspaceSlug, payload);
if (response) {
runInAction(() => set(this.issuesMap, response.id, response));
}
this.loader = undefined;
return response;
} catch (error) {
this.loader = undefined;
throw error;
}
};
updateIssue = async (workspaceSlug: string, issueId: string, payload: Partial<TWorkspaceDraftIssue>) => {
const issueBeforeUpdate = clone(this.getIssueById(issueId));
try {
this.loader = "update";
runInAction(() => {
set(this.issuesMap, [issueId, "updated_at"], getCurrentDateTimeInISO());
set(this.issuesMap, [issueId], { ...issueBeforeUpdate, ...payload });
});
const response = await workspaceDraftService.updateIssue(workspaceSlug, issueId, payload);
this.loader = undefined;
return response;
} catch (error) {
this.loader = undefined;
runInAction(() => {
set(this.issuesMap, [issueId], issueBeforeUpdate);
});
throw error;
}
};
deleteIssue = async (workspaceSlug: string, issueId: string) => {
try {
this.loader = "delete";
const response = await workspaceDraftService.deleteIssue(workspaceSlug, issueId);
runInAction(() => unset(this.issuesMap, issueId));
this.loader = undefined;
return response;
} catch (error) {
this.loader = undefined;
throw error;
}
};
moveIssue = async (workspaceSlug: string, issueId: string, payload: Partial<TWorkspaceDraftIssue>) => {
try {
this.loader = "move";
const response = await workspaceDraftService.moveIssue(workspaceSlug, issueId, payload);
runInAction(() => unset(this.issuesMap, issueId));
this.loader = undefined;
return response;
} catch (error) {
this.loader = undefined;
throw error;
}
};
addCycleToIssue = async (workspaceSlug: string, issueId: string, cycleId: string) => {
try {
this.loader = "update";
const response = await this.updateIssue(workspaceSlug, issueId, { cycle_id: cycleId });
return response;
} catch (error) {
this.loader = undefined;
throw error;
}
};
addModulesToIssue = async (workspaceSlug: string, issueId: string, moduleIds: string[]) => {
try {
this.loader = "update";
const response = this.updateIssue(workspaceSlug, issueId, { module_ids: moduleIds });
return response;
} catch (error) {
this.loader = undefined;
throw error;
}
};
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB