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,
|
||||
TransferCycleIssueEndpoint,
|
||||
CycleUserPropertiesEndpoint,
|
||||
ActiveCycleEndpoint
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/active-cycles/",
|
||||
ActiveCycleEndpoint.as_view(),
|
||||
name="workspace-active-cycle",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/",
|
||||
CycleViewSet.as_view(
|
||||
|
@ -62,6 +62,7 @@ from .cycle import (
|
||||
CycleFavoriteViewSet,
|
||||
TransferCycleIssueEndpoint,
|
||||
CycleUserPropertiesEndpoint,
|
||||
ActiveCycleEndpoint,
|
||||
)
|
||||
from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
|
||||
from .issue import (
|
||||
|
@ -39,6 +39,7 @@ from plane.app.serializers import (
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
ProjectLitePermission,
|
||||
WorkspaceUserPermission
|
||||
)
|
||||
from plane.db.models import (
|
||||
User,
|
||||
@ -909,3 +910,235 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
|
||||
)
|
||||
serializer = CycleUserPropertiesSerializer(cycle_properties)
|
||||
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";
|
||||
|
||||
@ -40,6 +47,7 @@ export interface ICycle {
|
||||
};
|
||||
workspace: string;
|
||||
workspace_detail: IWorkspaceLite;
|
||||
issues?: TIssue[];
|
||||
}
|
||||
|
||||
export type TAssigneesDistribution = {
|
||||
@ -80,9 +88,13 @@ export interface CycleIssueResponse {
|
||||
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 = {
|
||||
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 type { IUser, IUserLite, IWorkspace, IWorkspaceLite, TStateGroups } from ".";
|
||||
import type {
|
||||
IUser,
|
||||
IUserLite,
|
||||
IWorkspace,
|
||||
IWorkspaceLite,
|
||||
TStateGroups,
|
||||
} from ".";
|
||||
|
||||
export interface IProject {
|
||||
archive_in: number;
|
||||
@ -52,6 +58,11 @@ export interface IProjectLite {
|
||||
id: string;
|
||||
name: string;
|
||||
identifier: string;
|
||||
emoji: string | null;
|
||||
icon_prop: {
|
||||
name: string;
|
||||
color: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
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 "./cycle-peek-overview";
|
||||
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-issue-details";
|
||||
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 }) => (
|
||||
<>
|
||||
{labelDetails.length > 0 ? (
|
||||
{labelDetails?.length > 0 ? (
|
||||
labelDetails.length <= maxRender ? (
|
||||
<>
|
||||
{labelDetails.map((label) => (
|
||||
|
@ -13,3 +13,4 @@ export * from "./send-workspace-invitation-modal";
|
||||
export * from "./sidebar-dropdown";
|
||||
export * from "./sidebar-menu";
|
||||
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 { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { BarChart2, Briefcase, CheckCircle, LayoutGrid } from "lucide-react";
|
||||
import { BarChart2, Briefcase, CheckCircle, LayoutGrid, SendToBack } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication, useUser } from "hooks/store";
|
||||
// components
|
||||
@ -33,6 +33,11 @@ const workspaceLinks = (workspaceSlug: string) => [
|
||||
name: "All Issues",
|
||||
href: `/${workspaceSlug}/workspace-views/all-issues`,
|
||||
},
|
||||
{
|
||||
Icon: SendToBack,
|
||||
name: "Active Cycles",
|
||||
href: `/${workspaceSlug}/active-cycles`,
|
||||
},
|
||||
];
|
||||
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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> {
|
||||
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/`, data)
|
||||
.then((response) => response?.data)
|
||||
|
@ -12,9 +12,12 @@ import { ApiTokenStore, IApiTokenStore } from "./api-token.store";
|
||||
export interface IWorkspaceRootStore {
|
||||
// observables
|
||||
workspaces: Record<string, IWorkspace>;
|
||||
workspaceActiveCyclesSearchQuery: string;
|
||||
// computed
|
||||
currentWorkspace: IWorkspace | null;
|
||||
workspacesCreatedByCurrentUser: IWorkspace[] | null;
|
||||
// actions
|
||||
setWorkspaceActiveCyclesSearchQuery: (query: string) => void;
|
||||
// computed actions
|
||||
getWorkspaceBySlug: (workspaceSlug: string) => IWorkspace | null;
|
||||
getWorkspaceById: (workspaceId: string) => IWorkspace | null;
|
||||
@ -31,6 +34,7 @@ export interface IWorkspaceRootStore {
|
||||
|
||||
export class WorkspaceRootStore implements IWorkspaceRootStore {
|
||||
// observables
|
||||
workspaceActiveCyclesSearchQuery: string = "";
|
||||
workspaces: Record<string, IWorkspace> = {};
|
||||
// services
|
||||
workspaceService;
|
||||
@ -45,6 +49,7 @@ export class WorkspaceRootStore implements IWorkspaceRootStore {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
workspaces: observable,
|
||||
workspaceActiveCyclesSearchQuery: observable.ref,
|
||||
// computed
|
||||
currentWorkspace: computed,
|
||||
workspacesCreatedByCurrentUser: computed,
|
||||
@ -52,6 +57,7 @@ export class WorkspaceRootStore implements IWorkspaceRootStore {
|
||||
getWorkspaceBySlug: action,
|
||||
getWorkspaceById: action,
|
||||
// actions
|
||||
setWorkspaceActiveCyclesSearchQuery: action,
|
||||
fetchWorkspaces: action,
|
||||
createWorkspace: 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
|
||||
|
||||
/**
|
||||
* Sets search query
|
||||
* @param query
|
||||
*/
|
||||
setWorkspaceActiveCyclesSearchQuery = (query: string) => {
|
||||
this.workspaceActiveCyclesSearchQuery = query;
|
||||
};
|
||||
|
||||
/**
|
||||
* fetch user workspaces from API
|
||||
*/
|
||||
|
Loading…
Reference in New Issue
Block a user