[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 <anmolsinghbhatia@plane.so>
This commit is contained in:
guru_sainath 2024-02-21 19:20:46 +05:30 committed by GitHub
parent 56f4df4cb5
commit ac6e710623
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 696 additions and 60 deletions

View File

@ -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/<str:slug>/modules/",
WorkspaceModulesEndpoint.as_view(),
name="workspace-modules",
),
path(
"workspaces/<str:slug>/cycles/",
WorkspaceCyclesEndpoint.as_view(),
name="workspace-cycles",
),
]

View File

@ -49,6 +49,8 @@ from .workspace import (
WorkspaceUserPropertiesEndpoint,
WorkspaceStatesEndpoint,
WorkspaceEstimatesEndpoint,
WorkspaceModulesEndpoint,
WorkspaceCyclesEndpoint,
)
from .state import StateViewSet
from .view import (

View File

@ -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,

View File

@ -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 = {

View File

@ -86,7 +86,7 @@ export const CycleDropdown: React.FC<Props> = 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<Props> = observer((props) => {
<button
ref={setReferenceElement}
type="button"
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
className={cn(
"clickable block h-full w-full outline-none hover:bg-custom-background-80",
buttonContainerClassName
)}
onClick={handleOnClick}
>
{button}
@ -182,7 +185,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement}
type="button"
className={cn(
"block h-full max-w-full outline-none",
"clickable block h-full max-w-full outline-none hover:bg-custom-background-80",
{
"cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled,

View File

@ -278,7 +278,10 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
<button
ref={setReferenceElement}
type="button"
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
className={cn(
"clickable block h-full w-full outline-none hover:bg-custom-background-80",
buttonContainerClassName
)}
onClick={handleOnClick}
>
{button}
@ -288,7 +291,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement}
type="button"
className={cn(
"clickable block h-full max-w-full outline-none",
"clickable block h-full max-w-full outline-none hover:bg-custom-background-80",
{
"cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled,

View File

@ -1,5 +1,6 @@
import React, { useState } from "react";
import { observer } from "mobx-react-lite";
import xor from "lodash/xor";
// hooks
import { useIssueDetail } from "hooks/store";
// components
@ -36,16 +37,22 @@ export const IssueModuleSelect: React.FC<TIssueModuleSelect> = observer((props)
if (!issue || !issue.module_ids) return;
setIsUpdating(true);
const updatedModuleIds = xor(issue.module_ids, moduleIds);
const modulesToAdd: string[] = [];
const modulesToRemove: string[] = [];
if (moduleIds.length === 0)
await issueOperations.removeModulesFromIssue?.(workspaceSlug, projectId, issueId, issue.module_ids);
else if (moduleIds.length > issue.module_ids.length) {
const newModuleIds = moduleIds.filter((m) => !issue.module_ids?.includes(m));
await issueOperations.addModulesToIssue?.(workspaceSlug, projectId, issueId, newModuleIds);
} else if (moduleIds.length < issue.module_ids.length) {
const removedModuleIds = issue.module_ids.filter((m) => !moduleIds.includes(m));
await issueOperations.removeModulesFromIssue?.(workspaceSlug, projectId, issueId, removedModuleIds);
for (const moduleId of updatedModuleIds) {
if (issue.module_ids.includes(moduleId)) {
modulesToRemove.push(moduleId);
} else {
modulesToAdd.push(moduleId);
}
}
if (modulesToRemove.length > 0)
await issueOperations.removeModulesFromIssue?.(workspaceSlug, projectId, issueId, modulesToRemove);
if (modulesToAdd.length > 0)
await issueOperations.addModulesToIssue?.(workspaceSlug, projectId, issueId, modulesToAdd);
setIsUpdating(false);
};

View File

@ -1,6 +1,6 @@
import React from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
// components
import { FilterHeader } from "../helpers/filter-header";
// types
@ -14,10 +14,19 @@ type Props = {
};
export const FilterDisplayProperties: React.FC<Props> = observer((props) => {
const router = useRouter();
const { moduleId, cycleId } = router.query;
const { displayProperties, handleUpdate } = props;
const [previewEnabled, setPreviewEnabled] = React.useState(true);
const handleDisplayPropertyVisibility = (key: keyof IIssueDisplayProperties): boolean => {
const visibility = true;
if (key === "modules" && moduleId) return false;
if (key === "cycle" && cycleId) return false;
return visibility;
};
return (
<>
<FilterHeader
@ -27,24 +36,27 @@ export const FilterDisplayProperties: React.FC<Props> = observer((props) => {
/>
{previewEnabled && (
<div className="mt-1 flex flex-wrap items-center gap-2">
{ISSUE_DISPLAY_PROPERTIES.map((displayProperty) => (
<button
key={displayProperty.key}
type="button"
className={`rounded border px-2 py-0.5 text-xs transition-all ${
displayProperties?.[displayProperty.key]
? "border-custom-primary-100 bg-custom-primary-100 text-white"
: "border-custom-border-200 hover:bg-custom-background-80"
}`}
onClick={() =>
handleUpdate({
[displayProperty.key]: !displayProperties?.[displayProperty.key],
})
}
>
{displayProperty.title}
</button>
))}
{ISSUE_DISPLAY_PROPERTIES.map(
(displayProperty) =>
handleDisplayPropertyVisibility(displayProperty?.key) && (
<button
key={displayProperty.key}
type="button"
className={`rounded border px-2 py-0.5 text-xs transition-all ${
displayProperties?.[displayProperty.key]
? "border-custom-primary-100 bg-custom-primary-100 text-white"
: "border-custom-border-200 hover:bg-custom-background-80"
}`}
onClick={() =>
handleUpdate({
[displayProperty.key]: !displayProperties?.[displayProperty.key],
})
}
>
{displayProperty.title}
</button>
)
)}
</div>
)}
</>

View File

@ -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

View File

@ -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<IIssueProperties> = 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<IIssueProperties> = 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<IIssueProperties> = observer((props) => {
</div>
</WithDisplayPropertiesHOC>
{/* modules */}
{moduleId === undefined && (
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="modules">
<div className="h-5">
<ModuleDropdown
projectId={issue?.project_id}
value={issue?.module_ids ?? []}
onChange={handleModule}
disabled={isReadOnly}
multiple
buttonVariant="border-with-text"
showCount={true}
showTooltip
/>
</div>
</WithDisplayPropertiesHOC>
)}
{/* cycles */}
{cycleId === undefined && (
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="cycle">
<div className="h-5">
<CycleDropdown
projectId={issue?.project_id}
value={issue?.cycle_id}
onChange={handleCycle}
disabled={isReadOnly}
buttonVariant="border-with-text"
showTooltip
/>
</div>
</WithDisplayPropertiesHOC>
)}
{/* estimates */}
{areEstimatesEnabledForCurrentProject && (
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="estimate">

View File

@ -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<Props> = 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 (
<div className="h-11 border-b-[0.5px] border-custom-border-200">
<CycleDropdown
projectId={issue.project_id}
value={issue.cycle_id}
onChange={handleCycle}
disabled={disabled}
placeholder="Select cycle"
buttonVariant="transparent-with-text"
buttonContainerClassName="w-full relative flex items-center p-2"
buttonClassName="relative border-[0.5px] border-custom-border-400 h-4.5"
onClose={onClose}
/>
</div>
);
});

View File

@ -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";
export * from "./updated-on-column";
export * from "./module-column";
export * from "./cycle-column";

View File

@ -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<Props> = 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 (
<div className="h-11 border-b-[0.5px] border-custom-border-200">
<ModuleDropdown
projectId={issue?.project_id}
value={issue?.module_ids ?? []}
onChange={handleModule}
disabled={disabled}
placeholder="Select modules"
buttonVariant="transparent-with-text"
buttonContainerClassName="w-full relative flex items-center p-2"
buttonClassName="relative border-[0.5px] border-custom-border-400 h-4.5"
onClose={onClose}
multiple
showCount={true}
showTooltip
/>
</div>
);
});

View File

@ -242,7 +242,10 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
>
<div className="w-full overflow-hidden">
<Tooltip tooltipHeading="Title" tooltipContent={issueDetail.name}>
<div className="h-full w-full cursor-pointer truncate px-4 py-2.5 text-left text-[0.825rem] text-custom-text-100" tabIndex={-1}>
<div
className="h-full w-full cursor-pointer truncate px-4 py-2.5 text-left text-[0.825rem] text-custom-text-100"
tabIndex={-1}
>
{issueDetail.name}
</div>
</Tooltip>
@ -251,15 +254,15 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
</td>
{/* Rest of the columns */}
{SPREADSHEET_PROPERTY_LIST.map((property) => (
<IssueColumn
displayProperties={displayProperties}
issueDetail={issueDetail}
disableUserActions={disableUserActions}
property={property}
handleIssues={handleIssues}
isEstimateEnabled={isEstimateEnabled}
/>
))}
<IssueColumn
displayProperties={displayProperties}
issueDetail={issueDetail}
disableUserActions={disableUserActions}
property={property}
handleIssues={handleIssues}
isEstimateEnabled={isEstimateEnabled}
/>
))}
</>
);
});

View File

@ -207,7 +207,7 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
</div>
<div className="flex-shrink-0 relative flex items-center gap-3">
<Tooltip tooltipContent={`${moduleDetails.member_ids.length} Members`}>
<Tooltip tooltipContent={`${moduleDetails?.member_ids?.length || 0} Members`}>
<div className="flex w-10 cursor-default items-center justify-center gap-1">
{moduleDetails.member_ids.length > 0 ? (
<AvatarGroup showTooltip={false}>

View File

@ -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: {

View File

@ -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",

View File

@ -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,

View File

@ -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<ICycle[]> {
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<ICycle> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/`, data)
.then((response) => response?.data)

View File

@ -9,6 +9,14 @@ export class ModuleService extends APIService {
super(API_BASE_URL);
}
async getWorkspaceModules(workspaceSlug: string): Promise<IModule[]> {
return this.get(`/api/workspaces/${workspaceSlug}/modules/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getModules(workspaceSlug: string, projectId: string): Promise<IModule[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/`)
.then((response) => response?.data)

View File

@ -33,6 +33,7 @@ export interface ICycleStore {
// actions
validateDate: (workspaceSlug: string, projectId: string, payload: CycleDateCheckData) => Promise<any>;
// fetch
fetchWorkspaceCycles: (workspaceSlug: string) => Promise<ICycle[]>;
fetchAllCycles: (workspaceSlug: string, projectId: string) => Promise<undefined | ICycle[]>;
fetchActiveCycle: (workspaceSlug: string, projectId: string) => Promise<undefined | ICycle[]>;
fetchCycleDetails: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<ICycle>;
@ -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);

View File

@ -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,
});
/**

View File

@ -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

View File

@ -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<string, IWorkspaceMembership> | undefined;
memberMap: Record<string, IUserLite> | undefined;
projectMap: Record<string, IProject> | undefined;
moduleMap: Record<string, IModule> | undefined;
cycleMap: Record<string, ICycle> | undefined;
rootStore: RootStore;
@ -91,6 +93,8 @@ export class IssueRootStore implements IIssueRootStore {
workSpaceMemberRolesMap: Record<string, IWorkspaceMembership> | undefined = undefined;
memberMap: Record<string, IUserLite> | undefined = undefined;
projectMap: Record<string, IProject> | undefined = undefined;
moduleMap: Record<string, IModule> | undefined = undefined;
cycleMap: Record<string, ICycle> | 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();

View File

@ -22,6 +22,7 @@ export interface IModuleStore {
getProjectModuleIds: (projectId: string) => string[] | null;
// actions
// fetch
fetchWorkspaceModules: (workspaceSlug: string) => Promise<IModule[]>;
fetchModules: (workspaceSlug: string, projectId: string) => Promise<undefined | IModule[]>;
fetchModuleDetails: (workspaceSlug: string, projectId: string, moduleId: string) => Promise<IModule>;
// 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