mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
dev: workspace active cycles (#3378)
* chore: workspace active cycles
* fix: active cycles tab implementation
* chore: added distribution graph for active cycles
* chore: removed distribution graph and issues
* Revert "chore: removed issues"
This reverts commit 7d977ac8b0
.
* chore: workspace active cycles implementation
* chore: code refactor
---------
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
parent
9e8885df5f
commit
8ee5ba96ce
@ -8,10 +8,16 @@ from plane.app.views import (
|
|||||||
CycleFavoriteViewSet,
|
CycleFavoriteViewSet,
|
||||||
TransferCycleIssueEndpoint,
|
TransferCycleIssueEndpoint,
|
||||||
CycleUserPropertiesEndpoint,
|
CycleUserPropertiesEndpoint,
|
||||||
|
ActiveCycleEndpoint
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/active-cycles/",
|
||||||
|
ActiveCycleEndpoint.as_view(),
|
||||||
|
name="workspace-active-cycle",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/",
|
||||||
CycleViewSet.as_view(
|
CycleViewSet.as_view(
|
||||||
|
@ -62,6 +62,7 @@ from .cycle import (
|
|||||||
CycleFavoriteViewSet,
|
CycleFavoriteViewSet,
|
||||||
TransferCycleIssueEndpoint,
|
TransferCycleIssueEndpoint,
|
||||||
CycleUserPropertiesEndpoint,
|
CycleUserPropertiesEndpoint,
|
||||||
|
ActiveCycleEndpoint,
|
||||||
)
|
)
|
||||||
from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
|
from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
|
||||||
from .issue import (
|
from .issue import (
|
||||||
|
@ -39,6 +39,7 @@ from plane.app.serializers import (
|
|||||||
from plane.app.permissions import (
|
from plane.app.permissions import (
|
||||||
ProjectEntityPermission,
|
ProjectEntityPermission,
|
||||||
ProjectLitePermission,
|
ProjectLitePermission,
|
||||||
|
WorkspaceUserPermission
|
||||||
)
|
)
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
User,
|
User,
|
||||||
@ -909,3 +910,235 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
|
|||||||
)
|
)
|
||||||
serializer = CycleUserPropertiesSerializer(cycle_properties)
|
serializer = CycleUserPropertiesSerializer(cycle_properties)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class ActiveCycleEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [
|
||||||
|
WorkspaceUserPermission,
|
||||||
|
]
|
||||||
|
def get(self, request, slug):
|
||||||
|
subquery = CycleFavorite.objects.filter(
|
||||||
|
user=self.request.user,
|
||||||
|
cycle_id=OuterRef("pk"),
|
||||||
|
project_id=self.kwargs.get("project_id"),
|
||||||
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
|
)
|
||||||
|
active_cycles = (
|
||||||
|
Cycle.objects.filter(
|
||||||
|
workspace__slug=slug,
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
start_date__lte=timezone.now(),
|
||||||
|
end_date__gte=timezone.now(),
|
||||||
|
)
|
||||||
|
.select_related("project")
|
||||||
|
.select_related("workspace")
|
||||||
|
.select_related("owned_by")
|
||||||
|
.annotate(is_favorite=Exists(subquery))
|
||||||
|
.annotate(
|
||||||
|
total_issues=Count(
|
||||||
|
"issue_cycle",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="completed",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
cancelled_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="cancelled",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
started_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="started",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
unstarted_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="unstarted",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
backlog_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="backlog",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(total_estimates=Sum("issue_cycle__issue__estimate_point"))
|
||||||
|
.annotate(
|
||||||
|
completed_estimates=Sum(
|
||||||
|
"issue_cycle__issue__estimate_point",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="completed",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
started_estimates=Sum(
|
||||||
|
"issue_cycle__issue__estimate_point",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="started",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
status=Case(
|
||||||
|
When(
|
||||||
|
Q(start_date__lte=timezone.now())
|
||||||
|
& Q(end_date__gte=timezone.now()),
|
||||||
|
then=Value("CURRENT"),
|
||||||
|
),
|
||||||
|
When(start_date__gt=timezone.now(), then=Value("UPCOMING")),
|
||||||
|
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
|
||||||
|
When(
|
||||||
|
Q(start_date__isnull=True) & Q(end_date__isnull=True),
|
||||||
|
then=Value("DRAFT"),
|
||||||
|
),
|
||||||
|
default=Value("DRAFT"),
|
||||||
|
output_field=CharField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"issue_cycle__issue__assignees",
|
||||||
|
queryset=User.objects.only("avatar", "first_name", "id").distinct(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"issue_cycle__issue__labels",
|
||||||
|
queryset=Label.objects.only("name", "color", "id").distinct(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("-created_at")
|
||||||
|
)
|
||||||
|
|
||||||
|
cycles = CycleSerializer(active_cycles, many=True).data
|
||||||
|
|
||||||
|
for cycle in cycles:
|
||||||
|
assignee_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_cycle__cycle_id=cycle["id"],
|
||||||
|
project_id=cycle["project"],
|
||||||
|
workspace__slug=slug,
|
||||||
|
)
|
||||||
|
.annotate(display_name=F("assignees__display_name"))
|
||||||
|
.annotate(assignee_id=F("assignees__id"))
|
||||||
|
.annotate(avatar=F("assignees__avatar"))
|
||||||
|
.values("display_name", "assignee_id", "avatar")
|
||||||
|
.annotate(
|
||||||
|
total_issues=Count(
|
||||||
|
"assignee_id",
|
||||||
|
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"assignee_id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_issues=Count(
|
||||||
|
"assignee_id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("display_name")
|
||||||
|
)
|
||||||
|
|
||||||
|
label_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_cycle__cycle_id=cycle["id"],
|
||||||
|
project_id=cycle["project"],
|
||||||
|
workspace__slug=slug,
|
||||||
|
)
|
||||||
|
.annotate(label_name=F("labels__name"))
|
||||||
|
.annotate(color=F("labels__color"))
|
||||||
|
.annotate(label_id=F("labels__id"))
|
||||||
|
.values("label_name", "color", "label_id")
|
||||||
|
.annotate(
|
||||||
|
total_issues=Count(
|
||||||
|
"label_id",
|
||||||
|
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"label_id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_issues=Count(
|
||||||
|
"label_id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("label_name")
|
||||||
|
)
|
||||||
|
cycle["distribution"] = {
|
||||||
|
"assignees": assignee_distribution,
|
||||||
|
"labels": label_distribution,
|
||||||
|
"completion_chart": {},
|
||||||
|
}
|
||||||
|
if cycle["start_date"] and cycle["end_date"]:
|
||||||
|
cycle["distribution"][
|
||||||
|
"completion_chart"
|
||||||
|
] = burndown_plot(
|
||||||
|
queryset=active_cycles.get(pk=cycle["id"]),
|
||||||
|
slug=slug,
|
||||||
|
project_id=cycle["project"],
|
||||||
|
cycle_id=cycle["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(cycles, status=status.HTTP_200_OK)
|
||||||
|
18
packages/types/src/cycles.d.ts
vendored
18
packages/types/src/cycles.d.ts
vendored
@ -1,4 +1,11 @@
|
|||||||
import type { IUser, TIssue, IProjectLite, IWorkspaceLite, IIssueFilterOptions, IUserLite } from "@plane/types";
|
import type {
|
||||||
|
IUser,
|
||||||
|
TIssue,
|
||||||
|
IProjectLite,
|
||||||
|
IWorkspaceLite,
|
||||||
|
IIssueFilterOptions,
|
||||||
|
IUserLite,
|
||||||
|
} from "@plane/types";
|
||||||
|
|
||||||
export type TCycleView = "all" | "active" | "upcoming" | "completed" | "draft";
|
export type TCycleView = "all" | "active" | "upcoming" | "completed" | "draft";
|
||||||
|
|
||||||
@ -40,6 +47,7 @@ export interface ICycle {
|
|||||||
};
|
};
|
||||||
workspace: string;
|
workspace: string;
|
||||||
workspace_detail: IWorkspaceLite;
|
workspace_detail: IWorkspaceLite;
|
||||||
|
issues?: TIssue[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TAssigneesDistribution = {
|
export type TAssigneesDistribution = {
|
||||||
@ -80,9 +88,13 @@ export interface CycleIssueResponse {
|
|||||||
sub_issues_count: number;
|
sub_issues_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SelectCycleType = (ICycle & { actionType: "edit" | "delete" | "create-issue" }) | undefined;
|
export type SelectCycleType =
|
||||||
|
| (ICycle & { actionType: "edit" | "delete" | "create-issue" })
|
||||||
|
| undefined;
|
||||||
|
|
||||||
export type SelectIssue = (TIssue & { actionType: "edit" | "delete" | "create" }) | null;
|
export type SelectIssue =
|
||||||
|
| (TIssue & { actionType: "edit" | "delete" | "create" })
|
||||||
|
| null;
|
||||||
|
|
||||||
export type CycleDateCheckData = {
|
export type CycleDateCheckData = {
|
||||||
start_date: string;
|
start_date: string;
|
||||||
|
13
packages/types/src/projects.d.ts
vendored
13
packages/types/src/projects.d.ts
vendored
@ -1,5 +1,11 @@
|
|||||||
import { EUserProjectRoles } from "constants/project";
|
import { EUserProjectRoles } from "constants/project";
|
||||||
import type { IUser, IUserLite, IWorkspace, IWorkspaceLite, TStateGroups } from ".";
|
import type {
|
||||||
|
IUser,
|
||||||
|
IUserLite,
|
||||||
|
IWorkspace,
|
||||||
|
IWorkspaceLite,
|
||||||
|
TStateGroups,
|
||||||
|
} from ".";
|
||||||
|
|
||||||
export interface IProject {
|
export interface IProject {
|
||||||
archive_in: number;
|
archive_in: number;
|
||||||
@ -52,6 +58,11 @@ export interface IProjectLite {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
identifier: string;
|
identifier: string;
|
||||||
|
emoji: string | null;
|
||||||
|
icon_prop: {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProjectPreferences = {
|
type ProjectPreferences = {
|
||||||
|
254
web/components/cycles/active-cycle-info.tsx
Normal file
254
web/components/cycles/active-cycle-info.tsx
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
import { FC, MouseEvent, useCallback } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
// ui
|
||||||
|
import {
|
||||||
|
AvatarGroup,
|
||||||
|
Tooltip,
|
||||||
|
LinearProgressIndicator,
|
||||||
|
ContrastIcon,
|
||||||
|
RunningIcon,
|
||||||
|
LayersIcon,
|
||||||
|
StateGroupIcon,
|
||||||
|
Avatar,
|
||||||
|
} from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import { SingleProgressStats } from "components/core";
|
||||||
|
import { ActiveCycleProgressStats } from "./active-cycle-stats";
|
||||||
|
// hooks
|
||||||
|
import { useCycle } from "hooks/store";
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
|
import useLocalStorage from "hooks/use-local-storage";
|
||||||
|
// icons
|
||||||
|
import { ArrowRight, CalendarDays, Star, Target } from "lucide-react";
|
||||||
|
// types
|
||||||
|
import { ICycle, TCycleLayout, TCycleView } from "@plane/types";
|
||||||
|
// helpers
|
||||||
|
import { renderFormattedDate, findHowManyDaysLeft } from "helpers/date-time.helper";
|
||||||
|
import { truncateText } from "helpers/string.helper";
|
||||||
|
// constants
|
||||||
|
import { STATE_GROUPS_DETAILS } from "constants/cycle";
|
||||||
|
|
||||||
|
export type ActiveCycleInfoProps = {
|
||||||
|
cycle: ICycle;
|
||||||
|
workspaceSlug: string;
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ActiveCycleInfo: FC<ActiveCycleInfoProps> = (props) => {
|
||||||
|
const { cycle, workspaceSlug, projectId } = props;
|
||||||
|
|
||||||
|
// store
|
||||||
|
const { addCycleToFavorites, removeCycleFromFavorites } = useCycle();
|
||||||
|
// local storage
|
||||||
|
const { setValue: setCycleTab } = useLocalStorage<TCycleView>("cycle_tab", "active");
|
||||||
|
const { setValue: setCycleLayout } = useLocalStorage<TCycleLayout>("cycle_layout", "list");
|
||||||
|
// toast alert
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
const groupedIssues: any = {
|
||||||
|
backlog: cycle.backlog_issues,
|
||||||
|
unstarted: cycle.unstarted_issues,
|
||||||
|
started: cycle.started_issues,
|
||||||
|
completed: cycle.completed_issues,
|
||||||
|
cancelled: cycle.cancelled_issues,
|
||||||
|
};
|
||||||
|
|
||||||
|
const progressIndicatorData = STATE_GROUPS_DETAILS.map((group, index) => ({
|
||||||
|
id: index,
|
||||||
|
name: group.title,
|
||||||
|
value: cycle.total_issues > 0 ? ((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100 : 0,
|
||||||
|
color: group.color,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleCurrentLayout = useCallback(
|
||||||
|
(_layout: TCycleLayout) => {
|
||||||
|
setCycleLayout(_layout);
|
||||||
|
},
|
||||||
|
[setCycleLayout]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCurrentView = useCallback(
|
||||||
|
(_view: TCycleView) => {
|
||||||
|
setCycleTab(_view);
|
||||||
|
if (_view === "draft") handleCurrentLayout("list");
|
||||||
|
},
|
||||||
|
[handleCurrentLayout, setCycleTab]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
|
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycle.id).catch(() => {
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message: "Couldn't add the cycle to favorites. Please try again.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFromFavorites = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
|
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycle.id).catch(() => {
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message: "Couldn't add the cycle to favorites. Please try again.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid-row-2 grid divide-y rounded-[10px] border border-custom-border-200 bg-custom-background-100 shadow">
|
||||||
|
<div className="grid grid-cols-1 divide-y border-custom-border-200 lg:grid-cols-3 lg:divide-x lg:divide-y-0">
|
||||||
|
<div className="flex flex-col text-xs">
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<div className="flex h-60 flex-col justify-between gap-5 rounded-b-[10px] p-4">
|
||||||
|
<div className="flex items-center justify-between gap-1">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="h-5 w-5">
|
||||||
|
<ContrastIcon className="h-5 w-5" color="#09A953" />
|
||||||
|
</span>
|
||||||
|
<Tooltip tooltipContent={cycle.name} position="top-left">
|
||||||
|
<h3 className="break-words text-lg font-semibold">{truncateText(cycle.name, 70)}</h3>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 capitalize">
|
||||||
|
<span className="rounded-full px-1.5 py-0.5 bg-green-600/5 text-green-600">
|
||||||
|
<span className="flex gap-1 whitespace-nowrap">
|
||||||
|
<RunningIcon className="h-4 w-4" />
|
||||||
|
{findHowManyDaysLeft(cycle.end_date ?? new Date())} Days Left
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{cycle.is_favorite ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
handleRemoveFromFavorites(e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Star className="h-4 w-4 fill-orange-400 text-orange-400" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
handleAddToFavorites(e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Star className="h-4 w-4 " color="rgb(var(--color-text-200))" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-start gap-5 text-custom-text-200">
|
||||||
|
<div className="flex items-start gap-1">
|
||||||
|
<CalendarDays className="h-4 w-4" />
|
||||||
|
{cycle?.start_date && <span>{renderFormattedDate(cycle?.start_date)}</span>}
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="h-4 w-4 text-custom-text-200" />
|
||||||
|
<div className="flex items-start gap-1">
|
||||||
|
<Target className="h-4 w-4" />
|
||||||
|
{cycle?.end_date && <span>{renderFormattedDate(cycle?.end_date)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2.5 text-custom-text-200">
|
||||||
|
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
|
||||||
|
<img
|
||||||
|
src={cycle.owned_by.avatar}
|
||||||
|
height={16}
|
||||||
|
width={16}
|
||||||
|
className="rounded-full"
|
||||||
|
alt={cycle.owned_by.display_name}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-custom-background-100 capitalize">
|
||||||
|
{cycle.owned_by.display_name.charAt(0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-custom-text-200">{cycle.owned_by.display_name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cycle.assignees.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 text-custom-text-200">
|
||||||
|
<AvatarGroup>
|
||||||
|
{cycle.assignees.map((assignee: any) => (
|
||||||
|
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
|
||||||
|
))}
|
||||||
|
</AvatarGroup>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-custom-text-200">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<LayersIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
|
{cycle.total_issues} issues
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StateGroupIcon stateGroup="completed" height="14px" width="14px" />
|
||||||
|
{cycle.completed_issues} issues
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex item-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/${workspaceSlug}/projects/${projectId}/cycles`}
|
||||||
|
onClick={() => {
|
||||||
|
handleCurrentView("active");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="w-full rounded-md bg-custom-primary px-4 py-2 text-center text-sm font-medium text-white hover:bg-custom-primary/90">
|
||||||
|
View Cycle
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
|
||||||
|
<span className="w-full rounded-md bg-custom-primary px-4 py-2 text-center text-sm font-medium text-white hover:bg-custom-primary/90">
|
||||||
|
View Cycle Issues
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 grid grid-cols-1 divide-y border-custom-border-200 md:grid-cols-2 md:divide-x md:divide-y-0">
|
||||||
|
<div className="flex h-60 flex-col border-custom-border-200">
|
||||||
|
<div className="flex h-full w-full flex-col p-4 text-custom-text-200">
|
||||||
|
<div className="flex w-full items-center gap-2 py-1">
|
||||||
|
<span>Progress</span>
|
||||||
|
<LinearProgressIndicator data={progressIndicatorData} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-col items-center gap-1">
|
||||||
|
{Object.keys(groupedIssues).map((group, index) => (
|
||||||
|
<SingleProgressStats
|
||||||
|
key={index}
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="block h-3 w-3 rounded-full "
|
||||||
|
style={{
|
||||||
|
backgroundColor: STATE_GROUPS_DETAILS[index].color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-xs capitalize">{group}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
completed={groupedIssues[group]}
|
||||||
|
total={cycle.total_issues}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-60 overflow-y-scroll border-custom-border-200">
|
||||||
|
<ActiveCycleProgressStats cycle={cycle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -15,3 +15,4 @@ export * from "./cycles-board-card";
|
|||||||
export * from "./delete-modal";
|
export * from "./delete-modal";
|
||||||
export * from "./cycle-peek-overview";
|
export * from "./cycle-peek-overview";
|
||||||
export * from "./cycles-list-item";
|
export * from "./cycles-list-item";
|
||||||
|
export * from "./active-cycle-info";
|
||||||
|
@ -20,3 +20,4 @@ export * from "./project-archived-issue-details";
|
|||||||
export * from "./project-archived-issues";
|
export * from "./project-archived-issues";
|
||||||
export * from "./project-issue-details";
|
export * from "./project-issue-details";
|
||||||
export * from "./user-profile";
|
export * from "./user-profile";
|
||||||
|
export * from "./workspace-active-cycle";
|
||||||
|
37
web/components/headers/workspace-active-cycle.tsx
Normal file
37
web/components/headers/workspace-active-cycle.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Search, SendToBack } from "lucide-react";
|
||||||
|
// hooks
|
||||||
|
import { useWorkspace } from "hooks/store";
|
||||||
|
// ui
|
||||||
|
import { Breadcrumbs } from "@plane/ui";
|
||||||
|
|
||||||
|
export const WorkspaceActiveCycleHeader = observer(() => {
|
||||||
|
// store hooks
|
||||||
|
const { workspaceActiveCyclesSearchQuery, setWorkspaceActiveCyclesSearchQuery } = useWorkspace();
|
||||||
|
return (
|
||||||
|
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||||
|
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||||
|
<div>
|
||||||
|
<Breadcrumbs>
|
||||||
|
<Breadcrumbs.BreadcrumbItem
|
||||||
|
type="text"
|
||||||
|
icon={<SendToBack className="h-4 w-4 text-custom-text-300" />}
|
||||||
|
label="Active Cycles"
|
||||||
|
/>
|
||||||
|
</Breadcrumbs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex w-full items-center justify-start gap-1 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1.5 text-custom-text-400">
|
||||||
|
<Search className="h-3.5 w-3.5" />
|
||||||
|
<input
|
||||||
|
className="w-full min-w-[234px] border-none bg-transparent text-sm focus:outline-none"
|
||||||
|
value={workspaceActiveCyclesSearchQuery}
|
||||||
|
onChange={(e) => setWorkspaceActiveCyclesSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
@ -9,7 +9,7 @@ type Props = {
|
|||||||
|
|
||||||
export const ViewIssueLabel: React.FC<Props> = ({ labelDetails, maxRender = 1 }) => (
|
export const ViewIssueLabel: React.FC<Props> = ({ labelDetails, maxRender = 1 }) => (
|
||||||
<>
|
<>
|
||||||
{labelDetails.length > 0 ? (
|
{labelDetails?.length > 0 ? (
|
||||||
labelDetails.length <= maxRender ? (
|
labelDetails.length <= maxRender ? (
|
||||||
<>
|
<>
|
||||||
{labelDetails.map((label) => (
|
{labelDetails.map((label) => (
|
||||||
|
@ -13,3 +13,4 @@ export * from "./send-workspace-invitation-modal";
|
|||||||
export * from "./sidebar-dropdown";
|
export * from "./sidebar-dropdown";
|
||||||
export * from "./sidebar-menu";
|
export * from "./sidebar-menu";
|
||||||
export * from "./sidebar-quick-action";
|
export * from "./sidebar-quick-action";
|
||||||
|
export * from "./workspace-active-cycles-list";
|
||||||
|
@ -2,7 +2,7 @@ import React from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { BarChart2, Briefcase, CheckCircle, LayoutGrid } from "lucide-react";
|
import { BarChart2, Briefcase, CheckCircle, LayoutGrid, SendToBack } from "lucide-react";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication, useUser } from "hooks/store";
|
import { useApplication, useUser } from "hooks/store";
|
||||||
// components
|
// components
|
||||||
@ -33,6 +33,11 @@ const workspaceLinks = (workspaceSlug: string) => [
|
|||||||
name: "All Issues",
|
name: "All Issues",
|
||||||
href: `/${workspaceSlug}/workspace-views/all-issues`,
|
href: `/${workspaceSlug}/workspace-views/all-issues`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Icon: SendToBack,
|
||||||
|
name: "Active Cycles",
|
||||||
|
href: `/${workspaceSlug}/active-cycles`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const WorkspaceSidebarMenu = observer(() => {
|
export const WorkspaceSidebarMenu = observer(() => {
|
||||||
|
57
web/components/workspace/workspace-active-cycles-list.tsx
Normal file
57
web/components/workspace/workspace-active-cycles-list.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import useSWR from "swr";
|
||||||
|
// components
|
||||||
|
import { ActiveCycleInfo } from "components/cycles";
|
||||||
|
// services
|
||||||
|
import { CycleService } from "services/cycle.service";
|
||||||
|
const cycleService = new CycleService();
|
||||||
|
// hooks
|
||||||
|
import { useWorkspace } from "hooks/store";
|
||||||
|
// helpers
|
||||||
|
import { renderEmoji } from "helpers/emoji.helper";
|
||||||
|
|
||||||
|
export const WorkspaceActiveCyclesList = observer(() => {
|
||||||
|
// router
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug } = router.query;
|
||||||
|
// fetching active cycles in workspace
|
||||||
|
const { data } = useSWR("WORKSPACE_ACTIVE_CYCLES", () => cycleService.workspaceActiveCycles(workspaceSlug as string));
|
||||||
|
// store
|
||||||
|
const { workspaceActiveCyclesSearchQuery } = useWorkspace();
|
||||||
|
// filter cycles based on search query
|
||||||
|
const filteredCycles = data?.filter(
|
||||||
|
(cycle) =>
|
||||||
|
cycle.project_detail.name.toLowerCase().includes(workspaceActiveCyclesSearchQuery.toLowerCase()) ||
|
||||||
|
cycle.project_detail.identifier?.toLowerCase().includes(workspaceActiveCyclesSearchQuery.toLowerCase()) ||
|
||||||
|
cycle.name.toLowerCase().includes(workspaceActiveCyclesSearchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{workspaceSlug &&
|
||||||
|
filteredCycles &&
|
||||||
|
filteredCycles.map((cycle) => (
|
||||||
|
<div key={cycle.id} className="px-5 py-7">
|
||||||
|
<div className="flex items-center gap-1.5 px-3 py-1.5">
|
||||||
|
{cycle.project_detail.emoji ? (
|
||||||
|
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
|
||||||
|
{renderEmoji(cycle.project_detail.emoji)}
|
||||||
|
</span>
|
||||||
|
) : cycle.project_detail.icon_prop ? (
|
||||||
|
<div className="grid h-7 w-7 flex-shrink-0 place-items-center">
|
||||||
|
{renderEmoji(cycle.project_detail.icon_prop)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||||
|
{cycle.project_detail?.name.charAt(0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<h2 className="text-xl font-semibold">{cycle.project_detail.name}</h2>
|
||||||
|
</div>
|
||||||
|
<ActiveCycleInfo workspaceSlug={workspaceSlug?.toString()} projectId={cycle.project} cycle={cycle} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
@ -86,3 +86,32 @@ export const CYCLE_STATUS: {
|
|||||||
bgColor: "bg-custom-background-90",
|
bgColor: "bg-custom-background-90",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
export const STATE_GROUPS_DETAILS = [
|
||||||
|
{
|
||||||
|
key: "backlog_issues",
|
||||||
|
title: "Backlog",
|
||||||
|
color: "#dee2e6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "unstarted_issues",
|
||||||
|
title: "Unstarted",
|
||||||
|
color: "#26b5ce",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "started_issues",
|
||||||
|
title: "Started",
|
||||||
|
color: "#f7ae59",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "cancelled_issues",
|
||||||
|
title: "Cancelled",
|
||||||
|
color: "#d687ff",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "completed_issues",
|
||||||
|
title: "Completed",
|
||||||
|
color: "#09a953",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
16
web/pages/[workspaceSlug]/active-cycles.tsx
Normal file
16
web/pages/[workspaceSlug]/active-cycles.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { ReactElement } from "react";
|
||||||
|
// components
|
||||||
|
import { WorkspaceActiveCyclesList } from "components/workspace";
|
||||||
|
import { WorkspaceActiveCycleHeader } from "components/headers";
|
||||||
|
// layouts
|
||||||
|
import { AppLayout } from "layouts/app-layout";
|
||||||
|
// types
|
||||||
|
import { NextPageWithLayout } from "lib/types";
|
||||||
|
|
||||||
|
const WorkspaceActiveCyclesPage: NextPageWithLayout = () => <WorkspaceActiveCyclesList />;
|
||||||
|
|
||||||
|
WorkspaceActiveCyclesPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return <AppLayout header={<WorkspaceActiveCycleHeader />}>{page}</AppLayout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WorkspaceActiveCyclesPage;
|
@ -10,6 +10,14 @@ export class CycleService extends APIService {
|
|||||||
super(API_BASE_URL);
|
super(API_BASE_URL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async workspaceActiveCycles(workspaceSlug: string): Promise<ICycle[]> {
|
||||||
|
return this.get(`/api/workspaces/${workspaceSlug}/active-cycles/`)
|
||||||
|
.then((res) => res?.data)
|
||||||
|
.catch((err) => {
|
||||||
|
throw err?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async createCycle(workspaceSlug: string, projectId: string, data: any): Promise<ICycle> {
|
async createCycle(workspaceSlug: string, projectId: string, data: any): Promise<ICycle> {
|
||||||
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/`, data)
|
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/`, data)
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
|
@ -12,9 +12,12 @@ import { ApiTokenStore, IApiTokenStore } from "./api-token.store";
|
|||||||
export interface IWorkspaceRootStore {
|
export interface IWorkspaceRootStore {
|
||||||
// observables
|
// observables
|
||||||
workspaces: Record<string, IWorkspace>;
|
workspaces: Record<string, IWorkspace>;
|
||||||
|
workspaceActiveCyclesSearchQuery: string;
|
||||||
// computed
|
// computed
|
||||||
currentWorkspace: IWorkspace | null;
|
currentWorkspace: IWorkspace | null;
|
||||||
workspacesCreatedByCurrentUser: IWorkspace[] | null;
|
workspacesCreatedByCurrentUser: IWorkspace[] | null;
|
||||||
|
// actions
|
||||||
|
setWorkspaceActiveCyclesSearchQuery: (query: string) => void;
|
||||||
// computed actions
|
// computed actions
|
||||||
getWorkspaceBySlug: (workspaceSlug: string) => IWorkspace | null;
|
getWorkspaceBySlug: (workspaceSlug: string) => IWorkspace | null;
|
||||||
getWorkspaceById: (workspaceId: string) => IWorkspace | null;
|
getWorkspaceById: (workspaceId: string) => IWorkspace | null;
|
||||||
@ -31,6 +34,7 @@ export interface IWorkspaceRootStore {
|
|||||||
|
|
||||||
export class WorkspaceRootStore implements IWorkspaceRootStore {
|
export class WorkspaceRootStore implements IWorkspaceRootStore {
|
||||||
// observables
|
// observables
|
||||||
|
workspaceActiveCyclesSearchQuery: string = "";
|
||||||
workspaces: Record<string, IWorkspace> = {};
|
workspaces: Record<string, IWorkspace> = {};
|
||||||
// services
|
// services
|
||||||
workspaceService;
|
workspaceService;
|
||||||
@ -45,6 +49,7 @@ export class WorkspaceRootStore implements IWorkspaceRootStore {
|
|||||||
makeObservable(this, {
|
makeObservable(this, {
|
||||||
// observables
|
// observables
|
||||||
workspaces: observable,
|
workspaces: observable,
|
||||||
|
workspaceActiveCyclesSearchQuery: observable.ref,
|
||||||
// computed
|
// computed
|
||||||
currentWorkspace: computed,
|
currentWorkspace: computed,
|
||||||
workspacesCreatedByCurrentUser: computed,
|
workspacesCreatedByCurrentUser: computed,
|
||||||
@ -52,6 +57,7 @@ export class WorkspaceRootStore implements IWorkspaceRootStore {
|
|||||||
getWorkspaceBySlug: action,
|
getWorkspaceBySlug: action,
|
||||||
getWorkspaceById: action,
|
getWorkspaceById: action,
|
||||||
// actions
|
// actions
|
||||||
|
setWorkspaceActiveCyclesSearchQuery: action,
|
||||||
fetchWorkspaces: action,
|
fetchWorkspaces: action,
|
||||||
createWorkspace: action,
|
createWorkspace: action,
|
||||||
updateWorkspace: action,
|
updateWorkspace: action,
|
||||||
@ -102,6 +108,14 @@ export class WorkspaceRootStore implements IWorkspaceRootStore {
|
|||||||
*/
|
*/
|
||||||
getWorkspaceById = (workspaceId: string) => this.workspaces?.[workspaceId] || null; // TODO: use undefined instead of null
|
getWorkspaceById = (workspaceId: string) => this.workspaces?.[workspaceId] || null; // TODO: use undefined instead of null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets search query
|
||||||
|
* @param query
|
||||||
|
*/
|
||||||
|
setWorkspaceActiveCyclesSearchQuery = (query: string) => {
|
||||||
|
this.workspaceActiveCyclesSearchQuery = query;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* fetch user workspaces from API
|
* fetch user workspaces from API
|
||||||
*/
|
*/
|
||||||
|
Loading…
Reference in New Issue
Block a user