[WEB-5243]feat: milestone grouping for work items (#4596)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
+4
-2
@@ -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]);
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
+44
-17
@@ -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
|
||||
|
||||
+69
-51
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
+6
-4
@@ -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;
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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" }];
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user