[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:
Akshita Goyal
2025-01-29 16:07:36 +05:30
committed by GitHub
parent 836f2155e6
commit 01fc205cf0
12 changed files with 466 additions and 99 deletions

View File

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

View File

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

View File

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

View 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>
);
});

View File

@@ -0,0 +1 @@
export * from "./epics";

View File

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

View File

@@ -22,6 +22,7 @@ const defaultValues: Partial<TInitiative> = {
end_date: null,
lead: null,
project_ids: [],
epic_ids: [],
};
export const CreateUpdateInitiativeModal = observer((props: Props) => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ export type TInitiative = {
workspace: string;
lead: string | null;
project_ids: string[] | null;
epic_ids: string[] | null;
};
export type TInitiativeProject = {