[WEB-5243]feat: milestone grouping for work items (#4596)

This commit is contained in:
Vamsi Krishna
2025-10-29 20:24:11 +05:30
committed by GitHub
parent e42988ab79
commit 640afe788c
57 changed files with 767 additions and 298 deletions
+66 -22
View File
@@ -134,9 +134,13 @@ class IssueCreateSerializer(BaseSerializer):
# Validate description content for security
if "description_html" in attrs and attrs["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(attrs["description_html"])
is_valid, error_msg, sanitized_html = validate_html_content(
attrs["description_html"]
)
if not is_valid:
raise serializers.ValidationError({"error": "html content is not valid"})
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
# Update the attrs with sanitized HTML if available
if sanitized_html is not None:
attrs["description_html"] = sanitized_html
@@ -144,7 +148,9 @@ class IssueCreateSerializer(BaseSerializer):
if "description_binary" in attrs and attrs["description_binary"]:
is_valid, error_msg = validate_binary_data(attrs["description_binary"])
if not is_valid:
raise serializers.ValidationError({"description_binary": "Invalid binary data"})
raise serializers.ValidationError(
{"description_binary": "Invalid binary data"}
)
# Validate assignees are from project
if attrs.get("assignee_ids", []):
@@ -206,7 +212,9 @@ class IssueCreateSerializer(BaseSerializer):
pk=attrs.get("state").id,
).exists()
):
raise serializers.ValidationError("State is not valid please pass a valid state_id")
raise serializers.ValidationError(
"State is not valid please pass a valid state_id"
)
# Check parent issue is from workspace as it can be cross workspace
if (
@@ -216,7 +224,9 @@ class IssueCreateSerializer(BaseSerializer):
pk=attrs.get("parent").id,
).exists()
):
raise serializers.ValidationError("Parent is not valid issue_id please pass a valid issue_id")
raise serializers.ValidationError(
"Parent is not valid issue_id please pass a valid issue_id"
)
if (
attrs.get("estimate_point")
@@ -225,7 +235,9 @@ class IssueCreateSerializer(BaseSerializer):
pk=attrs.get("estimate_point").id,
).exists()
):
raise serializers.ValidationError("Estimate point is not valid please pass a valid estimate_point_id")
raise serializers.ValidationError(
"Estimate point is not valid please pass a valid estimate_point_id"
)
return attrs
@@ -443,7 +455,11 @@ class IssueActivitySerializer(BaseSerializer):
source_data = serializers.SerializerMethodField()
def get_source_data(self, obj):
if hasattr(obj, "issue") and hasattr(obj.issue, "source_data") and obj.issue.source_data:
if (
hasattr(obj, "issue")
and hasattr(obj.issue, "source_data")
and obj.issue.source_data
):
return {
"source": obj.issue.source_data[0].source,
"source_email": obj.issue.source_data[0].source_email,
@@ -493,8 +509,12 @@ class IssueLabelSerializer(BaseSerializer):
class IssueRelationSerializer(BaseSerializer):
id = serializers.UUIDField(source="related_issue.id", read_only=True)
project_id = serializers.PrimaryKeyRelatedField(source="related_issue.project_id", read_only=True)
sequence_id = serializers.IntegerField(source="related_issue.sequence_id", read_only=True)
project_id = serializers.PrimaryKeyRelatedField(
source="related_issue.project_id", read_only=True
)
sequence_id = serializers.IntegerField(
source="related_issue.sequence_id", read_only=True
)
name = serializers.CharField(source="related_issue.name", read_only=True)
type_id = serializers.UUIDField(source="related_issue.type.id", read_only=True)
relation_type = serializers.CharField(read_only=True)
@@ -539,7 +559,9 @@ class IssueRelationSerializer(BaseSerializer):
class RelatedIssueSerializer(BaseSerializer):
id = serializers.UUIDField(source="issue.id", read_only=True)
project_id = serializers.PrimaryKeyRelatedField(source="issue.project_id", read_only=True)
project_id = serializers.PrimaryKeyRelatedField(
source="issue.project_id", read_only=True
)
sequence_id = serializers.IntegerField(source="issue.sequence_id", read_only=True)
name = serializers.CharField(source="issue.name", read_only=True)
type_id = serializers.UUIDField(source="issue.type.id", read_only=True)
@@ -698,17 +720,25 @@ class IssueLinkSerializer(BaseSerializer):
# Validation if url already exists
def create(self, validated_data):
if IssueLink.objects.filter(url=validated_data.get("url"), issue_id=validated_data.get("issue_id")).exists():
raise serializers.ValidationError({"error": "URL already exists for this Issue"})
if IssueLink.objects.filter(
url=validated_data.get("url"), issue_id=validated_data.get("issue_id")
).exists():
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
return IssueLink.objects.create(**validated_data)
def update(self, instance, validated_data):
if (
IssueLink.objects.filter(url=validated_data.get("url"), issue_id=instance.issue_id)
IssueLink.objects.filter(
url=validated_data.get("url"), issue_id=instance.issue_id
)
.exclude(pk=instance.id)
.exists()
):
raise serializers.ValidationError({"error": "URL already exists for this Issue"})
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
return super().update(instance, validated_data)
@@ -902,6 +932,7 @@ class CustomerSerializer(DynamicBaseSerializer):
class IssueSerializer(DynamicBaseSerializer):
# ids
cycle_id = serializers.PrimaryKeyRelatedField(read_only=True)
milestone_id = serializers.PrimaryKeyRelatedField(read_only=True)
module_ids = serializers.ListField(child=serializers.UUIDField(), required=False)
# Many to many
@@ -915,11 +946,15 @@ class IssueSerializer(DynamicBaseSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Gate milestone_id behind MILESTONES feature flag. Omit key entirely when disabled.
# Gate milestone_id behind MILESTONES feature flag. Only include when enabled.
slug = self.context.get("slug", None)
user_id = self.context.get("user_id", None)
if slug and user_id and not check_workspace_feature_flag(
feature_key=FeatureFlag.MILESTONES, slug=slug, user_id=user_id
if not (
slug
and user_id
and check_workspace_feature_flag(
feature_key=FeatureFlag.MILESTONES, slug=slug, user_id=user_id
)
):
self.fields.pop("milestone_id", None)
@@ -939,6 +974,7 @@ class IssueSerializer(DynamicBaseSerializer):
"project_id",
"parent_id",
"cycle_id",
"milestone_id",
"module_ids",
"label_ids",
"assignee_ids",
@@ -1014,13 +1050,19 @@ class IssueListDetailSerializer(serializers.Serializer):
),
}
# Gate milestone_id behind MILESTONES feature flag. Omit key entirely when disabled.
# Gate milestone_id behind MILESTONES feature flag. Only add when enabled.
slug = self.context.get("slug", None)
user_id = self.context.get("user_id", None)
if slug and user_id and not check_workspace_feature_flag(
feature_key=FeatureFlag.MILESTONES, slug=slug, user_id=user_id
if (
slug
and user_id
and check_workspace_feature_flag(
feature_key=FeatureFlag.MILESTONES, slug=slug, user_id=user_id
)
):
data.pop("milestone_id", None)
data["milestone_id"] = (
instance.milestone_id if hasattr(instance, "milestone_id") else None
)
# Handle expanded fields only when requested - using direct field access
if self.expand:
@@ -1125,7 +1167,9 @@ class IssueDetailSerializer(IssueSerializer):
class IssuePublicSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project")
state_detail = StateLiteSerializer(read_only=True, source="state")
reactions = IssueReactionSerializer(read_only=True, many=True, source="issue_reactions")
reactions = IssueReactionSerializer(
read_only=True, many=True, source="issue_reactions"
)
votes = IssueVoteSerializer(read_only=True, many=True)
class Meta:
@@ -447,6 +447,7 @@ class EpicDetailSerializer(EpicSerializer):
child=serializers.UUIDField(), required=False
)
initiative_ids = serializers.ListField(read_only=True)
milestone_id = serializers.UUIDField(read_only=True)
class Meta(EpicSerializer.Meta):
fields = EpicSerializer.Meta.fields + [
@@ -455,6 +456,7 @@ class EpicDetailSerializer(EpicSerializer):
"customer_ids",
"customer_request_ids",
"initiative_ids",
"milestone_id",
]
read_only_fields = fields
@@ -15,6 +15,7 @@ class MilestoneWorkItemResponseSerializer(BaseSerializer):
label_ids = serializers.SerializerMethodField()
assignee_ids = serializers.SerializerMethodField()
type_id = serializers.UUIDField(source="type.id", read_only=True)
is_epic = serializers.SerializerMethodField()
def get_label_ids(self, obj):
return [label.label_id for label in obj.label_issue.all()]
@@ -22,6 +23,12 @@ class MilestoneWorkItemResponseSerializer(BaseSerializer):
def get_assignee_ids(self, obj):
return [assignee.assignee_id for assignee in obj.issue_assignee.all()]
def get_is_epic(self, obj):
"""Return True if the work item is an epic based on its type."""
if hasattr(obj, "type") and obj.type:
return obj.type.is_epic
return False
class Meta:
model = Issue
fields = [
@@ -36,6 +43,7 @@ class MilestoneWorkItemResponseSerializer(BaseSerializer):
"label_ids",
"assignee_ids",
"type_id",
"is_epic",
]
read_only_fields = fields
+17 -11
View File
@@ -31,7 +31,12 @@ from rest_framework.response import Response
# Module imports
from plane.app.permissions import allow_permission, ROLE
from plane.bgtasks.issue_activities_task import issue_activity
from plane.ee.models import EpicUserProperties, ProjectFeature, InitiativeEpic, MilestoneIssue
from plane.ee.models import (
EpicUserProperties,
ProjectFeature,
InitiativeEpic,
MilestoneIssue,
)
from plane.db.models import (
Issue,
FileAsset,
@@ -54,7 +59,10 @@ from plane.ee.serializers import (
EpicUserPropertySerializer,
IssueTypeSerializer,
)
from plane.payment.flags.flag_decorator import check_feature_flag, check_workspace_feature_flag
from plane.payment.flags.flag_decorator import (
check_feature_flag,
check_workspace_feature_flag,
)
from plane.payment.flags.flag import FeatureFlag
from plane.utils.grouper import issue_group_values, issue_on_results
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
@@ -185,6 +193,13 @@ class EpicViewSet(BaseViewSet):
Value([], output_field=ArrayField(UUIDField())),
)
)
.annotate(
milestone_id=Subquery(
MilestoneIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
).values("milestone_id")[:1]
)
)
.prefetch_related(
Prefetch(
"initiative_epics",
@@ -194,15 +209,6 @@ class EpicViewSet(BaseViewSet):
.prefetch_related("assignees", "labels")
)
# Annotate milestone_id if the MILESTONES feature flag is enabled
if check_workspace_feature_flag(feature_key=FeatureFlag.MILESTONES, slug=self.kwargs.get("slug"), user_id=str(self.request.user.id)):
queryset = queryset.annotate(
milestone_id=Subquery(
MilestoneIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("milestone_id")[
:1
]
)
)
return queryset
def get_queryset(self):
+28 -17
View File
@@ -1,4 +1,6 @@
# Python imports
import re
# Django imports
from plane.payment.flags.flag import FeatureFlag
from plane.payment.flags.flag_decorator import check_feature_flag
@@ -12,7 +14,6 @@ from rest_framework.response import Response
# Module imports
from plane.app.views import BaseAPIView
from plane.db.models import Issue
from plane.utils.issue_search import search_issues
class MilestoneWorkItemsSearchEndpoint(BaseAPIView):
@@ -20,22 +21,31 @@ class MilestoneWorkItemsSearchEndpoint(BaseAPIView):
@check_feature_flag(FeatureFlag.MILESTONES)
def get(self, request, slug, project_id):
"""Return issues in the project that are not linked to any active milestone.
"""Return work in the project that are not linked to any active milestone.
Optional query params:
- search: string to filter issues by name/sequence/project identifier
- search: string to filter work items by name/sequence/project identifier
- milestone_id: if milestone_id is provided, include work items that are linked to the milestone
"""
query = request.query_params.get("search", None)
milestone_id = request.query_params.get("milestone_id", None)
search = request.query_params.get("search", None)
fields = ["name", "sequence_id", "project__identifier"]
issues = (
Issue.objects.filter(
q = Q()
for field in fields:
if field == "sequence_id":
sequences = re.findall(r"\b\d+\b", search)
for sequence_id in sequences:
q |= Q(**{"sequence_id": sequence_id})
else:
q |= Q(**{f"{field}__icontains": search})
work_items = (
Issue.issue_and_epics_objects.filter(
q,
workspace__slug=slug,
project_id=project_id,
deleted_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
)
.filter(Q(state__is_triage=False) | Q(state__isnull=True))
.annotate(
active_milestones=Count(
"issue_milestone",
@@ -46,16 +56,17 @@ class MilestoneWorkItemsSearchEndpoint(BaseAPIView):
distinct=True,
)
)
.filter(active_milestones=0)
.filter(
Q(issue_milestone__milestone_id=milestone_id)
if milestone_id
else Q(active_milestones=0)
)
.select_related("state")
.accessible_to(self.request.user.id, slug)
.accessible_to(request.user.id, slug)
.distinct()
)
if query:
issues = search_issues(query, issues)
results = issues.values(
results = work_items.values(
"name",
"id",
"start_date",
@@ -68,5 +79,5 @@ class MilestoneWorkItemsSearchEndpoint(BaseAPIView):
"state__group",
"state__color",
"type_id",
)
)[:20]
return Response(list(results), status=status.HTTP_200_OK)
@@ -38,9 +38,13 @@ class MilestoneWorkItemsEndpoint(BaseAPIView):
).values_list("issue_id", flat=True)
# Base queryset with basic filters
issue_queryset = Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id, pk__in=workitem_ids
).prefetch_related("labels", "assignees")
issue_queryset = (
Issue.issue_and_epics_objects.filter(
workspace__slug=slug, project_id=project_id, pk__in=workitem_ids
)
.select_related("type")
.prefetch_related("labels", "assignees")
)
serializer = MilestoneWorkItemResponseSerializer(issue_queryset, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -105,8 +109,12 @@ class MilestoneWorkItemsEndpoint(BaseAPIView):
).values_list("issue_id", flat=True)
# Base queryset with basic filters
issue_queryset = Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id, pk__in=workitem_ids
).prefetch_related("labels", "assignees")
issue_queryset = (
Issue.issue_and_epics_objects.filter(
workspace__slug=slug, project_id=project_id, pk__in=workitem_ids
)
.select_related("type")
.prefetch_related("labels", "assignees")
)
serializer = MilestoneWorkItemResponseSerializer(issue_queryset, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -21,7 +21,9 @@ import type { CoreRootStore } from "../root.store";
// constants
// helpers
export type TIssueDisplayFilterOptions = Exclude<TIssueGroupByOptions, null | "team_project"> | "target_date";
export type TIssueDisplayFilterOptions =
| Exclude<TIssueGroupByOptions, null | "team_project" | "milestone">
| "target_date";
export enum EIssueGroupedAction {
ADD = "ADD",
+1 -1
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useRef } from "react";
import React, { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { useParams, useRouter } from "next/navigation";
// plane imports
+1 -1
View File
@@ -20,7 +20,7 @@ export const getDate = (date: string | Date | undefined | null): Date | undefine
if (!isNumber(year) || !isNumber(month) || !isNumber(day)) return;
return new Date(year, month - 1, day);
} catch (e) {
} catch {
return undefined;
}
};
@@ -0,0 +1,9 @@
import type { TIssue } from "@plane/types";
export type TDateAlertProps = {
date: string;
workItem: TIssue;
projectId: string;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const DateAlert = (props: TDateAlertProps) => <></>;
@@ -1,6 +1,7 @@
import type { FC } from "react";
import { CalendarDays, LayersIcon, Link2, Paperclip } from "lucide-react";
// types
import { ISSUE_GROUP_BY_OPTIONS } from "@plane/constants";
import type { ISvgIcons } from "@plane/propel/icons";
import {
CycleIcon,
@@ -13,7 +14,14 @@ import {
PriorityPropertyIcon,
StartDatePropertyIcon,
} from "@plane/propel/icons";
import type { IGroupByColumn, IIssueDisplayProperties, TGetColumns, TSpreadsheetColumn } from "@plane/types";
import type {
IGroupByColumn,
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
TGetColumns,
TIssueGroupByOptions,
TSpreadsheetColumn,
} from "@plane/types";
// components
import {
SpreadsheetAssigneeColumn,
@@ -96,3 +104,13 @@ export const SPREADSHEET_COLUMNS: { [key in keyof IIssueDisplayProperties]: TSpr
updated_on: SpreadsheetUpdatedOnColumn,
attachment_count: SpreadsheetAttachmentColumn,
};
export const useGroupByOptions = (
options: TIssueGroupByOptions[]
): {
key: TIssueGroupByOptions;
titleTranslationKey: string;
}[] => {
const groupByOptions = ISSUE_GROUP_BY_OPTIONS.filter((option) => options.includes(option.key));
return groupByOptions;
};
@@ -0,0 +1,3 @@
import type { IIssueDisplayFilterOptions } from "@plane/types";
export const getEnabledDisplayFilters = (displayFilters: IIssueDisplayFilterOptions) => displayFilters;
@@ -1,6 +1,6 @@
"use client";
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { Rocket, Search, X } from "lucide-react";
import { Combobox, Dialog, Transition } from "@headlessui/react";
// i18n
@@ -65,12 +65,14 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
const { isMobile } = usePlatformOS();
const debouncedSearchTerm: string = useDebounce(searchTerm, 500);
const { baseTabIndex } = getTabIndex(undefined, isMobile);
const hasInitializedSelection = useRef(false);
const handleClose = () => {
onClose();
setSearchTerm("");
setSelectedIssues([]);
setIsWorkspaceLevel(false);
hasInitializedSelection.current = false;
};
const onSubmit = async () => {
@@ -117,10 +119,11 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
};
useEffect(() => {
if (selectedWorkItemIds) {
if (isOpen && !hasInitializedSelection.current && selectedWorkItemIds && issues.length > 0) {
setSelectedIssues(issues.filter((issue) => selectedWorkItemIds.includes(issue.id)));
hasInitializedSelection.current = true;
}
}, [isOpen, selectedWorkItemIds]);
}, [isOpen, issues, selectedWorkItemIds]);
useEffect(() => {
handleSearch();
@@ -9,10 +9,7 @@ import { mutate } from "swr";
// icons
import { ArrowLeft, Check, List, Settings } from "lucide-react";
import { Button } from "@plane/propel/button";
import {
ListLayoutIcon,
MembersPropertyIcon,
} from "@plane/propel/icons";
import { ListLayoutIcon, MembersPropertyIcon } from "@plane/propel/icons";
// types
import type { IJiraImporterForm } from "@plane/types";
// ui
@@ -36,6 +36,7 @@ import { useProjectState } from "@/hooks/store/use-project-state";
// components
import { WorkItemAdditionalSidebarProperties } from "@/plane-web/components/issues/issue-details/additional-properties";
import { IssueParentSelectRoot } from "@/plane-web/components/issues/issue-details/parent-select-root";
import { DateAlert } from "@/plane-web/components/issues/issue-details/sidebar/date-alert";
import { IssueWorklogProperty } from "@/plane-web/components/issues/worklog/property";
import { IssueCycleSelect } from "./cycle-select";
import { IssueLabel } from "./label";
@@ -186,28 +187,31 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<DueDatePropertyIcon className="h-4 w-4 flex-shrink-0" />
<span>{t("common.order_by.due_date")}</span>
</div>
<DateDropdown
placeholder={t("issue.add.due_date")}
value={issue.target_date}
onChange={(val) =>
issueOperations.update(workspaceSlug, projectId, issueId, {
target_date: val ? renderFormattedPayloadDate(val) : null,
})
}
minDate={minDate ?? undefined}
disabled={!isEditable}
buttonVariant="transparent-with-text"
className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left"
buttonClassName={cn("text-sm", {
"text-custom-text-400": !issue.target_date,
"text-red-500": shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group),
})}
hideIcon
clearIconClassName="h-3 w-3 hidden group-hover:inline !text-custom-text-100"
// TODO: add this logic
// showPlaceholderIcon
/>
<div className="flex items-center gap-2">
<DateDropdown
placeholder={t("issue.add.due_date")}
value={issue.target_date}
onChange={(val) =>
issueOperations.update(workspaceSlug, projectId, issueId, {
target_date: val ? renderFormattedPayloadDate(val) : null,
})
}
minDate={minDate ?? undefined}
disabled={!isEditable}
buttonVariant="transparent-with-text"
className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left"
buttonClassName={cn("text-sm", {
"text-custom-text-400": !issue.target_date,
"text-red-500": shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group),
})}
hideIcon
clearIconClassName="h-3 w-3 hidden group-hover:inline !text-custom-text-100"
// TODO: add this logic
// showPlaceholderIcon
/>
{issue.target_date && <DateAlert date={issue.target_date} workItem={issue} projectId={projectId} />}
</div>
</div>
{projectId && areEstimateEnabledByProjectId(projectId) && (
@@ -1,10 +1,10 @@
import React, { useState } from "react";
import { observer } from "mobx-react";
import { ISSUE_GROUP_BY_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { IIssueDisplayFilterOptions, TIssueGroupByOptions } from "@plane/types";
// components
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
import { useGroupByOptions } from "@/plane-web/components/issues/issue-layouts/utils";
type Props = {
displayFilters: IIssueDisplayFilterOptions | undefined;
@@ -22,6 +22,8 @@ export const FilterGroupBy: React.FC<Props> = observer((props) => {
const selectedGroupBy = displayFilters?.group_by ?? null;
const selectedSubGroupBy = displayFilters?.sub_group_by ?? null;
const options = useGroupByOptions(groupByOptions);
return (
<>
<FilterHeader
@@ -31,7 +33,7 @@ export const FilterGroupBy: React.FC<Props> = observer((props) => {
/>
{previewEnabled && (
<div>
{ISSUE_GROUP_BY_OPTIONS.filter((option) => groupByOptions.includes(option.key)).map((groupBy) => {
{options.map((groupBy) => {
if (
displayFilters?.layout === "kanban" &&
selectedSubGroupBy !== null &&
@@ -33,6 +33,7 @@ import { Logo } from "@/components/common/logo";
import { store } from "@/lib/store-context";
// plane web store
import {
getMilestoneColumns,
getScopeMemberIds,
getTeamProjectColumns,
SpreadSheetPropertyIconMap,
@@ -119,6 +120,7 @@ export const getGroupByColumns = ({
assignees: getAssigneeColumns,
created_by: getCreatedByColumns,
team_project: getTeamProjectColumns,
milestone: getMilestoneColumns,
};
// Get and return the columns for the specified group by option
@@ -34,6 +34,7 @@ import { useProjectState } from "@/hooks/store/use-project-state";
// plane web components
import { WorkItemAdditionalSidebarProperties } from "@/plane-web/components/issues/issue-details/additional-properties";
import { IssueParentSelectRoot } from "@/plane-web/components/issues/issue-details/parent-select-root";
import { DateAlert } from "@/plane-web/components/issues/issue-details/sidebar/date-alert";
import { IssueWorklogProperty } from "@/plane-web/components/issues/worklog/property";
import type { TIssueOperations } from "../issue-detail";
import { IssueCycleSelect } from "../issue-detail/cycle-select";
@@ -189,28 +190,31 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
<DueDatePropertyIcon className="h-4 w-4 flex-shrink-0" />
<span>{t("common.order_by.due_date")}</span>
</div>
<DateDropdown
value={issue.target_date}
onChange={(val) =>
issueOperations.update(workspaceSlug, projectId, issueId, {
target_date: val ? renderFormattedPayloadDate(val) : null,
})
}
placeholder={t("issue.add.due_date")}
buttonVariant="transparent-with-text"
minDate={minDate ?? undefined}
disabled={disabled}
className="w-3/4 flex-grow group"
buttonContainerClassName="w-full text-left"
buttonClassName={cn("text-sm", {
"text-custom-text-400": !issue.target_date,
"text-red-500": shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group),
})}
hideIcon
clearIconClassName="h-3 w-3 hidden group-hover:inline !text-custom-text-100"
// TODO: add this logic
// showPlaceholderIcon
/>
<div className="flex gap-2 items-center">
<DateDropdown
value={issue.target_date}
onChange={(val) =>
issueOperations.update(workspaceSlug, projectId, issueId, {
target_date: val ? renderFormattedPayloadDate(val) : null,
})
}
placeholder={t("issue.add.due_date")}
buttonVariant="transparent-with-text"
minDate={minDate ?? undefined}
disabled={disabled}
className="w-3/4 flex-grow group"
buttonContainerClassName="w-full text-left"
buttonClassName={cn("text-sm", {
"text-custom-text-400": !issue.target_date,
"text-red-500": shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group),
})}
hideIcon
clearIconClassName="h-3 w-3 hidden group-hover:inline !text-custom-text-100"
// TODO: add this logic
// showPlaceholderIcon
/>
{issue.target_date && <DateAlert date={issue.target_date} workItem={issue} projectId={projectId} />}
</div>
</div>
{/* estimate */}
@@ -1,6 +1,6 @@
import { ArrowRight } from "lucide-react";
import { CalendarLayoutIcon } from "@plane/propel/icons";
import { cn, renderFormattedDate } from "@plane/utils"
import { cn, renderFormattedDate } from "@plane/utils";
export const DisplayDates = (props: {
startDate: string | null | undefined;
@@ -121,6 +121,7 @@ export const ISSUE_GROUP_BY_KEY: Record<TIssueDisplayFilterOptions, keyof TIssue
cycle: "cycle_id",
module: "module_ids",
team_project: "project_id",
milestone: "milestone_id",
};
export const ISSUE_FILTER_DEFAULT_DATA: Record<TIssueDisplayFilterOptions, keyof TIssue> = {
@@ -135,6 +136,7 @@ export const ISSUE_FILTER_DEFAULT_DATA: Record<TIssueDisplayFilterOptions, keyof
assignees: "assignee_ids",
target_date: "target_date",
team_project: "project_id",
milestone: "milestone_id",
};
// This constant maps the order by keys to the respective issue property that the key relies on
@@ -24,6 +24,7 @@ import { EIssueLayoutTypes } from "@plane/types";
import { getComputedDisplayFilters, getComputedDisplayProperties } from "@plane/utils";
// lib
import { storage } from "@/lib/local-storage";
import { getEnabledDisplayFilters } from "@/plane-web/hooks/store/issue/helpers/filter-utils";
interface ILocalStoreIssueFilters {
key: EIssuesStoreType;
@@ -176,7 +177,10 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore {
computedDisplayFilters = (
displayFilters: IIssueDisplayFilterOptions,
defaultValues?: IIssueDisplayFilterOptions
): IIssueDisplayFilterOptions => getComputedDisplayFilters(displayFilters, defaultValues);
): IIssueDisplayFilterOptions => {
const computedFilters = getComputedDisplayFilters(displayFilters, defaultValues);
return getEnabledDisplayFilters(computedFilters);
};
/**
* @description This method is used to apply the display properties on the issues
@@ -212,6 +212,7 @@ export class IssueStore implements IIssueStore {
is_epic: issue?.is_epic,
customer_request_ids: issue?.customer_request_ids,
initiative_ids: issue?.initiative_ids,
milestone_id: issue?.milestone_id,
};
this.rootIssueDetailStore.rootIssueStore.issues.addIssue([issuePayload]);
+1 -1
View File
@@ -76,7 +76,7 @@ export class ProjectStore implements IProjectStore {
fetchStatus: TFetchStatus = undefined;
projectMap: Record<string, TProject> = {};
projectAnalyticsCountMap: Record<string, TProjectAnalyticsCount> = {};
openCollapsibleSection: ProjectOverviewCollapsible[] = [];
openCollapsibleSection: ProjectOverviewCollapsible[] = ["milestones"];
lastCollapsibleAction: ProjectOverviewCollapsible | null = null;
// root store
@@ -1,6 +1,13 @@
import type { ReactNode } from "react";
import { AlignLeft, ArrowRightLeft, Briefcase, CalendarDays, FileText, Link, Paperclip, Type } from "lucide-react";
import { CustomersIcon, EpicIcon, EstimatePropertyIcon, LabelPropertyIcon, MembersPropertyIcon, StatePropertyIcon } from "@plane/propel/icons";
import {
CustomersIcon,
EpicIcon,
EstimatePropertyIcon,
LabelPropertyIcon,
MembersPropertyIcon,
StatePropertyIcon,
} from "@plane/propel/icons";
import type { TBaseActivityVerbs, TIssueActivity } from "@plane/types";
import { convertMinutesToHoursMinutesString, getPageName, renderFormattedDate } from "@plane/utils";
import { LabelActivityChip } from "@/components/issues/issue-detail/issue-activity/activity/actions";
@@ -3,7 +3,17 @@ import type { FC } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// ui
import { DueDatePropertyIcon, EstimatePropertyIcon, InitiativeIcon, LabelPropertyIcon, MembersPropertyIcon, PriorityPropertyIcon, StartDatePropertyIcon, StatePropertyIcon, UserCirclePropertyIcon } from "@plane/propel/icons";
import {
DueDatePropertyIcon,
EstimatePropertyIcon,
InitiativeIcon,
LabelPropertyIcon,
MembersPropertyIcon,
PriorityPropertyIcon,
StartDatePropertyIcon,
StatePropertyIcon,
UserCirclePropertyIcon,
} from "@plane/propel/icons";
// types
import { EIssueServiceType, EWorkItemTypeEntity } from "@plane/types";
// components
@@ -26,8 +36,11 @@ import { InitiativeMultiSelectModal } from "@/plane-web/components/initiatives/c
import { IssueAdditionalPropertyValuesUpdate } from "@/plane-web/components/issue-types/values/addition-properties-update";
// helpers
import { WorkItemSidebarCustomers } from "@/plane-web/components/issues/issue-details/sidebar/customer-list-root";
import { DateAlert } from "@/plane-web/components/issues/issue-details/sidebar/date-alert";
import { WorkItemSideBarMilestoneItem } from "@/plane-web/components/issues/issue-details/sidebar/milestones/root";
import { useCustomers } from "@/plane-web/hooks/store/customers";
import { useInitiatives } from "@/plane-web/hooks/store/use-initiatives";
import { useMilestones } from "@/plane-web/hooks/store/use-milestone";
import { useEpicOperations } from "../helper";
type Props = {
@@ -47,27 +60,29 @@ export const EpicSidebarPropertiesRoot: FC<Props> = observer((props) => {
const { getUserDetails } = useMember();
const { getStateById } = useProjectState();
const { isCustomersFeatureEnabled } = useCustomers();
const { isMilestonesEnabled } = useMilestones();
const {
initiative: { isInitiativeModalOpen, toggleInitiativeModal },
} = useInitiatives();
const { t } = useTranslation();
// derived values
const issue = getIssueById(epicId);
const epicDetails = getIssueById(epicId);
const isMilestonesFeatureEnabled = isMilestonesEnabled(workspaceSlug, projectId);
const epicOperations = useEpicOperations();
if (!issue) return <></>;
if (!epicDetails) return <></>;
// derived values
const createdByDetails = getUserDetails(issue.created_by);
const stateDetails = getStateById(issue.state_id);
const createdByDetails = getUserDetails(epicDetails.created_by);
const stateDetails = getStateById(epicDetails.state_id);
// min and max date for start and target date
const minDate = issue.start_date ? getDate(issue.start_date) : null;
const minDate = epicDetails.start_date ? getDate(epicDetails.start_date) : null;
minDate?.setDate(minDate.getDate());
const maxDate = issue.target_date ? getDate(issue.target_date) : null;
const maxDate = epicDetails.target_date ? getDate(epicDetails.target_date) : null;
maxDate?.setDate(maxDate.getDate());
return (
@@ -75,7 +90,7 @@ export const EpicSidebarPropertiesRoot: FC<Props> = observer((props) => {
<InitiativeMultiSelectModal
isOpen={isInitiativeModalOpen === epicId}
onClose={() => toggleInitiativeModal()}
selectedInitiativeIds={issue.initiative_ids ?? []}
selectedInitiativeIds={epicDetails.initiative_ids ?? []}
onSubmit={(initiativeIds) =>
epicOperations.update(workspaceSlug, projectId, epicId, { initiative_ids: initiativeIds })
}
@@ -87,7 +102,7 @@ export const EpicSidebarPropertiesRoot: FC<Props> = observer((props) => {
<span>State</span>
</div>
<StateDropdown
value={issue?.state_id}
value={epicDetails?.state_id}
onChange={(val) => epicOperations.update(workspaceSlug, projectId, epicId, { state_id: val })}
projectId={projectId?.toString() ?? ""}
disabled={disabled}
@@ -106,17 +121,17 @@ export const EpicSidebarPropertiesRoot: FC<Props> = observer((props) => {
<span>Assignees</span>
</div>
<MemberDropdown
value={issue?.assignee_ids ?? undefined}
value={epicDetails?.assignee_ids ?? undefined}
onChange={(val) => epicOperations.update(workspaceSlug, projectId, epicId, { assignee_ids: val })}
disabled={disabled}
projectId={projectId?.toString() ?? ""}
placeholder="Add assignees"
multiple
buttonVariant={issue?.assignee_ids?.length > 1 ? "transparent-without-text" : "transparent-with-text"}
buttonVariant={epicDetails?.assignee_ids?.length > 1 ? "transparent-without-text" : "transparent-with-text"}
className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left"
buttonClassName={`text-sm justify-between ${issue?.assignee_ids?.length > 0 ? "" : "text-custom-text-400"}`}
hideIcon={issue.assignee_ids?.length === 0}
buttonClassName={`text-sm justify-between ${epicDetails?.assignee_ids?.length > 0 ? "" : "text-custom-text-400"}`}
hideIcon={epicDetails.assignee_ids?.length === 0}
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
/>
@@ -128,7 +143,7 @@ export const EpicSidebarPropertiesRoot: FC<Props> = observer((props) => {
<span>Priority</span>
</div>
<PriorityDropdown
value={issue?.priority}
value={epicDetails?.priority}
onChange={(val) => epicOperations.update(workspaceSlug, projectId, epicId, { priority: val })}
disabled={disabled}
buttonVariant="border-with-text"
@@ -159,13 +174,13 @@ export const EpicSidebarPropertiesRoot: FC<Props> = observer((props) => {
className={cn(
"p-2 rounded text-sm text-custom-text-200 hover:bg-custom-background-80 justify-start flex items-start cursor-pointer",
{
"text-custom-text-400": !issue.initiative_ids?.length,
"text-custom-text-400": !epicDetails.initiative_ids?.length,
}
)}
onClick={() => toggleInitiativeModal(epicId)}
>
{issue.initiative_ids?.length
? t("initiatives.placeholder", { count: issue.initiative_ids?.length })
{epicDetails.initiative_ids?.length
? t("initiatives.placeholder", { count: epicDetails.initiative_ids?.length })
: t("initiatives.add_initiative")}
</div>
</div>
@@ -176,7 +191,7 @@ export const EpicSidebarPropertiesRoot: FC<Props> = observer((props) => {
</div>
<DateDropdown
placeholder="Add start date"
value={issue.start_date}
value={epicDetails.start_date}
onChange={(val) =>
epicOperations.update(workspaceSlug, projectId, epicId, {
start_date: val ? renderFormattedPayloadDate(val) : null,
@@ -187,7 +202,7 @@ export const EpicSidebarPropertiesRoot: FC<Props> = observer((props) => {
buttonVariant="transparent-with-text"
className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left"
buttonClassName={`text-sm ${issue?.start_date ? "" : "text-custom-text-400"}`}
buttonClassName={`text-sm ${epicDetails?.start_date ? "" : "text-custom-text-400"}`}
hideIcon
clearIconClassName="h-3 w-3 hidden group-hover:inline"
/>
@@ -198,26 +213,31 @@ export const EpicSidebarPropertiesRoot: FC<Props> = observer((props) => {
<DueDatePropertyIcon className="h-4 w-4 flex-shrink-0" />
<span>Due date</span>
</div>
<DateDropdown
placeholder="Add due date"
value={issue.target_date}
onChange={(val) =>
epicOperations.update(workspaceSlug, projectId, epicId, {
target_date: val ? renderFormattedPayloadDate(val) : null,
})
}
minDate={minDate ?? undefined}
disabled={disabled}
buttonVariant="transparent-with-text"
className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left"
buttonClassName={cn("text-sm", {
"text-custom-text-400": !issue.target_date,
"text-red-500": shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group),
})}
hideIcon
clearIconClassName="h-3 w-3 hidden group-hover:inline !text-custom-text-100"
/>
<div className="flex items-center gap-2">
<DateDropdown
placeholder="Add due date"
value={epicDetails.target_date}
onChange={(val) =>
epicOperations.update(workspaceSlug, projectId, epicId, {
target_date: val ? renderFormattedPayloadDate(val) : null,
})
}
minDate={minDate ?? undefined}
disabled={disabled}
buttonVariant="transparent-with-text"
className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left"
buttonClassName={cn("text-sm", {
"text-custom-text-400": !epicDetails.target_date,
"text-red-500": shouldHighlightIssueDueDate(epicDetails.target_date, stateDetails?.group),
})}
hideIcon
clearIconClassName="h-3 w-3 hidden group-hover:inline !text-custom-text-100"
/>
{epicDetails.target_date && (
<DateAlert date={epicDetails.target_date} workItem={epicDetails} projectId={projectId} />
)}
</div>
</div>
{projectId && areEstimateEnabledByProjectId(projectId) && (
@@ -227,7 +247,7 @@ export const EpicSidebarPropertiesRoot: FC<Props> = observer((props) => {
<span>Estimate</span>
</div>
<EstimateDropdown
value={issue?.estimate_point ?? undefined}
value={epicDetails?.estimate_point ?? undefined}
onChange={(val: string | undefined) =>
epicOperations.update(workspaceSlug, projectId, epicId, { estimate_point: val })
}
@@ -236,7 +256,7 @@ export const EpicSidebarPropertiesRoot: FC<Props> = observer((props) => {
buttonVariant="transparent-with-text"
className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left"
buttonClassName={`text-sm ${issue?.estimate_point !== null ? "" : "text-custom-text-400"}`}
buttonClassName={`text-sm ${epicDetails?.estimate_point !== null ? "" : "text-custom-text-400"}`}
placeholder="None"
hideIcon
dropdownArrow
@@ -265,10 +285,14 @@ export const EpicSidebarPropertiesRoot: FC<Props> = observer((props) => {
<WorkItemSidebarCustomers workItemId={epicId} workspaceSlug={workspaceSlug} isPeekView={false} />
)}
{issue.type_id && (
{isMilestonesFeatureEnabled && (
<WorkItemSideBarMilestoneItem projectId={projectId} workItemId={epicId} isPeekView={false} />
)}
{epicDetails.type_id && (
<IssueAdditionalPropertyValuesUpdate
issueId={epicId}
issueTypeId={issue.type_id}
issueTypeId={epicDetails.type_id}
projectId={projectId}
workspaceSlug={workspaceSlug}
isDisabled={disabled}
@@ -1,12 +1,6 @@
import type { ReactNode } from "react";
import { AlignLeft, Link, Paperclip, Type } from "lucide-react";
import {
CalendarLayoutIcon,
EpicIcon,
InitiativeIcon,
MembersPropertyIcon,
ProjectIcon,
} from "@plane/propel/icons";
import { CalendarLayoutIcon, EpicIcon, InitiativeIcon, MembersPropertyIcon, ProjectIcon } from "@plane/propel/icons";
import type { TBaseActivityVerbs } from "@plane/types";
import { store } from "@/lib/store-context";
import type { TInitiativeActivity } from "@/plane-web/types/initiative";
@@ -6,16 +6,24 @@ import type { TWorkItemAdditionalSidebarProperties } from "@/ce/components/issue
// plane web imports
import { IssueAdditionalPropertyValuesUpdate } from "@/plane-web/components/issue-types/values/addition-properties-update";
import { WorkItemSidebarCustomers } from "@/plane-web/components/issues/issue-details/sidebar/customer-list-root";
import { WorkItemSideBarMilestoneItem } from "@/plane-web/components/issues/issue-details/sidebar/milestones/root";
import { useCustomers } from "@/plane-web/hooks/store";
import { useMilestones } from "@/plane-web/hooks/store/use-milestone";
export const WorkItemAdditionalSidebarProperties: FC<TWorkItemAdditionalSidebarProperties> = observer((props) => {
const { workItemId, projectId, workItemTypeId, workspaceSlug, isEditable, isPeekView = false } = props;
const { isCustomersFeatureEnabled } = useCustomers();
const { isMilestonesEnabled } = useMilestones();
const isMilestonesFeatureEnabled = isMilestonesEnabled(workspaceSlug, projectId);
return (
<>
{isCustomersFeatureEnabled && (
<WorkItemSidebarCustomers workItemId={workItemId} workspaceSlug={workspaceSlug} isPeekView={isPeekView} />
)}
{isMilestonesFeatureEnabled && (
<WorkItemSideBarMilestoneItem isPeekView={isPeekView} projectId={projectId} workItemId={workItemId} />
)}
{workItemTypeId && (
<IssueAdditionalPropertyValuesUpdate
issueId={workItemId}
@@ -0,0 +1,32 @@
import { TriangleAlertIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import type { TDateAlertProps } from "@/ce/components/issues/issue-details/sidebar.tsx/date-alert";
import { useMilestones } from "@/plane-web/hooks/store/use-milestone";
export const DateAlert = (props: TDateAlertProps) => {
const { date, workItem, projectId } = props;
// store hooks
const { getMilestoneById } = useMilestones();
if (!workItem.milestone_id) return null;
const milestone = getMilestoneById(projectId, workItem.milestone_id);
if (!milestone || !milestone.target_date) return null;
const isWorkItemDatePast = new Date(date) > new Date(milestone.target_date);
if (!isWorkItemDatePast) return null;
return (
<Tooltip
tooltipContent="End date is after the milestone target. You can update to stay on track."
position="bottom-end"
>
<span className="inline-flex cursor-pointer">
<TriangleAlertIcon className="size-4 text-[#FE9A00]" />
</span>
</Tooltip>
);
};
@@ -0,0 +1,51 @@
import { observer } from "mobx-react";
import { MilestoneIcon } from "@plane/propel/icons";
import { cn } from "@plane/ui";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { getMilestoneVariant } from "@/plane-web/components/project-overview/details/main/milestones/helper";
import { useMilestones } from "@/plane-web/hooks/store/use-milestone";
type TWorkItemSideBarMilestoneItemProps = {
projectId: string;
workItemId: string;
isPeekView?: boolean;
};
export const WorkItemSideBarMilestoneItem = observer((props: TWorkItemSideBarMilestoneItemProps) => {
const { projectId, workItemId, isPeekView } = props;
//store hooks
const { getMilestoneById } = useMilestones();
const {
issue: { getIssueById },
} = useIssueDetail();
// derived values
const workItem = getIssueById(workItemId);
if (!workItem?.milestone_id) return null;
const milestone = getMilestoneById(projectId, workItem.milestone_id);
if (!milestone) return null;
return (
<>
<div className="flex min-h-8 gap-2">
<div
className={cn("flex flex-shrink-0 gap-1 pt-2 text-sm text-custom-text-300", isPeekView ? "w-1/4" : "w-2/5")}
>
<MilestoneIcon variant="custom" className="h-4 w-4 flex-shrink-0" />
<span>Milestone</span>
</div>
<div className="h-full min-h-8 w-3/5 flex flex-wrap gap-2 items-center px-1">
<MilestoneIcon
variant={getMilestoneVariant(milestone.progress_percentage)}
className="size-4 text-custom-text-200"
/>
<span className="text-sm">{milestone.title}</span>
</div>
</div>
</>
);
});
@@ -1,8 +1,16 @@
// types
import type { FC } from "react";
import { useParams } from "next/navigation";
import { ISSUE_GROUP_BY_OPTIONS } from "@plane/constants";
import type { ISvgIcons } from "@plane/propel/icons";
import { CustomerRequestIcon, CustomersIcon } from "@plane/propel/icons";
import type { IGroupByColumn, IIssueDisplayProperties, TGetColumns, TSpreadsheetColumn } from "@plane/types";
import { CustomerRequestIcon, CustomersIcon, MilestoneIcon } from "@plane/propel/icons";
import type {
IGroupByColumn,
IIssueDisplayProperties,
TGetColumns,
TIssueGroupByOptions,
TSpreadsheetColumn,
} from "@plane/types";
// components
import type { TGetScopeMemberIdsResult } from "@/ce/components/issues/issue-layouts/utils";
import {
@@ -17,6 +25,8 @@ import {
SpreadsheetCustomerColumn,
SpreadSheetCustomerRequestColumn,
} from "@/plane-web/components/issues/issue-layouts/spreadsheet";
import { getMilestoneVariant } from "@/plane-web/components/project-overview/details/main/milestones/helper";
import { useMilestones } from "@/plane-web/hooks/store/use-milestone";
export const getScopeMemberIds = ({ isWorkspaceLevel, projectId }: TGetColumns): TGetScopeMemberIdsResult => {
// store values
@@ -64,3 +74,70 @@ export const SPREADSHEET_COLUMNS: { [key in keyof IIssueDisplayProperties]: TSpr
customer_count: SpreadsheetCustomerColumn,
customer_request_count: SpreadSheetCustomerRequestColumn,
};
export const getMilestoneColumns = (): IGroupByColumn[] | undefined => {
const { projectId, workspaceSlug } = store.router;
const { getProjectMilestoneIds, getMilestoneById, isMilestonesEnabled } = store.milestone;
if (!projectId || !workspaceSlug) return;
const isMilestonesFeatureEnabled = isMilestonesEnabled(workspaceSlug, projectId);
if (!isMilestonesFeatureEnabled) return;
const projectMilestoneIds = getProjectMilestoneIds(projectId);
if (!projectMilestoneIds) return;
const milestoneColumns: IGroupByColumn[] = [
{
id: "None",
name: "None",
icon: <MilestoneIcon className="w-4 h-4" variant={"default"} />,
payload: {},
},
];
projectMilestoneIds.map((milestoneId) => {
const milestone = getMilestoneById(projectId.toString(), milestoneId);
if (!milestone) return;
milestoneColumns.push({
id: milestone.id,
name: milestone.title,
icon: <MilestoneIcon className="w-4 h-4" variant={getMilestoneVariant(milestone.progress_percentage)} />,
payload: { milestone_id: milestone.id },
});
});
return milestoneColumns;
};
export const useGroupByOptions = (
options: TIssueGroupByOptions[]
): { key: TIssueGroupByOptions; titleTranslationKey: string }[] => {
// router
const { workspaceSlug, projectId } = useParams();
// store hooks
const { isMilestonesEnabled } = useMilestones();
//derived values
const groupByOptions = ISSUE_GROUP_BY_OPTIONS.filter((option) => options.includes(option.key));
if (!workspaceSlug || !projectId) return groupByOptions;
const isMilestonesFeatureEnabled = isMilestonesEnabled(workspaceSlug.toString(), projectId.toString());
const FEATURES_STATUS_MAP: Record<string, boolean> = {
milestone: isMilestonesFeatureEnabled,
};
// filter out options that are not enabled
const enabledGroupByOptions = groupByOptions.filter((option) => {
if (option.key === null) return true;
const isEnabled = FEATURES_STATUS_MAP[option.key];
if (isEnabled === undefined) return true;
return isEnabled;
});
return enabledGroupByOptions;
};
@@ -1,7 +1,10 @@
import { useState } from "react";
import { observer } from "mobx-react";
// components
import { IssuePeekOverview } from "@/components/issues/peek-overview";
import { useProject } from "@/hooks/store/use-project";
import { CollapsibleDetailSection } from "@/plane-web/components/common/layout/main/sections/collapsible-root";
import { EpicPeekOverview } from "@/plane-web/components/epics/peek-overview";
import { useMilestones } from "@/plane-web/hooks/store/use-milestone";
import { AddMilestoneButton } from "./add-milestone-button";
import { CreateUpdateMilestoneModal } from "./create-update-modal";
@@ -54,6 +57,8 @@ export const ProjectMilestoneCollapsibleRoot = observer((props: Props) => {
isOpen={openCollapsibleSection.includes("milestones")}
onToggle={() => toggleOpenCollapsibleSection("milestones")}
/>
<IssuePeekOverview />
<EpicPeekOverview />
</>
);
});
@@ -5,12 +5,13 @@ import { useTranslation } from "@plane/i18n";
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
import type { ISearchIssueResponse, TMilestone } from "@plane/types";
import { EFileAssetType } from "@plane/types";
import { Button, Input, ModalCore } from "@plane/ui";
import { Button, EModalPosition, Input, Loader, ModalCore } from "@plane/ui";
import { cn, getChangedFields, getDescriptionPlaceholderI18n, renderFormattedPayloadDate } from "@plane/utils";
import { DateDropdown } from "@/components/dropdowns/date";
import { RichTextEditor } from "@/components/editor/rich-text";
import { useEditorAsset } from "@/hooks/store/use-editor-asset";
import { useWorkspace } from "@/hooks/store/use-workspace";
import useKeypress from "@/hooks/use-keypress";
import { useMilestones } from "@/plane-web/hooks/store/use-milestone";
import { WorkspaceService } from "@/plane-web/services";
import { MilestoneWorkItemActionButton } from "./quick-action-button";
@@ -51,6 +52,9 @@ export const CreateUpdateMilestoneModal = (props: Props) => {
} = useMilestones();
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id as string;
// states
const [isWorkItemsLoading, setIsWorkItemsLoading] = useState(false);
// derived values
const data = milestoneId ? getMilestoneById(projectId, milestoneId) : undefined;
@@ -102,7 +106,6 @@ export const CreateUpdateMilestoneModal = (props: Props) => {
: createMilestone(workspaceSlug, projectId, payload);
await promise
.then((response) => {
onClose();
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
@@ -110,6 +113,8 @@ export const CreateUpdateMilestoneModal = (props: Props) => {
});
if (response?.id) handleAddWorkItems(selectedWorkItemIds, response.id);
onClose();
})
.catch(() => {
setToast({
@@ -129,12 +134,23 @@ export const CreateUpdateMilestoneModal = (props: Props) => {
// fetch work items when modal is open
useEffect(() => {
(async () => {
if (isOpen && milestoneId) {
const workItemIds = await fetchMilestoneWorkItems(workspaceSlug, projectId, milestoneId);
setSelectedWorkItemIds(workItemIds);
}
})();
if (isOpen && milestoneId) {
setIsWorkItemsLoading(true);
fetchMilestoneWorkItems(workspaceSlug, projectId, milestoneId)
.then((workItemIds) => {
setSelectedWorkItemIds(workItemIds);
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Failed to fetch work items. Please try again.",
});
})
.finally(() => {
setIsWorkItemsLoading(false);
});
}
}, [isOpen, milestoneId, fetchMilestoneWorkItems, workspaceSlug, projectId]);
// ensure data populates the form when available; fallback to defaultValues
@@ -147,8 +163,12 @@ export const CreateUpdateMilestoneModal = (props: Props) => {
}
}, [isOpen, data, reset]);
useKeypress("Escape", () => {
if (isOpen) onClose();
});
return (
<ModalCore isOpen={isOpen} handleClose={handleClose}>
<ModalCore isOpen={isOpen} position={EModalPosition.TOP}>
<div className="p-4">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-4">
@@ -224,17 +244,24 @@ export const CreateUpdateMilestoneModal = (props: Props) => {
projectId={projectId}
workspaceSlug={workspaceSlug}
customButton={
<Button variant="neutral-primary" size="sm" className="text-custom-text-200 text-xs">
<LayersIcon className="size-3" />
{selectedWorkItemIds.length > 0 ? (
<span className="text-xs">{selectedWorkItemIds.length}</span>
) : (
"Link work items"
)}
</Button>
isWorkItemsLoading ? (
<Loader>
<Loader.Item height="28px" width="48px" />
</Loader>
) : (
<Button variant="neutral-primary" size="sm" className="text-xs text-custom-text-100">
<LayersIcon className="size-3" />
{selectedWorkItemIds.length > 0 ? (
<span className="text-xs">{selectedWorkItemIds.length}</span>
) : (
"Link work items"
)}
</Button>
)
}
handleSubmit={handleWorkItemsSubmit}
selectedWorkItemIds={selectedWorkItemIds}
milestoneId={milestoneId}
/>
<div className="space-y-1 flex gap-2">
<Controller
@@ -3,14 +3,17 @@ import React from "react";
import { observer } from "mobx-react";
// hooks
import { CircleMinus, LinkIcon } from "lucide-react";
import { usePlatformOS } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
import type { TIssue } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
import { CustomMenu } from "@plane/ui";
import { ControlLink, CustomMenu } from "@plane/ui";
import { generateWorkItemLink } from "@plane/utils";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
// plane web imports
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
import { WorkItemPropertiesLite } from "@/plane-web/components/issues/issue-details/properties";
import { useMilestonesWorkItemOperations } from "./helper";
@@ -23,16 +26,28 @@ type TProps = {
};
export const MilestoneWorkItem: FC<TProps> = observer((props) => {
// props
const { workspaceSlug, workItemId, projectId, milestoneId } = props;
// hooks
const project = useProject();
const {
issue: { getIssueById },
} = useIssueDetail();
const { getProjectStates } = useProjectState();
const { t } = useTranslation();
const { t } = useTranslation();
const { isMobile } = usePlatformOS();
// derived values
const workItem = getIssueById(workItemId);
const isEpic = workItem?.is_epic;
const workItemOperations = useMilestonesWorkItemOperations(
isEpic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES,
milestoneId
);
const { handleRedirection } = useIssuePeekOverviewRedirection(isEpic);
if (!workItem) return null;
@@ -41,13 +56,6 @@ export const MilestoneWorkItem: FC<TProps> = observer((props) => {
(workItem.project_id && getProjectStates(workItem.project_id)?.find((state) => workItem.state_id === state.id)) ||
undefined;
const isEpic = workItem.is_epic;
const workItemOperations = useMilestonesWorkItemOperations(
isEpic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES,
milestoneId
);
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: projectId,
@@ -58,6 +66,8 @@ export const MilestoneWorkItem: FC<TProps> = observer((props) => {
});
// handlers
const handleWorkItemPeekOverview = (workItem: TIssue) => handleRedirection(workspaceSlug, workItem, isMobile);
const handleRemoveRelation = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -71,50 +81,58 @@ export const MilestoneWorkItem: FC<TProps> = observer((props) => {
};
return (
<div className="group relative flex min-h-11 h-full w-full items-center gap-3 px-1.5 py-1 transition-all hover:bg-custom-background-90">
<div className="flex w-full truncate items-center gap-3">
<div
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{ backgroundColor: currentIssueStateDetail?.color ?? "#737373" }}
/>
<div className="flex-shrink-0">
{projectDetail && (
<IssueIdentifier
projectId={projectDetail.id}
issueTypeId={workItem.type_id || undefined}
projectIdentifier={projectDetail.identifier}
issueSequenceId={workItem.sequence_id}
textContainerClassName="text-xs text-custom-text-200"
/>
)}
<ControlLink href={workItemLink} onClick={() => handleWorkItemPeekOverview(workItem)}>
<div className="group relative flex min-h-11 h-full w-full items-center gap-3 px-1.5 py-1 transition-all hover:bg-custom-background-90">
<div className="flex w-full truncate items-center gap-3">
<div
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{ backgroundColor: currentIssueStateDetail?.color ?? "#737373" }}
/>
<div className="flex-shrink-0">
{projectDetail && (
<IssueIdentifier
projectId={projectDetail.id}
issueTypeId={workItem.type_id || undefined}
projectIdentifier={projectDetail.identifier}
issueSequenceId={workItem.sequence_id}
textContainerClassName="text-xs text-custom-text-200"
/>
)}
</div>
<span className="w-full truncate text-sm text-custom-text-100">{workItem.name}</span>
</div>
<span className="w-full truncate text-sm text-custom-text-100">{workItem.name}</span>
</div>
<div className="flex-shrink-0 text-sm">
<WorkItemPropertiesLite
workspaceSlug={workspaceSlug}
workItemId={workItem.id}
disabled={false}
workItemOperations={workItemOperations}
/>
</div>
<div className="flex-shrink-0 text-sm">
<CustomMenu placement="bottom-end" ellipsis>
<CustomMenu.MenuItem onClick={handleCopyWorkItemLink}>
<div className="flex items-center gap-2">
<LinkIcon className="h-3.5 w-3.5" strokeWidth={2} />
<span>{t("common.actions.copy_link")}</span>
</div>
</CustomMenu.MenuItem>
<div
className="flex-shrink-0 text-sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<WorkItemPropertiesLite
workspaceSlug={workspaceSlug}
workItemId={workItem.id}
disabled={false}
workItemOperations={workItemOperations}
/>
</div>
<div className="flex-shrink-0 text-sm">
<CustomMenu placement="bottom-end" ellipsis>
<CustomMenu.MenuItem onClick={handleCopyWorkItemLink}>
<div className="flex items-center gap-2">
<LinkIcon className="h-3.5 w-3.5" strokeWidth={2} />
<span>{t("common.actions.copy_link")}</span>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleRemoveRelation}>
<div className="flex items-center gap-2 text-red-500">
<CircleMinus className="h-3.5 w-3.5" strokeWidth={2} />
<span>{isEpic ? "Remove epic" : "Remove work item"}</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
<CustomMenu.MenuItem onClick={handleRemoveRelation}>
<div className="flex items-center gap-2 text-red-500">
<CircleMinus className="h-3.5 w-3.5" strokeWidth={2} />
<span>{isEpic ? "Remove epic" : "Remove work item"}</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</div>
</ControlLink>
);
});
@@ -1,3 +1,4 @@
import type { FC} from "react";
import { useState } from "react";
import { Plus } from "lucide-react";
import type { ISearchIssueResponse, TProjectIssuesSearchParams } from "@plane/types";
@@ -10,14 +11,15 @@ type Props = {
customButton?: React.ReactNode;
handleSubmit: (data: ISearchIssueResponse[]) => Promise<void>;
selectedWorkItemIds?: string[];
milestoneId?: string;
};
export const MilestoneWorkItemActionButton = (props: Props) => {
const { projectId, workspaceSlug, customButton, handleSubmit, selectedWorkItemIds } = props;
export const MilestoneWorkItemActionButton: FC<Props> = (props) => {
const { projectId, workspaceSlug, customButton, handleSubmit, selectedWorkItemIds, milestoneId } = props;
const [workItemsModal, setWorkItemsModal] = useState<boolean>(false);
const workItemSearchCallBack = async (params: TProjectIssuesSearchParams) =>
milestoneService.workItemsSearch(workspaceSlug, projectId, params.search);
milestoneService.workItemsSearch(workspaceSlug, projectId, params);
const handleClick = (e: React.MouseEvent<HTMLDivElement | SVGSVGElement, MouseEvent>) => {
e.preventDefault();
@@ -31,7 +33,7 @@ export const MilestoneWorkItemActionButton = (props: Props) => {
workspaceSlug={workspaceSlug}
isOpen={workItemsModal}
handleClose={() => setWorkItemsModal(false)}
searchParams={{}}
searchParams={{ milestone_id: milestoneId }}
selectedWorkItemIds={selectedWorkItemIds}
handleOnSubmit={handleSubmit}
workItemSearchServiceCallback={workItemSearchCallBack}
@@ -26,7 +26,7 @@ export const ProjectMilestoneRoot: FC<Props> = observer((props) => {
if (milestoneLoader) {
return (
<div className="mt-3">
<Loader>
<Loader className="space-y-4">
<Loader.Item height="120px" />
<Loader.Item height="120px" />
</Loader>
@@ -47,7 +47,7 @@ export const ProjectMilestoneRoot: FC<Props> = observer((props) => {
);
return (
<div className="space-y-2">
<div className="space-y-4">
{milestoneIds.map((milestoneId) => (
<MilestoneCard
key={milestoneId}
@@ -28,7 +28,6 @@ export const ProjectOverviewMainContentRoot: FC<Props> = (props) => {
const { projectOverviewSidebarCollapsed } = useAppTheme();
// helper hooks
const { fetchLinks } = useLinks(workspaceSlug.toString(), projectId.toString());
const { fetchMilestones } = useMilestones();
useSWR(
projectId && workspaceSlug ? `PROJECT_LINKS_${projectId}` : null,
@@ -49,16 +48,6 @@ export const ProjectOverviewMainContentRoot: FC<Props> = (props) => {
}
);
useSWR(
projectId && workspaceSlug ? `PROJECT_MILESTONES_${projectId}` : null,
projectId && workspaceSlug ? () => fetchMilestones(workspaceSlug, projectId) : null,
{
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
}
);
return (
<div className="h-full w-full flex flex-col overflow-y-auto">
<ProjectOverviewInfoSectionRoot workspaceSlug={workspaceSlug} projectId={projectId} />
@@ -62,11 +62,13 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
// derived values
const currentProjectDetails = getProjectById(projectId);
const FEATURES_LIST: (keyof TProjectFeaturesList)[] = ["is_milestone_enabled", "is_time_tracking_enabled"];
const handleSubmit = async (featureKey: string, featureProperty: string) => {
if (!workspaceSlug || !projectId || !currentProjectDetails) return;
// making the request to update the project feature
const settingsPayload = {
let settingsPayload = {
[featureProperty]: !currentProjectDetails?.[featureProperty as keyof IProject],
};
const updateProjectPromise = updateProject(workspaceSlug, projectId, settingsPayload).then(() => {
@@ -77,14 +79,18 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
},
});
});
const updatePromises = [updateProjectPromise];
if (["is_milestone_enabled", "is_time_tracking_enabled"].includes(featureProperty)) {
let updatePromise = updateProjectPromise;
if (FEATURES_LIST.includes(featureProperty as keyof TProjectFeaturesList)) {
settingsPayload = {
[featureProperty]: !isProjectFeatureEnabled(projectId, featureProperty as keyof TProjectFeaturesList),
};
const toggleProjectFeaturesPromise = toggleProjectFeatures(workspaceSlug, projectId, settingsPayload);
updatePromises.push(toggleProjectFeaturesPromise);
updatePromise = toggleProjectFeaturesPromise;
}
const promises = Promise.all(updatePromises);
setPromiseToast(promises, {
setPromiseToast(updatePromise, {
loading: "Updating project feature...",
success: {
title: "Success!",
@@ -97,9 +103,12 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
});
};
const isFeatureEnabled = (property: string): boolean =>
Boolean(currentProjectDetails?.[property as keyof IProject]) ||
isProjectFeatureEnabled(projectId, property as keyof TProjectFeaturesList);
const isFeatureEnabled = (property: string): boolean => {
if (FEATURES_LIST.includes(property as keyof TProjectFeaturesList)) {
return isProjectFeatureEnabled(projectId, property as keyof TProjectFeaturesList);
}
return Boolean(currentProjectDetails?.[property as keyof IProject]);
};
if (!currentUser) return <></>;
return (
@@ -1,5 +1,5 @@
import type { LucideIcon } from "lucide-react";
import type { ISvgIcons} from "@plane/propel/icons";
import type { ISvgIcons } from "@plane/propel/icons";
import { BoardLayoutIcon, CardLayoutIcon, ListLayoutIcon, TimelineLayoutIcon } from "@plane/propel/icons";
// plane web types
import type { TProjectGroupBy, TProjectSortBy, TProjectSortOrder } from "@/plane-web/types/workspace-project-filters";
@@ -0,0 +1,32 @@
import type { IIssueDisplayFilterOptions } from "@plane/types";
import { store } from "@/lib/store-context";
export const getEnabledDisplayFilters = (displayFilters: IIssueDisplayFilterOptions) => {
const { projectId, workspaceSlug } = store.router;
if (!projectId || !workspaceSlug) return displayFilters;
const { isMilestonesEnabled } = store.milestone;
const isMilestonesFeatureEnabled = isMilestonesEnabled(workspaceSlug.toString(), projectId.toString());
const FEATURES_STATUS_MAP: Record<string, boolean> = {
milestone: isMilestonesFeatureEnabled,
};
const enabledDisplayFilters: IIssueDisplayFilterOptions = {
...displayFilters,
group_by: displayFilters.group_by
? FEATURES_STATUS_MAP[displayFilters.group_by]
? displayFilters.group_by
: null
: null,
sub_group_by: displayFilters.sub_group_by
? FEATURES_STATUS_MAP[displayFilters.sub_group_by]
? displayFilters.sub_group_by
: null
: null,
};
return enabledDisplayFilters;
};
+14 -1
View File
@@ -9,6 +9,7 @@ import { useProjectState } from "@/hooks/store/use-project-state";
import { ProjectAuthWrapper as CoreProjectAuthWrapper } from "@/layouts/auth-layout/project-wrapper";
// plane web imports
import { useFlag, useIssueTypes } from "@/plane-web/hooks/store";
import { useMilestones } from "@/plane-web/hooks/store/use-milestone";
export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
// props
@@ -21,11 +22,12 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
fetchAllEpicPropertiesAndOptions,
} = useIssueTypes();
const { fetchWorkflowStates } = useProjectState();
const { fetchMilestones, isMilestonesEnabled } = useMilestones();
// derived values
const isWorkItemTypeEnabled = isWorkItemTypeEnabledForProject(workspaceSlug?.toString(), projectId?.toString());
const isEpicEnabled = isEpicEnabledForProject(workspaceSlug?.toString(), projectId?.toString());
const isWorkflowFeatureFlagEnabled = useFlag(workspaceSlug?.toString(), "WORKFLOWS");
const isMilestonesFeatureEnabled = isMilestonesEnabled(workspaceSlug?.toString(), projectId?.toString());
// fetching all work item types and properties
useSWR(
workspaceSlug && projectId && isWorkItemTypeEnabled
@@ -59,6 +61,17 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetching project level milestones
useSWR(
projectId && workspaceSlug && isMilestonesFeatureEnabled ? `PROJECT_MILESTONES_${projectId}` : null,
projectId && workspaceSlug && isMilestonesFeatureEnabled ? () => fetchMilestones(workspaceSlug, projectId) : null,
{
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
}
);
return (
<CoreProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
{children}
+7 -3
View File
@@ -1,5 +1,5 @@
import { API_BASE_URL } from "@plane/constants";
import type { ISearchIssueResponse, TIssue, TMilestone } from "@plane/types";
import type { ISearchIssueResponse, TIssue, TMilestone, TProjectIssuesSearchParams } from "@plane/types";
// services
import { APIService } from "@/services/api.service";
@@ -45,9 +45,13 @@ export class MilestoneService extends APIService {
});
}
async workItemsSearch(workspaceSlug: string, projectId: string, query: string): Promise<ISearchIssueResponse[]> {
async workItemsSearch(
workspaceSlug: string,
projectId: string,
params: TProjectIssuesSearchParams
): Promise<ISearchIssueResponse[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/milestones/work-items/search/`, {
params: { query },
params,
})
.then((res) => res?.data)
.catch((err) => {
@@ -1,7 +1,7 @@
import { action, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
import { MilestoneInstance } from "@plane/shared-state";
import type { ISearchIssueResponse, TMilestone } from "@plane/types";
import type { TMilestone } from "@plane/types";
// services
import milestoneService from "@/plane-web/services/milestone.service";
// stores
@@ -31,12 +31,11 @@ export interface IMilestoneStore {
projectId: string,
milestoneId: string,
data: Partial<TMilestone>
) => Promise<void>;
) => Promise<TMilestone>;
deleteMilestone: (workspaceSlug: string, projectId: string, milestoneId: string) => Promise<void>;
// work item actions
fetchMilestoneWorkItems: (workspaceSlug: string, projectId: string, milestoneId: string) => Promise<string[]>;
searchWorkItems: (workspaceSlug: string, projectId: string, query: string) => Promise<ISearchIssueResponse[]>;
removeWorkItemFromMilestone: (
workspaceSlug: string,
projectId: string,
@@ -74,7 +73,6 @@ export class MilestoneStore implements IMilestoneStore {
updateMilestone: action,
deleteMilestone: action,
fetchMilestoneWorkItems: action,
searchWorkItems: action,
removeWorkItemFromMilestone: action,
addWorkItemsToMilestone: action,
});
@@ -156,7 +154,7 @@ export class MilestoneStore implements IMilestoneStore {
projectId: string,
milestoneId: string,
data: Partial<TMilestone>
): Promise<void> => {
): Promise<TMilestone> => {
try {
const instance = this.getMilestoneById(projectId, milestoneId);
if (!instance) throw new Error("Milestone not found");
@@ -166,6 +164,8 @@ export class MilestoneStore implements IMilestoneStore {
runInAction(() => {
instance.update(updatedMilestone);
});
return updatedMilestone;
} catch (error) {
console.error("Failed to update milestone", error);
throw error;
@@ -242,9 +242,6 @@ export class MilestoneStore implements IMilestoneStore {
}
};
searchWorkItems = async (workspaceSlug: string, projectId: string, query: string): Promise<ISearchIssueResponse[]> =>
await milestoneService.workItemsSearch(workspaceSlug, projectId, query);
removeWorkItemFromMilestone = async (
workspaceSlug: string,
projectId: string,
+3 -2
View File
@@ -3,6 +3,7 @@
import { set, update } from "lodash-es";
import { action, makeObservable, observable, reaction, runInAction } from "mobx";
// store
import { computedFn } from "mobx-utils";
import { ProjectService } from "@/plane-web/services";
import type { RootStore } from "@/plane-web/store/root.store";
import type {
@@ -116,10 +117,10 @@ export class ProjectStore implements IProjectStore {
* @param { keyof TProjectFeatures } featureKey
* @returns { boolean }
*/
isProjectFeatureEnabled = (projectId: string, featureKey: keyof TProjectFeaturesList): boolean => {
isProjectFeatureEnabled = computedFn((projectId: string, featureKey: keyof TProjectFeaturesList): boolean => {
const projectFeatures = this.features[projectId];
return projectFeatures?.[featureKey] ?? false;
};
});
// helpers
/**
@@ -1,4 +1,4 @@
import { IIssueDisplayProperties, TIssueOrderByOptions } from "@plane/types";
import { IIssueDisplayProperties, TIssueGroupByOptions, TIssueOrderByOptions } from "@plane/types";
export const ISSUE_ADDITIONAL_DISPLAY_PROPERTIES_KEYS: (keyof IIssueDisplayProperties)[] = [
"customer_request_count",
@@ -45,3 +45,8 @@ export const ADDITIONAL_SPREADSHEET_PROPERTY_DETAILS: {
icon: "CustomersIcon",
},
};
export const ISSUE_GROUP_BY_OPTIONS_EXTENDED: {
key: TIssueGroupByOptions;
titleTranslationKey: string;
}[] = [{ key: "milestone", titleTranslationKey: "common.milestones" }];
+4
View File
@@ -11,6 +11,7 @@ import {
ADDITIONAL_SPREADSHEET_PROPERTY_LIST,
ISSUE_ADDITIONAL_DISPLAY_PROPERTIES,
ISSUE_ADDITIONAL_DISPLAY_PROPERTIES_KEYS,
ISSUE_GROUP_BY_OPTIONS_EXTENDED,
} from "./common-extended";
export const ALL_ISSUES = "All Issues";
@@ -37,6 +38,7 @@ export enum EIssueGroupByToServerOptions {
"created_by" = "created_by",
// eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values
"team_project" = "project_id",
"milestone" = "milestone_id",
}
export enum EIssueGroupBYServerToProperty {
@@ -50,6 +52,7 @@ export enum EIssueGroupBYServerToProperty {
"target_date" = "target_date",
"project_id" = "project_id",
"created_by" = "created_by",
"milestone_id" = "milestone_id",
}
export enum EIssueCommentAccessSpecifier {
@@ -124,6 +127,7 @@ export const ISSUE_GROUP_BY_OPTIONS: {
{ key: "labels", titleTranslationKey: "common.labels" },
{ key: "assignees", titleTranslationKey: "common.assignees" },
{ key: "created_by", titleTranslationKey: "common.created_by" },
...ISSUE_GROUP_BY_OPTIONS_EXTENDED,
{ key: null, titleTranslationKey: "common.none" },
];
@@ -11,7 +11,9 @@ import {
TIssueFiltersToDisplayByPageType,
} from "./filter";
export const ADDITIONAL_WORK_ITEM_FILTERS_KEYS = ["name"] as const;
export const ADDITIONAL_WORK_ITEM_FILTERS_KEYS = ["name", "milestone"] as const;
export const ADDITIONAL_WORK_ITEM_GROUP_BY_KEYS = ["milestone"] as const;
export const ADDITIONAL_ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
team_issues: {
@@ -175,7 +177,7 @@ export const ADDITIONAL_ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByP
list: {
display_properties: EPICS_DISPLAY_PROPERTIES_KEYS,
display_filters: {
group_by: ["state", "priority", "labels", "assignees", "created_by", null],
group_by: ["state", "priority", "labels", "assignees", "created_by", "milestone", null],
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"],
type: ["active", "backlog"],
},
@@ -187,7 +189,7 @@ export const ADDITIONAL_ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByP
kanban: {
display_properties: EPICS_DISPLAY_PROPERTIES_KEYS,
display_filters: {
group_by: ["state", "priority", "labels", "assignees", "created_by"],
group_by: ["state", "priority", "labels", "assignees", "created_by", "milestone"],
sub_group_by: ["state", "priority", "labels", "assignees", "created_by", null],
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority", "target_date"],
type: ["active", "backlog"],
+23 -2
View File
@@ -19,6 +19,7 @@ import {
ADDITIONAL_MY_ISSUES_DISPLAY_FILTERS,
EActivityFilterTypeEE,
shouldRenderActivity,
ADDITIONAL_WORK_ITEM_GROUP_BY_KEYS,
} from "./filter-extended";
import { TIssueLayout } from "./layout";
@@ -36,6 +37,7 @@ export enum EServerGroupByToFilterOptions {
"target_date" = "target_date",
"project_id" = "project",
"created_by" = "created_by",
"milestone_id" = "milestone",
}
export enum EIssueFilterType {
@@ -234,7 +236,17 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
list: {
display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS,
display_filters: {
group_by: ["state", "priority", "cycle", "module", "labels", "assignees", "created_by", null],
group_by: [
"state",
"priority",
"cycle",
"module",
"labels",
"assignees",
"created_by",
...ADDITIONAL_WORK_ITEM_GROUP_BY_KEYS,
null,
],
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority", "target_date"],
type: ["active", "backlog"],
},
@@ -246,7 +258,16 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
kanban: {
display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS,
display_filters: {
group_by: ["state", "priority", "cycle", "module", "labels", "assignees", "created_by"],
group_by: [
"state",
"priority",
"cycle",
"module",
"labels",
"assignees",
"created_by",
...ADDITIONAL_WORK_ITEM_GROUP_BY_KEYS,
],
sub_group_by: ["state", "priority", "cycle", "module", "labels", "assignees", "created_by", null],
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority", "target_date"],
type: ["active", "backlog"],
@@ -57,7 +57,7 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
titleEditor,
isEditorContentReady,
isContentInIndexedDb,
hasServerConnectionFailed: connectionFailed
hasServerConnectionFailed: connectionFailed,
} = useCollaborativeEditor({
disabledExtensions,
editable,
@@ -1,13 +1,6 @@
export type ConnectionStatus =
| "initial"
| "connecting"
| "connected"
| "reconnecting"
| "disconnected";
export type ConnectionStatus = "initial" | "connecting" | "connected" | "reconnecting" | "disconnected";
export type SyncStatus =
| "syncing"
| "synced";
export type SyncStatus = "syncing" | "synced";
export type CollaborationError =
| { type: "auth-failed"; message: string }
@@ -16,6 +16,7 @@ export default {
business: "Business",
members_and_teamspaces: "Members & Teamspaces",
recurring_work_items: "Recurring work items",
milestones: "Milestones",
},
issue: {
relation: {
+1
View File
@@ -82,6 +82,7 @@ export * from "./subscribe-icon";
export * from "./suspended-user";
export * from "./teams";
export * from "./transfer-icon";
export * from "./triangle-alert";
export * from "./tree-map-icon";
export * from "./updates-icon";
export * from "./user-activity-icon";
@@ -0,0 +1,14 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const TriangleAlertIcon: React.FC<ISvgIcons> = ({ className = "fill-current", ...rest }) => (
<svg viewBox="0 0 16 16" className={`${className} `} fill="none" xmlns="http://www.w3.org/2000/svg" {...rest}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.26734 2.00195C7.03734 0.66862 8.96267 0.66862 9.73201 2.00195L14.6353 10.5006C15.4047 11.834 14.442 13.5006 12.9027 13.5006H3.09667C1.55734 13.5006 0.595341 11.834 1.36467 10.5006L6.26734 2.00195ZM8.00001 5.49995C8.13262 5.49995 8.25979 5.55263 8.35356 5.6464C8.44733 5.74017 8.50001 5.86734 8.50001 5.99995V8.49995C8.50001 8.63256 8.44733 8.75974 8.35356 8.85351C8.25979 8.94727 8.13262 8.99995 8.00001 8.99995C7.8674 8.99995 7.74022 8.94727 7.64645 8.85351C7.55269 8.75974 7.50001 8.63256 7.50001 8.49995V5.99995C7.50001 5.86734 7.55269 5.74017 7.64645 5.6464C7.74022 5.55263 7.8674 5.49995 8.00001 5.49995ZM8.00001 11C8.13262 11 8.25979 10.9473 8.35356 10.8535C8.44733 10.7597 8.50001 10.6326 8.50001 10.5C8.50001 10.3673 8.44733 10.2402 8.35356 10.1464C8.25979 10.0526 8.13262 9.99995 8.00001 9.99995C7.8674 9.99995 7.74022 10.0526 7.64645 10.1464C7.55269 10.2402 7.50001 10.3673 7.50001 10.5C7.50001 10.6326 7.55269 10.7597 7.64645 10.8535C7.74022 10.9473 7.8674 11 8.00001 11Z"
fill="currentColor"
/>
</svg>
);
+2 -1
View File
@@ -188,7 +188,8 @@ export type GroupByColumnTypes =
| "labels"
| "assignees"
| "created_by"
| "team_project";
| "team_project"
| "milestone";
export type TGetColumns = {
isWorkspaceLevel?: boolean;
@@ -2,6 +2,7 @@ export type TWorkItemExtended = {
customer_ids?: string[];
customer_request_ids?: string[];
initiative_ids?: string[];
milestone_id?: string;
};
export type TWorkItemWidgetsExtended = "customer_requests" | "pages";
@@ -101,6 +101,7 @@ export type TProjectFeatures = {
} & TProjectFeaturesList;
export type TProjectIssuesSearchParamsExtended = {
milestone_id?: string;
customer_request_id?: string;
convert?: boolean;
};
+3 -1
View File
@@ -11,6 +11,8 @@ export type TExtendedIssueOrderByOptions =
| "customer_count"
| "-customer_count";
export const WORK_ITEM_FILTER_PROPERTY_KEYS_EXTENDED = ["team_project_id", "type_id", "name"] as const;
export const WORK_ITEM_FILTER_PROPERTY_KEYS_EXTENDED = ["team_project_id", "type_id", "name", "milestone"] as const;
export type TExtendedWorkItemFilterProperty = TCustomPropertyFilterKey;
export type TExtendedIssueGroupByOptions = "milestone";
+5 -2
View File
@@ -1,8 +1,9 @@
import { TIssue } from "./issues/issue";
import { EIssueLayoutTypes, TIssue } from "./issues/issue";
import { LOGICAL_OPERATOR, TSupportedOperators } from "./rich-filters";
import { CompleteOrEmpty } from "./utils";
import {
IExtendedIssueDisplayProperties,
TExtendedIssueGroupByOptions,
TExtendedIssueOrderByOptions,
TExtendedWorkItemFilterProperty,
WORK_ITEM_FILTER_PROPERTY_KEYS_EXTENDED,
@@ -22,6 +23,7 @@ export type TIssueGroupByOptions =
| "module"
| "target_date"
| "team_project"
| TExtendedIssueGroupByOptions
| null;
export type TIssueOrderByOptions =
@@ -86,7 +88,8 @@ export type TIssueParams =
| "issue_type"
| "layout"
| "expand"
| "filters";
| "filters"
| "milestone";
export type TCalendarLayouts = "month" | "week";