mirror of
https://github.com/makeplane/plane.git
synced 2026-07-12 21:40:18 +02:00
[WEB-3217] feat: added epics to initiatives modal and list layout (#2287)
* feat: added epics to initiatives modal and list layout * fix: initiative indicator * chore: add epics while creating initiative * feat: Update epics in same API * fix: epics dropdown * fix: epics api call * activity logs of initiative epics * Return epic ids in list initiative list api * Update initative activity * fix: patch api response only updating on second call * fix: progress indicator moved + empty initiatives * feat: Create initiatves without project and epic --------- Co-authored-by: sangeethailango <sangeethailango21@gmail.com>
This commit is contained in:
@@ -9,18 +9,15 @@ from celery import shared_task
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
CommentReaction,
|
||||
Label,
|
||||
Project,
|
||||
Workspace,
|
||||
)
|
||||
from plane.db.models import CommentReaction, Label, Project, Workspace
|
||||
from plane.ee.models import (
|
||||
Initiative,
|
||||
InitiativeActivity,
|
||||
InitiativeReaction,
|
||||
InitiativeComment,
|
||||
)
|
||||
from plane.db.models import Issue
|
||||
|
||||
from plane.settings.redis import redis_instance
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
@@ -130,9 +127,7 @@ def track_end_date(
|
||||
initiative_activities,
|
||||
epoch,
|
||||
):
|
||||
if current_instance.get("end_date") != requested_data.get(
|
||||
"end_date"
|
||||
):
|
||||
if current_instance.get("end_date") != requested_data.get("end_date"):
|
||||
initiative_activities.append(
|
||||
InitiativeActivity(
|
||||
initiative_id=initiative_id,
|
||||
@@ -200,12 +195,8 @@ def track_labels(
|
||||
initiative_activities,
|
||||
epoch,
|
||||
):
|
||||
requested_labels = set(
|
||||
[str(lab) for lab in requested_data.get("label_ids", [])]
|
||||
)
|
||||
current_labels = set(
|
||||
[str(lab) for lab in current_instance.get("label_ids", [])]
|
||||
)
|
||||
requested_labels = set([str(lab) for lab in requested_data.get("label_ids", [])])
|
||||
current_labels = set([str(lab) for lab in current_instance.get("label_ids", [])])
|
||||
|
||||
added_labels = requested_labels - current_labels
|
||||
dropped_labels = current_labels - requested_labels
|
||||
@@ -259,7 +250,6 @@ def track_projects(
|
||||
initiative_activities,
|
||||
epoch,
|
||||
):
|
||||
|
||||
requested_projects = (
|
||||
set([str(asg) for asg in requested_data.get("project_ids", [])])
|
||||
if requested_data is not None
|
||||
@@ -309,6 +299,65 @@ def track_projects(
|
||||
)
|
||||
|
||||
|
||||
def track_epics(
|
||||
requested_data,
|
||||
current_instance,
|
||||
initiative_id,
|
||||
workspace_id,
|
||||
actor_id,
|
||||
initiative_activities,
|
||||
epoch,
|
||||
):
|
||||
requested_epics = (
|
||||
set([str(asg) for asg in requested_data.get("epic_ids", [])])
|
||||
if requested_data is not None
|
||||
else set()
|
||||
)
|
||||
current_epics = (
|
||||
set([str(asg) for asg in current_instance.get("epic_ids", [])])
|
||||
if current_instance is not None
|
||||
else set()
|
||||
)
|
||||
|
||||
added_epics = requested_epics - current_epics
|
||||
dropped_epics = current_epics - requested_epics
|
||||
|
||||
for added_epic in added_epics:
|
||||
epic = Issue.objects.get(pk=added_epic)
|
||||
|
||||
initiative_activities.append(
|
||||
InitiativeActivity(
|
||||
initiative_id=initiative_id,
|
||||
actor_id=actor_id,
|
||||
workspace_id=workspace_id,
|
||||
verb="updated",
|
||||
old_value="",
|
||||
new_value=epic.name,
|
||||
field="epics",
|
||||
comment="added epic ",
|
||||
new_identifier=epic.id,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
for dropped_epic in dropped_epics:
|
||||
epic = Issue.objects.get(pk=dropped_epic)
|
||||
initiative_activities.append(
|
||||
InitiativeActivity(
|
||||
initiative_id=initiative_id,
|
||||
actor_id=actor_id,
|
||||
workspace_id=workspace_id,
|
||||
verb="updated",
|
||||
old_value=epic.name,
|
||||
new_value="",
|
||||
field="epics",
|
||||
comment="removed epic ",
|
||||
old_identifier=epic.id,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def track_lead(
|
||||
requested_data,
|
||||
current_instance,
|
||||
@@ -378,11 +427,11 @@ def update_initiative_activity(
|
||||
"lead": track_lead,
|
||||
"label_ids": track_labels,
|
||||
"project_ids": track_projects,
|
||||
"epic_ids": track_epics,
|
||||
}
|
||||
|
||||
requested_data = (
|
||||
json.loads(requested_data) if requested_data is not None else None
|
||||
)
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
@@ -432,9 +481,7 @@ def create_comment_activity(
|
||||
initiative_activities,
|
||||
epoch,
|
||||
):
|
||||
requested_data = (
|
||||
json.loads(requested_data) if requested_data is not None else None
|
||||
)
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
@@ -464,16 +511,12 @@ def update_comment_activity(
|
||||
initiative_activities,
|
||||
epoch,
|
||||
):
|
||||
requested_data = (
|
||||
json.loads(requested_data) if requested_data is not None else None
|
||||
)
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
|
||||
if current_instance.get("comment_html") != requested_data.get(
|
||||
"comment_html"
|
||||
):
|
||||
if current_instance.get("comment_html") != requested_data.get("comment_html"):
|
||||
initiative_activities.append(
|
||||
InitiativeActivity(
|
||||
initiative_id=initiative_id,
|
||||
@@ -523,9 +566,7 @@ def create_link_activity(
|
||||
initiative_activities,
|
||||
epoch,
|
||||
):
|
||||
requested_data = (
|
||||
json.loads(requested_data) if requested_data is not None else None
|
||||
)
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
@@ -554,9 +595,7 @@ def update_link_activity(
|
||||
initiative_activities,
|
||||
epoch,
|
||||
):
|
||||
requested_data = (
|
||||
json.loads(requested_data) if requested_data is not None else None
|
||||
)
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
@@ -616,9 +655,7 @@ def create_attachment_activity(
|
||||
initiative_activities,
|
||||
epoch,
|
||||
):
|
||||
requested_data = (
|
||||
json.loads(requested_data) if requested_data is not None else None
|
||||
)
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
@@ -669,9 +706,7 @@ def create_initiative_reaction_activity(
|
||||
initiative_activities,
|
||||
epoch,
|
||||
):
|
||||
requested_data = (
|
||||
json.loads(requested_data) if requested_data is not None else None
|
||||
)
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
if requested_data and requested_data.get("reaction") is not None:
|
||||
initiative_reaction = (
|
||||
InitiativeReaction.objects.filter(
|
||||
@@ -739,9 +774,7 @@ def create_comment_reaction_activity(
|
||||
initiative_activities,
|
||||
epoch,
|
||||
):
|
||||
requested_data = (
|
||||
json.loads(requested_data) if requested_data is not None else None
|
||||
)
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
if requested_data and requested_data.get("reaction") is not None:
|
||||
comment_reaction_id, comment_id = (
|
||||
CommentReaction.objects.filter(
|
||||
@@ -792,8 +825,7 @@ def delete_comment_reaction_activity(
|
||||
if current_instance and current_instance.get("reaction") is not None:
|
||||
initiative_id = (
|
||||
InitiativeComment.objects.filter(
|
||||
pk=current_instance.get("comment_id"),
|
||||
initiative_id=initiative_id,
|
||||
pk=current_instance.get("comment_id"), initiative_id=initiative_id
|
||||
)
|
||||
.values_list("initiative_id", flat=True)
|
||||
.first()
|
||||
|
||||
@@ -37,6 +37,7 @@ class InitiativeCommentReactionSerializer(BaseSerializer):
|
||||
|
||||
class InitiativeSerializer(BaseSerializer):
|
||||
project_ids = serializers.ListField(child=serializers.UUIDField(), required=False)
|
||||
epic_ids = serializers.ListField(child=serializers.UUIDField(), required=False)
|
||||
label_ids = serializers.ListField(child=serializers.UUIDField(), required=False)
|
||||
reactions = InitiativeCommentReactionSerializer(
|
||||
read_only=True, many=True, source="initiative_reactions"
|
||||
@@ -49,6 +50,7 @@ class InitiativeSerializer(BaseSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
projects = validated_data.pop("project_ids", None)
|
||||
epics = validated_data.pop("epic_ids", None)
|
||||
labels = validated_data.pop("label_ids", None)
|
||||
workspace_id = self.context["workspace_id"]
|
||||
lead = self.context["lead"]
|
||||
@@ -91,11 +93,27 @@ class InitiativeSerializer(BaseSerializer):
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
if epics is not None and len(epics):
|
||||
InitiativeEpic.objects.bulk_create(
|
||||
[
|
||||
InitiativeEpic(
|
||||
epic_id=epic_id,
|
||||
initiative=initiative,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for epic_id in epics
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
return initiative
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
projects = validated_data.pop("project_ids", None)
|
||||
labels = validated_data.pop("label_ids", None)
|
||||
epics = validated_data.pop("epic_ids", None)
|
||||
|
||||
# Related models
|
||||
workspace_id = instance.workspace_id
|
||||
@@ -134,6 +152,22 @@ class InitiativeSerializer(BaseSerializer):
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
if epics is not None:
|
||||
InitiativeEpic.objects.filter(initiative=instance).delete()
|
||||
InitiativeEpic.objects.bulk_create(
|
||||
[
|
||||
InitiativeEpic(
|
||||
epic_id=epic,
|
||||
initiative=instance,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for epic in epics
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
# Time updation occurs even when other related models are updated
|
||||
instance.updated_at = timezone.now()
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
@@ -53,7 +53,19 @@ class InitiativeEndpoint(BaseAPIView):
|
||||
.values("project_ids")
|
||||
),
|
||||
[],
|
||||
)
|
||||
),
|
||||
epic_ids=Coalesce(
|
||||
Subquery(
|
||||
InitiativeEpic.objects.filter(
|
||||
initiative_id=OuterRef("pk"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
.values("initiative_id")
|
||||
.annotate(epic_ids=ArrayAgg("epic_id", distinct=True))
|
||||
.values("epic_ids")
|
||||
),
|
||||
[],
|
||||
),
|
||||
)
|
||||
.order_by(self.kwargs.get("order_by", "-created_at"))
|
||||
.distinct()
|
||||
@@ -77,7 +89,19 @@ class InitiativeEndpoint(BaseAPIView):
|
||||
.values("project_ids")
|
||||
),
|
||||
[],
|
||||
)
|
||||
),
|
||||
epic_ids=Coalesce(
|
||||
Subquery(
|
||||
InitiativeEpic.objects.filter(
|
||||
initiative_id=OuterRef("pk"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
.values("initiative_id")
|
||||
.annotate(epic_ids=ArrayAgg("epic_id", distinct=True))
|
||||
.values("epic_ids")
|
||||
),
|
||||
[],
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
link_count=InitiativeLink.objects.filter(
|
||||
@@ -107,6 +131,7 @@ class InitiativeEndpoint(BaseAPIView):
|
||||
.first()
|
||||
)
|
||||
serializer = InitiativeSerializer(initiative)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
# Get all initiatives in workspace
|
||||
@@ -118,13 +143,6 @@ class InitiativeEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def post(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
project_ids = request.data.get("project_ids", [])
|
||||
|
||||
if not project_ids:
|
||||
return Response(
|
||||
{"error": "Project id's are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = InitiativeSerializer(
|
||||
data=request.data,
|
||||
@@ -149,6 +167,7 @@ class InitiativeEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
initiative = self.get_queryset().get(pk=serializer.data.get("id"))
|
||||
|
||||
serializer = InitiativeSerializer(initiative)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -168,18 +187,32 @@ class InitiativeEndpoint(BaseAPIView):
|
||||
.values("project_ids")
|
||||
),
|
||||
[],
|
||||
)
|
||||
),
|
||||
epic_ids=Coalesce(
|
||||
Subquery(
|
||||
InitiativeEpic.objects.filter(
|
||||
initiative_id=OuterRef("pk"), workspace__slug=slug
|
||||
)
|
||||
.values("initiative_id")
|
||||
.annotate(epic_ids=ArrayAgg("epic_id", distinct=True))
|
||||
.values("epic_ids")
|
||||
),
|
||||
[],
|
||||
),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
current_instance = json.dumps(
|
||||
InitiativeSerializer(initiative).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
|
||||
|
||||
serializer = InitiativeSerializer(initiative, data=request.data, partial=True)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
|
||||
initiative_activity.delay(
|
||||
type="initiative.activity.updated",
|
||||
slug=slug,
|
||||
@@ -191,6 +224,9 @@ class InitiativeEndpoint(BaseAPIView):
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
initiative = self.get_queryset().get(pk=pk)
|
||||
serializer = InitiativeSerializer(initiative)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -479,21 +515,16 @@ class InitiativeEpicAnalytics(BaseAPIView):
|
||||
)
|
||||
result = []
|
||||
for epic_id in initiative_epic:
|
||||
|
||||
# get all the issues of the particular epic
|
||||
issue_ids = get_all_related_issues(epic_id)
|
||||
|
||||
completed_issues = (
|
||||
issues.filter(
|
||||
id__in=issue_ids, workspace__slug=slug
|
||||
)
|
||||
issues.filter(id__in=issue_ids, workspace__slug=slug)
|
||||
.filter(state__group="completed")
|
||||
.count()
|
||||
)
|
||||
|
||||
total_issues = issues.filter(
|
||||
id__in=issue_ids, workspace__slug=slug
|
||||
).count()
|
||||
total_issues = issues.filter(id__in=issue_ids, workspace__slug=slug).count()
|
||||
|
||||
result.append(
|
||||
{
|
||||
|
||||
252
web/ee/components/dropdowns/epics.tsx
Normal file
252
web/ee/components/dropdowns/epics.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import { ReactNode, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { usePopper } from "react-popper";
|
||||
import useSWR from "swr";
|
||||
import { Check, ChevronDown, Search } from "lucide-react";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
// ui
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TWorkspaceEpicsSearchParams } from "@plane/types";
|
||||
import { ComboDropDown, EpicIcon } from "@plane/ui";
|
||||
// helpers
|
||||
import { DropdownButton } from "@/components/dropdowns/buttons";
|
||||
import { BUTTON_VARIANTS_WITH_TEXT } from "@/components/dropdowns/constants";
|
||||
import { TDropdownProps } from "@/components/dropdowns/types";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useDropdown } from "@/hooks/use-dropdown";
|
||||
// plane web types
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
|
||||
type Props = TDropdownProps & {
|
||||
button?: ReactNode;
|
||||
dropdownArrow?: boolean;
|
||||
dropdownArrowClassName?: string;
|
||||
onClose?: () => void;
|
||||
renderByDefault?: boolean;
|
||||
searchParams: Partial<TWorkspaceEpicsSearchParams>;
|
||||
} & (
|
||||
| {
|
||||
multiple: false;
|
||||
onChange: (val: string) => void;
|
||||
value: string | null;
|
||||
}
|
||||
| {
|
||||
multiple: true;
|
||||
onChange: (val: string[]) => void;
|
||||
value: string[];
|
||||
}
|
||||
);
|
||||
const workspaceService = new WorkspaceService();
|
||||
export const EpicsDropdown: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
button,
|
||||
buttonClassName,
|
||||
buttonContainerClassName,
|
||||
buttonVariant,
|
||||
className = "",
|
||||
disabled = false,
|
||||
dropdownArrow = false,
|
||||
dropdownArrowClassName = "",
|
||||
hideIcon = false,
|
||||
multiple,
|
||||
onChange,
|
||||
onClose,
|
||||
placeholder = "Epic",
|
||||
placement,
|
||||
showTooltip = false,
|
||||
tabIndex,
|
||||
value,
|
||||
renderByDefault = true,
|
||||
searchParams,
|
||||
} = props;
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// states
|
||||
const [query, setQuery] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
// popper-js refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
// popper-js init
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: placement ?? "bottom-start",
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
// store hooks
|
||||
const { t } = useTranslation();
|
||||
const { handleClose, handleKeyDown, handleOnClick, searchInputKeyDown } = useDropdown({
|
||||
dropdownRef,
|
||||
inputRef,
|
||||
isOpen,
|
||||
onClose,
|
||||
query,
|
||||
setIsOpen,
|
||||
setQuery,
|
||||
});
|
||||
const { data: epics, isLoading } = useSWR(
|
||||
workspaceSlug ? `WORKSPACE_EPICS_${workspaceSlug}` : null,
|
||||
workspaceSlug ? () => workspaceService.fetchWorkspaceEpics(workspaceSlug.toString(), {}) : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
const getEpicById = (id: string) => epics?.find((epic) => epic.id === id);
|
||||
const filteredEpics = epics
|
||||
? query
|
||||
? epics?.filter((epic) => epic.name.toLowerCase().includes(query.toLowerCase()))
|
||||
: epics
|
||||
: [];
|
||||
|
||||
const dropdownOnChange = (val: string & string[]) => {
|
||||
onChange(val);
|
||||
if (!multiple) handleClose();
|
||||
};
|
||||
const getDisplayName = (value: string | string[] | null, placeholder: string = "") => {
|
||||
if (Array.isArray(value)) {
|
||||
const firstEpic = getEpicById(value[0]);
|
||||
return value.length ? (value.length === 1 ? firstEpic?.name : `${value.length} epics`) : placeholder;
|
||||
} else {
|
||||
return value ? (getEpicById(value)?.name ?? placeholder) : placeholder;
|
||||
}
|
||||
};
|
||||
|
||||
const comboButton = (
|
||||
<>
|
||||
{button ? (
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
|
||||
onClick={handleOnClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{button}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"clickable block h-full max-w-full outline-none",
|
||||
{
|
||||
"cursor-not-allowed text-custom-text-200": disabled,
|
||||
"cursor-pointer": !disabled,
|
||||
},
|
||||
buttonContainerClassName
|
||||
)}
|
||||
onClick={handleOnClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<DropdownButton
|
||||
className={buttonClassName}
|
||||
isActive={isOpen}
|
||||
tooltipHeading="Epic"
|
||||
tooltipContent={value?.length ? `${value.length} epic${value.length !== 1 ? "s" : ""}` : placeholder}
|
||||
showTooltip={showTooltip}
|
||||
variant={buttonVariant}
|
||||
renderToolTipByDefault={renderByDefault}
|
||||
>
|
||||
{!hideIcon && <EpicIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
|
||||
<span className="flex-grow truncate max-w-40">{getDisplayName(value, placeholder)}</span>
|
||||
)}
|
||||
{dropdownArrow && (
|
||||
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
|
||||
)}
|
||||
</DropdownButton>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<ComboDropDown
|
||||
as="div"
|
||||
ref={dropdownRef}
|
||||
tabIndex={tabIndex}
|
||||
className={cn("h-full", className)}
|
||||
value={value}
|
||||
onChange={dropdownOnChange}
|
||||
disabled={disabled}
|
||||
onKeyDown={handleKeyDown}
|
||||
button={comboButton}
|
||||
renderByDefault={renderByDefault}
|
||||
multiple={multiple}
|
||||
>
|
||||
{isOpen &&
|
||||
(isLoading ? (
|
||||
<div className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none">
|
||||
<p className="text-custom-text-400 italic py-1 px-1.5">{t("loading")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<div
|
||||
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||
<Combobox.Input
|
||||
as="input"
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("search")}
|
||||
displayValue={(assigned: any) => assigned?.name}
|
||||
onKeyDown={searchInputKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||
{filteredEpics ? (
|
||||
filteredEpics.length > 0 ? (
|
||||
filteredEpics.map((option) => {
|
||||
if (!option) return;
|
||||
return (
|
||||
<Combobox.Option
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
className={({ active, selected }) =>
|
||||
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
|
||||
active ? "bg-custom-background-80" : ""
|
||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<EpicIcon className="h-4 w-4 text-custom-text-300" />
|
||||
<span className="flex-grow truncate">{option.name}</span>
|
||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="text-custom-text-400 italic py-1 px-1.5">{t("no_matching_results")}</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-custom-text-400 italic py-1 px-1.5">{t("loading")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
))}
|
||||
</ComboDropDown>
|
||||
);
|
||||
});
|
||||
1
web/ee/components/dropdowns/index.ts
Normal file
1
web/ee/components/dropdowns/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./epics";
|
||||
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Users } from "lucide-react";
|
||||
// plane
|
||||
import { Avatar, setToast, TOAST_TYPE, Tooltip } from "@plane/ui";
|
||||
import { Avatar, Tooltip } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { DateRangeDropdown, MemberDropdown, ProjectDropdown } from "@/components/dropdowns";
|
||||
@@ -14,6 +14,7 @@ import { useMember } from "@/hooks/store";
|
||||
// Plane Web
|
||||
import { useInitiatives } from "@/plane-web/hooks/store/use-initiatives";
|
||||
import { TInitiative } from "@/plane-web/types/initiative";
|
||||
import { EpicsDropdown } from "../../dropdowns";
|
||||
|
||||
type Props = {
|
||||
initiative: TInitiative;
|
||||
@@ -46,23 +47,11 @@ export const BlockProperties = observer((props: Props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleProjects = (ids: string | string[]) => {
|
||||
if (ids.length === 0) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Please select at least one project.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const handleChange = (updatedData: Partial<TInitiative>) => {
|
||||
if (updateInitiative) {
|
||||
updateInitiative(workspaceSlug.toString(), initiative.id, {
|
||||
project_ids: ids ? (Array.isArray(ids) ? ids : [ids]) : null,
|
||||
});
|
||||
updateInitiative(workspaceSlug.toString(), initiative.id, updatedData);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-wrap ${isSidebarCollapsed ? "md:flex-grow md:flex-shrink-0" : "lg:flex-grow lg:flex-shrink-0"} items-center gap-2 whitespace-nowrap`}
|
||||
@@ -134,13 +123,27 @@ export const BlockProperties = observer((props: Props) => {
|
||||
<div className="h-5">
|
||||
<ProjectDropdown
|
||||
buttonVariant={"border-with-text"}
|
||||
onChange={handleProjects}
|
||||
onChange={(ids) => handleChange({ project_ids: ids ? (Array.isArray(ids) ? ids : [ids]) : null })}
|
||||
value={initiative.project_ids || []}
|
||||
multiple
|
||||
showTooltip
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-5">
|
||||
<EpicsDropdown
|
||||
buttonVariant={"border-with-text"}
|
||||
onChange={(ids) => handleChange({ epic_ids: ids ? (Array.isArray(ids) ? ids : [ids]) : null })}
|
||||
value={initiative.epic_ids || []}
|
||||
multiple
|
||||
showTooltip
|
||||
disabled={disabled}
|
||||
searchParams={{
|
||||
initiative_id: initiative.id,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ const defaultValues: Partial<TInitiative> = {
|
||||
end_date: null,
|
||||
lead: null,
|
||||
project_ids: [],
|
||||
epic_ids: [],
|
||||
};
|
||||
|
||||
export const CreateUpdateInitiativeModal = observer((props: Props) => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { FC, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { EFileAssetType } from "@plane/types/src/enums";
|
||||
import { cn } from "@plane/utils";
|
||||
import { Button, Input, setToast, TOAST_TYPE } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { DateRangeDropdown, MemberDropdown, ProjectDropdown } from "@/components/dropdowns";
|
||||
import { RichTextEditor } from "@/components/editor";
|
||||
@@ -17,6 +17,7 @@ import { useEditorMentionSearch } from "@/plane-web/hooks/use-editor-mention-sea
|
||||
import { TInitiative } from "@/plane-web/types/initiative";
|
||||
// services
|
||||
import { FileService } from "@/services/file.service";
|
||||
import { EpicsDropdown } from "../../dropdowns";
|
||||
|
||||
const fileService = new FileService();
|
||||
|
||||
@@ -50,9 +51,6 @@ export const CreateUpdateInitiativeForm: FC<Props> = (props) => {
|
||||
if (!data.name || data.name.trim() === "") {
|
||||
newErrors.name = "Name is required";
|
||||
}
|
||||
if (!data.project_ids || data.project_ids.length === 0) {
|
||||
newErrors.project_ids = "Project is required";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
@@ -147,6 +145,21 @@ export const CreateUpdateInitiativeForm: FC<Props> = (props) => {
|
||||
tabIndex={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-7">
|
||||
<EpicsDropdown
|
||||
buttonVariant={"border-with-text"}
|
||||
onChange={(val) => {
|
||||
handleFormDataChange("epic_ids", val);
|
||||
}}
|
||||
value={formData.epic_ids || []}
|
||||
multiple
|
||||
showTooltip
|
||||
tabIndex={2}
|
||||
searchParams={{
|
||||
initiative_id: initiativeDetail?.id,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DateRangeDropdown
|
||||
buttonVariant="border-with-text"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { ListItem } from "@/components/core/list";
|
||||
// hooks
|
||||
import { getProgress } from "@/helpers/common.helper";
|
||||
import { useAppTheme, useUserPermissions } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web
|
||||
@@ -16,7 +17,6 @@ import { useInitiatives } from "@/plane-web/hooks/store/use-initiatives";
|
||||
// local components
|
||||
import { BlockProperties } from "./block-properties";
|
||||
import { InitiativeQuickActions } from "./quick-actions";
|
||||
import { getProgress } from "@/helpers/common.helper";
|
||||
|
||||
type Props = {
|
||||
initiativeId: string;
|
||||
@@ -46,7 +46,7 @@ export const InitiativeBlock = observer((props: Props) => {
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
const progress = getProgress(initiativeStats?.completed_issues, initiativeStats?.total_issues);
|
||||
const progress = getProgress(initiativeStats?.completed_issues ?? 0, initiativeStats?.total_issues);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
@@ -57,14 +57,18 @@ export const InitiativeBlock = observer((props: Props) => {
|
||||
<InitiativeIcon className="size-4 text-custom-text-300" />
|
||||
</div>
|
||||
}
|
||||
quickActionElement={
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
appendTitleElement={
|
||||
<>
|
||||
{initiativeStats && initiativeStats.total_issues > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<CircularProgressIndicator size={20} percentage={progress} strokeWidth={3} />
|
||||
<span className="text-sm font-medium text-custom-text-300 px-1">{`${progress}%`}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
quickActionElement={
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<BlockProperties initiative={initiative} isSidebarCollapsed={isSidebarCollapsed} disabled={!isEditable} />
|
||||
<div
|
||||
className={cn("hidden", {
|
||||
|
||||
@@ -19,14 +19,12 @@ export const QuickActions: React.FC<Props> = observer((props: Props) => {
|
||||
const { workspaceSlug, initiativeId, project } = props;
|
||||
// store hooks
|
||||
const {
|
||||
initiative: { getInitiativeById, updateInitiative },
|
||||
initiative: { updateInitiative, getInitiativeById },
|
||||
} = useInitiatives();
|
||||
|
||||
// derived states
|
||||
const projectLink = `${workspaceSlug}/projects/${project.id}/issues`;
|
||||
const initiative = initiativeId ? getInitiativeById(initiativeId) : undefined;
|
||||
|
||||
const shouldRenderRemove = (initiative?.project_ids || [])?.length > 1;
|
||||
const initiative = getInitiativeById(initiativeId);
|
||||
|
||||
// handler
|
||||
const handleCopyText = () =>
|
||||
@@ -44,14 +42,15 @@ export const QuickActions: React.FC<Props> = observer((props: Props) => {
|
||||
action: handleCopyText,
|
||||
title: "Copy link",
|
||||
icon: LinkIcon,
|
||||
shouldRender: true,
|
||||
},
|
||||
{
|
||||
key: "remove",
|
||||
action: () => updateInitiative(workspaceSlug, initiativeId, { project_ids: [project.id] }),
|
||||
action: () =>
|
||||
updateInitiative(workspaceSlug, initiativeId, {
|
||||
project_ids: initiative?.project_ids ? initiative?.project_ids.filter((id) => id !== project.id) : [],
|
||||
}),
|
||||
title: "Remove",
|
||||
icon: Trash2,
|
||||
shouldRender: shouldRenderRemove,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -67,7 +66,7 @@ export const QuickActions: React.FC<Props> = observer((props: Props) => {
|
||||
customButtonClassName="grid place-items-center"
|
||||
placement="bottom-start"
|
||||
>
|
||||
{MENU_ITEMS.filter((item) => item.shouldRender).map((item) => (
|
||||
{MENU_ITEMS.map((item) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -35,11 +35,7 @@ export const InitiativeInfoIndicatorItem: FC<Props> = observer((props) => {
|
||||
? Object.values(omit(initiativeAnalytics, "overdue_issues")).reduce((acc, val) => acc + val, 0)
|
||||
: 0;
|
||||
|
||||
const completedIssue = initiativeAnalytics
|
||||
? initiativeAnalytics.completed_issues + initiativeAnalytics.cancelled_issues
|
||||
: 0;
|
||||
|
||||
const completePercentage = getProgress(completedIssue, totalIssues);
|
||||
const completePercentage = getProgress(initiativeAnalytics?.completed_issues ?? 0, totalIssues);
|
||||
|
||||
if (!hasProject || totalIssues === 0) return <></>;
|
||||
return (
|
||||
|
||||
1
web/ee/types/initiative/initiative.d.ts
vendored
1
web/ee/types/initiative/initiative.d.ts
vendored
@@ -26,6 +26,7 @@ export type TInitiative = {
|
||||
workspace: string;
|
||||
lead: string | null;
|
||||
project_ids: string[] | null;
|
||||
epic_ids: string[] | null;
|
||||
};
|
||||
|
||||
export type TInitiativeProject = {
|
||||
|
||||
Reference in New Issue
Block a user