diff --git a/apiserver/plane/app/views/workspace/draft.py b/apiserver/plane/app/views/workspace/draft.py index ad543a75635..5bd8c2dfae7 100644 --- a/apiserver/plane/app/views/workspace/draft.py +++ b/apiserver/plane/app/views/workspace/draft.py @@ -44,18 +44,11 @@ class WorkspaceDraftIssueViewSet(BaseViewSet): - model = DraftIssue - @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 = ( - DraftIssue.objects.filter(workspace__slug=slug) - .filter(created_by=request.user) + def get_queryset(self): + return ( + DraftIssue.objects.filter(workspace__slug=self.kwargs.get("slug")) .select_related("workspace", "project", "state", "parent") .prefetch_related( "assignees", "labels", "draft_issue_module__module" @@ -91,6 +84,17 @@ def list(self, request, slug): 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") ) @@ -120,7 +124,34 @@ def create(self, request, slug): ) if serializer.is_valid(): 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) @allow_permission( @@ -131,45 +162,7 @@ def create(self, request, slug): ) def partial_update(self, request, slug, pk): issue = ( - DraftIssue.objects.filter(workspace__slug=slug) - .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() + self.get_queryset().filter(pk=pk, created_by=request.user).first() ) if not issue: @@ -202,46 +195,8 @@ def partial_update(self, request, slug, pk): ) def retrieve(self, request, slug, pk=None): issue = ( - DraftIssue.objects.filter(workspace__slug=slug) - .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() + self.get_queryset().filter(pk=pk, created_by=request.user).first() + ) if not issue: return Response( @@ -268,42 +223,7 @@ def destroy(self, request, slug, pk=None): level="WORKSPACE", ) def create_draft_to_issue(self, request, slug, draft_id): - draft_issue = ( - 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() - ) + draft_issue = self.get_queryset().filter(pk=draft_id).first() if not draft_issue.project_id: return Response( diff --git a/apiserver/plane/utils/paginator.py b/apiserver/plane/utils/paginator.py index 65f0aa7f746..219b646b25b 100644 --- a/apiserver/plane/utils/paginator.py +++ b/apiserver/plane/utils/paginator.py @@ -150,7 +150,7 @@ def get_result(self, limit=1000, cursor=None): raise BadPaginationError("Pagination offset cannot be negative") results = queryset[offset:stop] - + print(limit, "limit") if cursor.value != limit: results = results[-(limit + 1) :] @@ -761,7 +761,7 @@ def paginate( ): """Paginate the request""" 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 input_cursor = None try: @@ -788,6 +788,7 @@ def paginate( paginator = paginator_cls(**paginator_kwargs) try: + print(per_page, "per_page 2") cursor_result = paginator.get_result( limit=per_page, cursor=input_cursor ) diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index 6dfddc6b638..4559e79c837 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -29,3 +29,4 @@ export * from "./pragmatic"; export * from "./publish"; export * from "./workspace-notifications"; export * from "./favorite"; +export * from "./workspace-draft-issues/base"; diff --git a/packages/types/src/issues/base.d.ts b/packages/types/src/issues/base.d.ts index 8292c111649..05f679cce28 100644 --- a/packages/types/src/issues/base.d.ts +++ b/packages/types/src/issues/base.d.ts @@ -10,6 +10,7 @@ export * from "./issue_relation"; export * from "./issue_sub_issues"; export * from "./activity/base"; + export type TLoader = | "init-loader" | "mutation" diff --git a/packages/types/src/workspace-draft-issues/base.d.ts b/packages/types/src/workspace-draft-issues/base.d.ts new file mode 100644 index 00000000000..f0272defd5e --- /dev/null +++ b/packages/types/src/workspace-draft-issues/base.d.ts @@ -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 = { + 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; diff --git a/web/app/[workspaceSlug]/(projects)/drafts/header.tsx b/web/app/[workspaceSlug]/(projects)/drafts/header.tsx new file mode 100644 index 00000000000..dfe9c5fa320 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/drafts/header.tsx @@ -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 ( + <> + setIsDraftIssueModalOpen(false)} + isDraft + /> +
+ + + } />} + /> + + + + + + +
+ + ); +}); diff --git a/web/app/[workspaceSlug]/(projects)/drafts/layout.tsx b/web/app/[workspaceSlug]/(projects)/drafts/layout.tsx new file mode 100644 index 00000000000..a5a647bfdba --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/drafts/layout.tsx @@ -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 ( + <> + } /> + {children} + + ); +} diff --git a/web/app/[workspaceSlug]/(projects)/drafts/page.tsx b/web/app/[workspaceSlug]/(projects)/drafts/page.tsx new file mode 100644 index 00000000000..f94fc872aeb --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/drafts/page.tsx @@ -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 ( + <> + +
+ +
+ + ); +}; + +export default WorkspaceDraftPage; diff --git a/web/core/components/issues/issue-layouts/filters/applied-filters/roots/workspace-draft-root.tsx b/web/core/components/issues/issue-layouts/filters/applied-filters/roots/workspace-draft-root.tsx new file mode 100644 index 00000000000..b97786839fe --- /dev/null +++ b/web/core/components/issues/issue-layouts/filters/applied-filters/roots/workspace-draft-root.tsx @@ -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 ( + +
+
+ + +
+
+
+ ); +}); diff --git a/web/core/components/issues/issue-layouts/list/base-list-root.tsx b/web/core/components/issues/issue-layouts/list/base-list-root.tsx index a97ad0a8e05..d73ca6ff7be 100644 --- a/web/core/components/issues/issue-layouts/list/base-list-root.tsx +++ b/web/core/components/issues/issue-layouts/list/base-list-root.tsx @@ -25,7 +25,8 @@ type ListStoreType = | EIssuesStoreType.PROJECT_VIEW | EIssuesStoreType.DRAFT | EIssuesStoreType.PROFILE - | EIssuesStoreType.ARCHIVED; + | EIssuesStoreType.ARCHIVED + | EIssuesStoreType.WORKSPACE_DRAFT; interface IBaseListRoot { QuickActions: FC; addIssuesToView?: (issueIds: string[]) => Promise; @@ -61,8 +62,9 @@ export const BaseListRoot = observer((props: IBaseListRoot) => { const showEmptyGroup = displayFilters?.show_empty_groups ?? false; const { workspaceSlug, projectId } = useParams(); - const {updateFilters} = useIssuesActions(storeType); - const collapsedGroups = issuesFilter?.issueFilters?.kanbanFilters || { group_by: [], sub_group_by: [] } as TIssueKanbanFilters; + const { updateFilters } = useIssuesActions(storeType); + const collapsedGroups = + issuesFilter?.issueFilters?.kanbanFilters || ({ group_by: [], sub_group_by: [] } as TIssueKanbanFilters); useEffect(() => { fetchIssues("init-loader", { canGroup: true, perPageCount: group_by ? 50 : 100 }, viewId); @@ -122,15 +124,14 @@ export const BaseListRoot = observer((props: IBaseListRoot) => { } else { collapsedGroups.push(value); } - updateFilters(projectId?.toString() ?? "", EIssueFilterType.KANBAN_FILTERS, - { group_by: collapsedGroups } as TIssueKanbanFilters - ); + updateFilters(projectId?.toString() ?? "", EIssueFilterType.KANBAN_FILTERS, { + group_by: collapsedGroups, + } as TIssueKanbanFilters); } }, [workspaceSlug, issuesFilter, projectId, updateFilters] ); - return (
diff --git a/web/core/components/issues/issue-layouts/list/default.tsx b/web/core/components/issues/issue-layouts/list/default.tsx index befa1f8fdbc..fca8d68eb25 100644 --- a/web/core/components/issues/issue-layouts/list/default.tsx +++ b/web/core/components/issues/issue-layouts/list/default.tsx @@ -49,7 +49,7 @@ export interface IList { isCompletedCycle?: boolean; loadMoreIssues: (groupId?: string) => void; handleCollapsedGroups: (value: string) => void; - collapsedGroups : TIssueKanbanFilters; + collapsedGroups: TIssueKanbanFilters; } export const List: React.FC = observer((props) => { @@ -71,7 +71,7 @@ export const List: React.FC = observer((props) => { isCompletedCycle = false, loadMoreIssues, handleCollapsedGroups, - collapsedGroups + collapsedGroups, } = props; const storeType = useIssueStoreType(); @@ -133,7 +133,6 @@ export const List: React.FC = observer((props) => { } else { entities = orderedGroups; } - return (
{groups && ( diff --git a/web/core/components/issues/issue-layouts/list/list-view-types.d.ts b/web/core/components/issues/issue-layouts/list/list-view-types.d.ts index 6597855f6f8..089623ed931 100644 --- a/web/core/components/issues/issue-layouts/list/list-view-types.d.ts +++ b/web/core/components/issues/issue-layouts/list/list-view-types.d.ts @@ -9,6 +9,7 @@ export interface IQuickActionProps { handleRemoveFromView?: () => Promise; handleArchive?: () => Promise; handleRestore?: () => Promise; + handleMoveToIssues?: () => Promise; customActionButton?: React.ReactElement; portalElement?: HTMLDivElement | null; readOnly?: boolean; diff --git a/web/core/components/issues/issue-layouts/properties/all-properties.tsx b/web/core/components/issues/issue-layouts/properties/all-properties.tsx index e439635feeb..36bb6fafccb 100644 --- a/web/core/components/issues/issue-layouts/properties/all-properties.tsx +++ b/web/core/components/issues/issue-layouts/properties/all-properties.tsx @@ -37,9 +37,7 @@ import { WithDisplayPropertiesHOC } from "./with-display-properties-HOC"; export interface IIssueProperties { issue: TIssue; - updateIssue: - | ((projectId: string | null, issueId: string, data: Partial) => Promise) - | undefined; + updateIssue: ((projectId: string | null, issueId: string, data: Partial) => Promise) | undefined; displayProperties: IIssueDisplayProperties | undefined; isReadOnly: boolean; className: string; diff --git a/web/core/components/issues/issue-layouts/quick-action-dropdowns/index.ts b/web/core/components/issues/issue-layouts/quick-action-dropdowns/index.ts index 212a43f91c3..dbc1e9f5a74 100644 --- a/web/core/components/issues/issue-layouts/quick-action-dropdowns/index.ts +++ b/web/core/components/issues/issue-layouts/quick-action-dropdowns/index.ts @@ -4,3 +4,4 @@ export * from "./project-issue"; export * from "./archived-issue"; export * from "./draft-issue"; export * from "./all-issue"; +export * from "../../workspace-draft/quick-action"; diff --git a/web/core/components/issues/issue-modal/base.tsx b/web/core/components/issues/issue-modal/base.tsx index 0d1763011c4..d6ccf590921 100644 --- a/web/core/components/issues/issue-modal/base.tsx +++ b/web/core/components/issues/issue-modal/base.tsx @@ -50,7 +50,7 @@ export const CreateUpdateIssueModalBase: React.FC = observer(( const { fetchModuleDetails } = useModule(); const { issues } = useIssues(storeType); const { issues: projectIssues } = useIssues(EIssuesStoreType.PROJECT); - const { issues: draftIssues } = useIssues(EIssuesStoreType.DRAFT); + const { issues: draftIssues } = useIssues(EIssuesStoreType.WORKSPACE_DRAFT); const { fetchIssue } = useIssueDetail(); const { handleCreateUpdatePropertyValues } = useIssueModal(); // pathname @@ -70,7 +70,7 @@ export const CreateUpdateIssueModalBase: React.FC = observer(( if (!workspaceSlug) return; if (!projectId || issueId === undefined || !fetchIssueDetails) { - // Set description to the issue description from the props if available + // Set description to the issue description from the props if available setDescription(data?.description_html || "

"); return; } @@ -151,10 +151,9 @@ export const CreateUpdateIssueModalBase: React.FC = observer(( try { let response; - // if draft issue, use draft issue store to create 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 // or if the moduleIds in Payload does not match the moduleId in url @@ -213,8 +212,8 @@ export const CreateUpdateIssueModalBase: React.FC = observer(( payload: { ...response, state: "SUCCESS" }, path: pathname, }); - !createMore && handleClose(); - if (createMore) issueTitleRef && issueTitleRef?.current?.focus(); + if (!createMore) handleClose(); + if (createMore && issueTitleRef) issueTitleRef?.current?.focus(); setDescription("

"); setChangesMade(null); return response; @@ -237,9 +236,8 @@ export const CreateUpdateIssueModalBase: React.FC = observer(( if (!workspaceSlug || !payload.project_id || !data?.id) return; try { - isDraft - ? await draftIssues.updateIssue(workspaceSlug.toString(), payload.project_id, data.id, payload) - : updateIssue && (await updateIssue(payload.project_id, data.id, payload)); + if (isDraft) await draftIssues.updateIssue(workspaceSlug.toString(), data.id, payload); + else if (updateIssue) await updateIssue(payload.project_id, data.id, payload); // add other property values await handleCreateUpdatePropertyValues({ @@ -260,6 +258,7 @@ export const CreateUpdateIssueModalBase: React.FC = observer(( }); handleClose(); } catch (error) { + console.error(error); setToast({ type: TOAST_TYPE.ERROR, title: "Error!", @@ -314,7 +313,7 @@ export const CreateUpdateIssueModalBase: React.FC = observer(( issueTitleRef={issueTitleRef} onChange={handleFormChange} onClose={handleClose} - onSubmit={handleFormSubmit} + onSubmit={(payload) => handleFormSubmit(payload, isDraft)} projectId={activeProjectId} isCreateMoreToggleEnabled={createMore} onCreateMoreToggleChange={handleCreateMoreToggleChange} @@ -332,7 +331,7 @@ export const CreateUpdateIssueModalBase: React.FC = observer(( onClose={() => handleClose(false)} isCreateMoreToggleEnabled={createMore} onCreateMoreToggleChange={handleCreateMoreToggleChange} - onSubmit={handleFormSubmit} + onSubmit={(payload) => handleFormSubmit(payload, isDraft)} projectId={activeProjectId} isDraft={isDraft} /> diff --git a/web/core/components/issues/issue-modal/draft-issue-layout.tsx b/web/core/components/issues/issue-modal/draft-issue-layout.tsx index 49bb1734de5..38a230a2365 100644 --- a/web/core/components/issues/issue-modal/draft-issue-layout.tsx +++ b/web/core/components/issues/issue-modal/draft-issue-layout.tsx @@ -16,7 +16,7 @@ import { isEmptyHtmlString } from "@/helpers/string.helper"; import { useIssueModal } from "@/hooks/context/use-issue-modal"; import { useEventTracker } from "@/hooks/store"; // services -import { IssueDraftService } from "@/services/issue"; +import workspaceDraftService from "@/services/issue/workspace_draft.service"; // local components import { IssueFormRoot } from "./form"; @@ -33,8 +33,6 @@ export interface DraftIssueProps { isDraft: boolean; } -const issueDraftService = new IssueDraftService(); - export const DraftIssueLayout: React.FC = observer((props) => { const { changesMade, @@ -95,10 +93,11 @@ export const DraftIssueLayout: React.FC = observer((props) => { const payload = { ...changesMade, name: changesMade?.name && changesMade?.name?.trim() !== "" ? changesMade.name?.trim() : "Untitled", + project_id: projectId, }; - const response = await issueDraftService - .createDraftIssue(workspaceSlug.toString(), projectId.toString(), payload) + const response = await workspaceDraftService + .createIssue(workspaceSlug.toString(), payload) .then((res) => { setToast({ type: TOAST_TYPE.SUCCESS, diff --git a/web/core/components/issues/issue-modal/form.tsx b/web/core/components/issues/issue-modal/form.tsx index 35785d3487d..615d935f561 100644 --- a/web/core/components/issues/issue-modal/form.tsx +++ b/web/core/components/issues/issue-modal/form.tsx @@ -266,7 +266,9 @@ export const IssueFormRoot: FC = observer((props) => { )}
handleFormSubmit(data))}>
-

{data?.id ? "Update" : "Create new"} issue

+

+ {data?.id ? "Update" : isDraft ? "Create draft" : "Create new"} issue +

{/* Disable project selection if editing an issue */}
= observer((props) => { > Discard - {isDraft && ( - <> - {data?.id ? ( - - ) : ( - - )} - - )}
diff --git a/web/core/components/issues/workspace-draft/delete-modal.tsx b/web/core/components/issues/workspace-draft/delete-modal.tsx new file mode 100644 index 00000000000..25e181924c0 --- /dev/null +++ b/web/core/components/issues/workspace-draft/delete-modal.tsx @@ -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; +}; + +export const WorkspaceDraftIssueDeleteIssueModal: React.FC = (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 ( + + Are you sure you want to delete issue{" "} + {projectDetails?.identifier} + {""}? All of the data related to the issue will be permanently removed. This action cannot be undone. + + } + /> + ); +}; diff --git a/web/core/components/issues/workspace-draft/draft-issue-block.tsx b/web/core/components/issues/workspace-draft/draft-issue-block.tsx new file mode 100644 index 00000000000..8aad8ced739 --- /dev/null +++ b/web/core/components/issues/workspace-draft/draft-issue-block.tsx @@ -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 = 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(null); + // derived values + const issue = getIssueById(issueId); + const projectIdentifier = (issue && issue.project_id && getProjectIdentifierById(issue.project_id)) || undefined; + if (!issue || !projectIdentifier) return null; + + return ( +
+ +
+
+
+ {/* {displayProperties && (displayProperties.key || displayProperties.issue_type) && ( */} +
+ {issue.project_id && ( +
+ +
+ )} +
+ {/* )} */} + + {/* sub-issues chevron */} +
+
+ + +

{issue.name}

+
+
+ + {/* quick actions */} +
+ updateIssue(workspaceSlug, issueId, data)} + handleDelete={async () => deleteIssue(workspaceSlug, issueId)} + handleMoveToIssues={async () => moveIssue(workspaceSlug, issueId, issue)} + /> +
+
+ +
+ { + await updateIssue(workspaceSlug, issueId, data); + }} + activeLayout="List" + /> +
{ + e.preventDefault(); + e.stopPropagation(); + }} + > + updateIssue(workspaceSlug, issueId, data)} + handleDelete={async () => deleteIssue(workspaceSlug, issueId)} + handleMoveToIssues={async () => moveIssue(workspaceSlug, issueId, issue)} + /> +
+
+ +
+ ); +}); diff --git a/web/core/components/issues/workspace-draft/draft-issue-properties.tsx b/web/core/components/issues/workspace-draft/draft-issue-properties.tsx new file mode 100644 index 00000000000..7150012764c --- /dev/null +++ b/web/core/components/issues/workspace-draft/draft-issue-properties.tsx @@ -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) => Promise) + | undefined; + className: string; + activeLayout: string; +} + +export const DraftIssueProperties: React.FC = 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 ( +
+ {/* basic properties */} + {/* state */} +
+ +
+ + {/* priority */} +
+ +
+ + {/* label */} + +
+ +
+ + {/* start date */} +
+ } + buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"} + optionsClassName="z-10" + renderByDefault={isMobile} + showTooltip + /> +
+ + {/* target/due date */} +
+ } + 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 + /> +
+ + {/* assignee */} +
+ 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} + /> +
+ + {/* modules */} + {projectDetails?.module_view && ( +
+ +
+ )} + + {/* cycles */} + {projectDetails?.cycle_view && ( +
+ +
+ )} + + {/* estimates */} + {issue.project_id && areEstimateEnabledByProjectId(issue.project_id?.toString()) && ( +
+ +
+ )} +
+ ); +}); diff --git a/web/core/components/issues/workspace-draft/empty-state.tsx b/web/core/components/issues/workspace-draft/empty-state.tsx new file mode 100644 index 00000000000..4a1292d6160 --- /dev/null +++ b/web/core/components/issues/workspace-draft/empty-state.tsx @@ -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 ( + + setIsDraftIssueModalOpen(false)} + isDraft + /> +
+ { + setIsDraftIssueModalOpen(true); + }} + /> +
+
+ ); +}; diff --git a/web/core/components/issues/workspace-draft/index.ts b/web/core/components/issues/workspace-draft/index.ts new file mode 100644 index 00000000000..07138bc0bc2 --- /dev/null +++ b/web/core/components/issues/workspace-draft/index.ts @@ -0,0 +1,4 @@ +export * from "./draft-issue-block"; +export * from "./draft-issue-properties"; +export * from "./delete-modal"; +export * from "./root"; diff --git a/web/core/components/issues/workspace-draft/loader.tsx b/web/core/components/issues/workspace-draft/loader.tsx new file mode 100644 index 00000000000..d663a0d035e --- /dev/null +++ b/web/core/components/issues/workspace-draft/loader.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { FC } from "react"; +// components +import { ListLoaderItemRow } from "@/components/ui"; + +type TWorkspaceDraftIssuesLoader = { + items?: number; +}; + +export const WorkspaceDraftIssuesLoader: FC = (props) => { + const { items = 14 } = props; + return ( +
+ {[...Array(items)].map((_, index) => ( + + ))} +
+ ); +}; diff --git a/web/core/components/issues/workspace-draft/quick-action.tsx b/web/core/components/issues/workspace-draft/quick-action.tsx new file mode 100644 index 00000000000..5a3f8268830 --- /dev/null +++ b/web/core/components/issues/workspace-draft/quick-action.tsx @@ -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; + handleUpdate: (payload: Partial) => Promise; + handleMoveToIssues?: () => Promise; + customActionButton?: React.ReactElement; + portalElement?: HTMLDivElement | null; + placements?: Placement; + parentRef: React.RefObject; +} + +export const WorkspaceDraftIssueQuickActions: React.FC = observer((props) => { + const { + issue, + handleDelete, + handleUpdate, + handleMoveToIssues, + customActionButton, + portalElement, + placements = "bottom-end", + parentRef, + } = props; + // states + const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); + const [issueToEdit, setIssueToEdit] = useState(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 ( + <> + setDeleteIssueModal(false)} + onSubmit={handleDelete} + /> + { + 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 + /> + + + {MENU_ITEMS.map((item) => ( + { + 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.title}
+ {item.description && ( +

+ {item.description} +

+ )} +
+
+ ))} +
+ + ); +}); diff --git a/web/core/components/issues/workspace-draft/root.tsx b/web/core/components/issues/workspace-draft/root.tsx new file mode 100644 index 00000000000..a61e48aa01f --- /dev/null +++ b/web/core/components/issues/workspace-draft/root.tsx @@ -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 = 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 ; + } + + if (loader === "empty-state" && issueIds.length <= 0) return ; + + return ( +
+
+ {issueIds.map((issueId: string) => ( + + ))} +
+ {loader === "pagination" && issueIds.length >= 0 ? ( + + ) : ( +
+ Load More ↓ +
+ )} +
+ ); +}); diff --git a/web/core/components/workspace/sidebar/projects-list-item.tsx b/web/core/components/workspace/sidebar/projects-list-item.tsx index a512b9f33c7..2f809bb47d7 100644 --- a/web/core/components/workspace/sidebar/projects-list-item.tsx +++ b/web/core/components/workspace/sidebar/projects-list-item.tsx @@ -428,7 +428,7 @@ export const SidebarProjectsListItem: React.FC = observer((props) => {
)} - {isAuthorized && ( + {/* {isAuthorized && (
@@ -437,7 +437,7 @@ export const SidebarProjectsListItem: React.FC = observer((props) => {
- )} + )} */} diff --git a/web/core/components/workspace/sidebar/quick-actions.tsx b/web/core/components/workspace/sidebar/quick-actions.tsx index 5df8eb952e1..699660b268a 100644 --- a/web/core/components/workspace/sidebar/quick-actions.tsx +++ b/web/core/components/workspace/sidebar/quick-actions.tsx @@ -102,7 +102,7 @@ export const SidebarQuickActions = observer(() => { {!isSidebarCollapsed && New issue} - {!disabled && workspaceDraftIssue && ( + {/* {!disabled && workspaceDraftIssue && ( <> {!isSidebarCollapsed && (
)} - )} + )} */}