From ac6e710623ff347e7caf4b12bbad8e9120bb43a0 Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Wed, 21 Feb 2024 19:20:46 +0530 Subject: [PATCH] [WEB-478]: implemented cycle filters in display properties for list, kanban, and spreadsheet layouts (#3702) * chore: implemented the modules and cycle filter in the display properties * typo: added placeholders for module and cycle select in spreadsheet view * feat: created workspace modules and cycles endpoints in appi server and implemented in application * ui: UI changes in the spreadsheet module and cycle dropdown and added cursor navigation for cycle via arrow keys * format: formatted api sever * chore: module select logic updated * chore: updated module updated handler in all-properties and spreadsheet column * chore: updated url names for workspace modules and cycles * fix: validated members availability in the modules list member tooltip --------- Co-authored-by: Anmol Singh Bhatia --- apiserver/plane/app/urls/workspace.py | 12 ++ apiserver/plane/app/views/__init__.py | 2 + apiserver/plane/app/views/workspace.py | 196 ++++++++++++++++++ packages/types/src/view-props.d.ts | 6 + web/components/dropdowns/cycle.tsx | 9 +- web/components/dropdowns/module.tsx | 7 +- .../issues/issue-detail/module-select.tsx | 23 +- .../display-filters/display-properties.tsx | 50 +++-- .../issue-layouts/list/base-list-root.tsx | 7 +- .../properties/all-properties.tsx | 111 +++++++++- .../spreadsheet/columns/cycle-column.tsx | 66 ++++++ .../spreadsheet/columns/index.ts | 4 +- .../spreadsheet/columns/module-column.tsx | 79 +++++++ .../issue-layouts/spreadsheet/issue-row.tsx | 23 +- web/components/modules/module-list-item.tsx | 2 +- web/constants/issue.ts | 2 + web/constants/spreadsheet.ts | 24 ++- web/hooks/use-workspace-issue-properties.ts | 18 +- web/services/cycle.service.ts | 10 +- web/services/module.service.ts | 8 + web/store/cycle.store.ts | 20 +- .../helpers/issue-filter-helper.store.ts | 2 + web/store/issue/helpers/issue-helper.store.ts | 48 ++++- web/store/issue/root.store.ts | 10 +- web/store/module.store.ts | 17 ++ 25 files changed, 696 insertions(+), 60 deletions(-) create mode 100644 web/components/issues/issue-layouts/spreadsheet/columns/cycle-column.tsx create mode 100644 web/components/issues/issue-layouts/spreadsheet/columns/module-column.tsx diff --git a/apiserver/plane/app/urls/workspace.py b/apiserver/plane/app/urls/workspace.py index 7e64e586a..a70ff18e5 100644 --- a/apiserver/plane/app/urls/workspace.py +++ b/apiserver/plane/app/urls/workspace.py @@ -22,6 +22,8 @@ from plane.app.views import ( WorkspaceUserPropertiesEndpoint, WorkspaceStatesEndpoint, WorkspaceEstimatesEndpoint, + WorkspaceModulesEndpoint, + WorkspaceCyclesEndpoint, ) @@ -219,4 +221,14 @@ urlpatterns = [ WorkspaceEstimatesEndpoint.as_view(), name="workspace-estimate", ), + path( + "workspaces//modules/", + WorkspaceModulesEndpoint.as_view(), + name="workspace-modules", + ), + path( + "workspaces//cycles/", + WorkspaceCyclesEndpoint.as_view(), + name="workspace-cycles", + ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 79c3f9595..d4a13e497 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -49,6 +49,8 @@ from .workspace import ( WorkspaceUserPropertiesEndpoint, WorkspaceStatesEndpoint, WorkspaceEstimatesEndpoint, + WorkspaceModulesEndpoint, + WorkspaceCyclesEndpoint, ) from .state import StateViewSet from .view import ( diff --git a/apiserver/plane/app/views/workspace.py b/apiserver/plane/app/views/workspace.py index f4d3dbbb5..ba9314a75 100644 --- a/apiserver/plane/app/views/workspace.py +++ b/apiserver/plane/app/views/workspace.py @@ -22,6 +22,7 @@ from django.db.models import ( When, Max, IntegerField, + Sum, ) from django.db.models.functions import ExtractWeek, Cast, ExtractDay from django.db.models.fields import DateField @@ -73,6 +74,9 @@ from plane.db.models import ( WorkspaceUserProperties, Estimate, EstimatePoint, + Module, + ModuleLink, + Cycle, ) from plane.app.permissions import ( WorkSpaceBasePermission, @@ -85,6 +89,12 @@ from plane.app.permissions import ( from plane.bgtasks.workspace_invitation_task import workspace_invitation from plane.utils.issue_filters import issue_filters from plane.bgtasks.event_tracking_task import workspace_invite_event +from plane.app.serializers.module import ( + ModuleSerializer, +) +from plane.app.serializers.cycle import ( + CycleSerializer, +) class WorkSpaceViewSet(BaseViewSet): @@ -1490,6 +1500,192 @@ class WorkspaceEstimatesEndpoint(BaseAPIView): return Response(serializer.data, status=status.HTTP_200_OK) +class WorkspaceModulesEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceViewerPermission, + ] + + def get(self, request, slug): + modules = ( + Module.objects.filter(workspace__slug=slug) + .select_related("project") + .select_related("workspace") + .select_related("lead") + .prefetch_related("members") + .prefetch_related( + Prefetch( + "link_module", + queryset=ModuleLink.objects.select_related( + "module", "created_by" + ), + ) + ) + .annotate( + total_issues=Count( + "issue_module", + filter=Q( + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ), + ) + .annotate( + completed_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="completed", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="cancelled", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="started", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="unstarted", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="backlog", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + ) + + serializer = ModuleSerializer(modules, many=True).data + return Response(serializer, status=status.HTTP_200_OK) + + +class WorkspaceCyclesEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceViewerPermission, + ] + + def get(self, request, slug): + cycles = ( + Cycle.objects.filter(workspace__slug=slug) + .select_related("project") + .select_related("workspace") + .select_related("owned_by") + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="cancelled", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="unstarted", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="backlog", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + total_estimates=Sum("issue_cycle__issue__estimate_point") + ) + .annotate( + completed_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + serializer = CycleSerializer(cycles, many=True).data + return Response(serializer, status=status.HTTP_200_OK) + + class WorkspaceUserPropertiesEndpoint(BaseAPIView): permission_classes = [ WorkspaceViewerPermission, diff --git a/packages/types/src/view-props.d.ts b/packages/types/src/view-props.d.ts index 61cc7081b..b6454ae4c 100644 --- a/packages/types/src/view-props.d.ts +++ b/packages/types/src/view-props.d.ts @@ -30,6 +30,10 @@ export type TIssueOrderByOptions = | "-assignees__first_name" | "labels__name" | "-labels__name" + | "modules__name" + | "-modules__name" + | "cycle__name" + | "-cycle__name" | "target_date" | "-target_date" | "estimate_point" @@ -109,6 +113,8 @@ export interface IIssueDisplayProperties { estimate?: boolean; created_on?: boolean; updated_on?: boolean; + modules?: boolean; + cycle?: boolean; } export type TIssueKanbanFilters = { diff --git a/web/components/dropdowns/cycle.tsx b/web/components/dropdowns/cycle.tsx index ddcabb3c9..0104c3c1f 100644 --- a/web/components/dropdowns/cycle.tsx +++ b/web/components/dropdowns/cycle.tsx @@ -86,7 +86,7 @@ export const CycleDropdown: React.FC = observer((props) => { const cycleIds = (getProjectCycleIds(projectId) ?? [])?.filter((cycleId) => { const cycleDetails = getCycleById(cycleId); - return cycleDetails?.status.toLowerCase() != "completed" ? true : false; + return cycleDetails?.status ? (cycleDetails?.status.toLowerCase() != "completed" ? true : false) : true; }); const options: DropdownOptions = cycleIds?.map((cycleId) => { @@ -172,7 +172,10 @@ export const CycleDropdown: React.FC = observer((props) => { - ))} + {ISSUE_DISPLAY_PROPERTIES.map( + (displayProperty) => + handleDisplayPropertyVisibility(displayProperty?.key) && ( + + ) + )} )} diff --git a/web/components/issues/issue-layouts/list/base-list-root.tsx b/web/components/issues/issue-layouts/list/base-list-root.tsx index b1441cff7..6cec6d358 100644 --- a/web/components/issues/issue-layouts/list/base-list-root.tsx +++ b/web/components/issues/issue-layouts/list/base-list-root.tsx @@ -10,6 +10,7 @@ import { IProfileIssues, IProfileIssuesFilter } from "store/issue/profile"; import { IProjectViewIssues, IProjectViewIssuesFilter } from "store/issue/project-views"; import { IDraftIssuesFilter, IDraftIssues } from "store/issue/draft"; import { IArchivedIssuesFilter, IArchivedIssues } from "store/issue/archived"; +import { EIssueActions } from "../types"; // components import { IQuickActionProps } from "./list-view-types"; // constants @@ -18,12 +19,6 @@ import { TCreateModalStoreTypes } from "constants/issue"; // hooks import { useIssues, useUser } from "hooks/store"; -enum EIssueActions { - UPDATE = "update", - DELETE = "delete", - REMOVE = "remove", -} - interface IBaseListRoot { issuesFilter: | IProjectIssuesFilter diff --git a/web/components/issues/issue-layouts/properties/all-properties.tsx b/web/components/issues/issue-layouts/properties/all-properties.tsx index 390bea077..344c0dc7e 100644 --- a/web/components/issues/issue-layouts/properties/all-properties.tsx +++ b/web/components/issues/issue-layouts/properties/all-properties.tsx @@ -1,8 +1,10 @@ +import { useCallback, useMemo } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import { CalendarCheck2, CalendarClock, Layers, Link, Paperclip } from "lucide-react"; +import xor from "lodash/xor"; // hooks -import { useEventTracker, useEstimate, useLabel, useApplication } from "hooks/store"; +import { useEventTracker, useEstimate, useLabel, useIssues } from "hooks/store"; // components import { IssuePropertyLabels } from "../properties/labels"; import { Tooltip } from "@plane/ui"; @@ -12,6 +14,8 @@ import { EstimateDropdown, PriorityDropdown, ProjectMemberDropdown, + ModuleDropdown, + CycleDropdown, StateDropdown, } from "components/dropdowns"; // helpers @@ -20,6 +24,7 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper"; import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types"; // constants import { ISSUE_UPDATED } from "constants/event-tracker"; +import { EIssuesStoreType } from "constants/issue"; export interface IIssueProperties { issue: TIssue; @@ -36,12 +41,39 @@ export const IssueProperties: React.FC = observer((props) => { const { labelMap } = useLabel(); const { captureIssueEvent } = useEventTracker(); const { - router: { workspaceSlug }, - } = useApplication(); + issues: { addModulesToIssue, removeModulesFromIssue }, + } = useIssues(EIssuesStoreType.MODULE); + const { + issues: { addIssueToCycle, removeIssueFromCycle }, + } = useIssues(EIssuesStoreType.CYCLE); // router const router = useRouter(); + const { workspaceSlug, cycleId, moduleId } = router.query; const { areEstimatesEnabledForCurrentProject } = useEstimate(); const currentLayout = `${activeLayout} layout`; + + const issueOperations = useMemo( + () => ({ + addModulesToIssue: async (moduleIds: string[]) => { + if (!workspaceSlug || !issue.project_id || !issue.id) return; + await addModulesToIssue?.(workspaceSlug.toString(), issue.project_id, issue.id, moduleIds); + }, + removeModulesFromIssue: async (moduleIds: string[]) => { + if (!workspaceSlug || !issue.project_id || !issue.id) return; + await removeModulesFromIssue?.(workspaceSlug.toString(), issue.project_id, issue.id, moduleIds); + }, + addIssueToCycle: async (cycleId: string) => { + if (!workspaceSlug || !issue.project_id || !issue.id) return; + await addIssueToCycle?.(workspaceSlug.toString(), issue.project_id, cycleId, [issue.id]); + }, + removeIssueFromCycle: async (cycleId: string) => { + if (!workspaceSlug || !issue.project_id || !issue.id) return; + await removeIssueFromCycle?.(workspaceSlug.toString(), issue.project_id, cycleId, issue.id); + }, + }), + [workspaceSlug, issue, addModulesToIssue, removeModulesFromIssue, addIssueToCycle, removeIssueFromCycle] + ); + const handleState = (stateId: string) => { handleIssues({ ...issue, state_id: stateId }).then(() => { captureIssueEvent({ @@ -98,6 +130,45 @@ export const IssueProperties: React.FC = observer((props) => { }); }; + 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); + + captureIssueEvent({ + eventName: ISSUE_UPDATED, + payload: { ...issue, state: "SUCCESS", element: currentLayout }, + path: router.asPath, + updates: { changed_property: "module_ids", change_details: { module_ids: moduleIds } }, + }); + }, + [issueOperations, captureIssueEvent, currentLayout, router, issue] + ); + + const handleCycle = useCallback( + (cycleId: string | null) => { + if (!issue || issue.cycle_id === cycleId) return; + if (cycleId) issueOperations.addIssueToCycle?.(cycleId); + else issueOperations.removeIssueFromCycle?.(issue.cycle_id ?? ""); + + captureIssueEvent({ + eventName: ISSUE_UPDATED, + payload: { ...issue, state: "SUCCESS", element: currentLayout }, + path: router.asPath, + updates: { changed_property: "cycle", change_details: { cycle_id: cycleId } }, + }); + }, + [issue, issueOperations, captureIssueEvent, currentLayout, router.asPath] + ); + const handleStartDate = (date: Date | null) => { handleIssues({ ...issue, start_date: date ? renderFormattedPayloadDate(date) : null }).then(() => { captureIssueEvent({ @@ -249,6 +320,40 @@ export const IssueProperties: React.FC = observer((props) => { + {/* modules */} + {moduleId === undefined && ( + +
+ +
+
+ )} + + {/* cycles */} + {cycleId === undefined && ( + +
+ +
+
+ )} + {/* estimates */} {areEstimatesEnabledForCurrentProject && ( diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/cycle-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/cycle-column.tsx new file mode 100644 index 000000000..83b46baf6 --- /dev/null +++ b/web/components/issues/issue-layouts/spreadsheet/columns/cycle-column.tsx @@ -0,0 +1,66 @@ +import React, { useCallback } from "react"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +// hooks +import { useEventTracker, useIssues } from "hooks/store"; +// components +import { CycleDropdown } from "components/dropdowns"; +// types +import { TIssue } from "@plane/types"; +// constants +import { EIssuesStoreType } from "constants/issue"; + +type Props = { + issue: TIssue; + onClose: () => void; + disabled: boolean; +}; + +export const SpreadsheetCycleColumn: React.FC = observer((props) => { + // router + const router = useRouter(); + const { workspaceSlug } = router.query; + // props + const { issue, disabled, onClose } = props; + // hooks + const { captureIssueEvent } = useEventTracker(); + const { + issues: { addIssueToCycle, removeIssueFromCycle }, + } = useIssues(EIssuesStoreType.CYCLE); + + const handleCycle = useCallback( + async (cycleId: string | null) => { + console.log("cycleId", cycleId); + if (!workspaceSlug || !issue || issue.cycle_id === cycleId) return; + if (cycleId) await addIssueToCycle(workspaceSlug.toString(), issue.project_id, cycleId, [issue.id]); + else await removeIssueFromCycle(workspaceSlug.toString(), issue.project_id, issue.cycle_id ?? "", issue.id); + captureIssueEvent({ + eventName: "Issue updated", + payload: { + ...issue, + cycle_id: cycleId, + element: "Spreadsheet layout", + }, + updates: { changed_property: "cycle", change_details: { cycle_id: cycleId } }, + path: router.asPath, + }); + }, + [workspaceSlug, issue, addIssueToCycle, removeIssueFromCycle, captureIssueEvent, router.asPath] + ); + + return ( +
+ +
+ ); +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/index.ts b/web/components/issues/issue-layouts/spreadsheet/columns/index.ts index acfd02fc5..3439d398b 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/index.ts +++ b/web/components/issues/issue-layouts/spreadsheet/columns/index.ts @@ -9,4 +9,6 @@ export * from "./priority-column"; export * from "./start-date-column"; export * from "./state-column"; export * from "./sub-issue-column"; -export * from "./updated-on-column"; \ No newline at end of file +export * from "./updated-on-column"; +export * from "./module-column"; +export * from "./cycle-column"; diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/module-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/module-column.tsx new file mode 100644 index 000000000..c688c6e1d --- /dev/null +++ b/web/components/issues/issue-layouts/spreadsheet/columns/module-column.tsx @@ -0,0 +1,79 @@ +import React, { useCallback } from "react"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +import xor from "lodash/xor"; +// hooks +import { useEventTracker, useIssues } from "hooks/store"; +// components +import { ModuleDropdown } from "components/dropdowns"; +// types +import { TIssue } from "@plane/types"; +// constants +import { EIssuesStoreType } from "constants/issue"; + +type Props = { + issue: TIssue; + onClose: () => void; + disabled: boolean; +}; + +export const SpreadsheetModuleColumn: React.FC = observer((props) => { + // router + const router = useRouter(); + const { workspaceSlug } = router.query; + // props + const { issue, disabled, onClose } = props; + // hooks + const { captureIssueEvent } = useEventTracker(); + const { + issues: { addModulesToIssue, removeModulesFromIssue }, + } = useIssues(EIssuesStoreType.MODULE); + + const handleModule = useCallback( + async (moduleIds: string[] | null) => { + if (!workspaceSlug || !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) + addModulesToIssue(workspaceSlug.toString(), issue.project_id, issue.id, modulesToAdd); + if (modulesToRemove.length > 0) + removeModulesFromIssue(workspaceSlug.toString(), issue.project_id, issue.id, modulesToRemove); + + captureIssueEvent({ + eventName: "Issue updated", + payload: { + ...issue, + module_ids: moduleIds, + element: "Spreadsheet layout", + }, + updates: { changed_property: "module_ids", change_details: { module_ids: moduleIds } }, + path: router.asPath, + }); + }, + [workspaceSlug, issue, addModulesToIssue, removeModulesFromIssue, captureIssueEvent, router.asPath] + ); + + return ( +
+ +
+ ); +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx index b07dc93e2..2c5973132 100644 --- a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -242,7 +242,10 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => { >
-
+
{issueDetail.name}
@@ -251,15 +254,15 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => { {/* Rest of the columns */} {SPREADSHEET_PROPERTY_LIST.map((property) => ( - - ))} + + ))} ); }); diff --git a/web/components/modules/module-list-item.tsx b/web/components/modules/module-list-item.tsx index e3913115e..1ecc3974d 100644 --- a/web/components/modules/module-list-item.tsx +++ b/web/components/modules/module-list-item.tsx @@ -207,7 +207,7 @@ export const ModuleListItem: React.FC = observer((props) => {
- +
{moduleDetails.member_ids.length > 0 ? ( diff --git a/web/constants/issue.ts b/web/constants/issue.ts index eed1aafc3..94e57f8d9 100644 --- a/web/constants/issue.ts +++ b/web/constants/issue.ts @@ -116,6 +116,8 @@ export const ISSUE_DISPLAY_PROPERTIES: { { key: "attachment_count", title: "Attachment Count" }, { key: "link", title: "Link" }, { key: "estimate", title: "Estimate" }, + { key: "modules", title: "Modules" }, + { key: "cycle", title: "Cycle" }, ]; export const ISSUE_EXTRA_OPTIONS: { diff --git a/web/constants/spreadsheet.ts b/web/constants/spreadsheet.ts index 1a0097eb8..8d87fb132 100644 --- a/web/constants/spreadsheet.ts +++ b/web/constants/spreadsheet.ts @@ -1,5 +1,5 @@ import { IIssueDisplayProperties, TIssue, TIssueOrderByOptions } from "@plane/types"; -import { LayersIcon, DoubleCircleIcon, UserGroupIcon } from "@plane/ui"; +import { LayersIcon, DoubleCircleIcon, UserGroupIcon, DiceIcon, ContrastIcon } from "@plane/ui"; import { CalendarDays, Link2, Signal, Tag, Triangle, Paperclip, CalendarClock, CalendarCheck } from "lucide-react"; import { FC } from "react"; import { ISvgIcons } from "@plane/ui/src/icons/type"; @@ -10,6 +10,8 @@ import { SpreadsheetDueDateColumn, SpreadsheetEstimateColumn, SpreadsheetLabelColumn, + SpreadsheetModuleColumn, + SpreadsheetCycleColumn, SpreadsheetLinkColumn, SpreadsheetPriorityColumn, SpreadsheetStartDateColumn, @@ -79,6 +81,24 @@ export const SPREADSHEET_PROPERTY_DETAILS: { icon: Tag, Column: SpreadsheetLabelColumn, }, + modules: { + title: "Modules", + ascendingOrderKey: "modules__name", + ascendingOrderTitle: "A", + descendingOrderKey: "-modules__name", + descendingOrderTitle: "Z", + icon: DiceIcon, + Column: SpreadsheetModuleColumn, + }, + cycle: { + title: "Cycle", + ascendingOrderKey: "cycle__name", + ascendingOrderTitle: "A", + descendingOrderKey: "-cycle__name", + descendingOrderTitle: "Z", + icon: ContrastIcon, + Column: SpreadsheetCycleColumn, + }, priority: { title: "Priority", ascendingOrderKey: "priority", @@ -149,6 +169,8 @@ export const SPREADSHEET_PROPERTY_LIST: (keyof IIssueDisplayProperties)[] = [ "priority", "assignee", "labels", + "modules", + "cycle", "start_date", "due_date", "estimate", diff --git a/web/hooks/use-workspace-issue-properties.ts b/web/hooks/use-workspace-issue-properties.ts index f6c1c6c2f..5dd0bc82b 100644 --- a/web/hooks/use-workspace-issue-properties.ts +++ b/web/hooks/use-workspace-issue-properties.ts @@ -1,5 +1,5 @@ import useSWR from "swr"; -import { useEstimate, useLabel, useProjectState } from "./store"; +import { useCycle, useEstimate, useLabel, useModule, useProjectState } from "./store"; export const useWorkspaceIssueProperties = (workspaceSlug: string | string[] | undefined) => { const { fetchWorkspaceLabels } = useLabel(); @@ -8,6 +8,22 @@ export const useWorkspaceIssueProperties = (workspaceSlug: string | string[] | u const { fetchWorkspaceEstimates } = useEstimate(); + const { fetchWorkspaceModules } = useModule(); + + const { fetchWorkspaceCycles } = useCycle(); + + // fetch workspace Modules + useSWR( + workspaceSlug ? `WORKSPACE_MODULES_${workspaceSlug}` : null, + workspaceSlug ? () => fetchWorkspaceModules(workspaceSlug.toString()) : null + ); + + // fetch workspace Cycles + useSWR( + workspaceSlug ? `WORKSPACE_CYCLES_${workspaceSlug}` : null, + workspaceSlug ? () => fetchWorkspaceCycles(workspaceSlug.toString()) : null + ); + // fetch workspace labels useSWR( workspaceSlug ? `WORKSPACE_LABELS_${workspaceSlug}` : null, diff --git a/web/services/cycle.service.ts b/web/services/cycle.service.ts index 6b6d17231..5e13e3b8e 100644 --- a/web/services/cycle.service.ts +++ b/web/services/cycle.service.ts @@ -1,7 +1,7 @@ // services import { APIService } from "services/api.service"; // types -import type { CycleDateCheckData, ICycle, TIssue, TIssueMap } from "@plane/types"; +import type { CycleDateCheckData, ICycle, TIssue } from "@plane/types"; // helpers import { API_BASE_URL } from "helpers/common.helper"; @@ -10,6 +10,14 @@ export class CycleService extends APIService { super(API_BASE_URL); } + async getWorkspaceCycles(workspaceSlug: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/cycles/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async createCycle(workspaceSlug: string, projectId: string, data: any): Promise { return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/`, data) .then((response) => response?.data) diff --git a/web/services/module.service.ts b/web/services/module.service.ts index ebddfb055..1efad8a23 100644 --- a/web/services/module.service.ts +++ b/web/services/module.service.ts @@ -9,6 +9,14 @@ export class ModuleService extends APIService { super(API_BASE_URL); } + async getWorkspaceModules(workspaceSlug: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/modules/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async getModules(workspaceSlug: string, projectId: string): Promise { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/`) .then((response) => response?.data) diff --git a/web/store/cycle.store.ts b/web/store/cycle.store.ts index 917fd8022..16ada6060 100644 --- a/web/store/cycle.store.ts +++ b/web/store/cycle.store.ts @@ -33,6 +33,7 @@ export interface ICycleStore { // actions validateDate: (workspaceSlug: string, projectId: string, payload: CycleDateCheckData) => Promise; // fetch + fetchWorkspaceCycles: (workspaceSlug: string) => Promise; fetchAllCycles: (workspaceSlug: string, projectId: string) => Promise; fetchActiveCycle: (workspaceSlug: string, projectId: string) => Promise; fetchCycleDetails: (workspaceSlug: string, projectId: string, cycleId: string) => Promise; @@ -79,6 +80,7 @@ export class CycleStore implements ICycleStore { currentProjectDraftCycleIds: computed, currentProjectActiveCycleId: computed, // actions + fetchWorkspaceCycles: action, fetchAllCycles: action, fetchActiveCycle: action, fetchCycleDetails: action, @@ -218,6 +220,22 @@ export class CycleStore implements ICycleStore { validateDate = async (workspaceSlug: string, projectId: string, payload: CycleDateCheckData) => await this.cycleService.cycleDateCheck(workspaceSlug, projectId, payload); + /** + * @description fetch all cycles + * @param workspaceSlug + * @returns ICycle[] + */ + fetchWorkspaceCycles = async (workspaceSlug: string) => + await this.cycleService.getWorkspaceCycles(workspaceSlug).then((response) => { + runInAction(() => { + response.forEach((cycle) => { + set(this.cycleMap, [cycle.id], { ...this.cycleMap[cycle.id], ...cycle }); + set(this.fetchedMap, cycle.project_id, true); + }); + }); + return response; + }); + /** * @description fetches all cycles for a project * @param workspaceSlug @@ -337,7 +355,6 @@ export class CycleStore implements ICycleStore { */ addCycleToFavorites = async (workspaceSlug: string, projectId: string, cycleId: string) => { const currentCycle = this.getCycleById(cycleId); - const currentActiveCycle = this.getActiveCycleById(cycleId); try { runInAction(() => { if (currentCycle) set(this.cycleMap, [cycleId, "is_favorite"], true); @@ -362,7 +379,6 @@ export class CycleStore implements ICycleStore { */ removeCycleFromFavorites = async (workspaceSlug: string, projectId: string, cycleId: string) => { const currentCycle = this.getCycleById(cycleId); - const currentActiveCycle = this.getActiveCycleById(cycleId); try { runInAction(() => { if (currentCycle) set(this.cycleMap, [cycleId, "is_favorite"], false); diff --git a/web/store/issue/helpers/issue-filter-helper.store.ts b/web/store/issue/helpers/issue-filter-helper.store.ts index 6516b28fd..8ff45ed09 100644 --- a/web/store/issue/helpers/issue-filter-helper.store.ts +++ b/web/store/issue/helpers/issue-filter-helper.store.ts @@ -191,6 +191,8 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore { key: displayProperties?.key ?? true, created_on: displayProperties?.created_on ?? true, updated_on: displayProperties?.updated_on ?? true, + modules: displayProperties?.modules ?? true, + cycle: displayProperties?.cycle ?? true, }); /** diff --git a/web/store/issue/helpers/issue-helper.store.ts b/web/store/issue/helpers/issue-helper.store.ts index 3fca8aae8..9d498e900 100644 --- a/web/store/issue/helpers/issue-helper.store.ts +++ b/web/store/issue/helpers/issue-helper.store.ts @@ -170,7 +170,7 @@ export class IssueHelperStore implements TIssueHelperStore { * @returns string | string[] of sortable fields to be used for sorting */ populateIssueDataForSorting( - dataType: "state_id" | "label_ids" | "assignee_ids", + dataType: "state_id" | "label_ids" | "assignee_ids" | "module_ids" | "cycle_id", dataIds: string | string[] | null | undefined, order?: "asc" | "desc" ) { @@ -205,6 +205,22 @@ export class IssueHelperStore implements TIssueHelperStore { if (member && member.first_name) dataValues.push(member.first_name.toLocaleLowerCase()); } break; + case "module_ids": + const moduleMap = this.rootStore?.moduleMap; + if (!moduleMap) break; + for (const dataId of dataIdsArray) { + const _module = moduleMap[dataId]; + if (_module && _module.name) dataValues.push(_module.name.toLocaleLowerCase()); + } + break; + case "cycle_id": + const cycleMap = this.rootStore?.cycleMap; + if (!cycleMap) break; + for (const dataId of dataIdsArray) { + const cycle = cycleMap[dataId]; + if (cycle && cycle.name) dataValues.push(cycle.name.toLocaleLowerCase()); + } + break; } return isDataIdsArray ? (order ? orderBy(dataValues, undefined, [order]) : dataValues) : dataValues[0]; @@ -313,6 +329,36 @@ export class IssueHelperStore implements TIssueHelperStore { ["asc", "desc"] ); + case "modules__name": + return orderBy(array, [ + this.getSortOrderToFilterEmptyValues.bind(null, "module_ids"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("module_ids", issue["module_ids"], "asc"), + ]); + case "-modules__name": + return orderBy( + array, + [ + this.getSortOrderToFilterEmptyValues.bind(null, "module_ids"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("module_ids", issue["module_ids"], "desc"), + ], + ["asc", "desc"] + ); + + case "cycle__name": + return orderBy(array, [ + this.getSortOrderToFilterEmptyValues.bind(null, "cycle_id"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("cycle_id", issue["cycle_id"], "asc"), + ]); + case "-cycle__name": + return orderBy( + array, + [ + this.getSortOrderToFilterEmptyValues.bind(null, "cycle_id"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("cycle_id", issue["cycle_id"], "desc"), + ], + ["asc", "desc"] + ); + case "assignees__first_name": return orderBy(array, [ this.getSortOrderToFilterEmptyValues.bind(null, "assignee_ids"), //preferring sorting based on empty values to always keep the empty values below diff --git a/web/store/issue/root.store.ts b/web/store/issue/root.store.ts index ee2e6d84d..def91d200 100644 --- a/web/store/issue/root.store.ts +++ b/web/store/issue/root.store.ts @@ -4,7 +4,7 @@ import isEmpty from "lodash/isEmpty"; import { RootStore } from "../root.store"; import { IStateStore, StateStore } from "../state.store"; // issues data store -import { IIssueLabel, IProject, IState, IUserLite } from "@plane/types"; +import { ICycle, IIssueLabel, IModule, IProject, IState, IUserLite } from "@plane/types"; import { IIssueStore, IssueStore } from "./issue.store"; import { IIssueDetail, IssueDetail } from "./issue-details/root.store"; import { IWorkspaceIssuesFilter, WorkspaceIssuesFilter, IWorkspaceIssues, WorkspaceIssues } from "./workspace"; @@ -39,6 +39,8 @@ export interface IIssueRootStore { workSpaceMemberRolesMap: Record | undefined; memberMap: Record | undefined; projectMap: Record | undefined; + moduleMap: Record | undefined; + cycleMap: Record | undefined; rootStore: RootStore; @@ -91,6 +93,8 @@ export class IssueRootStore implements IIssueRootStore { workSpaceMemberRolesMap: Record | undefined = undefined; memberMap: Record | undefined = undefined; projectMap: Record | undefined = undefined; + moduleMap: Record | undefined = undefined; + cycleMap: Record | undefined = undefined; rootStore: RootStore; @@ -142,6 +146,8 @@ export class IssueRootStore implements IIssueRootStore { memberMap: observable, workSpaceMemberRolesMap: observable, projectMap: observable, + moduleMap: observable, + cycleMap: observable, }); this.rootStore = rootStore; @@ -163,6 +169,8 @@ export class IssueRootStore implements IIssueRootStore { if (!isEmpty(rootStore?.memberRoot?.memberMap)) this.memberMap = rootStore?.memberRoot?.memberMap || undefined; if (!isEmpty(rootStore?.projectRoot?.project?.projectMap)) this.projectMap = rootStore?.projectRoot?.project?.projectMap; + if (!isEmpty(rootStore?.module?.moduleMap)) this.moduleMap = rootStore?.module?.moduleMap; + if (!isEmpty(rootStore?.cycle?.cycleMap)) this.cycleMap = rootStore?.cycle?.cycleMap; }); this.issues = new IssueStore(); diff --git a/web/store/module.store.ts b/web/store/module.store.ts index e550dc7a0..c27ace487 100644 --- a/web/store/module.store.ts +++ b/web/store/module.store.ts @@ -22,6 +22,7 @@ export interface IModuleStore { getProjectModuleIds: (projectId: string) => string[] | null; // actions // fetch + fetchWorkspaceModules: (workspaceSlug: string) => Promise; fetchModules: (workspaceSlug: string, projectId: string) => Promise; fetchModuleDetails: (workspaceSlug: string, projectId: string, moduleId: string) => Promise; // crud @@ -73,6 +74,7 @@ export class ModulesStore implements IModuleStore { // computed projectModuleIds: computed, // actions + fetchWorkspaceModules: action, fetchModules: action, fetchModuleDetails: action, createModule: action, @@ -125,6 +127,21 @@ export class ModulesStore implements IModuleStore { return projectModuleIds; }); + /** + * @description fetch all modules + * @param workspaceSlug + * @returns IModule[] + */ + fetchWorkspaceModules = async (workspaceSlug: string) => + await this.moduleService.getWorkspaceModules(workspaceSlug).then((response) => { + runInAction(() => { + response.forEach((module) => { + set(this.moduleMap, [module.id], { ...this.moduleMap[module.id], ...module }); + }); + }); + return response; + }); + /** * @description fetch all modules * @param workspaceSlug